import Foundation import Testing @testable import gitbay 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 func argvFrom(_ url: URL) -> [String] { URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] } private let mixedResults = """ {"protocol_version":1,"data":[\ {"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\ {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\ "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52.595Z"},\ {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\ "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13.062Z"}\ ],"exit_code":0} """ struct SearchResultDecodingTests { /// Decodes with the client's own date strategy, as `DiscoveryTests` does. private func decode(_ json: String) throws -> [SearchResult] { let decoder = GitbayClient.decoder() return try decoder.decode([SearchResult].self, from: Data(json.utf8)) } private let rows = """ [{"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\ {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\ "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52Z"},\ {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\ "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13Z"}] """ @Test func decodesAllThreeKinds() throws { let results = try decode(rows) #expect(results.map(\.kind) == ["repo", "issue", "mr"]) #expect(results[1].number == 168) #expect(results[1].author == "cmc") #expect(results[1].state == "closed") } /// A repository result carries no number, author, state or time — /// they are omitempty and absent, not null. @Test func aRepositoryResultHasNoNumberOrAuthor() throws { let repo = try #require(try decode(rows).first) #expect(repo.number == nil) #expect(repo.author == nil) #expect(repo.state == nil) #expect(repo.updatedAt == nil) } /// A repo row and an issue row in the same repository must not /// collide, or SwiftUI reuses one for the other. @Test func idsAreUniqueAcrossKinds() throws { let ids = try decode(rows).map(\.id) #expect(Set(ids).count == ids.count) } } struct SearchKindTests { @Test func allSendsNoFlag() { #expect(SearchKind.all.flag == []) } @Test func theOtherThreeSendTheirWireSpelling() { #expect(SearchKind.repo.flag == ["--kind", "repo"]) #expect(SearchKind.issue.flag == ["--kind", "issue"]) #expect(SearchKind.mr.flag == ["--kind", "mr"]) } @Test func everyKindTheCommandAcceptsIsOffered() { #expect(SearchKind.allCases.map(\.rawValue) == ["all", "repo", "issue", "mr"]) } } @MainActor struct SearchViewModelTests { @Test func anEmptyQuerySearchesNothing() async throws { let (client, stub) = try makeClient() let model = SearchViewModel(client: client) model.query = " " try await Task.sleep(for: .milliseconds(500)) #expect(stub.seen.isEmpty) #expect(model.isSearching == false) } @Test func aQueryReachesTheSearchCommand() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) model.query = "parity" try await Task.sleep(for: .milliseconds(600)) let read = try #require(stub.seen.last) #expect(argvFrom(read.url) == ["search", "parity"]) #expect(model.state.value?.count == 3) #expect(model.isSearching) } @Test func typingDebouncesIntoOneRequest() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) for text in ["p", "pa", "par", "pari", "parity"] { model.query = text } try await Task.sleep(for: .milliseconds(700)) #expect(stub.seen.count == 1) #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"]) } @Test func narrowingByKindAddsTheFlag() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) model.query = "parity" try await Task.sleep(for: .milliseconds(600)) stub.enqueue(.init(status: 200, json: mixedResults)) model.kind = .issue await until { stub.seen.count == 2 } #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity", "--kind", "issue"]) // A picker, not typing: two requests by now, not one. #expect(stub.seen.count == 2) } /// Changing the kind with no query must not search. @Test func kindAloneSearchesNothing() async throws { let (client, stub) = try makeClient() let model = SearchViewModel(client: client) model.kind = .mr try await Task.sleep(for: .milliseconds(400)) #expect(stub.seen.isEmpty) } /// The server quotes FTS5 terms itself, so an operator is a word. @Test func theQueryIsPassedThroughUnquoted() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) model.query = "c++" try await Task.sleep(for: .milliseconds(600)) #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"]) } @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[],"exit_code":0} """)) let model = SearchViewModel(client: client) model.query = "zzzznothing" try await Task.sleep(for: .milliseconds(600)) guard case .empty = model.state else { Testing.Issue.record("expected empty, got \(model.state)") return } } @Test func aServerFailureLandsInFailedNotEmpty() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"boom","exit_code":1} """)) let model = SearchViewModel(client: client) model.query = "parity" try await Task.sleep(for: .milliseconds(600)) guard case .failed = model.state else { Testing.Issue.record("expected failed, got \(model.state)") return } } @Test func theQueryIsTrimmedBeforeBeingSent() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mixedResults)) let model = SearchViewModel(client: client) model.query = " parity " try await Task.sleep(for: .milliseconds(600)) #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"]) } }