PlayerRosterTests.swift (15799B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 @Suite("PlayerRoster") 9 @MainActor 10 struct PlayerRosterTests { 11 12 // MARK: - Helpers 13 14 private func makePersistenceWithGame() throws -> (PersistenceController, UUID) { 15 let p = makeTestPersistence() 16 let ctx = p.viewContext 17 let gameID = UUID() 18 let entity = GameEntity(context: ctx) 19 entity.id = gameID 20 entity.title = "Test" 21 entity.puzzleSource = "" 22 entity.createdAt = Date() 23 entity.updatedAt = Date() 24 entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) 25 // No ckZoneName — prevents the roster from attempting a CloudKit share fetch. 26 try ctx.save() 27 return (p, gameID) 28 } 29 30 private func addMoves( 31 authorIDs: [String], 32 gameID: UUID, 33 persistence: PersistenceController 34 ) { 35 let ctx = persistence.viewContext 36 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 37 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 38 guard let game = try? ctx.fetch(req).first else { return } 39 for (i, authorID) in authorIDs.enumerated() { 40 // Each MovesEntity row is per-(game, author, device); for the 41 // roster's purposes the per-cell content is irrelevant — only the 42 // `authorID` matters. Synthesise a unique deviceID per row. 43 let deviceID = "test-device-\(i)" 44 let entity = MovesEntity(context: ctx) 45 entity.game = game 46 entity.authorID = authorID 47 entity.deviceID = deviceID 48 entity.cells = Data() 49 entity.updatedAt = Date() 50 entity.ckRecordName = RecordSerializer.recordName( 51 forMovesInGame: gameID, 52 authorID: authorID, 53 deviceID: deviceID 54 ) 55 } 56 try? ctx.save() 57 } 58 59 private func addPlayerEntity( 60 authorID: String, 61 name: String, 62 gameID: UUID, 63 persistence: PersistenceController, 64 selection: PlayerSelection? = nil, 65 updatedAt: Date = Date(), 66 presenceUntil: Date? = Date().addingTimeInterval(600), 67 timeLog: TimeLog? = nil 68 ) { 69 let ctx = persistence.viewContext 70 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 71 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 72 guard let game = try? ctx.fetch(req).first else { return } 73 let player = PlayerEntity(context: ctx) 74 player.game = game 75 player.authorID = authorID 76 player.name = name 77 player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 78 player.updatedAt = updatedAt 79 player.presenceUntil = presenceUntil 80 if let timeLog { 81 player.timeLog = TimeLog.encode(timeLog) 82 } 83 if let selection { 84 player.selRow = NSNumber(value: selection.row) 85 player.selCol = NSNumber(value: selection.col) 86 player.selDir = NSNumber(value: selection.direction.rawValue) 87 } 88 try? ctx.save() 89 } 90 91 private func updateGame( 92 gameID: UUID, 93 persistence: PersistenceController, 94 completedAt: Date? = nil, 95 finalSolveSeconds: Int64? = nil 96 ) { 97 let ctx = persistence.viewContext 98 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 99 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 100 guard let game = try? ctx.fetch(req).first else { return } 101 game.completedAt = completedAt 102 if let finalSolveSeconds { 103 game.finalSolveSeconds = NSNumber(value: finalSolveSeconds) 104 } 105 try? ctx.save() 106 } 107 108 private func makeRoster( 109 gameID: UUID, 110 persistence: PersistenceController, 111 preferences: PlayerPreferences? = nil, 112 engagementStore: EngagementStore = EngagementStore() 113 ) -> PlayerRoster { 114 let prefs = preferences ?? PlayerPreferences( 115 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 116 ) 117 let container = CloudContainer.container 118 return PlayerRoster( 119 gameID: gameID, 120 authorIdentity: AuthorIdentity(testing: "_Local"), 121 preferences: prefs, 122 persistence: persistence, 123 container: container, 124 engagementStore: engagementStore 125 ) 126 } 127 128 // MARK: - Tests 129 130 @Test("Three remote participants get distinct colours, none matching local") 131 func threeParticipantsGetDistinctColors() async throws { 132 let (persistence, gameID) = try makePersistenceWithGame() 133 addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence) 134 135 let prefsDefaults = UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 136 let prefs = PlayerPreferences(local: prefsDefaults) 137 138 let roster = makeRoster( 139 gameID: gameID, 140 persistence: persistence, 141 preferences: prefs 142 ) 143 await roster.refresh() 144 145 #expect(roster.entries.count == 4) // 1 local + 3 remote 146 let allColorIDs = roster.entries.map { $0.color.id } 147 #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colours") 148 let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id } 149 #expect(!remoteColorIDs.contains(prefs.color.id), "remote colours should not collide with local") 150 } 151 152 @Test("Friend colour is derived per game (deliberately not stable across games)") 153 func friendColourIsPerGame() async throws { 154 let (p1, gameA) = try makePersistenceWithGame() 155 addMoves(authorIDs: ["_B"], gameID: gameA, persistence: p1) 156 let rosterA = makeRoster(gameID: gameA, persistence: p1) 157 await rosterA.refresh() 158 159 let (p2, gameB) = try makePersistenceWithGame() 160 addMoves(authorIDs: ["_B"], gameID: gameB, persistence: p2) 161 let rosterB = makeRoster(gameID: gameB, persistence: p2) 162 await rosterB.refresh() 163 164 // Each game derives the friend's colour from (authorID, gameID), 165 // de-conflicted against the local user's stable colour. The colour is a 166 // function of the game, so it is allowed to differ between games rather 167 // than acting as a fixed per-person badge. 168 func remoteColour(in roster: PlayerRoster, gameID: UUID) -> String? { 169 let localColorID = roster.entries.first { $0.isLocal }?.color.id ?? PlayerColor.blue.id 170 let expected = PlayerColor.assignedCompanions( 171 forSortedAuthorIDs: ["_B"], 172 inGame: gameID, 173 anchor: PlayerColor.color(for: localColorID) 174 ).first 175 #expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected?.id) 176 return roster.entries.first { $0.authorID == "_B" }?.color.id 177 } 178 179 #expect(remoteColour(in: rosterA, gameID: gameA) != nil) 180 #expect(remoteColour(in: rosterB, gameID: gameB) != nil) 181 } 182 183 @Test("Entry name comes from PlayerEntity when available") 184 func entryNameFromPlayerEntity() async throws { 185 let (persistence, gameID) = try makePersistenceWithGame() 186 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 187 addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence) 188 189 let roster = makeRoster(gameID: gameID, persistence: persistence) 190 await roster.refresh() 191 192 let remote = roster.entries.first { !$0.isLocal } 193 #expect(remote?.name == "Alice") 194 } 195 196 @Test("Preload restores durable names before presentation") 197 func preloadRestoresDurableNames() async throws { 198 let (persistence, gameID) = try makePersistenceWithGame() 199 addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence) 200 201 let roster = makeRoster(gameID: gameID, persistence: persistence) 202 #expect(roster.entries.isEmpty) 203 204 await roster.preload() 205 206 #expect(roster.entries.map(\.authorID) == ["_Local", "_B"]) 207 #expect(roster.entries.first { $0.authorID == "_B" }?.name == "Alice") 208 } 209 210 @Test("Entry name waits for PlayerEntity when no game-specific name has arrived") 211 func entryNameWaitsForPlayerEntity() async throws { 212 let (persistence, gameID) = try makePersistenceWithGame() 213 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 214 // No PlayerEntity for "_B". 215 216 let roster = makeRoster(gameID: gameID, persistence: persistence) 217 await roster.refresh() 218 219 let remote = roster.entries.first { !$0.isLocal } 220 #expect(remote?.name == "Waiting for player…") 221 } 222 223 @Test("Player row without name still identifies a remote participant") 224 func unnamedPlayerStillIdentifiesParticipant() async throws { 225 let (persistence, gameID) = try makePersistenceWithGame() 226 addPlayerEntity(authorID: "_B", name: "", gameID: gameID, persistence: persistence) 227 228 let roster = makeRoster(gameID: gameID, persistence: persistence) 229 await roster.preload() 230 231 let remote = roster.entries.first { !$0.isLocal } 232 #expect(remote?.authorID == "_B") 233 #expect(remote?.name == "Waiting for player…") 234 } 235 236 @Test("Current user placeholder is not shown as a remote player") 237 func currentUserPlaceholderIsHidden() async throws { 238 let (persistence, gameID) = try makePersistenceWithGame() 239 addMoves( 240 authorIDs: [CKCurrentUserDefaultName, "_B"], 241 gameID: gameID, 242 persistence: persistence 243 ) 244 addPlayerEntity( 245 authorID: CKCurrentUserDefaultName, 246 name: "Ghost", 247 gameID: gameID, 248 persistence: persistence 249 ) 250 251 let roster = makeRoster(gameID: gameID, persistence: persistence) 252 await roster.refresh() 253 254 #expect(!roster.entries.contains { $0.authorID == CKCurrentUserDefaultName }) 255 #expect(roster.entries.map(\.authorID).contains("_B")) 256 } 257 258 @Test("Engagement selection overrides persisted cursor track") 259 func engagementSelectionOverridesPersistedCursorTrack() async throws { 260 let (persistence, gameID) = try makePersistenceWithGame() 261 let now = Date() 262 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 263 addPlayerEntity( 264 authorID: "_B", 265 name: "Alice", 266 gameID: gameID, 267 persistence: persistence, 268 selection: PlayerSelection(row: 1, col: 2, direction: .across), 269 updatedAt: now.addingTimeInterval(-5) 270 ) 271 let engagementStore = EngagementStore() 272 engagementStore.set(EngagementSelectionUpdate( 273 gameID: gameID, 274 authorID: "_B", 275 deviceID: "remote-device", 276 selection: PlayerSelection(row: 3, col: 4, direction: .down), 277 updatedAt: now 278 )) 279 280 let roster = makeRoster( 281 gameID: gameID, 282 persistence: persistence, 283 engagementStore: engagementStore 284 ) 285 await roster.refresh() 286 287 let selection = roster.remoteSelections["_B"] 288 #expect(selection?.row == 3) 289 #expect(selection?.col == 4) 290 #expect(selection?.direction == .down) 291 292 engagementStore.clear(gameID: gameID) 293 294 let fallback = roster.remoteSelections["_B"] 295 #expect(fallback?.row == 1) 296 #expect(fallback?.col == 2) 297 #expect(fallback?.direction == .across) 298 } 299 300 @Test("Cursor is hidden when the peer's lease has expired") 301 func cursorHiddenWhenLeaseExpired() async throws { 302 let (persistence, gameID) = try makePersistenceWithGame() 303 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 304 addPlayerEntity( 305 authorID: "_B", 306 name: "Alice", 307 gameID: gameID, 308 persistence: persistence, 309 selection: PlayerSelection(row: 1, col: 2, direction: .across), 310 updatedAt: Date(), 311 // Lapsed well past the presence grace so the peer reads as gone. 312 presenceUntil: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60)) 313 ) 314 315 let roster = makeRoster(gameID: gameID, persistence: persistence) 316 await roster.refresh() 317 318 #expect(roster.remoteSelections["_B"] == nil, "an expired lease should hide the peer cursor") 319 } 320 321 @Test("Cursor stays visible for a present peer with a stale selection") 322 func cursorVisibleWhilePresentDespiteStaleSelection() async throws { 323 let (persistence, gameID) = try makePersistenceWithGame() 324 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 325 addPlayerEntity( 326 authorID: "_B", 327 name: "Alice", 328 gameID: gameID, 329 persistence: persistence, 330 selection: PlayerSelection(row: 1, col: 2, direction: .across), 331 updatedAt: Date().addingTimeInterval(-300), 332 presenceUntil: Date().addingTimeInterval(600) 333 ) 334 335 let roster = makeRoster(gameID: gameID, persistence: persistence) 336 await roster.refresh() 337 338 let selection = roster.remoteSelections["_B"] 339 #expect(selection?.row == 1, "a present peer keeps their cursor even when the selection is old") 340 #expect(selection?.col == 2) 341 } 342 343 @Test("Solve time is read from the refreshed roster snapshot") 344 func solveTimeUsesRefreshedSnapshot() async throws { 345 let (persistence, gameID) = try makePersistenceWithGame() 346 let start = Date(timeIntervalSinceReferenceDate: 1_000) 347 var log = TimeLog() 348 log.open(deviceID: RecordSerializer.localDeviceID, at: start) 349 addPlayerEntity( 350 authorID: "_Local", 351 name: "Me", 352 gameID: gameID, 353 persistence: persistence, 354 timeLog: log 355 ) 356 357 let roster = makeRoster(gameID: gameID, persistence: persistence) 358 #expect(roster.solveTime(asOf: start.addingTimeInterval(60)) == 0) 359 360 await roster.refresh() 361 362 #expect(roster.solveTime(asOf: start.addingTimeInterval(60)) == 60) 363 #expect(roster.solveTime(asOf: start.addingTimeInterval(90)) == 90) 364 } 365 366 @Test("Solve time freezes at completion") 367 func solveTimeFreezesAtCompletion() async throws { 368 let (persistence, gameID) = try makePersistenceWithGame() 369 let start = Date(timeIntervalSinceReferenceDate: 2_000) 370 let completedAt = start.addingTimeInterval(75) 371 var log = TimeLog() 372 log.open(deviceID: RecordSerializer.localDeviceID, at: start) 373 addPlayerEntity( 374 authorID: "_Local", 375 name: "Me", 376 gameID: gameID, 377 persistence: persistence, 378 timeLog: log 379 ) 380 updateGame(gameID: gameID, persistence: persistence, completedAt: completedAt) 381 382 let roster = makeRoster(gameID: gameID, persistence: persistence) 383 await roster.refresh() 384 385 #expect(roster.solveTime(asOf: start.addingTimeInterval(300)) == 75) 386 } 387 388 @Test("Materialized archive solve time uses final seconds") 389 func materializedArchiveSolveTimeUsesFinalSeconds() async throws { 390 let (persistence, gameID) = try makePersistenceWithGame() 391 updateGame(gameID: gameID, persistence: persistence, finalSolveSeconds: 743) 392 393 let roster = makeRoster(gameID: gameID, persistence: persistence) 394 await roster.refresh() 395 396 #expect(roster.solveTime(asOf: Date(timeIntervalSinceReferenceDate: 3_000)) == 743) 397 } 398 }