ed9bdd5e09
The plan answers "какие планы на сегодня?" by putting one day in order: calendar events (with #126's ambient provenance carried through and hedged), pending reminders, and one line per morning routine that still has items outstanding. "что дальше?" trims what has already passed. It lives in internal/morning, not in a parallel system, because it is the same question the checklist asks at a different scale — the routine knows what is missing from a window, the plan knows what the whole day holds, and both read the same facts and the same idea of "today". BuildPlan is pure; tickLoop.dayPlan is the impure half that reads the store. It is not a nag. Nothing here fires, schedules or announces: the plan is built only when asked, over IPC (day_plan) or on the existing /morning page. Unprompted delivery stays with the morning nudge and the dispatcher's policy. The query source sits before "calendar" in querySources because both match "…на сегодня" and the plan's matcher is the more specific one; IsDayPlanQuery matches whole words so "планёрка" (a meeting) is not read as a request for the plan, and refuses any utterance naming another day, since the plan is built for the clock's own day only. Verified: make build and make test both exit 0; new tests cover plan ordering, the checklist-only-what-is-left rule, other-day rejection, the RU rendering against the persona checks, rest-of-day trimming, the source ordering, and the matcher's refusals.
144 lines
4.4 KiB
Go
144 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|