Files
bitchat/bitchatTests/VoiceCaptureSessionTests.swift
T
jackandClaude Opus 5 ac13a0c22f Make the flake class unrepeatable, not just fixed
Raising four deadlines fixed the four tests that happened to fire. The
class was still there: fourteen separate waitUntil helpers, most defaulting
to 1.0s, plus wait call sites with literal budgets. The fifth instance
would have landed the same way, on someone else's unrelated PR.

The rule, now written down in TestConstants.settleTimeout: **a wait
deadline is not a latency budget.** It exists so a genuine hang eventually
fails the suite, so size it for the worst-case scheduler, never for how
long the operation should take. Waits return as soon as their condition
holds, so a generous deadline is free in the passing case and only extends
genuine failures.

- Every wait helper now defaults to TestConstants.settleTimeout (30s), and
  the literal wait call sites below the floor were converted too — sixteen
  sites across ten files.
- TestTimingHygieneTests enforces it by scanning the test sources: wait
  defaults and wait call sites must be at least minimumSettleTimeout, and
  no test may assert an upper bound on elapsed wall-clock time (the
  assertion that started this, which cannot separate correct behaviour from
  a slow machine).
- Both rules waive per line with "test-timing-ok: <reason>", accepted on
  the line or in the comment block above it so the reason has room to be a
  sentence. One legitimate use so far: a NEGATIVE wait in NoiseCoverageTests
  asserting a promotion has *not* completed within 50ms, where a long
  deadline would only make the suite slow while still passing.

Injected production timeouts are deliberately not matched — the Noise
handshake timeouts are the behaviour under test, and short values are
correct there.

Verified the guard actually fails: a canary file with both banned shapes
was flagged with file and line, and the suite went green again once it was
removed. Full suite passes with all 18 cores saturated.

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

266 lines
8.5 KiB
Swift

//
// VoiceCaptureSessionTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
@MainActor
private final class StubPTTCapture: PTTCapturing {
var onFrames: (([Data]) -> Void)?
var stopResult: (url: URL?, encodedFrames: Int)
var startError: Error?
private(set) var startCount = 0
private(set) var cancelCount = 0
init(
stopResult: (url: URL?, encodedFrames: Int),
startError: Error? = nil
) {
self.stopResult = stopResult
self.startError = startError
}
func start(outputURL: URL) async throws {
startCount += 1
if let startError {
throw startError
}
}
func stop() -> (url: URL?, encodedFrames: Int) {
stopResult
}
func cancel() {
cancelCount += 1
}
}
private final class CaptureLeaseSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock()
private var _activationCalls: [Bool] = []
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
lock.withLock { _activationCalls.append(active) }
}
}
@MainActor
private final class GatedVoiceCaptureSession: VoiceCaptureSession {
let isLive = false
private let startError: Error?
private(set) var finishStarted = false
private(set) var cancelCount = 0
private(set) var panicCancelCount = 0
private var finishContinuation: CheckedContinuation<URL?, Never>?
init(startError: Error? = nil) {
self.startError = startError
}
func requestPermission() async -> Bool { true }
func start() async throws {
if let startError { throw startError }
}
func finish() async -> URL? {
finishStarted = true
return await withCheckedContinuation { continuation in
finishContinuation = continuation
}
}
func cancel() async {
cancelCount += 1
}
func panicCancelSynchronously() {
panicCancelCount += 1
}
func resolveFinish(with url: URL?) {
let continuation = finishContinuation
finishContinuation = nil
continuation?.resume(returning: url)
}
}
@MainActor
struct VoiceCaptureSessionTests {
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(condition(), sourceLocation: sourceLocation)
}
private func isRecording(_ state: VoiceRecordingViewModel.State) -> Bool {
if case .recording = state { return true }
return false
}
@Test func staleCaptureCallbackCannotInvalidateNewGeneration() {
let generations = PTTCaptureGeneration()
let old = generations.begin()
generations.invalidate()
let current = generations.begin()
#expect(!generations.invalidate(ifCurrent: old))
#expect(generations.isCurrent(current))
#expect(generations.invalidate(ifCurrent: current))
#expect(!generations.isCurrent(current))
}
@Test func coordinatorCancellationIsNotReportedAsAStartedCapture() async {
let capture = StubPTTCapture(
stopResult: (nil, 0),
startError: CancellationError()
)
let session = PTTLiveVoiceSession(
sendPacket: { _ in },
capture: capture
)
await #expect(throws: CancellationError.self) {
try await session.start()
}
}
@Test func interruptedShortCaptureIsCanceledEvenAfterLongHold() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("ptt-interrupted-test-\(UUID().uuidString).m4a")
_ = FileManager.default.createFile(atPath: url.path, contents: Data([0x00]))
defer { try? FileManager.default.removeItem(at: url) }
let capture = StubPTTCapture(stopResult: (url, 1))
var sentPackets: [Data] = []
var now = Date()
let session = PTTLiveVoiceSession(
sendPacket: { sentPackets.append($0) },
capture: capture,
now: { now },
burstID: Data(repeating: 0xA5, count: 8)
)
try await session.start()
now = now.addingTimeInterval(2)
let result = await session.finish()
#expect(result == nil)
#expect(!FileManager.default.fileExists(atPath: url.path))
let packet = try #require(sentPackets.last.flatMap(VoiceBurstPacket.decode))
guard case .canceled = packet.kind else {
Issue.record("Expected a canceled control packet for a subsecond interrupted capture")
return
}
}
@Test func droppingCaptureLeaseReturnsItsCoordinatorToken() async throws {
let rawSession = CaptureLeaseSession()
let coordinator = AudioSessionCoordinator(session: rawSession)
let token = try await coordinator.acquire(.capture) {}
var lease: PTTCaptureSessionLease? = PTTCaptureSessionLease(coordinator: coordinator)
lease?.install(token)
lease = nil
await coordinator.drain()
#expect(rawSession.activationCalls == [true, false])
}
@Test func rejectedNewHoldAndStaleFinalizeLeaveNewerGenerationIdle() async {
let oldSession = GatedVoiceCaptureSession()
let newSession = GatedVoiceCaptureSession(
startError: VoiceRecorder.RecorderError.recordingInProgress
)
var sessions: [GatedVoiceCaptureSession] = [oldSession, newSession]
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { sessions.removeFirst() }
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.finish(completion: { _ in })
await waitUntil { oldSession.finishStarted }
viewModel.start(shouldShow: true)
await waitUntil { newSession.cancelCount == 1 && viewModel.state == .idle }
#expect(viewModel.state == .idle)
// The older finalize now fails after the newer press has completed.
// It must not replace the newer generation's idle state with an alert.
oldSession.resolveFinish(with: nil)
for _ in 0..<20 {
await Task.yield()
}
#expect(viewModel.state == .idle)
}
@Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async {
let session = GatedVoiceCaptureSession()
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { session }
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.panicWipe()
#expect(session.panicCancelCount == 1)
#expect(viewModel.state == .idle)
#expect(!viewModel.isLiveStreaming)
}
@Test func panicInvalidatesARecordingAlreadyFinalizing() async throws {
let session = GatedVoiceCaptureSession()
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { session }
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-panic-\(UUID().uuidString).m4a")
try Data([0x01]).write(to: url)
var delivered = false
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.finish { _ in delivered = true }
await waitUntil { session.finishStarted }
viewModel.panicWipe()
session.resolveFinish(with: url)
await waitUntil {
!FileManager.default.fileExists(atPath: url.path)
}
#expect(!delivered)
#expect(viewModel.state == .idle)
}
@Test func liveSessionPanicStopsCaptureWithoutSendingControl() {
let capture = StubPTTCapture(stopResult: (nil, 0))
var sentPackets: [Data] = []
let session = PTTLiveVoiceSession(
sendPacket: { sentPackets.append($0) },
capture: capture
)
session.panicCancelSynchronously()
#expect(capture.cancelCount == 1)
#expect(sentPackets.isEmpty)
}
}