GameStoreUnseenMovesTests.swift (5395B)
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("Opening a stale CmVer game reparses source and records current CmVer") 146 func openingStaleCmVerGameReparsesSource() throws { 147 let persistence = makeTestPersistence() 148 let store = makeTestStore(persistence: persistence) 149 let ctx = persistence.viewContext 150 let (entity, _) = try makeSharedGame(in: ctx) 151 entity.puzzleCmVersion = 0 152 entity.gridWidth = 0 153 entity.gridHeight = 0 154 entity.blockMask = nil 155 try ctx.save() 156 157 _ = try store.loadGame(id: entity.id!) 158 159 #expect(entity.puzzleCmVersion == Int64(XD.currentCmVersion)) 160 #expect(entity.gridWidth == 3) 161 #expect(entity.gridHeight == 3) 162 #expect(entity.blockMask?.count == 9) 163 } 164 }