crossmate

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

commit d69b0819f9493a101f48f4a68fb0554f3950e836
parent c264d3629c39a1849cee361830a9dd75ce97f562
Author: Michael Camilleri <[email protected]>
Date:   Sun, 26 Apr 2026 14:03:27 +0900

Add missing tests and fix colour cleanup on game delete

AppServices.onGameDeleted was enqueuing CloudKit record deletions without
clearing the colour store for the deleted game, leaving per-author colour
assignments in UserDefaults indefinitely. This commit extracts the
GamePlayerColorStore initialisation to a local variable so the closure can
capture it and call clearColors(forGame:) before the enqueue.

New test coverage added across four areas identified in code review:
PlayerRosterTests (colour collision across three participants, stale entry GC,
name resolution from PlayerEntity and fallback), NameBroadcasterTests
(broadcast writes to shared games, skips non-shared games, debounce coalesces
rapid name changes), GamePlayerColorStoreTests (colour store is cleared via
onGameDeleted), and RecordSerializerTests (applyGameRecord creates an entity
from a record name, and preserves id/createdAt while updating mutable fields on
a second apply).

PersistenceController now uses NSInMemoryStoreType instead of a /dev/null
SQLite URL for in-memory stores, and loads the managed object model once into a
shared static property. The previous approach caused a concurrency race between
test suites: concurrent /dev/null stores shared SQLite WAL state, and parallel
NSPersistentContainer initialisations could produce distinct
NSManagedObjectModel instances, triggering a
_PFManagedObject_coerceValueForKeyWithDescription abort at runtime.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/Persistence/PersistenceController.swift | 25++++++++++++++++++++++---
MCrossmate/Services/AppServices.swift | 9+++++++--
MCrossmate/Sync/AuthorIdentity.swift | 5+++++
MTests/Unit/GamePlayerColorStoreTests.swift | 35+++++++++++++++++++++++++++++++++++
ATests/Unit/NameBroadcasterTests.swift | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/PlayerRosterTests.swift | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/RecordSerializerTests.swift | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 465 insertions(+), 5 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; }; 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; }; + 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; }; 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */; }; 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; }; @@ -29,6 +30,7 @@ 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; + 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */; }; 7E54EC2E507C3BFD615FD621 /* MoveLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7422F19AA1F1692A98E3602 /* MoveLog.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; @@ -89,6 +91,7 @@ 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; + 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreSnapshotPruningTests.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; @@ -123,6 +126,7 @@ 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; + 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcasterTests.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMonitor.swift; sourceTree = "<group>"; }; ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; @@ -197,7 +201,9 @@ 1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */, BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */, 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */, + 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, + 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, ); @@ -433,7 +439,9 @@ 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */, 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, + 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, + 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */, ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift @@ -13,12 +13,17 @@ final class PersistenceController { var viewContext: NSManagedObjectContext { container.viewContext } init(inMemory: Bool = false) { - container = NSPersistentContainer(name: "CrossmateModel") + container = NSPersistentContainer( + name: "CrossmateModel", + managedObjectModel: Self.sharedModel + ) if inMemory { - // Anonymous, throwaway store — useful for previews and tests. + // NSInMemoryStoreType keeps each store fully isolated in process + // memory with no file involvement, which prevents concurrent test + // runs from colliding through shared SQLite WAL files at /dev/null. let description = NSPersistentStoreDescription() - description.url = URL(fileURLWithPath: "/dev/null") + description.type = NSInMemoryStoreType container.persistentStoreDescriptions = [description] } @@ -33,4 +38,18 @@ final class PersistenceController { container.viewContext.automaticallyMergesChangesFromParent = true } + + // Loaded once and shared across all container instances so that entity + // descriptions are identical objects, which is required for CoreData + // relationship type-checking to pass when tests create multiple containers + // concurrently. + private static let sharedModel: NSManagedObjectModel = { + for bundle in Bundle.allBundles + Bundle.allFrameworks { + if let url = bundle.url(forResource: "CrossmateModel", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: url) { + return model + } + } + fatalError("CrossmateModel.momd not found in any loaded bundle") + }() } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -60,10 +60,15 @@ final class AppServices { store.onGameUpdated = { [syncEngine] ckRecordName in Task { await syncEngine.enqueueGame(ckRecordName: ckRecordName) } } - store.onGameDeleted = { [syncEngine] ckRecordNames in + let colorStore = GamePlayerColorStore() + store.onGameDeleted = { [syncEngine, colorStore] ckRecordNames in + if let gameName = ckRecordNames.first(where: { $0.hasPrefix("game-") }), + let gameID = UUID(uuidString: String(gameName.dropFirst("game-".count))) { + colorStore.clearColors(forGame: gameID) + } Task { await syncEngine.enqueueDeleteRecords(ckRecordNames) } } - self.colorStore = GamePlayerColorStore() + self.colorStore = colorStore } func start(appDelegate: AppDelegate, preferences: PlayerPreferences) async { diff --git a/Crossmate/Sync/AuthorIdentity.swift b/Crossmate/Sync/AuthorIdentity.swift @@ -13,6 +13,11 @@ final class AuthorIdentity: Sendable { storage = OSAllocatedUnfairLock(initialState: nil) } + /// Injects a known ID without hitting CloudKit — use in unit tests only. + init(testing fixedID: String) { + storage = OSAllocatedUnfairLock(initialState: fixedID) + } + var currentID: String? { storage.withLock { $0 } } diff --git a/Tests/Unit/GamePlayerColorStoreTests.swift b/Tests/Unit/GamePlayerColorStoreTests.swift @@ -1,3 +1,4 @@ +import CoreData import Foundation import Testing @@ -145,6 +146,40 @@ struct GamePlayerColorStoreTests { #expect(ids == ["blue"]) #expect(!ids.contains("red")) } + + // MARK: - Color cleanup on game deletion + + @Test("Color store is cleared when a game is deleted via onGameDeleted") + func colorStoreCleanupOnGameDelete() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Test" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + try ctx.save() + + let store = GameStore(persistence: persistence) + let colorStore = makeStore() + colorStore.setColor(.red, forGame: gameID, authorID: "_A") + colorStore.setColor(.blue, forGame: gameID, authorID: "_B") + + // Wire cleanup the same way AppServices does after the fix. + store.onGameDeleted = { [colorStore] ckRecordNames in + if let gameName = ckRecordNames.first(where: { $0.hasPrefix("game-") }), + let id = UUID(uuidString: String(gameName.dropFirst("game-".count))) { + colorStore.clearColors(forGame: id) + } + } + + try store.deleteGame(id: gameID) + + #expect(colorStore.storedAuthorIDs(forGame: gameID).isEmpty) + } } // Minimal seeded RNG for deterministic tests. diff --git a/Tests/Unit/NameBroadcasterTests.swift b/Tests/Unit/NameBroadcasterTests.swift @@ -0,0 +1,148 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("NameBroadcaster", .serialized) +@MainActor +struct NameBroadcasterTests { + + // MARK: - Helpers + + /// Creates a persistence store with one game that qualifies for fan-out + /// (it has a `ckShareRecordName`, so `upsertPlayerRecords` will visit it). + private func makeSharedGame() throws -> (PersistenceController, UUID) { + let p = makeTestPersistence() + let ctx = p.viewContext + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Shared Test" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) + entity.ckShareRecordName = "share-\(UUID().uuidString)" + try ctx.save() + return (p, gameID) + } + + private func makeNonSharedGame() throws -> (PersistenceController, UUID) { + let p = makeTestPersistence() + let ctx = p.viewContext + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Solo Test" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) + // No ckShareRecordName and databaseScope == 0 → not picked up by fan-out. + try ctx.save() + return (p, gameID) + } + + private func fetchPlayerName(authorID: String, in persistence: PersistenceController) -> String? { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + req.predicate = NSPredicate(format: "authorID == %@", authorID) + req.fetchLimit = 1 + return (try? ctx.fetch(req).first)?.name + } + } + + private func makeBroadcaster( + preferences: PlayerPreferences, + persistence: PersistenceController, + authorID: String + ) -> NameBroadcaster { + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let syncEngine = SyncEngine(container: container, persistence: persistence) + return NameBroadcaster( + preferences: preferences, + persistence: persistence, + authorIdentity: AuthorIdentity(testing: authorID), + syncEngine: syncEngine + ) + } + + // MARK: - Tests + + @Test("broadcastName writes a PlayerEntity for shared/joined games") + func broadcastNameWritesPlayerEntityForSharedGame() async throws { + let (persistence, _) = try makeSharedGame() + let prefs = PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + prefs.name = "Alice" + let broadcaster = makeBroadcaster( + preferences: prefs, + persistence: persistence, + authorID: "_local" + ) + + await broadcaster.broadcastName() + + #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Alice") + } + + @Test("broadcastName is a no-op for non-shared games") + func broadcastNameSkipsNonSharedGames() async throws { + let (persistence, _) = try makeNonSharedGame() + let prefs = PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + prefs.name = "Alice" + let broadcaster = makeBroadcaster( + preferences: prefs, + persistence: persistence, + authorID: "_local" + ) + + await broadcaster.broadcastName() + + #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil) + } + + @Test("Debounce coalesces two rapid name changes into one fan-out with the final name") + func debounceCoalescesPair() async throws { + let (persistence, _) = try makeSharedGame() + let prefs = PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + let broadcaster = makeBroadcaster( + preferences: prefs, + persistence: persistence, + authorID: "_local" + ) + + // Allow the observation task to start and register its first + // withObservationTracking call before we change any values. + await Task.yield() + + // First name change — debounce timer #1 starts (250 ms). + prefs.name = "Alice" + await Task.yield() // let observation task call scheduleDebounce + + // Second change before timer fires — cancels #1, starts timer #2. + prefs.name = "Bob" + await Task.yield() // let observation task cancel #1 and start #2 + + // At 200 ms (< 250 ms debounce), nothing should have been written yet. + try await Task.sleep(for: .milliseconds(200)) + #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil, + "debounce should prevent any write before the timer fires") + + // At 200 + 300 = 500 ms, timer #2 must have fired. + try await Task.sleep(for: .milliseconds(300)) + #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Bob", + "only the final name should be written") + + // Keep broadcaster alive until assertions are done. + withExtendedLifetime(broadcaster) {} + } +} diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift @@ -0,0 +1,173 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("PlayerRoster") +@MainActor +struct PlayerRosterTests { + + // MARK: - Helpers + + private func makePersistenceWithGame() throws -> (PersistenceController, UUID) { + let p = makeTestPersistence() + let ctx = p.viewContext + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Test" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) + // No ckZoneName — prevents the roster from attempting a CloudKit share fetch. + try ctx.save() + return (p, gameID) + } + + private func addMoves( + authorIDs: [String], + gameID: UUID, + persistence: PersistenceController + ) { + let ctx = persistence.viewContext + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + guard let game = try? ctx.fetch(req).first else { return } + for (i, authorID) in authorIDs.enumerated() { + let move = MoveEntity(context: ctx) + move.game = game + move.authorID = authorID + move.lamport = Int64(i + 1) + move.row = 0 + move.col = Int16(i) + move.letter = "" + move.markKind = 0 + move.checkedWrong = false + move.createdAt = Date() + move.ckRecordName = RecordSerializer.recordName( + forMoveInGame: gameID, + lamport: Int64(i + 1) + ) + } + try? ctx.save() + } + + private func addPlayerEntity( + authorID: String, + name: String, + gameID: UUID, + persistence: PersistenceController + ) { + let ctx = persistence.viewContext + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + guard let game = try? ctx.fetch(req).first else { return } + let player = PlayerEntity(context: ctx) + player.game = game + player.authorID = authorID + player.name = name + player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) + player.updatedAt = Date() + try? ctx.save() + } + + private func makeRoster( + gameID: UUID, + persistence: PersistenceController, + colorStore: GamePlayerColorStore? = nil, + preferences: PlayerPreferences? = nil + ) -> PlayerRoster { + let store = colorStore ?? GamePlayerColorStore( + defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")! + ) + let prefs = preferences ?? PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let syncEngine = SyncEngine(container: container, persistence: persistence) + return PlayerRoster( + gameID: gameID, + colorStore: store, + authorIdentity: AuthorIdentity(), + preferences: prefs, + persistence: persistence, + container: container, + syncEngine: syncEngine + ) + } + + // MARK: - Tests + + @Test("Three remote participants are assigned distinct colors, none matching local") + func threeParticipantsGetDistinctColors() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence) + + let colorStore = GamePlayerColorStore( + defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")! + ) + let prefsDefaults = UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + let prefs = PlayerPreferences(local: prefsDefaults) + + let roster = makeRoster( + gameID: gameID, + persistence: persistence, + colorStore: colorStore, + preferences: prefs + ) + await roster.refresh() + + #expect(roster.entries.count == 4) // 1 local + 3 remote + let allColorIDs = roster.entries.map { $0.color.id } + #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colors") + let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id } + #expect(!remoteColorIDs.contains(prefs.color.id), "remote colors should not collide with local") + } + + @Test("Stale color entries are GC'd after refresh") + func staleColorEntriesAreGCd() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) + + let colorStore = GamePlayerColorStore( + defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")! + ) + colorStore.setColor(.red, forGame: gameID, authorID: "_B") + colorStore.setColor(.green, forGame: gameID, authorID: "_Stale") + + let roster = makeRoster(gameID: gameID, persistence: persistence, colorStore: colorStore) + await roster.refresh() + + #expect(colorStore.color(forGame: gameID, authorID: "_B") != nil, "_B should be kept") + #expect(colorStore.color(forGame: gameID, authorID: "_Stale") == nil, "_Stale should be GC'd") + } + + @Test("Entry name comes from PlayerEntity when available") + func entryNameFromPlayerEntity() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) + addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence) + + let roster = makeRoster(gameID: gameID, persistence: persistence) + await roster.refresh() + + let remote = roster.entries.first { !$0.isLocal } + #expect(remote?.name == "Alice") + } + + @Test("Entry name falls back to 'Player' when no PlayerEntity and no share") + func entryNameFallsBackToPlayer() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) + // No PlayerEntity for "_B". + + let roster = makeRoster(gameID: gameID, persistence: persistence) + await roster.refresh() + + let remote = roster.entries.first { !$0.isLocal } + #expect(remote?.name == "Player") + } +} diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -68,6 +68,73 @@ struct RecordSerializerTests { #expect(RecordSerializer.parsePlayerRecordName("player-12345678-1234-1234-1234-123456789ABC") == nil) } + // MARK: - applyGameRecord + + /// Writes `source` to a temp file and returns a `CKAsset` pointing to it. + /// The caller is responsible for removing the file when done. + private func makePuzzleAsset(source: String = "dummy puzzle source") throws -> (CKAsset, URL) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try source.write(to: url, atomically: true, encoding: .utf8) + return (CKAsset(fileURL: url), url) + } + + @Test("applyGameRecord creates entity with id derived from record name") + @MainActor func applyGameRecordCreatesEntity() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let zone = RecordSerializer.zoneID(for: gameID) + let recordName = RecordSerializer.recordName(forGameID: gameID) + let record = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: recordName, zoneID: zone)) + record["title"] = "Test Title" as CKRecordValue + let (asset, tmpURL) = try makePuzzleAsset() + defer { try? FileManager.default.removeItem(at: tmpURL) } + record["puzzleSource"] = asset as CKRecordValue + + let entity = RecordSerializer.applyGameRecord(record, to: ctx) + try ctx.save() + + #expect(entity.id == gameID) + #expect(entity.title == "Test Title") + #expect(entity.ckRecordName == recordName) + } + + @Test("applyGameRecord preserves id and createdAt on second apply, updates title") + @MainActor func applyGameRecordMergesOnServerRecordChanged() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let zone = RecordSerializer.zoneID(for: gameID) + let recordName = RecordSerializer.recordName(forGameID: gameID) + let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) + + let (asset1, tmpURL1) = try makePuzzleAsset(source: "original source") + defer { try? FileManager.default.removeItem(at: tmpURL1) } + + // First apply — creates the entity. + let record1 = CKRecord(recordType: "Game", recordID: recordID) + record1["title"] = "Original" as CKRecordValue + record1["puzzleSource"] = asset1 as CKRecordValue + let entity = RecordSerializer.applyGameRecord(record1, to: ctx) + try ctx.save() + + let frozenID = entity.id + let frozenCreatedAt = entity.createdAt + + // Second apply — simulates a server record change with an updated title. + // puzzleSource is intentionally absent here to verify it isn't wiped. + let record2 = CKRecord(recordType: "Game", recordID: recordID) + record2["title"] = "Updated" as CKRecordValue + let merged = RecordSerializer.applyGameRecord(record2, to: ctx) + try ctx.save() + + #expect(merged === entity) // same managed object + #expect(merged.id == frozenID) // id not overwritten + #expect(merged.createdAt == frozenCreatedAt) // createdAt not overwritten + #expect(merged.title == "Updated") // mutable field updated + } + // MARK: - System fields round-trip @Test("Encode and decode system fields preserves record type and zone")