crossmate

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

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 }