GameStoreUnseenMovesTests.swift (13028B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 /// Pins down the Date-based unread-badge heuristic on `GameStore`. A shared 8 /// game gains an unread badge when another author's `MovesEntity` row has a 9 /// later `updatedAt` than the local user's last open. 10 @Suite("GameStore unread badge", .serialized) 11 @MainActor 12 struct GameStoreUnseenMovesTests { 13 14 private static let localAuthorID = "local-author" 15 private static let otherAuthorID = "other-author" 16 17 private static let sharedPuzzleSource = """ 18 Title: Test Puzzle 19 Author: Test 20 21 22 ABC 23 D#E 24 FGH 25 26 27 A1. Across 1 ~ ABC 28 A4. Across 4 ~ DE 29 A5. Across 5 ~ FGH 30 D1. Down 1 ~ ADF 31 D2. Down 2 ~ BG 32 D3. Down 3 ~ CEH 33 """ 34 35 private func makeSharedGame( 36 in ctx: NSManagedObjectContext 37 ) throws -> (GameEntity, UUID) { 38 let gameID = UUID() 39 let xd = try XD.parse(Self.sharedPuzzleSource) 40 let puzzle = Puzzle(xd: xd) 41 42 let entity = GameEntity(context: ctx) 43 entity.id = gameID 44 entity.title = "Shared" 45 entity.puzzleSource = Self.sharedPuzzleSource 46 entity.createdAt = Date() 47 entity.updatedAt = Date() 48 entity.ckRecordName = "game-\(gameID.uuidString)" 49 entity.ckZoneName = "game-\(gameID.uuidString)" 50 entity.ckZoneOwnerName = "_someOtherUser" 51 entity.databaseScope = 1 52 // Pre-populate the cached summary fields so `GameSummary.init?` takes 53 // the fast path and doesn't have to re-parse XD. 54 entity.populateCachedSummaryFields(from: puzzle) 55 try ctx.save() 56 return (entity, gameID) 57 } 58 59 private func addMovesRow( 60 for entity: GameEntity, 61 gameID: UUID, 62 authorID: String, 63 updatedAt: Date, 64 in ctx: NSManagedObjectContext 65 ) throws { 66 let row = MovesEntity(context: ctx) 67 row.game = entity 68 row.authorID = authorID 69 row.deviceID = "test-\(authorID)" 70 row.cells = Data() 71 row.updatedAt = updatedAt 72 row.ckRecordName = RecordSerializer.recordName( 73 forMovesInGame: gameID, 74 authorID: authorID, 75 deviceID: "test-\(authorID)" 76 ) 77 try ctx.save() 78 } 79 80 @Test("Other-author Moves update marks the shared game unread") 81 func otherAuthorMoveMarksSharedGameUnread() throws { 82 let persistence = makeTestPersistence() 83 let store = makeTestStore(persistence: persistence) 84 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 85 let updatedAt = Date(timeIntervalSinceNow: -10) 86 try addMovesRow( 87 for: entity, 88 gameID: gameID, 89 authorID: Self.otherAuthorID, 90 updatedAt: updatedAt, 91 in: persistence.viewContext 92 ) 93 94 store.noteIncomingMovesUpdate( 95 gameIDs: [gameID], 96 currentAuthorID: Self.localAuthorID 97 ) 98 99 let summary = try #require(GameSummary(entity: entity)) 100 #expect(entity.latestOtherMoveAt == updatedAt) 101 #expect(entity.lastSeenOtherMoveAt == nil) 102 #expect(summary.hasUnseenOtherMoves) 103 } 104 105 @Test("Own Moves update does not mark the shared game unread") 106 func ownMoveDoesNotMarkSharedGameUnread() throws { 107 let persistence = makeTestPersistence() 108 let store = makeTestStore(persistence: persistence) 109 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 110 try addMovesRow( 111 for: entity, 112 gameID: gameID, 113 authorID: Self.localAuthorID, 114 updatedAt: Date(), 115 in: persistence.viewContext 116 ) 117 118 store.noteIncomingMovesUpdate( 119 gameIDs: [gameID], 120 currentAuthorID: Self.localAuthorID 121 ) 122 123 let summary = try #require(GameSummary(entity: entity)) 124 #expect(entity.latestOtherMoveAt == nil) 125 #expect(!summary.hasUnseenOtherMoves) 126 } 127 128 @Test("Opening a game advances lastSeenOtherMoveAt to latestOtherMoveAt") 129 func openingGameMarksOtherMovesSeen() throws { 130 let persistence = makeTestPersistence() 131 let store = makeTestStore(persistence: persistence) 132 let ctx = persistence.viewContext 133 let (entity, _) = try makeSharedGame(in: ctx) 134 let latest = Date(timeIntervalSinceNow: -10) 135 entity.latestOtherMoveAt = latest 136 try ctx.save() 137 138 _ = try store.loadGame(id: entity.id!) 139 140 #expect(entity.lastSeenOtherMoveAt == latest) 141 let summary = try #require(GameSummary(entity: entity)) 142 #expect(!summary.hasUnseenOtherMoves) 143 } 144 145 @Test("unseenOtherMovesGameCount tallies shared games with pending other-author moves") 146 func unseenOtherMovesGameCountAcrossGames() throws { 147 let persistence = makeTestPersistence() 148 let store = makeTestStore(persistence: persistence) 149 let ctx = persistence.viewContext 150 151 // Unseen: shared game with other-author moves and no lastSeen. 152 let (gameA, gameAID) = try makeSharedGame(in: ctx) 153 try addMovesRow( 154 for: gameA, 155 gameID: gameAID, 156 authorID: Self.otherAuthorID, 157 updatedAt: Date(timeIntervalSinceNow: -20), 158 in: ctx 159 ) 160 store.noteIncomingMovesUpdate( 161 gameIDs: [gameAID], 162 currentAuthorID: Self.localAuthorID 163 ) 164 165 // Seen: shared game whose lastSeen catches up to latest. 166 let (gameB, gameBID) = try makeSharedGame(in: ctx) 167 let seenLatest = Date(timeIntervalSinceNow: -30) 168 gameB.latestOtherMoveAt = seenLatest 169 gameB.lastSeenOtherMoveAt = seenLatest 170 try ctx.save() 171 172 #expect(store.unseenOtherMovesGameCount() == 1) 173 174 // Opening the unseen game advances lastSeen and clears the badge tally. 175 _ = try store.loadGame(id: gameAID) 176 #expect(store.unseenOtherMovesGameCount() == 0) 177 } 178 179 @Test("Inbound moves while the puzzle is visible advance lastSeenOtherMoveAt") 180 func inboundMovesWhilePuzzleVisibleMarkSeen() throws { 181 let persistence = makeTestPersistence() 182 let store = makeTestStore(persistence: persistence) 183 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 184 let updatedAt = Date(timeIntervalSinceNow: -10) 185 try addMovesRow( 186 for: entity, 187 gameID: gameID, 188 authorID: Self.otherAuthorID, 189 updatedAt: updatedAt, 190 in: persistence.viewContext 191 ) 192 193 NotificationState.setActivePuzzleID(gameID) 194 defer { NotificationState.setActivePuzzleID(nil) } 195 196 store.noteIncomingMovesUpdate( 197 gameIDs: [gameID], 198 currentAuthorID: Self.localAuthorID 199 ) 200 201 #expect(entity.lastSeenOtherMoveAt == updatedAt) 202 let summary = try #require(GameSummary(entity: entity)) 203 #expect(!summary.hasUnseenOtherMoves) 204 } 205 206 @Test("Inbound moves after backing out of a puzzle still mark it unseen") 207 func inboundMovesAfterBackOutMarkUnseen() throws { 208 let persistence = makeTestPersistence() 209 let store = makeTestStore(persistence: persistence) 210 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 211 // Simulate a prior open: `currentEntity` is set inside the store and 212 // `lastSeenOtherMoveAt` is up-to-date with no pending moves. The user 213 // then backs out — `NotificationState.activePuzzleID` clears, but the 214 // store's `currentEntity` deliberately stays put. 215 _ = try store.loadGame(id: gameID) 216 NotificationState.setActivePuzzleID(nil) 217 218 let updatedAt = Date() 219 try addMovesRow( 220 for: entity, 221 gameID: gameID, 222 authorID: Self.otherAuthorID, 223 updatedAt: updatedAt, 224 in: persistence.viewContext 225 ) 226 227 store.noteIncomingMovesUpdate( 228 gameIDs: [gameID], 229 currentAuthorID: Self.localAuthorID 230 ) 231 232 #expect(entity.latestOtherMoveAt == updatedAt) 233 #expect(entity.lastSeenOtherMoveAt == nil) 234 let summary = try #require(GameSummary(entity: entity)) 235 #expect(summary.hasUnseenOtherMoves) 236 } 237 238 @Test("Inbound moves within the leave grace after backing out stay seen") 239 func inboundMovesWithinLeaveGraceStaySeen() throws { 240 let persistence = makeTestPersistence() 241 let store = makeTestStore(persistence: persistence) 242 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 243 _ = try store.loadGame(id: gameID) 244 245 // Simulate the real open → back-out path: the view sets the active 246 // puzzle on appear and clears it on `.onDisappear`, which now opens a 247 // short grace window rather than dropping the active state instantly. 248 NotificationState.setActivePuzzleID(gameID) 249 NotificationState.clearActivePuzzleID(if: gameID) 250 defer { NotificationState.setActivePuzzleID(nil) } 251 252 let updatedAt = Date() 253 try addMovesRow( 254 for: entity, 255 gameID: gameID, 256 authorID: Self.otherAuthorID, 257 updatedAt: updatedAt, 258 in: persistence.viewContext 259 ) 260 261 // An inbound batch (or back-out catch-up) that finishes processing a 262 // beat after the view disappeared is still treated as seen — the user 263 // watched these moves arrive while the grid was on screen. 264 store.noteIncomingMovesUpdate( 265 gameIDs: [gameID], 266 currentAuthorID: Self.localAuthorID 267 ) 268 269 #expect(entity.lastSeenOtherMoveAt == updatedAt) 270 let summary = try #require(GameSummary(entity: entity)) 271 #expect(!summary.hasUnseenOtherMoves) 272 } 273 274 @Test("A sibling device's open lease keeps inbound moves seen here") 275 func remoteLeaseKeepsInboundMovesSeen() throws { 276 let persistence = makeTestPersistence() 277 let store = makeTestStore(persistence: persistence) 278 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 279 _ = try store.loadGame(id: gameID) 280 281 // Not viewing here, but a sibling device of the same user holds an 282 // open lease — the user has eyes on the puzzle there. 283 NotificationState.setActivePuzzleID(nil) 284 let sentMs = Int64(Date().timeIntervalSince1970 * 1000) 285 NotificationState.noteRemoteLease( 286 gameID: gameID, 287 until: Date().addingTimeInterval(NotificationState.openLeaseDuration), 288 sentAtMs: sentMs 289 ) 290 defer { 291 NotificationState.noteRemoteLease( 292 gameID: gameID, 293 until: Date().addingTimeInterval(-1), 294 sentAtMs: sentMs + 1 295 ) 296 } 297 298 let updatedAt = Date() 299 try addMovesRow( 300 for: entity, 301 gameID: gameID, 302 authorID: Self.otherAuthorID, 303 updatedAt: updatedAt, 304 in: persistence.viewContext 305 ) 306 307 store.noteIncomingMovesUpdate( 308 gameIDs: [gameID], 309 currentAuthorID: Self.localAuthorID 310 ) 311 312 #expect(entity.lastSeenOtherMoveAt == updatedAt) 313 let summary = try #require(GameSummary(entity: entity)) 314 #expect(!summary.hasUnseenOtherMoves) 315 } 316 317 @Test("Completed shared games do not show as unseen even with later other-author moves") 318 func completedSharedGameSuppressesUnseen() throws { 319 let persistence = makeTestPersistence() 320 let store = makeTestStore(persistence: persistence) 321 let ctx = persistence.viewContext 322 323 let (entity, gameID) = try makeSharedGame(in: ctx) 324 entity.completedAt = Date(timeIntervalSinceNow: -100) 325 try ctx.save() 326 327 try addMovesRow( 328 for: entity, 329 gameID: gameID, 330 authorID: Self.otherAuthorID, 331 updatedAt: Date(), 332 in: ctx 333 ) 334 335 store.noteIncomingMovesUpdate( 336 gameIDs: [gameID], 337 currentAuthorID: Self.localAuthorID 338 ) 339 340 let summary = try #require(GameSummary(entity: entity)) 341 #expect(!summary.hasUnseenOtherMoves) 342 #expect(store.unseenOtherMovesGameCount() == 0) 343 } 344 345 @Test("Opening a stale CmVer game reparses source and records current CmVer") 346 func openingStaleCmVerGameReparsesSource() throws { 347 let persistence = makeTestPersistence() 348 let store = makeTestStore(persistence: persistence) 349 let ctx = persistence.viewContext 350 let (entity, _) = try makeSharedGame(in: ctx) 351 entity.puzzleCmVersion = 0 352 entity.gridWidth = 0 353 entity.gridHeight = 0 354 entity.blockMask = nil 355 try ctx.save() 356 357 _ = try store.loadGame(id: entity.id!) 358 359 #expect(entity.puzzleCmVersion == Int64(XD.currentCmVersion)) 360 #expect(entity.gridWidth == 3) 361 #expect(entity.gridHeight == 3) 362 #expect(entity.blockMask?.count == 9) 363 } 364 }