crossmate

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

ArchiveTests.swift (54008B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// The private-zone archive of a finished shared game: deterministic identity,
      9 /// the `Archive` record ↔ payload wire format, materialization into a
     10 /// standalone completed game, idempotency, and the dedup-against-live-original
     11 /// rule in the inbound applier. The CloudKit write itself (`GameArchiver`) and
     12 /// the CKSyncEngine plumbing are exercised by the manual end-to-end check, not
     13 /// here.
     14 @MainActor
     15 @Suite("Archive")
     16 struct ArchiveTests {
     17 
     18     private let source = """
     19     Title: Test Puzzle
     20     Author: Test
     21 
     22 
     23     ABC
     24     D#E
     25     FGH
     26 
     27 
     28     A1. Across 1 ~ ABC
     29     A4. Across 4 ~ DE
     30     A5. Across 5 ~ FGH
     31     D1. Down 1 ~ ADF
     32     D2. Down 2 ~ BG
     33     D3. Down 3 ~ CEH
     34     """
     35 
     36     @Test("overlapping work for one key is coalesced")
     37     func overlappingWorkIsCoalesced() async {
     38         let coalescer = AsyncTaskCoalescer<UUID, Int>()
     39         let key = UUID()
     40         var starts = 0
     41 
     42         let first = Task { @MainActor in
     43             await coalescer.run(for: key) {
     44                 starts += 1
     45                 try? await Task.sleep(for: .milliseconds(20))
     46                 return 42
     47             }
     48         }
     49         while starts == 0 { await Task.yield() }
     50         let second = Task { @MainActor in
     51             await coalescer.run(for: key) {
     52                 starts += 1
     53                 return 99
     54             }
     55         }
     56 
     57         #expect(await first.value == 42)
     58         #expect(await second.value == 42)
     59         #expect(starts == 1)
     60 
     61         let later = await coalescer.run(for: key) {
     62             starts += 1
     63             return 7
     64         }
     65         #expect(later == 7)
     66         #expect(starts == 2)
     67     }
     68 
     69     @Test("retirement requires a complete materialized Chronicle")
     70     func retirementRequiresCompleteChronicle() async throws {
     71         let persistence = makeTestPersistence()
     72         let engine = try makeSyncEngine(persistence)
     73         let archiver = GameArchiver(
     74             container: CloudContainer.container,
     75             persistence: persistence,
     76             syncEngine: engine
     77         )
     78         let original = UUID()
     79         let ctx = persistence.viewContext
     80         let live = GameEntity(context: ctx)
     81         live.id = original
     82         live.title = "Live"
     83         live.puzzleSource = source
     84         live.createdAt = Date()
     85         live.updatedAt = Date()
     86         live.completedAt = Date()
     87         live.databaseScope = 1
     88         live.isAccessRevoked = true
     89         live.ckRecordName = "game-\(original.uuidString)"
     90 
     91         _ = Archive.materialize(
     92             Archive.payload(
     93                 from: sampleSnapshot(originalGameID: original),
     94                 replayState: .waiting(missing: 1)
     95             ),
     96             in: ctx
     97         )
     98         try ctx.save()
     99 
    100         #expect(await archiver.retireSupersededLiveRow(gameID: original) == false)
    101         let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    102         liveRequest.predicate = NSPredicate(format: "id == %@", original as CVarArg)
    103         #expect(try ctx.count(for: liveRequest) == 1)
    104     }
    105 
    106     @Test("retirement deletes the live row behind a complete Chronicle")
    107     func retirementDeletesLiveRow() async throws {
    108         let persistence = makeTestPersistence()
    109         let engine = try makeSyncEngine(persistence)
    110         let archiver = GameArchiver(
    111             container: CloudContainer.container,
    112             persistence: persistence,
    113             syncEngine: engine
    114         )
    115         let original = UUID()
    116         let ctx = persistence.viewContext
    117         let live = GameEntity(context: ctx)
    118         live.id = original
    119         live.title = "Live"
    120         live.puzzleSource = source
    121         live.createdAt = Date()
    122         live.updatedAt = Date()
    123         live.completedAt = Date()
    124         live.databaseScope = 1
    125         live.isAccessRevoked = true
    126         live.ckRecordName = "game-\(original.uuidString)"
    127 
    128         _ = Archive.materialize(
    129             Archive.payload(from: sampleSnapshot(originalGameID: original)),
    130             in: ctx
    131         )
    132         try ctx.save()
    133 
    134         #expect(await archiver.retireSupersededLiveRow(gameID: original))
    135         let verifyCtx = persistence.container.newBackgroundContext()
    136         let counts = await verifyCtx.perform {
    137             let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    138             liveRequest.predicate = NSPredicate(format: "id == %@", original as CVarArg)
    139             let chronicleRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    140             chronicleRequest.predicate = NSPredicate(
    141                 format: "id == %@",
    142                 Archive.archiveGameID(for: original) as CVarArg
    143             )
    144             return (
    145                 live: (try? verifyCtx.count(for: liveRequest)) ?? -1,
    146                 chronicle: (try? verifyCtx.count(for: chronicleRequest)) ?? -1
    147             )
    148         }
    149         #expect(counts.live == 0)
    150         #expect(counts.chronicle == 1)
    151     }
    152 
    153     @Test("retirement deletes an owned row handed over to its Chronicle")
    154     func retirementDeletesOwnedHandedOverRow() async throws {
    155         let persistence = makeTestPersistence()
    156         let engine = try makeSyncEngine(persistence)
    157         let archiver = GameArchiver(
    158             container: CloudContainer.container,
    159             persistence: persistence,
    160             syncEngine: engine
    161         )
    162         let original = UUID()
    163         let ctx = persistence.viewContext
    164         let live = GameEntity(context: ctx)
    165         live.id = original
    166         live.title = "Live"
    167         live.puzzleSource = source
    168         live.createdAt = Date()
    169         live.updatedAt = Date()
    170         live.completedAt = Date()
    171         // The owner's own game: never access-revoked, replaced by the Chronicle
    172         // when this account retired the zone.
    173         live.databaseScope = 0
    174         live.isSupersededByChronicle = true
    175         live.ckRecordName = "game-\(original.uuidString)"
    176 
    177         _ = Archive.materialize(
    178             Archive.payload(from: sampleSnapshot(originalGameID: original)),
    179             in: ctx
    180         )
    181         try ctx.save()
    182 
    183         #expect(await archiver.retireSupersededLiveRow(gameID: original))
    184         let verifyCtx = persistence.container.newBackgroundContext()
    185         let remaining = await verifyCtx.perform {
    186             let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    187             liveRequest.predicate = NSPredicate(format: "id == %@", original as CVarArg)
    188             return (try? verifyCtx.count(for: liveRequest)) ?? -1
    189         }
    190         #expect(remaining == 0)
    191     }
    192 
    193     @Test("the sweep collects handed-over rows and leaves ordinary ones alone")
    194     func sweepCollectsHandedOverRows() async throws {
    195         let persistence = makeTestPersistence()
    196         let engine = try makeSyncEngine(persistence)
    197         let archiver = GameArchiver(
    198             container: CloudContainer.container,
    199             persistence: persistence,
    200             syncEngine: engine
    201         )
    202         let ctx = persistence.viewContext
    203 
    204         let handedOver = UUID()
    205         let superseded = GameEntity(context: ctx)
    206         superseded.id = handedOver
    207         superseded.title = "Handed over"
    208         superseded.puzzleSource = source
    209         superseded.createdAt = Date()
    210         superseded.updatedAt = Date()
    211         superseded.completedAt = Date()
    212         superseded.isSupersededByChronicle = true
    213         superseded.ckRecordName = "game-\(handedOver.uuidString)"
    214 
    215         let ordinary = UUID()
    216         let finished = GameEntity(context: ctx)
    217         finished.id = ordinary
    218         finished.title = "Finished"
    219         finished.puzzleSource = source
    220         finished.createdAt = Date()
    221         finished.updatedAt = Date()
    222         finished.completedAt = Date()
    223         finished.ckRecordName = "game-\(ordinary.uuidString)"
    224         try ctx.save()
    225 
    226         // The handed-over row needs no promotion — asking for one would cost a
    227         // CloudKit read per list opening — and a finished game nothing has
    228         // replaced is not the sweep's business at all.
    229         let retirable = await archiver.sweepSupersededLiveGames()
    230         #expect(retirable == [handedOver])
    231     }
    232 
    233     @Test("recent Chronicle paging advances through the returned cursor")
    234     func recentPagingAdvancesCursor() async throws {
    235         let now = Date()
    236         let cutoff = now.addingTimeInterval(-7 * 24 * 60 * 60)
    237         var requestedCursors: [Int?] = []
    238 
    239         let result = try await CompletedMetadataPageWalker.recent(
    240             cutoff: cutoff,
    241             completedAt: { $0 }
    242         ) { cursor in
    243             requestedCursors.append(cursor)
    244             switch cursor {
    245             case nil:
    246                 return (
    247                     records: [now, now.addingTimeInterval(-60)],
    248                     cursor: 1
    249                 )
    250             case 1:
    251                 return (
    252                     records: [cutoff.addingTimeInterval(-1)],
    253                     cursor: 2
    254                 )
    255             default:
    256                 Issue.record("pager continued after finding an older record")
    257                 return (records: [], cursor: nil)
    258             }
    259         }
    260 
    261         #expect(requestedCursors == [nil, 1])
    262         #expect(result.selected.count == 2)
    263         #expect(result.buffered.count == 1)
    264         #expect(result.cursor == 2)
    265     }
    266 
    267     private func journalValue(
    268         seq: Int64,
    269         row: Int,
    270         col: Int,
    271         letter: String,
    272         actingAuthorID: String?
    273     ) -> JournalValue {
    274         JournalValue(
    275             seq: seq,
    276             timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(seq)),
    277             position: GridPosition(row: row, col: col),
    278             state: JournalCellState(letter: letter, mark: .pen(checked: nil), cellAuthorID: actingAuthorID),
    279             actingAuthorID: actingAuthorID,
    280             kind: .input,
    281             targetSeq: nil,
    282             batchID: nil,
    283             prevSeqAtCell: nil,
    284             direction: .across
    285         )
    286     }
    287 
    288     private func makeSyncEngine(_ persistence: PersistenceController) throws -> SyncEngine {
    289         SyncEngine(
    290             container: CloudContainer.container,
    291             persistence: persistence
    292         )
    293     }
    294 
    295     private let aliceKey = JournalDeviceKey(authorID: "alice", deviceID: "deviceA")
    296     private let bobKey = JournalDeviceKey(authorID: "bob", deviceID: "deviceB")
    297 
    298     private func sampleSnapshot(originalGameID: UUID) -> Archive.Snapshot {
    299         Archive.Snapshot(
    300             originalGameID: originalGameID,
    301             title: "Test Puzzle",
    302             puzzleSource: source,
    303             completedAt: Date(timeIntervalSince1970: 1_700_001_000),
    304             completedBy: "alice",
    305             solveSeconds: 743,
    306             wasShared: true,
    307             participants: [
    308                 .init(authorID: "alice", name: "Alice"),
    309                 .init(authorID: "bob", name: "Bob"),
    310             ],
    311             cells: [
    312                 .init(row: 0, col: 0, letter: "A", markCode: 0, letterAuthorID: "alice"),
    313                 .init(row: 0, col: 1, letter: "B", markCode: 0, letterAuthorID: "bob"),
    314                 .init(row: 2, col: 2, letter: "H", markCode: 0, letterAuthorID: "alice"),
    315             ],
    316             journal: [
    317                 DeviceJournal(key: aliceKey, entries: [
    318                     journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"),
    319                     journalValue(seq: 1, row: 2, col: 2, letter: "H", actingAuthorID: "alice"),
    320                 ]),
    321                 DeviceJournal(key: bobKey, entries: [
    322                     journalValue(seq: 0, row: 0, col: 1, letter: "B", actingAuthorID: "bob"),
    323                 ]),
    324             ]
    325         )
    326     }
    327 
    328     /// Normalizes per-device journals into a comparable, order-independent form.
    329     private func normalized(_ journals: [DeviceJournal]) -> [JournalDeviceKey: [JournalValue]] {
    330         Dictionary(uniqueKeysWithValues: journals.map { ($0.key, $0.entries) })
    331     }
    332 
    333     @Test("Chronicle completion metadata matches its compressed payload")
    334     func completionMetadataMatchesPayload() throws {
    335         let snapshot = sampleSnapshot(originalGameID: UUID())
    336         try withArchiveRecord(from: snapshot) { record in
    337             #expect(record["completedAt"] as? Date == snapshot.completedAt)
    338             #expect(Archive.payload(from: record)?.completedAt == snapshot.completedAt)
    339         }
    340     }
    341 
    342     /// A fetch that asks for fewer keys than `payloadDesiredKeys` returns a
    343     /// record whose missing `completedAt` fails the identity guard, so the
    344     /// payload decodes to nil and the caller sees an empty result rather than
    345     /// an error. Pins the constant as the contract every Chronicle fetch reads.
    346     @Test("A Chronicle fetched with payloadDesiredKeys still decodes")
    347     func payloadDesiredKeysSufficeToDecode() throws {
    348         let snapshot = sampleSnapshot(originalGameID: UUID())
    349         try withArchiveRecord(from: snapshot) { record in
    350             for key in record.allKeys()
    351             where !Archive.payloadDesiredKeys.contains(key) {
    352                 record[key] = nil as (any CKRecordValueProtocol)?
    353             }
    354             #expect(Archive.payload(from: record)?.originalGameID == snapshot.originalGameID)
    355 
    356             record["completedAt"] = nil as (any CKRecordValueProtocol)?
    357             #expect(Archive.payload(from: record) == nil)
    358         }
    359     }
    360 
    361     @Test("Chronicle rejects completion metadata that disagrees with its payload")
    362     func mismatchedCompletionMetadataRejected() throws {
    363         let snapshot = sampleSnapshot(originalGameID: UUID())
    364         try withArchiveRecord(from: snapshot) { record in
    365             record["completedAt"] = snapshot.completedAt.addingTimeInterval(1)
    366             #expect(Archive.payload(from: record) == nil)
    367         }
    368     }
    369 
    370     private func withArchiveRecord<T>(
    371         from snapshot: Archive.Snapshot,
    372         _ body: (CKRecord) throws -> T
    373     ) throws -> T {
    374         let package = try Archive.recordPackage(from: snapshot)
    375         defer {
    376             for url in package.temporaryAssetFileURLs {
    377                 try? FileManager.default.removeItem(at: url)
    378             }
    379         }
    380         return try body(package.record)
    381     }
    382 
    383     // MARK: - Identity
    384 
    385     @Test("archiveGameID is deterministic and distinct from the original")
    386     func deterministicArchiveID() {
    387         let original = UUID()
    388         let a = Archive.archiveGameID(for: original)
    389         let b = Archive.archiveGameID(for: original)
    390         #expect(a == b)
    391         #expect(a != original)
    392         #expect(Archive.archiveGameID(for: UUID()) != a)
    393     }
    394 
    395     @Test("zone and record names round-trip the original game id")
    396     func nameParsing() {
    397         let original = UUID()
    398         let name = Archive.recordName(forOriginalGameID: original)
    399         #expect(Archive.originalGameID(fromName: name) == original)
    400         #expect(Archive.originalGameID(
    401             fromName: Archive.legacyRecordName(forOriginalGameID: original)
    402         ) == original)
    403         #expect(Archive.isArchiveZone(Archive.zoneID.zoneName))
    404         #expect(Archive.isArchiveZone(Archive.legacyZoneID(
    405             forOriginalGameID: original
    406         ).zoneName))
    407         #expect(Archive.originalGameID(fromName: "game-\(original.uuidString)") == nil)
    408     }
    409 
    410     // MARK: - Wire format
    411 
    412     @Test("record ↔ payload round-trips every field, the grid, and the journal")
    413     func recordRoundTrip() throws {
    414         let original = UUID()
    415         let snapshot = sampleSnapshot(originalGameID: original)
    416         let payload = try withArchiveRecord(from: snapshot) { record in
    417             #expect(record.recordType == Archive.recordType)
    418             #expect(record.recordID.zoneID.zoneName == Archive.zoneName)
    419             return try #require(Archive.payload(from: record))
    420         }
    421         #expect(payload.originalGameID == original)
    422         #expect(payload.archiveGameID == Archive.archiveGameID(for: original))
    423         #expect(payload.title == snapshot.title)
    424         #expect(payload.completedAt == snapshot.completedAt)
    425         #expect(payload.completedBy == "alice")
    426         #expect(payload.solveSeconds == snapshot.solveSeconds)
    427         #expect(payload.formatVersion == Archive.currentPayloadFormatVersion)
    428         #expect(payload.wasShared)
    429         #expect(payload.participants == snapshot.participants)
    430         #expect(payload.puzzleSource == source)
    431         #expect(payload.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) } ==
    432                 snapshot.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) })
    433         #expect(normalized(payload.journal) == normalized(snapshot.journal))
    434     }
    435 
    436     @Test("format-1 Chronicle infers its shared contributors from journals")
    437     func legacyBlobInfersParticipants() throws {
    438         let snapshot = sampleSnapshot(originalGameID: UUID())
    439         let package = try Archive.recordPackage(
    440             from: snapshot,
    441             formatVersion: 1
    442         )
    443         defer {
    444             for url in package.temporaryAssetFileURLs {
    445                 try? FileManager.default.removeItem(at: url)
    446             }
    447         }
    448 
    449         let payload = try #require(Archive.payload(from: package.record))
    450         #expect(payload.formatVersion == 1)
    451         #expect(payload.wasShared)
    452         #expect(Set(payload.participants.map(\.authorID)) == ["alice", "bob"])
    453         #expect(payload.participants.allSatisfy { $0.name == nil })
    454     }
    455 
    456     @Test("record package exposes the temporary CKAsset files it creates")
    457     func recordPackageTracksTemporaryAssetFiles() throws {
    458         let original = UUID()
    459         let package = try Archive.recordPackage(from: sampleSnapshot(originalGameID: original))
    460         defer {
    461             for url in package.temporaryAssetFileURLs {
    462                 try? FileManager.default.removeItem(at: url)
    463             }
    464         }
    465 
    466         #expect(package.temporaryAssetFileURLs.count == 1)
    467         for url in package.temporaryAssetFileURLs {
    468             #expect(FileManager.default.fileExists(atPath: url.path))
    469         }
    470 
    471         let assetURLs = [(package.record[Archive.payloadKey] as? CKAsset)?.fileURL]
    472         #expect(Set(assetURLs.compactMap { $0 }) == Set(package.temporaryAssetFileURLs))
    473     }
    474 
    475     // MARK: - Compressed payload bounds
    476 
    477     private func withTamperedPayload<T>(
    478         data: Data,
    479         _ body: (Archive.Payload?, [String]) throws -> T
    480     ) throws -> T {
    481         let package = try Archive.recordPackage(from: sampleSnapshot(originalGameID: UUID()))
    482         let tampered = FileManager.default.temporaryDirectory
    483             .appendingPathComponent(UUID().uuidString)
    484         try data.write(to: tampered)
    485         defer {
    486             for url in package.temporaryAssetFileURLs + [tampered] {
    487                 try? FileManager.default.removeItem(at: url)
    488             }
    489         }
    490         package.record[Archive.payloadKey] = CKAsset(fileURL: tampered)
    491         var diagnostics: [String] = []
    492         let payload = Archive.payload(
    493             from: package.record,
    494             onDiagnostic: { diagnostics.append($0) }
    495         )
    496         return try body(payload, diagnostics)
    497     }
    498 
    499     @Test("an oversized compressed payload is rejected before reading")
    500     func oversizedPayloadRejected() throws {
    501         try withTamperedPayload(
    502             data: Data(count: Archive.maxPayloadAssetBytes + 1)
    503         ) { payload, diagnostics in
    504             #expect(payload == nil)
    505             #expect(diagnostics.count == 1)
    506             #expect(diagnostics.first?.contains("payload") == true)
    507             #expect(diagnostics.first?.contains("exceeds") == true)
    508         }
    509     }
    510 
    511     @Test("a corrupt compressed payload fails closed")
    512     func corruptPayloadRejected() throws {
    513         try withTamperedPayload(data: Data("not an archive".utf8)) { payload, diagnostics in
    514             #expect(payload == nil)
    515             #expect(diagnostics.count == 1)
    516             #expect(diagnostics.first?.contains("malformed") == true)
    517         }
    518     }
    519 
    520     @Test("a compressed payload over the cell-count cap is rejected whole")
    521     func cellCountExhaustionRejected() throws {
    522         let cells = (0...Archive.maxCellCount).map {
    523             Archive.Cell(
    524                 row: Int16($0 % 128), col: Int16($0 / 128 % 128),
    525                 letter: "A", markCode: 0, letterAuthorID: nil
    526             )
    527         }
    528         let base = sampleSnapshot(originalGameID: UUID())
    529         let snapshot = Archive.Snapshot(
    530             originalGameID: base.originalGameID,
    531             title: base.title,
    532             puzzleSource: base.puzzleSource,
    533             completedAt: base.completedAt,
    534             completedBy: base.completedBy,
    535             solveSeconds: base.solveSeconds,
    536             cells: cells,
    537             journal: base.journal
    538         )
    539         try withArchiveRecord(from: snapshot) { record in
    540             var diagnostics: [String] = []
    541             #expect(Archive.payload(from: record, onDiagnostic: { diagnostics.append($0) }) == nil)
    542             #expect(diagnostics.first?.contains("cells") == true)
    543         }
    544     }
    545 
    546     @Test("a compressed payload over the device-count cap is rejected whole")
    547     func journalDeviceCountExhaustionRejected() throws {
    548         let base = sampleSnapshot(originalGameID: UUID())
    549         let journals = (0...Archive.maxJournalDeviceCount).map {
    550             DeviceJournal(
    551                 key: JournalDeviceKey(authorID: "author\($0)", deviceID: "device"),
    552                 entries: []
    553             )
    554         }
    555         let snapshot = Archive.Snapshot(
    556             originalGameID: base.originalGameID,
    557             title: base.title,
    558             puzzleSource: base.puzzleSource,
    559             completedAt: base.completedAt,
    560             completedBy: base.completedBy,
    561             solveSeconds: base.solveSeconds,
    562             cells: base.cells,
    563             journal: journals
    564         )
    565         try withArchiveRecord(from: snapshot) { record in
    566             var diagnostics: [String] = []
    567             #expect(Archive.payload(from: record, onDiagnostic: { diagnostics.append($0) }) == nil)
    568             #expect(diagnostics.first?.contains("device journals") == true)
    569         }
    570     }
    571 
    572     @Test("a retention fallback omits journals and preserves the final state")
    573     func replayUnavailablePayload() throws {
    574         let snapshot = sampleSnapshot(originalGameID: UUID())
    575         let package = try Archive.recordPackage(from: snapshot, replayState: .unavailable)
    576         defer { package.temporaryAssetFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
    577         let payload = try #require(Archive.payload(from: package.record))
    578         #expect(!payload.replayAvailable)
    579         #expect(payload.journal.isEmpty)
    580         #expect(payload.cells == snapshot.cells)
    581         #expect(payload.puzzleSource == snapshot.puzzleSource)
    582         let persistence = makeTestPersistence()
    583         let game = try #require(Archive.materialize(payload, in: persistence.viewContext))
    584         #expect(game.replayUnavailable)
    585         #expect(!game.replayCacheComplete)
    586     }
    587 
    588     @Test("a provisional Chronicle waits for missing device journals")
    589     func replayPendingPayload() throws {
    590         let snapshot = sampleSnapshot(originalGameID: UUID())
    591         let package = try Archive.recordPackage(
    592             from: snapshot,
    593             replayState: .waiting(missing: 2)
    594         )
    595         defer { package.temporaryAssetFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
    596         let payload = try #require(Archive.payload(from: package.record))
    597         #expect(payload.replayState == .waiting(missing: 2))
    598         #expect(payload.journal.isEmpty)
    599 
    600         let persistence = makeTestPersistence()
    601         let game = try #require(Archive.materialize(payload, in: persistence.viewContext))
    602         #expect(!game.replayUnavailable)
    603         #expect(game.replayMissingDeviceCount?.intValue == 2)
    604         #expect(!game.replayCacheComplete)
    605     }
    606 
    607     @Test("the archive payload is compressed")
    608     func payloadIsCompressed() throws {
    609         let base = sampleSnapshot(originalGameID: UUID())
    610         let snapshot = Archive.Snapshot(
    611             originalGameID: base.originalGameID,
    612             title: base.title,
    613             puzzleSource: String(repeating: base.puzzleSource, count: 20),
    614             completedAt: base.completedAt,
    615             completedBy: base.completedBy,
    616             solveSeconds: base.solveSeconds,
    617             cells: base.cells,
    618             journal: base.journal
    619         )
    620         let package = try Archive.recordPackage(from: snapshot)
    621         defer { package.temporaryAssetFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
    622         let url = try #require((package.record[Archive.payloadKey] as? CKAsset)?.fileURL)
    623         let compressedBytes = try Data(contentsOf: url).count
    624         let uncompressedComponents = Data(snapshot.puzzleSource.utf8).count
    625             + (try JSONEncoder().encode(snapshot.cells)).count
    626             + snapshot.journal.reduce(0) { total, journal in
    627                 total + ((try? JournalCodec.encode(journal.entries).count) ?? 0)
    628             }
    629         #expect(compressedBytes < uncompressedComponents)
    630     }
    631 
    632     @Test("legacy three-asset records remain readable")
    633     func legacyRecordReadable() throws {
    634         struct WireJournal: Codable {
    635             let authorID: String
    636             let deviceID: String
    637             let entries: Data
    638         }
    639         let snapshot = sampleSnapshot(originalGameID: UUID())
    640         let zone = Archive.legacyZoneID(forOriginalGameID: snapshot.originalGameID)
    641         let record = CKRecord(
    642             recordType: Archive.legacyRecordType,
    643             recordID: CKRecord.ID(
    644                 recordName: Archive.legacyRecordName(
    645                     forOriginalGameID: snapshot.originalGameID
    646                 ),
    647                 zoneID: zone
    648             )
    649         )
    650         record["originalGameID"] = snapshot.originalGameID.uuidString as CKRecordValue
    651         record["archiveGameID"] = Archive.archiveGameID(for: snapshot.originalGameID).uuidString as CKRecordValue
    652         record["title"] = snapshot.title as CKRecordValue
    653         record["completedAt"] = snapshot.completedAt as CKRecordValue
    654         record["completedBy"] = snapshot.completedBy as CKRecordValue?
    655         record["solveSeconds"] = Int64(snapshot.solveSeconds) as CKRecordValue
    656 
    657         let files = (0..<3).map { _ in
    658             FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
    659         }
    660         defer { files.forEach { try? FileManager.default.removeItem(at: $0) } }
    661         try Data(snapshot.puzzleSource.utf8).write(to: files[0])
    662         try JSONEncoder().encode(snapshot.cells).write(to: files[1])
    663         let journals = try snapshot.journal.map {
    664             WireJournal(
    665                 authorID: $0.key.authorID,
    666                 deviceID: $0.key.deviceID,
    667                 entries: try JournalCodec.encode($0.entries)
    668             )
    669         }
    670         try JSONEncoder().encode(journals).write(to: files[2])
    671         record["puzzleSource"] = CKAsset(fileURL: files[0])
    672         record["cells"] = CKAsset(fileURL: files[1])
    673         record["journals"] = CKAsset(fileURL: files[2])
    674 
    675         let payload = try #require(Archive.payload(from: record))
    676         #expect(payload.replayAvailable)
    677         #expect(payload.originalGameID == snapshot.originalGameID)
    678         #expect(normalized(payload.journal) == normalized(snapshot.journal))
    679     }
    680 
    681     // MARK: - Convergence merge
    682 
    683     @Test("merging unions peer devices and keeps the local copy of shared keys")
    684     func mergingPriority() {
    685         let original = UUID()
    686         // Local snapshot has alice with one entry; a peer fetch has a *stale*
    687         // alice (should be ignored) and a new bob.
    688         let local = Archive.Snapshot(
    689             originalGameID: original,
    690             title: "T", puzzleSource: source,
    691             completedAt: Date(timeIntervalSince1970: 1),
    692             completedBy: nil,
    693             solveSeconds: 0,
    694             cells: [],
    695             journal: [DeviceJournal(key: aliceKey, entries: [
    696                 journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"),
    697             ])]
    698         )
    699         let peers = [
    700             DeviceJournal(key: aliceKey, entries: []),   // stale — must not win
    701             DeviceJournal(key: bobKey, entries: [
    702                 journalValue(seq: 0, row: 0, col: 1, letter: "B", actingAuthorID: "bob"),
    703             ]),
    704         ]
    705         let merged = normalized(Archive.merging(local, peerJournals: peers).journal)
    706         #expect(Set(merged.keys) == [aliceKey, bobKey])
    707         #expect(merged[aliceKey]?.count == 1)   // local alice kept, not the empty peer copy
    708         #expect(merged[bobKey]?.count == 1)     // peer bob added
    709     }
    710 
    711     // MARK: - Snapshot from Core Data
    712 
    713     @Test("snapshot reads a completed participant game's grid and journal")
    714     func snapshotFromStore() throws {
    715         let persistence = makeTestPersistence()
    716         let ctx = persistence.viewContext
    717         let gameID = UUID()
    718 
    719         let entity = GameEntity(context: ctx)
    720         entity.id = gameID
    721         entity.title = "Test Puzzle"
    722         entity.puzzleSource = source
    723         entity.createdAt = Date()
    724         entity.updatedAt = Date()
    725         entity.completedAt = Date(timeIntervalSince1970: 1_700_002_000)
    726         entity.completedBy = "alice"
    727         entity.databaseScope = 1
    728         entity.ckRecordName = "game-\(gameID.uuidString)"
    729 
    730         let cell = CellEntity(context: ctx)
    731         cell.game = entity
    732         cell.row = 0
    733         cell.col = 0
    734         cell.letter = "A"
    735         cell.markCode = 0
    736         cell.letterAuthorID = "alice"
    737 
    738         let journalRow = JournalEntity(context: ctx)
    739         journalRow.game = entity
    740         MovesJournal.assign(journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"),
    741                             to: journalRow, gameID: gameID)
    742         try ctx.save()
    743 
    744         let snapshot = try #require(Archive.snapshot(forGameID: gameID, in: ctx))
    745         #expect(snapshot.originalGameID == gameID)
    746         #expect(snapshot.completedBy == "alice")
    747         #expect(snapshot.cells.count == 1)
    748         #expect(snapshot.cells.first?.letter == "A")
    749         // The own log (sourceDeviceID == nil) becomes one per-device journal.
    750         #expect(snapshot.journal.count == 1)
    751         #expect(snapshot.journal.first?.entries.count == 1)
    752         #expect(snapshot.journal.first?.key.deviceID == RecordSerializer.localDeviceID)
    753     }
    754 
    755     @Test("snapshot is nil for an unfinished game")
    756     func snapshotNilWhenIncomplete() throws {
    757         let persistence = makeTestPersistence()
    758         let ctx = persistence.viewContext
    759         let gameID = UUID()
    760         let entity = GameEntity(context: ctx)
    761         entity.id = gameID
    762         entity.title = "Test"
    763         entity.puzzleSource = source
    764         entity.createdAt = Date()
    765         entity.updatedAt = Date()
    766         entity.databaseScope = 1
    767         try ctx.save()
    768         #expect(Archive.snapshot(forGameID: gameID, in: ctx) == nil)
    769     }
    770 
    771     // MARK: - Materialize
    772 
    773     @Test("materialize rebuilds a completed owned game with grid and journal")
    774     func materializeBuildsGame() throws {
    775         let persistence = makeTestPersistence()
    776         let ctx = persistence.viewContext
    777         let original = UUID()
    778         let payload = try withArchiveRecord(from: sampleSnapshot(originalGameID: original)) { record in
    779             try #require(Archive.payload(from: record))
    780         }
    781 
    782         let game = try #require(Archive.materialize(payload, in: ctx))
    783         #expect(game.id == Archive.archiveGameID(for: original))
    784         #expect(game.databaseScope == 0)            // owned
    785         #expect(game.ckZoneOwnerName == nil)        // owned
    786         #expect(game.completedAt == payload.completedAt)
    787         #expect(game.completedBy == "alice")
    788         #expect(game.finalSolveSeconds?.int64Value == 743)
    789         #expect(((game.cells as? Set<CellEntity>) ?? []).count == 3)
    790 
    791         let journalRows = (game.journal as? Set<JournalEntity>) ?? []
    792         #expect(journalRows.count == 3)            // alice's 2 + bob's 1
    793         // Every row carries its original device key (not this device's own log),
    794         // so the replay reader treats all authors as cached contributors.
    795         #expect(journalRows.allSatisfy { $0.sourceDeviceID != nil })
    796         #expect(Set(journalRows.compactMap { $0.sourceDeviceID }) == ["deviceA", "deviceB"])
    797         // Complete by construction, so replay serves from Core Data with no
    798         // shared-zone fetch.
    799         #expect(game.replayCacheComplete)
    800 
    801         let players = (game.players as? Set<PlayerEntity>) ?? []
    802         #expect(Set(players.compactMap(\.authorID)) == ["alice", "bob"])
    803         #expect(Set(players.compactMap(\.name)) == ["Alice", "Bob"])
    804 
    805         // The library can render it (owned + completed, parseable puzzle).
    806         let summary = try #require(GameSummary(entity: game))
    807         #expect(summary.isOwned)
    808         #expect(summary.isShared)
    809         #expect(Set(summary.allParticipants.map(\.authorID)) == ["alice", "bob"])
    810         #expect(summary.completedAt != nil)
    811 
    812         // Once retirement removes the live row, a shared Chronicle remains a
    813         // valid unread source for the app-icon count.
    814         game.latestOtherMoveAt = payload.completedAt
    815         game.readThroughAt = payload.completedAt.addingTimeInterval(-1)
    816         try ctx.save()
    817         let store = makeTestStore(persistence: persistence)
    818         #expect(store.unreadOtherMovesGameCount() == 1)
    819         #expect(store.unreadOtherMovesGameIDs() == [original])
    820         #expect(store.isCompletedGameFamily(gameID: original))
    821         #expect(store.advanceReadThrough(
    822             gameID: original,
    823             through: payload.completedAt
    824         ))
    825         #expect(store.unreadOtherMovesGameCount() == 0)
    826     }
    827 
    828     @Test("a materialized archive replays the full multi-author timeline locally")
    829     func materializedArchiveFeedsReplayCache() async throws {
    830         let persistence = makeTestPersistence()
    831         let store = makeTestStore(persistence: persistence)
    832         let ctx = persistence.viewContext
    833         let original = UUID()
    834         let payload = try withArchiveRecord(from: sampleSnapshot(originalGameID: original)) { record in
    835             try #require(Archive.payload(from: record))
    836         }
    837 
    838         _ = Archive.materialize(payload, in: ctx)
    839         try ctx.save()
    840 
    841         let archiveID = Archive.archiveGameID(for: original)
    842         let cached = try #require(await store.cachedRemoteJournals(forGameID: archiveID))
    843         // Both contributors are served from the local cache (no shared zone).
    844         #expect(Set(cached.map { $0.key }) == [aliceKey, bobKey])
    845         #expect(cached.reduce(0) { $0 + $1.entries.count } == 3)
    846     }
    847 
    848     @Test("a materialized format-1 Chronicle restores players and archived replay routing")
    849     func legacyBlobMaterializesRecoveredSemantics() async throws {
    850         let persistence = makeTestPersistence()
    851         let store = makeTestStore(persistence: persistence)
    852         let original = UUID()
    853         let package = try Archive.recordPackage(
    854             from: sampleSnapshot(originalGameID: original),
    855             formatVersion: 1
    856         )
    857         defer {
    858             for url in package.temporaryAssetFileURLs {
    859                 try? FileManager.default.removeItem(at: url)
    860             }
    861         }
    862         let payload = try #require(Archive.payload(from: package.record))
    863         let game = try #require(Archive.materialize(
    864             payload,
    865             in: persistence.viewContext
    866         ))
    867         try persistence.viewContext.save()
    868 
    869         let players = (game.players as? Set<PlayerEntity>) ?? []
    870         #expect(Set(players.compactMap(\.authorID)) == ["alice", "bob"])
    871         #expect(players.allSatisfy { ($0.name ?? "").isEmpty })
    872         #expect(GameSummary(entity: game)?.isShared == true)
    873         #expect(store.isGameArchived(gameID: game.id!))
    874         #expect(store.isGameArchived(game))
    875         #expect(await store.cachedRemoteJournals(forGameID: game.id!)?.count == 2)
    876     }
    877 
    878     @Test("a projection created before roster support repairs from cached Chronicle data")
    879     func staleLegacyProjectionRecoversParticipantsAndCellAuthors() async throws {
    880         let persistence = makeTestPersistence()
    881         let ctx = persistence.viewContext
    882         let original = UUID(uuidString: "00000000-0000-0000-0000-000000000004")!
    883         let package = try Archive.recordPackage(
    884             from: sampleSnapshot(originalGameID: original),
    885             formatVersion: 1
    886         )
    887         defer {
    888             for url in package.temporaryAssetFileURLs {
    889                 try? FileManager.default.removeItem(at: url)
    890             }
    891         }
    892         let payload = try #require(Archive.payload(from: package.record))
    893         let entity = try #require(Archive.materialize(payload, in: ctx))
    894 
    895         // Reproduce the local projection written by the first Chronicle build:
    896         // replay and final cells survived, but no roster marker or Player rows
    897         // were materialised.
    898         entity.archiveParticipants = nil
    899         for player in (entity.players as? Set<PlayerEntity>) ?? [] {
    900             ctx.delete(player)
    901         }
    902         for cell in (entity.cells as? Set<CellEntity>) ?? [] {
    903             cell.letter = ""
    904             cell.markCode = 0
    905             cell.letterAuthorID = nil
    906         }
    907         try ctx.save()
    908 
    909         let summary = try #require(GameSummary(entity: entity))
    910         #expect(summary.isShared)
    911         #expect(Set(summary.allParticipants.map(\.authorID)) == ["alice", "bob"])
    912 
    913         let store = makeTestStore(
    914             persistence: persistence,
    915             authorIDProvider: { "alice" }
    916         )
    917         let (game, mutator) = try store.loadGame(id: entity.id!)
    918         #expect(!mutator.isShared)
    919         #expect(mutator.isArchived)
    920         #expect(mutator.showsPlayerAttribution)
    921         #expect(game.squares[0][0].letterAuthorID == "alice")
    922         #expect(game.squares[0][1].letterAuthorID == "bob")
    923 
    924         let preferences = PlayerPreferences(
    925             local: UserDefaults(suiteName: "archive-roster-\(UUID().uuidString)")!
    926         )
    927         let roster = PlayerRoster(
    928             gameID: entity.id!,
    929             authorIdentity: AuthorIdentity(testing: "alice"),
    930             preferences: preferences,
    931             persistence: persistence,
    932             container: CloudContainer.container
    933         )
    934         await roster.preload()
    935         #expect(Set(roster.entries.map(\.authorID)) == ["alice", "bob"])
    936         let expectedBobColor = PlayerColor.assignedCompanions(
    937             forSortedAuthorIDs: ["bob"],
    938             inGame: original,
    939             anchor: preferences.color
    940         ).first
    941         #expect(roster.entries.first { $0.authorID == "bob" }?.color == expectedBobColor)
    942     }
    943 
    944     @Test("archive retry window expires 14 days after completion")
    945     func archiveRetryWindowExpiresAfterFourteenDays() {
    946         let completedAt = Date(timeIntervalSince1970: 1_700_000_000)
    947         #expect(!GameArchiver.hasArchiveRetryExpired(
    948             completedAt: completedAt,
    949             now: completedAt.addingTimeInterval(GameArchiver.archiveRetryWindow - 1)
    950         ))
    951         #expect(GameArchiver.hasArchiveRetryExpired(
    952             completedAt: completedAt,
    953             now: completedAt.addingTimeInterval(GameArchiver.archiveRetryWindow)
    954         ))
    955     }
    956 
    957     @Test("a settled provisional Chronicle is not rewritten on every pass")
    958     func provisionalChronicleWritesOnlyOnChange() {
    959         let alice = JournalDeviceKey(authorID: "alice", deviceID: "phone")
    960         let bob = JournalDeviceKey(authorID: "bob", deviceID: "pad")
    961         // A waiting Chronicle is always stored with an empty journal, so its
    962         // stored keys can never cover the merged local history. That must not
    963         // by itself force a re-upload.
    964         let waiting = (
    965             isLegacy: false,
    966             formatVersion: Archive.currentPayloadFormatVersion,
    967             replayState: Archive.ReplayState.waiting(missing: 1),
    968             journalKeys: Set<JournalDeviceKey>()
    969         )
    970         #expect(!GameArchiver.chronicleNeedsWrite(
    971             stored: waiting,
    972             replayState: .waiting(missing: 1),
    973             presentJournalKeys: [alice]
    974         ))
    975         // A changed missing count is real news and still writes.
    976         #expect(GameArchiver.chronicleNeedsWrite(
    977             stored: waiting,
    978             replayState: .waiting(missing: 2),
    979             presentJournalKeys: [alice]
    980         ))
    981         // So is completing, and so is a complete Chronicle that is genuinely
    982         // missing a journal this pass merged in.
    983         #expect(GameArchiver.chronicleNeedsWrite(
    984             stored: waiting,
    985             replayState: .available,
    986             presentJournalKeys: [alice]
    987         ))
    988         #expect(GameArchiver.chronicleNeedsWrite(
    989             stored: (
    990                 isLegacy: false,
    991                 formatVersion: Archive.currentPayloadFormatVersion,
    992                 replayState: .available,
    993                 journalKeys: [alice]
    994             ),
    995             replayState: .available,
    996             presentJournalKeys: [alice, bob]
    997         ))
    998         #expect(!GameArchiver.chronicleNeedsWrite(
    999             stored: (
   1000                 isLegacy: false,
   1001                 formatVersion: Archive.currentPayloadFormatVersion,
   1002                 replayState: .available,
   1003                 journalKeys: [alice, bob]
   1004             ),
   1005             replayState: .available,
   1006             presentJournalKeys: [alice, bob]
   1007         ))
   1008         // No Chronicle, a legacy one, and a stale format all still write.
   1009         #expect(GameArchiver.chronicleNeedsWrite(
   1010             stored: nil,
   1011             replayState: .waiting(missing: 1),
   1012             presentJournalKeys: [alice]
   1013         ))
   1014         #expect(GameArchiver.chronicleNeedsWrite(
   1015             stored: (
   1016                 isLegacy: true,
   1017                 formatVersion: Archive.currentPayloadFormatVersion,
   1018                 replayState: .waiting(missing: 1),
   1019                 journalKeys: []
   1020             ),
   1021             replayState: .waiting(missing: 1),
   1022             presentJournalKeys: [alice]
   1023         ))
   1024         #expect(GameArchiver.chronicleNeedsWrite(
   1025             stored: (
   1026                 isLegacy: false,
   1027                 formatVersion: Archive.currentPayloadFormatVersion - 1,
   1028                 replayState: .waiting(missing: 1),
   1029                 journalKeys: []
   1030             ),
   1031             replayState: .waiting(missing: 1),
   1032             presentJournalKeys: [alice]
   1033         ))
   1034     }
   1035 
   1036     @Test("an out-of-range missing count degrades instead of failing the Chronicle")
   1037     func outOfRangeMissingCountDegradesToUnavailable() throws {
   1038         let snapshot = sampleSnapshot(originalGameID: UUID())
   1039         let package = try Archive.recordPackage(
   1040             from: snapshot,
   1041             replayState: .waiting(missing: Archive.maxJournalDeviceCount + 1)
   1042         )
   1043         defer { package.temporaryAssetFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
   1044         // The count only words a progress message; the rest of the payload is
   1045         // still verified and playable, so it must not take the archive down.
   1046         let payload = try #require(Archive.payload(from: package.record))
   1047         #expect(payload.replayState == .unavailable)
   1048         #expect(payload.journal.isEmpty)
   1049     }
   1050 
   1051     @Test("migration grace gives old completions 14 days from v1.1.0 adoption")
   1052     func archiveRetryWindowUsesLaterMigrationStart() {
   1053         let completedAt = Date(timeIntervalSince1970: 1_600_000_000)
   1054         let graceStart = Date(timeIntervalSince1970: 1_700_000_000)
   1055         #expect(!GameArchiver.hasArchiveRetryExpired(
   1056             completedAt: completedAt,
   1057             graceStart: graceStart,
   1058             now: graceStart.addingTimeInterval(GameArchiver.archiveRetryWindow - 1)
   1059         ))
   1060         #expect(GameArchiver.hasArchiveRetryExpired(
   1061             completedAt: completedAt,
   1062             graceStart: graceStart,
   1063             now: graceStart.addingTimeInterval(GameArchiver.archiveRetryWindow)
   1064         ))
   1065     }
   1066 
   1067     @Test("materialize is idempotent — a second application creates no duplicate")
   1068     func materializeIdempotent() throws {
   1069         let persistence = makeTestPersistence()
   1070         let ctx = persistence.viewContext
   1071         let original = UUID()
   1072         let payload = try withArchiveRecord(from: sampleSnapshot(originalGameID: original)) { record in
   1073             try #require(Archive.payload(from: record))
   1074         }
   1075 
   1076         _ = Archive.materialize(payload, in: ctx)
   1077         _ = Archive.materialize(payload, in: ctx)
   1078         try ctx.save()
   1079 
   1080         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1081         req.predicate = NSPredicate(format: "id == %@",
   1082                                     Archive.archiveGameID(for: original) as CVarArg)
   1083         #expect(try ctx.count(for: req) == 1)
   1084     }
   1085 
   1086     @Test("a complete Chronicle replaces an earlier waiting projection")
   1087     func materializeUpgradesWaitingReplay() throws {
   1088         let persistence = makeTestPersistence()
   1089         let ctx = persistence.viewContext
   1090         let original = UUID()
   1091         let snapshot = sampleSnapshot(originalGameID: original)
   1092 
   1093         let fallback = Archive.payload(from: snapshot, replayState: .waiting(missing: 2))
   1094         let initial = try #require(Archive.materialize(fallback, in: ctx))
   1095         try ctx.save()
   1096         #expect(!initial.replayUnavailable)
   1097         #expect(initial.replayMissingDeviceCount?.intValue == 2)
   1098         #expect(((initial.journal as? Set<JournalEntity>) ?? []).isEmpty)
   1099 
   1100         let complete = Archive.payload(from: snapshot)
   1101         let upgraded = try #require(Archive.materialize(complete, in: ctx))
   1102         try ctx.save()
   1103 
   1104         #expect(upgraded === initial)
   1105         #expect(!upgraded.replayUnavailable)
   1106         #expect(upgraded.replayMissingDeviceCount == nil)
   1107         #expect(upgraded.replayCacheComplete)
   1108         #expect(((upgraded.journal as? Set<JournalEntity>) ?? []).count == 3)
   1109 
   1110         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1111         req.predicate = NSPredicate(
   1112             format: "id == %@",
   1113             Archive.archiveGameID(for: original) as CVarArg
   1114         )
   1115         #expect(try ctx.count(for: req) == 1)
   1116     }
   1117 
   1118     // MARK: - Dedup in the inbound applier
   1119 
   1120     @Test("applier skips materialization while a live original exists")
   1121     func applierSkipsWhenOriginalLive() throws {
   1122         let persistence = makeTestPersistence()
   1123         let engine = try makeSyncEngine(persistence)
   1124         let ctx = persistence.viewContext
   1125         let original = UUID()
   1126 
   1127         // The live shared original this device is still playing.
   1128         let live = GameEntity(context: ctx)
   1129         live.id = original
   1130         live.title = "Live"
   1131         live.puzzleSource = source
   1132         live.createdAt = Date()
   1133         live.updatedAt = Date()
   1134         live.databaseScope = 1
   1135         live.ckRecordName = "game-\(original.uuidString)"
   1136         try ctx.save()
   1137 
   1138         let result = try withArchiveRecord(from: sampleSnapshot(originalGameID: original)) { record in
   1139             engine.applyArchiveRecord(record, in: ctx)
   1140         }
   1141         #expect(result == nil)
   1142 
   1143         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1144         req.predicate = NSPredicate(format: "id == %@",
   1145                                     Archive.archiveGameID(for: original) as CVarArg)
   1146         #expect(try ctx.count(for: req) == 0)
   1147     }
   1148 
   1149     @Test("preferred Chronicle hides but retains its live Game")
   1150     func preferredChronicleReplacesLiveGameInList() throws {
   1151         let persistence = makeTestPersistence()
   1152         let engine = try makeSyncEngine(persistence)
   1153         let ctx = persistence.viewContext
   1154         let original = UUID(uuidString: "00000000-0000-0000-0000-000000000004")!
   1155 
   1156         let live = GameEntity(context: ctx)
   1157         live.id = original
   1158         live.title = "Live"
   1159         live.puzzleSource = source
   1160         live.createdAt = Date()
   1161         live.updatedAt = Date()
   1162         live.databaseScope = 1
   1163         live.ckRecordName = "game-\(original.uuidString)"
   1164         live.completedAt = Date(timeIntervalSince1970: 1_700_001_000)
   1165         live.latestOtherMoveAt = Date(timeIntervalSince1970: 1_700_000_900)
   1166         live.readThroughAt = Date(timeIntervalSince1970: 1_700_000_800)
   1167         for (authorID, name) in [("alice", "Alice"), ("bob", "Bob")] {
   1168             let player = PlayerEntity(context: ctx)
   1169             player.game = live
   1170             player.ckRecordName = RecordSerializer.recordName(
   1171                 forPlayerInGame: original,
   1172                 authorID: authorID
   1173             )
   1174             player.authorID = authorID
   1175             player.name = name
   1176             player.updatedAt = Date()
   1177         }
   1178         try ctx.save()
   1179 
   1180         let result = try withArchiveRecord(
   1181             from: sampleSnapshot(originalGameID: original)
   1182         ) { record in
   1183             engine.applyPreferredArchiveRecord(record, in: ctx)
   1184         }
   1185         #expect(result == Archive.archiveGameID(for: original))
   1186         #expect(!live.isHidden)
   1187         #expect(live.isSupersededByChronicle)
   1188 
   1189         let archiveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1190         archiveReq.predicate = NSPredicate(
   1191             format: "id == %@",
   1192             Archive.archiveGameID(for: original) as CVarArg
   1193         )
   1194         let archive = try #require(ctx.fetch(archiveReq).first)
   1195         #expect(!archive.isHidden)
   1196         #expect(!archive.isSupersededByChronicle)
   1197         // Derived columns are populated at materialization, so queries that
   1198         // read them (rather than reparsing the source) can see Chronicles.
   1199         #expect(archive.gridWidth == 3)
   1200         #expect(archive.blockMask?.count == 9)
   1201 
   1202         let liveSummary = try #require(GameSummary(
   1203             entity: live,
   1204             localAuthorID: "alice",
   1205             localColor: .blue
   1206         ))
   1207         let archiveSummary = try #require(GameSummary(
   1208             entity: archive,
   1209             localAuthorID: "alice",
   1210             localColor: .blue
   1211         ))
   1212         #expect(liveSummary.id != archiveSummary.id)
   1213         #expect(liveSummary.listID == original)
   1214         #expect(archiveSummary.listID == original)
   1215         #expect(liveSummary.hasUnreadOtherMoves)
   1216         #expect(archiveSummary.hasUnreadOtherMoves)
   1217         #expect(
   1218             liveSummary.allParticipants.first { $0.authorID == "bob" }?.color
   1219                 == archiveSummary.allParticipants.first { $0.authorID == "bob" }?.color
   1220         )
   1221 
   1222         // Both persisted rows describe one unread game, and opening the
   1223         // visible Chronicle clears the canonical live state plus its
   1224         // projection rather than addressing only the derived archive UUID.
   1225         let store = makeTestStore(persistence: persistence)
   1226         #expect(store.canonicalGameID(for: archive.id!) == original)
   1227         #expect(store.unreadOtherMovesGameCount() == 1)
   1228         _ = try store.loadGame(id: archive.id!)
   1229         let reviewedLive = try #require(GameSummary(entity: live))
   1230         let reviewedArchive = try #require(GameSummary(entity: archive))
   1231         #expect(!reviewedLive.hasUnreadOtherMoves)
   1232         #expect(!reviewedArchive.hasUnreadOtherMoves)
   1233         #expect(store.unreadOtherMovesGameCount() == 0)
   1234     }
   1235 
   1236     @Test("block reconciliation preserves legacy Chronicle replacement")
   1237     func blockReconciliationPreservesChronicleReplacement() throws {
   1238         let persistence = makeTestPersistence()
   1239         let engine = try makeSyncEngine(persistence)
   1240         let ctx = persistence.viewContext
   1241         let original = UUID()
   1242 
   1243         let live = GameEntity(context: ctx)
   1244         live.id = original
   1245         live.title = "Live"
   1246         live.puzzleSource = source
   1247         live.createdAt = Date()
   1248         live.updatedAt = Date()
   1249         live.databaseScope = 1
   1250         live.ckRecordName = "game-\(original.uuidString)"
   1251 
   1252         _ = try withArchiveRecord(
   1253             from: sampleSnapshot(originalGameID: original)
   1254         ) { record in
   1255             engine.applyPreferredArchiveRecord(record, in: ctx)
   1256         }
   1257 
   1258         // Reproduce the on-disk state written by the build that overloaded
   1259         // `isHidden` for both blocking and Chronicle replacement.
   1260         live.isHidden = true
   1261         live.isSupersededByChronicle = false
   1262 
   1263         #expect(GameEntity.reconcileBlockedFriendHiddenGames(
   1264             forGameIDs: [original],
   1265             in: ctx
   1266         ) == 1)
   1267         #expect(!live.isHidden)
   1268         #expect(live.isSupersededByChronicle)
   1269 
   1270         let visibleReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1271         visibleReq.predicate = GameEntity.visibleInGameListPredicate
   1272         let visible = try ctx.fetch(visibleReq)
   1273         #expect(visible.count == 1)
   1274         #expect(visible.first?.id == Archive.archiveGameID(for: original))
   1275     }
   1276 
   1277     @Test("applier materializes when the original is absent")
   1278     func applierMaterializesWhenAbsent() throws {
   1279         let persistence = makeTestPersistence()
   1280         let engine = try makeSyncEngine(persistence)
   1281         let ctx = persistence.viewContext
   1282         let original = UUID()
   1283 
   1284         let result = try withArchiveRecord(from: sampleSnapshot(originalGameID: original)) { record in
   1285             engine.applyArchiveRecord(record, in: ctx)
   1286         }
   1287         #expect(result == Archive.archiveGameID(for: original))
   1288     }
   1289 
   1290     @Test("applier materializes when the original is revoked")
   1291     func applierMaterializesWhenRevoked() throws {
   1292         let persistence = makeTestPersistence()
   1293         let engine = try makeSyncEngine(persistence)
   1294         let ctx = persistence.viewContext
   1295         let original = UUID()
   1296 
   1297         let revoked = GameEntity(context: ctx)
   1298         revoked.id = original
   1299         revoked.title = "Revoked"
   1300         revoked.puzzleSource = source
   1301         revoked.createdAt = Date()
   1302         revoked.updatedAt = Date()
   1303         revoked.databaseScope = 1
   1304         revoked.isAccessRevoked = true
   1305         revoked.ckRecordName = "game-\(original.uuidString)"
   1306         try ctx.save()
   1307 
   1308         let result = try withArchiveRecord(from: sampleSnapshot(originalGameID: original)) { record in
   1309             engine.applyArchiveRecord(record, in: ctx)
   1310         }
   1311         #expect(result == Archive.archiveGameID(for: original))
   1312     }
   1313 }