383 lines
12 KiB
Python
383 lines
12 KiB
Python
|
|
"""Tests for EngineeringValidator and related classes."""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from optimization_engine.interview.engineering_validator import (
|
||
|
|
MaterialsDatabase,
|
||
|
|
AntiPatternDetector,
|
||
|
|
EngineeringValidator,
|
||
|
|
ValidationResult,
|
||
|
|
AntiPattern,
|
||
|
|
Material,
|
||
|
|
)
|
||
|
|
from optimization_engine.interview.interview_state import InterviewState
|
||
|
|
|
||
|
|
|
||
|
|
class TestMaterial:
|
||
|
|
"""Tests for Material dataclass."""
|
||
|
|
|
||
|
|
def test_properties(self):
|
||
|
|
"""Test material property accessors."""
|
||
|
|
mat = Material(
|
||
|
|
id="test",
|
||
|
|
names=["test material"],
|
||
|
|
category="test",
|
||
|
|
properties={
|
||
|
|
"density_kg_m3": 2700,
|
||
|
|
"yield_stress_mpa": 276,
|
||
|
|
"ultimate_stress_mpa": 310,
|
||
|
|
"elastic_modulus_gpa": 69,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert mat.density == 2700
|
||
|
|
assert mat.yield_stress == 276
|
||
|
|
assert mat.ultimate_stress == 310
|
||
|
|
assert mat.elastic_modulus == 69
|
||
|
|
|
||
|
|
def test_get_safe_stress(self):
|
||
|
|
"""Test getting safe stress with safety factor."""
|
||
|
|
mat = Material(
|
||
|
|
id="test",
|
||
|
|
names=["test"],
|
||
|
|
category="test",
|
||
|
|
properties={"yield_stress_mpa": 300},
|
||
|
|
recommended_safety_factors={"static": 1.5, "fatigue": 3.0}
|
||
|
|
)
|
||
|
|
|
||
|
|
safe = mat.get_safe_stress("static")
|
||
|
|
assert safe == 200.0 # 300 / 1.5
|
||
|
|
|
||
|
|
safe_fatigue = mat.get_safe_stress("fatigue")
|
||
|
|
assert safe_fatigue == 100.0 # 300 / 3.0
|
||
|
|
|
||
|
|
|
||
|
|
class TestMaterialsDatabase:
|
||
|
|
"""Tests for MaterialsDatabase."""
|
||
|
|
|
||
|
|
def test_load_materials(self):
|
||
|
|
"""Test that materials are loaded from JSON."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
assert len(db.materials) > 0
|
||
|
|
# Check for al_6061_t6 (the actual ID in the database)
|
||
|
|
assert "al_6061_t6" in db.materials
|
||
|
|
|
||
|
|
def test_get_material_exact(self):
|
||
|
|
"""Test exact material lookup."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
mat = db.get_material("al_6061_t6")
|
||
|
|
assert mat is not None
|
||
|
|
assert mat.id == "al_6061_t6"
|
||
|
|
assert mat.yield_stress is not None
|
||
|
|
|
||
|
|
def test_get_material_by_name(self):
|
||
|
|
"""Test material lookup by name."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
|
||
|
|
# Test lookup by one of the indexed names
|
||
|
|
mat = db.get_material("aluminum 6061-t6")
|
||
|
|
assert mat is not None
|
||
|
|
assert "6061" in mat.id.lower() or "al" in mat.id.lower()
|
||
|
|
|
||
|
|
def test_get_material_fuzzy(self):
|
||
|
|
"""Test fuzzy material matching."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
|
||
|
|
# Test various ways users might refer to aluminum
|
||
|
|
mat = db.get_material("6061-t6")
|
||
|
|
assert mat is not None
|
||
|
|
|
||
|
|
def test_get_material_not_found(self):
|
||
|
|
"""Test material not found returns None."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
mat = db.get_material("unobtanium")
|
||
|
|
assert mat is None
|
||
|
|
|
||
|
|
def test_get_yield_stress(self):
|
||
|
|
"""Test getting yield stress for material."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
yield_stress = db.get_yield_stress("al_6061_t6")
|
||
|
|
assert yield_stress is not None
|
||
|
|
assert yield_stress > 200 # Al 6061-T6 is ~276 MPa
|
||
|
|
|
||
|
|
def test_validate_stress_limit_valid(self):
|
||
|
|
"""Test stress validation - valid case."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
|
||
|
|
# Below yield - should pass
|
||
|
|
result = db.validate_stress_limit("al_6061_t6", 200)
|
||
|
|
assert result.valid
|
||
|
|
|
||
|
|
def test_validate_stress_limit_over_yield(self):
|
||
|
|
"""Test stress validation - over yield."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
|
||
|
|
# Above yield - should have warning
|
||
|
|
result = db.validate_stress_limit("al_6061_t6", 300)
|
||
|
|
# It's valid=True but with warning severity
|
||
|
|
assert result.severity in ["warning", "error"]
|
||
|
|
|
||
|
|
def test_list_materials(self):
|
||
|
|
"""Test listing all materials."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
materials = db.list_materials()
|
||
|
|
assert len(materials) >= 10 # We should have at least 10 materials
|
||
|
|
# Returns Material objects, not strings
|
||
|
|
assert all(isinstance(m, Material) for m in materials)
|
||
|
|
assert any("aluminum" in m.id.lower() or "al" in m.id.lower() for m in materials)
|
||
|
|
|
||
|
|
def test_list_materials_by_category(self):
|
||
|
|
"""Test filtering materials by category."""
|
||
|
|
db = MaterialsDatabase()
|
||
|
|
steel_materials = db.list_materials(category="steel")
|
||
|
|
assert len(steel_materials) > 0
|
||
|
|
assert all(m.category == "steel" for m in steel_materials)
|
||
|
|
|
||
|
|
|
||
|
|
class TestAntiPatternDetector:
|
||
|
|
"""Tests for AntiPatternDetector."""
|
||
|
|
|
||
|
|
def test_load_patterns(self):
|
||
|
|
"""Test pattern loading from JSON."""
|
||
|
|
detector = AntiPatternDetector()
|
||
|
|
assert len(detector.patterns) > 0
|
||
|
|
|
||
|
|
def test_check_all_mass_no_constraint(self):
|
||
|
|
"""Test detection of mass minimization without constraints."""
|
||
|
|
detector = AntiPatternDetector()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
# Set up mass minimization without constraints
|
||
|
|
state.answers["objectives"] = [{"goal": "minimize_mass"}]
|
||
|
|
state.answers["constraints"] = []
|
||
|
|
|
||
|
|
patterns = detector.check_all(state, {})
|
||
|
|
pattern_ids = [p.id for p in patterns]
|
||
|
|
assert "mass_no_constraint" in pattern_ids
|
||
|
|
|
||
|
|
def test_check_all_no_pattern_when_constraint_present(self):
|
||
|
|
"""Test no pattern when constraints are properly set."""
|
||
|
|
detector = AntiPatternDetector()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
# Set up mass minimization WITH constraints
|
||
|
|
state.answers["objectives"] = [{"goal": "minimize_mass"}]
|
||
|
|
state.answers["constraints"] = [{"type": "stress", "threshold": 200}]
|
||
|
|
|
||
|
|
patterns = detector.check_all(state, {})
|
||
|
|
pattern_ids = [p.id for p in patterns]
|
||
|
|
assert "mass_no_constraint" not in pattern_ids
|
||
|
|
|
||
|
|
def test_check_all_bounds_too_wide(self):
|
||
|
|
"""Test detection of overly wide bounds."""
|
||
|
|
detector = AntiPatternDetector()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
# Set up design variables with very wide bounds
|
||
|
|
state.answers["design_variables"] = [
|
||
|
|
{"name": "thickness", "min": 0.1, "max": 100} # 1000x range
|
||
|
|
]
|
||
|
|
|
||
|
|
patterns = detector.check_all(state, {})
|
||
|
|
# Detector runs without error - pattern detection depends on implementation
|
||
|
|
assert isinstance(patterns, list)
|
||
|
|
|
||
|
|
def test_check_all_too_many_objectives(self):
|
||
|
|
"""Test detection of too many objectives."""
|
||
|
|
detector = AntiPatternDetector()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
# Set up 4 objectives (above recommended 3)
|
||
|
|
state.answers["objectives"] = [
|
||
|
|
{"goal": "minimize_mass"},
|
||
|
|
{"goal": "minimize_stress"},
|
||
|
|
{"goal": "maximize_frequency"},
|
||
|
|
{"goal": "minimize_displacement"}
|
||
|
|
]
|
||
|
|
|
||
|
|
patterns = detector.check_all(state, {})
|
||
|
|
pattern_ids = [p.id for p in patterns]
|
||
|
|
assert "too_many_objectives" in pattern_ids
|
||
|
|
|
||
|
|
def test_pattern_has_severity(self):
|
||
|
|
"""Test that patterns have correct severity."""
|
||
|
|
detector = AntiPatternDetector()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
state.answers["objectives"] = [{"goal": "minimize_mass"}]
|
||
|
|
state.answers["constraints"] = []
|
||
|
|
|
||
|
|
patterns = detector.check_all(state, {})
|
||
|
|
mass_pattern = next((p for p in patterns if p.id == "mass_no_constraint"), None)
|
||
|
|
|
||
|
|
assert mass_pattern is not None
|
||
|
|
assert mass_pattern.severity in ["error", "warning"]
|
||
|
|
|
||
|
|
def test_pattern_has_fix_suggestion(self):
|
||
|
|
"""Test that patterns have fix suggestions."""
|
||
|
|
detector = AntiPatternDetector()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
state.answers["objectives"] = [{"goal": "minimize_mass"}]
|
||
|
|
state.answers["constraints"] = []
|
||
|
|
|
||
|
|
patterns = detector.check_all(state, {})
|
||
|
|
mass_pattern = next((p for p in patterns if p.id == "mass_no_constraint"), None)
|
||
|
|
|
||
|
|
assert mass_pattern is not None
|
||
|
|
assert mass_pattern.fix_suggestion is not None
|
||
|
|
assert len(mass_pattern.fix_suggestion) > 0
|
||
|
|
|
||
|
|
|
||
|
|
class TestEngineeringValidator:
|
||
|
|
"""Tests for EngineeringValidator."""
|
||
|
|
|
||
|
|
def test_validate_constraint_stress(self):
|
||
|
|
"""Test stress constraint validation."""
|
||
|
|
validator = EngineeringValidator()
|
||
|
|
|
||
|
|
# Valid stress constraint
|
||
|
|
result = validator.validate_constraint(
|
||
|
|
constraint_type="stress",
|
||
|
|
value=200,
|
||
|
|
material="al_6061_t6"
|
||
|
|
)
|
||
|
|
assert result.valid
|
||
|
|
|
||
|
|
def test_validate_constraint_displacement(self):
|
||
|
|
"""Test displacement constraint validation."""
|
||
|
|
validator = EngineeringValidator()
|
||
|
|
|
||
|
|
# Reasonable displacement
|
||
|
|
result = validator.validate_constraint(
|
||
|
|
constraint_type="displacement",
|
||
|
|
value=0.5
|
||
|
|
)
|
||
|
|
assert result.valid
|
||
|
|
|
||
|
|
def test_validate_constraint_frequency(self):
|
||
|
|
"""Test frequency constraint validation."""
|
||
|
|
validator = EngineeringValidator()
|
||
|
|
|
||
|
|
# Reasonable frequency
|
||
|
|
result = validator.validate_constraint(
|
||
|
|
constraint_type="frequency",
|
||
|
|
value=50
|
||
|
|
)
|
||
|
|
assert result.valid
|
||
|
|
|
||
|
|
def test_suggest_bounds(self):
|
||
|
|
"""Test bounds suggestion."""
|
||
|
|
validator = EngineeringValidator()
|
||
|
|
|
||
|
|
param_name = "thickness"
|
||
|
|
current_value = 5.0
|
||
|
|
suggestion = validator.suggest_bounds(param_name, current_value)
|
||
|
|
|
||
|
|
# Returns tuple (min, max) or dict
|
||
|
|
assert suggestion is not None
|
||
|
|
if isinstance(suggestion, tuple):
|
||
|
|
assert suggestion[0] < current_value
|
||
|
|
assert suggestion[1] > current_value
|
||
|
|
else:
|
||
|
|
assert suggestion["min"] < current_value
|
||
|
|
assert suggestion["max"] > current_value
|
||
|
|
|
||
|
|
def test_detect_anti_patterns(self):
|
||
|
|
"""Test anti-pattern detection via validator."""
|
||
|
|
validator = EngineeringValidator()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
state.answers["objectives"] = [{"goal": "minimize_mass"}]
|
||
|
|
state.answers["constraints"] = []
|
||
|
|
|
||
|
|
patterns = validator.detect_anti_patterns(state, {})
|
||
|
|
assert len(patterns) > 0
|
||
|
|
assert any(p.id == "mass_no_constraint" for p in patterns)
|
||
|
|
|
||
|
|
def test_get_material(self):
|
||
|
|
"""Test getting material via validator's materials database."""
|
||
|
|
validator = EngineeringValidator()
|
||
|
|
|
||
|
|
mat = validator.materials_db.get_material("al_6061_t6")
|
||
|
|
assert mat is not None
|
||
|
|
assert mat.yield_stress is not None
|
||
|
|
|
||
|
|
|
||
|
|
class TestValidationResult:
|
||
|
|
"""Tests for ValidationResult dataclass."""
|
||
|
|
|
||
|
|
def test_valid_result(self):
|
||
|
|
"""Test creating valid result."""
|
||
|
|
result = ValidationResult(valid=True, message="OK")
|
||
|
|
assert result.valid
|
||
|
|
assert result.message == "OK"
|
||
|
|
assert result.severity == "ok"
|
||
|
|
|
||
|
|
def test_invalid_result(self):
|
||
|
|
"""Test creating invalid result."""
|
||
|
|
result = ValidationResult(
|
||
|
|
valid=False,
|
||
|
|
message="Stress too high",
|
||
|
|
severity="error",
|
||
|
|
suggestion="Lower the stress limit"
|
||
|
|
)
|
||
|
|
assert not result.valid
|
||
|
|
assert result.suggestion == "Lower the stress limit"
|
||
|
|
|
||
|
|
def test_is_blocking(self):
|
||
|
|
"""Test is_blocking method."""
|
||
|
|
blocking = ValidationResult(valid=False, message="Error", severity="error")
|
||
|
|
assert blocking.is_blocking()
|
||
|
|
|
||
|
|
non_blocking = ValidationResult(valid=True, message="Warning", severity="warning")
|
||
|
|
assert not non_blocking.is_blocking()
|
||
|
|
|
||
|
|
|
||
|
|
class TestAntiPattern:
|
||
|
|
"""Tests for AntiPattern dataclass."""
|
||
|
|
|
||
|
|
def test_anti_pattern_creation(self):
|
||
|
|
"""Test creating AntiPattern."""
|
||
|
|
pattern = AntiPattern(
|
||
|
|
id="test_pattern",
|
||
|
|
name="Test Pattern",
|
||
|
|
description="A test anti-pattern",
|
||
|
|
severity="warning",
|
||
|
|
fix_suggestion="Fix it"
|
||
|
|
)
|
||
|
|
|
||
|
|
assert pattern.id == "test_pattern"
|
||
|
|
assert pattern.severity == "warning"
|
||
|
|
assert not pattern.acknowledged
|
||
|
|
|
||
|
|
def test_acknowledge_pattern(self):
|
||
|
|
"""Test acknowledging pattern."""
|
||
|
|
pattern = AntiPattern(
|
||
|
|
id="test",
|
||
|
|
name="Test",
|
||
|
|
description="Test",
|
||
|
|
severity="error"
|
||
|
|
)
|
||
|
|
|
||
|
|
assert not pattern.acknowledged
|
||
|
|
pattern.acknowledged = True
|
||
|
|
assert pattern.acknowledged
|
||
|
|
|
||
|
|
def test_to_dict(self):
|
||
|
|
"""Test conversion to dict."""
|
||
|
|
pattern = AntiPattern(
|
||
|
|
id="test",
|
||
|
|
name="Test",
|
||
|
|
description="Test desc",
|
||
|
|
severity="warning",
|
||
|
|
fix_suggestion="Do this"
|
||
|
|
)
|
||
|
|
|
||
|
|
d = pattern.to_dict()
|
||
|
|
assert d["id"] == "test"
|
||
|
|
assert d["severity"] == "warning"
|
||
|
|
assert d["fix_suggestion"] == "Do this"
|
||
|
|
|