Brokkr/surfaces/blender/tests/test_node_actions.py

722 lines
38 KiB
Python

"""Node-graph action tests. No Blender: a fake bpy with just enough node-tree surface.
Run: python3 -m unittest discover -s surfaces/blender/tests -v
The real API is exercised by tests/live/gn_actions.py.
"""
from __future__ import annotations
import sys
import types
import unittest
import unittest.mock
from pathlib import Path
ADDON = Path(__file__).resolve().parents[1] / "brokkr_bridge"
package = types.ModuleType("brokkr_bridge")
package.__path__ = [str(ADDON)]
sys.modules.setdefault("brokkr_bridge", package)
from brokkr_bridge import blender_target as bt # noqa: E402
from brokkr_bridge import node_actions as na # noqa: E402
NS = types.SimpleNamespace
class Sock:
def __init__(self, identifier, name, kind="VALUE", default=0.0):
self.identifier, self.name, self.type, self.enabled = identifier, name, kind, True
self.default_value, self.is_linked = default, False
class Node(dict):
"""A node; ID properties are the dict part."""
counter = 1000
def as_pointer(self):
return self.pointer
PROPS = {"operation": NS(type="ENUM", is_readonly=False), "flag": NS(type="BOOLEAN", is_readonly=False),
"filepath": NS(type="STRING", is_readonly=False), "location": NS(type="FLOAT", is_readonly=False)}
def __init__(self, idname):
super().__init__()
self.bl_idname, self.location, self.label, self.operation, self.flag = idname, [0, 0], "", "ADD", False
Node.counter += 1
self.pointer = Node.counter
self.bl_rna = NS(properties=self.PROPS)
self.inputs = [Sock("A", "A"), Sock("Size", "Size", "VECTOR", (1.0, 1.0, 1.0)), Sock("B", "Dup"), Sock("B_VEC", "Dup")]
self.outputs = [Sock("Out", "Out")]
for socket in self.inputs + self.outputs:
socket.node = self
def __setattr__(self, key, value):
if key == "operation" and value not in ("ADD", "MULTIPLY"):
raise TypeError("enum not found")
object.__setattr__(self, key, value)
class Nodes(list):
def new(self, idname):
if idname.endswith("Missing"):
raise RuntimeError("Node type undefined")
node = Node(idname)
self.append(node)
return node
def remove(self, node):
list.remove(self, node)
class Proxy:
"""A fresh wrapper per access, as bpy hands out: equal by pointer, with a different id() each time."""
def __init__(self, node):
self._node = node
def as_pointer(self):
return self._node.pointer
def __getattr__(self, name):
return getattr(self._node, name)
def __eq__(self, other):
return getattr(other, "pointer", None) == self._node.pointer or (isinstance(other, Proxy) and other._node is self._node)
__hash__ = None
class Links(list):
def __init__(self, valid=True):
super().__init__()
self.valid = valid
def new(self, a, b):
for old in [l for l in self if l.to_socket is b]: # a single-link input loses its old link, as in Blender
list.remove(self, old)
link = NS(from_socket=a, to_socket=b, from_node=Proxy(a.node), to_node=Proxy(b.node), is_muted=False, is_valid=self.valid(a, b) if callable(self.valid) else self.valid)
self.append(link)
return link
def remove(self, link):
list.remove(self, link)
class Interface:
def __init__(self):
self.items_tree = []
def new_socket(self, name, in_out, socket_type):
item = NS(item_type="SOCKET", name=name, in_out=in_out, socket_type=socket_type, identifier=f"Socket_{len(self.items_tree)}", default_value=None)
self.items_tree.append(item)
return item
class Tree:
bl_idname = "GeometryNodeTree"
library = None
def __init__(self, name):
self.name, self.nodes, self.links, self.interface = name, Nodes(), Links(), Interface()
class Groups(dict):
def new(self, name, kind):
self[name] = Tree(name)
return self[name]
def remove(self, tree):
del self[tree.name]
class Inputs:
def __init__(self):
self.values = {}
def __getattr__(self, identifier):
return self.values.setdefault(identifier, NS(value=None))
class Modifiers(list):
refuses = False
def get(self, name):
return next((m for m in self if m.name == name), None)
def remove(self, modifier):
list.remove(self, modifier)
def new(self, name, kind):
if self.refuses:
return None # Blender returns None for object types without modifiers
m = NS(name=name, type=kind, node_group=None, properties=NS(inputs=Inputs()))
self.append(m)
return m
class Obj:
def __init__(self, name):
self.name, self.type, self.data, self.modifiers, self.tagged = name, "MESH", None, Modifiers(), 0
def update_tag(self):
self.tagged += 1
def get(self, key, default=None):
return default
def __setitem__(self, key, value):
self.custom = (key, value)
class FakeBpy:
def __init__(self):
self.data = NS(node_groups=Groups(), objects={"Host": Obj("Host")})
class NodeActionTests(unittest.TestCase):
def setUp(self):
self.bpy = FakeBpy()
self.target = bt.BrokkrBlenderTarget(self.bpy)
self.n = 0
def run_action(self, **command):
self.n += 1
command.setdefault("commandId", f"c{self.n}")
return self.target.execute_command(None, command)
def accepted(self, **command):
receipt = self.run_action(**command)
self.assertEqual(receipt["status"], "accepted", receipt["message"])
return receipt
def refused(self, **command):
receipt = self.run_action(**command)
self.assertEqual(receipt["status"], "failed", receipt["message"])
return receipt
def group(self, interface=None):
self.accepted(action="createNodeGroup", treeKey="G", interface=interface or [{"inOut": "OUTPUT", "name": "Geometry", "type": "Geometry"}, {"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}])
return self.bpy.data.node_groups["G"]
# createNodeGroup
def test_group_gets_interface_and_stamped_group_nodes(self):
receipt = self.accepted(action="createNodeGroup", treeKey="G", interface=[{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}])
tree = self.bpy.data.node_groups["G"]
self.assertEqual([n.get("brokkr_id") for n in tree.nodes], ["gin", "gout"])
self.assertEqual(tree.interface.items_tree[0].default_value, 8)
self.assertIn("Count=Socket_0", receipt["message"])
def test_group_refusals_create_nothing(self):
for interface in (None, [], "x", [{"inOut": "SIDEWAYS", "name": "a", "type": "Int"}], [{"inOut": "INPUT", "name": "a", "type": "Mesh"}],
[{"inOut": "INPUT", "name": "a", "type": "Int"}, {"inOut": "INPUT", "name": "a", "type": "Float"}],
[{"inOut": "INPUT", "name": "a", "type": "Int", "default": 1.5}], [{"inOut": "INPUT", "name": "a", "type": "Vector", "default": [1, 2]}]):
self.refused(action="createNodeGroup", treeKey="G", interface=interface)
self.refused(action="createNodeGroup", treeKey="../x", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.refused(action="createNodeGroup", treeKind="material", treeKey="G", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.assertEqual(len(self.bpy.data.node_groups), 0)
def test_group_that_exists_is_not_overwritten(self):
self.group()
self.refused(action="createNodeGroup", treeKey="G", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.assertEqual(len(self.bpy.data.node_groups["G"].interface.items_tree), 2)
# addNode
def test_add_node_applies_properties_then_inputs_and_stamps_id(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="m", blIdname="ShaderNodeMath", location=[10, 20], properties={"operation": "MULTIPLY"}, inputs={"A": 3, "Size": [1, 2, 3]})
node = tree.nodes[-1]
self.assertEqual((node.operation, node.location, node.inputs[0].default_value, node.inputs[1].default_value), ("MULTIPLY", [10.0, 20.0], 3.0, (1.0, 2.0, 3.0)))
def test_add_node_refusals_leave_no_node(self):
tree = self.group()
before = len(tree.nodes)
cases = [dict(blIdname="GeometryNodeImportOBJ"), dict(blIdname="GeometryNodeBake"), dict(blIdname="NodeGroupInput"), dict(blIdname="bpy.ops"),
dict(blIdname="ShaderNodeMissing"), dict(blIdname="ShaderNodeMath", properties={"operation": "NOPE"}),
dict(blIdname="ShaderNodeMath", properties={"filepath": "/etc/passwd"}), dict(blIdname="ShaderNodeMath", properties={"location": 1.0}),
dict(blIdname="ShaderNodeMath", properties={"missing": 1}), dict(blIdname="ShaderNodeMath", inputs={"Dup": 1}),
dict(blIdname="ShaderNodeMath", inputs={"Nope": 1}), dict(blIdname="ShaderNodeMath", inputs={"A": float("nan")}),
dict(blIdname="ShaderNodeMath", inputs={"A": True}), dict(blIdname="ShaderNodeMath", inputs={"A": 1e12}),
dict(blIdname="ShaderNodeMath", inputs={"Size": [1, 2]}), dict(blIdname="ShaderNodeMath", location=[1])]
for case in cases:
self.refused(action="addNode", treeKey="G", id="x", **case)
self.assertEqual(len(tree.nodes), before)
def test_string_and_reserved_properties_are_refused_by_rule(self):
self.group()
for name in ("filepath", "location"):
receipt = self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", properties={name: "v"})
self.assertIn("no settable property", receipt["message"])
def test_add_node_refuses_bad_ids_duplicates_and_missing_tree(self):
self.group()
for bad in ("", "a b", "x" * 64, "../x", None):
self.refused(action="addNode", treeKey="G", id=bad, blIdname="ShaderNodeMath")
self.refused(action="addNode", treeKey="G", id="gin", blIdname="ShaderNodeMath")
self.refused(action="addNode", treeKey="Nope", id="a", blIdname="ShaderNodeMath")
def test_node_cap(self):
tree = self.group()
for i in range(na.MAX_NODES - 2):
tree.nodes.new("ShaderNodeMath")
self.refused(action="addNode", treeKey="G", id="over", blIdname="ShaderNodeMath")
def test_linked_library_group_is_read_only(self):
tree = self.group()
tree.library = NS(filepath="lib.blend")
self.refused(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
# access rules, pinned by literals
def test_each_denied_node_family_is_refused_alone(self):
tree = self.group()
denied = ["GeometryNodeImportOBJ", "GeometryNodeExportPLY", "ShaderNodeScript", "GeometryNodeBake", "GeometryNodeGroup",
"GeometryNodeSimulationInput", "GeometryNodeRepeatInput", "GeometryNodeForeachGeometryElementInput",
"GeometryNodeViewer", "ShaderNodeTexImage", "ShaderNodeTexEnvironment", "GeometryNodeImageTexture",
"GeometryNodeInputImage", "ShaderNodeTexIES"]
before = len(tree.nodes)
for idname in denied:
self.refused(action="addNode", treeKey="G", id="x", blIdname=idname)
self.assertEqual(len(tree.nodes), before)
def test_legitimate_node_families_are_allowed(self):
for i, idname in enumerate(["ShaderNodeMath", "GeometryNodeMeshGrid", "FunctionNodeCompare", "GeometryNodeSetPosition", "ShaderNodeTexNoise"]):
self.group() if i == 0 else None
self.accepted(action="addNode", treeKey="G", id=f"n{i}", blIdname=idname)
def test_idname_must_be_a_whole_known_prefixed_name(self):
self.group()
for bad in ("Foo", "ShaderNode", "xShaderNodeMath", "ShaderNodeMath\n", "ShaderNode Math", "ShaderNode_Math", "CompositorNodeMath", "ShaderNodeMath;", "", None, 5):
self.refused(action="addNode", treeKey="G", id="x", blIdname=bad)
def test_node_cap_is_exactly_500(self):
tree = self.group()
for _ in range(497):
tree.nodes.new("ShaderNodeMath")
self.assertEqual(len(tree.nodes), 499)
self.accepted(action="addNode", treeKey="G", id="last", blIdname="ShaderNodeMath")
self.assertEqual(len(tree.nodes), 500)
self.refused(action="addNode", treeKey="G", id="over", blIdname="ShaderNodeMath")
def test_interface_cap_is_exactly_64(self):
def sockets(count):
return [{"inOut": "INPUT", "name": f"s{i}", "type": "Int"} for i in range(count)]
self.accepted(action="createNodeGroup", treeKey="A", interface=sockets(64))
self.refused(action="createNodeGroup", treeKey="B", interface=sockets(65))
self.refused(action="createNodeGroup", treeKey="C", interface=[])
def test_int_limit_is_exactly_4096(self):
self.group()
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for value in (4096, -4096):
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=value)
for value in (4097, -4097):
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=value)
self.accepted(action="addNode", treeKey="G", id="ok", blIdname="ShaderNodeMath", inputs={"A": 4097}) # a float input has no int cap
def test_value_limit_is_exactly_one_billion(self):
self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath", inputs={"A": 1e9})
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath", inputs={"A": -1e9})
self.refused(action="addNode", treeKey="G", id="c", blIdname="ShaderNodeMath", inputs={"A": 1000000001})
self.refused(action="addNode", treeKey="G", id="d", blIdname="ShaderNodeMath", inputs={"Size": [0, 0, -1000000001]})
self.accepted(action="addNode", treeKey="G", id="e", blIdname="ShaderNodeMath", location=[1e9, -1e9])
self.refused(action="addNode", treeKey="G", id="f", blIdname="ShaderNodeMath", location=[1000000001, 0])
def test_modifier_cap_is_exactly_8(self):
self.group()
for i in range(8):
self.accepted(action="addModifier", objectName="Host", modifierName=f"m{i}", treeKey="G")
self.refused(action="addModifier", objectName="Host", modifierName="m8", treeKey="G")
self.accepted(action="addModifier", objectName="Host", modifierName="m3", treeKey="G") # reuse is not growth
def test_location_must_be_two_finite_numbers(self):
tree = self.group()
for bad in ([1, 2, 3], "ab", 5, [1, float("nan")], [1, "2"], [True, 1]):
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", location=bad)
self.assertEqual(len(tree.nodes), 2)
def test_readonly_node_property_is_refused_and_untouched(self):
self.group()
with unittest.mock.patch.dict(Node.PROPS, {"flag": NS(type="BOOLEAN", is_readonly=True)}):
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", properties={"flag": True})
self.assertEqual(len(self.bpy.data.node_groups["G"].nodes), 2)
def test_input_without_a_default_or_with_a_link_is_refused(self):
tree = self.group()
original = Nodes.new
def linked(nodes, idname):
node = original(nodes, idname)
node.inputs[0].is_linked = True
return node
with unittest.mock.patch.object(Nodes, "new", linked):
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", inputs={"A": 1})
self.assertEqual(len(tree.nodes), 2)
def bare(nodes, idname):
node = original(nodes, idname)
del node.inputs[0].default_value
return node
with unittest.mock.patch.object(Nodes, "new", bare):
self.refused(action="addNode", treeKey="G", id="y", blIdname="ShaderNodeMath", inputs={"A": 1})
self.assertEqual(len(tree.nodes), 2)
def test_create_node_group_that_fails_midway_leaves_no_group(self):
original = Nodes.new
def failing(nodes, idname):
if idname == "NodeGroupOutput":
raise RuntimeError("boom")
return original(nodes, idname)
with unittest.mock.patch.object(Nodes, "new", failing):
self.refused(action="createNodeGroup", treeKey="Half", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.assertNotIn("Half", self.bpy.data.node_groups)
self.accepted(action="createNodeGroup", treeKey="Half", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
def test_add_modifier_will_not_take_over_a_foreign_modifier(self):
self.group()
host = self.bpy.data.objects["Host"]
foreign = host.modifiers.new("Mine", "SUBSURF")
self.refused(action="addModifier", objectName="Host", modifierName="Mine", treeKey="G")
self.assertIsNone(foreign.node_group)
self.assertEqual(len(host.modifiers), 1)
def test_set_modifier_input_only_drives_geometry_nodes_modifiers(self):
tree = self.group()
host = self.bpy.data.objects["Host"]
foreign = host.modifiers.new("Mine", "SUBSURF")
foreign.node_group = tree
self.refused(action="setModifierInput", objectName="Host", modifierName="Mine", socket="Count", value=1)
self.assertEqual(foreign.properties.inputs.values, {})
self.assertEqual(host.tagged, 0)
def test_blender_capabilities_include_everything_the_daemon_advertises_for_this_cut(self):
# brokkr-daemon/src/main.rs lists the same names (and asserts them in its own test); a capability
# added to one side only fails one of the two tests.
for name in ("asset.import.gltf", "camera.write", "light.write", "world.write", "render.still", "nodegraph.geometry.write", "geometry.evaluated.read"):
self.assertIn(name, bt.CAPABILITIES)
# linkSockets
def test_link_by_identifier_and_by_unique_name(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Size")
self.assertEqual(len(tree.links), 2)
self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Dup") # name shared by two sockets
self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="zzz", toSocket="A")
self.assertEqual(len(tree.links), 2)
def test_unavailable_socket_is_not_addressable_by_name(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
tree.nodes[-1].inputs[3].enabled = False
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Dup")
def test_invalid_link_is_refused_and_removed(self):
tree = self.group()
tree.links.valid = False
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
receipt = self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
self.assertIn("not valid", receipt["message"]) # past the cycle check: Blender was asked and said no
self.assertEqual(len(tree.links), 0)
# addModifier / setModifierInput
def test_modifier_assigns_group_and_reuses_by_name(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.assertEqual(len(host.modifiers), 1)
self.assertIs(host.modifiers[0].node_group, self.bpy.data.node_groups["G"])
self.refused(action="addModifier", objectName="Host", modifierType="SUBSURF", treeKey="G")
self.refused(action="addModifier", objectName="Nope", treeKey="G")
self.refused(action="addModifier", objectName="Host", treeKey="Nope")
def test_modifier_cap(self):
self.group()
for i in range(na.MAX_MODIFIERS):
self.accepted(action="addModifier", objectName="Host", modifierName=f"m{i}", treeKey="G")
self.refused(action="addModifier", objectName="Host", modifierName="extra", treeKey="G")
def test_set_modifier_input_writes_value_through_the_struct_and_tags(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
tags = host.tagged
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=12)
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 12)
self.assertEqual(host.tagged, tags + 1)
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Socket_1", value=3)
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 3)
def test_set_modifier_input_refusals_change_nothing(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for socket, value in (("Count", 1.5), ("Count", "x"), ("Count", True), ("Count", None), ("Count", 1e12), ("Nope", 1), ("Geometry", 1), ("", 1)):
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket=socket, value=value)
self.refused(action="setModifierInput", objectName="Host", modifierName="other", socket="Count", value=1)
self.assertEqual(host.modifiers[0].properties.inputs.values, {})
def test_set_modifier_input_by_name_needs_a_unique_name(self):
tree = self.group([{"inOut": "INPUT", "name": "Dup", "type": "Int"}])
tree.interface.new_socket("Dup", in_out="INPUT", socket_type="NodeSocketFloat") # a hand-made group can repeat a name
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket="Dup", value=1)
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Socket_1", value=1.5)
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 1.5)
# removal, unlinking, cycles
def chain(self):
tree = self.group()
for node_id in ("a", "b", "c"):
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
return tree
def test_cycles_and_self_links_are_refused_and_change_nothing(self):
tree = self.chain()
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
self.accepted(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
before = list(tree.links)
for src, dst in (("c", "a"), ("b", "a"), ("a", "a"), ("c", "b")):
receipt = self.refused(action="linkSockets", treeKey="G", fromNode=src, fromSocket="Out", toNode=dst, toSocket="Size")
self.assertIn("cycle", receipt["message"])
self.assertEqual(list(tree.links), before)
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="Size") # a forward skip is no cycle
def test_cycle_detection_survives_fresh_node_wrappers_on_long_chains(self):
tree = self.group()
ids = [f"n{i}" for i in range(12)]
for node_id in ids:
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
for a, b in zip(ids, ids[1:]):
self.accepted(action="linkSockets", treeKey="G", fromNode=a, fromSocket="Out", toNode=b, toSocket="A")
# a diamond ladder: every rung reachable two ways
for a, b in zip(ids[:-2], ids[2:]):
self.accepted(action="linkSockets", treeKey="G", fromNode=a, fromSocket="Out", toNode=b, toSocket="Size")
count = len(tree.links)
for head in ids[:-1]:
self.refused(action="linkSockets", treeKey="G", fromNode=ids[-1], fromSocket="Out", toNode=head, toSocket="Size")
self.assertEqual(len(tree.links), count)
def test_cycle_through_a_later_branch_is_found(self):
tree = self.group()
for node_id in "abcd":
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
for src, dst in (("a", "b"), ("a", "c"), ("c", "d")):
self.accepted(action="linkSockets", treeKey="G", fromNode=src, fromSocket="Out", toNode=dst, toSocket="A")
self.refused(action="linkSockets", treeKey="G", fromNode="d", fromSocket="Out", toNode="a", toSocket="A")
def test_cycle_walk_visits_each_node_once_on_a_ladder(self):
tree = self.group()
ids = [f"n{i}" for i in range(40)]
for node_id in ids:
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
for a, b in zip(ids, ids[1:]):
tree.links.new(tree.nodes[2 + ids.index(a)].outputs[0], tree.nodes[2 + ids.index(b)].inputs[0])
for a, b in zip(ids, ids[2:]):
tree.links.new(tree.nodes[2 + ids.index(a)].outputs[0], tree.nodes[2 + ids.index(b)].inputs[1])
calls = []
real = na.node_key
with unittest.mock.patch.object(na, "node_key", lambda n: calls.append(1) or real(n)):
self.assertFalse(na.reaches(tree, tree.nodes[2], NS(pointer=-1, as_pointer=lambda: -1)))
self.assertLess(len(calls), 4 * len(tree.links))
def test_remove_node(self):
tree = self.chain()
self.accepted(action="removeNode", treeKey="G", id="b")
self.assertEqual([n.get("brokkr_id") for n in tree.nodes], ["gin", "gout", "a", "c"])
again = self.accepted(action="removeNode", treeKey="G", id="b")
self.assertIn("already absent", again["message"])
for keep in ("gin", "gout"):
self.refused(action="removeNode", treeKey="G", id=keep)
self.refused(action="removeNode", treeKey="Nope", id="a")
self.refused(action="removeNode", treeKey="G", id="a b")
def test_unlink_removes_only_the_matching_links(self):
tree = self.chain()
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
self.accepted(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="Size")
self.refused(action="unlinkSockets", treeKey="G", fromNode="zzz", fromSocket="Out", toNode="c", toSocket="A")
self.assertIn("Removed 0", self.accepted(action="unlinkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")["message"])
self.assertEqual(len(tree.links), 2)
self.assertIn("Removed 1", self.accepted(action="unlinkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")["message"])
self.assertEqual(len(tree.links), 1)
self.accepted(action="unlinkSockets", treeKey="G", toNode="c", toSocket="Size")
self.assertEqual(len(tree.links), 0)
self.refused(action="unlinkSockets", treeKey="G", fromNode="a", toNode="c", toSocket="A") # a source node needs its socket
def test_remove_modifier_takes_only_geometry_nodes_modifiers(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
host.modifiers.new("Sub", "SUBSURF")
tags = host.tagged
self.refused(action="removeModifier", objectName="Host", modifierName="Sub")
self.assertEqual([m.name for m in host.modifiers], ["GN", "Sub"])
self.accepted(action="removeModifier", objectName="Host", modifierName="GN")
self.assertEqual([m.name for m in host.modifiers], ["Sub"])
self.assertEqual(host.tagged, tags + 1)
self.assertIn("already absent", self.accepted(action="removeModifier", objectName="Host", modifierName="GN")["message"])
self.refused(action="removeModifier", objectName="Nope", modifierName="GN")
for bad in (["x"], "x" * 256, "a\0b", 5):
self.refused(action="removeModifier", objectName="Host", modifierName=bad)
host.modifiers.new("Gé x/y", "NODES") # made by hand: any name Blender allows can be removed
self.accepted(action="removeModifier", objectName="Host", modifierName="Gé x/y")
self.assertEqual([m.name for m in host.modifiers], ["Sub"])
# setObjectCustomProperty on a Geometry Nodes host
def test_custom_property_naming_a_modifier_input_identifier_is_refused(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for name in ("Socket_1", "customProperties.Socket_1"):
receipt = self.refused(action="setObjectCustomProperty", targetObjectName="Host", propertyName=name, value=3)
self.assertIn("setModifierInput", receipt["message"])
self.assertFalse(hasattr(host, "custom"))
def test_custom_property_sharing_only_a_display_name_is_an_ordinary_property(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.accepted(action="setObjectCustomProperty", targetObjectName="Host", propertyName="Count", value=3)
self.assertEqual(host.custom, ("Count", 3))
def test_other_custom_properties_on_the_same_object_still_work(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.accepted(action="setObjectCustomProperty", targetObjectName="Host", propertyName="tag", value=3)
self.assertEqual(host.custom, ("tag", 3))
# review fixes
def test_refused_link_restores_the_link_it_displaced(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="c", blIdname="ShaderNodeMath")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
tree.links.valid = lambda a, b: a is not tree.nodes[3].outputs[0] # b.Out -> anything is invalid
self.refused(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
self.assertEqual([(l.from_socket, l.to_socket) for l in tree.links], [(tree.nodes[2].outputs[0], tree.nodes[4].inputs[0])])
def test_refused_link_restores_the_displaced_link_muted_state(self):
tree = self.group()
for node_id in ("a", "b", "c"):
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
tree.links[0].is_muted = True
tree.links.valid = lambda a, b: False
self.refused(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
self.assertEqual([l.is_muted for l in tree.links], [True])
def test_ints_are_capped_and_patterns_reject_trailing_newlines(self):
self.group()
self.accepted(action="addNode", treeKey="G", id="ok", blIdname="ShaderNodeMath")
self.refused(action="addNode", treeKey="G", id="a\n", blIdname="ShaderNodeMath")
self.refused(action="createNodeGroup", treeKey="T\n", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.refused(action="createNodeGroup", treeKey="H", interface=[{"inOut": "INPUT", "name": "a", "type": "Int", "default": na.MAX_INT + 1}])
self.accepted(action="createNodeGroup", treeKey="H", interface=[{"inOut": "INPUT", "name": "a", "type": "Int", "default": na.MAX_INT}])
def test_modifier_on_an_object_type_without_modifiers_is_a_clear_refusal(self):
self.group()
host = self.bpy.data.objects["Host"]
host.modifiers.refuses = True
host.type = "CAMERA"
receipt = self.refused(action="addModifier", objectName="Host", treeKey="G")
self.assertIn("CAMERA", receipt["message"])
# snapshot
def snapshot_fixture(self, coords, instances):
host = self.bpy.data.objects["Host"]
host.modifiers.new("GN", "NODES")
class Verts(list):
def foreach_get(self_inner, name, buffer):
flat = [c for v in self_inner for c in v.co]
buffer[:] = type(buffer)("f", flat)
mesh = NS(vertices=Verts(NS(co=c) for c in coords), edges=[1], polygons=[])
evaluated = NS(to_mesh=lambda: mesh, to_mesh_clear=lambda: None)
host.evaluated_get = lambda dg: evaluated
self.bpy.context = NS(evaluated_depsgraph_get=lambda: NS(update=lambda: None, object_instances=instances))
return host
@staticmethod
def instance(parent, point, is_instance=True):
return NS(is_instance=is_instance, parent=NS(original=parent) if parent else None, matrix_world=NS(translation=point))
def evaluated(self, host):
return self.target._evaluated_snapshot(host, self.target._instance_scan())
def test_evaluated_snapshot_reports_counts_separate_bounds_and_cap(self):
host = self.bpy.data.objects["Host"]
n = bt.EVALUATED_POSITION_CAP + 2
other = Obj("other")
instances = [self.instance(host, (0.0, 5.0 + i, 0.0)) for i in range(n)] + [self.instance(other, (9.0, 9.0, 9.0)), self.instance(None, (8.0, 8.0, 8.0), False)]
host = self.snapshot_fixture([(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (2.0, 0.0, 0.0)], instances)
data = self.evaluated(host)
self.assertEqual((data["vertexCount"], data["edgeCount"], data["instanceCount"]), (3, 1, n))
self.assertEqual((data["vertexBoundsMin"], data["vertexBoundsMax"]), ([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]))
self.assertEqual((data["instanceBoundsMin"], data["instanceBoundsMax"]), ([0.0, 5.0, 0.0], [0.0, 5.0 + n - 1, 0.0]))
self.assertEqual((len(data["instanceLocations"]), len(data["vertexLocations"]), data["truncated"]), (bt.EVALUATED_POSITION_CAP, 3, True))
self.assertFalse(data["nonFinite"])
def test_non_finite_positions_are_flagged_not_published(self):
host = self.snapshot_fixture([(0.0, 0.0, 0.0), (float("inf"), 0.0, 0.0)], [])
data = self.evaluated(host)
self.assertTrue(data["nonFinite"])
self.assertEqual((data["vertexLocations"], data["vertexBoundsMin"], data["vertexBoundsMax"]), ([], [], []))
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [self.instance(host, (float("nan"), 0.0, 0.0))])
data = self.evaluated(host)
self.assertTrue(data["nonFinite"] and data["instanceLocations"] == [] and data["instanceBoundsMin"] == [])
def test_a_huge_evaluated_mesh_reports_counts_only(self):
host = self.snapshot_fixture([(0.0, 0.0, 0.0)] * 3, [])
with unittest.mock.patch.object(bt, "EVALUATED_VERTEX_LIMIT", 2):
data = self.evaluated(host)
self.assertEqual((data["vertexCount"], data["positionsSkipped"], data["vertexLocations"], data["vertexBoundsMin"]), (3, True, [], []))
def test_per_object_snapshots_reuse_the_one_scan(self):
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [])
calls = []
get = self.bpy.context.evaluated_depsgraph_get
self.bpy.context.evaluated_depsgraph_get = lambda: calls.append(1) or get()
scan = self.target._instance_scan()
for _ in range(3):
self.target._evaluated_snapshot(host, scan)
self.assertEqual(len(calls), 1)
def test_instance_scan_stops_at_the_limit_and_says_so(self):
host = self.bpy.data.objects["Host"]
visited = []
def many():
for i in range(50):
visited.append(i)
yield self.instance(host, (float(i), 0.0, 0.0))
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], many())
with unittest.mock.patch.object(bt, "EVALUATED_INSTANCE_SCAN_LIMIT", 10):
scan = self.target._instance_scan()
data = self.target._evaluated_snapshot(host, scan)
self.assertEqual((data["instanceCount"], data["instancesComplete"]), (10, False))
other = Obj("Other")
other.modifiers.new("GN", "NODES")
other.evaluated_get = host.evaluated_get
self.assertIsNone(self.target._evaluated_snapshot(other, scan)["instanceCount"]) # never reached: unknown, not 0
self.assertLessEqual(len(visited), 11) # the generator is abandoned, not drained
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [self.instance(host, (1.0, 0.0, 0.0))])
self.assertTrue(self.evaluated(host)["instancesComplete"])
def test_object_without_modifiers_has_no_evaluated_block(self):
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"], None))
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"], (None, {}, True)))
if __name__ == "__main__":
unittest.main()