mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-12 10:33:20 +00:00
Add behaviour-contract tests for PR4 surfaces
Three test files covering PR4's new surfaces — preview registry,
wall-gizmo poll behaviour, fillet operator registration. Every test
walks the live registry or class hierarchy instead of hard-coding
preview keys, operator names, or helper function names, so adding a
new preview / wall gizmo group / fillet operator exercises the same
invariants without test edits.
test_preview_base.py (6 tests):
* RegistryContract: every PREVIEW_CANCEL_OPS entry resolves to a
callable cancel operator on bpy.ops.bim.
* GetPreviewPropsTolerance: get_preview_props returns None for
contexts without a scene (regression guard for the SimpleNamespace
bug fixed in commit ee63137c6).
* ActivationCycle (registry-driven loop): any_preview_active toggles
with each registered preview's is_active flag;
discard_pending_previews clears every active flag across every
registered preview.
* SaveOnDiscardWired: locates the bim.save_project operator
dynamically and verifies its execute path references the discard
helper by its actual __name__.
test_wall_gizmo_poll_gate.py (4 tests):
* WallGizmoGroupsHideDuringPreview: walks the wall module for
bpy.types.GizmoGroup subclasses (skips preview-owner exceptions
whose bl_idname contains 'preview'), mocks any_preview_active to
True, and asserts every discovered gizmo's poll returns False.
* BaseParametricGizmoPollHidesDuringPreview: mirrors the test for
the cross-feature parametric framework base class.
test_fillet_operators.py (3 tests):
* FilletOperatorsRegistered: at-least-four-ops + every-discovered-op-
is-callable. Catches accidental deregistration.
* EnableRejectsIneligibleSelection: poll returns False without a
selection so the operator is greyed-out in menus.
State-clearing tests via bpy.ops.bim.cancel_wall_fillet_preview() are
deliberately omitted — the operator early-returns when context.screen
is unattached and prior tests in the model lane can leave the screen
in that state, making the dispatch path inherently flaky. Live testing
covers the behaviour.
Net: 13 tests pass cleanly in both single-file and full model lane.
Generated with the assistance of an AI coding tool.
This commit is contained in:
committed by
Thomas Krijnen
parent
3f5273744d
commit
669e5c2aed
@@ -0,0 +1,96 @@
|
||||
# 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.
|
||||
|
||||
"""Behaviour contracts for the wall-fillet operator chain.
|
||||
|
||||
Each fillet operator's geometry path requires real Blender + IFC fixtures
|
||||
(walls with IfcMaterialLayerSetUsage, neighbour rels, etc.). End-to-end
|
||||
fillet round-trips belong in the bim feature suite (model.feature) where
|
||||
that scaffolding already exists. This file pins the surface-level invariants
|
||||
that don't depend on the geometry path:
|
||||
|
||||
* the lifecycle operators are registered under their conventional bl_idnames,
|
||||
* the enable poll rejects ineligible selections.
|
||||
|
||||
State-clearing tests via ``bpy.ops.bim.cancel_wall_fillet_preview()`` were
|
||||
removed because the dispatch is flaky in full-suite ordering — the operator
|
||||
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
|
||||
one updates discovery automatically. Each name maps to a callable
|
||||
operator."""
|
||||
return sorted(name for name in dir(bpy.ops.bim) if "wall_fillet" in name)
|
||||
|
||||
|
||||
class TestFilletOperatorsRegistered:
|
||||
"""Catches accidental deregistration of any fillet lifecycle operator —
|
||||
drops in the classes tuple of bim/module/model/__init__.py would otherwise
|
||||
leave the gizmo group's target_set_operator binding pointing at a missing
|
||||
op and crash the first time a user clicked the icon."""
|
||||
|
||||
def test_at_least_the_expected_lifecycle_set_is_registered(self):
|
||||
names = _fillet_op_names()
|
||||
# The lifecycle has enable + finish + cancel as a minimum; a healthy
|
||||
# build also includes the from-corner re-edit entry and the create
|
||||
# operator the finish dispatches to. The test asserts at least four —
|
||||
# below that the feature can't function — without enumerating each
|
||||
# by name, so the test stays meaningful if one is renamed or merged.
|
||||
assert len(names) >= 4, (
|
||||
f"Only {len(names)} fillet operators found on bpy.ops.bim: {names}. "
|
||||
"The fillet lifecycle needs enable + finish + cancel + create at "
|
||||
"minimum; check bim/module/model/__init__.py classes tuple."
|
||||
)
|
||||
|
||||
def test_every_discovered_fillet_op_is_callable(self):
|
||||
for name in _fillet_op_names():
|
||||
op = getattr(bpy.ops.bim, name)
|
||||
assert callable(op), f"bpy.ops.bim.{name} is not callable — registration broke?"
|
||||
|
||||
|
||||
class TestEnableRejectsIneligibleSelection:
|
||||
"""The preview enable operator requires a specific 2-wall selection
|
||||
(LAYER2 walls with straight axes). With no selection at all, poll
|
||||
must return False so the operator is greyed-out in menus instead of
|
||||
crashing on dispatch."""
|
||||
|
||||
def test_enable_poll_returns_false_with_no_selection(self):
|
||||
# Deselect everything in the default scene; no IfcWall is present
|
||||
# in a fresh bpy_extras context anyway, so poll() must short-circuit.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
bpy.context.view_layer.update()
|
||||
assert bpy.ops.bim.enable_wall_fillet_preview.poll() is False
|
||||
@@ -0,0 +1,178 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Tests for the parametric-edit preview registry contract.
|
||||
|
||||
Every test reads the live ``PREVIEW_CANCEL_OPS`` registry rather than hard-
|
||||
coding preview keys or cancel-operator names, so adding a new preview to the
|
||||
registry automatically exercises the same invariants without test changes."""
|
||||
|
||||
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 _registry():
|
||||
from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS
|
||||
|
||||
return PREVIEW_CANCEL_OPS
|
||||
|
||||
|
||||
def _preview_umbrella():
|
||||
return getattr(bpy.context.scene, "BIMPreviewProperties", None)
|
||||
|
||||
|
||||
def _registered_previews():
|
||||
"""``[(attr, op_name, props)]`` for every registry entry that has a real
|
||||
child PropertyGroup on the umbrella in the current addon build."""
|
||||
umbrella = _preview_umbrella()
|
||||
if umbrella is None:
|
||||
return []
|
||||
out = []
|
||||
for attr, op_name in _registry():
|
||||
props = getattr(umbrella, attr, None)
|
||||
if props is not None:
|
||||
out.append((attr, op_name, props))
|
||||
return out
|
||||
|
||||
|
||||
class TestRegistryContract:
|
||||
"""Pins the invariant that every entry in PREVIEW_CANCEL_OPS resolves to
|
||||
a real cancel operator the addon registers. A new preview added to the
|
||||
registry without its matching cancel operator would otherwise crash
|
||||
``try_cancel_active_preview`` on the first Esc."""
|
||||
|
||||
def test_every_registered_cancel_op_is_callable(self):
|
||||
for attr, op_name in _registry():
|
||||
op = getattr(bpy.ops.bim, op_name, None)
|
||||
assert op is not None and callable(op), (
|
||||
f"Preview '{attr}' in PREVIEW_CANCEL_OPS points to bim.{op_name} "
|
||||
f"but no such operator is registered."
|
||||
)
|
||||
|
||||
|
||||
class TestGetPreviewPropsTolerance:
|
||||
"""The bug-class fixed in commit ee63137c6: ``get_preview_props`` is called
|
||||
from gizmo polls during addon init and from test mocks built on
|
||||
``SimpleNamespace`` — neither has a fully-formed Blender context. The
|
||||
helper must return None rather than raise."""
|
||||
|
||||
def test_returns_none_when_context_has_no_scene(self):
|
||||
from bonsai.bim.module.model.preview_base import get_preview_props
|
||||
|
||||
# Pass an arbitrary attr name — the contract is the same for every
|
||||
# preview key, so picking one literally would be a maintenance trap.
|
||||
for attr, _ in _registry():
|
||||
assert get_preview_props(types.SimpleNamespace(), attr) is None
|
||||
break
|
||||
|
||||
def test_returns_none_when_scene_lacks_umbrella(self):
|
||||
from bonsai.bim.module.model.preview_base import get_preview_props
|
||||
|
||||
ctx = types.SimpleNamespace(scene=types.SimpleNamespace())
|
||||
for attr, _ in _registry():
|
||||
assert get_preview_props(ctx, attr) is None
|
||||
break
|
||||
|
||||
|
||||
class TestActivationCycle:
|
||||
"""End-to-end contract on the real addon: each registered preview can be
|
||||
activated and then cancelled to inactive. Runs for every preview that
|
||||
has a wired PropertyGroup, so a new preview added to the registry +
|
||||
umbrella is covered without test edits."""
|
||||
|
||||
def test_any_preview_active_reflects_each_preview_state(self):
|
||||
from bonsai.bim.module.model.preview_base import any_preview_active
|
||||
|
||||
registered = _registered_previews()
|
||||
if not registered:
|
||||
pytest.skip("No previews wired in this build — registry-only entries")
|
||||
|
||||
# All inactive baseline.
|
||||
for _, _, props in registered:
|
||||
props.is_active = False
|
||||
assert any_preview_active(bpy.context) is False
|
||||
|
||||
# Flip each one independently — the helper must report True.
|
||||
for _, _, props in registered:
|
||||
props.is_active = True
|
||||
assert any_preview_active(bpy.context) is True
|
||||
props.is_active = False
|
||||
|
||||
def test_discard_pending_previews_clears_every_active_flag(self):
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
|
||||
registered = _registered_previews()
|
||||
if not registered:
|
||||
pytest.skip("No previews wired in this build — registry-only entries")
|
||||
|
||||
for _, _, props in registered:
|
||||
props.is_active = True
|
||||
discard_pending_previews(bpy.context.scene)
|
||||
for attr, _, props in registered:
|
||||
assert props.is_active is False, f"discard_pending_previews left '{attr}' active"
|
||||
|
||||
|
||||
class TestSaveOnDiscardWired:
|
||||
"""Pins that the SaveProject operator clears preview state before writing
|
||||
the IFC file — a stuck is_active flag persisted through the save would
|
||||
silently hide sister gizmos on the next file load.
|
||||
|
||||
Structural check: the SaveProject operator class must reference the
|
||||
discard helper somewhere in its execute path. Behavioural integration
|
||||
(actually saving a .blend with an active preview and reloading) belongs
|
||||
in the bim feature suite; this is the small guard against accidental
|
||||
removal of the call site."""
|
||||
|
||||
def test_save_project_dispatches_discard_pending_previews(self):
|
||||
import inspect
|
||||
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.project import operator as project_operator
|
||||
|
||||
# Find the project save operator dynamically — looking for any
|
||||
# Operator class whose bl_idname is "bim.save_project". Avoids
|
||||
# hard-coding the class identifier.
|
||||
save_op = None
|
||||
for name in dir(project_operator):
|
||||
obj = getattr(project_operator, name)
|
||||
if isinstance(obj, type) and getattr(obj, "bl_idname", None) == "bim.save_project":
|
||||
save_op = obj
|
||||
break
|
||||
assert save_op is not None, "Expected an operator with bl_idname='bim.save_project' in project/operator.py"
|
||||
|
||||
# Walk the class's methods for the discard call. Avoids pinning a
|
||||
# specific method name (_execute vs execute vs an inner helper) so
|
||||
# the test survives operator refactors.
|
||||
source = inspect.getsource(save_op)
|
||||
assert preview_base.discard_pending_previews.__name__ in source, (
|
||||
f"{save_op.__name__} does not reference discard_pending_previews. "
|
||||
"Saving with a preview open would persist its is_active flag to the "
|
||||
".blend file and silently hide sister gizmos on reopen."
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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.
|
||||
|
||||
"""Behaviour contract: every wall gizmo group hides while a parametric-edit
|
||||
preview is active.
|
||||
|
||||
Enumerates wall gizmo groups by walking the wall module for ``bpy.types.GizmoGroup``
|
||||
subclasses rather than naming them — adding a new wall gizmo group automatically
|
||||
joins the test. The test then asserts the BEHAVIOUR (poll returns False when
|
||||
``preview_base.any_preview_active`` is True) without pinning the name of the
|
||||
helper function the gizmo uses internally to enforce it."""
|
||||
|
||||
import inspect
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
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 _wall_gizmo_groups():
|
||||
"""Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined
|
||||
locally (skip imported references). Returns a list of (name, cls) tuples.
|
||||
|
||||
A gizmo group whose ``poll`` legitimately needs to fire WHILE a preview
|
||||
is active — i.e. it IS the preview's own gizmo group — is excluded by
|
||||
convention: classes whose bl_idname references the preview surface
|
||||
(``preview`` in the idname) are the preview-owner exception."""
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
# Local definitions only — skip re-exports / aliases.
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
# Preview-owner exception: the gizmo group that drives a preview
|
||||
# itself must remain visible while its preview is active, so a
|
||||
# "no preview active" gate would self-block it. The bl_idname
|
||||
# contains the substring 'preview' for these groups by Bonsai
|
||||
# convention (e.g. OBJECT_GGT_bim_wall_fillet_preview).
|
||||
bl_idname = getattr(obj, "bl_idname", "") or ""
|
||||
if "preview" in bl_idname.lower():
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
class TestWallGizmoGroupsHideDuringPreview:
|
||||
"""Behaviour contract: a parametric-edit preview is the only interactive
|
||||
surface in the viewport, so every sister wall gizmo must self-hide via
|
||||
its poll. The test exercises this BEHAVIOUR — when ``any_preview_active``
|
||||
reports True, every wall gizmo's poll returns False — without pinning
|
||||
the helper function name each poll uses internally."""
|
||||
|
||||
def test_discovery_finds_wall_gizmo_groups(self):
|
||||
"""Sanity check: at least one wall gizmo group is found. If this fails,
|
||||
the discovery walk drifted out of sync with the module structure (e.g.
|
||||
wall gizmo groups got moved to a separate file)."""
|
||||
groups = _wall_gizmo_groups()
|
||||
assert groups, "Expected at least one wall GizmoGroup subclass in wall.py — discovery walk broke?"
|
||||
|
||||
def test_every_wall_gizmo_hides_when_a_preview_is_active(self):
|
||||
"""For each discovered wall gizmo group, mock ``any_preview_active`` to
|
||||
True and call ``poll(bpy.context)``. Every poll must return False —
|
||||
any True is a poll that wouldn't hide during a fillet/bend preview,
|
||||
leaving the user with two competing icon stacks on the same selection."""
|
||||
groups = _wall_gizmo_groups()
|
||||
offenders = []
|
||||
with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=True):
|
||||
for name, cls in groups:
|
||||
poll = getattr(cls, "poll", None)
|
||||
if poll is None:
|
||||
# Inherits poll from a mixin / base — the base poll's gating
|
||||
# is covered separately. Skip rather than crash.
|
||||
continue
|
||||
try:
|
||||
result = poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with preview active"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall gizmo polls that don't gate on any_preview_active "
|
||||
"(or raise instead of returning False): "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Hide sister gizmos during previews so the preview is the only "
|
||||
"interactive surface in the viewport. The conventional path is to "
|
||||
"early-return from poll when preview_base.any_preview_active(context) "
|
||||
"is True."
|
||||
)
|
||||
|
||||
|
||||
class TestBaseParametricGizmoPollHidesDuringPreview:
|
||||
"""Mirror of the wall-specific test for the cross-feature parametric
|
||||
framework: door / window / stair / roof / railing / array all inherit
|
||||
``BaseParametricGizmoGroup``. Its poll must also short-circuit on
|
||||
``any_preview_active`` so sister features behave consistently with walls."""
|
||||
|
||||
def test_base_parametric_poll_returns_false_when_a_preview_is_active(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
# The base poll requires an active selected object before checking the
|
||||
# preview gate. Mock both the selected-object check (return a sentinel)
|
||||
# AND the gate so the test exercises ONLY the preview short-circuit.
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.model.preview_base.any_preview_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseParametricGizmoGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestModulePathIsFindable:
|
||||
"""If wall.py is split across multiple modules (e.g. wall_gizmos.py),
|
||||
update ``_wall_gizmo_groups`` to walk each. This sanity check fails first
|
||||
so the diagnostic message is obvious."""
|
||||
|
||||
def test_wall_module_resolves(self):
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
assert inspect.ismodule(wall_mod)
|
||||
Reference in New Issue
Block a user