MoveLogTests.swift (9137B)
1 import CloudKit 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 @Suite("MoveLog.replay") 8 struct MoveLogReplayTests { 9 10 private let gameID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! 11 12 private func move( 13 lamport: Int64, 14 row: Int, 15 col: Int, 16 letter: String, 17 author: String? = nil 18 ) -> Move { 19 Move( 20 gameID: gameID, 21 lamport: lamport, 22 row: row, 23 col: col, 24 letter: letter, 25 markKind: 0, 26 checkedWrong: false, 27 authorID: author, 28 createdAt: Date(timeIntervalSince1970: TimeInterval(lamport)) 29 ) 30 } 31 32 @Test("Empty inputs produce an empty grid") 33 func emptyInputs() { 34 let grid = MoveLog.replay(snapshot: nil, moves: []) 35 #expect(grid.isEmpty) 36 } 37 38 @Test("Moves without a snapshot are applied in lamport order") 39 func movesOnly() { 40 let grid = MoveLog.replay( 41 snapshot: nil, 42 moves: [ 43 move(lamport: 1, row: 0, col: 0, letter: "A"), 44 move(lamport: 2, row: 0, col: 1, letter: "B"), 45 ] 46 ) 47 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") 48 #expect(grid[GridPosition(row: 0, col: 1)]?.letter == "B") 49 } 50 51 @Test("Later lamport for the same cell overwrites the earlier one") 52 func laterMoveWins() { 53 let grid = MoveLog.replay( 54 snapshot: nil, 55 moves: [ 56 move(lamport: 1, row: 0, col: 0, letter: "A"), 57 move(lamport: 2, row: 0, col: 0, letter: "B"), 58 ] 59 ) 60 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "B") 61 } 62 63 @Test("Moves are reordered by lamport even when input is shuffled") 64 func inputOrderIndependent() { 65 let grid = MoveLog.replay( 66 snapshot: nil, 67 moves: [ 68 move(lamport: 3, row: 0, col: 0, letter: "C"), 69 move(lamport: 1, row: 0, col: 0, letter: "A"), 70 move(lamport: 2, row: 0, col: 0, letter: "B"), 71 ] 72 ) 73 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "C") 74 } 75 76 @Test("Empty-letter move clears the cell's letter but retains the slot") 77 func clearingMovePreservesSlot() { 78 let grid = MoveLog.replay( 79 snapshot: nil, 80 moves: [ 81 move(lamport: 1, row: 0, col: 0, letter: "A", author: "alice"), 82 move(lamport: 2, row: 0, col: 0, letter: "", author: "alice"), 83 ] 84 ) 85 let cell = grid[GridPosition(row: 0, col: 0)] 86 #expect(cell?.letter == "") 87 #expect(cell?.authorID == "alice") 88 } 89 90 @Test("Snapshot seeds the grid and moves past its cutoff are applied") 91 func snapshotBaseAndTail() { 92 let snapshot = Snapshot( 93 gameID: gameID, 94 upToLamport: 5, 95 grid: [ 96 GridPosition(row: 0, col: 0): GridCell( 97 letter: "A", 98 markKind: 0, 99 checkedWrong: false, 100 authorID: nil 101 ) 102 ], 103 createdAt: Date() 104 ) 105 let grid = MoveLog.replay( 106 snapshot: snapshot, 107 moves: [ 108 move(lamport: 6, row: 1, col: 0, letter: "B"), 109 ] 110 ) 111 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") 112 #expect(grid[GridPosition(row: 1, col: 0)]?.letter == "B") 113 } 114 115 @Test("Moves at or below the snapshot cutoff are skipped") 116 func snapshotCutoffSkipsOlderMoves() { 117 let snapshot = Snapshot( 118 gameID: gameID, 119 upToLamport: 5, 120 grid: [ 121 GridPosition(row: 0, col: 0): GridCell( 122 letter: "Z", 123 markKind: 0, 124 checkedWrong: false, 125 authorID: nil 126 ) 127 ], 128 createdAt: Date() 129 ) 130 let grid = MoveLog.replay( 131 snapshot: snapshot, 132 moves: [ 133 // Lamport 3 is already folded into the snapshot; it must 134 // not re-apply on top and revert the cell. 135 move(lamport: 3, row: 0, col: 0, letter: "A"), 136 ] 137 ) 138 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "Z") 139 } 140 141 @Test("Latest snapshot picks the highest upToLamport") 142 func latestSnapshotSelection() { 143 let a = Snapshot(gameID: gameID, upToLamport: 10, grid: [:], createdAt: Date()) 144 let b = Snapshot(gameID: gameID, upToLamport: 50, grid: [:], createdAt: Date()) 145 let c = Snapshot(gameID: gameID, upToLamport: 25, grid: [:], createdAt: Date()) 146 #expect(MoveLog.latestSnapshot(from: [a, b, c]) == b) 147 } 148 } 149 150 @Suite("MoveLog grid state codec") 151 struct GridStateCodecTests { 152 153 @Test("Round-trip preserves all cell fields") 154 func roundTripPreservesFields() throws { 155 let grid: GridState = [ 156 GridPosition(row: 0, col: 0): GridCell( 157 letter: "A", markKind: 2, checkedWrong: true, authorID: "alice" 158 ), 159 GridPosition(row: 4, col: 7): GridCell( 160 letter: "", markKind: 0, checkedWrong: false, authorID: nil 161 ), 162 ] 163 let data = try MoveLog.encodeGridState(grid) 164 let decoded = try MoveLog.decodeGridState(data) 165 #expect(decoded == grid) 166 } 167 168 @Test("Encoding sorts entries in row-major, col-minor order regardless of dictionary iteration order") 169 func encodingSortsEntries() throws { 170 // Dictionary iteration order is unspecified; encoding must sort. 171 let grid: GridState = [ 172 GridPosition(row: 2, col: 1): GridCell( 173 letter: "X", markKind: 0, checkedWrong: false, authorID: nil 174 ), 175 GridPosition(row: 0, col: 0): GridCell( 176 letter: "A", markKind: 0, checkedWrong: false, authorID: nil 177 ), 178 ] 179 let payload = try JSONDecoder().decode( 180 MoveLog.GridStatePayload.self, 181 from: MoveLog.encodeGridState(grid) 182 ) 183 #expect(payload.entries.map { GridPosition(row: $0.row, col: $0.col) } == [ 184 GridPosition(row: 0, col: 0), 185 GridPosition(row: 2, col: 1), 186 ]) 187 } 188 } 189 190 @Suite("RecordSerializer Move/Snapshot") 191 struct RecordSerializerMoveSnapshotTests { 192 193 private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! 194 private var zoneID: CKRecordZone.ID { RecordSerializer.zoneID(for: gameID) } 195 196 @Test("Move record name uses the expected format") 197 func moveRecordNameFormat() { 198 let name = RecordSerializer.recordName(forMoveInGame: gameID, lamport: 42) 199 #expect(name == "move-\(gameID.uuidString)-42-\(RecordSerializer.localDeviceID)") 200 } 201 202 @Test("Snapshot record name uses the expected format") 203 func snapshotRecordNameFormat() { 204 let name = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: 100) 205 #expect(name == "snapshot-\(gameID.uuidString)-100-\(RecordSerializer.localDeviceID)") 206 } 207 208 @Test("Move record round-trips through CKRecord fields") 209 func moveRecordRoundTrip() { 210 let move = Move( 211 gameID: gameID, 212 lamport: 17, 213 row: 3, 214 col: 5, 215 letter: "Q", 216 markKind: 1, 217 checkedWrong: true, 218 authorID: "alice", 219 createdAt: Date(timeIntervalSince1970: 1_700_000_000) 220 ) 221 let record = RecordSerializer.moveRecord( 222 from: move, 223 zone: zoneID, 224 systemFields: nil 225 ) 226 let parsed = RecordSerializer.parseMoveRecord(record) 227 #expect(parsed == move) 228 } 229 230 @Test("Snapshot record round-trips through CKRecord fields") 231 func snapshotRecordRoundTrip() throws { 232 let grid: GridState = [ 233 GridPosition(row: 0, col: 0): GridCell( 234 letter: "A", markKind: 0, checkedWrong: false, authorID: "alice" 235 ), 236 GridPosition(row: 1, col: 2): GridCell( 237 letter: "B", markKind: 2, checkedWrong: true, authorID: nil 238 ), 239 ] 240 let snapshot = Snapshot( 241 gameID: gameID, 242 upToLamport: 42, 243 grid: grid, 244 createdAt: Date(timeIntervalSince1970: 1_700_000_000) 245 ) 246 let record = try RecordSerializer.snapshotRecord( 247 from: snapshot, 248 zone: zoneID, 249 systemFields: nil 250 ) 251 let parsed = RecordSerializer.parseSnapshotRecord(record) 252 #expect(parsed == snapshot) 253 } 254 255 @Test("Parsing rejects records with the wrong record type") 256 func parseRejectsWrongRecordType() { 257 let zoneID = RecordSerializer.zoneID(for: gameID) 258 let recordID = CKRecord.ID(recordName: RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1), zoneID: zoneID) 259 let record = CKRecord(recordType: "Cell", recordID: recordID) 260 #expect(RecordSerializer.parseMoveRecord(record) == nil) 261 } 262 }