An account folder is only registered in the index by an import, so a freshly configured account.toml produced an empty accounts table with nothing to say why or what to do about it. Each view now explains its own emptiness and names the key that resolves it, distinguishing configured-but-not-imported from no-folders-at-all, and telling apart a filter that matched nothing from having no data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
377 lines
9.8 KiB
Go
377 lines
9.8 KiB
Go
package tui
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"git.petrovv.com/nikola/money/internal/config"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
// 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: "csv"},
|
|
{Slug: "savings", Currency: "EUR", Parser: "csv"},
|
|
})
|
|
|
|
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{}
|
|
}
|