crossmate

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

commit b3cc7156efdd787de1ae70277c2ec36b199f6189
parent 1b77a5259412e99213de995d4226061ba4702783
Author: Michael Camilleri <[email protected]>
Date:   Wed,  3 Jun 2026 05:32:45 +0900

Collapse the cell-mark triple to a single markCode

CellMark already modelled check state cleanly — folded into .pen/.pencil
via an associated CheckResult? — but every storage and wire boundary
flattened it into a (markKind, checkedRight, checkedWrong) triple, and
two parallel bools can spell the illegal (true, true). The triple ran
the whole length of the grid path: GridCell, TimestampedCell,
RealtimeCellEdit, MovesUpdater.Pending, the CellEntity columns, and the
Moves JSON wire all carried three fields where one would do. It was also
a live footgun — RecordApplier.replayCellCache set markKind and
checkedWrong but silently forgot checkedRight when rebuilding the cache.

The eight legal CellMark states now encode as one lossless Int16
(CellMark.code / init(code:), plus a single-value Codable conformance),
and that is the only representation anywhere. CellMarkCodec is gone; the
in-memory value types carry a CellMark directly, and CellEntity stores a
markCode column like JournalEntity already did. The forgotten-field
class of bug disappears with the third field.

The Moves wire is a hard cutover: new records are markCode-only. The one
remaining concession is MovesCodec.Payload.Entry.decodeMark, the sole
reader that still understands the legacy triple — and the oldest blobs
that predate checkedRight entirely — so records already at rest in
CloudKit decode cleanly. Realtime edits carry no fallback; a stray
old-format message just drops and resyncs through Moves. Once every
device has migrated, that fallback and its legacy CodingKeys are dead
code. MovesCodecLegacyDecodeTests pins the fallback across every
kind/check combination and asserts new writes emit no legacy keys.

The Core Data model is edited in place rather than versioned, so an
existing store cannot open against the new schema. Because the local
store is a rebuildable cache of CloudKit, PersistenceController now
discards and recreates it on an unopenable open instead of crashing; the
grid repopulates from re-synced Moves, decoded through the same
fallback. This sacrifices any never-synced or not-yet-uploaded local
state on upgrade, which is acceptable pre-release and keeps the store on
one clean encoding with no migration-source version to carry.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++----
MCrossmate/CrossmateApp.swift | 3+--
MCrossmate/Models/CellMark.swift | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DCrossmate/Models/CellMarkCodec.swift | 73-------------------------------------------------------------------------
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 4+---
MCrossmate/Persistence/GameMutator.swift | 16+++-------------
MCrossmate/Persistence/GameStore.swift | 37++++++-------------------------------
MCrossmate/Persistence/Journal.swift | 12+++++-------
MCrossmate/Persistence/PersistenceController.swift | 49++++++++++++++++++++++++++++++++++++++++---------
MCrossmate/Sync/GridStateMerger.swift | 4+---
MCrossmate/Sync/Moves.swift | 82++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
MCrossmate/Sync/MovesUpdater.swift | 20+++++---------------
MCrossmate/Sync/RecordApplier.swift | 6++----
MTests/Unit/GameStoreCompletionLockTests.swift | 3+--
MTests/Unit/GameStoreMergedAuthorCellsTests.swift | 4+---
MTests/Unit/GameStoreUnreadMovesTests.swift | 12+++---------
MTests/Unit/GridStateMergerTests.swift | 13+++++--------
MTests/Unit/MovesUpdaterTests.swift | 31+++++++++++++++----------------
MTests/Unit/PendingEditFlagTests.swift | 4+---
MTests/Unit/RecordSerializerMovesTests.swift | 4++--
MTests/Unit/Sync/EngagementCoordinatorTests.swift | 4+---
ATests/Unit/Sync/MovesCodecLegacyDecodeTests.swift | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/Sync/MovesInboundTests.swift | 26+++++++++++++-------------
MTests/Unit/Sync/SessionMonitorTests.swift | 4+---
24 files changed, 317 insertions(+), 256 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -139,9 +139,9 @@ E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */; }; E16A8FE849A8E8BCC0F32280 /* CloudZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F86F0F1883A93F9622FB67 /* CloudZones.swift */; }; E354A588DBA74627A9CD5591 /* Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC4FF046BF772646B5CA73F /* Presence.swift */; }; + E454051BA4797000C8AD2B48 /* MovesCodecLegacyDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */; }; E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; }; E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; - EB877F0E759810CE84425237 /* CellMarkCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09734570F81F9D1DAF4CC9FF /* CellMarkCodec.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; }; F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; }; @@ -187,7 +187,6 @@ /* Begin PBXFileReference section */ 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; }; 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; }; - 09734570F81F9D1DAF4CC9FF /* CellMarkCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMarkCodec.swift; sourceTree = "<group>"; }; 09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreContributingDevicesTests.swift; sourceTree = "<group>"; }; 0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; }; 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; @@ -227,6 +226,7 @@ 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSessionNavigationTests.swift; sourceTree = "<group>"; }; 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; }; 4AF633D73818BD59F759FAC4 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; + 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesCodecLegacyDecodeTests.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDAcceptTests.swift; sourceTree = "<group>"; }; 507B4DC893CE8AC4778CBACE /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; @@ -429,7 +429,6 @@ isa = PBXGroup; children = ( B135C285570F91181595B405 /* CellMark.swift */, - 09734570F81F9D1DAF4CC9FF /* CellMarkCodec.swift */, 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */, B09D52DB46731E92C3E9297C /* EngagementStore.swift */, 465F2BB469EFE84CF3733398 /* Game.swift */, @@ -538,6 +537,7 @@ 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */, B766E872B12DC79ECCD80941 /* FriendModelTests.swift */, 800CCFBE90554F287E765755 /* FriendZoneTests.swift */, + 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */, EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */, BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */, 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */, @@ -744,6 +744,7 @@ AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */, 0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */, 6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */, + E454051BA4797000C8AD2B48 /* MovesCodecLegacyDecodeTests.swift in Sources */, DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */, F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */, C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */, @@ -790,7 +791,6 @@ AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */, C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, - EB877F0E759810CE84425237 /* CellMarkCodec.swift in Sources */, CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */, E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -828,8 +828,7 @@ private struct PuzzleDisplayView: View { } } // check/reveal no longer ping peers; cell state propagates through - // Moves (the `checkedRight`/`checkedWrong` flags + revealed mark do - // the work). + // Moves (the cell's `CellMark` carries the check/reveal result). } } diff --git a/Crossmate/Models/CellMark.swift b/Crossmate/Models/CellMark.swift @@ -41,4 +41,63 @@ enum CellMark: Sendable, Equatable { var isCheckedRight: Bool { checked == .right } var isCheckedWrong: Bool { checked == .wrong } + + /// Drops a `.wrong` check while preserving everything else. Used when a + /// cell is resolved to a correct letter: a lingering wrong-mark on a + /// now-correct entry is contradictory, but the pen/pencil styling (and any + /// `.right` check) should survive. + var withoutWrongCheck: CellMark { + switch self { + case .pen(.wrong): return .pen(checked: nil) + case .pencil(.wrong): return .pencil(checked: nil) + case .none, .pen, .pencil, .revealed: return self + } + } +} + +// MARK: - Persistent / wire encoding + +extension CellMark { + /// Losslessly maps the eight legal states to one `Int16`. This is the + /// single encoding used everywhere a `CellMark` is persisted (Core Data) + /// or serialized (Moves wire, realtime edits) — there is no longer any + /// `(markKind, checkedRight, checkedWrong)` flattening in the domain. + var code: Int16 { + switch self { + case .none: return 0 + case .pen(nil): return 1 + case .pen(.right): return 2 + case .pen(.wrong): return 3 + case .pencil(nil): return 4 + case .pencil(.right): return 5 + case .pencil(.wrong): return 6 + case .revealed: return 7 + } + } + + /// Inverse of `code`. Unknown codes decode to `.none`. + init(code: Int16) { + switch code { + case 1: self = .pen(checked: nil) + case 2: self = .pen(checked: .right) + case 3: self = .pen(checked: .wrong) + case 4: self = .pencil(checked: nil) + case 5: self = .pencil(checked: .right) + case 6: self = .pencil(checked: .wrong) + case 7: self = .revealed + default: self = .none + } + } +} + +extension CellMark: Codable { + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(code: try container.decode(Int16.self)) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(code) + } } diff --git a/Crossmate/Models/CellMarkCodec.swift b/Crossmate/Models/CellMarkCodec.swift @@ -1,73 +0,0 @@ -import Foundation - -/// Single source of truth for the `(markKind, checkedRight, checkedWrong)` -/// triple that the Moves wire format, `CellEntity`, and `JournalEntity` all use -/// to persist a `CellMark`. The pair of booleans encodes an optional -/// `CheckResult`: (false, false) = nil, (true, false) = .right, -/// (false, true) = .wrong. `markKind` is 0 none / 1 pen / 2 pencil / 3 revealed. -enum CellMarkCodec { - static func encode(_ mark: CellMark) -> (kind: Int16, checkedRight: Bool, checkedWrong: Bool) { - switch mark { - case .none: - return (0, false, false) - case .pen(let check): - return (1, check == .right, check == .wrong) - case .pencil(let check): - return (2, check == .right, check == .wrong) - case .revealed: - return (3, false, false) - } - } - - /// Inverse of `encode`. `checkedWrong` takes precedence if both somehow - /// ended up true (shouldn't happen — the invariant is enforced where marks - /// are constructed in `Game`, not here). - static func decode(kind: Int16, checkedRight: Bool, checkedWrong: Bool) -> CellMark { - let check: CheckResult? - if checkedWrong { - check = .wrong - } else if checkedRight { - check = .right - } else { - check = nil - } - switch kind { - case 1: return .pen(checked: check) - case 2: return .pencil(checked: check) - case 3: return .revealed - default: return .none - } - } - - // MARK: - Single-value encoding - - /// Losslessly maps the eight legal `CellMark` states to one `Int16`. Used - /// by `JournalEntity`, which models the whole mark as a single field rather - /// than the `markKind` + two-bool flattening the synced Moves format uses. - static func code(_ mark: CellMark) -> Int16 { - switch mark { - case .none: return 0 - case .pen(nil): return 1 - case .pen(.right): return 2 - case .pen(.wrong): return 3 - case .pencil(nil): return 4 - case .pencil(.right): return 5 - case .pencil(.wrong): return 6 - case .revealed: return 7 - } - } - - /// Inverse of `code(_:)`. Unknown codes decode to `.none`. - static func mark(code: Int16) -> CellMark { - switch code { - case 1: return .pen(checked: nil) - case 2: return .pen(checked: .right) - case 3: return .pen(checked: .wrong) - case 4: return .pencil(checked: nil) - case 5: return .pencil(checked: .right) - case 6: return .pencil(checked: .wrong) - case 7: return .revealed - default: return .none - } - } -} diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -69,8 +69,6 @@ </fetchIndex> </entity> <entity name="CellEntity" representedClassName="CellEntity" syncable="YES" codeGenerationType="class"> - <attribute name="checkedRight" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> - <attribute name="checkedWrong" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="ckRecordName" optional="YES" attributeType="String"/> <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> <attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> @@ -78,7 +76,7 @@ <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="letter" attributeType="String" defaultValueString=""/> <attribute name="letterAuthorID" optional="YES" attributeType="String"/> - <attribute name="markKind" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="markCode" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="row" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="cells" inverseEntity="GameEntity"/> diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -275,7 +275,7 @@ final class GameMutator { ) { guard !isAccessRevoked else { return } let square = game.squares[row][col] - let (markKind, checkedRight, checkedWrong) = encodeMark(square.mark) + let mark = square.mark let id = gameID let letter = square.entry // The cell's `letterAuthorID` is the canonical author for the square — @@ -318,9 +318,7 @@ final class GameMutator { row: row, col: col, letter: letter, - markKind: markKind, - checkedRight: checkedRight, - checkedWrong: checkedWrong, + mark: mark, updatedAt: enqueuedAt, cellAuthorID: cellAuthorID ) @@ -335,19 +333,11 @@ final class GameMutator { gameID: id, row: row, col: col, letter: letter, - markKind: markKind, - checkedRight: checkedRight, - checkedWrong: checkedWrong, + mark: mark, authorID: cellAuthorID, actingAuthorID: actingAuthorID, enqueuedAt: enqueuedAt ) } } - - /// Flattens `CellMark` into the (kind, checkedRight, checkedWrong) triple - /// that the Moves wire format and Core Data persistence store. - private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedRight: Bool, checkedWrong: Bool) { - CellMarkCodec.encode(mark) - } } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -429,9 +429,7 @@ final class GameStore { let position = GridPosition(row: edit.row, col: edit.col) let incoming = TimestampedCell( letter: edit.letter, - markKind: edit.markKind, - checkedRight: edit.checkedRight, - checkedWrong: edit.checkedWrong, + mark: edit.mark, updatedAt: edit.updatedAt, authorID: edit.cellAuthorID ) @@ -1498,11 +1496,7 @@ final class GameStore { game.squares[r][c].enqueuedAt = nil } game.squares[r][c].entry = cell.letter - game.squares[r][c].mark = decodeMark( - kind: cell.markKind, - checkedRight: cell.checkedRight, - checkedWrong: cell.checkedWrong - ) + game.squares[r][c].mark = cell.mark game.squares[r][c].letterAuthorID = cell.authorID } game.recomputeCompletionCache() @@ -1530,11 +1524,7 @@ final class GameStore { // (a stale wrong-mark on a correct letter is contradictory, // so force it off). game.squares[r][c].entry = merged.letter - game.squares[r][c].mark = decodeMark( - kind: merged.markKind, - checkedRight: merged.checkedRight, - checkedWrong: false - ) + game.squares[r][c].mark = merged.mark.withoutWrongCheck game.squares[r][c].letterAuthorID = merged.authorID } else if let solution = cell.solution { // Hole or stray post-completion letter — seal to solution. @@ -1544,11 +1534,7 @@ final class GameStore { } else if let merged { // No known solution — show whatever the merge resolved. game.squares[r][c].entry = merged.letter - game.squares[r][c].mark = decodeMark( - kind: merged.markKind, - checkedRight: merged.checkedRight, - checkedWrong: merged.checkedWrong - ) + game.squares[r][c].mark = merged.mark game.squares[r][c].letterAuthorID = merged.authorID } } @@ -1639,17 +1625,13 @@ final class GameStore { ce.game = gameEntity } ce.letter = cell.letter - ce.markKind = cell.markKind - ce.checkedRight = cell.checkedRight - ce.checkedWrong = cell.checkedWrong + ce.markCode = cell.mark.code ce.letterAuthorID = cell.authorID } for (position, ce) in existing where grid[position] == nil { ce.letter = "" - ce.markKind = 0 - ce.checkedRight = false - ce.checkedWrong = false + ce.markCode = 0 ce.letterAuthorID = nil } } @@ -1742,11 +1724,4 @@ final class GameStore { return entity } - - // MARK: - CellMark coding - - /// Inverse of `GameMutator.encodeMark`. - private func decodeMark(kind: Int16, checkedRight: Bool, checkedWrong: Bool) -> CellMark { - CellMarkCodec.decode(kind: kind, checkedRight: checkedRight, checkedWrong: checkedWrong) - } } diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift @@ -396,7 +396,7 @@ final class MovesJournal { entity.row = Int16(value.position.row) entity.col = Int16(value.position.col) entity.letter = value.state.letter - entity.markCode = CellMarkCodec.code(value.state.mark) + entity.markCode = value.state.mark.code entity.cellAuthorID = value.state.cellAuthorID entity.actingAuthorID = value.actingAuthorID entity.kind = value.kind.rawValue @@ -416,7 +416,7 @@ final class MovesJournal { position: GridPosition(row: Int(entity.row), col: Int(entity.col)), state: JournalCellState( letter: entity.letter ?? "", - mark: CellMarkCodec.mark(code: entity.markCode), + mark: CellMark(code: entity.markCode), cellAuthorID: entity.cellAuthorID ), actingAuthorID: entity.actingAuthorID, @@ -433,9 +433,7 @@ final class MovesJournal { /// uploaded as the `Journal` record's `entries` asset (Phase 2). A faithful /// dump of `[JournalValue]` in `seq` order — merging every device's decoded /// dump by `timestamp` reconstructs the whole game for replay. The mark is -/// carried as the single lossless `markCode` (`CellMarkCodec.code`), matching -/// `JournalEntity`, not the `markKind` + two-bool flattening the synced `Moves` -/// format uses. +/// carried as the single lossless `markCode` (`CellMark.code`). enum JournalCodec { struct Payload: Codable, Equatable { struct Entry: Codable, Equatable { @@ -512,7 +510,7 @@ enum JournalCodec { row: value.position.row, col: value.position.col, letter: value.state.letter, - markCode: CellMarkCodec.code(value.state.mark), + markCode: value.state.mark.code, cellAuthorID: value.state.cellAuthorID, actingAuthorID: value.actingAuthorID, kind: value.kind.rawValue, @@ -533,7 +531,7 @@ enum JournalCodec { position: GridPosition(row: entry.row, col: entry.col), state: JournalCellState( letter: entry.letter, - mark: CellMarkCodec.mark(code: entry.markCode), + mark: CellMark(code: entry.markCode), cellAuthorID: entry.cellAuthorID ), actingAuthorID: entry.actingAuthorID, diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift @@ -31,22 +31,22 @@ final class PersistenceController { } else { // Enable lightweight migration so additive schema changes — and // attribute renames carrying a `renamingIdentifier` in the model - // — apply on launch without a hand-written mapping. Without these - // flags a model edit fails the loadPersistentStores call and the - // app refuses to open the existing store. + // — apply on launch without a hand-written mapping. A non-additive + // change made in place (no prior model version kept as a migration + // source) can't be inferred; `recreateStore(after:)` handles that + // by discarding and rebuilding the store. for description in container.persistentStoreDescriptions { description.shouldMigrateStoreAutomatically = true description.shouldInferMappingModelAutomatically = true } } - container.loadPersistentStores { _, error in - if let error { - // Failing to open the store is unrecoverable for the app. - // Crash loudly so we notice in development rather than - // silently running with no persistence. - fatalError("Failed to load Core Data store: \(error)") + container.loadPersistentStores { [self] _, error in + guard let error else { return } + if inMemory { + fatalError("Failed to load in-memory Core Data store: \(error)") } + recreateStore(after: error) } container.viewContext.automaticallyMergesChangesFromParent = true @@ -57,6 +57,37 @@ final class PersistenceController { } } + /// Discards the on-disk store and rebuilds it empty. The local store is a + /// rebuildable cache of CloudKit, so when an existing store can't be opened + /// against the current model — e.g. after an in-place schema change with no + /// migration source — wiping it is recoverable: the sync engine refetches + /// every record on the next sync. Any never-synced or not-yet-uploaded + /// local state is intentionally sacrificed for a clean store. + private func recreateStore(after originalError: Error) { + let coordinator = container.persistentStoreCoordinator + for description in container.persistentStoreDescriptions { + guard let url = description.url else { continue } + do { + try coordinator.destroyPersistentStore( + at: url, + ofType: description.type, + options: description.options + ) + } catch { + // Best effort — fall through and let the reload attempt report + // the real failure if the store truly can't be replaced. + } + } + container.loadPersistentStores { _, retryError in + if let retryError { + fatalError( + "Failed to load Core Data store after reset: \(retryError) " + + "(original open error: \(originalError))" + ) + } + } + } + /// One-shot pass for `GameEntity` rows created before cached summary /// fields were wired into the creation paths. Runs off the main thread, /// no-ops on every subsequent launch. diff --git a/Crossmate/Sync/GridStateMerger.swift b/Crossmate/Sync/GridStateMerger.swift @@ -19,9 +19,7 @@ enum GridStateMerger { for (position, winner) in winners(moves, notAfter: cutoff) { grid[position] = GridCell( letter: winner.cell.letter, - markKind: winner.cell.markKind, - checkedRight: winner.cell.checkedRight, - checkedWrong: winner.cell.checkedWrong, + mark: winner.cell.mark, authorID: winner.cell.authorID ) } diff --git a/Crossmate/Sync/Moves.swift b/Crossmate/Sync/Moves.swift @@ -11,9 +11,7 @@ struct GridPosition: Hashable, Sendable, Codable { /// `authorID` is the *preserved* cell author from the winning entry. struct GridCell: Equatable, Sendable, Codable { var letter: String - var markKind: Int16 - var checkedRight: Bool - var checkedWrong: Bool + var mark: CellMark var authorID: String? } @@ -40,9 +38,7 @@ struct MovesValue: Equatable, Sendable { /// letter. The merger uses cell-level `authorID` when populating `GridCell`. struct TimestampedCell: Equatable, Sendable { var letter: String - var markKind: Int16 - var checkedRight: Bool - var checkedWrong: Bool + var mark: CellMark var updatedAt: Date var authorID: String? } @@ -54,9 +50,7 @@ struct RealtimeCellEdit: Codable, Equatable, Sendable { var row: Int var col: Int var letter: String - var markKind: Int16 - var checkedRight: Bool - var checkedWrong: Bool + var mark: CellMark var updatedAt: Date var cellAuthorID: String? } @@ -65,36 +59,39 @@ enum MovesCodec { /// Wire format for `MovesValue.cells`. Each entry's `authorID` is the /// preserved cell-level author — distinct from the parent record's /// authorID, which identifies the iCloud user who wrote the record. - /// `checkedRight` was added after the initial release; the custom - /// `init(from:)` defaults it to `false` so records written by older - /// clients still decode cleanly. + /// + /// The mark is written as a single lossless `markCode` (`CellMark.code`). + /// `decodeMark` is the **sole** reader that still understands the legacy + /// `(markKind, checkedRight, checkedWrong)` triple that pre-cutover clients + /// wrote; it lets records already at rest decode cleanly. Once every device + /// has migrated, that fallback (and the legacy `CodingKeys`) is dead code. struct Payload: Codable, Equatable { struct Entry: Codable, Equatable { let row: Int let col: Int let letter: String - let markKind: Int16 - let checkedRight: Bool - let checkedWrong: Bool + let mark: CellMark let updatedAt: Date let authorID: String? + enum CodingKeys: String, CodingKey { + case row, col, letter, markCode, updatedAt, authorID + // Legacy keys — read-only, for records written before cutover. + case markKind, checkedRight, checkedWrong + } + init( row: Int, col: Int, letter: String, - markKind: Int16, - checkedRight: Bool, - checkedWrong: Bool, + mark: CellMark, updatedAt: Date, authorID: String? ) { self.row = row self.col = col self.letter = letter - self.markKind = markKind - self.checkedRight = checkedRight - self.checkedWrong = checkedWrong + self.mark = mark self.updatedAt = updatedAt self.authorID = authorID } @@ -104,11 +101,40 @@ enum MovesCodec { row = try c.decode(Int.self, forKey: .row) col = try c.decode(Int.self, forKey: .col) letter = try c.decode(String.self, forKey: .letter) - markKind = try c.decode(Int16.self, forKey: .markKind) - checkedWrong = try c.decode(Bool.self, forKey: .checkedWrong) - checkedRight = (try? c.decode(Bool.self, forKey: .checkedRight)) ?? false updatedAt = try c.decode(Date.self, forKey: .updatedAt) authorID = try? c.decode(String.self, forKey: .authorID) + mark = try Self.decodeMark(from: c) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(row, forKey: .row) + try c.encode(col, forKey: .col) + try c.encode(letter, forKey: .letter) + try c.encode(mark.code, forKey: .markCode) + try c.encode(updatedAt, forKey: .updatedAt) + try c.encodeIfPresent(authorID, forKey: .authorID) + } + + /// Prefers the single `markCode`; falls back to the legacy triple + /// for records written before the single-code cutover. This is the + /// only place the triple is still understood. + private static func decodeMark( + from c: KeyedDecodingContainer<CodingKeys> + ) throws -> CellMark { + if let code = try c.decodeIfPresent(Int16.self, forKey: .markCode) { + return CellMark(code: code) + } + let kind = (try? c.decode(Int16.self, forKey: .markKind)) ?? 0 + let checkedWrong = (try? c.decode(Bool.self, forKey: .checkedWrong)) ?? false + let checkedRight = (try? c.decode(Bool.self, forKey: .checkedRight)) ?? false + let check: CheckResult? = checkedWrong ? .wrong : (checkedRight ? .right : nil) + switch kind { + case 1: return .pen(checked: check) + case 2: return .pencil(checked: check) + case 3: return .revealed + default: return .none + } } } let entries: [Entry] @@ -121,9 +147,7 @@ enum MovesCodec { row: position.row, col: position.col, letter: cell.letter, - markKind: cell.markKind, - checkedRight: cell.checkedRight, - checkedWrong: cell.checkedWrong, + mark: cell.mark, updatedAt: cell.updatedAt, authorID: cell.authorID ) @@ -141,9 +165,7 @@ enum MovesCodec { let position = GridPosition(row: entry.row, col: entry.col) cells[position] = TimestampedCell( letter: entry.letter, - markKind: entry.markKind, - checkedRight: entry.checkedRight, - checkedWrong: entry.checkedWrong, + mark: entry.mark, updatedAt: entry.updatedAt, authorID: entry.authorID ) diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift @@ -26,9 +26,7 @@ actor MovesUpdater { private struct Pending { var letter: String - var markKind: Int16 - var checkedRight: Bool - var checkedWrong: Bool + var mark: CellMark var authorID: String? var enqueuedAt: Date } @@ -68,9 +66,7 @@ actor MovesUpdater { row: Int, col: Int, letter: String, - markKind: Int16, - checkedRight: Bool, - checkedWrong: Bool, + mark: CellMark, authorID: String?, actingAuthorID: String? = nil, enqueuedAt: Date = Date() @@ -78,9 +74,7 @@ actor MovesUpdater { let key = Key(gameID: gameID, row: row, col: col) buffer[key] = Pending( letter: letter, - markKind: markKind, - checkedRight: checkedRight, - checkedWrong: checkedWrong, + mark: mark, authorID: authorID, enqueuedAt: enqueuedAt ) @@ -184,9 +178,7 @@ actor MovesUpdater { let position = GridPosition(row: key.row, col: key.col) let newCell = TimestampedCell( letter: pending.letter, - markKind: pending.markKind, - checkedRight: pending.checkedRight, - checkedWrong: pending.checkedWrong, + mark: pending.mark, updatedAt: pending.enqueuedAt, authorID: pending.authorID ) @@ -297,9 +289,7 @@ actor MovesUpdater { cells[position] = cell } cell.letter = pending.letter - cell.markKind = pending.markKind - cell.checkedRight = pending.checkedRight - cell.checkedWrong = pending.checkedWrong + cell.markCode = pending.mark.code cell.letterAuthorID = pending.authorID } } diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -294,15 +294,13 @@ extension SyncEngine { cell.col = Int16(pos.col) } cell.letter = gridCell.letter - cell.markKind = gridCell.markKind - cell.checkedWrong = gridCell.checkedWrong + cell.markCode = gridCell.mark.code cell.letterAuthorID = gridCell.authorID } for (pos, cell) in byPosition where gridState[pos] == nil { cell.letter = "" - cell.markKind = 0 - cell.checkedWrong = false + cell.markCode = 0 cell.letterAuthorID = nil } } diff --git a/Tests/Unit/GameStoreCompletionLockTests.swift b/Tests/Unit/GameStoreCompletionLockTests.swift @@ -69,8 +69,7 @@ struct GameStoreCompletionLockTests { var encoded: [GridPosition: TimestampedCell] = [:] for (pos, letter) in cells { encoded[pos] = TimestampedCell( - letter: letter, markKind: 0, checkedRight: false, - checkedWrong: false, updatedAt: now, authorID: Self.authorID + letter: letter, mark: .none, updatedAt: now, authorID: Self.authorID ) } row.cells = try MovesCodec.encode(encoded) diff --git a/Tests/Unit/GameStoreMergedAuthorCellsTests.swift b/Tests/Unit/GameStoreMergedAuthorCellsTests.swift @@ -65,9 +65,7 @@ struct GameStoreMergedAuthorCellsTests { ) -> TimestampedCell { TimestampedCell( letter: letter, - markKind: 0, - checkedRight: false, - checkedWrong: false, + mark: .none, updatedAt: updatedAt, authorID: Self.authorID ) diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift @@ -425,9 +425,7 @@ struct GameStoreUnreadMovesTests { row: 0, col: 0, letter: "Q", - markKind: 0, - checkedRight: false, - checkedWrong: false, + mark: .none, updatedAt: updatedAt, cellAuthorID: Self.localAuthorID )) @@ -456,9 +454,7 @@ struct GameStoreUnreadMovesTests { row: 0, col: 0, letter: "Q", - markKind: 0, - checkedRight: false, - checkedWrong: false, + mark: .none, updatedAt: later, cellAuthorID: Self.localAuthorID ))) @@ -469,9 +465,7 @@ struct GameStoreUnreadMovesTests { row: 0, col: 0, letter: "R", - markKind: 0, - checkedRight: false, - checkedWrong: false, + mark: .none, updatedAt: earlier, cellAuthorID: Self.localAuthorID ))) diff --git a/Tests/Unit/GridStateMergerTests.swift b/Tests/Unit/GridStateMergerTests.swift @@ -19,8 +19,7 @@ struct GridStateMergerTests { for entry in cells { dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell( letter: entry.letter, - markKind: 0, - checkedRight: false, checkedWrong: false, + mark: .none, updatedAt: entry.updatedAt, authorID: resolvedCellAuthor ) @@ -226,15 +225,13 @@ struct MovesCodecTests { let cells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( letter: "A", - markKind: 2, - checkedRight: false, checkedWrong: true, + mark: .pencil(checked: .wrong), updatedAt: Date(timeIntervalSince1970: 1_700_000_000), authorID: "alice" ), GridPosition(row: 4, col: 7): TimestampedCell( letter: "", - markKind: 0, - checkedRight: false, checkedWrong: false, + mark: .none, updatedAt: Date(timeIntervalSince1970: 1_700_000_500), authorID: nil ), @@ -248,12 +245,12 @@ struct MovesCodecTests { func encodingIsDeterministic() throws { let cells: [GridPosition: TimestampedCell] = [ GridPosition(row: 2, col: 1): TimestampedCell( - letter: "X", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "X", mark: .none, updatedAt: Date(timeIntervalSince1970: 1), authorID: "alice" ), GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "A", mark: .none, updatedAt: Date(timeIntervalSince1970: 2), authorID: "bob" ), diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift @@ -100,9 +100,9 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", mark: .none, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", mark: .none, authorID: "alice") await updater.flush() let cells = try decodedCells(gameID: gameID, persistence: persistence) @@ -116,8 +116,8 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", mark: .none, authorID: "alice") // The cell change should NOT have triggered a flush — both edits are // still buffered until the debounce (or explicit flush) fires. @@ -143,8 +143,8 @@ struct MovesUpdaterTests { sleep: manualSleep.sleepFn ) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", mark: .none, authorID: "alice") // Both enqueues are buffered; the second enqueue cancelled the // first debounce task. Releasing the manual sleep lets the live @@ -180,7 +180,7 @@ struct MovesUpdaterTests { writerAuthorID: "bob" ) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice") await updater.flush() let cells = try decodedCells(gameID: gameID, persistence: persistence) @@ -209,8 +209,8 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: g1, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: g2, row: 0, col: 0, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: g1, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice") + await updater.enqueue(gameID: g2, row: 0, col: 0, letter: "B", mark: .none, authorID: "alice") await updater.flush() let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") @@ -227,11 +227,11 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: "alice") await updater.flush() let firstObjectID = try #require(movesEntity(gameID: gameID, persistence: persistence)).objectID - await updater.enqueue(gameID: gameID, row: 1, col: 1, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 1, col: 1, letter: "B", mark: .none, authorID: "alice") await updater.flush() let entity = try #require(movesEntity(gameID: gameID, persistence: persistence)) @@ -248,7 +248,7 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: gameID, row: 2, col: 3, letter: "Q", markKind: 1, checkedRight: false, checkedWrong: true, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 2, col: 3, letter: "Q", mark: .pen(checked: .wrong), authorID: "alice") await updater.flush() let ctx = persistence.viewContext @@ -258,8 +258,7 @@ struct MovesUpdaterTests { let cells = try ctx.fetch(req) #expect(cells.count == 1) #expect(cells.first?.letter == "Q") - #expect(cells.first?.markKind == 1) - #expect(cells.first?.checkedWrong == true) + #expect(cells.first.map { CellMark(code: $0.markCode) } == .pen(checked: .wrong)) #expect(cells.first?.letterAuthorID == "alice") } @@ -275,7 +274,7 @@ struct MovesUpdaterTests { sink: { await capture.append($0) } ) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: nil) + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", mark: .none, authorID: nil) await updater.flush() #expect(await capture.flushCount == 0) diff --git a/Tests/Unit/PendingEditFlagTests.swift b/Tests/Unit/PendingEditFlagTests.swift @@ -77,9 +77,7 @@ struct PendingEditFlagTests { let cells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( letter: letter, - markKind: 1, - checkedRight: false, - checkedWrong: false, + mark: .pen(checked: nil), updatedAt: updatedAt, authorID: authorID ) diff --git a/Tests/Unit/RecordSerializerMovesTests.swift b/Tests/Unit/RecordSerializerMovesTests.swift @@ -37,12 +37,12 @@ struct RecordSerializerMovesTests { func movesRecordRoundTrip() throws { let cells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "A", mark: .none, updatedAt: Date(timeIntervalSince1970: 1_700_000_000), authorID: "alice" ), GridPosition(row: 1, col: 2): TimestampedCell( - letter: "B", markKind: 2, checkedRight: false, checkedWrong: true, + letter: "B", mark: .pencil(checked: .wrong), updatedAt: Date(timeIntervalSince1970: 1_700_000_500), authorID: nil ), diff --git a/Tests/Unit/Sync/EngagementCoordinatorTests.swift b/Tests/Unit/Sync/EngagementCoordinatorTests.swift @@ -98,9 +98,7 @@ struct EngagementCoordinatorTests { row: 1, col: 2, letter: "Z", - markKind: 2, - checkedRight: false, - checkedWrong: false, + mark: .pencil(checked: nil), updatedAt: Date(timeIntervalSince1970: 456), cellAuthorID: "alice" ) diff --git a/Tests/Unit/Sync/MovesCodecLegacyDecodeTests.swift b/Tests/Unit/Sync/MovesCodecLegacyDecodeTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down `MovesCodec`'s one back-compat concession: decoding `Moves` blobs +/// written before the single-`markCode` cutover, which encoded the mark as a +/// `(markKind, checkedRight, checkedWrong)` triple. Records at rest in CloudKit +/// (and any straggler peer) still carry that shape, so the fallback must lift +/// it to the right `CellMark`. New writes are markCode-only — also asserted +/// here so the legacy keys never creep back into the wire format. +@Suite("MovesCodec legacy decode") +struct MovesCodecLegacyDecodeTests { + + /// Builds an old-format blob: one entry per (markKind, checkedRight, + /// checkedWrong) triple, in the JSON shape a pre-cutover client produced. + /// `updatedAt` is a bare number because `MovesCodec` uses a plain + /// `JSONEncoder`/`Decoder` (deferred-to-date strategy = seconds). + private func legacyBlob(_ triples: [(row: Int, kind: Int, right: Bool, wrong: Bool)]) throws -> Data { + let entries: [[String: Any]] = triples.map { t in + [ + "row": t.row, "col": 0, "letter": "A", + "markKind": t.kind, + "checkedRight": t.right, + "checkedWrong": t.wrong, + "updatedAt": 0.0, + "authorID": "alice", + ] + } + return try JSONSerialization.data(withJSONObject: ["entries": entries]) + } + + @Test("legacy triple decodes to the right CellMark for every kind/check") + func legacyTripleDecodes() throws { + let blob = try legacyBlob([ + (row: 0, kind: 0, right: false, wrong: false), // .none + (row: 1, kind: 1, right: false, wrong: false), // .pen(nil) + (row: 2, kind: 1, right: true, wrong: false), // .pen(.right) + (row: 3, kind: 1, right: false, wrong: true), // .pen(.wrong) + (row: 4, kind: 2, right: false, wrong: false), // .pencil(nil) + (row: 5, kind: 2, right: true, wrong: false), // .pencil(.right) + (row: 6, kind: 2, right: false, wrong: true), // .pencil(.wrong) + (row: 7, kind: 3, right: false, wrong: false), // .revealed + ]) + + let cells = try MovesCodec.decode(blob) + func mark(_ row: Int) -> CellMark? { cells[GridPosition(row: row, col: 0)]?.mark } + + #expect(mark(0) == CellMark.none) + #expect(mark(1) == .pen(checked: nil)) + #expect(mark(2) == .pen(checked: .right)) + #expect(mark(3) == .pen(checked: .wrong)) + #expect(mark(4) == .pencil(checked: nil)) + #expect(mark(5) == .pencil(checked: .right)) + #expect(mark(6) == .pencil(checked: .wrong)) + #expect(mark(7) == .revealed) + } + + @Test("oldest blobs without a checkedRight key still decode") + func missingCheckedRightKey() throws { + // `checkedRight` was added after the first release; the very oldest + // records omit it entirely. It must default to false, not throw. + let entry: [String: Any] = [ + "row": 0, "col": 0, "letter": "Q", + "markKind": 1, "checkedWrong": false, + "updatedAt": 0.0, "authorID": "alice", + ] + let blob = try JSONSerialization.data(withJSONObject: ["entries": [entry]]) + + let cells = try MovesCodec.decode(blob) + #expect(cells[GridPosition(row: 0, col: 0)]?.mark == .pen(checked: nil)) + } + + @Test("new writes are markCode-only — no legacy keys on the wire") + func newFormatOmitsLegacyKeys() throws { + let cells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "A", mark: .pencil(checked: .wrong), + updatedAt: Date(timeIntervalSinceReferenceDate: 0), authorID: "alice" + ), + ] + let data = try MovesCodec.encode(cells) + + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let entry = (json?["entries"] as? [[String: Any]])?.first + #expect(entry?["markCode"] as? Int == 6) // .pencil(.wrong) + #expect(entry?["markKind"] == nil) + #expect(entry?["checkedRight"] == nil) + #expect(entry?["checkedWrong"] == nil) + + // And it round-trips back through decode. + let decoded = try MovesCodec.decode(data) + #expect(decoded[GridPosition(row: 0, col: 0)]?.mark == .pencil(checked: .wrong)) + } +} diff --git a/Tests/Unit/Sync/MovesInboundTests.swift b/Tests/Unit/Sync/MovesInboundTests.swift @@ -50,7 +50,7 @@ struct MovesInboundTests { deviceID: "deadbeef", cells: [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "A", mark: .none, updatedAt: Date(timeIntervalSince1970: 1_700_000_000) ), ], @@ -80,7 +80,7 @@ struct MovesInboundTests { let cells1: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "A", mark: .none, updatedAt: Date(timeIntervalSince1970: 1_700_000_000), authorID: "alice" ), @@ -99,12 +99,12 @@ struct MovesInboundTests { // (matched by ckRecordName), not create a duplicate. let cells2: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "B", mark: .none, updatedAt: Date(timeIntervalSince1970: 1_700_000_500), authorID: "alice" ), GridPosition(row: 1, col: 1): TimestampedCell( - letter: "C", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "C", mark: .none, updatedAt: Date(timeIntervalSince1970: 1_700_000_500), authorID: "alice" ), @@ -141,7 +141,7 @@ struct MovesInboundTests { let localCells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "B", mark: .none, updatedAt: Date(timeIntervalSince1970: 20), authorID: "alice" ), @@ -161,7 +161,7 @@ struct MovesInboundTests { let serverCells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "A", mark: .none, updatedAt: Date(timeIntervalSince1970: 10), authorID: "alice" ), @@ -203,12 +203,12 @@ struct MovesInboundTests { let cachedCells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "B", mark: .none, updatedAt: Date(timeIntervalSince1970: 20), authorID: "bob" ), GridPosition(row: 1, col: 1): TimestampedCell( - letter: "", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "", mark: .none, updatedAt: Date(timeIntervalSince1970: 30), authorID: "bob" ), @@ -228,17 +228,17 @@ struct MovesInboundTests { let serverCells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "A", mark: .none, updatedAt: Date(timeIntervalSince1970: 10), authorID: "bob" ), GridPosition(row: 1, col: 1): TimestampedCell( - letter: "Z", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "Z", mark: .none, updatedAt: Date(timeIntervalSince1970: 15), authorID: "bob" ), GridPosition(row: 2, col: 2): TimestampedCell( - letter: "C", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "C", mark: .none, updatedAt: Date(timeIntervalSince1970: 40), authorID: "bob" ), @@ -277,7 +277,7 @@ struct MovesInboundTests { deviceID: "phone", cells: [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "A", mark: .none, updatedAt: Date(timeIntervalSince1970: 1), authorID: "alice" ), @@ -290,7 +290,7 @@ struct MovesInboundTests { deviceID: "ipad", cells: [ GridPosition(row: 1, col: 1): TimestampedCell( - letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, + letter: "B", mark: .none, updatedAt: Date(timeIntervalSince1970: 2), authorID: "alice" ), diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -81,9 +81,7 @@ struct SessionMonitorTests { let stamped = cells.mapValues { value in TimestampedCell( letter: value.letter, - markKind: 0, - checkedRight: false, - checkedWrong: false, + mark: .none, updatedAt: value.updatedAt, authorID: authorID )