6d2616cad7
RiskReduction Struct + automatische Risk Trajectory:
- RiskReduction{SeverityDelta, ExposureDelta, ProbabilityDelta} auf ProtectiveMeasureEntry
- CalculateRiskTrajectory() in engine.go: berechnet schrittweise Risikoreduktion
entlang ISO 12100 Hierarchie (design → protection → information)
- Kumulative Deltas pro Stufe, Clamp auf Minimum 1
- RiskTrajectoryStep mit Stage, S/E/P, Score, Level, IsAcceptable
101 Massnahmen mit RiskReduction-Profilen versehen:
- Design/Geometry (M001-M010): S-1, E-1 (Gefahrstelle eliminiert)
- Design/Force (M011-M022): S-2 (Energie/Kraft reduziert)
- Design/Control (M039-M050): P-2 (sichere Steuerung)
- Protection/Guards (M061-M072): E-2 (Zugang verhindert)
- Protection/Electro (M073-M079): E-1, P-1 (Erkennung)
- Protection/Safety (M105-M113): P-2 (sichere SPS)
- Protection/Monitoring (M114-M120): P-1 (Frueerkennung)
- Protection/Cyber (M121-M130): P-1
- Information/Training (M161-M168): P-1
- Information/PPE (M169-M175): S-1
8 neue Tests: NoMeasures, DesignReduce, FullHierarchy, ClampMin1,
OnlyProtection, WithoutReduction, MandatoryAsProtective, LibraryCount
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
162 lines
5.4 KiB
Go
162 lines
5.4 KiB
Go
package iace
|
||
|
||
import "testing"
|
||
|
||
func TestRiskTrajectory_NoMeasures(t *testing.T) {
|
||
engine := NewRiskEngine()
|
||
steps := engine.CalculateRiskTrajectory(4, 4, 3, nil)
|
||
if len(steps) != 1 {
|
||
t.Fatalf("expected 1 step (inherent only), got %d", len(steps))
|
||
}
|
||
if steps[0].Stage != "inherent" {
|
||
t.Errorf("expected stage 'inherent', got %q", steps[0].Stage)
|
||
}
|
||
if steps[0].RiskScore != 48 {
|
||
t.Errorf("expected risk score 48 (4×4×3), got %.1f", steps[0].RiskScore)
|
||
}
|
||
}
|
||
|
||
func TestRiskTrajectory_DesignMeasuresReduce(t *testing.T) {
|
||
engine := NewRiskEngine()
|
||
measures := []ProtectiveMeasureEntry{
|
||
{ID: "M001", ReductionType: "design", RiskReduction: &RiskReduction{SeverityDelta: -1, ExposureDelta: -1}},
|
||
{ID: "M012", ReductionType: "design", RiskReduction: &RiskReduction{SeverityDelta: -2}},
|
||
}
|
||
steps := engine.CalculateRiskTrajectory(4, 4, 3, measures)
|
||
|
||
if len(steps) != 2 {
|
||
t.Fatalf("expected 2 steps (inherent + after_design), got %d", len(steps))
|
||
}
|
||
|
||
// After design: S=4+(-1)+(-2)=1, E=4+(-1)=3, P=3 → 1×3×3 = 9
|
||
after := steps[1]
|
||
if after.Stage != "after_design" {
|
||
t.Errorf("expected stage 'after_design', got %q", after.Stage)
|
||
}
|
||
if after.Severity != 1 {
|
||
t.Errorf("expected severity 1, got %d", after.Severity)
|
||
}
|
||
if after.Exposure != 3 {
|
||
t.Errorf("expected exposure 3, got %d", after.Exposure)
|
||
}
|
||
if after.RiskScore != 9 {
|
||
t.Errorf("expected risk score 9, got %.1f", after.RiskScore)
|
||
}
|
||
}
|
||
|
||
func TestRiskTrajectory_FullHierarchy(t *testing.T) {
|
||
engine := NewRiskEngine()
|
||
measures := []ProtectiveMeasureEntry{
|
||
{ID: "M001", ReductionType: "design", RiskReduction: &RiskReduction{ExposureDelta: -1}},
|
||
{ID: "M067", ReductionType: "protection", RiskReduction: &RiskReduction{ExposureDelta: -2, ProbabilityDelta: -1}},
|
||
{ID: "M161", ReductionType: "information", RiskReduction: &RiskReduction{ProbabilityDelta: -1}},
|
||
}
|
||
// Start: S=4, E=4, P=3 → 48
|
||
steps := engine.CalculateRiskTrajectory(4, 4, 3, measures)
|
||
|
||
if len(steps) != 4 {
|
||
t.Fatalf("expected 4 steps, got %d", len(steps))
|
||
}
|
||
|
||
// Inherent: 4×4×3 = 48
|
||
if steps[0].RiskScore != 48 {
|
||
t.Errorf("inherent: expected 48, got %.1f", steps[0].RiskScore)
|
||
}
|
||
// After design: S=4, E=3, P=3 → 36
|
||
if steps[1].RiskScore != 36 {
|
||
t.Errorf("after_design: expected 36, got %.1f", steps[1].RiskScore)
|
||
}
|
||
// After protection: S=4, E=1, P=2 → 8
|
||
if steps[2].RiskScore != 8 {
|
||
t.Errorf("after_protection: expected 8, got %.1f", steps[2].RiskScore)
|
||
}
|
||
// After information: S=4, E=1, P=1 → 4
|
||
if steps[3].RiskScore != 4 {
|
||
t.Errorf("after_information: expected 4, got %.1f", steps[3].RiskScore)
|
||
}
|
||
if !steps[3].IsAcceptable {
|
||
t.Error("final risk 4 should be acceptable")
|
||
}
|
||
}
|
||
|
||
func TestRiskTrajectory_ClampMinimum1(t *testing.T) {
|
||
engine := NewRiskEngine()
|
||
// Very aggressive reduction that would push below 1
|
||
measures := []ProtectiveMeasureEntry{
|
||
{ID: "M001", ReductionType: "design", RiskReduction: &RiskReduction{SeverityDelta: -5, ExposureDelta: -5, ProbabilityDelta: -5}},
|
||
}
|
||
steps := engine.CalculateRiskTrajectory(3, 3, 3, measures)
|
||
|
||
after := steps[1]
|
||
if after.Severity != 1 || after.Exposure != 1 || after.Probability != 1 {
|
||
t.Errorf("expected all clamped to 1, got S=%d E=%d P=%d", after.Severity, after.Exposure, after.Probability)
|
||
}
|
||
if after.RiskScore != 1 {
|
||
t.Errorf("expected minimum risk score 1, got %.1f", after.RiskScore)
|
||
}
|
||
}
|
||
|
||
func TestRiskTrajectory_OnlyProtectionMeasures(t *testing.T) {
|
||
engine := NewRiskEngine()
|
||
// No design measures, only protection
|
||
measures := []ProtectiveMeasureEntry{
|
||
{ID: "M067", ReductionType: "protection", RiskReduction: &RiskReduction{ExposureDelta: -2}},
|
||
}
|
||
steps := engine.CalculateRiskTrajectory(4, 4, 3, measures)
|
||
|
||
// Should have 2 steps: inherent + after_protection (no after_design)
|
||
if len(steps) != 2 {
|
||
t.Fatalf("expected 2 steps, got %d", len(steps))
|
||
}
|
||
if steps[1].Stage != "after_protection" {
|
||
t.Errorf("expected stage after_protection, got %q", steps[1].Stage)
|
||
}
|
||
// S=4, E=2, P=3 → 24
|
||
if steps[1].RiskScore != 24 {
|
||
t.Errorf("expected 24, got %.1f", steps[1].RiskScore)
|
||
}
|
||
}
|
||
|
||
func TestRiskTrajectory_MeasuresWithoutRiskReduction(t *testing.T) {
|
||
engine := NewRiskEngine()
|
||
// Measures without RiskReduction should be skipped (no delta)
|
||
measures := []ProtectiveMeasureEntry{
|
||
{ID: "M151", ReductionType: "information", Name: "Betriebsanleitung"},
|
||
}
|
||
steps := engine.CalculateRiskTrajectory(3, 3, 3, measures)
|
||
|
||
if len(steps) != 1 {
|
||
t.Fatalf("expected 1 step (measures without RiskReduction have no effect), got %d", len(steps))
|
||
}
|
||
}
|
||
|
||
func TestRiskTrajectory_MandatoryMeasuresAsProtective(t *testing.T) {
|
||
engine := NewRiskEngine()
|
||
// MN measures have ReductionType "protective" — should be grouped with "protection"
|
||
measures := []ProtectiveMeasureEntry{
|
||
{ID: "MN001", ReductionType: "protective", RiskReduction: &RiskReduction{ProbabilityDelta: -1}},
|
||
}
|
||
steps := engine.CalculateRiskTrajectory(4, 4, 3, measures)
|
||
|
||
if len(steps) != 2 {
|
||
t.Fatalf("expected 2 steps, got %d", len(steps))
|
||
}
|
||
if steps[1].Stage != "after_protection" {
|
||
t.Errorf("expected after_protection for 'protective' type, got %q", steps[1].Stage)
|
||
}
|
||
}
|
||
|
||
func TestRiskReduction_OnMeasureLibrary(t *testing.T) {
|
||
// Verify that the library has measures with RiskReduction profiles
|
||
measures := GetProtectiveMeasureLibrary()
|
||
withReduction := 0
|
||
for _, m := range measures {
|
||
if m.RiskReduction != nil {
|
||
withReduction++
|
||
}
|
||
}
|
||
if withReduction < 80 {
|
||
t.Errorf("expected at least 80 measures with RiskReduction profiles, got %d", withReduction)
|
||
}
|
||
}
|