diff --git a/pubspec.yaml b/pubspec.yaml index 63e0c82b..4d2ed61a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide # Pre-push line-coverage floor. Ratchets up only — see D-66. # Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`. -coverage_floor: 83 +coverage_floor: 86 # Project metadata (was project.yaml, folded in per D-056). # version: above is the single source of truth. The Makefile reads diff --git a/test/widgets/zero_coverage_widgets_test.dart b/test/widgets/zero_coverage_widgets_test.dart new file mode 100644 index 00000000..7a92bbef --- /dev/null +++ b/test/widgets/zero_coverage_widgets_test.dart @@ -0,0 +1,256 @@ +/// Smoke + branch tests for the previously-uncovered widgets under +/// `lib/widgets/src/`: ClidePalette, ClideFilterBox, ColumnHat, +/// ClideIconRail, ClideSpine, ClideResizeBorder. +library; + +import 'package:clide/clide.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/widgets/src/clide_column_hat.dart'; +import 'package:clide/widgets/src/clide_filter_box.dart'; +import 'package:clide/widgets/src/clide_icon_rail.dart'; +import 'package:clide/widgets/src/clide_palette.dart'; +import 'package:clide/widgets/src/clide_resize_border.dart'; +import 'package:clide/widgets/src/clide_spine.dart'; +import 'package:clide/widgets/src/icons/phosphor.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +void main() { + group('ClidePalette', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('renders nothing when the palette is closed', (tester) async { + await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()]))); + expect(find.byType(EditableText), findsNothing); + }); + + testWidgets('opens to show an input field + the registered commands', (tester) async { + f.services.commands.register(CommandContribution( + id: 'c1', + command: 'cmd.one', + title: 'Command One', + defaultBinding: 'ctrl+1', + run: (_) async => IpcResponse.ok(id: '', data: const {}), + )); + f.services.commands.register(CommandContribution( + id: 'c2', + command: 'cmd.two', + title: 'Command Two', + run: (_) async => IpcResponse.ok(id: '', data: const {}), + )); + f.services.palette.open(); + await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()]))); + await tester.pumpAndSettle(); + expect(find.byType(EditableText), findsOneWidget); + expect(find.text('Command One'), findsOneWidget); + expect(find.text('Command Two'), findsOneWidget); + }); + + testWidgets('tapping a row invokes the command + closes the palette', (tester) async { + var invocations = 0; + f.services.commands.register(CommandContribution( + id: 'c1', + command: 'tap.target', + title: 'Tap Target', + run: (_) async { + invocations++; + return IpcResponse.ok(id: '', data: const {}); + }, + )); + f.services.palette.open(); + await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()]))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Tap Target')); + await tester.pumpAndSettle(); + expect(invocations, 1); + }); + }); + + group('ClideFilterBox', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('debounces onChanged calls to the configured duration', (tester) async { + final values = []; + await tester.pumpWidget(harness( + f, + ClideFilterBox( + onChanged: values.add, + debounce: const Duration(milliseconds: 50), + ), + )); + // Type into the editable text field. + await tester.enterText(find.byType(EditableText), 'abc'); + // Before the debounce elapses no call. + await tester.pump(const Duration(milliseconds: 10)); + expect(values, isEmpty); + // After it fires we get exactly one call with the final value. + await tester.pump(const Duration(milliseconds: 100)); + expect(values, ['abc']); + }); + + testWidgets('clear button appears once the field has text and resets onChanged when tapped', (tester) async { + final values = []; + await tester.pumpWidget(harness( + f, + ClideFilterBox( + onChanged: values.add, + debounce: const Duration(milliseconds: 10), + ), + )); + // No clear icon when the field is empty. + expect(find.byType(GestureDetector), findsNothing); + await tester.enterText(find.byType(EditableText), 'foo'); + await tester.pump(const Duration(milliseconds: 50)); + // The clear button is the only GestureDetector wrapped around the + // trailing icon when there's text. + expect(find.byType(GestureDetector), findsOneWidget); + await tester.tap(find.byType(GestureDetector)); + await tester.pumpAndSettle(); + expect(values.last, isEmpty); + }); + + testWidgets('onSubmitted fires when the field is submitted', (tester) async { + String? submitted; + await tester.pumpWidget(harness( + f, + ClideFilterBox( + onChanged: (_) {}, + onSubmitted: (v) => submitted = v, + ), + )); + await tester.enterText(find.byType(EditableText), 'submit-me'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(submitted, 'submit-me'); + }); + }); + + group('ColumnHat', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('left / center / right factories render', (tester) async { + final wc = WindowControls(); + addTearDown(wc.dispose); + await tester.pumpWidget(harness( + f, + Column(children: [ + SizedBox(width: 200, child: ColumnHat.left(windowControls: wc)), + SizedBox(width: 200, child: ColumnHat.center(windowControls: wc, project: 'clide', branch: 'main')), + SizedBox(width: 200, child: ColumnHat.right(windowControls: wc)), + ]), + )); + // Center hat renders the joined label. + expect(find.text('clide > main'), findsOneWidget); + }); + + testWidgets('center hat falls back to "clide" with no project/branch', (tester) async { + final wc = WindowControls(); + addTearDown(wc.dispose); + await tester.pumpWidget(harness( + f, + SizedBox(width: 200, child: ColumnHat.center(windowControls: wc)), + )); + expect(find.text('clide'), findsOneWidget); + }); + }); + + group('ClideIconRail', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('renders one button per item, marks the active one, fires onSelect', (tester) async { + String? selected; + await tester.pumpWidget(harness( + f, + ClideIconRail( + items: const [ + ClideIconRailItem(id: 'a', icon: PhosphorIcons.folder, tooltip: 'A'), + ClideIconRailItem(id: 'b', icon: PhosphorIcons.gitBranch, tooltip: 'B'), + ], + activeId: 'a', + onSelect: (id) => selected = id, + ), + )); + await tester.pumpAndSettle(); + // Each item exposes a tooltip-labeled semantics button. + expect(find.bySemanticsLabel('A'), findsOneWidget); + expect(find.bySemanticsLabel('B'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('B')); + expect(selected, 'b'); + }); + }); + + group('ClideSpine', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('renders the rotated label + a badge dot when badgeCount > 0', (tester) async { + var taps = 0; + await tester.pumpWidget(harness( + f, + SizedBox( + height: 200, + width: ClideSpine.width, + child: ClideSpine( + label: 'SIDEBAR', + badgeCount: 3, + onExpand: () => taps++, + )), + )); + await tester.pumpAndSettle(); + expect(find.text('SIDEBAR'), findsOneWidget); + // Tap to expand. + await tester.tap(find.byType(ClideSpine)); + expect(taps, 1); + }); + + testWidgets('renders the right-side variant without throwing', (tester) async { + await tester.pumpWidget(harness( + f, + SizedBox( + height: 200, + width: ClideSpine.width, + child: ClideSpine( + label: 'CTX', + side: SpineSide.right, + onExpand: () {}, + )), + )); + await tester.pumpAndSettle(); + expect(find.text('CTX'), findsOneWidget); + }); + }); + + group('ClideResizeBorder', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('wraps a child and renders 8 resize zones', (tester) async { + final wc = WindowControls(); + addTearDown(wc.dispose); + await tester.pumpWidget(harness( + f, + ClideResizeBorder( + windowControls: wc, + child: const SizedBox.expand(child: ColoredBox(color: Color(0xFF000000))), + ), + )); + await tester.pumpAndSettle(); + // 4 corners + 4 edges = 8 zones each with a MouseRegion. + expect(find.byType(MouseRegion).evaluate().length, greaterThanOrEqualTo(8)); + }); + }); +}