commit d52619358ced96ddde4b942bc1f0808bc7476f28
parent 2e2fa7d2f6de00155a26e9919db88287a0b69aba
Author: Michael Camilleri <[email protected]>
Date: Sun, 21 Jun 2026 06:56:33 +0900
Keep a ledger of peer changes
Rejoining a shared game could announce that a collaborator had filled in
all of his or her squares when the collaborator had only run a check. A
check re-stamps every filled cell's updatedAt without changing its
letter, and the 'changed while you were away' borders and catch-up
banner keyed off that timestamp. To tell a genuine fill from a re-stamp,
RecentChanges reconstructs the grid as it stood at the last view with
GridStateMerger.merge(notAfter:) and compares letters — but the synced
Moves snapshot holds only each cell's current state, with no history, so
the cutoff dropped every re-stamped cell and its prior letter came back
empty. Every filled cell then read as a fresh fill.
This commit records what changed in a device-local per-cell ledger,
PeerChangeEntity, derived from the inbound snapshots themselves. A
cell's recorded timestamp advances only when its letter actually differs
from what the ledger holds, so a check writes nothing and can never
resurface old letters as new. recentChanges now reads the ledger rather
than reconstructing a baseline it cannot recover, and the borders and
the banner share that one source. The first build for a game seeds every
cell at distantPast — a silent baseline — so an upgrade or a fresh
device never flags pre-existing content.
The ledger is maintained off the inbound-moves hot path:
enqueuePeerChangeLedgerUpdate hands each batch to a single-consumer
AsyncStream whose worker writes on a background context, so a live
co-solve grid update is never blocked on the write and concurrent builds
— which could duplicate a row — cannot overlap.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
9 files changed, 504 insertions(+), 230 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -71,6 +71,7 @@
4D7BF839BA71E1BF0AE9BCE9 /* GameStoreContributingDevicesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */; };
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; };
4D9E2C35893E68E47F790994 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7023E506D777DB80B18A7DB5 /* BundledBrowseView.swift */; };
+ 4E14BB5D6F96D178373ED55A /* PeerChangeLedger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B427285F8D6BE35025591BFA /* PeerChangeLedger.swift */; };
4F1A93404828EDBDBBF86716 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */; };
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; };
@@ -177,6 +178,7 @@
D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD63A9B20168F3B81AF4348F /* RecordApplier.swift */; };
+ DAD7EA11DA7330773A485473 /* PeerChangeLedgerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D243575E32A8663B1AAF492A /* PeerChangeLedgerTests.swift */; };
DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */; };
DDC7994B951A3A7B836B36F6 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A62DA6F7138876CA5A27EF /* SuccessPanel.swift */; };
DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */; };
@@ -381,6 +383,7 @@
B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgraderTests.swift; sourceTree = "<group>"; };
B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverter.swift; sourceTree = "<group>"; };
B3D873ABDF871E14794A2845 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; };
+ B427285F8D6BE35025591BFA /* PeerChangeLedger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChangeLedger.swift; sourceTree = "<group>"; };
B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; };
B766E872B12DC79ECCD80941 /* FriendModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendModelTests.swift; sourceTree = "<group>"; };
B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalogTests.swift; sourceTree = "<group>"; };
@@ -408,6 +411,7 @@
CF3D29B227D2B0E699423C48 /* Journal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Journal.swift; sourceTree = "<group>"; };
CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; };
D16AC7215D0269195FEA8BA8 /* GridSilhouette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSilhouette.swift; sourceTree = "<group>"; };
+ D243575E32A8663B1AAF492A /* PeerChangeLedgerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChangeLedgerTests.swift; sourceTree = "<group>"; };
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
@@ -464,6 +468,7 @@
14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */,
86470163BFF956F3DE438506 /* Moves.swift */,
7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */,
+ B427285F8D6BE35025591BFA /* PeerChangeLedger.swift */,
11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */,
CFC4FF046BF772646B5CA73F /* Presence.swift */,
605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */,
@@ -540,6 +545,7 @@
B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */,
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */,
+ D243575E32A8663B1AAF492A /* PeerChangeLedgerTests.swift */,
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */,
4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */,
5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */,
@@ -965,6 +971,7 @@
E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */,
903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */,
C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */,
+ DAD7EA11DA7330773A485473 /* PeerChangeLedgerTests.swift in Sources */,
51E6F7F2FC52C2AA87B9DB45 /* PeerPresenceGraceTests.swift in Sources */,
A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */,
8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */,
@@ -1080,6 +1087,7 @@
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */,
CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */,
E6A13F8736ABF41F6346E301 /* ParticipantSummaries.swift in Sources */,
+ 4E14BB5D6F96D178373ED55A /* PeerChangeLedger.swift in Sources */,
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */,
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */,
3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */,
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -37,6 +37,7 @@
<relationship name="cells" toMany="YES" deletionRule="Cascade" destinationEntity="CellEntity" inverseName="game" inverseEntity="CellEntity"/>
<relationship name="journal" toMany="YES" deletionRule="Cascade" destinationEntity="JournalEntity" inverseName="game" inverseEntity="JournalEntity"/>
<relationship name="moves" toMany="YES" deletionRule="Cascade" destinationEntity="MovesEntity" inverseName="game" inverseEntity="MovesEntity"/>
+ <relationship name="peerChanges" toMany="YES" deletionRule="Cascade" destinationEntity="PeerChangeEntity" inverseName="game" inverseEntity="PeerChangeEntity"/>
<relationship name="players" toMany="YES" deletionRule="Cascade" destinationEntity="PlayerEntity" inverseName="game" inverseEntity="PlayerEntity"/>
</entity>
<entity name="PlayerEntity" representedClassName="PlayerEntity" syncable="YES" codeGenerationType="class">
@@ -154,4 +155,16 @@
<fetchIndexElement property="seq" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
+ <entity name="PeerChangeEntity" representedClassName="PeerChangeEntity" syncable="YES" codeGenerationType="class">
+ <attribute name="authorID" optional="YES" attributeType="String"/>
+ <attribute name="changedAt" attributeType="Date" usesScalarValueType="NO"/>
+ <attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="gameID" attributeType="UUID" usesScalarValueType="NO"/>
+ <attribute name="letter" attributeType="String" defaultValueString=""/>
+ <attribute name="row" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="peerChanges" inverseEntity="GameEntity"/>
+ <fetchIndex name="byGameID">
+ <fetchIndexElement property="gameID" type="Binary" order="ascending"/>
+ </fetchIndex>
+ </entity>
</model>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -503,6 +503,105 @@ final class GameStore {
}
}
+ /// Serial queue feeding the peer-change ledger writer. The continuation is
+ /// the producer end; a single long-lived consumer (started lazily on first
+ /// use) drains it one request at a time. One consumer is the whole safety
+ /// argument: builds never overlap, so the upsert-by-position in
+ /// `updatePeerChangeLedger` can't duplicate a row.
+ private var ledgerRequests: AsyncStream<Set<UUID>>.Continuation?
+
+ /// Fire-and-forget request to refresh the peer-change ledger for `gameIDs`.
+ /// Returns immediately — the inbound-moves hot path must never wait on this
+ /// database write. The request is just buffered onto the serial queue; the
+ /// single consumer applies it when it gets there.
+ func enqueuePeerChangeLedgerUpdate(for gameIDs: Set<UUID>) {
+ guard !gameIDs.isEmpty else { return }
+ if ledgerRequests == nil {
+ let (stream, continuation) = AsyncStream<Set<UUID>>.makeStream()
+ ledgerRequests = continuation
+ // The sole consumer: serial by construction, so no two builds run at
+ // once. Inherits this `@MainActor`, hopping to a background context
+ // only inside `updatePeerChangeLedger`.
+ Task { [weak self] in
+ for await gameIDs in stream {
+ await self?.updatePeerChangeLedger(for: gameIDs)
+ }
+ }
+ }
+ ledgerRequests?.yield(gameIDs)
+ }
+
+ /// Maintains the device-local per-cell letter-change ledger
+ /// (`PeerChangeEntity`) for each game that just received inbound moves. For
+ /// every cell whose letter differs from what the ledger holds, upserts a row
+ /// stamped with the move's letter-change time; a check (a mark-only
+ /// re-stamp) leaves the letter unchanged and so writes nothing. Runs on a
+ /// background context, off the main actor.
+ ///
+ /// The ledger is what the "changed while you were away" borders and catch-up
+ /// banner read (`recentChanges(forGame:since:)`). Recording a letter-change
+ /// time — rather than trusting the synced cell's `updatedAt`, which a check
+ /// bumps — is what stops a peer's check sweep from flagging the whole board
+ /// on rejoin. A first build for a game (no rows yet) seeds every current
+ /// cell at `.distantPast`, a silent baseline that surfaces nothing.
+ ///
+ /// In the app this is driven through `enqueuePeerChangeLedgerUpdate`, whose
+ /// single serial consumer guarantees builds never overlap — so the
+ /// upsert-by-position below can't duplicate a row. Tests call it directly
+ /// and `await` it for determinism.
+ func updatePeerChangeLedger(for gameIDs: Set<UUID>) async {
+ guard !gameIDs.isEmpty else { return }
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
+ await ctx.perform {
+ for gameID in gameIDs {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ guard let game = try? ctx.fetch(gameReq).first else { continue }
+
+ let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
+ movesReq.predicate = NSPredicate(format: "game == %@", game)
+ let values: [MovesValue] = ((try? ctx.fetch(movesReq)) ?? [])
+ .compactMap { Self.movesValue(from: $0) }
+ let current = GridStateMerger.mergeWithProvenance(values)
+
+ let ledgerReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity")
+ ledgerReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg)
+ var rowByPosition: [GridPosition: PeerChangeEntity] = [:]
+ for row in (try? ctx.fetch(ledgerReq)) ?? [] {
+ rowByPosition[GridPosition(row: Int(row.row), col: Int(row.col))] = row
+ }
+ let recorded = rowByPosition.mapValues { Self.peerChange(from: $0) }
+
+ let upserts = PeerChangeLedger.upserts(
+ current: current,
+ recorded: recorded,
+ seeding: recorded.isEmpty
+ )
+ for change in upserts {
+ let row = rowByPosition[change.position] ?? PeerChangeEntity(context: ctx)
+ row.gameID = gameID
+ row.row = Int16(change.position.row)
+ row.col = Int16(change.position.col)
+ row.letter = change.letter
+ row.authorID = change.authorID
+ row.changedAt = change.changedAt
+ row.game = game
+ }
+ }
+ guard ctx.hasChanges else { return }
+ do {
+ try ctx.save()
+ } catch {
+ let message = "GameStore: peer change ledger save failed — \(error)"
+ Task { @MainActor [weak self] in
+ self?.eventLog?.note(message, level: "error")
+ }
+ }
+ }
+ }
+
/// Updates `latestOtherMoveAt` for each game whose Moves record was just
/// updated by another iCloud user, driving the unread-badge heuristic.
/// `gameIDs` are the games that received an inbound `Moves` record in the
@@ -1598,16 +1697,22 @@ final class GameStore {
/// The full `RecentChanges.Changes` for `gameID` since `since`: the cell
/// map behind the borders *and* the per-author counts behind the catch-up
- /// banner, from one merge so the two surfaces always agree. Empty when the
- /// local author is unknown or there are no moves.
+ /// banner, from one pass over the per-cell letter-change ledger so the two
+ /// surfaces always agree. Empty when the local author is unknown, or when
+ /// the ledger holds nothing newer than `since` (including a game whose
+ /// ledger has not been seeded yet — a first open shows no banner).
func recentChanges(forGame gameID: UUID, since: Date) -> RecentChanges.Changes {
guard let localAuthorID = authorIDProvider() else { return .empty }
- let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
- request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
- let entities = (try? context.fetch(request)) ?? []
- let values: [MovesValue] = entities.compactMap { Self.movesValue(from: $0) }
- guard !values.isEmpty else { return .empty }
- return RecentChanges.changes(in: values, since: since, excludingAuthor: localAuthorID)
+ let request = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity")
+ request.predicate = NSPredicate(
+ format: "gameID == %@ AND changedAt > %@",
+ gameID as CVarArg,
+ since as NSDate
+ )
+ let rows = (try? context.fetch(request)) ?? []
+ guard !rows.isEmpty else { return .empty }
+ let entries = rows.map { Self.peerChange(from: $0) }
+ return RecentChanges.changes(in: entries, since: since, excludingAuthor: localAuthorID)
}
/// Sender-side measurements describing *why* the pause-push counts for
@@ -1954,6 +2059,15 @@ final class GameStore {
)
}
+ fileprivate nonisolated static func peerChange(from entity: PeerChangeEntity) -> PeerChange {
+ PeerChange(
+ position: GridPosition(row: Int(entity.row), col: Int(entity.col)),
+ letter: entity.letter ?? "",
+ authorID: entity.authorID,
+ changedAt: entity.changedAt ?? .distantPast
+ )
+ }
+
private func inferredObservedCompletionAuthorID(for id: UUID) -> String? {
let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -627,6 +627,12 @@ final class AppServices {
await self?.publishReadCursor(for: currentID, mode: .activeLease)
}
}
+ // Maintain the per-cell letter-change ledger that the "changed while
+ // you were away" borders and banner read. Captures peer fills/clears
+ // (never check re-stamps) as they arrive, whether or not the game is
+ // open. Fire-and-forget: the inbound-moves hot path must not wait on
+ // this background write, so the next batch isn't throttled behind it.
+ store.enqueuePeerChangeLedgerUpdate(for: gameIDs)
}
// Friendship bootstrap keys off the *first* sight of a collaborator's
diff --git a/Crossmate/Sync/PeerChangeLedger.swift b/Crossmate/Sync/PeerChangeLedger.swift
@@ -0,0 +1,61 @@
+import Foundation
+
+/// One recorded letter change for a single grid cell: the cell's letter as we
+/// last observed it, who wrote that letter, and *when the letter last changed*.
+/// Persisted device-locally (one `PeerChangeEntity` row per touched cell) and
+/// updated incrementally from the inbound `Moves` snapshots we receive.
+///
+/// The point of `changedAt` is to be a **letter**-change time, not a touch
+/// time. The synced `Moves` snapshot bumps a cell's `updatedAt` on any touch —
+/// including a check, which re-stamps every filled cell while leaving the
+/// letter untouched. Keying "changed while you were away" off that timestamp
+/// makes a peer's check sweep light up the whole board on rejoin. Here we only
+/// advance `changedAt` when the letter actually differs from what we recorded,
+/// so a check never moves it.
+struct PeerChange: Equatable, Sendable {
+ let position: GridPosition
+ let letter: String
+ let authorID: String?
+ let changedAt: Date
+}
+
+/// Pure logic for maintaining the per-cell letter-change ledger. Kept separate
+/// from Core Data so it runs off already-merged moves and is unit-testable in
+/// isolation.
+enum PeerChangeLedger {
+ /// The rows to upsert given the current merged grid and the letters already
+ /// recorded: one `PeerChange` per cell whose letter differs from what we
+ /// hold (or that we've never recorded). Cells whose letter is unchanged —
+ /// the common case for an inbound check sweep — produce nothing.
+ ///
+ /// Attribution mirrors `RecentChanges`' old rule: the letter's preserved
+ /// author (survives a check), falling back to whoever wrote the winning
+ /// move when a cleared cell carries no preserved author.
+ ///
+ /// `seeding` is set when the ledger has no rows for this game yet (a first
+ /// build, e.g. just after upgrade). Every current cell is then recorded at
+ /// `.distantPast`, establishing a silent baseline so the first build never
+ /// surfaces pre-existing content as "changed while you were away". Genuine
+ /// changes after the seed carry the move's real `updatedAt`.
+ static func upserts(
+ current: [GridPosition: GridStateMerger.Provenance],
+ recorded: [GridPosition: PeerChange],
+ seeding: Bool
+ ) -> [PeerChange] {
+ var result: [PeerChange] = []
+ for (position, provenance) in current {
+ let letter = provenance.cell.letter
+ if let existing = recorded[position], existing.letter == letter { continue }
+ let writer = provenance.cell.authorID ?? provenance.writerAuthorID
+ result.append(
+ PeerChange(
+ position: position,
+ letter: letter,
+ authorID: writer,
+ changedAt: seeding ? .distantPast : provenance.cell.updatedAt
+ )
+ )
+ }
+ return result
+ }
+}
diff --git a/Crossmate/Sync/RecentChanges.swift b/Crossmate/Sync/RecentChanges.swift
@@ -1,30 +1,23 @@
import Foundation
-/// Computes which grid cells had their *letter* changed since a cutoff,
-/// attributed to the player who wrote each change. Pure so it runs off the
-/// already-merged moves without touching Core Data and is unit-testable in
-/// isolation.
+/// Reduces the per-cell letter-change ledger (`PeerChange` rows) into the two
+/// shapes the receiver's "changed while you were away" surfaces need: the
+/// per-cell author map that draws the border highlights, and the per-author net
+/// counts that fill the catch-up banner. Both come from one pass over one cutoff
+/// so the borders and the banner can never describe different change sets.
///
-/// Fills, edits, *and* clears qualify — anything that altered what the cell
-/// reads. Mark-only moves do not: a check sweep re-stamps every filled cell
-/// with a fresh winning move (newer `updatedAt`, same letter, only the
-/// check/pencil mark changed), and those must not light up the whole board on
-/// rejoin. We therefore compare the cell's letter as it stood at the cutoff
-/// against its current letter, rather than trusting the timestamp alone.
+/// The ledger already records *when each cell's letter last changed* (see
+/// `PeerChange`), so a peer's check — which re-stamps `updatedAt` without
+/// touching the letter — never advanced an entry's `changedAt` and so never
+/// appears here. We therefore gate purely on `changedAt > since`; no
+/// reconstruction of the grid at the cutoff is needed.
///
-/// Each flagged cell is attributed to the player who set its current letter,
-/// not whoever touched it last. The winning move carries that author on
-/// `TimestampedCell.authorID` — a check preserves it, so a peer's check sweep
-/// over someone else's answer still credits the original typist. A clear drops
-/// the preserved author to `nil`, so for an emptied cell we fall back to the
-/// winning move's `writerAuthorID` (whoever did the clearing), which is what
-/// `GridStateMerger.mergeWithProvenance` surfaces.
+/// Each flagged cell is attributed to the author the ledger recorded for its
+/// current letter (preserved through checks; a clear credits whoever emptied it).
enum RecentChanges {
/// A peer's changes since a cutoff, in the two shapes the receiver surfaces
/// need: the per-cell author map that draws the border highlights, and the
- /// per-author net counts that fill the catch-up banner. Both are derived in
- /// one pass off one cutoff, so the borders and the banner cannot describe
- /// different change sets.
+ /// per-author net counts that fill the catch-up banner.
struct Changes: Equatable {
/// Changed positions mapped to the author who wrote the current letter.
var cells: [GridPosition: String]
@@ -40,34 +33,23 @@ enum RecentChanges {
var cleared: Int
}
- /// Positions whose winning move landed strictly after `since` and changed
- /// the cell's letter, each mapped to the author who wrote that letter, plus
- /// the per-author add/clear tally over the same set (`excluded` — typically
- /// the local user — is dropped). Last-writer-wins (inside
- /// `mergeWithProvenance`) decides the winning touch per cell, so a cell
- /// reverted back to its old value by a later move reflects that later move.
+ /// Ledger rows whose letter last changed strictly after `since`, each mapped
+ /// to the recorded author, plus the per-author add/clear tally over the same
+ /// set (`excluded` — typically the local user — is dropped, as are rows with
+ /// no recorded author).
static func changes(
- in moves: [MovesValue],
+ in entries: [PeerChange],
since: Date,
excludingAuthor excluded: String
) -> Changes {
- // The grid as it stood at the cutoff, so a mark-only move (a check) can
- // be told apart from a genuine letter change by comparing letters.
- let asOfCutoff = GridStateMerger.merge(moves, notAfter: since)
var cells: [GridPosition: String] = [:]
var counts: [String: Count] = [:]
- for (position, provenance) in GridStateMerger.mergeWithProvenance(moves) {
- guard provenance.cell.updatedAt > since else { continue }
- let priorLetter = asOfCutoff[position]?.letter ?? ""
- let currentLetter = provenance.cell.letter
- guard priorLetter != currentLetter else { continue }
- // The letter's author, preserved through later mark-only moves; a
- // cleared cell carries none, so credit whoever cleared it.
- let writer = provenance.cell.authorID ?? provenance.writerAuthorID
- guard writer != excluded else { continue }
- cells[position] = writer
+ for entry in entries {
+ guard entry.changedAt > since else { continue }
+ guard let writer = entry.authorID, writer != excluded else { continue }
+ cells[entry.position] = writer
var count = counts[writer] ?? Count(added: 0, cleared: 0)
- if currentLetter.isEmpty {
+ if entry.letter.isEmpty {
count.cleared += 1
} else {
count.added += 1
@@ -79,10 +61,10 @@ enum RecentChanges {
/// The cell→author map alone, for the border highlights.
static func changedCells(
- in moves: [MovesValue],
+ in entries: [PeerChange],
since: Date,
excludingAuthor excluded: String
) -> [GridPosition: String] {
- changes(in: moves, since: since, excludingAuthor: excluded).cells
+ changes(in: entries, since: since, excludingAuthor: excluded).cells
}
}
diff --git a/Tests/Unit/PeerChangeLedgerTests.swift b/Tests/Unit/PeerChangeLedgerTests.swift
@@ -0,0 +1,113 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("PeerChangeLedger.upserts")
+struct PeerChangeLedgerTests {
+
+ private func t(_ seconds: TimeInterval) -> Date {
+ Date(timeIntervalSinceReferenceDate: seconds)
+ }
+
+ /// A merged-grid provenance entry: `author` is the preserved cell author
+ /// (the letter's writer, `nil` for a clear), `writer` is the iCloud user
+ /// whose move won LWW.
+ private func prov(
+ _ letter: String,
+ author: String?,
+ writer: String,
+ at: Date
+ ) -> GridStateMerger.Provenance {
+ GridStateMerger.Provenance(
+ cell: TimestampedCell(letter: letter, mark: .none, updatedAt: at, authorID: author),
+ writerAuthorID: writer
+ )
+ }
+
+ private func apply(_ upserts: [PeerChange], to ledger: inout [GridPosition: PeerChange]) {
+ for change in upserts { ledger[change.position] = change }
+ }
+
+ private let p1 = GridPosition(row: 0, col: 0)
+ private let p2 = GridPosition(row: 0, col: 1)
+ private let p3 = GridPosition(row: 1, col: 0)
+
+ @Test("A first build seeds every current cell at .distantPast")
+ func seedingRecordsDistantPast() {
+ let current = [
+ p1: prov("A", author: "bob", writer: "bob", at: t(10)),
+ p2: prov("B", author: "bob", writer: "bob", at: t(20)),
+ ]
+ let upserts = PeerChangeLedger.upserts(current: current, recorded: [:], seeding: true)
+ #expect(upserts.count == 2)
+ #expect(upserts.allSatisfy { $0.changedAt == .distantPast })
+ }
+
+ @Test("After the seed, a genuine letter change records the move's real timestamp")
+ func letterChangeRecordsRealTimestamp() {
+ let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: .distantPast)]
+ let current = [p1: prov("B", author: "carol", writer: "carol", at: t(40))]
+ let upserts = PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false)
+ #expect(upserts == [PeerChange(position: p1, letter: "B", authorID: "carol", changedAt: t(40))])
+ }
+
+ @Test("A check re-stamp (same letter, newer updatedAt) records nothing")
+ func checkReStampIgnored() {
+ // The reported bug: a check bumps updatedAt but leaves the letter alone.
+ let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: t(10))]
+ let current = [p1: prov("A", author: "bob", writer: "bob", at: t(99))]
+ let upserts = PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false)
+ #expect(upserts.isEmpty)
+ }
+
+ @Test("A clear records an empty letter, attributed to whoever cleared it")
+ func clearAttributedToClearer() {
+ let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "carol", changedAt: t(10))]
+ // The clearing move carries no preserved cell author; the writer is Bob.
+ let current = [p1: prov("", author: nil, writer: "bob", at: t(40))]
+ let upserts = PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false)
+ #expect(upserts == [PeerChange(position: p1, letter: "", authorID: "bob", changedAt: t(40))])
+ }
+
+ @Test("An unchanged cell records nothing")
+ func unchangedCellIgnored() {
+ let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: t(10))]
+ let current = [p1: prov("A", author: "bob", writer: "bob", at: t(10))]
+ #expect(PeerChangeLedger.upserts(current: current, recorded: recorded, seeding: false).isEmpty)
+ }
+
+ // MARK: - End-to-end: the reported rejoin bug
+
+ @Test("A peer's check sweep after you leave never reads as 'filled while away'")
+ func checkSweepNotFlaggedOnReturn() {
+ // The game is in progress: bunny's two letters are already in the
+ // ledger, recorded at their genuine fill times before you left.
+ var ledger: [GridPosition: PeerChange] = [
+ p1: PeerChange(position: p1, letter: "A", authorID: "bunny", changedAt: t(10)),
+ p2: PeerChange(position: p2, letter: "B", authorID: "bunny", changedAt: t(20)),
+ ]
+ let viewedAt = t(30)
+
+ // bunny runs a check: same letters, fresh updatedAt — and, in the same
+ // sync, genuinely fills one new cell.
+ let inbound = [
+ p1: prov("A", author: "bunny", writer: "bunny", at: t(40)),
+ p2: prov("B", author: "bunny", writer: "bunny", at: t(50)),
+ p3: prov("C", author: "bunny", writer: "bunny", at: t(60)),
+ ]
+ apply(
+ PeerChangeLedger.upserts(current: inbound, recorded: ledger, seeding: false),
+ to: &ledger
+ )
+
+ let changes = RecentChanges.changes(
+ in: Array(ledger.values),
+ since: viewedAt,
+ excludingAuthor: "alice"
+ )
+ // Only the genuine new fill counts — not the two re-stamped letters.
+ #expect(changes.cells == [p3: "bunny"])
+ #expect(changes.counts["bunny"] == RecentChanges.Count(added: 1, cleared: 0))
+ }
+}
diff --git a/Tests/Unit/RecentChangesTests.swift b/Tests/Unit/RecentChangesTests.swift
@@ -3,183 +3,86 @@ import Testing
@testable import Crossmate
-@Suite("RecentChanges.changedCells")
+@Suite("RecentChanges over the ledger")
struct RecentChangesTests {
- private let gameID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!
-
- /// One author/device's contribution. Each cell is `(row, col, letter,
- /// updatedAt)`; an empty `letter` models a clear.
- private func view(
- author: String,
- device: String = "d1",
- cells: [(row: Int, col: Int, letter: String, updatedAt: Date)]
- ) -> MovesValue {
- var dict: [GridPosition: TimestampedCell] = [:]
- for entry in cells {
- dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell(
- letter: entry.letter,
- mark: .none,
- updatedAt: entry.updatedAt,
- // A clear leaves no preserved cell author, mirroring the app.
- authorID: entry.letter.isEmpty ? nil : author
- )
- }
- return MovesValue(
- gameID: gameID,
+ /// One ledger row: a cell's recorded letter, its author, and when the letter
+ /// last changed. An empty `letter` models a clear; a `nil` author models a
+ /// row with no recorded writer (dropped by the reduction).
+ private func change(
+ _ row: Int,
+ _ col: Int,
+ _ letter: String,
+ author: String?,
+ at: Date
+ ) -> PeerChange {
+ PeerChange(
+ position: GridPosition(row: row, col: col),
+ letter: letter,
authorID: author,
- deviceID: device,
- cells: dict,
- updatedAt: cells.map(\.updatedAt).max() ?? .distantPast
+ changedAt: at
)
}
private let cutoff = Date(timeIntervalSinceReferenceDate: 1_000)
private var before: Date { cutoff.addingTimeInterval(-60) }
private var after: Date { cutoff.addingTimeInterval(60) }
- private var later: Date { cutoff.addingTimeInterval(120) }
@Test("A peer's fill after the cutoff is included, attributed to the peer")
func peerFillIncluded() {
- let moves = [view(author: "bob", cells: [(1, 2, "A", after)])]
- let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
- #expect(changes == [GridPosition(row: 1, col: 2): "bob"])
+ let entries = [change(1, 2, "A", author: "bob", at: after)]
+ let cells = RecentChanges.changedCells(in: entries, since: cutoff, excludingAuthor: "alice")
+ #expect(cells == [GridPosition(row: 1, col: 2): "bob"])
}
@Test("My own change is excluded even though it is newer than the cutoff")
func ownChangeExcluded() {
- let moves = [view(author: "alice", cells: [(0, 0, "X", after)])]
- let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
- #expect(changes.isEmpty)
+ let entries = [change(0, 0, "X", author: "alice", at: after)]
+ let cells = RecentChanges.changedCells(in: entries, since: cutoff, excludingAuthor: "alice")
+ #expect(cells.isEmpty)
}
- @Test("A peer's clear after the cutoff is included and attributed to the peer")
+ @Test("A peer's clear after the cutoff is included and attributed to the clearer")
func peerClearIncluded() {
- // Carol fills the cell, then Bob clears it later — the clear is the
- // winning move, so the cell is attributed to Bob despite carrying no
- // preserved letter author.
- let moves = [
- view(author: "carol", cells: [(3, 4, "Q", before)]),
- view(author: "bob", cells: [(3, 4, "", later)]),
- ]
- let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
- #expect(changes == [GridPosition(row: 3, col: 4): "bob"])
+ let entries = [change(3, 4, "", author: "bob", at: after)]
+ let cells = RecentChanges.changedCells(in: entries, since: cutoff, excludingAuthor: "alice")
+ #expect(cells == [GridPosition(row: 3, col: 4): "bob"])
}
@Test("A change at or before the cutoff is excluded")
func preCutoffExcluded() {
- let moves = [
- view(author: "bob", cells: [(0, 0, "A", before)]),
- view(author: "carol", cells: [(1, 1, "B", cutoff)]),
- ]
- let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
- #expect(changes.isEmpty)
- }
-
- @Test("Later peer write wins the cell's attribution over an earlier one")
- func latestWriterWins() {
- let moves = [
- view(author: "bob", cells: [(2, 2, "A", after)]),
- view(author: "carol", cells: [(2, 2, "B", later)]),
- ]
- let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
- #expect(changes == [GridPosition(row: 2, col: 2): "carol"])
- }
-
- @Test("Empty moves produce no changes")
- func emptyMoves() {
- let changes = RecentChanges.changedCells(in: [], since: cutoff, excludingAuthor: "alice")
- #expect(changes.isEmpty)
- }
-
- /// A check sweep re-stamps a cell with a fresh, newer move that keeps the
- /// same letter and only flips the mark. `cellAuthor` is the preserved letter
- /// author (the original typist), distinct from the checking `author`.
- private func check(
- author: String,
- device: String = "d1",
- row: Int, col: Int, letter: String, cellAuthor: String,
- updatedAt: Date
- ) -> MovesValue {
- let cell = TimestampedCell(
- letter: letter,
- mark: .pen(checked: .right),
- updatedAt: updatedAt,
- authorID: cellAuthor
- )
- return MovesValue(
- gameID: gameID,
- authorID: author,
- deviceID: device,
- cells: [GridPosition(row: row, col: col): cell],
- updatedAt: updatedAt
- )
- }
-
- @Test("A peer's check that only changes the mark is excluded")
- func peerCheckExcluded() {
- // Alice typed the letter before the cutoff; Bob check-sweeps it after.
- // The letter never changed, so it must not light up on rejoin.
- let moves = [
- view(author: "alice", cells: [(1, 1, "A", before)]),
- check(author: "bob", row: 1, col: 1, letter: "A", cellAuthor: "alice", updatedAt: after),
+ let entries = [
+ change(0, 0, "A", author: "bob", at: before),
+ change(1, 1, "B", author: "carol", at: cutoff),
]
- let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
- #expect(changes.isEmpty)
+ let cells = RecentChanges.changedCells(in: entries, since: cutoff, excludingAuthor: "alice")
+ #expect(cells.isEmpty)
}
- @Test("A check sweep doesn't mask a real letter change, and credits the letter writer")
- func realChangeSurvivesLaterCheck() {
- // Carol overwrites the cell after the cutoff, then Bob checks it. The
- // letter did change, so the cell is flagged — and attributed to Carol,
- // who wrote the letter, not Bob, who merely checked it.
- let moves = [
- view(author: "alice", cells: [(2, 3, "A", before)]),
- view(author: "carol", cells: [(2, 3, "B", after)]),
- check(author: "bob", row: 2, col: 3, letter: "B", cellAuthor: "carol", updatedAt: later),
- ]
- let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
- #expect(changes == [GridPosition(row: 2, col: 3): "carol"])
+ @Test("A row with no recorded author is dropped")
+ func noAuthorDropped() {
+ let entries = [change(2, 2, "A", author: nil, at: after)]
+ let cells = RecentChanges.changedCells(in: entries, since: cutoff, excludingAuthor: "alice")
+ #expect(cells.isEmpty)
}
- @Test("A peer checking my own post-cutoff letter doesn't flag it as a change")
- func peerCheckOfOwnLetterExcluded() {
- // Alice (the viewer) writes the letter after the cutoff, then Bob checks
- // it. The preserved author is Alice, so attribution resolves to her and
- // the cell is excluded — even though Bob's check is the winning move.
- let moves = [
- view(author: "alice", cells: [(4, 5, "M", after)]),
- check(author: "bob", row: 4, col: 5, letter: "M", cellAuthor: "alice", updatedAt: later),
- ]
- let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
- #expect(changes.isEmpty)
- }
-
- @Test("A check on a cell first filled after the cutoff still counts as a change")
- func fillThenCheckIncluded() {
- // Bob fills an empty cell after the cutoff and checks it; empty -> letter
- // is a genuine change.
- let moves = [
- view(author: "bob", cells: [(0, 0, "Z", after)]),
- check(author: "bob", row: 0, col: 0, letter: "Z", cellAuthor: "bob", updatedAt: later),
- ]
- let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice")
- #expect(changes == [GridPosition(row: 0, col: 0): "bob"])
+ @Test("Empty entries produce no changes")
+ func emptyEntries() {
+ let cells = RecentChanges.changedCells(in: [], since: cutoff, excludingAuthor: "alice")
+ #expect(cells.isEmpty)
}
// MARK: - Per-author counts (the banner reduction)
@Test("Counts and the cell map describe the same set: fills add, clears clear")
func countsMatchCells() {
- // Bob fills two cells; Carol fills one new cell, and clears a cell she
- // had filled before the cutoff (two separate moves, the clear winning).
- let moves = [
- view(author: "bob", cells: [(0, 0, "A", after), (0, 1, "B", after)]),
- view(author: "carol", cells: [(1, 0, "C", after)]),
- view(author: "carol", cells: [(2, 0, "D", before)]),
- view(author: "carol", cells: [(2, 0, "", later)]),
+ let entries = [
+ change(0, 0, "A", author: "bob", at: after),
+ change(0, 1, "B", author: "bob", at: after),
+ change(1, 0, "C", author: "carol", at: after),
+ change(2, 0, "", author: "carol", at: after),
]
- let changes = RecentChanges.changes(in: moves, since: cutoff, excludingAuthor: "alice")
+ let changes = RecentChanges.changes(in: entries, since: cutoff, excludingAuthor: "alice")
#expect(changes.counts["bob"] == RecentChanges.Count(added: 2, cleared: 0))
#expect(changes.counts["carol"] == RecentChanges.Count(added: 1, cleared: 1))
// Every counted change is a bordered cell, and vice versa.
@@ -189,8 +92,8 @@ struct RecentChangesTests {
@Test("No changes after the cutoff yields empty counts")
func emptyCounts() {
- let moves = [view(author: "bob", cells: [(0, 0, "A", before)])]
- let changes = RecentChanges.changes(in: moves, since: cutoff, excludingAuthor: "alice")
+ let entries = [change(0, 0, "A", author: "bob", at: before)]
+ let changes = RecentChanges.changes(in: entries, since: cutoff, excludingAuthor: "alice")
#expect(changes.counts.isEmpty)
#expect(changes.cells.isEmpty)
}
diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift
@@ -71,6 +71,32 @@ struct SessionMonitorTests {
)
}
+ /// Seeds the per-cell letter-change ledger directly — the data `summaries`
+ /// reduces. Each cell is the recorded letter (empty = a clear) and when it
+ /// last changed. Most reduction tests use this rather than driving the
+ /// moves→ledger writer, whose translation is covered separately.
+ private func writeLedger(
+ in fixture: Fixture,
+ authorID: String?,
+ cells: [GridPosition: (letter: String, changedAt: Date)]
+ ) throws {
+ let ctx = fixture.persistence.viewContext
+ for (pos, value) in cells {
+ let row = PeerChangeEntity(context: ctx)
+ row.gameID = fixture.gameID
+ row.row = Int16(pos.row)
+ row.col = Int16(pos.col)
+ row.letter = value.letter
+ row.authorID = authorID
+ row.changedAt = value.changedAt
+ row.game = fixture.game
+ }
+ try ctx.save()
+ }
+
+ /// Upserts a device's full `MovesEntity` snapshot (one row per
+ /// author/device, as in production). Used by the integration tests that
+ /// drive the real moves→ledger writer.
private func writeMoves(
in fixture: Fixture,
authorID: String,
@@ -83,24 +109,35 @@ struct SessionMonitorTests {
letter: value.letter,
mark: .none,
updatedAt: value.updatedAt,
- authorID: authorID
+ authorID: value.letter.isEmpty ? nil : authorID
)
}
let data = try MovesCodec.encode(stamped)
- let row = MovesEntity(context: ctx)
+ let recordName = RecordSerializer.recordName(
+ forMovesInGame: fixture.gameID,
+ authorID: authorID,
+ deviceID: deviceID
+ )
+ let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
+ req.predicate = NSPredicate(
+ format: "game == %@ AND authorID == %@ AND deviceID == %@",
+ fixture.game, authorID, deviceID
+ )
+ req.fetchLimit = 1
+ let row = (try? ctx.fetch(req).first) ?? MovesEntity(context: ctx)
row.game = fixture.game
row.authorID = authorID
row.deviceID = deviceID
row.cells = data
row.updatedAt = Date()
- row.ckRecordName = RecordSerializer.recordName(
- forMovesInGame: fixture.gameID,
- authorID: authorID,
- deviceID: deviceID
- )
+ row.ckRecordName = recordName
try ctx.save()
}
+ private func buildLedger(in fixture: Fixture) async {
+ await fixture.store.updatePeerChangeLedger(for: [fixture.gameID])
+ }
+
private func addPlayer(
in fixture: Fixture,
authorID: String,
@@ -124,15 +161,14 @@ struct SessionMonitorTests {
private static let cutoff = Date(timeIntervalSince1970: 1_000)
private var before: Date { Self.cutoff.addingTimeInterval(-60) }
private var after: Date { Self.cutoff.addingTimeInterval(60) }
- private var later: Date { Self.cutoff.addingTimeInterval(120) }
- // MARK: - Counts since the view baseline
+ // MARK: - Counts since the view baseline (ledger reduction)
@Test("A peer's fills since the cutoff surface as a named summary")
func peerFillsSummarised() throws {
let fixture = try makeFixture()
try addPlayer(in: fixture, authorID: Self.alice, name: "Alice")
- try writeMoves(
+ try writeLedger(
in: fixture,
authorID: Self.alice,
cells: [
@@ -166,7 +202,7 @@ struct SessionMonitorTests {
friend.createdAt = Date()
friend.nickname = "Mum"
try ctx.save()
- try writeMoves(
+ try writeLedger(
in: fixture,
authorID: Self.alice,
cells: [position(0, 0): ("A", after)]
@@ -182,20 +218,13 @@ struct SessionMonitorTests {
func clearsCounted() throws {
let fixture = try makeFixture()
try addPlayer(in: fixture, authorID: Self.alice, name: "Alice")
- // Alice filled two cells before the cutoff, then clears one after it.
- try writeMoves(
+ // One cell still holds its (pre-cutoff) letter; another was emptied
+ // after the cutoff.
+ try writeLedger(
in: fixture,
authorID: Self.alice,
cells: [
position(0, 0): ("A", before),
- position(0, 1): ("B", before),
- ]
- )
- try writeMoves(
- in: fixture,
- authorID: Self.alice,
- cells: [
- position(0, 0): ("A", after),
position(0, 1): ("", after),
]
)
@@ -210,7 +239,7 @@ struct SessionMonitorTests {
func noActivityReturnsNothing() throws {
let fixture = try makeFixture()
try addPlayer(in: fixture, authorID: Self.alice, name: "Alice")
- try writeMoves(
+ try writeLedger(
in: fixture,
authorID: Self.alice,
cells: [position(0, 0): ("A", before)]
@@ -223,7 +252,7 @@ struct SessionMonitorTests {
func readsAreStable() throws {
let fixture = try makeFixture()
try addPlayer(in: fixture, authorID: Self.alice, name: "Alice")
- try writeMoves(
+ try writeLedger(
in: fixture,
authorID: Self.alice,
cells: [
@@ -235,16 +264,16 @@ struct SessionMonitorTests {
#expect(fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first?.added == 2)
}
- @Test("The local author's Moves row is excluded from the summaries")
+ @Test("The local author's change is excluded from the summaries")
func localAuthorExcluded() throws {
let fixture = try makeFixture()
try addPlayer(in: fixture, authorID: Self.alice, name: "Alice")
- try writeMoves(
+ try writeLedger(
in: fixture,
authorID: Self.localAuthorID,
cells: [position(0, 0): ("A", after)]
)
- try writeMoves(
+ try writeLedger(
in: fixture,
authorID: Self.alice,
cells: [position(0, 1): ("B", after)]
@@ -260,12 +289,12 @@ struct SessionMonitorTests {
let fixture = try makeFixture()
try addPlayer(in: fixture, authorID: Self.alice, name: "Alice")
try addPlayer(in: fixture, authorID: Self.bob, name: "Bob")
- try writeMoves(
+ try writeLedger(
in: fixture,
authorID: Self.alice,
cells: [position(0, 0): ("A", after)]
)
- try writeMoves(
+ try writeLedger(
in: fixture,
authorID: Self.bob,
cells: [
@@ -280,27 +309,72 @@ struct SessionMonitorTests {
#expect(summaries[1].added == 2)
}
- @Test("A peer's edits across two devices roll into one summary")
- func peerMultipleDevicesMerged() throws {
+ // MARK: - Integration: the moves→ledger writer
+
+ @Test("A peer's fills drive the ledger and surface as a summary")
+ func fillsDriveLedger() async throws {
let fixture = try makeFixture()
try addPlayer(in: fixture, authorID: Self.alice, name: "Alice")
+ // A baseline build (over a pre-cutoff cell) seeds the ledger, so the
+ // later fills are measured against it rather than absorbed by the seed.
try writeMoves(
in: fixture,
authorID: Self.alice,
- deviceID: "ipad",
- cells: [position(0, 0): ("A", after)]
+ cells: [position(2, 2): ("H", before)]
)
+ await buildLedger(in: fixture)
try writeMoves(
in: fixture,
authorID: Self.alice,
- deviceID: "iphone",
- cells: [position(0, 1): ("B", later)]
+ cells: [
+ position(2, 2): ("H", before),
+ position(0, 0): ("A", after),
+ position(0, 1): ("B", after),
+ ]
)
+ await buildLedger(in: fixture)
let summary = try #require(
fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first
)
- // The receiver banner should not split iPad/iPhone work.
#expect(summary.added == 2)
}
+
+ @Test("A peer's check sweep after the cutoff does not inflate the summary")
+ func checkSweepDoesNotInflateSummary() async throws {
+ // The reported rejoin bug: a check re-stamps every filled cell's
+ // updatedAt without changing the letter, and must not read as fresh
+ // fills on return.
+ let fixture = try makeFixture()
+ try addPlayer(in: fixture, authorID: Self.alice, name: "Alice")
+ // Alice's two letters arrive and are recorded before you leave.
+ try writeMoves(
+ in: fixture,
+ authorID: Self.alice,
+ cells: [
+ position(0, 0): ("A", before),
+ position(0, 1): ("B", before),
+ ]
+ )
+ await buildLedger(in: fixture)
+ // After the cutoff she runs a check (re-stamps both letters) and fills
+ // one genuinely new cell.
+ try writeMoves(
+ in: fixture,
+ authorID: Self.alice,
+ cells: [
+ position(0, 0): ("A", after),
+ position(0, 1): ("B", after),
+ position(2, 2): ("H", after),
+ ]
+ )
+ await buildLedger(in: fixture)
+
+ let summary = try #require(
+ fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first
+ )
+ // Only the genuine fill — not the two re-stamped letters.
+ #expect(summary.added == 1)
+ #expect(summary.cleared == 0)
+ }
}