Files
PilotVault/Fly App/test/fly/camera_test.dart
T
tajniak81andClaude Opus 5.5 c1770fff73 Give the Fly App GO 4-style camera controls
One settings table in CameraBridge watches every camera value into the
telemetry tick and backs a generic setCameraValue, so the tabbed camera
menu (Exposure / Photo / Video / Other) is built from row specs and the
camera's own capability ranges. Writes show at once and fall back to what
the camera reports. Adds the quick exposure panel (Auto/A/S/M, ISO slider,
shutter and aperture wheels, EV strip, metered EV in M), a new camera
column with record timer and interval/pano progress, a zoom column for
zoom cameras, tap-to-focus / tap-to-meter, and a live histogram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:57:38 +02:00

423 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/flight_model.dart';
import 'package:pilotvault_fly/fly/camera_exposure.dart';
import 'package:pilotvault_fly/fly/camera_menu.dart';
import 'package:pilotvault_fly/fly/fly_prefs.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
import 'package:pilotvault_fly/fly/state/camera_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:pilotvault_fly/ui/fly/panels/camera_menu/camera_menu_sheet.dart';
import 'package:pilotvault_fly/ui/fly/panels/camera_write.dart';
import 'fake_dji.dart';
import 'profile_fixtures.dart';
import 'telemetry_fixtures.dart';
/// A connected model whose camera has reported [settings] on top of the
/// fixture tick's, plus a system state and an exposure read-back.
FlightModel cameraModel(
Map<String, dynamic> profile, {
Map<String, Object?> settings = const <String, Object?>{},
Map<String, Object?> system = const <String, Object?>{},
}) {
final FlightModel m = FlightModel()
..connected = true
..profile = ProductProfile.fromBridge(profile);
final TelemetryReducer r = TelemetryReducer(m);
final Map<String, dynamic> t = telemetryTick();
t['cameraSettings'] = <Object?, Object?>{
...(t['cameraSettings'] as Map<Object?, Object?>),
'exposureMode': 'PROGRAM',
'isoSetting': 'AUTO',
'shootPhotoMode': 'SINGLE',
'meteringMode': 'CENTER',
'whiteBalancePreset': 'AUTO',
'histogramEnabled': false,
...settings,
};
r.apply(t);
r.apply(<String, dynamic>{'type': 'camera', 'isRecording': false, 'mode': 'SHOOT_PHOTO', ...system});
r.apply(<String, dynamic>{'type': 'exposure', 'iso': '100', 'shutter': '1/240', 'aperture': 'f/2.8', 'ev': '0.0'});
return m;
}
/// Re-sends the camera's settings group, as the next telemetry tick would.
void reportSettings(FlightModel m, Map<String, Object?> settings) {
m.camera.applyTelemetry(settings: <String, dynamic>{...m.camera.reportedSettings, ...settings});
}
void main() {
group('Exposure rules', () {
test('A only on adjustable-aperture lenses', () {
const List<String> all = <String>['PROGRAM', 'SHUTTER_PRIORITY', 'APERTURE_PRIORITY', 'MANUAL'];
expect(ExposureOptions.modes(const CameraProfile(exposureModes: all)),
<ExposureMode>[ExposureMode.auto, ExposureMode.shutter, ExposureMode.manual]);
expect(ExposureOptions.modes(const CameraProfile(exposureModes: all, adjustableApertureSupported: true)), ExposureMode.values);
expect(ExposureOptions.modes(ProductProfile.fromBridge(phantom4).camera), <ExposureMode>[ExposureMode.auto, ExposureMode.manual],
reason: 'only what the camera reports');
expect(ExposureOptions.modes(null), ExposureMode.values, reason: 'preview with nothing connected');
});
test('what each mode lets the pilot set', () {
expect(ExposureMode.auto.isoEditable, isFalse);
expect(ExposureMode.auto.evEditable, isTrue);
expect(ExposureMode.shutter.shutterEditable, isTrue);
expect(ExposureMode.shutter.apertureEditable, isFalse);
expect(ExposureMode.aperture.apertureEditable, isTrue);
expect(ExposureMode.manual.evEditable, isFalse, reason: 'EV is the meter in M');
expect(ExposureMode.fromWire('SHUTTER_PRIORITY'), ExposureMode.shutter);
});
test('option lists come from the camera, with fallbacks before the handshake', () {
final CameraProfile? c = ProductProfile.fromBridge(phantom4).camera;
expect(ExposureOptions.isos(c), <String>['100', '200', '400', '800', '1600'], reason: 'AUTO is its own toggle');
expect(ExposureOptions.shutters(c), <String>['1/8000', '1/1000', '1/100', '1/30']);
expect(ExposureOptions.isos(const CameraProfile()), isNotEmpty);
expect(ExposureOptions.evs(c), <String>['-3.0', '0.0', '+3.0']);
});
test('EV steps to the neighbour in the camera range and stops at the ends', () {
final List<String> evs = ExposureOptions.evs(null);
expect(ExposureOptions.stepEv('0.0', evs, 1), '+0.3');
expect(ExposureOptions.stepEv('0.0', evs, -1), '-0.3');
expect(ExposureOptions.stepEv('+3.0', evs, 1), isNull);
expect(ExposureOptions.stepEv(null, evs, 1), '+0.3');
});
test('a wheel starts on the nearest value', () {
const List<String> s = <String>['1/1000', '1/500', '1/240', '1/120'];
expect(ExposureOptions.nearest(s, '1/240'), 2);
expect(ExposureOptions.nearest(s, '1/250'), 2);
expect(ExposureOptions.nearest(const <String>['f/2.8', 'f/4', 'f/5.6'], 'f/4.5'), 1);
});
test('meter verdict', () {
final CameraState c = CameraState()..applyExposure(<String, dynamic>{'ev': '-1.0'});
expect(meterVerdict(c), MeterVerdict.under);
c.applyExposure(<String, dynamic>{'ev': '+0.3'});
expect(meterVerdict(c), MeterVerdict.ok);
});
});
group('Zoom', () {
test('factor, steps and the digital range', () {
final CameraState c = CameraState()
..applyTelemetry(settings: <String, dynamic>{
'zoomFocalLength': 240,
'zoomMin': 240,
'zoomMax': 960,
'zoomOpticalMax': 480,
'zoomStep': 10,
});
final ZoomInfo z = c.zoom!;
expect(z.factor, 1.0);
expect(z.maxFactor, 4.0);
expect(z.stepped(true), 330);
expect(z.stepped(false), 240, reason: 'clamped at the wide end');
expect(z.digital, isFalse);
c.applyTelemetry(settings: <String, dynamic>{...c.reportedSettings, 'zoomFocalLength': 600});
expect(c.zoom!.digital, isTrue);
});
test('no zoom without a spec', () {
expect((CameraState()..applyTelemetry(settings: <String, dynamic>{'zoomFocalLength': 240})).zoom, isNull);
});
});
group('Pending camera writes', () {
test('show at once, clear when the camera reports the setting moved', () {
final FlightModel m = cameraModel(phantom4);
final CameraState c = m.camera;
c.setPending(<String, Object?>{'photoAspectRatio': 'RATIO_16_9'});
expect(c.reportedSettings['photoAspectRatio'], 'RATIO_3_2');
expect(c.photoAspectRatio, 'RATIO_16_9');
expect(c.isPending('photoAspectRatio'), isTrue);
// A tick that only moved storage leaves the pending value up.
c.applyTelemetry(storage: <String, dynamic>{'inserted': true}, settings: c.reportedSettings);
expect(c.isPending('photoAspectRatio'), isTrue);
reportSettings(m, <String, Object?>{'photoAspectRatio': 'RATIO_16_9'});
expect(c.isPending('photoAspectRatio'), isFalse);
expect(c.setting('photoAspectRatio'), 'RATIO_16_9');
});
test('roll back to the reported value', () {
final CameraState c = cameraModel(phantom4).camera;
c.setPending(<String, Object?>{'photoAspectRatio': 'RATIO_16_9'});
c.dropPending(<String>['photoAspectRatio']);
expect(c.setting('photoAspectRatio'), 'RATIO_3_2');
});
test('a refused write rolls back and reports', () async {
final CameraState c = cameraModel(phantom4).camera;
final FakeDjiService dji = FakeDjiService()..failWith = PlatformException(code: 'DJI_ERROR', message: 'nope');
String? failed;
await writeCameraSetting(
c,
label: 'Image size',
pending: <String, Object?>{'photoAspectRatio': 'RATIO_16_9'},
send: () => dji.setCameraValue('photoAspectRatio', 'RATIO_16_9'),
onError: (String label, _) => failed = label,
);
expect(failed, 'Image size');
expect(c.setting('photoAspectRatio'), 'RATIO_3_2');
});
});
group('Camera menu rows', () {
List<String> ids(CameraMenuTab tab, FlightModel m) =>
CameraMenu.rowsFor(tab, m.profile, m.camera).map((CameraRow r) => r.id).toList();
test('photo-mode sub-rows follow the photo mode', () {
FlightModel m = cameraModel(phantom4, settings: <String, Object?>{'burstCount': 'BURST_COUNT_3', 'intervalSeconds': 5});
expect(ids(CameraMenuTab.photo, m), <String>['shootPhotoMode', 'photoAspectRatio', 'photoFileFormat']);
m = cameraModel(phantom4, settings: <String, Object?>{'shootPhotoMode': 'BURST', 'burstCount': 'BURST_COUNT_3'});
expect(ids(CameraMenuTab.photo, m), contains('burstCount'));
m = cameraModel(phantom4, settings: <String, Object?>{'shootPhotoMode': 'INTERVAL', 'intervalSeconds': 5});
expect(ids(CameraMenuTab.photo, m), contains('intervalSeconds'));
});
test('a setting the camera never reported has no row', () {
final FlightModel m = cameraModel(phantom4);
expect(ids(CameraMenuTab.video, m), isNot(contains('videoCaption')));
expect(ids(CameraMenuTab.video, cameraModel(phantom4, settings: <String, Object?>{'videoCaption': true})), contains('videoCaption'));
});
test('product predicates hide rows', () {
expect(ids(CameraMenuTab.exposure, cameraModel(phantom4, settings: <String, Object?>{'focusMode': 'AFC'})),
isNot(contains('focusMode')), reason: 'fixed-focus lens');
expect(ids(CameraMenuTab.exposure, cameraModel(inspire2X5s)), contains('focusMode'));
});
test('custom white balance shows the temperature stepper', () {
expect(ids(CameraMenuTab.video, cameraModel(phantom4)), isNot(contains('whiteBalanceKelvin')));
expect(
ids(CameraMenuTab.video, cameraModel(phantom4, settings: <String, Object?>{'whiteBalancePreset': 'CUSTOM', 'whiteBalanceKelvin': 5600})),
contains('whiteBalanceKelvin'),
);
});
test('options come from the camera range when it published one', () {
final FlightModel m = cameraModel(phantom4);
final ChoiceRow wb = CameraMenu.all.whereType<ChoiceRow>().firstWhere((ChoiceRow r) => r.id == 'whiteBalancePreset');
expect(wb.options(m.profile, m.camera).map((CameraOption o) => o.value), <String>['AUTO', 'SUNNY', 'CLOUDY', 'CUSTOM']);
final ChoiceRow size = CameraMenu.all.whereType<ChoiceRow>().firstWhere((ChoiceRow r) => r.id == 'videoSize');
expect(size.options(m.profile, m.camera).map((CameraOption o) => o.label), <String>['4K 29.97p', '1080p 59.94p']);
expect(size.current(m.camera), 'RESOLUTION_3840x2160@FRAME_RATE_29_DOT_970_FPS');
});
test('labels', () {
expect(CameraLabels.of('RATIO_16_9'), '16:9');
expect(CameraLabels.of('BURST_COUNT_7'), '7');
expect(CameraLabels.of('FILM_A'), 'Film a');
expect(CameraLabels.fpsLabel('FRAME_RATE_30_FPS'), '30p');
});
test('Format and Reset wait for shooting to stop', () {
final ActionRow format = CameraMenu.all.whereType<ActionRow>().firstWhere((ActionRow r) => r.id == 'formatSd');
final FlightModel m = cameraModel(phantom4, system: <String, Object?>{'isRecording': true});
expect(format.blockedReason(m.camera), 'Stop shooting first');
expect(format.blockedReason(cameraModel(phantom4).camera), isNull);
});
});
group('Camera HUD', () {
Future<FakeDjiService> pump(WidgetTester tester, FlightModel m, {Size size = const Size(844, 390)}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final FakeDjiService dji = FakeDjiService();
// A fresh navigator, so a sheet left open by an earlier pump is gone.
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji)));
await tester.pump(const Duration(milliseconds: 50));
return dji;
}
Future<void> openMenu(WidgetTester tester, String tab) async {
await tester.tap(find.byKey(const ValueKey<String>('camera-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(ValueKey<String>('tab-$tab')));
await tester.pump();
}
testWidgets('a menu row writes, shows the pending value, then what the camera reports', (WidgetTester tester) async {
final FlightModel m = cameraModel(phantom4);
final FakeDjiService dji = await pump(tester, m);
await openMenu(tester, 'photo');
expect(find.text('3:2'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('row-photoAspectRatio')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('opt-RATIO_16_9')));
await tester.pump();
expect(dji.calls, contains('setCameraValue(photoAspectRatio, RATIO_16_9)'));
expect(find.text('16:9'), findsOneWidget);
// The camera never confirmed: the row returns to the real value.
await tester.pump(cameraSettle);
expect(find.text('3:2'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('the camera confirming keeps the new value', (WidgetTester tester) async {
final FlightModel m = cameraModel(phantom4);
await pump(tester, m);
await openMenu(tester, 'photo');
await tester.tap(find.byKey(const ValueKey<String>('row-photoFileFormat')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('opt-JPEG')));
await tester.pump();
reportSettings(m, <String, Object?>{'photoFileFormat': 'JPEG'});
await tester.pump(cameraSettle);
expect(find.text('JPEG'), findsOneWidget);
});
testWidgets('a refused write snaps back and says why', (WidgetTester tester) async {
final FlightModel m = cameraModel(phantom4);
final FakeDjiService dji = await pump(tester, m);
dji.failWith = PlatformException(code: 'DJI_ERROR', message: 'Not supported');
await openMenu(tester, 'video');
await tester.tap(find.byKey(const ValueKey<String>('row-videoFileFormat')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('opt-MP4')));
await tester.pump();
await tester.pump();
expect(find.text('MOV'), findsOneWidget);
expect(find.textContaining('Not supported'), findsOneWidget);
});
testWidgets('quick exposure: switching to M turns EV into the meter', (WidgetTester tester) async {
final FlightModel m = cameraModel(phantom4);
final FakeDjiService dji = await pump(tester, m);
await tester.tap(find.byKey(const ValueKey<String>('camera-info')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('EV'), findsWidgets);
await tester.tap(find.byKey(const ValueKey<String>('ev-plus')));
await tester.pump();
expect(dji.calls, contains('setEV(+3.0)'), reason: 'next step in the camera range');
await tester.tap(find.byKey(const ValueKey<String>('exposure-manual')));
await tester.pump();
expect(dji.calls, contains('setCameraValue(exposureMode, MANUAL)'));
expect(find.text('METERED'), findsOneWidget);
dji.calls.clear();
await tester.tap(find.byKey(const ValueKey<String>('ev-plus')));
await tester.pump();
expect(dji.calls, isEmpty, reason: 'EV is not settable in M');
await tester.pump(cameraSettle);
});
testWidgets('no zoom column without a zoom camera; stepped zoom on hybrid', (WidgetTester tester) async {
await pump(tester, cameraModel(phantom4));
expect(find.byKey(const ValueKey<String>('zoom-in')), findsNothing);
final FakeDjiService dji = await pump(
tester,
cameraModel(mavic2Zoom, settings: <String, Object?>{
'zoomFocalLength': 240,
'zoomMin': 240,
'zoomMax': 960,
'zoomOpticalMax': 480,
'zoomStep': 10,
}),
);
expect(find.text('1.0×'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('zoom-in')));
await tester.pump();
expect(dji.calls, contains('setZoomFocalLength(330)'));
expect(tester.takeException(), isNull);
});
testWidgets('tap focuses on a focusing lens, meters on a fixed one; × cancels metering', (WidgetTester tester) async {
FakeDjiService dji = await pump(tester, cameraModel(inspire2X5s));
await tester.tapAt(const Offset(422, 195));
await tester.pump();
expect(dji.calls, contains('setFocusTarget(0.50, 0.50)'));
await tester.pump(const Duration(seconds: 2));
final FlightModel m = cameraModel(phantom4);
dji = await pump(tester, m);
await tester.tapAt(const Offset(422, 195));
await tester.pump();
expect(dji.calls, contains('setSpotMeteringTarget(0.50, 0.50)'));
reportSettings(m, <String, Object?>{'meteringMode': 'SPOT'});
await tester.pump();
expect(find.text('Exposure'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('meter-cancel')));
await tester.pump();
expect(dji.calls, contains('setCameraValue(meteringMode, CENTER)'));
expect(find.text('Exposure'), findsNothing);
await tester.pump(cameraSettle);
});
testWidgets('long press follows the setting', (WidgetTester tester) async {
addTearDown(() => FlyPrefs.longPress.value = LongPressAction.meter);
final FakeDjiService dji = await pump(tester, cameraModel(inspire2X5s));
await tester.longPressAt(const Offset(422, 195));
await tester.pump();
expect(dji.calls, contains('setSpotMeteringTarget(0.50, 0.50)'));
FlyPrefs.longPress.value = LongPressAction.off;
await tester.pump();
dji.calls.clear();
await tester.longPressAt(const Offset(422, 195));
await tester.pump();
expect(dji.calls, isEmpty);
});
testWidgets('the shutter stops a running interval and shows the mode', (WidgetTester tester) async {
final FlightModel m = cameraModel(phantom4,
settings: <String, Object?>{'shootPhotoMode': 'INTERVAL', 'intervalSeconds': 5}, system: <String, Object?>{'shootingInterval': true});
final FakeDjiService dji = await pump(tester, m);
expect(find.text('INTERVAL 5s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey<String>('shutter')));
await tester.pump();
expect(dji.calls, <String>['stopShootPhoto']);
});
testWidgets('AE lock toggles from the column', (WidgetTester tester) async {
final FakeDjiService dji = await pump(tester, cameraModel(phantom4));
await tester.tap(find.byKey(const ValueKey<String>('ae-lock')));
await tester.pump();
expect(dji.calls, contains('setCameraValue(aeLock, true)'));
await tester.pump(cameraSettle);
});
testWidgets('Format SD from the menu is slide-confirmed', (WidgetTester tester) async {
final FakeDjiService dji = await pump(tester, cameraModel(phantom4));
await openMenu(tester, 'other');
final Finder format = find.byKey(const ValueKey<String>('action-formatSd'));
await tester.scrollUntilVisible(format, 60,
scrollable: find.descendant(of: find.byType(CameraMenuSheet), matching: find.byType(Scrollable)));
await tester.tap(format);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.textContaining('This cannot be undone'), findsOneWidget);
expect(dji.calls, isNot(contains('formatStorage')));
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(dji.calls, contains('formatStorage'));
});
testWidgets('lays out without overflow on a small phone and a tablet, menu open', (WidgetTester tester) async {
for (final Size s in const <Size>[Size(740, 340), Size(1280, 800)]) {
await pump(tester, cameraModel(inspire2X5s), size: s);
expect(tester.takeException(), isNull, reason: '$s');
await tester.tap(find.byKey(const ValueKey<String>('camera-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
for (final String t in <String>['exposure', 'photo', 'video', 'other']) {
await tester.tap(find.byKey(ValueKey<String>('tab-$t')));
await tester.pump();
expect(tester.takeException(), isNull, reason: '$s $t');
}
}
});
});
}