"""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