Files
wristcode/watch/WristCodeTests/ModelsTests.swift
T

502 lines
24 KiB
Swift

// 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
@testable import WristCode
private func decode<T: Decodable>(_ 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)
#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 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 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)
for route in [Route.recent, .status, .host(id: "mac mini"), .notifications] {
#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")))
}
}
/// 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 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 })
}
}