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:
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"
)