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 }