gitbayTests/LabelTests.swift
352 lines · 14030 bytes
1import Foundation
2import Testing
3@testable import gitbay
4
5struct LabelPaletteTests {
6
7 // Golden values computed from the web's own algorithm: FNV-1a 32 of
8 // the name, modulo the eight-colour palette. If these drift, the two
9 // surfaces paint the same label differently.
10 @Test func derivesAStableColourFromTheName() {
11 #expect(LabelPalette.hex(name: "bug", stored: nil) == "#b93a86")
12 #expect(LabelPalette.hex(name: "ci", stored: nil) == "#1a7f37")
13 #expect(LabelPalette.hex(name: "docs", stored: nil) == "#9a6700")
14 #expect(LabelPalette.hex(name: "ops", stored: nil) == "#cf222e")
15 #expect(LabelPalette.hex(name: "mobile", stored: nil) == "#bf5b16")
16 }
17
18 @Test func theSameNameAlwaysDerivesTheSameColour() {
19 #expect(LabelPalette.hex(name: "bug", stored: nil)
20 == LabelPalette.hex(name: "bug", stored: nil))
21 }
22
23 /// A stored colour already inside the luminance window is used as-is.
24 @Test func keepsAStoredColourThatIsAlreadyLegible() {
25 #expect(LabelPalette.hex(name: "bug", stored: "#ff0000") == "#ff0000")
26 #expect(LabelPalette.hex(name: "bug", stored: "#0969da") == "#0969da")
27 }
28
29 /// Too dark or too bright is scaled back into the window, so a chip
30 /// stays readable as text on both grounds.
31 @Test func clampsAStoredColourOutsideTheWindow() {
32 #expect(LabelPalette.hex(name: "bug", stored: "#000000") == "#616161")
33 #expect(LabelPalette.hex(name: "bug", stored: "#ffffff") == "#909090")
34 }
35
36 @Test func upperCaseStoredColoursNormaliseDown() {
37 #expect(LabelPalette.hex(name: "bug", stored: "#FF0000") == "#ff0000")
38 }
39
40 /// `label set` stores `#rrggbb`. Anything else — an empty string, a
41 /// bare six digits, a name — never reached the store as a colour, so
42 /// it is treated as unset and the name derives one.
43 @Test func anythingThatIsNotAHexColourFallsBackToTheName() {
44 let derived = LabelPalette.hex(name: "bug", stored: nil)
45 #expect(LabelPalette.hex(name: "bug", stored: "") == derived)
46 #expect(LabelPalette.hex(name: "bug", stored: "ff0000") == derived)
47 #expect(LabelPalette.hex(name: "bug", stored: "red") == derived)
48 #expect(LabelPalette.hex(name: "bug", stored: "#fff") == derived)
49 }
50
51 @Test func everyPaletteEntryIsAlreadyLegible() {
52 // No palette colour is ever clamped, so a derived colour is a
53 // plain lookup. If this fails the palette drifted from the web's.
54 for hex in LabelPalette.palette {
55 #expect(LabelPalette.hex(name: "x", stored: hex) == hex)
56 }
57 }
58}
59
60struct IssueLabelDecodingTests {
61
62 @Test func decodesAListRow() throws {
63 let json = Data("""
64 [{"name":"bug","color":"#cf222e","issues":18},{"name":"ci","issues":11}]
65 """.utf8)
66 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
67
68 #expect(labels.count == 2)
69 #expect(labels[0].name == "bug")
70 #expect(labels[0].color == "#cf222e")
71 #expect(labels[0].issues == 18)
72 // `color` is omitempty on the wire — absent, not null, when unset.
73 #expect(labels[1].color == nil)
74 #expect(labels[1].issues == 11)
75 }
76
77 @Test func theIdIsTheName() throws {
78 let json = Data(#"[{"name":"bug","issues":0}]"#.utf8)
79 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
80 #expect(labels[0].id == "bug")
81 }
82}
83
84struct LabelColorsTests {
85
86 @Test func looksUpAStoredColour() {
87 let colors = LabelColors([
88 IssueLabel(name: "bug", color: "#cf222e", issues: 1),
89 IssueLabel(name: "ci", color: nil, issues: 2),
90 ])
91 #expect(colors.hex("bug") == "#cf222e")
92 #expect(colors.hex("ci") == "#1a7f37")
93 }
94
95 /// An issue can carry a label the map has not loaded — a stale list, or
96 /// a label added since. It still derives a colour rather than going grey.
97 @Test func anUnknownLabelStillDerivesItsColour() {
98 #expect(LabelColors().hex("docs") == "#9a6700")
99 }
100}
101
102private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
103 let box = StubProtocol.box()
104 let client = GitbayClient(
105 instance: try GitbayInstance(url: "https://gitbay.org"),
106 token: "test-token",
107 session: box.session()
108 )
109 return (client, box)
110}
111
112private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
113 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
114 return try #require(body["argv"] as? [String])
115}
116
117private let issuePageJSON = """
118 {"protocol_version":1,"data":{"items":[\
119 {"number":7,"title":"a bug","state":"open","author":"cmc",\
120 "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0}
121 """
122private let labelListJSON = """
123 {"protocol_version":1,"data":[\
124 {"name":"bug","color":"#cf222e","issues":18},\
125 {"name":"ci","issues":11}],"exit_code":0}
126 """
127
128@MainActor
129struct IssueListColorsTests {
130
131 @Test func loadsLabelsAlongsideTheIssues() async throws {
132 let (client, stub) = try makeClient()
133 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
134 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
135
136 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
137 await model.load()
138
139 #expect(model.state.value?.count == 1)
140 // The names are what MR 2's filter picker will offer.
141 #expect(model.labels.map(\.name) == ["bug", "ci"])
142 #expect(model.colors.hex("bug") == "#cf222e")
143 #expect(model.colors.hex("ci") == "#1a7f37")
144 #expect(stub.seen.contains {
145 $0.url.query() == "argv=label&argv=list&argv=krz/gitbay"
146 })
147 }
148
149 /// Decoration must not take the screen down with it.
150 @Test func issuesStillLoadWhenTheLabelReadFails() async throws {
151 let (client, stub) = try makeClient()
152 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
153 stub.enqueue(.init(status: 403, json: """
154 {"protocol_version":1,"error":"denied","exit_code":4}
155 """, match: "argv=label"))
156
157 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
158 await model.load()
159
160 #expect(model.state.value?.count == 1)
161 #expect(model.labels.isEmpty)
162 // Derived from the name, since nothing was stored.
163 #expect(model.colors.hex("bug") == "#b93a86")
164 }
165}
166
167@MainActor
168struct IssueDetailColorsTests {
169
170 @Test func loadsLabelsAlongsideTheIssue() async throws {
171 let (client, stub) = try makeClient()
172 stub.enqueue(.init(status: 200, json: """
173 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
174 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
175 "exit_code":0}
176 """, match: "argv=show"))
177 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
178
179 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
180 await model.load()
181
182 #expect(model.state.value?.number == 7)
183 #expect(model.colors.hex("bug") == "#cf222e")
184 }
185
186 /// Decoration must not take the screen down with it.
187 @Test func issueStillLoadsWhenTheLabelReadFails() async throws {
188 let (client, stub) = try makeClient()
189 stub.enqueue(.init(status: 200, json: """
190 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
191 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
192 "exit_code":0}
193 """, match: "argv=show"))
194 stub.enqueue(.init(status: 403, json: """
195 {"protocol_version":1,"error":"denied","exit_code":4}
196 """, match: "argv=label"))
197
198 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
199 await model.load()
200
201 #expect(model.state.value?.number == 7)
202 // Derived from the name, since nothing was stored.
203 #expect(model.colors.hex("bug") == "#b93a86")
204 }
205
206 /// Regression test: `perform(argv:)` used to reload labels on every
207 /// write. Labels rarely change mid-session, so the read must happen
208 /// at most once per view model instance.
209 @Test func aWriteDoesNotReissueLabelList() async throws {
210 let (client, stub) = try makeClient()
211 stub.enqueue(.init(status: 200, json: """
212 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
213 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
214 "exit_code":0}
215 """, match: "argv=show"))
216 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
217
218 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
219 await model.load()
220
221 stub.enqueue(.init(status: 200, json: """
222 {"protocol_version":1,"exit_code":0}
223 """, match: "argv=close"))
224 stub.enqueue(.init(status: 200, json: """
225 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"closed",\
226 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
227 "exit_code":0}
228 """, match: "argv=show"))
229
230 await model.close()
231
232 let labelReads = stub.seen.filter { ($0.url.query() ?? "").contains("argv=label") }
233 #expect(labelReads.count == 1)
234 }
235}
236
237@MainActor
238struct LabelListViewModelTests {
239
240 private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) {
241 let (client, stub) = try makeClient()
242 stub.enqueue(.init(status: 200, json: labelListJSON))
243 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
244 await model.load()
245 return (model, stub)
246 }
247
248 @Test func listsLabelsWithTheirUseCounts() async throws {
249 let (model, stub) = try await loaded()
250
251 #expect(model.state.value?.map(\.name) == ["bug", "ci"])
252 #expect(model.state.value?.map(\.issues) == [18, 11])
253 #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay")
254 }
255
256 @Test func aRepoWithNoLabelsIsAnEmptyState() async throws {
257 let (client, stub) = try makeClient()
258 stub.enqueue(.init(status: 200, json: """
259 {"protocol_version":1,"exit_code":0}
260 """))
261 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
262 await model.load()
263
264 guard case .empty = model.state else {
265 Testing.Issue.record("expected an empty state, got \(model.state)")
266 return
267 }
268 }
269
270 @Test func settingAColourSendsTheColourFlag() async throws {
271 let (model, stub) = try await loaded()
272 stub.enqueue(.init(status: 200, json: """
273 {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0}
274 """))
275 stub.enqueue(.init(status: 200, json: labelListJSON))
276
277 await model.setLabel("bug", color: "cf222e")
278
279 let write = try #require(stub.seen.first { $0.method == "POST" })
280 #expect(try argvOf(write)
281 == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"])
282 }
283
284 /// A label created without a colour must not send `--color`, which
285 /// would be "clear it" rather than "leave whatever it has".
286 @Test func creatingWithoutAColourOmitsTheFlag() async throws {
287 let (model, stub) = try await loaded()
288 stub.enqueue(.init(status: 200, json: """
289 {"protocol_version":1,"data":{"name":"new"},"exit_code":0}
290 """))
291 stub.enqueue(.init(status: 200, json: labelListJSON))
292
293 await model.setLabel("new", color: nil)
294
295 let write = try #require(stub.seen.first { $0.method == "POST" })
296 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"])
297 }
298
299 /// An empty string is not nil: it clears the colour.
300 @Test func clearingSendsAnEmptyColour() async throws {
301 let (model, stub) = try await loaded()
302 stub.enqueue(.init(status: 200, json: """
303 {"protocol_version":1,"data":{"name":"bug"},"exit_code":0}
304 """))
305 stub.enqueue(.init(status: 200, json: labelListJSON))
306
307 await model.setLabel("bug", color: "")
308
309 let write = try #require(stub.seen.first { $0.method == "POST" })
310 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""])
311 }
312
313 @Test func removingSendsTheRemoveCommand() async throws {
314 let (model, stub) = try await loaded()
315 stub.enqueue(.init(status: 200, json: """
316 {"protocol_version":1,"exit_code":0}
317 """))
318 stub.enqueue(.init(status: 200, json: labelListJSON))
319
320 await model.remove("bug")
321
322 let write = try #require(stub.seen.first { $0.method == "POST" })
323 #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"])
324 }
325
326 @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws {
327 let (model, stub) = try await loaded()
328 stub.enqueue(.init(status: 403, json: """
329 {"protocol_version":1,"error":"write access required","exit_code":4}
330 """))
331
332 await model.remove("bug")
333
334 #expect(model.actionError?.isEmpty == false)
335 #expect(model.working == false)
336 }
337
338 @Test func aServerFailureSurfacesAsFailed() async throws {
339 let (client, stub) = try makeClient()
340 stub.enqueue(.init(status: 200, json: """
341 {"protocol_version":1,"error":"boom","exit_code":1}
342 """))
343 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
344
345 await model.load()
346
347 guard case .failed = model.state else {
348 Testing.Issue.record("expected a failed state, got \(model.state)")
349 return
350 }
351 }
352}