SessionMonitorTests.swift (15730B)
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.createdAt = Date() 200 friend.nickname = "Mum" 201 try ctx.save() 202 try writeLedger( 203 in: fixture, 204 authorID: Self.alice, 205 cells: [position(0, 0): ("A", after)] 206 ) 207 208 let summary = try #require( 209 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 210 ) 211 #expect(summary.playerName == "Mum") 212 } 213 214 @Test("A clear (letter→empty) since the cutoff counts toward the cleared total") 215 func clearsCounted() throws { 216 let fixture = try makeFixture() 217 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 218 // One cell still holds its (pre-cutoff) letter; another was emptied 219 // after the cutoff. 220 try writeLedger( 221 in: fixture, 222 authorID: Self.alice, 223 cells: [ 224 position(0, 0): ("A", before), 225 position(0, 1): ("", after), 226 ] 227 ) 228 let summary = try #require( 229 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 230 ) 231 #expect(summary.added == 0) 232 #expect(summary.cleared == 1) 233 } 234 235 @Test("Nothing newer than the cutoff yields no summary") 236 func noActivityReturnsNothing() throws { 237 let fixture = try makeFixture() 238 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 239 try writeLedger( 240 in: fixture, 241 authorID: Self.alice, 242 cells: [position(0, 0): ("A", before)] 243 ) 244 245 #expect(fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).isEmpty) 246 } 247 248 @Test("summaries is read-only: repeated reads against the same cutoff are stable") 249 func readsAreStable() throws { 250 let fixture = try makeFixture() 251 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 252 try writeLedger( 253 in: fixture, 254 authorID: Self.alice, 255 cells: [ 256 position(0, 0): ("A", after), 257 position(0, 1): ("B", after), 258 ] 259 ) 260 #expect(fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first?.added == 2) 261 #expect(fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first?.added == 2) 262 } 263 264 @Test("The local author's change is excluded from the summaries") 265 func localAuthorExcluded() throws { 266 let fixture = try makeFixture() 267 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 268 try writeLedger( 269 in: fixture, 270 authorID: Self.localAuthorID, 271 cells: [position(0, 0): ("A", after)] 272 ) 273 try writeLedger( 274 in: fixture, 275 authorID: Self.alice, 276 cells: [position(0, 1): ("B", after)] 277 ) 278 279 let summaries = fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff) 280 #expect(summaries.count == 1) 281 #expect(summaries.first?.authorID == Self.alice) 282 } 283 284 @Test("Multiple peers each appear as their own summary, ordered by author") 285 func multiplePeers() throws { 286 let fixture = try makeFixture() 287 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 288 try addPlayer(in: fixture, authorID: Self.bob, name: "Bob") 289 try writeLedger( 290 in: fixture, 291 authorID: Self.alice, 292 cells: [position(0, 0): ("A", after)] 293 ) 294 try writeLedger( 295 in: fixture, 296 authorID: Self.bob, 297 cells: [ 298 position(0, 1): ("B", after), 299 position(0, 2): ("C", after), 300 ] 301 ) 302 303 let summaries = fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff) 304 #expect(summaries.map(\.authorID) == [Self.alice, Self.bob]) 305 #expect(summaries[0].added == 1) 306 #expect(summaries[1].added == 2) 307 } 308 309 // MARK: - Integration: the moves→ledger writer 310 311 @Test("An empty seed preserves the first later peer fill") 312 func emptySeedPreservesFirstPeerFill() async throws { 313 let fixture = try makeFixture() 314 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 315 316 // The user leaves before anyone has entered a letter. This first build 317 // must still persist that the empty snapshot has been established. 318 await buildLedger(in: fixture) 319 320 try writeMoves( 321 in: fixture, 322 authorID: Self.alice, 323 cells: [position(0, 0): ("A", after)] 324 ) 325 await buildLedger(in: fixture) 326 327 let summary = try #require( 328 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 329 ) 330 #expect(summary.added == 1) 331 #expect(summary.cleared == 0) 332 } 333 334 @Test("Legacy ledger rows preserve a later peer fill during migration") 335 func legacyLedgerRowsPreserveLaterPeerFill() async throws { 336 let fixture = try makeFixture() 337 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 338 try writeLedger( 339 in: fixture, 340 authorID: Self.alice, 341 cells: [position(2, 2): ("H", before)] 342 ) 343 try writeMoves( 344 in: fixture, 345 authorID: Self.alice, 346 cells: [ 347 position(2, 2): ("H", before), 348 position(0, 0): ("A", after), 349 ] 350 ) 351 352 await buildLedger(in: fixture) 353 354 let summary = try #require( 355 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 356 ) 357 #expect(summary.added == 1) 358 } 359 360 @Test("A peer's fills drive the ledger and surface as a summary") 361 func fillsDriveLedger() async throws { 362 let fixture = try makeFixture() 363 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 364 // A baseline build (over a pre-cutoff cell) seeds the ledger, so the 365 // later fills are measured against it rather than absorbed by the seed. 366 try writeMoves( 367 in: fixture, 368 authorID: Self.alice, 369 cells: [position(2, 2): ("H", before)] 370 ) 371 await buildLedger(in: fixture) 372 try writeMoves( 373 in: fixture, 374 authorID: Self.alice, 375 cells: [ 376 position(2, 2): ("H", before), 377 position(0, 0): ("A", after), 378 position(0, 1): ("B", after), 379 ] 380 ) 381 await buildLedger(in: fixture) 382 383 let summary = try #require( 384 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 385 ) 386 #expect(summary.added == 2) 387 } 388 389 @Test("A peer's check sweep after the cutoff does not inflate the summary") 390 func checkSweepDoesNotInflateSummary() async throws { 391 // The reported rejoin bug: a check re-stamps every filled cell's 392 // updatedAt without changing the letter, and must not read as fresh 393 // fills on return. 394 let fixture = try makeFixture() 395 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 396 // Alice's two letters arrive and are recorded before you leave. 397 try writeMoves( 398 in: fixture, 399 authorID: Self.alice, 400 cells: [ 401 position(0, 0): ("A", before), 402 position(0, 1): ("B", before), 403 ] 404 ) 405 await buildLedger(in: fixture) 406 // After the cutoff she runs a check (re-stamps both letters) and fills 407 // one genuinely new cell. 408 try writeMoves( 409 in: fixture, 410 authorID: Self.alice, 411 cells: [ 412 position(0, 0): ("A", after), 413 position(0, 1): ("B", after), 414 position(2, 2): ("H", after), 415 ] 416 ) 417 await buildLedger(in: fixture) 418 419 let summary = try #require( 420 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 421 ) 422 // Only the genuine fill — not the two re-stamped letters. 423 #expect(summary.added == 1) 424 #expect(summary.cleared == 0) 425 } 426 427 @Test("Completing a game drops its peer-change ledger") 428 func completionClearsLedger() async throws { 429 let fixture = try makeFixture() 430 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 431 try writeMoves( 432 in: fixture, 433 authorID: Self.alice, 434 cells: [position(0, 0): ("A", after), position(0, 1): ("B", after)] 435 ) 436 await buildLedger(in: fixture) 437 438 let req = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") 439 req.predicate = NSPredicate(format: "gameID == %@", fixture.gameID as CVarArg) 440 #expect(try fixture.persistence.viewContext.count(for: req) > 0) 441 442 // The game finishes; the next build drops the now-useless ledger. 443 fixture.game.completedAt = Date() 444 try fixture.persistence.viewContext.save() 445 await buildLedger(in: fixture) 446 447 #expect(try fixture.persistence.viewContext.count(for: req) == 0) 448 } 449 }