otko/tests/unit/test_grid_system.py
smillmorel 61b850bf46
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
style: resolve the remaining ruff findings
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).
2026-09-16 13:20:36 -04:00

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