nodeslicer/tests/test_debug.py
2026-10-14 13:54:04 +03:00

461 lines
17 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)
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()