crossmate

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

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 }