crossmate

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

MoveLogTests.swift (9137B)


      1 import CloudKit
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 @Suite("MoveLog.replay")
      8 struct MoveLogReplayTests {
      9 
     10     private let gameID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
     11 
     12     private func move(
     13         lamport: Int64,
     14         row: Int,
     15         col: Int,
     16         letter: String,
     17         author: String? = nil
     18     ) -> Move {
     19         Move(
     20             gameID: gameID,
     21             lamport: lamport,
     22             row: row,
     23             col: col,
     24             letter: letter,
     25             markKind: 0,
     26             checkedWrong: false,
     27             authorID: author,
     28             createdAt: Date(timeIntervalSince1970: TimeInterval(lamport))
     29         )
     30     }
     31 
     32     @Test("Empty inputs produce an empty grid")
     33     func emptyInputs() {
     34         let grid = MoveLog.replay(snapshot: nil, moves: [])
     35         #expect(grid.isEmpty)
     36     }
     37 
     38     @Test("Moves without a snapshot are applied in lamport order")
     39     func movesOnly() {
     40         let grid = MoveLog.replay(
     41             snapshot: nil,
     42             moves: [
     43                 move(lamport: 1, row: 0, col: 0, letter: "A"),
     44                 move(lamport: 2, row: 0, col: 1, letter: "B"),
     45             ]
     46         )
     47         #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A")
     48         #expect(grid[GridPosition(row: 0, col: 1)]?.letter == "B")
     49     }
     50 
     51     @Test("Later lamport for the same cell overwrites the earlier one")
     52     func laterMoveWins() {
     53         let grid = MoveLog.replay(
     54             snapshot: nil,
     55             moves: [
     56                 move(lamport: 1, row: 0, col: 0, letter: "A"),
     57                 move(lamport: 2, row: 0, col: 0, letter: "B"),
     58             ]
     59         )
     60         #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "B")
     61     }
     62 
     63     @Test("Moves are reordered by lamport even when input is shuffled")
     64     func inputOrderIndependent() {
     65         let grid = MoveLog.replay(
     66             snapshot: nil,
     67             moves: [
     68                 move(lamport: 3, row: 0, col: 0, letter: "C"),
     69                 move(lamport: 1, row: 0, col: 0, letter: "A"),
     70                 move(lamport: 2, row: 0, col: 0, letter: "B"),
     71             ]
     72         )
     73         #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "C")
     74     }
     75 
     76     @Test("Empty-letter move clears the cell's letter but retains the slot")
     77     func clearingMovePreservesSlot() {
     78         let grid = MoveLog.replay(
     79             snapshot: nil,
     80             moves: [
     81                 move(lamport: 1, row: 0, col: 0, letter: "A", author: "alice"),
     82                 move(lamport: 2, row: 0, col: 0, letter: "", author: "alice"),
     83             ]
     84         )
     85         let cell = grid[GridPosition(row: 0, col: 0)]
     86         #expect(cell?.letter == "")
     87         #expect(cell?.authorID == "alice")
     88     }
     89 
     90     @Test("Snapshot seeds the grid and moves past its cutoff are applied")
     91     func snapshotBaseAndTail() {
     92         let snapshot = Snapshot(
     93             gameID: gameID,
     94             upToLamport: 5,
     95             grid: [
     96                 GridPosition(row: 0, col: 0): GridCell(
     97                     letter: "A",
     98                     markKind: 0,
     99                     checkedWrong: false,
    100                     authorID: nil
    101                 )
    102             ],
    103             createdAt: Date()
    104         )
    105         let grid = MoveLog.replay(
    106             snapshot: snapshot,
    107             moves: [
    108                 move(lamport: 6, row: 1, col: 0, letter: "B"),
    109             ]
    110         )
    111         #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A")
    112         #expect(grid[GridPosition(row: 1, col: 0)]?.letter == "B")
    113     }
    114 
    115     @Test("Moves at or below the snapshot cutoff are skipped")
    116     func snapshotCutoffSkipsOlderMoves() {
    117         let snapshot = Snapshot(
    118             gameID: gameID,
    119             upToLamport: 5,
    120             grid: [
    121                 GridPosition(row: 0, col: 0): GridCell(
    122                     letter: "Z",
    123                     markKind: 0,
    124                     checkedWrong: false,
    125                     authorID: nil
    126                 )
    127             ],
    128             createdAt: Date()
    129         )
    130         let grid = MoveLog.replay(
    131             snapshot: snapshot,
    132             moves: [
    133                 // Lamport 3 is already folded into the snapshot; it must
    134                 // not re-apply on top and revert the cell.
    135                 move(lamport: 3, row: 0, col: 0, letter: "A"),
    136             ]
    137         )
    138         #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "Z")
    139     }
    140 
    141     @Test("Latest snapshot picks the highest upToLamport")
    142     func latestSnapshotSelection() {
    143         let a = Snapshot(gameID: gameID, upToLamport: 10, grid: [:], createdAt: Date())
    144         let b = Snapshot(gameID: gameID, upToLamport: 50, grid: [:], createdAt: Date())
    145         let c = Snapshot(gameID: gameID, upToLamport: 25, grid: [:], createdAt: Date())
    146         #expect(MoveLog.latestSnapshot(from: [a, b, c]) == b)
    147     }
    148 }
    149 
    150 @Suite("MoveLog grid state codec")
    151 struct GridStateCodecTests {
    152 
    153     @Test("Round-trip preserves all cell fields")
    154     func roundTripPreservesFields() throws {
    155         let grid: GridState = [
    156             GridPosition(row: 0, col: 0): GridCell(
    157                 letter: "A", markKind: 2, checkedWrong: true, authorID: "alice"
    158             ),
    159             GridPosition(row: 4, col: 7): GridCell(
    160                 letter: "", markKind: 0, checkedWrong: false, authorID: nil
    161             ),
    162         ]
    163         let data = try MoveLog.encodeGridState(grid)
    164         let decoded = try MoveLog.decodeGridState(data)
    165         #expect(decoded == grid)
    166     }
    167 
    168     @Test("Encoding sorts entries in row-major, col-minor order regardless of dictionary iteration order")
    169     func encodingSortsEntries() throws {
    170         // Dictionary iteration order is unspecified; encoding must sort.
    171         let grid: GridState = [
    172             GridPosition(row: 2, col: 1): GridCell(
    173                 letter: "X", markKind: 0, checkedWrong: false, authorID: nil
    174             ),
    175             GridPosition(row: 0, col: 0): GridCell(
    176                 letter: "A", markKind: 0, checkedWrong: false, authorID: nil
    177             ),
    178         ]
    179         let payload = try JSONDecoder().decode(
    180             MoveLog.GridStatePayload.self,
    181             from: MoveLog.encodeGridState(grid)
    182         )
    183         #expect(payload.entries.map { GridPosition(row: $0.row, col: $0.col) } == [
    184             GridPosition(row: 0, col: 0),
    185             GridPosition(row: 2, col: 1),
    186         ])
    187     }
    188 }
    189 
    190 @Suite("RecordSerializer Move/Snapshot")
    191 struct RecordSerializerMoveSnapshotTests {
    192 
    193     private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")!
    194     private var zoneID: CKRecordZone.ID { RecordSerializer.zoneID(for: gameID) }
    195 
    196     @Test("Move record name uses the expected format")
    197     func moveRecordNameFormat() {
    198         let name = RecordSerializer.recordName(forMoveInGame: gameID, lamport: 42)
    199         #expect(name == "move-\(gameID.uuidString)-42-\(RecordSerializer.localDeviceID)")
    200     }
    201 
    202     @Test("Snapshot record name uses the expected format")
    203     func snapshotRecordNameFormat() {
    204         let name = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: 100)
    205         #expect(name == "snapshot-\(gameID.uuidString)-100-\(RecordSerializer.localDeviceID)")
    206     }
    207 
    208     @Test("Move record round-trips through CKRecord fields")
    209     func moveRecordRoundTrip() {
    210         let move = Move(
    211             gameID: gameID,
    212             lamport: 17,
    213             row: 3,
    214             col: 5,
    215             letter: "Q",
    216             markKind: 1,
    217             checkedWrong: true,
    218             authorID: "alice",
    219             createdAt: Date(timeIntervalSince1970: 1_700_000_000)
    220         )
    221         let record = RecordSerializer.moveRecord(
    222             from: move,
    223             zone: zoneID,
    224             systemFields: nil
    225         )
    226         let parsed = RecordSerializer.parseMoveRecord(record)
    227         #expect(parsed == move)
    228     }
    229 
    230     @Test("Snapshot record round-trips through CKRecord fields")
    231     func snapshotRecordRoundTrip() throws {
    232         let grid: GridState = [
    233             GridPosition(row: 0, col: 0): GridCell(
    234                 letter: "A", markKind: 0, checkedWrong: false, authorID: "alice"
    235             ),
    236             GridPosition(row: 1, col: 2): GridCell(
    237                 letter: "B", markKind: 2, checkedWrong: true, authorID: nil
    238             ),
    239         ]
    240         let snapshot = Snapshot(
    241             gameID: gameID,
    242             upToLamport: 42,
    243             grid: grid,
    244             createdAt: Date(timeIntervalSince1970: 1_700_000_000)
    245         )
    246         let record = try RecordSerializer.snapshotRecord(
    247             from: snapshot,
    248             zone: zoneID,
    249             systemFields: nil
    250         )
    251         let parsed = RecordSerializer.parseSnapshotRecord(record)
    252         #expect(parsed == snapshot)
    253     }
    254 
    255 @Test("Parsing rejects records with the wrong record type")
    256     func parseRejectsWrongRecordType() {
    257         let zoneID = RecordSerializer.zoneID(for: gameID)
    258         let recordID = CKRecord.ID(recordName: RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1), zoneID: zoneID)
    259         let record = CKRecord(recordType: "Cell", recordID: recordID)
    260         #expect(RecordSerializer.parseMoveRecord(record) == nil)
    261     }
    262 }