- Leaving the Album put the camera in video mode even when it had been in photo mode: it restored FlightModel.captureMode, which only follows the HUD switch. It now restores the mode the camera reported before it was taken. - Playing a video on the aircraft drew the player over the whole HUD. The Album now sets FlightModel.hudHidden, and the flight screen shows only the feed, full-screen, while it is set (the HUD stays mounted, offstage). - Map tiles that failed once stayed as dark squares. Every map now uses one OpenStreetMap provider whose client also retries dropped connections, and failed tiles are fetched again when panned back into view. - Find My Drone saved simulated positions as the aircraft's last fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
12 KiB
Dart
274 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pilotvault_fly/dji_service.dart';
|
|
import 'package:pilotvault_fly/flight_model.dart';
|
|
import 'package:pilotvault_fly/fly/flight_status.dart';
|
|
import 'package:pilotvault_fly/fly/flight_trail.dart';
|
|
import 'package:pilotvault_fly/fly/hud_geometry.dart';
|
|
import 'package:pilotvault_fly/fly/product_profile.dart';
|
|
import 'package:pilotvault_fly/fly/state/aircraft_state.dart';
|
|
import 'package:pilotvault_fly/fly/state/alert_state.dart';
|
|
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
|
|
import 'package:pilotvault_fly/ui/fly/fly_screen.dart';
|
|
import 'package:pilotvault_fly/ui/fly/hud_kit.dart';
|
|
import 'package:pilotvault_fly/ui/fly/top/smart_battery_bar.dart';
|
|
|
|
import 'profile_fixtures.dart';
|
|
import 'telemetry_fixtures.dart';
|
|
|
|
AircraftState aircraftFrom(Map<String, dynamic> tick) => AircraftState()..apply(tick);
|
|
|
|
void main() {
|
|
group('FlightStatus', () {
|
|
test('disconnected and not-yet-reported', () {
|
|
expect(FlightStatus.of(connected: false, a: AircraftState()).text, 'Disconnected');
|
|
expect(FlightStatus.of(connected: true, a: AircraftState()).text, 'Connecting…');
|
|
});
|
|
|
|
test('in flight on GPS is ready', () {
|
|
expect(FlightStatus.of(connected: true, a: aircraftFrom(telemetryTick())), const FlightStatus('In Flight (GPS)', StatusLevel.ready));
|
|
});
|
|
|
|
test('on the ground with a weak GPS fix asks for caution', () {
|
|
final AircraftState a = aircraftFrom(telemetryTick()
|
|
..['isFlying'] = false
|
|
..['gpsSignalLevel'] = 1);
|
|
expect(FlightStatus.of(connected: true, a: a), const FlightStatus('Ready to Go (ATTI)', StatusLevel.caution));
|
|
a.visionPositioningBeingUsed = true;
|
|
expect(FlightStatus.of(connected: true, a: a).text, 'Ready to Go (Vision)');
|
|
});
|
|
|
|
test('automatic manoeuvres outrank battery and faults', () {
|
|
final AlertState alerts = AlertState()..apply(<Map<String, dynamic>>[motorBlocked]);
|
|
final AircraftState a = aircraftFrom(telemetryTick()
|
|
..['isGoingHome'] = true
|
|
..['isLowBattery'] = true);
|
|
expect(FlightStatus.of(connected: true, a: a, alerts: alerts).text, 'Returning to Home');
|
|
a.goingHome = false;
|
|
expect(FlightStatus.of(connected: true, a: a, alerts: alerts), const FlightStatus('Low battery', StatusLevel.caution));
|
|
a.lowBattery = false;
|
|
expect(FlightStatus.of(connected: true, a: a, alerts: alerts), const FlightStatus('Warning', StatusLevel.danger));
|
|
a.isFlying = false;
|
|
expect(FlightStatus.of(connected: true, a: a, alerts: alerts).text, 'Cannot take off');
|
|
});
|
|
|
|
test('a warning-level alert tints a ready state amber', () {
|
|
final AlertState alerts = AlertState()..apply(<Map<String, dynamic>>[sdSlow]);
|
|
expect(alerts.worst, AlertSeverity.warning);
|
|
expect(FlightStatus.of(connected: true, a: aircraftFrom(telemetryTick()), alerts: alerts).level, StatusLevel.caution);
|
|
});
|
|
});
|
|
|
|
group('HudGeometry', () {
|
|
test('bearing and distance', () {
|
|
expect(HudGeometry.bearing(55, 12, 55.001, 12), closeTo(0, 0.01));
|
|
expect(HudGeometry.bearing(55, 12, 55, 12.001), closeTo(90, 0.1));
|
|
expect(HudGeometry.bearing(55, 12, 54.999, 12), closeTo(180, 0.01));
|
|
expect(HudGeometry.bearing(55, 12, 55, 11.999), closeTo(270, 0.1));
|
|
// 0.001° of latitude ≈ 111 m.
|
|
expect(HudGeometry.distance(55, 12, 55.001, 12), closeTo(111.2, 0.5));
|
|
});
|
|
|
|
test('ball offset: north is up, east is right, beyond range pins to the rim', () {
|
|
Offset at(double b, double d, {double up = 0}) =>
|
|
HudGeometry.ballOffset(bearingDeg: b, distanceM: d, radius: 40, rangeM: 100, upDeg: up);
|
|
expect(at(0, 50).dy, closeTo(-20, 1e-9));
|
|
expect(at(0, 50).dx, closeTo(0, 1e-9));
|
|
expect(at(90, 50).dx, closeTo(20, 1e-9));
|
|
expect(at(180, 1000).dy, closeTo(40, 1e-9));
|
|
// Heading-up: a point dead ahead of an east-facing aircraft is drawn up.
|
|
expect(at(90, 100, up: 90).dy, closeTo(-40, 1e-9));
|
|
});
|
|
|
|
test('ball range steps up to fit the farthest point', () {
|
|
expect(HudGeometry.ballRange(0), 25);
|
|
expect(HudGeometry.ballRange(26), 50);
|
|
expect(HudGeometry.ballRange(450), 500);
|
|
expect(HudGeometry.ballRange(1e6), HudGeometry.ballRanges.last);
|
|
});
|
|
|
|
test('horizon moves up when the nose drops', () {
|
|
expect(HudGeometry.horizonShift(-9), lessThan(0));
|
|
expect(HudGeometry.horizonShift(9), greaterThan(0));
|
|
expect(HudGeometry.horizonShift(-90), -1);
|
|
});
|
|
|
|
test('formatDuration', () {
|
|
expect(formatDuration(null), '--:--');
|
|
expect(formatDuration(65), '01:05');
|
|
expect(formatDuration(3725), '1:02:05');
|
|
});
|
|
});
|
|
|
|
group('BatteryBarLayout', () {
|
|
BatteryBarLayout layout(int p) => BatteryBarLayout.from(
|
|
percent: p,
|
|
batteryNeededToLand: 6,
|
|
batteryNeededToGoHome: 12,
|
|
lowThreshold: 30,
|
|
criticalThreshold: 15,
|
|
);
|
|
|
|
test('markers sit at the reported percentages', () {
|
|
final BatteryBarLayout l = layout(64);
|
|
expect(l.fill, 0.64);
|
|
expect(l.landMarker, 0.06);
|
|
expect(l.homeMarker, 0.12);
|
|
expect(l.band, 0);
|
|
});
|
|
|
|
test('band follows the thresholds', () {
|
|
expect(layout(25).band, 1, reason: 'below the low warning');
|
|
expect(layout(14).band, 2, reason: 'below the critical warning');
|
|
expect(BatteryBarLayout.from(percent: 10, batteryNeededToGoHome: 12).band, 1);
|
|
expect(BatteryBarLayout.from(percent: 5, batteryNeededToLand: 6).band, 2);
|
|
});
|
|
|
|
test('unreported markers are absent', () {
|
|
final BatteryBarLayout l = BatteryBarLayout.from(percent: 50, batteryNeededToLand: 0);
|
|
expect(l.landMarker, isNull);
|
|
expect(l.homeMarker, isNull);
|
|
});
|
|
});
|
|
|
|
group('FlightTrail', () {
|
|
test('keeps a point every few metres and ignores no-fix zeros', () {
|
|
final AircraftState a = AircraftState();
|
|
final FlightTrail t = FlightTrail(a, minSpacing: 5);
|
|
a.apply(telemetryTick()
|
|
..['latitude'] = 0.0
|
|
..['longitude'] = 0.0);
|
|
expect(t.points, isEmpty);
|
|
a.apply(telemetryTick());
|
|
a.apply(telemetryTick()..['latitude'] = 55.67611); // ~1 m: dropped
|
|
a.apply(telemetryTick()..['latitude'] = 55.6762); // ~11 m: kept
|
|
expect(t.points, hasLength(2));
|
|
t.clear();
|
|
expect(t.points, isEmpty);
|
|
t.dispose();
|
|
});
|
|
});
|
|
|
|
group('P2 telemetry groups', () {
|
|
test('ssd, thermal and the RC role reach their sub-models', () {
|
|
final FlightModel m = FlightModel()..connected = true;
|
|
TelemetryReducer(m).apply(telemetryTick()
|
|
..['ssd'] = <Object?, Object?>{'connected': true, 'state': 'IDLE', 'remainingMB': 400000, 'availableRecordingSeconds': 3600}
|
|
..['thermal'] = <Object?, Object?>{'spotTemperature': 36.6}
|
|
..['rc'] = <Object?, Object?>{'batteryPercent': 80, 'mode': 'SLAVE', 'masterSlaveConnected': true});
|
|
expect(m.camera.ssd!.status, 'OK');
|
|
expect(m.camera.ssd!.availableRecordingSeconds, 3600);
|
|
expect(m.camera.thermalSpot, 36.6);
|
|
expect(m.links.rcRoleBadge, 'S');
|
|
});
|
|
});
|
|
|
|
group('HudScale', () {
|
|
test('phones shrink slightly, tablets grow', () {
|
|
expect(HudScale.scaleFor(390), 1.0);
|
|
expect(HudScale.scaleFor(340), lessThan(1.0));
|
|
expect(HudScale.scaleFor(800), greaterThan(1.2));
|
|
});
|
|
});
|
|
|
|
group('FlyScreen', () {
|
|
FlightModel modelFor(Map<String, dynamic> profile, {Map<String, dynamic>? tick}) {
|
|
final FlightModel m = FlightModel()
|
|
..connected = true
|
|
..profile = ProductProfile.fromBridge(profile);
|
|
TelemetryReducer(m).apply(tick ?? telemetryTick());
|
|
return m;
|
|
}
|
|
|
|
Map<String, dynamic> dualPackTick() {
|
|
final Map<String, dynamic> t = telemetryTick();
|
|
final Map<Object?, Object?> b = Map<Object?, Object?>.from(t['battery'] as Map);
|
|
final List<Object?> packs = List<Object?>.from(b['packs'] as List);
|
|
packs.add(<Object?, Object?>{...(packs.first as Map), 'index': 1, 'percent': 61});
|
|
b['packs'] = packs;
|
|
t['battery'] = b;
|
|
t['ssd'] = <Object?, Object?>{'connected': true, 'state': 'IDLE', 'remainingMB': 400000, 'availableRecordingSeconds': 3600};
|
|
t['rc'] = <Object?, Object?>{'batteryPercent': 80, 'mode': 'MASTER', 'masterSlaveConnected': true};
|
|
return t;
|
|
}
|
|
|
|
Future<void> pump(WidgetTester tester, FlightModel m, {Size size = const Size(844, 390)}) async {
|
|
tester.view.physicalSize = size;
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: DjiService())));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
}
|
|
|
|
int batteryBars(WidgetTester tester) =>
|
|
tester.widgetList(find.byWidgetPredicate((Widget w) => w is CustomPaint && w.painter is SmartBatteryPainter)).length;
|
|
|
|
testWidgets('Spark on Wi-Fi: Wi-Fi bars, one battery bar, no SSD', (WidgetTester tester) async {
|
|
await pump(tester, modelFor(sparkWifi));
|
|
expect(find.text('WI-FI'), findsOneWidget);
|
|
expect(find.text('RC'), findsNothing);
|
|
expect(batteryBars(tester), 1);
|
|
expect(find.textContaining('SSD'), findsNothing);
|
|
expect(find.text('In Flight (GPS)'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('hudHidden leaves only the feed, full-screen', (WidgetTester tester) async {
|
|
final FlightModel m = modelFor(phantom4);
|
|
await pump(tester, m);
|
|
expect(find.text('64%'), findsOneWidget);
|
|
|
|
m.hudHidden.value = true;
|
|
await tester.pump();
|
|
expect(find.text('64%'), findsNothing, reason: 'the HUD is offstage');
|
|
final Rect video = tester.getRect(find.byKey(const ValueKey<String>('video-slot')));
|
|
expect(video, Offset.zero & const Size(844, 390));
|
|
|
|
m.hudHidden.value = false;
|
|
await tester.pump();
|
|
expect(find.text('64%'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('Phantom 4: RC + HD bars and the telemetry strip', (WidgetTester tester) async {
|
|
await pump(tester, modelFor(phantom4));
|
|
expect(find.text('RC'), findsOneWidget);
|
|
expect(find.text('HD'), findsOneWidget);
|
|
expect(find.text('WI-FI'), findsNothing);
|
|
expect(batteryBars(tester), 1);
|
|
expect(find.text('32.4 m', findRichText: true), findsOneWidget, reason: 'altitude');
|
|
expect(find.text('13:00'), findsOneWidget, reason: 'remaining flight time');
|
|
expect(find.text('64%'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('Inspire 2 + X5S: dual battery, SSD, aperture and master badge', (WidgetTester tester) async {
|
|
await pump(tester, modelFor(inspire2X5s, tick: dualPackTick()));
|
|
expect(batteryBars(tester), 2);
|
|
expect(find.text('SSD'), findsOneWidget);
|
|
expect(find.text('F'), findsOneWidget);
|
|
expect(find.text('M'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('lays out without overflow on a tablet and a small phone', (WidgetTester tester) async {
|
|
for (final Size s in const <Size>[Size(1280, 800), Size(740, 340)]) {
|
|
await pump(tester, modelFor(inspire2X5s, tick: dualPackTick()), size: s);
|
|
expect(tester.takeException(), isNull, reason: '$s');
|
|
}
|
|
});
|
|
|
|
testWidgets('tapping the corner box swaps map and video', (WidgetTester tester) async {
|
|
await pump(tester, modelFor(phantom4));
|
|
expect(find.text('MAP'), findsOneWidget);
|
|
await tester.tap(find.text('MAP'));
|
|
await tester.pump();
|
|
expect(find.text('VIDEO'), findsOneWidget);
|
|
expect(find.text('CLR'), findsOneWidget, reason: 'minimap controls while the map is big');
|
|
await tester.tap(find.text('VIDEO'));
|
|
await tester.pump();
|
|
expect(find.text('MAP'), findsOneWidget);
|
|
});
|
|
});
|
|
}
|