diff --git a/src/bonsai/test/bim/test_parametric_lifecycle.py b/src/bonsai/test/bim/test_parametric_lifecycle.py
new file mode 100644
index 0000000000..4142f51e63
--- /dev/null
+++ b/src/bonsai/test/bim/test_parametric_lifecycle.py
@@ -0,0 +1,409 @@
+# 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.
+
+"""Unit coverage for the shared parametric-edit lifecycle mixins.
+
+``bonsai.bim.parametric_lifecycle`` is the load-bearing path for 4 of 6
+parametric features (door, window, railing, roof). The registry smoke test
+elsewhere verifies operators are wired up; the mixins' own state-transition
+contracts are tested here.
+
+The mixins are exercised through minimal in-test subclasses that supply the
+abstract hooks (``_is_element_type``, ``_get_props``, etc.). All ``tool.*`` and
+``ifcopenshell.*`` references at the module top of ``parametric_lifecycle`` are
+patched at the module attribute (not the source module) so each test sees
+isolated mock state."""
+
+import json
+from typing import ClassVar
+from unittest import mock
+
+import pytest
+
+pytestmark = pytest.mark.model
+
+
+@pytest.fixture(autouse=True)
+def _require_real_bpy():
+ import types as _types
+
+ import bpy
+
+ if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
+ pytest.skip("requires real Blender (bpy is mocked or absent)")
+
+
+class _FakeProps:
+ """Stand-in for ``BIMProperties`` — records what was set so tests can
+ assert state transitions without instantiating real PropertyGroups."""
+
+ def __init__(self):
+ self.is_editing = False
+ self.last_kwargs = None
+ self.general = {"width": 1000}
+ self.lining = {"thickness": 50}
+ self.panel = {"material": "wood"}
+
+ def set_props_kwargs_from_ifc_data(self, data):
+ self.last_kwargs = dict(data)
+
+ def get_general_kwargs(self, convert_to_project_units=True):
+ return dict(self.general)
+
+ def get_lining_kwargs(self, convert_to_project_units=True):
+ return dict(self.lining)
+
+ def get_panel_kwargs(self, convert_to_project_units=True):
+ return dict(self.panel)
+
+
+def _make_obj(props):
+ obj = mock.Mock()
+ obj.props = props
+ obj.name = "TestObj"
+ return obj
+
+
+def _make_pset_text(general, lining, panel):
+ payload = {"lining_properties": lining, "panel_properties": panel, **general}
+ return json.dumps(payload)
+
+
+# ----------------------------------------------------------------------
+# FeatureModifierEditMixin (door/window pattern)
+# ----------------------------------------------------------------------
+
+
+def _door_mixin_cls(match=True, raise_on_update=False):
+ from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
+
+ raised = raise_on_update
+
+ class _TestDoorMixin(FeatureModifierEditMixin):
+ pset_name: ClassVar[str] = "BBIM_Door"
+ representations_called: ClassVar[list] = []
+
+ @classmethod
+ def _is_element_type(cls, element):
+ return match
+
+ @classmethod
+ def _get_props(cls, obj):
+ return obj.props
+
+ @classmethod
+ def _update_modifier_representation(cls, obj, context):
+ cls.representations_called.append(obj)
+ if raised:
+ raise RuntimeError("simulated representation failure")
+
+ return _TestDoorMixin
+
+
+@pytest.fixture
+def patched_tool_and_ifc():
+ """Patch ``tool`` and ``ifcopenshell.*`` references on the lifecycle module.
+
+ Yields ``(mock_tool, mock_ifc_util_element, mock_ifc_api_pset,
+ mock_ifc_util_rep, mock_core_geometry)`` so tests can configure return
+ values and assert call args."""
+ target = "bonsai.bim.parametric_lifecycle"
+ with mock.patch(f"{target}.tool") as mock_tool, mock.patch(f"{target}.ifcopenshell") as mock_ifc, mock.patch(
+ f"{target}.bonsai"
+ ) as mock_bonsai:
+ # Element returned by tool.Ifc.get_entity is reused across mocks.
+ element = mock.Mock(name="entity")
+ mock_tool.Ifc.get_entity.return_value = element
+ mock_tool.Ifc.get.return_value = mock.Mock(name="ifc_file")
+ mock_tool.Model.get_constituents_props_data.return_value = {"materials": []}
+ mock_tool.Pset.get_element_pset.return_value = mock.Mock(name="pset")
+ mock_ifc.util.element.get_type.return_value = None # skip thumbnail mark
+ yield {
+ "tool": mock_tool,
+ "ifc": mock_ifc,
+ "bonsai": mock_bonsai,
+ "element": element,
+ }
+
+
+def test_feature_modifier_enable_one_sets_is_editing_and_loads_kwargs(patched_tool_and_ifc):
+ props = _FakeProps()
+ obj = _make_obj(props)
+ patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
+ {"width": 1234}, {"thickness": 50}, {"material": "wood"}
+ )
+
+ cls = _door_mixin_cls(match=True)
+ cls._enable_one(obj)
+
+ assert props.is_editing is True
+ assert props.last_kwargs is not None
+ assert props.last_kwargs["width"] == 1234
+ assert props.last_kwargs["thickness"] == 50
+ assert props.last_kwargs["material"] == "wood"
+ assert "materials" in props.last_kwargs # from get_constituents_props_data
+
+
+def test_feature_modifier_enable_one_noop_when_element_not_match(patched_tool_and_ifc):
+ props = _FakeProps()
+ obj = _make_obj(props)
+
+ cls = _door_mixin_cls(match=False)
+ cls._enable_one(obj)
+
+ assert props.is_editing is False
+ assert props.last_kwargs is None
+ # get_pset must not be called when _is_element_type returns False — the
+ # _resolve guard short-circuits before reading pset data.
+ patched_tool_and_ifc["ifc"].util.element.get_pset.assert_not_called()
+
+
+def test_feature_modifier_enable_one_noop_when_no_entity(patched_tool_and_ifc):
+ """tool.Ifc.get_entity returning None must short-circuit before predicate runs."""
+ props = _FakeProps()
+ obj = _make_obj(props)
+ patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
+
+ cls = _door_mixin_cls(match=True)
+ cls._enable_one(obj)
+
+ assert props.is_editing is False
+
+
+def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_tool_and_ifc):
+ props = _FakeProps()
+ props.is_editing = True
+ obj = _make_obj(props)
+ ctx = mock.Mock(name="context")
+
+ cls = _door_mixin_cls(match=True)
+ cls._finish_one(obj, ctx)
+
+ assert props.is_editing is False
+ assert obj in cls.representations_called
+ # edit_pset is called exactly once; properties key is "Data" wrapping JSON.
+ patched_tool_and_ifc["ifc"].api.pset.edit_pset.assert_called_once()
+ kwargs = patched_tool_and_ifc["ifc"].api.pset.edit_pset.call_args.kwargs
+ assert "properties" in kwargs and "Data" in kwargs["properties"]
+
+
+def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc):
+ """If _update_modifier_representation raises, is_editing must stay True
+ so the user's draft survives for retry. This is the contract called out
+ in parametric_lifecycle.py:161 — set is_editing=False only on success."""
+ props = _FakeProps()
+ props.is_editing = True
+ obj = _make_obj(props)
+ ctx = mock.Mock(name="context")
+
+ cls = _door_mixin_cls(match=True, raise_on_update=True)
+ with pytest.raises(RuntimeError, match="simulated representation failure"):
+ cls._finish_one(obj, ctx)
+
+ assert props.is_editing is True # draft survives
+
+
+def test_feature_modifier_cancel_one_restores_and_clears_is_editing(patched_tool_and_ifc):
+ props = _FakeProps()
+ props.is_editing = True
+ obj = _make_obj(props)
+ patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
+ {"width": 900}, {"thickness": 60}, {"material": "steel"}
+ )
+
+ cls = _door_mixin_cls(match=True)
+ cls._cancel_one(obj)
+
+ assert props.is_editing is False
+ assert props.last_kwargs is not None and props.last_kwargs["width"] == 900
+ # switch_representation must be called via bonsai.core.geometry.
+ patched_tool_and_ifc["bonsai"].core.geometry.switch_representation.assert_called_once()
+
+
+def test_feature_modifier_targets_loop_uses_iter_targets(patched_tool_and_ifc):
+ """_enable_targets / _finish_targets / _cancel_targets iterate
+ _iter_targets — default is [active_object]; subclasses can override."""
+ props_a, props_b = _FakeProps(), _FakeProps()
+ obj_a, obj_b = _make_obj(props_a), _make_obj(props_b)
+ patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
+ {"width": 1000}, {"thickness": 50}, {"material": "wood"}
+ )
+
+ cls = _door_mixin_cls(match=True)
+ cls._iter_targets = classmethod(lambda c, ctx: [obj_a, obj_b])
+
+ result = cls()._enable_targets(mock.Mock())
+
+ assert result == {"FINISHED"}
+ assert props_a.is_editing is True
+ assert props_b.is_editing is True
+
+
+# ----------------------------------------------------------------------
+# PathPreservingEditMixin (railing/roof pattern)
+# ----------------------------------------------------------------------
+
+
+class _FakePathProps:
+ """Stand-in for railing/roof properties — get_general_kwargs only (no lining/panel)."""
+
+ def __init__(self):
+ self.is_editing = False
+ self.last_kwargs = None
+ self.general = {"width": 200, "thickness": 10}
+
+ def set_props_kwargs_from_ifc_data(self, data):
+ self.last_kwargs = dict(data)
+
+ def get_general_kwargs(self, convert_to_project_units=True):
+ return dict(self.general)
+
+
+def _path_mixin_cls(match=True):
+ from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
+
+ class _TestPathMixin(PathPreservingEditMixin):
+ pset_name: ClassVar[str] = "BBIM_Railing"
+ pset_updates: ClassVar[list] = []
+ ifc_data_updates: ClassVar[list] = []
+ bmesh_updates: ClassVar[list] = []
+
+ @classmethod
+ def _is_element_type(cls, element):
+ return match
+
+ @classmethod
+ def _get_props(cls, obj):
+ return obj.props
+
+ @classmethod
+ def _update_pset(cls, element, data):
+ cls.pset_updates.append((element, data))
+
+ @classmethod
+ def _update_modifier_ifc_data(cls, obj, context):
+ cls.ifc_data_updates.append(obj)
+
+ @classmethod
+ def _update_modifier_bmesh(cls, obj, context):
+ cls.bmesh_updates.append(obj)
+
+ return _TestPathMixin
+
+
+def test_path_preserving_enable_one_sets_is_editing(patched_tool_and_ifc):
+ props = _FakePathProps()
+ obj = _make_obj(props)
+ patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
+ "data_dict": {"width": 250, "path_data": {"points": [[0, 0], [1, 0]]}}
+ }
+
+ cls = _path_mixin_cls(match=True)
+ cls._enable_one(obj)
+
+ assert props.is_editing is True
+ assert props.last_kwargs is not None
+ assert props.last_kwargs["width"] == 250
+ # path_data passes through (default _post_load_data is pass-through)
+ assert props.last_kwargs["path_data"] == {"points": [[0, 0], [1, 0]]}
+
+
+def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(patched_tool_and_ifc):
+ props = _FakePathProps()
+ props.is_editing = True
+ obj = _make_obj(props)
+ ctx = mock.Mock(name="context")
+ sentinel_path = {"points": [[5, 5], [9, 9]], "edges": [[0, 1]]}
+ patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
+ "data_dict": {"path_data": sentinel_path}
+ }
+
+ cls = _path_mixin_cls(match=True)
+ cls._finish_one(obj, ctx)
+
+ assert props.is_editing is False
+ assert cls.pset_updates, "_update_pset must be called on Finish"
+ assert cls.pset_updates[-1][1]["path_data"] is sentinel_path # preserved by reference
+ assert obj in cls.ifc_data_updates
+
+
+def test_path_preserving_cancel_one_calls_update_modifier_bmesh(patched_tool_and_ifc):
+ props = _FakePathProps()
+ props.is_editing = True
+ obj = _make_obj(props)
+ ctx = mock.Mock(name="context")
+ patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
+ "data_dict": {"width": 250, "path_data": {"points": []}}
+ }
+
+ cls = _path_mixin_cls(match=True)
+ cls._cancel_one(obj, ctx)
+
+ assert props.is_editing is False
+ assert obj in cls.bmesh_updates
+
+
+def test_path_preserving_enable_one_post_load_data_hook_runs(patched_tool_and_ifc):
+ """Railing overrides _post_load_data to JSON-serialise path_data —
+ confirm the hook is honoured (here we drop a sentinel key)."""
+ props = _FakePathProps()
+ obj = _make_obj(props)
+ patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
+ "data_dict": {"width": 250, "extra": "drop_me"}
+ }
+
+ cls = _path_mixin_cls(match=True)
+ cls._post_load_data = classmethod(lambda c, data: {k: v for k, v in data.items() if k != "extra"})
+ cls._enable_one(obj)
+
+ assert "extra" not in props.last_kwargs
+
+
+# ----------------------------------------------------------------------
+# _ParametricEditMixinBase._resolve guard
+# ----------------------------------------------------------------------
+
+
+def test_resolve_returns_none_when_obj_has_no_entity(patched_tool_and_ifc):
+ cls = _door_mixin_cls(match=True)
+ patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
+ obj = _make_obj(_FakeProps())
+
+ assert cls._resolve(obj) is None
+
+
+def test_resolve_returns_none_when_element_type_mismatch(patched_tool_and_ifc):
+ cls = _door_mixin_cls(match=False)
+ obj = _make_obj(_FakeProps())
+
+ assert cls._resolve(obj) is None
+
+
+def test_resolve_returns_tuple_when_match(patched_tool_and_ifc):
+ cls = _door_mixin_cls(match=True)
+ props = _FakeProps()
+ obj = _make_obj(props)
+
+ resolved = cls._resolve(obj)
+
+ assert resolved is not None
+ element, returned_props = resolved
+ assert element is patched_tool_and_ifc["element"]
+ assert returned_props is props
diff --git a/src/bonsai/test/bim/test_parametric_registry.py b/src/bonsai/test/bim/test_parametric_registry.py
index f5d3dac5a1..ec4383dccb 100644
--- a/src/bonsai/test/bim/test_parametric_registry.py
+++ b/src/bonsai/test/bim/test_parametric_registry.py
@@ -18,7 +18,7 @@
#
# This file was generated with the assistance of an AI coding tool.
-"""Registration smoke test for :attr:`tool.Parametric.EDIT_TYPES`.
+"""Registration smoke test for `tool.Parametric.EDIT_TYPES`.
The registry is the single source of truth for which parametric element types
exist. Every consumer (auto-commit on save, finish/cancel chains, the
@@ -29,7 +29,7 @@ registration and the silent-desync the framework exists to prevent will ship.
These tests pin the registry-to-runtime contract: for every entry the operator
``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the
``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type
-predicate exists on :class:`tool.Blender.Modifier`."""
+predicate exists on `tool.Blender.Modifier`."""
import types
@@ -88,24 +88,66 @@ def test_every_entry_has_modifier_predicate(registry):
assert not missing, f"tool.Blender.Modifier missing is_ predicates: {missing}"
+def test_every_predicate_does_not_raise_on_non_matching_element(registry):
+ """Each ``is_`` predicate must be **total**: accept any IFC entity
+ and return a truthy/falsy value, never raise.
+
+ The registry iterates every predicate against the active IFC element on
+ save; a raising predicate (e.g. ``AttributeError`` from a missing pset
+ accessor when handed a non-matching element type) propagates upward and
+ breaks the save path for *all* parametric types, not just its own.
+ This test probes each predicate with an ``IfcAnnotation`` (an element
+ that carries none of the BBIM_ psets the predicates look up) and
+ asserts the call does not raise. Falsy returns are acceptable — the
+ registry treats them as 'no match'. What's forbidden is raising."""
+ import ifcopenshell
+
+ from bonsai import tool
+
+ probe = ifcopenshell.file(schema="IFC4").create_entity("IfcAnnotation")
+
+ raised = []
+ for feature in registry:
+ predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
+ if predicate is None:
+ continue
+ try:
+ predicate(probe)
+ except Exception as e:
+ raised.append((feature.name, type(e).__name__, str(e)))
+ assert not raised, (
+ f"is_ predicates raised on a non-matching IfcAnnotation: {raised}. "
+ f"Predicates must be total — return bool, never raise. Add an "
+ f"`if not element.is_a('IfcXxx'): return False` short-circuit or guard the pset lookup."
+ )
+
+
def test_gizmo_preferences_attached_when_class_exists(registry):
"""For every registry entry whose ``GizmoPreferences`` class exists in
- ``bonsai.bim.ui``, the matching sub-PointerProperty must be attached to
+ ``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
- Catches the silent-skip behaviour of
- ``Parametric.iter_gizmo_preference_classes``: a typo in the class name
- or a dropped registration would otherwise produce a missing sub-panel at
- runtime with no error. Entries without a ``GizmoPreferences``
- class are allowed — not every parametric type ships gizmo prefs."""
+ Catches the silent-skip behaviour of the registry-driven gizmo-prefs
+ discovery: a typo in the class name or a dropped registration would
+ otherwise produce a missing sub-panel at runtime with no error.
+ Entries without a ``GizmoPreferences`` class are allowed — not
+ every parametric type ships gizmo prefs.
+
+ Checks ``__annotations__`` rather than ``hasattr`` because Blender's
+ PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
+ annotation-only assignment — the attribute only materialises on the
+ class after Blender's metaclass installs the bpy_struct descriptor,
+ which depends on registration timing. Reading ``__annotations__``
+ pins the source-level contract independently of when register() ran."""
from bonsai.bim import ui
+ annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
missing = []
for feature in registry:
prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
if not hasattr(ui, prefs_class_name):
continue
- if not hasattr(ui.GizmoPreferences, feature.name):
+ if feature.name not in annotations:
missing.append((feature.name, prefs_class_name))
assert not missing, (
f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing} — "