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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 23:06:16 +02:00
1 parent 91ca150a35
commit 636da1ac2c
57 files changed
+5363 -87

No files matched your search

+90 -2
View File
@@ -38,9 +38,13 @@ class FakeDjiService extends DjiService {
/// The last `uploadWaypointMission` payload.
Map<String, Object?>? uploaded;
/// Whether the last upload went to the Waypoints 2.0 operator.
bool uploadedV2 = false;
@override
Future<void> uploadWaypointMission(Map<String, Object?> mission) {
Future<void> uploadWaypointMission(Map<String, Object?> mission, {bool v2 = false}) {
uploaded = mission;
uploadedV2 = v2;
return _record('uploadWaypointMission(${(mission['waypoints'] as List<Object?>).length})');
}
@@ -50,6 +54,90 @@ class FakeDjiService extends DjiService {
Future<void> pauseWaypointMission() => _record('pauseWaypointMission');
@override
Future<void> resumeWaypointMission() => _record('resumeWaypointMission');
@override
Future<void> setWaypointAutoFlightSpeed(double speed) => _record('setWaypointAutoFlightSpeed(${speed.toStringAsFixed(0)})');
/// What `getWaypointInterruption` answers.
Map<String, dynamic>? interruption;
@override
Future<Map<String, dynamic>?> getWaypointInterruption() async => interruption;
// ── POI / Follow Me / orientation / QuickShots (plan P7) ───────────────────
Map<String, Object?>? hotpoint;
double? maxAngular;
@override
Future<void> startHotpoint({
required double lat,
required double lon,
required double altitude,
required double radius,
required double angularVelocity,
required bool clockwise,
String startPoint = 'NEAREST',
String heading = 'TOWARDS_HOT_POINT',
}) {
hotpoint = <String, Object?>{'lat': lat, 'lon': lon, 'altitude': altitude, 'radius': radius, 'angularVelocity': angularVelocity, 'clockwise': clockwise, 'startPoint': startPoint, 'heading': heading};
return _record('startHotpoint');
}
@override
Future<void> pauseHotpoint() => _record('pauseHotpoint');
@override
Future<void> resumeHotpoint() => _record('resumeHotpoint');
@override
Future<void> stopHotpoint() => _record('stopHotpoint');
@override
Future<void> setHotpointRadius(double radius) => _record('setHotpointRadius(${radius.round()})');
@override
Future<void> setHotpointAngularVelocity(double value) => _record('setHotpointAngularVelocity(${value.toStringAsFixed(1)})');
@override
Future<void> resetHotpointHeading() => _record('resetHotpointHeading');
@override
Future<double?> getHotpointMaxAngularVelocity(double radius) async => maxAngular;
@override
Future<void> startFollowMe({required double lat, required double lon, required double altitude, String heading = 'TOWARD_FOLLOW_POSITION'}) =>
_record('startFollowMe(${altitude.round()}, $heading)');
@override
Future<void> updateFollowMeTarget(double lat, double lon) => _record('updateFollowMeTarget');
@override
Future<void> stopFollowMe() => _record('stopFollowMe');
@override
Future<void> setFlightOrientationMode(String mode) => _record('setFlightOrientationMode($mode)');
@override
Future<void> lockCourseUsingCurrentHeading() => _record('lockCourseUsingCurrentHeading');
@override
Future<void> startQuickShot(String name, {double? x, double? y, double? w, double? h}) => _record('startQuickShot($name)');
@override
Future<void> setActiveTrackCircularSpeed(double speed) => _record('setActiveTrackCircularSpeed(${speed.toStringAsFixed(0)})');
@override
Future<void> setShootPhotoMode(String mode) => _record('setShootPhotoMode($mode)');
// ── Remote / link / cache (plan P6) ────────────────────────────────────────
@override
Future<void> searchMasters(bool on) => _record('searchMasters($on)');
@override
Future<String?> connectToMaster({required int id, required String name, required String password}) async {
await _record('connectToMaster($id, $name)');
return 'ACCEPTED';
}
@override
Future<List<Map<String, dynamic>>> getSlaves() async => const <Map<String, dynamic>>[];
@override
Future<void> watchSpectrum(bool on) async => calls.add('watchSpectrum($on)');
@override
Future<void> setWifiCredentials({String? ssid, String? password}) => _record('setWifiCredentials(${ssid ?? '-'}, ${password == null ? '-' : '***'})');
@override
Future<void> rebootWifi() => _record('rebootWifi');
@override
Future<void> setVideoCacheRecording(bool on, {int? limitMb}) async => calls.add('setVideoCacheRecording($on)');
@override
Future<Map<String, dynamic>> getVideoCacheInfo() async => <String, dynamic>{'bytes': 0, 'files': 0, 'recording': false};
@override
Future<void> clearVideoCache() => _record('clearVideoCache');
@override
Future<void> calibrateInfinityFocus() => _record('calibrateInfinityFocus');
// ── ActiveTrack / TapFly (plan P7b) ────────────────────────────────────────
@override
@@ -141,7 +229,7 @@ class FakeDjiService extends DjiService {
@override
Future<void> stopCompassCalibration() => _record('stopCompassCalibration');
@override
Future<void> startIMUCalibration({int? index}) => _record('startIMUCalibration');
Future<void> startIMUCalibration({int? index}) => _record(index == null ? 'startIMUCalibration' : 'startIMUCalibration($index)');
@override
Future<void> startGravityCenterCalibration() => _record('startGravityCenterCalibration');
@override
+167
View File
@@ -0,0 +1,167 @@
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');
});
});
}
+333
View File
@@ -0,0 +1,333 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import 'package:pilotvault_fly/flight_model.dart';
import 'package:pilotvault_fly/fly/action_rail_state.dart';
import 'package:pilotvault_fly/fly/fly_prefs.dart';
import 'package:pilotvault_fly/fly/modes/intelligent_modes.dart';
import 'package:pilotvault_fly/fly/modes/orbit_modes.dart';
import 'package:pilotvault_fly/fly/modes/terrain.dart';
import 'package:pilotvault_fly/fly/modes/vision_modes.dart';
import 'package:pilotvault_fly/fly/modes/waypoint_mission.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
import 'package:pilotvault_fly/fly/safety_gate.dart';
import 'package:pilotvault_fly/fly/state/mission_state.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart';
import 'package:pilotvault_fly/ui/fly/flight_command_flow.dart';
import 'package:pilotvault_fly/ui/fly/modes/mode_panels.dart';
import 'package:pilotvault_fly/ui/fly/modes/mode_picker.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
FlightModel _model(Map<String, dynamic> tick, {Map<String, dynamic>? profile}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile ?? phantom4);
TelemetryReducer(m).apply(tick);
return m;
}
Map<String, dynamic> _grounded() => telemetryTick()
..['isFlying'] = false
..['areMotorsOn'] = false;
List<String> _blockers(List<GateCheck> c) => <String>[for (final GateCheck g in c) if (g.blocks) g.message];
class _Ground implements ElevationSource {
_Ground(this.at);
/// Ground elevation for a point.
final double Function(LatLng p) at;
@override
Future<List<double>> elevations(List<LatLng> points) async => <double>[for (final LatLng p in points) at(p)];
}
void main() {
group('Point of Interest', () {
test('spec: °/s from the tangential speed, the speed capped at the aircraft\'s limit', () {
const PoiSpec p = PoiSpec(center: LatLng(55.676, 12.568), radius: 30, speed: 3);
expect(p.angularVelocity, closeTo(5.73, 0.01));
expect(PoiSpec.speedFor(p.angularVelocity, 30), closeTo(3, 1e-9));
expect(p.toBridge(maxAngular: 4)['angularVelocity'], 4);
expect(p.issues(maxAngular: 4).where(((bool, String) i) => !i.$1).map(((bool, String) i) => i.$2), contains(contains('at most')));
expect(const PoiSpec().issues().first.$1, isTrue, reason: 'no centre blocks');
expect(p.lapSeconds, 63);
});
test('gate: flying in P mode with GPS; refused on the ground or during another mode', () {
const PoiSpec p = PoiSpec(center: LatLng(55.676, 12.568));
final FlightModel air = _model(telemetryTick());
expect(_blockers(SafetyGate(model: air, dji: FakeDjiService()).checkPoi(p)), isEmpty);
expect(_blockers(SafetyGate(model: _model(_grounded()), dji: FakeDjiService()).checkPoi(p)), contains('Take off before starting Point of Interest.'));
air.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'tapFly', 'state': 'EXECUTING'});
expect(_blockers(SafetyGate(model: air, dji: FakeDjiService()).checkPoi(p)), contains('Stop the intelligent flight mode that is running first.'));
});
test('start, adjust and Stop go to the hotpoint operator', () async {
final FlightModel m = _model(telemetryTick());
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: m, dji: dji);
await g.startPoi(const PoiSpec(center: LatLng(55.676, 12.568), radius: 40, speed: 4, clockwise: false, heading: PoiHeading.forward));
expect(dji.hotpoint!['radius'], 40);
expect(dji.hotpoint!['clockwise'], isFalse);
expect(dji.hotpoint!['heading'], 'ALONG_CIRCLE_LOOKING_FORWARDS');
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'hotpoint', 'state': 'EXECUTING', 'radius': 40.0});
expect(m.mission.state, 'hotpoint');
expect(RailState.activeMode(m.aircraft, m.mission), ActiveMode.hotpoint);
await g.adjustPoi(speed: 2, clockwise: false, currentRadius: 40);
expect(dji.calls.last, 'setHotpointAngularVelocity(-2.9)', reason: 'anticlockwise is negative');
await g.run(FlightCommand.stopMode);
expect(dji.calls.last, 'stopHotpoint');
});
test('a finish with an error is reported once', () {
final MissionState ms = MissionState();
ms.apply(<String, dynamic>{'type': 'mission', 'operator': 'hotpoint', 'state': 'EXECUTING'});
ms.apply(<String, dynamic>{'type': 'mission', 'operator': 'hotpoint', 'state': 'READY_TO_EXECUTE', 'event': 'finish', 'error': 'GPS lost'});
expect(ms.modeResult, 'GPS lost');
expect(ms.modeFinishes, 1);
expect(ms.hotpoint.active, isFalse);
});
});
group('Follow Me', () {
test('needs the phone\'s fix; starts at the set height and streams the phone', () async {
final FlightModel m = _model(telemetryTick());
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: m, dji: dji);
expect(_blockers(g.checkFollowMe(const FollowMeSpec())), contains(contains('no location fix')));
m
..phoneLatitude = 55.6762
..phoneLongitude = 12.5684;
expect(_blockers(g.checkFollowMe(const FollowMeSpec())), isEmpty);
await g.startFollowMe(const FollowMeSpec(altitude: 25, heading: FollowMeHeading.remote));
expect(dji.calls.last, 'startFollowMe(25, CONTROLLED_BY_REMOTE_CONTROLLER)');
await g.updateFollowMe();
expect(dji.calls.last, isNot('updateFollowMeTarget'), reason: 'not following yet');
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'followMe', 'state': 'EXECUTING', 'distance': 12.0});
await g.updateFollowMe();
expect(dji.calls.last, 'updateFollowMeTarget');
expect(m.mission.followMe.distance, 12);
await g.run(FlightCommand.stopMode);
expect(dji.calls.last, 'stopFollowMe');
});
});
group('Orientation, Tripod, Hyperlapse', () {
test('Home Lock needs a home point 10 m away; Course Lock locks the heading', () async {
final FlightModel near = _model(telemetryTick()..['homeDistance'] = 4.0);
final FakeDjiService dji = FakeDjiService();
expect(_blockers(SafetyGate(model: near, dji: dji).checkOrientation(OrientationMode.homeLock)), contains(contains('10 m')));
final FlightModel m = _model(telemetryTick());
final SafetyGate g = SafetyGate(model: m, dji: dji);
await g.setOrientation(OrientationMode.courseLock);
expect(dji.calls.last, 'setFlightOrientationMode(COURSE_LOCK)');
await expectLater(g.lockCourse(), throwsA(isA<MissionRefused>()), reason: 'the FC still reports normal heading');
TelemetryReducer(m).apply(telemetryTick()..['orientationMode'] = 'COURSE_LOCK');
await g.lockCourse();
expect(dji.calls.last, 'lockCourseUsingCurrentHeading');
expect(OrientationMode.fromSdk('HOME_LOCK'), OrientationMode.homeLock);
});
test('Tripod and Cinematic are key writes behind the gate', () async {
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji);
await g.setSlowMode(cinematic: false, on: true);
expect(dji.calls.last, 'setKey(TRIPOD_MODE_ENABLED, true)');
await g.setSlowMode(cinematic: true, on: false);
expect(dji.calls.last, 'setKey(CINEMATIC_MODE_ENABLED, false)');
});
test('Free hyperlapse: frames from the video length, then the camera\'s interval mode', () async {
const HyperlapseSpec h = HyperlapseSpec(intervalSeconds: 3, videoSeconds: 10);
expect(h.frames, 250);
expect(h.shootingSeconds, 750);
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji);
await g.startHyperlapse(h);
expect(dji.calls, <String>['setCameraMode(photo)', 'setShootPhotoMode(INTERVAL)', 'setPhotoTimeInterval(250, 3)', 'startShootPhoto']);
// Over 254 frames the camera shoots "until stopped" and the gate stops it.
const HyperlapseSpec long = HyperlapseSpec(intervalSeconds: 2, videoSeconds: 20);
expect(long.cameraCount, 255);
dji.calls.clear();
await g.startHyperlapse(long);
expect(dji.calls, contains('setPhotoTimeInterval(255, 2)'));
await g.stopHyperlapse();
expect(dji.calls.last, 'stopShootPhoto');
});
});
group('QuickShots and ActiveTrack extras', () {
test('shots offered per product; the gate flies one on a boxed subject', () async {
expect(QuickShot.offered(omni: false, zoom: false), hasLength(4));
expect(QuickShot.offered(omni: true, zoom: true), contains(QuickShot.dollyZoom));
expect(TrackMode.offered(omni: false), isNot(contains(TrackMode.spotlightPro)));
expect(TrackMode.offered(omni: true), containsAll(<TrackMode>[TrackMode.spotlightPro, TrackMode.circle]));
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji);
expect(g.checkQuickShot(QuickShot.rocket).where((GateCheck c) => !c.blocks), isNotEmpty, reason: 'it always warns about the path');
await g.startQuickShot(const Rect.fromLTWH(0.4, 0.3, 0.2, 0.3), QuickShot.helix);
expect(dji.calls.last, 'startQuickShot(HELIX)');
await g.setTrackCircleSpeed(-4);
expect(dji.calls.last, 'setActiveTrackCircularSpeed(-4)');
});
test('QuickShot progress comes from the ActiveTrack operator', () {
final MissionState ms = MissionState();
ms.apply(<String, dynamic>{
'type': 'mission',
'operator': 'activeTrack',
'state': 'PERFORMING_QUICK_SHOT',
'tracking': <String, dynamic>{'percent': 40},
});
expect(ms.state, 'quickshot');
expect(ms.activeTrack.percent, 40);
});
});
group('Waypoints extras', () {
const WaypointMissionSpec route = WaypointMissionSpec(points: <WaypointSpec>[
WaypointSpec(at: LatLng(55.6760, 12.5680)),
WaypointSpec(at: LatLng(55.6770, 12.5680)),
WaypointSpec(at: LatLng(55.6780, 12.5680)),
WaypointSpec(at: LatLng(55.6790, 12.5680)),
]);
test('resume: an interruption on this route resumes from its waypoint', () async {
expect(route.from(2).points, hasLength(2));
expect(route.matchesInterruption(2, const LatLng(55.6775, 12.5680)), isTrue);
expect(route.matchesInterruption(2, const LatLng(55.70, 12.60)), isFalse);
final FakeDjiService dji = FakeDjiService()..interruption = <String, dynamic>{'index': 2, 'lat': 55.6775, 'lon': 12.5680};
final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji);
expect(await g.interruptionFor(route), 2);
dji.interruption = <String, dynamic>{'index': 3, 'lat': 55.6785, 'lon': 12.5680};
expect(await g.interruptionFor(route), isNull, reason: 'one waypoint left is not a route');
});
test('cruise speed in flight only while the mission flies', () async {
final FlightModel m = _model(telemetryTick());
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: m, dji: dji);
await expectLater(g.setWaypointCruiseSpeed(6), throwsA(isA<MissionRefused>()));
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'waypoint', 'state': 'EXECUTING'});
await g.setWaypointCruiseSpeed(20);
expect(dji.calls.last, 'setWaypointAutoFlightSpeed(15)');
});
test('a V2-only aircraft uploads to Waypoints 2.0 and is warned about actions', () async {
final Map<String, dynamic> m300 = <String, dynamic>{...mavic2Zoom, 'model': 'MATRICE_300_RTK'};
final FlightModel m = _model(telemetryTick(), profile: m300);
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: m, dji: dji);
expect(g.usesWaypointsV2, isTrue);
final WaypointMissionSpec shooting = route.copyWith(points: <WaypointSpec>[
for (final WaypointSpec p in route.points) p.copyWith(action: WaypointCameraAction.photo),
]);
expect(g.checkWaypointStart(shooting).map((GateCheck c) => c.message), contains(contains('Waypoints 2.0')));
await g.startWaypointMission(route);
expect(dji.uploadedV2, isTrue);
});
test('terrain: a hill between waypoints is found, flat ground is not', () async {
const TerrainCheck flat = TerrainCheck(_FlatGround());
expect(await flat.run(route, route.points.first.at), isEmpty);
// A 60 m hill around the second leg; the route flies 50 m above take-off.
final TerrainCheck hill = TerrainCheck(_Ground((LatLng p) => p.latitude > 55.6772 && p.latitude < 55.6778 ? 60 : 0));
final List<TerrainIssue> found = await hill.run(route, route.points.first.at);
expect(found.single.leg, 1);
expect(found.single.clearance, lessThan(0));
expect(found.single.toIssue().blocks, isTrue);
expect(hill.samples(route).length, lessThanOrEqualTo(100));
});
});
group('Catalogue and tutorials', () {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlyPrefs.seenTutorials.value = <String>{};
});
test('every mode is flyable and has a tutorial', () {
for (final ModeInfo i in ModeCatalog.all) {
expect(i.built, isTrue, reason: i.label);
expect(i.tutorial, isNotEmpty, reason: i.label);
}
});
testWidgets('a tutorial shows once, page by page', (WidgetTester tester) async {
late BuildContext ctx;
await tester.pumpWidget(MaterialApp(home: Builder(builder: (BuildContext c) {
ctx = c;
return const SizedBox();
})));
final ModeInfo poi = ModeCatalog.of(IntelligentMode.pointOfInterest);
final Future<void> shown = ensureModeTutorial(ctx, poi);
await tester.pumpAndSettle();
expect(find.text(poi.tutorial.first), findsOneWidget);
for (int i = 0; i < poi.tutorial.length; i++) {
await tester.tap(find.byKey(const ValueKey<String>('tutorial-next')));
await tester.pumpAndSettle();
}
await shown;
expect(FlyPrefs.seenTutorials.value, contains('pointOfInterest'));
ensureModeTutorial(ctx, poi);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey<String>('tutorial-step')), findsNothing, reason: 'seen once');
});
});
group('Mode panels', () {
Future<(FlightModel, FakeDjiService)> pump(WidgetTester tester, Widget Function(FlightModel m, SafetyGate g, FlightCommandFlow f) build) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final FlightModel m = _model(telemetryTick());
final FakeDjiService dji = FakeDjiService();
final SafetyGate g = SafetyGate(model: m, dji: dji);
final FlightCommandFlow f = FlightCommandFlow(gate: g, onError: (_, __) {}, reviewBeforeTakeOff: (_) async => true);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: Align(alignment: Alignment.topLeft, child: build(m, g, f)))));
return (m, dji);
}
testWidgets('POI: centre on the aircraft, GO slides to start', (WidgetTester tester) async {
final ModeSetup setup = ModeSetup();
final (FlightModel m, FakeDjiService dji) = await pump(
tester,
(FlightModel m, SafetyGate g, FlightCommandFlow f) =>
PoiPanel(model: m, setup: setup, gate: g, flow: f, dji: FakeDjiService(), onExit: () {}, onError: (_, __) {}),
);
expect(find.text('TAP THE MAP ON THE SUBJECT'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('poi-aircraft')));
await tester.pump();
expect(setup.poi.center, LatLng(m.aircraft.latitude!, m.aircraft.longitude!));
await tester.tap(find.byKey(const ValueKey<String>('poi-radius-plus')));
await tester.pump();
expect(setup.poi.radius, 35);
await tester.tap(find.byKey(const ValueKey<String>('mode-poi-go')));
await tester.pumpAndSettle();
expect(find.byType(SlideConfirmDialog), findsOneWidget);
expect(dji.calls, isEmpty);
});
testWidgets('Hyperlapse: picking the interval updates the frame count', (WidgetTester tester) async {
final ModeSetup setup = ModeSetup();
await pump(tester, (FlightModel m, SafetyGate g, FlightCommandFlow f) => HyperlapsePanel(model: m, setup: setup, gate: g, onExit: () {}, onError: (_, __) {}));
await tester.tap(find.byKey(const ValueKey<String>('hl-length-20')));
await tester.pump();
expect(find.textContaining('500 FRAMES'), findsOneWidget);
});
});
}
class _FlatGround implements ElevationSource {
const _FlatGround();
@override
Future<List<double>> elevations(List<LatLng> points) async => List<double>.filled(points.length, 12);
}
+5 -2
View File
@@ -71,7 +71,9 @@ void main() {
expect(rail(telemetryTick(), mission: 'executing').mode, ActiveMode.waypoint);
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_WAYPOINT').mode, ActiveMode.waypoint);
expect(rail(telemetryTick()..['flightModeEnum'] = 'TAP_FLY').mode, ActiveMode.tapFly);
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT').mode, ActiveMode.other);
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT').mode, ActiveMode.hotpoint);
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_FOLLOW_ME').mode, ActiveMode.followMe);
expect(rail(telemetryTick()..['flightModeEnum'] = 'DRAW').mode, ActiveMode.other);
expect(rail(telemetryTick(), mission: 'uploading').showStop, isFalse);
expect(rail(grounded(), mission: 'tracking').showStop, isFalse, reason: 'nothing to stop on the ground');
});
@@ -146,7 +148,8 @@ void main() {
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING'), FlightCommand.cancelLanding), isEmpty);
expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.confirmLanding), isEmpty);
expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.cancelGoHome), isEmpty);
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT'), FlightCommand.stopMode).single, contains('pause button'));
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'DRAW'), FlightCommand.stopMode).single, contains('pause button'));
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT'), FlightCommand.stopMode), isEmpty, reason: 'POI stops from the app now');
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'TAP_FLY'), FlightCommand.stopMode), isEmpty);
});
+13 -2
View File
@@ -112,8 +112,16 @@ void main() {
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']);
// The pair and cell-voltage groups are always listed and gated per profile.
expect(groups(phantom4), <String>['WARNINGS', 'BATTERY', 'BATTERY PAIR', 'CELL VOLTAGE']);
expect(groups(inspire2X5s), <String>['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<KeyReadoutSpec>(SettingsTabs.battery, 'cells1', ProductProfile.fromBridge(inspire2X5s));
expect(cells.key.index, 1);
});
@@ -287,6 +295,9 @@ void main() {
await tester.tap(find.byKey(const ValueKey<String>('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<String>('row-osChannel')), 80,
scrollable: find.descendant(of: find.byKey(const ValueKey<String>('settings-list-transmission')), matching: find.byType(Scrollable)));
expect(find.byKey(const ValueKey<String>('row-osChannel')), findsOneWidget);
await tester.pump(cameraSettle);
});
+2
View File
@@ -288,6 +288,8 @@ void main() {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlyPrefs.acceptedModeTerms.value = <String>{'activeTrack', 'tapFly'};
// The first-use tutorials have their own test (p7_modes_test.dart).
FlyPrefs.seenTutorials.value = <String>{for (final IntelligentMode m in IntelligentMode.values) m.name};
});
Future<FakeDjiService> pump(WidgetTester tester, FlightModel m) async {
+2
View File
@@ -682,6 +682,8 @@ void main() {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlyPrefs.acceptedModeTerms.value = <String>{};
// The first-use tutorials have their own test (p7_modes_test.dart).
FlyPrefs.seenTutorials.value = <String>{for (final IntelligentMode m in IntelligentMode.values) m.name};
});
Future<FakeDjiService> pump(WidgetTester tester, FlightModel m, {WaypointEditor? editor}) async {