This commit is contained in:
snowspeeder committed 2026-08-31 19:50:46 -04:00
1 parent c9c25f86eb
commit 40aaf4fea7
56 files changed
+5683 -179

No files matched your search

+258
View File
@@ -0,0 +1,258 @@
import Foundation
import Testing
@testable import Voice
/// Tests for ConversationStateModel phase transitions, specifically verifying
/// that remote speech ending returns to listening (not thinking forever),
/// matching the current web reference (Finding 9).
@MainActor
struct ConversationStateTests {
@Test("remote speech ending returns to listening, not thinking")
func remoteSpeechEndsToListening() {
let model = ConversationStateModel()
model.start()
#expect(model.phase == .connecting)
// Simulate agent speaking (EMA: 0.04, 0.064 — both above 0.02 threshold).
model.tick(localAudioLevel: 0, remoteAudioLevel: 0.1)
model.tick(localAudioLevel: 0, remoteAudioLevel: 0.1)
#expect(model.phase == .speaking)
// Agent goes silent. EMA decays: 0.0384, 0.02304 (still > 0.02),
// then 0.013824 (< 0.02) → listening. Five zero ticks deterministically
// cross the threshold.
for _ in 0..<5 {
model.tick(localAudioLevel: 0, remoteAudioLevel: 0)
}
#expect(model.phase == .listening)
}
@Test("user speaking transitions to listening")
func userSpeakingToListening() {
let model = ConversationStateModel()
model.start()
// User speaks.
model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0)
model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0)
#expect(model.phase == .listening)
// User stops speaking.
model.tick(localAudioLevel: 0, remoteAudioLevel: 0)
model.tick(localAudioLevel: 0, remoteAudioLevel: 0)
#expect(model.phase == .listening)
}
@Test("agent speaking takes priority over user listening")
func agentSpeakingPriority() {
let model = ConversationStateModel()
model.start()
// Both speaking — agent wins.
model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0.1)
model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0.1)
#expect(model.phase == .speaking)
}
@Test("stop resets to idle")
func stopResetsToIdle() {
let model = ConversationStateModel()
model.start()
model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0)
#expect(model.phase == .listening)
model.stop()
#expect(model.phase == .idle)
}
@Test("thinking chip is not shown when returning to listening")
func noStaleThinkingChip() {
let model = ConversationStateModel()
model.start()
// Agent speaks then goes silent.
model.tick(localAudioLevel: 0, remoteAudioLevel: 0.1)
model.tick(localAudioLevel: 0, remoteAudioLevel: 0.1)
#expect(model.phase == .speaking)
// Silence — should go to listening, not thinking. Five zero ticks
// deterministically cross the EMA threshold below agentThreshold.
for _ in 0..<5 {
model.tick(localAudioLevel: 0, remoteAudioLevel: 0)
}
#expect(model.phase == .listening)
#expect(!model.isThinkingChipVisible)
}
// MARK: - Interruption begin mute decision (Iteration 08 Item 1)
/// Pure decision seam test: verifies that the interruption-begin mute
/// decision uses user intent (not a pre-await isMuted snapshot), so the
/// begin A → end A/unmute → begin B sequence finishes muted.
@Test("interruption begin mutes when user did not intentionally mute")
func interruptionBeginShouldMuteUserNotMuted() {
let session = makeSessionForDecisionTest()
// User has not muted (userWantsMicMuted == false), text mode off, ready.
#expect(session.interruptionBeginShouldMute(
userWantsMicMuted: false,
textMode: false,
state: .ready
))
}
@Test("interruption begin does not mute when user intentionally muted")
func interruptionBeginShouldNotMuteUserMuted() {
let session = makeSessionForDecisionTest()
// User explicitly muted (userWantsMicMuted == true): hardware is
// already off, no need for the interruption to mute again.
#expect(!session.interruptionBeginShouldMute(
userWantsMicMuted: true,
textMode: false,
state: .ready
))
}
@Test("interruption begin does not mute in text mode")
func interruptionBeginShouldNotMuteTextMode() {
let session = makeSessionForDecisionTest()
// Text mode invariant: mic must stay off by design, not by interruption.
#expect(!session.interruptionBeginShouldMute(
userWantsMicMuted: false,
textMode: true,
state: .ready
))
}
@Test("interruption begin does not mute when session not ready")
func interruptionBeginShouldNotMuteNotReady() {
let session = makeSessionForDecisionTest()
// Session is not in a state where the mic would be active.
#expect(!session.interruptionBeginShouldMute(
userWantsMicMuted: false,
textMode: false,
state: .idle
))
#expect(!session.interruptionBeginShouldMute(
userWantsMicMuted: false,
textMode: false,
state: .connecting
))
}
/// Simulates the begin A → end A/unmute → begin B transient-state case.
/// After A's unmute completes (userWantsMicMuted cleared by successful
/// enable), B's decision sees userWantsMicMuted == false and mutes.
@Test("begin A end A unmute then begin B finishes muted")
func beginEndBeginFinishesMuted() {
let session = makeSessionForDecisionTest()
// Phase 1: Begin A. User did not mute, so A should mute.
#expect(session.interruptionBeginShouldMute(
userWantsMicMuted: false,
textMode: false,
state: .ready
))
// After A's mute succeeds: isMuted = true (interruption-owned),
// userWantsMicMuted remains false (user didn't mute).
// Phase 2: End A with shouldResume. The unmute succeeds, which calls
// enableMicrophone() semantics: isMuted = false, userWantsMicMuted = false.
// (In the real code, .ended sets isMuted = false directly; userWantsMicMuted
// was never set to true because the user didn't mute.)
// Phase 3: Begin B arrives while A's unmute is in flight. After awaiting
// the predecessor (A's unmute), B decides using current state:
// userWantsMicMuted == false, textMode == false, state == .ready.
#expect(session.interruptionBeginShouldMute(
userWantsMicMuted: false,
textMode: false,
state: .ready
))
// B mutes → final state is muted during interruption B. ✓
}
// MARK: - Room callback gating (Iteration 09 Item 1)
/// Pure predicate test: verifies that direct RoomDelegate callbacks that
/// mutate published ephemeral fields are accepted only for active lifecycle
/// states and rejected during teardown or terminal states.
@Test("room callback accepted for active states when not tearing down")
func roomCallbackAcceptedForActiveStates() {
let session = makeSessionForDecisionTest()
#expect(session.shouldAcceptRoomCallback(state: .connecting, isTearingDown: false))
#expect(session.shouldAcceptRoomCallback(state: .waitingAgent, isTearingDown: false))
#expect(session.shouldAcceptRoomCallback(state: .ready, isTearingDown: false))
}
@Test("room callback rejected for idle and failed states")
func roomCallbackRejectedForInactiveStates() {
let session = makeSessionForDecisionTest()
#expect(!session.shouldAcceptRoomCallback(state: .idle, isTearingDown: false))
#expect(!session.shouldAcceptRoomCallback(state: .failed("err"), isTearingDown: false))
}
@Test("room callback rejected during teardown even for active states")
func roomCallbackRejectedDuringTeardown() {
let session = makeSessionForDecisionTest()
// Even if the state were still .ready (before the final reset),
// isTearingDown == true must reject all callbacks.
#expect(!session.shouldAcceptRoomCallback(state: .ready, isTearingDown: true))
#expect(!session.shouldAcceptRoomCallback(state: .connecting, isTearingDown: true))
#expect(!session.shouldAcceptRoomCallback(state: .waitingAgent, isTearingDown: true))
}
@Test("room callback rejected for tearingDown state")
func roomCallbackRejectedForTearingDownState() {
let session = makeSessionForDecisionTest()
#expect(!session.shouldAcceptRoomCallback(state: .tearingDown, isTearingDown: false))
#expect(!session.shouldAcceptRoomCallback(state: .tearingDown, isTearingDown: true))
}
/// Simulates the exact scenario from Finding 1: failure teardown clears
/// ephemeral fields, then an old-room participantDidConnect callback arrives
/// during the teardownRoom() await. The callback must be rejected because
/// isTearingDown is true. Even if it somehow passed, the final resetEphemeralFields()
/// before .failed publication erases any repopulated values.
@Test("agent callback during failure teardown is rejected")
func agentCallbackDuringFailureTeardownRejected() {
let session = makeSessionForDecisionTest()
// Simulate: teardownForFailure has set state = .tearingDown and
// isTearingDown = true (via teardownRoom). An old-room participantDidConnect
// callback arrives during the room.disconnect() await.
let shouldAccept = session.shouldAcceptRoomCallback(
state: .tearingDown,
isTearingDown: true
)
#expect(!shouldAccept)
// Even if we model the state as still .ready (edge case where the
// state hasn't been updated yet), isTearingDown == true rejects it.
let shouldAcceptEdge = session.shouldAcceptRoomCallback(
state: .ready,
isTearingDown: true
)
#expect(!shouldAcceptEdge)
}
/// Simulates a reconnecting callback arriving during teardown: must be rejected.
@Test("reconnecting callback during teardown is rejected")
func reconnectingCallbackDuringTeardownRejected() {
let session = makeSessionForDecisionTest()
// (.connected → .reconnecting) would set isReconnecting = true,
// but the gate rejects it because isTearingDown == true.
#expect(!session.shouldAcceptRoomCallback(state: .ready, isTearingDown: true))
}
private func makeSessionForDecisionTest() -> VoiceSession {
guard case .success(let config) = ServerConfig.parse("https://192.168.1.100") else {
fatalError("Invalid test server URL")
}
return VoiceSession(
config: config,
tokenClient: TokenClient(config: config),
settings: SettingsStore()
)
}
}
+125
View File
@@ -0,0 +1,125 @@
import Foundation
import Testing
@testable import Voice
/// Minimal HTTP/1.1 stub built on BSD sockets that answers every request with a
/// successful JSON object body (mirroring the real media server's
/// `{"ok": true, ...}` shape). Used to prove MediaAPI no-response POSTs succeed
/// against an object body instead of throwing decodeFailed.
@MainActor
final class MediaAPIStubServer {
private(set) var url: URL!
nonisolated(unsafe) private var serverFD: Int32 = -1
nonisolated(unsafe) private var acceptThread: Thread?
nonisolated(unsafe) private var running = false
nonisolated(unsafe) private var lastPath: String?
func start() throws {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { throw MediaAPIError.decodeFailed }
var yes: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(0).bigEndian
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bindResult == 0 else { close(fd); throw MediaAPIError.decodeFailed }
guard listen(fd, 8) == 0 else { close(fd); throw MediaAPIError.decodeFailed }
var bound = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
withUnsafeMutablePointer(to: &bound) { ptr in
_ = ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
getsockname(fd, sa, &len)
}
}
let port = Int(UInt16(bigEndian: bound.sin_port))
serverFD = fd
running = true
url = URL(string: "http://127.0.0.1:\(port)")!
let thread = Thread { [weak self] in
self?.acceptLoop()
}
acceptThread = thread
thread.start()
}
func stop() {
running = false
if serverFD >= 0 { close(serverFD) }
serverFD = -1
acceptThread?.cancel()
}
var lastRequestPath: String? { lastPath }
nonisolated private func acceptLoop() {
while running {
var clientAddr = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
accept(serverFD, sa, &len)
}
}
guard clientFD >= 0 else { continue }
handleClient(clientFD)
}
}
nonisolated private func handleClient(_ clientFD: Int32) {
defer { close(clientFD) }
var buffer = [UInt8](repeating: 0, count: 4096)
let bytesRead = read(clientFD, &buffer, buffer.count)
guard bytesRead > 0 else { return }
guard let request = String(bytes: buffer[0..<bytesRead], encoding: .utf8) else { return }
var path = ""
if let firstLine = request.split(separator: "\r\n").first {
let parts = firstLine.split(separator: " ")
if parts.count >= 2 { path = String(parts[1]) }
}
lastPath = path
let body = #"{"ok": true, "room": "test", "desired": "stopped"}"#
let response = """
HTTP/1.1 200 OK\r
Content-Type: application/json\r
Content-Length: \(body.utf8.count)\r
Connection: close\r
\(body)
"""
_ = response.withCString { cstr in
write(clientFD, cstr, response.utf8.count)
}
}
}
@MainActor
struct MediaAPITests {
@Test("successful JSON object response does not throw decodeFailed")
func successfulObjectResponse() async throws {
let stub = MediaAPIStubServer()
try stub.start()
defer { stub.stop() }
// Bypass the https-only parse path: build the config directly.
let base = stub.url!
let config = ServerConfig(baseURL: base, livekitURL: base)
let api = MediaAPI(config: config)
try await api.control(room: "test", action: "pause")
try await api.report(room: "test", state: "paused", trackID: nil, position: 0, duration: nil)
try await api.play(room: "test", videoID: nil, title: nil, channel: nil, trackID: nil, startPaused: nil)
#expect(stub.lastRequestPath == "/media/api/play")
}
}
+124
View File
@@ -0,0 +1,124 @@
import Foundation
import Testing
@testable import Voice
@MainActor
struct MediaReconcileTests {
private func makeController() -> MediaPlayerController {
let config = ServerConfig(
baseURL: URL(string: "http://127.0.0.1:9")!,
livekitURL: URL(string: "ws://127.0.0.1:9")!
)
let api = MediaAPI(config: config)
return MediaPlayerController(api: api, settings: SettingsStore())
}
private func makeState(
desired: String? = "stopped",
trackID: String? = nil,
audioURL: String? = nil
) -> MediaState {
let track: MediaTrack? = trackID != nil ? MediaTrack(
id: trackID!,
kind: nil,
videoID: nil,
title: nil,
channel: nil,
duration: nil,
status: nil,
error: nil,
audioURL: audioURL,
artURL: nil
) : nil
return MediaState(
room: "default",
track: track,
desired: desired,
reported: nil,
seekHint: nil,
choices: [],
pending: [],
version: 1
)
}
@Test("reconcile updates published desired to playing")
func reconcileDesiredPlaying() {
let controller = makeController()
#expect(controller.desired == "stopped")
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
}
@Test("reconcile updates published desired to paused")
func reconcileDesiredPaused() {
let controller = makeController()
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
controller.reconcile(makeState(desired: "paused"))
#expect(controller.desired == "paused")
}
@Test("reconcile updates published desired to stopped")
func reconcileDesiredStopped() {
let controller = makeController()
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
controller.reconcile(makeState(desired: "stopped"))
#expect(controller.desired == "stopped")
}
@Test("reconcile with nil desired defaults to stopped")
func reconcileNilDesiredDefaultsToStopped() {
let controller = makeController()
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
controller.reconcile(makeState(desired: nil))
#expect(controller.desired == "stopped")
}
@Test("late audio: desired=playing before audio_url, then audio_url arrives")
func lateAudioReconciliation() {
let controller = makeController()
// Phase 1: server says playing but the track has no audio_url yet
// (still downloading). The source is not wired, so play cannot start.
let stateNoURL = makeState(desired: "playing", trackID: "track-1", audioURL: nil)
controller.reconcile(stateNoURL)
#expect(controller.desired == "playing")
#expect(controller.track?.id == "track-1")
// Player rate should still be zero because no source was wired.
#expect(controller.player.rate.isZero)
// Phase 2: the same track now has an audio_url. The reconcile path
// wires the source (wireSource) and then applyDesiredState retries
// play because desired is still "playing" and rate is still zero.
let stateWithURL = makeState(desired: "playing", trackID: "track-1", audioURL: "/stream/track-1.mp3")
controller.reconcile(stateWithURL)
#expect(controller.desired == "playing")
// The source should now be wired (loadedAudioPath is set internally).
// We can't directly observe loadedAudioPath, but we can verify the
// player has a current item after wireSource ran.
#expect(controller.player.currentItem != nil)
}
@Test("startDefaultPolling restarts after endConversation keeps scope")
func pollingRestartsAfterEndConversation() async {
let controller = makeController()
controller.startDefaultPolling()
await controller.endConversation()
controller.startDefaultPolling()
try? await Task.sleep(for: .milliseconds(50))
}
@Test("togglePlayPause without a loaded track is a no-op")
func toggleWithoutTrack() async {
let controller = makeController()
await controller.togglePlayPause()
#expect(controller.track == nil)
}
}
+126
View File
@@ -0,0 +1,126 @@
import Foundation
import Testing
@testable import Voice
/// Focused tests for PinnedMediaResourceLoader.parseTotalLength, covering
/// 200, 206 with known total, 206 with unknown total (*), and malformed
/// Content-Range headers (Finding 1 / 11).
struct PinnedMediaResourceLoaderTests {
private func makeResponse(status: Int, contentRange: String? = nil, expectedLength: Int64 = -1) -> HTTPURLResponse {
var headers: [String: String] = [:]
if let contentRange {
headers["Content-Range"] = contentRange
}
return HTTPURLResponse(
url: URL(string: "https://example.com/media/test.mp3")!,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
}
@Test("206 with known total parses Content-Range total")
func parse206KnownTotal() {
let response = makeResponse(status: 206, contentRange: "bytes 0-1023/4096", expectedLength: 1024)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == 4096)
}
@Test("206 with unknown total (*) returns nil")
func parse206UnknownTotal() {
let response = makeResponse(status: 206, contentRange: "bytes 0-1023/*", expectedLength: 1024)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("200 response returns nil (expectedContentLength is already the full size)")
func parse200ReturnsNil() {
let response = makeResponse(status: 200, expectedLength: 8192)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("206 without Content-Range header returns nil")
func parse206NoContentRange() {
let response = makeResponse(status: 206, expectedLength: 512)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("malformed Content-Range returns nil")
func parseMalformedContentRange() {
let response = makeResponse(status: 206, contentRange: "garbage", expectedLength: 100)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("206 with large total parses correctly")
func parseLargeTotal() {
let response = makeResponse(status: 206, contentRange: "bytes 1048576-2097151/1073741824", expectedLength: 1048576)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == 1_073_741_824)
}
@Test("streamURL constructs endpoint without downloading")
func streamURLConstruction() {
let config = ServerConfig(
baseURL: URL(string: "https://192.168.86.2:9178")!,
livekitURL: URL(string: "ws://192.168.86.2:7880")!
)
let api = MediaAPI(config: config)
let url = api.streamURL(trackID: "track-123")
// streamURL builds under the /media/api base (MediaAPI.baseURL).
#expect(url.absoluteString == "https://192.168.86.2:9178/media/api/stream/track-123")
}
// MARK: Rewrite / Resolve round-trip (Item 7)
@Test("rewrite maps https URL to pinned-media scheme and resolve inverts it")
func rewriteResolveRoundTrip() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let original = URL(string: "https://192.168.86.2:9178/media/track-123.mp3")!
let pinned = loader.rewrite(original)
#expect(pinned.scheme == "pinned-media")
#expect(pinned.absoluteString == "pinned-media://192.168.86.2:9178/media/track-123.mp3")
let resolved = loader.resolve(pinned)
#expect(resolved.absoluteString == original.absoluteString)
}
@Test("rewrite maps path-only URL using base host and port")
func rewritePathOnly() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let pathURL = URL(string: "/media/track-456.mp3")!
let pinned = loader.rewrite(pathURL)
#expect(pinned.scheme == "pinned-media")
#expect(pinned.absoluteString == "pinned-media://192.168.86.2:9178/media/track-456.mp3")
}
@Test("resolve leaves non-pinned URLs unchanged")
func resolveNonPinned() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let https = URL(string: "https://example.com/foo.mp3")!
#expect(loader.resolve(https) == https)
}
// MARK: Asset lifecycle (Item 7)
@Test("assetWithPinnedURL creates asset and removeLoader clears mapping")
func assetLifecycle() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let pinned = loader.rewrite(URL(string: "https://192.168.86.2:9178/media/test.mp3")!)
let asset = loader.assetWithPinnedURL(pinned)
// The asset should have a valid URL matching the pinned scheme.
#expect(asset.url.scheme == PinnedMediaResourceLoader.scheme)
// Remove the loader mapping — subsequent calls should not crash.
loader.removeLoader(asset)
}
@Test("removeLoader is idempotent (safe to call twice)")
func removeLoaderIdempotent() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let pinned = loader.rewrite(URL(string: "https://192.168.86.2:9178/media/test.mp3")!)
let asset = loader.assetWithPinnedURL(pinned)
loader.removeLoader(asset)
// Second call should be a no-op, not a crash.
loader.removeLoader(asset)
}
}
@@ -0,0 +1,146 @@
import Foundation
import Testing
@testable import Voice
/// Pure coordinator tests for the server replacement worker loop semantics
/// (Iteration 08 Item 2). These verify the latest-wins coalescing and
/// drain-until-empty behavior that the ConversationView worker implements,
/// without requiring a live SwiftUI view or real session disconnect.
///
/// The production code uses `@State` in a SwiftUI view; this test models the
/// same logic as a pure function to verify A→B→C semantics where C arrives
/// while B is disconnecting.
struct ServerReplacementCoordinatorTests {
/// Models the pending-URL coalescing and worker-loop drain semantics.
/// Returns the sequence of URLs the worker would process in order.
private static func drainPending(
initialActive: String,
applies: [String],
disconnectDurations: [Int] = []
) -> [String] {
var activeURL = initialActive
var pending: String? = nil
var processed: [String] = []
var isReplacing = false
for url in applies {
// Every accepted Apply writes pendingServerURL (latest wins).
pending = url
// Start the worker if not already active.
guard !isReplacing else { continue }
isReplacing = true
// Worker loop: drain until no pending value remains.
while let url = pending {
pending = nil
guard url != activeURL else { continue }
// Simulate disconnect (no-op in pure test).
activeURL = url
processed.append(url)
}
isReplacing = false
}
return processed
}
@Test("single apply processes one URL")
func singleApply() {
let result = Self.drainPending(
initialActive: "https://server-a",
applies: ["https://server-b"]
)
#expect(result == ["https://server-b"])
}
@Test("rapid A to B to C coalesces to final C")
func rapidABC() {
// All three Applies arrive before the worker starts (isReplacing
// is false for each, but the first one starts the worker which
// drains all pending values).
// In practice: Apply A sets pending=A, starts worker. Worker
// consumes A. Apply B sets pending=B during disconnect. Worker
// loops, consumes B. Apply C sets pending=C during B's disconnect.
// Worker loops, consumes C. Final state: activeServerURL == C.
let result = Self.drainPending(
initialActive: "https://server-a",
applies: ["https://server-b", "https://server-c", "https://server-d"]
)
// The worker processes each URL in order as it drains.
#expect(result == ["https://server-b", "https://server-c", "https://server-d"])
}
@Test("reapplying same URL is skipped")
func sameURLSkipped() {
let result = Self.drainPending(
initialActive: "https://server-a",
applies: ["https://server-a"]
)
#expect(result.isEmpty)
}
@Test("latest wins when multiple applies during replacement")
func latestWinsDuringReplacement() {
// Simulates: Apply B starts worker. During B's disconnect,
// Apply C arrives (pending=C). Worker finishes B, loops,
// sees pending=C, processes C. Final active = C.
var activeURL = "https://server-a"
var pending: String? = nil
// Apply B
pending = "https://server-b"
// Worker starts, consumes B
if let url = pending {
pending = nil
activeURL = url
}
// During B's disconnect, Apply C arrives
pending = "https://server-c"
// Worker loops: sees pending=C, processes it
while let url = pending {
pending = nil
guard url != activeURL else { continue }
activeURL = url
}
#expect(activeURL == "https://server-c")
}
@Test("A to B to C with C arriving during B disconnect ends on C")
func abcWithCDuringBDisconnect() {
var activeURL = "https://server-a"
var pending: String? = nil
var processed: [String] = []
// Apply B (A is the initial active URL)
pending = "https://server-b"
// Worker loop iteration 1: consume B
while let url = pending {
pending = nil
guard url != activeURL else { continue }
// Simulate disconnect of current session (A)
activeURL = url
processed.append(url)
// During B's disconnect, Apply C arrives
pending = "https://server-c"
}
// Worker loop iteration 2: consume C
while let url = pending {
pending = nil
guard url != activeURL else { continue }
activeURL = url
processed.append(url)
}
#expect(activeURL == "https://server-c")
#expect(processed == ["https://server-b", "https://server-c"])
}
}
+420
View File
@@ -0,0 +1,420 @@
import Foundation
import Testing
@testable import Voice
@MainActor
struct TranscriptDedupeTests {
private final class ClockBox {
var time: Date
init(time: Date) { self.time = time }
}
/// Returns a handler wired to a shared clock box, plus the box itself so
/// tests can advance time deterministically.
private func freshHandler() -> (handler: TranscriptHandler, clock: ClockBox) {
// Isolate from any persisted entries left over from other runs.
UserDefaults.standard.removeObject(forKey: TranscriptHistory.key)
let history = TranscriptHistory()
let handler = TranscriptHandler(history: history)
let box = ClockBox(time: Date(timeIntervalSince1970: 1_000_000))
handler.now = { box.time }
return (handler, box)
}
private func advance(_ box: ClockBox, _ seconds: TimeInterval) {
box.time = box.time.addingTimeInterval(seconds)
}
// MARK: Cross-source dedupe (opposite source within window)
@Test("primary final then identical data final is suppressed (primary→data)")
func primaryThenDataSuppressed() {
let (handler, _) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
#expect(handler.events.count == 1)
// Data topic delivers the same final within the window → suppressed.
handler.receiveDataTranscript(role: .agent, text: "good morning")
#expect(handler.events.count == 1)
#expect(handler.events[0].isFinal)
#expect(handler.events[0].text == "good morning")
}
@Test("data final then identical primary final is suppressed (data→primary)")
func dataThenPrimarySuppressed() {
let (handler, _) = freshHandler()
handler.receiveDataTranscript(role: .agent, text: "good morning")
#expect(handler.events.count == 1)
// Primary transcription delivers the same final within the window → suppressed.
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
#expect(handler.events.count == 1)
#expect(handler.events[0].isFinal)
#expect(handler.events[0].text == "good morning")
}
// MARK: Same-source repeats are never suppressed
@Test("same-source primary repeat within window is NOT suppressed")
func sameSourcePrimaryRepeatAllowed() {
let (handler, _) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .user, text: "hello", isFinal: true)
#expect(handler.events.count == 1)
// Same source (primary), same text, within window → allowed.
handler.receiveSegment(id: "seg-2", role: .user, text: "hello", isFinal: true)
#expect(handler.events.count == 2)
}
@Test("same-source data repeat within window is NOT suppressed")
func sameSourceDataRepeatAllowed() {
let (handler, _) = freshHandler()
handler.receiveDataTranscript(role: .user, text: "hello")
#expect(handler.events.count == 1)
// Same source (data), same text, within window → allowed.
handler.receiveDataTranscript(role: .user, text: "hello")
#expect(handler.events.count == 2)
}
// MARK: Window expiry
@Test("opposite-source final after window expiry is NOT suppressed")
func oppositeSourceAfterWindowAllowed() {
let (handler, clock) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
#expect(handler.events.count == 1)
// Advance past the 5-second dedupe window.
advance(clock, 6)
// Data topic delivers the same final after the window → allowed.
handler.receiveDataTranscript(role: .agent, text: "good morning")
#expect(handler.events.count == 2)
}
@Test("opposite-source final exactly at window boundary is NOT suppressed")
func oppositeSourceAtBoundaryAllowed() {
let (handler, clock) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
// Advance exactly to the window boundary (5.0s). The check is
// `timeIntervalSince < dedupeWindow`, so at exactly 5.0 it's NOT suppressed.
advance(clock, 5.0)
handler.receiveDataTranscript(role: .agent, text: "good morning")
#expect(handler.events.count == 2)
}
@Test("opposite-source final just before window boundary is suppressed")
func oppositeSourceJustBeforeBoundarySuppressed() {
let (handler, clock) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
// Advance to 4.9s — still within the window.
advance(clock, 4.9)
handler.receiveDataTranscript(role: .agent, text: "good morning")
#expect(handler.events.count == 1)
}
// MARK: Interleaved roles
@Test("interleaved roles do not interfere with cross-source dedupe")
func interleavedRolesDedupe() {
let (handler, _) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .user, text: "hi there", isFinal: false)
handler.receiveDataTranscript(role: .agent, text: "hello")
handler.receiveDataTranscript(role: .user, text: "hi there")
// User partial replaced in place; agent final appended. Two events total.
#expect(handler.events.count == 2)
#expect(handler.events[0].isFinal)
#expect(handler.events[0].text == "hi there")
#expect(handler.events[1].isFinal)
#expect(handler.events[1].text == "hello")
}
// MARK: Partial replacement
@Test("data final matching open partial replaces it instead of duplicating")
func dataFinalReplacesMatchingPartial() {
let (handler, _) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .user, text: "hello world", isFinal: false)
handler.receiveDataTranscript(role: .user, text: "hello world")
#expect(handler.events.count == 1)
#expect(handler.events[0].isFinal)
#expect(handler.events[0].id == "seg-1")
}
@Test("data final with differing open partial replaces the partial in place")
func differentPartialReplaced() {
let (handler, _) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .user, text: "first utterance", isFinal: false)
handler.receiveDataTranscript(role: .user, text: "second utterance")
#expect(handler.events.count == 1)
#expect(handler.events[0].isFinal)
#expect(handler.events[0].text == "second utterance")
}
@Test("data final with no open partial appends a new final row")
func dataFinalWithoutPartialAppends() {
let (handler, _) = freshHandler()
handler.receiveDataTranscript(role: .agent, text: "surprise final")
#expect(handler.events.count == 1)
#expect(handler.events[0].isFinal)
#expect(handler.events[0].text == "surprise final")
}
// MARK: Max events trimming
@Test("canonical collection trims to max 50 finals")
func trimsToMaxEvents() {
let (handler, _) = freshHandler()
// Add 55 distinct finals (different text so no dedupe).
for i in 0..<55 {
handler.receiveSegment(id: "seg-\(i)", role: .agent, text: "utterance \(i)", isFinal: true)
}
#expect(handler.events.count == TranscriptHandler.maxEvents)
// The oldest finals are dropped; the newest remain.
#expect(handler.events.last?.text == "utterance 54")
}
// MARK: Clear
@Test("clear removes events, dedupe state, and persistence")
func clearResetsState() {
let (handler, _) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .user, text: "test", isFinal: true)
#expect(handler.events.count == 1)
handler.clearAll()
#expect(handler.events.isEmpty)
// After clear, the same text can be recorded again (dedupe state reset).
handler.receiveSegment(id: "seg-2", role: .user, text: "test", isFinal: true)
#expect(handler.events.count == 1)
}
// MARK: Restoration from persisted history
@Test("handler initializes canonical events from persisted history")
func restoresFromHistory() {
UserDefaults.standard.removeObject(forKey: TranscriptHistory.key)
let history = TranscriptHistory()
// Simulate a prior session's persisted finals.
history.sync(finals: [
(role: "user", text: "old question", timestamp: Date(timeIntervalSince1970: 900_000)),
(role: "agent", text: "old answer", timestamp: Date(timeIntervalSince1970: 900_005)),
])
let handler = TranscriptHandler(history: history)
#expect(handler.events.count == 2)
#expect(handler.events[0].text == "old question")
#expect(handler.events[0].role == .user)
#expect(handler.events[1].text == "old answer")
#expect(handler.events[1].role == .agent)
// A new live event appends after the restored entries.
handler.receiveSegment(id: "seg-1", role: .user, text: "new question", isFinal: true)
#expect(handler.events.count == 3)
#expect(handler.events.last?.text == "new question")
}
// MARK: Live append after restoration
@Test("live finals append after restored entries in canonical collection")
func liveAppendAfterRestore() {
UserDefaults.standard.removeObject(forKey: TranscriptHistory.key)
let history = TranscriptHistory()
history.sync(finals: [
(role: "agent", text: "restored line", timestamp: Date(timeIntervalSince1970: 800_000)),
])
let handler = TranscriptHandler(history: history)
#expect(handler.events.count == 1)
// Live partial then final.
handler.receiveSegment(id: "seg-1", role: .user, text: "live partial", isFinal: false)
#expect(handler.events.count == 2)
handler.receiveSegment(id: "seg-1", role: .user, text: "live final", isFinal: true)
#expect(handler.events.count == 2)
#expect(handler.events.last?.text == "live final")
#expect(handler.events.last?.isFinal == true)
}
// MARK: Persistence sync
@Test("persistence mirrors canonical finals after each change")
func persistenceMirrorsCanonical() {
let (handler, _) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .user, text: "persist me", isFinal: true)
// The history store should now contain exactly the canonical finals.
#expect(handler.history.entries.count == 1)
#expect(handler.history.entries[0].text == "persist me")
#expect(handler.history.entries[0].role == "user")
}
@Test("cross-source dedupe does not drop the row from persistence")
func dedupeDoesNotDropPersistence() {
let (handler, _) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .agent, text: "deduped line", isFinal: true)
// Data copy is suppressed — still one canonical event.
handler.receiveDataTranscript(role: .agent, text: "deduped line")
#expect(handler.events.count == 1)
#expect(handler.history.entries.count == 1)
}
// MARK: Consume-on-suppress (Iteration 06 Item 2)
@Test("primary final, data duplicate suppressed, second data final with same text accepted")
func consumeOnSuppressAllowsSecondSameSource() {
let (handler, _) = freshHandler()
// Step 1: Primary delivers "hello".
handler.receiveSegment(id: "seg-1", role: .user, text: "hello", isFinal: true)
#expect(handler.events.count == 1)
// Step 2: Data delivers the same "hello" within window → suppressed.
handler.receiveDataTranscript(role: .user, text: "hello")
#expect(handler.events.count == 1)
// Step 3: Data delivers a *second* legitimate "hello" (same source as
// step 2). The consume-on-suppress should have updated the record to
// .data, so this same-source repeat is NOT suppressed.
handler.receiveDataTranscript(role: .user, text: "hello")
#expect(handler.events.count == 2)
}
@Test("data final, primary duplicate suppressed, second primary final with same text accepted")
func consumeOnSuppressAllowsSecondPrimary() {
let (handler, _) = freshHandler()
// Step 1: Data delivers "world".
handler.receiveDataTranscript(role: .agent, text: "world")
#expect(handler.events.count == 1)
// Step 2: Primary delivers the same "world" within window → suppressed.
handler.receiveSegment(id: "seg-1", role: .agent, text: "world", isFinal: true)
#expect(handler.events.count == 1)
// Step 3: Primary delivers a *second* legitimate "world" (same source
// as step 2). The consume-on-suppress should have updated the record to
// .primary, so this same-source repeat is NOT suppressed.
handler.receiveSegment(id: "seg-2", role: .agent, text: "world", isFinal: true)
#expect(handler.events.count == 2)
}
@Test("same-source immediate repeats still accepted after consume")
func sameSourceRepeatsAfterConsume() {
let (handler, _) = freshHandler()
// Primary "hi" → recorded as .primary.
handler.receiveSegment(id: "seg-1", role: .user, text: "hi", isFinal: true)
#expect(handler.events.count == 1)
// Data "hi" → suppressed (opposite source), record consumed (removed).
handler.receiveDataTranscript(role: .user, text: "hi")
#expect(handler.events.count == 1)
// Primary "hi" again → no record exists (was consumed), so this is a
// new pair and is accepted.
handler.receiveSegment(id: "seg-2", role: .user, text: "hi", isFinal: true)
#expect(handler.events.count == 2)
// Data "hi" again → suppressed (opposite source of the new record).
handler.receiveDataTranscript(role: .user, text: "hi")
#expect(handler.events.count == 2)
}
@Test("persistence remains exact after consume-on-suppress")
func persistenceExactAfterConsume() {
let (handler, _) = freshHandler()
handler.receiveSegment(id: "seg-1", role: .user, text: "exact", isFinal: true)
// Data duplicate suppressed.
handler.receiveDataTranscript(role: .user, text: "exact")
#expect(handler.events.count == 1)
#expect(handler.history.entries.count == 1)
// Second data final accepted.
handler.receiveDataTranscript(role: .user, text: "exact")
#expect(handler.events.count == 2)
#expect(handler.history.entries.count == 2)
}
// MARK: Session start preserves history (Iteration 06 Item 1)
@Test("starting a new session preserves restored history in canonical collection")
func newSessionPreservesHistory() {
UserDefaults.standard.removeObject(forKey: TranscriptHistory.key)
let history = TranscriptHistory()
history.sync(finals: [
(role: "user", text: "prior question", timestamp: Date(timeIntervalSince1970: 800_000)),
(role: "agent", text: "prior answer", timestamp: Date(timeIntervalSince1970: 800_005)),
])
// Simulate what happens when a new VoiceSession is created: the handler
// initializes from persisted history. Starting a session does NOT call
// clearAll() — it preserves the restored entries.
let handler = TranscriptHandler(history: history)
#expect(handler.events.count == 2)
#expect(handler.events[0].text == "prior question")
#expect(handler.events[1].text == "prior answer")
// A new live event appends after the restored entries.
handler.receiveSegment(id: "seg-1", role: .user, text: "new utterance", isFinal: true)
#expect(handler.events.count == 3)
#expect(handler.events[0].text == "prior question")
#expect(handler.events.last?.text == "new utterance")
}
// MARK: Repeated source pairs (Iteration 07 Item 3)
@Test("P1,D1,P2,D2 produces exactly two canonical and persisted rows")
func primaryDataPrimaryDataPairs() {
let (handler, _) = freshHandler()
// P1: Primary delivers "hello".
handler.receiveSegment(id: "seg-p1", role: .user, text: "hello", isFinal: true)
#expect(handler.events.count == 1)
// D1: Data delivers the same "hello" → suppressed (opposite source), record consumed.
handler.receiveDataTranscript(role: .user, text: "hello")
#expect(handler.events.count == 1)
// P2: Primary delivers a *second* legitimate "hello". No record exists
// (was consumed by D1 suppression), so this is accepted as a new pair.
handler.receiveSegment(id: "seg-p2", role: .user, text: "hello", isFinal: true)
#expect(handler.events.count == 2)
// D2: Data delivers the same "hello" again → suppressed (opposite source of P2).
handler.receiveDataTranscript(role: .user, text: "hello")
#expect(handler.events.count == 2)
// Persistence mirrors the canonical collection exactly.
#expect(handler.history.entries.count == 2)
}
@Test("D1,P1,D2,P2 produces exactly two canonical and persisted rows")
func dataPrimaryDataPrimaryPairs() {
let (handler, _) = freshHandler()
// D1: Data delivers "hello".
handler.receiveDataTranscript(role: .user, text: "hello")
#expect(handler.events.count == 1)
// P1: Primary delivers the same "hello" → suppressed (opposite source), record consumed.
handler.receiveSegment(id: "seg-p1", role: .user, text: "hello", isFinal: true)
#expect(handler.events.count == 1)
// D2: Data delivers a *second* legitimate "hello". No record exists
// (was consumed by P1 suppression), so this is accepted as a new pair.
handler.receiveDataTranscript(role: .user, text: "hello")
#expect(handler.events.count == 2)
// P2: Primary delivers the same "hello" again → suppressed (opposite source of D2).
handler.receiveSegment(id: "seg-p2", role: .user, text: "hello", isFinal: true)
#expect(handler.events.count == 2)
// Persistence mirrors the canonical collection exactly.
#expect(handler.history.entries.count == 2)
}
}