Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/classifier_test.go
Benjamin Boenisch 06711bad1c
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 44s
CI / test-python-backend-compliance (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 20s
feat(sdk,iace): add Personalized Drafting Pipeline v2 and IACE engine
Drafting Engine: 7-module pipeline with narrative tags, allowed facts governance,
PII sanitizer, prose validator with repair loop, hash-based cache, and terminology
guide. v1 fallback via ?v=1 query param.

IACE: Initial AI-Act Conformity Engine with risk classifier, completeness checker,
hazard library, and PostgreSQL store for AI system assessments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:27:06 +01:00

554 lines
18 KiB
Go

package iace
import (
"encoding/json"
"testing"
)
func TestClassifyAIAct(t *testing.T) {
c := NewClassifier()
tests := []struct {
name string
project *Project
components []Component
wantResult string
wantRiskLevel string
wantReqsEmpty bool
wantConfidence float64
}{
{
name: "no AI components returns not_applicable",
project: &Project{MachineName: "TestMachine"},
components: []Component{
{Name: "PLC", ComponentType: ComponentTypeSoftware},
{Name: "Ethernet", ComponentType: ComponentTypeNetwork},
},
wantResult: "not_applicable",
wantRiskLevel: "none",
wantReqsEmpty: true,
wantConfidence: 0.95,
},
{
name: "no components at all returns not_applicable",
project: &Project{MachineName: "EmptyMachine"},
components: []Component{},
wantResult: "not_applicable",
wantRiskLevel: "none",
wantReqsEmpty: true,
wantConfidence: 0.95,
},
{
name: "AI model not safety relevant returns limited_risk",
project: &Project{MachineName: "VisionMachine"},
components: []Component{
{Name: "QualityChecker", ComponentType: ComponentTypeAIModel, IsSafetyRelevant: false},
},
wantResult: "limited_risk",
wantRiskLevel: "medium",
wantReqsEmpty: false,
wantConfidence: 0.85,
},
{
name: "safety-relevant AI model returns high_risk",
project: &Project{MachineName: "SafetyMachine"},
components: []Component{
{Name: "SafetyAI", ComponentType: ComponentTypeAIModel, IsSafetyRelevant: true},
},
wantResult: "high_risk",
wantRiskLevel: "high",
wantReqsEmpty: false,
wantConfidence: 0.9,
},
{
name: "mixed components with safety-relevant AI returns high_risk",
project: &Project{MachineName: "ComplexMachine"},
components: []Component{
{Name: "PLC", ComponentType: ComponentTypeSoftware},
{Name: "BasicAI", ComponentType: ComponentTypeAIModel, IsSafetyRelevant: false},
{Name: "SafetyAI", ComponentType: ComponentTypeAIModel, IsSafetyRelevant: true},
{Name: "Cam", ComponentType: ComponentTypeSensor},
},
wantResult: "high_risk",
wantRiskLevel: "high",
wantReqsEmpty: false,
wantConfidence: 0.9,
},
{
name: "non-AI safety-relevant component does not trigger AI act",
project: &Project{MachineName: "SafetySoftwareMachine"},
components: []Component{
{Name: "SafetyPLC", ComponentType: ComponentTypeSoftware, IsSafetyRelevant: true},
},
wantResult: "not_applicable",
wantRiskLevel: "none",
wantReqsEmpty: true,
wantConfidence: 0.95,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := c.ClassifyAIAct(tt.project, tt.components)
if result.Regulation != RegulationAIAct {
t.Errorf("Regulation = %q, want %q", result.Regulation, RegulationAIAct)
}
if result.ClassificationResult != tt.wantResult {
t.Errorf("ClassificationResult = %q, want %q", result.ClassificationResult, tt.wantResult)
}
if result.RiskLevel != tt.wantRiskLevel {
t.Errorf("RiskLevel = %q, want %q", result.RiskLevel, tt.wantRiskLevel)
}
if (result.Requirements == nil || len(result.Requirements) == 0) != tt.wantReqsEmpty {
t.Errorf("Requirements empty = %v, want %v", result.Requirements == nil || len(result.Requirements) == 0, tt.wantReqsEmpty)
}
if result.Confidence != tt.wantConfidence {
t.Errorf("Confidence = %f, want %f", result.Confidence, tt.wantConfidence)
}
if result.Reasoning == "" {
t.Error("Reasoning should not be empty")
}
})
}
}
func TestClassifyMachineryRegulation(t *testing.T) {
c := NewClassifier()
tests := []struct {
name string
project *Project
components []Component
wantResult string
wantRiskLevel string
wantReqsLen int
}{
{
name: "no CE target and no safety SW returns standard",
project: &Project{MachineName: "BasicMachine", CEMarkingTarget: ""},
components: []Component{{Name: "App", ComponentType: ComponentTypeSoftware}},
wantResult: "standard",
wantRiskLevel: "low",
wantReqsLen: 3,
},
{
name: "CE target set returns applicable",
project: &Project{MachineName: "CEMachine", CEMarkingTarget: "2023/1230"},
components: []Component{{Name: "App", ComponentType: ComponentTypeSoftware}},
wantResult: "applicable",
wantRiskLevel: "medium",
wantReqsLen: 5,
},
{
name: "safety-relevant software overrides CE target to annex_iii",
project: &Project{MachineName: "SafetyMachine", CEMarkingTarget: "2023/1230"},
components: []Component{{Name: "SafetyPLC", ComponentType: ComponentTypeSoftware, IsSafetyRelevant: true}},
wantResult: "annex_iii",
wantRiskLevel: "high",
wantReqsLen: 7,
},
{
name: "safety-relevant firmware returns annex_iii",
project: &Project{MachineName: "FirmwareMachine", CEMarkingTarget: ""},
components: []Component{{Name: "SafetyFW", ComponentType: ComponentTypeFirmware, IsSafetyRelevant: true}},
wantResult: "annex_iii",
wantRiskLevel: "high",
wantReqsLen: 7,
},
{
name: "safety-relevant non-SW component does not trigger annex_iii",
project: &Project{MachineName: "SensorMachine", CEMarkingTarget: ""},
components: []Component{
{Name: "SafetySensor", ComponentType: ComponentTypeSensor, IsSafetyRelevant: true},
},
wantResult: "standard",
wantRiskLevel: "low",
wantReqsLen: 3,
},
{
name: "AI model safety-relevant does not trigger annex_iii (not software/firmware type)",
project: &Project{MachineName: "AIModelMachine", CEMarkingTarget: ""},
components: []Component{
{Name: "SafetyAI", ComponentType: ComponentTypeAIModel, IsSafetyRelevant: true},
},
wantResult: "standard",
wantRiskLevel: "low",
wantReqsLen: 3,
},
{
name: "empty components with no CE target returns standard",
project: &Project{MachineName: "EmptyMachine"},
components: []Component{},
wantResult: "standard",
wantRiskLevel: "low",
wantReqsLen: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := c.ClassifyMachineryRegulation(tt.project, tt.components)
if result.Regulation != RegulationMachineryRegulation {
t.Errorf("Regulation = %q, want %q", result.Regulation, RegulationMachineryRegulation)
}
if result.ClassificationResult != tt.wantResult {
t.Errorf("ClassificationResult = %q, want %q", result.ClassificationResult, tt.wantResult)
}
if result.RiskLevel != tt.wantRiskLevel {
t.Errorf("RiskLevel = %q, want %q", result.RiskLevel, tt.wantRiskLevel)
}
if len(result.Requirements) != tt.wantReqsLen {
t.Errorf("Requirements length = %d, want %d", len(result.Requirements), tt.wantReqsLen)
}
if result.Reasoning == "" {
t.Error("Reasoning should not be empty")
}
})
}
}
func TestClassifyCRA(t *testing.T) {
c := NewClassifier()
tests := []struct {
name string
project *Project
components []Component
wantResult string
wantRiskLevel string
wantReqsNil bool
}{
{
name: "no networked components returns not_applicable",
project: &Project{MachineName: "OfflineMachine"},
components: []Component{{Name: "PLC", ComponentType: ComponentTypeSoftware, IsNetworked: false}},
wantResult: "not_applicable",
wantRiskLevel: "none",
wantReqsNil: true,
},
{
name: "empty components returns not_applicable",
project: &Project{MachineName: "EmptyMachine"},
components: []Component{},
wantResult: "not_applicable",
wantRiskLevel: "none",
wantReqsNil: true,
},
{
name: "networked generic software returns default",
project: &Project{MachineName: "GenericNetworkedMachine"},
components: []Component{
{Name: "App", ComponentType: ComponentTypeSoftware, IsNetworked: true},
},
wantResult: "default",
wantRiskLevel: "low",
wantReqsNil: false,
},
{
name: "networked controller returns class_i",
project: &Project{MachineName: "ControllerMachine"},
components: []Component{
{Name: "MainPLC", ComponentType: ComponentTypeController, IsNetworked: true},
},
wantResult: "class_i",
wantRiskLevel: "medium",
wantReqsNil: false,
},
{
name: "networked network component returns class_i",
project: &Project{MachineName: "NetworkMachine"},
components: []Component{
{Name: "Switch", ComponentType: ComponentTypeNetwork, IsNetworked: true},
},
wantResult: "class_i",
wantRiskLevel: "medium",
wantReqsNil: false,
},
{
name: "networked sensor returns class_i",
project: &Project{MachineName: "SensorMachine"},
components: []Component{
{Name: "IoTSensor", ComponentType: ComponentTypeSensor, IsNetworked: true},
},
wantResult: "class_i",
wantRiskLevel: "medium",
wantReqsNil: false,
},
{
name: "safety-relevant networked component returns class_ii",
project: &Project{MachineName: "SafetyNetworkedMachine"},
components: []Component{
{Name: "SafetyNet", ComponentType: ComponentTypeSoftware, IsNetworked: true, IsSafetyRelevant: true},
},
wantResult: "class_ii",
wantRiskLevel: "high",
wantReqsNil: false,
},
{
name: "safety-relevant overrides critical type",
project: &Project{MachineName: "MixedMachine"},
components: []Component{
{Name: "PLC", ComponentType: ComponentTypeController, IsNetworked: true, IsSafetyRelevant: true},
},
wantResult: "class_ii",
wantRiskLevel: "high",
wantReqsNil: false,
},
{
name: "non-networked critical type is not_applicable",
project: &Project{MachineName: "OfflineControllerMachine"},
components: []Component{
{Name: "PLC", ComponentType: ComponentTypeController, IsNetworked: false},
},
wantResult: "not_applicable",
wantRiskLevel: "none",
wantReqsNil: true,
},
{
name: "HMI networked but not critical type returns default",
project: &Project{MachineName: "HMIMachine"},
components: []Component{
{Name: "Panel", ComponentType: ComponentTypeHMI, IsNetworked: true},
},
wantResult: "default",
wantRiskLevel: "low",
wantReqsNil: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := c.ClassifyCRA(tt.project, tt.components)
if result.Regulation != RegulationCRA {
t.Errorf("Regulation = %q, want %q", result.Regulation, RegulationCRA)
}
if result.ClassificationResult != tt.wantResult {
t.Errorf("ClassificationResult = %q, want %q", result.ClassificationResult, tt.wantResult)
}
if result.RiskLevel != tt.wantRiskLevel {
t.Errorf("RiskLevel = %q, want %q", result.RiskLevel, tt.wantRiskLevel)
}
if (result.Requirements == nil) != tt.wantReqsNil {
t.Errorf("Requirements nil = %v, want %v", result.Requirements == nil, tt.wantReqsNil)
}
if result.Reasoning == "" {
t.Error("Reasoning should not be empty")
}
})
}
}
func TestClassifyNIS2(t *testing.T) {
c := NewClassifier()
tests := []struct {
name string
metadata json.RawMessage
wantResult string
}{
{
name: "nil metadata returns not_applicable",
metadata: nil,
wantResult: "not_applicable",
},
{
name: "empty JSON object returns not_applicable",
metadata: json.RawMessage(`{}`),
wantResult: "not_applicable",
},
{
name: "invalid JSON returns not_applicable",
metadata: json.RawMessage(`not-json`),
wantResult: "not_applicable",
},
{
name: "kritis_supplier true returns indirect_obligation",
metadata: json.RawMessage(`{"kritis_supplier": true}`),
wantResult: "indirect_obligation",
},
{
name: "kritis_supplier false returns not_applicable",
metadata: json.RawMessage(`{"kritis_supplier": false}`),
wantResult: "not_applicable",
},
{
name: "critical_sector_clients non-empty array returns indirect_obligation",
metadata: json.RawMessage(`{"critical_sector_clients": ["energy"]}`),
wantResult: "indirect_obligation",
},
{
name: "critical_sector_clients empty array returns not_applicable",
metadata: json.RawMessage(`{"critical_sector_clients": []}`),
wantResult: "not_applicable",
},
{
name: "critical_sector_clients bool true returns indirect_obligation",
metadata: json.RawMessage(`{"critical_sector_clients": true}`),
wantResult: "indirect_obligation",
},
{
name: "critical_sector_clients bool false returns not_applicable",
metadata: json.RawMessage(`{"critical_sector_clients": false}`),
wantResult: "not_applicable",
},
{
name: "target_sectors with critical sector returns indirect_obligation",
metadata: json.RawMessage(`{"target_sectors": ["health"]}`),
wantResult: "indirect_obligation",
},
{
name: "target_sectors energy returns indirect_obligation",
metadata: json.RawMessage(`{"target_sectors": ["energy"]}`),
wantResult: "indirect_obligation",
},
{
name: "target_sectors transport returns indirect_obligation",
metadata: json.RawMessage(`{"target_sectors": ["transport"]}`),
wantResult: "indirect_obligation",
},
{
name: "target_sectors banking returns indirect_obligation",
metadata: json.RawMessage(`{"target_sectors": ["banking"]}`),
wantResult: "indirect_obligation",
},
{
name: "target_sectors water returns indirect_obligation",
metadata: json.RawMessage(`{"target_sectors": ["water"]}`),
wantResult: "indirect_obligation",
},
{
name: "target_sectors digital_infra returns indirect_obligation",
metadata: json.RawMessage(`{"target_sectors": ["digital_infra"]}`),
wantResult: "indirect_obligation",
},
{
name: "target_sectors non-critical sector returns not_applicable",
metadata: json.RawMessage(`{"target_sectors": ["retail"]}`),
wantResult: "not_applicable",
},
{
name: "target_sectors empty array returns not_applicable",
metadata: json.RawMessage(`{"target_sectors": []}`),
wantResult: "not_applicable",
},
{
name: "target_sectors case insensitive match",
metadata: json.RawMessage(`{"target_sectors": ["Health"]}`),
wantResult: "indirect_obligation",
},
{
name: "kritis_supplier takes precedence over target_sectors",
metadata: json.RawMessage(`{"kritis_supplier": true, "target_sectors": ["retail"]}`),
wantResult: "indirect_obligation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project := &Project{
MachineName: "TestMachine",
Metadata: tt.metadata,
}
result := c.ClassifyNIS2(project, nil)
if result.Regulation != RegulationNIS2 {
t.Errorf("Regulation = %q, want %q", result.Regulation, RegulationNIS2)
}
if result.ClassificationResult != tt.wantResult {
t.Errorf("ClassificationResult = %q, want %q", result.ClassificationResult, tt.wantResult)
}
if result.Reasoning == "" {
t.Error("Reasoning should not be empty")
}
if tt.wantResult == "indirect_obligation" {
if result.RiskLevel != "medium" {
t.Errorf("RiskLevel = %q, want %q", result.RiskLevel, "medium")
}
if result.Requirements == nil || len(result.Requirements) == 0 {
t.Error("Requirements should not be empty for indirect_obligation")
}
} else {
if result.RiskLevel != "none" {
t.Errorf("RiskLevel = %q, want %q", result.RiskLevel, "none")
}
if result.Requirements != nil {
t.Errorf("Requirements should be nil for not_applicable, got %v", result.Requirements)
}
}
})
}
}
func TestClassifyAll(t *testing.T) {
c := NewClassifier()
tests := []struct {
name string
project *Project
components []Component
}{
{
name: "returns exactly 4 results for empty project",
project: &Project{MachineName: "TestMachine"},
components: []Component{},
},
{
name: "returns exactly 4 results for complex project",
project: &Project{MachineName: "ComplexMachine", CEMarkingTarget: "2023/1230", Metadata: json.RawMessage(`{"kritis_supplier": true}`)},
components: []Component{
{Name: "SafetyAI", ComponentType: ComponentTypeAIModel, IsSafetyRelevant: true, IsNetworked: true},
{Name: "PLC", ComponentType: ComponentTypeController, IsNetworked: true},
{Name: "SafetyFW", ComponentType: ComponentTypeFirmware, IsSafetyRelevant: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := c.ClassifyAll(tt.project, tt.components)
if len(results) != 4 {
t.Fatalf("ClassifyAll returned %d results, want 4", len(results))
}
expectedRegulations := map[RegulationType]bool{
RegulationAIAct: false,
RegulationMachineryRegulation: false,
RegulationCRA: false,
RegulationNIS2: false,
}
for _, r := range results {
if _, ok := expectedRegulations[r.Regulation]; !ok {
t.Errorf("unexpected regulation %q in results", r.Regulation)
}
expectedRegulations[r.Regulation] = true
}
for reg, found := range expectedRegulations {
if !found {
t.Errorf("missing regulation %q in results", reg)
}
}
// Verify order: AI Act, Machinery, CRA, NIS2
if results[0].Regulation != RegulationAIAct {
t.Errorf("results[0].Regulation = %q, want %q", results[0].Regulation, RegulationAIAct)
}
if results[1].Regulation != RegulationMachineryRegulation {
t.Errorf("results[1].Regulation = %q, want %q", results[1].Regulation, RegulationMachineryRegulation)
}
if results[2].Regulation != RegulationCRA {
t.Errorf("results[2].Regulation = %q, want %q", results[2].Regulation, RegulationCRA)
}
if results[3].Regulation != RegulationNIS2 {
t.Errorf("results[3].Regulation = %q, want %q", results[3].Regulation, RegulationNIS2)
}
})
}
}