crossmate

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

PeerChangeLedgerTests.swift (5086B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 @Suite("PeerChangeLedger.upserts")
      7 struct PeerChangeLedgerTests {
      8 
      9     private func t(_ seconds: TimeInterval) -> Date {
     10         Date(timeIntervalSinceReferenceDate: seconds)
     11     }
     12 
     13     /// A merged-grid provenance entry: `author` is the preserved cell author
     14     /// (the letter's writer, `nil` for a clear), `writer` is the iCloud user
     15     /// whose move won LWW.
     16     private func prov(
     17         _ letter: String,
     18         author: String?,
     19         writer: String,
     20         at: Date
     21     ) -> GridStateMerger.Provenance {
     22         GridStateMerger.Provenance(
     23             cell: TimestampedCell(letter: letter, mark: .none, updatedAt: at, authorID: author),
     24             writerAuthorID: writer
     25         )
     26     }
     27 
     28     private func apply(_ upserts: [PeerChange], to ledger: inout [GridPosition: PeerChange]) {
     29         for change in upserts { ledger[change.position] = change }
     30     }
     31 
     32     private let p1 = GridPosition(row: 0, col: 0)
     33     private let p2 = GridPosition(row: 0, col: 1)
     34     private let p3 = GridPosition(row: 1, col: 0)
     35 
     36     @Test("A first build seeds every current cell at .distantPast")
     37     func seedingRecordsDistantPast() {
     38         let current = [
     39             p1: prov("A", author: "bob", writer: "bob", at: t(10)),
     40             p2: prov("B", author: "bob", writer: "bob", at: t(20)),
     41         ]
     42         let upserts = PeerChangeLedger.upserts(current: current, recorded: [:], seeding: true)
     43         #expect(upserts.count == 2)
     44         #expect(upserts.allSatisfy { $0.changedAt == .distantPast })
     45     }
     46 
     47     @Test("After the seed, a genuine letter change records the move's real timestamp")
     48     func letterChangeRecordsRealTimestamp() {
     49         let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: .distantPast)]
     50         let current = [p1: prov("B", author: "carol", writer: "carol", at: t(40))]
     51         let upserts = PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false)
     52         #expect(upserts == [PeerChange(position: p1, letter: "B", authorID: "carol", changedAt: t(40))])
     53     }
     54 
     55     @Test("A check re-stamp (same letter, newer updatedAt) records nothing")
     56     func checkReStampIgnored() {
     57         // The reported bug: a check bumps updatedAt but leaves the letter alone.
     58         let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: t(10))]
     59         let current = [p1: prov("A", author: "bob", writer: "bob", at: t(99))]
     60         let upserts = PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false)
     61         #expect(upserts.isEmpty)
     62     }
     63 
     64     @Test("A clear records an empty letter, attributed to whoever cleared it")
     65     func clearAttributedToClearer() {
     66         let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "carol", changedAt: t(10))]
     67         // The clearing move carries no preserved cell author; the writer is Bob.
     68         let current = [p1: prov("", author: nil, writer: "bob", at: t(40))]
     69         let upserts = PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false)
     70         #expect(upserts == [PeerChange(position: p1, letter: "", authorID: "bob", changedAt: t(40))])
     71     }
     72 
     73     @Test("An unchanged cell records nothing")
     74     func unchangedCellIgnored() {
     75         let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: t(10))]
     76         let current = [p1: prov("A", author: "bob", writer: "bob", at: t(10))]
     77         #expect(PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false).isEmpty)
     78     }
     79 
     80     // MARK: - End-to-end: the reported rejoin bug
     81 
     82     @Test("A peer's check sweep after you leave never reads as 'filled while away'")
     83     func checkSweepNotFlaggedOnReturn() {
     84         // The game is in progress: bunny's two letters are already in the
     85         // ledger, recorded at their genuine fill times before you left.
     86         var ledger: [GridPosition: PeerChange] = [
     87             p1: PeerChange(position: p1, letter: "A", authorID: "bunny", changedAt: t(10)),
     88             p2: PeerChange(position: p2, letter: "B", authorID: "bunny", changedAt: t(20)),
     89         ]
     90         let viewedAt = t(30)
     91 
     92         // bunny runs a check: same letters, fresh updatedAt — and, in the same
     93         // sync, genuinely fills one new cell.
     94         let inbound = [
     95             p1: prov("A", author: "bunny", writer: "bunny", at: t(40)),
     96             p2: prov("B", author: "bunny", writer: "bunny", at: t(50)),
     97             p3: prov("C", author: "bunny", writer: "bunny", at: t(60)),
     98         ]
     99         apply(
    100             PeerChangeLedger.upserts(current: inbound, recorded: ledger, seeding: false),
    101             to: &ledger
    102         )
    103 
    104         let changes = RecentChanges.changes(
    105             in: Array(ledger.values),
    106             since: viewedAt,
    107             excludingAuthor: "alice"
    108         )
    109         // Only the genuine new fill counts — not the two re-stamped letters.
    110         #expect(changes.cells == [p3: "bunny"])
    111         #expect(changes.counts["bunny"] == RecentChanges.Count(added: 1, cleared: 0))
    112     }
    113 }