PlayerRosterTests.swift (11690B)
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 readAt: Date? = Date().addingTimeInterval(600) 67 ) { 68 let ctx = persistence.viewContext 69 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 70 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 71 guard let game = try? ctx.fetch(req).first else { return } 72 let player = PlayerEntity(context: ctx) 73 player.game = game 74 player.authorID = authorID 75 player.name = name 76 player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 77 player.updatedAt = updatedAt 78 player.readAt = readAt 79 if let selection { 80 player.selRow = NSNumber(value: selection.row) 81 player.selCol = NSNumber(value: selection.col) 82 player.selDir = NSNumber(value: selection.direction.rawValue) 83 } 84 try? ctx.save() 85 } 86 87 private func makeRoster( 88 gameID: UUID, 89 persistence: PersistenceController, 90 preferences: PlayerPreferences? = nil, 91 engagementStore: EngagementStore = EngagementStore() 92 ) -> PlayerRoster { 93 let prefs = preferences ?? PlayerPreferences( 94 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 95 ) 96 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 97 return PlayerRoster( 98 gameID: gameID, 99 authorIdentity: AuthorIdentity(testing: "_Local"), 100 preferences: prefs, 101 persistence: persistence, 102 container: container, 103 engagementStore: engagementStore 104 ) 105 } 106 107 // MARK: - Tests 108 109 @Test("Three remote participants get distinct colours, none matching local") 110 func threeParticipantsGetDistinctColors() async throws { 111 let (persistence, gameID) = try makePersistenceWithGame() 112 addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence) 113 114 let prefsDefaults = UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 115 let prefs = PlayerPreferences(local: prefsDefaults) 116 117 let roster = makeRoster( 118 gameID: gameID, 119 persistence: persistence, 120 preferences: prefs 121 ) 122 await roster.refresh() 123 124 #expect(roster.entries.count == 4) // 1 local + 3 remote 125 let allColorIDs = roster.entries.map { $0.color.id } 126 #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colours") 127 let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id } 128 #expect(!remoteColorIDs.contains(prefs.color.id), "remote colours should not collide with local") 129 } 130 131 @Test("Friend colour is derived per game (deliberately not stable across games)") 132 func friendColourIsPerGame() async throws { 133 let (p1, gameA) = try makePersistenceWithGame() 134 addMoves(authorIDs: ["_B"], gameID: gameA, persistence: p1) 135 let rosterA = makeRoster(gameID: gameA, persistence: p1) 136 await rosterA.refresh() 137 138 let (p2, gameB) = try makePersistenceWithGame() 139 addMoves(authorIDs: ["_B"], gameID: gameB, persistence: p2) 140 let rosterB = makeRoster(gameID: gameB, persistence: p2) 141 await rosterB.refresh() 142 143 // Each game derives the friend's colour from (authorID, gameID), 144 // de-conflicted against the local user's stable colour. The colour is a 145 // function of the game, so it is allowed to differ between games rather 146 // than acting as a fixed per-person badge. 147 func remoteColour(in roster: PlayerRoster, gameID: UUID) -> String? { 148 let localColorID = roster.entries.first { $0.isLocal }?.color.id ?? PlayerColor.blue.id 149 let expected = PlayerColor.assignedCompanions( 150 forSortedAuthorIDs: ["_B"], 151 inGame: gameID, 152 anchor: PlayerColor.color(for: localColorID) 153 ).first 154 #expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected?.id) 155 return roster.entries.first { $0.authorID == "_B" }?.color.id 156 } 157 158 #expect(remoteColour(in: rosterA, gameID: gameA) != nil) 159 #expect(remoteColour(in: rosterB, gameID: gameB) != nil) 160 } 161 162 @Test("Entry name comes from PlayerEntity when available") 163 func entryNameFromPlayerEntity() async throws { 164 let (persistence, gameID) = try makePersistenceWithGame() 165 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 166 addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence) 167 168 let roster = makeRoster(gameID: gameID, persistence: persistence) 169 await roster.refresh() 170 171 let remote = roster.entries.first { !$0.isLocal } 172 #expect(remote?.name == "Alice") 173 } 174 175 @Test("Entry name waits for PlayerEntity when no game-specific name has arrived") 176 func entryNameWaitsForPlayerEntity() async throws { 177 let (persistence, gameID) = try makePersistenceWithGame() 178 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 179 // No PlayerEntity for "_B". 180 181 let roster = makeRoster(gameID: gameID, persistence: persistence) 182 await roster.refresh() 183 184 let remote = roster.entries.first { !$0.isLocal } 185 #expect(remote?.name == "Waiting for player...") 186 } 187 188 @Test("Current user placeholder is not shown as a remote player") 189 func currentUserPlaceholderIsHidden() async throws { 190 let (persistence, gameID) = try makePersistenceWithGame() 191 addMoves( 192 authorIDs: [CKCurrentUserDefaultName, "_B"], 193 gameID: gameID, 194 persistence: persistence 195 ) 196 addPlayerEntity( 197 authorID: CKCurrentUserDefaultName, 198 name: "Ghost", 199 gameID: gameID, 200 persistence: persistence 201 ) 202 203 let roster = makeRoster(gameID: gameID, persistence: persistence) 204 await roster.refresh() 205 206 #expect(!roster.entries.contains { $0.authorID == CKCurrentUserDefaultName }) 207 #expect(roster.entries.map(\.authorID).contains("_B")) 208 } 209 210 @Test("Engagement selection overrides persisted cursor track") 211 func engagementSelectionOverridesPersistedCursorTrack() async throws { 212 let (persistence, gameID) = try makePersistenceWithGame() 213 let now = Date() 214 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 215 addPlayerEntity( 216 authorID: "_B", 217 name: "Alice", 218 gameID: gameID, 219 persistence: persistence, 220 selection: PlayerSelection(row: 1, col: 2, direction: .across), 221 updatedAt: now.addingTimeInterval(-5) 222 ) 223 let engagementStore = EngagementStore() 224 engagementStore.set(EngagementSelectionUpdate( 225 gameID: gameID, 226 authorID: "_B", 227 deviceID: "remote-device", 228 selection: PlayerSelection(row: 3, col: 4, direction: .down), 229 updatedAt: now 230 )) 231 232 let roster = makeRoster( 233 gameID: gameID, 234 persistence: persistence, 235 engagementStore: engagementStore 236 ) 237 await roster.refresh() 238 239 let selection = roster.remoteSelections["_B"] 240 #expect(selection?.row == 3) 241 #expect(selection?.col == 4) 242 #expect(selection?.direction == .down) 243 244 engagementStore.clear(gameID: gameID) 245 246 let fallback = roster.remoteSelections["_B"] 247 #expect(fallback?.row == 1) 248 #expect(fallback?.col == 2) 249 #expect(fallback?.direction == .across) 250 } 251 252 @Test("Cursor is hidden when the peer's lease has expired") 253 func cursorHiddenWhenLeaseExpired() async throws { 254 let (persistence, gameID) = try makePersistenceWithGame() 255 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 256 addPlayerEntity( 257 authorID: "_B", 258 name: "Alice", 259 gameID: gameID, 260 persistence: persistence, 261 selection: PlayerSelection(row: 1, col: 2, direction: .across), 262 updatedAt: Date(), 263 // Lapsed well past the presence grace so the peer reads as gone. 264 readAt: Date().addingTimeInterval(-(PeerPresence.presenceGrace + 60)) 265 ) 266 267 let roster = makeRoster(gameID: gameID, persistence: persistence) 268 await roster.refresh() 269 270 #expect(roster.remoteSelections["_B"] == nil, "an expired lease should hide the peer cursor") 271 } 272 273 @Test("Cursor stays visible for a present peer with a stale selection") 274 func cursorVisibleWhilePresentDespiteStaleSelection() async throws { 275 let (persistence, gameID) = try makePersistenceWithGame() 276 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 277 addPlayerEntity( 278 authorID: "_B", 279 name: "Alice", 280 gameID: gameID, 281 persistence: persistence, 282 selection: PlayerSelection(row: 1, col: 2, direction: .across), 283 updatedAt: Date().addingTimeInterval(-300), 284 readAt: Date().addingTimeInterval(600) 285 ) 286 287 let roster = makeRoster(gameID: gameID, persistence: persistence) 288 await roster.refresh() 289 290 let selection = roster.remoteSelections["_B"] 291 #expect(selection?.row == 1, "a present peer keeps their cursor even when the selection is old") 292 #expect(selection?.col == 2) 293 } 294 }