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 }