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 nonisolated struct Row: Decodable, Sendable, Equatable { let number: Int } private func page(_ numbers: [Int], next: String? = nil) -> String { let items = numbers.map { #"{"number":\#($0)}"# }.joined(separator: ",") let nextField = next.map { #","next":"\#($0)""# } ?? "" return #"{"protocol_version":1,"data":{"items":[\#(items)]\#(nextField)},"exit_code":0}"# } struct ClientPagingTests { @Test func readPageSendsLimitAndCursorAndDecodesNext() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page([1, 2], next: "abc"))) stub.enqueue(.init(status: 200, json: page([3]))) let first = try await client.readPage(["issue", "list", "krz/gitbay"], of: Row.self, limit: 2) let second = try await client.readPage( ["issue", "list", "krz/gitbay"], of: Row.self, limit: 2, cursor: first.next ) #expect(first.items == [Row(number: 1), Row(number: 2)]) #expect(first.next == "abc") #expect(second.items == [Row(number: 3)]) #expect(second.next == nil) let firstSeen = try #require(stub.seen.first) #expect(firstSeen.url.query() == "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2") let secondSeen = try #require(stub.seen.dropFirst().first) #expect(secondSeen.url.query() == "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc") } } @MainActor struct PagedListModelTests { private func makeList(pageSize: Int = 2) throws -> (PagedListModel, StubProtocol.Box) { let (client, stub) = try makeClient() let list = PagedListModel( client: client, argv: ["issue", "list", "krz/gitbay"], emptyMessage: "nothing", pageSize: pageSize ) return (list, stub) } @Test func loadMoreAppendsUntilTheCursorRunsOut() async throws { let (list, stub) = try makeList() stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) stub.enqueue(.init(status: 200, json: page([3, 4], next: "c2"))) stub.enqueue(.init(status: 200, json: page([5]))) await list.reload() #expect(list.state.value == [Row(number: 1), Row(number: 2)]) #expect(list.hasMore) await list.loadMore() await list.loadMore() #expect(list.state.value?.map(\.number) == [1, 2, 3, 4, 5]) #expect(!list.hasMore) // Further calls are no-ops, not requests. await list.loadMore() #expect(stub.seen.count == 3) let second = try #require(stub.seen.dropFirst().first) #expect(second.url.query()?.contains("argv=--cursor&argv=c1") == true) let third = try #require(stub.seen.dropFirst(2).first) #expect(third.url.query()?.contains("argv=--cursor&argv=c2") == true) } @Test func anEmptyFirstPageIsAnEmptyState() async throws { let (list, stub) = try makeList() stub.enqueue(.init(status: 200, json: page([]))) await list.reload() guard case .empty(let message) = list.state else { Issue.record("expected .empty, got \(list.state)") return } #expect(message == "nothing") } @Test func aFailedLaterPageKeepsTheListAndTheCursor() async throws { let (list, stub) = try makeList() stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) await list.reload() await list.loadMore() #expect(list.state.value == [Row(number: 1), Row(number: 2)]) #expect(list.hasMore) // the cursor survives for a retry stub.enqueue(.init(status: 200, json: page([3]))) await list.loadMore() #expect(list.state.value?.map(\.number) == [1, 2, 3]) } /// A cancelled reload (e.g. superseded by a newer one from a fast /// filter change) must not write a `.failed` state — whatever error /// the cancellation surfaces as, it belongs to a fetch nobody wants /// the result of any more. @Test func aCancelledReloadDoesNotWriteAFailedState() async throws { let (list, _) = try makeList() let task = Task { await list.reload() } task.cancel() await task.value if case .failed(let message) = list.state { Issue.record("cancelled reload left the screen failed: \(message)") } } /// A list's `.task` runs again when a pushed screen is popped. Only a /// list with nothing to show yet loads there; one showing pages keeps /// them. @Test func needsLoadUntilAPageOrTheEmptyStateIsShown() async throws { let (list, stub) = try makeList() #expect(list.needsLoad) stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) await list.reload() #expect(!list.needsLoad) let (empty, emptyStub) = try makeList() emptyStub.enqueue(.init(status: 200, json: page([]))) await empty.reload() #expect(!empty.needsLoad) } /// A failed first page has nothing on screen to keep, so returning to /// the list tries again. @Test func aFailedFirstPageStillNeedsLoad() async throws { let (list, stub) = try makeList() stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"denied","exit_code":4}"#)) await list.reload() #expect(list.needsLoad) } @Test func reloadDropsTheOldCursorAndItems() async throws { let (list, stub) = try makeList() stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) stub.enqueue(.init(status: 200, json: page([9]))) await list.reload() await list.reload() #expect(list.state.value == [Row(number: 9)]) #expect(!list.hasMore) } }