SessionMonitorTests.swift (14203B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 @Suite("SessionMonitor", .isolatedNotificationState) 8 @MainActor 9 struct SessionMonitorTests { 10 11 private static let localAuthorID = "local-author" 12 private static let alice = "alice" 13 private static let bob = "bob" 14 15 private static let puzzleSource = """ 16 Title: Test Puzzle 17 Author: Test 18 19 20 ABC 21 D#E 22 FGH 23 24 25 A1. Across 1 ~ ABC 26 A4. Across 4 ~ DE 27 A5. Across 5 ~ FGH 28 D1. Down 1 ~ ADF 29 D2. Down 2 ~ BG 30 D3. Down 3 ~ CEH 31 """ 32 33 private struct Fixture { 34 let persistence: PersistenceController 35 let store: GameStore 36 let monitor: SessionMonitor 37 let gameID: UUID 38 let game: GameEntity 39 } 40 41 private func makeFixture( 42 localAuthorID: String? = SessionMonitorTests.localAuthorID 43 ) throws -> Fixture { 44 let persistence = makeTestPersistence() 45 let store = makeTestStore( 46 persistence: persistence, 47 authorIDProvider: { localAuthorID } 48 ) 49 let monitor = SessionMonitor( 50 store: store, 51 localAuthorIDProvider: { localAuthorID } 52 ) 53 let ctx = persistence.viewContext 54 let gameID = UUID() 55 let entity = GameEntity(context: ctx) 56 entity.id = gameID 57 entity.title = "Test Puzzle" 58 entity.puzzleSource = Self.puzzleSource 59 entity.createdAt = Date() 60 entity.updatedAt = Date() 61 entity.ckRecordName = "game-\(gameID.uuidString)" 62 let xd = try XD.parse(Self.puzzleSource) 63 entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) 64 try ctx.save() 65 return Fixture( 66 persistence: persistence, 67 store: store, 68 monitor: monitor, 69 gameID: gameID, 70 game: entity 71 ) 72 } 73 74 /// Seeds the per-cell letter-change ledger directly — the data `summaries` 75 /// reduces. Each cell is the recorded letter (empty = a clear) and when it 76 /// last changed. Most reduction tests use this rather than driving the 77 /// moves→ledger writer, whose translation is covered separately. 78 private func writeLedger( 79 in fixture: Fixture, 80 authorID: String?, 81 cells: [GridPosition: (letter: String, changedAt: Date)] 82 ) throws { 83 let ctx = fixture.persistence.viewContext 84 for (pos, value) in cells { 85 let row = PeerChangeEntity(context: ctx) 86 row.gameID = fixture.gameID 87 row.row = Int16(pos.row) 88 row.col = Int16(pos.col) 89 row.letter = value.letter 90 row.authorID = authorID 91 row.changedAt = value.changedAt 92 row.game = fixture.game 93 } 94 try ctx.save() 95 } 96 97 /// Upserts a device's full `MovesEntity` snapshot (one row per 98 /// author/device, as in production). Used by the integration tests that 99 /// drive the real moves→ledger writer. 100 private func writeMoves( 101 in fixture: Fixture, 102 authorID: String, 103 deviceID: String = "device-1", 104 cells: [GridPosition: (letter: String, updatedAt: Date)] 105 ) throws { 106 let ctx = fixture.persistence.viewContext 107 let stamped = cells.mapValues { value in 108 TimestampedCell( 109 letter: value.letter, 110 mark: .none, 111 updatedAt: value.updatedAt, 112 authorID: value.letter.isEmpty ? nil : authorID 113 ) 114 } 115 let data = try MovesCodec.encode(stamped) 116 let recordName = RecordSerializer.recordName( 117 forMovesInGame: fixture.gameID, 118 authorID: authorID, 119 deviceID: deviceID 120 ) 121 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 122 req.predicate = NSPredicate( 123 format: "game == %@ AND authorID == %@ AND deviceID == %@", 124 fixture.game, authorID, deviceID 125 ) 126 req.fetchLimit = 1 127 let row = (try? ctx.fetch(req).first) ?? MovesEntity(context: ctx) 128 row.game = fixture.game 129 row.authorID = authorID 130 row.deviceID = deviceID 131 row.cells = data 132 row.updatedAt = Date() 133 row.ckRecordName = recordName 134 try ctx.save() 135 } 136 137 private func buildLedger(in fixture: Fixture) async { 138 await fixture.store.updatePeerChangeLedger(for: [fixture.gameID]) 139 } 140 141 private func addPlayer( 142 in fixture: Fixture, 143 authorID: String, 144 name: String 145 ) throws { 146 let ctx = fixture.persistence.viewContext 147 let player = PlayerEntity(context: ctx) 148 player.game = fixture.game 149 player.authorID = authorID 150 player.name = name 151 player.updatedAt = Date() 152 player.ckRecordName = "player-\(fixture.gameID.uuidString)-\(authorID)" 153 try ctx.save() 154 } 155 156 private func position(_ row: Int, _ col: Int) -> GridPosition { 157 GridPosition(row: row, col: col) 158 } 159 160 /// The view baseline cutoff, with edits placed before/after it. 161 private static let cutoff = Date(timeIntervalSince1970: 1_000) 162 private var before: Date { Self.cutoff.addingTimeInterval(-60) } 163 private var after: Date { Self.cutoff.addingTimeInterval(60) } 164 165 // MARK: - Counts since the view baseline (ledger reduction) 166 167 @Test("A peer's fills since the cutoff surface as a named summary") 168 func peerFillsSummarised() throws { 169 let fixture = try makeFixture() 170 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 171 try writeLedger( 172 in: fixture, 173 authorID: Self.alice, 174 cells: [ 175 position(0, 0): ("A", after), 176 position(0, 1): ("B", after), 177 position(2, 2): ("H", after), 178 ] 179 ) 180 181 let summaries = fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff) 182 183 #expect(summaries.count == 1) 184 let summary = try #require(summaries.first) 185 #expect(summary.authorID == Self.alice) 186 #expect(summary.playerName == "Alice") 187 #expect(summary.added == 3) 188 #expect(summary.cleared == 0) 189 } 190 191 @Test("A friend nickname overrides the peer's published name in summaries") 192 func nicknameOverridesPlayerName() throws { 193 let fixture = try makeFixture() 194 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 195 let ctx = fixture.persistence.viewContext 196 let friend = FriendEntity(context: ctx) 197 friend.authorID = Self.alice 198 friend.pairKey = "pair-alice" 199 friend.friendZoneName = "friend-pair-alice" 200 friend.friendZoneOwnerName = "_owner" 201 friend.databaseScope = 0 202 friend.createdAt = Date() 203 friend.nickname = "Mum" 204 try ctx.save() 205 try writeLedger( 206 in: fixture, 207 authorID: Self.alice, 208 cells: [position(0, 0): ("A", after)] 209 ) 210 211 let summary = try #require( 212 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 213 ) 214 #expect(summary.playerName == "Mum") 215 } 216 217 @Test("A clear (letter→empty) since the cutoff counts toward the cleared total") 218 func clearsCounted() throws { 219 let fixture = try makeFixture() 220 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 221 // One cell still holds its (pre-cutoff) letter; another was emptied 222 // after the cutoff. 223 try writeLedger( 224 in: fixture, 225 authorID: Self.alice, 226 cells: [ 227 position(0, 0): ("A", before), 228 position(0, 1): ("", after), 229 ] 230 ) 231 let summary = try #require( 232 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 233 ) 234 #expect(summary.added == 0) 235 #expect(summary.cleared == 1) 236 } 237 238 @Test("Nothing newer than the cutoff yields no summary") 239 func noActivityReturnsNothing() throws { 240 let fixture = try makeFixture() 241 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 242 try writeLedger( 243 in: fixture, 244 authorID: Self.alice, 245 cells: [position(0, 0): ("A", before)] 246 ) 247 248 #expect(fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).isEmpty) 249 } 250 251 @Test("summaries is read-only: repeated reads against the same cutoff are stable") 252 func readsAreStable() throws { 253 let fixture = try makeFixture() 254 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 255 try writeLedger( 256 in: fixture, 257 authorID: Self.alice, 258 cells: [ 259 position(0, 0): ("A", after), 260 position(0, 1): ("B", after), 261 ] 262 ) 263 #expect(fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first?.added == 2) 264 #expect(fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first?.added == 2) 265 } 266 267 @Test("The local author's change is excluded from the summaries") 268 func localAuthorExcluded() throws { 269 let fixture = try makeFixture() 270 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 271 try writeLedger( 272 in: fixture, 273 authorID: Self.localAuthorID, 274 cells: [position(0, 0): ("A", after)] 275 ) 276 try writeLedger( 277 in: fixture, 278 authorID: Self.alice, 279 cells: [position(0, 1): ("B", after)] 280 ) 281 282 let summaries = fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff) 283 #expect(summaries.count == 1) 284 #expect(summaries.first?.authorID == Self.alice) 285 } 286 287 @Test("Multiple peers each appear as their own summary, ordered by author") 288 func multiplePeers() throws { 289 let fixture = try makeFixture() 290 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 291 try addPlayer(in: fixture, authorID: Self.bob, name: "Bob") 292 try writeLedger( 293 in: fixture, 294 authorID: Self.alice, 295 cells: [position(0, 0): ("A", after)] 296 ) 297 try writeLedger( 298 in: fixture, 299 authorID: Self.bob, 300 cells: [ 301 position(0, 1): ("B", after), 302 position(0, 2): ("C", after), 303 ] 304 ) 305 306 let summaries = fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff) 307 #expect(summaries.map(\.authorID) == [Self.alice, Self.bob]) 308 #expect(summaries[0].added == 1) 309 #expect(summaries[1].added == 2) 310 } 311 312 // MARK: - Integration: the moves→ledger writer 313 314 @Test("A peer's fills drive the ledger and surface as a summary") 315 func fillsDriveLedger() async throws { 316 let fixture = try makeFixture() 317 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 318 // A baseline build (over a pre-cutoff cell) seeds the ledger, so the 319 // later fills are measured against it rather than absorbed by the seed. 320 try writeMoves( 321 in: fixture, 322 authorID: Self.alice, 323 cells: [position(2, 2): ("H", before)] 324 ) 325 await buildLedger(in: fixture) 326 try writeMoves( 327 in: fixture, 328 authorID: Self.alice, 329 cells: [ 330 position(2, 2): ("H", before), 331 position(0, 0): ("A", after), 332 position(0, 1): ("B", after), 333 ] 334 ) 335 await buildLedger(in: fixture) 336 337 let summary = try #require( 338 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 339 ) 340 #expect(summary.added == 2) 341 } 342 343 @Test("A peer's check sweep after the cutoff does not inflate the summary") 344 func checkSweepDoesNotInflateSummary() async throws { 345 // The reported rejoin bug: a check re-stamps every filled cell's 346 // updatedAt without changing the letter, and must not read as fresh 347 // fills on return. 348 let fixture = try makeFixture() 349 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 350 // Alice's two letters arrive and are recorded before you leave. 351 try writeMoves( 352 in: fixture, 353 authorID: Self.alice, 354 cells: [ 355 position(0, 0): ("A", before), 356 position(0, 1): ("B", before), 357 ] 358 ) 359 await buildLedger(in: fixture) 360 // After the cutoff she runs a check (re-stamps both letters) and fills 361 // one genuinely new cell. 362 try writeMoves( 363 in: fixture, 364 authorID: Self.alice, 365 cells: [ 366 position(0, 0): ("A", after), 367 position(0, 1): ("B", after), 368 position(2, 2): ("H", after), 369 ] 370 ) 371 await buildLedger(in: fixture) 372 373 let summary = try #require( 374 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 375 ) 376 // Only the genuine fill — not the two re-stamped letters. 377 #expect(summary.added == 1) 378 #expect(summary.cleared == 0) 379 } 380 381 @Test("Completing a game drops its peer-change ledger") 382 func completionClearsLedger() async throws { 383 let fixture = try makeFixture() 384 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 385 try writeMoves( 386 in: fixture, 387 authorID: Self.alice, 388 cells: [position(0, 0): ("A", after), position(0, 1): ("B", after)] 389 ) 390 await buildLedger(in: fixture) 391 392 let req = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") 393 req.predicate = NSPredicate(format: "gameID == %@", fixture.gameID as CVarArg) 394 #expect(try fixture.persistence.viewContext.count(for: req) > 0) 395 396 // The game finishes; the next build drops the now-useless ledger. 397 fixture.game.completedAt = Date() 398 try fixture.persistence.viewContext.save() 399 await buildLedger(in: fixture) 400 401 #expect(try fixture.persistence.viewContext.count(for: req) == 0) 402 } 403 }