import Foundation import Testing @testable import gitbay struct IssueFilterTests { @Test func theDefaultIsOpenAndNothingElse() { #expect(IssueFilter().flags() == ["--state", "open"]) #expect(IssueFilter().isNarrowed == false) } @Test func stateIsAlwaysFirst() { var f = IssueFilter() f.state = .closed f.author = "cmc" #expect(f.flags().prefix(2) == ["--state", "closed"]) } @Test func eachFieldRendersItsOwnFlag() { var f = IssueFilter() f.label = "bug" f.assignee = "cmc" f.author = "rae" f.milestone = "v1.0.0" f.search = "crash on launch" #expect(f.flags() == [ "--state", "open", "--search", "crash on launch", "--label", "bug", "--assignee", "cmc", "--author", "rae", "--milestone", "v1.0.0", ]) #expect(f.isNarrowed) } /// An empty value must send no flag. `--search ""` is a different /// query from no search at all. @Test func emptyAndWhitespaceValuesSendNoFlag() { var f = IssueFilter() f.search = " " f.label = "" f.assignee = nil f.author = " " f.milestone = "" #expect(f.flags() == ["--state", "open"]) #expect(f.isNarrowed == false) } /// "none" is a real milestone filter meaning "has no milestone". @Test func milestoneNoneIsAValueNotAnAbsence() { var f = IssueFilter() f.milestone = "none" #expect(f.flags() == ["--state", "open", "--milestone", "none"]) #expect(f.isNarrowed) } /// The server quotes FTS5 terms itself, so an operator is a word to /// match. The app must not escape or reject it. @Test func searchTextIsPassedThroughUnquoted() { var f = IssueFilter() f.search = "c++ AND \"quoted" #expect(f.flags() == ["--state", "open", "--search", "c++ AND \"quoted"]) } @Test func surroundingWhitespaceIsTrimmedFromValues() { var f = IssueFilter() f.search = " crash " f.label = " bug " #expect(f.flags() == ["--state", "open", "--search", "crash", "--label", "bug"]) } @Test func theEmptyMessageNamesTheNarrowing() { #expect(IssueFilter().emptyMessage == "No open issues.") var all = IssueFilter() all.state = .all #expect(all.emptyMessage == "No issues.") var narrowed = IssueFilter() narrowed.label = "bug" #expect(narrowed.emptyMessage == "No open issues match these filters.") } } struct MRFilterTests { @Test func theDefaultIsOpenAndNothingElse() { #expect(MRFilter().flags() == ["--state", "open"]) #expect(MRFilter().isNarrowed == false) } @Test func eachFieldRendersItsOwnFlag() { var f = MRFilter() f.author = "cmc" f.milestone = "v1.0.0" f.search = "retarget" #expect(f.flags() == [ "--state", "open", "--search", "retarget", "--author", "cmc", "--milestone", "v1.0.0", ]) } /// `mr list` accepts source_gone; the spelling is the wire spelling, /// not the Swift case name. @Test func sourceGoneSpellsItselfTheWayTheCommandDoes() { var f = MRFilter() f.state = .sourceGone #expect(f.flags() == ["--state", "source_gone"]) #expect(MRFilter.State.sourceGone.label == "Source gone") } @Test func everyStateTheCommandAcceptsIsOffered() { #expect(MRFilter.State.allCases.map(\.rawValue) == ["open", "merged", "closed", "source_gone", "all"]) } @Test func emptyValuesSendNoFlag() { var f = MRFilter() f.search = " " f.author = "" #expect(f.flags() == ["--state", "open"]) } @Test func theEmptyMessageNamesTheNarrowing() { #expect(MRFilter().emptyMessage == "No open merge requests.") var narrowed = MRFilter() narrowed.search = "x" #expect(narrowed.emptyMessage == "No open merge requests match these filters.") } } private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private let emptyPage = """ {"protocol_version":1,"data":{"items":[]},"exit_code":0} """ private let noLabels = """ {"protocol_version":1,"data":[],"exit_code":0} """ private let pageWithCursor = """ {"protocol_version":1,"data":{"items":[\ {"number":7,"title":"a bug","state":"open","author":"cmc",\ "created_at":"2026-09-01T00:00:00Z"}],"next":"c1"},"exit_code":0} """ /// Every `argv=` value from a request URL, in order. private func argvFrom(_ url: URL) -> [String] { URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] } @MainActor struct IssueListFilterWiringTests { @Test func theDefaultReadIsOpenIssues() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") }) #expect(argvFrom(read.url).prefix(6) == [ "issue", "list", "krz/gitbay", "--state", "open", "--limit", ]) } @Test func aPickerChangeReloadsWithTheNewFlags() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) model.filter.label = "bug" await until { stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count == 2 } let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } let last = try #require(reads.last) #expect(argvFrom(last.url).contains("--label")) #expect(argvFrom(last.url).contains("bug")) // Immediate, not debounced: two reads by now. #expect(reads.count == 2) } /// Typing must not fire a request per keystroke. @Test func typingDebouncesIntoOneRead() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) // No sleeps between keystrokes: `Task.sleep` guarantees a minimum // wait, not a maximum, so any inter-keystroke delay is a window // for the debounce timer to fire early on a loaded machine. Typing // the whole string with no `await` between assignments removes // that window; only the final wait — comfortably past the 300ms // debounce — needs headroom. for text in ["c", "cr", "cra", "cras", "crash"] { model.filter.search = text } try await Task.sleep(for: .milliseconds(600)) let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } #expect(reads.count == 2) #expect(argvFrom(try #require(reads.last).url).contains("crash")) } /// Typing into `author` must debounce too — it is free text, exactly /// like `search`, and firing a request per keystroke would hit the /// rate limit on an 8-character username. @Test func authorDebouncesIntoOneRead() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) for text in ["c", "cm", "cmc"] { model.filter.author = text } try await Task.sleep(for: .milliseconds(600)) let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } #expect(reads.count == 2) let argv = argvFrom(try #require(reads.last).url) #expect(argv.contains("--author")) #expect(argv.contains("cmc")) } /// Same for `assignee`, issue-list-only. @Test func assigneeDebouncesIntoOneRead() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) for text in ["c", "cm", "cmc"] { model.filter.assignee = text } try await Task.sleep(for: .milliseconds(600)) let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } #expect(reads.count == 2) let argv = argvFrom(try #require(reads.last).url) #expect(argv.contains("--assignee")) #expect(argv.contains("cmc")) } /// A filter change reloads rather than pages, so it must not carry /// forward a cursor from whatever page was on screen. @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: pageWithCursor, match: "argv=issue")) stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() #expect(model.list.hasMore) stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) model.filter.label = "bug" // Poll for the read that actually carries the new filter, not // whatever happens to be last: a reload that never landed would // otherwise leave `last` pointing at the initial request, which // also carries no cursor and would pass just as wrongly. await until { stub.seen.contains { $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug") } } let reload = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug") }) #expect(!argvFrom(reload.url).contains("--cursor")) } /// Two rapid picker changes cancel the first reload's task. The /// cancelled fetch must not paint a bogus "check your connection" /// failure over the correct state the second reload lands. @Test func rapidFilterChangesDoNotLeaveTheScreenFailed() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) model.filter.label = "bug" model.filter.label = "feature" // Wait for the winning reload's read to land rather than a fixed // sleep: checking `.failed` against a still-untouched state (the // spawned task hasn't run yet) would pass no matter what the real // behaviour is. Whether the cancelled first task's own read also // reaches the stub before it notices cancellation is a race in // its own right, so this only requires the winning one. await until { stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count >= 2 } // The cancelled task's own completion can still land after the // winning reload's; a poll alone stops the instant the count is // satisfied and would miss a bogus overwrite arriving right // after. A short settle catches it. try await Task.sleep(for: .milliseconds(50)) // The state the poll waited for is "a reload's read landed", not // "the screen settled" — a filter change that reloaded nothing at // all would leave the pre-existing, unchanged, non-failed state // sitting there and pass just as wrongly as a `.loading` screen // checked too early would. let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count guard reads >= 2 else { Testing.Issue.record("rapid filter changes never reloaded") return } if case .failed(let message) = model.state { Testing.Issue.record("rapid filter changes left the screen failed: \(message)") } } @Test func theEmptyMessageComesFromTheFilter() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") await model.load() guard case .empty(let message) = model.state else { Testing.Issue.record("expected empty, got \(model.state)") return } #expect(message == "No open issues.") } } @MainActor struct MRListFilterWiringTests { @Test func searchReachesTheCommand() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage)) let model = MRListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: emptyPage)) model.filter.search = "retarget" await until { stub.seen.count == 2 } let last = try #require(stub.seen.last) let argv = argvFrom(last.url) #expect(argv.contains("--search")) #expect(argv.contains("retarget")) } /// Typing into `author` must debounce into one read, same as `search` /// — and, unlike the other tests in this struct, actually count the /// requests rather than just inspecting the last one. @Test func authorDebouncesIntoOneRead() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage)) let model = MRListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: emptyPage)) for text in ["c", "cm", "cmc"] { model.filter.author = text } try await Task.sleep(for: .milliseconds(600)) #expect(stub.seen.count == 2) let argv = argvFrom(try #require(stub.seen.last).url) #expect(argv.contains("--author")) #expect(argv.contains("cmc")) } @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: emptyPage)) let model = MRListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: emptyPage)) model.filter.state = .sourceGone await until { stub.seen.count == 2 } #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone")) } }