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>
257 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|