Files
PilotVault/Fly App/test/fly/hud_test.dart
T
tajniak81andClaude Opus 5.5 590f2a6152 Give the Fly App a GO 4-style flight HUD
The new flight screen (lib/ui/fly/fly_screen.dart) is built from layers.
Each readout listens to its own telemetry sub-model, so a 10 Hz tick only
repaints what changed. It adds a status bar with a status chip and tip
marquee, a smart battery bar with land/RTH markers, a camera info strip,
an attitude ball, an H/D/VS/HS/VPS strip, and a minimap that swaps places
with the video when tapped. The HUD is laid out in design pixels and
scaled to phone or tablet inside the safe area.

What the HUD shows depends on the product: dual or multi battery bars,
SSD status, a thermal spot reading, a master/slave RC badge, and Wi-Fi
bars in place of RC/HD. The bridges now report the data for these: the
camera's ssd/thermal groups and the RC's master/slave mode.

The old FlightControlPage can still be opened from Settings > About >
Classic flight screen until the P3 safety flows land. The same tab also
gets a toggle for the performance overlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:54:09 +02:00

257 lines
11 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('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);
});
});
}