Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway: an approval push for a file change says which files it would edit
This commit is contained in:
1 parent
4dc4e57dac
commit
edc4b88990
35 files changed
+1162
-144
No files matched your search
@@ -289,6 +289,50 @@ private let feedItemJSON = """
|
||||
#expect(search.unsearched == ["Laptop"])
|
||||
}
|
||||
|
||||
@Test func phase5FromDocs() throws {
|
||||
let feed = try decode(Feed.self, """
|
||||
{
|
||||
"needsYou": [], "working": [], "recent": [], "recentTotal": 0, "snoozedTotal": 2, "hosts": []
|
||||
}
|
||||
""")
|
||||
#expect(feed.snoozedTotal == 2)
|
||||
|
||||
let snoozed = try decode(RecentResponse.self, """
|
||||
{ "items": [{
|
||||
"hostId": "h", "hostLabel": "Mac mini", "threadId": "t", "projectId": "p", "projectTitle": "wristcode",
|
||||
"title": "Gateway Containerfile", "phase": "completed", "updatedAt": "2026-11-02T14:00:00.000Z",
|
||||
"lastMessage": null, "activity": null, "changes": null, "snoozedUntil": "2026-11-03T14:00:00.000Z",
|
||||
"request": null, "via": null
|
||||
}] }
|
||||
""")
|
||||
#expect(snoozed.items.first?.snoozedUntil == JSON.parseDate("2026-11-03T14:00:00.000Z"))
|
||||
|
||||
let request = try decode(PendingRequest.self, """
|
||||
{
|
||||
"id": "r", "kind": "file-change", "prompt": "Edit: deploy/compose.yml", "reason": null,
|
||||
"options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }],
|
||||
"files": [
|
||||
{ "path": "deploy/compose.yml", "name": "compose.yml", "additions": 9, "deletions": 6,
|
||||
"preview": ["- image: bun:1", "+ image: bun:1-alpine"] },
|
||||
{ "path": "gateway/src/health.ts", "name": "health.ts", "additions": null, "deletions": null, "preview": [] }
|
||||
],
|
||||
"questions": []
|
||||
}
|
||||
""")
|
||||
#expect(request.files.map(\.name) == ["compose.yml", "health.ts"])
|
||||
#expect(request.files[0].additions == 9)
|
||||
#expect(request.files[1].deletions == nil)
|
||||
#expect(request.files[0].preview.count == 2)
|
||||
|
||||
// Older gateways send neither, which decodes as nothing snoozed and no files.
|
||||
let old = try decode(Feed.self, """
|
||||
{ "needsYou": [\(feedItemJSON)], "working": [], "recent": [], "hosts": [] }
|
||||
""")
|
||||
#expect(old.snoozedTotal == 0)
|
||||
#expect(old.needsYou.first?.snoozedUntil == nil)
|
||||
#expect(old.needsYou.first?.request?.files == [])
|
||||
}
|
||||
|
||||
@Test func pushQuestion() throws {
|
||||
let info: [AnyHashable: Any] = [
|
||||
"hostId": "h", "threadId": "t", "requestId": "r",
|
||||
@@ -549,7 +593,7 @@ private let feedItemJSON = """
|
||||
#expect(Route(url: thread.url) == thread)
|
||||
#expect(Route(url: Route.newThread.url) == .newThread)
|
||||
for route in [
|
||||
Route.recent, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"),
|
||||
Route.recent, .snoozed, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"),
|
||||
.projectThreads(hostId: "mac mini", projectId: "p/1"), .search(query: "bun & test?"),
|
||||
] {
|
||||
#expect(Route(url: route.url) == route)
|
||||
@@ -576,6 +620,8 @@ 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")))
|
||||
#expect(NotificationCommand(actionIdentifier: "settle", userInfo: info, text: nil) == .settle(hostId: "h", threadId: "t"))
|
||||
#expect(NotificationCommand(actionIdentifier: "settle", userInfo: ["hostId": "h"], text: nil) == .open(nil))
|
||||
// Answer opens the thread's question screen.
|
||||
#expect(NotificationCommand(actionIdentifier: "answer", userInfo: info, text: nil) == .open(.thread(hostId: "h", threadId: "t")))
|
||||
}
|
||||
@@ -605,6 +651,52 @@ private let feedItemJSON = """
|
||||
#expect(NotificationCommand(actionIdentifier: "option.0", userInfo: ["hostId": "h", "threadId": "t", "requestId": "r"], text: nil) == thread)
|
||||
}
|
||||
|
||||
@MainActor @Test func finishedNotificationActions() throws {
|
||||
let finished = try #require(NotificationHandler.categories.first { $0.identifier == PushCategory.finished.rawValue })
|
||||
#expect(finished.actions.map(\.title) == ["Reply", "Settle", "Open"])
|
||||
// Settle runs in the background, without opening the app.
|
||||
#expect(!finished.actions[1].options.contains(.foreground))
|
||||
}
|
||||
|
||||
@MainActor @Test func cancelledCallsShowNoError() {
|
||||
let model = AppModel(mode: .demo)
|
||||
#expect(model.message(for: CancellationError()) == nil)
|
||||
#expect(model.message(for: URLError(.cancelled)) == nil)
|
||||
#expect(GatewayClient.transportError(URLError(.cancelled)) is CancellationError)
|
||||
#expect(model.message(for: GatewayClient.transportError(URLError(.cancelled))) == nil)
|
||||
// Other transport failures still show.
|
||||
let offline = GatewayClient.transportError(URLError(.notConnectedToInternet))
|
||||
#expect(offline as? GatewayError == .transport(URLError(.notConnectedToInternet).localizedDescription))
|
||||
#expect(model.message(for: offline) != nil)
|
||||
}
|
||||
|
||||
@Test func fileEditLines() throws {
|
||||
func request(_ files: [PendingFile], kind: String = "file-change") -> PendingRequest {
|
||||
PendingRequest(id: "r", kind: kind, prompt: "Edit: a", reason: nil, options: [], questions: [], files: files)
|
||||
}
|
||||
let compose = PendingFile(
|
||||
path: "deploy/compose.yml", name: "compose.yml", additions: 9, deletions: 6,
|
||||
preview: ["- image: bun:1", "+ image: bun:1-alpine", " context"])
|
||||
let health = PendingFile(path: "src/health.ts", name: "health.ts", additions: nil, deletions: nil, preview: ["+x"])
|
||||
|
||||
let one = try #require(request([compose]).fileEdit)
|
||||
#expect(one.title == "Wants to edit")
|
||||
#expect(one.sentence == "Wants to edit compose.yml")
|
||||
#expect(one.preview.map(\.change) == [.removed, .added, .context])
|
||||
#expect(compose.counts.additions == "+9" && compose.counts.deletions == "\u{2212}6")
|
||||
#expect(health.counts.additions == nil && health.counts.deletions == nil)
|
||||
|
||||
let two = try #require(request([compose, health]).fileEdit)
|
||||
#expect(two.title == "Wants to edit 2 files")
|
||||
#expect(two.sentence == "Wants to edit 2 files: compose.yml, health.ts")
|
||||
// Several files show no preview.
|
||||
#expect(two.preview.isEmpty)
|
||||
|
||||
// No files, or another kind, keeps the prompt.
|
||||
#expect(request([]).fileEdit == nil)
|
||||
#expect(request([compose], kind: "command").fileEdit == nil)
|
||||
}
|
||||
|
||||
@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")],
|
||||
@@ -827,3 +919,106 @@ private let feedItemJSON = """
|
||||
#expect(try await gateway.feed().working.contains { $0.id == item.id })
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct SnoozeTests {
|
||||
private var calendar: Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "America/Chicago")!
|
||||
return calendar
|
||||
}
|
||||
|
||||
private func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date {
|
||||
calendar.date(from: DateComponents(year: 2026, month: 11, day: day, hour: hour, minute: minute))!
|
||||
}
|
||||
|
||||
@Test func choicesOnAMondayAfternoon() {
|
||||
// Monday, November 2, 2026 at 2 PM.
|
||||
let choices = SnoozeChoice.choices(now: date(2, 14), calendar: calendar)
|
||||
#expect(choices.map(\.title) == ["In 1 hour", "This evening · 6 PM", "Tomorrow · 9 AM", "Next week"])
|
||||
#expect(choices.map(\.until) == [date(2, 15), date(2, 18), date(3, 9), date(9, 9)])
|
||||
}
|
||||
|
||||
@Test func eveningLeavesFromFivePM() {
|
||||
#expect(SnoozeChoice.choices(now: date(2, 16, 59), calendar: calendar).count == 4)
|
||||
let late = SnoozeChoice.choices(now: date(2, 17, 30), calendar: calendar)
|
||||
#expect(late.map(\.title) == ["In 1 hour", "Tomorrow · 9 AM", "Next week"])
|
||||
}
|
||||
|
||||
@Test func nextWeekIsNextMonday() {
|
||||
// Sunday the 8th: tomorrow is Monday. Saturday the 7th: two days on.
|
||||
#expect(SnoozeChoice.choices(now: date(8, 10), calendar: calendar).last?.until == date(9, 9))
|
||||
#expect(SnoozeChoice.choices(now: date(7, 10), calendar: calendar).last?.until == date(9, 9))
|
||||
#expect(SnoozeChoice.choices(now: date(4, 10), calendar: calendar).last?.until == date(9, 9))
|
||||
}
|
||||
|
||||
@Test func wakesWording() {
|
||||
let now = date(2, 14)
|
||||
#expect(Format.wakes(at: date(2, 18), now: now, calendar: calendar) == "Wakes at 6 PM")
|
||||
#expect(Format.wakes(at: date(2, 15, 30), now: now, calendar: calendar) == "Wakes at 3:30 PM")
|
||||
#expect(Format.wakes(at: date(3, 9), now: now, calendar: calendar) == "Wakes tomorrow 9 AM")
|
||||
#expect(Format.wakes(at: date(6, 9), now: now, calendar: calendar) == "Wakes Friday 9 AM")
|
||||
#expect(Format.wakes(at: date(30, 9), now: now, calendar: calendar) == "Wakes Nov 30, 9 AM")
|
||||
}
|
||||
|
||||
@Test func snoozeMovesAThreadFromRecentToSnoozed() async throws {
|
||||
let gateway = DemoGateway()
|
||||
let before = try await gateway.feed()
|
||||
let item = try #require(before.recent.first)
|
||||
try await gateway.snooze(hostId: item.hostId, threadId: item.threadId, until: .now.addingTimeInterval(3600))
|
||||
let after = try await gateway.feed()
|
||||
#expect(!after.recent.contains { $0.id == item.id })
|
||||
#expect(after.snoozedTotal == before.snoozedTotal + 1)
|
||||
#expect(try await gateway.snoozed().items.contains { $0.id == item.id && $0.snoozedUntil != nil })
|
||||
|
||||
try await gateway.unsnooze(hostId: item.hostId, threadId: item.threadId)
|
||||
#expect(try await gateway.feed().recent.contains { $0.id == item.id })
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor @Suite struct FeedCacheTests {
|
||||
private func temporaryCache() -> FeedCache {
|
||||
FeedCache(directory: FileManager.default.temporaryDirectory.appending(path: "FeedCacheTests-\(UUID().uuidString)"))
|
||||
}
|
||||
|
||||
/// Fixtures from a whole second, so their dates survive JSON, which keeps milliseconds.
|
||||
private let gateway = DemoGateway(now: Date(timeIntervalSince1970: 1_793_646_000))
|
||||
|
||||
@Test func roundTrip() async throws {
|
||||
let cache = temporaryCache()
|
||||
defer { cache.clear() }
|
||||
#expect(cache.feed() == nil)
|
||||
let feed = try await gateway.feed()
|
||||
cache.save(feed)
|
||||
#expect(cache.feed() == feed)
|
||||
|
||||
let detail = try await gateway.thread(hostId: "server-gib", threadId: "t-compose")
|
||||
cache.save(detail)
|
||||
#expect(cache.thread(hostId: "server-gib", threadId: "t-compose") == detail)
|
||||
#expect(cache.thread(hostId: "server-gib", threadId: "t-other") == nil)
|
||||
}
|
||||
|
||||
@Test func pairedModelStartsOnTheCacheAndSignOutClearsIt() async throws {
|
||||
let cache = temporaryCache()
|
||||
defer { cache.clear() }
|
||||
let feed = try await gateway.feed()
|
||||
cache.save(feed)
|
||||
let credentials = Credentials(gatewayURL: URL(string: "https://gateway.invalid")!, token: "token")
|
||||
let model = AppModel(mode: .paired(credentials), cache: cache)
|
||||
#expect(model.feed == feed)
|
||||
#expect(model.feedIsCached)
|
||||
|
||||
model.signOut()
|
||||
#expect(cache.feed() == nil)
|
||||
#expect(model.feed == nil)
|
||||
}
|
||||
|
||||
@Test func demoModeKeepsOffTheCache() async throws {
|
||||
let cache = temporaryCache()
|
||||
defer { cache.clear() }
|
||||
cache.save(try await DemoGateway().feed())
|
||||
let model = AppModel(mode: .demo, cache: cache)
|
||||
#expect(model.feed == nil)
|
||||
#expect(!model.feedIsCached)
|
||||
#expect(model.cachedThread(hostId: "server-gib", threadId: "t-compose") == nil)
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,40 @@ struct SnapshotTests {
|
||||
try write(titled("Notifications", NotificationsView(status: status, result: result, scrolls: false)), "status-notifications.png")
|
||||
}
|
||||
|
||||
@Test func snooze() async throws {
|
||||
let feed = try await DemoGateway().feed()
|
||||
let item = try #require(feed.recent.first)
|
||||
// 2 PM today, before This evening drops out, so all four choices show.
|
||||
let afternoon = try #require(Calendar.current.date(bySettingHour: 14, minute: 0, second: 0, of: .now))
|
||||
try write(titled("Snooze until", SnoozePicker(item: item, now: afternoon, scrolls: false)), "snooze-picker.png")
|
||||
// The preview model has the demo's snoozed threads loaded.
|
||||
try write(titled("Snoozed", SnoozedView(scrolls: false)), "snoozed.png")
|
||||
try write(feedColumn {
|
||||
RecentHeader(recent: feed.recent, more: feed.moreRecent)
|
||||
ForEach(feed.recent.suffix(2)) { StatusCard(item: $0) }
|
||||
MoreRecentRow(count: feed.moreRecent)
|
||||
SnoozedRow(count: feed.snoozedTotal)
|
||||
}, "feed-snoozed.png")
|
||||
}
|
||||
|
||||
@Test func fileApprovals() async throws {
|
||||
let gateway = DemoGateway()
|
||||
let feed = try await gateway.feed()
|
||||
let files = feed.needsYou.filter { $0.request?.fileEdit != nil }
|
||||
#expect(files.count == 2)
|
||||
try write(feedColumn { ForEach(files) { NeedsYouCard(item: $0) } }, "approval-file.png")
|
||||
let detail = try await gateway.thread(hostId: "server-gib", threadId: "t-compose")
|
||||
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-compose", detail: detail, scrolls: false)), "thread-approval-file.png")
|
||||
}
|
||||
|
||||
@Test func cachedFeed() async throws {
|
||||
let feed = try await DemoGateway().feed()
|
||||
try write(feedColumn {
|
||||
FeedHeader(feed: feed, cached: true)
|
||||
ForEach(feed.needsYou.prefix(2)) { NeedsYouCard(item: $0) }
|
||||
}, "feed-cached.png")
|
||||
}
|
||||
|
||||
/// Feed rows stacked as the feed's List lays them out, which ImageRenderer leaves blank.
|
||||
private func feedColumn(@ViewBuilder _ rows: () -> some View) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
|
||||
Reference in new issue
Block a user