PlayerNamePublisherTests.swift (5780B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 @Suite("PlayerNamePublisher", .serialized) 8 @MainActor 9 struct PlayerNamePublisherTests { 10 11 // MARK: - Helpers 12 13 /// Creates a persistence store with one game that qualifies for fan-out 14 /// (it has a `ckShareRecordName`, so `upsertPlayerRecords` will visit it). 15 private func makeSharedGame() throws -> (PersistenceController, UUID) { 16 let p = makeTestPersistence() 17 let ctx = p.viewContext 18 let gameID = UUID() 19 let entity = GameEntity(context: ctx) 20 entity.id = gameID 21 entity.title = "Shared Test" 22 entity.puzzleSource = "" 23 entity.createdAt = Date() 24 entity.updatedAt = Date() 25 entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) 26 entity.ckShareRecordName = "share-\(UUID().uuidString)" 27 try ctx.save() 28 return (p, gameID) 29 } 30 31 private func makeNonSharedGame() throws -> (PersistenceController, UUID) { 32 let p = makeTestPersistence() 33 let ctx = p.viewContext 34 let gameID = UUID() 35 let entity = GameEntity(context: ctx) 36 entity.id = gameID 37 entity.title = "Solo Test" 38 entity.puzzleSource = "" 39 entity.createdAt = Date() 40 entity.updatedAt = Date() 41 entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) 42 // No ckShareRecordName and databaseScope == 0 → not picked up by fan-out. 43 try ctx.save() 44 return (p, gameID) 45 } 46 47 private func fetchPlayerName(authorID: String, in persistence: PersistenceController) -> String? { 48 let ctx = persistence.container.newBackgroundContext() 49 return ctx.performAndWait { 50 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 51 req.predicate = NSPredicate(format: "authorID == %@", authorID) 52 req.fetchLimit = 1 53 return (try? ctx.fetch(req).first)?.name 54 } 55 } 56 57 private func makeBroadcaster( 58 preferences: PlayerPreferences, 59 persistence: PersistenceController, 60 authorID: String 61 ) -> PlayerNamePublisher { 62 PlayerNamePublisher( 63 preferences: preferences, 64 persistence: persistence, 65 authorIdentity: AuthorIdentity(testing: authorID), 66 enqueuePlayerRecord: { _, _ in } 67 ) 68 } 69 70 // MARK: - Tests 71 72 @Test("broadcastName writes a PlayerEntity for shared/joined games") 73 func broadcastNameWritesPlayerEntityForSharedGame() async throws { 74 let (persistence, _) = try makeSharedGame() 75 let prefs = PlayerPreferences( 76 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 77 ) 78 prefs.name = "Alice" 79 let broadcaster = makeBroadcaster( 80 preferences: prefs, 81 persistence: persistence, 82 authorID: "_local" 83 ) 84 85 await broadcaster.broadcastName() 86 87 #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Alice") 88 } 89 90 @Test("broadcastName is a no-op for non-shared games") 91 func broadcastNameSkipsNonSharedGames() async throws { 92 let (persistence, _) = try makeNonSharedGame() 93 let prefs = PlayerPreferences( 94 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 95 ) 96 prefs.name = "Alice" 97 let broadcaster = makeBroadcaster( 98 preferences: prefs, 99 persistence: persistence, 100 authorID: "_local" 101 ) 102 103 await broadcaster.broadcastName() 104 105 #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil) 106 } 107 108 @Test("Debounce coalesces two rapid name changes into one fan-out with the final name") 109 func debounceCoalescesPair() async throws { 110 let (persistence, _) = try makeSharedGame() 111 let prefs = PlayerPreferences( 112 local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! 113 ) 114 let broadcaster = makeBroadcaster( 115 preferences: prefs, 116 persistence: persistence, 117 authorID: "_local" 118 ) 119 120 let spy = FanOutSpy() 121 broadcaster.onFanOutForTesting = { name in spy.record(name) } 122 123 // Allow the observation task to make its first withObservationTracking 124 // registration before we mutate any values. 125 await Task.yield() 126 127 // First change — debounce timer #1 starts. 128 prefs.name = "Alice" 129 await Task.yield() // observation task → scheduleDebounce 130 131 // Second change before timer fires — must cancel #1, start #2. 132 prefs.name = "Bob" 133 await Task.yield() // observation task → cancel #1, start #2 134 135 // Poll until the (single, hopefully) fan-out fires. Loose deadline so 136 // CI scheduler jitter doesn't fail the test. 137 let deadline = Date().addingTimeInterval(2.0) 138 while spy.count == 0 && Date() < deadline { 139 try await Task.sleep(for: .milliseconds(20)) 140 } 141 142 // Grace period to catch a stray second fan-out from an uncancelled 143 // timer — the bug we're guarding against would surface here as count==2. 144 try await Task.sleep(for: .milliseconds(150)) 145 146 #expect(spy.count == 1, "two rapid changes should debounce into one fan-out") 147 #expect(spy.names.last == "Bob", "the final name should win") 148 #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Bob") 149 150 // Keep broadcaster alive until assertions are done. 151 withExtendedLifetime(broadcaster) {} 152 } 153 } 154 155 @MainActor 156 private final class FanOutSpy { 157 private(set) var names: [String] = [] 158 var count: Int { names.count } 159 func record(_ name: String) { names.append(name) } 160 }