GameStoreUnreadMovesTests.swift (21496B)
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", .isolatedNotificationState) 11 @MainActor 12 struct GameStoreUnreadMovesTests { 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.lastReadOtherMoveAt == nil) 102 #expect(summary.hasUnreadOtherMoves) 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.hasUnreadOtherMoves) 126 } 127 128 @Test("Opening a game advances readThroughAt 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.readThroughAt == latest) 141 let summary = try #require(GameSummary(entity: entity)) 142 #expect(!summary.hasUnreadOtherMoves) 143 } 144 145 @Test("unreadOtherMovesGameCount tallies shared games with pending other-author moves") 146 func unreadOtherMovesGameCountAcrossGames() 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 watermark catches up to latest. 166 let (gameB, _) = try makeSharedGame(in: ctx) 167 let seenLatest = Date(timeIntervalSinceNow: -30) 168 gameB.latestOtherMoveAt = seenLatest 169 gameB.readThroughAt = seenLatest 170 try ctx.save() 171 172 #expect(store.unreadOtherMovesGameCount() == 1) 173 174 // Opening the unseen game advances lastSeen and clears the badge tally. 175 _ = try store.loadGame(id: gameAID) 176 #expect(store.unreadOtherMovesGameCount() == 0) 177 } 178 179 @Test("Inbound moves while the puzzle is visible advance readThroughAt") 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.readThroughAt == updatedAt) 202 let summary = try #require(GameSummary(entity: entity)) 203 #expect(!summary.hasUnreadOtherMoves) 204 } 205 206 @Test("Inbound moves while visible do not shorten an active read lease") 207 func inboundMovesWhileVisiblePreserveFutureLease() throws { 208 let persistence = makeTestPersistence() 209 let store = makeTestStore(persistence: persistence) 210 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 211 let lease = Date(timeIntervalSinceNow: 10 * 60) 212 entity.lastReadOtherMoveAt = lease 213 try persistence.viewContext.save() 214 215 let updatedAt = Date(timeIntervalSinceNow: -10) 216 try addMovesRow( 217 for: entity, 218 gameID: gameID, 219 authorID: Self.otherAuthorID, 220 updatedAt: updatedAt, 221 in: persistence.viewContext 222 ) 223 224 NotificationState.setActivePuzzleID(gameID) 225 defer { NotificationState.setActivePuzzleID(nil) } 226 227 store.noteIncomingMovesUpdate( 228 gameIDs: [gameID], 229 currentAuthorID: Self.localAuthorID 230 ) 231 232 #expect(entity.latestOtherMoveAt == updatedAt) 233 #expect(entity.lastReadOtherMoveAt == lease) 234 let summary = try #require(GameSummary(entity: entity)) 235 #expect(!summary.hasUnreadOtherMoves) 236 } 237 238 @Test("Inbound moves after backing out of a puzzle still mark it unseen") 239 func inboundMovesAfterBackOutMarkUnseen() throws { 240 let persistence = makeTestPersistence() 241 let store = makeTestStore(persistence: persistence) 242 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 243 // Simulate a prior open: `currentEntity` is set inside the store and 244 // `lastReadOtherMoveAt` is up-to-date with no pending moves. The user 245 // then backs out — `NotificationState.activePuzzleID` clears, but the 246 // store's `currentEntity` deliberately stays put. 247 _ = try store.loadGame(id: gameID) 248 NotificationState.setActivePuzzleID(nil) 249 250 let updatedAt = Date() 251 try addMovesRow( 252 for: entity, 253 gameID: gameID, 254 authorID: Self.otherAuthorID, 255 updatedAt: updatedAt, 256 in: persistence.viewContext 257 ) 258 259 store.noteIncomingMovesUpdate( 260 gameIDs: [gameID], 261 currentAuthorID: Self.localAuthorID 262 ) 263 264 #expect(entity.latestOtherMoveAt == updatedAt) 265 #expect(entity.lastReadOtherMoveAt == nil) 266 let summary = try #require(GameSummary(entity: entity)) 267 #expect(summary.hasUnreadOtherMoves) 268 } 269 270 @Test("Inbound moves within the leave grace after backing out stay seen") 271 func inboundMovesWithinLeaveGraceStaySeen() throws { 272 let persistence = makeTestPersistence() 273 let store = makeTestStore(persistence: persistence) 274 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 275 _ = try store.loadGame(id: gameID) 276 277 // Simulate the real open → back-out path: the view sets the active 278 // puzzle on appear and clears it on `.onDisappear`, which now opens a 279 // short grace window rather than dropping the active state instantly. 280 NotificationState.setActivePuzzleID(gameID) 281 NotificationState.clearActivePuzzleID(if: gameID) 282 defer { NotificationState.setActivePuzzleID(nil) } 283 284 let updatedAt = Date() 285 try addMovesRow( 286 for: entity, 287 gameID: gameID, 288 authorID: Self.otherAuthorID, 289 updatedAt: updatedAt, 290 in: persistence.viewContext 291 ) 292 293 // An inbound batch (or back-out catch-up) that finishes processing a 294 // beat after the view disappeared is still treated as seen — the user 295 // watched these moves arrive while the grid was on screen. 296 store.noteIncomingMovesUpdate( 297 gameIDs: [gameID], 298 currentAuthorID: Self.localAuthorID 299 ) 300 301 #expect(entity.readThroughAt == updatedAt) 302 let summary = try #require(GameSummary(entity: entity)) 303 #expect(!summary.hasUnreadOtherMoves) 304 } 305 306 @Test("A sibling's presenceUntil presence lease is adopted last-writer-wins") 307 func incomingReadCursorSetsBadgeHorizon() throws { 308 let persistence = makeTestPersistence() 309 let store = makeTestStore( 310 persistence: persistence, 311 authorIDProvider: { Self.localAuthorID } 312 ) 313 let ctx = persistence.viewContext 314 let (entity, gameID) = try makeSharedGame(in: ctx) 315 316 let earlier = Date(timeIntervalSinceNow: -30) 317 let later = Date(timeIntervalSinceNow: -10) 318 let future = Date(timeIntervalSinceNow: 10 * 60) 319 320 // All of an account's devices share one Player record, so the inbound 321 // `presenceUntil` is the account's resolved *presence lease*: adopt it verbatim 322 // under last-writer-wins. This is the presence horizon only; the unread 323 // badge is driven by the read watermark (see the watermark test below). 324 store.noteIncomingReadCursor(gameID: gameID, presenceUntil: earlier) 325 #expect(entity.lastReadOtherMoveAt == earlier) 326 327 store.noteIncomingReadCursor(gameID: gameID, presenceUntil: later) 328 #expect(entity.lastReadOtherMoveAt == later) 329 330 // A sibling opens an active session and leases the horizon into the 331 // future; the lease is adopted verbatim. 332 store.noteIncomingReadCursor(gameID: gameID, presenceUntil: future) 333 #expect(entity.lastReadOtherMoveAt == future) 334 335 // That sibling leaves and publishes the current time. Under 336 // last-writer-wins the single shared scalar simply moves back to the 337 // close value — there is no per-device lease here to protect it. 338 store.noteIncomingReadCursor(gameID: gameID, presenceUntil: later) 339 #expect(entity.lastReadOtherMoveAt == later) 340 } 341 342 @Test("The unread badge tracks the read watermark, not the presence lease") 343 func badgeTracksWatermarkNotLease() throws { 344 let persistence = makeTestPersistence() 345 let store = makeTestStore( 346 persistence: persistence, 347 authorIDProvider: { Self.localAuthorID } 348 ) 349 let ctx = persistence.viewContext 350 let (entity, gameID) = try makeSharedGame(in: ctx) 351 352 let earlier = Date(timeIntervalSinceNow: -30) 353 let latest = Date(timeIntervalSinceNow: -10) 354 let future = Date(timeIntervalSinceNow: 10 * 60) 355 try addMovesRow( 356 for: entity, 357 gameID: gameID, 358 authorID: Self.otherAuthorID, 359 updatedAt: latest, 360 in: ctx 361 ) 362 store.noteIncomingMovesUpdate(gameIDs: [gameID], currentAuthorID: Self.localAuthorID) 363 #expect(entity.readThroughAt == nil) 364 #expect(store.unreadOtherMovesGameCount() == 1) 365 #expect(store.hasUnreadOtherMoves(gameID: gameID)) 366 367 // A future presence lease must NOT clear the badge — this is the bug: 368 // a leased-but-backgrounded reader had moves silently swallowed. 369 #expect(store.setReadCursor(gameID: gameID, presenceUntil: future)) 370 #expect(entity.lastReadOtherMoveAt == future) 371 #expect(store.unreadOtherMovesGameCount() == 1) 372 #expect(store.hasUnreadOtherMoves(gameID: gameID)) 373 374 // The watermark, older than the latest move, still leaves it unread. 375 #expect(store.advanceReadThrough(gameID: gameID, through: earlier)) 376 #expect(store.unreadOtherMovesGameCount() == 1) 377 #expect(store.hasUnreadOtherMoves(gameID: gameID)) 378 379 // The watermark catching the latest move is what clears the badge. 380 #expect(store.advanceReadThrough(gameID: gameID, through: latest)) 381 #expect(entity.readThroughAt == latest) 382 #expect(store.unreadOtherMovesGameCount() == 0) 383 #expect(!store.hasUnreadOtherMoves(gameID: gameID)) 384 } 385 386 /// Checks the summary and the store predicate agree: both must key off the 387 /// watermark alone, so a forward-dated presence lease cannot clear unread. 388 @Test("Read watermark overrides a newer presence lease") 389 func readWatermarkOverridesNewerPresenceLease() throws { 390 let persistence = makeTestPersistence() 391 let store = makeTestStore(persistence: persistence) 392 let ctx = persistence.viewContext 393 let (entity, gameID) = try makeSharedGame(in: ctx) 394 395 let readThrough = Date(timeIntervalSinceNow: -120) 396 let latest = Date(timeIntervalSinceNow: -60) 397 entity.latestOtherMoveAt = latest 398 entity.readThroughAt = readThrough 399 entity.lastReadOtherMoveAt = Date(timeIntervalSinceNow: 10 * 60) 400 try ctx.save() 401 402 let summary = try #require(GameSummary(entity: entity)) 403 #expect(summary.hasUnreadOtherMoves) 404 #expect(store.unreadOtherMovesGameCount() == 1) 405 #expect(store.hasUnreadOtherMoves(gameID: gameID)) 406 } 407 408 @Test("Active read leases refresh only when the horizon is below the floor") 409 func activeReadLeaseRefreshesAtFloor() throws { 410 let persistence = makeTestPersistence() 411 let store = makeTestStore(persistence: persistence) 412 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 413 414 let now = Date() 415 let farEnough = now.addingTimeInterval(6 * 60) 416 let floor = now.addingTimeInterval(5 * 60) 417 let refreshed = now.addingTimeInterval(10 * 60) 418 419 #expect(store.setReadCursor(gameID: gameID, presenceUntil: farEnough)) 420 #expect(!store.setReadCursor( 421 gameID: gameID, 422 presenceUntil: refreshed, 423 minimumExistingPresenceUntil: floor 424 )) 425 #expect(entity.lastReadOtherMoveAt == farEnough) 426 427 let tooClose = now.addingTimeInterval(4 * 60) 428 #expect(store.setReadCursor(gameID: gameID, presenceUntil: tooClose)) 429 #expect(store.setReadCursor( 430 gameID: gameID, 431 presenceUntil: refreshed, 432 minimumExistingPresenceUntil: floor 433 )) 434 #expect(entity.lastReadOtherMoveAt == refreshed) 435 } 436 437 @Test("Completed shared games show as unseen when a peer finished or resigned unseen") 438 func completedSharedGameSurfacesUnseen() throws { 439 let persistence = makeTestPersistence() 440 let store = makeTestStore(persistence: persistence) 441 let ctx = persistence.viewContext 442 443 // A peer's win or resignation is itself an unseen event: the move that 444 // finished the game lands as a later other-author move, so the finished 445 // game should flag as unread until the user opens it to review. 446 let (entity, gameID) = try makeSharedGame(in: ctx) 447 entity.completedAt = Date(timeIntervalSinceNow: -100) 448 try ctx.save() 449 450 try addMovesRow( 451 for: entity, 452 gameID: gameID, 453 authorID: Self.otherAuthorID, 454 updatedAt: Date(), 455 in: ctx 456 ) 457 458 store.noteIncomingMovesUpdate( 459 gameIDs: [gameID], 460 currentAuthorID: Self.localAuthorID 461 ) 462 463 let summary = try #require(GameSummary(entity: entity)) 464 #expect(summary.hasUnreadOtherMoves) 465 #expect(store.unreadOtherMovesGameCount() == 1) 466 467 // Reviewing the finished game advances the read watermark and clears it. 468 store.advanceReadThrough(gameID: gameID, through: Date()) 469 let reviewed = try #require(GameSummary(entity: entity)) 470 #expect(!reviewed.hasUnreadOtherMoves) 471 #expect(store.unreadOtherMovesGameCount() == 0) 472 } 473 474 @Test("Realtime cell edit updates the open game through the move merger") 475 func realtimeCellEditUpdatesOpenGame() throws { 476 let persistence = makeTestPersistence() 477 let store = makeTestStore( 478 persistence: persistence, 479 authorIDProvider: { Self.localAuthorID } 480 ) 481 let (_, gameID) = try makeSharedGame(in: persistence.viewContext) 482 let (game, _) = try store.loadGame(id: gameID) 483 let updatedAt = Date() 484 485 let applied = store.applyRealtimeCellEdit(RealtimeCellEdit( 486 gameID: gameID, 487 authorID: Self.localAuthorID, 488 deviceID: "remote-device", 489 row: 0, 490 col: 0, 491 letter: "Q", 492 mark: .none, 493 updatedAt: updatedAt, 494 cellAuthorID: Self.localAuthorID 495 )) 496 497 #expect(applied) 498 #expect(game.squares[0][0].entry == "Q") 499 #expect(game.squares[0][0].letterAuthorID == Self.localAuthorID) 500 } 501 502 @Test("Older realtime cell edit from the same device is ignored") 503 func olderRealtimeCellEditIsIgnored() throws { 504 let persistence = makeTestPersistence() 505 let store = makeTestStore( 506 persistence: persistence, 507 authorIDProvider: { Self.localAuthorID } 508 ) 509 let (_, gameID) = try makeSharedGame(in: persistence.viewContext) 510 let (game, _) = try store.loadGame(id: gameID) 511 let later = Date(timeIntervalSince1970: 200) 512 let earlier = Date(timeIntervalSince1970: 100) 513 514 #expect(store.applyRealtimeCellEdit(RealtimeCellEdit( 515 gameID: gameID, 516 authorID: Self.localAuthorID, 517 deviceID: "remote-device", 518 row: 0, 519 col: 0, 520 letter: "Q", 521 mark: .none, 522 updatedAt: later, 523 cellAuthorID: Self.localAuthorID 524 ))) 525 #expect(!store.applyRealtimeCellEdit(RealtimeCellEdit( 526 gameID: gameID, 527 authorID: Self.localAuthorID, 528 deviceID: "remote-device", 529 row: 0, 530 col: 0, 531 letter: "R", 532 mark: .none, 533 updatedAt: earlier, 534 cellAuthorID: Self.localAuthorID 535 ))) 536 537 #expect(game.squares[0][0].entry == "Q") 538 } 539 540 @Test("Opening a stale parser-version game reparses source and records current version") 541 func openingStaleParserVersionGameReparsesSource() throws { 542 let persistence = makeTestPersistence() 543 let store = makeTestStore(persistence: persistence) 544 let ctx = persistence.viewContext 545 let (entity, _) = try makeSharedGame(in: ctx) 546 entity.puzzleParserVersion = 0 547 entity.gridWidth = 0 548 entity.gridHeight = 0 549 entity.blockMask = nil 550 try ctx.save() 551 552 _ = try store.loadGame(id: entity.id!) 553 554 #expect(entity.puzzleParserVersion == Int64(XD.currentParserVersion)) 555 #expect(entity.gridWidth == 3) 556 #expect(entity.gridHeight == 3) 557 #expect(entity.blockMask?.count == 9) 558 } 559 }