otko/tests/tools/test_parse_schemas.py
smillmorel 61b850bf46
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / type (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (macos-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (ubuntu-latest, 3.12) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.10) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.11) (pull_request) Has been cancelled
CI / test-headless (windows-latest, 3.12) (pull_request) Has been cancelled
CI / test-gui (pull_request) Has been cancelled
CI / test-integration (macos-latest) (pull_request) Has been cancelled
CI / test-integration (ubuntu-latest) (pull_request) Has been cancelled
CI / test-integration (windows-latest) (pull_request) Has been cancelled
style: resolve the remaining ruff findings
Auto-fixes (UP037, UP038, F401, I001, SIM105, RUF100) plus hand-fixes:
drop the now-unused `Union` imports, annotate mutable class attributes
with ClassVar, raise ValidationError instead of blind Exception in the
zero-length test, delete two dead locals, and bind the per-iteration
grid-builder closures to their loop variables (B023).
2026-09-16 13:20:36 -04:00

391 lines
14 KiB
Python

"""
Tests for tools/gidopensees_import/parse_schemas.py.
Fast tests run without gidopensees present (snapshot + unit tests).
Integration tests that read the real .mat/.cnd files are skipped when the
files cannot be found, so CI still passes in environments without the
upstream repo checked out.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tools.gidopensees_import.parse_schemas import (
ParseError,
_parse_dependencies,
_parse_question,
_parse_value,
build_catalog,
parse_mat,
)
from tools.gidopensees_import.schema_model import CatalogSpec
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
FIXTURES = Path(__file__).parent / "fixtures"
MINIMAL_MAT = FIXTURES / "minimal.mat"
MINIMAL_EXPECTED = FIXTURES / "minimal_expected.json"
GIDOPENSEES = Path("d:/GitHub/gidopensees")
REAL_MAT = GIDOPENSEES / "OpenSees.mat"
REAL_CND = GIDOPENSEES / "OpenSees.cnd"
_have_real = REAL_MAT.exists() and REAL_CND.exists()
real_files = pytest.mark.skipif(not _have_real, reason="gidopensees repo not present")
# ---------------------------------------------------------------------------
# Unit tests — _parse_question
# ---------------------------------------------------------------------------
class TestParseQuestion:
def test_cb_with_options(self) -> None:
name, wtype, opts = _parse_question("Formulation#CB#(Stress-Strain,Force-Deformation)")
assert name == "Formulation"
assert wtype == "CB"
assert opts == ["Stress-Strain", "Force-Deformation"]
def test_cb_single_underscore(self) -> None:
name, wtype, opts = _parse_question("_#CB#(_)")
assert name == "_"
assert wtype == "CB"
assert opts == ["_"]
def test_units(self) -> None:
name, wtype, opts = _parse_question("Elastic_modulus_E#UNITS#")
assert name == "Elastic_modulus_E"
assert wtype == "UNITS"
assert opts == []
def test_mat_with_books(self) -> None:
name, wtype, opts = _parse_question(
"Ux_material#MAT#(Standard_Uniaxial_Materials,Uniaxial_Steel_Materials)"
)
assert name == "Ux_material"
assert wtype == "MAT"
assert "Standard_Uniaxial_Materials" in opts
def test_tuple(self) -> None:
name, wtype, opts = _parse_question(
"Cyclic_data(Max_compressive_strain,Max_tensile_strain,Number_of_cycles)"
)
assert name == "Cyclic_data"
assert wtype == "TUPLE"
assert opts == ["Max_compressive_strain", "Max_tensile_strain", "Number_of_cycles"]
def test_plain_scalar(self) -> None:
name, wtype, opts = _parse_question("Parameter_R0")
assert name == "Parameter_R0"
assert wtype == "SCALAR"
assert opts == []
def test_label_with_trailing_colon(self) -> None:
name, wtype, opts = _parse_question("Material:#CB#(Steel02)")
assert name == "Material"
assert wtype == "CB"
assert opts == ["Steel02"]
def test_plain_label_colon(self) -> None:
name, wtype, _opts = _parse_question("Layer_name:")
assert name == "Layer_name"
assert wtype == "SCALAR"
def test_cb_no_options(self) -> None:
name, wtype, opts = _parse_question("Section:#CB#(Fiber)")
assert name == "Section"
assert wtype == "CB"
assert opts == ["Fiber"]
# ---------------------------------------------------------------------------
# Unit tests — _parse_value
# ---------------------------------------------------------------------------
class TestParseValue:
def test_simple(self) -> None:
val, width = _parse_value("30 GPa")
assert val == "30 GPa"
assert width is None
def test_width_suffix(self) -> None:
val, width = _parse_value("0.0#WIDTH#(10)")
assert val == "0.0"
assert width == 10
def test_number_width(self) -> None:
val, width = _parse_value("20#WIDTH#(10)")
assert val == "20"
assert width == 10
def test_n_prefix(self) -> None:
val, width = _parse_value("#N# 3 -0.005 0.005 5")
assert val == "#N# 3 -0.005 0.005 5"
assert width is None
def test_empty(self) -> None:
val, width = _parse_value("")
assert val == ""
assert width is None
# ---------------------------------------------------------------------------
# Unit tests — _parse_dependencies
# ---------------------------------------------------------------------------
class TestParseDependencies:
_path = Path("test.mat")
def test_single_group_restore_hide(self) -> None:
s = "(Stress-Strain,RESTORE,Mat_type,#CURRENT#,HIDE,Stiffness_K,#CURRENT#)"
rules = _parse_dependencies(s, self._path, 1)
assert len(rules) == 1
rule = rules[0]
assert rule.trigger == "Stress-Strain"
assert len(rule.actions) == 2
assert rule.actions[0].action == "RESTORE"
assert rule.actions[0].field == "Mat_type"
assert rule.actions[1].action == "HIDE"
assert rule.actions[1].field == "Stiffness_K"
def test_two_groups_same_line(self) -> None:
s = "(0,SET,Rz_material,#CURRENT#) (1,RESTORE,Rz_material,#CURRENT#)"
rules = _parse_dependencies(s, self._path, 1)
assert len(rules) == 2
assert rules[0].trigger == "0"
assert rules[0].actions[0].action == "SET"
assert rules[1].trigger == "1"
assert rules[1].actions[0].action == "RESTORE"
def test_set_with_literal_value(self) -> None:
s = "(1,SET,Void_width_dv,0.0m,SET,Number_of_voids,0)"
rules = _parse_dependencies(s, self._path, 1)
assert len(rules) == 1
assert rules[0].actions[0].target == "0.0m"
assert rules[0].actions[1].target == "0"
def test_no_groups_raises(self) -> None:
with pytest.raises(ParseError):
_parse_dependencies("no parentheses here", self._path, 5)
def test_literal_integer_trigger(self) -> None:
s = "(1,RESTORE,Gap_length,#CURRENT#)"
rules = _parse_dependencies(s, self._path, 1)
assert rules[0].trigger == "1"
def test_fix_xyz_multi_action(self) -> None:
s = "(Fix_XYZ,RESTORE,X-Translation,1,RESTORE,Y-Translation,1,RESTORE,Z-Translation,1)"
rules = _parse_dependencies(s, self._path, 1)
assert len(rules) == 1
assert rules[0].trigger == "Fix_XYZ"
assert len(rules[0].actions) == 3
for act in rules[0].actions:
assert act.target == "1"
# ---------------------------------------------------------------------------
# Snapshot test — minimal fixture
# ---------------------------------------------------------------------------
class TestSnapshotMinimal:
def test_minimal_mat_matches_golden(self) -> None:
books = parse_mat(MINIMAL_MAT)
cat = CatalogSpec(mat_books=books)
actual = json.loads(cat.model_dump_json())
expected = json.loads(MINIMAL_EXPECTED.read_text(encoding="utf-8"))
assert actual == expected
def test_minimal_has_one_book(self) -> None:
books = parse_mat(MINIMAL_MAT)
assert len(books) == 1
assert books[0].name == "Test_Book"
def test_minimal_entry_fields(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
assert entry.name == "TestMaterial"
assert entry.comment.startswith("A minimal test material")
field_names = [f.name for f in entry.fields]
assert "Elastic_modulus_E" in field_names
assert "Stiffness_K" in field_names
assert "Gap_size" in field_names
assert "Combo_sub" in field_names
def test_formulation_two_deps(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
form = next(f for f in entry.fields if f.name == "Formulation")
assert len(form.dependencies) == 2
triggers = {d.trigger for d in form.dependencies}
assert "Stress-Strain" in triggers
assert "Force-Deformation" in triggers
def test_gap_size_width_hint(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
gap = next(f for f in entry.fields if f.name == "Gap_size")
assert gap.default == "0.0"
assert gap.width_hint == 10
def test_hidden_button_field(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
hidden = [f for f in entry.fields if f.state == "HIDDEN"]
assert any(f.name == "_" and "TK_MaterialTester" in f.tkwidgets for f in hidden)
def test_tkwidget_on_first_field(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
mat_field = entry.fields[0]
assert mat_field.name == "Material"
assert "TK_UpdateInfoBar" in mat_field.tkwidgets
assert mat_field.image == "img/test.png"
def test_tuple_field(self) -> None:
books = parse_mat(MINIMAL_MAT)
entry = books[0].entries[0]
combo = next(f for f in entry.fields if f.name == "Combo_sub")
assert combo.widget_type == "TUPLE"
assert combo.options == ["Val_A", "Val_B", "Val_C"]
assert combo.default == "#N# 3 1.0 2.0 3.0"
# ---------------------------------------------------------------------------
# Integration tests — real gidopensees files
# ---------------------------------------------------------------------------
@real_files
class TestRealFiles:
def test_mat_book_count(self, catalog: CatalogSpec) -> None:
assert len(catalog.mat_books) == 14
def test_cnd_book_count(self, catalog: CatalogSpec) -> None:
assert len(catalog.cnd_books) == 6
def test_mat_material_count(self, catalog: CatalogSpec) -> None:
total = sum(len(b.entries) for b in catalog.mat_books)
assert total == 58
def test_cnd_condition_count(self, catalog: CatalogSpec) -> None:
total = sum(len(b.entries) for b in catalog.cnd_books)
assert total >= 39
def test_steel02_field_names(self, catalog: CatalogSpec) -> None:
steel2 = _find_entry(catalog, "Steel02")
assert steel2 is not None
fnames = {f.name for f in steel2.fields}
required = {
"Steel_grade",
"Yield_Stress_Fy",
"Strain-hardening_ratio_b",
"Parameter_R0",
"Parameter_cR1",
"Parameter_cR2",
}
assert required.issubset(fnames), f"Missing: {required - fnames}"
def test_steel02_formulation_three_deps(self, catalog: CatalogSpec) -> None:
steel2 = _find_entry(catalog, "Steel02")
assert steel2 is not None
form = next(f for f in steel2.fields if f.name == "Formulation")
assert (
len(form.dependencies) == 3
), f"Expected 3 dependency rules on Formulation, got {len(form.dependencies)}"
def test_steel02_formulation_dep_triggers(self, catalog: CatalogSpec) -> None:
steel2 = _find_entry(catalog, "Steel02")
assert steel2 is not None
form = next(f for f in steel2.fields if f.name == "Formulation")
triggers = {d.trigger for d in form.dependencies}
assert triggers == {"Stress-Strain", "Force-Deformation", "Moment-Rotation"}
def test_concrete04_exists(self, catalog: CatalogSpec) -> None:
entry = _find_entry(catalog, "Concrete04_(Popovics_concrete)")
assert entry is not None
assert entry.book == "Uniaxial_Concrete_Materials"
def test_all_mat_entries_have_book(self, catalog: CatalogSpec) -> None:
for book in catalog.mat_books:
for entry in book.entries:
assert (
entry.book == book.name
), f"{entry.name!r} has book={entry.book!r}, expected {book.name!r}"
def test_all_fields_have_valid_widget_type(self, catalog: CatalogSpec) -> None:
valid = {"CB", "UNITS", "MAT", "SCALAR", "TUPLE"}
for book in catalog.mat_books:
for entry in book.entries:
for f in entry.fields:
assert (
f.widget_type in valid
), f"{entry.name}.{f.name}: unknown widget_type={f.widget_type!r}"
def test_point_restraints_condtype(self, catalog: CatalogSpec) -> None:
entry = _find_cnd_entry(catalog, "Point_Restraints")
assert entry is not None
assert "point" in entry.condtype.lower()
assert "node" in entry.condmeshtype.lower()
def test_zerolength_mat_fields(self, catalog: CatalogSpec) -> None:
entry = _find_cnd_entry(catalog, "Point_ZeroLength")
assert entry is not None
mat_fields = [f for f in entry.fields if f.widget_type == "MAT"]
assert len(mat_fields) >= 6 # Ux,Uy,Uz,Rx,Ry,Rz materials
def test_no_empty_field_names(self, catalog: CatalogSpec) -> None:
for book in catalog.mat_books + catalog.cnd_books:
for entry in book.entries:
for f in entry.fields:
assert f.name, f"{entry.name} has a field with empty name"
def test_dependencies_actions_are_valid(self, catalog: CatalogSpec) -> None:
valid_actions = {"RESTORE", "HIDE", "SET"}
for book in catalog.mat_books + catalog.cnd_books:
for entry in book.entries:
for fspec in entry.fields:
for rule in fspec.dependencies:
for act in rule.actions:
assert (
act.action in valid_actions
), f"{entry.name}.{fspec.name}: invalid action {act.action!r}"
# ---------------------------------------------------------------------------
# Fixtures (pytest)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def catalog() -> CatalogSpec:
return build_catalog(REAL_MAT, REAL_CND)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _find_entry(catalog: CatalogSpec, name: str) -> object | None:
for book in catalog.mat_books:
for entry in book.entries:
if entry.name == name:
return entry
return None
def _find_cnd_entry(catalog: CatalogSpec, name: str) -> object | None:
for book in catalog.cnd_books:
for entry in book.entries:
if entry.name == name:
return entry
return None