b3c2fad4ec
Four defects in the plan, all of them in what it reads or how it prints it. The checklist line was keyed on Status.Active, which Evaluate reports only inside the window, so a morning routine skipped and asked about at 14:00 said nothing. Outstanding answers the question the plan asks, "what did today still not get done", and the line stays placed at the nudge time so it sorts to the top of the day. Nothing before the window opens counts, so 06:00 is not a complaint. The event text kept the "@ 14:00-14:30" tail FactValue writes, next to a line that prints the hour itself, so every event said its time twice. Reminders came off ListReminders, which orders by creation, so the 500 row cap dropped a reminder stated long ago for today and kept one stated this morning for next year. PendingReminders bounds by fire time instead. The pending filter used a string literal, one typo from matching nothing. After now marks the plan it trimmed. "что дальше?" past the last item answered "на 03.08.2026 ничего не запланировано", which denies a day he just lived through. The surface the plan belongs on is still open, tracked as Vikunja #431; the comment in actions_query.go points at it. Found in review of #58.
313 lines
10 KiB
Go
313 lines
10 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 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|