A Flutter app that runs next to DJI Fly without the DJI SDK. It receives the aircraft's Remote ID (Bluetooth, BT5, Wi-Fi NaN and Beacon) in a foreground service, streams the paired aircraft to the hub over /ws/device, and turns take-off to landing into a logbook entry filed through /api/flights, queued when offline. Covers phases A0-A3 of the Fly Agent plan; needs no server change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
3.7 KiB
Dart
98 lines
3.7 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pilotvault_fly_agent/session/flight_session.dart';
|
|
|
|
void main() {
|
|
final DateTime t0 = DateTime(2026, 10, 22, 14, 5);
|
|
|
|
FixSample fix(int s, {bool airborne = true, double height = 30, double lat = 55.0, double lon = 12.0}) =>
|
|
FixSample(at: t0.add(Duration(seconds: s)), airborne: airborne, lat: lat, lon: lon, height: height, speed: 3);
|
|
|
|
FlightSession armed() => FlightSession()
|
|
..droneId = 'd1'
|
|
..ridSerial = 'SN1'
|
|
..arm(t0);
|
|
|
|
test('armed session starts on the first airborne fix', () {
|
|
final FlightSession s = armed();
|
|
expect(s.onFix(fix(0, airborne: false, height: 0)), isFalse);
|
|
expect(s.phase, SessionPhase.armed);
|
|
expect(s.onFix(fix(5)), isTrue);
|
|
expect(s.phase, SessionPhase.active);
|
|
expect(s.autoStarted, isTrue);
|
|
expect(s.startedAt, t0.add(const Duration(seconds: 5)));
|
|
});
|
|
|
|
test('stops at touchdown after the landed hold, not at the end of the hold', () {
|
|
final FlightSession s = armed();
|
|
s.onFix(fix(0));
|
|
s.onFix(fix(10, airborne: false, height: 0));
|
|
expect(s.onFix(fix(20, airborne: false, height: 0)), isFalse);
|
|
expect(s.onFix(fix(26, airborne: false, height: 0)), isTrue);
|
|
expect(s.phase, SessionPhase.landed);
|
|
expect(s.endedAt, t0.add(const Duration(seconds: 10)));
|
|
});
|
|
|
|
test('a brief low hover does not end the flight', () {
|
|
final FlightSession s = armed();
|
|
s.onFix(fix(0));
|
|
s.onFix(fix(10, airborne: false, height: 0.5));
|
|
s.onFix(fix(20, airborne: true));
|
|
s.onFix(fix(30, airborne: false, height: 0));
|
|
s.onFix(fix(40, airborne: false, height: 0));
|
|
expect(s.phase, SessionPhase.active);
|
|
});
|
|
|
|
test('Remote ID silence stops the flight at last contact', () {
|
|
final FlightSession s = armed();
|
|
s.onFix(fix(0));
|
|
s.onFix(fix(30));
|
|
expect(s.onTick(t0.add(const Duration(seconds: 60))), isFalse);
|
|
expect(s.onTick(t0.add(const Duration(seconds: 91))), isTrue);
|
|
expect(s.endedAt, t0.add(const Duration(seconds: 30)));
|
|
expect(s.alerts.single.code, 'rid_lost');
|
|
});
|
|
|
|
test('tracks max height, distance and a single height alert', () {
|
|
final FlightSession s = armed();
|
|
s.onFix(fix(0, height: 50, lat: 55.0));
|
|
s.onFix(fix(1, height: 125, lat: 55.001));
|
|
s.onFix(fix(2, height: 130, lat: 55.002));
|
|
expect(s.maxHeight, 130);
|
|
expect(s.distance, closeTo(222, 2));
|
|
expect(s.alerts.where((SessionAlert a) => a.code == 'height').length, 1);
|
|
});
|
|
|
|
test('flight input carries the § 5 manual fields and a GeoJSON route', () {
|
|
final FlightSession s = armed()
|
|
..purpose = 'commercial'
|
|
..category = 'open'
|
|
..weather = '12°C';
|
|
s.onFix(fix(0, lat: 55.0));
|
|
s.onFix(fix(60, lat: 55.001, height: 80));
|
|
s.stop(t0.add(const Duration(minutes: 12)));
|
|
final Map<String, dynamic> j = s.toFlightInput();
|
|
expect(j['operationDate'], '2026-10-22');
|
|
expect(j['startTime'], '14:05');
|
|
expect(j['endTime'], '14:17');
|
|
expect(j['drone'], 'd1');
|
|
expect(j['maxAltitudeAgl'], 80.0);
|
|
expect(j['loggingPath'], 'manual');
|
|
expect((j['areaRoute'] as String), contains('Remote ID track'));
|
|
expect(j['routeGeojson']['type'], 'LineString');
|
|
expect((j['routeGeojson']['coordinates'] as List).first, <double>[12.0, 55.0, 30.0]);
|
|
expect(j['notes'], contains('serial SN1'));
|
|
});
|
|
|
|
test('survives a round trip through persistence', () {
|
|
final FlightSession s = armed();
|
|
s.onFix(fix(0));
|
|
s.onFix(fix(30, height: 140, lat: 55.001));
|
|
final FlightSession r = FlightSession.decode(s.encode());
|
|
expect(r.phase, SessionPhase.active);
|
|
expect(r.track.length, 2);
|
|
expect(r.maxHeight, 140);
|
|
expect(r.alerts.single.code, 'height');
|
|
expect(r.startedAt, s.startedAt);
|
|
});
|
|
}
|