GameStoreUnreadMovesTests.swift (25110B)
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 readAt 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 // `readAt` 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, readAt: earlier) 325 #expect(entity.lastReadOtherMoveAt == earlier) 326 327 store.noteIncomingReadCursor(gameID: gameID, readAt: 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, readAt: 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, readAt: 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, readAt: 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 @Test("Legacy read cursor backs pre-watermark rows") 387 func legacyReadCursorBacksPreWatermarkRows() throws { 388 let persistence = makeTestPersistence() 389 let store = makeTestStore(persistence: persistence) 390 let ctx = persistence.viewContext 391 let (entity, gameID) = try makeSharedGame(in: ctx) 392 393 let latest = Date(timeIntervalSinceNow: -60) 394 entity.latestOtherMoveAt = latest 395 entity.lastReadOtherMoveAt = latest 396 entity.readThroughAt = nil 397 try ctx.save() 398 399 let summary = try #require(GameSummary(entity: entity)) 400 #expect(!summary.hasUnreadOtherMoves) 401 #expect(store.unreadOtherMovesGameCount() == 0) 402 #expect(!store.hasUnreadOtherMoves(gameID: gameID)) 403 } 404 405 @Test("Read watermark overrides a newer legacy presence lease") 406 func readWatermarkOverridesNewerLegacyPresenceLease() throws { 407 let persistence = makeTestPersistence() 408 let store = makeTestStore(persistence: persistence) 409 let ctx = persistence.viewContext 410 let (entity, gameID) = try makeSharedGame(in: ctx) 411 412 let readThrough = Date(timeIntervalSinceNow: -120) 413 let latest = Date(timeIntervalSinceNow: -60) 414 entity.latestOtherMoveAt = latest 415 entity.readThroughAt = readThrough 416 entity.lastReadOtherMoveAt = Date(timeIntervalSinceNow: 10 * 60) 417 try ctx.save() 418 419 let summary = try #require(GameSummary(entity: entity)) 420 #expect(summary.hasUnreadOtherMoves) 421 #expect(store.unreadOtherMovesGameCount() == 1) 422 #expect(store.hasUnreadOtherMoves(gameID: gameID)) 423 } 424 425 @Test("Legacy read-through backfill clears phantom pre-watermark unread rows") 426 func legacyReadThroughBackfillClearsPhantomRows() throws { 427 let persistence = makeTestPersistence() 428 let store = makeTestStore(persistence: persistence) 429 let ctx = persistence.viewContext 430 let (entity, gameID) = try makeSharedGame(in: ctx) 431 432 let latest = Date(timeIntervalSinceNow: -60) 433 entity.latestOtherMoveAt = latest 434 entity.readThroughAt = nil 435 entity.lastReadOtherMoveAt = nil 436 try ctx.save() 437 438 #expect(store.unreadOtherMovesGameCount() == 1) 439 440 #expect(store.backfillLegacyReadThrough(excluding: []) == 1) 441 #expect(entity.readThroughAt == latest) 442 #expect(store.unreadOtherMovesGameCount() == 0) 443 #expect(!store.hasUnreadOtherMoves(gameID: gameID)) 444 } 445 446 @Test("Legacy read-through backfill preserves delivered unread games") 447 func legacyReadThroughBackfillPreservesDeliveredUnread() throws { 448 let persistence = makeTestPersistence() 449 let store = makeTestStore(persistence: persistence) 450 let ctx = persistence.viewContext 451 let (entity, gameID) = try makeSharedGame(in: ctx) 452 453 entity.latestOtherMoveAt = Date(timeIntervalSinceNow: -60) 454 entity.readThroughAt = nil 455 try ctx.save() 456 457 #expect(store.backfillLegacyReadThrough(excluding: [gameID]) == 0) 458 #expect(entity.readThroughAt == nil) 459 #expect(store.unreadOtherMovesGameCount() == 1) 460 } 461 462 @Test("Legacy read-through backfill advances stale watermarks") 463 func legacyReadThroughBackfillAdvancesStaleWatermarks() throws { 464 let persistence = makeTestPersistence() 465 let store = makeTestStore(persistence: persistence) 466 let ctx = persistence.viewContext 467 let (entity, gameID) = try makeSharedGame(in: ctx) 468 469 let readThrough = Date(timeIntervalSinceNow: -120) 470 let latest = Date(timeIntervalSinceNow: -60) 471 entity.latestOtherMoveAt = latest 472 entity.readThroughAt = readThrough 473 try ctx.save() 474 475 #expect(store.unreadOtherMovesGameCount() == 1) 476 477 #expect(store.backfillLegacyReadThrough(excluding: []) == 1) 478 #expect(entity.readThroughAt == latest) 479 #expect(store.unreadOtherMovesGameCount() == 0) 480 #expect(!store.hasUnreadOtherMoves(gameID: gameID)) 481 } 482 483 @Test("Legacy read-through backfill preserves delivered stale unread games") 484 func legacyReadThroughBackfillPreservesDeliveredStaleUnread() throws { 485 let persistence = makeTestPersistence() 486 let store = makeTestStore(persistence: persistence) 487 let ctx = persistence.viewContext 488 let (entity, gameID) = try makeSharedGame(in: ctx) 489 490 let readThrough = Date(timeIntervalSinceNow: -120) 491 entity.latestOtherMoveAt = Date(timeIntervalSinceNow: -60) 492 entity.readThroughAt = readThrough 493 try ctx.save() 494 495 #expect(store.backfillLegacyReadThrough(excluding: [gameID]) == 0) 496 #expect(entity.readThroughAt == readThrough) 497 #expect(store.unreadOtherMovesGameCount() == 1) 498 } 499 500 @Test("Active read leases refresh only when the horizon is below the floor") 501 func activeReadLeaseRefreshesAtFloor() throws { 502 let persistence = makeTestPersistence() 503 let store = makeTestStore(persistence: persistence) 504 let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) 505 506 let now = Date() 507 let farEnough = now.addingTimeInterval(6 * 60) 508 let floor = now.addingTimeInterval(5 * 60) 509 let refreshed = now.addingTimeInterval(10 * 60) 510 511 #expect(store.setReadCursor(gameID: gameID, readAt: farEnough)) 512 #expect(!store.setReadCursor( 513 gameID: gameID, 514 readAt: refreshed, 515 minimumExistingReadAt: floor 516 )) 517 #expect(entity.lastReadOtherMoveAt == farEnough) 518 519 let tooClose = now.addingTimeInterval(4 * 60) 520 #expect(store.setReadCursor(gameID: gameID, readAt: tooClose)) 521 #expect(store.setReadCursor( 522 gameID: gameID, 523 readAt: refreshed, 524 minimumExistingReadAt: floor 525 )) 526 #expect(entity.lastReadOtherMoveAt == refreshed) 527 } 528 529 @Test("Completed shared games show as unseen when a peer finished or resigned unseen") 530 func completedSharedGameSurfacesUnseen() throws { 531 let persistence = makeTestPersistence() 532 let store = makeTestStore(persistence: persistence) 533 let ctx = persistence.viewContext 534 535 // A peer's win or resignation is itself an unseen event: the move that 536 // finished the game lands as a later other-author move, so the finished 537 // game should flag as unread until the user opens it to review. 538 let (entity, gameID) = try makeSharedGame(in: ctx) 539 entity.completedAt = Date(timeIntervalSinceNow: -100) 540 try ctx.save() 541 542 try addMovesRow( 543 for: entity, 544 gameID: gameID, 545 authorID: Self.otherAuthorID, 546 updatedAt: Date(), 547 in: ctx 548 ) 549 550 store.noteIncomingMovesUpdate( 551 gameIDs: [gameID], 552 currentAuthorID: Self.localAuthorID 553 ) 554 555 let summary = try #require(GameSummary(entity: entity)) 556 #expect(summary.hasUnreadOtherMoves) 557 #expect(store.unreadOtherMovesGameCount() == 1) 558 559 // Reviewing the finished game advances the read watermark and clears it. 560 store.advanceReadThrough(gameID: gameID, through: Date()) 561 let reviewed = try #require(GameSummary(entity: entity)) 562 #expect(!reviewed.hasUnreadOtherMoves) 563 #expect(store.unreadOtherMovesGameCount() == 0) 564 } 565 566 @Test("Realtime cell edit updates the open game through the move merger") 567 func realtimeCellEditUpdatesOpenGame() throws { 568 let persistence = makeTestPersistence() 569 let store = makeTestStore( 570 persistence: persistence, 571 authorIDProvider: { Self.localAuthorID } 572 ) 573 let (_, gameID) = try makeSharedGame(in: persistence.viewContext) 574 let (game, _) = try store.loadGame(id: gameID) 575 let updatedAt = Date() 576 577 let applied = store.applyRealtimeCellEdit(RealtimeCellEdit( 578 gameID: gameID, 579 authorID: Self.localAuthorID, 580 deviceID: "remote-device", 581 row: 0, 582 col: 0, 583 letter: "Q", 584 mark: .none, 585 updatedAt: updatedAt, 586 cellAuthorID: Self.localAuthorID 587 )) 588 589 #expect(applied) 590 #expect(game.squares[0][0].entry == "Q") 591 #expect(game.squares[0][0].letterAuthorID == Self.localAuthorID) 592 } 593 594 @Test("Older realtime cell edit from the same device is ignored") 595 func olderRealtimeCellEditIsIgnored() throws { 596 let persistence = makeTestPersistence() 597 let store = makeTestStore( 598 persistence: persistence, 599 authorIDProvider: { Self.localAuthorID } 600 ) 601 let (_, gameID) = try makeSharedGame(in: persistence.viewContext) 602 let (game, _) = try store.loadGame(id: gameID) 603 let later = Date(timeIntervalSince1970: 200) 604 let earlier = Date(timeIntervalSince1970: 100) 605 606 #expect(store.applyRealtimeCellEdit(RealtimeCellEdit( 607 gameID: gameID, 608 authorID: Self.localAuthorID, 609 deviceID: "remote-device", 610 row: 0, 611 col: 0, 612 letter: "Q", 613 mark: .none, 614 updatedAt: later, 615 cellAuthorID: Self.localAuthorID 616 ))) 617 #expect(!store.applyRealtimeCellEdit(RealtimeCellEdit( 618 gameID: gameID, 619 authorID: Self.localAuthorID, 620 deviceID: "remote-device", 621 row: 0, 622 col: 0, 623 letter: "R", 624 mark: .none, 625 updatedAt: earlier, 626 cellAuthorID: Self.localAuthorID 627 ))) 628 629 #expect(game.squares[0][0].entry == "Q") 630 } 631 632 @Test("Opening a stale CmVer game reparses source and records current CmVer") 633 func openingStaleCmVerGameReparsesSource() throws { 634 let persistence = makeTestPersistence() 635 let store = makeTestStore(persistence: persistence) 636 let ctx = persistence.viewContext 637 let (entity, _) = try makeSharedGame(in: ctx) 638 entity.puzzleCmVersion = 0 639 entity.gridWidth = 0 640 entity.gridHeight = 0 641 entity.blockMask = nil 642 try ctx.save() 643 644 _ = try store.loadGame(id: entity.id!) 645 646 #expect(entity.puzzleCmVersion == Int64(XD.currentCmVersion)) 647 #expect(entity.gridWidth == 3) 648 #expect(entity.gridHeight == 3) 649 #expect(entity.blockMask?.count == 9) 650 } 651 }