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) def test_node_failure_includes_node_and_input_names(self): from NodeSlicer.infra.blender.blender_slice_nodes_generic import attach_node_failure_info, get_node_input_names class FailingNode(SliceNode): bl_idname = "SLICENODE_FailingOp" bl_label = "Failing Operation" def __init__(self): self.name = "Custom Failing Node" self.inputs = DummySocketCollection([ DummyNodeSocket("Primary", "Primary", "SLICESOCK_Mesh"), DummyNodeSocket("Offset Val", "Offset Val", "NodeSocketFloat"), DummyNodeSocket("Rotation Angle", "Rotation Angle", "NodeSocketRotation"), ], node=self) node = FailingNode() inputs = get_node_input_names(node) self.assertEqual(inputs, ["Primary", "Offset Val", "Rotation Angle"]) exc = ValueError("Invalid operation arguments") attach_node_failure_info(node, exc) err_msg = str(exc) self.assertIn("Custom Failing Node", err_msg) self.assertIn("Failing Operation", err_msg) self.assertIn("Primary, Offset Val, Rotation Angle", err_msg) self.assertIn("Invalid operation arguments", err_msg) def test_node_failure_preserves_innermost_node_on_bubble_up(self): from NodeSlicer.infra.blender.blender_slice_nodes_generic import attach_node_failure_info class InnerNode(SliceNode): bl_idname = "SLICENODE_Inner" bl_label = "Inner Worker" def __init__(self): self.name = "My Inner Worker" self.inputs = DummySocketCollection([ DummyNodeSocket("InnerInput1", "InnerInput1", "NodeSocketFloat"), DummyNodeSocket("InnerInput2", "InnerInput2", "NodeSocketString"), ], node=self) class OuterNode(SliceNode): bl_idname = "SLICENODE_Outer" bl_label = "Outer Caller" def __init__(self): self.name = "My Outer Caller" self.inputs = DummySocketCollection([ DummyNodeSocket("OuterInput", "OuterInput", "NodeSocketFloat") ], node=self) inner = InnerNode() outer = OuterNode() # Inner node fails first inner_exc = RuntimeError("Hardware buffer failure") attach_node_failure_info(inner, inner_exc) # Bubbles up to outer node attach_node_failure_info(outer, inner_exc) # Must still point to inner node and inner inputs, NOT overwritten by outer node msg = str(inner_exc) self.assertIn("My Inner Worker", msg) self.assertIn("Inner Worker", msg) self.assertIn("InnerInput1, InnerInput2", msg) self.assertNotIn("My Outer Caller", msg) self.assertNotIn("OuterInput", msg) def test_node_error_reporting_proxy_formats_operator_errors(self): from NodeSlicer.infra.blender.blender_slice_nodes_generic import NodeErrorReportingProxy class ActionNode(SliceNode): bl_idname = "SLICENODE_Export" bl_label = "Export Geometry" def __init__(self): self.name = "Export Step" self.inputs = DummySocketCollection([ DummyNodeSocket("Geometry", "Geometry", "SLICESOCK_Mesh"), DummyNodeSocket("Folder", "Folder", "NodeSocketString"), DummyNodeSocket("Filename", "Filename", "NodeSocketString"), ], node=self) node = ActionNode() mock_operator = MagicMock() proxy = NodeErrorReportingProxy(mock_operator, node) # Call error report proxy.report({"ERROR"}, "Directory does not exist") mock_operator.report.assert_called_once() args = mock_operator.report.call_args[0] self.assertEqual(args[0], {"ERROR"}) reported_msg = args[1] self.assertIn("Export Step", reported_msg) self.assertIn("Export Geometry", reported_msg) self.assertIn("Geometry, Folder, Filename", reported_msg) self.assertIn("Directory does not exist", reported_msg) if __name__ == '__main__': unittest.main()