Edit a rule from the rules screen
A rule could be written and deleted but never changed, so fixing a glob meant deleting the rule and typing it again -- losing its note, the comments around it, and its position, which still breaks ties between equally specific rules. e now opens the selected rule in the builder and enter rewrites it where it sits. config.ReplaceRule edits rules.toml textually, as deleting does, and keeps the comments above the rule: they say why it is there, which editing its glob rarely changes. The round trip must not lose what the form does not show. The builder has four fields and a Rule has five, so an edit carries the type pattern through untouched and says so under the glob; a type-only rule saves without one. The preview needed the same care in reverse: a working rule's transactions are tagged, so an untagged-only preview would be empty for it. Its own rows are added back, and the ones a narrowed glob stops catching stay on screen marked -- giving one up is the decision being made, and it must not happen silently. The builder and its list shared one return view, so opening the builder from the list left esc pointing back into the form. Each screen now remembers its own way out; transfers had the same trap and the same fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -673,3 +673,140 @@ func TestTransferToleranceIsBounded(t *testing.T) {
|
||||
t.Error("expected an out-of-range tolerance to be refused on load")
|
||||
}
|
||||
}
|
||||
|
||||
// Editing rewrites the rule where it sits: the position is what still breaks
|
||||
// ties between equally specific rules, so a rule that moved could start beating
|
||||
// one it never used to.
|
||||
func TestReplaceRuleKeepsPositionAndComments(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, RulesFile)
|
||||
if err := os.WriteFile(path, []byte(rulesWithComments), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edited := Rule{Match: "*LIDL SOFIA*", Tag: "groceries", Account: "checking", Note: "just the branch"}
|
||||
if err := ReplaceRule(root, 0, edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatalf("the file no longer parses after editing: %v", err)
|
||||
}
|
||||
if len(loaded.Rule) != 3 {
|
||||
t.Fatalf("got %d rules, want the same 3: %+v", len(loaded.Rule), loaded.Rule)
|
||||
}
|
||||
if loaded.Rule[0] != edited {
|
||||
t.Errorf("rule 1 = %+v, want %+v", loaded.Rule[0], edited)
|
||||
}
|
||||
if loaded.Rule[1].Match != "*PAYROLL*" || loaded.Rule[2].Match != "*TO SAVINGS*" {
|
||||
t.Errorf("the rest = %+v, want them where they were", loaded.Rule[1:])
|
||||
}
|
||||
|
||||
// The comment above a rule says why it is there, which editing its glob
|
||||
// does not change.
|
||||
text, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"# Rules for my accounts.", "# Weekly shop.", "# Moving money to myself."} {
|
||||
if !strings.Contains(string(text), want) {
|
||||
t.Errorf("comment %q was lost:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(text), `"*LIDL*"`) {
|
||||
t.Errorf("the old pattern is still there:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// A rule and the transfer below it share the file, so a replacement has to end
|
||||
// where the next block of any kind begins.
|
||||
func TestReplaceRuleLeavesTheOtherKindAlone(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, RulesFile)
|
||||
original := `[[rule]]
|
||||
match = "*LIDL*"
|
||||
tag = "groceries"
|
||||
|
||||
# Moving money to the broker.
|
||||
[[transfer]]
|
||||
from_account = "nlb"
|
||||
from_desc = "*TO TRADEREPUBLIC*"
|
||||
to_account = "traderepublic"
|
||||
to_desc = "*FROM NLB*"
|
||||
|
||||
[[rule]]
|
||||
match = "*ZARA*"
|
||||
tag = "clothes"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ReplaceRule(root, 0, Rule{Match: "*KAUFLAND*", Tag: "groceries"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Transfer) != 1 || loaded.Transfer[0].FromDesc != "*TO TRADEREPUBLIC*" {
|
||||
t.Errorf("transfers = %+v, want the definition untouched", loaded.Transfer)
|
||||
}
|
||||
if len(loaded.Rule) != 2 || loaded.Rule[0].Match != "*KAUFLAND*" || loaded.Rule[1].Match != "*ZARA*" {
|
||||
t.Errorf("rules = %+v, want only the first one rewritten", loaded.Rule)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(raw), "# Moving money to the broker.") {
|
||||
t.Errorf("the transfer's comment was lost:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Editing a rule that sets a type keeps it: the caller decides what the rule
|
||||
// becomes, and a pattern left out of the replacement is a pattern removed.
|
||||
func TestReplaceRuleWritesEveryPattern(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
body := "[[rule]]\nmatch = \"*SPOTIFY*\"\ntype = \"CARD_PAYMENT\"\ntag = \"music\"\n"
|
||||
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
kept := Rule{Match: "*SPOTIFY*", Type: "CARD_PAYMENT", Tag: "subscriptions"}
|
||||
if err := ReplaceRule(root, 0, kept); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Rule[0] != kept {
|
||||
t.Errorf("rule = %+v, want %+v", loaded.Rule[0], kept)
|
||||
}
|
||||
|
||||
// A type-only rule is a rule; a rule with neither pattern is not.
|
||||
if err := ReplaceRule(root, 0, Rule{Type: "CARD_PAYMENT", Tag: "cards"}); err != nil {
|
||||
t.Errorf("a type-only rule was refused: %v", err)
|
||||
}
|
||||
if err := ReplaceRule(root, 0, Rule{Tag: "nothing"}); err == nil {
|
||||
t.Error("expected a rule with no pattern to be rejected")
|
||||
}
|
||||
if err := ReplaceRule(root, 0, Rule{Match: "*X*"}); err == nil {
|
||||
t.Error("expected a rule with no tag to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceRuleOutOfRange(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := AppendRule(root, Rule{Match: "*LIDL*", Tag: "groceries"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, pos := range []int{-1, 1, 7} {
|
||||
if err := ReplaceRule(root, pos, Rule{Match: "*X*", Tag: "x"}); err == nil {
|
||||
t.Errorf("position %d: expected an error", pos)
|
||||
}
|
||||
}
|
||||
loaded, _ := LoadRules(root)
|
||||
if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*LIDL*" {
|
||||
t.Errorf("rules = %+v, want the file untouched", loaded.Rule)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user