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 })
+109 -9
View File
@@ -27,9 +27,62 @@ struct SnapshotTests {
hostId: target.hostId, threadId: target.threadId,
request: .decision(requestId: item.request!.id, decision: .decline))
}
let idle = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
let working = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
try write(ComplicationGallery(waiting: waiting, waitingCount: feed.needsYou.count, idle: idle), "complication.png")
try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting), ("Nothing waiting", working)]), "complication.png")
try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting)]), "complication-waiting.png")
try write(ComplicationGallery(sections: [("Nothing waiting", working)]), "complication-working.png")
}
@Test func smartStack() async throws {
// Answering the release question leaves the homelab card's subagent approval waiting longest.
let gateway = DemoGateway()
let release = try await gateway.thread(hostId: "mac-mini", threadId: "t-release")
try await gateway.respond(
hostId: "mac-mini", threadId: "t-release",
request: .answers(requestId: release.item.request!.id, answers: ["checks": .values(["unit"])]))
let summary = try await gateway.summary()
let approval = try #require(await WaitingEntry.Approval.load(for: summary, api: gateway))
let waiting = WaitingEntry(date: .now, content: .counts(summary, stale: false), approval: approval)
// Denying everything on a fresh gateway leaves the scaffold thread working most recently.
let idle = DemoGateway()
for item in try await idle.feed().needsYou {
let target = item.requestThread
try await idle.respond(
hostId: target.hostId, threadId: target.threadId,
request: .decision(requestId: item.request!.id, decision: .decline))
}
let working = WaitingEntry(date: .now, content: .counts(try await idle.summary(), stale: false))
try write(SmartStackGallery(entries: [waiting, working]), "smart-stack.png")
}
@Test func changes() async throws {
let gateway = DemoGateway()
let feed = try await gateway.feed()
try write(feedColumn {
RecentHeader(recent: feed.recent, more: feed.moreRecent)
ForEach(feed.recent.prefix(4)) { StatusCard(item: $0) }
}, "feed-changes.png")
let detail = try await gateway.thread(hostId: "littleman", threadId: "t-next")
try write(titled("Thread", ThreadView(hostId: "littleman", threadId: "t-next", detail: detail, scrolls: false)), "thread-changes.png")
}
@Test func search() async throws {
let gateway = DemoGateway()
let hits = try await gateway.search("healthcheck")
try write(titled("\u{201C}healthcheck\u{201D}", SearchView(query: "healthcheck", response: hits, scrolls: false)), "search.png")
let none = try await gateway.search("terraform")
try write(titled("\u{201C}terraform\u{201D}", SearchView(query: "terraform", response: none, scrolls: false)), "search-empty.png")
}
@Test func questionNotification() throws {
let question = PushQuestion(
id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")],
allowCustomAnswer: true)
try write(NotificationPreview(
view: InputNotificationView(title: "Gateway Containerfile", question: "Which base image?"),
actions: NotificationHandler.inputActions(question).map(\.title)), "notification-question.png")
}
@Test func multiSelectQuestion() async throws {
@@ -169,16 +222,15 @@ struct SnapshotTests {
}
}
/// Every family at its 46mm size, once with threads waiting and once with nothing waiting.
/// Every family at its 46mm size, once per titled entry.
private struct ComplicationGallery: View {
var waiting: WaitingEntry
var waitingCount: Int
var idle: WaitingEntry
var sections: [(title: String, entry: WaitingEntry)]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
section("\(waitingCount) waiting", waiting)
section("Nothing waiting", idle)
ForEach(sections.indices, id: \.self) { index in
section(sections[index].title, sections[index].entry)
}
}
.padding(.horizontal, 10)
.padding(.top, 8)
@@ -193,7 +245,7 @@ private struct ComplicationGallery: View {
WaitingView(entry: entry, familyOverride: .accessoryInline)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.frame(height: 18)
WaitingView(entry: entry, familyOverride: .accessoryRectangular)
WaitingView(entry: entry, familyOverride: .accessoryRectangular, inStackOverride: false)
.frame(width: 184, height: 72)
HStack(spacing: 16) {
// Outside a face AccessoryWidgetBackground fills a square; a face clips it round.
@@ -206,3 +258,51 @@ private struct ComplicationGallery: View {
}
}
}
/// The rectangular widget as the Smart Stack shows it, on its card and with Allow and Deny when the
/// waiting thread wants an approval.
private struct SmartStackGallery: View {
var entries: [WaitingEntry]
var body: some View {
VStack(spacing: 10) {
ForEach(entries.indices, id: \.self) { index in
WaitingView(entry: entries[index], familyOverride: .accessoryRectangular, inStackOverride: true)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(width: 196, height: 96, alignment: .topLeading)
.background(Color(white: 0.11), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
}
.padding(.top, 8)
.foregroundStyle(.white)
.fontDesign(.rounded)
}
}
/// The question notification's long look over its actions. The system draws the actions; these
/// stand in for them with the same titles.
private struct NotificationPreview: View {
var view: InputNotificationView
var actions: [String]
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("WRISTCODE")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.secondaryText)
view
.padding(.bottom, 4)
ForEach(actions, id: \.self) { title in
Text(title)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(Theme.control, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
.padding(.horizontal, 8)
.padding(.top, 10)
.foregroundStyle(.white)
}
}