Skip to content

Commit

Permalink
Introduce tapAt to act (#80)
Browse files Browse the repository at this point in the history
Co-authored-by: Pascal Welsch <pascal@phntm.xyz>
  • Loading branch information
robiness and passsy authored Nov 28, 2024
1 parent 564b2f2 commit cfef3b3
Show file tree
Hide file tree
Showing 3 changed files with 247 additions and 40 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions lib/src/act/act.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> tapAt(Offset position) async {
return TestAsyncUtils.guard<void>(() 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`.
Expand Down Expand Up @@ -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, {
Expand Down
207 changes: 167 additions & 40 deletions test/act/act_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextFormField>(), '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<TextField>(), '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<MaterialApp>();
app.existsOnce().hasWidgetProp(
prop: widgetProp('color', (w) => w.color),
match: (it) => it.equals(Colors.blue),
);
final button = spot<ElevatedButton>();

// 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<TextField>(), '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<FlutterError>().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<Text>(), '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<TextFormField>(), '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<TextField>(), '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<TextField>(), '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<Text>(), 'hello'),
throwsSpotErrorContaining([
"Widget 'Text' is not a descendant of EditableText.",
]),
);
});
});
}
Expand Down

0 comments on commit cfef3b3

Please sign in to comment.