GameStoreCompletionLockTests.swift (5150B)
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 }