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/report" "git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/store" "git.petrovv.com/nikola/money/internal/transfers" ) // 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, transfers.New(&config.Rules{})) 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 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) } // A rule that claims the last untagged row empties the view; the cursor // must stay valid. Saving a rule is the only way to tag anything now. // // Saving reloads the rules from rules.toml, so the fixture's rule has to // exist there too or retagging would drop the tag it granted. if err := config.AppendRule(m.root, config.Rule{Match: "*LIDL*", Tag: "groceries"}); err != nil { t.Fatal(err) } cursorTo(t, m, "SOME RANDOM SHOP") key(t, m, "4") m.ruleGlob.SetValue("*RANDOM*") m.setRuleFocus(2) m.ruleTag.SetValue("hobby") key(t, m, "enter") key(t, m, "esc") // back to the transaction list, where u applies again 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)) } } // Every transaction reaches the report: the tagged ones under their tag, the // rest under (untagged). func TestReportViewCoversEveryTransaction(t *testing.T) { m, _ := newTestModel(t) key(t, m, "3") // report view view := m.View() for _, want := range []string{"groceries", report.Untagged, "TOTAL"} { if !strings.Contains(view, want) { t.Errorf("report is missing %q:\n%s", want, view) } } } // 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(config.IndexPath(root)) 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: "revolut"}, {Slug: "savings", Currency: "EUR", Parser: "revolut"}, }, rules.New(&config.Rules{}), transfers.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.RuleTag == "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, 3, 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 := completionHint(&m.ruleTag, true, "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 != 3 { t.Errorf("focus = %d, want tab to move on to the note field", m.ruleFocus) } } // A note is free text that rides along with the rule: written to rules.toml, // read back, and shown on the rules screen so the reason survives longer than // the memory of writing it. func TestRuleBuilderSavesNote(t *testing.T) { m, _, root := newRuleModel(t) key(t, m, "4") m.ruleGlob.SetValue("*LIDL*") m.setRuleFocus(2) m.ruleTag.SetValue("groceries") m.setRuleFocus(3) typeText(t, m, "the weekly shop") key(t, m, "enter") loaded, err := config.LoadRules(root) if err != nil { t.Fatal(err) } if len(loaded.Rule) != 1 || loaded.Rule[0].Note != "the weekly shop" { t.Fatalf("rules.toml holds %+v, want the note round-tripped", loaded.Rule) } // The note is documentation, not a pattern: it must not narrow what the // rule catches. if loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[0].Tag != "groceries" { t.Errorf("rule = %+v, want the glob and tag untouched", loaded.Rule[0]) } if m.ruleNote.Value() != "" { t.Errorf("note = %q, want the field cleared for the next rule", m.ruleNote.Value()) } m.openRuleList() row := m.ruleListTable.Rows()[0] if row[len(row)-1] != "the weekly shop" { t.Errorf("rules screen row = %v, want the note in the last column", row) } } // A rule without a note is still a rule; nothing about saving changes. func TestRuleBuilderNoteIsOptional(t *testing.T) { m, _, root := newRuleModel(t) key(t, m, "4") m.ruleGlob.SetValue("*LIDL*") m.ruleTag.SetValue("groceries") key(t, m, "enter") raw, err := os.ReadFile(filepath.Join(root, config.RulesFile)) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), "note") { t.Errorf("rules.toml = %q, want no empty note key", raw) } } // The form has to survive a short window: four fields spaced out are taller // than the room a 24-line terminal leaves. func TestRuleFormFitsShortTerminals(t *testing.T) { m, _, _ := newRuleModel(t) key(t, m, "4") for _, height := range []int{40, 32, 30, 26, 24} { m.Update(tea.WindowSizeMsg{Width: 140, Height: height}) form := m.ruleFormView() // The same budget the tables get: the title, status and a help line // that may wrap onto a second row. if lines := strings.Count(form, "\n") + 1; lines > height-6 { t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6) } // However tight it gets, every field keeps its label and its box. for _, label := range []string{"glob", "account", "tag", "note"} { if !strings.Contains(form, label) { t.Errorf("at height %d the %s field disappeared", height, label) } } if boxes := strings.Count(form, "╭"); boxes != 4 { t.Errorf("at height %d there are %d input boxes, want 4", height, boxes) } } } // 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" # Dead: the rule above is just as specific and gets there first, so this one # never claims anything. A narrower pattern would have won instead. [[rule]] match = "*LIDL*" 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.RuleTag == "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"}}, {"transfers", "7", []string{"d delete transfer", "r refresh pairing", "6 new transfer", "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 builders are forms, so they advertise their 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) } key(t, m, "esc") key(t, m, "6") if help := m.help(); !strings.Contains(help, "enter save transfer") || !strings.Contains(help, "esc back") { t.Errorf("transfer 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{}), transfers.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: "revolut"}, {Slug: "savings", Currency: "EUR", Parser: "revolut"}, }) 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{} } // newTransferModel builds a model over a real data root holding one complete // movement between two accounts, one leg whose counterpart never arrived, and // ordinary spending that is neither. func newTransferModel(t *testing.T) (*Model, *store.DB, string) { t.Helper() root := t.TempDir() db, err := store.Open(config.IndexPath(root)) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) checking, err := db.UpsertAccount(model.Account{ Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, }) if err != nil { t.Fatal(err) } savings, err := db.UpsertAccount(model.Account{ Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2, }) if err != nil { t.Fatal(err) } sourceID, err := db.SourceFile(checking, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") if err != nil { t.Fatal(err) } seed := []struct { account int64 date string desc string amount int64 }{ {checking, "2026-03-01", "TRANSFER TO SAVINGS", -50000}, {savings, "2026-03-02", "TRANSFER FROM CHECKING", 50000}, {checking, "2026-04-01", "TRANSFER TO SAVINGS", -50000}, // never arrived {checking, "2026-03-05", "LIDL SOFIA 4412", -2000}, } for i, s := range seed { if _, err := db.InsertTransaction(model.Transaction{ AccountID: s.account, SourceFileID: sourceID, Fingerprint: fmt.Sprintf("xfer-%d", i), Date: s.date, Description: s.desc, AmountMinor: s.amount, }); err != nil { t.Fatal(err) } } m := New(root, db, []*config.Account{ {Slug: "checking", Currency: "EUR", Parser: "revolut"}, {Slug: "savings", Currency: "EUR", Parser: "revolut"}, }, rules.New(&config.Rules{}), transfers.New(&config.Rules{})) if err := m.reload(); err != nil { t.Fatal(err) } m.Update(tea.WindowSizeMsg{Width: 140, Height: 40}) return m, db, root } // fillTransfer types a whole definition into the builder's fields. func fillTransfer(m *Model, from, fromDesc, to, toDesc string) { m.transferFrom.SetValue(from) m.transferFromDesc.SetValue(fromDesc) m.transferTo.SetValue(to) m.transferToDesc.SetValue(toDesc) m.refreshTransferPreview() } // The preview answers the question the form is asking: what would this pair, // and what would it catch and fail to pair? func TestTransferBuilderPreviewsPairsAndOrphans(t *testing.T) { m, _, _ := newTransferModel(t) key(t, m, "6") if m.view != viewTransfers { t.Fatal("expected 6 to open the transfer builder") } fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") if m.previewPairs != 1 || m.previewUnmatched != 1 { t.Fatalf("preview = %d pairs, %d unpaired; want 1 and 1", m.previewPairs, m.previewUnmatched) } var paired, orphaned int for _, row := range m.transferTable.Rows() { switch row[0] { case "▸": paired++ if row[3] != "checking → savings" { t.Errorf("movement = %q, want the direction spelled out", row[3]) } case "⚠": orphaned++ if row[1] != "2026-04-01" { t.Errorf("unpaired row = %v, want the April leg", row) } } } if paired != 1 || orphaned != 1 { t.Errorf("rows = %d paired, %d unpaired; want 1 and 1", paired, orphaned) } // A glob that catches nothing pairs nothing, rather than pairing loosely. fillTransfer(m, "checking", "*NOTHING LIKE THIS*", "savings", "*FROM CHECKING*") if m.previewPairs != 0 { t.Errorf("pairs = %d, want none", m.previewPairs) } } // Saving writes the definition and pairs immediately, and the report stops // counting the pair as spending. func TestTransferBuilderSavesAndPairs(t *testing.T) { m, db, root := newTransferModel(t) key(t, m, "6") fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") m.setTransferFocus(4) m.transferNote.SetValue("monthly saving") key(t, m, "enter") loaded, err := config.LoadRules(root) if err != nil { t.Fatal(err) } if len(loaded.Transfer) != 1 { t.Fatalf("rules.toml holds %+v", loaded.Transfer) } if got := loaded.Transfer[0]; got.FromAccount != "checking" || got.ToDesc != "*FROM CHECKING*" || got.Note != "monthly saving" { t.Errorf("saved transfer = %+v", got) } txns, err := db.Transactions(store.Filter{}) if err != nil { t.Fatal(err) } legs := 0 for _, txn := range txns { if txn.IsTransferLeg() { legs++ } // The April leg never arrived, so it is not part of a transfer. if txn.Date == "2026-04-01" && txn.IsTransferLeg() { t.Error("an unpaired leg must not be recorded as a transfer") } } if legs != 2 { t.Errorf("%d legs paired in the index, want 2", legs) } // The paired movement is out of the report; the unpaired leg is not. var out int64 for _, r := range report.ByTag(txns) { out += r.Out } if want := int64(52000); out != want { t.Errorf("outflow in the report = %d, want %d: the pair excluded, the orphan kept", out, want) } // The two accounts were kept for the next definition, the globs cleared. if m.transferFrom.Value() != "checking" || m.transferFromDesc.Value() != "" { t.Errorf("after saving: from = %q, from_desc = %q", m.transferFrom.Value(), m.transferFromDesc.Value()) } if !strings.Contains(m.status, "1 leg(s) unpaired") { t.Errorf("status = %q, want the unpaired leg reported", m.status) } } // A half-written definition is refused, with the missing side named. func TestTransferBuilderRejectsIncomplete(t *testing.T) { m, _, root := newTransferModel(t) key(t, m, "6") m.transferFrom.SetValue("checking") m.transferFromDesc.SetValue("*TO SAVINGS*") m.Update(tea.KeyMsg{Type: tea.KeyEnter}) if m.err == nil { t.Fatal("expected saving half a transfer to fail") } if !strings.Contains(m.err.Error(), "arrives") { t.Errorf("error = %v, want it to name the missing side", m.err) } // An unknown account is refused too, before anything is written. m.err = nil fillTransfer(m, "checking", "*TO SAVINGS*", "nosuchaccount", "*FROM CHECKING*") m.Update(tea.KeyMsg{Type: tea.KeyEnter}) if m.err == nil || !strings.Contains(m.err.Error(), "nosuchaccount") { t.Errorf("error = %v, want the unknown account named", m.err) } if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) { t.Error("a rejected transfer must not write rules.toml") } } // The builder is a form, so single-letter global keys are ordinary text. func TestTransferBuilderSwallowsGlobalKeys(t *testing.T) { m, _, _ := newTransferModel(t) key(t, m, "6") typeText(t, m, "q1i") if m.view != viewTransfers { t.Fatal("typing must not switch views") } if got := m.transferFrom.Value(); got != "q1i" { t.Errorf("from account = %q, want the typed characters", got) } if m.importing { t.Error("typing i must not start an import") } } // The list is the screen that finds the problems: what pairs, what does not, // and what matches nothing at all. func TestTransferListShowsPairingAndUnpaired(t *testing.T) { m, _, root := newTransferModel(t) if err := config.AppendTransfer(root, config.Transfer{ FromAccount: "checking", FromDesc: "*TO SAVINGS*", ToAccount: "savings", ToDesc: "*FROM CHECKING*", }); err != nil { t.Fatal(err) } if err := config.AppendTransfer(root, config.Transfer{ FromAccount: "checking", FromDesc: "*TO NOWHERE*", ToAccount: "savings", ToDesc: "*FROM NOWHERE*", Note: "dead", }); err != nil { t.Fatal(err) } if err := m.reloadConfig(); err != nil { t.Fatal(err) } key(t, m, "7") if m.view != viewTransferList { t.Fatal("expected 7 to open the transfer list") } rows := m.transferListTable.Rows() if len(rows) != 2 { t.Fatalf("rows = %v, want one per definition", rows) } if rows[0][1] != "⚠" || rows[0][4] != "1" || rows[0][5] != "1" { t.Errorf("first row = %v, want 1 pair, 1 unpaired and a warning marker", rows[0]) } if rows[1][1] != "✗" || rows[1][4] != "0" || rows[1][5] != "0" { t.Errorf("second row = %v, want it marked as matching nothing", rows[1]) } if !strings.Contains(m.title(), "1 leg(s) unpaired") { t.Errorf("title = %q, want the unpaired leg counted", m.title()) } // Pruning takes the definition that matches nothing, and leaves the one // that is merely unpaired: that one is doing something. if got := m.unusedTransfers(); len(got) != 1 || got[0] != 1 { t.Fatalf("unused = %v, want only the dead definition", got) } key(t, m, "p") if m.confirm != confirmPruneTransfers { t.Fatal("prune must ask first") } key(t, m, "y") loaded, err := config.LoadRules(root) if err != nil { t.Fatal(err) } if len(loaded.Transfer) != 1 || loaded.Transfer[0].FromDesc != "*TO SAVINGS*" { t.Errorf("transfers = %+v, want the working one kept", loaded.Transfer) } } // Deleting a definition takes its pairing with it, so the legs count again. func TestTransferListDeleteUnpairs(t *testing.T) { m, db, root := newTransferModel(t) key(t, m, "6") fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") key(t, m, "enter") // The builder is a form, so leaving it takes esc rather than a view key. key(t, m, "esc") key(t, m, "7") key(t, m, "d") if m.confirm != confirmDeleteTransfer { t.Fatal("delete must ask first") } if !strings.Contains(m.status, "checking → savings") { t.Errorf("prompt = %q, want it to name the transfer", m.status) } key(t, m, "y") loaded, err := config.LoadRules(root) if err != nil { t.Fatal(err) } if len(loaded.Transfer) != 0 { t.Fatalf("transfers = %+v, want it gone", loaded.Transfer) } txns, err := db.Transactions(store.Filter{}) if err != nil { t.Fatal(err) } for _, txn := range txns { if txn.IsTransferLeg() { t.Fatalf("%s is still recorded as a transfer leg", txn.Description) } } } // Retagging re-derives both halves of what rules.toml decides. func TestRetagAlsoRepairsThePairing(t *testing.T) { m, db, root := newTransferModel(t) if err := config.AppendTransfer(root, config.Transfer{ FromAccount: "checking", FromDesc: "*TO SAVINGS*", ToAccount: "savings", ToDesc: "*FROM CHECKING*", }); err != nil { t.Fatal(err) } if err := m.reloadConfig(); err != nil { t.Fatal(err) } key(t, m, "r") if !strings.Contains(m.status, "1 transfers matched") { t.Errorf("status = %q, want the pairing reported", m.status) } txns, err := db.Transactions(store.Filter{}) if err != nil { t.Fatal(err) } legs := 0 for _, txn := range txns { if txn.IsTransferLeg() { legs++ } } if legs != 2 { t.Errorf("%d legs paired after retag, want 2", legs) } } // Six fields need more room than four, so the transfer form gives up its // spacing, then its hints, then the borders on the fields not being edited. No // field may disappear at any height, and the one with the cursor in it keeps // its box however tight things get. func TestTransferFormFitsShortTerminals(t *testing.T) { m, _, _ := newTransferModel(t) key(t, m, "6") for _, height := range []int{50, 44, 38, 34, 30, 28} { m.Update(tea.WindowSizeMsg{Width: 140, Height: height}) form := m.transferFormView() if lines := strings.Count(form, "\n") + 1; lines > height-6 { t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6) } for _, label := range []string{ "from account", "from desc", "to account", "to desc", "tolerance %", "note", } { if !strings.Contains(form, label) { t.Errorf("at height %d the %q field disappeared", height, label) } } boxes := strings.Count(form, "╭") if boxes != 6 && boxes != 1 { t.Errorf("at height %d there are %d input boxes, want 6 or just the focused 1", height, boxes) } } } // A paired leg has been accounted for, so it is not waiting for a tag: it must // not turn up in the untagged view, nor in the rule builder's preview, which is // the list of things still asking to be tagged. func TestPairedLegsAreNotUntagged(t *testing.T) { m, _, _ := newTransferModel(t) key(t, m, "6") fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") key(t, m, "enter") key(t, m, "esc") key(t, m, "u") var seen []string for _, txn := range m.txns { seen = append(seen, txn.Date+" "+txn.Description) } want := []string{"2026-04-01 TRANSFER TO SAVINGS", "2026-03-05 LIDL SOFIA 4412"} if len(seen) != len(want) { t.Fatalf("untagged = %v, want %v: only the unpaired leg and the shopping", seen, want) } for i, w := range want { if seen[i] != w { t.Errorf("untagged[%d] = %q, want %q", i, seen[i], w) } } // The rule builder previews the same set. key(t, m, "4") all, _ := previewRows(m) if len(all) != 2 { t.Errorf("preview = %v, want the paired legs left out", all) } for _, desc := range all { if desc == "TRANSFER FROM CHECKING" { t.Error("the arriving leg of a matched transfer is still offered for tagging") } } } // An exchange between two of your own accounts pairs on the dates alone, and // the preview shows both amounts: they are the only place the rate appears. func TestTransferBuilderShowsBothSidesOfAnExchange(t *testing.T) { m, db, _ := newTransferModel(t) bgn, err := db.UpsertAccount(model.Account{ Slug: "revolut", Name: "Revolut BGN", Currency: "BGN", MinorDigits: 2, }) if err != nil { t.Fatal(err) } source, err := db.SourceFile(bgn, "revolut/st.csv", "sha", "2026-01-01T00:00:00Z") if err != nil { t.Fatal(err) } accounts, err := db.Accounts() if err != nil { t.Fatal(err) } var checking int64 for _, a := range accounts { if a.Slug == "checking" { checking = a.ID } } for _, leg := range []struct { account int64 fp string date string desc string amount int64 }{ {checking, "x-out", "2026-05-01", "TRANSFER TO REVOLUT", -50000}, // EUR {bgn, "x-in", "2026-05-02", "TOP-UP FROM CHECKING", 97790}, } { if _, err := db.InsertTransaction(model.Transaction{ AccountID: leg.account, SourceFileID: source, Fingerprint: leg.fp, Date: leg.date, Description: leg.desc, AmountMinor: leg.amount, }); err != nil { t.Fatal(err) } } key(t, m, "6") fillTransfer(m, "checking", "*TO REVOLUT*", "revolut", "*FROM CHECKING*") if m.previewPairs != 1 || m.previewUnmatched != 0 { t.Fatalf("preview = %d pairs, %d unpaired; want the exchange paired", m.previewPairs, m.previewUnmatched) } var amount string for _, row := range m.transferTable.Rows() { if row[0] == "▸" { amount = row[2] } } if amount != "500.00 → 977.90" { t.Errorf("amount = %q, want both sides of the exchange", amount) } // Saving it keeps both legs out of the report, each in its own currency. key(t, m, "enter") txns, err := db.Transactions(store.Filter{}) if err != nil { t.Fatal(err) } excluded := report.Excluded(txns) if len(excluded) != 2 { t.Fatalf("excluded = %+v, want a row per currency", excluded) } for _, x := range excluded { if x.Legs != 1 { t.Errorf("%s row = %+v, want the one leg it saw", x.Currency, x) } } } // A paired leg reads as accounted for in the tag column rather than as a blank, // but the label is display only: rule_tag stays what rules.toml made it, so // retagging is still safe to run at any time. func TestTransferLegsDisplayAsTags(t *testing.T) { m, db, _ := newTransferModel(t) key(t, m, "6") fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") key(t, m, "enter") key(t, m, "esc") var tags []string for _, row := range m.txnTable.Rows() { tags = append(tags, row[3]) } // Newest first: the unpaired April leg, the shopping, then the two legs of // the March movement. want := []string{"", "", model.TransferTag, model.TransferTag} if len(tags) != len(want) { t.Fatalf("tags = %v, want %v", tags, want) } for i, w := range want { if tags[i] != w { t.Errorf("row %d (%s) tag = %q, want %q", i, m.txns[i].Description, tags[i], w) } } // Nothing was written: the index still holds no tag for either leg, and the // label is not offered as a tag to complete against. txns, err := db.Transactions(store.Filter{}) if err != nil { t.Fatal(err) } for _, txn := range txns { if txn.RuleTag != "" { t.Errorf("%s has rule_tag %q; a transfer must never write one", txn.Description, txn.RuleTag) } } known, err := m.knownTags() if err != nil { t.Fatal(err) } for _, tag := range known { if tag == model.TransferTag { t.Error("the transfer label must not be offered as a tag to write rules with") } } // A rule tag wins where there is one, since it is the user's own word. key(t, m, "4") m.ruleGlob.SetValue("*TO SAVINGS*") m.setRuleFocus(2) m.ruleTag.SetValue("saving") key(t, m, "enter") key(t, m, "esc") for i, txn := range m.txns { if txn.Description == "TRANSFER TO SAVINGS" && m.txnTable.Rows()[i][3] != "saving" { t.Errorf("tag = %q, want the rule's own tag", m.txnTable.Rows()[i][3]) } } } // A route where the bank takes a fee is the reason tolerance_pct exists: the // builder must write it, pair on it, and say what it is admitting -- and the // report must then account for the difference, since the pair leaves it. func TestTransferBuilderTolerance(t *testing.T) { m, db, root := newTransferModel(t) checking, err := db.UpsertAccount(model.Account{ Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, }) if err != nil { t.Fatal(err) } savings, err := db.UpsertAccount(model.Account{ Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2, }) if err != nil { t.Fatal(err) } sourceID, err := db.SourceFile(checking, "checking/wire.csv", "sha2", "2026-01-01T00:00:00Z") if err != nil { t.Fatal(err) } // 500.00 leaves, 495.00 arrives: the bank kept 5.00 on the way. fee := []struct { account int64 date string desc string amount int64 }{ {checking, "2026-05-01", "WIRE TO SAVINGS", -50000}, {savings, "2026-05-02", "WIRE FROM CHECKING", 49500}, } for i, s := range fee { if _, err := db.InsertTransaction(model.Transaction{ AccountID: s.account, SourceFileID: sourceID, Fingerprint: fmt.Sprintf("wire-%d", i), Date: s.date, Description: s.desc, AmountMinor: s.amount, }); err != nil { t.Fatal(err) } } if err := m.reload(); err != nil { t.Fatal(err) } key(t, m, "6") fillTransfer(m, "checking", "*WIRE TO SAVINGS*", "savings", "*WIRE FROM CHECKING*") if m.previewPairs != 0 { t.Fatalf("previewPairs = %d, want 0 before a tolerance is given", m.previewPairs) } m.transferTolerance.SetValue("1") m.refreshTransferPreview() if m.previewPairs != 1 || m.previewFees != 500 { t.Fatalf("preview = %d pairs, %d in fees; want 1 and 500", m.previewPairs, m.previewFees) } // Both amounts show, exactly as they do for an exchange: the fee is the // thing to eyeball before saving. if want := "500.00 → 495.00"; !strings.Contains(m.transferTable.View(), want) { t.Errorf("preview table does not show %q:\n%s", want, m.transferTable.View()) } key(t, m, "enter") if m.err != nil { t.Fatalf("saving: %v", m.err) } loaded, err := config.LoadRules(root) if err != nil { t.Fatal(err) } if len(loaded.Transfer) != 1 || loaded.Transfer[0].TolerancePct != 1 { t.Fatalf("transfers = %+v, want the tolerance written", loaded.Transfer) } // Saving clears it: a tolerance carried into the next definition would // loosen a route that never asked for one. if v := m.transferTolerance.Value(); v != "" { t.Errorf("tolerance field = %q after saving, want it cleared", v) } // The pair is gone from the report, so the fee it took has to be named. var rows []string for _, r := range m.reportTable.Rows() { rows = append(rows, strings.Join(r, " ")) } joined := strings.Join(rows, "\n") if !strings.Contains(joined, transfersRow) { t.Errorf("report has no transfers row:\n%s", joined) } if !strings.Contains(joined, feesRow) || !strings.Contains(joined, "5.00") { t.Errorf("report does not account for the 5.00 fee:\n%s", joined) } } // A tolerance that is not a number is refused on save rather than written out // as a silent zero -- but it must not stop the preview updating as it is typed. func TestTransferBuilderRejectsABadTolerance(t *testing.T) { m, _, root := newTransferModel(t) key(t, m, "6") fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") m.transferTolerance.SetValue("1.") m.refreshTransferPreview() // must not panic or wipe the preview if m.previewPairs != 1 { t.Errorf("previewPairs = %d, want the preview to survive a half-typed number", m.previewPairs) } m.transferTolerance.SetValue("a lot") m.Update(tea.KeyMsg{Type: tea.KeyEnter}) // not key(), which fails on m.err if m.err == nil { t.Fatal("expected saving to be refused") } if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) { t.Error("a rejected transfer must not be written") } } // The list is where a definition is judged, and a tolerance changes what its // counts mean: those pairs were matched on slack rather than on the amount, so // the column has to say which ones. func TestTransferListShowsTheTolerance(t *testing.T) { m, _, root := newTransferModel(t) if err := config.AppendTransfer(root, config.Transfer{ FromAccount: "checking", FromDesc: "*TO SAVINGS*", ToAccount: "savings", ToDesc: "*FROM CHECKING*", }); err != nil { t.Fatal(err) } if err := config.AppendTransfer(root, config.Transfer{ FromAccount: "checking", FromDesc: "*WIRE TO SAVINGS*", ToAccount: "savings", ToDesc: "*WIRE FROM CHECKING*", TolerancePct: 1.5, }); err != nil { t.Fatal(err) } if err := m.reloadConfig(); err != nil { t.Fatal(err) } key(t, m, "7") rows := m.transferListTable.Rows() if len(rows) != 2 { t.Fatalf("rows = %v, want one per definition", rows) } // Blank, not "0%": every definition has the default, and printing it down // the column would bury the row where amounts may actually disagree. if rows[0][6] != "" { t.Errorf("strict row shows tolerance %q, want it blank", rows[0][6]) } if rows[1][6] != "1.5%" { t.Errorf("tolerant row shows %q, want 1.5%%", rows[1][6]) } } // Once a glob is typed the preview is the rule's answer, not a list to search // by eye: the rows that do not match go, and the count says what they were // chosen out of, since the rows on screen can no longer say it themselves. func TestRuleBuilderPreviewShowsOnlyMatches(t *testing.T) { m, _, _ := newRuleModel(t) key(t, m, "4") // With no glob there is nothing to filter by, so the screen answers its // other question: everything still waiting for a rule. if all, _ := previewRows(m); len(all) != 4 { t.Fatalf("preview = %v, want every untagged description before a glob", all) } if !strings.Contains(m.ruleFormView(), "4 untagged descriptions") { t.Errorf("summary = %q, want the untagged count", m.ruleFormView()) } typeText(t, m, "*LIDL*") all, matched := previewRows(m) if len(all) != 2 || len(matched) != 2 { t.Fatalf("preview = %v (matched %v), want only the two LIDL rows", all, matched) } for _, desc := range all { if !strings.Contains(desc, "LIDL") { t.Errorf("preview kept a non-matching row %q", desc) } } // Two of the four still in view, not two of the two left on screen. if m.ruleCandidates != 4 { t.Errorf("candidates = %d, want 4", m.ruleCandidates) } if !strings.Contains(m.ruleFormView(), "2 of 4 descriptions match") { t.Errorf("summary = %q, want the glob's selectivity", m.ruleFormView()) } // A glob that matches nothing empties the list, and says so rather than // leaving rows on screen that the rule would not claim. m.ruleGlob.SetValue("*NOTHING*") m.refreshRulePreview() if all, _ := previewRows(m); len(all) != 0 { t.Errorf("preview = %v, want nothing", all) } if !strings.Contains(m.ruleFormView(), "0 of 4 descriptions match") { t.Errorf("summary = %q, want 0 of 4", m.ruleFormView()) } } // loadRulesFile puts a rules.toml under the model's root and makes the model // read it, as though it had been there all along. func loadRulesFile(t *testing.T, m *Model, body string) { t.Helper() if err := os.WriteFile(filepath.Join(m.root, config.RulesFile), []byte(body), 0o644); err != nil { t.Fatal(err) } if err := m.reloadConfig(); err != nil { t.Fatal(err) } if _, err := m.engine.Retag(m.db); err != nil { t.Fatal(err) } if err := m.reload(); err != nil { t.Fatal(err) } } const editableRules = `[[rule]] match = "*ZZZ*" tag = "misc" # The weekly shop. [[rule]] match = "*LIDL*" tag = "groceries" note = "both branches" ` // Editing rewrites the rule where it sits rather than appending a second, // almost identical one, and lands back on the list it started from. func TestRuleListEditsRuleInPlace(t *testing.T) { m, db, root := newRuleModel(t) loadRulesFile(t, m, editableRules) key(t, m, "5") m.ruleListTable.SetCursor(1) key(t, m, "e") if m.view != viewRules || !m.ruleEditing { t.Fatalf("e opened view %v (editing %v), want the builder on the rule", m.view, m.ruleEditing) } if m.ruleGlob.Value() != "*LIDL*" || m.ruleTag.Value() != "groceries" || m.ruleNote.Value() != "both branches" { t.Fatalf("form = %q / %q / %q, want the rule filled in", m.ruleGlob.Value(), m.ruleTag.Value(), m.ruleNote.Value()) } m.ruleTag.SetValue("food") key(t, m, "enter") loaded, err := config.LoadRules(root) if err != nil { t.Fatal(err) } want := config.Rule{Match: "*LIDL*", Tag: "food", Note: "both branches"} if len(loaded.Rule) != 2 { t.Fatalf("rules = %+v, want the same 2: an edit must not append", loaded.Rule) } if loaded.Rule[0].Match != "*ZZZ*" { t.Errorf("rule 1 = %+v, want it where it was", loaded.Rule[0]) } if loaded.Rule[1] != want { t.Errorf("rule 2 = %+v, want %+v", loaded.Rule[1], want) } // The file is edited textually, so the comment documenting the rule stays. raw, err := os.ReadFile(filepath.Join(root, config.RulesFile)) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), "# The weekly shop.") { t.Errorf("the rule's comment was lost:\n%s", raw) } // Saving retags immediately, as it does for a new rule. txns, err := db.Transactions(store.Filter{}) if err != nil { t.Fatal(err) } tagged := 0 for _, txn := range txns { if txn.RuleTag == "food" { tagged++ } } if tagged != 3 { t.Errorf("%d transactions tagged food, want 3", tagged) } // An edit is a round trip: it ends on the list, with the new counts. if m.view != viewRuleList { t.Fatalf("view = %v after saving, want the rules list", m.view) } if rows := m.ruleListTable.Rows(); len(rows) != 2 || rows[1][4] != "food" { t.Errorf("rows = %v, want the edited tag on the list", rows) } if !strings.Contains(m.status, "rule 2 is now") { t.Errorf("status = %q, want it to name the rule that changed", m.status) } } // The preview lists what the rule already claims, not only what is untagged: // for a rule that works those are the same rows, and an empty preview would say // nothing about the edit. Narrowing the glob then shows what it gives up. func TestRuleEditPreviewsWhatItDrops(t *testing.T) { m, _, _ := newRuleModel(t) loadRulesFile(t, m, editableRules) key(t, m, "5") m.ruleListTable.SetCursor(1) key(t, m, "e") all, matched := previewRows(m) if len(all) != 2 || len(matched) != 2 { t.Fatalf("preview = %v (matched %v), want the two descriptions the rule claims", all, matched) } m.ruleGlob.SetValue("*SOFIA*") m.refreshRulePreview() all, matched = previewRows(m) if len(matched) != 1 || matched[0] != "LIDL SOFIA 4412" { t.Errorf("matched = %v, want only the Sofia branch", matched) } // The row it stops claiming stays on screen: losing one is the thing worth // seeing before saving, so it is marked rather than dropped silently. if len(all) != 2 || m.ruleDropped != 1 { t.Fatalf("preview = %v, dropped = %d; want the Varna row kept and counted", all, m.ruleDropped) } var dropped []string for _, row := range m.ruleTable.Rows() { if row[0] == "−" { dropped = append(dropped, row[1]) } } if len(dropped) != 1 || dropped[0] != "LIDL VARNA 9911" { t.Errorf("dropped rows = %v, want the Varna branch", dropped) } if !strings.Contains(m.ruleFormView(), "1 no longer claimed") { t.Errorf("summary = %q, want the dropped count", m.ruleFormView()) } } // The form has no type field, so an edit carries the pattern through instead of // silently widening the rule to every transaction the glob matches. func TestRuleEditKeepsTypePattern(t *testing.T) { m, _, root := newRuleModel(t) loadRulesFile(t, m, "[[rule]]\nmatch = \"*ZZZ*\"\ntype = \"CARD_PAYMENT\"\ntag = \"misc\"\n") key(t, m, "5") key(t, m, "e") if !strings.Contains(m.ruleFormView(), "type:CARD_PAYMENT") { t.Errorf("form = %q, want it to name the type it is carrying", m.ruleFormView()) } m.ruleTag.SetValue("cards") key(t, m, "enter") loaded, err := config.LoadRules(root) if err != nil { t.Fatal(err) } want := config.Rule{Match: "*ZZZ*", Type: "CARD_PAYMENT", Tag: "cards"} if len(loaded.Rule) != 1 || loaded.Rule[0] != want { t.Errorf("rules = %+v, want %+v", loaded.Rule, want) } } // Leaving an edit must not leave the form holding that rule, or the next thing // saved would be a near-duplicate of the rule just edited. func TestNewRuleAfterAnEditStartsEmpty(t *testing.T) { m, _, _ := newRuleModel(t) loadRulesFile(t, m, editableRules) key(t, m, "5") m.ruleListTable.SetCursor(1) key(t, m, "e") key(t, m, "esc") key(t, m, "4") if m.ruleEditing { t.Error("4 opened the builder still in edit mode") } for _, in := range m.ruleInputs() { if in.Value() != "" { t.Errorf("form field = %q, want a blank form for a new rule", in.Value()) } } } // The builder is reached from the list, so it is not somewhere the list can // return to: esc must walk back out, not bounce between the two screens. func TestEscFromRuleListDoesNotBounceIntoTheBuilder(t *testing.T) { m, _, _ := newRuleModel(t) loadRulesFile(t, m, editableRules) key(t, m, "2") key(t, m, "5") key(t, m, "e") key(t, m, "esc") if m.view != viewRuleList { t.Fatalf("esc from the builder went to %v, want the list it was opened from", m.view) } key(t, m, "esc") if m.view != viewTxns { t.Errorf("esc from the list went to %v, want the transactions it was opened from", m.view) } }