camera: Dart facade + backend + channel + preview + tests
Phase 1c+1d of the ux.camera plan (see ~/banlu/plans/ux_camera.md).
lib/src/camera/camera.dart — UxCameraController (ValueNotifier),
UxCameraValue, UxCameraDescription,
enums, UxCameraException,
uxAvailableCameras().
lib/src/camera/camera_backend.dart — abstract UxCameraBackend +
UxCameraCreateResult + sealed
UxCameraEvent variants.
lib/src/camera/camera_channel.dart — MethodChannelUxCameraBackend over
ux/camera + ux/camera/events. Per-
handle event demux. Maps
PlatformException → UxCameraException.
lib/src/camera/camera_preview.dart — UxCameraPreview: Texture-backed,
Hero-flightable preview widget.
lib/src/testing/fake_camera.dart — FakeUxCameraBackend with per-method
call lists + emitXxx event injection.
Exported from package:ux/testing.dart.
test/camera/camera_controller_test — 16 tests covering init/dispose,
orientation events, takePicture
(explicit + UxSensor fallback),
startVideoRecording / stop,
flip, flash, lock/unlock,
multi-instance, error propagation.
test/camera/camera_channel_test — 10 tests pinning the wire format
for every method + PlatformException
mapping.
Orientation snapshot for capture is computed Dart-side and passed in as an
explicit arg to takePicture / startVideoRecording (default falls back to
UxSensor.orientation at call time). Native never queries UIDevice itself
for the snapshot — Dart-side fakes drive orientation deterministically.
Native plugin code lands in Phase 2+; today every channel call throws
MissingPluginException at runtime, which is fine — the controller is only
mounted from the camera page once Phase 5 cuts over. The test backend
already exercises the full controller surface.
This commit is contained in:
170
test/camera/camera_channel_test.dart
Normal file
170
test/camera/camera_channel_test.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('MethodChannelUxCameraBackend — arg/return parsing', () {
|
||||
const channel = MethodChannel('ux/camera');
|
||||
|
||||
late MethodChannelUxCameraBackend backend;
|
||||
late List<MethodCall> calls;
|
||||
|
||||
setUp(() {
|
||||
backend = MethodChannelUxCameraBackend();
|
||||
calls = [];
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
void handle(Object? Function(MethodCall) reply) {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return reply(call);
|
||||
});
|
||||
}
|
||||
|
||||
test('availableCameras decodes the platform list', () async {
|
||||
handle((_) => [
|
||||
{'id': 'a', 'lens': 'front', 'sensorOrientation': 270},
|
||||
{'id': 'b', 'lens': 'back', 'sensorOrientation': 90},
|
||||
]);
|
||||
|
||||
final result = await backend.availableCameras();
|
||||
|
||||
expect(calls.single.method, 'availableCameras');
|
||||
expect(result, [
|
||||
const UxCameraDescription(
|
||||
id: 'a', lens: UxCameraLens.front, sensorOrientation: 270),
|
||||
const UxCameraDescription(
|
||||
id: 'b', lens: UxCameraLens.back, sensorOrientation: 90),
|
||||
]);
|
||||
});
|
||||
|
||||
test('create sends cameraId/enableAudio/preset and decodes the tuple',
|
||||
() async {
|
||||
handle((_) => {
|
||||
'handle': 42,
|
||||
'textureId': 7,
|
||||
'previewSize': {'width': 1920, 'height': 1080},
|
||||
});
|
||||
|
||||
final result = await backend.create(
|
||||
cameraId: 'cam-1',
|
||||
enableAudio: true,
|
||||
preset: UxResolutionPreset.high,
|
||||
);
|
||||
|
||||
expect(calls.single.method, 'create');
|
||||
expect(calls.single.arguments, {
|
||||
'cameraId': 'cam-1',
|
||||
'enableAudio': true,
|
||||
'preset': 'high',
|
||||
});
|
||||
expect(result.handle, 42);
|
||||
expect(result.textureId, 7);
|
||||
expect(result.previewSize, const Size(1920, 1080));
|
||||
});
|
||||
|
||||
test('initialize / dispose send only the handle', () async {
|
||||
handle((_) => null);
|
||||
|
||||
await backend.initialize(11);
|
||||
await backend.disposeInstance(11);
|
||||
|
||||
expect(calls.map((c) => c.method).toList(), ['initialize', 'dispose']);
|
||||
expect(calls.every((c) => (c.arguments as Map)['handle'] == 11), isTrue);
|
||||
});
|
||||
|
||||
test('setDescription returns the new previewSize', () async {
|
||||
handle((_) => {
|
||||
'previewSize': {'width': 1280, 'height': 720},
|
||||
});
|
||||
|
||||
final size = await backend.setDescription(7, 'next');
|
||||
|
||||
expect(calls.single.method, 'setDescription');
|
||||
expect(calls.single.arguments, {'handle': 7, 'cameraId': 'next'});
|
||||
expect(size, const Size(1280, 720));
|
||||
});
|
||||
|
||||
test('setFlashMode encodes the enum', () async {
|
||||
handle((_) => null);
|
||||
|
||||
await backend.setFlashMode(3, UxFlashMode.always);
|
||||
await backend.setFlashMode(3, UxFlashMode.off);
|
||||
|
||||
expect(calls.map((c) => (c.arguments as Map)['mode']).toList(),
|
||||
['always', 'off']);
|
||||
});
|
||||
|
||||
test('lock/unlockCaptureOrientation encode the orientation string',
|
||||
() async {
|
||||
handle((_) => null);
|
||||
|
||||
await backend.lockCaptureOrientation(5, DeviceOrientation.portraitUp);
|
||||
await backend.lockCaptureOrientation(5, DeviceOrientation.landscapeLeft);
|
||||
await backend.lockCaptureOrientation(5, DeviceOrientation.landscapeRight);
|
||||
await backend.lockCaptureOrientation(5, DeviceOrientation.portraitDown);
|
||||
await backend.unlockCaptureOrientation(5);
|
||||
|
||||
expect(
|
||||
calls.take(4).map((c) => (c.arguments as Map)['orientation']).toList(),
|
||||
['portraitUp', 'landscapeLeft', 'landscapeRight', 'portraitDown'],
|
||||
);
|
||||
expect(calls.last.method, 'unlockCaptureOrientation');
|
||||
});
|
||||
|
||||
test('takePicture sends snapshotOrientation and decodes the file path',
|
||||
() async {
|
||||
handle((_) => {'path': '/tmp/a.jpg'});
|
||||
|
||||
final file = await backend.takePicture(
|
||||
9, DeviceOrientation.landscapeLeft);
|
||||
|
||||
expect(calls.single.method, 'takePicture');
|
||||
expect(calls.single.arguments,
|
||||
{'handle': 9, 'snapshotOrientation': 'landscapeLeft'});
|
||||
expect(file.path, '/tmp/a.jpg');
|
||||
});
|
||||
|
||||
test('startVideoRecording sends snapshotOrientation', () async {
|
||||
handle((_) => null);
|
||||
|
||||
await backend.startVideoRecording(9, DeviceOrientation.landscapeRight);
|
||||
|
||||
expect(calls.single.method, 'startVideoRecording');
|
||||
expect(calls.single.arguments,
|
||||
{'handle': 9, 'snapshotOrientation': 'landscapeRight'});
|
||||
});
|
||||
|
||||
test('stopVideoRecording decodes the file path', () async {
|
||||
handle((_) => {'path': '/tmp/v.mp4'});
|
||||
|
||||
final file = await backend.stopVideoRecording(9);
|
||||
|
||||
expect(calls.single.method, 'stopVideoRecording');
|
||||
expect(file.path, '/tmp/v.mp4');
|
||||
});
|
||||
|
||||
test('PlatformException maps to UxCameraException carrying code/message',
|
||||
() async {
|
||||
handle((_) => throw PlatformException(
|
||||
code: 'device_busy',
|
||||
message: 'front camera in use',
|
||||
));
|
||||
|
||||
await expectLater(
|
||||
backend.initialize(1),
|
||||
throwsA(isA<UxCameraException>()
|
||||
.having((e) => e.code, 'code', 'device_busy')
|
||||
.having((e) => e.description, 'description', 'front camera in use')),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
227
test/camera/camera_controller_test.dart
Normal file
227
test/camera/camera_controller_test.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/testing.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
const _front = UxCameraDescription(
|
||||
id: 'front',
|
||||
lens: UxCameraLens.front,
|
||||
sensorOrientation: 270,
|
||||
);
|
||||
const _back = UxCameraDescription(
|
||||
id: 'back',
|
||||
lens: UxCameraLens.back,
|
||||
sensorOrientation: 90,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late FakeUxCameraBackend fake;
|
||||
|
||||
setUp(() {
|
||||
fake = FakeUxCameraBackend(cameras: const [_front, _back]);
|
||||
UxCameraBackend.instance = fake;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
UxCameraBackend.instance = MethodChannelUxCameraBackend();
|
||||
});
|
||||
|
||||
test('uxAvailableCameras dispatches through the backend', () async {
|
||||
final cameras = await uxAvailableCameras();
|
||||
expect(cameras, [_front, _back]);
|
||||
expect(fake.availableCamerasCalls, 1);
|
||||
});
|
||||
|
||||
test('initialize creates the native instance, subscribes to events, '
|
||||
'and reports previewSize / isInitialized', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
|
||||
expect(ctrl.value.isInitialized, isFalse);
|
||||
expect(ctrl.value.previewSize, isNull);
|
||||
|
||||
await ctrl.initialize();
|
||||
|
||||
expect(fake.createCalls.single,
|
||||
(cameraId: 'front', enableAudio: false, preset: UxResolutionPreset.high));
|
||||
expect(fake.initializeCalls.single, 1);
|
||||
expect(ctrl.textureId, 100);
|
||||
expect(ctrl.value.isInitialized, isTrue);
|
||||
expect(ctrl.value.previewSize, const Size(1920, 1080));
|
||||
});
|
||||
|
||||
test('deviceOrientationChanged events update value.deviceOrientation', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
expect(ctrl.value.deviceOrientation, DeviceOrientation.portraitUp);
|
||||
|
||||
fake.emitOrientationChanged(1, DeviceOrientation.landscapeLeft);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(ctrl.value.deviceOrientation, DeviceOrientation.landscapeLeft);
|
||||
|
||||
fake.emitOrientationChanged(1, DeviceOrientation.portraitDown);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(ctrl.value.deviceOrientation, DeviceOrientation.portraitDown);
|
||||
});
|
||||
|
||||
test('sessionError events surface as value.errorDescription', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
fake.emitSessionError(1, 'recorder_failed', 'writer ended');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(ctrl.value.errorDescription, 'writer ended');
|
||||
expect(ctrl.value.hasError, isTrue);
|
||||
});
|
||||
|
||||
test('takePicture passes the explicit captureOrientation through', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
final file = await ctrl.takePicture(captureOrientation: DeviceOrientation.landscapeRight);
|
||||
|
||||
expect(file.path, '/tmp/fake_picture.jpg');
|
||||
expect(fake.takePictureCalls.single,
|
||||
(handle: 1, snapshotOrientation: DeviceOrientation.landscapeRight));
|
||||
});
|
||||
|
||||
test('takePicture without captureOrientation falls back to UxSensor', () async {
|
||||
// UxSensor.orientation returns portraitUp under flutter_test (no native sensor).
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.takePicture();
|
||||
|
||||
expect(fake.takePictureCalls.single,
|
||||
(handle: 1, snapshotOrientation: DeviceOrientation.portraitUp));
|
||||
});
|
||||
|
||||
test('startVideoRecording flips isRecordingVideo and passes orientation', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.startVideoRecording(captureOrientation: DeviceOrientation.landscapeLeft);
|
||||
|
||||
expect(ctrl.value.isRecordingVideo, isTrue);
|
||||
expect(fake.startVideoRecordingCalls.single,
|
||||
(handle: 1, snapshotOrientation: DeviceOrientation.landscapeLeft));
|
||||
});
|
||||
|
||||
test('stopVideoRecording resets isRecordingVideo and returns the file', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
await ctrl.startVideoRecording(captureOrientation: DeviceOrientation.landscapeLeft);
|
||||
|
||||
final file = await ctrl.stopVideoRecording();
|
||||
|
||||
expect(ctrl.value.isRecordingVideo, isFalse);
|
||||
expect(file.path, '/tmp/fake_video.mp4');
|
||||
expect(fake.stopVideoRecordingCalls.single, 1);
|
||||
});
|
||||
|
||||
test('setDescription updates value.description and previewSize', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
fake.previewSize = const Size(1280, 720);
|
||||
await ctrl.setDescription(_back);
|
||||
|
||||
expect(ctrl.value.description, _back);
|
||||
expect(ctrl.value.previewSize, const Size(1280, 720));
|
||||
expect(fake.setDescriptionCalls.single, (handle: 1, cameraId: 'back'));
|
||||
});
|
||||
|
||||
test('setFlashMode forwards to the backend', () async {
|
||||
final ctrl = UxCameraController(_back, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.setFlashMode(UxFlashMode.always);
|
||||
|
||||
expect(fake.setFlashModeCalls.single, (handle: 1, mode: UxFlashMode.always));
|
||||
});
|
||||
|
||||
test('lockCaptureOrientation / unlockCaptureOrientation forward to backend',
|
||||
() async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.lockCaptureOrientation(DeviceOrientation.portraitUp);
|
||||
expect(fake.lockCaptureOrientationCalls.single,
|
||||
(handle: 1, orientation: DeviceOrientation.portraitUp));
|
||||
|
||||
await ctrl.unlockCaptureOrientation();
|
||||
expect(fake.unlockCaptureOrientationCalls.single, 1);
|
||||
});
|
||||
|
||||
test('dispose tears down the native instance and is idempotent', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.dispose();
|
||||
expect(fake.disposeCalls.single, 1);
|
||||
|
||||
// Second dispose is a no-op (does not call backend again, does not throw).
|
||||
await ctrl.dispose();
|
||||
expect(fake.disposeCalls, [1]);
|
||||
});
|
||||
|
||||
test('calls against a disposed controller throw UxCameraException', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
await ctrl.initialize();
|
||||
await ctrl.dispose();
|
||||
|
||||
expect(() => ctrl.takePicture(captureOrientation: DeviceOrientation.portraitUp),
|
||||
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'disposed')));
|
||||
});
|
||||
|
||||
test('calls before initialize throw UxCameraException("not_initialized")',
|
||||
() async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
|
||||
expect(() => ctrl.setFlashMode(UxFlashMode.off),
|
||||
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'not_initialized')));
|
||||
});
|
||||
|
||||
test('multi-instance: two controllers get distinct handles + textures',
|
||||
() async {
|
||||
final a = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
final b = UxCameraController(_back, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(a.dispose);
|
||||
addTearDown(b.dispose);
|
||||
|
||||
await a.initialize();
|
||||
await b.initialize();
|
||||
|
||||
expect(a.textureId, isNot(b.textureId));
|
||||
expect(fake.createCalls.map((c) => c.cameraId), ['front', 'back']);
|
||||
|
||||
// Events route per-handle: orientation update to A doesn't touch B.
|
||||
fake.emitOrientationChanged(1, DeviceOrientation.landscapeLeft);
|
||||
fake.emitOrientationChanged(2, DeviceOrientation.landscapeRight);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(a.value.deviceOrientation, DeviceOrientation.landscapeLeft);
|
||||
expect(b.value.deviceOrientation, DeviceOrientation.landscapeRight);
|
||||
});
|
||||
|
||||
test('initialize propagates UxCameraException("permission_denied")', () async {
|
||||
fake.createError = const UxCameraException('permission_denied', 'camera');
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
|
||||
expect(() => ctrl.initialize(),
|
||||
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'permission_denied')));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user