package main import ( "context" "testing" "time" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/store" ) // Vikunja #281 — the fourth delivery outcome: a care candidate the restraint // gate suppresses (quiet hours / away / calendar-busy) is not necessarily // lost. If it's worth resurfacing (loop.DigestEligible), it's durably held // (internal/store's digest_entries) and spoken as one bundle once speaking // is appropriate again — never while the suppression reason still holds. func breakTrace(blockedBy string) *loop.TickTrace { return &loop.TickTrace{ RuleTraces: []loop.RuleTrace{{ RuleName: "break", Severity: loop.Sev2, PredicateResult: true, GateResult: false, GateBlockedBy: blockedBy, }}, } } // TestSuppressedCareDigestsAcrossQuietHours — a Sev2 care candidate blocked // by quiet hours is enqueued into the durable digest, and is spoken as a // "digest" nudge only once quiet hours actually end — never while still // suppressed (that would just be a second way to nag through quiet hours). func TestSuppressedCareDigestsAcrossQuietHours(t *testing.T) { st := newTestStore(t) sink := &fakeSink{} tl := newTestTickLoop(t, st, sink, nil) ctx := context.Background() now := refNow() quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present} tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now) entries, err := st.PendingDigestEntries(ctx, now) if err != nil { t.Fatalf("pending: %v", err) } if len(entries) != 1 || entries[0].Rule != "break" { t.Fatalf("want 1 pending digest entry for break, got %+v", entries) } // still quiet hours: draining now must not speak — the same restraint // that suppressed the live nudge must suppress the bundle too. tl.maybeDrainDigest(ctx, quiet, now) if len(sink.sends) != 0 { t.Fatalf("digest must not drain while quiet hours holds, got %+v", sink.sends) } // quiet hours end: this is the moment speaking is appropriate again. after := now.Add(time.Hour) clear := loop.State{Now: after, QuietHours: false, Presence: store.Present} tl.maybeDrainDigest(ctx, clear, after) if len(sink.sends) != 1 { t.Fatalf("want exactly 1 dispatched digest bundle, got %d: %+v", len(sink.sends), sink.sends) } if sink.sends[0].RuleName != "digest" { t.Fatalf("want RuleName digest, got %q", sink.sends[0].RuleName) } remaining, err := st.PendingDigestEntries(ctx, after) if err != nil { t.Fatalf("pending after drain: %v", err) } if len(remaining) != 0 { t.Fatalf("drained entry must no longer be pending, got %+v", remaining) } } // TestSuppressedCareDigestDedupesAcrossTicks — quiet hours holding for // several ticks must not enqueue several copies of the same suppressed // nudge; he hears it once when the bundle finally drains. func TestSuppressedCareDigestDedupesAcrossTicks(t *testing.T) { st := newTestStore(t) sink := &fakeSink{} tl := newTestTickLoop(t, st, sink, nil) ctx := context.Background() now := refNow() quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present} for i := 0; i < 3; i++ { tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now.Add(time.Duration(i)*time.Minute)) } entries, err := st.PendingDigestEntries(ctx, now) if err != nil { t.Fatalf("pending: %v", err) } if len(entries) != 1 { t.Fatalf("3 suppressions of the same nudge must collapse to 1 pending entry, got %d", len(entries)) } } // TestSuppressedCareDigestExpiresRatherThanDeliveringLate — an entry that // aged out before the suppression cleared is dropped, not spoken late: a // two-day-old "you skipped a break" is noise, not news. func TestSuppressedCareDigestExpiresRatherThanDeliveringLate(t *testing.T) { st := newTestStore(t) sink := &fakeSink{} tl := newTestTickLoop(t, st, sink, nil) ctx := context.Background() now := refNow() quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present} tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now) // well past digestExpiry (24h) before the suppression ever clears. stale := now.Add(48 * time.Hour) tl.expireStaleDigest(ctx, stale) clear := loop.State{Now: stale, QuietHours: false, Presence: store.Present} tl.maybeDrainDigest(ctx, clear, stale) if len(sink.sends) != 0 { t.Fatalf("a stale digest entry must be dropped, not delivered late; got %+v", sink.sends) } } // TestSuppressedCareDigestIgnoresHighSeverity — defense in depth at the // wiring layer: even if a RuleTrace somehow showed a high-severity rule // blocked by a care-only gate reason, the tick driver must not durably // digest it. Alarms bypass the gate and deliver now, unchanged; they must // never be silently delayed into a bundle. func TestSuppressedCareDigestIgnoresHighSeverity(t *testing.T) { st := newTestStore(t) sink := &fakeSink{} tl := newTestTickLoop(t, st, sink, nil) ctx := context.Background() now := refNow() trace := &loop.TickTrace{RuleTraces: []loop.RuleTrace{{ RuleName: "service_down", Severity: loop.Sev4, PredicateResult: true, GateResult: false, GateBlockedBy: "quiet_hours", }}} quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present} tl.enqueueSuppressedDigest(ctx, trace, quiet, now) entries, err := st.PendingDigestEntries(ctx, now) if err != nil { t.Fatalf("pending: %v", err) } if len(entries) != 0 { t.Fatalf("high severity must never be digested, got %+v", entries) } }