10975eff07
Measured on the box at 04:45: "что дальше?" read 43 entries, 05:45 to 21:12, as one spoken sentence. The rest-of-day path already trimmed to what had not happened yet, and at 04:45 that trim removes nothing — the whole day is still ahead. Trimming was never the narrowing; nothing capped the read. Plan.Next(now, n) is After with a cap, and the overflow is counted rather than dropped. The cap is three. One entry is defensible and reads as an oracle: it says what is next and says nothing about whether the day is full. Three is what the feed already reads back for headlines, it fits in one breath, and the reply is spoken — he cannot scroll it back. Above three the answer stops being an answer and becomes a recital, which is the defect. The sentence says whether more remains: plan_next is "дальше: …" and plan_next_more appends "и ещё 40 дел до конца дня." So a capped answer never implies the day ends after the third line. After is now strictly after now. An entry at exactly the asking minute is the thing happening, not the thing next. "что у меня сегодня?" is untouched and was never on this path: it carries no plan word, so IsDayPlanQuery declines it and the calendar listing answers the whole day. TestWholeDayQuestionIsNotTheRestOfTheDay pins the two apart. The empty case already said the right thing — plan_rest_empty, "на сегодня больше ничего не запланировано", not the whole-day empty line that would deny a day he just lived — and now has a test at the cap boundary too. Routing fixture unchanged, 64/91 (70.3% full, 70.3% intent-only) before and after: no router file is touched. Suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
436 lines
16 KiB
Go
436 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/calendar"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/morning"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes
|
|
// nothing, because the whole day is still ahead. She read 43 entries aloud as
|
|
// one sentence. The zone is three hours off UTC so the test also fails under
|
|
// TZ=UTC if the rendering ever slips zones.
|
|
func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) {
|
|
zone := time.FixedZone("MSK", 3*60*60)
|
|
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone)
|
|
plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"}
|
|
for i := 0; i < 43; i++ {
|
|
plan.Items = append(plan.Items, ipc.DayPlanItem{
|
|
At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward
|
|
Text: fmt.Sprintf("пункт %d", i),
|
|
Kind: "event",
|
|
})
|
|
}
|
|
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
|
return time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
|
|
}}
|
|
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 n := strings.Count(reply, "пункт "); n != morning.NextSpoken {
|
|
t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply)
|
|
}
|
|
if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") {
|
|
t.Errorf("the next thing is not first: %q", reply)
|
|
}
|
|
// The rest is counted, not silently dropped.
|
|
if !strings.Contains(reply, "и ещё 40 дел до конца дня.") {
|
|
t.Errorf("the sentence hides that the day goes on: %q", reply)
|
|
}
|
|
}
|
|
|
|
// "что у меня сегодня?" is the whole day and is not narrowed. It carries no
|
|
// plan word, so the plan source declines it and the calendar listing answers —
|
|
// asserted here beside the cap so the two questions cannot drift together.
|
|
func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) {
|
|
if router.IsDayPlanQuery("что у меня сегодня?") {
|
|
t.Error("the plan source claims the whole-day question")
|
|
}
|
|
if !router.IsDayPlanQuery("что дальше?") {
|
|
t.Error("the plan source stopped claiming the rest-of-day question")
|
|
}
|
|
if router.IsRestOfDayQuery("какие планы на сегодня?") {
|
|
t.Error("the whole-day plan question got narrowed to the rest of the day")
|
|
}
|
|
}
|
|
|
|
// 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 !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
|
t.Errorf("reply = %q, want the honest failure", 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)
|
|
}
|
|
}
|
|
|
|
// TestHandlerUpgradesToTheDaemonAPI — wireVoice runs before the tick loop
|
|
// exists, so the handler starts with the bare store adapter, and that adapter
|
|
// refuses DayPlan ("not available via direct store API"). main back-patches
|
|
// the real one in. Without the patch every "какие у меня планы на сегодня"
|
|
// answered "не получилось собрать план" on the deployed daemon, 01-08-2026.
|
|
func TestHandlerUpgradesToTheDaemonAPI(t *testing.T) {
|
|
h := &reactiveHandler{api: ipc.NewStoreAPI(nil), now: planDay}
|
|
if _, err := h.api.DayPlan(context.Background()); err == nil {
|
|
t.Fatal("the bare store adapter served a day plan; this test is measuring nothing")
|
|
}
|
|
|
|
want := samplePlan()
|
|
h.upgradeAPI(&daemonAPI{
|
|
CoreAPI: ipc.UnimplementedCoreAPI{},
|
|
getDayPlan: func(context.Context) ipc.DayPlan { return want },
|
|
})
|
|
|
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня планы на сегодня?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("queryDayPlan passed on a plan question")
|
|
}
|
|
if reply != want.Spoken {
|
|
t.Fatalf("reply = %q, want the assembled plan", reply)
|
|
}
|
|
}
|