GridStateMergerTests.swift (12154B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("GridStateMerger.merge") 7 struct GridStateMergerTests { 8 9 private let gameID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! 10 11 private func view( 12 author: String, 13 device: String, 14 cells: [(row: Int, col: Int, letter: String, updatedAt: Date)], 15 cellAuthor: String? = nil 16 ) -> MovesValue { 17 let resolvedCellAuthor = cellAuthor ?? author 18 var dict: [GridPosition: TimestampedCell] = [:] 19 for entry in cells { 20 dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell( 21 letter: entry.letter, 22 mark: .none, 23 updatedAt: entry.updatedAt, 24 authorID: resolvedCellAuthor 25 ) 26 } 27 return MovesValue( 28 gameID: gameID, 29 authorID: author, 30 deviceID: device, 31 cells: dict, 32 updatedAt: cells.map(\.updatedAt).max() ?? .distantPast 33 ) 34 } 35 36 @Test("Empty input produces an empty grid") 37 func emptyInputs() { 38 #expect(GridStateMerger.merge([]).isEmpty) 39 } 40 41 @Test("Single MovesValue is reproduced cell-for-cell") 42 func singleViewPassesThrough() { 43 let v = view( 44 author: "alice", 45 device: "d1", 46 cells: [ 47 (0, 0, "A", Date(timeIntervalSince1970: 1)), 48 (1, 2, "B", Date(timeIntervalSince1970: 2)), 49 ] 50 ) 51 let grid = GridStateMerger.merge([v]) 52 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") 53 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 54 #expect(grid[GridPosition(row: 1, col: 2)]?.letter == "B") 55 } 56 57 @Test("Later updatedAt wins for the same cell across devices") 58 func laterTimestampWins() { 59 let earlier = view( 60 author: "alice", 61 device: "d1", 62 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 63 ) 64 let later = view( 65 author: "bob", 66 device: "d2", 67 cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] 68 ) 69 let grid = GridStateMerger.merge([earlier, later]) 70 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "B") 71 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "bob") 72 } 73 74 @Test("A higher logical tick wins despite an older wall clock") 75 func logicalTickWinsAcrossClockSkew() { 76 let position = GridPosition(row: 0, col: 0) 77 let legacyDate = Date(timeIntervalSince1970: 2_000) 78 let legacy = MovesValue( 79 gameID: gameID, 80 authorID: "alice", 81 deviceID: "legacy", 82 cells: [position: TimestampedCell( 83 letter: "N", 84 mark: .none, 85 updatedAt: legacyDate, 86 authorID: "alice" 87 )], 88 updatedAt: legacyDate 89 ) 90 let modern = MovesValue( 91 gameID: gameID, 92 authorID: "bob", 93 deviceID: "modern", 94 cells: [position: TimestampedCell( 95 letter: "M", 96 mark: .none, 97 updatedAt: Date(timeIntervalSince1970: 1), 98 authorID: "bob", 99 tick: 2_000_001 100 )], 101 updatedAt: Date(timeIntervalSince1970: 1) 102 ) 103 104 #expect(GridStateMerger.merge([legacy, modern])[position]?.letter == "M") 105 } 106 107 @Test("Logical ticks, not wall time, order modern cells") 108 func logicalTicksIgnoreModernWallClock() { 109 let position = GridPosition(row: 0, col: 0) 110 let lowerTick = MovesValue( 111 gameID: gameID, 112 authorID: "alice", 113 deviceID: "fast-clock", 114 cells: [position: TimestampedCell( 115 letter: "N", 116 mark: .none, 117 updatedAt: Date(timeIntervalSince1970: 9_999), 118 authorID: "alice", 119 tick: 40 120 )], 121 updatedAt: Date(timeIntervalSince1970: 9_999) 122 ) 123 let higherTick = MovesValue( 124 gameID: gameID, 125 authorID: "bob", 126 deviceID: "slow-clock", 127 cells: [position: TimestampedCell( 128 letter: "M", 129 mark: .none, 130 updatedAt: Date(timeIntervalSince1970: 1), 131 authorID: "bob", 132 tick: 41 133 )], 134 updatedAt: Date(timeIntervalSince1970: 1) 135 ) 136 137 #expect(GridStateMerger.merge([lowerTick, higherTick])[position]?.letter == "M") 138 } 139 140 @Test("Input order does not affect the merged result") 141 func inputOrderIndependent() { 142 let earlier = view( 143 author: "alice", 144 device: "d1", 145 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 146 ) 147 let later = view( 148 author: "bob", 149 device: "d2", 150 cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] 151 ) 152 let forward = GridStateMerger.merge([earlier, later]) 153 let reversed = GridStateMerger.merge([later, earlier]) 154 #expect(forward == reversed) 155 } 156 157 @Test("Equal updatedAt: lexicographically smaller authorID wins") 158 func authorTieBreak() { 159 let same = Date(timeIntervalSince1970: 5) 160 let bob = view( 161 author: "bob", 162 device: "d1", 163 cells: [(0, 0, "B", same)] 164 ) 165 let alice = view( 166 author: "alice", 167 device: "d2", 168 cells: [(0, 0, "A", same)] 169 ) 170 let grid = GridStateMerger.merge([bob, alice]) 171 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") 172 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 173 } 174 175 @Test("Equal updatedAt and author: smaller deviceID wins") 176 func deviceTieBreak() { 177 let same = Date(timeIntervalSince1970: 5) 178 let onPhone = view( 179 author: "alice", 180 device: "phone", 181 cells: [(0, 0, "P", same)] 182 ) 183 let onIpad = view( 184 author: "alice", 185 device: "ipad", 186 cells: [(0, 0, "I", same)] 187 ) 188 let grid = GridStateMerger.merge([onPhone, onIpad]) 189 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "I") 190 } 191 192 @Test("Cells touched by only one device are all present in the merged grid") 193 func disjointCellsCoexist() { 194 let alice = view( 195 author: "alice", 196 device: "d1", 197 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 198 ) 199 let bob = view( 200 author: "bob", 201 device: "d2", 202 cells: [(1, 1, "B", Date(timeIntervalSince1970: 1))] 203 ) 204 let grid = GridStateMerger.merge([alice, bob]) 205 #expect(grid.count == 2) 206 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 207 #expect(grid[GridPosition(row: 1, col: 1)]?.authorID == "bob") 208 } 209 210 @Test("Cell-level authorID is preserved when the winning entry's parent writer differs") 211 func cellLevelAuthorWins() { 212 // Bob's device (writer) writes a cell whose preserved authorID is alice 213 // — mirrors the "reveal-of-correct" / "same-letter rewrite" behaviors 214 // where bob's mutation hands authorship back to alice. 215 let preserved = view( 216 author: "bob", 217 device: "d1", 218 cells: [(0, 0, "A", Date(timeIntervalSince1970: 2))], 219 cellAuthor: "alice" 220 ) 221 let grid = GridStateMerger.merge([preserved]) 222 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 223 } 224 225 @Test("Cleared letter (empty string) still wins if its updatedAt is latest") 226 func clearingMoveWins() { 227 let written = view( 228 author: "alice", 229 device: "d1", 230 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 231 ) 232 let cleared = view( 233 author: "alice", 234 device: "d2", 235 cells: [(0, 0, "", Date(timeIntervalSince1970: 2))] 236 ) 237 let grid = GridStateMerger.merge([written, cleared]) 238 let cell = grid[GridPosition(row: 0, col: 0)] 239 #expect(cell?.letter == "") 240 #expect(cell != nil) 241 } 242 243 @Test("notAfter keeps the latest write at or before the cutoff") 244 func cutoffKeepsPreLatchWinner() { 245 let cutoff = Date(timeIntervalSince1970: 100) 246 let preLatch = view( 247 author: "alice", 248 device: "d1", 249 cells: [(0, 0, "A", Date(timeIntervalSince1970: 100))] 250 ) 251 let postLatch = view( 252 author: "bob", 253 device: "d2", 254 cells: [(0, 0, "B", Date(timeIntervalSince1970: 101))] 255 ) 256 let grid = GridStateMerger.merge([preLatch, postLatch], notAfter: cutoff) 257 // Bob's later write would win an unbounded LWW, but it is stamped 258 // after the cutoff, so Alice's at-cutoff write survives. 259 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") 260 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 261 } 262 263 @Test("notAfter drops a cell whose only write is after the cutoff") 264 func cutoffDropsPostLatchOnlyCell() { 265 let cutoff = Date(timeIntervalSince1970: 100) 266 let postLatch = view( 267 author: "bob", 268 device: "d2", 269 cells: [(3, 4, "Z", Date(timeIntervalSince1970: 200))] 270 ) 271 let grid = GridStateMerger.merge([postLatch], notAfter: cutoff) 272 #expect(grid[GridPosition(row: 3, col: 4)] == nil) 273 } 274 275 @Test("A nil cutoff merges every write") 276 func nilCutoffIsUnbounded() { 277 let v = view( 278 author: "alice", 279 device: "d1", 280 cells: [(0, 0, "A", Date(timeIntervalSince1970: 9_999))] 281 ) 282 #expect(GridStateMerger.merge([v], notAfter: nil) == GridStateMerger.merge([v])) 283 } 284 } 285 286 @Suite("MovesCodec round-trip") 287 struct MovesCodecTests { 288 289 @Test("Round-trip preserves all cell fields including per-cell authorID") 290 func roundTrip() throws { 291 let cells: [GridPosition: TimestampedCell] = [ 292 GridPosition(row: 0, col: 0): TimestampedCell( 293 letter: "A", 294 mark: .pencil(checked: .wrong), 295 updatedAt: Date(timeIntervalSince1970: 1_700_000_000), 296 authorID: "alice", 297 tick: 42 298 ), 299 GridPosition(row: 4, col: 7): TimestampedCell( 300 letter: "", 301 mark: .none, 302 updatedAt: Date(timeIntervalSince1970: 1_700_000_500), 303 authorID: nil 304 ), 305 ] 306 let data = try MovesCodec.encode(cells) 307 let decoded = try MovesCodec.decode(data) 308 #expect(decoded == cells) 309 } 310 311 @Test("Encoded entries are sorted in row-major, col-minor order") 312 func encodingIsDeterministic() throws { 313 let cells: [GridPosition: TimestampedCell] = [ 314 GridPosition(row: 2, col: 1): TimestampedCell( 315 letter: "X", mark: .none, 316 updatedAt: Date(timeIntervalSince1970: 1), 317 authorID: "alice" 318 ), 319 GridPosition(row: 0, col: 0): TimestampedCell( 320 letter: "A", mark: .none, 321 updatedAt: Date(timeIntervalSince1970: 2), 322 authorID: "bob" 323 ), 324 ] 325 let payload = try JSONDecoder().decode( 326 MovesCodec.Payload.self, 327 from: MovesCodec.encode(cells) 328 ) 329 #expect(payload.entries.map { GridPosition(row: $0.row, col: $0.col) } == [ 330 GridPosition(row: 0, col: 0), 331 GridPosition(row: 2, col: 1), 332 ]) 333 #expect(payload.entries.map(\.authorID) == ["bob", "alice"]) 334 } 335 336 @Test("Legacy payloads decode without a logical tick") 337 func legacyPayloadHasNoTick() throws { 338 let data = Data(#"{"entries":[{"row":0,"col":0,"letter":"N","markCode":0,"updatedAt":0}]}"#.utf8) 339 let decoded = try MovesCodec.decode(data) 340 #expect(decoded[GridPosition(row: 0, col: 0)]?.tick == nil) 341 } 342 }