feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s
Rebuild & Deploy / rebuild (push) Failing after 23s
This commit is contained in:
commit
d735ee72fa
102 files changed
+13077
-258
No files matched your search
@@ -0,0 +1,182 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Voice
|
||||
|
||||
private final class AuthURLProtocol: URLProtocol {
|
||||
static var handler: ((URLRequest) throws -> (Int, Data))?
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
do {
|
||||
let (status, data) = try Self.handler!(request)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct AuthTests {
|
||||
private func config(_ value: String = "https://example.com") throws -> ServerConfig {
|
||||
try ServerConfig.parse(value).get()
|
||||
}
|
||||
private func session() -> URLSession {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [AuthURLProtocol.self]
|
||||
return URLSession(configuration: config)
|
||||
}
|
||||
@Test func englishVoiceCatalogAndPreferenceMigration() async throws {
|
||||
AuthURLProtocol.handler = { request in
|
||||
#expect(request.url?.path == "/voices")
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
|
||||
return (200, Data(#"{"default_voice":"en-US-Ava:DragonHDOmniLatestNeural","catalog_available":true,"voices":[{"id":"en-GB-Sonia:DragonHDOmniLatestNeural","name":"Sonia","locale":"en-GB","gender":"Female","tag":"Omni"}]}"#.utf8))
|
||||
}
|
||||
let catalog = try await AuthClient(config: config(), session: session()).voices(token: "test-session")
|
||||
#expect(catalog.catalogAvailable)
|
||||
#expect(catalog.voices.first?.details == "en-GB · Female · Omni")
|
||||
#expect(Voices.restoredID(catalog.voices.first?.id) == "en-GB-Sonia:DragonHDOmniLatestNeural")
|
||||
// A valid server-catalogued choice must survive SettingsStore's next launch,
|
||||
// even if it is not among the offline fallback voices.
|
||||
#expect(Voices.restoredID("en-GB-Sonia:DragonHDOmniLatestNeural") == "en-GB-Sonia:DragonHDOmniLatestNeural")
|
||||
#expect(Voices.restoredID("en-US-Ava:DragonHDLatestNeural") == Voices.defaultID)
|
||||
for legacy in ["Jenny", "Guy", "Andrew", "Aria", "Emma", "Eric", "Brian", "Ashley", "Richard", "Tina", "Steffan"] {
|
||||
let migrated = Voices.restoredID("en-US-\(legacy)Neural")
|
||||
#expect(migrated.contains(":DragonHDOmniLatestNeural"))
|
||||
#expect(Voices.display(for: migrated) != nil)
|
||||
}
|
||||
#expect(Voices.restoredID("invalid <voice>") == Voices.defaultID)
|
||||
#expect(Voices.restoredID(nil) == Voices.defaultID)
|
||||
}
|
||||
|
||||
@Test func certificateFailureHasSafeRecovery() throws {
|
||||
let url = URL(string: "https://192.168.86.2:9178")!
|
||||
let message = ConnectionFailure.message(URLError(.serverCertificateUntrusted), server: url)
|
||||
#expect(message.contains("192.168.86.2"))
|
||||
#expect(message.contains("Certificate Trust Settings"))
|
||||
#expect(!message.contains("disable"))
|
||||
#expect(ConnectionFailure.message(AuthError.status(401), server: url).contains("username and password"))
|
||||
}
|
||||
@Test func canonicalOriginsAndSecureIPv6() throws {
|
||||
let parsed = try config(" HTTPS://EXAMPLE.COM:443/path ")
|
||||
#expect(parsed.baseURL.absoluteString == "https://example.com")
|
||||
#expect(parsed.livekitURL.absoluteString == "wss://example.com/livekit")
|
||||
#expect(try config("https://[fd00::1]:9178").livekitURL.absoluteString == "wss://[fd00::1]:9178/livekit")
|
||||
for invalid in ["https://u:[email protected]", "https://example.com?token=x", "https://example.com#fragment", "http://192.168.1.1"] {
|
||||
if case .success = ServerConfig.parse(invalid) { Issue.record("Accepted unsafe origin") }
|
||||
}
|
||||
}
|
||||
@Test func bearerBootstrapAndMissingAuth() async throws {
|
||||
let c = try config()
|
||||
AuthURLProtocol.handler = { request in
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
|
||||
#expect(request.url?.path == "/token")
|
||||
return (200, Data(#"{"token":"room-jwt","room":"room","user":{"id":"one","display_name":"One"}}"#.utf8))
|
||||
}
|
||||
let response = try await TokenClient(config: c, bearerToken: "test-session", session: session()).fetchToken()
|
||||
#expect(response.user.id == "one")
|
||||
do {
|
||||
_ = try await TokenClient(config: c, session: session()).fetchToken()
|
||||
Issue.record("Unauthenticated bootstrap accepted")
|
||||
} catch { #expect(error is AuthError) }
|
||||
}
|
||||
@Test func loginMeLogoutWireContract() async throws {
|
||||
let c = try config()
|
||||
let user = #"{"id":"one","username":"alice","display_name":"Alice","role":"user","status":"ready"}"#
|
||||
AuthURLProtocol.handler = { request in
|
||||
switch request.url!.path {
|
||||
case "/api/auth/login":
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||
return (200, Data("{\"token\":\"test-session\",\"user\":\(user),\"expires_at\":4102444800}".utf8))
|
||||
case "/api/auth/me":
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
|
||||
return (200, Data("{\"user\":\(user)}".utf8))
|
||||
default:
|
||||
#expect(request.url!.path == "/api/auth/logout")
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
|
||||
return (204, Data())
|
||||
}
|
||||
}
|
||||
let memory = MemoryTokenStore()
|
||||
let client = AuthClient(config: c, session: session())
|
||||
let store = AuthStore(config: c, client: client, tokens: memory)
|
||||
await store.login(username: "alice", password: "never-persist-this")
|
||||
#expect(store.user?.id == "one")
|
||||
#expect(try memory.token(origin: c.baseURL.absoluteString) == "test-session")
|
||||
#expect(try memory.token(origin: "https://another.example") == nil)
|
||||
let restored = AuthStore(config: c, client: client, tokens: memory)
|
||||
await restored.restore()
|
||||
#expect(restored.user?.username == "alice")
|
||||
await store.logout {
|
||||
#expect(store.token == nil)
|
||||
#expect(store.user?.id == "one") // old UI survives only until transport disconnect
|
||||
}
|
||||
#expect(store.user == nil)
|
||||
#expect(try memory.token(origin: c.baseURL.absoluteString) == nil)
|
||||
}
|
||||
@Test func transientRestoreFailureKeepsTokenButUnauthorizedDeletesIt() async throws {
|
||||
let c = try config()
|
||||
let memory = MemoryTokenStore()
|
||||
try memory.save("test-session", origin: c.baseURL.absoluteString)
|
||||
AuthURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) }
|
||||
let offline = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory)
|
||||
await offline.restore()
|
||||
#expect(offline.user == nil)
|
||||
#expect(try memory.token(origin: c.baseURL.absoluteString) == "test-session")
|
||||
AuthURLProtocol.handler = { _ in (401, Data()) }
|
||||
let revoked = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory)
|
||||
await revoked.restore()
|
||||
#expect(revoked.user == nil)
|
||||
#expect(try memory.token(origin: c.baseURL.absoluteString) == nil)
|
||||
}
|
||||
@Test func logoutInvalidatesSuspendedRestore() async throws {
|
||||
let c = try config()
|
||||
let memory = MemoryTokenStore()
|
||||
try memory.save("test-session", origin: c.baseURL.absoluteString)
|
||||
AuthURLProtocol.handler = { _ in
|
||||
return (200, Data(#"{"user":{"id":"one","username":"alice","role":"user","status":"ready"}}"#.utf8))
|
||||
}
|
||||
let store = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory)
|
||||
let restore = Task { await store.restore() }
|
||||
await Task.yield()
|
||||
await store.logout {}
|
||||
await restore.value
|
||||
#expect(store.user == nil)
|
||||
#expect(store.token == nil)
|
||||
}
|
||||
@Test func historyNeverImportsGlobalLegacyAndSeparatesAccounts() throws {
|
||||
let origin = "https://history-test.example"
|
||||
let legacy = AppPreferences.store.object(forKey: "voice_history")
|
||||
defer {
|
||||
if let legacy { AppPreferences.store.set(legacy, forKey: "voice_history") }
|
||||
else { AppPreferences.store.removeObject(forKey: "voice_history") }
|
||||
}
|
||||
AppPreferences.store.set("[{\"role\":\"user\",\"text\":\"legacy secret\",\"timestamp\":0}]", forKey: "voice_history")
|
||||
let first = TranscriptHistory(origin: origin, userID: "one")
|
||||
#expect(!first.entries.contains { $0.text == "legacy secret" })
|
||||
first.clear()
|
||||
first.sync(finals: [("user", "private", Date())])
|
||||
#expect(TranscriptHistory(origin: origin, userID: "one").entries.last?.text == "private")
|
||||
let other = TranscriptHistory(origin: origin, userID: "two")
|
||||
#expect(other.entries.isEmpty)
|
||||
#expect(TranscriptHistory(origin: "https://other.example", userID: "one").entries.isEmpty)
|
||||
first.clear()
|
||||
}
|
||||
@Test func progressCompletesWithoutTranscriptPollution() throws {
|
||||
let settings = SettingsStore()
|
||||
let c = try config()
|
||||
let voice = VoiceSession(config: c, tokenClient: TokenClient(config: c), settings: settings)
|
||||
let before = voice.transcriptHandler.events
|
||||
voice.receiveAssistantProgress(Data(#"{"type":"assistant_progress","request_id":"a","status":"working","message":"Checking your calendar…"}"#.utf8))
|
||||
#expect(voice.assistantActivity == "Checking your calendar…")
|
||||
voice.receiveAssistantProgress(Data(#"{"type":"assistant_progress","request_id":"a","status":"completed","message":"Done"}"#.utf8))
|
||||
#expect(voice.assistantActivity == nil)
|
||||
#expect(voice.transcriptHandler.events == before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import XCTest
|
||||
@testable import Voice
|
||||
|
||||
/// Deliberate physical/native transport qualification. No stored credentials,
|
||||
/// microphones, account memory edits, preference mutation or payload logging.
|
||||
final class ChatLiveTests: XCTestCase {
|
||||
func testAuthenticatedNativeDurableChat() async throws {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard env["HOPE_CHAT_LIVE"] == "1" else { throw XCTSkip("Native live chat requires explicit HOPE_CHAT_LIVE=1") }
|
||||
guard let username = env["HOPE_TEST_USERNAME"], let password = env["HOPE_TEST_PASSWORD"], !password.isEmpty else { throw XCTSkip("Protected live credentials were not supplied") }
|
||||
let config = try ServerConfig.parse(env["HOPE_TEST_ORIGIN"] ?? "https://192.168.86.2:9178").get()
|
||||
let auth = AuthClient(config: config)
|
||||
print("HOPE_CHAT_PROBE native authenticated HTTP started; microphone remains off")
|
||||
let login = try await auth.login(username: username, password: password)
|
||||
let api = ChatAPI(config: config, token: login.token)
|
||||
var createdID: String?
|
||||
do {
|
||||
let verified = try await auth.me(token: login.token)
|
||||
XCTAssertEqual(verified.id, login.user.id)
|
||||
let creation = try await api.request("api/conversations", method: "POST", body: ["title": "Native qualification · disposable"])
|
||||
let conversation = try ChatCoding.decoder.decode(ConversationEnvelope.self, from: creation).conversation
|
||||
createdID = conversation.id
|
||||
let sent = try await api.request("api/conversations/\(conversation.id)/messages", method: "POST", body: ["content": "Reply exactly: native chat transport verified", "idempotency_key": UUID().uuidString, "mode": "normal"])
|
||||
let operation = try ChatCoding.decoder.decode(SendEnvelope.self, from: sent)
|
||||
var completed: RunDetail?
|
||||
for _ in 0..<90 {
|
||||
let bytes = try await api.request("api/runs/\(operation.run.id)")
|
||||
let detail = try ChatCoding.decoder.decode(RunDetail.self, from: bytes)
|
||||
if !detail.run.active { completed = detail; break }
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
}
|
||||
let result = try XCTUnwrap(completed, "Run did not terminate within qualification deadline")
|
||||
XCTAssertEqual(result.run.status, "completed", "Server run failed; body intentionally not logged")
|
||||
XCTAssertTrue(result.messages.contains { $0.role == "assistant" && !$0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
let recorder = await MainActor.run { LiveEventRecorder() }
|
||||
try await api.events(run: operation.run.id, after: 0) { event in recorder.accept(event) }
|
||||
let replay = await MainActor.run { recorder.ids }
|
||||
XCTAssertFalse(replay.isEmpty)
|
||||
XCTAssertEqual(replay, replay.sorted())
|
||||
XCTAssertEqual(Set(replay).count, replay.count)
|
||||
let detailBytes = try await api.request("api/conversations/\(conversation.id)")
|
||||
let detail = try ChatCoding.decoder.decode(ConversationDetail.self, from: detailBytes)
|
||||
XCTAssertTrue(detail.messages.contains { $0.role == "user" })
|
||||
XCTAssertTrue(detail.messages.contains { $0.role == "assistant" })
|
||||
let pinnedBytes = try await api.request("api/conversations/\(conversation.id)", method: "PATCH", body: ["revision": detail.conversation.revision, "pinned": true])
|
||||
let pinned = try ChatCoding.decoder.decode(ConversationEnvelope.self, from: pinnedBytes).conversation
|
||||
XCTAssertTrue(pinned.pinned)
|
||||
_ = try await api.request("api/conversations/\(conversation.id)", method: "PATCH", body: ["revision": pinned.revision, "archived": true])
|
||||
_ = try await api.request("api/conversations/\(conversation.id)", method: "DELETE")
|
||||
createdID = nil
|
||||
try await auth.logout(token: login.token)
|
||||
api.cancel()
|
||||
print("HOPE_CHAT_PROBE native login/me/create/send/completion/replay/pin/archive/delete/logout completed; no audio captured")
|
||||
} catch {
|
||||
if let createdID { _ = try? await api.request("api/conversations/\(createdID)", method: "DELETE") }
|
||||
try? await auth.logout(token: login.token)
|
||||
api.cancel()
|
||||
print("HOPE_CHAT_PROBE cleanup attempted after failure; bodies/credentials withheld")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@MainActor
|
||||
private final class LiveEventRecorder {
|
||||
var ids: [Int] = []
|
||||
func accept(_ event: ChatEvent) { ids.append(event.id) }
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import XCTest
|
||||
@testable import Voice
|
||||
|
||||
@MainActor
|
||||
final class ChatTests: XCTestCase {
|
||||
func testOfflineCRUDSearchPinsArchiveMoveAndPreferences() async throws {
|
||||
let api = OfflineChatAPI()
|
||||
let store = ChatStore(api: api)
|
||||
await store.load()
|
||||
XCTAssertTrue(store.conversations.isEmpty)
|
||||
await store.saveWorkspace(nil, name: "Writing", instructions: "Be concise")
|
||||
let space = try XCTUnwrap(store.workspaces.first)
|
||||
await store.create(workspace: space.id)
|
||||
let original = try XCTUnwrap(store.selected)
|
||||
store.draft = "A durable offline message"
|
||||
await store.send()
|
||||
XCTAssertEqual(store.messages.count, 2)
|
||||
XCTAssertEqual(store.draft, "")
|
||||
await store.patch(original, changes: ["title": "Weekend", "pinned": true])
|
||||
XCTAssertEqual(store.selected?.title, "Weekend")
|
||||
XCTAssertTrue(store.selected?.pinned == true)
|
||||
await store.load(query: "durable")
|
||||
XCTAssertEqual(store.conversations.count, 1)
|
||||
await store.savePreferences(palette: "ocean", appearance: "dark")
|
||||
XCTAssertEqual(store.preferences.palette, "ocean")
|
||||
await store.deleteWorkspace(space)
|
||||
XCTAssertNil(store.conversations.first?.workspaceId)
|
||||
let renamed = try XCTUnwrap(store.selected)
|
||||
await store.patch(renamed, changes: ["archived": true])
|
||||
await store.load()
|
||||
XCTAssertTrue(store.conversations.isEmpty)
|
||||
await store.load(archived: true)
|
||||
XCTAssertEqual(store.conversations.count, 1)
|
||||
await store.delete(try XCTUnwrap(store.conversations.first))
|
||||
XCTAssertNil(store.selected)
|
||||
XCTAssertTrue(store.messages.isEmpty)
|
||||
}
|
||||
func testWorkspaceSaveReportsFailureAndPreservesExistingWorkspaces() async throws {
|
||||
let store = ChatStore(api: OfflineChatAPI())
|
||||
let rejected = await store.saveWorkspace(nil, name: " ", instructions: "Ignored")
|
||||
XCTAssertFalse(rejected)
|
||||
XCTAssertEqual(store.error, "Enter a workspace name.")
|
||||
XCTAssertTrue(store.workspaces.isEmpty)
|
||||
let saved = await store.saveWorkspace(nil, name: "Writing", instructions: "Be concise")
|
||||
XCTAssertTrue(saved)
|
||||
XCTAssertEqual(store.workspaces.map(\.name), ["Writing"])
|
||||
}
|
||||
func testPreviewClearsOldContentBeforeNewRequestAndOnTeardown() async throws {
|
||||
let api = OfflineChatAPI()
|
||||
let store = ChatStore(api: api)
|
||||
await store.features.load()
|
||||
let data = Data("Private preview".utf8)
|
||||
_ = await store.features.upload(data, filename: "preview.txt", mime: "text/plain")
|
||||
let attachment = try XCTUnwrap(store.features.attachments.first)
|
||||
store.features.previewText = "stale"
|
||||
await store.features.preview(attachment)
|
||||
XCTAssertEqual(store.features.previewAttachment?.id, attachment.id)
|
||||
XCTAssertNotEqual(store.features.previewText, "stale")
|
||||
store.features.clearPreview()
|
||||
XCTAssertNil(store.features.previewAttachment)
|
||||
XCTAssertNil(store.features.previewText)
|
||||
}
|
||||
func testOfflineResearchTruthfulExportAndTeardown() async throws {
|
||||
let store = ChatStore(api: OfflineChatAPI())
|
||||
await store.create()
|
||||
store.draft = "Export this"; await store.send()
|
||||
await store.loadResearch()
|
||||
XCTAssertFalse(store.researchAvailable)
|
||||
await store.exportConversation(format: "markdown")
|
||||
XCTAssertTrue(store.exportText?.contains("Export this") == true)
|
||||
XCTAssertTrue(store.exportFile?.filename.hasSuffix(".md") == true)
|
||||
XCTAssertEqual(store.exportFile?.data, Data(store.exportText!.utf8))
|
||||
store.foreground(true)
|
||||
store.foreground(false)
|
||||
store.close()
|
||||
XCTAssertNil(store.exportText)
|
||||
XCTAssertNil(store.exportFile)
|
||||
XCTAssertFalse(store.research)
|
||||
}
|
||||
func testBranchAndRegeneratePreserveOriginal() async throws {
|
||||
let store = ChatStore(api: OfflineChatAPI())
|
||||
await store.create()
|
||||
let original = try XCTUnwrap(store.selected)
|
||||
store.draft = "Original question"; await store.send()
|
||||
let user = try XCTUnwrap(store.messages.first)
|
||||
await store.branch(user, edited: "Edited question")
|
||||
XCTAssertNotEqual(store.selected?.id, original.id)
|
||||
XCTAssertEqual(store.messages.first?.content, "Edited question")
|
||||
await store.open(original)
|
||||
XCTAssertEqual(store.messages.first?.content, "Original question")
|
||||
let answer = try XCTUnwrap(store.messages.last)
|
||||
await store.branch(answer, regenerate: true)
|
||||
XCTAssertNotEqual(store.selected?.id, original.id)
|
||||
XCTAssertEqual(store.messages.first?.content, "Original question")
|
||||
}
|
||||
func testReplayFullReplacementAndDuplicateCursor() async throws {
|
||||
let store = ChatStore(api: OfflineChatAPI())
|
||||
await store.create()
|
||||
let id = try XCTUnwrap(store.selected?.id)
|
||||
func event(_ seq: Int, _ text: String) -> ChatEvent {
|
||||
ChatEvent(id: seq, name: "message", data: Data("{\"message\":{\"id\":\"m\",\"conversation_id\":\"\(id)\",\"role\":\"assistant\",\"content\":\"\(text)\",\"status\":\"streaming\",\"run_id\":\"r\",\"created_at\":1}}".utf8))
|
||||
}
|
||||
try store.apply(event(1, "A"), run: "r")
|
||||
try store.apply(event(2, "Answer"), run: "r")
|
||||
try store.apply(event(1, "Duplicate"), run: "r")
|
||||
XCTAssertEqual(store.messages.count, 1)
|
||||
XCTAssertEqual(store.messages.first?.content, "Answer")
|
||||
store.close()
|
||||
XCTAssertTrue(store.messages.isEmpty)
|
||||
XCTAssertNil(store.selected)
|
||||
XCTAssertEqual(store.draft, "")
|
||||
}
|
||||
func testSSECommentsMultilineAndFraming() throws {
|
||||
var parser = SSEParser()
|
||||
XCTAssertNil(parser.consume(": keepalive"))
|
||||
XCTAssertNil(parser.consume("id: 12"))
|
||||
XCTAssertNil(parser.consume("event: message"))
|
||||
XCTAssertNil(parser.consume("data: {"))
|
||||
XCTAssertNil(parser.consume("data: }"))
|
||||
let result = try XCTUnwrap(parser.consume(""))
|
||||
XCTAssertEqual(result.id, 12)
|
||||
XCTAssertEqual(result.name, "message")
|
||||
XCTAssertEqual(String(data: result.data, encoding: .utf8), "{\n}")
|
||||
XCTAssertNil(parser.consume(""))
|
||||
}
|
||||
func testRevisionConflictRetainsDraft() async throws {
|
||||
let store = ChatStore(api: OfflineChatAPI())
|
||||
await store.create(); let old = try XCTUnwrap(store.selected)
|
||||
await store.patch(old, changes: ["title": "First"])
|
||||
store.draft = "Keep me"
|
||||
await store.patch(old, changes: ["title": "Stale"])
|
||||
XCTAssertEqual(store.selected?.title, "First")
|
||||
XCTAssertEqual(store.draft, "Keep me")
|
||||
XCTAssertNotNil(store.error)
|
||||
}
|
||||
func testAdvancedOfflineUploadArtifactTaskMemoryAndPrivacy() async throws {
|
||||
let api = OfflineChatAPI()
|
||||
let features = ChatFeatureStore(api: api)
|
||||
await features.load()
|
||||
XCTAssertTrue(features.enabled.contains("attachments"))
|
||||
let uploaded = await features.upload(Data("Private notes".utf8), filename: "notes.txt", mime: "text/plain")
|
||||
let upload = try XCTUnwrap(uploaded)
|
||||
await features.preview(upload)
|
||||
XCTAssertEqual(features.previewText, "Private notes")
|
||||
await features.saveArtifact(nil, title: "Draft", kind: "markdown", content: "# Draft")
|
||||
let item = try XCTUnwrap(features.artifacts.first)
|
||||
await features.saveArtifact(item, title: "Revised", kind: "markdown", content: "# Revised")
|
||||
XCTAssertEqual(features.artifacts.first?.revision, 2)
|
||||
await features.saveTask(nil, name: "Reminder", schedule: "in 1 hour", prompt: "Check notes")
|
||||
let task = try XCTUnwrap(features.tasks.first)
|
||||
await features.taskAction(task, action: "pause")
|
||||
XCTAssertEqual(features.tasks.first?.enabled, false)
|
||||
let memory = try XCTUnwrap(features.memory.first)
|
||||
await features.saveMemory(memory, content: "New memory")
|
||||
XCTAssertEqual(features.memory.first?.content, "New memory")
|
||||
await features.saveMemory(memory, content: "Stale overwrite")
|
||||
XCTAssertNotNil(features.error)
|
||||
XCTAssertEqual(features.memory.first?.content, "New memory")
|
||||
features.close()
|
||||
XCTAssertTrue(features.attachments.isEmpty)
|
||||
XCTAssertTrue(features.artifacts.isEmpty)
|
||||
XCTAssertTrue(features.tasks.isEmpty)
|
||||
XCTAssertTrue(features.memory.isEmpty)
|
||||
XCTAssertNil(features.previewText)
|
||||
}
|
||||
func testSharedThemeContrastAcrossPalettes() {
|
||||
func luminance(_ rgb: HopeRGB) -> Double {
|
||||
func channel(_ x: Double) -> Double { x <= 0.04045 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4) }
|
||||
return 0.2126 * channel(rgb.red) + 0.7152 * channel(rgb.green) + 0.0722 * channel(rgb.blue)
|
||||
}
|
||||
for palette in HopePalette.allCases { for dark in [false, true] {
|
||||
let ground = luminance(HopeTheme.rgb(.ground, palette: palette, dark: dark))
|
||||
for role in [HopeThemeRole.ink, .quiet, .accent] {
|
||||
let text = luminance(HopeTheme.rgb(role, palette: palette, dark: dark))
|
||||
XCTAssertGreaterThanOrEqual((max(text, ground) + 0.05) / (min(text, ground) + 0.05), 4.5, "\(palette) \(role) \(dark)")
|
||||
}
|
||||
} }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import XCTest
|
||||
@testable import Voice
|
||||
|
||||
private final class ChatURLProtocol: URLProtocol {
|
||||
static var handler: ((URLRequest) throws -> (Int, Data))?
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
do {
|
||||
let (status, body) = try Self.handler!(request)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: body); client?.urlProtocolDidFinishLoading(self)
|
||||
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
final class ChatTransportTests: XCTestCase {
|
||||
func testBearerSameOriginQueryAndRawUpload() async throws {
|
||||
guard case .success(let config) = ServerConfig.parse("https://hope.example:9178") else { XCTFail(); return }
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [ChatURLProtocol.self]
|
||||
let api = ChatAPI(config: config, token: "offline-test-bearer", session: URLSession(configuration: configuration))
|
||||
ChatURLProtocol.handler = { request in
|
||||
XCTAssertEqual(request.url?.host, "hope.example")
|
||||
XCTAssertEqual(request.url?.scheme, "https")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer offline-test-bearer")
|
||||
XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "a & b")
|
||||
return (200, Data("{\"conversations\":[],\"next_cursor\":null}".utf8))
|
||||
}
|
||||
_ = try await api.request("api/conversations", query: [URLQueryItem(name: "q", value: "a & b")])
|
||||
ChatURLProtocol.handler = { request in
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.url?.path, "/api/attachments")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Filename"), "notes.txt")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "text/plain")
|
||||
return (201, Data("{}".utf8))
|
||||
}
|
||||
_ = try await api.upload(data: Data("test".utf8), filename: "notes.txt", mime: "text/plain")
|
||||
api.cancel(); ChatURLProtocol.handler = nil
|
||||
}
|
||||
func testUnauthorizedAndRedirectStatusCannotBecomeSuccess() async throws {
|
||||
guard case .success(let config) = ServerConfig.parse("https://hope.example") else { XCTFail(); return }
|
||||
let configuration = URLSessionConfiguration.ephemeral; configuration.protocolClasses = [ChatURLProtocol.self]
|
||||
let api = ChatAPI(config: config, token: "offline", session: URLSession(configuration: configuration))
|
||||
for status in [401, 403, 302, 409] {
|
||||
ChatURLProtocol.handler = { _ in (status, Data()) }
|
||||
do { _ = try await api.request("api/conversations"); XCTFail("Expected rejection") }
|
||||
catch ChatFailure.status(let actual) { XCTAssertEqual(actual, status) }
|
||||
}
|
||||
api.cancel(); ChatURLProtocol.handler = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@Test("square LED presence responds to input and emphasizes assistant output")
|
||||
func ledPresenceAudioResponse() {
|
||||
let quiet = LightwellView.dot(row: 10, column: 10, phase: .listening(level: 0), time: 0, reduceMotion: false)
|
||||
let listening = LightwellView.dot(row: 10, column: 10, phase: .listening(level: 0.8), time: 0, reduceMotion: false)
|
||||
let speaking = LightwellView.dot(row: 10, column: 10, phase: .speaking(level: 0.8), time: 0, reduceMotion: false)
|
||||
#expect(listening.opacity > quiet.opacity)
|
||||
#expect(listening.scale > quiet.scale)
|
||||
#expect(speaking.opacity > listening.opacity)
|
||||
#expect(speaking.scale > listening.scale)
|
||||
}
|
||||
|
||||
@Test("LED reduce motion freezes audio geometry and pending waves")
|
||||
func ledReduceMotion() {
|
||||
for phase: LightwellView.Phase in [.connecting, .thinking, .speaking(level: 0.8)] {
|
||||
#expect(LightwellView.dot(row: 8, column: 9, phase: phase, time: 0, reduceMotion: true)
|
||||
== LightwellView.dot(row: 8, column: 9, phase: phase, time: 7, reduceMotion: true))
|
||||
}
|
||||
#expect(LightwellView.dot(row: 10, column: 10, phase: .listening(level: 0), time: 0, reduceMotion: true)
|
||||
== LightwellView.dot(row: 10, column: 10, phase: .listening(level: 1), time: 0, reduceMotion: true))
|
||||
}
|
||||
|
||||
@Test("LED state patterns are distinct and invalid signal levels remain bounded")
|
||||
func ledStatePatterns() {
|
||||
let dormant = LightwellView.dot(row: 10, column: 10, phase: .idle, time: 0, reduceMotion: false)
|
||||
let thinking = LightwellView.dot(row: 10, column: 10, phase: .thinking, time: 0, reduceMotion: true)
|
||||
#expect(thinking.opacity > dormant.opacity)
|
||||
for level in [Double.nan, Double.infinity, -1, 10] {
|
||||
let dot = LightwellView.dot(row: 10, column: 10, phase: .speaking(level: level), time: 0, reduceMotion: false)
|
||||
#expect(dot.opacity.isFinite && (0...1).contains(dot.opacity))
|
||||
#expect(dot.scale.isFinite && dot.scale >= 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,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,46 @@
|
||||
import Foundation
|
||||
import LiveKit
|
||||
import Testing
|
||||
@testable import Voice
|
||||
|
||||
/// Explicit opt-in native CFNetwork probe; no account credentials or bodies.
|
||||
struct TLSLiveTests {
|
||||
@Test func pinnedLANHTTPS() async throws {
|
||||
guard ProcessInfo.processInfo.environment["HOPE_TLS_LIVE"] == "1" else {
|
||||
print("HOPE_TLS_PROBE skipped: explicit live opt-in absent")
|
||||
return
|
||||
}
|
||||
print("HOPE_TLS_PROBE native HTTPS validation started")
|
||||
let (data, response) = try await TLSPinning.session().data(from: URL(string: "https://192.168.86.2:9178/api/auth/me")!)
|
||||
#expect((response as? HTTPURLResponse)?.statusCode == 401)
|
||||
#expect(!data.isEmpty)
|
||||
}
|
||||
|
||||
@Test func systemTrustedLANHTTPS() async throws {
|
||||
guard ProcessInfo.processInfo.environment["HOPE_TLS_LIVE"] == "1" else { return }
|
||||
print("HOPE_TLS_PROBE system trust validation started")
|
||||
let (_, response) = try await URLSession.shared.data(from: URL(string: "https://192.168.86.2:9178/api/auth/me")!)
|
||||
#expect((response as? HTTPURLResponse)?.statusCode == 401)
|
||||
print("HOPE_TLS_PROBE system trust validation completed")
|
||||
}
|
||||
|
||||
@Test func nativeAccountLogin() async throws {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard env["HOPE_TLS_LIVE"] == "1", let username = env["HOPE_TEST_USERNAME"],
|
||||
let password = env["HOPE_TEST_PASSWORD"] else { return }
|
||||
print("HOPE_TLS_PROBE native account login started")
|
||||
let config = try ServerConfig.parse("https://192.168.86.2:9178").get()
|
||||
let client = AuthClient(config: config)
|
||||
let login = try await client.login(username: username, password: password)
|
||||
#expect(login.user.username == username)
|
||||
#expect(try await client.me(token: login.token).id == login.user.id)
|
||||
let bootstrap = try await TokenClient(config: config, bearerToken: login.token).fetchToken()
|
||||
let room = Room()
|
||||
try await room.connect(url: config.livekitURL.absoluteString, token: bootstrap.token)
|
||||
#expect(room.connectionState == .connected)
|
||||
await room.disconnect()
|
||||
print("HOPE_TLS_PROBE authenticated LiveKit WSS connected and disconnected; microphone not enabled")
|
||||
try await client.logout(token: login.token)
|
||||
print("HOPE_TLS_PROBE native login/me/logout completed")
|
||||
}
|
||||
}
|
||||
@@ -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