Give the Fly App GO 4-style intelligent modes and a Waypoints editor

P7a of the GO 4-parity plan. The rail's GRID button becomes MODES, which
opens a picker of the product's intelligent flight modes with first-use
terms; Waypoints is the first one built.

Waypoints opens on the big map: tap to add waypoints or POIs, long-press
to drag, "+" to insert on a leg, and a point editor for altitude, speed,
heading (route / custom / POI), gimbal (set or aim at POI), camera action
and hover, and turn radius, with a swipe-to-adjust readout. Route settings,
a local mission library and undo sit in a tool column. Start runs the
SafetyGate's pre-start check in the slide-to-confirm dialog, then uploads
and starts; a run panel shows upload and flight progress with Pause,
Resume and Stop.

MissionBridge now listens to the waypoint operator and builds the full V1
mission (per-waypoint headings, turn direction, gimbal pitch, actions).
Not yet run on a simulator or aircraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 16:22:53 +02:00
1 parent a892bfb209
commit 43a66df8df
22 files changed
+4276 -83

No files matched your search

+17
View File
@@ -33,6 +33,23 @@ class FakeDjiService extends DjiService {
Future<void> setHomeLocation(double latitude, double longitude) => _record('setHomeLocation');
@override
Future<void> stopWaypointMission() => _record('stopWaypointMission');
// ── Missions (plan P7) ─────────────────────────────────────────────────────
/// The last `uploadWaypointMission` payload.
Map<String, Object?>? uploaded;
@override
Future<void> uploadWaypointMission(Map<String, Object?> mission) {
uploaded = mission;
return _record('uploadWaypointMission(${(mission['waypoints'] as List<Object?>).length})');
}
@override
Future<void> startWaypointMission() => _record('startWaypointMission');
@override
Future<void> pauseWaypointMission() => _record('pauseWaypointMission');
@override
Future<void> resumeWaypointMission() => _record('resumeWaypointMission');
@override
Future<void> stopActiveTrack() => _record('stopActiveTrack');
@override
+649
View File
@@ -0,0 +1,649 @@
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/mission_library.dart';
import 'package:pilotvault_fly/fly/modes/waypoint_editor.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/aircraft_state.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/fly_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
const LatLng base = LatLng(55.6761, 12.5683);
/// [dLat] / [dLon] in metres from [base] (near enough at this latitude).
LatLng at(double north, double east) => LatLng(base.latitude + north / 111195, base.longitude + east / 62790);
WaypointMissionSpec route(List<LatLng> pts, {double altitude = 50}) =>
WaypointMissionSpec(points: <WaypointSpec>[for (final LatLng p in pts) WaypointSpec(at: p, altitude: altitude)]);
Map<String, dynamic> waypointEvent(String state, {Map<String, Object?>? upload, Map<String, Object?>? execution, String? event, String? error}) =>
<String, dynamic>{
'type': 'mission',
'operator': 'waypoint',
'state': state,
if (upload != null) 'upload': upload,
if (execution != null) 'execution': execution,
if (event != null) 'event': event,
if (error != null) 'error': error,
};
FlightModel modelWith(Map<String, dynamic> tick, {String operator = 'READY_TO_UPLOAD'}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(phantom4);
final TelemetryReducer r = TelemetryReducer(m);
r.apply(tick);
r.apply(waypointEvent(operator));
return m;
}
Map<String, dynamic> grounded() => telemetryTick()
..['isFlying'] = false
..['areMotorsOn'] = false
..['flightTimeSeconds'] = 0;
void main() {
group('WaypointMissionSpec.toBridge', () {
test('maps the route settings to SDK enums and keeps per-point defaults', () {
final WaypointMissionSpec m = route(<LatLng>[base, at(0, 100)]).copyWith(
cruiseSpeed: 6,
finishAction: RouteFinishAction.land,
pathMode: RoutePathMode.straight,
exitOnSignalLost: false,
gotoFirst: RouteGotoFirst.pointToPoint,
repeatTimes: 2,
);
final Map<String, Object?> b = m.toBridge();
expect(b['autoFlightSpeed'], 6);
expect(b['maxFlightSpeed'], WaypointMissionSpec.maxSpeed);
expect(b['finishedAction'], 'AUTO_LAND');
expect(b['headingMode'], 'AUTO');
expect(b['flightPathMode'], 'NORMAL');
expect(b['gotoFirstWaypointMode'], 'POINT_TO_POINT');
expect(b['exitMissionOnRCSignalLost'], false);
expect(b['gimbalPitchRotation'], false);
expect(b['repeatTimes'], 2);
final Map<String, Object?> w = (b['waypoints'] as List<Object?>).first as Map<String, Object?>;
expect(w['speed'], 0.0, reason: '0 = the mission auto speed');
expect(w['cornerRadius'], WaypointMissionSpec.minTurnRadius);
expect(w.containsKey('heading'), isFalse, reason: 'only sent with per-waypoint headings');
expect(w.containsKey('gimbalPitch'), isFalse);
expect(w['actions'], isEmpty);
});
test('a hover then the camera action, in that order', () {
final WaypointMissionSpec m = WaypointMissionSpec(points: <WaypointSpec>[
WaypointSpec(at: base, hoverSeconds: 3, action: WaypointCameraAction.photo),
WaypointSpec(at: at(0, 50), action: WaypointCameraAction.startRecord),
WaypointSpec(at: at(0, 100), hoverSeconds: 99, action: WaypointCameraAction.stopRecord),
]);
final List<Object?> wps = m.toBridge()['waypoints'] as List<Object?>;
List<Object?> actions(int i) => (wps[i] as Map<String, Object?>)['actions'] as List<Object?>;
expect(actions(0), <Object?>[
<String, Object?>{'type': 'STAY', 'param': 3000},
<String, Object?>{'type': 'START_TAKE_PHOTO', 'param': 0},
]);
expect(actions(1), <Object?>[<String, Object?>{'type': 'START_RECORD', 'param': 0}]);
expect((actions(2).first as Map<String, Object?>)['param'], 32000, reason: 'STAY is capped at 32 s');
});
test('curved routes send the turn radius', () {
final WaypointMissionSpec m = WaypointMissionSpec(
pathMode: RoutePathMode.curved,
points: <WaypointSpec>[WaypointSpec(at: base, turnRadius: 5), WaypointSpec(at: at(0, 100), turnRadius: 0.01)],
);
final List<Object?> wps = m.toBridge()['waypoints'] as List<Object?>;
expect((wps[0] as Map<String, Object?>)['cornerRadius'], 5);
expect((wps[1] as Map<String, Object?>)['cornerRadius'], WaypointMissionSpec.minTurnRadius);
});
test('Towards POI sends the first POI as the mission point of interest', () {
final WaypointMissionSpec m = route(<LatLng>[base, at(0, 100)]).copyWith(
headingMode: RouteHeadingMode.poi,
pois: <MissionPoi>[MissionPoi(id: 'a', at: at(50, 50))],
);
final Map<String, Object?> b = m.toBridge();
expect(b['headingMode'], 'TOWARD_POINT_OF_INTEREST');
expect((b['pointOfInterest'] as Map<String, Object?>)['lat'], closeTo(at(50, 50).latitude, 1e-9));
});
});
group('heading resolution', () {
test('route headings point at the next waypoint; the last keeps the final leg', () {
final WaypointMissionSpec m = route(<LatLng>[base, at(0, 100), at(100, 100)]);
final List<int> h = m.resolvedHeadings();
expect(h[0], closeTo(90, 1));
expect(h[1], closeTo(0, 1));
expect(h[2], closeTo(0, 1));
});
test('custom and POI headings, signed −180…180', () {
final WaypointMissionSpec m = WaypointMissionSpec(
headingMode: RouteHeadingMode.perPoint,
pois: <MissionPoi>[MissionPoi(id: 'p', at: at(0, -100))],
points: <WaypointSpec>[
WaypointSpec(at: base, heading: WaypointHeading.custom, customHeading: 200),
WaypointSpec(at: at(0, 100), heading: WaypointHeading.poi, poiId: 'p'),
WaypointSpec(at: at(0, 200), heading: WaypointHeading.poi), // no POI linked → route
],
);
final List<int> h = m.resolvedHeadings();
expect(h[0], 180, reason: 'clamped');
expect(h[1], closeTo(-90, 1), reason: 'west, towards the POI');
expect(h[2], closeTo(90, 1));
});
test('per-waypoint headings turn the short way round', () {
final WaypointMissionSpec m = WaypointMissionSpec(
headingMode: RouteHeadingMode.perPoint,
points: <WaypointSpec>[
WaypointSpec(at: base, heading: WaypointHeading.custom, customHeading: 170),
WaypointSpec(at: at(0, 50), heading: WaypointHeading.custom, customHeading: -170),
WaypointSpec(at: at(0, 100), heading: WaypointHeading.custom, customHeading: 170),
],
);
final List<Object?> wps = m.toBridge()['waypoints'] as List<Object?>;
String turn(int i) => (wps[i] as Map<String, Object?>)['turnMode']! as String;
expect((wps[0] as Map<String, Object?>)['heading'], 170);
expect(turn(1), 'CLOCKWISE', reason: '170 → −170 is 20° clockwise');
expect(turn(2), 'COUNTER_CLOCKWISE');
});
});
group('gimbal resolution', () {
test('no pitch anywhere leaves the gimbal to the pilot', () {
expect(route(<LatLng>[base, at(0, 100)]).resolvedGimbal(), isNull);
});
test('a pitch carries forward, and leading waypoints take the first one', () {
final WaypointMissionSpec m = WaypointMissionSpec(points: <WaypointSpec>[
WaypointSpec(at: base),
WaypointSpec(at: at(0, 50), gimbalPitch: -30),
WaypointSpec(at: at(0, 100)),
WaypointSpec(at: at(0, 150), gimbalPitch: -120),
]);
expect(m.resolvedGimbal(), <double>[-30, -30, -30, -90]);
expect(m.toBridge()['gimbalPitchRotation'], isTrue);
});
test('aim at POI tilts down by the drop over the distance', () {
final WaypointMissionSpec m = WaypointMissionSpec(
pois: <MissionPoi>[MissionPoi(id: 'p', at: at(50, 0))],
points: <WaypointSpec>[
WaypointSpec(at: base, altitude: 50, poiId: 'p', gimbalAimPoi: true),
WaypointSpec(at: at(0, 100)),
],
);
expect(m.resolvedGimbal()![0], closeTo(-45, 0.5));
});
});
group('validate', () {
List<String> errors(WaypointMissionSpec m, [MissionLimits l = const MissionLimits()]) =>
<String>[for (final MissionIssue i in m.validate(l)) if (i.blocks) i.message];
List<String> warnings(WaypointMissionSpec m) => <String>[for (final MissionIssue i in m.validate()) if (!i.blocks) i.message];
test('a valid route has no errors', () {
expect(errors(route(<LatLng>[base, at(0, 100), at(100, 100)])), isEmpty);
});
test('needs two waypoints', () {
expect(errors(route(<LatLng>[base])), contains('Add at least 2 waypoints.'));
});
test('altitude above the aircraft limit, and low waypoints warn', () {
final WaypointMissionSpec m = WaypointMissionSpec(points: <WaypointSpec>[
WaypointSpec(at: base, altitude: 150),
WaypointSpec(at: at(0, 100), altitude: 5),
]);
expect(errors(m, const MissionLimits(maxAltitude: 120)).single, contains('above the 120 m altitude limit'));
expect(warnings(m).single, contains('only 5 m above'));
});
test('waypoints on top of each other, or more than 2 km apart', () {
expect(errors(route(<LatLng>[base, base])).single, contains('on top of each other'));
expect(errors(route(<LatLng>[base, at(0, 2500)])).single, contains('more than 2 km apart'));
});
test('beyond the distance limit from home', () {
final List<String> e = errors(route(<LatLng>[base, at(0, 600)]), MissionLimits(maxRadius: 500, home: base));
expect(e.single, contains('Waypoint 2 is beyond the 500 m distance limit'));
});
test('curved: overlapping radii are refused, skipped actions warned about', () {
final WaypointMissionSpec m = WaypointMissionSpec(pathMode: RoutePathMode.curved, points: <WaypointSpec>[
WaypointSpec(at: base, turnRadius: 30, action: WaypointCameraAction.photo),
WaypointSpec(at: at(0, 50), turnRadius: 30),
]);
expect(errors(m).single, contains('turn radii'));
expect(warnings(m).single, contains('skipped'));
});
test('Towards POI without a POI', () {
expect(errors(route(<LatLng>[base, at(0, 100)]).copyWith(headingMode: RouteHeadingMode.poi)).single, contains('needs a point of interest'));
});
});
test('length and time estimate', () {
final WaypointMissionSpec m = route(<LatLng>[base, at(0, 100), at(100, 100)]).copyWith(cruiseSpeed: 10);
expect(m.length, closeTo(200, 2));
// 200 m at 10 m/s + one 3 s stop at the middle waypoint.
expect(m.estimatedSeconds, closeTo(23, 1));
});
test('JSON round trip keeps everything', () {
final WaypointMissionSpec m = WaypointMissionSpec(
id: 'wp-1',
name: 'Bridge survey',
cruiseSpeed: 7.5,
headingMode: RouteHeadingMode.perPoint,
finishAction: RouteFinishAction.firstWaypoint,
pathMode: RoutePathMode.curved,
exitOnSignalLost: false,
gotoFirst: RouteGotoFirst.pointToPoint,
repeatTimes: 3,
updatedAt: DateTime.utc(2026, 10, 20, 9),
pois: <MissionPoi>[MissionPoi(id: 'p', at: at(10, 10), height: 12)],
points: <WaypointSpec>[
WaypointSpec(at: base, altitude: 40, speed: 4, heading: WaypointHeading.custom, customHeading: -45, gimbalPitch: -20, turnRadius: 3),
WaypointSpec(at: at(0, 100), poiId: 'p', heading: WaypointHeading.poi, gimbalAimPoi: true, action: WaypointCameraAction.photo, hoverSeconds: 4),
],
);
final WaypointMissionSpec back = WaypointMissionSpec.fromJson(m.toJson());
expect(back.toJson(), m.toJson());
});
group('MissionLibrary', () {
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
test('save gives an id, newest first, survives a reload; delete removes', () async {
final MissionLibrary lib = MissionLibrary();
await lib.load();
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base, at(0, 100)]).copyWith(name: 'A'), now: DateTime(2026, 1, 1));
final WaypointMissionSpec b = await lib.save(route(<LatLng>[base, at(0, 50)]).copyWith(name: 'B'), now: DateTime(2026, 1, 2));
expect(a.id, isNotEmpty);
expect(lib.missions.map((WaypointMissionSpec m) => m.name), <String>['B', 'A']);
await lib.save(a.copyWith(name: 'A2'));
expect(lib.missions.map((WaypointMissionSpec m) => m.name), <String>['A2', 'B'], reason: 'same id replaces');
final MissionLibrary again = MissionLibrary();
await again.load();
expect(again.missions.map((WaypointMissionSpec m) => m.name), <String>['A2', 'B']);
await again.delete(b.id);
expect(again.missions.single.name, 'A2');
});
test('a corrupt entry is skipped, not the whole library', () {
final List<WaypointMissionSpec> out = MissionLibrary.decode('[{"name":"ok","points":[]}, {"points": 7}, 3]');
expect(out.map((WaypointMissionSpec m) => m.name), <String>['ok']);
});
});
group('WaypointEditor', () {
test('a new waypoint copies the one before, and is selected', () {
final WaypointEditor e = WaypointEditor()..addPoint(base);
e.updatePoint(0, (WaypointSpec p) => p.copyWith(altitude: 80, speed: 5, gimbalPitch: -40));
e.addPoint(at(0, 100));
expect(e.selected, 1);
expect(e.mission.points[1].altitude, 80);
expect(e.mission.points[1].speed, 5);
expect(e.mission.points[1].gimbalPitch, -40);
});
test('a tap adds what the tool says', () {
final WaypointEditor e = WaypointEditor()..tapMap(base);
e.setTool(MapTool.poi);
e.tapMap(at(10, 10));
expect(e.mission.points, hasLength(1));
expect(e.mission.pois, hasLength(1));
expect(e.selectedPoi, e.mission.pois.single.id);
expect(e.selected, isNull);
});
test('insert a midpoint, remove, undo', () {
final WaypointEditor e = WaypointEditor()
..addPoint(base)
..addPoint(at(0, 100));
e.updatePoint(1, (WaypointSpec p) => p.copyWith(altitude: 100));
e.insertMidpoint(0);
expect(e.mission.points, hasLength(3));
expect(e.mission.points[1].at.longitude, closeTo(at(0, 50).longitude, 1e-9));
expect(e.mission.points[1].altitude, 75);
expect(e.selected, 1);
e.removePoint(1);
expect(e.mission.points, hasLength(2));
e.undo();
expect(e.mission.points, hasLength(3));
e.undo();
expect(e.mission.points, hasLength(2), reason: 'the midpoint insert is one step');
});
test('a drag is one undo step', () {
final WaypointEditor e = WaypointEditor()
..addPoint(base)
..addPoint(at(0, 100));
e.beginDrag();
for (int i = 1; i <= 5; i++) {
e.movePoint(1, at(i * 10, 100));
}
e.undo();
expect(e.mission.points[1].at, at(0, 100));
});
test('a per-point heading switches the route to per-waypoint headings', () {
final WaypointEditor e = WaypointEditor()
..addPoint(base)
..addPoint(at(0, 100));
expect(e.mission.headingMode, RouteHeadingMode.auto);
e.setHeading(0, WaypointHeading.custom, custom: 45);
expect(e.mission.headingMode, RouteHeadingMode.perPoint);
expect(e.mission.points[0].customHeading, 45);
});
test('linking a POI faces it; removing the POI unlinks every waypoint', () {
final WaypointEditor e = WaypointEditor()
..addPoint(base)
..addPoint(at(0, 100))
..addPoi(at(50, 50));
final String id = e.mission.pois.single.id;
e.linkPoi(0, id);
e.linkPoi(1, id);
e.updatePoint(1, (WaypointSpec p) => p.copyWith(gimbalAimPoi: true));
expect(e.mission.points[0].heading, WaypointHeading.poi);
expect(e.mission.headingMode, RouteHeadingMode.perPoint);
e.removePoi(id);
expect(e.mission.points.every((WaypointSpec p) => p.poiId == null && !p.gimbalAimPoi && p.heading == WaypointHeading.route), isTrue);
});
test('apply to all copies the selected waypoint\'s field', () {
final WaypointEditor e = WaypointEditor()
..addPoint(base)
..addPoint(at(0, 100))
..addPoint(at(0, 200));
e.select(1);
e.updatePoint(1, (WaypointSpec p) => p.copyWith(action: WaypointCameraAction.photo, hoverSeconds: 2, altitude: 70));
e.applyToAll(PointField.action);
expect(e.mission.points.every((WaypointSpec p) => p.action == WaypointCameraAction.photo && p.hoverSeconds == 2), isTrue);
expect(e.mission.points[0].altitude, 50, reason: 'only the one field');
});
test('loading resets history and dirtiness', () {
final WaypointEditor e = WaypointEditor()..addPoint(base);
expect(e.dirty, isTrue);
e.load(route(<LatLng>[base, at(0, 100)]));
expect(e.dirty, isFalse);
expect(e.canUndo, isFalse);
expect(e.selected, 0);
});
});
group('MissionState (waypoint operator)', () {
test('upload progress, execution, pause, finish', () {
final MissionState s = MissionState()
..apply(waypointEvent('UPLOADING', upload: <String, Object?>{'index': 2, 'total': 6, 'summary': true}));
expect(s.state, 'uploading');
expect(s.waypoint.uploadFraction, closeTo(0.5, 1e-9));
s.apply(waypointEvent('READY_TO_EXECUTE'));
expect(s.state, 'ready');
s.apply(waypointEvent('EXECUTING', event: 'start'));
s.apply(waypointEvent('EXECUTING', execution: <String, Object?>{'target': 3, 'reached': false, 'executeState': 'MOVING', 'total': 6}));
expect(s.state, 'executing');
expect(s.waypoint.target, 3);
expect(s.waypoint.executeLabel, 'Flying');
s.apply(waypointEvent('EXECUTION_PAUSED'));
expect(s.state, 'executing', reason: 'paused still needs Stop');
expect(s.waypoint.paused, isTrue);
expect(s.waypoint.target, 3, reason: 'progress kept across state-only events');
s.apply(waypointEvent('READY_TO_UPLOAD', event: 'finish'));
expect(s.state, 'idle');
expect(s.waypointResult, '');
expect(s.waypointFinishes, 1);
s.apply(waypointEvent('READY_TO_UPLOAD', event: 'finish', error: 'Mission interrupted'));
expect(s.waypointResult, 'Mission interrupted');
expect(s.waypointFinishes, 2);
});
test('ActiveTrack keeps its legacy state alongside the operator', () {
final MissionState s = MissionState()
..apply(<String, dynamic>{'type': 'mission', 'state': 'tracking'})
..apply(waypointEvent('READY_TO_UPLOAD'));
expect(s.tracking, isTrue);
});
test('a paused mission still offers Stop on the rail', () {
final MissionState s = MissionState()..apply(waypointEvent('EXECUTION_PAUSED'));
final AircraftState a = AircraftState()..apply(telemetryTick());
expect(RailState.of(connected: true, a: a, mission: s).mode, ActiveMode.waypoint);
});
});
group('SafetyGate (waypoints)', () {
final WaypointMissionSpec ok = route(<LatLng>[at(5, 5), at(0, 100), at(100, 100)]);
List<String> blockers(FlightModel m, [WaypointMissionSpec? spec]) =>
<String>[for (final GateCheck c in SafetyGate(model: m, dji: FakeDjiService()).checkWaypointStart(spec ?? ok)) if (c.blocks) c.message];
List<String> warnings(FlightModel m) =>
<String>[for (final GateCheck c in SafetyGate(model: m, dji: FakeDjiService()).checkWaypointStart(ok)) if (!c.blocks) c.message];
test('airborne, good GPS and battery: nothing blocks', () {
expect(blockers(modelWith(telemetryTick())), isEmpty);
});
test('disconnected, unsupported or busy operator', () {
expect(blockers(FlightModel()), <String>['No aircraft connected.']);
expect(blockers(modelWith(telemetryTick(), operator: 'NOT_SUPPORTED')), contains('This aircraft cannot fly waypoint missions.'));
expect(blockers(modelWith(telemetryTick(), operator: 'EXECUTING')), contains('A waypoint mission is already running.'));
expect(blockers(modelWith(telemetryTick(), operator: 'UPLOADING')), contains('A mission is still uploading.'));
});
test('route errors block', () {
expect(blockers(modelWith(telemetryTick()), route(<LatLng>[base])), contains('Add at least 2 waypoints.'));
});
test('weak GPS, no home, going home, critical battery', () {
expect(blockers(modelWith(telemetryTick()..['gpsSignalLevel'] = 1)), contains('GPS too weak for a waypoint mission.'));
expect(blockers(modelWith(telemetryTick()..['isHomeLocationSet'] = false)), contains('No home point recorded yet.'));
expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true)), contains('Cancel Return to Home first.'));
expect(blockers(modelWith(telemetryTick(battery: 10))).any((String b) => b.startsWith('Battery critically low')), isTrue);
});
test('on the ground it warns that the aircraft takes off by itself', () {
expect(warnings(modelWith(grounded())), contains('The aircraft takes off and flies to waypoint 1 on its own.'));
expect(warnings(modelWith(telemetryTick())), isNot(contains('The aircraft takes off and flies to waypoint 1 on its own.')));
});
test('a route longer than the battery warns', () {
final FlightModel m = modelWith(telemetryTick());
final WaypointMissionSpec long = route(<LatLng>[base, at(0, 1900), at(1900, 1900), at(1900, 0)]).copyWith(cruiseSpeed: 2);
final List<GateCheck> c = SafetyGate(model: m, dji: FakeDjiService()).checkWaypointStart(long);
expect(c.any((GateCheck g) => !g.blocks && g.message.contains('of flight left')), isTrue);
});
test('start writes the RTH altitude, uploads, then starts', () async {
final FakeDjiService dji = FakeDjiService();
final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick()), dji: dji);
int uploadedAt = -1;
await gate.startWaypointMission(ok, rthHeight: 80, onUploaded: () => uploadedAt = dji.calls.length);
expect(dji.calls, <String>['setGoHomeHeight(80)', 'uploadWaypointMission(3)', 'startWaypointMission']);
expect(uploadedAt, 2);
expect((dji.uploaded!['waypoints'] as List<Object?>).length, 3);
});
test('start refuses a blocked mission without touching the aircraft', () async {
final FakeDjiService dji = FakeDjiService();
final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick()..['gpsSignalLevel'] = 0), dji: dji);
await expectLater(gate.startWaypointMission(ok), throwsA(isA<MissionRefused>()));
expect(dji.calls, isEmpty);
});
test('pause only while flying the mission, resume only while paused', () async {
final FakeDjiService dji = FakeDjiService();
final FlightModel m = modelWith(telemetryTick(), operator: 'EXECUTING');
final SafetyGate gate = SafetyGate(model: m, dji: dji);
await expectLater(gate.resumeWaypointMission(), throwsA(isA<MissionRefused>()));
await gate.pauseWaypointMission();
m.mission.apply(waypointEvent('EXECUTION_PAUSED'));
await expectLater(gate.pauseWaypointMission(), throwsA(isA<MissionRefused>()));
await gate.resumeWaypointMission();
expect(dji.calls, <String>['pauseWaypointMission', 'resumeWaypointMission']);
});
test('Stop on the rail stops a paused mission', () async {
final FakeDjiService dji = FakeDjiService();
final FlightModel m = modelWith(telemetryTick(), operator: 'EXECUTION_PAUSED');
await SafetyGate(model: m, dji: dji).run(FlightCommand.stopMode);
expect(dji.calls, <String>['stopWaypointMission']);
});
test('missionLimits reads the flight controller and the gimbal range', () async {
final MissionLimits l = await SafetyGate(model: modelWith(telemetryTick()), dji: FakeDjiService()).missionLimits();
expect(l.maxAltitude, 120);
expect(l.maxRadius, 500);
expect(l.home, isNotNull);
});
});
test('the catalogue lists only the product\'s modes, all of them when disconnected', () {
final ProductProfile p4 = ProductProfile.fromBridge(phantom4);
final Set<IntelligentMode> listed = <IntelligentMode>{for (final ModeInfo i in ModeCatalog.forProfile(p4)) i.mode};
expect(listed, p4.modes);
expect(ModeCatalog.forProfile(ProductProfile.disconnected), hasLength(ModeCatalog.all.length));
expect(ModeCatalog.of(IntelligentMode.waypoint).built, isTrue);
});
group('FlyScreen (waypoints)', () {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlyPrefs.acceptedModeTerms.value = <String>{};
});
Future<FakeDjiService> pump(WidgetTester tester, FlightModel m, {WaypointEditor? editor}) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final FakeDjiService dji = FakeDjiService();
await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji, waypoints: editor)));
await tester.pump();
return dji;
}
Future<void> openWaypoints(WidgetTester tester) async {
await tester.tap(find.byKey(const ValueKey<String>('rail-modes')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey<String>('mode-waypoint')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
testWidgets('MODES → Waypoints asks the terms once, then opens the editor on the map', (WidgetTester tester) async {
final FlightModel m = modelWith(grounded());
await pump(tester, m);
expect(find.byKey(const ValueKey<String>('rail-modes')), findsOneWidget);
await openWaypoints(tester);
expect(find.byKey(const ValueKey<String>('terms-agree')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('terms-disagree')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsNothing, reason: 'disagreeing stays out of the mode');
await openWaypoints(tester);
await tester.tap(find.byKey(const ValueKey<String>('terms-agree')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('wp-board')), findsOneWidget);
expect(FlyPrefs.acceptedModeTerms.value, contains('waypoint'));
// Second time round: straight in.
await tester.tap(find.byKey(const ValueKey<String>('wp-tool-exit')));
await tester.pump();
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsNothing);
await openWaypoints(tester);
expect(find.byKey(const ValueKey<String>('terms-agree')), findsNothing);
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsOneWidget);
});
testWidgets('the point editor sets the selected waypoint and replaces the attitude ball', (WidgetTester tester) async {
FlyPrefs.acceptedModeTerms.value = <String>{'waypoint'};
final FlightModel m = modelWith(grounded());
final WaypointEditor e = WaypointEditor();
await pump(tester, m, editor: e);
await openWaypoints(tester);
// Taps on the map tiles are hard to aim in a test; drive the editor's own entry point.
e.addPoint(at(5, 5));
e.addPoint(at(0, 100));
await tester.pump();
expect(find.byKey(const ValueKey<String>('readout-altitude')), findsOneWidget);
expect(find.text('WAYPOINT 2/2'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('readout-altitude-plus')));
await tester.pump();
expect(e.mission.points[1].altitude, 51);
// Swipe up 5 steps.
await tester.drag(find.byKey(const ValueKey<String>('readout-altitude')), const Offset(0, -44));
await tester.pump();
expect(e.mission.points[1].altitude, greaterThanOrEqualTo(55));
await tester.tap(find.byKey(const ValueKey<String>('tab-action')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('choice-Photo')));
await tester.pump();
expect(e.mission.points[1].action, WaypointCameraAction.photo);
await tester.tap(find.byKey(const ValueKey<String>('wp-deselect')));
await tester.pump();
expect(find.byKey(const ValueKey<String>('readout-altitude')), findsNothing);
});
testWidgets('Start: pre-start check, slide, upload, start', (WidgetTester tester) async {
FlyPrefs.acceptedModeTerms.value = <String>{'waypoint'};
final FlightModel m = modelWith(telemetryTick());
final WaypointEditor e = WaypointEditor();
final FakeDjiService dji = await pump(tester, m, editor: e);
await openWaypoints(tester);
e.addPoint(at(5, 5));
e.addPoint(at(0, 100));
e.select(null);
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('wp-tool-start')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.textContaining('Start "New route"'), findsOneWidget);
expect(find.text('SLIDE TO START'), findsOneWidget);
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
await tester.pumpAndSettle();
expect(dji.calls, containsAllInOrder(<String>['uploadWaypointMission(2)', 'startWaypointMission']));
// The operator reports the flight: the editor's tools give way to the run panel.
TelemetryReducer(m).apply(waypointEvent('EXECUTING', execution: <String, Object?>{'target': 0, 'reached': false, 'executeState': 'MOVING', 'total': 2}));
await tester.pump();
expect(find.byKey(const ValueKey<String>('wp-run')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsNothing);
await tester.tap(find.byKey(const ValueKey<String>('wp-run-pause')));
await tester.pump();
expect(dji.calls.last, 'pauseWaypointMission');
});
});
}