PlayerRosterTests.swift (7541B)
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 ) { 65 let ctx = persistence.viewContext 66 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 67 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 68 guard let game = try? ctx.fetch(req).first else { return } 69 let player = PlayerEntity(context: ctx) 70 player.game = game 71 player.authorID = authorID 72 player.name = name 73 player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 74 player.updatedAt = Date() 75 try? ctx.save() 76 } 77 78 private func makeRoster( 79 gameID: UUID, 80 persistence: PersistenceController, 81 colorStore: GamePlayerColorStore? = nil, 82 preferences: PlayerPreferences? = nil 83 ) -> PlayerRoster { 84 let store = colorStore ?? GamePlayerColorStore( 85 defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")! 86 ) 87 let prefs = preferences ?? PlayerPreferences( 88 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 89 ) 90 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 91 return PlayerRoster( 92 gameID: gameID, 93 colorStore: store, 94 authorIdentity: AuthorIdentity(testing: "_Local"), 95 preferences: prefs, 96 persistence: persistence, 97 container: container 98 ) 99 } 100 101 // MARK: - Tests 102 103 @Test("Three remote participants are assigned distinct colors, none matching local") 104 func threeParticipantsGetDistinctColors() async throws { 105 let (persistence, gameID) = try makePersistenceWithGame() 106 addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence) 107 108 let colorStore = GamePlayerColorStore( 109 defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")! 110 ) 111 let prefsDefaults = UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 112 let prefs = PlayerPreferences(local: prefsDefaults) 113 114 let roster = makeRoster( 115 gameID: gameID, 116 persistence: persistence, 117 colorStore: colorStore, 118 preferences: prefs 119 ) 120 await roster.refresh() 121 122 #expect(roster.entries.count == 4) // 1 local + 3 remote 123 let allColorIDs = roster.entries.map { $0.color.id } 124 #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colors") 125 let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id } 126 #expect(!remoteColorIDs.contains(prefs.color.id), "remote colors should not collide with local") 127 } 128 129 @Test("Stale color entries are GC'd after refresh") 130 func staleColorEntriesAreGCd() async throws { 131 let (persistence, gameID) = try makePersistenceWithGame() 132 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 133 134 let colorStore = GamePlayerColorStore( 135 defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")! 136 ) 137 colorStore.setColor(.red, forGame: gameID, authorID: "_B") 138 colorStore.setColor(.green, forGame: gameID, authorID: "_Stale") 139 140 let roster = makeRoster(gameID: gameID, persistence: persistence, colorStore: colorStore) 141 await roster.refresh() 142 143 #expect(colorStore.color(forGame: gameID, authorID: "_B") != nil, "_B should be kept") 144 #expect(colorStore.color(forGame: gameID, authorID: "_Stale") == nil, "_Stale should be GC'd") 145 } 146 147 @Test("Entry name comes from PlayerEntity when available") 148 func entryNameFromPlayerEntity() async throws { 149 let (persistence, gameID) = try makePersistenceWithGame() 150 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 151 addPlayerEntity(authorID: "_B", name: "Alice", gameID: gameID, persistence: persistence) 152 153 let roster = makeRoster(gameID: gameID, persistence: persistence) 154 await roster.refresh() 155 156 let remote = roster.entries.first { !$0.isLocal } 157 #expect(remote?.name == "Alice") 158 } 159 160 @Test("Entry name waits for PlayerEntity when no game-specific name has arrived") 161 func entryNameWaitsForPlayerEntity() async throws { 162 let (persistence, gameID) = try makePersistenceWithGame() 163 addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) 164 // No PlayerEntity for "_B". 165 166 let roster = makeRoster(gameID: gameID, persistence: persistence) 167 await roster.refresh() 168 169 let remote = roster.entries.first { !$0.isLocal } 170 #expect(remote?.name == "Waiting for player...") 171 } 172 173 @Test("Current user placeholder is not shown as a remote player") 174 func currentUserPlaceholderIsHidden() async throws { 175 let (persistence, gameID) = try makePersistenceWithGame() 176 addMoves( 177 authorIDs: [CKCurrentUserDefaultName, "_B"], 178 gameID: gameID, 179 persistence: persistence 180 ) 181 addPlayerEntity( 182 authorID: CKCurrentUserDefaultName, 183 name: "Ghost", 184 gameID: gameID, 185 persistence: persistence 186 ) 187 188 let roster = makeRoster(gameID: gameID, persistence: persistence) 189 await roster.refresh() 190 191 #expect(!roster.entries.contains { $0.authorID == CKCurrentUserDefaultName }) 192 #expect(roster.entries.map(\.authorID).contains("_B")) 193 } 194 }