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 })
|
||||
|
||||
Reference in new issue
Block a user