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
|
@ObservationIgnored
|
||||||
private let locale: Binding<Locale>
|
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.
|
/// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var taskTimer: Task<Void, Never>?
|
private var taskTimer: Task<Void, Never>?
|
||||||
@@ -133,8 +137,8 @@ extension RecordingView {
|
|||||||
|
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
|
||||||
Task {
|
enqueueCapturer {
|
||||||
try? await capturer.pause()
|
try? await self.capturer.pause()
|
||||||
}
|
}
|
||||||
case .paused:
|
case .paused:
|
||||||
state = .recording
|
state = .recording
|
||||||
@@ -156,8 +160,8 @@ extension RecordingView {
|
|||||||
|
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
|
||||||
Task {
|
enqueueCapturer {
|
||||||
await processInput()
|
await self.processInput()
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -207,21 +211,34 @@ private extension RecordingView.Model {
|
|||||||
state = .notRecording
|
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.
|
/// 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.
|
/// - Parameter isNewRecording: Whether a new recording should be started, as opposed to a paused one being resumed.
|
||||||
func startCapturer(
|
func startCapturer(
|
||||||
_ isNewRecording: Bool
|
_ isNewRecording: Bool
|
||||||
) {
|
) {
|
||||||
Task {
|
enqueueCapturer {
|
||||||
do {
|
do {
|
||||||
isNewRecording
|
isNewRecording
|
||||||
? try await capturer.start()
|
? try await self.capturer.start()
|
||||||
: try await capturer.resume()
|
: try await self.capturer.resume()
|
||||||
} catch {
|
} catch {
|
||||||
stopTimer()
|
self.stopTimer()
|
||||||
|
|
||||||
state = .notRecording
|
self.state = .notRecording
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,8 +258,7 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
try await Task.sleep(for: .seconds(0.1))
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
#expect(capturer.countStart == 1)
|
#expect(capturer.calls == ["start"])
|
||||||
#expect(capturer.countResume == 0)
|
|
||||||
|
|
||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
}
|
}
|
||||||
@@ -273,7 +272,7 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
try await Task.sleep(for: .seconds(0.1))
|
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 {
|
@Test func `resumes the capture after a pause`() async throws {
|
||||||
@@ -286,8 +285,7 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
try await Task.sleep(for: .seconds(0.1))
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
#expect(capturer.countStart == 1)
|
#expect(capturer.calls == ["start", "pause", "resume"])
|
||||||
#expect(capturer.countResume == 1)
|
|
||||||
|
|
||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
}
|
}
|
||||||
@@ -303,7 +301,29 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
try await Task.sleep(for: .seconds(0.5))
|
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 {
|
@Test func `falls back to not recording when the capture fails`() async throws {
|
||||||
@@ -419,52 +439,45 @@ private extension RecordingView.Model {
|
|||||||
|
|
||||||
// MARK: - Mocks
|
// 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
|
@MainActor
|
||||||
private final class CapturingMock: Capturing {
|
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.
|
/// The error the service throws from every method, or `nil` when it should succeed.
|
||||||
var error: Error?
|
var error: Error?
|
||||||
|
|
||||||
/// The number of times ``start()`` has been called.
|
/// The names of the methods called on the service, in call order.
|
||||||
private(set) var countStart = 0
|
private(set) var calls: [String] = []
|
||||||
|
|
||||||
/// 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 {
|
func start() async throws {
|
||||||
countStart += 1
|
try await called("start")
|
||||||
|
|
||||||
try throwConfiguredError()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() async throws {
|
func pause() async throws {
|
||||||
countPause += 1
|
try await called("pause")
|
||||||
|
|
||||||
try throwConfiguredError()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resume() async throws {
|
func resume() async throws {
|
||||||
countResume += 1
|
try await called("resume")
|
||||||
|
|
||||||
try throwConfiguredError()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() async throws -> Data {
|
func stop() async throws -> Data {
|
||||||
countStop += 1
|
try await called("stop")
|
||||||
|
|
||||||
try throwConfiguredError()
|
|
||||||
|
|
||||||
return .init()
|
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 {
|
if let error {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user