@dpantani/tdmcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -143
- package/dist/cli/agent.d.ts +24 -1
- package/dist/cli/agent.js +423 -365
- package/dist/cli/agent.js.map +1 -1
- package/dist/index.js +1723 -491
- package/dist/index.js.map +1 -1
- package/dist/knowledge/data/meta.json +1 -1
- package/dist/recipes/feedback_tunnel.json +4 -4
- package/dist/recipes/noise_landscape.json +102 -1
- package/dist/recipes/performable_feedback_tunnel.json +92 -0
- package/dist/recipes/webcam_glitch.json +5 -1
- package/package.json +1 -1
- package/recipes/feedback_tunnel.json +4 -4
- package/recipes/noise_landscape.json +102 -1
- package/recipes/performable_feedback_tunnel.json +92 -0
- package/recipes/webcam_glitch.json +5 -1
- package/td/modules/mcp/controllers/api_controller.py +29 -1
- package/td/modules/mcp/services/analysis_service.py +7 -2
- package/td/modules/mcp/services/api_service.py +25 -3
- package/td/modules/mcp/services/batch_service.py +36 -2
- package/td/modules/mcp/services/preview_service.py +84 -11
- package/td/tests/test_api_controller.py +34 -0
- package/td/tests/test_services.py +209 -0
|
@@ -11,8 +11,42 @@ def connect(source_path, target_path, source_output=0, target_input=0):
|
|
|
11
11
|
src = op(source_path)
|
|
12
12
|
dst = op(target_path)
|
|
13
13
|
if src is None or dst is None:
|
|
14
|
-
raise LookupError("Source or target not found")
|
|
15
|
-
|
|
14
|
+
raise LookupError("Source or target not found: %s -> %s" % (source_path, target_path))
|
|
15
|
+
src_parent = src.parent()
|
|
16
|
+
dst_parent = dst.parent()
|
|
17
|
+
# TD wires only connect operators sharing a parent. A cross-container connect
|
|
18
|
+
# silently no-ops (no exception, no wire), so reject it with an actionable msg.
|
|
19
|
+
if src_parent is None or dst_parent is None or src_parent.path != dst_parent.path:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
"Cannot wire across containers: %s (in %s) -> %s (in %s). "
|
|
22
|
+
"Wires only connect operators sharing a parent; to bring an operator "
|
|
23
|
+
"across networks use a Select/In OP (e.g. a Select TOP/CHOP whose source "
|
|
24
|
+
"parameter points at %r)."
|
|
25
|
+
% (
|
|
26
|
+
src.path,
|
|
27
|
+
getattr(src_parent, "path", "<root>"),
|
|
28
|
+
dst.path,
|
|
29
|
+
getattr(dst_parent, "path", "<root>"),
|
|
30
|
+
source_path,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
in_conn = dst.inputConnectors[target_input]
|
|
35
|
+
out_conn = src.outputConnectors[source_output]
|
|
36
|
+
except IndexError:
|
|
37
|
+
raise IndexError(
|
|
38
|
+
"Connector index out of range: target_input=%d (%s has %d input(s)), "
|
|
39
|
+
"source_output=%d (%s has %d output(s))."
|
|
40
|
+
% (
|
|
41
|
+
target_input,
|
|
42
|
+
dst.path,
|
|
43
|
+
len(dst.inputConnectors),
|
|
44
|
+
source_output,
|
|
45
|
+
src.path,
|
|
46
|
+
len(src.outputConnectors),
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
in_conn.connect(out_conn)
|
|
16
50
|
|
|
17
51
|
|
|
18
52
|
def run(operations):
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
"""Capture a TOP as a base64-encoded PNG.
|
|
1
|
+
"""Capture a TOP as a base64-encoded PNG.
|
|
2
|
+
|
|
3
|
+
The TOP is composited over a checkerboard first, so transparent regions read as a
|
|
4
|
+
checker pattern instead of solid white when a viewer flattens the PNG's alpha over a
|
|
5
|
+
white page. Opaque TOPs are unaffected (the checker stays fully hidden). If the
|
|
6
|
+
composite path errors for any reason, we fall back to saving the TOP directly.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
import base64
|
|
4
10
|
|
|
@@ -6,14 +12,18 @@ import td
|
|
|
6
12
|
|
|
7
13
|
op = td.op # TD globals are not available inside imported modules; reach via td
|
|
8
14
|
|
|
15
|
+
# Mid-gray checkerboard (16x9 cells). Self-contained fragment shader, no inputs/uniforms.
|
|
16
|
+
_CHECKER_FRAG = """out vec4 fragColor;
|
|
17
|
+
void main(){
|
|
18
|
+
vec2 cell = floor(vUV.st * vec2(16.0, 9.0));
|
|
19
|
+
float k = mod(cell.x + cell.y, 2.0);
|
|
20
|
+
fragColor = vec4(mix(vec3(0.16), vec3(0.30), k), 1.0);
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
9
23
|
|
|
10
|
-
def capture(path, width=640, height=360):
|
|
11
|
-
node = op(path)
|
|
12
|
-
if node is None:
|
|
13
|
-
raise LookupError("Node not found: %s" % path)
|
|
14
|
-
if getattr(node, "family", None) != "TOP":
|
|
15
|
-
raise ValueError("Preview is only supported for TOPs, got %s" % path)
|
|
16
24
|
|
|
25
|
+
def _save_png(node):
|
|
26
|
+
"""Return the node's PNG bytes, via saveByteArray with a temp-file fallback."""
|
|
17
27
|
data = None
|
|
18
28
|
try:
|
|
19
29
|
data = node.saveByteArray(".png")
|
|
@@ -21,7 +31,6 @@ def capture(path, width=640, height=360):
|
|
|
21
31
|
data = None
|
|
22
32
|
|
|
23
33
|
if data is None:
|
|
24
|
-
# Fallback: save to a temp file and read it back.
|
|
25
34
|
import os
|
|
26
35
|
import tempfile
|
|
27
36
|
|
|
@@ -30,11 +39,75 @@ def capture(path, width=640, height=360):
|
|
|
30
39
|
with open(tmp, "rb") as handle:
|
|
31
40
|
data = handle.read()
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
return bytes(data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _checkerboard_png(node, width, height):
|
|
46
|
+
"""Composite `node` over a checkerboard and return the flattened PNG bytes.
|
|
47
|
+
|
|
48
|
+
Creates a few temporary nodes in the node's parent and destroys them afterwards
|
|
49
|
+
(even on error). Returns None if the composite could not be produced, so callers
|
|
50
|
+
can fall back to a direct save.
|
|
51
|
+
"""
|
|
52
|
+
parent = node.parent()
|
|
53
|
+
if parent is None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
temps = []
|
|
57
|
+
try:
|
|
58
|
+
frag = parent.create("textDAT", "__tdmcp_pv_frag")
|
|
59
|
+
temps.append(frag)
|
|
60
|
+
frag.text = _CHECKER_FRAG
|
|
61
|
+
|
|
62
|
+
bg = parent.create("glslTOP", "__tdmcp_pv_bg")
|
|
63
|
+
temps.append(bg)
|
|
64
|
+
bg.par.pixeldat = frag.name
|
|
65
|
+
bg.par.outputresolution = "custom"
|
|
66
|
+
bg.par.resolutionw = width
|
|
67
|
+
bg.par.resolutionh = height
|
|
68
|
+
|
|
69
|
+
comp = parent.create("compositeTOP", "__tdmcp_pv_comp")
|
|
70
|
+
temps.append(comp)
|
|
71
|
+
comp.par.operand = "over"
|
|
72
|
+
comp.par.outputresolution = "custom"
|
|
73
|
+
comp.par.resolutionw = width
|
|
74
|
+
comp.par.resolutionh = height
|
|
75
|
+
comp.inputConnectors[0].connect(node) # foreground (over)
|
|
76
|
+
comp.inputConnectors[1].connect(bg) # background (under)
|
|
77
|
+
comp.cook(force=True)
|
|
78
|
+
|
|
79
|
+
if comp.errors():
|
|
80
|
+
return None
|
|
81
|
+
return _save_png(comp)
|
|
82
|
+
except Exception: # noqa: BLE001
|
|
83
|
+
return None
|
|
84
|
+
finally:
|
|
85
|
+
for t in reversed(temps):
|
|
86
|
+
try:
|
|
87
|
+
t.destroy()
|
|
88
|
+
except Exception: # noqa: BLE001
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def capture(path, width=640, height=360):
|
|
93
|
+
node = op(path)
|
|
94
|
+
if node is None:
|
|
95
|
+
raise LookupError("Node not found: %s" % path)
|
|
96
|
+
if getattr(node, "family", None) != "TOP":
|
|
97
|
+
raise ValueError("Preview is only supported for TOPs, got %s" % path)
|
|
98
|
+
|
|
99
|
+
w = int(getattr(node, "width", width) or width)
|
|
100
|
+
h = int(getattr(node, "height", height) or height)
|
|
101
|
+
|
|
102
|
+
data = _checkerboard_png(node, w, h)
|
|
103
|
+
if data is None:
|
|
104
|
+
data = _save_png(node)
|
|
105
|
+
|
|
106
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
34
107
|
return {
|
|
35
108
|
"path": node.path,
|
|
36
|
-
"width":
|
|
37
|
-
"height":
|
|
109
|
+
"width": w,
|
|
110
|
+
"height": h,
|
|
38
111
|
"format": "png",
|
|
39
112
|
"base64": encoded,
|
|
40
113
|
}
|
|
@@ -202,6 +202,40 @@ class ParsingTests(unittest.TestCase):
|
|
|
202
202
|
self.assertIsNone(ac._find_header({"other": "v"}, "x-token"))
|
|
203
203
|
|
|
204
204
|
|
|
205
|
+
class OriginTests(unittest.TestCase):
|
|
206
|
+
"""The Origin guard blocks browser-driven CSRF / DNS-rebinding."""
|
|
207
|
+
|
|
208
|
+
def test_no_origin_is_allowed(self):
|
|
209
|
+
ac._check_origin({"method": "GET"}) # the Node client sends no Origin
|
|
210
|
+
|
|
211
|
+
def test_loopback_origins_allowed(self):
|
|
212
|
+
for origin in (
|
|
213
|
+
"http://127.0.0.1:9980",
|
|
214
|
+
"http://localhost",
|
|
215
|
+
"https://localhost:3000",
|
|
216
|
+
"http://[::1]:9980",
|
|
217
|
+
):
|
|
218
|
+
ac._check_origin({"Origin": origin}) # must not raise
|
|
219
|
+
|
|
220
|
+
def test_cross_origin_rejected(self):
|
|
221
|
+
for origin in ("http://evil.com", "https://attacker.example:8443", "http://192.168.1.5"):
|
|
222
|
+
with self.assertRaises(PermissionError, msg=origin):
|
|
223
|
+
ac._check_origin({"Origin": origin})
|
|
224
|
+
|
|
225
|
+
def test_opaque_null_origin_rejected(self):
|
|
226
|
+
with self.assertRaises(PermissionError):
|
|
227
|
+
ac._check_origin({"Origin": "null"})
|
|
228
|
+
|
|
229
|
+
def test_origin_lookup_is_case_insensitive_and_nested(self):
|
|
230
|
+
with self.assertRaises(PermissionError):
|
|
231
|
+
ac._check_origin({"headers": {"origin": "http://evil.com"}})
|
|
232
|
+
|
|
233
|
+
def test_handle_rejects_cross_origin_with_401(self):
|
|
234
|
+
resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": "http://evil.com"}, {})
|
|
235
|
+
self.assertEqual(resp["statusCode"], 401)
|
|
236
|
+
self.assertIn("cross-origin", resp["data"])
|
|
237
|
+
|
|
238
|
+
|
|
205
239
|
class HandleTests(unittest.TestCase):
|
|
206
240
|
def tearDown(self):
|
|
207
241
|
_clear_exec_env()
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Unit tests for the bridge service layer: parameter validation + connect guards.
|
|
2
|
+
|
|
3
|
+
Exercises api_service.update_parameters / create_node and batch_service.connect
|
|
4
|
+
off-TD using lightweight fakes for TD node objects. These lock in the fixes for
|
|
5
|
+
two silent-failure bugs:
|
|
6
|
+
|
|
7
|
+
* setting an unknown parameter name (e.g. `gain` on a levelTOP) used to be
|
|
8
|
+
silently dropped; it now raises (update) or warns (create).
|
|
9
|
+
* wiring two operators in different containers used to silently no-op; it now
|
|
10
|
+
raises with an actionable message.
|
|
11
|
+
|
|
12
|
+
Stdlib only. Run: `python3 -m unittest discover -s td/tests` (or `npm run test:bridge`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import types
|
|
18
|
+
import unittest
|
|
19
|
+
from unittest import mock
|
|
20
|
+
|
|
21
|
+
# --- Make the bridge importable without TouchDesigner --------------------------
|
|
22
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
_MODULES = os.path.abspath(os.path.join(_HERE, "..", "modules"))
|
|
24
|
+
if _MODULES not in sys.path:
|
|
25
|
+
sys.path.insert(0, _MODULES)
|
|
26
|
+
|
|
27
|
+
# `mcp.services.*` bind `op/app/project` from `td` at import time; a stub suffices.
|
|
28
|
+
_td_stub = types.ModuleType("td")
|
|
29
|
+
_td_stub.op = mock.MagicMock(name="op")
|
|
30
|
+
_td_stub.app = mock.MagicMock(name="app")
|
|
31
|
+
_td_stub.project = mock.MagicMock(name="project")
|
|
32
|
+
sys.modules.setdefault("td", _td_stub)
|
|
33
|
+
|
|
34
|
+
from mcp.services import analysis_service, api_service, batch_service # noqa: E402
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- Lightweight fakes for TD node objects -------------------------------------
|
|
38
|
+
class FakePar:
|
|
39
|
+
def __init__(self, name, val=None):
|
|
40
|
+
self.name = name
|
|
41
|
+
self.val = val
|
|
42
|
+
|
|
43
|
+
def eval(self):
|
|
44
|
+
return self.val
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FakeParCollection:
|
|
48
|
+
"""getattr(self, name, None) -> FakePar for known names, None otherwise."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, names):
|
|
51
|
+
for n in names:
|
|
52
|
+
setattr(self, n, FakePar(n))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FakeConnector:
|
|
56
|
+
def __init__(self):
|
|
57
|
+
self.connected_to = None
|
|
58
|
+
|
|
59
|
+
def connect(self, other):
|
|
60
|
+
self.connected_to = other
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FakeNode:
|
|
64
|
+
def __init__(self, path, par_names=(), parent_path="/project1", n_in=1, n_out=1):
|
|
65
|
+
self.path = path
|
|
66
|
+
self.name = path.rsplit("/", 1)[-1]
|
|
67
|
+
self.OPType = "fakeTOP"
|
|
68
|
+
self.par = FakeParCollection(par_names)
|
|
69
|
+
self.inputs = []
|
|
70
|
+
self.outputs = []
|
|
71
|
+
self.inputConnectors = [FakeConnector() for _ in range(n_in)]
|
|
72
|
+
self.outputConnectors = [FakeConnector() for _ in range(n_out)]
|
|
73
|
+
self._parent_path = parent_path
|
|
74
|
+
|
|
75
|
+
def pars(self):
|
|
76
|
+
return [v for v in vars(self.par).values() if isinstance(v, FakePar)]
|
|
77
|
+
|
|
78
|
+
def parent(self):
|
|
79
|
+
return types.SimpleNamespace(path=self._parent_path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class UpdateParametersTests(unittest.TestCase):
|
|
83
|
+
def test_rejects_unknown_param_atomically(self):
|
|
84
|
+
node = FakeNode("/project1/lvl", ["brightness1"])
|
|
85
|
+
with mock.patch.object(api_service, "op", lambda p: node):
|
|
86
|
+
with self.assertRaises(ValueError) as cm:
|
|
87
|
+
api_service.update_parameters("/project1/lvl", {"gain": 0.5, "brightness1": 0.9})
|
|
88
|
+
self.assertIn("gain", str(cm.exception))
|
|
89
|
+
# Atomic: a valid sibling param in the same call is NOT applied either.
|
|
90
|
+
self.assertIsNone(node.par.brightness1.val)
|
|
91
|
+
|
|
92
|
+
def test_applies_known_params(self):
|
|
93
|
+
node = FakeNode("/project1/lvl", ["brightness1", "opacity"])
|
|
94
|
+
with mock.patch.object(api_service, "op", lambda p: node):
|
|
95
|
+
detail = api_service.update_parameters(
|
|
96
|
+
"/project1/lvl", {"brightness1": 0.85, "opacity": 0.5}
|
|
97
|
+
)
|
|
98
|
+
self.assertEqual(node.par.brightness1.val, 0.85)
|
|
99
|
+
self.assertEqual(node.par.opacity.val, 0.5)
|
|
100
|
+
self.assertEqual(detail["path"], "/project1/lvl")
|
|
101
|
+
|
|
102
|
+
def test_missing_node_raises(self):
|
|
103
|
+
with mock.patch.object(api_service, "op", lambda p: None):
|
|
104
|
+
with self.assertRaises(LookupError):
|
|
105
|
+
api_service.update_parameters("/nope", {"x": 1})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CreateNodeWarningTests(unittest.TestCase):
|
|
109
|
+
def test_failed_params_become_warnings_not_silent(self):
|
|
110
|
+
node = FakeNode("/project1/lvl", ["brightness1"])
|
|
111
|
+
parent = mock.MagicMock(name="parent")
|
|
112
|
+
parent.create.return_value = node
|
|
113
|
+
with mock.patch.object(api_service, "op", lambda p: parent), mock.patch.object(
|
|
114
|
+
api_service, "_resolve_type", lambda t: object
|
|
115
|
+
):
|
|
116
|
+
ref = api_service.create_node(
|
|
117
|
+
"/project1", "fakeTOP", "lvl", {"gain": 1, "brightness1": 0.5}
|
|
118
|
+
)
|
|
119
|
+
# Node is still created; the bad param surfaces as a warning, not silence.
|
|
120
|
+
self.assertEqual(ref["path"], "/project1/lvl")
|
|
121
|
+
self.assertIn("gain", ref.get("parameter_warnings", []))
|
|
122
|
+
# The valid param still applied.
|
|
123
|
+
self.assertEqual(node.par.brightness1.val, 0.5)
|
|
124
|
+
|
|
125
|
+
def test_no_warnings_when_all_params_valid(self):
|
|
126
|
+
node = FakeNode("/project1/lvl", ["brightness1"])
|
|
127
|
+
parent = mock.MagicMock(name="parent")
|
|
128
|
+
parent.create.return_value = node
|
|
129
|
+
with mock.patch.object(api_service, "op", lambda p: parent), mock.patch.object(
|
|
130
|
+
api_service, "_resolve_type", lambda t: object
|
|
131
|
+
):
|
|
132
|
+
ref = api_service.create_node("/project1", "fakeTOP", "lvl", {"brightness1": 0.5})
|
|
133
|
+
self.assertNotIn("parameter_warnings", ref)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ConnectGuardTests(unittest.TestCase):
|
|
137
|
+
def _patch_op(self, nodes):
|
|
138
|
+
return mock.patch.object(batch_service, "op", lambda p: nodes.get(p))
|
|
139
|
+
|
|
140
|
+
def test_rejects_cross_container(self):
|
|
141
|
+
src = FakeNode("/project1/a/x", parent_path="/project1/a")
|
|
142
|
+
dst = FakeNode("/project1/b/y", parent_path="/project1/b")
|
|
143
|
+
with self._patch_op({"/project1/a/x": src, "/project1/b/y": dst}):
|
|
144
|
+
with self.assertRaises(ValueError) as cm:
|
|
145
|
+
batch_service.connect("/project1/a/x", "/project1/b/y")
|
|
146
|
+
self.assertIn("across containers", str(cm.exception))
|
|
147
|
+
# No phantom wire was made.
|
|
148
|
+
self.assertIsNone(dst.inputConnectors[0].connected_to)
|
|
149
|
+
|
|
150
|
+
def test_same_parent_wires(self):
|
|
151
|
+
src = FakeNode("/project1/x", parent_path="/project1")
|
|
152
|
+
dst = FakeNode("/project1/y", parent_path="/project1")
|
|
153
|
+
with self._patch_op({"/project1/x": src, "/project1/y": dst}):
|
|
154
|
+
batch_service.connect("/project1/x", "/project1/y")
|
|
155
|
+
self.assertIs(dst.inputConnectors[0].connected_to, src.outputConnectors[0])
|
|
156
|
+
|
|
157
|
+
def test_bad_connector_index_raises(self):
|
|
158
|
+
src = FakeNode("/project1/x", parent_path="/project1")
|
|
159
|
+
dst = FakeNode("/project1/y", parent_path="/project1", n_in=1)
|
|
160
|
+
with self._patch_op({"/project1/x": src, "/project1/y": dst}):
|
|
161
|
+
with self.assertRaises(IndexError):
|
|
162
|
+
batch_service.connect("/project1/x", "/project1/y", target_input=3)
|
|
163
|
+
|
|
164
|
+
def test_missing_node_raises(self):
|
|
165
|
+
with self._patch_op({}):
|
|
166
|
+
with self.assertRaises(LookupError):
|
|
167
|
+
batch_service.connect("/a", "/b")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _PerfNode:
|
|
171
|
+
def __init__(self, path, cook_time=0.0, cook_count=0):
|
|
172
|
+
self.path = path
|
|
173
|
+
self.cookTime = cook_time
|
|
174
|
+
self.cookCount = cook_count
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _PerfRoot:
|
|
178
|
+
"""Returns direct children at depth=1, all descendants otherwise — like a TD COMP."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, direct, nested):
|
|
181
|
+
self._direct = direct
|
|
182
|
+
self._nested = nested
|
|
183
|
+
|
|
184
|
+
def findChildren(self, depth=None):
|
|
185
|
+
return self._direct if depth == 1 else self._nested
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class PerformanceRecursiveTests(unittest.TestCase):
|
|
189
|
+
def _run(self, recursive):
|
|
190
|
+
direct = [_PerfNode("/p/a", 1.0)]
|
|
191
|
+
nested = [_PerfNode("/p/a", 1.0), _PerfNode("/p/sys/inner", 2.0)]
|
|
192
|
+
root = _PerfRoot(direct, nested)
|
|
193
|
+
with mock.patch.object(analysis_service, "op", lambda path: root):
|
|
194
|
+
return analysis_service.performance("/p", recursive=recursive)
|
|
195
|
+
|
|
196
|
+
def test_shallow_measures_only_direct_children(self):
|
|
197
|
+
result = self._run(recursive=False)
|
|
198
|
+
self.assertEqual([n["path"] for n in result["nodes"]], ["/p/a"])
|
|
199
|
+
self.assertEqual(result["total_cook_time_ms"], 1.0)
|
|
200
|
+
|
|
201
|
+
def test_recursive_measures_nested_nodes_too(self):
|
|
202
|
+
result = self._run(recursive=True)
|
|
203
|
+
self.assertEqual(sorted(n["path"] for n in result["nodes"]), ["/p/a", "/p/sys/inner"])
|
|
204
|
+
# Nested node's cook time is now counted in the total.
|
|
205
|
+
self.assertEqual(result["total_cook_time_ms"], 3.0)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
unittest.main()
|