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
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).
274 lines
9.1 KiB
Python
274 lines
9.1 KiB
Python
"""Unit tests for the SAP2000-style grid system."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from otko.core import (
|
|
CoordinateGridSystem,
|
|
CoordinateSystem,
|
|
GridSystem,
|
|
Node,
|
|
Project,
|
|
default_global_system,
|
|
)
|
|
from otko.services import load_project, save_project
|
|
|
|
|
|
def test_empty_grid_default() -> None:
|
|
g = GridSystem()
|
|
assert g.x_lines == [] and g.y_lines == [] and g.z_lines == []
|
|
assert g.visible is True
|
|
|
|
|
|
def test_grid_sorts_and_dedupes() -> None:
|
|
g = GridSystem(x_lines=[3.0, 1.0, 2.0, 1.0 + 1e-12, 2.0])
|
|
assert g.x_lines == [1.0, 2.0, 3.0]
|
|
|
|
|
|
def test_grid_bounds() -> None:
|
|
g = GridSystem(x_lines=[0, 4, 8], y_lines=[-1, 1], z_lines=[])
|
|
(xmin, xmax), (ymin, ymax), (zmin, zmax) = g.bounds()
|
|
assert (xmin, xmax) == (0, 8)
|
|
assert (ymin, ymax) == (-1, 1)
|
|
assert (zmin, zmax) == (0, 0)
|
|
|
|
|
|
def test_project_default_has_empty_grid() -> None:
|
|
p = Project()
|
|
assert isinstance(p.grid_system, GridSystem)
|
|
assert p.grid_system.x_lines == []
|
|
|
|
|
|
def test_grid_round_trips(tmp_path: Path) -> None:
|
|
p = Project(
|
|
nodes=[Node(id=1, coords=(0, 0, 0))],
|
|
grid_system=GridSystem(
|
|
x_lines=[0.0, 3.0, 6.0, 9.0],
|
|
y_lines=[0.0, 4.0, 8.0],
|
|
z_lines=[0.0, 3.0],
|
|
visible=False,
|
|
),
|
|
)
|
|
path = tmp_path / "with_grid.osmodel"
|
|
save_project(p, path)
|
|
r = load_project(path)
|
|
assert r.grid_system.x_lines == [0.0, 3.0, 6.0, 9.0]
|
|
assert r.grid_system.y_lines == [0.0, 4.0, 8.0]
|
|
assert r.grid_system.z_lines == [0.0, 3.0]
|
|
assert r.grid_system.visible is False
|
|
|
|
|
|
def test_set_grid_system_command_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
"""SetGridSystemCommand must preserve the previous grid for undo."""
|
|
from otko.commands import SetGridSystemCommand
|
|
from otko.viewmodels import ProjectViewModel
|
|
|
|
vm = ProjectViewModel()
|
|
vm.new_project()
|
|
assert vm.project is not None
|
|
# Start from an empty grid, set a new one, undo, redo.
|
|
new_grid = GridSystem(x_lines=[0.0, 2.0, 4.0])
|
|
vm.apply_command(SetGridSystemCommand(vm, new_grid))
|
|
assert vm.project.grid_system.x_lines == [0.0, 2.0, 4.0]
|
|
vm.undo_stack.undo()
|
|
assert vm.project.grid_system.x_lines == []
|
|
vm.undo_stack.redo()
|
|
assert vm.project.grid_system.x_lines == [0.0, 2.0, 4.0]
|
|
|
|
|
|
def test_dialog_parse_spacings_formats() -> None:
|
|
"""Dialog parsers accept all three accepted forms."""
|
|
from otko.views.dialogs.grid_system import (
|
|
_coords_from_spacings,
|
|
_parse_spacings,
|
|
)
|
|
|
|
# Blank → no lines.
|
|
assert _parse_spacings("") == []
|
|
# Single integer → N-1 unit spacings.
|
|
assert _parse_spacings("4") == [1.0, 1.0, 1.0]
|
|
# n@d syntax.
|
|
assert _parse_spacings("3@2.5") == [2.5, 2.5, 2.5]
|
|
# Comma list.
|
|
assert _parse_spacings("1, 2, 3") == [1.0, 2.0, 3.0]
|
|
|
|
# Spacings → absolute coordinates.
|
|
assert _coords_from_spacings([2.5, 2.5, 2.5]) == [0.0, 2.5, 5.0, 7.5]
|
|
|
|
|
|
def test_add_node_dialog_snaps(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
"""AddNodeDialog snaps to nearest grid line when the flag is set."""
|
|
from otko.views.dialogs.add_node import AddNodeDialog
|
|
|
|
grid = GridSystem(x_lines=[0.0, 3.0, 6.0], y_lines=[0.0, 4.0], z_lines=[0.0])
|
|
dlg = AddNodeDialog(next_node_id=1, grid=grid, ndm=3)
|
|
qtbot.addWidget(dlg)
|
|
dlg._x.setValue(3.4) # → should snap to 3.0
|
|
dlg._y.setValue(3.9) # → should snap to 4.0
|
|
dlg._z.setValue(-0.3) # → should snap to 0.0
|
|
dlg._snap_cb.setChecked(True)
|
|
node = dlg.node()
|
|
assert node.coords == (3.0, 4.0, 0.0)
|
|
|
|
|
|
def test_add_node_dialog_no_snap(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
"""Without the snap flag, AddNodeDialog preserves entered coordinates."""
|
|
from otko.views.dialogs.add_node import AddNodeDialog
|
|
|
|
grid = GridSystem(x_lines=[0.0, 3.0], y_lines=[0.0])
|
|
dlg = AddNodeDialog(next_node_id=1, grid=grid, ndm=3)
|
|
qtbot.addWidget(dlg)
|
|
dlg._x.setValue(1.7)
|
|
dlg._y.setValue(0.2)
|
|
dlg._z.setValue(5.5)
|
|
dlg._snap_cb.setChecked(False)
|
|
node = dlg.node()
|
|
assert node.coords == pytest.approx((1.7, 0.2, 5.5))
|
|
|
|
|
|
# ══════════════════════════ SAP2000-style coord systems ══════════════════
|
|
def test_default_project_has_global_system() -> None:
|
|
p = Project()
|
|
assert len(p.coord_systems) == 1
|
|
assert p.coord_systems[0].name == "Global"
|
|
assert p.coord_systems[0].is_global() is True
|
|
|
|
|
|
def test_global_is_auto_inserted_if_missing() -> None:
|
|
# Construct a project whose only coord_system is named 'Floor2' —
|
|
# the model validator must prepend a Global entry.
|
|
p = Project(
|
|
coord_systems=[
|
|
CoordinateGridSystem(
|
|
name="Floor2",
|
|
coord=CoordinateSystem(origin=(0, 0, 3)),
|
|
),
|
|
],
|
|
)
|
|
names = [cs.name for cs in p.coord_systems]
|
|
assert names[0] == "Global"
|
|
assert "Floor2" in names
|
|
|
|
|
|
def test_coord_system_rotation_matrix_identity() -> None:
|
|
cs = CoordinateSystem()
|
|
m = cs.rotation_matrix()
|
|
assert m[0] == [1.0, 0.0, 0.0]
|
|
assert m[1] == [0.0, 1.0, 0.0]
|
|
assert m[2] == [0.0, 0.0, 1.0]
|
|
|
|
|
|
def test_coord_system_z_rotation() -> None:
|
|
cs = CoordinateSystem(rotation_deg=(0, 0, 90))
|
|
wx = cs.local_to_world((1.0, 0.0, 0.0))
|
|
assert wx[0] == pytest.approx(0.0, abs=1e-9)
|
|
assert wx[1] == pytest.approx(1.0, abs=1e-9)
|
|
|
|
|
|
def test_coord_system_round_trip_world_local() -> None:
|
|
cs = CoordinateSystem(origin=(2, 3, 5), rotation_deg=(10, 20, 30))
|
|
p_local = (1.5, -0.5, 2.0)
|
|
p_world = cs.local_to_world(p_local)
|
|
p_back = cs.world_to_local(p_world)
|
|
for a, b in zip(p_local, p_back, strict=False):
|
|
assert a == pytest.approx(b, abs=1e-9)
|
|
|
|
|
|
def test_legacy_grid_system_field_migrates() -> None:
|
|
"""An .osmodel written with the old schema must still load correctly."""
|
|
raw = {
|
|
"nodes": [],
|
|
"materials": [],
|
|
"sections": [],
|
|
"elements": [],
|
|
"time_series": [],
|
|
"load_patterns": [],
|
|
"spectra": [],
|
|
"analyses": [],
|
|
"grid_system": {
|
|
"x_lines": [0.0, 3.0, 6.0],
|
|
"y_lines": [0.0, 4.0],
|
|
"z_lines": [],
|
|
"visible": True,
|
|
},
|
|
}
|
|
p = Project.model_validate(raw)
|
|
assert p.coord_systems[0].name == "Global"
|
|
assert p.coord_systems[0].grid.x_lines == [0.0, 3.0, 6.0]
|
|
# The property proxy still works.
|
|
assert p.grid_system.x_lines == [0.0, 3.0, 6.0]
|
|
|
|
|
|
def test_multiple_coord_systems_round_trip(tmp_path: Path) -> None:
|
|
p = Project(
|
|
coord_systems=[
|
|
default_global_system(),
|
|
CoordinateGridSystem(
|
|
name="Floor2",
|
|
coord=CoordinateSystem(
|
|
origin=(0, 0, 3.5),
|
|
rotation_deg=(0, 0, 30),
|
|
),
|
|
grid=GridSystem(x_lines=[0.0, 6.0], y_lines=[0.0, 4.0]),
|
|
),
|
|
],
|
|
)
|
|
path = tmp_path / "multi.osmodel"
|
|
save_project(p, path)
|
|
r = load_project(path)
|
|
assert [cs.name for cs in r.coord_systems] == ["Global", "Floor2"]
|
|
floor2 = r.coord_systems[1]
|
|
assert floor2.coord.origin == (0.0, 0.0, 3.5)
|
|
assert floor2.coord.rotation_deg == (0.0, 0.0, 30.0)
|
|
assert floor2.grid.x_lines == [0.0, 6.0]
|
|
|
|
|
|
def test_snap_across_systems_picks_closest(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
"""A click near Floor2's intersection must snap there, not to Global."""
|
|
from otko.views.tools.draw_node import _snap_across_systems
|
|
|
|
systems = [
|
|
CoordinateGridSystem(
|
|
name="Global",
|
|
grid=GridSystem(x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0]),
|
|
),
|
|
CoordinateGridSystem(
|
|
name="Floor2",
|
|
coord=CoordinateSystem(origin=(0, 0, 3.0)),
|
|
grid=GridSystem(x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0]),
|
|
),
|
|
]
|
|
# Click near Floor2 grid intersection at world (3, 0, 3) — closer to Floor2.
|
|
result = _snap_across_systems((2.9, 0.1, 2.9), systems)
|
|
assert result == pytest.approx((3.0, 0.0, 3.0), abs=1e-9)
|
|
|
|
# Click near Global's (3, 0, 0) — should snap there.
|
|
result2 = _snap_across_systems((2.9, 0.1, 0.1), systems)
|
|
assert result2 == pytest.approx((3.0, 0.0, 0.0), abs=1e-9)
|
|
|
|
|
|
def test_set_coord_systems_command_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
"""SetCoordSystemsCommand atomically swaps the whole list (undoable)."""
|
|
from otko.commands import SetCoordSystemsCommand
|
|
from otko.viewmodels import ProjectViewModel
|
|
|
|
vm = ProjectViewModel()
|
|
vm.new_project()
|
|
assert vm.project is not None
|
|
new_list = [
|
|
default_global_system(),
|
|
CoordinateGridSystem(
|
|
name="Floor2",
|
|
coord=CoordinateSystem(origin=(0, 0, 3)),
|
|
grid=GridSystem(x_lines=[0, 6]),
|
|
),
|
|
]
|
|
vm.apply_command(SetCoordSystemsCommand(vm, new_list))
|
|
assert [cs.name for cs in vm.project.coord_systems] == ["Global", "Floor2"]
|
|
vm.undo_stack.undo()
|
|
assert [cs.name for cs in vm.project.coord_systems] == ["Global"]
|
|
vm.undo_stack.redo()
|
|
assert len(vm.project.coord_systems) == 2
|