PlayerSelectionPublisherTests.swift (19252B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 @Suite("PlayerSelectionPublisher", .serialized) 8 @MainActor 9 struct PlayerSelectionPublisherTests { 10 11 /// Captures the `(gameID, authorID)` sink fan-outs. 12 actor Capture { 13 private(set) var notifications: [(UUID, String)] = [] 14 var count: Int { notifications.count } 15 func append(_ gameID: UUID, _ authorID: String) { 16 notifications.append((gameID, authorID)) 17 } 18 } 19 20 /// Mutable flag for tests that need to toggle the peer-presence 21 /// predicate mid-test. 22 actor PresenceFlag { 23 private(set) var value: Bool = false 24 func set(_ newValue: Bool) { value = newValue } 25 } 26 27 /// Records the durations handed to the injected sleep. 28 actor DurationRecorder { 29 private(set) var durations: [Duration] = [] 30 func record(_ duration: Duration) { durations.append(duration) } 31 } 32 33 /// Records the game IDs the interval provider is consulted for. 34 actor GameIDRecorder { 35 private(set) var ids: [UUID] = [] 36 func record(_ id: UUID) { ids.append(id) } 37 } 38 39 private func makePersistenceWithGame() throws -> (PersistenceController, UUID) { 40 let persistence = makeTestPersistence() 41 let context = persistence.viewContext 42 let gameID = UUID() 43 let entity = GameEntity(context: context) 44 entity.id = gameID 45 entity.title = "Test" 46 entity.puzzleSource = "" 47 entity.createdAt = Date() 48 entity.updatedAt = Date() 49 entity.ckRecordName = "game-\(gameID.uuidString)" 50 try context.save() 51 return (persistence, gameID) 52 } 53 54 @Test("Publish + flush writes the PlayerEntity row and notifies the sink") 55 func publishWritesEntity() async throws { 56 let (persistence, gameID) = try makePersistenceWithGame() 57 let capture = Capture() 58 let publisher = PlayerSelectionPublisher( 59 debounceInterval: { _ in .seconds(10) }, 60 persistence: persistence, 61 sink: { id, author, _ in await capture.append(id, author) } 62 ) 63 64 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 65 await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down)) 66 await publisher.flush() 67 68 let count = await capture.count 69 #expect(count == 1) 70 let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) 71 #expect(values?.selRow == 3) 72 #expect(values?.selCol == 4) 73 #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue)) 74 #expect(values?.ckRecordName == "player-\(gameID.uuidString)-alice") 75 } 76 77 @Test("Repeated identical selections don't refire the sink") 78 func dedupesIdenticalSelections() async throws { 79 let (persistence, gameID) = try makePersistenceWithGame() 80 let capture = Capture() 81 let publisher = PlayerSelectionPublisher( 82 debounceInterval: { _ in .milliseconds(40) }, 83 persistence: persistence, 84 sink: { id, author, _ in await capture.append(id, author) } 85 ) 86 87 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 88 let selection = PlayerSelection(row: 0, col: 0, direction: .across) 89 await publisher.publish(selection) 90 try await Task.sleep(for: .milliseconds(120)) 91 await publisher.publish(selection) 92 await publisher.publish(selection) 93 try await Task.sleep(for: .milliseconds(120)) 94 95 let count = await capture.count 96 #expect(count == 1) 97 } 98 99 @Test("Rapid distinct selections coalesce into a single trailing flush") 100 func debounceCoalescesRapidPublishes() async throws { 101 let (persistence, gameID) = try makePersistenceWithGame() 102 let capture = Capture() 103 let manualSleep = ManualDebounceSleep() 104 let publisher = PlayerSelectionPublisher( 105 debounceInterval: { _ in .seconds(10) }, 106 persistence: persistence, 107 sink: { id, author, _ in await capture.append(id, author) }, 108 sleep: manualSleep.sleepFn 109 ) 110 111 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 112 for col in 0...4 { 113 await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across)) 114 } 115 // Each publish cancelled the prior debounce task; only the latest is 116 // live. Releasing the manual sleep wakes every captured continuation 117 // — the cancelled tasks observe Task.isCancelled and exit without 118 // flushing; the live task proceeds to flush with the final value. 119 manualSleep.releaseAll() 120 try await waitForCount(1, capture: capture) 121 122 let count = await capture.count 123 #expect(count == 1) 124 let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) 125 #expect(values?.selCol == 4) 126 } 127 128 @Test("Debounce interval is sourced from the provider, keyed by the active game") 129 func debounceIntervalComesFromProvider() async throws { 130 let (persistence, gameID) = try makePersistenceWithGame() 131 let capture = Capture() 132 let sleepRecorder = DurationRecorder() 133 let providerGames = GameIDRecorder() 134 let publisher = PlayerSelectionPublisher( 135 debounceInterval: { id in 136 await providerGames.record(id) 137 return .milliseconds(1234) 138 }, 139 persistence: persistence, 140 sink: { id, author, _ in await capture.append(id, author) }, 141 sleep: { duration in await sleepRecorder.record(duration) } 142 ) 143 144 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 145 await publisher.publish(PlayerSelection(row: 0, col: 1, direction: .across)) 146 try await waitForCount(1, capture: capture) 147 148 // The provider was asked for this game, and its returned interval is 149 // exactly what the debounce slept on. 150 #expect(await providerGames.ids == [gameID]) 151 #expect(await sleepRecorder.durations == [.milliseconds(1234)]) 152 } 153 154 private func waitForCount( 155 _ expected: Int, 156 capture: Capture, 157 timeout: Duration = .seconds(5) 158 ) async throws { 159 let deadline = ContinuousClock.now.advanced(by: timeout) 160 while await capture.count != expected, 161 ContinuousClock.now < deadline { 162 try await Task.sleep(for: .milliseconds(20)) 163 } 164 } 165 166 @Test("Clear nils the selection fields and notifies the sink") 167 func clearWritesNilSelection() async throws { 168 let (persistence, gameID) = try makePersistenceWithGame() 169 let capture = Capture() 170 let publisher = PlayerSelectionPublisher( 171 debounceInterval: { _ in .seconds(10) }, 172 persistence: persistence, 173 sink: { id, author, _ in await capture.append(id, author) } 174 ) 175 176 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 177 await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down)) 178 await publisher.flush() 179 await publisher.clear() 180 181 let count = await capture.count 182 #expect(count == 2) 183 let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) 184 #expect(values != nil) 185 #expect(values?.selRow == nil) 186 #expect(values?.selCol == nil) 187 #expect(values?.selDir == nil) 188 } 189 190 @Test("Clear is a no-op when nothing has been published yet") 191 func clearWithoutPriorPublishIsNoOp() async throws { 192 let (persistence, gameID) = try makePersistenceWithGame() 193 let capture = Capture() 194 let publisher = PlayerSelectionPublisher( 195 debounceInterval: { _ in .seconds(10) }, 196 persistence: persistence, 197 sink: { id, author, _ in await capture.append(id, author) } 198 ) 199 200 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 201 await publisher.clear() 202 203 let count = await capture.count 204 #expect(count == 0) 205 } 206 207 @Test("Publish without begin is silently ignored") 208 func publishBeforeBeginIsDropped() async throws { 209 let (persistence, _) = try makePersistenceWithGame() 210 let capture = Capture() 211 let publisher = PlayerSelectionPublisher( 212 debounceInterval: { _ in .milliseconds(40) }, 213 persistence: persistence, 214 sink: { id, author, _ in await capture.append(id, author) } 215 ) 216 217 await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across)) 218 await publisher.flush() 219 220 let count = await capture.count 221 #expect(count == 0) 222 } 223 224 @Test("Updates an existing PlayerEntity rather than creating a duplicate") 225 func updatesExistingEntity() async throws { 226 let (persistence, gameID) = try makePersistenceWithGame() 227 // Pre-seed a PlayerEntity (e.g. from a name broadcast or remote sync). 228 let context = persistence.viewContext 229 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 230 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 231 let game = try #require(try context.fetch(gameReq).first) 232 let preexisting = PlayerEntity(context: context) 233 preexisting.game = game 234 preexisting.authorID = "alice" 235 preexisting.name = "Alice" 236 preexisting.updatedAt = Date() 237 preexisting.ckRecordName = "player-\(gameID.uuidString)-alice" 238 try context.save() 239 240 let publisher = PlayerSelectionPublisher( 241 debounceInterval: { _ in .seconds(10) }, 242 persistence: persistence, 243 sink: { _, _, _ in } 244 ) 245 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 246 await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across)) 247 await publisher.flush() 248 249 let rows = fetchAllPlayers(gameID: gameID, persistence: persistence) 250 #expect(rows.count == 1) 251 #expect(rows.first?.name == "Alice") // pre-existing field preserved 252 #expect(rows.first?.selRow == 5) 253 #expect(rows.first?.selCol == 6) 254 } 255 256 @Test("Gate denied: Core Data row still updated but sink is skipped") 257 func gateDeniedSkipsSink() async throws { 258 let (persistence, gameID) = try makePersistenceWithGame() 259 let capture = Capture() 260 let publisher = PlayerSelectionPublisher( 261 debounceInterval: { _ in .seconds(10) }, 262 persistence: persistence, 263 sink: { id, author, _ in await capture.append(id, author) }, 264 peerPresent: { _ in false } 265 ) 266 267 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 268 await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down)) 269 await publisher.flush() 270 271 let count = await capture.count 272 #expect(count == 0) 273 let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) 274 #expect(values?.selRow == 3) 275 #expect(values?.selCol == 4) 276 } 277 278 @Test("Gate denied then peerPresenceMayHaveChanged ships the held selection") 279 func gateResumeShipsPending() async throws { 280 let (persistence, gameID) = try makePersistenceWithGame() 281 let capture = Capture() 282 let presence = PresenceFlag() 283 let publisher = PlayerSelectionPublisher( 284 debounceInterval: { _ in .seconds(10) }, 285 persistence: persistence, 286 sink: { id, author, _ in await capture.append(id, author) }, 287 peerPresent: { _ in await presence.value } 288 ) 289 290 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 291 await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .across)) 292 await publisher.flush() 293 #expect(await capture.count == 0) 294 295 await presence.set(true) 296 await publisher.peerPresenceMayHaveChanged() 297 #expect(await capture.count == 1) 298 299 // A subsequent publish to the same selection shouldn't refire. 300 await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .across)) 301 await publisher.flush() 302 #expect(await capture.count == 1) 303 } 304 305 @Test("peerPresenceMayHaveChanged does not pre-empt an active debounce") 306 func gateResumeRespectsActiveDebounce() async throws { 307 let (persistence, gameID) = try makePersistenceWithGame() 308 let capture = Capture() 309 let manualSleep = ManualDebounceSleep() 310 let publisher = PlayerSelectionPublisher( 311 debounceInterval: { _ in .seconds(10) }, 312 persistence: persistence, 313 sink: { id, author, _ in await capture.append(id, author) }, 314 peerPresent: { _ in true }, 315 sleep: manualSleep.sleepFn 316 ) 317 318 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 319 await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across)) 320 // Debounce is now scheduled. A peer-presence nudge here must not 321 // pre-empt it — that would let a chatty partner pressure us into 322 // one CloudKit write per partner-move. 323 await publisher.peerPresenceMayHaveChanged() 324 #expect(await capture.count == 0) 325 326 // Release the debounce normally and confirm we still ship exactly once. 327 manualSleep.releaseAll() 328 try await waitForCount(1, capture: capture) 329 #expect(await capture.count == 1) 330 } 331 332 @Test("peerPresenceMayHaveChanged with non-matching gameIDs is a no-op") 333 func gateResumeScopedByGameID() async throws { 334 let (persistence, gameID) = try makePersistenceWithGame() 335 let capture = Capture() 336 let publisher = PlayerSelectionPublisher( 337 debounceInterval: { _ in .seconds(10) }, 338 persistence: persistence, 339 sink: { id, author, _ in await capture.append(id, author) }, 340 peerPresent: { _ in true } 341 ) 342 343 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 344 await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across)) 345 // Don't flush — keep pending set by skipping the debounce. Force a 346 // gated state by swapping the predicate isn't supported, so instead 347 // verify scoping: a resume call for a different game shouldn't drain. 348 await publisher.peerPresenceMayHaveChanged(gameIDs: [UUID()]) 349 #expect(await capture.count == 0) 350 } 351 352 @Test("publishImmediately bypasses the gate") 353 func publishImmediatelyBypassesGate() async throws { 354 let (persistence, gameID) = try makePersistenceWithGame() 355 let capture = Capture() 356 let publisher = PlayerSelectionPublisher( 357 debounceInterval: { _ in .seconds(10) }, 358 persistence: persistence, 359 sink: { id, author, _ in await capture.append(id, author) }, 360 peerPresent: { _ in false } 361 ) 362 363 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 364 await publisher.publishImmediately(PlayerSelection(row: 7, col: 8, direction: .across)) 365 366 let count = await capture.count 367 #expect(count == 1) 368 } 369 370 @Test("clear bypasses the gate") 371 func clearBypassesGate() async throws { 372 let (persistence, gameID) = try makePersistenceWithGame() 373 let capture = Capture() 374 let publisher = PlayerSelectionPublisher( 375 debounceInterval: { _ in .seconds(10) }, 376 persistence: persistence, 377 sink: { id, author, _ in await capture.append(id, author) }, 378 peerPresent: { _ in false } 379 ) 380 381 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 382 await publisher.publishImmediately(PlayerSelection(row: 1, col: 1, direction: .down)) 383 await publisher.clear() 384 385 let count = await capture.count 386 #expect(count == 2) 387 } 388 389 @Test("Publishing again after clear writes the new selection") 390 func publishAfterClear() async throws { 391 let (persistence, gameID) = try makePersistenceWithGame() 392 let capture = Capture() 393 let publisher = PlayerSelectionPublisher( 394 debounceInterval: { _ in .seconds(10) }, 395 persistence: persistence, 396 sink: { id, author, _ in await capture.append(id, author) } 397 ) 398 399 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 400 await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across)) 401 await publisher.flush() 402 await publisher.clear() 403 await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down)) 404 await publisher.flush() 405 406 let count = await capture.count 407 #expect(count == 3) 408 let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) 409 #expect(values?.selRow == 2) 410 #expect(values?.selCol == 2) 411 #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue)) 412 } 413 414 // MARK: - Helpers 415 416 struct PlayerValues { 417 let name: String? 418 let selRow: Int64? 419 let selCol: Int64? 420 let selDir: Int64? 421 let ckRecordName: String? 422 } 423 424 private func fetchPlayer( 425 gameID: UUID, 426 authorID: String, 427 persistence: PersistenceController 428 ) -> PlayerValues? { 429 let context = persistence.container.newBackgroundContext() 430 return context.performAndWait { 431 let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 432 request.predicate = NSPredicate( 433 format: "game.id == %@ AND authorID == %@", 434 gameID as CVarArg, 435 authorID 436 ) 437 request.fetchLimit = 1 438 guard let entity = try? context.fetch(request).first else { return nil } 439 return PlayerValues( 440 name: entity.name, 441 selRow: entity.selRow?.int64Value, 442 selCol: entity.selCol?.int64Value, 443 selDir: entity.selDir?.int64Value, 444 ckRecordName: entity.ckRecordName 445 ) 446 } 447 } 448 449 private func fetchAllPlayers( 450 gameID: UUID, 451 persistence: PersistenceController 452 ) -> [PlayerValues] { 453 let context = persistence.container.newBackgroundContext() 454 return context.performAndWait { 455 let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 456 request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 457 guard let entities = try? context.fetch(request) else { return [] } 458 return entities.map { 459 PlayerValues( 460 name: $0.name, 461 selRow: $0.selRow?.int64Value, 462 selCol: $0.selCol?.int64Value, 463 selDir: $0.selDir?.int64Value, 464 ckRecordName: $0.ckRecordName 465 ) 466 } 467 } 468 } 469 }