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 }