gitbayTests/LabelTests.swift
361 lines · 14470 bytes
34 symbols in this file
LabelPaletteTestsderivesAStableColourFromTheNametheSameNameAlwaysDerivesTheSameColourkeepsAStoredColourThatIsAlreadyLegibleclampsAStoredColourOutsideTheWindowupperCaseStoredColoursNormaliseDownanythingThatIsNotAHexColourFallsBackToTheNameeveryPaletteEntryIsAlreadyLegibleIssueLabelDecodingTestsdecodesAListRowanOrgRowIsMarkedAndARepoRowIsNottheIdIsTheNameLabelColorsTestslooksUpAStoredColouranUnknownLabelStillDerivesItsColourmakeClientargvOfIssueListColorsTestsloadsLabelsAlongsideTheIssuesissuesStillLoadWhenTheLabelReadFailsIssueDetailColorsTestsloadsLabelsAlongsideTheIssueissueStillLoadsWhenTheLabelReadFailsaWriteDoesNotReissueLabelListLabelListViewModelTestsloadedlistsLabelsWithTheirUseCountsaRepoWithNoLabelsIsAnEmptyStatesettingAColourSendsTheColourFlagcreatingWithoutAColourOmitsTheFlagclearingSendsAnEmptyColourremovingSendsTheRemoveCommandaRefusalSurfacesRatherThanBeingSwallowedaServerFailureSurfacesAsFailed
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 /// v1.19.0: a row the repository sees through its org carries
78 /// `org: true`; repository rows and older servers omit the key.
79 @Test func anOrgRowIsMarkedAndARepoRowIsNot() throws {
80 let json = Data(#"[{"name":"bug","org":true,"issues":4},{"name":"ci","issues":1}]"#.utf8)
81 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
82 #expect(labels[0].org)
83 #expect(!labels[1].org)
84 }
85
86 @Test func theIdIsTheName() throws {
87 let json = Data(#"[{"name":"bug","issues":0}]"#.utf8)
88 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
89 #expect(labels[0].id == "bug")
90 }
91}
92
93struct LabelColorsTests {
94
95 @Test func looksUpAStoredColour() {
96 let colors = LabelColors([
97 IssueLabel(name: "bug", color: "#cf222e", issues: 1),
98 IssueLabel(name: "ci", color: nil, issues: 2),
99 ])
100 #expect(colors.hex("bug") == "#cf222e")
101 #expect(colors.hex("ci") == "#1a7f37")
102 }
103
104 /// An issue can carry a label the map has not loaded — a stale list, or
105 /// a label added since. It still derives a colour rather than going grey.
106 @Test func anUnknownLabelStillDerivesItsColour() {
107 #expect(LabelColors().hex("docs") == "#9a6700")
108 }
109}
110
111private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
112 let box = StubProtocol.box()
113 let client = GitbayClient(
114 instance: try GitbayInstance(url: "https://gitbay.org"),
115 token: "test-token",
116 session: box.session()
117 )
118 return (client, box)
119}
120
121private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
122 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
123 return try #require(body["argv"] as? [String])
124}
125
126private let issuePageJSON = """
127 {"protocol_version":1,"data":{"items":[\
128 {"number":7,"title":"a bug","state":"open","author":"cmc",\
129 "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0}
130 """
131private let labelListJSON = """
132 {"protocol_version":1,"data":[\
133 {"name":"bug","color":"#cf222e","issues":18},\
134 {"name":"ci","issues":11}],"exit_code":0}
135 """
136
137@MainActor
138struct IssueListColorsTests {
139
140 @Test func loadsLabelsAlongsideTheIssues() async throws {
141 let (client, stub) = try makeClient()
142 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
143 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
144
145 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
146 await model.load()
147
148 #expect(model.state.value?.count == 1)
149 // The names are what MR 2's filter picker will offer.
150 #expect(model.labels.map(\.name) == ["bug", "ci"])
151 #expect(model.colors.hex("bug") == "#cf222e")
152 #expect(model.colors.hex("ci") == "#1a7f37")
153 #expect(stub.seen.contains {
154 $0.url.query() == "argv=label&argv=list&argv=krz/gitbay"
155 })
156 }
157
158 /// Decoration must not take the screen down with it.
159 @Test func issuesStillLoadWhenTheLabelReadFails() async throws {
160 let (client, stub) = try makeClient()
161 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
162 stub.enqueue(.init(status: 403, json: """
163 {"protocol_version":1,"error":"denied","exit_code":4}
164 """, match: "argv=label"))
165
166 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
167 await model.load()
168
169 #expect(model.state.value?.count == 1)
170 #expect(model.labels.isEmpty)
171 // Derived from the name, since nothing was stored.
172 #expect(model.colors.hex("bug") == "#b93a86")
173 }
174}
175
176@MainActor
177struct IssueDetailColorsTests {
178
179 @Test func loadsLabelsAlongsideTheIssue() async throws {
180 let (client, stub) = try makeClient()
181 stub.enqueue(.init(status: 200, json: """
182 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
183 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
184 "exit_code":0}
185 """, match: "argv=show"))
186 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
187
188 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
189 await model.load()
190
191 #expect(model.state.value?.number == 7)
192 #expect(model.colors.hex("bug") == "#cf222e")
193 }
194
195 /// Decoration must not take the screen down with it.
196 @Test func issueStillLoadsWhenTheLabelReadFails() async throws {
197 let (client, stub) = try makeClient()
198 stub.enqueue(.init(status: 200, json: """
199 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
200 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
201 "exit_code":0}
202 """, match: "argv=show"))
203 stub.enqueue(.init(status: 403, json: """
204 {"protocol_version":1,"error":"denied","exit_code":4}
205 """, match: "argv=label"))
206
207 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
208 await model.load()
209
210 #expect(model.state.value?.number == 7)
211 // Derived from the name, since nothing was stored.
212 #expect(model.colors.hex("bug") == "#b93a86")
213 }
214
215 /// Regression test: `perform(argv:)` used to reload labels on every
216 /// write. Labels rarely change mid-session, so the read must happen
217 /// at most once per view model instance.
218 @Test func aWriteDoesNotReissueLabelList() async throws {
219 let (client, stub) = try makeClient()
220 stub.enqueue(.init(status: 200, json: """
221 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
222 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
223 "exit_code":0}
224 """, match: "argv=show"))
225 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
226
227 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
228 await model.load()
229
230 stub.enqueue(.init(status: 200, json: """
231 {"protocol_version":1,"exit_code":0}
232 """, match: "argv=close"))
233 stub.enqueue(.init(status: 200, json: """
234 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"closed",\
235 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
236 "exit_code":0}
237 """, match: "argv=show"))
238
239 await model.close()
240
241 let labelReads = stub.seen.filter { ($0.url.query() ?? "").contains("argv=label") }
242 #expect(labelReads.count == 1)
243 }
244}
245
246@MainActor
247struct LabelListViewModelTests {
248
249 private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) {
250 let (client, stub) = try makeClient()
251 stub.enqueue(.init(status: 200, json: labelListJSON))
252 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
253 await model.load()
254 return (model, stub)
255 }
256
257 @Test func listsLabelsWithTheirUseCounts() async throws {
258 let (model, stub) = try await loaded()
259
260 #expect(model.state.value?.map(\.name) == ["bug", "ci"])
261 #expect(model.state.value?.map(\.issues) == [18, 11])
262 #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay")
263 }
264
265 @Test func aRepoWithNoLabelsIsAnEmptyState() async throws {
266 let (client, stub) = try makeClient()
267 stub.enqueue(.init(status: 200, json: """
268 {"protocol_version":1,"exit_code":0}
269 """))
270 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
271 await model.load()
272
273 guard case .empty = model.state else {
274 Testing.Issue.record("expected an empty state, got \(model.state)")
275 return
276 }
277 }
278
279 @Test func settingAColourSendsTheColourFlag() async throws {
280 let (model, stub) = try await loaded()
281 stub.enqueue(.init(status: 200, json: """
282 {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0}
283 """))
284 stub.enqueue(.init(status: 200, json: labelListJSON))
285
286 await model.setLabel("bug", color: "cf222e")
287
288 let write = try #require(stub.seen.first { $0.method == "POST" })
289 #expect(try argvOf(write)
290 == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"])
291 }
292
293 /// A label created without a colour must not send `--color`, which
294 /// would be "clear it" rather than "leave whatever it has".
295 @Test func creatingWithoutAColourOmitsTheFlag() async throws {
296 let (model, stub) = try await loaded()
297 stub.enqueue(.init(status: 200, json: """
298 {"protocol_version":1,"data":{"name":"new"},"exit_code":0}
299 """))
300 stub.enqueue(.init(status: 200, json: labelListJSON))
301
302 await model.setLabel("new", color: nil)
303
304 let write = try #require(stub.seen.first { $0.method == "POST" })
305 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"])
306 }
307
308 /// An empty string is not nil: it clears the colour.
309 @Test func clearingSendsAnEmptyColour() async throws {
310 let (model, stub) = try await loaded()
311 stub.enqueue(.init(status: 200, json: """
312 {"protocol_version":1,"data":{"name":"bug"},"exit_code":0}
313 """))
314 stub.enqueue(.init(status: 200, json: labelListJSON))
315
316 await model.setLabel("bug", color: "")
317
318 let write = try #require(stub.seen.first { $0.method == "POST" })
319 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""])
320 }
321
322 @Test func removingSendsTheRemoveCommand() async throws {
323 let (model, stub) = try await loaded()
324 stub.enqueue(.init(status: 200, json: """
325 {"protocol_version":1,"exit_code":0}
326 """))
327 stub.enqueue(.init(status: 200, json: labelListJSON))
328
329 await model.remove("bug")
330
331 let write = try #require(stub.seen.first { $0.method == "POST" })
332 #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"])
333 }
334
335 @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws {
336 let (model, stub) = try await loaded()
337 stub.enqueue(.init(status: 403, json: """
338 {"protocol_version":1,"error":"write access required","exit_code":4}
339 """))
340
341 await model.remove("bug")
342
343 #expect(model.actionError?.isEmpty == false)
344 #expect(model.working == false)
345 }
346
347 @Test func aServerFailureSurfacesAsFailed() async throws {
348 let (client, stub) = try makeClient()
349 stub.enqueue(.init(status: 200, json: """
350 {"protocol_version":1,"error":"boom","exit_code":1}
351 """))
352 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
353
354 await model.load()
355
356 guard case .failed = model.state else {
357 Testing.Issue.record("expected a failed state, got \(model.state)")
358 return
359 }
360 }
361}