gitbayTests/LabelTests.swift

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

361 lines · 14470 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    /// 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}