import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pilotvault_fly/flight_model.dart'; import 'package:pilotvault_fly/fly/state/alert_state.dart'; import 'package:pilotvault_fly/fly/state/link_state.dart'; import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; import 'package:pilotvault_fly/ui/fly/debug/telemetry_inspector_page.dart'; import 'telemetry_fixtures.dart'; /// Counts notifications from [l]. int Function() counter(Listenable l) { int n = 0; l.addListener(() => n++); return () => n; } void main() { late FlightModel m; late TelemetryReducer r; setUp(() { m = FlightModel()..connected = true; r = TelemetryReducer(m); }); group('TelemetryReducer', () { test('a tick fills every sub-model', () { expect(r.apply(telemetryTick()), isTrue); expect(m.aircraft.flightMode, 'P-GPS'); expect(m.aircraft.flightModeEnum, 'GPS_ATTI'); expect(m.aircraft.pitch, -1.2); expect(m.aircraft.flightTimeSeconds, 245); expect(m.aircraft.ultrasonicHeight, isNull, reason: 'NaN reads as unreported'); expect(m.aircraft.vpsHeight, isNull); expect(m.aircraft.goHome!.batteryNeededToGoHome, 12); expect(m.aircraft.goHome!.remainingFlightTime, 780); expect(m.battery.percent, 64); expect(m.battery.lowWarningThreshold, 30); expect(m.battery.packs.single.cellVoltages, [3.91, 3.90, 3.92, 3.88]); expect(m.battery.packs.single.cellDeviation, closeTo(0.04, 1e-9)); expect(m.links.uplinkQuality, 88); expect(m.links.downlinkQuality, 74); expect(m.links.rcBatteryPercent, 81); expect(m.camera.storage!.status, 'OK'); expect(m.camera.storage!.availableRecordingSeconds, 7260); expect(m.camera.focusMode, 'AFC'); expect(m.gimbal.pitch, -30); expect(m.vision.nose!.sectors, hasLength(4)); expect(m.vision.nose!.sectors[1].level, 2); expect(m.vision.nose!.sectors[3].level, 0); expect(m.vision.closest, 6.5); // Pre-P2 screens read the same values through the flat getters. expect(m.batteryPercent, 64); expect(m.altitude, 32.4); expect(m.gimbalPitch, -30); expect(m.sdRemainingMB, 48211); }); test('FlightModel notifies once per tick, not once per sub-model', () { final int Function() n = counter(m); r.apply(telemetryTick()); expect(n(), 1); }); test('an unchanged slow group does not re-notify its sub-model', () { r.apply(telemetryTick()); final int Function() battery = counter(m.battery); final int Function() links = counter(m.links); final int Function() aircraft = counter(m.aircraft); r.apply(telemetryTick(altitude: 33)); // only flight state moved expect(aircraft(), 1); expect(battery(), 0); expect(links(), 0); r.apply(telemetryTick(altitude: 33, battery: 63, uplink: 60)); expect(battery(), 1); expect(links(), 1); expect(m.battery.percent, 63); expect(LinkState.bars(m.links.uplinkQuality), 3); }); test('a group missing from the snapshot leaves its sub-model empty', () { r.apply(telemetryTick()); final Map noVision = telemetryTick()..remove('vision'); r.apply(noVision); expect(m.vision.sensors, isEmpty); }); test('telemetry after a disconnect is ignored', () { m.connected = false; r.apply(telemetryTick()); expect(m.aircraft.flightMode, isNull); expect(m.battery.percent, isNull); }); test('clearTelemetry + reset start the next aircraft from scratch', () { r.apply(telemetryTick()); m.clearTelemetry(); r.reset(); expect(m.battery.percent, isNull); expect(m.camera.storage, isNull); r.apply(telemetryTick()); expect(m.battery.percent, 64, reason: 'same groups as before must re-apply after a reset'); }); test('smart-RTH countdown is visible', () { r.apply(telemetryTick(smartRTHState: 'COUNTING_DOWN')); expect(m.aircraft.goHome!.smartRTHCountingDown, isTrue); }); test('non-telemetry events pass through', () { expect(r.apply({'type': 'connection', 'connected': true}), isFalse); expect(r.apply({'type': 'mission', 'state': 'tracking'}), isTrue); expect(m.tracking, isTrue); }); }); group('AlertState', () { test('diagnostics are graded, ordered and de-duplicated', () { r.apply(diagnostics(>[compassInterference, sdSlow, motorBlocked, compassInterference])); final List a = m.alerts.all; expect(a, hasLength(3)); expect(a.first.reason, 'Motor blocked.'); expect(a.first.severity, AlertSeverity.critical); expect(m.alerts.top!.code, 1301); expect(a.map((DiagnosticAlert d) => d.severity).skip(1), everyElement(AlertSeverity.warning)); expect(m.alerts.lastAdded, hasLength(3)); }); test('a listed alert keeps its first-seen time; cleared ones drop out', () { final AlertState s = AlertState(); final DateTime t0 = DateTime(2026, 10, 19, 12); s.apply(>[compassInterference], now: t0); s.apply(>[compassInterference, sdSlow], now: t0.add(const Duration(seconds: 5))); expect(s.all.firstWhere((DiagnosticAlert d) => d.code == 1004).firstSeen, t0); expect(s.lastAdded, {'CAMERA/2010/0'}); s.apply(>[sdSlow]); expect(s.all.single.code, 2010); }); test('a dismissed alert stays hidden until it clears and returns', () { final AlertState s = AlertState(); s.apply(>[sdSlow]); s.dismiss('CAMERA/2010/0'); expect(s.active, isEmpty); expect(s.top, isNotNull, reason: 'the marquee still shows it'); s.apply(>[sdSlow]); expect(s.active, isEmpty); s.apply(const >[]); s.apply(>[sdSlow]); expect(s.active, hasLength(1)); }); test('grading heuristic', () { expect(AlertState.grade('BATTERY', 'Critically low battery.', null), AlertSeverity.critical); expect(AlertState.grade('FLIGHT_CONTROLLER', 'IMU preheating.', null), AlertSeverity.info); expect(AlertState.grade('GIMBAL', 'Gimbal motor overloaded.', null), AlertSeverity.critical); expect(AlertState.grade('CAMERA', 'SD card write speed slow.', null), AlertSeverity.warning); }); }); testWidgets('telemetry inspector renders every section from a tick', (WidgetTester tester) async { r.apply(telemetryTick()); r.apply(diagnostics(>[motorBlocked])); await tester.pumpWidget(MaterialApp(home: Scaffold(body: TelemetryInspector(model: m)))); expect(find.text('P-GPS (GPS_ATTI)'), findsOneWidget); await tester.scrollUntilVisible(find.text('88 / 100'), 300); expect(find.text('88 / 100'), findsOneWidget); await tester.scrollUntilVisible(find.text('critical · FLIGHT_CONTROLLER 1301'), 300); expect(find.text('critical · FLIGHT_CONTROLLER 1301'), findsOneWidget); await tester.pumpWidget(const SizedBox()); // dispose the timer }); }