360 lines
13 KiB
Python
360 lines
13 KiB
Python
import unittest
|
|
from unittest.mock import MagicMock
|
|
import sys
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import numpy as np
|
|
from pathlib import Path
|
|
|
|
# Add workspace directory to sys.path
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.append(str(PROJECT_ROOT.parent))
|
|
|
|
# Setup dummy bpy
|
|
class DummyNodeSocket:
|
|
def __init__(self, name="Socket", identifier="Socket", bl_idname="NodeSocketFloat", value=0.0, node=None):
|
|
self.name = name
|
|
self.identifier = identifier
|
|
self.bl_idname = bl_idname
|
|
self.value = value
|
|
self.default_value = value
|
|
self.is_linked = False
|
|
self.links = []
|
|
self.node = node
|
|
|
|
def as_pointer(self):
|
|
return id(self)
|
|
|
|
class DummyNode:
|
|
def as_pointer(self):
|
|
return id(self)
|
|
|
|
class DummySocketCollection(list):
|
|
def __init__(self, items=None, node=None):
|
|
super().__init__(items or [])
|
|
self.node = node
|
|
if items and node:
|
|
for item in items:
|
|
item.node = node
|
|
|
|
def get(self, key, default=None):
|
|
for item in self:
|
|
if getattr(item, "name", None) == key or getattr(item, "identifier", None) == key:
|
|
return item
|
|
return default
|
|
|
|
def new(self, bl_idname, name, identifier=None):
|
|
sock = DummyNodeSocket(name, identifier or name, bl_idname, node=self.node)
|
|
self.append(sock)
|
|
return sock
|
|
|
|
mock_types = MagicMock()
|
|
mock_types.NodeSocket = DummyNodeSocket
|
|
mock_types.Node = DummyNode
|
|
mock_types.Object = MagicMock
|
|
mock_types.Mesh = MagicMock
|
|
mock_types.NodeSocketShader = MagicMock
|
|
mock_types.NodeCustomGroup = MagicMock
|
|
mock_types.NodeTree = MagicMock
|
|
mock_types.Operator = MagicMock
|
|
mock_types.Menu = MagicMock
|
|
mock_types.Panel = MagicMock
|
|
mock_types.PropertyGroup = MagicMock
|
|
|
|
mock_bpy = MagicMock()
|
|
mock_bpy.context.scene = MagicMock()
|
|
mock_bpy.types = mock_types
|
|
|
|
sys.modules['bpy'] = mock_bpy
|
|
sys.modules['bpy.types'] = mock_types
|
|
sys.modules['bpy.props'] = MagicMock()
|
|
sys.modules['bpy.utils'] = MagicMock()
|
|
sys.modules['bpy.utils.previews'] = MagicMock()
|
|
sys.modules['bpy.app'] = MagicMock()
|
|
sys.modules['bpy.app.handlers'] = MagicMock()
|
|
|
|
from NodeSlicer.core.slice import SlicePaths
|
|
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
|
|
from NodeSlicer.infra.blender.blender_slice_nodes_generic import SliceNode
|
|
from NodeSlicer.infra.debug import (
|
|
debug,
|
|
debug_session,
|
|
get_last_debug_dump,
|
|
get_last_debug_text,
|
|
load_last_inputs,
|
|
clear_debug_dumps,
|
|
set_debug_dump_dir,
|
|
get_debug_dump_dir,
|
|
dump_node_execution
|
|
)
|
|
|
|
|
|
class TestNodeDebugSystem(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.test_dir = tempfile.mkdtemp(prefix="nodeslicer_debug_test_")
|
|
set_debug_dump_dir(self.test_dir)
|
|
clear_debug_dumps()
|
|
|
|
def tearDown(self):
|
|
clear_debug_dumps()
|
|
if os.path.exists(self.test_dir):
|
|
shutil.rmtree(self.test_dir)
|
|
|
|
def test_debug_decorator_on_node_class(self):
|
|
@debug
|
|
class TestMathNode(SliceNode):
|
|
bl_idname = "SLICENODE_TestMath"
|
|
multiplier: float = 2.5
|
|
|
|
def __init__(self):
|
|
self.name = "Test Math Node"
|
|
self.inputs = DummySocketCollection([
|
|
DummyNodeSocket("A", "A", "NodeSocketFloat", value=10.0),
|
|
DummyNodeSocket("B", "B", "NodeSocketFloat", value=3.0),
|
|
], node=self)
|
|
self.outputs = DummySocketCollection([
|
|
DummyNodeSocket("Result", "Result", "NodeSocketFloat", value=0.0)
|
|
], node=self)
|
|
|
|
def evaluate_output(self, socket_name: str, **kwargs):
|
|
a = self.resolve("A") or 0.0
|
|
b = self.resolve("B") or 0.0
|
|
return (a + b) * self.multiplier
|
|
|
|
node = TestMathNode()
|
|
res = node.evaluate_output("Result")
|
|
self.assertEqual(res, (10.0 + 3.0) * 2.5)
|
|
|
|
# Verify dump files created
|
|
dump = get_last_debug_dump()
|
|
self.assertIsNotNone(dump)
|
|
self.assertEqual(dump["node_name"], "Test Math Node")
|
|
self.assertEqual(dump["node_type"], "SLICENODE_TestMath")
|
|
self.assertEqual(dump["socket_evaluated"], "Result")
|
|
self.assertEqual(dump["status"], "OK")
|
|
self.assertEqual(dump["properties"]["multiplier"], 2.5)
|
|
self.assertEqual(dump["inputs"]["A"]["value_summary"], 10.0)
|
|
self.assertEqual(dump["inputs"]["B"]["value_summary"], 3.0)
|
|
self.assertEqual(dump["output_summary"], 32.5)
|
|
|
|
# Check formatted report text
|
|
txt = get_last_debug_text()
|
|
self.assertIn("Test Math Node", txt)
|
|
self.assertIn("Result", txt)
|
|
self.assertIn("32.5", txt)
|
|
|
|
# Check raw unpickled inputs
|
|
raw = load_last_inputs()
|
|
self.assertIsNotNone(raw)
|
|
self.assertEqual(raw["inputs"]["A"], 10.0)
|
|
self.assertEqual(raw["inputs"]["B"], 3.0)
|
|
self.assertEqual(raw["output"], 32.5)
|
|
|
|
def test_debug_complex_slicepaths_and_mesh_dump(self):
|
|
@debug
|
|
class TestSeamNode(SliceNode):
|
|
bl_idname = "SLICENODE_SeamMock"
|
|
mode: str = "CLOSEST"
|
|
acquire_transforms: bool = True
|
|
|
|
def __init__(self, sp, so):
|
|
self.name = "Seam Mock Node"
|
|
self._sp = sp
|
|
self._so = so
|
|
self.inputs = DummySocketCollection([
|
|
DummyNodeSocket("Paths", "Paths", "SLICESOCK_Paths"),
|
|
DummyNodeSocket("Mesh", "Mesh", "SLICESOCK_Mesh"),
|
|
], node=self)
|
|
self.outputs = DummySocketCollection([
|
|
DummyNodeSocket("Paths", "Paths", "SLICESOCK_Paths")
|
|
], node=self)
|
|
|
|
def resolve(self, fieldname):
|
|
if fieldname == "Paths":
|
|
return self._sp
|
|
if fieldname == "Mesh":
|
|
return self._so
|
|
return None
|
|
|
|
def evaluate_output(self, socket_name: str, **kwargs):
|
|
paths = self.resolve("Paths")
|
|
mesh = self.resolve("Mesh")
|
|
return paths.place_seams(mesh, mode=self.mode, acquire_transforms=self.acquire_transforms)
|
|
|
|
# Create SlicePaths
|
|
poly = np.array([[-10.0, -10.0, 0.0], [10.0, -10.0, 0.0], [10.0, 10.0, 0.0], [-10.0, 10.0, 0.0], [-10.0, -10.0, 0.0]])
|
|
sp = SlicePaths([poly], layer_height=0.2)
|
|
|
|
# Create SlicingObject
|
|
tri = np.zeros((2, 4, 3), dtype=np.float64)
|
|
so = SlicingObject()
|
|
so.mesh = tri
|
|
|
|
node = TestSeamNode(sp, so)
|
|
res = node.evaluate_output("Paths")
|
|
self.assertIsInstance(res, SlicePaths)
|
|
|
|
dump = get_last_debug_dump()
|
|
self.assertIsNotNone(dump)
|
|
self.assertEqual(dump["node_type"], "SLICENODE_SeamMock")
|
|
self.assertEqual(dump["properties"]["mode"], "CLOSEST")
|
|
self.assertTrue(dump["properties"]["acquire_transforms"])
|
|
|
|
# Check SlicePaths summary in JSON dump
|
|
paths_summary = dump["inputs"]["Paths"]["value_summary"]
|
|
self.assertEqual(paths_summary["__type__"], "SlicePaths")
|
|
self.assertEqual(paths_summary["num_paths"], 1)
|
|
self.assertEqual(paths_summary["total_vertices"], 5)
|
|
self.assertEqual(paths_summary["layer_height"], 0.2)
|
|
|
|
# Check SlicingObject summary in JSON dump
|
|
mesh_summary = dump["inputs"]["Mesh"]["value_summary"]
|
|
self.assertEqual(mesh_summary["__type__"], "SlicingObject")
|
|
self.assertEqual(mesh_summary["triangles_count"], 2)
|
|
|
|
def test_debug_exception_capture(self):
|
|
@debug
|
|
class CrashingNode(SliceNode):
|
|
bl_idname = "SLICENODE_Crash"
|
|
|
|
def __init__(self):
|
|
self.name = "Crashing Node"
|
|
self.inputs = DummySocketCollection([
|
|
DummyNodeSocket("Divisor", "Divisor", "NodeSocketFloat", value=0.0)
|
|
], node=self)
|
|
|
|
def evaluate_output(self, socket_name: str, **kwargs):
|
|
div = self.resolve("Divisor")
|
|
return 100.0 / div
|
|
|
|
node = CrashingNode()
|
|
with self.assertRaises(ZeroDivisionError):
|
|
node.evaluate_output("Result")
|
|
|
|
# Verify that debug captured the error and traceback
|
|
dump = get_last_debug_dump()
|
|
self.assertIsNotNone(dump)
|
|
self.assertEqual(dump["status"], "ERROR")
|
|
self.assertIn("division by zero", dump["exception"])
|
|
self.assertIsNotNone(dump["traceback"])
|
|
self.assertEqual(dump["inputs"]["Divisor"]["value_summary"], 0.0)
|
|
|
|
# Check text report
|
|
txt = get_last_debug_text()
|
|
self.assertIn("EXCEPTION: float division by zero", txt)
|
|
|
|
def test_debug_function_decorator(self):
|
|
@debug
|
|
def calculate_area(width: float, height: float) -> float:
|
|
return width * height
|
|
|
|
res = calculate_area(5.0, 10.0)
|
|
self.assertEqual(res, 50.0)
|
|
|
|
dump = get_last_debug_dump()
|
|
self.assertIsNotNone(dump)
|
|
self.assertEqual(dump["node_name"], "calculate_area")
|
|
self.assertEqual(dump["inputs"]["arg_0"]["value_summary"], 5.0)
|
|
self.assertEqual(dump["inputs"]["arg_1"]["value_summary"], 10.0)
|
|
self.assertEqual(dump["output_summary"], 50.0)
|
|
|
|
def test_node_dump_inputs_direct_method(self):
|
|
class DirectNode(SliceNode):
|
|
bl_idname = "SLICENODE_Direct"
|
|
factor: float = 3.14
|
|
|
|
def __init__(self):
|
|
self.name = "Direct Node"
|
|
self.inputs = DummySocketCollection([
|
|
DummyNodeSocket("Radius", "Radius", "NodeSocketFloat", value=4.0)
|
|
], node=self)
|
|
|
|
node = DirectNode()
|
|
dump = node.dump_inputs("Output")
|
|
self.assertEqual(dump["node_name"], "Direct Node")
|
|
self.assertEqual(dump["properties"]["factor"], 3.14)
|
|
self.assertEqual(dump["inputs"]["Radius"]["value_summary"], 4.0)
|
|
|
|
def test_debug_decorator_on_method(self):
|
|
class MethodDecoratedNode(SliceNode):
|
|
bl_idname = "SLICENODE_MethodDec"
|
|
|
|
def __init__(self):
|
|
self.name = "Method Node"
|
|
self.inputs = DummySocketCollection([
|
|
DummyNodeSocket("X", "X", "NodeSocketFloat", value=7.0)
|
|
], node=self)
|
|
|
|
@debug
|
|
def evaluate_output(self, socket_name: str, **kwargs):
|
|
return (self.resolve("X") or 0.0) * 2
|
|
|
|
node = MethodDecoratedNode()
|
|
res = node.evaluate_output("Result")
|
|
self.assertEqual(res, 14.0)
|
|
|
|
dump = get_last_debug_dump()
|
|
self.assertIsNotNone(dump)
|
|
self.assertEqual(dump["node_name"], "Method Node")
|
|
self.assertEqual(dump["inputs"]["X"]["value_summary"], 7.0)
|
|
self.assertEqual(dump["output_summary"], 14.0)
|
|
|
|
def test_debug_session_context_manager(self):
|
|
class NormalNode(SliceNode):
|
|
bl_idname = "SLICENODE_Normal"
|
|
|
|
def __init__(self):
|
|
self.name = "Normal Node"
|
|
self.inputs = DummySocketCollection([
|
|
DummyNodeSocket("Val", "Val", "NodeSocketFloat", value=42.0)
|
|
], node=self)
|
|
|
|
def evaluate_output(self, socket_name: str, **kwargs):
|
|
return self.resolve("Val")
|
|
|
|
node = NormalNode()
|
|
|
|
# Inside debug_session, resolve_input or direct calls capture dumps
|
|
with debug_session():
|
|
dump = node.dump_inputs("Val")
|
|
self.assertEqual(dump["inputs"]["Val"]["value_summary"], 42.0)
|
|
|
|
def test_non_existent_socket_error_message_with_resolve(self):
|
|
from NodeSlicer.infra.blender.blender_slice_nodes_generic import NodeException
|
|
|
|
class TestNode(SliceNode):
|
|
bl_idname = "SLICENODE_Test"
|
|
bl_label = "Test Node"
|
|
|
|
def __init__(self):
|
|
self.name = "My Test Node"
|
|
self.inputs = DummySocketCollection([
|
|
DummyNodeSocket("Valid", "Valid", "NodeSocketFloat", value=1.0)
|
|
], node=self)
|
|
|
|
node = TestNode()
|
|
with self.assertRaises(NodeException) as ctx:
|
|
node.resolve("NonExistentSocket")
|
|
|
|
err_msg = str(ctx.exception)
|
|
self.assertIn("NonExistentSocket", err_msg)
|
|
self.assertIn("My Test Node", err_msg)
|
|
self.assertIn("Test Node", err_msg)
|
|
self.assertIn("non-existent", err_msg)
|
|
|
|
def test_non_existent_socket_error_message_isolated(self):
|
|
from NodeSlicer.infra.blender.blender_slice_nodes_generic import NodeException, resolve_input
|
|
|
|
with self.assertRaises(NodeException) as ctx:
|
|
resolve_input(None)
|
|
|
|
err_msg = str(ctx.exception)
|
|
self.assertIn("non-existent", err_msg)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|
|
|
|
|