Stabilize VoiceRecorder async gate tests

This commit is contained in:
jack
2026-07-26 14:14:13 +02:00
committed by jack
parent 359fd07371
commit a30c7078a2
+34 -21
View File
@@ -13,17 +13,16 @@ import Testing
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable { private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private let activationGate = DispatchSemaphore(value: 0) private let activationGate = DispatchSemaphore(value: 0)
private let activationBeganGate = DispatchSemaphore(value: 0)
private let shouldGateFirstActivation: Bool private let shouldGateFirstActivation: Bool
private var gatedFirstActivation = false private var gatedFirstActivation = false
private var _activationCalls: [Bool] = [] private var _activationCalls: [Bool] = []
private var _activationBegan = false
init(gateFirstActivation: Bool = false) { init(gateFirstActivation: Bool = false) {
self.shouldGateFirstActivation = gateFirstActivation self.shouldGateFirstActivation = gateFirstActivation
} }
var activationCalls: [Bool] { lock.withLock { _activationCalls } } var activationCalls: [Bool] { lock.withLock { _activationCalls } }
var activationBegan: Bool { lock.withLock { _activationBegan } }
func setCategory(_ category: AudioSessionCoordinator.Category) throws {} func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
@@ -32,14 +31,28 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab
_activationCalls.append(active) _activationCalls.append(active)
guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false } guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false }
gatedFirstActivation = true gatedFirstActivation = true
_activationBegan = true
return true return true
} }
if shouldWait { if shouldWait {
activationBeganGate.signal()
activationGate.wait() activationGate.wait()
} }
} }
func waitUntilActivationBegan(
timeout: DispatchTimeInterval = .seconds(5)
) async -> Bool {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(
returning: self.activationBeganGate.wait(
timeout: DispatchTime.now() + timeout
) == .success
)
}
}
}
func resumeActivation() { func resumeActivation() {
activationGate.signal() activationGate.signal()
} }
@@ -142,26 +155,38 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
/// this remains deterministic when the full test suite saturates the executor. /// this remains deterministic when the full test suite saturates the executor.
private final class VoiceRecorderPaddingGate: @unchecked Sendable { private final class VoiceRecorderPaddingGate: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var _entered = false private let enteredGate = DispatchSemaphore(value: 0)
private var isOpen = false private var isOpen = false
private var openWaiters: [CheckedContinuation<Void, Never>] = [] private var openWaiters: [CheckedContinuation<Void, Never>] = []
var entered: Bool { lock.withLock { _entered } }
func wait() async { func wait() async {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
let resumeImmediately = lock.withLock { () -> Bool in let resumeImmediately = lock.withLock { () -> Bool in
_entered = true
guard !isOpen else { return true } guard !isOpen else { return true }
openWaiters.append(continuation) openWaiters.append(continuation)
return false return false
} }
enteredGate.signal()
if resumeImmediately { if resumeImmediately {
continuation.resume() continuation.resume()
} }
} }
} }
func waitUntilEntered(
timeout: DispatchTimeInterval = .seconds(5)
) async -> Bool {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(
returning: self.enteredGate.wait(
timeout: DispatchTime.now() + timeout
) == .success
)
}
}
}
func open() { func open() {
let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
isOpen = true isOpen = true
@@ -181,18 +206,6 @@ struct VoiceRecorderTests {
return url return url
} }
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(condition(), sourceLocation: sourceLocation)
}
@Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws { @Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws {
let directory = try makeTemporaryDirectory() let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) } defer { try? FileManager.default.removeItem(at: directory) }
@@ -210,7 +223,7 @@ struct VoiceRecorderTests {
let owner = VoiceRecorder.RecordingOwner() let owner = VoiceRecorder.RecordingOwner()
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) } let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
await waitUntil { session.activationBegan } #expect(await session.waitUntilActivationBegan())
await voiceRecorder.cancelRecording(owner: owner) await voiceRecorder.cancelRecording(owner: owner)
session.resumeActivation() session.resumeActivation()
@@ -308,7 +321,7 @@ struct VoiceRecorderTests {
try await finishingHold.start() try await finishingHold.start()
let firstURL = try #require(factory.urls.first) let firstURL = try #require(factory.urls.first)
let finishTask = Task { await finishingHold.finish() } let finishTask = Task { await finishingHold.finish() }
await waitUntil { paddingGate.entered } #expect(await paddingGate.waitUntilEntered())
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) { await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
try await rejectedHold.start() try await rejectedHold.start()