feat: initial otko import
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
This commit is contained in:
commit
612936a00b
540 changed files with 174136 additions and 0 deletions
269
tests/unit/test_grid_system.py
Normal file
269
tests/unit/test_grid_system.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""Unit tests for the SAP2000-style grid system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
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):
|
||||
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
|
||||
Loading…
Reference in a new issue