Files
bitchat/bitchatTests/VoiceNotePlaybackControllerTests.swift
T
jackandClaude Opus 5 44fadeeaba Raise two more starvation-prone test deadlines
Both surfaced on the CI runs for this PR and #1487, both in tests neither
PR touches, both the same shape as the two already fixed here: a deadline
sized for the work rather than for a runner executing many suites at once.

VoiceNotePlaybackControllerTests waited 5s for a @MainActor Task that
playback schedules for the session acquire and its failure path. When that
Task is not scheduled in time the helper reports *two* failures — the wait,
and the `!isPlaying` the un-run failure path has not reset yet — which
reads like a playback bug rather than a starved scheduler. That is exactly
what CI showed.

GeoRelayDirectoryTests waited 10s for work the directory runs in
Task.detached(priority: .utility); utility priority competes with every
other suite. The retry-scheduling case timed out at exactly 10.06s with the
retry never scheduled, reading like a missing retry rather than a starved
background task.

Raised to 30s each with the reasoning recorded at the helper. Both helpers
return as soon as their condition holds, so nothing slows down when tests
pass — only the genuine-failure case takes longer to report.

Verified 3x with all cores saturated: both suites clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:00:04 +01:00

159 lines
6.3 KiB
Swift

//
// VoiceNotePlaybackControllerTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import AVFoundation
import Foundation
@testable import bitchat
/// Thread-safe: the coordinator invokes it on its private serial queue (the
/// blocking session IPC runs off the main thread) while the test reads from
/// the main actor.
private final class RecordingAudioSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock()
private var _activationCalls: [Bool] = []
private var _categoryCallCount = 0
private var _categoryError: Error?
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
var categoryCallCount: Int { lock.withLock { _categoryCallCount } }
var categoryError: Error? {
get { lock.withLock { _categoryError } }
set { lock.withLock { _categoryError = newValue } }
}
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
try lock.withLock {
_categoryCallCount += 1
if let error = _categoryError {
_categoryError = nil
throw error
}
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
lock.withLock { _activationCalls.append(active) }
}
}
private struct PlaybackSessionError: Error {}
@MainActor
struct VoiceNotePlaybackControllerTests {
/// A short silent PCM file `AVAudioPlayer` can open on the test host.
private func makeTempVoiceNote(seconds: Double = 0.2) throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-test-\(UUID().uuidString).caf")
let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1))
let file = try AVAudioFile(forWriting: url, settings: format.settings)
let frames = AVAudioFrameCount(seconds * 16_000)
let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames))
buffer.frameLength = frames
try file.write(from: buffer)
return url
}
/// Waits for an async settle, then asserts.
///
/// The deadline is deliberately far larger than the work it waits on. Every
/// condition here depends on a `@MainActor` Task that playback schedules
/// (the session acquire and its failure path), and on a CI runner executing
/// many suites in parallel that Task can simply not be scheduled for
/// seconds. At five seconds this timed out on CI and reported *two*
/// failures — the wait itself, and the `!isPlaying` that the un-run failure
/// path had not yet reset — which reads like a playback bug rather than a
/// starved scheduler.
///
/// A generous deadline costs nothing when the condition holds, since this
/// returns as soon as it does; it only extends the genuine-failure case.
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(30))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(condition(), sourceLocation: sourceLocation)
}
@Test func seekWhilePausedDoesNotAcquireSession() throws {
let session = RecordingAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let url = try makeTempVoiceNote()
defer { try? FileManager.default.removeItem(at: url) }
let controller = VoiceNotePlaybackController(
url: url,
sessionCoordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator()
)
controller.seek(to: 0.5)
// The scrub position moved (the player is real and ready)...
#expect(controller.progress > 0.25)
// ...but nothing is audible, so the session must not be held: an
// acquired-while-paused token on a discarded row would pin the
// session (and any escalated category) forever.
#expect(session.activationCalls.isEmpty)
#expect(!controller.isPlaying)
}
@Test func deinitReleasesSessionAndStopsPlayback() async throws {
let session = RecordingAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let url = try makeTempVoiceNote()
defer { try? FileManager.default.removeItem(at: url) }
// Fresh exclusivity slot: a parallel test's play() must not pause
// this controller while its session acquire is in flight.
var controller: VoiceNotePlaybackController? = VoiceNotePlaybackController(
url: url,
sessionCoordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator()
)
controller?.play()
// The session acquire is asynchronous now (its blocking IPC runs off
// the main thread), so await the activation instead of asserting
// right after play().
await waitUntil { session.activationCalls == [true] }
// Navigating away discards the row's @StateObject mid-playback:
// deinit must release the session hold (a fire-and-forget hop onto
// the coordinator's queue).
controller = nil
await waitUntil { session.activationCalls == [true, false] }
}
@Test func activationFailureDoesNotStartUnregisteredPlayback() async throws {
let session = RecordingAudioSession()
session.categoryError = PlaybackSessionError()
let coordinator = AudioSessionCoordinator(session: session)
let url = try makeTempVoiceNote(seconds: 30)
defer { try? FileManager.default.removeItem(at: url) }
let controller = VoiceNotePlaybackController(
url: url,
sessionCoordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator()
)
controller.play()
await waitUntil {
session.categoryCallCount == 1 && !controller.isPlaybackStartPending
}
#expect(session.activationCalls.isEmpty)
#expect(!controller.isPlaying)
#expect(controller.currentTime == 0)
}
}