Nodes: Automated tests for group operators

This adds tests for a number of related operators:
- `node.group_make`
- `node.group_ungroup`
- `node.group_insert`
- `node.group_separate`

The tests are based on a blend file with a wide range of nodes and sockets, testing various features like types, UI options, and other settings. The file also contains "ground truth" data in the form of node groups, which the operator results are compared against.

Test cases are organized using frame nodes: any nodes inside a top-level frame are considered a sub-test and are grouped together.

The `Tests` node tree contains the user-defined test cases. Ground truth data for comparison is generated from the `Tests` tree, by passing a `--generate` argument to the test script. This creates a derived node tree for each of the main operators, applying the operator to each of the sub-tests frames.

Example invocation:
`./bin/blender --background --factory-startup --python ../blender/tests/python/bl_node_copy_operators.py
-- --testdir ../blender/tests/files/node_group --generate`

Generated node trees are also linked in modifiers of the main `TestObject` in the scene. This is necessary for the `group_separate` operator which requires a valid editor _tree path_ to copy or move nodes into the parent tree. It's also convenient for selecting and comparing generated node trees.

Pull Request: https://projects.blender.org/blender/blender/pulls/151785
This commit is contained in:
Lukas Tönne 2026-01-07 08:59:42 +01:00
parent 4edddd4766
commit 1888655f6e
3 changed files with 562 additions and 0 deletions

View file

@ -678,6 +678,13 @@ if(TEST_SRC_DIR_EXISTS)
--
--testdir "${TEST_SRC_DIR}/node_group"
)
add_blender_test(
bl_node_copy_operators
--python ${CMAKE_CURRENT_LIST_DIR}/bl_node_copy_operators.py
--
--testdir "${TEST_SRC_DIR}/node_group"
)
endif()
# ------------------------------------------------------------------------------

View file

@ -0,0 +1,552 @@
# 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
# This test is based around the "Tests" node group in the test file.
# Test cases are added by frame nodes in the node group. Each frame represents one sub-test.
# 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'.
#
# The script can be invoked with an additional argument '--generate' to update the ground truth test data.
# Nodes in the "Expected***" node trees are replaced with the result of operators applied to the "Tests" node tree.
#
# ./bin/blender --factory-startup --python <SOURCEPATH>/tests/python/bl_node_copy_operators.py
# --
# --testdir <SOURCEPATH>/tests/files/node_group --generate
args = None
testfile = "node_copy_operators.blend"
# 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):
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):
self.socket_map[test_socket] = expected_socket
for test_socket, expected_socket in zip(test_node.outputs, expected_node.outputs):
self.socket_map[test_socket] = expected_socket
def extend_nodes(self, test_nodes, expected_nodes):
for test_node, expected_node in zip(test_nodes, expected_nodes):
self.add_node(test_node, expected_node)
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(tree):
return [frame.label for frame in top_level_frames(tree)]
# Filter test cases based on script arguments.
def filtered_test_cases(tree):
if args.subtest:
return filter(lambda test_case: test_case == args.subtest, test_cases(tree))
else:
return test_cases(tree)
# 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.
def find_expected_nodes(tree, test_name):
test_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:
test_nodes.append(node)
return test_nodes
# Run the 'node.make_group' operator on test nodes.
def execute_make_group(test_case, test_tree, expected_tree=None):
test_nodes = find_expected_nodes(test_tree, test_case)
with node_editor_context_override(bpy.context, test_tree, selected_nodes=test_nodes):
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 expected_tree:
# Map resulting nodes to expected nodes.
expected_nodes = find_expected_nodes(expected_tree, test_case)
assert len(expected_nodes) == 1
expected_node = expected_nodes[0]
mapping = NodeMapping()
mapping.add_tree(group_node.node_tree, expected_node.node_tree)
mapping.add_node(group_node, expected_node)
mapping.extend_nodes(group_node.node_tree.nodes, expected_node.node_tree.nodes)
return mapping
# Run the 'node.group_insert' operator on test nodes.
def execute_group_insert(test_case, test_tree, expected_tree=None):
test_nodes = find_expected_nodes(test_tree, test_case)
centroid = node_centroid(test_nodes)
# 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):
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 + [group_node], active_node=group_node):
bpy.ops.node.group_insert()
if expected_tree:
# Map resulting nodes to expected nodes.
expected_nodes = find_expected_nodes(expected_tree, test_case)
assert len(expected_nodes) == 1
expected_node = expected_nodes[0]
mapping = NodeMapping()
mapping.add_tree(group_node.node_tree, expected_node.node_tree)
mapping.add_node(group_node, expected_node)
mapping.extend_nodes(group_node.node_tree.nodes, expected_node.node_tree.nodes)
return mapping
# Run the 'node.ungroup' operator on test nodes.
def execute_ungroup(test_case, test_tree, expected_tree=None):
test_nodes = find_expected_nodes(test_tree, test_case)
with node_editor_context_override(bpy.context, test_tree, selected_nodes=test_nodes):
bpy.ops.node.group_ungroup()
internal_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 internal_nodes:
node.parent = find_test_frame(test_tree, test_case)
if expected_tree:
# Map resulting nodes to expected nodes.
expected_nodes = find_expected_nodes(expected_tree, test_case)
mapping = NodeMapping()
mapping.extend_nodes(internal_nodes, expected_nodes)
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):
test_nodes = find_expected_nodes(test_tree, test_case)
# Test nodes should be node groups
assert len(test_nodes) == 1
group_node = test_nodes[0]
assert isinstance(group_node, bpy.types.GeometryNodeGroup)
# Ensure single-user node group, so that moving nodes out 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=[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]
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 expected_tree:
# Map resulting nodes to expected nodes.
result_nodes = find_expected_nodes(test_tree, test_case)
expected_nodes = find_expected_nodes(expected_tree, test_case)
mapping = NodeMapping()
mapping.extend_nodes(result_nodes, expected_nodes)
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()
self.assertEqual(bpy.data.version, (5, 1, 16))
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)
# 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))
for test_link, expected_link in zip(test_socket.links, expected_socket.links):
# If there is no entry in the mapping for the connected test socket yet then the expected socket is use as default.
# External connections are not usually added to the map to keep test cases simple.
# This ensures that any socket with external links is in fact connected,
# without specifying the exact external node.
if expected_socket.is_output:
self.assertEqual(
mapping.node_map.setdefault(
test_link.to_node,
expected_link.to_node),
expected_link.to_node)
self.assertEqual(
mapping.socket_map.setdefault(
test_link.to_socket,
expected_link.to_socket),
expected_link.to_socket)
else:
self.assertEqual(
mapping.node_map.setdefault(
test_link.from_node,
expected_link.from_node),
expected_link.from_node)
self.assertEqual(
mapping.socket_map.setdefault(
test_link.from_socket,
expected_link.from_socket),
expected_link.from_socket)
# 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):
# New sockets may be added to this dictionary while comparing nodes!
# Make a copy of the original nodes that should be compared.
orig_test_nodes = list(mapping.node_map.keys())
orig_expected_nodes = list(mapping.node_map.values())
for test_tree, expected_tree in mapping.tree_map.items():
self.compare_tree_interface(test_tree, expected_tree)
for test_node, expected_node in zip(orig_test_nodes, orig_expected_nodes):
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(test_tree):
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(test_tree):
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(test_tree):
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(test_tree):
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(test_tree):
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(test_tree):
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(test_tree):
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
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(
'--generate',
action='store_true',
help="Generate ground truth test data instead of running the test")
parser.add_argument('--subtest', default=None, help="Select a single test case")
args, remaining = parser.parse_known_args(argv)
if args.generate:
generate_test_data()
else:
unittest.main(argv=remaining)
if __name__ == "__main__":
main()