// SPDX-License-Identifier: GPL-3.0-or-later package model import ( "context" "errors" "os" "path/filepath" "strings" "testing" "krino/internal/engine" ) // TestOpenAndCheck: the editor opens a directory's own file, reports the // mistakes in unsaved text with their positions, and says so when there are // none (GUI design §5.3). func TestOpenAndCheck(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) r, err := OpenRules(e, "dl") if err != nil { t.Fatal(err) } if r.Text != conf { t.Fatalf("text = %q, want the file as written", r.Text) } if diags := r.Check(); len(diags) != 0 { t.Fatalf("a good file reports %v", diags) } if r.Modified() { t.Error("an untouched file counts as modified") } // An unknown placeholder is refused at check time (plan 12 task 3), and // the position is the action's, inside this file. r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Out/{nope}\"))\n") if !r.Modified() { t.Error("edited text does not count as modified") } diags := r.Check() if len(diags) != 1 { t.Fatalf("diags = %v, want one", diags) } if diags[0].File != r.File || diags[0].Pos.Line != 2 { t.Errorf("diag = %s, want it in %s at line 2", diags[0], r.File) } if !strings.Contains(diags[0].Msg, "nope") { t.Errorf("message does not name the placeholder: %s", diags[0].Msg) } // Checking never writes: the file on disk still holds the good text. on, _ := os.ReadFile(r.File) if string(on) != conf { t.Errorf("check wrote to the file: %q", on) } } // TestSaveKeepsABackup: saving writes the text, keeps what was there as // NAME.conf.bak, and the engine then loads the new rules (GUI design §5.3). func TestSaveKeepsABackup(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) r, err := OpenRules(e, "dl") if err != nil { t.Fatal(err) } next := "(path \"~/dl\")\n(rule \"all\" (move \"Elsewhere\"))\n" r.SetText(next) if err := r.Save(); err != nil { t.Fatal(err) } if on, _ := os.ReadFile(r.File); string(on) != next { t.Errorf("file = %q, want the new text", on) } bak := filepath.Join(filepath.Dir(r.File), "dl.conf.bak") if on, _ := os.ReadFile(bak); string(on) != conf { t.Errorf("backup = %q, want the previous text", on) } if r.Modified() { t.Error("a saved file still counts as modified") } // The saved rules are what krino now reads. fresh := reload(t, h) tab, err := Plan(context.Background(), fresh, fresh.Dirs[0]) if err != nil { t.Fatal(err) } defer tab.Close() if len(tab.Rows) != 1 || len(tab.Rows[0].Steps) == 0 || !strings.Contains(tab.Rows[0].Steps[0].Dst, "Elsewhere") { t.Errorf("the saved rule is not in effect: %+v", tab.Rows) } } // TestSaveRefusesBrokenRules: a file that will not load is not written, so // a window cannot leave krino unable to run (GUI design §5.3). func TestSaveRefusesBrokenRules(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) r, err := OpenRules(e, "dl") if err != nil { t.Fatal(err) } r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Out/{nope}\"))\n") err = r.Save() if err == nil { t.Fatal("Save accepted a file with errors") } if !strings.Contains(err.Error(), "nope") { t.Errorf("the refusal does not say what is wrong: %v", err) } if on, _ := os.ReadFile(r.File); string(on) != conf { t.Errorf("the refused save wrote anyway: %q", on) } if _, err := os.Stat(filepath.Join(filepath.Dir(r.File), "dl.conf.bak")); !os.IsNotExist(err) { t.Error("the refused save left a backup") } } // TestSaveRefusesAFileChangedOnDisk: something else edited the file while // the window had it open, so saving would lose that edit; the caller is // told, and can Reload (GUI design §5.3). func TestSaveRefusesAFileChangedOnDisk(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) r, err := OpenRules(e, "dl") if err != nil { t.Fatal(err) } elsewhere := "(path \"~/dl\")\n(rule \"all\" (move \"Elsewhere\"))\n" if err := os.WriteFile(r.File, []byte(elsewhere), 0o644); err != nil { t.Fatal(err) } r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Mine\"))\n") if err := r.Save(); !errors.Is(err, ErrChangedOnDisk) { t.Fatalf("Save = %v, want ErrChangedOnDisk", err) } if on, _ := os.ReadFile(r.File); string(on) != elsewhere { t.Errorf("the refused save overwrote the other edit: %q", on) } // Overwrite keeps the other edit as the backup rather than losing it. if err := r.SaveOverwriting(); err != nil { t.Fatal(err) } if on, _ := os.ReadFile(r.File); !strings.Contains(string(on), "Mine") { t.Errorf("overwrite did not save: %q", on) } if on, _ := os.ReadFile(r.File + ".bak"); string(on) != elsewhere { t.Errorf("backup = %q, want the edit that was overwritten", on) } // And from the other direction: reload takes the other edit and drops // ours; saving then works. if err := os.WriteFile(r.File, []byte(elsewhere), 0o644); err != nil { t.Fatal(err) } if err := r.Reload(); err != nil { t.Fatal(err) } if r.Text != elsewhere { t.Errorf("reload = %q, want what is on disk", r.Text) } r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Mine\"))\n") if err := r.Save(); err != nil { t.Errorf("save after reload: %v", err) } } // TestExplainWithUnsavedText: Test on file answers for the text in the // editor, not for what is on disk (GUI design §5.3). func TestExplainWithUnsavedText(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Out\"))\n" e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) r, err := OpenRules(e, "dl") if err != nil { t.Fatal(err) } file := filepath.Join(h, "dl", "a.pdf") text, err := r.Explain(context.Background(), file) if err != nil { t.Fatal(err) } if !strings.Contains(text, "rule pdfs: MATCH") || !strings.Contains(text, "Out") { t.Errorf("explanation of the saved rules = %q", text) } r.SetText("(path \"~/dl\")\n(rule \"pdfs\" (when (type image)) (move \"Out\"))\n") text, err = r.Explain(context.Background(), file) if err != nil { t.Fatal(err) } if strings.Contains(text, "MATCH") { t.Errorf("the unsaved rule was ignored: %q", text) } // The unsaved text is never written by a test run. if on, _ := os.ReadFile(r.File); string(on) != conf { t.Errorf("Test on file wrote to the config: %q", on) } } // TestExplainReportsBrokenRules: testing a file against text that will not // load says so instead of answering from the saved rules. func TestExplainReportsBrokenRules(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Out\"))\n" e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) r, err := OpenRules(e, "dl") if err != nil { t.Fatal(err) } r.SetText("(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Out/{nope}\"))\n") if _, err := r.Explain(context.Background(), filepath.Join(h, "dl", "a.pdf")); err == nil { t.Fatal("Test on file answered from a file that will not load") } } // TestRuleFilesLister: the picker offers every included directory that has // a file of its own. func TestRuleFilesLister(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) if got := RuleFiles(e); len(got) != 1 || got[0] != "dl" { t.Errorf("RuleFiles = %v, want [dl]", got) } if _, err := OpenRules(e, "nosuch"); err == nil { t.Error("OpenRules accepted a directory that is not included") } } // reload builds a fresh engine from the sandbox's config, the way the next // krino run would read it. func reload(t *testing.T, home string) *engine.Engine { t.Helper() e, errs := engine.Load(filepath.Join(home, ".config", "krino", "krino.conf")) if len(errs) > 0 { t.Fatal(errs) } return e }