crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }