Files
PilotVault/Fly App/test/fly/hud_test.dart
T
tajniak81andClaude Opus 5.5 cfab444c65 Fix what the first secondary-screen run on the Mavic Pro turned up
- 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>
2026-10-21 21:13:24 +02:00

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);
});
});
}