package iace import "testing" func TestPatternEngine_RobotArm_MechanicalHazards(t *testing.T) { engine := NewPatternEngine() result := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001"}, // Roboterarm: moving_part, rotating_part, high_force EnergySourceIDs: []string{"EN01", "EN02"}, // kinetic translational + rotational }) if len(result.MatchedPatterns) == 0 { t.Fatal("expected matched patterns for robot arm + kinetic energy") } // Should match mechanical hazard patterns (HP001, HP002, HP006) hasMechHazard := false for _, h := range result.SuggestedHazards { if h.Category == "mechanical_hazard" { hasMechHazard = true break } } if !hasMechHazard { t.Error("expected mechanical_hazard in suggested hazards for robot arm") } } func TestPatternEngine_Schaltschrank_ElectricalHazards(t *testing.T) { engine := NewPatternEngine() result := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C061"}, // Schaltschrank: high_voltage, electrical_part EnergySourceIDs: []string{"EN04"}, // Elektrische Energie }) if len(result.MatchedPatterns) == 0 { t.Fatal("expected matched patterns for control cabinet + electrical energy") } hasElecHazard := false for _, h := range result.SuggestedHazards { if h.Category == "electrical_hazard" { hasElecHazard = true break } } if !hasElecHazard { t.Error("expected electrical_hazard in suggested hazards for Schaltschrank") } } func TestPatternEngine_SPS_Switch_SoftwareCyberHazards(t *testing.T) { engine := NewPatternEngine() result := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C071", "C111"}, // SPS + Switch: has_software, programmable, networked, it_component EnergySourceIDs: []string{"EN18"}, // Cyber/Data energy }) if len(result.MatchedPatterns) == 0 { t.Fatal("expected matched patterns for SPS + Switch + cyber energy") } categories := make(map[string]bool) for _, h := range result.SuggestedHazards { categories[h.Category] = true } if !categories["software_fault"] { t.Error("expected software_fault hazard for SPS") } if !categories["unauthorized_access"] { t.Error("expected unauthorized_access hazard for networked components + cyber energy") } } func TestPatternEngine_AIModule_AISpecificHazards(t *testing.T) { engine := NewPatternEngine() result := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C119"}, // KI-Inferenzmodul: has_ai, has_software, networked EnergySourceIDs: []string{"EN19", "EN18"}, // AI model + cyber energy }) if len(result.MatchedPatterns) == 0 { t.Fatal("expected matched patterns for AI inference module") } categories := make(map[string]bool) for _, h := range result.SuggestedHazards { categories[h.Category] = true } if !categories["false_classification"] { t.Error("expected false_classification hazard for AI module") } if !categories["model_drift"] { t.Error("expected model_drift hazard for AI module") } } func TestPatternEngine_EmptyInput_EmptyResults(t *testing.T) { engine := NewPatternEngine() result := engine.Match(MatchInput{}) if len(result.MatchedPatterns) != 0 { t.Errorf("expected no matched patterns for empty input, got %d", len(result.MatchedPatterns)) } if len(result.SuggestedHazards) != 0 { t.Errorf("expected no suggested hazards for empty input, got %d", len(result.SuggestedHazards)) } } func TestPatternEngine_ExclusionTags(t *testing.T) { engine := NewPatternEngine() // HP029 requires has_software+programmable, excludes has_ai // C071 (SPS) has has_software+programmable but NOT has_ai → should match HP029 result1 := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C071"}, // SPS }) hasHP029 := false for _, p := range result1.MatchedPatterns { if p.PatternID == "HP029" { hasHP029 = true break } } if !hasHP029 { t.Error("HP029 should match for SPS (has_software+programmable, no has_ai)") } // C119 (KI-Inferenzmodul) has has_ai → HP029 should be excluded result2 := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C119"}, // KI-Inferenzmodul: has_ai }) hasHP029_2 := false for _, p := range result2.MatchedPatterns { if p.PatternID == "HP029" { hasHP029_2 = true break } } if hasHP029_2 { t.Error("HP029 should NOT match for AI module (excluded by has_ai tag)") } } func TestPatternEngine_HydraulicPatterns(t *testing.T) { engine := NewPatternEngine() result := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C042"}, // Hydraulikzylinder EnergySourceIDs: []string{"EN05"}, // Hydraulische Energie }) hasHydraulicHazard := false for _, h := range result.SuggestedHazards { if h.Category == "pneumatic_hydraulic" { hasHydraulicHazard = true break } } if !hasHydraulicHazard { t.Error("expected pneumatic_hydraulic hazard for hydraulic cylinder + hydraulic energy") } } func TestPatternEngine_MeasuresAndEvidence(t *testing.T) { engine := NewPatternEngine() result := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001"}, // Roboterarm EnergySourceIDs: []string{"EN01"}, // Kinetische Energie }) if len(result.SuggestedMeasures) == 0 { t.Error("expected suggested measures for robot arm") } if len(result.SuggestedEvidence) == 0 { t.Error("expected suggested evidence for robot arm") } } func TestPatternEngine_ResolvedTags(t *testing.T) { engine := NewPatternEngine() result := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001"}, EnergySourceIDs: []string{"EN01"}, CustomTags: []string{"my_custom_tag"}, }) tagSet := toSet(result.ResolvedTags) if !tagSet["moving_part"] { t.Error("expected 'moving_part' in resolved tags") } if !tagSet["kinetic"] { t.Error("expected 'kinetic' in resolved tags") } if !tagSet["my_custom_tag"] { t.Error("expected 'my_custom_tag' in resolved tags") } } func TestPatternEngine_SafetyDevice_HighPriority(t *testing.T) { engine := NewPatternEngine() result := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C101"}, // Not-Halt-Taster: safety_device, emergency_stop }) hasSafetyFail := false for _, h := range result.SuggestedHazards { if h.Category == "safety_function_failure" { hasSafetyFail = true break } } if !hasSafetyFail { t.Error("expected safety_function_failure for Not-Halt-Taster") } } // ── Operational State Graph tests ────────────────────────────────── func TestPatternEngine_OperationalState_NilFiresAlways(t *testing.T) { // Patterns with nil OperationalStates should fire regardless of input states engine := NewPatternEngine() result1 := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001"}, EnergySourceIDs: []string{"EN01"}, }) result2 := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001"}, EnergySourceIDs: []string{"EN01"}, OperationalStates: []string{"automatic_operation"}, }) if len(result1.MatchedPatterns) == 0 { t.Fatal("expected patterns to fire without operational states") } if len(result2.MatchedPatterns) == 0 { t.Fatal("expected patterns to fire with automatic_operation state") } // nil-state patterns should fire in both cases if len(result1.MatchedPatterns) != len(result2.MatchedPatterns) { t.Logf("patterns without states: %d, with automatic_operation: %d", len(result1.MatchedPatterns), len(result2.MatchedPatterns)) // This is OK — some patterns may have OperationalStates set and filter differently } } func TestPatternEngine_OperationalState_MaintenanceFilter(t *testing.T) { // HP073 (LOTO) has OperationalStates: ["maintenance"] // It should only fire when maintenance is in the input states engine := NewPatternEngine() // Without maintenance state — HP073 should still fire if no states in input // (because empty input states = no filtering) resultNoState := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001"}, EnergySourceIDs: []string{"EN01"}, LifecyclePhases: []string{"maintenance"}, }) // With maintenance state — HP073 should fire resultMaint := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001"}, EnergySourceIDs: []string{"EN01"}, LifecyclePhases: []string{"maintenance"}, OperationalStates: []string{"maintenance"}, }) // With only automatic_operation — HP073 should NOT fire resultAuto := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001"}, EnergySourceIDs: []string{"EN01"}, LifecyclePhases: []string{"maintenance"}, OperationalStates: []string{"automatic_operation"}, }) // HP073 should be in resultMaint but not in resultAuto hasHP073Maint := false for _, p := range resultMaint.MatchedPatterns { if p.PatternID == "HP073" { hasHP073Maint = true break } } hasHP073Auto := false for _, p := range resultAuto.MatchedPatterns { if p.PatternID == "HP073" { hasHP073Auto = true break } } if !hasHP073Maint { t.Error("HP073 should fire with maintenance operational state") } if hasHP073Auto { t.Error("HP073 should NOT fire with automatic_operation operational state") } _ = resultNoState // HP073 fires here too because empty input states = no filter } func TestPatternEngine_StateTransition_UnexpectedRestart(t *testing.T) { // HP068 has StateTransitions: ["maintenance→automatic_operation", "emergency_stop→recovery_mode"] engine := NewPatternEngine() // HP068 requires moving_part + programmable — C001 (Roboterarm) + C071 (SPS) // With matching transition resultMatch := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001", "C071"}, EnergySourceIDs: []string{"EN01"}, LifecyclePhases: []string{"fault_clearing"}, OperationalStates: []string{"recovery_mode", "emergency_stop"}, StateTransitions: []string{"maintenance→automatic_operation"}, }) // With non-matching transition resultNoMatch := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001", "C071"}, EnergySourceIDs: []string{"EN01"}, LifecyclePhases: []string{"fault_clearing"}, OperationalStates: []string{"recovery_mode", "emergency_stop"}, StateTransitions: []string{"startup→homing"}, }) hasHP068Match := false for _, p := range resultMatch.MatchedPatterns { if p.PatternID == "HP068" { hasHP068Match = true // Verify explainability reasons include state transition hasTransReason := false for _, r := range p.MatchReasons { if r.Type == "state_transition" { hasTransReason = true break } } if !hasTransReason { t.Error("HP068 match should include state_transition reason") } break } } hasHP068NoMatch := false for _, p := range resultNoMatch.MatchedPatterns { if p.PatternID == "HP068" { hasHP068NoMatch = true break } } if !hasHP068Match { t.Error("HP068 should fire with matching state transition") } if hasHP068NoMatch { t.Error("HP068 should NOT fire with non-matching state transition") } } func TestPatternEngine_MatchReasons_IncludeOperationalState(t *testing.T) { // Verify that MatchReasons include operational_state entries engine := NewPatternEngine() result := engine.Match(MatchInput{ ComponentLibraryIDs: []string{"C001"}, EnergySourceIDs: []string{"EN01"}, LifecyclePhases: []string{"maintenance"}, OperationalStates: []string{"maintenance"}, }) for _, p := range result.MatchedPatterns { if p.PatternID == "HP073" { hasStateReason := false for _, r := range p.MatchReasons { if r.Type == "operational_state" && r.Tag == "maintenance" && r.Met { hasStateReason = true break } } if !hasStateReason { t.Error("HP073 MatchReasons should include operational_state:maintenance with Met=true") } return } } t.Error("HP073 not found in matched patterns") } func TestAllOperationalStates_Count(t *testing.T) { states := AllOperationalStates() if len(states) != 9 { t.Errorf("expected 9 operational states, got %d", len(states)) } } func TestStandardStateTransitions_Valid(t *testing.T) { transitions := StandardStateTransitions() if len(transitions) < 15 { t.Errorf("expected at least 15 state transitions, got %d", len(transitions)) } // Verify all transitions reference valid states stateSet := make(map[string]bool) for _, s := range AllOperationalStates() { stateSet[s] = true } for _, tr := range transitions { // Format: "from→to" (→ is multi-byte UTF-8) parts := splitTransition(tr) if len(parts) != 2 { t.Errorf("invalid transition format: %q", tr) continue } if !stateSet[parts[0]] { t.Errorf("transition %q references unknown state: %q", tr, parts[0]) } if !stateSet[parts[1]] { t.Errorf("transition %q references unknown state: %q", tr, parts[1]) } } } func splitTransition(tr string) []string { // Split on → (UTF-8: 0xE2 0x86 0x92) idx := 0 for i := 0; i < len(tr); i++ { if tr[i] == 0xE2 && i+2 < len(tr) && tr[i+1] == 0x86 && tr[i+2] == 0x92 { return []string{tr[:i], tr[i+3:]} } idx = i } _ = idx return []string{tr} }