Serialized the capturing service calls for the RecordingViewModel view model in the Recording package target.

This commit is contained in:
2026-07-04 14:55:49 +02:00
parent 702333038c
commit ed9d1e86fa
2 changed files with 70 additions and 40 deletions
@@ -43,6 +43,10 @@ extension RecordingView {
@ObservationIgnored
private let locale: Binding<Locale>
/// The task that serializes the calls to the capturing service, so rapid state changes can never reach the service out of order.
@ObservationIgnored
private var taskCapturer: Task<Void, Never>?
/// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording.
@ObservationIgnored
private var taskTimer: Task<Void, Never>?
@@ -133,8 +137,8 @@ extension RecordingView {
stopTimer()
Task {
try? await capturer.pause()
enqueueCapturer {
try? await self.capturer.pause()
}
case .paused:
state = .recording
@@ -156,8 +160,8 @@ extension RecordingView {
stopTimer()
Task {
await processInput()
enqueueCapturer {
await self.processInput()
}
default:
break
@@ -207,21 +211,34 @@ private extension RecordingView.Model {
state = .notRecording
}
/// Enqueues an operation behind any previously enqueued ones, so the calls to the capturing service always reach it in the order
/// the states changed, no matter how quickly the user presses the view's controls.
///
/// - Parameter operation: The operation on the capturing service to enqueue.
func enqueueCapturer(
_ operation: @escaping @MainActor () async -> Void
) {
taskCapturer = Task { [taskCapturer] in
await taskCapturer?.value
await operation()
}
}
/// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state when the service fails.
///
/// - Parameter isNewRecording: Whether a new recording should be started, as opposed to a paused one being resumed.
func startCapturer(
_ isNewRecording: Bool
) {
Task {
enqueueCapturer {
do {
isNewRecording
? try await capturer.start()
: try await capturer.resume()
? try await self.capturer.start()
: try await self.capturer.resume()
} catch {
stopTimer()
self.stopTimer()
state = .notRecording
self.state = .notRecording
}
}
}
@@ -258,8 +258,7 @@ struct RecordingViewModelTests {
try await Task.sleep(for: .seconds(0.1))
#expect(capturer.countStart == 1)
#expect(capturer.countResume == 0)
#expect(capturer.calls == ["start"])
model.pressedMain()
}
@@ -273,7 +272,7 @@ struct RecordingViewModelTests {
try await Task.sleep(for: .seconds(0.1))
#expect(capturer.countPause == 1)
#expect(capturer.calls == ["start", "pause"])
}
@Test func `resumes the capture after a pause`() async throws {
@@ -286,8 +285,7 @@ struct RecordingViewModelTests {
try await Task.sleep(for: .seconds(0.1))
#expect(capturer.countStart == 1)
#expect(capturer.countResume == 1)
#expect(capturer.calls == ["start", "pause", "resume"])
model.pressedMain()
}
@@ -303,7 +301,29 @@ struct RecordingViewModelTests {
try await Task.sleep(for: .seconds(0.5))
#expect(capturer.countStop == 1)
#expect(capturer.calls == ["start", "pause", "stop"])
}
@Test func `serializes the calls when states change rapidly`() async throws {
let capturer = CapturingMock()
capturer.delay = .seconds(0.2)
let model = Model(capturer: capturer)
model.pressedMain()
model.pressedMain()
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
#expect(capturer.calls == ["start"])
try await Task.sleep(for: .seconds(0.6))
#expect(capturer.calls == ["start", "pause", "resume"])
model.pressedMain()
}
@Test func `falls back to not recording when the capture fails`() async throws {
@@ -419,52 +439,45 @@ private extension RecordingView.Model {
// MARK: - Mocks
/// A recording service that counts its invocations and can be configured to fail.
/// A recording service that records its invocations in call order, each one taking a configurable amount of time, and can be
/// configured to fail.
@MainActor
private final class CapturingMock: Capturing {
/// The duration every method takes before returning, simulating slow capture work.
var delay: Duration = .zero
/// 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
/// The names of the methods called on the service, in call order.
private(set) var calls: [String] = []
func start() async throws {
countStart += 1
try throwConfiguredError()
try await called("start")
}
func pause() async throws {
countPause += 1
try throwConfiguredError()
try await called("pause")
}
func resume() async throws {
countResume += 1
try throwConfiguredError()
try await called("resume")
}
func stop() async throws -> Data {
countStop += 1
try throwConfiguredError()
try await called("stop")
return .init()
}
private func throwConfiguredError() throws {
private func called(
_ name: String
) async throws {
calls.append(name)
try await Task.sleep(for: delay)
if let error {
throw error
}