@dpantani/tdmcp 0.4.0 → 0.6.1
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 +15 -10
- package/dist/cli/agent.d.ts +119 -1
- package/dist/cli/agent.js +14188 -1962
- package/dist/cli/agent.js.map +1 -1
- package/dist/index.js +30182 -14832
- package/dist/index.js.map +1 -1
- package/dist/recipes/animus_rings_visualizer.json +37 -0
- package/dist/recipes/body_tracking_reactive.json +127 -0
- package/dist/recipes/mediapipe_body_dots.json +86 -0
- package/dist/recipes/pose_skeleton_mediapipe.json +67 -0
- package/package.json +36 -6
- package/recipes/animus_rings_visualizer.json +37 -0
- package/recipes/body_tracking_reactive.json +127 -0
- package/recipes/mediapipe_body_dots.json +86 -0
- package/recipes/pose_skeleton_mediapipe.json +67 -0
- package/safeskill.manifest.json +37 -0
- package/scripts/fetch-shader-park-td.mjs +79 -0
- package/scripts/tdmcp-lops.mjs +52 -0
- package/td/README.md +1 -1
- package/td/bootstrap.py +41 -9
- package/td/modules/mcp/controllers/api_controller.py +87 -3
- package/td/modules/mcp/install.py +49 -1
- package/td/modules/mcp/services/api_service.py +82 -0
- package/td/modules/mcp/services/connect_service.py +213 -0
- package/td/modules/mcp/services/log_service.py +136 -0
- package/td/modules/mcp/services/param_text_service.py +218 -0
- package/td/modules/utils/version.py +1 -1
- package/td/tests/test_api_controller.py +0 -307
- package/td/tests/test_services.py +0 -209
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
"""Unit tests for the tdmcp bridge HTTP router.
|
|
2
|
-
|
|
3
|
-
The bridge normally runs inside TouchDesigner, where `td`, `op`, `app` and the
|
|
4
|
-
operator classes are injected globals. To exercise the router's pure logic —
|
|
5
|
-
auth, the arbitrary-code gate, request parsing, path decoding and route
|
|
6
|
-
dispatch — off-TD, we install a stub `td` module before importing the package
|
|
7
|
-
and replace the service layer with recorders for dispatch assertions.
|
|
8
|
-
|
|
9
|
-
Run from the repo root: `python3 -m unittest discover -s td/tests`
|
|
10
|
-
(or `npm run test:bridge`). No third-party dependencies — stdlib only.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import os
|
|
14
|
-
import sys
|
|
15
|
-
import types
|
|
16
|
-
import unittest
|
|
17
|
-
from unittest import mock
|
|
18
|
-
|
|
19
|
-
# --- Make the bridge importable without TouchDesigner --------------------------
|
|
20
|
-
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
21
|
-
_MODULES = os.path.abspath(os.path.join(_HERE, "..", "modules"))
|
|
22
|
-
if _MODULES not in sys.path:
|
|
23
|
-
sys.path.insert(0, _MODULES)
|
|
24
|
-
|
|
25
|
-
# `mcp.services.*` bind `op/app/project` from `td` at import time; a stub suffices.
|
|
26
|
-
_td_stub = types.ModuleType("td")
|
|
27
|
-
_td_stub.op = mock.MagicMock(name="op")
|
|
28
|
-
_td_stub.app = mock.MagicMock(name="app")
|
|
29
|
-
_td_stub.project = mock.MagicMock(name="project")
|
|
30
|
-
sys.modules.setdefault("td", _td_stub)
|
|
31
|
-
|
|
32
|
-
from mcp.controllers import api_controller as ac # noqa: E402
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _clear_exec_env():
|
|
36
|
-
os.environ.pop("TDMCP_BRIDGE_ALLOW_EXEC", None)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _clear_token_env():
|
|
40
|
-
os.environ.pop("TDMCP_BRIDGE_TOKEN", None)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class AuthTests(unittest.TestCase):
|
|
44
|
-
def tearDown(self):
|
|
45
|
-
_clear_token_env()
|
|
46
|
-
|
|
47
|
-
def test_no_token_means_auth_off(self):
|
|
48
|
-
_clear_token_env()
|
|
49
|
-
# Should not raise even with no Authorization header.
|
|
50
|
-
ac._check_auth({"method": "GET"})
|
|
51
|
-
|
|
52
|
-
def test_valid_bearer_token_passes(self):
|
|
53
|
-
os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
|
|
54
|
-
ac._check_auth({"Authorization": "Bearer s3cret"})
|
|
55
|
-
|
|
56
|
-
def test_invalid_token_raises(self):
|
|
57
|
-
os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
|
|
58
|
-
with self.assertRaises(PermissionError):
|
|
59
|
-
ac._check_auth({"Authorization": "Bearer wrong"})
|
|
60
|
-
|
|
61
|
-
def test_missing_header_raises_when_token_required(self):
|
|
62
|
-
os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
|
|
63
|
-
with self.assertRaises(PermissionError):
|
|
64
|
-
ac._check_auth({"method": "GET"})
|
|
65
|
-
|
|
66
|
-
def test_header_lookup_is_case_insensitive_and_nested(self):
|
|
67
|
-
os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
|
|
68
|
-
# Header nested under a 'headers' dict with odd casing — must still match.
|
|
69
|
-
ac._check_auth({"headers": {"authorization": "Bearer s3cret"}})
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
class ExecGateTests(unittest.TestCase):
|
|
73
|
-
def tearDown(self):
|
|
74
|
-
_clear_exec_env()
|
|
75
|
-
_clear_token_env()
|
|
76
|
-
|
|
77
|
-
def test_allowed_by_default(self):
|
|
78
|
-
_clear_exec_env()
|
|
79
|
-
self.assertTrue(ac._exec_allowed())
|
|
80
|
-
|
|
81
|
-
def test_disabled_values(self):
|
|
82
|
-
for value in ("0", "false", "FALSE", "no", "off", " Off "):
|
|
83
|
-
os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = value
|
|
84
|
-
self.assertFalse(ac._exec_allowed(), value)
|
|
85
|
-
|
|
86
|
-
def test_enabled_values(self):
|
|
87
|
-
for value in ("1", "true", "yes", "on", "", "anything"):
|
|
88
|
-
os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = value
|
|
89
|
-
self.assertTrue(ac._exec_allowed(), value)
|
|
90
|
-
|
|
91
|
-
def test_route_blocks_exec_when_disabled(self):
|
|
92
|
-
os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = "0"
|
|
93
|
-
with self.assertRaises(PermissionError):
|
|
94
|
-
ac._route("POST", "/api/exec", {}, {"script": "print(1)"})
|
|
95
|
-
|
|
96
|
-
def test_route_blocks_node_method_when_disabled(self):
|
|
97
|
-
os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = "0"
|
|
98
|
-
with self.assertRaises(PermissionError):
|
|
99
|
-
ac._route("POST", "/api/nodes/project1/geo1/method", {}, {"method": "cook"})
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
class RoutingTests(unittest.TestCase):
|
|
103
|
-
"""Dispatch logic, with the service layer swapped for recorders."""
|
|
104
|
-
|
|
105
|
-
def setUp(self):
|
|
106
|
-
_clear_exec_env()
|
|
107
|
-
self._saved = {
|
|
108
|
-
"api": ac.api_service,
|
|
109
|
-
"batch": ac.batch_service,
|
|
110
|
-
"analysis": ac.analysis_service,
|
|
111
|
-
"preview": ac.preview_service,
|
|
112
|
-
}
|
|
113
|
-
ac.api_service = mock.MagicMock(name="api_service")
|
|
114
|
-
ac.batch_service = mock.MagicMock(name="batch_service")
|
|
115
|
-
ac.analysis_service = mock.MagicMock(name="analysis_service")
|
|
116
|
-
ac.preview_service = mock.MagicMock(name="preview_service")
|
|
117
|
-
|
|
118
|
-
def tearDown(self):
|
|
119
|
-
ac.api_service = self._saved["api"]
|
|
120
|
-
ac.batch_service = self._saved["batch"]
|
|
121
|
-
ac.analysis_service = self._saved["analysis"]
|
|
122
|
-
ac.preview_service = self._saved["preview"]
|
|
123
|
-
|
|
124
|
-
def test_get_info(self):
|
|
125
|
-
ac._route("GET", "/api/info", {}, {})
|
|
126
|
-
ac.api_service.get_info.assert_called_once_with()
|
|
127
|
-
|
|
128
|
-
def test_create_node(self):
|
|
129
|
-
ac._route("POST", "/api/nodes", {}, {"parent_path": "/project1", "type": "noiseTOP"})
|
|
130
|
-
ac.api_service.create_node.assert_called_once()
|
|
131
|
-
self.assertEqual(ac.api_service.create_node.call_args.args[0], "/project1")
|
|
132
|
-
self.assertEqual(ac.api_service.create_node.call_args.args[1], "noiseTOP")
|
|
133
|
-
|
|
134
|
-
def test_list_nodes_uses_parent_query(self):
|
|
135
|
-
ac._route("GET", "/api/nodes", {"parent": ["/project1"]}, {})
|
|
136
|
-
ac.api_service.get_nodes.assert_called_once_with("/project1")
|
|
137
|
-
|
|
138
|
-
def test_exec_dispatch_when_allowed(self):
|
|
139
|
-
ac._route("POST", "/api/exec", {}, {"script": "x=1", "return_output": False})
|
|
140
|
-
ac.api_service.exec_script.assert_called_once_with("x=1", False)
|
|
141
|
-
|
|
142
|
-
def test_batch_dispatch(self):
|
|
143
|
-
ops = [{"action": "create", "parent_path": "/p", "type": "noiseTOP"}]
|
|
144
|
-
ac._route("POST", "/api/batch", {}, {"operations": ops})
|
|
145
|
-
ac.batch_service.run.assert_called_once_with(ops)
|
|
146
|
-
|
|
147
|
-
def test_node_get_patch_delete(self):
|
|
148
|
-
ac._route("GET", "/api/nodes/project1/noise1", {}, {})
|
|
149
|
-
ac.api_service.get_node.assert_called_once_with("/project1/noise1")
|
|
150
|
-
|
|
151
|
-
ac._route("PATCH", "/api/nodes/project1/noise1", {}, {"parameters": {"period": 4}})
|
|
152
|
-
ac.api_service.update_parameters.assert_called_once_with("/project1/noise1", {"period": 4})
|
|
153
|
-
|
|
154
|
-
ac._route("DELETE", "/api/nodes/project1/noise1", {}, {})
|
|
155
|
-
ac.api_service.delete_node.assert_called_once_with("/project1/noise1")
|
|
156
|
-
|
|
157
|
-
def test_node_method_dispatch(self):
|
|
158
|
-
ac._route(
|
|
159
|
-
"POST",
|
|
160
|
-
"/api/nodes/project1/geo1/method",
|
|
161
|
-
{},
|
|
162
|
-
{"method": "cook", "args": [1], "kwargs": {"force": True}},
|
|
163
|
-
)
|
|
164
|
-
ac.api_service.call_method.assert_called_once_with("/project1/geo1", "cook", [1], {"force": True})
|
|
165
|
-
|
|
166
|
-
def test_node_errors_dispatch(self):
|
|
167
|
-
ac._route("GET", "/api/nodes/project1/geo1/errors", {}, {})
|
|
168
|
-
ac.api_service.get_node_errors.assert_called_once_with("/project1/geo1", recursive=False)
|
|
169
|
-
|
|
170
|
-
def test_preview_dispatch_with_dimensions(self):
|
|
171
|
-
ac._route("GET", "/api/preview/project1/out1", {"width": ["800"], "height": ["600"]}, {})
|
|
172
|
-
ac.preview_service.capture.assert_called_once_with("/project1/out1", 800, 600)
|
|
173
|
-
|
|
174
|
-
def test_network_topology_dispatch(self):
|
|
175
|
-
ac._route("GET", "/api/network/project1/topology", {"recursive": ["true"]}, {})
|
|
176
|
-
ac.analysis_service.topology.assert_called_once_with("/project1", recursive=True)
|
|
177
|
-
|
|
178
|
-
def test_unknown_route_raises(self):
|
|
179
|
-
with self.assertRaises(ValueError):
|
|
180
|
-
ac._route("GET", "/api/does-not-exist", {}, {})
|
|
181
|
-
|
|
182
|
-
def test_create_node_missing_fields_raises_descriptive(self):
|
|
183
|
-
with self.assertRaises(ValueError) as cm:
|
|
184
|
-
ac._route("POST", "/api/nodes", {}, {})
|
|
185
|
-
self.assertIn("parent_path", str(cm.exception))
|
|
186
|
-
self.assertIn("type", str(cm.exception))
|
|
187
|
-
|
|
188
|
-
def test_exec_missing_script_raises_descriptive(self):
|
|
189
|
-
with self.assertRaises(ValueError) as cm:
|
|
190
|
-
ac._route("POST", "/api/exec", {}, {})
|
|
191
|
-
self.assertIn("script", str(cm.exception))
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
class ParsingTests(unittest.TestCase):
|
|
195
|
-
def test_parse_body_variants(self):
|
|
196
|
-
self.assertEqual(ac._parse_body({"data": ""}), {})
|
|
197
|
-
self.assertEqual(ac._parse_body({"data": None}), {})
|
|
198
|
-
self.assertEqual(ac._parse_body({"data": '{"a": 1}'}), {"a": 1})
|
|
199
|
-
self.assertEqual(ac._parse_body({"data": b'{"b": 2}'}), {"b": 2})
|
|
200
|
-
self.assertEqual(ac._parse_body({"data": {"c": 3}}), {"c": 3})
|
|
201
|
-
|
|
202
|
-
def test_node_path_rejoins_and_restores_leading_slash(self):
|
|
203
|
-
self.assertEqual(ac._node_path(["project1", "noise1"]), "/project1/noise1")
|
|
204
|
-
# Percent-encoded segment decodes back to a slash-bearing path.
|
|
205
|
-
self.assertEqual(ac._node_path(["project1%2Fgeo1"]), "/project1/geo1")
|
|
206
|
-
|
|
207
|
-
def test_qs_returns_first_or_default(self):
|
|
208
|
-
self.assertEqual(ac._qs({"k": ["v1", "v2"]}, "k"), "v1")
|
|
209
|
-
self.assertEqual(ac._qs({}, "missing", "fallback"), "fallback")
|
|
210
|
-
|
|
211
|
-
def test_find_header_top_level_and_default(self):
|
|
212
|
-
self.assertEqual(ac._find_header({"X-Token": "abc"}, "x-token"), "abc")
|
|
213
|
-
self.assertIsNone(ac._find_header({"other": "v"}, "x-token"))
|
|
214
|
-
|
|
215
|
-
def test_find_header_accepts_list_value(self):
|
|
216
|
-
# A repeated/multi-value header may arrive as a list; take the first str.
|
|
217
|
-
self.assertEqual(ac._find_header({"Origin": ["http://x", "http://y"]}, "origin"), "http://x")
|
|
218
|
-
self.assertIsNone(ac._find_header({"Origin": []}, "origin"))
|
|
219
|
-
self.assertIsNone(ac._find_header({"Origin": [123]}, "origin"))
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
class OriginTests(unittest.TestCase):
|
|
223
|
-
"""The Origin guard blocks browser-driven CSRF / DNS-rebinding."""
|
|
224
|
-
|
|
225
|
-
def test_no_origin_is_allowed(self):
|
|
226
|
-
ac._check_origin({"method": "GET"}) # the Node client sends no Origin
|
|
227
|
-
|
|
228
|
-
def test_loopback_origins_allowed(self):
|
|
229
|
-
for origin in (
|
|
230
|
-
"http://127.0.0.1:9980",
|
|
231
|
-
"http://localhost",
|
|
232
|
-
"https://localhost:3000",
|
|
233
|
-
"http://[::1]:9980",
|
|
234
|
-
):
|
|
235
|
-
ac._check_origin({"Origin": origin}) # must not raise
|
|
236
|
-
|
|
237
|
-
def test_cross_origin_rejected(self):
|
|
238
|
-
for origin in ("http://evil.com", "https://attacker.example:8443", "http://192.168.1.5"):
|
|
239
|
-
with self.assertRaises(PermissionError, msg=origin):
|
|
240
|
-
ac._check_origin({"Origin": origin})
|
|
241
|
-
|
|
242
|
-
def test_opaque_null_origin_rejected(self):
|
|
243
|
-
with self.assertRaises(PermissionError):
|
|
244
|
-
ac._check_origin({"Origin": "null"})
|
|
245
|
-
|
|
246
|
-
def test_origin_lookup_is_case_insensitive_and_nested(self):
|
|
247
|
-
with self.assertRaises(PermissionError):
|
|
248
|
-
ac._check_origin({"headers": {"origin": "http://evil.com"}})
|
|
249
|
-
|
|
250
|
-
def test_handle_rejects_cross_origin_with_403(self):
|
|
251
|
-
resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": "http://evil.com"}, {})
|
|
252
|
-
self.assertEqual(resp["statusCode"], 403)
|
|
253
|
-
self.assertIn("cross-origin", resp["data"])
|
|
254
|
-
|
|
255
|
-
def test_handle_rejects_list_valued_cross_origin(self):
|
|
256
|
-
# Some TD builds surface a header as a list; it must not slip the guard.
|
|
257
|
-
resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": ["http://evil.com"]}, {})
|
|
258
|
-
self.assertEqual(resp["statusCode"], 403)
|
|
259
|
-
self.assertIn("cross-origin", resp["data"])
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
class HandleTests(unittest.TestCase):
|
|
263
|
-
def tearDown(self):
|
|
264
|
-
_clear_exec_env()
|
|
265
|
-
_clear_token_env()
|
|
266
|
-
|
|
267
|
-
def _resp(self):
|
|
268
|
-
return {}
|
|
269
|
-
|
|
270
|
-
def test_ok_envelope(self):
|
|
271
|
-
with mock.patch.object(ac, "_route", return_value={"hello": "world"}):
|
|
272
|
-
resp = ac.handle({"method": "GET", "uri": "/api/info"}, self._resp())
|
|
273
|
-
self.assertEqual(resp["statusCode"], 200)
|
|
274
|
-
self.assertIn('"ok": true', resp["data"])
|
|
275
|
-
self.assertIn("world", resp["data"])
|
|
276
|
-
|
|
277
|
-
def test_auth_failure_is_401(self):
|
|
278
|
-
os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
|
|
279
|
-
resp = ac.handle({"method": "GET", "uri": "/api/info"}, self._resp())
|
|
280
|
-
self.assertEqual(resp["statusCode"], 401)
|
|
281
|
-
self.assertIn('"ok": false', resp["data"])
|
|
282
|
-
|
|
283
|
-
def test_error_is_400(self):
|
|
284
|
-
with mock.patch.object(ac, "_route", side_effect=ValueError("boom")):
|
|
285
|
-
resp = ac.handle({"method": "GET", "uri": "/api/info"}, self._resp())
|
|
286
|
-
self.assertEqual(resp["statusCode"], 400)
|
|
287
|
-
self.assertIn("boom", resp["data"])
|
|
288
|
-
|
|
289
|
-
def test_exec_disabled_surfaces_as_403_with_message(self):
|
|
290
|
-
os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = "0"
|
|
291
|
-
resp = ac.handle(
|
|
292
|
-
{"method": "POST", "uri": "/api/exec", "data": '{"script": "1"}'}, self._resp()
|
|
293
|
-
)
|
|
294
|
-
self.assertEqual(resp["statusCode"], 403)
|
|
295
|
-
self.assertIn("disabled", resp["data"])
|
|
296
|
-
|
|
297
|
-
def test_missing_required_field_surfaces_as_400_with_name(self):
|
|
298
|
-
# A POST with valid JSON but a missing field gets a descriptive 400,
|
|
299
|
-
# not a bare KeyError message of just the key name.
|
|
300
|
-
resp = ac.handle({"method": "POST", "uri": "/api/nodes", "data": "{}"}, self._resp())
|
|
301
|
-
self.assertEqual(resp["statusCode"], 400)
|
|
302
|
-
self.assertIn("parent_path", resp["data"])
|
|
303
|
-
self.assertIn("Missing required field", resp["data"])
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if __name__ == "__main__":
|
|
307
|
-
unittest.main()
|
|
@@ -1,209 +0,0 @@
|
|
|
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()
|