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

334 lines
17 KiB
Dart

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);
}