The "•••" button now opens a right-side glass sheet over the live view with Safety, Vision, Remote, Video link, Battery, Gimbal and General tabs. Each row is a one-line spec bound to a KeyManager key: rows hide when the aircraft does not support their key, writes show at once and roll back if refused, and ground-only settings lock in flight. KeyBridge gains argument-less action keys and converts range and LED settings values. settings_menu_page.dart is retired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
17 KiB
Dart
314 lines
17 KiB
Dart
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<String, dynamic> profile, {bool flying = false}) {
|
|
final FlightModel m = FlightModel()
|
|
..connected = true
|
|
..profile = ProductProfile.fromBridge(profile);
|
|
final Map<String, dynamic> t = telemetryTick();
|
|
t['isFlying'] = flying;
|
|
t['areMotorsOn'] = flying;
|
|
TelemetryReducer(m).apply(t);
|
|
return m;
|
|
}
|
|
|
|
void seed(KeyState s, Map<DjiKey, Object?> values) {
|
|
values.forEach((DjiKey k, Object? v) => s.apply(k.id, v));
|
|
}
|
|
|
|
T spec<T extends SettingSpec>(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<KeyNumberSpec>(SettingsTabs.safety, 'maxAltitude');
|
|
expect(alt.range(s), (20, 500), reason: 'fallback until the range key reports');
|
|
seed(s, <DjiKey, Object?>{
|
|
SettingsKeys.maxHeight: 120,
|
|
SettingsKeys.maxHeightRange: <String, dynamic>{'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, <DjiKey, Object?>{SettingsKeys.lowBattery: 25, SettingsKeys.criticalBattery: 20});
|
|
final KeyNumberSpec low = spec<KeyNumberSpec>(SettingsTabs.battery, 'lowBattery');
|
|
final KeyNumberSpec critical = spec<KeyNumberSpec>(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<KeyChoiceSpec>(SettingsTabs.transmission, 'osBand');
|
|
expect(band.optionsFor(s).length, 4);
|
|
seed(s, <DjiKey, Object?>{
|
|
SettingsKeys.osBands: <Object?>['FREQUENCY_BAND_2_DOT_4_GHZ', 'FREQUENCY_BAND_5_DOT_8_GHZ'],
|
|
SettingsKeys.osBand: 'FREQUENCY_BAND_2_DOT_4_GHZ',
|
|
});
|
|
expect(<String>[for (final (String v, _) in band.optionsFor(s)) v], <String>['FREQUENCY_BAND_2_DOT_4_GHZ', 'FREQUENCY_BAND_5_DOT_8_GHZ']);
|
|
final KeyChoiceSpec stick = spec<KeyChoiceSpec>(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<KeySwitchSpec>(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<KeyNumberSpec>(SettingsTabs.safety, 'maxDistance');
|
|
expect(distance.visible(c()), isFalse);
|
|
m.keys.apply(SettingsKeys.maxRadiusEnabled.id, true);
|
|
expect(distance.visible(c()), isTrue);
|
|
|
|
final KeySwitchSpec beginner = spec<KeySwitchSpec>(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<KeyChoiceSpec>(SettingsTabs.transmission, 'osChannelMode').visible(osc), isTrue);
|
|
expect(spec<KeyChoiceSpec>(SettingsTabs.transmission, 'osChannelMode').visible(lbc), isFalse);
|
|
expect(spec<KeyChoiceSpec>(SettingsTabs.transmission, 'lbBand').visible(lbc), isTrue);
|
|
expect(spec<KeyChoiceSpec>(SettingsTabs.transmission, 'wifiBand').visible(osc), isFalse);
|
|
|
|
List<String> groups(Map<String, dynamic> p) => <String>[
|
|
for (final SettingSpec r in SettingsTabs.all(ProductProfile.fromBridge(p)).firstWhere((SettingsTab t) => t.id == 'battery').rows)
|
|
if (r is GroupSpec) r.label,
|
|
];
|
|
expect(groups(phantom4), <String>['WARNINGS', 'BATTERY']);
|
|
expect(groups(inspire2X5s), <String>['WARNINGS', 'BATTERY 1', 'BATTERY 2']);
|
|
final KeyReadoutSpec cells = spec<KeyReadoutSpec>(SettingsTabs.battery, 'cells1', ProductProfile.fromBridge(inspire2X5s));
|
|
expect(cells.key.index, 1);
|
|
});
|
|
|
|
test('formatting', () {
|
|
expect(formatCells(<Object?>[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(<String, dynamic>{'type': 'key', 'id': SettingsKeys.novice.id, 'value': true}), isTrue);
|
|
expect(m.keys.value(SettingsKeys.novice), isTrue);
|
|
m.clearTelemetry();
|
|
m.connected = false;
|
|
r.apply(<String, dynamic>{'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(<DjiKey>[SettingsKeys.novice, SettingsKeys.escBeep, SettingsKeys.novice]);
|
|
expect(dji.calls, <String>['listenKey(NOVICE_MODE_ENABLED)']);
|
|
expect(s.supported(SettingsKeys.escBeep), isFalse);
|
|
expect(s.supported(SettingsKeys.novice), isTrue);
|
|
subs.dispose();
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(dji.calls.last, 'cancelKey');
|
|
});
|
|
});
|
|
|
|
group('Sheet', () {
|
|
Future<FakeDjiService> 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<String>('settings-button')));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
return d;
|
|
}
|
|
|
|
Future<void> tab(WidgetTester tester, String id) async {
|
|
await tester.tap(find.byKey(ValueKey<String>('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 <String>['safety', 'vision', 'rc', 'transmission', 'battery', 'gimbal', 'general']) {
|
|
expect(find.byKey(ValueKey<String>('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<String>('settings-close')));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(const ValueKey<String>('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, <DjiKey, Object?>{SettingsKeys.smartRth: false});
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await tester.tap(find.byKey(const ValueKey<String>('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<String>('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, <DjiKey, Object?>{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<String>('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<String>('spec-sensors')), 200,
|
|
scrollable: find.descendant(of: find.byKey(const ValueKey<String>('settings-list-safety')), matching: find.byType(Scrollable)));
|
|
expect(find.byKey(const ValueKey<String>('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, <DjiKey, Object?>{SettingsKeys.goHomeHeight: 100});
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await tester.tap(find.byKey(const ValueKey<String>('value-rthAltitude')));
|
|
await tester.pumpAndSettle();
|
|
await tester.enterText(find.byKey(const ValueKey<String>('number-input')), '900');
|
|
await tester.tap(find.byKey(const ValueKey<String>('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, <DjiKey, Object?>{SettingsKeys.novice: false});
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await tester.scrollUntilVisible(find.byKey(const ValueKey<String>('row-beginner')), 200,
|
|
scrollable: find.descendant(of: find.byKey(const ValueKey<String>('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<String>('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, <DjiKey, Object?>{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<String>('row-stickMode')));
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const ValueKey<String>('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<String>('action-startPairing')), 200,
|
|
scrollable: find.descendant(of: find.byKey(const ValueKey<String>('settings-list-rc')), matching: find.byType(Scrollable)));
|
|
await tester.ensureVisible(find.byKey(const ValueKey<String>('action-startPairing')));
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const ValueKey<String>('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, <DjiKey, Object?>{SettingsKeys.osChannelMode: 'AUTO'});
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await tab(tester, 'vision');
|
|
expect(find.byKey(const ValueKey<String>('row-collision')), findsOneWidget);
|
|
await tab(tester, 'transmission');
|
|
expect(find.byKey(const ValueKey<String>('row-osChannelMode')), findsOneWidget);
|
|
expect(find.byKey(const ValueKey<String>('row-osChannel')), findsNothing, reason: 'Auto picks the channel');
|
|
expect(find.byKey(const ValueKey<String>('row-lbBand')), findsNothing);
|
|
await tester.tap(find.byKey(const ValueKey<String>('row-osChannelMode')));
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const ValueKey<String>('opt-MANUAL')));
|
|
await tester.pump();
|
|
expect(dji.calls, contains('setKey(CHANNEL_SELECTION_MODE, MANUAL)'));
|
|
expect(find.byKey(const ValueKey<String>('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<String>('row-showRoute')), findsOneWidget);
|
|
expect(find.byKey(const ValueKey<String>('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);
|
|
});
|
|
});
|
|
}
|