diff options
Diffstat (limited to 'tests/python')
-rw-r--r-- | tests/python/CMakeLists.txt | 27 | ||||
-rw-r--r-- | tests/python/bl_blendfile_library_overrides.py | 105 | ||||
-rw-r--r-- | tests/python/bl_load_addons.py | 3 | ||||
-rw-r--r-- | tests/python/bl_pyapi_bpy_path.py | 12 | ||||
-rw-r--r-- | tests/python/bl_pyapi_bpy_utils_units.py | 4 | ||||
-rw-r--r-- | tests/python/bl_pyapi_idprop.py | 52 | ||||
-rw-r--r-- | tests/python/bl_pyapi_mathutils.py | 31 | ||||
-rw-r--r-- | tests/python/bl_run_operators_event_simulate.py | 597 | ||||
-rw-r--r-- | tests/python/bl_test.py | 13 | ||||
-rw-r--r-- | tests/python/compositor_render_tests.py | 68 | ||||
-rw-r--r-- | tests/python/operators.py | 122 |
11 files changed, 1007 insertions, 27 deletions
diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index 969b748e973..92cebb7d274 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -713,6 +713,33 @@ if(WITH_CYCLES OR WITH_OPENGL_RENDER_TESTS) endif() endif() +if(WITH_COMPOSITOR) + set(compositor_tests + color + converter + distort + filter + input + matte + output + vector + + multiple_node_setups + ) + + foreach(comp_test ${compositor_tests}) + add_python_test( + compositor_${comp_test}_test + ${CMAKE_CURRENT_LIST_DIR}/compositor_render_tests.py + -blender "${TEST_BLENDER_EXE}" + -testdir "${TEST_SRC_DIR}/compositor/${comp_test}" + -idiff "${OPENIMAGEIO_IDIFF}" + -outdir "${TEST_OUT_DIR}/compositor" + ) + endforeach() + +endif() + if(WITH_OPENGL_DRAW_TESTS) if(NOT OPENIMAGEIO_IDIFF) MESSAGE(STATUS "Disabling OpenGL draw tests because OIIO idiff does not exist") diff --git a/tests/python/bl_blendfile_library_overrides.py b/tests/python/bl_blendfile_library_overrides.py index ab75a410590..48625a1ecdb 100644 --- a/tests/python/bl_blendfile_library_overrides.py +++ b/tests/python/bl_blendfile_library_overrides.py @@ -1,6 +1,6 @@ # Apache License, Version 2.0 -# ./blender.bin --background -noaudio --python tests/python/bl_blendfile_library_overrides.py +# ./blender.bin --background -noaudio --python tests/python/bl_blendfile_library_overrides.py -- --output-dir=/tmp/ import pathlib import bpy import sys @@ -16,6 +16,8 @@ class TestLibraryOverrides(TestHelper, unittest.TestCase): OBJECT_LIBRARY_PARENT = "LibMeshParent" MESH_LIBRARY_CHILD = "LibMeshChild" OBJECT_LIBRARY_CHILD = "LibMeshChild" + MESH_LIBRARY_PERMISSIVE = "LibMeshPermissive" + OBJECT_LIBRARY_PERMISSIVE = "LibMeshPermissive" def __init__(self, args): self.args = args @@ -33,6 +35,14 @@ class TestLibraryOverrides(TestHelper, unittest.TestCase): obj_child = bpy.data.objects.new(TestLibraryOverrides.OBJECT_LIBRARY_CHILD, object_data=mesh_child) obj_child.parent = obj bpy.context.collection.objects.link(obj_child) + + mesh = bpy.data.meshes.new(TestLibraryOverrides.MESH_LIBRARY_PERMISSIVE) + obj = bpy.data.objects.new(TestLibraryOverrides.OBJECT_LIBRARY_PERMISSIVE, object_data=mesh) + bpy.context.collection.objects.link(obj) + obj.override_template_create() + prop = obj.override_library.properties.add(rna_path='scale') + prop.operations.add(operation='NOOP') + bpy.ops.wm.save_as_mainfile(filepath=str(self.output_path), check_existing=False, compress=False) def __ensure_override_library_updated(self): @@ -47,29 +57,109 @@ class TestLibraryOverrides(TestHelper, unittest.TestCase): bpy.ops.wm.link(directory=str(link_dir), filename=TestLibraryOverrides.OBJECT_LIBRARY_PARENT) obj = bpy.data.objects[TestLibraryOverrides.OBJECT_LIBRARY_PARENT] - assert(obj.override_library is None) + self.assertIsNone(obj.override_library) local_id = obj.override_create() - assert(local_id.override_library) - assert(local_id.data.override_library is None) + self.assertIsNotNone(local_id.override_library) + self.assertIsNone(local_id.data.override_library) assert(len(local_id.override_library.properties) == 0) local_id.location.y = 1.0 self.__ensure_override_library_updated() - assert (len(local_id.override_library.properties) == 1) assert(len(local_id.override_library.properties) == 1) override_prop = local_id.override_library.properties[0] assert(override_prop.rna_path == "location"); assert(len(override_prop.operations) == 1) override_operation = override_prop.operations[0] - assert (override_operation.operation == 'REPLACE') + assert(override_operation.operation == 'REPLACE') # Setting location.y overridded all elements in the location array. -1 is a wildcard. assert(override_operation.subitem_local_index == -1) - + + def test_link_permissive(self): + """ + Linked assets with a permissive template. + + - Checks if the NOOP is properly handled. + - Checks if the correct properties and operations are created/updated. + """ + bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True) + bpy.data.orphans_purge() + + link_dir = self.output_path / "Object" + bpy.ops.wm.link(directory=str(link_dir), filename=TestLibraryOverrides.OBJECT_LIBRARY_PERMISSIVE) + + obj = bpy.data.objects[TestLibraryOverrides.OBJECT_LIBRARY_PERMISSIVE] + self.assertIsNotNone(obj.override_library) + local_id = obj.override_create() + self.assertIsNotNone(local_id.override_library) + self.assertIsNone(local_id.data.override_library) + assert(len(local_id.override_library.properties) == 1) + override_prop = local_id.override_library.properties[0] + assert(override_prop.rna_path == "scale"); + assert(len(override_prop.operations) == 1) + override_operation = override_prop.operations[0] + assert(override_operation.operation == 'NOOP') + assert(override_operation.subitem_local_index == -1) + + local_id.location.y = 1.0 + local_id.scale.x = 0.5 + # `scale.x` will apply, but will be reverted when the library overrides + # are updated. This is by design so python scripts can still alter the + # properties locally what is a typical usecase in productions. + assert(local_id.scale.x == 0.5) + assert(local_id.location.y == 1.0) + + self.__ensure_override_library_updated() + assert(local_id.scale.x == 1.0) + assert(local_id.location.y == 1.0) + + assert(len(local_id.override_library.properties) == 2) + override_prop = local_id.override_library.properties[0] + assert(override_prop.rna_path == "scale"); + assert(len(override_prop.operations) == 1) + override_operation = override_prop.operations[0] + assert(override_operation.operation == 'NOOP') + assert(override_operation.subitem_local_index == -1) + + override_prop = local_id.override_library.properties[1] + assert(override_prop.rna_path == "location"); + assert(len(override_prop.operations) == 1) + override_operation = override_prop.operations[0] + assert(override_operation.operation == 'REPLACE') + assert (override_operation.subitem_local_index == -1) + + +class TestLibraryTemplate(TestHelper, unittest.TestCase): + MESH_LIBRARY_PERMISSIVE = "LibMeshPermissive" + OBJECT_LIBRARY_PERMISSIVE = "LibMeshPermissive" + + def __init__(self, args): + pass + + def test_permissive_template(self): + """ + Test setting up a permissive template. + """ + bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True) + mesh = bpy.data.meshes.new(TestLibraryTemplate.MESH_LIBRARY_PERMISSIVE) + obj = bpy.data.objects.new(TestLibraryTemplate.OBJECT_LIBRARY_PERMISSIVE, object_data=mesh) + bpy.context.collection.objects.link(obj) + assert(obj.override_library is None) + obj.override_template_create() + assert(obj.override_library is not None) + assert(len(obj.override_library.properties) == 0) + prop = obj.override_library.properties.add(rna_path='scale') + assert(len(obj.override_library.properties) == 1) + assert(len(prop.operations) == 0) + operation = prop.operations.add(operation='NOOP') + assert(len(prop.operations) == 1) + assert(operation.operation == 'NOOP') + TESTS = ( TestLibraryOverrides, + TestLibraryTemplate, ) @@ -95,6 +185,7 @@ def main(): # Don't write thumbnails into the home directory. bpy.context.preferences.filepaths.use_save_preview_images = False + bpy.context.preferences.experimental.use_override_templates = True for Test in TESTS: Test(args).run_all_tests() diff --git a/tests/python/bl_load_addons.py b/tests/python/bl_load_addons.py index 01f0b4d72d8..60f44c9452a 100644 --- a/tests/python/bl_load_addons.py +++ b/tests/python/bl_load_addons.py @@ -59,7 +59,8 @@ def _init_addon_blacklist(): def addon_modules_sorted(): - modules = addon_utils.modules({}) + # Pass in an empty module cache to prevent `addon_utils` local module cache being manipulated. + modules = addon_utils.modules(module_cache={}) modules[:] = [ mod for mod in modules if not (mod.__file__.startswith(BLACKLIST_DIRS)) diff --git a/tests/python/bl_pyapi_bpy_path.py b/tests/python/bl_pyapi_bpy_path.py index 2d6019fbb07..ddb13cfe2ce 100644 --- a/tests/python/bl_pyapi_bpy_path.py +++ b/tests/python/bl_pyapi_bpy_path.py @@ -26,12 +26,12 @@ class TestBpyPath(unittest.TestCase): self.assertEqual(ensure_ext('', ''), '') self.assertEqual(ensure_ext('', '.blend'), '.blend') - # Test case-sensitive behaviour. - self.assertEqual(ensure_ext('demo', '.blend', True), 'demo.blend') - self.assertEqual(ensure_ext('demo.BLEND', '.blend', True), 'demo.BLEND.blend') - self.assertEqual(ensure_ext('demo', 'Blend', True), 'demoBlend') - self.assertEqual(ensure_ext('demoBlend', 'blend', True), 'demoBlendblend') - self.assertEqual(ensure_ext('demo', '', True), 'demo') + # Test case-sensitive behavior. + self.assertEqual(ensure_ext('demo', '.blend', case_sensitive=True), 'demo.blend') + self.assertEqual(ensure_ext('demo.BLEND', '.blend', case_sensitive=True), 'demo.BLEND.blend') + self.assertEqual(ensure_ext('demo', 'Blend', case_sensitive=True), 'demoBlend') + self.assertEqual(ensure_ext('demoBlend', 'blend', case_sensitive=True), 'demoBlendblend') + self.assertEqual(ensure_ext('demo', '', case_sensitive=True), 'demo') if __name__ == '__main__': diff --git a/tests/python/bl_pyapi_bpy_utils_units.py b/tests/python/bl_pyapi_bpy_utils_units.py index d5d9c9c707b..bdb64fc361e 100644 --- a/tests/python/bl_pyapi_bpy_utils_units.py +++ b/tests/python/bl_pyapi_bpy_utils_units.py @@ -54,7 +54,7 @@ class UnitsTesting(unittest.TestCase): return ((abs(v1 - v2) / max(abs(v1), abs(v2))) <= e) for usys, utype, ref, inpt, val in self.INPUT_TESTS: - opt_val = units.to_value(usys, utype, inpt, ref) + opt_val = units.to_value(usys, utype, inpt, str_ref_unit=ref) # Note: almostequal is not good here, precision is fixed on decimal digits, not variable with # magnitude of numbers (i.e. 1609.4416 ~= 1609.4456 fails even at 5 of 'places'...). self.assertTrue(similar_values(opt_val, val, 1e-7), @@ -63,7 +63,7 @@ class UnitsTesting(unittest.TestCase): def test_units_outputs(self): for usys, utype, prec, sep, compat, val, output in self.OUTPUT_TESTS: - opt_str = units.to_string(usys, utype, val, prec, sep, compat) + opt_str = units.to_string(usys, utype, val, precision=prec, split_unit=sep, compatible_unit=compat) self.assertEqual( opt_str, output, msg=( diff --git a/tests/python/bl_pyapi_idprop.py b/tests/python/bl_pyapi_idprop.py index 3d0cbd2a7bb..1e570bf9a7f 100644 --- a/tests/python/bl_pyapi_idprop.py +++ b/tests/python/bl_pyapi_idprop.py @@ -2,6 +2,7 @@ # ./blender.bin --background -noaudio --python tests/python/bl_pyapi_idprop.py -- --verbose import bpy +import idprop import unittest import numpy as np from array import array @@ -15,12 +16,12 @@ class TestHelper: def setUp(self): self._id = bpy.context.scene - assert(len(self._id.keys()) == 0 or self._id.keys() == ["cycles"]) + self._id.pop("cycles", None) + assert(len(self._id.keys()) == 0) def tearDown(self): for key in list(self._id.keys()): - if key != "cycles": - del self._id[key] + del self._id[key] def assertAlmostEqualSeq(self, list1, list2): self.assertEqual(len(list1), len(list2)) @@ -139,6 +140,51 @@ class TestIdPropertyCreation(TestHelper, unittest.TestCase): with self.assertRaises(TypeError): self.id["a"] = self +class TestIdPropertyGroupView(TestHelper, unittest.TestCase): + + def test_type(self): + self.assertEqual(type(self.id.keys()), idprop.types.IDPropertyGroupViewKeys) + self.assertEqual(type(self.id.values()), idprop.types.IDPropertyGroupViewValues) + self.assertEqual(type(self.id.items()), idprop.types.IDPropertyGroupViewItems) + + self.assertEqual(type(iter(self.id.keys())), idprop.types.IDPropertyGroupIterKeys) + self.assertEqual(type(iter(self.id.values())), idprop.types.IDPropertyGroupIterValues) + self.assertEqual(type(iter(self.id.items())), idprop.types.IDPropertyGroupIterItems) + + def test_basic(self): + text = ["A", "B", "C"] + for i, ch in enumerate(text): + self.id[ch] = i + self.assertEqual(len(self.id.keys()), len(text)) + self.assertEqual(list(self.id.keys()), text) + self.assertEqual(list(reversed(self.id.keys())), list(reversed(text))) + + self.assertEqual(len(self.id.values()), len(text)) + self.assertEqual(list(self.id.values()), list(range(len(text)))) + self.assertEqual(list(reversed(self.id.values())), list(reversed(range(len(text))))) + + self.assertEqual(len(self.id.items()), len(text)) + self.assertEqual(list(self.id.items()), [(k, v) for v, k in enumerate(text)]) + self.assertEqual(list(reversed(self.id.items())), list(reversed([(k, v) for v, k in enumerate(text)]))) + + def test_contains(self): + # Check `idprop.types.IDPropertyGroupView{Keys/Values/Items}.__contains__` + text = ["A", "B", "C"] + for i, ch in enumerate(text): + self.id[ch] = i + + self.assertIn("A", self.id) + self.assertNotIn("D", self.id) + + self.assertIn("A", self.id.keys()) + self.assertNotIn("D", self.id.keys()) + + self.assertIn(2, self.id.values()) + self.assertNotIn(3, self.id.values()) + + self.assertIn(("A", 0), self.id.items()) + self.assertNotIn(("D", 3), self.id.items()) + class TestBufferProtocol(TestHelper, unittest.TestCase): diff --git a/tests/python/bl_pyapi_mathutils.py b/tests/python/bl_pyapi_mathutils.py index 9dfc6c159cc..c3a22be421a 100644 --- a/tests/python/bl_pyapi_mathutils.py +++ b/tests/python/bl_pyapi_mathutils.py @@ -2,7 +2,7 @@ # ./blender.bin --background -noaudio --python tests/python/bl_pyapi_mathutils.py -- --verbose import unittest -from mathutils import Matrix, Vector, Quaternion +from mathutils import Matrix, Vector, Quaternion, Euler from mathutils import kdtree, geometry import math @@ -233,6 +233,27 @@ class MatrixTesting(unittest.TestCase): self.assertEqual(mat @ mat, prod_mat) + def test_loc_rot_scale(self): + euler = Euler((math.radians(90), 0, math.radians(90)), 'ZYX') + expected = Matrix(((0, -5, 0, 1), + (0, 0, -6, 2), + (4, 0, 0, 3), + (0, 0, 0, 1))) + + result = Matrix.LocRotScale((1, 2, 3), euler, (4, 5, 6)) + self.assertAlmostEqualMatrix(result, expected, 4) + + result = Matrix.LocRotScale((1, 2, 3), euler.to_quaternion(), (4, 5, 6)) + self.assertAlmostEqualMatrix(result, expected, 4) + + result = Matrix.LocRotScale((1, 2, 3), euler.to_matrix(), (4, 5, 6)) + self.assertAlmostEqualMatrix(result, expected, 4) + + def assertAlmostEqualMatrix(self, first, second, size, *, places=6, msg=None, delta=None): + for i in range(size): + for j in range(size): + self.assertAlmostEqual(first[i][j], second[i][j], places=places, msg=msg, delta=delta) + class VectorTesting(unittest.TestCase): @@ -442,20 +463,20 @@ class KDTreeTesting(unittest.TestCase): ret_regular = k_odd.find(co) self.assertEqual(ret_regular[1] % 2, 1) - ret_filter = k_all.find(co, lambda i: (i % 2) == 1) + ret_filter = k_all.find(co, filter=lambda i: (i % 2) == 1) self.assertAlmostEqualVector(ret_regular, ret_filter) ret_regular = k_evn.find(co) self.assertEqual(ret_regular[1] % 2, 0) - ret_filter = k_all.find(co, lambda i: (i % 2) == 0) + ret_filter = k_all.find(co, filter=lambda i: (i % 2) == 0) self.assertAlmostEqualVector(ret_regular, ret_filter) # filter out all values (search odd tree for even values and the reverse) co = (0,) * 3 - ret_filter = k_odd.find(co, lambda i: (i % 2) == 0) + ret_filter = k_odd.find(co, filter=lambda i: (i % 2) == 0) self.assertEqual(ret_filter[1], None) - ret_filter = k_evn.find(co, lambda i: (i % 2) == 1) + ret_filter = k_evn.find(co, filter=lambda i: (i % 2) == 1) self.assertEqual(ret_filter[1], None) def test_kdtree_invalid_size(self): diff --git a/tests/python/bl_run_operators_event_simulate.py b/tests/python/bl_run_operators_event_simulate.py new file mode 100644 index 00000000000..92315d3e853 --- /dev/null +++ b/tests/python/bl_run_operators_event_simulate.py @@ -0,0 +1,597 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +# <pep8 compliant> + +r""" +Overview +======== + +This is a utility to generate events from the command line, +so reproducible test cases can be written without having to create a custom script each time. + +The key differentiating feature for this utility as is that it's able to control modal operators. + +Possible use cases for this script include: + +- Creating reproducible user interactions for the purpose of benchmarking and profiling. + + Note that cursor-motion actions report the update time between events + which can be helpful when measuring optimizations. + +- As a convenient way to replay interactive actions that reproduce a bug. + +- For writing tests (although some extra functionality may be necessary in this case). + + +Actions +======= + +You will notice most of the functionality is supported using the actions command line argument, +this is a kind of mini-language to drive Blender. + +While the current set of commands is fairly limited more can be added as needed. + +To see a list of actions as well as their arguments run: + + ./blender.bin --python tests/python/bl_run_operators_event_simulate.py -- --help + + +Examples +======== + +Rotate in edit-mode examples: + + ./blender.bin \ + --factory-startup \ + --enable-event-simulate \ + --python tests/python/bl_run_operators_event_simulate.py \ + -- \ + --actions \ + 'area_maximize(ui_type="VIEW_3D")' \ + 'operator("object.mode_set", mode="EDIT")' \ + 'operator("mesh.select_all", action="SELECT")' \ + 'operator("mesh.subdivide", number_cuts=5)' \ + 'operator("transform.rotate")' \ + 'cursor_motion(path="CIRCLE", radius=300, steps=100, repeat=2)' + +Sculpt stroke: + + ./blender.bin \ + --factory-startup \ + --enable-event-simulate \ + --python tests/python/bl_run_operators_event_simulate.py \ + -- \ + --actions \ + 'area_maximize(ui_type="VIEW_3D")' \ + 'event(type="FIVE", value="TAP", ctrl=True)' \ + 'menu("Visual Geometry to Mesh")' \ + 'menu("Frame Selected")' \ + 'menu("Toggle Sculpt Mode")' \ + 'event(type="WHEELDOWNMOUSE", value="TAP", repeat=2)' \ + 'event(type="LEFTMOUSE", value="PRESS")' \ + 'cursor_motion(path="CIRCLE", radius=300, steps=100, repeat=5)' \ + 'event(type="LEFTMOUSE", value="RELEASE")' + + +Implementation +============== + +While most of the operations listed above can be executed in Python directly, +either the event loop won't be handled between actions (the case for typical Python script), +or the context for executing the actions is not properly set (the case for timers). + +This utility executes actions as if the user initiated them from a key shortcut. +""" + + +import os +import sys +import argparse +from argparse import ArgumentTypeError + +import bpy + + +# ----------------------------------------------------------------------------- +# Constants + +EVENT_TYPES = tuple(bpy.types.Event.bl_rna.properties["type"].enum_items.keys()) +EVENT_VALUES = tuple(bpy.types.Event.bl_rna.properties["value"].enum_items.keys()) +# `TAP` is just convenience for (`PRESS`, `RELEASE`). +EVENT_VALUES_EXTRA = EVENT_VALUES + ('TAP',) + + +# ----------------------------------------------------------------------------- +# Globals + +# Assign a global since this script is not going to be loading new files (which would free the window). +win = bpy.context.window_manager.windows[0] + + +# ----------------------------------------------------------------------------- +# Utilities + +def find_main_area(ui_type=None): + """ + Find the largest area from the current screen. + """ + area_best = None + size_best = -1 + for area in win.screen.areas: + if ui_type is not None: + if ui_type != area.ui_type: + continue + + size = area.width * area.height + if size > size_best: + size_best = size + area_best = area + return area_best + + +def gen_events_type_text(text): + """ + Generate events to type in `text`. + """ + for ch in text: + kw_extra = {} + # The event type in this case is ignored as only the unicode value is used for text input. + type = 'SPACE' + if ch == '\t': + type = 'TAB' + elif ch == '\n': + type = 'RET' + else: + kw_extra["unicode"] = ch + + yield dict(type=type, value='PRESS', **kw_extra) + kw_extra.pop("unicode", None) + yield dict(type=type, value='RELEASE', **kw_extra) + + +# ----------------------------------------------------------------------------- +# Simulate Events + +def mouse_location_get(): + return ( + run_event_simulate.last_event["x"], + run_event_simulate.last_event["y"], + ) + + +def run_event_simulate(*, event_iter, exit_fn): + """ + Pass events from event_iter into Blender. + """ + last_event = run_event_simulate.last_event + + def event_step(): + win = bpy.context.window_manager.windows[0] + + val = next(event_step.run_events, Ellipsis) + if val is Ellipsis: + bpy.app.use_event_simulate = False + print("Finished simulation") + exit_fn() + return None + + # Run event simulation. + for attr in ("x", "y"): + if attr in val: + last_event[attr] = val[attr] + else: + val[attr] = last_event[attr] + + # Fake event value, since press, release is so common. + if val.get("value") == 'TAP': + del val["value"] + win.event_simulate(**val, value='PRESS') + # Needed if new files are loaded. + # win = bpy.context.window_manager.windows[0] + win.event_simulate(**val, value='RELEASE') + else: + # print("val", val) + win.event_simulate(**val) + return 0.0 + + event_step.run_events = iter(event_iter) + + bpy.app.timers.register(event_step, first_interval=0.0, persistent=True) + + +run_event_simulate.last_event = dict( + x=win.width // 2, + y=win.height // 2, +) + + +# ----------------------------------------------------------------------------- +# Action Implementations + +# Static methods from this class are automatically exposed as actions and included in the help text. +class action_handlers: + + @staticmethod + def area_maximize(*, ui_type=None, only_validate=False): + """ + ui_type: + Select the area type (typically 'VIEW_3D'). + Note that this area type needs to exist in the current screen. + """ + if not ((ui_type is None) or (isinstance(ui_type, str))): + raise ArgumentTypeError("'type' argument %r not None or a string type") + + if only_validate: + return + + area = find_main_area(ui_type=ui_type) + if area is None: + raise ArgumentTypeError("Area with ui_type=%r not found" % ui_type) + + x = area.x + (area.width // 2) + y = area.y + (area.height // 2) + + yield dict(type='MOUSEMOVE', value='NOTHING', x=x, y=y) + yield dict(type='SPACE', value='TAP', ctrl=True, alt=True) + + x = win.width // 2 + y = win.height // 2 + + yield dict(type='MOUSEMOVE', value='NOTHING', x=x, y=y) + + @staticmethod + def menu(text, *, only_validate=False): + """ + text: Menu item to search for and execute. + """ + if not isinstance(text, str): + raise ArgumentTypeError("'text' argument not a string") + + if only_validate: + return + + yield dict(type='F3', value='TAP') + yield from gen_events_type_text(text) + yield dict(type='RET', value='TAP') + + @staticmethod + def event(*, value, type, ctrl=False, alt=False, shift=False, repeat=1, only_validate=False): + """ + value: The event, typically key, e.g. 'ESC', 'RET', 'SPACE', 'A'. + type: The event type, valid values include: 'PRESS', 'RELEASE', 'TAP'. + ctrl: Control modifier. + alt: Alt modifier. + shift: Shift modifier. + """ + valid_items = EVENT_VALUES_EXTRA + if value not in valid_items: + raise ArgumentTypeError("'value' argument %r not in %r" % (value, valid_items)) + valid_items = EVENT_TYPES + if type not in valid_items: + raise ArgumentTypeError("'type' argument %r not in %r" % (value, valid_items)) + valid_items = range(1, sys.maxsize) + if repeat not in valid_items: + raise ArgumentTypeError("'repeat' argument %r not in %r" % (repeat, valid_items)) + del valid_items + + if only_validate: + return + + for _ in range(repeat): + yield dict(type=type, ctrl=ctrl, alt=alt, shift=shift, value=value) + + @staticmethod + def cursor_motion(*, path, steps, radius=100, repeat=1, only_validate=False): + """ + path: The path type to use in ('CIRCLE'). + steps: The number of events to generate. + radius: The radius in pixels. + repeat: Number of times to repeat the cursor rotation. + """ + + import time + from math import sin, cos, pi + + valid_items = range(1, sys.maxsize) + if steps not in valid_items: + raise ArgumentTypeError("'steps' argument %r not in %r" % (steps, valid_items)) + + valid_items = range(1, sys.maxsize) + if radius not in valid_items: + raise ArgumentTypeError("'radius' argument %r not in %r" % (steps, valid_items)) + + valid_items = ('CIRCLE',) + if path not in valid_items: + raise ArgumentTypeError("'path' argument %r not in %r" % (path, valid_items)) + + valid_items = range(1, sys.maxsize) + if repeat not in valid_items: + raise ArgumentTypeError("'repeat' argument %r not in %r" % (repeat, valid_items)) + del valid_items + + if only_validate: + return + + x_init, y_init = mouse_location_get() + + y_init_ofs = y_init + radius + + yield dict(type='MOUSEMOVE', value='NOTHING', x=x_init, y=y_init_ofs) + + print("\n" "Times for: %s" % os.path.basename(bpy.data.filepath)) + + t = time.time() + step_total = 0 + + if path == 'CIRCLE': + for _ in range(repeat): + for i in range(1, steps + 1): + phi = (i / steps) * 2.0 * pi + x_ofs = -radius * sin(phi) + y_ofs = +radius * cos(phi) + step_total += 1 + yield dict( + type='MOUSEMOVE', + value='NOTHING', + x=int(x_init + x_ofs), + y=int(y_init + y_ofs), + ) + + delta = time.time() - t + delta_step = delta / step_total + print( + "Average:", + ("%.6f FPS" % (1 / delta_step)).rjust(10), + ) + + yield dict(type='MOUSEMOVE', value='NOTHING', x=x_init, y=y_init) + + @staticmethod + def operator(idname, *, only_validate=False, **kw): + """ + idname: The operator identifier (positional argument only). + kw: Passed to the operator. + """ + + # Create a temporary key binding to call the operator. + wm = bpy.context.window_manager + keyconf = wm.keyconfigs.user + + keymap_id = "Screen" + key_to_map = 'F24' + + if only_validate: + op_mod, op_submod = idname.partition(".")[0::2] + op = getattr(getattr(bpy.ops, op_mod), op_submod) + try: + # The poll result doesn't matter we only want to know if the operator exists or not. + op.poll() + except AttributeError: + raise ArgumentTypeError("Operator %r does not exist" % (idname)) + + keymap = keyconf.keymaps[keymap_id] + kmi = keymap.keymap_items.new(idname=idname, type=key_to_map, value='PRESS') + kmi.idname = idname + props = kmi.properties + for key, value in kw.items(): + if not hasattr(props, key): + raise ArgumentTypeError("Operator %r does not have a %r property" % (idname, key)) + + try: + setattr(props, key, value) + except Exception as ex: + raise ArgumentTypeError("Operator %r assign %r property with error %s" % (idname, key, str(ex))) + + keymap.keymap_items.remove(kmi) + return + + keymap = keyconf.keymaps[keymap_id] + kmi = keymap.keymap_items.new(idname=idname, type=key_to_map, value='PRESS') + kmi.idname = idname + props = kmi.properties + for key, value in kw.items(): + setattr(props, key, value) + + yield dict(type=key_to_map, value='TAP') + + keymap = keyconf.keymaps[keymap_id] + kmi = keymap.keymap_items[-1] + keymap.keymap_items.remove(kmi) + + +ACTION_DIR = tuple([ + key for key in sorted(action_handlers.__dict__.keys()) + if not key.startswith("_") +]) + + +def handle_action(op, args, kwargs, only_validate=False): + fn = getattr(action_handlers, op, None) + if fn is None: + raise ArgumentTypeError("Action %r is not found in %r" % (op, ACTION_DIR)) + yield from fn(*args, **kwargs, only_validate=only_validate) + + +# ----------------------------------------------------------------------------- +# Argument Parsing + + +class BlenderAction(argparse.Action): + """ + This class is used to extract positional & keyword arguments from + a string, validate them, and return the (action, positional_args, keyword_args). + + All of this happens during argument parsing so any errors in the actions + show useful error messages instead of failing to execute part way through. + """ + + @staticmethod + def _parse_value(value, index): + """ + Convert: + "value(1, 2, a=1, b='', c=None)" + To: + ("value", (1, 2), {"a": 1, "b": "", "c": None}) + """ + split = value.find("(") + if split == -1: + op = value + args = None + kwargs = None + else: + op = value[:split] + namespace = {op: lambda *args, **kwargs: (args, kwargs)} + expr = value + try: + args, kwargs = eval(expr, namespace, namespace) + except Exception as ex: + raise ArgumentTypeError("Unable to parse \"%s\" at index %d, error: %s" % (expr, index, str(ex))) + + # Creating a list is necessary since this is a generator. + try: + dummy_result = list(handle_action(op, args, kwargs, only_validate=True)) + except ArgumentTypeError as ex: + raise ArgumentTypeError("Invalid 'action' arguments \"%s\" at index %d, %s" % (value, index, str(ex))) + # Validation should never yield any events. + assert(not dummy_result) + + return (op, args, kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + setattr( + namespace, + self.dest, [ + self._parse_value(value, index) + for index, value in enumerate(values) + ], + ) + + +def argparse_create(): + import inspect + import textwrap + + # When --help or no args are given, print this help + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawTextHelpFormatter, + ) + + parser.add_argument( + "--keep-open", + dest="keep_open", + default=False, + action="store_true", + help=( + "Keep the window open instead of exiting once event simulation is complete.\n" + "This can be useful to inspect the state of the file once the simulation is complete." + ), + required=False, + ) + + # Collect doc-strings from static methods in `actions`. + actions_docstring = [] + for action_key in ACTION_DIR: + action = getattr(action_handlers, action_key) + args = str(inspect.signature(action)) + args = "(" + args[1:].removeprefix("*, ") + args = args.replace(", *, ", ", ") # Needed in case the are positional arguments. + args = args.replace(", only_validate=False", "") + + actions_docstring.append("- %s%s\n" % (action_key, args)) + docs = textwrap.dedent((action.__doc__ or "").lstrip("\n").rstrip()) + "\n\n" + + actions_docstring.append(textwrap.indent(docs, " ")) + + parser.add_argument( + "--actions", + dest="actions", + metavar='ACTIONS', type=str, + help=( + "\n" "Arguments must use one of the following prefix:\n" + "\n" + "".join(actions_docstring) + ), + nargs='+', + required=True, + action=BlenderAction, + ) + + return parser + + +# ----------------------------------------------------------------------------- +# Default Startup + + +def setup_default_preferences(prefs): + """ + Set preferences useful for automation. + """ + prefs.view.show_splash = False + prefs.view.smooth_view = 0 + prefs.view.use_save_prompt = False + prefs.view.show_developer_ui = True + prefs.filepaths.use_auto_save_temporary_files = False + + +# ----------------------------------------------------------------------------- +# Main Function + + +def main_event_iter(*, action_list): + """ + Yield all events from action handlers. + """ + area = find_main_area() + + x_init = area.x + (area.width // 2) + y_init = area.y + (area.height // 2) + + yield dict(type='MOUSEMOVE', value='NOTHING', x=x_init, y=y_init) + + for (op, args, kwargs) in action_list: + yield from handle_action(op, args, kwargs) + + +def main(): + from sys import argv + argv = argv[argv.index("--") + 1:] if "--" in argv else [] + + try: + args = argparse_create().parse_args(argv) + except ArgumentTypeError as ex: + print(ex) + sys.exit(1) + + setup_default_preferences(bpy.context.preferences) + + def exit_fn(): + if not args.keep_open: + sys.exit(0) + else: + bpy.app.use_event_simulate = False + + run_event_simulate( + event_iter=main_event_iter(action_list=args.actions), + exit_fn=exit_fn, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/python/bl_test.py b/tests/python/bl_test.py index 6315ffbfa9d..110b4238f6c 100644 --- a/tests/python/bl_test.py +++ b/tests/python/bl_test.py @@ -32,9 +32,18 @@ def replace_bpy_app_version(): app = bpy.app app_fake = type(bpy)("bpy.app") + app_attr_exclude = { + # This causes a noisy warning every time. + "binary_path_python", + } + for attr in dir(app): - if not attr.startswith("_"): - setattr(app_fake, attr, getattr(app, attr)) + if attr.startswith("_"): + continue + if attr in app_attr_exclude: + continue + + setattr(app_fake, attr, getattr(app, attr)) app_fake.version = 0, 0, 0 app_fake.version_string = "0.00 (sub 0)" diff --git a/tests/python/compositor_render_tests.py b/tests/python/compositor_render_tests.py new file mode 100644 index 00000000000..057d4a2e6dd --- /dev/null +++ b/tests/python/compositor_render_tests.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# Apache License, Version 2.0 + +import argparse +import os +import shlex +import shutil +import subprocess +import sys + + +# When run from inside Blender, render and exit. +try: + import bpy + inside_blender = True +except ImportError: + inside_blender = False + +def get_arguments(filepath, output_filepath): + return [ + "--background", + "-noaudio", + "--factory-startup", + "--enable-autoexec", + "--debug-memory", + "--debug-exit-on-error", + filepath, + "-P", + os.path.realpath(__file__), + "-o", output_filepath, + "-F", "PNG", + "-f", "1"] + + +def create_argparse(): + parser = argparse.ArgumentParser() + parser.add_argument("-blender", nargs="+") + parser.add_argument("-testdir", nargs=1) + parser.add_argument("-outdir", nargs=1) + parser.add_argument("-idiff", nargs=1) + return parser + + +def main(): + parser = create_argparse() + args = parser.parse_args() + + blender = args.blender[0] + test_dir = args.testdir[0] + idiff = args.idiff[0] + output_dir = args.outdir[0] + + from modules import render_report + report = render_report.Report("Compositor", output_dir, idiff) + report.set_pixelated(True) + report.set_reference_dir("compositor_renders") + + # Temporary change to pass OpenImageDenoise test with both 1.3 and 1.4. + if os.path.basename(test_dir) == 'filter': + report.set_fail_threshold(0.05) + + ok = report.run(test_dir, blender, get_arguments, batch=True) + + sys.exit(not ok) + + +if not inside_blender and __name__ == "__main__": + main() diff --git a/tests/python/operators.py b/tests/python/operators.py index 461880ec214..c209b01c20c 100644 --- a/tests/python/operators.py +++ b/tests/python/operators.py @@ -137,7 +137,6 @@ def main(): MeshTest("CubeEdgeSplit", "testCubeEdgeSplit", "expectedCubeEdgeSplit", [OperatorSpecEditMode("edge_split", {}, "EDGE", {2, 5, 8, 11, 14, 17, 20, 23})]), - ### 25 # edge ring select - Cannot be tested. Need user input. # MeshTest("CubeEdgeRingSelect", "testCubeEdgeRingSelect", "expectedCubeEdgeRingSelect", # [OperatorSpecEditMode("edgering_select", {}, "EDGE", {5, 20, 25, 26})]), @@ -146,6 +145,16 @@ def main(): # MeshTest("EmptyMeshEdgeRingSelect", "testEmptyMeshdgeRingSelect", "expectedEmptyMeshEdgeRingSelect", # [OperatorSpecEditMode("edgering_select", {}, "VERT", {})]), + # edges select sharp + MeshTest("CubeEdgesSelectSharp", "testCubeEdgeSelectSharp", "expectedCubeEdgeSelectSharp", + [OperatorSpecEditMode("edges_select_sharp", {}, "EDGE", {20})]), + MeshTest("SphereEdgesSelectSharp", "testSphereEdgesSelectSharp", "expectedSphereEdgeSelectSharp", + [OperatorSpecEditMode("edges_select_sharp", {"sharpness": 0.25}, "EDGE", {288})]), + MeshTest("HoledSphereEdgesSelectSharp", "testHoledSphereEdgesSelectSharp", "expectedHoledSphereEdgeSelectSharp", + [OperatorSpecEditMode("edges_select_sharp", {"sharpness": 0.18}, "VERT", {})]), + MeshTest("EmptyMeshEdgesSelectSharp", "testEmptyMeshEdgeSelectSharp", "expectedEmptyMeshEdgeSelectSharp", + [OperatorSpecEditMode("edges_select_sharp", {}, "VERT", {})]), + # face make planar MeshTest("MonkeyFaceMakePlanar", "testMonkeyFaceMakePlanar", "expectedMonkeyFaceMakePlanar", @@ -187,6 +196,14 @@ def main(): MeshTest("SphereFillHoles", "testSphereFillHoles", "expectedSphereFillHoles", [OperatorSpecEditMode("fill_holes", {"sides": 9}, "VERT", {i for i in range(481)})]), + # face shade smooth (not a real test) + MeshTest("CubeShadeSmooth", "testCubeShadeSmooth", "expectedCubeShadeSmooth", + [OperatorSpecEditMode("faces_shade_smooth", {}, "VERT", {i for i in range(8)})]), + + # faces shade flat (not a real test) + MeshTest("CubeShadeFlat", "testCubeShadeFlat", "expectedCubeShadeFlat", + [OperatorSpecEditMode("faces_shade_flat", {}, "FACE", {i for i in range(6)})]), + # inset faces MeshTest("CubeInset", "testCubeInset", "expectedCubeInset", [OperatorSpecEditMode("inset", {"thickness": 0.2}, "VERT", @@ -208,6 +225,109 @@ def main(): [OperatorSpecEditMode("inset", {"thickness": 0.4, "use_relative_offset": True}, "FACE", {35, 36, 37, 45, 46, 47, 55, 56, 57})]), + + # loop multi select + MeshTest("MokeyLoopMultiSelect", "testMonkeyLoopMultiSelect", "expectedMonkeyLoopMultiSelect", + [OperatorSpecEditMode("loop_multi_select", {}, "VERT", {355, 359, 73, 301, 302})]), + MeshTest("HoledGridLoopMultiSelect", "testGridLoopMultiSelect", "expectedGridLoopMultiSelect", + [OperatorSpecEditMode("loop_multi_select", {}, "VERT", {257, 169, 202, 207, 274, 278, 63})]), + MeshTest("EmptyMeshLoopMultiSelect", "testEmptyMeshLoopMultiSelect", "expectedEmptyMeshLoopMultiSelect", + [OperatorSpecEditMode("loop_multi_select", {}, "VERT", {})]), + + # mark seam + MeshTest("CubeMarkSeam", "testCubeMarkSeam", "expectedCubeMarkSeam", + [OperatorSpecEditMode("mark_seam", {}, "EDGE", {1})]), + + # select all + MeshTest("CircleSelectAll", "testCircleSelectAll", "expectedCircleSelectAll", + [OperatorSpecEditMode("select_all", {}, "VERT", {1})]), + MeshTest("IsolatedVertsSelectAll", "testIsolatedVertsSelectAll", "expectedIsolatedVertsSelectAll", + [OperatorSpecEditMode("select_all", {}, "VERT", {})]), + MeshTest("EmptyMeshSelectAll", "testEmptyMeshSelectAll", "expectedEmptyMeshSelectAll", + [OperatorSpecEditMode("select_all", {}, "VERT", {})]), + + # select axis - Cannot be tested. Needs active vert selection + # MeshTest("MonkeySelectAxisX", "testMonkeySelectAxisX", "expectedMonkeySelectAxisX", + # [OperatorSpecEditMode("select_axis", {"axis": "X"}, "VERT", {13})]), + # MeshTest("MonkeySelectAxisY", "testMonkeySelectAxisY", "expectedMonkeySelectAxisY", + # [OperatorSpecEditMode("select_axis", {"axis": "Y", "sign": "NEG"}, "FACE", {317})]), + # MeshTest("MonkeySelectAxisXYZ", "testMonkeySelectAxisXYZ", "expectedMonkeySelectAxisXYZ", + # [OperatorSpecEditMode("select_axis", {"axis": "X", "sign": "NEG"}, "FACE", {317}), + # OperatorSpecEditMode("select_axis", {"axis": "Y", "sign": "POS"}, "FACE", {}), + # OperatorSpecEditMode("select_axis", {"axis": "Z", "sign": "NEG"}, "FACE", {})]), + + # select faces by sides + MeshTest("CubeSelectFacesBySide", "testCubeSelectFacesBySide", "expectedCubeSelectFacesBySide", + [OperatorSpecEditMode("select_face_by_sides", {"number": 4}, "FACE", {})]), + MeshTest("CubeSelectFacesBySideGreater", "testCubeSelectFacesBySideGreater", "expectedCubeSelectFacesBySideGreater", + [OperatorSpecEditMode("select_face_by_sides", {"number": 4, "type": "GREATER", "extend": True}, "FACE", {})]), + MeshTest("CubeSelectFacesBySideLess", "testCubeSelectFacesBySideLess", "expectedCubeSelectFacesBySideLess", + [OperatorSpecEditMode("select_face_by_sides", {"number": 4, "type": "GREATER", "extend": True}, "FACE", {})]), + + # select interior faces + MeshTest("CubeSelectInteriorFaces", "testCubeSelectInteriorFaces", "expectedCubeSelectInteriorFaces", + [OperatorSpecEditMode("select_face_by_sides", {"number": 4}, "FACE", {})]), + MeshTest("HoledCubeSelectInteriorFaces", "testHoledCubeSelectInteriorFaces", "expectedHoledCubeSelectInteriorFaces", + [OperatorSpecEditMode("select_face_by_sides", {"number": 4}, "FACE", {})]), + MeshTest("EmptyMeshSelectInteriorFaces", "testEmptyMeshSelectInteriorFaces", "expectedEmptyMeshSelectInteriorFaces", + [OperatorSpecEditMode("select_face_by_sides", {"number": 4}, "FACE", {})]), + + # select less + MeshTest("MonkeySelectLess", "testMonkeySelectLess", "expectedMonkeySelectLess", + [OperatorSpecEditMode("select_less", {}, "VERT", {2, 8, 24, 34, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 68, + 69, 70, 71, 74, 75, 78, 80, 81, 82, 83, 90, 91, 93, 95, 97, 99, + 101, 109, 111, 115, 117, 119, 121, 123, 125, 127, 129, 130, 131, + 132, 133, 134, 135, 136, 138, 141, 143, 145, 147, 149, 151, 153, + 155, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, + 175, 176, 177, 178, 181, 182, 184, 185, 186, 187, 188, 189, 190, + 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, + 206, 207, 208, 210, 216, 217, 218, 219, 220, 221, 222, 229, 230, + 231, 233, 235, 237, 239, 241, 243, 245, 247, 249, 251, 253, 255, + 257, 259, 263, 267, 269, 271, 275, 277, 289, 291, 293, 295, 309, + 310, 311, 312, 316, 317, 318, 319, 320, 323, 325, 327, 329, 331, + 341, 347, 349, 350, 351, 354, 356, 359, 361, 363, 365, 367, 369, + 375, 379, 381, 382, 385, 386, 387, 388, 389, 390, 391, 392, 393, + 394, 395, 396, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, + 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, + 420, 421, 423, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, + 435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, + 448, 449, 450, 451, 452, 454, 455, 456, 457, 458, 459, 460, 461, + 462, 463, 464, 471, 473, 474, 475, 476, 477, 478, 479, 480, 481, + 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 495, + 496, 497, 498, 499, 502, 505})]), + MeshTest("HoledCubeSelectLess", "testHoledCubeSelectLess", "expectedHoledCubeSelectLess", + [OperatorSpecEditMode("select_face_by_sides", {}, "FACE", {})]), + MeshTest("EmptyMeshSelectLess", "testEmptyMeshSelectLess", "expectedEmptyMeshSelectLess", + [OperatorSpecEditMode("select_face_by_sides", {}, "VERT", {})]), + + # select linked + MeshTest("PlanesSelectLinked", "testPlanesSelectLinked", "expectedPlanesSelectedLinked", + [OperatorSpecEditMode("select_linked", {}, "VERT", {7})]), + MeshTest("CubesSelectLinked", "testCubesSelectLinked", "expectedCubesSelectLinked", + [OperatorSpecEditMode("select_linked", {}, "VERT", {11})]), + MeshTest("EmptyMeshSelectLinked", "testEmptyMeshSelectLinked", "expectedEmptyMeshSelectLinked", + [OperatorSpecEditMode("select_linked", {}, "VERT", {})]), + + # select nth (checkered deselect) + MeshTest("CircleSelect2nd", "testCircleSelect2nd", "expectedCircleSelect2nd", + [OperatorSpecEditMode("select_nth", {}, "VERT", {i for i in range(32)})]), + + # unsubdivide + # normal case + MeshTest("CubeFaceUnsubdivide", "testCubeUnsubdivide", "expectedCubeUnsubdivide", + [OperatorSpecEditMode("unsubdivide", {}, "FACE", {i for i in range(6)})]), + + # T87259 - test cases + MeshTest("CubeEdgeUnsubdivide", "testCubeEdgeUnsubdivide", "expectedCubeEdgeUnsubdivide", + [OperatorSpecEditMode("unsubdivide", {}, "EDGE", {i for i in range(6)})]), + MeshTest("UVSphereUnsubdivide", "testUVSphereUnsubdivide", "expectedUVSphereUnsubdivide", + [OperatorSpecEditMode("unsubdivide", {'iterations': 9}, "FACE", {i for i in range(512)})]), + + # vert connect path + # Tip: It works only if there is an already existing face or more than 2 vertices. + MeshTest("CubeVertConnectPath", "testCubeVertConnectPath", "expectedCubeVertConnectPath", + [OperatorSpecEditMode("vert_connect_path", {}, "VERT", {0, 5})]), + ] operators_test = RunTest(tests) |