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 posted(_ stub: StubProtocol.Box) throws -> ([String], String?) { let seen = try #require(stub.seen.first { $0.method == "POST" }) let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return (try #require(body["argv"] as? [String]), body["stdin"] as? String) } private let diffText = """ diff --git a/main.go b/main.go --- a/main.go +++ b/main.go @@ -10,4 +10,4 @@ func main() { \tname := os.Args[1] -\tlog.Print("starting") +\tlog.Printf("starting %s", name) \trun(name) } """ private let mrShowJSON = """ {"protocol_version":1,"data":{"number":4,"title":"t","state":"open","author":"krz",\ "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z"},\ "exit_code":0} """ private let threadsJSON = """ {"protocol_version":1,"data":[{"id":9,"path":"main.go","side":"new","start_line":11,"line":12,\ "stale":false,"suggestion":{"path":"main.go","start_line":11,"end_line":12,"commit":"abc",\ "original":"\\tlog.Printf(\\"starting %s\\", name)\\n\\trun(name)\\n",\ "replacement":"\\tlog.Printf(\\"starting %q\\", name)\\n\\trun(name)\\n",\ "outdated":false,"apply":"server"},\ "comments":[{"id":9,"author":"cmc","body":"quote it","created_at":"2026-08-22T09:00:00.000Z"}]}],\ "exit_code":0} """ private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"# private func hunk() throws -> (UnifiedDiff.File, UnifiedDiff.Hunk) { let file = try #require(UnifiedDiff.parse(diffText).files.first) return (file, try #require(file.hunks.first)) } struct DiffCommentDraftTests { @Test func aNewSideLineAnchorsOnTheNewFileAndCanSuggest() throws { let (file, hunk) = try hunk() let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context }) let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) #expect(draft.canSuggest) #expect(draft.line == 12) // The removed line has no new-side number and is not a candidate. #expect(draft.candidates.map(\.number) == [10, 11, 12]) #expect(draft.firstLine == 10) #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 12) == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12", "--file", "-"]) #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11) == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12", "--start-line", "11", "--file", "-"]) } @Test func theSuggestionBlockCarriesTheRangesCurrentText() throws { let (file, hunk) = try hunk() let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context }) let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) #expect(draft.suggestion(from: 11) == "```suggestion\n\tlog.Printf(\"starting %s\", name)\n\trun(name)\n```\n") } @Test func aRemovedLineAnchorsOnTheOldSideAndCannotSuggest() throws { let (file, hunk) = try hunk() let target = try #require(hunk.lines.first { $0.kind == .deletion }) let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) #expect(!draft.canSuggest) #expect(draft.candidates.map(\.number) == [10, 11]) #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11) == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "11", "--old", "--file", "-"]) } } struct SuggestionReviewFixTests { /// The server checks --path against the diff's names, which list a /// renamed file under its new name only, so both sides send it. @Test func aRemovedLineInARenamedFileSendsTheNewPath() throws { let diff = UnifiedDiff.parse(""" diff --git a/old.go b/new.go --- a/old.go +++ b/new.go @@ -1,2 +1,2 @@ -a +b c """) let file = try #require(diff.files.first) let hunk = try #require(file.hunks.first) let removed = try #require(hunk.lines.first { $0.kind == .deletion }) #expect(DiffCommentDraft(file: file, hunk: hunk, line: removed)?.path == "new.go") } /// Moving the start of the range rebuilds an untouched suggestion so it /// covers the range, and leaves an edited one alone. @Test func movingTheRangeStartRebuildsAnUntouchedSuggestion() throws { let (file, hunk) = try hunk() let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context }) let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) let untouched = draft.suggestion(from: 12) #expect(draft.rebuilt(untouched, from: 12, to: 11) == draft.suggestion(from: 11)) #expect(draft.rebuilt("my own words", from: 12, to: 11) == "my own words") } /// The thread draws the suggestion as replaced and proposed lines, so /// the comment shows the rest of its text without the block. @Test func theBlockIsLeftOutOfTheCommentShownBesideTheSuggestion() { #expect(Suggestion.withoutBlock("Say who.\n```suggestion\n return x\n```\n") == "Say who.") #expect(Suggestion.withoutBlock("```suggestion\n```\n") == "") #expect(Suggestion.withoutBlock("no block here") == "no block here") } } @MainActor struct SuggestionViewModelTests { private func loaded() 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: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=revisions")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 4) await model.load() return (model, stub) } @Test func threadsDecodeTheirRangeAndSuggestion() async throws { let (model, _) = try await loaded() let thread = try #require(model.threads.first) #expect(thread.startLine == 11) let suggestion = try #require(thread.suggestion) #expect(suggestion.appliesOnServer) #expect(!suggestion.outdated) #expect(Suggestion.lines(suggestion.replacement) == ["\tlog.Printf(\"starting %q\", name)", "\trun(name)"]) #expect(Suggestion.lines("") == []) } @Test func applyingASuggestionNamesItsThread() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 200, json: ok, match: "cmd")) await model.applySuggestion(try #require(model.threads.first)) let (argv, _) = try posted(stub) #expect(argv == ["mr", "apply-suggestion", "krz/gitbay", "4", "9"]) } @Test func aNewCommentSendsItsAnchorAndBodyOnStdin() async throws { let (model, stub) = try await loaded() stub.enqueue(.init(status: 200, json: ok, match: "cmd")) let (file, hunk) = try hunk() let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context }) let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target)) let sent = await model.diffComment(draft, startLine: 11, body: "quote it") #expect(sent) let (argv, stdin) = try posted(stub) #expect(argv == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12", "--start-line", "11", "--file", "-"]) #expect(stdin == "quote it") } }