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 let mrListJSON = """ {"protocol_version":1,"data":{"items":[\ {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\ "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\ "created_at":"2026-08-20T10:00:00.000Z"},\ {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\ "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\ "created_at":"2026-08-21T10:00:00.000Z"}\ ]},"exit_code":0} """ private let mrShowJSON = """ {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\ "state":"open","author":"cmc","source":"client-envelope","target_ref":"main",\ "head_sha":"aabbcc","body":"Speaks both surfaces.","created_at":"2026-08-20T10:00:00.000Z",\ "checks":[{"context":"build","state":"success"}],"checks_combined":"success",\ "unresolved_threads":1,\ "commits":[{"sha":"aabbcc00000000000000","subject":"client: envelope"}],\ "comments":[{"author":"krz","body":"looks right","created_at":"2026-08-20T11:00:00.000Z"}],\ "reviews":[{"reviewer":"krz","verdict":"approve","stale":false}]},"exit_code":0} """ private let threadsJSON = """ {"protocol_version":1,"data":[\ {"id":3,"path":"gitbay/Client.swift","side":"new","line":42,"stale":false,\ "comments":[{"id":3,"author":"krz","body":"why retry twice?","created_at":"2026-08-20T10:30:00.000Z"}]},\ {"id":5,"path":"gitbay/Client.swift","side":"new","line":90,"stale":true,"resolved_by":"cmc",\ "comments":[{"id":5,"author":"krz","body":"naming","created_at":"2026-08-20T10:31:00.000Z"},\ {"id":6,"author":"cmc","body":"renamed","created_at":"2026-08-20T10:35:00.000Z"}]}\ ],"exit_code":0} """ private let diffText = """ diff --git a/main.go b/main.go index 1234567..89abcde 100644 --- a/main.go +++ b/main.go @@ -1,4 +1,5 @@ package main -import "fmt" +import ( +\t"fmt" +) -func main() {} @@ -10,2 +11,3 @@ func helper() { \tx := 1 +\ty := 2 \t_ = x diff --git a/new.txt b/new.txt new file mode 100644 --- /dev/null +++ b/new.txt @@ -0,0 +1,2 @@ +hello +world """ private func diffEnvelope() -> String { let escaped = diffText .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") .replacingOccurrences(of: "\n", with: "\\n") .replacingOccurrences(of: "\t", with: "\\t") return "{\"protocol_version\":1,\"output\":\"\(escaped)\",\"exit_code\":0}" } struct UnifiedDiffParserTests { @Test func parsesFilesHunksAndLineNumbers() { let diff = UnifiedDiff.parse(diffText) #expect(diff.files.count == 2) let first = diff.files[0] #expect(first.displayPath == "main.go") #expect(first.hunks.count == 2) #expect(first.additions == 4) #expect(first.deletions == 2) // Line numbering advances per side. let lines = first.hunks[0].lines #expect(lines[0].kind == .context) #expect(lines[0].oldNumber == 1) #expect(lines[0].newNumber == 1) #expect(lines[1].kind == .deletion) #expect(lines[1].oldNumber == 2) #expect(lines[1].newNumber == nil) #expect(lines[2].kind == .addition) #expect(lines[2].oldNumber == nil) #expect(lines[2].newNumber == 2) // Second hunk restarts numbering from its header. #expect(first.hunks[1].lines[0].oldNumber == 10) #expect(first.hunks[1].lines[0].newNumber == 11) } @Test func newFilesAreMarked() { let diff = UnifiedDiff.parse(diffText) let added = diff.files[1] #expect(added.isNew) #expect(!added.isDeleted) #expect(added.displayPath == "new.txt") #expect(added.additions == 2) } @Test func totalsSumAcrossFiles() { let diff = UnifiedDiff.parse(diffText) #expect(diff.additions == 6) #expect(diff.deletions == 2) } @Test func binaryFilesAreRecognised() { let diff = UnifiedDiff.parse(""" diff --git a/logo.png b/logo.png Binary files a/logo.png and b/logo.png differ """) #expect(diff.files.count == 1) #expect(diff.files[0].isBinary) } @Test func emptyDiffParsesToNoFiles() { #expect(UnifiedDiff.parse("").files.isEmpty) } } @MainActor struct MRListViewModelTests { @Test func listsInServerOrderWithPagingFlags() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mrListJSON)) let model = MRListViewModel(client: client, repoPath: "krz/gitbay") await model.load() let mrs = try #require(model.state.value) #expect(mrs.map(\.number) == [7, 9]) let seen = try #require(stub.seen.first) #expect(seen.url.query() == "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50") } @Test func changingTheFilterReloadsWithThatState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mrListJSON)) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) let model = MRListViewModel(client: client, repoPath: "krz/gitbay") await model.load() model.filter = .merged // The reload happens in a spawned task; give it a beat. try await Task.sleep(for: .milliseconds(300)) #expect(stub.seen.count == 2) #expect(stub.seen[1].url.query()?.contains("argv=merged") == true) guard case .empty = model.state else { Issue.record("expected .empty after filtering, got \(model.state)") return } } } @MainActor struct MRDetailViewModelTests { private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() return (model, stub) } @Test func loadsHeaderDiffAndThreads() async throws { let (model, _) = try await loadedModel() let mr = try #require(model.state.value) #expect(mr.title == "client: envelope decoding") #expect(mr.checksCombined == "success") #expect(mr.reviews?.first?.verdict == "approve") #expect(model.diff?.files.count == 2) #expect(model.threads.count == 2) #expect(model.unresolvedCount == 1) } @Test func approveSendsTheWriteThenReloads() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) await model.review(.approve) let write = stub.seen[3] #expect(write.method == "POST") #expect(write.url.path() == "/api/v1/cmd") let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) #expect(body["argv"] as? [String] == ["mr", "review", "krz/gitbay", "7", "--approve"]) #expect(model.actionError == nil) } @Test func commentGoesThroughStdinNotArgv() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) await model.comment("long review text\nwith lines") let write = stub.seen[3] let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"]) #expect(body["stdin"] as? String == "long review text\nwith lines") } @Test func aMergeRefusalSurfacesTheServersRuleVerbatim() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"merge blocked: 1 review thread unresolved","exit_code":4}"#)) await model.merge() #expect(model.actionError == "merge blocked: 1 review thread unresolved") // The refusal did not wipe the loaded screen. #expect(model.state.value != nil) #expect(stub.seen.count == 4) } @Test func resolveTargetsTheThreadID() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) let thread = try #require(model.threads.first { !$0.isResolved }) await model.setResolved(thread, true) let write = stub.seen[3] let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"]) } @Test func replyUsesDiffCommentWithReplyFlag() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":9,"thread":3},"exit_code":0}"#)) stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff")) stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) let thread = try #require(model.threads.first { !$0.isResolved }) await model.reply(to: thread, "because 5xx is transient") let write = stub.seen[3] let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) #expect(body["argv"] as? [String] == ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"]) #expect(body["stdin"] as? String == "because 5xx is transient") } } /// Where a review thread hangs in the diff. Threads the diff has moved /// past must not vanish — they render in their own section. struct ThreadAnchoringTests { private func thread( path: String, line: Int64, side: String = "new", stale: Bool = false ) throws -> ReviewThread { let json = """ {"id":1,"path":"\(path)","side":"\(side)","line":\(line),"stale":\(stale),\ "comments":[{"id":1,"author":"krz","body":"why?",\ "created_at":"2026-08-20T10:30:00.000Z"}]} """ let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(ReviewThread.self, from: Data(json.utf8)) } private let diff = UnifiedDiff.parse(""" diff --git a/main.go b/main.go --- a/main.go +++ b/main.go @@ -1,4 +1,5 @@ package main -import "fmt" +import ( +\t"fmt" +) """) @Test func aThreadAnchorsToItsLineOnTheNewSide() throws { // "+import (" is new line 2. let subject = try thread(path: "main.go", line: 2) #expect(diff.anchors(subject)) let file = try #require(diff.files.first) let line = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 }) #expect(line.anchors(subject, in: file)) } @Test func anOldSideThreadAnchorsToTheDeletedLine() throws { // `-import "fmt"` is old line 2 and has no new number. let subject = try thread(path: "main.go", line: 2, side: "old") #expect(diff.anchors(subject)) let file = try #require(diff.files.first) let deletion = try #require(file.hunks.first?.lines.first { $0.kind == .deletion }) #expect(deletion.anchors(subject, in: file)) // The same line number on the other side is a different anchor. let addition = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 }) #expect(!addition.anchors(subject, in: file)) } @Test func aStaleThreadAnchorsNowhere() throws { // Its head is gone, so the line numbers cannot be trusted. let subject = try thread(path: "main.go", line: 2, stale: true) #expect(!diff.anchors(subject)) } @Test func aThreadOnAnotherFileOrLineDoesNotAnchor() throws { #expect(!diff.anchors(try thread(path: "other.go", line: 2))) #expect(!diff.anchors(try thread(path: "main.go", line: 999))) } }