Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View 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

View 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
]

View 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()

View 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()

View 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()

View 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')

View 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")

View 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)

View 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

View 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")

View 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')

View 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")