GridStateMergerTests.swift (8038B)
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 markKind: 0, 23 checkedWrong: false, 24 updatedAt: entry.updatedAt, 25 authorID: resolvedCellAuthor 26 ) 27 } 28 return MovesValue( 29 gameID: gameID, 30 authorID: author, 31 deviceID: device, 32 cells: dict, 33 updatedAt: cells.map(\.updatedAt).max() ?? .distantPast 34 ) 35 } 36 37 @Test("Empty input produces an empty grid") 38 func emptyInputs() { 39 #expect(GridStateMerger.merge([]).isEmpty) 40 } 41 42 @Test("Single MovesValue is reproduced cell-for-cell") 43 func singleViewPassesThrough() { 44 let v = view( 45 author: "alice", 46 device: "d1", 47 cells: [ 48 (0, 0, "A", Date(timeIntervalSince1970: 1)), 49 (1, 2, "B", Date(timeIntervalSince1970: 2)), 50 ] 51 ) 52 let grid = GridStateMerger.merge([v]) 53 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") 54 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 55 #expect(grid[GridPosition(row: 1, col: 2)]?.letter == "B") 56 } 57 58 @Test("Later updatedAt wins for the same cell across devices") 59 func laterTimestampWins() { 60 let earlier = view( 61 author: "alice", 62 device: "d1", 63 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 64 ) 65 let later = view( 66 author: "bob", 67 device: "d2", 68 cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] 69 ) 70 let grid = GridStateMerger.merge([earlier, later]) 71 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "B") 72 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "bob") 73 } 74 75 @Test("Input order does not affect the merged result") 76 func inputOrderIndependent() { 77 let earlier = view( 78 author: "alice", 79 device: "d1", 80 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 81 ) 82 let later = view( 83 author: "bob", 84 device: "d2", 85 cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] 86 ) 87 let forward = GridStateMerger.merge([earlier, later]) 88 let reversed = GridStateMerger.merge([later, earlier]) 89 #expect(forward == reversed) 90 } 91 92 @Test("Equal updatedAt: lexicographically smaller authorID wins") 93 func authorTieBreak() { 94 let same = Date(timeIntervalSince1970: 5) 95 let bob = view( 96 author: "bob", 97 device: "d1", 98 cells: [(0, 0, "B", same)] 99 ) 100 let alice = view( 101 author: "alice", 102 device: "d2", 103 cells: [(0, 0, "A", same)] 104 ) 105 let grid = GridStateMerger.merge([bob, alice]) 106 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") 107 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 108 } 109 110 @Test("Equal updatedAt and author: smaller deviceID wins") 111 func deviceTieBreak() { 112 let same = Date(timeIntervalSince1970: 5) 113 let onPhone = view( 114 author: "alice", 115 device: "phone", 116 cells: [(0, 0, "P", same)] 117 ) 118 let onIpad = view( 119 author: "alice", 120 device: "ipad", 121 cells: [(0, 0, "I", same)] 122 ) 123 let grid = GridStateMerger.merge([onPhone, onIpad]) 124 #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "I") 125 } 126 127 @Test("Cells touched by only one device are all present in the merged grid") 128 func disjointCellsCoexist() { 129 let alice = view( 130 author: "alice", 131 device: "d1", 132 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 133 ) 134 let bob = view( 135 author: "bob", 136 device: "d2", 137 cells: [(1, 1, "B", Date(timeIntervalSince1970: 1))] 138 ) 139 let grid = GridStateMerger.merge([alice, bob]) 140 #expect(grid.count == 2) 141 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 142 #expect(grid[GridPosition(row: 1, col: 1)]?.authorID == "bob") 143 } 144 145 @Test("Cell-level authorID is preserved when the winning entry's parent writer differs") 146 func cellLevelAuthorWins() { 147 // Bob's device (writer) writes a cell whose preserved authorID is alice 148 // — mirrors the "reveal-of-correct" / "same-letter rewrite" behaviors 149 // where bob's mutation hands authorship back to alice. 150 let preserved = view( 151 author: "bob", 152 device: "d1", 153 cells: [(0, 0, "A", Date(timeIntervalSince1970: 2))], 154 cellAuthor: "alice" 155 ) 156 let grid = GridStateMerger.merge([preserved]) 157 #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") 158 } 159 160 @Test("Cleared letter (empty string) still wins if its updatedAt is latest") 161 func clearingMoveWins() { 162 let written = view( 163 author: "alice", 164 device: "d1", 165 cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] 166 ) 167 let cleared = view( 168 author: "alice", 169 device: "d2", 170 cells: [(0, 0, "", Date(timeIntervalSince1970: 2))] 171 ) 172 let grid = GridStateMerger.merge([written, cleared]) 173 let cell = grid[GridPosition(row: 0, col: 0)] 174 #expect(cell?.letter == "") 175 #expect(cell != nil) 176 } 177 } 178 179 @Suite("MovesCodec round-trip") 180 struct MovesCodecTests { 181 182 @Test("Round-trip preserves all cell fields including per-cell authorID") 183 func roundTrip() throws { 184 let cells: [GridPosition: TimestampedCell] = [ 185 GridPosition(row: 0, col: 0): TimestampedCell( 186 letter: "A", 187 markKind: 2, 188 checkedWrong: true, 189 updatedAt: Date(timeIntervalSince1970: 1_700_000_000), 190 authorID: "alice" 191 ), 192 GridPosition(row: 4, col: 7): TimestampedCell( 193 letter: "", 194 markKind: 0, 195 checkedWrong: false, 196 updatedAt: Date(timeIntervalSince1970: 1_700_000_500), 197 authorID: nil 198 ), 199 ] 200 let data = try MovesCodec.encode(cells) 201 let decoded = try MovesCodec.decode(data) 202 #expect(decoded == cells) 203 } 204 205 @Test("Encoded entries are sorted in row-major, col-minor order") 206 func encodingIsDeterministic() throws { 207 let cells: [GridPosition: TimestampedCell] = [ 208 GridPosition(row: 2, col: 1): TimestampedCell( 209 letter: "X", markKind: 0, checkedWrong: false, 210 updatedAt: Date(timeIntervalSince1970: 1), 211 authorID: "alice" 212 ), 213 GridPosition(row: 0, col: 0): TimestampedCell( 214 letter: "A", markKind: 0, checkedWrong: false, 215 updatedAt: Date(timeIntervalSince1970: 2), 216 authorID: "bob" 217 ), 218 ] 219 let payload = try JSONDecoder().decode( 220 MovesCodec.Payload.self, 221 from: MovesCodec.encode(cells) 222 ) 223 #expect(payload.entries.map { GridPosition(row: $0.row, col: $0.col) } == [ 224 GridPosition(row: 0, col: 0), 225 GridPosition(row: 2, col: 1), 226 ]) 227 #expect(payload.entries.map(\.authorID) == ["bob", "alice"]) 228 } 229 }