JournalUploadTests.swift (10600B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// Phase 2 upload pipeline: the `JournalCodec` wire format, the `Journal` 9 /// record naming/building in `RecordSerializer`, and the end-to-end enqueue + 10 /// `buildRecord` path through `SyncEngine`. The replay viewer (Phase 2b) is not 11 /// covered here. 12 13 // MARK: - Wire format 14 15 @Suite("JournalCodec") 16 struct JournalCodecTests { 17 18 private func value( 19 seq: Int64, 20 row: Int, 21 col: Int, 22 letter: String, 23 mark: CellMark, 24 kind: JournalKind, 25 actingAuthorID: String? = nil, 26 cellAuthorID: String? = nil, 27 targetSeq: Int64? = nil, 28 batchID: UUID? = nil, 29 prevSeqAtCell: Int64? = nil, 30 direction: Puzzle.Direction? = nil 31 ) -> JournalValue { 32 JournalValue( 33 seq: seq, 34 timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(seq)), 35 position: GridPosition(row: row, col: col), 36 state: JournalCellState(letter: letter, mark: mark, cellAuthorID: cellAuthorID), 37 actingAuthorID: actingAuthorID, 38 kind: kind, 39 targetSeq: targetSeq, 40 batchID: batchID, 41 prevSeqAtCell: prevSeqAtCell, 42 direction: direction 43 ) 44 } 45 46 @Test("encode/decode round-trips every field, including nil optionals") 47 func roundTrip() throws { 48 let batch = UUID() 49 let values = [ 50 value(seq: 0, row: 0, col: 0, letter: "A", mark: .pen(checked: nil), kind: .input, 51 actingAuthorID: "alice", cellAuthorID: "alice", direction: .across), 52 value(seq: 1, row: 1, col: 2, letter: "B", mark: .pencil(checked: .wrong), kind: .clear, 53 actingAuthorID: "alice", cellAuthorID: nil, batchID: batch, prevSeqAtCell: 0, direction: .down), 54 value(seq: 2, row: 2, col: 2, letter: "", mark: .none, kind: .undo, 55 targetSeq: 0, batchID: batch), 56 value(seq: 3, row: 0, col: 1, letter: "C", mark: .revealed, kind: .reveal), 57 ] 58 let data = try JournalCodec.encode(values) 59 let decoded = try JournalCodec.decode(data) 60 #expect(decoded == values) 61 } 62 63 @Test("encode sorts entries by seq") 64 func encodeSortsBySeq() throws { 65 let values = [ 66 value(seq: 2, row: 0, col: 0, letter: "C", mark: .none, kind: .input), 67 value(seq: 0, row: 0, col: 0, letter: "A", mark: .none, kind: .input), 68 value(seq: 1, row: 0, col: 0, letter: "B", mark: .none, kind: .input), 69 ] 70 let decoded = try JournalCodec.decode(try JournalCodec.encode(values)) 71 #expect(decoded.map(\.seq) == [0, 1, 2]) 72 } 73 74 @Test("decode tolerates a payload missing the optional keys (forward-compat)") 75 func decodeToleratesMissingOptionals() throws { 76 let ts = Date(timeIntervalSince1970: 1_700_000_000) 77 let json: [String: Any] = ["entries": [[ 78 "seq": 5, 79 "timestamp": ts.timeIntervalSinceReferenceDate, 80 "row": 1, 81 "col": 2, 82 "letter": "Z", 83 "markCode": 7, 84 "kind": 2, 85 ]]] 86 let data = try JSONSerialization.data(withJSONObject: json) 87 let decoded = try JournalCodec.decode(data) 88 let entry = try #require(decoded.first) 89 #expect(entry.seq == 5) 90 #expect(entry.position == GridPosition(row: 1, col: 2)) 91 #expect(entry.state.letter == "Z") 92 #expect(entry.state.mark == .revealed) 93 #expect(entry.kind == .reveal) 94 #expect(entry.state.cellAuthorID == nil) 95 #expect(entry.actingAuthorID == nil) 96 #expect(entry.targetSeq == nil) 97 #expect(entry.batchID == nil) 98 #expect(entry.prevSeqAtCell == nil) 99 #expect(entry.direction == nil) 100 } 101 } 102 103 // MARK: - Record naming + building 104 105 @Suite("RecordSerializer Journal") 106 struct RecordSerializerJournalTests { 107 108 private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! 109 private var zoneID: CKRecordZone.ID { RecordSerializer.zoneID(for: gameID) } 110 111 @Test("Journal record name uses the expected format") 112 func nameFormat() { 113 let name = RecordSerializer.recordName( 114 forJournalInGame: gameID, 115 authorID: "alice", 116 deviceID: "deadbeef" 117 ) 118 #expect(name == "journal-\(gameID.uuidString)-alice-deadbeef") 119 } 120 121 @Test("Journal record name parses back, even when authorID contains dashes") 122 func nameRoundTrip() { 123 let name = RecordSerializer.recordName( 124 forJournalInGame: gameID, 125 authorID: "alice-with-dashes", 126 deviceID: "deadbeef" 127 ) 128 let parsed = RecordSerializer.parseJournalRecordName(name) 129 #expect(parsed?.0 == gameID) 130 #expect(parsed?.1 == "alice-with-dashes") 131 #expect(parsed?.2 == "deadbeef") 132 } 133 134 @Test("parse rejects a non-journal name") 135 func parseRejectsOtherPrefix() { 136 let moves = RecordSerializer.recordName( 137 forMovesInGame: gameID, authorID: "alice", deviceID: "deadbeef" 138 ) 139 #expect(RecordSerializer.parseJournalRecordName(moves) == nil) 140 } 141 142 @Test("Journal record carries the entries asset, which decodes back") 143 func recordAssetRoundTrips() throws { 144 let entries = [ 145 JournalValue( 146 seq: 0, 147 timestamp: Date(timeIntervalSince1970: 1_700_000_000), 148 position: GridPosition(row: 0, col: 0), 149 state: JournalCellState(letter: "A", mark: .pen(checked: nil), cellAuthorID: "alice"), 150 actingAuthorID: "alice", 151 kind: .input, 152 targetSeq: nil, 153 batchID: nil, 154 prevSeqAtCell: nil, 155 direction: nil 156 ), 157 JournalValue( 158 seq: 1, 159 timestamp: Date(timeIntervalSince1970: 1_700_000_010), 160 position: GridPosition(row: 1, col: 2), 161 state: JournalCellState(letter: "B", mark: .revealed, cellAuthorID: nil), 162 actingAuthorID: "bob", 163 kind: .reveal, 164 targetSeq: nil, 165 batchID: UUID(), 166 prevSeqAtCell: nil, 167 direction: nil 168 ), 169 ] 170 let updatedAt = Date(timeIntervalSince1970: 1_700_000_010) 171 let record = try RecordSerializer.journalRecord( 172 gameID: gameID, 173 authorID: "alice", 174 deviceID: "deadbeef", 175 updatedAt: updatedAt, 176 entries: entries, 177 zone: zoneID 178 ) 179 180 #expect(record.recordType == "Journal") 181 #expect(record["authorID"] as? String == "alice") 182 #expect(record["deviceID"] as? String == "deadbeef") 183 #expect(record["updatedAt"] as? Date == updatedAt) 184 185 let asset = try #require(record["entries"] as? CKAsset) 186 let url = try #require(asset.fileURL) 187 let data = try Data(contentsOf: url) 188 #expect(try JournalCodec.decode(data) == entries) 189 } 190 } 191 192 // MARK: - End-to-end enqueue + build 193 194 @Suite("Journal upload via SyncEngine", .serialized) 195 @MainActor 196 struct JournalUploadEngineTests { 197 198 private func makeEngine(persistence: PersistenceController) async -> SyncEngine { 199 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 200 let engine = SyncEngine(container: container, persistence: persistence) 201 await engine.start() 202 return engine 203 } 204 205 private func makePrivateGame(in ctx: NSManagedObjectContext) throws -> UUID { 206 let id = UUID() 207 let zoneName = "game-\(id.uuidString)" 208 let entity = GameEntity(context: ctx) 209 entity.id = id 210 entity.title = "Private" 211 entity.puzzleSource = "" 212 entity.createdAt = Date() 213 entity.updatedAt = Date() 214 entity.ckRecordName = zoneName 215 entity.ckZoneName = zoneName 216 entity.databaseScope = 0 217 try ctx.save() 218 return id 219 } 220 221 private func seedJournalRow(gameID: UUID, seq: Int64, in ctx: NSManagedObjectContext) throws { 222 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 223 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 224 gameReq.fetchLimit = 1 225 let game = try #require(try ctx.fetch(gameReq).first) 226 let row = JournalEntity(context: ctx) 227 row.game = game 228 row.gameID = gameID 229 row.seq = seq 230 row.timestamp = Date(timeIntervalSince1970: 1_700_000_000 + Double(seq)) 231 row.row = 0 232 row.col = Int16(seq) 233 row.letter = "A" 234 row.markCode = 0 235 row.kind = JournalKind.input.rawValue 236 try ctx.save() 237 } 238 239 @Test("enqueue registers a buildable Journal save that survives a batch build") 240 func enqueuePreservesBuildableJournal() async throws { 241 let persistence = makeTestPersistence() 242 let ctx = persistence.viewContext 243 let gameID = try makePrivateGame(in: ctx) 244 try seedJournalRow(gameID: gameID, seq: 0, in: ctx) 245 try seedJournalRow(gameID: gameID, seq: 1, in: ctx) 246 let engine = await makeEngine(persistence: persistence) 247 248 await engine.enqueueJournalUpload(gameID: gameID, authorID: "alice") 249 let before = await engine.pendingSaveRecordNames(scope: .private) 250 let journalName = try #require(before.first { $0.hasPrefix("journal-") }) 251 252 // The JournalEntity rows back the record, so `buildRecord` materializes 253 // it and the reap must not fire. 254 _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private) 255 256 let after = await engine.pendingSaveRecordNames(scope: .private) 257 #expect(after.contains(journalName)) 258 } 259 260 @Test("an enqueued journal with no rows is reaped (nothing to upload)") 261 func emptyJournalIsReaped() async throws { 262 let persistence = makeTestPersistence() 263 let ctx = persistence.viewContext 264 let gameID = try makePrivateGame(in: ctx) 265 let engine = await makeEngine(persistence: persistence) 266 267 await engine.enqueueJournalUpload(gameID: gameID, authorID: "alice") 268 let before = await engine.pendingSaveRecordNames(scope: .private) 269 let journalName = try #require(before.first { $0.hasPrefix("journal-") }) 270 271 _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private) 272 273 let after = await engine.pendingSaveRecordNames(scope: .private) 274 #expect(!after.contains(journalName)) 275 } 276 }