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) } }