// Decodes the docs/api.md examples into the Codable models, and checks the small bits of logic // the screens rely on. import Foundation import Security import Testing import UserNotifications @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") // An older gateway without `recentTotal` has nothing past `recent`. #expect(feed.recentTotal == 0) #expect(feed.moreRecent == 0) } @Test func feedWithRecentTotal() throws { let item = feedItemJSON.replacingOccurrences(of: "waiting_for_approval", with: "completed") let feed = try decode(Feed.self, """ { "needsYou": [], "working": [], "recent": [\(item)], "recentTotal": 12, "hosts": [] } """) #expect(feed.recentTotal == 12) #expect(feed.moreRecent == 11) #expect(Feed(needsYou: [], working: [], recent: feed.recent, hosts: []).moreRecent == 0) } @Test func recentResponses() throws { let item = feedItemJSON.replacingOccurrences( of: "\"Parser and tests are written. I want to run the suite.\"", with: "null") let recent = try decode(RecentResponse.self, "{ \"items\": [\(item)] }") #expect(recent.items.count == 1) #expect(recent.items.first?.lastMessage == nil) let settled = try decode(SettleRecentResponse.self, #"{ "settled": 11, "failed": 1 }"#) #expect(settled == SettleRecentResponse(settled: 11, failed: 1)) } @Test func subagentRequest() throws { let item = try decode(FeedItem.self, feedItemJSON.replacingOccurrences( of: "\"request\": {", with: #""via": { "hostId": "mac", "threadId": "sub", "title": "Check Panama PATH" }, "request": {"#)) #expect(item.via == FeedItem.Via(hostId: "mac", threadId: "sub", title: "Check Panama PATH")) #expect(item.requestThread == ("mac", "sub")) #expect(item.route == .thread(hostId: "...", threadId: "...")) // A gateway older than `via` leaves it out, and the request belongs to the thread itself. let plain = try decode(FeedItem.self, feedItemJSON) #expect(plain.via == nil) #expect(plain.requestThread == ("...", "...")) } @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) // An older gateway leaves out askFirst, so the switch stays hidden, and the phase 3 fields. #expect(detail.askFirst == nil) #expect(detail.earlierCursor == nil) #expect(detail.subagents.isEmpty) #expect(detail.item.activity == nil) #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"]) #expect(host.providers.isEmpty) #expect(host.projects.first?.defaultModel == nil) } @Test func phase2FromDocs() throws { let projects = try decode(ProjectsResponse.self, """ { "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null, "projects": [{ "id": "a", "title": "wristcode", "model": "claude-opus-5-5", "instanceId": "claudeAgent" }], "providers": [{ "instanceId": "claudeAgent", "name": "Claude", "canAskFirst": true, "models": [{ "slug": "claude-opus-5-5", "name": "Claude Opus 5.5" }] }] }] } """) let host = try #require(projects.hosts.first) #expect(host.projects.first?.defaultModel == ModelRef(instanceId: "claudeAgent", slug: "claude-opus-5-5")) #expect(host.offers(ModelRef(instanceId: "claudeAgent", slug: "claude-opus-5-5"))) #expect(!host.offers(ModelRef(instanceId: "codex", slug: "claude-opus-5-5"))) let detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": true, "askFirst": false }"#) #expect(detail.askFirst == false) let prefs = try decode(NotificationPrefs.self, #"{ "needsMe": true, "finished": true, "failed": true, "subagents": false }"#) #expect(NotificationPrefs.Key.allCases.map { prefs[$0] } == [true, true, true, false]) let request = try encodeObject(NewThreadRequest( hostId: "h", projectId: "p", text: "t", model: ModelRef(instanceId: "claudeAgent", slug: "claude-sonnet-5-5"), askFirst: true)) #expect((request["model"] as? [String: String]) == ["instanceId": "claudeAgent", "slug": "claude-sonnet-5-5"]) #expect(request["askFirst"] as? Bool == true) let plain = try encodeObject(NewThreadRequest(hostId: "h", projectId: "p", text: "t")) #expect(plain["model"] == nil && plain["askFirst"] == nil) } @Test func phase3FromDocs() throws { let working = feedItemJSON .replacingOccurrences(of: "waiting_for_approval", with: "running") .replacingOccurrences( of: #""request": {"#, with: #""activity": { "kind": "command", "text": "bun test", "startedAt": "2026-11-01T09:40:00.000Z" }, "request": {"#) let detail = try decode(ThreadDetail.self, """ { "item": \(working), "messages": [], "canStop": true, "askFirst": false, "earlierCursor": "w12", "subagents": [ { "hostId": "h", "threadId": "s1", "title": "Check PATH", "phase": "running", "settled": false, "updatedAt": "2026-11-01T09:41:00.000Z" }, { "hostId": "h", "threadId": "s2", "title": "Stopped", "phase": null, "settled": true, "updatedAt": "2026-11-01T09:30:00.000Z" } ] } """) let activity = try #require(detail.item.activity) #expect(activity.kind == .command) #expect(activity.text == "bun test") #expect(activity.startedAt == Date(timeIntervalSince1970: 1_793_526_000)) #expect(detail.earlierCursor == "w12") #expect(detail.subagents.map(\.statusWord) == ["working", "settled"]) #expect(detail.subagents.map(\.isActive) == [true, false]) #expect(detail.subagents.last?.phase == nil) let thinking = try decode(Activity.self, #"{ "kind": "thinking", "text": "Thinking", "startedAt": null }"#) #expect(thinking.startedAt == nil) let history = try decode(HistoryResponse.self, """ { "messages": [{ "id": "m1", "role": "user", "text": "Hi", "createdAt": "2026-11-01T09:00:00Z" }], "earlierCursor": null } """) #expect(history.messages.map(\.id) == ["m1"]) #expect(history.earlierCursor == nil) let list = try decode(ThreadListResponse.self, """ { "items": [{ "hostId": "h", "threadId": "t", "title": "Phase 3", "phase": "failed", "settled": false, "updatedAt": "2026-11-01T09:41:00.000Z" }] } """) #expect(list.items.first?.statusWord == "failed") #expect(list.items.first?.route == .thread(hostId: "h", threadId: "t")) } @Test func phase4FromDocs() throws { let item = try decode(FeedItem.self, feedItemJSON.replacingOccurrences( of: #""request": {"#, with: #""changes": { "files": 3, "additions": 42, "deletions": 8 }, "request": {"#)) #expect(item.changes == ChangeTotals(files: 3, additions: 42, deletions: 8)) #expect(try decode(FeedItem.self, feedItemJSON).changes == nil) let detail = try decode(ThreadDetail.self, """ { "item": \(feedItemJSON), "messages": [], "canStop": false, "subagents": [], "changedFiles": [{ "path": "gateway/Containerfile", "name": "Containerfile", "additions": 31, "deletions": 0 }] } """) #expect(detail.changedFiles == [ChangedFile(path: "gateway/Containerfile", name: "Containerfile", additions: 31, deletions: 0)]) #expect(try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": true }"#).changedFiles.isEmpty) let summary = try decode(Summary.self, """ { "needsYou": 1, "working": 3, "hostsOffline": 0, "next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini", "request": "bun test --filter ocr" }, "current": { "hostId": "h", "threadId": "w", "title": "Phase 3 screens", "activity": { "kind": "command", "text": "bun test", "startedAt": null } } } """) #expect(summary.next?.request == "bun test --filter ocr") #expect(summary.current?.title == "Phase 3 screens") #expect(summary.current?.activity?.text == "bun test") #expect(summary.current?.route == .thread(hostId: "h", threadId: "w")) let unread = try decode(Summary.self, """ { "needsYou": 1, "working": 0, "hostsOffline": 0, "current": null, "next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini", "request": null } } """) #expect(unread.next?.request == nil) #expect(unread.current == nil) let search = try decode(SearchResponse.self, """ { "hits": [{ "thread": { "hostId": "h", "threadId": "t", "title": "Jellyfin image", "phase": null, "settled": true, "updatedAt": "2026-11-01T09:41:00.000Z" }, "projectTitle": "homelab", "hostLabel": "Mac mini", "snippet": "…rebuild from the Containerfile and…" }, { "thread": { "hostId": "h", "threadId": "u", "title": "Gateway Containerfile", "phase": "running", "settled": false, "updatedAt": "2026-11-01T09:42:00.000Z" }, "projectTitle": "wristcode", "hostLabel": "Mac mini", "snippet": null }], "unsearched": ["Laptop"] } """) #expect(search.hits.map(\.id) == ["h/t", "h/u"]) #expect(search.hits.first?.snippet == "…rebuild from the Containerfile and…") #expect(search.hits.last?.snippet == nil) #expect(search.unsearched == ["Laptop"]) } @Test func pushQuestion() throws { let info: [AnyHashable: Any] = [ "hostId": "h", "threadId": "t", "requestId": "r", "question": ["id": "q1", "options": [["label": "bun:1-alpine", "value": "alpine"]], "allowCustomAnswer": true], ] let question = try #require(PushQuestion(userInfo: info)) #expect(question.id == "q1") #expect(question.options.map(\.value) == ["alpine"]) #expect(question.allowCustomAnswer) // Pushes without one, as from an older gateway, and malformed ones have none. #expect(PushQuestion(userInfo: ["hostId": "h"]) == nil) #expect(PushQuestion(userInfo: ["question": ["id": "q1"]]) == nil) } @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 statusFromDocs() throws { let status = try decode(StatusResponse.self, """ { "hosts": [{ "id": "laptop", "label": "Laptop", "status": "online", "error": null, "activeUrl": "http://laptopvpn.gib:3773", "routes": [ { "url": "http://laptop.gib:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "2026-11-01T09:12:00.000Z" }, { "url": "http://laptopvpn.gib:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "2026-11-01T09:12:00.000Z" }, { "url": "https://t3laptop.gbrown.org", "kind": "public", "result": "untried", "error": null, "checkedAt": null } ], "onlineSince": "2026-11-01T09:12:00.000Z", "lastOnlineAt": null, "nextRetryAt": null, "tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5" }], "gateway": { "startedAt": "2026-11-01T08:00:00.000Z", "pushConfigured": true, "lastPush": { "ok": false, "at": "2026-11-01T09:40:00.000Z", "status": 400, "reason": "BadDeviceToken" } }, "device": { "name": "Apple Watch", "pairedAt": "2026-10-30T08:00:00.000Z", "pushRegistered": true, "apnsEnvironment": null } } """) let host = try #require(status.hosts.first) #expect(host.onFallback) #expect(host.activeRoute?.kind == .vpn) #expect(host.routes.map(\.hostPort) == ["laptop.gib:3773", "laptopvpn.gib:3773", "t3laptop.gbrown.org"]) #expect(status.device.apnsEnvironment == nil) #expect(status.gateway.lastPush?.hint?.contains("sandbox/production") == true) } @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 elapsedTimes() { let now = Date(timeIntervalSince1970: 100_000) #expect(Format.elapsed(since: now.addingTimeInterval(5), now: now) == "0s") #expect(Format.elapsed(since: now.addingTimeInterval(-42), now: now) == "42s") #expect(Format.elapsed(since: now.addingTimeInterval(-60), now: now) == "1m") #expect(Format.elapsed(since: now.addingTimeInterval(-199), now: now) == "3m") #expect(Format.elapsed(since: now.addingTimeInterval(-3600), now: now) == "1h") #expect(Format.elapsed(since: now.addingTimeInterval(-50 * 3600), now: now) == "2d") } @Test func olderMessagesMerge() throws { func m(_ id: String) -> Message { Message(id: id, role: .assistant, text: id, createdAt: .now) } let detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": false, "earlierCursor": "w3" }"#) var history = MessageHistory() #expect(history.cursor(detail) == "w3") #expect(history.messages(before: [m("4"), m("5")]).map(\.id) == ["4", "5"]) // A page that overlaps the window shows each message once. history.prepend(HistoryResponse(messages: [m("2"), m("3"), m("4")], earlierCursor: "w1")) #expect(history.cursor(detail) == "w1") #expect(history.messages(before: [m("4"), m("5")]).map(\.id) == ["2", "3", "4", "5"]) // A refresh moves the window on. The outgoing window stays, so no gap opens. history.keep([m("4"), m("5")]) #expect(history.messages(before: [m("6"), m("7")]).map(\.id) == ["2", "3", "4", "5", "6", "7"]) history.keep([m("6"), m("7")]) history.prepend(HistoryResponse(messages: [m("1"), m("2")], earlierCursor: nil)) #expect(history.cursor(detail) == nil) #expect(history.messages(before: [m("7")]).map(\.id) == ["1", "2", "3", "4", "5", "6", "7"]) // Nothing is kept before Load earlier is used. var fresh = MessageHistory() fresh.keep([m("4")]) #expect(fresh.messages(before: [m("5")]).map(\.id) == ["5"]) } @Test func markdownReadsCleanly() { #expect(Markdown.block("## Done") == "**Done**") #expect(Markdown.block(" - item") == " • item") #expect(Markdown.block("> quoted") == "quoted") #expect(Markdown.block("**bold** stays") == "**bold** stays") let text = String(Markdown.render("All **48** pass in `host.ts`:\n- bounded retry\n```\n# not a heading\n```").characters) #expect(text == "All 48 pass in host.ts:\n• bounded retry\n# not a heading") } @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 newThreadAgentAndOptions() { let claude = AgentProvider( instanceId: "claudeAgent", name: "Claude", canAskFirst: true, models: [.init(slug: "opus", name: "Claude Opus"), .init(slug: "sonnet", name: "Claude Sonnet")]) let other = AgentProvider(instanceId: "other", name: "Other", canAskFirst: false, models: [.init(slug: "o1", name: "O1")]) let project = ProjectsResponse.Project(id: "p", title: "p", model: "opus", instanceId: "claudeAgent") let host = ProjectsResponse.Host(id: "h", label: "H", status: .online, error: nil, projects: [project], providers: [claude, other]) let sonnet = ModelRef(instanceId: "claudeAgent", slug: "sonnet") // The last pick wins while the host still offers it, else the project's default. #expect(NewThreadView.agent(on: host, project: project, last: sonnet) == sonnet) #expect(NewThreadView.agent(on: host, project: project, last: ModelRef(instanceId: "claudeAgent", slug: "gone")) == project.defaultModel) #expect(NewThreadView.agent(on: host, project: project, last: nil) == project.defaultModel) // The default model is left out so it keeps its options. A provider that cannot ask sends false. let byDefault = NewThreadView.options(project: project, agent: project.defaultModel, provider: claude, askFirst: true) #expect(byDefault.model == nil && byDefault.askFirst == true) let picked = NewThreadView.options(project: project, agent: sonnet, provider: claude, askFirst: false) #expect(picked.model == sonnet && picked.askFirst == false) let o1 = ModelRef(instanceId: "other", slug: "o1") #expect(NewThreadView.options(project: project, agent: o1, provider: other, askFirst: true).askFirst == false) // An older gateway lists no providers, so neither is sent. let old = NewThreadView.options(project: project, agent: nil, provider: nil, askFirst: true) #expect(old.model == nil && old.askFirst == nil) #expect(AgentList.shortName("Claude Opus 5.5", provider: "Claude") == "Opus 5.5") #expect(AgentList.shortName("GPT-5.5", provider: "Codex") == "GPT-5.5") } @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 summaryFromFeedNamesRequestAndCurrent() throws { let feed = DemoFixtures.previewFeed() let summary = Summary(feed) #expect(summary.next?.request == feed.needsYou.first?.request?.summary) #expect(summary.current?.threadId == feed.working.first?.threadId) #expect(summary.current?.activity == feed.working.first?.activity) } @Test func changesLine() { let parts = ChangeTotals(files: 3, additions: 42, deletions: 8).parts #expect(parts.additions == "+42") #expect(parts.deletions == "\u{2212}8") #expect(parts.files == "3 files") #expect(ChangeTotals(files: 1, additions: 0, deletions: 2).parts == ("+0", "\u{2212}2", "1 file")) } @Test func unsearchedNote() { #expect(SearchResponse.unsearchedNote([], offline: []) == nil) #expect(SearchResponse.unsearchedNote(["Laptop"], offline: ["Laptop"]) == "Laptop is offline, so its threads were not searched.") #expect(SearchResponse.unsearchedNote(["Laptop", "LittleMan"], offline: ["Laptop", "LittleMan"]) == "Laptop and LittleMan are offline, so their threads were not searched.") // A host the watch saw online failed or was slow, which the gateway does not tell apart. #expect(SearchResponse.unsearchedNote(["Laptop"], offline: []) == "Laptop could not be searched.") #expect(SearchResponse.unsearchedNote(["Laptop", "LittleMan"], offline: ["Laptop"]) == "Laptop and LittleMan could not be searched.") #expect(SearchResponse.unsearchedNote(["A", "B", "C"], offline: []) == "A, B and C could not be searched.") } @Test func complicationMode() { let next = Summary.Next(hostId: "h", threadId: "t", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test") let current = Summary.Current(hostId: "h", threadId: "w", title: "Phase 3", activity: nil) #expect(ComplicationMode(Summary(needsYou: 2, working: 1, next: next, current: current, hostsOffline: 0)) == .waiting(next)) #expect(ComplicationMode(Summary(needsYou: 0, working: 1, next: nil, current: current, hostsOffline: 0)) == .working(current)) #expect(ComplicationMode(Summary(needsYou: 0, working: 0, next: nil, hostsOffline: 0)) == .idle) // An older gateway names no working thread. #expect(ComplicationMode(Summary(needsYou: 0, working: 3, next: nil, hostsOffline: 0)) == .idle) } @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) for route in [ Route.recent, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"), .projectThreads(hostId: "mac mini", projectId: "p/1"), .search(query: "bun & test?"), ] { #expect(Route(url: route.url) == route) } #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"))) // Answer opens the thread's question screen. #expect(NotificationCommand(actionIdentifier: "answer", userInfo: info, text: nil) == .open(.thread(hostId: "h", threadId: "t"))) } @Test func questionActionsBuildAnswers() throws { let info: [AnyHashable: Any] = [ "hostId": "h", "threadId": "t", "requestId": "r", "question": [ "id": "base", "allowCustomAnswer": true, "options": [["label": "bun:1-alpine", "value": "alpine"], ["label": "bun:1-debian", "value": "debian"]], ], ] func body(_ action: String, _ text: String? = nil) throws -> [String: Any] { let response = try #require(NotificationCommand(actionIdentifier: action, userInfo: info, text: text).response) #expect(response.hostId == "h" && response.threadId == "t") return try encodeObject(response.request) } let picked = try body("option.1") #expect(picked["requestId"] as? String == "r") #expect(picked["answers"] as? [String: String] == ["base": "debian"]) #expect(try body("say", " the slim one ")["answers"] as? [String: String] == ["base": "the slim one"]) // An option the push does not have, an empty answer, or no question opens the thread instead. let thread = NotificationCommand.open(.thread(hostId: "h", threadId: "t")) #expect(NotificationCommand(actionIdentifier: "option.4", userInfo: info, text: nil) == thread) #expect(NotificationCommand(actionIdentifier: "say", userInfo: info, text: " ") == thread) #expect(NotificationCommand(actionIdentifier: "option.0", userInfo: ["hostId": "h", "threadId": "t", "requestId": "r"], text: nil) == thread) } @MainActor @Test func questionNotificationActions() throws { let question = PushQuestion( id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")], allowCustomAnswer: true) let actions = NotificationHandler.inputActions(question) #expect(actions.map(\.title) == ["bun:1-alpine", "bun:1-debian", "Say an answer", "Open"]) #expect(actions.map(\.identifier) == ["option.0", "option.1", "say", "open"]) #expect(actions[2] is UNTextInputNotificationAction) var closed = question closed.allowCustomAnswer = false #expect(NotificationHandler.inputActions(closed).map(\.title) == ["bun:1-alpine", "bun:1-debian", "Open"]) #expect(NotificationHandler.inputActions(nil).map(\.title) == ["Answer", "Open"]) } } /// Reads and writes the real shared Keychain group, so the entitlement and Info.plist group agree. @Suite(.serialized) struct SharedStateTests { @Test func accessGroupHasTeamPrefix() { #expect(DeviceStore.accessGroup.hasSuffix(".org.gbrown.wristcode.shared")) #expect(!DeviceStore.accessGroup.hasPrefix("$")) } @Test func roundTrip() { let demo = SharedState.demo let summary = SharedState.lastSummary defer { SharedState.demo = demo SharedState.lastSummary = summary } let saved = Summary(needsYou: 2, working: 1, next: nil, hostsOffline: 0) SharedState.lastSummary = saved #expect(SharedState.lastSummary == saved) SharedState.lastSummary = nil #expect(SharedState.lastSummary == nil) SharedState.demo = true #expect(SharedState.demo) SharedState.demo = false #expect(!SharedState.demo) } /// A token saved by a build before the complication sits in the app's own group. Launch moves it. @Test func migratesLegacyItems() throws { let before = DeviceStore.load() defer { if let before { DeviceStore.save(before) } else { DeviceStore.clearToken() } } let team = try #require(DeviceStore.accessGroup.split(separator: ".").first) let appGroup = "\(team).org.gbrown.wristcode.watchkitapp" func item(_ account: String) -> [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "org.gbrown.wristcode", kSecAttrAccount as String: account] } for (account, value) in [("deviceToken", "wc_legacy"), ("gatewayURL", "https://legacy.example")] { SecItemDelete(item(account) as CFDictionary) var legacy = item(account) legacy[kSecAttrAccessGroup as String] = appGroup legacy[kSecValueData as String] = Data(value.utf8) #expect(SecItemAdd(legacy as CFDictionary, nil) == errSecSuccess) } #expect(DeviceStore.sharedData("deviceToken") == nil) // Before the move the app still finds the old items. #expect(DeviceStore.load()?.token == "wc_legacy") DeviceStore.migrateToSharedGroup() #expect(DeviceStore.sharedData("deviceToken") == Data("wc_legacy".utf8)) #expect(DeviceStore.load() == Credentials(gatewayURL: URL(string: "https://legacy.example")!, token: "wc_legacy")) var old = item("deviceToken") old[kSecAttrAccessGroup as String] = appGroup #expect(SecItemCopyMatching(old as CFDictionary, nil) == errSecItemNotFound) } } @Suite struct StatusReportTests { private let now = Date(timeIntervalSince1970: 1_800_000_000) @Test func demoListsIssuesWorstFirst() { let report = StatusReport(DemoFixtures(now: now).status, now: now) #expect(report.issues.map(\.title) == [ "LittleMan offline", "Laptop is on its VPN route", "server-gib token expires in 5 days", ]) #expect(report.issues.map(\.detail) == ["Both routes failed · 4 min", "laptop.gib:3773 did not answer", "Run renew-hosts.sh"]) #expect(!report.allConnected) #expect(report.worst == .problem) #expect(report.healthySummary == "2 more, all direct") #expect(report.subline == "4 environments · pushes OK") } @Test func fallbackAloneIsStillAllConnected() { var status = DemoFixtures(now: now).status status.hosts.removeAll { $0.id == "littleman" } for i in status.hosts.indices { status.hosts[i].tokenExpiresAt = now.addingTimeInterval(30 * 86_400) } let report = StatusReport(status, now: now) #expect(report.allConnected) #expect(report.issues.map(\.kind) == [.fallback]) } @Test func pushProblems() { var status = DemoFixtures(now: now).status status.device.pushRegistered = false #expect(StatusReport(status, now: now).pushSummary == "watch not registered") status.gateway.pushConfigured = false let report = StatusReport(status, now: now) #expect(report.pushSummary == "pushes off") #expect(report.issues.first { $0.kind == .push }?.route == .notifications) } } @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 && $0.via == 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 subagentApprovalGoesToTheSubagent() async throws { let gateway = DemoGateway() let item = try #require(try await gateway.feed().needsYou.first { $0.via != nil }) let via = try #require(item.via) let decision = RespondRequest.decision(requestId: item.request!.id, decision: .accept) // The parent has no request of its own to answer. await #expect(throws: GatewayError.http(status: 409, message: "Already answered.")) { try await gateway.respond(hostId: item.hostId, threadId: item.threadId, request: decision) } try await gateway.respond(hostId: via.hostId, threadId: via.threadId, request: decision) let after = try await gateway.feed() #expect(!after.needsYou.contains { $0.id == item.id }) #expect(after.working.first { $0.id == item.id }?.via == nil) } @Test func settleLeavesTheFeed() async throws { let gateway = DemoGateway() let item = try #require(try await gateway.feed().recent.first) try await gateway.settle(hostId: item.hostId, threadId: item.threadId) #expect(!(try await gateway.feed().recent.contains { $0.id == item.id })) // A reply brings it back. try await gateway.sendMessage(hostId: item.hostId, threadId: item.threadId, text: "One more thing") #expect(try await gateway.feed().working.contains { $0.id == item.id }) } @Test func feedShowsTenRecentAndCountsTheRest() async throws { let gateway = DemoGateway() let feed = try await gateway.feed() let all = try await gateway.recent().items #expect(feed.recent.count == 10) #expect(feed.recentTotal == all.count) #expect(feed.moreRecent == all.count - 10) #expect(feed.recent == Array(all.prefix(10))) } @Test func settleRecentLeavesFailedThreads() async throws { let gateway = DemoGateway() let all = try await gateway.recent().items let result = try await gateway.settleRecent() #expect(result == SettleRecentResponse(settled: all.filter { $0.phase == .completed }.count, failed: 0)) let left = try await gateway.recent().items #expect(!left.isEmpty && left.allSatisfy { $0.phase == .failed }) #expect(try await gateway.feed().moreRecent == 0) } @Test func subagentsOpenButStayOutOfTheFeed() async throws { let gateway = DemoGateway() let feed = try await gateway.feed() let parent = try #require(feed.working.first { $0.threadId == "t-container" }) let detail = try await gateway.thread(hostId: parent.hostId, threadId: parent.threadId) #expect(!detail.subagents.isEmpty) let listed = feed.needsYou + feed.working + feed.recent for row in detail.subagents { #expect(!listed.contains { $0.threadId == row.threadId }) #expect(try await gateway.thread(hostId: row.hostId, threadId: row.threadId).item.title == row.title) } } @Test func searchSkipsOfflineHosts() async throws { let gateway = DemoGateway() let response = try await gateway.search("healthcheck") #expect(response.hits.map(\.thread.threadId).sorted() == ["t-container", "t-health"]) #expect(response.hits.first { $0.thread.threadId == "t-container" }?.snippet?.contains("healthcheck") == true) #expect(response.hits.first { $0.thread.threadId == "t-health" }?.snippet == nil) #expect(response.unsearched == ["LittleMan"]) // Bump Next.js is on LittleMan, which is offline. #expect(try await gateway.search("Next.js").hits.isEmpty) } @Test func approvalForTheSmartStack() async throws { let gateway = DemoGateway() // The demo's longest wait is a question, which gets no buttons. #expect(await WaitingEntry.Approval.load(for: try await gateway.summary(), api: gateway) == nil) let summary = Summary( needsYou: 1, working: 0, next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini"), hostsOffline: 0) let approval = try #require(await WaitingEntry.Approval.load(for: summary, api: gateway)) #expect(approval.requestId == "r-ocr") #expect(approval.allow.decision == .accept && approval.deny.decision == .decline) } @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 }) } }