SessionMonitorTests.swift (14072B)
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("A peer's fills drive the ledger and surface as a summary") 312 func fillsDriveLedger() async throws { 313 let fixture = try makeFixture() 314 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 315 // A baseline build (over a pre-cutoff cell) seeds the ledger, so the 316 // later fills are measured against it rather than absorbed by the seed. 317 try writeMoves( 318 in: fixture, 319 authorID: Self.alice, 320 cells: [position(2, 2): ("H", before)] 321 ) 322 await buildLedger(in: fixture) 323 try writeMoves( 324 in: fixture, 325 authorID: Self.alice, 326 cells: [ 327 position(2, 2): ("H", before), 328 position(0, 0): ("A", after), 329 position(0, 1): ("B", after), 330 ] 331 ) 332 await buildLedger(in: fixture) 333 334 let summary = try #require( 335 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 336 ) 337 #expect(summary.added == 2) 338 } 339 340 @Test("A peer's check sweep after the cutoff does not inflate the summary") 341 func checkSweepDoesNotInflateSummary() async throws { 342 // The reported rejoin bug: a check re-stamps every filled cell's 343 // updatedAt without changing the letter, and must not read as fresh 344 // fills on return. 345 let fixture = try makeFixture() 346 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 347 // Alice's two letters arrive and are recorded before you leave. 348 try writeMoves( 349 in: fixture, 350 authorID: Self.alice, 351 cells: [ 352 position(0, 0): ("A", before), 353 position(0, 1): ("B", before), 354 ] 355 ) 356 await buildLedger(in: fixture) 357 // After the cutoff she runs a check (re-stamps both letters) and fills 358 // one genuinely new cell. 359 try writeMoves( 360 in: fixture, 361 authorID: Self.alice, 362 cells: [ 363 position(0, 0): ("A", after), 364 position(0, 1): ("B", after), 365 position(2, 2): ("H", after), 366 ] 367 ) 368 await buildLedger(in: fixture) 369 370 let summary = try #require( 371 fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first 372 ) 373 // Only the genuine fill — not the two re-stamped letters. 374 #expect(summary.added == 1) 375 #expect(summary.cleared == 0) 376 } 377 378 @Test("Completing a game drops its peer-change ledger") 379 func completionClearsLedger() async throws { 380 let fixture = try makeFixture() 381 try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") 382 try writeMoves( 383 in: fixture, 384 authorID: Self.alice, 385 cells: [position(0, 0): ("A", after), position(0, 1): ("B", after)] 386 ) 387 await buildLedger(in: fixture) 388 389 let req = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") 390 req.predicate = NSPredicate(format: "gameID == %@", fixture.gameID as CVarArg) 391 #expect(try fixture.persistence.viewContext.count(for: req) > 0) 392 393 // The game finishes; the next build drops the now-useless ledger. 394 fixture.game.completedAt = Date() 395 try fixture.persistence.viewContext.save() 396 await buildLedger(in: fixture) 397 398 #expect(try fixture.persistence.viewContext.count(for: req) == 0) 399 } 400 }