crossmate

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

MovesInboundTests.swift (17013B)


      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.title = ""
    233         game.puzzleSource = ""
    234         game.createdAt = Date(timeIntervalSince1970: 0)
    235         game.updatedAt = Date(timeIntervalSince1970: 20)
    236 
    237         let localCells: [GridPosition: TimestampedCell] = [
    238             GridPosition(row: 0, col: 0): TimestampedCell(
    239                 letter: "B", mark: .none,
    240                 updatedAt: Date(timeIntervalSince1970: 20),
    241                 authorID: "alice"
    242             ),
    243         ]
    244         let local = MovesEntity(context: ctx)
    245         local.game = game
    246         local.ckRecordName = RecordSerializer.recordName(
    247             forMovesInGame: gameID,
    248             authorID: "alice",
    249             deviceID: RecordSerializer.localDeviceID
    250         )
    251         local.authorID = "alice"
    252         local.deviceID = RecordSerializer.localDeviceID
    253         local.updatedAt = Date(timeIntervalSince1970: 20)
    254         local.cells = try MovesCodec.encode(localCells)
    255         try ctx.save()
    256 
    257         let serverCells: [GridPosition: TimestampedCell] = [
    258             GridPosition(row: 0, col: 0): TimestampedCell(
    259                 letter: "A", mark: .none,
    260                 updatedAt: Date(timeIntervalSince1970: 10),
    261                 authorID: "alice"
    262             ),
    263         ]
    264         let (rec, value) = try record(
    265             in: ctx,
    266             authorID: "alice",
    267             deviceID: RecordSerializer.localDeviceID,
    268             cells: serverCells,
    269             updatedAt: Date(timeIntervalSince1970: 10)
    270         )
    271 
    272         RecordSerializer.applyMovesRecord(
    273             rec,
    274             value: value,
    275             to: ctx,
    276             localAuthorID: "alice"
    277         )
    278 
    279         let row = try #require(fetchAll(ctx).first)
    280         #expect(row.updatedAt == Date(timeIntervalSince1970: 20))
    281         let decoded = try MovesCodec.decode(row.cells ?? Data())
    282         #expect(decoded == localCells)
    283         #expect(row.ckSystemFields != nil)
    284     }
    285 
    286     @Test("Inbound other-device record preserves newer realtime cells")
    287     func inboundOtherDeviceRecordPreservesNewerRealtimeCells() throws {
    288         let persistence = makeTestPersistence()
    289         let ctx = persistence.viewContext
    290 
    291         let game = GameEntity(context: ctx)
    292         game.id = gameID
    293         game.ckRecordName = "game-\(gameID.uuidString)"
    294         game.title = ""
    295         game.puzzleSource = ""
    296         game.createdAt = Date(timeIntervalSince1970: 0)
    297         game.updatedAt = Date(timeIntervalSince1970: 20)
    298 
    299         let cachedCells: [GridPosition: TimestampedCell] = [
    300             GridPosition(row: 0, col: 0): TimestampedCell(
    301                 letter: "B", mark: .none,
    302                 updatedAt: Date(timeIntervalSince1970: 20),
    303                 authorID: "bob"
    304             ),
    305             GridPosition(row: 1, col: 1): TimestampedCell(
    306                 letter: "", mark: .none,
    307                 updatedAt: Date(timeIntervalSince1970: 30),
    308                 authorID: "bob"
    309             ),
    310         ]
    311         let cached = MovesEntity(context: ctx)
    312         cached.game = game
    313         cached.ckRecordName = RecordSerializer.recordName(
    314             forMovesInGame: gameID,
    315             authorID: "bob",
    316             deviceID: "phone"
    317         )
    318         cached.authorID = "bob"
    319         cached.deviceID = "phone"
    320         cached.updatedAt = Date(timeIntervalSince1970: 20)
    321         cached.cells = try MovesCodec.encode(cachedCells)
    322         try ctx.save()
    323 
    324         let serverCells: [GridPosition: TimestampedCell] = [
    325             GridPosition(row: 0, col: 0): TimestampedCell(
    326                 letter: "A", mark: .none,
    327                 updatedAt: Date(timeIntervalSince1970: 10),
    328                 authorID: "bob"
    329             ),
    330             GridPosition(row: 1, col: 1): TimestampedCell(
    331                 letter: "Z", mark: .none,
    332                 updatedAt: Date(timeIntervalSince1970: 15),
    333                 authorID: "bob"
    334             ),
    335             GridPosition(row: 2, col: 2): TimestampedCell(
    336                 letter: "C", mark: .none,
    337                 updatedAt: Date(timeIntervalSince1970: 40),
    338                 authorID: "bob"
    339             ),
    340         ]
    341         let (rec, value) = try record(
    342             in: ctx,
    343             authorID: "bob",
    344             deviceID: "phone",
    345             cells: serverCells,
    346             updatedAt: Date(timeIntervalSince1970: 10)
    347         )
    348 
    349         RecordSerializer.applyMovesRecord(
    350             rec,
    351             value: value,
    352             to: ctx,
    353             localAuthorID: "alice"
    354         )
    355 
    356         let row = try #require(fetchAll(ctx).first)
    357         #expect(row.updatedAt == Date(timeIntervalSince1970: 40))
    358         let decoded = try MovesCodec.decode(row.cells ?? Data())
    359         #expect(decoded[GridPosition(row: 0, col: 0)]?.letter == "B")
    360         #expect(decoded[GridPosition(row: 1, col: 1)]?.letter == "")
    361         #expect(decoded[GridPosition(row: 2, col: 2)]?.letter == "C")
    362     }
    363 
    364     @Test("Two devices for the same game produce two distinct rows")
    365     func twoDevicesYieldTwoRows() throws {
    366         let persistence = makeTestPersistence()
    367         let ctx = persistence.viewContext
    368 
    369         let (rec1, value1) = try record(
    370             in: ctx,
    371             authorID: "alice",
    372             deviceID: "phone",
    373             cells: [
    374                 GridPosition(row: 0, col: 0): TimestampedCell(
    375                     letter: "A", mark: .none,
    376                     updatedAt: Date(timeIntervalSince1970: 1),
    377                     authorID: "alice"
    378                 ),
    379             ],
    380             updatedAt: Date(timeIntervalSince1970: 1)
    381         )
    382         let (rec2, value2) = try record(
    383             in: ctx,
    384             authorID: "alice",
    385             deviceID: "ipad",
    386             cells: [
    387                 GridPosition(row: 1, col: 1): TimestampedCell(
    388                     letter: "B", mark: .none,
    389                     updatedAt: Date(timeIntervalSince1970: 2),
    390                     authorID: "alice"
    391                 ),
    392             ],
    393             updatedAt: Date(timeIntervalSince1970: 2)
    394         )
    395         RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx)
    396         RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx)
    397 
    398         let rows = fetchAll(ctx)
    399         #expect(rows.count == 2)
    400         #expect(Set(rows.compactMap(\.deviceID)) == ["phone", "ipad"])
    401     }
    402 
    403     @Test("Creates a stub GameEntity if none exists yet (lazy parent)")
    404     func lazyParentStub() throws {
    405         let persistence = makeTestPersistence()
    406         let ctx = persistence.viewContext
    407 
    408         let (rec, value) = try record(
    409             in: ctx,
    410             authorID: "alice",
    411             deviceID: "d1",
    412             cells: [:],
    413             updatedAt: Date(timeIntervalSince1970: 1)
    414         )
    415         RecordSerializer.applyMovesRecord(rec, value: value, to: ctx)
    416 
    417         let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    418         gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    419         let games = try ctx.fetch(gameReq)
    420         #expect(games.count == 1)
    421         #expect(games.first?.title == "")
    422         // The Moves row should be parented to the new stub.
    423         let movesRows = fetchAll(ctx)
    424         #expect(movesRows.count == 1)
    425         #expect(movesRows.first?.game?.objectID == games.first?.objectID)
    426     }
    427 
    428     @Test("Bumps the parent game's updatedAt when the record is fresher")
    429     func bumpsGameUpdatedAt() throws {
    430         let persistence = makeTestPersistence()
    431         let ctx = persistence.viewContext
    432 
    433         // Pre-create the game with an old timestamp so the bump is observable.
    434         let game = GameEntity(context: ctx)
    435         game.id = gameID
    436         game.ckRecordName = "game-\(gameID.uuidString)"
    437         game.title = ""
    438         game.puzzleSource = ""
    439         game.createdAt = Date(timeIntervalSince1970: 0)
    440         game.updatedAt = Date(timeIntervalSince1970: 0)
    441         try ctx.save()
    442 
    443         let (rec, value) = try record(
    444             in: ctx,
    445             authorID: "alice",
    446             deviceID: "d1",
    447             cells: [:],
    448             updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
    449         )
    450         RecordSerializer.applyMovesRecord(rec, value: value, to: ctx)
    451 
    452         #expect(game.updatedAt == value.updatedAt)
    453     }
    454 }