crossmate

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

commit 10e80df6a2f34ccd4267252bcbc4c14795fbee7e
parent fd383b72e1809739db5bc6851dbfdfc86a08db53
Author: Michael Camilleri <[email protected]>
Date:   Tue,  2 Jun 2026 17:02:10 +0900

Summarise a paused session by gesture not touch

The pause push body was built from a net merged-grid diff: every cell in
the author's merged-across-devices Moves whose updatedAt was newer than
the recipient's Player.readAt, partitioned by empty/filled into 'added'
and 'cleared' letters. That diff carries no gesture identity, so a bulk
'help' action read as a wall of letter churn. A whole-grid check
re-marks every filled cell (a mark change re-stamps the Moves cell), and
clear/reveal touch the grid wholesale, so a single gesture re-stamped
essentially every playable cell.

The count now comes from the sender's own local journal
(GameStore.localJournalEntries) rather than the merged grid. Each
JournalValue already records its JournalKind and a per-gesture batchID,
which is exactly the information the merged-cell diff threw away.
SessionPushPlanner.tally walks the per-cell history once and, windowed
per recipient by readAt, reports four numbers:

1. fills / clears — net-per-cell letter change (the cell's final letter
   vs. the letter the recipient last saw at readAt). A cell typed then
   deleted within the window nets to nothing. A check changes only
   marks, so it can never move a letter count; a reveal-touched cell is
   attributed to reveals (a reveal locks the cell, so its final letter
   is the reveal's) and never counts as a fill.

2. checks / reveals — gesture counts, the number of distinct check /
   reveal batches in the window, so a whole-grid check is one check.

PushPayload.Event.pause carries (fills, clears, checks, reveals) and its
wire keys are renamed added/cleared -> fills/clears. No back-compat is
kept for the old key names. marksUnread is now true when any of the four
is non-zero — a check or a reveal alters the shared grid the recipient
will see on opening, so it is worth a badge.
PuzzleNotificationText.pauseBody composes the clauses, folding checks
and reveals into one 'ran …' clause, e.g. 'filled 5 letters, cleared 2
letters and ran 1 check and 1 reveal'.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Models/PuzzleNotificationText.swift | 56+++++++++++++++++++++++++++++++++++++-------------------
MCrossmate/Services/AppServices.swift | 14++++++++++----
MCrossmate/Services/SessionPushPlanner.swift | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
MShared/PushPayload.swift | 33+++++++++++++++++++++++----------
MTests/Unit/PushPayloadTests.swift | 31+++++++++++++++++++++++--------
MTests/Unit/PuzzleNotificationTextTests.swift | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
MTests/Unit/SessionPushPlannerTests.swift | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
7 files changed, 390 insertions(+), 89 deletions(-)

diff --git a/Crossmate/Models/PuzzleNotificationText.swift b/Crossmate/Models/PuzzleNotificationText.swift @@ -16,35 +16,53 @@ enum PuzzleNotificationText { ) } - /// Body for a session-end push, addressed to a single recipient. The - /// `added` / `cleared` counts describe cells *that recipient* hasn't - /// seen yet (cells in the author's merged-across-devices Moves whose - /// `updatedAt` is newer than that recipient's last-known - /// `Player.readAt`). When both counts are zero the recipient still gets - /// the push as a presence signal ("stopped solving") — the session end is - /// worth surfacing even with nothing unseen — but the payload's zero - /// counts keep it from bumping the badge. + /// Body for a session-end push, addressed to a single recipient, + /// describing what the peer did since that recipient last looked (entries + /// in the peer's journal newer than the recipient's last-known + /// `Player.readAt`): net letter `fills` / `clears`, and the number of + /// `checks` / `reveals` *gestures* run. When every count is zero the + /// recipient still gets the push as a presence signal ("stopped solving") + /// — the session end is worth surfacing even with nothing unseen — but the + /// payload's zero counts keep it from bumping the badge. static func pauseBody( playerName: String, puzzleTitle: String, - added: Int, - cleared: Int + fills: Int, + clears: Int, + checks: Int, + reveals: Int ) -> String { let resolvedName = playerName.isEmpty ? "A player" : playerName let puzzleSuffix = puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(puzzleTitle)'" - var parts: [String] = [] - if added > 0 { - parts.append("added \(added) \(added == 1 ? "letter" : "letters")") - } - if cleared > 0 { - parts.append("cleared \(cleared) \(cleared == 1 ? "letter" : "letters")") - } - if parts.isEmpty { + + func letters(_ n: Int) -> String { "\(n) \(n == 1 ? "letter" : "letters")" } + + var clauses: [String] = [] + if fills > 0 { clauses.append("filled \(letters(fills))") } + if clears > 0 { clauses.append("cleared \(letters(clears))") } + // Checks and reveals are help gestures; fold them into one "ran …" + // clause so the sentence doesn't repeat the verb. + var help: [String] = [] + if checks > 0 { help.append("\(checks) \(checks == 1 ? "check" : "checks")") } + if reveals > 0 { help.append("\(reveals) \(reveals == 1 ? "reveal" : "reveals")") } + if !help.isEmpty { clauses.append("ran \(joinList(help))") } + + if clauses.isEmpty { return "\(resolvedName) stopped solving \(puzzleSuffix)." } - return "\(resolvedName) \(parts.joined(separator: " and ")) in \(puzzleSuffix)" + return "\(resolvedName) \(joinList(clauses)) in \(puzzleSuffix)" + } + + /// Joins clauses into prose: "a", "a and b", or "a, b and c". + private static func joinList(_ parts: [String]) -> String { + switch parts.count { + case 0: return "" + case 1: return parts[0] + case 2: return "\(parts[0]) and \(parts[1])" + default: return "\(parts.dropLast().joined(separator: ", ")) and \(parts[parts.count - 1])" + } } private static func subtitle(publisher: String?, date: Date?) -> String? { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -917,7 +917,11 @@ final class AppServices { syncMonitor.note("push(pause): skipped (access revoked)") return } - let mergedCells = store.mergedAuthorCells(for: gameID, by: localAuthorID) + // The pause counts are derived from this device's own journal (gesture + // history), not the merged grid, so the summary can name fills/clears/ + // checks/reveals. The merged-grid measurements still ride the + // diagnostics block below for context. + let journalEntries = store.localJournalEntries(for: gameID) // Sender-side diagnostics: store-derived measurements plus this // device's clock and the session-start it announced. Rides the // per-recipient payload (the planner stamps each recipient's readAt) @@ -931,7 +935,7 @@ final class AppServices { // `SessionPushPlanner.sessionEndAddressees`). let addressees = SessionPushPlanner.sessionEndAddressees( recipients: plan.recipients, - mergedCells: mergedCells, + journalEntries: journalEntries, playerName: preferences.name, puzzleTitle: plan.title, diagnostics: diagnostics @@ -946,8 +950,10 @@ final class AppServices { let fallbackBody = PuzzleNotificationText.pauseBody( playerName: preferences.name, puzzleTitle: plan.title, - added: 0, - cleared: 0 + fills: 0, + clears: 0, + checks: 0, + reveals: 0 ) await pushClient.publish( kind: "pause", diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift @@ -47,26 +47,41 @@ enum SessionPushPlanner { /// even when nothing changed. The per-recipient `PushPayload` counts let /// the extension decide the badge; the body renders "stopped solving" at /// zero. Recipients with no published push capability are dropped. + /// + /// `journalEntries` is the sender's own local journal for the game (this + /// device's recorded moves, in seq order). Counts are derived from it — + /// not the merged grid — so the summary can name *gestures*: net letter + /// `fills` / `clears` plus the number of `check` / `reveal` gestures, each + /// windowed to entries newer than the recipient's `readAt`. The journal is + /// this device's only, so a session run on another of the author's devices + /// is described by *that* device's own pause; "eventual consistency is OK" + /// covers the gap. static func sessionEndAddressees( recipients: [AppServices.PushRecipient], - mergedCells: [TimestampedCell], + journalEntries: [JournalValue], playerName: String, puzzleTitle: String, diagnostics: PushPayload.Diagnostics? = nil ) -> [PushClient.Addressee] { - recipients.compactMap { recipient -> PushClient.Addressee? in + // Per-cell history in seq order, computed once and reused per recipient. + var history: [GridPosition: [JournalValue]] = [:] + for entry in journalEntries { + history[entry.position, default: []].append(entry) + } + for key in history.keys { + history[key]?.sort { $0.seq < $1.seq } + } + + return recipients.compactMap { recipient -> PushClient.Addressee? in guard let address = recipient.pushAddress else { return nil } - let readAt = recipient.readAt ?? .distantPast - var added = 0 - var cleared = 0 - for cell in mergedCells where cell.updatedAt > readAt { - if cell.letter.isEmpty { cleared += 1 } else { added += 1 } - } + let counts = tally(history: history, since: recipient.readAt ?? .distantPast) let body = PuzzleNotificationText.pauseBody( playerName: playerName, puzzleTitle: puzzleTitle, - added: added, - cleared: cleared + fills: counts.fills, + clears: counts.clears, + checks: counts.checks, + reveals: counts.reveals ) // Stamp the exact cutoff this recipient's diff used. `nil` readAt // (no cursor yet) is preserved as nil — itself diagnostic, since it @@ -77,10 +92,68 @@ enum SessionPushPlanner { address: address, body: body, payload: PushPayload( - event: .pause(added: added, cleared: cleared), + event: .pause( + fills: counts.fills, + clears: counts.clears, + checks: counts.checks, + reveals: counts.reveals + ), diagnostics: perRecipient ) ) } } + + private struct SessionCounts { + var fills = 0 + var clears = 0 + var checks = 0 + var reveals = 0 + } + + /// Tallies what the author did after `cutoff`, given the per-cell journal + /// history. Letter `fills` / `clears` are net-per-cell (the cell's final + /// letter vs. the letter the recipient last saw at `cutoff`); `checks` / + /// `reveals` are gesture counts (distinct batches). A reveal-touched cell is + /// attributed to `reveals`, never the letter counts; a check changes only + /// marks, so it can't shift a letter count. + private static func tally( + history: [GridPosition: [JournalValue]], + since cutoff: Date + ) -> SessionCounts { + var counts = SessionCounts() + var checkBatches: Set<String> = [] + var revealBatches: Set<String> = [] + + for (_, entries) in history { + guard entries.contains(where: { $0.timestamp > cutoff }) else { continue } + + for entry in entries where entry.timestamp > cutoff { + let key = entry.batchID?.uuidString ?? "seq-\(entry.seq)" + switch entry.kind { + case .check: checkBatches.insert(key) + case .reveal: revealBatches.insert(key) + default: break + } + } + + // A revealed cell is locked, so its final letter is the reveal's — + // count it under reveals, not as a fill. + if entries.contains(where: { $0.timestamp > cutoff && $0.kind == .reveal }) { + continue + } + + let before = entries.last(where: { $0.timestamp <= cutoff })?.state.letter ?? "" + let after = entries.last?.state.letter ?? "" + if !after.isEmpty, after != before { + counts.fills += 1 + } else if after.isEmpty, !before.isEmpty { + counts.clears += 1 + } + } + + counts.checks = checkBatches.count + counts.reveals = revealBatches.count + return counts + } } diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -106,7 +106,13 @@ struct PushPayload: Codable, Sendable, Equatable { enum Event: Sendable, Equatable { case play - case pause(added: Int, cleared: Int) + /// A session-end summary, broken down by what the peer did since the + /// recipient last looked: net letter `fills` / `clears`, and the count + /// of `checks` / `reveals` *gestures* run. Letter counts are + /// net-per-cell (a typed-then-deleted cell nets to nothing) and never + /// include reveal fills — those are owned by `reveals`. A check changes + /// only marks, so it never touches the letter counts. + case pause(fills: Int, clears: Int, checks: Int, reveals: Int) case win case resign /// An event introduced by a newer build. Treated as carrying no @@ -115,10 +121,13 @@ struct PushPayload: Codable, Sendable, Equatable { /// True when the event represents grid changes the recipient hasn't /// seen — the sole input to whether a delivered push marks its game - /// unread (and so bumps the app-icon badge). + /// unread (and so bumps the app-icon badge). Any of the four pause + /// tallies counts: a reveal or even a check alters the shared grid the + /// recipient will see on opening. var marksUnread: Bool { switch self { - case .pause(let added, let cleared): return added + cleared > 0 + case .pause(let fills, let clears, let checks, let reveals): + return fills + clears + checks + reveals > 0 case .win, .resign: return true case .play, .unknown: return false } @@ -149,7 +158,7 @@ extension PushPayload { extension PushPayload.Event: Codable { private enum CodingKeys: String, CodingKey { - case type, added, cleared + case type, fills, clears, checks, reveals } private enum Discriminator: String { @@ -163,9 +172,11 @@ extension PushPayload.Event: Codable { case .play: self = .play case .pause: - let added = try container.decodeIfPresent(Int.self, forKey: .added) ?? 0 - let cleared = try container.decodeIfPresent(Int.self, forKey: .cleared) ?? 0 - self = .pause(added: added, cleared: cleared) + let fills = try container.decodeIfPresent(Int.self, forKey: .fills) ?? 0 + let clears = try container.decodeIfPresent(Int.self, forKey: .clears) ?? 0 + let checks = try container.decodeIfPresent(Int.self, forKey: .checks) ?? 0 + let reveals = try container.decodeIfPresent(Int.self, forKey: .reveals) ?? 0 + self = .pause(fills: fills, clears: clears, checks: checks, reveals: reveals) case .win: self = .win case .resign: @@ -181,10 +192,12 @@ extension PushPayload.Event: Codable { switch self { case .play: try container.encode(Discriminator.play.rawValue, forKey: .type) - case .pause(let added, let cleared): + case .pause(let fills, let clears, let checks, let reveals): try container.encode(Discriminator.pause.rawValue, forKey: .type) - try container.encode(added, forKey: .added) - try container.encode(cleared, forKey: .cleared) + try container.encode(fills, forKey: .fills) + try container.encode(clears, forKey: .clears) + try container.encode(checks, forKey: .checks) + try container.encode(reveals, forKey: .reveals) case .win: try container.encode(Discriminator.win.rawValue, forKey: .type) case .resign: diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift @@ -14,8 +14,9 @@ struct PushPayloadTests { func eventsRoundTrip() throws { let cases: [PushPayload.Event] = [ .play, - .pause(added: 3, cleared: 2), - .pause(added: 0, cleared: 0), + .pause(fills: 3, clears: 2, checks: 1, reveals: 0), + .pause(fills: 0, clears: 0, checks: 0, reveals: 0), + .pause(fills: 0, clears: 0, checks: 0, reveals: 2), .win, .resign ] @@ -65,7 +66,7 @@ struct PushPayloadTests { latestEdit: Date(timeIntervalSince1970: 1_900) ) let payload = PushPayload( - event: .pause(added: 125, cleared: 75), + event: .pause(fills: 125, clears: 75, checks: 3, reveals: 1), diagnostics: diagnostics ) @@ -78,13 +79,24 @@ struct PushPayloadTests { @Test("A pause without diagnostics decodes with nil diagnostics") func diagnosticsAbsenceTolerated() throws { - let json = #"{"version":1,"event":{"type":"pause","added":1,"cleared":0}}"# + let json = #"{"version":1,"event":{"type":"pause","fills":1,"clears":0,"checks":0,"reveals":0}}"# let encoded = Data(json.utf8).base64EncodedString() let decoded = try #require(PushPayload.decode(from: encoded)) #expect(decoded.diagnostics == nil) - #expect(decoded.event == .pause(added: 1, cleared: 0)) + #expect(decoded.event == .pause(fills: 1, clears: 0, checks: 0, reveals: 0)) + } + + @Test("A pause missing the gesture counts decodes them as zero") + func pauseGestureCountsDefaultToZero() throws { + // A sender that only wrote letter counts (or a future trimmed payload). + let json = #"{"version":1,"event":{"type":"pause","fills":2,"clears":1}}"# + let encoded = Data(json.utf8).base64EncodedString() + + let decoded = try #require(PushPayload.decode(from: encoded)) + + #expect(decoded.event == .pause(fills: 2, clears: 1, checks: 0, reveals: 0)) } @Test("Diagnostics summary renders values and dashes for absent fields") @@ -104,12 +116,15 @@ struct PushPayloadTests { @Test("Only unseen content marks a game unread") func marksUnreadMatrix() { - #expect(PushPayload(event: .pause(added: 1, cleared: 0)).marksUnread) - #expect(PushPayload(event: .pause(added: 0, cleared: 2)).marksUnread) + #expect(PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0)).marksUnread) + #expect(PushPayload(event: .pause(fills: 0, clears: 2, checks: 0, reveals: 0)).marksUnread) + // A check or a reveal alone still changes the shared grid. + #expect(PushPayload(event: .pause(fills: 0, clears: 0, checks: 1, reveals: 0)).marksUnread) + #expect(PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 1)).marksUnread) #expect(PushPayload(event: .win).marksUnread) #expect(PushPayload(event: .resign).marksUnread) - #expect(!PushPayload(event: .pause(added: 0, cleared: 0)).marksUnread) + #expect(!PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0)).marksUnread) #expect(!PushPayload(event: .play).marksUnread) #expect(!PushPayload(event: .unknown).marksUnread) } diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -32,44 +32,89 @@ struct PuzzleNotificationTextTests { #expect(AppServices.bodyText(for: ping) == "system-only ping should not be presented") } - @Test("pauseBody combines added and cleared counts when both are non-zero") - func pauseBodyAddedAndCleared() { + @Test("pauseBody combines fills and clears counts when both are non-zero") + func pauseBodyFillsAndClears() { let body = PuzzleNotificationText.pauseBody( playerName: "Alice", puzzleTitle: "Saturday Puzzle", - added: 3, - cleared: 2 + fills: 3, + clears: 2, + checks: 0, + reveals: 0 ) - #expect(body == "Alice added 3 letters and cleared 2 letters in the puzzle 'Saturday Puzzle'") + #expect(body == "Alice filled 3 letters and cleared 2 letters in the puzzle 'Saturday Puzzle'") } @Test("pauseBody pluralises only multi-letter counts") func pauseBodyPluralisation() { - let added = PuzzleNotificationText.pauseBody( + let filled = PuzzleNotificationText.pauseBody( playerName: "Alice", puzzleTitle: "X", - added: 1, - cleared: 0 + fills: 1, + clears: 0, + checks: 0, + reveals: 0 ) let cleared = PuzzleNotificationText.pauseBody( playerName: "Alice", puzzleTitle: "X", - added: 0, - cleared: 1 + fills: 0, + clears: 1, + checks: 0, + reveals: 0 ) - #expect(added == "Alice added 1 letter in the puzzle 'X'") + #expect(filled == "Alice filled 1 letter in the puzzle 'X'") #expect(cleared == "Alice cleared 1 letter in the puzzle 'X'") } - @Test("pauseBody falls back to no-edits wording when both counts are zero") + @Test("pauseBody reports check and reveal gestures, folded into one clause") + func pauseBodyGestures() { + let checkOnly = PuzzleNotificationText.pauseBody( + playerName: "Alice", + puzzleTitle: "X", + fills: 0, + clears: 0, + checks: 1, + reveals: 0 + ) + let both = PuzzleNotificationText.pauseBody( + playerName: "Alice", + puzzleTitle: "X", + fills: 0, + clears: 0, + checks: 2, + reveals: 1 + ) + + #expect(checkOnly == "Alice ran 1 check in the puzzle 'X'") + #expect(both == "Alice ran 2 checks and 1 reveal in the puzzle 'X'") + } + + @Test("pauseBody joins all four clauses with an Oxford comma") + func pauseBodyAllClauses() { + let body = PuzzleNotificationText.pauseBody( + playerName: "Alice", + puzzleTitle: "X", + fills: 5, + clears: 2, + checks: 1, + reveals: 1 + ) + + #expect(body == "Alice filled 5 letters, cleared 2 letters and ran 1 check and 1 reveal in the puzzle 'X'") + } + + @Test("pauseBody falls back to no-edits wording when every count is zero") func pauseBodyZeroCounts() { let body = PuzzleNotificationText.pauseBody( playerName: "Alice", puzzleTitle: "Saturday Puzzle", - added: 0, - cleared: 0 + fills: 0, + clears: 0, + checks: 0, + reveals: 0 ) #expect(body == "Alice stopped solving the puzzle 'Saturday Puzzle'.") @@ -80,11 +125,13 @@ struct PuzzleNotificationTextTests { let body = PuzzleNotificationText.pauseBody( playerName: "", puzzleTitle: "", - added: 2, - cleared: 0 + fills: 2, + clears: 0, + checks: 0, + reveals: 0 ) - #expect(body == "A player added 2 letters in the puzzle") + #expect(body == "A player filled 2 letters in the puzzle") } @Test("Invite body names the inviter and puzzle") diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift @@ -45,14 +45,28 @@ struct SessionAnnouncementLogTests { @Suite("Session push planner") struct SessionPushPlannerTests { - private func cell(_ letter: String, at updatedAt: Date) -> TimestampedCell { - TimestampedCell( - letter: letter, - markKind: 0, - checkedRight: false, - checkedWrong: false, - updatedAt: updatedAt, - authorID: "author" + /// One journal entry. `seq` orders entries within a cell; `kind` and + /// `batch` drive the gesture tallies. + private func entry( + _ letter: String, + at timestamp: Date, + seq: Int64, + row: Int = 0, + col: Int = 0, + kind: JournalKind = .input, + batch: UUID? = nil + ) -> JournalValue { + JournalValue( + seq: seq, + timestamp: timestamp, + position: GridPosition(row: row, col: col), + state: JournalCellState(letter: letter, mark: .none, cellAuthorID: "author"), + actingAuthorID: "author", + kind: kind, + targetSeq: nil, + batchID: batch, + prevSeqAtCell: nil, + direction: nil ) } @@ -63,44 +77,159 @@ struct SessionPushPlannerTests { @Test("A caught-up recipient is still addressed, with a presence-only payload") func caughtUpRecipientIncluded() { let edit = Date(timeIntervalSince1970: 1_000) - let cells = [cell("X", at: edit), cell("", at: edit)] + let entries = [ + entry("X", at: edit, seq: 1, col: 0), + entry("", at: edit, seq: 2, col: 1) + ] let seenEverything = recipient("addr-1", readAt: edit.addingTimeInterval(60)) let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [seenEverything], - mergedCells: cells, + journalEntries: entries, playerName: "Bunny", puzzleTitle: "Tuesday" ) #expect(addressees.count == 1) - #expect(addressees[0].payload == PushPayload(event: .pause(added: 0, cleared: 0))) + #expect(addressees[0].payload + == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0))) #expect(addressees[0].payload?.marksUnread == false) #expect(addressees[0].body == "Bunny stopped solving the puzzle 'Tuesday'.") } - @Test("A behind recipient gets the unseen counts and a badge-marking payload") + @Test("A behind recipient gets net fills/clears and a badge-marking payload") func behindRecipientCounts() { let edit = Date(timeIntervalSince1970: 1_000) - let cells = [cell("X", at: edit), cell("", at: edit)] // 1 added, 1 cleared + let entries = [ + // (0,0): empty → "X" after the cutoff = one fill. + entry("X", at: edit, seq: 2, col: 0), + // (0,1): "Y" seen before the cutoff, then emptied after = one clear. + entry("Y", at: edit.addingTimeInterval(-120), seq: 1, col: 1), + entry("", at: edit, seq: 3, col: 1) + ] let behind = recipient("addr-2", readAt: edit.addingTimeInterval(-60)) let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [behind], - mergedCells: cells, + journalEntries: entries, playerName: "Bunny", puzzleTitle: "Tuesday" ) #expect(addressees.count == 1) - #expect(addressees[0].payload == PushPayload(event: .pause(added: 1, cleared: 1))) + #expect(addressees[0].payload + == PushPayload(event: .pause(fills: 1, clears: 1, checks: 0, reveals: 0))) #expect(addressees[0].payload?.marksUnread == true) + #expect(addressees[0].body + == "Bunny filled 1 letter and cleared 1 letter in the puzzle 'Tuesday'") + } + + @Test("A whole-grid check reports one check gesture, not a wall of letter churn") + func checkGestureCounted() { + let edit = Date(timeIntervalSince1970: 1_000) + let batch = UUID() + // Two letters typed before the cutoff, then one check gesture marks both. + // The mark change re-stamps each cell but leaves the letters intact. + let entries = [ + entry("A", at: edit.addingTimeInterval(-120), seq: 1, col: 0), + entry("B", at: edit.addingTimeInterval(-120), seq: 2, col: 1), + entry("A", at: edit, seq: 3, col: 0, kind: .check, batch: batch), + entry("B", at: edit, seq: 4, col: 1, kind: .check, batch: batch) + ] + let behind = recipient("addr-check", readAt: edit.addingTimeInterval(-60)) + + let addressees = SessionPushPlanner.sessionEndAddressees( + recipients: [behind], + journalEntries: entries, + playerName: "Bunny", + puzzleTitle: "Tuesday" + ) + + #expect(addressees[0].payload + == PushPayload(event: .pause(fills: 0, clears: 0, checks: 1, reveals: 0))) + #expect(addressees[0].body == "Bunny ran 1 check in the puzzle 'Tuesday'") + } + + @Test("A reveal is attributed to reveals, never counted as a fill") + func revealGestureCounted() { + let edit = Date(timeIntervalSince1970: 1_000) + let batch = UUID() + let entries = [ + entry("Z", at: edit, seq: 1, col: 0, kind: .reveal, batch: batch), + entry("Q", at: edit, seq: 2, col: 1, kind: .reveal, batch: batch) + ] + let behind = recipient("addr-reveal", readAt: edit.addingTimeInterval(-60)) + + let addressees = SessionPushPlanner.sessionEndAddressees( + recipients: [behind], + journalEntries: entries, + playerName: "Bunny", + puzzleTitle: "Tuesday" + ) + + #expect(addressees[0].payload + == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 1))) + #expect(addressees[0].body == "Bunny ran 1 reveal in the puzzle 'Tuesday'") + } + + @Test("All four tallies combine into one sentence") + func mixedGestures() { + let edit = Date(timeIntervalSince1970: 1_000) + let before = edit.addingTimeInterval(-120) + let checkBatch = UUID() + let revealBatch = UUID() + let entries = [ + // fill + entry("A", at: edit, seq: 10, col: 0), + // clear (seen filled before the cutoff) + entry("B", at: before, seq: 1, col: 1), + entry("", at: edit, seq: 11, col: 1), + // check (seen filled before the cutoff) + entry("C", at: before, seq: 2, col: 2), + entry("C", at: edit, seq: 12, col: 2, kind: .check, batch: checkBatch), + // reveal + entry("D", at: edit, seq: 13, col: 3, kind: .reveal, batch: revealBatch) + ] + let behind = recipient("addr-mixed", readAt: edit.addingTimeInterval(-60)) + + let addressees = SessionPushPlanner.sessionEndAddressees( + recipients: [behind], + journalEntries: entries, + playerName: "Bunny", + puzzleTitle: "Tuesday" + ) + + #expect(addressees[0].payload + == PushPayload(event: .pause(fills: 1, clears: 1, checks: 1, reveals: 1))) + #expect(addressees[0].body + == "Bunny filled 1 letter, cleared 1 letter and ran 1 check and 1 reveal in the puzzle 'Tuesday'") + } + + @Test("A cell typed then deleted within the window nets to nothing") + func netPerCellIgnoresTypeThenDelete() { + let edit = Date(timeIntervalSince1970: 1_000) + let entries = [ + entry("A", at: edit, seq: 1, col: 0), + entry("", at: edit.addingTimeInterval(1), seq: 2, col: 0) + ] + let behind = recipient("addr-noop", readAt: edit.addingTimeInterval(-60)) + + let addressees = SessionPushPlanner.sessionEndAddressees( + recipients: [behind], + journalEntries: entries, + playerName: "Bunny", + puzzleTitle: "Tuesday" + ) + + #expect(addressees[0].payload + == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0))) + #expect(addressees[0].payload?.marksUnread == false) } @Test("Diagnostics ride each recipient's payload, stamped with that recipient's readAt") func diagnosticsStampedPerRecipient() { let edit = Date(timeIntervalSince1970: 1_000) - let cells = [cell("X", at: edit)] + let entries = [entry("X", at: edit, seq: 1, col: 0)] let aReadAt = edit.addingTimeInterval(-60) let recipientA = recipient("addr-a", readAt: aReadAt) let recipientB = recipient("addr-b", readAt: nil) @@ -112,7 +241,7 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [recipientA, recipientB], - mergedCells: cells, + journalEntries: entries, playerName: "Bunny", puzzleTitle: "Tuesday", diagnostics: base @@ -135,7 +264,7 @@ struct SessionPushPlannerTests { let edit = Date(timeIntervalSince1970: 1_000) let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [recipient(nil, readAt: nil)], - mergedCells: [cell("X", at: edit)], + journalEntries: [entry("X", at: edit, seq: 1, col: 0)], playerName: "Bunny", puzzleTitle: "Tuesday" ) @@ -146,13 +275,13 @@ struct SessionPushPlannerTests { @Test("Caught-up and behind recipients are both addressed in one fan-out") func mixedRecipientsAllIncluded() { let edit = Date(timeIntervalSince1970: 1_000) - let cells = [cell("X", at: edit)] + let entries = [entry("X", at: edit, seq: 1, col: 0)] let caughtUp = recipient("addr-caught-up", readAt: edit.addingTimeInterval(60)) let behind = recipient("addr-behind", readAt: edit.addingTimeInterval(-60)) let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [caughtUp, behind], - mergedCells: cells, + journalEntries: entries, playerName: "Bunny", puzzleTitle: "Tuesday" )