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 }