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 }