Files
money/internal/tui/tui_test.go
T
nikolaandClaude Opus 5 7d4c3eba17 Open the report on last month
The report answered for the whole index, which is the one window a spending
report is least often asked for: last January's groceries sat in the same total
as last week's, and nothing on screen said which was which. It now opens on the
month that has just ended -- the last one a statement can be complete for --
and left/right step along an axis beside the totals, from all time down through
the named windows to the oldest month the index holds.

The period narrows the report and nothing else. reloadReport runs its own query
rather than reusing the rows the transaction list is showing: the two share the
account, the search and the untagged toggle, and differ only in the date
bounds, so opening on last month must not hide the rest of the index from the
list beside it. Nothing may put the period into m.filter, which is exactly what
would make it leak.

The windows are relative to today, never to the newest statement. "Last month"
with nothing in it reports nothing and says so, because silently answering for
a month nobody asked for is worse than an empty screen; an empty period and an
empty index therefore give different messages, one asking for another period
and the other for an import. The rolling windows run to the end of this month
rather than to the last complete one -- "last 3 months" is asked in order to
see what is happening now, and leaving out the days since the 1st answers a
question nobody put. The axis is built over the whole index rather than the
rows in view, or it would grow and shrink as the account or search filter
changed and move under the cursor; a reload rebuilds it, since an import can
reach further back, but keeps the window the user was on.

s cycles how those rows are arranged: largest out first as before, then in, net
lowest first so the biggest losses lead, count, and the tag A to Z. A letter
rather than a chord because the report is not a form. The marked heading says
which column the rows are read from and the help names what the key does next,
as the rule builder's preview already does. The sort rearranges rows and never
changes which rows there are, so the order stays out of the filter for the same
reason the period does. Currency remains the outer key under every order --
there are no rates here, so two currencies interleaved by amount would invite a
comparison that cannot be made -- and every order falls back to the tag, so
ties keep a fixed position instead of reshuffling between reloads.

money report takes the same choice as --sort out|in|net|count|tag, and a
misspelt one is refused rather than silently reporting in the default order. It
keeps --month and has no equivalent of the wider windows.

store.Filter gains From and To, compared as strings because dates are stored
ISO-8601 and a string comparison is therefore a date comparison. store.Months
replaces report.Months, which nothing had ever called: the axis needs the
months of the whole index, not of a slice already in hand.

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

2585 lines
77 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"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
}
// allTime moves the report off its default window. That window is last month,
// while the fixtures are dated whenever they were written, so a test that wants
// to see every transaction in the report has to say so.
func allTime(t *testing.T, m *Model) {
t.Helper()
m.setPeriod(0)
if p, _ := m.currentPeriod(); p.Label != "all time" {
t.Fatalf("period = %q, want all time", p.Label)
}
if m.err != nil {
t.Fatal(m.err)
}
}
// 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}
case "ctrl+s":
msg = tea.KeyMsg{Type: tea.KeyCtrlS}
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
allTime(t, m)
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)
allTime(t, m)
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, report.OrderOut) {
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.
allTime(t, m)
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)
}
}
// Two orders, because the preview answers two questions: by name finds the
// payee you are looking at, by count finds the rule worth writing next.
func TestRuleBuilderSortsByCount(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
alphabetical := []string{
"AAA FIRST ALPHABETICALLY",
"LIDL SOFIA 4412",
"LIDL VARNA 9911",
"ZZZ LAST ALPHABETICALLY",
}
if all, _ := previewRows(m); !slices.Equal(all, alphabetical) {
t.Fatalf("preview = %v, want %v by default", all, alphabetical)
}
key(t, m, "ctrl+s")
// The twice-seen description leads; the rest tie on one and keep the
// alphabetical order between them, so the list does not reshuffle.
byCount := []string{
"LIDL SOFIA 4412",
"AAA FIRST ALPHABETICALLY",
"LIDL VARNA 9911",
"ZZZ LAST ALPHABETICALLY",
}
if all, _ := previewRows(m); !slices.Equal(all, byCount) {
t.Fatalf("preview = %v, want %v after sorting by count", all, byCount)
}
// The header names the column the order is read from, and the help names
// what the key does next rather than where the list already is.
if cols := m.ruleTable.Columns(); cols[2].Title != "N ↓" || strings.Contains(cols[1].Title, "↓") {
t.Errorf("headers = %q / %q, want the count column marked", cols[1].Title, cols[2].Title)
}
if !strings.Contains(m.help(), "ctrl+s sort by name") {
t.Errorf("help = %q, want the key to offer the way back", m.help())
}
key(t, m, "ctrl+s")
if all, _ := previewRows(m); !slices.Equal(all, alphabetical) {
t.Errorf("preview = %v, want %v back", all, alphabetical)
}
// The choice is the user's, so it survives a glob narrowing the list and
// the builder being left and reopened.
key(t, m, "ctrl+s")
typeText(t, m, "*LIDL*")
key(t, m, "esc")
key(t, m, "4")
if m.previewSort != sortByCount {
t.Fatal("the sort was reset by leaving the builder")
}
want := []string{"LIDL SOFIA 4412", "LIDL VARNA 9911"}
if all, _ := previewRows(m); !slices.Equal(all, want) {
t.Errorf("preview = %v, want %v: the glob still filters, in count order", all, want)
}
}
// monthsAgo is the 15th of the month n before this one. Mid-month on purpose:
// a fixture on the 31st would land in a different month than intended as soon
// as the month before it is shorter.
func monthsAgo(n int) time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), 15, 0, 0, 0, 0, time.UTC).AddDate(0, -n, 0)
}
// newDatedModel builds a model whose transactions are dated relative to today:
// 10.00 of groceries last month and 25.00 three months back.
func newDatedModel(t *testing.T) *Model {
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 _, tc := range []struct {
when time.Time
desc string
amount int64
}{
{monthsAgo(1), "LIDL SOFIA", -1000},
{monthsAgo(3), "LIDL PLOVDIV", -2500},
} {
if _, err := db.InsertTransaction(model.Transaction{
AccountID: accountID,
SourceFileID: sourceID,
Fingerprint: tc.desc,
Date: tc.when.Format("2006-01-02"),
Description: tc.desc,
AmountMinor: tc.amount,
}); 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
}
// reportOut is what the report says one tag spent, or "" if it has no row.
func reportOut(m *Model, tag string) string {
for _, r := range m.reportTable.Rows() {
if r[0] == tag {
return r[2]
}
}
return ""
}
// The report opens on last month, not on everything: an all-time total is the
// one number a spending report is least often asked for.
func TestReportOpensOnLastMonth(t *testing.T) {
m := newDatedModel(t)
p, ok := m.currentPeriod()
if !ok || p.Label != report.LastMonth {
t.Fatalf("period = %+v, want %q", p, report.LastMonth)
}
if got := reportOut(m, "groceries"); got != "10.00" {
t.Errorf("groceries = %q, want only last month's 10.00", got)
}
key(t, m, "3")
if view := m.View(); !strings.Contains(view, "last month") {
t.Errorf("title does not name the period:\n%s", view)
}
}
// The period narrows the report and only the report. Opening on last month
// must not quietly hide the rest of the index from the transaction list.
func TestReportPeriodLeavesTheTransactionListAlone(t *testing.T) {
m := newDatedModel(t)
if len(m.txns) != 2 {
t.Fatalf("transaction list has %d rows, want both", len(m.txns))
}
key(t, m, "3")
key(t, m, "right")
if len(m.txns) != 2 {
t.Errorf("stepping the period changed the transaction list to %d rows", len(m.txns))
}
if m.filter.From != "" || m.filter.To != "" {
t.Errorf("the period leaked into the shared filter: %+v", m.filter)
}
}
// →Steps to the next window along, which past last month is the months the
// index actually holds.
func TestReportPeriodSteps(t *testing.T) {
m := newDatedModel(t)
key(t, m, "3")
key(t, m, "right")
want := monthsAgo(3).Format("2006-01")
if p, _ := m.currentPeriod(); p.Label != want {
t.Fatalf("period = %q, want %q", p.Label, want)
}
if got := reportOut(m, "groceries"); got != "25.00" {
t.Errorf("groceries = %q, want that month's 25.00", got)
}
key(t, m, "left")
if p, _ := m.currentPeriod(); p.Label != report.LastMonth {
t.Errorf("stepping back landed on %q, want %q", p.Label, report.LastMonth)
}
}
// The axis clamps at both ends rather than wrapping: all time and the oldest
// month are as far as the two directions go.
func TestReportPeriodClampsAtBothEnds(t *testing.T) {
m := newDatedModel(t)
key(t, m, "3")
for range 10 {
key(t, m, "left")
}
if p, _ := m.currentPeriod(); p.Label != "all time" {
t.Errorf("period = %q, want all time", p.Label)
}
if got := reportOut(m, "groceries"); got != "35.00" {
t.Errorf("groceries over all time = %q, want 35.00", got)
}
last := len(m.reportPeriods) - 1
for range 20 {
key(t, m, "right")
}
if m.reportPeriod != last {
t.Errorf("period index = %d, want it to stop at %d", m.reportPeriod, last)
}
}
// The axis is on screen beside the totals, so the window the numbers are for
// does not have to be read out of the title.
func TestReportShowsThePeriodAxis(t *testing.T) {
m := newDatedModel(t)
key(t, m, "3")
view := m.View()
for _, want := range []string{"Period", "▸ " + report.LastMonth, "all time", "←/→ period"} {
if !strings.Contains(view, want) {
t.Errorf("report view is missing %q:\n%s", want, view)
}
}
}
// A window with nothing in it is not an empty index, and the two want different
// answers: one is fixed by stepping the period, the other by importing.
func TestEmptyPeriodIsNotAnEmptyIndex(t *testing.T) {
m := newDatedModel(t)
key(t, m, "3")
// Two months back holds neither fixture.
for _, p := range m.reportPeriods {
if p.Label == monthsAgo(2).Format("2006-01") {
t.Fatalf("did not expect a window for an empty month: %+v", p)
}
}
m.filter.Search = "NOTHING MATCHES THIS"
if err := m.reloadReport(); err != nil {
t.Fatal(err)
}
if got := m.emptyMessage(); !strings.Contains(got, "period") {
t.Errorf("empty period message = %q, want it to point at the period", got)
}
if strings.Contains(m.emptyMessage(), "import some statements") {
t.Error("an empty period asked for an import, but the index has statements")
}
}
// An import that reaches further back grows the axis without moving the window
// the user is reading.
func TestPeriodAxisSurvivesAReload(t *testing.T) {
m := newDatedModel(t)
key(t, m, "3")
key(t, m, "right")
was, _ := m.currentPeriod()
if err := m.reload(); err != nil {
t.Fatal(err)
}
if p, _ := m.currentPeriod(); p.Label != was.Label {
t.Errorf("reload moved the period from %q to %q", was.Label, p.Label)
}
}
// newSortModel builds a report worth sorting: three tags that come out in a
// different order under each arrangement.
func newSortModel(t *testing.T) *Model {
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)
}
// One big outflow, three small ones, and an inflow bigger than any of them.
rows := []struct {
desc string
amount int64
}{
{"RENT", -100000},
{"COFFEE 1", -500},
{"COFFEE 2", -500},
{"COFFEE 3", -500},
{"SALARY", 200000},
}
for _, r := range rows {
if _, err := db.InsertTransaction(model.Transaction{
AccountID: accountID,
SourceFileID: sourceID,
Fingerprint: r.desc,
Date: monthsAgo(1).Format("2006-01-02"),
Description: r.desc,
AmountMinor: r.amount,
}); err != nil {
t.Fatal(err)
}
}
engine := rules.New(&config.Rules{Rule: []config.Rule{
{Match: "RENT", Tag: "rent"},
{Match: "COFFEE*", Tag: "coffee"},
{Match: "SALARY", Tag: "salary"},
}})
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})
key(t, m, "3")
return m
}
// reportTags is the order the report's tag rows came out in, without the
// TOTAL and transfer rows the table grows below them.
func reportTags(m *Model) []string {
var out []string
for _, r := range m.reportTable.Rows() {
if r[0] == "TOTAL" || r[0] == transfersRow || r[0] == feesRow {
continue
}
out = append(out, r[0])
}
return out
}
// s cycles the arrangement, and every one of them is reachable that way.
func TestReportSortCyclesThroughEveryOrder(t *testing.T) {
m := newSortModel(t)
want := map[report.Order][]string{
report.OrderOut: {"rent", "coffee", "salary"},
report.OrderIn: {"salary", "coffee", "rent"},
report.OrderNet: {"rent", "coffee", "salary"},
report.OrderCount: {"coffee", "rent", "salary"},
report.OrderTag: {"coffee", "rent", "salary"},
}
if m.reportOrder != report.OrderOut {
t.Fatalf("report opened on %s, want %s", m.reportOrder, report.OrderOut)
}
seen := map[report.Order]bool{}
for range len(want) {
seen[m.reportOrder] = true
if got := reportTags(m); !slices.Equal(got, want[m.reportOrder]) {
t.Errorf("%s: rows = %v, want %v", m.reportOrder, got, want[m.reportOrder])
}
key(t, m, "s")
}
if len(seen) != len(want) {
t.Errorf("cycling reached %d of %d orders", len(seen), len(want))
}
if m.reportOrder != report.OrderOut {
t.Errorf("the cycle ended on %s, want it back where it started", m.reportOrder)
}
}
// The sort rearranges the rows and nothing else: the same tags are there, for
// the same period, and the transaction list is untouched.
func TestReportSortChangesOnlyTheOrder(t *testing.T) {
m := newSortModel(t)
before := slices.Clone(reportTags(m))
txns := len(m.txns)
period, _ := m.currentPeriod()
key(t, m, "s")
after := slices.Clone(reportTags(m))
slices.Sort(before)
slices.Sort(after)
if !slices.Equal(before, after) {
t.Errorf("sorting changed which rows there are: %v then %v", before, after)
}
if len(m.txns) != txns {
t.Errorf("sorting the report changed the transaction list to %d rows", len(m.txns))
}
if p, _ := m.currentPeriod(); p.Label != period.Label {
t.Errorf("sorting moved the period to %q", p.Label)
}
}
// The marker sits on the column being sorted, so the order is visible in the
// table rather than only in the key list.
func TestReportMarksTheSortedColumn(t *testing.T) {
m := newSortModel(t)
for _, want := range []string{"Out ▾", "In ▾", "Net ▾", "N ▾", "Tag ▴"} {
view := m.View()
if !strings.Contains(view, want) {
t.Errorf("%s: header does not mark %q:\n%s", m.reportOrder, want, view)
}
// Exactly one column carries it, or the header would lie about which
// column the rows are arranged by.
if n := strings.Count(view, "▾") + strings.Count(view, "▴"); n != 1 {
t.Errorf("%s: %d columns marked, want 1", m.reportOrder, n)
}
key(t, m, "s")
}
}
// The key list names what pressing s does next, not the order already in force.
func TestReportSortHelpNamesTheNextOrder(t *testing.T) {
m := newSortModel(t)
if got := m.help(); !strings.Contains(got, "s sort by in") {
t.Errorf("help = %q, want it to offer the next order", got)
}
key(t, m, "s")
if got := m.help(); !strings.Contains(got, "s sort by net") {
t.Errorf("help = %q, want it to offer the next order", got)
}
}
// s belongs to the report. Elsewhere it goes to the table like any other key,
// so a view that later wants it is not fighting a global binding.
func TestSortKeyIsReportOnly(t *testing.T) {
m := newSortModel(t)
key(t, m, "2")
key(t, m, "s")
if m.reportOrder != report.OrderOut {
t.Errorf("s re-sorted the report from the transaction list: %s", m.reportOrder)
}
}