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>
168 lines
8.0 KiB
Dart
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');
|
|
});
|
|
});
|
|
}
|