Files
PilotVault/Fly App/test/fly/safety_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

396 lines
21 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.hotpoint);
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_FOLLOW_ME').mode, ActiveMode.followMe);
expect(rail(telemetryTick()..['flightModeEnum'] = 'DRAW').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'] = 'DRAW'), FlightCommand.stopMode).single, contains('pause button'));
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT'), FlightCommand.stopMode), isEmpty, reason: 'POI stops from the app now');
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);
});
});
}