Version, authenticate and fully trace ecosystem calls #84
+31
-16
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -28,12 +29,18 @@ import (
|
||||
// tracking is not connected". It never computes, estimates or rounds a total of
|
||||
// its own — an invented number about his money is the worst thing this could do.
|
||||
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||
q, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if q.Window == router.MoneyUnsupported {
|
||||
// Two windows are stored and no others. Answering "сколько я потратил
|
||||
// вчера?" with the month-to-date total answers a different question
|
||||
// with a real number, which is the shape of a lie he cannot spot.
|
||||
return "я храню только сегодняшние траты и за этот месяц.", true
|
||||
}
|
||||
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
|
||||
if window == router.MoneyToday {
|
||||
if q.Window == router.MoneyToday {
|
||||
key, phrase = zenmoney.KeySpentToday, "сегодня"
|
||||
}
|
||||
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
|
||||
@@ -51,28 +58,36 @@ func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: money fact: decode: %v", err)
|
||||
return "не получилось прочитать траты.", true
|
||||
}
|
||||
now := h.now()
|
||||
if q.Window == router.MoneyToday && !val.CoversDay(now) {
|
||||
// The day window rolled over and the poller had nothing to write,
|
||||
// because he has not spent anything yet today. The fact is fresh by ts
|
||||
// and covers yesterday, so no staleness check can catch it — only the
|
||||
// window stamp inside the value can.
|
||||
return "сегодня пока ничего не вижу.", true
|
||||
}
|
||||
reply := val.FormatRU(phrase)
|
||||
if q.Income {
|
||||
reply = val.FormatIncomeRU(phrase)
|
||||
}
|
||||
if reply == "" {
|
||||
return "по тратам пока нечего сказать.", true
|
||||
}
|
||||
// A stale fact is reported as stale rather than spoken as today's number.
|
||||
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
|
||||
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
|
||||
// The age is measured from when the figure was last READ, not from when it
|
||||
// last changed: a month with no spending in it does not go stale.
|
||||
asOf := val.AsOf
|
||||
if asOf.IsZero() {
|
||||
asOf = fact.Ts
|
||||
}
|
||||
if now.Sub(asOf) > zenmoney.StaleAfter {
|
||||
return "данные от " + asOf.Local().Format("02.01") + ": " + reply, true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
|
||||
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. The
|
||||
// hand-rolled loop this replaces missed any error implementing Is(error) bool.
|
||||
func isNoFactErr(err error) bool {
|
||||
for e := err; e != nil; {
|
||||
if e == ipc.ErrNoFact {
|
||||
return true
|
||||
}
|
||||
u, ok := e.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
return errors.Is(err, ipc.ErrNoFact)
|
||||
}
|
||||
|
||||
@@ -137,3 +137,92 @@ func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+34
-16
@@ -28,6 +28,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -193,6 +194,14 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
//
|
||||
// Both windows are read from one diff call each. Two calls an hour against an
|
||||
// API whose whole job is this is not worth caching.
|
||||
//
|
||||
// The write is UNCONDITIONAL, unlike every other poll in this file. The
|
||||
// value-dedupe in writeIfChangedRaw only advances ts when the number moves, and
|
||||
// for money that made ts mean "last changed" while the reader was asking it "as
|
||||
// of when". A quiet 27 hours had core prefixing "данные от 30.07" to a figure
|
||||
// that was current. The value now carries its own read stamp, so it differs
|
||||
// every poll anyway and there is nothing left for the dedupe to catch.
|
||||
|
||||
// moneyWindow — one fact key and the period it covers.
|
||||
type moneyWindow struct {
|
||||
key string
|
||||
@@ -215,12 +224,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
val, ok := sum.Value()
|
||||
val, ok := sum.Value(now)
|
||||
if !ok {
|
||||
// Nothing read. Silence, not a zero.
|
||||
// Nothing read. Silence, not a zero. The last good fact stays, and
|
||||
// the window stamp inside it is what stops core reciting yesterday's
|
||||
// day total as today's after midnight.
|
||||
continue
|
||||
}
|
||||
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
|
||||
if err := p.writeMoneyFact(ctx, w.key, val, now); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
@@ -422,20 +433,27 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
||||
// errors.Is is the right check; keep a helper so the switch above reads clean.
|
||||
func isNoFact(err error) bool {
|
||||
for e := err; e != nil; {
|
||||
if e == ipc.ErrNoFact {
|
||||
return true
|
||||
}
|
||||
u, ok := e.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = u.Unwrap()
|
||||
// writeMoneyFact writes a money fact every poll, with no value comparison. See
|
||||
// the comment above pollZenmoney for why this one does not go through
|
||||
// writeIfChangedRaw.
|
||||
//
|
||||
// The log line names the key only, never the figures: mavpoll's log is not the
|
||||
// place his spending ends up.
|
||||
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||
Source: zenmoney.Source, Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
}
|
||||
return false
|
||||
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
||||
// errors.Is is the right check.
|
||||
func isNoFact(err error) bool {
|
||||
return errors.Is(err, ipc.ErrNoFact)
|
||||
}
|
||||
|
||||
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
|
||||
|
||||
+45
-12
@@ -16,12 +16,29 @@ const (
|
||||
MoneyNone MoneyWindow = iota
|
||||
MoneyToday
|
||||
MoneyMonth
|
||||
// MoneyUnsupported — a money question over a window nothing is stored for
|
||||
// ("вчера", "на прошлой неделе"). Claimed, not answered: the poller keeps
|
||||
// today and the month, and answering a question about yesterday with the
|
||||
// month-to-date total is worse than saying she does not keep it.
|
||||
MoneyUnsupported
|
||||
)
|
||||
|
||||
// MoneyQuery — a parsed money question. Income is set when he asked what he
|
||||
// EARNED rather than what he spent; the two read the same fact and differ only
|
||||
// in which half of it leads the answer.
|
||||
type MoneyQuery struct {
|
||||
Window MoneyWindow
|
||||
Income bool
|
||||
}
|
||||
|
||||
// incomeNouns — the words that make a money question be about income.
|
||||
var incomeNouns = []string{"заработал", "заработала", "получил", "доход", "доходы", "earned", "income"}
|
||||
|
||||
// moneyNouns — the words that make a question be about his money.
|
||||
var moneyNouns = []string{
|
||||
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
|
||||
"заработал", "потрачено", "денег", "spend", "spent", "expenses",
|
||||
"заработал", "заработала", "доход", "доходы", "потрачено", "денег",
|
||||
"spend", "spent", "expenses", "earned", "income",
|
||||
}
|
||||
|
||||
// ParseMoneyQuery reports whether an utterance asks about spending or income,
|
||||
@@ -30,11 +47,14 @@ var moneyNouns = []string{
|
||||
//
|
||||
// Narrow on purpose. A money noun alone is not enough — "я потратил весь день
|
||||
// на это" is him talking about his day, so an amount word or an explicit
|
||||
// question word has to be there too.
|
||||
func ParseMoneyQuery(text string) (MoneyWindow, bool) {
|
||||
// question word has to be there too. The two evidence halves are INDEPENDENT:
|
||||
// "траты" and "расходы" used to sit in both lists, so either word alone
|
||||
// satisfied the whole gate and "у меня в этом месяце большие траты", a
|
||||
// statement, came back with a figure.
|
||||
func ParseMoneyQuery(text string) (MoneyQuery, bool) {
|
||||
toks := planTokens(text)
|
||||
if len(toks) == 0 {
|
||||
return MoneyNone, false
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
hasNoun := false
|
||||
for _, t := range toks {
|
||||
@@ -45,27 +65,40 @@ func ParseMoneyQuery(text string) (MoneyWindow, bool) {
|
||||
}
|
||||
}
|
||||
if !hasNoun {
|
||||
return MoneyNone, false
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
// "весь день", "время", "силы" — spending that is not money.
|
||||
for _, t := range toks {
|
||||
switch t {
|
||||
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
|
||||
return MoneyNone, false
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
}
|
||||
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
|
||||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
|
||||
hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы")
|
||||
hasTok(toks, "мои")
|
||||
if !asking {
|
||||
return MoneyNone, false
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
income := false
|
||||
for _, t := range toks {
|
||||
for _, n := range incomeNouns {
|
||||
if t == n {
|
||||
income = true
|
||||
}
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
switch {
|
||||
// Windows nothing is stored for, named explicitly so they are refused
|
||||
// rather than silently answered with the month.
|
||||
case hasTok(toks, "вчера") || hasTok(toks, "позавчера") || strings.Contains(lower, "yesterday"),
|
||||
hasTok(toks, "неделю") || hasTok(toks, "неделе") || hasTok(toks, "неделя") ||
|
||||
strings.Contains(lower, "week"),
|
||||
hasTok(toks, "год") || hasTok(toks, "году") || strings.Contains(lower, "year"):
|
||||
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
|
||||
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
|
||||
return MoneyToday, true
|
||||
case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"):
|
||||
return MoneyMonth, true
|
||||
return MoneyQuery{Window: MoneyToday, Income: income}, true
|
||||
}
|
||||
return MoneyMonth, true
|
||||
return MoneyQuery{Window: MoneyMonth, Income: income}, true
|
||||
}
|
||||
|
||||
@@ -15,17 +15,25 @@ func TestParseMoneyQuery(t *testing.T) {
|
||||
{"какие у меня расходы за месяц", MoneyMonth, true},
|
||||
{"how much did I spend today", MoneyToday, true},
|
||||
{"сколько я заработал в этом месяце", MoneyMonth, true},
|
||||
// Windows nothing is stored for are claimed and refused, never answered
|
||||
// with the month-to-date figure.
|
||||
{"сколько я потратил вчера?", MoneyUnsupported, true},
|
||||
{"сколько я потратил на прошлой неделе?", MoneyUnsupported, true},
|
||||
{"how much did I spend yesterday", MoneyUnsupported, true},
|
||||
// Not about money.
|
||||
{"я потратил весь день на это", MoneyNone, false},
|
||||
// A statement, not a question: the noun and the ask must be independent
|
||||
// evidence, and "траты" used to satisfy both halves on its own.
|
||||
{"у меня в этом месяце большие траты", MoneyNone, false},
|
||||
{"потратил много сил", MoneyNone, false},
|
||||
{"какая погода?", MoneyNone, false},
|
||||
{"я купил молоко", MoneyNone, false},
|
||||
{"", MoneyNone, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
w, ok := ParseMoneyQuery(c.in)
|
||||
if ok != c.ok || w != c.window {
|
||||
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, w, ok, c.window, c.ok)
|
||||
q, ok := ParseMoneyQuery(c.in)
|
||||
if ok != c.ok || q.Window != c.window {
|
||||
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, q.Window, ok, c.window, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+84
-11
@@ -27,6 +27,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -40,6 +41,14 @@ type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTP *http.Client
|
||||
|
||||
// instruments — id → short title, fetched once from a cursor-zero diff and
|
||||
// kept for the process lifetime. Currencies do not change; the reason this
|
||||
// cache exists is that a windowed diff only returns objects changed since
|
||||
// the cursor, so a day window almost never carries the instrument rows the
|
||||
// transactions in it point at.
|
||||
mu sync.Mutex
|
||||
instruments map[int64]string
|
||||
}
|
||||
|
||||
// New returns a client with a bounded HTTP timeout. An empty token is a
|
||||
@@ -124,14 +133,77 @@ type Summary struct {
|
||||
// since then, which for a "this month" window is everything filed this month.
|
||||
// The caveat, deliberately accepted: a transaction he EDITED this month but
|
||||
// dated last month arrives too, and is then excluded by date — so editing old
|
||||
// records cannot inflate this month's total. The reverse case (a transaction
|
||||
// dated this month, filed and last changed before `from`) cannot exist.
|
||||
// records cannot inflate this month's total.
|
||||
//
|
||||
// The reverse case is real and undercounts: a transaction dated inside the
|
||||
// window but last CHANGED before `from` never arrives. A planned transaction
|
||||
// entered last month and dated this month is exactly that, and it goes missing
|
||||
// from the total. Widening the cursor would mean pulling his whole history
|
||||
// every poll, so the total is "what was filed or touched in the window", and
|
||||
// that is the honest reading of it.
|
||||
func (c *Client) Since(ctx context.Context, from, to time.Time) (Summary, error) {
|
||||
resp, err := c.diff(ctx, from.Unix())
|
||||
if err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
return summarize(resp, from, to), nil
|
||||
names := c.currencyNames(ctx, resp)
|
||||
return summarize(resp, names, from, to), nil
|
||||
}
|
||||
|
||||
// currencyNames resolves instrument ids to short titles. The window's own diff
|
||||
// first, then — only if a transaction in it points at an instrument the window
|
||||
// did not carry — one cursor-zero diff, cached for the process lifetime.
|
||||
//
|
||||
// A failed instrument fetch is not an error: the summary is still every number
|
||||
// the API returned, and an amount whose currency cannot be named is dropped
|
||||
// from the spoken string rather than read out as "1749.5 ?".
|
||||
func (c *Client) currencyNames(ctx context.Context, resp diffResponse) map[int64]string {
|
||||
names := map[int64]string{}
|
||||
for _, in := range resp.Instrument {
|
||||
names[in.ID] = in.ShortTitle
|
||||
}
|
||||
missing := false
|
||||
for _, t := range resp.Transaction {
|
||||
if t.Deleted {
|
||||
continue
|
||||
}
|
||||
for _, id := range []int64{t.OutcomeInstrmnt, t.IncomeInstrument} {
|
||||
if id != 0 && names[id] == "" {
|
||||
missing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !missing {
|
||||
return names
|
||||
}
|
||||
for id, title := range c.allInstruments(ctx) {
|
||||
if names[id] == "" {
|
||||
names[id] = title
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// allInstruments fetches every instrument once, from a cursor-zero diff, and
|
||||
// caches it. The response also carries transactions, which are decoded and
|
||||
// dropped: this is the one call in the package that reads more of his financial
|
||||
// life than the question needs, and it happens at most once per process.
|
||||
func (c *Client) allInstruments(ctx context.Context) map[int64]string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.instruments != nil {
|
||||
return c.instruments
|
||||
}
|
||||
resp, err := c.diff(ctx, 0)
|
||||
if err != nil {
|
||||
// Not cached: a network failure is not a fact about his currencies.
|
||||
return nil
|
||||
}
|
||||
c.instruments = make(map[int64]string, len(resp.Instrument))
|
||||
for _, in := range resp.Instrument {
|
||||
c.instruments[in.ID] = in.ShortTitle
|
||||
}
|
||||
return c.instruments
|
||||
}
|
||||
|
||||
func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse, error) {
|
||||
@@ -179,11 +251,7 @@ func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse,
|
||||
// transfers and currency exchanges (income and outcome both non-zero — moving
|
||||
// his own money between his own accounts is not spending), and anything dated
|
||||
// outside the window.
|
||||
func summarize(resp diffResponse, from, to time.Time) Summary {
|
||||
cur := map[int64]string{}
|
||||
for _, in := range resp.Instrument {
|
||||
cur[in.ID] = in.ShortTitle
|
||||
}
|
||||
func summarize(resp diffResponse, cur map[int64]string, from, to time.Time) Summary {
|
||||
spent := map[string]float64{}
|
||||
earned := map[string]float64{}
|
||||
count := 0
|
||||
@@ -217,14 +285,19 @@ func summarize(resp diffResponse, from, to time.Time) Summary {
|
||||
}
|
||||
}
|
||||
|
||||
// UnknownCurrency — the label for an instrument id nothing could name. It
|
||||
// survives into the Summary so a caller can see that a bucket exists; the
|
||||
// renderer drops it rather than reading "?" aloud as a currency.
|
||||
const UnknownCurrency = "?"
|
||||
|
||||
// currency names the instrument, or says it does not know. An unknown id keeps
|
||||
// the amount rather than dropping it: a sum without a currency label is still
|
||||
// his money, and silently discarding it would understate the total.
|
||||
// its bucket in the Summary rather than being folded into a named one: a sum is
|
||||
// only checkable against his bank if every amount in it is in one currency.
|
||||
func currency(names map[int64]string, id int64) string {
|
||||
if s := names[id]; s != "" {
|
||||
return s
|
||||
}
|
||||
return "?"
|
||||
return UnknownCurrency
|
||||
}
|
||||
|
||||
// sortMoney gives the amounts a stable order (largest first) so the rendered
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestSinceEmptyWindowIsNotAZero(t *testing.T) {
|
||||
if !s.Empty() {
|
||||
t.Fatalf("summary = %+v, want empty", s)
|
||||
}
|
||||
if _, ok := s.Value(); ok {
|
||||
if _, ok := s.Value(time.Now()); ok {
|
||||
t.Error("an empty summary must not produce a fact value")
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestMonthAndDayWindows(t *testing.T) {
|
||||
|
||||
func TestFactValueRoundTripAndFormat(t *testing.T) {
|
||||
s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3}
|
||||
raw, ok := s.Value()
|
||||
raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
|
||||
if !ok {
|
||||
t.Fatal("want a fact value")
|
||||
}
|
||||
@@ -176,3 +176,99 @@ func TestFormatAmountKeepsTheTruth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The day window rolls over at midnight and the first spend of the new day may
|
||||
// be hours away, so the last good money_today fact keeps a fresh ts while
|
||||
// covering yesterday. Only the window stamp inside the value can tell.
|
||||
func TestFactValueCoversDay(t *testing.T) {
|
||||
from, _ := DayWindow(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
|
||||
s := Summary{From: from, Spent: []Money{{"RUB", 1749.5}}, Count: 1}
|
||||
raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
|
||||
if !ok {
|
||||
t.Fatal("want a fact value")
|
||||
}
|
||||
v, err := ParseFactValue(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !v.CoversDay(time.Date(2026, 8, 1, 23, 59, 0, 0, time.UTC)) {
|
||||
t.Error("the same day must be covered")
|
||||
}
|
||||
if v.CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) {
|
||||
t.Error("yesterday's day total must not count as today's")
|
||||
}
|
||||
if (FactValue{}).CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) {
|
||||
t.Error("a value with no window stamp must fail closed")
|
||||
}
|
||||
if v.AsOf.IsZero() {
|
||||
t.Error("the value must carry when it was read, not only when it changed")
|
||||
}
|
||||
}
|
||||
|
||||
// The instrument rows are only in the diff when they changed since the cursor,
|
||||
// which for a day window they usually have not. An amount whose currency
|
||||
// nothing could name is dropped from the spoken string rather than read out
|
||||
// as "1749.5 ?".
|
||||
func TestUnknownCurrencyIsNotSpoken(t *testing.T) {
|
||||
v := FactValue{Spent: []Money{{UnknownCurrency, 1749.5}}, Count: 1}
|
||||
if got := v.FormatRU("сегодня"); got != "" {
|
||||
t.Errorf("reply = %q, want nothing said about an unlabelled amount", got)
|
||||
}
|
||||
v = FactValue{Spent: []Money{{"RUB", 100}, {UnknownCurrency, 1749.5}}, Count: 2}
|
||||
got := v.FormatRU("сегодня")
|
||||
if strings.Contains(got, UnknownCurrency) {
|
||||
t.Errorf("reply = %q, want no %q currency", got, UnknownCurrency)
|
||||
}
|
||||
if !strings.Contains(got, "100 RUB") {
|
||||
t.Errorf("reply = %q, want the amount that does have a currency", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A day diff cursored at midnight usually carries no instrument rows at all.
|
||||
// The client fetches them once from a cursor-zero diff instead of labelling
|
||||
// every amount "?".
|
||||
func TestSinceResolvesCurrencyFromASeparateDiff(t *testing.T) {
|
||||
body, err := os.ReadFile("testdata/diff.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var full diffResponse
|
||||
if err := json.Unmarshal(body, &full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
windowed := diffResponse{ServerTimestamp: full.ServerTimestamp, Transaction: full.Transaction}
|
||||
instrumentsOnly := diffResponse{ServerTimestamp: full.ServerTimestamp, Instrument: full.Instrument}
|
||||
zeroCursorCalls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req diffRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
out := windowed
|
||||
if req.ServerTimestamp == 0 {
|
||||
zeroCursorCalls++
|
||||
out = instrumentsOnly
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New("tok", srv.URL, time.Second)
|
||||
s, err := c.Since(context.Background(), aug(1), aug(6))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range s.Spent {
|
||||
if m.Currency == UnknownCurrency {
|
||||
t.Fatalf("spent = %+v, want every amount named", s.Spent)
|
||||
}
|
||||
}
|
||||
// Cached for the process lifetime: a second window does not refetch.
|
||||
if _, err := c.Since(context.Background(), aug(1), aug(6)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zeroCursorCalls != 1 {
|
||||
t.Errorf("cursor-zero diffs = %d, want exactly 1", zeroCursorCalls)
|
||||
}
|
||||
}
|
||||
|
||||
+80
-14
@@ -20,29 +20,61 @@ const (
|
||||
// asks, never a reason to speak. Maven is not a nag, least of all about money.
|
||||
const Source = "poll:zenmoney"
|
||||
|
||||
// FactValue — the JSON stored in a money fact. A wire shape of its own rather
|
||||
// than the Summary struct so From/To (which carry a timezone and a clock) stay
|
||||
// out of the store; the key already says which window it is.
|
||||
// FactValue — the JSON stored in a money fact.
|
||||
//
|
||||
// From and AsOf are both here because the fact's own Ts can express neither.
|
||||
//
|
||||
// - From is the first instant of the window the figure covers. The day fact
|
||||
// is only true for the day it was read on, and after midnight the poller
|
||||
// has nothing new to write until the first spend of the new day, so the
|
||||
// previous day's total sits there as the latest money_today looking
|
||||
// perfectly fresh. Without From, "сколько я потратил сегодня?" at 09:00
|
||||
// answered with yesterday's spending.
|
||||
// - AsOf is when the figure was last READ, not when it last changed. The
|
||||
// poller used to skip a write when the value was byte-identical, so a quiet
|
||||
// stretch left Ts pointing at the last time the number moved and the answer
|
||||
// came back prefixed "данные от 30.07" while being current and correct.
|
||||
type FactValue struct {
|
||||
Spent []Money `json:"spent"`
|
||||
Earned []Money `json:"earned"`
|
||||
Count int `json:"count"`
|
||||
Spent []Money `json:"spent"`
|
||||
Earned []Money `json:"earned"`
|
||||
Count int `json:"count"`
|
||||
From time.Time `json:"from,omitempty"`
|
||||
AsOf time.Time `json:"as_of,omitempty"`
|
||||
}
|
||||
|
||||
// Value encodes the summary for the facts table. Returns ok=false for an empty
|
||||
// summary: no transactions read means no fact written, so that a failed or
|
||||
// empty poll can never be recited back to him as a zero.
|
||||
func (s Summary) Value() (string, bool) {
|
||||
// Value encodes the summary for the facts table, stamped with the instant it
|
||||
// was read. Returns ok=false for an empty summary: no transactions read means
|
||||
// no fact written, so that a failed or empty poll can never be recited back to
|
||||
// him as a zero.
|
||||
func (s Summary) Value(asOf time.Time) (string, bool) {
|
||||
if s.Empty() {
|
||||
return "", false
|
||||
}
|
||||
b, err := json.Marshal(FactValue{Spent: s.Spent, Earned: s.Earned, Count: s.Count})
|
||||
b, err := json.Marshal(FactValue{
|
||||
Spent: s.Spent, Earned: s.Earned, Count: s.Count,
|
||||
From: s.From, AsOf: asOf,
|
||||
})
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
// CoversDay reports whether this value's window starts at now's midnight, in
|
||||
// now's location. A day total whose window has rolled over is not a stale
|
||||
// figure to be prefixed with a date, it is an answer to a different question,
|
||||
// and it must not be spoken as today's.
|
||||
//
|
||||
// A value written before From existed has a zero From and fails the check,
|
||||
// which is the safe direction: the next poll rewrites it.
|
||||
func (v FactValue) CoversDay(now time.Time) bool {
|
||||
if v.From.IsZero() {
|
||||
return false
|
||||
}
|
||||
f := v.From.In(now.Location())
|
||||
return f.Year() == now.Year() && f.Month() == now.Month() && f.Day() == now.Day()
|
||||
}
|
||||
|
||||
// ParseFactValue decodes a stored money fact.
|
||||
func ParseFactValue(raw string) (FactValue, error) {
|
||||
var v FactValue
|
||||
@@ -59,15 +91,41 @@ func ParseFactValue(raw string) (FactValue, error) {
|
||||
// No commentary. She reports the figure and stops: an opinion about his
|
||||
// spending is exactly the nagging Maven is not for.
|
||||
func (v FactValue) FormatRU(window string) string {
|
||||
return v.formatRU(window, false)
|
||||
}
|
||||
|
||||
// FormatIncomeRU is FormatRU with the income read first, for a question that
|
||||
// asked about income ("сколько я заработал в этом месяце?"). Same figures, same
|
||||
// refusal to comment; only the order of the two halves differs, so the number
|
||||
// he asked for is the number she says first.
|
||||
func (v FactValue) FormatIncomeRU(window string) string {
|
||||
return v.formatRU(window, true)
|
||||
}
|
||||
|
||||
func (v FactValue) formatRU(window string, incomeFirst bool) string {
|
||||
if v.Count == 0 {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
spent, earned := "", ""
|
||||
if len(v.Spent) > 0 {
|
||||
parts = append(parts, "потратил "+joinMoney(v.Spent))
|
||||
if s := joinMoney(v.Spent); s != "" {
|
||||
spent = "потратил " + s
|
||||
}
|
||||
}
|
||||
if len(v.Earned) > 0 {
|
||||
parts = append(parts, "получил "+joinMoney(v.Earned))
|
||||
if s := joinMoney(v.Earned); s != "" {
|
||||
earned = "получил " + s
|
||||
}
|
||||
}
|
||||
order := []string{spent, earned}
|
||||
if incomeFirst {
|
||||
order = []string{earned, spent}
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range order {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
@@ -75,9 +133,17 @@ func (v FactValue) FormatRU(window string) string {
|
||||
return window + " ты " + strings.Join(parts, ", ") + "."
|
||||
}
|
||||
|
||||
// joinMoney renders the amounts, DROPPING any whose currency could not be
|
||||
// named. "сегодня ты потратил 1749.5 ?." is not something to read aloud, and a
|
||||
// figure with no currency on it is not a figure he can check against his bank.
|
||||
// An amount silently missing is the lesser wrong: the alternative is speaking a
|
||||
// number whose units Maven does not know.
|
||||
func joinMoney(ms []Money) string {
|
||||
parts := make([]string, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
if m.Currency == UnknownCurrency || m.Currency == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s %s", formatAmount(m.Amount), m.Currency))
|
||||
}
|
||||
return strings.Join(parts, " и ")
|
||||
|
||||
Reference in New Issue
Block a user