crossmate

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

MovesInboundTests.swift (12518B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Pins down `RecordSerializer.applyMovesRecord` — the inbound persistence path
      9 /// that turns a `Moves` CKRecord into a `MovesEntity` row.
     10 @Suite("RecordSerializer.applyMovesRecord")
     11 @MainActor
     12 struct MovesInboundTests {
     13 
     14     private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")!
     15 
     16     private func record(
     17         in ctx: NSManagedObjectContext,
     18         authorID: String,
     19         deviceID: String,
     20         cells: [GridPosition: TimestampedCell],
     21         updatedAt: Date
     22     ) throws -> (CKRecord, MovesValue) {
     23         let value = MovesValue(
     24             gameID: gameID,
     25             authorID: authorID,
     26             deviceID: deviceID,
     27             cells: cells,
     28             updatedAt: updatedAt
     29         )
     30         let record = try RecordSerializer.movesRecord(
     31             from: value,
     32             zone: RecordSerializer.zoneID(for: gameID),
     33             systemFields: nil
     34         )
     35         return (record, value)
     36     }
     37 
     38     private func fetchAll(_ ctx: NSManagedObjectContext) -> [MovesEntity] {
     39         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
     40         return (try? ctx.fetch(req)) ?? []
     41     }
     42 
     43     @Test("Persists a MovesEntity with the record's fields")
     44     func persistsEntity() throws {
     45         let persistence = makeTestPersistence()
     46         let ctx = persistence.viewContext
     47         let (rec, value) = try record(
     48             in: ctx,
     49             authorID: "alice",
     50             deviceID: "deadbeef",
     51             cells: [
     52                 GridPosition(row: 0, col: 0): TimestampedCell(
     53                     letter: "A", markKind: 0, checkedWrong: false,
     54                     updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
     55                 ),
     56             ],
     57             updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
     58         )
     59 
     60         RecordSerializer.applyMovesRecord(rec, value: value, to: ctx)
     61 
     62         let rows = fetchAll(ctx)
     63         #expect(rows.count == 1)
     64         let entity = try #require(rows.first)
     65         #expect(entity.ckRecordName == rec.recordID.recordName)
     66         #expect(entity.authorID == "alice")
     67         #expect(entity.deviceID == "deadbeef")
     68         #expect(entity.updatedAt == value.updatedAt)
     69         #expect(entity.ckSystemFields != nil)
     70         // `cells` should be the verbatim record blob — decode round-trip recovers
     71         // the same cells we encoded.
     72         let decoded = try MovesCodec.decode(entity.cells ?? Data())
     73         #expect(decoded == value.cells)
     74     }
     75 
     76     @Test("Re-applying the same record updates the existing row in place")
     77     func reapplyUpdatesSameRow() throws {
     78         let persistence = makeTestPersistence()
     79         let ctx = persistence.viewContext
     80 
     81         let cells1: [GridPosition: TimestampedCell] = [
     82             GridPosition(row: 0, col: 0): TimestampedCell(
     83                 letter: "A", markKind: 0, checkedWrong: false,
     84                 updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
     85                 authorID: "alice"
     86             ),
     87         ]
     88         let (rec1, value1) = try record(
     89             in: ctx,
     90             authorID: "alice",
     91             deviceID: "d1",
     92             cells: cells1,
     93             updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
     94         )
     95         RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx)
     96         let firstID = try #require(fetchAll(ctx).first?.objectID)
     97 
     98         // A later update from the same device should land on the same row
     99         // (matched by ckRecordName), not create a duplicate.
    100         let cells2: [GridPosition: TimestampedCell] = [
    101             GridPosition(row: 0, col: 0): TimestampedCell(
    102                 letter: "B", markKind: 0, checkedWrong: false,
    103                 updatedAt: Date(timeIntervalSince1970: 1_700_000_500),
    104                 authorID: "alice"
    105             ),
    106             GridPosition(row: 1, col: 1): TimestampedCell(
    107                 letter: "C", markKind: 0, checkedWrong: false,
    108                 updatedAt: Date(timeIntervalSince1970: 1_700_000_500),
    109                 authorID: "alice"
    110             ),
    111         ]
    112         let (rec2, value2) = try record(
    113             in: ctx,
    114             authorID: "alice",
    115             deviceID: "d1",
    116             cells: cells2,
    117             updatedAt: Date(timeIntervalSince1970: 1_700_000_500)
    118         )
    119         RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx)
    120 
    121         let rows = fetchAll(ctx)
    122         #expect(rows.count == 1)
    123         #expect(rows.first?.objectID == firstID)
    124         #expect(rows.first?.updatedAt == value2.updatedAt)
    125         let decoded = try MovesCodec.decode(rows.first?.cells ?? Data())
    126         #expect(decoded == cells2)
    127     }
    128 
    129     @Test("Inbound local-device record does not clobber existing local row")
    130     func inboundLocalDeviceRecordDoesNotClobberExistingLocalRow() throws {
    131         let persistence = makeTestPersistence()
    132         let ctx = persistence.viewContext
    133 
    134         let game = GameEntity(context: ctx)
    135         game.id = gameID
    136         game.ckRecordName = "game-\(gameID.uuidString)"
    137         game.title = ""
    138         game.puzzleSource = ""
    139         game.createdAt = Date(timeIntervalSince1970: 0)
    140         game.updatedAt = Date(timeIntervalSince1970: 20)
    141 
    142         let localCells: [GridPosition: TimestampedCell] = [
    143             GridPosition(row: 0, col: 0): TimestampedCell(
    144                 letter: "B", markKind: 0, checkedWrong: false,
    145                 updatedAt: Date(timeIntervalSince1970: 20),
    146                 authorID: "alice"
    147             ),
    148         ]
    149         let local = MovesEntity(context: ctx)
    150         local.game = game
    151         local.ckRecordName = RecordSerializer.recordName(
    152             forMovesInGame: gameID,
    153             authorID: "alice",
    154             deviceID: RecordSerializer.localDeviceID
    155         )
    156         local.authorID = "alice"
    157         local.deviceID = RecordSerializer.localDeviceID
    158         local.updatedAt = Date(timeIntervalSince1970: 20)
    159         local.cells = try MovesCodec.encode(localCells)
    160         try ctx.save()
    161 
    162         let serverCells: [GridPosition: TimestampedCell] = [
    163             GridPosition(row: 0, col: 0): TimestampedCell(
    164                 letter: "A", markKind: 0, checkedWrong: false,
    165                 updatedAt: Date(timeIntervalSince1970: 10),
    166                 authorID: "alice"
    167             ),
    168         ]
    169         let (rec, value) = try record(
    170             in: ctx,
    171             authorID: "alice",
    172             deviceID: RecordSerializer.localDeviceID,
    173             cells: serverCells,
    174             updatedAt: Date(timeIntervalSince1970: 10)
    175         )
    176 
    177         RecordSerializer.applyMovesRecord(
    178             rec,
    179             value: value,
    180             to: ctx,
    181             localAuthorID: "alice"
    182         )
    183 
    184         let row = try #require(fetchAll(ctx).first)
    185         #expect(row.updatedAt == Date(timeIntervalSince1970: 20))
    186         let decoded = try MovesCodec.decode(row.cells ?? Data())
    187         #expect(decoded == localCells)
    188         #expect(row.ckSystemFields != nil)
    189     }
    190 
    191     @Test("Inbound other-device record replaces cached row")
    192     func inboundOtherDeviceRecordReplacesCachedRow() throws {
    193         let persistence = makeTestPersistence()
    194         let ctx = persistence.viewContext
    195 
    196         let game = GameEntity(context: ctx)
    197         game.id = gameID
    198         game.ckRecordName = "game-\(gameID.uuidString)"
    199         game.title = ""
    200         game.puzzleSource = ""
    201         game.createdAt = Date(timeIntervalSince1970: 0)
    202         game.updatedAt = Date(timeIntervalSince1970: 20)
    203 
    204         let cachedCells: [GridPosition: TimestampedCell] = [
    205             GridPosition(row: 0, col: 0): TimestampedCell(
    206                 letter: "B", markKind: 0, checkedWrong: false,
    207                 updatedAt: Date(timeIntervalSince1970: 20),
    208                 authorID: "bob"
    209             ),
    210         ]
    211         let cached = MovesEntity(context: ctx)
    212         cached.game = game
    213         cached.ckRecordName = RecordSerializer.recordName(
    214             forMovesInGame: gameID,
    215             authorID: "bob",
    216             deviceID: "phone"
    217         )
    218         cached.authorID = "bob"
    219         cached.deviceID = "phone"
    220         cached.updatedAt = Date(timeIntervalSince1970: 20)
    221         cached.cells = try MovesCodec.encode(cachedCells)
    222         try ctx.save()
    223 
    224         let serverCells: [GridPosition: TimestampedCell] = [
    225             GridPosition(row: 0, col: 0): TimestampedCell(
    226                 letter: "A", markKind: 0, checkedWrong: false,
    227                 updatedAt: Date(timeIntervalSince1970: 10),
    228                 authorID: "bob"
    229             ),
    230         ]
    231         let (rec, value) = try record(
    232             in: ctx,
    233             authorID: "bob",
    234             deviceID: "phone",
    235             cells: serverCells,
    236             updatedAt: Date(timeIntervalSince1970: 10)
    237         )
    238 
    239         RecordSerializer.applyMovesRecord(
    240             rec,
    241             value: value,
    242             to: ctx,
    243             localAuthorID: "alice"
    244         )
    245 
    246         let row = try #require(fetchAll(ctx).first)
    247         #expect(row.updatedAt == Date(timeIntervalSince1970: 10))
    248         let decoded = try MovesCodec.decode(row.cells ?? Data())
    249         #expect(decoded == serverCells)
    250     }
    251 
    252     @Test("Two devices for the same game produce two distinct rows")
    253     func twoDevicesYieldTwoRows() throws {
    254         let persistence = makeTestPersistence()
    255         let ctx = persistence.viewContext
    256 
    257         let (rec1, value1) = try record(
    258             in: ctx,
    259             authorID: "alice",
    260             deviceID: "phone",
    261             cells: [
    262                 GridPosition(row: 0, col: 0): TimestampedCell(
    263                     letter: "A", markKind: 0, checkedWrong: false,
    264                     updatedAt: Date(timeIntervalSince1970: 1),
    265                     authorID: "alice"
    266                 ),
    267             ],
    268             updatedAt: Date(timeIntervalSince1970: 1)
    269         )
    270         let (rec2, value2) = try record(
    271             in: ctx,
    272             authorID: "alice",
    273             deviceID: "ipad",
    274             cells: [
    275                 GridPosition(row: 1, col: 1): TimestampedCell(
    276                     letter: "B", markKind: 0, checkedWrong: false,
    277                     updatedAt: Date(timeIntervalSince1970: 2),
    278                     authorID: "alice"
    279                 ),
    280             ],
    281             updatedAt: Date(timeIntervalSince1970: 2)
    282         )
    283         RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx)
    284         RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx)
    285 
    286         let rows = fetchAll(ctx)
    287         #expect(rows.count == 2)
    288         #expect(Set(rows.compactMap(\.deviceID)) == ["phone", "ipad"])
    289     }
    290 
    291     @Test("Creates a stub GameEntity if none exists yet (lazy parent)")
    292     func lazyParentStub() throws {
    293         let persistence = makeTestPersistence()
    294         let ctx = persistence.viewContext
    295 
    296         let (rec, value) = try record(
    297             in: ctx,
    298             authorID: "alice",
    299             deviceID: "d1",
    300             cells: [:],
    301             updatedAt: Date(timeIntervalSince1970: 1)
    302         )
    303         RecordSerializer.applyMovesRecord(rec, value: value, to: ctx)
    304 
    305         let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    306         gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    307         let games = try ctx.fetch(gameReq)
    308         #expect(games.count == 1)
    309         #expect(games.first?.title == "")
    310         // The Moves row should be parented to the new stub.
    311         let movesRows = fetchAll(ctx)
    312         #expect(movesRows.count == 1)
    313         #expect(movesRows.first?.game?.objectID == games.first?.objectID)
    314     }
    315 
    316     @Test("Bumps the parent game's updatedAt when the record is fresher")
    317     func bumpsGameUpdatedAt() throws {
    318         let persistence = makeTestPersistence()
    319         let ctx = persistence.viewContext
    320 
    321         // Pre-create the game with an old timestamp so the bump is observable.
    322         let game = GameEntity(context: ctx)
    323         game.id = gameID
    324         game.ckRecordName = "game-\(gameID.uuidString)"
    325         game.title = ""
    326         game.puzzleSource = ""
    327         game.createdAt = Date(timeIntervalSince1970: 0)
    328         game.updatedAt = Date(timeIntervalSince1970: 0)
    329         try ctx.save()
    330 
    331         let (rec, value) = try record(
    332             in: ctx,
    333             authorID: "alice",
    334             deviceID: "d1",
    335             cells: [:],
    336             updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
    337         )
    338         RecordSerializer.applyMovesRecord(rec, value: value, to: ctx)
    339 
    340         #expect(game.updatedAt == value.updatedAt)
    341     }
    342 }