The gateway sends threadId "" on host notifications; tapping one now opens the feed instead of a thread that does not exist. Co-Authored-By: Claude Opus 5.5 <[email protected]>
227 lines
11 KiB
Swift
227 lines
11 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 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")
|
|
}
|
|
|
|
@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)
|
|
}
|
|
|
|
@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": "yes"]))
|
|
#expect(object["answers"] as? [String: String] == ["q1": "yes"])
|
|
#expect(object["decision"] == nil)
|
|
}
|
|
|
|
@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 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))
|
|
}
|
|
}
|
|
}
|