crossmate

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

AuthorDeltaTests.swift (10021B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 @Suite("GridStateMerger.mergeWithProvenance")
      7 struct GridStateMergerProvenanceTests {
      8 
      9     private let gameID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!
     10 
     11     private func view(
     12         author: String,
     13         device: String = "d1",
     14         cells: [(row: Int, col: Int, letter: String, updatedAt: Date)]
     15     ) -> MovesValue {
     16         var dict: [GridPosition: TimestampedCell] = [:]
     17         for entry in cells {
     18             dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell(
     19                 letter: entry.letter,
     20                 markKind: 0,
     21                 checkedWrong: false,
     22                 updatedAt: entry.updatedAt,
     23                 authorID: author
     24             )
     25         }
     26         return MovesValue(
     27             gameID: gameID,
     28             authorID: author,
     29             deviceID: device,
     30             cells: dict,
     31             updatedAt: cells.map(\.updatedAt).max() ?? .distantPast
     32         )
     33     }
     34 
     35     @Test("Provenance carries the writer authorID, not just the cell author")
     36     func writerAttribution() {
     37         let alice = view(
     38             author: "alice",
     39             cells: [(0, 0, "A", Date(timeIntervalSince1970: 5))]
     40         )
     41         let result = GridStateMerger.mergeWithProvenance([alice])
     42         let entry = result[GridPosition(row: 0, col: 0)]
     43         #expect(entry?.writerAuthorID == "alice")
     44         #expect(entry?.cell.letter == "A")
     45         #expect(entry?.cell.updatedAt == Date(timeIntervalSince1970: 5))
     46     }
     47 
     48     @Test("Cleared cells (empty letter) are retained with their writer")
     49     func emptyCellRetained() {
     50         let cleared = view(
     51             author: "alice",
     52             cells: [(0, 0, "", Date(timeIntervalSince1970: 9))]
     53         )
     54         let result = GridStateMerger.mergeWithProvenance([cleared])
     55         let entry = result[GridPosition(row: 0, col: 0)]
     56         #expect(entry?.cell.letter == "")
     57         #expect(entry?.writerAuthorID == "alice")
     58     }
     59 
     60     @Test("LWW winner's writer survives across multiple devices")
     61     func lwwWinnerWriter() {
     62         let alice = view(
     63             author: "alice",
     64             device: "ipad",
     65             cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))]
     66         )
     67         let bob = view(
     68             author: "bob",
     69             device: "phone",
     70             cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))]
     71         )
     72         let result = GridStateMerger.mergeWithProvenance([alice, bob])
     73         #expect(result[GridPosition(row: 0, col: 0)]?.writerAuthorID == "bob")
     74     }
     75 }
     76 
     77 @Suite("RecordApplier.authorDeltas")
     78 struct AuthorDeltaTests {
     79 
     80     private let gameA = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
     81     private let gameB = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
     82 
     83     /// Convenience to build a per-game provenance map without going through
     84     /// GridStateMerger — keeps tests focused on the diff logic.
     85     private func snapshot(
     86         _ entries: [(row: Int, col: Int, letter: String, writer: String, updatedAt: Date)]
     87     ) -> [GridPosition: GridStateMerger.Provenance] {
     88         var out: [GridPosition: GridStateMerger.Provenance] = [:]
     89         for entry in entries {
     90             out[GridPosition(row: entry.row, col: entry.col)] = GridStateMerger.Provenance(
     91                 cell: TimestampedCell(
     92                     letter: entry.letter,
     93                     markKind: 0,
     94                     checkedWrong: false,
     95                     updatedAt: entry.updatedAt,
     96                     authorID: entry.writer
     97                 ),
     98                 writerAuthorID: entry.writer
     99             )
    100         }
    101         return out
    102     }
    103 
    104     private let recentEnough = Date(timeIntervalSince1970: 10_000)
    105     private let cutoff = Date(timeIntervalSince1970: 9_000)
    106 
    107     @Test("Empty-to-letter transition counts as added, attributed to the writer")
    108     func addedCount() {
    109         let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]]
    110         let after = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])]
    111         let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
    112         #expect(deltas.count == 1)
    113         #expect(deltas[0].gameID == gameA)
    114         #expect(deltas[0].authorID == "alice")
    115         #expect(deltas[0].added == 1)
    116         #expect(deltas[0].cleared == 0)
    117     }
    118 
    119     @Test("Letter-to-empty transition counts as cleared, attributed to the clearing writer")
    120     func clearedCount() {
    121         // Bob wrote "B" originally, then Alice's record overwrites that cell
    122         // with an empty letter — the clear is attributed to Alice, who is
    123         // the LWW-winning writer of the post-apply state.
    124         let before = [gameA: snapshot([(0, 0, "B", "bob", recentEnough)])]
    125         let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])]
    126         let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
    127         #expect(deltas.count == 1)
    128         #expect(deltas[0].authorID == "alice")
    129         #expect(deltas[0].added == 0)
    130         #expect(deltas[0].cleared == 1)
    131     }
    132 
    133     @Test("Letter-to-different-letter contributes zero (no add, no clear)")
    134     func overwriteIsNeutral() {
    135         let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])]
    136         let after = [gameA: snapshot([(0, 0, "B", "bob", recentEnough.addingTimeInterval(1))])]
    137         let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
    138         #expect(deltas.isEmpty)
    139     }
    140 
    141     @Test("Cells unchanged between before and after contribute zero")
    142     func unchangedCells() {
    143         // Re-applying the same merged-grid state (idempotent CKSyncEngine
    144         // re-delivery) yields no deltas.
    145         let stamp = recentEnough
    146         let same = snapshot([(0, 0, "A", "alice", stamp)])
    147         let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff)
    148         #expect(deltas.isEmpty)
    149     }
    150 
    151     @Test("Cells whose winning entry predates cutoff are gated out")
    152     func cutoffGateExcludesStale() {
    153         let stale = Date(timeIntervalSince1970: 5_000) // well before cutoff
    154         let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]]
    155         let after = [gameA: snapshot([(0, 0, "A", "alice", stale)])]
    156         let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
    157         #expect(deltas.isEmpty)
    158     }
    159 
    160     @Test("Adds and clears in the same batch aggregate per author")
    161     func aggregatesPerAuthor() {
    162         let before = [gameA: snapshot([
    163             (0, 0, "X", "alice", recentEnough),
    164             (0, 1, "Y", "alice", recentEnough),
    165         ])]
    166         let after = [gameA: snapshot([
    167             (0, 0, "",  "alice", recentEnough.addingTimeInterval(1)), // cleared
    168             (0, 1, "Y", "alice", recentEnough),                       // unchanged
    169             (0, 2, "Z", "alice", recentEnough.addingTimeInterval(2)), // added
    170             (0, 3, "W", "alice", recentEnough.addingTimeInterval(3)), // added
    171         ])]
    172         let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
    173         #expect(deltas.count == 1)
    174         #expect(deltas[0].added == 2)
    175         #expect(deltas[0].cleared == 1)
    176         #expect(deltas[0].latestUpdate == recentEnough.addingTimeInterval(3))
    177     }
    178 
    179     @Test("Multi-device same-author writes all attribute to one bucket")
    180     func multiDeviceSameAuthor() {
    181         // Alice's iPad wrote A at (0,0) in the prior batch; her iPhone
    182         // arrives with a clear at the same cell. Both writes have authorID
    183         // = "alice", so the cleared count rolls up into one bucket — the
    184         // hard problem the design was supposed to solve.
    185         let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])]
    186         let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])]
    187         let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
    188         #expect(deltas.count == 1)
    189         #expect(deltas[0].authorID == "alice")
    190         #expect(deltas[0].cleared == 1)
    191     }
    192 
    193     @Test("Different authors in the same game produce distinct buckets")
    194     func separateAuthorsSeparateBuckets() {
    195         let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]]
    196         let after = [gameA: snapshot([
    197             (0, 0, "A", "alice", recentEnough),
    198             (1, 1, "B", "bob",   recentEnough),
    199         ])]
    200         let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
    201         let byAuthor = Dictionary(uniqueKeysWithValues: deltas.map { ($0.authorID, $0) })
    202         #expect(byAuthor["alice"]?.added == 1)
    203         #expect(byAuthor["bob"]?.added == 1)
    204     }
    205 
    206     @Test("Deltas across multiple games are surfaced independently")
    207     func multiGameDeltas() {
    208         let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [
    209             gameA: [:],
    210             gameB: [:],
    211         ]
    212         let after = [
    213             gameA: snapshot([(0, 0, "A", "alice", recentEnough)]),
    214             gameB: snapshot([(0, 0, "B", "bob",   recentEnough)]),
    215         ]
    216         let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
    217         #expect(deltas.count == 2)
    218         #expect(Set(deltas.map { $0.gameID }) == Set([gameA, gameB]))
    219     }
    220 
    221     @Test("LWW-loser writes don't show up: if the cell didn't change, no delta")
    222     func lwwLoserIgnored() {
    223         // Before: Alice already wrote A at t=20. After-apply still shows A
    224         // at t=20 (LWW kept Alice's value despite a stale Bob write being
    225         // applied at t=5, which lost to Alice and so doesn't change the
    226         // merged-grid). The diff sees no transition.
    227         let stamp = recentEnough.addingTimeInterval(20)
    228         let same = snapshot([(0, 0, "A", "alice", stamp)])
    229         let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff)
    230         #expect(deltas.isEmpty)
    231     }
    232 }