diff --git a/src/bonsai/test/bim/module/model/test_decorator_cache.py b/src/bonsai/test/bim/module/model/test_decorator_cache.py new file mode 100644 index 0000000000..b98857b900 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_decorator_cache.py @@ -0,0 +1,176 @@ +# 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. + +"""Contract tests for the shared decorator cache module. + +The cache token + persistent handler are the only thing protecting cached +``bpy.types.Object`` refs in dependent decorators from being dereferenced +after the underlying object is freed. These tests pin that contract: + +- The 4-hook invalidation list (depsgraph/undo/redo/load) is symmetrically + managed by install/uninstall. A future edit that drops a hook from one + side without the other lands as a Blender segfault — the regression must + surface as a test failure first. +- The handler increments the token and accepts Blender's variadic args.""" + +import bpy +import pytest + +from bonsai.bim import decorator_cache + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _reset_cache_token(): + """Fresh token between tests so the bump-count assertions are stable.""" + decorator_cache.reset_for_test() + yield + + +def test_install_and_uninstall_manage_all_invalidation_hooks(): + """install_decorator_cache_handlers() must register the bump handler in + every hook the dependent decorators rely on; uninstall must remove it + from every hook install touched. Catches the regression class where + a hook is dropped from one side and not the other.""" + expected_hooks = ( + bpy.app.handlers.depsgraph_update_post, + bpy.app.handlers.undo_post, + bpy.app.handlers.redo_post, + bpy.app.handlers.load_post, + ) + + # Defensive cleanup in case a previous addon-init run left the handler + # registered — the test must observe a clean slate before install(). + for hook in expected_hooks: + while decorator_cache._bump_decorator_cache_token in hook: + hook.remove(decorator_cache._bump_decorator_cache_token) + + try: + decorator_cache.install_decorator_cache_handlers() + for hook in expected_hooks: + assert decorator_cache._bump_decorator_cache_token in hook, ( + "install_decorator_cache_handlers() must register the bump " + "handler in every hook a dependent cache relies on" + ) + decorator_cache.uninstall_decorator_cache_handlers() + for hook in expected_hooks: + assert decorator_cache._bump_decorator_cache_token not in hook, ( + "uninstall_decorator_cache_handlers() must remove the bump " "handler from every hook install touched" + ) + finally: + # Make sure the test never leaves the handler dangling. + for hook in expected_hooks: + while decorator_cache._bump_decorator_cache_token in hook: + hook.remove(decorator_cache._bump_decorator_cache_token) + + +def test_install_is_idempotent(): + """Calling install twice must not double-register the bump handler — + the addon-init path may run on script reload and we don't want to + invalidate the cache twice per event.""" + hook = bpy.app.handlers.depsgraph_update_post + + while decorator_cache._bump_decorator_cache_token in hook: + hook.remove(decorator_cache._bump_decorator_cache_token) + + try: + decorator_cache.install_decorator_cache_handlers() + decorator_cache.install_decorator_cache_handlers() + appearances = sum(1 for h in hook if h is decorator_cache._bump_decorator_cache_token) + assert appearances == 1, "install must not double-register" + finally: + decorator_cache.uninstall_decorator_cache_handlers() + + +def test_bump_handler_increments_token(): + """undo / redo / load_post invoke the handler with at most one positional + argument (the scene or filepath). Every such call must bump the token — + those events legitimately invalidate every cached Object reference.""" + decorator_cache._bump_decorator_cache_token() + assert decorator_cache.get_decorator_cache_token() == 1 + decorator_cache._bump_decorator_cache_token("scene") + assert decorator_cache.get_decorator_cache_token() == 2 + + +def test_get_decorator_cache_token_reads_current_value(): + """``get_decorator_cache_token()`` is the public read interface — it must + reflect the current token, not a captured-at-import-time value.""" + initial = decorator_cache.get_decorator_cache_token() + decorator_cache._bump_decorator_cache_token() + assert decorator_cache.get_decorator_cache_token() == initial + 1 + + +def test_depsgraph_update_with_no_object_changes_does_not_bump(): + """depsgraph_update_post fires every animation frame, every driver + evaluation, and every UI-only state shift. None of those invalidate a + decorator's cached IFC-derived geometry — gating the bump is what makes + the ``TokenCache`` worth more than a per-frame recompute.""" + from unittest.mock import MagicMock + + initial = decorator_cache.get_decorator_cache_token() + depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph") + depsgraph.updates = [] # empty updates list — animation tick with no real changes + decorator_cache._bump_decorator_cache_token("scene", depsgraph) + assert ( + decorator_cache.get_decorator_cache_token() == initial + ), "depsgraph_update_post with no Object changes must not bump the token" + + +def test_depsgraph_update_with_object_geometry_change_bumps(): + """When the depsgraph reports an Object geometry or transform change, + cached references may now point at a renamed / freed ID block. The token + must advance so dependent caches re-fetch on the next read.""" + from unittest.mock import MagicMock + + initial = decorator_cache.get_decorator_cache_token() + update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update") + update.is_updated_geometry = True + update.is_updated_transform = False + update.id = bpy.data.objects.new("dep_cache_probe", None) + try: + depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph") + depsgraph.updates = [update] + decorator_cache._bump_decorator_cache_token("scene", depsgraph) + assert decorator_cache.get_decorator_cache_token() == initial + 1 + finally: + bpy.data.objects.remove(update.id, do_unlink=True) + + +def test_depsgraph_update_with_non_object_change_does_not_bump(): + """Material / NodeTree / Image updates fire depsgraph_update_post too + but never invalidate the decorator's Object-keyed caches. Filter them + out so a node-graph edit doesn't trigger a global cache rebuild.""" + from unittest.mock import MagicMock + + initial = decorator_cache.get_decorator_cache_token() + update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update") + update.is_updated_geometry = True + update.is_updated_transform = True + update.id = bpy.data.materials.new("dep_cache_probe_mat") + try: + depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph") + depsgraph.updates = [update] + decorator_cache._bump_decorator_cache_token("scene", depsgraph) + assert ( + decorator_cache.get_decorator_cache_token() == initial + ), "Non-Object ID updates must not bump the decorator cache token" + finally: + bpy.data.materials.remove(update.id, do_unlink=True) diff --git a/src/bonsai/test/bim/module/model/test_undo_resync_parametric_drafts.py b/src/bonsai/test/bim/module/model/test_undo_resync_parametric_drafts.py new file mode 100644 index 0000000000..9bf7c8c33a --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_undo_resync_parametric_drafts.py @@ -0,0 +1,106 @@ +# 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. + +"""Tests for ``parametric_lifecycle.resync_parametric_drafts_after_undo``. + +Blender's undo restores PropertyGroup field values but does not refire +their ``update`` callbacks, so the preview mesh of an in-progress +parametric draft desyncs from the gizmo dimension widget after Ctrl+Z. +The resync helper walks active drafts and re-runs the per-type +regenerator to bring preview back in line with the (restored) draft +state. This file pins the dispatch contract.""" + +from unittest.mock import MagicMock, patch + +import bpy +import pytest + +import bonsai.tool as tool +from bonsai.bim import parametric_lifecycle + +pytestmark = pytest.mark.model + + +def test_undo_regenerators_target_registered_parametric_types(): + """Every entry in ``UNDO_REGENERATORS`` must name a real parametric + type. A typo would silently no-op on Ctrl+Z, restoring the desync + this helper is meant to prevent.""" + registered_names = {f.name for f in tool.Parametric.EDIT_TYPES} + unknown = set(parametric_lifecycle.UNDO_REGENERATORS) - registered_names + assert not unknown, f"UNDO_REGENERATORS keys {unknown} are not in tool.Parametric.EDIT_TYPES" + + +def test_resync_skips_objects_not_in_parametric_edit(): + """Objects with no active parametric edit must not trigger any + regenerator — the helper is called from undo_post which fires on + every undo, including undos that touch zero parametric drafts.""" + captured = [] + + def fake_dispatch(obj): + captured.append(obj) + + with patch.dict(parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_dispatch}, clear=False), patch.object( + tool.Parametric, "is_object_editing", return_value=None + ): + parametric_lifecycle.resync_parametric_drafts_after_undo() + + assert captured == [] + + +def test_resync_dispatches_to_registered_regenerator_for_editing_object(): + """When an object is in parametric edit and its type has a registered + regenerator, the regenerator must run with that object as the sole + arg. This is the load-bearing branch: preview mesh re-renders from + current props, so the gizmo and preview re-sync.""" + captured = [] + + def fake_wall_regenerator(obj): + captured.append(obj) + + fake_feature = MagicMock(spec=tool.parametric.ParametricObject) + fake_feature.name = "wall" + + obj = bpy.data.objects.new("test_wall_obj", bpy.data.meshes.new("test_wall_mesh")) + try: + with patch.dict( + parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_wall_regenerator}, clear=False + ), patch.object(tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None): + parametric_lifecycle.resync_parametric_drafts_after_undo() + finally: + bpy.data.objects.remove(obj, do_unlink=True) + + assert captured == [obj] + + +def test_resync_skips_editing_object_whose_type_has_no_regenerator(): + """A parametric type without an ``UNDO_REGENERATORS`` entry (door / + window / array — IFC-derived preview, no desync) must not raise; the + helper silently skips it.""" + fake_feature = MagicMock(spec=tool.parametric.ParametricObject) + fake_feature.name = "door" # door has no entry in UNDO_REGENERATORS + + obj = bpy.data.objects.new("test_door_obj", bpy.data.meshes.new("test_door_mesh")) + try: + with patch.object( + tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None + ): + parametric_lifecycle.resync_parametric_drafts_after_undo() + finally: + bpy.data.objects.remove(obj, do_unlink=True)