crossmate

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

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 }