crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit a1c161ec0688e1daeb6ca764267f2c2f7098c33c
parent fd641654d80bffe4a48f2cafe9cc554340113732
Author: Michael Camilleri <[email protected]>
Date:   Sun, 17 May 2026 06:05:42 +0900

Fix test helper for debounce-coalesce tests

ManualDebounceSleep.releaseAll() was a one-shot gate: it resumed only the
continuations registered at the instant it was called, then cleared the array.
Any sleep() that parked afterward waited on a continuation nothing would ever
resume again. The debounce-coalesce tests spawn the live (non-cancelled)
debounce task in the final loop iteration with no suspension point before
releaseAll(), and Task creation only enqueues — the task registers its
continuation when it later starts running, not synchronously.

releaseAll() is now sticky: it sets a released flag so a sleep() reaching
the gate afterward resumes immediately instead of parking. The release no
longer depends on every debounce task having been scheduled first, so
both suites are deterministic regardless of task-scheduling order.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MTests/Support/TestHelpers.swift | 14++++++++++++++
1 file changed, 14 insertions(+), 0 deletions(-)

diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -40,15 +40,28 @@ func makeTestStore( /// wall-clock timing — Task.sleep on a contended simulator can wake before /// the next actor hop lands, racing the cancellation that should have /// suppressed the prior debounce task. +/// +/// `releaseAll()` is sticky: it opens the gate permanently, so a debounce +/// task that only reaches its `sleep()` *after* the test called +/// `releaseAll()` resumes immediately instead of parking forever. Without +/// this, the live (non-cancelled) debounce task — spawned with no +/// suspension point before `releaseAll()` — could miss the one-shot +/// release and strand the flush, failing the test with `count → 0`. final class ManualDebounceSleep: @unchecked Sendable { private let lock = NSLock() private var continuations: [CheckedContinuation<Void, Never>] = [] + private var released = false var sleepFn: @Sendable (Duration) async throws -> Void { { @Sendable [weak self] _ in await withCheckedContinuation { cont in guard let self else { cont.resume(); return } self.lock.lock() + if self.released { + self.lock.unlock() + cont.resume() + return + } self.continuations.append(cont) self.lock.unlock() } @@ -57,6 +70,7 @@ final class ManualDebounceSleep: @unchecked Sendable { func releaseAll() { lock.lock() + released = true let toRelease = continuations continuations.removeAll() lock.unlock()