Files
money/internal/rules/rules_test.go
T
nikolaandClaude Opus 5 442684be60 Try the most specific rule first, not the topmost
File order decided precedence, so a narrow rule had to be written above the
broad one it carves an exception out of -- an ordering constraint the file
cannot show and the user has to remember. *NIKOLA* below *NIK* silently matched
nothing, and a catch-all * could only ever be the last line.

Engine.New now sorts once and MatchIndex walks that order: most literal
characters first, then fewest *, then account-scoped over unscoped. Literals
are what a rule commits to and a * is what it gives up, so a bare * is tried
last wherever it sits. The sort is stable, so equally specific rules keep file
order and the earlier one wins -- which is all position decides now, and why
AppendRule can keep appending without displacing a rule written by hand.

The two orders must not be confused: Rules(), Usage and MatchIndex still speak
in file positions, because that is what the rules screen numbers and what
DeleteRules deletes by. A shadowed rule still reports zero usage, but a zero no
longer says anything about where the rule sits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:30:04 +02:00

257 lines
8.2 KiB
Go

package rules
import (
"path/filepath"
"testing"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/model"
"git.petrovv.com/nikola/money/internal/store"
)
func testDB(t *testing.T) *store.DB {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
// seed inserts transactions with no rule verdict yet.
func seed(t *testing.T, db *store.DB, descriptions ...string) int64 {
t.Helper()
accountID, err := db.UpsertAccount(model.Account{
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
})
if err != nil {
t.Fatal(err)
}
sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
for i, d := range descriptions {
added, err := db.InsertTransaction(model.Transaction{
AccountID: accountID,
SourceFileID: sourceID,
Fingerprint: d,
Date: "2026-01-0" + string(rune('1'+i)),
Description: d,
AmountMinor: -1000,
})
if err != nil {
t.Fatal(err)
}
if !added {
t.Fatalf("seed %q was not inserted", d)
}
}
return accountID
}
func byDescription(t *testing.T, db *store.DB, desc string) model.Transaction {
t.Helper()
txns, err := db.Transactions(store.Filter{})
if err != nil {
t.Fatal(err)
}
for _, x := range txns {
if x.Description == desc {
return x
}
}
t.Fatalf("no transaction with description %q", desc)
return model.Transaction{}
}
// A tag is derived state: rules.toml is the only thing that decides it, so
// retagging rewrites every tag from the current rules however often it runs,
// including back to nothing when no rule matches any more.
func TestRetagRewritesEveryTag(t *testing.T) {
db := testDB(t)
seed(t, db, "LIDL SOFIA", "SOME RANDOM SHOP")
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*LIDL*", Tag: "groceries"},
{Match: "*SHOP*", Tag: "shopping"},
}})
n, err := engine.Retag(db)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("retagged %d rows, want 2", n)
}
if got := byDescription(t, db, "LIDL SOFIA").RuleTag; got != "groceries" {
t.Errorf("tag = %q, want groceries", got)
}
// Re-running with the same rules changes nothing and says so.
if n, err := engine.Retag(db); err != nil || n != 0 {
t.Errorf("second retag: %d rows, %v; want 0 and no error", n, err)
}
// New rules win, and a row no rule matches loses its tag entirely.
engine = New(&config.Rules{Rule: []config.Rule{
{Match: "*LIDL*", Tag: "food"},
}})
if _, err := engine.Retag(db); err != nil {
t.Fatal(err)
}
if got := byDescription(t, db, "LIDL SOFIA").RuleTag; got != "food" {
t.Errorf("tag = %q, want food", got)
}
if got := byDescription(t, db, "SOME RANDOM SHOP").RuleTag; got != "" {
t.Errorf("tag = %q, want empty once no rule matches", got)
}
}
// The narrower rule claims what it names wherever it sits in the file: the
// catch-all above it must not swallow the exception written below.
func TestMoreSpecificRuleWins(t *testing.T) {
both := [][]config.Rule{
{{Match: "NIK*", Tag: "misc"}, {Match: "NIKOLA*", Tag: "family"}},
{{Match: "NIKOLA*", Tag: "family"}, {Match: "NIK*", Tag: "misc"}},
}
for _, rs := range both {
engine := New(&config.Rules{Rule: rs})
if tag := engine.Apply("checking", "NIKOLA PETROV"); tag != "family" {
t.Errorf("%q first: tag = %q, want family", rs[0].Match, tag)
}
if tag := engine.Apply("checking", "NIKI TODOROV"); tag != "misc" {
t.Errorf("%q first: tag = %q, want misc", rs[0].Match, tag)
}
}
}
// Specificity is measured on what the pattern spells out, so the wildcards
// around a literal do not buy it precedence, and a bare "*" is always last.
func TestSpecificityOrder(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*", Tag: "other"},
{Match: "*LIDL*", Tag: "groceries"},
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
}})
for _, c := range []struct{ desc, want string }{
{"CARD LIDL EXPRESS 12", "snacks"},
{"CARD LIDL 12", "groceries"},
{"SOMETHING ELSE", "other"},
} {
if tag := engine.Apply("checking", c.desc); tag != c.want {
t.Errorf("%q: tag = %q, want %q", c.desc, tag, c.want)
}
}
}
// Two rules that are equally specific are still decided by the file: the
// earlier one wins, which is what makes appending a rule safe.
func TestEqualSpecificityKeepsFileOrder(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*ACME*", Tag: "first"},
{Match: "*ACME*", Tag: "second"},
}})
if tag := engine.Apply("checking", "ACME LTD"); tag != "first" {
t.Errorf("tag = %q, want first", tag)
}
if tag := engine.Apply("checking", "SOMETHING ELSE"); tag != "" {
t.Errorf("tag = %q, want empty for an unmatched description", tag)
}
}
// A rule can be dead two ways: nothing matches it, or a rule that beats it
// already claimed everything it would have caught. Usage must report both as
// zero, indexed by file position however the rules are ordered for matching.
func TestUsageCountsWinnersOnly(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*LIDL SOFIA*", Tag: "groceries"}, // the narrower of the two
{Match: "*LIDL*", Tag: "shadowed"}, // every LIDL row here is Sofia
{Match: "*NOTHING MATCHES ME*", Tag: "no"}, // matches nothing at all
{Match: "*PAYROLL*", Tag: "salary"}, // claims one row
}})
txns := []model.Transaction{
{AccountSlug: "checking", Description: "LIDL SOFIA 4412"},
{AccountSlug: "checking", Description: "LIDL SOFIA 9911"},
{AccountSlug: "checking", Description: "ACME PAYROLL"},
{AccountSlug: "checking", Description: "UNMATCHED SHOP"},
}
usage := engine.Usage(txns)
want := []int{2, 0, 0, 1}
if len(usage) != len(want) {
t.Fatalf("usage has %d entries, want %d", len(usage), len(want))
}
for i := range want {
if usage[i] != want[i] {
t.Errorf("rule %d used by %d transactions, want %d", i+1, usage[i], want[i])
}
}
}
// The index is the rule's position in rules.toml, not its position in the order
// it was tried in — that is the number the rules screen shows and deletes by.
func TestMatchIndex(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*LIDL*", Tag: "groceries"},
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
}})
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL EXPRESS 1"}); got != 1 {
t.Errorf("index = %d, want 1", got)
}
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL 1"}); got != 0 {
t.Errorf("index = %d, want 0", got)
}
if got := engine.MatchIndex("checking", model.Transaction{Description: "OTHER"}); got != -1 {
t.Errorf("index = %d, want -1 for no match", got)
}
}
// Naming an account is itself a narrowing, so the scoped rule beats the
// identical unscoped one even when the unscoped one is written first.
func TestAccountScopedRule(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*TRANSFER*", Tag: "misc"},
{Match: "*TRANSFER*", Tag: "transfer", Account: "savings"},
}})
if tag := engine.Apply("savings", "TRANSFER FROM CHECKING"); tag != "transfer" {
t.Errorf("savings: tag = %q, want transfer", tag)
}
if tag := engine.Apply("checking", "TRANSFER FROM CHECKING"); tag != "misc" {
t.Errorf("checking: tag = %q, want misc", tag)
}
}
// Besides the description, a rule can match the bank's own classification of
// the transaction, on its own or alongside a description glob.
func TestTypeRules(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Type: "CARD_PAYMENT", Match: "*LIDL*", Tag: "groceries"},
{Type: "ATM", Tag: "cash"},
}})
// A rule setting several patterns requires all of them to match.
tag := engine.ApplyTxn("checking", model.Transaction{
Description: "LIDL SOFIA", Type: "CARD_PAYMENT",
})
if tag != "groceries" {
t.Errorf("tag = %q, want groceries", tag)
}
tag = engine.ApplyTxn("checking", model.Transaction{
Description: "LIDL SOFIA", Type: "TRANSFER",
})
if tag != "" {
t.Errorf("tag = %q, want empty: the type does not match", tag)
}
// Type alone works too.
if tag := engine.ApplyTxn("checking", model.Transaction{
Description: "WITHDRAWAL", Type: "ATM",
}); tag != "cash" {
t.Errorf("tag = %q, want cash", tag)
}
}