A rule could be written and deleted but never changed, so fixing a glob meant deleting the rule and typing it again -- losing its note, the comments around it, and its position, which still breaks ties between equally specific rules. e now opens the selected rule in the builder and enter rewrites it where it sits. config.ReplaceRule edits rules.toml textually, as deleting does, and keeps the comments above the rule: they say why it is there, which editing its glob rarely changes. The round trip must not lose what the form does not show. The builder has four fields and a Rule has five, so an edit carries the type pattern through untouched and says so under the glob; a type-only rule saves without one. The preview needed the same care in reverse: a working rule's transactions are tagged, so an untagged-only preview would be empty for it. Its own rows are added back, and the ones a narrowed glob stops catching stay on screen marked -- giving one up is the decision being made, and it must not happen silently. The builder and its list shared one return view, so opening the builder from the list left esc pointing back into the form. Each screen now remembers its own way out; transfers had the same trap and the same fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2127 lines
63 KiB
Go
2127 lines
63 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"
|
||
|
||
# Dead: the rule above is just as specific and gets there first, so this one
|
||
# never claims anything. A narrower pattern would have won instead.
|
||
[[rule]]
|
||
match = "*LIDL*"
|
||
tag = "shadowed"
|
||
|
||
[[rule]]
|
||
match = "*NEVER MATCHES ANYTHING*"
|
||
tag = "dead"
|
||
|
||
[[rule]]
|
||
match = "*ZARA*"
|
||
tag = "clothes"
|
||
`
|
||
if err := os.WriteFile(filepath.Join(root, config.RulesFile), []byte(rulesTOML), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m.engine = rules.New(loaded)
|
||
if _, err := m.engine.Retag(m.db); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
key(t, m, "5")
|
||
return m, root
|
||
}
|
||
|
||
func TestRuleListCountsUsage(t *testing.T) {
|
||
m, _ := newRuleListModel(t)
|
||
|
||
if m.view != viewRuleList {
|
||
t.Fatal("expected 5 to open the rule list")
|
||
}
|
||
|
||
// LIDL SOFIA x2 plus LIDL VARNA; the shadowed and dead rules claim nothing.
|
||
want := []int{3, 0, 0, 0}
|
||
if len(m.ruleUsage) != len(want) {
|
||
t.Fatalf("usage = %v, want %d entries", m.ruleUsage, len(want))
|
||
}
|
||
for i := range want {
|
||
if m.ruleUsage[i] != want[i] {
|
||
t.Errorf("rule %d used by %d, want %d", i+1, m.ruleUsage[i], want[i])
|
||
}
|
||
}
|
||
|
||
// Unused rules are marked so they can be picked out at a glance.
|
||
rows := m.ruleListTable.Rows()
|
||
if rows[0][1] != " " {
|
||
t.Errorf("the used rule is marked %q, want no marker", rows[0][1])
|
||
}
|
||
for _, i := range []int{1, 2, 3} {
|
||
if rows[i][1] != "✗" {
|
||
t.Errorf("rule %d marker = %q, want ✗", i+1, rows[i][1])
|
||
}
|
||
}
|
||
|
||
if !strings.Contains(m.View(), "3 match nothing") {
|
||
t.Errorf("expected the title to count the dead rules:\n%s", m.View())
|
||
}
|
||
}
|
||
|
||
// Deleting rewrites a hand-maintained file, so it takes a confirmation.
|
||
func TestRuleListDeleteNeedsConfirmation(t *testing.T) {
|
||
m, root := newRuleListModel(t)
|
||
|
||
m.ruleListTable.SetCursor(2) // the dead rule
|
||
key(t, m, "d")
|
||
if m.confirm != confirmDeleteRule {
|
||
t.Fatal("expected d to ask for confirmation")
|
||
}
|
||
if !strings.Contains(m.status, "delete rule 3") {
|
||
t.Errorf("status = %q, want the rule identified", m.status)
|
||
}
|
||
|
||
key(t, m, "n") // anything but y cancels
|
||
if m.confirm != confirmNone {
|
||
t.Error("expected the confirmation to clear")
|
||
}
|
||
loaded, _ := config.LoadRules(root)
|
||
if len(loaded.Rule) != 4 {
|
||
t.Errorf("cancelling deleted something: %d rules left", len(loaded.Rule))
|
||
}
|
||
}
|
||
|
||
func TestRuleListDeleteSelected(t *testing.T) {
|
||
m, root := newRuleListModel(t)
|
||
|
||
m.ruleListTable.SetCursor(1) // the shadowed rule
|
||
key(t, m, "d")
|
||
key(t, m, "y")
|
||
|
||
if m.err != nil {
|
||
t.Fatalf("delete failed: %v", m.err)
|
||
}
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(loaded.Rule) != 3 {
|
||
t.Fatalf("got %d rules, want 3: %+v", len(loaded.Rule), loaded.Rule)
|
||
}
|
||
for _, r := range loaded.Rule {
|
||
if r.Tag == "shadowed" {
|
||
t.Error("the selected rule is still in rules.toml")
|
||
}
|
||
}
|
||
// The on-screen counts are rebuilt from the new file.
|
||
if len(m.ruleUsage) != 3 {
|
||
t.Errorf("usage still has %d entries, want 3", len(m.ruleUsage))
|
||
}
|
||
}
|
||
|
||
func TestRuleListPruneUnused(t *testing.T) {
|
||
m, root := newRuleListModel(t)
|
||
|
||
key(t, m, "p")
|
||
if m.confirm != confirmPruneRules {
|
||
t.Fatal("expected p to ask for confirmation")
|
||
}
|
||
if !strings.Contains(m.status, "all 3 rules") {
|
||
t.Errorf("status = %q, want the count of dead rules", m.status)
|
||
}
|
||
key(t, m, "y")
|
||
|
||
if m.err != nil {
|
||
t.Fatalf("prune failed: %v", m.err)
|
||
}
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(loaded.Rule) != 1 || loaded.Rule[0].Tag != "groceries" {
|
||
t.Fatalf("remaining rules = %+v, want only the groceries rule", loaded.Rule)
|
||
}
|
||
|
||
// The rule that was doing work still tags what it did before.
|
||
txns, err := m.db.Transactions(store.Filter{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tagged := 0
|
||
for _, txn := range txns {
|
||
if txn.RuleTag == "groceries" {
|
||
tagged++
|
||
}
|
||
}
|
||
if tagged != 3 {
|
||
t.Errorf("%d transactions tagged after pruning, want 3", tagged)
|
||
}
|
||
if !strings.Contains(m.View(), "all in use") {
|
||
t.Errorf("expected the title to report no dead rules left:\n%s", m.View())
|
||
}
|
||
}
|
||
|
||
func TestRuleListPruneWithNothingToDo(t *testing.T) {
|
||
m, root := newRuleListModel(t)
|
||
key(t, m, "p")
|
||
key(t, m, "y")
|
||
|
||
// Second prune: nothing left to remove.
|
||
key(t, m, "p")
|
||
if m.confirm != confirmNone {
|
||
t.Error("expected no confirmation when there is nothing to prune")
|
||
}
|
||
if !strings.Contains(m.status, "no unused rules") {
|
||
t.Errorf("status = %q, want a note that there is nothing to prune", m.status)
|
||
}
|
||
loaded, _ := config.LoadRules(root)
|
||
if len(loaded.Rule) != 1 {
|
||
t.Errorf("got %d rules, want the 1 survivor untouched", len(loaded.Rule))
|
||
}
|
||
}
|
||
|
||
// Every screen must advertise how to reach the others and how to quit. These
|
||
// lines drift as screens are added, and a line that outgrows the terminal
|
||
// loses its tail silently.
|
||
func TestHelpLinesMentionEveryScreen(t *testing.T) {
|
||
m, _ := newTestModel(t)
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
key string
|
||
want []string
|
||
}{
|
||
{"accounts", "1", []string{"4 new rule", "5 rules", "q quit"}},
|
||
{"transactions", "2", []string{"4 new rule", "5 rules", "q quit"}},
|
||
{"report", "3", []string{"4 new rule", "5 rules", "q quit"}},
|
||
{"rules", "5", []string{"d delete rule", "p prune all unused", "4 new rule", "q quit"}},
|
||
{"transfers", "7", []string{"d delete transfer", "r refresh pairing", "6 new transfer", "q quit"}},
|
||
} {
|
||
key(t, m, tc.key)
|
||
help := m.help()
|
||
for _, want := range tc.want {
|
||
if !strings.Contains(help, want) {
|
||
t.Errorf("%s help = %q, missing %q", tc.name, help, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The builders are forms, so they advertise their own keys instead.
|
||
key(t, m, "4")
|
||
if help := m.help(); !strings.Contains(help, "enter save rule") || !strings.Contains(help, "esc back") {
|
||
t.Errorf("rule builder help = %q", help)
|
||
}
|
||
key(t, m, "esc")
|
||
key(t, m, "6")
|
||
if help := m.help(); !strings.Contains(help, "enter save transfer") || !strings.Contains(help, "esc back") {
|
||
t.Errorf("transfer builder help = %q", help)
|
||
}
|
||
}
|
||
|
||
// A help line longer than the window must wrap, not be cut off.
|
||
func TestHelpWrapsOnNarrowTerminals(t *testing.T) {
|
||
m, _ := newTestModel(t)
|
||
key(t, m, "2")
|
||
m.Update(tea.WindowSizeMsg{Width: 90, Height: 30})
|
||
|
||
view := m.View()
|
||
if !strings.Contains(view, "q quit") {
|
||
t.Errorf("the end of the help line was lost at 90 columns:\n%s", view)
|
||
}
|
||
for _, line := range strings.Split(view, "\n") {
|
||
if len([]rune(line)) > 90 {
|
||
t.Errorf("line is %d columns wide, wider than the window:\n%q", len([]rune(line)), line)
|
||
}
|
||
}
|
||
}
|
||
|
||
// newEmptyModel builds a model over an index with nothing in it.
|
||
func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
|
||
t.Helper()
|
||
db, err := store.Open(filepath.Join(t.TempDir(), "index.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
|
||
m := New("/data/root", db, accounts, rules.New(&config.Rules{}), transfers.New(&config.Rules{}))
|
||
if err := m.reload(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
||
return m
|
||
}
|
||
|
||
// Configured but never imported is the confusing case: the folders exist on
|
||
// disk, but nothing is registered in the index until an import runs.
|
||
func TestEmptyAccountsWithConfiguredFolders(t *testing.T) {
|
||
m := newEmptyModel(t, []*config.Account{
|
||
{Slug: "checking", Currency: "EUR", Parser: "revolut"},
|
||
{Slug: "savings", Currency: "EUR", Parser: "revolut"},
|
||
})
|
||
|
||
view := m.View()
|
||
for _, want := range []string{"No accounts imported yet", "2 account folder(s)", "/data/root", "Press i to import"} {
|
||
if !strings.Contains(view, want) {
|
||
t.Errorf("accounts view missing %q:\n%s", want, view)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEmptyAccountsWithNoFolders(t *testing.T) {
|
||
m := newEmptyModel(t, nil)
|
||
|
||
view := m.View()
|
||
for _, want := range []string{"No account folders found", "/data/root", "account.toml"} {
|
||
if !strings.Contains(view, want) {
|
||
t.Errorf("accounts view missing %q:\n%s", want, view)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEmptyTransactionsMessages(t *testing.T) {
|
||
m := newEmptyModel(t, nil)
|
||
key(t, m, "2")
|
||
|
||
if view := m.View(); !strings.Contains(view, "No transactions yet") {
|
||
t.Errorf("expected an empty-transactions message:\n%s", view)
|
||
}
|
||
|
||
// Each way of emptying the view explains itself differently.
|
||
m.filter.AccountSlug = "checking"
|
||
if err := m.reloadTxns(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if view := m.View(); !strings.Contains(view, "No transactions in checking") {
|
||
t.Errorf("expected an account-scoped message:\n%s", view)
|
||
}
|
||
|
||
m.filter.AccountSlug = ""
|
||
m.filter.Search = "lidl"
|
||
if err := m.reloadTxns(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if view := m.View(); !strings.Contains(view, "No transactions match /lidl") {
|
||
t.Errorf("expected a search message:\n%s", view)
|
||
}
|
||
|
||
m.filter.Search = ""
|
||
m.onlyUntagged = true
|
||
if err := m.reloadTxns(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if view := m.View(); !strings.Contains(view, "Nothing untagged") {
|
||
t.Errorf("expected an untagged message:\n%s", view)
|
||
}
|
||
}
|
||
|
||
func TestEmptyReportMessage(t *testing.T) {
|
||
m := newEmptyModel(t, nil)
|
||
key(t, m, "3")
|
||
if view := m.View(); !strings.Contains(view, "Nothing to report yet") {
|
||
t.Errorf("expected an empty-report message:\n%s", view)
|
||
}
|
||
}
|
||
|
||
// With data present, the tables render instead of the empty-state text.
|
||
func TestNoEmptyMessageWhenPopulated(t *testing.T) {
|
||
m, _ := newTestModel(t)
|
||
for _, k := range []string{"1", "2", "3"} {
|
||
key(t, m, k)
|
||
if got := m.emptyMessage(); got != "" {
|
||
t.Errorf("view %s showed an empty message with data present: %q", k, got)
|
||
}
|
||
}
|
||
key(t, m, "2")
|
||
if !strings.Contains(m.View(), "LIDL SOFIA") {
|
||
t.Error("expected the transactions table to render")
|
||
}
|
||
}
|
||
|
||
func findTxn(t *testing.T, db *store.DB, desc string) model.Transaction {
|
||
t.Helper()
|
||
txns, err := db.Transactions(store.Filter{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, txn := range txns {
|
||
if txn.Description == desc {
|
||
return txn
|
||
}
|
||
}
|
||
t.Fatalf("no transaction with description %q", desc)
|
||
return model.Transaction{}
|
||
}
|
||
|
||
// newTransferModel builds a model over a real data root holding one complete
|
||
// movement between two accounts, one leg whose counterpart never arrived, and
|
||
// ordinary spending that is neither.
|
||
func newTransferModel(t *testing.T) (*Model, *store.DB, string) {
|
||
t.Helper()
|
||
root := t.TempDir()
|
||
|
||
db, err := store.Open(config.IndexPath(root))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
|
||
checking, err := db.UpsertAccount(model.Account{
|
||
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
savings, err := db.UpsertAccount(model.Account{
|
||
Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sourceID, err := db.SourceFile(checking, "checking/st.csv", "sha", "2026-01-01T00:00:00Z")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
seed := []struct {
|
||
account int64
|
||
date string
|
||
desc string
|
||
amount int64
|
||
}{
|
||
{checking, "2026-03-01", "TRANSFER TO SAVINGS", -50000},
|
||
{savings, "2026-03-02", "TRANSFER FROM CHECKING", 50000},
|
||
{checking, "2026-04-01", "TRANSFER TO SAVINGS", -50000}, // never arrived
|
||
{checking, "2026-03-05", "LIDL SOFIA 4412", -2000},
|
||
}
|
||
for i, s := range seed {
|
||
if _, err := db.InsertTransaction(model.Transaction{
|
||
AccountID: s.account,
|
||
SourceFileID: sourceID,
|
||
Fingerprint: fmt.Sprintf("xfer-%d", i),
|
||
Date: s.date,
|
||
Description: s.desc,
|
||
AmountMinor: s.amount,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
m := New(root, db, []*config.Account{
|
||
{Slug: "checking", Currency: "EUR", Parser: "revolut"},
|
||
{Slug: "savings", Currency: "EUR", Parser: "revolut"},
|
||
}, rules.New(&config.Rules{}), transfers.New(&config.Rules{}))
|
||
if err := m.reload(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m.Update(tea.WindowSizeMsg{Width: 140, Height: 40})
|
||
return m, db, root
|
||
}
|
||
|
||
// fillTransfer types a whole definition into the builder's fields.
|
||
func fillTransfer(m *Model, from, fromDesc, to, toDesc string) {
|
||
m.transferFrom.SetValue(from)
|
||
m.transferFromDesc.SetValue(fromDesc)
|
||
m.transferTo.SetValue(to)
|
||
m.transferToDesc.SetValue(toDesc)
|
||
m.refreshTransferPreview()
|
||
}
|
||
|
||
// The preview answers the question the form is asking: what would this pair,
|
||
// and what would it catch and fail to pair?
|
||
func TestTransferBuilderPreviewsPairsAndOrphans(t *testing.T) {
|
||
m, _, _ := newTransferModel(t)
|
||
key(t, m, "6")
|
||
|
||
if m.view != viewTransfers {
|
||
t.Fatal("expected 6 to open the transfer builder")
|
||
}
|
||
|
||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||
if m.previewPairs != 1 || m.previewUnmatched != 1 {
|
||
t.Fatalf("preview = %d pairs, %d unpaired; want 1 and 1", m.previewPairs, m.previewUnmatched)
|
||
}
|
||
|
||
var paired, orphaned int
|
||
for _, row := range m.transferTable.Rows() {
|
||
switch row[0] {
|
||
case "▸":
|
||
paired++
|
||
if row[3] != "checking → savings" {
|
||
t.Errorf("movement = %q, want the direction spelled out", row[3])
|
||
}
|
||
case "⚠":
|
||
orphaned++
|
||
if row[1] != "2026-04-01" {
|
||
t.Errorf("unpaired row = %v, want the April leg", row)
|
||
}
|
||
}
|
||
}
|
||
if paired != 1 || orphaned != 1 {
|
||
t.Errorf("rows = %d paired, %d unpaired; want 1 and 1", paired, orphaned)
|
||
}
|
||
|
||
// A glob that catches nothing pairs nothing, rather than pairing loosely.
|
||
fillTransfer(m, "checking", "*NOTHING LIKE THIS*", "savings", "*FROM CHECKING*")
|
||
if m.previewPairs != 0 {
|
||
t.Errorf("pairs = %d, want none", m.previewPairs)
|
||
}
|
||
}
|
||
|
||
// Saving writes the definition and pairs immediately, and the report stops
|
||
// counting the pair as spending.
|
||
func TestTransferBuilderSavesAndPairs(t *testing.T) {
|
||
m, db, root := newTransferModel(t)
|
||
key(t, m, "6")
|
||
|
||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||
m.setTransferFocus(4)
|
||
m.transferNote.SetValue("monthly saving")
|
||
key(t, m, "enter")
|
||
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(loaded.Transfer) != 1 {
|
||
t.Fatalf("rules.toml holds %+v", loaded.Transfer)
|
||
}
|
||
if got := loaded.Transfer[0]; got.FromAccount != "checking" || got.ToDesc != "*FROM CHECKING*" ||
|
||
got.Note != "monthly saving" {
|
||
t.Errorf("saved transfer = %+v", got)
|
||
}
|
||
|
||
txns, err := db.Transactions(store.Filter{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
legs := 0
|
||
for _, txn := range txns {
|
||
if txn.IsTransferLeg() {
|
||
legs++
|
||
}
|
||
// The April leg never arrived, so it is not part of a transfer.
|
||
if txn.Date == "2026-04-01" && txn.IsTransferLeg() {
|
||
t.Error("an unpaired leg must not be recorded as a transfer")
|
||
}
|
||
}
|
||
if legs != 2 {
|
||
t.Errorf("%d legs paired in the index, want 2", legs)
|
||
}
|
||
|
||
// The paired movement is out of the report; the unpaired leg is not.
|
||
var out int64
|
||
for _, r := range report.ByTag(txns) {
|
||
out += r.Out
|
||
}
|
||
if want := int64(52000); out != want {
|
||
t.Errorf("outflow in the report = %d, want %d: the pair excluded, the orphan kept", out, want)
|
||
}
|
||
|
||
// The two accounts were kept for the next definition, the globs cleared.
|
||
if m.transferFrom.Value() != "checking" || m.transferFromDesc.Value() != "" {
|
||
t.Errorf("after saving: from = %q, from_desc = %q",
|
||
m.transferFrom.Value(), m.transferFromDesc.Value())
|
||
}
|
||
if !strings.Contains(m.status, "1 leg(s) unpaired") {
|
||
t.Errorf("status = %q, want the unpaired leg reported", m.status)
|
||
}
|
||
}
|
||
|
||
// A half-written definition is refused, with the missing side named.
|
||
func TestTransferBuilderRejectsIncomplete(t *testing.T) {
|
||
m, _, root := newTransferModel(t)
|
||
key(t, m, "6")
|
||
|
||
m.transferFrom.SetValue("checking")
|
||
m.transferFromDesc.SetValue("*TO SAVINGS*")
|
||
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||
if m.err == nil {
|
||
t.Fatal("expected saving half a transfer to fail")
|
||
}
|
||
if !strings.Contains(m.err.Error(), "arrives") {
|
||
t.Errorf("error = %v, want it to name the missing side", m.err)
|
||
}
|
||
|
||
// An unknown account is refused too, before anything is written.
|
||
m.err = nil
|
||
fillTransfer(m, "checking", "*TO SAVINGS*", "nosuchaccount", "*FROM CHECKING*")
|
||
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||
if m.err == nil || !strings.Contains(m.err.Error(), "nosuchaccount") {
|
||
t.Errorf("error = %v, want the unknown account named", m.err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) {
|
||
t.Error("a rejected transfer must not write rules.toml")
|
||
}
|
||
}
|
||
|
||
// The builder is a form, so single-letter global keys are ordinary text.
|
||
func TestTransferBuilderSwallowsGlobalKeys(t *testing.T) {
|
||
m, _, _ := newTransferModel(t)
|
||
key(t, m, "6")
|
||
|
||
typeText(t, m, "q1i")
|
||
if m.view != viewTransfers {
|
||
t.Fatal("typing must not switch views")
|
||
}
|
||
if got := m.transferFrom.Value(); got != "q1i" {
|
||
t.Errorf("from account = %q, want the typed characters", got)
|
||
}
|
||
if m.importing {
|
||
t.Error("typing i must not start an import")
|
||
}
|
||
}
|
||
|
||
// The list is the screen that finds the problems: what pairs, what does not,
|
||
// and what matches nothing at all.
|
||
func TestTransferListShowsPairingAndUnpaired(t *testing.T) {
|
||
m, _, root := newTransferModel(t)
|
||
if err := config.AppendTransfer(root, config.Transfer{
|
||
FromAccount: "checking", FromDesc: "*TO SAVINGS*",
|
||
ToAccount: "savings", ToDesc: "*FROM CHECKING*",
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := config.AppendTransfer(root, config.Transfer{
|
||
FromAccount: "checking", FromDesc: "*TO NOWHERE*",
|
||
ToAccount: "savings", ToDesc: "*FROM NOWHERE*", Note: "dead",
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := m.reloadConfig(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
key(t, m, "7")
|
||
if m.view != viewTransferList {
|
||
t.Fatal("expected 7 to open the transfer list")
|
||
}
|
||
|
||
rows := m.transferListTable.Rows()
|
||
if len(rows) != 2 {
|
||
t.Fatalf("rows = %v, want one per definition", rows)
|
||
}
|
||
if rows[0][1] != "⚠" || rows[0][4] != "1" || rows[0][5] != "1" {
|
||
t.Errorf("first row = %v, want 1 pair, 1 unpaired and a warning marker", rows[0])
|
||
}
|
||
if rows[1][1] != "✗" || rows[1][4] != "0" || rows[1][5] != "0" {
|
||
t.Errorf("second row = %v, want it marked as matching nothing", rows[1])
|
||
}
|
||
if !strings.Contains(m.title(), "1 leg(s) unpaired") {
|
||
t.Errorf("title = %q, want the unpaired leg counted", m.title())
|
||
}
|
||
|
||
// Pruning takes the definition that matches nothing, and leaves the one
|
||
// that is merely unpaired: that one is doing something.
|
||
if got := m.unusedTransfers(); len(got) != 1 || got[0] != 1 {
|
||
t.Fatalf("unused = %v, want only the dead definition", got)
|
||
}
|
||
key(t, m, "p")
|
||
if m.confirm != confirmPruneTransfers {
|
||
t.Fatal("prune must ask first")
|
||
}
|
||
key(t, m, "y")
|
||
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(loaded.Transfer) != 1 || loaded.Transfer[0].FromDesc != "*TO SAVINGS*" {
|
||
t.Errorf("transfers = %+v, want the working one kept", loaded.Transfer)
|
||
}
|
||
}
|
||
|
||
// Deleting a definition takes its pairing with it, so the legs count again.
|
||
func TestTransferListDeleteUnpairs(t *testing.T) {
|
||
m, db, root := newTransferModel(t)
|
||
key(t, m, "6")
|
||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||
key(t, m, "enter")
|
||
|
||
// The builder is a form, so leaving it takes esc rather than a view key.
|
||
key(t, m, "esc")
|
||
key(t, m, "7")
|
||
key(t, m, "d")
|
||
if m.confirm != confirmDeleteTransfer {
|
||
t.Fatal("delete must ask first")
|
||
}
|
||
if !strings.Contains(m.status, "checking → savings") {
|
||
t.Errorf("prompt = %q, want it to name the transfer", m.status)
|
||
}
|
||
key(t, m, "y")
|
||
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(loaded.Transfer) != 0 {
|
||
t.Fatalf("transfers = %+v, want it gone", loaded.Transfer)
|
||
}
|
||
|
||
txns, err := db.Transactions(store.Filter{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, txn := range txns {
|
||
if txn.IsTransferLeg() {
|
||
t.Fatalf("%s is still recorded as a transfer leg", txn.Description)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Retagging re-derives both halves of what rules.toml decides.
|
||
func TestRetagAlsoRepairsThePairing(t *testing.T) {
|
||
m, db, root := newTransferModel(t)
|
||
if err := config.AppendTransfer(root, config.Transfer{
|
||
FromAccount: "checking", FromDesc: "*TO SAVINGS*",
|
||
ToAccount: "savings", ToDesc: "*FROM CHECKING*",
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := m.reloadConfig(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
key(t, m, "r")
|
||
if !strings.Contains(m.status, "1 transfers matched") {
|
||
t.Errorf("status = %q, want the pairing reported", m.status)
|
||
}
|
||
txns, err := db.Transactions(store.Filter{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
legs := 0
|
||
for _, txn := range txns {
|
||
if txn.IsTransferLeg() {
|
||
legs++
|
||
}
|
||
}
|
||
if legs != 2 {
|
||
t.Errorf("%d legs paired after retag, want 2", legs)
|
||
}
|
||
}
|
||
|
||
// Six fields need more room than four, so the transfer form gives up its
|
||
// spacing, then its hints, then the borders on the fields not being edited. No
|
||
// field may disappear at any height, and the one with the cursor in it keeps
|
||
// its box however tight things get.
|
||
func TestTransferFormFitsShortTerminals(t *testing.T) {
|
||
m, _, _ := newTransferModel(t)
|
||
key(t, m, "6")
|
||
|
||
for _, height := range []int{50, 44, 38, 34, 30, 28} {
|
||
m.Update(tea.WindowSizeMsg{Width: 140, Height: height})
|
||
form := m.transferFormView()
|
||
if lines := strings.Count(form, "\n") + 1; lines > height-6 {
|
||
t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6)
|
||
}
|
||
for _, label := range []string{
|
||
"from account", "from desc", "to account", "to desc", "tolerance %", "note",
|
||
} {
|
||
if !strings.Contains(form, label) {
|
||
t.Errorf("at height %d the %q field disappeared", height, label)
|
||
}
|
||
}
|
||
boxes := strings.Count(form, "╭")
|
||
if boxes != 6 && boxes != 1 {
|
||
t.Errorf("at height %d there are %d input boxes, want 6 or just the focused 1", height, boxes)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A paired leg has been accounted for, so it is not waiting for a tag: it must
|
||
// not turn up in the untagged view, nor in the rule builder's preview, which is
|
||
// the list of things still asking to be tagged.
|
||
func TestPairedLegsAreNotUntagged(t *testing.T) {
|
||
m, _, _ := newTransferModel(t)
|
||
key(t, m, "6")
|
||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||
key(t, m, "enter")
|
||
key(t, m, "esc")
|
||
|
||
key(t, m, "u")
|
||
var seen []string
|
||
for _, txn := range m.txns {
|
||
seen = append(seen, txn.Date+" "+txn.Description)
|
||
}
|
||
want := []string{"2026-04-01 TRANSFER TO SAVINGS", "2026-03-05 LIDL SOFIA 4412"}
|
||
if len(seen) != len(want) {
|
||
t.Fatalf("untagged = %v, want %v: only the unpaired leg and the shopping", seen, want)
|
||
}
|
||
for i, w := range want {
|
||
if seen[i] != w {
|
||
t.Errorf("untagged[%d] = %q, want %q", i, seen[i], w)
|
||
}
|
||
}
|
||
|
||
// The rule builder previews the same set.
|
||
key(t, m, "4")
|
||
all, _ := previewRows(m)
|
||
if len(all) != 2 {
|
||
t.Errorf("preview = %v, want the paired legs left out", all)
|
||
}
|
||
for _, desc := range all {
|
||
if desc == "TRANSFER FROM CHECKING" {
|
||
t.Error("the arriving leg of a matched transfer is still offered for tagging")
|
||
}
|
||
}
|
||
}
|
||
|
||
// An exchange between two of your own accounts pairs on the dates alone, and
|
||
// the preview shows both amounts: they are the only place the rate appears.
|
||
func TestTransferBuilderShowsBothSidesOfAnExchange(t *testing.T) {
|
||
m, db, _ := newTransferModel(t)
|
||
|
||
bgn, err := db.UpsertAccount(model.Account{
|
||
Slug: "revolut", Name: "Revolut BGN", Currency: "BGN", MinorDigits: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
source, err := db.SourceFile(bgn, "revolut/st.csv", "sha", "2026-01-01T00:00:00Z")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
accounts, err := db.Accounts()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var checking int64
|
||
for _, a := range accounts {
|
||
if a.Slug == "checking" {
|
||
checking = a.ID
|
||
}
|
||
}
|
||
for _, leg := range []struct {
|
||
account int64
|
||
fp string
|
||
date string
|
||
desc string
|
||
amount int64
|
||
}{
|
||
{checking, "x-out", "2026-05-01", "TRANSFER TO REVOLUT", -50000}, // EUR
|
||
{bgn, "x-in", "2026-05-02", "TOP-UP FROM CHECKING", 97790},
|
||
} {
|
||
if _, err := db.InsertTransaction(model.Transaction{
|
||
AccountID: leg.account,
|
||
SourceFileID: source,
|
||
Fingerprint: leg.fp,
|
||
Date: leg.date,
|
||
Description: leg.desc,
|
||
AmountMinor: leg.amount,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
key(t, m, "6")
|
||
fillTransfer(m, "checking", "*TO REVOLUT*", "revolut", "*FROM CHECKING*")
|
||
|
||
if m.previewPairs != 1 || m.previewUnmatched != 0 {
|
||
t.Fatalf("preview = %d pairs, %d unpaired; want the exchange paired",
|
||
m.previewPairs, m.previewUnmatched)
|
||
}
|
||
var amount string
|
||
for _, row := range m.transferTable.Rows() {
|
||
if row[0] == "▸" {
|
||
amount = row[2]
|
||
}
|
||
}
|
||
if amount != "500.00 → 977.90" {
|
||
t.Errorf("amount = %q, want both sides of the exchange", amount)
|
||
}
|
||
|
||
// Saving it keeps both legs out of the report, each in its own currency.
|
||
key(t, m, "enter")
|
||
txns, err := db.Transactions(store.Filter{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
excluded := report.Excluded(txns)
|
||
if len(excluded) != 2 {
|
||
t.Fatalf("excluded = %+v, want a row per currency", excluded)
|
||
}
|
||
for _, x := range excluded {
|
||
if x.Legs != 1 {
|
||
t.Errorf("%s row = %+v, want the one leg it saw", x.Currency, x)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A paired leg reads as accounted for in the tag column rather than as a blank,
|
||
// but the label is display only: rule_tag stays what rules.toml made it, so
|
||
// retagging is still safe to run at any time.
|
||
func TestTransferLegsDisplayAsTags(t *testing.T) {
|
||
m, db, _ := newTransferModel(t)
|
||
key(t, m, "6")
|
||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||
key(t, m, "enter")
|
||
key(t, m, "esc")
|
||
|
||
var tags []string
|
||
for _, row := range m.txnTable.Rows() {
|
||
tags = append(tags, row[3])
|
||
}
|
||
// Newest first: the unpaired April leg, the shopping, then the two legs of
|
||
// the March movement.
|
||
want := []string{"", "", model.TransferTag, model.TransferTag}
|
||
if len(tags) != len(want) {
|
||
t.Fatalf("tags = %v, want %v", tags, want)
|
||
}
|
||
for i, w := range want {
|
||
if tags[i] != w {
|
||
t.Errorf("row %d (%s) tag = %q, want %q", i, m.txns[i].Description, tags[i], w)
|
||
}
|
||
}
|
||
|
||
// Nothing was written: the index still holds no tag for either leg, and the
|
||
// label is not offered as a tag to complete against.
|
||
txns, err := db.Transactions(store.Filter{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, txn := range txns {
|
||
if txn.RuleTag != "" {
|
||
t.Errorf("%s has rule_tag %q; a transfer must never write one",
|
||
txn.Description, txn.RuleTag)
|
||
}
|
||
}
|
||
known, err := m.knownTags()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, tag := range known {
|
||
if tag == model.TransferTag {
|
||
t.Error("the transfer label must not be offered as a tag to write rules with")
|
||
}
|
||
}
|
||
|
||
// A rule tag wins where there is one, since it is the user's own word.
|
||
key(t, m, "4")
|
||
m.ruleGlob.SetValue("*TO SAVINGS*")
|
||
m.setRuleFocus(2)
|
||
m.ruleTag.SetValue("saving")
|
||
key(t, m, "enter")
|
||
key(t, m, "esc")
|
||
for i, txn := range m.txns {
|
||
if txn.Description == "TRANSFER TO SAVINGS" && m.txnTable.Rows()[i][3] != "saving" {
|
||
t.Errorf("tag = %q, want the rule's own tag", m.txnTable.Rows()[i][3])
|
||
}
|
||
}
|
||
}
|
||
|
||
// A route where the bank takes a fee is the reason tolerance_pct exists: the
|
||
// builder must write it, pair on it, and say what it is admitting -- and the
|
||
// report must then account for the difference, since the pair leaves it.
|
||
func TestTransferBuilderTolerance(t *testing.T) {
|
||
m, db, root := newTransferModel(t)
|
||
|
||
checking, err := db.UpsertAccount(model.Account{
|
||
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
savings, err := db.UpsertAccount(model.Account{
|
||
Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sourceID, err := db.SourceFile(checking, "checking/wire.csv", "sha2", "2026-01-01T00:00:00Z")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 500.00 leaves, 495.00 arrives: the bank kept 5.00 on the way.
|
||
fee := []struct {
|
||
account int64
|
||
date string
|
||
desc string
|
||
amount int64
|
||
}{
|
||
{checking, "2026-05-01", "WIRE TO SAVINGS", -50000},
|
||
{savings, "2026-05-02", "WIRE FROM CHECKING", 49500},
|
||
}
|
||
for i, s := range fee {
|
||
if _, err := db.InsertTransaction(model.Transaction{
|
||
AccountID: s.account,
|
||
SourceFileID: sourceID,
|
||
Fingerprint: fmt.Sprintf("wire-%d", i),
|
||
Date: s.date,
|
||
Description: s.desc,
|
||
AmountMinor: s.amount,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if err := m.reload(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
key(t, m, "6")
|
||
fillTransfer(m, "checking", "*WIRE TO SAVINGS*", "savings", "*WIRE FROM CHECKING*")
|
||
if m.previewPairs != 0 {
|
||
t.Fatalf("previewPairs = %d, want 0 before a tolerance is given", m.previewPairs)
|
||
}
|
||
|
||
m.transferTolerance.SetValue("1")
|
||
m.refreshTransferPreview()
|
||
if m.previewPairs != 1 || m.previewFees != 500 {
|
||
t.Fatalf("preview = %d pairs, %d in fees; want 1 and 500",
|
||
m.previewPairs, m.previewFees)
|
||
}
|
||
// Both amounts show, exactly as they do for an exchange: the fee is the
|
||
// thing to eyeball before saving.
|
||
if want := "500.00 → 495.00"; !strings.Contains(m.transferTable.View(), want) {
|
||
t.Errorf("preview table does not show %q:\n%s", want, m.transferTable.View())
|
||
}
|
||
|
||
key(t, m, "enter")
|
||
if m.err != nil {
|
||
t.Fatalf("saving: %v", m.err)
|
||
}
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(loaded.Transfer) != 1 || loaded.Transfer[0].TolerancePct != 1 {
|
||
t.Fatalf("transfers = %+v, want the tolerance written", loaded.Transfer)
|
||
}
|
||
// Saving clears it: a tolerance carried into the next definition would
|
||
// loosen a route that never asked for one.
|
||
if v := m.transferTolerance.Value(); v != "" {
|
||
t.Errorf("tolerance field = %q after saving, want it cleared", v)
|
||
}
|
||
|
||
// The pair is gone from the report, so the fee it took has to be named.
|
||
var rows []string
|
||
for _, r := range m.reportTable.Rows() {
|
||
rows = append(rows, strings.Join(r, " "))
|
||
}
|
||
joined := strings.Join(rows, "\n")
|
||
if !strings.Contains(joined, transfersRow) {
|
||
t.Errorf("report has no transfers row:\n%s", joined)
|
||
}
|
||
if !strings.Contains(joined, feesRow) || !strings.Contains(joined, "5.00") {
|
||
t.Errorf("report does not account for the 5.00 fee:\n%s", joined)
|
||
}
|
||
}
|
||
|
||
// A tolerance that is not a number is refused on save rather than written out
|
||
// as a silent zero -- but it must not stop the preview updating as it is typed.
|
||
func TestTransferBuilderRejectsABadTolerance(t *testing.T) {
|
||
m, _, root := newTransferModel(t)
|
||
key(t, m, "6")
|
||
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||
|
||
m.transferTolerance.SetValue("1.")
|
||
m.refreshTransferPreview() // must not panic or wipe the preview
|
||
if m.previewPairs != 1 {
|
||
t.Errorf("previewPairs = %d, want the preview to survive a half-typed number", m.previewPairs)
|
||
}
|
||
|
||
m.transferTolerance.SetValue("a lot")
|
||
m.Update(tea.KeyMsg{Type: tea.KeyEnter}) // not key(), which fails on m.err
|
||
if m.err == nil {
|
||
t.Fatal("expected saving to be refused")
|
||
}
|
||
if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) {
|
||
t.Error("a rejected transfer must not be written")
|
||
}
|
||
}
|
||
|
||
// The list is where a definition is judged, and a tolerance changes what its
|
||
// counts mean: those pairs were matched on slack rather than on the amount, so
|
||
// the column has to say which ones.
|
||
func TestTransferListShowsTheTolerance(t *testing.T) {
|
||
m, _, root := newTransferModel(t)
|
||
if err := config.AppendTransfer(root, config.Transfer{
|
||
FromAccount: "checking", FromDesc: "*TO SAVINGS*",
|
||
ToAccount: "savings", ToDesc: "*FROM CHECKING*",
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := config.AppendTransfer(root, config.Transfer{
|
||
FromAccount: "checking", FromDesc: "*WIRE TO SAVINGS*",
|
||
ToAccount: "savings", ToDesc: "*WIRE FROM CHECKING*",
|
||
TolerancePct: 1.5,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := m.reloadConfig(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
key(t, m, "7")
|
||
rows := m.transferListTable.Rows()
|
||
if len(rows) != 2 {
|
||
t.Fatalf("rows = %v, want one per definition", rows)
|
||
}
|
||
// Blank, not "0%": every definition has the default, and printing it down
|
||
// the column would bury the row where amounts may actually disagree.
|
||
if rows[0][6] != "" {
|
||
t.Errorf("strict row shows tolerance %q, want it blank", rows[0][6])
|
||
}
|
||
if rows[1][6] != "1.5%" {
|
||
t.Errorf("tolerant row shows %q, want 1.5%%", rows[1][6])
|
||
}
|
||
}
|
||
|
||
// Once a glob is typed the preview is the rule's answer, not a list to search
|
||
// by eye: the rows that do not match go, and the count says what they were
|
||
// chosen out of, since the rows on screen can no longer say it themselves.
|
||
func TestRuleBuilderPreviewShowsOnlyMatches(t *testing.T) {
|
||
m, _, _ := newRuleModel(t)
|
||
key(t, m, "4")
|
||
|
||
// With no glob there is nothing to filter by, so the screen answers its
|
||
// other question: everything still waiting for a rule.
|
||
if all, _ := previewRows(m); len(all) != 4 {
|
||
t.Fatalf("preview = %v, want every untagged description before a glob", all)
|
||
}
|
||
if !strings.Contains(m.ruleFormView(), "4 untagged descriptions") {
|
||
t.Errorf("summary = %q, want the untagged count", m.ruleFormView())
|
||
}
|
||
|
||
typeText(t, m, "*LIDL*")
|
||
all, matched := previewRows(m)
|
||
if len(all) != 2 || len(matched) != 2 {
|
||
t.Fatalf("preview = %v (matched %v), want only the two LIDL rows", all, matched)
|
||
}
|
||
for _, desc := range all {
|
||
if !strings.Contains(desc, "LIDL") {
|
||
t.Errorf("preview kept a non-matching row %q", desc)
|
||
}
|
||
}
|
||
// Two of the four still in view, not two of the two left on screen.
|
||
if m.ruleCandidates != 4 {
|
||
t.Errorf("candidates = %d, want 4", m.ruleCandidates)
|
||
}
|
||
if !strings.Contains(m.ruleFormView(), "2 of 4 descriptions match") {
|
||
t.Errorf("summary = %q, want the glob's selectivity", m.ruleFormView())
|
||
}
|
||
|
||
// A glob that matches nothing empties the list, and says so rather than
|
||
// leaving rows on screen that the rule would not claim.
|
||
m.ruleGlob.SetValue("*NOTHING*")
|
||
m.refreshRulePreview()
|
||
if all, _ := previewRows(m); len(all) != 0 {
|
||
t.Errorf("preview = %v, want nothing", all)
|
||
}
|
||
if !strings.Contains(m.ruleFormView(), "0 of 4 descriptions match") {
|
||
t.Errorf("summary = %q, want 0 of 4", m.ruleFormView())
|
||
}
|
||
}
|
||
|
||
// loadRulesFile puts a rules.toml under the model's root and makes the model
|
||
// read it, as though it had been there all along.
|
||
func loadRulesFile(t *testing.T, m *Model, body string) {
|
||
t.Helper()
|
||
if err := os.WriteFile(filepath.Join(m.root, config.RulesFile), []byte(body), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := m.reloadConfig(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := m.engine.Retag(m.db); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := m.reload(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
const editableRules = `[[rule]]
|
||
match = "*ZZZ*"
|
||
tag = "misc"
|
||
|
||
# The weekly shop.
|
||
[[rule]]
|
||
match = "*LIDL*"
|
||
tag = "groceries"
|
||
note = "both branches"
|
||
`
|
||
|
||
// Editing rewrites the rule where it sits rather than appending a second,
|
||
// almost identical one, and lands back on the list it started from.
|
||
func TestRuleListEditsRuleInPlace(t *testing.T) {
|
||
m, db, root := newRuleModel(t)
|
||
loadRulesFile(t, m, editableRules)
|
||
|
||
key(t, m, "5")
|
||
m.ruleListTable.SetCursor(1)
|
||
key(t, m, "e")
|
||
|
||
if m.view != viewRules || !m.ruleEditing {
|
||
t.Fatalf("e opened view %v (editing %v), want the builder on the rule", m.view, m.ruleEditing)
|
||
}
|
||
if m.ruleGlob.Value() != "*LIDL*" || m.ruleTag.Value() != "groceries" || m.ruleNote.Value() != "both branches" {
|
||
t.Fatalf("form = %q / %q / %q, want the rule filled in",
|
||
m.ruleGlob.Value(), m.ruleTag.Value(), m.ruleNote.Value())
|
||
}
|
||
|
||
m.ruleTag.SetValue("food")
|
||
key(t, m, "enter")
|
||
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := config.Rule{Match: "*LIDL*", Tag: "food", Note: "both branches"}
|
||
if len(loaded.Rule) != 2 {
|
||
t.Fatalf("rules = %+v, want the same 2: an edit must not append", loaded.Rule)
|
||
}
|
||
if loaded.Rule[0].Match != "*ZZZ*" {
|
||
t.Errorf("rule 1 = %+v, want it where it was", loaded.Rule[0])
|
||
}
|
||
if loaded.Rule[1] != want {
|
||
t.Errorf("rule 2 = %+v, want %+v", loaded.Rule[1], want)
|
||
}
|
||
|
||
// The file is edited textually, so the comment documenting the rule stays.
|
||
raw, err := os.ReadFile(filepath.Join(root, config.RulesFile))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(string(raw), "# The weekly shop.") {
|
||
t.Errorf("the rule's comment was lost:\n%s", raw)
|
||
}
|
||
|
||
// Saving retags immediately, as it does for a new rule.
|
||
txns, err := db.Transactions(store.Filter{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tagged := 0
|
||
for _, txn := range txns {
|
||
if txn.RuleTag == "food" {
|
||
tagged++
|
||
}
|
||
}
|
||
if tagged != 3 {
|
||
t.Errorf("%d transactions tagged food, want 3", tagged)
|
||
}
|
||
|
||
// An edit is a round trip: it ends on the list, with the new counts.
|
||
if m.view != viewRuleList {
|
||
t.Fatalf("view = %v after saving, want the rules list", m.view)
|
||
}
|
||
if rows := m.ruleListTable.Rows(); len(rows) != 2 || rows[1][4] != "food" {
|
||
t.Errorf("rows = %v, want the edited tag on the list", rows)
|
||
}
|
||
if !strings.Contains(m.status, "rule 2 is now") {
|
||
t.Errorf("status = %q, want it to name the rule that changed", m.status)
|
||
}
|
||
}
|
||
|
||
// The preview lists what the rule already claims, not only what is untagged:
|
||
// for a rule that works those are the same rows, and an empty preview would say
|
||
// nothing about the edit. Narrowing the glob then shows what it gives up.
|
||
func TestRuleEditPreviewsWhatItDrops(t *testing.T) {
|
||
m, _, _ := newRuleModel(t)
|
||
loadRulesFile(t, m, editableRules)
|
||
|
||
key(t, m, "5")
|
||
m.ruleListTable.SetCursor(1)
|
||
key(t, m, "e")
|
||
|
||
all, matched := previewRows(m)
|
||
if len(all) != 2 || len(matched) != 2 {
|
||
t.Fatalf("preview = %v (matched %v), want the two descriptions the rule claims", all, matched)
|
||
}
|
||
|
||
m.ruleGlob.SetValue("*SOFIA*")
|
||
m.refreshRulePreview()
|
||
|
||
all, matched = previewRows(m)
|
||
if len(matched) != 1 || matched[0] != "LIDL SOFIA 4412" {
|
||
t.Errorf("matched = %v, want only the Sofia branch", matched)
|
||
}
|
||
// The row it stops claiming stays on screen: losing one is the thing worth
|
||
// seeing before saving, so it is marked rather than dropped silently.
|
||
if len(all) != 2 || m.ruleDropped != 1 {
|
||
t.Fatalf("preview = %v, dropped = %d; want the Varna row kept and counted", all, m.ruleDropped)
|
||
}
|
||
var dropped []string
|
||
for _, row := range m.ruleTable.Rows() {
|
||
if row[0] == "−" {
|
||
dropped = append(dropped, row[1])
|
||
}
|
||
}
|
||
if len(dropped) != 1 || dropped[0] != "LIDL VARNA 9911" {
|
||
t.Errorf("dropped rows = %v, want the Varna branch", dropped)
|
||
}
|
||
if !strings.Contains(m.ruleFormView(), "1 no longer claimed") {
|
||
t.Errorf("summary = %q, want the dropped count", m.ruleFormView())
|
||
}
|
||
}
|
||
|
||
// The form has no type field, so an edit carries the pattern through instead of
|
||
// silently widening the rule to every transaction the glob matches.
|
||
func TestRuleEditKeepsTypePattern(t *testing.T) {
|
||
m, _, root := newRuleModel(t)
|
||
loadRulesFile(t, m, "[[rule]]\nmatch = \"*ZZZ*\"\ntype = \"CARD_PAYMENT\"\ntag = \"misc\"\n")
|
||
|
||
key(t, m, "5")
|
||
key(t, m, "e")
|
||
|
||
if !strings.Contains(m.ruleFormView(), "type:CARD_PAYMENT") {
|
||
t.Errorf("form = %q, want it to name the type it is carrying", m.ruleFormView())
|
||
}
|
||
|
||
m.ruleTag.SetValue("cards")
|
||
key(t, m, "enter")
|
||
|
||
loaded, err := config.LoadRules(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := config.Rule{Match: "*ZZZ*", Type: "CARD_PAYMENT", Tag: "cards"}
|
||
if len(loaded.Rule) != 1 || loaded.Rule[0] != want {
|
||
t.Errorf("rules = %+v, want %+v", loaded.Rule, want)
|
||
}
|
||
}
|
||
|
||
// Leaving an edit must not leave the form holding that rule, or the next thing
|
||
// saved would be a near-duplicate of the rule just edited.
|
||
func TestNewRuleAfterAnEditStartsEmpty(t *testing.T) {
|
||
m, _, _ := newRuleModel(t)
|
||
loadRulesFile(t, m, editableRules)
|
||
|
||
key(t, m, "5")
|
||
m.ruleListTable.SetCursor(1)
|
||
key(t, m, "e")
|
||
key(t, m, "esc")
|
||
key(t, m, "4")
|
||
|
||
if m.ruleEditing {
|
||
t.Error("4 opened the builder still in edit mode")
|
||
}
|
||
for _, in := range m.ruleInputs() {
|
||
if in.Value() != "" {
|
||
t.Errorf("form field = %q, want a blank form for a new rule", in.Value())
|
||
}
|
||
}
|
||
}
|
||
|
||
// The builder is reached from the list, so it is not somewhere the list can
|
||
// return to: esc must walk back out, not bounce between the two screens.
|
||
func TestEscFromRuleListDoesNotBounceIntoTheBuilder(t *testing.T) {
|
||
m, _, _ := newRuleModel(t)
|
||
loadRulesFile(t, m, editableRules)
|
||
|
||
key(t, m, "2")
|
||
key(t, m, "5")
|
||
key(t, m, "e")
|
||
key(t, m, "esc")
|
||
if m.view != viewRuleList {
|
||
t.Fatalf("esc from the builder went to %v, want the list it was opened from", m.view)
|
||
}
|
||
key(t, m, "esc")
|
||
if m.view != viewTxns {
|
||
t.Errorf("esc from the list went to %v, want the transactions it was opened from", m.view)
|
||
}
|
||
}
|