import Foundation import Testing @testable import gitbay struct LabelPaletteTests { // Golden values computed from the web's own algorithm: FNV-1a 32 of // the name, modulo the eight-colour palette. If these drift, the two // surfaces paint the same label differently. @Test func derivesAStableColourFromTheName() { #expect(LabelPalette.hex(name: "bug", stored: nil) == "#b93a86") #expect(LabelPalette.hex(name: "ci", stored: nil) == "#1a7f37") #expect(LabelPalette.hex(name: "docs", stored: nil) == "#9a6700") #expect(LabelPalette.hex(name: "ops", stored: nil) == "#cf222e") #expect(LabelPalette.hex(name: "mobile", stored: nil) == "#bf5b16") } @Test func theSameNameAlwaysDerivesTheSameColour() { #expect(LabelPalette.hex(name: "bug", stored: nil) == LabelPalette.hex(name: "bug", stored: nil)) } /// A stored colour already inside the luminance window is used as-is. @Test func keepsAStoredColourThatIsAlreadyLegible() { #expect(LabelPalette.hex(name: "bug", stored: "#ff0000") == "#ff0000") #expect(LabelPalette.hex(name: "bug", stored: "#0969da") == "#0969da") } /// Too dark or too bright is scaled back into the window, so a chip /// stays readable as text on both grounds. @Test func clampsAStoredColourOutsideTheWindow() { #expect(LabelPalette.hex(name: "bug", stored: "#000000") == "#616161") #expect(LabelPalette.hex(name: "bug", stored: "#ffffff") == "#909090") } @Test func upperCaseStoredColoursNormaliseDown() { #expect(LabelPalette.hex(name: "bug", stored: "#FF0000") == "#ff0000") } /// `label set` stores `#rrggbb`. Anything else — an empty string, a /// bare six digits, a name — never reached the store as a colour, so /// it is treated as unset and the name derives one. @Test func anythingThatIsNotAHexColourFallsBackToTheName() { let derived = LabelPalette.hex(name: "bug", stored: nil) #expect(LabelPalette.hex(name: "bug", stored: "") == derived) #expect(LabelPalette.hex(name: "bug", stored: "ff0000") == derived) #expect(LabelPalette.hex(name: "bug", stored: "red") == derived) #expect(LabelPalette.hex(name: "bug", stored: "#fff") == derived) } @Test func everyPaletteEntryIsAlreadyLegible() { // No palette colour is ever clamped, so a derived colour is a // plain lookup. If this fails the palette drifted from the web's. for hex in LabelPalette.palette { #expect(LabelPalette.hex(name: "x", stored: hex) == hex) } } } struct IssueLabelDecodingTests { @Test func decodesAListRow() throws { let json = Data(""" [{"name":"bug","color":"#cf222e","issues":18},{"name":"ci","issues":11}] """.utf8) let labels = try JSONDecoder().decode([IssueLabel].self, from: json) #expect(labels.count == 2) #expect(labels[0].name == "bug") #expect(labels[0].color == "#cf222e") #expect(labels[0].issues == 18) // `color` is omitempty on the wire — absent, not null, when unset. #expect(labels[1].color == nil) #expect(labels[1].issues == 11) } /// v1.19.0: a row the repository sees through its org carries /// `org: true`; repository rows and older servers omit the key. @Test func anOrgRowIsMarkedAndARepoRowIsNot() throws { let json = Data(#"[{"name":"bug","org":true,"issues":4},{"name":"ci","issues":1}]"#.utf8) let labels = try JSONDecoder().decode([IssueLabel].self, from: json) #expect(labels[0].org) #expect(!labels[1].org) } @Test func theIdIsTheName() throws { let json = Data(#"[{"name":"bug","issues":0}]"#.utf8) let labels = try JSONDecoder().decode([IssueLabel].self, from: json) #expect(labels[0].id == "bug") } } struct LabelColorsTests { @Test func looksUpAStoredColour() { let colors = LabelColors([ IssueLabel(name: "bug", color: "#cf222e", issues: 1), IssueLabel(name: "ci", color: nil, issues: 2), ]) #expect(colors.hex("bug") == "#cf222e") #expect(colors.hex("ci") == "#1a7f37") } /// An issue can carry a label the map has not loaded — a stale list, or /// a label added since. It still derives a colour rather than going grey. @Test func anUnknownLabelStillDerivesItsColour() { #expect(LabelColors().hex("docs") == "#9a6700") } } private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } private let issuePageJSON = """ {"protocol_version":1,"data":{"items":[\ {"number":7,"title":"a bug","state":"open","author":"cmc",\ "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0} """ private let labelListJSON = """ {"protocol_version":1,"data":[\ {"name":"bug","color":"#cf222e","issues":18},\ {"name":"ci","issues":11}],"exit_code":0} """ @MainActor struct IssueListColorsTests { @Test func loadsLabelsAlongsideTheIssues() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue")) stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() #expect(model.state.value?.count == 1) // The names are what MR 2's filter picker will offer. #expect(model.labels.map(\.name) == ["bug", "ci"]) #expect(model.colors.hex("bug") == "#cf222e") #expect(model.colors.hex("ci") == "#1a7f37") #expect(stub.seen.contains { $0.url.query() == "argv=label&argv=list&argv=krz/gitbay" }) } /// Decoration must not take the screen down with it. @Test func issuesStillLoadWhenTheLabelReadFails() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue")) stub.enqueue(.init(status: 403, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() #expect(model.state.value?.count == 1) #expect(model.labels.isEmpty) // Derived from the name, since nothing was stored. #expect(model.colors.hex("bug") == "#b93a86") } } @MainActor struct IssueDetailColorsTests { @Test func loadsLabelsAlongsideTheIssue() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\ "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\ "exit_code":0} """, match: "argv=show")) stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label")) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() #expect(model.state.value?.number == 7) #expect(model.colors.hex("bug") == "#cf222e") } /// Decoration must not take the screen down with it. @Test func issueStillLoadsWhenTheLabelReadFails() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\ "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\ "exit_code":0} """, match: "argv=show")) stub.enqueue(.init(status: 403, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """, match: "argv=label")) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() #expect(model.state.value?.number == 7) // Derived from the name, since nothing was stored. #expect(model.colors.hex("bug") == "#b93a86") } /// Regression test: `perform(argv:)` used to reload labels on every /// write. Labels rarely change mid-session, so the read must happen /// at most once per view model instance. @Test func aWriteDoesNotReissueLabelList() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\ "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\ "exit_code":0} """, match: "argv=show")) stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label")) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """, match: "argv=close")) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"closed",\ "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\ "exit_code":0} """, match: "argv=show")) await model.close() let labelReads = stub.seen.filter { ($0.url.query() ?? "").contains("argv=label") } #expect(labelReads.count == 1) } } @MainActor struct LabelListViewModelTests { private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: labelListJSON)) let model = LabelListViewModel(client: client, repoPath: "krz/gitbay") await model.load() return (model, stub) } @Test func listsLabelsWithTheirUseCounts() async throws { let (model, stub) = try await loaded() #expect(model.state.value?.map(\.name) == ["bug", "ci"]) #expect(model.state.value?.map(\.issues) == [18, 11]) #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay") } @Test func aRepoWithNoLabelsIsAnEmptyState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) let model = LabelListViewModel(client: client, repoPath: "krz/gitbay") await model.load() guard case .empty = model.state else { Testing.Issue.record("expected an empty state, got \(model.state)") return } } @Test func settingAColourSendsTheColourFlag() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0} """)) stub.enqueue(.init(status: 200, json: labelListJSON)) await model.setLabel("bug", color: "cf222e") let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"]) } /// A label created without a colour must not send `--color`, which /// would be "clear it" rather than "leave whatever it has". @Test func creatingWithoutAColourOmitsTheFlag() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"name":"new"},"exit_code":0} """)) stub.enqueue(.init(status: 200, json: labelListJSON)) await model.setLabel("new", color: nil) let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"]) } /// An empty string is not nil: it clears the colour. @Test func clearingSendsAnEmptyColour() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"name":"bug"},"exit_code":0} """)) stub.enqueue(.init(status: 200, json: labelListJSON)) await model.setLabel("bug", color: "") let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""]) } @Test func removingSendsTheRemoveCommand() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: labelListJSON)) await model.remove("bug") let write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"]) } @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 403, json: """ {"protocol_version":1,"error":"write access required","exit_code":4} """)) await model.remove("bug") #expect(model.actionError?.isEmpty == false) #expect(model.working == false) } @Test func aServerFailureSurfacesAsFailed() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"boom","exit_code":1} """)) let model = LabelListViewModel(client: client, repoPath: "krz/gitbay") await model.load() guard case .failed = model.state else { Testing.Issue.record("expected a failed state, got \(model.state)") return } } }