crossmate

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

commit ef23fd766891eecb1132631c99c37f45c2e1e02d
parent 2c1772195d543713adbccfa563c6b3a9a57c5fc2
Author: Michael Camilleri <[email protected]>
Date:   Fri, 15 May 2026 16:43:51 +0900

Gate lastSeenOtherMoveAt advance on puzzle visibility

A collaborator's edit to a shared game wasn't producing the per-row library dot
or the app-icon badge after the user had previously opened — then backed out of
— the same puzzle in the current app session. Unfortunately, the visibility
check was `currentEntity?.id == gameID` and so this was causing the
notification to be suppressed. NotificationState.activePuzzleID is the
foreground-visible signal that is appropriate for this use case.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 13+++++++++++++
MTests/Unit/GameStoreUnseenMovesTests.swift | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 72 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -575,6 +575,19 @@ final class GameStore { return try context.fetch(request).first } + /// Marks any unseen other-author moves for `gameID` as seen without + /// loading the game or disturbing the current-game pointer. Driven by + /// `.opened` pings so a sibling device that opened the puzzle is treated + /// as "the user saw the moves," keeping the badge in agreement across + /// the user's devices. + func markOtherMovesSeenWithoutLoading(gameID: UUID) { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + guard let entity = try? context.fetch(request).first else { return } + markOtherMovesSeen(for: entity) + } + private func markOtherMovesSeen(for entity: GameEntity) { let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 guard isShared, let latest = entity.latestOtherMoveAt else { return } diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift @@ -176,6 +176,65 @@ struct GameStoreUnseenMovesTests { #expect(store.unseenOtherMovesGameCount() == 0) } + @Test("Inbound moves while the puzzle is visible advance lastSeenOtherMoveAt") + func inboundMovesWhilePuzzleVisibleMarkSeen() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) + let updatedAt = Date(timeIntervalSinceNow: -10) + try addMovesRow( + for: entity, + gameID: gameID, + authorID: Self.otherAuthorID, + updatedAt: updatedAt, + in: persistence.viewContext + ) + + NotificationState.setActivePuzzleID(gameID) + defer { NotificationState.setActivePuzzleID(nil) } + + store.noteIncomingMovesUpdate( + gameIDs: [gameID], + currentAuthorID: Self.localAuthorID + ) + + #expect(entity.lastSeenOtherMoveAt == updatedAt) + let summary = try #require(GameSummary(entity: entity)) + #expect(!summary.hasUnseenOtherMoves) + } + + @Test("Inbound moves after backing out of a puzzle still mark it unseen") + func inboundMovesAfterBackOutMarkUnseen() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) + // Simulate a prior open: `currentEntity` is set inside the store and + // `lastSeenOtherMoveAt` is up-to-date with no pending moves. The user + // then backs out — `NotificationState.activePuzzleID` clears, but the + // store's `currentEntity` deliberately stays put. + _ = try store.loadGame(id: gameID) + NotificationState.setActivePuzzleID(nil) + + let updatedAt = Date() + try addMovesRow( + for: entity, + gameID: gameID, + authorID: Self.otherAuthorID, + updatedAt: updatedAt, + in: persistence.viewContext + ) + + store.noteIncomingMovesUpdate( + gameIDs: [gameID], + currentAuthorID: Self.localAuthorID + ) + + #expect(entity.latestOtherMoveAt == updatedAt) + #expect(entity.lastSeenOtherMoveAt == nil) + let summary = try #require(GameSummary(entity: entity)) + #expect(summary.hasUnseenOtherMoves) + } + @Test("Completed shared games do not show as unseen even with later other-author moves") func completedSharedGameSuppressesUnseen() throws { let persistence = makeTestPersistence()