blender/tests/python/bl_node_copy_operators.py
Lukas Tönne f7f4071e2e Fix #160692: Auto-removal of viewer socket crashes group operator
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
2026-06-26 14:59:43 +02:00

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()