Files
PilotVault/Fly App/test/fly/waypoint_test.dart
T
tajniak81andClaude Opus 5.5 43a66df8df 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>
2026-10-20 16:22:53 +02:00

650 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});
}