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:
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")