Commit cc8a97bf86
Unsigned
Layout: unified · split
gitbayTests/BlameEditTests.swift +2 −1
| @@ -64,7 +64,8 @@ struct BlameViewModelTests { | ||
| 64 | 64 | await model.loadMore() |
| 65 | 65 | #expect(model.hunks.count == 2) |
| 66 | 66 | #expect(!model.hasMore) |
| 67 | #expect(stub.seen[1].url.query()?.contains("argv=--from&argv=1001") == true) | |
| 67 | let second = try #require(stub.seen.dropFirst().first) | |
| 68 | #expect(second.url.query()?.contains("argv=--from&argv=1001") == true) | |
| 68 | 69 | |
| 69 | 70 | // Nothing further to ask for. |
| 70 | 71 | await model.loadMore() |
gitbayTests/ExploreRefLogTests.swift +2 −1
| @@ -84,7 +84,8 @@ struct ExploreTests { | ||
| 84 | 84 | await list.loadMore() |
| 85 | 85 | #expect(list.state.value?.count == 3) |
| 86 | 86 | #expect(!list.hasMore) |
| 87 | #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=ZXhwbG9yZTpr") == true) | |
| 87 | let second = try #require(stub.seen.dropFirst().first) | |
| 88 | #expect(second.url.query()?.contains("argv=--cursor&argv=ZXhwbG9yZTpr") == true) | |
| 88 | 89 | } |
| 89 | 90 | |
| 90 | 91 | @Test func ownerAndNameSplitForDisplay() { |
gitbayTests/GitbayClientTests.swift +6 −3
| @@ -101,8 +101,10 @@ struct GitbayClientReadTests { | ||
| 101 | 101 | #expect(first[0].path == "krz/gitbay") |
| 102 | 102 | let requests = stub.seen |
| 103 | 103 | #expect(requests.count == 2) |
| 104 | #expect(requests[0].headers["If-None-Match"] == nil) | |
| 105 | #expect(requests[1].headers["If-None-Match"] == etag) | |
| 104 | let firstRequest = try #require(requests.first) | |
| 105 | #expect(firstRequest.headers["If-None-Match"] == nil) | |
| 106 | let secondRequest = try #require(requests.dropFirst().first) | |
| 107 | #expect(secondRequest.headers["If-None-Match"] == etag) | |
| 106 | 108 | } |
| 107 | 109 | |
| 108 | 110 | @Test func changedBodyReplacesTheCachedValidator() async throws { |
| @@ -117,7 +119,8 @@ struct GitbayClientReadTests { | ||
| 117 | 119 | |
| 118 | 120 | #expect(refreshed.isEmpty) |
| 119 | 121 | #expect(revalidated.isEmpty) |
| 120 | #expect(stub.seen[2].headers["If-None-Match"] == "\"bb\"") | |
| 122 | let third = try #require(stub.seen.dropFirst(2).first) | |
| 123 | #expect(third.headers["If-None-Match"] == "\"bb\"") | |
| 121 | 124 | } |
| 122 | 125 | |
| 123 | 126 | @Test func absentDataDecodesAsEmptyList() async throws { |
gitbayTests/ListFilterTests.swift +39 −8
| @@ -184,7 +184,7 @@ struct IssueListFilterWiringTests { | ||
| 184 | 184 | |
| 185 | 185 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) |
| 186 | 186 | model.filter.label = "bug" |
| 187 | try await Task.sleep(for: .milliseconds(150)) | |
| 187 | await until { stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count == 2 } | |
| 188 | 188 | |
| 189 | 189 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } |
| 190 | 190 | let last = try #require(reads.last) |
| @@ -275,11 +275,18 @@ struct IssueListFilterWiringTests { | ||
| 275 | 275 | |
| 276 | 276 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) |
| 277 | 277 | model.filter.label = "bug" |
| 278 | try await Task.sleep(for: .milliseconds(150)) | |
| 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 | } | |
| 279 | 285 | |
| 280 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | |
| 281 | let last = try #require(reads.last) | |
| 282 | #expect(!argvFrom(last.url).contains("--cursor")) | |
| 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")) | |
| 283 | 290 | } |
| 284 | 291 | |
| 285 | 292 | /// Two rapid picker changes cancel the first reload's task. The |
| @@ -296,7 +303,31 @@ struct IssueListFilterWiringTests { | ||
| 296 | 303 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) |
| 297 | 304 | model.filter.label = "bug" |
| 298 | 305 | model.filter.label = "feature" |
| 299 | try await Task.sleep(for: .milliseconds(200)) | |
| 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 | } | |
| 300 | 331 | |
| 301 | 332 | if case .failed(let message) = model.state { |
| 302 | 333 | Testing.Issue.record("rapid filter changes left the screen failed: \(message)") |
| @@ -329,7 +360,7 @@ struct MRListFilterWiringTests { | ||
| 329 | 360 | |
| 330 | 361 | stub.enqueue(.init(status: 200, json: emptyPage)) |
| 331 | 362 | model.filter.search = "retarget" |
| 332 | try await Task.sleep(for: .milliseconds(500)) | |
| 363 | await until { stub.seen.count == 2 } | |
| 333 | 364 | |
| 334 | 365 | let last = try #require(stub.seen.last) |
| 335 | 366 | let argv = argvFrom(last.url) |
| @@ -366,7 +397,7 @@ struct MRListFilterWiringTests { | ||
| 366 | 397 | |
| 367 | 398 | stub.enqueue(.init(status: 200, json: emptyPage)) |
| 368 | 399 | model.filter.state = .sourceGone |
| 369 | try await Task.sleep(for: .milliseconds(150)) | |
| 400 | await until { stub.seen.count == 2 } | |
| 370 | 401 | |
| 371 | 402 | #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone")) |
| 372 | 403 | } |
gitbayTests/MRViewModelTests.swift +17 −7
| @@ -172,11 +172,21 @@ struct MRListViewModelTests { | ||
| 172 | 172 | await model.load() |
| 173 | 173 | |
| 174 | 174 | model.filter.state = .merged |
| 175 | // The reload happens in a spawned task; give it a beat. | |
| 176 | try await Task.sleep(for: .milliseconds(300)) | |
| 175 | // The reload happens in a spawned task; wait for it to settle | |
| 176 | // rather than for a fixed interval to pass. Checking `model.state` | |
| 177 | // alone is not enough: right after the assignment, before the | |
| 178 | // spawned task has even run, the state is still the *previous* | |
| 179 | // load's non-loading result, which would satisfy a bare | |
| 180 | // "not loading" check before the reload ever started. | |
| 181 | await until { | |
| 182 | guard stub.seen.count == 2 else { return false } | |
| 183 | if case .loading = model.state { return false } | |
| 184 | return true | |
| 185 | } | |
| 177 | 186 | |
| 178 | 187 | #expect(stub.seen.count == 2) |
| 179 | #expect(stub.seen[1].url.query()?.contains("argv=merged") == true) | |
| 188 | let second = try #require(stub.seen.dropFirst().first) | |
| 189 | #expect(second.url.query()?.contains("argv=merged") == true) | |
| 180 | 190 | guard case .empty = model.state else { |
| 181 | 191 | Issue.record("expected .empty after filtering, got \(model.state)") |
| 182 | 192 | return |
| @@ -255,7 +265,7 @@ struct MRDetailViewModelTests { | ||
| 255 | 265 | |
| 256 | 266 | await model.review(.approve) |
| 257 | 267 | |
| 258 | let write = stub.seen[3] | |
| 268 | let write = try #require(stub.seen.dropFirst(3).first) | |
| 259 | 269 | #expect(write.method == "POST") |
| 260 | 270 | #expect(write.url.path() == "/api/v1/cmd") |
| 261 | 271 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| @@ -273,7 +283,7 @@ struct MRDetailViewModelTests { | ||
| 273 | 283 | |
| 274 | 284 | await model.comment("long review text\nwith lines") |
| 275 | 285 | |
| 276 | let write = stub.seen[3] | |
| 286 | let write = try #require(stub.seen.dropFirst(3).first) | |
| 277 | 287 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 278 | 288 | #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"]) |
| 279 | 289 | #expect(body["stdin"] as? String == "long review text\nwith lines") |
| @@ -302,7 +312,7 @@ struct MRDetailViewModelTests { | ||
| 302 | 312 | |
| 303 | 313 | await model.setResolved(thread, true) |
| 304 | 314 | |
| 305 | let write = stub.seen[3] | |
| 315 | let write = try #require(stub.seen.dropFirst(3).first) | |
| 306 | 316 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 307 | 317 | #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"]) |
| 308 | 318 | } |
| @@ -317,7 +327,7 @@ struct MRDetailViewModelTests { | ||
| 317 | 327 | |
| 318 | 328 | await model.reply(to: thread, "because 5xx is transient") |
| 319 | 329 | |
| 320 | let write = stub.seen[3] | |
| 330 | let write = try #require(stub.seen.dropFirst(3).first) | |
| 321 | 331 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 322 | 332 | #expect(body["argv"] as? [String] == |
| 323 | 333 | ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"]) |
gitbayTests/NotificationTests.swift +9 −5
| @@ -127,7 +127,7 @@ struct NotificationsViewModelTests { | ||
| 127 | 127 | |
| 128 | 128 | stub.enqueue(.init(status: 200, json: page)) |
| 129 | 129 | model.showAll = true |
| 130 | try await Task.sleep(for: .milliseconds(150)) | |
| 130 | await until { stub.seen.count == 2 } | |
| 131 | 131 | |
| 132 | 132 | #expect(argvFrom(try #require(stub.seen.last).url).contains("--all")) |
| 133 | 133 | } |
| @@ -143,10 +143,14 @@ struct NotificationsViewModelTests { | ||
| 143 | 143 | |
| 144 | 144 | stub.enqueue(.init(status: 200, json: page)) |
| 145 | 145 | model.showAll = true |
| 146 | try await Task.sleep(for: .milliseconds(150)) | |
| 147 | ||
| 148 | let argv = argvFrom(try #require(stub.seen.last).url) | |
| 149 | #expect(argv.contains("--cursor") == false) | |
| 146 | // Poll for the read that actually carries the new filter, not | |
| 147 | // whatever happens to be last: a reload that never landed would | |
| 148 | // otherwise leave `last` pointing at the initial request, which | |
| 149 | // also carries no cursor and would pass just as wrongly. | |
| 150 | await until { stub.seen.contains { argvFrom($0.url).contains("--all") } } | |
| 151 | ||
| 152 | let reload = try #require(stub.seen.first { argvFrom($0.url).contains("--all") }) | |
| 153 | #expect(!argvFrom(reload.url).contains("--cursor")) | |
| 150 | 154 | } |
| 151 | 155 | |
| 152 | 156 | @Test func markingOneReadSendsItsId() async throws { |
gitbayTests/PagedListTests.swift +8 −4
| @@ -38,9 +38,11 @@ struct ClientPagingTests { | ||
| 38 | 38 | #expect(first.next == "abc") |
| 39 | 39 | #expect(second.items == [Row(number: 3)]) |
| 40 | 40 | #expect(second.next == nil) |
| 41 | #expect(stub.seen[0].url.query() == | |
| 41 | let firstSeen = try #require(stub.seen.first) | |
| 42 | #expect(firstSeen.url.query() == | |
| 42 | 43 | "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2") |
| 43 | #expect(stub.seen[1].url.query() == | |
| 44 | let secondSeen = try #require(stub.seen.dropFirst().first) | |
| 45 | #expect(secondSeen.url.query() == | |
| 44 | 46 | "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc") |
| 45 | 47 | } |
| 46 | 48 | } |
| @@ -77,8 +79,10 @@ struct PagedListModelTests { | ||
| 77 | 79 | // Further calls are no-ops, not requests. |
| 78 | 80 | await list.loadMore() |
| 79 | 81 | #expect(stub.seen.count == 3) |
| 80 | #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=c1") == true) | |
| 81 | #expect(stub.seen[2].url.query()?.contains("argv=--cursor&argv=c2") == true) | |
| 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) | |
| 82 | 86 | } |
| 83 | 87 | |
| 84 | 88 | @Test func anEmptyFirstPageIsAnEmptyState() async throws { |
gitbayTests/ProfileCommitTests.swift +2 −1
| @@ -59,7 +59,8 @@ struct ProfileAggregateTests { | ||
| 59 | 59 | #expect(profile.activityTotal == 15) |
| 60 | 60 | // One read builds the whole page. |
| 61 | 61 | #expect(stub.seen.count == 1) |
| 62 | #expect(stub.seen[0].url.query() == "argv=profile&argv=show&argv=cmc") | |
| 62 | let first = try #require(stub.seen.first) | |
| 63 | #expect(first.url.query() == "argv=profile&argv=show&argv=cmc") | |
| 63 | 64 | } |
| 64 | 65 | |
| 65 | 66 | @Test func anOrgProfileCarriesMembersInsteadOfOrgs() async throws { |
gitbayTests/RefsMilestoneTests.swift +13 −2
| @@ -107,10 +107,21 @@ struct MilestoneListViewModelTests { | ||
| 107 | 107 | await model.load() |
| 108 | 108 | |
| 109 | 109 | model.filter = .closed |
| 110 | try await Task.sleep(for: .milliseconds(300)) | |
| 110 | // The reload happens in a spawned task; wait for it to settle | |
| 111 | // rather than for a fixed interval to pass. Checking `model.state` | |
| 112 | // alone is not enough: right after the assignment, before the | |
| 113 | // spawned task has even run, the state is still the *previous* | |
| 114 | // load's non-loading result, which would satisfy a bare | |
| 115 | // "not loading" check before the reload ever started. | |
| 116 | await until { | |
| 117 | guard stub.seen.count == 2 else { return false } | |
| 118 | if case .loading = model.state { return false } | |
| 119 | return true | |
| 120 | } | |
| 111 | 121 | |
| 112 | 122 | #expect(stub.seen.count == 2) |
| 113 | #expect(stub.seen[1].url.query()?.contains("argv=closed") == true) | |
| 123 | let second = try #require(stub.seen.dropFirst().first) | |
| 124 | #expect(second.url.query()?.contains("argv=closed") == true) | |
| 114 | 125 | } |
| 115 | 126 | } |
| 116 | 127 | |
gitbayTests/SearchTests.swift +1 −1
| @@ -137,7 +137,7 @@ struct SearchViewModelTests { | ||
| 137 | 137 | |
| 138 | 138 | stub.enqueue(.init(status: 200, json: mixedResults)) |
| 139 | 139 | model.kind = .issue |
| 140 | try await Task.sleep(for: .milliseconds(150)) | |
| 140 | await until { stub.seen.count == 2 } | |
| 141 | 141 | |
| 142 | 142 | #expect(argvFrom(try #require(stub.seen.last).url) |
| 143 | 143 | == ["search", "parity", "--kind", "issue"]) |
gitbayTests/SessionStoreTests.swift +3 −2
| @@ -162,8 +162,9 @@ struct SessionStoreTests { | ||
| 162 | 162 | await #expect(throws: GitbayError.self) { |
| 163 | 163 | _ = try await client.readList(["repo", "list"], of: RepoSummary.self) |
| 164 | 164 | } |
| 165 | // The sign-out hops through the main actor; give it a beat. | |
| 166 | try await Task.sleep(for: .milliseconds(200)) | |
| 165 | // The sign-out hops through the main actor; wait for it rather | |
| 166 | // than for a fixed interval to pass. | |
| 167 | await until { session.current == nil } | |
| 167 | 168 | |
| 168 | 169 | #expect(session.current == nil) |
| 169 | 170 | #expect(session.client == nil) |
gitbayTests/StubProtocol.swift +16
| @@ -161,3 +161,19 @@ nonisolated final class Mutex<Value>: @unchecked Sendable { | ||
| 161 | 161 | return body(&value) |
| 162 | 162 | } |
| 163 | 163 | } |
| 164 | ||
| 165 | /// Wait for a condition rather than sleeping a fixed interval. The view | |
| 166 | /// models reload through unstructured Tasks, so a fixed sleep is a race: | |
| 167 | /// under load the reload has not landed when it expires. Polling is both | |
| 168 | /// faster in the common case and deterministic. | |
| 169 | @MainActor | |
| 170 | func until( | |
| 171 | _ timeout: Duration = .seconds(2), | |
| 172 | _ condition: () -> Bool | |
| 173 | ) async { | |
| 174 | let deadline = ContinuousClock.now + timeout | |
| 175 | while !condition() { | |
| 176 | guard ContinuousClock.now < deadline else { return } | |
| 177 | try? await Task.sleep(for: .milliseconds(10)) | |
| 178 | } | |
| 179 | } | |