# SPDX-FileCopyrightText: 2021-2023 Blender Foundation # # SPDX-License-Identifier: GPL-2.0-or-later import pathlib import sys import unittest import tempfile import math from dataclasses import dataclass import bpy args = None type_info = { ("VALUE", "NONE"): "NodeSocketFloat", ("VALUE", "UNSIGNED"): "NodeSocketFloatUnsigned", ("VALUE", "PERCENTAGE"): "NodeSocketFloatPercentage", ("VALUE", "FACTOR"): "NodeSocketFloatFactor", ("VALUE", "ANGLE"): "NodeSocketFloatAngle", ("VALUE", "TIME"): "NodeSocketFloatTime", ("VALUE", "TIME_ABSOLUTE"): "NodeSocketFloatTimeAbsolute", ("VALUE", "DISTANCE"): "NodeSocketFloatDistance", ("INT", "NONE"): "NodeSocketInt", ("INT", "UNSIGNED"): "NodeSocketIntUnsigned", ("INT", "PERCENTAGE"): "NodeSocketIntPercentage", ("INT", "FACTOR"): "NodeSocketIntFactor", ("BOOLEAN", "NONE"): "NodeSocketBool", ("ROTATION", "NONE"): "NodeSocketRotation", ("VECTOR", "NONE"): "NodeSocketVector", ("VECTOR", "TRANSLATION"): "NodeSocketVectorTranslation", ("VECTOR", "DIRECTION"): "NodeSocketVectorDirection", ("VECTOR", "VELOCITY"): "NodeSocketVectorVelocity", ("VECTOR", "ACCELERATION"): "NodeSocketVectorAcceleration", ("VECTOR", "EULER"): "NodeSocketVectorEuler", ("VECTOR", "XYZ"): "NodeSocketVectorXYZ", ("RGBA", "NONE"): "NodeSocketColor", ("STRING", "NONE"): "NodeSocketString", ("SHADER", "NONE"): "NodeSocketShader", ("OBJECT", "NONE"): "NodeSocketObject", ("IMAGE", "NONE"): "NodeSocketImage", ("GEOMETRY", "NONE"): "NodeSocketGeometry", ("COLLECTION", "NONE"): "NodeSocketCollection", ("TEXTURE", "NONE"): "NodeSocketTexture", ("MATERIAL", "NONE"): "NodeSocketMaterial", } @dataclass class SocketSpec(): name: str identifier: str type: str subtype: str = 'NONE' hide_value: bool = False hide_in_modifier: bool = False default_value: object = None min_value: object = None max_value: object = None internal_links: int = 1 external_links: int = 1 @property def idname(self): return type_info[(self.type, self.subtype)] class AbstractNodeGroupInterfaceTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.testdir = args.testdir cls._tempdir = tempfile.TemporaryDirectory() cls.tempdir = pathlib.Path(cls._tempdir.name) def setUp(self): self.assertTrue(self.testdir.exists(), 'Test dir {0} should exist'.format(self.testdir)) def tearDown(self): self._tempdir.cleanup() def subtype_compare(self, value, expected, subtype): # Rounding errors introduced at various levels, only check for roughly expected values. if subtype in {'ANGLE', 'EULER'}: # Angle values are shown in degrees in the UI, but stored as radians. self.assertAlmostEqual(value, math.radians(expected)) else: self.assertAlmostEqual(value, expected) # Test properties of a node group item and associated node socket with spec data. def compare_group_socket_to_spec(self, item, node, spec: SocketSpec, test_links=True): group = item.id_data # Examine the interface item. self.assertEqual(item.name, spec.name) self.assertEqual(item.bl_socket_idname, spec.idname) self.assertEqual(item.identifier, spec.identifier) # Types that have subtypes. if spec.type in {'VALUE', 'INT', 'VECTOR'}: self.assertEqual(item.subtype, spec.subtype) self.assertEqual(item.hide_value, spec.hide_value) self.assertEqual(item.hide_in_modifier, spec.hide_in_modifier) if spec.type in {'VALUE', 'INT'}: self.subtype_compare(item.default_value, spec.default_value, spec.subtype) self.assertEqual(item.min_value, spec.min_value) self.assertEqual(item.max_value, spec.max_value) elif spec.type == 'VECTOR': self.subtype_compare(item.default_value[0], spec.default_value[0], spec.subtype) self.subtype_compare(item.default_value[1], spec.default_value[1], spec.subtype) self.subtype_compare(item.default_value[2], spec.default_value[2], spec.subtype) self.assertEqual(item.min_value, spec.min_value) self.assertEqual(item.max_value, spec.max_value) elif spec.type == 'RGBA': # Colors stored as int8 internally, enough rounding error to require fuzzy test. self.assertAlmostEqual(item.default_value[0], spec.default_value[0]) self.assertAlmostEqual(item.default_value[1], spec.default_value[1]) self.assertAlmostEqual(item.default_value[2], spec.default_value[2]) self.assertAlmostEqual(item.default_value[3], spec.default_value[3]) elif spec.type in {'STRING', 'BOOLEAN', 'MATERIAL', 'TEXTURE', 'OBJECT', 'COLLECTION', 'IMAGE'}: self.assertEqual(item.default_value, spec.default_value) elif spec.type in {'SHADER', 'GEOMETRY'}: pass else: # Add socket type testing above if this happens. self.fail("Socket type not supported by test") # Examine the node socket. if 'INPUT' in item.in_out: socket = next(s for s in node.inputs if s.identifier == spec.identifier) self.assertIsNotNone(socket, f"Could not find socket for group input identifier {spec.identifier}") self.assertEqual(socket.name, spec.name) self.assertEqual(socket.bl_idname, spec.idname) self.assertEqual(socket.type, spec.type) self.assertEqual(socket.hide_value, spec.hide_value) if test_links: self.assertEqual(len(socket.links), spec.external_links, f"Socket should have exactly {spec.external_links} external connections") input_node = next(n for n in group.nodes if n.bl_idname == 'NodeGroupInput') self.assertIsNotNone(input_node, "Could not find an input node in the group") socket = next(s for s in input_node.outputs if s.identifier == spec.identifier) self.assertIsNotNone( socket, f"Could not find group input socket for group input identifier {spec.identifier}") self.assertEqual(socket.name, spec.name) self.assertEqual(socket.bl_idname, spec.idname) self.assertEqual(socket.type, spec.type) self.assertEqual(socket.hide_value, spec.hide_value) if test_links: self.assertEqual(len(socket.links), spec.internal_links, f"Socket should have exactly {spec.internal_links} internal connections") if 'OUTPUT' in item.in_out: socket = next(s for s in node.outputs if s.identifier == spec.identifier) self.assertIsNotNone(socket, f"Could not find socket for group output identifier {spec.identifier}") self.assertEqual(socket.name, spec.name) self.assertEqual(socket.bl_idname, spec.idname) self.assertEqual(socket.type, spec.type) self.assertEqual(socket.hide_value, spec.hide_value) if test_links: self.assertEqual(len(socket.links), spec.external_links, f"Socket should have exactly {spec.external_links} external connections") output_node = next(n for n in group.nodes if n.bl_idname == 'NodeGroupOutput') self.assertIsNotNone(output_node, "Could not find an output node in the group") socket = next(s for s in output_node.inputs if s.identifier == spec.identifier) self.assertIsNotNone( socket, f"Could not find group output socket for group output identifier {spec.identifier}") self.assertEqual(socket.name, spec.name) self.assertEqual(socket.bl_idname, spec.idname) self.assertEqual(socket.type, spec.type) self.assertEqual(socket.hide_value, spec.hide_value) if test_links: self.assertEqual(len(socket.links), spec.internal_links, f"Socket should have exactly {spec.internal_links} internal connections") # Test node group items and associated node sockets with spec data. def compare_group_to_specs(self, group, node, specs, test_links=True): for index, spec in enumerate(specs): self.compare_group_socket_to_spec(group.interface.items_tree[index], node, spec, test_links=test_links) class NodeGroupVersioning36Test(AbstractNodeGroupInterfaceTest): def open_file(self): bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "nodegroup36.blend")) self.assertEqual(bpy.data.version, (3, 6, 11)) def test_load_compositor_nodes(self): self.open_file() tree = bpy.data.scenes['Scene'].node_tree group = bpy.data.node_groups.get('NodeGroup') self.assertIsNotNone(group, "Compositor node group not found") node = tree.nodes['Group'] self.assertEqual(node.node_tree, group, "Node group must use compositor node tree") # autopep8: off self.compare_group_to_specs(group, node, [ SocketSpec("Output Float", "Output_9", "VALUE", hide_value=True, default_value=3.0, min_value=1.0, max_value=1.0), SocketSpec("Output Vector", "Output_10", "VECTOR", subtype="EULER", default_value=( 10, 20, 30), min_value=-10.0, max_value=10.0), SocketSpec("Output Color", "Output_11", "RGBA", default_value=(0, 1, 1, 1)), SocketSpec("Input Float", "Input_6", "VALUE", subtype="ANGLE", default_value=-20.0, min_value=5.0, max_value=6.0), SocketSpec("Input Vector", "Input_7", "VECTOR", hide_value=True, default_value=( 2, 4, 6), min_value=-4.0, max_value=100.0), SocketSpec("Input Color", "Input_8", "RGBA", default_value=(0.5, 0.4, 0.3, 0.2)), ]) # autopep8: on def test_load_shader_nodes(self): self.open_file() tree = bpy.data.materials['Material'].node_tree group = bpy.data.node_groups.get('NodeGroup.003') self.assertIsNotNone(group, "Shader node group not found") node = tree.nodes['Group'] self.assertEqual(node.node_tree, group, "Node group must use shader node tree") # autopep8: off self.compare_group_to_specs(group, node, [ SocketSpec("Output Float", "Output_30", "VALUE", hide_value=True, default_value=3.0, min_value=1.0, max_value=1.0), SocketSpec("Output Vector", "Output_31", "VECTOR", subtype="EULER", default_value=( 10, 20, 30), min_value=-10.0, max_value=10.0), SocketSpec("Output Color", "Output_32", "RGBA", default_value=(0, 1, 1, 1)), SocketSpec("Output Shader", "Output_33", "SHADER"), SocketSpec("Input Float", "Input_26", "VALUE", subtype="ANGLE", default_value=-20.0, min_value=5.0, max_value=6.0), SocketSpec("Input Vector", "Input_27", "VECTOR", hide_value=True, default_value=( 2, 4, 6), min_value=-4.0, max_value=100.0), SocketSpec("Input Color", "Input_28", "RGBA", default_value=(0.5, 0.4, 0.3, 0.2)), SocketSpec("Input Shader", "Input_29", "SHADER"), ]) # autopep8: on def test_load_geometry_nodes(self): self.open_file() tree = bpy.data.node_groups['Geometry Nodes'] group = bpy.data.node_groups.get('NodeGroup.002') self.assertIsNotNone(group, "Geometry node group not found") node = tree.nodes['Group'] self.assertEqual(node.node_tree, group, "Node group must use geometry node tree") # autopep8: off self.compare_group_to_specs(group, node, [ SocketSpec("Output Float", "Output_7", "VALUE", hide_value=True, default_value=3.0, min_value=1.0, max_value=1.0), SocketSpec("Output Vector", "Output_8", "VECTOR", subtype="EULER", default_value=( 10, 20, 30), min_value=-10.0, max_value=10.0), SocketSpec("Output Color", "Output_9", "RGBA", default_value=(0, 1, 1, 1)), SocketSpec("Output String", "Output_19", "STRING", default_value=""), SocketSpec("Output Bool", "Output_20", "BOOLEAN", default_value=False), SocketSpec("Output Material", "Output_21", "MATERIAL", default_value=bpy.data.materials['TestMaterial']), SocketSpec("Output Int", "Output_22", "INT", default_value=0, min_value=-2147483648, max_value=2147483647), SocketSpec("Output Geometry", "Output_23", "GEOMETRY"), SocketSpec("Output Collection", "Output_24", "COLLECTION", default_value=bpy.data.collections['TestCollection']), SocketSpec("Output Texture", "Output_25", "TEXTURE", default_value=bpy.data.textures['TestTexture']), SocketSpec("Output Object", "Output_26", "OBJECT", default_value=bpy.data.objects['TestObject']), SocketSpec("Output Image", "Output_27", "IMAGE", default_value=bpy.data.images['TestImage']), SocketSpec("Input Float", "Input_4", "VALUE", subtype="ANGLE", default_value=-20.0, min_value=5.0, max_value=6.0), SocketSpec("Input Vector", "Input_5", "VECTOR", hide_value=True, default_value=( 2, 4, 6), min_value=-4.0, max_value=100.0), SocketSpec("Input Color", "Input_6", "RGBA", default_value=(0.5, 0.4, 0.3, 0.2)), SocketSpec("Input String", "Input_10", "STRING", default_value="hello world!"), SocketSpec("Input Bool", "Input_11", "BOOLEAN", default_value=True, hide_in_modifier=True), SocketSpec("Input Material", "Input_12", "MATERIAL", default_value=bpy.data.materials['TestMaterial']), SocketSpec("Input Int", "Input_13", "INT", default_value=500, min_value=200, max_value=1000), SocketSpec("Input Geometry", "Input_14", "GEOMETRY"), SocketSpec("Input Collection", "Input_15", "COLLECTION", default_value=bpy.data.collections['TestCollection']), SocketSpec("Input Texture", "Input_16", "TEXTURE", default_value=bpy.data.textures['TestTexture']), SocketSpec("Input Object", "Input_17", "OBJECT", default_value=bpy.data.objects['TestObject']), SocketSpec("Input Image", "Input_18", "IMAGE", default_value=bpy.data.images['TestImage']), ]) # autopep8: on class NodeGroupVersioning25Test(AbstractNodeGroupInterfaceTest): def open_file(self): bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "nodegroup25.blend")) self.assertEqual(bpy.data.version, (2, 55, 0)) def test_load_compositor_nodes(self): self.open_file() tree = bpy.data.scenes['Scene'].node_tree group = bpy.data.node_groups.get('NodeGroup.002') self.assertIsNotNone(group, "Compositor node group not found") node = tree.nodes['NodeGroup.002'] self.assertEqual(node.node_tree, group, "Node group must use compositor node tree") # autopep8: off self.compare_group_to_specs(group, node, [ SocketSpec("Image", "Image", "RGBA", default_value=(0, 0, 0, 1)), SocketSpec("Alpha", "Alpha", "VALUE", default_value=1.0, min_value=0.0, max_value=0.0), SocketSpec("Alpha", "Alpha.001", "VALUE", default_value=0.0, min_value=0.0, max_value=0.0), SocketSpec("Alpha", "Alpha.002", "VALUE", default_value=0.0, min_value=0.0, max_value=0.0), SocketSpec("Fac", "Fac", "VALUE", default_value=0.5, min_value=0.0, max_value=0.0), SocketSpec("ID value", "ID value", "VALUE", default_value=0.8, min_value=0.0, max_value=0.0), SocketSpec("ID value", "ID value.001", "VALUE", default_value=0.8, min_value=0.0, max_value=0.0), ], test_links=False) # autopep8: on def test_load_shader_nodes(self): self.open_file() tree = bpy.data.materials['Material'].node_tree group = bpy.data.node_groups.get('NodeGroup') self.assertIsNotNone(group, "Shader node group not found") node = tree.nodes['NodeGroup'] self.assertEqual(node.node_tree, group, "Node group must use shader node tree") # autopep8: off self.compare_group_to_specs(group, node, [ SocketSpec("Color", "Color", "RGBA", default_value=(0, 0, 0, 1)), SocketSpec("Color", "Color.001", "RGBA", default_value=(0, 0, 0, 1)), SocketSpec("Vector", "Vector", "VECTOR", default_value=(0, 0, 0), min_value=0.0, max_value=0.0), SocketSpec("Value", "Value", "VALUE", default_value=0.0, min_value=0.0, max_value=0.0), SocketSpec("Fac", "Fac", "VALUE", default_value=0.5, min_value=0.0, max_value=0.0), SocketSpec("Color1", "Color1", "RGBA", default_value=(0.5, 0.5, 0.5, 1)), SocketSpec("Color2", "Color2", "RGBA", default_value=(0.5, 0.5, 0.5, 1)), SocketSpec("Fac", "Fac.001", "VALUE", default_value=0.5, min_value=0.0, max_value=0.0), SocketSpec("Color1", "Color1.001", "RGBA", default_value=(0.5, 0.5, 0.5, 1)), SocketSpec("Color2", "Color2.001", "RGBA", default_value=(0.5, 0.5, 0.5, 1)), SocketSpec("Vector", "Vector", "VECTOR", default_value=(0.5, 0.5, 0.5), min_value=0.0, max_value=0.0), SocketSpec("Vector", "Vector.001", "VECTOR", default_value=(0.5, 0.5, 0.5), min_value=0.0, max_value=0.0), ], test_links=False) # autopep8: on 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) args, remaining = parser.parse_known_args(argv) unittest.main(argv=remaining) if __name__ == "__main__": main()