From a6a4f416c2ab2c477d3847192fdfb2017cb9400d Mon Sep 17 00:00:00 2001 From: muaviz Date: Thu, 12 Nov 2026 23:59:36 +0530 Subject: [PATCH] fix(ui): automatically update component editability on connection changes --- src/main/python/utils/Container.py | 79 +++++++++++++++--- src/main/python/utils/Graphics.py | 34 ++++---- src/main/python/utils/Streams.py | 79 ++++++++++++++---- tests/test_container.py | 129 +++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 45 deletions(-) diff --git a/src/main/python/utils/Container.py b/src/main/python/utils/Container.py index 1b3cd0c..97a8e16 100644 --- a/src/main/python/utils/Container.py +++ b/src/main/python/utils/Container.py @@ -149,11 +149,73 @@ def delete(self, l): print(f"[DEBUG] delete: error deleting item {getattr(item, 'name', None)}: {e}") if items_to_delete: + self.update_components_editability() self.graphics.push_snapshot() except Exception as e: print("[DEBUG] delete: outer exception:", e) + def update_components_editability(self): + """ + Recalculate and update the editable/read-only state of every component + in the scene based strictly on its current connection state. + + - If a component has an incoming connection (connected to the output of an upstream component): + Its property window becomes read-only / disabled (e.g., product stream). + - If a component has NO incoming connections (feed stream or isolated component): + Its property window becomes editable immediately, even post-simulation. + - If a component is completely disconnected from all other components: + Its property window is immediately re-enabled. + """ + try: + scene = getattr(self, "scene", None) + if scene is None and hasattr(self, "graphics"): + scene = getattr(self.graphics, "scene", None) + if scene is None: + return + + for item in scene.items(): + if not isinstance(item, NodeItem): + continue + + dw = getattr(item, "dock_widget", None) + if dw is None: + continue + + # Check if item has any incoming connection from an upstream component's output + has_incoming_connection = False + for sock in getattr(item, "input", []): + if len(getattr(sock, "in_lines", [])) > 0: + has_incoming_connection = True + break + + # Check if item has any outgoing connection + has_outgoing_connection = False + for sock in getattr(item, "output", []): + if len(getattr(sock, "out_lines", [])) > 0: + has_outgoing_connection = True + break + + is_connected = has_incoming_connection or has_outgoing_connection + + if getattr(item, "type", "") == "MaterialStream": + if has_incoming_connection: + if hasattr(item.obj, "disableInputDataTab"): + item.obj.disableInputDataTab(dw) + elif hasattr(dw, "set_read_only"): + dw.set_read_only(True) + else: + if hasattr(item.obj, "enableInputDataTab"): + item.obj.enableInputDataTab(dw) + elif hasattr(dw, "set_read_only"): + dw.set_read_only(False) + else: + if not is_connected or not has_incoming_connection: + if hasattr(dw, "set_read_only"): + dw.set_read_only(False) + except Exception as e: + print(f"[DEBUG] update_components_editability failed: {e}") + def fetch_object(self, name): for i in self.unit_operations: if i.name == name: @@ -396,11 +458,8 @@ def try_get_other_node_from_line(ln, this_item): it.update_tooltip_selectedVar() except Exception as tooltip_err: print(f"[DEBUG] update_tooltip_selectedVar failed for {it.name}: {tooltip_err}") - try: - if len(it.input[0].in_lines) > 0: - it.obj.disableInputDataTab(it.dock_widget) - except Exception: - pass + + self.update_components_editability() # Step 9: Populate Results Tabs (via signal for thread-safety) self.signals.results_ready.emit() @@ -430,14 +489,8 @@ def _populate_results(self): if hasattr(dw, 'set_read_only'): dw.set_read_only(False) - # Preserve connection-based restrictions for MaterialStream - for it in self.graphics.scene.items(): - if isinstance(it, NodeItem) and getattr(it, "type", "") == "MaterialStream": - try: - if len(it.input[0].in_lines) > 0: - it.obj.disableInputDataTab(it.dock_widget) - except Exception as e: - print(f"[DEBUG] MaterialStream input state update failed: {e}") + # Preserve connection-based restrictions for all components + self.update_components_editability() # Make all result table items non-editable for dw in dock_widget_lst: diff --git a/src/main/python/utils/Graphics.py b/src/main/python/utils/Graphics.py index 58949fd..1b7d175 100644 --- a/src/main/python/utils/Graphics.py +++ b/src/main/python/utils/Graphics.py @@ -373,6 +373,9 @@ def load_canvas_from_snapshot(self, snapshot, container): except Exception as e: print("[DEBUG] load_canvas_from_snapshot: rebuild connection failed:", e) + if hasattr(container, "update_components_editability"): + container.update_components_editability() + self._restore_counters(container.unit_operations) except Exception as e: @@ -700,14 +703,9 @@ def mouseReleaseEvent(self, event): if getattr(self.new_line.target.parent.obj, 'type', None) not in stm: self.new_line.target.parent.obj.add_connection(1, self.new_line.target.id, self.new_line.source.parent.obj) - sc = self.new_line.source.parent - tg = self.new_line.target.parent - if getattr(sc.obj, 'type', None) == 'MaterialStream': - if len(getattr(sc.input[0], 'in_lines', [])) > 0: - sc.obj.disableInputDataTab(sc.dock_widget) - if getattr(tg.obj, 'type', None) == 'MaterialStream': - if len(getattr(tg.input[0], 'in_lines', [])) > 0: - tg.obj.disableInputDataTab(tg.dock_widget) + if hasattr(self, "parent") and hasattr(self.parent, "container") and self.parent.container: + if hasattr(self.parent.container, "update_components_editability"): + self.parent.container.update_components_editability() try: self.parent.container.graphics.push_snapshot() @@ -756,14 +754,9 @@ def mouseReleaseEvent(self, event): if getattr(self.new_line.target.parent.obj, 'type', None) not in stm: self.new_line.target.parent.obj.add_connection(1, self.new_line.target.id, self.new_line.source.parent.obj) - sc = self.new_line.source.parent - tg = self.new_line.target.parent - if getattr(sc.obj, 'type', None) == 'MaterialStream': - if len(getattr(sc.input[0], 'in_lines', [])) > 0: - sc.obj.disableInputDataTab(sc.dock_widget) - if getattr(tg.obj, 'type', None) == 'MaterialStream': - if len(getattr(tg.input[0], 'in_lines', [])) > 0: - tg.obj.disableInputDataTab(tg.dock_widget) + if hasattr(self, "parent") and hasattr(self.parent, "container") and self.parent.container: + if hasattr(self.parent.container, "update_components_editability"): + self.parent.container.update_components_editability() try: self.parent.container.graphics.push_snapshot() @@ -1228,6 +1221,9 @@ def resize_node(self, new_width, new_height): self.update() + if hasattr(self, "container") and self.container is not None and hasattr(self.container, "update_components_editability"): + self.container.update_components_editability() + def _reposition_sockets(self): """Reposition sockets after a resize while keeping their relative positions on the component. @@ -1541,6 +1537,9 @@ def update_input_ports(self, new_count): self.update() + if hasattr(self, "container") and self.container is not None and hasattr(self.container, "update_components_editability"): + self.container.update_components_editability() + print( f"[DEBUG] update_input_ports complete: " f"{old_count} -> {new_count}" @@ -1850,6 +1849,9 @@ def update_output_ports(self, new_count): self.update() + if hasattr(self, "container") and self.container is not None and hasattr(self.container, "update_components_editability"): + self.container.update_components_editability() + print( f"[DEBUG] update_output_ports complete: " f"{old_count} -> {new_count}" diff --git a/src/main/python/utils/Streams.py b/src/main/python/utils/Streams.py index 136adb9..25b828c 100644 --- a/src/main/python/utils/Streams.py +++ b/src/main/python/utils/Streams.py @@ -543,27 +543,74 @@ def OM_Flowsheet_Equation(self, addedcomp, method): def disableInputDataTab(self, dockwidget): + if dockwidget is None: + return + # Disable main inputs - dockwidget.comboBox.setDisabled(True) + if hasattr(dockwidget, 'comboBox') and dockwidget.comboBox is not None: + dockwidget.comboBox.setDisabled(True) # Safe access helper def safe_set_text(widget, key, default=0): - if key in self.variables: - widget.setText(str(round(float(self.variables[key]['value']),4))) - else: - widget.setText(str(default)) - print(f"[DEBUG] Key not found: {key}") - widget.setDisabled(True) - - safe_set_text(dockwidget.input_dict['P'], 'P') - safe_set_text(dockwidget.input_dict['T'], 'T') - safe_set_text(dockwidget.input_dict['MolFlow'], 'F_p[1]') - - dockwidget.cbTP.setCurrentText(str(self.thermo_package)) - dockwidget.cbTP.setDisabled(True) - dockwidget.pushButton_2.setDisabled(True) + if hasattr(widget, 'setText') and hasattr(widget, 'setDisabled'): + if key in self.variables: + widget.setText(str(round(float(self.variables[key]['value']),4))) + else: + widget.setText(str(default)) + print(f"[DEBUG] Key not found: {key}") + widget.setDisabled(True) + + if hasattr(dockwidget, 'input_dict') and isinstance(dockwidget.input_dict, dict): + if 'P' in dockwidget.input_dict: + safe_set_text(dockwidget.input_dict['P'], 'P') + if 'T' in dockwidget.input_dict: + safe_set_text(dockwidget.input_dict['T'], 'T') + if 'MolFlow' in dockwidget.input_dict: + safe_set_text(dockwidget.input_dict['MolFlow'], 'F_p[1]') + + if hasattr(dockwidget, 'cbTP') and dockwidget.cbTP is not None: + dockwidget.cbTP.setCurrentText(str(self.thermo_package)) + dockwidget.cbTP.setDisabled(True) + + if hasattr(dockwidget, 'pushButton_2') and dockwidget.pushButton_2 is not None: + dockwidget.pushButton_2.setDisabled(True) # Disable x_pc values safely - for index, k in enumerate(dockwidget.x_pclist): + for index, k in enumerate(getattr(dockwidget, 'x_pclist', [])): key_name = f"x_pc[1,{index+1}]" safe_set_text(k, key_name) + + for attr in ('btn_normalize', 'btn_equalize', 'cbCompositionBasis'): + w = getattr(dockwidget, attr, None) + if w is not None and hasattr(w, 'setDisabled'): + w.setDisabled(True) + + def enableInputDataTab(self, dockwidget): + if dockwidget is None: + return + + if hasattr(dockwidget, 'comboBox') and dockwidget.comboBox is not None: + dockwidget.comboBox.setDisabled(False) + + if hasattr(dockwidget, 'input_dict') and isinstance(dockwidget.input_dict, dict): + for k, w in dockwidget.input_dict.items(): + if hasattr(w, 'setDisabled'): + w.setDisabled(False) + + if hasattr(dockwidget, 'cbTP') and dockwidget.cbTP is not None: + dockwidget.cbTP.setDisabled(False) + + if hasattr(dockwidget, 'pushButton_2') and dockwidget.pushButton_2 is not None: + dockwidget.pushButton_2.setDisabled(False) + + for le in getattr(dockwidget, 'x_pclist', []): + if hasattr(le, 'setDisabled'): + le.setDisabled(False) + + for attr in ('btn_normalize', 'btn_equalize', 'cbCompositionBasis'): + w = getattr(dockwidget, attr, None) + if w is not None and hasattr(w, 'setDisabled'): + w.setDisabled(False) + + if hasattr(dockwidget, 'set_read_only'): + dockwidget.set_read_only(False) diff --git a/tests/test_container.py b/tests/test_container.py index 1c741fc..55e204c 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -568,5 +568,134 @@ def test_unlock_interface_after_simulation(self): self.assertEqual(QApplication.overrideCursor().shape(), Qt.ArrowCursor) +# ============================================================================= +# 8. Dynamic Component Editability on Connection Changes (Task 2) +# ============================================================================= + +class TestDynamicComponentEditability(ContainerTestBase): + """Test suite for automatic component editability updates on connection changes.""" + + def setUp(self): + super().setUp() + self.container.disableInterfaceforSimulation = MagicMock() + self.flowsheet_patch = patch.object(container_module, "Flowsheet") + self.mock_flowsheet_cls = self.flowsheet_patch.start() + self.fs = self.mock_flowsheet_cls.return_value + self.fs.result_data = [["header"], ["values"]] + self.fs.last_error = "" + + def tearDown(self): + self.flowsheet_patch.stop() + super().tearDown() + + def test_material_stream_becomes_read_only_when_connected_as_output(self): + mixer = self.add_node(Mixer()) + s2 = self.add_node(MaterialStream()) + + # Initially s2 has no incoming connection -> editable + self.assertTrue(s2.dock_widget.comboBox.isEnabled()) + self.assertTrue(s2.dock_widget.pushButton_2.isEnabled()) + + # Connect mixer output to s2 input + self.connect(mixer, 0, s2, 0) + self.container.update_components_editability() + + # s2 now has incoming connection -> read-only + self.assertFalse(s2.dock_widget.comboBox.isEnabled()) + self.assertFalse(s2.dock_widget.pushButton_2.isEnabled()) + + def test_material_stream_becomes_editable_when_line_deleted(self): + mixer = self.add_node(Mixer()) + s2 = self.add_node(MaterialStream()) + line = self.connect(mixer, 0, s2, 0) + self.container.update_components_editability() + self.assertFalse(s2.dock_widget.comboBox.isEnabled()) + + # Delete the connection line + self.container.delete([line]) + + # s2 should immediately become editable + self.assertTrue(s2.dock_widget.comboBox.isEnabled()) + self.assertTrue(s2.dock_widget.pushButton_2.isEnabled()) + + def test_material_stream_becomes_editable_when_upstream_node_deleted(self): + mixer = self.add_node(Mixer()) + s2 = self.add_node(MaterialStream()) + self.connect(mixer, 0, s2, 0) + self.container.update_components_editability() + self.assertFalse(s2.dock_widget.comboBox.isEnabled()) + + # Delete the upstream Mixer node + self.container.delete([mixer]) + + # s2 should immediately become editable + self.assertTrue(s2.dock_widget.comboBox.isEnabled()) + self.assertTrue(s2.dock_widget.pushButton_2.isEnabled()) + + def test_post_simulation_editability_lifecycle(self): + """Simulate MS1, MS2 -> Mixer -> MS3, then delete Mixer, assert MS3 editable.""" + s1 = self.add_node(MaterialStream()) + s2 = self.add_node(MaterialStream()) + mixer = self.add_node(Mixer()) + s3 = self.add_node(MaterialStream()) + + self.connect(s1, 0, mixer, 0) + self.connect(s2, 0, mixer, 1) + self.connect(mixer, 0, s3, 0) + + # Mock successful simulation results + self.fs.result_data = [ + ["time", f"{s3.name}.P", f"{s3.name}.T", f"{s3.name}.TotalMolarFlow"], + ["0.0", "101325", "300", "100"], + ["1.0", "101325", "310", "100"] + ] + + self.container.simulate("EQN") + + # Feed streams s1, s2 should be editable + self.assertTrue(s1.dock_widget.comboBox.isEnabled()) + self.assertTrue(s2.dock_widget.comboBox.isEnabled()) + + # Output stream s3 should be disabled / read-only + self.assertFalse(s3.dock_widget.comboBox.isEnabled()) + self.assertFalse(s3.dock_widget.pushButton_2.isEnabled()) + + # Now delete the Mixer component + self.container.delete([mixer]) + + # Since s3 is no longer connected to any component, it must become editable immediately + self.assertTrue(s3.dock_widget.comboBox.isEnabled()) + self.assertTrue(s3.dock_widget.pushButton_2.isEnabled()) + self.assertTrue(s3.dock_widget.cbTP.isEnabled()) + + def test_reconnecting_stream_makes_it_read_only_again(self): + s1 = self.add_node(MaterialStream()) + mixer1 = self.add_node(Mixer()) + mixer2 = self.add_node(Mixer()) + + # Connect mixer1 -> s1 + line1 = self.connect(mixer1, 0, s1, 0) + self.container.update_components_editability() + self.assertFalse(s1.dock_widget.comboBox.isEnabled()) + + # Disconnect by deleting line1 + self.container.delete([line1]) + self.assertTrue(s1.dock_widget.comboBox.isEnabled()) + + # Reconnect mixer2 -> s1 + line2 = self.connect(mixer2, 0, s1, 0) + self.container.update_components_editability() + self.assertFalse(s1.dock_widget.comboBox.isEnabled()) + + def test_isolated_component_remains_editable(self): + """A component not connected to anything must always be editable.""" + mixer = self.add_node(Mixer()) + s1 = self.add_node(MaterialStream()) + + self.container.update_components_editability() + self.assertTrue(mixer.dock_widget.btn.isEnabled() if hasattr(mixer.dock_widget, 'btn') else True) + self.assertTrue(s1.dock_widget.comboBox.isEnabled()) + + if __name__ == "__main__": unittest.main()