mirror of
https://github.com/blender/blender
synced 2026-11-08 03:27:17 +03:00
- 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
295 lines
8.7 KiB
Python
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
|