gitbayTests/PagedListTests.swift
173 lines · 6419 bytes
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private nonisolated struct Row: Decodable, Sendable, Equatable {
16 let number: Int
17}
18
19private func page(_ numbers: [Int], next: String? = nil) -> String {
20 let items = numbers.map { #"{"number":\#($0)}"# }.joined(separator: ",")
21 let nextField = next.map { #","next":"\#($0)""# } ?? ""
22 return #"{"protocol_version":1,"data":{"items":[\#(items)]\#(nextField)},"exit_code":0}"#
23}
24
25struct ClientPagingTests {
26
27 @Test func readPageSendsLimitAndCursorAndDecodesNext() async throws {
28 let (client, stub) = try makeClient()
29 stub.enqueue(.init(status: 200, json: page([1, 2], next: "abc")))
30 stub.enqueue(.init(status: 200, json: page([3])))
31
32 let first = try await client.readPage(["issue", "list", "krz/gitbay"], of: Row.self, limit: 2)
33 let second = try await client.readPage(
34 ["issue", "list", "krz/gitbay"], of: Row.self, limit: 2, cursor: first.next
35 )
36
37 #expect(first.items == [Row(number: 1), Row(number: 2)])
38 #expect(first.next == "abc")
39 #expect(second.items == [Row(number: 3)])
40 #expect(second.next == nil)
41 let firstSeen = try #require(stub.seen.first)
42 #expect(firstSeen.url.query() ==
43 "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2")
44 let secondSeen = try #require(stub.seen.dropFirst().first)
45 #expect(secondSeen.url.query() ==
46 "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc")
47 }
48}
49
50@MainActor
51struct PagedListModelTests {
52
53 private func makeList(pageSize: Int = 2) throws -> (PagedListModel<Row>, StubProtocol.Box) {
54 let (client, stub) = try makeClient()
55 let list = PagedListModel<Row>(
56 client: client,
57 argv: ["issue", "list", "krz/gitbay"],
58 emptyMessage: "nothing",
59 pageSize: pageSize
60 )
61 return (list, stub)
62 }
63
64 @Test func loadMoreAppendsUntilTheCursorRunsOut() async throws {
65 let (list, stub) = try makeList()
66 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
67 stub.enqueue(.init(status: 200, json: page([3, 4], next: "c2")))
68 stub.enqueue(.init(status: 200, json: page([5])))
69
70 await list.reload()
71 #expect(list.state.value == [Row(number: 1), Row(number: 2)])
72 #expect(list.hasMore)
73
74 await list.loadMore()
75 await list.loadMore()
76 #expect(list.state.value?.map(\.number) == [1, 2, 3, 4, 5])
77 #expect(!list.hasMore)
78
79 // Further calls are no-ops, not requests.
80 await list.loadMore()
81 #expect(stub.seen.count == 3)
82 let second = try #require(stub.seen.dropFirst().first)
83 #expect(second.url.query()?.contains("argv=--cursor&argv=c1") == true)
84 let third = try #require(stub.seen.dropFirst(2).first)
85 #expect(third.url.query()?.contains("argv=--cursor&argv=c2") == true)
86 }
87
88 @Test func anEmptyFirstPageIsAnEmptyState() async throws {
89 let (list, stub) = try makeList()
90 stub.enqueue(.init(status: 200, json: page([])))
91
92 await list.reload()
93
94 guard case .empty(let message) = list.state else {
95 Issue.record("expected .empty, got \(list.state)")
96 return
97 }
98 #expect(message == "nothing")
99 }
100
101 @Test func aFailedLaterPageKeepsTheListAndTheCursor() async throws {
102 let (list, stub) = try makeList()
103 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
104 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
105 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
106
107 await list.reload()
108 await list.loadMore()
109
110 #expect(list.state.value == [Row(number: 1), Row(number: 2)])
111 #expect(list.hasMore) // the cursor survives for a retry
112
113 stub.enqueue(.init(status: 200, json: page([3])))
114 await list.loadMore()
115 #expect(list.state.value?.map(\.number) == [1, 2, 3])
116 }
117
118 /// A cancelled reload (e.g. superseded by a newer one from a fast
119 /// filter change) must not write a `.failed` state — whatever error
120 /// the cancellation surfaces as, it belongs to a fetch nobody wants
121 /// the result of any more.
122 @Test func aCancelledReloadDoesNotWriteAFailedState() async throws {
123 let (list, _) = try makeList()
124
125 let task = Task { await list.reload() }
126 task.cancel()
127 await task.value
128
129 if case .failed(let message) = list.state {
130 Issue.record("cancelled reload left the screen failed: \(message)")
131 }
132 }
133
134 /// A list's `.task` runs again when a pushed screen is popped. Only a
135 /// list with nothing to show yet loads there; one showing pages keeps
136 /// them.
137 @Test func needsLoadUntilAPageOrTheEmptyStateIsShown() async throws {
138 let (list, stub) = try makeList()
139 #expect(list.needsLoad)
140
141 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
142 await list.reload()
143 #expect(!list.needsLoad)
144
145 let (empty, emptyStub) = try makeList()
146 emptyStub.enqueue(.init(status: 200, json: page([])))
147 await empty.reload()
148 #expect(!empty.needsLoad)
149 }
150
151 /// A failed first page has nothing on screen to keep, so returning to
152 /// the list tries again.
153 @Test func aFailedFirstPageStillNeedsLoad() async throws {
154 let (list, stub) = try makeList()
155 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"denied","exit_code":4}"#))
156
157 await list.reload()
158
159 #expect(list.needsLoad)
160 }
161
162 @Test func reloadDropsTheOldCursorAndItems() async throws {
163 let (list, stub) = try makeList()
164 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))
165 stub.enqueue(.init(status: 200, json: page([9])))
166
167 await list.reload()
168 await list.reload()
169
170 #expect(list.state.value == [Row(number: 9)])
171 #expect(!list.hasMore)
172 }
173}