crossmate

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

PlayerRecordPresenceTests.swift (9027B)


      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: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"),
     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 snapshot = Data("baseline".utf8)
    195         let readAt = 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             readAt: readAt,
    203             sessionSnapshot: snapshot,
    204             zone: zoneID,
    205             systemFields: nil
    206         )
    207 
    208         var readCursors: [(UUID, Date, Data?)] = []
    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.sessionSnapshot == snapshot)
    222         #expect(readCursors.count == 1)
    223         #expect(readCursors.first?.0 == gameID)
    224         #expect(readCursors.first?.1 == readAt)
    225         #expect(readCursors.first?.2 == snapshot)
    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     private func fetchLocalPlayer(in ctx: NSManagedObjectContext) throws -> PlayerEntity? {
    233         let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    234         req.predicate = NSPredicate(
    235             format: "ckRecordName == %@",
    236             RecordSerializer.recordName(forPlayerInGame: gameID, authorID: localAuthorID)
    237         )
    238         req.fetchLimit = 1
    239         return try ctx.fetch(req).first
    240     }
    241 
    242     private func fetchRemotePlayer(in ctx: NSManagedObjectContext) throws -> PlayerEntity? {
    243         let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    244         req.predicate = NSPredicate(
    245             format: "ckRecordName == %@",
    246             RecordSerializer.recordName(forPlayerInGame: gameID, authorID: remoteAuthorID)
    247         )
    248         req.fetchLimit = 1
    249         return try ctx.fetch(req).first
    250     }
    251 }