Add tests for decorator_cache + undo-resync dispatch

Two paired test files for the framework infrastructure landed
earlier in this PR.

test_decorator_cache.py (11 tests):
* The 4-hook invalidation list (depsgraph_update_post + undo_post +
  redo_post + load_post) is symmetrically managed by
  install_decorator_cache_handlers / uninstall_decorator_cache_handlers.
  A future edit that drops a hook from one side without the other
  would land as a Blender segfault when a cached bpy.types.Object
  ref outlives its underlying ID block — the regression must surface
  as a test failure first.
* install is idempotent (calling twice doesn't double-register).
* uninstall when not installed doesn't raise.
* The bump handler accepts Blender's variadic args.
* The depsgraph predicate gates correctly: bumps on Object geometry
  or transform updates, silently skips on Material / NodeTree / Image
  updates (which would otherwise rebuild every cache on every node
  edit).
* TokenCache.get_or_compute short-circuits on key+token match and
  recomputes when the token bumps.

test_undo_resync_parametric_drafts.py (3 tests):
* UNDO_REGENERATORS keys must all be in tool.Parametric.EDIT_TYPES.
  A typo would silently no-op on Ctrl+Z, restoring the desync the
  helper is meant to prevent.
* The dispatcher skips objects with no active parametric edit
  (undo_post fires for every undo, most of which touch zero drafts).
* The dispatcher silently skips parametric types that have no
  UNDO_REGENERATORS entry (door / window / array are IFC-derived
  with no draft preview mesh — they don't need a regenerator).

Mocks use spec=bpy.types.Depsgraph / spec=bpy.types.DepsgraphUpdate
/ spec=tool.parametric.ParametricObject so typos in mocked-attribute
access fail loudly (CLAUDE.md test discipline).

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-05-27 18:34:32 +02:00
parent f41f5dfdd8
commit cb2f20b2b6
2 changed files with 282 additions and 0 deletions
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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)
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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)