crossmate

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

NotificationStateTests.swift (11700B)


      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("A delayed completion event does not resurrect a badge after it was seen")
     96     func delayedCompletionDoesNotBeatSeen() {
     97         let gameID = UUID()
     98         let completedAt = Date(timeIntervalSince1970: 25_000)
     99         let reviewedAt = completedAt.addingTimeInterval(30)
    100 
    101         BadgeState.markSeen(gameID: gameID, at: reviewedAt)
    102 
    103         // The notification may arrive hours later, but the NSE records its
    104         // durable completion horizon rather than its delivery time.
    105         #expect(BadgeState.markUnread(gameID: gameID, at: completedAt) == 0)
    106         #expect(BadgeState.unreadGameIDs().isEmpty)
    107     }
    108 
    109     @Test("Seeding Core Data unread surfaces games but never resurrects a seen one")
    110     func seedUnreadRespectsSeenHorizon() {
    111         let fresh = UUID()
    112         let alreadySeen = UUID()
    113         let base = Date(timeIntervalSince1970: 30_000)
    114 
    115         // The user has already opened `alreadySeen` more recently than its
    116         // latest move — the NSE seed must not bring it back.
    117         BadgeState.markSeen(gameID: alreadySeen, at: base.addingTimeInterval(10))
    118 
    119         BadgeState.seedUnread([
    120             fresh: base,
    121             alreadySeen: base
    122         ])
    123 
    124         #expect(BadgeState.unreadGameIDs() == Set([fresh]))
    125     }
    126 
    127     @Test("Forgetting a game drops its ledger entry so a deleted game can't badge")
    128     func forgetRemovesLedgerEntry() {
    129         let gameID = UUID()
    130         let at = Date(timeIntervalSince1970: 40_000)
    131 
    132         #expect(BadgeState.markUnread(gameID: gameID, at: at) == 1)
    133         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
    134 
    135         // A deleted game can never be opened to advance `seenAt`, so the entry
    136         // must be removed outright rather than just marked seen.
    137         BadgeState.forget(gameID: gameID)
    138         #expect(BadgeState.unreadGameIDs().isEmpty)
    139 
    140         // A re-seed of a forgotten game (e.g. a stale push) starts clean.
    141         #expect(BadgeState.markUnread(gameID: gameID, at: at) == 1)
    142     }
    143 
    144     @Test("A push during the suppression horizon badges once the leave collapse lands")
    145     func suppressionCollapseResurrectsPostLeavePush() {
    146         let gameID = UUID()
    147         let open = Date(timeIntervalSince1970: 60_000)
    148         let lease = open.addingTimeInterval(600)
    149         let leave = open.addingTimeInterval(120)
    150         let push = open.addingTimeInterval(300)
    151 
    152         // Opening the puzzle adopts the forward-dated lease: watermark stays
    153         // at `now`, suppression takes the full horizon.
    154         BadgeState.adoptReadHorizon(gameID: gameID, horizon: lease, now: open)
    155 
    156         // A push landing while the lease horizon covers it is presumed watched.
    157         #expect(BadgeState.markUnread(gameID: gameID, at: push) == 0)
    158         #expect(BadgeState.unreadGameIDs().isEmpty)
    159 
    160         // The PLAN.md scenario: the user left at `leave`, before the push
    161         // arrived. Collapsing the suppression to the leave instant resurrects
    162         // it — under the old forward-dated `seenAt` it stayed swallowed until
    163         // the lease ran out.
    164         BadgeState.markSeen(gameID: gameID, at: leave)
    165         BadgeState.collapseSuppression(gameID: gameID, to: leave)
    166         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
    167     }
    168 
    169     @Test("A push the user actually watched stays cleared after the collapse")
    170     func suppressionCollapseKeepsWatchedPushCleared() {
    171         let gameID = UUID()
    172         let open = Date(timeIntervalSince1970: 70_000)
    173         let lease = open.addingTimeInterval(600)
    174         let push = open.addingTimeInterval(60)
    175         let leave = open.addingTimeInterval(120)
    176 
    177         BadgeState.adoptReadHorizon(gameID: gameID, horizon: lease, now: open)
    178         #expect(BadgeState.markUnread(gameID: gameID, at: push) == 0)
    179 
    180         // The push arrived before the user left, so the leave-time watermark
    181         // covers it: collapsing the suppression must not bring it back.
    182         BadgeState.markSeen(gameID: gameID, at: leave)
    183         BadgeState.collapseSuppression(gameID: gameID, to: leave)
    184         #expect(BadgeState.unreadGameIDs().isEmpty)
    185     }
    186 
    187     @Test("adoptReadHorizon never forward-dates the seen watermark")
    188     func adoptReadHorizonClampsWatermark() {
    189         let gameID = UUID()
    190         let now = Date(timeIntervalSince1970: 80_000)
    191         let lease = now.addingTimeInterval(600)
    192 
    193         BadgeState.adoptReadHorizon(gameID: gameID, horizon: lease, now: now)
    194 
    195         // With the suppression out of the way, only the (clamped) watermark
    196         // remains — a push after `now` must count as unread. A forward-dated
    197         // watermark here is the irreversible-swallow bug.
    198         BadgeState.collapseSuppression(gameID: gameID, to: now)
    199         #expect(BadgeState.markUnread(gameID: gameID, at: now.addingTimeInterval(10)) == 1)
    200         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
    201     }
    202 
    203     @Test("A stale lease arriving late cannot shorten an extended suppression")
    204     func extendSuppressionIsMonotonic() {
    205         let gameID = UUID()
    206         let base = Date(timeIntervalSince1970: 90_000)
    207 
    208         BadgeState.extendSuppression(gameID: gameID, until: base.addingTimeInterval(600))
    209         // An older horizon (e.g. an out-of-order accountSeen) must not pull
    210         // the active one back — only an explicit collapse may do that.
    211         BadgeState.extendSuppression(gameID: gameID, until: base.addingTimeInterval(300))
    212 
    213         #expect(BadgeState.markUnread(gameID: gameID, at: base.addingTimeInterval(400)) == 0)
    214         #expect(BadgeState.unreadGameIDs().isEmpty)
    215 
    216         BadgeState.collapseSuppression(gameID: gameID, to: base.addingTimeInterval(300))
    217         #expect(BadgeState.unreadGameIDs() == Set([gameID]))
    218     }
    219 
    220     @Test("Collapsing suppression for an unknown game leaves the ledger clean")
    221     func collapseSuppressionWithoutEntryIsNoOp() {
    222         let gameID = UUID()
    223         BadgeState.collapseSuppression(gameID: gameID, to: Date(timeIntervalSince1970: 95_000))
    224         #expect(BadgeState.unreadGameIDs().isEmpty)
    225 
    226         // The game still badges normally afterwards.
    227         #expect(BadgeState.markUnread(gameID: gameID, at: Date(timeIntervalSince1970: 95_100)) == 1)
    228     }
    229 
    230     @Test("Pending invites round-trip and overwrite wholesale")
    231     func pendingInvitesOverwrite() {
    232         let first = UUID()
    233         let second = UUID()
    234 
    235         BadgeState.setPendingInvites([first])
    236         #expect(BadgeState.pendingInviteGameIDs() == Set([first]))
    237 
    238         // The app republishes ground truth each refresh, so a new set replaces
    239         // the old one rather than unioning.
    240         BadgeState.setPendingInvites([second])
    241         #expect(BadgeState.pendingInviteGameIDs() == Set([second]))
    242 
    243         // An empty set clears the store entirely (e.g. the last invite accepted).
    244         BadgeState.setPendingInvites([])
    245         #expect(BadgeState.pendingInviteGameIDs().isEmpty)
    246     }
    247 
    248     @Test("Pending invites are independent of the unread-moves ledger")
    249     func pendingInvitesIndependentOfLedger() {
    250         let move = UUID()
    251         let invite = UUID()
    252         let at = Date(timeIntervalSince1970: 50_000)
    253 
    254         BadgeState.markUnread(gameID: move, at: at)
    255         BadgeState.setPendingInvites([invite])
    256 
    257         // The badge count unions the two disjoint sets.
    258         #expect(BadgeState.unreadGameIDs().union(BadgeState.pendingInviteGameIDs())
    259             == Set([move, invite]))
    260 
    261         // Clearing invites leaves the moves ledger untouched, and vice versa.
    262         BadgeState.setPendingInvites([])
    263         #expect(BadgeState.unreadGameIDs() == Set([move]))
    264     }
    265 
    266     @Test("Reset clears pending invites alongside the ledger")
    267     func resetClearsPendingInvites() {
    268         BadgeState.markUnread(gameID: UUID())
    269         BadgeState.setPendingInvites([UUID()])
    270 
    271         BadgeState.reset()
    272         #expect(BadgeState.unreadGameIDs().isEmpty)
    273         #expect(BadgeState.pendingInviteGameIDs().isEmpty)
    274     }
    275 }