Complete account and tag names in the rule builder
Both fields are free text, and a slug or tag that is slightly wrong produces a rule that silently catches nothing — or a second, near-identical tag. They now complete against what already exists: accounts from the folders on disk and the index, tags from every tag in use plus any named in rules.toml, so a tag is completable from the moment a rule mentions it. The completion is ghosted after the cursor and never committed until it is accepted, so inventing a new tag still works. tab takes it and moves focus only when there is nothing left to complete; ctrl+n/ctrl+p cycle an ambiguous prefix, and the hint under the box says how many candidates are left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -654,6 +654,140 @@ func TestRuleBuilderTabCyclesFieldsAndEscLeaves(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The account field completes against the accounts that exist, because a slug
|
||||
// typed by hand only has to be slightly wrong to produce a rule that silently
|
||||
// catches nothing.
|
||||
func TestRuleBuilderCompletesAccount(t *testing.T) {
|
||||
m, _, _ := newRuleModel(t)
|
||||
key(t, m, "4")
|
||||
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyTab}) // onto the account field
|
||||
typeText(t, m, "sa")
|
||||
|
||||
// Nothing is committed until it is accepted: the value is still what was
|
||||
// typed, with the rest offered as a ghost.
|
||||
if m.ruleAccount.Value() != "sa" {
|
||||
t.Fatalf("account = %q, want the typed text to stand until accepted", m.ruleAccount.Value())
|
||||
}
|
||||
if got := m.ruleAccount.CurrentSuggestion(); got != "savings" {
|
||||
t.Fatalf("suggestion = %q, want savings", got)
|
||||
}
|
||||
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
if m.ruleAccount.Value() != "savings" {
|
||||
t.Fatalf("account = %q after tab, want savings", m.ruleAccount.Value())
|
||||
}
|
||||
// Tab took the completion rather than the focus; the next one moves on.
|
||||
if m.ruleFocus != 1 {
|
||||
t.Errorf("focus = %d, want the account field to keep it while completing", m.ruleFocus)
|
||||
}
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
if m.ruleFocus != 2 {
|
||||
t.Errorf("focus = %d, want the tag field once there is nothing left to complete", m.ruleFocus)
|
||||
}
|
||||
|
||||
// Accepting an account narrows the preview, exactly as typing it in full does.
|
||||
if all, _ := previewRows(m); len(all) != 1 || all[0] != "LIDL VARNA 9911" {
|
||||
t.Errorf("preview = %v, want only the savings row", all)
|
||||
}
|
||||
}
|
||||
|
||||
// → accepts too, at the end of the line where it has nothing else to do.
|
||||
func TestRuleBuilderRightArrowAcceptsCompletion(t *testing.T) {
|
||||
m, _, _ := newRuleModel(t)
|
||||
key(t, m, "4")
|
||||
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
typeText(t, m, "che")
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||
if m.ruleAccount.Value() != "checking" {
|
||||
t.Errorf("account = %q after →, want checking", m.ruleAccount.Value())
|
||||
}
|
||||
|
||||
// With no completion pending it is an ordinary cursor key again.
|
||||
m.ruleAccount.SetCursor(0)
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||
if m.ruleAccount.Position() != 1 {
|
||||
t.Errorf("cursor = %d, want → to move within the text once there is nothing to accept",
|
||||
m.ruleAccount.Position())
|
||||
}
|
||||
}
|
||||
|
||||
// Tags come from the index and from rules.toml, so a tag is completable from
|
||||
// the moment it is named in a rule, not only once something matches it.
|
||||
func TestRuleBuilderCompletesTag(t *testing.T) {
|
||||
m, _, _ := newRuleModel(t)
|
||||
m.engine = rules.New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*NOTHING*", Tag: "groceries"},
|
||||
{Match: "*ALSO NOTHING*", Tag: "grooming"},
|
||||
}})
|
||||
key(t, m, "4")
|
||||
|
||||
m.setRuleFocus(2)
|
||||
typeText(t, m, "gro")
|
||||
if got := m.ruleTag.CurrentSuggestion(); got != "groceries" {
|
||||
t.Fatalf("suggestion = %q, want the first of the two alphabetically", got)
|
||||
}
|
||||
if n := len(m.ruleTag.MatchedSuggestions()); n != 2 {
|
||||
t.Fatalf("%d matched suggestions, want both gro* tags", n)
|
||||
}
|
||||
|
||||
// An ambiguous prefix is cycled, not guessed.
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
|
||||
if got := m.ruleTag.CurrentSuggestion(); got != "grooming" {
|
||||
t.Fatalf("suggestion = %q after ctrl+n, want grooming", got)
|
||||
}
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
if m.ruleTag.Value() != "grooming" {
|
||||
t.Errorf("tag = %q, want the cycled-to suggestion", m.ruleTag.Value())
|
||||
}
|
||||
|
||||
// The hint under the box names the key that accepts, and says how many
|
||||
// other candidates there are.
|
||||
m.ruleTag.SetValue("gro")
|
||||
m.ruleTag.SetSuggestions(m.ruleTag.AvailableSuggestions())
|
||||
if hint := m.completionHint(2, "applied to matches"); !strings.Contains(hint, "tab") ||
|
||||
!strings.Contains(hint, "1 more") {
|
||||
t.Errorf("hint = %q, want it to name tab and the remaining candidate", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// A tag invented in the builder is completable next time round.
|
||||
func TestSavingARuleAddsItsTagToTheSuggestions(t *testing.T) {
|
||||
m, _, _ := newRuleModel(t)
|
||||
key(t, m, "4")
|
||||
|
||||
m.ruleGlob.SetValue("*LIDL*")
|
||||
m.setRuleFocus(2)
|
||||
m.ruleTag.SetValue("groceries")
|
||||
key(t, m, "enter")
|
||||
|
||||
m.setRuleFocus(2) // saving resets the form to the glob field
|
||||
typeText(t, m, "gro")
|
||||
if got := m.ruleTag.CurrentSuggestion(); got != "groceries" {
|
||||
t.Errorf("suggestion = %q, want the tag just saved", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix nothing matches must stay exactly as typed: the completion never
|
||||
// invents a tag, and tab goes back to moving between fields.
|
||||
func TestRuleBuilderKeepsUnmatchedInput(t *testing.T) {
|
||||
m, _, _ := newRuleModel(t)
|
||||
m.engine = rules.New(&config.Rules{Rule: []config.Rule{{Match: "*X*", Tag: "groceries"}}})
|
||||
key(t, m, "4")
|
||||
|
||||
m.setRuleFocus(2)
|
||||
typeText(t, m, "zzz")
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
|
||||
if m.ruleTag.Value() != "zzz" {
|
||||
t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value())
|
||||
}
|
||||
if m.ruleFocus != 0 {
|
||||
t.Errorf("focus = %d, want tab to wrap round to the glob field", m.ruleFocus)
|
||||
}
|
||||
}
|
||||
|
||||
// newRuleListModel seeds a data root whose rules.toml holds one useful rule,
|
||||
// one shadowed rule and one that matches nothing.
|
||||
func newRuleListModel(t *testing.T) (*Model, string) {
|
||||
|
||||
Reference in New Issue
Block a user