diff --git a/HISTORY.md b/HISTORY.md index da98a3b7..e7c6e2c0 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,6 +19,24 @@ --> # Version History +- 0.2.232 - Retain all toolbox relations on selection by removing relation + filtering logic and updating tests for full visibility across + categories. +- 0.2.231 - Populate toolbox groups from connection rules so Entities and Safety & + Security Mgmt categories expose all rule-defined elements and + relations. Add grouped tests for toolbox externals. +- 0.2.230 - Preserve toolbox contents after undo, redo and clipboard operations by + refreshing active frames. Add regression tests for sync and + refresh routines. +- 0.2.229 - Preserve governance toolbox contents after diagram edits by + retaining relation tools across focus changes. Add tests + confirming focus events do not drop relations. +- 0.2.228 - Preserve toolbox frames for all open governance diagrams by + avoiding global memory cleanup during toolbox switches. +- 0.2.227 - Remove relation filtering when rebuilding toolboxes so all + defined relationships remain visible across diagram sessions. +- 0.2.226 - Map all nodes appearing in connection rules to toolbox groups and + retain unmapped external relations for Safety & AI toolbox. - 0.2.225 - Scope toolbox caches to diagram sessions and clean obsolete frames when windows close. Add regression tests ensuring related elements and relationships persist across sequential diagrams. diff --git a/README.md b/README.md index b46e854f..eb7d73c7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -version: 0.2.225 +version: 0.2.232 Author: Miguel Marina - [LinkedIn](https://www.linkedin.com/in/progman32/) # AutoML diff --git a/gui/windows/architecture.py b/gui/windows/architecture.py index 9a64b157..4d503bf4 100644 --- a/gui/windows/architecture.py +++ b/gui/windows/architecture.py @@ -34,6 +34,7 @@ from pathlib import Path from dataclasses import dataclass, field, asdict, replace from typing import Dict, List, Tuple, Callable +import warnings from mainappsrc.models.sysml.sysml_repository import SysMLRepository, SysMLDiagram, SysMLElement from gui.styles.style_manager import StyleManager @@ -287,6 +288,8 @@ def _make_gov_element_classes(nodes: list[str]) -> dict[str, list[str]]: for n in GOV_CORE_NODES: NODE_TO_GROUP[n] = "Governance Core" +UNMAPPED_GROUP = "Unmapped" + # Directed relationship rules for connections between Safety & AI elements. # Each entry maps a connection type to allowed source and target element # combinations. Rules are only enforced when both endpoints are Safety & AI @@ -312,6 +315,35 @@ def _make_gov_element_classes(nodes: list[str]) -> dict[str, list[str]]: GUARD_NODES = set(_CONFIG.get("guard_nodes", [])) +def _map_rule_nodes() -> None: + """Ensure ``NODE_TO_GROUP`` includes nodes from all connection rules.""" + rule_nodes: set[str] = set() + for conns in CONNECTION_RULES.values(): + for srcs in conns.values(): + for src, dests in srcs.items(): + rule_nodes.add(src) + rule_nodes.update(dests) + for srcs in SAFETY_AI_RELATION_RULES.values(): + for src, dests in srcs.items(): + rule_nodes.add(src) + rule_nodes.update(dests) + for node in rule_nodes: + NODE_TO_GROUP.setdefault(node, UNMAPPED_GROUP) + + +def _expand_group_nodes_from_rules() -> None: + """Ensure toolbox groups include nodes referenced in connection rules.""" + for node, group in NODE_TO_GROUP.items(): + if group in {UNMAPPED_GROUP, "Safety & AI Lifecycle", "Governance Core"}: + continue + nodes = GOV_ELEMENT_CLASSES.setdefault(group, []) + if node not in nodes: + nodes.append(node) + +_map_rule_nodes() +_expand_group_nodes_from_rules() + + def _connection_rule_allows( diag_type: str, conn_type: str, src_type: str, dst_type: str ) -> tuple[bool, str]: @@ -379,9 +411,15 @@ def add(group: str, node: str, rel: str) -> None: src_group = NODE_TO_GROUP.get(src) for dest in dests: dest_group = NODE_TO_GROUP.get(dest) - if src in node_set and dest not in node_set and dest_group: + if src in node_set and dest not in node_set: + if not dest_group: + warnings.warn(f"No toolbox group for node '{dest}'") + dest_group = UNMAPPED_GROUP add(dest_group, dest, rel) - elif dest in node_set and src not in node_set and src_group: + elif dest in node_set and src not in node_set: + if not src_group: + warnings.warn(f"No toolbox group for node '{src}'") + src_group = UNMAPPED_GROUP add(src_group, src, rel) for rel, srcs in SAFETY_AI_RELATION_RULES.items(): @@ -389,9 +427,15 @@ def add(group: str, node: str, rel: str) -> None: src_group = NODE_TO_GROUP.get(src) for dest in dests: dest_group = NODE_TO_GROUP.get(dest) - if src in node_set and dest not in node_set and dest_group: + if src in node_set and dest not in node_set: + if not dest_group: + warnings.warn(f"No toolbox group for node '{dest}'") + dest_group = UNMAPPED_GROUP add(dest_group, dest, rel) - elif dest in node_set and src not in node_set and src_group: + elif dest in node_set and src not in node_set: + if not src_group: + warnings.warn(f"No toolbox group for node '{src}'") + src_group = UNMAPPED_GROUP add(src_group, src, rel) result: dict[str, dict[str, list[str]]] = {} @@ -403,40 +447,6 @@ def add(group: str, node: str, rel: str) -> None: return result -def _dedup_category(data: dict, seen: set[str] | None = None) -> None: - """Remove duplicate relations within ``data`` and its externals. - - When ``seen`` is provided the set is used to track relationships across - categories so each relationship only appears once in the toolbox overall. - Any relations kept in ``data`` or its externals are added to ``seen`` for - subsequent calls. - """ - - if seen is None: - seen = set() - rels: list[str] = [] - for r in data.get("relations", []) or []: - if r not in seen: - seen.add(r) - rels.append(r) - data["relations"] = rels - for sub in data.get("externals", {}).values(): - sub_rels: list[str] = [] - for r in sub.get("relations", []) or []: - if r not in seen: - seen.add(r) - sub_rels.append(r) - sub["relations"] = sub_rels - - -def _dedup_core_category(data: dict) -> None: - """Deduplicate Governance Core lists without cross-checking externals.""" - - data["relations"] = list(dict.fromkeys(data.get("relations", []) or [])) - for sub in data.get("externals", {}).values(): - sub["relations"] = list(dict.fromkeys(sub.get("relations", []) or [])) - - def _core_toolbox_template() -> dict[str, list[str] | dict]: """Return a pristine Governance Core toolbox definition.""" @@ -445,7 +455,6 @@ def _core_toolbox_template() -> dict[str, list[str] | dict]: "relations": _relations_for(GOV_CORE_NODES), "externals": copy.deepcopy(_external_relations_for(GOV_CORE_NODES)), } - _dedup_core_category(core) return core def _toolbox_defs() -> dict[str, dict[str, list[str] | dict]]: @@ -475,43 +484,6 @@ def _toolbox_defs() -> dict[str, dict[str, list[str] | dict]]: return defs -def _filter_global_relations( - defs: dict[str, dict], ai_data: dict | None, global_rels: set[str] -) -> None: - """Remove ``global_rels`` from category definitions.""" - - for name, data in defs.items(): - if name == "Governance Core": - continue - data["relations"] = [ - r for r in data.get("relations", []) if r not in global_rels - ] - for sub in data.get("externals", {}).values(): - sub["relations"] = [ - r for r in sub.get("relations", []) if r not in global_rels - ] - if ai_data: - ai_data["relations"] = [ - r for r in ai_data.get("relations", []) if r not in global_rels - ] - for sub in ai_data.get("externals", {}).values(): - sub["relations"] = [ - r for r in sub.get("relations", []) if r not in global_rels - ] - - -def _deduplicate_relations(defs: dict[str, dict], ai_data: dict | None) -> None: - """Deduplicate relations within each category while preserving order.""" - - for name, data in defs.items(): - if name == "Governance Core": - _dedup_core_category(data) - else: - _dedup_category(data) - if ai_data: - _dedup_category(ai_data) - - def _gov_connection_text(node_type: str) -> str: """Return tooltip text listing governance connections for ``node_type``.""" node_type = _GOV_TYPE_ALIASES.get(node_type, node_type) @@ -880,6 +852,8 @@ def reload_config() -> None: } NODE_CONNECTION_LIMITS = _CONFIG.get("node_connection_limits", {}) GUARD_NODES = set(_CONFIG.get("guard_nodes", [])) + _map_rule_nodes() + _expand_group_nodes_from_rules() _enforce_connection_rules() for ref in list(ARCH_WINDOWS): win = ref() @@ -3705,7 +3679,6 @@ def __init__( relation_tools = list(relation_tools or []) self.relation_tools = relation_tools - self._has_relation_filters = bool(relation_tools) if isinstance(self.master, tk.Toplevel): self.master.protocol("WM_DELETE_WINDOW", self.on_close) @@ -3946,8 +3919,6 @@ def _on_focus_in(self, event=None): def _on_focus_out(self, event=None): self._sync_to_repository() - self.relation_tools = [] - self._has_relation_filters = False def _fit_toolbox(self) -> None: """Resize the toolbox to the smallest width that shows all button text.""" @@ -9693,6 +9664,7 @@ def copy_selected(self, _event=None): parent_name = self._task_parent_name(self.selected_obj) self.app.diagram_clipboard.diagram_clipboard_parent_name = parent_name self.app.diagram_clipboard.diagram_clipboard_type = diag.diag_type if diag else None + self._switch_toolbox() def cut_selected(self, _event=None): if self.repo.diagram_read_only(self.diagram_id): @@ -9728,6 +9700,7 @@ def cut_selected(self, _event=None): self._sync_to_repository() self.redraw() self.update_property_view() + self._switch_toolbox() def paste_selected(self, _event=None): if self.repo.diagram_read_only(self.diagram_id): @@ -9828,6 +9801,7 @@ def paste_selected(self, _event=None): self.update_property_view() self.app.diagram_clipboard.diagram_clipboard = None self.app.diagram_clipboard.cut_mode = False + self._switch_toolbox() def _remove_wp_and_disable(self, name: str, wp) -> None: toolbox = getattr(self.app, "safety_mgmt_toolbox", None) or ACTIVE_TOOLBOX @@ -10184,6 +10158,7 @@ def _sync_to_repository(self) -> None: o.obj_id == data["obj_id"] for o in self.objects ): self.objects.append(SysMLObject(**data)) + self._switch_toolbox() def refresh_from_repository(self, _event=None) -> None: """Reload diagram objects from the repository and redraw.""" @@ -10224,6 +10199,7 @@ def refresh_from_repository(self, _event=None) -> None: _next_obj_id = max(o.obj_id for o in self.objects) + 1 self.redraw() self.update_property_view() + self._switch_toolbox() def on_close(self): diag_id = getattr(self, "diagram_id", "0") @@ -10232,7 +10208,6 @@ def on_close(self): ARCH_WINDOWS.discard(getattr(self, "_arch_ref", None)) self._sync_to_repository() self.relation_tools = [] - self._has_relation_filters = False self.destroy() @@ -12202,10 +12177,6 @@ def _rebuild_toolboxes(self) -> None: defs = copy.deepcopy(_toolbox_defs()) ai_data = defs.pop("Safety & AI Lifecycle", None) defs["Governance Core"] = _core_toolbox_template() - global_rels = set(getattr(self, "relation_tools", [])) - if getattr(self, "_has_relation_filters", False) and global_rels: - _filter_global_relations(defs, ai_data, global_rels) - _deduplicate_relations(defs, ai_data) if hasattr(self.tools_frame, "pack_forget"): self.tools_frame.pack_forget() if getattr(self, "rel_frame", None) and hasattr(self.rel_frame, "pack_forget"): @@ -12372,7 +12343,6 @@ def _switch_toolbox(self) -> None: for frame in frames: if frame and hasattr(frame, "pack"): frame.pack(fill=tk.X, padx=2, pady=2) - memory_manager.cleanup() class _SelectDialog(simpledialog.Dialog): # pragma: no cover - requires tkinter def __init__(self, parent, title: str, options: list[str]): diff --git a/mainappsrc/version.py b/mainappsrc/version.py index 3af3035b..7ea3d204 100644 --- a/mainappsrc/version.py +++ b/mainappsrc/version.py @@ -18,6 +18,6 @@ """Project version information.""" -VERSION = "0.2.225" +VERSION = "0.2.232" __all__ = ["VERSION"] diff --git a/tests/detachment/toolbox/test_toolbox_sequential_diagrams.py b/tests/detachment/toolbox/test_toolbox_sequential_diagrams.py index 9db541cb..226b68ed 100644 --- a/tests/detachment/toolbox/test_toolbox_sequential_diagrams.py +++ b/tests/detachment/toolbox/test_toolbox_sequential_diagrams.py @@ -68,7 +68,6 @@ def switch(self) -> None: for frame in frames: if frame and hasattr(frame, "pack"): frame.pack() - memory_manager.cleanup() def on_close(self) -> None: prefix = f"{self.diagram_id}:{id(self)}:toolbox:" @@ -106,3 +105,20 @@ def test_relationships_persist(self) -> None: assert frame1.relations == ["R1"] assert frame2.relations == ["R2"] win2.on_close() + + +class TestConcurrentToolboxPersistence: + """Tests verifying multiple windows retain their toolboxes when open.""" + + def test_windows_preserve_toolboxes(self) -> None: + memory_manager.cleanup() + win1 = _open_window(["A"], ["R1"]) + win2 = _open_window(["B"], ["R2"]) + frame1 = win1._toolbox_frames["Rel"][0] + frame2 = win2._toolbox_frames["Rel"][0] + assert frame1.elements == ["A"] + assert frame1.relations == ["R1"] + assert frame2.elements == ["B"] + assert frame2.relations == ["R2"] + win1.on_close() + win2.on_close() diff --git a/tests/governance/test_governance_toolbox_clipboard_undo.py b/tests/governance/test_governance_toolbox_clipboard_undo.py new file mode 100644 index 00000000..fc0dfe79 --- /dev/null +++ b/tests/governance/test_governance_toolbox_clipboard_undo.py @@ -0,0 +1,127 @@ +# Author: Miguel Marina +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Copyright (C) 2025 Capek System Safety & Robotic Solutions +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Tests verifying toolbox survives sync and refresh operations.""" + +import copy +import types +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[2])) + +import gui.architecture as arch +from gui.architecture import GovernanceDiagramWindow +from mainappsrc.models.sysml.sysml_repository import SysMLRepository + + +class DummyWidget: + def __init__(self, *_, **__): + pass + + def pack(self, *_, **__): + pass + + def pack_forget(self, *_, **__): + pass + + def bind(self, *_, **__): + pass + + def configure(self, *_, **__): + pass + + def destroy(self, *_, **__): + pass + + def after(self, *_, **__): + pass + + +class TestToolboxClipboardUndoRedo: + """Grouped tests validating toolbox state after sync and refresh.""" + + def _init(self, repo): + def fake_sysml_init( + self, + master, + title, + tools, + diagram_id=None, + app=None, + history=None, + relation_tools=None, + tool_groups=None, + ): + self.app = app + self.repo = repo + self.diagram_id = diagram_id + self.toolbox = DummyWidget() + self.tools_frame = DummyWidget() + self.rel_frame = DummyWidget() + self.toolbox_selector = DummyWidget() + self.toolbox_var = types.SimpleNamespace(get=lambda: "", set=lambda v: None) + self.relation_tools = relation_tools or [] + self._toolbox_frames = {} + self.canvas = types.SimpleNamespace( + master=DummyWidget(), configure=lambda *a, **k: None + ) + self.objects = [] + self.connections = [] + + return fake_sysml_init + + def _common_patches(self, monkeypatch, repo, defs_data): + monkeypatch.setattr(arch, "_toolbox_defs", lambda: copy.deepcopy(defs_data)) + monkeypatch.setattr(arch.SysMLDiagramWindow, "__init__", self._init(repo)) + monkeypatch.setattr(arch, "draw_icon", lambda *a, **k: None) + monkeypatch.setattr(arch.GovernanceDiagramWindow, "redraw", lambda self: None) + monkeypatch.setattr( + arch.GovernanceDiagramWindow, "update_property_view", lambda self: None + ) + monkeypatch.setattr(arch.ttk, "Combobox", DummyWidget) + monkeypatch.setattr(arch.ttk, "Frame", DummyWidget) + monkeypatch.setattr(arch.ttk, "LabelFrame", DummyWidget) + monkeypatch.setattr(arch.ttk, "Button", DummyWidget) + + def test_relation_tools_survive_sync(self, monkeypatch): + SysMLRepository._instance = None + repo = SysMLRepository.get_instance() + diag = repo.create_diagram("Governance Diagram") + defs_data = {"Entities": {"nodes": [], "relations": ["Rel"], "externals": {}}} + self._common_patches(monkeypatch, repo, defs_data) + win = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) + win.relation_tools = ["Rel"] + win._rebuild_toolboxes() + win._sync_to_repository() + win._rebuild_toolboxes() + ents = win._frame_loaders["Entities"].__defaults__[0] + assert ents["relations"] == ["Rel"] + + def test_relation_tools_survive_refresh(self, monkeypatch): + SysMLRepository._instance = None + repo = SysMLRepository.get_instance() + diag = repo.create_diagram("Governance Diagram") + defs_data = {"Entities": {"nodes": [], "relations": ["Rel"], "externals": {}}} + self._common_patches(monkeypatch, repo, defs_data) + win = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) + win.relation_tools = ["Rel"] + win._rebuild_toolboxes() + win.refresh_from_repository() + win._rebuild_toolboxes() + ents = win._frame_loaders["Entities"].__defaults__[0] + assert ents["relations"] == ["Rel"] diff --git a/tests/governance/test_governance_toolbox_external.py b/tests/governance/test_governance_toolbox_external.py new file mode 100644 index 00000000..3c5b869e --- /dev/null +++ b/tests/governance/test_governance_toolbox_external.py @@ -0,0 +1,106 @@ +# Author: Miguel Marina +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Copyright (C) 2025 Capek System Safety & Robotic Solutions +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import warnings +from pathlib import Path +import sys +import types + +sys.path.append(str(Path(__file__).resolve().parents[2])) +import mainappsrc # type: ignore +import mainappsrc.ui # type: ignore +ui_stub = types.ModuleType("mainappsrc.ui.app_lifecycle_ui") +ui_stub.AppLifecycleUI = object +sys.modules["mainappsrc.ui.app_lifecycle_ui"] = ui_stub + +from gui import architecture + + +def _write_config(tmp_path, cfg): + path = tmp_path / "diagram_rules.json" + path.write_text(json.dumps(cfg)) + return path + + +class TestSafetyAIToolboxExternal: + + def test_cross_toolbox_relations_listed(self, tmp_path, monkeypatch): + cfg = { + "ai_nodes": ["AI Node"], + "governance_element_nodes": ["Task"], + "connection_rules": { + "Governance Diagram": {"Rel": {"AI Node": ["Task"]}} + }, + } + path = _write_config(tmp_path, cfg) + monkeypatch.setattr(architecture, "_CONFIG_PATH", path) + architecture.reload_config() + externals = architecture._external_relations_for(["AI Node"]) + assert externals == {"Processes": {"nodes": ["Task"], "relations": ["Rel"]}} + + def test_unmapped_relations_retained(self, tmp_path, monkeypatch): + cfg = { + "ai_nodes": ["AI Node"], + "connection_rules": { + "Governance Diagram": {"Rel": {"AI Node": ["Ghost"]}} + }, + } + path = _write_config(tmp_path, cfg) + monkeypatch.setattr(architecture, "_CONFIG_PATH", path) + architecture.reload_config() + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + externals = architecture._external_relations_for(["AI Node"]) + assert externals == {"Unmapped": {"nodes": ["Ghost"], "relations": ["Rel"]}} + + +class TestEntitiesToolboxExternal: + def test_entities_nodes_and_relations(self, tmp_path, monkeypatch): + cfg = { + "governance_element_nodes": ["Role", "Organization", "Task"], + "connection_rules": { + "Governance Diagram": {"Rel": {"Role": ["Organization", "Task"]}} + }, + } + path = _write_config(tmp_path, cfg) + monkeypatch.setattr(architecture, "_CONFIG_PATH", path) + architecture.reload_config() + defs = architecture._toolbox_defs() + ent = defs["Entities"] + assert set(ent["nodes"]) == {"Role", "Organization"} + assert ent["relations"] == ["Rel"] + assert ent["externals"] == {"Processes": {"nodes": ["Task"], "relations": ["Rel"]}} + + +class TestSafetySecurityMgmtToolboxExternal: + def test_safety_mgmt_nodes_and_relations(self, tmp_path, monkeypatch): + cfg = { + "governance_element_nodes": ["Plan", "Report", "Role"], + "connection_rules": { + "Governance Diagram": {"Rel": {"Plan": ["Report", "Role"], "Report": ["Plan"]}} + }, + } + path = _write_config(tmp_path, cfg) + monkeypatch.setattr(architecture, "_CONFIG_PATH", path) + architecture.reload_config() + defs = architecture._toolbox_defs() + sm = defs["Safety & Security Mgmt"] + assert set(sm["nodes"]) == {"Plan", "Report"} + assert sm["relations"] == ["Rel"] + assert sm["externals"] == {"Entities": {"nodes": ["Role"], "relations": ["Rel"]}} diff --git a/tests/governance/test_governance_toolbox_focus_persistence.py b/tests/governance/test_governance_toolbox_focus_persistence.py new file mode 100644 index 00000000..cd6a53d1 --- /dev/null +++ b/tests/governance/test_governance_toolbox_focus_persistence.py @@ -0,0 +1,122 @@ +# Author: Miguel Marina +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Copyright (C) 2025 Capek System Safety & Robotic Solutions +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Tests ensuring toolbox contents survive focus changes.""" + +import copy +import types +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[2])) + +import gui.architecture as arch +from gui.architecture import GovernanceDiagramWindow +from mainappsrc.models.sysml.sysml_repository import SysMLRepository + + +class DummyWidget: + def __init__(self, *_, **__): + pass + + def pack(self, *_, **__): + pass + + def pack_forget(self, *_, **__): + pass + + def bind(self, *_, **__): + pass + + def configure(self, *_, **__): + pass + + def destroy(self, *_, **__): + pass + + def after(self, *_, **__): + pass + + +class TestToolboxFocusPersistence: + """Grouped tests validating toolbox state after focus events.""" + + def _init(self, repo): + def fake_sysml_init( + self, + master, + title, + tools, + diagram_id=None, + app=None, + history=None, + relation_tools=None, + tool_groups=None, + ): + self.app = app + self.repo = repo + self.diagram_id = diagram_id + self.toolbox = DummyWidget() + self.tools_frame = DummyWidget() + self.rel_frame = DummyWidget() + self.toolbox_selector = DummyWidget() + self.toolbox_var = types.SimpleNamespace(get=lambda: "", set=lambda v: None) + self.relation_tools = relation_tools or [] + self._toolbox_frames = {} + self.canvas = types.SimpleNamespace( + master=DummyWidget(), configure=lambda *a, **k: None + ) + self._sync_to_repository = lambda: None + + return fake_sysml_init + + def test_relation_tools_survive_focus_out(self, monkeypatch): + SysMLRepository._instance = None + repo = SysMLRepository.get_instance() + diag = repo.create_diagram("Governance Diagram") + defs_data = {"Entities": {"nodes": [], "relations": ["Rel"], "externals": {}}} + monkeypatch.setattr(arch, "_toolbox_defs", lambda: copy.deepcopy(defs_data)) + monkeypatch.setattr(arch.SysMLDiagramWindow, "__init__", self._init(repo)) + monkeypatch.setattr(arch, "draw_icon", lambda *a, **k: None) + monkeypatch.setattr( + arch.GovernanceDiagramWindow, "refresh_from_repository", lambda self, e=None: None + ) + monkeypatch.setattr(arch.ttk, "Combobox", DummyWidget) + monkeypatch.setattr(arch.ttk, "Frame", DummyWidget) + monkeypatch.setattr(arch.ttk, "LabelFrame", DummyWidget) + monkeypatch.setattr(arch.ttk, "Button", DummyWidget) + + win = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) + win.relation_tools = ["Rel"] + win._rebuild_toolboxes() + win._on_focus_out() + win._rebuild_toolboxes() + ents = win._frame_loaders["Entities"].__defaults__[0] + assert ents["relations"] == ["Rel"] + + def test_focus_out_does_not_clear_state(self, monkeypatch): + SysMLRepository._instance = None + repo = SysMLRepository.get_instance() + diag = repo.create_diagram("Governance Diagram") + monkeypatch.setattr(arch.SysMLDiagramWindow, "__init__", self._init(repo)) + monkeypatch.setattr(arch.GovernanceDiagramWindow, "refresh_from_repository", lambda self, e=None: None) + + win = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) + win.relation_tools = ["Foo"] + win._on_focus_out() + assert win.relation_tools == ["Foo"] + diff --git a/tests/governance/test_governance_toolbox_relation_dedup.py b/tests/governance/test_governance_toolbox_relation_preservation.py similarity index 85% rename from tests/governance/test_governance_toolbox_relation_dedup.py rename to tests/governance/test_governance_toolbox_relation_preservation.py index a9ccc930..c8267e3f 100644 --- a/tests/governance/test_governance_toolbox_relation_dedup.py +++ b/tests/governance/test_governance_toolbox_relation_preservation.py @@ -48,7 +48,7 @@ def destroy(self, *a, **k): pass -class TestGlobalRelationFiltering: +class TestRelationRetention: def _init(self, repo): def fake_sysml_init( self, @@ -70,7 +70,6 @@ def fake_sysml_init( self.toolbox_selector = DummyWidget() self.toolbox_var = types.SimpleNamespace(get=lambda: "", set=lambda v: None) self.relation_tools = relation_tools or [] - self._has_relation_filters = bool(relation_tools) self._toolbox_frames = {} self.canvas = types.SimpleNamespace( master=DummyWidget(), configure=lambda *a, **k: None @@ -80,7 +79,7 @@ def fake_sysml_init( return fake_sysml_init - def test_global_relation_not_duplicated(self, monkeypatch): + def test_global_relations_retained(self, monkeypatch): SysMLRepository._instance = None repo = SysMLRepository.get_instance() diag = repo.create_diagram("Governance Diagram") @@ -104,10 +103,9 @@ def test_global_relation_not_duplicated(self, monkeypatch): win = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) win.relation_tools = ["Flow"] - win._has_relation_filters = True win._rebuild_toolboxes() ai_copy = win._frame_loaders["Safety & AI Lifecycle"].__defaults__[0] - assert ai_copy["relations"] == ["Assess"] + assert ai_copy["relations"] == ["Flow", "Assess"] def test_governance_core_keeps_global_relations(self, monkeypatch): SysMLRepository._instance = None @@ -138,56 +136,15 @@ def test_governance_core_keeps_global_relations(self, monkeypatch): win = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) win.relation_tools = ["Trace"] - win._has_relation_filters = True win._rebuild_toolboxes() entities = win._frame_loaders["Entities"].__defaults__[0] core = win._frame_loaders["Governance Core"].__defaults__[0] - assert entities["relations"] == [] + assert entities["relations"] == ["Trace"] assert core["relations"] == ["Trace"] assert core["externals"]["Entities"]["relations"] == ["Trace"] - def test_relation_tools_reset_between_windows(self, monkeypatch): - SysMLRepository._instance = None - repo = SysMLRepository.get_instance() - diag1 = repo.create_diagram("Governance Diagram") - diag2 = repo.create_diagram("Governance Diagram") - - calls = [] - def record_filter(defs, ai_data, rels): - calls.append(set(rels)) - - monkeypatch.setattr(arch, "_filter_global_relations", record_filter) - defs_data = { - "Safety & AI Lifecycle": {"nodes": [], "relations": ["Trace", "Flow"], "externals": {}} - } - monkeypatch.setattr(arch, "_toolbox_defs", lambda: copy.deepcopy(defs_data)) - - monkeypatch.setattr(arch.SysMLDiagramWindow, "__init__", self._init(repo)) - monkeypatch.setattr(arch, "draw_icon", lambda *a, **k: None) - monkeypatch.setattr( - arch.GovernanceDiagramWindow, "refresh_from_repository", lambda self: None - ) - monkeypatch.setattr(arch.ttk, "Combobox", DummyWidget) - monkeypatch.setattr(arch.ttk, "Frame", DummyWidget) - monkeypatch.setattr(arch.ttk, "LabelFrame", DummyWidget) - monkeypatch.setattr(arch.ttk, "Button", DummyWidget) - - first = GovernanceDiagramWindow(None, None, diagram_id=diag1.diag_id) - first.relation_tools = ["Trace"] - first._has_relation_filters = True - first._rebuild_toolboxes() - first.on_close() - - second = GovernanceDiagramWindow(None, None, diagram_id=diag2.diag_id) - second.relation_tools = ["Flow"] - second._has_relation_filters = True - second._rebuild_toolboxes() - - assert calls[-1] == {"Flow"} - - -class TestCategoryDeduplication: +class TestCategoryPreservation: def _init(self, repo): def fake_sysml_init( self, @@ -214,7 +171,7 @@ def fake_sysml_init( return fake_sysml_init - def test_category_relations_deduplicated(self, monkeypatch): + def test_category_relations_preserved(self, monkeypatch): SysMLRepository._instance = None repo = SysMLRepository.get_instance() diag = repo.create_diagram("Governance Diagram") @@ -245,10 +202,10 @@ def test_category_relations_deduplicated(self, monkeypatch): win = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) art = win._frame_loaders["Artifacts"].__defaults__[0] assert art["relations"] == ["Approves"] - assert art["externals"]["Roles"]["relations"] == ["Manage"] - assert art["externals"]["Processes"]["relations"] == [] + assert art["externals"]["Roles"]["relations"] == ["Approves", "Manage"] + assert art["externals"]["Processes"]["relations"] == ["Approves"] - def test_governance_core_relations_deduplicated(self, monkeypatch): + def test_governance_core_relations_preserved(self, monkeypatch): SysMLRepository._instance = None repo = SysMLRepository.get_instance() diag = repo.create_diagram("Governance Diagram") @@ -278,8 +235,8 @@ def test_governance_core_relations_deduplicated(self, monkeypatch): win = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) core = win._frame_loaders["Governance Core"].__defaults__[0] - assert core["relations"] == ["Propagate", "Re-use"] - assert core["externals"]["Artifacts"]["relations"] == ["Propagate", "Re-use"] + assert core["relations"] == ["Propagate", "Propagate", "Re-use"] + assert core["externals"]["Artifacts"]["relations"] == ["Propagate", "Re-use", "Re-use"] assert core["externals"]["Entities"]["relations"] == ["Propagate"] @@ -452,7 +409,6 @@ def test_core_survives_global_filters_between_windows(self, monkeypatch): first = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) first.relation_tools = ["Trace"] - first._has_relation_filters = True first._rebuild_toolboxes() second = GovernanceDiagramWindow(None, None, diagram_id=diag.diag_id) @@ -549,29 +505,3 @@ def test_non_core_relations_persist_across_rebuilds(self, monkeypatch): assert entities3["relations"] == ["Link"] -class TestGovernanceCoreHelperExemptions: - """Verify helper functions never strip Governance Core relations.""" - - def test_filter_global_relations_skips_core(self): - defs = { - "Governance Core": { - "nodes": [], - "relations": ["Trace"], - "externals": {}, - } - } - arch._filter_global_relations(defs, None, {"Trace"}) - assert defs["Governance Core"]["relations"] == ["Trace"] - - def test_deduplicate_relations_preserves_categories(self): - defs = { - "Other": {"nodes": [], "relations": ["Link"], "externals": {}}, - "Governance Core": { - "nodes": [], - "relations": ["Link", "Trace"], - "externals": {}, - }, - } - arch._deduplicate_relations(defs, None) - assert defs["Governance Core"]["relations"] == ["Link", "Trace"] - assert defs["Other"]["relations"] == ["Link"]