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{} }