From 589c6a48525cb01fa5fe563bcee506a35e70a415 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Wed, 2 Apr 2025 22:08:46 -0700 Subject: [PATCH 1/3] make vm init public; namespace some public vars --- .../DualCameraKit/DualCameraEnvironment.swift | 13 +++++++++++ ...wift => DualCameraMediaSaveStrategy.swift} | 14 ++++++------ .../Screen/DualCameraViewModel.swift | 22 +++++++------------ .../MediaSaveStrategyTests.swift | 10 ++++----- 4 files changed, 33 insertions(+), 26 deletions(-) rename Sources/DualCameraKit/FileSaving/{MediaSaveStrategy.swift => DualCameraMediaSaveStrategy.swift} (74%) diff --git a/Sources/DualCameraKit/DualCameraEnvironment.swift b/Sources/DualCameraKit/DualCameraEnvironment.swift index 620090d..340c5c3 100644 --- a/Sources/DualCameraKit/DualCameraEnvironment.swift +++ b/Sources/DualCameraKit/DualCameraEnvironment.swift @@ -4,8 +4,21 @@ import UIKit /// A value-type container for default services, /// allowing dependency injection with safe, testable defaults. /// Inspired by https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy +/// Trying this out for now. It still feels kinda weird and so +/// at some point it might be worth moving it to a more formal DI. public struct DualCameraEnvironment: Sendable { public var mediaLibraryService: MediaLibraryService = .live() + public var dualCameraController: DualCameraControlling = Self.getDefaultCameraController() + + + @MainActor + static func getDefaultCameraController() -> DualCameraControlling { +#if targetEnvironment(simulator) + return DualCameraMockController() +#else + return DualCameraController() +#endif + } } // swiftlint:disable:next identifier_name public let CurrentDualCameraEnvironment = DualCameraEnvironment() diff --git a/Sources/DualCameraKit/FileSaving/MediaSaveStrategy.swift b/Sources/DualCameraKit/FileSaving/DualCameraMediaSaveStrategy.swift similarity index 74% rename from Sources/DualCameraKit/FileSaving/MediaSaveStrategy.swift rename to Sources/DualCameraKit/FileSaving/DualCameraMediaSaveStrategy.swift index b8bcfa1..e0426d0 100644 --- a/Sources/DualCameraKit/FileSaving/MediaSaveStrategy.swift +++ b/Sources/DualCameraKit/FileSaving/DualCameraMediaSaveStrategy.swift @@ -6,7 +6,7 @@ import UIKit /// `saveToPhotoLibrary` involves permission grant to write to user's photo library. /// /// `custom` opts-out of saving to user's photo library; useful for when you don't need to save to library or want to handle permission flow in a custom way. -public enum MediaSaveStrategy: Sendable { +public enum DualCameraMediaSaveStrategy: Sendable { case saveToMediaLibrary(@Sendable (Media) async throws -> Void) case custom(@Sendable (Media) async throws -> Void) @@ -21,25 +21,25 @@ public enum MediaSaveStrategy: Sendable { } // MARK: - Type Aliases -public typealias PhotoSaveStrategy = MediaSaveStrategy -public typealias VideoSaveStrategy = MediaSaveStrategy +public typealias DualCameraPhotoSaveStrategy = DualCameraMediaSaveStrategy +public typealias DualCameraVideoSaveStrategy = DualCameraMediaSaveStrategy -public extension PhotoSaveStrategy { +public extension DualCameraPhotoSaveStrategy { /// Creates a strategy that saves photos to the user's media library using the provided service. /// - Parameter service: The service responsible for handling photo library operations. /// - Returns: A configured save strategy for photos. - static func photoLibrary(service: MediaLibraryService) -> PhotoSaveStrategy { + static func photoLibrary(service: MediaLibraryService) -> DualCameraPhotoSaveStrategy { .saveToMediaLibrary { [service] image in try await service.saveImage(image) } } } -public extension VideoSaveStrategy { +public extension DualCameraVideoSaveStrategy { /// Creates a strategy that saves videos to the user's media library using the provided service. /// - Parameter service: The service responsible for handling photo library operations. /// - Returns: A configured save strategy for photos. - static func videoLibrary(service: MediaLibraryService) -> VideoSaveStrategy { + static func videoLibrary(service: MediaLibraryService) -> DualCameraVideoSaveStrategy { .saveToMediaLibrary { [service] url in try await service.saveVideo(url) } diff --git a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift index 292f284..bb73d11 100644 --- a/Sources/DualCameraKit/Screen/DualCameraViewModel.swift +++ b/Sources/DualCameraKit/Screen/DualCameraViewModel.swift @@ -25,15 +25,15 @@ public final class DualCameraViewModel { let controller: DualCameraControlling private var recordingTimer: Timer? - private var videoSaveStrategy: VideoSaveStrategy - private var photoSaveStrategy: PhotoSaveStrategy + private var videoSaveStrategy: DualCameraVideoSaveStrategy + private var photoSaveStrategy: DualCameraPhotoSaveStrategy - init( - dualCameraController: DualCameraControlling, + public init( + dualCameraController: DualCameraControlling = CurrentDualCameraEnvironment.dualCameraController, layout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing), videoRecorderMode: DualCameraVideoRecordingMode = .cpuBased(.init(photoCaptureMode: .fullScreen)), - videoSaveStrategy: VideoSaveStrategy = .videoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), - photoSaveStrategy: PhotoSaveStrategy = .photoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService) + videoSaveStrategy: DualCameraVideoSaveStrategy = .videoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService), + photoSaveStrategy: DualCameraPhotoSaveStrategy = .photoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService) ) { self.controller = dualCameraController @@ -219,16 +219,10 @@ public final class DualCameraViewModel { extension DualCameraViewModel { public static func `default`() -> DualCameraViewModel { - #if targetEnvironment(simulator) - let dualCameraController = DualCameraMockController() - #else - let dualCameraController = DualCameraController() - #endif - return DualCameraViewModel( - dualCameraController: dualCameraController + dualCameraController: CurrentDualCameraEnvironment.dualCameraController ) - } + } } // MARK: - UI State Helpers diff --git a/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift b/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift index 5c534a2..468dc31 100644 --- a/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift +++ b/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift @@ -6,7 +6,7 @@ final class MediaSaveStrategyTests: XCTestCase { let imageBox = TestBox() let testImage = UIImage() - let strategy = PhotoSaveStrategy.custom { image in + let strategy = DualCameraPhotoSaveStrategy.custom { image in await imageBox.set(image) } @@ -20,7 +20,7 @@ final class MediaSaveStrategyTests: XCTestCase { let urlBox = TestBox() let testUrl = URL.mock() - let strategy = VideoSaveStrategy.custom { url in + let strategy = DualCameraVideoSaveStrategy.custom { url in await urlBox.set(url) } @@ -37,7 +37,7 @@ final class MediaSaveStrategyTests: XCTestCase { await urlBox.set(url) }) - let strategy = VideoSaveStrategy.saveToMediaLibrary(mock.saveVideo) + let strategy = DualCameraVideoSaveStrategy.saveToMediaLibrary(mock.saveVideo) try await strategy.save(testUrl) let savedUrl = await urlBox.get() XCTAssertEqual(savedUrl, testUrl) @@ -50,14 +50,14 @@ final class MediaSaveStrategyTests: XCTestCase { await imageBox.set(image) }) - let strategy = PhotoSaveStrategy.saveToMediaLibrary(mock.saveImage) + let strategy = DualCameraPhotoSaveStrategy.saveToMediaLibrary(mock.saveImage) try await strategy.save(testImage) let savedImage = await imageBox.get() XCTAssertEqual(savedImage, testImage) } func test_mediaLibraryStrategy_unimplementedFails() async { - let strategy = PhotoSaveStrategy.saveToMediaLibrary(MediaLibraryService.failing.saveImage) + let strategy = DualCameraPhotoSaveStrategy.saveToMediaLibrary(MediaLibraryService.failing.saveImage) await XCTAssertThrowsError(ofType: MediaLibraryError.self, try await strategy.save(UIImage()) ) } From 934edc754b0a0527c5e36223dc1d3ed2beb17da8 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Wed, 2 Apr 2025 22:49:10 -0700 Subject: [PATCH 2/3] add initial vm tests --- .../DualCameraKit/DualCameraController.swift | 8 +- .../DualCameraKit/DualCameraEnvironment.swift | 7 +- .../DualCameraViewModelTests.swift | 189 ++++++++++++++++++ .../MediaSaveStrategyTests.swift | 6 +- Tests/Helpers/TestBox.swift | 1 - Tests/Helpers/XCTAssertThrowsError.swift | 7 +- 6 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 Tests/DualCameraKitTests/DualCameraViewModelTests.swift diff --git a/Sources/DualCameraKit/DualCameraController.swift b/Sources/DualCameraKit/DualCameraController.swift index aa8bd14..82758d1 100644 --- a/Sources/DualCameraKit/DualCameraController.swift +++ b/Sources/DualCameraKit/DualCameraController.swift @@ -44,11 +44,11 @@ extension DualCameraControlling { // default implementations for `DualCameraPhotoCapturing` - proxy to implementation in `photoCapturer` public extension DualCameraControlling { - public func captureCurrentScreen(mode: DualCameraPhotoCaptureMode = .fullScreen) async throws -> UIImage { + func captureCurrentScreen(mode: DualCameraPhotoCaptureMode = .fullScreen) async throws -> UIImage { try await photoCapturer.captureCurrentScreen(mode: mode) } - public func captureRawPhotos() async throws -> (front: UIImage, back: UIImage) { + func captureRawPhotos() async throws -> (front: UIImage, back: UIImage) { let frontRenderer = getRenderer(for: .front) let backRenderer = getRenderer(for: .back) @@ -133,7 +133,7 @@ public final class DualCameraController: DualCameraControlling { let task = Task { for await buffer in stream { if Task.isCancelled { break } - await renderer.update(with: buffer.buffer) + renderer.update(with: buffer.buffer) } } streamTasks[source] = task @@ -213,7 +213,7 @@ public final class DualCameraMockController: DualCameraControlling { let task = Task { for await buffer in stream { if Task.isCancelled { break } - await renderer.update(with: buffer.buffer) + renderer.update(with: buffer.buffer) } } streamTasks[source] = task diff --git a/Sources/DualCameraKit/DualCameraEnvironment.swift b/Sources/DualCameraKit/DualCameraEnvironment.swift index 340c5c3..a37635d 100644 --- a/Sources/DualCameraKit/DualCameraEnvironment.swift +++ b/Sources/DualCameraKit/DualCameraEnvironment.swift @@ -8,10 +8,10 @@ import UIKit /// at some point it might be worth moving it to a more formal DI. public struct DualCameraEnvironment: Sendable { public var mediaLibraryService: MediaLibraryService = .live() - public var dualCameraController: DualCameraControlling = Self.getDefaultCameraController() + public var dualCameraController: DualCameraControlling = Self.getDefaultCameraController() - @MainActor + @MainActor static func getDefaultCameraController() -> DualCameraControlling { #if targetEnvironment(simulator) return DualCameraMockController() @@ -20,5 +20,6 @@ public struct DualCameraEnvironment: Sendable { #endif } } + // swiftlint:disable:next identifier_name -public let CurrentDualCameraEnvironment = DualCameraEnvironment() +public var CurrentDualCameraEnvironment = DualCameraEnvironment() diff --git a/Tests/DualCameraKitTests/DualCameraViewModelTests.swift b/Tests/DualCameraKitTests/DualCameraViewModelTests.swift new file mode 100644 index 0000000..1fe921b --- /dev/null +++ b/Tests/DualCameraKitTests/DualCameraViewModelTests.swift @@ -0,0 +1,189 @@ + +import XCTest +@testable import DualCameraKit + +@MainActor +final class DualCameraViewModelTests: XCTestCase { + + // MARK: - Initialization Tests + + func test_init_withDefaultParams_setsDefaultValues() async { + let mockController = MockDualCameraController() + CurrentDualCameraEnvironment.dualCameraController = mockController + + let viewModel = DualCameraViewModel() + + XCTAssertEqual(viewModel.viewState, .loading) + XCTAssertEqual(viewModel.configuration.layout, .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing)) + XCTAssertEqual(viewModel.videoRecorderType, .cpuBased(.init(photoCaptureMode: .fullScreen))) + XCTAssertIdentical(viewModel.controller as? MockDualCameraController, mockController) + } + + func test_init_withCustomParams_setsCustomValues() { + let mockController = MockDualCameraController() + let customLayout: DualCameraLayout = .sideBySide + let customRecorderMode: DualCameraVideoRecordingMode = .replayKit() + + let viewModel = DualCameraViewModel( + dualCameraController: mockController, + layout: customLayout, + videoRecorderMode: customRecorderMode + ) + + // Then + XCTAssertEqual(viewModel.configuration.layout, customLayout) + XCTAssertEqual(viewModel.videoRecorderType, customRecorderMode) + } + + // MARK: - Lifecycle Tests + + func test_onAppear_startsSession() async { + let mockController = MockDualCameraController() + let viewModel = DualCameraViewModel(dualCameraController: mockController) + + viewModel.onAppear(containerSize: CGSize(width: 390, height: 844)) + + // Need to wait for the async Task to complete + await Task.yield() + + // Then + XCTAssertTrue(mockController.sessionStarted) + XCTAssertEqual(viewModel.configuration.containerSize, CGSize(width: 390, height: 844)) + XCTAssertEqual(viewModel.viewState, .ready) + } + + func test_onAppear_handlesError() async { + let mockController = MockDualCameraController() + mockController.shouldFailStartSession = true + let viewModel = DualCameraViewModel(dualCameraController: mockController) + + viewModel.onAppear(containerSize: CGSize(width: 390, height: 844)) + + // Need to wait for the async Task to complete + await Task.yield() + + XCTAssertEqual(viewModel.viewState, .error(DualCameraError.unknownError)) + XCTAssertNotNil(viewModel.alert) + } + + func test_onDisappear_stopsSession() async { + let mockController = MockDualCameraController() + let viewModel = DualCameraViewModel(dualCameraController: mockController) + + viewModel.onAppear(containerSize: CGSize(width: 390, height: 844)) + await Task.yield() + + viewModel.onDisappear() + + XCTAssertTrue(mockController.sessionStopped) + } + +} + +// MARK: - Test Helpers + +class MockDualCameraController: DualCameraControlling { + var sessionStarted = false + var sessionStopped = false + var videoRecordingStarted = false + var videoRecordingStopped = false + var shouldFailStartSession = false + var shouldFailCaptureScreen = false + + var mockCapturedImage = UIImage() + var mockVideoOutputURL = URL(string: "file:///tmp/test.mp4")! + + var frontCameraStream: AsyncStream { + AsyncStream { continuation in + continuation.finish() + } + } + + var backCameraStream: AsyncStream { + AsyncStream { continuation in + continuation.finish() + } + } + + func getRenderer(for source: DualCameraSource) -> CameraRenderer { + MockCameraRenderer() + } + + func startSession() async throws { + if shouldFailStartSession { + throw DualCameraError.unknownError + } + sessionStarted = true + } + + func stopSession() { + sessionStopped = true + } + + var photoCapturer: any DualCameraPhotoCapturing { + MockPhotoCapturer(mockImage: mockCapturedImage, shouldFail: shouldFailCaptureScreen) + } + + func captureRawPhotos() async throws -> (front: UIImage, back: UIImage) { + if shouldFailCaptureScreen { + throw DualCameraError.captureFailure(.noFrameAvailable) + } + return (front: mockCapturedImage, back: mockCapturedImage) + } + + func captureCurrentScreen(mode: DualCameraPhotoCaptureMode) async throws -> UIImage { + if shouldFailCaptureScreen { + throw DualCameraError.captureFailure(.noFrameAvailable) + } + return mockCapturedImage + } + + var videoRecorder: (any DualCameraVideoRecording)? = nil + + func setVideoRecorder(_ recorder: any DualCameraVideoRecording) async throws { + videoRecorder = recorder + } + + func startVideoRecording(mode: DualCameraVideoRecordingMode) async throws { + videoRecordingStarted = true + } + + func stopVideoRecording() async throws -> URL { + videoRecordingStopped = true + return mockVideoOutputURL + } +} + +class MockCameraRenderer: CameraRenderer { + func update(with buffer: CVPixelBuffer) { + // No-op for tests + } + + func captureCurrentFrame() async throws -> UIImage { + return UIImage() + } +} + +class MockPhotoCapturer: DualCameraPhotoCapturing { + private let mockImage: UIImage + private let shouldFail: Bool + + init(mockImage: UIImage, shouldFail: Bool = false) { + self.mockImage = mockImage + self.shouldFail = shouldFail + } + + func captureRawPhotos(frontRenderer: CameraRenderer, backRenderer: CameraRenderer) async throws -> (front: UIImage, back: UIImage) { + if shouldFail { + throw DualCameraError.captureFailure(.noFrameAvailable) + } + return (front: mockImage, back: mockImage) + } + + func captureCurrentScreen(mode: DualCameraPhotoCaptureMode) async throws -> UIImage { + if shouldFail { + throw DualCameraError.captureFailure(.noFrameAvailable) + } + return mockImage + } +} diff --git a/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift b/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift index 468dc31..6adcd43 100644 --- a/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift +++ b/Tests/DualCameraKitTests/MediaSaveStrategyTests.swift @@ -11,7 +11,7 @@ final class MediaSaveStrategyTests: XCTestCase { } try await strategy.save(testImage) - + let savedImage = await imageBox.get() XCTAssertEqual(savedImage, testImage) } @@ -33,7 +33,7 @@ final class MediaSaveStrategyTests: XCTestCase { func test_mediaLibraryVideoStrategy() async throws { let urlBox = TestBox() let testUrl = URL.mock() - let mock = MediaLibraryService.test(saveVideo: { url in + let mock = MediaLibraryService.test(saveVideo: { url in await urlBox.set(url) }) @@ -46,7 +46,7 @@ final class MediaSaveStrategyTests: XCTestCase { func test_mediaLibraryPhotoStrategy() async throws { let imageBox = TestBox() let testImage = UIImage() - let mock = MediaLibraryService.test(saveImage: { image in + let mock = MediaLibraryService.test(saveImage: { image in await imageBox.set(image) }) diff --git a/Tests/Helpers/TestBox.swift b/Tests/Helpers/TestBox.swift index 72c5485..46d8a87 100644 --- a/Tests/Helpers/TestBox.swift +++ b/Tests/Helpers/TestBox.swift @@ -1,4 +1,3 @@ - actor TestBox { private(set) var value: T? diff --git a/Tests/Helpers/XCTAssertThrowsError.swift b/Tests/Helpers/XCTAssertThrowsError.swift index 70ab510..2fb13a1 100644 --- a/Tests/Helpers/XCTAssertThrowsError.swift +++ b/Tests/Helpers/XCTAssertThrowsError.swift @@ -1,7 +1,12 @@ import XCTest @discardableResult -public func XCTAssertThrowsError(ofType expectedType: T.Type, _ expression: @autoclosure () async throws -> Void, file: StaticString = #file, line: UInt = #line) async -> T? { +public func XCTAssertThrowsError( + ofType expectedType: T.Type, + _ expression: @autoclosure () async throws -> Void, + file: StaticString = #file, + line: UInt = #line +) async -> T? { do { try await expression() XCTFail("Expected error of type \(expectedType)", file: file, line: line) From 78f4ba30003abe2cf831ab8037738556a266b5d5 Mon Sep 17 00:00:00 2001 From: Liam Ronan Date: Wed, 2 Apr 2025 22:53:04 -0700 Subject: [PATCH 3/3] Update DualCameraViewModelTests.swift --- .../DualCameraViewModelTests.swift | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/Tests/DualCameraKitTests/DualCameraViewModelTests.swift b/Tests/DualCameraKitTests/DualCameraViewModelTests.swift index 1fe921b..ee517d3 100644 --- a/Tests/DualCameraKitTests/DualCameraViewModelTests.swift +++ b/Tests/DualCameraKitTests/DualCameraViewModelTests.swift @@ -1,4 +1,3 @@ - import XCTest @testable import DualCameraKit @@ -6,7 +5,7 @@ import XCTest final class DualCameraViewModelTests: XCTestCase { // MARK: - Initialization Tests - + func test_init_withDefaultParams_setsDefaultValues() async { let mockController = MockDualCameraController() CurrentDualCameraEnvironment.dualCameraController = mockController @@ -23,61 +22,60 @@ final class DualCameraViewModelTests: XCTestCase { let mockController = MockDualCameraController() let customLayout: DualCameraLayout = .sideBySide let customRecorderMode: DualCameraVideoRecordingMode = .replayKit() - + let viewModel = DualCameraViewModel( dualCameraController: mockController, layout: customLayout, videoRecorderMode: customRecorderMode ) - - // Then + + XCTAssertEqual(viewModel.configuration.layout, customLayout) XCTAssertEqual(viewModel.videoRecorderType, customRecorderMode) } // MARK: - Lifecycle Tests - + func test_onAppear_startsSession() async { let mockController = MockDualCameraController() let viewModel = DualCameraViewModel(dualCameraController: mockController) - + viewModel.onAppear(containerSize: CGSize(width: 390, height: 844)) - + // Need to wait for the async Task to complete await Task.yield() - - // Then + XCTAssertTrue(mockController.sessionStarted) XCTAssertEqual(viewModel.configuration.containerSize, CGSize(width: 390, height: 844)) XCTAssertEqual(viewModel.viewState, .ready) } - + func test_onAppear_handlesError() async { let mockController = MockDualCameraController() mockController.shouldFailStartSession = true let viewModel = DualCameraViewModel(dualCameraController: mockController) - + viewModel.onAppear(containerSize: CGSize(width: 390, height: 844)) - + // Need to wait for the async Task to complete await Task.yield() - + XCTAssertEqual(viewModel.viewState, .error(DualCameraError.unknownError)) XCTAssertNotNil(viewModel.alert) } - + func test_onDisappear_stopsSession() async { let mockController = MockDualCameraController() let viewModel = DualCameraViewModel(dualCameraController: mockController) - + viewModel.onAppear(containerSize: CGSize(width: 390, height: 844)) await Task.yield() - + viewModel.onDisappear() - + XCTAssertTrue(mockController.sessionStopped) } - + } // MARK: - Test Helpers @@ -89,7 +87,7 @@ class MockDualCameraController: DualCameraControlling { var videoRecordingStopped = false var shouldFailStartSession = false var shouldFailCaptureScreen = false - + var mockCapturedImage = UIImage() var mockVideoOutputURL = URL(string: "file:///tmp/test.mp4")! @@ -98,24 +96,24 @@ class MockDualCameraController: DualCameraControlling { continuation.finish() } } - + var backCameraStream: AsyncStream { AsyncStream { continuation in continuation.finish() } } - + func getRenderer(for source: DualCameraSource) -> CameraRenderer { MockCameraRenderer() } - + func startSession() async throws { if shouldFailStartSession { throw DualCameraError.unknownError } sessionStarted = true } - + func stopSession() { sessionStopped = true } @@ -138,7 +136,7 @@ class MockDualCameraController: DualCameraControlling { return mockCapturedImage } - var videoRecorder: (any DualCameraVideoRecording)? = nil + var videoRecorder: (any DualCameraVideoRecording)? func setVideoRecorder(_ recorder: any DualCameraVideoRecording) async throws { videoRecorder = recorder @@ -173,7 +171,9 @@ class MockPhotoCapturer: DualCameraPhotoCapturing { self.shouldFail = shouldFail } - func captureRawPhotos(frontRenderer: CameraRenderer, backRenderer: CameraRenderer) async throws -> (front: UIImage, back: UIImage) { + func captureRawPhotos( + frontRenderer: CameraRenderer, backRenderer: CameraRenderer + ) async throws -> (front: UIImage, back: UIImage) { if shouldFail { throw DualCameraError.captureFailure(.noFrameAvailable) }