Add Chromium-only Blender WebEngine parity work
This commit is contained in:
391
blender-5.2.0/tests/python/ui_simulate/modules/easy_keys.py
Normal file
391
blender-5.2.0/tests/python/ui_simulate/modules/easy_keys.py
Normal file
@@ -0,0 +1,391 @@
|
||||
# SPDX-FileCopyrightText: 2019-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import datetime
|
||||
import string
|
||||
import bpy
|
||||
event_types = tuple(
|
||||
e.identifier.lower()
|
||||
for e in bpy.types.Event.bl_rna.properties["type"].enum_items_static
|
||||
)
|
||||
del bpy
|
||||
|
||||
# We don't normally care about which one.
|
||||
event_types_alias = {
|
||||
"ctrl": "left_ctrl",
|
||||
"shift": "left_shift",
|
||||
"alt": "left_alt",
|
||||
|
||||
# Collides with Python keywords.
|
||||
"delete": "del",
|
||||
}
|
||||
|
||||
|
||||
# Note, we could add support for other keys using control characters,
|
||||
# for example: `\xF12` could be used for the F12 key.
|
||||
#
|
||||
# Besides this, we could encode symbols into a regular string using our own syntax
|
||||
# which can mix regular text and key symbols.
|
||||
#
|
||||
# At the moment this doesn't seem necessary, no need to add it.
|
||||
event_types_text = (
|
||||
('ZERO', "0", False),
|
||||
('ONE', "1", False),
|
||||
('TWO', "2", False),
|
||||
('THREE', "3", False),
|
||||
('FOUR', "4", False),
|
||||
('FIVE', "5", False),
|
||||
('SIX', "6", False),
|
||||
('SEVEN', "7", False),
|
||||
('EIGHT', "8", False),
|
||||
('NINE', "9", False),
|
||||
|
||||
('ONE', "!", True),
|
||||
('TWO', "@", True),
|
||||
('THREE', "#", True),
|
||||
('FOUR', "$", True),
|
||||
('FIVE', "%", True),
|
||||
('SIX', "^", True),
|
||||
('SEVEN', "&", True),
|
||||
('EIGHT', "*", True),
|
||||
('NINE', "(", True),
|
||||
('ZERO', ")", True),
|
||||
|
||||
('MINUS', "-", False),
|
||||
('MINUS', "_", True),
|
||||
|
||||
('EQUAL', "=", False),
|
||||
('EQUAL', "+", True),
|
||||
|
||||
('ACCENT_GRAVE', "`", False),
|
||||
('ACCENT_GRAVE', "~", True),
|
||||
|
||||
('LEFT_BRACKET', "[", False),
|
||||
('LEFT_BRACKET', "{", True),
|
||||
|
||||
('RIGHT_BRACKET', "]", False),
|
||||
('RIGHT_BRACKET', "}", True),
|
||||
|
||||
('SEMI_COLON', ";", False),
|
||||
('SEMI_COLON', ":", True),
|
||||
|
||||
('PERIOD', ".", False),
|
||||
('PERIOD', ">", True),
|
||||
|
||||
('COMMA', ",", False),
|
||||
('COMMA', "<", True),
|
||||
|
||||
('QUOTE', "'", False),
|
||||
('QUOTE', '"', True),
|
||||
|
||||
('SLASH', "/", False),
|
||||
('SLASH', "?", True),
|
||||
|
||||
('BACK_SLASH', "\\", False),
|
||||
('BACK_SLASH', "|", True),
|
||||
|
||||
|
||||
*((ch_upper, ch, False) for (ch_upper, ch) in zip(string.ascii_uppercase, string.ascii_lowercase)),
|
||||
*((ch, ch, True) for ch in string.ascii_uppercase),
|
||||
|
||||
('SPACE', " ", False),
|
||||
('RET', "\n", False),
|
||||
('TAB', "\t", False),
|
||||
)
|
||||
|
||||
event_types_text_from_char = {ch: (ty, is_shift) for (ty, ch, is_shift) in event_types_text}
|
||||
event_types_text_from_event = {(ty, is_shift): ch for (ty, ch, is_shift) in event_types_text}
|
||||
|
||||
|
||||
class _EventBuilder:
|
||||
__slots__ = (
|
||||
"_shared_event_gen",
|
||||
"_event_type",
|
||||
"_parent",
|
||||
)
|
||||
|
||||
def __init__(self, event_gen, ty):
|
||||
self._shared_event_gen = event_gen
|
||||
self._event_type = ty
|
||||
self._parent = None
|
||||
|
||||
def __call__(self, count=1):
|
||||
assert count >= 0
|
||||
for _ in range(count):
|
||||
self.tap()
|
||||
return self._shared_event_gen
|
||||
|
||||
def _key_press_release(self, do_press=False, do_release=False, unicode_override=None):
|
||||
assert (do_press or do_release)
|
||||
keys_held = self._shared_event_gen._event_types_held
|
||||
build_keys = []
|
||||
e = self
|
||||
while e is not None:
|
||||
build_keys.append(e._event_type.upper())
|
||||
e = e._parent
|
||||
build_keys.reverse()
|
||||
|
||||
events = [None, None]
|
||||
for i, value in enumerate(('PRESS', 'RELEASE')):
|
||||
if value == 'RELEASE':
|
||||
build_keys.reverse()
|
||||
for event_type in build_keys:
|
||||
if value == 'PRESS':
|
||||
keys_held.add(event_type)
|
||||
else:
|
||||
keys_held.remove(event_type)
|
||||
|
||||
if (not do_press) and value == 'PRESS':
|
||||
continue
|
||||
if (not do_release) and value == 'RELEASE':
|
||||
continue
|
||||
|
||||
shift = 'LEFT_SHIFT' in keys_held or 'RIGHT_SHIFT' in keys_held
|
||||
ctrl = 'LEFT_CTRL' in keys_held or 'RIGHT_CTRL' in keys_held
|
||||
shift = 'LEFT_SHIFT' in keys_held or 'RIGHT_SHIFT' in keys_held
|
||||
alt = 'LEFT_ALT' in keys_held or 'RIGHT_ALT' in keys_held
|
||||
oskey = 'OSKEY' in keys_held
|
||||
hyper = 'HYPER' in keys_held
|
||||
|
||||
unicode = None
|
||||
if value == 'PRESS':
|
||||
if ctrl is False and alt is False and oskey is False:
|
||||
if unicode_override is not None:
|
||||
unicode = unicode_override
|
||||
else:
|
||||
unicode = event_types_text_from_event.get((event_type, shift))
|
||||
if unicode is None and shift:
|
||||
# Some keys don't care about shift
|
||||
unicode = event_types_text_from_event.get((event_type, False))
|
||||
|
||||
event = self._shared_event_gen.window.event_simulate(
|
||||
type=event_type,
|
||||
value=value,
|
||||
unicode=unicode,
|
||||
shift=shift,
|
||||
ctrl=ctrl,
|
||||
alt=alt,
|
||||
oskey=oskey,
|
||||
hyper=hyper,
|
||||
x=self._shared_event_gen._mouse_co[0],
|
||||
y=self._shared_event_gen._mouse_co[1],
|
||||
)
|
||||
events[i] = event
|
||||
return tuple(events)
|
||||
|
||||
def tap(self):
|
||||
return self._key_press_release(do_press=True, do_release=True)
|
||||
|
||||
def press(self):
|
||||
return self._key_press_release(do_press=True)[0]
|
||||
|
||||
def release(self):
|
||||
return self._key_press_release(do_release=True)[1]
|
||||
|
||||
def cursor_motion(self, coords):
|
||||
coords = list(coords)
|
||||
self._shared_event_gen.cursor_position_set(*coords[0], move=True)
|
||||
yield
|
||||
|
||||
event = self.press()
|
||||
shift = event.shift
|
||||
ctrl = event.ctrl
|
||||
shift = event.shift
|
||||
alt = event.alt
|
||||
oskey = event.oskey
|
||||
hyper = event.hyper
|
||||
yield
|
||||
|
||||
for x, y in coords:
|
||||
self._shared_event_gen.window.event_simulate(
|
||||
type='MOUSEMOVE',
|
||||
value='NOTHING',
|
||||
unicode=None,
|
||||
shift=shift,
|
||||
ctrl=ctrl,
|
||||
alt=alt,
|
||||
oskey=oskey,
|
||||
hyper=hyper,
|
||||
x=x,
|
||||
y=y
|
||||
)
|
||||
yield
|
||||
self._shared_event_gen.cursor_position_set(x, y, move=False)
|
||||
self.release()
|
||||
yield
|
||||
|
||||
def __getattr__(self, attr):
|
||||
attr = event_types_alias.get(attr, attr)
|
||||
if attr in event_types:
|
||||
e = _EventBuilder(self._shared_event_gen, attr)
|
||||
e._parent = self
|
||||
return e
|
||||
raise Exception(f"{attr!r} not found in {event_types!r}")
|
||||
|
||||
|
||||
class EventGenerate:
|
||||
__slots__ = (
|
||||
"window",
|
||||
|
||||
"_mouse_co",
|
||||
"_event_types_held",
|
||||
)
|
||||
|
||||
def __init__(self, window):
|
||||
self.window = window
|
||||
self._mouse_co = [0, 0]
|
||||
self._event_types_held = set()
|
||||
|
||||
self.cursor_position_set(window.width // 2, window.height // 2)
|
||||
|
||||
def cursor_position_set(self, x, y, move=False):
|
||||
self._mouse_co[:] = x, y
|
||||
if move:
|
||||
self.window.event_simulate(
|
||||
type='MOUSEMOVE',
|
||||
value='NOTHING',
|
||||
x=x,
|
||||
y=y,
|
||||
)
|
||||
|
||||
def text(self, text):
|
||||
""" Type in entire phrases. """
|
||||
for ch in text:
|
||||
ty, shift = event_types_text_from_char[ch]
|
||||
ty = ty.lower()
|
||||
if shift:
|
||||
eb = getattr(_EventBuilder(self, 'left_shift'), ty)
|
||||
else:
|
||||
eb = _EventBuilder(self, ty)
|
||||
eb.tap()
|
||||
return self
|
||||
|
||||
def text_unicode(self, text):
|
||||
# Since the only purpose of this key-press is to enter text
|
||||
# the key can be almost anything, use a key which isn't likely to be assigned to any other action.
|
||||
#
|
||||
# If it were possible `EVT_UNKNOWNKEY` would be most correct
|
||||
# as dead keys map to this and still enter text.
|
||||
ty_dummy = 'F24'
|
||||
for ch in text:
|
||||
eb = _EventBuilder(self, ty_dummy)
|
||||
eb._key_press_release(do_press=True, do_release=True, unicode_override=ch)
|
||||
return self
|
||||
|
||||
def __getattr__(self, attr):
|
||||
attr = event_types_alias.get(attr, attr)
|
||||
if attr in event_types:
|
||||
return _EventBuilder(self, attr)
|
||||
raise Exception(f"{attr!r} not found in {event_types!r}")
|
||||
|
||||
def __del__(self):
|
||||
if self._event_types_held:
|
||||
print("'__del__' with keys held:", repr(self._event_types_held))
|
||||
|
||||
|
||||
def run(
|
||||
event_iter, *,
|
||||
on_error=None,
|
||||
on_exit=None,
|
||||
on_step_command_pre=None,
|
||||
on_step_command_post=None,
|
||||
):
|
||||
import bpy
|
||||
|
||||
TICKS = 4 # 3 works, 4 to be on the safe side.
|
||||
|
||||
# If we try to handle events this many times
|
||||
TICKS_HANDLING_BREAK_MAX = 256
|
||||
|
||||
def event_step():
|
||||
|
||||
# Handle `is_event_handling_break` here so we don't incorrectly detect
|
||||
# consecutive `is_event_handling_break` based on other functions exiting early.
|
||||
is_event_handling_break = bpy.context.window_manager.is_event_handling_break
|
||||
if is_event_handling_break:
|
||||
if event_step._ticks_handling_break_consecutive > TICKS_HANDLING_BREAK_MAX:
|
||||
raise RuntimeError(
|
||||
"window_manager.is_event_handling_break set {:d} times, may be an event handling bug!".format(
|
||||
TICKS_HANDLING_BREAK_MAX,
|
||||
)
|
||||
)
|
||||
event_step._ticks_handling_break_consecutive += 1
|
||||
else:
|
||||
event_step._ticks_handling_break_consecutive = 0
|
||||
|
||||
# Run once 'TICKS' is reached.
|
||||
if event_step._ticks < TICKS:
|
||||
event_step._ticks += 1
|
||||
return 0.0
|
||||
event_step._ticks = 0
|
||||
|
||||
# Wait for any pending event queue break to be cleared before advancing,
|
||||
# otherwise events injected by the next step may be deferred unexpectedly.
|
||||
if is_event_handling_break:
|
||||
return 0.0
|
||||
|
||||
if on_step_command_pre:
|
||||
if event_step.run_events.gi_frame is not None:
|
||||
import shlex
|
||||
import subprocess
|
||||
subprocess.call(
|
||||
shlex.split(
|
||||
on_step_command_pre.replace(
|
||||
"{file}", event_step.run_events.gi_frame.f_code.co_filename,
|
||||
).replace(
|
||||
"{line}", str(event_step.run_events.gi_frame.f_lineno),
|
||||
)
|
||||
)
|
||||
)
|
||||
try:
|
||||
val = next(event_step.run_events, Ellipsis)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
if on_error is not None:
|
||||
on_error()
|
||||
if on_exit is not None:
|
||||
on_exit()
|
||||
return None
|
||||
|
||||
if on_step_command_post:
|
||||
if event_step.run_events.gi_frame is not None:
|
||||
import shlex
|
||||
import subprocess
|
||||
subprocess.call(
|
||||
shlex.split(
|
||||
on_step_command_post.replace(
|
||||
"{file}", event_step.run_events.gi_frame.f_code.co_filename,
|
||||
).replace(
|
||||
"{line}", str(event_step.run_events.gi_frame.f_lineno),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(val, EventGenerate) or val is None:
|
||||
return 0.0
|
||||
elif isinstance(val, datetime.timedelta):
|
||||
return val.total_seconds()
|
||||
elif val is Ellipsis:
|
||||
if on_exit is not None:
|
||||
on_exit()
|
||||
return None
|
||||
else:
|
||||
raise Exception(f"{val!r} of type {type(val)!r} not supported")
|
||||
|
||||
event_step.run_events = iter(event_iter)
|
||||
event_step._ticks = 0
|
||||
event_step._ticks_handling_break_consecutive = 0
|
||||
|
||||
# Persistent so this keeps working when tests load a blend file.
|
||||
bpy.app.timers.register(event_step, first_interval=0.0, persistent=True)
|
||||
|
||||
|
||||
def setup_default_preferences(preferences):
|
||||
""" Set preferences useful for automation.
|
||||
"""
|
||||
preferences.view.show_splash = False
|
||||
preferences.view.smooth_view = 0
|
||||
preferences.view.use_save_prompt = False
|
||||
preferences.filepaths.use_auto_save_temporary_files = False
|
||||
196
blender-5.2.0/tests/python/ui_simulate/modules/ui_test_utils.py
Normal file
196
blender-5.2.0/tests/python/ui_simulate/modules/ui_test_utils.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# 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",
|
||||
"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
|
||||
]
|
||||
202
blender-5.2.0/tests/python/ui_simulate/run.py
Executable file
202
blender-5.2.0/tests/python/ui_simulate/run.py
Executable file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2019-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Run interaction tests using event simulation.
|
||||
|
||||
Example usage from Blender's source dir:
|
||||
|
||||
This uses ``test_undo.py``, running the ``text_editor_simple`` function.
|
||||
|
||||
To run all tests:
|
||||
|
||||
./tests/python/ui_simulate/run.py --blender=blender.bin --tests '*'
|
||||
|
||||
For an editor to follow the tests:
|
||||
|
||||
./tests/python/ui_simulate/run.py --blender=blender.bin --tests '*' \
|
||||
--step-command-pre='gvim --remote-silent +{line} "{file}"'
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def create_parser():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--blender",
|
||||
dest="blender",
|
||||
required=True,
|
||||
metavar="BLENDER_COMMAND",
|
||||
help="Location of the blender command to run (when quoted, may include arguments).",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--tests",
|
||||
dest="tests",
|
||||
nargs='+',
|
||||
required=True,
|
||||
metavar="TEST_ID",
|
||||
help="Names of tests to run, use '*' to run all tests.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--jobs", "-j",
|
||||
dest="jobs",
|
||||
default=1,
|
||||
type=int,
|
||||
help="Number of tests (and instances of Blender) to run in parallel.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--keep-open",
|
||||
dest="keep_open",
|
||||
default=False,
|
||||
action='store_true',
|
||||
required=False,
|
||||
help="Keep the Blender window open after running the test.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--list-tests",
|
||||
dest="list_tests",
|
||||
default=False,
|
||||
action='store_true',
|
||||
required=False,
|
||||
help="Show a list of available TEST_ID.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--step-command-pre",
|
||||
dest="step_command_pre",
|
||||
required=False,
|
||||
metavar="STEP_COMMAND_PRE",
|
||||
help=(
|
||||
"Command to run that takes the test file and line as arguments. "
|
||||
"Literals {file} and {line} will be replaced with the file and line."
|
||||
"Called for every event."
|
||||
"Called for every event, allows an editor to track which commands run."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step-command-post",
|
||||
dest="step_command_post",
|
||||
required=False,
|
||||
metavar="STEP_COMMAND_POST",
|
||||
help=(
|
||||
"Command to run that takes the test file and line as arguments. "
|
||||
"Literals {file} and {line} will be replaced with the file and line."
|
||||
"Called for every event, allows an editor to track which commands run."
|
||||
)
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def all_test_ids(directory):
|
||||
from types import FunctionType
|
||||
for f in sorted(os.listdir(directory)):
|
||||
if f.startswith("test_") and f.endswith(".py"):
|
||||
mod = __import__(f[:-3])
|
||||
for k, v in sorted(vars(mod).items()):
|
||||
if not k.startswith("_") and isinstance(v, FunctionType):
|
||||
yield f.rpartition(".")[0] + "." + k
|
||||
|
||||
|
||||
def list_tests(directory):
|
||||
for test_id in all_test_ids(directory):
|
||||
print(test_id)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def _process_test_id_fn(env, args, test_id):
|
||||
import subprocess
|
||||
import shlex
|
||||
|
||||
directory = os.path.dirname(__file__)
|
||||
cmd = (
|
||||
*shlex.split(args.blender),
|
||||
"--enable-event-simulate",
|
||||
"--factory-startup",
|
||||
"--python", os.path.join(directory, "run_blender_setup.py"),
|
||||
"--",
|
||||
"--tests", test_id,
|
||||
*(("--keep-open",) if args.keep_open else ()),
|
||||
*(("--step-command-pre", args.step_command_pre) if args.step_command_pre else ()),
|
||||
*(("--step-command-post", args.step_command_post) if args.step_command_post else ()),
|
||||
)
|
||||
callproc = subprocess.run(cmd, env=env)
|
||||
return test_id, callproc.returncode == 0
|
||||
|
||||
|
||||
def run(empty_user_dir):
|
||||
directory = os.path.dirname(__file__)
|
||||
if "--list-tests" in sys.argv:
|
||||
list_tests(directory)
|
||||
sys.exit(0)
|
||||
|
||||
if "bpy" in sys.modules:
|
||||
raise Exception("Cannot run inside Blender")
|
||||
|
||||
parser = create_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
tests = args.tests
|
||||
|
||||
# Validate tests exist
|
||||
test_ids = list(all_test_ids(directory))
|
||||
if tests[0] == "*":
|
||||
tests = test_ids
|
||||
else:
|
||||
for test_id in tests:
|
||||
if test_id not in test_ids:
|
||||
print(test_id, "not found in", test_ids)
|
||||
return
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"LSAN_OPTIONS": "exitcode=0",
|
||||
"BLENDER_USER_RESOURCES": empty_user_dir,
|
||||
})
|
||||
|
||||
# We could support multiple tests per Blender session.
|
||||
results = []
|
||||
results_fail = 0
|
||||
if args.jobs <= 1:
|
||||
for test_id in tests:
|
||||
_, success = _process_test_id_fn(env, args, test_id)
|
||||
results.append((test_id, success))
|
||||
if not success:
|
||||
results_fail += 1
|
||||
else:
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
executor = ProcessPoolExecutor(max_workers=args.jobs)
|
||||
num_tests = len(tests)
|
||||
for test_id, success in executor.map(_process_test_id_fn, (env,) * num_tests, (args,) * num_tests, tests):
|
||||
results.append((test_id, success))
|
||||
if not success:
|
||||
results_fail += 1
|
||||
|
||||
print(len(results), "tests,", results_fail, "failed")
|
||||
for test_id, ok in results:
|
||||
print("OK: " if ok else "FAIL:", test_id)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as empty_user_dir:
|
||||
sys.exit(run(empty_user_dir))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
143
blender-5.2.0/tests/python/ui_simulate/run_blender_setup.py
Normal file
143
blender-5.2.0/tests/python/ui_simulate/run_blender_setup.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# SPDX-FileCopyrightText: 2019-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Utility script, called by ``run.py`` or ``blender_headless.py`` to run inside Blender,
|
||||
to avoid boilerplate code having to be added into each test.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def create_parser():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--keep-open",
|
||||
dest="keep_open",
|
||||
default=False,
|
||||
action='store_true',
|
||||
required=False,
|
||||
help="Keep the Blender window open after running the test.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--step-command-pre",
|
||||
dest="step_command_pre",
|
||||
default=None,
|
||||
required=False,
|
||||
help="See 'run.py'",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step-command-post",
|
||||
dest="step_command_post",
|
||||
default=None,
|
||||
required=False,
|
||||
help="See 'run.py'",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--tests",
|
||||
dest="tests",
|
||||
nargs='+',
|
||||
required=True,
|
||||
metavar="TEST_ID",
|
||||
help="Names of tests to run.",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
directory = os.path.dirname(__file__)
|
||||
sys.path.insert(0, directory)
|
||||
if "bpy" not in sys.modules:
|
||||
raise Exception("This must run inside Blender")
|
||||
import bpy
|
||||
import gpu
|
||||
|
||||
parser = create_parser()
|
||||
args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:])
|
||||
verbose = os.getenv('BLENDER_VERBOSE') is not None
|
||||
|
||||
# Check if `bpy.app.use_event_simulate` has been enabled by the test itself.
|
||||
# When writing tests, it's useful if the test can temporarily be set to keep the window open.
|
||||
|
||||
def on_error():
|
||||
if not bpy.app.use_event_simulate:
|
||||
args.keep_open = True
|
||||
|
||||
if not args.keep_open:
|
||||
sys.exit(1)
|
||||
|
||||
def on_exit():
|
||||
if not bpy.app.use_event_simulate:
|
||||
args.keep_open = True
|
||||
|
||||
if not args.keep_open:
|
||||
try:
|
||||
bpy.ops.wm.quit_blender()
|
||||
except RuntimeError:
|
||||
sys.exit(1)
|
||||
else:
|
||||
bpy.app.use_event_simulate = False
|
||||
|
||||
gpu_device = gpu.platform.device_type_get()
|
||||
gpu_backend = gpu.platform.backend_type_get()
|
||||
|
||||
BLOCKLIST = []
|
||||
if os.getenv("BLENDER_TEST_IGNORE_BLOCKLIST") is None and os.getenv("BLENDER_TEST_IGNORE_VENDOR_BLOCKLIST") is None:
|
||||
if sys.platform == "win32" and gpu_device == "INTEL" and gpu_backend == "OPENGL":
|
||||
# See #149084 for the tracking issue
|
||||
BLOCKLIST.append("test_workspace")
|
||||
if sys.platform == "win32" and gpu_device == "AMD" and gpu_backend == "VULKAN":
|
||||
# See #155536 for the tracking issue
|
||||
BLOCKLIST.append("test_render")
|
||||
if sys.platform == "win32" and gpu_device == "AMD":
|
||||
# See #155536 for the tracking issue
|
||||
BLOCKLIST.append("test_render.interactive_rendering_cycles")
|
||||
|
||||
is_first = True
|
||||
for test_id in args.tests:
|
||||
mod_name, fn_name = test_id.partition(".")[0::2]
|
||||
|
||||
if mod_name in BLOCKLIST or test_id in BLOCKLIST:
|
||||
if not args.keep_open:
|
||||
try:
|
||||
bpy.ops.wm.quit_blender()
|
||||
except RuntimeError:
|
||||
sys.exit(1)
|
||||
|
||||
if not is_first:
|
||||
bpy.ops.wm.read_homefile()
|
||||
is_first = False
|
||||
|
||||
mod = __import__(mod_name)
|
||||
test_fn = getattr(mod, fn_name)
|
||||
|
||||
from modules import easy_keys
|
||||
|
||||
# So we can get the operator ID's.
|
||||
bpy.context.preferences.view.show_developer_ui = True
|
||||
|
||||
# Hack back in operator search.
|
||||
|
||||
easy_keys.setup_default_preferences(bpy.context.preferences)
|
||||
easy_keys.run(
|
||||
test_fn(),
|
||||
on_error=on_error,
|
||||
on_exit=on_exit,
|
||||
# Optional.
|
||||
on_step_command_pre=args.step_command_pre,
|
||||
on_step_command_post=args.step_command_post,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
blender-5.2.0/tests/python/ui_simulate/test_bpy_types.py
Normal file
64
blender-5.2.0/tests/python/ui_simulate/test_bpy_types.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This file does not run anything, its methods are accessed for tests by ``run_blender_setup.py``.
|
||||
"""
|
||||
import modules.ui_test_utils as ui
|
||||
|
||||
|
||||
def _test_panel():
|
||||
from bpy.types import Panel
|
||||
|
||||
class TEST_PT_panel(Panel):
|
||||
bl_label = "Test Panel"
|
||||
bl_idname = "TEST_PT_panel"
|
||||
bl_category = 'Test Panel'
|
||||
bl_space_type = 'TEXT_EDITOR'
|
||||
bl_region_type = 'UI'
|
||||
|
||||
def draw(self, context):
|
||||
self.layout.operator("test.operator")
|
||||
|
||||
return TEST_PT_panel
|
||||
|
||||
|
||||
def _test_operator():
|
||||
from bpy.types import Operator
|
||||
|
||||
class TEST_PT_operator(Operator):
|
||||
bl_label = "Test Operator"
|
||||
bl_idname = "test.operator"
|
||||
bl_category = 'Test Operator'
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
return TEST_PT_operator
|
||||
|
||||
|
||||
def unregister_referenced_type():
|
||||
e, _t, _window = ui.test_window()
|
||||
|
||||
test_panel = _test_panel()
|
||||
test_operator = _test_operator()
|
||||
import bpy
|
||||
|
||||
bpy.utils.register_class(test_panel)
|
||||
bpy.utils.register_class(test_operator)
|
||||
|
||||
yield
|
||||
|
||||
# Show the popup with a 'test.operator' reference
|
||||
bpy.ops.wm.call_panel(name=test_panel.bl_idname, keep_open=True)
|
||||
|
||||
yield
|
||||
|
||||
bpy.utils.unregister_class(test_operator)
|
||||
|
||||
# Let popup be refreshed
|
||||
yield
|
||||
|
||||
# If the reference is not removed activating the button should crash
|
||||
yield e.ret()
|
||||
166
blender-5.2.0/tests/python/ui_simulate/test_fullscreen.py
Normal file
166
blender-5.2.0/tests/python/ui_simulate/test_fullscreen.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This file does not run anything, its methods are accessed for tests by ``run_blender_setup.py``.
|
||||
"""
|
||||
import modules.ui_test_utils as ui
|
||||
|
||||
|
||||
def wm_toggle_fullscreen():
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
# Pre-condition so tests make sense.
|
||||
t.assertNotEqual(len(window.screen.areas), 1, "Expected a window with more than one area")
|
||||
|
||||
yield from ui.call_operator(e, "Toggle Maximize Area")
|
||||
yield e.ret()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
|
||||
yield from ui.call_operator(e, "Toggle Maximize Area")
|
||||
yield e.ret()
|
||||
t.assertNotEqual(len(window.screen.areas), 1)
|
||||
|
||||
|
||||
# Checks that opening a temporary file browser exits correctly, as well as exiting a temporary file
|
||||
# browser on top of a maximized area.
|
||||
# See: 0a28bb1422
|
||||
def wm_toggle_stacked_fullscreen_file_browser():
|
||||
import bpy
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
# Pre-condition so tests make sense.
|
||||
t.assertNotEqual(len(window.screen.areas), 1, "Expected a window with more than one area")
|
||||
|
||||
# Ensure temporary file browsers will be opened in a maximized screen.
|
||||
bpy.context.preferences.view.filebrowser_display_type = 'SCREEN'
|
||||
|
||||
# Open temporary file browser.
|
||||
yield e.ctrl.o()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
|
||||
# Escape should leave the temporary file browser maximized screen.
|
||||
yield e.esc()
|
||||
t.assertNotEqual(len(window.screen.areas), 1)
|
||||
t.assertNotEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
|
||||
# Maximize a normal area.
|
||||
yield from ui.call_operator(e, "Toggle Maximize Area")
|
||||
yield e.ret()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertNotEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
|
||||
# Open a file browser in a stacked fullscreen.
|
||||
yield e.ctrl.o()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
|
||||
# Pressing 'Escape' should return to the previous full screen.
|
||||
yield e.esc()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertNotEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
restored_area = window.screen.areas[0]
|
||||
|
||||
# Pressing 'Escape' again shouldn't cause any change.
|
||||
yield e.esc()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertNotEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
t.assertEqual(restored_area, window.screen.areas[0])
|
||||
|
||||
# Restore to non-maximized area.
|
||||
yield from ui.call_operator(e, "Toggle Maximize Area")
|
||||
yield e.ret()
|
||||
t.assertNotEqual(len(window.screen.areas), 1)
|
||||
|
||||
|
||||
# Checks that stacking a temporary file browser on top of a temporary image editor exits correctly.
|
||||
# See: ef7fd50f8a, e61588c5a5 (second glitch mentioned there)
|
||||
def wm_toggle_stacked_fullscreens():
|
||||
import bpy
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
# Pre-condition so tests make sense.
|
||||
t.assertNotEqual(len(window.screen.areas), 1, "Expected a window with more than one area")
|
||||
|
||||
# Ensure temporary file browsers will be opened in a maximized screen.
|
||||
bpy.context.preferences.view.filebrowser_display_type = 'SCREEN'
|
||||
bpy.context.preferences.view.render_display_type = 'SCREEN'
|
||||
|
||||
initial_area = window.screen.areas[0]
|
||||
|
||||
# Open temporary image editor (would use F12, but better to not involve rendering in tests).
|
||||
yield e.f11()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'IMAGE_EDITOR')
|
||||
# Create a new image (wouldn't be needed with F12).
|
||||
yield e.alt.n()
|
||||
yield e.ret()
|
||||
yield e.ret()
|
||||
|
||||
# Save image to spawn a temporary file browser.
|
||||
yield e.alt.s()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
|
||||
# Cancel file browser, back to temporary image editor.
|
||||
yield e.esc()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'IMAGE_EDITOR')
|
||||
|
||||
# Cancel temporary image editor, back to normal screen.
|
||||
yield e.esc()
|
||||
t.assertNotEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0], initial_area)
|
||||
|
||||
# Similar test now but other way around: Open a temporary image editor from a temporary file
|
||||
# browser. See e61588c5a5 (second glitch mentioned there).
|
||||
|
||||
t.assertNotEqual(window.screen.areas[0].type, 'IMAGE_EDITOR')
|
||||
|
||||
yield e.ctrl.o()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
|
||||
yield e.f11()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'IMAGE_EDITOR')
|
||||
|
||||
# Cancel temporary image editor, back to temporary file browser.
|
||||
yield e.esc()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
|
||||
# Cancel temporary file browser too, back to normal screen.
|
||||
yield e.esc()
|
||||
t.assertNotEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0], initial_area)
|
||||
|
||||
|
||||
# See: e61588c5a5 (first glitch mentioned there)
|
||||
def wm_toggle_temporary_fullscreen_stacked_on_same_type():
|
||||
import bpy
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
# Pre-condition so tests make sense.
|
||||
t.assertNotEqual(len(window.screen.areas), 1, "Expected a window with more than one area")
|
||||
|
||||
# Ensure temporary file browsers will be opened in a maximized screen.
|
||||
bpy.context.preferences.view.filebrowser_display_type = 'SCREEN'
|
||||
bpy.context.preferences.view.render_display_type = 'SCREEN'
|
||||
|
||||
window.screen.areas[0].type = 'FILE_BROWSER'
|
||||
t.assertNotEqual(len(window.screen.areas), 1)
|
||||
|
||||
yield e.ctrl.o()
|
||||
t.assertEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
|
||||
# Cancel temporary file browser, check if we're still in a (now normal) file browser.
|
||||
yield e.esc()
|
||||
t.assertNotEqual(len(window.screen.areas), 1)
|
||||
t.assertEqual(window.screen.areas[0].type, 'FILE_BROWSER')
|
||||
84
blender-5.2.0/tests/python/ui_simulate/test_quick_effects.py
Normal file
84
blender-5.2.0/tests/python/ui_simulate/test_quick_effects.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This file does not run anything, its methods are accessed for tests by ``run_blender_setup.py``.
|
||||
|
||||
Does not thoroughly test the quick effects themselves (as in, testing that they apply the right
|
||||
settings), it just tests that adding them works and doesn't crash when done through the UI. This
|
||||
tends to break, indicating regressions elsewhere.
|
||||
"""
|
||||
import modules.ui_test_utils as ui
|
||||
|
||||
|
||||
def add_quick_fur():
|
||||
import bpy
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
object = bpy.context.object
|
||||
# Just a pre-condition
|
||||
t.assertIsNotNone(object)
|
||||
|
||||
yield from ui.call_operator(e, "Quick Fur")
|
||||
|
||||
curves_object = bpy.context.object
|
||||
t.assertIsNotNone(curves_object)
|
||||
t.assertEqual(curves_object.type, 'CURVES')
|
||||
# Multiple modifiers are added and reordered then. Just make sure there's at least one nodes
|
||||
# modifier.
|
||||
t.assertEqual(curves_object.modifiers[0].type, 'NODES')
|
||||
|
||||
|
||||
def add_quick_smoke():
|
||||
import bpy
|
||||
|
||||
if not bpy.app.build_options.fluid:
|
||||
print("Fluid is not enabled, skipping quick smoke test")
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
object = bpy.context.object
|
||||
# Just a pre-condition
|
||||
t.assertIsNotNone(object)
|
||||
|
||||
yield from ui.call_operator(e, "Quick Smoke")
|
||||
t.assertEqual(object.modifiers[0].type, 'FLUID')
|
||||
t.assertEqual(object.modifiers[0].fluid_type, 'FLOW')
|
||||
t.assertEqual(object.modifiers[0].flow_settings.flow_type, 'SMOKE')
|
||||
|
||||
|
||||
def add_quick_liquid():
|
||||
import bpy
|
||||
|
||||
if not bpy.app.build_options.fluid:
|
||||
print("Fluid is not enabled, skipping quick liquid test")
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
object = bpy.context.object
|
||||
# Just a pre-condition
|
||||
t.assertIsNotNone(object)
|
||||
|
||||
yield from ui.call_operator(e, "Quick Liquid")
|
||||
t.assertEqual(object.modifiers[0].type, 'FLUID')
|
||||
t.assertEqual(object.modifiers[0].flow_settings.flow_type, 'LIQUID')
|
||||
|
||||
|
||||
def add_quick_explode():
|
||||
import bpy
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
object = bpy.context.object
|
||||
# Just a pre-condition
|
||||
t.assertIsNotNone(object)
|
||||
|
||||
yield from ui.call_operator(e, "Quick Explode")
|
||||
t.assertEqual(object.modifiers[0].type, 'PARTICLE_SYSTEM')
|
||||
t.assertEqual(object.modifiers[1].type, 'EXPLODE')
|
||||
|
||||
# Just start and stop the animation.
|
||||
yield from ui.call_operator(e, "Play Animation")
|
||||
yield from ui.call_operator(e, "Play Animation")
|
||||
198
blender-5.2.0/tests/python/ui_simulate/test_render.py
Normal file
198
blender-5.2.0/tests/python/ui_simulate/test_render.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This file does not run anything, its methods are accessed for tests by ``run_blender_setup.py``.
|
||||
"""
|
||||
import modules.ui_test_utils as ui
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Viewport Rendering
|
||||
|
||||
|
||||
def _interactive_rendering(engine):
|
||||
import bpy
|
||||
e, _, window = ui.test_window()
|
||||
|
||||
rd = window.scene.render
|
||||
rd.engine = engine
|
||||
|
||||
# Set up shading workspace with material editor and rendered viewport
|
||||
window.workspace = bpy.data.workspaces.get("Shading")
|
||||
yield
|
||||
properties_area = ui.get_window_area_by_type(window, 'PROPERTIES')
|
||||
properties_area.spaces.active.context = 'MATERIAL'
|
||||
view3d_area = ui.get_window_area_by_type(window, 'VIEW_3D')
|
||||
view3d_area.spaces.active.shading.type = 'RENDERED'
|
||||
yield
|
||||
|
||||
# A few editing actions.
|
||||
def action_move():
|
||||
e.cursor_position_set(*ui.get_area_center(view3d_area), move=True)
|
||||
yield e.g().x().text("0.5").ret()
|
||||
|
||||
def action_add_cube():
|
||||
e.cursor_position_set(*ui.get_area_center(view3d_area), move=True)
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
yield
|
||||
|
||||
def action_delete():
|
||||
e.cursor_position_set(*ui.get_area_center(view3d_area), move=True)
|
||||
yield e.delete().ret()
|
||||
|
||||
def action_undo():
|
||||
yield e.ctrl.z()
|
||||
|
||||
def action_redo():
|
||||
yield e.ctrl.shift.z()
|
||||
|
||||
def action_add_node():
|
||||
ob = bpy.context.active_object
|
||||
tree = ob.active_material.node_tree
|
||||
tree.nodes.new(type="ShaderNodeMix")
|
||||
yield
|
||||
|
||||
def action_unassign_material():
|
||||
ob = bpy.context.active_object
|
||||
ob.data.materials.pop()
|
||||
yield
|
||||
|
||||
def action_assign_material():
|
||||
mat = bpy.data.materials.new(name="Test Material")
|
||||
ob = bpy.context.active_object
|
||||
ob.data.materials.append(mat)
|
||||
yield
|
||||
|
||||
action_sequence = [
|
||||
action_move,
|
||||
action_add_cube,
|
||||
action_undo,
|
||||
action_redo,
|
||||
action_assign_material,
|
||||
action_add_node,
|
||||
action_unassign_material,
|
||||
action_delete,
|
||||
]
|
||||
|
||||
# Run actions one by one, for each giving a bit of time for
|
||||
# the interactive render to do some work.
|
||||
for action in action_sequence:
|
||||
yield from action()
|
||||
yield from ui.idle_until(lambda: False, timeout=0.2)
|
||||
|
||||
|
||||
def interactive_rendering_cycles():
|
||||
yield from _interactive_rendering('CYCLES')
|
||||
|
||||
|
||||
def interactive_rendering_eevee():
|
||||
yield from _interactive_rendering('BLENDER_EEVEE')
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Animation Rendering and Player
|
||||
|
||||
|
||||
def _test_animation_player(t, window):
|
||||
# Launch animation player and verify it starts without crashing. It
|
||||
# doesn't support event simulation so not much we can test beyond that.
|
||||
import bpy
|
||||
import gpu
|
||||
import subprocess
|
||||
|
||||
scene = window.scene
|
||||
rd = scene.render
|
||||
frame_path = rd.frame_path(frame=scene.frame_start)
|
||||
frame_path = bpy.path.abspath(frame_path)
|
||||
|
||||
cmd = [
|
||||
bpy.app.binary_path,
|
||||
"--gpu-backend", gpu.platform.backend_type_get().lower(),
|
||||
"-a",
|
||||
"-f", str(rd.fps), str(rd.fps_base),
|
||||
"-s", str(scene.frame_start),
|
||||
"-e", str(scene.frame_end),
|
||||
"-j", str(scene.frame_step),
|
||||
frame_path,
|
||||
]
|
||||
|
||||
player_process = subprocess.Popen(cmd)
|
||||
|
||||
# Wait a moment to verify it starts without immediately crashing.
|
||||
yield from ui.idle_until(lambda: False, timeout=2.0)
|
||||
t.assertIsNone(player_process.poll(), "Animation player process exited unexpectedly")
|
||||
|
||||
# Terminate the player.
|
||||
player_process.terminate()
|
||||
try:
|
||||
player_process.wait(timeout=5.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
player_process.kill()
|
||||
|
||||
|
||||
def _animation_rendering_and_player(temp_dir):
|
||||
import bpy
|
||||
from pathlib import Path
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
# Change engine to Cycles and make it render quick.
|
||||
scene = window.scene
|
||||
rd = scene.render
|
||||
rd.engine = 'CYCLES'
|
||||
scene.cycles.samples = 1
|
||||
scene.cycles.use_denoising = False
|
||||
rd.resolution_x = 128
|
||||
rd.resolution_y = 128
|
||||
scene.frame_start = 1
|
||||
scene.frame_end = 20
|
||||
|
||||
# Keyframe cube in two different locations.
|
||||
cube = bpy.data.objects.get("Cube")
|
||||
scene.frame_set(1)
|
||||
cube.location = (0, 0, 0)
|
||||
cube.keyframe_insert(data_path="location", frame=1)
|
||||
scene.frame_set(20)
|
||||
cube.location = (5, 5, 5)
|
||||
cube.keyframe_insert(data_path="location", frame=20)
|
||||
yield
|
||||
|
||||
# Render animation and cancel after a few frames.
|
||||
rd.filepath = str(Path(temp_dir) / "final_")
|
||||
start_path = Path(bpy.path.abspath(rd.frame_path(frame=scene.frame_start)))
|
||||
|
||||
bpy.ops.render.render('INVOKE_DEFAULT', animation=True)
|
||||
yield
|
||||
yield from ui.idle_until(
|
||||
lambda: start_path.exists() and scene.frame_current > 2,
|
||||
timeout=20.0)
|
||||
yield e.esc()
|
||||
yield from ui.idle_until(
|
||||
lambda: not bpy.app.is_job_running('RENDER'),
|
||||
timeout=20.0)
|
||||
|
||||
t.assertTrue(start_path.exists(), "Start frame was not rendered")
|
||||
|
||||
# In 3D viewport, render complete playblast.
|
||||
rd.filepath = str(Path(temp_dir) / "playblast_")
|
||||
start_path = Path(bpy.path.abspath(rd.frame_path(frame=scene.frame_start)))
|
||||
end_path = Path(bpy.path.abspath(rd.frame_path(frame=scene.frame_end)))
|
||||
|
||||
view3d_area = ui.get_window_area_by_type(window, 'VIEW_3D')
|
||||
e.cursor_position_set(*ui.get_area_center(view3d_area), move=True)
|
||||
yield from ui.call_menu(e, "Render Playblast")
|
||||
yield from ui.idle_until(
|
||||
lambda: end_path.exists() and not bpy.app.is_job_running('RENDER'),
|
||||
timeout=20.0)
|
||||
|
||||
t.assertTrue(start_path.exists(), "Start frame was not rendered")
|
||||
t.assertTrue(end_path.exists(), "End frame was not rendered")
|
||||
|
||||
# Test animation player.
|
||||
yield from _test_animation_player(t, window)
|
||||
yield
|
||||
|
||||
|
||||
def animation_rendering_and_player():
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory(prefix="blender_test_render_") as temp_dir:
|
||||
yield from _animation_rendering_and_player(temp_dir)
|
||||
349
blender-5.2.0/tests/python/ui_simulate/test_sculpt.py
Normal file
349
blender-5.2.0/tests/python/ui_simulate/test_sculpt.py
Normal file
@@ -0,0 +1,349 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This file does not run anything, its methods are accessed for tests by ``run_blender_setup.py``.
|
||||
"""
|
||||
import modules.ui_test_utils as ui
|
||||
|
||||
|
||||
def asset_shelf_brush_selection():
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
yield e.shift.f5() # 3D Viewport.
|
||||
yield e.ctrl.alt.space() # Full-screen.
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
|
||||
area = ui.get_window_area_by_type(window, 'VIEW_3D')
|
||||
# We use this hardcoded area percent position because the asset shelf is very large, this centers the cursor
|
||||
# in the correct position.
|
||||
position = (area.x + int(area.width * 0.30), area.y + area.height // 2)
|
||||
e.cursor_position_set(*position, move=True) # Move mouse
|
||||
yield
|
||||
|
||||
yield e.shift.space() # Asset Shelf
|
||||
yield e.text("Blob") # Search for "Blob"
|
||||
yield e.esc()
|
||||
|
||||
# We repeat this again because the asset shelf is too large to fit on the screen the first time...
|
||||
yield e.shift.space() # Asset Shelf
|
||||
|
||||
e.leftmouse.tap()
|
||||
yield
|
||||
|
||||
import bpy
|
||||
current_brush = bpy.context.tool_settings.sculpt.brush
|
||||
t.assertEqual(current_brush.name, "Blob")
|
||||
|
||||
|
||||
def _view3d_startup_area_maximized(e):
|
||||
"""
|
||||
Set the 3D viewport and set the area full-screen so no other regions.
|
||||
"""
|
||||
yield e.shift.f5() # 3D Viewport.
|
||||
yield e.ctrl.alt.space() # Full-screen.
|
||||
yield e.a() # Select all.
|
||||
yield e.delete().ret() # Delete all.
|
||||
|
||||
|
||||
def _reset_objects(e):
|
||||
yield e.ctrl.tab().o() # Object Mode
|
||||
yield e.a() # Select all.
|
||||
yield e.delete().ret() # Delete all.
|
||||
|
||||
|
||||
def _subdivide_mesh(e, times):
|
||||
yield e.tab() # Enter Edit mode.
|
||||
for i in range(times):
|
||||
yield e.ctrl.e().d() # Subdivide.
|
||||
yield e.tab() # Leave Edit mode.
|
||||
|
||||
|
||||
def _create_test_monkey(e):
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Monkey")
|
||||
yield e.numpad_period() # View monkey
|
||||
|
||||
yield from _subdivide_mesh(e, 3)
|
||||
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
|
||||
|
||||
def _num_matching_face_set(face_set_id):
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
mesh = bpy.context.object.data
|
||||
|
||||
if not mesh.attributes.get('.sculpt_face_set'):
|
||||
return 0
|
||||
|
||||
face_set_attr = mesh.attributes['.sculpt_face_set']
|
||||
|
||||
num_faces = mesh.attributes.domain_size('FACE')
|
||||
|
||||
face_set_data = np.zeros(num_faces, dtype=np.int32)
|
||||
face_set_attr.data.foreach_get('value', face_set_data)
|
||||
|
||||
return np.count_nonzero(face_set_data == face_set_id)
|
||||
|
||||
|
||||
def face_set_expand():
|
||||
import bpy
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from _create_test_monkey(e)
|
||||
|
||||
area = ui.get_window_area_by_type(window, 'VIEW_3D')
|
||||
position = (area.x + area.width // 2, area.y + area.height // 2)
|
||||
yield e.cursor_position_set(*position, move=True) # Move mouse to center
|
||||
|
||||
yield e.shift.w() # Expand operator
|
||||
|
||||
yield from _move_horizontal(e, position, 200)
|
||||
e.leftmouse.tap()
|
||||
yield
|
||||
|
||||
non_default_faces = _num_matching_face_set(2)
|
||||
t.assertEqual(non_default_faces, 8682)
|
||||
|
||||
default_faces = _num_matching_face_set(1)
|
||||
mesh = bpy.context.object.data
|
||||
num_faces = mesh.attributes.domain_size('FACE')
|
||||
t.assertEqual(default_faces + non_default_faces, num_faces)
|
||||
|
||||
|
||||
def face_set_gestures():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
# Box Face Set
|
||||
yield from _create_test_monkey(e)
|
||||
yield from ui.call_operator(e, "Box Face Set")
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_xy(window))
|
||||
t.assertEqual(_num_matching_face_set(2), 29816)
|
||||
yield from _reset_objects(e)
|
||||
|
||||
# Lasso Face Set
|
||||
yield from _create_test_monkey(e)
|
||||
t.assertEqual(_num_fully_masked_vertices(), 0)
|
||||
yield from ui.call_operator(e, "Lasso Face Set")
|
||||
center = ui.get_area_center_from_spacetype(window, 'VIEW_3D')
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_circle(center, 100))
|
||||
t.assertEqual(_num_matching_face_set(2), 8749)
|
||||
yield from _reset_objects(e)
|
||||
|
||||
# Line Face Set
|
||||
yield from _create_test_monkey(e)
|
||||
yield from ui.call_operator(e, "Line Face Set")
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
t.assertEqual(_num_matching_face_set(2), 6795)
|
||||
|
||||
|
||||
def _num_hidden_vertices():
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
mesh = bpy.context.object.data
|
||||
|
||||
if not mesh.attributes.get('.hide_vert'):
|
||||
return 0
|
||||
|
||||
hide_attr = mesh.attributes['.hide_vert']
|
||||
|
||||
num_vertices = mesh.attributes.domain_size('POINT')
|
||||
|
||||
hide_data = np.zeros(num_vertices, dtype=np.bool)
|
||||
hide_attr.data.foreach_get('value', hide_data)
|
||||
|
||||
return np.count_nonzero(hide_data)
|
||||
|
||||
|
||||
def hide_gestures():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
# Box Hide
|
||||
yield from _create_test_monkey(e)
|
||||
t.assertEqual(_num_hidden_vertices(), 0)
|
||||
yield from ui.call_operator(e, "Box Hide")
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_xy(window))
|
||||
t.assertEqual(_num_hidden_vertices(), 29029)
|
||||
yield from _reset_objects(e)
|
||||
|
||||
# Lasso Hide
|
||||
yield from _create_test_monkey(e)
|
||||
t.assertEqual(_num_hidden_vertices(), 0)
|
||||
yield from ui.call_operator(e, "Lasso Hide")
|
||||
center = ui.get_area_center_from_spacetype(window, 'VIEW_3D')
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_circle(center, 100))
|
||||
t.assertEqual(_num_hidden_vertices(), 8548)
|
||||
yield from _reset_objects(e)
|
||||
|
||||
# Line Hide
|
||||
yield from _create_test_monkey(e)
|
||||
t.assertEqual(_num_hidden_vertices(), 0)
|
||||
yield from ui.call_operator(e, "Line Hide")
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
t.assertEqual(_num_hidden_vertices(), 6633)
|
||||
|
||||
|
||||
def _move_horizontal(e, start_position, pixels):
|
||||
for x in range(pixels // 10):
|
||||
position = (start_position[0] + x * 10, start_position[1])
|
||||
yield e.cursor_position_set(*position, move=True)
|
||||
|
||||
|
||||
def _num_fully_masked_vertices():
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
mesh = bpy.context.object.data
|
||||
|
||||
if not mesh.attributes.get('.sculpt_mask'):
|
||||
return 0
|
||||
|
||||
mask_attr = mesh.attributes['.sculpt_mask']
|
||||
|
||||
num_vertices = mesh.attributes.domain_size('POINT')
|
||||
|
||||
mask_data = np.zeros(num_vertices, dtype=np.float32)
|
||||
mask_attr.data.foreach_get('value', mask_data)
|
||||
|
||||
return np.count_nonzero(mask_data == 1.0)
|
||||
|
||||
|
||||
def mask_expand_and_invert():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from _create_test_monkey(e)
|
||||
|
||||
area = ui.get_window_area_by_type(window, 'VIEW_3D')
|
||||
position = (area.x + area.width // 2, area.y + area.height // 2)
|
||||
yield e.cursor_position_set(*position, move=True) # Move mouse to center
|
||||
|
||||
yield e.shift.a() # Expand operator
|
||||
|
||||
yield from _move_horizontal(e, position, 200)
|
||||
e.leftmouse.tap()
|
||||
yield
|
||||
|
||||
initial_masked_verts = _num_fully_masked_vertices()
|
||||
t.assertEqual(initial_masked_verts, 8548)
|
||||
|
||||
yield e.a() # Mask pie menu
|
||||
yield e.i() # Invert
|
||||
|
||||
inverted_masked_verts = _num_fully_masked_vertices()
|
||||
t.assertEqual(inverted_masked_verts, 22598)
|
||||
|
||||
import bpy
|
||||
mesh = bpy.context.object.data
|
||||
total_verts = mesh.attributes.domain_size('POINT')
|
||||
t.assertEqual(initial_masked_verts + inverted_masked_verts, total_verts)
|
||||
|
||||
|
||||
def mask_gestures():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
# Box Mask
|
||||
yield from _create_test_monkey(e)
|
||||
t.assertEqual(_num_fully_masked_vertices(), 0)
|
||||
yield from ui.call_operator(e, "Box Mask")
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_xy(window))
|
||||
t.assertEqual(_num_fully_masked_vertices(), 29029)
|
||||
yield from _reset_objects(e)
|
||||
|
||||
# Lasso Mask
|
||||
yield from _create_test_monkey(e)
|
||||
t.assertEqual(_num_fully_masked_vertices(), 0)
|
||||
yield from ui.call_operator(e, "Lasso Mask")
|
||||
center = ui.get_area_center_from_spacetype(window, 'VIEW_3D')
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_circle(center, 100))
|
||||
t.assertEqual(_num_fully_masked_vertices(), 8548)
|
||||
yield from _reset_objects(e)
|
||||
|
||||
# Line Mask
|
||||
yield from _create_test_monkey(e)
|
||||
t.assertEqual(_num_fully_masked_vertices(), 0)
|
||||
yield from ui.call_operator(e, "Line Mask")
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
t.assertEqual(_num_fully_masked_vertices(), 6633)
|
||||
|
||||
|
||||
def _create_test_cube(e):
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Cube")
|
||||
yield e.numpad_period() # View Cube
|
||||
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
|
||||
|
||||
def trim_gestures():
|
||||
import bpy
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
# Box Trim
|
||||
yield from _create_test_cube(e)
|
||||
mesh = bpy.context.object.data
|
||||
t.assertEqual(mesh.attributes.domain_size('POINT'), 8)
|
||||
yield from ui.call_operator(e, "Box Trim")
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_xy(window))
|
||||
t.assertEqual(mesh.attributes.domain_size('POINT'), 28)
|
||||
yield from _reset_objects(e)
|
||||
|
||||
# Lasso Trim
|
||||
yield from _create_test_cube(e)
|
||||
mesh = bpy.context.object.data
|
||||
t.assertEqual(mesh.attributes.domain_size('POINT'), 8)
|
||||
yield from ui.call_operator(e, "Lasso Trim")
|
||||
center = ui.get_area_center_from_spacetype(window, 'VIEW_3D')
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_circle(center, 100))
|
||||
t.assertEqual(mesh.attributes.domain_size('POINT'), 88)
|
||||
yield from _reset_objects(e)
|
||||
|
||||
# Line Trim
|
||||
yield from _create_test_cube(e)
|
||||
mesh = bpy.context.object.data
|
||||
t.assertEqual(mesh.attributes.domain_size('POINT'), 8)
|
||||
yield from ui.call_operator(e, "Line Trim")
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
t.assertEqual(mesh.attributes.domain_size('POINT'), 10)
|
||||
|
||||
|
||||
def primitive_tool_add():
|
||||
import bpy
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
|
||||
yield from ui.call_menu(e, "Sculpt -> Add Primitive -> Add Cube") # Select add cube tool
|
||||
|
||||
area = ui.get_window_area_by_type(window, 'VIEW_3D')
|
||||
position = (area.x + area.width // 2, area.y + area.height // 2)
|
||||
yield e.cursor_position_set(*position, move=True) # Move mouse to center
|
||||
|
||||
e.leftmouse.press()
|
||||
yield
|
||||
|
||||
pixels = 10
|
||||
for delta in range(pixels):
|
||||
position = (position[0] + delta, position[1] + delta)
|
||||
yield e.cursor_position_set(*position, move=True)
|
||||
|
||||
e.leftmouse.release()
|
||||
yield
|
||||
|
||||
for delta in range(pixels):
|
||||
position = (position[0] - delta, position[1] - delta)
|
||||
yield e.cursor_position_set(*position, move=True)
|
||||
|
||||
e.leftmouse.tap()
|
||||
yield
|
||||
|
||||
mesh = bpy.context.object.data
|
||||
num_faces = mesh.attributes.domain_size('FACE')
|
||||
t.assertEqual(num_faces, 12) # There should be 6 + 6 faces
|
||||
125
blender-5.2.0/tests/python/ui_simulate/test_tools.py
Normal file
125
blender-5.2.0/tests/python/ui_simulate/test_tools.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This file does not run anything, its methods are accessed for tests by ``run_blender_setup.py``.
|
||||
"""
|
||||
import modules.ui_test_utils as ui
|
||||
|
||||
|
||||
def sculpt_mode_toolbar():
|
||||
import sys
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
# In the default properties area, set it to the tool tab to force access of all
|
||||
# tool properties when a tool is activated.
|
||||
properties_area = ui.get_window_area_by_type(window, 'PROPERTIES')
|
||||
properties_area.spaces[0].context = 'TOOL'
|
||||
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
|
||||
area = ui.get_window_area_by_type(window, 'VIEW_3D')
|
||||
position = (area.x + int(area.width * 0.05), area.y + area.height // 2)
|
||||
e.cursor_position_set(*position, move=True) # Move mouse over the toolbar
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.one()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.brush")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.two()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin_brush.paint")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.three()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin_brush.mask")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.four()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin_brush.draw_face_sets")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.five()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.primitive_cube_add")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.six()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.primitive_cone_add")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.seven()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.primitive_cylinder_add")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.eight()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.primitive_uv_sphere_add")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.nine()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.primitive_ico_sphere_add")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.b()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.box_mask")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.shift.three()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.box_hide")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.shift.seven()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.box_face_set")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.ctrl.one()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.box_trim")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.ctrl.five()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.line_project")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.ctrl.six()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.mesh_filter")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.ctrl.seven()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.cloth_filter")
|
||||
|
||||
yield e.shift.space()
|
||||
if sys.platform == "darwin":
|
||||
# Assigning a keymap entry to Ctrl on MacOS also assigns it to Command. In most cases, either
|
||||
# keybind is accepted. However, the toolbar specifically responds to Command, not Ctrl
|
||||
yield e.oskey.x()
|
||||
else:
|
||||
yield e.ctrl.x()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.color_filter")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.ctrl.w()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.face_set_edit")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.ctrl.eight()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.mask_by_color")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.ctrl.nine()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.move")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.ctrl.zero()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.rotate")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.alt.one()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.scale")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.t()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.transform")
|
||||
|
||||
yield e.shift.space()
|
||||
yield e.d()
|
||||
t.assertEqual(window.workspace.tools.from_space_view3d_mode('SCULPT').idname, "builtin.annotate")
|
||||
986
blender-5.2.0/tests/python/ui_simulate/test_undo.py
Normal file
986
blender-5.2.0/tests/python/ui_simulate/test_undo.py
Normal file
@@ -0,0 +1,986 @@
|
||||
# SPDX-FileCopyrightText: 2019-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This file does not run anything, its methods are accessed for tests by ``run_blender_setup.py``.
|
||||
"""
|
||||
import modules.ui_test_utils as ui
|
||||
|
||||
# FIXME: Since 2.8 or so, there is a problem with simulated events
|
||||
# where a popup needs the main-loop to cycle once before new events
|
||||
# are handled. This isn't great but seems not to be a problem for users?
|
||||
_MENU_CONFIRM_HACK = True
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utilities
|
||||
|
||||
|
||||
def _view3d_object_calc_screen_space_location(window, name: str):
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
|
||||
area = ui.get_window_area_by_type(window, 'VIEW_3D')
|
||||
region = next((region for region in area.regions if region.type == 'WINDOW'))
|
||||
rv3d = region.data
|
||||
|
||||
ob = window.view_layer.objects[name]
|
||||
co = location_3d_to_region_2d(region, rv3d, ob.matrix_world.translation)
|
||||
return int(co[0]), int(co[1])
|
||||
|
||||
|
||||
def _view3d_object_select_by_name(e, name: str):
|
||||
location = _view3d_object_calc_screen_space_location(e.window, name)
|
||||
e.cursor_position_set(*location, move=True)
|
||||
# e.shift.rightmouse.tap() # Set the cursor so it's possible to see what was selected.
|
||||
yield
|
||||
e.ctrl.leftmouse.tap()
|
||||
yield
|
||||
|
||||
|
||||
def _setup_window_areas_from_ui_types(e, ui_types):
|
||||
assert len(e.window.screen.areas) == 1
|
||||
total_areas = len(ui_types)
|
||||
i = 0
|
||||
while len(e.window.screen.areas) < total_areas:
|
||||
areas = list(e.window.screen.areas)
|
||||
for area in areas:
|
||||
event_xy = ui.get_area_center(area)
|
||||
e.cursor_position_set(x=event_xy[0], y=event_xy[1], move=True)
|
||||
# areas_len_prev = len(e.window.screen.areas)
|
||||
if (i % 2) == 0:
|
||||
yield from ui.call_menu(e, "View -> Area -> Horizontal Split")
|
||||
else:
|
||||
yield from ui.call_menu(e, "View -> Area -> Vertical Split")
|
||||
e.leftmouse.tap()
|
||||
yield
|
||||
# areas_len_curr = len(e.window.screen.areas)
|
||||
# assert areas_len_curr != areas_len_prev
|
||||
if len(e.window.screen.areas) >= total_areas:
|
||||
break
|
||||
i += 1
|
||||
|
||||
# Use direct assignment, it's possible to use shortcuts for most area types, it's tedious.
|
||||
for ty, area in zip(ui_types, e.window.screen.areas, strict=True):
|
||||
area.ui_type = ty
|
||||
yield
|
||||
|
||||
|
||||
def _print_undo_steps_and_line():
|
||||
"""
|
||||
Keep even when unused, handy for tracking down problems.
|
||||
"""
|
||||
from inspect import currentframe
|
||||
cf = currentframe()
|
||||
line = cf.f_back.f_lineno
|
||||
|
||||
import bpy
|
||||
wm = bpy.data.window_managers[0]
|
||||
print(__file__ + ":" + str(line))
|
||||
wm.print_undo_steps()
|
||||
|
||||
|
||||
def _bmesh_from_object(ob):
|
||||
import bmesh
|
||||
return bmesh.from_edit_mesh(ob.data)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Text Editor
|
||||
|
||||
def _text_editor_startup(e):
|
||||
yield e.shift.f11() # Text editor.
|
||||
yield e.ctrl.alt.space() # Full-screen.
|
||||
yield e.alt.n() # New text.
|
||||
|
||||
|
||||
def _text_editor_and_3dview_startup(e, window):
|
||||
# Add text block in properties editors.
|
||||
pos_text = ui.get_area_center_from_spacetype(window, 'PROPERTIES')
|
||||
e.cursor_position_set(*pos_text, move=True)
|
||||
yield e.shift.f11() # Text editor.
|
||||
yield e.alt.n() # New text.
|
||||
|
||||
|
||||
def text_editor_simple():
|
||||
e, t, _ = ui.test_window()
|
||||
|
||||
import bpy
|
||||
yield from _text_editor_startup(e)
|
||||
text = bpy.data.texts[0]
|
||||
|
||||
yield e.text("Hello\nWorld")
|
||||
t.assertEqual(text.as_string(), "Hello\nWorld")
|
||||
yield e.shift.home().ctrl.x().back_space()
|
||||
yield e.home().ctrl.v().ret()
|
||||
t.assertEqual(text.as_string(), "World\nHello")
|
||||
yield e.ctrl.a().tab()
|
||||
t.assertEqual(text.as_string(), " World\n Hello")
|
||||
yield e.ctrl.z(5)
|
||||
t.assertEqual(text.as_string(), "Hello\nWorld")
|
||||
|
||||
|
||||
def text_editor_edit_mode_mix():
|
||||
# Ensure text edits and mesh edits can co-exist properly (see: T66658).
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
import bpy
|
||||
yield from _text_editor_and_3dview_startup(e, window)
|
||||
text = bpy.data.texts[0]
|
||||
|
||||
pos_text = ui.get_area_center_from_spacetype(window, 'TEXT_EDITOR')
|
||||
pos_v3d = ui.get_area_center_from_spacetype(window, 'VIEW_3D')
|
||||
|
||||
# View 3D: edit-mode
|
||||
e.cursor_position_set(*pos_v3d, move=True)
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Cube")
|
||||
|
||||
yield e.numpad_period() # View all.
|
||||
yield e.tab() # Edit mode.
|
||||
yield e.a() # Select all.
|
||||
|
||||
# Text: add text 'AA'.
|
||||
e.cursor_position_set(*pos_text, move=True)
|
||||
yield e.text("AA")
|
||||
t.assertEqual(text.as_string(), "AA")
|
||||
|
||||
# View 3D: duplicate & move.
|
||||
e.cursor_position_set(*pos_v3d, move=True)
|
||||
yield e.shift.d().x().text("3").ret()
|
||||
yield e.g().z().text("1").ret()
|
||||
t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 2)
|
||||
e.home()
|
||||
|
||||
# Text: add text 'BB'
|
||||
e.cursor_position_set(*pos_text, move=True)
|
||||
yield e.text("BB")
|
||||
t.assertEqual(text.as_string(), "AABB")
|
||||
|
||||
# View 3D: duplicate & move.
|
||||
e.cursor_position_set(*pos_v3d, move=True)
|
||||
yield e.shift.d().x().text("3").ret()
|
||||
yield e.g().z().text("1").ret()
|
||||
e.home()
|
||||
t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 3)
|
||||
|
||||
# Text: add text 'CC'
|
||||
e.cursor_position_set(*pos_text, move=True)
|
||||
yield e.text("CC")
|
||||
t.assertEqual(text.as_string(), "AABBCC")
|
||||
|
||||
# View 3D: duplicate & move.
|
||||
e.cursor_position_set(*pos_v3d, move=True)
|
||||
yield e.shift.d().x().text("3").ret()
|
||||
yield e.g().z().text("1").ret()
|
||||
e.home()
|
||||
t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 4)
|
||||
|
||||
# Undo and check the state is valid.
|
||||
yield e.ctrl.z(4)
|
||||
t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 3)
|
||||
t.assertEqual(text.as_string(), "AABB")
|
||||
|
||||
yield e.ctrl.z(4)
|
||||
t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 2)
|
||||
t.assertEqual(text.as_string(), "AA")
|
||||
|
||||
yield e.ctrl.z(4)
|
||||
t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8)
|
||||
t.assertEqual(text.as_string(), "")
|
||||
|
||||
# Finally redo all.
|
||||
yield e.ctrl.shift.z(4 * 3)
|
||||
t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 4)
|
||||
t.assertEqual(text.as_string(), "AABBCC")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Node Editor
|
||||
|
||||
|
||||
def _compositor_startup_area(e):
|
||||
"""
|
||||
Set up the compositor node editor
|
||||
"""
|
||||
yield e.shift.f3(2) # Compositor
|
||||
# yield e.ctrl.alt.space() # Full-screen.
|
||||
|
||||
|
||||
def compositor_make_group():
|
||||
import bpy
|
||||
e, t, window = ui.test_window()
|
||||
yield from _compositor_startup_area(e)
|
||||
|
||||
# Create a node tree with multiple nodes and select all nodes.
|
||||
# TODO: Node tree should be created through the UI
|
||||
node_group = bpy.data.node_groups.new(name="comp ntree", type="CompositorNodeTree")
|
||||
window.scene.compositing_node_group = node_group
|
||||
yield from ui.call_menu(e, "Add -> Color -> Alpha Convert")
|
||||
yield e.ret() # Confirm adding node.
|
||||
yield from ui.call_menu(e, "Add -> Filter -> Filter")
|
||||
yield e.ret()
|
||||
yield e.a() # Select all.
|
||||
t.assertEqual(len(window.scene.compositing_node_group.nodes), 2)
|
||||
yield e.ctrl.g() # Make group.
|
||||
t.assertEqual(len(window.scene.compositing_node_group.nodes), 1)
|
||||
yield e.ctrl.z()
|
||||
t.assertEqual(len(window.scene.compositing_node_group.nodes), 2)
|
||||
yield e.ctrl.z(5) # Revert to original state
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3D View
|
||||
|
||||
def _view3d_startup_area_maximized(e):
|
||||
"""
|
||||
Set the 3D viewport and set the area full-screen so no other regions.
|
||||
"""
|
||||
yield e.shift.f5() # 3D Viewport.
|
||||
yield e.ctrl.alt.space() # Full-screen.
|
||||
yield e.a() # Select all.
|
||||
yield e.delete().ret() # Delete all.
|
||||
|
||||
|
||||
def _view3d_startup_area_single(e):
|
||||
"""
|
||||
Create a single area (not full screen)
|
||||
this has the advantage that the window can be duplicated (not the case with a full-screened area).
|
||||
"""
|
||||
yield e.shift.f5() # 3D Viewport.
|
||||
yield e.a() # Select all.
|
||||
yield e.delete().ret() # Delete all.
|
||||
|
||||
for _ in range(len(e.window.screen.areas)):
|
||||
# 3D Viewport.
|
||||
event_xy = ui.get_area_center_from_spacetype(e.window, e.window.screen.areas[0].type)
|
||||
e.cursor_position_set(x=event_xy[0], y=event_xy[1], move=True)
|
||||
yield e.shift.f5()
|
||||
yield from ui.call_menu(e, "View -> Area -> Close Area")
|
||||
assert len(e.window.screen.areas) == 1
|
||||
|
||||
|
||||
def view3d_simple():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
# NOTE: it should be possible to consider "Add -> Mesh -> Plane" an exact match.
|
||||
# However, shortcuts are now included so without them this ends up fuzzy-matching to "Add -> Image -> Mesh Plane".
|
||||
# To resolve that it's necessary to match the entire shortcut which... changes based on the platform (sign!).
|
||||
use_menu_search_workaround = True
|
||||
if use_menu_search_workaround:
|
||||
import sys
|
||||
yield from ui.call_menu(e, "Add ({:s} A) -> Mesh -> Plane".format(
|
||||
"\u21e7" if sys.platform == "darwin" else "Shift"
|
||||
))
|
||||
del sys
|
||||
else:
|
||||
# It would be nice if this could be restored.
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Plane")
|
||||
|
||||
# Duplicate and rotate.
|
||||
for _ in range(3):
|
||||
yield e.shift.d().x().text("3").ret()
|
||||
yield e.r.z().text("15").ret()
|
||||
t.assertEqual(len(window.view_layer.objects), 4)
|
||||
yield e.a() # Select all.
|
||||
yield e.numpad_7().numpad_period() # View top.
|
||||
yield e.ctrl.j() # Join.
|
||||
t.assertEqual(len(window.view_layer.objects), 1)
|
||||
yield e.tab() # Edit mode.
|
||||
yield from ui.call_menu(e, "Edge -> Subdivide")
|
||||
yield e.tab() # Object mode.
|
||||
t.assertEqual(len(window.view_layer.objects.active.data.polygons), 16)
|
||||
yield e.ctrl.z(12) # Undo until start.
|
||||
t.assertEqual(len(window.view_layer.objects), 0)
|
||||
yield e.ctrl.shift.z(12) # Redo until end.
|
||||
t.assertEqual(len(window.view_layer.objects.active.data.polygons), 16)
|
||||
|
||||
|
||||
def view3d_sculpt_with_memfile_step():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Torus")
|
||||
|
||||
# Note: this could also be replaced by adding the multires modifier (see comment below).
|
||||
yield e.tab() # Enter Edit mode.
|
||||
yield e.ctrl.e().d() # Subdivide.
|
||||
yield e.ctrl.e().d() # Subdivide.
|
||||
yield e.tab() # Leave Edit mode.
|
||||
|
||||
yield e.numpad_period() # View all.
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
|
||||
# Add a 'memfile' undo step without leaving Sculpt mode.
|
||||
yield e.f3().text("add const").ret().d() # Add 'Limit Distance' constraint.
|
||||
# Note: Multires modifier exhibits even more issues with undo/redo in sculpt mode, but unfortunately geometry is not
|
||||
# available from python anymore while in sculpt mode, so we cannot test/check if undo/redo steps apply properly.
|
||||
# yield e.ctrl.two() # Add multires modifier.
|
||||
|
||||
# Utility to extract current mesh coordinates (used to ensure undo/redo steps are applied properly).
|
||||
def extract_mesh_cos(window):
|
||||
# TODO: Find/add a way to get that info when there is a multires active in Sculpt mode.
|
||||
window.view_layer.update()
|
||||
tmp_mesh = window.view_layer.objects.active.to_mesh(preserve_all_data_layers=True)
|
||||
tmp_cos = [0.0] * len(tmp_mesh.vertices) * 3
|
||||
tmp_mesh.vertices.foreach_get("co", tmp_cos)
|
||||
window.view_layer.objects.active.to_mesh_clear()
|
||||
return tmp_cos
|
||||
|
||||
mesh_verts_cos_before_sculpt = extract_mesh_cos(window)
|
||||
|
||||
# Add a first sculpt stroke.
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
mesh_verts_cos_sculpt_stroke1 = extract_mesh_cos(window)
|
||||
t.assertNotEqual(mesh_verts_cos_before_sculpt, mesh_verts_cos_sculpt_stroke1)
|
||||
|
||||
# Add a second sculpt stroke.
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
mesh_verts_cos_sculpt_stroke2 = extract_mesh_cos(window)
|
||||
t.assertNotEqual(mesh_verts_cos_sculpt_stroke1, mesh_verts_cos_sculpt_stroke2)
|
||||
|
||||
# Undo to first sculpt stroke.
|
||||
yield e.ctrl.z()
|
||||
mesh_verts_cos = extract_mesh_cos(window)
|
||||
t.assertEqual(mesh_verts_cos, mesh_verts_cos_sculpt_stroke1)
|
||||
|
||||
# Undo to memfile step (add constraint), fine here (T82532),
|
||||
# but would fail if we had added a Multires modifier instead (T82851).
|
||||
yield e.ctrl.z()
|
||||
mesh_verts_cos = extract_mesh_cos(window)
|
||||
t.assertEqual(mesh_verts_cos, mesh_verts_cos_before_sculpt)
|
||||
|
||||
# Redo first sculpt stroke, would now be undone (in Multires case, T82851),
|
||||
# or not redone (in constraint case, T82532).
|
||||
yield e.ctrl.shift.z()
|
||||
mesh_verts_cos = extract_mesh_cos(window)
|
||||
t.assertEqual(mesh_verts_cos, mesh_verts_cos_sculpt_stroke1)
|
||||
|
||||
# Redo second sculpt stroke, would redo properly,
|
||||
# as well as part of the first one that affects the same nodes (T82851, T82532).
|
||||
yield e.ctrl.shift.z()
|
||||
mesh_verts_cos = extract_mesh_cos(window)
|
||||
t.assertEqual(mesh_verts_cos, mesh_verts_cos_sculpt_stroke2)
|
||||
|
||||
|
||||
def view3d_sculpt_dyntopo_simple():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Torus")
|
||||
# Avoid dynamic topology prompt.
|
||||
yield from ui.call_operator(e, "Remove UV Map")
|
||||
if _MENU_CONFIRM_HACK:
|
||||
yield
|
||||
yield e.r().y().text("45").ret() # Rotate Y 45.
|
||||
yield e.ctrl.a().r() # Apply rotation.
|
||||
yield e.numpad_period() # View all.
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
yield from ui.call_menu(e, "Sculpt -> Dynamic Topology Toggle")
|
||||
# TODO: should be accessible from menu.
|
||||
yield from ui.call_operator(e, "Symmetrize")
|
||||
yield e.ctrl.tab().o() # Object mode.
|
||||
t.assertEqual(len(window.view_layer.objects.active.data.polygons), 1258)
|
||||
yield e.delete() # Delete the object.
|
||||
yield e.ctrl.z() # Undo...
|
||||
yield e.ctrl.z() # Undo used to crash here: T60974
|
||||
t.assertEqual(len(window.view_layer.objects.active.data.polygons), 1258)
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'SCULPT')
|
||||
|
||||
|
||||
def view3d_sculpt_dyntopo_and_edit():
|
||||
e, _, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Torus")
|
||||
yield e.numpad_period() # View all.
|
||||
yield from ui.call_operator(e, "Remove UV Map")
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
yield e.ctrl.d().ret() # Dynamic topology.
|
||||
# TODO: should be accessible from menu.
|
||||
yield from ui.call_operator(e, "Symmetrize")
|
||||
# Some painting (demo it works, not needed for the crash)
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
yield e.tab() # Edit mode.
|
||||
yield e.tab() # Object mode.
|
||||
yield e.ctrl.z(3) # Undo
|
||||
# yield e.ctrl.z() # Undo asserts (nested undo call from dyntopo)
|
||||
|
||||
|
||||
def view3d_sculpt_trim():
|
||||
"""
|
||||
Test that trim functionality can be undone and redone correctly.
|
||||
Operations that work on the entire mesh exercise a different code path from normal sculpt undo.
|
||||
"""
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Torus")
|
||||
yield e.numpad_period() # View all.
|
||||
yield from ui.call_operator(e, "Remove UV Map")
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
|
||||
# Utility to extract current mesh coordinates (used to ensure undo/redo steps are applied properly).
|
||||
def extract_mesh_positions(window):
|
||||
# TODO: Find/add a way to get that info when there is a multires active in Sculpt mode.
|
||||
window.view_layer.update()
|
||||
tmp_mesh = window.view_layer.objects.active.to_mesh(preserve_all_data_layers=True)
|
||||
tmp_cos = [0.0] * len(tmp_mesh.vertices) * 3
|
||||
tmp_mesh.vertices.foreach_get("co", tmp_cos)
|
||||
window.view_layer.objects.active.to_mesh_clear()
|
||||
return tmp_cos
|
||||
|
||||
beginning_positions = extract_mesh_positions(window)
|
||||
yield from ui.call_operator(e, "Box Trim")
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_xy(window)) # Perform the trim
|
||||
after_trim_positions = extract_mesh_positions(window)
|
||||
t.assertNotEqual(beginning_positions, after_trim_positions)
|
||||
|
||||
yield e.ctrl.z() # Undo Trim
|
||||
after_undo_positions = extract_mesh_positions(window)
|
||||
t.assertEqual(beginning_positions, after_undo_positions)
|
||||
|
||||
yield e.ctrl.shift.z() # Redo Trim
|
||||
after_redo_positions = extract_mesh_positions(window)
|
||||
t.assertEqual(after_trim_positions, after_redo_positions)
|
||||
|
||||
|
||||
def view3d_sculpt_dyntopo_stroke_toggle():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Torus")
|
||||
yield e.numpad_period() # View all.
|
||||
yield from ui.call_operator(e, "Remove UV Map")
|
||||
yield e.ctrl.tab().s() # Sculpt via pie menu.
|
||||
|
||||
# Utility to extract current mesh coordinates (used to ensure undo/redo steps are applied properly).
|
||||
def extract_mesh_positions(window):
|
||||
# TODO: Find/add a way to get that info when there is a multires active in Sculpt mode.
|
||||
window.view_layer.update()
|
||||
tmp_mesh = window.view_layer.objects.active.to_mesh(preserve_all_data_layers=True)
|
||||
tmp_cos = [0.0] * len(tmp_mesh.vertices) * 3
|
||||
tmp_mesh.vertices.foreach_get("co", tmp_cos)
|
||||
window.view_layer.objects.active.to_mesh_clear()
|
||||
return tmp_cos
|
||||
|
||||
original_positions = extract_mesh_positions(window)
|
||||
yield from ui.call_operator(e, "Dynamic Topology") # On
|
||||
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
|
||||
yield from ui.call_operator(e, "Dynamic Topology") # Off
|
||||
after_toggle_off = extract_mesh_positions(window)
|
||||
t.assertNotEqual(original_positions, after_toggle_off)
|
||||
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
after_normal_stroke = extract_mesh_positions(window)
|
||||
t.assertNotEqual(after_toggle_off, after_normal_stroke)
|
||||
|
||||
yield e.ctrl.z() # Undo Stroke
|
||||
after_first_undo = extract_mesh_positions(window)
|
||||
t.assertEqual(after_first_undo, after_toggle_off)
|
||||
|
||||
yield e.ctrl.z() # Undo Toggle Off
|
||||
yield e.ctrl.z() # Undo Dyntopo Stroke
|
||||
yield e.ctrl.z() # Undo Toggle On
|
||||
after_full_undo = extract_mesh_positions(window)
|
||||
t.assertEqual(after_full_undo, original_positions)
|
||||
|
||||
yield e.ctrl.shift.z() # Redo Toggle On
|
||||
yield e.ctrl.shift.z() # Redo Dyntopo Stroke
|
||||
yield e.ctrl.shift.z() # Redo Toggle Off
|
||||
after_toggle_off_redo = extract_mesh_positions(window)
|
||||
t.assertEqual(after_toggle_off_redo, after_toggle_off)
|
||||
|
||||
yield e.ctrl.shift.z() # Redo Normal Stroke
|
||||
after_normal_stroke_redo = extract_mesh_positions(window)
|
||||
t.assertEqual(after_normal_stroke, after_normal_stroke_redo)
|
||||
|
||||
|
||||
def view3d_texture_paint_simple():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Monkey")
|
||||
yield e.numpad_period() # View monkey
|
||||
yield e.ctrl.tab().t() # Paint via pie menu.
|
||||
yield from ui.call_operator(e, "Add Texture Paint Slot")
|
||||
yield e.ret() # Accept popup.
|
||||
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
yield e.ctrl.z(2) # Undo: initial texture paint.
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'TEXTURE_PAINT')
|
||||
yield e.ctrl.z() # Undo: object mode.
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT')
|
||||
yield e.ctrl.shift.z(2) # Redo: initial blank canvas.
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'TEXTURE_PAINT')
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
yield e.ctrl.z() # Used to crash T61172.
|
||||
|
||||
|
||||
def view3d_texture_paint_complex():
|
||||
import bpy
|
||||
# More complex test than `view3d_texture_paint_simple`,
|
||||
# including interleaved memfile steps,
|
||||
# and a call to history to undo several steps at once.
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Monkey")
|
||||
yield e.numpad_period() # View monkey
|
||||
yield e.ctrl.tab().t() # Paint via pie menu.
|
||||
|
||||
yield from ui.call_operator(e, "Add Texture Paint Slot")
|
||||
yield e.ret() # Accept popup.
|
||||
|
||||
initial_data = tuple(bpy.data.images['Suzanne Base Color'].pixels)
|
||||
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
|
||||
after_strokes = tuple(bpy.data.images['Suzanne Base Color'].pixels)
|
||||
t.assertTrue(any([orig != new for (orig, new) in zip(initial_data, after_strokes)]),
|
||||
"At least one pixel should differ in color component")
|
||||
|
||||
yield from ui.call_operator(e, "Add Texture Paint Slot")
|
||||
yield e.ret() # Accept popup.
|
||||
|
||||
yield from ui.call_operator(e, "Add Modifier")
|
||||
yield e.a() # Array modifier
|
||||
t.assertEqual(len(bpy.context.active_object.modifiers), 1, "One modifier should exist")
|
||||
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
|
||||
yield e.ctrl.z(6) # Undo: second slot added.
|
||||
t.assertEqual(len(bpy.context.active_object.modifiers), 0, "No modifiers should exist")
|
||||
|
||||
after_undo = tuple(bpy.data.images['Suzanne Base Color'].pixels)
|
||||
t.assertTrue(all([orig == new for (orig, new) in zip(initial_data, after_undo)]),
|
||||
"All pixels should be the same as their original state")
|
||||
|
||||
yield e.ctrl.z(1) # Undo: initial texture paint.
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'TEXTURE_PAINT')
|
||||
yield e.ctrl.z() # Undo: object mode.
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT')
|
||||
|
||||
yield e.ctrl.shift.z(2) # Redo: initial blank canvas.
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'TEXTURE_PAINT')
|
||||
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
|
||||
yield from ui.call_operator(e, "Undo History")
|
||||
yield e.o() # Undo everything to Original step.
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT')
|
||||
|
||||
|
||||
def view3d_mesh_edit_separate():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Cube")
|
||||
yield e.numpad_period() # View all.
|
||||
yield e.tab() # Edit mode.
|
||||
yield e.shift.d() # Duplicate...
|
||||
yield e.x().text("3").ret() # Move X-3.
|
||||
yield e.p().s() # Separate selection.
|
||||
t.assertEqual(len(window.view_layer.objects), 2)
|
||||
yield e.ctrl.z() # Undo.
|
||||
t.assertEqual(len(window.view_layer.objects), 1)
|
||||
yield e.tab() # Object mode.
|
||||
t.assertEqual(len(window.view_layer.objects.active.data.polygons), 12)
|
||||
yield e.tab() # Edit mode.
|
||||
yield e.ctrl.i() # Invert selection.
|
||||
yield e.p().s() # Separate selection.
|
||||
yield e.tab() # Object mode.
|
||||
t.assertEqual([len(ob.data.polygons) for ob in window.view_layer.objects], [6, 6])
|
||||
yield e.ctrl.z(8) # Undo until start.
|
||||
t.assertEqual(len(window.view_layer.objects), 0)
|
||||
yield e.ctrl.shift.z(8) # Redo until end.
|
||||
t.assertEqual([len(ob.data.polygons) for ob in window.view_layer.objects], [6, 6])
|
||||
|
||||
|
||||
def view3d_mesh_particle_edit_mode_simple():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Cube")
|
||||
yield e.r.z().text("15").ret() # Single object-mode action (to test mixing different kinds of undo steps).
|
||||
yield from ui.call_menu(e, "Object -> Quick Effects -> Quick Fur")
|
||||
|
||||
yield e.ctrl.tab().s() # Particle sculpt mode.
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'SCULPT_CURVES')
|
||||
|
||||
# Brush strokes.
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
|
||||
# Undo and redo.
|
||||
yield e.ctrl.z(5)
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT')
|
||||
yield e.shift.ctrl.z(5)
|
||||
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'SCULPT_CURVES')
|
||||
|
||||
# Brush strokes.
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
|
||||
yield e.ctrl.z(7)
|
||||
t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT')
|
||||
yield e.shift.ctrl.z(7)
|
||||
|
||||
|
||||
def view3d_font_edit_mode_simple():
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
yield from ui.call_menu(e, "Add -> Text")
|
||||
yield e.numpad_period() # View all.
|
||||
yield e.tab() # Edit mode.
|
||||
yield e.ctrl.back_space()
|
||||
yield e.text("Hello\nWorld")
|
||||
yield e.tab() # Object mode.
|
||||
t.assertEqual(window.view_layer.objects.active.data.body, 'Hello\nWorld')
|
||||
yield e.r.x().text("90").ret() # Rotate 90, face the view.
|
||||
yield e.tab() # Edit mode.
|
||||
yield e.end() # Edit mode.
|
||||
yield e.ctrl.back_space()
|
||||
yield e.back_space()
|
||||
yield e.tab() # Object mode.
|
||||
t.assertEqual(window.view_layer.objects.active.data.body, 'Hello')
|
||||
|
||||
yield e.ctrl.z(3)
|
||||
t.assertEqual(window.view_layer.objects.active.data.body, 'Hello\nWorld')
|
||||
yield e.shift.ctrl.z(3)
|
||||
t.assertEqual(window.view_layer.objects.active.data.body, 'Hello')
|
||||
|
||||
|
||||
def view3d_multi_mode_select():
|
||||
# Note, this test should be extended to change modes for each object type.
|
||||
e, t, window = ui.test_window()
|
||||
yield from _view3d_startup_area_maximized(e)
|
||||
|
||||
object_names = []
|
||||
|
||||
for i, (menu_search, ob_name) in enumerate((
|
||||
("Add -> Armature", "Armature"),
|
||||
("Add -> Text", "Text"),
|
||||
("Add -> Mesh -> Cube", "Cube"),
|
||||
("Add -> Curve -> Bézier", "Curve"),
|
||||
("Add -> Volume -> Empty", "Volume Empty"),
|
||||
("Add -> Metaball -> Ball", "Metaball"),
|
||||
("Add -> Lattice", "Lattice"),
|
||||
("Add -> Light -> Point", "Point Light"),
|
||||
("Add -> Camera", "Camera"),
|
||||
("Add -> Empty -> Plain Axis", "Empty"),
|
||||
)):
|
||||
yield from ui.call_menu(e, menu_search)
|
||||
# Single object-mode action (to test mixing different kinds of undo steps).
|
||||
yield e.g.z().text(str(i * 2)).ret()
|
||||
# Rename.
|
||||
yield e.f2().text(ob_name).ret()
|
||||
|
||||
object_names.append(window.view_layer.objects.active.name)
|
||||
|
||||
yield from ui.call_menu(e, "View -> Frame All")
|
||||
# print(object_names)
|
||||
|
||||
for ob_name in object_names:
|
||||
yield from _view3d_object_select_by_name(e, ob_name)
|
||||
yield
|
||||
# print()
|
||||
# print('=' * 40)
|
||||
# print(window.view_layer.objects.active.name, ob_name)
|
||||
|
||||
for ob_name in reversed(object_names):
|
||||
t.assertEqual(ob_name, window.view_layer.objects.active.name)
|
||||
yield e.ctrl.z()
|
||||
|
||||
|
||||
def view3d_multi_mode_multi_window():
|
||||
e_a, t, window_a = ui.test_window(0)
|
||||
yield from ui.call_menu(e_a, "Window -> New Main Window")
|
||||
|
||||
e_b, _, window_b = ui.test_window(1)
|
||||
del _
|
||||
yield from ui.call_menu(e_b, "New Scene")
|
||||
yield e_b.ret()
|
||||
if _MENU_CONFIRM_HACK:
|
||||
yield from ui.idle_until(lambda: window_a.view_layer != window_b.view_layer)
|
||||
|
||||
t.assertNotEqual(window_a.view_layer, window_b.view_layer, "Windows should have different view layers")
|
||||
|
||||
for e in (e_a, e_b):
|
||||
pos_v3d = ui.get_area_center_from_spacetype(e.window, 'VIEW_3D')
|
||||
e.cursor_position_set(x=pos_v3d[0], y=pos_v3d[1], move=True)
|
||||
del pos_v3d
|
||||
|
||||
yield from _view3d_startup_area_maximized(e_a)
|
||||
yield from _view3d_startup_area_maximized(e_b)
|
||||
|
||||
undo_current = 0
|
||||
undo_state_empty = undo_current
|
||||
|
||||
yield from ui.call_menu(e_a, "Add -> Torus")
|
||||
yield from ui.call_menu(e_b, "Add -> Monkey")
|
||||
undo_current += 2
|
||||
|
||||
# Weight paint via pie menu.
|
||||
yield e_a.ctrl.tab().w()
|
||||
yield e_b.ctrl.tab().w()
|
||||
undo_current += 2
|
||||
undo_state_wpaint = undo_current
|
||||
|
||||
t.assertEqual(window_a.view_layer.objects.active.mode, 'WEIGHT_PAINT')
|
||||
t.assertEqual(window_b.view_layer.objects.active.mode, 'WEIGHT_PAINT')
|
||||
|
||||
# Object mode via pie menu.
|
||||
yield e_a.ctrl.tab().o()
|
||||
yield e_b.ctrl.tab().o()
|
||||
undo_current += 2
|
||||
|
||||
undo_state_non_empty_start = undo_current
|
||||
|
||||
# Edit mode.
|
||||
yield e_a.tab()
|
||||
yield e_b.tab()
|
||||
undo_current += 2
|
||||
|
||||
vert_count_a_start = len(_bmesh_from_object(window_a.view_layer.objects.active).verts)
|
||||
vert_count_b_start = len(_bmesh_from_object(window_b.view_layer.objects.active).verts)
|
||||
|
||||
yield from ui.call_menu(e_a, "Edge -> Subdivide")
|
||||
yield from ui.call_menu(e_b, "Edge -> Subdivide")
|
||||
undo_current += 2
|
||||
|
||||
yield e_a.r().y().text("45").ret() # Rotate Y 45.
|
||||
yield e_b.r().z().text("45").ret() # Rotate Z 45.
|
||||
undo_current += 2
|
||||
|
||||
# Object mode.
|
||||
yield e_a.tab()
|
||||
yield e_b.tab()
|
||||
undo_current += 2
|
||||
|
||||
# Object mode via pie menu.
|
||||
yield e_a.ctrl.tab().s()
|
||||
yield e_b.ctrl.tab().s()
|
||||
undo_current += 2
|
||||
|
||||
t.assertEqual(window_a.view_layer.objects.active.mode, 'SCULPT')
|
||||
t.assertEqual(window_b.view_layer.objects.active.mode, 'SCULPT')
|
||||
|
||||
# Rotate 90.
|
||||
yield from ui.call_menu(e_a, "Sculpt -> Rotate")
|
||||
yield e_a.text("90").ret()
|
||||
yield from ui.call_menu(e_b, "Sculpt -> Rotate")
|
||||
yield e_b.text("90").ret()
|
||||
undo_current += 2
|
||||
|
||||
# Object mode.
|
||||
yield e_a.ctrl.tab().o()
|
||||
yield e_b.ctrl.tab().o()
|
||||
undo_current += 2
|
||||
|
||||
# Edit mode.
|
||||
yield e_a.tab()
|
||||
yield e_b.tab()
|
||||
undo_current += 2
|
||||
|
||||
yield from ui.call_menu(e_a, "Edge -> Subdivide")
|
||||
yield from ui.call_menu(e_b, "Edge -> Subdivide")
|
||||
undo_current += 2
|
||||
|
||||
vert_count_a_end = len(_bmesh_from_object(window_a.view_layer.objects.active).verts)
|
||||
vert_count_b_end = len(_bmesh_from_object(window_b.view_layer.objects.active).verts)
|
||||
|
||||
t.assertEqual(vert_count_a_end, 9216)
|
||||
t.assertEqual(vert_count_b_end, 7830)
|
||||
|
||||
yield e_a.r().y().text("45").ret() # Rotate Y 45.
|
||||
yield e_b.r().z().text("45").ret() # Rotate Z 45.
|
||||
undo_current += 2
|
||||
|
||||
yield e_a.tab()
|
||||
yield e_b.tab()
|
||||
undo_current += 2
|
||||
|
||||
undo_state_final = undo_current
|
||||
|
||||
undo_delta = undo_state_final - undo_state_empty
|
||||
|
||||
yield e_a.ctrl.z(undo_delta)
|
||||
undo_current -= undo_delta
|
||||
|
||||
# Ensure scene is empty.
|
||||
t.assertEqual(len(window_a.view_layer.objects), 0)
|
||||
t.assertEqual(len(window_b.view_layer.objects), 0)
|
||||
|
||||
undo_delta = undo_state_final - undo_state_empty
|
||||
yield e_a.ctrl.shift.z(undo_delta)
|
||||
undo_current += undo_delta
|
||||
|
||||
t.assertEqual(window_a.view_layer.objects.active.mode, 'OBJECT')
|
||||
t.assertEqual(window_b.view_layer.objects.active.mode, 'OBJECT')
|
||||
|
||||
t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_end)
|
||||
t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_end)
|
||||
|
||||
undo_delta = undo_state_final - undo_state_wpaint
|
||||
yield e_a.ctrl.z(undo_delta)
|
||||
undo_current -= undo_delta
|
||||
|
||||
t.assertEqual(window_a.view_layer.objects.active.mode, 'WEIGHT_PAINT')
|
||||
t.assertEqual(window_b.view_layer.objects.active.mode, 'WEIGHT_PAINT')
|
||||
|
||||
undo_delta = undo_state_non_empty_start - undo_state_wpaint
|
||||
yield e_a.ctrl.shift.z(undo_delta)
|
||||
undo_current += undo_delta
|
||||
|
||||
t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_start)
|
||||
t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_start)
|
||||
|
||||
# Further checks could be added but this seems enough.
|
||||
|
||||
|
||||
def view3d_edit_mode_multi_window():
|
||||
"""
|
||||
Use undo and redo with multiple windows in edit-mode,
|
||||
this test caused a crash with #110022.
|
||||
"""
|
||||
e_a, t, window_a = ui.test_window(0)
|
||||
|
||||
# Nice but slower.
|
||||
use_all_area_ui_types = False
|
||||
|
||||
# Use a large, single area so the window can be duplicated & split.
|
||||
yield from _view3d_startup_area_single(e_a)
|
||||
|
||||
yield from ui.call_menu(e_a, "Window -> New Main Window")
|
||||
|
||||
e_b, _, window_b = ui.test_window(1)
|
||||
del _
|
||||
|
||||
yield from ui.call_menu(e_b, "New Scene")
|
||||
yield e_b.ret()
|
||||
if _MENU_CONFIRM_HACK:
|
||||
yield from ui.idle_until(lambda: window_a.view_layer != window_b.view_layer)
|
||||
|
||||
t.assertNotEqual(window_a.view_layer, window_b.view_layer, "Windows should have different view layers")
|
||||
|
||||
for e in (e_a, e_b):
|
||||
pos_v3d = ui.get_area_center_from_spacetype(e.window, 'VIEW_3D')
|
||||
e.cursor_position_set(x=pos_v3d[0], y=pos_v3d[1], move=True)
|
||||
del pos_v3d
|
||||
|
||||
undo_current = 0
|
||||
|
||||
yield from ui.call_menu(e_a, "Add -> Cone")
|
||||
yield from ui.call_menu(e_b, "Add -> Cylinder")
|
||||
undo_current += 2
|
||||
|
||||
# Edit mode.
|
||||
yield e_a.tab()
|
||||
yield e_b.tab()
|
||||
undo_current += 2
|
||||
|
||||
undo_state_edit_mode = undo_current
|
||||
|
||||
vert_count_a_start = len(_bmesh_from_object(window_a.view_layer.objects.active).verts)
|
||||
vert_count_b_start = len(_bmesh_from_object(window_b.view_layer.objects.active).verts)
|
||||
|
||||
yield e_a.r().y().text("45").ret() # Rotate Y 45.
|
||||
yield e_b.r().z().text("45").ret() # Rotate Z 45.
|
||||
undo_current += 2
|
||||
|
||||
yield from ui.call_menu(e_a, "Face -> Poke Faces")
|
||||
yield from ui.call_menu(e_b, "Face -> Poke Faces")
|
||||
undo_current += 2
|
||||
|
||||
yield from ui.call_menu(e_a, "Face -> Beautify Faces")
|
||||
yield from ui.call_menu(e_b, "Face -> Beautify Faces")
|
||||
undo_current += 2
|
||||
|
||||
yield from ui.call_menu(e_a, "Face -> Wireframe")
|
||||
yield from ui.call_menu(e_b, "Face -> Wireframe")
|
||||
undo_current += 2
|
||||
|
||||
vert_count_a_end = len(_bmesh_from_object(window_a.view_layer.objects.active).verts)
|
||||
vert_count_b_end = len(_bmesh_from_object(window_b.view_layer.objects.active).verts)
|
||||
|
||||
# Object mode.
|
||||
yield e_a.tab()
|
||||
yield e_b.tab()
|
||||
undo_current += 2
|
||||
|
||||
# Finished with edits, assert undo is working as expected.
|
||||
|
||||
yield e_a.ctrl.z(undo_current - undo_state_edit_mode)
|
||||
|
||||
t.assertEqual(len(_bmesh_from_object(window_a.view_layer.objects.active).verts), vert_count_a_start)
|
||||
t.assertEqual(len(_bmesh_from_object(window_b.view_layer.objects.active).verts), vert_count_b_start)
|
||||
t.assertEqual(window_a.view_layer.objects.active.mode, 'EDIT')
|
||||
t.assertEqual(window_b.view_layer.objects.active.mode, 'EDIT')
|
||||
|
||||
yield e_a.ctrl.shift.z(undo_current - undo_state_edit_mode)
|
||||
|
||||
t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_end)
|
||||
t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_end)
|
||||
t.assertEqual(window_a.view_layer.objects.active.mode, 'OBJECT')
|
||||
t.assertEqual(window_b.view_layer.objects.active.mode, 'OBJECT')
|
||||
|
||||
# Delete objects.
|
||||
yield e_a.delete()
|
||||
yield e_b.delete()
|
||||
undo_current += 2
|
||||
|
||||
yield e_b.ctrl.z(undo_current)
|
||||
|
||||
# Ensure scene is empty.
|
||||
t.assertEqual(len(window_a.view_layer.objects), 0)
|
||||
t.assertEqual(len(window_b.view_layer.objects), 0)
|
||||
|
||||
yield e_b.ctrl.shift.z(undo_current - 2)
|
||||
undo_current -= 2
|
||||
|
||||
t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_end)
|
||||
t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_end)
|
||||
t.assertEqual(window_a.view_layer.objects.active.mode, 'OBJECT')
|
||||
t.assertEqual(window_b.view_layer.objects.active.mode, 'OBJECT')
|
||||
|
||||
# Second phase!
|
||||
# Split windows & show space types (could be a utility function).
|
||||
# Test undo / redo doesn't cause issues when showing different space types.
|
||||
if use_all_area_ui_types:
|
||||
# TODO: extracting the enum from an exception is not good.
|
||||
# As it's a dynamic enum it can't be accessed from `bl_rna.properties`.
|
||||
try:
|
||||
e_a.window.screen.areas[0].ui_type = '__INVALID__'
|
||||
except TypeError as ex:
|
||||
ui_types = ex.args[0]
|
||||
ui_types = eval(ui_types[ui_types.rfind("("):])
|
||||
else:
|
||||
ui_types = ('VIEW_3D', 'PROPERTIES')
|
||||
|
||||
for e in (e_a, e_b):
|
||||
yield from _setup_window_areas_from_ui_types(e, ui_types)
|
||||
|
||||
# Ensure each undo step redraws.
|
||||
for _ in range(undo_current - undo_state_edit_mode):
|
||||
yield e_b.ctrl.z()
|
||||
|
||||
t.assertEqual(len(_bmesh_from_object(window_a.view_layer.objects.active).verts), vert_count_a_start)
|
||||
t.assertEqual(len(_bmesh_from_object(window_b.view_layer.objects.active).verts), vert_count_b_start)
|
||||
t.assertEqual(window_a.view_layer.objects.active.mode, 'EDIT')
|
||||
t.assertEqual(window_b.view_layer.objects.active.mode, 'EDIT')
|
||||
|
||||
# Ensure each undo step redraws.
|
||||
for _ in range(undo_current - undo_state_edit_mode):
|
||||
yield e_b.ctrl.shift.z()
|
||||
|
||||
t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_end)
|
||||
t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_end)
|
||||
t.assertEqual(window_a.view_layer.objects.active.mode, 'OBJECT')
|
||||
t.assertEqual(window_b.view_layer.objects.active.mode, 'OBJECT')
|
||||
147
blender-5.2.0/tests/python/ui_simulate/test_workspace.py
Normal file
147
blender-5.2.0/tests/python/ui_simulate/test_workspace.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This file does not run anything, its methods are accessed for tests by ``run_blender_setup.py``.
|
||||
"""
|
||||
import modules.ui_test_utils as ui
|
||||
|
||||
|
||||
def sanity_check_general():
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("ga") # General > Animation
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Animation")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gc") # General > Compositing
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Compositing")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gg") # General > Geometry Nodes
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Geometry Nodes")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gl") # General > Layout
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Layout")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gm") # General > Modeling
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Modeling")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gr") # General > Rendering
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Rendering")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gscr") # General > Scripting
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Scripting")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gscu") # General > Sculpting
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Sculpting")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gsh") # General > Shading
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Shading")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gt") # General > Texture Paint
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Texture Paint")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("gu") # General > UV Editing
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "UV Editing")
|
||||
|
||||
|
||||
def sanity_check_2d_animation():
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text(" 2d") # 2D Animation > 2D Animation
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "2D Animation")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("fu") # 2D Animation > 2D Full Canvas
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "2D Full Canvas")
|
||||
|
||||
|
||||
def sanity_check_sculpting():
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("sscu") # Sculpting > Sculpting
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Sculpting")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("ssh") # Sculpting > Shading
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Shading")
|
||||
|
||||
|
||||
def sanity_check_storyboarding():
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("sv") # Storyboarding > Video Editing
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Video Editing")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("st") # Storyboarding > Storyboarding
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Storyboarding")
|
||||
|
||||
|
||||
def sanity_check_vfx():
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("vc") # VFX > Compositing
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Compositing")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("vma") # VFX > Masking
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Masking")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("vmo") # VFX > Motion Tracking
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Motion Tracking")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("vr") # VFX > Rendering
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Rendering")
|
||||
|
||||
|
||||
def sanity_check_video_editing():
|
||||
e, t, window = ui.test_window()
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("vir") # Video Editing > Rendering
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Rendering")
|
||||
|
||||
yield from ui.call_operator(e, "Add Workspace")
|
||||
yield e.text("vv") # Video Editing > Video Editing
|
||||
yield e.ret()
|
||||
t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Video Editing")
|
||||
Reference in New Issue
Block a user