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>
370 lines
18 KiB
Dart
370 lines
18 KiB
Dart
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.
|
|
});
|
|
});
|
|
}
|