Let rules carry a note
A glob like *4412* says nothing about why it exists or who it catches, and six months later neither does memory. Rules get an optional note: free text that never takes part in matching, written as a TOML key rather than a comment so it survives a round trip and can be shown back. The rule builder grows a fourth field for it and the rules screen a last column. Four fields spaced out are taller than a short window has room for, so the form now drops its blank lines and then the hints on unfocused fields before anything would run off the bottom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,11 @@ could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a
|
|||||||
earlier one correctly reports zero. That is what makes the rules screen able to
|
earlier one correctly reports zero. That is what makes the rules screen able to
|
||||||
find dead rules at all.
|
find dead rules at all.
|
||||||
|
|
||||||
|
**A rule's `note` is documentation that round-trips.** It is a TOML key rather
|
||||||
|
than a `#` comment so `LoadRules` can return it, the builder can write it and
|
||||||
|
the rules screen can show it. It never takes part in matching — `rules.Engine`
|
||||||
|
does not look at it — and it must stay that way.
|
||||||
|
|
||||||
**`config.DeleteRules` edits rules.toml textually, never by re-serialising the
|
**`config.DeleteRules` edits rules.toml textually, never by re-serialising the
|
||||||
parsed rules**, because comments and formatting are not recoverable from
|
parsed rules**, because comments and formatting are not recoverable from
|
||||||
`[]Rule`. A rule owns the comment lines directly above it; a comment separated
|
`[]Rule`. A rule owns the comment lines directly above it; a comment separated
|
||||||
|
|||||||
@@ -100,14 +100,22 @@ a `▸` against each one the glob currently matches and a running
|
|||||||
╰────────────────────────────╯
|
╰────────────────────────────╯
|
||||||
tab completes · ctrl+n: 1 more
|
tab completes · ctrl+n: 1 more
|
||||||
|
|
||||||
|
note
|
||||||
|
╭────────────────────────────╮
|
||||||
|
│ optional │
|
||||||
|
╰────────────────────────────╯
|
||||||
|
why this rule exists
|
||||||
|
|
||||||
2 of 7 descriptions match
|
2 of 7 descriptions match
|
||||||
```
|
```
|
||||||
|
|
||||||
Only `gro` was typed in the tag field; `ceries` is the ghosted completion.
|
Only `gro` was typed in the tag field; `ceries` is the ghosted completion.
|
||||||
|
|
||||||
`tab` / `↑↓` move between the glob, account and tag fields, `pgup` / `pgdn`
|
`tab` / `↑↓` move between the glob, account, tag and note fields, `pgup` /
|
||||||
scroll the list, and `enter` appends the rule to `rules.toml` and retags
|
`pgdn` scroll the list, and `enter` appends the rule to `rules.toml` and retags
|
||||||
immediately, so the rows it caught disappear from the list. `esc` goes back.
|
immediately, so the rows it caught disappear from the list. `esc` goes back.
|
||||||
|
On a short window the form gives up its spacing and then its hints, so all four
|
||||||
|
fields stay on screen.
|
||||||
|
|
||||||
The account and tag fields complete as you type: the rest of the match is
|
The account and tag fields complete as you type: the rest of the match is
|
||||||
ghosted in grey after the cursor, and `tab` (or `→` at the end of the line)
|
ghosted in grey after the cursor, and `tab` (or `→` at the end of the line)
|
||||||
@@ -131,12 +139,12 @@ Rules that claim none are marked `✗`.
|
|||||||
```
|
```
|
||||||
money · rules · 5 rules · 2 match nothing
|
money · rules · 5 rules · 2 match nothing
|
||||||
|
|
||||||
# Pattern Account Tag T Txns
|
# Pattern Account Tag T Txns Note
|
||||||
1 *LIDL* (all) groceries 3
|
1 *LIDL* (all) groceries 3 the weekly shop
|
||||||
2 ✗ *LIDL SOFIA* (all) shadowed 0
|
2 ✗ *LIDL SOFIA* (all) shadowed 0
|
||||||
3 ✗ *OLD BANK NAME* (all) dead 0
|
3 ✗ *OLD BANK NAME* (all) dead 0 closed in 2025
|
||||||
4 *ZARA* (all) clothes 1
|
4 *ZARA* (all) clothes 1
|
||||||
5 *КАУФЛАНД* checking groceries 1
|
5 *КАУФЛАНД* checking groceries 1 4412 is the branch
|
||||||
```
|
```
|
||||||
|
|
||||||
The count is how many transactions the rule *wins*, not how many its glob could
|
The count is how many transactions the rule *wins*, not how many its glob could
|
||||||
@@ -161,6 +169,12 @@ A rule matches on `match` (the description), `counterparty` (the other side's
|
|||||||
account number) and `type` (the bank's own classification). Setting several is
|
account number) and `type` (the bank's own classification). Setting several is
|
||||||
an "and": all must match.
|
an "and": all must match.
|
||||||
|
|
||||||
|
`note` is free text for you, never for the matcher: why the rule is there, or
|
||||||
|
what the unrecognisable payee behind the glob actually is. It shows in the last
|
||||||
|
column of the rules screen. Ordinary `#` comments still work and are preserved
|
||||||
|
on delete; a `note` differs in that it survives a round trip through the tool,
|
||||||
|
so the rule builder can write one and the rules screen can show it.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[[rule]]
|
[[rule]]
|
||||||
tag = "groceries"
|
tag = "groceries"
|
||||||
@@ -169,6 +183,7 @@ match = "*LIDL*"
|
|||||||
[[rule]]
|
[[rule]]
|
||||||
tag = "salary"
|
tag = "salary"
|
||||||
match = "*ACME PAYROLL*"
|
match = "*ACME PAYROLL*"
|
||||||
|
note = "paid on the 4th; the December one lands early"
|
||||||
|
|
||||||
# Money moved between your own accounts. Both legs need a rule.
|
# Money moved between your own accounts. Both legs need a rule.
|
||||||
[[rule]]
|
[[rule]]
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ type Rule struct {
|
|||||||
// Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT.
|
// Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT.
|
||||||
// Optional; when set, it must match as well as Match.
|
// Optional; when set, it must match as well as Match.
|
||||||
Type string `toml:"type"`
|
Type string `toml:"type"`
|
||||||
|
// Note is free text for the reader: why the rule exists, or what the
|
||||||
|
// unrecognisable payee behind the glob actually is. It never affects
|
||||||
|
// matching. It is a key rather than a comment so it survives a round trip
|
||||||
|
// through LoadRules and can be shown on the rules screen.
|
||||||
|
Note string `toml:"note"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rules is the parsed rules.toml.
|
// Rules is the parsed rules.toml.
|
||||||
@@ -256,6 +261,9 @@ func formatRule(r Rule) string {
|
|||||||
if r.Transfer {
|
if r.Transfer {
|
||||||
b.WriteString("transfer = true\n")
|
b.WriteString("transfer = true\n")
|
||||||
}
|
}
|
||||||
|
// Last, so the patterns and the tag stay lined up above it however long
|
||||||
|
// the note runs.
|
||||||
|
write("note", r.Note)
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,63 @@ func TestAppendRuleQuotesValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A note is documentation carried with the rule. It is a key rather than a
|
||||||
|
// comment precisely so it survives the round trip.
|
||||||
|
func TestAppendRuleWithNote(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
note := `the "corner shop" — 4412 is the branch code`
|
||||||
|
if err := AppendRule(root, Rule{Match: "*4412*", Tag: "groceries", Note: note}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := LoadRules(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the note broke the file: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Rule[0].Note != note {
|
||||||
|
t.Errorf("note = %q, want %q", loaded.Rule[0].Note, note)
|
||||||
|
}
|
||||||
|
// A note alone is not a rule; the usual validation still applies.
|
||||||
|
if err := AppendRule(root, Rule{Note: "just a thought"}); err == nil {
|
||||||
|
t.Error("expected a rule with only a note to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting a rule takes its note with it and leaves everyone else's alone.
|
||||||
|
func TestDeleteRuleTakesItsNote(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
path := filepath.Join(root, RulesFile)
|
||||||
|
original := `[[rule]]
|
||||||
|
match = "*LIDL*"
|
||||||
|
tag = "groceries"
|
||||||
|
note = "the weekly shop"
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
match = "*ZARA*"
|
||||||
|
tag = "clothes"
|
||||||
|
note = "keep: for the January review"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := DeleteRules(root, []int{0}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "weekly shop") {
|
||||||
|
t.Errorf("rules.toml = %q, want the deleted rule's note gone with it", raw)
|
||||||
|
}
|
||||||
|
loaded, err := LoadRules(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(loaded.Rule) != 1 || loaded.Rule[0].Note != "keep: for the January review" {
|
||||||
|
t.Errorf("rules = %+v, want the surviving rule to keep its note", loaded.Rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rulesWithComments = `# Rules for my accounts.
|
const rulesWithComments = `# Rules for my accounts.
|
||||||
# Order matters: the first match wins.
|
# Order matters: the first match wins.
|
||||||
|
|
||||||
|
|||||||
+47
-20
@@ -83,7 +83,8 @@ type Model struct {
|
|||||||
ruleGlob textinput.Model
|
ruleGlob textinput.Model
|
||||||
ruleAccount textinput.Model
|
ruleAccount textinput.Model
|
||||||
ruleTag textinput.Model
|
ruleTag textinput.Model
|
||||||
ruleFocus int // which of the three inputs has the cursor
|
ruleNote textinput.Model
|
||||||
|
ruleFocus int // which of the inputs has the cursor
|
||||||
ruleTable table.Model
|
ruleTable table.Model
|
||||||
ruleReturn view // the view to go back to on esc
|
ruleReturn view // the view to go back to on esc
|
||||||
untagged []descGroup
|
untagged []descGroup
|
||||||
@@ -196,6 +197,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
|||||||
ruleGlob: newInput("*LIDL*"),
|
ruleGlob: newInput("*LIDL*"),
|
||||||
ruleAccount: completing(newInput("blank = every account")),
|
ruleAccount: completing(newInput("blank = every account")),
|
||||||
ruleTag: completing(newInput("groceries")),
|
ruleTag: completing(newInput("groceries")),
|
||||||
|
ruleNote: newInput("optional"),
|
||||||
ruleTable: newTable([]table.Column{
|
ruleTable: newTable([]table.Column{
|
||||||
{Title: " ", Width: 1},
|
{Title: " ", Width: 1},
|
||||||
{Title: "Untagged description", Width: 44},
|
{Title: "Untagged description", Width: 44},
|
||||||
@@ -209,6 +211,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
|||||||
{Title: "Tag", Width: 14},
|
{Title: "Tag", Width: 14},
|
||||||
{Title: "T", Width: 1},
|
{Title: "T", Width: 1},
|
||||||
{Title: "Txns", Width: 6},
|
{Title: "Txns", Width: 6},
|
||||||
|
{Title: "Note", Width: 24},
|
||||||
}),
|
}),
|
||||||
reportTable: newTable([]table.Column{
|
reportTable: newTable([]table.Column{
|
||||||
{Title: "Tag", Width: 20},
|
{Title: "Tag", Width: 20},
|
||||||
@@ -416,6 +419,7 @@ func (m *Model) saveRule() error {
|
|||||||
Match: strings.TrimSpace(m.ruleGlob.Value()),
|
Match: strings.TrimSpace(m.ruleGlob.Value()),
|
||||||
Account: strings.TrimSpace(m.ruleAccount.Value()),
|
Account: strings.TrimSpace(m.ruleAccount.Value()),
|
||||||
Tag: strings.TrimSpace(m.ruleTag.Value()),
|
Tag: strings.TrimSpace(m.ruleTag.Value()),
|
||||||
|
Note: strings.TrimSpace(m.ruleNote.Value()),
|
||||||
}
|
}
|
||||||
if r.Match == "" {
|
if r.Match == "" {
|
||||||
return fmt.Errorf("enter a glob first, e.g. *LIDL*")
|
return fmt.Errorf("enter a glob first, e.g. *LIDL*")
|
||||||
@@ -447,6 +451,7 @@ func (m *Model) saveRule() error {
|
|||||||
m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n)
|
m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n)
|
||||||
m.ruleGlob.SetValue("")
|
m.ruleGlob.SetValue("")
|
||||||
m.ruleTag.SetValue("")
|
m.ruleTag.SetValue("")
|
||||||
|
m.ruleNote.SetValue("")
|
||||||
m.setRuleFocus(0)
|
m.setRuleFocus(0)
|
||||||
if err := m.reloadSuggestions(); err != nil {
|
if err := m.reloadSuggestions(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -578,7 +583,7 @@ func (m *Model) reloadRuleList() error {
|
|||||||
}
|
}
|
||||||
rows = append(rows, table.Row{
|
rows = append(rows, table.Row{
|
||||||
fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, transfer,
|
fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, transfer,
|
||||||
fmt.Sprintf("%d", m.ruleUsage[i]),
|
fmt.Sprintf("%d", m.ruleUsage[i]), r.Note,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,7 +761,7 @@ func (m *Model) openRuleBuilder() tea.Cmd {
|
|||||||
|
|
||||||
// ruleInputs lists the form fields in tab order.
|
// ruleInputs lists the form fields in tab order.
|
||||||
func (m *Model) ruleInputs() []*textinput.Model {
|
func (m *Model) ruleInputs() []*textinput.Model {
|
||||||
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag}
|
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag, &m.ruleNote}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setRuleFocus moves the cursor between the form fields, wrapping around.
|
// setRuleFocus moves the cursor between the form fields, wrapping around.
|
||||||
@@ -957,20 +962,28 @@ func (m *Model) resize() {
|
|||||||
m.ruleTable.SetColumns(cols)
|
m.ruleTable.SetColumns(cols)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give the description column whatever is left over.
|
// The free-text columns come last and get whatever is left over.
|
||||||
if m.width > 0 {
|
m.stretchLastColumn(&m.txnTable, 20)
|
||||||
cols := m.txnTable.Columns()
|
m.stretchLastColumn(&m.ruleListTable, 12)
|
||||||
fixed := 0
|
}
|
||||||
for _, c := range cols[:len(cols)-1] {
|
|
||||||
fixed += c.Width + 2
|
// stretchLastColumn widens a table's final column to fill the window, down to
|
||||||
}
|
// a floor below which truncation is worse than letting the row overflow.
|
||||||
desc := m.width - fixed - 4
|
func (m *Model) stretchLastColumn(t *table.Model, min int) {
|
||||||
if desc < 20 {
|
if m.width <= 0 {
|
||||||
desc = 20
|
return
|
||||||
}
|
|
||||||
cols[len(cols)-1].Width = desc
|
|
||||||
m.txnTable.SetColumns(cols)
|
|
||||||
}
|
}
|
||||||
|
cols := t.Columns()
|
||||||
|
fixed := 0
|
||||||
|
for _, c := range cols[:len(cols)-1] {
|
||||||
|
fixed += c.Width + 2
|
||||||
|
}
|
||||||
|
last := m.width - fixed - 4
|
||||||
|
if last < min {
|
||||||
|
last = min
|
||||||
|
}
|
||||||
|
cols[len(cols)-1].Width = last
|
||||||
|
t.SetColumns(cols)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateInput handles typing into the tag or search prompt.
|
// updateInput handles typing into the tag or search prompt.
|
||||||
@@ -1238,6 +1251,15 @@ func (m *Model) rulesView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) ruleFormView() string {
|
func (m *Model) ruleFormView() string {
|
||||||
|
// Spaced out, the four fields come to 25 lines, which is more than a short
|
||||||
|
// window has left once the same allowance the tables get is taken off.
|
||||||
|
// Give up the blank lines between fields first (21 lines) and the hints on
|
||||||
|
// unfocused fields second (18), rather than letting the last field run off
|
||||||
|
// the bottom. Below that the box borders are the floor.
|
||||||
|
room := m.height - 6
|
||||||
|
spaced := m.height <= 0 || room >= 25
|
||||||
|
hints := m.height <= 0 || room >= 21
|
||||||
|
|
||||||
field := func(i int, label, help string) string {
|
field := func(i int, label, help string) string {
|
||||||
name := labelStyle.Render(" " + label)
|
name := labelStyle.Render(" " + label)
|
||||||
box := boxStyle.Render(m.ruleInputs()[i].View())
|
box := boxStyle.Render(m.ruleInputs()[i].View())
|
||||||
@@ -1245,16 +1267,21 @@ func (m *Model) ruleFormView() string {
|
|||||||
name = focusedLabelStyle.Render("▸ " + label)
|
name = focusedLabelStyle.Render("▸ " + label)
|
||||||
box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
|
box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
|
||||||
}
|
}
|
||||||
return name + "\n" + box + "\n" + hintStyle.Render(help) + "\n"
|
out := name + "\n" + box + "\n"
|
||||||
|
if hints || i == m.ruleFocus {
|
||||||
|
out += hintStyle.Render(help) + "\n"
|
||||||
|
}
|
||||||
|
if spaced {
|
||||||
|
out += "\n"
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(field(0, "glob", "vs. the description"))
|
b.WriteString(field(0, "glob", "vs. the description"))
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(field(1, "account", m.completionHint(1, "blank = all accounts")))
|
b.WriteString(field(1, "account", m.completionHint(1, "blank = all accounts")))
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches")))
|
b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches")))
|
||||||
b.WriteString("\n")
|
b.WriteString(field(3, "note", "why this rule exists"))
|
||||||
|
|
||||||
// The count is the whole point of the preview: it says what the rule will
|
// The count is the whole point of the preview: it says what the rule will
|
||||||
// do before it is written to disk.
|
// do before it is written to disk.
|
||||||
|
|||||||
@@ -638,7 +638,7 @@ func TestRuleBuilderTabCyclesFieldsAndEscLeaves(t *testing.T) {
|
|||||||
key(t, m, "2") // come from the transactions view
|
key(t, m, "2") // come from the transactions view
|
||||||
key(t, m, "4")
|
key(t, m, "4")
|
||||||
|
|
||||||
for i, want := range []int{1, 2, 0} {
|
for i, want := range []int{1, 2, 3, 0} {
|
||||||
m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
if m.ruleFocus != want {
|
if m.ruleFocus != want {
|
||||||
t.Errorf("tab %d moved focus to %d, want %d", i+1, m.ruleFocus, want)
|
t.Errorf("tab %d moved focus to %d, want %d", i+1, m.ruleFocus, want)
|
||||||
@@ -783,8 +783,89 @@ func TestRuleBuilderKeepsUnmatchedInput(t *testing.T) {
|
|||||||
if m.ruleTag.Value() != "zzz" {
|
if m.ruleTag.Value() != "zzz" {
|
||||||
t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value())
|
t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value())
|
||||||
}
|
}
|
||||||
if m.ruleFocus != 0 {
|
if m.ruleFocus != 3 {
|
||||||
t.Errorf("focus = %d, want tab to wrap round to the glob field", m.ruleFocus)
|
t.Errorf("focus = %d, want tab to move on to the note field", m.ruleFocus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note is free text that rides along with the rule: written to rules.toml,
|
||||||
|
// read back, and shown on the rules screen so the reason survives longer than
|
||||||
|
// the memory of writing it.
|
||||||
|
func TestRuleBuilderSavesNote(t *testing.T) {
|
||||||
|
m, _, root := newRuleModel(t)
|
||||||
|
key(t, m, "4")
|
||||||
|
|
||||||
|
m.ruleGlob.SetValue("*LIDL*")
|
||||||
|
m.setRuleFocus(2)
|
||||||
|
m.ruleTag.SetValue("groceries")
|
||||||
|
m.setRuleFocus(3)
|
||||||
|
typeText(t, m, "the weekly shop")
|
||||||
|
key(t, m, "enter")
|
||||||
|
|
||||||
|
loaded, err := config.LoadRules(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(loaded.Rule) != 1 || loaded.Rule[0].Note != "the weekly shop" {
|
||||||
|
t.Fatalf("rules.toml holds %+v, want the note round-tripped", loaded.Rule)
|
||||||
|
}
|
||||||
|
// The note is documentation, not a pattern: it must not narrow what the
|
||||||
|
// rule catches.
|
||||||
|
if loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[0].Tag != "groceries" {
|
||||||
|
t.Errorf("rule = %+v, want the glob and tag untouched", loaded.Rule[0])
|
||||||
|
}
|
||||||
|
if m.ruleNote.Value() != "" {
|
||||||
|
t.Errorf("note = %q, want the field cleared for the next rule", m.ruleNote.Value())
|
||||||
|
}
|
||||||
|
|
||||||
|
m.openRuleList()
|
||||||
|
row := m.ruleListTable.Rows()[0]
|
||||||
|
if row[len(row)-1] != "the weekly shop" {
|
||||||
|
t.Errorf("rules screen row = %v, want the note in the last column", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rule without a note is still a rule; nothing about saving changes.
|
||||||
|
func TestRuleBuilderNoteIsOptional(t *testing.T) {
|
||||||
|
m, _, root := newRuleModel(t)
|
||||||
|
key(t, m, "4")
|
||||||
|
|
||||||
|
m.ruleGlob.SetValue("*LIDL*")
|
||||||
|
m.ruleTag.SetValue("groceries")
|
||||||
|
key(t, m, "enter")
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(filepath.Join(root, config.RulesFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "note") {
|
||||||
|
t.Errorf("rules.toml = %q, want no empty note key", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The form has to survive a short window: four fields spaced out are taller
|
||||||
|
// than the room a 24-line terminal leaves.
|
||||||
|
func TestRuleFormFitsShortTerminals(t *testing.T) {
|
||||||
|
m, _, _ := newRuleModel(t)
|
||||||
|
key(t, m, "4")
|
||||||
|
|
||||||
|
for _, height := range []int{40, 32, 30, 26, 24} {
|
||||||
|
m.Update(tea.WindowSizeMsg{Width: 140, Height: height})
|
||||||
|
form := m.ruleFormView()
|
||||||
|
// The same budget the tables get: the title, status and a help line
|
||||||
|
// that may wrap onto a second row.
|
||||||
|
if lines := strings.Count(form, "\n") + 1; lines > height-6 {
|
||||||
|
t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6)
|
||||||
|
}
|
||||||
|
// However tight it gets, every field keeps its label and its box.
|
||||||
|
for _, label := range []string{"glob", "account", "tag", "note"} {
|
||||||
|
if !strings.Contains(form, label) {
|
||||||
|
t.Errorf("at height %d the %s field disappeared", height, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if boxes := strings.Count(form, "╭"); boxes != 4 {
|
||||||
|
t.Errorf("at height %d there are %d input boxes, want 4", height, boxes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user