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:
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()