// Decodes the docs/api.md examples into the Codable models, and checks the small bits of logic // the screens rely on. import Foundation import Testing @testable import WristCode private func decode(_ type: T.Type, _ json: String) throws -> T { try JSON.decoder().decode(type, from: Data(json.utf8)) } private func encodeObject(_ value: some Encodable) throws -> [String: Any] { let data = try JSON.encoder().encode(value) return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) } private let feedItemJSON = """ { "hostId": "...", "hostLabel": "Mac mini", "threadId": "...", "projectId": "...", "projectTitle": "bill-tracker", "title": "Receipt OCR", "phase": "waiting_for_approval", "updatedAt": "2026-11-01T09:41:00.000Z", "lastMessage": "Parser and tests are written. I want to run the suite.", "request": { "id": "...", "kind": "command", "prompt": "bun test --filter ocr", "reason": null, "options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }], "questions": [] } } """ @Suite struct DecodingTests { @Test func feedFromDocs() throws { let feed = try decode(Feed.self, """ { "needsYou": [\(feedItemJSON)], "working": [], "recent": [], "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }] } """) #expect(feed.needsYou.count == 1) #expect(feed.hosts.first?.status == .online) #expect(feed.hosts.first?.error == nil) let item = try #require(feed.needsYou.first) #expect(item.phase == .waitingForApproval) #expect(item.phase.needsYou) #expect(item.updatedAt == Date(timeIntervalSince1970: 1_793_526_060)) let request = try #require(item.request) #expect(request.prompt == "bun test --filter ocr") #expect(request.reason == nil) #expect(request.quickOptions?.allow.label == "Allow") #expect(request.quickOptions?.deny.decision == .decline) #expect(request.summary == "bun test --filter ocr") } @Test func questionRequest() throws { let request = try decode(PendingRequest.self, """ { "id": "r1", "kind": "user_input", "prompt": null, "reason": null, "options": [], "questions": [{ "id": "q1", "header": "Storage", "question": "Hash the codes?", "options": [{ "label": "Yes", "value": "yes" }], "allowCustomAnswer": true }] } """) #expect(request.isQuestion) #expect(request.quickOptions == nil) #expect(request.summary == "Hash the codes?") #expect(request.questions.first?.allowCustomAnswer == true) // A gateway older than multi-select leaves the flag out, which means single-select. #expect(request.questions.first?.multiSelect == false) } @Test func multiSelectQuestion() throws { let question = try decode(PendingRequest.Question.self, """ { "id": "q1", "header": "Checks", "question": "Which checks?", "options": [{ "label": "Unit", "value": "unit" }, { "label": "Lint", "value": "lint" }], "allowCustomAnswer": false, "multiSelect": true } """) #expect(question.multiSelect) #expect(question.options.map(\.value) == ["unit", "lint"]) } @Test func summaryFromDocs() throws { let summary = try decode(Summary.self, """ { "needsYou": 1, "working": 3, "hostsOffline": 0, "next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini" } } """) #expect(summary.needsYou == 1) #expect(summary.working == 3) #expect(summary.next?.title == "Receipt OCR") #expect(summary.next?.route == .thread(hostId: "h", threadId: "t")) let idle = try decode(Summary.self, #"{ "needsYou": 0, "working": 0, "hostsOffline": 2, "next": null }"#) #expect(idle.next == nil) #expect(idle.hostsOffline == 2) } @Test func unknownEnumValuesStillDecode() throws { let item = try decode(FeedItem.self, feedItemJSON .replacingOccurrences(of: "waiting_for_approval", with: "hibernating") .replacingOccurrences(of: "\"decline\"", with: "\"rejectForever\"")) #expect(item.phase.rawValue == "hibernating") #expect(!item.phase.needsYou && !item.phase.isWorking) #expect(item.request?.options.last?.decision.rawValue == "rejectForever") let host = try decode(HostSummary.self, #"{ "id": "h", "label": "L", "status": "sleeping", "error": "zzz" }"#) #expect(host.status.problem == "offline") } @Test func threadFromDocs() throws { let detail = try decode(ThreadDetail.self, """ { "item": \(feedItemJSON), "messages": [{ "id": "...", "role": "assistant", "text": "...", "createdAt": "2026-11-01T09:40:00Z" }], "canStop": true } """) #expect(detail.canStop) #expect(detail.messages.first?.role == .assistant) #expect(detail.messages.first?.createdAt == Date(timeIntervalSince1970: 1_793_526_000)) } @Test func projectsFromDocs() throws { let response = try decode(ProjectsResponse.self, """ { "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null, "projects": [{ "id": "a", "title": "wristcode", "model": "claude-opus-5-5" }, { "id": "b", "title": "scratch", "model": null }] }] } """) let host = try #require(response.hosts.first) #expect(host.projects.count == 2) #expect(host.launchable.map(\.id) == ["a"]) } @Test func smallResponses() throws { #expect(try decode(PairResponse.self, #"{ "deviceId": "9b0c", "deviceToken": "wc_x" }"#).deviceToken == "wc_x") #expect(try decode(NewThreadResponse.self, #"{ "hostId": "h", "threadId": "t" }"#).threadId == "t") #expect(try decode(ErrorResponse.self, #"{ "error": "That host is offline" }"#).error == "That host is offline") } @Test func badDateFails() { #expect(throws: DecodingError.self) { try decode(Message.self, #"{ "id": "m", "role": "user", "text": "x", "createdAt": "yesterday" }"#) } } } @Suite struct EncodingTests { @Test func respondWithDecision() throws { let object = try encodeObject(RespondRequest.decision(requestId: "r1", decision: .accept)) #expect(object["requestId"] as? String == "r1") #expect(object["decision"] as? String == "accept") #expect(object["answers"] == nil) } @Test func respondWithAnswers() throws { let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": .text("yes")])) #expect(object["answers"] as? [String: String] == ["q1": "yes"]) #expect(object["decision"] == nil) } @Test func respondWithMultiSelectAnswers() throws { let object = try encodeObject(RespondRequest.answers( requestId: "r1", answers: ["checks": .values(["unit", "lint"]), "note": .text("after lunch")])) let answers = try #require(object["answers"] as? [String: Any]) #expect(answers["checks"] as? [String] == ["unit", "lint"]) #expect(answers["note"] as? String == "after lunch") } @Test func pushRegistration() throws { let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox)) #expect(object["apnsEnvironment"] as? String == "sandbox") } } @Suite struct LogicTests { @Test func gatewayURLNormalizing() { #expect(DeviceStore.normalizeGatewayURL(" wristcode.gbrown.org/ ")?.absoluteString == "https://wristcode.gbrown.org") #expect(DeviceStore.normalizeGatewayURL("http://10.0.0.5:8080")?.absoluteString == "http://10.0.0.5:8080") #expect(DeviceStore.normalizeGatewayURL("") == nil) #expect(DeviceStore.normalizeGatewayURL("ftp://x.org") == nil) } @Test func modelNames() { #expect(Format.modelName("claude-opus-5-5") == "Claude Opus 5.5") #expect(Format.modelName("claude-haiku-5-5") == "Claude Haiku 5.5") #expect(Format.modelName("gpt-5-codex") == "GPT 5 Codex") } @Test func ages() { let now = Date(timeIntervalSince1970: 100_000) #expect(Format.age(of: now, now: now) == "now") #expect(Format.age(of: now.addingTimeInterval(-4 * 60), now: now) == "4 min") #expect(Format.age(of: now.addingTimeInterval(-62 * 60), now: now) == "1 h") #expect(Format.age(of: now.addingTimeInterval(-50 * 3600), now: now) == "2 d") } @Test func messageSplitShowsLatestAgentMessage() { func m(_ id: String, _ role: Message.Role) -> Message { Message(id: id, role: role, text: id, createdAt: .now) } let split = MessageSplit([m("1", .user), m("2", .assistant), m("3", .assistant), m("4", .user)]) #expect(split.earlier.map(\.id) == ["1", "2"]) #expect(split.latest?.id == "3") #expect(split.after.map(\.id) == ["4"]) #expect(MessageSplit([m("1", .user)]).latest == nil) } @Test func defaultProjectPrefersLastUsed() { let projects = ProjectsResponse(hosts: [ .init(id: "off", label: "Off", status: .offline, error: nil, projects: [.init(id: "x", title: "x", model: "m")]), .init(id: "on", label: "On", status: .online, error: nil, projects: [ .init(id: "none", title: "none", model: nil), .init(id: "a", title: "a", model: "m"), .init(id: "b", title: "b", model: "m"), ]), ]) let last = ProjectRef(hostId: "on", projectId: "b") #expect(NewThreadView.defaultSelection(in: projects, last: last) == last) // A forgotten or model-less project falls back to the first online host's first usable project. #expect(NewThreadView.defaultSelection(in: projects, last: ProjectRef(hostId: "on", projectId: "none")) == ProjectRef(hostId: "on", projectId: "a")) } @Test func summaryFromFeed() throws { let feed = DemoFixtures.previewFeed() let summary = Summary(feed) #expect(summary.needsYou == feed.needsYou.count) #expect(summary.working == feed.working.count) #expect(summary.hostsOffline == 1) #expect(summary.next?.threadId == feed.needsYou.first?.threadId) #expect(Summary(Feed(needsYou: [], working: [], recent: [], hosts: [])).next == nil) } @Test func routeURLs() { let thread = Route.thread(hostId: "mac mini", threadId: "t/1") #expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1") #expect(Route(url: thread.url) == thread) #expect(Route(url: Route.newThread.url) == .newThread) #expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil) #expect(Route(url: URL(string: "https://thread/h/t")!) == nil) } @Test func notificationCommands() { let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"] #expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil) == .respond(hostId: "h", threadId: "t", requestId: "r", decision: .accept)) #expect(NotificationCommand(actionIdentifier: "deny", userInfo: info, text: nil) == .respond(hostId: "h", threadId: "t", requestId: "r", decision: .decline)) #expect(NotificationCommand(actionIdentifier: "reply", userInfo: info, text: " Commit it ") == .reply(hostId: "h", threadId: "t", text: "Commit it")) #expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: info, text: nil) == .open(.thread(hostId: "h", threadId: "t"))) // A host notification has no thread, so tapping it opens the feed. #expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: [:], text: nil) == .open(nil)) #expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: ["hostId": "h", "threadId": ""], text: nil) == .open(nil)) // Allow without a requestId cannot answer, so it opens the thread instead. #expect(NotificationCommand(actionIdentifier: "allow", userInfo: ["hostId": "h", "threadId": "t"], text: nil) == .open(.thread(hostId: "h", threadId: "t"))) } } @Suite struct DemoGatewayTests { @Test func allowMovesThreadToWorking() async throws { let gateway = DemoGateway() let before = try await gateway.feed() let item = try #require(before.needsYou.first { $0.request?.quickOptions != nil }) try await gateway.respond( hostId: item.hostId, threadId: item.threadId, request: .decision(requestId: item.request!.id, decision: .accept)) let after = try await gateway.feed() #expect(!after.needsYou.contains { $0.id == item.id }) #expect(after.working.contains { $0.id == item.id }) await #expect(throws: GatewayError.http(status: 409, message: "Already answered.")) { try await gateway.respond( hostId: item.hostId, threadId: item.threadId, request: .decision(requestId: item.request!.id, decision: .accept)) } } @Test func multiSelectAnswerMovesThreadToWorking() async throws { let gateway = DemoGateway() let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true }) let request = try #require(item.request) let question = try #require(request.questions.first) try await gateway.respond( hostId: item.hostId, threadId: item.threadId, request: .answers(requestId: request.id, answers: [question.id: .values(["unit", "lint"])])) #expect(try await gateway.summary().working == 3) #expect(try await gateway.feed().working.contains { $0.id == item.id }) } }