gitbayTests/ListFilterTests.swift

v1.11.0
gitbay-ios/gitbayTests/ListFilterTests.swift history · blame · raw

453 lines · 17983 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    /// 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}