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