Commit cc8a97bf86
Unsigned
Layout: unified · split
gitbayTests/BlameEditTests.swift +2 −1
| @@ -64,7 +64,8 @@ struct BlameViewModelTests { | |||
| 64 | await model.loadMore() | 64 | await model.loadMore() |
| 65 | #expect(model.hunks.count == 2) | 65 | #expect(model.hunks.count == 2) |
| 66 | #expect(!model.hasMore) | 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 | // Nothing further to ask for. | 70 | // Nothing further to ask for. |
| 70 | await model.loadMore() | 71 | await model.loadMore() |
gitbayTests/ExploreRefLogTests.swift +2 −1
| @@ -84,7 +84,8 @@ struct ExploreTests { | |||
| 84 | await list.loadMore() | 84 | await list.loadMore() |
| 85 | #expect(list.state.value?.count == 3) | 85 | #expect(list.state.value?.count == 3) |
| 86 | #expect(!list.hasMore) | 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 | @Test func ownerAndNameSplitForDisplay() { | 91 | @Test func ownerAndNameSplitForDisplay() { |
gitbayTests/GitbayClientTests.swift +6 −3
| @@ -101,8 +101,10 @@ struct GitbayClientReadTests { | |||
| 101 | #expect(first[0].path == "krz/gitbay") | 101 | #expect(first[0].path == "krz/gitbay") |
| 102 | let requests = stub.seen | 102 | let requests = stub.seen |
| 103 | #expect(requests.count == 2) | 103 | #expect(requests.count == 2) |
| 104 | #expect(requests[0].headers["If-None-Match"] == nil) | 104 | let firstRequest = try #require(requests.first) |
| 105 | #expect(requests[1].headers["If-None-Match"] == etag) | 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 | @Test func changedBodyReplacesTheCachedValidator() async throws { | 110 | @Test func changedBodyReplacesTheCachedValidator() async throws { |
| @@ -117,7 +119,8 @@ struct GitbayClientReadTests { | |||
| 117 | 119 | ||
| 118 | #expect(refreshed.isEmpty) | 120 | #expect(refreshed.isEmpty) |
| 119 | #expect(revalidated.isEmpty) | 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 | @Test func absentDataDecodesAsEmptyList() async throws { | 126 | @Test func absentDataDecodesAsEmptyList() async throws { |
gitbayTests/ListFilterTests.swift +39 −8
| @@ -184,7 +184,7 @@ struct IssueListFilterWiringTests { | |||
| 184 | 184 | ||
| 185 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | 185 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) |
| 186 | model.filter.label = "bug" | 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 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | 189 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } |
| 190 | let last = try #require(reads.last) | 190 | let last = try #require(reads.last) |
| @@ -275,11 +275,18 @@ struct IssueListFilterWiringTests { | |||
| 275 | 275 | ||
| 276 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | 276 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) |
| 277 | model.filter.label = "bug" | 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") } | 286 | let reload = try #require(stub.seen.first { |
| 281 | let last = try #require(reads.last) | 287 | $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug") |
| 282 | #expect(!argvFrom(last.url).contains("--cursor")) | 288 | }) |
| 289 | #expect(!argvFrom(reload.url).contains("--cursor")) | ||
| 283 | } | 290 | } |
| 284 | 291 | ||
| 285 | /// Two rapid picker changes cancel the first reload's task. The | 292 | /// Two rapid picker changes cancel the first reload's task. The |
| @@ -296,7 +303,31 @@ struct IssueListFilterWiringTests { | |||
| 296 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | 303 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) |
| 297 | model.filter.label = "bug" | 304 | model.filter.label = "bug" |
| 298 | model.filter.label = "feature" | 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 | if case .failed(let message) = model.state { | 332 | if case .failed(let message) = model.state { |
| 302 | Testing.Issue.record("rapid filter changes left the screen failed: \(message)") | 333 | Testing.Issue.record("rapid filter changes left the screen failed: \(message)") |
| @@ -329,7 +360,7 @@ struct MRListFilterWiringTests { | |||
| 329 | 360 | ||
| 330 | stub.enqueue(.init(status: 200, json: emptyPage)) | 361 | stub.enqueue(.init(status: 200, json: emptyPage)) |
| 331 | model.filter.search = "retarget" | 362 | model.filter.search = "retarget" |
| 332 | try await Task.sleep(for: .milliseconds(500)) | 363 | await until { stub.seen.count == 2 } |
| 333 | 364 | ||
| 334 | let last = try #require(stub.seen.last) | 365 | let last = try #require(stub.seen.last) |
| 335 | let argv = argvFrom(last.url) | 366 | let argv = argvFrom(last.url) |
| @@ -366,7 +397,7 @@ struct MRListFilterWiringTests { | |||
| 366 | 397 | ||
| 367 | stub.enqueue(.init(status: 200, json: emptyPage)) | 398 | stub.enqueue(.init(status: 200, json: emptyPage)) |
| 368 | model.filter.state = .sourceGone | 399 | model.filter.state = .sourceGone |
| 369 | try await Task.sleep(for: .milliseconds(150)) | 400 | await until { stub.seen.count == 2 } |
| 370 | 401 | ||
| 371 | #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone")) | 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 | await model.load() | 172 | await model.load() |
| 173 | 173 | ||
| 174 | model.filter.state = .merged | 174 | model.filter.state = .merged |
| 175 | // The reload happens in a spawned task; give it a beat. | 175 | // The reload happens in a spawned task; wait for it to settle |
| 176 | try await Task.sleep(for: .milliseconds(300)) | 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 | #expect(stub.seen.count == 2) | 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 | guard case .empty = model.state else { | 190 | guard case .empty = model.state else { |
| 181 | Issue.record("expected .empty after filtering, got \(model.state)") | 191 | Issue.record("expected .empty after filtering, got \(model.state)") |
| 182 | return | 192 | return |
| @@ -255,7 +265,7 @@ struct MRDetailViewModelTests { | |||
| 255 | 265 | ||
| 256 | await model.review(.approve) | 266 | await model.review(.approve) |
| 257 | 267 | ||
| 258 | let write = stub.seen[3] | 268 | let write = try #require(stub.seen.dropFirst(3).first) |
| 259 | #expect(write.method == "POST") | 269 | #expect(write.method == "POST") |
| 260 | #expect(write.url.path() == "/api/v1/cmd") | 270 | #expect(write.url.path() == "/api/v1/cmd") |
| 261 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | 271 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| @@ -273,7 +283,7 @@ struct MRDetailViewModelTests { | |||
| 273 | 283 | ||
| 274 | await model.comment("long review text\nwith lines") | 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 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | 287 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 278 | #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"]) | 288 | #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"]) |
| 279 | #expect(body["stdin"] as? String == "long review text\nwith lines") | 289 | #expect(body["stdin"] as? String == "long review text\nwith lines") |
| @@ -302,7 +312,7 @@ struct MRDetailViewModelTests { | |||
| 302 | 312 | ||
| 303 | await model.setResolved(thread, true) | 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 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | 316 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 307 | #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"]) | 317 | #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"]) |
| 308 | } | 318 | } |
| @@ -317,7 +327,7 @@ struct MRDetailViewModelTests { | |||
| 317 | 327 | ||
| 318 | await model.reply(to: thread, "because 5xx is transient") | 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 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | 331 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 322 | #expect(body["argv"] as? [String] == | 332 | #expect(body["argv"] as? [String] == |
| 323 | ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"]) | 333 | ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"]) |
gitbayTests/NotificationTests.swift +9 −5
| @@ -127,7 +127,7 @@ struct NotificationsViewModelTests { | |||
| 127 | 127 | ||
| 128 | stub.enqueue(.init(status: 200, json: page)) | 128 | stub.enqueue(.init(status: 200, json: page)) |
| 129 | model.showAll = true | 129 | model.showAll = true |
| 130 | try await Task.sleep(for: .milliseconds(150)) | 130 | await until { stub.seen.count == 2 } |
| 131 | 131 | ||
| 132 | #expect(argvFrom(try #require(stub.seen.last).url).contains("--all")) | 132 | #expect(argvFrom(try #require(stub.seen.last).url).contains("--all")) |
| 133 | } | 133 | } |
| @@ -143,10 +143,14 @@ struct NotificationsViewModelTests { | |||
| 143 | 143 | ||
| 144 | stub.enqueue(.init(status: 200, json: page)) | 144 | stub.enqueue(.init(status: 200, json: page)) |
| 145 | model.showAll = true | 145 | model.showAll = true |
| 146 | try await Task.sleep(for: .milliseconds(150)) | 146 | // Poll for the read that actually carries the new filter, not |
| 147 | 147 | // whatever happens to be last: a reload that never landed would | |
| 148 | let argv = argvFrom(try #require(stub.seen.last).url) | 148 | // otherwise leave `last` pointing at the initial request, which |
| 149 | #expect(argv.contains("--cursor") == false) | 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 | @Test func markingOneReadSendsItsId() async throws { | 156 | @Test func markingOneReadSendsItsId() async throws { |
gitbayTests/PagedListTests.swift +8 −4
| @@ -38,9 +38,11 @@ struct ClientPagingTests { | |||
| 38 | #expect(first.next == "abc") | 38 | #expect(first.next == "abc") |
| 39 | #expect(second.items == [Row(number: 3)]) | 39 | #expect(second.items == [Row(number: 3)]) |
| 40 | #expect(second.next == nil) | 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 | "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2") | 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 | "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc") | 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 | // Further calls are no-ops, not requests. | 79 | // Further calls are no-ops, not requests. |
| 78 | await list.loadMore() | 80 | await list.loadMore() |
| 79 | #expect(stub.seen.count == 3) | 81 | #expect(stub.seen.count == 3) |
| 80 | #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=c1") == true) | 82 | let second = try #require(stub.seen.dropFirst().first) |
| 81 | #expect(stub.seen[2].url.query()?.contains("argv=--cursor&argv=c2") == true) | 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 | @Test func anEmptyFirstPageIsAnEmptyState() async throws { | 88 | @Test func anEmptyFirstPageIsAnEmptyState() async throws { |
gitbayTests/ProfileCommitTests.swift +2 −1
| @@ -59,7 +59,8 @@ struct ProfileAggregateTests { | |||
| 59 | #expect(profile.activityTotal == 15) | 59 | #expect(profile.activityTotal == 15) |
| 60 | // One read builds the whole page. | 60 | // One read builds the whole page. |
| 61 | #expect(stub.seen.count == 1) | 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 | @Test func anOrgProfileCarriesMembersInsteadOfOrgs() async throws { | 66 | @Test func anOrgProfileCarriesMembersInsteadOfOrgs() async throws { |
gitbayTests/RefsMilestoneTests.swift +13 −2
| @@ -107,10 +107,21 @@ struct MilestoneListViewModelTests { | |||
| 107 | await model.load() | 107 | await model.load() |
| 108 | 108 | ||
| 109 | model.filter = .closed | 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 | #expect(stub.seen.count == 2) | 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 | stub.enqueue(.init(status: 200, json: mixedResults)) | 138 | stub.enqueue(.init(status: 200, json: mixedResults)) |
| 139 | model.kind = .issue | 139 | model.kind = .issue |
| 140 | try await Task.sleep(for: .milliseconds(150)) | 140 | await until { stub.seen.count == 2 } |
| 141 | 141 | ||
| 142 | #expect(argvFrom(try #require(stub.seen.last).url) | 142 | #expect(argvFrom(try #require(stub.seen.last).url) |
| 143 | == ["search", "parity", "--kind", "issue"]) | 143 | == ["search", "parity", "--kind", "issue"]) |
gitbayTests/SessionStoreTests.swift +3 −2
| @@ -162,8 +162,9 @@ struct SessionStoreTests { | |||
| 162 | await #expect(throws: GitbayError.self) { | 162 | await #expect(throws: GitbayError.self) { |
| 163 | _ = try await client.readList(["repo", "list"], of: RepoSummary.self) | 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. | 165 | // The sign-out hops through the main actor; wait for it rather |
| 166 | try await Task.sleep(for: .milliseconds(200)) | 166 | // than for a fixed interval to pass. |
| 167 | await until { session.current == nil } | ||
| 167 | 168 | ||
| 168 | #expect(session.current == nil) | 169 | #expect(session.current == nil) |
| 169 | #expect(session.client == nil) | 170 | #expect(session.client == nil) |
gitbayTests/StubProtocol.swift +16
| @@ -161,3 +161,19 @@ nonisolated final class Mutex<Value>: @unchecked Sendable { | |||
| 161 | return body(&value) | 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 | } | ||