2026-07-03 18:49:30 +02:00
|
|
|
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)
|
2026-07-03 20:30:37 +02:00
|
|
|
#expect(model.elapsedSeconds == 0)
|
2026-07-03 19:21:37 +02:00
|
|
|
#expect(model.textTimer == "00:00")
|
2026-07-03 21:33:46 +02:00
|
|
|
#expect(model.textTranscription == nil)
|
2026-07-03 18:49:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
|
2026-07-03 20:30:37 +02:00
|
|
|
#expect(model.elapsedSeconds >= 1)
|
2026-07-03 19:21:37 +02:00
|
|
|
#expect(model.textTimer == "00:01")
|
2026-07-03 18:49:30 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-07-03 20:30:37 +02:00
|
|
|
let secondsWhenPaused = model.elapsedSeconds
|
2026-07-03 18:49:30 +02:00
|
|
|
|
|
|
|
|
try await Task.sleep(for: .seconds(1.2))
|
|
|
|
|
|
|
|
|
|
#expect(secondsWhenPaused >= 1)
|
2026-07-03 20:30:37 +02:00
|
|
|
#expect(model.elapsedSeconds == secondsWhenPaused)
|
2026-07-03 18:49:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
2026-07-03 20:30:37 +02:00
|
|
|
let secondsWhenPaused = model.elapsedSeconds
|
2026-07-03 18:49:30 +02:00
|
|
|
|
|
|
|
|
model.state = .recording
|
|
|
|
|
model.updatedState(shouldRestartTimer: false)
|
|
|
|
|
|
2026-07-03 20:30:37 +02:00
|
|
|
#expect(model.elapsedSeconds == secondsWhenPaused)
|
2026-07-03 18:49:30 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-07-03 20:30:37 +02:00
|
|
|
#expect(model.elapsedSeconds == 0)
|
2026-07-03 18:49:30 +02:00
|
|
|
|
|
|
|
|
model.state = .notRecording
|
|
|
|
|
model.updatedState(shouldRestartTimer: false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:33:46 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 18:49:30 +02:00
|
|
|
// MARK: Processing
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
@Suite("Processing")
|
|
|
|
|
struct Processing {
|
|
|
|
|
|
2026-07-03 21:33:46 +02:00
|
|
|
@Test func `returns to not recording with a reset timer and a transcription`() async throws {
|
|
|
|
|
let model = Model(
|
|
|
|
|
transcriber: TranscribingServiceMock()
|
|
|
|
|
)
|
2026-07-03 18:49:30 +02:00
|
|
|
|
2026-07-03 20:30:37 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-07-03 18:49:30 +02:00
|
|
|
model.state = .processing
|
|
|
|
|
model.updatedState(shouldRestartTimer: false)
|
|
|
|
|
|
2026-07-03 21:33:46 +02:00
|
|
|
try await Task.sleep(for: .seconds(0.5))
|
2026-07-03 18:49:30 +02:00
|
|
|
|
|
|
|
|
#expect(model.state == .notRecording)
|
2026-07-03 20:30:37 +02:00
|
|
|
#expect(model.elapsedSeconds == 0)
|
2026-07-03 21:33:46 +02:00
|
|
|
#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)
|
2026-07-03 18:49:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
2026-07-03 21:33:46 +02:00
|
|
|
|
|
|
|
|
// 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 {}
|