Give the Fly App GO 4-style obstacle radar, on-screen sticks, AirSense and fly zones

Vision radar: graded obstacle sectors around the centre of the video,
the nearest distance per side, the obstacle overhead, and BLOCKED on a
side whose sensors cannot see once the motors run. On aircraft with 360°
sensing, sides without their own readings are filled from the
perception ring. The same sectors sit on the attitude ball's rim, turned
by the heading. Settings > Vision gains Display radar chart and the
braking / sensing distances per direction.

On-screen sticks: the rail's STICKS button hands the aircraft to two
stick pads after a slide-to-confirm. The 25 Hz stream runs natively and
sends zeros once Dart's last command is 300 ms old, so a stalled UI
leaves the aircraft hovering. The sticks close when the remote takes
over, on RTH or on landing. They are simulator-only on aircraft with a
remote unless Settings > General > Developer allows real flights; Spark
and Mavic Air on Wi-Fi may fly them for real. Which MSDK axis is forward
in velocity + body mode is coded from DJI's documentation and must be
checked in the simulator first.

AirSense: a banner for manned aircraft at warning level 2 and up, with
distance, direction and ICAO code; the planes on the map; the full list
in a sheet and Settings > Safety; and the aircraft's own avoidance
action with its countdown.

Fly zones: DJI GEO zones around the aircraft (or the phone) on the map
with their height limits, a banner for FlySafe notices and in/near-zone
states, take-off refused in restricted and locked authorization zones,
self-unlock of authorization zones, the aircraft's licences (enable,
sync from the DJI account, remove all; ground only), and the GEO
database version with an update button.

Tested with unit and widget tests and screenshot renders only; nothing
has run on an aircraft or the simulator yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 21:14:12 +02:00
1 parent 93f4a580e9
commit e02bb0e5cd
34 files changed
+4376 -15

No files matched your search

+54
View File
@@ -80,6 +80,60 @@ class FakeDjiService extends DjiService {
Future<bool?> getActiveTrackRetreat() async => false;
@override
Future<void> stopActiveTrack() => _record('stopActiveTrack');
// ── Sensing distances / fly zones (plan P8) ────────────────────────────────
Map<String, dynamic> sensing = <String, dynamic>{};
List<Map<String, dynamic>> flyZones = <Map<String, dynamic>>[];
List<int> unlockedZones = <int>[];
/// Zone fetches, counted apart from [calls]: the flight screen fetches on its own.
int zoneFetches = 0;
@override
Future<Map<String, dynamic>> getSensingDistances() async => sensing;
@override
Future<void> setAvoidanceDistance(String direction, double metres) => _record('setAvoidanceDistance($direction, $metres)');
@override
Future<void> setMaxPerceptionDistance(String direction, double metres) => _record('setMaxPerceptionDistance($direction, $metres)');
@override
Future<List<Map<String, dynamic>>> getFlyZonesNearby({double? latitude, double? longitude}) async {
zoneFetches++;
return flyZones;
}
@override
Future<List<int>> getUnlockedFlyZones() async => unlockedZones;
@override
Future<void> unlockAuthorizationFlyZone(int id) async {
await _record('unlockAuthorizationFlyZone($id)');
unlockedZones = <int>[...unlockedZones, id];
}
/// What `getFlyZoneLicences` / `getFlyZoneDatabase` answer.
Map<String, dynamic> licences = <String, dynamic>{'supported': false, 'zones': <Object?>[]};
Map<String, dynamic> flyZoneDb = <String, dynamic>{};
@override
Future<Map<String, dynamic>> getFlyZoneLicences() async => licences;
@override
Future<void> setFlyZoneLicenceEnabled(int id, bool enabled) => _record('setFlyZoneLicenceEnabled($id, $enabled)');
@override
Future<void> syncFlyZoneLicences() => _record('syncFlyZoneLicences');
@override
Future<void> deleteAllFlyZoneLicences() => _record('deleteAllFlyZoneLicences');
@override
Future<Map<String, dynamic>> getFlyZoneDatabase() async => flyZoneDb;
@override
Future<void> startFlyZoneDatabaseUpgrade() => _record('startFlyZoneDatabaseUpgrade');
@override
Future<void> stopFlyZoneDatabaseUpgrade() => _record('stopFlyZoneDatabaseUpgrade');
// ── Virtual sticks (plan P8) ───────────────────────────────────────────────
@override
Future<void> setVirtualStickEnabled(bool on) => _record('setVirtualStickEnabled($on)');
@override
Future<void> sendVirtualStick({required double forward, required double right, required double yaw, required double up}) =>
_record('sendVirtualStick(${forward.toStringAsFixed(1)}, ${right.toStringAsFixed(1)}, ${yaw.toStringAsFixed(0)}, ${up.toStringAsFixed(1)})');
@override
Future<void> startCompassCalibration() => _record('startCompassCalibration');
+369
View File
@@ -0,0 +1,369 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/flight_model.dart';
import 'package:pilotvault_fly/fly/fly_prefs.dart';
import 'package:pilotvault_fly/fly/fly_zone_loader.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
import 'package:pilotvault_fly/fly/safety_gate.dart';
import 'package:pilotvault_fly/fly/sensing_distances.dart';
import 'package:pilotvault_fly/fly/state/fly_zone_state.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/fly/vision_radar.dart';
import 'package:pilotvault_fly/ui/fly/bottom/attitude_ball.dart';
import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart';
import 'package:pilotvault_fly/ui/fly/fly_screen.dart';
import 'package:pilotvault_fly/ui/fly/panels/settings/sensing_rows.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
FlightModel modelWith(Map<String, dynamic> tick, {Map<String, dynamic>? profile}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile ?? phantom4);
TelemetryReducer(m).apply(tick);
return m;
}
Map<String, dynamic> airport({int id = 101, String category = 'AUTHORIZATION', double lat = 55.68, double lon = 12.57, double radius = 500}) =>
<String, dynamic>{
'id': id,
'name': 'Test Airport $id',
'category': category,
'reason': 'COMMERCIAL_AIRPORTS',
'latitude': lat,
'longitude': lon,
'radius': radius,
'subZones': <Object?>[
<String, Object?>{
'areaId': 1,
'shape': 'POLYGON',
'maxHeight': 0,
'vertices': <Object?>[
<Object?>[55.67, 12.56],
<Object?>[55.69, 12.56],
<Object?>[55.69, 12.58],
],
},
],
};
void main() {
group('Attitude ball radar ring', () {
test('graded sectors become rim arcs, nose-relative; empty without obstacles', () {
final FlightModel m = modelWith(telemetryTick(noseDistance: 2.0));
final AttitudeBallData d = AttitudeBallData.from(m.aircraft, radar: VisionRadar.of(m.vision));
// The fixture's INVALID sector drops out; the rest keep their band.
expect(d.obstacles, hasLength(3));
expect(d.obstacles.first.start, -30);
expect(d.obstacles.map((BallObstacle o) => o.band), contains(RadarBand.notice));
expect(AttitudeBallData.from(m.aircraft).obstacles, isEmpty);
expect(d == AttitudeBallData.from(m.aircraft, radar: VisionRadar.of(m.vision)), isTrue);
expect(d == AttitudeBallData.from(m.aircraft), isFalse);
});
});
group('SensingDistances', () {
test('reads per direction; unsupported is null; steps snap and clamp', () {
final SensingDistances d = SensingDistances.fromBridge(<String, dynamic>{
'avoidance': <String, Object?>{'Horizontal': 3.0, 'Upward': null, 'Downward': 0.5},
'perception': <String, Object?>{'Horizontal': 20.0},
});
expect(d.value(SensingKind.avoidance, SensingDirection.horizontal), 3.0);
expect(d.value(SensingKind.avoidance, SensingDirection.up), isNull);
expect(d.value(SensingKind.perception, SensingDirection.down), isNull);
expect(d.isEmpty, isFalse);
expect(SensingDistances.stepped(SensingKind.avoidance, SensingDirection.horizontal, 3, 1), 3.5);
expect(SensingDistances.stepped(SensingKind.avoidance, SensingDirection.horizontal, 10, 1), 10);
expect(SensingDistances.stepped(SensingKind.avoidance, SensingDirection.down, 0.5, -1), 0.5);
expect(SensingDistances.stepped(SensingKind.avoidance, SensingDirection.down, 0.5, 1), 0.6);
expect(const SensingDistances().isEmpty, isTrue);
});
testWidgets('rows show what the aircraft reports; a step writes, a refusal rolls back', (WidgetTester tester) async {
final FakeDjiService dji = FakeDjiService()
..sensing = <String, dynamic>{
'avoidance': <String, Object?>{'Horizontal': 3.0, 'Upward': null, 'Downward': null},
'perception': <String, Object?>{'Horizontal': null, 'Upward': null, 'Downward': null},
};
final List<String> errors = <String>[];
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: SensingDistanceRows(dji: dji, connected: true, onError: (String l, Object e) => errors.add(l))),
));
await tester.pump();
expect(find.byKey(const ValueKey<String>('row-sensing-avoidance-horizontal')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('row-sensing-avoidance-up')), findsNothing);
await tester.tap(find.byKey(const ValueKey<String>('step-sensing-avoidance-horizontal-plus')));
await tester.pump();
expect(dji.calls.single, 'setAvoidanceDistance(Horizontal, 3.5)');
expect(find.text('3.5 m'), findsOneWidget);
dji.failWith = Exception('refused');
await tester.tap(find.byKey(const ValueKey<String>('step-sensing-avoidance-horizontal-plus')));
await tester.pump();
await tester.pump();
expect(find.text('3.5 m'), findsOneWidget, reason: 'rolled back');
expect(errors, <String>['Braking distance']);
});
});
group('AirSense avoidance', () {
test('the flight controller\'s own action is read off the fast group', () {
final FlightModel m = modelWith(telemetryTick()..['airSenseAvoidance'] = <String, Object?>{'mode': 'LANDING', 'countdown': 4.2});
expect(m.airSense.avoiding, isTrue);
expect(m.airSense.avoidanceCountdown, 4.2);
TelemetryReducer(m).apply(telemetryTick()..['airSenseAvoidance'] = <String, Object?>{'mode': 'NONE', 'countdown': 0.0});
expect(m.airSense.avoiding, isFalse);
});
});
group('FlyZoneState', () {
test('zones parse with their polygons and sort by distance from the edge', () {
final FlyZoneState z = FlyZoneState()
..applyZones(<Map<String, dynamic>>[
airport(id: 1, lat: 55.80),
airport(id: 2, category: 'RESTRICTED', lat: 55.68, radius: 100),
], latitude: 55.6761, longitude: 12.5683);
final List<FlyZone> near = z.nearest(55.6761, 12.5683);
expect(near.map((FlyZone f) => f.id), <int>[2, 1]);
expect(near.first.level, ZoneLevel.danger);
expect(near.last.unlockable, isTrue);
expect(near.last.subZones.single.isPolygon, isTrue);
expect(near.last.reasonLabel, 'Commercial airports');
});
test('a notice lasts 15 s; a serious one stays while it counts down', () {
final DateTime t0 = DateTime(2026, 10, 20, 12);
final FlyZoneState z = FlyZoneState()..applyNotice(<String, dynamic>{'kind': 'WARNING', 'event': 'TAKE_OFF_NEAR_NO_FLY_ZONE'}, now: t0);
expect(z.noticeAt(t0.add(const Duration(seconds: 10))), isNotNull);
expect(z.noticeAt(t0.add(const Duration(seconds: 16))), isNull);
z.applyNotice(<String, dynamic>{'kind': 'SERIOUS_WARNING', 'event': 'IN_NO_FLY_ZONE', 'countdown': 60, 'zones': <Object?>[7]}, now: t0);
expect(z.noticeAt(t0.add(const Duration(seconds: 40)))!.title, 'In a no-fly zone: the aircraft is landing');
expect(z.noticeAt(t0.add(const Duration(seconds: 61))), isNull);
expect(z.notice!.zoneIds, <int>[7]);
});
test('a refetch is due after 2 km or 10 minutes', () {
final DateTime t0 = DateTime(2026, 10, 20, 12);
final FlyZoneState z = FlyZoneState();
expect(z.needsZonesFor(55.6761, 12.5683), isTrue);
z.applyZones(const <Map<String, dynamic>>[], latitude: 55.6761, longitude: 12.5683, now: t0);
expect(z.needsZonesFor(55.6800, 12.5683, now: t0), isFalse);
expect(z.needsZonesFor(55.7000, 12.5683, now: t0), isTrue);
expect(z.needsZonesFor(55.6761, 12.5683, now: t0.add(const Duration(minutes: 11))), isTrue);
});
test('FlySafe notices count without an aircraft; the state needs one', () {
final FlightModel m = FlightModel();
TelemetryReducer(m).apply(<String, dynamic>{'type': 'flySafe', 'kind': 'FLYING_SAFETY_TIP', 'event': 'TAKE_OFF_IN_WARNING_AREA'});
expect(m.flyZones.notice!.kind, 'FLYING_SAFETY_TIP');
final FlightModel c = modelWith(telemetryTick()..['flyZone'] = <String, Object?>{'state': 'NEAR_RESTRICTED_ZONE'});
expect(c.flyZones.stateLevel, ZoneLevel.caution);
expect(c.flyZones.stateLabel, 'Near a restricted zone');
c.clearTelemetry();
expect(c.flyZones.state, isNull);
});
});
group('SafetyGate fly zones', () {
Map<String, dynamic> onGround(String state) => telemetryTick()
..['isFlying'] = false
..['areMotorsOn'] = false
..['flyZone'] = <String, Object?>{'state': state};
test('take-off is refused in restricted and locked authorization zones, warned in warning zones', () {
String? block(String state) =>
SafetyGate(model: modelWith(onGround(state)), dji: FakeDjiService()).check(FlightCommand.takeOff).blockers.firstOrNull?.message;
expect(block('IN_RESTRICTED_ZONE'), contains('restricted zone'));
expect(block('IN_AUTHORIZED_ZONE'), contains('unlock it first'));
final GateVerdict warn = SafetyGate(model: modelWith(onGround('IN_WARNING_ZONE')), dji: FakeDjiService()).check(FlightCommand.takeOff);
expect(warn.allowed, isTrue);
expect(warn.warnings.map((GateCheck c) => c.message), contains(startsWith('Warning zone')));
});
test('unlock calls the GEO system and refreshes the unlocked list; only authorization zones', () async {
final FakeDjiService dji = FakeDjiService();
final FlightModel m = modelWith(telemetryTick());
final SafetyGate gate = SafetyGate(model: m, dji: dji);
await gate.unlockFlyZone(FlyZone.fromMap(airport(id: 42)));
expect(dji.calls.single, 'unlockAuthorizationFlyZone(42)');
expect(m.flyZones.unlocked, <int>{42});
await expectLater(gate.unlockFlyZone(FlyZone.fromMap(airport(category: 'RESTRICTED'))), throwsA(isA<MissionRefused>()));
});
});
group('FlyZoneLoader', () {
test('fetches around the aircraft once, and again only after it moved', () async {
final FakeDjiService dji = FakeDjiService()..flyZones = <Map<String, dynamic>>[airport()];
final FlightModel m = modelWith(telemetryTick());
final FlyZoneLoader loader = FlyZoneLoader(model: m, dji: dji)..start();
await Future<void>.delayed(Duration.zero);
expect(dji.zoneFetches, 1);
expect(m.flyZones.zones.single.id, 101);
TelemetryReducer(m).apply(telemetryTick());
await Future<void>.delayed(Duration.zero);
expect(dji.zoneFetches, 1);
TelemetryReducer(m).apply(telemetryTick()..['latitude'] = 55.72);
await Future<void>.delayed(Duration.zero);
expect(dji.zoneFetches, 2);
loader.dispose();
});
});
group('Licences and GEO database', () {
Map<String, dynamic> licence(int id, {bool enabled = false, bool expired = false}) => <String, dynamic>{
'id': id,
'index': id,
'name': 'Site $id',
'type': 'CIRCLE_UNLOCK_AREA',
'latitude': 55.68,
'longitude': 12.57,
'radius': 300.0,
'height': 120,
'startTime': '2026-10-01',
'endTime': '2026-12-31',
'expired': expired,
'enabled': enabled,
};
test('licences parse in index order; one is the enabled one', () {
final FlyZoneState z = FlyZoneState()
..applyLicences(<String, dynamic>{
'supported': true,
'zones': <Object?>[licence(2, enabled: true), licence(1)],
});
expect(z.licencesSupported, isTrue);
expect(z.licences.map((FlyZoneLicence l) => l.id), <int>[1, 2]);
expect(z.enabledLicence!.id, 2);
expect(z.licences.first.hasArea, isTrue);
expect(z.licences.first.validity, '2026-10-01 → 2026-12-31');
z.clearAircraft();
expect(z.licencesSupported, isNull);
expect(z.licences, isEmpty);
});
test('database state, and progress read as a fraction or a percentage', () {
final FlyZoneState z = FlyZoneState()
..applyDatabase(<String, dynamic>{'basicVersion': '2.1', 'basicState': 'OUT_OF_DATE', 'preciseVersion': null, 'preciseState': 'UP_TO_DATE'});
expect(z.database!.outOfDate, isTrue);
expect(FlyZoneDatabase.stateLabel(z.database!.basicState), 'Out of date');
z.applyUpgradeProgress(0.4);
expect(z.upgradeProgress, 0.4);
z.applyUpgradeProgress(55);
expect(z.upgradeProgress, closeTo(0.55, 1e-9));
z.applyUpgradeProgress(100);
expect(z.upgradeProgress, isNull, reason: 'a complete download ends the upgrade');
});
test('sub-zone height labels and where they go', () {
final SubFlyZone poly = SubFlyZone.fromMap(airport()['subZones'][0] as Map<String, dynamic>);
expect(poly.heightLabel, 'NO FLY');
expect(poly.labelPoint!.$1, closeTo((55.67 + 55.69 + 55.69) / 3, 1e-9));
const SubFlyZone ceiling = SubFlyZone(shape: 'CYLINDER', latitude: 55.6, longitude: 12.5, radius: 100, maxHeight: 60);
expect(ceiling.heightLabel, '60 m');
expect(ceiling.labelPoint, (55.6, 12.5));
expect(const SubFlyZone(shape: 'CYLINDER').heightLabel, isNull);
});
test('licence changes are ground-only, refused when expired, and refresh the list', () async {
final FakeDjiService dji = FakeDjiService()
..licences = <String, dynamic>{'supported': true, 'zones': <Object?>[licence(1, enabled: true)]};
final FlightModel flying = modelWith(telemetryTick());
await expectLater(SafetyGate(model: flying, dji: dji).syncLicences(), throwsA(isA<MissionRefused>()));
expect(dji.calls, isEmpty);
final FlightModel ground = modelWith(telemetryTick()
..['isFlying'] = false
..['areMotorsOn'] = false);
final SafetyGate gate = SafetyGate(model: ground, dji: dji);
await gate.setLicenceEnabled(FlyZoneLicence.fromMap(licence(1)), true);
expect(dji.calls.single, 'setFlyZoneLicenceEnabled(1, true)');
expect(ground.flyZones.enabledLicence!.id, 1, reason: 'list refreshed after the change');
await expectLater(gate.setLicenceEnabled(FlyZoneLicence.fromMap(licence(3, expired: true)), true), throwsA(isA<MissionRefused>()));
await gate.syncLicences();
await gate.deleteAllLicences();
expect(dji.calls.skip(1), <String>['syncFlyZoneLicences', 'deleteAllFlyZoneLicences']);
});
test('a database upgrade shows progress, then refreshes the state', () async {
final FakeDjiService dji = FakeDjiService()..flyZoneDb = <String, dynamic>{'basicVersion': '2.2', 'basicState': 'UP_TO_DATE'};
final FlightModel m = modelWith(telemetryTick());
await SafetyGate(model: m, dji: dji).upgradeFlyZoneDatabase();
expect(dji.calls.single, 'startFlyZoneDatabaseUpgrade');
expect(m.flyZones.upgradeProgress, isNull);
expect(m.flyZones.database!.basicVersion, '2.2');
TelemetryReducer(m).apply(<String, dynamic>{'type': 'flyZoneDbProgress', 'progress': 0.3});
expect(m.flyZones.upgradeProgress, 0.3);
});
testWidgets('the zone sheet lists licences; enabling slides first', (WidgetTester tester) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
SharedPreferences.setMockInitialValues(<String, Object>{});
final FakeDjiService dji = FakeDjiService()
..licences = <String, dynamic>{'supported': true, 'zones': <Object?>[licence(5)]}
..flyZoneDb = <String, dynamic>{'basicVersion': '2.1', 'basicState': 'OUT_OF_DATE'};
final FlightModel m = modelWith(telemetryTick()
..['isFlying'] = false
..['areMotorsOn'] = false
..['flyZone'] = <String, Object?>{'state': 'NEAR_RESTRICTED_ZONE'});
await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji)));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('banner-flyzone')));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.byKey(const ValueKey<String>('licence-5')), 100, scrollable: find.byType(Scrollable).last);
expect(find.text('Site 5'), findsOneWidget);
await tester.tap(find.descendant(of: find.byKey(const ValueKey<String>('licence-5')), matching: find.byType(Switch)));
await tester.pumpAndSettle();
expect(dji.calls, isEmpty, reason: 'nothing changes before the slide');
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
await tester.pumpAndSettle();
expect(dji.calls.single, 'setFlyZoneLicenceEnabled(5, true)');
await tester.scrollUntilVisible(find.byKey(const ValueKey<String>('flyzone-db')), 100, scrollable: find.byType(Scrollable).last);
expect(find.textContaining('Out of date'), findsOneWidget);
});
});
group('FlyScreen fly zones', () {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlyPrefs.showRadar.value = true;
});
testWidgets('a notice shows, the sheet lists nearby zones, Unlock slides then unlocks', (WidgetTester tester) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final FakeDjiService dji = FakeDjiService()..flyZones = <Map<String, dynamic>>[airport(id: 77)];
final FlightModel m = modelWith(telemetryTick());
await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji)));
await tester.pump();
expect(find.byKey(const ValueKey<String>('banner-flyzone')), findsNothing);
TelemetryReducer(m).apply(<String, dynamic>{
'type': 'flySafe',
'kind': 'WARNING',
'event': 'AUTHORIZED_ZONE_WITHOUT_UNLOCKING_NEARBY',
'description': 'Authorization zone nearby',
'zones': <Object?>[77],
});
await tester.pump();
expect(find.byKey(const ValueKey<String>('banner-flyzone')), findsOneWidget);
expect(find.textContaining('Test Airport 77'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('banner-flyzone')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey<String>('flyzone-77')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('flyzone-unlock-77')));
await tester.pumpAndSettle();
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
await tester.pumpAndSettle();
expect(dji.calls, contains('unlockAuthorizationFlyZone(77)'));
expect(find.byKey(const ValueKey<String>('flyzone-unlock-77')), findsNothing);
// Expiry runs on the wall clock, which widget tests do not advance: see the FlyZoneState tests.
});
});
}
@@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/flight_model.dart';
import 'package:pilotvault_fly/fly/fly_prefs.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
import 'package:pilotvault_fly/fly/safety_gate.dart';
import 'package:pilotvault_fly/fly/state/airsense_state.dart';
import 'package:pilotvault_fly/fly/state/vision_state.dart';
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
import 'package:pilotvault_fly/fly/virtual_sticks.dart';
import 'package:pilotvault_fly/fly/vision_radar.dart';
import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart';
import 'package:pilotvault_fly/ui/fly/fly_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
FlightModel modelWith(Map<String, dynamic> tick, {Map<String, dynamic>? profile}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile ?? phantom4);
TelemetryReducer(m).apply(tick);
return m;
}
/// A 5° perception ring with [hits] (bearing → metres) and nothing elsewhere.
Map<String, Object?> ring(Map<int, double> hits, {double? up}) => <String, Object?>{
'angleInterval': 5.0,
'distances': <Object?>[for (int i = 0; i < 72; i++) hits[i * 5]],
'up': up,
'down': null,
};
Map<String, Object?> plane(String code, {int level = 0, double distance = 5000, String direction = 'NORTH_EAST'}) => <String, Object?>{
'code': code,
'warningLevel': level,
'direction': direction,
'heading': 270.0,
'distance': distance,
'latitude': 55.70,
'longitude': 12.60,
};
void main() {
group('VisionRadar', () {
test('a side takes the aircraft\'s own sectors, left to right, clockwise', () {
final FlightModel m = modelWith(telemetryTick(noseDistance: 6.5));
final VisionRadar r = VisionRadar.of(m.vision);
final RadarSideView front = r.sides[RadarSide.front]!;
expect(front.sectors, hasLength(4));
expect(front.sectors.first.start, -30);
expect(front.sectors.last.start + front.sectors.last.sweep, 30);
// The INVALID sector draws nothing; the nearest graded sector is the closest.
expect(front.sectors.map((RadarSector s) => s.level), <int>[1, 2, 1, 0]);
expect(front.closest, 6.5);
expect(front.band, RadarBand.notice);
expect(r.sides.containsKey(RadarSide.back), isFalse);
});
test('distances grade into levels; far is nothing', () {
expect(VisionRadar.levelFor(null), 0);
expect(VisionRadar.levelFor(1.0), 6);
expect(VisionRadar.levelFor(2.0), 5);
expect(VisionRadar.levelFor(3.5), 4);
expect(VisionRadar.levelFor(5.0), 3);
expect(VisionRadar.levelFor(8.0), 2);
expect(VisionRadar.levelFor(14.0), 1);
expect(VisionRadar.levelFor(VisionRadar.range + 1), 0);
expect(VisionRadar.bandFor(5), RadarBand.danger);
expect(VisionRadar.bandFor(3), RadarBand.caution);
});
test('omni perception fills sides the aircraft gave no sectors for, wrapping at the nose', () {
final VisionState v = VisionState()
..applyPerception(ring(<int, double>{180: 2.0, 355: 4.0, 90: 30}, up: 3.0).cast<String, dynamic>());
final VisionRadar r = VisionRadar.of(v);
final RadarSideView back = r.sides[RadarSide.back]!;
expect(back.closest, 2.0);
expect(back.band, RadarBand.danger);
// 355° lies in the front arc's left half.
final RadarSideView front = r.sides[RadarSide.front]!;
expect(front.closest, 4.0);
expect(front.sectors[1].distance, 4.0);
// 30 m is out of range: the right side has sectors but nothing to draw.
expect(r.sides[RadarSide.right]!.level, 0);
expect(r.up, 3.0);
expect(r.level, 5);
v.applyPerception(ring(<int, double>{}, up: 12).cast<String, dynamic>());
expect(VisionRadar.of(v).up, isNull, reason: 'overhead beyond 10 m is not shown');
expect(VisionRadar.of(v).isEmpty, isTrue);
});
test('a sensor switched on but not working is blocked; one switched off is off', () {
final FlightModel m = modelWith(telemetryTick()
..['obstacleSensors'] = <String, Object?>{
'forward': <String, Object?>{'working': false, 'enabled': true},
'backward': <String, Object?>{'working': false, 'enabled': false},
'up': <String, Object?>{'working': true, 'enabled': true},
});
final VisionRadar r = VisionRadar.of(m.vision);
expect(r.sides[RadarSide.front]!.blocked, isTrue);
expect(r.sides[RadarSide.back]!.off, isTrue);
expect(r.sides[RadarSide.back]!.blocked, isFalse);
expect(r.blocked, <RadarSide>[RadarSide.front]);
expect(m.vision.health['up']!.blocked, isFalse);
});
});
group('StickMapper', () {
test('Mode 2: left is throttle + yaw, right is pitch + roll, scaled to the limits', () {
final StickCommand c = StickMapper.command(const Offset(0, 1), const Offset(-1, 0));
expect(c.up, StickLimits.normal.vertical);
expect(c.right, -StickLimits.normal.horizontal);
expect(c.forward, 0);
expect(c.yaw, 0);
expect(StickMapper.command(const Offset(1, 0), const Offset(0, -1)).yaw, StickLimits.normal.yaw);
expect(StickMapper.command(const Offset(1, 0), const Offset(0, -1)).forward, -StickLimits.normal.horizontal);
});
test('Mode 1 and Mode 3 swap the axes like the remote', () {
final StickCommand m1 = StickMapper.command(const Offset(0, 1), const Offset(0, 1), mode: StickMode.mode1);
expect(m1.forward, StickLimits.normal.horizontal);
expect(m1.up, StickLimits.normal.vertical);
final StickCommand m3 = StickMapper.command(const Offset(1, 0), const Offset(1, 0), mode: StickMode.mode3);
expect(m3.right, StickLimits.normal.horizontal);
expect(m3.yaw, StickLimits.normal.yaw);
});
test('the deadzone holds still; small deflections are finer than linear', () {
expect(StickMapper.command(const Offset(0.05, 0.05), const Offset(-0.07, 0.02)), StickCommand.zero);
final double half = StickMapper.shape(0.5);
expect(half, greaterThan(0));
expect(half, lessThan(0.5 - StickMapper.deadzone));
expect(StickMapper.shape(-1), -1);
});
test('a finger outside the ring clamps to it, and screen-down is stick-down', () {
final Offset v = StickMapper.normalise(const Offset(0, 200), 50);
expect(v.dx, 0);
expect(v.dy, -1);
expect(StickMapper.normalise(const Offset(25, -25), 50), const Offset(0.5, 0.5));
});
});
group('VirtualStickController', () {
testWidgets('sends on change, repeats while held, one zero on release', (WidgetTester tester) async {
final List<StickCommand> sent = <StickCommand>[];
final VirtualStickController c = VirtualStickController(send: (StickCommand x) async => sent.add(x));
addTearDown(c.dispose);
c.setRight(const Offset(0, 1));
expect(sent, isEmpty, reason: 'nothing goes out before the aircraft confirmed the mode');
c.opened();
c.setRight(const Offset(0, 1));
expect(sent.single.forward, StickLimits.normal.horizontal);
await tester.pump(const Duration(milliseconds: 350));
expect(sent.length, 4, reason: 'the 100 ms heartbeat keeps a held stick alive');
c.centre();
expect(sent.last, StickCommand.zero);
final int n = sent.length;
await tester.pump(const Duration(milliseconds: 500));
expect(sent.length, n, reason: 'centred sticks go quiet; the native loop hovers');
});
test('the aircraft leaving the mode, RTH or landing closes the sticks with a reason', () {
final VirtualStickController c = VirtualStickController(send: (_) async {});
c.opened();
c.onAircraft(enabled: false, goingHome: false, landing: false);
expect(c.open, isTrue, reason: 'a stale "off" from before the mode was confirmed is ignored');
c.onAircraft(enabled: true, goingHome: false, landing: false);
c.onAircraft(enabled: false, goingHome: false, landing: false);
expect(c.open, isFalse);
expect(c.closedReason, 'The remote took back control');
c.opened();
c.onAircraft(enabled: true, goingHome: true, landing: false);
expect(c.closedReason, 'Return to Home took over');
c.opened();
c.close();
expect(c.closedReason, isNull, reason: 'the pilot closing is not news');
c.dispose();
});
});
group('SafetyGate virtual sticks', () {
setUp(() => FlyPrefs.sticksInRealFlight.value = false);
test('on an aircraft with a remote they are simulator-only by default', () {
final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick()), dji: FakeDjiService());
expect(gate.checkVirtualSticks().where((GateCheck c) => c.blocks).single.message, contains('simulator'));
FlyPrefs.sticksInRealFlight.value = true;
final List<GateCheck> real = gate.checkVirtualSticks();
expect(real.any((GateCheck c) => c.blocks), isFalse);
expect(real.map((GateCheck c) => c.message), contains(contains('real flight')));
});
test('in the simulator they are allowed, warning that the remote is ignored', () {
final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick()..['simulatorActive'] = true), dji: FakeDjiService());
final List<GateCheck> checks = gate.checkVirtualSticks();
expect(checks.any((GateCheck c) => c.blocks), isFalse);
expect(checks.map((GateCheck c) => c.message), contains(contains("remote's sticks are ignored")));
});
test('a Wi-Fi aircraft flown from the phone may use them for real', () {
final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick(), profile: sparkWifi), dji: FakeDjiService());
expect(gate.model.profile.virtualStickPrimary, isTrue);
expect(gate.checkVirtualSticks().any((GateCheck c) => c.blocks), isFalse);
});
test('RTH, a running mission and a critical battery refuse them', () {
final FlightModel m = modelWith(telemetryTick(battery: 9)
..['simulatorActive'] = true
..['isGoingHome'] = true);
final List<String> blocks = <String>[
for (final GateCheck c in SafetyGate(model: m, dji: FakeDjiService()).checkVirtualSticks())
if (c.blocks) c.message,
];
expect(blocks, contains('Cancel Return to Home first.'));
expect(blocks, contains(startsWith('Battery critically low')));
});
test('enable re-checks before switching the mode on; disable always goes', () async {
final FakeDjiService dji = FakeDjiService();
final SafetyGate blocked = SafetyGate(model: modelWith(telemetryTick()), dji: dji);
await expectLater(blocked.enableVirtualSticks(), throwsA(isA<MissionRefused>()));
expect(dji.calls, isEmpty);
await blocked.disableVirtualSticks();
expect(dji.calls.single, 'setVirtualStickEnabled(false)');
final SafetyGate ok = SafetyGate(model: modelWith(telemetryTick()..['simulatorActive'] = true), dji: dji);
await ok.enableVirtualSticks();
await ok.sendSticks(const StickCommand(forward: 2, right: -1, yaw: 30, up: 0.5));
expect(dji.calls.skip(1), <String>['setVirtualStickEnabled(true)', 'sendVirtualStick(2.0, -1.0, 30, 0.5)']);
});
});
group('AirSenseState', () {
test('nearest first; the level is the worst of the receiver and any plane', () {
final FlightModel m = modelWith(telemetryTick()
..['airSense'] = <String, Object?>{
'warningLevel': 1,
'airplanes': <Object?>[plane('ABC123', distance: 9000), plane('DEF456', level: 3, distance: 2500, direction: 'SOUTH')],
});
final AirSenseState s = m.airSense;
expect(s.reported, isTrue);
expect(s.planes.map((AirSensePlane p) => p.code), <String>['DEF456', 'ABC123']);
expect(s.level, 3);
expect(s.worst!.code, 'DEF456');
expect(s.worst!.octant, 'S');
expect(s.threats, hasLength(1));
});
test('absent on aircraft without a receiver, cleared on disconnect', () {
final FlightModel m = modelWith(telemetryTick());
expect(m.airSense.reported, isFalse);
TelemetryReducer(m).apply(telemetryTick()..['airSense'] = <String, Object?>{'warningLevel': 0, 'airplanes': <Object?>[]});
expect(m.airSense.reported, isTrue);
m.clearTelemetry();
expect(m.airSense.reported, isFalse);
});
});
group('FlyScreen (P8)', () {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlyPrefs.sticksInRealFlight.value = false;
FlyPrefs.showRadar.value = true;
});
Future<FakeDjiService> pump(WidgetTester tester, FlightModel m) async {
tester.view.physicalSize = const Size(844, 390);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final FakeDjiService dji = FakeDjiService();
await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji)));
await tester.pump();
return dji;
}
testWidgets('the radar draws on sensing aircraft and follows the display switch', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick(noseDistance: 2.0), profile: mavic2Zoom);
await pump(tester, m);
expect(find.byKey(const ValueKey<String>('vision-radar')), findsOneWidget);
FlyPrefs.showRadar.value = false;
await tester.pump();
expect(find.byKey(const ValueKey<String>('vision-radar')), findsNothing);
});
testWidgets('STICKS: slide to hand over, the pads fly, the remote taking over closes them', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick()..['simulatorActive'] = true);
final FakeDjiService dji = await pump(tester, m);
expect(find.byKey(const ValueKey<String>('stick-left')), findsNothing);
await tester.tap(find.byKey(const ValueKey<String>('rail-sticks')));
await tester.pumpAndSettle();
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
await tester.pumpAndSettle();
expect(dji.calls, <String>['setVirtualStickEnabled(true)']);
expect(find.byKey(const ValueKey<String>('stick-left')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('stick-right')), findsOneWidget);
// Push the right stick fully forward (Mode 2) and hold.
final TestGesture g = await tester.startGesture(tester.getCenter(find.byKey(const ValueKey<String>('stick-right'))) - const Offset(0, 6));
await g.moveBy(const Offset(0, -80));
await tester.pump(const Duration(milliseconds: 120));
expect(dji.calls.where((String c) => c.startsWith('sendVirtualStick(5.0')), isNotEmpty);
await g.up();
await tester.pump();
expect(dji.calls.last, 'sendVirtualStick(0.0, 0.0, 0, 0.0)');
// The flight controller confirms, then the remote's mode switch ends it.
TelemetryReducer(m)
..apply(telemetryTick()..['simulatorActive'] = true..['virtualStick'] = <String, Object?>{'enabled': true, 'sending': true})
..apply(telemetryTick()..['simulatorActive'] = true..['virtualStick'] = <String, Object?>{'enabled': false, 'sending': false});
await tester.pump();
expect(find.byKey(const ValueKey<String>('stick-left')), findsNothing);
expect(find.text('On-screen sticks off: The remote took back control'), findsOneWidget);
expect(dji.calls.last, 'setVirtualStickEnabled(false)');
});
testWidgets('an AirSense warning names the plane and opens the list', (WidgetTester tester) async {
final FlightModel m = modelWith(telemetryTick()
..['airSense'] = <String, Object?>{
'warningLevel': 3,
'airplanes': <Object?>[plane('4CA1B2', level: 3, distance: 1800)],
});
await pump(tester, m);
expect(find.text('Manned aircraft close: descend'), findsOneWidget);
expect(find.text('1.8 km · NE · 4CA1B2'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('banner-airsense')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey<String>('airsense-4CA1B2')), findsOneWidget);
});
});
}