package main import ( "context" "database/sql" "strings" "testing" "time" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/store" ) // seedRefillEvents writes N weekly "refill/cat_water" events straight to the // events table — this is what the tick reads, independent of any utterance. func seedRefillEvents(t *testing.T, st *store.Store, ctx context.Context, base time.Time, n int) { t.Helper() for i := 0; i < n; i++ { factID, err := st.WriteFact(ctx, base.Add(time.Duration(i)*7*24*time.Hour), store.KindSelf, "cat_water", "refill", "test", 1.0, sql.NullInt64{}) if err != nil { t.Fatalf("write fact %d: %v", i, err) } if _, err := st.CreateEvent(ctx, factID, "refill", "cat_water", base.Add(time.Duration(i)*7*24*time.Hour)); err != nil { t.Fatalf("create event %d: %v", i, err) } } } // TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on // its own, reading straight from the store — not as a side effect of a live // utterance (Vikunja #43). MinEvents weekly events with no voice turn in // sight must produce exactly one proposed routine. func TestTickDetectsPatternFromStoredEvents(t *testing.T) { st := newTestStore(t) ctx := context.Background() now := refNow() seedRefillEvents(t, st, ctx, now, pattern.MinEvents) tl := newTestTickLoop(t, st, &fakeSink{}, nil) tl.detectPatterns(ctx, now, loop.State{}) rows, err := st.ListProposedRoutines(ctx) if err != nil { t.Fatalf("list proposed routines: %v", err) } if len(rows) != 1 { t.Fatalf("proposed routines = %d, want 1: %+v", len(rows), rows) } if rows[0].Action != "refill" || rows[0].Object != "cat_water" { t.Errorf("proposed routine = %s/%s, want refill/cat_water", rows[0].Action, rows[0].Object) } } // TestTickPatternDetectionIsIdempotent proves running the tick's pattern scan // twice does not spam a second proposal for the same pair, and that the store // itself is what stops the duplicate (not tick-local state) — the whole point // of the guard, since the tick has no memory of what it proposed last time. func TestTickPatternDetectionIsIdempotent(t *testing.T) { st := newTestStore(t) ctx := context.Background() now := refNow() seedRefillEvents(t, st, ctx, now, pattern.MinEvents) tl := newTestTickLoop(t, st, &fakeSink{}, nil) tl.detectPatterns(ctx, now, loop.State{}) tl.detectPatterns(ctx, now.Add(time.Hour), loop.State{}) rows, err := st.ListProposedRoutines(ctx) if err != nil { t.Fatalf("list proposed routines: %v", err) } if len(rows) != 1 { t.Fatalf("proposed routines after two ticks = %d, want 1 (no duplicate): %+v", len(rows), rows) } } // TestTickPatternDetectionRespectsDismissal proves the single worst failure // mode here — a proposal the owner already said no to coming back on the next // tick — cannot happen. Dismissal flips the row's status in place; it must // still be there to block re-proposal. func TestTickPatternDetectionRespectsDismissal(t *testing.T) { st := newTestStore(t) ctx := context.Background() now := refNow() seedRefillEvents(t, st, ctx, now, pattern.MinEvents) tl := newTestTickLoop(t, st, &fakeSink{}, nil) tl.detectPatterns(ctx, now, loop.State{}) rows, err := st.ListProposedRoutines(ctx) if err != nil { t.Fatalf("list proposed routines: %v", err) } if len(rows) != 1 { t.Fatalf("setup: proposed routines = %d, want 1", len(rows)) } if err := st.DismissProposedRoutine(ctx, rows[0].ID); err != nil { t.Fatalf("dismiss: %v", err) } // More events for the same pair arrive, and the tick runs again — a // dismissed pattern must not resurface. seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), pattern.MinEvents) tl.detectPatterns(ctx, now.Add(60*24*time.Hour), loop.State{}) proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed) if err != nil { t.Fatalf("list proposed: %v", err) } if len(proposed) != 0 { t.Fatalf("a dismissed pattern came back: %+v", proposed) } all, err := st.ListProposedRoutinesByStatus(ctx, "") if err != nil { t.Fatalf("list all: %v", err) } if len(all) != 1 { t.Fatalf("total rows for the pair = %d, want 1 (still dismissed, not duplicated): %+v", len(all), all) } if all[0].Status != store.RoutineDismissed { t.Errorf("status = %s, want dismissed", all[0].Status) } } // proposalRule — the rule name announceProposal uses for the seeded pair. const proposalRule = "proposal:refill cat_water" // TestTickProposalSilentByDefault — detection is always on, announcing is not. // With no pattern_proposals block the tick still records the proposal, and says // nothing about it: Maven is not autonomous, so a behaviour that speaks without // being asked stays off until it is configured. func TestTickProposalSilentByDefault(t *testing.T) { st := newTestStore(t) ctx := context.Background() now := refNow() seedRefillEvents(t, st, ctx, now, pattern.MinEvents) markPresent(t, st, ctx, now) sink := &fakeSink{} tl := newTestTickLoop(t, st, sink, nil) tl.tick(ctx, now) if n := countSends(sink, proposalRule); n != 0 { t.Fatalf("announced %d proposals with no config, want 0", n) } rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed) if err != nil { t.Fatalf("list proposed: %v", err) } if len(rows) != 1 { t.Fatalf("proposed routines = %d, want 1 (silent, but recorded)", len(rows)) } } // TestTickAnnouncesProposalWhenConfigured — with notify on, the proposal goes // out once through the ordinary delivery path, worded by the detector itself. // Later ticks stay quiet because the pair is already proposed: one pattern is // one announcement, ever. func TestTickAnnouncesProposalWhenConfigured(t *testing.T) { st := newTestStore(t) ctx := context.Background() now := refNow() seedRefillEvents(t, st, ctx, now, pattern.MinEvents) markPresent(t, st, ctx, now) sink := &fakeSink{} tl := newTestTickLoop(t, st, sink, nil) tl.proposalCfg = &config.PatternProposalConfig{Notify: true} tl.tick(ctx, now) var got *delivery.Sendable for i := range sink.sends { if sink.sends[i].RuleName == proposalRule { got = &sink.sends[i] } } if got == nil { t.Fatalf("proposal was not announced; sends=%+v", sink.sends) } if !strings.Contains(got.Body, "напоминать?") { t.Errorf("body = %q, want the detector's own question", got.Body) } if got.Channel != delivery.ChannelVoice { t.Errorf("channel = %v, want voice (sev1, present)", got.Channel) } // A month of further ticks: the pair already has a row, so there is // nothing new to detect and nothing more to say. sink.sends = nil later := now.Add(40 * 24 * time.Hour) markPresent(t, st, ctx, later) tl.tick(ctx, later) if n := countSends(sink, proposalRule); n != 0 { t.Fatalf("re-announced an existing proposal %d times, want 0", n) } } // TestTickProposalRespectsGate — a proposal is the least urgent thing Maven can // say, so it is sev1 and the restraint gate suppresses it. Away presence means // it is not announced at all: it is not held, not retried, it just lives on // /routines. The proposal row is still written — noticing is never gated. func TestTickProposalRespectsGate(t *testing.T) { st := newTestStore(t) ctx := context.Background() now := refNow() seedRefillEvents(t, st, ctx, now, pattern.MinEvents) // no presence probes ⇒ away ⇒ care-class gate blocks. sink := &fakeSink{} tl := newTestTickLoop(t, st, sink, nil) tl.proposalCfg = &config.PatternProposalConfig{Notify: true} tl.tick(ctx, now) if n := countSends(sink, proposalRule); n != 0 { t.Fatalf("away: announced %d proposals, want 0", n) } if !tl.lastProposalAt.IsZero() { t.Error("cooldown clock advanced on a suppressed announcement") } rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed) if err != nil { t.Fatalf("list proposed: %v", err) } if len(rows) != 1 { t.Fatalf("proposed routines = %d, want 1 (detection is never gated)", len(rows)) } } // TestTickProposalCooldownSpacesAnnouncements — two patterns detected on the // same tick must not become two interruptions. The second one waits for the // cooldown, and is on /routines meanwhile. func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) { st := newTestStore(t) ctx := context.Background() now := refNow() seedRefillEvents(t, st, ctx, now, pattern.MinEvents) for i := 0; i < pattern.MinEvents; i++ { ts := now.Add(time.Duration(i) * 3 * 24 * time.Hour) factID, err := st.WriteFact(ctx, ts, store.KindSelf, "litter_box", "clean", "test", 1.0, sql.NullInt64{}) if err != nil { t.Fatalf("write fact: %v", err) } if _, err := st.CreateEvent(ctx, factID, "clean", "litter_box", ts); err != nil { t.Fatalf("create event: %v", err) } } markPresent(t, st, ctx, now) sink := &fakeSink{} tl := newTestTickLoop(t, st, sink, nil) tl.proposalCfg = &config.PatternProposalConfig{Notify: true, Cooldown: config.Duration(24 * time.Hour)} tl.tick(ctx, now) announced := 0 for _, s := range sink.sends { if strings.HasPrefix(s.RuleName, "proposal:") { announced++ } } if announced != 1 { t.Fatalf("announced %d proposals on one tick, want exactly 1", announced) } rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed) if err != nil { t.Fatalf("list proposed: %v", err) } if len(rows) != 2 { t.Fatalf("proposed routines = %d, want 2 (both recorded, one announced)", len(rows)) } // Still inside the cooldown: silence, even though a proposal is pending. sink.sends = nil soon := now.Add(time.Hour) markPresent(t, st, ctx, soon) tl.tick(ctx, soon) for _, s := range sink.sends { if strings.HasPrefix(s.RuleName, "proposal:") { t.Fatalf("announced %q inside the cooldown", s.RuleName) } } }