counterparty was a structured field only nlb could fill honestly. revolut and traderepublic invented one by running an IBAN-shaped regex over the description they had just built, and the two spellings disagreed -- SI56 1234 5678 9012 345 against SI56123456789012345 -- so a literal rule pattern that worked on one account silently matched nothing on another. It is gone from the model, the index, the rule keys, ls --wide and the rules screen. nlb now appends its IBAN column to the end of the description, where the other two already keep theirs, so match = "*SI56*" works everywhere. That changes those descriptions and with them their fingerprints, so a statement overlapping an already-imported period will re-add rather than dedupe those rows until the index is rebuilt. An index built by an older binary drops the column when it is opened. The index itself moves from .money/index.db up to index.db beside rules.toml. Nothing looks in the old location, so an existing one has to be moved by hand -- otherwise the tool quietly starts a fresh index and the manual tags in the old file, the only thing statements cannot reproduce, stay behind in it. The csv and cmd parsers are gone along with the [csv] and [cmd] config they carried. cmd shelled out to the Python extractors, which were ported to Go and deleted, so it bridged to nothing; csv was a generic column-mapped fallback that no account used, and between them they were the largest configuration surface in the tool. A bank is now described in Go, where it can be tested. The importer tests register their own three-column parser rather than borrow a bank's, so they stay about the directory walk, dedupe and per-file error reporting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1224 lines
35 KiB
Go
1224 lines
35 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/rules"
|
|
"git.petrovv.com/nikola/money/internal/store"
|
|
)
|
|
|
|
// 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)
|
|
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 TestTagFlow(t *testing.T) {
|
|
m, db := newTestModel(t)
|
|
|
|
key(t, m, "2") // transactions view
|
|
cursorTo(t, m, "SOME RANDOM SHOP")
|
|
|
|
key(t, m, "t") // open the tag prompt
|
|
if m.input != inputTag {
|
|
t.Fatal("expected the tag prompt to open")
|
|
}
|
|
typeText(t, m, "hobby")
|
|
key(t, m, "enter")
|
|
|
|
if m.input != inputNone {
|
|
t.Error("expected the prompt to close after enter")
|
|
}
|
|
|
|
txns, err := db.Transactions(store.Filter{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var found bool
|
|
for _, txn := range txns {
|
|
if txn.Description == "SOME RANDOM SHOP" {
|
|
found = true
|
|
if txn.ManualTag != "hobby" {
|
|
t.Errorf("manual_tag = %q, want %q", txn.ManualTag, "hobby")
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("transaction disappeared after tagging")
|
|
}
|
|
|
|
// The tag column marks manual overrides with a trailing asterisk.
|
|
if !strings.Contains(m.View(), "hobby*") {
|
|
t.Error("expected the tagged row to render with a manual-override marker")
|
|
}
|
|
}
|
|
|
|
func TestTagPromptCancel(t *testing.T) {
|
|
m, db := newTestModel(t)
|
|
key(t, m, "2")
|
|
cursorTo(t, m, "SOME RANDOM SHOP")
|
|
|
|
key(t, m, "t")
|
|
typeText(t, m, "nope")
|
|
key(t, m, "esc")
|
|
|
|
if m.input != inputNone {
|
|
t.Error("expected esc to close the prompt")
|
|
}
|
|
txns, _ := db.Transactions(store.Filter{Untagged: true})
|
|
if len(txns) != 1 {
|
|
t.Errorf("esc should not have tagged anything; untagged count = %d, want 1", len(txns))
|
|
}
|
|
}
|
|
|
|
func TestTransferToggleAndClear(t *testing.T) {
|
|
m, db := newTestModel(t)
|
|
key(t, m, "2")
|
|
cursorTo(t, m, "LIDL SOFIA")
|
|
|
|
key(t, m, "x")
|
|
txn := findTxn(t, db, "LIDL SOFIA")
|
|
if !txn.IsTransfer() {
|
|
t.Error("expected x to mark the row as a transfer")
|
|
}
|
|
|
|
key(t, m, "x")
|
|
if findTxn(t, db, "LIDL SOFIA").IsTransfer() {
|
|
t.Error("expected a second x to unmark it")
|
|
}
|
|
|
|
// c drops the manual override, restoring the rule's verdict.
|
|
key(t, m, "c")
|
|
txn = findTxn(t, db, "LIDL SOFIA")
|
|
if txn.ManualTransfer != nil || txn.ManualTag != "" {
|
|
t.Errorf("expected c to clear overrides, got manual tag %q transfer %v", txn.ManualTag, txn.ManualTransfer)
|
|
}
|
|
if txn.Tag() != "groceries" {
|
|
t.Errorf("tag after clearing = %q, want groceries from the rule", txn.Tag())
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Tagging the last untagged row empties the view; the cursor must stay valid.
|
|
cursorTo(t, m, "SOME RANDOM SHOP")
|
|
key(t, m, "t")
|
|
typeText(t, m, "hobby")
|
|
key(t, m, "enter")
|
|
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))
|
|
}
|
|
}
|
|
|
|
func TestReportViewExcludesTransfers(t *testing.T) {
|
|
m, _ := newTestModel(t)
|
|
key(t, m, "2")
|
|
cursorTo(t, m, "LIDL SOFIA")
|
|
key(t, m, "x") // mark as a transfer
|
|
|
|
key(t, m, "3") // report view
|
|
view := m.View()
|
|
if strings.Contains(view, "groceries") {
|
|
t.Error("a transfer must not appear in the report")
|
|
}
|
|
if !strings.Contains(view, "TOTAL") {
|
|
t.Error("expected a TOTAL row in the report")
|
|
}
|
|
}
|
|
|
|
// 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{}))
|
|
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.Tag() == "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 := m.completionHint(2, "applied to matches"); !strings.Contains(hint, "tab") ||
|
|
!strings.Contains(hint, "1 more") {
|
|
t.Errorf("hint = %q, want it to name tab and the remaining candidate", hint)
|
|
}
|
|
}
|
|
|
|
// A tag invented in the builder is completable next time round.
|
|
func TestSavingARuleAddsItsTagToTheSuggestions(t *testing.T) {
|
|
m, _, _ := newRuleModel(t)
|
|
key(t, m, "4")
|
|
|
|
m.ruleGlob.SetValue("*LIDL*")
|
|
m.setRuleFocus(2)
|
|
m.ruleTag.SetValue("groceries")
|
|
key(t, m, "enter")
|
|
|
|
m.setRuleFocus(2) // saving resets the form to the glob field
|
|
typeText(t, m, "gro")
|
|
if got := m.ruleTag.CurrentSuggestion(); got != "groceries" {
|
|
t.Errorf("suggestion = %q, want the tag just saved", got)
|
|
}
|
|
}
|
|
|
|
// A prefix nothing matches must stay exactly as typed: the completion never
|
|
// invents a tag, and tab goes back to moving between fields.
|
|
func TestRuleBuilderKeepsUnmatchedInput(t *testing.T) {
|
|
m, _, _ := newRuleModel(t)
|
|
m.engine = rules.New(&config.Rules{Rule: []config.Rule{{Match: "*X*", Tag: "groceries"}}})
|
|
key(t, m, "4")
|
|
|
|
m.setRuleFocus(2)
|
|
typeText(t, m, "zzz")
|
|
m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
|
|
|
if m.ruleTag.Value() != "zzz" {
|
|
t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value())
|
|
}
|
|
if m.ruleFocus != 3 {
|
|
t.Errorf("focus = %d, want tab to move on to the note field", m.ruleFocus)
|
|
}
|
|
}
|
|
|
|
// A note is free text that rides along with the rule: written to rules.toml,
|
|
// read back, and shown on the rules screen so the reason survives longer than
|
|
// the memory of writing it.
|
|
func TestRuleBuilderSavesNote(t *testing.T) {
|
|
m, _, root := newRuleModel(t)
|
|
key(t, m, "4")
|
|
|
|
m.ruleGlob.SetValue("*LIDL*")
|
|
m.setRuleFocus(2)
|
|
m.ruleTag.SetValue("groceries")
|
|
m.setRuleFocus(3)
|
|
typeText(t, m, "the weekly shop")
|
|
key(t, m, "enter")
|
|
|
|
loaded, err := config.LoadRules(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(loaded.Rule) != 1 || loaded.Rule[0].Note != "the weekly shop" {
|
|
t.Fatalf("rules.toml holds %+v, want the note round-tripped", loaded.Rule)
|
|
}
|
|
// The note is documentation, not a pattern: it must not narrow what the
|
|
// rule catches.
|
|
if loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[0].Tag != "groceries" {
|
|
t.Errorf("rule = %+v, want the glob and tag untouched", loaded.Rule[0])
|
|
}
|
|
if m.ruleNote.Value() != "" {
|
|
t.Errorf("note = %q, want the field cleared for the next rule", m.ruleNote.Value())
|
|
}
|
|
|
|
m.openRuleList()
|
|
row := m.ruleListTable.Rows()[0]
|
|
if row[len(row)-1] != "the weekly shop" {
|
|
t.Errorf("rules screen row = %v, want the note in the last column", row)
|
|
}
|
|
}
|
|
|
|
// A rule without a note is still a rule; nothing about saving changes.
|
|
func TestRuleBuilderNoteIsOptional(t *testing.T) {
|
|
m, _, root := newRuleModel(t)
|
|
key(t, m, "4")
|
|
|
|
m.ruleGlob.SetValue("*LIDL*")
|
|
m.ruleTag.SetValue("groceries")
|
|
key(t, m, "enter")
|
|
|
|
raw, err := os.ReadFile(filepath.Join(root, config.RulesFile))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(raw), "note") {
|
|
t.Errorf("rules.toml = %q, want no empty note key", raw)
|
|
}
|
|
}
|
|
|
|
// The form has to survive a short window: four fields spaced out are taller
|
|
// than the room a 24-line terminal leaves.
|
|
func TestRuleFormFitsShortTerminals(t *testing.T) {
|
|
m, _, _ := newRuleModel(t)
|
|
key(t, m, "4")
|
|
|
|
for _, height := range []int{40, 32, 30, 26, 24} {
|
|
m.Update(tea.WindowSizeMsg{Width: 140, Height: height})
|
|
form := m.ruleFormView()
|
|
// The same budget the tables get: the title, status and a help line
|
|
// that may wrap onto a second row.
|
|
if lines := strings.Count(form, "\n") + 1; lines > height-6 {
|
|
t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6)
|
|
}
|
|
// However tight it gets, every field keeps its label and its box.
|
|
for _, label := range []string{"glob", "account", "tag", "note"} {
|
|
if !strings.Contains(form, label) {
|
|
t.Errorf("at height %d the %s field disappeared", height, label)
|
|
}
|
|
}
|
|
if boxes := strings.Count(form, "╭"); boxes != 4 {
|
|
t.Errorf("at height %d there are %d input boxes, want 4", height, boxes)
|
|
}
|
|
}
|
|
}
|
|
|
|
// newRuleListModel seeds a data root whose rules.toml holds one useful rule,
|
|
// one shadowed rule and one that matches nothing.
|
|
func newRuleListModel(t *testing.T) (*Model, string) {
|
|
t.Helper()
|
|
m, _, root := newRuleModel(t)
|
|
|
|
rulesTOML := `# My rules.
|
|
|
|
# The weekly shop.
|
|
[[rule]]
|
|
match = "*LIDL*"
|
|
tag = "groceries"
|
|
|
|
[[rule]]
|
|
match = "*LIDL SOFIA*"
|
|
tag = "shadowed"
|
|
|
|
[[rule]]
|
|
match = "*NEVER MATCHES ANYTHING*"
|
|
tag = "dead"
|
|
|
|
[[rule]]
|
|
match = "*ZARA*"
|
|
tag = "clothes"
|
|
`
|
|
if err := os.WriteFile(filepath.Join(root, config.RulesFile), []byte(rulesTOML), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loaded, err := config.LoadRules(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.engine = rules.New(loaded)
|
|
if _, err := m.engine.Retag(m.db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key(t, m, "5")
|
|
return m, root
|
|
}
|
|
|
|
func TestRuleListCountsUsage(t *testing.T) {
|
|
m, _ := newRuleListModel(t)
|
|
|
|
if m.view != viewRuleList {
|
|
t.Fatal("expected 5 to open the rule list")
|
|
}
|
|
|
|
// LIDL SOFIA x2 plus LIDL VARNA; the shadowed and dead rules claim nothing.
|
|
want := []int{3, 0, 0, 0}
|
|
if len(m.ruleUsage) != len(want) {
|
|
t.Fatalf("usage = %v, want %d entries", m.ruleUsage, len(want))
|
|
}
|
|
for i := range want {
|
|
if m.ruleUsage[i] != want[i] {
|
|
t.Errorf("rule %d used by %d, want %d", i+1, m.ruleUsage[i], want[i])
|
|
}
|
|
}
|
|
|
|
// Unused rules are marked so they can be picked out at a glance.
|
|
rows := m.ruleListTable.Rows()
|
|
if rows[0][1] != " " {
|
|
t.Errorf("the used rule is marked %q, want no marker", rows[0][1])
|
|
}
|
|
for _, i := range []int{1, 2, 3} {
|
|
if rows[i][1] != "✗" {
|
|
t.Errorf("rule %d marker = %q, want ✗", i+1, rows[i][1])
|
|
}
|
|
}
|
|
|
|
if !strings.Contains(m.View(), "3 match nothing") {
|
|
t.Errorf("expected the title to count the dead rules:\n%s", m.View())
|
|
}
|
|
}
|
|
|
|
// Deleting rewrites a hand-maintained file, so it takes a confirmation.
|
|
func TestRuleListDeleteNeedsConfirmation(t *testing.T) {
|
|
m, root := newRuleListModel(t)
|
|
|
|
m.ruleListTable.SetCursor(2) // the dead rule
|
|
key(t, m, "d")
|
|
if m.confirm != confirmDeleteRule {
|
|
t.Fatal("expected d to ask for confirmation")
|
|
}
|
|
if !strings.Contains(m.status, "delete rule 3") {
|
|
t.Errorf("status = %q, want the rule identified", m.status)
|
|
}
|
|
|
|
key(t, m, "n") // anything but y cancels
|
|
if m.confirm != confirmNone {
|
|
t.Error("expected the confirmation to clear")
|
|
}
|
|
loaded, _ := config.LoadRules(root)
|
|
if len(loaded.Rule) != 4 {
|
|
t.Errorf("cancelling deleted something: %d rules left", len(loaded.Rule))
|
|
}
|
|
}
|
|
|
|
func TestRuleListDeleteSelected(t *testing.T) {
|
|
m, root := newRuleListModel(t)
|
|
|
|
m.ruleListTable.SetCursor(1) // the shadowed rule
|
|
key(t, m, "d")
|
|
key(t, m, "y")
|
|
|
|
if m.err != nil {
|
|
t.Fatalf("delete failed: %v", m.err)
|
|
}
|
|
loaded, err := config.LoadRules(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(loaded.Rule) != 3 {
|
|
t.Fatalf("got %d rules, want 3: %+v", len(loaded.Rule), loaded.Rule)
|
|
}
|
|
for _, r := range loaded.Rule {
|
|
if r.Tag == "shadowed" {
|
|
t.Error("the selected rule is still in rules.toml")
|
|
}
|
|
}
|
|
// The on-screen counts are rebuilt from the new file.
|
|
if len(m.ruleUsage) != 3 {
|
|
t.Errorf("usage still has %d entries, want 3", len(m.ruleUsage))
|
|
}
|
|
}
|
|
|
|
func TestRuleListPruneUnused(t *testing.T) {
|
|
m, root := newRuleListModel(t)
|
|
|
|
key(t, m, "p")
|
|
if m.confirm != confirmPruneRules {
|
|
t.Fatal("expected p to ask for confirmation")
|
|
}
|
|
if !strings.Contains(m.status, "all 3 rules") {
|
|
t.Errorf("status = %q, want the count of dead rules", m.status)
|
|
}
|
|
key(t, m, "y")
|
|
|
|
if m.err != nil {
|
|
t.Fatalf("prune failed: %v", m.err)
|
|
}
|
|
loaded, err := config.LoadRules(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(loaded.Rule) != 1 || loaded.Rule[0].Tag != "groceries" {
|
|
t.Fatalf("remaining rules = %+v, want only the groceries rule", loaded.Rule)
|
|
}
|
|
|
|
// The rule that was doing work still tags what it did before.
|
|
txns, err := m.db.Transactions(store.Filter{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tagged := 0
|
|
for _, txn := range txns {
|
|
if txn.Tag() == "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"}},
|
|
} {
|
|
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 rule builder is a form, so it advertises its 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)
|
|
}
|
|
}
|
|
|
|
// 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{}))
|
|
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{}
|
|
}
|