package main import ( "context" "errors" "strings" "testing" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" ) // 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) } } // 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{ "что у меня сегодня?", "какие планы на завтра?", "когда планёрка?", "какая погода?", "", } { 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 RecentFacts — the whole input the behaviour profile // needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired. type habitAPI struct { ipc.UnimplementedCoreAPI facts []ipc.Fact err error calls int } func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) { a.calls++ 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) } }