crossmate

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

commit 238812f5d75f66507d4d246f80e35385254d32f4
parent 5aa266d712a1616a8ffc4b03a4979f0f182abc76
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 14:08:49 +0900

Accept partial Player records without names

This commit stops applyPlayerRecord from treating a missing name field
as an invalid Player record. A partial fetch or future desired-key
change can legitimately omit name while still carrying selection,
presence, read cursor, time-log, or push-address updates, and the
previous early return dropped all of that state.

Now the name field is optional on apply. A present non-empty name still
updates the row, while a missing or empty value preserves the existing
resolved name and lets the rest of the record converge normally.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Sync/RecordApplier.swift | 4++--
MTests/Unit/Sync/PlayerRecordPresenceTests.swift | 50++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 52 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -228,7 +228,7 @@ extension SyncEngine { guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else { return } - guard let renderedName = record["name"] as? String else { return } + let renderedName = record["name"] as? String let updatedAt = record["updatedAt"] as? Date ?? record.modificationDate ?? Date() @@ -355,7 +355,7 @@ extension SyncEngine { // An empty `name` is what older builds shipped from the selection publisher // before the fix; treat it as "no information" rather than letting it // clobber a previously-resolved name. - if !renderedName.isEmpty { + if let renderedName, !renderedName.isEmpty { entity.name = renderedName } entity.updatedAt = updatedAt diff --git a/Tests/Unit/Sync/PlayerRecordPresenceTests.swift b/Tests/Unit/Sync/PlayerRecordPresenceTests.swift @@ -229,6 +229,56 @@ struct PlayerRecordPresenceTests { #expect(row.updatedAt == Date(timeIntervalSince1970: 20)) } + @Test("Partial player record without name still applies other fields") + func partialPlayerRecordWithoutNameStillAppliesFields() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let game = try makeGame(in: ctx) + try makeExistingRemotePlayer( + in: ctx, + game: game, + updatedAt: Date(timeIntervalSince1970: 10), + selection: nil + ) + let engine = makeEngine(persistence: persistence) + + let recordName = RecordSerializer.recordName( + forPlayerInGame: gameID, + authorID: remoteAuthorID + ) + let record = CKRecord( + recordType: "Player", + recordID: CKRecord.ID(recordName: recordName, zoneID: zoneID) + ) + record["authorID"] = remoteAuthorID as CKRecordValue + record["updatedAt"] = Date(timeIntervalSince1970: 20) as CKRecordValue + record["selRow"] = Int64(3) as CKRecordValue + record["selCol"] = Int64(4) as CKRecordValue + record["selDir"] = Int64(Puzzle.Direction.down.rawValue) as CKRecordValue + + var firstTimeGameIDs: [UUID] = [] + var presenceGameIDs: [UUID] = [] + engine.applyPlayerRecord( + record, + in: ctx, + localAuthorID: localAuthorID, + onFirstTime: { firstTimeGameIDs.append($0) }, + onPresenceChange: { presenceGameIDs.append($0) }, + onReadCursor: { _, _, _ in } + ) + + #expect(firstTimeGameIDs.isEmpty) + #expect(presenceGameIDs == [gameID]) + + let fetched = try fetchRemotePlayer(in: ctx) + let row = try #require(fetched) + #expect(row.name == "Remote") + #expect(row.updatedAt == Date(timeIntervalSince1970: 20)) + #expect(row.selRow?.intValue == 3) + #expect(row.selCol?.intValue == 4) + #expect(row.selDir?.intValue == Puzzle.Direction.down.rawValue) + } + private func fetchLocalPlayer(in ctx: NSManagedObjectContext) throws -> PlayerEntity? { let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") req.predicate = NSPredicate(