crossmate

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

PlayerRecordPresenceTests.swift (12806B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Pins down the inbound Player-record callback split: first sight of a
      9 /// collaborator still drives friendship bootstrap, while later presence
     10 /// updates from known collaborators wake live engagement.
     11 @Suite("Player record presence")
     12 @MainActor
     13 struct PlayerRecordPresenceTests {
     14 
     15     private let gameID = UUID(uuidString: "ABCD1234-0000-0000-0000-111122223333")!
     16     private let localAuthorID = "local-author"
     17     private let remoteAuthorID = "remote-author"
     18 
     19     private var zoneID: CKRecordZone.ID {
     20         RecordSerializer.zoneID(for: gameID)
     21     }
     22 
     23     private func makeEngine(persistence: PersistenceController) -> SyncEngine {
     24         SyncEngine(
     25             container: CloudContainer.container,
     26             persistence: persistence
     27         )
     28     }
     29 
     30     private func makeGame(in ctx: NSManagedObjectContext) throws -> GameEntity {
     31         let game = GameEntity(context: ctx)
     32         game.id = gameID
     33         game.title = "Presence Test"
     34         game.puzzleSource = ""
     35         game.createdAt = Date(timeIntervalSince1970: 1)
     36         game.updatedAt = Date(timeIntervalSince1970: 1)
     37         game.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
     38         game.ckZoneName = zoneID.zoneName
     39         game.databaseScope = 0
     40         try ctx.save()
     41         return game
     42     }
     43 
     44     private func makeExistingRemotePlayer(
     45         in ctx: NSManagedObjectContext,
     46         game: GameEntity,
     47         updatedAt: Date,
     48         selection: PlayerSelection?
     49     ) throws {
     50         let player = PlayerEntity(context: ctx)
     51         player.game = game
     52         player.ckRecordName = RecordSerializer.recordName(
     53             forPlayerInGame: gameID,
     54             authorID: remoteAuthorID
     55         )
     56         player.authorID = remoteAuthorID
     57         player.name = "Remote"
     58         player.updatedAt = updatedAt
     59         if let selection {
     60             player.selRow = NSNumber(value: selection.row)
     61             player.selCol = NSNumber(value: selection.col)
     62             player.selDir = NSNumber(value: selection.direction.rawValue)
     63         }
     64         try ctx.save()
     65     }
     66 
     67     private func makeExistingLocalPlayer(
     68         in ctx: NSManagedObjectContext,
     69         game: GameEntity,
     70         updatedAt: Date
     71     ) throws {
     72         let player = PlayerEntity(context: ctx)
     73         player.game = game
     74         player.ckRecordName = RecordSerializer.recordName(
     75             forPlayerInGame: gameID,
     76             authorID: localAuthorID
     77         )
     78         player.authorID = localAuthorID
     79         player.name = "Local"
     80         player.updatedAt = updatedAt
     81         try ctx.save()
     82     }
     83 
     84     private func remotePlayerRecord(
     85         updatedAt: Date,
     86         selection: PlayerSelection?
     87     ) -> CKRecord {
     88         RecordSerializer.playerRecord(
     89             gameID: gameID,
     90             authorID: remoteAuthorID,
     91             name: "Remote",
     92             updatedAt: updatedAt,
     93             selection: selection,
     94             zone: zoneID,
     95             systemFields: nil
     96         )
     97     }
     98 
     99     @Test("Existing remote selection move fires presence change only")
    100     func existingRemoteSelectionMoveFiresPresenceChangeOnly() throws {
    101         let persistence = makeTestPersistence()
    102         let ctx = persistence.viewContext
    103         let game = try makeGame(in: ctx)
    104         try makeExistingRemotePlayer(
    105             in: ctx,
    106             game: game,
    107             updatedAt: Date(timeIntervalSince1970: 10),
    108             selection: PlayerSelection(row: 1, col: 2, direction: .across)
    109         )
    110         let engine = makeEngine(persistence: persistence)
    111 
    112         var firstTimeGameIDs: [UUID] = []
    113         var presenceGameIDs: [UUID] = []
    114         let record = remotePlayerRecord(
    115             updatedAt: Date(timeIntervalSince1970: 20),
    116             selection: PlayerSelection(row: 2, col: 2, direction: .down)
    117         )
    118 
    119         engine.applyPlayerRecord(
    120             record,
    121             in: ctx,
    122             localAuthorID: localAuthorID,
    123             onFirstTime: { firstTimeGameIDs.append($0) },
    124             onPresenceChange: { presenceGameIDs.append($0) },
    125             onReadCursor: { _, _, _ in }
    126         )
    127 
    128         #expect(firstTimeGameIDs.isEmpty)
    129         #expect(presenceGameIDs == [gameID])
    130 
    131         let fetched = try fetchRemotePlayer(in: ctx)
    132         let row = try #require(fetched)
    133         #expect(row.selRow?.intValue == 2)
    134         #expect(row.selCol?.intValue == 2)
    135         #expect(row.selDir?.intValue == Puzzle.Direction.down.rawValue)
    136     }
    137 
    138     @Test("Existing remote selection clear fires presence change only")
    139     func existingRemoteSelectionClearFiresPresenceChangeOnly() throws {
    140         let persistence = makeTestPersistence()
    141         let ctx = persistence.viewContext
    142         let game = try makeGame(in: ctx)
    143         try makeExistingRemotePlayer(
    144             in: ctx,
    145             game: game,
    146             updatedAt: Date(timeIntervalSince1970: 10),
    147             selection: PlayerSelection(row: 1, col: 2, direction: .across)
    148         )
    149         let engine = makeEngine(persistence: persistence)
    150 
    151         var firstTimeGameIDs: [UUID] = []
    152         var presenceGameIDs: [UUID] = []
    153         let record = remotePlayerRecord(
    154             updatedAt: Date(timeIntervalSince1970: 20),
    155             selection: nil
    156         )
    157 
    158         engine.applyPlayerRecord(
    159             record,
    160             in: ctx,
    161             localAuthorID: localAuthorID,
    162             onFirstTime: { firstTimeGameIDs.append($0) },
    163             onPresenceChange: { presenceGameIDs.append($0) },
    164             onReadCursor: { _, _, _ in }
    165         )
    166 
    167         #expect(firstTimeGameIDs.isEmpty)
    168         #expect(presenceGameIDs == [gameID])
    169 
    170         let fetched = try fetchRemotePlayer(in: ctx)
    171         let row = try #require(fetched)
    172         #expect(row.selRow == nil)
    173         #expect(row.selCol == nil)
    174         #expect(row.selDir == nil)
    175     }
    176 
    177     @Test("Stale-updatedAt local record still converges the catch-up baseline")
    178     func staleLocalRecordStillAdoptsSessionSnapshot() throws {
    179         let persistence = makeTestPersistence()
    180         let ctx = persistence.viewContext
    181         let game = try makeGame(in: ctx)
    182         // This device already holds a fresher local cursor write on the
    183         // account's own Player row (e.g. a live selection move).
    184         try makeExistingLocalPlayer(
    185             in: ctx,
    186             game: game,
    187             updatedAt: Date(timeIntervalSince1970: 20)
    188         )
    189         let engine = makeEngine(persistence: persistence)
    190 
    191         // A sibling committed the catch-up baseline on leave: it ships the
    192         // snapshot + read cursor but with a stale `updatedAt`, because leaving
    193         // advances the read cursor and snapshot without bumping `updatedAt`.
    194         let baseline = Date(timeIntervalSince1970: 12)
    195         let presenceUntil = Date(timeIntervalSince1970: 15)
    196         let record = RecordSerializer.playerRecord(
    197             gameID: gameID,
    198             authorID: localAuthorID,
    199             name: "Local",
    200             updatedAt: Date(timeIntervalSince1970: 10),
    201             selection: PlayerSelection(row: 5, col: 5, direction: .across),
    202             presenceUntil: presenceUntil,
    203             viewedAt: baseline,
    204             zone: zoneID,
    205             systemFields: nil
    206         )
    207 
    208         var readCursors: [(UUID, Date, Date?)] = []
    209         engine.applyPlayerRecord(
    210             record,
    211             in: ctx,
    212             localAuthorID: localAuthorID,
    213             onFirstTime: { _ in },
    214             onPresenceChange: { _ in },
    215             onReadCursor: { readCursors.append(($0, $1, $2)) }
    216         )
    217 
    218         // The baseline converges despite the stale `updatedAt`...
    219         let fetched = try fetchLocalPlayer(in: ctx)
    220         let row = try #require(fetched)
    221         #expect(row.viewedAt == baseline)
    222         #expect(readCursors.count == 1)
    223         #expect(readCursors.first?.0 == gameID)
    224         #expect(readCursors.first?.1 == presenceUntil)
    225         #expect(readCursors.first?.2 == baseline)
    226         // ...while the cursor LWW still shields the fresher local selection from
    227         // the stale record.
    228         #expect(row.selRow == nil)
    229         #expect(row.updatedAt == Date(timeIntervalSince1970: 20))
    230     }
    231 
    232     @Test("A sibling read watermark clears the visible Chronicle projection")
    233     func siblingReadThroughMirrorsToChronicle() throws {
    234         let persistence = makeTestPersistence()
    235         let ctx = persistence.viewContext
    236         let game = try makeGame(in: ctx)
    237         let latest = Date(timeIntervalSince1970: 12)
    238         game.latestOtherMoveAt = latest
    239         try makeExistingLocalPlayer(
    240             in: ctx,
    241             game: game,
    242             updatedAt: Date(timeIntervalSince1970: 10)
    243         )
    244 
    245         let chronicle = GameEntity(context: ctx)
    246         chronicle.id = Archive.archiveGameID(for: gameID)
    247         chronicle.ckRecordName = Archive.recordName(forOriginalGameID: gameID)
    248         chronicle.title = "Presence Test Chronicle"
    249         chronicle.puzzleSource = ""
    250         chronicle.createdAt = Date(timeIntervalSince1970: 1)
    251         chronicle.updatedAt = Date(timeIntervalSince1970: 1)
    252         chronicle.latestOtherMoveAt = latest
    253         chronicle.readThroughAt = Date(timeIntervalSince1970: 5)
    254         try ctx.save()
    255 
    256         let record = RecordSerializer.playerRecord(
    257             gameID: gameID,
    258             authorID: localAuthorID,
    259             name: "Local",
    260             updatedAt: Date(timeIntervalSince1970: 20),
    261             selection: nil,
    262             presenceUntil: Date(timeIntervalSince1970: 20),
    263             readThrough: latest,
    264             zone: zoneID,
    265             systemFields: nil
    266         )
    267 
    268         let engine = makeEngine(persistence: persistence)
    269         engine.applyPlayerRecord(
    270             record,
    271             in: ctx,
    272             localAuthorID: localAuthorID,
    273             onFirstTime: { _ in },
    274             onPresenceChange: { _ in },
    275             onReadCursor: { _, _, _ in }
    276         )
    277 
    278         #expect(game.readThroughAt == latest)
    279         #expect(chronicle.readThroughAt == latest)
    280     }
    281 
    282     @Test("Partial player record without name still applies other fields")
    283     func partialPlayerRecordWithoutNameStillAppliesFields() throws {
    284         let persistence = makeTestPersistence()
    285         let ctx = persistence.viewContext
    286         let game = try makeGame(in: ctx)
    287         try makeExistingRemotePlayer(
    288             in: ctx,
    289             game: game,
    290             updatedAt: Date(timeIntervalSince1970: 10),
    291             selection: nil
    292         )
    293         let engine = makeEngine(persistence: persistence)
    294 
    295         let recordName = RecordSerializer.recordName(
    296             forPlayerInGame: gameID,
    297             authorID: remoteAuthorID
    298         )
    299         let record = CKRecord(
    300             recordType: "Player",
    301             recordID: CKRecord.ID(recordName: recordName, zoneID: zoneID)
    302         )
    303         // authorID is recovered from the record name, not a field.
    304         record["updatedAt"] = Date(timeIntervalSince1970: 20) as CKRecordValue
    305         record["selRow"] = Int64(3) as CKRecordValue
    306         record["selCol"] = Int64(4) as CKRecordValue
    307         record["selDir"] = Int64(Puzzle.Direction.down.rawValue) as CKRecordValue
    308 
    309         var firstTimeGameIDs: [UUID] = []
    310         var presenceGameIDs: [UUID] = []
    311         engine.applyPlayerRecord(
    312             record,
    313             in: ctx,
    314             localAuthorID: localAuthorID,
    315             onFirstTime: { firstTimeGameIDs.append($0) },
    316             onPresenceChange: { presenceGameIDs.append($0) },
    317             onReadCursor: { _, _, _ in }
    318         )
    319 
    320         #expect(firstTimeGameIDs.isEmpty)
    321         #expect(presenceGameIDs == [gameID])
    322 
    323         let fetched = try fetchRemotePlayer(in: ctx)
    324         let row = try #require(fetched)
    325         #expect(row.name == "Remote")
    326         #expect(row.updatedAt == Date(timeIntervalSince1970: 20))
    327         #expect(row.selRow?.intValue == 3)
    328         #expect(row.selCol?.intValue == 4)
    329         #expect(row.selDir?.intValue == Puzzle.Direction.down.rawValue)
    330     }
    331 
    332     private func fetchLocalPlayer(in ctx: NSManagedObjectContext) throws -> PlayerEntity? {
    333         let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    334         req.predicate = NSPredicate(
    335             format: "ckRecordName == %@",
    336             RecordSerializer.recordName(forPlayerInGame: gameID, authorID: localAuthorID)
    337         )
    338         req.fetchLimit = 1
    339         return try ctx.fetch(req).first
    340     }
    341 
    342     private func fetchRemotePlayer(in ctx: NSManagedObjectContext) throws -> PlayerEntity? {
    343         let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    344         req.predicate = NSPredicate(
    345             format: "ckRecordName == %@",
    346             RecordSerializer.recordName(forPlayerInGame: gameID, authorID: remoteAuthorID)
    347         )
    348         req.fetchLimit = 1
    349         return try ctx.fetch(req).first
    350     }
    351 }