mirror of
https://github.com/blender/blender
synced 2026-11-01 03:16:58 +03:00
The `Make Group` operator for nodes expects that sockets remain valid when removing a link to the node (to be replaced by a new external link). This is not the case for highly dynamic nodes like viewer nodes. A viewer socket is removed as soon as it is un-linked. Make the group operator more robust by checking if sockets exist and ignoring any link to group nodes when the socket disappears. Ideally the viewer node would remain connected through these operations, but that requires some detail special case for viewer behavior or some way to ensure a viewer node copy has the same sockets as the original. A regression test is also added for this case. The `bl_node_copy_operators.py` has been modified to use the `BLENDER_TEST_UPDATE` environment variable for updating the expected test data, instead of the previous `--generate` script parameter. This is consistent with other test scripts using the same mechanism. A bug in `nearest_node_grid_coord` was fixed which created `nan` node locations when running in `--background` mode due to zero UI scale. Introduced by #157834. Pull Request: https://projects.blender.org/blender/blender/pulls/160718
650 lines
29 KiB
Python
650 lines
29 KiB
Python
# SPDX-FileCopyrightText: 2026 Blender Authors
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import pathlib
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from mathutils import Vector
|
|
|
|
import bpy
|
|
|
|
# To update the test data, set the `BLENDER_TEST_UPDATE` environment variable:
|
|
# `BLENDER_TEST_UPDATE=1 ctest -R bl_node_copy_operators`
|
|
#
|
|
# Test cases listed below must have a test data in the "Tests" node group in the main test file.
|
|
# Each test case should have a frame node whose content is used as input for operators.
|
|
# Nodes labeled "external" are excluded from the grouping operator to test external links.
|
|
#
|
|
# A single test case can be tested using the '--subtest <NAME>' argument:
|
|
#
|
|
# ./bin/blender --factory-startup --python <SOURCEPATH>/tests/python/bl_node_copy_operators.py
|
|
# --
|
|
# --testdir <SOURCEPATH>/tests/files/node_group --subtest <NAME>
|
|
#
|
|
# Operators are applied to each frame and compared to expected results,
|
|
# stored in separate node trees:
|
|
# - bpy.data.node_groups["ExpectedMakeGroup"]: Result of node.make_group operator.
|
|
# - bpy.data.node_groups["ExpectedGroupInsert"]: Result of node.group_insert operator.
|
|
# - bpy.data.node_groups["ExpectedUngroup"]: Result of node.ungroup operator.
|
|
# - bpy.data.node_groups["ExpectedGroupSeparateCopy"]: Result of node.group_separate operator with type='COPY'.
|
|
# - bpy.data.node_groups["ExpectedGroupSeparateMove"]: Result of node.group_separate operator with type='MOVE'.
|
|
|
|
args = None
|
|
testfile = "node_copy_operators.blend"
|
|
|
|
# List of test cases expected in the test file.
|
|
# A set of options can be associated with each test case:
|
|
# - NODE_GROUP: Test starts as a node group instead of internal nodes. Only ungrouping and separating is tested.
|
|
# - GROUP_INSERT: There should be one node group labeled "external", all other nodes are inserted into the group.
|
|
all_test_cases = [
|
|
('test_empty', {}),
|
|
('test_socket_types', {}),
|
|
('test_structure_types', {}),
|
|
('test_panels', {}),
|
|
('test_panel_state', {}),
|
|
('test_external_links', {}),
|
|
('test_internal_links', {}),
|
|
('test_single_node_links', {}),
|
|
('test_skip_invisible_sockets', {}),
|
|
('test_multi_input_socket', {}),
|
|
('test_frame_nodes', {}),
|
|
('test_subtypes', {}),
|
|
('test_ui_settings', {}),
|
|
('test_vector_dimension', {}),
|
|
('test_default_input', {}),
|
|
('test_anim_data', {}),
|
|
('test_anim_data_ungroup', {'NODE_GROUP'}),
|
|
('test_extension_sockets', {}),
|
|
('test_pass_through', {'NODE_GROUP'}),
|
|
('test_insert_group_with_sockets', {'GROUP_INSERT'}),
|
|
('test_ungroup_multiple', {'NODE_GROUP'}),
|
|
('test_ungroup_proxy_nodes', {'NODE_GROUP'}),
|
|
('test_viewer_node', {}),
|
|
]
|
|
|
|
|
|
# Utility for mapping nodes and sockets to ground truth data.
|
|
class NodeMapping:
|
|
def __init__(self):
|
|
self.tree_map = dict()
|
|
self.node_map = dict()
|
|
self.socket_map = dict()
|
|
|
|
def add_tree(self, test_tree, expected_tree):
|
|
self.tree_map[test_tree] = expected_tree
|
|
|
|
def add_node(self, test_node, expected_node, map_group_node_trees):
|
|
assert self.node_map.get(test_node, expected_node) == expected_node
|
|
self.node_map[test_node] = expected_node
|
|
# Add all sockets of mapped nodes to their own dictionary, assuming the socket order is the same.
|
|
for test_socket, expected_socket in zip(test_node.inputs, expected_node.inputs):
|
|
assert self.socket_map.get(test_socket, expected_socket) == expected_socket
|
|
self.socket_map[test_socket] = expected_socket
|
|
for test_socket, expected_socket in zip(test_node.outputs, expected_node.outputs):
|
|
assert self.socket_map.get(test_socket, expected_socket) == expected_socket
|
|
self.socket_map[test_socket] = expected_socket
|
|
|
|
if map_group_node_trees and isinstance(expected_node, bpy.types.GeometryNodeGroup):
|
|
assert isinstance(test_node, bpy.types.GeometryNodeGroup)
|
|
self.add_tree(test_node.node_tree, expected_node.node_tree)
|
|
self.add_nodes_by_name(test_node.node_tree.nodes, expected_node.node_tree.nodes, map_group_node_trees)
|
|
|
|
def add_nodes_by_name(self, test_nodes, expected_nodes, map_group_node_trees):
|
|
expected_node_by_name = {node.name: node for node in expected_nodes}
|
|
for test_node in test_nodes:
|
|
# Raises key error if not all test nodes can be mapped.
|
|
expected_node = expected_node_by_name.pop(test_node.name)
|
|
self.add_node(test_node, expected_node, map_group_node_trees)
|
|
|
|
# Should map all expected nodes.
|
|
assert not expected_node_by_name
|
|
|
|
|
|
def open_test_file():
|
|
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / testfile))
|
|
|
|
|
|
def save_test_file():
|
|
bpy.ops.wm.save_mainfile(filepath=str(args.testdir / testfile))
|
|
|
|
|
|
def select_nodes(tree, selected_nodes, active_node=None):
|
|
for node in tree.nodes:
|
|
node.select = False
|
|
for node in selected_nodes:
|
|
node.select = True
|
|
tree.nodes.active = active_node if active_node else (selected_nodes[0] if selected_nodes else None)
|
|
|
|
|
|
def node_centroid(nodes):
|
|
return sum((node.location for node in nodes), Vector((0.0, 0.0))) / max(len(nodes), 1)
|
|
|
|
|
|
# Provide a valid context override to run node editor operators
|
|
def node_editor_context_override(context, tree, selected_nodes=[], active_node=None):
|
|
window = context.window if context.window else next(
|
|
window for window in context.window_manager.windows if window.screen is not None)
|
|
screen = context.screen if context.screen else window.screen
|
|
area = next(area for area in screen.areas if area.type == 'NODE_EDITOR')
|
|
region = next(region for region in area.regions if region.type == 'WINDOW')
|
|
space = area.spaces[0]
|
|
|
|
# Explicitly set the space tree, otherwise requires a context update to ensure
|
|
# that the space tree matches the active modifier tree.
|
|
space.node_tree = tree
|
|
# Relying on context.selected_nodes and context.active_node does not work for many/most node operators
|
|
# because they rely on actual selected/active nodes in the tree, rather than the context.
|
|
select_nodes(tree, selected_nodes, active_node)
|
|
|
|
context_override = context.copy()
|
|
context_override["window"] = window
|
|
context_override["screen"] = screen
|
|
context_override["area"] = area
|
|
context_override["region"] = region
|
|
context_override["space_data"] = space
|
|
context_override["selected_nodes"] = selected_nodes
|
|
context_override["active_node"] = tree.nodes.active
|
|
return context.temp_override(**context_override)
|
|
|
|
# Find all top-level frames in a tree.
|
|
|
|
|
|
def top_level_frames(tree):
|
|
for node in tree.nodes:
|
|
if isinstance(node, bpy.types.NodeFrame) and node.parent is None:
|
|
yield node
|
|
|
|
|
|
# Names of test cases found in a node tree.
|
|
def test_cases():
|
|
return list(name for name, _ in all_test_cases)
|
|
|
|
|
|
# Filter test cases based on script arguments.
|
|
def filtered_test_cases():
|
|
if args.subtest:
|
|
return filter(lambda test_case: test_case == args.subtest, test_cases())
|
|
else:
|
|
return test_cases()
|
|
|
|
|
|
def test_options(test_name):
|
|
return next(options for name, options in all_test_cases if name == test_name)
|
|
|
|
|
|
# Returns the frame node for a test case.
|
|
def find_test_frame(tree, test_name):
|
|
for frame in top_level_frames(tree):
|
|
if frame.label == test_name:
|
|
return frame
|
|
|
|
|
|
# Find nodes inside a top level frame of the given name.
|
|
# Returns two lists: internal_nodes, external_nodes
|
|
# Only internal nodes should be included in the grouping operation.
|
|
# External nodes are identified by "external" prefix.
|
|
def find_test_nodes(tree, test_name):
|
|
internal_nodes = list()
|
|
external_nodes = list()
|
|
for node in tree.nodes:
|
|
top_parent = node.parent
|
|
while top_parent:
|
|
if not top_parent.parent:
|
|
break
|
|
top_parent = top_parent.parent
|
|
|
|
if top_parent and isinstance(top_parent, bpy.types.NodeFrame) and top_parent.label == test_name:
|
|
if node.label.lower() == "external":
|
|
external_nodes.append(node)
|
|
else:
|
|
internal_nodes.append(node)
|
|
return internal_nodes, external_nodes
|
|
|
|
|
|
# Skip a test case.
|
|
def execute_skip(test_case, test_tree, expected_tree=None):
|
|
if expected_tree:
|
|
return NodeMapping()
|
|
|
|
|
|
# NOOP case when the test starts out with node groups.
|
|
def execute_node_group_noop(test_case, test_tree, expected_tree=None):
|
|
test_nodes_internal, test_nodes_external = find_test_nodes(test_tree, test_case)
|
|
|
|
if not expected_tree:
|
|
return
|
|
# Map resulting nodes to expected nodes.
|
|
expected_nodes_internal, expected_nodes_external = find_test_nodes(expected_tree, test_case)
|
|
mapping = NodeMapping()
|
|
mapping.add_nodes_by_name(test_nodes_internal, expected_nodes_internal, True)
|
|
mapping.add_nodes_by_name(test_nodes_external, expected_nodes_external, False)
|
|
return mapping
|
|
|
|
|
|
# Run the 'node.make_group' operator on test nodes.
|
|
def execute_make_group(test_case, test_tree, expected_tree=None):
|
|
# Special case: skip if the test starts as a node group.
|
|
if 'NODE_GROUP' in test_options(test_case):
|
|
return execute_node_group_noop(test_case, test_tree, expected_tree)
|
|
if 'GROUP_INSERT' in test_options(test_case):
|
|
return execute_skip(test_case, test_tree, expected_tree)
|
|
|
|
test_nodes_internal, test_nodes_external = find_test_nodes(test_tree, test_case)
|
|
|
|
with node_editor_context_override(bpy.context, test_tree, selected_nodes=test_nodes_internal):
|
|
bpy.ops.node.group_make()
|
|
group_node = test_tree.nodes.active
|
|
# Re-attach to the parent frame to identify the operator result.
|
|
group_node.parent = find_test_frame(test_tree, test_case)
|
|
|
|
if not expected_tree:
|
|
return
|
|
# Map resulting nodes to expected nodes.
|
|
expected_nodes_internal, expected_nodes_external = find_test_nodes(expected_tree, test_case)
|
|
assert len(expected_nodes_internal) == 1
|
|
expected_node = expected_nodes_internal[0]
|
|
mapping = NodeMapping()
|
|
mapping.add_node(group_node, expected_node, True)
|
|
mapping.add_nodes_by_name(test_nodes_external, expected_nodes_external, False)
|
|
return mapping
|
|
|
|
|
|
# Run the 'node.group_insert' operator on test nodes.
|
|
def execute_group_insert(test_case, test_tree, expected_tree=None):
|
|
# Special case: skip if the test starts as a node group.
|
|
if 'NODE_GROUP' in test_options(test_case):
|
|
return execute_node_group_noop(test_case, test_tree, expected_tree)
|
|
|
|
test_nodes_internal, test_nodes_external = find_test_nodes(test_tree, test_case)
|
|
centroid = node_centroid(test_nodes_internal)
|
|
|
|
# Special case: Expect an existing group node to insert into.
|
|
if 'GROUP_INSERT' in test_options(test_case):
|
|
print(len(test_nodes_internal), [n.name for n in test_nodes_external])
|
|
# Should have one node group labeled "external".
|
|
assert len(test_nodes_external) == 1
|
|
group_node = test_nodes_external[0]
|
|
# Ensure single-user node group, so that inserting nodes does not modify a shared tree.
|
|
group_node.node_tree = group_node.node_tree.copy()
|
|
with node_editor_context_override(bpy.context, test_tree, selected_nodes=test_nodes_internal + [group_node], active_node=group_node):
|
|
bpy.ops.node.group_insert()
|
|
|
|
if not expected_tree:
|
|
return
|
|
# Map resulting nodes to expected nodes.
|
|
expected_nodes_internal, expected_nodes_external = find_test_nodes(expected_tree, test_case)
|
|
assert len(expected_nodes_internal) == 0
|
|
assert len(expected_nodes_external) == 1
|
|
expected_node = expected_nodes_external[0]
|
|
mapping = NodeMapping()
|
|
mapping.add_node(group_node, expected_node, True)
|
|
mapping.add_nodes_by_name(test_nodes_external, expected_nodes_external, False)
|
|
return mapping
|
|
|
|
# Make empty node group.
|
|
group_tree = bpy.data.node_groups.new(f"{test_case}_GroupInsert", 'GeometryNodeTree')
|
|
# Copy nodes into the tree to force deduplication testing.
|
|
# Note: this is not ideal since it depends on yet another operator,
|
|
# but there is no way to retain the original nodes when inserting to enforce duplicate names.
|
|
with node_editor_context_override(bpy.context, test_tree, selected_nodes=test_nodes_internal):
|
|
bpy.ops.node.clipboard_copy()
|
|
with node_editor_context_override(bpy.context, group_tree):
|
|
bpy.ops.node.clipboard_paste()
|
|
# Make a group node with the new tree.
|
|
with node_editor_context_override(bpy.context, test_tree):
|
|
bpy.ops.node.add_node(
|
|
settings=[
|
|
{"name": "name", "value": f"'{test_case}_GroupNode'"},
|
|
{"name": "node_tree", "value": f"bpy.data.node_groups['{group_tree.name}']"},
|
|
],
|
|
type='GeometryNodeGroup',
|
|
)
|
|
group_node = test_tree.nodes.active
|
|
group_node.parent = find_test_frame(test_tree, test_case)
|
|
group_node.location = centroid
|
|
# Insert nodes into the group.
|
|
with node_editor_context_override(bpy.context, test_tree, selected_nodes=test_nodes_internal + [group_node], active_node=group_node):
|
|
bpy.ops.node.group_insert()
|
|
|
|
if not expected_tree:
|
|
return
|
|
# Map resulting nodes to expected nodes.
|
|
expected_nodes_internal, expected_nodes_external = find_test_nodes(expected_tree, test_case)
|
|
assert len(expected_nodes_internal) == 1
|
|
expected_node = expected_nodes_internal[0]
|
|
mapping = NodeMapping()
|
|
mapping.add_node(group_node, expected_node, True)
|
|
mapping.add_nodes_by_name(test_nodes_external, expected_nodes_external, False)
|
|
return mapping
|
|
|
|
|
|
# Run the 'node.ungroup' operator on test nodes.
|
|
def execute_ungroup(test_case, test_tree, expected_tree=None):
|
|
if 'GROUP_INSERT' in test_options(test_case):
|
|
return execute_skip(test_case, test_tree, expected_tree)
|
|
|
|
test_nodes_internal, test_nodes_external = find_test_nodes(test_tree, test_case)
|
|
|
|
with node_editor_context_override(bpy.context, test_tree, selected_nodes=test_nodes_internal):
|
|
bpy.ops.node.group_ungroup()
|
|
ungrouped_nodes = [node for node in test_tree.nodes if node.select]
|
|
# Re-attach to the parent frame to identify the operator result.
|
|
for node in ungrouped_nodes:
|
|
if node.parent is None:
|
|
node.parent = find_test_frame(test_tree, test_case)
|
|
|
|
if not expected_tree:
|
|
return
|
|
# Map resulting nodes to expected nodes.
|
|
expected_nodes_internal, expected_nodes_external = find_test_nodes(expected_tree, test_case)
|
|
mapping = NodeMapping()
|
|
mapping.add_nodes_by_name(ungrouped_nodes, expected_nodes_internal, False)
|
|
mapping.add_nodes_by_name(test_nodes_external, expected_nodes_external, False)
|
|
return mapping
|
|
|
|
|
|
# Run the 'node.group_separate' operator on test nodes.
|
|
# type can be 'COPY' or 'MOVE'.
|
|
def execute_group_separate(type, test_case, test_tree, expected_tree=None):
|
|
if 'GROUP_INSERT' in test_options(test_case):
|
|
return execute_skip(test_case, test_tree, expected_tree)
|
|
|
|
test_nodes_internal, test_nodes_external = find_test_nodes(test_tree, test_case)
|
|
|
|
# Test nodes should be node groups
|
|
# Ensure single-user node group, so that moving nodes out does not modify a shared tree.
|
|
all_group_trees = dict()
|
|
for group_node in test_nodes_internal:
|
|
assert isinstance(group_node, bpy.types.GeometryNodeGroup)
|
|
group_tree = all_group_trees.get(group_node.node_tree)
|
|
if not group_tree:
|
|
group_tree = group_node.node_tree.copy()
|
|
all_group_trees[group_node.node_tree] = group_tree
|
|
group_node.node_tree = group_tree
|
|
|
|
with node_editor_context_override(bpy.context, test_tree, selected_nodes=[group_node]):
|
|
bpy.ops.node.group_edit(exit=False)
|
|
# Stay in current context so that the tree path has a valid "parent" tree to copy nodes into.
|
|
|
|
# Select all nodes for separating.
|
|
select_nodes(group_node.node_tree, selected_nodes=group_node.node_tree.nodes)
|
|
bpy.ops.node.group_separate(type=type)
|
|
|
|
separated_nodes = [node for node in test_tree.nodes if node.select and node.parent is None]
|
|
centroid = node_centroid(separated_nodes)
|
|
# Re-attach to the parent frame to identify the operator result.
|
|
for node in separated_nodes:
|
|
offset = node.location - centroid
|
|
node.parent = find_test_frame(test_tree, test_case)
|
|
node.location = group_node.location + Vector((0, -1000)) + offset
|
|
|
|
if not expected_tree:
|
|
return
|
|
# Map resulting nodes to expected nodes.
|
|
result_nodes_internal, _ = find_test_nodes(test_tree, test_case)
|
|
expected_nodes_internal, expected_nodes_external = find_test_nodes(expected_tree, test_case)
|
|
mapping = NodeMapping()
|
|
mapping.add_nodes_by_name(result_nodes_internal, expected_nodes_internal, False)
|
|
mapping.add_nodes_by_name(test_nodes_external, expected_nodes_external, False)
|
|
return mapping
|
|
|
|
|
|
class AbstractNodeCopyOperatorTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls._tempdir = tempfile.TemporaryDirectory()
|
|
cls.tempdir = pathlib.Path(cls._tempdir.name)
|
|
|
|
def setUp(self):
|
|
self.assertTrue(args.testdir.exists(),
|
|
'Test dir {0} should exist'.format(args.testdir))
|
|
open_test_file()
|
|
|
|
def tearDown(self):
|
|
self._tempdir.cleanup()
|
|
|
|
def compare_value(self, bl_idname, value_a, value_b):
|
|
# Note: Socket subtypes are not actually subclasses of the base types.
|
|
# We rely on name prefixes instead of python issubclass tests here, to keep this check compact.
|
|
vector_socket_prefixes = ["NodeSocketColor", "NodeSocketMatrix", "NodeSocketRotation", "NodeSocketVector"]
|
|
if any(bl_idname.startswith(prefix) for prefix in vector_socket_prefixes):
|
|
for comp_a, comp_b in zip(value_a, value_b):
|
|
self.assertEqual(comp_a, comp_b)
|
|
else:
|
|
self.assertEqual(value_a, value_b)
|
|
|
|
# Validate node socket properties and connections in the tree.
|
|
# Links to/from the socket are compared to expected values using the node and socket maps.
|
|
def compare_socket(self, test_socket, mapping):
|
|
expected_socket = mapping.socket_map.get(test_socket, None)
|
|
self.assertIsNotNone(expected_socket)
|
|
with self.subTest(test_socket=test_socket.name, expected_socket=expected_socket.name):
|
|
# Generic socket properties
|
|
self.assertEqual(test_socket.name, expected_socket.name)
|
|
self.assertEqual(test_socket.bl_idname, expected_socket.bl_idname)
|
|
self.assertEqual(test_socket.type, expected_socket.type)
|
|
self.assertEqual(test_socket.description, expected_socket.description)
|
|
self.assertEqual(test_socket.is_output, expected_socket.is_output)
|
|
self.assertEqual(test_socket.hide, expected_socket.hide)
|
|
|
|
# Input value
|
|
if not expected_socket.is_output:
|
|
self.assertEqual(test_socket.hide_value, expected_socket.hide_value)
|
|
test_has_value = hasattr(test_socket, "default_value")
|
|
expected_has_value = hasattr(expected_socket, "default_value")
|
|
self.assertEqual(test_has_value, expected_has_value)
|
|
if test_has_value and expected_has_value:
|
|
self.compare_value(
|
|
expected_socket.bl_idname,
|
|
test_socket.default_value,
|
|
expected_socket.default_value)
|
|
|
|
# Links
|
|
self.assertEqual(test_socket.is_linked, expected_socket.is_linked)
|
|
if expected_socket.is_linked:
|
|
self.assertEqual(len(test_socket.links), len(expected_socket.links))
|
|
# Compare connected sockets as an unordered set, because link order might
|
|
# change between generated and tested data and should not be relevant.
|
|
if expected_socket.is_output:
|
|
def link_target(link): return (link.to_node, link.to_socket)
|
|
else:
|
|
def link_target(link): return (link.from_node, link.from_socket)
|
|
expected_targets = set(link_target(link) for link in expected_socket.links)
|
|
|
|
for test_link in test_socket.links:
|
|
test_target = link_target(test_link)
|
|
expected_target = (mapping.node_map[test_target[0]], mapping.socket_map[test_target[1]])
|
|
self.assertIn(expected_target, expected_targets)
|
|
|
|
# Validate a node against the expected data using the node map.
|
|
def compare_node(self, test_node, expected_node, mapping):
|
|
self.assertEqual(len(test_node.inputs), len(expected_node.inputs))
|
|
self.assertEqual(len(test_node.outputs), len(expected_node.outputs))
|
|
for test_socket in test_node.inputs:
|
|
self.compare_socket(test_socket, mapping)
|
|
for test_socket in test_node.outputs:
|
|
self.compare_socket(test_socket, mapping)
|
|
|
|
# Validate the tree interface settings of a node group.
|
|
def compare_tree_interface(self, test_tree, expected_tree):
|
|
test_items = test_tree.interface.items_tree
|
|
expected_items = expected_tree.interface.items_tree
|
|
self.assertEqual(len(test_items), len(expected_items))
|
|
for test_item, expected_item in zip(test_items, expected_items):
|
|
self.assertEqual(test_item.index, expected_item.index)
|
|
self.assertEqual(test_item.item_type, expected_item.item_type)
|
|
# Find expected parent panel by index from the expected items list.
|
|
# Item with index -1 is the root panel and can be ignored.
|
|
if test_item.parent.index >= 0:
|
|
expected_parent = expected_items[test_item.parent.index]
|
|
self.assertEqual(expected_parent, expected_item.parent)
|
|
else:
|
|
self.assertEqual(test_item.parent.index, -1)
|
|
self.assertEqual(test_item.position, expected_item.position)
|
|
|
|
if expected_item.item_type == 'SOCKET':
|
|
# General properties.
|
|
self.assertEqual(test_item.bl_socket_idname, expected_item.bl_socket_idname)
|
|
self.assertEqual(test_item.in_out, expected_item.in_out)
|
|
self.assertEqual(test_item.name, expected_item.name)
|
|
self.assertEqual(test_item.description, expected_item.description)
|
|
self.assertEqual(test_item.optional_label, expected_item.optional_label)
|
|
self.assertEqual(test_item.socket_type, expected_item.socket_type)
|
|
self.assertEqual(test_item.structure_type, expected_item.structure_type)
|
|
self.assertEqual(test_item.is_panel_toggle, expected_item.is_panel_toggle)
|
|
self.assertEqual(test_item.layer_selection_field, expected_item.layer_selection_field)
|
|
|
|
# Default value.
|
|
self.assertEqual(test_item.hide_value, expected_item.hide_value)
|
|
self.assertEqual(test_item.hide_in_modifier, expected_item.hide_in_modifier)
|
|
self.assertEqual(test_item.default_input, expected_item.default_input)
|
|
self.assertEqual(test_item.menu_expanded, expected_item.menu_expanded)
|
|
if hasattr(expected_item, "default_value"):
|
|
self.compare_value(
|
|
expected_item.bl_socket_idname,
|
|
test_item.default_value,
|
|
expected_item.default_value)
|
|
if hasattr(expected_item, "min_value"):
|
|
self.assertEqual(test_item.min_value, expected_item.min_value)
|
|
if hasattr(expected_item, "max_value"):
|
|
self.assertEqual(test_item.max_value, expected_item.max_value)
|
|
if hasattr(expected_item, "subtype"):
|
|
self.assertEqual(test_item.subtype, expected_item.subtype)
|
|
if hasattr(expected_item, "dimensions"):
|
|
self.assertEqual(test_item.dimensions, expected_item.dimensions)
|
|
|
|
# Attribute settings.
|
|
self.assertEqual(test_item.attribute_domain, expected_item.attribute_domain)
|
|
self.assertEqual(test_item.default_attribute_name, expected_item.default_attribute_name)
|
|
|
|
if expected_item.item_type == 'PANEL':
|
|
self.assertEqual(test_item.name, expected_item.name)
|
|
self.assertEqual(test_item.description, expected_item.description)
|
|
self.assertEqual(test_item.default_closed, expected_item.default_closed)
|
|
|
|
def compare(self, mapping):
|
|
for test_tree, expected_tree in mapping.tree_map.items():
|
|
self.compare_tree_interface(test_tree, expected_tree)
|
|
for test_node, expected_node in mapping.node_map.items():
|
|
self.compare_node(test_node, expected_node, mapping)
|
|
|
|
|
|
class NodeMakeGroupTest(AbstractNodeCopyOperatorTest):
|
|
def test_make_group(self):
|
|
test_tree = bpy.data.node_groups["Tests"]
|
|
expected_tree = bpy.data.node_groups["ExpectedMakeGroup"]
|
|
for test_case in filtered_test_cases():
|
|
with self.subTest(case=test_case):
|
|
mapping = execute_make_group(test_case, test_tree, expected_tree)
|
|
self.compare(mapping)
|
|
|
|
def test_group_insert(self):
|
|
test_tree = bpy.data.node_groups["Tests"]
|
|
expected_tree = bpy.data.node_groups["ExpectedGroupInsert"]
|
|
for test_case in filtered_test_cases():
|
|
with self.subTest(case=test_case):
|
|
mapping = execute_group_insert(test_case, test_tree, expected_tree)
|
|
self.compare(mapping)
|
|
|
|
def test_ungroup(self):
|
|
# Start with grouped nodes.
|
|
test_tree = bpy.data.node_groups["ExpectedMakeGroup"]
|
|
expected_tree = bpy.data.node_groups["ExpectedUngroup"]
|
|
for test_case in filtered_test_cases():
|
|
with self.subTest(case=test_case):
|
|
mapping = execute_ungroup(test_case, test_tree, expected_tree)
|
|
self.compare(mapping)
|
|
|
|
def test_group_separate_copy(self):
|
|
# Start with grouped nodes.
|
|
test_tree = bpy.data.node_groups["ExpectedMakeGroup"]
|
|
expected_tree = bpy.data.node_groups["ExpectedGroupSeparateCopy"]
|
|
for test_case in filtered_test_cases():
|
|
with self.subTest(case=test_case):
|
|
mapping = execute_group_separate('COPY', test_case, test_tree, expected_tree)
|
|
self.compare(mapping)
|
|
|
|
def test_group_separate_move(self):
|
|
# Start with grouped nodes.
|
|
test_tree = bpy.data.node_groups["ExpectedMakeGroup"]
|
|
expected_tree = bpy.data.node_groups["ExpectedGroupSeparateMove"]
|
|
for test_case in filtered_test_cases():
|
|
with self.subTest(case=test_case):
|
|
mapping = execute_group_separate('MOVE', test_case, test_tree, expected_tree)
|
|
self.compare(mapping)
|
|
|
|
|
|
################
|
|
# Code for generating ground truth test data, sharing functions with test code.
|
|
|
|
def copy_tree(src_tree, dst_modifier):
|
|
ob = dst_modifier.id_data
|
|
ob.modifiers.active = dst_modifier
|
|
|
|
# Clean up old data
|
|
dst_modifier.node_group = None
|
|
# Note: calling bpy.data.orphans_purge() directly does not work for some reason.
|
|
bpy.ops.outliner.orphans_purge()
|
|
|
|
dst_tree = src_tree.copy()
|
|
dst_tree.name = dst_modifier.name
|
|
dst_modifier.node_group = dst_tree
|
|
|
|
# Ensure a single user action for the tree to avoid destroying animation data.
|
|
if dst_tree.animation_data.action and dst_tree.animation_data.action.users > 1:
|
|
dst_tree.animation_data.action = dst_tree.animation_data.action.copy()
|
|
|
|
return dst_tree
|
|
|
|
|
|
def generate_test_data():
|
|
open_test_file()
|
|
|
|
test_tree = bpy.data.node_groups["Tests"]
|
|
ob = bpy.data.objects["TestObject"]
|
|
|
|
expected_tree__make_group = copy_tree(test_tree, ob.modifiers["ExpectedMakeGroup"])
|
|
# Use result of grouping as starting point for ungrouping and separating.
|
|
for test_case in test_cases():
|
|
execute_make_group(test_case, expected_tree__make_group)
|
|
|
|
expected_tree__group_insert = copy_tree(test_tree, ob.modifiers["ExpectedGroupInsert"])
|
|
expected_tree__ungroup = copy_tree(expected_tree__make_group, ob.modifiers["ExpectedUngroup"])
|
|
expected_tree__group_separate_copy = copy_tree(expected_tree__make_group, ob.modifiers["ExpectedGroupSeparateCopy"])
|
|
expected_tree__group_separate_move = copy_tree(expected_tree__make_group, ob.modifiers["ExpectedGroupSeparateMove"])
|
|
for test_case in test_cases():
|
|
execute_group_insert(test_case, expected_tree__group_insert)
|
|
execute_ungroup(test_case, expected_tree__ungroup)
|
|
execute_group_separate('COPY', test_case, expected_tree__group_separate_copy)
|
|
execute_group_separate('MOVE', test_case, expected_tree__group_separate_move)
|
|
|
|
save_test_file()
|
|
|
|
################
|
|
|
|
|
|
def main():
|
|
global args
|
|
import argparse
|
|
import os
|
|
|
|
if '--' in sys.argv:
|
|
argv = [sys.argv[0]] + sys.argv[sys.argv.index('--') + 1:]
|
|
else:
|
|
argv = sys.argv
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--testdir', required=True, type=pathlib.Path)
|
|
parser.add_argument('--subtest', default=None, help="Select a single test case")
|
|
args, remaining = parser.parse_known_args(argv)
|
|
|
|
if os.getenv("BLENDER_TEST_UPDATE") is not None:
|
|
generate_test_data()
|
|
|
|
unittest.main(argv=remaining)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|