Files
PilotVault/Fly App/test/fly/safety_test.dart
T
tajniak81andClaude Opus 5.5 2c908cf17e Give the Fly App GO 4-style ActiveTrack and TapFly
P7b of the GO 4-parity plan. Both modes open from the MODES picker on the
live video, where they take over the touches the camera and gimbal used.

ActiveTrack: drag a box round the subject (or tap it), then GO. The
operator's own box follows on the video, coloured by confidence, with
confirm / reject when it asks and the reason when it cannot follow.
Trace, Profile and Spotlight switch live from the mode panel.

TapFly: tap where to fly, then GO; the aircraft's heading point and any
obstacle bypass show while it flies, and the speed changes live. Forward
only on aircraft without rear sensors; Backward and Free otherwise.

Both go through new SafetyGate checks (flying, P mode, above 2 m, nothing
else in control, battery), start on GO when the check is clean and ask for
the slide when it warns. Stop on the rail and Exit stop them. MissionBridge
now listens to the ActiveTrack and TapFly operators. Not yet run on a
simulator or aircraft.

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

393 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/dji_service.dart';
import 'package:pilotvault_fly/flight_model.dart';
import 'package:pilotvault_fly/fly/action_rail_state.dart';
import 'package:pilotvault_fly/fly/aircraft_checklist.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:pilotvault_fly/ui/fly/panels/aircraft_status_sheet.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
Map<String, dynamic> grounded() => telemetryTick()
..['isFlying'] = false
..['areMotorsOn'] = false
..['flightTimeSeconds'] = 0;
FlightModel modelWith(Map<String, dynamic> tick, {Map<String, dynamic>? profile, List<Map<String, Object?>> alerts = const <Map<String, Object?>>[]}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile ?? phantom4);
final TelemetryReducer r = TelemetryReducer(m);
r.apply(tick);
if (alerts.isNotEmpty) r.apply(diagnostics(alerts));
return m;
}
AircraftState aircraft(Map<String, dynamic> tick) => AircraftState()..apply(tick);
void main() {
group('RailState', () {
RailState rail(Map<String, dynamic> tick, {bool connected = true, String mission = 'idle'}) =>
RailState.of(connected: connected, a: aircraft(tick), mission: MissionState()..state = mission);
test('on the ground: Take-off only', () {
expect(rail(grounded()), const RailState(primary: FlightCommand.takeOff));
});
test('before the flight controller reports, the rail is disabled', () {
expect(RailState.of(connected: true, a: AircraftState()).enabled, isFalse);
expect(rail(grounded(), connected: false).enabled, isFalse);
});
test('airborne: Land plus RTH', () {
expect(rail(telemetryTick()), const RailState(primary: FlightCommand.land, rth: FlightCommand.goHome));
});
test('landing: Cancel landing, then Confirm landing when the aircraft asks', () {
expect(rail(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING').primary, FlightCommand.cancelLanding);
expect(rail(telemetryTick()..['isLandingConfirmationNeeded'] = true).primary, FlightCommand.confirmLanding);
expect(rail(telemetryTick()..['flightModeEnum'] = 'CONFIRM_LANDING').primary, FlightCommand.confirmLanding);
});
test('going home: RTH turns into Cancel RTH, Land stays', () {
final RailState r = rail(telemetryTick()
..['isGoingHome'] = true
..['flightModeEnum'] = 'GO_HOME');
expect(r.primary, FlightCommand.land);
expect(r.rth, FlightCommand.cancelGoHome);
});
test('an intelligent mode adds Stop, aimed at the right operator', () {
expect(rail(telemetryTick(), mission: 'tracking').mode, ActiveMode.activeTrack);
expect(rail(telemetryTick(), mission: 'executing').mode, ActiveMode.waypoint);
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_WAYPOINT').mode, ActiveMode.waypoint);
expect(rail(telemetryTick()..['flightModeEnum'] = 'TAP_FLY').mode, ActiveMode.tapFly);
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT').mode, ActiveMode.other);
expect(rail(telemetryTick(), mission: 'uploading').showStop, isFalse);
expect(rail(grounded(), mission: 'tracking').showStop, isFalse, reason: 'nothing to stop on the ground');
});
});
group('SafetyGate.check', () {
List<String> blockers(FlightModel m, FlightCommand c) =>
SafetyGate(model: m, dji: FakeDjiService()).check(c).blockers.map((GateCheck g) => g.message).toList();
List<String> warnings(FlightModel m, FlightCommand c) =>
SafetyGate(model: m, dji: FakeDjiService()).check(c).warnings.map((GateCheck g) => g.message).toList();
test('nothing passes without an aircraft', () {
final FlightModel m = FlightModel();
for (final FlightCommand c in FlightCommand.values) {
expect(SafetyGate(model: m, dji: FakeDjiService()).check(c).allowed, isFalse, reason: '$c');
}
});
test('a healthy aircraft on the ground may take off with no remarks', () {
final GateVerdict v = SafetyGate(model: modelWith(grounded()), dji: FakeDjiService()).check(FlightCommand.takeOff);
expect(v.allowed, isTrue);
expect(v.checks, isEmpty);
});
test('take-off: refused when flying, critically low, preheating or faulted', () {
expect(blockers(modelWith(telemetryTick()), FlightCommand.takeOff), isNotEmpty);
expect(blockers(modelWith(grounded()..['battery'] = (telemetryTick(battery: 12)['battery'])), FlightCommand.takeOff).single,
contains('critically low (12%)'));
expect(blockers(modelWith(grounded()..['isIMUPreheating'] = true), FlightCommand.takeOff).single, contains('IMU'));
expect(blockers(modelWith(grounded()..['compassHasError'] = true), FlightCommand.takeOff).single, contains('Compass'));
expect(blockers(modelWith(grounded(), alerts: <Map<String, Object?>>[motorBlocked]), FlightCommand.takeOff), <String>['Motor blocked.']);
});
test('take-off: warns on low battery, weak GPS, no home point and a bad SD card', () {
expect(warnings(modelWith(grounded()..['battery'] = (telemetryTick(battery: 25)['battery'])), FlightCommand.takeOff).single,
contains('Battery low (25%)'));
final FlightModel weak = modelWith(grounded()
..['gpsSignalLevel'] = 1
..['isHomeLocationSet'] = false);
expect(warnings(weak, FlightCommand.takeOff), hasLength(2));
expect(warnings(weak, FlightCommand.takeOff).first, contains('ATTI'));
final Map<String, dynamic> noSd = grounded()
..['storage'] = <Object?, Object?>{'inserted': false};
expect(warnings(modelWith(noSd), FlightCommand.takeOff).single, contains('No SD'));
expect(warnings(modelWith(grounded(), alerts: <Map<String, Object?>>[sdSlow]), FlightCommand.takeOff).single, 'SD card write speed slow.');
});
test('RTH: needs to be flying with a home point and GPS', () {
expect(SafetyGate(model: modelWith(telemetryTick()), dji: FakeDjiService()).check(FlightCommand.goHome).allowed, isTrue);
expect(blockers(modelWith(grounded()), FlightCommand.goHome), isNotEmpty);
expect(blockers(modelWith(telemetryTick()..['isHomeLocationSet'] = false), FlightCommand.goHome).single, contains('home point'));
expect(blockers(modelWith(telemetryTick()..['gpsSignalLevel'] = 2), FlightCommand.goHome).single, contains('GPS'));
expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.goHome).single, contains('already'));
});
test('RTH: warns when the battery cannot make it or the altitude is low without sensing', () {
expect(warnings(modelWith(telemetryTick(battery: 10)), FlightCommand.goHome).single, contains('below what the trip home needs'));
expect(warnings(modelWith(telemetryTick()..['goHomeHeight'] = 20, profile: sparkWifi), FlightCommand.goHome), isEmpty,
reason: 'Spark has front sensing');
// A model the matrix does not know: no obstacle sensing assumed.
final FlightModel blind = modelWith(telemetryTick()..['goHomeHeight'] = 20, profile: <String, dynamic>{...phantom4, 'model': 'SOMETHING_NEW'});
expect(blind.profile.hasObstacleSensing, isFalse);
expect(warnings(blind, FlightCommand.goHome).single, contains('cannot see obstacles'));
});
test('cancel / confirm commands only when the state they answer is live', () {
final FlightModel flying = modelWith(telemetryTick());
expect(blockers(flying, FlightCommand.cancelLanding), isNotEmpty);
expect(blockers(flying, FlightCommand.confirmLanding), isNotEmpty);
expect(blockers(flying, FlightCommand.cancelGoHome), isNotEmpty);
expect(blockers(flying, FlightCommand.stopMode), isNotEmpty);
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING'), FlightCommand.cancelLanding), isEmpty);
expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.confirmLanding), isEmpty);
expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.cancelGoHome), isEmpty);
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT'), FlightCommand.stopMode).single, contains('pause button'));
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'TAP_FLY'), FlightCommand.stopMode), isEmpty);
});
test('the RTH target is offered once the pilot is more than 30 m from home', () {
final FlightModel m = modelWith(telemetryTick());
final SafetyGate g = SafetyGate(model: m, dji: FakeDjiService());
expect(g.offersRthTarget, isFalse, reason: 'no phone fix');
m
..phoneLatitude = 55.6760
..phoneLongitude = 12.5682; // ~13 m from home
expect(g.offersRthTarget, isFalse);
m.phoneLatitude = 55.6765; // ~56 m
expect(g.offersRthTarget, isTrue);
});
});
group('SafetyGate.run', () {
test('sends take-off, or precision take-off when asked', () async {
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: modelWith(grounded()), dji: dji);
await g.run(FlightCommand.takeOff);
await g.run(FlightCommand.takeOff, precision: true);
expect(dji.calls, <String>['takeOff', 'precisionTakeOff']);
});
test('refuses without calling the bridge when the state moved on', () async {
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: modelWith(telemetryTick()), dji: dji);
await expectLater(g.run(FlightCommand.takeOff), throwsA(isA<SafetyGateRefused>()));
expect(dji.calls, isEmpty);
});
test('RTH writes the home point and altitude first, clamped, and skips an unchanged altitude', () async {
final FakeDjiService dji = FakeDjiService();
final FlightModel m = modelWith(telemetryTick())
..phoneLatitude = 55.6765
..phoneLongitude = 12.5682;
final SafetyGate g = SafetyGate(model: m, dji: dji);
await g.run(FlightCommand.goHome, rthHeight: 80, homeToPilot: true);
expect(dji.calls, <String>['setHomeLocation', 'setGoHomeHeight(80)', 'startGoHome']);
dji.calls.clear();
await g.run(FlightCommand.goHome, rthHeight: 60);
expect(dji.calls, <String>['startGoHome'], reason: 'the fixture already has 60 m');
dji.calls.clear();
await g.run(FlightCommand.goHome, rthHeight: 5);
expect(dji.calls.first, 'setGoHomeHeight(20)');
});
test('Stop addresses the operator that is running', () async {
final FakeDjiService dji = FakeDjiService();
final FlightModel m = modelWith(telemetryTick());
m.mission.state = 'tracking';
await SafetyGate(model: m, dji: dji).run(FlightCommand.stopMode);
m.mission.state = 'executing';
await SafetyGate(model: m, dji: dji).run(FlightCommand.stopMode);
expect(dji.calls, <String>['stopActiveTrack', 'stopWaypointMission']);
});
test('the smart-RTH answer only goes out during the countdown', () async {
final FakeDjiService dji = FakeDjiService();
await SafetyGate(model: modelWith(telemetryTick()), dji: dji).answerSmartRth(goHome: true);
expect(dji.calls, isEmpty);
await SafetyGate(model: modelWith(telemetryTick(smartRTHState: 'COUNTING_DOWN')), dji: dji).answerSmartRth(goHome: false);
expect(dji.calls, <String>['confirmSmartRTH(false)']);
});
});
group('AircraftChecklist', () {
ChecklistItem row(List<ChecklistItem> items, String id) => items.firstWhere((ChecklistItem i) => i.id == id);
test('a healthy Phantom 4 on the ground is all green, with Calibrate and Format offered', () {
final List<ChecklistItem> items = AircraftChecklist.build(modelWith(grounded()),
limits: const FlightLimits(maxHeight: 120, maxRadius: 500, maxRadiusEnabled: true));
expect(AircraftChecklist.overall(items), CheckState.ok);
expect(row(items, 'compass').action, ChecklistAction.calibrateCompass);
expect(row(items, 'imu').action, ChecklistAction.calibrateImu);
expect(row(items, 'sd').action, ChecklistAction.formatSd);
expect(row(items, 'maxHeight').value, '120 m');
expect(row(items, 'radio').value, 'RC 88% · HD 74%');
});
test('no calibration or format while flying or recording', () {
final List<ChecklistItem> flying = AircraftChecklist.build(modelWith(telemetryTick()));
expect(row(flying, 'compass').action, isNull);
expect(row(flying, 'sd').action, isNull);
final FlightModel rec = modelWith(grounded());
rec.camera.isRecording = true;
expect(row(AircraftChecklist.build(rec), 'sd').action, isNull);
});
test('faults colour their own rows', () {
final List<ChecklistItem> items = AircraftChecklist.build(
modelWith(grounded(), alerts: <Map<String, Object?>>[compassInterference, motorBlocked]));
expect(row(items, 'compass').state, CheckState.caution);
expect(row(items, 'compass').detail, 'Compass interference.');
expect(row(items, 'esc').state, CheckState.danger);
expect(AircraftChecklist.overall(items), CheckState.danger);
});
test('a running compass calibration shows its step and offers Stop', () {
final ChecklistItem c = row(
AircraftChecklist.build(modelWith(grounded()
..['compassCalibrating'] = true
..['compassCalibrationState'] = 'VERTICAL')),
'compass');
expect(c.value, contains('nose down'));
expect(c.action, ChecklistAction.stopCompassCalibration);
});
test('Spark on Wi-Fi: Wi-Fi signal row, no RC rows', () {
final List<ChecklistItem> items = AircraftChecklist.build(modelWith(grounded(), profile: sparkWifi));
expect(row(items, 'radio').label, 'Wi-Fi signal');
expect(items.any((ChecklistItem i) => i.id == 'rcMode' || i.id == 'rcBattery'), isFalse);
});
});
group('Safety flows on the HUD', () {
Future<void> pump(WidgetTester tester, FlightModel m, DjiService dji) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji)));
await tester.pump(const Duration(milliseconds: 50));
}
Future<void> slide(WidgetTester tester) async {
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
await tester.pumpAndSettle();
}
testWidgets('first take-off: status sheet, then slide; the second skips the sheet', (WidgetTester tester) async {
final FakeDjiService dji = FakeDjiService();
await pump(tester, modelWith(grounded()), dji);
expect(find.text('TAKE OFF'), findsOneWidget);
expect(find.text('RTH'), findsNothing);
await tester.tap(find.text('TAKE OFF'));
await tester.pumpAndSettle();
expect(find.text('Aircraft Status'), findsOneWidget);
expect(find.text('120 m'), findsOneWidget, reason: 'limits read from the KeyManager');
await tester.tap(find.byKey(const ValueKey<String>('status-continue-takeoff')));
await tester.pumpAndSettle();
expect(find.text('Precision Takeoff'), findsOneWidget, reason: 'Phantom 4 has downward vision');
// A short drag springs back and sends nothing.
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(60, 0));
await tester.pumpAndSettle();
expect(dji.calls, isEmpty);
await slide(tester);
expect(dji.calls, <String>['takeOff']);
await tester.tap(find.text('TAKE OFF'));
await tester.pumpAndSettle();
expect(find.text('Aircraft Status'), findsNothing);
expect(find.byKey(SlideToConfirmTrack.thumbKey), findsOneWidget);
});
testWidgets('a blocked take-off says why and has no slider', (WidgetTester tester) async {
final FakeDjiService dji = FakeDjiService();
await pump(tester, modelWith(grounded()..['isIMUPreheating'] = true), dji);
await tester.tap(find.text('TAKE OFF'));
await tester.pumpAndSettle();
expect(find.textContaining('IMU is warming up'), findsOneWidget);
expect(find.byKey(SlideToConfirmTrack.thumbKey), findsNothing);
expect(dji.calls, isEmpty);
});
testWidgets('RTH far from home asks the target, then sends altitude + RTH', (WidgetTester tester) async {
final FakeDjiService dji = FakeDjiService();
final FlightModel m = modelWith(telemetryTick())
..phoneLatitude = 55.6765
..phoneLongitude = 12.5682;
await pump(tester, m, dji);
expect(find.text('LAND'), findsOneWidget);
await tester.tap(find.text('RTH'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey<String>('rth-target-pilot')));
await tester.pumpAndSettle();
expect(find.text('60 m'), findsOneWidget);
await tester.tap(find.text('+'));
await tester.pump();
await slide(tester);
expect(dji.calls, <String>['setHomeLocation', 'setGoHomeHeight(65)', 'startGoHome']);
});
testWidgets('Cancel RTH goes straight out, no slider', (WidgetTester tester) async {
final FakeDjiService dji = FakeDjiService();
await pump(tester, modelWith(telemetryTick()..['isGoingHome'] = true), dji);
await tester.tap(find.text('CANCEL RTH'));
await tester.pumpAndSettle();
expect(dji.calls, <String>['cancelGoHome']);
});
testWidgets('error stack: one card per fault, dismissible, info stays out', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick(), alerts: <Map<String, Object?>>[
compassInterference,
motorBlocked,
<String, Object?>{'component': 'GIMBAL', 'code': 7, 'reason': 'Gimbal calibrating'},
]);
await pump(tester, m, FakeDjiService());
expect(find.byKey(const ValueKey<String>('alert-FLIGHT_CONTROLLER/1301/0')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('alert-FLIGHT_CONTROLLER/1004/0')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('alert-GIMBAL/7/0')), findsNothing);
await tester.tap(find.descendant(
of: find.byKey(const ValueKey<String>('alert-FLIGHT_CONTROLLER/1004/0')), matching: find.byType(GestureDetector)).last);
await tester.pump();
expect(find.byKey(const ValueKey<String>('alert-FLIGHT_CONTROLLER/1004/0')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('smart-RTH countdown banner answers the flight controller', (WidgetTester tester) async {
final FakeDjiService dji = FakeDjiService();
await pump(tester, modelWith(telemetryTick(smartRTHState: 'COUNTING_DOWN')..['goHome']['smartRTHCountdown'] = 9), dji);
expect(find.text('Low battery: returning home in 9 s'), findsOneWidget);
await tester.tap(find.text('Go home now'));
await tester.pump();
expect(dji.calls, <String>['confirmSmartRTH(true)']);
});
testWidgets('critical battery in flight shows the forced-landing banner', (WidgetTester tester) async {
await pump(tester, modelWith(telemetryTick()..['isSeriousLowBattery'] = true), FakeDjiService());
expect(find.text('Critically low battery: landing'), findsOneWidget);
});
testWidgets('the status chip opens the sheet; Format SD slide-confirms', (WidgetTester tester) async {
final FakeDjiService dji = FakeDjiService();
await pump(tester, modelWith(grounded()), dji);
await tester.tap(find.text('Ready to Go (GPS)'));
await tester.pumpAndSettle();
expect(find.text('Aircraft Status'), findsOneWidget);
expect(find.byKey(const ValueKey<String>('status-continue-takeoff')), findsNothing);
final Finder format = find.byKey(const ValueKey<String>('status-action-sd'));
await tester.scrollUntilVisible(format, 80,
scrollable: find.descendant(of: find.byType(AircraftStatusSheet), matching: find.byType(Scrollable)));
await tester.tap(format);
await tester.pumpAndSettle();
expect(find.text('Format SD card'), findsOneWidget);
await slide(tester);
expect(dji.calls, <String>['formatStorage']);
expect(tester.takeException(), isNull);
});
});
}