Files
Maven/cmd/mavend/historyq_test.go
claude a8710c859b history: answer the side of the question that was asked (V-456)
"что ты записала сегодня?" was recognised as a history question and then
answered with "ты говорил: …". The rows are right — a tapped fact is one act
seen from two sides — but the sentence hands the question back instead of
answering it.

historyAsks returns which side was asked and queryHistory phrases from it,
including the nothing-found reply. His side is tested first, because "отмечать"
is on both verb lists and "что я отметил" is not a question about her.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 20:54:11 +04:00

143 lines
5.3 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},
// Forms the truncated prefixes did not reach. The dictionary answers
// these because it lemmatises both sides (V-530).
{"что я тебе рассказывал?", true},
{"что я сказала вчера", true},
{"что ты запомнила?", true},
// The noun, not the verb. "рассказ" was a prefix of the old pair, so
// this read as a history question — the same defect V-528 fixed in
// complaint.go, where "лаг" matched "лагерь".
{"что я читал рассказ", false},
// A verb of saying with nobody saying it.
{"что записать?", false},
// 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)
}
}
// The rows are the same either way, because a tapped fact is one act seen from
// two sides. The sentence is not: "что ты записала" answered with "ты говорил"
// hands the question back (Vikunja #456).
func TestHistoryAnswersTheSideItWasAsked(t *testing.T) {
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
h, _ := historyHandler(now, ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)})
his, ok := askHistory(h, "что я тебе говорил?")
if !ok || !strings.HasPrefix(his, "ты говорил") {
t.Errorf("reply = %q, ok = %v, want his side", his, ok)
}
hers, ok := askHistory(h, "что ты записала сегодня?")
if !ok || !strings.HasPrefix(hers, "я записала") {
t.Errorf("reply = %q, ok = %v, want her side", hers, ok)
}
// "отмечать" is on both verb lists, so his subject has to win.
if side, ok := historyAsks("что я отметил?"); !ok || side != historyAskedHim {
t.Errorf("historyAsks(что я отметил) = %v, %v", side, ok)
}
empty, _ := historyHandler(now)
none, ok := askHistory(empty, "что ты записала сегодня?")
if !ok || !strings.Contains(none, "не записывала") {
t.Errorf("empty reply = %q, ok = %v, want her side", none, ok)
}
}
// 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)
}
}