GamePlayerColorStoreTests.swift (8173B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 @Suite("GamePlayerColorStore") 9 @MainActor 10 struct GamePlayerColorStoreTests { 11 12 private func makeStore() -> GamePlayerColorStore { 13 // Use a fresh UserDefaults suite per test to avoid cross-test pollution. 14 let suiteName = "test-\(UUID().uuidString)" 15 let defaults = UserDefaults(suiteName: suiteName)! 16 return GamePlayerColorStore(defaults: defaults) 17 } 18 19 // MARK: - ensureColor 20 21 @Test("ensureColor on empty store picks a palette entry and persists it") 22 func ensureColorPersists() { 23 let store = makeStore() 24 let gameID = UUID() 25 let color = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: []) 26 #expect(PlayerColor.palette.contains(color)) 27 // Second call returns the same colour. 28 let again = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: []) 29 #expect(again.id == color.id) 30 } 31 32 @Test("ensureColor never returns a reserved colour when alternatives exist") 33 func ensureColorRespectsReserved() { 34 let store = makeStore() 35 let gameID = UUID() 36 // Reserve all colours except the last one. 37 let reserved = Set(PlayerColor.palette.dropLast().map { $0.id }) 38 let color = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: reserved) 39 #expect(!reserved.contains(color.id)) 40 #expect(color.id == PlayerColor.palette.last!.id) 41 } 42 43 @Test("ensureColor with seeded RNG is deterministic") 44 func ensureColorSeededRNG() { 45 let store = makeStore() 46 let gameID = UUID() 47 var rng = SeededRNG(seed: 42) 48 let c1 = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [], using: &rng) 49 50 let store2 = makeStore() 51 var rng2 = SeededRNG(seed: 42) 52 let c2 = store2.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [], using: &rng2) 53 54 #expect(c1.id == c2.id) 55 } 56 57 @Test("ensureColor with palette fully saturated falls back to a palette entry") 58 func ensureColorFallback() { 59 let store = makeStore() 60 let gameID = UUID() 61 // Assign all palette colours to other authors for this game. 62 for (i, color) in PlayerColor.palette.enumerated() { 63 store.setColor(color, forGame: gameID, authorID: "_other\(i)") 64 } 65 // All colours are assigned; ensureColor should still return something. 66 let reserved = Set(PlayerColor.palette.map { $0.id }) 67 var rng = SeededRNG(seed: 99) 68 let fallback = store.ensureColor( 69 forGame: gameID, 70 authorID: "_new", 71 reservedColorIDs: reserved, 72 using: &rng 73 ) 74 #expect(PlayerColor.palette.contains(fallback)) 75 } 76 77 @Test("ensureColor persists without posting a change notification") 78 func ensureColorDoesNotPostChangeNotification() { 79 let store = makeStore() 80 let gameID = UUID() 81 var notificationCount = 0 82 let observer = NotificationCenter.default.addObserver( 83 forName: .gamePlayerColorsChanged, 84 object: nil, 85 queue: nil 86 ) { note in 87 guard let id = note.userInfo?["gameID"] as? UUID, id == gameID else { return } 88 notificationCount += 1 89 } 90 defer { NotificationCenter.default.removeObserver(observer) } 91 92 _ = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: []) 93 94 #expect(notificationCount == 0) 95 } 96 97 // MARK: - setColor / color round-trip 98 99 @Test("setColor and color round-trip correctly") 100 func setColorRoundTrip() { 101 let store = makeStore() 102 let gameID = UUID() 103 store.setColor(.red, forGame: gameID, authorID: "_A") 104 #expect(store.color(forGame: gameID, authorID: "_A")?.id == PlayerColor.red.id) 105 } 106 107 @Test("color returns nil for unknown authorID") 108 func colorNilForUnknown() { 109 let store = makeStore() 110 #expect(store.color(forGame: UUID(), authorID: "_unknown") == nil) 111 } 112 113 // MARK: - clearColors / clearColor 114 115 @Test("clearColors removes all entries for a game") 116 func clearColorsRemovesAll() { 117 let store = makeStore() 118 let gameID = UUID() 119 store.setColor(.red, forGame: gameID, authorID: "_A") 120 store.setColor(.blue, forGame: gameID, authorID: "_B") 121 store.clearColors(forGame: gameID) 122 #expect(store.color(forGame: gameID, authorID: "_A") == nil) 123 #expect(store.color(forGame: gameID, authorID: "_B") == nil) 124 } 125 126 @Test("clearColor removes only the targeted author") 127 func clearColorScopedToAuthor() { 128 let store = makeStore() 129 let gameID = UUID() 130 store.setColor(.red, forGame: gameID, authorID: "_A") 131 store.setColor(.blue, forGame: gameID, authorID: "_B") 132 store.clearColor(forGame: gameID, authorID: "_A") 133 #expect(store.color(forGame: gameID, authorID: "_A") == nil) 134 #expect(store.color(forGame: gameID, authorID: "_B")?.id == PlayerColor.blue.id) 135 } 136 137 @Test("clearColors for one game does not affect another game") 138 func clearColorsGameScoped() { 139 let store = makeStore() 140 let g1 = UUID() 141 let g2 = UUID() 142 store.setColor(.red, forGame: g1, authorID: "_A") 143 store.setColor(.blue, forGame: g2, authorID: "_A") 144 store.clearColors(forGame: g1) 145 #expect(store.color(forGame: g2, authorID: "_A")?.id == PlayerColor.blue.id) 146 } 147 148 // MARK: - assignedColorIDs 149 150 @Test("assignedColorIDs returns IDs of all assigned colours for a game") 151 func assignedColorIDsAll() { 152 let store = makeStore() 153 let gameID = UUID() 154 store.setColor(.red, forGame: gameID, authorID: "_A") 155 store.setColor(.blue, forGame: gameID, authorID: "_B") 156 let ids = store.assignedColorIDs(forGame: gameID, excludingAuthorID: nil) 157 #expect(ids == ["red", "blue"]) 158 } 159 160 @Test("assignedColorIDs excludes the specified authorID") 161 func assignedColorIDsExcludes() { 162 let store = makeStore() 163 let gameID = UUID() 164 store.setColor(.red, forGame: gameID, authorID: "_A") 165 store.setColor(.blue, forGame: gameID, authorID: "_B") 166 let ids = store.assignedColorIDs(forGame: gameID, excludingAuthorID: "_A") 167 #expect(ids == ["blue"]) 168 #expect(!ids.contains("red")) 169 } 170 171 // MARK: - Color cleanup on game deletion 172 173 @Test("Color store is cleared when a game is deleted via onGameDeleted") 174 func colorStoreCleanupOnGameDelete() throws { 175 let persistence = makeTestPersistence() 176 let ctx = persistence.viewContext 177 let gameID = UUID() 178 let entity = GameEntity(context: ctx) 179 entity.id = gameID 180 entity.title = "Test" 181 entity.puzzleSource = "" 182 entity.createdAt = Date() 183 entity.updatedAt = Date() 184 entity.ckRecordName = "game-\(gameID.uuidString)" 185 try ctx.save() 186 187 let colorStore = makeStore() 188 colorStore.setColor(.red, forGame: gameID, authorID: "_A") 189 colorStore.setColor(.blue, forGame: gameID, authorID: "_B") 190 191 // Use the production factory so this test fails if the real wiring 192 // ever drops the colour-cleanup branch. 193 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 194 let syncEngine = SyncEngine(container: container, persistence: persistence) 195 let store = makeTestStore( 196 persistence: persistence, 197 onGameDeleted: AppServices.makeOnGameDeleted( 198 syncEngine: syncEngine, 199 colorStore: colorStore 200 ) 201 ) 202 203 try store.deleteGame(id: gameID) 204 205 #expect(colorStore.storedAuthorIDs(forGame: gameID).isEmpty) 206 } 207 } 208 209 // Minimal seeded RNG for deterministic tests. 210 private struct SeededRNG: RandomNumberGenerator { 211 private var state: UInt64 212 213 init(seed: UInt64) { state = seed } 214 215 mutating func next() -> UInt64 { 216 state = state &* 6364136223846793005 &+ 1442695040888963407 217 return state 218 } 219 }