PlayerNamePublisherTests.swift (11412B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 @Suite("PlayerNamePublisher", .serialized) 9 @MainActor 10 struct PlayerNamePublisherTests { 11 12 // MARK: - Helpers 13 14 /// One enqueued name Decision as seen by the spy closure. 15 private struct EnqueuedDecision: Equatable { 16 let authorID: String 17 let name: String 18 let version: Int64 19 let zoneID: CKRecordZone.ID 20 let scope: Int16 21 } 22 23 @MainActor 24 private final class DecisionSpy { 25 private(set) var decisions: [EnqueuedDecision] = [] 26 func record(_ d: EnqueuedDecision) { decisions.append(d) } 27 } 28 29 /// A unique author per test keeps `NameVersionStore`'s UserDefaults state 30 /// from leaking between runs — every test starts at generation 0. 31 private func freshAuthorID() -> String { 32 "_local-\(UUID().uuidString)" 33 } 34 35 private func addFriend( 36 to persistence: PersistenceController, 37 authorID: String, 38 zoneName: String, 39 zoneOwnerName: String = CKCurrentUserDefaultName, 40 scope: Int16 = 0, 41 blocked: Bool = false 42 ) throws { 43 let ctx = persistence.viewContext 44 let friend = FriendEntity(context: ctx) 45 friend.authorID = authorID 46 friend.pairKey = "pair-\(authorID)" 47 friend.friendZoneName = zoneName 48 friend.friendZoneOwnerName = zoneOwnerName 49 friend.databaseScope = scope 50 friend.isBlocked = blocked 51 friend.createdAt = Date() 52 try ctx.save() 53 } 54 55 private func makeBroadcaster( 56 preferences: PlayerPreferences, 57 persistence: PersistenceController, 58 authorID: String, 59 spy: DecisionSpy, 60 enqueuePlayer: @escaping (UUID, String, String) async -> Void = { _, _, _ in } 61 ) -> PlayerNamePublisher { 62 PlayerNamePublisher( 63 preferences: preferences, 64 persistence: persistence, 65 authorIdentity: AuthorIdentity(testing: authorID), 66 enqueuePlayer: enqueuePlayer, 67 enqueueNameDecision: { authorID, name, version, zoneID, scope in 68 await spy.record(EnqueuedDecision( 69 authorID: authorID, 70 name: name, 71 version: version, 72 zoneID: zoneID, 73 scope: scope 74 )) 75 } 76 ) 77 } 78 79 private func fetchPlayerNames(authorID: String, in persistence: PersistenceController) -> [String] { 80 let ctx = persistence.container.newBackgroundContext() 81 return ctx.performAndWait { 82 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 83 req.predicate = NSPredicate(format: "authorID == %@", authorID) 84 let entities = (try? ctx.fetch(req)) ?? [] 85 return entities.compactMap(\.name) 86 } 87 } 88 89 // MARK: - Decision fan-out 90 91 @Test("broadcastName publishes to the account zone and every non-blocked friend zone") 92 func broadcastNameFansOutDecisions() async throws { 93 let persistence = makeTestPersistence() 94 let authorID = freshAuthorID() 95 try addFriend( 96 to: persistence, authorID: "_bob", 97 zoneName: "friend-bob", scope: 0 98 ) 99 try addFriend( 100 to: persistence, authorID: "_carol", 101 zoneName: "friend-carol", zoneOwnerName: "_carol-owner", scope: 1 102 ) 103 try addFriend( 104 to: persistence, authorID: "_mallory", 105 zoneName: "friend-mallory", blocked: true 106 ) 107 108 let prefs = PlayerPreferences( 109 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 110 ) 111 prefs.name = "Alice" 112 let spy = DecisionSpy() 113 let broadcaster = makeBroadcaster( 114 preferences: prefs, persistence: persistence, 115 authorID: authorID, spy: spy 116 ) 117 118 await broadcaster.broadcastName() 119 120 // Account zone copy plus one per non-blocked friend; blocked zone skipped. 121 #expect(spy.decisions.count == 3) 122 #expect(spy.decisions.allSatisfy { $0.authorID == authorID }) 123 #expect(spy.decisions.allSatisfy { $0.name == "Alice" }) 124 #expect(spy.decisions.allSatisfy { $0.version == 1 }) 125 let zoneNames = Set(spy.decisions.map(\.zoneID.zoneName)) 126 #expect(zoneNames == [ 127 RecordSerializer.accountZoneID.zoneName, "friend-bob", "friend-carol" 128 ]) 129 let carol = spy.decisions.first { $0.zoneID.zoneName == "friend-carol" } 130 #expect(carol?.scope == 1) 131 #expect(carol?.zoneID.ownerName == "_carol-owner") 132 // Fan-out no longer touches per-game Player rows. 133 #expect(fetchPlayerNames(authorID: authorID, in: persistence).isEmpty) 134 135 withExtendedLifetime(broadcaster) {} 136 } 137 138 @Test("each broadcast bumps the name generation") 139 func broadcastNameBumpsVersion() async throws { 140 let persistence = makeTestPersistence() 141 let authorID = freshAuthorID() 142 let prefs = PlayerPreferences( 143 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 144 ) 145 prefs.name = "Alice" 146 let spy = DecisionSpy() 147 let broadcaster = makeBroadcaster( 148 preferences: prefs, persistence: persistence, 149 authorID: authorID, spy: spy 150 ) 151 152 await broadcaster.broadcastName() 153 prefs.name = "Alicia" 154 await broadcaster.broadcastName() 155 156 #expect(spy.decisions.map(\.version) == [1, 2]) 157 #expect(spy.decisions.map(\.name) == ["Alice", "Alicia"]) 158 159 withExtendedLifetime(broadcaster) {} 160 } 161 162 @Test("broadcastName skips an empty or whitespace-only name") 163 func broadcastNameSkipsEmptyName() async throws { 164 let persistence = makeTestPersistence() 165 let authorID = freshAuthorID() 166 let prefs = PlayerPreferences( 167 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 168 ) 169 prefs.name = " " 170 let spy = DecisionSpy() 171 let broadcaster = makeBroadcaster( 172 preferences: prefs, persistence: persistence, 173 authorID: authorID, spy: spy 174 ) 175 176 await broadcaster.broadcastName() 177 178 #expect(spy.decisions.isEmpty) 179 // The skipped broadcast must not consume a generation. 180 #expect(NameVersionStore.current(authorID: authorID) == 0) 181 182 withExtendedLifetime(broadcaster) {} 183 } 184 185 // MARK: - Per-game snapshot (game open) 186 187 @Test("publishName writes only the requested shared game") 188 func publishNameWritesOnlyRequestedGame() async throws { 189 let p = makeTestPersistence() 190 let ctx = p.viewContext 191 let firstID = UUID() 192 let secondID = UUID() 193 for id in [firstID, secondID] { 194 let entity = GameEntity(context: ctx) 195 entity.id = id 196 entity.title = "Shared" 197 entity.puzzleSource = "## Metadata\nTitle: Shared\n" 198 entity.createdAt = Date() 199 entity.updatedAt = Date() 200 entity.ckRecordName = RecordSerializer.recordName(forGameID: id) 201 entity.ckShareRecordName = "share-\(id.uuidString)" 202 } 203 try ctx.save() 204 205 let authorID = freshAuthorID() 206 let prefs = PlayerPreferences( 207 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 208 ) 209 prefs.name = "Alice" 210 let spy = DecisionSpy() 211 var enqueued: [UUID] = [] 212 let broadcaster = makeBroadcaster( 213 preferences: prefs, persistence: p, 214 authorID: authorID, spy: spy, 215 enqueuePlayer: { gameID, _, _ in enqueued.append(gameID) } 216 ) 217 218 await broadcaster.publishName(for: secondID) 219 220 #expect(enqueued == [secondID]) 221 #expect(fetchPlayerNames(authorID: authorID, in: p) == ["Alice"]) 222 // Opening a game publishes no Decisions. 223 #expect(spy.decisions.isEmpty) 224 225 withExtendedLifetime(broadcaster) {} 226 } 227 228 @Test("publishName is a no-op for a non-shared game") 229 func publishNameSkipsNonSharedGame() async throws { 230 let p = makeTestPersistence() 231 let ctx = p.viewContext 232 let gameID = UUID() 233 let entity = GameEntity(context: ctx) 234 entity.id = gameID 235 entity.title = "Solo" 236 entity.puzzleSource = "" 237 entity.createdAt = Date() 238 entity.updatedAt = Date() 239 entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) 240 try ctx.save() 241 242 let authorID = freshAuthorID() 243 let prefs = PlayerPreferences( 244 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 245 ) 246 prefs.name = "Alice" 247 let spy = DecisionSpy() 248 let broadcaster = makeBroadcaster( 249 preferences: prefs, persistence: p, 250 authorID: authorID, spy: spy 251 ) 252 253 await broadcaster.publishName(for: gameID) 254 255 #expect(fetchPlayerNames(authorID: authorID, in: p).isEmpty) 256 257 withExtendedLifetime(broadcaster) {} 258 } 259 260 // MARK: - Debounce 261 262 @Test("Debounce coalesces two rapid name changes into one fan-out with the final name") 263 func debounceCoalescesPair() async throws { 264 let persistence = makeTestPersistence() 265 let authorID = freshAuthorID() 266 let prefs = PlayerPreferences( 267 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 268 ) 269 let spy = DecisionSpy() 270 let broadcaster = makeBroadcaster( 271 preferences: prefs, persistence: persistence, 272 authorID: authorID, spy: spy 273 ) 274 275 let fanOuts = FanOutSpy() 276 broadcaster.onFanOutForTesting = { name in fanOuts.record(name) } 277 278 // Allow the observation task to make its first withObservationTracking 279 // registration before we mutate any values. 280 await Task.yield() 281 282 // First change — debounce timer #1 starts. 283 prefs.name = "Alice" 284 await Task.yield() // observation task → scheduleDebounce 285 286 // Second change before timer fires — must cancel #1, start #2. 287 prefs.name = "Bob" 288 await Task.yield() // observation task → cancel #1, start #2 289 290 // Poll until the (single, hopefully) fan-out fires. Loose deadline so 291 // CI scheduler jitter doesn't fail the test. 292 let deadline = Date().addingTimeInterval(2.0) 293 while fanOuts.count == 0 && Date() < deadline { 294 try await Task.sleep(for: .milliseconds(20)) 295 } 296 297 // Grace period to catch a stray second fan-out from an uncancelled 298 // timer — the bug we're guarding against would surface here as count==2. 299 try await Task.sleep(for: .milliseconds(150)) 300 301 #expect(fanOuts.count == 1, "two rapid changes should debounce into one fan-out") 302 #expect(fanOuts.names.last == "Bob", "the final name should win") 303 // One coalesced rename → one generation, carrying the final name. 304 #expect(spy.decisions.map(\.version) == [1]) 305 #expect(spy.decisions.first?.name == "Bob") 306 307 // Keep broadcaster alive until assertions are done. 308 withExtendedLifetime(broadcaster) {} 309 } 310 } 311 312 @MainActor 313 private final class FanOutSpy { 314 private(set) var names: [String] = [] 315 var count: Int { names.count } 316 func record(_ name: String) { names.append(name) } 317 }