crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 13+++++++++++++
MCrossmate/Persistence/GameStore.swift | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MCrossmate/Services/AppServices.swift | 6++++++
ACrossmate/Sync/PeerChangeLedger.swift | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecentChanges.swift | 68+++++++++++++++++++++++++-------------------------------------------
ATests/Unit/PeerChangeLedgerTests.swift | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/RecentChangesTests.swift | 191++++++++++++++++++++-----------------------------------------------------------
MTests/Unit/Sync/SessionMonitorTests.swift | 144++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
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) + } }