crossmate

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

NotificationStateTests.swift (11401B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 @Suite("Notification state", .isolatedNotificationState)
      7 struct NotificationStateTests {
      8     @Test("Active puzzle stays suppressed through the leave grace, then clears")
      9     func activePuzzleSuppressionClears() {
     10         let gameID = UUID()
     11         let base = Date(timeIntervalSince1970: 3_000_000)
     12         NotificationState.setActivePuzzleID(nil)
     13 
     14         NotificationState.setActivePuzzleID(gameID)
     15         #expect(NotificationState.isActive(gameID: gameID, now: base))
     16 
     17         NotificationState.clearActivePuzzleID(if: gameID, now: base)
     18         // The grace tail keeps the just-left puzzle active briefly so
     19         // in-flight inbound work still counts as seen.
     20         #expect(NotificationState.isActive(gameID: gameID, now: base))
     21         #expect(NotificationState.isActive(
     22             gameID: gameID,
     23             now: base.addingTimeInterval(NotificationState.leaveGraceWindow - 1)
     24         ))
     25         // At and past the grace boundary it is no longer active.
     26         #expect(!NotificationState.isActive(
     27             gameID: gameID,
     28             now: base.addingTimeInterval(NotificationState.leaveGraceWindow)
     29         ))
     30         NotificationState.setActivePuzzleID(nil)
     31     }
     32 
     33     @Test("Clearing one puzzle does not clear another active puzzle")
     34     func clearActivePuzzleRequiresMatchingID() {
     35         let gameID = UUID()
     36         let otherID = UUID()
     37         NotificationState.setActivePuzzleID(nil)
     38 
     39         NotificationState.setActivePuzzleID(gameID)
     40         NotificationState.clearActivePuzzleID(if: otherID)
     41 
     42         #expect(NotificationState.activePuzzleID() == gameID)
     43         NotificationState.setActivePuzzleID(nil)
     44     }
     45 
     46     @Test("isSuppressed tracks the local active puzzle (and its grace tail)")
     47     func suppressedTracksLocalActive() {
     48         let gameID = UUID()
     49         let base = Date(timeIntervalSince1970: 7_000_000)
     50         NotificationState.setActivePuzzleID(nil)
     51         #expect(!NotificationState.isSuppressed(gameID: gameID, now: base))
     52 
     53         NotificationState.setActivePuzzleID(gameID)
     54         #expect(NotificationState.isSuppressed(gameID: gameID, now: base))
     55 
     56         // Clearing the active ID keeps the just-left puzzle suppressed
     57         // through the grace tail and releases it after.
     58         NotificationState.clearActivePuzzleID(if: gameID, now: base)
     59         #expect(NotificationState.isSuppressed(gameID: gameID, now: base))
     60         #expect(!NotificationState.isSuppressed(
     61             gameID: gameID,
     62             now: base.addingTimeInterval(NotificationState.leaveGraceWindow)
     63         ))
     64         NotificationState.setActivePuzzleID(nil)
     65     }
     66 
     67     @Test("Badge ledger marks unread then seen by horizon")
     68     func badgeLedgerMarksUnreadThenSeen() {
     69         let gameID = UUID()
     70         let unreadAt = Date(timeIntervalSince1970: 10_000)
     71         let seenAt = unreadAt.addingTimeInterval(1)
     72 
     73         #expect(BadgeState.markUnread(gameID: gameID, at: unreadAt) == 1)
     74         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
     75 
     76         #expect(BadgeState.markSeen(gameID: gameID, at: seenAt) == 0)
     77         #expect(BadgeState.unreadGameIDs().isEmpty)
     78     }
     79 
     80     @Test("Older unread events do not beat newer seen horizon")
     81     func olderUnreadDoesNotBeatSeen() {
     82         let gameID = UUID()
     83         let seenAt = Date(timeIntervalSince1970: 20_000)
     84         let staleUnread = seenAt.addingTimeInterval(-10)
     85         let freshUnread = seenAt.addingTimeInterval(10)
     86 
     87         #expect(BadgeState.markSeen(gameID: gameID, at: seenAt) == 0)
     88         #expect(BadgeState.markUnread(gameID: gameID, at: staleUnread) == 0)
     89         #expect(BadgeState.unreadGameIDs().isEmpty)
     90 
     91         #expect(BadgeState.markUnread(gameID: gameID, at: freshUnread) == 1)
     92         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
     93     }
     94 
     95     @Test("Seeding Core Data unread surfaces games but never resurrects a seen one")
     96     func seedUnreadRespectsSeenHorizon() {
     97         let fresh = UUID()
     98         let alreadySeen = UUID()
     99         let base = Date(timeIntervalSince1970: 30_000)
    100 
    101         // The user has already opened `alreadySeen` more recently than its
    102         // latest move — the NSE seed must not bring it back.
    103         BadgeState.markSeen(gameID: alreadySeen, at: base.addingTimeInterval(10))
    104 
    105         BadgeState.seedUnread([
    106             fresh: base,
    107             alreadySeen: base
    108         ])
    109 
    110         #expect(BadgeState.unreadGameIDs() == Set([fresh]))
    111     }
    112 
    113     @Test("Forgetting a game drops its ledger entry so a deleted game can't badge")
    114     func forgetRemovesLedgerEntry() {
    115         let gameID = UUID()
    116         let at = Date(timeIntervalSince1970: 40_000)
    117 
    118         #expect(BadgeState.markUnread(gameID: gameID, at: at) == 1)
    119         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
    120 
    121         // A deleted game can never be opened to advance `seenAt`, so the entry
    122         // must be removed outright rather than just marked seen.
    123         BadgeState.forget(gameID: gameID)
    124         #expect(BadgeState.unreadGameIDs().isEmpty)
    125 
    126         // A re-seed of a forgotten game (e.g. a stale push) starts clean.
    127         #expect(BadgeState.markUnread(gameID: gameID, at: at) == 1)
    128     }
    129 
    130     @Test("A push during the suppression horizon badges once the leave collapse lands")
    131     func suppressionCollapseResurrectsPostLeavePush() {
    132         let gameID = UUID()
    133         let open = Date(timeIntervalSince1970: 60_000)
    134         let lease = open.addingTimeInterval(600)
    135         let leave = open.addingTimeInterval(120)
    136         let push = open.addingTimeInterval(300)
    137 
    138         // Opening the puzzle adopts the forward-dated lease: watermark stays
    139         // at `now`, suppression takes the full horizon.
    140         BadgeState.adoptReadHorizon(gameID: gameID, horizon: lease, now: open)
    141 
    142         // A push landing while the lease horizon covers it is presumed watched.
    143         #expect(BadgeState.markUnread(gameID: gameID, at: push) == 0)
    144         #expect(BadgeState.unreadGameIDs().isEmpty)
    145 
    146         // The PLAN.md scenario: the user left at `leave`, before the push
    147         // arrived. Collapsing the suppression to the leave instant resurrects
    148         // it — under the old forward-dated `seenAt` it stayed swallowed until
    149         // the lease ran out.
    150         BadgeState.markSeen(gameID: gameID, at: leave)
    151         BadgeState.collapseSuppression(gameID: gameID, to: leave)
    152         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
    153     }
    154 
    155     @Test("A push the user actually watched stays cleared after the collapse")
    156     func suppressionCollapseKeepsWatchedPushCleared() {
    157         let gameID = UUID()
    158         let open = Date(timeIntervalSince1970: 70_000)
    159         let lease = open.addingTimeInterval(600)
    160         let push = open.addingTimeInterval(60)
    161         let leave = open.addingTimeInterval(120)
    162 
    163         BadgeState.adoptReadHorizon(gameID: gameID, horizon: lease, now: open)
    164         #expect(BadgeState.markUnread(gameID: gameID, at: push) == 0)
    165 
    166         // The push arrived before the user left, so the leave-time watermark
    167         // covers it: collapsing the suppression must not bring it back.
    168         BadgeState.markSeen(gameID: gameID, at: leave)
    169         BadgeState.collapseSuppression(gameID: gameID, to: leave)
    170         #expect(BadgeState.unreadGameIDs().isEmpty)
    171     }
    172 
    173     @Test("adoptReadHorizon never forward-dates the seen watermark")
    174     func adoptReadHorizonClampsWatermark() {
    175         let gameID = UUID()
    176         let now = Date(timeIntervalSince1970: 80_000)
    177         let lease = now.addingTimeInterval(600)
    178 
    179         BadgeState.adoptReadHorizon(gameID: gameID, horizon: lease, now: now)
    180 
    181         // With the suppression out of the way, only the (clamped) watermark
    182         // remains — a push after `now` must count as unread. A forward-dated
    183         // watermark here is the irreversible-swallow bug.
    184         BadgeState.collapseSuppression(gameID: gameID, to: now)
    185         #expect(BadgeState.markUnread(gameID: gameID, at: now.addingTimeInterval(10)) == 1)
    186         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
    187     }
    188 
    189     @Test("A stale lease arriving late cannot shorten an extended suppression")
    190     func extendSuppressionIsMonotonic() {
    191         let gameID = UUID()
    192         let base = Date(timeIntervalSince1970: 90_000)
    193 
    194         BadgeState.extendSuppression(gameID: gameID, until: base.addingTimeInterval(600))
    195         // An older horizon (e.g. an out-of-order accountSeen) must not pull
    196         // the active one back — only an explicit collapse may do that.
    197         BadgeState.extendSuppression(gameID: gameID, until: base.addingTimeInterval(300))
    198 
    199         #expect(BadgeState.markUnread(gameID: gameID, at: base.addingTimeInterval(400)) == 0)
    200         #expect(BadgeState.unreadGameIDs().isEmpty)
    201 
    202         BadgeState.collapseSuppression(gameID: gameID, to: base.addingTimeInterval(300))
    203         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
    204     }
    205 
    206     @Test("Collapsing suppression for an unknown game leaves the ledger clean")
    207     func collapseSuppressionWithoutEntryIsNoOp() {
    208         let gameID = UUID()
    209         BadgeState.collapseSuppression(gameID: gameID, to: Date(timeIntervalSince1970: 95_000))
    210         #expect(BadgeState.unreadGameIDs().isEmpty)
    211 
    212         // The game still badges normally afterwards.
    213         #expect(BadgeState.markUnread(gameID: gameID, at: Date(timeIntervalSince1970: 95_100)) == 1)
    214     }
    215 
    216     @Test("Pending invites round-trip and overwrite wholesale")
    217     func pendingInvitesOverwrite() {
    218         let first = UUID()
    219         let second = UUID()
    220 
    221         BadgeState.setPendingInvites([first])
    222         #expect(BadgeState.pendingInviteGameIDs() == Set([first]))
    223 
    224         // The app republishes ground truth each refresh, so a new set replaces
    225         // the old one rather than unioning.
    226         BadgeState.setPendingInvites([second])
    227         #expect(BadgeState.pendingInviteGameIDs() == Set([second]))
    228 
    229         // An empty set clears the store entirely (e.g. the last invite accepted).
    230         BadgeState.setPendingInvites([])
    231         #expect(BadgeState.pendingInviteGameIDs().isEmpty)
    232     }
    233 
    234     @Test("Pending invites are independent of the unread-moves ledger")
    235     func pendingInvitesIndependentOfLedger() {
    236         let move = UUID()
    237         let invite = UUID()
    238         let at = Date(timeIntervalSince1970: 50_000)
    239 
    240         BadgeState.markUnread(gameID: move, at: at)
    241         BadgeState.setPendingInvites([invite])
    242 
    243         // The badge count unions the two disjoint sets.
    244         #expect(BadgeState.unreadGameIDs().union(BadgeState.pendingInviteGameIDs())
    245             == Set([move, invite]))
    246 
    247         // Clearing invites leaves the moves ledger untouched, and vice versa.
    248         BadgeState.setPendingInvites([])
    249         #expect(BadgeState.unreadGameIDs() == Set([move]))
    250     }
    251 
    252     @Test("Reset clears pending invites alongside the ledger")
    253     func resetClearsPendingInvites() {
    254         BadgeState.markUnread(gameID: UUID())
    255         BadgeState.setPendingInvites([UUID()])
    256 
    257         BadgeState.reset()
    258         #expect(BadgeState.unreadGameIDs().isEmpty)
    259         #expect(BadgeState.pendingInviteGameIDs().isEmpty)
    260     }
    261 
    262     @Test("Legacy read-through heal is claimed once")
    263     func legacyReadThroughHealClaimedOnce() {
    264         #expect(BadgeState.claimLegacyReadThroughHealNeeded())
    265         #expect(!BadgeState.claimLegacyReadThroughHealNeeded())
    266 
    267         BadgeState.reset()
    268         #expect(BadgeState.claimLegacyReadThroughHealNeeded())
    269     }
    270 }