Add a rule builder screen with a live glob preview
Writing a glob by hand meant guessing what it would catch, then running retag to find out. The new screen, on 4, puts the glob, account and tag fields on the left and every still-untagged description on the right, sorted alphabetically and grouped by description with an occurrence count. Matches are marked as the glob is typed, along with a count, so the effect of a rule is visible before it is written. Enter appends it to rules.toml via config.AppendRule, reloads the engine from disk and retags, so the rows it caught leave the list immediately. Rules are appended rather than prepended, keeping the precedence of anything already in the file. Since the preview only lists transactions no existing rule has tagged, it reflects that precedence for free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+342
-4
@@ -4,6 +4,7 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
"git.petrovv.com/nikola/money/internal/glob"
|
||||
"git.petrovv.com/nikola/money/internal/importer"
|
||||
"git.petrovv.com/nikola/money/internal/model"
|
||||
"git.petrovv.com/nikola/money/internal/report"
|
||||
@@ -26,6 +28,9 @@ const (
|
||||
viewAccounts view = iota
|
||||
viewTxns
|
||||
viewReport
|
||||
viewRules
|
||||
|
||||
viewCount = 4
|
||||
)
|
||||
|
||||
// input is the modal state: the transaction list is read-only until the user
|
||||
@@ -60,6 +65,17 @@ type Model struct {
|
||||
|
||||
txns []model.Transaction // rows currently shown in txnTable
|
||||
|
||||
// Rule builder: three inputs on the left, and a live preview on the right
|
||||
// of which untagged descriptions the glob would catch.
|
||||
ruleGlob textinput.Model
|
||||
ruleAccount textinput.Model
|
||||
ruleTag textinput.Model
|
||||
ruleFocus int // which of the three inputs has the cursor
|
||||
ruleTable table.Model
|
||||
ruleReturn view // the view to go back to on esc
|
||||
untagged []descGroup
|
||||
ruleMatches int // untagged descriptions the current glob matches
|
||||
|
||||
filter store.Filter
|
||||
onlyUntagged bool
|
||||
|
||||
@@ -75,6 +91,19 @@ var (
|
||||
errStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")).Padding(0, 1)
|
||||
helpStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
|
||||
emptyStyle = lipgloss.NewStyle().Padding(1, 2)
|
||||
|
||||
labelStyle = lipgloss.NewStyle().Faint(true)
|
||||
focusedLabelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62"))
|
||||
// Both boxes are pinned to the same width: a focused input renders one
|
||||
// extra cell for the cursor, which would otherwise shift the border as
|
||||
// the cursor moves between fields.
|
||||
boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).Padding(0, 1).Width(ruleInputWidth + 2)
|
||||
focusedBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("62")).Padding(0, 1).Width(ruleInputWidth + 2)
|
||||
hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2)
|
||||
matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2)
|
||||
ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth)
|
||||
)
|
||||
|
||||
// Run starts the interface.
|
||||
@@ -93,6 +122,15 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
||||
ti.Prompt = ""
|
||||
ti.CharLimit = 64
|
||||
|
||||
newInput := func(placeholder string) textinput.Model {
|
||||
in := textinput.New()
|
||||
in.Prompt = ""
|
||||
in.CharLimit = 80
|
||||
in.Width = ruleInputWidth
|
||||
in.Placeholder = placeholder
|
||||
return in
|
||||
}
|
||||
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
|
||||
@@ -128,6 +166,14 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
||||
{Title: "T", Width: 1},
|
||||
{Title: "Description", Width: 40},
|
||||
}),
|
||||
ruleGlob: newInput("*LIDL*"),
|
||||
ruleAccount: newInput("blank = every account"),
|
||||
ruleTag: newInput("groceries"),
|
||||
ruleTable: newTable([]table.Column{
|
||||
{Title: " ", Width: 1},
|
||||
{Title: "Untagged description", Width: 44},
|
||||
{Title: "N", Width: 4},
|
||||
}),
|
||||
reportTable: newTable([]table.Column{
|
||||
{Title: "Tag", Width: 20},
|
||||
{Title: "Cur", Width: 4},
|
||||
@@ -236,6 +282,196 @@ func (m *Model) reloadReport(txns []model.Transaction) {
|
||||
m.reportTable.SetRows(out)
|
||||
}
|
||||
|
||||
// ruleFormWidth is the width of the rule builder's left-hand form, and
|
||||
// ruleInputWidth the editable area inside its boxes. The form must stay wider
|
||||
// than a box plus its border, padding and the block cursor, or lipgloss wraps
|
||||
// the border and the panel falls apart.
|
||||
const (
|
||||
ruleInputWidth = 26
|
||||
ruleFormWidth = ruleInputWidth + 12
|
||||
)
|
||||
|
||||
// descGroup is one distinct untagged description and how often it occurs.
|
||||
// Grouping matters: a statement holds the same payee dozens of times, and a
|
||||
// rule is written against the description, not against individual rows.
|
||||
type descGroup struct {
|
||||
Description string
|
||||
Accounts map[string]bool
|
||||
Count int
|
||||
}
|
||||
|
||||
// reloadUntagged rebuilds the alphabetical list of untagged descriptions that
|
||||
// the rule builder previews against.
|
||||
func (m *Model) reloadUntagged() error {
|
||||
txns, err := m.db.Transactions(store.Filter{Untagged: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
byDesc := map[string]*descGroup{}
|
||||
for _, t := range txns {
|
||||
key := model.NormalizeDescription(t.Description)
|
||||
g, ok := byDesc[key]
|
||||
if !ok {
|
||||
g = &descGroup{Description: t.Description, Accounts: map[string]bool{}}
|
||||
byDesc[key] = g
|
||||
}
|
||||
g.Accounts[t.AccountSlug] = true
|
||||
g.Count++
|
||||
}
|
||||
|
||||
m.untagged = make([]descGroup, 0, len(byDesc))
|
||||
for _, g := range byDesc {
|
||||
m.untagged = append(m.untagged, *g)
|
||||
}
|
||||
sort.Slice(m.untagged, func(i, j int) bool {
|
||||
a := model.NormalizeDescription(m.untagged[i].Description)
|
||||
b := model.NormalizeDescription(m.untagged[j].Description)
|
||||
if a != b {
|
||||
return a < b
|
||||
}
|
||||
return m.untagged[i].Description < m.untagged[j].Description
|
||||
})
|
||||
|
||||
m.refreshRulePreview()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshRulePreview re-marks the preview against whatever is typed right now.
|
||||
// It runs on every keystroke, so the glob is checked against descriptions only,
|
||||
// never against the database.
|
||||
func (m *Model) refreshRulePreview() {
|
||||
var (
|
||||
pattern = strings.TrimSpace(m.ruleGlob.Value())
|
||||
account = strings.TrimSpace(m.ruleAccount.Value())
|
||||
rows = make([]table.Row, 0, len(m.untagged))
|
||||
)
|
||||
m.ruleMatches = 0
|
||||
|
||||
for _, g := range m.untagged {
|
||||
// An account filter narrows the preview the same way the saved rule
|
||||
// will narrow its matching.
|
||||
if account != "" && !g.Accounts[account] {
|
||||
continue
|
||||
}
|
||||
marker := " "
|
||||
if pattern != "" && glob.Match(pattern, model.NormalizeDescription(g.Description)) {
|
||||
marker = "▸"
|
||||
m.ruleMatches++
|
||||
}
|
||||
rows = append(rows, table.Row{marker, g.Description, fmt.Sprintf("%d", g.Count)})
|
||||
}
|
||||
|
||||
cursor := m.ruleTable.Cursor()
|
||||
m.ruleTable.SetRows(rows)
|
||||
if cursor >= len(rows) {
|
||||
cursor = len(rows) - 1
|
||||
}
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
m.ruleTable.SetCursor(cursor)
|
||||
}
|
||||
|
||||
// saveRule appends the composed rule to rules.toml, reloads the engine and
|
||||
// retags, so the effect is visible immediately.
|
||||
func (m *Model) saveRule() error {
|
||||
r := config.Rule{
|
||||
Match: strings.TrimSpace(m.ruleGlob.Value()),
|
||||
Account: strings.TrimSpace(m.ruleAccount.Value()),
|
||||
Tag: strings.TrimSpace(m.ruleTag.Value()),
|
||||
}
|
||||
if r.Match == "" {
|
||||
return fmt.Errorf("enter a glob first, e.g. *LIDL*")
|
||||
}
|
||||
if r.Tag == "" {
|
||||
return fmt.Errorf("enter a tag to apply")
|
||||
}
|
||||
if r.Account != "" && !m.knownAccount(r.Account) {
|
||||
return fmt.Errorf("no account called %q; leave it blank to apply to every account", r.Account)
|
||||
}
|
||||
|
||||
if err := config.AppendRule(m.root, r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Re-read the file rather than appending to the in-memory engine, so what
|
||||
// runs is exactly what is now on disk.
|
||||
loaded, err := config.LoadRules(m.root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err)
|
||||
}
|
||||
m.engine = rules.New(loaded)
|
||||
|
||||
n, err := m.engine.Retag(m.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n)
|
||||
m.ruleGlob.SetValue("")
|
||||
m.ruleTag.SetValue("")
|
||||
m.setRuleFocus(0)
|
||||
return m.reload()
|
||||
}
|
||||
|
||||
func (m *Model) knownAccount(slug string) bool {
|
||||
for _, a := range m.accounts {
|
||||
if a.Slug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Fall back to the index, so accounts work even when the caller passed no
|
||||
// config (as the tests do).
|
||||
accounts, err := m.db.Accounts()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range accounts {
|
||||
if a.Slug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// openRuleBuilder switches to the rule builder, remembering where to return to
|
||||
// and seeding the account field from whatever account is being browsed.
|
||||
func (m *Model) openRuleBuilder() tea.Cmd {
|
||||
if m.view != viewRules {
|
||||
m.ruleReturn = m.view
|
||||
}
|
||||
m.view = viewRules
|
||||
if m.ruleAccount.Value() == "" && m.filter.AccountSlug != "" {
|
||||
m.ruleAccount.SetValue(m.filter.AccountSlug)
|
||||
}
|
||||
m.setRuleFocus(0)
|
||||
if err := m.reloadUntagged(); err != nil {
|
||||
m.err = err
|
||||
}
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
// ruleInputs lists the form fields in tab order.
|
||||
func (m *Model) ruleInputs() []*textinput.Model {
|
||||
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag}
|
||||
}
|
||||
|
||||
// setRuleFocus moves the cursor between the form fields, wrapping around.
|
||||
func (m *Model) setRuleFocus(i int) {
|
||||
inputs := m.ruleInputs()
|
||||
n := len(inputs)
|
||||
m.ruleFocus = ((i % n) + n) % n
|
||||
for j, in := range inputs {
|
||||
if j == m.ruleFocus {
|
||||
in.Focus()
|
||||
in.CursorEnd()
|
||||
continue
|
||||
}
|
||||
in.Blur()
|
||||
}
|
||||
}
|
||||
|
||||
// selected returns the transaction under the cursor, if any.
|
||||
func (m *Model) selected() (model.Transaction, bool) {
|
||||
i := m.txnTable.Cursor()
|
||||
@@ -284,11 +520,55 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.input != inputNone {
|
||||
return m.updateInput(msg)
|
||||
}
|
||||
// The rule builder is a form: every printable key belongs to the
|
||||
// focused input, so the global single-letter keymap cannot apply.
|
||||
if m.view == viewRules {
|
||||
return m.updateRules(msg)
|
||||
}
|
||||
return m.updateNormal(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateRules drives the rule builder form.
|
||||
func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
m.view = m.ruleReturn
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.setRuleFocus(m.ruleFocus + 1)
|
||||
return m, nil
|
||||
case "shift+tab", "up":
|
||||
m.setRuleFocus(m.ruleFocus - 1)
|
||||
return m, nil
|
||||
case "pgdown":
|
||||
m.ruleTable.MoveDown(10)
|
||||
return m, nil
|
||||
case "pgup":
|
||||
m.ruleTable.MoveUp(10)
|
||||
return m, nil
|
||||
case "enter":
|
||||
m.err = nil
|
||||
if err := m.saveRule(); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
if err := m.reloadUntagged(); err != nil {
|
||||
m.err = err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
inputs := m.ruleInputs()
|
||||
*inputs[m.ruleFocus], cmd = inputs[m.ruleFocus].Update(msg)
|
||||
m.refreshRulePreview()
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) finishImport(msg importDoneMsg) (tea.Model, tea.Cmd) {
|
||||
m.importing = false
|
||||
if msg.err != nil {
|
||||
@@ -339,6 +619,18 @@ func (m *Model) resize() {
|
||||
m.accountTable.SetHeight(h)
|
||||
m.txnTable.SetHeight(h)
|
||||
m.reportTable.SetHeight(h)
|
||||
m.ruleTable.SetHeight(h)
|
||||
|
||||
// The preview list gets whatever the form does not use.
|
||||
if m.width > 0 {
|
||||
cols := m.ruleTable.Columns()
|
||||
desc := m.width - ruleFormWidth - cols[0].Width - cols[2].Width - 10
|
||||
if desc < 20 {
|
||||
desc = 20
|
||||
}
|
||||
cols[1].Width = desc
|
||||
m.ruleTable.SetColumns(cols)
|
||||
}
|
||||
|
||||
// Give the description column whatever is left over.
|
||||
if m.width > 0 {
|
||||
@@ -417,8 +709,13 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "3":
|
||||
m.view = viewReport
|
||||
return m, nil
|
||||
case "4":
|
||||
return m, m.openRuleBuilder()
|
||||
case "tab":
|
||||
m.view = (m.view + 1) % 3
|
||||
m.view = (m.view + 1) % viewCount
|
||||
if m.view == viewRules {
|
||||
return m, m.openRuleBuilder()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
@@ -572,6 +869,8 @@ func (m *Model) View() string {
|
||||
b.WriteString(m.txnTable.View())
|
||||
case viewReport:
|
||||
b.WriteString(m.reportTable.View())
|
||||
case viewRules:
|
||||
b.WriteString(m.rulesView())
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
@@ -593,6 +892,41 @@ func (m *Model) View() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rulesView puts the form on the left and the live preview on the right.
|
||||
func (m *Model) rulesView() string {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, m.ruleFormView(), m.ruleTable.View())
|
||||
}
|
||||
|
||||
func (m *Model) ruleFormView() string {
|
||||
field := func(i int, label, help string) string {
|
||||
name := labelStyle.Render(" " + label)
|
||||
box := boxStyle.Render(m.ruleInputs()[i].View())
|
||||
if i == m.ruleFocus {
|
||||
name = focusedLabelStyle.Render("▸ " + label)
|
||||
box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
|
||||
}
|
||||
return name + "\n" + box + "\n" + hintStyle.Render(help) + "\n"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(field(0, "glob", "vs. the description"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(field(1, "account", "blank = all accounts"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(field(2, "tag", "applied to matches"))
|
||||
b.WriteString("\n")
|
||||
|
||||
// The count is the whole point of the preview: it says what the rule will
|
||||
// do before it is written to disk.
|
||||
summary := fmt.Sprintf("%d of %d descriptions match", m.ruleMatches, len(m.ruleTable.Rows()))
|
||||
if strings.TrimSpace(m.ruleGlob.Value()) == "" {
|
||||
summary = fmt.Sprintf("%d untagged descriptions", len(m.ruleTable.Rows()))
|
||||
}
|
||||
b.WriteString(matchCountStyle.Render(summary))
|
||||
|
||||
return ruleFormStyle.Render(b.String())
|
||||
}
|
||||
|
||||
// emptyMessage explains an empty view and names the key that fixes it, or
|
||||
// returns "" when there is something to show.
|
||||
func (m *Model) emptyMessage() string {
|
||||
@@ -656,6 +990,8 @@ func (m *Model) title() string {
|
||||
switch m.view {
|
||||
case viewAccounts:
|
||||
return "money · accounts"
|
||||
case viewRules:
|
||||
return "money · rule builder · writes to rules.toml"
|
||||
case viewReport:
|
||||
return "money · report · " + scope + " · transfers excluded"
|
||||
default:
|
||||
@@ -681,10 +1017,12 @@ func (m *Model) help() string {
|
||||
}
|
||||
switch m.view {
|
||||
case viewAccounts:
|
||||
return "enter open · 2 transactions · 3 report · i import · r retag · q quit"
|
||||
return "enter open · 2 transactions · 3 report · 4 rules · i import · r retag · q quit"
|
||||
case viewRules:
|
||||
return "tab/↑↓ field · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit"
|
||||
case viewReport:
|
||||
return "1 accounts · 2 transactions · u untagged · a all accounts · q quit"
|
||||
return "1 accounts · 2 transactions · 4 rules · u untagged · a all accounts · q quit"
|
||||
default:
|
||||
return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 1 accounts · 3 report · q quit"
|
||||
return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 4 rules · 1 accounts · 3 report · q quit"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -380,6 +382,278 @@ func TestImportWarningsSurface(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(filepath.Join(root, ".money", "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)
|
||||
}
|
||||
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: "csv"},
|
||||
{Slug: "savings", Currency: "EUR", Parser: "csv"},
|
||||
}, 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, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// newEmptyModel builds a model over an index with nothing in it.
|
||||
func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user