blender/tests/python/ui_simulate/modules/ui_test_utils.py
phantom 7c4b6f5ad8 UI: Add tests for operator poll
- Add a regression test verifying operator search respects poll() and
  only exposes an operator when its poll condition is satisfied.
- Add a poll sweep test that checks registered operators across
  supported editor types and verifies poll() always returns a `bool`
  without raising exceptions.
- Add a  `poll_sweep()` helper that reuses a single area while switching
  editor types and runs each operator's `poll()`  under the
  appropriate window, area, and region context.

Pull Request: https://projects.blender.org/blender/blender/pulls/162764
2026-08-26 07:07:19 +02:00

295 lines
8.7 KiB
Python

# SPDX-FileCopyrightText: 2019-2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"call_menu",
"call_operator",
"cursor_motion_data_x",
"cursor_motion_data_y",
"cursor_motion_data_xy",
"cursor_motion_data_circle",
"get_area_center",
"get_area_center_from_spacetype",
"get_window_area_by_type",
"get_window_size_in_pixels",
"idle_until",
"keep_open",
"poll_sweep",
"test_window",
)
def test_window(window_index=0):
"""
Get window with associated event simulator and tests.
"""
import unittest
from .easy_keys import EventGenerate
import bpy
window = bpy.data.window_managers[0].windows[window_index]
return (
EventGenerate(window),
unittest.TestCase(),
window,
)
def call_operator(e, text: str):
"""
Call operator by name.
"""
yield e.f3()
yield e.text(text)
yield e.ret()
def call_menu(e, text: str):
"""
Call operator through menu.
"""
yield e.f3()
yield e.text_unicode(text.replace(" -> ", " \u25b8 "))
yield e.ret()
def get_window_area_by_type(window, space_type):
"""
Get first area of the specified space type in a window.
"""
for area in window.screen.areas:
if area.type == space_type:
return area
def get_area_center(area):
"""
Get coordinate in the center of an area, e.g. for placing the cursor.
"""
return (
area.x + area.width // 2,
area.y + area.height // 2,
)
def get_area_center_from_spacetype(window, space_type):
"""
Get coordinate in the center of the first area with the given spacetype.
"""
area = get_window_area_by_type(window, space_type)
if area is None:
raise Exception("Space Type {!r} not found".format(space_type))
return get_area_center(area)
def get_window_size_in_pixels(window):
"""
Get window size that can be used for positioning a cursor.
"""
import sys
size = window.width, window.height
# macOS window size is a multiple of the pixel_size.
if sys.platform == "darwin":
from bpy import context
# The value is always rounded to an int, so converting to an int is safe here.
pixel_size = int(context.preferences.system.pixel_size)
size = size[0] * pixel_size, size[1] * pixel_size
return size
def idle_until(until, idle=1 / 60, timeout=1.0):
"""
Idle while the internal event loop runs until a specified condition is true.
This should be used sparingly as it may represent some other failure
condition inside Blender. Currently, it is used to:
- Test completion and cancellation of operators that use jobs.
- Multi window undo tests that need separate view layer (see #148903).
Note: In practice, the timeout value of 1.0 seconds should be more than enough
for all cases. In testing with a fixed, constant delay, the tests succeeded
with a timeout of 1/6th of a second.
:param until: lambda to check the condition of after each sleep
:param idle: how long to idle between checks of the `until` lambda.
Defaults to 60Hz due to common refresh rates.
:param timeout: the max time in seconds that this busy wait will execute.
:return:
"""
import datetime
import time
start_time = time.time()
current_time = time.time()
while current_time - start_time < timeout and not until():
yield datetime.timedelta(seconds=idle)
current_time = time.time()
def keep_open():
"""
Only for development, handy so we can quickly keep the window open while testing.
"""
import bpy
bpy.app.use_event_simulate = False
def cursor_motion_data_x(window, margin=0.2):
"""
Generate a range of (x,y) positions in screen space, centered vertically in the window
from left to right.
:param margin: Percentage of left and right window space to leave unused
"""
size = get_window_size_in_pixels(window)
return [
(x, size[1] // 2) for x in
range(int(size[0] * margin), int(size[0] * (1.0 - margin)), 80)
]
def cursor_motion_data_y(window, margin=0.2):
"""
Generate a range of (x,y) positions in screen space, centered horizontally in the window
from bottom to top.
:param margin: Percentage of top and bottom window space to leave unused
"""
size = get_window_size_in_pixels(window)
return [
(size[0] // 2, y) for y in
range(int(size[1] * margin), int(size[1] * (1.0 - margin)), 80)
]
def cursor_motion_data_xy(window, margin=0.2):
"""
Generate a range of (x,y) positions in screen space from bottom left to top right
:param margin: Percentage of window space to leave unused
"""
size = get_window_size_in_pixels(window)
return [
(p, p) for p in
range(int(size[0] * margin), int(size[0] * (1.0 - margin)), 80)
]
def cursor_motion_data_circle(center, radius):
"""
Generate a range of (x,y) positions in screen space as a circle.
:param center: The center of the circle
:param radius: The radius of the circle
"""
import sys
from math import sin, cos, pi
if sys.platform == "darwin":
from bpy import context
# The value is always rounded to an int, so converting to an int is safe here.
radius = radius * int(context.preferences.system.pixel_size)
steps = 20
angles = [(i / steps) * 2.0 * pi for i in range(steps)]
angles.append(0.0)
return [
(int(center[0] + -radius * sin(phi)), int(center[1] + radius * cos(phi))) for phi in angles
]
# Grid helpers
def largest_area(screen):
"""
Return the currently largest visible area in the screen.
"""
return max(
screen.areas,
key=lambda a: a.width * a.height
)
def split_area(area, direction='VERTICAL', factor=0.5):
"""
Split an area using Blender's screen split operator.
:param area: area to split.
:param direction: plit direction: 'VERTICAL' / 'HORIZONTAL'.
:param factor: Split ratio in normalized coordinates.
Default 0.5 creates an even split.
"""
import bpy
with bpy.context.temp_override(area=area):
bpy.ops.screen.area_split(
direction=direction,
factor=factor,
)
def build_grid(screen, target_count):
"""
Dynamically construct a visible grid of areas.
"""
while len(screen.areas) < target_count:
prev_count = len(screen.areas)
area = largest_area(screen)
direction = (
'VERTICAL'
if area.width >= area.height
else 'HORIZONTAL'
)
split_area(area, direction, 0.5)
yield
if len(screen.areas) == prev_count:
raise Exception(f"Area split did not increase area count (stuck at {prev_count}). ")
def poll_sweep(window, area):
"""
Sweep every registered operator's poll() across a set of editor types,
reusing a single area (avoids GPU/area-count exhaustion -- see
test_open_editor_types()).
:arg window: window to run the sweep in (used for the temp_override).
:arg area: a single area, reused across every editor type.
"""
import bpy
UNSETTABLE_EDITOR_TYPES = {'EMPTY', 'TOPBAR', 'STATUSBAR'}
area_types = [
item.identifier
for item in bpy.types.Area.bl_rna.properties["type"].enum_items
if item.identifier not in UNSETTABLE_EDITOR_TYPES
]
for area_type in area_types:
area.type = area_type
yield None # Give Blender a tick to process the editor switch.
region = next(r for r in area.regions if r.type == 'WINDOW')
failures = []
total = 0
with bpy.context.temp_override(window=window, area=area, region=region):
for mod_name in dir(bpy.ops):
mod = getattr(bpy.ops, mod_name)
for op_name in dir(mod):
idname = f"{mod_name}.{op_name}"
op = getattr(mod, op_name)
total += 1
try:
result = op.poll()
# NOTE: BPY currently loses the Python type information when converting
# the poll result, so this cannot detect invalid return types yet.
# Keep the check so it catches them once BPY preserves the type.
if not isinstance(result, bool):
failures.append(
f"{area_type}: {idname} poll returned {result!r} (expected bool)"
)
except Exception as ex:
failures.append(f"{area_type}: {idname} poll raised {ex!r}")
yield area_type, failures, total