crossmate

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

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 }