Files
bitchat/bitchatTests/VoiceNotePlaybackControllerTests.swift
T
jackandGitHub 820c933958 Harden PTT audio and the 1.7.1 release (#1423)
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.

Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
2026-07-10 14:04:09 +02:00

146 lines
5.5 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
}
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 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)
}
}