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)
+ )