AuthorDeltaTests.swift (10021B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("GridStateMerger.mergeWithProvenance") 7 struct GridStateMergerProvenanceTests { 8 9 private let gameID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! 10 11 private func view( 12 author: String, 13 device: String = "d1", 14 cells: [(row: Int, col: Int, letter: String, updatedAt: Date)] 15 ) -> MovesValue { 16 var dict: [GridPosition: TimestampedCell] = [:] 17 for entry in cells { 18 dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell( 19 letter: entry.letter, 20 markKind: 0, 21 checkedWrong: false, 22 updatedAt: entry.updatedAt, 23 authorID: author 24 ) 25 } 26 return MovesValue( 27 gameID: gameID, 28 authorID: author, 29 deviceID: device, 30 cells: dict, 31 updatedAt: cells.map(\.updatedAt).max() ?? .distantPast 32 ) 33 } 34 35 @Test("Provenance carries the writer authorID, not just the cell author") 36 func writerAttribution() { 37 let alice = view( 38 author: "alice", 39 cells: [(0, 0, "A", Date(timeIntervalSince1970: 5))] 40 ) 41 let result = GridStateMerger.mergeWithProvenance([alice]) 42 let entry = result[GridPosition(row: 0, col: 0)] 43 #expect(entry?.writerAuthorID == "alice") 44 #expect(entry?.cell.letter == "A") 45 #expect(entry?.cell.updatedAt == Date(timeIntervalSince1970: 5)) 46 } 47 48 @Test("Cleared cells (empty letter) are retained with their writer") 49 func emptyCellRetained() { 50 let cleared = view( 51 author: "alice", 52 cells: [(0, 0, "", Date(timeIntervalSince1970: 9))] 53 ) 54 let result = GridStateMerger.mergeWithProvenance([cleared]) 55 let entry = result[GridPosition(row: 0, col: 0)] 56 #expect(entry?.cell.letter == "") 57 #expect(entry?.writerAuthorID == "alice") 58 } 59 60 @Test("LWW winner's writer survives across multiple devices") 61 func lwwWinnerWriter() { 62 let alice = view( 63 author: "alice", 64 device: "ipad", 65 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 66 ) 67 let bob = view( 68 author: "bob", 69 device: "phone", 70 cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] 71 ) 72 let result = GridStateMerger.mergeWithProvenance([alice, bob]) 73 #expect(result[GridPosition(row: 0, col: 0)]?.writerAuthorID == "bob") 74 } 75 } 76 77 @Suite("RecordApplier.authorDeltas") 78 struct AuthorDeltaTests { 79 80 private let gameA = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! 81 private let gameB = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! 82 83 /// Convenience to build a per-game provenance map without going through 84 /// GridStateMerger — keeps tests focused on the diff logic. 85 private func snapshot( 86 _ entries: [(row: Int, col: Int, letter: String, writer: String, updatedAt: Date)] 87 ) -> [GridPosition: GridStateMerger.Provenance] { 88 var out: [GridPosition: GridStateMerger.Provenance] = [:] 89 for entry in entries { 90 out[GridPosition(row: entry.row, col: entry.col)] = GridStateMerger.Provenance( 91 cell: TimestampedCell( 92 letter: entry.letter, 93 markKind: 0, 94 checkedWrong: false, 95 updatedAt: entry.updatedAt, 96 authorID: entry.writer 97 ), 98 writerAuthorID: entry.writer 99 ) 100 } 101 return out 102 } 103 104 private let recentEnough = Date(timeIntervalSince1970: 10_000) 105 private let cutoff = Date(timeIntervalSince1970: 9_000) 106 107 @Test("Empty-to-letter transition counts as added, attributed to the writer") 108 func addedCount() { 109 let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]] 110 let after = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])] 111 let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) 112 #expect(deltas.count == 1) 113 #expect(deltas[0].gameID == gameA) 114 #expect(deltas[0].authorID == "alice") 115 #expect(deltas[0].added == 1) 116 #expect(deltas[0].cleared == 0) 117 } 118 119 @Test("Letter-to-empty transition counts as cleared, attributed to the clearing writer") 120 func clearedCount() { 121 // Bob wrote "B" originally, then Alice's record overwrites that cell 122 // with an empty letter — the clear is attributed to Alice, who is 123 // the LWW-winning writer of the post-apply state. 124 let before = [gameA: snapshot([(0, 0, "B", "bob", recentEnough)])] 125 let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])] 126 let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) 127 #expect(deltas.count == 1) 128 #expect(deltas[0].authorID == "alice") 129 #expect(deltas[0].added == 0) 130 #expect(deltas[0].cleared == 1) 131 } 132 133 @Test("Letter-to-different-letter contributes zero (no add, no clear)") 134 func overwriteIsNeutral() { 135 let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])] 136 let after = [gameA: snapshot([(0, 0, "B", "bob", recentEnough.addingTimeInterval(1))])] 137 let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) 138 #expect(deltas.isEmpty) 139 } 140 141 @Test("Cells unchanged between before and after contribute zero") 142 func unchangedCells() { 143 // Re-applying the same merged-grid state (idempotent CKSyncEngine 144 // re-delivery) yields no deltas. 145 let stamp = recentEnough 146 let same = snapshot([(0, 0, "A", "alice", stamp)]) 147 let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff) 148 #expect(deltas.isEmpty) 149 } 150 151 @Test("Cells whose winning entry predates cutoff are gated out") 152 func cutoffGateExcludesStale() { 153 let stale = Date(timeIntervalSince1970: 5_000) // well before cutoff 154 let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]] 155 let after = [gameA: snapshot([(0, 0, "A", "alice", stale)])] 156 let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) 157 #expect(deltas.isEmpty) 158 } 159 160 @Test("Adds and clears in the same batch aggregate per author") 161 func aggregatesPerAuthor() { 162 let before = [gameA: snapshot([ 163 (0, 0, "X", "alice", recentEnough), 164 (0, 1, "Y", "alice", recentEnough), 165 ])] 166 let after = [gameA: snapshot([ 167 (0, 0, "", "alice", recentEnough.addingTimeInterval(1)), // cleared 168 (0, 1, "Y", "alice", recentEnough), // unchanged 169 (0, 2, "Z", "alice", recentEnough.addingTimeInterval(2)), // added 170 (0, 3, "W", "alice", recentEnough.addingTimeInterval(3)), // added 171 ])] 172 let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) 173 #expect(deltas.count == 1) 174 #expect(deltas[0].added == 2) 175 #expect(deltas[0].cleared == 1) 176 #expect(deltas[0].latestUpdate == recentEnough.addingTimeInterval(3)) 177 } 178 179 @Test("Multi-device same-author writes all attribute to one bucket") 180 func multiDeviceSameAuthor() { 181 // Alice's iPad wrote A at (0,0) in the prior batch; her iPhone 182 // arrives with a clear at the same cell. Both writes have authorID 183 // = "alice", so the cleared count rolls up into one bucket — the 184 // hard problem the design was supposed to solve. 185 let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])] 186 let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])] 187 let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) 188 #expect(deltas.count == 1) 189 #expect(deltas[0].authorID == "alice") 190 #expect(deltas[0].cleared == 1) 191 } 192 193 @Test("Different authors in the same game produce distinct buckets") 194 func separateAuthorsSeparateBuckets() { 195 let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]] 196 let after = [gameA: snapshot([ 197 (0, 0, "A", "alice", recentEnough), 198 (1, 1, "B", "bob", recentEnough), 199 ])] 200 let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) 201 let byAuthor = Dictionary(uniqueKeysWithValues: deltas.map { ($0.authorID, $0) }) 202 #expect(byAuthor["alice"]?.added == 1) 203 #expect(byAuthor["bob"]?.added == 1) 204 } 205 206 @Test("Deltas across multiple games are surfaced independently") 207 func multiGameDeltas() { 208 let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [ 209 gameA: [:], 210 gameB: [:], 211 ] 212 let after = [ 213 gameA: snapshot([(0, 0, "A", "alice", recentEnough)]), 214 gameB: snapshot([(0, 0, "B", "bob", recentEnough)]), 215 ] 216 let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) 217 #expect(deltas.count == 2) 218 #expect(Set(deltas.map { $0.gameID }) == Set([gameA, gameB])) 219 } 220 221 @Test("LWW-loser writes don't show up: if the cell didn't change, no delta") 222 func lwwLoserIgnored() { 223 // Before: Alice already wrote A at t=20. After-apply still shows A 224 // at t=20 (LWW kept Alice's value despite a stale Bob write being 225 // applied at t=5, which lost to Alice and so doesn't change the 226 // merged-grid). The diff sees no transition. 227 let stamp = recentEnough.addingTimeInterval(20) 228 let same = snapshot([(0, 0, "A", "alice", stamp)]) 229 let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff) 230 #expect(deltas.isEmpty) 231 } 232 }