diff --git a/README.md b/README.md index d45f238a..f573a554 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ visualizes the steps of a widget test as HTML report with automatic screenshots, - [Find offstage widgets](#find-offstage-widgets) - [act - tap, drag, type](#act-tap-drag-type-click) - [tap](#tap) + - [tapAt](#tapAt) - [enterText](#entertext) - [dragUntilVisible](#draguntilvisible) - [more act functions](#more-act-functions) @@ -528,6 +529,18 @@ Tapping a widget looks almost identical to the `WidgetTester` API but with a few - pumps automatically after the tap - When multiple widgets are found, it prints a useful error message +### tapAt + +```dart +await act.tapAt(const Offset(100, 100)); +``` + +Taps the screen (down + up) at `position` on the global coordinate system and pumps a frame. + +- Checks that `position` is within the window viewport +- Lists all widgets reacting to the hitTest in the timeline +- pumps automatically after the tap + ### enterText ```dart diff --git a/lib/src/act/act.dart b/lib/src/act/act.dart index bbd64461..f17f7771 100644 --- a/lib/src/act/act.dart +++ b/lib/src/act/act.dart @@ -143,6 +143,61 @@ class Act { }); } + /// Taps the screen (down + up) at [position] on the global coordinate system + /// and pumps a frame. + /// + /// - Checks that [position] is within the viewport. + /// - Lists all widgets at that position in the timeline + Future tapAt(Offset position) async { + return TestAsyncUtils.guard(() async { + return _alwaysPropagateDevicePointerEvents(() async { + final binding = TestWidgetsFlutterBinding.instance; + _validatePositionInViewBounds(position); + if (timeline.mode != TimelineMode.off) { + final screenshot = timeline.takeScreenshotSync( + annotators: [ + CrosshairAnnotator(centerPosition: position), + ], + ); + final HitTestResult result = HitTestResult(); + // ignore: deprecated_member_use + binding.hitTest(result, position); + final hits = result.path.map((e) => e.element).toList(); + + final widgetInProject = hits.mapNotNull((e) { + if (e == null) return null; + final debugWidgetLocation = e.debugWidgetLocation; + if (debugWidgetLocation == null || + debugWidgetLocation.isUserCode == false) { + return null; + } + return "${e.widget.toStringShort()} at ${debugWidgetLocation.file.path}"; + }).joinToString(prefix: '\n- '); + + final allWidgets = hits.mapNotNull((e) { + if (e == null) return null; + return "${e.widget.toStringShort()} at ${e.debugWidgetLocation?.file.path}"; + }).joinToString(prefix: '\n- '); + + timeline.addEvent( + eventType: 'TapAt Event', + details: 'TapAt $position.\n' + 'Relevant widgets at position: $widgetInProject' + '\n\n' + 'Widgets at position: $allWidgets', + screenshot: screenshot, + color: Colors.blue, + ); + } + final downEvent = PointerDownEvent(position: position); + binding.handlePointerEvent(downEvent); + final upEvent = PointerUpEvent(position: position); + binding.handlePointerEvent(upEvent); + await binding.pump(); + }); + }); + } + /// Repeatedly drags at the position of `dragStart` by `moveStep` until `dragTarget` is visible. /// /// Between each drag, advances the clock by `duration`. @@ -312,6 +367,18 @@ class Act { return element?.renderObject; } + void _validatePositionInViewBounds(Offset position) { + // ignore: deprecated_member_use + final view = WidgetsBinding.instance.renderView; + final Rect viewport = Offset.zero & view.size; + final isInViewport = viewport.contains(position); + if (!isInViewport) { + throw TestFailure( + "Tried to tapAt position ($position) which is outside the viewport (${view.size}).", + ); + } + } + // Validates that the widget is at least partially visible in the viewport. bool _validateViewBounds( RenderBox renderBox, { diff --git a/test/act/act_test.dart b/test/act/act_test.dart index cd99bb0b..fe88f17a 100644 --- a/test/act/act_test.dart +++ b/test/act/act_test.dart @@ -380,52 +380,179 @@ void actTests() { ]), ); }); + }); - group('enter text', () { - testWidgets('enter text in text form field', (tester) async { - await tester.pumpWidget( - MaterialApp(home: Material(child: Form(child: TextFormField()))), - ); - await act.enterText(spot(), 'hello'); - spotText('hello').existsOnce(); - }); + group('tapAt', () { + testWidgets('tapAt', (tester) async { + Offset? tapPosition; + await tester.pumpWidget( + MaterialApp( + home: GestureDetector( + onTapDown: (details) => tapPosition = details.globalPosition, + child: const ColoredBox(color: Colors.blue), + ), + ), + ); + await act.tapAt(const Offset(100, 100)); + expect(tapPosition, const Offset(100, 100)); + }); - testWidgets('enter text in text field', (tester) async { - await tester - .pumpWidget(const MaterialApp(home: Material(child: TextField()))); - await act.enterText(spot(), 'hello'); - spotText('hello').existsOnce(); - }); + testWidgets('tapAt pumps a new frame', (tester) async { + await tester.pumpWidget(const ColorToggleApp()); - testWidgets('spot a non existing widget throws an error', (tester) async { - await tester.pumpWidget( - const Directionality( - textDirection: TextDirection.ltr, - child: Text("any text"), + final app = spot(); + app.existsOnce().hasWidgetProp( + prop: widgetProp('color', (w) => w.color), + match: (it) => it.equals(Colors.blue), + ); + final button = spot(); + + // Get the RenderBox of the button + final renderBox = button.snapshotRenderBox(); + + // Calculate the center of the button + final center = renderBox.localToGlobal( + Offset(renderBox.size.width / 2, renderBox.size.height / 2), + ); + await act.tapAt(center); + app.existsOnce().hasWidgetProp( + prop: widgetProp('color', (w) => w.color), + match: (it) => it.equals(Colors.red), + ); + }); + + testWidgets('tapAt must be awaited', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ElevatedButton( + onPressed: () {}, + child: const Text('Home'), + ), ), - ); - await expectLater( - () => act.enterText(spot(), 'hello'), - throwsSpotErrorContaining([ - "Could not find TextField in widget tree", - ]), - ); - }); + ), + ); + final future = act.tapAt(Offset.zero); - testWidgets('spot a non editable text throws an error', (tester) async { - await tester.pumpWidget( - const Directionality( - textDirection: TextDirection.ltr, - child: Text("any text"), + try { + TestAsyncUtils.guardSync(); + fail('Expected to throw'); + } catch (e) { + check(e).isA().has((it) => it.message, 'message') + ..contains( + 'You must use "await" with all Future-returning test APIs.', + ) + ..contains( + 'The guarded method "tapAt" from class Act was called from', + ) + ..contains('act_test.dart'); + } + await future; + }); + testWidgets('tapAt shows items in the timeline', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: Colors.blue, key: ValueKey(1)), + ColoredBox(color: Colors.red, key: ValueKey(2)), + ColoredBox(color: Colors.green, key: ValueKey(3)), + ], ), - ); - await expectLater( - () => act.enterText(spot(), 'hello'), - throwsSpotErrorContaining([ - "Widget 'Text' is not a descendant of EditableText.", - ]), - ); - }); + ), + ); + // tap + await act.tapAt(const Offset(100, 100)); + final event = timeline.events.last; + expect(event.eventType.label, 'TapAt Event'); + expect( + event.details, + stringContainsInOrder([ + 'Relevant widgets at position: ', + 'ColoredBox-[<3>]', + 'Stack', + 'Widgets at position:', + 'ColoredBox-[<3>]', + 'Stack', + '_Theater', + ]), + ); + expect(event.details, isNot(contains('ColoredBox-[<1>]'))); + expect(event.details, isNot(contains('ColoredBox-[<2>]'))); + }); + + testWidgets('tapAt throws if position not in view (lower bounds)', + (tester) async { + await tester.pumpWidget(const MaterialApp()); + + await expectLater( + () => act.tapAt(const Offset(-100, -100)), + throwsSpotErrorContaining([ + "Tried to tapAt position (Offset(-100.0, -100.0)) which is outside the viewport ", + ]), + ); + }); + testWidgets('tapAt throws if position not in view (upper bounds)', + (tester) async { + await tester.pumpWidget(const MaterialApp()); + + // ignore: deprecated_member_use + final viewSize = tester.binding.renderView.size; + final outOutside = viewSize.bottomRight(const Offset(100, 100)); + await expectLater( + () => act.tapAt(outOutside), + throwsSpotErrorContaining([ + "Tried to tapAt position ($outOutside) which is outside the viewport ", + ]), + ); + }); + }); + + group('enter text', () { + testWidgets('enter text in text form field', (tester) async { + await tester.pumpWidget( + MaterialApp(home: Material(child: Form(child: TextFormField()))), + ); + await act.enterText(spot(), 'hello'); + spotText('hello').existsOnce(); + }); + + testWidgets('enter text in text field', (tester) async { + await tester + .pumpWidget(const MaterialApp(home: Material(child: TextField()))); + await act.enterText(spot(), 'hello'); + spotText('hello').existsOnce(); + }); + + testWidgets('spot a non existing widget throws an error', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Text("any text"), + ), + ); + await expectLater( + () => act.enterText(spot(), 'hello'), + throwsSpotErrorContaining([ + "Could not find TextField in widget tree", + ]), + ); + }); + + testWidgets('spot a non editable text throws an error', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Text("any text"), + ), + ); + await expectLater( + () => act.enterText(spot(), 'hello'), + throwsSpotErrorContaining([ + "Widget 'Text' is not a descendant of EditableText.", + ]), + ); }); }); }