Serialized the capturing service calls for the RecordingViewModel view model in the Recording package target.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user