crossmate

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

ArchiveTests.swift (45404B)


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