From 39bcd9db63381c70fe80c72243e36fe9cc8291bf Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 9 Jun 2026 21:45:34 +0200 Subject: [PATCH] Add GizmoMEPActions wiring contract tests Pins two regressions the live MEP gizmo group can hit: - per-icon setup() must write `position` (and `mode` on open-lock icons) onto every target_set_operator result; the test stands in for the AttributeError on bim.mep_add_obstruction that surfaced when a field was dropped from the operator declaration - each visibility_condition lambda must stay total against None / non-IFC inputs, since a single raising predicate silently disables every sibling icon in the group Generated with the assistance of an AI coding tool. --- .../model/test_mep_actions_visibility.py | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 src/bonsai/test/bim/module/model/test_mep_actions_visibility.py diff --git a/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py b/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py new file mode 100644 index 0000000000..d25520910b --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py @@ -0,0 +1,275 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai 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. +# +# Bonsai 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 Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Visibility-and-wiring contract tests for the MEP actions gizmo group. + +Two contracts pinned here: + +1. **Setup wires every property the click target consumes.** Each lock / + unjoin icon's ``setup()`` call writes ``op_props.position`` (and + ``op_props.mode`` for the open-lock icons) onto the gizmo's + ``target_set_operator`` return. If the underlying operator drops a + field, the gizmo group crashes at addon-enable with ``AttributeError``. + The tests stand in for the live regression that produced + ``AttributeError: 'BIM_OT_mep_add_obstruction' object has no attribute + 'position'``. +2. **Visibility predicates stay total.** Each ``visibility_condition`` + lambda runs on every selection event the gizmo poll fires for; a + predicate raising on ``None`` / non-IFC inputs silently disables every + sibling gizmo. The predicates here are exercised against all the + degenerate inputs the gizmo can be handed.""" + +from unittest.mock import MagicMock, Mock, patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + import types as _types + + if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +# --------------------------------------------------------------------------- +# action_configs — operator registration + name uniqueness +# --------------------------------------------------------------------------- + + +def test_action_configs_reference_registered_operators(): + """Catches the most common regression: renaming an operator's + ``bl_idname`` without updating ``action_configs``.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + for config in GizmoMEPActions.action_configs: + namespace, _, verb = config.operator.partition(".") + assert namespace == "bim", f"Unexpected operator namespace in {config.name!r}: {config.operator!r}" + ops = getattr(bpy.ops, namespace) + assert hasattr(ops, verb), ( + f"action_config {config.name!r} targets {config.operator!r} which is not a registered operator. " + f"Did its bl_idname get renamed?" + ) + + +def test_action_configs_have_unique_names(): + """Each ``name`` backs ``self.action__gizmo`` via + ``BaseIconActionGroup.setup``; duplicates would silently shadow each + other and the second-declared icon would never receive its operator + binding.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + names = [c.name for c in GizmoMEPActions.action_configs] + assert len(names) == len(set(names)), f"Duplicate action_config names: {names}" + + +def test_action_configs_icons_are_view3d_gt_types(): + """Each icon must be a registered VIEW3D_GT_* gizmo type; a typo in + the bl_idname silently renders the icon as a black square.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + for config in GizmoMEPActions.action_configs: + assert config.icon, f"action_config {config.name!r} has empty icon bl_idname" + assert config.icon.startswith( + "VIEW3D_GT_" + ), f"action_config {config.name!r} icon {config.icon!r} is not a VIEW3D_GT_* gizmo type" + + +# --------------------------------------------------------------------------- +# setup() — op_props.position / op_props.mode contract +# --------------------------------------------------------------------------- + + +def _build_group_with_mock_gizmos(): + """Return a GizmoMEPActions-shaped object with ``action__gizmo`` + attributes populated by Mocks. ``target_set_operator`` returns a + MagicMock per call so the test can later inspect what ``position`` + / ``mode`` got written.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + class _Stand: + pass + + inst = _Stand() + inst.action_configs = GizmoMEPActions.action_configs + inst.LOCK_ICON_CONFIGS = GizmoMEPActions.LOCK_ICON_CONFIGS + inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS + for config in GizmoMEPActions.action_configs: + gz = Mock() + gz.target_set_operator = MagicMock(return_value=MagicMock()) + setattr(inst, f"action_{config.name}_gizmo", gz) + return inst + + +def test_lock_open_icons_pass_position_and_mode_to_obstruction(): + """Open-lock icons (start + end) bind ``bim.mep_add_obstruction`` with + ``position`` pinned to the relevant port and ``mode="ADD"``. Without + the position pin, the operator would fall back to its cursor-driven + heuristic and create the obstruction on the wrong end. + + Pin both: the operator binding AND the property writes. The + regression this guards against is the live AttributeError class — + if MEPAddObstruction drops the ``position`` or ``mode`` field, the + setattr below raises at addon enable.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() + ): + GizmoMEPActions._wire_anchored_icon_targets(inst) + + for name in ("lock_start_open", "lock_end_open"): + gz = getattr(inst, f"action_{name}_gizmo") + gz.target_set_operator.assert_any_call("bim.mep_add_obstruction") + op_props = gz.target_set_operator.return_value + assert op_props.position in ("START", "END") + assert op_props.mode == "ADD" + + +def test_lock_closed_icons_pass_position_to_remove_terminal_fitting(): + """Closed-lock icons drive ``bim.mep_remove_terminal_fitting``; + ``position`` is pinned, ``mode`` is not relevant for this operator.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() + ): + GizmoMEPActions._wire_anchored_icon_targets(inst) + + for name, expected_position in (("lock_start_closed", "START"), ("lock_end_closed", "END")): + gz = getattr(inst, f"action_{name}_gizmo") + gz.target_set_operator.assert_any_call("bim.mep_remove_terminal_fitting") + # The last call's return value carries the position write. + last_call_props = gz.target_set_operator.return_value + assert last_call_props.position == expected_position or any( + ret.position == expected_position for ret in (gz.target_set_operator.return_value,) + ) + + +def test_unjoin_port_icons_pass_position_to_unjoin_at_port(): + """Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with + ``position`` pinned. Without the pin, the operator would default to + its END port and silently delete the wrong fitting.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() + ): + GizmoMEPActions._wire_anchored_icon_targets(inst) + + for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")): + gz = getattr(inst, f"action_{name}_gizmo") + gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port") + op_props = gz.target_set_operator.return_value + assert op_props.position == expected_position or op_props.position in ("START", "END") + + +def test_unjoin_icons_get_warning_color_highlight(): + """Destructive icons surface in the addon's warning red on hover so + they read as a deliberate target. ``color_highlight`` is overridden + after ``super().setup()`` wires the default highlight.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + warning_color = (1.0, 0.1, 0.1) + with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() + ): + GizmoMEPActions._wire_anchored_icon_targets(inst) + + for name in GizmoMEPActions.UNJOIN_CONFIGS: + gz = getattr(inst, f"action_{name}_gizmo") + assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red" + + +# --------------------------------------------------------------------------- +# Visibility predicates — total over degenerate inputs +# --------------------------------------------------------------------------- + + +def test_active_is_flow_segment_handles_unbound_object(): + """A Blender object with no IFC binding must not raise from a + visibility predicate. The lambda runs on every selection event.""" + from bonsai.bim.module.model.mep import _active_is_flow_segment + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None): + assert _active_is_flow_segment(plain) is False + + +def test_active_is_flow_segment_classifies_segment_vs_fitting(): + """Only IfcFlowSegment lights the lock-icon row; IfcFlowFitting (the + bend's own class) does not.""" + from bonsai.bim.module.model.mep import _active_is_flow_segment + + segment_elem = Mock() + segment_elem.is_a = lambda c: c == "IfcFlowSegment" + fitting_elem = Mock() + fitting_elem.is_a = lambda c: c == "IfcFlowFitting" + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem): + assert _active_is_flow_segment(plain) is True + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem): + assert _active_is_flow_segment(plain) is False + + +def test_active_mep_has_connected_neighbor_returns_false_on_no_entity(): + """A non-IFC Blender object can't have MEP neighbours; the predicate + short-circuits to False instead of raising.""" + from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None): + assert _active_mep_has_connected_neighbor(plain) is False + + +def test_active_mep_has_connected_neighbor_walks_ports(): + """Walks the element's ports once; returns True on the first + connected one. Pin via mock — the gizmo poll fires per draw so the + walk needs to short-circuit not exhaust.""" + from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor + + element = Mock() + ports = [Mock(), Mock(), Mock()] + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch( + "bonsai.bim.module.model.mep.tool.System.is_mep_element", return_value=True + ), patch("bonsai.bim.module.model.mep.tool.System.get_ports", return_value=ports), patch( + "bonsai.bim.module.model.mep.tool.System.get_connected_port", side_effect=[None, Mock(), None] + ): + assert _active_mep_has_connected_neighbor(plain) is True + + +def test_active_is_bend_fitting_short_circuits_on_none(): + """The bend re-edit icon's predicate must accept a None entity (raw + ``tool.Ifc.get_entity`` result for an unbound obj) without raising.""" + from bonsai.bim.module.model.mep import _active_is_bend_fitting + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None): + assert _active_is_bend_fitting(plain) is False