Files
money/internal/tui/tui_test.go
T
nikolaandClaude Opus 5 c2eba53fd4 Add a rules screen that finds and removes dead rules
Nothing showed whether a rule was still earning its place. The new screen, on
5, lists every rule in file order with the number of transactions it claims,
marking those that claim none.

The count comes from Engine.Usage, which counts by first match, so a rule
shadowed by an earlier one reports zero even though its glob matches. That is
the case worth catching: such a rule looks correct in isolation and can never
fire.

d removes the selected rule and p removes every unused one, each behind a y/n
confirmation since this rewrites a hand-maintained file. config.DeleteRules
edits rules.toml textually rather than re-serialising the parsed rules, so
comments and layout survive; a comment directly above a rule goes with it,
while one separated by a blank line is left as a heading. The result is
re-parsed before it replaces the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 02:02:01 +02:00

960 lines
26 KiB
Go

package tui
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/importer"
"git.petrovv.com/nikola/money/internal/model"
"git.petrovv.com/nikola/money/internal/rules"
"git.petrovv.com/nikola/money/internal/store"
)
// newTestModel builds a model over an index holding two transactions, one of
// which the rules tag as groceries.
func newTestModel(t *testing.T) (*Model, *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() })
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 []string{"LIDL SOFIA", "SOME RANDOM SHOP"} {
if _, err := db.InsertTransaction(model.Transaction{
AccountID: accountID,
SourceFileID: sourceID,
Fingerprint: d,
Date: "2026-01-0" + string(rune('1'+i)),
Description: d,
AmountMinor: -1000,
}); err != nil {
t.Fatal(err)
}
}
engine := rules.New(&config.Rules{Rule: []config.Rule{{Match: "*LIDL*", Tag: "groceries"}}})
if _, err := engine.Retag(db); err != nil {
t.Fatal(err)
}
m := New(t.TempDir(), db, nil, engine)
if err := m.reload(); err != nil {
t.Fatal(err)
}
m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
return m, db
}
// key sends a single keypress to the model.
func key(t *testing.T, m *Model, s string) {
t.Helper()
var msg tea.KeyMsg
switch s {
case "enter":
msg = tea.KeyMsg{Type: tea.KeyEnter}
case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc}
default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
m.Update(msg)
if m.err != nil {
t.Fatalf("key %q produced an error: %v", s, m.err)
}
}
// typeText sends each character as its own keypress, as a terminal would.
func typeText(t *testing.T, m *Model, s string) {
t.Helper()
for _, r := range s {
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
}
// cursorTo moves the transaction cursor onto the row with the given description.
func cursorTo(t *testing.T, m *Model, desc string) {
t.Helper()
for i, txn := range m.txns {
if txn.Description == desc {
m.txnTable.SetCursor(i)
return
}
}
t.Fatalf("no visible transaction with description %q", desc)
}
func TestTagFlow(t *testing.T) {
m, db := newTestModel(t)
key(t, m, "2") // transactions view
cursorTo(t, m, "SOME RANDOM SHOP")
key(t, m, "t") // open the tag prompt
if m.input != inputTag {
t.Fatal("expected the tag prompt to open")
}
typeText(t, m, "hobby")
key(t, m, "enter")
if m.input != inputNone {
t.Error("expected the prompt to close after enter")
}
txns, err := db.Transactions(store.Filter{})
if err != nil {
t.Fatal(err)
}
var found bool
for _, txn := range txns {
if txn.Description == "SOME RANDOM SHOP" {
found = true
if txn.ManualTag != "hobby" {
t.Errorf("manual_tag = %q, want %q", txn.ManualTag, "hobby")
}
}
}
if !found {
t.Fatal("transaction disappeared after tagging")
}
// The tag column marks manual overrides with a trailing asterisk.
if !strings.Contains(m.View(), "hobby*") {
t.Error("expected the tagged row to render with a manual-override marker")
}
}
func TestTagPromptCancel(t *testing.T) {
m, db := newTestModel(t)
key(t, m, "2")
cursorTo(t, m, "SOME RANDOM SHOP")
key(t, m, "t")
typeText(t, m, "nope")
key(t, m, "esc")
if m.input != inputNone {
t.Error("expected esc to close the prompt")
}
txns, _ := db.Transactions(store.Filter{Untagged: true})
if len(txns) != 1 {
t.Errorf("esc should not have tagged anything; untagged count = %d, want 1", len(txns))
}
}
func TestTransferToggleAndClear(t *testing.T) {
m, db := newTestModel(t)
key(t, m, "2")
cursorTo(t, m, "LIDL SOFIA")
key(t, m, "x")
txn := findTxn(t, db, "LIDL SOFIA")
if !txn.IsTransfer() {
t.Error("expected x to mark the row as a transfer")
}
key(t, m, "x")
if findTxn(t, db, "LIDL SOFIA").IsTransfer() {
t.Error("expected a second x to unmark it")
}
// c drops the manual override, restoring the rule's verdict.
key(t, m, "c")
txn = findTxn(t, db, "LIDL SOFIA")
if txn.ManualTransfer != nil || txn.ManualTag != "" {
t.Errorf("expected c to clear overrides, got manual tag %q transfer %v", txn.ManualTag, txn.ManualTransfer)
}
if txn.Tag() != "groceries" {
t.Errorf("tag after clearing = %q, want groceries from the rule", txn.Tag())
}
}
func TestUntaggedToggle(t *testing.T) {
m, _ := newTestModel(t)
key(t, m, "u")
if !m.onlyUntagged {
t.Fatal("expected u to enable the untagged filter")
}
if len(m.txns) != 1 || m.txns[0].Description != "SOME RANDOM SHOP" {
t.Fatalf("untagged view shows %+v, want only SOME RANDOM SHOP", m.txns)
}
// Tagging the last untagged row empties the view; the cursor must stay valid.
cursorTo(t, m, "SOME RANDOM SHOP")
key(t, m, "t")
typeText(t, m, "hobby")
key(t, m, "enter")
if len(m.txns) != 0 {
t.Errorf("expected the untagged view to be empty, got %d rows", len(m.txns))
}
if _, ok := m.selected(); ok {
t.Error("expected no selection in an empty view")
}
m.View() // must not panic on an empty table
key(t, m, "u")
if len(m.txns) != 2 {
t.Errorf("expected all rows back after toggling off, got %d", len(m.txns))
}
}
func TestSearchFilter(t *testing.T) {
m, _ := newTestModel(t)
key(t, m, "/")
if m.input != inputSearch {
t.Fatal("expected the search prompt to open")
}
typeText(t, m, "lidl")
key(t, m, "enter")
if len(m.txns) != 1 || m.txns[0].Description != "LIDL SOFIA" {
t.Fatalf("search results = %+v, want only LIDL SOFIA", m.txns)
}
// Reopening the prompt pre-fills the active query so it can be edited.
key(t, m, "/")
if got := m.text.Value(); got != "lidl" {
t.Errorf("search prompt pre-filled with %q, want %q", got, "lidl")
}
for range len("lidl") {
m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
}
key(t, m, "enter") // an emptied query clears the filter
if len(m.txns) != 2 {
t.Errorf("expected the filter to clear, got %d rows", len(m.txns))
}
}
func TestReportViewExcludesTransfers(t *testing.T) {
m, _ := newTestModel(t)
key(t, m, "2")
cursorTo(t, m, "LIDL SOFIA")
key(t, m, "x") // mark as a transfer
key(t, m, "3") // report view
view := m.View()
if strings.Contains(view, "groceries") {
t.Error("a transfer must not appear in the report")
}
if !strings.Contains(view, "TOTAL") {
t.Error("expected a TOTAL row in the report")
}
}
// Import must not block the event loop: pressing i starts a background command
// and puts the view into a spinning state that keeps redrawing.
func TestImportShowsSpinnerAndDoesNotBlock(t *testing.T) {
m, _ := newTestModel(t)
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
if !m.importing {
t.Fatal("expected the model to be in the importing state")
}
if cmd == nil {
t.Fatal("expected a command to run the import in the background")
}
if view := m.View(); !strings.Contains(view, "importing") {
t.Errorf("expected the status line to say it is importing:\n%s", view)
}
// A tick advances the spinner and schedules the next frame.
_, tickCmd := m.Update(spinner.TickMsg{Time: time.Now()})
if tickCmd == nil {
t.Error("expected the spinner to schedule another tick while importing")
}
// Pressing i again must not start a second concurrent import.
if _, again := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}); again != nil {
t.Error("expected a repeat i press to be ignored while importing")
}
// Nor may retag run against the database mid-import.
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
if !strings.Contains(m.status, "import in progress") {
t.Errorf("status = %q, want retag to be refused during an import", m.status)
}
// The result arriving clears the spinner and reports what happened.
m.Update(importDoneMsg{res: importer.Result{
Files: []importer.FileResult{{Path: "checking/st.csv", New: 3, Skipped: 1}},
}})
if m.importing {
t.Error("expected the importing state to clear")
}
if !strings.Contains(m.status, "3 new") || !strings.Contains(m.status, "1 duplicate") {
t.Errorf("status = %q, want the import counts", m.status)
}
// A stale tick after the import finished must not restart the spinner.
if _, cmd := m.Update(spinner.TickMsg{Time: time.Now()}); cmd != nil {
t.Error("expected a tick after the import to be ignored")
}
}
// The spinner must keep ticking for as long as the import runs: one frame and
// then silence looks exactly like the freeze it is meant to rule out.
func TestSpinnerKeepsTicking(t *testing.T) {
m, _ := newTestModel(t)
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
// Drive the loop the way Bubble Tea does: run the command, feed the
// message it produces back into Update, and repeat.
var frames []string
cmd := tea.Cmd(m.spinner.Tick)
for i := 0; i < 4; i++ {
if cmd == nil {
t.Fatalf("tick %d: no command scheduled, the spinner stopped", i)
}
msg := cmd()
if msg == nil {
t.Fatalf("tick %d: command produced no message", i)
}
_, cmd = m.Update(msg)
frames = append(frames, m.spinner.View())
}
if len(unique(frames)) < 2 {
t.Errorf("spinner rendered %v; want the frame to advance between ticks", frames)
}
}
func unique(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
func TestImportFailureIsReported(t *testing.T) {
m, _ := newTestModel(t)
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
m.Update(importDoneMsg{err: errors.New("index is locked")})
if m.importing {
t.Error("expected the importing state to clear on failure")
}
if m.err == nil || !strings.Contains(m.err.Error(), "index is locked") {
t.Errorf("err = %v, want the failure surfaced", m.err)
}
}
// A per-file warning is worth seeing without leaving the TUI.
func TestImportWarningsSurface(t *testing.T) {
m, _ := newTestModel(t)
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
m.Update(importDoneMsg{res: importer.Result{Files: []importer.FileResult{{
Path: "revolut/statement.csv",
New: 2,
Warnings: []string{"skipped 1 PENDING transactions", "skipped 3 rows in JPY"},
}}}})
if !strings.Contains(m.status, "skipped 1 PENDING") {
t.Errorf("status = %q, want the first warning", m.status)
}
if !strings.Contains(m.status, "+1 more") {
t.Errorf("status = %q, want a count of the remaining warnings", m.status)
}
}
// newRuleModel builds a model whose data root is a real directory, so the rule
// builder can actually write rules.toml.
func newRuleModel(t *testing.T) (*Model, *store.DB, string) {
t.Helper()
root := t.TempDir()
db, err := store.Open(filepath.Join(root, ".money", "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
accountID, err := db.UpsertAccount(model.Account{
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
})
if err != nil {
t.Fatal(err)
}
savingsID, err := db.UpsertAccount(model.Account{
Slug: "savings", Name: "Savings", 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)
}
// Deliberately unsorted, with LIDL appearing twice so the preview has
// something to group.
seed := []struct {
account int64
desc string
}{
{accountID, "ZZZ LAST ALPHABETICALLY"},
{accountID, "LIDL SOFIA 4412"},
{accountID, "LIDL SOFIA 4412"},
{accountID, "AAA FIRST ALPHABETICALLY"},
{savingsID, "LIDL VARNA 9911"},
}
for i, s := range seed {
if _, err := db.InsertTransaction(model.Transaction{
AccountID: s.account,
SourceFileID: sourceID,
Fingerprint: fmt.Sprintf("fp-%d", i),
Date: "2026-01-01",
Description: s.desc,
AmountMinor: -1000,
}); err != nil {
t.Fatal(err)
}
}
m := New(root, db, []*config.Account{
{Slug: "checking", Currency: "EUR", Parser: "csv"},
{Slug: "savings", Currency: "EUR", Parser: "csv"},
}, rules.New(&config.Rules{}))
if err := m.reload(); err != nil {
t.Fatal(err)
}
m.Update(tea.WindowSizeMsg{Width: 140, Height: 30})
return m, db, root
}
// descriptions returns the preview list as it is rendered, in order.
func previewRows(m *Model) (all []string, matched []string) {
for _, row := range m.ruleTable.Rows() {
all = append(all, row[1])
if row[0] == "▸" {
matched = append(matched, row[1])
}
}
return all, matched
}
func TestRuleBuilderListsUntaggedAlphabetically(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
if m.view != viewRules {
t.Fatal("expected 4 to open the rule builder")
}
all, _ := previewRows(m)
want := []string{
"AAA FIRST ALPHABETICALLY",
"LIDL SOFIA 4412",
"LIDL VARNA 9911",
"ZZZ LAST ALPHABETICALLY",
}
if len(all) != len(want) {
t.Fatalf("preview = %v, want %v (identical descriptions must be grouped)", all, want)
}
for i := range want {
if all[i] != want[i] {
t.Errorf("row %d = %q, want %q", i, all[i], want[i])
}
}
// The repeated LIDL row carries its occurrence count.
for _, row := range m.ruleTable.Rows() {
if row[1] == "LIDL SOFIA 4412" && row[2] != "2" {
t.Errorf("count for the repeated description = %q, want 2", row[2])
}
}
}
func TestRuleBuilderPreviewsGlobAsYouType(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
if _, matched := previewRows(m); len(matched) != 0 {
t.Errorf("nothing should match before a glob is typed, got %v", matched)
}
typeText(t, m, "*LIDL*")
_, matched := previewRows(m)
if len(matched) != 2 || m.ruleMatches != 2 {
t.Fatalf("matched = %v (count %d), want both LIDL rows", matched, m.ruleMatches)
}
// Narrowing the glob narrows the preview.
typeText(t, m, "\b") // not a backspace key; replace the value instead
m.ruleGlob.SetValue("*SOFIA*")
m.refreshRulePreview()
if _, matched := previewRows(m); len(matched) != 1 || matched[0] != "LIDL SOFIA 4412" {
t.Errorf("matched = %v, want only the Sofia row", matched)
}
}
// The account field scopes the preview the same way it will scope the rule.
func TestRuleBuilderAccountFiltersPreview(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
m.ruleGlob.SetValue("*LIDL*")
m.ruleAccount.SetValue("savings")
m.refreshRulePreview()
all, matched := previewRows(m)
if len(all) != 1 || all[0] != "LIDL VARNA 9911" {
t.Errorf("preview = %v, want only the savings row", all)
}
if len(matched) != 1 {
t.Errorf("matched = %v, want the savings row to match", matched)
}
}
// Typing must never reach the global keymap: q, i and 1 are ordinary text here.
func TestRuleBuilderSwallowsGlobalKeys(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
typeText(t, m, "q1i")
if m.view != viewRules {
t.Fatal("typing must not switch views")
}
if got := m.ruleGlob.Value(); got != "q1i" {
t.Errorf("glob = %q, want the typed characters", got)
}
if m.importing {
t.Error("typing i must not start an import")
}
}
func TestRuleBuilderSavesRuleAndRetags(t *testing.T) {
m, db, root := newRuleModel(t)
key(t, m, "4")
m.ruleGlob.SetValue("*LIDL*")
m.setRuleFocus(2)
m.ruleTag.SetValue("groceries")
key(t, m, "enter")
if m.err != nil {
t.Fatalf("saving failed: %v", m.err)
}
// It landed in rules.toml...
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[0].Tag != "groceries" {
t.Fatalf("rules.toml holds %+v", loaded.Rule)
}
// ...and was applied straight away.
txns, err := db.Transactions(store.Filter{})
if err != nil {
t.Fatal(err)
}
tagged := 0
for _, txn := range txns {
if txn.Tag() == "groceries" {
tagged++
}
}
if tagged != 3 {
t.Errorf("%d transactions tagged, want 3", tagged)
}
if !strings.Contains(m.status, "3 transactions retagged") {
t.Errorf("status = %q, want the retag count", m.status)
}
// Those rows are no longer untagged, so the preview drops them.
all, _ := previewRows(m)
for _, d := range all {
if strings.Contains(d, "LIDL") {
t.Errorf("preview still lists %q after it was tagged", d)
}
}
// The glob and tag are cleared, ready for the next rule.
if m.ruleGlob.Value() != "" || m.ruleTag.Value() != "" {
t.Errorf("form not reset: glob=%q tag=%q", m.ruleGlob.Value(), m.ruleTag.Value())
}
}
func TestRuleBuilderRejectsIncompleteRule(t *testing.T) {
m, _, root := newRuleModel(t)
key(t, m, "4")
// No glob, no tag.
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if m.err == nil {
t.Error("expected an error when saving an empty form")
}
// Glob but no tag.
m.err = nil
m.ruleGlob.SetValue("*LIDL*")
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if m.err == nil || !strings.Contains(m.err.Error(), "tag") {
t.Errorf("err = %v, want a complaint about the missing tag", m.err)
}
// An account that does not exist is caught before anything is written.
m.err = nil
m.ruleTag.SetValue("groceries")
m.ruleAccount.SetValue("nosuchaccount")
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if m.err == nil || !strings.Contains(m.err.Error(), "nosuchaccount") {
t.Errorf("err = %v, want a complaint about the unknown account", m.err)
}
if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) {
t.Error("a rejected rule must not write rules.toml")
}
}
func TestRuleBuilderTabCyclesFieldsAndEscLeaves(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "2") // come from the transactions view
key(t, m, "4")
for i, want := range []int{1, 2, 0} {
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleFocus != want {
t.Errorf("tab %d moved focus to %d, want %d", i+1, m.ruleFocus, want)
}
}
if !m.ruleGlob.Focused() {
t.Error("expected the glob input to be focused after wrapping around")
}
m.Update(tea.KeyMsg{Type: tea.KeyEsc})
if m.view != viewTxns {
t.Errorf("esc returned to view %d, want the transactions view it came from", m.view)
}
}
// newRuleListModel seeds a data root whose rules.toml holds one useful rule,
// one shadowed rule and one that matches nothing.
func newRuleListModel(t *testing.T) (*Model, string) {
t.Helper()
m, _, root := newRuleModel(t)
rulesTOML := `# My rules.
# The weekly shop.
[[rule]]
match = "*LIDL*"
tag = "groceries"
[[rule]]
match = "*LIDL SOFIA*"
tag = "shadowed"
[[rule]]
match = "*NEVER MATCHES ANYTHING*"
tag = "dead"
[[rule]]
match = "*ZARA*"
tag = "clothes"
`
if err := os.WriteFile(filepath.Join(root, config.RulesFile), []byte(rulesTOML), 0o644); err != nil {
t.Fatal(err)
}
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
m.engine = rules.New(loaded)
if _, err := m.engine.Retag(m.db); err != nil {
t.Fatal(err)
}
key(t, m, "5")
return m, root
}
func TestRuleListCountsUsage(t *testing.T) {
m, _ := newRuleListModel(t)
if m.view != viewRuleList {
t.Fatal("expected 5 to open the rule list")
}
// LIDL SOFIA x2 plus LIDL VARNA; the shadowed and dead rules claim nothing.
want := []int{3, 0, 0, 0}
if len(m.ruleUsage) != len(want) {
t.Fatalf("usage = %v, want %d entries", m.ruleUsage, len(want))
}
for i := range want {
if m.ruleUsage[i] != want[i] {
t.Errorf("rule %d used by %d, want %d", i+1, m.ruleUsage[i], want[i])
}
}
// Unused rules are marked so they can be picked out at a glance.
rows := m.ruleListTable.Rows()
if rows[0][1] != " " {
t.Errorf("the used rule is marked %q, want no marker", rows[0][1])
}
for _, i := range []int{1, 2, 3} {
if rows[i][1] != "✗" {
t.Errorf("rule %d marker = %q, want ✗", i+1, rows[i][1])
}
}
if !strings.Contains(m.View(), "3 match nothing") {
t.Errorf("expected the title to count the dead rules:\n%s", m.View())
}
}
// Deleting rewrites a hand-maintained file, so it takes a confirmation.
func TestRuleListDeleteNeedsConfirmation(t *testing.T) {
m, root := newRuleListModel(t)
m.ruleListTable.SetCursor(2) // the dead rule
key(t, m, "d")
if m.confirm != confirmDeleteRule {
t.Fatal("expected d to ask for confirmation")
}
if !strings.Contains(m.status, "delete rule 3") {
t.Errorf("status = %q, want the rule identified", m.status)
}
key(t, m, "n") // anything but y cancels
if m.confirm != confirmNone {
t.Error("expected the confirmation to clear")
}
loaded, _ := config.LoadRules(root)
if len(loaded.Rule) != 4 {
t.Errorf("cancelling deleted something: %d rules left", len(loaded.Rule))
}
}
func TestRuleListDeleteSelected(t *testing.T) {
m, root := newRuleListModel(t)
m.ruleListTable.SetCursor(1) // the shadowed rule
key(t, m, "d")
key(t, m, "y")
if m.err != nil {
t.Fatalf("delete failed: %v", m.err)
}
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 3 {
t.Fatalf("got %d rules, want 3: %+v", len(loaded.Rule), loaded.Rule)
}
for _, r := range loaded.Rule {
if r.Tag == "shadowed" {
t.Error("the selected rule is still in rules.toml")
}
}
// The on-screen counts are rebuilt from the new file.
if len(m.ruleUsage) != 3 {
t.Errorf("usage still has %d entries, want 3", len(m.ruleUsage))
}
}
func TestRuleListPruneUnused(t *testing.T) {
m, root := newRuleListModel(t)
key(t, m, "p")
if m.confirm != confirmPruneRules {
t.Fatal("expected p to ask for confirmation")
}
if !strings.Contains(m.status, "all 3 rules") {
t.Errorf("status = %q, want the count of dead rules", m.status)
}
key(t, m, "y")
if m.err != nil {
t.Fatalf("prune failed: %v", m.err)
}
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 1 || loaded.Rule[0].Tag != "groceries" {
t.Fatalf("remaining rules = %+v, want only the groceries rule", loaded.Rule)
}
// The rule that was doing work still tags what it did before.
txns, err := m.db.Transactions(store.Filter{})
if err != nil {
t.Fatal(err)
}
tagged := 0
for _, txn := range txns {
if txn.Tag() == "groceries" {
tagged++
}
}
if tagged != 3 {
t.Errorf("%d transactions tagged after pruning, want 3", tagged)
}
if !strings.Contains(m.View(), "all in use") {
t.Errorf("expected the title to report no dead rules left:\n%s", m.View())
}
}
func TestRuleListPruneWithNothingToDo(t *testing.T) {
m, root := newRuleListModel(t)
key(t, m, "p")
key(t, m, "y")
// Second prune: nothing left to remove.
key(t, m, "p")
if m.confirm != confirmNone {
t.Error("expected no confirmation when there is nothing to prune")
}
if !strings.Contains(m.status, "no unused rules") {
t.Errorf("status = %q, want a note that there is nothing to prune", m.status)
}
loaded, _ := config.LoadRules(root)
if len(loaded.Rule) != 1 {
t.Errorf("got %d rules, want the 1 survivor untouched", len(loaded.Rule))
}
}
// newEmptyModel builds a model over an index with nothing in it.
func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
m := New("/data/root", db, accounts, rules.New(&config.Rules{}))
if err := m.reload(); err != nil {
t.Fatal(err)
}
m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
return m
}
// Configured but never imported is the confusing case: the folders exist on
// disk, but nothing is registered in the index until an import runs.
func TestEmptyAccountsWithConfiguredFolders(t *testing.T) {
m := newEmptyModel(t, []*config.Account{
{Slug: "checking", Currency: "EUR", Parser: "csv"},
{Slug: "savings", Currency: "EUR", Parser: "csv"},
})
view := m.View()
for _, want := range []string{"No accounts imported yet", "2 account folder(s)", "/data/root", "Press i to import"} {
if !strings.Contains(view, want) {
t.Errorf("accounts view missing %q:\n%s", want, view)
}
}
}
func TestEmptyAccountsWithNoFolders(t *testing.T) {
m := newEmptyModel(t, nil)
view := m.View()
for _, want := range []string{"No account folders found", "/data/root", "account.toml"} {
if !strings.Contains(view, want) {
t.Errorf("accounts view missing %q:\n%s", want, view)
}
}
}
func TestEmptyTransactionsMessages(t *testing.T) {
m := newEmptyModel(t, nil)
key(t, m, "2")
if view := m.View(); !strings.Contains(view, "No transactions yet") {
t.Errorf("expected an empty-transactions message:\n%s", view)
}
// Each way of emptying the view explains itself differently.
m.filter.AccountSlug = "checking"
if err := m.reloadTxns(); err != nil {
t.Fatal(err)
}
if view := m.View(); !strings.Contains(view, "No transactions in checking") {
t.Errorf("expected an account-scoped message:\n%s", view)
}
m.filter.AccountSlug = ""
m.filter.Search = "lidl"
if err := m.reloadTxns(); err != nil {
t.Fatal(err)
}
if view := m.View(); !strings.Contains(view, "No transactions match /lidl") {
t.Errorf("expected a search message:\n%s", view)
}
m.filter.Search = ""
m.onlyUntagged = true
if err := m.reloadTxns(); err != nil {
t.Fatal(err)
}
if view := m.View(); !strings.Contains(view, "Nothing untagged") {
t.Errorf("expected an untagged message:\n%s", view)
}
}
func TestEmptyReportMessage(t *testing.T) {
m := newEmptyModel(t, nil)
key(t, m, "3")
if view := m.View(); !strings.Contains(view, "Nothing to report yet") {
t.Errorf("expected an empty-report message:\n%s", view)
}
}
// With data present, the tables render instead of the empty-state text.
func TestNoEmptyMessageWhenPopulated(t *testing.T) {
m, _ := newTestModel(t)
for _, k := range []string{"1", "2", "3"} {
key(t, m, k)
if got := m.emptyMessage(); got != "" {
t.Errorf("view %s showed an empty message with data present: %q", k, got)
}
}
key(t, m, "2")
if !strings.Contains(m.View(), "LIDL SOFIA") {
t.Error("expected the transactions table to render")
}
}
func findTxn(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 _, txn := range txns {
if txn.Description == desc {
return txn
}
}
t.Fatalf("no transaction with description %q", desc)
return model.Transaction{}
}