crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 17e71e9b4e23fd091e86ff2293900ec3f19a68d8
parent e2a65536e80906b0a95ea0eda1f79de531705f47
Author: Michael Camilleri <[email protected]>
Date:   Wed, 22 Apr 2026 18:36:13 +0900

Begin process of moving to snapshots and move log

To make Crossmate work better with CloudKit, the synchronisation logic
is moving away from separate records for updates to cells in puzzles.
Instead, a combination of snapshots and a move log will try to reduce
the number of separate CloudKit operations.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 32++++++++++++++++++++++++++++++++
ACrossmate/Sync/MoveLog.swift | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecordSerializer.swift | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/MoveLogTests.swift | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 588 insertions(+), 0 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; }; 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; }; + 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */; }; 2604F612080A211A8D249237 /* PushDebouncerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D859D183886DEE009E5495B /* PushDebouncerTests.swift */; }; 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; }; 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; }; @@ -25,6 +26,7 @@ 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; + 7E54EC2E507C3BFD615FD621 /* MoveLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7422F19AA1F1692A98E3602 /* MoveLog.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; @@ -90,6 +92,7 @@ 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; }; + 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLogTests.swift; sourceTree = "<group>"; }; 5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; }; 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeTests.swift; sourceTree = "<group>"; }; 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; @@ -133,6 +136,7 @@ EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; F13AB28AA016F8A3DF53E6AA /* OutboxRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxRecorder.swift; sourceTree = "<group>"; }; F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; }; + F7422F19AA1F1692A98E3602 /* MoveLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLog.swift; sourceTree = "<group>"; }; F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -150,6 +154,7 @@ 074C2962E79CAE6C0EA6431A /* Sync */ = { isa = PBXGroup; children = ( + F7422F19AA1F1692A98E3602 /* MoveLog.swift */, E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */, 7D1A5FDF357F6541B4D485AE /* PushDebouncer.swift */, 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, @@ -173,6 +178,7 @@ isa = PBXGroup; children = ( BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, + 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */, 8D859D183886DEE009E5495B /* PushDebouncerTests.swift */, @@ -392,6 +398,7 @@ buildActionMask = 2147483647; files = ( 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, + 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, 83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */, 2604F612080A211A8D249237 /* PushDebouncerTests.swift in Sources */, @@ -422,6 +429,7 @@ 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */, + 7E54EC2E507C3BFD615FD621 /* MoveLog.swift in Sources */, 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */, FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */, D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */, diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -7,11 +7,43 @@ <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="lamportHighWater" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="puzzleSource" attributeType="String"/> <attribute name="title" attributeType="String"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <relationship name="cells" toMany="YES" deletionRule="Cascade" destinationEntity="CellEntity" inverseName="game" inverseEntity="CellEntity"/> + <relationship name="moves" toMany="YES" deletionRule="Cascade" destinationEntity="MoveEntity" inverseName="game" inverseEntity="MoveEntity"/> + <relationship name="snapshots" toMany="YES" deletionRule="Cascade" destinationEntity="SnapshotEntity" inverseName="game" inverseEntity="SnapshotEntity"/> + </entity> + <entity name="MoveEntity" representedClassName="MoveEntity" syncable="YES" codeGenerationType="class"> + <attribute name="authorID" optional="YES" attributeType="String"/> + <attribute name="checkedWrong" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="ckRecordName" attributeType="String"/> + <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> + <attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="lamport" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="letter" attributeType="String" defaultValueString=""/> + <attribute name="markKind" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="row" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="moves" inverseEntity="GameEntity"/> + <fetchIndex name="byGameAndLamport"> + <fetchIndexElement property="game" type="Binary" order="ascending"/> + <fetchIndexElement property="lamport" type="Binary" order="ascending"/> + </fetchIndex> + </entity> + <entity name="SnapshotEntity" representedClassName="SnapshotEntity" syncable="YES" codeGenerationType="class"> + <attribute name="ckRecordName" attributeType="String"/> + <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="gridState" attributeType="Binary"/> + <attribute name="upToLamport" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="snapshots" inverseEntity="GameEntity"/> + <fetchIndex name="byGameAndLamport"> + <fetchIndexElement property="game" type="Binary" order="ascending"/> + <fetchIndexElement property="upToLamport" type="Binary" order="ascending"/> + </fetchIndex> </entity> <entity name="CellEntity" representedClassName="CellEntity" syncable="YES" codeGenerationType="class"> <attribute name="checkedWrong" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> diff --git a/Crossmate/Sync/MoveLog.swift b/Crossmate/Sync/MoveLog.swift @@ -0,0 +1,129 @@ +import Foundation + +/// A single cell-state mutation in the move log. Append-only: a move is +/// never rewritten, only superseded by a later move at the same position. +struct Move: Equatable, Sendable { + let gameID: UUID + let lamport: Int64 + let row: Int + let col: Int + let letter: String + let markKind: Int16 + let checkedWrong: Bool + let authorID: String? + let createdAt: Date +} + +/// A compacted grid state at a lamport boundary. Every move with +/// `lamport <= upToLamport` is already folded into `grid`, so those moves +/// can be deleted once the snapshot has been durably stored. +struct Snapshot: Equatable, Sendable { + let gameID: UUID + let upToLamport: Int64 + let grid: GridState + let createdAt: Date +} + +/// A touched cell in the grid. Cells the user has never entered a letter +/// or mark into are absent from `GridState` entirely. +struct GridCell: Equatable, Sendable, Codable { + var letter: String + var markKind: Int16 + var checkedWrong: Bool + var authorID: String? +} + +struct GridPosition: Hashable, Sendable, Codable { + let row: Int + let col: Int +} + +typealias GridState = [GridPosition: GridCell] + +enum MoveLog { + /// Deterministic replay: fold `moves` on top of an optional base + /// `snapshot` to produce the current grid state. Moves with + /// `lamport <= snapshot.upToLamport` are skipped — they are already + /// folded into the snapshot. Input order doesn't matter; the function + /// sorts by lamport internally. + static func replay(snapshot: Snapshot?, moves: [Move]) -> GridState { + var grid: GridState = snapshot?.grid ?? [:] + let cutoff: Int64 = snapshot?.upToLamport ?? 0 + let ordered = moves + .filter { $0.lamport > cutoff } + .sorted { $0.lamport < $1.lamport } + + for move in ordered { + let position = GridPosition(row: move.row, col: move.col) + grid[position] = GridCell( + letter: move.letter, + markKind: move.markKind, + checkedWrong: move.checkedWrong, + authorID: move.authorID + ) + } + return grid + } + + /// Returns the most recent snapshot by `upToLamport`, or `nil` if the + /// input is empty. Ties are broken by `createdAt` so two snapshots at + /// the same lamport don't produce a nondeterministic winner. + static func latestSnapshot(from snapshots: [Snapshot]) -> Snapshot? { + snapshots.max { lhs, rhs in + if lhs.upToLamport != rhs.upToLamport { + return lhs.upToLamport < rhs.upToLamport + } + return lhs.createdAt < rhs.createdAt + } + } + + /// Wire format for `Snapshot.grid`. A flat array of entries keeps the + /// encoding `JSONEncoder`-friendly (dicts keyed by `GridPosition` + /// would need a custom encoder) and round-trips cleanly. + struct GridStatePayload: Codable, Equatable { + struct Entry: Codable, Equatable { + let row: Int + let col: Int + let letter: String + let markKind: Int16 + let checkedWrong: Bool + let authorID: String? + } + let entries: [Entry] + } + + static func encodeGridState(_ grid: GridState) throws -> Data { + let entries = grid + .map { position, cell in + GridStatePayload.Entry( + row: position.row, + col: position.col, + letter: cell.letter, + markKind: cell.markKind, + checkedWrong: cell.checkedWrong, + authorID: cell.authorID + ) + } + // Sort for determinism so identical grids encode to identical + // bytes — matters for tests and for diffing snapshots. + .sorted { lhs, rhs in + lhs.row != rhs.row ? lhs.row < rhs.row : lhs.col < rhs.col + } + return try JSONEncoder().encode(GridStatePayload(entries: entries)) + } + + static func decodeGridState(_ data: Data) throws -> GridState { + let payload = try JSONDecoder().decode(GridStatePayload.self, from: data) + var grid: GridState = [:] + for entry in payload.entries { + let position = GridPosition(row: entry.row, col: entry.col) + grid[position] = GridCell( + letter: entry.letter, + markKind: entry.markKind, + checkedWrong: entry.checkedWrong, + authorID: entry.authorID + ) + } + return grid + } +} diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -17,6 +17,14 @@ enum RecordSerializer { "cell-\(gameID.uuidString)-\(row)-\(col)" } + static func recordName(forMoveInGame gameID: UUID, lamport: Int64) -> String { + "move-\(gameID.uuidString)-\(lamport)" + } + + static func recordName(forSnapshotInGame gameID: UUID, upToLamport: Int64) -> String { + "snapshot-\(gameID.uuidString)-\(upToLamport)" + } + // MARK: - Zone static func zone() -> CKRecordZone { @@ -83,6 +91,139 @@ enum RecordSerializer { return record } + // MARK: - Move / Snapshot record building + + static func moveRecord( + from move: Move, + zone: CKRecordZone.ID, + systemFields: Data? + ) -> CKRecord { + let moveName = recordName(forMoveInGame: move.gameID, lamport: move.lamport) + let record = restoreOrCreate( + recordType: "Move", + recordName: moveName, + zone: zone, + systemFields: systemFields + ) + + record["lamport"] = move.lamport as CKRecordValue + record["row"] = Int64(move.row) as CKRecordValue + record["col"] = Int64(move.col) as CKRecordValue + record["letter"] = move.letter as CKRecordValue + record["markKind"] = Int64(move.markKind) as CKRecordValue + record["checkedWrong"] = move.checkedWrong as CKRecordValue + record["authorID"] = move.authorID as CKRecordValue? + record["createdAt"] = move.createdAt as CKRecordValue + + let gameName = recordName(forGameID: move.gameID) + let parentID = CKRecord.ID(recordName: gameName, zoneID: zone) + record.parent = CKRecord.Reference(recordID: parentID, action: .none) + return record + } + + static func snapshotRecord( + from snapshot: Snapshot, + zone: CKRecordZone.ID, + systemFields: Data? + ) throws -> CKRecord { + let snapshotName = recordName( + forSnapshotInGame: snapshot.gameID, + upToLamport: snapshot.upToLamport + ) + let record = restoreOrCreate( + recordType: "Snapshot", + recordName: snapshotName, + zone: zone, + systemFields: systemFields + ) + + record["upToLamport"] = snapshot.upToLamport as CKRecordValue + record["createdAt"] = snapshot.createdAt as CKRecordValue + record["gridState"] = try MoveLog.encodeGridState(snapshot.grid) as CKRecordValue + + let gameName = recordName(forGameID: snapshot.gameID) + let parentID = CKRecord.ID(recordName: gameName, zoneID: zone) + record.parent = CKRecord.Reference(recordID: parentID, action: .none) + return record + } + + /// Parses an incoming `Move` CKRecord into a value type without + /// touching Core Data. Returns `nil` if required fields are missing or + /// the record name doesn't match the `move-<gameUUID>-<lamport>` shape. + static func parseMoveRecord(_ record: CKRecord) -> Move? { + guard record.recordType == "Move" else { return nil } + guard let (gameID, _) = parseMoveRecordName(record.recordID.recordName) else { + return nil + } + guard let lamport = record["lamport"] as? Int64 else { return nil } + let row = (record["row"] as? Int64).map(Int.init) ?? 0 + let col = (record["col"] as? Int64).map(Int.init) ?? 0 + let letter = record["letter"] as? String ?? "" + let markKind = Int16((record["markKind"] as? Int64) ?? 0) + let checkedWrong = (record["checkedWrong"] as? Bool) ?? false + let authorID = record["authorID"] as? String + let createdAt = record["createdAt"] as? Date ?? record.creationDate ?? Date() + + return Move( + gameID: gameID, + lamport: lamport, + row: row, + col: col, + letter: letter, + markKind: markKind, + checkedWrong: checkedWrong, + authorID: authorID, + createdAt: createdAt + ) + } + + /// Parses an incoming `Snapshot` CKRecord into a value type. Returns + /// `nil` if required fields are missing or the grid state payload + /// fails to decode. + static func parseSnapshotRecord(_ record: CKRecord) -> Snapshot? { + guard record.recordType == "Snapshot" else { return nil } + guard let (gameID, _) = parseSnapshotRecordName(record.recordID.recordName) else { + return nil + } + guard let upToLamport = record["upToLamport"] as? Int64, + let data = record["gridState"] as? Data, + let grid = try? MoveLog.decodeGridState(data) + else { return nil } + + let createdAt = record["createdAt"] as? Date ?? record.creationDate ?? Date() + return Snapshot( + gameID: gameID, + upToLamport: upToLamport, + grid: grid, + createdAt: createdAt + ) + } + + private static func parseMoveRecordName(_ name: String) -> (UUID, Int64)? { + parseGameScopedRecordName(name, prefix: "move-") + } + + private static func parseSnapshotRecordName(_ name: String) -> (UUID, Int64)? { + parseGameScopedRecordName(name, prefix: "snapshot-") + } + + private static func parseGameScopedRecordName( + _ name: String, + prefix: String + ) -> (UUID, Int64)? { + guard name.hasPrefix(prefix) else { return nil } + let rest = name.dropFirst(prefix.count) + // `<UUID>-<Int64>`. UUID itself contains dashes, so split from the + // last separator rather than naively on every dash. + guard let lastDash = rest.lastIndex(of: "-") else { return nil } + let uuidPart = String(rest[rest.startIndex..<lastDash]) + let lamportPart = String(rest[rest.index(after: lastDash)...]) + guard let gameID = UUID(uuidString: uuidPart), + let lamport = Int64(lamportPart) + else { return nil } + return (gameID, lamport) + } + // MARK: - Applying incoming CKRecords to Core Data static func applyGameRecord( diff --git a/Tests/Unit/MoveLogTests.swift b/Tests/Unit/MoveLogTests.swift @@ -0,0 +1,278 @@ +import CloudKit +import Foundation +import Testing + +@testable import Crossmate + +@Suite("MoveLog.replay") +struct MoveLogReplayTests { + + private let gameID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + + private func move( + lamport: Int64, + row: Int, + col: Int, + letter: String, + author: String? = nil + ) -> Move { + Move( + gameID: gameID, + lamport: lamport, + row: row, + col: col, + letter: letter, + markKind: 0, + checkedWrong: false, + authorID: author, + createdAt: Date(timeIntervalSince1970: TimeInterval(lamport)) + ) + } + + @Test("Empty inputs produce an empty grid") + func emptyInputs() { + let grid = MoveLog.replay(snapshot: nil, moves: []) + #expect(grid.isEmpty) + } + + @Test("Moves without a snapshot are applied in lamport order") + func movesOnly() { + let grid = MoveLog.replay( + snapshot: nil, + moves: [ + move(lamport: 1, row: 0, col: 0, letter: "A"), + move(lamport: 2, row: 0, col: 1, letter: "B"), + ] + ) + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(grid[GridPosition(row: 0, col: 1)]?.letter == "B") + } + + @Test("Later lamport for the same cell overwrites the earlier one") + func laterMoveWins() { + let grid = MoveLog.replay( + snapshot: nil, + moves: [ + move(lamport: 1, row: 0, col: 0, letter: "A"), + move(lamport: 2, row: 0, col: 0, letter: "B"), + ] + ) + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "B") + } + + @Test("Moves are reordered by lamport even when input is shuffled") + func inputOrderIndependent() { + let grid = MoveLog.replay( + snapshot: nil, + moves: [ + move(lamport: 3, row: 0, col: 0, letter: "C"), + move(lamport: 1, row: 0, col: 0, letter: "A"), + move(lamport: 2, row: 0, col: 0, letter: "B"), + ] + ) + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "C") + } + + @Test("Empty-letter move clears the cell's letter but retains the slot") + func clearingMovePreservesSlot() { + let grid = MoveLog.replay( + snapshot: nil, + moves: [ + move(lamport: 1, row: 0, col: 0, letter: "A", author: "alice"), + move(lamport: 2, row: 0, col: 0, letter: "", author: "alice"), + ] + ) + let cell = grid[GridPosition(row: 0, col: 0)] + #expect(cell?.letter == "") + #expect(cell?.authorID == "alice") + } + + @Test("Snapshot seeds the grid and moves past its cutoff are applied") + func snapshotBaseAndTail() { + let snapshot = Snapshot( + gameID: gameID, + upToLamport: 5, + grid: [ + GridPosition(row: 0, col: 0): GridCell( + letter: "A", + markKind: 0, + checkedWrong: false, + authorID: nil + ) + ], + createdAt: Date() + ) + let grid = MoveLog.replay( + snapshot: snapshot, + moves: [ + move(lamport: 6, row: 1, col: 0, letter: "B"), + ] + ) + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(grid[GridPosition(row: 1, col: 0)]?.letter == "B") + } + + @Test("Moves at or below the snapshot cutoff are skipped") + func snapshotCutoffSkipsOlderMoves() { + let snapshot = Snapshot( + gameID: gameID, + upToLamport: 5, + grid: [ + GridPosition(row: 0, col: 0): GridCell( + letter: "Z", + markKind: 0, + checkedWrong: false, + authorID: nil + ) + ], + createdAt: Date() + ) + let grid = MoveLog.replay( + snapshot: snapshot, + moves: [ + // Lamport 3 is already folded into the snapshot; it must + // not re-apply on top and revert the cell. + move(lamport: 3, row: 0, col: 0, letter: "A"), + ] + ) + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "Z") + } + + @Test("Latest snapshot picks the highest upToLamport") + func latestSnapshotSelection() { + let a = Snapshot(gameID: gameID, upToLamport: 10, grid: [:], createdAt: Date()) + let b = Snapshot(gameID: gameID, upToLamport: 50, grid: [:], createdAt: Date()) + let c = Snapshot(gameID: gameID, upToLamport: 25, grid: [:], createdAt: Date()) + #expect(MoveLog.latestSnapshot(from: [a, b, c]) == b) + } +} + +@Suite("MoveLog grid state codec") +struct GridStateCodecTests { + + @Test("Round-trip preserves all cell fields") + func roundTripPreservesFields() throws { + let grid: GridState = [ + GridPosition(row: 0, col: 0): GridCell( + letter: "A", markKind: 2, checkedWrong: true, authorID: "alice" + ), + GridPosition(row: 4, col: 7): GridCell( + letter: "", markKind: 0, checkedWrong: false, authorID: nil + ), + ] + let data = try MoveLog.encodeGridState(grid) + let decoded = try MoveLog.decodeGridState(data) + #expect(decoded == grid) + } + + @Test("Encoding is deterministic for the same grid") + func encodingIsDeterministic() throws { + let grid: GridState = [ + GridPosition(row: 2, col: 1): GridCell( + letter: "X", markKind: 0, checkedWrong: false, authorID: nil + ), + GridPosition(row: 0, col: 0): GridCell( + letter: "A", markKind: 0, checkedWrong: false, authorID: nil + ), + ] + let first = try MoveLog.encodeGridState(grid) + let second = try MoveLog.encodeGridState(grid) + #expect(first == second) + } +} + +@Suite("RecordSerializer Move/Snapshot") +struct RecordSerializerMoveSnapshotTests { + + private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! + private let zoneID = RecordSerializer.zoneID() + + @Test("Move record name uses the expected format") + func moveRecordNameFormat() { + let name = RecordSerializer.recordName(forMoveInGame: gameID, lamport: 42) + #expect(name == "move-\(gameID.uuidString)-42") + } + + @Test("Snapshot record name uses the expected format") + func snapshotRecordNameFormat() { + let name = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: 100) + #expect(name == "snapshot-\(gameID.uuidString)-100") + } + + @Test("Move record round-trips through CKRecord fields") + func moveRecordRoundTrip() { + let move = Move( + gameID: gameID, + lamport: 17, + row: 3, + col: 5, + letter: "Q", + markKind: 1, + checkedWrong: true, + authorID: "alice", + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let record = RecordSerializer.moveRecord( + from: move, + zone: zoneID, + systemFields: nil + ) + let parsed = RecordSerializer.parseMoveRecord(record) + #expect(parsed == move) + } + + @Test("Snapshot record round-trips through CKRecord fields") + func snapshotRecordRoundTrip() throws { + let grid: GridState = [ + GridPosition(row: 0, col: 0): GridCell( + letter: "A", markKind: 0, checkedWrong: false, authorID: "alice" + ), + GridPosition(row: 1, col: 2): GridCell( + letter: "B", markKind: 2, checkedWrong: true, authorID: nil + ), + ] + let snapshot = Snapshot( + gameID: gameID, + upToLamport: 42, + grid: grid, + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let record = try RecordSerializer.snapshotRecord( + from: snapshot, + zone: zoneID, + systemFields: nil + ) + let parsed = RecordSerializer.parseSnapshotRecord(record) + #expect(parsed == snapshot) + } + + @Test("Move record sets parent reference to the owning game") + func moveRecordHasGameParent() { + let move = Move( + gameID: gameID, + lamport: 1, + row: 0, + col: 0, + letter: "A", + markKind: 0, + checkedWrong: false, + authorID: nil, + createdAt: Date() + ) + let record = RecordSerializer.moveRecord( + from: move, + zone: zoneID, + systemFields: nil + ) + let expectedParentName = RecordSerializer.recordName(forGameID: gameID) + #expect(record.parent?.recordID.recordName == expectedParentName) + } + + @Test("Parsing rejects records with the wrong record type") + func parseRejectsWrongRecordType() { + let zoneID = RecordSerializer.zoneID() + let recordID = CKRecord.ID(recordName: "move-\(gameID.uuidString)-1", zoneID: zoneID) + let record = CKRecord(recordType: "Cell", recordID: recordID) + #expect(RecordSerializer.parseMoveRecord(record) == nil) + } +}