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 }