diff --git a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift index b0e7ace..26efe84 100644 --- a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift @@ -43,6 +43,10 @@ extension RecordingView { @ObservationIgnored private let locale: Binding + /// 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? + /// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording. @ObservationIgnored private var taskTimer: Task? @@ -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 } } } diff --git a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift index 1fa23e5..2c83458 100644 --- a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift +++ b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift @@ -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 }