b0f5a16ec9
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.
359 lines
12 KiB
Go
359 lines
12 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// Tests for the universal restraint gate.
|
|
//
|
|
// DESIGN.md § Trigger model: "the gate is universal, applied by the loop, never
|
|
// per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in
|
|
// one fires()." These tests pin the CONSERVATIVE side of that: the cases where
|
|
// Maven must stay quiet. They exist so nobody loosens the gate by accident.
|
|
//
|
|
// Where the code does not yet do what DESIGN.md promises, the test is written to
|
|
// show the gap and then skipped, with the file and line to fix. Behaviour is not
|
|
// changed to make a test pass.
|
|
|
|
// testRule — a rule at the given severity that always wants to fire, so the
|
|
// only thing under test is the gate.
|
|
func testRule(name string, sev Severity) Rule {
|
|
return Rule{
|
|
Name: name,
|
|
Severity: sev,
|
|
Cooldown: Cooldown{Base: 30 * time.Minute, Min: time.Minute, Max: time.Hour},
|
|
Predicate: func(State) bool { return true },
|
|
}
|
|
}
|
|
|
|
// ---------------------------- quiet hours ------------------------------------
|
|
|
|
// Quiet hours silence care and leave ops alone. A failed backup at 2am matters;
|
|
// a water nudge at 2am does not.
|
|
func TestGateQuietHoursSuppressesCareOnly(t *testing.T) {
|
|
cases := []struct {
|
|
sev Severity
|
|
want bool
|
|
}{
|
|
{Sev1, false},
|
|
{Sev2, false},
|
|
{Sev3, true},
|
|
{Sev4, true},
|
|
}
|
|
for _, c := range cases {
|
|
s := State{Now: refTime(), Presence: store.Present, QuietHours: true}
|
|
if got := Gate(s, testRule("r", c.sev)); got != c.want {
|
|
t.Errorf("quiet hours sev%d: want fire=%v, got %v", c.sev, c.want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------- presence ---------------------------------------
|
|
|
|
// DESIGN.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water
|
|
// nudge is noise, a missed backup failure isn't."
|
|
func TestGateAwayDropsCareHoldsOps(t *testing.T) {
|
|
cases := []struct {
|
|
sev Severity
|
|
want bool
|
|
}{
|
|
{Sev1, false},
|
|
{Sev2, false},
|
|
{Sev3, true},
|
|
{Sev4, true},
|
|
}
|
|
for _, c := range cases {
|
|
s := State{Now: refTime(), Presence: store.Away}
|
|
if got := Gate(s, testRule("r", c.sev)); got != c.want {
|
|
t.Errorf("away sev%d: want fire=%v, got %v", c.sev, c.want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Care nudges are allowed through when the user is actually there and nothing
|
|
// else is suppressing. Without this the "quiet" tests above could pass on a
|
|
// gate that simply never fires.
|
|
func TestGateAllowsCareWhenPresentAndClear(t *testing.T) {
|
|
s := State{Now: refTime(), Presence: store.Present}
|
|
if !Gate(s, testRule("r", Sev1)) {
|
|
t.Fatal("present and clear: care nudge should be allowed")
|
|
}
|
|
}
|
|
|
|
// ---------------------------- calendar busy ----------------------------------
|
|
|
|
// "Don't nag mid-meeting" is an env predicate in the gate, not the LLM's call.
|
|
// Ops still gets through — a service being down mid-meeting is worth the
|
|
// interruption.
|
|
func TestGateCalendarBusySuppressesCareOnly(t *testing.T) {
|
|
care := State{Now: refTime(), Presence: store.Present, CalendarBusy: true}
|
|
if Gate(care, testRule("r", Sev2)) {
|
|
t.Error("calendar busy: care nudge should be suppressed")
|
|
}
|
|
if !Gate(care, testRule("r", Sev4)) {
|
|
t.Error("calendar busy: ops hard should still fire")
|
|
}
|
|
}
|
|
|
|
// ---------------------------- cooldown ---------------------------------------
|
|
|
|
// Cooldown holds for every severity — it is the anti-nag knob, so ops cannot
|
|
// buy its way past it either.
|
|
func TestGateCooldownHoldsForAllSeverities(t *testing.T) {
|
|
now := refTime()
|
|
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
|
|
s := State{
|
|
Now: now,
|
|
Presence: store.Present,
|
|
CooldownUntil: map[string]time.Time{"r": now.Add(10 * time.Minute)},
|
|
}
|
|
if Gate(s, testRule("r", sev)) {
|
|
t.Errorf("cooldown sev%d: should be suppressed", sev)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cooldown is per-rule: one rule cooling down must not mute another.
|
|
func TestGateCooldownIsPerRule(t *testing.T) {
|
|
now := refTime()
|
|
s := State{
|
|
Now: now,
|
|
Presence: store.Present,
|
|
CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
|
|
}
|
|
if Gate(s, testRule("water", Sev1)) {
|
|
t.Error("water is cooling down and should be suppressed")
|
|
}
|
|
if !Gate(s, testRule("meal", Sev1)) {
|
|
t.Error("meal has no cooldown and should be allowed")
|
|
}
|
|
}
|
|
|
|
// The moment the cooldown expires the rule is free again — the gate compares
|
|
// with Before, so "until" itself is already clear.
|
|
func TestGateCooldownExpires(t *testing.T) {
|
|
now := refTime()
|
|
s := State{
|
|
Now: now,
|
|
Presence: store.Present,
|
|
CooldownUntil: map[string]time.Time{"r": now},
|
|
}
|
|
if !Gate(s, testRule("r", Sev1)) {
|
|
t.Fatal("cooldown at exactly now should already be clear")
|
|
}
|
|
}
|
|
|
|
// ---------------------------- snooze -----------------------------------------
|
|
|
|
// Snooze is the user saying "not about this". It beats everything, including
|
|
// ops hard.
|
|
func TestGateSnoozeHoldsForAllSeverities(t *testing.T) {
|
|
now := refTime()
|
|
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
|
|
s := State{
|
|
Now: now,
|
|
Presence: store.Present,
|
|
SnoozeUntil: map[string]time.Time{"r": now.Add(time.Hour)},
|
|
}
|
|
if Gate(s, testRule("r", sev)) {
|
|
t.Errorf("snooze sev%d: should be suppressed", sev)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------- no-data backstop -------------------------------
|
|
|
|
// The gate enforces no-data inertness a second time, for any rule that declared
|
|
// the keys it needs. A predicate that forgets the check still cannot fire.
|
|
func TestGateNoDataBackstopBeatsAnEagerPredicate(t *testing.T) {
|
|
now := refTime()
|
|
eager := Rule{
|
|
Name: "eager",
|
|
Severity: Sev4, // even ops hard does not get past missing data
|
|
Predicate: func(State) bool { return true },
|
|
InertWhenNoData: []string{"water", "meal"},
|
|
}
|
|
// one of the two keys present is not enough.
|
|
s := State{
|
|
Now: now,
|
|
Presence: store.Present,
|
|
Facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, time.Hour)},
|
|
}
|
|
if Gate(s, eager) {
|
|
t.Fatal("a rule missing one of its keys must stay inert")
|
|
}
|
|
}
|
|
|
|
// ---------------------------- one nudge per tick -----------------------------
|
|
|
|
// All five default rules want to fire at once. The tick must still emit exactly
|
|
// one candidate, the loudest — never a dogpile.
|
|
func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
|
now := refTime()
|
|
s := State{
|
|
Now: now,
|
|
Presence: store.Present,
|
|
Facts: map[string]store.Fact{
|
|
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
|
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
|
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
|
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
|
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
|
},
|
|
}
|
|
// sanity: every rule really does want to fire, so the pick is a real choice.
|
|
for _, r := range DefaultRules() {
|
|
if !r.Predicate(s) {
|
|
t.Fatalf("setup: rule %q does not want to fire", r.Name)
|
|
}
|
|
}
|
|
got := Tick(s, DefaultRules())
|
|
if got == nil {
|
|
t.Fatal("all rules firing: want one candidate, got nil")
|
|
}
|
|
if got.Rule.Name != "service_down" || got.Severity != Sev4 {
|
|
t.Fatalf("want the loudest (service_down/sev4), got %s/sev%d", got.Rule.Name, got.Severity)
|
|
}
|
|
}
|
|
|
|
// Tick returns a single Candidate by type, so "one per tick" cannot be violated
|
|
// by count — what can drift is WHICH one. Equal severities tie-break by name so
|
|
// the choice is deterministic across ticks.
|
|
func TestTickTieBreaksByNameForDeterminism(t *testing.T) {
|
|
s := State{Now: refTime(), Presence: store.Present}
|
|
rules := []Rule{testRule("zebra", Sev2), testRule("apple", Sev2), testRule("mango", Sev2)}
|
|
for i := 0; i < 5; i++ {
|
|
got := Tick(s, rules)
|
|
if got == nil || got.Rule.Name != "apple" {
|
|
t.Fatalf("tie-break: want apple every time, got %+v", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The loudest candidate wins even when the quiet one is listed first.
|
|
func TestTickOrderOfRulesDoesNotMatter(t *testing.T) {
|
|
s := State{Now: refTime(), Presence: store.Present}
|
|
first := Tick(s, []Rule{testRule("care", Sev1), testRule("ops", Sev4)})
|
|
second := Tick(s, []Rule{testRule("ops", Sev4), testRule("care", Sev1)})
|
|
if first == nil || second == nil {
|
|
t.Fatal("want a candidate from both orderings")
|
|
}
|
|
if first.Rule.Name != "ops" || second.Rule.Name != "ops" {
|
|
t.Fatalf("order changed the pick: %s then %s", first.Rule.Name, second.Rule.Name)
|
|
}
|
|
}
|
|
|
|
// ---------------------------- reminders bypass the gate ----------------------
|
|
|
|
// DESIGN.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires
|
|
// in quiet hours; that's the point." Every suppressor set at once, and the
|
|
// reminder still comes through.
|
|
func TestRemindersBypassEverySuppressor(t *testing.T) {
|
|
now := refTime()
|
|
s := State{
|
|
Now: now,
|
|
Presence: store.Away,
|
|
QuietHours: true,
|
|
CalendarBusy: true,
|
|
CooldownUntil: map[string]time.Time{"reminder": now.Add(time.Hour)},
|
|
}
|
|
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
|
|
got := RemindDecisions(s, due)
|
|
if len(got) != 1 || got[0].Reminder.ID != 7 {
|
|
t.Fatalf("reminder must bypass the gate, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// GAP — DESIGN.md § User reminders ends "Snooze still applies." RemindDecisions
|
|
// passes every due reminder straight through with no snooze check, so a snoozed
|
|
// reminder fires anyway. The test below is what the contract asks for.
|
|
func TestRemindersStillHonourSnooze(t *testing.T) {
|
|
|
|
now := refTime()
|
|
s := State{
|
|
Now: now,
|
|
Presence: store.Present,
|
|
SnoozeUntil: map[string]time.Time{"reminder:7": now.Add(time.Hour)},
|
|
}
|
|
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
|
|
if got := RemindDecisions(s, due); len(got) != 0 {
|
|
t.Fatalf("snoozed reminder should not be delivered, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// ---------------------------- digest eligibility ------------------------------
|
|
|
|
// A Sev2 care candidate (break) suppressed for a genuine restraint reason is
|
|
// worth resurfacing later.
|
|
func TestDigestEligibleSev2SuppressedByRestraint(t *testing.T) {
|
|
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
|
|
if !DigestEligible(Sev2, reason) {
|
|
t.Errorf("sev2 blocked by %q: want digest-eligible", reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A Sev1 care candidate (water/meal) never digests — a biological timer
|
|
// nudge is stale by the time anyone could resurface it, so it just drops.
|
|
func TestDigestEligibleSev1NeverDigests(t *testing.T) {
|
|
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
|
|
if DigestEligible(Sev1, reason) {
|
|
t.Errorf("sev1 blocked by %q: want drop, got digest-eligible", reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ops severities are never blocked by these reasons in practice (Gate only
|
|
// applies quiet_hours/calendar_busy/presence to care severities), but the
|
|
// boundary itself must refuse to digest a high severity even if asked —
|
|
// alarms bypass the gate and deliver now, unchanged, never delayed.
|
|
func TestDigestEligibleNeverDigestsHighSeverity(t *testing.T) {
|
|
for _, sev := range []Severity{Sev3, Sev4} {
|
|
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
|
|
if DigestEligible(sev, reason) {
|
|
t.Errorf("sev%d blocked by %q: high severity must never digest", sev, reason)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// cooldown and snooze are not "suppression" in the digest sense — cooldown
|
|
// means it was already said recently, snooze means the user asked to not
|
|
// hear about it. Neither should resurface later just because the severity
|
|
// matches.
|
|
func TestDigestEligibleExcludesCooldownAndSnooze(t *testing.T) {
|
|
for _, reason := range []string{"cooldown", "snooze", "inert_no_data", "predicate", ""} {
|
|
if DigestEligible(Sev2, reason) {
|
|
t.Errorf("sev2 blocked by %q: should not be digest-eligible", reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil
|
|
// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the
|
|
// unit tests above pass while nothing can ever populate the map. This asserts
|
|
// the Gatherer actually produces a snooze map.
|
|
func TestGathererPopulatesSnoozeUntil(t *testing.T) {
|
|
|
|
ctx := context.Background()
|
|
st, err := store.Open(ctx, t.TempDir()+"/m.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
g := NewGatherer(st, DefaultRules())
|
|
snap, _, err := g.GatherState(ctx, refTime())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if snap.SnoozeUntil == nil {
|
|
t.Fatal("Gatherer returned a nil SnoozeUntil map")
|
|
}
|
|
}
|