import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pilotvault_fly/flight_model.dart'; import 'package:pilotvault_fly/fly/calibration.dart'; import 'package:pilotvault_fly/fly/product_profile.dart'; import 'package:pilotvault_fly/fly/safety_gate.dart'; import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; import 'package:pilotvault_fly/ui/fly/panels/aircraft_status_sheet.dart'; import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/calibration_hub.dart'; import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/wizards.dart'; import 'fake_dji.dart'; import 'profile_fixtures.dart'; import 'telemetry_fixtures.dart'; /// A connected aircraft on the ground, fed through the real reducer; [tick] /// sends the next telemetry snapshot with [patch] on top. class Rig { Rig({Map? profile, bool flying = false}) { model ..connected = true ..profile = ProductProfile.fromBridge(profile ?? phantom4); tick(flying ? const {} : const {'isFlying': false, 'areMotorsOn': false}); } final FlightModel model = FlightModel(); late final TelemetryReducer reducer = TelemetryReducer(model); final Map _state = {}; void tick(Map patch) { _state.addAll(patch); reducer.apply({...telemetryTick(), ..._state}); } void calibration(Map group) => tick({'calibration': group}); } Map withLens(Map p) => { ...p, 'cameras': [ for (final Object? c in p['cameras'] as List) {...(c as Map), 'interchangeableLensSupported': true}, ], }; void main() { group('State', () { test('the calibration group lands in CalibrationState and clears when absent', () { final Rig r = Rig(); r.calibration({ 'support': {'compassCount': 2, 'imuCount': 2, 'gravityCenter': false, 'rc': true}, 'imu': { 'units': [ {'index': 0, 'state': 'CALIBRATING', 'progress': 40, 'connected': true}, ], 'multiSide': true, 'toCalibrate': ['NOSE_DOWN'], 'calibrated': ['BOTTOM_DOWN'], }, 'rc': {'state': 'MIDDLE', 'segments': 4, 'axes': {'A': 2}}, 'rcHardware': {'lx': 100, 'ly': -660, 'rx': 0, 'ry': 0, 'leftDial': 10, 'rightDial': -5}, }); final c = r.model.calibration; expect(c.compassCount, 2); expect(c.gravityCenterSupported, isFalse); expect(c.imuUnits.single.progress, 40); expect(c.imuToCalibrate, ['NOSE_DOWN']); expect(c.rcState, 'MIDDLE'); expect(c.rcAxes['A'], 2); expect(c.rcHardware!.ly, -660); r.tick({'calibration': null}); expect(c.compassCount, isNull); expect(c.imuUnits, isEmpty); expect(c.rcHardware, isNull); }); }); group('Availability', () { test('profile features, then runtime support, decide the hub', () { final Rig r = Rig(); List kinds() => CalibrationKinds.available(r.model.profile, r.model.calibration); expect(kinds(), containsAll([ CalibrationKind.compass, CalibrationKind.imu, CalibrationKind.rc, CalibrationKind.gimbal, CalibrationKind.gimbalAdjust, CalibrationKind.visionSensors, ])); expect(kinds(), isNot(contains(CalibrationKind.lens)), reason: 'fixed lens'); expect(kinds(), isNot(contains(CalibrationKind.sensorCleaning))); expect(kinds(), contains(CalibrationKind.gravityCenter), reason: 'unknown until the aircraft answers'); r.calibration({ 'support': {'gravityCenter': false, 'rc': false, 'compassCount': 1}, }); expect(kinds(), isNot(contains(CalibrationKind.gravityCenter))); expect(kinds(), isNot(contains(CalibrationKind.rc))); expect(kinds(), contains(CalibrationKind.compass)); }); test('interchangeable-lens cameras get lens calibration and sensor cleaning; Spark has no RC wizard', () { final Rig x5s = Rig(profile: withLens(inspire2X5s)); final List k = CalibrationKinds.available(x5s.model.profile, x5s.model.calibration); expect(k, containsAll([CalibrationKind.lens, CalibrationKind.sensorCleaning])); final Rig spark = Rig(profile: sparkWifi); expect(CalibrationKinds.available(spark.model.profile, spark.model.calibration), isNot(contains(CalibrationKind.rc))); }); }); group('SafetyGate', () { test('ground work is refused in the air and with the motors running', () { final Rig r = Rig(flying: true); final SafetyGate gate = SafetyGate(model: r.model, dji: FakeDjiService()); for (final CalibrationKind k in [ CalibrationKind.compass, CalibrationKind.imu, CalibrationKind.rc, CalibrationKind.gimbal, CalibrationKind.lens, CalibrationKind.sensorCleaning, CalibrationKind.propeller, ]) { expect(gate.checkMaintenance(k).where((GateCheck c) => c.blocks).map((GateCheck c) => c.message), contains('Land first.'), reason: k.name); } r.tick({'isFlying': false, 'areMotorsOn': true}); expect(gate.checkMaintenance(CalibrationKind.compass).single.message, 'Stop the motors first.'); r.tick({'areMotorsOn': false}); expect(gate.checkMaintenance(CalibrationKind.compass), isEmpty); }); test('center of gravity is the exception: it needs a hover', () { final Rig ground = Rig(); final SafetyGate g1 = SafetyGate(model: ground.model, dji: FakeDjiService()); expect(g1.checkMaintenance(CalibrationKind.gravityCenter).any((GateCheck c) => c.blocks), isTrue); final Rig air = Rig(flying: true); final SafetyGate g2 = SafetyGate(model: air.model, dji: FakeDjiService()); expect(g2.checkMaintenance(CalibrationKind.gravityCenter).any((GateCheck c) => c.blocks), isFalse); expect(g2.checkMaintenance(CalibrationKind.gimbalAdjust), isEmpty, reason: 'trim is fine in flight'); }); test('runMaintenance re-checks and refuses without sending', () async { final Rig r = Rig(flying: true); final FakeDjiService dji = FakeDjiService(); final SafetyGate gate = SafetyGate(model: r.model, dji: dji); await expectLater(gate.runMaintenance(CalibrationKind.imu, dji.startIMUCalibration), throwsA(isA())); expect(dji.calls, isEmpty); }); test('a low battery and a cold IMU only warn', () { final Rig r = Rig(); r.tick({ 'isIMUPreheating': true, 'battery': {'percent': 20}, }); final List checks = SafetyGate(model: r.model, dji: FakeDjiService()).checkMaintenance(CalibrationKind.imu); expect(checks, isNotEmpty); expect(checks.any((GateCheck c) => c.blocks), isFalse); }); }); group('Runs', () { test('compass: a stale result never ends a new run; steps, then success', () { final Rig r = Rig(); r.tick({'compassCalibrationState': 'SUCCESSFUL'}); final CompassRun run = CompassRun()..begin(); run.observe(r.model); expect(run.phase, RunPhase.starting, reason: 'SUCCESSFUL from before the start'); r.tick({'compassCalibrating': true, 'compassCalibrationState': 'HORIZONTAL'}); run.observe(r.model); expect((run.phase, run.step), (RunPhase.running, CompassStep.horizontal)); r.tick({'compassCalibrationState': 'VERTICAL'}); run.observe(r.model); expect(run.step, CompassStep.vertical); r.tick({'compassCalibrating': false, 'compassCalibrationState': 'SUCCESSFUL'}); run.observe(r.model); expect(run.phase, RunPhase.succeeded); }); test('compass: an end without a verdict counts by the error flag', () { final Rig r = Rig(); final CompassRun run = CompassRun()..begin(); r.tick({'compassCalibrating': true, 'compassCalibrationState': 'VERTICAL'}); run.observe(r.model); r.tick({'compassCalibrating': false, 'compassCalibrationState': 'NOT_CALIBRATING', 'compassHasError': true}); run.observe(r.model); expect(run.phase, RunPhase.failed); expect(run.failure, isNotNull); }); test('IMU: multi-side sequence walks the sides in order and averages progress', () { final Rig r = Rig(); final ImuRun run = ImuRun()..begin(); Map unit(int i, String state, int progress) => {'index': i, 'state': state, 'progress': progress, 'connected': true}; r.calibration({ 'imu': { 'units': [unit(0, 'CALIBRATING', 20), unit(1, 'CALIBRATING', 40)], 'multiSide': true, 'orientationState': 'CALIBRATING', 'toCalibrate': ['NOSE_DOWN', 'LEFT_DOWN', 'BOTTOM_DOWN'], 'calibrated': ['BOTTOM_DOWN'], }, }); run.observe(r.model); expect(run.phase, RunPhase.running); expect(run.progress, closeTo(0.3, 1e-9)); expect(run.orientation, 'NOSE_DOWN'); expect(run.sides, <(String, bool)>[('BOTTOM_DOWN', true), ('NOSE_DOWN', false), ('LEFT_DOWN', false)]); r.calibration({ 'imu': { 'units': [unit(0, 'SUCCESSFUL', 100), unit(1, 'SUCCESSFUL', 100)], 'multiSide': true, 'orientationState': 'COMPLETED', }, }); run.observe(r.model); expect(run.phase, RunPhase.succeeded); }); test('IMU: one failed unit fails the run', () { final Rig r = Rig(); final ImuRun run = ImuRun()..begin(); r.calibration({ 'imu': { 'units': [{'index': 0, 'state': 'CALIBRATING', 'progress': 10}], }, }); run.observe(r.model); r.calibration({ 'imu': { 'units': [{'index': 0, 'state': 'FAILED'}], }, }); run.observe(r.model); expect(run.phase, RunPhase.failed); }); test('RC: MIDDLE → LIMITS → finish; a timeout fails', () { final Rig r = Rig(); final RcRun run = RcRun()..begin(); r.calibration({'rc': {'state': 'MIDDLE'}}); run.observe(r.model); expect((run.phase, run.step), (RunPhase.running, RcStep.middle)); r.calibration({'rc': {'state': 'LIMITS'}}); run.observe(r.model); expect(run.step, RcStep.limits); run ..finishing() ..observe(r.model); expect(run.phase, RunPhase.succeeded); run.begin(); r.calibration({'rc': {'state': 'MIDDLE'}}); run.observe(r.model); r.calibration({'rc': {'state': 'TIMEOUT'}}); run.observe(r.model); expect(run.phase, RunPhase.failed); }); test('sensor cleaning ends well only after leaving the mode', () { final Rig r = Rig(); final SensorCleaningRun run = SensorCleaningRun()..begin(); for (final String s in ['INITIATING', 'WAITING_FOR_LENS_REMOVAL', 'READY', 'CLEANING', 'WAITING_FOR_LENS_REMOUNT']) { r.calibration({'sensorCleaning': s}); run.observe(r.model); expect(run.phase, RunPhase.running, reason: s); } expect(run.step, CleaningStep.remountLens); run.exiting(); run.observe(r.model); expect(run.phase, RunPhase.running, reason: 'still in the mode'); r.calibration({'sensorCleaning': 'IDLE'}); run.observe(r.model); expect(run.phase, RunPhase.succeeded); }); test('lens and center of gravity read their verdicts', () { final Rig r = Rig(); final LensRun lens = LensRun()..begin(); r.calibration({'lens': {'state': 'CALIBRATING', 'result': 'IDLE'}}); lens.observe(r.model); r.calibration({'lens': {'state': 'CALIBRATED', 'result': 'DATA_ERROR'}}); lens.observe(r.model); expect(lens.phase, RunPhase.failed); final GravityCenterRun gc = GravityCenterRun()..begin(); r.calibration({'gravityCenter': {'state': 'CALCULATING'}}); gc.observe(r.model); r.calibration({'gravityCenter': {'state': 'SUCCESSFUL'}}); gc.observe(r.model); expect(gc.phase, RunPhase.succeeded); }); }); group('Wizards', () { // The compass illustration spins forever, so pumpAndSettle never would. Future settle(WidgetTester tester) async { await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); } Future open(WidgetTester tester, Rig r, CalibrationKind kind) 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: Builder( builder: (BuildContext context) => Scaffold( body: Center( child: TextButton( onPressed: () => openCalibration(context, kind, model: r.model, dji: dji, onError: (_, __) {}), child: const Text('open'), ), ), ), ), )); await tester.tap(find.text('open')); await settle(tester); return dji; } String heading(WidgetTester tester) => tester.widget(find.byKey(const ValueKey('wizard-heading'))).data!; testWidgets('compass: start, the two steps follow telemetry, done', (WidgetTester tester) async { final Rig r = Rig(); final FakeDjiService dji = await open(tester, r, CalibrationKind.compass); expect(heading(tester), 'Calibrate the compass'); await tester.tap(find.byKey(const ValueKey('wizard-start'))); await tester.pump(); expect(dji.calls, ['startCompassCalibration']); expect(heading(tester), 'Starting…'); r.tick({'compassCalibrating': true, 'compassCalibrationState': 'HORIZONTAL'}); await tester.pump(); expect(find.text('STEP 1 OF 2'), findsOneWidget); r.tick({'compassCalibrationState': 'VERTICAL'}); await tester.pump(); expect(find.text('STEP 2 OF 2'), findsOneWidget); r.tick({'compassCalibrating': false, 'compassCalibrationState': 'SUCCESSFUL'}); await tester.pump(); expect(heading(tester), 'Compass calibrated'); await tester.tap(find.byKey(const ValueKey('wizard-done'))); await settle(tester); expect(find.byType(CompassWizard), findsNothing); }); testWidgets('compass: closing mid-run asks, then stops the calibration', (WidgetTester tester) async { final Rig r = Rig(); final FakeDjiService dji = await open(tester, r, CalibrationKind.compass); await tester.tap(find.byKey(const ValueKey('wizard-start'))); await tester.pump(); r.tick({'compassCalibrating': true, 'compassCalibrationState': 'HORIZONTAL'}); await tester.pump(); await tester.tap(find.byKey(const ValueKey('wizard-close'))); await settle(tester); await tester.tap(find.byKey(const ValueKey('wizard-confirm-stop'))); await settle(tester); expect(dji.calls, contains('stopCompassCalibration')); expect(find.byType(CompassWizard), findsNothing); }); testWidgets('a blocked start says why and sends nothing', (WidgetTester tester) async { final Rig r = Rig(flying: true); final FakeDjiService dji = await open(tester, r, CalibrationKind.imu); expect(find.text('Land first.'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('wizard-start'))); await tester.pump(); expect(dji.calls, isEmpty); }); testWidgets('IMU: shows the side to put down; leaving does not try to stop it', (WidgetTester tester) async { final Rig r = Rig(); final FakeDjiService dji = await open(tester, r, CalibrationKind.imu); await tester.tap(find.byKey(const ValueKey('wizard-start'))); await tester.pump(); r.calibration({ 'imu': { 'units': [{'index': 0, 'state': 'CALIBRATING', 'progress': 50}], 'multiSide': true, 'toCalibrate': ['NOSE_DOWN', 'TAIL_DOWN'], 'calibrated': ['BOTTOM_DOWN'], }, }); await tester.pump(); expect(heading(tester), 'Put it nose down'); expect(find.text('SIDE 2 OF 3'), findsOneWidget); expect(find.byKey(const ValueKey('imu-side-TAIL_DOWN')), findsOneWidget); await tester.tap(find.byKey(const ValueKey('wizard-close'))); await tester.pumpAndSettle(); expect(find.byType(ImuWizard), findsNothing, reason: 'no stop to confirm'); expect(dji.calls, ['startIMUCalibration']); }); testWidgets('RC: watches the sticks, steps MIDDLE → LIMITS → QUIT, unwatches on the way out', (WidgetTester tester) async { final Rig r = Rig(); final FakeDjiService dji = await open(tester, r, CalibrationKind.rc); expect(dji.calls, ['watchRcHardware(true)']); await tester.tap(find.byKey(const ValueKey('wizard-start'))); await tester.pump(); expect(dji.calls.last, 'setRcCalibrationStep(MIDDLE)'); r.calibration({'rc': {'state': 'MIDDLE'}}); await tester.pump(); await tester.tap(find.byKey(const ValueKey('wizard-next'))); await tester.pump(); expect(dji.calls.last, 'setRcCalibrationStep(LIMITS)'); r.calibration({ 'rc': {'state': 'LIMITS'}, 'rcHardware': {'lx': 660, 'ly': 660, 'rx': -660, 'ry': -660}, }); await tester.pump(); expect(heading(tester), 'Move every stick and dial to its limits'); await tester.tap(find.byKey(const ValueKey('wizard-finish'))); await tester.pump(); expect(dji.calls.last, 'setRcCalibrationStep(QUIT)'); expect(heading(tester), 'Remote controller calibrated'); await tester.tap(find.byKey(const ValueKey('wizard-done'))); await tester.pumpAndSettle(); expect(dji.calls.last, 'watchRcHardware(false)'); }); testWidgets('sensor cleaning: the buttons follow the camera, Stop leaves the mode', (WidgetTester tester) async { final Rig r = Rig(profile: withLens(inspire2X5s)); final FakeDjiService dji = await open(tester, r, CalibrationKind.sensorCleaning); await tester.tap(find.byKey(const ValueKey('wizard-start'))); await tester.pump(); r.calibration({'sensorCleaning': 'READY'}); await tester.pump(); await tester.tap(find.byKey(const ValueKey('wizard-clean'))); await tester.pump(); expect(dji.calls, ['initSensorCleaning', 'startSensorCleaning']); await tester.tap(find.byKey(const ValueKey('wizard-stop'))); await tester.pump(); expect(dji.calls.last, 'exitSensorCleaning'); expect(heading(tester), 'Clean the image sensor', reason: 'back at the start'); }); testWidgets('the hub lists the offered wizards and opens one', (WidgetTester tester) async { tester.view.physicalSize = const Size(844, 390); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final Rig r = Rig(); r.calibration({ 'support': {'gravityCenter': false}, }); await tester.pumpWidget(MaterialApp(home: CalibrationHubPage(model: r.model, dji: FakeDjiService(), onError: (_, __) {}))); expect(find.byKey(const ValueKey('hub-compass')), findsOneWidget); expect(find.byKey(const ValueKey('hub-gravityCenter')), findsNothing); expect(find.byKey(const ValueKey('hub-lens')), findsNothing); await tester.tap(find.byKey(const ValueKey('hub-imu'))); await tester.pumpAndSettle(); expect(find.byType(ImuWizard), findsOneWidget); }); testWidgets('Aircraft Status: Calibrate opens the compass wizard instead of starting at once', (WidgetTester tester) async { tester.view.physicalSize = const Size(844, 390); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final Rig r = Rig(); final FakeDjiService dji = FakeDjiService(); await tester.pumpWidget(MaterialApp( home: Builder( builder: (BuildContext context) => TextButton( onPressed: () => showAircraftStatusSheet(context, model: r.model, dji: dji, onError: (_, __) {}), child: const Text('status'), ), ), )); await tester.tap(find.text('status')); await settle(tester); final Finder calibrate = find.byKey(const ValueKey('status-action-compass')); await tester.scrollUntilVisible(calibrate, 60, scrollable: find.byType(Scrollable).last); await tester.tap(calibrate); await settle(tester); expect(find.byType(CompassWizard), findsOneWidget); expect(dji.calls, isNot(contains('startCompassCalibration'))); }); testWidgets('gimbal adjust lays the horizon over the screen and nudges each axis', (WidgetTester tester) async { final Rig r = Rig(profile: inspire2X5s); final FakeDjiService dji = await open(tester, r, CalibrationKind.gimbalAdjust); expect(find.byType(GimbalAdjustOverlay), findsOneWidget); expect(find.text('open'), findsOneWidget, reason: 'the page underneath stays visible'); await tester.tap(find.byKey(const ValueKey('step-adjustRoll-plus'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('step-adjustPitch-minus'))); await tester.pump(); expect(dji.calls, ['fineTuneGimbal(roll, 0.1)', 'fineTuneGimbal(pitch, -0.1)']); await tester.tap(find.byKey(const ValueKey('adjust-done'))); await tester.pumpAndSettle(); expect(find.byType(GimbalAdjustOverlay), findsNothing); }); }); }