crossmate

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

commit 5e4eb4b166455f320e2873aba6123bbdf3b180dd
parent 8920e3c978b261f4c8f7363a62db11fbb892195c
Author: Michael Camilleri <[email protected]>
Date:   Fri, 15 May 2026 04:22:39 +0900

Expand usage of injectable debounce timing

debounceCoalescesRapidPublishes flaked on a contended simulator for the same
reason debounceCoalescesRapidEnqueues did in 3b48d55: the test publishes five
distinct selections at 20ms intervals against an 80ms debounce, relying on
the wall-clock cadence of inter-publish actor hops to keep each new publish
ahead of the previously-scheduled Task.sleep. When the simulator stalled,
one of the cancelled debounce tasks could wake before the next publish
landed, and the trailing flush no longer carried the final value.

PlayerSelectionPublisher now takes an injectable sleep closure (defaulting
to Task.sleep) and the debounce timer goes through it, mirroring the
existing pattern on MovesUpdater. The test passes a ManualDebounceSleep that
captures every sleep continuation and releases them on demand, so all five
publishes can be buffered before any debounce task wakes. The cancelled
tasks wake, observe Task.isCancelled, and return without flushing; the live
task wakes and flushes once with the final value.

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

Diffstat:
MCrossmate/Sync/PlayerSelectionPublisher.swift | 12++++++++++--
MTests/Support/TestHelpers.swift | 29+++++++++++++++++++++++++++++
MTests/Unit/MovesUpdaterTests.swift | 29-----------------------------
MTests/Unit/PlayerSelectionPublisherTests.swift | 26++++++++++++++++++++++----
4 files changed, 61 insertions(+), 35 deletions(-)

diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift @@ -10,6 +10,11 @@ actor PlayerSelectionPublisher { private let debounceInterval: Duration private let persistence: PersistenceController private let sink: @Sendable (UUID, String) async -> Void + /// Sleep primitive used by the debounce timer. Injected so tests can + /// drive flushes deterministically instead of racing against wall-clock + /// `Task.sleep` from the actor's own task queue. Mirrors the pattern in + /// `MovesUpdater`. + private let sleep: @Sendable (Duration) async throws -> Void private var pending: PlayerSelection? private var lastPublished: PlayerSelection? @@ -26,11 +31,13 @@ actor PlayerSelectionPublisher { init( debounceInterval: Duration = .milliseconds(300), persistence: PersistenceController, - sink: @escaping @Sendable (UUID, String) async -> Void + sink: @escaping @Sendable (UUID, String) async -> Void, + sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } ) { self.debounceInterval = debounceInterval self.persistence = persistence self.sink = sink + self.sleep = sleep } /// Starts publishing for a new puzzle session. Resets dedupe state so the @@ -79,8 +86,9 @@ actor PlayerSelectionPublisher { private func scheduleDebounce() { debounceTask?.cancel() let interval = debounceInterval + let sleep = self.sleep debounceTask = Task { [weak self] in - try? await Task.sleep(for: interval) + try? await sleep(interval) if Task.isCancelled { return } await self?.debouncedFlush() } diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -35,6 +35,35 @@ func makeTestStore( ) } +/// Test-controllable sleep: pending awaits hang until `releaseAll()`. Lets +/// debounce-based tests verify "rapid inputs coalesce" without depending on +/// 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. +final class ManualDebounceSleep: @unchecked Sendable { + private let lock = NSLock() + private var continuations: [CheckedContinuation<Void, Never>] = [] + + 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() + self.continuations.append(cont) + self.lock.unlock() + } + } + } + + func releaseAll() { + lock.lock() + let toRelease = continuations + continuations.removeAll() + lock.unlock() + toRelease.forEach { $0.resume() } + } +} + /// Creates a Game, GameEntity, and GameMutator backed by an in-memory store. /// The puzzle is a minimal 3x3 grid with a single block at (1,1). /// `movesUpdater` is nil — tests that need emission verify via MovesUpdater's own suite. diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift @@ -56,35 +56,6 @@ struct MovesUpdaterTests { ) } - /// Test-controllable sleep: pending awaits hang until `releaseAll()`. - /// Lets the debounce test verify "rapid enqueues coalesce" without - /// depending on wall-clock timing — the previous test relied on a 50ms - /// `Task.sleep` finishing later than the inter-enqueue actor hop, which - /// flaked on a contended simulator. - final class ManualDebounceSleep: @unchecked Sendable { - private let lock = NSLock() - private var continuations: [CheckedContinuation<Void, Never>] = [] - - 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() - self.continuations.append(cont) - self.lock.unlock() - } - } - } - - func releaseAll() { - lock.lock() - let toRelease = continuations - continuations.removeAll() - lock.unlock() - toRelease.forEach { $0.resume() } - } - } - private func movesEntity( gameID: UUID, persistence: PersistenceController diff --git a/Tests/Unit/PlayerSelectionPublisherTests.swift b/Tests/Unit/PlayerSelectionPublisherTests.swift @@ -81,18 +81,24 @@ struct PlayerSelectionPublisherTests { func debounceCoalescesRapidPublishes() async throws { let (persistence, gameID) = try makePersistenceWithGame() let capture = Capture() + let manualSleep = ManualDebounceSleep() let publisher = PlayerSelectionPublisher( - debounceInterval: .milliseconds(80), + debounceInterval: .seconds(10), persistence: persistence, - sink: { id, author in await capture.append(id, author) } + sink: { id, author in await capture.append(id, author) }, + sleep: manualSleep.sleepFn ) await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") for col in 0...4 { await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across)) - try await Task.sleep(for: .milliseconds(20)) } - try await Task.sleep(for: .milliseconds(250)) + // Each publish cancelled the prior debounce task; only the latest is + // live. Releasing the manual sleep wakes every captured continuation + // — the cancelled tasks observe Task.isCancelled and exit without + // flushing; the live task proceeds to flush with the final value. + manualSleep.releaseAll() + try await waitForCount(1, capture: capture) let count = await capture.count #expect(count == 1) @@ -100,6 +106,18 @@ struct PlayerSelectionPublisherTests { #expect(values?.selCol == 4) } + private func waitForCount( + _ expected: Int, + capture: Capture, + timeout: Duration = .seconds(5) + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while await capture.count != expected, + ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + } + @Test("Clear nils the selection fields and notifies the sink") func clearWritesNilSelection() async throws { let (persistence, gameID) = try makePersistenceWithGame()