aboutsummaryrefslogtreecommitdiff
path: root/internal/cond/eval_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cond/eval_test.go')
-rw-r--r--internal/cond/eval_test.go222
1 files changed, 222 insertions, 0 deletions
diff --git a/internal/cond/eval_test.go b/internal/cond/eval_test.go
new file mode 100644
index 0000000..3607978
--- /dev/null
+++ b/internal/cond/eval_test.go
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package cond
+
+import (
+ "errors"
+ "math/rand"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/norm"
+)
+
+var now = time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)
+
+type fake struct {
+ name, rel, raw string
+ rawErr error
+ size int64
+ age time.Duration
+ matched bool
+ dupOrig string
+ dupOK bool
+ contentCalls int
+}
+
+func (f *fake) Name() string { return f.name }
+func (f *fake) Rel() string {
+ if f.rel != "" {
+ return f.rel
+ }
+ return f.name
+}
+func (f *fake) Size() int64 { return f.size }
+func (f *fake) ModTime() time.Time { return now.Add(-f.age) }
+func (f *fake) Now() time.Time { return now }
+func (f *fake) Matched() bool { return f.matched }
+func (f *fake) Content(ic, fold bool) (string, error) {
+ f.contentCalls++
+ if f.rawErr != nil {
+ return "", f.rawErr
+ }
+ return norm.Text(f.raw, ic, fold), nil
+}
+func (f *fake) Duplicate(dirs []string) (string, bool, error) { return f.dupOrig, f.dupOK, nil }
+
+func eval(t *testing.T, src string, opt Options, f Facts) Result {
+ t.Helper()
+ c, errs := Compile("d.conf", nodes(t, src), opt)
+ if len(errs) > 0 {
+ t.Fatalf("%s: %v", src, errs)
+ }
+ return c.Eval(f)
+}
+
+func TestTruth(t *testing.T) {
+ ic := Options{IgnoreCase: true}
+ pdf := &fake{name: "Scan001.PDF", raw: "Invoice from ACME LTD", size: 20 << 20, age: 40 * 24 * time.Hour}
+ tests := []struct {
+ src string
+ want bool
+ }{
+ {``, true},
+ {`(type pdf)`, true},
+ {`(type document)`, true},
+ {`(type jpg)`, false},
+ {`(type pdf) (size > 10M)`, true},
+ {`(type pdf) (size < 10M)`, false},
+ {`(or (type jpg) (content "acme ltd"))`, true},
+ {`(not (content "acme ltd"))`, false},
+ {`(age > 30d) (age <= 41d)`, true},
+ {`(name "^scan\d+")`, true},
+ {`(name "(?-i)^scan")`, false},
+ {`(matched)`, false},
+ {`(and (type pdf) (or (name "^x") (not (name "^y"))))`, true},
+ }
+ for _, tt := range tests {
+ if got := eval(t, tt.src, ic, pdf); got.Match != tt.want {
+ t.Errorf("%s = %v, want %v (%+v)", tt.src, got.Match, tt.want, got)
+ }
+ }
+}
+
+func TestCaseAndFold(t *testing.T) {
+ f := &fake{name: "SPOLKA-umowa.pdf", raw: "Umowa: spółka z o.o."}
+ if !eval(t, `(name "spółka")`, Options{IgnoreCase: true, Fold: true}, f).Match {
+ t.Error("folded, case-ignoring name did not match")
+ }
+ if eval(t, `(name "spółka")`, Options{IgnoreCase: true, Fold: false}, f).Match {
+ t.Error("matched without folding")
+ }
+ if !eval(t, `(content "SPOLKA Z O.O.")`, Options{IgnoreCase: true, Fold: true}, f).Match {
+ t.Error("folded content did not match")
+ }
+ img := &fake{name: "IMG_0001.jpg"}
+ if eval(t, `(name "^img")`, Options{IgnoreCase: false}, img).Match {
+ t.Error("strict case matched")
+ }
+ if !eval(t, `(name "(?i)^img")`, Options{IgnoreCase: false}, img).Match {
+ t.Error("inline (?i) did not override strict case")
+ }
+}
+
+func TestReasonsAndCaptures(t *testing.T) {
+ f := &fake{name: "Screenshot_20260911.png", raw: "acme ltd"}
+ r := eval(t, `(type image) (name "^Screenshot_(\d{4})(\d{2})") (not (name "^x(y)"))`, Options{IgnoreCase: true}, f)
+ if !r.Match {
+ t.Fatal("no match")
+ }
+ if want := []string{"Screenshot_202609", "2026", "09"}; !reflect.DeepEqual(r.Captures, want) {
+ t.Errorf("captures = %q, want %q", r.Captures, want)
+ }
+ if want := []string{`type png`, `name "^Screenshot_(\d{4})(\d{2})"`, `not name "^x(y)"`}; !reflect.DeepEqual(r.Reasons, want) {
+ t.Errorf("reasons = %q, want %q", r.Reasons, want)
+ }
+ r = eval(t, `(or (content "nope" "acme ltd") (type png))`, Options{IgnoreCase: true}, f)
+ if want := []string{`type png`}; !reflect.DeepEqual(r.Reasons, want) {
+ t.Errorf("or reasons = %q, want %q (cheapest true child)", r.Reasons, want)
+ }
+ r = eval(t, `(content "nope" "acme ltd")`, Options{IgnoreCase: true}, f)
+ if want := []string{`content "acme ltd"`}; !reflect.DeepEqual(r.Reasons, want) {
+ t.Errorf("content reasons = %q, want %q", r.Reasons, want)
+ }
+ if r := eval(t, ``, Options{}, f); !reflect.DeepEqual(r.Reasons, []string{"no condition"}) {
+ t.Errorf("empty reasons = %q", r.Reasons)
+ }
+ d := &fake{name: "report (1).pdf", dupOrig: "report.pdf", dupOK: true}
+ if r := eval(t, `(duplicate)`, Options{}, d); !r.Match || r.Reasons[0] != "duplicate of report.pdf" {
+ t.Errorf("duplicate = %+v", r)
+ }
+}
+
+func TestCheapFirstAvoidsContent(t *testing.T) {
+ f := &fake{name: "a.txt", raw: "x"}
+ eval(t, `(and (content "x") (type pdf))`, Options{}, f)
+ if f.contentCalls != 0 {
+ t.Errorf("content read although type was false (%d calls)", f.contentCalls)
+ }
+ g := &fake{name: "a.pdf", raw: "x"}
+ eval(t, `(or (content "x") (type pdf))`, Options{}, g)
+ if g.contentCalls != 0 {
+ t.Errorf("content read although type was true (%d calls)", g.contentCalls)
+ }
+}
+
+func TestContentErrorWarns(t *testing.T) {
+ f := &fake{name: "a.pdf", rawErr: errors.New("needs pdftotext, not installed")}
+ r := eval(t, `(or (content "acme") (content "other"))`, Options{}, f)
+ if r.Match {
+ t.Fatal("matched unreadable content")
+ }
+ if want := []string{"content unreadable: needs pdftotext, not installed"}; !reflect.DeepEqual(r.Warnings, want) {
+ t.Errorf("warnings = %q, want %q (de-duplicated)", r.Warnings, want)
+ }
+}
+
+func TestExplainFormat(t *testing.T) {
+ f := &fake{name: "scan.pdf", rawErr: errors.New("needs pdftotext, not installed")}
+ c, _ := Compile("d.conf", nodes(t, `(type pdf) (or (content "acme ltd") (name "\bacme\b"))`), Options{IgnoreCase: true})
+ var b strings.Builder
+ c.Explain(f).Format(&b)
+ want := "no and\n" +
+ "yes type pdf\n" +
+ "no or\n" +
+ "no name \"\\bacme\\b\"\n" +
+ "no content \"acme ltd\" (content unreadable: needs pdftotext, not installed)\n"
+ if b.String() != want {
+ t.Fatalf("got\n%s\nwant\n%s", b.String(), want)
+ }
+}
+
+// TestReorderKeepsMeaning: random trees give the same answer with and without
+// cost reordering.
+func TestReorderKeepsMeaning(t *testing.T) {
+ leaves := []string{`(type pdf)`, `(type txt)`, `(name "^a")`, `(name "b$")`, `(size > 10)`, `(size < 5)`,
+ `(age > 1d)`, `(content "x")`, `(content "y")`, `(matched)`}
+ rng := rand.New(rand.NewSource(1))
+ var gen func(depth int) string
+ gen = func(depth int) string {
+ if depth == 0 || rng.Intn(3) == 0 {
+ return leaves[rng.Intn(len(leaves))]
+ }
+ switch rng.Intn(3) {
+ case 0:
+ return "(not " + gen(depth-1) + ")"
+ case 1:
+ return "(and " + gen(depth-1) + " " + gen(depth-1) + ")"
+ default:
+ return "(or " + gen(depth-1) + " " + gen(depth-1) + " " + gen(depth-1) + ")"
+ }
+ }
+ names := []string{"a.pdf", "b.txt", "ab.pdf", "c.jpg"}
+ for i := 0; i < 500; i++ {
+ src := gen(4)
+ f := &fake{name: names[rng.Intn(len(names))], raw: []string{"x", "y", "xy", ""}[rng.Intn(4)],
+ size: int64(rng.Intn(20)), age: time.Duration(rng.Intn(72)) * time.Hour, matched: rng.Intn(2) == 0}
+ a, _ := compile("d.conf", nodes(t, src), Options{}, true)
+ b, _ := compile("d.conf", nodes(t, src), Options{}, false)
+ if ra, rb := a.Eval(f), b.Eval(f); ra.Match != rb.Match {
+ t.Fatalf("%s on %+v: reordered %v, original %v", src, f, ra.Match, rb.Match)
+ }
+ }
+}
+
+// TestNegatedCombinatorReason: E4. Negating a combinator must read as
+// something a person would write ("not (and ...)" / "not (or ...)"), not
+// the bare "not and" / "not or"; a negated leaf keeps its own label
+// unchanged ("not matched").
+func TestNegatedCombinatorReason(t *testing.T) {
+ f := &fake{name: "a.pdf"}
+ if r := eval(t, `(not (and (type pdf) (matched)))`, Options{}, f); !r.Match || r.Reasons[0] != "not (and ...)" {
+ t.Errorf("negated and = %+v, want reason %q", r, "not (and ...)")
+ }
+ if r := eval(t, `(not (or (matched) (name "^zzz")))`, Options{}, f); !r.Match || r.Reasons[0] != "not (or ...)" {
+ t.Errorf("negated or = %+v, want reason %q", r, "not (or ...)")
+ }
+ if r := eval(t, `(not (matched))`, Options{}, f); !r.Match || r.Reasons[0] != "not matched" {
+ t.Errorf("negated leaf = %+v, want its own label unchanged: %q", r, "not matched")
+ }
+}