crossmate

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

commit b23aa20c561bf9b83097c8af7dd09e50cb0c7299
parent b5b7b6e04ce569cdfd3bffb0b1b4038fa16c7be5
Author: Michael Camilleri <[email protected]>
Date:   Thu, 21 May 2026 13:35:03 +0900

Support mockable sleep function in AnnouncementCenter

Diffstat:
MCrossmate/Services/AnnouncementCenter.swift | 13+++++++++++--
MTests/Support/TestHelpers.swift | 13+++++++++++++
MTests/Unit/AnnouncementCenterTests.swift | 34++++++++++++++++++++++++++--------
3 files changed, 50 insertions(+), 10 deletions(-)

diff --git a/Crossmate/Services/AnnouncementCenter.swift b/Crossmate/Services/AnnouncementCenter.swift @@ -107,8 +107,16 @@ final class AnnouncementCenter { /// stale dismissal against a fresh announcement that happens to share /// the id. private var dismissalTasks: [String: Task<Void, Never>] = [:] + /// Sleep primitive used by transient auto-dismiss timers. Injected so + /// tests can drive expiry deterministically instead of racing wall-clock + /// `Task.sleep` on a contended simulator. + private let sleep: @Sendable (Duration) async throws -> Void - init() {} + init( + sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) { + self.sleep = sleep + } func post(_ announcement: Announcement) { if let existing = dismissalTasks.removeValue(forKey: announcement.id) { @@ -117,8 +125,9 @@ final class AnnouncementCenter { byId[announcement.id] = announcement if case let .transient(after) = announcement.dismissal { let id = announcement.id + let sleep = self.sleep dismissalTasks[id] = Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(after)) + try? await sleep(.seconds(after)) guard !Task.isCancelled else { return } self?.autoDismiss(id: id) } diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -51,12 +51,14 @@ final class ManualDebounceSleep: @unchecked Sendable { private let lock = NSLock() private var continuations: [CheckedContinuation<Void, Never>] = [] private var released = false + private var sleeperCount = 0 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.sleeperCount += 1 if self.released { self.lock.unlock() cont.resume() @@ -76,6 +78,17 @@ final class ManualDebounceSleep: @unchecked Sendable { lock.unlock() toRelease.forEach { $0.resume() } } + + func waitForSleeperCount( + _ expected: Int, + timeout: Duration = .seconds(5) + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while lock.withLock({ sleeperCount }) < expected, + ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + } } /// Creates a Game, GameEntity, and GameMutator backed by an in-memory store. diff --git a/Tests/Unit/AnnouncementCenterTests.swift b/Tests/Unit/AnnouncementCenterTests.swift @@ -27,6 +27,19 @@ struct AnnouncementCenterTests { ) } + private func waitForCurrent( + _ expected: Announcement?, + center: AnnouncementCenter, + gameID: UUID, + timeout: Duration = .seconds(5) + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while center.current(forGame: gameID) != expected, + ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + } + @Test("Posting an announcement makes it the current for its scope") func postShowsAsCurrent() { let center = AnnouncementCenter() @@ -76,28 +89,32 @@ struct AnnouncementCenterTests { } @Test(".transient announcements auto-dismiss after their delay") - func transientAutoDismisses() async { - let center = AnnouncementCenter() + func transientAutoDismisses() async throws { + let manualSleep = ManualDebounceSleep() + let center = AnnouncementCenter(sleep: manualSleep.sleepFn) let gameID = UUID() center.post(makeAnnouncement( id: "k", scope: .game(gameID), - dismissal: .transient(after: 0.05) + dismissal: .transient(after: 10) )) #expect(center.current(forGame: gameID) != nil) - try? await Task.sleep(for: .milliseconds(200)) + manualSleep.releaseAll() + try await waitForCurrent(nil, center: center, gameID: gameID) #expect(center.current(forGame: gameID) == nil) } @Test("Replacing a .transient with a .sticky cancels the pending auto-dismiss") - func replacementCancelsAutoDismiss() async { - let center = AnnouncementCenter() + func replacementCancelsAutoDismiss() async throws { + let manualSleep = ManualDebounceSleep() + let center = AnnouncementCenter(sleep: manualSleep.sleepFn) let gameID = UUID() center.post(makeAnnouncement( id: "k", scope: .game(gameID), - dismissal: .transient(after: 0.05) + dismissal: .transient(after: 10) )) + try await manualSleep.waitForSleeperCount(1) // Replace with a sticky one before the transient delay elapses. center.post(makeAnnouncement( id: "k", @@ -105,7 +122,8 @@ struct AnnouncementCenterTests { body: "sticky", dismissal: .sticky )) - try? await Task.sleep(for: .milliseconds(200)) + manualSleep.releaseAll() + await Task.yield() // The transient's scheduled dismissal should not have fired // against the sticky replacement. #expect(center.current(forGame: gameID)?.body == "sticky")