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 }