Files
PilotVault/Fly App/test/fly/vision_modes_test.dart
T
tajniak81andClaude Opus 5.5 e49f318a0e Finish the Fly App's ActiveTrack and TapFly extras and letterbox the feed
Auto-detect: on aircraft that can sense subjects themselves, the
ActiveTrack panel offers Auto-detect; the subjects it finds show as dashed
boxes on the video, and tapping one then GO follows it by its index.

Trace gets a Backward switch (fly ahead of a subject walking towards the
aircraft, obstacle-sensing aircraft only) and TapFly Free a Reset heading
button. Settings > Vision gains Gesture mode and Palm control where the
flight assistant supports them, and the HUD shows when palm control is
flying the aircraft.

The live view used to stretch the feed over the screen. It now fits the
picture to the decoder's own size with black bars, and reports that size,
so the ActiveTrack / TapFly overlays, tap-to-focus / meter and the grid
all map into the picture and ignore taps on the bars.

Not yet run on a simulator or aircraft.

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

446 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'};
});
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');
});
});
}