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