Files
money/internal/tui/tui_test.go
T
nikolaandClaude Opus 5 73fefea96d Complete account and tag names in the rule builder
Both fields are free text, and a slug or tag that is slightly wrong produces
a rule that silently catches nothing — or a second, near-identical tag. They
now complete against what already exists: accounts from the folders on disk
and the index, tags from every tag in use plus any named in rules.toml, so a
tag is completable from the moment a rule mentions it.

The completion is ghosted after the cursor and never committed until it is
accepted, so inventing a new tag still works. tab takes it and moves focus
only when there is nothing left to complete; ctrl+n/ctrl+p cycle an ambiguous
prefix, and the hint under the box says how many candidates are left.

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

1143 lines
32 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)
}
}
// The account field completes against the accounts that exist, because a slug
// typed by hand only has to be slightly wrong to produce a rule that silently
// catches nothing.
func TestRuleBuilderCompletesAccount(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
m.Update(tea.KeyMsg{Type: tea.KeyTab}) // onto the account field
typeText(t, m, "sa")
// Nothing is committed until it is accepted: the value is still what was
// typed, with the rest offered as a ghost.
if m.ruleAccount.Value() != "sa" {
t.Fatalf("account = %q, want the typed text to stand until accepted", m.ruleAccount.Value())
}
if got := m.ruleAccount.CurrentSuggestion(); got != "savings" {
t.Fatalf("suggestion = %q, want savings", got)
}
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleAccount.Value() != "savings" {
t.Fatalf("account = %q after tab, want savings", m.ruleAccount.Value())
}
// Tab took the completion rather than the focus; the next one moves on.
if m.ruleFocus != 1 {
t.Errorf("focus = %d, want the account field to keep it while completing", m.ruleFocus)
}
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleFocus != 2 {
t.Errorf("focus = %d, want the tag field once there is nothing left to complete", m.ruleFocus)
}
// Accepting an account narrows the preview, exactly as typing it in full does.
if all, _ := previewRows(m); len(all) != 1 || all[0] != "LIDL VARNA 9911" {
t.Errorf("preview = %v, want only the savings row", all)
}
}
// → accepts too, at the end of the line where it has nothing else to do.
func TestRuleBuilderRightArrowAcceptsCompletion(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
m.Update(tea.KeyMsg{Type: tea.KeyTab})
typeText(t, m, "che")
m.Update(tea.KeyMsg{Type: tea.KeyRight})
if m.ruleAccount.Value() != "checking" {
t.Errorf("account = %q after →, want checking", m.ruleAccount.Value())
}
// With no completion pending it is an ordinary cursor key again.
m.ruleAccount.SetCursor(0)
m.Update(tea.KeyMsg{Type: tea.KeyRight})
if m.ruleAccount.Position() != 1 {
t.Errorf("cursor = %d, want → to move within the text once there is nothing to accept",
m.ruleAccount.Position())
}
}
// Tags come from the index and from rules.toml, so a tag is completable from
// the moment it is named in a rule, not only once something matches it.
func TestRuleBuilderCompletesTag(t *testing.T) {
m, _, _ := newRuleModel(t)
m.engine = rules.New(&config.Rules{Rule: []config.Rule{
{Match: "*NOTHING*", Tag: "groceries"},
{Match: "*ALSO NOTHING*", Tag: "grooming"},
}})
key(t, m, "4")
m.setRuleFocus(2)
typeText(t, m, "gro")
if got := m.ruleTag.CurrentSuggestion(); got != "groceries" {
t.Fatalf("suggestion = %q, want the first of the two alphabetically", got)
}
if n := len(m.ruleTag.MatchedSuggestions()); n != 2 {
t.Fatalf("%d matched suggestions, want both gro* tags", n)
}
// An ambiguous prefix is cycled, not guessed.
m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
if got := m.ruleTag.CurrentSuggestion(); got != "grooming" {
t.Fatalf("suggestion = %q after ctrl+n, want grooming", got)
}
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleTag.Value() != "grooming" {
t.Errorf("tag = %q, want the cycled-to suggestion", m.ruleTag.Value())
}
// The hint under the box names the key that accepts, and says how many
// other candidates there are.
m.ruleTag.SetValue("gro")
m.ruleTag.SetSuggestions(m.ruleTag.AvailableSuggestions())
if hint := m.completionHint(2, "applied to matches"); !strings.Contains(hint, "tab") ||
!strings.Contains(hint, "1 more") {
t.Errorf("hint = %q, want it to name tab and the remaining candidate", hint)
}
}
// A tag invented in the builder is completable next time round.
func TestSavingARuleAddsItsTagToTheSuggestions(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
m.ruleGlob.SetValue("*LIDL*")
m.setRuleFocus(2)
m.ruleTag.SetValue("groceries")
key(t, m, "enter")
m.setRuleFocus(2) // saving resets the form to the glob field
typeText(t, m, "gro")
if got := m.ruleTag.CurrentSuggestion(); got != "groceries" {
t.Errorf("suggestion = %q, want the tag just saved", got)
}
}
// A prefix nothing matches must stay exactly as typed: the completion never
// invents a tag, and tab goes back to moving between fields.
func TestRuleBuilderKeepsUnmatchedInput(t *testing.T) {
m, _, _ := newRuleModel(t)
m.engine = rules.New(&config.Rules{Rule: []config.Rule{{Match: "*X*", Tag: "groceries"}}})
key(t, m, "4")
m.setRuleFocus(2)
typeText(t, m, "zzz")
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleTag.Value() != "zzz" {
t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value())
}
if m.ruleFocus != 0 {
t.Errorf("focus = %d, want tab to wrap round to the glob field", m.ruleFocus)
}
}
// 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))
}
}
// Every screen must advertise how to reach the others and how to quit. These
// lines drift as screens are added, and a line that outgrows the terminal
// loses its tail silently.
func TestHelpLinesMentionEveryScreen(t *testing.T) {
m, _ := newTestModel(t)
for _, tc := range []struct {
name string
key string
want []string
}{
{"accounts", "1", []string{"4 new rule", "5 rules", "q quit"}},
{"transactions", "2", []string{"4 new rule", "5 rules", "q quit"}},
{"report", "3", []string{"4 new rule", "5 rules", "q quit"}},
{"rules", "5", []string{"d delete rule", "p prune all unused", "4 new rule", "q quit"}},
} {
key(t, m, tc.key)
help := m.help()
for _, want := range tc.want {
if !strings.Contains(help, want) {
t.Errorf("%s help = %q, missing %q", tc.name, help, want)
}
}
}
// The rule builder is a form, so it advertises its own keys instead.
key(t, m, "4")
if help := m.help(); !strings.Contains(help, "enter save rule") || !strings.Contains(help, "esc back") {
t.Errorf("rule builder help = %q", help)
}
}
// A help line longer than the window must wrap, not be cut off.
func TestHelpWrapsOnNarrowTerminals(t *testing.T) {
m, _ := newTestModel(t)
key(t, m, "2")
m.Update(tea.WindowSizeMsg{Width: 90, Height: 30})
view := m.View()
if !strings.Contains(view, "q quit") {
t.Errorf("the end of the help line was lost at 90 columns:\n%s", view)
}
for _, line := range strings.Split(view, "\n") {
if len([]rune(line)) > 90 {
t.Errorf("line is %d columns wide, wider than the window:\n%q", len([]rune(line)), line)
}
}
}
// 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{}
}