# 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 ' argument: # # ./bin/blender --factory-startup --python /tests/python/bl_node_copy_operators.py # -- # --testdir /tests/files/node_group --subtest # # 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()