gitbayTests/MRViewModelTests.swift
343 lines · 13734 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 let mrListJSON = """
16 {"protocol_version":1,"data":{"items":[\
17 {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\
18 "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\
19 "created_at":"2026-08-20T10:00:00.000Z"},\
20 {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\
21 "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\
22 "created_at":"2026-08-21T10:00:00.000Z"}\
23 ]},"exit_code":0}
24 """
25
26private let mrShowJSON = """
27 {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\
28 "state":"open","author":"cmc","source":"client-envelope","target_ref":"main",\
29 "head_sha":"aabbcc","body":"Speaks both surfaces.","created_at":"2026-08-20T10:00:00.000Z",\
30 "checks":[{"context":"build","state":"success"}],"checks_combined":"success",\
31 "unresolved_threads":1,\
32 "commits":[{"sha":"aabbcc00000000000000","subject":"client: envelope"}],\
33 "comments":[{"author":"krz","body":"looks right","created_at":"2026-08-20T11:00:00.000Z"}],\
34 "reviews":[{"reviewer":"krz","verdict":"approve","stale":false}]},"exit_code":0}
35 """
36
37private let threadsJSON = """
38 {"protocol_version":1,"data":[\
39 {"id":3,"path":"gitbay/Client.swift","side":"new","line":42,"stale":false,\
40 "comments":[{"id":3,"author":"krz","body":"why retry twice?","created_at":"2026-08-20T10:30:00.000Z"}]},\
41 {"id":5,"path":"gitbay/Client.swift","side":"new","line":90,"stale":true,"resolved_by":"cmc",\
42 "comments":[{"id":5,"author":"krz","body":"naming","created_at":"2026-08-20T10:31:00.000Z"},\
43 {"id":6,"author":"cmc","body":"renamed","created_at":"2026-08-20T10:35:00.000Z"}]}\
44 ],"exit_code":0}
45 """
46
47private let diffText = """
48 diff --git a/main.go b/main.go
49 index 1234567..89abcde 100644
50 --- a/main.go
51 +++ b/main.go
52 @@ -1,4 +1,5 @@
53 package main
54 -import "fmt"
55 +import (
56 +\t"fmt"
57 +)
58
59 -func main() {}
60 @@ -10,2 +11,3 @@ func helper() {
61 \tx := 1
62 +\ty := 2
63 \t_ = x
64 diff --git a/new.txt b/new.txt
65 new file mode 100644
66 --- /dev/null
67 +++ b/new.txt
68 @@ -0,0 +1,2 @@
69 +hello
70 +world
71 """
72
73private func diffEnvelope() -> String {
74 let escaped = diffText
75 .replacingOccurrences(of: "\\", with: "\\\\")
76 .replacingOccurrences(of: "\"", with: "\\\"")
77 .replacingOccurrences(of: "\n", with: "\\n")
78 .replacingOccurrences(of: "\t", with: "\\t")
79 return "{\"protocol_version\":1,\"output\":\"\(escaped)\",\"exit_code\":0}"
80}
81
82struct UnifiedDiffParserTests {
83
84 @Test func parsesFilesHunksAndLineNumbers() {
85 let diff = UnifiedDiff.parse(diffText)
86
87 #expect(diff.files.count == 2)
88 let first = diff.files[0]
89 #expect(first.displayPath == "main.go")
90 #expect(first.hunks.count == 2)
91 #expect(first.additions == 4)
92 #expect(first.deletions == 2)
93
94 // Line numbering advances per side.
95 let lines = first.hunks[0].lines
96 #expect(lines[0].kind == .context)
97 #expect(lines[0].oldNumber == 1)
98 #expect(lines[0].newNumber == 1)
99 #expect(lines[1].kind == .deletion)
100 #expect(lines[1].oldNumber == 2)
101 #expect(lines[1].newNumber == nil)
102 #expect(lines[2].kind == .addition)
103 #expect(lines[2].oldNumber == nil)
104 #expect(lines[2].newNumber == 2)
105
106 // Second hunk restarts numbering from its header.
107 #expect(first.hunks[1].lines[0].oldNumber == 10)
108 #expect(first.hunks[1].lines[0].newNumber == 11)
109 }
110
111 @Test func newFilesAreMarked() {
112 let diff = UnifiedDiff.parse(diffText)
113 let added = diff.files[1]
114 #expect(added.isNew)
115 #expect(!added.isDeleted)
116 #expect(added.displayPath == "new.txt")
117 #expect(added.additions == 2)
118 }
119
120 @Test func totalsSumAcrossFiles() {
121 let diff = UnifiedDiff.parse(diffText)
122 #expect(diff.additions == 6)
123 #expect(diff.deletions == 2)
124 }
125
126 @Test func binaryFilesAreRecognised() {
127 let diff = UnifiedDiff.parse("""
128 diff --git a/logo.png b/logo.png
129 Binary files a/logo.png and b/logo.png differ
130 """)
131 #expect(diff.files.count == 1)
132 #expect(diff.files[0].isBinary)
133 }
134
135 @Test func emptyDiffParsesToNoFiles() {
136 #expect(UnifiedDiff.parse("").files.isEmpty)
137 }
138}
139
140@MainActor
141struct MRListViewModelTests {
142
143 @Test func listsInServerOrderWithPagingFlags() async throws {
144 let (client, stub) = try makeClient()
145 stub.enqueue(.init(status: 200, json: mrListJSON))
146 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
147
148 await model.load()
149
150 let mrs = try #require(model.state.value)
151 #expect(mrs.map(\.number) == [7, 9])
152 let seen = try #require(stub.seen.first)
153 #expect(seen.url.query() ==
154 "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50")
155 }
156
157 @Test func changingTheFilterReloadsWithThatState() async throws {
158 let (client, stub) = try makeClient()
159 stub.enqueue(.init(status: 200, json: mrListJSON))
160 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
161 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
162 await model.load()
163
164 model.filter = .merged
165 // The reload happens in a spawned task; give it a beat.
166 try await Task.sleep(for: .milliseconds(300))
167
168 #expect(stub.seen.count == 2)
169 #expect(stub.seen[1].url.query()?.contains("argv=merged") == true)
170 guard case .empty = model.state else {
171 Issue.record("expected .empty after filtering, got \(model.state)")
172 return
173 }
174 }
175}
176
177@MainActor
178struct MRDetailViewModelTests {
179
180 private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) {
181 let (client, stub) = try makeClient()
182 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
183 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
184 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
185 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
186 await model.load()
187 return (model, stub)
188 }
189
190 @Test func loadsHeaderDiffAndThreads() async throws {
191 let (model, _) = try await loadedModel()
192
193 let mr = try #require(model.state.value)
194 #expect(mr.title == "client: envelope decoding")
195 #expect(mr.checksCombined == "success")
196 #expect(mr.reviews?.first?.verdict == "approve")
197 #expect(model.diff?.files.count == 2)
198 #expect(model.threads.count == 2)
199 #expect(model.unresolvedCount == 1)
200 }
201
202 @Test func approveSendsTheWriteThenReloads() async throws {
203 let (model, stub) = try await loadedModel()
204 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
205 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
206 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
207 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
208
209 await model.review(.approve)
210
211 let write = stub.seen[3]
212 #expect(write.method == "POST")
213 #expect(write.url.path() == "/api/v1/cmd")
214 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
215 #expect(body["argv"] as? [String] ==
216 ["mr", "review", "krz/gitbay", "7", "--approve"])
217 #expect(model.actionError == nil)
218 }
219
220 @Test func commentGoesThroughStdinNotArgv() async throws {
221 let (model, stub) = try await loadedModel()
222 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
223 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
224 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
225 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
226
227 await model.comment("long review text\nwith lines")
228
229 let write = stub.seen[3]
230 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
231 #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"])
232 #expect(body["stdin"] as? String == "long review text\nwith lines")
233 }
234
235 @Test func aMergeRefusalSurfacesTheServersRuleVerbatim() async throws {
236 let (model, stub) = try await loadedModel()
237 stub.enqueue(.init(status: 403, json:
238 #"{"protocol_version":1,"error":"merge blocked: 1 review thread unresolved","exit_code":4}"#))
239
240 await model.merge()
241
242 #expect(model.actionError == "merge blocked: 1 review thread unresolved")
243 // The refusal did not wipe the loaded screen.
244 #expect(model.state.value != nil)
245 #expect(stub.seen.count == 4)
246 }
247
248 @Test func resolveTargetsTheThreadID() async throws {
249 let (model, stub) = try await loadedModel()
250 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
251 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
252 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
253 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
254 let thread = try #require(model.threads.first { !$0.isResolved })
255
256 await model.setResolved(thread, true)
257
258 let write = stub.seen[3]
259 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
260 #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"])
261 }
262
263 @Test func replyUsesDiffCommentWithReplyFlag() async throws {
264 let (model, stub) = try await loadedModel()
265 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":9,"thread":3},"exit_code":0}"#))
266 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
267 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
268 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
269 let thread = try #require(model.threads.first { !$0.isResolved })
270
271 await model.reply(to: thread, "because 5xx is transient")
272
273 let write = stub.seen[3]
274 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
275 #expect(body["argv"] as? [String] ==
276 ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"])
277 #expect(body["stdin"] as? String == "because 5xx is transient")
278 }
279}
280
281/// Where a review thread hangs in the diff. Threads the diff has moved
282/// past must not vanish — they render in their own section.
283struct ThreadAnchoringTests {
284
285 private func thread(
286 path: String, line: Int64, side: String = "new", stale: Bool = false
287 ) throws -> ReviewThread {
288 let json = """
289 {"id":1,"path":"\(path)","side":"\(side)","line":\(line),"stale":\(stale),\
290 "comments":[{"id":1,"author":"krz","body":"why?",\
291 "created_at":"2026-08-20T10:30:00.000Z"}]}
292 """
293 let decoder = JSONDecoder()
294 decoder.dateDecodingStrategy = .iso8601
295 return try decoder.decode(ReviewThread.self, from: Data(json.utf8))
296 }
297
298 private let diff = UnifiedDiff.parse("""
299 diff --git a/main.go b/main.go
300 --- a/main.go
301 +++ b/main.go
302 @@ -1,4 +1,5 @@
303 package main
304 -import "fmt"
305 +import (
306 +\t"fmt"
307 +)
308 """)
309
310 @Test func aThreadAnchorsToItsLineOnTheNewSide() throws {
311 // "+import (" is new line 2.
312 let subject = try thread(path: "main.go", line: 2)
313 #expect(diff.anchors(subject))
314
315 let file = try #require(diff.files.first)
316 let line = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 })
317 #expect(line.anchors(subject, in: file))
318 }
319
320 @Test func anOldSideThreadAnchorsToTheDeletedLine() throws {
321 // `-import "fmt"` is old line 2 and has no new number.
322 let subject = try thread(path: "main.go", line: 2, side: "old")
323 #expect(diff.anchors(subject))
324
325 let file = try #require(diff.files.first)
326 let deletion = try #require(file.hunks.first?.lines.first { $0.kind == .deletion })
327 #expect(deletion.anchors(subject, in: file))
328 // The same line number on the other side is a different anchor.
329 let addition = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 })
330 #expect(!addition.anchors(subject, in: file))
331 }
332
333 @Test func aStaleThreadAnchorsNowhere() throws {
334 // Its head is gone, so the line numbers cannot be trusted.
335 let subject = try thread(path: "main.go", line: 2, stale: true)
336 #expect(!diff.anchors(subject))
337 }
338
339 @Test func aThreadOnAnotherFileOrLineDoesNotAnchor() throws {
340 #expect(!diff.anchors(try thread(path: "other.go", line: 2)))
341 #expect(!diff.anchors(try thread(path: "main.go", line: 999)))
342 }
343}