chore: adopt remaining local development state

Catch-all for the intermixed residue of the unpushed otko-development
work ported into this tree: combinations/console-dock/quick-guide wiring
across commands, core, services, views and tests; repo-wide ruff-format
normalization; README/CONTRIBUTING updates; and the toolbar default
(both toolbars now open in the top area, quick guide text updated).

Splitting this further would require hunk-level surgery with low
confidence; the preceding commits in this branch isolate the
self-contained features.
This commit is contained in:
smillmorel 2026-09-16 12:03:22 -04:00
commit ba783718d4
152 changed files with 3394 additions and 1651 deletions

View file

@ -29,8 +29,9 @@ def _vm_with_pattern() -> ProjectViewModel:
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
vm.project.time_series.append(LinearTimeSeries(id=1))
vm.project.load_patterns.append(
PlainLoadPattern(id=1, time_series_id=1,
nodal_loads=[NodalLoad(node_id=1, forces=(100, 0, 0, 0, 0, 0))])
PlainLoadPattern(
id=1, time_series_id=1, nodal_loads=[NodalLoad(node_id=1, forces=(100, 0, 0, 0, 0, 0))]
)
)
return vm
@ -57,9 +58,14 @@ def test_add_modal_case_no_pattern_needed(qtbot) -> None: # type: ignore[no-unt
def test_add_emits_modelMutated(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern()
with qtbot.waitSignal(vm.modelMutated, timeout=500):
vm.apply_command(AddAnalysisCasesCommand(vm, [
StaticCase(id=1, pattern_ids=[1]),
]))
vm.apply_command(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, pattern_ids=[1]),
],
)
)
@pytest.mark.gui
@ -74,9 +80,14 @@ def test_add_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untyped-def]
@pytest.mark.gui
def test_update_static_changes_n_steps(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern()
vm.apply_command(AddAnalysisCasesCommand(vm, [
StaticCase(id=1, name="A", pattern_ids=[1], n_steps=1),
]))
vm.apply_command(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, name="A", pattern_ids=[1], n_steps=1),
],
)
)
new = StaticCase(id=1, name="A", pattern_ids=[1], n_steps=10)
vm.apply_command(UpdateAnalysisCaseCommand(vm, new))
assert vm.project.analyses[0].n_steps == 10
@ -87,9 +98,14 @@ def test_update_static_changes_n_steps(qtbot) -> None: # type: ignore[no-untype
@pytest.mark.gui
def test_update_changes_case_type(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern()
vm.apply_command(AddAnalysisCasesCommand(vm, [
StaticCase(id=1, pattern_ids=[1]),
]))
vm.apply_command(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, pattern_ids=[1]),
],
)
)
swapped = ModalCase(id=1, name="Mode swap", n_modes=5)
vm.apply_command(UpdateAnalysisCaseCommand(vm, swapped))
assert isinstance(vm.project.analyses[0], ModalCase)
@ -109,10 +125,15 @@ def test_update_unknown_id_raises(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui
def test_delete_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern()
vm.apply_command(AddAnalysisCasesCommand(vm, [
StaticCase(id=1, pattern_ids=[1]),
ModalCase(id=2),
]))
vm.apply_command(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, pattern_ids=[1]),
ModalCase(id=2),
],
)
)
vm.apply_command(DeleteAnalysisCasesCommand(vm, {1}))
assert {c.id for c in vm.project.analyses} == {2}
vm.undo_stack.undo()

View file

@ -35,21 +35,42 @@ def cantilever_vm() -> ProjectViewModel:
L = 5.0
vm = ProjectViewModel()
vm.new_project(ndm=2, ndf=3)
vm.apply_command(AddNodesCommand(vm, [
Node(id=1, coords=(0.0, 0.0, 0.0),
restraint=(True, True, False, False, False, True)),
Node(id=2, coords=(L, 0.0, 0.0)),
]))
vm.apply_command(AddSectionsCommand(vm, [
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6),
]))
vm.apply_command(AddElementsCommand(vm, [
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
]))
vm.apply_command(
AddNodesCommand(
vm,
[
Node(
id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)
),
Node(id=2, coords=(L, 0.0, 0.0)),
],
)
)
vm.apply_command(
AddSectionsCommand(
vm,
[
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6),
],
)
)
vm.apply_command(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
],
)
)
vm.apply_command(AddNodalLoadsCommand(vm, {2}, (0.0, -1000.0, 0.0, 0.0, 0.0, 0.0)))
vm.apply_command(AddAnalysisCasesCommand(vm, [
StaticCase(id=1, name="Cantilever", pattern_ids=[1]),
]))
vm.apply_command(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, name="Cantilever", pattern_ids=[1]),
],
)
)
return vm

View file

@ -48,10 +48,11 @@ def test_dialog_lists_existing_patterns(qtbot) -> None: # type: ignore[no-untyp
@pytest.mark.gui
def test_dialog_enables_name_field_for_new_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
dlg = AssignLoadDialog(
n_selected=1, existing_patterns=[(1, "Gravity")],
n_selected=1,
existing_patterns=[(1, "Gravity")],
)
qtbot.addWidget(dlg)
dlg._pattern_cb.setCurrentIndex(1) # "<New pattern…>"
dlg._pattern_cb.setCurrentIndex(1) # "<New pattern…>"
assert dlg.selected_pattern_id() is None
assert dlg._new_name_edit.isEnabled()
dlg._new_name_edit.setText("RefMoment")
@ -68,14 +69,23 @@ def test_command_creates_named_pattern(qtbot) -> None: # type: ignore[no-untype
# Pre-existing 'Gravity' pattern — must NOT be reused when the user
# asks for a new one named 'RefMoment'.
vm.project.time_series.append(LinearTimeSeries(id=1, name="Gravity"))
vm.project.load_patterns.append(PlainLoadPattern(
id=1, name="Gravity", time_series_id=1,
))
vm.project.load_patterns.append(
PlainLoadPattern(
id=1,
name="Gravity",
time_series_id=1,
)
)
vm.apply_command(AddNodalLoadsCommand(
vm, {1}, (0, 0, 0, 0, 0, 1.0),
pattern_id=None, new_pattern_name="RefMoment",
))
vm.apply_command(
AddNodalLoadsCommand(
vm,
{1},
(0, 0, 0, 0, 0, 1.0),
pattern_id=None,
new_pattern_name="RefMoment",
)
)
# Two patterns now: Gravity (id=1) and RefMoment (id=2).
names = [p.name for p in vm.project.load_patterns]
assert "Gravity" in names and "RefMoment" in names
@ -90,13 +100,20 @@ def test_command_creates_constant_timeseries_when_requested(qtbot) -> None: # t
the new pattern — required for axial preloads in moment-curvature
runs where pseudoTime must NOT scale the axial force."""
from otko.core import ConstantTimeSeries
vm = ProjectViewModel()
vm.new_project(ndm=2, ndf=3)
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
vm.apply_command(AddNodalLoadsCommand(
vm, {1}, (-180, 0, 0, 0, 0, 0),
pattern_id=None, new_pattern_name="AxialP", new_ts_type="Constant",
))
vm.apply_command(
AddNodalLoadsCommand(
vm,
{1},
(-180, 0, 0, 0, 0, 0),
pattern_id=None,
new_pattern_name="AxialP",
new_ts_type="Constant",
)
)
# Pattern + TimeSeries both created with the "AxialP" name.
assert vm.project.load_patterns[0].name == "AxialP"
ts = vm.project.time_series[0]
@ -111,11 +128,20 @@ def test_command_reuses_existing_pattern_by_id(qtbot) -> None: # type: ignore[n
vm.new_project(ndm=2, ndf=3)
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
vm.project.time_series.append(LinearTimeSeries(id=1, name="Existing"))
vm.project.load_patterns.append(PlainLoadPattern(
id=1, name="Existing", time_series_id=1,
))
vm.apply_command(AddNodalLoadsCommand(
vm, {1}, (0, 0, 0, 0, 0, 5.0), pattern_id=1,
))
vm.project.load_patterns.append(
PlainLoadPattern(
id=1,
name="Existing",
time_series_id=1,
)
)
vm.apply_command(
AddNodalLoadsCommand(
vm,
{1},
(0, 0, 0, 0, 0, 5.0),
pattern_id=1,
)
)
assert len(vm.project.load_patterns) == 1
assert len(vm.project.load_patterns[0].nodal_loads) == 1

View file

@ -23,7 +23,9 @@ def test_mass_vector_reads_spinboxes(qtbot) -> None: # type: ignore[no-untyped-
dlg._mx.setValue(5000.0)
dlg._my.setValue(5000.0)
dlg._mz.setValue(100.0)
dlg._mxx.setValue(0.0); dlg._myy.setValue(0.0); dlg._mzz.setValue(0.0)
dlg._mxx.setValue(0.0)
dlg._myy.setValue(0.0)
dlg._mzz.setValue(0.0)
assert dlg.mass_vector() == (5000.0, 5000.0, 100.0, 0.0, 0.0, 0.0)

View file

@ -18,15 +18,24 @@ from otko.views.dialogs.assign_zls import ( # noqa: E402
def _project_with_section() -> Project:
return Project(
ndm=2, ndf=3,
ndm=2,
ndf=3,
nodes=[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(0, 0, 0)),
],
sections=[ElasticSection(
id=3, name="MK", E=30000, A=200, Iz=6667, Iy=6667,
G=12000, J=100,
)],
sections=[
ElasticSection(
id=3,
name="MK",
E=30000,
A=200,
Iz=6667,
Iy=6667,
G=12000,
J=100,
)
],
)
@ -43,7 +52,8 @@ def test_dialog_lists_project_sections(qtbot) -> None: # type: ignore[no-untype
@pytest.mark.gui
def test_dialog_disabled_when_no_sections(qtbot) -> None: # type: ignore[no-untyped-def]
p = Project(
ndm=2, ndf=3,
ndm=2,
ndf=3,
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(0, 0, 0))],
)
dlg = AssignZeroLengthSectionDialog(p, (1, 2))

View file

@ -23,7 +23,8 @@ from otko.core import ( # noqa: E402
# ────────────────────── logic helpers (no VTK) ──────────────────────
def _nearest_snap(
cx: float, cy: float,
cx: float,
cy: float,
world_pts: np.ndarray,
screen_pts: np.ndarray,
tol_px: float,
@ -33,7 +34,7 @@ def _nearest_snap(
return None
d2 = (screen_pts[:, 0] - cx) ** 2 + (screen_pts[:, 1] - cy) ** 2
idx = int(np.argmin(d2))
if d2[idx] <= tol_px ** 2:
if d2[idx] <= tol_px**2:
return tuple(float(v) for v in world_pts[idx]) # type: ignore[return-value]
return None
@ -65,6 +66,7 @@ def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None:
"""ModelCanvas._grid_intersections_world combines every visible system's
intersections (transformed by that system's origin/rotation)."""
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
@ -101,15 +103,17 @@ def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None:
def test_grid_intersections_world_returns_none_without_grid(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(Project()) # default: Global system with no grid lines
canvas.show_project(Project()) # default: Global system with no grid lines
assert canvas._grid_intersections_world() is None
def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-untyped-def]
"""A system with ``hide_all=True`` must not contribute snap targets."""
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
@ -134,20 +138,23 @@ def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-un
def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untyped-def]
"""set_hover_snap(pt) creates an actor; passing None removes it."""
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(Project(
coord_systems=[
CoordinateGridSystem(
name="Global",
grid=GridSystem(
x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
y_grid_lines=make_grid_lines("Y", [0.0, 4.0]),
z_grid_lines=make_grid_lines("Z", [0.0]),
canvas.show_project(
Project(
coord_systems=[
CoordinateGridSystem(
name="Global",
grid=GridSystem(
x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
y_grid_lines=make_grid_lines("Y", [0.0, 4.0]),
z_grid_lines=make_grid_lines("Z", [0.0]),
),
),
),
],
))
],
)
)
r = canvas._renderer
assert r._hover_actor is None
r.set_hover_snap((3.0, 0.0, 0.0))
@ -158,20 +165,23 @@ def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untype
def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(Project(
coord_systems=[
CoordinateGridSystem(
name="Global",
grid=GridSystem(
x_grid_lines=make_grid_lines("X", [0.0]),
y_grid_lines=make_grid_lines("Y", [0.0]),
z_grid_lines=make_grid_lines("Z", [0.0]),
canvas.show_project(
Project(
coord_systems=[
CoordinateGridSystem(
name="Global",
grid=GridSystem(
x_grid_lines=make_grid_lines("X", [0.0]),
y_grid_lines=make_grid_lines("Y", [0.0]),
z_grid_lines=make_grid_lines("Z", [0.0]),
),
),
),
],
))
],
)
)
canvas.set_snap_preview_enabled(True)
canvas._renderer.set_hover_snap((0.0, 0.0, 0.0))
assert canvas._renderer._hover_actor is not None
@ -183,7 +193,7 @@ def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-unty
def _pt_to_segment_d2(p: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Vectorised point-to-segment squared distance, used to verify logic."""
ab = b - a
ab_sq = (ab ** 2).sum(axis=1)
ab_sq = (ab**2).sum(axis=1)
ab_sq = np.where(ab_sq == 0, 1.0, ab_sq)
pa = p - a
t = (pa * ab).sum(axis=1) / ab_sq
@ -204,21 +214,21 @@ def test_point_to_segment_midpoint_hit() -> None:
def test_point_to_segment_endpoint_hit() -> None:
"""A click near an endpoint must also hit — the bug fix this test guards."""
p = np.array([1.0, 51.0]) # 1 px off node-a on a horizontal frame
p = np.array([1.0, 51.0]) # 1 px off node-a on a horizontal frame
a = np.array([[0.0, 50.0]])
b = np.array([[100.0, 50.0]])
d2 = _pt_to_segment_d2(p, a, b)
assert d2[0] <= 2.0 ** 2 # well inside a 5-pixel test tolerance
assert d2[0] <= 2.0**2 # well inside a 5-pixel test tolerance
def test_point_to_segment_orthogonal_miss() -> None:
"""Clicking far from a short frame must produce a large distance."""
p = np.array([50.0, 200.0])
a = np.array([[0.0, 50.0]])
b = np.array([[10.0, 50.0]]) # short frame at (0..10, 50)
b = np.array([[10.0, 50.0]]) # short frame at (0..10, 50)
d2 = _pt_to_segment_d2(p, a, b)
# Closest point is (10, 50), distance ≈ sqrt(40² + 150²) ≈ 155
assert d2[0] > 150.0 ** 2
assert d2[0] > 150.0**2
def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-untyped-def]
@ -226,6 +236,7 @@ def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-
grid bounds so the node remains visible (regression guard)."""
from otko.core import Node
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
p = Project(

View file

@ -77,17 +77,27 @@ def test_add_nodes_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untype
@pytest.mark.gui
def test_delete_node_cascades_to_elements(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm()
vm.apply_command(AddNodesCommand(vm, [
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 0, 0)),
Node(id=3, coords=(2, 0, 0)),
]))
vm.apply_command(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 0, 0)),
Node(id=3, coords=(2, 0, 0)),
],
)
)
# Add a material so the element is valid.
vm.project.materials.append(Steel01(id=1, Fy=420e6, E0=200e9, b=0.01))
vm.apply_command(AddElementsCommand(vm, [
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
]))
vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
],
)
)
# Delete node 2 → both elements should disappear.
vm.apply_command(DeleteNodesCommand(vm, {2}))
assert {n.id for n in vm.project.nodes} == {1, 3}
@ -102,10 +112,15 @@ def test_delete_node_cascades_to_elements(qtbot) -> None: # type: ignore[no-unt
@pytest.mark.gui
def test_set_restraint_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm()
vm.apply_command(AddNodesCommand(vm, [
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 0, 0)),
]))
vm.apply_command(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 0, 0)),
],
)
)
fix = (True, True, True, True, True, True)
vm.apply_command(SetRestraintCommand(vm, {1, 2}, fix))
assert vm.project.node(1).restraint == fix

View file

@ -26,32 +26,62 @@ from otko.viewmodels import ProjectViewModel # noqa: E402
def _vm_setup() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
vm.apply_command(AddNodesCommand(vm, [
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
]))
vm.apply_command(AddMaterialsCommand(vm, [
ElasticUniaxial(id=1, name="Steel", E=200e9),
]))
vm.apply_command(AddSectionsCommand(vm, [
ElasticSection(
id=1, name="Default", E=200e9, A=0.01,
Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6,
),
]))
vm.apply_command(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
],
)
)
vm.apply_command(
AddMaterialsCommand(
vm,
[
ElasticUniaxial(id=1, name="Steel", E=200e9),
],
)
)
vm.apply_command(
AddSectionsCommand(
vm,
[
ElasticSection(
id=1,
name="Default",
E=200e9,
A=0.01,
Iz=8.33e-6,
Iy=8.33e-6,
G=80e9,
J=1e-6,
),
],
)
)
return vm
@pytest.mark.gui
def test_frame_to_truss_conversion(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
]))
vm.apply_command(ConvertElementTypeCommand(
vm, {1}, "Truss",
defaults={"material_id": 1, "area": 0.001},
))
vm.apply_command(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
],
)
)
vm.apply_command(
ConvertElementTypeCommand(
vm,
{1},
"Truss",
defaults={"material_id": 1, "area": 0.001},
)
)
el = vm.project.element(1)
assert isinstance(el, TrussElement)
assert el.nodes == (1, 2)
@ -62,13 +92,22 @@ def test_frame_to_truss_conversion(qtbot) -> None: # type: ignore[no-untyped-de
@pytest.mark.gui
def test_truss_to_frame_conversion(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
]))
vm.apply_command(ConvertElementTypeCommand(
vm, {1}, "ElasticBeamColumn",
defaults={"section_id": 1},
))
vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
],
)
)
vm.apply_command(
ConvertElementTypeCommand(
vm,
{1},
"ElasticBeamColumn",
defaults={"section_id": 1},
)
)
el = vm.project.element(1)
assert isinstance(el, ElasticBeamColumn)
assert el.nodes == (1, 2)
@ -78,12 +117,22 @@ def test_truss_to_frame_conversion(qtbot) -> None: # type: ignore[no-untyped-de
@pytest.mark.gui
def test_convert_preserves_id_and_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [
ElasticBeamColumn(id=42, name="A", nodes=(1, 2), section_id=1),
]))
vm.apply_command(ConvertElementTypeCommand(
vm, {42}, "Truss", defaults={"material_id": 1, "area": 0.002},
))
vm.apply_command(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=42, name="A", nodes=(1, 2), section_id=1),
],
)
)
vm.apply_command(
ConvertElementTypeCommand(
vm,
{42},
"Truss",
defaults={"material_id": 1, "area": 0.002},
)
)
assert isinstance(vm.project.element(42), TrussElement)
assert vm.project.element(42).name == "A"
vm.undo_stack.undo()
@ -93,13 +142,23 @@ def test_convert_preserves_id_and_is_undoable(qtbot) -> None: # type: ignore[no
@pytest.mark.gui
def test_convert_same_type_is_noop(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
]))
vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
],
)
)
before = vm.project.element(1)
vm.apply_command(ConvertElementTypeCommand(
vm, {1}, "Truss", defaults={"material_id": 1, "area": 0.002},
))
vm.apply_command(
ConvertElementTypeCommand(
vm,
{1},
"Truss",
defaults={"material_id": 1, "area": 0.002},
)
)
# Class is still Truss and the element object is unchanged.
assert vm.project.element(1) is before
@ -107,10 +166,20 @@ def test_convert_same_type_is_noop(qtbot) -> None: # type: ignore[no-untyped-de
@pytest.mark.gui
def test_unknown_target_type_raises(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
]))
vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
],
)
)
with pytest.raises(ValueError):
vm.apply_command(ConvertElementTypeCommand(
vm, {1}, "NotAType", defaults={},
))
vm.apply_command(
ConvertElementTypeCommand(
vm,
{1},
"NotAType",
defaults={},
)
)

View file

@ -27,10 +27,12 @@ def test_linear_time_series_dialog_builds_entity(qtbot) -> None: # type: ignore
@pytest.mark.gui
def test_plain_pattern_dialog_uses_selected_time_series(qtbot) -> None: # type: ignore[no-untyped-def]
proj = Project(time_series=[
LinearTimeSeries(id=1, name="GravityTS"),
LinearTimeSeries(id=2, name="RampTS"),
])
proj = Project(
time_series=[
LinearTimeSeries(id=1, name="GravityTS"),
LinearTimeSeries(id=2, name="RampTS"),
]
)
dlg = PlainPatternDialog(project=proj, next_pattern_id=4)
qtbot.addWidget(dlg)

View file

@ -35,10 +35,15 @@ class _CanvasStub:
def _vm_with_two_nodes() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
vm.apply_command(AddNodesCommand(vm, [
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
]))
vm.apply_command(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
],
)
)
return vm
@ -86,7 +91,7 @@ def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
tool.activate()
tool.on_node_picked(1)
tool.on_node_picked(1) # same node — should NOT create an element
tool.on_node_picked(1) # same node — should NOT create an element
assert vm.project.elements == []
# First-node state preserved so user can finish the gesture.
assert tool._first_node_id == 1
@ -150,6 +155,7 @@ def test_reset_clears_first_pick(qtbot) -> None: # type: ignore[no-untyped-def]
def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-untyped-def]
"""Two clicks on empty grid intersections → 2 nodes + 1 frame."""
from otko.core import GridSystem
vm = ProjectViewModel()
vm.new_project()
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
@ -165,9 +171,9 @@ def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-u
tool.on_empty_clicked(0.0, 0.0, 0.0)
tool.on_empty_clicked(3.0, 4.0, 0.0)
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
elem = vm.project.elements[0] # type: ignore[union-attr]
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
elem = vm.project.elements[0] # type: ignore[union-attr]
assert isinstance(elem, ElasticBeamColumn)
n1 = next(n for n in vm.project.nodes if n.id == elem.nodes[0]) # type: ignore[union-attr]
n2 = next(n for n in vm.project.nodes if n.id == elem.nodes[1]) # type: ignore[union-attr]
@ -179,18 +185,21 @@ def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-u
def test_empty_click_reuses_coincident_node(qtbot) -> None: # type: ignore[no-untyped-def]
"""An empty click at an existing node's location must not duplicate it."""
from otko.core import GridSystem
vm = _vm_with_two_nodes() # nodes 1, 2 at (0,0,0) and (3,0,0)
vm = _vm_with_two_nodes() # nodes 1, 2 at (0,0,0) and (3,0,0)
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0],
x_lines=[0.0, 3.0],
y_lines=[0.0],
z_lines=[0.0],
)
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
tool.on_empty_clicked(0.0, 0.0, 0.0) # existing node 1 at (0,0,0)
tool.on_empty_clicked(3.0, 0.0, 0.0) # existing node 2 at (3,0,0)
tool.on_empty_clicked(0.0, 0.0, 0.0) # existing node 1 at (0,0,0)
tool.on_empty_clicked(3.0, 0.0, 0.0) # existing node 2 at (3,0,0)
assert len(vm.project.nodes) == 2 # type: ignore[union-attr] (no new nodes)
elem = vm.project.elements[0] # type: ignore[union-attr]
assert len(vm.project.nodes) == 2 # type: ignore[union-attr] (no new nodes)
elem = vm.project.elements[0] # type: ignore[union-attr]
assert set(elem.nodes) == {1, 2}
@ -198,19 +207,22 @@ def test_empty_click_reuses_coincident_node(qtbot) -> None: # type: ignore[no-u
def test_mixed_node_pick_then_empty_click(qtbot) -> None: # type: ignore[no-untyped-def]
"""First click picks existing node; second click creates new node + frame."""
from otko.core import GridSystem
vm = _vm_with_two_nodes()
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
x_lines=[0.0, 3.0, 6.0], y_lines=[0.0], z_lines=[0.0],
x_lines=[0.0, 3.0, 6.0],
y_lines=[0.0],
z_lines=[0.0],
)
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
tool.on_node_picked(1) # start at node 1 = (0,0,0)
tool.on_node_picked(1) # start at node 1 = (0,0,0)
tool.on_empty_clicked(6.0, 0.0, 0.0) # canvas emits exact snap
assert len(vm.project.nodes) == 3 # type: ignore[union-attr] (new node added)
assert len(vm.project.nodes) == 3 # type: ignore[union-attr] (new node added)
assert len(vm.project.elements) == 1
new_node = vm.project.nodes[-1] # type: ignore[union-attr]
new_node = vm.project.nodes[-1] # type: ignore[union-attr]
assert new_node.coords == (6.0, 0.0, 0.0)
elem = vm.project.elements[0]
assert set(elem.nodes) == {1, new_node.id}

View file

@ -46,8 +46,12 @@ def _vm_with_grid() -> ProjectViewModel:
# ────────────────────────── snap helper ─────────────────────────────
def test_snap_to_grid_picks_nearest_lines() -> None:
x, y, z = _snap_to_grid(
2.2, 3.7, 0.6,
x_lines=[0.0, 3.0], y_lines=[0.0, 4.0], z_lines=[0.0, 3.0],
2.2,
3.7,
0.6,
x_lines=[0.0, 3.0],
y_lines=[0.0, 4.0],
z_lines=[0.0, 3.0],
)
assert (x, y, z) == (3.0, 4.0, 0.0)
@ -99,10 +103,11 @@ def test_grid_less_canvas_never_emits_to_tool(qtbot) -> None: # type: ignore[no
target. For a grid-less project, the canvas never emits, so the
tool is never invoked. We therefore don't test rejection here.
"""
vm = ProjectViewModel(); vm.new_project()
vm = ProjectViewModel()
vm.new_project()
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
assert vm.project.nodes == [] # type: ignore[union-attr]
assert vm.project.nodes == [] # type: ignore[union-attr]
@pytest.mark.gui

View file

@ -34,10 +34,12 @@ class _CanvasStub:
def _vm_with_grid_and_nodes() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
vm.project.nodes.extend([
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
])
vm.project.nodes.extend(
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
]
)
vm.project.coord_systems = [
CoordinateGridSystem(
name="Global",
@ -60,13 +62,13 @@ def test_two_clicks_create_truss(qtbot) -> None: # type: ignore[no-untyped-def]
tool.on_node_picked(1)
tool.on_node_picked(2)
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
el = vm.project.elements[0] # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
el = vm.project.elements[0] # type: ignore[union-attr]
assert isinstance(el, TrussElement)
assert el.nodes == (1, 2)
assert el.area > 0
# A default ElasticUniaxial must have been created for the material.
assert len(vm.project.materials) == 1 # type: ignore[union-attr]
assert len(vm.project.materials) == 1 # type: ignore[union-attr]
assert isinstance(vm.project.materials[0], ElasticUniaxial)
@ -78,9 +80,9 @@ def test_draw_truss_reuses_existing_material(qtbot) -> None: # type: ignore[no-
tool.activate()
tool.on_node_picked(1)
tool.on_node_picked(2)
el = vm.project.elements[0] # type: ignore[union-attr]
el = vm.project.elements[0] # type: ignore[union-attr]
assert el.material_id == 1
assert len(vm.project.materials) == 1 # type: ignore[union-attr] (no new material)
assert len(vm.project.materials) == 1 # type: ignore[union-attr] (no new material)
@pytest.mark.gui
@ -104,8 +106,8 @@ def test_empty_click_creates_node_then_truss(qtbot) -> None: # type: ignore[no-
tool.on_empty_clicked(3.0, 0.0, 0.0)
# Two nodes created + one truss element.
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
assert isinstance(vm.project.elements[0], TrussElement)
@ -115,5 +117,5 @@ def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
tool = DrawTrussTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
tool.on_node_picked(1)
tool.on_node_picked(1) # same node — must not create a zero-length truss
assert vm.project.elements == [] # type: ignore[union-attr]
tool.on_node_picked(1) # same node — must not create a zero-length truss
assert vm.project.elements == [] # type: ignore[union-attr]

View file

@ -19,11 +19,18 @@ from otko.core import ( # noqa: E402
@pytest.mark.gui
def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
dlg = QuickGridLinesDialog()
qtbot.addWidget(dlg)
dlg._x_n.setValue(4); dlg._x_s.setValue(3.0); dlg._x_f.setValue(0.0)
dlg._y_n.setValue(2); dlg._y_s.setValue(4.0); dlg._y_f.setValue(0.0)
dlg._z_n.setValue(3); dlg._z_s.setValue(3.0); dlg._z_f.setValue(-3.0)
dlg._x_n.setValue(4)
dlg._x_s.setValue(3.0)
dlg._x_f.setValue(0.0)
dlg._y_n.setValue(2)
dlg._y_s.setValue(4.0)
dlg._y_f.setValue(0.0)
dlg._z_n.setValue(3)
dlg._z_s.setValue(3.0)
dlg._z_f.setValue(-3.0)
xs, ys, zs = dlg.ordinates()
assert xs == [0.0, 3.0, 6.0, 9.0]
assert ys == [0.0, 4.0]
@ -33,6 +40,7 @@ def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-
@pytest.mark.gui
def test_quick_grid_zero_lines_produces_empty(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
dlg = QuickGridLinesDialog()
qtbot.addWidget(dlg)
dlg._x_n.setValue(0)
@ -48,6 +56,7 @@ def test_locate_origin_round_trip(qtbot) -> None: # type: ignore[no-untyped-def
from otko.views.dialogs.locate_origin import (
CoordSystemLocationOrientationDialog,
)
dlg = CoordSystemLocationOrientationDialog(
origin=(1.5, -2.0, 3.0),
rotation_deg=(0.0, 0.0, 45.0),
@ -65,6 +74,7 @@ def test_define_grid_data_round_trip(qtbot) -> None: # type: ignore[no-untyped-
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
cs = CoordinateGridSystem(
name="Floor2",
coord=CoordinateSystem(origin=(0, 0, 3.5)),
@ -96,6 +106,7 @@ def test_define_grid_data_spacing_mode_conversion(qtbot) -> None: # type: ignor
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
cs = CoordinateGridSystem(
name="Test",
grid=GridSystem(x_grid_lines=make_grid_lines("X", [0.0, 3.0, 6.0, 10.0])),
@ -117,6 +128,7 @@ def test_define_grid_data_global_name_locked(qtbot) -> None: # type: ignore[no-
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
dlg = DefineGridSystemDataDialog(is_global=True)
qtbot.addWidget(dlg)
assert dlg._name_edit.text() == "Global"
@ -130,6 +142,7 @@ def test_define_grid_data_add_and_delete_row(qtbot) -> None: # type: ignore[no-
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
dlg = DefineGridSystemDataDialog()
qtbot.addWidget(dlg)
# Start with empty X table.

View file

@ -20,3 +20,22 @@ def test_main_window_opens(qtbot) -> None: # type: ignore[no-untyped-def]
window.show()
assert window.isVisible()
assert window.windowTitle() == "OTKO"
@pytest.mark.gui
def test_close_dirty_hidden_window_does_not_block(qtbot) -> None: # type: ignore[no-untyped-def]
"""Closing a dirty but hidden window must not raise a modal prompt.
Qt closes hidden widgets during teardown (as ``pytest-qt`` does); a
modal "unsaved changes" dialog there would block forever with no user
to answer it. The prompt is therefore gated on visibility.
"""
from otko.views.main_window import MainWindow
window = MainWindow()
qtbot.addWidget(window)
window._vm.new_project()
window._vm.mark_dirty()
assert not window.isVisible()
assert window.close() is True
assert window._vm.is_dirty # unchanged: the prompt was skipped

View file

@ -37,7 +37,7 @@ def test_concrete04_read_no_tension(qtbot) -> None: # type: ignore[no-untyped-d
form._epsc0.setValue(-0.002)
form._epscu.setValue(-0.005)
form._Ec.setValue(30e9)
form._fct.setValue(0.0) # 0 → no tension
form._fct.setValue(0.0) # 0 → no tension
form._et.setValue(0.0)
form._material_id = 1
@ -77,9 +77,14 @@ def test_concrete04_read_with_tension(qtbot) -> None: # type: ignore[no-untyped
def test_concrete04_populate_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
"""populate(mat) + read() reproduces the original model object."""
original = Concrete04(
id=3, name="C40-Tension",
fpc=-40e6, epsc0=-0.0022, epscu=-0.006, Ec=32e9,
fct=2.5e6, et=8e-5,
id=3,
name="C40-Tension",
fpc=-40e6,
epsc0=-0.0022,
epscu=-0.006,
Ec=32e9,
fct=2.5e6,
et=8e-5,
)
form = Concrete04Form()
@ -102,8 +107,8 @@ def test_concrete04_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untype
"""Form must not clamp US-customary (ksi) values for fpc."""
form = Concrete04Form()
qtbot.addWidget(form)
form._fpc.setValue(-6.0) # ksi
form._Ec.setValue(3600.0) # ksi
form._fpc.setValue(-6.0) # ksi
form._Ec.setValue(3600.0) # ksi
assert form._fpc.value() == pytest.approx(-6.0)
assert form._Ec.value() == pytest.approx(3600.0)

View file

@ -50,7 +50,7 @@ def test_concrete02_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untype
qtbot.addWidget(form)
form._fpc.setValue(-6.0)
form._fpcu.setValue(-5.0)
form._ft.setValue(0.6) # ksi tensile strength
form._ft.setValue(0.6) # ksi tensile strength
assert form._fpc.value() == pytest.approx(-6.0)
assert form._fpcu.value() == pytest.approx(-5.0)
assert form._ft.value() == pytest.approx(0.6)
@ -60,8 +60,8 @@ def test_concrete02_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untype
def test_steel01_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untyped-def]
form = Steel01Form()
qtbot.addWidget(form)
form._fy.setValue(60.0) # ksi
form._e0.setValue(30000.0) # ksi
form._fy.setValue(60.0) # ksi
form._e0.setValue(30000.0) # ksi
form._b.setValue(0.01)
assert form._fy.value() == pytest.approx(60.0)
assert form._e0.value() == pytest.approx(30000.0)
@ -83,6 +83,7 @@ def test_spinbox_uses_c_locale_for_decimal_separator(qtbot) -> None: # type: ig
the OS locale. On tr_TR / de_DE Windows the default QLocale expects
',' and silently rejects Tcl-style '-0.004' inputs."""
from PySide6.QtCore import QLocale
form = Concrete01Form()
qtbot.addWidget(form)
assert form._epsc0.locale().decimalPoint() == "."

View file

@ -28,9 +28,9 @@ def test_add_time_series_command_is_undoable(qtbot) -> None: # type: ignore[no-
vm.new_project()
ts = PathTimeSeries(id=1, name="GM", dt=0.01, factor=386.4, values=[0.1, 0.2])
vm.apply_command(AddTimeSeriesCommand(vm, ts))
assert len(vm.project.time_series) == 1 # type: ignore[union-attr]
assert len(vm.project.time_series) == 1 # type: ignore[union-attr]
vm.undo_stack.undo()
assert vm.project.time_series == [] # type: ignore[union-attr]
assert vm.project.time_series == [] # type: ignore[union-attr]
vm.undo_stack.redo()
assert vm.project.time_series[0].name == "GM" # type: ignore[union-attr]
@ -39,9 +39,14 @@ def test_add_time_series_command_is_undoable(qtbot) -> None: # type: ignore[no-
def test_add_time_series_rejects_duplicate_id(qtbot) -> None: # type: ignore[no-untyped-def]
vm = ProjectViewModel()
vm.new_project()
vm.project.time_series.append(PathTimeSeries( # type: ignore[union-attr]
id=1, name="Existing", dt=0.01, values=[0.0],
))
vm.project.time_series.append(
PathTimeSeries( # type: ignore[union-attr]
id=1,
name="Existing",
dt=0.01,
values=[0.0],
)
)
ts2 = PathTimeSeries(id=1, name="Duplicate", dt=0.01, values=[0.0])
with pytest.raises(ValueError):
vm.apply_command(AddTimeSeriesCommand(vm, ts2))
@ -51,22 +56,31 @@ def test_add_time_series_rejects_duplicate_id(qtbot) -> None: # type: ignore[no
def test_add_load_pattern_command_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
vm = ProjectViewModel()
vm.new_project()
vm.project.time_series.append(PathTimeSeries( # type: ignore[union-attr]
id=1, name="GM", dt=0.01, values=[0.1, 0.2],
))
vm.project.time_series.append(
PathTimeSeries( # type: ignore[union-attr]
id=1,
name="GM",
dt=0.01,
values=[0.1, 0.2],
)
)
pat = UniformExcitationPattern(
id=1, name="EQ", direction=1, accel_series_id=1,
id=1,
name="EQ",
direction=1,
accel_series_id=1,
)
vm.apply_command(AddLoadPatternCommand(vm, pat))
assert len(vm.project.load_patterns) == 1 # type: ignore[union-attr]
assert len(vm.project.load_patterns) == 1 # type: ignore[union-attr]
vm.undo_stack.undo()
assert vm.project.load_patterns == [] # type: ignore[union-attr]
assert vm.project.load_patterns == [] # type: ignore[union-attr]
# ─────────────── PathTimeSeriesDialog ──────────────
@pytest.mark.gui
def test_path_ts_dialog_manual_entry(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
dlg = PathTimeSeriesDialog(next_ts_id=1)
qtbot.addWidget(dlg)
# Simulate a plain-values import by directly seeding the values
@ -87,20 +101,24 @@ def test_path_ts_dialog_manual_entry(qtbot, tmp_path) -> None: # type: ignore[n
def test_path_ts_dialog_imports_peer(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def]
"""_on_import_peer populates dt / npts from the header + value list."""
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
rec = tmp_path / "test.at2"
rec.write_text(textwrap.dedent("""\
rec.write_text(
textwrap.dedent("""\
PEER PACIFIC
EL CENTRO 1940
ACCELERATION IN G
3 0.025 NPTS, DT
0.01 -0.02 0.03
"""))
""")
)
dlg = PathTimeSeriesDialog(next_ts_id=1)
qtbot.addWidget(dlg)
# Feed the file path directly through the parser + state-setter
# logic. Simulating QFileDialog in a headless test is fragile;
# calling _on_import_peer's internals is the stable path.
from otko.services.peer_record import parse_peer_record
dt, npts, vals = parse_peer_record(rec)
dlg._values = vals
dlg._dt_spin.setValue(dt)
@ -114,6 +132,7 @@ def test_path_ts_dialog_imports_peer(qtbot, tmp_path) -> None: # type: ignore[n
def test_uniform_excitation_dialog_requires_time_series(qtbot) -> None: # type: ignore[no-untyped-def]
"""With no TimeSeries defined, the dialog's picker is disabled."""
from otko.views.dialogs.uniform_excitation import UniformExcitationDialog
proj = Project()
dlg = UniformExcitationDialog(project=proj, next_pattern_id=1)
qtbot.addWidget(dlg)
@ -123,9 +142,12 @@ def test_uniform_excitation_dialog_requires_time_series(qtbot) -> None: # type:
@pytest.mark.gui
def test_uniform_excitation_dialog_builds_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.uniform_excitation import UniformExcitationDialog
proj = Project(time_series=[
PathTimeSeries(id=7, name="GM", dt=0.01, values=[0.0, 0.1]),
])
proj = Project(
time_series=[
PathTimeSeries(id=7, name="GM", dt=0.01, values=[0.0, 0.1]),
]
)
dlg = UniformExcitationDialog(project=proj, next_pattern_id=2)
qtbot.addWidget(dlg)
# Pick direction = 1 (X) — already default.

View file

@ -30,18 +30,34 @@ from otko.viewmodels import ProjectViewModel # noqa: E402
def _vm_with_steel() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
vm.apply_command(AddNodesCommand(vm, [
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 0, 0)),
Node(id=3, coords=(2, 0, 0)),
]))
vm.apply_command(AddMaterialsCommand(vm, [
Steel01(id=1, name="S420", Fy=420e6, E0=200e9, b=0.01),
]))
vm.apply_command(AddSectionsCommand(vm, [
ElasticSection(id=1, name="Default", E=200e9, A=0.01,
Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6),
]))
vm.apply_command(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 0, 0)),
Node(id=3, coords=(2, 0, 0)),
],
)
)
vm.apply_command(
AddMaterialsCommand(
vm,
[
Steel01(id=1, name="S420", Fy=420e6, E0=200e9, b=0.01),
],
)
)
vm.apply_command(
AddSectionsCommand(
vm,
[
ElasticSection(
id=1, name="Default", E=200e9, A=0.01, Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6
),
],
)
)
return vm
@ -91,8 +107,7 @@ def test_update_unknown_id_raises(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui
def test_update_section_changes_inertia(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel()
new = ElasticSection(id=1, name="Updated", E=200e9, A=0.02,
Iz=2e-5, Iy=2e-5, G=80e9, J=2e-6)
new = ElasticSection(id=1, name="Updated", E=200e9, A=0.02, Iz=2e-5, Iy=2e-5, G=80e9, J=2e-6)
vm.apply_command(UpdateSectionCommand(vm, new))
s = vm.project.section(1)
assert s.A == 0.02
@ -106,14 +121,23 @@ def test_update_section_changes_inertia(qtbot) -> None: # type: ignore[no-untyp
def test_assign_section_to_frames(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel()
# Add a second section and two frames.
vm.apply_command(AddSectionsCommand(vm, [
ElasticSection(id=2, name="Big", E=200e9, A=0.05,
Iz=4e-5, Iy=4e-5, G=80e9, J=2e-6),
]))
vm.apply_command(AddElementsCommand(vm, [
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
]))
vm.apply_command(
AddSectionsCommand(
vm,
[
ElasticSection(id=2, name="Big", E=200e9, A=0.05, Iz=4e-5, Iy=4e-5, G=80e9, J=2e-6),
],
)
)
vm.apply_command(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
],
)
)
vm.apply_command(AssignSectionCommand(vm, {1, 2}, section_id=2))
assert vm.project.element(1).section_id == 2
assert vm.project.element(2).section_id == 2
@ -126,10 +150,15 @@ def test_assign_section_to_frames(qtbot) -> None: # type: ignore[no-untyped-def
def test_assign_section_skips_truss_elements(qtbot) -> None: # type: ignore[no-untyped-def]
"""Truss has no section_id — command should silently skip it."""
vm = _vm_with_steel()
vm.apply_command(AddElementsCommand(vm, [
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
]))
vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
],
)
)
vm.apply_command(AssignSectionCommand(vm, {1, 2}, section_id=1))
# Frame ok, truss unchanged (still material-based).
assert vm.project.element(2).section_id == 1
@ -140,13 +169,23 @@ def test_assign_section_skips_truss_elements(qtbot) -> None: # type: ignore[no-
@pytest.mark.gui
def test_assign_material_to_truss(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel()
vm.apply_command(AddMaterialsCommand(vm, [
Steel02(id=2, name="S355", Fy=355e6, E0=210e9, b=0.005),
]))
vm.apply_command(AddElementsCommand(vm, [
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
]))
vm.apply_command(
AddMaterialsCommand(
vm,
[
Steel02(id=2, name="S355", Fy=355e6, E0=210e9, b=0.005),
],
)
)
vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
],
)
)
vm.apply_command(AssignMaterialCommand(vm, {1, 2}, material_id=2))
assert vm.project.element(1).material_id == 2
assert vm.project.element(2).material_id == 2
@ -157,10 +196,15 @@ def test_assign_material_to_truss(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui
def test_assign_material_skips_frame_elements(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel()
vm.apply_command(AddElementsCommand(vm, [
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
]))
vm.apply_command(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
],
)
)
vm.apply_command(AssignMaterialCommand(vm, {1, 2}, material_id=1))
# Frame doesn't have material_id; only the truss is affected.
assert vm.project.element(2).material_id == 1

View file

@ -37,8 +37,7 @@ def test_steel01_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
def test_steel02_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.material_forms import Steel02Form
original = Steel02(id=3, name="S355", Fy=355e6, E0=210e9, b=0.005,
R0=18.0, cR1=0.925, cR2=0.15)
original = Steel02(id=3, name="S355", Fy=355e6, E0=210e9, b=0.005, R0=18.0, cR1=0.925, cR2=0.15)
form = Steel02Form()
form.populate(original)
restored = form.read()
@ -50,8 +49,15 @@ def test_concrete02_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-d
from otko.views.dialogs.material_forms import Concrete02Form
original = Concrete02(
id=2, name="C30", fpc=-30e6, epsc0=-0.002, fpcu=-15e6, epsU=-0.005,
ft=3e6, Ets=2e9, **{"lambda": 0.1},
id=2,
name="C30",
fpc=-30e6,
epsc0=-0.002,
fpcu=-15e6,
epsU=-0.005,
ft=3e6,
Ets=2e9,
**{"lambda": 0.1},
)
form = Concrete02Form()
form.populate(original)
@ -75,8 +81,14 @@ def test_elastic_section_form_round_trip(qtbot) -> None: # type: ignore[no-unty
from otko.views.dialogs.section_forms import ElasticSectionForm
original = ElasticSection(
id=11, name="W14x90",
E=200e9, A=0.017, Iz=4.16e-4, Iy=1.29e-4, G=80e9, J=2.04e-6,
id=11,
name="W14x90",
E=200e9,
A=0.017,
Iz=4.16e-4,
Iy=1.29e-4,
G=80e9,
J=2.04e-6,
)
form = ElasticSectionForm()
form.populate(original)

View file

@ -17,8 +17,11 @@ from otko.views.docks.pushover_curve import ( # noqa: E402
def _pushover(control_dof: int = 1) -> PushoverResults:
return PushoverResults(
case_id=1, case_name="tst", n_steps=5,
control_node=2, control_dof=control_dof,
case_id=1,
case_name="tst",
n_steps=5,
control_node=2,
control_dof=control_dof,
control_disp=np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]),
base_shear=np.array([0.0, 100.0, 200.0, 300.0, 400.0, 500.0]),
)
@ -34,10 +37,10 @@ def test_is_rotation_dof_ndf3() -> None:
def test_is_rotation_dof_ndf6() -> None:
"""In a 3D/ndf=6 model, DOFs 4, 5, 6 are rotational."""
assert _is_rotation_dof(1, ndf=6) is False
assert _is_rotation_dof(3, ndf=6) is False # Uz in 3D
assert _is_rotation_dof(4, ndf=6) is True # Rx
assert _is_rotation_dof(5, ndf=6) is True # Ry
assert _is_rotation_dof(6, ndf=6) is True # Rz
assert _is_rotation_dof(3, ndf=6) is False # Uz in 3D
assert _is_rotation_dof(4, ndf=6) is True # Rx
assert _is_rotation_dof(5, ndf=6) is True # Ry
assert _is_rotation_dof(6, ndf=6) is True # Rz
@pytest.mark.gui
@ -96,8 +99,7 @@ def test_no_auto_scaling_applied_to_values(qtbot) -> None: # type: ignore[no-un
v.set_results(r)
# Take the single line item that was added; the item's data
# should match the input arrays point-for-point.
items = [it for it in v._plot.listDataItems()
if hasattr(it, "getData")]
items = [it for it in v._plot.listDataItems() if hasattr(it, "getData")]
assert items, "Pushover curve has no plot items"
xs, ys = items[0].getData()
# The first line item is the actual data (reference line is second).

View file

@ -41,16 +41,18 @@ def test_run_dialog_applies_transient_damping_overrides(qtbot, tmp_path) -> None
vm.new_project()
vm.project.time_series.append(LinearTimeSeries(id=1, name="Ramp")) # type: ignore[union-attr]
vm.project.load_patterns.append(PlainLoadPattern(id=1, name="P1", time_series_id=1)) # type: ignore[union-attr]
vm.project.analyses.append(TransientCase( # type: ignore[union-attr]
id=1,
name="EQ",
pattern_ids=[1],
dt=0.01,
n_steps=10,
rayleigh_alpha_m=0.1,
rayleigh_beta_k=0.002,
rayleigh_mode1_damping=0.02,
))
vm.project.analyses.append(
TransientCase( # type: ignore[union-attr]
id=1,
name="EQ",
pattern_ids=[1],
dt=0.01,
n_steps=10,
rayleigh_alpha_m=0.1,
rayleigh_beta_k=0.002,
rayleigh_mode1_damping=0.02,
)
)
vm._path = Path(tmp_path) / "demo.osmodel" # type: ignore[attr-defined]
runner = _FakeRunner()

View file

@ -18,18 +18,25 @@ def _frame_project() -> Project:
# Rectangular section 0.3×0.5 m.
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
return Project(
nodes=[
Node(id=1, coords=(0.0, 0.0, 0.0),
restraint=(True, True, True, True, True, True)),
Node(id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, True, True, True, True)),
Node(id=2, coords=(6.0, 0.0, 0.0)),
],
sections=[ElasticSection(
id=1, name="Rect", E=200e9, A=A,
Iz=Iz, Iy=Iy, G=80e9, J=1e-6,
)],
sections=[
ElasticSection(
id=1,
name="Rect",
E=200e9,
A=A,
Iz=Iz,
Iy=Iy,
G=80e9,
J=1e-6,
)
],
elements=[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
],
@ -39,6 +46,7 @@ def _frame_project() -> Project:
@pytest.mark.gui
def test_toggle_creates_and_removes_extrusion_actor(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_frame_project())
@ -58,6 +66,7 @@ def test_extrusion_skips_elements_without_section(qtbot) -> None: # type: ignor
"""A truss element has no section — it must be skipped silently."""
from otko.core import ElasticUniaxial, TrussElement
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
p = Project(

View file

@ -31,7 +31,14 @@ def test_section_aggregator_has_registered_form() -> None:
@pytest.mark.gui
def test_form_for_elastic_section_returns_editable_form(qtbot) -> None: # type: ignore[no-untyped-def]
s = ElasticSection(
id=1, name="Rect", E=200e9, A=0.01, Iz=1e-5, Iy=1e-5, G=80e9, J=1e-6,
id=1,
name="Rect",
E=200e9,
A=0.01,
Iz=1e-5,
Iy=1e-5,
G=80e9,
J=1e-6,
)
f = form_for(s)
qtbot.addWidget(f)
@ -41,11 +48,19 @@ def test_form_for_elastic_section_returns_editable_form(qtbot) -> None: # type:
@pytest.mark.gui
def test_form_for_fiber_section_returns_summary(qtbot) -> None: # type: ignore[no-untyped-def]
s = FiberSection(
id=1, name="RC",
patches=[RectangularPatch(
material_id=1, n_fib_y=4, n_fib_z=4,
y_i=-0.1, z_i=-0.15, y_j=0.1, z_j=0.15,
)],
id=1,
name="RC",
patches=[
RectangularPatch(
material_id=1,
n_fib_y=4,
n_fib_z=4,
y_i=-0.1,
z_i=-0.15,
y_j=0.1,
z_j=0.15,
)
],
)
f = form_for(s)
qtbot.addWidget(f)
@ -59,7 +74,9 @@ def test_form_for_fiber_section_returns_summary(qtbot) -> None: # type: ignore[
@pytest.mark.gui
def test_form_for_section_aggregator_returns_summary(qtbot) -> None: # type: ignore[no-untyped-def]
s = SectionAggregator(
id=2, name="Agg", section_id=1,
id=2,
name="Agg",
section_id=1,
pairings=[AggregatorDOF(material_id=3, dof="T")],
)
f = form_for(s)
@ -71,10 +88,12 @@ def test_form_for_section_aggregator_returns_summary(qtbot) -> None: # type: ig
def test_form_for_unknown_type_falls_back(qtbot) -> None: # type: ignore[no-untyped-def]
"""An unknown section type must NOT raise — the dialog gets a
placeholder form so the whole UI doesn't go down."""
class _Mystery:
type = "NotRegistered"
id = 99
name = "???"
f = form_for(_Mystery())
qtbot.addWidget(f)
# Placeholder form exists; no exception raised.

View file

@ -21,19 +21,29 @@ def _populated_vm() -> ProjectViewModel:
"""A small VM with 4 corner nodes + 4 truss elements forming a square."""
vm = ProjectViewModel()
vm.new_project()
vm.apply_command(AddNodesCommand(vm, [
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(1, 0, 0)),
Node(id=3, coords=(1, 1, 0)),
Node(id=4, coords=(0, 1, 0)),
]))
vm.apply_command(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0), restraint=(True,) * 6),
Node(id=2, coords=(1, 0, 0)),
Node(id=3, coords=(1, 1, 0)),
Node(id=4, coords=(0, 1, 0)),
],
)
)
vm.project.materials.append(Steel01(id=1, Fy=420e6, E0=200e9, b=0.01))
vm.apply_command(AddElementsCommand(vm, [
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
TrussElement(id=3, nodes=(3, 4), area=1e-3, material_id=1),
TrussElement(id=4, nodes=(4, 1), area=1e-3, material_id=1),
]))
vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
TrussElement(id=3, nodes=(3, 4), area=1e-3, material_id=1),
TrussElement(id=4, nodes=(4, 1), area=1e-3, material_id=1),
],
)
)
return vm
@ -44,7 +54,7 @@ def test_move_translates_in_place(qtbot) -> None: # type: ignore[no-untyped-def
vm.apply_command(MoveNodesCommand(vm, {1, 2, 3, 4}, (10.0, 0.0, 0.0)))
assert vm.project.node(1).coords == (10.0, 0.0, 0.0)
assert vm.project.node(2).coords == (11.0, 0.0, 0.0)
assert len(vm.project.nodes) == 4 # no copies created
assert len(vm.project.nodes) == 4 # no copies created
@pytest.mark.gui
@ -68,8 +78,9 @@ def test_move_preserves_restraint(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui
def test_replicate_creates_n_copies(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _populated_vm()
vm.apply_command(ReplicateCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4},
offset=(0, 0, 3.0), n_copies=2))
vm.apply_command(
ReplicateCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4}, offset=(0, 0, 3.0), n_copies=2)
)
# Original 4 + 2*4 copies = 12 nodes; original 4 + 2*4 elements = 12 elements
assert len(vm.project.nodes) == 12
assert len(vm.project.elements) == 12
@ -81,8 +92,9 @@ def test_replicate_creates_n_copies(qtbot) -> None: # type: ignore[no-untyped-d
@pytest.mark.gui
def test_replicate_undo_removes_only_copies(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _populated_vm()
vm.apply_command(ReplicateCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4},
offset=(0, 0, 3.0), n_copies=2))
vm.apply_command(
ReplicateCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4}, offset=(0, 0, 3.0), n_copies=2)
)
vm.undo_stack.undo()
assert len(vm.project.nodes) == 4
assert len(vm.project.elements) == 4
@ -94,8 +106,7 @@ def test_replicate_undo_removes_only_copies(qtbot) -> None: # type: ignore[no-u
def test_replicate_skips_elements_with_unselected_endpoints(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _populated_vm()
# Select only node 1 and 2; elements 1 (1↔2) is fully covered, others aren't.
vm.apply_command(ReplicateCommand(vm, {1, 2}, {1, 2, 3, 4},
offset=(0, 0, 3.0), n_copies=1))
vm.apply_command(ReplicateCommand(vm, {1, 2}, {1, 2, 3, 4}, offset=(0, 0, 3.0), n_copies=1))
# 2 new nodes + 1 new element (only element 1 was fully bracketed).
assert len(vm.project.nodes) == 6
assert len(vm.project.elements) == 5

View file

@ -16,6 +16,7 @@ from otko.core import Node # noqa: E402
def test_tree_is_in_extended_selection_mode(qtbot) -> None: # type: ignore[no-untyped-def]
"""Default QTreeWidget is single-select; we need ExtendedSelection."""
from otko.views.main_window import MainWindow
mw = MainWindow()
qtbot.addWidget(mw)
assert mw._tree.selectionMode() == QTreeWidget.SelectionMode.ExtendedSelection
@ -27,14 +28,17 @@ def test_tree_multi_select_syncs_canvas(qtbot) -> None: # type: ignore[no-untyp
selection with both ids — the precondition for the Zero-Length
Section dialog (exactly 2 joints)."""
from otko.views.main_window import MainWindow
mw = MainWindow()
qtbot.addWidget(mw)
mw._vm.new_project(ndm=2, ndf=3)
mw._vm.project.nodes.extend([
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(0, 0, 0)),
Node(id=3, coords=(1, 0, 0)),
])
mw._vm.project.nodes.extend(
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(0, 0, 0)),
Node(id=3, coords=(1, 0, 0)),
]
)
mw._refresh_tree(mw._vm.project)
nodes_cat = mw._tree_categories["Nodes"]

View file

@ -12,6 +12,7 @@ from otko.core import UnitSystem # noqa: E402
@pytest.mark.gui
def test_status_bar_combo_lists_all_unit_systems(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow
mw = MainWindow()
qtbot.addWidget(mw)
assert mw._units_combo.count() == len(list(UnitSystem))
@ -20,6 +21,7 @@ def test_status_bar_combo_lists_all_unit_systems(qtbot) -> None: # type: ignore
@pytest.mark.gui
def test_status_bar_combo_reflects_project_units(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow
mw = MainWindow()
qtbot.addWidget(mw)
mw._vm.new_project()
@ -32,6 +34,7 @@ def test_status_bar_combo_reflects_project_units(qtbot) -> None: # type: ignore
def test_status_bar_combo_write_updates_project(qtbot) -> None: # type: ignore[no-untyped-def]
"""Changing the combo writes through to project.meta.units."""
from otko.views.main_window import MainWindow
mw = MainWindow()
qtbot.addWidget(mw)
mw._vm.new_project()
@ -46,6 +49,7 @@ def test_status_bar_combo_write_updates_project(qtbot) -> None: # type: ignore[
def test_status_bar_combo_no_project_noop(qtbot) -> None: # type: ignore[no-untyped-def]
"""Before a project is loaded, changing the combo is a no-op."""
from otko.views.main_window import MainWindow
mw = MainWindow()
qtbot.addWidget(mw)
# No crash even with no project.

View file

@ -23,16 +23,31 @@ from otko.viewmodels import ProjectViewModel # noqa: E402
def _vm_with_truss() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
vm.apply_command(AddNodesCommand(vm, [
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
]))
vm.apply_command(AddMaterialsCommand(vm, [
ElasticUniaxial(id=1, name="Steel", E=200e9),
]))
vm.apply_command(AddElementsCommand(vm, [
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
]))
vm.apply_command(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
],
)
)
vm.apply_command(
AddMaterialsCommand(
vm,
[
ElasticUniaxial(id=1, name="Steel", E=200e9),
],
)
)
vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
],
)
)
return vm
@ -57,9 +72,13 @@ def test_update_area_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
def test_unknown_field_is_silently_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
"""Fields not in the element's model are filtered out — no exception."""
vm = _vm_with_truss()
vm.apply_command(UpdateElementFieldsCommand(
vm, 1, {"section_id": 42}, # TrussElement has no section_id
))
vm.apply_command(
UpdateElementFieldsCommand(
vm,
1,
{"section_id": 42}, # TrussElement has no section_id
)
)
# The element is unchanged.
assert vm.project.element(1).area == pytest.approx(0.001)
assert vm.project.element(1).material_id == 1
@ -68,9 +87,13 @@ def test_unknown_field_is_silently_ignored(qtbot) -> None: # type: ignore[no-un
@pytest.mark.gui
def test_update_multiple_fields_at_once(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_truss()
vm.apply_command(UpdateElementFieldsCommand(
vm, 1, {"area": 0.003, "rho": 7850.0},
))
vm.apply_command(
UpdateElementFieldsCommand(
vm,
1,
{"area": 0.003, "rho": 7850.0},
)
)
el = vm.project.element(1)
assert el.area == pytest.approx(0.003)
assert el.rho == pytest.approx(7850.0)

View file

@ -33,6 +33,7 @@ def _multi_z_project() -> Project:
@pytest.mark.gui
def test_no_working_plane_returns_all_intersections(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@ -45,6 +46,7 @@ def test_no_working_plane_returns_all_intersections(qtbot) -> None: # type: ign
@pytest.mark.gui
def test_xy_plane_filters_to_single_z_level(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@ -61,6 +63,7 @@ def test_xy_plane_filters_to_single_z_level(qtbot) -> None: # type: ignore[no-u
@pytest.mark.gui
def test_xz_plane_filters_to_single_y_level(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@ -74,6 +77,7 @@ def test_xz_plane_filters_to_single_y_level(qtbot) -> None: # type: ignore[no-u
@pytest.mark.gui
def test_yz_plane_filters_to_single_x_level(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@ -87,6 +91,7 @@ def test_yz_plane_filters_to_single_x_level(qtbot) -> None: # type: ignore[no-u
@pytest.mark.gui
def test_clear_working_plane_restores_all(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@ -100,6 +105,7 @@ def test_clear_working_plane_restores_all(qtbot) -> None: # type: ignore[no-unt
@pytest.mark.gui
def test_invalid_plane_raises(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
with pytest.raises(ValueError):
@ -112,10 +118,11 @@ def test_offset_with_no_matching_grid_returns_none(qtbot) -> None: # type: igno
(e.g. typed a custom value off the grid), the snap list is empty
so draw-click does nothing."""
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
canvas.set_working_plane("XY", 1.234) # not in [0, 3, 6]
canvas.set_working_plane("XY", 1.234) # not in [0, 3, 6]
assert canvas._grid_intersections_world() is None
@ -126,6 +133,7 @@ def test_switching_levels_rebuilds_grid_actors(qtbot) -> None: # type: ignore[n
renders — ensuring the screen actually changes when the user
switches Z=0 → Z=3."""
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(_multi_z_project())
@ -133,7 +141,7 @@ def test_switching_levels_rebuilds_grid_actors(qtbot) -> None: # type: ignore[n
# Without a working plane the renderer builds grid + vertical
# connectors across all 3 Z-levels. Count aux actors as a proxy.
base_count = len(canvas._renderer._aux_actors)
assert base_count >= 2 # at least one grid-lines + one dots actor
assert base_count >= 2 # at least one grid-lines + one dots actor
# Pick an XY plane at Z=3 → render rebuilt.
canvas.set_working_plane("XY", 3.0)
@ -162,6 +170,7 @@ def test_switching_levels_rebuilds_grid_actors(qtbot) -> None: # type: ignore[n
@pytest.mark.gui
def test_top_button_populates_level_combo_with_z_ordinates(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.main_window import MainWindow
mw = MainWindow()
qtbot.addWidget(mw)
mw._vm.new_project()