mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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)
|
||||
Reference in New Issue
Block a user