diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
| commit | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch) | |
| tree | ecbb277ff916b719f2ee45fba017792b85d5faf9 /internal/cond/eval_test.go | |
| parent | 42b02c47be9b285099203e44a2570636d4ca6f03 (diff) | |
| download | krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip | |
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/cond/eval_test.go')
| -rw-r--r-- | internal/cond/eval_test.go | 222 |
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") + } +} |
