From 7093164f751ed607783dde8da55bf7a60d9c499d Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 12:02:55 -0400 Subject: [PATCH] test: relocate service-layer tests to tests/services/ Mirrors the source layout (core/services split) so unit/ holds core-only tests and services get their own home. --- tests/services/__init__.py | 1 + .../{unit => services}/test_element_forces.py | 45 ++++++----- tests/{unit => services}/test_export.py | 0 .../test_imposed_support_motion.py | 0 tests/{unit => services}/test_peer_record.py | 0 tests/{unit => services}/test_persistence.py | 16 +++- .../test_runner_translation.py | 65 ++++++++++------ tests/{unit => services}/test_section_bbox.py | 75 +++++++++++++------ .../test_transient_and_export.py | 21 ++---- 9 files changed, 144 insertions(+), 79 deletions(-) create mode 100644 tests/services/__init__.py rename tests/{unit => services}/test_element_forces.py (90%) rename tests/{unit => services}/test_export.py (100%) rename tests/{unit => services}/test_imposed_support_motion.py (100%) rename tests/{unit => services}/test_peer_record.py (100%) rename tests/{unit => services}/test_persistence.py (90%) rename tests/{unit => services}/test_runner_translation.py (89%) rename tests/{unit => services}/test_section_bbox.py (66%) rename tests/{unit => services}/test_transient_and_export.py (80%) diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..c495081 --- /dev/null +++ b/tests/services/__init__.py @@ -0,0 +1 @@ +"""Service-layer tests: runner, persistence, export, results I/O.""" diff --git a/tests/unit/test_element_forces.py b/tests/services/test_element_forces.py similarity index 90% rename from tests/unit/test_element_forces.py rename to tests/services/test_element_forces.py index 3fc8dd8..ad03cda 100644 --- a/tests/unit/test_element_forces.py +++ b/tests/services/test_element_forces.py @@ -24,6 +24,7 @@ from otko.services.results import StaticResults # Fixtures: a tiny 3D project with 2 elements + canned force results. # ────────────────────────────────────────────────────────────────────── + @pytest.fixture def project_3d() -> Project: return Project( @@ -44,12 +45,12 @@ def project_3d() -> Project: def static_3d_results() -> StaticResults: """One step of analysis. Element 10 carries [N=100, …]; element 20 carries [N=-50, …] (compression). 12 components, single time step.""" - forces_10 = np.array([[100.0, 5.0, 7.0, 1.0, 8.0, 9.0, - -100.0, -5.0, -7.0, -1.0, -8.0, -9.0]]) - forces_20 = np.array([[-50.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 50.0, 0.0, 0.0, 0.0, 0.0, 0.0]]) + forces_10 = np.array([[100.0, 5.0, 7.0, 1.0, 8.0, 9.0, -100.0, -5.0, -7.0, -1.0, -8.0, -9.0]]) + forces_20 = np.array([[-50.0, 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0]]) return StaticResults( - case_id=1, case_name="tst", n_steps=1, + case_id=1, + case_name="tst", + n_steps=1, element_forces={10: forces_10, 20: forces_20}, ) @@ -58,8 +59,10 @@ def static_3d_results() -> StaticResults: # extract_diagram_data # ────────────────────────────────────────────────────────────────────── + def test_extract_axial_yields_correct_per_end_values( - project_3d: Project, static_3d_results: StaticResults, + project_3d: Project, + static_3d_results: StaticResults, ) -> None: data = extract_diagram_data(project_3d, static_3d_results, ForceComponent.N) assert data.component is ForceComponent.N @@ -86,22 +89,20 @@ def test_extract_handles_2d_force_vectors(project_3d: Project) -> None: """2D OpenSees beams return 6 components: [N, Vy, Mz] × 2 ends. Asking for V3 / M2 in this case should yield no rows for that element.""" forces_10 = np.array([[100.0, 5.0, 9.0, -100.0, -5.0, -9.0]]) - results = StaticResults(case_id=1, case_name="2d", n_steps=1, - element_forces={10: forces_10}) + results = StaticResults(case_id=1, case_name="2d", n_steps=1, element_forces={10: forces_10}) n_data = extract_diagram_data(project_3d, results, ForceComponent.N) np.testing.assert_array_equal(n_data.values_i, [100.0]) v3_data = extract_diagram_data(project_3d, results, ForceComponent.V3) - assert v3_data.element_ids.size == 0 # V3 not available in 2D output + assert v3_data.element_ids.size == 0 # V3 not available in 2D output def test_extract_uses_specified_step(project_3d: Project) -> None: """Multi-step pushover: pick the first step explicitly.""" f = np.zeros((3, 12)) - f[0, 0] = 10.0 # step 0 axial @ node i + f[0, 0] = 10.0 # step 0 axial @ node i f[1, 0] = 20.0 f[2, 0] = 30.0 - results = StaticResults(case_id=1, case_name="push", n_steps=3, - element_forces={10: f}) + results = StaticResults(case_id=1, case_name="push", n_steps=3, element_forces={10: f}) data = extract_diagram_data(project_3d, results, ForceComponent.N, step=0) np.testing.assert_array_equal(data.values_i, [10.0]) data = extract_diagram_data(project_3d, results, ForceComponent.N, step=2) @@ -112,8 +113,10 @@ def test_extract_uses_specified_step(project_3d: Project) -> None: # auto_scale # ────────────────────────────────────────────────────────────────────── + def test_auto_scale_scales_to_target_fraction( - project_3d: Project, static_3d_results: StaticResults, + project_3d: Project, + static_3d_results: StaticResults, ) -> None: data = extract_diagram_data(project_3d, static_3d_results, ForceComponent.N) # Bounding-box diagonal of the 3-node line is 6.0 along X. @@ -127,7 +130,9 @@ def test_auto_scale_zero_force_returns_unity(project_3d: Project) -> None: empty = DiagramData( component=ForceComponent.N, element_ids=np.empty(0, dtype=int), - values_i=np.empty(0), values_j=np.empty(0), abs_max=0.0, + values_i=np.empty(0), + values_j=np.empty(0), + abs_max=0.0, ) assert auto_scale(project_3d, empty) == 1.0 @@ -138,8 +143,10 @@ def test_auto_scale_zero_force_returns_unity(project_3d: Project) -> None: @pytest.fixture def truss_project_2d() -> Project: from otko.core import ElasticUniaxial, TrussElement + return Project( - ndm=2, ndf=2, + ndm=2, + ndf=2, nodes=[ Node(id=1, coords=(0.0, 0.0, 0.0)), Node(id=2, coords=(3.0, 0.0, 0.0)), @@ -163,7 +170,9 @@ def test_truss_axial_uses_correct_index_map(truss_project_2d: Project) -> None: # (equilibrium signs: end-i points out, end-j points in). forces = np.array([[-1500.0, 0.0, 1500.0, 0.0]]) results = StaticResults( - case_id=1, case_name="tst", n_steps=1, + case_id=1, + case_name="tst", + n_steps=1, element_forces={1: forces}, ) data = extract_diagram_data(truss_project_2d, results, ForceComponent.N) @@ -178,7 +187,9 @@ def test_truss_has_no_shear_or_moment_components(truss_project_2d: Project) -> N """Requesting V2 / V3 / M3 on a truss returns an empty diagram.""" forces = np.array([[100.0, 0.0, -100.0, 0.0]]) results = StaticResults( - case_id=1, case_name="tst", n_steps=1, + case_id=1, + case_name="tst", + n_steps=1, element_forces={1: forces}, ) for comp in (ForceComponent.V2, ForceComponent.V3, ForceComponent.M3): diff --git a/tests/unit/test_export.py b/tests/services/test_export.py similarity index 100% rename from tests/unit/test_export.py rename to tests/services/test_export.py diff --git a/tests/unit/test_imposed_support_motion.py b/tests/services/test_imposed_support_motion.py similarity index 100% rename from tests/unit/test_imposed_support_motion.py rename to tests/services/test_imposed_support_motion.py diff --git a/tests/unit/test_peer_record.py b/tests/services/test_peer_record.py similarity index 100% rename from tests/unit/test_peer_record.py rename to tests/services/test_peer_record.py diff --git a/tests/unit/test_persistence.py b/tests/services/test_persistence.py similarity index 90% rename from tests/unit/test_persistence.py rename to tests/services/test_persistence.py index 7416697..93319ff 100644 --- a/tests/unit/test_persistence.py +++ b/tests/services/test_persistence.py @@ -27,7 +27,8 @@ from otko.services import PROJECT_FILE_SUFFIX, load_project, save_project def _sample_project() -> Project: return Project( meta=ProjectMeta(name="Sample", author="Ozan", units=UnitSystem.SI_M_N), - ndm=3, ndf=6, + ndm=3, + ndf=6, nodes=[ Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=2, coords=(0, 0, 3.0)), @@ -35,8 +36,14 @@ def _sample_project() -> Project: materials=[ Steel01(id=1, Fy=420e6, E0=200e9, b=0.01), Concrete02( - id=2, fpc=-30e6, epsc0=-0.002, fpcu=-15e6, epsU=-0.005, - ft=3e6, Ets=2e9, **{"lambda": 0.1}, + id=2, + fpc=-30e6, + epsc0=-0.002, + fpcu=-15e6, + epsU=-0.005, + ft=3e6, + Ets=2e9, + **{"lambda": 0.1}, ), ], sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6)], @@ -47,7 +54,8 @@ def _sample_project() -> Project: time_series=[LinearTimeSeries(id=1, factor=1.0)], load_patterns=[ PlainLoadPattern( - id=1, time_series_id=1, + id=1, + time_series_id=1, nodal_loads=[NodalLoad(node_id=2, forces=(0, 0, -10e3, 0, 0, 0))], ) ], diff --git a/tests/unit/test_runner_translation.py b/tests/services/test_runner_translation.py similarity index 89% rename from tests/unit/test_runner_translation.py rename to tests/services/test_runner_translation.py index c4d4e15..1bbee24 100644 --- a/tests/unit/test_runner_translation.py +++ b/tests/services/test_runner_translation.py @@ -34,7 +34,8 @@ from otko.services.opensees_runner import OpenSeesRunner, _dof_indices # ───────────────────────── helpers ───────────────────────── def _truss_2d() -> Project: return Project( - ndm=2, ndf=2, + 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)), @@ -48,7 +49,8 @@ def _truss_2d() -> Project: time_series=[LinearTimeSeries(id=1)], load_patterns=[ PlainLoadPattern( - id=1, time_series_id=1, + id=1, + time_series_id=1, nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))], ) ], @@ -57,7 +59,8 @@ def _truss_2d() -> Project: def _portal_3d() -> Project: return Project( - ndm=3, ndf=6, + ndm=3, + ndf=6, nodes=[ Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), Node(id=2, coords=(0, 0, 3)), @@ -72,27 +75,35 @@ def _isolator_3d() -> Project: """A grounded HystereticSM isolator (zeroLength) under a -cMass frame — the wire-rope benchmark's building block in miniature.""" return Project( - ndm=3, ndf=6, + ndm=3, + ndf=6, nodes=[ Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6), # grounded - Node(id=2, coords=(0, 0, 0)), # coincident + Node(id=2, coords=(0, 0, 0)), # coincident Node(id=3, coords=(0, 0, 3)), ], materials=[ HystereticSM( - id=1, name="axial", + id=1, + name="axial", pos_env=[(1.57, 0.00207), (69.1, 0.0399)], neg_env=[(-1.4, -0.00057), (-15.31, -0.0483)], ), HystereticSM(id=2, name="shear", pos_env=[(0.12, 0.00067), (9.21, 0.0804)]), ], sections=[ - ElasticSection(id=1, E=2.1e8, A=9.13e-4, Iz=7.373e-7, Iy=7.373e-7, G=8.08e7, J=2.494e-8), + ElasticSection( + id=1, E=2.1e8, A=9.13e-4, Iz=7.373e-7, Iy=7.373e-7, G=8.08e7, J=2.494e-8 + ), ], elements=[ ZeroLengthElement(id=1, nodes=(1, 2), material_ids=(1, 2), dofs=(3, 1)), ElasticBeamColumn( - id=2, nodes=(2, 3), section_id=1, rho=0.00717, consistent_mass=True, + id=2, + nodes=(2, 3), + section_id=1, + rho=0.00717, + consistent_mass=True, ), ], ) @@ -100,7 +111,8 @@ def _isolator_3d() -> Project: def _shear_frame_2d() -> Project: return Project( - ndm=2, ndf=3, + ndm=2, + ndf=3, nodes=[ Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, True)), Node(id=2, coords=(4, 0, 0), restraint=(True, True, False, False, False, True)), @@ -210,9 +222,18 @@ class TestMaterialEmission: OpenSeesRunner(_isolator_3d(), ops_module=ops).build() # Pairs are flattened in (force, deformation) command order, both envelopes. ops.uniaxialMaterial.assert_any_call( - "HystereticSM", 1, - "-posEnv", 1.57, 0.00207, 69.1, 0.0399, - "-negEnv", -1.4, -0.00057, -15.31, -0.0483, + "HystereticSM", + 1, + "-posEnv", + 1.57, + 0.00207, + 69.1, + 0.0399, + "-negEnv", + -1.4, + -0.00057, + -15.31, + -0.0483, ) def test_hystereticsm_symmetric_omits_neg_envelope(self) -> None: @@ -220,7 +241,13 @@ class TestMaterialEmission: OpenSeesRunner(_isolator_3d(), ops_module=ops).build() # No neg_env ⇒ only -posEnv is emitted (OpenSees mirrors it). ops.uniaxialMaterial.assert_any_call( - "HystereticSM", 2, "-posEnv", 0.12, 0.00067, 9.21, 0.0804, + "HystereticSM", + 2, + "-posEnv", + 0.12, + 0.00067, + 9.21, + 0.0804, ) @@ -234,24 +261,18 @@ class TestElementEmission: ops = MagicMock() OpenSeesRunner(_portal_3d(), ops_module=ops).build() # geomTransf tag 1 was allocated for "Linear"; element should use it. - ops.element.assert_any_call( - "elasticBeamColumn", 1, 1, 2, 1, 1, "-mass", 0.0 - ) + ops.element.assert_any_call("elasticBeamColumn", 1, 1, 2, 1, 1, "-mass", 0.0) def test_elastic_beam_column_cmass_appends_flag(self) -> None: ops = MagicMock() OpenSeesRunner(_isolator_3d(), ops_module=ops).build() # cMass=True ⇒ the consistent-mass flag trails the -mass density. - ops.element.assert_any_call( - "elasticBeamColumn", 2, 2, 3, 1, 1, "-mass", 0.00717, "-cMass" - ) + ops.element.assert_any_call("elasticBeamColumn", 2, 2, 3, 1, 1, "-mass", 0.00717, "-cMass") def test_zero_length_emits_mat_and_dir_lists(self) -> None: ops = MagicMock() OpenSeesRunner(_isolator_3d(), ops_module=ops).build() - ops.element.assert_any_call( - "zeroLength", 1, 1, 2, "-mat", 1, 2, "-dir", 3, 1 - ) + ops.element.assert_any_call("zeroLength", 1, 1, 2, "-mat", 1, 2, "-dir", 3, 1) class TestPatternEmission: diff --git a/tests/unit/test_section_bbox.py b/tests/services/test_section_bbox.py similarity index 66% rename from tests/unit/test_section_bbox.py rename to tests/services/test_section_bbox.py index 68ee8cd..43ab0c0 100644 --- a/tests/unit/test_section_bbox.py +++ b/tests/services/test_section_bbox.py @@ -24,11 +24,17 @@ def test_elastic_section_back_solves_rectangle() -> None: # Known rectangle: b = 0.3, h = 0.5 → A = 0.15, Iz = 3.125e-3 b, h = 0.30, 0.50 A = b * h - Iz = b * h ** 3 / 12.0 - Iy = h * b ** 3 / 12.0 + Iz = b * h**3 / 12.0 + Iy = h * b**3 / 12.0 sec = ElasticSection( - id=1, name="R", - E=200e9, A=A, Iz=Iz, Iy=Iy, G=80e9, J=1e-6, + id=1, + name="R", + E=200e9, + A=A, + Iz=Iz, + Iy=Iy, + G=80e9, + J=1e-6, ) dims = bbox_for_section(sec) assert dims is not None @@ -41,10 +47,17 @@ def test_elastic_section_back_solves_rectangle() -> None: def test_fiber_section_bbox_from_rect_patch() -> None: sec = FiberSection( id=1, - patches=[RectangularPatch( - material_id=1, n_fib_y=4, n_fib_z=4, - y_i=-0.15, z_i=-0.25, y_j=0.15, z_j=0.25, - )], + patches=[ + RectangularPatch( + material_id=1, + n_fib_y=4, + n_fib_z=4, + y_i=-0.15, + z_i=-0.25, + y_j=0.15, + z_j=0.25, + ) + ], ) dims = bbox_for_section(sec) assert dims is not None @@ -56,11 +69,16 @@ def test_fiber_section_bbox_from_rect_patch() -> None: def test_fiber_section_bbox_includes_circ_patches() -> None: sec = FiberSection( id=1, - patches=[CircularPatch( - material_id=1, n_fib_circ=8, n_fib_rad=2, - y_center=0.0, z_center=0.0, - r_outer=0.2, - )], + patches=[ + CircularPatch( + material_id=1, + n_fib_circ=8, + n_fib_rad=2, + y_center=0.0, + z_center=0.0, + r_outer=0.2, + ) + ], ) dims = bbox_for_section(sec) assert dims is not None @@ -72,15 +90,28 @@ def test_fiber_section_bbox_grows_for_layers() -> None: """A bar layer that extends past the patches must widen the bbox.""" sec = FiberSection( id=1, - patches=[RectangularPatch( - material_id=1, n_fib_y=2, n_fib_z=2, - y_i=-0.10, z_i=-0.10, y_j=0.10, z_j=0.10, - )], - layers=[StraightLayer( - material_id=2, n_bars=3, bar_area=1e-4, - y_start=-0.15, z_start=0.12, - y_end=0.15, z_end=0.12, - )], + patches=[ + RectangularPatch( + material_id=1, + n_fib_y=2, + n_fib_z=2, + y_i=-0.10, + z_i=-0.10, + y_j=0.10, + z_j=0.10, + ) + ], + layers=[ + StraightLayer( + material_id=2, + n_bars=3, + bar_area=1e-4, + y_start=-0.15, + z_start=0.12, + y_end=0.15, + z_end=0.12, + ) + ], ) dims = bbox_for_section(sec) assert dims is not None diff --git a/tests/unit/test_transient_and_export.py b/tests/services/test_transient_and_export.py similarity index 80% rename from tests/unit/test_transient_and_export.py rename to tests/services/test_transient_and_export.py index 30985b1..31b9199 100644 --- a/tests/unit/test_transient_and_export.py +++ b/tests/services/test_transient_and_export.py @@ -22,20 +22,15 @@ def fake_transient_h5(tmp_path: Path) -> Path: f.create_dataset("time", data=np.linspace(0.0, 0.04, n_steps)) for nid in (1, 2): base = nid * 10.0 - f.create_dataset(f"nodes/{nid}/disp", - data=np.full((n_steps, ndf), base)) - f.create_dataset(f"nodes/{nid}/vel", - data=np.full((n_steps, ndf), base + 0.1)) - f.create_dataset(f"nodes/{nid}/accel", - data=np.full((n_steps, ndf), base + 0.2)) - f.create_dataset("elements/100/forces", - data=np.full((n_steps, 12), 5.0)) + f.create_dataset(f"nodes/{nid}/disp", data=np.full((n_steps, ndf), base)) + f.create_dataset(f"nodes/{nid}/vel", data=np.full((n_steps, ndf), base + 0.1)) + f.create_dataset(f"nodes/{nid}/accel", data=np.full((n_steps, ndf), base + 0.2)) + f.create_dataset("elements/100/forces", data=np.full((n_steps, 12), 5.0)) return h5_path def test_node_disp_vel_accel_history_round_trip(fake_transient_h5: Path) -> None: - r = TransientResults(case_id=1, case_name="t", - h5_path=fake_transient_h5, n_steps=5, dt=0.01) + r = TransientResults(case_id=1, case_name="t", h5_path=fake_transient_h5, n_steps=5, dt=0.01) np.testing.assert_array_equal(r.node_disp_history(1), np.full((5, 6), 10.0)) np.testing.assert_array_equal(r.node_vel_history(1), np.full((5, 6), 10.1)) np.testing.assert_array_equal(r.node_accel_history(1), np.full((5, 6), 10.2)) @@ -52,8 +47,7 @@ def test_missing_history_raises_keyerror(tmp_path: Path) -> None: f.create_dataset("time", data=np.array([0.0, 0.01])) f.create_dataset("nodes/1/disp", data=np.zeros((2, 6))) - r = TransientResults(case_id=1, case_name="x", - h5_path=h5_path, n_steps=2, dt=0.01) + r = TransientResults(case_id=1, case_name="x", h5_path=h5_path, n_steps=2, dt=0.01) # disp works: assert r.node_disp_history(1).shape == (2, 6) # vel/accel raise: @@ -107,8 +101,7 @@ def test_export_time_history_video_decimates(tmp_path: Path) -> None: out = tmp_path / "th.gif" # 100 steps with every=10 → 10 frames captured. - export_time_history_video(plotter, set_step, out, n_steps=100, - fps=4, every=10) + export_time_history_video(plotter, set_step, out, n_steps=100, fps=4, every=10) plotter.close() assert out.exists()