crossmate

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

commit ab75fbd3d674bacfccb06cfe60cbde7a63d0ec56
parent e5aaa00f41697c1f66205cb005f6396cc3259e2a
Author: Michael Camilleri <[email protected]>
Date:   Thu, 23 Jul 2026 18:20:38 +0900

Preserve Chronicle participants and replay semantics

Completed games restored from Chronicles could appear as solo games and
lose their replay because materialisation did not recreate the roster
and sent the private projection down the local-only replay path.
Completed NYT puzzles could also redownload on every visit because their
local source update was replaced by the next Chronicle materialisation.

This commit advances the compact payload to format 2 with shared status
and the participant roster inside the compressed blob. It recreates
Player rows and routes Chronicle replay through the cached multi-device
journals. Format 1 payloads remain readable and infer participant
identities from retained history where possible, while live games
rewrite them in the current format.

Additionally, completed puzzles are now excluded from source upgrades,
and complete current-format Chronicles avoid refetching replay during
migration.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 3++-
MCrossmate/Persistence/GameStore.swift | 33+++++++++++++++++++++++++++++++--
MCrossmate/Sync/Archive.swift | 169++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MCrossmate/Sync/GameArchiver.swift | 17++++++++++++++---
MTests/Unit/ArchiveTests.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/NYTPuzzleUpgraderTests.swift | 39+++++++++++++++++++++++++++++++++++++++
6 files changed, 313 insertions(+), 11 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -889,7 +889,8 @@ private struct PuzzleDisplayView: View { // contributing device's journal instead of caching // an incomplete local timeline. let entries = store.localJournalEntries(for: gameID) - if !store.isGameShared(gameID: gameID) { + if !store.isGameShared(gameID: gameID), + !store.usesArchivedReplay(gameID: gameID) { services.syncMonitor.note( "replay[\(short)]: local-only path " + "(unshared game), localEntries=\(entries.count)" diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -14,6 +14,14 @@ private func playerEntities(for entity: GameEntity) -> [PlayerEntity] { return (try? context.fetch(request)) ?? [] } +private func isMaterializedArchive(_ entity: GameEntity) -> Bool { + entity.ckRecordName.flatMap(Archive.originalGameID(fromName:)) != nil +} + +private func isArchivedSharedGame(_ entity: GameEntity) -> Bool { + isMaterializedArchive(entity) && entity.archiveParticipants != nil +} + /// Per-cell state for rendering a thumbnail. Plain value type so /// SwiftUI can diff it cheaply. enum GameThumbnailCell: Equatable { @@ -152,7 +160,9 @@ struct GameSummary: Identifiable, Equatable { self.gridHeight = height self.thumbnailCells = thumbCells self.isOwned = entity.databaseScope == 0 - self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 + self.isShared = entity.ckShareRecordName != nil + || entity.databaseScope == 1 + || isArchivedSharedGame(entity) self.isAccessRevoked = entity.isAccessRevoked self.allParticipants = Self.computeParticipants( gameID: id, @@ -201,7 +211,10 @@ struct GameSummary: Identifiable, Equatable { localColor: PlayerColor, scoreByAuthorID: [String: Int] ) -> [GameParticipantSummary] { - guard entity.ckShareRecordName != nil || entity.databaseScope == 1 else { + guard entity.ckShareRecordName != nil + || entity.databaseScope == 1 + || isArchivedSharedGame(entity) + else { return [] } @@ -2053,7 +2066,12 @@ final class GameStore { return PuzzleInfo( gameID: id, source: source, + // Completed puzzles are immutable history. In particular, updating + // a Chronicle's disposable local projection would be undone by its + // next materialisation. isOwned: entity.databaseScope == 0 + && entity.completedAt == nil + && !isMaterializedArchive(entity) ) } @@ -2147,6 +2165,17 @@ final class GameStore { return entity.ckShareRecordName != nil || entity.databaseScope == 1 } + /// A materialised Chronicle carries its replay as cached Journal rows, + /// regardless of whether the original game was shared. It must use the + /// merged replay loader rather than the live game's local-journal shortcut. + func usesArchivedReplay(gameID: UUID) -> Bool { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + guard let entity = try? context.fetch(request).first else { return false } + return isMaterializedArchive(entity) + } + /// Reads, mutates, and re-persists the local author's `Player.timeLog`, /// creating a stub `PlayerEntity` if none exists (works for solo games — no /// `isShared` gate). `updatedAt` is left untouched on an existing row: the diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift @@ -55,6 +55,8 @@ enum Archive { /// preventing a hostile envelope from requesting an arbitrary allocation. static let maxPayloadAssetBytes = 12_582_912 static let maxDecodedPayloadBytes = 16_777_216 + static let currentPayloadFormatVersion = 2 + private static let maxParticipantCount = 64 /// Upper bound on decoded final-grid cells; `XD.maxGridDimension`² is the /// largest cell count any admissible puzzle can produce. @@ -144,6 +146,14 @@ enum Archive { let letterAuthorID: String? } + /// A frozen roster entry carried inside the compressed Chronicle payload. + /// Names remain optional because v1 Chronicles can recover author IDs from + /// their journals but cannot reconstruct names after the live zone is gone. + struct Participant: Codable, Equatable { + let authorID: String + let name: String? + } + private static func encodeCells(_ cells: [Cell]) throws -> Data { try JSONEncoder().encode(cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) @@ -156,6 +166,7 @@ enum Archive { enum LimitError: Error, CustomStringConvertible { case tooManyCells(count: Int) case tooManyDeviceJournals(count: Int) + case tooManyParticipants(count: Int) var description: String { switch self { @@ -163,6 +174,8 @@ enum Archive { return "cells asset exceeds \(maxCellCount) cells (\(count))" case .tooManyDeviceJournals(let count): return "journals asset exceeds \(maxJournalDeviceCount) device journals (\(count))" + case .tooManyParticipants(let count): + return "archive payload exceeds \(maxParticipantCount) participants (\(count))" } } } @@ -203,6 +216,10 @@ enum Archive { let replayAvailable: Bool let cells: [Cell] let journals: [DeviceJournalWire] + /// Added in format 2. Optional so already-written format-1 payloads + /// continue to decode and can infer contributors from their journals. + let wasShared: Bool? + let participants: [Participant]? } private static let envelopeMagic = Data("CMARCH01".utf8) @@ -350,12 +367,38 @@ enum Archive { /// zero. Whole seconds — the clock is only ever shown at second /// resolution. let solveSeconds: Int + let wasShared: Bool + let participants: [Participant] let cells: [Cell] /// The full move log, kept *per contributing device* (not flattened) so /// the materialized game replays exactly as the live one does: the replay /// assembler merges one log per device and gates on every expected device /// being present. See `GameArchiver` for how peers' logs are gathered. let journal: [DeviceJournal] + + init( + originalGameID: UUID, + title: String, + puzzleSource: String, + completedAt: Date, + completedBy: String?, + solveSeconds: Int, + wasShared: Bool = false, + participants: [Participant] = [], + cells: [Cell], + journal: [DeviceJournal] + ) { + self.originalGameID = originalGameID + self.title = title + self.puzzleSource = puzzleSource + self.completedAt = completedAt + self.completedBy = completedBy + self.solveSeconds = solveSeconds + self.wasShared = wasShared + self.participants = participants + self.cells = cells + self.journal = journal + } } /// Reads the local game's finished state, with the journal grouped by @@ -388,6 +431,33 @@ enum Archive { ) } + let journal = localDeviceJournals(forGameID: gameID, in: ctx) + var participantsByAuthor: [String: Participant] = [:] + let playerReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + playerReq.predicate = NSPredicate(format: "game == %@", entity) + for player in (try? ctx.fetch(playerReq)) ?? [] { + guard let authorID = player.authorID, !authorID.isEmpty else { continue } + let trimmedName = player.name?.trimmingCharacters( + in: .whitespacesAndNewlines + ) + participantsByAuthor[authorID] = Participant( + authorID: authorID, + name: trimmedName?.isEmpty == false ? trimmedName : nil + ) + } + for encoded in [entity.archiveParticipants, entity.shareParticipants] { + for authorID in encoded?.split(separator: ",").map(String.init) ?? [] { + guard !authorID.isEmpty else { continue } + participantsByAuthor[authorID] = participantsByAuthor[authorID] + ?? Participant(authorID: authorID, name: nil) + } + } + for deviceJournal in journal where !deviceJournal.key.authorID.isEmpty { + let authorID = deviceJournal.key.authorID + participantsByAuthor[authorID] = participantsByAuthor[authorID] + ?? Participant(authorID: authorID, name: nil) + } + return Snapshot( originalGameID: originalGameID ?? gameID, title: entity.title ?? "", @@ -395,8 +465,12 @@ enum Archive { completedAt: completedAt, completedBy: entity.completedBy, solveSeconds: solveSeconds(forGameID: gameID, asOf: completedAt, in: ctx), + wasShared: entity.ckShareRecordName != nil || entity.databaseScope == 1, + participants: participantsByAuthor.values.sorted { + $0.authorID < $1.authorID + }, cells: cells, - journal: localDeviceJournals(forGameID: gameID, in: ctx) + journal: journal ) } @@ -465,6 +539,8 @@ enum Archive { completedAt: snapshot.completedAt, completedBy: snapshot.completedBy, solveSeconds: snapshot.solveSeconds, + wasShared: snapshot.wasShared, + participants: snapshot.participants, cells: snapshot.cells, journal: byKey.map { DeviceJournal(key: $0.key, entries: $0.value) } ) @@ -479,7 +555,8 @@ enum Archive { static func recordPackage( from snapshot: Snapshot, - replayAvailable: Bool = true + replayAvailable: Bool = true, + formatVersion: Int = currentPayloadFormatVersion ) throws -> RecordPackage { let zone = zoneID let recordID = CKRecord.ID( @@ -489,7 +566,7 @@ enum Archive { let record = CKRecord(recordType: recordType, recordID: recordID) let blob = Blob( - formatVersion: 1, + formatVersion: formatVersion, originalGameID: snapshot.originalGameID, archiveGameID: archiveGameID(for: snapshot.originalGameID), title: snapshot.title, @@ -499,7 +576,9 @@ enum Archive { solveSeconds: snapshot.solveSeconds, replayAvailable: replayAvailable, cells: snapshot.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) }, - journals: replayAvailable ? try journalWire(snapshot.journal) : [] + journals: replayAvailable ? try journalWire(snapshot.journal) : [], + wasShared: formatVersion >= 2 ? snapshot.wasShared : nil, + participants: formatVersion >= 2 ? snapshot.participants : nil ) let encoded = try JSONEncoder().encode(blob) let payload = try asset(for: encodeEnvelope(encoded), ext: "cmarchive") @@ -523,6 +602,7 @@ enum Archive { /// The decoded payload of an inbound `Archive` record. struct Payload { + let formatVersion: Int let originalGameID: UUID let archiveGameID: UUID let title: String @@ -533,6 +613,8 @@ enum Archive { /// before the field existed (their materialised game simply shows no time). let solveSeconds: Int? let replayAvailable: Bool + let wasShared: Bool + let participants: [Participant] let cells: [Cell] let journal: [DeviceJournal] } @@ -546,6 +628,7 @@ enum Archive { replayAvailable: Bool = true ) -> Payload { Payload( + formatVersion: currentPayloadFormatVersion, originalGameID: snapshot.originalGameID, archiveGameID: archiveGameID(for: snapshot.originalGameID), title: snapshot.title, @@ -554,6 +637,8 @@ enum Archive { completedBy: snapshot.completedBy, solveSeconds: snapshot.solveSeconds, replayAvailable: replayAvailable, + wasShared: snapshot.wasShared, + participants: snapshot.participants, cells: snapshot.cells, journal: replayAvailable ? snapshot.journal : [] ) @@ -592,7 +677,7 @@ enum Archive { ) let decoded = try decodeEnvelope(envelope) let blob = try JSONDecoder().decode(Blob.self, from: decoded) - guard blob.formatVersion == 1 else { + guard (1...currentPayloadFormatVersion).contains(blob.formatVersion) else { throw PayloadError.unsupportedFormat(blob.formatVersion) } guard let recordCompletedAt = record["completedAt"] as? Date else { @@ -613,7 +698,15 @@ enum Archive { throw LimitError.tooManyCells(count: blob.cells.count) } let journals = blob.replayAvailable ? try decodeJournals(blob.journals) : [] + let participants = try validatedParticipants( + blob.participants ?? inferredParticipants( + journals: journals, + cells: blob.cells, + completedBy: blob.completedBy + ) + ) return Payload( + formatVersion: blob.formatVersion, originalGameID: blob.originalGameID, archiveGameID: blob.archiveGameID, title: blob.title, @@ -622,6 +715,9 @@ enum Archive { completedBy: blob.completedBy, solveSeconds: blob.solveSeconds, replayAvailable: blob.replayAvailable, + wasShared: blob.wasShared + ?? (Set(participants.map(\.authorID)).count > 1), + participants: participants, cells: blob.cells, journal: journals ) @@ -676,8 +772,25 @@ enum Archive { } ?? "" let cells = decoded("cells", limit: maxCellsAssetBytes, decodeCells) ?? [] let journal = decoded("journals", limit: maxJournalsAssetBytes, decodeJournals) ?? [] + let participants: [Participant] + do { + participants = try validatedParticipants( + inferredParticipants( + journals: journal, + cells: cells, + completedBy: record["completedBy"] as? String + ) + ) + } catch { + onDiagnostic?( + "archive participants rejected for " + + "\(record.recordID.recordName): \(error)" + ) + return nil + } return Payload( + formatVersion: 0, originalGameID: originalGameID, archiveGameID: archiveGameID, title: record["title"] as? String ?? "", @@ -686,6 +799,8 @@ enum Archive { completedBy: record["completedBy"] as? String, solveSeconds: (record["solveSeconds"] as? Int64).map(Int.init), replayAvailable: true, + wasShared: Set(journal.map(\.key.authorID).filter { !$0.isEmpty }).count > 1, + participants: participants, cells: cells, journal: journal ) @@ -722,6 +837,9 @@ enum Archive { for journal in (existing.journal as? Set<JournalEntity>) ?? [] { ctx.delete(journal) } + for player in (existing.players as? Set<PlayerEntity>) ?? [] { + ctx.delete(player) + } } else { entity = GameEntity(context: ctx) entity.id = archiveID @@ -748,6 +866,9 @@ enum Archive { entity.updatedAt = payload.completedAt entity.archivedAt = payload.completedAt entity.archiveGameID = archiveID + entity.archiveParticipants = payload.wasShared + ? payload.participants.map(\.authorID).sorted().joined(separator: ",") + : nil entity.isHidden = false // A deadline fallback deliberately carries no journal. Mark it // unavailable rather than presenting an authoritative-looking empty or @@ -768,6 +889,18 @@ enum Archive { row.letterAuthorID = cell.letterAuthorID } + for participant in payload.participants { + let player = PlayerEntity(context: ctx) + player.game = entity + player.authorID = participant.authorID + player.name = participant.name ?? "" + player.ckRecordName = RecordSerializer.recordName( + forPlayerInGame: archiveID, + authorID: participant.authorID + ) + player.updatedAt = payload.completedAt + } + // Each device's log is stored as `sourceDeviceID`-tagged rows so the // replay reader treats every author — including the archiving user's own // historical moves — as a cached contributor (the archived game has no @@ -784,4 +917,30 @@ enum Archive { return entity } + + private static func inferredParticipants( + journals: [DeviceJournal], + cells: [Cell], + completedBy: String? + ) -> [Participant] { + var authorIDs = Set(journals.map(\.key.authorID)) + authorIDs.formUnion(cells.compactMap(\.letterAuthorID)) + if let completedBy { authorIDs.insert(completedBy) } + authorIDs.remove("") + authorIDs.remove(CKCurrentUserDefaultName) + return authorIDs.sorted().map { Participant(authorID: $0, name: nil) } + } + + private static func validatedParticipants( + _ participants: [Participant] + ) throws -> [Participant] { + guard participants.count <= maxParticipantCount else { + throw LimitError.tooManyParticipants(count: participants.count) + } + var byAuthor: [String: Participant] = [:] + for participant in participants where !participant.authorID.isEmpty { + byAuthor[participant.authorID] = participant + } + return byAuthor.values.sorted { $0.authorID < $1.authorID } + } } diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift @@ -216,8 +216,19 @@ final class GameArchiver { ) async -> (local: LocalGame, snapshot: Archive.Snapshot, complete: Bool)? { guard let local = await localGame(gameID: gameID) else { return nil } - let fetch = try? await syncEngine.fetchReplay(forGameID: gameID) let stored = await fetchArchive(originalGameID: gameID) + // A complete compact Chronicle is authoritative: it was written only + // after every expected device journal was present. Re-reading the live + // replay cannot improve it and makes overlapping migration backstops + // download the same Moves and Journal assets repeatedly. + let storedComplete = stored.map { + !$0.isLegacy && $0.payload.replayAvailable + } ?? false + let canSkipReplayFetch = storedComplete + && stored?.payload.formatVersion == Archive.currentPayloadFormatVersion + let fetch = canSkipReplayFetch + ? nil + : try? await syncEngine.fetchReplay(forGameID: gameID) var snapshot = local.snapshot if let stored { snapshot = Archive.merging(snapshot, peerJournals: stored.payload.journal) } if let fetch { snapshot = Archive.merging(snapshot, peerJournals: fetch.journals) } @@ -227,12 +238,12 @@ final class GameArchiver { // Only a compact payload's explicit flag is authoritative. Legacy // archives used archivedAt for both completeness and a timed best-effort // fallback, so they must be checked against the still-live zone again. - let compactComplete = stored.map { !$0.isLegacy && $0.payload.replayAvailable } ?? false - let complete = compactComplete || fetchedComplete + let complete = storedComplete || fetchedComplete let storedKeys = Set(stored?.payload.journal.map(\.key) ?? []) let needsWrite = stored == nil || stored?.isLegacy == true + || stored?.payload.formatVersion != Archive.currentPayloadFormatVersion || stored?.payload.replayAvailable != complete || !present.isSubset(of: storedKeys) if needsWrite { diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -106,6 +106,11 @@ struct ArchiveTests { completedAt: Date(timeIntervalSince1970: 1_700_001_000), completedBy: "alice", solveSeconds: 743, + wasShared: true, + participants: [ + .init(authorID: "alice", name: "Alice"), + .init(authorID: "bob", name: "Bob"), + ], cells: [ .init(row: 0, col: 0, letter: "A", markCode: 0, letterAuthorID: "alice"), .init(row: 0, col: 1, letter: "B", markCode: 0, letterAuthorID: "bob"), @@ -203,12 +208,35 @@ struct ArchiveTests { #expect(payload.completedAt == snapshot.completedAt) #expect(payload.completedBy == "alice") #expect(payload.solveSeconds == snapshot.solveSeconds) + #expect(payload.formatVersion == Archive.currentPayloadFormatVersion) + #expect(payload.wasShared) + #expect(payload.participants == snapshot.participants) #expect(payload.puzzleSource == source) #expect(payload.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) } == snapshot.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) }) #expect(normalized(payload.journal) == normalized(snapshot.journal)) } + @Test("format-1 Chronicle infers its shared contributors from journals") + func legacyBlobInfersParticipants() throws { + let snapshot = sampleSnapshot(originalGameID: UUID()) + let package = try Archive.recordPackage( + from: snapshot, + formatVersion: 1 + ) + defer { + for url in package.temporaryAssetFileURLs { + try? FileManager.default.removeItem(at: url) + } + } + + let payload = try #require(Archive.payload(from: package.record)) + #expect(payload.formatVersion == 1) + #expect(payload.wasShared) + #expect(Set(payload.participants.map(\.authorID)) == ["alice", "bob"]) + #expect(payload.participants.allSatisfy { $0.name == nil }) + } + @Test("record package exposes the temporary CKAsset files it creates") func recordPackageTracksTemporaryAssetFiles() throws { let original = UUID() @@ -535,9 +563,15 @@ struct ArchiveTests { // shared-zone fetch. #expect(game.replayCacheComplete) + let players = (game.players as? Set<PlayerEntity>) ?? [] + #expect(Set(players.compactMap(\.authorID)) == ["alice", "bob"]) + #expect(Set(players.compactMap(\.name)) == ["Alice", "Bob"]) + // The library can render it (owned + completed, parseable puzzle). let summary = try #require(GameSummary(entity: game)) #expect(summary.isOwned) + #expect(summary.isShared) + #expect(Set(summary.allParticipants.map(\.authorID)) == ["alice", "bob"]) #expect(summary.completedAt != nil) } @@ -561,6 +595,35 @@ struct ArchiveTests { #expect(cached.reduce(0) { $0 + $1.entries.count } == 3) } + @Test("a materialized format-1 Chronicle restores players and archived replay routing") + func legacyBlobMaterializesRecoveredSemantics() async throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let original = UUID() + let package = try Archive.recordPackage( + from: sampleSnapshot(originalGameID: original), + formatVersion: 1 + ) + defer { + for url in package.temporaryAssetFileURLs { + try? FileManager.default.removeItem(at: url) + } + } + let payload = try #require(Archive.payload(from: package.record)) + let game = try #require(Archive.materialize( + payload, + in: persistence.viewContext + )) + try persistence.viewContext.save() + + let players = (game.players as? Set<PlayerEntity>) ?? [] + #expect(Set(players.compactMap(\.authorID)) == ["alice", "bob"]) + #expect(players.allSatisfy { ($0.name ?? "").isEmpty }) + #expect(GameSummary(entity: game)?.isShared == true) + #expect(store.usesArchivedReplay(gameID: game.id!)) + #expect(await store.cachedRemoteJournals(forGameID: game.id!)?.count == 2) + } + @Test("archive retry window expires 14 days after completion") func archiveRetryWindowExpiresAfterFourteenDays() { let completedAt = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/Unit/NYTPuzzleUpgraderTests.swift b/Tests/Unit/NYTPuzzleUpgraderTests.swift @@ -99,6 +99,45 @@ struct NYTPuzzleUpgraderTests { // MARK: - Outcomes + @Test("Materialised Chronicles are not re-fetched as writable NYT games") + @MainActor + func materializedChronicleHasNoUpgradePlan() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let originalID = UUID() + let entity = GameEntity(context: persistence.viewContext) + entity.id = Archive.archiveGameID(for: originalID) + entity.title = "Test" + entity.puzzleSource = openGridSource() + entity.createdAt = Date() + entity.updatedAt = Date() + entity.completedAt = Date() + entity.databaseScope = 0 + entity.ckRecordName = Archive.recordName(forOriginalGameID: originalID) + entity.ckZoneName = Archive.zoneName + try persistence.viewContext.save() + + #expect(NYTPuzzleUpgrader.plan(for: entity.id!, store: store) == nil) + } + + @Test("Completed owned games are not re-fetched for NYT upgrades") + @MainActor + func completedOwnedGameHasNoUpgradePlan() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let entity = GameEntity(context: persistence.viewContext) + entity.id = UUID() + entity.title = "Test" + entity.puzzleSource = openGridSource() + entity.createdAt = Date() + entity.updatedAt = Date() + entity.completedAt = Date() + entity.databaseScope = 0 + try persistence.viewContext.save() + + #expect(NYTPuzzleUpgrader.plan(for: entity.id!, store: store) == nil) + } + @Test("Clue-only diff is .upgraded — grid + solutions match") func clueOnlyDiffIsUpgraded() async { let old = openGridSource(clueAcross1: "[aria-label] Old prefix")