012bdcc1ae
The behaviour profile read the newest 2000 rows of the shared facts table and then discarded everything that was not kind=self, so the length of the window was set by the noisiest writer. mavpoll writes a wg_handshake row every time a peer rehandshakes, about every two minutes per peer, which is enough to reduce 2000 rows to under three days. A weekday habit needs two distinct Tuesdays, so that window can never hold one, and she answered that she knows no habits on a store holding a year of taps. RecentActiveFactsByKind filters kind in SQL, and also drops rows a later row voids along with the void marker itself. The old read counted both a retracted tap and its retraction, so a fact he explicitly took back still shaped what she said he usually does. A correction still counts, because a correction is a value he stands behind. Found in review of #59.
253 lines
8.1 KiB
Go
253 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// 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 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)
|
|
}
|
|
}
|