Intelligent modes: Point of Interest (orbit on the map, live radius and speed), Follow Me (follows the phone), QuickShots on a boxed subject, Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and Circle. Every mode has a first-use tutorial and stops from the rail. Waypoints can resume an interrupted mission, change cruise speed in flight and check terrain clearance (Open-Meteo, on tap). Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI output, a link spectrum chart, Wi-Fi name and password, aircraft name, video cache, battery warning history and pairing, non-smart cell voltages. Calibration: compass interference meter, per-IMU calibration, lens infinity focus and a first-manual-focus prompt. Also an RTK chip, gimbal movement presets and motor tuning, Precision Takeoff gated on the flight controller, and the precise GEO database's server version. Built and unit/widget-tested only; nothing has flown yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
846 lines
39 KiB
Dart
846 lines
39 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:latlong2/latlong.dart' show LatLng;
|
||
import 'package:pilotvault_fly/flight_model.dart';
|
||
import 'package:pilotvault_fly/fly/action_rail_state.dart';
|
||
import 'package:pilotvault_fly/fly/fly_prefs.dart';
|
||
import 'package:pilotvault_fly/fly/modes/intelligent_modes.dart';
|
||
import 'package:pilotvault_fly/fly/modes/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>{};
|
||
// The first-use tutorials have their own test (p7_modes_test.dart).
|
||
FlyPrefs.seenTutorials.value = <String>{for (final IntelligentMode m in IntelligentMode.values) m.name};
|
||
});
|
||
|
||
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;
|
||
}
|
||
}
|