crossmate

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

commit 57bfdf38421d7fd20f202f1cb285c8f4b0625eac
parent 8ebbef3cf89ae36f754e5705de62f51eb16bd0ab
Author: Michael Camilleri <[email protected]>
Date:   Sat, 30 May 2026 10:57:47 +0900

Upload move journal at game completion

This commit adds the Phase 2 upload pipeline on top of the local move
journal: when a game completes — a win or a resignation — the device
encodes its whole JournalEntity log and pushes it to CloudKit as a
Journal record carrying the moves as a CKAsset. Merging every device's
upload by timestamp is what a later replay viewer will read; this commit
only puts the data in place.

There is one Journal record per (game, author, device), named
journal-<gameID>-<authorID>-<deviceID> and written into the game's
existing zone, mirroring the Moves record. Like Ping and Decision it is
write-once with no system-fields archive; buildRecord reconstructs the
asset from the durable JournalEntity rows rather than an in-memory
stash, so a pending upload survives an app kill, and
MovesJournal.flush() is awaited before enqueue to beat the journal's
async persistence. Inbound Journal records are deliberately ignored —
the replay viewer will fetch them on demand — so collaborators don't
apply each other's logs.

JournalCodec is the wire format, carrying the mark as the single
lossless markCode (matching JournalEntity) rather than the Moves
two-bool flattening, with a forward-compatible decoder. The completion
lockout of undo and the replay UI are deferred.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Persistence/GameStore.swift | 22++++++++++++++++++++++
MCrossmate/Persistence/Journal.swift | 132++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCrossmate/Services/AppServices.swift | 6++++++
MCrossmate/Sync/RecordBuilder.swift | 22++++++++++++++++++++++
MCrossmate/Sync/RecordSerializer.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 30++++++++++++++++++++++++++++++
ATests/Unit/JournalUploadTests.swift | 271+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 554 insertions(+), 1 deletion(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; 6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */; }; + 6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */; }; 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 */; }; @@ -192,6 +193,7 @@ 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; + 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalUploadTests.swift; sourceTree = "<group>"; }; 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; }; 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; }; 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; }; @@ -365,6 +367,7 @@ 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */, 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */, 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */, + 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */, 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */, 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */, FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */, @@ -699,6 +702,7 @@ 2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */, 449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */, AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */, + 6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */, DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */, F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */, C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */, diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -232,6 +232,12 @@ final class GameStore { /// changes and needs to be re-pushed. private let onGameUpdated: (String) -> Void + /// Called once a game completes (win or resign) with `(gameID, authorID)`, + /// so this device's move journal can be uploaded for later replay (Phase + /// 2). Separate from `onGameUpdated`: that re-pushes the Game record, this + /// pushes the per-device Journal asset. + private let onJournalComplete: (UUID, String) -> Void + /// Fires when the count of shared games with unseen other-author moves /// may have changed (inbound moves merged, a game opened, a game /// deleted). Consumers refresh the app-icon badge from here. @@ -249,6 +255,7 @@ final class GameStore { onGameCreated: @escaping (String) -> Void, onGameUpdated: @escaping (String) -> Void, onGameDeleted: @escaping (GameCloudDeletion) -> Void, + onJournalComplete: @escaping (UUID, String) -> Void = { _, _ in }, eventLog: EventLog? = nil ) { self.persistence = persistence @@ -261,6 +268,7 @@ final class GameStore { self.onGameCreated = onGameCreated self.onGameUpdated = onGameUpdated self.onGameDeleted = onGameDeleted + self.onJournalComplete = onJournalComplete self.eventLog = eventLog } @@ -596,6 +604,7 @@ final class GameStore { onGameUpdated(ckName) } Task { await movesUpdater.flush() } + triggerJournalUpload(id: id) // Clean up current references currentGame = nil @@ -638,9 +647,22 @@ final class GameStore { onGameUpdated(ckName) } Task { await movesUpdater.flush() } + triggerJournalUpload(id: id) return true } + /// Uploads this device's move journal for a just-completed game (Phase 2). + /// Flushes the journal's async persistence first so the record builder, + /// reading Core Data on its own context, sees every entry. Attributed to + /// the local user (not the solver) — a resigner still has a log to upload. + private func triggerJournalUpload(id: UUID) { + guard let authorID = authorIDProvider(), !authorID.isEmpty else { return } + Task { + await movesJournal.flush() + onJournalComplete(id, authorID) + } + } + // MARK: - Engagement room /// The shared live-engagement room creds for `gameID` (an encoded diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift @@ -323,6 +323,16 @@ final class MovesJournal { } } + /// Awaits the background persistence queue so every recorded entry has + /// landed in the store. `record(...)` persists asynchronously and the + /// in-memory list is authoritative for play, but the Phase 2 upload + /// reconstructs the asset from Core Data on a *separate* background + /// context — call this first (e.g. at completion) so that context sees the + /// final entries rather than racing the in-flight saves. + func flush() async { + await backgroundContext.perform { } + } + private func persist(_ value: JournalValue, gameID: UUID) { let ctx = backgroundContext ctx.perform { @@ -355,7 +365,10 @@ final class MovesJournal { } } - private nonisolated static func value(from entity: JournalEntity) -> JournalValue { + /// Maps a persisted row to its in-memory value. `internal` (not `private`) + /// so `RecordBuilder` can reconstruct the upload asset straight from Core + /// Data on its own background context. + nonisolated static func value(from entity: JournalEntity) -> JournalValue { JournalValue( seq: entity.seq, timestamp: entity.timestamp ?? .distantPast, @@ -373,3 +386,120 @@ final class MovesJournal { ) } } + +/// Wire format for a device's journal, encoded once at game completion and +/// uploaded as the `Journal` record's `entries` asset (Phase 2). A faithful +/// dump of `[JournalValue]` in `seq` order — merging every device's decoded +/// dump by `timestamp` reconstructs the whole game for replay. The mark is +/// carried as the single lossless `markCode` (`CellMarkCodec.code`), matching +/// `JournalEntity`, not the `markKind` + two-bool flattening the synced `Moves` +/// format uses. +enum JournalCodec { + struct Payload: Codable, Equatable { + struct Entry: Codable, Equatable { + let seq: Int64 + let timestamp: Date + let row: Int + let col: Int + let letter: String + let markCode: Int16 + let cellAuthorID: String? + let actingAuthorID: String? + let kind: Int16 + let targetSeq: Int64? + let batchID: UUID? + let prevSeqAtCell: Int64? + + init( + seq: Int64, + timestamp: Date, + row: Int, + col: Int, + letter: String, + markCode: Int16, + cellAuthorID: String?, + actingAuthorID: String?, + kind: Int16, + targetSeq: Int64?, + batchID: UUID?, + prevSeqAtCell: Int64? + ) { + self.seq = seq + self.timestamp = timestamp + self.row = row + self.col = col + self.letter = letter + self.markCode = markCode + self.cellAuthorID = cellAuthorID + self.actingAuthorID = actingAuthorID + self.kind = kind + self.targetSeq = targetSeq + self.batchID = batchID + self.prevSeqAtCell = prevSeqAtCell + } + + // Optionals are decoded leniently so a record written by a newer + // client that added fields still decodes cleanly on an older one + // (same forward-compat stance as `MovesCodec.Payload.Entry`). + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + seq = try c.decode(Int64.self, forKey: .seq) + timestamp = try c.decode(Date.self, forKey: .timestamp) + row = try c.decode(Int.self, forKey: .row) + col = try c.decode(Int.self, forKey: .col) + letter = try c.decode(String.self, forKey: .letter) + markCode = try c.decode(Int16.self, forKey: .markCode) + kind = try c.decode(Int16.self, forKey: .kind) + cellAuthorID = try? c.decode(String.self, forKey: .cellAuthorID) + actingAuthorID = try? c.decode(String.self, forKey: .actingAuthorID) + targetSeq = try? c.decode(Int64.self, forKey: .targetSeq) + batchID = try? c.decode(UUID.self, forKey: .batchID) + prevSeqAtCell = try? c.decode(Int64.self, forKey: .prevSeqAtCell) + } + } + let entries: [Entry] + } + + static func encode(_ values: [JournalValue]) throws -> Data { + let entries = values + .sorted { $0.seq < $1.seq } + .map { value in + Payload.Entry( + seq: value.seq, + timestamp: value.timestamp, + row: value.position.row, + col: value.position.col, + letter: value.state.letter, + markCode: CellMarkCodec.code(value.state.mark), + cellAuthorID: value.state.cellAuthorID, + actingAuthorID: value.actingAuthorID, + kind: value.kind.rawValue, + targetSeq: value.targetSeq, + batchID: value.batchID, + prevSeqAtCell: value.prevSeqAtCell + ) + } + return try JSONEncoder().encode(Payload(entries: entries)) + } + + static func decode(_ data: Data) throws -> [JournalValue] { + let payload = try JSONDecoder().decode(Payload.self, from: data) + return payload.entries.map { entry in + JournalValue( + seq: entry.seq, + timestamp: entry.timestamp, + position: GridPosition(row: entry.row, col: entry.col), + state: JournalCellState( + letter: entry.letter, + mark: CellMarkCodec.mark(code: entry.markCode), + cellAuthorID: entry.cellAuthorID + ), + actingAuthorID: entry.actingAuthorID, + kind: JournalKind(rawValue: entry.kind) ?? .input, + targetSeq: entry.targetSeq, + batchID: entry.batchID, + prevSeqAtCell: entry.prevSeqAtCell + ) + } + } +} diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -238,6 +238,12 @@ final class AppServices { guard preferences.isICloudSyncEnabled else { return } onGameDeletedHandler(deletion) }, + onJournalComplete: { [preferences, syncEngine] gameID, authorID in + Task { + guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } + await syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID) + } + }, eventLog: eventLog ) self.store = store diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift @@ -69,6 +69,28 @@ extension SyncEngine { zone: zoneID, systemFields: entity.ckSystemFields ) + } else if name.hasPrefix("journal-") { + // Reconstruct the upload asset from the durable local journal + // (every JournalEntity row for this game belongs to this + // device). Reading Core Data — not an in-memory stash — means a + // pending journal save survives an app kill, like Moves/Player. + guard let (gameID, authorID, deviceID) = + RecordSerializer.parseJournalRecordName(name) + else { return nil } + let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") + req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) + req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] + guard let rows = try? ctx.fetch(req), !rows.isEmpty else { return nil } + let entries = rows.map(MovesJournal.value(from:)) + let updatedAt = entries.map(\.timestamp).max() ?? Date() + return try? RecordSerializer.journalRecord( + gameID: gameID, + authorID: authorID, + deviceID: deviceID, + updatedAt: updatedAt, + entries: entries, + zone: zoneID + ) } else if name.hasPrefix("player-") { let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") req.predicate = NSPredicate(format: "ckRecordName == %@", name) diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -43,6 +43,18 @@ enum RecordSerializer { "moves-\(gameID.uuidString)-\(authorID)-\(deviceID)" } + /// One Journal record per `(game, authorID, deviceID)` — this device's + /// whole local move log, uploaded once at completion (Phase 2). Same + /// `(game, author, device)` shape as the Moves record so collaborators' + /// uploads stay distinct and mergeable by timestamp for replay. + static func recordName( + forJournalInGame gameID: UUID, + authorID: String, + deviceID: String + ) -> String { + "journal-\(gameID.uuidString)-\(authorID)-\(deviceID)" + } + /// One player record per (game, author). Each participant only ever /// writes to their own slot, so there are no write-write conflicts on /// the field. @@ -134,6 +146,40 @@ enum RecordSerializer { return record } + // MARK: - Journal record building + + /// Builds the `Journal` record carrying this device's full move log as a + /// `CKAsset`. Write-once at completion, so there is no system-fields + /// archive (mirrors `Ping`/`Decision`): a fresh record each build, and a + /// re-send of an already-uploaded journal is a benign conflict the send + /// path drops. The encoded entries are written to a temp file the same way + /// `populateGameRecord` stages `puzzleSource` — CloudKit copies the asset + /// on upload and the OS reaps the temporary directory. + static func journalRecord( + gameID: UUID, + authorID: String, + deviceID: String, + updatedAt: Date, + entries: [JournalValue], + zone: CKRecordZone.ID + ) throws -> CKRecord { + let name = recordName(forJournalInGame: gameID, authorID: authorID, deviceID: deviceID) + let recordID = CKRecord.ID(recordName: name, zoneID: zone) + let record = CKRecord(recordType: "Journal", recordID: recordID) + record["authorID"] = authorID as CKRecordValue + record["deviceID"] = deviceID as CKRecordValue + record["updatedAt"] = updatedAt as CKRecordValue + + let data = try JournalCodec.encode(entries) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("json") + try data.write(to: url, options: .atomic) + record["entries"] = CKAsset(fileURL: url) + + return record + } + static func gameRecord( from entity: GameEntity, recordID: CKRecord.ID, @@ -403,6 +449,28 @@ enum RecordSerializer { return (gameID, authorID, deviceID) } + /// Parses `journal-<gameUUID>-<authorID>-<deviceID>` into its three parts. + /// Same decomposition as `parseMovesRecordName` (deviceID is the suffix + /// after the final `-`; authorID may itself contain dashes). + static func parseJournalRecordName(_ name: String) -> (UUID, String, String)? { + let prefix = "journal-" + guard name.hasPrefix(prefix) else { return nil } + let rest = name.dropFirst(prefix.count) + let uuidLength = 36 + guard rest.count > uuidLength, + rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" + else { return nil } + let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) + guard let gameID = UUID(uuidString: uuidPart) else { return nil } + let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1) + let tail = rest[afterUUID...] + guard let lastDash = tail.lastIndex(of: "-") else { return nil } + let authorID = String(tail[tail.startIndex..<lastDash]) + let deviceID = String(tail[tail.index(after: lastDash)...]) + guard !authorID.isEmpty, !deviceID.isEmpty else { return nil } + return (gameID, authorID, deviceID) + } + // MARK: - Applying incoming CKRecords to Core Data /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -903,6 +903,29 @@ actor SyncEngine { sendChangesDetached(on: engine) } + /// Registers this device's move journal as a pending send, into the game's + /// existing zone (no `saveZone` — by completion the zone is long since + /// created). One `Journal` record per (game, author, device); the asset is + /// rebuilt from the durable local `JournalEntity` log in `buildRecord`, so + /// no payload is stashed here. Called once at completion; a re-send is a + /// benign conflict the send path drops. Skipped on an access-revoked game, + /// where any save would just fail with `.zoneNotFound`. + func enqueueJournalUpload(gameID: UUID, authorID: String) { + let ctx = persistence.container.newBackgroundContext() + guard let info = zoneInfo(forGameID: gameID, in: ctx), + !info.isAccessRevoked else { return } + let engine = info.scope == 1 ? sharedEngine : privateEngine + guard let engine else { return } + let recordName = RecordSerializer.recordName( + forJournalInGame: gameID, + authorID: authorID, + deviceID: RecordSerializer.localDeviceID + ) + let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) + engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) + sendChangesDetached(on: engine) + } + // MARK: - Explicit sync triggers (called by AppServices / diagnostics view) func fetchChanges(source: String = "manual") async throws { @@ -1224,6 +1247,12 @@ actor SyncEngine { let gid = UUID(uuidString: dKey) { effects.removed.insert(gid) } + case "Journal": + // Phase 2 upload-only: a collaborator's journal asset is + // not applied to Core Data here. The replay viewer (Phase + // 2b) fetches journals on demand; this case marks that + // omission as deliberate rather than a missing apply path. + break default: break } @@ -1313,6 +1342,7 @@ actor SyncEngine { private nonisolated static func inferredRecordType(for recordID: CKRecord.ID) -> String { let name = recordID.recordName if name.hasPrefix("moves-") { return "Moves" } + if name.hasPrefix("journal-") { return "Journal" } if name.hasPrefix("player-") { return "Player" } if name.hasPrefix("game-") { return "Game" } if name.hasPrefix("ping-") { return "Ping" } diff --git a/Tests/Unit/JournalUploadTests.swift b/Tests/Unit/JournalUploadTests.swift @@ -0,0 +1,271 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Phase 2 upload pipeline: the `JournalCodec` wire format, the `Journal` +/// record naming/building in `RecordSerializer`, and the end-to-end enqueue + +/// `buildRecord` path through `SyncEngine`. The replay viewer (Phase 2b) is not +/// covered here. + +// MARK: - Wire format + +@Suite("JournalCodec") +struct JournalCodecTests { + + private func value( + seq: Int64, + row: Int, + col: Int, + letter: String, + mark: CellMark, + kind: JournalKind, + actingAuthorID: String? = nil, + cellAuthorID: String? = nil, + targetSeq: Int64? = nil, + batchID: UUID? = nil, + prevSeqAtCell: Int64? = nil + ) -> JournalValue { + JournalValue( + seq: seq, + timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(seq)), + position: GridPosition(row: row, col: col), + state: JournalCellState(letter: letter, mark: mark, cellAuthorID: cellAuthorID), + actingAuthorID: actingAuthorID, + kind: kind, + targetSeq: targetSeq, + batchID: batchID, + prevSeqAtCell: prevSeqAtCell + ) + } + + @Test("encode/decode round-trips every field, including nil optionals") + func roundTrip() throws { + let batch = UUID() + let values = [ + value(seq: 0, row: 0, col: 0, letter: "A", mark: .pen(checked: nil), kind: .input, + actingAuthorID: "alice", cellAuthorID: "alice"), + value(seq: 1, row: 1, col: 2, letter: "B", mark: .pencil(checked: .wrong), kind: .clear, + actingAuthorID: "alice", cellAuthorID: nil, batchID: batch, prevSeqAtCell: 0), + value(seq: 2, row: 2, col: 2, letter: "", mark: .none, kind: .undo, + targetSeq: 0, batchID: batch), + value(seq: 3, row: 0, col: 1, letter: "C", mark: .revealed, kind: .reveal), + ] + let data = try JournalCodec.encode(values) + let decoded = try JournalCodec.decode(data) + #expect(decoded == values) + } + + @Test("encode sorts entries by seq") + func encodeSortsBySeq() throws { + let values = [ + value(seq: 2, row: 0, col: 0, letter: "C", mark: .none, kind: .input), + value(seq: 0, row: 0, col: 0, letter: "A", mark: .none, kind: .input), + value(seq: 1, row: 0, col: 0, letter: "B", mark: .none, kind: .input), + ] + let decoded = try JournalCodec.decode(try JournalCodec.encode(values)) + #expect(decoded.map(\.seq) == [0, 1, 2]) + } + + @Test("decode tolerates a payload missing the optional keys (forward-compat)") + func decodeToleratesMissingOptionals() throws { + let ts = Date(timeIntervalSince1970: 1_700_000_000) + let json: [String: Any] = ["entries": [[ + "seq": 5, + "timestamp": ts.timeIntervalSinceReferenceDate, + "row": 1, + "col": 2, + "letter": "Z", + "markCode": 7, + "kind": 2, + ]]] + let data = try JSONSerialization.data(withJSONObject: json) + let decoded = try JournalCodec.decode(data) + let entry = try #require(decoded.first) + #expect(entry.seq == 5) + #expect(entry.position == GridPosition(row: 1, col: 2)) + #expect(entry.state.letter == "Z") + #expect(entry.state.mark == .revealed) + #expect(entry.kind == .reveal) + #expect(entry.state.cellAuthorID == nil) + #expect(entry.actingAuthorID == nil) + #expect(entry.targetSeq == nil) + #expect(entry.batchID == nil) + #expect(entry.prevSeqAtCell == nil) + } +} + +// MARK: - Record naming + building + +@Suite("RecordSerializer Journal") +struct RecordSerializerJournalTests { + + private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! + private var zoneID: CKRecordZone.ID { RecordSerializer.zoneID(for: gameID) } + + @Test("Journal record name uses the expected format") + func nameFormat() { + let name = RecordSerializer.recordName( + forJournalInGame: gameID, + authorID: "alice", + deviceID: "deadbeef" + ) + #expect(name == "journal-\(gameID.uuidString)-alice-deadbeef") + } + + @Test("Journal record name parses back, even when authorID contains dashes") + func nameRoundTrip() { + let name = RecordSerializer.recordName( + forJournalInGame: gameID, + authorID: "alice-with-dashes", + deviceID: "deadbeef" + ) + let parsed = RecordSerializer.parseJournalRecordName(name) + #expect(parsed?.0 == gameID) + #expect(parsed?.1 == "alice-with-dashes") + #expect(parsed?.2 == "deadbeef") + } + + @Test("parse rejects a non-journal name") + func parseRejectsOtherPrefix() { + let moves = RecordSerializer.recordName( + forMovesInGame: gameID, authorID: "alice", deviceID: "deadbeef" + ) + #expect(RecordSerializer.parseJournalRecordName(moves) == nil) + } + + @Test("Journal record carries the entries asset, which decodes back") + func recordAssetRoundTrips() throws { + let entries = [ + JournalValue( + seq: 0, + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + position: GridPosition(row: 0, col: 0), + state: JournalCellState(letter: "A", mark: .pen(checked: nil), cellAuthorID: "alice"), + actingAuthorID: "alice", + kind: .input, + targetSeq: nil, + batchID: nil, + prevSeqAtCell: nil + ), + JournalValue( + seq: 1, + timestamp: Date(timeIntervalSince1970: 1_700_000_010), + position: GridPosition(row: 1, col: 2), + state: JournalCellState(letter: "B", mark: .revealed, cellAuthorID: nil), + actingAuthorID: "bob", + kind: .reveal, + targetSeq: nil, + batchID: UUID(), + prevSeqAtCell: nil + ), + ] + let updatedAt = Date(timeIntervalSince1970: 1_700_000_010) + let record = try RecordSerializer.journalRecord( + gameID: gameID, + authorID: "alice", + deviceID: "deadbeef", + updatedAt: updatedAt, + entries: entries, + zone: zoneID + ) + + #expect(record.recordType == "Journal") + #expect(record["authorID"] as? String == "alice") + #expect(record["deviceID"] as? String == "deadbeef") + #expect(record["updatedAt"] as? Date == updatedAt) + + let asset = try #require(record["entries"] as? CKAsset) + let url = try #require(asset.fileURL) + let data = try Data(contentsOf: url) + #expect(try JournalCodec.decode(data) == entries) + } +} + +// MARK: - End-to-end enqueue + build + +@Suite("Journal upload via SyncEngine", .serialized) +@MainActor +struct JournalUploadEngineTests { + + private func makeEngine(persistence: PersistenceController) async -> SyncEngine { + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let engine = SyncEngine(container: container, persistence: persistence) + await engine.start() + return engine + } + + private func makePrivateGame(in ctx: NSManagedObjectContext) throws -> UUID { + let id = UUID() + let zoneName = "game-\(id.uuidString)" + let entity = GameEntity(context: ctx) + entity.id = id + entity.title = "Private" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = zoneName + entity.ckZoneName = zoneName + entity.databaseScope = 0 + try ctx.save() + return id + } + + private func seedJournalRow(gameID: UUID, seq: Int64, in ctx: NSManagedObjectContext) throws { + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameReq.fetchLimit = 1 + let game = try #require(try ctx.fetch(gameReq).first) + let row = JournalEntity(context: ctx) + row.game = game + row.gameID = gameID + row.seq = seq + row.timestamp = Date(timeIntervalSince1970: 1_700_000_000 + Double(seq)) + row.row = 0 + row.col = Int16(seq) + row.letter = "A" + row.markCode = 0 + row.kind = JournalKind.input.rawValue + try ctx.save() + } + + @Test("enqueue registers a buildable Journal save that survives a batch build") + func enqueuePreservesBuildableJournal() async throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = try makePrivateGame(in: ctx) + try seedJournalRow(gameID: gameID, seq: 0, in: ctx) + try seedJournalRow(gameID: gameID, seq: 1, in: ctx) + let engine = await makeEngine(persistence: persistence) + + await engine.enqueueJournalUpload(gameID: gameID, authorID: "alice") + let before = await engine.pendingSaveRecordNames(scope: .private) + let journalName = try #require(before.first { $0.hasPrefix("journal-") }) + + // The JournalEntity rows back the record, so `buildRecord` materializes + // it and the reap must not fire. + _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private) + + let after = await engine.pendingSaveRecordNames(scope: .private) + #expect(after.contains(journalName)) + } + + @Test("an enqueued journal with no rows is reaped (nothing to upload)") + func emptyJournalIsReaped() async throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = try makePrivateGame(in: ctx) + let engine = await makeEngine(persistence: persistence) + + await engine.enqueueJournalUpload(gameID: gameID, authorID: "alice") + let before = await engine.pendingSaveRecordNames(scope: .private) + let journalName = try #require(before.first { $0.hasPrefix("journal-") }) + + _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private) + + let after = await engine.pendingSaveRecordNames(scope: .private) + #expect(!after.contains(journalName)) + } +}