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)