Intelligent modes: Point of Interest (orbit on the map, live radius and speed), Follow Me (follows the phone), QuickShots on a boxed subject, Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and Circle. Every mode has a first-use tutorial and stops from the rail. Waypoints can resume an interrupted mission, change cruise speed in flight and check terrain clearance (Open-Meteo, on tap). Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI output, a link spectrum chart, Wi-Fi name and password, aircraft name, video cache, battery warning history and pairing, non-smart cell voltages. Calibration: compass interference meter, per-IMU calibration, lens infinity focus and a first-manual-focus prompt. Also an RTK chip, gimbal movement presets and motor tuning, Precision Takeoff gated on the flight controller, and the precise GEO database's server version. Built and unit/widget-tested only; nothing has flown yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
448 lines
23 KiB
Dart
448 lines
23 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/action_rail_state.dart';
|
|
import 'package:pilotvault_fly/fly/fly_prefs.dart';
|
|
import 'package:pilotvault_fly/fly/modes/vision_modes.dart';
|
|
import 'package:pilotvault_fly/fly/product_profile.dart';
|
|
import 'package:pilotvault_fly/fly/safety_gate.dart';
|
|
import 'package:pilotvault_fly/fly/state/aircraft_state.dart';
|
|
import 'package:pilotvault_fly/fly/state/mission_state.dart';
|
|
import 'package:pilotvault_fly/fly/telemetry_reducer.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';
|
|
|
|
Map<String, dynamic> trackEvent(String state, {Map<String, Object?>? tracking, String? error}) => <String, dynamic>{
|
|
'type': 'mission',
|
|
'operator': 'activeTrack',
|
|
'state': state,
|
|
if (tracking != null) 'tracking': tracking,
|
|
if (error != null) 'error': error,
|
|
};
|
|
|
|
Map<String, dynamic> tapFlyEvent(String state, {Map<String, Object?>? execution}) => <String, dynamic>{
|
|
'type': 'mission',
|
|
'operator': 'tapFly',
|
|
'state': state,
|
|
if (execution != null) 'execution': execution,
|
|
};
|
|
|
|
const Map<String, Object?> box = <String, Object?>{'l': 0.4, 't': 0.3, 'r': 0.6, 'b': 0.7};
|
|
|
|
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)
|
|
..apply(trackEvent('IDLE'))
|
|
..apply(tapFlyEvent('IDLE'));
|
|
return m;
|
|
}
|
|
|
|
Map<String, dynamic> grounded() => telemetryTick()
|
|
..['isFlying'] = false
|
|
..['areMotorsOn'] = false;
|
|
|
|
void main() {
|
|
group('VisionGeometry', () {
|
|
const Rect view = Rect.fromLTWH(0, 0, 800, 400);
|
|
|
|
test('the picture is aspect-fitted and centred; unknown size fills the view', () {
|
|
// 16:9 feed on a 19.5:9 phone: bars left and right.
|
|
final Rect wide = VisionGeometry.contentRect(const Size(844, 390), const Size(1920, 1080));
|
|
expect(wide.height, 390);
|
|
expect(wide.width, closeTo(693.33, 0.01));
|
|
expect(wide.left, closeTo((844 - 693.33) / 2, 0.01));
|
|
// 4:3 feed on a 16:10 tablet: bars left and right too.
|
|
expect(VisionGeometry.contentRect(const Size(1280, 800), const Size(1440, 1080)).width, closeTo(1066.67, 0.01));
|
|
// A feed wider than the view: bars top and bottom.
|
|
final Rect tall = VisionGeometry.contentRect(const Size(400, 400), const Size(1920, 1080));
|
|
expect(tall.width, 400);
|
|
expect(tall.top, closeTo(87.5, 0.01));
|
|
expect(VisionGeometry.contentRect(const Size(844, 390), null), const Rect.fromLTWH(0, 0, 844, 390));
|
|
});
|
|
|
|
test('a drag becomes a 0..1 box whichever way it was drawn, clipped to the picture', () {
|
|
final Rect r = VisionGeometry.dragBox(const Offset(600, 300), const Offset(200, -50), view)!;
|
|
expect(r, const Rect.fromLTRB(0.25, 0, 0.75, 0.75));
|
|
});
|
|
|
|
test('a small drag, or one on a bar, is no box', () {
|
|
expect(VisionGeometry.dragBox(const Offset(100, 100), const Offset(110, 200), view), isNull);
|
|
const Rect content = Rect.fromLTWH(100, 0, 600, 400);
|
|
expect(VisionGeometry.dragBox(const Offset(0, 50), const Offset(90, 200), content), isNull);
|
|
});
|
|
|
|
test('taps are relative to the picture, and nothing on a bar', () {
|
|
const Rect content = Rect.fromLTWH(100, 0, 600, 400);
|
|
expect(VisionGeometry.normalise(const Offset(400, 200), content), const Offset(0.5, 0.5));
|
|
expect(VisionGeometry.normalise(const Offset(50, 200), content), isNull);
|
|
expect(VisionGeometry.tapBoxAt(const Offset(750, 200), content), isNull);
|
|
expect(VisionGeometry.pointToScreen(const Offset(0.5, 0.5), content), const Offset(400, 200));
|
|
expect(VisionGeometry.toScreen(const Rect.fromLTRB(0, 0, 1, 1), content), content);
|
|
});
|
|
|
|
test('a tap makes a person-sized box, kept inside the picture', () {
|
|
final Rect mid = VisionGeometry.tapBoxAt(const Offset(400, 200), view)!;
|
|
expect(mid.center, const Offset(0.5, 0.5));
|
|
expect(mid.width, closeTo(VisionGeometry.tapBox.width, 1e-9));
|
|
expect(mid.height, closeTo(VisionGeometry.tapBox.height, 1e-9));
|
|
final Rect corner = VisionGeometry.tapBoxAt(const Offset(1, 1), view)!;
|
|
expect(corner.left, closeTo(0, 1e-9));
|
|
expect(corner.top, closeTo(0, 1e-9));
|
|
});
|
|
});
|
|
|
|
group('MissionState (vision operators)', () {
|
|
test('ActiveTrack: selecting, confirming, following, losing, done', () {
|
|
final MissionState s = MissionState()..apply(trackEvent('WAITING_FOR_CONFIRMATION', tracking: <String, Object?>{
|
|
'targetState': 'WAITING_FOR_CONFIRMATION',
|
|
'targetType': 'HUMAN',
|
|
'rect': box,
|
|
}));
|
|
expect(s.activeTrack.waitingForConfirmation, isTrue);
|
|
expect(s.activeTrack.rect, const Rect.fromLTRB(0.4, 0.3, 0.6, 0.7));
|
|
expect(s.state, 'tracking');
|
|
expect(s.tracking, isTrue);
|
|
|
|
s.apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: <String, Object?>{'targetState': 'TRACKING_WITH_LOW_CONFIDENCE', 'rect': box}));
|
|
expect(s.activeTrack.following, isTrue);
|
|
expect(s.activeTrack.lowConfidence, isTrue);
|
|
|
|
s.apply(trackEvent('FINDING_TRACKED_TARGET'));
|
|
expect(s.activeTrack.rect, isNotNull, reason: 'a state-only event keeps the box while tracking');
|
|
|
|
s.apply(trackEvent('CANNOT_CONFIRM', tracking: <String, Object?>{'targetState': 'CANNOT_CONFIRM', 'reason': 'TARGET_TOO_CLOSE', 'rect': box}));
|
|
expect(s.activeTrack.cannotConfirm, isTrue);
|
|
expect(s.activeTrack.reasonLabel, 'Subject too close');
|
|
|
|
s.apply(trackEvent('IDLE'));
|
|
expect(s.activeTrack.active, isFalse);
|
|
expect(s.activeTrack.rect, isNull);
|
|
expect(s.state, 'idle');
|
|
});
|
|
|
|
test('a malformed rect is ignored', () {
|
|
final MissionState s = MissionState()
|
|
..apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: <String, Object?>{'rect': <String, Object?>{'l': 0.6, 't': 0.3, 'r': 0.4, 'b': 0.7}}));
|
|
expect(s.activeTrack.rect, isNull);
|
|
});
|
|
|
|
test('the operator replaces the old QuickShot guess', () {
|
|
final MissionState s = MissionState()..apply(<String, dynamic>{'type': 'mission', 'state': 'quickshot'});
|
|
expect(s.tracking, isTrue);
|
|
s.apply(trackEvent('IDLE'));
|
|
expect(s.tracking, isFalse);
|
|
});
|
|
|
|
test('TapFly: flying with an image point and a bypass, then idle', () {
|
|
final MissionState s = MissionState()
|
|
..apply(tapFlyEvent('EXECUTING', execution: <String, Object?>{'imageX': 0.3, 'imageY': 0.4, 'bypass': 'LEFT', 'speed': 4.0}));
|
|
expect(s.tapFly.active, isTrue);
|
|
expect(s.tapFly.image, const Offset(0.3, 0.4));
|
|
expect(s.tapFly.bypassLabel, contains('left'));
|
|
expect(s.state, 'tapfly');
|
|
final AircraftState a = AircraftState()..apply(telemetryTick());
|
|
expect(RailState.of(connected: true, a: a, mission: s).mode, ActiveMode.tapFly);
|
|
s.apply(tapFlyEvent('IDLE'));
|
|
expect(s.tapFly.active, isFalse);
|
|
expect(s.tapFly.image, isNull);
|
|
});
|
|
});
|
|
|
|
group('SafetyGate (vision modes)', () {
|
|
List<String> blockers(List<GateCheck> c) => <String>[for (final GateCheck g in c) if (g.blocks) g.message];
|
|
List<String> warnings(List<GateCheck> c) => <String>[for (final GateCheck g in c) if (!g.blocks) g.message];
|
|
SafetyGate gate(FlightModel m, [FakeDjiService? dji]) => SafetyGate(model: m, dji: dji ?? FakeDjiService());
|
|
|
|
test('airborne in P mode with a good battery: ActiveTrack and TapFly may start', () {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
expect(blockers(gate(m).checkActiveTrack(TrackMode.trace)), isEmpty);
|
|
expect(blockers(gate(m).checkTapFly(TapFlyDirection.forward)), isEmpty);
|
|
});
|
|
|
|
test('on the ground, low, in Sport, going home or during a waypoint mission they refuse', () {
|
|
expect(blockers(gate(modelWith(grounded())).checkActiveTrack(TrackMode.trace)), <String>['Take off first.']);
|
|
expect(blockers(gate(modelWith(telemetryTick(altitude: 1))).checkTapFly(TapFlyDirection.forward)), contains('Climb above 2 m first.'));
|
|
expect(blockers(gate(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_SPORT')).checkTapFly(TapFlyDirection.forward)),
|
|
contains('Switch the remote to P mode first.'));
|
|
expect(blockers(gate(modelWith(telemetryTick()..['isGoingHome'] = true)).checkActiveTrack(TrackMode.trace)), contains('Cancel Return to Home first.'));
|
|
final FlightModel wp = modelWith(telemetryTick());
|
|
wp.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'waypoint', 'state': 'EXECUTING'});
|
|
expect(blockers(gate(wp).checkActiveTrack(TrackMode.trace)), contains('Stop the waypoint mission first.'));
|
|
});
|
|
|
|
test('one vision mode at a time', () {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
m.mission.apply(tapFlyEvent('EXECUTING'));
|
|
expect(blockers(gate(m).checkActiveTrack(TrackMode.trace)), contains('Stop TapFly first.'));
|
|
m.mission.apply(tapFlyEvent('IDLE'));
|
|
m.mission.apply(trackEvent('AIRCRAFT_FOLLOWING'));
|
|
expect(blockers(gate(m).checkTapFly(TapFlyDirection.forward)), contains('Stop ActiveTrack first.'));
|
|
});
|
|
|
|
test('TapFly Backward and Free need rear sensors', () {
|
|
final FlightModel p4 = modelWith(telemetryTick()); // Phantom 4: front only
|
|
expect(gate(p4).tapFlyDirections(), <TapFlyDirection>[TapFlyDirection.forward]);
|
|
expect(blockers(gate(p4).checkTapFly(TapFlyDirection.backward)).single, contains('not available'));
|
|
});
|
|
|
|
test('Free warns that it does not avoid obstacles', () {
|
|
final FlightModel m = FlightModel()..connected = true;
|
|
TelemetryReducer(m).apply(telemetryTick());
|
|
expect(warnings(gate(m).checkTapFly(TapFlyDirection.free)).single, contains('Free does not avoid'));
|
|
});
|
|
|
|
test('Profile warns about its blind side', () {
|
|
expect(warnings(gate(modelWith(telemetryTick())).checkActiveTrack(TrackMode.profile)).single, contains('sideways'));
|
|
});
|
|
|
|
test('start sends the clipped box / point, and refuses without touching the aircraft', () async {
|
|
final FakeDjiService dji = FakeDjiService();
|
|
await gate(modelWith(telemetryTick()), dji).startActiveTrack(const Rect.fromLTRB(0.2, -0.1, 0.4, 0.5), TrackMode.spotlight);
|
|
await gate(modelWith(telemetryTick()), dji).startTapFly(const Offset(0.7, 0.4), TapFlyDirection.forward, speed: 30);
|
|
expect(dji.calls, <String>['startActiveTrack(0.20, 0.00, 0.20, 0.50, SPOTLIGHT)', 'startTapFly(0.70, 0.40, FORWARD, 10, true)']);
|
|
|
|
final FakeDjiService none = FakeDjiService();
|
|
await expectLater(gate(modelWith(grounded()), none).startTapFly(const Offset(0.5, 0.5), TapFlyDirection.forward), throwsA(isA<MissionRefused>()));
|
|
expect(none.calls, isEmpty);
|
|
});
|
|
|
|
test('confirm only answers a question that was asked; Stop reaches TapFly', () async {
|
|
final FakeDjiService dji = FakeDjiService();
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
await gate(m, dji).confirmActiveTrack(yes: true);
|
|
expect(dji.calls, isEmpty);
|
|
m.mission.apply(trackEvent('WAITING_FOR_CONFIRMATION'));
|
|
await gate(m, dji).confirmActiveTrack(yes: true);
|
|
await gate(m, dji).confirmActiveTrack(yes: false);
|
|
m.mission.apply(trackEvent('IDLE'));
|
|
m.mission.apply(tapFlyEvent('EXECUTING'));
|
|
await gate(m, dji).run(FlightCommand.stopMode);
|
|
expect(dji.calls, <String>['acceptActiveTrackConfirmation', 'rejectActiveTrackConfirmation', 'stopTapFly']);
|
|
});
|
|
});
|
|
|
|
group('auto-sensing, retreat, reset heading, palm, video size', () {
|
|
test('sensed subjects arrive while auto-sensing, the flags stick across events', () {
|
|
final MissionState s = MissionState()
|
|
..apply(<String, dynamic>{'type': 'mission', 'operator': 'activeTrack', 'state': 'IDLE', 'autoSensingSupported': true, 'autoSensing': false});
|
|
expect(s.activeTrack.autoSensingSupported, isTrue);
|
|
s.apply(trackEvent('AUTO_SENSING', tracking: <String, Object?>{
|
|
'subjects': <Object?>[
|
|
<String, Object?>{'index': 3, 'type': 'HUMAN', 'rect': box},
|
|
<String, Object?>{'index': 4, 'type': 'CAR', 'rect': <String, Object?>{'l': 0.1, 't': 0.1, 'r': 0.05, 'b': 0.2}}, // bad rect
|
|
],
|
|
})..['autoSensing'] = true);
|
|
expect(s.activeTrack.subjects.single.index, 3);
|
|
expect(s.activeTrack.autoSensing, isTrue);
|
|
expect(s.activeTrack.autoSensingSupported, isTrue, reason: 'not repeated, still known');
|
|
expect(s.activeTrack.stateLabel, 'Tap a subject');
|
|
s.apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: <String, Object?>{'rect': box}));
|
|
expect(s.activeTrack.subjects, isEmpty);
|
|
});
|
|
|
|
test('auto-sensing needs support and no tracking; a subject starts by index', () async {
|
|
final FakeDjiService dji = FakeDjiService();
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
final SafetyGate g = SafetyGate(model: m, dji: dji);
|
|
await expectLater(g.setAutoSensing(true), throwsA(isA<MissionRefused>()));
|
|
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'activeTrack', 'state': 'IDLE', 'autoSensingSupported': true});
|
|
await g.setAutoSensing(true);
|
|
await g.startActiveTrackSubject(3, TrackMode.profile);
|
|
expect(dji.calls, <String>['setActiveTrackAutoSensing(true)', 'startActiveTrackSubject(3, PROFILE)']);
|
|
});
|
|
|
|
test('backward needs obstacle sensing; reset heading needs TapFly flying', () async {
|
|
final FakeDjiService dji = FakeDjiService();
|
|
final FlightModel noSensors = FlightModel()..connected = true;
|
|
TelemetryReducer(noSensors).apply(telemetryTick());
|
|
await expectLater(SafetyGate(model: noSensors, dji: dji).setTrackRetreat(true), throwsA(isA<MissionRefused>()));
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
final SafetyGate g = SafetyGate(model: m, dji: dji);
|
|
await g.setTrackRetreat(true);
|
|
await expectLater(g.resetTapFlyHeading(), throwsA(isA<MissionRefused>()));
|
|
m.mission.apply(tapFlyEvent('EXECUTING'));
|
|
await g.resetTapFlyHeading();
|
|
expect(dji.calls, <String>['setActiveTrackRetreat(true)', 'resetTapFlyHeading']);
|
|
});
|
|
|
|
test('the reducer takes the video size and the palm-control state', () {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
TelemetryReducer(m)
|
|
..apply(<String, dynamic>{'type': 'videoSize', 'width': 1440, 'height': 1080})
|
|
..apply(telemetryTick()..['palmControl'] = 'FOLLOWING');
|
|
expect(m.videoSize.value, const Size(1440, 1080));
|
|
expect(m.vision.palmActive, isTrue);
|
|
expect(m.vision.palmLabel, 'Following your palm');
|
|
});
|
|
});
|
|
|
|
group('FlyScreen (vision modes)', () {
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
|
FlyPrefs.acceptedModeTerms.value = <String>{'activeTrack', 'tapFly'};
|
|
// The first-use tutorials have their own test (p7_modes_test.dart).
|
|
FlyPrefs.seenTutorials.value = <String>{for (final IntelligentMode m in IntelligentMode.values) m.name};
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
Future<void> open(WidgetTester tester, String mode) async {
|
|
await tester.tap(find.byKey(const ValueKey<String>('rail-modes')));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await tester.tap(find.byKey(ValueKey<String>('mode-$mode')));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
}
|
|
|
|
testWidgets('ActiveTrack: draw a box, GO starts it, the tracked box and Stop follow', (WidgetTester tester) async {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await open(tester, 'activeTrack');
|
|
expect(find.byKey(const ValueKey<String>('track-panel')), findsOneWidget);
|
|
|
|
// Draw in the empty middle of the view, clear of the HUD.
|
|
await tester.dragFrom(const Offset(380, 160), const Offset(80, 120));
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('track-go')), findsOneWidget);
|
|
await tester.tap(find.byKey(const ValueKey<String>('track-go')));
|
|
await tester.pumpAndSettle();
|
|
expect(dji.calls.single, startsWith('startActiveTrack('));
|
|
expect(dji.calls.single, endsWith('TRACE)'));
|
|
|
|
TelemetryReducer(m).apply(trackEvent('WAITING_FOR_CONFIRMATION', tracking: <String, Object?>{'targetState': 'WAITING_FOR_CONFIRMATION', 'rect': box}));
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('track-box')), findsOneWidget);
|
|
await tester.tap(find.byKey(const ValueKey<String>('track-confirm')));
|
|
await tester.pump();
|
|
expect(dji.calls.last, 'acceptActiveTrackConfirmation');
|
|
|
|
TelemetryReducer(m).apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: <String, Object?>{'targetState': 'TRACKING_WITH_HIGH_CONFIDENCE', 'rect': box}));
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const ValueKey<String>('track-spotlight')));
|
|
await tester.pump();
|
|
expect(dji.calls.last, 'switchActiveTrackMode(SPOTLIGHT)');
|
|
expect(find.byKey(const ValueKey<String>('rail-stopMode')), findsOneWidget);
|
|
|
|
await tester.tap(find.byKey(const ValueKey<String>('vision-exit')));
|
|
await tester.pumpAndSettle();
|
|
expect(dji.calls.last, 'stopActiveTrack', reason: 'Exit stops a running mode');
|
|
expect(find.byKey(const ValueKey<String>('track-panel')), findsNothing);
|
|
});
|
|
|
|
testWidgets('Auto-detect: tap a sensed subject, GO follows it by index', (WidgetTester tester) async {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'activeTrack', 'state': 'IDLE', 'autoSensingSupported': true});
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await open(tester, 'activeTrack');
|
|
await tester.tap(find.byKey(const ValueKey<String>('track-autosense')));
|
|
await tester.pump();
|
|
expect(dji.calls.last, 'setActiveTrackAutoSensing(true)');
|
|
|
|
TelemetryReducer(m).apply(trackEvent('AUTO_SENSING', tracking: <String, Object?>{
|
|
'subjects': <Object?>[<String, Object?>{'index': 7, 'type': 'HUMAN', 'rect': box}],
|
|
})..['autoSensing'] = true);
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('subject-7')), findsOneWidget);
|
|
// The box only draws; the layer underneath takes the tap.
|
|
await tester.tapAt(tester.getCenter(find.byKey(const ValueKey<String>('subject-7'))));
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const ValueKey<String>('track-go')));
|
|
await tester.pumpAndSettle();
|
|
expect(dji.calls.last, 'startActiveTrackSubject(7, TRACE)');
|
|
});
|
|
|
|
testWidgets('Backward toggles retreat for Trace', (WidgetTester tester) async {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await open(tester, 'activeTrack');
|
|
await tester.tap(find.byKey(const ValueKey<String>('track-retreat')));
|
|
await tester.pump();
|
|
expect(dji.calls.last, 'setActiveTrackRetreat(true)');
|
|
expect(find.text('Backward on'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('with a 4:3 feed, a tap on the black bar is no TapFly target', (WidgetTester tester) async {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
m.videoSize.value = const Size(1440, 1080); // 520 px wide on a 390 px tall screen: bars of 162 px
|
|
await pump(tester, m);
|
|
await open(tester, 'tapFly');
|
|
await tester.tapAt(const Offset(780, 200));
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('tapfly-target')), findsNothing);
|
|
await tester.tapAt(const Offset(500, 200));
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('tapfly-target')), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('palm control shows while the aircraft follows a palm', (WidgetTester tester) async {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
await pump(tester, m);
|
|
expect(find.byKey(const ValueKey<String>('palm-control')), findsNothing);
|
|
TelemetryReducer(m).apply(telemetryTick()..['palmControl'] = 'FOLLOWING');
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('palm-control')), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a vision mode that would warn asks for the slide first', (WidgetTester tester) async {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await open(tester, 'activeTrack');
|
|
await tester.tap(find.byKey(const ValueKey<String>('track-profile')));
|
|
await tester.pump();
|
|
await tester.tapAt(const Offset(420, 200));
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const ValueKey<String>('track-go')));
|
|
await tester.pumpAndSettle();
|
|
expect(dji.calls, isEmpty);
|
|
expect(find.byKey(SlideToConfirmTrack.thumbKey), findsOneWidget);
|
|
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
|
|
await tester.pumpAndSettle();
|
|
expect(dji.calls.single, endsWith('PROFILE)'));
|
|
});
|
|
|
|
testWidgets('TapFly: tap a point, set the speed, GO, Stop', (WidgetTester tester) async {
|
|
final FlightModel m = modelWith(telemetryTick());
|
|
final FakeDjiService dji = await pump(tester, m);
|
|
await open(tester, 'tapFly');
|
|
expect(find.byKey(const ValueKey<String>('tapfly-panel')), findsOneWidget);
|
|
expect(find.byKey(const ValueKey<String>('tapfly-backward')), findsNothing, reason: 'Phantom 4 has front sensors only');
|
|
|
|
await tester.tap(find.byKey(const ValueKey<String>('tapfly-speed-plus')));
|
|
await tester.pump();
|
|
await tester.tapAt(const Offset(422, 195));
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('tapfly-target')), findsOneWidget);
|
|
await tester.tap(find.byKey(const ValueKey<String>('tapfly-go')));
|
|
await tester.pumpAndSettle();
|
|
expect(dji.calls.single, 'startTapFly(0.50, 0.50, FORWARD, 6, true)');
|
|
|
|
TelemetryReducer(m).apply(tapFlyEvent('EXECUTING', execution: <String, Object?>{'imageX': 0.5, 'imageY': 0.5, 'bypass': 'NONE', 'speed': 6.0}));
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey<String>('tapfly-heading')), findsOneWidget);
|
|
await tester.tap(find.byKey(const ValueKey<String>('tapfly-speed-minus')));
|
|
await tester.pump();
|
|
expect(dji.calls.last, 'setTapFlySpeed(5)');
|
|
await tester.tap(find.byKey(const ValueKey<String>('vision-stop')));
|
|
await tester.pumpAndSettle();
|
|
expect(dji.calls.last, 'stopTapFly');
|
|
});
|
|
});
|
|
}
|