crossmate

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

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 }