package main import ( "context" "database/sql" "testing" "time" "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). Three 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, 3) tl := newTestTickLoop(t, st, &fakeSink{}, nil) tl.detectPatterns(ctx, now) 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, 3) tl := newTestTickLoop(t, st, &fakeSink{}, nil) tl.detectPatterns(ctx, now) tl.detectPatterns(ctx, now.Add(time.Hour)) 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, 3) tl := newTestTickLoop(t, st, &fakeSink{}, nil) tl.detectPatterns(ctx, now) 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), 3) tl.detectPatterns(ctx, now.Add(60*24*time.Hour)) 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) } }