Files
PilotVault/Fly App/test/fly/vision_modes_test.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:06:16 +02:00

448 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/flight_model.dart';
import 'package:pilotvault_fly/fly/action_rail_state.dart';
import 'package:pilotvault_fly/fly/fly_prefs.dart';
import 'package:pilotvault_fly/fly/modes/vision_modes.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
import 'package:pilotvault_fly/fly/safety_gate.dart';
import 'package:pilotvault_fly/fly/state/aircraft_state.dart';
import 'package:pilotvault_fly/fly/state/mission_state.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart';
import 'package:pilotvault_fly/ui/fly/fly_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
Map<String, dynamic> trackEvent(String state, {Map<String, Object?>? tracking, String? error}) => <String, dynamic>{
'type': 'mission',
'operator': 'activeTrack',
'state': state,
if (tracking != null) 'tracking': tracking,
if (error != null) 'error': error,
};
Map<String, dynamic> tapFlyEvent(String state, {Map<String, Object?>? execution}) => <String, dynamic>{
'type': 'mission',
'operator': 'tapFly',
'state': state,
if (execution != null) 'execution': execution,
};
const Map<String, Object?> box = <String, Object?>{'l': 0.4, 't': 0.3, 'r': 0.6, 'b': 0.7};
FlightModel modelWith(Map<String, dynamic> tick, {Map<String, dynamic>? profile}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile ?? phantom4);
TelemetryReducer(m)
..apply(tick)
..apply(trackEvent('IDLE'))
..apply(tapFlyEvent('IDLE'));
return m;
}
Map<String, dynamic> grounded() => telemetryTick()
..['isFlying'] = false
..['areMotorsOn'] = false;
void main() {
group('VisionGeometry', () {
const Rect view = Rect.fromLTWH(0, 0, 800, 400);
test('the picture is aspect-fitted and centred; unknown size fills the view', () {
// 16:9 feed on a 19.5:9 phone: bars left and right.
final Rect wide = VisionGeometry.contentRect(const Size(844, 390), const Size(1920, 1080));
expect(wide.height, 390);
expect(wide.width, closeTo(693.33, 0.01));
expect(wide.left, closeTo((844 - 693.33) / 2, 0.01));
// 4:3 feed on a 16:10 tablet: bars left and right too.
expect(VisionGeometry.contentRect(const Size(1280, 800), const Size(1440, 1080)).width, closeTo(1066.67, 0.01));
// A feed wider than the view: bars top and bottom.
final Rect tall = VisionGeometry.contentRect(const Size(400, 400), const Size(1920, 1080));
expect(tall.width, 400);
expect(tall.top, closeTo(87.5, 0.01));
expect(VisionGeometry.contentRect(const Size(844, 390), null), const Rect.fromLTWH(0, 0, 844, 390));
});
test('a drag becomes a 0..1 box whichever way it was drawn, clipped to the picture', () {
final Rect r = VisionGeometry.dragBox(const Offset(600, 300), const Offset(200, -50), view)!;
expect(r, const Rect.fromLTRB(0.25, 0, 0.75, 0.75));
});
test('a small drag, or one on a bar, is no box', () {
expect(VisionGeometry.dragBox(const Offset(100, 100), const Offset(110, 200), view), isNull);
const Rect content = Rect.fromLTWH(100, 0, 600, 400);
expect(VisionGeometry.dragBox(const Offset(0, 50), const Offset(90, 200), content), isNull);
});
test('taps are relative to the picture, and nothing on a bar', () {
const Rect content = Rect.fromLTWH(100, 0, 600, 400);
expect(VisionGeometry.normalise(const Offset(400, 200), content), const Offset(0.5, 0.5));
expect(VisionGeometry.normalise(const Offset(50, 200), content), isNull);
expect(VisionGeometry.tapBoxAt(const Offset(750, 200), content), isNull);
expect(VisionGeometry.pointToScreen(const Offset(0.5, 0.5), content), const Offset(400, 200));
expect(VisionGeometry.toScreen(const Rect.fromLTRB(0, 0, 1, 1), content), content);
});
test('a tap makes a person-sized box, kept inside the picture', () {
final Rect mid = VisionGeometry.tapBoxAt(const Offset(400, 200), view)!;
expect(mid.center, const Offset(0.5, 0.5));
expect(mid.width, closeTo(VisionGeometry.tapBox.width, 1e-9));
expect(mid.height, closeTo(VisionGeometry.tapBox.height, 1e-9));
final Rect corner = VisionGeometry.tapBoxAt(const Offset(1, 1), view)!;
expect(corner.left, closeTo(0, 1e-9));
expect(corner.top, closeTo(0, 1e-9));
});
});
group('MissionState (vision operators)', () {
test('ActiveTrack: selecting, confirming, following, losing, done', () {
final MissionState s = MissionState()..apply(trackEvent('WAITING_FOR_CONFIRMATION', tracking: <String, Object?>{
'targetState': 'WAITING_FOR_CONFIRMATION',
'targetType': 'HUMAN',
'rect': box,
}));
expect(s.activeTrack.waitingForConfirmation, isTrue);
expect(s.activeTrack.rect, const Rect.fromLTRB(0.4, 0.3, 0.6, 0.7));
expect(s.state, 'tracking');
expect(s.tracking, isTrue);
s.apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: <String, Object?>{'targetState': 'TRACKING_WITH_LOW_CONFIDENCE', 'rect': box}));
expect(s.activeTrack.following, isTrue);
expect(s.activeTrack.lowConfidence, isTrue);
s.apply(trackEvent('FINDING_TRACKED_TARGET'));
expect(s.activeTrack.rect, isNotNull, reason: 'a state-only event keeps the box while tracking');
s.apply(trackEvent('CANNOT_CONFIRM', tracking: <String, Object?>{'targetState': 'CANNOT_CONFIRM', 'reason': 'TARGET_TOO_CLOSE', 'rect': box}));
expect(s.activeTrack.cannotConfirm, isTrue);
expect(s.activeTrack.reasonLabel, 'Subject too close');
s.apply(trackEvent('IDLE'));
expect(s.activeTrack.active, isFalse);
expect(s.activeTrack.rect, isNull);
expect(s.state, 'idle');
});
test('a malformed rect is ignored', () {
final MissionState s = MissionState()
..apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: <String, Object?>{'rect': <String, Object?>{'l': 0.6, 't': 0.3, 'r': 0.4, 'b': 0.7}}));
expect(s.activeTrack.rect, isNull);
});
test('the operator replaces the old QuickShot guess', () {
final MissionState s = MissionState()..apply(<String, dynamic>{'type': 'mission', 'state': 'quickshot'});
expect(s.tracking, isTrue);
s.apply(trackEvent('IDLE'));
expect(s.tracking, isFalse);
});
test('TapFly: flying with an image point and a bypass, then idle', () {
final MissionState s = MissionState()
..apply(tapFlyEvent('EXECUTING', execution: <String, Object?>{'imageX': 0.3, 'imageY': 0.4, 'bypass': 'LEFT', 'speed': 4.0}));
expect(s.tapFly.active, isTrue);
expect(s.tapFly.image, const Offset(0.3, 0.4));
expect(s.tapFly.bypassLabel, contains('left'));
expect(s.state, 'tapfly');
final AircraftState a = AircraftState()..apply(telemetryTick());
expect(RailState.of(connected: true, a: a, mission: s).mode, ActiveMode.tapFly);
s.apply(tapFlyEvent('IDLE'));
expect(s.tapFly.active, isFalse);
expect(s.tapFly.image, isNull);
});
});
group('SafetyGate (vision modes)', () {
List<String> blockers(List<GateCheck> c) => <String>[for (final GateCheck g in c) if (g.blocks) g.message];
List<String> warnings(List<GateCheck> c) => <String>[for (final GateCheck g in c) if (!g.blocks) g.message];
SafetyGate gate(FlightModel m, [FakeDjiService? dji]) => SafetyGate(model: m, dji: dji ?? FakeDjiService());
test('airborne in P mode with a good battery: ActiveTrack and TapFly may start', () {
final FlightModel m = modelWith(telemetryTick());
expect(blockers(gate(m).checkActiveTrack(TrackMode.trace)), isEmpty);
expect(blockers(gate(m).checkTapFly(TapFlyDirection.forward)), isEmpty);
});
test('on the ground, low, in Sport, going home or during a waypoint mission they refuse', () {
expect(blockers(gate(modelWith(grounded())).checkActiveTrack(TrackMode.trace)), <String>['Take off first.']);
expect(blockers(gate(modelWith(telemetryTick(altitude: 1))).checkTapFly(TapFlyDirection.forward)), contains('Climb above 2 m first.'));
expect(blockers(gate(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_SPORT')).checkTapFly(TapFlyDirection.forward)),
contains('Switch the remote to P mode first.'));
expect(blockers(gate(modelWith(telemetryTick()..['isGoingHome'] = true)).checkActiveTrack(TrackMode.trace)), contains('Cancel Return to Home first.'));
final FlightModel wp = modelWith(telemetryTick());
wp.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'waypoint', 'state': 'EXECUTING'});
expect(blockers(gate(wp).checkActiveTrack(TrackMode.trace)), contains('Stop the waypoint mission first.'));
});
test('one vision mode at a time', () {
final FlightModel m = modelWith(telemetryTick());
m.mission.apply(tapFlyEvent('EXECUTING'));
expect(blockers(gate(m).checkActiveTrack(TrackMode.trace)), contains('Stop TapFly first.'));
m.mission.apply(tapFlyEvent('IDLE'));
m.mission.apply(trackEvent('AIRCRAFT_FOLLOWING'));
expect(blockers(gate(m).checkTapFly(TapFlyDirection.forward)), contains('Stop ActiveTrack first.'));
});
test('TapFly Backward and Free need rear sensors', () {
final FlightModel p4 = modelWith(telemetryTick()); // Phantom 4: front only
expect(gate(p4).tapFlyDirections(), <TapFlyDirection>[TapFlyDirection.forward]);
expect(blockers(gate(p4).checkTapFly(TapFlyDirection.backward)).single, contains('not available'));
});
test('Free warns that it does not avoid obstacles', () {
final FlightModel m = FlightModel()..connected = true;
TelemetryReducer(m).apply(telemetryTick());
expect(warnings(gate(m).checkTapFly(TapFlyDirection.free)).single, contains('Free does not avoid'));
});
test('Profile warns about its blind side', () {
expect(warnings(gate(modelWith(telemetryTick())).checkActiveTrack(TrackMode.profile)).single, contains('sideways'));
});
test('start sends the clipped box / point, and refuses without touching the aircraft', () async {
final FakeDjiService dji = FakeDjiService();
await gate(modelWith(telemetryTick()), dji).startActiveTrack(const Rect.fromLTRB(0.2, -0.1, 0.4, 0.5), TrackMode.spotlight);
await gate(modelWith(telemetryTick()), dji).startTapFly(const Offset(0.7, 0.4), TapFlyDirection.forward, speed: 30);
expect(dji.calls, <String>['startActiveTrack(0.20, 0.00, 0.20, 0.50, SPOTLIGHT)', 'startTapFly(0.70, 0.40, FORWARD, 10, true)']);
final FakeDjiService none = FakeDjiService();
await expectLater(gate(modelWith(grounded()), none).startTapFly(const Offset(0.5, 0.5), TapFlyDirection.forward), throwsA(isA<MissionRefused>()));
expect(none.calls, isEmpty);
});
test('confirm only answers a question that was asked; Stop reaches TapFly', () async {
final FakeDjiService dji = FakeDjiService();
final FlightModel m = modelWith(telemetryTick());
await gate(m, dji).confirmActiveTrack(yes: true);
expect(dji.calls, isEmpty);
m.mission.apply(trackEvent('WAITING_FOR_CONFIRMATION'));
await gate(m, dji).confirmActiveTrack(yes: true);
await gate(m, dji).confirmActiveTrack(yes: false);
m.mission.apply(trackEvent('IDLE'));
m.mission.apply(tapFlyEvent('EXECUTING'));
await gate(m, dji).run(FlightCommand.stopMode);
expect(dji.calls, <String>['acceptActiveTrackConfirmation', 'rejectActiveTrackConfirmation', 'stopTapFly']);
});
});
group('auto-sensing, retreat, reset heading, palm, video size', () {
test('sensed subjects arrive while auto-sensing, the flags stick across events', () {
final MissionState s = MissionState()
..apply(<String, dynamic>{'type': 'mission', 'operator': 'activeTrack', 'state': 'IDLE', 'autoSensingSupported': true, 'autoSensing': false});
expect(s.activeTrack.autoSensingSupported, isTrue);
s.apply(trackEvent('AUTO_SENSING', tracking: <String, Object?>{
'subjects': <Object?>[
<String, Object?>{'index': 3, 'type': 'HUMAN', 'rect': box},
<String, Object?>{'index': 4, 'type': 'CAR', 'rect': <String, Object?>{'l': 0.1, 't': 0.1, 'r': 0.05, 'b': 0.2}}, // bad rect
],
})..['autoSensing'] = true);
expect(s.activeTrack.subjects.single.index, 3);
expect(s.activeTrack.autoSensing, isTrue);
expect(s.activeTrack.autoSensingSupported, isTrue, reason: 'not repeated, still known');
expect(s.activeTrack.stateLabel, 'Tap a subject');
s.apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: <String, Object?>{'rect': box}));
expect(s.activeTrack.subjects, isEmpty);
});
test('auto-sensing needs support and no tracking; a subject starts by index', () async {
final FakeDjiService dji = FakeDjiService();
final FlightModel m = modelWith(telemetryTick());
final SafetyGate g = SafetyGate(model: m, dji: dji);
await expectLater(g.setAutoSensing(true), throwsA(isA<MissionRefused>()));
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'activeTrack', 'state': 'IDLE', 'autoSensingSupported': true});
await g.setAutoSensing(true);
await g.startActiveTrackSubject(3, TrackMode.profile);
expect(dji.calls, <String>['setActiveTrackAutoSensing(true)', 'startActiveTrackSubject(3, PROFILE)']);
});
test('backward needs obstacle sensing; reset heading needs TapFly flying', () async {
final FakeDjiService dji = FakeDjiService();
final FlightModel noSensors = FlightModel()..connected = true;
TelemetryReducer(noSensors).apply(telemetryTick());
await expectLater(SafetyGate(model: noSensors, dji: dji).setTrackRetreat(true), throwsA(isA<MissionRefused>()));
final FlightModel m = modelWith(telemetryTick());
final SafetyGate g = SafetyGate(model: m, dji: dji);
await g.setTrackRetreat(true);
await expectLater(g.resetTapFlyHeading(), throwsA(isA<MissionRefused>()));
m.mission.apply(tapFlyEvent('EXECUTING'));
await g.resetTapFlyHeading();
expect(dji.calls, <String>['setActiveTrackRetreat(true)', 'resetTapFlyHeading']);
});
test('the reducer takes the video size and the palm-control state', () {
final FlightModel m = modelWith(telemetryTick());
TelemetryReducer(m)
..apply(<String, dynamic>{'type': 'videoSize', 'width': 1440, 'height': 1080})
..apply(telemetryTick()..['palmControl'] = 'FOLLOWING');
expect(m.videoSize.value, const Size(1440, 1080));
expect(m.vision.palmActive, isTrue);
expect(m.vision.palmLabel, 'Following your palm');
});
});
group('FlyScreen (vision modes)', () {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlyPrefs.acceptedModeTerms.value = <String>{'activeTrack', 'tapFly'};
// The first-use tutorials have their own test (p7_modes_test.dart).
FlyPrefs.seenTutorials.value = <String>{for (final IntelligentMode m in IntelligentMode.values) m.name};
});
Future<FakeDjiService> pump(WidgetTester tester, FlightModel m) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final FakeDjiService dji = FakeDjiService();
await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji)));
await tester.pump();
return dji;
}
Future<void> open(WidgetTester tester, String mode) async {
await tester.tap(find.byKey(const ValueKey<String>('rail-modes')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(ValueKey<String>('mode-$mode')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
testWidgets('ActiveTrack: draw a box, GO starts it, the tracked box and Stop follow', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick());
final FakeDjiService dji = await pump(tester, m);
await open(tester, 'activeTrack');
expect(find.byKey(const ValueKey<String>('track-panel')), findsOneWidget);
// Draw in the empty middle of the view, clear of the HUD.
await tester.dragFrom(const Offset(380, 160), const Offset(80, 120));
await tester.pump();
expect(find.byKey(const ValueKey<String>('track-go')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('track-go')));
await tester.pumpAndSettle();
expect(dji.calls.single, startsWith('startActiveTrack('));
expect(dji.calls.single, endsWith('TRACE)'));
TelemetryReducer(m).apply(trackEvent('WAITING_FOR_CONFIRMATION', tracking: <String, Object?>{'targetState': 'WAITING_FOR_CONFIRMATION', 'rect': box}));
await tester.pump();
expect(find.byKey(const ValueKey<String>('track-box')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('track-confirm')));
await tester.pump();
expect(dji.calls.last, 'acceptActiveTrackConfirmation');
TelemetryReducer(m).apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: <String, Object?>{'targetState': 'TRACKING_WITH_HIGH_CONFIDENCE', 'rect': box}));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('track-spotlight')));
await tester.pump();
expect(dji.calls.last, 'switchActiveTrackMode(SPOTLIGHT)');
expect(find.byKey(const ValueKey<String>('rail-stopMode')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('vision-exit')));
await tester.pumpAndSettle();
expect(dji.calls.last, 'stopActiveTrack', reason: 'Exit stops a running mode');
expect(find.byKey(const ValueKey<String>('track-panel')), findsNothing);
});
testWidgets('Auto-detect: tap a sensed subject, GO follows it by index', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick());
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'activeTrack', 'state': 'IDLE', 'autoSensingSupported': true});
final FakeDjiService dji = await pump(tester, m);
await open(tester, 'activeTrack');
await tester.tap(find.byKey(const ValueKey<String>('track-autosense')));
await tester.pump();
expect(dji.calls.last, 'setActiveTrackAutoSensing(true)');
TelemetryReducer(m).apply(trackEvent('AUTO_SENSING', tracking: <String, Object?>{
'subjects': <Object?>[<String, Object?>{'index': 7, 'type': 'HUMAN', 'rect': box}],
})..['autoSensing'] = true);
await tester.pump();
expect(find.byKey(const ValueKey<String>('subject-7')), findsOneWidget);
// The box only draws; the layer underneath takes the tap.
await tester.tapAt(tester.getCenter(find.byKey(const ValueKey<String>('subject-7'))));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('track-go')));
await tester.pumpAndSettle();
expect(dji.calls.last, 'startActiveTrackSubject(7, TRACE)');
});
testWidgets('Backward toggles retreat for Trace', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick());
final FakeDjiService dji = await pump(tester, m);
await open(tester, 'activeTrack');
await tester.tap(find.byKey(const ValueKey<String>('track-retreat')));
await tester.pump();
expect(dji.calls.last, 'setActiveTrackRetreat(true)');
expect(find.text('Backward on'), findsOneWidget);
});
testWidgets('with a 4:3 feed, a tap on the black bar is no TapFly target', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick());
m.videoSize.value = const Size(1440, 1080); // 520 px wide on a 390 px tall screen: bars of 162 px
await pump(tester, m);
await open(tester, 'tapFly');
await tester.tapAt(const Offset(780, 200));
await tester.pump();
expect(find.byKey(const ValueKey<String>('tapfly-target')), findsNothing);
await tester.tapAt(const Offset(500, 200));
await tester.pump();
expect(find.byKey(const ValueKey<String>('tapfly-target')), findsOneWidget);
});
testWidgets('palm control shows while the aircraft follows a palm', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick());
await pump(tester, m);
expect(find.byKey(const ValueKey<String>('palm-control')), findsNothing);
TelemetryReducer(m).apply(telemetryTick()..['palmControl'] = 'FOLLOWING');
await tester.pump();
expect(find.byKey(const ValueKey<String>('palm-control')), findsOneWidget);
});
testWidgets('a vision mode that would warn asks for the slide first', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick());
final FakeDjiService dji = await pump(tester, m);
await open(tester, 'activeTrack');
await tester.tap(find.byKey(const ValueKey<String>('track-profile')));
await tester.pump();
await tester.tapAt(const Offset(420, 200));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('track-go')));
await tester.pumpAndSettle();
expect(dji.calls, isEmpty);
expect(find.byKey(SlideToConfirmTrack.thumbKey), findsOneWidget);
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
await tester.pumpAndSettle();
expect(dji.calls.single, endsWith('PROFILE)'));
});
testWidgets('TapFly: tap a point, set the speed, GO, Stop', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick());
final FakeDjiService dji = await pump(tester, m);
await open(tester, 'tapFly');
expect(find.byKey(const ValueKey<String>('tapfly-panel')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('tapfly-backward')), findsNothing, reason: 'Phantom 4 has front sensors only');
await tester.tap(find.byKey(const ValueKey<String>('tapfly-speed-plus')));
await tester.pump();
await tester.tapAt(const Offset(422, 195));
await tester.pump();
expect(find.byKey(const ValueKey<String>('tapfly-target')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('tapfly-go')));
await tester.pumpAndSettle();
expect(dji.calls.single, 'startTapFly(0.50, 0.50, FORWARD, 6, true)');
TelemetryReducer(m).apply(tapFlyEvent('EXECUTING', execution: <String, Object?>{'imageX': 0.5, 'imageY': 0.5, 'bypass': 'NONE', 'speed': 6.0}));
await tester.pump();
expect(find.byKey(const ValueKey<String>('tapfly-heading')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('tapfly-speed-minus')));
await tester.pump();
expect(dji.calls.last, 'setTapFlySpeed(5)');
await tester.tap(find.byKey(const ValueKey<String>('vision-stop')));
await tester.pumpAndSettle();
expect(dji.calls.last, 'stopTapFly');
});
});
}