mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 17:58:20 +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