Tests: move undo tests from SVN into tests/python/ui_simulate

This commit is contained in:
Campbell Barton 2023-11-07 14:50:22 +11:00
parent 1496aa9d3e
commit f2fcfc8730
6 changed files with 1559 additions and 2 deletions

@ -1067,7 +1067,7 @@ endif()
if(WITH_UI_TESTS)
# This could be generated with:
# `"${TEST_PYTHON_EXE}" "${TEST_SRC_DIR}/ui_simulate/run.py" --list-tests`
# `"${TEST_PYTHON_EXE}" "${CMAKE_CURRENT_LIST_DIR}/ui_simulate/run.py" --list-tests`
# list explicitly so changes bisecting/updated are sure to re-run CMake.
set(_undo_tests
test_undo.text_editor_edit_mode_mix
@ -1089,7 +1089,7 @@ if(WITH_UI_TESTS)
add_blender_test_headless(
"bf_ui_${ui_test}"
--enable-event-simulate
--python "${TEST_SRC_DIR}/ui_simulate/run_blender_setup.py"
--python "${CMAKE_CURRENT_LIST_DIR}/ui_simulate/run_blender_setup.py"
--
--tests "${ui_test}"
)

@ -0,0 +1,357 @@
# SPDX-License-Identifier: GPL-2.0-or-later
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
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,
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
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,
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 ot 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.
def event_step():
# Run once 'TICKS' is reached.
if event_step._ticks < TICKS:
event_step._ticks += 1
return 0.0
event_step._ticks = 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 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
bpy.app.timers.register(event_step, first_interval=0.0)
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

193
tests/python/ui_simulate/run.py Executable file

@ -0,0 +1,193 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Run interaction tests using event simulation.
Example usage from Blender's source dir:
./tests/python/ui_simulate/run.py --blender=./blender.bin --tests test_undo.text_editor_simple
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:
./lib/python/tests/ui_simulate/run.py --blender=blender.bin --tests '*' \
--step-command-pre='gvim --remote-silent +{line} "{file}"'
"""
import os
import sys
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 main():
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",
})
# 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)
if __name__ == "__main__":
main()

@ -0,0 +1,113 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Utility script, called by ``run.py`` to run inside Blender,
to avoid boiler plate 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
parser = create_parser()
args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:])
# Check if `bpy.app.use_event_simulate` has been enabled by the test it's self.
# 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:
sys.exit(0)
else:
bpy.app.use_event_simulate = False
is_first = True
for test_id in args.tests:
if not is_first:
bpy.ops.wm.read_homefile()
is_first = False
mod_name, fn_name = test_id.partition(".")[0::2]
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()

@ -0,0 +1,894 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Only access for it's methods.
# 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 _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 _test_window(windows_exclude=None):
import bpy
wm = bpy.data.window_managers[0]
# Use -1 so the last added window is always used.
if windows_exclude is None:
return wm.windows[0]
for window in wm.windows:
if window not in windows_exclude:
return window
return None
def _test_vars(window):
import unittest
from modules.easy_keys import EventGenerate
return (
EventGenerate(window),
unittest.TestCase(),
)
def _call_by_name(e, text: str):
yield e.f3()
yield e.text(text)
yield e.ret()
def _call_menu(e, text: str):
yield e.f3()
yield e.text_unicode(text.replace(" -> ", " \u25b8 "))
yield e.ret()
def _cursor_motion_data_x(window):
size = window.width, window.height
return [
(x, size[1] // 2) for x in
range(int(size[0] * 0.2), int(size[0] * 0.8), 80)
]
def _cursor_motion_data_y(window):
size = window.width, window.height
return [
(size[0] // 2, y) for y in
range(int(size[1] * 0.2), int(size[1] * 0.8), 80)
]
def _window_area_get_by_type(window, space_type):
for area in window.screen.areas:
if area.type == space_type:
return area
def _cursor_position_from_area(area):
return (
area.x + area.width // 2,
area.y + area.height // 2,
)
def _cursor_position_from_spacetype(window, space_type):
area = _window_area_get_by_type(window, space_type)
if area is None:
raise Exception("Space Type %r not found" % space_type)
return _cursor_position_from_area(area)
def _view3d_object_calc_screen_space_location(window, name: str):
from bpy_extras.view3d_utils import location_3d_to_region_2d
area = _window_area_get_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 = _cursor_position_from_area(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 _call_menu(e, "View -> Area -> Horizontal Split")
else:
yield from _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 = _cursor_position_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 = _test_vars(_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 = _test_vars(window := _test_window())
import bpy
yield from _text_editor_and_3dview_startup(e, window)
text = bpy.data.texts[0]
pos_text = _cursor_position_from_spacetype(window, 'TEXT_EDITOR')
pos_v3d = _cursor_position_from_spacetype(window, 'VIEW_3D')
# View 3D: edit-mode
e.cursor_position_set(*pos_v3d, move=True)
yield from _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")
# -----------------------------------------------------------------------------
# 3D View
def _view3d_startup_area_maximized(e):
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):
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 = _cursor_position_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 _call_menu(e, "View -> Area -> Close Area")
assert len(e.window.screen.areas) == 1
def view3d_simple():
e, t = _test_vars(window := _test_window())
yield from _view3d_startup_area_maximized(e)
yield from _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 _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 = _test_vars(window := _test_window())
yield from _view3d_startup_area_maximized(e)
yield from _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(_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(_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 = _test_vars(window := _test_window())
yield from _view3d_startup_area_maximized(e)
yield from _call_menu(e, "Add -> Mesh -> Torus")
# Avoid dynamic topology prompt.
yield from _call_by_name(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 _call_menu(e, "Sculpt -> Dynamic Topology Toggle")
# TODO: should be accessible from menu.
yield from _call_by_name(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, t = _test_vars(window := _test_window())
yield from _view3d_startup_area_maximized(e)
yield from _call_menu(e, "Add -> Mesh -> Torus")
yield e.numpad_period() # View all.
yield from _call_by_name(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 _call_by_name(e, "Symmetrize")
# Some painting (demo it works, not needed for the crash)
yield from e.leftmouse.cursor_motion(_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_texture_paint_simple():
e, t = _test_vars(window := _test_window())
yield from _view3d_startup_area_maximized(e)
yield from _call_menu(e, "Add -> Mesh -> Monkey")
yield e.numpad_period() # View monkey
yield e.ctrl.tab().t() # Paint via pie menu.
yield from _call_by_name(e, "Add Texture Paint Slot")
yield e.ret() # Accept popup.
yield from e.leftmouse.cursor_motion(_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(_cursor_motion_data_x(window))
yield e.ctrl.z() # Used to crash T61172.
# test_undo.view3d_sculpt_dyntopo_simple
# test_undo.view3d_texture_paint_simple
def view3d_texture_paint_complex():
# 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 = _test_vars(window := _test_window())
yield from _view3d_startup_area_maximized(e)
yield from _call_menu(e, "Add -> Mesh -> Monkey")
yield e.numpad_period() # View monkey
yield e.ctrl.tab().t() # Paint via pie menu.
yield from _call_by_name(e, "Add Texture Paint Slot")
yield e.ret() # Accept popup.
yield from e.leftmouse.cursor_motion(_cursor_motion_data_x(window))
yield from e.leftmouse.cursor_motion(_cursor_motion_data_y(window))
yield from _call_by_name(e, "Add Texture Paint Slot")
yield e.ret() # Accept popup.
yield from e.leftmouse.cursor_motion(_cursor_motion_data_x(window))
yield from e.leftmouse.cursor_motion(_cursor_motion_data_y(window))
yield e.ctrl.z(6) # 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(_cursor_motion_data_x(window))
yield from e.leftmouse.cursor_motion(_cursor_motion_data_y(window))
yield from _call_by_name(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 = _test_vars(window := _test_window())
yield from _view3d_startup_area_maximized(e)
yield from _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 = _test_vars(window := _test_window())
yield from _view3d_startup_area_maximized(e)
yield from _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 _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(_cursor_motion_data_y(window))
yield from e.leftmouse.cursor_motion(_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(_cursor_motion_data_y(window))
yield from e.leftmouse.cursor_motion(_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 = _test_vars(window := _test_window())
yield from _view3d_startup_area_maximized(e)
yield from _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 = _test_vars(window := _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 -> Bezier", "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 _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 _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 = _test_vars(window_a := _test_window())
yield from _call_menu(e_a, "Window -> New Main Window")
e_b, _ = _test_vars(window_b := _test_window(windows_exclude={window_a}))
del _
yield from _call_menu(e_b, "New Scene")
yield e_b.ret()
if _MENU_CONFIRM_HACK:
yield
for e in (e_a, e_b):
pos_v3d = _cursor_position_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 _call_menu(e_a, "Add -> Torus")
yield from _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 _call_menu(e_a, "Edge -> Subdivide")
yield from _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 _call_menu(e_a, "Sculpt -> Rotate")
yield e_a.text("90").ret()
yield from _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 _call_menu(e_a, "Edge -> Subdivide")
yield from _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 = _test_vars(window_a := _test_window())
# 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 _call_menu(e_a, "Window -> New Main Window")
e_b, _ = _test_vars(window_b := _test_window(windows_exclude={window_a}))
del _
yield from _call_menu(e_b, "New Scene")
yield e_b.ret()
if _MENU_CONFIRM_HACK:
yield
for e in (e_a, e_b):
pos_v3d = _cursor_position_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 _call_menu(e_a, "Add -> Cone")
yield from _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 _call_menu(e_a, "Face -> Poke Faces")
yield from _call_menu(e_b, "Face -> Poke Faces")
undo_current += 2
yield from _call_menu(e_a, "Face -> Beautify Faces")
yield from _call_menu(e_b, "Face -> Beautify Faces")
undo_current += 2
yield from _call_menu(e_a, "Face -> Wireframe")
yield from _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')