import 'package:flutter/material.dart'; import 'package:flutter/services.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/product_profile.dart'; import 'package:pilotvault_fly/fly/settings/settings_spec.dart'; import 'package:pilotvault_fly/fly/settings/settings_tabs.dart'; import 'package:pilotvault_fly/fly/state/key_state.dart'; import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; import 'package:pilotvault_fly/ui/fly/fly_screen.dart'; import 'package:pilotvault_fly/ui/fly/panels/camera_write.dart'; import 'package:pilotvault_fly/ui/fly/panels/gimbal_sheet.dart'; import 'fake_dji.dart'; import 'profile_fixtures.dart'; import 'telemetry_fixtures.dart'; FlightModel connectedModel(Map profile, {bool flying = false}) { final FlightModel m = FlightModel() ..connected = true ..profile = ProductProfile.fromBridge(profile); final Map t = telemetryTick(); t['isFlying'] = flying; t['areMotorsOn'] = flying; TelemetryReducer(m).apply(t); return m; } void seed(KeyState s, Map values) { values.forEach((DjiKey k, Object? v) => s.apply(k.id, v)); } T spec(String tab, String id, [ProductProfile p = ProductProfile.disconnected]) => SettingsTabs.all(p).firstWhere((SettingsTab t) => t.id == tab).rows.firstWhere((SettingSpec r) => r.id == id) as T; void main() { group('Specs', () { test('number rows take the aircraft\'s range and step within it', () { final KeyState s = KeyState(); final KeyNumberSpec alt = spec(SettingsTabs.safety, 'maxAltitude'); expect(alt.range(s), (20, 500), reason: 'fallback until the range key reports'); seed(s, { SettingsKeys.maxHeight: 120, SettingsKeys.maxHeightRange: {'supported': true, 'min': 20.0, 'max': 125.0}, }); expect(alt.range(s), (20, 125)); expect(alt.stepped(s, 1), 125, reason: 'clamped to the max'); expect(alt.stepped(s, -1), 110); s.apply(SettingsKeys.maxHeight.id, 125); expect(alt.stepped(s, 1), isNull, reason: 'nothing to step to'); }); test('low and critical battery keep 5 % apart', () { final KeyState s = KeyState(); seed(s, {SettingsKeys.lowBattery: 25, SettingsKeys.criticalBattery: 20}); final KeyNumberSpec low = spec(SettingsTabs.battery, 'lowBattery'); final KeyNumberSpec critical = spec(SettingsTabs.battery, 'criticalBattery'); expect(low.range(s), (25, 50)); expect(low.stepped(s, -1), isNull); expect(critical.range(s), (10, 20)); expect(critical.stepped(s, 1), isNull); expect(critical.stepped(s, -1), 19); }); test('choices follow the supported list and keep an unknown current value', () { final KeyState s = KeyState(); final KeyChoiceSpec band = spec(SettingsTabs.transmission, 'osBand'); expect(band.optionsFor(s).length, 4); seed(s, { SettingsKeys.osBands: ['FREQUENCY_BAND_2_DOT_4_GHZ', 'FREQUENCY_BAND_5_DOT_8_GHZ'], SettingsKeys.osBand: 'FREQUENCY_BAND_2_DOT_4_GHZ', }); expect([for (final (String v, _) in band.optionsFor(s)) v], ['FREQUENCY_BAND_2_DOT_4_GHZ', 'FREQUENCY_BAND_5_DOT_8_GHZ']); final KeyChoiceSpec stick = spec(SettingsTabs.rc, 'stickMode'); s.apply(SettingsKeys.stickMode.id, 'STYLE_CUSTOM'); expect(stick.optionsFor(s).last, ('STYLE_CUSTOM', 'Style custom')); }); test('unsupported keys hide, dependent rows follow their switch, ground-only rows lock in flight', () { final FlightModel m = connectedModel(mavic2Zoom); SettingsContext c() => SettingsContext(profile: m.profile, keys: m.keys, aircraft: m.aircraft); final KeySwitchSpec smartRth = spec(SettingsTabs.safety, 'smartRth'); expect(smartRth.visible(c()), isTrue, reason: 'unknown support still shows'); m.keys.setSupported(SettingsKeys.smartRth, false); expect(smartRth.visible(c()), isFalse); final KeyNumberSpec distance = spec(SettingsTabs.safety, 'maxDistance'); expect(distance.visible(c()), isFalse); m.keys.apply(SettingsKeys.maxRadiusEnabled.id, true); expect(distance.visible(c()), isTrue); final KeySwitchSpec beginner = spec(SettingsTabs.safety, 'beginner'); expect(beginner.lockedReason(c()), isNull); final FlightModel air = connectedModel(mavic2Zoom, flying: true); expect(beginner.lockedReason(SettingsContext(profile: air.profile, keys: air.keys, aircraft: air.aircraft)), contains('Land')); expect(beginner.lockedReason(SettingsContext(profile: ProductProfile.disconnected, keys: KeyState(), aircraft: air.aircraft)), contains('Connect')); }); test('transmission rows follow the link type; battery groups repeat per battery', () { final ProductProfile os = ProductProfile.fromBridge(mavic2Zoom); final ProductProfile lb = ProductProfile.fromBridge(phantom4); final SettingsContext osc = SettingsContext(profile: os, keys: KeyState(), aircraft: connectedModel(mavic2Zoom).aircraft); final SettingsContext lbc = SettingsContext(profile: lb, keys: KeyState(), aircraft: connectedModel(phantom4).aircraft); expect(spec(SettingsTabs.transmission, 'osChannelMode').visible(osc), isTrue); expect(spec(SettingsTabs.transmission, 'osChannelMode').visible(lbc), isFalse); expect(spec(SettingsTabs.transmission, 'lbBand').visible(lbc), isTrue); expect(spec(SettingsTabs.transmission, 'wifiBand').visible(osc), isFalse); List groups(Map p) => [ for (final SettingSpec r in SettingsTabs.all(ProductProfile.fromBridge(p)).firstWhere((SettingsTab t) => t.id == 'battery').rows) if (r is GroupSpec) r.label, ]; // The pair and cell-voltage groups are always listed and gated per profile. expect(groups(phantom4), ['WARNINGS', 'BATTERY', 'BATTERY PAIR', 'CELL VOLTAGE']); expect(groups(inspire2X5s), ['WARNINGS', 'BATTERY 1', 'BATTERY 2', 'BATTERY PAIR', 'CELL VOLTAGE']); final SettingsTab p4 = SettingsTabs.all(lb).firstWhere((SettingsTab t) => t.id == 'battery'); final SettingsTab i2 = SettingsTabs.all(ProductProfile.fromBridge(inspire2X5s)).firstWhere((SettingsTab t) => t.id == 'battery'); final SettingsContext i2c = SettingsContext( profile: ProductProfile.fromBridge(inspire2X5s), keys: KeyState(), aircraft: connectedModel(inspire2X5s).aircraft); expect(p4.rows.firstWhere((SettingSpec r) => r.id == 'group-BATTERY PAIR').visible(lbc), isFalse, reason: 'one pack, nothing to pair'); expect(i2.rows.firstWhere((SettingSpec r) => r.id == 'group-BATTERY PAIR').visible(i2c), isTrue); expect(i2.rows.firstWhere((SettingSpec r) => r.id == 'group-CELL VOLTAGE').visible(i2c), isFalse, reason: 'smart batteries'); final KeyReadoutSpec cells = spec(SettingsTabs.battery, 'cells1', ProductProfile.fromBridge(inspire2X5s)); expect(cells.key.index, 1); }); test('formatting', () { expect(formatCells([3850, 3861]), '3.85 3.86 V'); expect(formatValue('FREQUENCY_BAND_2_DOT_4_GHZ'), 'Frequency band 2.4 ghz'); expect(formatValue(true), 'On'); expect(feetHint(120), '≈ 394 ft'); }); }); group('Key state', () { test('key events reach the model only while connected', () { final FlightModel m = connectedModel(phantom4); final TelemetryReducer r = TelemetryReducer(m); expect(r.apply({'type': 'key', 'id': SettingsKeys.novice.id, 'value': true}), isTrue); expect(m.keys.value(SettingsKeys.novice), isTrue); m.clearTelemetry(); m.connected = false; r.apply({'type': 'key', 'id': SettingsKeys.novice.id, 'value': false}); expect(m.keys.value(SettingsKeys.novice), isNull); }); test('subscriptions listen only to supported keys and cancel on dispose', () async { final FakeDjiService dji = FakeDjiService()..unsupportedKeys.add('ESC_BEEPING_ENABLED'); final KeyState s = KeyState(); final KeySubscriptions subs = KeySubscriptions(dji, s); await subs.watch([SettingsKeys.novice, SettingsKeys.escBeep, SettingsKeys.novice]); expect(dji.calls, ['listenKey(NOVICE_MODE_ENABLED)']); expect(s.supported(SettingsKeys.escBeep), isFalse); expect(s.supported(SettingsKeys.novice), isTrue); subs.dispose(); await Future.delayed(Duration.zero); expect(dji.calls.last, 'cancelKey'); }); }); group('Sheet', () { Future pump(WidgetTester tester, FlightModel m, {FakeDjiService? dji}) async { tester.view.physicalSize = const Size(844, 390); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final FakeDjiService d = dji ?? FakeDjiService(); await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: d))); await tester.pump(const Duration(milliseconds: 50)); await tester.tap(find.byKey(const ValueKey('settings-button'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); return d; } Future tab(WidgetTester tester, String id) async { await tester.tap(find.byKey(ValueKey('settings-tab-$id'))); await tester.pump(); await tester.pump(); } testWidgets('opens over the video with the product\'s tabs', (WidgetTester tester) async { final FakeDjiService dji = await pump(tester, connectedModel(mavic2Zoom)); for (final String t in ['safety', 'vision', 'rc', 'transmission', 'battery', 'gimbal', 'general']) { expect(find.byKey(ValueKey('settings-tab-$t')), findsOneWidget, reason: t); } expect(find.byType(FlyScreen), findsOneWidget, reason: 'a sheet, not a route'); expect(dji.calls, contains('listenKey(GO_HOME_HEIGHT_IN_METERS)')); await tester.tap(find.byKey(const ValueKey('settings-close'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('settings-tab-safety')), findsNothing); expect(dji.calls.last, 'cancelKey'); }); testWidgets('a switch writes optimistically and rolls back on refusal', (WidgetTester tester) async { final FlightModel m = connectedModel(mavic2Zoom); seed(m.keys, {SettingsKeys.smartRth: false}); final FakeDjiService dji = await pump(tester, m); await tester.tap(find.byKey(const ValueKey('row-smartRth'))); await tester.pump(); expect(dji.calls, contains('setKey(SMART_RETURN_TO_HOME_ENABLED, true)')); expect(m.keys.value(SettingsKeys.smartRth), isTrue); await tester.pump(cameraSettle); expect(m.keys.value(SettingsKeys.smartRth), isFalse, reason: 'never confirmed'); dji.failWith = PlatformException(code: 'DJI_ERROR', message: 'Not supported'); await tester.tap(find.byKey(const ValueKey('row-smartRth'))); await tester.pump(); await tester.pump(); expect(m.keys.value(SettingsKeys.smartRth), isFalse); expect(find.textContaining('Not supported'), findsOneWidget); }); testWidgets('a confirmed write stays; unsupported rows are gone', (WidgetTester tester) async { final FlightModel m = connectedModel(mavic2Zoom); seed(m.keys, {SettingsKeys.goHomeHeight: 100}); final FakeDjiService dji = FakeDjiService() ..echo = m.keys ..unsupportedKeys.add('ESC_BEEPING_ENABLED'); await pump(tester, m, dji: dji); expect(find.text('≈ 328 ft'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('step-rthAltitude-plus'))); await tester.pump(cameraSettle); expect(dji.calls, contains('setKey(GO_HOME_HEIGHT_IN_METERS, 110)')); expect(find.text('110 m'), findsOneWidget); await tester.scrollUntilVisible(find.byKey(const ValueKey('spec-sensors')), 200, scrollable: find.descendant(of: find.byKey(const ValueKey('settings-list-safety')), matching: find.byType(Scrollable))); expect(find.byKey(const ValueKey('row-escBeep')), findsNothing); }); testWidgets('a far-off number can be typed, clamped to the range', (WidgetTester tester) async { final FlightModel m = connectedModel(mavic2Zoom); seed(m.keys, {SettingsKeys.goHomeHeight: 100}); final FakeDjiService dji = await pump(tester, m); await tester.tap(find.byKey(const ValueKey('value-rthAltitude'))); await tester.pumpAndSettle(); await tester.enterText(find.byKey(const ValueKey('number-input')), '900'); await tester.tap(find.byKey(const ValueKey('number-set'))); await tester.pumpAndSettle(); expect(dji.calls, contains('setKey(GO_HOME_HEIGHT_IN_METERS, 500)')); await tester.pump(cameraSettle); }); testWidgets('ground-only rows lock in flight', (WidgetTester tester) async { final FlightModel m = connectedModel(mavic2Zoom, flying: true); seed(m.keys, {SettingsKeys.novice: false}); final FakeDjiService dji = await pump(tester, m); await tester.scrollUntilVisible(find.byKey(const ValueKey('row-beginner')), 200, scrollable: find.descendant(of: find.byKey(const ValueKey('settings-list-safety')), matching: find.byType(Scrollable))); expect(find.text('Land and stop the motors to change'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('row-beginner')), warnIfMissed: false); await tester.pump(); expect(dji.calls.where((String c) => c.startsWith('setKey')), isEmpty); }); testWidgets('remote tab: stick mode with its diagram, linking is slide-confirmed', (WidgetTester tester) async { final FlightModel m = connectedModel(mavic2Zoom); seed(m.keys, {SettingsKeys.stickMode: 'STYLE_2', SettingsKeys.pairingState: 'PAIRED'}); final FakeDjiService dji = await pump(tester, m); await tab(tester, 'rc'); expect(find.text('Mode 2'), findsOneWidget); expect(find.text('Up-down: Up / down'), findsOneWidget, reason: 'Mode 2 left stick is throttle'); await tester.tap(find.byKey(const ValueKey('row-stickMode'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('opt-STYLE_1'))); await tester.pump(); expect(dji.calls, contains('setKey(AIRCRAFT_MAPPING_STYLE, STYLE_1)')); expect(find.text('Up-down: Forward / back'), findsOneWidget); await tester.pump(cameraSettle); await tester.scrollUntilVisible(find.byKey(const ValueKey('action-startPairing')), 200, scrollable: find.descendant(of: find.byKey(const ValueKey('settings-list-rc')), matching: find.byType(Scrollable))); await tester.ensureVisible(find.byKey(const ValueKey('action-startPairing'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('action-startPairing'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.textContaining("aircraft's link button"), findsOneWidget, reason: 'the slide-to-confirm dialog'); expect(dji.calls.where((String c) => c.startsWith('performKeyAction')), isEmpty, reason: 'nothing runs before the slide'); }); testWidgets('vision tab on an obstacle-sensing aircraft; transmission shows the link\'s rows', (WidgetTester tester) async { final FlightModel m = connectedModel(mavic2Zoom); seed(m.keys, {SettingsKeys.osChannelMode: 'AUTO'}); final FakeDjiService dji = await pump(tester, m); await tab(tester, 'vision'); expect(find.byKey(const ValueKey('row-collision')), findsOneWidget); await tab(tester, 'transmission'); expect(find.byKey(const ValueKey('row-osChannelMode')), findsOneWidget); expect(find.byKey(const ValueKey('row-osChannel')), findsNothing, reason: 'Auto picks the channel'); expect(find.byKey(const ValueKey('row-lbBand')), findsNothing); await tester.tap(find.byKey(const ValueKey('row-osChannelMode'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('opt-MANUAL'))); await tester.pump(); expect(dji.calls, contains('setKey(CHANNEL_SELECTION_MODE, MANUAL)')); // The spectrum chart above pushes the channel row down the lazy list. await tester.scrollUntilVisible(find.byKey(const ValueKey('row-osChannel')), 80, scrollable: find.descendant(of: find.byKey(const ValueKey('settings-list-transmission')), matching: find.byType(Scrollable))); expect(find.byKey(const ValueKey('row-osChannel')), findsOneWidget); await tester.pump(cameraSettle); }); testWidgets('gimbal tab hosts the gimbal list; general clears the route', (WidgetTester tester) async { final FlightModel m = connectedModel(mavic2Zoom); await pump(tester, m); await tab(tester, 'gimbal'); expect(find.byType(GimbalSettingsList), findsOneWidget); await tab(tester, 'general'); expect(find.byKey(const ValueKey('row-showRoute')), findsOneWidget); expect(find.byKey(const ValueKey('action-clearRoute')), findsOneWidget); expect(find.text('Mavic 2 Zoom'), findsNothing, reason: 'About shows the model the connection reported'); }); testWidgets('without a connection nothing is asked and rows show as previews', (WidgetTester tester) async { final FlightModel m = FlightModel(); final FakeDjiService dji = await pump(tester, m); expect(find.text('NOT CONNECTED'), findsOneWidget); expect(dji.calls.where((String c) => c.startsWith('listenKey')), isEmpty); expect(find.text('Connect the aircraft to change'), findsWidgets); }); }); }