Implemented the resumed interrupted recordings and allowed processing discards on the RecordingViewModel view model in the Recording package target.

This commit is contained in:
2026-07-05 16:16:53 +02:00
parent 8576d6bac6
commit c2ac476257
14 changed files with 563 additions and 309 deletions
@@ -5,6 +5,8 @@ import Recording
/// configured to fail.
@MainActor
final class CapturingMock: Capturing {
// MARK: Properties
/// The stream of events the service emits outside its method calls.
let events: AsyncStream<CapturingEvent>
@@ -20,16 +22,29 @@ final class CapturingMock: Capturing {
/// The continuation that feeds ``events``.
private let continuation: AsyncStream<CapturingEvent>.Continuation
// MARK: Initializers
init() {
(events, continuation) = AsyncStream.makeStream(of: CapturingEvent.self)
}
// MARK: Methods
/// Emits an interruption of the ongoing capture through ``events``.
func interrupt() {
continuation.yield(.interrupted)
}
/// Emits the end of an interruption of the capture through ``events``.
///
/// - Parameter shouldResume: Whether the capture may resume right away.
func endInterruption(
shouldResume: Bool
) {
continuation.yield(.interruptionEnded(shouldResume: shouldResume))
}
func start() async throws {
try await called("start")
}
@@ -1,53 +0,0 @@
import Foundation
import Recording
/// A preinstalling service that records its requests, resolves the supported locales from configurable tables, and emits events
/// on demand.
@MainActor
final class PreinstallingMock: Preinstalling {
/// The stream of events the service emits while preinstalling.
let events: AsyncStream<PreinstallingEvent>
/// The supported equivalents the service resolves, keyed by the requested locale.
var equivalents: [Locale: Locale] = [:]
/// The locales the service reports as supported.
var localesSupported: [Locale] = []
/// The locales a preinstallation was requested for, in request order.
private(set) var localesPreinstalled: [Locale] = []
/// The continuation that feeds ``events``.
private let continuation: AsyncStream<PreinstallingEvent>.Continuation
init() {
(events, continuation) = AsyncStream.makeStream(of: PreinstallingEvent.self)
}
/// Emits the given event through ``events``.
///
/// - Parameter event: The event to emit.
func emit(
_ event: PreinstallingEvent
) {
continuation.yield(event)
}
func preinstall(
for locale: Locale
) async {
localesPreinstalled.append(locale)
}
func supportedLocale(
equivalentTo locale: Locale
) async -> Locale? {
equivalents[locale]
}
func supportedLocales() async -> [Locale] {
localesSupported
}
}
@@ -4,6 +4,8 @@ import Recording
/// A transcribing service with a configurable delay, output, and failure.
@MainActor
final class TranscribingMock: Transcribing {
// MARK: Properties
/// The duration of the simulated transcription work.
var delay: Duration = .seconds(0.2)
@@ -13,6 +15,8 @@ final class TranscribingMock: Transcribing {
/// The transcription the service returns.
var transcription = "This is a mocked transcription."
// MARK: Methods
func callAsFunction(
_ audio: URL,
@@ -15,7 +15,7 @@ struct TranscriptionTests {
"",
"This is a transcription.",
" ",
"Multiline\ntranscribed\ntext."
"Multiline\ntranscribed\ntext.",
])
func `initializer stores the given text`(text: String) {
let transcription = Transcription(text: text)
@@ -23,7 +23,8 @@ struct TranscriptionTests {
#expect(transcription.text == text)
}
@Test func `initializer stores the given identifier`() {
@Test
func `initializer stores the given identifier`() {
let id = UUID()
let transcription = Transcription(
id: id,
@@ -40,10 +41,12 @@ struct TranscriptionTests {
@Suite("Computed properties")
struct ComputedProperties {
@Test(arguments: zip(
["", "This is a transcription.", " "],
[true, false, false]
))
@Test(
arguments: zip(
["", "This is a transcription.", " "],
[true, false, false]
)
)
func `is empty only when the text has no characters`(
for text: String,
expected: Bool
@@ -60,7 +63,8 @@ struct TranscriptionTests {
@Suite("Equatable")
struct EquatableConformance {
@Test func `transcriptions with the same identifier and text are equal`() {
@Test
func `transcriptions with the same identifier and text are equal`() {
let id = UUID()
let first = Transcription(
@@ -75,7 +79,8 @@ struct TranscriptionTests {
#expect(first == second)
}
@Test func `transcriptions with generated identifiers are never equal`() {
@Test
func `transcriptions with generated identifiers are never equal`() {
let first = Transcription(text: "This is a transcription.")
let second = Transcription(text: "This is a transcription.")
@@ -89,13 +94,15 @@ struct TranscriptionTests {
@Suite("Identifiable")
struct IdentifiableConformance {
@Test func `identifier is stable across accesses`() {
@Test
func `identifier is stable across accesses`() {
let transcription = Transcription(text: "This is a transcription.")
#expect(transcription.id == transcription.id)
}
@Test func `identifiers differ between transcriptions`() {
@Test
func `identifiers differ between transcriptions`() {
let first = Transcription(text: "This is a transcription.")
let second = Transcription(text: "This is a transcription.")
@@ -0,0 +1,61 @@
import Foundation
import Testing
@testable import Recording
@Suite("Capturing interruption events")
struct CapturingEventInterruptionTests {
@Test
func `a began interruption maps to the interrupted event`() {
let event = CapturingEvent(interruption: [
"AVAudioSessionInterruptionTypeKey": UInt(1)
])
#expect(event == .interrupted)
}
@Test
func `an ended interruption without options maps to a non-resuming end event`() {
let event = CapturingEvent(interruption: [
"AVAudioSessionInterruptionTypeKey": UInt(0)
])
#expect(event == .interruptionEnded(shouldResume: false))
}
@Test
func `an ended interruption with the resume option maps to a resuming end event`() {
let event = CapturingEvent(interruption: [
"AVAudioSessionInterruptionTypeKey": UInt(0),
"AVAudioSessionInterruptionOptionKey": UInt(1),
])
#expect(event == .interruptionEnded(shouldResume: true))
}
@Test
func `a missing interruption type maps to no event`() {
#expect(CapturingEvent(interruption: [:]) == nil)
#expect(CapturingEvent(interruption: nil) == nil)
}
@Test
func `an unknown interruption type maps to no event`() {
let event = CapturingEvent(interruption: [
"AVAudioSessionInterruptionTypeKey": UInt(99)
])
#expect(event == nil)
}
@Test
func `a mistyped interruption type maps to no event`() {
let event = CapturingEvent(interruption: [
"AVAudioSessionInterruptionTypeKey": "began"
])
#expect(event == nil)
}
}
@@ -14,12 +14,14 @@ struct RecordingViewModelTests {
@Suite("Initial state")
struct InitialState {
@Test func `initial state is not recording with a zeroed timer`() {
@Test
func `initial state is not recording with a zeroed timer`() {
let model = Model()
#expect(model.state == .notRecording)
#expect(model.elapsedSeconds == 0)
#expect(model.error == nil)
#expect(model.textAlertMessage == nil)
#expect(model.textTimer == "00:00")
#expect(model.transcription == nil)
}
@@ -68,7 +70,7 @@ struct RecordingViewModelTests {
@Test(arguments: zip(
[Model.State.notRecording, .recording, .paused, .processing],
[Model.State.notRecording, .recording, .notRecording, .processing]
[Model.State.notRecording, .recording, .notRecording, .notRecording]
))
func `pressed discard transitions to the expected state`(
from initial: Model.State,
@@ -136,6 +138,46 @@ struct RecordingViewModelTests {
#expect(model.isProcessing == expected)
}
@Test(arguments: zip(
[Model.State.notRecording, .recording, .paused, .processing],
[
LocalizedStringResource.viewRecordingButtonMainLabelRecord,
.viewRecordingButtonMainLabelPause,
.viewRecordingButtonMainLabelResume,
.viewRecordingButtonMainLabelRecord,
]
))
func `main label matches the state`(
for state: Model.State,
expected: LocalizedStringResource
) {
let model = Model()
model.drive(to: state)
#expect(model.labelMain == expected)
}
@Test(arguments: zip(
[Model.State.notRecording, .recording, .paused, .processing],
[
LocalizedStringResource.viewRecordingButtonSendLabelSend,
.viewRecordingButtonSendLabelSend,
.viewRecordingButtonSendLabelSend,
.viewRecordingButtonSendLabelProcessing,
]
))
func `send label matches the state`(
for state: Model.State,
expected: LocalizedStringResource
) {
let model = Model()
model.drive(to: state)
#expect(model.labelSend == expected)
}
@Test(arguments: zip(
[Model.State.notRecording, .recording, .paused, .processing],
[false, false, false, true]
@@ -155,7 +197,7 @@ struct RecordingViewModelTests {
[Model.State.notRecording, .recording, .paused, .processing],
[false, false, true, true]
))
func `discard button is visible only while paused`(
func `discard button is visible while paused or processing`(
for state: Model.State,
expected: Bool
) {
@@ -204,7 +246,8 @@ struct RecordingViewModelTests {
@Suite("Timer")
struct Timer {
@Test func `ticks while recording`() async throws {
@Test
func `ticks while recording`() async throws {
let model = Model()
model.pressedMain()
@@ -217,7 +260,8 @@ struct RecordingViewModelTests {
model.pressedMain()
}
@Test func `stops while paused`() async throws {
@Test
func `stops while paused`() async throws {
let model = Model()
model.pressedMain()
@@ -234,7 +278,8 @@ struct RecordingViewModelTests {
#expect(model.elapsedSeconds == secondsWhenPaused)
}
@Test func `keeps elapsed seconds when resumed`() async throws {
@Test
func `keeps elapsed seconds when resumed`() async throws {
let model = Model()
model.pressedMain()
@@ -252,7 +297,8 @@ struct RecordingViewModelTests {
model.pressedMain()
}
@Test func `restarts for a new recording`() async throws {
@Test
func `restarts for a new recording`() async throws {
let model = Model(
transcribe: TranscribingMock()
)
@@ -283,7 +329,8 @@ struct RecordingViewModelTests {
@Suite("Capturer")
struct Capturer {
@Test func `starts the capture for a new recording`() async throws {
@Test
func `starts the capture for a new recording`() async throws {
let capturer = CapturingMock()
let model = Model(capturer: capturer)
@@ -296,7 +343,8 @@ struct RecordingViewModelTests {
model.pressedMain()
}
@Test func `pauses the capture while paused`() async throws {
@Test
func `pauses the capture while paused`() async throws {
let capturer = CapturingMock()
let model = Model(capturer: capturer)
@@ -308,7 +356,8 @@ struct RecordingViewModelTests {
#expect(capturer.calls == ["start", "pause"])
}
@Test func `resumes the capture after a pause`() async throws {
@Test
func `resumes the capture after a pause`() async throws {
let capturer = CapturingMock()
let model = Model(capturer: capturer)
@@ -323,7 +372,8 @@ struct RecordingViewModelTests {
model.pressedMain()
}
@Test func `stops the capture when the input is sent`() async throws {
@Test
func `stops the capture when the input is sent`() async throws {
let capturer = CapturingMock()
let model = Model(
capturer: capturer,
@@ -337,7 +387,8 @@ struct RecordingViewModelTests {
#expect(capturer.calls == ["start", "pause", "stop"])
}
@Test func `stops the capture when a paused recording is discarded`() async throws {
@Test
func `stops the capture when a paused recording is discarded`() async throws {
let capturer = CapturingMock()
let model = Model(capturer: capturer)
@@ -352,7 +403,8 @@ struct RecordingViewModelTests {
#expect(model.elapsedSeconds == 0)
}
@Test func `serializes the calls when states change rapidly`() async throws {
@Test
func `serializes the calls when states change rapidly`() async throws {
let capturer = CapturingMock()
capturer.delay = .seconds(0.2)
@@ -374,7 +426,8 @@ struct RecordingViewModelTests {
model.pressedMain()
}
@Test func `falls back to not recording when the capture fails`() async throws {
@Test
func `falls back to not recording when the capture fails`() async throws {
let capturer = CapturingMock()
capturer.error = ErrorMock()
@@ -396,7 +449,8 @@ struct RecordingViewModelTests {
@Suite("Errors")
struct Errors {
@Test func `surfaces a capture failure`() async throws {
@Test
func `surfaces a capture failure`() async throws {
let capturer = CapturingMock()
capturer.error = ErrorMock()
@@ -409,9 +463,11 @@ struct RecordingViewModelTests {
#expect(model.error == .captureFailed)
#expect(model.state == .notRecording)
#expect(model.textAlertMessage == .viewRecordingAlertErrorMessageCapture)
}
@Test func `surfaces a denied microphone permission`() async throws {
@Test
func `surfaces a denied microphone permission`() async throws {
let capturer = CapturingMock()
capturer.error = AudioCapturingError.permissionNotGranted
@@ -423,9 +479,11 @@ struct RecordingViewModelTests {
try await Task.sleep(for: .seconds(0.1))
#expect(model.error == .permissionDenied)
#expect(model.textAlertMessage == .viewRecordingAlertErrorMessagePermission)
}
@Test func `surfaces unavailable speech model assets`() async throws {
@Test
func `surfaces unavailable speech model assets`() async throws {
let transcriber = TranscribingMock()
transcriber.error = AudioTranscribingError.assetsNotInstalled
@@ -439,10 +497,12 @@ struct RecordingViewModelTests {
try await Task.sleep(for: .seconds(0.5))
#expect(model.error == .assetsUnavailable)
#expect(model.textAlertMessage == .viewRecordingAlertErrorMessageAssets)
#expect(model.transcription == nil)
}
@Test func `surfaces a transcription failure`() async throws {
@Test
func `surfaces a transcription failure`() async throws {
let transcriber = TranscribingMock()
transcriber.error = ErrorMock()
@@ -456,10 +516,12 @@ struct RecordingViewModelTests {
try await Task.sleep(for: .seconds(0.5))
#expect(model.error == .transcriptionFailed)
#expect(model.textAlertMessage == .viewRecordingAlertErrorMessageTranscription)
#expect(model.transcription == nil)
}
@Test func `clears the error when dismissed`() async throws {
@Test
func `clears the error when dismissed`() async throws {
let capturer = CapturingMock()
capturer.error = ErrorMock()
@@ -475,9 +537,11 @@ struct RecordingViewModelTests {
model.dismissedError()
#expect(model.error == nil)
#expect(model.textAlertMessage == nil)
}
@Test func `clears the error when a new recording starts`() async throws {
@Test
func `clears the error when a new recording starts`() async throws {
let capturer = CapturingMock()
capturer.error = ErrorMock()
@@ -506,7 +570,8 @@ struct RecordingViewModelTests {
@Suite("Interruptions")
struct Interruptions {
@Test func `pauses an ongoing recording when the capture is interrupted`() async throws {
@Test
func `pauses an ongoing recording when the capture is interrupted`() async throws {
let capturer = CapturingMock()
let model = Model(capturer: capturer)
@@ -522,7 +587,8 @@ struct RecordingViewModelTests {
#expect(capturer.calls == ["start", "pause"])
}
@Test func `changes nothing when the capture is interrupted while paused`() async throws {
@Test
func `changes nothing when the capture is interrupted while paused`() async throws {
let capturer = CapturingMock()
let model = Model(capturer: capturer)
@@ -539,6 +605,68 @@ struct RecordingViewModelTests {
#expect(capturer.calls == ["start", "pause"])
}
@Test
func `resumes an interrupted recording when the interruption ends with the resume hint`() async throws {
let capturer = CapturingMock()
let model = Model(capturer: capturer)
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
capturer.interrupt()
try await Task.sleep(for: .seconds(0.1))
capturer.endInterruption(shouldResume: true)
try await Task.sleep(for: .seconds(0.1))
#expect(model.state == .recording)
#expect(capturer.calls == ["start", "pause", "resume"])
model.pressedMain()
}
@Test
func `stays paused when the interruption ends without the resume hint`() async throws {
let capturer = CapturingMock()
let model = Model(capturer: capturer)
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
capturer.interrupt()
try await Task.sleep(for: .seconds(0.1))
capturer.endInterruption(shouldResume: false)
try await Task.sleep(for: .seconds(0.1))
#expect(model.state == .paused)
#expect(capturer.calls == ["start", "pause"])
}
@Test
func `never resumes a recording the user paused`() async throws {
let capturer = CapturingMock()
let model = Model(capturer: capturer)
model.pressedMain()
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
capturer.endInterruption(shouldResume: true)
try await Task.sleep(for: .seconds(0.1))
#expect(model.state == .paused)
#expect(capturer.calls == ["start", "pause"])
}
}
// MARK: Processing
@@ -547,7 +675,8 @@ struct RecordingViewModelTests {
@Suite("Processing")
struct Processing {
@Test func `returns to not recording with a reset timer and a transcription`() async throws {
@Test
func `returns to not recording with a reset timer and a transcription`() async throws {
let model = Model(
transcribe: TranscribingMock()
)
@@ -569,7 +698,8 @@ struct RecordingViewModelTests {
#expect(model.transcription != nil)
}
@Test func `clears the transcription when the transcriber fails`() async throws {
@Test
func `clears the transcription when the transcriber fails`() async throws {
let transcriber = TranscribingMock()
transcriber.error = ErrorMock()
@@ -587,7 +717,34 @@ struct RecordingViewModelTests {
#expect(model.transcription == nil)
}
@Test func `clears the transcription when a new recording starts`() async throws {
@Test
func `discarding while processing cancels the transcription`() async throws {
let capturer = CapturingMock()
let transcriber = TranscribingMock()
transcriber.delay = .seconds(0.5)
let model = Model(
capturer: capturer,
transcribe: transcriber
)
model.drive(to: .processing)
try await Task.sleep(for: .seconds(0.2))
model.pressedDiscard()
try await Task.sleep(for: .seconds(0.6))
#expect(model.state == .notRecording)
#expect(model.elapsedSeconds == 0)
#expect(model.error == nil)
#expect(model.transcription == nil)
}
@Test
func `clears the transcription when a new recording starts`() async throws {
let model = Model(
transcribe: TranscribingMock()
)