File order decided precedence, so a narrow rule had to be written above the broad one it carves an exception out of -- an ordering constraint the file cannot show and the user has to remember. *NIKOLA* below *NIK* silently matched nothing, and a catch-all * could only ever be the last line. Engine.New now sorts once and MatchIndex walks that order: most literal characters first, then fewest *, then account-scoped over unscoped. Literals are what a rule commits to and a * is what it gives up, so a bare * is tried last wherever it sits. The sort is stable, so equally specific rules keep file order and the earlier one wins -- which is all position decides now, and why AppendRule can keep appending without displacing a rule written by hand. The two orders must not be confused: Rules(), Usage and MatchIndex still speak in file positions, because that is what the rules screen numbers and what DeleteRules deletes by. A shadowed rule still reports zero usage, but a zero no longer says anything about where the rule sits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1917 lines
57 KiB
Go
1917 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"
|
|
|
|
# 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())
|
|
}
|
|
}
|