feat: named case-result load combinations with full GUI support
Snapshots the current development tree, headlined by proper load combinations (user request): a reusable LoadCombination entity of weighted completed static-case results (e.g. 1.2xDead + 1.6xLive). - core: LoadCombination/LoadCombinationItem entities, Project integration (lookup, unique ids, reference validation) - services: combinations.py (linear superposition + envelope), exported via services __init__ - commands: undoable Add/Delete/Update for combinations - GUI: Load Combinations manager dialog, Run-dialog evaluation, envelope display in Results panel, Combinations tab in Table dock - tests: unit coverage (validation, math, error paths) + integration superposition check vs a single factored run
This commit is contained in:
commit
f361fee969
560 changed files with 178701 additions and 0 deletions
0
tests/gui/__init__.py
Normal file
0
tests/gui/__init__.py
Normal file
151
tests/gui/test_analysis_commands.py
Normal file
151
tests/gui/test_analysis_commands.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Unit tests for analysis case commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.commands import ( # noqa: E402
|
||||
AddAnalysisCasesCommand,
|
||||
DeleteAnalysisCasesCommand,
|
||||
UpdateAnalysisCaseCommand,
|
||||
)
|
||||
from otko.core import ( # noqa: E402
|
||||
LinearTimeSeries,
|
||||
ModalCase,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
StaticCase,
|
||||
TransientCase,
|
||||
)
|
||||
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||
|
||||
|
||||
def _vm_with_pattern() -> ProjectViewModel:
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
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))]
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
# ──────────────────────── Add ────────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_add_static_case(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_pattern()
|
||||
case = StaticCase(id=1, name="LS", pattern_ids=[1])
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [case]))
|
||||
assert len(vm.project.analyses) == 1
|
||||
assert isinstance(vm.project.analyses[0], StaticCase)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_add_modal_case_no_pattern_needed(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [ModalCase(id=1, n_modes=3)]))
|
||||
assert isinstance(vm.project.analyses[0], ModalCase)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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]),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_add_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_pattern()
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [StaticCase(id=1, pattern_ids=[1])]))
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [StaticCase(id=1, pattern_ids=[1])]))
|
||||
|
||||
|
||||
# ──────────────────────── Update ────────────────────────
|
||||
@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),
|
||||
],
|
||||
)
|
||||
)
|
||||
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
|
||||
vm.undo_stack.undo()
|
||||
assert vm.project.analyses[0].n_steps == 1
|
||||
|
||||
|
||||
@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]),
|
||||
],
|
||||
)
|
||||
)
|
||||
swapped = ModalCase(id=1, name="Mode swap", n_modes=5)
|
||||
vm.apply_command(UpdateAnalysisCaseCommand(vm, swapped))
|
||||
assert isinstance(vm.project.analyses[0], ModalCase)
|
||||
vm.undo_stack.undo()
|
||||
assert isinstance(vm.project.analyses[0], StaticCase)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_update_unknown_id_raises(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_pattern()
|
||||
bogus = ModalCase(id=99)
|
||||
with pytest.raises(KeyError):
|
||||
vm.apply_command(UpdateAnalysisCaseCommand(vm, bogus))
|
||||
|
||||
|
||||
# ──────────────────────── Delete ────────────────────────
|
||||
@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(DeleteAnalysisCasesCommand(vm, {1}))
|
||||
assert {c.id for c in vm.project.analyses} == {2}
|
||||
vm.undo_stack.undo()
|
||||
assert {c.id for c in vm.project.analyses} == {1, 2}
|
||||
|
||||
|
||||
# ──────────────────────── Transient defaults ────────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_transient_case_default_integrator_params(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Newmark γ=0.5, β=0.25 (average acceleration) — unconditionally stable."""
|
||||
vm = _vm_with_pattern()
|
||||
case = TransientCase(id=1, pattern_ids=[1], dt=0.01, n_steps=100)
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [case]))
|
||||
assert vm.project.analyses[0].integrator == "Newmark"
|
||||
assert vm.project.analyses[0].integrator_params == (0.5, 0.25)
|
||||
115
tests/gui/test_analysis_runner.py
Normal file
115
tests/gui/test_analysis_runner.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Unit tests for the AnalysisRunner Qt-thread orchestration.
|
||||
|
||||
These tests run a tiny analytical model end-to-end (Cantilever +
|
||||
linear static), verifying signal flow without mocking. If openseespy
|
||||
isn't importable on this platform, the whole module is skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
pytest.importorskip("openseespy.opensees")
|
||||
|
||||
from otko.commands import ( # noqa: E402
|
||||
AddAnalysisCasesCommand,
|
||||
AddElementsCommand,
|
||||
AddNodalLoadsCommand,
|
||||
AddNodesCommand,
|
||||
AddSectionsCommand,
|
||||
)
|
||||
from otko.core import ( # noqa: E402
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
Node,
|
||||
StaticCase,
|
||||
)
|
||||
from otko.services.results import StaticResults # noqa: E402
|
||||
from otko.viewmodels import AnalysisRunner, ProjectViewModel # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cantilever_vm() -> ProjectViewModel:
|
||||
"""A minimal verifiable model: 2D cantilever beam with tip load."""
|
||||
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(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]),
|
||||
],
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_runner_emits_finished_with_static_results(qtbot, cantilever_vm) -> None: # type: ignore[no-untyped-def]
|
||||
runner = AnalysisRunner()
|
||||
case = cantilever_vm.project.analyses[0]
|
||||
|
||||
with qtbot.waitSignal(runner.finished, timeout=10000) as blocker:
|
||||
runner.run(cantilever_vm.project, case)
|
||||
|
||||
results = blocker.args[0]
|
||||
assert isinstance(results, StaticResults)
|
||||
assert results.case_id == 1
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_runner_running_state_toggles(qtbot, cantilever_vm) -> None: # type: ignore[no-untyped-def]
|
||||
runner = AnalysisRunner()
|
||||
case = cantilever_vm.project.analyses[0]
|
||||
assert not runner.is_running
|
||||
|
||||
with qtbot.waitSignal(runner.runningChanged, timeout=10000) as blocker_start:
|
||||
runner.run(cantilever_vm.project, case)
|
||||
# First emission: running → True
|
||||
assert blocker_start.args[0] is True
|
||||
|
||||
# Wait for finished + the second runningChanged → False
|
||||
with qtbot.waitSignal(runner.runningChanged, timeout=10000) as blocker_end:
|
||||
pass
|
||||
assert blocker_end.args[0] is False
|
||||
assert not runner.is_running
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_double_run_rejected(qtbot, cantilever_vm) -> None: # type: ignore[no-untyped-def]
|
||||
runner = AnalysisRunner()
|
||||
case = cantilever_vm.project.analyses[0]
|
||||
runner.run(cantilever_vm.project, case)
|
||||
with pytest.raises(RuntimeError, match="already running"):
|
||||
runner.run(cantilever_vm.project, case)
|
||||
qtbot.waitSignal(runner.finished, timeout=10000).wait()
|
||||
147
tests/gui/test_assign_load_pattern_name.py
Normal file
147
tests/gui/test_assign_load_pattern_name.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""AssignLoadDialog lets the user pick or name a load pattern."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.commands import AddNodalLoadsCommand # noqa: E402
|
||||
from otko.core import ( # noqa: E402
|
||||
LinearTimeSeries,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Project,
|
||||
)
|
||||
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||
from otko.views.dialogs.assign_load import AssignLoadDialog # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_dialog_defaults_to_new_when_no_patterns(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
dlg = AssignLoadDialog(n_selected=1, existing_patterns=[])
|
||||
qtbot.addWidget(dlg)
|
||||
# Only one row, the "<New pattern…>" sentinel.
|
||||
assert dlg._pattern_cb.count() == 1
|
||||
assert dlg.selected_pattern_id() is None
|
||||
assert dlg._new_name_edit.isEnabled()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_dialog_lists_existing_patterns(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
dlg = AssignLoadDialog(
|
||||
n_selected=2,
|
||||
existing_patterns=[(1, "Gravity"), (2, "RefMoment")],
|
||||
)
|
||||
qtbot.addWidget(dlg)
|
||||
# 2 existing + 1 new sentinel.
|
||||
assert dlg._pattern_cb.count() == 3
|
||||
assert dlg._pattern_cb.currentIndex() == 0
|
||||
assert dlg.selected_pattern_id() == 1
|
||||
# Switch to RefMoment.
|
||||
dlg._pattern_cb.setCurrentIndex(1)
|
||||
assert dlg.selected_pattern_id() == 2
|
||||
# The new-name field is disabled for existing picks.
|
||||
assert not dlg._new_name_edit.isEnabled()
|
||||
|
||||
|
||||
@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")],
|
||||
)
|
||||
qtbot.addWidget(dlg)
|
||||
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")
|
||||
assert dlg.new_pattern_name() == "RefMoment"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_command_creates_named_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""AddNodalLoadsCommand with new_pattern_name must create a fresh
|
||||
PlainLoadPattern with that name, rather than reusing a default."""
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project(ndm=2, ndf=3)
|
||||
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
|
||||
# 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.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
|
||||
ref = next(p for p in vm.project.load_patterns if p.name == "RefMoment")
|
||||
assert ref.id == 2
|
||||
assert ref.nodal_loads[0].forces[5] == pytest.approx(1.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_command_creates_constant_timeseries_when_requested(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""new_ts_type='Constant' produces a ConstantTimeSeries alongside
|
||||
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",
|
||||
)
|
||||
)
|
||||
# Pattern + TimeSeries both created with the "AxialP" name.
|
||||
assert vm.project.load_patterns[0].name == "AxialP"
|
||||
ts = vm.project.time_series[0]
|
||||
assert isinstance(ts, ConstantTimeSeries)
|
||||
assert ts.name == "AxialP"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_command_reuses_existing_pattern_by_id(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""pattern_id=1 reuses the existing pattern — doesn't create new."""
|
||||
vm = ProjectViewModel()
|
||||
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,
|
||||
)
|
||||
)
|
||||
assert len(vm.project.load_patterns) == 1
|
||||
assert len(vm.project.load_patterns[0].nodal_loads) == 1
|
||||
54
tests/gui/test_assign_masses.py
Normal file
54
tests/gui/test_assign_masses.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Unit tests for AssignMassesDialog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.views.dialogs.assign_masses import AssignMassesDialog # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_mass_vector_default_is_zero(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
dlg = AssignMassesDialog(n_selected=3, ndf=6)
|
||||
qtbot.addWidget(dlg)
|
||||
assert dlg.mass_vector() == (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_mass_vector_reads_spinboxes(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
dlg = AssignMassesDialog(n_selected=1, ndf=6)
|
||||
qtbot.addWidget(dlg)
|
||||
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)
|
||||
assert dlg.mass_vector() == (5000.0, 5000.0, 100.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_xy_link_ties_y_to_x(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Toggling the lumped-mass checkbox should mirror Mx → My live."""
|
||||
dlg = AssignMassesDialog(n_selected=1, ndf=6)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg._xy_link.setChecked(True)
|
||||
dlg._mx.setValue(4200.0)
|
||||
assert dlg._my.value() == pytest.approx(4200.0)
|
||||
dlg._xy_link.setChecked(False)
|
||||
dlg._mx.setValue(9999.0)
|
||||
# After unlink, Y stays where it was.
|
||||
assert dlg._my.value() == pytest.approx(4200.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_ndf_3_hides_rotational_fields(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""For ndf=3 models there are no rotational DOFs on a joint."""
|
||||
dlg = AssignMassesDialog(n_selected=1, ndf=3)
|
||||
qtbot.addWidget(dlg)
|
||||
# The rotational spinboxes exist but aren't in the form layout.
|
||||
# We can still read them; they default to 0.
|
||||
vec = dlg.mass_vector()
|
||||
assert vec[3:] == (0.0, 0.0, 0.0)
|
||||
62
tests/gui/test_assign_zls.py
Normal file
62
tests/gui/test_assign_zls.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Unit tests for the Assign Zero-Length Section dialog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
ElasticSection,
|
||||
Node,
|
||||
Project,
|
||||
)
|
||||
from otko.views.dialogs.assign_zls import ( # noqa: E402
|
||||
AssignZeroLengthSectionDialog,
|
||||
)
|
||||
|
||||
|
||||
def _project_with_section() -> Project:
|
||||
return Project(
|
||||
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,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_dialog_lists_project_sections(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
p = _project_with_section()
|
||||
dlg = AssignZeroLengthSectionDialog(p, (1, 2))
|
||||
qtbot.addWidget(dlg)
|
||||
assert dlg._section_cb.count() == 1
|
||||
assert dlg._section_cb.itemData(0) == 3
|
||||
assert dlg.section_id() == 3
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_dialog_disabled_when_no_sections(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
p = Project(
|
||||
ndm=2,
|
||||
ndf=3,
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(0, 0, 0))],
|
||||
)
|
||||
dlg = AssignZeroLengthSectionDialog(p, (1, 2))
|
||||
qtbot.addWidget(dlg)
|
||||
assert not dlg._section_cb.isEnabled()
|
||||
assert dlg.section_id() is None
|
||||
259
tests/gui/test_canvas_grid_snap.py
Normal file
259
tests/gui/test_canvas_grid_snap.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""Tests for the canvas-side pixel-space grid snap.
|
||||
|
||||
We verify the snap LOGIC by stubbing the world→screen projection so the
|
||||
tests don't need a live VTK renderer. The real canvas composes these
|
||||
pieces during a click.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
CoordinateGridSystem,
|
||||
CoordinateSystem,
|
||||
GridSystem,
|
||||
Project,
|
||||
make_grid_lines,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────── logic helpers (no VTK) ──────────────────────
|
||||
def _nearest_snap(
|
||||
cx: float,
|
||||
cy: float,
|
||||
world_pts: np.ndarray,
|
||||
screen_pts: np.ndarray,
|
||||
tol_px: float,
|
||||
) -> tuple[float, float, float] | None:
|
||||
"""Mimics ModelCanvas._nearest_grid_intersection_px with pre-projected data."""
|
||||
if len(world_pts) == 0:
|
||||
return None
|
||||
d2 = (screen_pts[:, 0] - cx) ** 2 + (screen_pts[:, 1] - cy) ** 2
|
||||
idx = int(np.argmin(d2))
|
||||
if d2[idx] <= tol_px**2:
|
||||
return tuple(float(v) for v in world_pts[idx]) # type: ignore[return-value]
|
||||
return None
|
||||
|
||||
|
||||
def test_snap_commits_when_click_is_within_tolerance() -> None:
|
||||
world = np.array([[0, 0, 0], [3, 0, 0], [3, 4, 0]], dtype=float)
|
||||
# Project as if they mapped to these screen pixels.
|
||||
screen = np.array([[100, 100], [300, 100], [300, 250]], dtype=float)
|
||||
# Click 10 pixels away from intersection #1 (at 300, 100).
|
||||
snapped = _nearest_snap(306, 108, world, screen, tol_px=15.0)
|
||||
assert snapped == (3.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def test_snap_rejects_when_click_is_beyond_tolerance() -> None:
|
||||
world = np.array([[0, 0, 0], [3, 0, 0]], dtype=float)
|
||||
screen = np.array([[100, 100], [300, 100]], dtype=float)
|
||||
# Click dead centre between the two pixels (200, 100) — 100 px away
|
||||
# from each, well beyond tol=15 px.
|
||||
snapped = _nearest_snap(200, 100, world, screen, tol_px=15.0)
|
||||
assert snapped is None
|
||||
|
||||
|
||||
def test_snap_rejects_on_empty_grid() -> None:
|
||||
assert _nearest_snap(100, 100, np.empty((0, 3)), np.empty((0, 2)), 15.0) is None
|
||||
|
||||
|
||||
# ────────────────── project ↔ intersections plumbing ─────────────────
|
||||
def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""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)
|
||||
|
||||
p = 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]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
),
|
||||
),
|
||||
CoordinateGridSystem(
|
||||
name="Floor2",
|
||||
coord=CoordinateSystem(origin=(0, 0, 3)),
|
||||
grid=GridSystem(
|
||||
x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
|
||||
y_grid_lines=make_grid_lines("Y", [0.0]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
canvas.show_project(p)
|
||||
pts = canvas._grid_intersections_world()
|
||||
assert pts is not None
|
||||
# 2 X × 1 Y × 1 Z = 2 from each system; 4 total.
|
||||
assert pts.shape == (4, 3)
|
||||
# Floor2 intersections are at z=3.
|
||||
zs = sorted(set(float(z) for z in pts[:, 2]))
|
||||
assert zs == [0.0, 3.0]
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
p = 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]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
hide_all=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
canvas.show_project(p)
|
||||
assert canvas._grid_intersections_world() is None
|
||||
|
||||
|
||||
# ──────────────────────── hover snap + radius sizing ──────────────────────
|
||||
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]),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
r = canvas._renderer
|
||||
assert r._hover_actor is None
|
||||
r.set_hover_snap((3.0, 0.0, 0.0))
|
||||
assert r._hover_actor is not None
|
||||
r.set_hover_snap(None)
|
||||
assert r._hover_actor is None
|
||||
|
||||
|
||||
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.set_snap_preview_enabled(True)
|
||||
canvas._renderer.set_hover_snap((0.0, 0.0, 0.0))
|
||||
assert canvas._renderer._hover_actor is not None
|
||||
canvas.set_snap_preview_enabled(False)
|
||||
assert canvas._renderer._hover_actor is None
|
||||
|
||||
|
||||
# ──────────────────────── frame picking (point-to-segment) ────────────────
|
||||
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 = np.where(ab_sq == 0, 1.0, ab_sq)
|
||||
pa = p - a
|
||||
t = (pa * ab).sum(axis=1) / ab_sq
|
||||
t = np.clip(t, 0.0, 1.0)
|
||||
closest = a + t[:, None] * ab
|
||||
return ((p - closest) ** 2).sum(axis=1)
|
||||
|
||||
|
||||
def test_point_to_segment_midpoint_hit() -> None:
|
||||
"""A click at the midpoint of a long frame must match — the old midpoint-
|
||||
only test already passed; sanity check before end-hit tests."""
|
||||
p = np.array([50.0, 50.0])
|
||||
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] == pytest.approx(0.0)
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
d2 = _pt_to_segment_d2(p, a, b)
|
||||
# Closest point is (10, 50), distance ≈ sqrt(40² + 150²) ≈ 155
|
||||
assert d2[0] > 150.0**2
|
||||
|
||||
|
||||
def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""With a single node placed, the sphere radius must scale with the
|
||||
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(
|
||||
nodes=[Node(id=1, coords=(0.0, 0.0, 0.0))],
|
||||
coord_systems=[
|
||||
CoordinateGridSystem(
|
||||
name="Global",
|
||||
grid=GridSystem(
|
||||
x_grid_lines=make_grid_lines("X", [0.0, 10.0]),
|
||||
y_grid_lines=make_grid_lines("Y", [0.0, 10.0]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
canvas.show_project(p)
|
||||
r = canvas._renderer._scene_node_radius()
|
||||
# Grid diagonal = sqrt(10² + 10²) ≈ 14.14. Radius ≈ 0.008 · 14.14 ≈ 0.113.
|
||||
# Without the grid fix we'd get the 0.05 floor. Assert it's *above* the floor.
|
||||
assert r > 0.05
|
||||
84
tests/gui/test_case_forms.py
Normal file
84
tests/gui/test_case_forms.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""GUI tests for analysis case forms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
NodalLoad,
|
||||
PlainLoadPattern,
|
||||
StaticCase,
|
||||
TransientCase,
|
||||
)
|
||||
from otko.views.dialogs.case_forms import TransientCaseForm # noqa: E402
|
||||
|
||||
|
||||
def _patterns(): # type: ignore[no-untyped-def]
|
||||
return [
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
name="Gravity",
|
||||
time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=1, forces=(0.0, -1.0, 0.0, 0.0, 0.0, 0.0))],
|
||||
),
|
||||
PlainLoadPattern(
|
||||
id=2,
|
||||
name="EQ",
|
||||
time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=1, forces=(1.0, 0.0, 0.0, 0.0, 0.0, 0.0))],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_transient_case_form_round_trips_preload_and_damping(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
analyses = [
|
||||
StaticCase(id=1, name="Gravity", pattern_ids=[1], n_steps=10),
|
||||
StaticCase(id=2, name="Wind", pattern_ids=[2], n_steps=5),
|
||||
]
|
||||
form = TransientCaseForm(_patterns(), analyses)
|
||||
qtbot.addWidget(form)
|
||||
|
||||
case = TransientCase(
|
||||
id=3,
|
||||
name="Earthquake",
|
||||
pattern_ids=[2],
|
||||
preload_case_ids=[1],
|
||||
remove_patterns=[1],
|
||||
dt=0.01,
|
||||
n_steps=3995,
|
||||
rayleigh_alpha_m=0.01,
|
||||
rayleigh_beta_k=2.5e-4,
|
||||
rayleigh_mode1_damping=0.02,
|
||||
)
|
||||
form.populate(case)
|
||||
|
||||
rebuilt = form.read()
|
||||
assert rebuilt.pattern_ids == [2]
|
||||
assert rebuilt.preload_case_ids == [1]
|
||||
assert rebuilt.remove_patterns == [1]
|
||||
assert rebuilt.dt == pytest.approx(0.01)
|
||||
assert rebuilt.n_steps == 3995
|
||||
assert rebuilt.rayleigh_alpha_m == pytest.approx(0.01)
|
||||
assert rebuilt.rayleigh_beta_k == pytest.approx(2.5e-4)
|
||||
assert rebuilt.rayleigh_mode1_damping == pytest.approx(0.02)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_transient_case_form_zero_mode1_damping_reads_as_none(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
form = TransientCaseForm(_patterns(), [StaticCase(id=1, name="Gravity", pattern_ids=[1])])
|
||||
qtbot.addWidget(form)
|
||||
|
||||
form._name_edit.setText("Transient")
|
||||
form._dt.setValue(0.02)
|
||||
form._n_steps.setValue(100)
|
||||
form._mode1_damping.setValue(0.0)
|
||||
form._beta_k.setValue(1.0e-4)
|
||||
form._patterns_picker.item(1).setSelected(True)
|
||||
|
||||
rebuilt = form.read(case_id=2)
|
||||
assert rebuilt.pattern_ids == [2]
|
||||
assert rebuilt.rayleigh_beta_k == pytest.approx(1.0e-4)
|
||||
assert rebuilt.rayleigh_mode1_damping is None
|
||||
181
tests/gui/test_commands.py
Normal file
181
tests/gui/test_commands.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Unit tests for ProjectCommand subclasses.
|
||||
|
||||
Verify that every command's ``redo`` is exactly reversed by ``undo``,
|
||||
that the undo stack auto-tracks dirty state, and that cascade-delete
|
||||
removes affected elements.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.commands import ( # noqa: E402
|
||||
AddElementsCommand,
|
||||
AddNodalLoadsCommand,
|
||||
AddNodesCommand,
|
||||
DeleteElementsCommand,
|
||||
DeleteNodesCommand,
|
||||
SetRestraintCommand,
|
||||
)
|
||||
from otko.core import ( # noqa: E402
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
Steel01,
|
||||
TrussElement,
|
||||
)
|
||||
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||
|
||||
|
||||
# ─────────────────────────── helpers ────────────────────────────────
|
||||
def _vm() -> ProjectViewModel:
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
return vm
|
||||
|
||||
|
||||
# ─────────────────────────── AddNodesCommand ────────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_add_nodes_then_undo(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
new_nodes = [Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))]
|
||||
vm.apply_command(AddNodesCommand(vm, new_nodes))
|
||||
assert len(vm.project.nodes) == 2
|
||||
vm.undo_stack.undo()
|
||||
assert len(vm.project.nodes) == 0
|
||||
vm.undo_stack.redo()
|
||||
assert len(vm.project.nodes) == 2
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_add_nodes_emits_modelMutated(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
with qtbot.waitSignal(vm.modelMutated, timeout=500):
|
||||
vm.apply_command(AddNodesCommand(vm, [Node(id=1, coords=(0, 0, 0))]))
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_add_nodes_marks_dirty(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
assert not vm.is_dirty
|
||||
vm.apply_command(AddNodesCommand(vm, [Node(id=1, coords=(0, 0, 0))]))
|
||||
assert vm.is_dirty
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_add_nodes_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
vm.apply_command(AddNodesCommand(vm, [Node(id=5, coords=(0, 0, 0))]))
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
vm.apply_command(AddNodesCommand(vm, [Node(id=5, coords=(1, 0, 0))]))
|
||||
|
||||
|
||||
# ─────────────────────────── DeleteNodesCommand (cascade) ───────────
|
||||
@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)),
|
||||
],
|
||||
)
|
||||
)
|
||||
# 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),
|
||||
],
|
||||
)
|
||||
)
|
||||
# Delete node 2 → both elements should disappear.
|
||||
vm.apply_command(DeleteNodesCommand(vm, {2}))
|
||||
assert {n.id for n in vm.project.nodes} == {1, 3}
|
||||
assert vm.project.elements == []
|
||||
# Undo restores everything.
|
||||
vm.undo_stack.undo()
|
||||
assert {n.id for n in vm.project.nodes} == {1, 2, 3}
|
||||
assert {e.id for e in vm.project.elements} == {1, 2}
|
||||
|
||||
|
||||
# ─────────────────────────── SetRestraintCommand ───────────────────
|
||||
@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)),
|
||||
],
|
||||
)
|
||||
)
|
||||
fix = (True, True, True, True, True, True)
|
||||
vm.apply_command(SetRestraintCommand(vm, {1, 2}, fix))
|
||||
assert vm.project.node(1).restraint == fix
|
||||
assert vm.project.node(2).restraint == fix
|
||||
vm.undo_stack.undo()
|
||||
assert vm.project.node(1).restraint == (False,) * 6
|
||||
assert vm.project.node(2).restraint == (False,) * 6
|
||||
|
||||
|
||||
# ─────────────────────────── AddNodalLoadsCommand ──────────────────
|
||||
@pytest.mark.gui
|
||||
def test_add_load_creates_default_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
vm.apply_command(AddNodesCommand(vm, [Node(id=1, coords=(0, 0, 0))]))
|
||||
forces = (100.0, 0, 0, 0, 0, 0)
|
||||
vm.apply_command(AddNodalLoadsCommand(vm, {1}, forces))
|
||||
assert len(vm.project.time_series) == 1
|
||||
assert isinstance(vm.project.time_series[0], LinearTimeSeries)
|
||||
assert len(vm.project.load_patterns) == 1
|
||||
pat = vm.project.load_patterns[0]
|
||||
assert isinstance(pat, PlainLoadPattern)
|
||||
assert len(pat.nodal_loads) == 1
|
||||
assert pat.nodal_loads[0] == NodalLoad(node_id=1, forces=forces)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_add_load_undo_removes_default_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
vm.apply_command(AddNodesCommand(vm, [Node(id=1, coords=(0, 0, 0))]))
|
||||
vm.apply_command(AddNodalLoadsCommand(vm, {1}, (100.0, 0, 0, 0, 0, 0)))
|
||||
vm.undo_stack.undo()
|
||||
# Both the load AND the auto-created pattern + ts should be gone.
|
||||
assert vm.project.time_series == []
|
||||
assert vm.project.load_patterns == []
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_add_load_uses_existing_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
vm.apply_command(AddNodesCommand(vm, [Node(id=1, coords=(0, 0, 0))]))
|
||||
# Pre-populate a pattern.
|
||||
vm.project.time_series.append(LinearTimeSeries(id=1))
|
||||
vm.project.load_patterns.append(PlainLoadPattern(id=1, time_series_id=1))
|
||||
vm.apply_command(AddNodalLoadsCommand(vm, {1}, (50.0, 0, 0, 0, 0, 0)))
|
||||
# No new pattern should be created.
|
||||
assert len(vm.project.time_series) == 1
|
||||
assert len(vm.project.load_patterns) == 1
|
||||
assert len(vm.project.load_patterns[0].nodal_loads) == 1
|
||||
|
||||
|
||||
# ─────────────────────────── stack semantics ───────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_save_marks_clean(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
vm.apply_command(AddNodesCommand(vm, [Node(id=1, coords=(0, 0, 0))]))
|
||||
assert vm.is_dirty
|
||||
vm.save(tmp_path / "x.osmodel")
|
||||
assert not vm.is_dirty
|
||||
40
tests/gui/test_constraint_and_display_dialogs.py
Normal file
40
tests/gui/test_constraint_and_display_dialogs.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""GUI tests for equalDOF and display-option dialogs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.views.dialogs.assign_equal_dof import AssignEqualDOFDialog
|
||||
from otko.views.dialogs.display_options import DisplayOptionsDialog
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_assign_equal_dof_dialog_requires_explicit_dof_choice(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
dlg = AssignEqualDOFDialog([3, 4], ndf=3)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
with pytest.raises(ValueError, match="at least one DOF"):
|
||||
dlg.constraint()
|
||||
|
||||
dlg2 = AssignEqualDOFDialog([3, 4], ndf=3, suggested_dofs=(2, 3))
|
||||
qtbot.addWidget(dlg2)
|
||||
|
||||
c = dlg2.constraint()
|
||||
assert c.retained_node == 3
|
||||
assert c.constrained_node == 4
|
||||
assert c.dofs == (2, 3)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_display_options_dialog_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
dlg = DisplayOptionsDialog(
|
||||
show_node_labels=False,
|
||||
show_element_labels=True,
|
||||
)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
dlg._show_node_labels.setChecked(True)
|
||||
dlg._show_element_labels.setChecked(False)
|
||||
assert dlg.values() == (True, False)
|
||||
185
tests/gui/test_convert_element_type.py
Normal file
185
tests/gui/test_convert_element_type.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""Tests for ConvertElementTypeCommand — swap element class by id."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.commands import ( # noqa: E402
|
||||
AddElementsCommand,
|
||||
AddMaterialsCommand,
|
||||
AddNodesCommand,
|
||||
AddSectionsCommand,
|
||||
ConvertElementTypeCommand,
|
||||
)
|
||||
from otko.core import ( # noqa: E402
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
ElasticUniaxial,
|
||||
Node,
|
||||
TrussElement,
|
||||
)
|
||||
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,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
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},
|
||||
)
|
||||
)
|
||||
el = vm.project.element(1)
|
||||
assert isinstance(el, TrussElement)
|
||||
assert el.nodes == (1, 2)
|
||||
assert el.material_id == 1
|
||||
assert el.area == pytest.approx(0.001)
|
||||
|
||||
|
||||
@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},
|
||||
)
|
||||
)
|
||||
el = vm.project.element(1)
|
||||
assert isinstance(el, ElasticBeamColumn)
|
||||
assert el.nodes == (1, 2)
|
||||
assert el.section_id == 1
|
||||
|
||||
|
||||
@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},
|
||||
)
|
||||
)
|
||||
assert isinstance(vm.project.element(42), TrussElement)
|
||||
assert vm.project.element(42).name == "A"
|
||||
vm.undo_stack.undo()
|
||||
assert isinstance(vm.project.element(42), ElasticBeamColumn)
|
||||
|
||||
|
||||
@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),
|
||||
],
|
||||
)
|
||||
)
|
||||
before = vm.project.element(1)
|
||||
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
|
||||
|
||||
|
||||
@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),
|
||||
],
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
vm.apply_command(
|
||||
ConvertElementTypeCommand(
|
||||
vm,
|
||||
{1},
|
||||
"NotAType",
|
||||
defaults={},
|
||||
)
|
||||
)
|
||||
45
tests/gui/test_define_dialogs.py
Normal file
45
tests/gui/test_define_dialogs.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""GUI tests for Define dialogs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import LinearTimeSeries, Project # noqa: E402
|
||||
from otko.views.dialogs.linear_time_series import LinearTimeSeriesDialog # noqa: E402
|
||||
from otko.views.dialogs.plain_pattern import PlainPatternDialog # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_linear_time_series_dialog_builds_entity(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
dlg = LinearTimeSeriesDialog(next_ts_id=3)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
dlg._name_edit.setText("Gravity")
|
||||
dlg._factor_spin.setValue(1.5)
|
||||
|
||||
ts = dlg.time_series()
|
||||
assert ts.id == 3
|
||||
assert ts.name == "Gravity"
|
||||
assert ts.factor == pytest.approx(1.5)
|
||||
|
||||
|
||||
@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"),
|
||||
]
|
||||
)
|
||||
dlg = PlainPatternDialog(project=proj, next_pattern_id=4)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
dlg._name_edit.setText("Gravity")
|
||||
dlg._ts_cb.setCurrentIndex(1)
|
||||
|
||||
pat = dlg.pattern()
|
||||
assert pat.id == 4
|
||||
assert pat.name == "Gravity"
|
||||
assert pat.time_series_id == 2
|
||||
228
tests/gui/test_draw_frame_tool.py
Normal file
228
tests/gui/test_draw_frame_tool.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""Unit tests for DrawFrameTool.
|
||||
|
||||
The tool needs a ``canvas`` reference for selection feedback, so we
|
||||
substitute a tiny stub that exposes only what the tool touches:
|
||||
``selection.select_node()`` and ``selection.clear()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.commands import AddNodesCommand # noqa: E402
|
||||
from otko.core import ElasticBeamColumn, ElasticSection, Node # noqa: E402
|
||||
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||
from otko.views.canvas3d import SelectionState # noqa: E402
|
||||
from otko.views.tools.draw_frame import DrawFrameTool # noqa: E402
|
||||
|
||||
|
||||
class _CanvasStub:
|
||||
"""The minimum interface DrawFrameTool reads from a ModelCanvas."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.selection = SelectionState()
|
||||
|
||||
def view_xy(self) -> None:
|
||||
# Tool calls view_xy() on activate; stub accepts and ignores.
|
||||
pass
|
||||
|
||||
def set_snap_preview_enabled(self, _enabled: bool) -> None:
|
||||
pass
|
||||
|
||||
|
||||
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)),
|
||||
],
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
# ──────────────────────────── basic flow ────────────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_first_pick_stores_node_and_highlights(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_two_nodes()
|
||||
canvas = _CanvasStub()
|
||||
tool = DrawFrameTool(canvas, vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
tool.on_node_picked(1)
|
||||
assert tool._first_node_id == 1
|
||||
assert canvas.selection.nodes == frozenset({1})
|
||||
# No element should have been created yet.
|
||||
assert vm.project.elements == []
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_second_pick_creates_element_and_resets(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_two_nodes()
|
||||
canvas = _CanvasStub()
|
||||
tool = DrawFrameTool(canvas, vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
tool.on_node_picked(1)
|
||||
tool.on_node_picked(2)
|
||||
|
||||
# One element + one auto-created default section, all in a single macro.
|
||||
assert len(vm.project.elements) == 1
|
||||
assert isinstance(vm.project.elements[0], ElasticBeamColumn)
|
||||
assert vm.project.elements[0].nodes == (1, 2)
|
||||
assert len(vm.project.sections) == 1
|
||||
assert isinstance(vm.project.sections[0], ElasticSection)
|
||||
# Tool should have reset and cleared the highlight.
|
||||
assert tool._first_node_id is None
|
||||
assert canvas.selection.is_empty
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_two_nodes()
|
||||
canvas = _CanvasStub()
|
||||
tool = DrawFrameTool(canvas, vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
tool.on_node_picked(1)
|
||||
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
|
||||
|
||||
|
||||
# ──────────────────────────── undo as macro ────────────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_draw_frame_undoes_atomically(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Section + element should disappear in one Undo, not two."""
|
||||
vm = _vm_with_two_nodes()
|
||||
canvas = _CanvasStub()
|
||||
tool = DrawFrameTool(canvas, vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
tool.on_node_picked(1)
|
||||
tool.on_node_picked(2)
|
||||
assert len(vm.project.sections) == 1
|
||||
assert len(vm.project.elements) == 1
|
||||
|
||||
vm.undo_stack.undo()
|
||||
# Both should be gone after a single undo (macro).
|
||||
assert vm.project.sections == []
|
||||
assert vm.project.elements == []
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_subsequent_draws_reuse_existing_section(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_two_nodes()
|
||||
vm.apply_command(AddNodesCommand(vm, [Node(id=3, coords=(6, 0, 0))]))
|
||||
canvas = _CanvasStub()
|
||||
tool = DrawFrameTool(canvas, vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
tool.on_node_picked(1)
|
||||
tool.on_node_picked(2)
|
||||
tool.on_node_picked(2)
|
||||
tool.on_node_picked(3)
|
||||
|
||||
# Section created once; second draw reuses it.
|
||||
assert len(vm.project.sections) == 1
|
||||
assert len(vm.project.elements) == 2
|
||||
|
||||
|
||||
# ──────────────────────────── reset ────────────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_reset_clears_first_pick(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_two_nodes()
|
||||
canvas = _CanvasStub()
|
||||
tool = DrawFrameTool(canvas, vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
tool.on_node_picked(1)
|
||||
assert tool._first_node_id == 1
|
||||
tool.reset()
|
||||
assert tool._first_node_id is None
|
||||
assert canvas.selection.is_empty
|
||||
|
||||
|
||||
# ──────────────────── empty-click → auto-node → frame ───────────────
|
||||
@pytest.mark.gui
|
||||
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]
|
||||
x_lines=[0.0, 3.0, 6.0],
|
||||
y_lines=[0.0, 4.0],
|
||||
z_lines=[0.0],
|
||||
)
|
||||
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
# Canvas does the pixel-snap before emitting; tool receives exact
|
||||
# intersection coords.
|
||||
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 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]
|
||||
assert n1.coords == (0.0, 0.0, 0.0)
|
||||
assert n2.coords == (3.0, 4.0, 0.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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.project.grid_system = GridSystem( # type: ignore[union-attr]
|
||||
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)
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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],
|
||||
)
|
||||
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
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.elements) == 1
|
||||
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}
|
||||
149
tests/gui/test_draw_node_tool.py
Normal file
149
tests/gui/test_draw_node_tool.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Unit tests for DrawNodeTool.
|
||||
|
||||
The tool only uses ``vm.project`` and ``vm.apply_command``; it does
|
||||
not touch the canvas except through the stub selection interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import GridSystem, Node, Project # noqa: E402
|
||||
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||
from otko.views.canvas3d.selection import SelectionState # noqa: E402
|
||||
from otko.views.tools.draw_node import ( # noqa: E402
|
||||
DrawNodeTool,
|
||||
_snap_to_grid,
|
||||
)
|
||||
|
||||
|
||||
class _CanvasStub:
|
||||
def __init__(self) -> None:
|
||||
self.selection = SelectionState()
|
||||
|
||||
def view_xy(self) -> None:
|
||||
# No-op: real canvas switches camera; tool tests don't care.
|
||||
pass
|
||||
|
||||
def set_snap_preview_enabled(self, _enabled: bool) -> None:
|
||||
# Hover-snap preview is a real-canvas feature; tool tests ignore it.
|
||||
pass
|
||||
|
||||
|
||||
def _vm_with_grid() -> ProjectViewModel:
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
|
||||
x_lines=[0.0, 3.0, 6.0],
|
||||
y_lines=[0.0, 4.0],
|
||||
z_lines=[0.0],
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
# ────────────────────────── 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],
|
||||
)
|
||||
assert (x, y, z) == (3.0, 4.0, 0.0)
|
||||
|
||||
|
||||
def test_snap_to_grid_identity_on_empty_axes() -> None:
|
||||
x, y, z = _snap_to_grid(1.5, 2.5, 3.5, x_lines=[], y_lines=[], z_lines=[])
|
||||
assert (x, y, z) == (1.5, 2.5, 3.5)
|
||||
|
||||
|
||||
# ────────────────────────── tool behaviour ──────────────────────────
|
||||
# NB: the canvas now does pixel-space snap BEFORE emitting emptyClicked;
|
||||
# the tool receives only valid grid-intersection coordinates. These tests
|
||||
# simulate that by passing exact intersection coords.
|
||||
@pytest.mark.gui
|
||||
def test_empty_click_creates_node_at_exact_intersection(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_grid()
|
||||
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
# Canvas guarantees the coords ARE an intersection.
|
||||
tool.on_empty_clicked(0.0, 0.0, 0.0)
|
||||
assert len(vm.project.nodes) == 1 # type: ignore[union-attr]
|
||||
assert vm.project.nodes[0].coords == (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 vm.project.nodes[1].coords == (3.0, 4.0, 0.0) # type: ignore[union-attr]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_empty_click_duplicate_is_rejected(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Clicking exactly on an existing node's coords must not duplicate it."""
|
||||
vm = _vm_with_grid()
|
||||
vm.project.nodes.append(Node(id=42, coords=(3.0, 4.0, 0.0))) # type: ignore[union-attr]
|
||||
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
status_msgs: list[str] = []
|
||||
tool.statusChanged.connect(status_msgs.append)
|
||||
tool.on_empty_clicked(3.0, 4.0, 0.0)
|
||||
assert len(vm.project.nodes) == 1 # type: ignore[union-attr] (unchanged)
|
||||
assert any("already exists" in m for m in status_msgs)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_grid_less_canvas_never_emits_to_tool(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Documents the new contract: tool simply trusts canvas.
|
||||
|
||||
The canvas only emits emptyClicked when it has a valid snap
|
||||
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()
|
||||
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
assert vm.project.nodes == [] # type: ignore[union-attr]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_consecutive_clicks_allocate_unique_ids(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_grid()
|
||||
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
tool.on_empty_clicked(0.0, 0.0, 0.0)
|
||||
tool.on_empty_clicked(3.0, 0.0, 0.0)
|
||||
tool.on_empty_clicked(6.0, 0.0, 0.0)
|
||||
ids = [n.id for n in vm.project.nodes] # type: ignore[union-attr]
|
||||
assert len(ids) == 3
|
||||
assert len(set(ids)) == 3 # all unique
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_picking_existing_node_emits_status_only(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_grid()
|
||||
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0))) # type: ignore[union-attr]
|
||||
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
status_msgs: list[str] = []
|
||||
tool.statusChanged.connect(status_msgs.append)
|
||||
tool.on_node_picked(1)
|
||||
# No new nodes; user was told about the existing one.
|
||||
assert len(vm.project.nodes) == 1 # type: ignore[union-attr]
|
||||
assert any("already exists" in m for m in status_msgs)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_tool_is_active_after_activate(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_grid()
|
||||
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
assert tool.is_active is False
|
||||
tool.activate()
|
||||
assert tool.is_active is True
|
||||
tool.deactivate()
|
||||
assert tool.is_active is False
|
||||
121
tests/gui/test_draw_truss_tool.py
Normal file
121
tests/gui/test_draw_truss_tool.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Tests for DrawTrussTool — two-click truss-bar drawing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
CoordinateGridSystem,
|
||||
CoordinateSystem,
|
||||
ElasticUniaxial,
|
||||
GridSystem,
|
||||
Node,
|
||||
TrussElement,
|
||||
make_grid_lines,
|
||||
)
|
||||
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||
from otko.views.canvas3d.selection import SelectionState # noqa: E402
|
||||
from otko.views.tools.draw_truss import DrawTrussTool # noqa: E402
|
||||
|
||||
|
||||
class _CanvasStub:
|
||||
def __init__(self) -> None:
|
||||
self.selection = SelectionState()
|
||||
|
||||
def view_xy(self) -> None:
|
||||
pass
|
||||
|
||||
def set_snap_preview_enabled(self, _enabled: bool) -> None:
|
||||
pass
|
||||
|
||||
|
||||
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.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]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
),
|
||||
),
|
||||
]
|
||||
return vm
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_two_clicks_create_truss(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_grid_and_nodes()
|
||||
tool = DrawTrussTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
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 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 isinstance(vm.project.materials[0], ElasticUniaxial)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_draw_truss_reuses_existing_material(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_grid_and_nodes()
|
||||
vm.project.materials.append(ElasticUniaxial(id=1, name="Existing", E=150e9)) # type: ignore[union-attr]
|
||||
tool = DrawTrussTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
tool.on_node_picked(1)
|
||||
tool.on_node_picked(2)
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_empty_click_creates_node_then_truss(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Canvas-assumed: coordinates passed are already on a snap target."""
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
vm.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]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
),
|
||||
),
|
||||
]
|
||||
tool = DrawTrussTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
tool.on_empty_clicked(0.0, 0.0, 0.0)
|
||||
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 isinstance(vm.project.elements[0], TrussElement)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_grid_and_nodes()
|
||||
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]
|
||||
188
tests/gui/test_grid_dialog.py
Normal file
188
tests/gui/test_grid_dialog.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""SAP2000-style grid dialog tests — DefineGridSystemDataDialog and friends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
CoordinateGridSystem,
|
||||
CoordinateSystem,
|
||||
GridLine,
|
||||
GridSystem,
|
||||
make_grid_lines,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────── Quick Grid Lines ──────────────────────────
|
||||
@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)
|
||||
xs, ys, zs = dlg.ordinates()
|
||||
assert xs == [0.0, 3.0, 6.0, 9.0]
|
||||
assert ys == [0.0, 4.0]
|
||||
assert zs == [-3.0, 0.0, 3.0]
|
||||
|
||||
|
||||
@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)
|
||||
dlg._y_n.setValue(0)
|
||||
dlg._z_n.setValue(0)
|
||||
xs, ys, zs = dlg.ordinates()
|
||||
assert xs == [] and ys == [] and zs == []
|
||||
|
||||
|
||||
# ────────────────────── Coord Location + Orientation ──────────────────
|
||||
@pytest.mark.gui
|
||||
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),
|
||||
)
|
||||
qtbot.addWidget(dlg)
|
||||
origin, rot = dlg.location()
|
||||
assert origin == pytest.approx((1.5, -2.0, 3.0))
|
||||
assert rot == pytest.approx((0.0, 0.0, 45.0))
|
||||
|
||||
|
||||
# ────────────────────── Define Grid System Data form ──────────────────
|
||||
@pytest.mark.gui
|
||||
def test_define_grid_data_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Loading an existing system and reading .system() must round-trip."""
|
||||
from otko.views.dialogs.define_grid_data import (
|
||||
DefineGridSystemDataDialog,
|
||||
)
|
||||
|
||||
cs = CoordinateGridSystem(
|
||||
name="Floor2",
|
||||
coord=CoordinateSystem(origin=(0, 0, 3.5)),
|
||||
grid=GridSystem(
|
||||
x_grid_lines=make_grid_lines("X", [0.0, 4.0, 8.0]),
|
||||
y_grid_lines=make_grid_lines("Y", [0.0, 6.0]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
hide_all=False,
|
||||
glue_to_grid=True,
|
||||
bubble_size=18,
|
||||
),
|
||||
)
|
||||
dlg = DefineGridSystemDataDialog(existing=cs)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
result = dlg.system()
|
||||
assert result.name == "Floor2"
|
||||
assert result.coord.origin == (0.0, 0.0, 3.5)
|
||||
assert result.grid.x_lines == [0.0, 4.0, 8.0]
|
||||
assert result.grid.y_lines == [0.0, 6.0]
|
||||
assert result.grid.z_lines == [0.0]
|
||||
assert result.grid.glue_to_grid is True
|
||||
assert result.grid.bubble_size == 18
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_define_grid_data_spacing_mode_conversion(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Switching to Spacing mode and back must preserve ordinate values."""
|
||||
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])),
|
||||
)
|
||||
dlg = DefineGridSystemDataDialog(existing=cs)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# Toggle to Spacing mode.
|
||||
dlg._rb_spacing.setChecked(True)
|
||||
# Then back to Ordinates.
|
||||
dlg._rb_ordinates.setChecked(True)
|
||||
result = dlg.system()
|
||||
assert result.grid.x_lines == pytest.approx([0.0, 3.0, 6.0, 10.0])
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_define_grid_data_global_name_locked(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""For the Global system, the Name field is disabled."""
|
||||
from otko.views.dialogs.define_grid_data import (
|
||||
DefineGridSystemDataDialog,
|
||||
)
|
||||
|
||||
dlg = DefineGridSystemDataDialog(is_global=True)
|
||||
qtbot.addWidget(dlg)
|
||||
assert dlg._name_edit.text() == "Global"
|
||||
assert not dlg._name_edit.isEnabled()
|
||||
assert not dlg._btn_locate.isEnabled()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_define_grid_data_add_and_delete_row(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Add Row / Delete Row buttons modify the X tab table directly."""
|
||||
from otko.views.dialogs.define_grid_data import (
|
||||
DefineGridSystemDataDialog,
|
||||
)
|
||||
|
||||
dlg = DefineGridSystemDataDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
# Start with empty X table.
|
||||
assert dlg._tab_x._table.rowCount() == 0
|
||||
dlg._tab_x._on_add_row()
|
||||
dlg._tab_x._on_add_row()
|
||||
assert dlg._tab_x._table.rowCount() == 2
|
||||
dlg._tab_x._table.selectRow(0)
|
||||
dlg._tab_x._on_delete_row()
|
||||
assert dlg._tab_x._table.rowCount() == 1
|
||||
|
||||
|
||||
# ────────────────────── GridLine + migration tests ────────────────────
|
||||
def test_grid_line_defaults() -> None:
|
||||
ln = GridLine(id="A", ordinate=3.0)
|
||||
assert ln.id == "A"
|
||||
assert ln.line_type == "Primary"
|
||||
assert ln.visible is True
|
||||
assert ln.bubble_loc == "End"
|
||||
assert ln.color == "#808080"
|
||||
|
||||
|
||||
def test_grid_system_migrates_flat_x_lines() -> None:
|
||||
"""Legacy constructor with list[float] must convert to GridLine records."""
|
||||
g = GridSystem(x_lines=[0.0, 2.5, 5.0])
|
||||
assert len(g.x_grid_lines) == 3
|
||||
ids = [ln.id for ln in g.x_grid_lines]
|
||||
assert ids == ["X1", "X2", "X3"]
|
||||
assert g.x_lines == [0.0, 2.5, 5.0]
|
||||
|
||||
|
||||
def test_grid_system_hide_all_and_glue_persist() -> None:
|
||||
g = GridSystem(
|
||||
x_grid_lines=make_grid_lines("X", [0.0, 1.0]),
|
||||
hide_all=True,
|
||||
glue_to_grid=True,
|
||||
bubble_size=24,
|
||||
)
|
||||
d = g.model_dump()
|
||||
r = GridSystem.model_validate(d)
|
||||
assert r.hide_all is True
|
||||
assert r.glue_to_grid is True
|
||||
assert r.bubble_size == 24
|
||||
41
tests/gui/test_main_window.py
Normal file
41
tests/gui/test_main_window.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Smoke test for the MainWindow.
|
||||
|
||||
Marked as ``gui`` so it can be deselected on environments without a display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
pytest.importorskip("pyvistaqt")
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_main_window_opens(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
window = MainWindow()
|
||||
qtbot.addWidget(window)
|
||||
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
|
||||
121
tests/gui/test_material_dialog_concrete04.py
Normal file
121
tests/gui/test_material_dialog_concrete04.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""GUI tests for the Concrete04 material form."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import Concrete04 # noqa: E402
|
||||
from otko.views.dialogs.material_forms import ( # noqa: E402
|
||||
Concrete04Form,
|
||||
FORM_REGISTRY,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete04_in_registry(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
assert "Concrete04" in FORM_REGISTRY
|
||||
assert FORM_REGISTRY["Concrete04"] is Concrete04Form
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete04_form_constructs(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
form = Concrete04Form()
|
||||
qtbot.addWidget(form)
|
||||
assert form.type_label == "Concrete04 — Popovics concrete (optional tension)"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete04_read_no_tension(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Fill the form and read back a Concrete04 model (no tension branch)."""
|
||||
form = Concrete04Form()
|
||||
qtbot.addWidget(form)
|
||||
|
||||
form._name_edit.setText("C30")
|
||||
form._fpc.setValue(-30e6)
|
||||
form._epsc0.setValue(-0.002)
|
||||
form._epscu.setValue(-0.005)
|
||||
form._Ec.setValue(30e9)
|
||||
form._fct.setValue(0.0) # 0 → no tension
|
||||
form._et.setValue(0.0)
|
||||
form._material_id = 1
|
||||
|
||||
mat = form.read()
|
||||
assert isinstance(mat, Concrete04)
|
||||
assert mat.id == 1
|
||||
assert mat.name == "C30"
|
||||
assert mat.fpc == pytest.approx(-30e6)
|
||||
assert mat.epsc0 == pytest.approx(-0.002)
|
||||
assert mat.epscu == pytest.approx(-0.005)
|
||||
assert mat.Ec == pytest.approx(30e9)
|
||||
assert mat.fct is None
|
||||
assert mat.et is None
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete04_read_with_tension(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Form with positive fct + et produces the tensile-branch model."""
|
||||
form = Concrete04Form()
|
||||
qtbot.addWidget(form)
|
||||
|
||||
form._fpc.setValue(-30e6)
|
||||
form._epsc0.setValue(-0.002)
|
||||
form._epscu.setValue(-0.005)
|
||||
form._Ec.setValue(30e9)
|
||||
form._fct.setValue(3.0e6)
|
||||
form._et.setValue(1e-4)
|
||||
form._material_id = 2
|
||||
|
||||
mat = form.read()
|
||||
assert isinstance(mat, Concrete04)
|
||||
assert mat.fct == pytest.approx(3.0e6)
|
||||
assert mat.et == pytest.approx(1e-4)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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,
|
||||
)
|
||||
|
||||
form = Concrete04Form()
|
||||
qtbot.addWidget(form)
|
||||
form.populate(original)
|
||||
|
||||
recovered = form.read()
|
||||
assert recovered.id == original.id
|
||||
assert recovered.name == original.name
|
||||
assert recovered.fpc == pytest.approx(original.fpc)
|
||||
assert recovered.epsc0 == pytest.approx(original.epsc0)
|
||||
assert recovered.epscu == pytest.approx(original.epscu)
|
||||
assert recovered.Ec == pytest.approx(original.Ec)
|
||||
assert recovered.fct == pytest.approx(original.fct)
|
||||
assert recovered.et == pytest.approx(original.et)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete04_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""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
|
||||
assert form._fpc.value() == pytest.approx(-6.0)
|
||||
assert form._Ec.value() == pytest.approx(3600.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete04_spinboxes_use_c_locale(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
form = Concrete04Form()
|
||||
qtbot.addWidget(form)
|
||||
assert form._epsc0.locale().decimalPoint() == "."
|
||||
assert form._fpc.locale().decimalPoint() == "."
|
||||
155
tests/gui/test_material_form_ranges.py
Normal file
155
tests/gui/test_material_form_ranges.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""Material form spin-box ranges must accept any consistent unit system.
|
||||
|
||||
Regression: the old Concrete01 form capped ``fpc`` at -1e3 (Pa),
|
||||
silently clamping kip-in / ksi users' ``-6`` input to ``-1000``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.views.dialogs.material_forms import ( # noqa: E402
|
||||
Concrete01Form,
|
||||
Concrete02Form,
|
||||
Steel01Form,
|
||||
Steel02Form,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete01_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""User types -6 (ksi) → spinbox stores -6, not clamped to -1000."""
|
||||
form = Concrete01Form()
|
||||
qtbot.addWidget(form)
|
||||
form._fpc.setValue(-6.0)
|
||||
form._epsc0.setValue(-0.004)
|
||||
form._fpcu.setValue(-5.0)
|
||||
form._epsU.setValue(-0.014)
|
||||
|
||||
assert form._fpc.value() == pytest.approx(-6.0)
|
||||
assert form._epsc0.value() == pytest.approx(-0.004)
|
||||
assert form._fpcu.value() == pytest.approx(-5.0)
|
||||
assert form._epsU.value() == pytest.approx(-0.014)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete01_still_accepts_si_pa_values(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
form = Concrete01Form()
|
||||
qtbot.addWidget(form)
|
||||
form._fpc.setValue(-30e6)
|
||||
form._fpcu.setValue(-5e6)
|
||||
assert form._fpc.value() == pytest.approx(-30e6)
|
||||
assert form._fpcu.value() == pytest.approx(-5e6)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete02_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
form = Concrete02Form()
|
||||
qtbot.addWidget(form)
|
||||
form._fpc.setValue(-6.0)
|
||||
form._fpcu.setValue(-5.0)
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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._b.setValue(0.01)
|
||||
assert form._fy.value() == pytest.approx(60.0)
|
||||
assert form._e0.value() == pytest.approx(30000.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_steel02_accepts_ksi_values(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
form = Steel02Form()
|
||||
qtbot.addWidget(form)
|
||||
form._fy.setValue(60.0)
|
||||
form._e0.setValue(30000.0)
|
||||
assert form._fy.value() == pytest.approx(60.0)
|
||||
assert form._e0.value() == pytest.approx(30000.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_spinbox_uses_c_locale_for_decimal_separator(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Spinboxes must force '.' as the decimal separator regardless of
|
||||
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() == "."
|
||||
assert form._fpc.locale().decimalPoint() == "."
|
||||
# Even if we fake a Turkish default locale, the spinbox's own
|
||||
# locale stays C.
|
||||
QLocale.setDefault(QLocale(QLocale.Language.Turkish))
|
||||
try:
|
||||
form2 = Concrete01Form()
|
||||
qtbot.addWidget(form2)
|
||||
assert form2._epsc0.locale().decimalPoint() == "."
|
||||
finally:
|
||||
QLocale.setDefault(QLocale(QLocale.Language.C))
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete01_epsc0_accepts_four_thousandths(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Regression: the user's -0.004 must stick exactly after setValue."""
|
||||
form = Concrete01Form()
|
||||
qtbot.addWidget(form)
|
||||
form._epsc0.setValue(-0.004)
|
||||
assert form._epsc0.value() == pytest.approx(-0.004, abs=1e-12)
|
||||
form._epsU.setValue(-0.014)
|
||||
assert form._epsU.value() == pytest.approx(-0.014, abs=1e-12)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_spinbox_keyboard_tracking_is_off(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""With keyboardTracking=False, typing '-0.004' character-by-character
|
||||
doesn't clamp intermediate values to the range. The final commit
|
||||
happens on Enter / focus-out, preserving the user's input."""
|
||||
form = Concrete01Form()
|
||||
qtbot.addWidget(form)
|
||||
assert not form._epsc0.keyboardTracking()
|
||||
assert not form._fpc.keyboardTracking()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete01_strain_range_allows_zero_max(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""The spin-box max for strain fields is 0 (Pydantic rejects 0
|
||||
at commit time); any intermediate typing value stays in range."""
|
||||
form = Concrete01Form()
|
||||
qtbot.addWidget(form)
|
||||
assert form._epsc0.maximum() == pytest.approx(0.0)
|
||||
assert form._epsU.maximum() == pytest.approx(0.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete01_round_trip_preserves_ksi_values(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Form → Concrete01 model → form round-trip keeps ksi values intact."""
|
||||
form = Concrete01Form()
|
||||
qtbot.addWidget(form)
|
||||
form._fpc.setValue(-6.0)
|
||||
form._epsc0.setValue(-0.004)
|
||||
form._fpcu.setValue(-5.0)
|
||||
form._epsU.setValue(-0.014)
|
||||
form._name_edit.setText("Core-Conc")
|
||||
form._material_id = 1
|
||||
mat = form.read()
|
||||
assert mat.fpc == pytest.approx(-6.0)
|
||||
assert mat.epsc0 == pytest.approx(-0.004)
|
||||
assert mat.fpcu == pytest.approx(-5.0)
|
||||
assert mat.epsU == pytest.approx(-0.014)
|
||||
# Round-trip through a fresh form.
|
||||
form2 = Concrete01Form()
|
||||
qtbot.addWidget(form2)
|
||||
form2.populate(mat)
|
||||
assert form2._fpc.value() == pytest.approx(-6.0)
|
||||
assert form2._fpcu.value() == pytest.approx(-5.0)
|
||||
99
tests/gui/test_material_tester_dialog.py
Normal file
99
tests/gui/test_material_tester_dialog.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""GUI tests for the Material Tester dialog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
pytest.importorskip("openseespy.opensees")
|
||||
|
||||
from otko.core import ElasticUniaxial
|
||||
from otko.viewmodels import ProjectViewModel
|
||||
from otko.views.dialogs.material_tester import MaterialTesterDialog
|
||||
|
||||
|
||||
def _make_vm() -> ProjectViewModel:
|
||||
"""View-model with one elastic uniaxial material to probe."""
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
assert vm.project is not None
|
||||
vm.project.materials.append(ElasticUniaxial(id=1, name="TestMat", E=200e9))
|
||||
return vm
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_monotonic_run_plots_service_result(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _make_vm()
|
||||
dlg = MaterialTesterDialog(vm)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
dlg._comp_spin.setValue(-0.005)
|
||||
dlg._tens_spin.setValue(0.005)
|
||||
dlg._steps_spin.setValue(5)
|
||||
|
||||
protocol = dlg.build_protocol()
|
||||
assert protocol.kind == "monotonic"
|
||||
|
||||
result = dlg.run_test()
|
||||
assert result is not None
|
||||
assert len(result.strain) == 10 # 2 branches x 5 steps
|
||||
assert len(result.stress) == len(result.strain)
|
||||
assert dlg.last_result is result
|
||||
|
||||
# NOTE: getOriginalDataset, not getData — pyqtgraph >= 0.14 clips the
|
||||
# *display* dataset to +/- dynamicRangeLimit (1e6) x view height, and
|
||||
# the view never autoranges in a hidden (headless) widget.
|
||||
x, y = dlg._curve.getOriginalDataset()
|
||||
assert list(x) == pytest.approx(result.strain)
|
||||
assert list(y) == pytest.approx(result.stress)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_cyclic_protocol_builds_segment_and_runs(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _make_vm()
|
||||
dlg = MaterialTesterDialog(vm)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
assert not dlg._cycles_spin.isEnabled()
|
||||
dlg._kind_cb.setCurrentIndex(1)
|
||||
assert dlg._cycles_spin.isEnabled()
|
||||
|
||||
dlg._comp_spin.setValue(-0.004)
|
||||
dlg._tens_spin.setValue(0.004)
|
||||
dlg._steps_spin.setValue(4)
|
||||
dlg._cycles_spin.setValue(2)
|
||||
|
||||
protocol = dlg.build_protocol()
|
||||
assert protocol.kind == "cyclic"
|
||||
assert protocol.cycles is not None and len(protocol.cycles) == 1
|
||||
assert protocol.cycles[0].n_cycles == 2
|
||||
|
||||
result = dlg.run_test()
|
||||
assert result is not None
|
||||
assert len(result.strain) == 2 * 3 * 4 # cycles x branches x steps
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_cyclic_without_tensile_peak_shows_message(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _make_vm()
|
||||
dlg = MaterialTesterDialog(vm)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
dlg._kind_cb.setCurrentIndex(1)
|
||||
dlg._tens_spin.setValue(0.0)
|
||||
|
||||
assert dlg.run_test() is None
|
||||
assert "tensile" in dlg._message.text().lower()
|
||||
assert dlg.last_result is None
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_empty_project_disables_run(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
dlg = MaterialTesterDialog(vm)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
assert not dlg._run_btn.isEnabled()
|
||||
assert dlg.selected_material() is None
|
||||
assert dlg.run_test() is None
|
||||
161
tests/gui/test_path_ts_dialogs.py
Normal file
161
tests/gui/test_path_ts_dialogs.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""GUI tests for PathTimeSeries + UniformExcitation dialogs + commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.commands import ( # noqa: E402
|
||||
AddLoadPatternCommand,
|
||||
AddTimeSeriesCommand,
|
||||
)
|
||||
from otko.core import ( # noqa: E402
|
||||
Node,
|
||||
PathTimeSeries,
|
||||
Project,
|
||||
UniformExcitationPattern,
|
||||
)
|
||||
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||
|
||||
|
||||
# ─────────────── AddTimeSeriesCommand / AddLoadPatternCommand ──────────────
|
||||
@pytest.mark.gui
|
||||
def test_add_time_series_command_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = ProjectViewModel()
|
||||
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]
|
||||
vm.undo_stack.undo()
|
||||
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]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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],
|
||||
)
|
||||
)
|
||||
ts2 = PathTimeSeries(id=1, name="Duplicate", dt=0.01, values=[0.0])
|
||||
with pytest.raises(ValueError):
|
||||
vm.apply_command(AddTimeSeriesCommand(vm, ts2))
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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],
|
||||
)
|
||||
)
|
||||
pat = UniformExcitationPattern(
|
||||
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]
|
||||
vm.undo_stack.undo()
|
||||
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
|
||||
# field (matches what _on_import_plain does after file read).
|
||||
dlg._values = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||
dlg._name_edit.setText("GM-test")
|
||||
dlg._dt_spin.setValue(0.02)
|
||||
dlg._factor_spin.setValue(386.4)
|
||||
ts = dlg.time_series()
|
||||
assert ts.id == 1
|
||||
assert ts.name == "GM-test"
|
||||
assert ts.dt == pytest.approx(0.02)
|
||||
assert ts.factor == pytest.approx(386.4)
|
||||
assert ts.values == pytest.approx([0.1, 0.2, 0.3, 0.4, 0.5])
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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("""\
|
||||
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)
|
||||
ts = dlg.time_series()
|
||||
assert ts.dt == pytest.approx(0.025)
|
||||
assert ts.values == pytest.approx([0.01, -0.02, 0.03])
|
||||
|
||||
|
||||
# ─────────────── UniformExcitationDialog ──────────────
|
||||
@pytest.mark.gui
|
||||
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)
|
||||
assert not dlg._accel_cb.isEnabled()
|
||||
|
||||
|
||||
@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]),
|
||||
]
|
||||
)
|
||||
dlg = UniformExcitationDialog(project=proj, next_pattern_id=2)
|
||||
qtbot.addWidget(dlg)
|
||||
# Pick direction = 1 (X) — already default.
|
||||
dlg._name_edit.setText("Earthquake-X")
|
||||
dlg._factor_spin.setValue(1.0)
|
||||
pat = dlg.pattern()
|
||||
assert pat.id == 2
|
||||
assert pat.name == "Earthquake-X"
|
||||
assert pat.direction == 1
|
||||
assert pat.accel_series_id == 7
|
||||
assert pat.factor == pytest.approx(1.0)
|
||||
212
tests/gui/test_pattern_load_audit.py
Normal file
212
tests/gui/test_pattern_load_audit.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
"""Pattern rows in the Model Explorer list every load that was assigned.
|
||||
|
||||
Before this change a user could apply nodal and element loads through
|
||||
the Assign dialogs but the Patterns tree rows only listed the pattern
|
||||
itself — making it impossible to audit stored loads without re-running
|
||||
the analysis. This test pins down the fix:
|
||||
|
||||
- Each ``PlainLoadPattern`` row in the tree expands with one child per
|
||||
stored load, summarising the non-zero components.
|
||||
- Children carry no ``UserRole`` payload, so selecting them does NOT
|
||||
touch the canvas selection.
|
||||
- The ``PatternLoadsDialog`` shows both loads in full when opened via
|
||||
the double-click handler wired to ``_on_tree_double_clicked``.
|
||||
|
||||
A second test covers ``UniformExcitationPattern`` to make sure the
|
||||
dialog handles the load-less case without crashing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from otko.core import (
|
||||
ElasticUniaxial,
|
||||
LinearTimeSeries,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
TrussElement,
|
||||
UniformElementLoad,
|
||||
UniformExcitationPattern,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_pattern_rows_list_nodal_and_element_loads(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Each load gets its own child row under its parent pattern row."""
|
||||
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=(1, 0, 0)),
|
||||
]
|
||||
)
|
||||
mw._vm.project.materials.append(ElasticUniaxial(id=1, name="Steel", E=200e9))
|
||||
mw._vm.project.elements.append(
|
||||
TrussElement(id=5, nodes=(1, 2), material_id=1, area=0.01),
|
||||
)
|
||||
mw._vm.project.time_series.append(LinearTimeSeries(id=1, name="Gravity"))
|
||||
pat = PlainLoadPattern(id=1, name="Gravity", time_series_id=1)
|
||||
pat.nodal_loads.append(
|
||||
NodalLoad(node_id=1, forces=(10.0, 0.0, -5.0, 0.0, 0.0, 0.0)),
|
||||
)
|
||||
pat.element_loads.append(
|
||||
UniformElementLoad(element_id=5, wy=-2.0, wz=0.0, wx=0.0),
|
||||
)
|
||||
mw._vm.project.load_patterns.append(pat)
|
||||
mw._refresh_tree(mw._vm.project)
|
||||
|
||||
patterns_cat = mw._tree_categories["Patterns"]
|
||||
patterns_cat.setExpanded(True)
|
||||
pat_row = patterns_cat.child(0)
|
||||
assert pat_row is not None
|
||||
# Pattern row keeps its existing label format + (kind, id) payload.
|
||||
assert pat_row.text(0).startswith("#1")
|
||||
assert pat_row.text(0).endswith("Gravity")
|
||||
assert pat_row.data(0, Qt.ItemDataRole.UserRole) == ("Patterns", 1)
|
||||
|
||||
# One child per stored load — both rows present and ordered.
|
||||
assert pat_row.childCount() == 2
|
||||
child_texts = [pat_row.child(i).text(0) for i in range(pat_row.childCount())]
|
||||
nodal_text = next(t for t in child_texts if t.startswith("Node #1:"))
|
||||
element_text = next(t for t in child_texts if t.startswith("Element #5:"))
|
||||
assert "Fx=10" in nodal_text
|
||||
assert "Fz=-5" in nodal_text
|
||||
assert "Fy=0" not in nodal_text # zero components are filtered out
|
||||
assert "Mx=" not in nodal_text
|
||||
assert "wy=-2" in element_text
|
||||
assert "wx=" not in element_text # zero components are filtered out
|
||||
|
||||
# Children carry no payload, so selection sync leaves the canvas untouched.
|
||||
for i in range(pat_row.childCount()):
|
||||
child = pat_row.child(i)
|
||||
assert child.data(0, Qt.ItemDataRole.UserRole) is None
|
||||
|
||||
mw._canvas.selection.clear()
|
||||
pat_row.child(0).setSelected(True)
|
||||
pat_row.child(1).setSelected(True)
|
||||
assert mw._canvas.selection.nodes == frozenset()
|
||||
assert mw._canvas.selection.elements == frozenset()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_double_click_pattern_row_opens_summary_dialog(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""The double-click handler must find the pattern and pop the dialog."""
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
mw = MainWindow()
|
||||
qtbot.addWidget(mw)
|
||||
mw._vm.new_project(ndm=2, ndf=3)
|
||||
mw._vm.project.nodes.append(Node(id=3, coords=(0, 0, 0)))
|
||||
mw._vm.project.time_series.append(LinearTimeSeries(id=1, name="Gravity"))
|
||||
pat = PlainLoadPattern(id=2, name="RefMoment", time_series_id=1)
|
||||
pat.nodal_loads.append(
|
||||
NodalLoad(node_id=3, forces=(0.0, 0.0, 0.0, 0.0, 0.0, 1.5)),
|
||||
)
|
||||
pat.element_loads.append(
|
||||
UniformElementLoad(element_id=7, wy=-2.0, wz=0.5, wx=0.0),
|
||||
)
|
||||
mw._vm.project.load_patterns.append(pat)
|
||||
mw._refresh_tree(mw._vm.project)
|
||||
|
||||
# Replace the real exec() with a recorder so the test stays headless-safe.
|
||||
from otko.views import dialogs as dialogs_pkg
|
||||
|
||||
captured: list[object] = []
|
||||
real_exec = dialogs_pkg.PatternLoadsDialog.exec
|
||||
|
||||
def fake_exec(self) -> int: # type: ignore[no-untyped-def]
|
||||
captured.append(self)
|
||||
return 0 # QDialog.Rejected
|
||||
|
||||
dialogs_pkg.PatternLoadsDialog.exec = fake_exec # type: ignore[assignment]
|
||||
try:
|
||||
patterns_cat = mw._tree_categories["Patterns"]
|
||||
pat_row = patterns_cat.child(0)
|
||||
assert pat_row is not None
|
||||
mw._on_tree_double_clicked(pat_row, 0)
|
||||
finally:
|
||||
dialogs_pkg.PatternLoadsDialog.exec = real_exec # type: ignore[assignment]
|
||||
|
||||
assert len(captured) == 1
|
||||
dlg = captured[0]
|
||||
# Both tables populated, with one row each, matching the project.
|
||||
assert dlg._nodal_table is not None
|
||||
assert dlg._nodal_table.rowCount() == 1
|
||||
assert dlg._nodal_table.item(0, 0).text() == "3"
|
||||
assert dlg._nodal_table.item(0, 6).text() == "1.5" # Mz column
|
||||
assert dlg._element_table is not None
|
||||
assert dlg._element_table.rowCount() == 1
|
||||
assert dlg._element_table.item(0, 0).text() == "7"
|
||||
assert dlg._element_table.item(0, 2).text() == "-2" # wy column
|
||||
assert dlg._element_table.item(0, 3).text() == "0.5" # wz column
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_dialog_handles_empty_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""An empty PlainLoadPattern still opens — both tables show '(none)'."""
|
||||
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
||||
|
||||
pat = PlainLoadPattern(id=9, name="Empty", time_series_id=1)
|
||||
dlg = PatternLoadsDialog(pat)
|
||||
qtbot.addWidget(dlg)
|
||||
# Tables are never instantiated when there are no loads.
|
||||
assert dlg._nodal_table is None
|
||||
assert dlg._element_table is None
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_dialog_handles_uniform_excitation(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""UniformExcitationPattern has no loads — dialog must not crash."""
|
||||
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
||||
|
||||
pat = UniformExcitationPattern(
|
||||
id=3,
|
||||
name="EQ",
|
||||
direction=1,
|
||||
accel_series_id=1,
|
||||
factor=1.0,
|
||||
)
|
||||
dlg = PatternLoadsDialog(pat)
|
||||
qtbot.addWidget(dlg)
|
||||
assert dlg._nodal_table is None
|
||||
assert dlg._element_table is None
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_uniform_excitation_row_gets_info_child(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""UniformExcitation patterns expand with a single informational child."""
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
mw = MainWindow()
|
||||
qtbot.addWidget(mw)
|
||||
mw._vm.new_project(ndm=2, ndf=3)
|
||||
mw._vm.project.time_series.append(LinearTimeSeries(id=1, name="EQ"))
|
||||
mw._vm.project.load_patterns.append(
|
||||
UniformExcitationPattern(
|
||||
id=1,
|
||||
name="Ground",
|
||||
direction=1,
|
||||
accel_series_id=1,
|
||||
)
|
||||
)
|
||||
mw._refresh_tree(mw._vm.project)
|
||||
|
||||
patterns_cat = mw._tree_categories["Patterns"]
|
||||
patterns_cat.setExpanded(True)
|
||||
pat_row = patterns_cat.child(0)
|
||||
assert pat_row is not None
|
||||
assert pat_row.childCount() == 1
|
||||
info = pat_row.child(0).text(0)
|
||||
assert "dir=1" in info
|
||||
assert "series=#1" in info
|
||||
assert pat_row.child(0).data(0, Qt.ItemDataRole.UserRole) is None
|
||||
211
tests/gui/test_property_commands.py
Normal file
211
tests/gui/test_property_commands.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""Unit tests for property assignment and material/section update commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.commands import ( # noqa: E402
|
||||
AddElementsCommand,
|
||||
AddMaterialsCommand,
|
||||
AddNodesCommand,
|
||||
AddSectionsCommand,
|
||||
AssignMaterialCommand,
|
||||
AssignSectionCommand,
|
||||
UpdateMaterialCommand,
|
||||
UpdateSectionCommand,
|
||||
)
|
||||
from otko.core import ( # noqa: E402
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
Node,
|
||||
Steel01,
|
||||
Steel02,
|
||||
TrussElement,
|
||||
)
|
||||
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
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
# ──────────────────────── UpdateMaterialCommand ────────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_update_material_changes_parameters(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_steel()
|
||||
new = Steel01(id=1, name="S420 (revised)", Fy=500e6, E0=210e9, b=0.02)
|
||||
vm.apply_command(UpdateMaterialCommand(vm, new))
|
||||
m = vm.project.material(1)
|
||||
assert m.Fy == 500e6
|
||||
assert m.b == 0.02
|
||||
assert m.name == "S420 (revised)"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_update_material_undo_restores_original(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_steel()
|
||||
new = Steel01(id=1, Fy=500e6, E0=200e9, b=0.02)
|
||||
vm.apply_command(UpdateMaterialCommand(vm, new))
|
||||
vm.undo_stack.undo()
|
||||
m = vm.project.material(1)
|
||||
assert m.Fy == 420e6
|
||||
assert m.b == 0.01
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_update_material_can_change_type(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_steel()
|
||||
# Same id, different concrete class — discriminator-driven swap.
|
||||
swapped = Steel02(id=1, name="Switched", Fy=355e6, E0=210e9, b=0.005)
|
||||
vm.apply_command(UpdateMaterialCommand(vm, swapped))
|
||||
assert isinstance(vm.project.material(1), Steel02)
|
||||
vm.undo_stack.undo()
|
||||
assert isinstance(vm.project.material(1), Steel01)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_update_unknown_id_raises(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_steel()
|
||||
bogus = Steel01(id=99, Fy=420e6, E0=200e9, b=0.01)
|
||||
with pytest.raises(KeyError):
|
||||
vm.apply_command(UpdateMaterialCommand(vm, bogus))
|
||||
|
||||
|
||||
# ──────────────────────── UpdateSectionCommand ────────────────────────
|
||||
@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)
|
||||
vm.apply_command(UpdateSectionCommand(vm, new))
|
||||
s = vm.project.section(1)
|
||||
assert s.A == 0.02
|
||||
assert s.Iz == 2e-5
|
||||
vm.undo_stack.undo()
|
||||
assert vm.project.section(1).A == 0.01
|
||||
|
||||
|
||||
# ──────────────────────── AssignSectionCommand ────────────────────────
|
||||
@pytest.mark.gui
|
||||
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(AssignSectionCommand(vm, {1, 2}, section_id=2))
|
||||
assert vm.project.element(1).section_id == 2
|
||||
assert vm.project.element(2).section_id == 2
|
||||
vm.undo_stack.undo()
|
||||
assert vm.project.element(1).section_id == 1
|
||||
assert vm.project.element(2).section_id == 1
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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(AssignSectionCommand(vm, {1, 2}, section_id=1))
|
||||
# Frame ok, truss unchanged (still material-based).
|
||||
assert vm.project.element(2).section_id == 1
|
||||
assert isinstance(vm.project.element(1), TrussElement)
|
||||
|
||||
|
||||
# ──────────────────────── AssignMaterialCommand ────────────────────────
|
||||
@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(AssignMaterialCommand(vm, {1, 2}, material_id=2))
|
||||
assert vm.project.element(1).material_id == 2
|
||||
assert vm.project.element(2).material_id == 2
|
||||
vm.undo_stack.undo()
|
||||
assert vm.project.element(1).material_id == 1
|
||||
|
||||
|
||||
@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(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
|
||||
assert isinstance(vm.project.element(1), ElasticBeamColumn)
|
||||
130
tests/gui/test_property_forms.py
Normal file
130
tests/gui/test_property_forms.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Unit tests for material and section parameter forms.
|
||||
|
||||
Each form must satisfy a round-trip property: ``form.populate(m); m2 = form.read()``
|
||||
yields ``m2 == m`` (modulo any None defaults the form's spinbox can't represent).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
Concrete01,
|
||||
Concrete02,
|
||||
ElasticIsotropic,
|
||||
ElasticPP,
|
||||
ElasticSection,
|
||||
ElasticUniaxial,
|
||||
Steel01,
|
||||
Steel02,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_steel01_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.dialogs.material_forms import Steel01Form
|
||||
|
||||
original = Steel01(id=7, name="MyS420", Fy=420e6, E0=200e9, b=0.015)
|
||||
form = Steel01Form()
|
||||
form.populate(original)
|
||||
restored = form.read()
|
||||
assert restored == original
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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)
|
||||
form = Steel02Form()
|
||||
form.populate(original)
|
||||
restored = form.read()
|
||||
assert restored == original
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_concrete02_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
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},
|
||||
)
|
||||
form = Concrete02Form()
|
||||
form.populate(original)
|
||||
restored = form.read()
|
||||
assert restored == original
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_elastic_isotropic_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.dialogs.material_forms import ElasticIsotropicForm
|
||||
|
||||
original = ElasticIsotropic(id=1, name="Steel", E=200e9, nu=0.3, rho=7850.0)
|
||||
form = ElasticIsotropicForm()
|
||||
form.populate(original)
|
||||
restored = form.read()
|
||||
assert restored == original
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_elastic_section_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
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,
|
||||
)
|
||||
form = ElasticSectionForm()
|
||||
form.populate(original)
|
||||
restored = form.read()
|
||||
assert restored == original
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_form_for_dispatches_by_type(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.dialogs.material_forms import (
|
||||
Steel01Form,
|
||||
form_for as material_form_for,
|
||||
)
|
||||
from otko.views.dialogs.section_forms import (
|
||||
ElasticSectionForm,
|
||||
form_for as section_form_for,
|
||||
)
|
||||
|
||||
s = Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)
|
||||
f = material_form_for(s)
|
||||
assert isinstance(f, Steel01Form)
|
||||
|
||||
es = ElasticSection(id=1, E=200e9, A=0.01, Iz=1e-5)
|
||||
f2 = section_form_for(es)
|
||||
assert isinstance(f2, ElasticSectionForm)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_unknown_material_type_raises(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""form_for raises KeyError for unsupported types."""
|
||||
from otko.views.dialogs.material_forms import form_for
|
||||
|
||||
class FakeMat:
|
||||
type = "Unobtanium"
|
||||
id = 1
|
||||
name = ""
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
form_for(FakeMat())
|
||||
107
tests/gui/test_pushover_curve_units.py
Normal file
107
tests/gui/test_pushover_curve_units.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Pushover curve axis labels must follow the project's unit system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import UnitSystem # noqa: E402
|
||||
from otko.services.results import PushoverResults # noqa: E402
|
||||
from otko.views.docks.pushover_curve import ( # noqa: E402
|
||||
PushoverCurveView,
|
||||
_is_rotation_dof,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
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]),
|
||||
)
|
||||
|
||||
|
||||
def test_is_rotation_dof_ndf3() -> None:
|
||||
"""In a 2D/ndf=3 model, only DOF 3 is rotational."""
|
||||
assert _is_rotation_dof(1, ndf=3) is False
|
||||
assert _is_rotation_dof(2, ndf=3) is False
|
||||
assert _is_rotation_dof(3, ndf=3) is True
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_si_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
v = PushoverCurveView(units=UnitSystem.SI_M_N, ndf=6)
|
||||
qtbot.addWidget(v)
|
||||
v.set_results(_pushover(control_dof=1))
|
||||
assert "m" in v._plot.getAxis("bottom").labelText
|
||||
assert "Displacement" in v._plot.getAxis("bottom").labelText
|
||||
assert "N" in v._plot.getAxis("left").labelText
|
||||
assert "Base shear" in v._plot.getAxis("left").labelText
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_si_rotation_labels_show_curvature_and_moment(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
v = PushoverCurveView(units=UnitSystem.SI_M_N, ndf=3)
|
||||
qtbot.addWidget(v)
|
||||
v.set_results(_pushover(control_dof=3))
|
||||
assert "1/m" in v._plot.getAxis("bottom").labelText
|
||||
assert "Curvature" in v._plot.getAxis("bottom").labelText
|
||||
assert "N·m" in v._plot.getAxis("left").labelText
|
||||
assert "Moment" in v._plot.getAxis("left").labelText
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_us_in_kip_rotation_labels(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""kip-in Moment-Curvature project must NOT show cm / kN anywhere."""
|
||||
v = PushoverCurveView(units=UnitSystem.US_IN_KIP, ndf=3)
|
||||
qtbot.addWidget(v)
|
||||
v.set_results(_pushover(control_dof=3))
|
||||
x_label = v._plot.getAxis("bottom").labelText
|
||||
y_label = v._plot.getAxis("left").labelText
|
||||
assert "1/in" in x_label
|
||||
assert "kip·in" in y_label
|
||||
assert "cm" not in x_label
|
||||
assert "kN" not in y_label
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_us_in_kip_translation_labels(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
v = PushoverCurveView(units=UnitSystem.US_IN_KIP, ndf=3)
|
||||
qtbot.addWidget(v)
|
||||
v.set_results(_pushover(control_dof=1))
|
||||
assert "in" in v._plot.getAxis("bottom").labelText
|
||||
assert "kip" in v._plot.getAxis("left").labelText
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_no_auto_scaling_applied_to_values(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Regression: earlier versions divided base_shear by 1000 to
|
||||
display kN. The values must now be drawn exactly as stored so
|
||||
kip-in users don't see nonsense scaling."""
|
||||
v = PushoverCurveView(units=UnitSystem.US_IN_KIP, ndf=3)
|
||||
qtbot.addWidget(v)
|
||||
r = _pushover(control_dof=3)
|
||||
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")]
|
||||
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).
|
||||
np.testing.assert_allclose(xs, r.control_disp)
|
||||
np.testing.assert_allclose(ys, r.base_shear)
|
||||
76
tests/gui/test_run_analysis.py
Normal file
76
tests/gui/test_run_analysis.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""GUI tests for the Run Analysis dialog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from PySide6.QtCore import QObject, Signal # noqa: E402
|
||||
|
||||
from otko.core import LinearTimeSeries, PlainLoadPattern, Project, TransientCase # noqa: E402
|
||||
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||
from otko.views.dialogs.run_analysis import RunAnalysisDialog # noqa: E402
|
||||
|
||||
|
||||
class _FakeRunner(QObject):
|
||||
started = Signal()
|
||||
log = Signal(str)
|
||||
finished = Signal(object)
|
||||
failed = Signal(str)
|
||||
runningChanged = Signal(bool)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.is_running = False
|
||||
self.last_project = None
|
||||
self.last_case = None
|
||||
self.last_results_dir = None
|
||||
|
||||
def run(self, project, case, results_dir=None) -> None: # type: ignore[no-untyped-def]
|
||||
self.last_project = project
|
||||
self.last_case = case
|
||||
self.last_results_dir = results_dir
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_run_dialog_applies_transient_damping_overrides(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
vm = ProjectViewModel()
|
||||
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._path = Path(tmp_path) / "demo.osmodel" # type: ignore[attr-defined]
|
||||
|
||||
runner = _FakeRunner()
|
||||
dlg = RunAnalysisDialog(vm, runner)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
dlg._case_combo.setCurrentIndex(0)
|
||||
assert dlg._alpha_m.value() == pytest.approx(0.1)
|
||||
assert dlg._beta_k.value() == pytest.approx(0.002)
|
||||
assert dlg._mode1_damping.value() == pytest.approx(0.02)
|
||||
|
||||
dlg._alpha_m.setValue(0.3)
|
||||
dlg._beta_k.setValue(0.005)
|
||||
dlg._mode1_damping.setValue(0.05)
|
||||
dlg._on_run()
|
||||
|
||||
assert runner.last_project is vm.project
|
||||
assert runner.last_case.rayleigh_alpha_m == pytest.approx(0.3)
|
||||
assert runner.last_case.rayleigh_beta_k == pytest.approx(0.005)
|
||||
assert runner.last_case.rayleigh_mode1_damping == pytest.approx(0.05)
|
||||
assert runner.last_results_dir == tmp_path / "demo_results"
|
||||
86
tests/gui/test_section_extrusion.py
Normal file
86
tests/gui/test_section_extrusion.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Smoke tests for the Show Extruded Sections overlay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
ElasticBeamColumn,
|
||||
ElasticSection,
|
||||
Node,
|
||||
Project,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
return Project(
|
||||
nodes=[
|
||||
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,
|
||||
)
|
||||
],
|
||||
elements=[
|
||||
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@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())
|
||||
|
||||
before = len(canvas._renderer._aux_actors)
|
||||
canvas.set_show_section_extrusions(True)
|
||||
after_on = len(canvas._renderer._aux_actors)
|
||||
assert after_on > before, "Extrusion actor should appear after toggling on"
|
||||
|
||||
canvas.set_show_section_extrusions(False)
|
||||
after_off = len(canvas._renderer._aux_actors)
|
||||
assert after_off == before, "Extrusion actor must disappear when toggled off"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_extrusion_skips_elements_without_section(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""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(
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0), restraint=(True, True, True, True, True, True)),
|
||||
Node(id=2, coords=(3, 0, 0)),
|
||||
],
|
||||
materials=[ElasticUniaxial(id=1, E=200e9)],
|
||||
elements=[
|
||||
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
|
||||
],
|
||||
)
|
||||
canvas.show_project(p)
|
||||
canvas.set_show_section_extrusions(True)
|
||||
# No crash, no extra actor (truss has no section_id).
|
||||
# (Other aux actors like the grid might exist; only verify nothing blew up.)
|
||||
assert canvas._renderer._aux_actors is not None
|
||||
100
tests/gui/test_section_forms.py
Normal file
100
tests/gui/test_section_forms.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Regression: clicking a FiberSection / SectionAggregator row in the
|
||||
Section Library must not crash ``form_for`` with KeyError."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
AggregatorDOF,
|
||||
ElasticSection,
|
||||
FiberSection,
|
||||
RectangularPatch,
|
||||
SectionAggregator,
|
||||
)
|
||||
from otko.views.dialogs.section_forms import ( # noqa: E402
|
||||
FORM_REGISTRY,
|
||||
form_for,
|
||||
)
|
||||
|
||||
|
||||
def test_fiber_section_has_registered_form() -> None:
|
||||
assert "FiberSection" in FORM_REGISTRY
|
||||
|
||||
|
||||
def test_section_aggregator_has_registered_form() -> None:
|
||||
assert "SectionAggregator" in FORM_REGISTRY
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
f = form_for(s)
|
||||
qtbot.addWidget(f)
|
||||
assert f.type_label == "Elastic Section"
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
],
|
||||
)
|
||||
f = form_for(s)
|
||||
qtbot.addWidget(f)
|
||||
assert f.type_label == "Fiber Section"
|
||||
# read() round-trips without crashing.
|
||||
out = f.read(1)
|
||||
assert isinstance(out, FiberSection)
|
||||
assert len(out.patches) == 1
|
||||
|
||||
|
||||
@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,
|
||||
pairings=[AggregatorDOF(material_id=3, dof="T")],
|
||||
)
|
||||
f = form_for(s)
|
||||
qtbot.addWidget(f)
|
||||
assert f.type_label == "Section Aggregator"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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.
|
||||
assert f is not None
|
||||
70
tests/gui/test_selection.py
Normal file
70
tests/gui/test_selection.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Unit tests for the SelectionState QObject."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.views.canvas3d import SelectionState # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_initial_state_is_empty(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
s = SelectionState()
|
||||
assert s.is_empty
|
||||
assert s.nodes == frozenset()
|
||||
assert s.elements == frozenset()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_select_node_emits_signal(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
s = SelectionState()
|
||||
with qtbot.waitSignal(s.selectionChanged, timeout=500) as blocker:
|
||||
s.select_node(7)
|
||||
assert blocker.args[0] == frozenset({7})
|
||||
assert blocker.args[1] == frozenset()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_replacing_selection_clears_other_kind(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
s = SelectionState()
|
||||
s.select_node(1)
|
||||
s.select_element(5)
|
||||
assert s.nodes == frozenset()
|
||||
assert s.elements == frozenset({5})
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_additive_selection_keeps_both(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
s = SelectionState()
|
||||
s.select_node(1)
|
||||
s.select_node(2, additive=True)
|
||||
assert s.nodes == frozenset({1, 2})
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_toggle_node(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
s = SelectionState()
|
||||
s.toggle_node(3)
|
||||
assert 3 in s.nodes
|
||||
s.toggle_node(3)
|
||||
assert 3 not in s.nodes
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_clear_no_op_when_empty_does_not_emit(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
s = SelectionState()
|
||||
received: list[tuple] = []
|
||||
s.selectionChanged.connect(lambda n, e: received.append((n, e)))
|
||||
s.clear()
|
||||
assert received == []
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_clear_emits_when_non_empty(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
s = SelectionState()
|
||||
s.select_node(1)
|
||||
with qtbot.waitSignal(s.selectionChanged, timeout=500):
|
||||
s.clear()
|
||||
assert s.is_empty
|
||||
152
tests/gui/test_transforms.py
Normal file
152
tests/gui/test_transforms.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Unit tests for geometric transform commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.commands import ( # noqa: E402
|
||||
AddElementsCommand,
|
||||
AddNodesCommand,
|
||||
MirrorCommand,
|
||||
MoveNodesCommand,
|
||||
ReplicateCommand,
|
||||
)
|
||||
from otko.core import Node, Steel01, TrussElement # noqa: E402
|
||||
from otko.viewmodels import ProjectViewModel # noqa: E402
|
||||
|
||||
|
||||
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.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),
|
||||
],
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
# ──────────────────────────── Move ────────────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_move_translates_in_place(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _populated_vm()
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_move_undo_restores_original_coords(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _populated_vm()
|
||||
vm.apply_command(MoveNodesCommand(vm, {1}, (5.0, 0.0, 0.0)))
|
||||
assert vm.project.node(1).coords == (5.0, 0.0, 0.0)
|
||||
vm.undo_stack.undo()
|
||||
assert vm.project.node(1).coords == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_move_preserves_restraint(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _populated_vm()
|
||||
fix6 = (True,) * 6
|
||||
vm.apply_command(MoveNodesCommand(vm, {1}, (5.0, 0.0, 0.0)))
|
||||
assert vm.project.node(1).restraint == fix6
|
||||
|
||||
|
||||
# ──────────────────────────── Replicate ────────────────────────────
|
||||
@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)
|
||||
)
|
||||
# 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
|
||||
# Replica 1 sits at z=3, replica 2 at z=6.
|
||||
z_values = sorted({n.coords[2] for n in vm.project.nodes})
|
||||
assert z_values == [0.0, 3.0, 6.0]
|
||||
|
||||
|
||||
@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.undo_stack.undo()
|
||||
assert len(vm.project.nodes) == 4
|
||||
assert len(vm.project.elements) == 4
|
||||
# Original ids preserved.
|
||||
assert {n.id for n in vm.project.nodes} == {1, 2, 3, 4}
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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))
|
||||
# 2 new nodes + 1 new element (only element 1 was fully bracketed).
|
||||
assert len(vm.project.nodes) == 6
|
||||
assert len(vm.project.elements) == 5
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_replicate_zero_copies_rejected(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _populated_vm()
|
||||
with pytest.raises(ValueError):
|
||||
ReplicateCommand(vm, {1}, set(), offset=(0, 0, 1), n_copies=0)
|
||||
|
||||
|
||||
# ──────────────────────────── Mirror ────────────────────────────
|
||||
@pytest.mark.gui
|
||||
def test_mirror_yz_flips_x(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _populated_vm()
|
||||
vm.apply_command(MirrorCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4}, plane="YZ"))
|
||||
assert len(vm.project.nodes) == 8
|
||||
assert len(vm.project.elements) == 8
|
||||
# The mirrored copies should have negative x for original-non-zero x.
|
||||
new_node_ids = {n.id for n in vm.project.nodes} - {1, 2, 3, 4}
|
||||
new_xs = {vm.project.node(nid).coords[0] for nid in new_node_ids}
|
||||
assert -1.0 in new_xs
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_mirror_xy_flips_z(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _populated_vm()
|
||||
# Move the square up first so z=2 (mirroring across z=0 gives -2).
|
||||
vm.apply_command(MoveNodesCommand(vm, {1, 2, 3, 4}, (0, 0, 2.0)))
|
||||
vm.apply_command(MirrorCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4}, plane="XY"))
|
||||
new_node_ids = {n.id for n in vm.project.nodes} - {1, 2, 3, 4}
|
||||
new_zs = {vm.project.node(nid).coords[2] for nid in new_node_ids}
|
||||
assert new_zs == {-2.0}
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_mirror_undo_removes_copies(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _populated_vm()
|
||||
vm.apply_command(MirrorCommand(vm, {1, 2, 3, 4}, {1, 2, 3, 4}, plane="YZ"))
|
||||
vm.undo_stack.undo()
|
||||
assert len(vm.project.nodes) == 4
|
||||
assert len(vm.project.elements) == 4
|
||||
61
tests/gui/test_tree_multi_select.py
Normal file
61
tests/gui/test_tree_multi_select.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Model Tree supports Ctrl+click multi-selection — regression guard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from PySide6.QtCore import Qt # noqa: E402
|
||||
from PySide6.QtWidgets import QTreeWidget # noqa: E402
|
||||
|
||||
from otko.core import Node # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_tree_multi_select_syncs_canvas(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Selecting two node rows in the tree must populate the canvas
|
||||
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._refresh_tree(mw._vm.project)
|
||||
|
||||
nodes_cat = mw._tree_categories["Nodes"]
|
||||
nodes_cat.setExpanded(True)
|
||||
# Find the three child items and select rows 0 + 1.
|
||||
items = [nodes_cat.child(i) for i in range(nodes_cat.childCount())]
|
||||
assert len(items) == 3
|
||||
items[0].setSelected(True)
|
||||
items[1].setSelected(True)
|
||||
|
||||
assert mw._canvas.selection.nodes == frozenset({1, 2})
|
||||
assert mw._canvas.selection.elements == frozenset()
|
||||
|
||||
# Adding a third selection (row 2) keeps all three.
|
||||
items[2].setSelected(True)
|
||||
assert mw._canvas.selection.nodes == frozenset({1, 2, 3})
|
||||
|
||||
# Deselecting row 0 leaves rows 1 + 2.
|
||||
items[0].setSelected(False)
|
||||
assert mw._canvas.selection.nodes == frozenset({2, 3})
|
||||
57
tests/gui/test_units_status_bar.py
Normal file
57
tests/gui/test_units_status_bar.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Status-bar unit picker syncs with Project.meta.units."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@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()
|
||||
mw._vm.project.meta.units = UnitSystem.US_IN_KIP
|
||||
mw._sync_units_combo()
|
||||
assert mw._units_combo.currentData() == UnitSystem.US_IN_KIP
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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()
|
||||
# Default is SI_M_N — switch to US_IN_KIP via the combo.
|
||||
target_idx = mw._units_combo.findData(UnitSystem.US_IN_KIP)
|
||||
mw._units_combo.setCurrentIndex(target_idx)
|
||||
assert mw._vm.project.meta.units == UnitSystem.US_IN_KIP
|
||||
assert mw._vm.is_dirty
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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.
|
||||
mw._units_combo.setCurrentIndex(2)
|
||||
assert mw._vm.project is None
|
||||
99
tests/gui/test_update_element_fields.py
Normal file
99
tests/gui/test_update_element_fields.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Tests for UpdateElementFieldsCommand — inline scalar edits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.commands import ( # noqa: E402
|
||||
AddElementsCommand,
|
||||
AddMaterialsCommand,
|
||||
AddNodesCommand,
|
||||
UpdateElementFieldsCommand,
|
||||
)
|
||||
from otko.core import ( # noqa: E402
|
||||
ElasticUniaxial,
|
||||
Node,
|
||||
TrussElement,
|
||||
)
|
||||
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),
|
||||
],
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_update_area_on_truss_element(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_truss()
|
||||
vm.apply_command(UpdateElementFieldsCommand(vm, 1, {"area": 0.00645}))
|
||||
assert vm.project.element(1).area == pytest.approx(0.00645)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_update_area_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_truss()
|
||||
vm.apply_command(UpdateElementFieldsCommand(vm, 1, {"area": 0.005}))
|
||||
vm.undo_stack.undo()
|
||||
assert vm.project.element(1).area == pytest.approx(0.001)
|
||||
vm.undo_stack.redo()
|
||||
assert vm.project.element(1).area == pytest.approx(0.005)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
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
|
||||
)
|
||||
)
|
||||
# The element is unchanged.
|
||||
assert vm.project.element(1).area == pytest.approx(0.001)
|
||||
assert vm.project.element(1).material_id == 1
|
||||
|
||||
|
||||
@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},
|
||||
)
|
||||
)
|
||||
el = vm.project.element(1)
|
||||
assert el.area == pytest.approx(0.003)
|
||||
assert el.rho == pytest.approx(7850.0)
|
||||
107
tests/gui/test_viewport_axes.py
Normal file
107
tests/gui/test_viewport_axes.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Viewport orientation triad is present and survives every re-render path.
|
||||
|
||||
Before this change the 3D viewport had no X/Y/Z corner marker — users
|
||||
who rotated the camera off the default isometric view lost track of
|
||||
which world axis was which. The triad is now built once during the
|
||||
canvas's scene-furniture setup and lives in screen-space via PyVista's
|
||||
``vtkOrientationMarkerWidget``, so it is not affected by the regular
|
||||
actor rebuilds in :meth:`ModelRenderer.render` /
|
||||
:meth:`ModelRenderer._teardown_all`.
|
||||
|
||||
These tests pin the contract:
|
||||
|
||||
- Triad exists right after ``MainWindow`` construction.
|
||||
- It still exists after a tree refresh (which calls
|
||||
``ModelRenderer.render`` through ``MainWindow._on_project_changed``).
|
||||
- It still exists after loading an empty project, then a small project
|
||||
with a node — the most common real-world re-render path.
|
||||
- The triad carries the conventional X / Y / Z labels.
|
||||
- It is pinned to the lower-left viewport corner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import Node
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_axes_widget_exists_after_window_construction(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
mw = MainWindow()
|
||||
qtbot.addWidget(mw)
|
||||
aw = mw._canvas.renderer.axes_widget
|
||||
assert aw is not None
|
||||
# PyVista binds a vtkOrientationMarkerWidget; the orientation marker
|
||||
# it carries is a vtkAxesActor with X/Y/Z label text.
|
||||
actor = aw.GetOrientationMarker()
|
||||
assert actor.GetXAxisLabelText() == "X"
|
||||
assert actor.GetYAxisLabelText() == "Y"
|
||||
assert actor.GetZAxisLabelText() == "Z"
|
||||
# Pinned to the lower-left viewport patch (normalized 0..1 coords).
|
||||
vp = aw.GetViewport()
|
||||
assert vp[0] == pytest.approx(0.0)
|
||||
assert vp[1] == pytest.approx(0.0)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_axes_widget_survives_tree_refresh(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""``MainWindow._refresh_tree`` triggers a renderer rebuild via
|
||||
``_on_project_changed``; the orientation widget must stay put."""
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
mw = MainWindow()
|
||||
qtbot.addWidget(mw)
|
||||
aw_before = mw._canvas.renderer.axes_widget
|
||||
|
||||
mw._vm.new_project(ndm=3, ndf=6)
|
||||
mw._vm.project.nodes.extend(
|
||||
[
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(3, 0, 0)),
|
||||
]
|
||||
)
|
||||
mw._refresh_tree(mw._vm.project)
|
||||
|
||||
aw_after = mw._canvas.renderer.axes_widget
|
||||
# Same widget instance — proves the marker survived the rebuild.
|
||||
assert aw_after is aw_before
|
||||
actor = aw_after.GetOrientationMarker()
|
||||
assert actor.GetXAxisLabelText() == "X"
|
||||
assert actor.GetYAxisLabelText() == "Y"
|
||||
assert actor.GetZAxisLabelText() == "Z"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_axes_widget_survives_empty_then_loaded_project(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""The two re-render paths the engineer hits during a session:
|
||||
empty project → first node added → tree refreshed. The triad must
|
||||
still be in the lower-left corner with all three labels."""
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
mw = MainWindow()
|
||||
qtbot.addWidget(mw)
|
||||
aw_before = mw._canvas.renderer.axes_widget
|
||||
assert aw_before is not None
|
||||
|
||||
# Empty project — model renderer builds no actors, just the grid.
|
||||
mw._vm.new_project(ndm=3, ndf=6)
|
||||
mw._refresh_tree(mw._vm.project)
|
||||
assert mw._canvas.renderer.axes_widget is aw_before
|
||||
|
||||
# Small project with one node — exercises the node-actor rebuild path.
|
||||
mw._vm.project.nodes.append(Node(id=1, coords=(1, 2, 3)))
|
||||
mw._refresh_tree(mw._vm.project)
|
||||
assert mw._canvas.renderer.axes_widget is aw_before
|
||||
|
||||
# Same after a full canvas refresh too — show_project rebuilds actors.
|
||||
mw._canvas.show_project(mw._vm.project)
|
||||
assert mw._canvas.renderer.axes_widget is aw_before
|
||||
actor = mw._canvas.renderer.axes_widget.GetOrientationMarker()
|
||||
assert actor.GetXAxisLabelText() == "X"
|
||||
assert actor.GetYAxisLabelText() == "Y"
|
||||
assert actor.GetZAxisLabelText() == "Z"
|
||||
207
tests/gui/test_working_plane.py
Normal file
207
tests/gui/test_working_plane.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""Tests for the SAP2000-style working plane (plan / elevation level)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from otko.core import ( # noqa: E402
|
||||
CoordinateGridSystem,
|
||||
GridSystem,
|
||||
Project,
|
||||
make_grid_lines,
|
||||
)
|
||||
|
||||
|
||||
def _multi_z_project() -> Project:
|
||||
return 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, 3.0, 6.0]),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@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())
|
||||
assert canvas.working_plane_type() is None
|
||||
pts = canvas._grid_intersections_world()
|
||||
# 2 × 2 × 3 = 12 intersections
|
||||
assert pts is not None and pts.shape == (12, 3)
|
||||
|
||||
|
||||
@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())
|
||||
|
||||
canvas.set_working_plane("XY", 3.0)
|
||||
assert canvas.working_plane_type() == "XY"
|
||||
assert canvas.working_plane_offset() == pytest.approx(3.0)
|
||||
pts = canvas._grid_intersections_world()
|
||||
# Only intersections at z=3 should remain → 2×2 = 4
|
||||
assert pts is not None and pts.shape == (4, 3)
|
||||
assert np.allclose(pts[:, 2], 3.0)
|
||||
|
||||
|
||||
@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())
|
||||
canvas.set_working_plane("XZ", 4.0)
|
||||
pts = canvas._grid_intersections_world()
|
||||
# 2 × 1 × 3 = 6 at y=4
|
||||
assert pts is not None and pts.shape == (6, 3)
|
||||
assert np.allclose(pts[:, 1], 4.0)
|
||||
|
||||
|
||||
@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())
|
||||
canvas.set_working_plane("YZ", 0.0)
|
||||
pts = canvas._grid_intersections_world()
|
||||
# 1 × 2 × 3 = 6 at x=0
|
||||
assert pts is not None and pts.shape == (6, 3)
|
||||
assert np.allclose(pts[:, 0], 0.0)
|
||||
|
||||
|
||||
@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())
|
||||
canvas.set_working_plane("XY", 6.0)
|
||||
canvas.clear_working_plane()
|
||||
assert canvas.working_plane_type() is None
|
||||
pts = canvas._grid_intersections_world()
|
||||
assert pts is not None and pts.shape == (12, 3)
|
||||
|
||||
|
||||
@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):
|
||||
canvas.set_working_plane("XX", 0.0) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_offset_with_no_matching_grid_returns_none(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""If the user picks a level that doesn't match any intersection
|
||||
(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]
|
||||
assert canvas._grid_intersections_world() is None
|
||||
|
||||
|
||||
# ── renderer: grid overlay follows the active plane ──────────────────
|
||||
@pytest.mark.gui
|
||||
def test_switching_levels_rebuilds_grid_actors(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Activating a plane should rebuild the grid so only that level
|
||||
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())
|
||||
|
||||
# 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
|
||||
|
||||
# Pick an XY plane at Z=3 → render rebuilt.
|
||||
canvas.set_working_plane("XY", 3.0)
|
||||
single_level_count = len(canvas._renderer._aux_actors)
|
||||
# Still has grid lines and intersection dots, but no vertical
|
||||
# connectors; typically fewer overall actors/points.
|
||||
assert single_level_count >= 2
|
||||
|
||||
# Intersections actually filter: snap candidates drop to 2×2 = 4.
|
||||
pts = canvas._grid_intersections_world()
|
||||
assert pts is not None and pts.shape == (4, 3)
|
||||
assert np.allclose(pts[:, 2], 3.0)
|
||||
|
||||
# Switching to Z=6 changes snap targets but actor count stays similar.
|
||||
canvas.set_working_plane("XY", 6.0)
|
||||
pts2 = canvas._grid_intersections_world()
|
||||
assert pts2 is not None and np.allclose(pts2[:, 2], 6.0)
|
||||
|
||||
# Clearing goes back to full 3D.
|
||||
canvas.clear_working_plane()
|
||||
pts_full = canvas._grid_intersections_world()
|
||||
assert pts_full is not None and pts_full.shape == (12, 3)
|
||||
|
||||
|
||||
# ── MainWindow wiring: Top/Front/Right populate the Level combo ───────
|
||||
@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()
|
||||
mw._vm.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]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0, 3.0, 6.0]),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
# Default (iso): combo empty / disabled.
|
||||
mw._on_view_iso()
|
||||
assert not mw._level_combo.isEnabled()
|
||||
|
||||
# Top view: combo fills with Z ordinates, first one active.
|
||||
mw._on_view_top()
|
||||
assert mw._level_combo.isEnabled()
|
||||
assert mw._level_combo.count() == 3
|
||||
assert mw._level_combo.itemText(0).startswith("Z = 0")
|
||||
assert mw._canvas.working_plane_type() == "XY"
|
||||
assert mw._canvas.working_plane_offset() == pytest.approx(0.0)
|
||||
|
||||
# Pick Z=3.
|
||||
mw._level_combo.setCurrentIndex(1)
|
||||
assert mw._canvas.working_plane_offset() == pytest.approx(3.0)
|
||||
|
||||
# Front view resets combo to Y ordinates.
|
||||
mw._on_view_front()
|
||||
assert mw._level_combo.itemText(0).startswith("Y = 0")
|
||||
assert mw._canvas.working_plane_type() == "XZ"
|
||||
Loading…
Reference in a new issue