Files
money/internal/tui/tui_test.go
T
nikolaandClaude Opus 5 ebf7770569 Pair transfers from rules.toml
The boolean transfer flag went two commits ago because a one-sided verdict let
half a movement vanish and left the report unbalanced. This is what replaces
it: a [[transfer]] block names both legs, and only a matched pair is dropped
from the report -- both legs together, never one.

Legs pair within five days, nearest date first, and a transaction belongs to at
most one transfer, so the first definition to claim a leg keeps it, exactly as
the first matching rule keeps a tag. The pairing is derived state like the tags:
Engine.Link rewrites the whole transfers table from rules.toml, which is why
retag re-derives both halves of what that file decides, and why it runs over
the whole index rather than a filtered view -- pairing inside one would let a
movement count as a transfer in one report and not in another. An unmatched leg
is not a transfer and keeps counting, surfaced as a warning instead.

Within one currency the amount is the evidence and must be the exact opposite.
Across currencies it is not checked at all: there are no rates here, so the two
numbers are unrelated and the dates carry the pairing alone.

tolerance_pct is the one exception, per definition, for a route where the bank
takes a fee and the two statements genuinely disagree. It defaults to zero and
belongs on the one definition that charges; a global or default tolerance would
loosen every route that does not. The difference it admits is not forgiven --
the pair leaves the report entirely, so a fee hidden inside one would be
spending that appears nowhere. Pair.Fee is what left less what arrived, and
report.Excluded carries it out per currency alongside the legs. It counts only
pairs whose legs are both in view, for the same reason it counts legs and not
transfers: half a pair cannot say what the other half received.

The screens:

- 6 builds a definition against the index as you type, showing the pairs it
  would form and the legs it would catch but leave unpaired. Six fields need
  more room than the rule builder's four, so the form sheds its spacing, then
  its hints, then the borders on unfocused fields.
- 7 lists every definition with what it pairs. Two counts, because they mean
  different things: an unpaired leg is a definition doing something and not
  finishing it, no pairs at all is dead weight. Tol names the tolerance, blank
  where amounts must agree.
- 3 grows a (transfers) row under TOTAL, and a fees row beneath it, or the
  report silently disagrees with the account balances.

Two things that are not part of transfers but are the same day's work:

- ls --uniq lists each account and description once, normalised the way a glob
  sees them, which is the shape of "what still needs a rule?" -- fifty visits
  to one shop are one pattern to write, not fifty rows to read.
- The rule builder's preview now filters to what the glob matches instead of
  marking matches in a full list. The count carries the context the rows no
  longer can: 2 of 7, measured against everything still in view.

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

1915 lines
57 KiB
Go

package tui
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/importer"
"git.petrovv.com/nikola/money/internal/model"
"git.petrovv.com/nikola/money/internal/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"
[[rule]]
match = "*LIDL SOFIA*"
tag = "shadowed"
[[rule]]
match = "*NEVER MATCHES ANYTHING*"
tag = "dead"
[[rule]]
match = "*ZARA*"
tag = "clothes"
`
if err := os.WriteFile(filepath.Join(root, config.RulesFile), []byte(rulesTOML), 0o644); err != nil {
t.Fatal(err)
}
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
m.engine = rules.New(loaded)
if _, err := m.engine.Retag(m.db); err != nil {
t.Fatal(err)
}
key(t, m, "5")
return m, root
}
func TestRuleListCountsUsage(t *testing.T) {
m, _ := newRuleListModel(t)
if m.view != viewRuleList {
t.Fatal("expected 5 to open the rule list")
}
// LIDL SOFIA x2 plus LIDL VARNA; the shadowed and dead rules claim nothing.
want := []int{3, 0, 0, 0}
if len(m.ruleUsage) != len(want) {
t.Fatalf("usage = %v, want %d entries", m.ruleUsage, len(want))
}
for i := range want {
if m.ruleUsage[i] != want[i] {
t.Errorf("rule %d used by %d, want %d", i+1, m.ruleUsage[i], want[i])
}
}
// Unused rules are marked so they can be picked out at a glance.
rows := m.ruleListTable.Rows()
if rows[0][1] != " " {
t.Errorf("the used rule is marked %q, want no marker", rows[0][1])
}
for _, i := range []int{1, 2, 3} {
if rows[i][1] != "✗" {
t.Errorf("rule %d marker = %q, want ✗", i+1, rows[i][1])
}
}
if !strings.Contains(m.View(), "3 match nothing") {
t.Errorf("expected the title to count the dead rules:\n%s", m.View())
}
}
// Deleting rewrites a hand-maintained file, so it takes a confirmation.
func TestRuleListDeleteNeedsConfirmation(t *testing.T) {
m, root := newRuleListModel(t)
m.ruleListTable.SetCursor(2) // the dead rule
key(t, m, "d")
if m.confirm != confirmDeleteRule {
t.Fatal("expected d to ask for confirmation")
}
if !strings.Contains(m.status, "delete rule 3") {
t.Errorf("status = %q, want the rule identified", m.status)
}
key(t, m, "n") // anything but y cancels
if m.confirm != confirmNone {
t.Error("expected the confirmation to clear")
}
loaded, _ := config.LoadRules(root)
if len(loaded.Rule) != 4 {
t.Errorf("cancelling deleted something: %d rules left", len(loaded.Rule))
}
}
func TestRuleListDeleteSelected(t *testing.T) {
m, root := newRuleListModel(t)
m.ruleListTable.SetCursor(1) // the shadowed rule
key(t, m, "d")
key(t, m, "y")
if m.err != nil {
t.Fatalf("delete failed: %v", m.err)
}
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 3 {
t.Fatalf("got %d rules, want 3: %+v", len(loaded.Rule), loaded.Rule)
}
for _, r := range loaded.Rule {
if r.Tag == "shadowed" {
t.Error("the selected rule is still in rules.toml")
}
}
// The on-screen counts are rebuilt from the new file.
if len(m.ruleUsage) != 3 {
t.Errorf("usage still has %d entries, want 3", len(m.ruleUsage))
}
}
func TestRuleListPruneUnused(t *testing.T) {
m, root := newRuleListModel(t)
key(t, m, "p")
if m.confirm != confirmPruneRules {
t.Fatal("expected p to ask for confirmation")
}
if !strings.Contains(m.status, "all 3 rules") {
t.Errorf("status = %q, want the count of dead rules", m.status)
}
key(t, m, "y")
if m.err != nil {
t.Fatalf("prune failed: %v", m.err)
}
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 1 || loaded.Rule[0].Tag != "groceries" {
t.Fatalf("remaining rules = %+v, want only the groceries rule", loaded.Rule)
}
// The rule that was doing work still tags what it did before.
txns, err := m.db.Transactions(store.Filter{})
if err != nil {
t.Fatal(err)
}
tagged := 0
for _, txn := range txns {
if txn.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())
}
}