package main import ( "context" "strings" "testing" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/zenmoney" ) // moneyAPI answers only LatestFactBySource; everything else is unimplemented, // which is the assertion that answering a money question costs no model call // and reaches no network. type moneyAPI struct { ipc.UnimplementedCoreAPI fact ipc.Fact err error gotKey string gotSrc string callCnt int } func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) { a.gotKey, a.gotSrc = key, source a.callCnt++ return a.fact, a.err } func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) } func moneyFact(ts time.Time, val string) ipc.Fact { return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts} } func TestQueryMoneyAnswersFromTheFact(t *testing.T) { api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)} h := &reactiveHandler{api: api, now: moneyNow} reply, ok := h.queryMoney(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"}, }) if !ok { t.Fatal("the money source must claim a money question") } if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source { t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc) } if !strings.Contains(reply, "1749.5") { t.Errorf("reply = %q, want the exact figure", reply) } if !strings.Contains(reply, "в этом месяце") { t.Errorf("reply = %q, want the window named", reply) } } func TestQueryMoneyPicksTodaysKey(t *testing.T) { api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)} h := &reactiveHandler{api: api, now: moneyNow} if _, ok := h.queryMoney(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "сколько я потратил сегодня?"}, }); !ok { t.Fatal("expected the source to claim it") } if api.gotKey != zenmoney.KeySpentToday { t.Errorf("key = %q, want today's", api.gotKey) } } // The capability is off unless configured, and then there is no fact. She says // so instead of letting the recall pass answer a money question from a note. func TestQueryMoneySaysNotConnected(t *testing.T) { h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow} reply, ok := h.queryMoney(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "сколько я потратил?"}, }) if !ok { t.Fatal("expected the source to claim it") } if !strings.Contains(reply, "не подключено") { t.Errorf("reply = %q, want an honest 'not connected'", reply) } // No number of any kind in that answer. for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} { if strings.Contains(reply, d) { t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply) } } } // A fact older than the staleness bound is dated rather than spoken as if it // were current: the poller can be down, and last week's total presented as // today's is a lie by omission. func TestQueryMoneyDatesAStaleFact(t *testing.T) { old := moneyNow().Add(-72 * time.Hour) api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)} h := &reactiveHandler{api: api, now: moneyNow} reply, _ := h.queryMoney(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "сколько я потратил?"}, }) if !strings.Contains(reply, "данные от") { t.Errorf("reply = %q, want the stale fact dated", reply) } } func TestQueryMoneyPassesOtherQuestions(t *testing.T) { api := &moneyAPI{} h := &reactiveHandler{api: api, now: moneyNow} for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} { if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok { t.Errorf("the money source claimed %q", u) } } if api.callCnt != 0 { t.Error("a non-money question must not read the money facts") } } // Money must be answered before the recall sources, or a question about // spending gets answered by the nearest note. func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) { moneyAt, notesAt := -1, -1 for i, src := range querySources { switch src.name { case "money": moneyAt = i case "notes": notesAt = i } } if moneyAt < 0 || notesAt < 0 { t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt) } if moneyAt > notesAt { t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt) } }