Commit 5c2efd71c6

5c2efd71c6bfd5b1fa90a16d02fbd0309dd62feb

parent: f321cd5b62

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 23:14 UTC

Fix notification inbox review findings

- Pin the dashboard unread count in the fixture and assert it decodes,
  so a wrong key name can't silently fall back to 0.
- Gate swipe-to-mark-read on `working`, matching the toolbar action.
- Clear `actionError` on load and on filter change, not just at the
  start of the next action.
- Give the dashboard bell an accessibility label that states the
  unread count as one sentence, not a bare number after "bell".
- Use fractional-second timestamps in notification fixtures to match
  the server and the rest of the suite; add a cursor-reset test for a
  notifications filter change.

Layout: unified · split

gitbay/Account/NotificationsViewModel.swift +3
@@ -60,6 +60,7 @@ final class NotificationsViewModel {
6060 var showAll = false {
6161 didSet {
6262 guard showAll != oldValue else { return }
63 actionError = nil
6364 configureList()
6465 reloadTask?.cancel()
6566 reloadTask = Task { await list.reload() }
@@ -85,10 +86,12 @@ final class NotificationsViewModel {
8586
8687 func load() async {
8788 reloadTask?.cancel()
89 actionError = nil
8890 await list.reload()
8991 }
9092
9193 func markRead(_ id: Int64) async {
94 guard !working else { return }
9295 await perform(["notifications", "read", String(id)])
9396 }
9497
gitbay/Views/Dashboard/DashboardView.swift +6
@@ -55,6 +55,7 @@ struct DashboardView: View {
5555 }
5656 }
5757 .accessibilityIdentifier("dashboard-notifications-button")
58 .accessibilityLabel(bellLabel)
5859 }
5960 ToolbarItem(placement: .topBarTrailing) {
6061 Button {
@@ -78,6 +79,11 @@ struct DashboardView: View {
7879 .refreshable { await model.load() }
7980 }
8081
82 private var bellLabel: String {
83 guard let unread = model.state.value?.unread, unread > 0 else { return "Notifications" }
84 return "Notifications, \(unread) unread"
85 }
86
8187 private func itemSection(
8288 _ title: String,
8389 items: [DashboardItem],
gitbayTests/DashboardViewModelTests.swift +3 −1
@@ -27,7 +27,8 @@ private let dashboardJSON = """
2727 "data":{"number":2},"created_at":"2026-08-27T10:30:00.000Z"}],\
2828 "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\
2929 "sha":"65ba14e0000000000000","ref":"refs/heads/main",\
30 "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}]},\
30 "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}],\
31 "unread":3},\
3132 "exit_code":0}
3233 """
3334
@@ -54,6 +55,7 @@ struct DashboardViewModelTests {
5455 #expect(data.openIssues.first?.number == 11)
5556 #expect(data.recentActivity.first?.phrase == "merged !2")
5657 #expect(data.builds.first?.status == "success")
58 #expect(data.unread == 3)
5759 // The whole screen cost exactly one request.
5860 #expect(stub.seen.count == 1)
5961 #expect(stub.seen.first?.url.query() == "argv=dashboard")
gitbayTests/NotificationTests.swift +27 −6
@@ -31,7 +31,7 @@ private func decode(_ json: String) throws -> InboxNotification {
3131private let unreadBuild = """
3232 {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
3333 "summary":"build 966 failed","path":"krz/gitbay/builds/966",\
34 "created_at":"2026-09-06T22:14:46Z"}
34 "created_at":"2026-09-06T22:14:46.000Z"}
3535 """
3636
3737struct NotificationDecodingTests {
@@ -48,7 +48,7 @@ struct NotificationDecodingTests {
4848 let n = try decode("""
4949 {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
5050 "summary":"x","path":"krz/gitbay/builds/966",\
51 "created_at":"2026-09-06T22:14:46Z","read_at":"2026-09-06T22:20:24Z"}
51 "created_at":"2026-09-06T22:14:46.000Z","read_at":"2026-09-06T22:20:24.000Z"}
5252 """)
5353 #expect(n.readAt != nil)
5454 #expect(n.isUnread == false)
@@ -57,14 +57,14 @@ struct NotificationDecodingTests {
5757 @Test func everyKindRoutesToItsScreen() throws {
5858 let issue = try decode("""
5959 {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
60 "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46Z"}
60 "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46.000Z"}
6161 """)
6262 #expect(issue.destination == .issue(repo: "krz/gitbay", number: 168))
6363
6464 // The segment is "mrs", not "merge_requests".
6565 let mr = try decode("""
6666 {"id":2,"repo":"krz/gitbay","kind":"mr","actor":"cmc","summary":"x",\
67 "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46Z"}
67 "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46.000Z"}
6868 """)
6969 #expect(mr.destination == .mr(repo: "krz/gitbay", number: 282))
7070
@@ -79,7 +79,7 @@ struct NotificationDecodingTests {
7979 "too/short", "krz/gitbay/issues/1/extra", ""] {
8080 let n = try decode("""
8181 {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
82 "path":"\(path)","created_at":"2026-09-06T22:14:46Z"}
82 "path":"\(path)","created_at":"2026-09-06T22:14:46.000Z"}
8383 """)
8484 #expect(n.destination == nil, "\(path) should not route")
8585 }
@@ -90,7 +90,7 @@ struct NotificationDecodingTests {
9090 @Test func theDestinationRepoComesFromThePath() throws {
9191 let n = try decode("""
9292 {"id":1,"repo":"other/repo","kind":"issue","actor":"cmc","summary":"x",\
93 "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46Z"}
93 "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46.000Z"}
9494 """)
9595 #expect(n.destination == .issue(repo: "krz/gitbay", number: 7))
9696 }
@@ -103,6 +103,10 @@ struct NotificationsViewModelTests {
103103 {"protocol_version":1,"data":{"items":[\(unreadBuild)]},"exit_code":0}
104104 """
105105
106 private let pageWithCursor = """
107 {"protocol_version":1,"data":{"items":[\(unreadBuild)],"next":"c1"},"exit_code":0}
108 """
109
106110 @Test func theDefaultListingIsUnreadOnly() async throws {
107111 let (client, stub) = try makeClient()
108112 stub.enqueue(.init(status: 200, json: page))
@@ -128,6 +132,23 @@ struct NotificationsViewModelTests {
128132 #expect(argvFrom(try #require(stub.seen.last).url).contains("--all"))
129133 }
130134
135 /// A filter change reloads rather than pages, so it must not carry
136 /// forward a cursor from whatever page was on screen.
137 @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws {
138 let (client, stub) = try makeClient()
139 stub.enqueue(.init(status: 200, json: pageWithCursor))
140 let model = NotificationsViewModel(client: client)
141 await model.load()
142 #expect(model.list.hasMore)
143
144 stub.enqueue(.init(status: 200, json: page))
145 model.showAll = true
146 try await Task.sleep(for: .milliseconds(150))
147
148 let argv = argvFrom(try #require(stub.seen.last).url)
149 #expect(argv.contains("--cursor") == false)
150 }
151
131152 @Test func markingOneReadSendsItsId() async throws {
132153 let (client, stub) = try makeClient()
133154 stub.enqueue(.init(status: 200, json: page))