internal/rules implements Stufe 2 from the core principle: the LLM extracts facts, this deterministic engine judges them against versioned YAML rules. Facts/Condition/Rule/Finding types, a loader that refuses to load on a missing id/version or a duplicate rule id rather than silently skipping a bad file, and Evaluate() matching facts against rules. Two real rules grounded in verified research (see rules/OPEN.md for the open questions that surfaced along the way): - WK-001: no disclosure at all despite consideration (§ 5a Abs. 4 UWG, § 22 Abs. 1 MStV) - WK-004: disclosure present but hidden behind a "mehr anzeigen" cut (§ 5a Abs. 4 UWG, Leitfaden der Medienanstalten, LG Köln 12.05.2026) The two are deliberately disjoint (WK-004 requires disclosure_present= true) so a post with no disclosure at all doesn't double-fire both rules. Golden suite in testdata/golden/ covers both rules plus two clean cases; it's this suite, not the UI, that's the actual asset per CLAUDE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
102 lines
2.4 KiB
Go
102 lines
2.4 KiB
Go
package rules_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/netcell-it/deklarix/internal/rules"
|
|
)
|
|
|
|
// goldenCase spiegelt eine Datei aus testdata/golden/: die extrahierten
|
|
// Fakten eines Beispielbeitrags plus die Findings, die das Regelwerk
|
|
// dafür liefern muss. Das ist das eigentliche Asset des Projekts, nicht
|
|
// die UI — siehe CLAUDE.md.
|
|
type goldenCase struct {
|
|
Name string `json:"name"`
|
|
Facts rules.Facts `json:"facts"`
|
|
ExpectedFindings []goldenFinding `json:"expected_findings"`
|
|
}
|
|
|
|
type goldenFinding struct {
|
|
RuleID string `json:"rule_id"`
|
|
RuleVersion int `json:"rule_version"`
|
|
Severity string `json:"severity"`
|
|
}
|
|
|
|
const (
|
|
rulesDir = "../../rules"
|
|
goldenDir = "../../testdata/golden"
|
|
)
|
|
|
|
func TestGolden(t *testing.T) {
|
|
ruleSet, err := rules.Load(os.DirFS(rulesDir))
|
|
if err != nil {
|
|
t.Fatalf("Load rules: %v", err)
|
|
}
|
|
|
|
entries, err := os.ReadDir(goldenDir)
|
|
if err != nil {
|
|
t.Fatalf("read golden dir: %v", err)
|
|
}
|
|
|
|
found := 0
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
|
continue
|
|
}
|
|
found++
|
|
entry := entry
|
|
t.Run(entry.Name(), func(t *testing.T) {
|
|
data, err := os.ReadFile(filepath.Join(goldenDir, entry.Name()))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", entry.Name(), err)
|
|
}
|
|
var gc goldenCase
|
|
if err := json.Unmarshal(data, &gc); err != nil {
|
|
t.Fatalf("parse %s: %v", entry.Name(), err)
|
|
}
|
|
|
|
got := rules.Evaluate(ruleSet, gc.Facts)
|
|
|
|
gotKeys := make([]string, 0, len(got))
|
|
for _, f := range got {
|
|
gotKeys = append(gotKeys, findingKey(f.RuleID, f.RuleVersion, string(f.Severity)))
|
|
}
|
|
wantKeys := make([]string, 0, len(gc.ExpectedFindings))
|
|
for _, ef := range gc.ExpectedFindings {
|
|
wantKeys = append(wantKeys, findingKey(ef.RuleID, ef.RuleVersion, ef.Severity))
|
|
}
|
|
sort.Strings(gotKeys)
|
|
sort.Strings(wantKeys)
|
|
|
|
if !equalStrings(gotKeys, wantKeys) {
|
|
t.Fatalf("%s: got findings %v, want %v", gc.Name, gotKeys, wantKeys)
|
|
}
|
|
})
|
|
}
|
|
|
|
if found == 0 {
|
|
t.Fatal("no golden cases found in " + goldenDir)
|
|
}
|
|
}
|
|
|
|
func findingKey(ruleID string, version int, severity string) string {
|
|
return ruleID + "/" + strconv.Itoa(version) + "/" + severity
|
|
}
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|