crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }