Files
PilotVault/Fly App/test/fly/p9_secondary_test.dart
T
tajniak81andClaude Opus 5.5 91ca150a35 Give the Fly App GO 4-style playback, flight records, Find My Drone and live streaming
Playback: the flight screen's playback button opens the album over the
live view, so a video tapped there plays on the aircraft into the feed
with a transport bar. Long press selects files to download or delete
(delete asks first); one file opens in a viewer with the camera's
preview. Leaving the album puts the camera back in its capture mode.

Flight records: every flight is recorded from take-off to landing (1 Hz
track, max height / distance / speed, distance flown, battery used) and
kept on the phone. When it lands while signed in, it is uploaded to the
PilotVault logbook through the existing /api/drones/auto and
/api/flights endpoints as an automatic-path entry with the track as
GeoJSON. Simulator flights are never uploaded; flights recorded while
signed out retry on sign-in. Flight logs lists them with their upload
state and a track map, and the profile stats come from real flights.

Find My Drone: the aircraft's last fix is kept across lost links and
restarts, and shown against the phone's own GPS with distance, direction
and a hand-off to the maps app.

Live streaming: a new LiveStreamBridge streams the main camera to an
RTMP address set in Settings > General, with a LIVE pill on the flight
screen that turns amber when the uplink falls behind and stops on tap.

Pre-flight checklist (manual checks plus the aircraft's live status)
and an in-app Academy with six short guides that work offline.

None of this has run on hardware yet.

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

576 lines
23 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 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/flight_model.dart';
import 'package:pilotvault_fly/fly/find_my_drone.dart';
import 'package:pilotvault_fly/fly/flight_log.dart';
import 'package:pilotvault_fly/fly/flight_record.dart';
import 'package:pilotvault_fly/fly/flight_record_sync.dart';
import 'package:pilotvault_fly/fly/fly_prefs.dart';
import 'package:pilotvault_fly/fly/preflight_checklist.dart';
import 'package:pilotvault_fly/fly/state/live_stream_state.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/theme.dart';
import 'package:pilotvault_fly/ui/academy_page.dart';
import 'package:pilotvault_fly/ui/album_page.dart';
import 'package:pilotvault_fly/ui/flight_logs_page.dart';
import 'package:pilotvault_fly/ui/fly/overlays/live_stream.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'fake_dji.dart';
/// A controllable clock for the recorder, locator and checklist.
class _Clock {
DateTime now = DateTime(2026, 10, 20, 14, 0);
DateTime call() => now;
void advance(Duration d) => now = now.add(d);
}
/// One flight-controller tick.
Map<String, dynamic> _tick({
bool flying = true,
double lat = 55.6761,
double lon = 12.5683,
double alt = 30,
double dist = 0,
double speed = 0,
bool sim = false,
}) =>
<String, dynamic>{
'satelliteCount': 14,
'isFlying': flying,
'areMotorsOn': flying,
'latitude': lat,
'longitude': lon,
'altitude': alt,
'homeLatitude': 55.6761,
'homeLongitude': 12.5683,
'homeDistance': dist,
'horizontalSpeed': speed,
'simulatorActive': sim,
};
FlightModel _connected() => FlightModel()
..connected = true
..model = 'Mavic 2 Pro'
..flightControllerSerial = 'FC123';
class _MemoryStore implements FlightRecordStore {
final Map<String, FlightRecord> saved = <String, FlightRecord>{};
@override
Future<List<FlightRecord>> loadAll() async => saved.values.toList();
@override
Future<void> save(FlightRecord r) async => saved[r.id] = r;
@override
Future<void> delete(String id) async => saved.remove(id);
}
class _FakeLogbook implements LogbookRemote {
bool signedIn = true;
Object? failWith;
final List<Map<String, Object?>> flights = <Map<String, Object?>>[];
final List<String> drones = <String>[];
@override
bool get available => signedIn;
@override
Future<String> ensureDrone({required String flightControllerSerial, String? model, String? firmware}) async {
if (failWith != null) throw failWith!;
drones.add(flightControllerSerial);
return 'drone-1';
}
@override
Future<String> createFlight(Map<String, Object?> entry) async {
flights.add(entry);
return 'flight-${flights.length}';
}
}
FlightRecord _record({String id = 'fr-1', bool sim = false, String? serial = 'FC123', int points = 3}) => FlightRecord(
id: id,
start: DateTime(2026, 10, 20, 14, 5),
end: DateTime(2026, 10, 20, 14, 17, 30),
aircraftModel: 'Mavic 2 Pro',
flightControllerSerial: serial,
homeLatitude: 55.6761,
homeLongitude: 12.5683,
track: <TrackPoint>[for (int i = 0; i < points; i++) TrackPoint(55.6761 + i * 0.001, 12.5683, 20.0 + i, i)],
maxAltitude: 42.4,
maxDistance: 310,
maxSpeed: 9.5,
distanceFlown: 1234,
batteryStart: 96,
batteryEnd: 41,
simulated: sim,
);
void main() {
group('FlightRecorder', () {
late _Clock clock;
late FlightModel m;
late List<FlightRecord> done;
late FlightRecorder rec;
setUp(() {
clock = _Clock();
m = _connected();
done = <FlightRecord>[];
rec = FlightRecorder(model: m, onFinished: done.add, clock: clock.call);
});
tearDown(() => rec.dispose());
void tick(Map<String, dynamic> t, {Duration after = const Duration(milliseconds: 100)}) {
clock.advance(after);
m.aircraft.apply(t);
}
test('records take-off to landing with a 1 Hz track and the maxima', () {
tick(_tick(flying: false));
expect(rec.recording, isFalse);
m.battery.apply(<String, dynamic>{'percent': 95});
tick(_tick(alt: 1));
expect(rec.recording, isTrue);
// 20 s of flight at 10 Hz, climbing and moving north.
for (int i = 1; i <= 200; i++) {
tick(_tick(lat: 55.6761 + i * 0.00001, alt: 1 + i * 0.2, dist: i.toDouble(), speed: i == 100 ? 12 : 5));
}
m.battery.apply(<String, dynamic>{'percent': 80});
// Landed where it last flew: the flight ends once the grace has passed.
const double end = 55.6761 + 200 * 0.00001;
tick(_tick(flying: false, lat: end, alt: 0));
expect(done, isEmpty);
tick(_tick(flying: false, lat: end, alt: 0), after: const Duration(seconds: 6));
expect(rec.recording, isFalse);
final FlightRecord r = done.single;
expect(r.track.length, inInclusiveRange(20, 22)); // one a second
expect(r.maxAltitude, closeTo(41, 0.01));
expect(r.maxDistance, 200);
expect(r.maxSpeed, 12);
expect(r.distanceFlown, closeTo(222, 3)); // 200 × 0.00001° ≈ 222 m
expect(r.batteryStart, 95);
expect(r.batteryEnd, 80);
expect(r.flightControllerSerial, 'FC123');
expect(r.duration.inSeconds, inInclusiveRange(20, 21)); // ends when it landed, not after the grace
expect(r.simulated, isFalse);
});
test('drops hops shorter than the minimum', () {
tick(_tick());
tick(_tick(), after: const Duration(seconds: 2));
tick(_tick(flying: false));
tick(_tick(flying: false), after: const Duration(seconds: 6));
expect(done, isEmpty);
expect(rec.recording, isFalse);
});
test('a touch-and-go within the grace stays one flight', () {
tick(_tick());
tick(_tick(), after: const Duration(seconds: 10));
tick(_tick(flying: false));
tick(_tick(flying: false), after: const Duration(seconds: 2));
tick(_tick(), after: const Duration(seconds: 1));
tick(_tick(), after: const Duration(seconds: 10));
tick(_tick(flying: false));
tick(_tick(flying: false), after: const Duration(seconds: 6));
expect(done, hasLength(1));
});
test('a disconnect mid-air ends the flight where it was last seen', () {
tick(_tick());
tick(_tick(lat: 55.68), after: const Duration(seconds: 30));
final DateTime lastSeen = clock.now;
clock.advance(const Duration(seconds: 20));
m.connected = false;
m.clearTelemetry();
m.bump();
expect(done.single.end, lastSeen);
expect(done.single.lastPoint!.latitude, 55.68);
});
test('marks simulator flights', () {
tick(_tick(sim: true));
tick(_tick(sim: true), after: const Duration(seconds: 30));
tick(_tick(flying: false, sim: true));
tick(_tick(flying: false, sim: true), after: const Duration(seconds: 6));
expect(done.single.simulated, isTrue);
});
test('ignores (0, 0) fixes in the track', () {
tick(_tick(lat: 0, lon: 0));
for (int i = 0; i < 10; i++) {
tick(_tick(lat: 0, lon: 0), after: const Duration(seconds: 1));
}
tick(_tick(flying: false, lat: 0, lon: 0));
tick(_tick(flying: false, lat: 0, lon: 0), after: const Duration(seconds: 6));
expect(done.single.track, isEmpty);
});
});
group('FlightRecord', () {
test('round-trips through JSON', () {
final FlightRecord r = _record()..logbookId = 'abc';
final FlightRecord back = FlightRecord.fromJson(Map<String, dynamic>.from(r.toJson()));
expect(back.toJson(), r.toJson());
expect(back.track[1].latitude, closeTo(55.6771, 1e-9));
expect(back.uploaded, isTrue);
});
test('labels its duration', () {
expect(_record().durationLabel, '12 min 30 s');
});
});
group('logbook upload', () {
test('the entry carries the § 5 fields, the track and the numbers', () {
final Map<String, Object?> e = logbookEntry(_record(), droneId: 'd1');
expect(e['operationDate'], '2026-10-20');
expect(e['startTime'], '14:05');
expect(e['endTime'], '14:17');
expect(e['drone'], 'd1');
expect(e['maxAltitudeAgl'], 42.4);
expect(e['loggingPath'], 'automatic');
expect(e['areaRoute'], 'Take-off 55.67610° N, 12.56830° E');
final Map<String, Object?> route = e['routeGeojson']! as Map<String, Object?>;
expect(route['type'], 'LineString');
expect((route['coordinates']! as List<Object?>).first, <double>[12.5683, 55.6761, 20.0]); // lon, lat, alt
expect(e['notes'], contains('1.23 km flown'));
expect(e['notes'], contains('battery 96% → 41%'));
});
test('long tracks are thinned evenly, keeping both ends', () {
final List<TrackPoint> track = <TrackPoint>[for (int i = 0; i < 2000; i++) TrackPoint(55 + i * 1e-5, 12, 10, i)];
final List<Object?> coords = routeGeoJson(track, maxPoints: 100)!['coordinates']! as List<Object?>;
expect(coords, hasLength(100));
expect((coords.first! as List<double>)[1], 55);
expect((coords.last! as List<double>)[1], closeTo(55 + 1999e-5, 1e-6));
expect(routeGeoJson(track.take(1).toList()), isNull);
});
test('uploads through the fleet drone', () async {
final _FakeLogbook remote = _FakeLogbook();
final FlightRecord r = await uploadFlightRecord(_record(), remote);
expect(r.logbookId, 'flight-1');
expect(r.uploadError, isNull);
expect(remote.drones, <String>['FC123']);
// A second upload is a no-op.
await uploadFlightRecord(r, remote);
expect(remote.flights, hasLength(1));
});
test('refuses simulator flights, missing serials and signed-out uploads', () async {
final _FakeLogbook remote = _FakeLogbook();
expect((await uploadFlightRecord(_record(sim: true), remote)).uploadError, contains('Simulator'));
expect((await uploadFlightRecord(_record(serial: null), remote)).uploadError, contains('serial'));
remote.signedIn = false;
expect((await uploadFlightRecord(_record(), remote)).uploadError, contains('Sign in'));
expect(remote.flights, isEmpty);
});
test('keeps the server\'s reason on failure', () async {
final _FakeLogbook remote = _FakeLogbook()..failWith = const LogbookException('this drone is registered to another organisation');
final FlightRecord r = await uploadFlightRecord(_record(), remote);
expect(r.uploaded, isFalse);
expect(r.uploadError, 'this drone is registered to another organisation');
remote.failWith = const SocketException('down');
expect((await uploadFlightRecord(r, remote)).uploadError, 'The API Server could not be reached');
});
});
group('FlightLog', () {
setUp(() => FlyPrefs.autoUploadFlights.value = true);
test('saves each finished flight and uploads it when signed in', () async {
final _MemoryStore store = _MemoryStore();
final _FakeLogbook remote = _FakeLogbook();
final FlightLog log = FlightLog(store: store, remote: remote);
await log.load();
await log.add(_record(id: 'fr-1'));
await log.add(_record(id: 'fr-2', sim: true));
expect(store.saved.keys, containsAll(<String>['fr-1', 'fr-2']));
expect(store.saved['fr-1']!.logbookId, 'flight-1');
expect(store.saved['fr-2']!.uploaded, isFalse); // simulator: never uploaded
expect(log.pending, isEmpty);
});
test('keeps flights pending while signed out, and sends them later', () async {
final _FakeLogbook remote = _FakeLogbook()..signedIn = false;
final FlightLog log = FlightLog(store: _MemoryStore(), remote: remote);
await log.add(_record(id: 'fr-1'));
await log.add(_record(id: 'fr-2'));
expect(log.pending, hasLength(2));
remote.signedIn = true;
expect(await log.uploadPending(), 2);
expect(log.pending, isEmpty);
});
test('auto-upload off leaves new flights local', () async {
FlyPrefs.autoUploadFlights.value = false;
final _FakeLogbook remote = _FakeLogbook();
final FlightLog log = FlightLog(store: _MemoryStore(), remote: remote);
await log.add(_record());
expect(remote.flights, isEmpty);
expect(log.pending, hasLength(1));
});
test('lists newest first', () async {
final _MemoryStore store = _MemoryStore()
..saved['a'] = FlightRecord(id: 'a', start: DateTime(2026, 1, 1), end: DateTime(2026, 1, 1))
..saved['b'] = FlightRecord(id: 'b', start: DateTime(2026, 3, 1), end: DateTime(2026, 3, 1));
final FlightLog log = FlightLog(store: store, remote: _FakeLogbook());
await log.load();
expect(log.records.map((FlightRecord r) => r.id), <String>['b', 'a']);
expect(log.latest!.id, 'b');
});
test('the file store round-trips and skips broken files', () async {
final Directory tmp = await Directory.systemTemp.createTemp('flight_records');
addTearDown(() => tmp.delete(recursive: true));
final FileFlightRecordStore store = FileFlightRecordStore(root: () async => tmp);
await store.save(_record(id: 'fr-1'));
await File('${tmp.path}${Platform.pathSeparator}flight_records${Platform.pathSeparator}junk.json').writeAsString('{not json');
final List<FlightRecord> all = await store.loadAll();
expect(all.single.id, 'fr-1');
expect(all.single.track, hasLength(3));
await store.delete('fr-1');
expect(await store.loadAll(), isEmpty);
});
});
group('Find My Drone', () {
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
test('keeps the last fix and saves it for the next launch', () async {
final _Clock clock = _Clock();
final FlightModel m = _connected();
final AircraftLocator loc = AircraftLocator(model: m, clock: clock.call);
m.battery.apply(<String, dynamic>{'percent': 37});
m.aircraft.apply(_tick(lat: 55.7, lon: 12.6, alt: 48));
expect(loc.last!.latitude, 55.7);
expect(loc.last!.inFlight, isTrue);
expect(loc.last!.batteryPercent, 37);
// Lost link: the position stays, and is on disk.
clock.advance(const Duration(seconds: 1));
m.aircraft.apply(_tick(lat: 55.701, lon: 12.6));
m.connected = false;
m.clearTelemetry();
m.bump();
expect(loc.last!.latitude, 55.701);
await Future<void>.delayed(Duration.zero);
loc.dispose();
final AircraftLocator next = AircraftLocator(model: FlightModel());
await next.load();
expect(next.last!.latitude, 55.701);
expect(next.last!.altitude, 30);
next.dispose();
});
test('does not record without a fix or a connection', () {
final FlightModel m = FlightModel();
final AircraftLocator loc = AircraftLocator(model: m);
m.aircraft.apply(_tick());
expect(loc.last, isNull);
m.connected = true;
m.aircraft.apply(_tick(lat: 0, lon: 0));
expect(loc.last, isNull);
loc.dispose();
});
test('distance, direction and age read naturally', () {
final LastKnownPosition p = LastKnownPosition(latitude: 55.6861, longitude: 12.5683, at: DateTime(2026, 10, 20, 14));
expect(p.distanceFrom(55.6761, 12.5683), closeTo(1112, 2));
expect(compassPoint(p.bearingFrom(55.6761, 12.5683)), 'N');
expect(compassPoint(134), 'SE');
expect(compassPoint(359), 'N');
expect(distanceLabel(1112), '1.1 km');
expect(distanceLabel(84.4), '84 m');
expect(agoLabel(DateTime(2026, 10, 20, 14), DateTime(2026, 10, 20, 14, 12)), '12 min ago');
expect(agoLabel(DateTime(2026, 10, 18), DateTime(2026, 10, 20, 1)), '2 days ago');
});
});
group('PreflightChecklist', () {
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
test('ticks persist for the session and go stale after it', () async {
final _Clock clock = _Clock();
final PreflightChecklist c = PreflightChecklist(clock: clock.call);
c.toggle(c.items[0]);
c.toggle(c.items[1]);
c.toggle(c.items[1]);
expect(c.done, 1);
await Future<void>.delayed(Duration.zero);
final PreflightChecklist again = PreflightChecklist(clock: clock.call);
await again.load();
expect(again.isTicked(again.items[0]), isTrue);
clock.advance(const Duration(hours: 7));
final PreflightChecklist tomorrow = PreflightChecklist(clock: clock.call);
await tomorrow.load();
expect(tomorrow.done, 0);
});
test('complete once every item is ticked', () {
final PreflightChecklist c = PreflightChecklist();
for (final PreflightItem i in c.items) {
c.toggle(i);
}
expect(c.complete, isTrue);
c.reset();
expect(c.done, 0);
});
});
group('live streaming', () {
test('reads the bridge state', () {
final FlightModel m = FlightModel();
TelemetryReducer(m).apply(<String, dynamic>{
'type': 'liveStream',
'streaming': true,
'status': 'STREAMING',
'videoKbps': 2200,
'audioKbps': 128,
'fps': 29.97,
'seconds': 3725,
'cacheSize': 90,
});
final LiveStreamState l = m.liveStream;
expect(l.streaming, isTrue);
expect(l.bitrateLabel, '2.3 Mbps');
expect(l.elapsedLabel, '1:02:05');
expect(l.congested, isTrue);
});
test('masks the stream key and validates addresses', () {
expect(maskStreamUrl('rtmp://a.rtmp.youtube.com/live2/abcd-efgh-x9'), 'rtmp://a.rtmp.youtube.com/live2/•••x9');
expect(maskStreamUrl('rtmp://host'), 'rtmp://host');
expect(validStreamUrl('rtmp://a.rtmp.youtube.com/live2/key'), isTrue);
expect(validStreamUrl('rtmps://live-api-s.facebook.com:443/rtmp/key'), isTrue);
expect(validStreamUrl('http://example.com/live'), isFalse);
expect(validStreamUrl('rtmp://host'), isFalse);
expect(validStreamUrl(''), isFalse);
});
testWidgets('Start needs a connection and an address; the badge stops it', (WidgetTester tester) async {
final FakeDjiService dji = FakeDjiService();
final LiveStreamState live = LiveStreamState();
FlyPrefs.liveStreamUrl.value = '';
Widget host(bool connected) => MaterialApp(
theme: PV.theme(),
home: Scaffold(
body: Column(children: <Widget>[
LiveStreamRows(live: live, dji: dji, connected: connected, onError: (_, __) {}),
LiveBadge(live: live, dji: dji, onError: (_, __) {}),
]),
),
);
await tester.pumpWidget(host(true));
await tester.tap(find.byKey(const ValueKey<String>('action-liveStreamToggle')));
expect(dji.calls, isEmpty); // no address yet
FlyPrefs.liveStreamUrl.value = 'rtmp://host/app/key';
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('action-liveStreamToggle')));
expect(dji.calls, <String>['startLiveStream(rtmp://host/app/key, false)']);
expect(find.byKey(const ValueKey<String>('live-badge')), findsNothing);
live.apply(<String, dynamic>{'streaming': true, 'videoKbps': 900, 'seconds': 65});
await tester.pump();
expect(find.text('1:05 · 900 kbps'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('live-badge')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey<String>('live-stop')));
await tester.pumpAndSettle();
expect(dji.calls.last, 'stopLiveStream');
});
});
group('playback album', () {
MediaItem item(int i, {String type = 'JPEG', bool video = false}) =>
MediaItem.fromMap(<String, dynamic>{'index': i, 'key': 'k$i', 'fileName': 'DJI_000$i', 'type': video ? 'video' : 'photo', 'mediaType': type});
test('filters by kind', () {
final MediaItem photo = item(0), clip = item(1, type: 'MP4', video: true), pano = item(2, type: 'PANORAMA');
expect(AlbumFilter.photos.accepts(photo), isTrue);
expect(AlbumFilter.photos.accepts(pano), isFalse);
expect(AlbumFilter.pano.accepts(pano), isTrue);
expect(AlbumFilter.videos.accepts(clip), isTrue);
expect(MediaItem.fromMap(<String, dynamic>{'index': 0, 'sizeBytes': 13002342}).sizeLabel, '12.4 MB');
});
testWidgets('long press selects, delete asks first, leaving restores the camera', (WidgetTester tester) async {
final FakeDjiService dji = FakeDjiService();
final FlightModel m = FlightModel()
..connected = true
..captureMode = CaptureMode.video
..media = <MediaItem>[item(0), item(1), item(2, type: 'MP4', video: true)];
await tester.pumpWidget(MaterialApp(theme: PV.theme(), home: AlbumPage(model: m, dji: dji)));
m.mediaLoading = false;
m.bump();
await tester.pump();
expect(dji.calls, <String>['refreshMediaList']);
await tester.longPress(find.byKey(const ValueKey<String>('media-k0')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('media-k2')));
await tester.pump();
expect(find.text('2 selected'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('album-delete')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey<String>('album-delete-confirm')));
await tester.pumpAndSettle();
expect(dji.calls, contains('deleteMedia([0, 2])'));
expect(find.text('2 selected'), findsNothing);
await tester.pumpWidget(const SizedBox());
expect(dji.calls.last, 'setCameraMode(video)');
});
});
testWidgets('the checklist page ticks items', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final PreflightChecklist c = PreflightChecklist();
await tester.pumpWidget(MaterialApp(theme: PV.theme(), home: PreflightChecklistPage(model: FlightModel(), checklist: c)));
expect(find.text('Connect the aircraft to see its own checks here.'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('check-site')));
await tester.pump();
expect(c.done, 1);
expect(find.text('1 of ${c.items.length} checks done'), findsOneWidget);
});
testWidgets('flight logs and the Academy fit a phone', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final _FakeLogbook remote = _FakeLogbook()..signedIn = false;
final FlightLog log = FlightLog(store: _MemoryStore(), remote: remote);
await log.load();
await log.add(_record(id: 'fr-1'));
await log.add(_record(id: 'fr-2', sim: true));
final FlightModel m = FlightModel();
final AircraftLocator loc = AircraftLocator(model: m);
await tester.pumpWidget(MaterialApp(theme: PV.theme(), home: FlightLogsPage(log: log, locator: loc, model: m)));
await tester.pump();
expect(find.text('2 flights'), findsOneWidget);
expect(find.text('1 flight not in the logbook'), findsOneWidget);
expect(find.text('SIMULATOR'), findsOneWidget);
expect(find.text('LOCAL'), findsOneWidget); // signed out: never tried
await tester.pumpWidget(MaterialApp(theme: PV.theme(), home: AcademyPage(model: m, checklist: PreflightChecklist())));
await tester.pump();
expect(find.text('Pre-flight checklist'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('article-rth')));
await tester.pumpAndSettle();
expect(find.text('Return-to-home height'), findsOneWidget);
loc.dispose();
});
}