Add digest as a real outcome: suppressed care nudges get resurfaced, not lost
Vikunja #281. The interruption policy promised four outcomes — deliver_now, queue, digest, drop — but only three existed: a care candidate the restraint gate suppressed for quiet hours / away / calendar-busy simply vanished in loop.Tick's `continue`, with only the trace remembering why. internal/morning turned out not to be the natural drain: it's a fixed Item/FactKey checklist engine, not a generic message bundler, so gate- suppressed nudge text has nowhere to plug into its evidence model. Built a parallel (but small, reusing the outbox's shape) durable digest instead: - internal/store: digest_entries table + EnqueueDigestEntry (dedupes by rule+body, mirroring the delivery outbox's bodyHash), PendingDigestEntries, ExpireStaleDigestEntries, DrainDigestEntries (mark, never delete — an audit trail of what she actually said). - internal/loop: DigestEligible(severity, blockedBy) is the pure boundary — only genuine restraint blocks (quiet_hours/calendar_busy/presence) even qualify (cooldown/snooze are not "suppression"); within care, Sev2 (break) digests, Sev1 (water/meal — stale by the time anyone could resurface them) drops. High severity never digests; alarms bypass the gate and deliver unchanged, on purpose. - cmd/mavend/tick.go: each tick scans ExplainTick's trace for eligible blocked candidates, enqueues them, sweeps stale entries (24h expiry — the care rules are daily-cadence, so anything older is describing a day that's over), and drains the bundle only once the suppression reason has actually cleared, capped at 3 spoken items plus a trailing count so a digest can't turn into the exact nagging it was built to avoid. Tests: store-level round-trip/restart-survival/dedupe/expiry/drain, loop- level severity-boundary unit tests, and tick-level integration tests for the drain-only-when-clear and never-digest-high-severity behavior.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDigestEntryRoundTrips — a suppressed care candidate lands durably and
|
||||
// comes back out of PendingDigestEntries with its severity and body intact.
|
||||
func TestDigestEntryRoundTrips(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
id, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if deduped {
|
||||
t.Fatal("first enqueue must not report deduped")
|
||||
}
|
||||
if id == 0 {
|
||||
t.Fatal("want a nonzero id")
|
||||
}
|
||||
|
||||
entries, err := s.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != id {
|
||||
t.Fatalf("want 1 pending entry with id %d, got %+v", id, entries)
|
||||
}
|
||||
if entries[0].Rule != "break" || entries[0].Severity != 2 || entries[0].Body != "ты долго не отдыхала" {
|
||||
t.Fatalf("entry contents wrong: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEntrySurvivesRestart — durability is the whole point: a fresh
|
||||
// Store handle on the same file must see the same pending entry, exactly
|
||||
// like the delivery outbox's crash-recovery promise.
|
||||
func TestDigestEntrySurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
s1, err := Open(ctx, dir+"/m.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
id, _, err := s1.EnqueueDigestEntry(ctx, "break", 2, "перерыв", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if err := s1.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
// simulated restart: a brand new Store handle on the same file.
|
||||
s2, err := Open(ctx, dir+"/m.db")
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer func() { _ = s2.Close() }()
|
||||
|
||||
entries, err := s2.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("pending after restart: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != id {
|
||||
t.Fatalf("digest entry did not survive restart: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEntryDedupesSameRuleAndBody — the same suppressed nudge
|
||||
// repeating across ticks (quiet hours holding for hours) must not pile up
|
||||
// into several copies of itself; he hears it once.
|
||||
func TestDigestEntryDedupesSameRuleAndBody(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
id1, deduped1, err := s.EnqueueDigestEntry(ctx, "break", 2, "перерыв нужен", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("first enqueue: %v", err)
|
||||
}
|
||||
if deduped1 {
|
||||
t.Fatal("first enqueue should not be deduped")
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
id2, deduped2, err := s.EnqueueDigestEntry(ctx, "break", 2, "перерыв нужен", now.Add(time.Minute), now.Add(25*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("repeat enqueue: %v", err)
|
||||
}
|
||||
if !deduped2 {
|
||||
t.Fatal("repeat enqueue of the same rule+body should report deduped")
|
||||
}
|
||||
if id2 != id1 {
|
||||
t.Fatalf("deduped enqueue should return the original id: want %d got %d", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := s.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("want exactly 1 pending entry after 3 enqueues of the same nudge, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEntryExpiresRatherThanDeliversLate — a stale entry (past its
|
||||
// expires_ts) must not surface in PendingDigestEntries, and the sweep should
|
||||
// mark it expired instead of leaving it around to be delivered late.
|
||||
func TestDigestEntryExpiresRatherThanDeliversLate(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
created := time.Now()
|
||||
expiresAt := created.Add(time.Hour)
|
||||
|
||||
id, _, err := s.EnqueueDigestEntry(ctx, "water", 1, "стакан воды", created, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
|
||||
afterExpiry := expiresAt.Add(time.Minute)
|
||||
|
||||
// even before the sweep runs, a stale entry must not be handed back as
|
||||
// pending — "not yet swept" must not mean "still deliverable".
|
||||
entries, err := s.PendingDigestEntries(ctx, afterExpiry)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("stale entry must not be returned as pending, got %+v", entries)
|
||||
}
|
||||
|
||||
n, err := s.ExpireStaleDigestEntries(ctx, afterExpiry)
|
||||
if err != nil {
|
||||
t.Fatalf("expire sweep: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("want 1 entry expired, got %d", n)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM digest_entries WHERE id = ?`, id).Scan(&status); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if status != DigestExpired {
|
||||
t.Fatalf("status: want %q, got %q", DigestExpired, status)
|
||||
}
|
||||
|
||||
// idempotent: a second sweep finds nothing new.
|
||||
n2, err := s.ExpireStaleDigestEntries(ctx, afterExpiry.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("second sweep: %v", err)
|
||||
}
|
||||
if n2 != 0 {
|
||||
t.Fatalf("second sweep should find nothing, got %d", n2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEntryDrainMarksDrainedNotDeleted — draining is bookkeeping, not
|
||||
// deletion: the row survives as an audit trail of what she actually said.
|
||||
func TestDigestEntryDrainMarksDrainedNotDeleted(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
id1, _, err := s.EnqueueDigestEntry(ctx, "break", 2, "перерыв", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue 1: %v", err)
|
||||
}
|
||||
id2, _, err := s.EnqueueDigestEntry(ctx, "break2", 2, "другое", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue 2: %v", err)
|
||||
}
|
||||
|
||||
if err := s.DrainDigestEntries(ctx, []int64{id1, id2}, now.Add(time.Hour)); err != nil {
|
||||
t.Fatalf("drain: %v", err)
|
||||
}
|
||||
|
||||
entries, err := s.PendingDigestEntries(ctx, now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("drained entries must not still be pending, got %+v", entries)
|
||||
}
|
||||
|
||||
for _, id := range []int64{id1, id2} {
|
||||
var status string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM digest_entries WHERE id = ?`, id).Scan(&status); err != nil {
|
||||
t.Fatalf("read back %d: %v", id, err)
|
||||
}
|
||||
if status != DigestDrained {
|
||||
t.Fatalf("entry %d status: want %q, got %q", id, DigestDrained, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user