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 trackEvent(String state, {Map? tracking, String? error}) => { 'type': 'mission', 'operator': 'activeTrack', 'state': state, if (tracking != null) 'tracking': tracking, if (error != null) 'error': error, }; Map tapFlyEvent(String state, {Map? execution}) => { 'type': 'mission', 'operator': 'tapFly', 'state': state, if (execution != null) 'execution': execution, }; const Map box = {'l': 0.4, 't': 0.3, 'r': 0.6, 'b': 0.7}; FlightModel modelWith(Map tick, {Map? 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 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: { '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: {'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: {'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: {'rect': {'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({'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: {'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 blockers(List c) => [for (final GateCheck g in c) if (g.blocks) g.message]; List warnings(List c) => [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)), ['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({'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.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, ['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())); 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, ['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({'type': 'mission', 'operator': 'activeTrack', 'state': 'IDLE', 'autoSensingSupported': true, 'autoSensing': false}); expect(s.activeTrack.autoSensingSupported, isTrue); s.apply(trackEvent('AUTO_SENSING', tracking: { 'subjects': [ {'index': 3, 'type': 'HUMAN', 'rect': box}, {'index': 4, 'type': 'CAR', 'rect': {'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: {'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())); m.mission.apply({'type': 'mission', 'operator': 'activeTrack', 'state': 'IDLE', 'autoSensingSupported': true}); await g.setAutoSensing(true); await g.startActiveTrackSubject(3, TrackMode.profile); expect(dji.calls, ['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())); final FlightModel m = modelWith(telemetryTick()); final SafetyGate g = SafetyGate(model: m, dji: dji); await g.setTrackRetreat(true); await expectLater(g.resetTapFlyHeading(), throwsA(isA())); m.mission.apply(tapFlyEvent('EXECUTING')); await g.resetTapFlyHeading(); expect(dji.calls, ['setActiveTrackRetreat(true)', 'resetTapFlyHeading']); }); test('the reducer takes the video size and the palm-control state', () { final FlightModel m = modelWith(telemetryTick()); TelemetryReducer(m) ..apply({'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({}); FlyPrefs.acceptedModeTerms.value = {'activeTrack', 'tapFly'}; }); Future 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 open(WidgetTester tester, String mode) async { await tester.tap(find.byKey(const ValueKey('rail-modes'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.byKey(ValueKey('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('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('track-go')), findsOneWidget); await tester.tap(find.byKey(const ValueKey('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: {'targetState': 'WAITING_FOR_CONFIRMATION', 'rect': box})); await tester.pump(); expect(find.byKey(const ValueKey('track-box')), findsOneWidget); await tester.tap(find.byKey(const ValueKey('track-confirm'))); await tester.pump(); expect(dji.calls.last, 'acceptActiveTrackConfirmation'); TelemetryReducer(m).apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: {'targetState': 'TRACKING_WITH_HIGH_CONFIDENCE', 'rect': box})); await tester.pump(); await tester.tap(find.byKey(const ValueKey('track-spotlight'))); await tester.pump(); expect(dji.calls.last, 'switchActiveTrackMode(SPOTLIGHT)'); expect(find.byKey(const ValueKey('rail-stopMode')), findsOneWidget); await tester.tap(find.byKey(const ValueKey('vision-exit'))); await tester.pumpAndSettle(); expect(dji.calls.last, 'stopActiveTrack', reason: 'Exit stops a running mode'); expect(find.byKey(const ValueKey('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({'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('track-autosense'))); await tester.pump(); expect(dji.calls.last, 'setActiveTrackAutoSensing(true)'); TelemetryReducer(m).apply(trackEvent('AUTO_SENSING', tracking: { 'subjects': [{'index': 7, 'type': 'HUMAN', 'rect': box}], })..['autoSensing'] = true); await tester.pump(); expect(find.byKey(const ValueKey('subject-7')), findsOneWidget); // The box only draws; the layer underneath takes the tap. await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('subject-7')))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('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('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('tapfly-target')), findsNothing); await tester.tapAt(const Offset(500, 200)); await tester.pump(); expect(find.byKey(const ValueKey('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('palm-control')), findsNothing); TelemetryReducer(m).apply(telemetryTick()..['palmControl'] = 'FOLLOWING'); await tester.pump(); expect(find.byKey(const ValueKey('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('track-profile'))); await tester.pump(); await tester.tapAt(const Offset(420, 200)); await tester.pump(); await tester.tap(find.byKey(const ValueKey('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('tapfly-panel')), findsOneWidget); expect(find.byKey(const ValueKey('tapfly-backward')), findsNothing, reason: 'Phantom 4 has front sensors only'); await tester.tap(find.byKey(const ValueKey('tapfly-speed-plus'))); await tester.pump(); await tester.tapAt(const Offset(422, 195)); await tester.pump(); expect(find.byKey(const ValueKey('tapfly-target')), findsOneWidget); await tester.tap(find.byKey(const ValueKey('tapfly-go'))); await tester.pumpAndSettle(); expect(dji.calls.single, 'startTapFly(0.50, 0.50, FORWARD, 6, true)'); TelemetryReducer(m).apply(tapFlyEvent('EXECUTING', execution: {'imageX': 0.5, 'imageY': 0.5, 'bypass': 'NONE', 'speed': 6.0})); await tester.pump(); expect(find.byKey(const ValueKey('tapfly-heading')), findsOneWidget); await tester.tap(find.byKey(const ValueKey('tapfly-speed-minus'))); await tester.pump(); expect(dji.calls.last, 'setTapFlySpeed(5)'); await tester.tap(find.byKey(const ValueKey('vision-stop'))); await tester.pumpAndSettle(); expect(dji.calls.last, 'stopTapFly'); }); }); }