Archived
status
This commit is contained in:
1 parent
c9c25f86eb
commit
40aaf4fea7
56 files changed
+5683
-179
No files matched your search
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user