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>
334 lines
17 KiB
Dart
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);
|
|
}
|