crossmate

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

PeerChangeLedgerTests.swift (5465B)


      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 non-empty 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             p3: prov("", author: nil, writer: "bob", at: t(30)),
     42         ]
     43         let upserts = PeerChangeLedger.upserts(current: current, recorded: [:], seeding: true)
     44         #expect(upserts.count == 2)
     45         #expect(upserts.allSatisfy { $0.changedAt == .distantPast })
     46     }
     47 
     48     @Test("A first-seen empty cell records nothing")
     49     func firstSeenEmptyCellIgnored() {
     50         let current = [p1: prov("", author: nil, writer: "bob", at: t(40))]
     51         let upserts = PeerChangeLedger.upserts(current: current, recorded: [:], seeding: false)
     52         #expect(upserts.isEmpty)
     53     }
     54 
     55     @Test("After the seed, a genuine letter change records the move's real timestamp")
     56     func letterChangeRecordsRealTimestamp() {
     57         let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: .distantPast)]
     58         let current = [p1: prov("B", author: "carol", writer: "carol", at: t(40))]
     59         let upserts = PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false)
     60         #expect(upserts == [PeerChange(position: p1, letter: "B", authorID: "carol", changedAt: t(40))])
     61     }
     62 
     63     @Test("A check re-stamp (same letter, newer updatedAt) records nothing")
     64     func checkReStampIgnored() {
     65         // The reported bug: a check bumps updatedAt but leaves the letter alone.
     66         let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: t(10))]
     67         let current = [p1: prov("A", author: "bob", writer: "bob", at: t(99))]
     68         let upserts = PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false)
     69         #expect(upserts.isEmpty)
     70     }
     71 
     72     @Test("A clear records an empty letter, attributed to whoever cleared it")
     73     func clearAttributedToClearer() {
     74         let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "carol", changedAt: t(10))]
     75         // The clearing move carries no preserved cell author; the writer is Bob.
     76         let current = [p1: prov("", author: nil, writer: "bob", at: t(40))]
     77         let upserts = PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false)
     78         #expect(upserts == [PeerChange(position: p1, letter: "", authorID: "bob", changedAt: t(40))])
     79     }
     80 
     81     @Test("An unchanged cell records nothing")
     82     func unchangedCellIgnored() {
     83         let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: t(10))]
     84         let current = [p1: prov("A", author: "bob", writer: "bob", at: t(10))]
     85         #expect(PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false).isEmpty)
     86     }
     87 
     88     // MARK: - End-to-end: the reported rejoin bug
     89 
     90     @Test("A peer's check sweep after you leave never reads as 'filled while away'")
     91     func checkSweepNotFlaggedOnReturn() {
     92         // The game is in progress: bunny's two letters are already in the
     93         // ledger, recorded at their genuine fill times before you left.
     94         var ledger: [GridPosition: PeerChange] = [
     95             p1: PeerChange(position: p1, letter: "A", authorID: "bunny", changedAt: t(10)),
     96             p2: PeerChange(position: p2, letter: "B", authorID: "bunny", changedAt: t(20)),
     97         ]
     98         let viewedAt = t(30)
     99 
    100         // bunny runs a check: same letters, fresh updatedAt — and, in the same
    101         // sync, genuinely fills one new cell.
    102         let inbound = [
    103             p1: prov("A", author: "bunny", writer: "bunny", at: t(40)),
    104             p2: prov("B", author: "bunny", writer: "bunny", at: t(50)),
    105             p3: prov("C", author: "bunny", writer: "bunny", at: t(60)),
    106         ]
    107         apply(
    108             PeerChangeLedger.upserts(current: inbound, recorded: ledger, seeding: false),
    109             to: &ledger
    110         )
    111 
    112         let changes = RecentChanges.changes(
    113             in: Array(ledger.values),
    114             since: viewedAt,
    115             excludingAuthor: "alice"
    116         )
    117         // Only the genuine new fill counts — not the two re-stamped letters.
    118         #expect(changes.cells == [p3: "bunny"])
    119         #expect(changes.counts["bunny"] == RecentChanges.Count(added: 1, cleared: 0))
    120     }
    121 }