diff --git a/src/bonsai/test/bim/module/model/test_mep_actions_cache.py b/src/bonsai/test/bim/module/model/test_mep_actions_cache.py new file mode 100644 index 0000000000..7f0a144c7e --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_actions_cache.py @@ -0,0 +1,268 @@ +# 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. + +"""Cache-invalidation tests for ``GizmoMEPActions.position_gizmos``. + +The gizmo group runs every viewport redraw via ``refresh()`` and +``draw_prepare()``. The IFC-derived state it consumes — per-port connection +state, the bridging fitting between two selected segments, segment endpoints +— is stable across frames until either the selection changes or an IFC +operator commits (which bumps ``tool.Parametric.get_geom_generation``). +These tests pin that the per-frame redraw reuses the cached state.""" + +from unittest.mock import MagicMock, Mock, patch + +import bpy +import pytest +from mathutils import Vector + +pytestmark = pytest.mark.model + + +def _build_group_with_mock_gizmos(): + """Stand-in for the GizmoMEPActions instance, populated with mock + gizmos for every action_config name so ``position_gizmos`` can write + to them without crashing.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + class _Stand: + pass + + inst = _Stand() + inst.action_configs = GizmoMEPActions.action_configs + inst.ENDPOINT_CONFIGS = GizmoMEPActions.ENDPOINT_CONFIGS + inst.BEND_ANCHOR_CONFIGS = GizmoMEPActions.BEND_ANCHOR_CONFIGS + inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS + inst.ICON_ROW_Z_OFFSET = GizmoMEPActions.ICON_ROW_Z_OFFSET + inst.ICON_SPACING_X = GizmoMEPActions.ICON_SPACING_X + inst.ICON_SCALE = GizmoMEPActions.ICON_SCALE + inst.ENDPOINT_SCALE_RATIO = GizmoMEPActions.ENDPOINT_SCALE_RATIO + inst._scale_for_config = GizmoMEPActions._scale_for_config.__get__(inst) + inst.position_gizmos = GizmoMEPActions.position_gizmos.__get__(inst) + for config in GizmoMEPActions.action_configs: + gz = Mock() + setattr(inst, f"action_{config.name}_gizmo", gz) + return inst + + +def _mock_segment_obj(name: str = "Segment.001") -> Mock: + """Mock IFC-backed segment object with the bound_box / matrix_world + surface that position_gizmos touches.""" + obj = Mock() + obj.name = name + obj.bound_box = [ + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), + (1.0, 0.0, 1.0), + (1.0, 1.0, 1.0), + (0.0, 1.0, 1.0), + ] + obj.matrix_world = Mock() + obj.matrix_world.__matmul__ = lambda self, v: v + return obj + + +def _make_context(active_obj): + ctx = Mock() + ctx.active_object = active_obj + ctx.scene = Mock() + ctx.scene.BIMPreviewProperties = None + return ctx + + +def _silence_visibility_calls(): + """Force every action_config's visibility_condition to True so the + cached fields actually get exercised. Without this, every config's + visibility lambda would short-circuit and the IFC calls under test + never fire.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + sentinel_lambdas = [] + for config in GizmoMEPActions.action_configs: + sentinel_lambdas.append((config, config.visibility_condition)) + config.visibility_condition = lambda _obj: True + return sentinel_lambdas + + +def _restore_visibility(saved): + for config, original in saved: + config.visibility_condition = original + + +@pytest.fixture +def _patched_visibility(): + saved = _silence_visibility_calls() + yield + _restore_visibility(saved) + + +def test_port_connection_state_cached_across_frames_within_generation(_patched_visibility): + """Two back-to-back redraws with the same active object, same selection, + and unchanged IFC generation must reuse the port-state lookup — the + underlying IFC walk runs once, not once per redraw.""" + inst = _build_group_with_mock_gizmos() + active = _mock_segment_obj("Segment.001") + other = _mock_segment_obj("Segment.002") + context = _make_context(active) + + element = Mock() + element.is_a = lambda c: c == "IfcFlowSegment" + + call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0} + + def counting_port_state(elem, at_start): + call_counts["port_connection_state"] += 1 + return "FREE" + + def counting_find_fitting(a, b): + call_counts["find_fitting_between_segments"] += 1 + return None + + def counting_join_location(): + call_counts["compute_mep_join_location"] += 1 + return Vector((0.0, 0.0, 0.0)) + + patches = [ + patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=42), + patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]), + patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), + patch( + "bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis", + return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))), + ), + patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state), + patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting), + patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location), + patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()), + patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()), + ] + + with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], patches[7], patches[8]: + inst.position_gizmos(context) + first = dict(call_counts) + inst.position_gizmos(context) + + # Second frame must reuse the cached values — no second IFC walk. + assert call_counts["port_connection_state"] == first["port_connection_state"] + assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"] + assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"] + + +def test_generation_advance_invalidates_cache(_patched_visibility): + """An IFC operator commit bumps ``get_geom_generation`` — the next + redraw must recompute port state and friends to pick up any + downstream changes.""" + inst = _build_group_with_mock_gizmos() + active = _mock_segment_obj("Segment.001") + other = _mock_segment_obj("Segment.002") + context = _make_context(active) + + element = Mock() + element.is_a = lambda c: c == "IfcFlowSegment" + + port_call_count = {"n": 0} + fitting_call_count = {"n": 0} + + def counting_port_state(elem, at_start): + port_call_count["n"] += 1 + return "FREE" + + def counting_find_fitting(a, b): + fitting_call_count["n"] += 1 + return None + + gen_state = {"gen": 1} + + with patch( + "bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"] + ), patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]), patch( + "bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element + ), patch( + "bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis", + return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))), + ), patch( + "bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state + ), patch( + "bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting + ), patch( + "bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0)) + ), patch( + "bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock() + ), patch( + "bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock() + ): + inst.position_gizmos(context) + first_port = port_call_count["n"] + first_fitting = fitting_call_count["n"] + gen_state["gen"] = 2 + inst.position_gizmos(context) + + assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance" + assert ( + fitting_call_count["n"] > first_fitting + ), "find_fitting_between_segments must recompute after generation advance" + + +def test_selection_change_invalidates_cache(_patched_visibility): + """Changing the selection (e.g. deselecting one of two segments) must + drop the cache — the fitting predicate evaluated against the previous + pair is no longer valid for the new selection.""" + inst = _build_group_with_mock_gizmos() + active = _mock_segment_obj("Segment.001") + other_a = _mock_segment_obj("Segment.002") + other_b = _mock_segment_obj("Segment.003") + context = _make_context(active) + + element = Mock() + element.is_a = lambda c: c == "IfcFlowSegment" + + fitting_call_count = {"n": 0} + + def counting_find_fitting(a, b): + fitting_call_count["n"] += 1 + return None + + selection_state = {"selected": [active, other_a]} + + with patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=1), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", side_effect=lambda: selection_state["selected"] + ), patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch( + "bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis", + return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))), + ), patch( + "bonsai.bim.module.model.mep.port_connection_state", return_value="FREE" + ), patch( + "bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting + ), patch( + "bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0)) + ), patch( + "bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock() + ), patch( + "bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock() + ): + inst.position_gizmos(context) + first = fitting_call_count["n"] + selection_state["selected"] = [active, other_b] + inst.position_gizmos(context) + + assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change" diff --git a/src/bonsai/test/bim/module/model/test_mep_bend_preview_cache.py b/src/bonsai/test/bim/module/model/test_mep_bend_preview_cache.py new file mode 100644 index 0000000000..52b232436a --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_bend_preview_cache.py @@ -0,0 +1,188 @@ +# 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. + +"""Cache-invalidation tests for ``cached_compute_bend_preview_polylines``. + +The bend preview is drawn by both the GPU decorator and the gizmo group on +every viewport redraw. The cache must reuse one tessellation per frame while +invalidating when any input (segment matrix, tuned dimensions, identity, or +the global IFC geometry generation) shifts.""" + +from unittest.mock import Mock, patch + +import pytest +from mathutils import Matrix + +pytestmark = pytest.mark.model + + +def _mock_obj(name: str, matrix: Matrix) -> Mock: + obj = Mock() + obj.name = name + obj.matrix_world = matrix + return obj + + +@pytest.fixture(autouse=True) +def _clear_memo(): + from bonsai.bim.module.model import mep + + mep._bend_preview_memo = None + yield + mep._bend_preview_memo = None + + +def _patches(call_count_sentinel: dict): + from bonsai import tool + from bonsai.bim.module.model import mep + + def counting_compute(*args, **kwargs): + call_count_sentinel["calls"] += 1 + return {"valid": True, "leg_a": None, "leg_b": None, "arc": []} + + return ( + patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute), + patch.object(tool.Parametric, "get_geom_generation", return_value=call_count_sentinel.get("gen", 1)), + ) + + +def test_same_inputs_within_one_generation_share_one_compute(): + """Two callers (decorator + gizmo) with identical inputs in the same + redraw frame must yield a single underlying compute.""" + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 7} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + + assert sentinel["calls"] == 1 + + +def test_radius_change_invalidates_cache(): + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.4) # radius changed + + assert sentinel["calls"] == 2 + + +def test_start_length_change_invalidates_cache(): + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, b, 0.15, 0.2, 0.3) # start_length changed + + assert sentinel["calls"] == 2 + + +def test_end_length_change_invalidates_cache(): + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, b, 0.1, 0.25, 0.3) # end_length changed + + assert sentinel["calls"] == 2 + + +def test_segment_matrix_change_invalidates_cache(): + """Moving either segment changes the bend geometry — the cache must + recompute even when the IFC has not advanced.""" + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + b.matrix_world = Matrix.Translation((2, 0, 0)) + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + + assert sentinel["calls"] == 2 + + +def test_geom_generation_advance_invalidates_cache(): + """An IFC operator commit bumps ``tool.Parametric.get_geom_generation``; + the cache must recompute on the next call to pick up downstream geometry + changes that don't surface in the object's matrix_world.""" + from bonsai import tool + from bonsai.bim.module.model import mep + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0} + + def counting_compute(*args, **kwargs): + sentinel["calls"] += 1 + return {"valid": True, "leg_a": None, "leg_b": None, "arc": []} + + gen_state = {"gen": 1} + with patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute): + with patch.object(tool.Parametric, "get_geom_generation", side_effect=lambda: gen_state["gen"]): + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + gen_state["gen"] = 2 + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + + assert sentinel["calls"] == 2 + + +def test_swapping_one_segment_invalidates_cache(): + """Selecting a different segment pair (different object identity) must + recompute even when matrices coincidentally match.""" + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + c = _mock_obj("seg_c", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, c, 0.1, 0.2, 0.3) + + assert sentinel["calls"] == 2 diff --git a/src/bonsai/test/bim/module/model/test_mep_distribution_fit_smoke.py b/src/bonsai/test/bim/module/model/test_mep_distribution_fit_smoke.py new file mode 100644 index 0000000000..55d325d76c --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_distribution_fit_smoke.py @@ -0,0 +1,227 @@ +# 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. + +"""Smoke coverage for ``RegenerateDistributionElement`` and +``FitFlowSegments``. + +Both operators carry substantial branching that the bend / port test +files don't reach. These tests pin: + +- the operator-registration contract (bl_idname / bl_label / bl_options), +- ``FitFlowSegments`` dispatch table — 0 / 1 / mixed-class selections + resolve to the documented no-op or operator dispatch without raising, +- ``RegenerateDistributionElement`` runs on a leaf element (no connected + neighbours) without crashing on the recursion entry point. + +Deeper geometry-tree behaviour (multi-branch traversal, port-aligned +translation, segment regrowth) is deferred to integration testing +against real IFC fixtures; the smoke tests are explicitly the +oversight-prevention floor, not the full contract.""" + +from unittest.mock import MagicMock, Mock, patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +def _segment(ifc_class: str = "IfcFlowSegment"): + """Stand-in for an IfcFlowSegment / subclass entity. + + ``is_a("IfcFlowSegment" | )`` returns True; ``is_a()`` with + no args returns the class name (the IfcOpenShell API exposes both + forms — ``FitFlowSegments`` calls ``element.is_a()`` to record the + selection's class for the mixed-class refusal check).""" + + def fake_is_a(c=None): + if c is None: + return ifc_class + return c in {"IfcFlowSegment", ifc_class} + + e = Mock() + e.is_a = fake_is_a + return e + + +def _make_op(**fields): + op = Mock() + for k, v in fields.items(): + setattr(op, k, v) + op.report = MagicMock() + return op + + +# --------------------------------------------------------------------------- +# Registration smoke +# --------------------------------------------------------------------------- + + +def test_regenerate_distribution_element_is_registered(): + """``RegenerateDistributionElement`` is the entry point for the + distribution-tree repropagation. Pin the bl_idname so a typo in the + classes tuple wouldn't silently drop the operator.""" + from bonsai.bim.module.model import mep + + assert mep.RegenerateDistributionElement.bl_idname == "bim.regenerate_distribution_element" + assert mep.RegenerateDistributionElement.bl_label == "Regenerate Distribution Element" + assert mep.RegenerateDistributionElement.bl_options == {"REGISTER", "UNDO"} + + +def test_fit_flow_segments_is_registered(): + """``FitFlowSegments`` is the cursor-based "add a fitting from the + current selection" entry point. Pin the registration contract so the + operator stays callable from the workspace tool.""" + from bonsai.bim.module.model import mep + + assert mep.FitFlowSegments.bl_idname == "bim.fit_flow_segments" + assert mep.FitFlowSegments.bl_label == "Fit Flow Segments" + assert mep.FitFlowSegments.bl_options == {"REGISTER", "UNDO"} + + +# --------------------------------------------------------------------------- +# FitFlowSegments dispatch table +# --------------------------------------------------------------------------- + + +def test_fit_flow_segments_with_no_selection_is_noop(): + """Nothing selected → no fitting type resolved → operator returns + without dispatching any ``bim.mep_add_*`` op. The user-facing + contract is "this is a tool you fire with a selection"; the silent + no-op on empty selection is intentional (no popup, no error).""" + from bonsai.bim.module.model import mep + + context = MagicMock() + context.selected_objects = [] + + op = _make_op() + with patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object( + mep.MEPAddBend, "_execute", return_value=None + ) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition: + mep.FitFlowSegments._execute(op, context=context) + + obstruction.assert_not_called() + bend.assert_not_called() + transition.assert_not_called() + + +def test_fit_flow_segments_with_single_segment_dispatches_obstruction(): + """Exactly one IfcFlowSegment selected → OBSTRUCTION fitting type, + delegates to ``bim.mep_add_obstruction`` which handles the + cursor-anchored placement. + + ``bpy.ops`` resolves operator dispatch through Blender's internal id + table, not through Python attribute access, so a Python-level patch + on ``bpy.ops.bim.mep_add_obstruction`` doesn't intercept the call. + Patch the operator's ``_execute`` instead — same effect, exercises + the real dispatch path that the user hits at runtime.""" + from bonsai.bim.module.model import mep + + segment_obj = MagicMock() + segment_profile = MagicMock() + segment_entity = _segment("IfcPipeSegment") + + context = MagicMock() + context.selected_objects = [segment_obj] + + op = _make_op() + with patch.object(mep.tool.Ifc, "get_entity", return_value=segment_entity), patch.object( + mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile + ), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object( + mep.MEPAddBend, "_execute", return_value=None + ) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition: + mep.FitFlowSegments._execute(op, context=context) + + assert obstruction.call_count == 1 + bend.assert_not_called() + transition.assert_not_called() + + +def test_fit_flow_segments_refuses_mixed_pipe_and_duct(): + """Selecting one IfcPipeSegment + one IfcDuctSegment → the operator + bails out before any fitting dispatch. The user-facing path is + "select segments of one kind"; mixing pipe + duct would create an + invalid IFC fitting type.""" + from bonsai.bim.module.model import mep + + pipe_obj = MagicMock() + duct_obj = MagicMock() + pipe_entity = _segment("IfcPipeSegment") + duct_entity = _segment("IfcDuctSegment") + profile = MagicMock() + + context = MagicMock() + context.selected_objects = [pipe_obj, duct_obj] + + def fake_get_entity(obj): + return pipe_entity if obj is pipe_obj else duct_entity + + op = _make_op() + with patch.object(mep.tool.Ifc, "get_entity", side_effect=fake_get_entity), patch.object( + mep.tool.Model, "get_flow_segment_profile", return_value=profile + ), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object( + mep.MEPAddBend, "_execute", return_value=None + ) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition: + mep.FitFlowSegments._execute(op, context=context) + + obstruction.assert_not_called() + bend.assert_not_called() + transition.assert_not_called() + + +# --------------------------------------------------------------------------- +# RegenerateDistributionElement +# --------------------------------------------------------------------------- + + +def test_regenerate_distribution_element_on_leaf_is_safe(): + """A distribution element with no connected neighbours → the inner + queue stays empty → the operator returns cleanly without entering + the per-branch processing path. + + This pins the safety floor: the recursion entry point should not + crash on a single-element graph, which is the most common shape + when a user fires this operator on an isolated segment.""" + from bonsai.bim.module.model import mep + + leaf_element = _segment("IfcPipeSegment") + leaf_obj = MagicMock() + + context = MagicMock() + context.active_object = leaf_obj + + fake_active = MagicMock() + fake_active.is_a = lambda c: False # bpy.context.active_object stub + + op = _make_op() + with patch.object(mep.tool.Ifc, "get_entity", return_value=leaf_element), patch( + "ifcopenshell.util.system.get_connected_to", return_value=[] + ), patch("ifcopenshell.util.system.get_connected_from", return_value=[]), patch.object( + mep.tool.Ifc, "get", return_value=MagicMock() + ), patch( + "ifcopenshell.util.unit.calculate_unit_scale", return_value=1.0 + ), patch.object( + bpy, "context", new=context + ): + mep.RegenerateDistributionElement._execute(op, context=context) + + # The contract on a leaf is "nothing to do". No exception, no IFC + # mutation. The bpy.ops dispatch table inside process_branch never + # fires because queue is empty. diff --git a/src/bonsai/test/bim/test_preview_cancel_ops_forward_compat.py b/src/bonsai/test/bim/test_preview_cancel_ops_forward_compat.py new file mode 100644 index 0000000000..d67f258eaa --- /dev/null +++ b/src/bonsai/test/bim/test_preview_cancel_ops_forward_compat.py @@ -0,0 +1,144 @@ +# 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. + +"""Forward-compat AST contract for the preview cancellation registry. + +Every ``PointerProperty`` child of ``BIMPreviewProperties`` whose target +PropertyGroup declares an ``is_active`` BoolProperty is a Scene-level +preview. Each must have a matching ``(child_attr, cancel_op_name)`` entry +in ``preview_base.PREVIEW_CANCEL_OPS`` so the Esc dispatcher and the +``load_post`` stale-flag discard both cover it. + +A new preview type that defines its own Enable / Decorator without +registering the cancel pair will silently ignore Esc and leave a stuck +``is_active`` flag across file reloads — exactly the failure mode the +sibling forward-compat guards exist to prevent.""" + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.model + + +BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai" +PROP_FILE = BONSAI_ROOT / "bim" / "module" / "model" / "prop.py" +UMBRELLA_CLASS = "BIMPreviewProperties" + + +def _find_class(tree: ast.Module, name: str) -> ast.ClassDef | None: + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == name: + return node + return None + + +def _iter_pointer_property_children(class_node: ast.ClassDef): + """Yield ``(attr_name, target_class_name)`` for each + ``: bpy.props.PointerProperty(type=)`` annotated + assignment in the umbrella class body. + + Bonsai follows the Blender convention where the property call lives in + the *annotation* (PEP 526 syntax) rather than the value — Blender's + PropertyGroup metaclass picks it up at class creation time.""" + for node in class_node.body: + if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name): + continue + if not isinstance(node.annotation, ast.Call): + continue + func = node.annotation.func + if not isinstance(func, ast.Attribute) or func.attr != "PointerProperty": + continue + for kw in node.annotation.keywords: + if kw.arg == "type" and isinstance(kw.value, ast.Name): + yield node.target.id, kw.value.id + break + + +def _class_has_is_active_bool(class_node: ast.ClassDef) -> bool: + """Return True if ``class_node`` declares ``is_active: bpy.props.BoolProperty(...)``.""" + for node in class_node.body: + if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name): + continue + if node.target.id != "is_active": + continue + if not isinstance(node.annotation, ast.Call): + continue + func = node.annotation.func + if isinstance(func, ast.Attribute) and func.attr == "BoolProperty": + return True + return False + + +def test_every_preview_propertygroup_is_registered_in_cancel_ops() -> None: + from bonsai.bim.module.model import preview_base + + registered_attrs = {attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS} + + tree = ast.parse(PROP_FILE.read_text(encoding="utf-8")) + umbrella = _find_class(tree, UMBRELLA_CLASS) + assert umbrella is not None, ( + f"Could not find {UMBRELLA_CLASS!r} in {PROP_FILE}. Either the umbrella class " + "was renamed (this test needs updating) or prop.py was restructured." + ) + + preview_children: list[tuple[str, str]] = [] + for attr, target_class_name in _iter_pointer_property_children(umbrella): + target = _find_class(tree, target_class_name) + if target is None: + continue + if _class_has_is_active_bool(target): + preview_children.append((attr, target_class_name)) + + assert preview_children, ( + "No PointerProperty children with ``is_active`` BoolProperty found under " + f"{UMBRELLA_CLASS}. Either the preview convention has been refactored away " + "(this test needs updating) or prop.py was restructured." + ) + + missing = [(attr, cls) for attr, cls in preview_children if attr not in registered_attrs] + assert not missing, ( + "Every Scene-level preview PropertyGroup must have a matching " + "(child_attr, cancel_op_name) tuple in preview_base.PREVIEW_CANCEL_OPS so " + "Esc dispatch and load_post stale-flag discard cover it. Missing entries:\n " + + "\n ".join(f"BIMPreviewProperties.{attr} (target={cls!r})" for attr, cls in missing) + ) + + +def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None: + """The reverse contract: a stale entry in ``PREVIEW_CANCEL_OPS`` whose + PropertyGroup has been deleted would silently leak to every Esc press + (dispatching to a missing operator raises ``AttributeError`` inside + ``try_cancel_active_preview``). Pin that the registry never goes + stale relative to ``BIMPreviewProperties``.""" + from bonsai.bim.module.model import preview_base + + tree = ast.parse(PROP_FILE.read_text(encoding="utf-8")) + umbrella = _find_class(tree, UMBRELLA_CLASS) + assert umbrella is not None + + declared_attrs = {attr for attr, _target in _iter_pointer_property_children(umbrella)} + orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs] + assert not orphaned, ( + "PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer " + f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n " + + "\n ".join(orphaned) + )