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