crossmate

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

JournalReplayTests.swift (11005B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 /// Cross-device replay reconstruction (`ReplayTimeline`) and the strict
      7 /// completeness gate (`ReplayAssembler`). Both are pure value types, so these
      8 /// tests build `JournalValue`s directly — no Core Data, no CloudKit.
      9 @Suite("Journal replay")
     10 struct JournalReplayTests {
     11 
     12     private func entry(
     13         seq: Int64,
     14         at seconds: TimeInterval,
     15         row: Int,
     16         col: Int,
     17         letter: String,
     18         mark: CellMark = .none,
     19         author: String = "author",
     20         kind: JournalKind = .input,
     21         batchID: UUID? = nil,
     22         direction: Puzzle.Direction? = nil
     23     ) -> JournalValue {
     24         JournalValue(
     25             seq: seq,
     26             timestamp: Date(timeIntervalSince1970: seconds),
     27             position: GridPosition(row: row, col: col),
     28             state: JournalCellState(letter: letter, mark: mark, cellAuthorID: author),
     29             actingAuthorID: author,
     30             kind: kind,
     31             targetSeq: nil,
     32             batchID: batchID,
     33             prevSeqAtCell: nil,
     34             direction: direction
     35         )
     36     }
     37 
     38     private func pos(_ row: Int, _ col: Int) -> GridPosition {
     39         GridPosition(row: row, col: col)
     40     }
     41 
     42     // MARK: - Merge ordering
     43 
     44     @Test("merge orders every device's entries by timestamp")
     45     func mergeOrdersByTimestamp() {
     46         let a = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A")
     47         let b = entry(seq: 1, at: 30, row: 0, col: 1, letter: "B")
     48         let c = entry(seq: 0, at: 20, row: 1, col: 0, letter: "C", author: "other")
     49 
     50         let timeline = ReplayTimeline(merging: [[a, b], [c]])
     51 
     52         #expect(timeline.entries.map(\.state.letter) == ["A", "C", "B"])
     53     }
     54 
     55     @Test("same-timestamp ties break deterministically regardless of fetch order")
     56     func tiesAreDeterministic() {
     57         // Same instant, different acting authors: the tiebreak orders on
     58         // author then seq, so fetch order can't change the result.
     59         let x = entry(seq: 5, at: 10, row: 0, col: 0, letter: "X", author: "zzz")
     60         let y = entry(seq: 0, at: 10, row: 0, col: 1, letter: "Y", author: "aaa")
     61 
     62         let forward = ReplayTimeline(merging: [[x], [y]])
     63         let reversed = ReplayTimeline(merging: [[y], [x]])
     64 
     65         #expect(forward.entries.map(\.state.letter) == ["Y", "X"])
     66         #expect(forward == reversed)
     67     }
     68 
     69     // MARK: - State reconstruction
     70 
     71     @Test("state(through:) replays last-write-per-cell across devices")
     72     func stateReconstructsGrid() {
     73         let a = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "d1")
     74         let c = entry(seq: 0, at: 20, row: 0, col: 1, letter: "C", author: "d2")
     75         let b = entry(seq: 1, at: 30, row: 0, col: 0, letter: "B", author: "d1") // overwrites A
     76 
     77         let timeline = ReplayTimeline(merging: [[a, b], [c]])
     78         #expect(timeline.count == 3)
     79 
     80         #expect(timeline.state(through: 0).isEmpty)
     81 
     82         let afterFirst = timeline.state(through: 1)
     83         #expect(afterFirst[pos(0, 0)]?.letter == "A")
     84         #expect(afterFirst[pos(0, 1)] == nil)
     85 
     86         let afterSecond = timeline.state(through: 2)
     87         #expect(afterSecond[pos(0, 0)]?.letter == "A")
     88         #expect(afterSecond[pos(0, 1)]?.letter == "C")
     89 
     90         let afterThird = timeline.state(through: 3)
     91         #expect(afterThird[pos(0, 0)]?.letter == "B")
     92         #expect(afterThird[pos(0, 1)]?.letter == "C")
     93 
     94         // count clamps to the available range.
     95         #expect(timeline.state(through: 99) == afterThird)
     96         #expect(timeline.state(through: -5).isEmpty)
     97     }
     98 
     99     @Test("undo rows replay as a normal timestamped restore")
    100     func undoReplaysAsRestore() {
    101         let typed = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", kind: .input)
    102         // An undo carries the restored after-state (empty here) and its own time.
    103         let undone = entry(seq: 1, at: 20, row: 0, col: 0, letter: "", kind: .undo)
    104 
    105         let timeline = ReplayTimeline(merging: [[typed, undone]])
    106 
    107         #expect(timeline.state(through: 1)[pos(0, 0)]?.letter == "A")
    108         #expect(timeline.state(through: 2)[pos(0, 0)]?.letter == "")
    109     }
    110 
    111     @Test("check/reveal marks surface during replay")
    112     func marksSurface() {
    113         let typed = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A",
    114                           mark: .pen(checked: nil), kind: .input)
    115         let checked = entry(seq: 1, at: 20, row: 0, col: 0, letter: "A",
    116                             mark: .pen(checked: .wrong), kind: .check)
    117 
    118         let timeline = ReplayTimeline(merging: [[typed, checked]])
    119 
    120         #expect(timeline.state(through: 1)[pos(0, 0)]?.mark == .pen(checked: nil))
    121         #expect(timeline.state(through: 2)[pos(0, 0)]?.mark == .pen(checked: .wrong))
    122     }
    123 
    124     @Test("a batched gesture (e.g. reveal puzzle) is a single replay step")
    125     func batchedGestureIsOneStep() {
    126         let batch = UUID()
    127         let reveals = [
    128             entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", kind: .reveal, batchID: batch),
    129             entry(seq: 1, at: 10, row: 0, col: 1, letter: "B", kind: .reveal, batchID: batch),
    130             entry(seq: 2, at: 10, row: 1, col: 0, letter: "C", kind: .reveal, batchID: batch),
    131         ]
    132         // A single letter typed earlier is its own step; the reveal is one more.
    133         let typed = entry(seq: 0, at: 5, row: 2, col: 2, letter: "Z")
    134         let timeline = ReplayTimeline(merging: [[typed], reveals])
    135 
    136         #expect(timeline.count == 2)                 // typed, then the whole reveal
    137         #expect(timeline.state(through: 1).count == 1)
    138         #expect(timeline.state(through: 2).count == 4) // reveal lands all three at once
    139         #expect(timeline.focus(ofStep: 0) == pos(2, 2)) // single cell has a playhead
    140         #expect(timeline.focus(ofStep: 1) == nil)       // batch has none
    141     }
    142 
    143     @Test("single-cell replay steps expose their recorded cursor direction")
    144     func singleCellStepExposesDirection() {
    145         let typed = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", direction: .down)
    146         let batch = UUID()
    147         let clear = [
    148             entry(seq: 1, at: 20, row: 0, col: 0, letter: "", kind: .clear, batchID: batch, direction: .down),
    149             entry(seq: 2, at: 20, row: 0, col: 1, letter: "", kind: .clear, batchID: batch, direction: .down),
    150         ]
    151 
    152         let timeline = ReplayTimeline(merging: [[typed] + clear])
    153 
    154         #expect(timeline.direction(ofStep: 0) == .down)
    155         #expect(timeline.direction(ofStep: 1) == nil)
    156     }
    157 
    158     // MARK: - Completeness gate
    159 
    160     private let d1 = JournalDeviceKey(authorID: "a1", deviceID: "dev1")
    161     private let d2 = JournalDeviceKey(authorID: "a1", deviceID: "dev2")
    162     private let d3 = JournalDeviceKey(authorID: "a2", deviceID: "dev3")
    163 
    164     @Test("ready once a journal is present for every expected device")
    165     func readyWhenComplete() {
    166         let peer = entry(seq: 0, at: 20, row: 0, col: 1, letter: "B", author: "a1")
    167         let mine = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "a1")
    168 
    169         // d2's journal arrives over the network; d1 (local) supplies its own.
    170         let fetch = JournalReplayFetch(
    171             journals: [DeviceJournal(key: d2, entries: [peer])],
    172             expectedDevices: [d1, d2]
    173         )
    174         let result = ReplayAssembler.assemble(fetch: fetch, localKey: d1, localEntries: [mine])
    175 
    176         guard case .ready(let timeline) = result else {
    177             Issue.record("expected .ready, got \(result)")
    178             return
    179         }
    180         #expect(timeline.count == 2)
    181         #expect(timeline.entries.map(\.state.letter) == ["A", "B"])
    182     }
    183 
    184     @Test("waiting reports how many expected devices haven't uploaded")
    185     func waitingWhenIncomplete() {
    186         let peer = entry(seq: 0, at: 20, row: 0, col: 1, letter: "B", author: "a1")
    187         let mine = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "a1")
    188 
    189         // d3 contributed (in expected) but its journal is absent.
    190         let fetch = JournalReplayFetch(
    191             journals: [DeviceJournal(key: d2, entries: [peer])],
    192             expectedDevices: [d1, d2, d3]
    193         )
    194         let result = ReplayAssembler.assemble(fetch: fetch, localKey: d1, localEntries: [mine])
    195 
    196         #expect(result == .waiting(missing: 1))
    197     }
    198 
    199     @Test("local live log overrides a stale uploaded copy of itself")
    200     func localOverlayWins() {
    201         let stale = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "a1")
    202         let fresh1 = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "a1")
    203         let fresh2 = entry(seq: 1, at: 15, row: 0, col: 1, letter: "B", author: "a1")
    204 
    205         // The fetch already contains d1's own (stale, 1-entry) upload.
    206         let fetch = JournalReplayFetch(
    207             journals: [DeviceJournal(key: d1, entries: [stale])],
    208             expectedDevices: [d1]
    209         )
    210         let result = ReplayAssembler.assemble(
    211             fetch: fetch,
    212             localKey: d1,
    213             localEntries: [fresh1, fresh2]
    214         )
    215 
    216         guard case .ready(let timeline) = result else {
    217             Issue.record("expected .ready, got \(result)")
    218             return
    219         }
    220         #expect(timeline.count == 2) // live two-entry log, not the stale one
    221     }
    222 
    223     // MARK: - Per-game memo
    224 
    225     @Test("a cached timeline short-circuits without re-assembling")
    226     func memoServesCache() async {
    227         let cached = ReplayTimeline(merging: [[entry(seq: 0, at: 10, row: 0, col: 0, letter: "A")]])
    228         var assembleCalls = 0
    229         var hits = 0
    230 
    231         let result = await ReplayAssembler.memoised(
    232             cached: cached,
    233             onHit: { _ in hits += 1 },
    234             store: { _ in Issue.record("must not cache on a hit") }
    235         ) {
    236             assembleCalls += 1
    237             return .unavailable
    238         }
    239 
    240         #expect(result == .ready(cached))
    241         #expect(assembleCalls == 0) // never re-ran the merge
    242         #expect(hits == 1)
    243     }
    244 
    245     @Test("a ready assembly is cached; waiting/unavailable are not")
    246     func memoCachesOnlyReady() async {
    247         // Ready → stored.
    248         var storedReady: ReplayTimeline?
    249         let ready = ReplayTimeline(merging: [[entry(seq: 0, at: 10, row: 0, col: 0, letter: "A")]])
    250         _ = await ReplayAssembler.memoised(cached: nil, store: { storedReady = $0 }) { .ready(ready) }
    251         #expect(storedReady == ready)
    252 
    253         // Waiting → never stored, stays retryable.
    254         var storedWaiting = false
    255         let waiting = await ReplayAssembler.memoised(
    256             cached: nil,
    257             store: { _ in storedWaiting = true }
    258         ) { .waiting(missing: 2) }
    259         #expect(waiting == .waiting(missing: 2))
    260         #expect(!storedWaiting)
    261 
    262         // Unavailable → never stored.
    263         var storedUnavailable = false
    264         let unavailable = await ReplayAssembler.memoised(
    265             cached: nil,
    266             store: { _ in storedUnavailable = true }
    267         ) { .unavailable }
    268         #expect(unavailable == .unavailable)
    269         #expect(!storedUnavailable)
    270     }
    271 }