gitbayTests/LabelTests.swift

v1.0.6
gitbay-ios/gitbayTests/LabelTests.swift history · blame · raw

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}