crossmate

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

commit 95333a9ce12e3d2115ed59666fbbcce4d6a5546f
parent 127ef9e38aaba6345023fc7fc31af423b21afdbd
Author: Michael Camilleri <[email protected]>
Date:   Thu, 18 Jun 2026 22:22:25 +0900

Unify the catch-up banner and away-change highlights

When a user returned to a shared puzzle by waking the device rather than
navigating in fresh, the catch-up banner could report a peer's changes
but not highlight any cells. This is because the banner re-derived on
every foreground, while the away-change borders were captured once, only
on a fresh open, so a background-to-foreground resume of an already-open
puzzle left them stale or absent. The two surfaces also diffed against
separate baselines — the banner against a per-peer Moves snapshot, the
borders against a device-local 'last viewed' timestamp — so even when
both appeared they could describe different change sets, which a user
reasonably reads as one statement.

This commit drives both surfaces from one cutoff and one computation.
RecentChanges now returns the changed-cell map and the per-author add
and clear counts from a single pass, so the borders and the banner
cannot disagree: the grid renders the cells, the banner reduces the
counts. Both read this device's 'last viewed' time, and a first-ever
open has no baseline, so neither surface fires rather than flagging the
whole grid.

That baseline is now monotonic and shared across the account's own
devices. GameViewedStore.advance only ever moves the cutoff forward, and
the leave path ships it on the existing Player.sessionSnapshot field as
a SeenBaseline that a sibling folds in with a max-merge — so a laggy or
stale write never regresses what has already been seen, and an older
per-peer-snapshot payload simply fails to decode and falls back to the
local value. The per-peer snapshot baseline the banner used previously,
along with its sibling-adoption path, is retired.

The borders are now recaptured whenever the puzzle returns to the
foreground, not only on a fresh navigation, so a wake reveals them on
the same beat the banner re-derives.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 21++++++++++++++++++++-
MCrossmate/Models/GameViewedStore.swift | 20++++++++++++++++++--
MCrossmate/Persistence/GameStore.swift | 16++++++++++++----
MCrossmate/Services/AppServices.swift | 29+++++++++++++++--------------
MCrossmate/Services/SessionCoordinator.swift | 41++++++++++++++++++++++-------------------
MCrossmate/Sync/RecentChanges.swift | 60+++++++++++++++++++++++++++++++++++++++++++++++++-----------
MCrossmate/Sync/RecordSerializer.swift | 10++++++----
MCrossmate/Sync/SessionMonitor.swift | 183+++++++++++--------------------------------------------------------------------
ATests/Unit/GameViewedStoreTests.swift | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/RecentChangesTests.swift | 28++++++++++++++++++++++++++++
MTests/Unit/Sync/AppServicesAnnouncementTests.swift | 19++++++-------------
MTests/Unit/Sync/SessionMonitorTests.swift | 371+++++++++++++------------------------------------------------------------------
12 files changed, 318 insertions(+), 539 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -897,6 +897,10 @@ private struct PuzzleDisplayView: View { // (the coordinator only acts from an idle state). await services.engagement.startEngagementIfPossible(gameID: id) } + // Reveal any peer changes that landed while we were away on the + // same resume the catch-up banner re-derives on, not only on a + // fresh navigation into the puzzle. + Task { await recaptureRecentChanges() } case .background: // Stop the engagement reconnect loop so it doesn't keep // re-dialling the live socket on background CKSyncEngine wakes. @@ -1004,7 +1008,22 @@ private struct PuzzleDisplayView: View { /// interaction (via `PuzzleView`'s acknowledgement) and on leave/background. private func stampPuzzleViewed() { guard session?.mutator.isShared == true else { return } - services.gameViewedStore.setLastViewed(Date(), forGame: gameID) + services.gameViewedStore.advance(Date(), forGame: gameID) + } + + /// Recaptures the "changed while you were away" borders against the current + /// view baseline. The `.task`-driven capture only runs on a fresh open, so a + /// background→foreground resume of the same open puzzle would otherwise leave + /// the borders stale (or absent) even as the catch-up banner re-derives. + /// Mirrors the open beat's settle so the diff reflects the freshened grid; + /// idempotent — `recentChanges` is `Equatable`, and the baseline only + /// advances on leave. + private func recaptureRecentChanges() async { + try? await Task.sleep(for: .milliseconds(750)) + guard let session, session.mutator.isShared, + let since = services.gameViewedStore.lastViewed(forGame: gameID) + else { return } + session.recentChanges = store.recentlyChangedCells(forGame: gameID, since: since) } /// Initialises shared-game state (roster, selection publishing, name broadcast) for diff --git a/Crossmate/Models/GameViewedStore.swift b/Crossmate/Models/GameViewedStore.swift @@ -33,9 +33,15 @@ final class GameViewedStore { // MARK: - Write - func setLastViewed(_ date: Date, forGame gameID: UUID) { + /// Moves the baseline for `gameID` forward to `date`, never backward. The + /// "last viewed" cutoff is monotonic: an older stamp (a laggy leave, or a + /// stale value adopted from a sibling) must not regress it, or already-seen + /// changes would re-surface as borders and a banner on the next open. + func advance(_ date: Date, forGame gameID: UUID) { + let interval = date.timeIntervalSinceReferenceDate var s = rawStore - s[gameID.uuidString] = date.timeIntervalSinceReferenceDate + if let existing = s[gameID.uuidString], existing >= interval { return } + s[gameID.uuidString] = interval rawStore = s } @@ -53,3 +59,13 @@ final class GameViewedStore { set { defaults.set(newValue, forKey: defaultsKey) } } } + +/// The device-local "last viewed" cutoff, shipped across the account's own +/// devices on `Player.sessionSnapshot` so a sibling converges on the latest +/// view time rather than recomputing from its own (possibly stale) view. Adopted +/// monotonically via `GameViewedStore.advance`. Encoded with the default +/// `JSONEncoder`, so an older per-peer-snapshot payload from a not-yet-upgraded +/// sibling simply fails to decode and is ignored (per-device fallback). +struct SeenBaseline: Codable, Equatable { + let viewedAt: Date +} diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1700,13 +1700,21 @@ final class GameStore { /// preserved cell author. The local player's own edits are excluded. Returns /// empty when the local author is unknown (nothing to compare against). func recentlyChangedCells(forGame gameID: UUID, since: Date) -> [GridPosition: String] { - guard let localAuthorID = authorIDProvider() else { return [:] } + recentChanges(forGame: gameID, since: since).cells + } + + /// 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. + 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 [:] } - return RecentChanges.changedCells(in: values, since: since, excludingAuthor: localAuthorID) + guard !values.isEmpty else { return .empty } + return RecentChanges.changes(in: values, since: since, excludingAuthor: localAuthorID) } /// Sender-side measurements describing *why* the pause-push counts for @@ -1834,7 +1842,7 @@ final class GameStore { /// or `nil` when none is set. The same override `resolvedDisplayName` /// applies, exposed here for surfaces hydrated through `GameStore` /// rather than from a `FriendEntity` row — currently the catch-up - /// banner's `SessionMonitor.movesSummaries`. + /// banner's `SessionMonitor.summaries`. func friendNickname(for authorID: String) -> String? { guard !authorID.isEmpty else { return nil } let request = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -304,6 +304,7 @@ final class AppServices { syncEngine: syncEngine, syncMonitor: self.syncMonitor, sessionMonitor: sessionMonitor, + gameViewedStore: gameViewedStore, announcements: self.announcements, identity: identity, preferences: preferences, @@ -528,7 +529,7 @@ final class AppServices { // delivered for that game (e.g. "X is solving"); opening it here is no // longer something to nudge for. A past readAt is just a closed-session // horizon bump and leaves delivered notifications untouched. - await syncEngine.setOnIncomingReadCursor { [weak self, store, sessionMonitor] pairs in + await syncEngine.setOnIncomingReadCursor { [weak self, store, gameViewedStore] pairs in let now = Date() for (gameID, readAt, seenBaselineData) in pairs { let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, readAt: readAt) @@ -538,16 +539,14 @@ final class AppServices { "was=\(previous?.ISO8601Format() ?? "—")" + (adopted ? "" : " (no-op)") ) - // A sibling device shipped the per-peer baseline it saw on its - // own `Player.sessionSnapshot`; adopt it directly so we converge - // on what the account actually saw rather than recomputing from - // this device's (possibly stale) local view. + // A sibling device shipped its "last viewed" baseline on its own + // `Player.sessionSnapshot`; fold it in monotonically so we + // converge on the latest view time across the account rather than + // recomputing from this device's (possibly stale) local view. An + // older or old-format payload simply fails to advance / decode. if let data = seenBaselineData, - let map = try? JSONDecoder().decode( - [String: LocalMovesSnapshot].self, from: data - ), - !map.isEmpty { - sessionMonitor.applyMovesBaseline(map, for: gameID) + let baseline = try? JSONDecoder().decode(SeenBaseline.self, from: data) { + gameViewedStore.advance(baseline.viewedAt, forGame: gameID) } if readAt > now { await self?.badge.dismissDeliveredNotifications( @@ -641,9 +640,11 @@ final class AppServices { await self.accountPush.reconcilePushRegistration() } - await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor, announcements, gameArchiver] gameID in + await syncEngine.setOnGameAccessRevoked { [store, gameViewedStore, announcements, gameArchiver] gameID in store.markAccessRevoked(gameID: gameID) - sessionMonitor.clearMovesSnapshots(for: gameID, by: nil) + // Supersede any pending catch-up banner: advancing the view baseline + // to now leaves nothing for the next open to diff against. + gameViewedStore.advance(Date(), forGame: gameID) // Surface the revocation as a sticky, input-blocking banner on // the open puzzle, replacing the former AccessRevokedBanner // overlay. Game-scoped, so it only shows for this puzzle. @@ -655,9 +656,9 @@ final class AppServices { await gameArchiver.promoteRevoked(gameID: gameID) } - await syncEngine.setOnGameRemoved { [weak self, store, sessionMonitor, announcements] gameID in + await syncEngine.setOnGameRemoved { [weak self, store, gameViewedStore, announcements] gameID in let wasOpen = store.handleRemoteRemoval(gameID: gameID) - sessionMonitor.clearMovesSnapshots(for: gameID, by: nil) + gameViewedStore.advance(Date(), forGame: gameID) // The local row is gone, so drop its badge ledger entry: a seen // horizon can't clear it once there's no game left to open. BadgeState.forget(gameID: gameID) diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -33,6 +33,7 @@ final class SessionCoordinator { private let syncEngine: SyncEngine private let syncMonitor: SyncMonitor private let sessionMonitor: SessionMonitor + private let gameViewedStore: GameViewedStore private let announcements: AnnouncementCenter private let identity: AuthorIdentity private let preferences: PlayerPreferences @@ -55,6 +56,7 @@ final class SessionCoordinator { syncEngine: SyncEngine, syncMonitor: SyncMonitor, sessionMonitor: SessionMonitor, + gameViewedStore: GameViewedStore, announcements: AnnouncementCenter, identity: AuthorIdentity, preferences: PlayerPreferences, @@ -65,6 +67,7 @@ final class SessionCoordinator { self.syncEngine = syncEngine self.syncMonitor = syncMonitor self.sessionMonitor = sessionMonitor + self.gameViewedStore = gameViewedStore self.announcements = announcements self.identity = identity self.preferences = preferences @@ -522,29 +525,29 @@ final class SessionCoordinator { } /// Called when the user leaves the puzzle (backgrounded or navigated away). - /// Drops a still-pending banner timer and commits the per-peer baseline — - /// the user has now seen what's on screen, so the next open diffs against - /// this state — then ships that baseline to sibling devices on this - /// account's own `Player.sessionSnapshot`, so they adopt it rather than - /// recomputing from their own view. Returns the committed snapshots. - @discardableResult - private func handlePuzzleLeft(gameID: UUID) -> [String: LocalMovesSnapshot] { + /// Drops a still-pending banner timer and advances the local "last viewed" + /// baseline — the user has now seen what's on screen, so the next open diffs + /// against this moment — then ships that baseline to sibling devices on this + /// account's own `Player.sessionSnapshot`, so they converge on the latest + /// view time rather than recomputing from their own view. The advance is + /// monotonic, so it is harmless that `CrossmateApp`'s leave handler also + /// stamps the baseline. + private func handlePuzzleLeft(gameID: UUID) { sessions[gameID]?.cancelPendingSummaryBanner() - let committed = sessionMonitor.commitMovesBaseline(for: gameID) + gameViewedStore.advance(Date(), forGame: gameID) guard let authorID = identity.currentID, !authorID.isEmpty, - !committed.isEmpty, - let data = try? JSONEncoder().encode(committed) - else { return committed } + let viewedAt = gameViewedStore.lastViewed(forGame: gameID), + let data = try? JSONEncoder().encode(SeenBaseline(viewedAt: viewedAt)) + else { return } // Write it onto our own Player record and enqueue the send. This also // rides the leave's read-cursor Player write, but enqueuing directly // guarantees it ships even when that write is a no-op. store.setSessionSnapshot(data, gameID: gameID, authorID: authorID) let syncEngine = self.syncEngine // Leave-path Player write: enqueue durably but don't force a drain that - // would race the suspension budget — siblings adopt the snapshot on the + // would race the suspension budget — siblings adopt the baseline on the // next CKSyncEngine sync. Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "sessionSnapshot", drain: false) } - return committed } /// Computes the receiver-side catch-up summary for `gameID` and, when a peer @@ -553,13 +556,17 @@ final class SessionCoordinator { /// so it is safe to recompute on every foreground. Logs the per-peer counts /// it surfaces so a missing or wrong banner is diagnosable after the fact. private func postSessionSummaryBanner(gameID: UUID, reason: String) { - let summaries = sessionMonitor.movesSummaries(for: gameID) + // No baseline means a first-ever open: stay silent rather than flag the + // whole grid, exactly as the border highlights do — the two surfaces + // read this one cutoff so they always agree. + guard let since = gameViewedStore.lastViewed(forGame: gameID) else { return } + let summaries = sessionMonitor.summaries(for: gameID, since: since) guard !summaries.isEmpty else { return } let detail = summaries.map { summary -> String in let who = summary.playerName.isEmpty ? String(summary.authorID.prefix(8)) : summary.playerName - return "\(who) +\(summary.added)/-\(summary.cleared)\(summary.isFirstObservation ? " first" : "")" + return "\(who) +\(summary.added)/-\(summary.cleared)" }.joined(separator: ", ") syncMonitor.note( "session summary[\(gameID.uuidString.prefix(8))] \(reason): \(detail)" @@ -600,10 +607,6 @@ final class SessionCoordinator { guard !summaries.isEmpty else { return "" } let phrases: [String] = summaries.map { summary in let name = summary.playerName.isEmpty ? "A player" : summary.playerName - if summary.isFirstObservation { - let count = summary.added - return "\(name) added \(count) \(count == 1 ? "letter" : "letters") while you were away" - } var parts: [String] = [] if summary.added > 0 { parts.append("added \(summary.added) \(summary.added == 1 ? "letter" : "letters")") diff --git a/Crossmate/Sync/RecentChanges.swift b/Crossmate/Sync/RecentChanges.swift @@ -20,31 +20,69 @@ import Foundation /// winning move's `writerAuthorID` (whoever did the clearing), which is what /// `GridStateMerger.mergeWithProvenance` surfaces. 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. + struct Changes: Equatable { + /// Changed positions mapped to the author who wrote the current letter. + var cells: [GridPosition: String] + /// Per-author net counts: `added` for cells whose current letter is + /// non-empty, `cleared` for cells emptied since the cutoff. + var counts: [String: Count] + + static let empty = Changes(cells: [:], counts: [:]) + } + + struct Count: Equatable { + var added: Int + 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 - /// (`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. - static func changedCells( + /// 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. + static func changes( in moves: [MovesValue], since: Date, excludingAuthor excluded: String - ) -> [GridPosition: 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 result: [GridPosition: String] = [:] + 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 ?? "" - guard priorLetter != provenance.cell.letter else { continue } + 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 } - result[position] = writer + cells[position] = writer + var count = counts[writer] ?? Count(added: 0, cleared: 0) + if currentLetter.isEmpty { + count.cleared += 1 + } else { + count.added += 1 + } + counts[writer] = count } - return result + return Changes(cells: cells, counts: counts) + } + + /// The cell→author map alone, for the border highlights. + static func changedCells( + in moves: [MovesValue], + since: Date, + excludingAuthor excluded: String + ) -> [GridPosition: String] { + changes(in: moves, since: since, excludingAuthor: excluded).cells } } diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -529,11 +529,13 @@ enum RecordSerializer { } /// Reads `sessionSnapshot` off an inbound Player record — the encoded - /// `[peerAuthorID: LocalMovesSnapshot]` map of what this account has seen of - /// each peer, written on leave. Shared across the author's own devices so a - /// sibling adopts the catch-up baseline rather than recomputing it from its + /// `SeenBaseline` (this account's "last viewed" cutoff), written on leave. + /// Shared across the author's own devices so a sibling converges on the + /// latest view time rather than recomputing the catch-up baseline from its /// own (possibly stale) view. Returns `nil` on older records or when the - /// account has not yet left a game with peers. + /// account has not yet left a game with peers. (Pre-unification builds wrote + /// a `[peerAuthorID: LocalMovesSnapshot]` map here, which simply fails to + /// decode as a `SeenBaseline` and is ignored — a per-device fallback.) static func parsePlayerSessionSnapshot(from record: CKRecord) -> Data? { record["sessionSnapshot"] as? Data } diff --git a/Crossmate/Sync/SessionMonitor.swift b/Crossmate/Sync/SessionMonitor.swift @@ -1,23 +1,11 @@ import Foundation -/// Receiver-side computation of the catch-up announcement banner that fires -/// when the user opens a game ("Alice added 12 letters; Bob added 5"). For -/// each peer who has a `MovesEntity` for the game, diffs their current Moves -/// snapshot against the baseline persisted on the matching `PlayerEntity` -/// (the `LocalMovesSnapshot` captured by the last `commitMovesBaseline`, which -/// runs when the user leaves the puzzle). -/// -/// Reading the diff (`movesSummaries`) and advancing the baseline -/// (`commitMovesBaseline`) are deliberately separate: the banner is computed on -/// open without consuming, so a half-synced grid or a repeated foreground just -/// re-reads the same growing delta; the baseline only moves forward on leave, -/// once the user has actually seen what's on screen. -/// -/// Shares the same snapshot/diff algorithm the sender-side pause push uses -/// against its own MovesEntity row, so the numbers agree on both ends. First -/// observation of a peer per `(gameID, authorID)` reports the peer's whole -/// contribution as away context, so a fresh install or new collaborator gets -/// "added N while you were away" rather than a giant bare count. +/// Receiver-side data for the catch-up banner that fires when the user opens a +/// game ("Alice added 12 letters; Bob cleared 5"). A thin wrapper over +/// `GameStore.recentChanges`: it reduces the per-author counts that the border +/// highlights are also drawn from, and hydrates each peer's display name. Both +/// surfaces read the one `viewedAt` cutoff (`GameViewedStore`), so the banner +/// and the borders can never describe different change sets. @MainActor final class SessionMonitor { private let store: GameStore @@ -30,157 +18,34 @@ final class SessionMonitor { /// One author's worth of unseen activity, surfaced to the puzzle's /// announcement banner when the user opens a game. Hydrated with the - /// player and puzzle names so the caller can format a body string - /// without a second Core Data round-trip. - /// - /// `isFirstObservation` flips the formatter from regular delta wording - /// ("Alice added 5 letters") to away wording ("Alice added 50 letters - /// while you were away"). Set on the first ever observation of `(gameID, authorID)` — - /// e.g. just-joined shared game or fresh install — when there is no - /// "since you last looked" period to describe and `added` is the peer's - /// current total filled cells, not a per-session delta. + /// player name so the caller can format a body string without a second + /// Core Data round-trip. struct SessionSummary: Equatable, Sendable { - let gameID: UUID let authorID: String let playerName: String - let puzzleTitle: String let added: Int let cleared: Int - let isFirstObservation: Bool - - init( - gameID: UUID, - authorID: String, - playerName: String, - puzzleTitle: String, - added: Int, - cleared: Int, - isFirstObservation: Bool = false - ) { - self.gameID = gameID - self.authorID = authorID - self.playerName = playerName - self.puzzleTitle = puzzleTitle - self.added = added - self.cleared = cleared - self.isFirstObservation = isFirstObservation - } } - /// Walks every peer with a MovesEntity for `gameID`, diffs each against the - /// per-peer baseline, and returns the non-zero deltas as `SessionSummary` - /// records. Read-only: it does not advance the baseline, so it is safe to - /// call repeatedly while a puzzle is open (every foreground) and against a - /// half-synced grid — the diff just grows as peer moves arrive. The baseline - /// moves forward only in `commitMovesBaseline`, on leave. - func movesSummaries(for gameID: UUID) -> [SessionSummary] { - let localAuthorID = localAuthorIDProvider() - let peers = store.peerAuthorIDs(for: gameID, excluding: localAuthorID) - guard !peers.isEmpty else { return [] } - let puzzleTitle = store.puzzleTitleForNotification(for: gameID) - var summaries: [SessionSummary] = [] - for authorID in peers { - let current = store.movesSnapshot(for: gameID, by: authorID, on: nil) - let baseline = store.lastMovesSnapshot(for: gameID, by: authorID) - let added: Int - let cleared: Int - let isFirstObservation: Bool - if let baseline { - added = current.filled.subtracting(baseline.filled).count - cleared = current.cleared.subtracting(baseline.cleared).count - isFirstObservation = false - } else { - // First observation of this peer — there's no "since you - // last looked" period to describe. Surface the peer's - // current contribution as away context ("Alice added 50 - // letters while you were away") so a freshly joined user knows - // there's progress already in the grid. - added = current.filled.count - cleared = 0 - isFirstObservation = true - } - guard added > 0 || cleared > 0 else { continue } - summaries.append(SessionSummary( - gameID: gameID, + /// Per-peer summaries of what changed since `since` (this device's last-view + /// baseline), ordered deterministically by author so the banner text is + /// stable. Empty when nothing changed — including the first-ever open, where + /// `since` is the caller's baseline and there is simply no prior view to diff + /// against. Read-only; the baseline advances on leave, never here. + func summaries(for gameID: UUID, since: Date) -> [SessionSummary] { + let counts = store.recentChanges(forGame: gameID, since: since).counts + guard !counts.isEmpty else { return [] } + return counts.keys.sorted().map { authorID in + let count = counts[authorID] ?? RecentChanges.Count(added: 0, cleared: 0) + return SessionSummary( authorID: authorID, - // A nickname the user assigned via Rename wins over the - // peer's own published name, matching every other surface. + // A nickname the user assigned via Rename wins over the peer's + // own published name, matching every other surface. playerName: store.friendNickname(for: authorID) ?? store.playerName(for: gameID, by: authorID), - puzzleTitle: puzzleTitle, - added: added, - cleared: cleared, - isFirstObservation: isFirstObservation - )) - } - return summaries - } - - /// Advances every peer's baseline for `gameID` to the merged-moves state - /// currently visible on this device, and returns the snapshots it committed - /// keyed by peer authorID — the caller ships these to sibling devices so - /// they converge without recomputing from their own (possibly stale) view. - /// Called when the user leaves the puzzle: they have now seen everything on - /// screen, so the next `movesSummaries` diffs against "now". - @discardableResult - func commitMovesBaseline(for gameID: UUID) -> [String: LocalMovesSnapshot] { - let localAuthorID = localAuthorIDProvider() - let peers = store.peerAuthorIDs(for: gameID, excluding: localAuthorID) - var committed: [String: LocalMovesSnapshot] = [:] - for authorID in peers { - let current = store.movesSnapshot(for: gameID, by: authorID, on: nil) - store.setLastMovesSnapshot(current, for: gameID, by: authorID) - committed[authorID] = current - } - return committed - } - - /// Adopts the per-peer baseline a sibling device shipped on its own - /// `Player.sessionSnapshot` (decoded from the synced record). Overwrites each - /// peer's baseline with the sibling's snapshot rather than recomputing from - /// this device's local view, so a freshly-woken or partially-synced device - /// converges on what the account actually saw. Latest write wins by the - /// Player record's own last-writer-wins, so the most recent leaver's view is - /// the one adopted. - func applyMovesBaseline(_ snapshots: [String: LocalMovesSnapshot], for gameID: UUID) { - for (authorID, snapshot) in snapshots { - store.setLastMovesSnapshot(snapshot, for: gameID, by: authorID) - } - } - - /// Drops the persisted baseline for `(gameID, authorID)`. Pass - /// `authorID == nil` to clear every author's baseline for the game — - /// used when the user opens the puzzle elsewhere or the Game gains a - /// `completedBy` (the catch-up banner is superseded by a stronger - /// signal), and when the game is access-revoked or removed. - func clearMovesSnapshots(for gameID: UUID, by authorID: String?) { - let authors: [String] - if let authorID { - authors = [authorID] - } else { - authors = store.peerAuthorIDs(for: gameID, excluding: nil) - } - for author in authors { - store.setLastMovesSnapshot(.empty, for: gameID, by: author) - } - } - - static func bodyText( - playerName: String, - puzzleTitle: String, - added: Int, - cleared: Int - ) -> String { - let name = playerName.isEmpty ? "A player" : playerName - let suffix = puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(puzzleTitle)'" - var parts: [String] = [] - if added > 0 { - parts.append("added \(added) \(added == 1 ? "letter" : "letters")") - } - if cleared > 0 { - parts.append("cleared \(cleared) \(cleared == 1 ? "letter" : "letters")") + added: count.added, + cleared: count.cleared + ) } - let action = parts.isEmpty ? "made changes" : parts.joined(separator: " and ") - return "\(name) \(action) in \(suffix)" } } diff --git a/Tests/Unit/GameViewedStoreTests.swift b/Tests/Unit/GameViewedStoreTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("GameViewedStore") +@MainActor +struct GameViewedStoreTests { + + private func makeStore() -> GameViewedStore { + // Fresh UserDefaults suite per test to avoid cross-test pollution. + let defaults = UserDefaults(suiteName: "test-\(UUID().uuidString)")! + return GameViewedStore(defaults: defaults) + } + + private let gameID = UUID() + private let base = Date(timeIntervalSinceReferenceDate: 10_000) + + @Test("A first open has no baseline") + func noBaselineInitially() { + #expect(makeStore().lastViewed(forGame: gameID) == nil) + } + + @Test("advance sets the baseline, and moves it forward") + func advanceMovesForward() { + let store = makeStore() + store.advance(base, forGame: gameID) + #expect(store.lastViewed(forGame: gameID) == base) + + let later = base.addingTimeInterval(60) + store.advance(later, forGame: gameID) + #expect(store.lastViewed(forGame: gameID) == later) + } + + @Test("advance never regresses the baseline to an older value") + func advanceIsMonotonic() { + let store = makeStore() + store.advance(base, forGame: gameID) + // A laggy leave, or a stale value adopted from a sibling, must not pull + // the baseline back — already-seen changes would re-surface otherwise. + store.advance(base.addingTimeInterval(-300), forGame: gameID) + #expect(store.lastViewed(forGame: gameID) == base) + } + + @Test("advance is scoped per game") + func advanceScopedPerGame() { + let store = makeStore() + let other = UUID() + store.advance(base, forGame: gameID) + #expect(store.lastViewed(forGame: other) == nil) + } + + @Test("A synced SeenBaseline round-trips through JSON") + func seenBaselineRoundTrips() throws { + let encoded = try JSONEncoder().encode(SeenBaseline(viewedAt: base)) + let decoded = try JSONDecoder().decode(SeenBaseline.self, from: encoded) + #expect(decoded.viewedAt == base) + } +} diff --git a/Tests/Unit/RecentChangesTests.swift b/Tests/Unit/RecentChangesTests.swift @@ -166,4 +166,32 @@ struct RecentChangesTests { let changes = RecentChanges.changedCells(in: moves, since: cutoff, excludingAuthor: "alice") #expect(changes == [GridPosition(row: 0, col: 0): "bob"]) } + + // 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 changes = RecentChanges.changes(in: moves, 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. + let addedAndCleared = changes.counts.values.reduce(0) { $0 + $1.added + $1.cleared } + #expect(addedAndCleared == changes.cells.count) + } + + @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") + #expect(changes.counts.isEmpty) + #expect(changes.cells.isEmpty) + } } diff --git a/Tests/Unit/Sync/AppServicesAnnouncementTests.swift b/Tests/Unit/Sync/AppServicesAnnouncementTests.swift @@ -7,24 +7,17 @@ import Testing @Suite("SessionCoordinator.formatSummaryBanner") struct AppServicesAnnouncementTests { - private let gameID = UUID() - private func summary( author: String, playerName: String, added: Int = 0, - cleared: Int = 0, - puzzleTitle: String = "Tuesday Mini", - isFirstObservation: Bool = false + cleared: Int = 0 ) -> SessionMonitor.SessionSummary { SessionMonitor.SessionSummary( - gameID: gameID, authorID: author, playerName: playerName, - puzzleTitle: puzzleTitle, added: added, - cleared: cleared, - isFirstObservation: isFirstObservation + cleared: cleared ) } @@ -53,12 +46,12 @@ struct AppServicesAnnouncementTests { #expect(body == "A player added 3 letters.") } - @Test("First-observation summary uses away wording") - func firstObservation() { + @Test("A summary with only clears reads as a clear phrase") + func clearsOnly() { let body = SessionCoordinator.formatSummaryBanner([ - summary(author: "a", playerName: "Alice", added: 4, isFirstObservation: true), + summary(author: "a", playerName: "Alice", cleared: 2), ]) - #expect(body == "Alice added 4 letters while you were away.") + #expect(body == "Alice cleared 2 letters.") } } diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -120,23 +120,29 @@ struct SessionMonitorTests { GridPosition(row: row, col: col) } - // MARK: - First observation + /// The view baseline cutoff, with edits placed before/after it. + 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) } - @Test("First observation of a peer surfaces a cumulative summary with isFirstObservation set") - func firstObservationCumulative() throws { + // MARK: - Counts since the view baseline + + @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( in: fixture, authorID: Self.alice, cells: [ - position(0, 0): ("A", Date()), - position(0, 1): ("B", Date()), - position(2, 2): ("H", Date()), + position(0, 0): ("A", after), + position(0, 1): ("B", after), + position(2, 2): ("H", after), ] ) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) + let summaries = fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff) #expect(summaries.count == 1) let summary = try #require(summaries.first) @@ -144,7 +150,6 @@ struct SessionMonitorTests { #expect(summary.playerName == "Alice") #expect(summary.added == 3) #expect(summary.cleared == 0) - #expect(summary.isFirstObservation) } @Test("A friend nickname overrides the peer's published name in summaries") @@ -164,396 +169,138 @@ struct SessionMonitorTests { try writeMoves( in: fixture, authorID: Self.alice, - cells: [position(0, 0): ("A", Date())] + cells: [position(0, 0): ("A", after)] ) - let summary = try #require(fixture.monitor.movesSummaries(for: fixture.gameID).first) + let summary = try #require( + fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first + ) #expect(summary.playerName == "Mum") } - @Test("First observation skips a peer whose filled count is zero") - func firstObservationSkipsEmptyPeer() throws { + @Test("A clear (letter→empty) since the cutoff counts toward the cleared total") + func clearsCounted() throws { let fixture = try makeFixture() try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") - // Alice has only an explicit-clear entry — no visible letters. - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [position(0, 0): ("", Date())] - ) - - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - #expect(summaries.isEmpty) - } - - @Test("First observation still seeds the baseline so the next call diffs against it") - func firstObservationSeedsBaseline() throws { - let fixture = try makeFixture() - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [position(0, 0): ("A", Date())] - ) - fixture.monitor.commitMovesBaseline(for: fixture.gameID) - - // Alice adds another letter, then the user opens the puzzle again. + // Alice filled two cells before the cutoff, then clears one after it. try writeMoves( in: fixture, authorID: Self.alice, cells: [ - position(0, 0): ("A", Date()), - position(0, 1): ("B", Date()), + position(0, 0): ("A", before), + position(0, 1): ("B", before), ] ) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - - let summary = try #require(summaries.first) - #expect(!summary.isFirstObservation) - #expect(summary.added == 1) - #expect(summary.cleared == 0) - } - - // MARK: - Delta observation - - @Test("After baseline is established, consume returns the per-cell delta") - func deltaAfterBaseline() throws { - let fixture = try makeFixture() - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [position(0, 0): ("A", Date())] - ) - fixture.monitor.commitMovesBaseline(for: fixture.gameID) - try writeMoves( in: fixture, authorID: Self.alice, cells: [ - position(0, 0): ("A", Date()), - position(2, 0): ("F", Date()), - position(2, 2): ("H", Date()), + position(0, 0): ("A", after), + position(0, 1): ("", after), ] ) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - let summary = try #require(summaries.first) - #expect(summary.added == 2) - #expect(summary.cleared == 0) - #expect(!summary.isFirstObservation) - } - - @Test("A clear (letter→empty) since the baseline counts toward the cleared total") - func clearsCountInDelta() throws { - let fixture = try makeFixture() - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [ - position(0, 0): ("A", Date()), - position(0, 1): ("B", Date()), - ] + let summary = try #require( + fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first ) - fixture.monitor.commitMovesBaseline(for: fixture.gameID) - - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [ - position(0, 0): ("A", Date()), - position(0, 1): ("", Date()), - ] - ) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - let summary = try #require(summaries.first) #expect(summary.added == 0) #expect(summary.cleared == 1) } - @Test("No activity since the baseline returns no summary") + @Test("Nothing newer than the cutoff yields no summary") func noActivityReturnsNothing() throws { let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") try writeMoves( in: fixture, authorID: Self.alice, - cells: [position(0, 0): ("A", Date())] + cells: [position(0, 0): ("A", before)] ) - fixture.monitor.commitMovesBaseline(for: fixture.gameID) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - #expect(summaries.isEmpty) + #expect(fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).isEmpty) } - // MARK: - Read / commit separation - - @Test("movesSummaries is read-only; commitMovesBaseline advances the baseline") - func summariesReadOnlyCommitAdvances() throws { - let fixture = try makeFixture() - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [ - position(0, 0): ("A", Date()), - position(0, 1): ("B", Date()), - ] - ) - - // Reading twice without committing returns the same delta — the - // baseline has not moved, so a repeated foreground is idempotent. - #expect(fixture.monitor.movesSummaries(for: fixture.gameID).first?.added == 2) - #expect(fixture.monitor.movesSummaries(for: fixture.gameID).first?.added == 2) - - // Committing advances the baseline to current; the next read is empty. - let committed = fixture.monitor.commitMovesBaseline(for: fixture.gameID) - #expect(committed[Self.alice]?.filled.count == 2) - #expect(fixture.monitor.movesSummaries(for: fixture.gameID).isEmpty) - } - - @Test("applyMovesBaseline adopts a sibling's shipped snapshot") - func applyAdoptsSiblingBaseline() throws { + @Test("summaries is read-only: repeated reads against the same cutoff are stable") + func readsAreStable() throws { let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") try writeMoves( in: fixture, authorID: Self.alice, cells: [ - position(0, 0): ("A", Date()), - position(0, 1): ("B", Date()), + position(0, 0): ("A", after), + position(0, 1): ("B", after), ] ) - // A sibling committed its baseline at Alice's current two-cell state and - // shipped it. Adopting that snapshot suppresses the banner here without - // this device recomputing from its own view. - let shipped: [String: LocalMovesSnapshot] = [ - Self.alice: LocalMovesSnapshot( - filled: [position(0, 0), position(0, 1)], - cleared: [] - ) - ] - fixture.monitor.applyMovesBaseline(shipped, for: fixture.gameID) - #expect(fixture.monitor.movesSummaries(for: fixture.gameID).isEmpty) + #expect(fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first?.added == 2) + #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") func localAuthorExcluded() throws { let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") try writeMoves( in: fixture, authorID: Self.localAuthorID, - cells: [position(0, 0): ("A", Date())] + cells: [position(0, 0): ("A", after)] ) try writeMoves( in: fixture, authorID: Self.alice, - cells: [position(0, 1): ("B", Date())] + cells: [position(0, 1): ("B", after)] ) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) + let summaries = fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff) #expect(summaries.count == 1) #expect(summaries.first?.authorID == Self.alice) } - @Test("Multiple peers each appear as their own summary") + @Test("Multiple peers each appear as their own summary, ordered by author") func multiplePeers() throws { let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") + try addPlayer(in: fixture, authorID: Self.bob, name: "Bob") try writeMoves( in: fixture, authorID: Self.alice, - cells: [position(0, 0): ("A", Date())] + cells: [position(0, 0): ("A", after)] ) try writeMoves( in: fixture, authorID: Self.bob, cells: [ - position(0, 1): ("B", Date()), - position(0, 2): ("C", Date()), + position(0, 1): ("B", after), + position(0, 2): ("C", after), ] ) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - #expect(summaries.count == 2) - let alice = try #require(summaries.first { $0.authorID == Self.alice }) - let bob = try #require(summaries.first { $0.authorID == Self.bob }) - #expect(alice.added == 1) - #expect(bob.added == 2) - #expect(alice.isFirstObservation) - #expect(bob.isFirstObservation) + let summaries = fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff) + #expect(summaries.map(\.authorID) == [Self.alice, Self.bob]) + #expect(summaries[0].added == 1) + #expect(summaries[1].added == 2) } - @Test("Per-author snapshot merges multiple devices' Moves rows") + @Test("A peer's edits across two devices roll into one summary") func peerMultipleDevicesMerged() throws { let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") try writeMoves( in: fixture, authorID: Self.alice, deviceID: "ipad", - cells: [position(0, 0): ("A", Date(timeIntervalSince1970: 100))] + cells: [position(0, 0): ("A", after)] ) try writeMoves( in: fixture, authorID: Self.alice, deviceID: "iphone", - cells: [position(0, 1): ("B", Date(timeIntervalSince1970: 200))] - ) - - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - let summary = try #require(summaries.first) - // Both devices' filled cells roll into Alice's single across-devices - // total — the receiver banner should not split iPad/iPhone work. - #expect(summary.added == 2) - #expect(summary.isFirstObservation) - } - - // MARK: - clearMovesSnapshots - - @Test("clearMovesSnapshots with nil authorID resets every peer for the game") - func clearAllResets() throws { - let fixture = try makeFixture() - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [position(0, 0): ("A", Date())] + cells: [position(0, 1): ("B", later)] ) - try writeMoves( - in: fixture, - authorID: Self.bob, - cells: [position(0, 1): ("B", Date())] - ) - fixture.monitor.commitMovesBaseline(for: fixture.gameID) - - fixture.monitor.clearMovesSnapshots(for: fixture.gameID, by: nil) - - // Both peers' baselines are wiped, so the next consume sees no - // activity (the seeded baseline equals the current snapshot). - // Reissuing means the diff against an emptied baseline registers - // every still-filled cell as a re-add. Match against current state. - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [ - position(0, 0): ("A", Date()), - position(2, 2): ("H", Date()), - ] - ) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - let alice = try #require(summaries.first { $0.authorID == Self.alice }) - // Alice now has 2 filled cells, baseline was reset to .empty, so the - // diff is 2 against an empty baseline — Alice's net "added since - // baseline" is 2. - #expect(alice.added == 2) - #expect(alice.cleared == 0) - #expect(!alice.isFirstObservation) - } - - @Test("clearMovesSnapshots with a specific authorID leaves other peers' baselines untouched") - func clearSingleAuthorScoped() throws { - let fixture = try makeFixture() - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [position(0, 0): ("A", Date())] - ) - try writeMoves( - in: fixture, - authorID: Self.bob, - cells: [position(0, 1): ("B", Date())] - ) - fixture.monitor.commitMovesBaseline(for: fixture.gameID) - - fixture.monitor.clearMovesSnapshots(for: fixture.gameID, by: Self.alice) - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [ - position(0, 0): ("A", Date()), - position(2, 0): ("F", Date()), - ] + let summary = try #require( + fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first ) - try writeMoves( - in: fixture, - authorID: Self.bob, - cells: [ - position(0, 1): ("B", Date()), - position(2, 2): ("H", Date()), - ] - ) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - let alice = try #require(summaries.first { $0.authorID == Self.alice }) - let bob = try #require(summaries.first { $0.authorID == Self.bob }) - // Alice's baseline was reset, so her current 2 filled cells diff - // against empty → 2 added. Bob's baseline survives, so only the new - // cell since his prior consume counts → 1 added. - #expect(alice.added == 2) - #expect(bob.added == 1) - } - - // MARK: - Sibling-readAt baseline adoption - - @Test("Adopting current snapshots suppresses the catch-up banner on the next consume") - func adoptSuppressesNextBanner() throws { - let fixture = try makeFixture() - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [ - position(0, 0): ("A", Date()), - position(0, 1): ("B", Date()), - ] - ) - // Simulate the sibling-device readAt sync: pretend the catch-up - // banner was already shown elsewhere, so the baseline jumps to - // current without consuming. - fixture.monitor.commitMovesBaseline(for: fixture.gameID) - - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - #expect(summaries.isEmpty) - } - - @Test("Peers who write after adoption still surface in the next banner") - func adoptThenLaterEditsStillReport() throws { - let fixture = try makeFixture() - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [position(0, 0): ("A", Date())] - ) - fixture.monitor.commitMovesBaseline(for: fixture.gameID) - - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [ - position(0, 0): ("A", Date()), - position(0, 1): ("B", Date()), - ] - ) - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - let summary = try #require(summaries.first) - #expect(summary.added == 1) - #expect(!summary.isFirstObservation) - } - - @Test("Adoption skips the local author's Moves row") - func adoptSkipsLocalAuthor() throws { - let fixture = try makeFixture() - // Local user has some letters; should not become a baseline target. - try writeMoves( - in: fixture, - authorID: Self.localAuthorID, - cells: [position(0, 0): ("A", Date())] - ) - // And Alice has some too. - try writeMoves( - in: fixture, - authorID: Self.alice, - cells: [position(2, 2): ("H", Date())] - ) - - fixture.monitor.commitMovesBaseline(for: fixture.gameID) - - // Local author's PlayerEntity (if one exists) should not have had - // its lastMovesSnapshot written by adoption; only peers do. The - // observable proxy is that consume still ignores the local author - // and reports nothing about them. - let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) - #expect(summaries.allSatisfy { $0.authorID != Self.localAuthorID }) + // The receiver banner should not split iPad/iPhone work. + #expect(summary.added == 2) } }