Files
PilotVault/Fly App/test/fly/hud_test.dart
T
tajniak81andClaude Opus 5.5 789818679a Switch between Course Lock and Home Lock through normal heading
On the Mavic Pro the flight controller refused Course Lock -> Home Lock with
"There is a conflicting setting in the mission"; from normal heading the same
switch works, so the gate now goes through normal first. The mode chip also
stops repeating the lock when the aircraft already names it as the mode
("CL · CL" -> "CL").

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

285 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('the mode chip does not repeat a lock the mode already names', (WidgetTester tester) async {
await pump(tester, modelFor(phantom4, tick: telemetryTick()..['flightMode'] = 'CL'..['orientationMode'] = 'COURSE_LOCK'));
expect(find.text('CL'), findsOneWidget);
expect(find.text('CL · CL'), findsNothing);
});
testWidgets('the mode chip adds the lock to a plain mode', (WidgetTester tester) async {
await pump(tester, modelFor(phantom4, tick: telemetryTick()..['orientationMode'] = 'HOME_LOCK'));
expect(find.text('P-GPS · HL'), findsOneWidget);
});
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);
});
});
}