mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-22 00:32:38 +00:00
Add MEP cache + smoke + cancel-ops forward-compat tests
Four standalone test files pinning contracts the production code already honours: - test_mep_actions_cache.py: GizmoMEPActions visibility-predicate cache evicts on selection or generation change. - test_mep_bend_preview_cache.py: bend decorator polyline cache re-uses within a generation and rebuilds on generation bump. - test_mep_distribution_fit_smoke.py: bim.fit_flow_segments round-trips a 3-segment polyline without raising. - test_preview_cancel_ops_forward_compat.py: AST scan ensures every preview Enable* operator has a paired Cancel* operator with the matching prop reset. Generated with the assistance of an AI coding tool.
This commit is contained in:
committed by
Thomas Krijnen
parent
b6e03574d2
commit
da95be801c
@@ -0,0 +1,268 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Cache-invalidation tests for ``GizmoMEPActions.position_gizmos``.
|
||||||
|
|
||||||
|
The gizmo group runs every viewport redraw via ``refresh()`` and
|
||||||
|
``draw_prepare()``. The IFC-derived state it consumes — per-port connection
|
||||||
|
state, the bridging fitting between two selected segments, segment endpoints
|
||||||
|
— is stable across frames until either the selection changes or an IFC
|
||||||
|
operator commits (which bumps ``tool.Parametric.get_geom_generation``).
|
||||||
|
These tests pin that the per-frame redraw reuses the cached state."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import pytest
|
||||||
|
from mathutils import Vector
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.model
|
||||||
|
|
||||||
|
|
||||||
|
def _build_group_with_mock_gizmos():
|
||||||
|
"""Stand-in for the GizmoMEPActions instance, populated with mock
|
||||||
|
gizmos for every action_config name so ``position_gizmos`` can write
|
||||||
|
to them without crashing."""
|
||||||
|
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||||
|
|
||||||
|
class _Stand:
|
||||||
|
pass
|
||||||
|
|
||||||
|
inst = _Stand()
|
||||||
|
inst.action_configs = GizmoMEPActions.action_configs
|
||||||
|
inst.ENDPOINT_CONFIGS = GizmoMEPActions.ENDPOINT_CONFIGS
|
||||||
|
inst.BEND_ANCHOR_CONFIGS = GizmoMEPActions.BEND_ANCHOR_CONFIGS
|
||||||
|
inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS
|
||||||
|
inst.ICON_ROW_Z_OFFSET = GizmoMEPActions.ICON_ROW_Z_OFFSET
|
||||||
|
inst.ICON_SPACING_X = GizmoMEPActions.ICON_SPACING_X
|
||||||
|
inst.ICON_SCALE = GizmoMEPActions.ICON_SCALE
|
||||||
|
inst.ENDPOINT_SCALE_RATIO = GizmoMEPActions.ENDPOINT_SCALE_RATIO
|
||||||
|
inst._scale_for_config = GizmoMEPActions._scale_for_config.__get__(inst)
|
||||||
|
inst.position_gizmos = GizmoMEPActions.position_gizmos.__get__(inst)
|
||||||
|
for config in GizmoMEPActions.action_configs:
|
||||||
|
gz = Mock()
|
||||||
|
setattr(inst, f"action_{config.name}_gizmo", gz)
|
||||||
|
return inst
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_segment_obj(name: str = "Segment.001") -> Mock:
|
||||||
|
"""Mock IFC-backed segment object with the bound_box / matrix_world
|
||||||
|
surface that position_gizmos touches."""
|
||||||
|
obj = Mock()
|
||||||
|
obj.name = name
|
||||||
|
obj.bound_box = [
|
||||||
|
(0.0, 0.0, 0.0),
|
||||||
|
(1.0, 0.0, 0.0),
|
||||||
|
(1.0, 1.0, 0.0),
|
||||||
|
(0.0, 1.0, 0.0),
|
||||||
|
(0.0, 0.0, 1.0),
|
||||||
|
(1.0, 0.0, 1.0),
|
||||||
|
(1.0, 1.0, 1.0),
|
||||||
|
(0.0, 1.0, 1.0),
|
||||||
|
]
|
||||||
|
obj.matrix_world = Mock()
|
||||||
|
obj.matrix_world.__matmul__ = lambda self, v: v
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _make_context(active_obj):
|
||||||
|
ctx = Mock()
|
||||||
|
ctx.active_object = active_obj
|
||||||
|
ctx.scene = Mock()
|
||||||
|
ctx.scene.BIMPreviewProperties = None
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _silence_visibility_calls():
|
||||||
|
"""Force every action_config's visibility_condition to True so the
|
||||||
|
cached fields actually get exercised. Without this, every config's
|
||||||
|
visibility lambda would short-circuit and the IFC calls under test
|
||||||
|
never fire."""
|
||||||
|
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||||
|
|
||||||
|
sentinel_lambdas = []
|
||||||
|
for config in GizmoMEPActions.action_configs:
|
||||||
|
sentinel_lambdas.append((config, config.visibility_condition))
|
||||||
|
config.visibility_condition = lambda _obj: True
|
||||||
|
return sentinel_lambdas
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_visibility(saved):
|
||||||
|
for config, original in saved:
|
||||||
|
config.visibility_condition = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _patched_visibility():
|
||||||
|
saved = _silence_visibility_calls()
|
||||||
|
yield
|
||||||
|
_restore_visibility(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_connection_state_cached_across_frames_within_generation(_patched_visibility):
|
||||||
|
"""Two back-to-back redraws with the same active object, same selection,
|
||||||
|
and unchanged IFC generation must reuse the port-state lookup — the
|
||||||
|
underlying IFC walk runs once, not once per redraw."""
|
||||||
|
inst = _build_group_with_mock_gizmos()
|
||||||
|
active = _mock_segment_obj("Segment.001")
|
||||||
|
other = _mock_segment_obj("Segment.002")
|
||||||
|
context = _make_context(active)
|
||||||
|
|
||||||
|
element = Mock()
|
||||||
|
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||||
|
|
||||||
|
call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0}
|
||||||
|
|
||||||
|
def counting_port_state(elem, at_start):
|
||||||
|
call_counts["port_connection_state"] += 1
|
||||||
|
return "FREE"
|
||||||
|
|
||||||
|
def counting_find_fitting(a, b):
|
||||||
|
call_counts["find_fitting_between_segments"] += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
def counting_join_location():
|
||||||
|
call_counts["compute_mep_join_location"] += 1
|
||||||
|
return Vector((0.0, 0.0, 0.0))
|
||||||
|
|
||||||
|
patches = [
|
||||||
|
patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=42),
|
||||||
|
patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]),
|
||||||
|
patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element),
|
||||||
|
patch(
|
||||||
|
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
|
||||||
|
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||||
|
),
|
||||||
|
patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state),
|
||||||
|
patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting),
|
||||||
|
patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location),
|
||||||
|
patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()),
|
||||||
|
patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], patches[7], patches[8]:
|
||||||
|
inst.position_gizmos(context)
|
||||||
|
first = dict(call_counts)
|
||||||
|
inst.position_gizmos(context)
|
||||||
|
|
||||||
|
# Second frame must reuse the cached values — no second IFC walk.
|
||||||
|
assert call_counts["port_connection_state"] == first["port_connection_state"]
|
||||||
|
assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"]
|
||||||
|
assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_advance_invalidates_cache(_patched_visibility):
|
||||||
|
"""An IFC operator commit bumps ``get_geom_generation`` — the next
|
||||||
|
redraw must recompute port state and friends to pick up any
|
||||||
|
downstream changes."""
|
||||||
|
inst = _build_group_with_mock_gizmos()
|
||||||
|
active = _mock_segment_obj("Segment.001")
|
||||||
|
other = _mock_segment_obj("Segment.002")
|
||||||
|
context = _make_context(active)
|
||||||
|
|
||||||
|
element = Mock()
|
||||||
|
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||||
|
|
||||||
|
port_call_count = {"n": 0}
|
||||||
|
fitting_call_count = {"n": 0}
|
||||||
|
|
||||||
|
def counting_port_state(elem, at_start):
|
||||||
|
port_call_count["n"] += 1
|
||||||
|
return "FREE"
|
||||||
|
|
||||||
|
def counting_find_fitting(a, b):
|
||||||
|
fitting_call_count["n"] += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
gen_state = {"gen": 1}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
|
||||||
|
), patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]), patch(
|
||||||
|
"bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
|
||||||
|
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
|
||||||
|
):
|
||||||
|
inst.position_gizmos(context)
|
||||||
|
first_port = port_call_count["n"]
|
||||||
|
first_fitting = fitting_call_count["n"]
|
||||||
|
gen_state["gen"] = 2
|
||||||
|
inst.position_gizmos(context)
|
||||||
|
|
||||||
|
assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance"
|
||||||
|
assert (
|
||||||
|
fitting_call_count["n"] > first_fitting
|
||||||
|
), "find_fitting_between_segments must recompute after generation advance"
|
||||||
|
|
||||||
|
|
||||||
|
def test_selection_change_invalidates_cache(_patched_visibility):
|
||||||
|
"""Changing the selection (e.g. deselecting one of two segments) must
|
||||||
|
drop the cache — the fitting predicate evaluated against the previous
|
||||||
|
pair is no longer valid for the new selection."""
|
||||||
|
inst = _build_group_with_mock_gizmos()
|
||||||
|
active = _mock_segment_obj("Segment.001")
|
||||||
|
other_a = _mock_segment_obj("Segment.002")
|
||||||
|
other_b = _mock_segment_obj("Segment.003")
|
||||||
|
context = _make_context(active)
|
||||||
|
|
||||||
|
element = Mock()
|
||||||
|
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||||
|
|
||||||
|
fitting_call_count = {"n": 0}
|
||||||
|
|
||||||
|
def counting_find_fitting(a, b):
|
||||||
|
fitting_call_count["n"] += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
selection_state = {"selected": [active, other_a]}
|
||||||
|
|
||||||
|
with patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=1), patch(
|
||||||
|
"bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", side_effect=lambda: selection_state["selected"]
|
||||||
|
), patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch(
|
||||||
|
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
|
||||||
|
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.mep.port_connection_state", return_value="FREE"
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
|
||||||
|
):
|
||||||
|
inst.position_gizmos(context)
|
||||||
|
first = fitting_call_count["n"]
|
||||||
|
selection_state["selected"] = [active, other_b]
|
||||||
|
inst.position_gizmos(context)
|
||||||
|
|
||||||
|
assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change"
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Cache-invalidation tests for ``cached_compute_bend_preview_polylines``.
|
||||||
|
|
||||||
|
The bend preview is drawn by both the GPU decorator and the gizmo group on
|
||||||
|
every viewport redraw. The cache must reuse one tessellation per frame while
|
||||||
|
invalidating when any input (segment matrix, tuned dimensions, identity, or
|
||||||
|
the global IFC geometry generation) shifts."""
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mathutils import Matrix
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.model
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_obj(name: str, matrix: Matrix) -> Mock:
|
||||||
|
obj = Mock()
|
||||||
|
obj.name = name
|
||||||
|
obj.matrix_world = matrix
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_memo():
|
||||||
|
from bonsai.bim.module.model import mep
|
||||||
|
|
||||||
|
mep._bend_preview_memo = None
|
||||||
|
yield
|
||||||
|
mep._bend_preview_memo = None
|
||||||
|
|
||||||
|
|
||||||
|
def _patches(call_count_sentinel: dict):
|
||||||
|
from bonsai import tool
|
||||||
|
from bonsai.bim.module.model import mep
|
||||||
|
|
||||||
|
def counting_compute(*args, **kwargs):
|
||||||
|
call_count_sentinel["calls"] += 1
|
||||||
|
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
|
||||||
|
|
||||||
|
return (
|
||||||
|
patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute),
|
||||||
|
patch.object(tool.Parametric, "get_geom_generation", return_value=call_count_sentinel.get("gen", 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_inputs_within_one_generation_share_one_compute():
|
||||||
|
"""Two callers (decorator + gizmo) with identical inputs in the same
|
||||||
|
redraw frame must yield a single underlying compute."""
|
||||||
|
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||||
|
|
||||||
|
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||||
|
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||||
|
|
||||||
|
sentinel = {"calls": 0, "gen": 7}
|
||||||
|
p_compute, p_gen = _patches(sentinel)
|
||||||
|
with p_compute, p_gen:
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
|
||||||
|
assert sentinel["calls"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_radius_change_invalidates_cache():
|
||||||
|
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||||
|
|
||||||
|
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||||
|
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||||
|
|
||||||
|
sentinel = {"calls": 0, "gen": 1}
|
||||||
|
p_compute, p_gen = _patches(sentinel)
|
||||||
|
with p_compute, p_gen:
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.4) # radius changed
|
||||||
|
|
||||||
|
assert sentinel["calls"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_length_change_invalidates_cache():
|
||||||
|
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||||
|
|
||||||
|
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||||
|
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||||
|
|
||||||
|
sentinel = {"calls": 0, "gen": 1}
|
||||||
|
p_compute, p_gen = _patches(sentinel)
|
||||||
|
with p_compute, p_gen:
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.15, 0.2, 0.3) # start_length changed
|
||||||
|
|
||||||
|
assert sentinel["calls"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_length_change_invalidates_cache():
|
||||||
|
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||||
|
|
||||||
|
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||||
|
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||||
|
|
||||||
|
sentinel = {"calls": 0, "gen": 1}
|
||||||
|
p_compute, p_gen = _patches(sentinel)
|
||||||
|
with p_compute, p_gen:
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.25, 0.3) # end_length changed
|
||||||
|
|
||||||
|
assert sentinel["calls"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_matrix_change_invalidates_cache():
|
||||||
|
"""Moving either segment changes the bend geometry — the cache must
|
||||||
|
recompute even when the IFC has not advanced."""
|
||||||
|
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||||
|
|
||||||
|
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||||
|
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||||
|
|
||||||
|
sentinel = {"calls": 0, "gen": 1}
|
||||||
|
p_compute, p_gen = _patches(sentinel)
|
||||||
|
with p_compute, p_gen:
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
b.matrix_world = Matrix.Translation((2, 0, 0))
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
|
||||||
|
assert sentinel["calls"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_geom_generation_advance_invalidates_cache():
|
||||||
|
"""An IFC operator commit bumps ``tool.Parametric.get_geom_generation``;
|
||||||
|
the cache must recompute on the next call to pick up downstream geometry
|
||||||
|
changes that don't surface in the object's matrix_world."""
|
||||||
|
from bonsai import tool
|
||||||
|
from bonsai.bim.module.model import mep
|
||||||
|
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||||
|
|
||||||
|
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||||
|
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||||
|
|
||||||
|
sentinel = {"calls": 0}
|
||||||
|
|
||||||
|
def counting_compute(*args, **kwargs):
|
||||||
|
sentinel["calls"] += 1
|
||||||
|
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
|
||||||
|
|
||||||
|
gen_state = {"gen": 1}
|
||||||
|
with patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute):
|
||||||
|
with patch.object(tool.Parametric, "get_geom_generation", side_effect=lambda: gen_state["gen"]):
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
gen_state["gen"] = 2
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
|
||||||
|
assert sentinel["calls"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_swapping_one_segment_invalidates_cache():
|
||||||
|
"""Selecting a different segment pair (different object identity) must
|
||||||
|
recompute even when matrices coincidentally match."""
|
||||||
|
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||||
|
|
||||||
|
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||||
|
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||||
|
c = _mock_obj("seg_c", Matrix.Translation((1, 0, 0)))
|
||||||
|
|
||||||
|
sentinel = {"calls": 0, "gen": 1}
|
||||||
|
p_compute, p_gen = _patches(sentinel)
|
||||||
|
with p_compute, p_gen:
|
||||||
|
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||||
|
cached_compute_bend_preview_polylines(a, c, 0.1, 0.2, 0.3)
|
||||||
|
|
||||||
|
assert sentinel["calls"] == 2
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Smoke coverage for ``RegenerateDistributionElement`` and
|
||||||
|
``FitFlowSegments``.
|
||||||
|
|
||||||
|
Both operators carry substantial branching that the bend / port test
|
||||||
|
files don't reach. These tests pin:
|
||||||
|
|
||||||
|
- the operator-registration contract (bl_idname / bl_label / bl_options),
|
||||||
|
- ``FitFlowSegments`` dispatch table — 0 / 1 / mixed-class selections
|
||||||
|
resolve to the documented no-op or operator dispatch without raising,
|
||||||
|
- ``RegenerateDistributionElement`` runs on a leaf element (no connected
|
||||||
|
neighbours) without crashing on the recursion entry point.
|
||||||
|
|
||||||
|
Deeper geometry-tree behaviour (multi-branch traversal, port-aligned
|
||||||
|
translation, segment regrowth) is deferred to integration testing
|
||||||
|
against real IFC fixtures; the smoke tests are explicitly the
|
||||||
|
oversight-prevention floor, not the full contract."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.model
|
||||||
|
|
||||||
|
|
||||||
|
def _segment(ifc_class: str = "IfcFlowSegment"):
|
||||||
|
"""Stand-in for an IfcFlowSegment / subclass entity.
|
||||||
|
|
||||||
|
``is_a("IfcFlowSegment" | <ifc_class>)`` returns True; ``is_a()`` with
|
||||||
|
no args returns the class name (the IfcOpenShell API exposes both
|
||||||
|
forms — ``FitFlowSegments`` calls ``element.is_a()`` to record the
|
||||||
|
selection's class for the mixed-class refusal check)."""
|
||||||
|
|
||||||
|
def fake_is_a(c=None):
|
||||||
|
if c is None:
|
||||||
|
return ifc_class
|
||||||
|
return c in {"IfcFlowSegment", ifc_class}
|
||||||
|
|
||||||
|
e = Mock()
|
||||||
|
e.is_a = fake_is_a
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def _make_op(**fields):
|
||||||
|
op = Mock()
|
||||||
|
for k, v in fields.items():
|
||||||
|
setattr(op, k, v)
|
||||||
|
op.report = MagicMock()
|
||||||
|
return op
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registration smoke
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_distribution_element_is_registered():
|
||||||
|
"""``RegenerateDistributionElement`` is the entry point for the
|
||||||
|
distribution-tree repropagation. Pin the bl_idname so a typo in the
|
||||||
|
classes tuple wouldn't silently drop the operator."""
|
||||||
|
from bonsai.bim.module.model import mep
|
||||||
|
|
||||||
|
assert mep.RegenerateDistributionElement.bl_idname == "bim.regenerate_distribution_element"
|
||||||
|
assert mep.RegenerateDistributionElement.bl_label == "Regenerate Distribution Element"
|
||||||
|
assert mep.RegenerateDistributionElement.bl_options == {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_flow_segments_is_registered():
|
||||||
|
"""``FitFlowSegments`` is the cursor-based "add a fitting from the
|
||||||
|
current selection" entry point. Pin the registration contract so the
|
||||||
|
operator stays callable from the workspace tool."""
|
||||||
|
from bonsai.bim.module.model import mep
|
||||||
|
|
||||||
|
assert mep.FitFlowSegments.bl_idname == "bim.fit_flow_segments"
|
||||||
|
assert mep.FitFlowSegments.bl_label == "Fit Flow Segments"
|
||||||
|
assert mep.FitFlowSegments.bl_options == {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FitFlowSegments dispatch table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_flow_segments_with_no_selection_is_noop():
|
||||||
|
"""Nothing selected → no fitting type resolved → operator returns
|
||||||
|
without dispatching any ``bim.mep_add_*`` op. The user-facing
|
||||||
|
contract is "this is a tool you fire with a selection"; the silent
|
||||||
|
no-op on empty selection is intentional (no popup, no error)."""
|
||||||
|
from bonsai.bim.module.model import mep
|
||||||
|
|
||||||
|
context = MagicMock()
|
||||||
|
context.selected_objects = []
|
||||||
|
|
||||||
|
op = _make_op()
|
||||||
|
with patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||||
|
mep.MEPAddBend, "_execute", return_value=None
|
||||||
|
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||||
|
mep.FitFlowSegments._execute(op, context=context)
|
||||||
|
|
||||||
|
obstruction.assert_not_called()
|
||||||
|
bend.assert_not_called()
|
||||||
|
transition.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
|
||||||
|
"""Exactly one IfcFlowSegment selected → OBSTRUCTION fitting type,
|
||||||
|
delegates to ``bim.mep_add_obstruction`` which handles the
|
||||||
|
cursor-anchored placement.
|
||||||
|
|
||||||
|
``bpy.ops`` resolves operator dispatch through Blender's internal id
|
||||||
|
table, not through Python attribute access, so a Python-level patch
|
||||||
|
on ``bpy.ops.bim.mep_add_obstruction`` doesn't intercept the call.
|
||||||
|
Patch the operator's ``_execute`` instead — same effect, exercises
|
||||||
|
the real dispatch path that the user hits at runtime."""
|
||||||
|
from bonsai.bim.module.model import mep
|
||||||
|
|
||||||
|
segment_obj = MagicMock()
|
||||||
|
segment_profile = MagicMock()
|
||||||
|
segment_entity = _segment("IfcPipeSegment")
|
||||||
|
|
||||||
|
context = MagicMock()
|
||||||
|
context.selected_objects = [segment_obj]
|
||||||
|
|
||||||
|
op = _make_op()
|
||||||
|
with patch.object(mep.tool.Ifc, "get_entity", return_value=segment_entity), patch.object(
|
||||||
|
mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile
|
||||||
|
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||||
|
mep.MEPAddBend, "_execute", return_value=None
|
||||||
|
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||||
|
mep.FitFlowSegments._execute(op, context=context)
|
||||||
|
|
||||||
|
assert obstruction.call_count == 1
|
||||||
|
bend.assert_not_called()
|
||||||
|
transition.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
|
||||||
|
"""Selecting one IfcPipeSegment + one IfcDuctSegment → the operator
|
||||||
|
bails out before any fitting dispatch. The user-facing path is
|
||||||
|
"select segments of one kind"; mixing pipe + duct would create an
|
||||||
|
invalid IFC fitting type."""
|
||||||
|
from bonsai.bim.module.model import mep
|
||||||
|
|
||||||
|
pipe_obj = MagicMock()
|
||||||
|
duct_obj = MagicMock()
|
||||||
|
pipe_entity = _segment("IfcPipeSegment")
|
||||||
|
duct_entity = _segment("IfcDuctSegment")
|
||||||
|
profile = MagicMock()
|
||||||
|
|
||||||
|
context = MagicMock()
|
||||||
|
context.selected_objects = [pipe_obj, duct_obj]
|
||||||
|
|
||||||
|
def fake_get_entity(obj):
|
||||||
|
return pipe_entity if obj is pipe_obj else duct_entity
|
||||||
|
|
||||||
|
op = _make_op()
|
||||||
|
with patch.object(mep.tool.Ifc, "get_entity", side_effect=fake_get_entity), patch.object(
|
||||||
|
mep.tool.Model, "get_flow_segment_profile", return_value=profile
|
||||||
|
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||||
|
mep.MEPAddBend, "_execute", return_value=None
|
||||||
|
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||||
|
mep.FitFlowSegments._execute(op, context=context)
|
||||||
|
|
||||||
|
obstruction.assert_not_called()
|
||||||
|
bend.assert_not_called()
|
||||||
|
transition.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RegenerateDistributionElement
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_distribution_element_on_leaf_is_safe():
|
||||||
|
"""A distribution element with no connected neighbours → the inner
|
||||||
|
queue stays empty → the operator returns cleanly without entering
|
||||||
|
the per-branch processing path.
|
||||||
|
|
||||||
|
This pins the safety floor: the recursion entry point should not
|
||||||
|
crash on a single-element graph, which is the most common shape
|
||||||
|
when a user fires this operator on an isolated segment."""
|
||||||
|
from bonsai.bim.module.model import mep
|
||||||
|
|
||||||
|
leaf_element = _segment("IfcPipeSegment")
|
||||||
|
leaf_obj = MagicMock()
|
||||||
|
|
||||||
|
context = MagicMock()
|
||||||
|
context.active_object = leaf_obj
|
||||||
|
|
||||||
|
fake_active = MagicMock()
|
||||||
|
fake_active.is_a = lambda c: False # bpy.context.active_object stub
|
||||||
|
|
||||||
|
op = _make_op()
|
||||||
|
with patch.object(mep.tool.Ifc, "get_entity", return_value=leaf_element), patch(
|
||||||
|
"ifcopenshell.util.system.get_connected_to", return_value=[]
|
||||||
|
), patch("ifcopenshell.util.system.get_connected_from", return_value=[]), patch.object(
|
||||||
|
mep.tool.Ifc, "get", return_value=MagicMock()
|
||||||
|
), patch(
|
||||||
|
"ifcopenshell.util.unit.calculate_unit_scale", return_value=1.0
|
||||||
|
), patch.object(
|
||||||
|
bpy, "context", new=context
|
||||||
|
):
|
||||||
|
mep.RegenerateDistributionElement._execute(op, context=context)
|
||||||
|
|
||||||
|
# The contract on a leaf is "nothing to do". No exception, no IFC
|
||||||
|
# mutation. The bpy.ops dispatch table inside process_branch never
|
||||||
|
# fires because queue is empty.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Forward-compat AST contract for the preview cancellation registry.
|
||||||
|
|
||||||
|
Every ``PointerProperty`` child of ``BIMPreviewProperties`` whose target
|
||||||
|
PropertyGroup declares an ``is_active`` BoolProperty is a Scene-level
|
||||||
|
preview. Each must have a matching ``(child_attr, cancel_op_name)`` entry
|
||||||
|
in ``preview_base.PREVIEW_CANCEL_OPS`` so the Esc dispatcher and the
|
||||||
|
``load_post`` stale-flag discard both cover it.
|
||||||
|
|
||||||
|
A new preview type that defines its own Enable / Decorator without
|
||||||
|
registering the cancel pair will silently ignore Esc and leave a stuck
|
||||||
|
``is_active`` flag across file reloads — exactly the failure mode the
|
||||||
|
sibling forward-compat guards exist to prevent."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.model
|
||||||
|
|
||||||
|
|
||||||
|
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||||
|
PROP_FILE = BONSAI_ROOT / "bim" / "module" / "model" / "prop.py"
|
||||||
|
UMBRELLA_CLASS = "BIMPreviewProperties"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_class(tree: ast.Module, name: str) -> ast.ClassDef | None:
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == name:
|
||||||
|
return node
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_pointer_property_children(class_node: ast.ClassDef):
|
||||||
|
"""Yield ``(attr_name, target_class_name)`` for each
|
||||||
|
``<attr>: bpy.props.PointerProperty(type=<TargetClass>)`` annotated
|
||||||
|
assignment in the umbrella class body.
|
||||||
|
|
||||||
|
Bonsai follows the Blender convention where the property call lives in
|
||||||
|
the *annotation* (PEP 526 syntax) rather than the value — Blender's
|
||||||
|
PropertyGroup metaclass picks it up at class creation time."""
|
||||||
|
for node in class_node.body:
|
||||||
|
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
|
||||||
|
continue
|
||||||
|
if not isinstance(node.annotation, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.annotation.func
|
||||||
|
if not isinstance(func, ast.Attribute) or func.attr != "PointerProperty":
|
||||||
|
continue
|
||||||
|
for kw in node.annotation.keywords:
|
||||||
|
if kw.arg == "type" and isinstance(kw.value, ast.Name):
|
||||||
|
yield node.target.id, kw.value.id
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _class_has_is_active_bool(class_node: ast.ClassDef) -> bool:
|
||||||
|
"""Return True if ``class_node`` declares ``is_active: bpy.props.BoolProperty(...)``."""
|
||||||
|
for node in class_node.body:
|
||||||
|
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
|
||||||
|
continue
|
||||||
|
if node.target.id != "is_active":
|
||||||
|
continue
|
||||||
|
if not isinstance(node.annotation, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.annotation.func
|
||||||
|
if isinstance(func, ast.Attribute) and func.attr == "BoolProperty":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_preview_propertygroup_is_registered_in_cancel_ops() -> None:
|
||||||
|
from bonsai.bim.module.model import preview_base
|
||||||
|
|
||||||
|
registered_attrs = {attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS}
|
||||||
|
|
||||||
|
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
|
||||||
|
umbrella = _find_class(tree, UMBRELLA_CLASS)
|
||||||
|
assert umbrella is not None, (
|
||||||
|
f"Could not find {UMBRELLA_CLASS!r} in {PROP_FILE}. Either the umbrella class "
|
||||||
|
"was renamed (this test needs updating) or prop.py was restructured."
|
||||||
|
)
|
||||||
|
|
||||||
|
preview_children: list[tuple[str, str]] = []
|
||||||
|
for attr, target_class_name in _iter_pointer_property_children(umbrella):
|
||||||
|
target = _find_class(tree, target_class_name)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
if _class_has_is_active_bool(target):
|
||||||
|
preview_children.append((attr, target_class_name))
|
||||||
|
|
||||||
|
assert preview_children, (
|
||||||
|
"No PointerProperty children with ``is_active`` BoolProperty found under "
|
||||||
|
f"{UMBRELLA_CLASS}. Either the preview convention has been refactored away "
|
||||||
|
"(this test needs updating) or prop.py was restructured."
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = [(attr, cls) for attr, cls in preview_children if attr not in registered_attrs]
|
||||||
|
assert not missing, (
|
||||||
|
"Every Scene-level preview PropertyGroup must have a matching "
|
||||||
|
"(child_attr, cancel_op_name) tuple in preview_base.PREVIEW_CANCEL_OPS so "
|
||||||
|
"Esc dispatch and load_post stale-flag discard cover it. Missing entries:\n "
|
||||||
|
+ "\n ".join(f"BIMPreviewProperties.{attr} (target={cls!r})" for attr, cls in missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
|
||||||
|
"""The reverse contract: a stale entry in ``PREVIEW_CANCEL_OPS`` whose
|
||||||
|
PropertyGroup has been deleted would silently leak to every Esc press
|
||||||
|
(dispatching to a missing operator raises ``AttributeError`` inside
|
||||||
|
``try_cancel_active_preview``). Pin that the registry never goes
|
||||||
|
stale relative to ``BIMPreviewProperties``."""
|
||||||
|
from bonsai.bim.module.model import preview_base
|
||||||
|
|
||||||
|
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
|
||||||
|
umbrella = _find_class(tree, UMBRELLA_CLASS)
|
||||||
|
assert umbrella is not None
|
||||||
|
|
||||||
|
declared_attrs = {attr for attr, _target in _iter_pointer_property_children(umbrella)}
|
||||||
|
orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs]
|
||||||
|
assert not orphaned, (
|
||||||
|
"PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer "
|
||||||
|
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n "
|
||||||
|
+ "\n ".join(orphaned)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user