import Foundation import Testing @testable import gitbay 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 refsJSON = """ {"protocol_version":1,"data":{"branches":[\ {"name":"main","sha":"aaaaaaaaaabbbbbbbbbb"},{"name":"feature","sha":"cccccccccc"}],\ "tags":[{"name":"v1.0.0","sha":"dddddddddd"}]},"exit_code":0} """ private let repoShowJSON = """ {"protocol_version":1,"data":{"path":"krz/gitbay","visibility":"public",\ "default_branch":"main"},"exit_code":0} """ private let milestonesJSON = """ {"protocol_version":1,"data":[\ {"title":"v1.0.0","description":"launch","state":"open","open":3,"closed":9},\ {"title":"v0.5.0","state":"closed","open":0,"closed":4}],"exit_code":0} """ @MainActor struct RefsViewModelTests { private func loadedModel() async throws -> (RefsViewModel, StubProtocol.Box) { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: refsJSON, match: "argv=refs")) stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show")) let model = RefsViewModel(client: client, repoPath: "krz/gitbay") await model.load() return (model, stub) } @Test func loadsBranchesTagsAndTheDefault() async throws { let (model, stub) = try await loadedModel() #expect(model.branches.map(\.name) == ["main", "feature"]) #expect(model.tags.map(\.name) == ["v1.0.0"]) #expect(model.defaultBranch == "main") #expect(stub.seen.contains { $0.url.query() == "argv=repo&argv=refs&argv=krz/gitbay" }) } @Test func theFilterMatchesBothBranchesAndTags() async throws { let (model, _) = try await loadedModel() model.searchText = "v1" #expect(model.branches.isEmpty) #expect(model.tags.map(\.name) == ["v1.0.0"]) model.searchText = "feat" #expect(model.branches.map(\.name) == ["feature"]) #expect(model.tags.isEmpty) } @Test func aRepoWithoutRefsIsAnEmptyState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"branches":[],"tags":[]},"exit_code":0}"#, match: "argv=refs")) stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show")) let model = RefsViewModel(client: client, repoPath: "krz/empty") await model.load() guard case .empty = model.state else { Issue.record("expected .empty, got \(model.state)") return } } } @MainActor struct MilestoneListViewModelTests { @Test func loadsMilestonesWithProgress() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: milestonesJSON)) let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay") await model.load() let milestones = try #require(model.state.value) #expect(milestones.map(\.title) == ["v1.0.0", "v0.5.0"]) #expect(milestones[0].open == 3 && milestones[0].closed == 9) #expect(stub.seen.first?.url.query() == "argv=milestone&argv=list&argv=krz/gitbay&argv=--state&argv=open") } @Test func changingTheFilterReloadsWithThatState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: milestonesJSON)) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#)) let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay") await model.load() model.filter = .closed try await Task.sleep(for: .milliseconds(300)) #expect(stub.seen.count == 2) #expect(stub.seen[1].url.query()?.contains("argv=closed") == true) } } @MainActor struct MRMilestoneTests { private let mrShow = """ {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\ "source":"b","target_ref":"main","head_sha":"aa","milestone":"v1.0.0",\ "created_at":"2026-08-20T10:00:00.000Z"},"exit_code":0} """ private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() return (model, stub) } @Test func mrShowCarriesItsMilestone() async throws { let (model, _) = try await loadedModel() // Previously a blind write: you could set it and nothing read back. #expect(model.state.value?.milestone == "v1.0.0") } @Test func settingAndClearingUseTheCommandsSpelling() async throws { let (model, stub) = try await loadedModel() for _ in 0..<2 { stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd")) stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads")) } await model.setMilestone("v1.1.0") await model.setMilestone(nil) let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) #expect(writes[0] == ["mr", "milestone", "krz/gitbay", "7", "v1.1.0"]) #expect(writes[1] == ["mr", "milestone", "krz/gitbay", "7", "none"]) } }