This change moves the tests data files and publish folder of assets repository to the main blender.git repository as LFS files. The goal of this change is to eliminate toil of modifying tests, cherry-picking changes to LFS branches, adding tests as part of a PR which brings new features or fixes. More detailed explanation and conversation can be found in the design task. Ref #137215 Pull Request: https://projects.blender.org/blender/blender/pulls/137219
293 lines
9.2 KiB
Python
293 lines
9.2 KiB
Python
# SPDX-FileCopyrightText: 2024 Blender Authors
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
# A framework to run regression tests on mesh operators
|
|
#
|
|
# General idea:
|
|
# A test is:
|
|
# Object mode
|
|
# Select <test object>
|
|
# Duplicate the object
|
|
# Edit mode
|
|
# Select None
|
|
# Select according to <select spec>
|
|
# Apply <mesh operator> with <test params>
|
|
# Object mode
|
|
# test_mesh = <test object>.data
|
|
# run test_mesh.validate()
|
|
# run test_mesh.unit_test_compare(<expected object>.data)
|
|
# delete the duplicate object
|
|
#
|
|
# The things in angle brackets are parameters of the test, and are specified in
|
|
# a declarative TestSpec.
|
|
#
|
|
# If tests fail and it is because of a known and OK change due to things that have
|
|
# changed in Blender, we can use the 'update_expected' parameter of RunTest
|
|
# to update the <expected object>.
|
|
|
|
import bpy
|
|
|
|
|
|
class TestSpec:
|
|
"""Test specification.
|
|
|
|
Holds names of test and expected result objects,
|
|
a selection specification,
|
|
a mesh op to run and its arguments
|
|
"""
|
|
|
|
def __init__(self, op, test_obj, expected_obj, select, params):
|
|
"""Construct a test spec.
|
|
|
|
Args:
|
|
op: string - name of a function in bpy.ops.mesh
|
|
test_obj: string - name of the object to apply the test to
|
|
expected_obj: string - name of the object that has the expected result
|
|
select: string - should be V, E, or F followed by space seperated indices of desired selection
|
|
params: string - space-separated name=val pairs giving operator arguments
|
|
"""
|
|
|
|
self.test_obj = test_obj
|
|
self.expected_obj = expected_obj
|
|
self.select = select
|
|
self.op = op
|
|
self.params = params
|
|
|
|
def __str__(self):
|
|
return self.op + "(" + self.params + ") on " + self.test_obj + " selecting " + self.select
|
|
|
|
def ParseParams(self, verbose=False):
|
|
"""Parse a space-separated list of name=value pairs.
|
|
|
|
Args:
|
|
self: TestSpec
|
|
Returns:
|
|
dict - the parsed self.params
|
|
"""
|
|
|
|
ans = {}
|
|
nvs = self.params.split()
|
|
for nv in nvs:
|
|
parts = nv.split('=')
|
|
if len(parts) != 2:
|
|
if verbose:
|
|
print('Parameter syntax error at', nv)
|
|
break
|
|
name = parts[0]
|
|
try:
|
|
val = eval(parts[1])
|
|
except SyntaxError:
|
|
if verbose:
|
|
print('Parameter value syntax error at', nv)
|
|
break
|
|
ans[name] = val
|
|
return ans
|
|
|
|
|
|
# This only works if there is a 3D View area visible somewhere in current window
|
|
def GetView3DContext(verbose=False):
|
|
"""Get a context dictionary for a View3D window.
|
|
|
|
This can be used as a context override to ensure that an operator will
|
|
be executed with a View3D window and other variables implied by that context.
|
|
|
|
Args:
|
|
verbose: bool - should we be wordy about errors
|
|
Returns:
|
|
dict - with keys for window, screen, area, region, and scene
|
|
"""
|
|
|
|
win = bpy.context.window
|
|
scr = win.screen
|
|
a3d = None
|
|
for a in scr.areas:
|
|
if a.type == 'VIEW_3D':
|
|
a3d = a
|
|
break
|
|
if a3d is None:
|
|
if verbose:
|
|
print('No 3d view area')
|
|
return None
|
|
rwin = None
|
|
for r in a3d.regions:
|
|
if r.type == 'WINDOW':
|
|
rwin = r
|
|
break
|
|
if rwin is None:
|
|
if verbose:
|
|
print('No window in 3d view area')
|
|
return None
|
|
return {'window': win, 'screen': scr, 'area': a3d, 'region': rwin, 'scene': bpy.context.scene}
|
|
|
|
|
|
def DoMeshSelect(select, verbose=False):
|
|
"""Given a selection spec string, switch to the desired selection mode and do the select.
|
|
|
|
Assume we are in Object mode on a mesh object.
|
|
The selection spec string should start with V, E, or F (for vertex, edge, face) to choose
|
|
the kind of element selected, and the is followed with a list of element indices
|
|
(use --debug to Blender to visualize these).
|
|
|
|
Args:
|
|
select: string - see comment above for syntax
|
|
verbose: bool - should we be wordy
|
|
Returns:
|
|
string - which select_mode to switch to
|
|
"""
|
|
|
|
m = bpy.context.active_object.data
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='DESELECT')
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
if m.is_editmode:
|
|
if verbose:
|
|
print('Select failed, not in Object mode')
|
|
return 'VERT'
|
|
if not select:
|
|
if verbose:
|
|
print('No select spec')
|
|
return 'VERT'
|
|
if select[0] == 'V':
|
|
seltype = 'VERT'
|
|
elif select[0] == 'E':
|
|
seltype = 'EDGE'
|
|
elif select[0] == 'F':
|
|
seltype = 'FACE'
|
|
else:
|
|
if verbose:
|
|
print('Bad select type', select[0])
|
|
return 'VERT'
|
|
bpy.context.tool_settings.mesh_select_mode = (seltype == 'VERT', seltype == 'EDGE', seltype == 'FACE')
|
|
parts = select[1:].split()
|
|
try:
|
|
elems = set([int(p) for p in parts])
|
|
except ValueError:
|
|
if verbose:
|
|
print('Bad syntax in select spec', select)
|
|
return
|
|
for i in elems:
|
|
if seltype == 'VERT':
|
|
m.vertices[i].select = True
|
|
elif seltype == 'EDGE':
|
|
m.edges[i].select = True
|
|
else:
|
|
m.polygons[i].select = True
|
|
return seltype
|
|
|
|
|
|
def RunTest(t, cleanup=True, verbose=False, update_expected=False):
|
|
"""Run the test specified by given TestSpec.
|
|
|
|
Args:
|
|
t: TestSpec
|
|
cleanup: bool - should we clean up duplicate after the test
|
|
verbose: bool - should be we wordy
|
|
update_expected: bool - should we replace the golden expected object
|
|
with the result of current run?
|
|
Only has effect if cleanup is false.
|
|
Returns:
|
|
bool - True if test passes, False otherwise
|
|
"""
|
|
|
|
if verbose:
|
|
print("Run test:", t)
|
|
objs = bpy.data.objects
|
|
if t.test_obj in objs:
|
|
otest = objs[t.test_obj]
|
|
else:
|
|
if verbose:
|
|
print('No test object', t.test_obj)
|
|
return False
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
bpy.context.view_layer.objects.active = otest
|
|
otest.select_set(True)
|
|
bpy.ops.object.duplicate()
|
|
otestdup = bpy.context.active_object
|
|
smode = DoMeshSelect(t.select, verbose=verbose)
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_mode(type=smode)
|
|
f = getattr(bpy.ops.mesh, t.op)
|
|
if not f:
|
|
if verbose:
|
|
print('No mesh op', t.op)
|
|
if cleanup:
|
|
bpy.ops.object.delete()
|
|
return False
|
|
kw = t.ParseParams(verbose)
|
|
retval = f(**kw)
|
|
if retval != {'FINISHED'}:
|
|
if verbose:
|
|
print('unexpected operator return value', retval)
|
|
if cleanup:
|
|
bpy.ops.object.delete()
|
|
return False
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
if t.expected_obj in objs:
|
|
oexpected = objs[t.expected_obj]
|
|
else:
|
|
# If no expected object, test is run just for effect
|
|
if verbose:
|
|
print('No expected object', t.expected_obj)
|
|
return True
|
|
mtest = otestdup.data
|
|
mexpected = oexpected.data
|
|
cmpret = mtest.unit_test_compare(mesh=mexpected)
|
|
success = (cmpret == 'Same')
|
|
if success:
|
|
if verbose:
|
|
print('Success')
|
|
else:
|
|
if verbose:
|
|
print('Fail', cmpret)
|
|
if cleanup:
|
|
bpy.ops.object.delete()
|
|
otest.select_set(state=True, view_layer=None)
|
|
bpy.context.view_layer.objects.active = otest
|
|
elif update_expected:
|
|
if verbose:
|
|
print('Updating expected object', t.expected_obj)
|
|
oexpected.name = oexpected.name + '_pendingdelete'
|
|
otestdup.location = oexpected.location
|
|
expected_collections = oexpected.users_collection
|
|
testdup_collections = otestdup.users_collection
|
|
# should be exactly 1 collection each for otestdup and oexpected
|
|
tcoll = testdup_collections[0]
|
|
ecoll = expected_collections[0]
|
|
ecoll.objects.link(otestdup)
|
|
tcoll.objects.unlink(otestdup)
|
|
bpy.context.view_layer.objects.active = oexpected
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
oexpected.select_set(state=True, view_layer=None)
|
|
bpy.ops.object.delete()
|
|
otestdup.name = t.expected_obj
|
|
bpy.context.view_layer.objects.active = otest
|
|
return success
|
|
|
|
|
|
def RunAllTests(tests, cleanup=True, verbose=False, update_expected=False):
|
|
"""Run all tests.
|
|
|
|
Args:
|
|
tests: list of TestSpec - tests to run
|
|
cleanup: bool - if True, don't leave result objects lying around
|
|
verbose: bool - if True, chatter about running tests and failures
|
|
update_expected: bool - if True, replace all expected objects with
|
|
current results
|
|
Returns:
|
|
bool - True if all tests pass
|
|
"""
|
|
|
|
tot = 0
|
|
failed = 0
|
|
for t in tests:
|
|
tot += 1
|
|
if not RunTest(t, cleanup=cleanup, verbose=verbose, update_expected=update_expected):
|
|
failed += 1
|
|
if verbose:
|
|
print('Ran', tot, 'tests,' if tot > 1 else 'test,', failed, 'failed')
|
|
if failed > 0:
|
|
print('Tests Failed')
|
|
return failed == 0
|