crossmate

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

MovesInboundTests.swift (19341B)


      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", mark: .none,
     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", mark: .none,
     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", mark: .none,
    103                 updatedAt: Date(timeIntervalSince1970: 1_700_000_500),
    104                 authorID: "alice"
    105             ),
    106             GridPosition(row: 1, col: 1): TimestampedCell(
    107                 letter: "C", mark: .none,
    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("onNewAuthor fires once for a remote contributor's first row, not repeats")
    130     func onNewAuthorFiresForNewRemoteContributor() throws {
    131         let persistence = makeTestPersistence()
    132         let ctx = persistence.viewContext
    133         let cell: [GridPosition: TimestampedCell] = [
    134             GridPosition(row: 0, col: 0): TimestampedCell(
    135                 letter: "A", mark: .none,
    136                 updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
    137                 authorID: "bob"
    138             ),
    139         ]
    140         let (rec, value) = try record(
    141             in: ctx, authorID: "bob", deviceID: "d1",
    142             cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
    143         )
    144 
    145         var newAuthors: [String] = []
    146         RecordSerializer.applyMovesRecord(
    147             rec, value: value, to: ctx, localAuthorID: "alice",
    148             onNewAuthor: { newAuthors.append($0) }
    149         )
    150         #expect(newAuthors == ["bob"])
    151 
    152         // A later update lands on the same row (ckRecordName), so it is not a
    153         // new contributor and must not re-trigger the roster.
    154         let (rec2, value2) = try record(
    155             in: ctx, authorID: "bob", deviceID: "d1",
    156             cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_500)
    157         )
    158         RecordSerializer.applyMovesRecord(
    159             rec2, value: value2, to: ctx, localAuthorID: "alice",
    160             onNewAuthor: { newAuthors.append($0) }
    161         )
    162         #expect(newAuthors == ["bob"])
    163     }
    164 
    165     @Test("onNewAuthor does not fire for a known contributor's second device")
    166     func onNewAuthorSkipsKnownAuthorSecondDevice() throws {
    167         let persistence = makeTestPersistence()
    168         let ctx = persistence.viewContext
    169         let cell: [GridPosition: TimestampedCell] = [
    170             GridPosition(row: 0, col: 0): TimestampedCell(
    171                 letter: "A", mark: .none,
    172                 updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
    173                 authorID: "bob"
    174             ),
    175         ]
    176         let (phoneRecord, phoneValue) = try record(
    177             in: ctx, authorID: "bob", deviceID: "phone",
    178             cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
    179         )
    180         let (ipadRecord, ipadValue) = try record(
    181             in: ctx, authorID: "bob", deviceID: "ipad",
    182             cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_500)
    183         )
    184 
    185         var newAuthors: [String] = []
    186         RecordSerializer.applyMovesRecord(
    187             phoneRecord, value: phoneValue, to: ctx, localAuthorID: "alice",
    188             onNewAuthor: { newAuthors.append($0) }
    189         )
    190         RecordSerializer.applyMovesRecord(
    191             ipadRecord, value: ipadValue, to: ctx, localAuthorID: "alice",
    192             onNewAuthor: { newAuthors.append($0) }
    193         )
    194 
    195         #expect(newAuthors == ["bob"])
    196     }
    197 
    198     @Test("onNewAuthor does not fire for the local author's own moves")
    199     func onNewAuthorSkipsLocalAuthor() throws {
    200         let persistence = makeTestPersistence()
    201         let ctx = persistence.viewContext
    202         let cell: [GridPosition: TimestampedCell] = [
    203             GridPosition(row: 0, col: 0): TimestampedCell(
    204                 letter: "A", mark: .none,
    205                 updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
    206                 authorID: "alice"
    207             ),
    208         ]
    209         // A sibling device of the local author (same authorID, new row) is not
    210         // a new participant.
    211         let (rec, value) = try record(
    212             in: ctx, authorID: "alice", deviceID: "sibling-device",
    213             cells: cell, updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
    214         )
    215 
    216         var newAuthors: [String] = []
    217         RecordSerializer.applyMovesRecord(
    218             rec, value: value, to: ctx, localAuthorID: "alice",
    219             onNewAuthor: { newAuthors.append($0) }
    220         )
    221         #expect(newAuthors.isEmpty)
    222     }
    223 
    224     @Test("Inbound local-device record does not clobber existing local row")
    225     func inboundLocalDeviceRecordDoesNotClobberExistingLocalRow() throws {
    226         let persistence = makeTestPersistence()
    227         let ctx = persistence.viewContext
    228 
    229         let game = GameEntity(context: ctx)
    230         game.id = gameID
    231         game.ckRecordName = "game-\(gameID.uuidString)"
    232         game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName
    233         game.title = ""
    234         game.puzzleSource = ""
    235         game.createdAt = Date(timeIntervalSince1970: 0)
    236         game.updatedAt = Date(timeIntervalSince1970: 20)
    237 
    238         let localCells: [GridPosition: TimestampedCell] = [
    239             GridPosition(row: 0, col: 0): TimestampedCell(
    240                 letter: "B", mark: .none,
    241                 updatedAt: Date(timeIntervalSince1970: 20),
    242                 authorID: "alice"
    243             ),
    244         ]
    245         let local = MovesEntity(context: ctx)
    246         local.game = game
    247         local.ckRecordName = RecordSerializer.recordName(
    248             forMovesInGame: gameID,
    249             authorID: "alice",
    250             deviceID: RecordSerializer.localDeviceID
    251         )
    252         local.authorID = "alice"
    253         local.deviceID = RecordSerializer.localDeviceID
    254         local.updatedAt = Date(timeIntervalSince1970: 20)
    255         local.cells = try MovesCodec.encode(localCells)
    256         try ctx.save()
    257 
    258         let serverCells: [GridPosition: TimestampedCell] = [
    259             GridPosition(row: 0, col: 0): TimestampedCell(
    260                 letter: "A", mark: .none,
    261                 updatedAt: Date(timeIntervalSince1970: 10),
    262                 authorID: "alice"
    263             ),
    264         ]
    265         let (rec, value) = try record(
    266             in: ctx,
    267             authorID: "alice",
    268             deviceID: RecordSerializer.localDeviceID,
    269             cells: serverCells,
    270             updatedAt: Date(timeIntervalSince1970: 10)
    271         )
    272 
    273         RecordSerializer.applyMovesRecord(
    274             rec,
    275             value: value,
    276             to: ctx,
    277             localAuthorID: "alice"
    278         )
    279 
    280         let row = try #require(fetchAll(ctx).first)
    281         #expect(row.updatedAt == Date(timeIntervalSince1970: 20))
    282         let decoded = try MovesCodec.decode(row.cells ?? Data())
    283         #expect(decoded == localCells)
    284         #expect(row.ckSystemFields != nil)
    285     }
    286 
    287     @Test("Inbound other-device record preserves newer realtime cells")
    288     func inboundOtherDeviceRecordPreservesNewerRealtimeCells() throws {
    289         let persistence = makeTestPersistence()
    290         let ctx = persistence.viewContext
    291 
    292         let game = GameEntity(context: ctx)
    293         game.id = gameID
    294         game.ckRecordName = "game-\(gameID.uuidString)"
    295         game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName
    296         game.title = ""
    297         game.puzzleSource = ""
    298         game.createdAt = Date(timeIntervalSince1970: 0)
    299         game.updatedAt = Date(timeIntervalSince1970: 20)
    300 
    301         let cachedCells: [GridPosition: TimestampedCell] = [
    302             GridPosition(row: 0, col: 0): TimestampedCell(
    303                 letter: "B", mark: .none,
    304                 updatedAt: Date(timeIntervalSince1970: 20),
    305                 authorID: "bob"
    306             ),
    307             GridPosition(row: 1, col: 1): TimestampedCell(
    308                 letter: "", mark: .none,
    309                 updatedAt: Date(timeIntervalSince1970: 30),
    310                 authorID: "bob"
    311             ),
    312         ]
    313         let cached = MovesEntity(context: ctx)
    314         cached.game = game
    315         cached.ckRecordName = RecordSerializer.recordName(
    316             forMovesInGame: gameID,
    317             authorID: "bob",
    318             deviceID: "phone"
    319         )
    320         cached.authorID = "bob"
    321         cached.deviceID = "phone"
    322         cached.updatedAt = Date(timeIntervalSince1970: 20)
    323         cached.cells = try MovesCodec.encode(cachedCells)
    324         try ctx.save()
    325 
    326         let serverCells: [GridPosition: TimestampedCell] = [
    327             GridPosition(row: 0, col: 0): TimestampedCell(
    328                 letter: "A", mark: .none,
    329                 updatedAt: Date(timeIntervalSince1970: 10),
    330                 authorID: "bob"
    331             ),
    332             GridPosition(row: 1, col: 1): TimestampedCell(
    333                 letter: "Z", mark: .none,
    334                 updatedAt: Date(timeIntervalSince1970: 15),
    335                 authorID: "bob"
    336             ),
    337             GridPosition(row: 2, col: 2): TimestampedCell(
    338                 letter: "C", mark: .none,
    339                 updatedAt: Date(timeIntervalSince1970: 40),
    340                 authorID: "bob"
    341             ),
    342         ]
    343         let (rec, value) = try record(
    344             in: ctx,
    345             authorID: "bob",
    346             deviceID: "phone",
    347             cells: serverCells,
    348             updatedAt: Date(timeIntervalSince1970: 10)
    349         )
    350 
    351         RecordSerializer.applyMovesRecord(
    352             rec,
    353             value: value,
    354             to: ctx,
    355             localAuthorID: "alice"
    356         )
    357 
    358         let row = try #require(fetchAll(ctx).first)
    359         #expect(row.updatedAt == Date(timeIntervalSince1970: 40))
    360         let decoded = try MovesCodec.decode(row.cells ?? Data())
    361         #expect(decoded[GridPosition(row: 0, col: 0)]?.letter == "B")
    362         #expect(decoded[GridPosition(row: 1, col: 1)]?.letter == "")
    363         #expect(decoded[GridPosition(row: 2, col: 2)]?.letter == "C")
    364     }
    365 
    366     @Test("Inbound other-device record keeps existing cells on equal timestamps")
    367     func inboundOtherDeviceRecordKeepsExistingCellsOnEqualTimestamps() throws {
    368         let persistence = makeTestPersistence()
    369         let ctx = persistence.viewContext
    370 
    371         let game = GameEntity(context: ctx)
    372         game.id = gameID
    373         game.ckRecordName = "game-\(gameID.uuidString)"
    374         game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName
    375         game.title = ""
    376         game.puzzleSource = ""
    377         game.createdAt = Date(timeIntervalSince1970: 0)
    378         game.updatedAt = Date(timeIntervalSince1970: 20)
    379 
    380         let same = Date(timeIntervalSince1970: 20)
    381         let position = GridPosition(row: 0, col: 0)
    382         let cachedCells: [GridPosition: TimestampedCell] = [
    383             position: TimestampedCell(
    384                 letter: "B", mark: .none, updatedAt: same, authorID: "bob"
    385             ),
    386         ]
    387         let cached = MovesEntity(context: ctx)
    388         cached.game = game
    389         cached.ckRecordName = RecordSerializer.recordName(
    390             forMovesInGame: gameID,
    391             authorID: "bob",
    392             deviceID: "phone"
    393         )
    394         cached.authorID = "bob"
    395         cached.deviceID = "phone"
    396         cached.updatedAt = same
    397         cached.cells = try MovesCodec.encode(cachedCells)
    398         try ctx.save()
    399 
    400         let serverCells: [GridPosition: TimestampedCell] = [
    401             position: TimestampedCell(
    402                 letter: "A", mark: .none, updatedAt: same, authorID: "bob"
    403             ),
    404         ]
    405         let (rec, value) = try record(
    406             in: ctx,
    407             authorID: "bob",
    408             deviceID: "phone",
    409             cells: serverCells,
    410             updatedAt: same
    411         )
    412 
    413         RecordSerializer.applyMovesRecord(
    414             rec,
    415             value: value,
    416             to: ctx,
    417             localAuthorID: "alice"
    418         )
    419 
    420         let row = try #require(fetchAll(ctx).first)
    421         let decoded = try MovesCodec.decode(row.cells ?? Data())
    422         #expect(decoded[position]?.letter == "B")
    423         #expect(decoded[position]?.updatedAt == same)
    424     }
    425 
    426     @Test("Two devices for the same game produce two distinct rows")
    427     func twoDevicesYieldTwoRows() throws {
    428         let persistence = makeTestPersistence()
    429         let ctx = persistence.viewContext
    430 
    431         let (rec1, value1) = try record(
    432             in: ctx,
    433             authorID: "alice",
    434             deviceID: "phone",
    435             cells: [
    436                 GridPosition(row: 0, col: 0): TimestampedCell(
    437                     letter: "A", mark: .none,
    438                     updatedAt: Date(timeIntervalSince1970: 1),
    439                     authorID: "alice"
    440                 ),
    441             ],
    442             updatedAt: Date(timeIntervalSince1970: 1)
    443         )
    444         let (rec2, value2) = try record(
    445             in: ctx,
    446             authorID: "alice",
    447             deviceID: "ipad",
    448             cells: [
    449                 GridPosition(row: 1, col: 1): TimestampedCell(
    450                     letter: "B", mark: .none,
    451                     updatedAt: Date(timeIntervalSince1970: 2),
    452                     authorID: "alice"
    453                 ),
    454             ],
    455             updatedAt: Date(timeIntervalSince1970: 2)
    456         )
    457         RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx)
    458         RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx)
    459 
    460         let rows = fetchAll(ctx)
    461         #expect(rows.count == 2)
    462         #expect(Set(rows.compactMap(\.deviceID)) == ["phone", "ipad"])
    463     }
    464 
    465     @Test("Creates a stub GameEntity if none exists yet (lazy parent)")
    466     func lazyParentStub() throws {
    467         let persistence = makeTestPersistence()
    468         let ctx = persistence.viewContext
    469 
    470         let (rec, value) = try record(
    471             in: ctx,
    472             authorID: "alice",
    473             deviceID: "d1",
    474             cells: [:],
    475             updatedAt: Date(timeIntervalSince1970: 1)
    476         )
    477         RecordSerializer.applyMovesRecord(rec, value: value, to: ctx)
    478 
    479         let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    480         gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    481         let games = try ctx.fetch(gameReq)
    482         #expect(games.count == 1)
    483         #expect(games.first?.title == "")
    484         // The Moves row should be parented to the new stub.
    485         let movesRows = fetchAll(ctx)
    486         #expect(movesRows.count == 1)
    487         #expect(movesRows.first?.game?.objectID == games.first?.objectID)
    488     }
    489 
    490     @Test("Bumps the parent game's updatedAt when the record is fresher")
    491     func bumpsGameUpdatedAt() throws {
    492         let persistence = makeTestPersistence()
    493         let ctx = persistence.viewContext
    494 
    495         // Pre-create the game with an old timestamp so the bump is observable.
    496         let game = GameEntity(context: ctx)
    497         game.id = gameID
    498         game.ckRecordName = "game-\(gameID.uuidString)"
    499         game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName
    500         game.title = ""
    501         game.puzzleSource = ""
    502         game.createdAt = Date(timeIntervalSince1970: 0)
    503         game.updatedAt = Date(timeIntervalSince1970: 0)
    504         try ctx.save()
    505 
    506         let (rec, value) = try record(
    507             in: ctx,
    508             authorID: "alice",
    509             deviceID: "d1",
    510             cells: [:],
    511             updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
    512         )
    513         RecordSerializer.applyMovesRecord(rec, value: value, to: ctx)
    514 
    515         #expect(game.updatedAt == value.updatedAt)
    516     }
    517 }