Changeset View
Standalone View
tests/python/modules/transform_test.py
- This file was added.
| # ##### 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> | |||||
| import bpy | |||||
| import os | |||||
| # Internal Utilities # | |||||
sybren: The function tries to find a 3D View. This should be clear from the name, and if that's not… | |||||
| def _find_area_view3d(): | |||||
| """Returns the Area of the first 3DView found.""" | |||||
| for area in bpy.context.screen.areas: | |||||
| if area.type == 'VIEW_3D': | |||||
Done Inline ActionsNever, ever use a plain raise. Choose the appropriate exception type, and pass it an explanation of what's going wrong. sybren: Never, ever use a plain `raise`. Choose the appropriate exception type, and pass it an… | |||||
Done Inline ActionsOf course this comment also applies to all the other plain raise invocations. sybren: Of course this comment also applies to all the other plain `raise` invocations.
| |||||
| return area | |||||
| raise RuntimeError('No VIEW_3D are found!') | |||||
| def _find_region_window(area): | |||||
| """Returns the area's 'WINDOW' type region.""" | |||||
| for region in area.regions: | |||||
| if region.type == 'WINDOW': | |||||
| return region | |||||
| raise RuntimeError('No WINDOW type region found!') | |||||
| def _find_rv3d(area): | |||||
| """Return the special 'RegionView3D' object from View3D spaces.""" | |||||
| for space in area.spaces: | |||||
| if space.type == 'VIEW_3D': | |||||
| return space.region_3d | |||||
| raise RuntimeError('No RegionView3D found in the area!') | |||||
| def _context_mode_to_object_mode(mode): | |||||
Done Inline ActionsSince this class is contained in a test-specific file, and it's not a test itself, there is not really a need to have "Test" in the name. MeshCompare or ObjectCompare or something along those lines would be better. sybren: Since this class is contained in a test-specific file, and it's not a test itself, there is not… | |||||
| if mode == 'EDIT_MESH': | |||||
| return 'EDIT' | |||||
| if mode == 'PAINT_WEIGHT': | |||||
Done Inline ActionsNo need to repeat what's already in the __init__(); these kind of comments tend to rot quickly. I think it's more important to describe what compare_data actually means. sybren: No need to repeat what's already in the `__init__()`; these kind of comments tend to rot… | |||||
| return 'WEIGHT_PAINT' | |||||
| return mode | |||||
| class _ObjectCompare: | |||||
| """ | |||||
| Internal container with the objects, comparison type (matrix or mesh) and test description. | |||||
| :arg obj_a, obj_b: Objects to compare | |||||
| :type obj_a, obj_b: :class:`bpy.types.Object` | |||||
| :arg compare_matrix: enables matrix comparison. It can be enabled and disabled at any time. | |||||
| :type compare_matrix: bool | |||||
| :arg compare_meshes: enables mesh comparison (with Mesh::unit_test_compare). It can be enabled and disabled at any time. | |||||
| :type compare_matrix: bool | |||||
| :arg description: Test description. Displayed if the test fails. | |||||
| :type description: str | |||||
| """ | |||||
| def __init__(self, obj_a, obj_b, compare_matrix=False, compare_meshes=False, description=""): | |||||
| self.obj_a = obj_a | |||||
Done Inline ActionsDon't use single-letter names. sybren: Don't use single-letter names. | |||||
| self.obj_b = obj_b | |||||
Done Inline ActionsDon't change the type associated with a name. What's wrong with a loop like for row in mat_diff: for value_diff in row:? sybren: Don't change the type associated with a name.
What's wrong with a loop like `for row in… | |||||
| self.compare_matrix = compare_matrix | |||||
| self.compare_meshes = compare_meshes | |||||
| self.description = description | |||||
| def _compare_matrix(self, threshould=0.0001): | |||||
| mat_diff = self.obj_a.matrix_world - self.obj_b.matrix_world | |||||
| for row in mat_diff: | |||||
Done Inline ActionsHaving break/else/continue/break on consecutive lines makes it really hard to follow what's going on. Move this code into its own function, then you can replace the first break with a return and all the other keywords can go. sybren: Having `break/else/continue/break` on consecutive lines makes it really hard to follow what's… | |||||
| for value_diff in row: | |||||
| if abs(value_diff) > threshould: | |||||
| return False, "Matrix mismatch" | |||||
| return True, "" | |||||
Done Inline ActionsThe fact that only mesh objects are supported, and that self.compare_data actually means self.compare_meshes should be documented. sybren: The fact that only mesh objects are supported, and that `self.compare_data` actually means… | |||||
| def _compare_meshes(self): | |||||
Done Inline ActionsThis should also be its own function. sybren: This should also be its own function. | |||||
| mesh_a = self.obj_a.data | |||||
| mesh_b = self.obj_b.data | |||||
| compare_result = mesh_a.unit_test_compare(mesh=mesh_b) | |||||
| return (compare_result == 'Same'), compare_result | |||||
| def run(self, index, verbose=True): | |||||
| if verbose: | |||||
| print() | |||||
| print("Test comparison {}: {}".format(index, self.description)) | |||||
| if not self.compare_matrix and not self.compare_meshes: | |||||
| raise RuntimeError('No test specified for the objects: {} and {}!'. | |||||
| format(self.obj_a.name, self.obj_b.name)) | |||||
| compare_success = True | |||||
| compare_result = "" | |||||
| if self.compare_matrix: | |||||
| compare_success, compare_result = self._compare_matrix() | |||||
| if compare_success and self.compare_meshes: | |||||
| compare_success, compare_result = self._compare_meshes() | |||||
| if not compare_success: | |||||
| print("Test comparison result: {}".format(compare_result)) | |||||
| print("Resulting object '{}' did not match expected object '{}' from file {}". | |||||
| format(self.obj_a.name, self.obj_b.name, bpy.data.filepath)) | |||||
| return False | |||||
Done Inline ActionsNo need to do a late import here. sybren: No need to do a late import here.
| |||||
| if verbose: | |||||
| print("Success!") | |||||
| return True | |||||
| # Public API # | |||||
| class TestContext: | |||||
| """ | |||||
| Context that will be used in the test functions. | |||||
| This object has utilities to override bpy.context and compare results (with `_ObjectCompare` object) of bpy.ops operations. | |||||
| Members: | |||||
| - test_ctx.funcs: Test functions. | |||||
| - test_ctx.cleanup: If True, removes all duplicate objects. | |||||
| - test_ctx.verbose: Prints a description on each test. | |||||
| - test_ctx.on_error_fn: Callback called if an execution error occurs. | |||||
| - test_ctx.on_exit_fn: Callback called when the tests are finished. | |||||
| Utilities: | |||||
| - test_ctx.mode_set(mode) | |||||
| - test_ctx.compare_data(obj_a, obj_b, description) | |||||
| - test_ctx.compare_matrix(obj_a, obj_b, description) | |||||
| - test_ctx.select_objects(select_objs, active=None) | |||||
| - test_ctx.duplicate_object(obj) | |||||
| - test_ctx.duplicate_selected() | |||||
| Main function: | |||||
| - test_ctx.run() | |||||
| Row to use: | |||||
| 1. Create the context object. Eg. `test_ctx = TestContext()` | |||||
| 2. Create a list with the test functions. | |||||
| Each function must have a single `TestContext` parameter and test the results with `test_ctx.compare_data` or `test_ctx.compare_matrix`. | |||||
| 3. Reference the functions in the the context. `test_ctx.funcs = funcs` | |||||
| 4. Run the tests with `test_ctx.run()` | |||||
Done Inline ActionsIf I read the code correctly, ret can get two values: None and 0.0. These are not exactly meaningful values, and the handling of ret is not None is so far away from ret = 0.0 that it makes it hard to understand what exactly is going on. sybren: If I read the code correctly, `ret` can get two values: `None` and `0.0`. These are not exactly… | |||||
Done Inline ActionsThis method is the callback used in timers (I will move it closer, improve description and change the name to make it clear). When a timers callback returns None, it is unregistered. If the return is a float, the float value represents the minimum time for the test to be run again. The fact that the tests need to run on a timers callback makes it difficult to use with unittest module. Unfortunately this is the only way I have found to be able to use --event-simulate in tests. (This is also done in other test files). mano-wii: This method is the callback used in timers (I will move it closer, improve description and… | |||||
| """ | |||||
| def __init__(self): | |||||
| area = _find_area_view3d() | |||||
| region = _find_region_window(area) | |||||
| self.override_context = { | |||||
| 'area': area, | |||||
| 'region': region, | |||||
| } | |||||
| # These values can be changed at any time. | |||||
| self.cleanup = True | |||||
| self.funcs = None | |||||
| self.verbose = os.environ.get("BLENDER_VERBOSE") is not None | |||||
| self.on_error_fn = None | |||||
| self.on_exit_fn = None | |||||
| # Internals. | |||||
| self._compare_queue = [] | |||||
| self._dupli_objects = [] | |||||
| self._index_curr = 0 | |||||
| self._override_context_update() | |||||
| def _override_context_update(self): | |||||
| """ | |||||
| Since the members of bpy.context are being overwritten, it is important | |||||
| to set some values to avoid errors in the operators' pools. | |||||
| """ | |||||
| context = bpy.context | |||||
| obact = context.view_layer.objects.active | |||||
| self.override_context['mode'] = context.mode | |||||
| self.override_context['scene'] = context.scene | |||||
| self.override_context['selected_objects'] = context.selected_objects | |||||
| self.override_context['view_layer'] = context.view_layer | |||||
| self.override_context['object'] = obact | |||||
| self.override_context['active_object'] = obact | |||||
| def _default_settings_set(self): | |||||
| rv3d = _find_rv3d(self.override_context['area']) | |||||
| if rv3d.view_perspective != 'CAMERA': | |||||
| bpy.ops.view3d.view_camera(self.override_context) | |||||
| scene = self.override_context['scene'] | |||||
| tool_settings = scene.tool_settings | |||||
| scene.transform_orientation_slots[0].type = 'GLOBAL' | |||||
| tool_settings.transform_pivot_point = 'MEDIAN_POINT' | |||||
| tool_settings.snap_elements = {'INCREMENT'} | |||||
Done Inline ActionsWhat is this mapping for? sybren: What is this mapping for? | |||||
Done Inline ActionsBlender should use a consistent enum for "mode", no matter if it is the mode in the context or the mode of the object. I will split things. mano-wii: Blender should use a consistent enum for "mode", no matter if it is the mode in the context or… | |||||
| tool_settings.use_snap = False | |||||
| tool_settings.use_snap_grid_absolute = False | |||||
| tool_settings.use_snap_translate = True | |||||
| tool_settings.use_snap_rotate = False | |||||
| tool_settings.use_snap_scale = False | |||||
| tool_settings.use_proportional_edit_objects = False | |||||
| tool_settings.proportional_edit_falloff = 'SMOOTH' | |||||
| self.mode_set('OBJECT') | |||||
| if self.override_context['active_object']: | |||||
| # An active object is required for the 'select_all' operator to | |||||
| # work. | |||||
| bpy.ops.object.select_all(self.override_context, action="DESELECT") | |||||
| self._override_context_update() | |||||
| def compare_data(self, obj_a, obj_b, description): | |||||
| """ | |||||
| The coordinates on the object data will be compared. | |||||
| Currently only mesh works | |||||
| """ | |||||
| self._compare_queue.append(_ObjectCompare(obj_a, obj_b, compare_meshes=True, description=description)) | |||||
| def compare_matrix(self, obj_a, obj_b, description): | |||||
| """The matrix of objects will be compared.""" | |||||
| self._compare_queue.append(_ObjectCompare(obj_a, obj_b, compare_matrix=True, description=description)) | |||||
| def mode_set(self, mode): | |||||
| mode_cur = self.override_context['mode'] | |||||
Done Inline ActionsParentheses aren't necessary here. Same for other if-statements. sybren: Parentheses aren't necessary here. Same for other `if`-statements. | |||||
| if mode != mode_cur: | |||||
| bpy.ops.object.mode_set(self.override_context, mode=_context_mode_to_object_mode(mode)) | |||||
| self._override_context_update() | |||||
| def select_objects(self, select_objs, active=None): | |||||
| """Optional utility for selecting objects.""" | |||||
| mode_cur = self.override_context['mode'] | |||||
| self.mode_set('OBJECT') | |||||
| if self.override_context['active_object']: | |||||
| # An active object is required for the 'select_all' operator to | |||||
| # work. | |||||
| bpy.ops.object.select_all(self.override_context, action="DESELECT") | |||||
| if select_objs: | |||||
| for sel_obj in select_objs: | |||||
| sel_obj.select_set(True) | |||||
| if not active: | |||||
| active = select_objs[-1] | |||||
| if active: | |||||
| self.override_context['view_layer'].objects.active = active | |||||
| self._override_context_update() | |||||
| self.mode_set(mode_cur) | |||||
| def duplicate_object(self, obj): | |||||
| """Objects that are duplicated with this utility will be stored for later deletion.""" | |||||
| mode_cur = self.override_context['mode'] | |||||
| self.mode_set('OBJECT') | |||||
| self.select_objects((obj,), active=obj) | |||||
| bpy.ops.object.duplicate(self.override_context) | |||||
| self._override_context_update() | |||||
| obj_dupl = self.override_context['view_layer'].objects.active | |||||
| self.select_objects((obj_dupl,), active=obj_dupl) | |||||
| self._dupli_objects.append(obj_dupl) | |||||
| self.mode_set(mode_cur) | |||||
| return obj_dupl | |||||
| def duplicate_selected(self): | |||||
| """Objects that are duplicated with this utility will be stored for later deletion.""" | |||||
| mode_cur = self.override_context['mode'] | |||||
| self.mode_set('OBJECT') | |||||
| bpy.ops.object.duplicate(self.override_context) | |||||
| self._override_context_update() | |||||
| for obj in self.override_context['selected_objects']: | |||||
| self._dupli_objects.append(obj) | |||||
| obj_dupl_act = self.override_context['view_layer'].objects.active | |||||
| self.mode_set(mode_cur) | |||||
| return obj_dupl_act | |||||
| def _run_test_function_index(self, index): | |||||
| """Run the test function. Results are added to the queue.""" | |||||
| try: | |||||
| # Run test function. `self.compare_queue` is filled here. | |||||
| self._default_settings_set() | |||||
| self.funcs[index](self) | |||||
| except Exception as e: | |||||
| print("PyError in test number {}: {}".format(index, e)) | |||||
| if self.on_error_fn: | |||||
| self.on_error_fn() | |||||
| def _run_test_compute_results(self): | |||||
| """Compare the results and ends the execution.""" | |||||
| # Disable use_event_simulate to check the result. | |||||
| bpy.app.use_event_simulate = False | |||||
| failed = [] | |||||
| for i, comparison in enumerate(self._compare_queue): | |||||
| sucess = comparison.run(i, self.verbose) | |||||
| if not sucess: | |||||
| failed.append(i) | |||||
| if self.cleanup: | |||||
| self._default_settings_set() | |||||
| self.select_objects(self._dupli_objects) | |||||
| bpy.ops.object.delete(self.override_context) | |||||
| #self._override_context_update() | |||||
| if failed: | |||||
| if self.verbose: | |||||
| tot = len(failed) | |||||
| print('Ran', tot, 'tests,' if tot > 1 else 'test,', failed, 'failed') | |||||
| print("Tests {} failed".format(failed)) | |||||
| if self.on_error_fn: | |||||
| self.on_error_fn() | |||||
| if self.on_exit_fn: | |||||
| self.on_exit_fn() | |||||
| def _dispatch_test_events_timer_fn(self): | |||||
| """ | |||||
| Callback registered in `bpy.app.timers.register`. | |||||
| All test functions are performed here. | |||||
| By testing in this callback, the scene and events are updated conveniently. | |||||
| This is necessary for the `--enable-event-simulate` command to work properly. | |||||
| Note: | |||||
| - `None` return unregisters the callback. | |||||
| - A float return specifies the minimum interval for the next call. | |||||
| """ | |||||
| if self._index_curr < len(self.funcs): | |||||
| self._run_test_function_index(self._index_curr) | |||||
| self._index_curr += 1 | |||||
| # No delay. | |||||
| return 0.0 #seconds | |||||
| else: | |||||
| # Test results and unregister callback. | |||||
| self._run_test_compute_results() | |||||
| self._index_curr = 0 | |||||
| # Unregister callback. | |||||
| return None | |||||
| def run(self, on_error_fn=None, on_exit_fn=None): | |||||
| """ | |||||
| Runs each test function added to the context. | |||||
| :arg func: Sequence with all test functions that will be performed. | |||||
| :type func: sequence` | |||||
| :arg on_error_fn: Optional parameterless callback that will be executed when a python error occurs. | |||||
| :arg on_exit_fn: Optional parameterless callback that will be executed on exit. | |||||
| """ | |||||
| if not self.funcs: | |||||
| raise RuntimeError('No functions to test!') | |||||
| self.on_error_fn = on_error_fn | |||||
| self.on_exit_fn = on_exit_fn | |||||
| # Call the test functions within callbacks of `timers` to: | |||||
| # - Allow sales simulation with the `--enable-event-simulate` command; | |||||
| # - Ensure that the scene is updated correctly after each call. | |||||
| bpy.app.timers.register(self._dispatch_test_events_timer_fn, first_interval=0.0) | |||||
The function tries to find a 3D View. This should be clear from the name, and if that's not possible, from a docstring.