AnnouncementCenterTests.swift (8317B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("AnnouncementCenter") 7 @MainActor 8 struct AnnouncementCenterTests { 9 10 private func makeAnnouncement( 11 id: String = "test", 12 scope: Announcement.Scope = .global, 13 severity: Announcement.Severity = .info, 14 body: String = "Body", 15 dismissal: Announcement.Dismissal = .manual, 16 blocksInput: Bool = false, 17 createdAt: Date = Date() 18 ) -> Announcement { 19 Announcement( 20 id: id, 21 scope: scope, 22 severity: severity, 23 body: body, 24 dismissal: dismissal, 25 blocksInput: blocksInput, 26 createdAt: createdAt 27 ) 28 } 29 30 private func waitForCurrent( 31 _ expected: Announcement?, 32 center: AnnouncementCenter, 33 gameID: UUID, 34 timeout: Duration = .seconds(5) 35 ) async throws { 36 let deadline = ContinuousClock.now.advanced(by: timeout) 37 while center.current(forGame: gameID) != expected, 38 ContinuousClock.now < deadline { 39 try await Task.sleep(for: .milliseconds(20)) 40 } 41 } 42 43 @Test("Posting an announcement makes it the current for its scope") 44 func postShowsAsCurrent() { 45 let center = AnnouncementCenter() 46 let gameID = UUID() 47 center.post(makeAnnouncement(scope: .game(gameID), body: "Hi")) 48 #expect(center.current(forGame: gameID)?.body == "Hi") 49 } 50 51 @Test("Reposting with the same id replaces the prior announcement in place") 52 func replaceById() { 53 let center = AnnouncementCenter() 54 let gameID = UUID() 55 center.post(makeAnnouncement(id: "k", scope: .game(gameID), body: "first")) 56 center.post(makeAnnouncement(id: "k", scope: .game(gameID), body: "second")) 57 #expect(center.current(forGame: gameID)?.body == "second") 58 } 59 60 @Test("dismiss(id:) removes the announcement") 61 func dismissRemoves() { 62 let center = AnnouncementCenter() 63 let gameID = UUID() 64 center.post(makeAnnouncement(id: "k", scope: .game(gameID))) 65 center.dismiss(id: "k") 66 #expect(center.current(forGame: gameID) == nil) 67 } 68 69 @Test("Game-scoped announcements take priority over global ones for the puzzle header") 70 func scopePrecedence() { 71 let center = AnnouncementCenter() 72 let gameID = UUID() 73 center.post(makeAnnouncement(id: "g", scope: .global, body: "global")) 74 center.post(makeAnnouncement(id: "p", scope: .game(gameID), body: "game")) 75 #expect(center.current(forGame: gameID)?.body == "game") 76 // currentGlobal still surfaces the global one for surfaces that 77 // have no specific game context. 78 #expect(center.currentGlobal()?.body == "global") 79 } 80 81 @Test("Higher severity wins among multiple announcements of the same scope") 82 func severityWinsWithinScope() { 83 let center = AnnouncementCenter() 84 let gameID = UUID() 85 center.post(makeAnnouncement(id: "info", scope: .game(gameID), severity: .info, body: "i")) 86 center.post(makeAnnouncement(id: "error", scope: .game(gameID), severity: .error, body: "e")) 87 center.post(makeAnnouncement(id: "warning", scope: .game(gameID), severity: .warning, body: "w")) 88 #expect(center.current(forGame: gameID)?.body == "e") 89 } 90 91 @Test("A tip yields the slot to any real announcement, then returns when it clears") 92 func tipIsLowestPriority() { 93 let center = AnnouncementCenter() 94 center.post(makeAnnouncement(id: "tip", severity: .tip, body: "tip")) 95 #expect(center.currentGlobal()?.body == "tip") 96 // A real status message of any higher severity displaces the tip. 97 center.post(makeAnnouncement(id: "info", severity: .info, body: "info")) 98 #expect(center.currentGlobal()?.body == "info") 99 // Once the real message clears, the tip resurfaces. 100 center.dismiss(id: "info") 101 #expect(center.currentGlobal()?.body == "tip") 102 } 103 104 @Test("A global tip shows on the game list but never in the puzzle header") 105 func tipIsGameListOnly() { 106 let center = AnnouncementCenter() 107 let gameID = UUID() 108 center.post(makeAnnouncement(id: "tip", severity: .tip, body: "tip")) 109 // The game list surface still sees it. 110 #expect(center.currentGlobal()?.body == "tip") 111 // The puzzle header does not fall back to a tip. 112 #expect(center.current(forGame: gameID) == nil) 113 // A real global announcement still surfaces in the header as before. 114 center.post(makeAnnouncement(id: "err", severity: .error, body: "err")) 115 #expect(center.current(forGame: gameID)?.body == "err") 116 } 117 118 @Test(".transient announcements auto-dismiss after their delay") 119 func transientAutoDismisses() async throws { 120 let manualSleep = ManualDebounceSleep() 121 let center = AnnouncementCenter(sleep: manualSleep.sleepFn) 122 let gameID = UUID() 123 center.post(makeAnnouncement( 124 id: "k", 125 scope: .game(gameID), 126 dismissal: .transient(after: 10) 127 )) 128 #expect(center.current(forGame: gameID) != nil) 129 manualSleep.releaseAll() 130 try await waitForCurrent(nil, center: center, gameID: gameID) 131 #expect(center.current(forGame: gameID) == nil) 132 } 133 134 @Test("Replacing a .transient with a .sticky cancels the pending auto-dismiss") 135 func replacementCancelsAutoDismiss() async throws { 136 let manualSleep = ManualDebounceSleep() 137 let center = AnnouncementCenter(sleep: manualSleep.sleepFn) 138 let gameID = UUID() 139 center.post(makeAnnouncement( 140 id: "k", 141 scope: .game(gameID), 142 dismissal: .transient(after: 10) 143 )) 144 try await manualSleep.waitForSleeperCount(1) 145 // Replace with a sticky one before the transient delay elapses. 146 center.post(makeAnnouncement( 147 id: "k", 148 scope: .game(gameID), 149 body: "sticky", 150 dismissal: .sticky 151 )) 152 manualSleep.releaseAll() 153 await Task.yield() 154 // The transient's scheduled dismissal should not have fired 155 // against the sticky replacement. 156 #expect(center.current(forGame: gameID)?.body == "sticky") 157 } 158 159 @Test("isInputBlocked reflects the current announcement's blocksInput flag") 160 func inputBlockedFollowsCurrent() { 161 let center = AnnouncementCenter() 162 let gameID = UUID() 163 #expect(!center.isInputBlocked(forGame: gameID)) 164 center.post(makeAnnouncement( 165 id: "k", 166 scope: .game(gameID), 167 dismissal: .sticky, 168 blocksInput: true 169 )) 170 #expect(center.isInputBlocked(forGame: gameID)) 171 center.dismiss(id: "k") 172 #expect(!center.isInputBlocked(forGame: gameID)) 173 } 174 175 @Test("The access-revoked announcement blocks input for its game only") 176 func accessRevokedAnnouncementBlocksInput() { 177 let center = AnnouncementCenter() 178 let gameID = UUID() 179 let announcement = Announcement.accessRevoked(gameID: gameID) 180 #expect(announcement.dismissal == .sticky) 181 #expect(announcement.blocksInput) 182 center.post(announcement) 183 #expect(center.isInputBlocked(forGame: gameID)) 184 // Game-scoped: it must not block input on an unrelated puzzle. 185 #expect(!center.isInputBlocked(forGame: UUID())) 186 } 187 188 @Test("The game-removed announcement is a sticky, game-scoped, input-blocking banner") 189 func gameRemovedAnnouncementBlocksInput() { 190 let center = AnnouncementCenter() 191 let gameID = UUID() 192 let announcement = Announcement.gameRemoved(gameID: gameID) 193 #expect(announcement.scope == .game(gameID)) 194 #expect(announcement.severity == .error) 195 #expect(announcement.dismissal == .sticky) 196 #expect(announcement.blocksInput) 197 center.post(announcement) 198 #expect(center.current(forGame: gameID)?.id == announcement.id) 199 #expect(center.isInputBlocked(forGame: gameID)) 200 // Game-scoped: never on the game list, never on an unrelated puzzle. 201 #expect(center.currentGlobal() == nil) 202 #expect(!center.isInputBlocked(forGame: UUID())) 203 } 204 }