GridStateMergerTests.swift (9555B)
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("Input order does not affect the merged result") 75 func inputOrderIndependent() { 76 let earlier = view( 77 author: "alice", 78 device: "d1", 79 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 80 ) 81 let later = view( 82 author: "bob", 83 device: "d2", 84 cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] 85 ) 86 let forward = GridStateMerger.merge([earlier, later]) 87 let reversed = GridStateMerger.merge([later, earlier]) 88 #expect(forward == reversed) 89 } 90 91 @Test("Equal updatedAt: lexicographically smaller authorID wins") 92 func authorTieBreak() { 93 let same = Date(timeIntervalSince1970: 5) 94 let bob = view( 95 author: "bob", 96 device: "d1", 97 cells: [(0, 0, "B", same)] 98 ) 99 let alice = view( 100 author: "alice", 101 device: "d2", 102 cells: [(0, 0, "A", same)] 103 ) 104 let grid = GridStateMerger.merge([bob, alice]) 105 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") 106 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 107 } 108 109 @Test("Equal updatedAt and author: smaller deviceID wins") 110 func deviceTieBreak() { 111 let same = Date(timeIntervalSince1970: 5) 112 let onPhone = view( 113 author: "alice", 114 device: "phone", 115 cells: [(0, 0, "P", same)] 116 ) 117 let onIpad = view( 118 author: "alice", 119 device: "ipad", 120 cells: [(0, 0, "I", same)] 121 ) 122 let grid = GridStateMerger.merge([onPhone, onIpad]) 123 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "I") 124 } 125 126 @Test("Cells touched by only one device are all present in the merged grid") 127 func disjointCellsCoexist() { 128 let alice = view( 129 author: "alice", 130 device: "d1", 131 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 132 ) 133 let bob = view( 134 author: "bob", 135 device: "d2", 136 cells: [(1, 1, "B", Date(timeIntervalSince1970: 1))] 137 ) 138 let grid = GridStateMerger.merge([alice, bob]) 139 #expect(grid.count == 2) 140 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 141 #expect(grid[GridPosition(row: 1, col: 1)]?.authorID == "bob") 142 } 143 144 @Test("Cell-level authorID is preserved when the winning entry's parent writer differs") 145 func cellLevelAuthorWins() { 146 // Bob's device (writer) writes a cell whose preserved authorID is alice 147 // — mirrors the "reveal-of-correct" / "same-letter rewrite" behaviors 148 // where bob's mutation hands authorship back to alice. 149 let preserved = view( 150 author: "bob", 151 device: "d1", 152 cells: [(0, 0, "A", Date(timeIntervalSince1970: 2))], 153 cellAuthor: "alice" 154 ) 155 let grid = GridStateMerger.merge([preserved]) 156 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 157 } 158 159 @Test("Cleared letter (empty string) still wins if its updatedAt is latest") 160 func clearingMoveWins() { 161 let written = view( 162 author: "alice", 163 device: "d1", 164 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 165 ) 166 let cleared = view( 167 author: "alice", 168 device: "d2", 169 cells: [(0, 0, "", Date(timeIntervalSince1970: 2))] 170 ) 171 let grid = GridStateMerger.merge([written, cleared]) 172 let cell = grid[GridPosition(row: 0, col: 0)] 173 #expect(cell?.letter == "") 174 #expect(cell != nil) 175 } 176 177 @Test("notAfter keeps the latest write at or before the cutoff") 178 func cutoffKeepsPreLatchWinner() { 179 let cutoff = Date(timeIntervalSince1970: 100) 180 let preLatch = view( 181 author: "alice", 182 device: "d1", 183 cells: [(0, 0, "A", Date(timeIntervalSince1970: 100))] 184 ) 185 let postLatch = view( 186 author: "bob", 187 device: "d2", 188 cells: [(0, 0, "B", Date(timeIntervalSince1970: 101))] 189 ) 190 let grid = GridStateMerger.merge([preLatch, postLatch], notAfter: cutoff) 191 // Bob's later write would win an unbounded LWW, but it is stamped 192 // after the cutoff, so Alice's at-cutoff write survives. 193 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") 194 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 195 } 196 197 @Test("notAfter drops a cell whose only write is after the cutoff") 198 func cutoffDropsPostLatchOnlyCell() { 199 let cutoff = Date(timeIntervalSince1970: 100) 200 let postLatch = view( 201 author: "bob", 202 device: "d2", 203 cells: [(3, 4, "Z", Date(timeIntervalSince1970: 200))] 204 ) 205 let grid = GridStateMerger.merge([postLatch], notAfter: cutoff) 206 #expect(grid[GridPosition(row: 3, col: 4)] == nil) 207 } 208 209 @Test("A nil cutoff merges every write") 210 func nilCutoffIsUnbounded() { 211 let v = view( 212 author: "alice", 213 device: "d1", 214 cells: [(0, 0, "A", Date(timeIntervalSince1970: 9_999))] 215 ) 216 #expect(GridStateMerger.merge([v], notAfter: nil) == GridStateMerger.merge([v])) 217 } 218 } 219 220 @Suite("MovesCodec round-trip") 221 struct MovesCodecTests { 222 223 @Test("Round-trip preserves all cell fields including per-cell authorID") 224 func roundTrip() throws { 225 let cells: [GridPosition: TimestampedCell] = [ 226 GridPosition(row: 0, col: 0): TimestampedCell( 227 letter: "A", 228 mark: .pencil(checked: .wrong), 229 updatedAt: Date(timeIntervalSince1970: 1_700_000_000), 230 authorID: "alice" 231 ), 232 GridPosition(row: 4, col: 7): TimestampedCell( 233 letter: "", 234 mark: .none, 235 updatedAt: Date(timeIntervalSince1970: 1_700_000_500), 236 authorID: nil 237 ), 238 ] 239 let data = try MovesCodec.encode(cells) 240 let decoded = try MovesCodec.decode(data) 241 #expect(decoded == cells) 242 } 243 244 @Test("Encoded entries are sorted in row-major, col-minor order") 245 func encodingIsDeterministic() throws { 246 let cells: [GridPosition: TimestampedCell] = [ 247 GridPosition(row: 2, col: 1): TimestampedCell( 248 letter: "X", mark: .none, 249 updatedAt: Date(timeIntervalSince1970: 1), 250 authorID: "alice" 251 ), 252 GridPosition(row: 0, col: 0): TimestampedCell( 253 letter: "A", mark: .none, 254 updatedAt: Date(timeIntervalSince1970: 2), 255 authorID: "bob" 256 ), 257 ] 258 let payload = try JSONDecoder().decode( 259 MovesCodec.Payload.self, 260 from: MovesCodec.encode(cells) 261 ) 262 #expect(payload.entries.map { GridPosition(row: $0.row, col: $0.col) } == [ 263 GridPosition(row: 0, col: 0), 264 GridPosition(row: 2, col: 1), 265 ]) 266 #expect(payload.entries.map(\.authorID) == ["bob", "alice"]) 267 } 268 }