import SwiftUI import Testing @testable import Recording @Suite("Recording view model") struct RecordingViewModelTests { typealias Model = RecordingView.Model // MARK: Initial state @MainActor @Suite("Initial state") struct InitialState { @Test func `initial state is not recording with a zeroed timer`() { let model = Model() #expect(model.state == .notRecording) #expect(model.elapsedSeconds == 0) #expect(model.textTimer == "00:00") #expect(model.textTranscription == nil) } } // MARK: Button presses @MainActor @Suite("Button presses") struct ButtonPresses { @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [Model.State.recording, .paused, .recording, .processing] )) func `pressed main transitions to the expected state`( from initial: Model.State, to expected: Model.State ) { let model = Model() model.state = initial model.pressedMain() #expect(model.state == expected) } @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [Model.State.notRecording, .recording, .processing, .processing] )) func `pressed send transitions to the expected state`( from initial: Model.State, to expected: Model.State ) { let model = Model() model.state = initial model.pressedSend() #expect(model.state == expected) } } // MARK: Computed properties @MainActor @Suite("Computed properties") struct ComputedProperties { @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [ImageResource.Icon.record, .Icon.pause, .Icon.record, .Icon.record] )) func `main icon matches the state`( for state: Model.State, expected: ImageResource ) { let model = Model() model.state = state #expect(model.iconMain == expected) } @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [nil, nil, ImageResource.Icon.send, nil] )) func `send icon matches the state`( for state: Model.State, expected: ImageResource? ) { let model = Model() model.state = state #expect(model.iconSend == expected) } @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [false, false, false, true] )) func `is processing only while processing`( for state: Model.State, expected: Bool ) { let model = Model() model.state = state #expect(model.isProcessing == expected) } @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [false, false, false, true] )) func `main button is disabled only while processing`( for state: Model.State, expected: Bool ) { let model = Model() model.state = state #expect(model.shouldDisableMain == expected) } @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [false, false, true, true] )) func `send button is visible while paused or processing`( for state: Model.State, expected: Bool ) { let model = Model() model.state = state #expect(model.shouldShowSend == expected) } @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [false, true, true, true] )) func `timer is visible unless not recording`( for state: Model.State, expected: Bool ) { let model = Model() model.state = state #expect(model.shouldShowTimer == expected) } } // MARK: Timer @MainActor @Suite("Timer") struct Timer { @Test func `ticks while recording`() async throws { let model = Model() model.state = .recording model.updatedState(shouldRestartTimer: true) try await Task.sleep(for: .seconds(1.2)) #expect(model.elapsedSeconds >= 1) #expect(model.textTimer == "00:01") model.state = .notRecording model.updatedState(shouldRestartTimer: false) } @Test func `stops while paused`() async throws { let model = Model() model.state = .recording model.updatedState(shouldRestartTimer: true) try await Task.sleep(for: .seconds(1.2)) model.state = .paused model.updatedState(shouldRestartTimer: false) let secondsWhenPaused = model.elapsedSeconds try await Task.sleep(for: .seconds(1.2)) #expect(secondsWhenPaused >= 1) #expect(model.elapsedSeconds == secondsWhenPaused) } @Test func `keeps elapsed seconds when resumed`() async throws { let model = Model() model.state = .recording model.updatedState(shouldRestartTimer: true) try await Task.sleep(for: .seconds(1.2)) model.state = .paused model.updatedState(shouldRestartTimer: false) let secondsWhenPaused = model.elapsedSeconds model.state = .recording model.updatedState(shouldRestartTimer: false) #expect(model.elapsedSeconds == secondsWhenPaused) model.state = .notRecording model.updatedState(shouldRestartTimer: false) } @Test func `restarts for a new recording`() async throws { let model = Model() model.state = .recording model.updatedState(shouldRestartTimer: true) try await Task.sleep(for: .seconds(1.2)) model.state = .paused model.updatedState(shouldRestartTimer: false) model.state = .recording model.updatedState(shouldRestartTimer: true) #expect(model.elapsedSeconds == 0) model.state = .notRecording model.updatedState(shouldRestartTimer: false) } } // MARK: Recorder @MainActor @Suite("Recorder") struct Recorder { @Test func `starts the capture for a new recording`() async throws { let recorder = RecordingServiceMock() let model = Model(recorder: recorder) model.state = .recording model.updatedState(shouldRestartTimer: true) try await Task.sleep(for: .seconds(0.1)) #expect(recorder.countStart == 1) #expect(recorder.countResume == 0) model.state = .notRecording model.updatedState(shouldRestartTimer: false) } @Test func `pauses the capture while paused`() async throws { let recorder = RecordingServiceMock() let model = Model(recorder: recorder) model.state = .recording model.updatedState(shouldRestartTimer: true) model.state = .paused model.updatedState(shouldRestartTimer: false) try await Task.sleep(for: .seconds(0.1)) #expect(recorder.countPause == 1) } @Test func `resumes the capture after a pause`() async throws { let recorder = RecordingServiceMock() let model = Model(recorder: recorder) model.state = .recording model.updatedState(shouldRestartTimer: true) model.state = .paused model.updatedState(shouldRestartTimer: false) model.state = .recording model.updatedState(shouldRestartTimer: false) try await Task.sleep(for: .seconds(0.1)) #expect(recorder.countStart == 1) #expect(recorder.countResume == 1) model.state = .notRecording model.updatedState(shouldRestartTimer: false) } @Test func `stops the capture when the input is sent`() async throws { let recorder = RecordingServiceMock() let model = Model( recorder: recorder, transcriber: TranscribingServiceMock() ) model.state = .processing model.updatedState(shouldRestartTimer: false) try await Task.sleep(for: .seconds(0.5)) #expect(recorder.countStop == 1) } @Test func `falls back to not recording when the capture fails`() async throws { let recorder = RecordingServiceMock() recorder.error = ErrorMock() let model = Model(recorder: recorder) model.state = .recording model.updatedState(shouldRestartTimer: true) try await Task.sleep(for: .seconds(0.1)) #expect(model.state == .notRecording) } } // MARK: Processing @MainActor @Suite("Processing") struct Processing { @Test func `returns to not recording with a reset timer and a transcription`() async throws { let model = Model( transcriber: TranscribingServiceMock() ) model.state = .recording model.updatedState(shouldRestartTimer: true) try await Task.sleep(for: .seconds(1.2)) model.state = .paused model.updatedState(shouldRestartTimer: false) #expect(model.elapsedSeconds >= 1) model.state = .processing model.updatedState(shouldRestartTimer: false) try await Task.sleep(for: .seconds(0.5)) #expect(model.state == .notRecording) #expect(model.elapsedSeconds == 0) #expect(model.textTranscription != nil) } @Test func `clears the transcription when the transcriber fails`() async throws { let transcriber = TranscribingServiceMock() transcriber.error = ErrorMock() let model = Model( transcriber: transcriber ) model.state = .processing model.updatedState(shouldRestartTimer: false) try await Task.sleep(for: .seconds(0.5)) #expect(model.state == .notRecording) #expect(model.elapsedSeconds == 0) #expect(model.textTranscription == nil) } @Test func `clears the transcription when a new recording starts`() async throws { let model = Model( transcriber: TranscribingServiceMock() ) model.state = .processing model.updatedState(shouldRestartTimer: false) try await Task.sleep(for: .seconds(0.5)) #expect(model.textTranscription != nil) model.state = .recording model.updatedState(shouldRestartTimer: true) #expect(model.textTranscription == nil) model.state = .notRecording model.updatedState(shouldRestartTimer: false) } } } // MARK: - Mocks /// A recording service that counts its invocations and can be configured to fail. @MainActor private final class RecordingServiceMock: RecordingService { /// The error the service throws from every method, or `nil` when it should succeed. var error: Error? /// The number of times ``start()`` has been called. private(set) var countStart = 0 /// The number of times ``pause()`` has been called. private(set) var countPause = 0 /// The number of times ``resume()`` has been called. private(set) var countResume = 0 /// The number of times ``stop()`` has been called. private(set) var countStop = 0 func start() async throws { countStart += 1 try throwConfiguredError() } func pause() async throws { countPause += 1 try throwConfiguredError() } func resume() async throws { countResume += 1 try throwConfiguredError() } func stop() async throws -> Data { countStop += 1 try throwConfiguredError() return .init() } private func throwConfiguredError() throws { if let error { throw error } } } /// A transcribing service with a configurable delay, output, and failure. @MainActor private final class TranscribingServiceMock: TranscribingService { /// The duration of the simulated transcription work. var delay: Duration = .seconds(0.2) /// The error the service throws, or `nil` when it should succeed. var error: Error? /// The transcription the service returns. var transcription = "This is a mocked transcription." func transcribe(_ audio: Data) async throws -> String { try await Task.sleep(for: delay) if let error { throw error } return transcription } } /// An error to configure the service mocks with. private struct ErrorMock: Error {}