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 }