Files
deklarix/internal/rules/golden_test.go
noroot da91c3e2c1 feat: add Claude API extraction (internal/extract)
Stufe 1 from the core principle: Client calls the Claude Messages API
directly over net/http (no SDK dependency, stays consistent with
"Go-Standard-Library wo möglich") and forces tool-use with a strict JSON
schema instead of parsing free text. Extract() returns rules.Facts
directly rather than an intermediate DTO, since producing exactly that
is the point of this stage. Platform is supplied by the caller, never
guessed by the model.

Every failure mode returns an error instead of a zero-value Facts:
network errors, non-200 API responses, a missing tool_use block, and —
critically — a gegenleistung value outside the four allowed enum
values, which would otherwise get silently coerced into a wrong fact.
Tested entirely against an httptest fake server, no real API calls.

Building this surfaced a real gap in internal/rules: Evaluate() treated
Consideration=="unklar" the same as any other value, i.e. it just
produced an empty finding list — indistinguishable from "everything's
fine". That contradicts the core principle (uncertain extraction should
trigger a user clarification, never a judgment). Evaluate() now returns
(findings, needsClarification), with a golden case covering it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:43:01 +02:00

107 lines
2.7 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"`
ExpectNeedsClarification bool `json:"expect_needs_clarification"`
}
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, needsClarification := rules.Evaluate(ruleSet, gc.Facts)
if needsClarification != gc.ExpectNeedsClarification {
t.Fatalf("%s: needsClarification = %v, want %v", gc.Name, needsClarification, gc.ExpectNeedsClarification)
}
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
}