"""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