Centralise model test fixtures via conftest

bim/module/model/conftest.py exposes the autouse _require_real_bpy
skip-guard, four make_* factories (obj / element / context /
ifc_file), and a patched_tool context-manager factory that wires
the half-dozen tool.* boundary patches every gizmo + decorator
test was repeating.

Existing test files in the directory drop their local copies of
_require_real_bpy and adopt the patched_tool / make_* fixtures
where the call site simplifies — test_mep_port_operators.py is
the biggest beneficiary (−89 LOC).

No production behaviour change.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-10 12:24:44 +02:00
committed by Thomas Krijnen
parent 9e35db593e
commit a9512492f0
18 changed files with 79 additions and 181 deletions
@@ -54,15 +54,31 @@ module if the helper count grows past ~6 or any helper picks up its own
non-trivial dependencies."""
import contextlib
import types
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import bpy
import ifcopenshell
import pytest
from bonsai import tool
@pytest.fixture(autouse=True)
def _require_real_bpy():
"""Skip every test in this directory when ``bpy`` is mocked or absent.
The model gizmo / decorator suite reaches into Blender's RNA layer
(``bpy.types.Operator``, registered ``bl_idname`` lookups, ``Modifier``
predicates) that ``Mock`` cannot impersonate, so a tool-lane run with a
stubbed ``bpy`` would error rather than meaningfully exercise the
contract. The autouse scope means new test files added under this
directory inherit the gate without re-declaring it."""
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def make_obj(*, session_uid=None, selected=True, **attrs):
"""Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
@@ -29,7 +29,6 @@ Two layers:
the edit-mode gizmo would, so the two surfaces stay visually identical
even when a new ``door_type`` is added."""
import types
from types import SimpleNamespace
from typing import get_args
@@ -39,12 +38,6 @@ import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
# ----------------------------------------------------------------------------
# _visible_arcs — per-door-type arc selection
# ----------------------------------------------------------------------------
@@ -26,7 +26,6 @@ against a SimpleNamespace stand-in that records ``matrix_basis`` assignments and
the tests describe the geometric contract directly rather than echoing the
implementation."""
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -37,12 +36,6 @@ from mathutils import Matrix, Vector
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _make_props(door_type, overall_width=0.9, lining_offset=0.0, is_editing=True):
return SimpleNamespace(
door_type=door_type,
@@ -32,8 +32,6 @@ These tests exercise:
- one end-to-end invocation through ``bpy.ops`` to pin the wiring between
the decision and ``invoke_props_dialog``."""
import types
import bpy
import pytest
@@ -42,12 +40,6 @@ from bonsai.bim.module.model.array import EnableEditingParametric
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
class TestShouldShowSharedRepDialog:
"""Exhaustive truth table for the pre-edit-warning decision. Keeping this
pure (no bpy, no operator instance) means a future change to the dispatch
@@ -35,20 +35,12 @@ early-returns when ``context.screen`` is unattached and prior tests can leave
the screen in that state. The behaviour is covered by the user-visible live
test loop instead."""
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _fillet_op_names():
"""Walk bpy.ops.bim for operators whose name contains ``wall_fillet`` —
avoids hard-coding the five lifecycle bl_idnames so adding / renaming
@@ -44,14 +44,6 @@ import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
import types as _types
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
# ---------------------------------------------------------------------------
# action_configs — operator registration + name uniqueness
# ---------------------------------------------------------------------------
@@ -242,7 +242,7 @@ def test_bend_preview_gizmo_group_is_registered():
"""``GizmoBendPreview`` polls when ``scene.BIMPreviewProperties.bend.is_active``
is True. Pin the bl_idname so a typo wouldn't silently hide the preview
gizmos at runtime."""
from bonsai.bim.module.model.mep import GizmoBendPreview
from bonsai.bim.module.model.mep_bend_preview import GizmoBendPreview
assert GizmoBendPreview.bl_idname == "OBJECT_GGT_bim_bend_preview"
assert issubclass(GizmoBendPreview, bpy.types.GizmoGroup)
@@ -336,7 +336,7 @@ def test_finish_bend_preview_catches_runtime_error_from_dispatch():
from types import SimpleNamespace
from bonsai import tool
from bonsai.bim.module.model.mep import FinishBendPreview
from bonsai.bim.module.model.mep_bend_preview import FinishBendPreview
class _Stand:
def __init__(self):
@@ -39,14 +39,6 @@ from mathutils import Vector
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
import types as _types
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
# ---------------------------------------------------------------------------
# _bend_profile_cross_section — IFC profile → 2D sample points
# ---------------------------------------------------------------------------
@@ -33,14 +33,6 @@ import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
import types as _types
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _segment(predefined_type=None):
"""Stand-in IFC entity that reports ``is_a("IfcFlowSegment")`` True."""
e = Mock()
@@ -74,13 +66,30 @@ def _make_op(_cls, **fields):
# ---------------------------------------------------------------------------
def test_unjoin_at_port_deletes_joining_fitting():
"""Happy path: port state is JOINED, fitting is non-OBSTRUCTION →
delete the bridging fitting via the standard delete path."""
@pytest.mark.parametrize(
"port_state, fitting_predefined_type, expected_result, expects_delete",
[
pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"),
pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"),
pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"),
],
)
def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete):
"""``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect
by ``(port_state, fitting type)``.
- ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path,
the bridging fitting is deleted via the standard delete entry point.
- ``JOINED + OBSTRUCTION``: deliberately refused obstructions go
through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment
extends to absorb the freed length; using delete here would leave
a visible gap.
- ``FREE``: nothing to do no bridging fitting exists. The operator
reports a user-facing error and CANCELS rather than no-op silently."""
from bonsai.bim.module.model import mep
segment = _segment()
fitting = _fitting(predefined_type="JUNCTION")
fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None
fitting_obj = Mock()
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
@@ -89,61 +98,19 @@ def test_unjoin_at_port_deletes_joining_fitting():
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(mep, "port_connection_state", return_value="JOINED"), patch.object(
), patch.object(mep, "port_connection_state", return_value=port_state), patch.object(
mep, "get_connected_element_at_segment_port", return_value=fitting
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == {"FINISHED"}
delete.assert_called_once_with(fitting_obj)
def test_unjoin_at_port_refuses_obstruction_fitting():
"""OBSTRUCTION fittings route through ``bim.mep_add_obstruction``
(mode=REMOVE) which extends the segment to absorb the freed length
using unjoin here would leave a gap."""
from bonsai.bim.module.model import mep
segment = _segment()
obstruction = _fitting(predefined_type="OBSTRUCTION")
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep, "port_connection_state", return_value="JOINED"
), patch.object(mep, "get_connected_element_at_segment_port", return_value=obstruction), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_at_port_cancels_when_port_is_free():
"""Port has no connection at all → no fitting to delete → CANCELLED
with a user-facing error rather than a silent no-op."""
from bonsai.bim.module.model import mep
segment = _segment()
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="START")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep, "port_connection_state", return_value="FREE"
), patch.object(mep.tool.Geometry, "delete_ifc_object") as delete:
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
assert result == expected_result
if expects_delete:
delete.assert_called_once_with(fitting_obj)
else:
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_at_port_cancels_when_active_is_not_segment():
@@ -32,12 +32,6 @@ import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _registry():
from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS
@@ -39,12 +39,6 @@ import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _rotation_close(a, b, tol: float = 1e-6) -> bool:
for row_a, row_b in zip(a, b):
for va, vb in zip(row_a, row_b):
@@ -27,7 +27,6 @@ BEHAVIOUR (poll returns False / draw_prepare early-returns when a transform
modal is active) without pinning the name of the helper used internally."""
import importlib
import types
from unittest.mock import MagicMock, patch
import bpy
@@ -46,12 +45,6 @@ PARAMETRIC_MODULES = (
)
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _discover_parametric_gizmo_groups():
"""Walk each parametric-edit module for ``bpy.types.GizmoGroup`` subclasses
defined locally. Preview-owning gizmo groups (bl_idname contains 'preview')
@@ -39,7 +39,6 @@ allow-list with an explanation) fails this test."""
import ast
import inspect
import types
import bpy
import pytest
@@ -53,12 +52,6 @@ _ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _wall_module_source():
from bonsai.bim.module.model import wall as wall_mod
@@ -28,7 +28,6 @@ joins the test. The test then asserts the BEHAVIOUR (poll returns False when
helper function the gizmo uses internally to enforce it."""
import inspect
import types
from unittest.mock import patch
import bpy
@@ -37,12 +36,6 @@ import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _wall_gizmo_groups():
"""Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined
locally (skip imported references). Returns a list of (name, cls) tuples.
@@ -25,7 +25,6 @@ logic can be exercised without a real IFC fixture. Each test pins one of the
gates ``poll()`` walks, so any silent regression in the gate order or in the
LAYER3-active / LAYER2-other contract is caught by a dedicated assertion."""
import types
from types import SimpleNamespace
from unittest.mock import patch
@@ -35,10 +34,15 @@ import pytest
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
class _Obj:
"""Hashable, name-bearing stand-in for a ``bpy.types.Object`` selection
slot. ``SimpleNamespace`` defines ``__eq__`` (and so ``__hash__ = None``)
which makes it unusable inside the ``set()`` that
``get_selected_objects()`` returns; a plain class falls back to
identity-based hashing and works inside both ``set`` and ``list``."""
def __init__(self, name: str) -> None:
self.name = name
def _make_context(active, selected):
@@ -76,19 +80,23 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
# The array-child filter is pinned by its own test file; stub it here
# so these poll tests stay focused on the count / layer-usage gates
# and don't have to scaffold the memoization cache key.
patch.object(tool.Blender.Modifier, "any_selected_is_array_child", return_value=False),
]
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
slab_obj = object()
wall_obj = object()
active = slab_obj if active_is_in_selected else object()
slab_obj = _Obj("slab")
wall_obj = _Obj("wall")
active = slab_obj if active_is_in_selected else _Obj("active_extra")
if len_override is None:
selected = [slab_obj, wall_obj]
else:
selected = [object() for _ in range(len_override)]
selected = [_Obj(f"obj_{i}") for i in range(len_override)]
if active_is_in_selected and selected:
active = selected[0]
@@ -23,7 +23,7 @@ selection that contains a Bonsai array child. Discovers gated gizmo
groups and guarded operators by source inspection so additions inherit
the rule automatically."""
import types
from types import SimpleNamespace
from unittest.mock import patch
import bpy
@@ -32,12 +32,6 @@ import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _wall_gizmo_groups_using_gate():
"""Wall-module ``bpy.types.GizmoGroup`` subclasses whose ``poll`` calls
``_wall_topology_gizmo_poll_gate``. Discovered by source inspection so
@@ -69,9 +63,11 @@ def _wall_gizmo_groups_using_gate():
def _wall_operators_with_array_child_guard():
"""Wall-module ``bpy.types.Operator`` subclasses whose ``poll`` references
``any_selected_is_array_child``. The operator-level guard is defence in
depth against keymap / F3 paths that bypass the gizmo entirely."""
"""Wall-module ``bpy.types.Operator`` subclasses whose ``poll`` rejects
array-child selections, either by referencing the central predicate
directly or by routing through the shared ``_poll_reject_array_children``
helper that wraps it. The operator-level guard is defence in depth
against keymap / F3 paths that bypass the gizmo entirely."""
import inspect
from bonsai.bim.module.model import wall as wall_mod
@@ -92,7 +88,7 @@ def _wall_operators_with_array_child_guard():
src = inspect.getsource(poll)
except (OSError, TypeError):
continue
if "any_selected_is_array_child" not in src:
if "any_selected_is_array_child" not in src and "_poll_reject_array_children" not in src:
continue
out.append((name, obj))
return out
@@ -141,8 +137,9 @@ class TestWallOperatorsRejectArrayChildSelection:
def test_discovery_finds_wall_topology_operators(self):
ops = _wall_operators_with_array_child_guard()
assert ops, (
"Expected at least one wall Operator whose poll references "
"any_selected_is_array_child — discovery walk drifted out of sync?"
"Expected at least one wall Operator whose poll rejects array-child "
"selections (via any_selected_is_array_child or _poll_reject_array_children) "
"— discovery walk drifted out of sync?"
)
def test_every_guarded_wall_operator_polls_false_on_array_child_selection(self):
@@ -171,8 +168,9 @@ class TestWallOperatorsRejectArrayChildSelection:
assert not offenders, (
"Wall topology operators that accept array-child selections: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Add `if tool.Blender.Modifier.any_selected_is_array_child(): "
"return False` early in the poll."
+ ". Route the poll through `_poll_reject_array_children(cls)` (the "
"shared helper that sets the standard poll message and reuses the "
"central `any_selected_is_array_child` predicate)."
)
@@ -190,8 +188,8 @@ class TestAnySelectedIsArrayChildHelper:
def test_returns_true_when_any_selected_passes_predicate(self):
from bonsai import tool
child_obj, child_element = object(), object()
parent_obj, parent_element = object(), object()
child_obj, child_element = SimpleNamespace(name="child"), object()
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
def get_entity(obj):
return {id(child_obj): child_element, id(parent_obj): parent_element}.get(id(obj))
@@ -207,7 +205,7 @@ class TestAnySelectedIsArrayChildHelper:
def test_returns_false_when_no_selected_passes_predicate(self):
from bonsai import tool
parent_obj, parent_element = object(), object()
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj]):
with patch.object(tool.Ifc, "get_entity", return_value=parent_element):
with patch.object(tool.Blender.Modifier, "is_array_child", return_value=False):
@@ -39,12 +39,6 @@ import pytest
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_refresh_post_commit_bumps_generation_for_every_operator():
"""The generation counter advances on every commit, regardless of
operator class it's the cache-invalidation signal for any code
@@ -37,12 +37,6 @@ from mathutils import Vector
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_regenerate_wall_mesh_from_props_outward_normals():
"""Every face of the preview box must have its normal pointing away
from the box centroid the contract every other preview-mesh builder