PresencePublisherTests.swift (10892B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 @Suite("PresencePublisher", .serialized) 8 @MainActor 9 struct PresencePublisherTests { 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 private func makePersistenceWithGame() throws -> (PersistenceController, UUID) { 21 let persistence = makeTestPersistence() 22 let context = persistence.viewContext 23 let gameID = UUID() 24 let entity = GameEntity(context: context) 25 entity.id = gameID 26 entity.title = "Test" 27 entity.puzzleSource = "" 28 entity.createdAt = Date() 29 entity.updatedAt = Date() 30 entity.ckRecordName = "game-\(gameID.uuidString)" 31 try context.save() 32 return (persistence, gameID) 33 } 34 35 @Test("Publish + flush writes the PlayerEntity row and notifies the sink") 36 func publishWritesEntity() async throws { 37 let (persistence, gameID) = try makePersistenceWithGame() 38 let capture = Capture() 39 let publisher = PresencePublisher( 40 debounceInterval: .seconds(10), 41 persistence: persistence, 42 sink: { id, author in await capture.append(id, author) } 43 ) 44 45 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 46 await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down)) 47 await publisher.flush() 48 49 let count = await capture.count 50 #expect(count == 1) 51 let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) 52 #expect(values?.selRow == 3) 53 #expect(values?.selCol == 4) 54 #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue)) 55 #expect(values?.ckRecordName == "player-\(gameID.uuidString)-alice") 56 } 57 58 @Test("Repeated identical selections don't refire the sink") 59 func dedupesIdenticalSelections() async throws { 60 let (persistence, gameID) = try makePersistenceWithGame() 61 let capture = Capture() 62 let publisher = PresencePublisher( 63 debounceInterval: .milliseconds(40), 64 persistence: persistence, 65 sink: { id, author in await capture.append(id, author) } 66 ) 67 68 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 69 let selection = PlayerSelection(row: 0, col: 0, direction: .across) 70 await publisher.publish(selection) 71 try await Task.sleep(for: .milliseconds(120)) 72 await publisher.publish(selection) 73 await publisher.publish(selection) 74 try await Task.sleep(for: .milliseconds(120)) 75 76 let count = await capture.count 77 #expect(count == 1) 78 } 79 80 @Test("Rapid distinct selections coalesce into a single trailing flush") 81 func debounceCoalescesRapidPublishes() async throws { 82 let (persistence, gameID) = try makePersistenceWithGame() 83 let capture = Capture() 84 let publisher = PresencePublisher( 85 debounceInterval: .milliseconds(80), 86 persistence: persistence, 87 sink: { id, author in await capture.append(id, author) } 88 ) 89 90 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 91 for col in 0...4 { 92 await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across)) 93 try await Task.sleep(for: .milliseconds(20)) 94 } 95 try await Task.sleep(for: .milliseconds(250)) 96 97 let count = await capture.count 98 #expect(count == 1) 99 let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) 100 #expect(values?.selCol == 4) 101 } 102 103 @Test("Clear nils the selection fields and notifies the sink") 104 func clearWritesNilSelection() async throws { 105 let (persistence, gameID) = try makePersistenceWithGame() 106 let capture = Capture() 107 let publisher = PresencePublisher( 108 debounceInterval: .seconds(10), 109 persistence: persistence, 110 sink: { id, author in await capture.append(id, author) } 111 ) 112 113 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 114 await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down)) 115 await publisher.flush() 116 await publisher.clear() 117 // clear() spawns a Task internally — give it a moment to land. 118 try await Task.sleep(for: .milliseconds(50)) 119 120 let count = await capture.count 121 #expect(count == 2) 122 let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) 123 #expect(values != nil) 124 #expect(values?.selRow == nil) 125 #expect(values?.selCol == nil) 126 #expect(values?.selDir == nil) 127 } 128 129 @Test("Clear is a no-op when nothing has been published yet") 130 func clearWithoutPriorPublishIsNoOp() async throws { 131 let (persistence, gameID) = try makePersistenceWithGame() 132 let capture = Capture() 133 let publisher = PresencePublisher( 134 debounceInterval: .seconds(10), 135 persistence: persistence, 136 sink: { id, author in await capture.append(id, author) } 137 ) 138 139 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 140 await publisher.clear() 141 try await Task.sleep(for: .milliseconds(50)) 142 143 let count = await capture.count 144 #expect(count == 0) 145 } 146 147 @Test("Publish without begin is silently ignored") 148 func publishBeforeBeginIsDropped() async throws { 149 let (persistence, _) = try makePersistenceWithGame() 150 let capture = Capture() 151 let publisher = PresencePublisher( 152 debounceInterval: .milliseconds(40), 153 persistence: persistence, 154 sink: { id, author in await capture.append(id, author) } 155 ) 156 157 await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across)) 158 await publisher.flush() 159 160 let count = await capture.count 161 #expect(count == 0) 162 } 163 164 @Test("Updates an existing PlayerEntity rather than creating a duplicate") 165 func updatesExistingEntity() async throws { 166 let (persistence, gameID) = try makePersistenceWithGame() 167 // Pre-seed a PlayerEntity (e.g. from a name broadcast or remote sync). 168 let context = persistence.viewContext 169 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 170 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 171 let game = try #require(try context.fetch(gameReq).first) 172 let preexisting = PlayerEntity(context: context) 173 preexisting.game = game 174 preexisting.authorID = "alice" 175 preexisting.name = "Alice" 176 preexisting.updatedAt = Date() 177 preexisting.ckRecordName = "player-\(gameID.uuidString)-alice" 178 try context.save() 179 180 let publisher = PresencePublisher( 181 debounceInterval: .seconds(10), 182 persistence: persistence, 183 sink: { _, _ in } 184 ) 185 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 186 await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across)) 187 await publisher.flush() 188 189 let rows = fetchAllPlayers(gameID: gameID, persistence: persistence) 190 #expect(rows.count == 1) 191 #expect(rows.first?.name == "Alice") // pre-existing field preserved 192 #expect(rows.first?.selRow == 5) 193 #expect(rows.first?.selCol == 6) 194 } 195 196 @Test("Publishing again after clear writes the new selection") 197 func publishAfterClear() async throws { 198 let (persistence, gameID) = try makePersistenceWithGame() 199 let capture = Capture() 200 let publisher = PresencePublisher( 201 debounceInterval: .seconds(10), 202 persistence: persistence, 203 sink: { id, author in await capture.append(id, author) } 204 ) 205 206 await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") 207 await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across)) 208 await publisher.flush() 209 await publisher.clear() 210 try await Task.sleep(for: .milliseconds(50)) 211 await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down)) 212 await publisher.flush() 213 214 let count = await capture.count 215 #expect(count == 3) 216 let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) 217 #expect(values?.selRow == 2) 218 #expect(values?.selCol == 2) 219 #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue)) 220 } 221 222 // MARK: - Helpers 223 224 struct PlayerValues { 225 let name: String? 226 let selRow: Int64? 227 let selCol: Int64? 228 let selDir: Int64? 229 let ckRecordName: String? 230 } 231 232 private func fetchPlayer( 233 gameID: UUID, 234 authorID: String, 235 persistence: PersistenceController 236 ) -> PlayerValues? { 237 let context = persistence.container.newBackgroundContext() 238 return context.performAndWait { 239 let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 240 request.predicate = NSPredicate( 241 format: "game.id == %@ AND authorID == %@", 242 gameID as CVarArg, 243 authorID 244 ) 245 request.fetchLimit = 1 246 guard let entity = try? context.fetch(request).first else { return nil } 247 return PlayerValues( 248 name: entity.name, 249 selRow: entity.selRow?.int64Value, 250 selCol: entity.selCol?.int64Value, 251 selDir: entity.selDir?.int64Value, 252 ckRecordName: entity.ckRecordName 253 ) 254 } 255 } 256 257 private func fetchAllPlayers( 258 gameID: UUID, 259 persistence: PersistenceController 260 ) -> [PlayerValues] { 261 let context = persistence.container.newBackgroundContext() 262 return context.performAndWait { 263 let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 264 request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 265 guard let entities = try? context.fetch(request) else { return [] } 266 return entities.map { 267 PlayerValues( 268 name: $0.name, 269 selRow: $0.selRow?.int64Value, 270 selCol: $0.selCol?.int64Value, 271 selDir: $0.selDir?.int64Value, 272 ckRecordName: $0.ckRecordName 273 ) 274 } 275 } 276 } 277 }