// SPDX-License-Identifier: GPL-3.0-or-later package model import ( "strings" "testing" "krino/internal/config" ) // formsFile is a directory file with comments in the places that matter: // above a rule (its own), inside a rule, and free-standing. const formsFile = `(path "~/dl") (exclude (name "^keep-")) ;; invoices go to their year (rule "invoices" (when (and (type pdf) (content "invoice"))) (move "Invoices/{mtime:%Y}") (stop)) (rule "images" (when (type image)) ; anything the type table calls an image (move "Images") (stop)) ;; last resort (rule "rest" (move "Other")) ` // openForms puts text in an editor over the sandbox's file. func openForms(t *testing.T, text string) *Rules { t.Helper() e, _ := sandboxDir(t, formsFile, map[string]string{"a.pdf": "one"}) r, err := OpenRules(e, "dl") if err != nil { t.Fatal(err) } if text != "" { r.SetText(text) } return r } // TestFormsLists: every editable form of the file, in file order, named the // way the list shows it. func TestFormsLists(t *testing.T) { r := openForms(t, "") forms, err := r.Forms() if err != nil { t.Fatal(err) } var got []string for _, f := range forms { got = append(got, f.Label) } want := []string{`(exclude (name "^keep-"))`, "invoices", "images", "rest"} if strings.Join(got, "|") != strings.Join(want, "|") { t.Errorf("forms = %v, want %v", got, want) } if forms[0].Kind != ExcludeForm || forms[1].Kind != RuleForm { t.Errorf("kinds = %v, %v", forms[0].Kind, forms[1].Kind) } if forms[1].Rule == nil || forms[1].Rule.Name != "invoices" { t.Errorf("rule form carries no rule: %+v", forms[1]) } } // TestDeleteFormTakesItsComments: a rule's block is its form plus the // comment lines directly above it with no blank line between (GUI design // §5.1), so deleting it does not leave its comment stranded - while a // comment separated by a blank line stays. func TestDeleteFormTakesItsComments(t *testing.T) { r := openForms(t, "") if err := r.DeleteForm(1); err != nil { // invoices, with its comment t.Fatal(err) } if strings.Contains(r.Text, "invoices go to their year") { t.Errorf("the rule's own comment was left behind:\n%s", r.Text) } if strings.Contains(r.Text, `(rule "invoices"`) { t.Errorf("the rule is still there:\n%s", r.Text) } // The free-standing comment above "rest" is separated by a blank line, // so deleting that rule leaves it alone. forms, err := r.Forms() if err != nil { t.Fatal(err) } if err := r.DeleteForm(len(forms) - 1); err != nil { t.Fatal(err) } if !strings.Contains(r.Text, ";; last resort") { t.Errorf("a comment separated by a blank line was taken too:\n%s", r.Text) } if diags := r.Check(); len(diags) > 0 { t.Errorf("the file no longer loads: %v", diags) } } // TestMoveForm: a rule moves with its comments, and the order of everything // else is kept. func TestMoveForm(t *testing.T) { r := openForms(t, "") if err := r.MoveForm(2, -1); err != nil { // images, up past invoices t.Fatal(err) } forms, err := r.Forms() if err != nil { t.Fatal(err) } var got []string for _, f := range forms { got = append(got, f.Label) } want := []string{`(exclude (name "^keep-"))`, "images", "invoices", "rest"} if strings.Join(got, "|") != strings.Join(want, "|") { t.Fatalf("after moving up: %v, want %v", got, want) } // The comment travelled with its rule, and still sits above it. iInvoices := strings.Index(r.Text, `(rule "invoices"`) iComment := strings.Index(r.Text, ";; invoices go to their year") if iComment < 0 || iComment > iInvoices { t.Errorf("the comment did not travel with its rule:\n%s", r.Text) } if strings.Index(r.Text, `(rule "images"`) > iComment { t.Errorf("images did not move above invoices:\n%s", r.Text) } if diags := r.Check(); len(diags) > 0 { t.Errorf("the file no longer loads: %v", diags) } // Moving the first form up, or the last down, does nothing. before := r.Text if err := r.MoveForm(0, -1); err != nil { t.Fatal(err) } if err := r.MoveForm(len(forms)-1, 1); err != nil { t.Fatal(err) } if r.Text != before { t.Error("moving past the ends changed the file") } } // TestAddRuleAfter: a new rule lands after the one selected, is the form // the printer writes, and the file still loads. func TestAddRuleAfter(t *testing.T) { r := openForms(t, "") i, err := r.AddRuleAfter(1, "new one") if err != nil { t.Fatal(err) } forms, err := r.Forms() if err != nil { t.Fatal(err) } if i != 2 || forms[2].Label != "new one" { t.Fatalf("new rule at %d: %v", i, labels(forms)) } if diags := r.Check(); len(diags) > 0 { t.Errorf("the file no longer loads: %v", diags) } } // TestReplaceForm: editing one form rewrites that form's text and nothing // else - every comment and every other line stays byte for byte. func TestReplaceForm(t *testing.T) { r := openForms(t, "") forms, err := r.Forms() if err != nil { t.Fatal(err) } rule := *forms[1].Rule // invoices rule.Actions = []config.Action{{Kind: config.Move, Arg: "Faktury"}} if err := r.ReplaceForm(1, config.PrintRule(&rule)); err != nil { t.Fatal(err) } if !strings.Contains(r.Text, `(move "Faktury")`) { t.Errorf("the edit did not land:\n%s", r.Text) } if strings.Contains(r.Text, `(move "Invoices/{mtime:%Y}")`) { t.Errorf("the old action is still there:\n%s", r.Text) } for _, keep := range []string{";; invoices go to their year", ";; last resort", "; anything the type table calls an image", `(exclude (name "^keep-"))`} { if !strings.Contains(r.Text, keep) { t.Errorf("replacing one form lost %q:\n%s", keep, r.Text) } } if diags := r.Check(); len(diags) > 0 { t.Errorf("the file no longer loads: %v", diags) } } // TestFormHasComments: a form with a comment inside it cannot be rewritten // from a form editor without losing it, so the caller is warned; a // semicolon inside a string is not a comment (GUI design §5.1). func TestFormHasComments(t *testing.T) { r := openForms(t, "") forms, err := r.Forms() if err != nil { t.Fatal(err) } if got, err := r.FormHasComments(2); err != nil || !got { // images t.Errorf("FormHasComments(images) = %v, %v; want true", got, err) } if got, err := r.FormHasComments(1); err != nil || got { // invoices t.Errorf("FormHasComments(invoices) = %v, %v; want false", got, err) } _ = forms r.SetText("(path \"~/dl\")\n(rule \"semi\" (when (name \"a;b\")) (move \"Out\"))\n") if got, err := r.FormHasComments(0); err != nil || got { t.Errorf("a semicolon inside a string counted as a comment: %v, %v", got, err) } } // TestFormsRefusesBrokenText: the forms list comes from parsing, so text // that does not parse has no forms to show, and says so. func TestFormsRefusesBrokenText(t *testing.T) { r := openForms(t, "(path \"~/dl\")\n(rule \"unclosed\"\n") if _, err := r.Forms(); err == nil { t.Error("Forms answered for text that does not parse") } if err := r.DeleteForm(0); err == nil { t.Error("DeleteForm acted on text that does not parse") } } func labels(forms []Form) []string { var out []string for _, f := range forms { out = append(out, f.Label) } return out }