crossmate

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

commit 24093ce722295b05a1d4635b87c2c2413d68e66c
parent 5e4eb4b166455f320e2873aba6123bbdf3b180dd
Author: Michael Camilleri <[email protected]>
Date:   Fri, 15 May 2026 10:58:49 +0900

Synchronise on state instead of wall-clock in mutator/clear tests

Five tests previously slept a fixed 50ms before asserting, as a grace
period for a spawned Task to land before the test moved on. Replace that
sleep-then-assert pattern with deterministic synchronisation, in two
different ways depending on which side owns the asynchrony.

PlayerSelectionPublisher.clear() now awaits its flush directly rather
than spawning a detached Task. Both production call sites already wrote
`await selectionPublisher.clear()` — the await was previously only
covering the actor hop while the inner flush ran as fire-and-forget.
After this change the same await covers the flush too. clearWritesNil-
Selection, clearWithoutPriorPublishIsNoOp, and publishAfterClear lose
their 50ms grace periods; the await is now load-bearing.

The two GameMutator emission tests take the other route. setLetter and
revealCells spawn a Task internally to call MovesUpdater.enqueue, and
the synchronous-from-views call-site contract means we don't want to
propagate async through PlayerSession and every keyboard handler.
Instead the tests poll updater.flush() on a 5ms interval until the
collector observes the emitted gameID — same assertion, but the test
no longer waits 50ms whether or not the enqueue has landed.

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

Diffstat:
MCrossmate/Sync/PlayerSelectionPublisher.swift | 9++++++---
MTests/Unit/GameMutatorTests.swift | 34++++++++++++++++++++++++++++++----
MTests/Unit/PlayerSelectionPublisherTests.swift | 4----
3 files changed, 36 insertions(+), 11 deletions(-)

diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift @@ -67,13 +67,16 @@ actor PlayerSelectionPublisher { } /// Records a "no cursor track" — used on puzzle teardown so the peer's - /// overlay disappears promptly instead of waiting for staleness. - func clear() { + /// overlay disappears promptly instead of waiting for staleness. Awaits + /// the flush so callers can sequence work that depends on the cleared + /// state being visible to the sink (and so tests don't have to race a + /// detached Task). + func clear() async { guard gameID != nil, authorID != nil else { return } pending = nil debounceTask?.cancel() debounceTask = nil - Task { await self.flushClear() } + await flushClear() } /// Flushes any pending selection immediately and cancels the debounce. diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift @@ -211,8 +211,11 @@ struct GameMutatorTests { ) mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) - try await Task.sleep(for: .milliseconds(50)) - await updater.flush() + try await waitForEmittedGameID( + capture.gameID, + updater: updater, + collector: capture.collector + ) let affected = await capture.collector.allGameIDs #expect(affected.contains(capture.gameID)) @@ -242,8 +245,11 @@ struct GameMutatorTests { ) mutator.revealCells([game.puzzle.cells[0][0]]) - try await Task.sleep(for: .milliseconds(50)) - await updater.flush() + try await waitForEmittedGameID( + capture.gameID, + updater: updater, + collector: capture.collector + ) let affected = await capture.collector.allGameIDs #expect(affected.contains(capture.gameID)) @@ -266,6 +272,26 @@ struct GameMutatorTests { func append(_ ids: Set<UUID>) { allGameIDs.formUnion(ids) } } + /// Polls `updater.flush()` until the collector sees `gameID`. `setLetter` + /// and friends spawn an internal Task to call `updater.enqueue`; if the + /// test flushed before that Task landed it would see an empty buffer. + /// Each iteration re-flushes so the first flush after the enqueue lands + /// reaches the sink; total wall-clock is typically <10ms versus the + /// previous fixed 50ms grace period. + private func waitForEmittedGameID( + _ gameID: UUID, + updater: MovesUpdater, + collector: GameIDCollector, + timeout: Duration = .seconds(2) + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + await updater.flush() + if await collector.allGameIDs.contains(gameID) { return } + try await Task.sleep(for: .milliseconds(5)) + } + } + struct UpdaterHarness { let collector: GameIDCollector let gameID: UUID diff --git a/Tests/Unit/PlayerSelectionPublisherTests.swift b/Tests/Unit/PlayerSelectionPublisherTests.swift @@ -132,8 +132,6 @@ struct PlayerSelectionPublisherTests { await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down)) await publisher.flush() await publisher.clear() - // clear() spawns a Task internally — give it a moment to land. - try await Task.sleep(for: .milliseconds(50)) let count = await capture.count #expect(count == 2) @@ -156,7 +154,6 @@ struct PlayerSelectionPublisherTests { await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") await publisher.clear() - try await Task.sleep(for: .milliseconds(50)) let count = await capture.count #expect(count == 0) @@ -225,7 +222,6 @@ struct PlayerSelectionPublisherTests { await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across)) await publisher.flush() await publisher.clear() - try await Task.sleep(for: .milliseconds(50)) await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down)) await publisher.flush()