Files
Maven/cmd/mavend/historyq_test.go
T
claude 0f361d2034 mavend: answer what he told her, from the facts he already tapped in (V-456)
Read-only over rows that exist. No new mechanism and no new storage: every
fact he tapped in already carries a source and a timestamp, and the history
source only reads them back.

Only "tap:" sources, and only the last day. A fact written by a poller, an
inference or the ambient relay is a thing she learned rather than a thing he
said, and reading those back under "что я тебе говорил?" would put words in
his mouth. Five at a time, which is what fits in one spoken breath — the rest
are on /history, which is the surface for reading a list.

Above the recall sources, with the others that read his own rows: the notes
pass would otherwise answer this from whatever note is nearest, which reads
as an answer and is not one. The matcher wants both halves of a history
phrase and steps aside when he names a topic, so "что я говорил про сервер"
stays a recall question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:07:43 +04:00

105 lines
3.4 KiB
Go

package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
)
// historyAPI serves a fixed set of recent facts.
type historyAPI struct {
ipc.UnimplementedCoreAPI
facts []ipc.Fact
calls int
}
func (a *historyAPI) RecentFacts(context.Context, int) ([]ipc.Fact, error) {
a.calls++
return a.facts, nil
}
func historyHandler(now time.Time, facts ...ipc.Fact) (*reactiveHandler, *historyAPI) {
api := &historyAPI{facts: facts}
return &reactiveHandler{api: api, now: func() time.Time { return now }}, api
}
func askHistory(h *reactiveHandler, u string) (string, bool) {
return h.queryHistory(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
})
}
func TestIsHistoryQuery(t *testing.T) {
for _, tc := range []struct {
text string
want bool
}{
{"что я тебе говорил?", true},
{"что ты записала сегодня?", true},
{"что я отмечал?", true},
// A named topic is a recall question, and the notes pass answers it
// better than a list of the last five facts does.
{"что я говорил про сервер?", false},
{"что у меня сегодня?", false},
{"", false},
} {
if got := isHistoryQuery(tc.text); got != tc.want {
t.Errorf("isHistoryQuery(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
h, api := historyHandler(now,
ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)},
// Learned, not said: a poller writing this back under "что я тебе
// говорил?" would put words in his mouth.
ipc.Fact{Key: "spent_today", Value: "1200", Source: "poll:zenmoney", Ts: now.Add(-time.Hour)},
// Older than the window.
ipc.Fact{Key: "shower", Value: "принял", Source: "tap:voice", Ts: now.Add(-30 * time.Hour)},
)
reply, ok := askHistory(h, "что я тебе говорил?")
if !ok {
t.Fatal("the history question must be claimed before the recall sources")
}
if !strings.Contains(reply, "water") {
t.Errorf("reply = %q, want the fact he tapped in", reply)
}
if strings.Contains(reply, "spent_today") || strings.Contains(reply, "shower") {
t.Errorf("reply = %q, want only what he said inside the window", reply)
}
if api.calls != 1 {
t.Errorf("RecentFacts called %d times, want 1", api.calls)
}
}
// Nothing said is an answer of its own. Falling through would hand the question
// to recall, which answers it with an old note.
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
h, _ := historyHandler(now)
reply, ok := askHistory(h, "что я тебе говорил?")
if !ok || !strings.Contains(reply, "ничего") {
t.Fatalf("reply = %q, ok = %v", reply, ok)
}
}
// Five is what fits in one spoken breath; the rest are on /history.
func TestHistoryStopsAtFive(t *testing.T) {
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
var facts []ipc.Fact
for i := 0; i < 12; i++ {
facts = append(facts, ipc.Fact{Key: "k", Value: "v", Source: "tap:voice", Ts: now.Add(-time.Minute)})
}
h, _ := historyHandler(now, facts...)
reply, _ := askHistory(h, "что ты записала?")
if got := strings.Count(reply, ";"); got != historyReadOut-1 {
t.Fatalf("reply = %q has %d separators, want %d", reply, got, historyReadOut-1)
}
}