Files
PilotVault/Fly App/test/fly/waypoint_test.dart
T
tajniak81andClaude Opus 5.5 93f4a580e9 Sync the Fly App's waypoint routes through the API Server
Routes saved in the Fly App's mission library now sync to a new per-user
routes collection on the API Server, in the WaypointMissionSpec JSON
format. The library pulls on load and pushes on every save and delete,
merges last-writer-wins by updatedAt, keeps unacknowledged deletes as
tombstones, and keeps working offline. Each signed-in account has its
own copy on the device; routes saved while signed out go to the first
account that signs in.

The API Server gets list / get / upsert / delete route endpoints with a
PocketBase migration, and the Web App a read-only Routes view on the
dashboard (rebuilt dist).

Tested against a fake PocketBase and a fake server only; not yet tried
with a real sign-in on the tablet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:26:45 +02:00

844 lines
39 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/mission_sync.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('sync', () {
WaypointMissionSpec named(String id, String name, int day) =>
route(<LatLng>[base, at(0, 100)]).copyWith(id: id, name: name, updatedAt: DateTime.utc(2026, 1, day));
RemoteRoute live(WaypointMissionSpec m) => RemoteRoute(m.id, m.updatedAt!, m);
RemoteRoute gone(String id, int day) => RemoteRoute(id, DateTime.utc(2026, 1, day), null);
List<String> names(Iterable<WaypointMissionSpec> ms) => <String>[for (final WaypointMissionSpec m in ms) m.name];
test('merge: newest write wins per route, either side', () {
final MissionMerge m = mergeMissions(
local: <WaypointMissionSpec>[named('a', 'A local', 5), named('b', 'B local', 2), named('c', 'C only here', 3)],
tombstones: const <String, DateTime>{},
remote: <RemoteRoute>[live(named('a', 'A server', 4)), live(named('b', 'B server', 6)), live(named('d', 'D only there', 1))],
);
expect(names(m.missions), <String>['B server', 'A local', 'C only here', 'D only there'], reason: 'newest first');
expect(names(m.toPush), unorderedEquals(<String>['A local', 'C only here']));
expect(m.toDelete, isEmpty);
});
test('merge: a tie goes to the server and pushes nothing', () {
final MissionMerge m = mergeMissions(
local: <WaypointMissionSpec>[named('a', 'mine', 3)],
tombstones: const <String, DateTime>{},
remote: <RemoteRoute>[live(named('a', 'theirs', 3))],
);
expect(names(m.missions), <String>['theirs']);
expect(m.toPush, isEmpty);
});
test('merge: deletes are writes too', () {
final MissionMerge m = mergeMissions(
local: <WaypointMissionSpec>[named('kept', 'edited after their delete', 5), named('dropped', 'stale', 2)],
tombstones: <String, DateTime>{
'del': DateTime.utc(2026, 1, 5), // deleted here after their edit
'lost': DateTime.utc(2026, 1, 1), // deleted here, but edited there since
'never': DateTime.utc(2026, 1, 1), // deleted before it ever reached the server
'both': DateTime.utc(2026, 1, 4), // deleted on both sides
},
remote: <RemoteRoute>[
gone('kept', 4),
gone('dropped', 3),
live(named('del', 'old', 4)),
live(named('lost', 'edited there', 2)),
gone('both', 2),
],
);
expect(names(m.missions), <String>['edited after their delete', 'edited there']);
expect(names(m.toPush), <String>['edited after their delete'], reason: 'resurrects it on the server');
expect(m.toDelete, <String, DateTime>{'del': DateTime.utc(2026, 1, 5)});
expect(m.tombstones.keys, <String>['del'], reason: 'only unacknowledged deletes stay');
});
test('merge: a route with no updatedAt is the oldest write, and gets one to push', () {
final MissionMerge m = mergeMissions(
local: <WaypointMissionSpec>[route(<LatLng>[base]).copyWith(id: 'legacy', name: 'legacy')],
tombstones: const <String, DateTime>{},
remote: const <RemoteRoute>[],
);
expect(m.toPush.single.updatedAt, isNotNull);
});
test('the server view parses, tombstones included', () {
final WaypointMissionSpec spec = named('wp-1', 'R', 3);
final RemoteRoute? r = RemoteRoute.fromView(<String, dynamic>{
'id': 'wp-1',
'updatedAt': '2026-01-03T00:00:00.000000Z',
'deleted': false,
'spec': spec.toJson(),
});
expect(r!.spec!.toJson(), spec.toJson());
final RemoteRoute? t = RemoteRoute.fromView(<String, dynamic>{'id': 'wp-1', 'updatedAt': '2026-01-04T00:00:00Z', 'deleted': true});
expect(t!.deleted, isTrue);
expect(RemoteRoute.fromView(<String, dynamic>{'id': 'wp-1', 'deleted': false, 'spec': spec.toJson()}), isNull);
});
test('a route saved on one tablet is there after a reinstall + sign-in', () async {
final _FakeRemote server = _FakeRemote('pilot@x');
final MissionLibrary tablet = MissionLibrary(remote: server);
await tablet.load();
final WaypointMissionSpec saved = await tablet.save(route(<LatLng>[base, at(0, 100)]).copyWith(name: 'Survey'));
await tablet.idle;
expect(server.routes.keys, <String>[saved.id]);
expect(tablet.syncStatus, MissionSyncStatus.synced);
SharedPreferences.setMockInitialValues(<String, Object>{}); // reinstall
final MissionLibrary fresh = MissionLibrary(remote: server);
await fresh.load();
expect(fresh.missions.single.toJson(), saved.toJson());
await fresh.delete(saved.id);
await fresh.idle;
expect(server.routes[saved.id]!.deleted, isTrue);
await tablet.sync();
expect(tablet.missions, isEmpty, reason: 'the other tablet learns of the delete');
});
test('offline: saves and deletes stay local and go up on the next sync', () async {
final _FakeRemote server = _FakeRemote('pilot@x')..offline = true;
final MissionLibrary lib = MissionLibrary(remote: server);
await lib.load();
expect(lib.syncStatus, MissionSyncStatus.failed);
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base]).copyWith(name: 'A'), now: DateTime.utc(2026, 1, 1));
final WaypointMissionSpec b = await lib.save(route(<LatLng>[base]).copyWith(name: 'B'), now: DateTime.utc(2026, 1, 2));
server.routes[b.id] = RemoteRoute(b.id, DateTime.utc(2026, 1, 2), b); // reached the server earlier
await lib.delete(b.id, now: DateTime.utc(2026, 1, 3));
await lib.idle;
expect(names(lib.missions), <String>['A'], reason: 'works offline');
final MissionLibrary reopened = MissionLibrary(remote: server);
await reopened.load();
expect(names(reopened.missions), <String>['A'], reason: 'the pending delete survives a restart');
server.offline = false;
await reopened.sync();
expect(server.routes[a.id]!.spec!.name, 'A');
expect(server.routes[b.id]!.deleted, isTrue);
expect(reopened.syncStatus, MissionSyncStatus.synced);
});
test('a refused push adopts the newer server copy', () async {
final _FakeRemote server = _FakeRemote('pilot@x');
final MissionLibrary lib = MissionLibrary(remote: server);
await lib.load();
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base]).copyWith(name: 'mine'), now: DateTime.utc(2026, 1, 1));
// Someone else's newer save lands between our pull and our push.
server.beforePush = () => server.routes[a.id] = live(a.copyWith(name: 'theirs', updatedAt: DateTime.utc(2026, 1, 9)));
await lib.idle;
expect(names(lib.missions), <String>['theirs']);
});
test('signed-out routes are claimed by the first account; accounts stay apart', () async {
final _FakeRemote server = _FakeRemote('');
final MissionLibrary lib = MissionLibrary(remote: server);
await lib.load();
await lib.save(route(<LatLng>[base]).copyWith(name: 'before sign-in'));
await lib.idle;
expect(lib.syncStatus, MissionSyncStatus.local);
expect(server.routes, isEmpty);
server.who = 'a@x';
await lib.sync();
expect(names(lib.missions), <String>['before sign-in']);
expect(server.routes.values.single.spec!.name, 'before sign-in');
server.who = 'b@x';
server.routes.clear(); // b's own (empty) server library
await lib.sync();
expect(lib.missions, isEmpty, reason: "b never sees a's routes");
});
});
});
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');
});
});
}
/// An in-memory API Server route store, with the server's last-writer-wins rule.
class _FakeRemote implements MissionRemote {
_FakeRemote(this.who);
String who;
bool offline = false;
void Function()? beforePush;
final Map<String, RemoteRoute> routes = <String, RemoteRoute>{};
@override
String get account => who;
void _check() {
if (offline) throw const MissionSyncException(0, <String, dynamic>{'error': 'offline'});
}
@override
Future<List<RemoteRoute>> pull() async {
_check();
return routes.values.toList();
}
@override
Future<RemoteRoute?> push(WaypointMissionSpec m) async {
_check();
beforePush?.call();
final RemoteRoute? have = routes[m.id];
if (have != null && have.updatedAt.isAfter(m.updatedAt!)) return have;
routes[m.id] = RemoteRoute(m.id, m.updatedAt!, m);
return null;
}
@override
Future<RemoteRoute?> pushDelete(String id, DateTime at) async {
_check();
final RemoteRoute? have = routes[id];
if (have == null) return null;
if (have.updatedAt.isAfter(at)) return have;
routes[id] = RemoteRoute(id, at, null);
return null;
}
}