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) } }) } }