77a497d930
State Graph: - 9 Standard-Betriebszustaende (startup, homing, automatic_operation, manual_operation, teach_mode, maintenance, cleaning, emergency_stop, recovery_mode) - 20 State-Transitions als gerichteter Graph - OperationalStates + StateTransitions Felder in HazardPattern, MatchInput, PatternMatch - patternMatches() filtert Patterns nach Betriebszustand (nil = feuert immer) - Narrative-Parser extrahiert States aus Maschinenbeschreibung (22 Keywords + 4 Transition-Keywords) - 27 bestehende Patterns mit State-Einschraenkungen annotiert (10 operational, 15 maintenance, 2 cobot) - MatchReason um operational_state + state_transition Typen erweitert (Explainability) - 6 neue Tests: NilFiresAlways, MaintenanceFilter, StateTransition, MatchReasons, Count, TransitionValid UCCA fix: - Stabiler Tiebreaker (Pattern-ID aufsteigend) bei gleichem Keyword-Score in MatchByKeywords - Behebt flaky TestControlPatternIndex_MatchByKeywords (1/10 Failure-Rate durch Go map iteration order) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
434 lines
13 KiB
Go
434 lines
13 KiB
Go
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}
|
|
}
|