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 }