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 pts, {double altitude = 50}) => WaypointMissionSpec(points: [for (final LatLng p in pts) WaypointSpec(at: p, altitude: altitude)]); Map waypointEvent(String state, {Map? upload, Map? execution, String? event, String? error}) => { '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 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 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([base, at(0, 100)]).copyWith( cruiseSpeed: 6, finishAction: RouteFinishAction.land, pathMode: RoutePathMode.straight, exitOnSignalLost: false, gotoFirst: RouteGotoFirst.pointToPoint, repeatTimes: 2, ); final Map 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 w = (b['waypoints'] as List).first as Map; 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(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 wps = m.toBridge()['waypoints'] as List; List actions(int i) => (wps[i] as Map)['actions'] as List; expect(actions(0), [ {'type': 'STAY', 'param': 3000}, {'type': 'START_TAKE_PHOTO', 'param': 0}, ]); expect(actions(1), [{'type': 'START_RECORD', 'param': 0}]); expect((actions(2).first as Map)['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(at: base, turnRadius: 5), WaypointSpec(at: at(0, 100), turnRadius: 0.01)], ); final List wps = m.toBridge()['waypoints'] as List; expect((wps[0] as Map)['cornerRadius'], 5); expect((wps[1] as Map)['cornerRadius'], WaypointMissionSpec.minTurnRadius); }); test('Towards POI sends the first POI as the mission point of interest', () { final WaypointMissionSpec m = route([base, at(0, 100)]).copyWith( headingMode: RouteHeadingMode.poi, pois: [MissionPoi(id: 'a', at: at(50, 50))], ); final Map b = m.toBridge(); expect(b['headingMode'], 'TOWARD_POINT_OF_INTEREST'); expect((b['pointOfInterest'] as Map)['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([base, at(0, 100), at(100, 100)]); final List 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(id: 'p', at: at(0, -100))], points: [ 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 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(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 wps = m.toBridge()['waypoints'] as List; String turn(int i) => (wps[i] as Map)['turnMode']! as String; expect((wps[0] as Map)['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([base, at(0, 100)]).resolvedGimbal(), isNull); }); test('a pitch carries forward, and leading waypoints take the first one', () { final WaypointMissionSpec m = WaypointMissionSpec(points: [ 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(), [-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(id: 'p', at: at(50, 0))], points: [ 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 errors(WaypointMissionSpec m, [MissionLimits l = const MissionLimits()]) => [for (final MissionIssue i in m.validate(l)) if (i.blocks) i.message]; List warnings(WaypointMissionSpec m) => [for (final MissionIssue i in m.validate()) if (!i.blocks) i.message]; test('a valid route has no errors', () { expect(errors(route([base, at(0, 100), at(100, 100)])), isEmpty); }); test('needs two waypoints', () { expect(errors(route([base])), contains('Add at least 2 waypoints.')); }); test('altitude above the aircraft limit, and low waypoints warn', () { final WaypointMissionSpec m = WaypointMissionSpec(points: [ 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([base, base])).single, contains('on top of each other')); expect(errors(route([base, at(0, 2500)])).single, contains('more than 2 km apart')); }); test('beyond the distance limit from home', () { final List e = errors(route([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(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([base, at(0, 100)]).copyWith(headingMode: RouteHeadingMode.poi)).single, contains('needs a point of interest')); }); }); test('length and time estimate', () { final WaypointMissionSpec m = route([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(id: 'p', at: at(10, 10), height: 12)], points: [ 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({})); 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([base, at(0, 100)]).copyWith(name: 'A'), now: DateTime(2026, 1, 1)); final WaypointMissionSpec b = await lib.save(route([base, at(0, 50)]).copyWith(name: 'B'), now: DateTime(2026, 1, 2)); expect(a.id, isNotEmpty); expect(lib.missions.map((WaypointMissionSpec m) => m.name), ['B', 'A']); await lib.save(a.copyWith(name: 'A2')); expect(lib.missions.map((WaypointMissionSpec m) => m.name), ['A2', 'B'], reason: 'same id replaces'); final MissionLibrary again = MissionLibrary(); await again.load(); expect(again.missions.map((WaypointMissionSpec m) => m.name), ['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 out = MissionLibrary.decode('[{"name":"ok","points":[]}, {"points": 7}, 3]'); expect(out.map((WaypointMissionSpec m) => m.name), ['ok']); }); group('sync', () { WaypointMissionSpec named(String id, String name, int day) => route([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 names(Iterable ms) => [for (final WaypointMissionSpec m in ms) m.name]; test('merge: newest write wins per route, either side', () { final MissionMerge m = mergeMissions( local: [named('a', 'A local', 5), named('b', 'B local', 2), named('c', 'C only here', 3)], tombstones: const {}, remote: [live(named('a', 'A server', 4)), live(named('b', 'B server', 6)), live(named('d', 'D only there', 1))], ); expect(names(m.missions), ['B server', 'A local', 'C only here', 'D only there'], reason: 'newest first'); expect(names(m.toPush), unorderedEquals(['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: [named('a', 'mine', 3)], tombstones: const {}, remote: [live(named('a', 'theirs', 3))], ); expect(names(m.missions), ['theirs']); expect(m.toPush, isEmpty); }); test('merge: deletes are writes too', () { final MissionMerge m = mergeMissions( local: [named('kept', 'edited after their delete', 5), named('dropped', 'stale', 2)], tombstones: { '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: [ gone('kept', 4), gone('dropped', 3), live(named('del', 'old', 4)), live(named('lost', 'edited there', 2)), gone('both', 2), ], ); expect(names(m.missions), ['edited after their delete', 'edited there']); expect(names(m.toPush), ['edited after their delete'], reason: 'resurrects it on the server'); expect(m.toDelete, {'del': DateTime.utc(2026, 1, 5)}); expect(m.tombstones.keys, ['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: [route([base]).copyWith(id: 'legacy', name: 'legacy')], tombstones: const {}, remote: const [], ); 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({ '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({'id': 'wp-1', 'updatedAt': '2026-01-04T00:00:00Z', 'deleted': true}); expect(t!.deleted, isTrue); expect(RemoteRoute.fromView({'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([base, at(0, 100)]).copyWith(name: 'Survey')); await tablet.idle; expect(server.routes.keys, [saved.id]); expect(tablet.syncStatus, MissionSyncStatus.synced); SharedPreferences.setMockInitialValues({}); // 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([base]).copyWith(name: 'A'), now: DateTime.utc(2026, 1, 1)); final WaypointMissionSpec b = await lib.save(route([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), ['A'], reason: 'works offline'); final MissionLibrary reopened = MissionLibrary(remote: server); await reopened.load(); expect(names(reopened.missions), ['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([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), ['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([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), ['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([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: {'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: {'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({'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([at(5, 5), at(0, 100), at(100, 100)]); List blockers(FlightModel m, [WaypointMissionSpec? spec]) => [for (final GateCheck c in SafetyGate(model: m, dji: FakeDjiService()).checkWaypointStart(spec ?? ok)) if (c.blocks) c.message]; List warnings(FlightModel m) => [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()), ['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([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([base, at(0, 1900), at(1900, 1900), at(1900, 0)]).copyWith(cruiseSpeed: 2); final List 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, ['setGoHomeHeight(80)', 'uploadWaypointMission(3)', 'startWaypointMission']); expect(uploadedAt, 2); expect((dji.uploaded!['waypoints'] as List).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())); 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())); await gate.pauseWaypointMission(); m.mission.apply(waypointEvent('EXECUTION_PAUSED')); await expectLater(gate.pauseWaypointMission(), throwsA(isA())); await gate.resumeWaypointMission(); expect(dji.calls, ['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, ['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 listed = {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({}); FlyPrefs.acceptedModeTerms.value = {}; }); Future 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 openWaypoints(WidgetTester tester) async { await tester.tap(find.byKey(const ValueKey('rail-modes'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.byKey(const ValueKey('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('rail-modes')), findsOneWidget); await openWaypoints(tester); expect(find.byKey(const ValueKey('terms-agree')), findsOneWidget); await tester.tap(find.byKey(const ValueKey('terms-disagree'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.byKey(const ValueKey('wp-tool-start')), findsNothing, reason: 'disagreeing stays out of the mode'); await openWaypoints(tester); await tester.tap(find.byKey(const ValueKey('terms-agree'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.byKey(const ValueKey('wp-tool-start')), findsOneWidget); expect(find.byKey(const ValueKey('wp-board')), findsOneWidget); expect(FlyPrefs.acceptedModeTerms.value, contains('waypoint')); // Second time round: straight in. await tester.tap(find.byKey(const ValueKey('wp-tool-exit'))); await tester.pump(); expect(find.byKey(const ValueKey('wp-tool-start')), findsNothing); await openWaypoints(tester); expect(find.byKey(const ValueKey('terms-agree')), findsNothing); expect(find.byKey(const ValueKey('wp-tool-start')), findsOneWidget); }); testWidgets('the point editor sets the selected waypoint and replaces the attitude ball', (WidgetTester tester) async { FlyPrefs.acceptedModeTerms.value = {'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('readout-altitude')), findsOneWidget); expect(find.text('WAYPOINT 2/2'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('readout-altitude-plus'))); await tester.pump(); expect(e.mission.points[1].altitude, 51); // Swipe up 5 steps. await tester.drag(find.byKey(const ValueKey('readout-altitude')), const Offset(0, -44)); await tester.pump(); expect(e.mission.points[1].altitude, greaterThanOrEqualTo(55)); await tester.tap(find.byKey(const ValueKey('tab-action'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('choice-Photo'))); await tester.pump(); expect(e.mission.points[1].action, WaypointCameraAction.photo); await tester.tap(find.byKey(const ValueKey('wp-deselect'))); await tester.pump(); expect(find.byKey(const ValueKey('readout-altitude')), findsNothing); }); testWidgets('Start: pre-start check, slide, upload, start', (WidgetTester tester) async { FlyPrefs.acceptedModeTerms.value = {'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('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(['uploadWaypointMission(2)', 'startWaypointMission'])); // The operator reports the flight: the editor's tools give way to the run panel. TelemetryReducer(m).apply(waypointEvent('EXECUTING', execution: {'target': 0, 'reached': false, 'executeState': 'MOVING', 'total': 2})); await tester.pump(); expect(find.byKey(const ValueKey('wp-run')), findsOneWidget); expect(find.byKey(const ValueKey('wp-tool-start')), findsNothing); await tester.tap(find.byKey(const ValueKey('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 routes = {}; @override String get account => who; void _check() { if (offline) throw const MissionSyncException(0, {'error': 'offline'}); } @override Future> pull() async { _check(); return routes.values.toList(); } @override Future 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 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; } }