otko/tests/unit/test_export.py

240 lines
9.2 KiB
Python
Raw Normal View History

2026-09-08 02:12:15 -04:00
"""Unit tests for the script exporter (services.export).
The exporter drives OpenSeesRunner with a RecordingOps stand-in and
renders the captured calls as OpenSeesPy / Tcl. No solver, no Qt.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from otko import __version__
from otko.core import (
ElasticBeamColumn,
ElasticIsotropic,
ElasticSection,
LinearTimeSeries,
NodalLoad,
Node,
PathTimeSeries,
PlainLoadPattern,
Project,
StaticCase,
Steel01,
TrussElement,
)
from otko.services.export import (
PINNED_OPENSEESPY,
RecordingOps,
_render_opspy,
_render_tcl,
export_opspy,
export_tcl,
)
# ───────────────────────── fixtures ─────────────────────────
def _truss_2d() -> Project:
"""2D truss + Linear pattern + StaticCase #1."""
return Project(
ndm=2,
ndf=2,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)),
Node(id=2, coords=(4, 0, 0), restraint=(False, True, False, False, False, False)),
Node(id=3, coords=(2, 3, 0)),
],
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
elements=[
TrussElement(id=1, nodes=(1, 3), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
],
time_series=[LinearTimeSeries(id=1)],
load_patterns=[
PlainLoadPattern(
id=1,
time_series_id=1,
nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
)
],
analyses=[StaticCase(id=1, name="push", pattern_ids=[1])],
)
def _frame_3d() -> Project:
"""3D cantilever column (exercises geomTransf tags)."""
return Project(
ndm=3,
ndf=6,
nodes=[
Node(
id=1,
coords=(0, 0, 0),
restraint=(True, True, True, True, True, True),
),
Node(id=2, coords=(0, 0, 3)),
],
materials=[ElasticIsotropic(id=1, E=200e9, nu=0.3)],
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-4, Iy=1e-4, G=80e9, J=1e-6)],
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
)
def _path_series_project() -> Project:
"""Single truss + PathTimeSeries(dt) + StaticCase."""
return Project(
ndm=2,
ndf=2,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)),
Node(id=2, coords=(1, 0, 0)),
],
materials=[Steel01(id=1, Fy=250e6, E0=200e9, b=0.01)],
elements=[TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1)],
time_series=[PathTimeSeries(id=7, dt=0.02, values=[0.0, 1.0, 0.5], factor=2.0)],
load_patterns=[
PlainLoadPattern(
id=1,
time_series_id=7,
nodal_loads=[NodalLoad(node_id=2, forces=(100, 0, 0, 0, 0, 0))],
)
],
analyses=[StaticCase(id=1, name="transient-prep", pattern_ids=[1])],
)
# ───────────────────────── header ─────────────────────────
class TestHeader:
def test_opspy_header_pins_solver_version_and_units(self) -> None:
script = export_opspy(_truss_2d())
assert PINNED_OPENSEESPY == "openseespy==3.8.0.0"
assert "openseespy==3.8.0.0" in script
2026-09-08 02:12:15 -04:00
assert __version__ in script
assert _truss_2d().meta.units.value in script
def test_tcl_header_pins_solver_version_and_units(self) -> None:
script = export_tcl(_truss_2d())
assert "openseespy==3.8.0.0" in script
2026-09-08 02:12:15 -04:00
assert __version__ in script
# ───────────────────────── model-only ─────────────────────────
class TestModelOnly:
def test_opspy_model_only_commands(self) -> None:
script = export_opspy(_truss_2d())
assert "import openseespy.opensees as ops" in script
assert "ops.wipe()" in script
assert script.index("ops.wipe()") < script.index("ops.model(")
assert 'ops.model("basic", "-ndm", 2, "-ndf", 2)' in script
assert "ops.node(1, 0.0, 0.0)" in script
assert "ops.fix(1, 1, 1)" in script
assert 'ops.uniaxialMaterial("Steel01", 1,' in script
assert 'ops.element("truss", 1,' in script
# Model-only scope: no patterns, no analysis.
assert "ops.pattern(" not in script
assert "ops.analyze(" not in script
assert "model only" in script
def test_tcl_model_only_commands(self) -> None:
script = export_tcl(_truss_2d())
lines = script.splitlines()
assert "wipe" in lines
assert "model basic -ndm 2 -ndf 2" in lines
assert "node 1 0.0 0.0" in lines
assert "fix 1 1 1" in lines
assert any(line.startswith("uniaxialMaterial Steel01 1 ") for line in lines)
assert any(line.startswith("element truss 1 ") for line in lines)
assert not any(line.startswith("pattern ") for line in lines)
def test_no_query_commands_leak(self) -> None:
script = export_opspy(_truss_2d(), case_id=1)
for banned in ("nodeDisp", "nodeReaction", "eleResponse", "eleForce", "reactions"):
assert banned not in script
# ───────────────────────── with case ─────────────────────────
class TestWithCase:
def test_opspy_static_case_appends_analysis(self) -> None:
script = export_opspy(_truss_2d(), case_id=1)
assert 'ops.timeSeries("Linear", 1, "-factor", 1.0)' in script
assert 'ops.pattern("Plain", 1, 1)' in script
assert "ops.load(3, 0.0, -1000.0)" in script
assert "ops.system(" in script
assert "ops.analyze(1)" in script
assert "Static case #1" in script
def test_tcl_static_case_appends_analysis(self) -> None:
script = export_tcl(_truss_2d(), case_id=1)
assert "timeSeries Linear 1 -factor 1.0" in script
assert "pattern Plain 1 1" in script
assert "load 3 0.0 -1000.0" in script
assert "analyze 1" in script
def test_unknown_case_id_raises(self) -> None:
with pytest.raises(ValueError, match="999"):
export_opspy(_truss_2d(), case_id=999)
with pytest.raises(ValueError, match="999"):
export_tcl(_truss_2d(), case_id=999)
# ───────────────────────── gotchas ─────────────────────────
class TestGotchas:
def test_path_timeseries_dt_preserved(self) -> None:
py = export_opspy(_path_series_project(), case_id=1)
tcl = export_tcl(_path_series_project(), case_id=1)
assert '"Path", 7, "-dt", 0.02' in py
assert "timeSeries Path 7 -dt 0.02" in tcl
def test_geomtransf_tags_preserved(self) -> None:
py = export_opspy(_frame_3d())
tcl = export_tcl(_frame_3d())
assert 'ops.geomTransf("Linear", 1, 1.0, 0.0, 0.0)' in py
assert 'ops.element("elasticBeamColumn", 1, 1, 2, 1, 1,' in py
assert "geomTransf Linear 1 1.0 0.0 0.0" in tcl
assert "element elasticBeamColumn 1 1 2 1 1" in tcl
def test_fix_uses_dof_subset(self) -> None:
"""2D-frame (ndf=3) fix emits exactly (Ux, Uy, Rz)."""
proj = Project(
ndm=2,
ndf=3,
nodes=[
Node(
id=1,
coords=(0, 0, 0),
restraint=(True, True, False, False, False, True),
),
Node(id=2, coords=(0, 3, 0)),
],
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-4)],
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1)],
)
assert "ops.fix(1, 1, 1, 1)" in export_opspy(proj)
assert "fix 1 1 1 1" in export_tcl(proj).splitlines()
# ───────────────────────── unsupported ─────────────────────────
class TestUnsupported:
def test_unknown_op_raises_with_marker(self) -> None:
rec = RecordingOps()
rec.someFutureCommand(1, 2)
with pytest.raises(NotImplementedError, match="UNSUPPORTED"):
_render_opspy(rec.calls, _truss_2d(), None)
with pytest.raises(NotImplementedError, match="UNSUPPORTED"):
_render_tcl(rec.calls, _truss_2d(), None)
def test_no_top_level_openseespy_import(self) -> None:
"""services.export and the touched views must not import openseespy."""
import re
pattern = re.compile(r"^\s*(import|from)\s+openseespy", re.MULTILINE)
root = Path(__file__).resolve().parents[2]
for rel in (
"src/otko/services/export.py",
"src/otko/views/menu_builder.py",
"src/otko/views/action_handlers.py",
):
text = (root / rel).read_text(encoding="utf-8")
assert pattern.search(text) is None, rel