Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 68 additions & 25 deletions Tests/BlurtEngineTests/MicCaptureWarmTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,40 @@ import Testing
/// refuse to double-prepare, validate against the live input before reuse, and
/// tear down on a stale expiry ticket.
///
/// Runs without hardware: none of this begins capture — `warmUp()` only
/// constructs and prepares a file-backed recorder, so unlike the capture
/// lifecycle in `MicCapture.swift` (excluded from the coverage gate, exercised
/// by the env-gated `MicCaptureLevelsTests`) the decisions here are reachable in
/// CI. The device-identity checks are driven through
/// `installWarmRecorder(boundTo:)` below rather than `warmUp()` itself, so what
/// the test machine's `AudioRoute.currentInput()` answers never decides a test.
/// Everything here is driven through the `installWarmRecorder(boundTo:)` seam
/// and direct state setters, never through `prepareWarmRecorder()`. Preparing
/// for real reads `AudioRoute.currentInput()` — a CoreAudio HAL query that needs
/// an input device. On a headless CI runner that read fails or, worse, blocks
/// inside CoreAudio, pinning the cooperative pool and wedging the whole parallel
/// test process (a pinned pool means even the suite's `.timeLimit` never fires).
/// Production `warmUp()` / `scheduleRewarm()` therefore appear below only on
/// their refusal paths, which return before the prepare. The cost is honest:
/// `prepareWarmRecorder()`'s success path is exercised only on hardware.
@Suite("MicCapture warm recorder", .timeLimit(.minutes(1)))
struct MicCaptureWarmTests {
private let builtIn = AudioRoute.InputSnapshot(deviceID: 7, transportType: nil)
private let airPods = AudioRoute.InputSnapshot(deviceID: 8, transportType: nil)

@Test("warmUp prepares a recorder once; a second call has been overtaken and no-ops")
func warmUpPreparesOnce() async throws {
@Test("warmUp refuses while the slot is taken, so a re-warm can't stack a second recorder")
func warmUpRefusedWhileSlotTaken() async throws {
let mic = MicCapture()
#expect(await mic.canPrepareWarmRecorder)

await mic.warmUp()
try await mic.installWarmRecorder(boundTo: builtIn)
#expect(await mic.hasWarmRecorder)
// The slot is taken, so it is no longer safe to open the input for another.
#expect(await mic.canPrepareWarmRecorder == false)

// A second warm-up — e.g. a scheduled re-warm that lost its race with a
// A warm-up landing now — e.g. a scheduled re-warm that lost its race with a
// launch-time warmUp — must not stack a second open recorder onto the input.
// Safe to call for real: the guard refuses before the device read.
let generation = await mic.preparedGeneration
await mic.warmUp()
#expect(await mic.preparedGeneration == generation)

// Emptying the slot reopens the guard.
await mic.discardWarmRecorder()
#expect(await mic.canPrepareWarmRecorder)
}

@Test("warmUp is refused across the bring-up window, when both recorder slots are nil")
Expand All @@ -45,14 +50,15 @@ struct MicCaptureWarmTests {
// the already-open input.
let mic = MicCapture()
await mic.setBringingUpCapture(true)
#expect(await mic.canPrepareWarmRecorder == false)
await mic.warmUp()
#expect(await mic.hasWarmRecorder == false)

// Closing the window reopens the guard. Asserted on the guard itself rather
// than by calling `warmUp()` again — past the guard it prepares for real,
// which needs an input device (see the suite comment).
await mic.setBringingUpCapture(false)
await mic.warmUp()
#expect(await mic.hasWarmRecorder)

await mic.discardWarmRecorder()
#expect(await mic.canPrepareWarmRecorder)
}

@Test("a warm recorder is reused only while still bound to the live default input")
Expand Down Expand Up @@ -109,18 +115,52 @@ struct MicCaptureWarmTests {
#expect(await mic.hasWarmRecorder == false)
}

@Test("a scheduled re-warm eventually prepares a recorder on its own turn")
func scheduledRewarmPreparesARecorder() async throws {
@Test("arming the expiry attaches a countdown; consuming the recorder cancels it")
func armingExpiryAttachesCountdown() async throws {
let mic = MicCapture()
try await mic.installWarmRecorder(boundTo: builtIn)
#expect(await mic.hasExpiryCountdown == false)

let generation = await mic.preparedGeneration
await mic.armPreparedRecorderExpiry(generation: generation)
#expect(await mic.hasExpiryCountdown)

// Re-arming cancels the previous countdown and installs a fresh one rather
// than leaving two racing tasks against the same recorder.
await mic.armPreparedRecorderExpiry(generation: generation)
#expect(await mic.hasExpiryCountdown)

// The take cancels the countdown along with consuming the recorder, so a
// consumed recorder's expiry can never fire against its successor.
#expect(await mic.takeWarm(matching: builtIn))
#expect(await mic.hasExpiryCountdown == false)
}

@Test("a scheduled re-warm that has been overtaken no-ops on its turn")
func overtakenScheduledRewarmDoesNothing() async throws {
// `stop()`/`cancelCapture()` schedule rather than prepare inline because
// preparing re-opens the input — the slow part — and both sit on paths the
// user is waiting behind. All this can pin deterministically is the other
// half of that contract: the scheduled task does land, and prepares.
// Condition-waited rather than yield-budgeted (see `awaitCancelRequest`);
// the suite's time limit turns a re-warm that never lands into a failure.
// user is waiting behind. That deferral means a press (here: an installed
// recorder) can land before the re-warm's turn, and the overtaken task must
// find the slot taken and do nothing. (The success half of the contract —
// the scheduled task preparing a fresh recorder — goes through the device
// read and is only reachable on hardware; see the suite comment.)
let mic = MicCapture()
try await mic.installWarmRecorder(boundTo: builtIn)
let before = await mic.preparedGeneration

await mic.scheduleRewarm()
while await mic.preparedGeneration == before { await Task.yield() }
// A deadline-bounded settling window, not a condition wait: the assertion is
// a negative, so there is nothing to wait *for* — the window just gives the
// scheduled task actor turns. A regression (a bumped generation) exits
// early, straight into the failing expectation; the sleep is
// cancellation-aware so the suite's time limit can still preempt it.
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .milliseconds(500))
while await mic.preparedGeneration == before, clock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
}
#expect(await mic.preparedGeneration == before)
#expect(await mic.hasWarmRecorder)

await mic.discardWarmRecorder()
Expand All @@ -135,6 +175,9 @@ extension MicCapture {
/// Whether a warm recorder is currently held.
var hasWarmRecorder: Bool { warm != nil }

/// Whether the held warm recorder has an expiry countdown armed.
var hasExpiryCountdown: Bool { warm?.expiry != nil }

/// Opens or closes the bring-up window `canPrepareWarmRecorder` guards on,
/// standing in for a `start()` suspended in its liveness wait (which needs
/// real hardware to reach).
Expand All @@ -143,9 +186,9 @@ extension MicCapture {
}

/// Installs a warm recorder bound to a *known* input — `prepareWarmRecorder`
/// with the `AudioRoute.currentInput()` read replaced by `input`, so the
/// device-identity tests don't depend on what the test machine's routing
/// happens to answer.
/// with the `AudioRoute.currentInput()` read replaced by `input`, so no test
/// depends on what the test machine's routing happens to answer (or on there
/// being an input device at all).
func installWarmRecorder(boundTo input: AudioRoute.InputSnapshot?) throws {
preparedGeneration += 1
warm = WarmRecorder(
Expand Down