From 9e2af3286bcce1cdc2cca689610e268a8e1782a8 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:12:25 +0400 Subject: [PATCH 1/2] Unit-test all five proactive rule predicates Table-driven tests for water, meal, break, service_down and netdata_critical, straight against the predicate with a fake State. Reviewers: the no-data rows (every rule must stay quiet when its key is missing) and the ops forgery rows, where a fact with the right value but the wrong source must be refused. No rule fired on missing data, so no fix was needed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/loop/rules_test.go | 394 ++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 internal/loop/rules_test.go diff --git a/internal/loop/rules_test.go b/internal/loop/rules_test.go new file mode 100644 index 0000000..0da19e2 --- /dev/null +++ b/internal/loop/rules_test.go @@ -0,0 +1,394 @@ +package loop + +import ( + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +// Direct tests for the five default rule predicates. +// +// A predicate is pure — (State) -> bool, no I/O — so these need no store and no +// daemon. They test the predicate ALONE: the restraint gate is tested in +// loop_test.go and gate_test.go, never here. +// +// Every rule gets the same three questions plus its own edges: +// - does it fire when it should? +// - does it stay quiet when it should? +// - is it silent when the key it needs has no data at all? +// +// The last one is load-bearing. DESIGN.md: "since(key)==null → don't fire. +// Silence on no-data is 'shuts up when uncertain'." + +// stateWith builds a snapshot at refTime() holding just the given facts. +// Presence and the env flags are left zero — the predicate must not read them. +func stateWith(facts map[string]store.Fact) State { + return State{Now: refTime(), Facts: facts} +} + +// ago is a fact for key written `d` before refTime(). +func ago(key, source, value string, d time.Duration) store.Fact { + return factAt(key, source, value, refTime().Add(-d)) +} + +// ---------------------------- since-based care rules ------------------------- + +// The three care rules share one shape: "fire when it has been at least N since +// the last fact for key". One table drives all of them. +func TestCareRulePredicates(t *testing.T) { + cases := []struct { + name string + rule Rule + facts map[string]store.Fact + want bool + }{ + // water — threshold 3h. + { + name: "water fires at 4h", + rule: WaterRule(), + facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour)}, + want: true, + }, + { + name: "water fires exactly at the 3h threshold", + rule: WaterRule(), + facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour)}, + want: true, + }, + { + name: "water quiet just under 3h", + rule: WaterRule(), + facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour-time.Minute)}, + want: false, + }, + { + name: "water quiet on no data", + rule: WaterRule(), + facts: nil, + want: false, + }, + { + name: "water quiet on a zero-timestamp fact", + rule: WaterRule(), + facts: map[string]store.Fact{"water": {Key: "water", Source: "tap:water", Value: `"250ml"`}}, + want: false, + }, + { + name: "water quiet when the only fact is for another key", + rule: WaterRule(), + facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 9*time.Hour)}, + want: false, + }, + + // meal — threshold 6h. + { + name: "meal fires at 7h", + rule: MealRule(), + facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour)}, + want: true, + }, + { + name: "meal fires exactly at the 6h threshold", + rule: MealRule(), + facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour)}, + want: true, + }, + { + name: "meal quiet just under 6h", + rule: MealRule(), + facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour-time.Minute)}, + want: false, + }, + { + name: "meal quiet on no data", + rule: MealRule(), + facts: nil, + want: false, + }, + + // break — needs BOTH anchors: at the desk now, and no break for 90min. + { + name: "break fires when at desk and no break for 2h", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), + "break": ago("break", "voice", `"walk"`, 2*time.Hour), + }, + want: true, + }, + { + name: "break fires exactly at both thresholds", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 2*time.Minute), + "break": ago("break", "voice", `"walk"`, 90*time.Minute), + }, + want: true, + }, + { + name: "break quiet when the desk signal is stale (user left)", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 10*time.Minute), + "break": ago("break", "voice", `"walk"`, 2*time.Hour), + }, + want: false, + }, + { + name: "break quiet when the last break was recent", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), + "break": ago("break", "voice", `"walk"`, 20*time.Minute), + }, + want: false, + }, + { + name: "break quiet with only the desk anchor", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), + }, + want: false, + }, + { + name: "break quiet with only the break anchor", + rule: BreakRule(), + facts: map[string]store.Fact{ + "break": ago("break", "voice", `"walk"`, 2*time.Hour), + }, + want: false, + }, + { + name: "break quiet on no data", + rule: BreakRule(), + facts: nil, + want: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.rule.Predicate(stateWith(c.facts)); got != c.want { + t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got) + } + }) + } +} + +// ---------------------------- ops rules -------------------------------------- + +// The two ops rules match on a value AND on which poller wrote it. DESIGN.md: +// "a compromised poller must not be able to forge a trigger." Half of this +// table is forgery attempts; all of them must be refused. +func TestOpsRulePredicates(t *testing.T) { + cases := []struct { + name string + rule Rule + facts map[string]store.Fact + want bool + }{ + // service_down — only poll:uptimekuma may say a service is down. + { + name: "service_down fires on a kuma down fact", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)}, + want: true, + }, + { + name: "service_down quiet when kuma says up", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)}, + want: false, + }, + { + name: "service_down quiet on no data", + rule: ServiceDownRule(), + facts: nil, + want: false, + }, + { + name: "service_down quiet on a zero-timestamp fact", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}}, + want: false, + }, + // forgery attempts — right value, wrong writer. + { + name: "service_down refuses a forgery from the netdata poller", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)}, + want: false, + }, + { + name: "service_down refuses a forgery from ambient audio", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)}, + want: false, + }, + { + name: "service_down refuses a forgery from the user's own voice", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)}, + want: false, + }, + { + name: "service_down refuses a source that only looks like kuma", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)}, + want: false, + }, + { + name: "service_down refuses an unquoted down value", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)}, + want: false, + }, + + // netdata_critical — only poll:netdata may raise a critical alarm. + { + name: "netdata_critical fires on a netdata critical alarm", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute)}, + want: true, + }, + { + name: "netdata_critical quiet on a warning alarm", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"warning"`, time.Minute)}, + want: false, + }, + { + name: "netdata_critical quiet on a cleared alarm", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"clear"`, time.Minute)}, + want: false, + }, + { + name: "netdata_critical quiet on no data", + rule: NetdataCriticalRule(), + facts: nil, + want: false, + }, + { + name: "netdata_critical quiet on a zero-timestamp fact", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": {Key: "netdata_alarm", Source: "poll:netdata", Value: `"critical"`}}, + want: false, + }, + { + name: "netdata_critical refuses a forgery from the kuma poller", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:uptimekuma", `"critical"`, time.Minute)}, + want: false, + }, + { + name: "netdata_critical refuses a forgery from ambient audio", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "ambient:other", `"critical"`, time.Minute)}, + want: false, + }, + { + name: "netdata_critical reads netdata_alarm, not netdata_critical", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_critical": ago("netdata_critical", "poll:netdata", `"critical"`, time.Minute)}, + want: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.rule.Predicate(stateWith(c.facts)); got != c.want { + t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got) + } + }) + } +} + +// ---------------------------- rule metadata ---------------------------------- + +// Every default rule must declare the keys it needs. The gate uses that list as +// a second no-data backstop, so a rule that forgets it loses the safety net +// even if its predicate happens to check. +func TestDefaultRulesDeclareInertKeys(t *testing.T) { + for _, r := range DefaultRules() { + if len(r.InertWhenNoData) == 0 { + t.Errorf("rule %q declares no InertWhenNoData keys", r.Name) + } + } +} + +// A no-data snapshot must make EVERY default rule quiet, predicate alone, with +// the gate out of the picture. This is the whole-set version of the per-rule +// no-data cases above. +func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) { + empty := stateWith(nil) + for _, r := range DefaultRules() { + if r.Predicate(empty) { + t.Errorf("rule %q fires on an empty snapshot", r.Name) + } + } +} + +// Severities are the delivery contract (DESIGN.md § Delivery / channel +// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin +// them so a change to a rule's insistence has to be deliberate. +func TestDefaultRuleSeverities(t *testing.T) { + want := map[string]Severity{ + "water": Sev1, + "meal": Sev1, + "break": Sev2, + "service_down": Sev4, + "netdata_critical": Sev3, + } + got := map[string]Severity{} + for _, r := range DefaultRules() { + got[r.Name] = r.Severity + } + if len(got) != len(want) { + t.Fatalf("rule count changed: want %d, got %d", len(want), len(got)) + } + for name, sev := range want { + if got[name] != sev { + t.Errorf("rule %q severity: want %d, got %d", name, sev, got[name]) + } + } +} + +// Cooldown bounds keep the feedback tuner honest — DESIGN.md wants +// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or +// stalker". A base outside its own envelope would make that meaningless. +func TestDefaultRuleCooldownsAreBounded(t *testing.T) { + for _, r := range DefaultRules() { + c := r.Cooldown + if c.Min <= 0 || c.Base <= 0 || c.Max <= 0 { + t.Errorf("rule %q has a non-positive cooldown: %+v", r.Name, c) + continue + } + if c.Base < c.Min || c.Base > c.Max { + t.Errorf("rule %q base %v outside envelope [%v, %v]", r.Name, c.Base, c.Min, c.Max) + } + } +} + +// A predicate must read only the snapshot it is handed. Same snapshot twice +// (and a snapshot shared between two rules) must give the same answer — no +// hidden state, no clock reads. +func TestPredicatesArePure(t *testing.T) { + s := stateWith(map[string]store.Fact{ + "water": ago("water", "tap:water", `"250ml"`, 4*time.Hour), + "meal": ago("meal", "voice", `"lunch"`, 7*time.Hour), + "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), + "break": ago("break", "voice", `"walk"`, 2*time.Hour), + "service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute), + }) + for _, r := range DefaultRules() { + first := r.Predicate(s) + for i := 0; i < 3; i++ { + if again := r.Predicate(s); again != first { + t.Fatalf("rule %q predicate is not pure: %v then %v", r.Name, first, again) + } + } + } +} From 9d8fcf42f3a4e9cf75706180ea0a2d39ebc461e4 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:13:46 +0400 Subject: [PATCH 2/2] Test the universal restraint gate, including two gaps Pins the conservative side of Gate(): quiet hours, away, calendar-busy, cooldown and snooze, plus one nudge per tick at max severity. Reviewers: the two skipped tests at the bottom are real gaps, not flakes. Reminders ignore snooze (loop.go:120) and the Gatherer never fills SnoozeUntil (gather.go:153), so snooze does nothing at runtime. No behaviour was changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/loop/gate_test.go | 312 +++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 internal/loop/gate_test.go diff --git a/internal/loop/gate_test.go b/internal/loop/gate_test.go new file mode 100644 index 0000000..09bcb62 --- /dev/null +++ b/internal/loop/gate_test.go @@ -0,0 +1,312 @@ +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) { + t.Skip("snooze is not applied to reminders — RemindDecisions ignores SnoozeUntil, internal/loop/loop.go:120") + + 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) + } +} + +// 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) { + t.Skip("Gatherer never populates SnoozeUntil, so snooze cannot suppress anything at runtime, internal/loop/gather.go:153") + + 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") + } +}