ReplayCacheTests.swift (5656B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 /// `GameStore`'s on-device replay cache: once a finished game's remote journals 8 /// are fetched in full they are stored as `JournalEntity` rows carrying a source 9 /// device key, so re-opening the game replays from Core Data without a CloudKit 10 /// round-trip. These rows must round-trip faithfully *and* stay out of this 11 /// device's own log (undo/redo and the journal upload read the same table). 12 @Suite("Replay cache", .serialized) 13 @MainActor 14 struct ReplayCacheTests { 15 16 private func makeGame(in persistence: PersistenceController) -> UUID { 17 let context = persistence.viewContext 18 let gameID = UUID() 19 let entity = GameEntity(context: context) 20 entity.id = gameID 21 entity.title = "Cached Game" 22 entity.puzzleSource = "Title: Cached\n\n\nAB\n\n\nA1. _ ~ AB" 23 entity.createdAt = Date() 24 entity.updatedAt = Date() 25 entity.ckRecordName = "game-\(gameID.uuidString)" 26 try? context.save() 27 return gameID 28 } 29 30 private func remoteJournal( 31 author: String, 32 device: String, 33 letters: [(Int64, String)] 34 ) -> DeviceJournal { 35 let entries = letters.map { seq, letter in 36 JournalValue( 37 seq: seq, 38 timestamp: Date(timeIntervalSince1970: TimeInterval(seq)), 39 position: GridPosition(row: 0, col: Int(seq)), 40 state: JournalCellState(letter: letter, mark: .none, cellAuthorID: author), 41 actingAuthorID: author, 42 kind: .input, 43 targetSeq: nil, 44 batchID: nil, 45 prevSeqAtCell: nil, 46 direction: nil 47 ) 48 } 49 return DeviceJournal( 50 key: JournalDeviceKey(authorID: author, deviceID: device), 51 entries: entries 52 ) 53 } 54 55 @Test("cache is nil until a complete fetch is stored") 56 func cacheEmptyBeforeStore() async { 57 let persistence = makeTestPersistence() 58 let store = makeTestStore(persistence: persistence) 59 let gameID = makeGame(in: persistence) 60 61 #expect(await store.cachedRemoteJournals(forGameID: gameID) == nil) 62 } 63 64 @Test("stored remote journals round-trip, grouped by source device") 65 func storedJournalsRoundTrip() async { 66 let persistence = makeTestPersistence() 67 let store = makeTestStore(persistence: persistence) 68 let gameID = makeGame(in: persistence) 69 70 let a = remoteJournal(author: "alice", device: "dev-a", letters: [(0, "A"), (1, "B")]) 71 let b = remoteJournal(author: "bob", device: "dev-b", letters: [(0, "C")]) 72 await store.cacheRemoteJournals([a, b], forGameID: gameID) 73 74 let cached = await store.cachedRemoteJournals(forGameID: gameID) 75 let byKey = Dictionary( 76 uniqueKeysWithValues: (cached ?? []).map { ($0.key, $0.entries) } 77 ) 78 #expect(cached?.count == 2) 79 #expect(byKey[a.key]?.map(\.state.letter) == ["A", "B"]) 80 #expect(byKey[b.key]?.map(\.state.letter) == ["C"]) 81 } 82 83 @Test("solo game with no remote contributors still caches as complete") 84 func soloGameCachesAsComplete() async { 85 let persistence = makeTestPersistence() 86 let store = makeTestStore(persistence: persistence) 87 let gameID = makeGame(in: persistence) 88 89 await store.cacheRemoteJournals([], forGameID: gameID) 90 91 // Non-nil (complete), but empty — distinct from the un-cached nil. 92 let cached = await store.cachedRemoteJournals(forGameID: gameID) 93 #expect(cached != nil) 94 #expect(cached?.isEmpty == true) 95 } 96 97 @Test("re-caching replaces prior remote rows") 98 func reCacheReplaces() async { 99 let persistence = makeTestPersistence() 100 let store = makeTestStore(persistence: persistence) 101 let gameID = makeGame(in: persistence) 102 103 await store.cacheRemoteJournals( 104 [remoteJournal(author: "alice", device: "dev-a", letters: [(0, "A")])], 105 forGameID: gameID 106 ) 107 await store.cacheRemoteJournals( 108 [remoteJournal(author: "bob", device: "dev-b", letters: [(0, "C")])], 109 forGameID: gameID 110 ) 111 112 let cached = await store.cachedRemoteJournals(forGameID: gameID) 113 #expect(cached?.count == 1) 114 #expect(cached?.first?.key.authorID == "bob") 115 } 116 117 @Test("cached remote rows stay out of this device's own journal") 118 func remoteRowsExcludedFromLocalLog() async { 119 let persistence = makeTestPersistence() 120 let store = makeTestStore(persistence: persistence) 121 let gameID = makeGame(in: persistence) 122 123 // Seed one *local* entry (no source key) through the real write path. 124 let localJournal = MovesJournal(persistence: persistence) 125 _ = localJournal.record( 126 gameID: gameID, 127 position: GridPosition(row: 0, col: 0), 128 state: JournalCellState(letter: "L", mark: .none, cellAuthorID: "me"), 129 actingAuthorID: "me", 130 kind: .input, 131 targetSeq: nil, 132 batchID: nil 133 ) 134 await localJournal.flush() 135 136 // Cache a remote device's journal into the same table. 137 await store.cacheRemoteJournals( 138 [remoteJournal(author: "alice", device: "dev-a", letters: [(0, "A"), (1, "B")])], 139 forGameID: gameID 140 ) 141 142 // The local-log reader sees only the local row, never the cached remotes. 143 let local = store.localJournalEntries(for: gameID) 144 #expect(local.map(\.state.letter) == ["L"]) 145 } 146 }