@dpantani/tdmcp 0.4.0 → 0.5.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.
@@ -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()