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>
576 lines
23 KiB
Dart
576 lines
23 KiB
Dart
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();
|
||
});
|
||
}
|