Import ran inline in the update loop, so the whole interface froze for as long as pdftotext took over a stack of statements, with no way to tell work in progress apart from a hang. It now runs as a command off the event loop, with a spinner and a note that PDFs take a while. A repeat i press is ignored and retag is refused while an import is running, so nothing writes to the index concurrently. The result arrives as a message carrying the counts, the first failure, and the first warning with a count of any others. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
500 lines
14 KiB
Go
500 lines
14 KiB
Go
package tui
|
|
|
|
import (
|
|
"errors"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// 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{}
|
|
}
|