Commit 80d5f2e5cb
Verified · cmc
Layout: unified · split
gitbay/Issues/LabelListViewModel.swift added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `label list <repo>`, plus the two commands that change the set: | |
| 5 | /// `label set` creates one or recolours it, `label remove` takes it off | |
| 6 | /// the repository and off every issue that carried it. | |
| 7 | @Observable | |
| 8 | @MainActor | |
| 9 | final class LabelListViewModel { | |
| 10 | ||
| 11 | private(set) var state: LoadState<[IssueLabel]> = .loading | |
| 12 | private(set) var actionError: String? | |
| 13 | private(set) var working = false | |
| 14 | ||
| 15 | private let client: GitbayClient | |
| 16 | let repoPath: String | |
| 17 | ||
| 18 | init(client: GitbayClient, repoPath: String) { | |
| 19 | self.client = client | |
| 20 | self.repoPath = repoPath | |
| 21 | } | |
| 22 | ||
| 23 | func load() async { | |
| 24 | state = .loading | |
| 25 | do { | |
| 26 | let labels = try await client.readList( | |
| 27 | ["label", "list", repoPath], of: IssueLabel.self | |
| 28 | ) | |
| 29 | state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels) | |
| 30 | } catch { | |
| 31 | state = .from(error) | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | /// Create a label or set its colour. A nil colour omits `--color` | |
| 36 | /// entirely, which the server reads as "make sure it exists" and | |
| 37 | /// leaves any colour it already had; an empty string clears it. | |
| 38 | func setLabel(_ name: String, color: String?) async { | |
| 39 | var argv = ["label", "set", repoPath, name] | |
| 40 | if let color { argv.append(contentsOf: ["--color", color]) } | |
| 41 | await perform(argv) | |
| 42 | } | |
| 43 | ||
| 44 | func remove(_ name: String) async { | |
| 45 | await perform(["label", "remove", repoPath, name]) | |
| 46 | } | |
| 47 | ||
| 48 | private func perform(_ argv: [String]) async { | |
| 49 | working = true | |
| 50 | actionError = nil | |
| 51 | defer { working = false } | |
| 52 | do { | |
| 53 | try await client.run(argv) | |
| 54 | await load() | |
| 55 | } catch let error as GitbayError { | |
| 56 | actionError = error.userFacingMessage | |
| 57 | } catch { | |
| 58 | actionError = GitbayError.transport(error).userFacingMessage | |
| 59 | } | |
| 60 | } | |
| 61 | } | |
gitbayTests/LabelTests.swift +102
| @@ -183,3 +183,105 @@ struct IssueDetailColorsTests { | ||
| 183 | 183 | #expect(model.colors.hex("bug") == "#cf222e") |
| 184 | 184 | } |
| 185 | 185 | } |
| 186 | ||
| 187 | @MainActor | |
| 188 | struct LabelListViewModelTests { | |
| 189 | ||
| 190 | private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) { | |
| 191 | let (client, stub) = try makeClient() | |
| 192 | stub.enqueue(.init(status: 200, json: labelListJSON)) | |
| 193 | let model = LabelListViewModel(client: client, repoPath: "krz/gitbay") | |
| 194 | await model.load() | |
| 195 | return (model, stub) | |
| 196 | } | |
| 197 | ||
| 198 | @Test func listsLabelsWithTheirUseCounts() async throws { | |
| 199 | let (model, stub) = try await loaded() | |
| 200 | ||
| 201 | #expect(model.state.value?.map(\.name) == ["bug", "ci"]) | |
| 202 | #expect(model.state.value?.map(\.issues) == [18, 11]) | |
| 203 | #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay") | |
| 204 | } | |
| 205 | ||
| 206 | @Test func aRepoWithNoLabelsIsAnEmptyState() async throws { | |
| 207 | let (client, stub) = try makeClient() | |
| 208 | stub.enqueue(.init(status: 200, json: """ | |
| 209 | {"protocol_version":1,"exit_code":0} | |
| 210 | """)) | |
| 211 | let model = LabelListViewModel(client: client, repoPath: "krz/gitbay") | |
| 212 | await model.load() | |
| 213 | ||
| 214 | guard case .empty = model.state else { | |
| 215 | Testing.Issue.record("expected an empty state, got \(model.state)") | |
| 216 | return | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 220 | @Test func settingAColourSendsTheColourFlag() async throws { | |
| 221 | let (model, stub) = try await loaded() | |
| 222 | stub.enqueue(.init(status: 200, json: """ | |
| 223 | {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0} | |
| 224 | """)) | |
| 225 | stub.enqueue(.init(status: 200, json: labelListJSON)) | |
| 226 | ||
| 227 | await model.setLabel("bug", color: "cf222e") | |
| 228 | ||
| 229 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 230 | #expect(try argvOf(write) | |
| 231 | == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"]) | |
| 232 | } | |
| 233 | ||
| 234 | /// A label created without a colour must not send `--color`, which | |
| 235 | /// would be "clear it" rather than "leave whatever it has". | |
| 236 | @Test func creatingWithoutAColourOmitsTheFlag() async throws { | |
| 237 | let (model, stub) = try await loaded() | |
| 238 | stub.enqueue(.init(status: 200, json: """ | |
| 239 | {"protocol_version":1,"data":{"name":"new"},"exit_code":0} | |
| 240 | """)) | |
| 241 | stub.enqueue(.init(status: 200, json: labelListJSON)) | |
| 242 | ||
| 243 | await model.setLabel("new", color: nil) | |
| 244 | ||
| 245 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 246 | #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"]) | |
| 247 | } | |
| 248 | ||
| 249 | /// An empty string is not nil: it clears the colour. | |
| 250 | @Test func clearingSendsAnEmptyColour() async throws { | |
| 251 | let (model, stub) = try await loaded() | |
| 252 | stub.enqueue(.init(status: 200, json: """ | |
| 253 | {"protocol_version":1,"data":{"name":"bug"},"exit_code":0} | |
| 254 | """)) | |
| 255 | stub.enqueue(.init(status: 200, json: labelListJSON)) | |
| 256 | ||
| 257 | await model.setLabel("bug", color: "") | |
| 258 | ||
| 259 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 260 | #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""]) | |
| 261 | } | |
| 262 | ||
| 263 | @Test func removingSendsTheRemoveCommand() async throws { | |
| 264 | let (model, stub) = try await loaded() | |
| 265 | stub.enqueue(.init(status: 200, json: """ | |
| 266 | {"protocol_version":1,"exit_code":0} | |
| 267 | """)) | |
| 268 | stub.enqueue(.init(status: 200, json: labelListJSON)) | |
| 269 | ||
| 270 | await model.remove("bug") | |
| 271 | ||
| 272 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 273 | #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"]) | |
| 274 | } | |
| 275 | ||
| 276 | @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws { | |
| 277 | let (model, stub) = try await loaded() | |
| 278 | stub.enqueue(.init(status: 403, json: """ | |
| 279 | {"protocol_version":1,"error":"write access required","exit_code":4} | |
| 280 | """)) | |
| 281 | ||
| 282 | await model.remove("bug") | |
| 283 | ||
| 284 | #expect(model.actionError?.isEmpty == false) | |
| 285 | #expect(model.working == false) | |
| 286 | } | |
| 287 | } | |