gitbayTests/ListFilterTests.swift
453 lines · 17983 bytes
38 symbols in this file
IssueFilterTeststheDefaultIsOpenAndNothingElsestateIsAlwaysFirsteachFieldRendersItsOwnFlagemptyAndWhitespaceValuesSendNoFlagmilestoneNoneIsAValueNotAnAbsencesearchTextIsPassedThroughUnquotedsurroundingWhitespaceIsTrimmedFromValuestheEmptyMessageNamesTheNarrowingMRFilterTeststheDefaultIsOpenAndNothingElseeachFieldRendersItsOwnFlagsourceGoneSpellsItselfTheWayTheCommandDoesstatesReadInSentenceCaseeveryStateTheCommandAcceptsIsOfferedemptyValuesSendNoFlagtheEmptyMessageNamesTheNarrowingmakeClientargvFromIssueListFilterWiringTeststheDefaultReadIsOpenIssuesaPickerChangeReloadsWithTheNewFlagstypingDebouncesIntoOneReadauthorDebouncesIntoOneReadassigneeDebouncesIntoOneReadaFilterChangeDoesNotCarryAStaleCursorrapidFilterChangesDoNotLeaveTheScreenFailedtheEmptyMessageComesFromTheFilterMRListFilterWiringTestssearchReachesTheCommandauthorDebouncesIntoOneReadsourceGoneReachesTheCommandInItsWireSpellingBuildFilterTeststheDefaultSendsNoFlagseachFieldRendersItsOwnFlagaBlankBranchSendsNoFlagtheStatusVocabularyIsTheServersFivetheEmptyMessageSaysWhetherFiltersApply
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 /// Filters, chips and headers all say a state the same way.
114 @Test func statesReadInSentenceCase() {
115 #expect(stateLabel("open") == "Open")
116 #expect(stateLabel("merged") == "Merged")
117 #expect(stateLabel("source_gone") == "Source gone")
118 #expect(MRFilter.State.allCases.map(\.label)
119 == ["Open", "Merged", "Closed", "Source gone", "All"])
120 #expect(IssueFilter.State.allCases.map(\.label) == ["Open", "Closed", "All"])
121 }
122
123 @Test func everyStateTheCommandAcceptsIsOffered() {
124 #expect(MRFilter.State.allCases.map(\.rawValue)
125 == ["open", "merged", "closed", "source_gone", "all"])
126 }
127
128 @Test func emptyValuesSendNoFlag() {
129 var f = MRFilter()
130 f.search = " "
131 f.author = ""
132 #expect(f.flags() == ["--state", "open"])
133 }
134
135 @Test func theEmptyMessageNamesTheNarrowing() {
136 #expect(MRFilter().emptyMessage == "No open merge requests.")
137 var narrowed = MRFilter()
138 narrowed.search = "x"
139 #expect(narrowed.emptyMessage == "No open merge requests match these filters.")
140 }
141}
142
143private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
144 let box = StubProtocol.box()
145 let client = GitbayClient(
146 instance: try GitbayInstance(url: "https://gitbay.org"),
147 token: "test-token",
148 session: box.session()
149 )
150 return (client, box)
151}
152
153private let emptyPage = """
154 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
155 """
156private let noLabels = """
157 {"protocol_version":1,"data":[],"exit_code":0}
158 """
159private let pageWithCursor = """
160 {"protocol_version":1,"data":{"items":[\
161 {"number":7,"title":"a bug","state":"open","author":"cmc",\
162 "created_at":"2026-09-01T00:00:00Z"}],"next":"c1"},"exit_code":0}
163 """
164
165/// Every `argv=` value from a request URL, in order.
166private func argvFrom(_ url: URL) -> [String] {
167 URLComponents(url: url, resolvingAgainstBaseURL: false)?
168 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
169}
170
171@MainActor
172struct IssueListFilterWiringTests {
173
174 @Test func theDefaultReadIsOpenIssues() async throws {
175 let (client, stub) = try makeClient()
176 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
177 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
178
179 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
180 await model.load()
181
182 let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") })
183 #expect(argvFrom(read.url).prefix(6) == [
184 "issue", "list", "krz/gitbay", "--state", "open", "--limit",
185 ])
186 }
187
188 @Test func aPickerChangeReloadsWithTheNewFlags() async throws {
189 let (client, stub) = try makeClient()
190 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
191 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
192 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
193 await model.load()
194
195 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
196 model.filter.label = "bug"
197 await until { stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count == 2 }
198
199 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
200 let last = try #require(reads.last)
201 #expect(argvFrom(last.url).contains("--label"))
202 #expect(argvFrom(last.url).contains("bug"))
203 // Immediate, not debounced: two reads by now.
204 #expect(reads.count == 2)
205 }
206
207 /// Typing must not fire a request per keystroke.
208 @Test func typingDebouncesIntoOneRead() async throws {
209 let (client, stub) = try makeClient()
210 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
211 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
212 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
213 await model.load()
214
215 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
216 // No sleeps between keystrokes: `Task.sleep` guarantees a minimum
217 // wait, not a maximum, so any inter-keystroke delay is a window
218 // for the debounce timer to fire early on a loaded machine. Typing
219 // the whole string with no `await` between assignments removes
220 // that window; only the final wait — comfortably past the 300ms
221 // debounce — needs headroom.
222 for text in ["c", "cr", "cra", "cras", "crash"] {
223 model.filter.search = text
224 }
225 try await Task.sleep(for: .milliseconds(600))
226
227 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
228 #expect(reads.count == 2)
229 #expect(argvFrom(try #require(reads.last).url).contains("crash"))
230 }
231
232 /// Typing into `author` must debounce too — it is free text, exactly
233 /// like `search`, and firing a request per keystroke would hit the
234 /// rate limit on an 8-character username.
235 @Test func authorDebouncesIntoOneRead() async throws {
236 let (client, stub) = try makeClient()
237 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
238 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
239 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
240 await model.load()
241
242 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
243 for text in ["c", "cm", "cmc"] {
244 model.filter.author = text
245 }
246 try await Task.sleep(for: .milliseconds(600))
247
248 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
249 #expect(reads.count == 2)
250 let argv = argvFrom(try #require(reads.last).url)
251 #expect(argv.contains("--author"))
252 #expect(argv.contains("cmc"))
253 }
254
255 /// Same for `assignee`, issue-list-only.
256 @Test func assigneeDebouncesIntoOneRead() async throws {
257 let (client, stub) = try makeClient()
258 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
259 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
260 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
261 await model.load()
262
263 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
264 for text in ["c", "cm", "cmc"] {
265 model.filter.assignee = text
266 }
267 try await Task.sleep(for: .milliseconds(600))
268
269 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
270 #expect(reads.count == 2)
271 let argv = argvFrom(try #require(reads.last).url)
272 #expect(argv.contains("--assignee"))
273 #expect(argv.contains("cmc"))
274 }
275
276 /// A filter change reloads rather than pages, so it must not carry
277 /// forward a cursor from whatever page was on screen.
278 @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws {
279 let (client, stub) = try makeClient()
280 stub.enqueue(.init(status: 200, json: pageWithCursor, match: "argv=issue"))
281 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
282 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
283 await model.load()
284 #expect(model.list.hasMore)
285
286 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
287 model.filter.label = "bug"
288 // Poll for the read that actually carries the new filter, not
289 // whatever happens to be last: a reload that never landed would
290 // otherwise leave `last` pointing at the initial request, which
291 // also carries no cursor and would pass just as wrongly.
292 await until {
293 stub.seen.contains { $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug") }
294 }
295
296 let reload = try #require(stub.seen.first {
297 $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug")
298 })
299 #expect(!argvFrom(reload.url).contains("--cursor"))
300 }
301
302 /// Two rapid picker changes cancel the first reload's task. The
303 /// cancelled fetch must not paint a bogus "check your connection"
304 /// failure over the correct state the second reload lands.
305 @Test func rapidFilterChangesDoNotLeaveTheScreenFailed() async throws {
306 let (client, stub) = try makeClient()
307 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
308 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
309 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
310 await model.load()
311
312 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
313 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
314 model.filter.label = "bug"
315 model.filter.label = "feature"
316 // Wait for the winning reload's read to land rather than a fixed
317 // sleep: checking `.failed` against a still-untouched state (the
318 // spawned task hasn't run yet) would pass no matter what the real
319 // behaviour is. Whether the cancelled first task's own read also
320 // reaches the stub before it notices cancellation is a race in
321 // its own right, so this only requires the winning one.
322 await until {
323 stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count >= 2
324 }
325 // The cancelled task's own completion can still land after the
326 // winning reload's; a poll alone stops the instant the count is
327 // satisfied and would miss a bogus overwrite arriving right
328 // after. A short settle catches it.
329 try await Task.sleep(for: .milliseconds(50))
330
331 // The state the poll waited for is "a reload's read landed", not
332 // "the screen settled" — a filter change that reloaded nothing at
333 // all would leave the pre-existing, unchanged, non-failed state
334 // sitting there and pass just as wrongly as a `.loading` screen
335 // checked too early would.
336 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count
337 guard reads >= 2 else {
338 Testing.Issue.record("rapid filter changes never reloaded")
339 return
340 }
341
342 if case .failed(let message) = model.state {
343 Testing.Issue.record("rapid filter changes left the screen failed: \(message)")
344 }
345 }
346
347 @Test func theEmptyMessageComesFromTheFilter() async throws {
348 let (client, stub) = try makeClient()
349 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
350 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
351 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
352 await model.load()
353
354 guard case .empty(let message) = model.state else {
355 Testing.Issue.record("expected empty, got \(model.state)")
356 return
357 }
358 #expect(message == "No open issues.")
359 }
360}
361
362@MainActor
363struct MRListFilterWiringTests {
364
365 @Test func searchReachesTheCommand() async throws {
366 let (client, stub) = try makeClient()
367 stub.enqueue(.init(status: 200, json: emptyPage))
368 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
369 await model.load()
370
371 stub.enqueue(.init(status: 200, json: emptyPage))
372 model.filter.search = "retarget"
373 await until { stub.seen.count == 2 }
374
375 let last = try #require(stub.seen.last)
376 let argv = argvFrom(last.url)
377 #expect(argv.contains("--search"))
378 #expect(argv.contains("retarget"))
379 }
380
381 /// Typing into `author` must debounce into one read, same as `search`
382 /// — and, unlike the other tests in this struct, actually count the
383 /// requests rather than just inspecting the last one.
384 @Test func authorDebouncesIntoOneRead() async throws {
385 let (client, stub) = try makeClient()
386 stub.enqueue(.init(status: 200, json: emptyPage))
387 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
388 await model.load()
389
390 stub.enqueue(.init(status: 200, json: emptyPage))
391 for text in ["c", "cm", "cmc"] {
392 model.filter.author = text
393 }
394 try await Task.sleep(for: .milliseconds(600))
395
396 #expect(stub.seen.count == 2)
397 let argv = argvFrom(try #require(stub.seen.last).url)
398 #expect(argv.contains("--author"))
399 #expect(argv.contains("cmc"))
400 }
401
402 @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws {
403 let (client, stub) = try makeClient()
404 stub.enqueue(.init(status: 200, json: emptyPage))
405 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
406 await model.load()
407
408 stub.enqueue(.init(status: 200, json: emptyPage))
409 model.filter.state = .sourceGone
410 await until { stub.seen.count == 2 }
411
412 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone"))
413 }
414}
415
416struct BuildFilterTests {
417
418 /// `build list` has no state flag, so nothing is narrowed by default
419 /// and the bare command is sent.
420 @Test func theDefaultSendsNoFlags() {
421 #expect(BuildFilter().flags().isEmpty)
422 #expect(BuildFilter().isNarrowed == false)
423 }
424
425 @Test func eachFieldRendersItsOwnFlag() {
426 var f = BuildFilter()
427 f.ref = "main"
428 f.status = .failure
429 f.job = "test"
430 #expect(f.flags() == ["--ref", "main", "--status", "failure", "--job", "test"])
431 #expect(f.isNarrowed)
432 }
433
434 @Test func aBlankBranchSendsNoFlag() {
435 var f = BuildFilter()
436 f.ref = " "
437 #expect(f.flags().isEmpty)
438 #expect(f.isNarrowed == false)
439 }
440
441 @Test func theStatusVocabularyIsTheServersFive() {
442 #expect(BuildFilter.Status.allCases.map(\.rawValue)
443 == ["pending", "running", "success", "failure", "cancelled"])
444 }
445
446 @Test func theEmptyMessageSaysWhetherFiltersApply() {
447 #expect(BuildFilter().emptyMessage
448 == "No builds. Builds run when a push touches a repo with a .gitbay/ job file.")
449 var f = BuildFilter()
450 f.status = .success
451 #expect(f.emptyMessage == "No builds match these filters.")
452 }
453}