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} — "