import XCTest @testable import Voice private final class ParityChatAPI: ChatTransport { struct Request { let path: String; let method: String; let body: [String: Any]?; let query: [URLQueryItem] } var requests: [Request] = [] var handler: ((Request) async throws -> Data?)? private let fallback = OfflineChatAPI() func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data { let request = Request(path: path, method: method, body: body, query: query) requests.append(request) if let data = try await handler?(request) { return data } return try await fallback.request(path, method: method, body: body, query: query) } func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {} func cancel() {} } @MainActor final class ChatParityTests: XCTestCase { private func conversation(_ id: String, workspace: String? = nil, revision: Int = 1) -> HopeConversation { HopeConversation(id: id, title: id, workspaceId: workspace, pinned: false, pinRank: 0, archived: false, temporary: false, revision: revision, createdAt: 1, updatedAt: 1) } private func encoded(_ value: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: value) } private func projection(_ conversation: HopeConversation) throws -> Any { let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase return try JSONSerialization.jsonObject(with: encoder.encode(conversation)) } private func attachment(_ id: String, mime: String = "text/plain", status: String = "ready") -> ChatAttachment { ChatAttachment(id: id, filename: "notes.txt", mime: mime, size: 12, extractionStatus: status, createdAt: 1) } func testWorkspaceFilterPrecedesPagingAndScopeChangeDropsCursor() async throws { let api = ParityChatAPI(); let store = ChatStore(api: api) api.handler = { request in guard request.path == "api/conversations" else { return nil } return Data("{\"conversations\":[],\"next_cursor\":\"page-two\"}".utf8) } await store.load(query: "search", archived: true, workspace: "writing") await store.load(query: "search", archived: true, workspace: "writing", more: true) await store.load(query: "search", archived: true, workspace: "different", more: true) let lists = api.requests.filter { $0.path == "api/conversations" } XCTAssertEqual(lists.count, 3) XCTAssertEqual(lists[0].query.first { $0.name == "workspace_id" }?.value, "writing") XCTAssertEqual(lists[1].query.first { $0.name == "cursor" }?.value, "page-two") XCTAssertNil(lists[2].query.first { $0.name == "cursor" }) } func testSyncPreservesTwoLoadedPagesAndContinuationCursor() async throws { let api = ParityChatAPI(); let store = ChatStore(api: api) let first = conversation("first", workspace: "writing") let older = conversation("older", workspace: "writing") var changed = older; changed.title = "Renamed remotely"; changed.revision = 2 api.handler = { [self] request in if request.path == "api/conversations" { let secondPage = request.query.contains { $0.name == "cursor" && $0.value == "page-two" } return try encoded(["conversations": [projection(secondPage ? older : first)], "next_cursor": secondPage ? "page-three" : "page-two"]) } if request.path == "api/sync" { return try encoded(["changes": [["id": changed.id, "kind": "conversation", "deleted": false, "data": projection(changed)]], "cursor": 1]) } return nil } await store.load(query: "message match", workspace: "writing") await store.load(query: "message match", workspace: "writing", more: true) XCTAssertEqual(store.conversations.map(\.id), [first.id, older.id]) XCTAssertEqual(store.nextCursor, "page-three") await store.synchronize() XCTAssertEqual(store.conversations.map(\.id), [first.id, older.id]) XCTAssertEqual(store.conversations.last?.title, "Renamed remotely") XCTAssertEqual(store.nextCursor, "page-three") XCTAssertEqual(api.requests.filter { $0.path == "api/conversations" }.count, 2) } func testOtherRowMutationPreservesComposerAndActiveRunAndScope() async throws { let api = ParityChatAPI(); let store = ChatStore(api: api) let selected = conversation("selected", workspace: "writing") let other = conversation("other", workspace: "writing") let moved = conversation("other", workspace: "elsewhere", revision: 2) api.handler = { [self] request in if request.method == "PATCH" { return try encoded(["conversation": projection(moved)]) } if request.path == "api/conversations" { return Data("{\"conversations\":[],\"next_cursor\":null}".utf8) } return nil } await store.load(workspace: "writing") store.selected = selected; store.conversations = [selected, other]; store.draft = "Keep draft" store.messages = [HopeMessage(id: "m", conversationId: selected.id, role: "assistant", content: "Streaming", status: "streaming", runId: "r", createdAt: 1)] store.runs = [HopeRun(id: "r", conversationId: selected.id, status: "running", messageId: "m", createdAt: 1, updatedAt: 1, error: nil)] XCTAssertFalse(store.canMutate(selected)); XCTAssertTrue(store.canMutate(other)) await store.patch(other, changes: ["workspace_id": "elsewhere"]) XCTAssertEqual(store.selected, selected); XCTAssertEqual(store.draft, "Keep draft") XCTAssertEqual(store.messages.first?.content, "Streaming"); XCTAssertEqual(store.activeRun?.id, "r") XCTAssertEqual(api.requests.last { $0.path == "api/conversations" }?.query.first { $0.name == "workspace_id" }?.value, "writing") XCTAssertFalse(store.conversations.contains { $0.id == other.id }) } func testMutationCompletionSurvivesNavigationButNotAccountClose() async throws { for close in [false, true] { let api = ParityChatAPI(); let store = ChatStore(api: api) await store.create(); let first = try XCTUnwrap(store.selected) await store.create(); let second = try XCTUnwrap(store.selected) var finish: CheckedContinuation? var started: CheckedContinuation? api.handler = { request in if request.method == "PATCH" { started?.resume(); started = nil await withCheckedContinuation { finish = $0 } } return nil } let operation = Task { await store.patch(first, changes: ["title": "Renamed"]) } await withCheckedContinuation { started = $0 } if close { store.close() } else { await store.open(first); await store.open(second) } finish?.resume(); await operation.value if close { XCTAssertTrue(store.conversations.isEmpty); XCTAssertNil(store.selected) } else { XCTAssertEqual(store.selected?.id, second.id); XCTAssertEqual(store.conversations.first { $0.id == first.id }?.title, "Renamed") } } } func testStaleRevisionRefreshesMetadataOnlyAndRetryIsActionable() async throws { let store = ChatStore(api: OfflineChatAPI()) await store.create(); let stale = try XCTUnwrap(store.selected) await store.patch(stale, changes: ["title": "Latest"]) store.draft = "Private unsent draft" await store.patch(stale, changes: ["title": "Stale overwrite"]) XCTAssertEqual(store.selected?.title, "Latest"); XCTAssertEqual(store.draft, "Private unsent draft") XCTAssertTrue(store.error?.contains("latest details are loaded") == true) await store.patch(try XCTUnwrap(store.selected), changes: ["title": "Retry"]) XCTAssertEqual(store.selected?.title, "Retry") } func testAttachmentQueueBindsComposerAndRejectsPendingAndUnsupportedFiles() async throws { let store = ChatStore(api: OfflineChatAPI()); await store.create() store.features.enabled = ["attachments"] let epoch = store.draftGeneration let accepted = await store.queueAttachment(attachment("a"), expectedDraftGeneration: epoch) XCTAssertTrue(accepted) let image = await store.queueAttachment(attachment("image", mime: "image/png", status: "image"), expectedDraftGeneration: epoch) XCTAssertFalse(image) let failed = await store.queueAttachment(attachment("failed", status: "encrypted"), expectedDraftGeneration: epoch) XCTAssertFalse(failed) for i in 1...7 { _ = await store.queueAttachment(attachment("extra-\(i)"), expectedDraftGeneration: epoch) } let excess = await store.queueAttachment(attachment("ninth"), expectedDraftGeneration: epoch) XCTAssertFalse(excess); XCTAssertEqual(store.selectedAttachmentIds.count, 8) store.busy = true let pending = await store.queueAttachment(attachment("pending"), expectedDraftGeneration: epoch) XCTAssertFalse(pending); store.busy = false await store.create() let stale = await store.queueAttachment(attachment("stale"), expectedDraftGeneration: epoch) XCTAssertFalse(stale); XCTAssertTrue(store.selectedAttachmentIds.isEmpty) store.close() let closed = await store.queueAttachment(attachment("closed")) XCTAssertFalse(closed) } func testUnavailableAttachmentCanBeRemovedAndFreshQueueWins() async throws { let store = ChatStore(api: OfflineChatAPI()); await store.create() store.features.enabled = ["attachments"] let current = attachment("a") let accepted = await store.queueAttachment(current) XCTAssertTrue(accepted) store.features.attachments = [attachment("a", status: "failed")] XCTAssertEqual(store.queuedAttachment("a")?.extractionStatus, "ready") XCTAssertTrue(store.canSendAttachments) store.selectedAttachmentIds.insert("missing") XCTAssertNil(store.queuedAttachment("missing")) XCTAssertFalse(store.canSendAttachments) store.selectedAttachmentIds.remove("missing") XCTAssertTrue(store.canSendAttachments) store.close() } func testEditAndRegenerateResendExactOriginalAttachmentIDs() async throws { for regenerate in [false, true] { let api = ParityChatAPI(); let store = ChatStore(api: api) await store.create(); store.draft = "Original"; await store.send() let ids = ["00000000000000000000000000000001", "00000000000000000000000000000002"] store.messages[0].attachments = ids api.handler = { request in if request.path.hasPrefix("api/attachments/") { let id = String(request.path.split(separator: "/").last!) return Data("{\"attachment\":{\"id\":\"\(id)\",\"filename\":\"notes.txt\",\"mime\":\"text/plain\",\"size\":12,\"extraction_status\":\"ready\",\"created_at\":1}}".utf8) } return nil } let target = try XCTUnwrap(regenerate ? store.messages.last : store.messages.first) await store.branch(target, edited: regenerate ? nil : "Edited", regenerate: regenerate) let resent = try XCTUnwrap(api.requests.last { $0.path.hasSuffix("/messages") }) XCTAssertEqual(resent.body?["attachment_ids"] as? [String], ids) } } func testModelSaveRefreshFailureDisablesImageCapability() async throws { let api = ParityChatAPI(); let store = ChatStore(api: api) await store.create() store.features.enabled = ["attachments", "image_input"] let image = attachment("qualified-image", mime: "image/png", status: "image") let queued = await store.queueAttachment(image, expectedDraftGeneration: store.draftGeneration) XCTAssertTrue(queued); XCTAssertTrue(store.canSendAttachments) store.draft = "Keep this image draft" api.handler = { request in if request.path == "api/settings/model" { return Data("{\"settings\":{\"model\":\"auto\",\"thinking_level\":\"off\"},\"message\":null}".utf8) } if request.path == "api/capabilities" { throw ChatFailure.status(503) } return nil } await store.saveModel() XCTAssertFalse(store.features.enabled.contains("image_input")) XCTAssertTrue(api.requests.contains { $0.path == "api/capabilities" }) XCTAssertFalse(store.canSendAttachments) await store.send() XCTAssertFalse(api.requests.contains { $0.path.hasSuffix("/messages") }) XCTAssertEqual(store.draft, "Keep this image draft") XCTAssertEqual(store.selectedAttachmentIds, Set([image.id])) XCTAssertTrue(store.error?.contains("current model") == true) } }