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:
1 parent
f8241e42bb
commit
bfcbb69550
29 files changed
+1100
-137
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user