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