GameStoreCompletionLockTests.swift (8859B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 /// Completion makes a game terminal. Opening it seals the displayed grid to the 8 /// puzzle solution — so post-completion merge drift (a late clear, clock skew, 9 /// or an edit that reached a peer over engagement but never synced) can't leave 10 /// a hole or stray letter in a "finished" puzzle — and the returned mutator is 11 /// locked against further input. 12 @Suite("GameStore completion lock", .isolatedNotificationState) 13 @MainActor 14 struct GameStoreCompletionLockTests { 15 16 private static let authorID = "alice" 17 private static let puzzleSource = """ 18 Title: Test Puzzle 19 Author: Test 20 21 22 AB 23 CD 24 25 26 A1. Across 1 ~ AB 27 A3. Across 3 ~ CD 28 D1. Down 1 ~ AC 29 D2. Down 2 ~ BD 30 """ 31 32 private func makeGame( 33 completed: Bool, 34 in ctx: NSManagedObjectContext 35 ) throws -> (GameEntity, UUID) { 36 let xd = try XD.parse(Self.puzzleSource) 37 let puzzle = Puzzle(xd: xd) 38 let gameID = UUID() 39 let entity = GameEntity(context: ctx) 40 entity.id = gameID 41 entity.title = "Test" 42 entity.puzzleSource = Self.puzzleSource 43 entity.createdAt = Date() 44 entity.updatedAt = Date() 45 if completed { entity.completedAt = Date() } 46 entity.ckRecordName = "game-\(gameID.uuidString)" 47 entity.populateCachedSummaryFields(from: puzzle) 48 try ctx.save() 49 return (entity, gameID) 50 } 51 52 /// Writes a single local-device Moves row carrying `cells` (position → letter). 53 private func writeMoves( 54 for entity: GameEntity, 55 gameID: UUID, 56 cells: [GridPosition: String], 57 in ctx: NSManagedObjectContext 58 ) throws { 59 let row = MovesEntity(context: ctx) 60 row.game = entity 61 row.authorID = Self.authorID 62 row.deviceID = "device-a" 63 row.ckRecordName = RecordSerializer.recordName( 64 forMovesInGame: gameID, 65 authorID: Self.authorID, 66 deviceID: "device-a" 67 ) 68 let now = Date() 69 var encoded: [GridPosition: TimestampedCell] = [:] 70 for (pos, letter) in cells { 71 encoded[pos] = TimestampedCell( 72 letter: letter, mark: .none, updatedAt: now, authorID: Self.authorID 73 ) 74 } 75 row.cells = try MovesCodec.encode(encoded) 76 row.updatedAt = now 77 try ctx.save() 78 } 79 80 @Test("A completed game seals a hole and a stray wrong letter to the solution") 81 func sealsToSolution() throws { 82 let persistence = makeTestPersistence() 83 let store = makeTestStore(persistence: persistence) 84 let ctx = persistence.viewContext 85 let (entity, gameID) = try makeGame(completed: true, in: ctx) 86 87 // (0,0) correct, (0,1) a stray wrong letter, (1,0) correct, (1,1) a 88 // hole — the post-completion drift the seal has to repair. 89 try writeMoves( 90 for: entity, gameID: gameID, 91 cells: [ 92 GridPosition(row: 0, col: 0): "A", 93 GridPosition(row: 0, col: 1): "Z", 94 GridPosition(row: 1, col: 0): "C" 95 ], 96 in: ctx 97 ) 98 99 let (game, _) = try store.loadGame(id: gameID) 100 101 #expect(game.squares[0][0].entry == "A") // correct — kept 102 #expect(game.squares[0][1].entry == "B") // stray — overwritten 103 #expect(game.squares[1][0].entry == "C") // correct — kept 104 #expect(game.squares[1][1].entry == "D") // hole — filled 105 #expect(game.completionState == .solved) 106 } 107 108 @Test("Loading a completed game returns a locked mutator that rejects input") 109 func loadedMutatorIsLocked() throws { 110 let persistence = makeTestPersistence() 111 let store = makeTestStore(persistence: persistence) 112 let ctx = persistence.viewContext 113 let (entity, gameID) = try makeGame(completed: true, in: ctx) 114 try writeMoves( 115 for: entity, gameID: gameID, 116 cells: [GridPosition(row: 0, col: 0): "A"], 117 in: ctx 118 ) 119 120 let (game, mutator) = try store.loadGame(id: gameID) 121 #expect(mutator.isCompleted) 122 123 mutator.setLetter("Z", atRow: 1, atCol: 1, pencil: false) 124 #expect(game.squares[1][1].entry == "D") // seal stands; input rejected 125 } 126 127 @Test("An incomplete game is not sealed — an untouched hole stays empty") 128 func incompleteGameNotSealed() throws { 129 let persistence = makeTestPersistence() 130 let store = makeTestStore(persistence: persistence) 131 let ctx = persistence.viewContext 132 let (entity, gameID) = try makeGame(completed: false, in: ctx) 133 try writeMoves( 134 for: entity, gameID: gameID, 135 cells: [GridPosition(row: 0, col: 0): "A"], 136 in: ctx 137 ) 138 139 let (game, mutator) = try store.loadGame(id: gameID) 140 141 #expect(game.squares[0][0].entry == "A") 142 #expect(game.squares[1][1].entry == "") // untouched hole stays empty 143 #expect(mutator.isCompleted == false) 144 #expect(game.completionState != .solved) 145 } 146 147 @Test("Opening an owned legacy game upgrades it to the current sync version") 148 func ownerOpenUpgradesSyncVersion() throws { 149 let persistence = makeTestPersistence() 150 var enqueuedRecordNames: [String] = [] 151 let store = makeTestStore( 152 persistence: persistence, 153 onGameUpdated: { enqueuedRecordNames.append($0) } 154 ) 155 let ctx = persistence.viewContext 156 let (entity, gameID) = try makeGame(completed: false, in: ctx) 157 entity.databaseScope = DatabaseScope.private.rawValue 158 entity.syncVersion = GameSyncVersion.legacy 159 try ctx.save() 160 161 _ = try store.loadGame(id: gameID) 162 163 #expect(entity.syncVersion == GameSyncVersion.current) 164 #expect(entity.hasPendingSave) 165 #expect(enqueuedRecordNames == ["game-\(gameID.uuidString)"]) 166 } 167 168 @Test("Opening a participant legacy game waits for the owner's upgrade") 169 func participantOpenDoesNotUpgradeSyncVersion() throws { 170 let persistence = makeTestPersistence() 171 var enqueuedRecordNames: [String] = [] 172 let store = makeTestStore( 173 persistence: persistence, 174 onGameUpdated: { enqueuedRecordNames.append($0) } 175 ) 176 let ctx = persistence.viewContext 177 let (entity, gameID) = try makeGame(completed: false, in: ctx) 178 entity.databaseScope = DatabaseScope.shared.rawValue 179 entity.syncVersion = GameSyncVersion.legacy 180 try ctx.save() 181 182 _ = try store.loadGame(id: gameID) 183 184 #expect(entity.syncVersion == GameSyncVersion.legacy) 185 #expect(!entity.hasPendingSave) 186 #expect(enqueuedRecordNames.isEmpty) 187 } 188 189 @Test("Completion delivery waits for both Game and Moves, then journal") 190 func completionDeliveryGates() { 191 var progress = CompletionDeliveryProgress(resigned: false) 192 #expect(!progress.canDeliverCompletion) 193 194 progress.durableRecords.insert(.game) 195 #expect(!progress.canDeliverCompletion) 196 progress.durableRecords.insert(.journal) 197 #expect(!progress.canDeliverCompletion) 198 199 progress.durableRecords.insert(.moves) 200 #expect(progress.canDeliverCompletion) 201 #expect(!progress.canDeliverReplay) 202 203 progress.completionDelivered = true 204 #expect(progress.canDeliverReplay) 205 progress.replayDelivered = true 206 #expect(!progress.canDeliverReplay) 207 } 208 209 @Test("A pre-completion Moves acknowledgement cannot open the gate") 210 func completionDeliveryOrdering() { 211 var progress = CompletionDeliveryProgress(resigned: false) 212 progress.acknowledge([.moves, .journal]) 213 #expect(progress.durableRecords.isEmpty) 214 progress.acknowledge([.game]) 215 #expect(!progress.canDeliverCompletion) 216 progress.acknowledge([.moves, .journal]) 217 #expect(progress.canDeliverCompletion) 218 #expect(progress.durableRecords.contains(.journal)) 219 } 220 221 @Test("Only the device that initiates completion stages a peer notification") 222 func completionCallbackDistinguishesObservedSolve() throws { 223 let persistence = makeTestPersistence() 224 let store = makeTestStore( 225 persistence: persistence, 226 authorIDProvider: { Self.authorID } 227 ) 228 let (_, gameID) = try makeGame(completed: false, in: persistence.viewContext) 229 var callback: (resigned: Bool, notifyPeers: Bool)? 230 store.onJournalComplete = { _, _, resigned, notifyPeers in 231 callback = (resigned, notifyPeers) 232 } 233 234 #expect(try store.markCompletedFromObservedSolvedState(id: gameID)) 235 #expect(callback?.resigned == false) 236 #expect(callback?.notifyPeers == false) 237 } 238 }