gitbayTests/SuggestionTests.swift
187 lines · 8252 bytes
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func posted(_ stub: StubProtocol.Box) throws -> ([String], String?) {
16 let seen = try #require(stub.seen.first { $0.method == "POST" })
17 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
18 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
19}
20
21private let diffText = """
22 diff --git a/main.go b/main.go
23 --- a/main.go
24 +++ b/main.go
25 @@ -10,4 +10,4 @@ func main() {
26 \tname := os.Args[1]
27 -\tlog.Print("starting")
28 +\tlog.Printf("starting %s", name)
29 \trun(name)
30 }
31 """
32
33private let mrShowJSON = """
34 {"protocol_version":1,"data":{"number":4,"title":"t","state":"open","author":"krz",\
35 "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z"},\
36 "exit_code":0}
37 """
38
39private let threadsJSON = """
40 {"protocol_version":1,"data":[{"id":9,"path":"main.go","side":"new","start_line":11,"line":12,\
41 "stale":false,"suggestion":{"path":"main.go","start_line":11,"end_line":12,"commit":"abc",\
42 "original":"\\tlog.Printf(\\"starting %s\\", name)\\n\\trun(name)\\n",\
43 "replacement":"\\tlog.Printf(\\"starting %q\\", name)\\n\\trun(name)\\n",\
44 "outdated":false,"apply":"server"},\
45 "comments":[{"id":9,"author":"cmc","body":"quote it","created_at":"2026-08-22T09:00:00.000Z"}]}],\
46 "exit_code":0}
47 """
48
49private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"#
50
51private func hunk() throws -> (UnifiedDiff.File, UnifiedDiff.Hunk) {
52 let file = try #require(UnifiedDiff.parse(diffText).files.first)
53 return (file, try #require(file.hunks.first))
54}
55
56struct DiffCommentDraftTests {
57
58 @Test func aNewSideLineAnchorsOnTheNewFileAndCanSuggest() throws {
59 let (file, hunk) = try hunk()
60 let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
61 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
62
63 #expect(draft.canSuggest)
64 #expect(draft.line == 12)
65 // The removed line has no new-side number and is not a candidate.
66 #expect(draft.candidates.map(\.number) == [10, 11, 12])
67 #expect(draft.firstLine == 10)
68 #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 12)
69 == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12", "--file", "-"])
70 #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11)
71 == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12",
72 "--start-line", "11", "--file", "-"])
73 }
74
75 @Test func theSuggestionBlockCarriesTheRangesCurrentText() throws {
76 let (file, hunk) = try hunk()
77 let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
78 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
79
80 #expect(draft.suggestion(from: 11) == "```suggestion\n\tlog.Printf(\"starting %s\", name)\n\trun(name)\n```\n")
81 }
82
83 @Test func aRemovedLineAnchorsOnTheOldSideAndCannotSuggest() throws {
84 let (file, hunk) = try hunk()
85 let target = try #require(hunk.lines.first { $0.kind == .deletion })
86 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
87
88 #expect(!draft.canSuggest)
89 #expect(draft.candidates.map(\.number) == [10, 11])
90 #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11)
91 == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "11", "--old", "--file", "-"])
92 }
93}
94
95struct SuggestionReviewFixTests {
96
97 /// The server checks --path against the diff's names, which list a
98 /// renamed file under its new name only, so both sides send it.
99 @Test func aRemovedLineInARenamedFileSendsTheNewPath() throws {
100 let diff = UnifiedDiff.parse("""
101 diff --git a/old.go b/new.go
102 --- a/old.go
103 +++ b/new.go
104 @@ -1,2 +1,2 @@
105 -a
106 +b
107 c
108 """)
109 let file = try #require(diff.files.first)
110 let hunk = try #require(file.hunks.first)
111 let removed = try #require(hunk.lines.first { $0.kind == .deletion })
112 #expect(DiffCommentDraft(file: file, hunk: hunk, line: removed)?.path == "new.go")
113 }
114
115 /// Moving the start of the range rebuilds an untouched suggestion so it
116 /// covers the range, and leaves an edited one alone.
117 @Test func movingTheRangeStartRebuildsAnUntouchedSuggestion() throws {
118 let (file, hunk) = try hunk()
119 let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
120 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
121 let untouched = draft.suggestion(from: 12)
122
123 #expect(draft.rebuilt(untouched, from: 12, to: 11) == draft.suggestion(from: 11))
124 #expect(draft.rebuilt("my own words", from: 12, to: 11) == "my own words")
125 }
126
127 /// The thread draws the suggestion as replaced and proposed lines, so
128 /// the comment shows the rest of its text without the block.
129 @Test func theBlockIsLeftOutOfTheCommentShownBesideTheSuggestion() {
130 #expect(Suggestion.withoutBlock("Say who.\n```suggestion\n return x\n```\n") == "Say who.")
131 #expect(Suggestion.withoutBlock("```suggestion\n```\n") == "")
132 #expect(Suggestion.withoutBlock("no block here") == "no block here")
133 }
134}
135
136@MainActor
137struct SuggestionViewModelTests {
138
139 private func loaded() async throws -> (MRDetailViewModel, StubProtocol.Box) {
140 let (client, stub) = try makeClient()
141 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
142 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff"))
143 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
144 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=revisions"))
145 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 4)
146 await model.load()
147 return (model, stub)
148 }
149
150 @Test func threadsDecodeTheirRangeAndSuggestion() async throws {
151 let (model, _) = try await loaded()
152
153 let thread = try #require(model.threads.first)
154 #expect(thread.startLine == 11)
155 let suggestion = try #require(thread.suggestion)
156 #expect(suggestion.appliesOnServer)
157 #expect(!suggestion.outdated)
158 #expect(Suggestion.lines(suggestion.replacement) == ["\tlog.Printf(\"starting %q\", name)", "\trun(name)"])
159 #expect(Suggestion.lines("") == [])
160 }
161
162 @Test func applyingASuggestionNamesItsThread() async throws {
163 let (model, stub) = try await loaded()
164 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
165
166 await model.applySuggestion(try #require(model.threads.first))
167
168 let (argv, _) = try posted(stub)
169 #expect(argv == ["mr", "apply-suggestion", "krz/gitbay", "4", "9"])
170 }
171
172 @Test func aNewCommentSendsItsAnchorAndBodyOnStdin() async throws {
173 let (model, stub) = try await loaded()
174 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
175 let (file, hunk) = try hunk()
176 let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
177 let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
178
179 let sent = await model.diffComment(draft, startLine: 11, body: "quote it")
180
181 #expect(sent)
182 let (argv, stdin) = try posted(stub)
183 #expect(argv == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12",
184 "--start-line", "11", "--file", "-"])
185 #expect(stdin == "quote it")
186 }
187}