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 }