crossmate

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

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 }