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{} }