Some checks failed
CI / lint (pull_request) Has been cancelled
CI / test (macos-latest, 3.10) (pull_request) Has been cancelled
CI / test (macos-latest, 3.11) (pull_request) Has been cancelled
CI / test (macos-latest, 3.12) (pull_request) Has been cancelled
CI / test (ubuntu-latest, 3.10) (pull_request) Has been cancelled
CI / test (ubuntu-latest, 3.11) (pull_request) Has been cancelled
CI / test (ubuntu-latest, 3.12) (pull_request) Has been cancelled
CI / test (windows-latest, 3.10) (pull_request) Has been cancelled
CI / test (windows-latest, 3.11) (pull_request) Has been cancelled
CI / test (windows-latest, 3.12) (pull_request) Has been cancelled
240 lines
9.2 KiB
Python
240 lines
9.2 KiB
Python
"""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
|
|
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
|
|
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
|