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

168 lines
8.0 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/fly_prefs.dart';
import 'package:pilotvault_fly/fly/gimbal_control.dart';
import 'package:pilotvault_fly/fly/hud_features.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/aircraft_state.dart';
import 'package:pilotvault_fly/fly/state/fly_zone_state.dart';
import 'package:pilotvault_fly/fly/state/key_state.dart';
import 'package:pilotvault_fly/fly/state/link_extras.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/fly/units.dart';
import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/wizards.dart';
import 'package:pilotvault_fly/ui/fly/panels/settings/link_rows.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
FlightModel _connected(Map<String, dynamic> profile) => FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile);
void main() {
group('Status bar and safety extras (P2 / P3)', () {
test('RTK and compass interference ride in the telemetry tick', () {
final FlightModel m = _connected(phantom4);
TelemetryReducer(m).apply(telemetryTick()
..['rtk'] = <String, dynamic>{'solution': 'FIXED_POINT', 'inUse': true, 'satellites': 24}
..['compassSensors'] = <Object?>[
<String, dynamic>{'index': 0, 'state': 'NORMAL_MODULUS', 'value': 1500},
<String, dynamic>{'index': 1, 'state': 'SERIOUS_MODULUS', 'value': 2600},
]);
expect(m.aircraft.rtk!.label, 'FIX');
expect(m.aircraft.rtk!.fixed, isTrue);
expect(m.aircraft.compassSensors.map((CompassSensor c) => c.level), <int?>[0, 2]);
TelemetryReducer(m).apply(telemetryTick());
expect(m.aircraft.rtk, isNull, reason: 'no module, no chip');
});
test('Precision Takeoff follows the flight controller once it answered', () {
ProductProfile with_(bool? supported) => ProductProfile.fromBridge(<String, dynamic>{
...phantom4,
'flightController': <String, dynamic>{...(phantom4['flightController'] as Map<String, dynamic>), 'precisionTakeOffSupported': supported},
});
expect(HudFeatures.precisionTakeoff.enabledFor(with_(null)), ProductProfile.fromBridge(phantom4).hasVps);
expect(HudFeatures.precisionTakeoff.enabledFor(with_(false)), isFalse);
expect(HudFeatures.precisionTakeoff.enabledFor(with_(true)), isTrue);
});
});
group('Gimbal presets (P5)', () {
test('Fast / Medium / Slow write fractions of each range and are recognised back', () {
const Map<String, AxisRange> ranges = <String, AxisRange>{
'pitchMaxSpeed': AxisRange(0, 100),
'pitchSmoothing': AxisRange(0, 30),
};
expect(GimbalPreset.fast.valuesFor(ranges), <String, int>{'pitchMaxSpeed': 80, 'pitchSmoothing': 6});
final Map<String, int> slow = GimbalPreset.slow.valuesFor(ranges);
expect(GimbalPreset.matching(ranges, (String id) => slow[id]), GimbalPreset.slow);
expect(GimbalPreset.matching(ranges, (String id) => id == 'pitchMaxSpeed' ? 50 : 1), isNull, reason: 'a custom mix');
});
});
group('Settings extras (P6)', () {
test('units', () {
expect(Units.length(100, UnitSystem.imperial), '328 ft');
expect(Units.speed(10, UnitSystem.imperial), '22.4 mph');
expect(Units.distance(1500, UnitSystem.metric), '1.50 km');
expect(Units.distance(2000, UnitSystem.imperial), '1.24 mi');
});
test('spectrum: OcuSync bands and Lightbridge channels, the quietest marked', () {
final Spectrum os = Spectrum.fromEvent(<String, dynamic>{
'link': 'ocusync',
'bands': <Object?>[
<String, dynamic>{'from': 2400.0, 'to': 2410.0, 'rssi': -90},
<String, dynamic>{'from': 2410.0, 'to': 2420.0, 'rssi': -100},
],
});
expect(os.bars.map((SpectrumBar b) => b.label), <String>['2405', '2415']);
expect(os.quietest!.label, '2415');
final Spectrum lb = Spectrum.fromEvent(<String, dynamic>{
'link': 'lightbridge',
'channels': <Object?>[<String, dynamic>{'channel': 13, 'power': -70}],
});
expect(lb.bars.single.label, '13');
});
test('battery warning records read as what went wrong', () {
expect(formatWarningRecord(<String, dynamic>{'error': false}), 'None');
expect(formatWarningRecord(<String, dynamic>{'overHeated': true, 'lowVoltageCell': 3}), 'overheated, low cell 3');
expect(
formatWarningHistory(<Object?>[
<String, dynamic>{'error': false},
<String, dynamic>{'underVoltage': true},
]),
'1 of 2: under-voltage',
);
});
test('text rows: secrets never show; master / slave rows follow the role', () {
const KeyTextSpec pw = KeyTextSpec(id: 'pw', label: 'Password', key: DjiKey.wifi('PASSWORD'), secret: true);
final KeyState k = KeyState()..apply(pw.key.id, 'hunter22');
expect(pw.shown(k), '••••••');
final ProductProfile p = ProductProfile.fromBridge(inspire2X5s);
final SettingsTab rc = SettingsTabs.all(p).firstWhere((SettingsTab t) => t.id == SettingsTabs.rc);
final SettingSpec password = rc.rows.firstWhere((SettingSpec r) => r.id == 'rcLinkPassword');
final KeyState s = KeyState();
final FlightModel m = _connected(inspire2X5s);
SettingsContext ctx() => SettingsContext(profile: p, keys: s, aircraft: m.aircraft);
expect(password.visible(ctx()), isFalse, reason: 'single-operator remote');
s.apply(SettingsKeys.rcRole.id, 'MASTER');
expect(password.visible(ctx()), p.hasMasterSlaveRc);
});
test('a C1 press reaches the model as a new value each time', () {
final FlightModel m = _connected(phantom4);
final TelemetryReducer r = TelemetryReducer(m);
r.apply(<String, dynamic>{'type': 'rcButton', 'button': 'C1'});
final RcButtonPress first = m.rcButton.value!;
r.apply(<String, dynamic>{'type': 'rcButton', 'button': 'C1'});
expect(m.rcButton.value!.button, RcButton.c1);
expect(m.rcButton.value, isNot(first));
});
testWidgets('C1 / C2 choices are saved on the phone', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
await FlyPrefs.load();
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SingleChildScrollView(child: CustomButtonRows()))));
await tester.tap(find.byKey(const ValueKey<String>('row-rcC1')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('opt-mapSwap')));
await tester.pump();
expect(FlyPrefs.c1.value, RcButtonAction.mapSwap);
});
});
group('Calibration extras (P6b)', () {
testWidgets('the interference meter grades every compass', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Scaffold(
body: CompassInterferenceMeter(sensors: <CompassSensor>[CompassSensor(0, 'NORMAL_MODULUS', 1500), CompassSensor(1, 'WEAK_MODULUS', 2100)]),
),
));
expect(find.text('Low'), findsOneWidget);
expect(find.text('Moderate'), findsOneWidget);
expect(find.text('Compass 2'), findsOneWidget);
});
});
group('GEO database (P8)', () {
test('a newer precise database on the server marks it out of date, across re-reads', () {
final FlyZoneState z = FlyZoneState();
z.applyDatabase(<String, dynamic>{'preciseVersion': '1.0', 'preciseState': 'UP_TO_DATE'});
expect(z.database!.outOfDate, isFalse);
z.applyRemoteVersion('1.1');
expect(z.database!.preciseBehind, isTrue);
z.applyDatabase(<String, dynamic>{'preciseVersion': '1.0', 'preciseState': 'UP_TO_DATE'});
expect(z.database!.outOfDate, isTrue, reason: 'the remote version is kept');
});
});
}