crossmate

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

commit 5d5aad03c598a6923b6c9b9ea14e7789ab8fd59c
parent 362b43d4f5756e85eda0a7312eb5817c06cf68c0
Author: Michael Camilleri <[email protected]>
Date:   Thu, 11 Jun 2026 08:53:18 +0900

Derive a completed game's thumbnail from the completion latch

The library thumbnail is built from the CellEntity cache, which mirrors
the raw un-watermarked merge of every device's Moves rows. A completed
game's grid display is sealed to the solution against a merge
watermarked at completedAt (sealToSolution), but the cache is not — so
in the completion race, a clear stamped after the latch by a device that
hadn't yet learned of the win beats the winning letter on wall-clock LWW
every time the cache is replayed. The opened puzzle renders solved with
correct authorship while the thumbnail shows a permanent hole at the
winning square.

GameSummary now renders every non-block cell as filled when completedAt
is set, skipping the cell cache entirely. A completed game is terminal
and by invariant renders solved, and the thumbnail encodes only
block/empty/filled — no authorship — so the latch alone determines it.
Because thumbnails are derived at render time, existing divergent games
(including materialized archives, which always carry completedAt) heal
without any data migration. The cache stays a raw-merge mirror, which
the post-completion authorship re-attribution depends on.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Persistence/GameStore.swift | 17+++++++++++++----
ATests/Unit/GameSummaryThumbnailTests.swift | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 116 insertions(+), 4 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -70,6 +70,7 @@ 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800CCFBE90554F287E765755 /* FriendZoneTests.swift */; }; 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; + 7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; }; @@ -344,6 +345,7 @@ DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; }; E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; }; + E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSummaryThumbnailTests.swift; sourceTree = "<group>"; }; E655698481325C92EF5C348B /* FriendController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendController.swift; sourceTree = "<group>"; }; E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; }; E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadTests.swift; sourceTree = "<group>"; }; @@ -422,6 +424,7 @@ 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */, 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */, 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */, + E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */, 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */, 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */, 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */, @@ -781,6 +784,7 @@ 262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */, 2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */, 449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */, + 7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */, AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */, 0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */, 6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */, diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -76,10 +76,19 @@ struct GameSummary: Identifiable, Equatable { blocks = bs } - let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] + // A completed game is terminal and always renders solved (restore + // seals it to the solution), but the CellEntity cache mirrors the raw + // un-watermarked merge, which can permanently lack a winning letter — + // a clear stamped after the completion latch beats it on LWW forever. + // Derive the thumbnail from the latch, not the cache, so a finished + // game's thumbnail is full regardless of merge drift. + let isCompleted = entity.completedAt != nil var filledSet: Set<Int> = [] - for ce in cellEntities where !(ce.letter ?? "").isEmpty { - filledSet.insert(Int(ce.row) * width + Int(ce.col)) + if !isCompleted { + let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] + for ce in cellEntities where !(ce.letter ?? "").isEmpty { + filledSet.insert(Int(ce.row) * width + Int(ce.col)) + } } var thumbCells: [GameThumbnailCell] = [] @@ -89,7 +98,7 @@ struct GameSummary: Identifiable, Equatable { let idx = r * width + c if blocks[idx] { thumbCells.append(.block) - } else if filledSet.contains(idx) { + } else if isCompleted || filledSet.contains(idx) { thumbCells.append(.filled) } else { thumbCells.append(.empty) diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift @@ -0,0 +1,99 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down `GameSummary`'s thumbnail derivation. An in-progress game's +/// thumbnail mirrors the `CellEntity` cache, but a completed game is terminal +/// and always renders solved (restore seals it to the solution), so its +/// thumbnail must come from the completion latch — the raw merge behind the +/// cache can permanently lack the winning letter when a clear stamped after +/// the latch wins LWW. +@Suite("GameSummary thumbnail") +@MainActor +struct GameSummaryThumbnailTests { + + private static let puzzleSource = """ + Title: Test Puzzle + Author: Test + + + ABC + D#E + FGH + + + A1. Across 1 ~ ABC + A4. Across 4 ~ DE + A5. Across 5 ~ FGH + D1. Down 1 ~ ADF + D2. Down 2 ~ BG + D3. Down 3 ~ CEH + """ + + private func makeGame(in ctx: NSManagedObjectContext) throws -> GameEntity { + let xd = try XD.parse(Self.puzzleSource) + let puzzle = Puzzle(xd: xd) + + let entity = GameEntity(context: ctx) + entity.id = UUID() + entity.title = "Thumbnail" + entity.puzzleSource = Self.puzzleSource + entity.createdAt = Date() + entity.updatedAt = Date() + entity.populateCachedSummaryFields(from: puzzle) + return entity + } + + private func addCell( + _ letter: String, + row: Int16, + col: Int16, + to entity: GameEntity, + in ctx: NSManagedObjectContext + ) { + let cell = CellEntity(context: ctx) + cell.game = entity + cell.row = row + cell.col = col + cell.letter = letter + } + + @Test("In-progress thumbnail mirrors the CellEntity cache") + func inProgressThumbnailMirrorsCache() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let entity = try makeGame(in: ctx) + addCell("A", row: 0, col: 0, to: entity, in: ctx) + addCell("", row: 2, col: 2, to: entity, in: ctx) + try ctx.save() + + let summary = try #require(GameSummary(entity: entity)) + #expect(summary.thumbnailCells == [ + .filled, .empty, .empty, + .empty, .block, .empty, + .empty, .empty, .empty, + ]) + } + + @Test("Completed thumbnail is full even when the cache has a hole") + func completedThumbnailIgnoresCacheHoles() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let entity = try makeGame(in: ctx) + // The winning letter never reached the durable merge: its cell holds + // an empty tombstone and another square has no row at all. + addCell("A", row: 0, col: 0, to: entity, in: ctx) + addCell("", row: 2, col: 2, to: entity, in: ctx) + entity.completedAt = Date() + try ctx.save() + + let summary = try #require(GameSummary(entity: entity)) + #expect(summary.thumbnailCells == [ + .filled, .filled, .filled, + .filled, .block, .filled, + .filled, .filled, .filled, + ]) + } +}