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:
1 parent
93f4a580e9
commit
e02bb0e5cd
34 files changed
+4376
-15
No files matched your search
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user