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 profile, { Map settings = const {}, Map system = const {}, }) { final FlightModel m = FlightModel() ..connected = true ..profile = ProductProfile.fromBridge(profile); final TelemetryReducer r = TelemetryReducer(m); final Map t = telemetryTick(); t['cameraSettings'] = { ...(t['cameraSettings'] as Map), 'exposureMode': 'PROGRAM', 'isoSetting': 'AUTO', 'shootPhotoMode': 'SINGLE', 'meteringMode': 'CENTER', 'whiteBalancePreset': 'AUTO', 'histogramEnabled': false, ...settings, }; r.apply(t); r.apply({'type': 'camera', 'isRecording': false, 'mode': 'SHOOT_PHOTO', ...system}); r.apply({'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 settings) { m.camera.applyTelemetry(settings: {...m.camera.reportedSettings, ...settings}); } void main() { group('Exposure rules', () { test('A only on adjustable-aperture lenses', () { const List all = ['PROGRAM', 'SHUTTER_PRIORITY', 'APERTURE_PRIORITY', 'MANUAL']; expect(ExposureOptions.modes(const CameraProfile(exposureModes: all)), [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.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), ['100', '200', '400', '800', '1600'], reason: 'AUTO is its own toggle'); expect(ExposureOptions.shutters(c), ['1/8000', '1/1000', '1/100', '1/30']); expect(ExposureOptions.isos(const CameraProfile()), isNotEmpty); expect(ExposureOptions.evs(c), ['-3.0', '0.0', '+3.0']); }); test('EV steps to the neighbour in the camera range and stops at the ends', () { final List 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 s = ['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 ['f/2.8', 'f/4', 'f/5.6'], 'f/4.5'), 1); }); test('meter verdict', () { final CameraState c = CameraState()..applyExposure({'ev': '-1.0'}); expect(meterVerdict(c), MeterVerdict.under); c.applyExposure({'ev': '+0.3'}); expect(meterVerdict(c), MeterVerdict.ok); }); }); group('Zoom', () { test('factor, steps and the digital range', () { final CameraState c = CameraState() ..applyTelemetry(settings: { '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: {...c.reportedSettings, 'zoomFocalLength': 600}); expect(c.zoom!.digital, isTrue); }); test('no zoom without a spec', () { expect((CameraState()..applyTelemetry(settings: {'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({'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: {'inserted': true}, settings: c.reportedSettings); expect(c.isPending('photoAspectRatio'), isTrue); reportSettings(m, {'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({'photoAspectRatio': 'RATIO_16_9'}); c.dropPending(['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: {'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 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: {'burstCount': 'BURST_COUNT_3', 'intervalSeconds': 5}); expect(ids(CameraMenuTab.photo, m), ['shootPhotoMode', 'photoAspectRatio', 'photoFileFormat']); m = cameraModel(phantom4, settings: {'shootPhotoMode': 'BURST', 'burstCount': 'BURST_COUNT_3'}); expect(ids(CameraMenuTab.photo, m), contains('burstCount')); m = cameraModel(phantom4, settings: {'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: {'videoCaption': true})), contains('videoCaption')); }); test('product predicates hide rows', () { expect(ids(CameraMenuTab.exposure, cameraModel(phantom4, settings: {'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: {'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().firstWhere((ChoiceRow r) => r.id == 'whiteBalancePreset'); expect(wb.options(m.profile, m.camera).map((CameraOption o) => o.value), ['AUTO', 'SUNNY', 'CLOUDY', 'CUSTOM']); final ChoiceRow size = CameraMenu.all.whereType().firstWhere((ChoiceRow r) => r.id == 'videoSize'); expect(size.options(m.profile, m.camera).map((CameraOption o) => o.label), ['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().firstWhere((ActionRow r) => r.id == 'formatSd'); final FlightModel m = cameraModel(phantom4, system: {'isRecording': true}); expect(format.blockedReason(m.camera), 'Stop shooting first'); expect(format.blockedReason(cameraModel(phantom4).camera), isNull); }); }); group('Camera HUD', () { Future 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 openMenu(WidgetTester tester, String tab) async { await tester.tap(find.byKey(const ValueKey('camera-menu'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.byKey(ValueKey('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('row-photoAspectRatio'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('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('row-photoFileFormat'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('opt-JPEG'))); await tester.pump(); reportSettings(m, {'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('row-videoFileFormat'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('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('camera-info'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('EV'), findsWidgets); await tester.tap(find.byKey(const ValueKey('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('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('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('zoom-in')), findsNothing); final FakeDjiService dji = await pump( tester, cameraModel(mavic2Zoom, settings: { 'zoomFocalLength': 240, 'zoomMin': 240, 'zoomMax': 960, 'zoomOpticalMax': 480, 'zoomStep': 10, }), ); expect(find.text('1.0×'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('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, {'meteringMode': 'SPOT'}); await tester.pump(); expect(find.text('Exposure'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('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: {'shootPhotoMode': 'INTERVAL', 'intervalSeconds': 5}, system: {'shootingInterval': true}); final FakeDjiService dji = await pump(tester, m); expect(find.text('INTERVAL 5s'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('shutter'))); await tester.pump(); expect(dji.calls, ['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('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('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(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('camera-menu'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); for (final String t in ['exposure', 'photo', 'video', 'other']) { await tester.tap(find.byKey(ValueKey('tab-$t'))); await tester.pump(); expect(tester.takeException(), isNull, reason: '$s $t'); } } }); }); }