89afe4ca99
# Conflicts: # cmd/mavend/actions_query.go # cmd/mavend/dayplan_test.go
354 lines
12 KiB
Go
354 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/calendar"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// planAPI answers only DayPlan; every other call is unimplemented, which is
|
|
// exactly the assertion that the plan source needs nothing else.
|
|
type planAPI struct {
|
|
ipc.UnimplementedCoreAPI
|
|
plan ipc.DayPlan
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (a *planAPI) DayPlan(context.Context) (ipc.DayPlan, error) {
|
|
a.calls++
|
|
if a.err != nil {
|
|
return ipc.DayPlan{}, a.err
|
|
}
|
|
return a.plan, nil
|
|
}
|
|
|
|
func planDay() time.Time { return time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) }
|
|
|
|
func samplePlan() ipc.DayPlan {
|
|
day := planDay()
|
|
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
|
return ipc.DayPlan{
|
|
Date: mid,
|
|
Items: []ipc.DayPlanItem{
|
|
{At: day.Add(-2 * time.Hour), Text: "Standup @ 10:00-10:30", Kind: "event"},
|
|
{At: day.Add(2 * time.Hour), Text: "Планёрка @ 14:00-14:30", Kind: "event", Uncertain: true},
|
|
{At: day.Add(6 * time.Hour), Text: "позвонить маме", Kind: "reminder"},
|
|
},
|
|
Spoken: "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
|
"похоже, 14:00 — Планёрка @ 14:00-14:30; 18:00 — позвонить маме.",
|
|
}
|
|
}
|
|
|
|
func planHandler(api ipc.CoreAPI) *reactiveHandler {
|
|
return &reactiveHandler{api: api, now: planDay}
|
|
}
|
|
|
|
func TestQueryDayPlanRecitesTheDay(t *testing.T) {
|
|
api := &planAPI{plan: samplePlan()}
|
|
h := planHandler(api)
|
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие планы на сегодня?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("the plan source must claim a plan question")
|
|
}
|
|
if reply != api.plan.Spoken {
|
|
t.Errorf("reply = %q, want the core's spoken plan %q", reply, api.plan.Spoken)
|
|
}
|
|
}
|
|
|
|
// "что дальше?" is the rest of the day, not the whole day: what has already
|
|
// happened is not a plan.
|
|
func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
|
|
h := planHandler(&planAPI{plan: samplePlan()})
|
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected the plan source to claim it")
|
|
}
|
|
if strings.Contains(reply, "Standup") {
|
|
t.Errorf("a passed item must not be read back: %q", reply)
|
|
}
|
|
if !strings.Contains(reply, "Планёрка") || !strings.Contains(reply, "позвонить маме") {
|
|
t.Errorf("the rest of the day is missing: %q", reply)
|
|
}
|
|
// Provenance survives the trim.
|
|
if !strings.Contains(reply, "похоже,") {
|
|
t.Errorf("a relayed event must stay hedged: %q", reply)
|
|
}
|
|
}
|
|
|
|
// "что дальше?" after the last item of the day. The day was not empty, it is
|
|
// over, and the whole-day empty line says something false about a day he just
|
|
// lived through.
|
|
func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
|
plan := samplePlan()
|
|
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
|
return time.Date(2026, 8, 3, 23, 0, 0, 0, time.UTC)
|
|
}}
|
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected the plan source to claim it")
|
|
}
|
|
if strings.Contains(reply, plan.Date.Format("02.01.2006")) {
|
|
t.Errorf("the day had things on it and they are done, not empty: %q", reply)
|
|
}
|
|
if reply != "на сегодня больше ничего не запланировано." {
|
|
t.Errorf("reply = %q", reply)
|
|
}
|
|
}
|
|
|
|
// A question that is not about the plan must fall through, or the plan buries
|
|
// the calendar listing and the weather behind it.
|
|
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
|
for _, q := range []string{
|
|
"что у меня сегодня?",
|
|
"какие планы на завтра?",
|
|
// The plan can only be built for the clock's own day. Naming another
|
|
// one has to fall through, not get answered with today.
|
|
"какие планы на понедельник?",
|
|
"какие планы на неделю?",
|
|
"какие планы на выходные?",
|
|
"what are my plans for friday?",
|
|
"когда планёрка?",
|
|
"какая погода?",
|
|
"",
|
|
} {
|
|
api := &planAPI{plan: samplePlan()}
|
|
reply, ok := planHandler(api).queryDayPlan(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
|
})
|
|
if ok {
|
|
t.Errorf("%q was claimed by the plan source (reply %q)", q, reply)
|
|
}
|
|
if api.calls != 0 {
|
|
t.Errorf("%q hit the core for a plan it does not want", q)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryDayPlanCoreFailure(t *testing.T) {
|
|
h := planHandler(&planAPI{err: errors.New("socket closed")})
|
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "план на сегодня"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
|
}
|
|
if reply != "не получилось собрать план." {
|
|
t.Errorf("reply = %q", reply)
|
|
}
|
|
}
|
|
|
|
// The day plan must sit before the calendar listing: both match "…на сегодня",
|
|
// and the more specific matcher has to get first refusal (see #373 for what
|
|
// happens when the order is wrong).
|
|
func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
|
|
plan, cal := -1, -1
|
|
for i, s := range querySources {
|
|
switch s.name {
|
|
case "day-plan":
|
|
plan = i
|
|
case "calendar":
|
|
cal = i
|
|
}
|
|
}
|
|
if plan < 0 || cal < 0 {
|
|
t.Fatalf("sources missing: day-plan=%d calendar=%d", plan, cal)
|
|
}
|
|
if plan > cal {
|
|
t.Errorf("day-plan at %d must come before calendar at %d", plan, cal)
|
|
}
|
|
}
|
|
|
|
// habitAPI answers only the kind-filtered fact read — the whole input the
|
|
// behaviour profile needs (Vikunja #254). Nothing is asked of the LLM, so
|
|
// nothing else is wired. RecentFacts is left unimplemented on purpose: the
|
|
// profile must not read the mixed window, and a caller that does fails here.
|
|
type habitAPI struct {
|
|
ipc.UnimplementedCoreAPI
|
|
facts []ipc.Fact
|
|
err error
|
|
calls int
|
|
kind string
|
|
}
|
|
|
|
func (a *habitAPI) RecentActiveFactsByKind(_ context.Context, kind string, _ int) ([]ipc.Fact, error) {
|
|
a.calls++
|
|
a.kind = kind
|
|
return a.facts, a.err
|
|
}
|
|
|
|
// tuesdayFacts — n weekly Tuesday rows for key, ending before now.
|
|
func tuesdayFacts(key string, hh, weeks int, now time.Time) []ipc.Fact {
|
|
d := now
|
|
for d.Weekday() != time.Tuesday {
|
|
d = d.AddDate(0, 0, -1)
|
|
}
|
|
var out []ipc.Fact
|
|
for i := 0; i < weeks; i++ {
|
|
day := d.AddDate(0, 0, -7*i)
|
|
out = append(out, ipc.Fact{
|
|
Ts: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
|
Kind: "self",
|
|
Key: key,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestQueryHabitsAnswersFromCountedFacts(t *testing.T) {
|
|
now := planDay() // a Monday
|
|
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
|
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
|
|
|
reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("the habit source must claim a habit question")
|
|
}
|
|
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
|
t.Errorf("reply = %q, want %q", reply, want)
|
|
}
|
|
}
|
|
|
|
func TestQueryHabitsPassesOnEverythingElse(t *testing.T) {
|
|
now := planDay()
|
|
for _, q := range []string{"что я делаю в среду?", "что у меня сегодня?", "какие планы на сегодня?", ""} {
|
|
api := &habitAPI{}
|
|
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
|
if reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
|
}); ok {
|
|
t.Errorf("%q was claimed by the habit source (reply %q)", q, reply)
|
|
}
|
|
if api.calls != 0 {
|
|
t.Errorf("%q scanned the fact log for a profile it does not want", q)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Both specific sources must precede the calendar listing, which matches any
|
|
// utterance naming a day.
|
|
func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
|
habits, cal := -1, -1
|
|
for i, s := range querySources {
|
|
switch s.name {
|
|
case "habits":
|
|
habits = i
|
|
case "calendar":
|
|
cal = i
|
|
}
|
|
}
|
|
if habits < 0 || cal < 0 {
|
|
t.Fatalf("sources missing: habits=%d calendar=%d", habits, cal)
|
|
}
|
|
if habits > cal {
|
|
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
|
}
|
|
}
|
|
|
|
// TestQueryHabitsReadsSelfFactsOnly — the profile window is a budget over rows,
|
|
// so it must be spent on the rows the profile can use. Reading the mixed table
|
|
// let one chatty poller (wg_handshake, roughly every two minutes per peer) push
|
|
// every tap out of the window, and she then reported no habits on a store that
|
|
// held them.
|
|
func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) {
|
|
now := planDay()
|
|
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
|
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
|
|
|
if _, ok := h.queryHabits(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
|
}); !ok {
|
|
t.Fatal("the habit source must claim a habit question")
|
|
}
|
|
if api.kind != string(store.KindSelf) {
|
|
t.Errorf("profile read kind %q, want %q", api.kind, store.KindSelf)
|
|
}
|
|
}
|
|
|
|
// TestHabitQueryWithPlanWordReachesHabits — the whole chain, not just the
|
|
// matchers: a habit question carrying "планы" used to be answered by the day
|
|
// plan with today's calendar, because day-plan sits above habits.
|
|
func TestHabitQueryWithPlanWordReachesHabits(t *testing.T) {
|
|
now := planDay()
|
|
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
|
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
|
|
|
reply := h.actionQuery(context.Background(), router.Decision{
|
|
Intent: router.IntentQuery,
|
|
Utterance: "какие у меня обычно планы по вторникам?",
|
|
})
|
|
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
|
t.Errorf("reply = %q, want %q", reply, want)
|
|
}
|
|
}
|
|
|
|
// The plan reads the store on the owner's clock: one line per event, the hour
|
|
// printed once, and reminders selected by fire time rather than by how
|
|
// recently they were stated.
|
|
func TestTickDayPlanReadsTheStore(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
|
|
|
now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.Local)
|
|
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
|
|
ev := calendar.Event{
|
|
Summary: "Standup",
|
|
Start: day.Add(14 * time.Hour),
|
|
End: day.Add(14*time.Hour + 30*time.Minute),
|
|
}
|
|
// Rescheduled: same key, a second row.
|
|
if _, err := st.WriteFact(ctx, ev.Start, store.KindEnv, calendar.FactKey(ev),
|
|
calendar.FactValue(ev), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
|
t.Fatalf("WriteFact: %v", err)
|
|
}
|
|
moved := ev
|
|
moved.Start, moved.End = day.Add(16*time.Hour), day.Add(16*time.Hour+30*time.Minute)
|
|
if _, err := st.WriteFact(ctx, moved.Start, store.KindEnv, calendar.FactKey(moved),
|
|
calendar.FactValue(moved), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
|
t.Fatalf("WriteFact: %v", err)
|
|
}
|
|
// One reminder today, one next year. Both are pending; only today's is a
|
|
// plan for today.
|
|
if _, err := st.CreateReminder(ctx, day.Add(18*time.Hour), "позвонить маме", ""); err != nil {
|
|
t.Fatalf("CreateReminder: %v", err)
|
|
}
|
|
if _, err := st.CreateReminder(ctx, day.AddDate(1, 0, 0), "продлить страховку", ""); err != nil {
|
|
t.Fatalf("CreateReminder: %v", err)
|
|
}
|
|
|
|
plan := tl.dayPlan(ctx, now)
|
|
if len(plan.Items) != 2 {
|
|
t.Fatalf("got %d items, want the moved standup and today's reminder: %+v", len(plan.Items), plan.Items)
|
|
}
|
|
ev0 := plan.Items[0]
|
|
if ev0.Kind != "event" || ev0.At.In(time.Local).Format("15:04") != "16:00" {
|
|
t.Errorf("event = %+v, want the 16:00 one", ev0)
|
|
}
|
|
if ev0.Text != "Standup" {
|
|
t.Errorf("text = %q — the plan prints the hour itself", ev0.Text)
|
|
}
|
|
if plan.Items[1].Text != "позвонить маме" {
|
|
t.Errorf("second item = %+v", plan.Items[1])
|
|
}
|
|
if strings.Contains(plan.Spoken, "страховку") {
|
|
t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken)
|
|
}
|
|
}
|