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) } } // The day window rolls over at midnight and the poller writes nothing until the // first spend of the new day, so the last money_today fact is fresh by ts and // covers yesterday. No staleness check can catch that. func TestQueryMoneyRefusesYesterdaysDayTotal(t *testing.T) { yesterday, _ := zenmoney.DayWindow(moneyNow().AddDate(0, 0, -1)) sum := zenmoney.Summary{From: yesterday, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 1749.5}}, Count: 3} val, ok := sum.Value(moneyNow().AddDate(0, 0, -1).Add(2 * time.Hour)) if !ok { t.Fatal("want a fact value") } api := &moneyAPI{fact: ipc.Fact{ Kind: "env", Key: zenmoney.KeySpentToday, Value: val, Source: zenmoney.Source, Ts: moneyNow().Add(-11 * time.Hour), }} h := &reactiveHandler{api: api, now: moneyNow} reply, claimed := h.queryMoney(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "сколько я потратил сегодня?"}, }) if !claimed { t.Fatal("expected the source to claim it") } if strings.Contains(reply, "1749.5") { t.Errorf("reply = %q — that is yesterday's spending spoken as today's", reply) } } // Ts advances only when the number moves, so a quiet month used to be reported // as stale while being current. The read stamp inside the value is what the // staleness check means. func TestQueryMoneyMeasuresStalenessFromTheRead(t *testing.T) { from, _ := zenmoney.MonthWindow(moneyNow()) sum := zenmoney.Summary{From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Count: 1} val, _ := sum.Value(moneyNow().Add(-time.Hour)) // The fact itself last CHANGED three days ago: nothing was spent since. api := &moneyAPI{fact: ipc.Fact{ Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: moneyNow().Add(-72 * time.Hour), }} h := &reactiveHandler{api: api, now: moneyNow} reply, _ := h.queryMoney(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"}, }) if strings.Contains(reply, "данные от") { t.Errorf("reply = %q — the figure was read an hour ago and is current", reply) } } // Two windows are stored and no others. Answering "вчера" with the // month-to-date total answers a different question with a real number. func TestQueryMoneyRefusesWindowsItDoesNotKeep(t *testing.T) { api := &moneyAPI{} h := &reactiveHandler{api: api, now: moneyNow} reply, ok := h.queryMoney(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "сколько я потратил вчера?"}, }) if !ok { t.Fatal("a money question must be claimed, not passed to recall") } if !strings.Contains(reply, "только") { t.Errorf("reply = %q, want her to say which windows she keeps", reply) } if api.callCnt != 0 { t.Error("a window she does not keep must not read a fact") } } // "сколько я заработал" reads the same fact and must lead with the income. func TestQueryMoneyLeadsWithIncomeWhenAsked(t *testing.T) { from, _ := zenmoney.MonthWindow(moneyNow()) sum := zenmoney.Summary{ From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Earned: []zenmoney.Money{{Currency: "RUB", Amount: 3000}}, Count: 2, } val, _ := sum.Value(moneyNow()) api := &moneyAPI{fact: ipc.Fact{ Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: moneyNow(), }} h := &reactiveHandler{api: api, now: moneyNow} reply, _ := h.queryMoney(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "сколько я заработал в этом месяце?"}, }) if strings.Index(reply, "3000") > strings.Index(reply, "100") { t.Errorf("reply = %q, want the income he asked about first", reply) } }