crossmate

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

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 }