Watch: what changed on Recent cards and the thread screen, search in All threads, a switching complication with Allow and Deny in the Smart Stack, and question options on notifications

This commit is contained in:
gib committed 2026-11-02 13:56:10 -05:00
1 parent f8241e42bb
commit bfcbb69550
29 files changed
+1100 -137

No files matched your search

+164 -1
View File
@@ -4,6 +4,7 @@
import Foundation
import Security
import Testing
import UserNotifications
@testable import WristCode
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
@@ -243,6 +244,65 @@ private let feedItemJSON = """
#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")
@@ -444,6 +504,45 @@ private let feedItemJSON = """
#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")
@@ -451,7 +550,7 @@ private let feedItemJSON = """
#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"),
.projectThreads(hostId: "mac mini", projectId: "p/1"), .search(query: "bun & test?"),
] {
#expect(Route(url: route.url) == route)
}
@@ -477,6 +576,47 @@ private let feedItemJSON = """
// 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"])
}
}
@@ -652,6 +792,29 @@ private let feedItemJSON = """
}
}
@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 })