gitbayTests/ListFilterTests.swift
404 lines · 16244 bytes
1import Foundation
2import Testing
3@testable import gitbay
4
5struct IssueFilterTests {
6
7 @Test func theDefaultIsOpenAndNothingElse() {
8 #expect(IssueFilter().flags() == ["--state", "open"])
9 #expect(IssueFilter().isNarrowed == false)
10 }
11
12 @Test func stateIsAlwaysFirst() {
13 var f = IssueFilter()
14 f.state = .closed
15 f.author = "cmc"
16 #expect(f.flags().prefix(2) == ["--state", "closed"])
17 }
18
19 @Test func eachFieldRendersItsOwnFlag() {
20 var f = IssueFilter()
21 f.label = "bug"
22 f.assignee = "cmc"
23 f.author = "rae"
24 f.milestone = "v1.0.0"
25 f.search = "crash on launch"
26 #expect(f.flags() == [
27 "--state", "open",
28 "--search", "crash on launch",
29 "--label", "bug",
30 "--assignee", "cmc",
31 "--author", "rae",
32 "--milestone", "v1.0.0",
33 ])
34 #expect(f.isNarrowed)
35 }
36
37 /// An empty value must send no flag. `--search ""` is a different
38 /// query from no search at all.
39 @Test func emptyAndWhitespaceValuesSendNoFlag() {
40 var f = IssueFilter()
41 f.search = " "
42 f.label = ""
43 f.assignee = nil
44 f.author = " "
45 f.milestone = ""
46 #expect(f.flags() == ["--state", "open"])
47 #expect(f.isNarrowed == false)
48 }
49
50 /// "none" is a real milestone filter meaning "has no milestone".
51 @Test func milestoneNoneIsAValueNotAnAbsence() {
52 var f = IssueFilter()
53 f.milestone = "none"
54 #expect(f.flags() == ["--state", "open", "--milestone", "none"])
55 #expect(f.isNarrowed)
56 }
57
58 /// The server quotes FTS5 terms itself, so an operator is a word to
59 /// match. The app must not escape or reject it.
60 @Test func searchTextIsPassedThroughUnquoted() {
61 var f = IssueFilter()
62 f.search = "c++ AND \"quoted"
63 #expect(f.flags() == ["--state", "open", "--search", "c++ AND \"quoted"])
64 }
65
66 @Test func surroundingWhitespaceIsTrimmedFromValues() {
67 var f = IssueFilter()
68 f.search = " crash "
69 f.label = " bug "
70 #expect(f.flags() == ["--state", "open", "--search", "crash", "--label", "bug"])
71 }
72
73 @Test func theEmptyMessageNamesTheNarrowing() {
74 #expect(IssueFilter().emptyMessage == "No open issues.")
75 var all = IssueFilter()
76 all.state = .all
77 #expect(all.emptyMessage == "No issues.")
78 var narrowed = IssueFilter()
79 narrowed.label = "bug"
80 #expect(narrowed.emptyMessage == "No open issues match these filters.")
81 }
82}
83
84struct MRFilterTests {
85
86 @Test func theDefaultIsOpenAndNothingElse() {
87 #expect(MRFilter().flags() == ["--state", "open"])
88 #expect(MRFilter().isNarrowed == false)
89 }
90
91 @Test func eachFieldRendersItsOwnFlag() {
92 var f = MRFilter()
93 f.author = "cmc"
94 f.milestone = "v1.0.0"
95 f.search = "retarget"
96 #expect(f.flags() == [
97 "--state", "open",
98 "--search", "retarget",
99 "--author", "cmc",
100 "--milestone", "v1.0.0",
101 ])
102 }
103
104 /// `mr list` accepts source_gone; the spelling is the wire spelling,
105 /// not the Swift case name.
106 @Test func sourceGoneSpellsItselfTheWayTheCommandDoes() {
107 var f = MRFilter()
108 f.state = .sourceGone
109 #expect(f.flags() == ["--state", "source_gone"])
110 #expect(MRFilter.State.sourceGone.label == "Source gone")
111 }
112
113 @Test func everyStateTheCommandAcceptsIsOffered() {
114 #expect(MRFilter.State.allCases.map(\.rawValue)
115 == ["open", "merged", "closed", "source_gone", "all"])
116 }
117
118 @Test func emptyValuesSendNoFlag() {
119 var f = MRFilter()
120 f.search = " "
121 f.author = ""
122 #expect(f.flags() == ["--state", "open"])
123 }
124
125 @Test func theEmptyMessageNamesTheNarrowing() {
126 #expect(MRFilter().emptyMessage == "No open merge requests.")
127 var narrowed = MRFilter()
128 narrowed.search = "x"
129 #expect(narrowed.emptyMessage == "No open merge requests match these filters.")
130 }
131}
132
133private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
134 let box = StubProtocol.box()
135 let client = GitbayClient(
136 instance: try GitbayInstance(url: "https://gitbay.org"),
137 token: "test-token",
138 session: box.session()
139 )
140 return (client, box)
141}
142
143private let emptyPage = """
144 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
145 """
146private let noLabels = """
147 {"protocol_version":1,"data":[],"exit_code":0}
148 """
149private let pageWithCursor = """
150 {"protocol_version":1,"data":{"items":[\
151 {"number":7,"title":"a bug","state":"open","author":"cmc",\
152 "created_at":"2026-09-01T00:00:00Z"}],"next":"c1"},"exit_code":0}
153 """
154
155/// Every `argv=` value from a request URL, in order.
156private func argvFrom(_ url: URL) -> [String] {
157 URLComponents(url: url, resolvingAgainstBaseURL: false)?
158 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
159}
160
161@MainActor
162struct IssueListFilterWiringTests {
163
164 @Test func theDefaultReadIsOpenIssues() async throws {
165 let (client, stub) = try makeClient()
166 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
167 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
168
169 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
170 await model.load()
171
172 let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") })
173 #expect(argvFrom(read.url).prefix(6) == [
174 "issue", "list", "krz/gitbay", "--state", "open", "--limit",
175 ])
176 }
177
178 @Test func aPickerChangeReloadsWithTheNewFlags() async throws {
179 let (client, stub) = try makeClient()
180 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
181 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
182 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
183 await model.load()
184
185 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
186 model.filter.label = "bug"
187 await until { stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count == 2 }
188
189 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
190 let last = try #require(reads.last)
191 #expect(argvFrom(last.url).contains("--label"))
192 #expect(argvFrom(last.url).contains("bug"))
193 // Immediate, not debounced: two reads by now.
194 #expect(reads.count == 2)
195 }
196
197 /// Typing must not fire a request per keystroke.
198 @Test func typingDebouncesIntoOneRead() async throws {
199 let (client, stub) = try makeClient()
200 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
201 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
202 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
203 await model.load()
204
205 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
206 // No sleeps between keystrokes: `Task.sleep` guarantees a minimum
207 // wait, not a maximum, so any inter-keystroke delay is a window
208 // for the debounce timer to fire early on a loaded machine. Typing
209 // the whole string with no `await` between assignments removes
210 // that window; only the final wait — comfortably past the 300ms
211 // debounce — needs headroom.
212 for text in ["c", "cr", "cra", "cras", "crash"] {
213 model.filter.search = text
214 }
215 try await Task.sleep(for: .milliseconds(600))
216
217 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
218 #expect(reads.count == 2)
219 #expect(argvFrom(try #require(reads.last).url).contains("crash"))
220 }
221
222 /// Typing into `author` must debounce too — it is free text, exactly
223 /// like `search`, and firing a request per keystroke would hit the
224 /// rate limit on an 8-character username.
225 @Test func authorDebouncesIntoOneRead() async throws {
226 let (client, stub) = try makeClient()
227 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
228 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
229 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
230 await model.load()
231
232 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
233 for text in ["c", "cm", "cmc"] {
234 model.filter.author = text
235 }
236 try await Task.sleep(for: .milliseconds(600))
237
238 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
239 #expect(reads.count == 2)
240 let argv = argvFrom(try #require(reads.last).url)
241 #expect(argv.contains("--author"))
242 #expect(argv.contains("cmc"))
243 }
244
245 /// Same for `assignee`, issue-list-only.
246 @Test func assigneeDebouncesIntoOneRead() async throws {
247 let (client, stub) = try makeClient()
248 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
249 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
250 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
251 await model.load()
252
253 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
254 for text in ["c", "cm", "cmc"] {
255 model.filter.assignee = text
256 }
257 try await Task.sleep(for: .milliseconds(600))
258
259 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
260 #expect(reads.count == 2)
261 let argv = argvFrom(try #require(reads.last).url)
262 #expect(argv.contains("--assignee"))
263 #expect(argv.contains("cmc"))
264 }
265
266 /// A filter change reloads rather than pages, so it must not carry
267 /// forward a cursor from whatever page was on screen.
268 @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws {
269 let (client, stub) = try makeClient()
270 stub.enqueue(.init(status: 200, json: pageWithCursor, match: "argv=issue"))
271 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
272 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
273 await model.load()
274 #expect(model.list.hasMore)
275
276 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
277 model.filter.label = "bug"
278 // Poll for the read that actually carries the new filter, not
279 // whatever happens to be last: a reload that never landed would
280 // otherwise leave `last` pointing at the initial request, which
281 // also carries no cursor and would pass just as wrongly.
282 await until {
283 stub.seen.contains { $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug") }
284 }
285
286 let reload = try #require(stub.seen.first {
287 $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug")
288 })
289 #expect(!argvFrom(reload.url).contains("--cursor"))
290 }
291
292 /// Two rapid picker changes cancel the first reload's task. The
293 /// cancelled fetch must not paint a bogus "check your connection"
294 /// failure over the correct state the second reload lands.
295 @Test func rapidFilterChangesDoNotLeaveTheScreenFailed() async throws {
296 let (client, stub) = try makeClient()
297 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
298 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
299 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
300 await model.load()
301
302 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
303 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
304 model.filter.label = "bug"
305 model.filter.label = "feature"
306 // Wait for the winning reload's read to land rather than a fixed
307 // sleep: checking `.failed` against a still-untouched state (the
308 // spawned task hasn't run yet) would pass no matter what the real
309 // behaviour is. Whether the cancelled first task's own read also
310 // reaches the stub before it notices cancellation is a race in
311 // its own right, so this only requires the winning one.
312 await until {
313 stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count >= 2
314 }
315 // The cancelled task's own completion can still land after the
316 // winning reload's; a poll alone stops the instant the count is
317 // satisfied and would miss a bogus overwrite arriving right
318 // after. A short settle catches it.
319 try await Task.sleep(for: .milliseconds(50))
320
321 // The state the poll waited for is "a reload's read landed", not
322 // "the screen settled" — a filter change that reloaded nothing at
323 // all would leave the pre-existing, unchanged, non-failed state
324 // sitting there and pass just as wrongly as a `.loading` screen
325 // checked too early would.
326 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count
327 guard reads >= 2 else {
328 Testing.Issue.record("rapid filter changes never reloaded")
329 return
330 }
331
332 if case .failed(let message) = model.state {
333 Testing.Issue.record("rapid filter changes left the screen failed: \(message)")
334 }
335 }
336
337 @Test func theEmptyMessageComesFromTheFilter() async throws {
338 let (client, stub) = try makeClient()
339 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
340 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
341 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
342 await model.load()
343
344 guard case .empty(let message) = model.state else {
345 Testing.Issue.record("expected empty, got \(model.state)")
346 return
347 }
348 #expect(message == "No open issues.")
349 }
350}
351
352@MainActor
353struct MRListFilterWiringTests {
354
355 @Test func searchReachesTheCommand() async throws {
356 let (client, stub) = try makeClient()
357 stub.enqueue(.init(status: 200, json: emptyPage))
358 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
359 await model.load()
360
361 stub.enqueue(.init(status: 200, json: emptyPage))
362 model.filter.search = "retarget"
363 await until { stub.seen.count == 2 }
364
365 let last = try #require(stub.seen.last)
366 let argv = argvFrom(last.url)
367 #expect(argv.contains("--search"))
368 #expect(argv.contains("retarget"))
369 }
370
371 /// Typing into `author` must debounce into one read, same as `search`
372 /// — and, unlike the other tests in this struct, actually count the
373 /// requests rather than just inspecting the last one.
374 @Test func authorDebouncesIntoOneRead() async throws {
375 let (client, stub) = try makeClient()
376 stub.enqueue(.init(status: 200, json: emptyPage))
377 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
378 await model.load()
379
380 stub.enqueue(.init(status: 200, json: emptyPage))
381 for text in ["c", "cm", "cmc"] {
382 model.filter.author = text
383 }
384 try await Task.sleep(for: .milliseconds(600))
385
386 #expect(stub.seen.count == 2)
387 let argv = argvFrom(try #require(stub.seen.last).url)
388 #expect(argv.contains("--author"))
389 #expect(argv.contains("cmc"))
390 }
391
392 @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws {
393 let (client, stub) = try makeClient()
394 stub.enqueue(.init(status: 200, json: emptyPage))
395 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
396 await model.load()
397
398 stub.enqueue(.init(status: 200, json: emptyPage))
399 model.filter.state = .sourceGone
400 await until { stub.seen.count == 2 }
401
402 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone"))
403 }
404}