gitbayTests/ListFilterTests.swift

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

443 lines · 17498 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 == "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}
405
406struct BuildFilterTests {
407
408    /// `build list` has no state flag, so nothing is narrowed by default
409    /// and the bare command is sent.
410    @Test func theDefaultSendsNoFlags() {
411        #expect(BuildFilter().flags().isEmpty)
412        #expect(BuildFilter().isNarrowed == false)
413    }
414
415    @Test func eachFieldRendersItsOwnFlag() {
416        var f = BuildFilter()
417        f.ref = "main"
418        f.status = .failure
419        f.job = "test"
420        #expect(f.flags() == ["--ref", "main", "--status", "failure", "--job", "test"])
421        #expect(f.isNarrowed)
422    }
423
424    @Test func aBlankBranchSendsNoFlag() {
425        var f = BuildFilter()
426        f.ref = "  "
427        #expect(f.flags().isEmpty)
428        #expect(f.isNarrowed == false)
429    }
430
431    @Test func theStatusVocabularyIsTheServersFive() {
432        #expect(BuildFilter.Status.allCases.map(\.rawValue)
433            == ["pending", "running", "success", "failure", "cancelled"])
434    }
435
436    @Test func theEmptyMessageSaysWhetherFiltersApply() {
437        #expect(BuildFilter().emptyMessage
438            == "No builds. Builds run when a push touches a repo with a .gitbay/ job file.")
439        var f = BuildFilter()
440        f.status = .success
441        #expect(f.emptyMessage == "No builds match these filters.")
442    }
443}