Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce tapAt to act #80

Merged
merged 13 commits into from
Nov 28, 2024
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