crossmate

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

SessionPushPlannerTests.swift (16156B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 @Suite("Session push planner")
      7 struct SessionPushPlannerTests {
      8     /// One journal entry. `seq` orders entries within a cell; `kind` and
      9     /// `batch` drive the gesture tallies.
     10     private func entry(
     11         _ letter: String,
     12         at timestamp: Date,
     13         seq: Int64,
     14         row: Int = 0,
     15         col: Int = 0,
     16         kind: JournalKind = .input,
     17         batch: UUID? = nil,
     18         cellAuthorID: String = "author"
     19     ) -> JournalValue {
     20         JournalValue(
     21             seq: seq,
     22             timestamp: timestamp,
     23             position: GridPosition(row: row, col: col),
     24             state: JournalCellState(letter: letter, mark: .none, cellAuthorID: cellAuthorID),
     25             actingAuthorID: "author",
     26             kind: kind,
     27             targetSeq: nil,
     28             batchID: batch,
     29             prevSeqAtCell: nil,
     30             direction: nil
     31         )
     32     }
     33 
     34     private func recipient(
     35         _ address: String?,
     36         readThrough: Date?,
     37         notifiedThrough: Date? = nil
     38     ) -> PushRecipient {
     39         PushRecipient(
     40             authorID: "peer",
     41             readThrough: readThrough,
     42             notifiedThrough: notifiedThrough,
     43             pushAddress: address
     44         )
     45     }
     46 
     47     @Test("A caught-up recipient is dropped — a session end is no longer a presence ping")
     48     func caughtUpRecipientDropped() {
     49         let edit = Date(timeIntervalSince1970: 1_000)
     50         let entries = [
     51             entry("X", at: edit, seq: 1, col: 0),
     52             entry("", at: edit, seq: 2, col: 1)
     53         ]
     54         let seenEverything = recipient("addr-1", readThrough: edit.addingTimeInterval(60))
     55 
     56         let addressees = SessionPushPlanner.sessionEndAddressees(
     57             recipients: [seenEverything],
     58             journalEntries: entries,
     59             selfAuthorID: "author",
     60             playerName: "Alice",
     61             puzzleTitle: "Tuesday"
     62         )
     63 
     64         // Nothing the recipient hasn't seen, so no push goes out at all — the
     65         // begin push that used to carry presence is gone.
     66         #expect(addressees.isEmpty)
     67     }
     68 
     69     @Test("A behind recipient gets net fills/clears and a badge-marking payload")
     70     func behindRecipientCounts() {
     71         let edit = Date(timeIntervalSince1970: 1_000)
     72         let entries = [
     73             // (0,0): empty → "X" after the cutoff = one fill.
     74             entry("X", at: edit, seq: 2, col: 0),
     75             // (0,1): "Y" seen before the cutoff, then emptied after = one clear.
     76             entry("Y", at: edit.addingTimeInterval(-120), seq: 1, col: 1),
     77             entry("", at: edit, seq: 3, col: 1)
     78         ]
     79         let behind = recipient("addr-2", readThrough: edit.addingTimeInterval(-60))
     80 
     81         let addressees = SessionPushPlanner.sessionEndAddressees(
     82             recipients: [behind],
     83             journalEntries: entries,
     84             selfAuthorID: "author",
     85             playerName: "Alice",
     86             puzzleTitle: "Tuesday"
     87         )
     88 
     89         #expect(addressees.count == 1)
     90         #expect(addressees[0].payload
     91             == PushPayload(event: .pause(fills: 1, clears: 1, checks: 0, reveals: 0), playerName: "Alice"))
     92         #expect(addressees[0].payload?.marksUnread == true)
     93         #expect(addressees[0].body
     94             == "Alice filled 1 letter and cleared 1 letter in the puzzle 'Tuesday'")
     95     }
     96 
     97     @Test("A move already notified isn't re-counted, even if the recipient never read it")
     98     func notifiedThroughWindowsOutPriorMoves() {
     99         // The bounce case: the recipient stayed backgrounded (readThrough stuck far
    100         // in the past), but we already paused to them once covering `edit`. The
    101         // second pause must not re-report that fill — it tallies to zero, so the
    102         // recipient is dropped entirely rather than getting a duplicate summary.
    103         let edit = Date(timeIntervalSince1970: 1_000)
    104         let entries = [entry("X", at: edit, seq: 1, col: 0)]
    105         let staleReadAt = edit.addingTimeInterval(-3_600)
    106 
    107         // First pause (never notified): the fill is news to the recipient.
    108         let firstPause = SessionPushPlanner.sessionEndAddressees(
    109             recipients: [recipient("addr", readThrough: staleReadAt)],
    110             journalEntries: entries,
    111             selfAuthorID: "author",
    112             playerName: "Alice",
    113             puzzleTitle: "Tuesday"
    114         )
    115         #expect(firstPause[0].payload
    116             == PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0), playerName: "Alice"))
    117 
    118         // Second pause after a bounce with no new move: readThrough is still stale,
    119         // but the watermark already covers `edit`, so nothing is re-reported.
    120         let secondPause = SessionPushPlanner.sessionEndAddressees(
    121             recipients: [recipient("addr", readThrough: staleReadAt, notifiedThrough: edit)],
    122             journalEntries: entries,
    123             selfAuthorID: "author",
    124             playerName: "Alice",
    125             puzzleTitle: "Tuesday"
    126         )
    127         #expect(secondPause.isEmpty)
    128     }
    129 
    130     @Test("A present-but-backgrounded recipient still gets a summary (lease ≠ watermark)")
    131     func presenceLeaseDoesNotSuppressSummary() {
    132         // Regression for the "moves via banner, no push" bug. The recipient held
    133         // a *presence lease* dated into the future (they were "present"), then
    134         // backgrounded before our edit. Their read *watermark* (`readThrough`)
    135         // is what the planner sees, and it sits before the edit — so the fill is
    136         // still news and must be summarised. The forward-dated lease is no
    137         // longer an input here, which is the whole point of the split.
    138         let edit = Date(timeIntervalSince1970: 10_000)
    139         let entries = [entry("X", at: edit, seq: 1, col: 0)]
    140         // Watermark from when they last actually looked, well before the edit.
    141         let watermarkBeforeEdit = edit.addingTimeInterval(-600)
    142 
    143         let addressees = SessionPushPlanner.sessionEndAddressees(
    144             recipients: [recipient("addr", readThrough: watermarkBeforeEdit)],
    145             journalEntries: entries,
    146             selfAuthorID: "author",
    147             playerName: "Alice",
    148             puzzleTitle: "Tuesday"
    149         )
    150 
    151         #expect(addressees.count == 1)
    152         #expect(addressees[0].payload
    153             == PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0), playerName: "Alice"))
    154         #expect(addressees[0].body == "Alice filled 1 letter in the puzzle 'Tuesday'")
    155     }
    156 
    157     @Test("A check-only session reaches no one — checks aren't letter changes")
    158     func checkOnlySessionDropped() {
    159         let edit = Date(timeIntervalSince1970: 1_000)
    160         let batch = UUID()
    161         // Two letters typed before the cutoff, then one check gesture marks both.
    162         // The mark change re-stamps each cell but leaves the letters intact, so
    163         // there is no unseen letter change to report.
    164         let entries = [
    165             entry("A", at: edit.addingTimeInterval(-120), seq: 1, col: 0),
    166             entry("B", at: edit.addingTimeInterval(-120), seq: 2, col: 1),
    167             entry("A", at: edit, seq: 3, col: 0, kind: .check, batch: batch),
    168             entry("B", at: edit, seq: 4, col: 1, kind: .check, batch: batch)
    169         ]
    170         let behind = recipient("addr-check", readThrough: edit.addingTimeInterval(-60))
    171 
    172         let addressees = SessionPushPlanner.sessionEndAddressees(
    173             recipients: [behind],
    174             journalEntries: entries,
    175             selfAuthorID: "author",
    176             playerName: "Alice",
    177             puzzleTitle: "Tuesday"
    178         )
    179 
    180         #expect(addressees.isEmpty)
    181     }
    182 
    183     @Test("Checking a peer's letter is never a fill, so a peer-check session reaches no one")
    184     func checkingPeerLetterIsNotAFill() {
    185         // Regression: a peer filled these cells; in *this* author's journal they
    186         // appear only as check entries that carry the peer's letter but preserve
    187         // the peer's `cellAuthorID`. The whole-grid check of an already-filled
    188         // puzzle must not read as a wall of phantom fills — with no genuine
    189         // letter change, no push goes out.
    190         let edit = Date(timeIntervalSince1970: 1_000)
    191         let batch = UUID()
    192         let entries = [
    193             entry("P", at: edit, seq: 1, col: 0, kind: .check, batch: batch, cellAuthorID: "peer"),
    194             entry("Q", at: edit, seq: 2, col: 1, kind: .check, batch: batch, cellAuthorID: "peer")
    195         ]
    196         let behind = recipient("addr-peercheck", readThrough: edit.addingTimeInterval(-60))
    197 
    198         let addressees = SessionPushPlanner.sessionEndAddressees(
    199             recipients: [behind],
    200             journalEntries: entries,
    201             selfAuthorID: "author",
    202             playerName: "Alice",
    203             puzzleTitle: "Tuesday"
    204         )
    205 
    206         #expect(addressees.isEmpty)
    207     }
    208 
    209     @Test("Filling then checking one's own cell still counts as a fill")
    210     func checkingOwnFillStillCounts() {
    211         // The flip side of the peer-check fix: a cell this author filled in the
    212         // window and then checked (inking a pencil entry) keeps `cellAuthorID`
    213         // as theirs, so it must not be dropped from the fill count.
    214         let edit = Date(timeIntervalSince1970: 1_000)
    215         let batch = UUID()
    216         let entries = [
    217             entry("A", at: edit, seq: 1, col: 0),
    218             entry("A", at: edit.addingTimeInterval(1), seq: 2, col: 0, kind: .check, batch: batch)
    219         ]
    220         let behind = recipient("addr-owncheck", readThrough: edit.addingTimeInterval(-60))
    221 
    222         let addressees = SessionPushPlanner.sessionEndAddressees(
    223             recipients: [behind],
    224             journalEntries: entries,
    225             selfAuthorID: "author",
    226             playerName: "Alice",
    227             puzzleTitle: "Tuesday"
    228         )
    229 
    230         #expect(addressees[0].payload
    231             == PushPayload(event: .pause(fills: 1, clears: 0, checks: 1, reveals: 0), playerName: "Alice"))
    232         #expect(addressees[0].body
    233             == "Alice filled 1 letter and ran 1 check in the puzzle 'Tuesday'")
    234     }
    235 
    236     @Test("A reveal is attributed to reveals, never counted as a fill")
    237     func revealGestureCounted() {
    238         let edit = Date(timeIntervalSince1970: 1_000)
    239         let batch = UUID()
    240         let entries = [
    241             entry("Z", at: edit, seq: 1, col: 0, kind: .reveal, batch: batch),
    242             entry("Q", at: edit, seq: 2, col: 1, kind: .reveal, batch: batch)
    243         ]
    244         let behind = recipient("addr-reveal", readThrough: edit.addingTimeInterval(-60))
    245 
    246         let addressees = SessionPushPlanner.sessionEndAddressees(
    247             recipients: [behind],
    248             journalEntries: entries,
    249             selfAuthorID: "author",
    250             playerName: "Alice",
    251             puzzleTitle: "Tuesday"
    252         )
    253 
    254         #expect(addressees[0].payload
    255             == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 1), playerName: "Alice"))
    256         #expect(addressees[0].body == "Alice ran 1 reveal in the puzzle 'Tuesday'")
    257     }
    258 
    259     @Test("All four tallies combine into one sentence")
    260     func mixedGestures() {
    261         let edit = Date(timeIntervalSince1970: 1_000)
    262         let before = edit.addingTimeInterval(-120)
    263         let checkBatch = UUID()
    264         let revealBatch = UUID()
    265         let entries = [
    266             // fill
    267             entry("A", at: edit, seq: 10, col: 0),
    268             // clear (seen filled before the cutoff)
    269             entry("B", at: before, seq: 1, col: 1),
    270             entry("", at: edit, seq: 11, col: 1),
    271             // check (seen filled before the cutoff)
    272             entry("C", at: before, seq: 2, col: 2),
    273             entry("C", at: edit, seq: 12, col: 2, kind: .check, batch: checkBatch),
    274             // reveal
    275             entry("D", at: edit, seq: 13, col: 3, kind: .reveal, batch: revealBatch)
    276         ]
    277         let behind = recipient("addr-mixed", readThrough: edit.addingTimeInterval(-60))
    278 
    279         let addressees = SessionPushPlanner.sessionEndAddressees(
    280             recipients: [behind],
    281             journalEntries: entries,
    282             selfAuthorID: "author",
    283             playerName: "Alice",
    284             puzzleTitle: "Tuesday"
    285         )
    286 
    287         #expect(addressees[0].payload
    288             == PushPayload(event: .pause(fills: 1, clears: 1, checks: 1, reveals: 1), playerName: "Alice"))
    289         #expect(addressees[0].body
    290             == "Alice filled 1 letter, cleared 1 letter and ran 1 check and 1 reveal in the puzzle 'Tuesday'")
    291     }
    292 
    293     @Test("A cell typed then deleted within the window nets to nothing, so no push goes out")
    294     func netPerCellIgnoresTypeThenDelete() {
    295         let edit = Date(timeIntervalSince1970: 1_000)
    296         let entries = [
    297             entry("A", at: edit, seq: 1, col: 0),
    298             entry("", at: edit.addingTimeInterval(1), seq: 2, col: 0)
    299         ]
    300         let behind = recipient("addr-noop", readThrough: edit.addingTimeInterval(-60))
    301 
    302         let addressees = SessionPushPlanner.sessionEndAddressees(
    303             recipients: [behind],
    304             journalEntries: entries,
    305             selfAuthorID: "author",
    306             playerName: "Alice",
    307             puzzleTitle: "Tuesday"
    308         )
    309 
    310         #expect(addressees.isEmpty)
    311     }
    312 
    313     @Test("Diagnostics ride each recipient's payload, stamped with that recipient's readThrough")
    314     func diagnosticsStampedPerRecipient() {
    315         let edit = Date(timeIntervalSince1970: 1_000)
    316         let entries = [entry("X", at: edit, seq: 1, col: 0)]
    317         let aReadAt = edit.addingTimeInterval(-60)
    318         let recipientA = recipient("addr-a", readThrough: aReadAt)
    319         let recipientB = recipient("addr-b", readThrough: nil)
    320         let base = PushPayload.Diagnostics(
    321             gridWidth: 15,
    322             gridHeight: 15,
    323             mergedCells: 200
    324         )
    325 
    326         let addressees = SessionPushPlanner.sessionEndAddressees(
    327             recipients: [recipientA, recipientB],
    328             journalEntries: entries,
    329             selfAuthorID: "author",
    330             playerName: "Alice",
    331             puzzleTitle: "Tuesday",
    332             diagnostics: base
    333         )
    334 
    335         let byAddress = Dictionary(uniqueKeysWithValues: addressees.map { ($0.address, $0) })
    336         let diagnosticsA = byAddress["addr-a"]?.payload?.diagnostics
    337         #expect(diagnosticsA?.recipientReadAt == aReadAt)
    338         #expect(diagnosticsA?.gridWidth == 15)
    339         #expect(diagnosticsA?.mergedCells == 200)
    340         // A recipient with no cursor keeps recipientReadAt nil — itself
    341         // diagnostic, since the count then covered the whole history.
    342         let diagnosticsB = byAddress["addr-b"]?.payload?.diagnostics
    343         #expect(diagnosticsB?.recipientReadAt == nil)
    344         #expect(diagnosticsB?.gridWidth == 15)
    345     }
    346 
    347     @Test("Recipients without a push capability are dropped")
    348     func unaddressableDropped() {
    349         let edit = Date(timeIntervalSince1970: 1_000)
    350         let addressees = SessionPushPlanner.sessionEndAddressees(
    351             recipients: [recipient(nil, readThrough: nil)],
    352             journalEntries: [entry("X", at: edit, seq: 1, col: 0)],
    353             selfAuthorID: "author",
    354             playerName: "Alice",
    355             puzzleTitle: "Tuesday"
    356         )
    357 
    358         #expect(addressees.isEmpty)
    359     }
    360 
    361     @Test("Only the behind recipient is addressed; the caught-up one is dropped")
    362     func behindAddressedCaughtUpDropped() {
    363         let edit = Date(timeIntervalSince1970: 1_000)
    364         let entries = [entry("X", at: edit, seq: 1, col: 0)]
    365         let caughtUp = recipient("addr-caught-up", readThrough: edit.addingTimeInterval(60))
    366         let behind = recipient("addr-behind", readThrough: edit.addingTimeInterval(-60))
    367 
    368         let addressees = SessionPushPlanner.sessionEndAddressees(
    369             recipients: [caughtUp, behind],
    370             journalEntries: entries,
    371             selfAuthorID: "author",
    372             playerName: "Alice",
    373             puzzleTitle: "Tuesday"
    374         )
    375 
    376         let byAddress = Dictionary(uniqueKeysWithValues: addressees.map { ($0.address, $0) })
    377         #expect(addressees.count == 1)
    378         #expect(byAddress["addr-caught-up"] == nil)
    379         #expect(byAddress["addr-behind"]?.payload?.marksUnread == true)
    380     }
    381 }