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>
396 lines
21 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|