Give the Fly App every remaining GO 4 flight mode and the missing settings
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>
This commit is contained in:
1 parent
91ca150a35
commit
636da1ac2c
57 files changed
+5363
-87
No files matched your search
@@ -38,9 +38,13 @@ class FakeDjiService extends DjiService {
|
||||
/// The last `uploadWaypointMission` payload.
|
||||
Map<String, Object?>? uploaded;
|
||||
|
||||
/// Whether the last upload went to the Waypoints 2.0 operator.
|
||||
bool uploadedV2 = false;
|
||||
|
||||
@override
|
||||
Future<void> uploadWaypointMission(Map<String, Object?> mission) {
|
||||
Future<void> uploadWaypointMission(Map<String, Object?> mission, {bool v2 = false}) {
|
||||
uploaded = mission;
|
||||
uploadedV2 = v2;
|
||||
return _record('uploadWaypointMission(${(mission['waypoints'] as List<Object?>).length})');
|
||||
}
|
||||
|
||||
@@ -50,6 +54,90 @@ class FakeDjiService extends DjiService {
|
||||
Future<void> pauseWaypointMission() => _record('pauseWaypointMission');
|
||||
@override
|
||||
Future<void> resumeWaypointMission() => _record('resumeWaypointMission');
|
||||
@override
|
||||
Future<void> setWaypointAutoFlightSpeed(double speed) => _record('setWaypointAutoFlightSpeed(${speed.toStringAsFixed(0)})');
|
||||
|
||||
/// What `getWaypointInterruption` answers.
|
||||
Map<String, dynamic>? interruption;
|
||||
@override
|
||||
Future<Map<String, dynamic>?> getWaypointInterruption() async => interruption;
|
||||
|
||||
// ── POI / Follow Me / orientation / QuickShots (plan P7) ───────────────────
|
||||
Map<String, Object?>? hotpoint;
|
||||
double? maxAngular;
|
||||
|
||||
@override
|
||||
Future<void> startHotpoint({
|
||||
required double lat,
|
||||
required double lon,
|
||||
required double altitude,
|
||||
required double radius,
|
||||
required double angularVelocity,
|
||||
required bool clockwise,
|
||||
String startPoint = 'NEAREST',
|
||||
String heading = 'TOWARDS_HOT_POINT',
|
||||
}) {
|
||||
hotpoint = <String, Object?>{'lat': lat, 'lon': lon, 'altitude': altitude, 'radius': radius, 'angularVelocity': angularVelocity, 'clockwise': clockwise, 'startPoint': startPoint, 'heading': heading};
|
||||
return _record('startHotpoint');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pauseHotpoint() => _record('pauseHotpoint');
|
||||
@override
|
||||
Future<void> resumeHotpoint() => _record('resumeHotpoint');
|
||||
@override
|
||||
Future<void> stopHotpoint() => _record('stopHotpoint');
|
||||
@override
|
||||
Future<void> setHotpointRadius(double radius) => _record('setHotpointRadius(${radius.round()})');
|
||||
@override
|
||||
Future<void> setHotpointAngularVelocity(double value) => _record('setHotpointAngularVelocity(${value.toStringAsFixed(1)})');
|
||||
@override
|
||||
Future<void> resetHotpointHeading() => _record('resetHotpointHeading');
|
||||
@override
|
||||
Future<double?> getHotpointMaxAngularVelocity(double radius) async => maxAngular;
|
||||
@override
|
||||
Future<void> startFollowMe({required double lat, required double lon, required double altitude, String heading = 'TOWARD_FOLLOW_POSITION'}) =>
|
||||
_record('startFollowMe(${altitude.round()}, $heading)');
|
||||
@override
|
||||
Future<void> updateFollowMeTarget(double lat, double lon) => _record('updateFollowMeTarget');
|
||||
@override
|
||||
Future<void> stopFollowMe() => _record('stopFollowMe');
|
||||
@override
|
||||
Future<void> setFlightOrientationMode(String mode) => _record('setFlightOrientationMode($mode)');
|
||||
@override
|
||||
Future<void> lockCourseUsingCurrentHeading() => _record('lockCourseUsingCurrentHeading');
|
||||
@override
|
||||
Future<void> startQuickShot(String name, {double? x, double? y, double? w, double? h}) => _record('startQuickShot($name)');
|
||||
@override
|
||||
Future<void> setActiveTrackCircularSpeed(double speed) => _record('setActiveTrackCircularSpeed(${speed.toStringAsFixed(0)})');
|
||||
@override
|
||||
Future<void> setShootPhotoMode(String mode) => _record('setShootPhotoMode($mode)');
|
||||
|
||||
// ── Remote / link / cache (plan P6) ────────────────────────────────────────
|
||||
@override
|
||||
Future<void> searchMasters(bool on) => _record('searchMasters($on)');
|
||||
@override
|
||||
Future<String?> connectToMaster({required int id, required String name, required String password}) async {
|
||||
await _record('connectToMaster($id, $name)');
|
||||
return 'ACCEPTED';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> getSlaves() async => const <Map<String, dynamic>>[];
|
||||
@override
|
||||
Future<void> watchSpectrum(bool on) async => calls.add('watchSpectrum($on)');
|
||||
@override
|
||||
Future<void> setWifiCredentials({String? ssid, String? password}) => _record('setWifiCredentials(${ssid ?? '-'}, ${password == null ? '-' : '***'})');
|
||||
@override
|
||||
Future<void> rebootWifi() => _record('rebootWifi');
|
||||
@override
|
||||
Future<void> setVideoCacheRecording(bool on, {int? limitMb}) async => calls.add('setVideoCacheRecording($on)');
|
||||
@override
|
||||
Future<Map<String, dynamic>> getVideoCacheInfo() async => <String, dynamic>{'bytes': 0, 'files': 0, 'recording': false};
|
||||
@override
|
||||
Future<void> clearVideoCache() => _record('clearVideoCache');
|
||||
@override
|
||||
Future<void> calibrateInfinityFocus() => _record('calibrateInfinityFocus');
|
||||
|
||||
// ── ActiveTrack / TapFly (plan P7b) ────────────────────────────────────────
|
||||
@override
|
||||
@@ -141,7 +229,7 @@ class FakeDjiService extends DjiService {
|
||||
@override
|
||||
Future<void> stopCompassCalibration() => _record('stopCompassCalibration');
|
||||
@override
|
||||
Future<void> startIMUCalibration({int? index}) => _record('startIMUCalibration');
|
||||
Future<void> startIMUCalibration({int? index}) => _record(index == null ? 'startIMUCalibration' : 'startIMUCalibration($index)');
|
||||
@override
|
||||
Future<void> startGravityCenterCalibration() => _record('startGravityCenterCalibration');
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pilotvault_fly/dji_service.dart';
|
||||
import 'package:pilotvault_fly/flight_model.dart';
|
||||
import 'package:pilotvault_fly/fly/fly_prefs.dart';
|
||||
import 'package:pilotvault_fly/fly/gimbal_control.dart';
|
||||
import 'package:pilotvault_fly/fly/hud_features.dart';
|
||||
import 'package:pilotvault_fly/fly/product_profile.dart';
|
||||
import 'package:pilotvault_fly/fly/settings/settings_spec.dart';
|
||||
import 'package:pilotvault_fly/fly/settings/settings_tabs.dart';
|
||||
import 'package:pilotvault_fly/fly/state/aircraft_state.dart';
|
||||
import 'package:pilotvault_fly/fly/state/fly_zone_state.dart';
|
||||
import 'package:pilotvault_fly/fly/state/key_state.dart';
|
||||
import 'package:pilotvault_fly/fly/state/link_extras.dart';
|
||||
import 'package:pilotvault_fly/fly/telemetry_reducer.dart';
|
||||
import 'package:pilotvault_fly/fly/units.dart';
|
||||
import 'package:pilotvault_fly/ui/fly/panels/settings/calibration/wizards.dart';
|
||||
import 'package:pilotvault_fly/ui/fly/panels/settings/link_rows.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'profile_fixtures.dart';
|
||||
import 'telemetry_fixtures.dart';
|
||||
|
||||
FlightModel _connected(Map<String, dynamic> profile) => FlightModel()
|
||||
..connected = true
|
||||
..profile = ProductProfile.fromBridge(profile);
|
||||
|
||||
void main() {
|
||||
group('Status bar and safety extras (P2 / P3)', () {
|
||||
test('RTK and compass interference ride in the telemetry tick', () {
|
||||
final FlightModel m = _connected(phantom4);
|
||||
TelemetryReducer(m).apply(telemetryTick()
|
||||
..['rtk'] = <String, dynamic>{'solution': 'FIXED_POINT', 'inUse': true, 'satellites': 24}
|
||||
..['compassSensors'] = <Object?>[
|
||||
<String, dynamic>{'index': 0, 'state': 'NORMAL_MODULUS', 'value': 1500},
|
||||
<String, dynamic>{'index': 1, 'state': 'SERIOUS_MODULUS', 'value': 2600},
|
||||
]);
|
||||
expect(m.aircraft.rtk!.label, 'FIX');
|
||||
expect(m.aircraft.rtk!.fixed, isTrue);
|
||||
expect(m.aircraft.compassSensors.map((CompassSensor c) => c.level), <int?>[0, 2]);
|
||||
TelemetryReducer(m).apply(telemetryTick());
|
||||
expect(m.aircraft.rtk, isNull, reason: 'no module, no chip');
|
||||
});
|
||||
|
||||
test('Precision Takeoff follows the flight controller once it answered', () {
|
||||
ProductProfile with_(bool? supported) => ProductProfile.fromBridge(<String, dynamic>{
|
||||
...phantom4,
|
||||
'flightController': <String, dynamic>{...(phantom4['flightController'] as Map<String, dynamic>), 'precisionTakeOffSupported': supported},
|
||||
});
|
||||
expect(HudFeatures.precisionTakeoff.enabledFor(with_(null)), ProductProfile.fromBridge(phantom4).hasVps);
|
||||
expect(HudFeatures.precisionTakeoff.enabledFor(with_(false)), isFalse);
|
||||
expect(HudFeatures.precisionTakeoff.enabledFor(with_(true)), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Gimbal presets (P5)', () {
|
||||
test('Fast / Medium / Slow write fractions of each range and are recognised back', () {
|
||||
const Map<String, AxisRange> ranges = <String, AxisRange>{
|
||||
'pitchMaxSpeed': AxisRange(0, 100),
|
||||
'pitchSmoothing': AxisRange(0, 30),
|
||||
};
|
||||
expect(GimbalPreset.fast.valuesFor(ranges), <String, int>{'pitchMaxSpeed': 80, 'pitchSmoothing': 6});
|
||||
final Map<String, int> slow = GimbalPreset.slow.valuesFor(ranges);
|
||||
expect(GimbalPreset.matching(ranges, (String id) => slow[id]), GimbalPreset.slow);
|
||||
expect(GimbalPreset.matching(ranges, (String id) => id == 'pitchMaxSpeed' ? 50 : 1), isNull, reason: 'a custom mix');
|
||||
});
|
||||
});
|
||||
|
||||
group('Settings extras (P6)', () {
|
||||
test('units', () {
|
||||
expect(Units.length(100, UnitSystem.imperial), '328 ft');
|
||||
expect(Units.speed(10, UnitSystem.imperial), '22.4 mph');
|
||||
expect(Units.distance(1500, UnitSystem.metric), '1.50 km');
|
||||
expect(Units.distance(2000, UnitSystem.imperial), '1.24 mi');
|
||||
});
|
||||
|
||||
test('spectrum: OcuSync bands and Lightbridge channels, the quietest marked', () {
|
||||
final Spectrum os = Spectrum.fromEvent(<String, dynamic>{
|
||||
'link': 'ocusync',
|
||||
'bands': <Object?>[
|
||||
<String, dynamic>{'from': 2400.0, 'to': 2410.0, 'rssi': -90},
|
||||
<String, dynamic>{'from': 2410.0, 'to': 2420.0, 'rssi': -100},
|
||||
],
|
||||
});
|
||||
expect(os.bars.map((SpectrumBar b) => b.label), <String>['2405', '2415']);
|
||||
expect(os.quietest!.label, '2415');
|
||||
final Spectrum lb = Spectrum.fromEvent(<String, dynamic>{
|
||||
'link': 'lightbridge',
|
||||
'channels': <Object?>[<String, dynamic>{'channel': 13, 'power': -70}],
|
||||
});
|
||||
expect(lb.bars.single.label, '13');
|
||||
});
|
||||
|
||||
test('battery warning records read as what went wrong', () {
|
||||
expect(formatWarningRecord(<String, dynamic>{'error': false}), 'None');
|
||||
expect(formatWarningRecord(<String, dynamic>{'overHeated': true, 'lowVoltageCell': 3}), 'overheated, low cell 3');
|
||||
expect(
|
||||
formatWarningHistory(<Object?>[
|
||||
<String, dynamic>{'error': false},
|
||||
<String, dynamic>{'underVoltage': true},
|
||||
]),
|
||||
'1 of 2: under-voltage',
|
||||
);
|
||||
});
|
||||
|
||||
test('text rows: secrets never show; master / slave rows follow the role', () {
|
||||
const KeyTextSpec pw = KeyTextSpec(id: 'pw', label: 'Password', key: DjiKey.wifi('PASSWORD'), secret: true);
|
||||
final KeyState k = KeyState()..apply(pw.key.id, 'hunter22');
|
||||
expect(pw.shown(k), '••••••');
|
||||
final ProductProfile p = ProductProfile.fromBridge(inspire2X5s);
|
||||
final SettingsTab rc = SettingsTabs.all(p).firstWhere((SettingsTab t) => t.id == SettingsTabs.rc);
|
||||
final SettingSpec password = rc.rows.firstWhere((SettingSpec r) => r.id == 'rcLinkPassword');
|
||||
final KeyState s = KeyState();
|
||||
final FlightModel m = _connected(inspire2X5s);
|
||||
SettingsContext ctx() => SettingsContext(profile: p, keys: s, aircraft: m.aircraft);
|
||||
expect(password.visible(ctx()), isFalse, reason: 'single-operator remote');
|
||||
s.apply(SettingsKeys.rcRole.id, 'MASTER');
|
||||
expect(password.visible(ctx()), p.hasMasterSlaveRc);
|
||||
});
|
||||
|
||||
test('a C1 press reaches the model as a new value each time', () {
|
||||
final FlightModel m = _connected(phantom4);
|
||||
final TelemetryReducer r = TelemetryReducer(m);
|
||||
r.apply(<String, dynamic>{'type': 'rcButton', 'button': 'C1'});
|
||||
final RcButtonPress first = m.rcButton.value!;
|
||||
r.apply(<String, dynamic>{'type': 'rcButton', 'button': 'C1'});
|
||||
expect(m.rcButton.value!.button, RcButton.c1);
|
||||
expect(m.rcButton.value, isNot(first));
|
||||
});
|
||||
|
||||
testWidgets('C1 / C2 choices are saved on the phone', (WidgetTester tester) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
await FlyPrefs.load();
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SingleChildScrollView(child: CustomButtonRows()))));
|
||||
await tester.tap(find.byKey(const ValueKey<String>('row-rcC1')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey<String>('opt-mapSwap')));
|
||||
await tester.pump();
|
||||
expect(FlyPrefs.c1.value, RcButtonAction.mapSwap);
|
||||
});
|
||||
});
|
||||
|
||||
group('Calibration extras (P6b)', () {
|
||||
testWidgets('the interference meter grades every compass', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CompassInterferenceMeter(sensors: <CompassSensor>[CompassSensor(0, 'NORMAL_MODULUS', 1500), CompassSensor(1, 'WEAK_MODULUS', 2100)]),
|
||||
),
|
||||
));
|
||||
expect(find.text('Low'), findsOneWidget);
|
||||
expect(find.text('Moderate'), findsOneWidget);
|
||||
expect(find.text('Compass 2'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('GEO database (P8)', () {
|
||||
test('a newer precise database on the server marks it out of date, across re-reads', () {
|
||||
final FlyZoneState z = FlyZoneState();
|
||||
z.applyDatabase(<String, dynamic>{'preciseVersion': '1.0', 'preciseState': 'UP_TO_DATE'});
|
||||
expect(z.database!.outOfDate, isFalse);
|
||||
z.applyRemoteVersion('1.1');
|
||||
expect(z.database!.preciseBehind, isTrue);
|
||||
z.applyDatabase(<String, dynamic>{'preciseVersion': '1.0', 'preciseState': 'UP_TO_DATE'});
|
||||
expect(z.database!.outOfDate, isTrue, reason: 'the remote version is kept');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
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/intelligent_modes.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/orbit_modes.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/terrain.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/vision_modes.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/waypoint_mission.dart';
|
||||
import 'package:pilotvault_fly/fly/product_profile.dart';
|
||||
import 'package:pilotvault_fly/fly/safety_gate.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/flight_command_flow.dart';
|
||||
import 'package:pilotvault_fly/ui/fly/modes/mode_panels.dart';
|
||||
import 'package:pilotvault_fly/ui/fly/modes/mode_picker.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'fake_dji.dart';
|
||||
import 'profile_fixtures.dart';
|
||||
import 'telemetry_fixtures.dart';
|
||||
|
||||
FlightModel _model(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> _grounded() => telemetryTick()
|
||||
..['isFlying'] = false
|
||||
..['areMotorsOn'] = false;
|
||||
|
||||
List<String> _blockers(List<GateCheck> c) => <String>[for (final GateCheck g in c) if (g.blocks) g.message];
|
||||
|
||||
class _Ground implements ElevationSource {
|
||||
_Ground(this.at);
|
||||
|
||||
/// Ground elevation for a point.
|
||||
final double Function(LatLng p) at;
|
||||
|
||||
@override
|
||||
Future<List<double>> elevations(List<LatLng> points) async => <double>[for (final LatLng p in points) at(p)];
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Point of Interest', () {
|
||||
test('spec: °/s from the tangential speed, the speed capped at the aircraft\'s limit', () {
|
||||
const PoiSpec p = PoiSpec(center: LatLng(55.676, 12.568), radius: 30, speed: 3);
|
||||
expect(p.angularVelocity, closeTo(5.73, 0.01));
|
||||
expect(PoiSpec.speedFor(p.angularVelocity, 30), closeTo(3, 1e-9));
|
||||
expect(p.toBridge(maxAngular: 4)['angularVelocity'], 4);
|
||||
expect(p.issues(maxAngular: 4).where(((bool, String) i) => !i.$1).map(((bool, String) i) => i.$2), contains(contains('at most')));
|
||||
expect(const PoiSpec().issues().first.$1, isTrue, reason: 'no centre blocks');
|
||||
expect(p.lapSeconds, 63);
|
||||
});
|
||||
|
||||
test('gate: flying in P mode with GPS; refused on the ground or during another mode', () {
|
||||
const PoiSpec p = PoiSpec(center: LatLng(55.676, 12.568));
|
||||
final FlightModel air = _model(telemetryTick());
|
||||
expect(_blockers(SafetyGate(model: air, dji: FakeDjiService()).checkPoi(p)), isEmpty);
|
||||
expect(_blockers(SafetyGate(model: _model(_grounded()), dji: FakeDjiService()).checkPoi(p)), contains('Take off before starting Point of Interest.'));
|
||||
air.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'tapFly', 'state': 'EXECUTING'});
|
||||
expect(_blockers(SafetyGate(model: air, dji: FakeDjiService()).checkPoi(p)), contains('Stop the intelligent flight mode that is running first.'));
|
||||
});
|
||||
|
||||
test('start, adjust and Stop go to the hotpoint operator', () async {
|
||||
final FlightModel m = _model(telemetryTick());
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate g = SafetyGate(model: m, dji: dji);
|
||||
await g.startPoi(const PoiSpec(center: LatLng(55.676, 12.568), radius: 40, speed: 4, clockwise: false, heading: PoiHeading.forward));
|
||||
expect(dji.hotpoint!['radius'], 40);
|
||||
expect(dji.hotpoint!['clockwise'], isFalse);
|
||||
expect(dji.hotpoint!['heading'], 'ALONG_CIRCLE_LOOKING_FORWARDS');
|
||||
|
||||
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'hotpoint', 'state': 'EXECUTING', 'radius': 40.0});
|
||||
expect(m.mission.state, 'hotpoint');
|
||||
expect(RailState.activeMode(m.aircraft, m.mission), ActiveMode.hotpoint);
|
||||
await g.adjustPoi(speed: 2, clockwise: false, currentRadius: 40);
|
||||
expect(dji.calls.last, 'setHotpointAngularVelocity(-2.9)', reason: 'anticlockwise is negative');
|
||||
await g.run(FlightCommand.stopMode);
|
||||
expect(dji.calls.last, 'stopHotpoint');
|
||||
});
|
||||
|
||||
test('a finish with an error is reported once', () {
|
||||
final MissionState ms = MissionState();
|
||||
ms.apply(<String, dynamic>{'type': 'mission', 'operator': 'hotpoint', 'state': 'EXECUTING'});
|
||||
ms.apply(<String, dynamic>{'type': 'mission', 'operator': 'hotpoint', 'state': 'READY_TO_EXECUTE', 'event': 'finish', 'error': 'GPS lost'});
|
||||
expect(ms.modeResult, 'GPS lost');
|
||||
expect(ms.modeFinishes, 1);
|
||||
expect(ms.hotpoint.active, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('Follow Me', () {
|
||||
test('needs the phone\'s fix; starts at the set height and streams the phone', () async {
|
||||
final FlightModel m = _model(telemetryTick());
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate g = SafetyGate(model: m, dji: dji);
|
||||
expect(_blockers(g.checkFollowMe(const FollowMeSpec())), contains(contains('no location fix')));
|
||||
m
|
||||
..phoneLatitude = 55.6762
|
||||
..phoneLongitude = 12.5684;
|
||||
expect(_blockers(g.checkFollowMe(const FollowMeSpec())), isEmpty);
|
||||
await g.startFollowMe(const FollowMeSpec(altitude: 25, heading: FollowMeHeading.remote));
|
||||
expect(dji.calls.last, 'startFollowMe(25, CONTROLLED_BY_REMOTE_CONTROLLER)');
|
||||
await g.updateFollowMe();
|
||||
expect(dji.calls.last, isNot('updateFollowMeTarget'), reason: 'not following yet');
|
||||
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'followMe', 'state': 'EXECUTING', 'distance': 12.0});
|
||||
await g.updateFollowMe();
|
||||
expect(dji.calls.last, 'updateFollowMeTarget');
|
||||
expect(m.mission.followMe.distance, 12);
|
||||
await g.run(FlightCommand.stopMode);
|
||||
expect(dji.calls.last, 'stopFollowMe');
|
||||
});
|
||||
});
|
||||
|
||||
group('Orientation, Tripod, Hyperlapse', () {
|
||||
test('Home Lock needs a home point 10 m away; Course Lock locks the heading', () async {
|
||||
final FlightModel near = _model(telemetryTick()..['homeDistance'] = 4.0);
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
expect(_blockers(SafetyGate(model: near, dji: dji).checkOrientation(OrientationMode.homeLock)), contains(contains('10 m')));
|
||||
final FlightModel m = _model(telemetryTick());
|
||||
final SafetyGate g = SafetyGate(model: m, dji: dji);
|
||||
await g.setOrientation(OrientationMode.courseLock);
|
||||
expect(dji.calls.last, 'setFlightOrientationMode(COURSE_LOCK)');
|
||||
await expectLater(g.lockCourse(), throwsA(isA<MissionRefused>()), reason: 'the FC still reports normal heading');
|
||||
TelemetryReducer(m).apply(telemetryTick()..['orientationMode'] = 'COURSE_LOCK');
|
||||
await g.lockCourse();
|
||||
expect(dji.calls.last, 'lockCourseUsingCurrentHeading');
|
||||
expect(OrientationMode.fromSdk('HOME_LOCK'), OrientationMode.homeLock);
|
||||
});
|
||||
|
||||
test('Tripod and Cinematic are key writes behind the gate', () async {
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji);
|
||||
await g.setSlowMode(cinematic: false, on: true);
|
||||
expect(dji.calls.last, 'setKey(TRIPOD_MODE_ENABLED, true)');
|
||||
await g.setSlowMode(cinematic: true, on: false);
|
||||
expect(dji.calls.last, 'setKey(CINEMATIC_MODE_ENABLED, false)');
|
||||
});
|
||||
|
||||
test('Free hyperlapse: frames from the video length, then the camera\'s interval mode', () async {
|
||||
const HyperlapseSpec h = HyperlapseSpec(intervalSeconds: 3, videoSeconds: 10);
|
||||
expect(h.frames, 250);
|
||||
expect(h.shootingSeconds, 750);
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji);
|
||||
await g.startHyperlapse(h);
|
||||
expect(dji.calls, <String>['setCameraMode(photo)', 'setShootPhotoMode(INTERVAL)', 'setPhotoTimeInterval(250, 3)', 'startShootPhoto']);
|
||||
// Over 254 frames the camera shoots "until stopped" and the gate stops it.
|
||||
const HyperlapseSpec long = HyperlapseSpec(intervalSeconds: 2, videoSeconds: 20);
|
||||
expect(long.cameraCount, 255);
|
||||
dji.calls.clear();
|
||||
await g.startHyperlapse(long);
|
||||
expect(dji.calls, contains('setPhotoTimeInterval(255, 2)'));
|
||||
await g.stopHyperlapse();
|
||||
expect(dji.calls.last, 'stopShootPhoto');
|
||||
});
|
||||
});
|
||||
|
||||
group('QuickShots and ActiveTrack extras', () {
|
||||
test('shots offered per product; the gate flies one on a boxed subject', () async {
|
||||
expect(QuickShot.offered(omni: false, zoom: false), hasLength(4));
|
||||
expect(QuickShot.offered(omni: true, zoom: true), contains(QuickShot.dollyZoom));
|
||||
expect(TrackMode.offered(omni: false), isNot(contains(TrackMode.spotlightPro)));
|
||||
expect(TrackMode.offered(omni: true), containsAll(<TrackMode>[TrackMode.spotlightPro, TrackMode.circle]));
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji);
|
||||
expect(g.checkQuickShot(QuickShot.rocket).where((GateCheck c) => !c.blocks), isNotEmpty, reason: 'it always warns about the path');
|
||||
await g.startQuickShot(const Rect.fromLTWH(0.4, 0.3, 0.2, 0.3), QuickShot.helix);
|
||||
expect(dji.calls.last, 'startQuickShot(HELIX)');
|
||||
await g.setTrackCircleSpeed(-4);
|
||||
expect(dji.calls.last, 'setActiveTrackCircularSpeed(-4)');
|
||||
});
|
||||
|
||||
test('QuickShot progress comes from the ActiveTrack operator', () {
|
||||
final MissionState ms = MissionState();
|
||||
ms.apply(<String, dynamic>{
|
||||
'type': 'mission',
|
||||
'operator': 'activeTrack',
|
||||
'state': 'PERFORMING_QUICK_SHOT',
|
||||
'tracking': <String, dynamic>{'percent': 40},
|
||||
});
|
||||
expect(ms.state, 'quickshot');
|
||||
expect(ms.activeTrack.percent, 40);
|
||||
});
|
||||
});
|
||||
|
||||
group('Waypoints extras', () {
|
||||
const WaypointMissionSpec route = WaypointMissionSpec(points: <WaypointSpec>[
|
||||
WaypointSpec(at: LatLng(55.6760, 12.5680)),
|
||||
WaypointSpec(at: LatLng(55.6770, 12.5680)),
|
||||
WaypointSpec(at: LatLng(55.6780, 12.5680)),
|
||||
WaypointSpec(at: LatLng(55.6790, 12.5680)),
|
||||
]);
|
||||
|
||||
test('resume: an interruption on this route resumes from its waypoint', () async {
|
||||
expect(route.from(2).points, hasLength(2));
|
||||
expect(route.matchesInterruption(2, const LatLng(55.6775, 12.5680)), isTrue);
|
||||
expect(route.matchesInterruption(2, const LatLng(55.70, 12.60)), isFalse);
|
||||
final FakeDjiService dji = FakeDjiService()..interruption = <String, dynamic>{'index': 2, 'lat': 55.6775, 'lon': 12.5680};
|
||||
final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji);
|
||||
expect(await g.interruptionFor(route), 2);
|
||||
dji.interruption = <String, dynamic>{'index': 3, 'lat': 55.6785, 'lon': 12.5680};
|
||||
expect(await g.interruptionFor(route), isNull, reason: 'one waypoint left is not a route');
|
||||
});
|
||||
|
||||
test('cruise speed in flight only while the mission flies', () async {
|
||||
final FlightModel m = _model(telemetryTick());
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate g = SafetyGate(model: m, dji: dji);
|
||||
await expectLater(g.setWaypointCruiseSpeed(6), throwsA(isA<MissionRefused>()));
|
||||
m.mission.apply(<String, dynamic>{'type': 'mission', 'operator': 'waypoint', 'state': 'EXECUTING'});
|
||||
await g.setWaypointCruiseSpeed(20);
|
||||
expect(dji.calls.last, 'setWaypointAutoFlightSpeed(15)');
|
||||
});
|
||||
|
||||
test('a V2-only aircraft uploads to Waypoints 2.0 and is warned about actions', () async {
|
||||
final Map<String, dynamic> m300 = <String, dynamic>{...mavic2Zoom, 'model': 'MATRICE_300_RTK'};
|
||||
final FlightModel m = _model(telemetryTick(), profile: m300);
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate g = SafetyGate(model: m, dji: dji);
|
||||
expect(g.usesWaypointsV2, isTrue);
|
||||
final WaypointMissionSpec shooting = route.copyWith(points: <WaypointSpec>[
|
||||
for (final WaypointSpec p in route.points) p.copyWith(action: WaypointCameraAction.photo),
|
||||
]);
|
||||
expect(g.checkWaypointStart(shooting).map((GateCheck c) => c.message), contains(contains('Waypoints 2.0')));
|
||||
await g.startWaypointMission(route);
|
||||
expect(dji.uploadedV2, isTrue);
|
||||
});
|
||||
|
||||
test('terrain: a hill between waypoints is found, flat ground is not', () async {
|
||||
const TerrainCheck flat = TerrainCheck(_FlatGround());
|
||||
expect(await flat.run(route, route.points.first.at), isEmpty);
|
||||
// A 60 m hill around the second leg; the route flies 50 m above take-off.
|
||||
final TerrainCheck hill = TerrainCheck(_Ground((LatLng p) => p.latitude > 55.6772 && p.latitude < 55.6778 ? 60 : 0));
|
||||
final List<TerrainIssue> found = await hill.run(route, route.points.first.at);
|
||||
expect(found.single.leg, 1);
|
||||
expect(found.single.clearance, lessThan(0));
|
||||
expect(found.single.toIssue().blocks, isTrue);
|
||||
expect(hill.samples(route).length, lessThanOrEqualTo(100));
|
||||
});
|
||||
});
|
||||
|
||||
group('Catalogue and tutorials', () {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
FlyPrefs.seenTutorials.value = <String>{};
|
||||
});
|
||||
|
||||
test('every mode is flyable and has a tutorial', () {
|
||||
for (final ModeInfo i in ModeCatalog.all) {
|
||||
expect(i.built, isTrue, reason: i.label);
|
||||
expect(i.tutorial, isNotEmpty, reason: i.label);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('a tutorial shows once, page by page', (WidgetTester tester) async {
|
||||
late BuildContext ctx;
|
||||
await tester.pumpWidget(MaterialApp(home: Builder(builder: (BuildContext c) {
|
||||
ctx = c;
|
||||
return const SizedBox();
|
||||
})));
|
||||
final ModeInfo poi = ModeCatalog.of(IntelligentMode.pointOfInterest);
|
||||
final Future<void> shown = ensureModeTutorial(ctx, poi);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(poi.tutorial.first), findsOneWidget);
|
||||
for (int i = 0; i < poi.tutorial.length; i++) {
|
||||
await tester.tap(find.byKey(const ValueKey<String>('tutorial-next')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await shown;
|
||||
expect(FlyPrefs.seenTutorials.value, contains('pointOfInterest'));
|
||||
ensureModeTutorial(ctx, poi);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey<String>('tutorial-step')), findsNothing, reason: 'seen once');
|
||||
});
|
||||
});
|
||||
|
||||
group('Mode panels', () {
|
||||
Future<(FlightModel, FakeDjiService)> pump(WidgetTester tester, Widget Function(FlightModel m, SafetyGate g, FlightCommandFlow f) build) async {
|
||||
tester.view.physicalSize = const Size(844, 390);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final FlightModel m = _model(telemetryTick());
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate g = SafetyGate(model: m, dji: dji);
|
||||
final FlightCommandFlow f = FlightCommandFlow(gate: g, onError: (_, __) {}, reviewBeforeTakeOff: (_) async => true);
|
||||
await tester.pumpWidget(MaterialApp(home: Scaffold(body: Align(alignment: Alignment.topLeft, child: build(m, g, f)))));
|
||||
return (m, dji);
|
||||
}
|
||||
|
||||
testWidgets('POI: centre on the aircraft, GO slides to start', (WidgetTester tester) async {
|
||||
final ModeSetup setup = ModeSetup();
|
||||
final (FlightModel m, FakeDjiService dji) = await pump(
|
||||
tester,
|
||||
(FlightModel m, SafetyGate g, FlightCommandFlow f) =>
|
||||
PoiPanel(model: m, setup: setup, gate: g, flow: f, dji: FakeDjiService(), onExit: () {}, onError: (_, __) {}),
|
||||
);
|
||||
expect(find.text('TAP THE MAP ON THE SUBJECT'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('poi-aircraft')));
|
||||
await tester.pump();
|
||||
expect(setup.poi.center, LatLng(m.aircraft.latitude!, m.aircraft.longitude!));
|
||||
await tester.tap(find.byKey(const ValueKey<String>('poi-radius-plus')));
|
||||
await tester.pump();
|
||||
expect(setup.poi.radius, 35);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('mode-poi-go')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SlideConfirmDialog), findsOneWidget);
|
||||
expect(dji.calls, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('Hyperlapse: picking the interval updates the frame count', (WidgetTester tester) async {
|
||||
final ModeSetup setup = ModeSetup();
|
||||
await pump(tester, (FlightModel m, SafetyGate g, FlightCommandFlow f) => HyperlapsePanel(model: m, setup: setup, gate: g, onExit: () {}, onError: (_, __) {}));
|
||||
await tester.tap(find.byKey(const ValueKey<String>('hl-length-20')));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('500 FRAMES'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FlatGround implements ElevationSource {
|
||||
const _FlatGround();
|
||||
|
||||
@override
|
||||
Future<List<double>> elevations(List<LatLng> points) async => List<double>.filled(points.length, 12);
|
||||
}
|
||||
@@ -71,7 +71,9 @@ void main() {
|
||||
expect(rail(telemetryTick(), mission: 'executing').mode, ActiveMode.waypoint);
|
||||
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_WAYPOINT').mode, ActiveMode.waypoint);
|
||||
expect(rail(telemetryTick()..['flightModeEnum'] = 'TAP_FLY').mode, ActiveMode.tapFly);
|
||||
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT').mode, ActiveMode.other);
|
||||
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT').mode, ActiveMode.hotpoint);
|
||||
expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_FOLLOW_ME').mode, ActiveMode.followMe);
|
||||
expect(rail(telemetryTick()..['flightModeEnum'] = 'DRAW').mode, ActiveMode.other);
|
||||
expect(rail(telemetryTick(), mission: 'uploading').showStop, isFalse);
|
||||
expect(rail(grounded(), mission: 'tracking').showStop, isFalse, reason: 'nothing to stop on the ground');
|
||||
});
|
||||
@@ -146,7 +148,8 @@ void main() {
|
||||
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING'), FlightCommand.cancelLanding), isEmpty);
|
||||
expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.confirmLanding), isEmpty);
|
||||
expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.cancelGoHome), isEmpty);
|
||||
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT'), FlightCommand.stopMode).single, contains('pause button'));
|
||||
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'DRAW'), FlightCommand.stopMode).single, contains('pause button'));
|
||||
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT'), FlightCommand.stopMode), isEmpty, reason: 'POI stops from the app now');
|
||||
expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'TAP_FLY'), FlightCommand.stopMode), isEmpty);
|
||||
});
|
||||
|
||||
|
||||
@@ -112,8 +112,16 @@ void main() {
|
||||
for (final SettingSpec r in SettingsTabs.all(ProductProfile.fromBridge(p)).firstWhere((SettingsTab t) => t.id == 'battery').rows)
|
||||
if (r is GroupSpec) r.label,
|
||||
];
|
||||
expect(groups(phantom4), <String>['WARNINGS', 'BATTERY']);
|
||||
expect(groups(inspire2X5s), <String>['WARNINGS', 'BATTERY 1', 'BATTERY 2']);
|
||||
// The pair and cell-voltage groups are always listed and gated per profile.
|
||||
expect(groups(phantom4), <String>['WARNINGS', 'BATTERY', 'BATTERY PAIR', 'CELL VOLTAGE']);
|
||||
expect(groups(inspire2X5s), <String>['WARNINGS', 'BATTERY 1', 'BATTERY 2', 'BATTERY PAIR', 'CELL VOLTAGE']);
|
||||
final SettingsTab p4 = SettingsTabs.all(lb).firstWhere((SettingsTab t) => t.id == 'battery');
|
||||
final SettingsTab i2 = SettingsTabs.all(ProductProfile.fromBridge(inspire2X5s)).firstWhere((SettingsTab t) => t.id == 'battery');
|
||||
final SettingsContext i2c = SettingsContext(
|
||||
profile: ProductProfile.fromBridge(inspire2X5s), keys: KeyState(), aircraft: connectedModel(inspire2X5s).aircraft);
|
||||
expect(p4.rows.firstWhere((SettingSpec r) => r.id == 'group-BATTERY PAIR').visible(lbc), isFalse, reason: 'one pack, nothing to pair');
|
||||
expect(i2.rows.firstWhere((SettingSpec r) => r.id == 'group-BATTERY PAIR').visible(i2c), isTrue);
|
||||
expect(i2.rows.firstWhere((SettingSpec r) => r.id == 'group-CELL VOLTAGE').visible(i2c), isFalse, reason: 'smart batteries');
|
||||
final KeyReadoutSpec cells = spec<KeyReadoutSpec>(SettingsTabs.battery, 'cells1', ProductProfile.fromBridge(inspire2X5s));
|
||||
expect(cells.key.index, 1);
|
||||
});
|
||||
@@ -287,6 +295,9 @@ void main() {
|
||||
await tester.tap(find.byKey(const ValueKey<String>('opt-MANUAL')));
|
||||
await tester.pump();
|
||||
expect(dji.calls, contains('setKey(CHANNEL_SELECTION_MODE, MANUAL)'));
|
||||
// The spectrum chart above pushes the channel row down the lazy list.
|
||||
await tester.scrollUntilVisible(find.byKey(const ValueKey<String>('row-osChannel')), 80,
|
||||
scrollable: find.descendant(of: find.byKey(const ValueKey<String>('settings-list-transmission')), matching: find.byType(Scrollable)));
|
||||
expect(find.byKey(const ValueKey<String>('row-osChannel')), findsOneWidget);
|
||||
await tester.pump(cameraSettle);
|
||||
});
|
||||
|
||||
@@ -288,6 +288,8 @@ void main() {
|
||||
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 {
|
||||
|
||||
@@ -682,6 +682,8 @@ void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
FlyPrefs.acceptedModeTerms.value = <String>{};
|
||||
// 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, {WaypointEditor? editor}) async {
|
||||
|
||||
Reference in new issue
Block a user