Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/engine_test.go
Benjamin Admin c7651796c9
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 36s
CI/CD / test-python-document-crawler (push) Successful in 22s
CI/CD / test-python-dsms-gateway (push) Successful in 18s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Successful in 2s
feat(iace): integrate ISO 12100 machine risk model with 4-factor assessment
Add dual-mode risk engine: legacy S×E×P (avoidance=0) and ISO mode S×F×P×A
(avoidance>=1) with new thresholds (low/medium/high/very_high/not_acceptable).

- 150+ hazard library entries across 28 categories incl. physical hazards
  (mechanical, electrical, thermal, pneumatic/hydraulic, noise/vibration,
  ergonomic, material/environmental)
- 160-entry protective measures library with 3-step hierarchy validation
  (design → protective → information)
- 25 lifecycle phases, 20 affected person roles, 50 evidence types
- 10 verification methods (expanded from 7)
- New API endpoints: lifecycle-phases, roles, evidence-types,
  protective-measures-library, validate-mitigation-hierarchy
- DB migrations 018+019 for extended schema
- Frontend: 4-slider risk assessment, hierarchy warnings, measures library modal
- MkDocs wiki updated with ISO mode docs and legal notice (no norm text)

All content uses original wording — norms referenced as methodology only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:13:41 +01:00

1173 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package iace
import (
"math"
"testing"
)
// ============================================================================
// Helper
// ============================================================================
const floatTolerance = 1e-9
func almostEqual(a, b float64) bool {
return math.Abs(a-b) < floatTolerance
}
// ============================================================================
// 1. CalculateInherentRisk — S × E × P
// ============================================================================
func TestCalculateInherentRisk_BasicCases(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
s, ex, p int
expected float64
}{
// Minimum
{"min 1×1×1", 1, 1, 1, 1},
// Maximum
{"max 5×5×5", 5, 5, 5, 125},
// Single factor high
{"5×1×1", 5, 1, 1, 5},
{"1×5×1", 1, 5, 1, 5},
{"1×1×5", 1, 1, 5, 5},
// Typical mid-range
{"3×3×3", 3, 3, 3, 27},
{"2×4×3", 2, 4, 3, 24},
{"4×2×5", 4, 2, 5, 40},
// Boundary at thresholds
{"3×5×5 = 75 (critical threshold)", 3, 5, 5, 75},
{"2×4×5 = 40 (high threshold)", 2, 4, 5, 40},
{"3×5×1 = 15 (medium threshold)", 3, 5, 1, 15},
{"5×1×1 = 5 (low threshold)", 5, 1, 1, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateInherentRisk(tt.s, tt.ex, tt.p, 0)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateInherentRisk(%d, %d, %d) = %v, want %v", tt.s, tt.ex, tt.p, result, tt.expected)
}
})
}
}
func TestCalculateInherentRisk_Clamping(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
s, ex, p int
expected float64
}{
{"below min clamped to 1", 0, 0, 0, 1},
{"negative clamped to 1", -5, -3, -1, 1},
{"above max clamped to 5", 10, 8, 6, 125},
{"mixed out-of-range", 0, 10, 3, 15}, // clamp(0,1,5)=1, clamp(10,1,5)=5, clamp(3,1,5)=3 → 1*5*3=15
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateInherentRisk(tt.s, tt.ex, tt.p, 0)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateInherentRisk(%d, %d, %d) = %v, want %v", tt.s, tt.ex, tt.p, result, tt.expected)
}
})
}
}
// Full S×E×P coverage: verify all 125 combinations produce correct multiplication.
func TestCalculateInherentRisk_FullCoverage(t *testing.T) {
e := NewRiskEngine()
for s := 1; s <= 5; s++ {
for ex := 1; ex <= 5; ex++ {
for p := 1; p <= 5; p++ {
expected := float64(s * ex * p)
result := e.CalculateInherentRisk(s, ex, p, 0)
if !almostEqual(result, expected) {
t.Errorf("CalculateInherentRisk(%d, %d, %d) = %v, want %v", s, ex, p, result, expected)
}
}
}
}
}
// ============================================================================
// 2. CalculateControlEffectiveness
// C_eff = min(1, 0.2*(maturity/4.0) + 0.5*coverage + 0.3*testEvidence)
// ============================================================================
func TestCalculateControlEffectiveness(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
maturity int
coverage float64
testEvidence float64
expected float64
}{
// All zeros → 0
{"all zero", 0, 0.0, 0.0, 0.0},
// All max → min(1, 0.2*1 + 0.5*1 + 0.3*1) = min(1, 1.0) = 1.0
{"all max", 4, 1.0, 1.0, 1.0},
// Only maturity max → 0.2 * (4/4) = 0.2
{"maturity only", 4, 0.0, 0.0, 0.2},
// Only coverage max → 0.5
{"coverage only", 0, 1.0, 0.0, 0.5},
// Only test evidence max → 0.3
{"evidence only", 0, 0.0, 1.0, 0.3},
// Half maturity → 0.2 * (2/4) = 0.1
{"half maturity", 2, 0.0, 0.0, 0.1},
// Typical mid-range: maturity=2, coverage=0.6, evidence=0.4
// 0.2*(2/4) + 0.5*0.6 + 0.3*0.4 = 0.1 + 0.3 + 0.12 = 0.52
{"typical mid", 2, 0.6, 0.4, 0.52},
// High values exceeding 1.0 should be capped
// maturity=4, coverage=1.0, evidence=1.0 → 0.2+0.5+0.3 = 1.0
{"capped at 1.0", 4, 1.0, 1.0, 1.0},
// maturity=3, coverage=0.8, evidence=0.9
// 0.2*(3/4) + 0.5*0.8 + 0.3*0.9 = 0.15 + 0.4 + 0.27 = 0.82
{"high controls", 3, 0.8, 0.9, 0.82},
// maturity=1, coverage=0.2, evidence=0.1
// 0.2*(1/4) + 0.5*0.2 + 0.3*0.1 = 0.05 + 0.1 + 0.03 = 0.18
{"low controls", 1, 0.2, 0.1, 0.18},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateControlEffectiveness(tt.maturity, tt.coverage, tt.testEvidence)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateControlEffectiveness(%d, %v, %v) = %v, want %v",
tt.maturity, tt.coverage, tt.testEvidence, result, tt.expected)
}
})
}
}
func TestCalculateControlEffectiveness_Clamping(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
maturity int
coverage float64
testEvidence float64
expected float64
}{
// Maturity below 0 → clamped to 0
{"maturity below zero", -1, 0.5, 0.5, 0.5*0.5 + 0.3*0.5},
// Maturity above 4 → clamped to 4
{"maturity above max", 10, 0.0, 0.0, 0.2},
// Coverage below 0 → clamped to 0
{"coverage below zero", 0, -0.5, 0.0, 0.0},
// Coverage above 1 → clamped to 1
{"coverage above max", 0, 2.0, 0.0, 0.5},
// Evidence below 0 → clamped to 0
{"evidence below zero", 0, 0.0, -1.0, 0.0},
// Evidence above 1 → clamped to 1
{"evidence above max", 0, 0.0, 5.0, 0.3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateControlEffectiveness(tt.maturity, tt.coverage, tt.testEvidence)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateControlEffectiveness(%d, %v, %v) = %v, want %v",
tt.maturity, tt.coverage, tt.testEvidence, result, tt.expected)
}
})
}
}
// ============================================================================
// 3. CalculateResidualRisk — R_residual = S × E × P × (1 - C_eff)
// ============================================================================
func TestCalculateResidualRisk(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
s, ex, p int
cEff float64
expected float64
}{
// No controls → residual = inherent
{"no controls", 5, 5, 5, 0.0, 125.0},
// Perfect controls → residual = 0
{"perfect controls", 5, 5, 5, 1.0, 0.0},
// Half effectiveness
{"half controls 3×3×3", 3, 3, 3, 0.5, 13.5},
// Typical scenario: inherent=40, cEff=0.6 → residual=16
{"typical 2×4×5 cEff=0.6", 2, 4, 5, 0.6, 16.0},
// Low risk with some controls
{"low 1×2×3 cEff=0.3", 1, 2, 3, 0.3, 4.2},
// High risk with strong controls
{"high 5×4×4 cEff=0.82", 5, 4, 4, 0.82, 14.4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateResidualRisk(tt.s, tt.ex, tt.p, tt.cEff)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateResidualRisk(%d, %d, %d, %v) = %v, want %v",
tt.s, tt.ex, tt.p, tt.cEff, result, tt.expected)
}
})
}
}
// ============================================================================
// 4. DetermineRiskLevel — threshold classification
// ============================================================================
func TestDetermineRiskLevel(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
residual float64
expected RiskLevel
}{
// Critical: >= 75
{"critical at 75", 75.0, RiskLevelCritical},
{"critical at 125", 125.0, RiskLevelCritical},
{"critical at 100", 100.0, RiskLevelCritical},
// High: >= 40
{"high at 40", 40.0, RiskLevelHigh},
{"high at 74.9", 74.9, RiskLevelHigh},
{"high at 50", 50.0, RiskLevelHigh},
// Medium: >= 15
{"medium at 15", 15.0, RiskLevelMedium},
{"medium at 39.9", 39.9, RiskLevelMedium},
{"medium at 27", 27.0, RiskLevelMedium},
// Low: >= 5
{"low at 5", 5.0, RiskLevelLow},
{"low at 14.9", 14.9, RiskLevelLow},
{"low at 10", 10.0, RiskLevelLow},
// Negligible: < 5
{"negligible at 4.9", 4.9, RiskLevelNegligible},
{"negligible at 0", 0.0, RiskLevelNegligible},
{"negligible at 1", 1.0, RiskLevelNegligible},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.DetermineRiskLevel(tt.residual)
if result != tt.expected {
t.Errorf("DetermineRiskLevel(%v) = %v, want %v", tt.residual, result, tt.expected)
}
})
}
}
// ============================================================================
// 5. IsAcceptable — ALARP principle
// ============================================================================
func TestIsAcceptable(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
residual float64
allReduction bool
justification bool
wantAcceptable bool
wantReason string
}{
// Below 15 → always acceptable
{"residual 14.9 always ok", 14.9, false, false, true, "Restrisiko unter Schwellwert"},
{"residual 0 always ok", 0.0, false, false, true, "Restrisiko unter Schwellwert"},
{"residual 10 always ok", 10.0, false, false, true, "Restrisiko unter Schwellwert"},
// 15-39.9 with all reduction + justification → ALARP
{"ALARP 20 all+just", 20.0, true, true, true, "ALARP-Prinzip: Restrisiko akzeptabel mit vollstaendiger Risikominderung"},
{"ALARP 39.9 all+just", 39.9, true, true, true, "ALARP-Prinzip: Restrisiko akzeptabel mit vollstaendiger Risikominderung"},
{"ALARP 15 all+just", 15.0, true, true, true, "ALARP-Prinzip: Restrisiko akzeptabel mit vollstaendiger Risikominderung"},
// 15-39.9 without all reduction → NOT acceptable
{"no reduction 20", 20.0, false, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
// 15-39.9 without justification → NOT acceptable
{"no justification 20", 20.0, true, false, false, "Restrisiko zu hoch - blockiert CE-Export"},
// 15-39.9 without either → NOT acceptable
{"neither 30", 30.0, false, false, false, "Restrisiko zu hoch - blockiert CE-Export"},
// >= 40 → NEVER acceptable
{"residual 40 blocked", 40.0, true, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
{"residual 75 blocked", 75.0, true, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
{"residual 125 blocked", 125.0, true, true, false, "Restrisiko zu hoch - blockiert CE-Export"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
acceptable, reason := e.IsAcceptable(tt.residual, tt.allReduction, tt.justification)
if acceptable != tt.wantAcceptable {
t.Errorf("IsAcceptable(%v, %v, %v) acceptable = %v, want %v",
tt.residual, tt.allReduction, tt.justification, acceptable, tt.wantAcceptable)
}
if reason != tt.wantReason {
t.Errorf("IsAcceptable(%v, %v, %v) reason = %q, want %q",
tt.residual, tt.allReduction, tt.justification, reason, tt.wantReason)
}
})
}
}
// ============================================================================
// 6. CalculateCompletenessScore
// ============================================================================
func TestCalculateCompletenessScore(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
passedReq, totalReq, passedRec, totalRec, passedOpt, totalOpt int
expected float64
}{
// All passed
{"all passed", 20, 20, 5, 5, 3, 3, 100.0},
// Nothing passed
{"nothing passed", 0, 20, 0, 5, 0, 3, 0.0},
// Only required fully passed
{"only required", 20, 20, 0, 5, 0, 3, 80.0},
// Only recommended fully passed
{"only recommended", 0, 20, 5, 5, 0, 3, 15.0},
// Only optional fully passed
{"only optional", 0, 20, 0, 5, 3, 3, 5.0},
// Half required, no others
{"half required", 10, 20, 0, 5, 0, 3, 40.0},
// All zero totals → 0 (division by zero safety)
{"all zero totals", 0, 0, 0, 0, 0, 0, 0.0},
// Typical: 18/20 req + 3/5 rec + 1/3 opt
// (18/20)*80 + (3/5)*15 + (1/3)*5 = 72 + 9 + 1.6667 = 82.6667
{"typical", 18, 20, 3, 5, 1, 3, 72.0 + 9.0 + 5.0/3.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateCompletenessScore(
tt.passedReq, tt.totalReq, tt.passedRec, tt.totalRec, tt.passedOpt, tt.totalOpt)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateCompletenessScore(%d/%d, %d/%d, %d/%d) = %v, want %v",
tt.passedReq, tt.totalReq, tt.passedRec, tt.totalRec, tt.passedOpt, tt.totalOpt,
result, tt.expected)
}
})
}
}
// ============================================================================
// 7. ComputeRisk — integration test
// ============================================================================
func TestComputeRisk_ValidInput(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
input RiskComputeInput
wantInherent float64
wantCEff float64
wantResidual float64
wantLevel RiskLevel
wantAcceptable bool
}{
{
name: "no controls high risk",
input: RiskComputeInput{
Severity: 5, Exposure: 5, Probability: 5,
ControlMaturity: 0, ControlCoverage: 0, TestEvidence: 0,
},
wantInherent: 125,
wantCEff: 0,
wantResidual: 125,
wantLevel: RiskLevelCritical,
wantAcceptable: false,
},
{
name: "perfect controls zero residual",
input: RiskComputeInput{
Severity: 5, Exposure: 5, Probability: 5,
ControlMaturity: 4, ControlCoverage: 1.0, TestEvidence: 1.0,
},
wantInherent: 125,
wantCEff: 1.0,
wantResidual: 0,
wantLevel: RiskLevelNegligible,
wantAcceptable: true,
},
{
name: "medium risk acceptable",
input: RiskComputeInput{
Severity: 2, Exposure: 2, Probability: 2,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
// C_eff = 0.2*(2/4) + 0.5*0.5 + 0.3*0.5 = 0.1 + 0.25 + 0.15 = 0.5
// inherent = 8, residual = 8 * 0.5 = 4 → negligible → acceptable
},
wantInherent: 8,
wantCEff: 0.5,
wantResidual: 4,
wantLevel: RiskLevelNegligible,
wantAcceptable: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := e.ComputeRisk(tt.input)
if err != nil {
t.Fatalf("ComputeRisk returned error: %v", err)
}
if !almostEqual(result.InherentRisk, tt.wantInherent) {
t.Errorf("InherentRisk = %v, want %v", result.InherentRisk, tt.wantInherent)
}
if !almostEqual(result.ControlEffectiveness, tt.wantCEff) {
t.Errorf("ControlEffectiveness = %v, want %v", result.ControlEffectiveness, tt.wantCEff)
}
if !almostEqual(result.ResidualRisk, tt.wantResidual) {
t.Errorf("ResidualRisk = %v, want %v", result.ResidualRisk, tt.wantResidual)
}
if result.RiskLevel != tt.wantLevel {
t.Errorf("RiskLevel = %v, want %v", result.RiskLevel, tt.wantLevel)
}
if result.IsAcceptable != tt.wantAcceptable {
t.Errorf("IsAcceptable = %v, want %v", result.IsAcceptable, tt.wantAcceptable)
}
})
}
}
func TestComputeRisk_InvalidInput(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
input RiskComputeInput
}{
{"severity zero", RiskComputeInput{Severity: 0, Exposure: 3, Probability: 3}},
{"exposure zero", RiskComputeInput{Severity: 3, Exposure: 0, Probability: 3}},
{"probability zero", RiskComputeInput{Severity: 3, Exposure: 3, Probability: 0}},
{"all zero", RiskComputeInput{Severity: 0, Exposure: 0, Probability: 0}},
{"negative values", RiskComputeInput{Severity: -1, Exposure: -2, Probability: -3}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := e.ComputeRisk(tt.input)
if err == nil {
t.Errorf("ComputeRisk expected error for input %+v, got result %+v", tt.input, result)
}
})
}
}
// ============================================================================
// 8. Golden Test Suite — 10 Referenzmaschinen (Industrial Machine Scenarios)
//
// Each machine scenario tests the full pipeline:
// Inherent Risk → Control Effectiveness → Residual Risk → Risk Level → Acceptability
//
// The control parameters reflect realistic mitigation states for each machine type.
// These serve as regression tests: if any threshold or formula changes,
// these tests will catch the impact immediately.
// ============================================================================
// referenceMachine defines a complete end-to-end test scenario for a real machine type.
type referenceMachine struct {
name string
description string
// Inherent risk factors (pre-mitigation)
severity int // 1-5
exposure int // 1-5
probability int // 1-5
// Control parameters (mitigation state)
controlMaturity int // 0-4
controlCoverage float64 // 0-1
testEvidence float64 // 0-1
hasJustification bool
// Expected outputs
expectedInherentRisk float64
expectedCEff float64
expectedResidualRisk float64
expectedRiskLevel RiskLevel
expectedAcceptable bool
}
// computeExpectedCEff calculates the expected control effectiveness for documentation/verification.
func computeExpectedCEff(maturity int, coverage, testEvidence float64) float64 {
cEff := 0.2*(float64(maturity)/4.0) + 0.5*coverage + 0.3*testEvidence
if cEff > 1.0 {
return 1.0
}
return cEff
}
func getReferenceMachines() []referenceMachine {
return []referenceMachine{
// ---------------------------------------------------------------
// 1. Industrieroboter-Zelle mit Schutzzaun
// 6-Achs-Roboter, Materialhandling, Schutzzaun + Lichtschranke.
// Hauptgefaehrdung: Quetschung/Kollision bei Betreten.
// Hohe Massnahmen: Sicherheits-SPS, zweikanalige Tuerueberwachung.
// ---------------------------------------------------------------
{
name: "Industrieroboter-Zelle",
description: "6-Achs-Roboter mit Schutzzaun, Quetsch-/Kollisionsgefahr",
severity: 5, // Lebensgefaehrlich
exposure: 3, // Regelmaessiger Zugang (Wartung)
probability: 4, // Wahrscheinlich bei offenem Zugang
controlMaturity: 3,
controlCoverage: 0.8,
testEvidence: 0.7,
hasJustification: false,
// Inherent: 5*3*4 = 60
// C_eff: 0.2*(3/4) + 0.5*0.8 + 0.3*0.7 = 0.15 + 0.4 + 0.21 = 0.76
// Residual: 60 * (1-0.76) = 60 * 0.24 = 14.4
// Level: medium (>=5, <15) → actually 14.4 < 15 → low
// Acceptable: 14.4 < 15 → yes
expectedInherentRisk: 60,
expectedCEff: 0.76,
expectedResidualRisk: 60 * (1 - 0.76),
expectedRiskLevel: RiskLevelLow,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 2. CNC-Fraesmaschine mit automatischem Werkzeugwechsel
// Werkzeugbruch, Spaeneflug. Vollschutzkabine + Drehzahlueberwachung.
// ---------------------------------------------------------------
{
name: "CNC-Fraesmaschine",
description: "CNC mit Werkzeugwechsel, Werkzeugbruch/Spaeneflug",
severity: 4, // Schwere Verletzung
exposure: 3, // Bediener steht regelmaessig davor
probability: 3, // Moeglich
controlMaturity: 3,
controlCoverage: 0.9,
testEvidence: 0.8,
hasJustification: true,
// Inherent: 4*3*3 = 36
// C_eff: 0.2*(3/4) + 0.5*0.9 + 0.3*0.8 = 0.15 + 0.45 + 0.24 = 0.84
// Residual: 36 * 0.16 = 5.76
// Level: low (>=5, <15)
// Acceptable: 5.76 < 15 → yes
expectedInherentRisk: 36,
expectedCEff: 0.84,
expectedResidualRisk: 36 * (1 - 0.84),
expectedRiskLevel: RiskLevelLow,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 3. Verpackungsmaschine mit Schneideeinheit
// Foerderband + Klinge. Schnittverletzung.
// Zweihandbedienung, Sicherheitsrelais, Abdeckung.
// ---------------------------------------------------------------
{
name: "Verpackungsmaschine",
description: "Foerderband + Schneideeinheit, Schnittverletzungsgefahr",
severity: 4, // Schwere Schnittverletzung
exposure: 4, // Dauerbetrieb mit Bediener
probability: 3, // Moeglich
controlMaturity: 2,
controlCoverage: 0.7,
testEvidence: 0.5,
hasJustification: true,
// Inherent: 4*4*3 = 48
// C_eff: 0.2*(2/4) + 0.5*0.7 + 0.3*0.5 = 0.1 + 0.35 + 0.15 = 0.6
// Residual: 48 * 0.4 = 19.2
// Level: medium (>=15, <40)
// Acceptable: 19.2 >= 15, allReduction=false (ComputeRisk default) → NOT acceptable
expectedInherentRisk: 48,
expectedCEff: 0.6,
expectedResidualRisk: 48 * (1 - 0.6),
expectedRiskLevel: RiskLevelMedium,
expectedAcceptable: false, // ComputeRisk sets allReductionStepsApplied=false
},
// ---------------------------------------------------------------
// 4. Automatisierte Pressanlage
// Quetschung im Pressbereich. Hoechste Gefaehrdung.
// Lichtvorhang, Kat-4-Steuerung, mechanische Verriegelung.
// ---------------------------------------------------------------
{
name: "Pressanlage",
description: "Automatische Presse, Quetschgefahr im Pressbereich",
severity: 5, // Toedlich
exposure: 4, // Bediener staendig im Bereich
probability: 4, // Wahrscheinlich ohne Schutz
controlMaturity: 4,
controlCoverage: 0.9,
testEvidence: 0.9,
hasJustification: true,
// Inherent: 5*4*4 = 80
// C_eff: 0.2*(4/4) + 0.5*0.9 + 0.3*0.9 = 0.2 + 0.45 + 0.27 = 0.92
// Residual: 80 * 0.08 = 6.4
// Level: low (>=5, <15)
// Acceptable: 6.4 < 15 → yes
expectedInherentRisk: 80,
expectedCEff: 0.92,
expectedResidualRisk: 80 * (1 - 0.92),
expectedRiskLevel: RiskLevelLow,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 5. Lasergravur-Anlage (Klasse 4)
// Augenverletzung durch Laserstrahl.
// Geschlossene Kabine, Interlock.
// ---------------------------------------------------------------
{
name: "Lasergravur-Anlage",
description: "Klasse-4-Laser, Augenverletzungsgefahr",
severity: 5, // Irreversible Augenschaeden
exposure: 2, // Selten direkter Zugang
probability: 2, // Selten bei geschlossener Kabine
controlMaturity: 3,
controlCoverage: 0.95,
testEvidence: 0.8,
hasJustification: false,
// Inherent: 5*2*2 = 20
// C_eff: 0.2*(3/4) + 0.5*0.95 + 0.3*0.8 = 0.15 + 0.475 + 0.24 = 0.865
// Residual: 20 * 0.135 = 2.7
// Level: negligible (<5)
// Acceptable: 2.7 < 15 → yes
expectedInherentRisk: 20,
expectedCEff: 0.865,
expectedResidualRisk: 20 * (1 - 0.865),
expectedRiskLevel: RiskLevelNegligible,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 6. Fahrerloses Transportsystem (AGV)
// Kollision mit Personen in Produktionshalle.
// Laserscanner, Not-Aus, Geschwindigkeitsbegrenzung.
// ---------------------------------------------------------------
{
name: "AGV (Fahrerloses Transportsystem)",
description: "Autonomes Fahrzeug, Kollisionsgefahr mit Personen",
severity: 4, // Schwere Verletzung
exposure: 4, // Dauerhaft Personen in der Naehe
probability: 3, // Moeglich in offener Umgebung
controlMaturity: 3,
controlCoverage: 0.7,
testEvidence: 0.6,
hasJustification: true,
// Inherent: 4*4*3 = 48
// C_eff: 0.2*(3/4) + 0.5*0.7 + 0.3*0.6 = 0.15 + 0.35 + 0.18 = 0.68
// Residual: 48 * 0.32 = 15.36
// Level: medium (>=15, <40)
// Acceptable: 15.36 >= 15, allReduction=false → NOT acceptable
expectedInherentRisk: 48,
expectedCEff: 0.68,
expectedResidualRisk: 48 * (1 - 0.68),
expectedRiskLevel: RiskLevelMedium,
expectedAcceptable: false, // ComputeRisk sets allReductionStepsApplied=false
},
// ---------------------------------------------------------------
// 7. Abfuellanlage fuer Chemikalien
// Kontakt mit gefaehrlichem Medium.
// Geschlossene Leitungen, Leckageerkennung.
// ---------------------------------------------------------------
{
name: "Abfuellanlage Chemikalien",
description: "Chemikalien-Abfuellung, Kontaktgefahr",
severity: 4, // Schwere Veraetzung
exposure: 2, // Gelegentlicher Zugang
probability: 2, // Selten bei geschlossenen Leitungen
controlMaturity: 3,
controlCoverage: 0.85,
testEvidence: 0.7,
hasJustification: false,
// Inherent: 4*2*2 = 16
// C_eff: 0.2*(3/4) + 0.5*0.85 + 0.3*0.7 = 0.15 + 0.425 + 0.21 = 0.785
// Residual: 16 * 0.215 = 3.44
// Level: negligible (<5)
// Acceptable: 3.44 < 15 → yes
expectedInherentRisk: 16,
expectedCEff: 0.785,
expectedResidualRisk: 16 * (1 - 0.785),
expectedRiskLevel: RiskLevelNegligible,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 8. Industrie-3D-Drucker (Metallpulver)
// Feinstaub-Inhalation, Explosionsgefahr.
// Absauganlage, Explosionsschutz.
// ---------------------------------------------------------------
{
name: "Industrie-3D-Drucker Metallpulver",
description: "Metallpulver-Drucker, Feinstaub und ATEX",
severity: 4, // Schwere Lungenschaeden / Explosion
exposure: 3, // Regelmaessig (Druckjob-Wechsel)
probability: 3, // Moeglich
controlMaturity: 2,
controlCoverage: 0.6,
testEvidence: 0.5,
hasJustification: true,
// Inherent: 4*3*3 = 36
// C_eff: 0.2*(2/4) + 0.5*0.6 + 0.3*0.5 = 0.1 + 0.3 + 0.15 = 0.55
// Residual: 36 * 0.45 = 16.2
// Level: medium (>=15, <40)
// Acceptable: 16.2 >= 15, allReduction=false → NOT acceptable
expectedInherentRisk: 36,
expectedCEff: 0.55,
expectedResidualRisk: 36 * (1 - 0.55),
expectedRiskLevel: RiskLevelMedium,
expectedAcceptable: false,
},
// ---------------------------------------------------------------
// 9. Automatisches Hochregallager
// Absturz von Lasten, Regalbediengeraet.
// Lastsicherung, Sensoren, regelmaessige Wartung.
// ---------------------------------------------------------------
{
name: "Hochregallager",
description: "Automatisches Regallager, Lastabsturzgefahr",
severity: 5, // Toedlich bei Absturz schwerer Lasten
exposure: 2, // Selten (automatisiert, Wartungszugang)
probability: 2, // Selten bei ordnungsgemaessem Betrieb
controlMaturity: 3,
controlCoverage: 0.8,
testEvidence: 0.7,
hasJustification: false,
// Inherent: 5*2*2 = 20
// C_eff: 0.2*(3/4) + 0.5*0.8 + 0.3*0.7 = 0.15 + 0.4 + 0.21 = 0.76
// Residual: 20 * 0.24 = 4.8
// Level: negligible (<5)
// Acceptable: 4.8 < 15 → yes
expectedInherentRisk: 20,
expectedCEff: 0.76,
expectedResidualRisk: 20 * (1 - 0.76),
expectedRiskLevel: RiskLevelNegligible,
expectedAcceptable: true,
},
// ---------------------------------------------------------------
// 10. KI-Bildverarbeitung Qualitaetskontrolle
// Fehlklassifikation → sicherheitsrelevantes Bauteil wird freigegeben.
// Redundante Pruefung, Validierungsdatensatz, KI-Risikobeurteilung.
// AI Act relevant.
// ---------------------------------------------------------------
{
name: "KI-Qualitaetskontrolle",
description: "KI-Vision fuer Bauteilpruefung, Fehlklassifikationsgefahr (AI Act relevant)",
severity: 4, // Fehlerhaftes Sicherheitsbauteil im Feld
exposure: 5, // Kontinuierlich (jedes Bauteil)
probability: 3, // Moeglich (ML nie 100% korrekt)
controlMaturity: 2,
controlCoverage: 0.5,
testEvidence: 0.6,
hasJustification: true,
// Inherent: 4*5*3 = 60
// C_eff: 0.2*(2/4) + 0.5*0.5 + 0.3*0.6 = 0.1 + 0.25 + 0.18 = 0.53
// Residual: 60 * 0.47 = 28.2
// Level: medium (>=15, <40)
// Acceptable: 28.2 >= 15, allReduction=false → NOT acceptable
expectedInherentRisk: 60,
expectedCEff: 0.53,
expectedResidualRisk: 60 * (1 - 0.53),
expectedRiskLevel: RiskLevelMedium,
expectedAcceptable: false,
},
}
}
func TestReferenceMachines_ComputeRisk(t *testing.T) {
engine := NewRiskEngine()
machines := getReferenceMachines()
for _, m := range machines {
t.Run(m.name, func(t *testing.T) {
input := RiskComputeInput{
Severity: m.severity,
Exposure: m.exposure,
Probability: m.probability,
ControlMaturity: m.controlMaturity,
ControlCoverage: m.controlCoverage,
TestEvidence: m.testEvidence,
HasJustification: m.hasJustification,
}
result, err := engine.ComputeRisk(input)
if err != nil {
t.Fatalf("ComputeRisk returned error: %v", err)
}
// Verify inherent risk
if !almostEqual(result.InherentRisk, m.expectedInherentRisk) {
t.Errorf("InherentRisk = %v, want %v", result.InherentRisk, m.expectedInherentRisk)
}
// Verify control effectiveness
if !almostEqual(result.ControlEffectiveness, m.expectedCEff) {
t.Errorf("ControlEffectiveness = %v, want %v", result.ControlEffectiveness, m.expectedCEff)
}
// Verify residual risk
if !almostEqual(result.ResidualRisk, m.expectedResidualRisk) {
t.Errorf("ResidualRisk = %v, want %v (diff: %v)",
result.ResidualRisk, m.expectedResidualRisk,
math.Abs(result.ResidualRisk-m.expectedResidualRisk))
}
// Verify risk level
if result.RiskLevel != m.expectedRiskLevel {
t.Errorf("RiskLevel = %v, want %v (residual=%v)",
result.RiskLevel, m.expectedRiskLevel, result.ResidualRisk)
}
// Verify acceptability
if result.IsAcceptable != m.expectedAcceptable {
t.Errorf("IsAcceptable = %v, want %v (residual=%v, level=%v)",
result.IsAcceptable, m.expectedAcceptable, result.ResidualRisk, result.RiskLevel)
}
})
}
}
// TestReferenceMachines_InherentRiskDistribution verifies that the 10 machines
// cover a meaningful range of inherent risk values (not all clustered).
func TestReferenceMachines_InherentRiskDistribution(t *testing.T) {
machines := getReferenceMachines()
var minRisk, maxRisk float64
minRisk = 999
levelCounts := map[RiskLevel]int{}
for _, m := range machines {
if m.expectedInherentRisk < minRisk {
minRisk = m.expectedInherentRisk
}
if m.expectedInherentRisk > maxRisk {
maxRisk = m.expectedInherentRisk
}
levelCounts[m.expectedRiskLevel]++
}
// Should span a meaningful range
if maxRisk-minRisk < 40 {
t.Errorf("Inherent risk range too narrow: [%v, %v], want spread >= 40", minRisk, maxRisk)
}
// Should cover at least 3 different risk levels
if len(levelCounts) < 3 {
t.Errorf("Only %d risk levels covered, want at least 3: %v", len(levelCounts), levelCounts)
}
}
// TestReferenceMachines_AcceptabilityMix verifies that the test suite has both
// acceptable and unacceptable outcomes.
func TestReferenceMachines_AcceptabilityMix(t *testing.T) {
machines := getReferenceMachines()
acceptableCount := 0
unacceptableCount := 0
for _, m := range machines {
if m.expectedAcceptable {
acceptableCount++
} else {
unacceptableCount++
}
}
if acceptableCount == 0 {
t.Error("No acceptable machines in test suite — need at least one")
}
if unacceptableCount == 0 {
t.Error("No unacceptable machines in test suite — need at least one")
}
t.Logf("Acceptability mix: %d acceptable, %d unacceptable out of %d machines",
acceptableCount, unacceptableCount, len(machines))
}
// ============================================================================
// 9. Edge Cases
// ============================================================================
func TestClamp(t *testing.T) {
tests := []struct {
name string
v, lo, hi int
expected int
}{
{"in range", 3, 1, 5, 3},
{"at low", 1, 1, 5, 1},
{"at high", 5, 1, 5, 5},
{"below low", 0, 1, 5, 1},
{"above high", 10, 1, 5, 5},
{"negative", -100, 0, 4, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := clamp(tt.v, tt.lo, tt.hi)
if result != tt.expected {
t.Errorf("clamp(%d, %d, %d) = %d, want %d", tt.v, tt.lo, tt.hi, result, tt.expected)
}
})
}
}
// ============================================================================
// 10. ISO 12100 Mode — S × F × P × A (direct multiplication)
// ============================================================================
func TestCalculateInherentRisk_ISO12100Mode(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
s, f, p, a int
expected float64
}{
// Minimum: 1×1×1×1 = 1
{"min 1×1×1×1", 1, 1, 1, 1, 1},
// Maximum: 5×5×5×5 = 625
{"max 5×5×5×5", 5, 5, 5, 5, 625},
// Typical mid-range: 3×3×3×3 = 81
{"mid 3×3×3×3", 3, 3, 3, 3, 81},
// High severity, low avoidance: 5×3×4×1 = 60
{"high S low A", 5, 3, 4, 1, 60},
// All factors high: 4×4×4×4 = 256
{"high 4×4×4×4", 4, 4, 4, 4, 256},
// Low risk: 2×1×2×1 = 4
{"low risk", 2, 1, 2, 1, 4},
// At not_acceptable boundary: 5×5×3×5 = 375
{"above 300", 5, 5, 3, 5, 375},
// At very_high boundary: 4×3×4×4 = 192
{"very high range", 4, 3, 4, 4, 192},
// At high boundary: 3×3×3×3 = 81
{"high range", 3, 3, 3, 3, 81},
// At medium range: 2×3×3×2 = 36
{"medium range", 2, 3, 3, 2, 36},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.CalculateInherentRisk(tt.s, tt.f, tt.p, tt.a)
if !almostEqual(result, tt.expected) {
t.Errorf("CalculateInherentRisk(%d, %d, %d, %d) = %v, want %v",
tt.s, tt.f, tt.p, tt.a, result, tt.expected)
}
})
}
}
func TestDetermineRiskLevelISO(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
risk float64
expected RiskLevel
}{
// not_acceptable: > 300
{"not_acceptable at 301", 301, RiskLevelNotAcceptable},
{"not_acceptable at 625", 625, RiskLevelNotAcceptable},
{"not_acceptable at 400", 400, RiskLevelNotAcceptable},
// very_high: 151-300
{"very_high at 300", 300, RiskLevelVeryHigh},
{"very_high at 151", 151, RiskLevelVeryHigh},
{"very_high at 200", 200, RiskLevelVeryHigh},
// high: 61-150
{"high at 150", 150, RiskLevelHigh},
{"high at 61", 61, RiskLevelHigh},
{"high at 100", 100, RiskLevelHigh},
// medium: 21-60
{"medium at 60", 60, RiskLevelMedium},
{"medium at 21", 21, RiskLevelMedium},
{"medium at 40", 40, RiskLevelMedium},
// low: 1-20
{"low at 20", 20, RiskLevelLow},
{"low at 1", 1, RiskLevelLow},
{"low at 10", 10, RiskLevelLow},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := e.DetermineRiskLevelISO(tt.risk)
if result != tt.expected {
t.Errorf("DetermineRiskLevelISO(%v) = %v, want %v", tt.risk, result, tt.expected)
}
})
}
}
func TestAvoidanceBackwardCompat(t *testing.T) {
e := NewRiskEngine()
// With avoidance=0, formula must remain S×E×P (legacy mode)
tests := []struct {
s, ex, p int
expected float64
}{
{5, 5, 5, 125},
{3, 3, 3, 27},
{1, 1, 1, 1},
{4, 2, 5, 40},
}
for _, tt := range tests {
result := e.CalculateInherentRisk(tt.s, tt.ex, tt.p, 0)
if !almostEqual(result, tt.expected) {
t.Errorf("Legacy mode: CalculateInherentRisk(%d,%d,%d,0) = %v, want %v",
tt.s, tt.ex, tt.p, result, tt.expected)
}
}
}
func TestValidateProtectiveMeasureHierarchy(t *testing.T) {
e := NewRiskEngine()
tests := []struct {
name string
reductionType ReductionType
existing []Mitigation
wantWarnings int
}{
{
name: "design measure — no warning",
reductionType: ReductionTypeDesign,
existing: nil,
wantWarnings: 0,
},
{
name: "information without any — full warning",
reductionType: ReductionTypeInformation,
existing: nil,
wantWarnings: 1,
},
{
name: "information with design — no warning",
reductionType: ReductionTypeInformation,
existing: []Mitigation{
{ReductionType: ReductionTypeDesign, Status: MitigationStatusImplemented},
{ReductionType: ReductionTypeProtective, Status: MitigationStatusPlanned},
},
wantWarnings: 0,
},
{
name: "information with only protective — missing design warning",
reductionType: ReductionTypeInformation,
existing: []Mitigation{
{ReductionType: ReductionTypeProtective, Status: MitigationStatusImplemented},
},
wantWarnings: 1,
},
{
name: "information with rejected measures — full warning",
reductionType: ReductionTypeInformation,
existing: []Mitigation{
{ReductionType: ReductionTypeDesign, Status: MitigationStatusRejected},
{ReductionType: ReductionTypeProtective, Status: MitigationStatusRejected},
},
wantWarnings: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
warnings := e.ValidateProtectiveMeasureHierarchy(tt.reductionType, tt.existing)
if len(warnings) != tt.wantWarnings {
t.Errorf("got %d warnings, want %d: %v", len(warnings), tt.wantWarnings, warnings)
}
})
}
}
func TestComputeRisk_ISOMode(t *testing.T) {
e := NewRiskEngine()
// ISO mode: avoidance >= 1 → uses DetermineRiskLevelISO for inherent risk classification
tests := []struct {
name string
input RiskComputeInput
wantLevel RiskLevel
}{
{
name: "ISO low risk",
input: RiskComputeInput{
Severity: 2, Exposure: 1, Probability: 2, Avoidance: 1,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
},
wantLevel: RiskLevelLow, // 2×1×2×1 = 4 → low
},
{
name: "ISO medium risk",
input: RiskComputeInput{
Severity: 3, Exposure: 2, Probability: 3, Avoidance: 2,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
},
wantLevel: RiskLevelMedium, // 3×2×3×2 = 36 → medium
},
{
name: "ISO high risk",
input: RiskComputeInput{
Severity: 4, Exposure: 3, Probability: 3, Avoidance: 3,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
},
wantLevel: RiskLevelHigh, // 4×3×3×3 = 108 → high
},
{
name: "ISO very high risk",
input: RiskComputeInput{
Severity: 4, Exposure: 4, Probability: 4, Avoidance: 3,
ControlMaturity: 2, ControlCoverage: 0.5, TestEvidence: 0.5,
},
wantLevel: RiskLevelVeryHigh, // 4×4×4×3 = 192 → very_high
},
{
name: "ISO not acceptable",
input: RiskComputeInput{
Severity: 5, Exposure: 5, Probability: 4, Avoidance: 4,
ControlMaturity: 4, ControlCoverage: 1.0, TestEvidence: 1.0,
},
wantLevel: RiskLevelNotAcceptable, // 5×5×4×4 = 400 → not_acceptable
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := e.ComputeRisk(tt.input)
if err != nil {
t.Fatalf("ComputeRisk returned error: %v", err)
}
if result.RiskLevel != tt.wantLevel {
t.Errorf("RiskLevel = %v, want %v (inherent=%v)",
result.RiskLevel, tt.wantLevel, result.InherentRisk)
}
})
}
}
// ============================================================================
// 11. Edge Cases (continued)
// ============================================================================
func TestClampFloat(t *testing.T) {
tests := []struct {
name string
v, lo, hi float64
expected float64
}{
{"in range", 0.5, 0, 1, 0.5},
{"at low", 0, 0, 1, 0},
{"at high", 1.0, 0, 1, 1.0},
{"below low", -0.5, 0, 1, 0},
{"above high", 2.5, 0, 1, 1.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := clampFloat(tt.v, tt.lo, tt.hi)
if !almostEqual(result, tt.expected) {
t.Errorf("clampFloat(%v, %v, %v) = %v, want %v", tt.v, tt.lo, tt.hi, result, tt.expected)
}
})
}
}