crossmate

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

ArchiveTests.swift (15473B)


      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     private func journalValue(
     37         seq: Int64,
     38         row: Int,
     39         col: Int,
     40         letter: String,
     41         actingAuthorID: String?
     42     ) -> JournalValue {
     43         JournalValue(
     44             seq: seq,
     45             timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(seq)),
     46             position: GridPosition(row: row, col: col),
     47             state: JournalCellState(letter: letter, mark: .pen(checked: nil), cellAuthorID: actingAuthorID),
     48             actingAuthorID: actingAuthorID,
     49             kind: .input,
     50             targetSeq: nil,
     51             batchID: nil,
     52             prevSeqAtCell: nil,
     53             direction: .across
     54         )
     55     }
     56 
     57     private func makeSyncEngine(_ persistence: PersistenceController) throws -> SyncEngine {
     58         SyncEngine(
     59             container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"),
     60             persistence: persistence
     61         )
     62     }
     63 
     64     private let aliceKey = JournalDeviceKey(authorID: "alice", deviceID: "deviceA")
     65     private let bobKey = JournalDeviceKey(authorID: "bob", deviceID: "deviceB")
     66 
     67     private func sampleSnapshot(originalGameID: UUID) -> Archive.Snapshot {
     68         Archive.Snapshot(
     69             originalGameID: originalGameID,
     70             title: "Test Puzzle",
     71             puzzleSource: source,
     72             completedAt: Date(timeIntervalSince1970: 1_700_001_000),
     73             completedBy: "alice",
     74             solveSeconds: 743,
     75             cells: [
     76                 .init(row: 0, col: 0, letter: "A", markCode: 0, letterAuthorID: "alice"),
     77                 .init(row: 0, col: 1, letter: "B", markCode: 0, letterAuthorID: "bob"),
     78                 .init(row: 2, col: 2, letter: "H", markCode: 0, letterAuthorID: "alice"),
     79             ],
     80             journal: [
     81                 DeviceJournal(key: aliceKey, entries: [
     82                     journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"),
     83                     journalValue(seq: 1, row: 2, col: 2, letter: "H", actingAuthorID: "alice"),
     84                 ]),
     85                 DeviceJournal(key: bobKey, entries: [
     86                     journalValue(seq: 0, row: 0, col: 1, letter: "B", actingAuthorID: "bob"),
     87                 ]),
     88             ]
     89         )
     90     }
     91 
     92     /// Normalizes per-device journals into a comparable, order-independent form.
     93     private func normalized(_ journals: [DeviceJournal]) -> [JournalDeviceKey: [JournalValue]] {
     94         Dictionary(uniqueKeysWithValues: journals.map { ($0.key, $0.entries) })
     95     }
     96 
     97     // MARK: - Identity
     98 
     99     @Test("archiveGameID is deterministic and distinct from the original")
    100     func deterministicArchiveID() {
    101         let original = UUID()
    102         let a = Archive.archiveGameID(for: original)
    103         let b = Archive.archiveGameID(for: original)
    104         #expect(a == b)
    105         #expect(a != original)
    106         #expect(Archive.archiveGameID(for: UUID()) != a)
    107     }
    108 
    109     @Test("zone and record names round-trip the original game id")
    110     func nameParsing() {
    111         let original = UUID()
    112         let name = Archive.recordName(forOriginalGameID: original)
    113         #expect(Archive.originalGameID(fromName: name) == original)
    114         #expect(Archive.isArchiveZone(
    115             Archive.zoneID(forOriginalGameID: original).zoneName
    116         ))
    117         #expect(Archive.originalGameID(fromName: "game-\(original.uuidString)") == nil)
    118     }
    119 
    120     // MARK: - Wire format
    121 
    122     @Test("record ↔ payload round-trips every field, the grid, and the journal")
    123     func recordRoundTrip() throws {
    124         let original = UUID()
    125         let snapshot = sampleSnapshot(originalGameID: original)
    126         let record = try Archive.record(from: snapshot)
    127 
    128         #expect(record.recordType == Archive.recordType)
    129         #expect(record.recordID.zoneID.zoneName == "archive-\(original.uuidString)")
    130 
    131         let payload = try #require(Archive.payload(from: record))
    132         #expect(payload.originalGameID == original)
    133         #expect(payload.archiveGameID == Archive.archiveGameID(for: original))
    134         #expect(payload.title == snapshot.title)
    135         #expect(payload.completedAt == snapshot.completedAt)
    136         #expect(payload.completedBy == "alice")
    137         #expect(payload.solveSeconds == snapshot.solveSeconds)
    138         #expect(payload.puzzleSource == source)
    139         #expect(payload.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) } ==
    140                 snapshot.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) })
    141         #expect(normalized(payload.journal) == normalized(snapshot.journal))
    142     }
    143 
    144     // MARK: - Convergence merge
    145 
    146     @Test("merging unions peer devices and keeps the local copy of shared keys")
    147     func mergingPriority() {
    148         let original = UUID()
    149         // Local snapshot has alice with one entry; a peer fetch has a *stale*
    150         // alice (should be ignored) and a new bob.
    151         let local = Archive.Snapshot(
    152             originalGameID: original,
    153             title: "T", puzzleSource: source,
    154             completedAt: Date(timeIntervalSince1970: 1),
    155             completedBy: nil,
    156             solveSeconds: 0,
    157             cells: [],
    158             journal: [DeviceJournal(key: aliceKey, entries: [
    159                 journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"),
    160             ])]
    161         )
    162         let peers = [
    163             DeviceJournal(key: aliceKey, entries: []),   // stale — must not win
    164             DeviceJournal(key: bobKey, entries: [
    165                 journalValue(seq: 0, row: 0, col: 1, letter: "B", actingAuthorID: "bob"),
    166             ]),
    167         ]
    168         let merged = normalized(Archive.merging(local, peerJournals: peers).journal)
    169         #expect(Set(merged.keys) == [aliceKey, bobKey])
    170         #expect(merged[aliceKey]?.count == 1)   // local alice kept, not the empty peer copy
    171         #expect(merged[bobKey]?.count == 1)     // peer bob added
    172     }
    173 
    174     // MARK: - Snapshot from Core Data
    175 
    176     @Test("snapshot reads a completed participant game's grid and journal")
    177     func snapshotFromStore() throws {
    178         let persistence = makeTestPersistence()
    179         let ctx = persistence.viewContext
    180         let gameID = UUID()
    181 
    182         let entity = GameEntity(context: ctx)
    183         entity.id = gameID
    184         entity.title = "Test Puzzle"
    185         entity.puzzleSource = source
    186         entity.createdAt = Date()
    187         entity.updatedAt = Date()
    188         entity.completedAt = Date(timeIntervalSince1970: 1_700_002_000)
    189         entity.completedBy = "alice"
    190         entity.databaseScope = 1
    191         entity.ckRecordName = "game-\(gameID.uuidString)"
    192 
    193         let cell = CellEntity(context: ctx)
    194         cell.game = entity
    195         cell.row = 0
    196         cell.col = 0
    197         cell.letter = "A"
    198         cell.markCode = 0
    199         cell.letterAuthorID = "alice"
    200 
    201         let journalRow = JournalEntity(context: ctx)
    202         journalRow.game = entity
    203         MovesJournal.assign(journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"),
    204                             to: journalRow, gameID: gameID)
    205         try ctx.save()
    206 
    207         let snapshot = try #require(Archive.snapshot(forGameID: gameID, in: ctx))
    208         #expect(snapshot.originalGameID == gameID)
    209         #expect(snapshot.completedBy == "alice")
    210         #expect(snapshot.cells.count == 1)
    211         #expect(snapshot.cells.first?.letter == "A")
    212         // The own log (sourceDeviceID == nil) becomes one per-device journal.
    213         #expect(snapshot.journal.count == 1)
    214         #expect(snapshot.journal.first?.entries.count == 1)
    215         #expect(snapshot.journal.first?.key.deviceID == RecordSerializer.localDeviceID)
    216     }
    217 
    218     @Test("snapshot is nil for an unfinished game")
    219     func snapshotNilWhenIncomplete() throws {
    220         let persistence = makeTestPersistence()
    221         let ctx = persistence.viewContext
    222         let gameID = UUID()
    223         let entity = GameEntity(context: ctx)
    224         entity.id = gameID
    225         entity.title = "Test"
    226         entity.puzzleSource = source
    227         entity.createdAt = Date()
    228         entity.updatedAt = Date()
    229         entity.databaseScope = 1
    230         try ctx.save()
    231         #expect(Archive.snapshot(forGameID: gameID, in: ctx) == nil)
    232     }
    233 
    234     // MARK: - Materialize
    235 
    236     @Test("materialize rebuilds a completed owned game with grid and journal")
    237     func materializeBuildsGame() throws {
    238         let persistence = makeTestPersistence()
    239         let ctx = persistence.viewContext
    240         let original = UUID()
    241         let record = try Archive.record(from: sampleSnapshot(originalGameID: original))
    242         let payload = try #require(Archive.payload(from: record))
    243 
    244         let game = try #require(Archive.materialize(payload, in: ctx))
    245         #expect(game.id == Archive.archiveGameID(for: original))
    246         #expect(game.databaseScope == 0)            // owned
    247         #expect(game.ckZoneOwnerName == nil)        // owned
    248         #expect(game.completedAt == payload.completedAt)
    249         #expect(game.completedBy == "alice")
    250         #expect(game.finalSolveSeconds?.int64Value == 743)
    251         #expect(((game.cells as? Set<CellEntity>) ?? []).count == 3)
    252 
    253         let journalRows = (game.journal as? Set<JournalEntity>) ?? []
    254         #expect(journalRows.count == 3)            // alice's 2 + bob's 1
    255         // Every row carries its original device key (not this device's own log),
    256         // so the replay reader treats all authors as cached contributors.
    257         #expect(journalRows.allSatisfy { $0.sourceDeviceID != nil })
    258         #expect(Set(journalRows.compactMap { $0.sourceDeviceID }) == ["deviceA", "deviceB"])
    259         // Complete by construction, so replay serves from Core Data with no
    260         // shared-zone fetch.
    261         #expect(game.replayCacheComplete)
    262 
    263         // The library can render it (owned + completed, parseable puzzle).
    264         let summary = try #require(GameSummary(entity: game))
    265         #expect(summary.isOwned)
    266         #expect(summary.completedAt != nil)
    267     }
    268 
    269     @Test("a materialized archive replays the full multi-author timeline locally")
    270     func materializedArchiveFeedsReplayCache() async throws {
    271         let persistence = makeTestPersistence()
    272         let store = makeTestStore(persistence: persistence)
    273         let ctx = persistence.viewContext
    274         let original = UUID()
    275         let record = try Archive.record(from: sampleSnapshot(originalGameID: original))
    276         let payload = try #require(Archive.payload(from: record))
    277 
    278         _ = Archive.materialize(payload, in: ctx)
    279         try ctx.save()
    280 
    281         let archiveID = Archive.archiveGameID(for: original)
    282         let cached = try #require(await store.cachedRemoteJournals(forGameID: archiveID))
    283         // Both contributors are served from the local cache (no shared zone).
    284         #expect(Set(cached.map { $0.key }) == [aliceKey, bobKey])
    285         #expect(cached.reduce(0) { $0 + $1.entries.count } == 3)
    286     }
    287 
    288     @Test("archive retry window expires 14 days after completion")
    289     func archiveRetryWindowExpiresAfterFourteenDays() {
    290         let completedAt = Date(timeIntervalSince1970: 1_700_000_000)
    291         #expect(!GameArchiver.hasArchiveRetryExpired(
    292             completedAt: completedAt,
    293             now: completedAt.addingTimeInterval(GameArchiver.archiveRetryWindow - 1)
    294         ))
    295         #expect(GameArchiver.hasArchiveRetryExpired(
    296             completedAt: completedAt,
    297             now: completedAt.addingTimeInterval(GameArchiver.archiveRetryWindow)
    298         ))
    299     }
    300 
    301     @Test("materialize is idempotent — a second application creates no duplicate")
    302     func materializeIdempotent() throws {
    303         let persistence = makeTestPersistence()
    304         let ctx = persistence.viewContext
    305         let original = UUID()
    306         let record = try Archive.record(from: sampleSnapshot(originalGameID: original))
    307         let payload = try #require(Archive.payload(from: record))
    308 
    309         _ = Archive.materialize(payload, in: ctx)
    310         _ = Archive.materialize(payload, in: ctx)
    311         try ctx.save()
    312 
    313         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    314         req.predicate = NSPredicate(format: "id == %@",
    315                                     Archive.archiveGameID(for: original) as CVarArg)
    316         #expect(try ctx.count(for: req) == 1)
    317     }
    318 
    319     // MARK: - Dedup in the inbound applier
    320 
    321     @Test("applier skips materialization while a live original exists")
    322     func applierSkipsWhenOriginalLive() throws {
    323         let persistence = makeTestPersistence()
    324         let engine = try makeSyncEngine(persistence)
    325         let ctx = persistence.viewContext
    326         let original = UUID()
    327 
    328         // The live shared original this device is still playing.
    329         let live = GameEntity(context: ctx)
    330         live.id = original
    331         live.title = "Live"
    332         live.puzzleSource = source
    333         live.createdAt = Date()
    334         live.updatedAt = Date()
    335         live.databaseScope = 1
    336         live.ckRecordName = "game-\(original.uuidString)"
    337         try ctx.save()
    338 
    339         let record = try Archive.record(from: sampleSnapshot(originalGameID: original))
    340         let result = engine.applyArchiveRecord(record, in: ctx)
    341         #expect(result == nil)
    342 
    343         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    344         req.predicate = NSPredicate(format: "id == %@",
    345                                     Archive.archiveGameID(for: original) as CVarArg)
    346         #expect(try ctx.count(for: req) == 0)
    347     }
    348 
    349     @Test("applier materializes when the original is absent")
    350     func applierMaterializesWhenAbsent() throws {
    351         let persistence = makeTestPersistence()
    352         let engine = try makeSyncEngine(persistence)
    353         let ctx = persistence.viewContext
    354         let original = UUID()
    355 
    356         let record = try Archive.record(from: sampleSnapshot(originalGameID: original))
    357         let result = engine.applyArchiveRecord(record, in: ctx)
    358         #expect(result == Archive.archiveGameID(for: original))
    359     }
    360 
    361     @Test("applier materializes when the original is revoked")
    362     func applierMaterializesWhenRevoked() throws {
    363         let persistence = makeTestPersistence()
    364         let engine = try makeSyncEngine(persistence)
    365         let ctx = persistence.viewContext
    366         let original = UUID()
    367 
    368         let revoked = GameEntity(context: ctx)
    369         revoked.id = original
    370         revoked.title = "Revoked"
    371         revoked.puzzleSource = source
    372         revoked.createdAt = Date()
    373         revoked.updatedAt = Date()
    374         revoked.databaseScope = 1
    375         revoked.isAccessRevoked = true
    376         revoked.ckRecordName = "game-\(original.uuidString)"
    377         try ctx.save()
    378 
    379         let record = try Archive.record(from: sampleSnapshot(originalGameID: original))
    380         let result = engine.applyArchiveRecord(record, in: ctx)
    381         #expect(result == Archive.archiveGameID(for: original))
    382     }
    383 }