SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
tools/check.py (90.7K)
1 """Headless smoke check for the hc package.
2
3 There is no test runner in this project -- everything runs inside Houdini's
4 embedded interpreter, and a broken command in the HC Panel just looks like
5 "nothing happened". This catches the cheap half of that class of bug without
6 launching the GUI:
7
8 $HFS/bin/hython3.13 tools/check.py
9
10 Run it from the repo root. It imports hc, binds every HCMaps entry (so a
11 command whose method was renamed or deleted fails loudly), and exercises the
12 pure logic that does not need a UI. It cannot replace testing in Houdini --
13 anything touching hou.ui, panes or the network editor still has to be driven
14 by hand -- but a failure here is always a real one.
15 """
16
17 import os
18 import sys
19 import time
20 import types
21 from pathlib import Path
22
23 ROOT = Path(__file__).resolve().parent.parent
24 sys.path.insert(0, str(ROOT / "python3.13libs"))
25 os.environ.setdefault("HC_PATH", str(ROOT))
26
27 import hou # noqa: E402
28
29 # hython has already imported `hc` from the checkout the package manifest
30 # names before this script runs. In a git worktree that is the *other*
31 # checkout, and every check below would silently exercise its code instead
32 # of the one being edited. Drop that copy so the import below resolves
33 # against ROOT.
34 for _name, _mod in list(sys.modules.items()):
35 if _name == "hc" or _name.startswith("hc."):
36 _file = getattr(_mod, "__file__", None) or ""
37 if not _file.startswith(str(ROOT / "python3.13libs")):
38 del sys.modules[_name]
39
40 from hc import ( # noqa: E402
41 HCNetworkEditor,
42 HCPane,
43 HCParameterTab,
44 HCPathTab,
45 HCSceneViewer,
46 HCSession,
47 HCSettings,
48 HCTab,
49 hcschema,
50 hcstate,
51 )
52 from hc import hccommands # noqa: E402
53 from hc.hcgeometryutils import merged_visible_geo # noqa: E402
54 from hc.hcmaps import HCMaps # noqa: E402
55 from hc.hcvisibility import childCategory, collect_visible_nodes # noqa: E402
56
57
58 passed = 0
59 failed = 0
60
61
62 def check(label, fn):
63 global passed, failed
64 try:
65 result = fn()
66 except Exception as e:
67 print(f" FAIL {label}: {type(e).__name__}: {e}")
68 failed += 1
69 else:
70 print(f" ok {label}: {result}")
71 passed += 1
72
73
74 def blank(cls):
75 """An uninitialized instance -- enough to resolve bound methods."""
76 return cls.__new__(cls)
77
78
79 def withTab(cls, **hou_attrs):
80 """A blank wrapper around a fake hou tab answering `hou_attrs`.
81
82 Binding commands evaluates each `available` predicate on the instance,
83 and those read self.hou_tab -- which a blank() has not got.
84 """
85 instance = blank(cls)
86 instance.hou_tab = types.SimpleNamespace(
87 **{name: (lambda v=value: v) for name, value in hou_attrs.items()})
88 return instance
89
90
91 class nodeColoring:
92 """Pin the node_coloring switch for one check.
93
94 The maintenance checks exercise what updateNodeColors() does when it
95 runs; whether it runs is the user's setting in hc_settings.json, which
96 ships off since 3c95f98. Reading the file made four checks fail on a
97 setting, not a bug. Use as a context manager or a decorator.
98 """
99
100 def __init__(self, enabled):
101 self.enabled = enabled
102
103 def __enter__(self):
104 self.original = HCSettings.nodeColoringEnabled
105 HCSettings.nodeColoringEnabled = lambda _self, enabled=self.enabled: enabled
106
107 def __exit__(self, *exc):
108 HCSettings.nodeColoringEnabled = self.original
109
110 def __call__(self, fn):
111 def wrapped():
112 with self:
113 return fn()
114 return wrapped
115
116
117 def check_settings():
118 print("settings")
119 settings = HCSettings()
120
121 def merged_defaults():
122 # A section missing from hc_settings.json must still come back whole:
123 # callers index straight into prefs()['node_graph']['node_color'].
124 node_graph = HCSettings._merged(HCSettings.DEFAULTS, {"node_graph": {}})["node_graph"]
125 missing = set(HCSettings.DEFAULTS["node_graph"]) - set(node_graph)
126 assert not missing, f"defaults dropped: {sorted(missing)}"
127 return f"{len(node_graph)} node_graph keys"
128
129 check("prefs() overlays DEFAULTS", merged_defaults)
130 check("nodeColor() parses the hex", lambda: settings.nodeColor())
131 check("nodeShape()", lambda: settings.nodeShape())
132 check("hcnetcursorEnabled()", lambda: settings.hcnetcursorEnabled())
133
134 def copy_is_independent():
135 original = settings.prefs()["node_graph"]["node_color"]
136 settings.prefsCopy()["node_graph"]["node_color"] = "#000000"
137 assert settings.prefs()["node_graph"]["node_color"] == original, \
138 "prefsCopy() aliased the cache"
139 return original
140
141 check("prefsCopy() does not alias the cache", copy_is_independent)
142
143 def cache_is_warm():
144 start = time.perf_counter()
145 for _ in range(2000):
146 settings.prefs()
147 micros = (time.perf_counter() - start) / 2000 * 1e6
148 assert micros < 200, f"{micros:.0f}us per read -- cache not working"
149 return f"{micros:.1f}us per read"
150
151 check("prefs() is cached on the hot path", cache_is_warm)
152
153 def schema_covers_the_file():
154 """Every section in the shipped file must be declared in the schema.
155
156 keycam used to be absent from DEFAULTS entirely, so keycam.py and
157 hcguides.py had nothing to fall back on.
158 """
159 on_disk = set(settings.prefs())
160 declared = set(hcschema.defaults())
161 undeclared = on_disk - declared
162 assert not undeclared, f"settings not in the schema: {sorted(undeclared)}"
163 return f"{len(declared)} top-level sections"
164
165 check("schema declares every section", schema_covers_the_file)
166
167 def keycam_survives_a_missing_section():
168 merged = HCSettings._merged(HCSettings.DEFAULTS, {"desktop_mode": "attached"})
169 # The exact chains keycam.py and hcguides.py walk.
170 assert merged["keycam"]["units"]["delta_r"] is not None
171 assert merged["keycam"]["guides"]["axis_size"] is not None
172 assert settings.keycam("units").get("delta_r") is not None
173 assert settings.section("nope", "nothing") == {}, "section() must not return None"
174 return "keycam reads survive an absent section"
175
176 check("keycam has defaults", keycam_survives_a_missing_section)
177
178 def widgets_come_from_declared_kinds():
179 """delta_ow/delta_z are step magnitudes that happen to equal 1; the old
180 panel guessed from the value and rendered them as checkboxes."""
181 for name in ("delta_ow", "delta_z"):
182 setting = hcschema.lookup(("keycam", "units", name))
183 assert setting is not None, f"{name} not declared"
184 assert setting.kind == "float", f"{name} is {setting.kind}, expected float"
185 assert hcschema.lookup(("keycam", "guides", "bbox")).kind == "flag"
186 assert hcschema.lookup(("node_graph", "hcnetcursor")).kind == "bool"
187 return "step magnitudes declared float, flags declared flag"
188
189 check("widget kinds are declared, not guessed", widgets_come_from_declared_kinds)
190
191 def restart_flags():
192 """The panel's restart notice: settings read only at startup are
193 declared restart=True, and restartPending() compares the saved value
194 with the snapshot uiready captured."""
195 paths = hcschema.restart_paths()
196 for expected in (("desktop_mode",), ("startup", "show_prompt"),
197 ("startup", "default_autosave_state"),
198 ("startup", "show_main_menu")):
199 assert expected in paths, f"{expected} not declared restart-only: {paths}"
200 assert all(hcschema.lookup(p).restart for p in paths)
201 assert not hcschema.lookup(("node_graph", "node_shape")).restart, \
202 "node_shape is read live by OnCreated.py"
203 settings.captureStartupValues()
204 assert settings.restartPending() == [], settings.restartPending()
205 snapshot = settings.startupValues()
206 snapshot["desktop_mode"] = "detached" if snapshot["desktop_mode"] == "attached" else "attached"
207 try:
208 assert settings.restartPending() == [("desktop_mode",)], settings.restartPending()
209 finally:
210 settings.captureStartupValues()
211 return f"{len(paths)} restart-only settings; pending tracks the startup snapshot"
212
213 check("restart-only settings", restart_flags)
214
215
216 def check_commands():
217 print("commands")
218 maps = HCMaps()
219 session, pane = blank(HCSession), blank(HCPane)
220
221 for label, cls in (
222 ("network editor", HCNetworkEditor),
223 ("scene viewer", HCSceneViewer),
224 ("details view", HCPathTab),
225 ("parameters", HCParameterTab),
226 ("other tabs", HCTab),
227 ):
228 def binds(c=cls):
229 commands = maps.commands(session, pane, withTab(c, hasNetworkControls=True))
230 assert commands, "no commands bound"
231 for name, method in commands.items():
232 assert callable(method), f"{name} is not callable"
233 assert method.kind in hccommands.KINDS, f"{name}: kind {method.kind!r}"
234 kinds = {}
235 for bound in commands.values():
236 kinds[bound.kind] = kinds.get(bound.kind, 0) + 1
237 return f"{len(commands)} commands ({kinds})"
238
239 check(f"{label} panel binds", binds)
240
241 def scoping_holds():
242 """Where a command shows is the class it is declared on: Pin on
243 HCPathTab reaches every path tab and no other; Replace Node stays in
244 network editors. Path is the one command whose scope is a predicate,
245 because plain HCTab tabs differ in whether they have a path bar."""
246 for cls in (HCPathTab, HCParameterTab, HCNetworkEditor, HCSceneViewer):
247 bound = maps.commands(session, pane, withTab(cls, hasNetworkControls=True))
248 assert "Pin" in bound, f"{cls.__name__} lost Pin"
249 assert bound["Pin"].kind == "toggle", f"{cls.__name__}: Pin is not a toggle"
250 assert "Path" in bound, f"{cls.__name__} lost Path"
251 other = maps.commands(session, pane, withTab(HCTab, hasNetworkControls=True))
252 assert "Pin" not in other, "Pin leaked into tabs without a path"
253 assert "Path" in other, "a plain tab with a path bar lost Path"
254 bare = maps.commands(session, pane, withTab(HCTab, hasNetworkControls=False))
255 assert "Path" not in bare, "Path offered on a tab with no path bar"
256 network = maps.commands(session, pane, withTab(HCNetworkEditor, hasNetworkControls=True))
257 parm = maps.commands(session, pane, withTab(HCParameterTab, hasNetworkControls=True))
258 assert "Replace Node" in network, "network editors lost Replace Node"
259 assert "Replace Node" not in parm, "Replace Node leaked into parameter tabs"
260 return "class scope and the Path predicate hold"
261
262 check("command scoping", scoping_holds)
263
264 def controls_resolve():
265 """Every state getter exists and every choice method takes a value.
266
267 A getter named in a decorator is a string; nothing else checks it
268 until the panel opens and the row's checkbox greys out.
269 """
270 problems = []
271 for cls in (HCSession, HCPane, HCTab, HCPathTab, HCParameterTab,
272 HCNetworkEditor, HCSceneViewer):
273 problems.extend(hccommands.verify(cls))
274 assert not problems, "; ".join(problems)
275 return "state getters and choice setters resolve"
276
277 check("panel controls resolve", controls_resolve)
278
279 def choices_have_values():
280 """Choice commands list at least two (label, value) pairs and the
281 value that reads back from the getter is one of them, or a toggle's
282 getter is one the panel can coerce."""
283 counted = 0
284 for cls in (HCSession, HCPane, HCTab, HCNetworkEditor, HCSceneViewer):
285 for label, (name, spec) in hccommands.declared(cls).items():
286 if spec.kind != "choice":
287 continue
288 assert len(spec.choices) >= 2, f"{label}: fewer than two choices"
289 values = [value for _, value in spec.choices]
290 assert len(set(values)) == len(values), f"{label}: duplicate values"
291 counted += 1
292 assert counted, "no choice commands declared"
293 return f"{counted} dropdowns"
294
295 check("dropdown choices", choices_have_values)
296
297 def state_coercion():
298 """Houdini prefs are '0'/'1' strings; bool('0') is True."""
299 assert hccommands.asBool("0") is False
300 assert hccommands.asBool("1") is True
301 assert hccommands.asBool(0) is False
302 assert hccommands.asBool(2) is True
303 assert hccommands.asBool(None) is False
304 return "'0' reads as off"
305
306 check("toggle state coercion", state_coercion)
307
308 def update_mode_reads_back():
309 """The Update Mode dropdown's getter returns one of its own values."""
310 bound = maps.commands(session, pane, None)["Update Mode"]
311 value = bound.state()
312 values = [v for _, v in bound.choices]
313 assert value in values, f"{value!r} not in {values}"
314 return f"currently {value}"
315
316 check("Update Mode state", update_mode_reads_back)
317
318 def labels_are_unique():
319 seen = {}
320 for cls in (HCSession, HCPane, HCTab, HCPathTab, HCParameterTab,
321 HCNetworkEditor, HCSceneViewer):
322 for label, (name, _) in hccommands.declared(cls).items():
323 if label in seen and seen[label] != (cls.__name__, name):
324 # Inherited commands legitimately repeat; a genuine clash is
325 # two different methods claiming one label.
326 prev_cls, prev_name = seen[label]
327 if prev_name != name:
328 raise AssertionError(
329 f"{label!r} claimed by {prev_cls}.{prev_name} and {cls.__name__}.{name}"
330 )
331 seen[label] = (cls.__name__, name)
332 return f"{len(seen)} distinct labels"
333
334 check("no duplicate command labels", labels_are_unique)
335
336
337 def check_state():
338 print("state")
339
340 def scopes_differ():
341 assert hcstate.PANE != hcstate.NETWORK
342 store = hcstate.Store("_check_pane", hcstate.PANE)
343 net = hcstate.Store("_check_network", hcstate.NETWORK)
344 assert store.scope == hcstate.PANE and net.scope == hcstate.NETWORK
345 return "PANE and NETWORK scopes registered"
346
347 check("two declared scopes", scopes_differ)
348
349 def eviction_drops_dead_panes():
350 store = hcstate.Store("_check_evict", hcstate.PANE)
351 store._data[999001] = "stale"
352 store._data[999002] = "also stale"
353 assert len(store) == 2
354 store._evict(live_pane_ids={999002})
355 assert len(store) == 1 and 999002 in store._data, "wrong entry evicted"
356 store.clear()
357 return "closed-pane entries are dropped"
358
359 check("eviction", eviction_drops_dead_panes)
360
361 def network_keys_separate_networks():
362 store = hcstate.Store("_check_netkey", hcstate.NETWORK)
363 store._data[(7, "/obj")] = "parent"
364 store._data[(7, "/obj/geo1")] = "child"
365 # Same pane, different network: descending must not read the parent's
366 # entry. This was the selection-signature bug.
367 assert store._data[(7, "/obj")] != store._data[(7, "/obj/geo1")]
368 store._evict(live_pane_ids={7})
369 assert len(store) == 2, "live pane entries were evicted"
370 store._evict(live_pane_ids=set())
371 store.clear()
372 return "per-network entries stay distinct within a pane"
373
374 check("network scope", network_keys_separate_networks)
375
376
377 def check_chrome():
378 print("chrome")
379
380 def every_tab_answers():
381 for cls in (HCTab, HCPathTab, HCParameterTab, HCNetworkEditor, HCSceneViewer):
382 for name in ("isChromeVisible", "showChrome"):
383 assert callable(getattr(cls, name, None)), f"{cls.__name__} lacks {name}"
384 return "all tab classes implement the chrome pair"
385
386 check("polymorphic chrome", every_tab_answers)
387
388 def overrides_extend_the_base():
389 # Each override must call up, or a subclass silently drops the base
390 # network-controls handling.
391 import inspect
392 for cls in (HCNetworkEditor, HCSceneViewer):
393 for name in ("isChromeVisible", "showChrome"):
394 source = inspect.getsource(getattr(cls, name))
395 assert "super()" in source, f"{cls.__name__}.{name} does not call super()"
396 return "subclass overrides chain to HCTab"
397
398 check("chrome overrides chain", overrides_extend_the_base)
399
400
401 def check_split_handles():
402 """HCSplitHandles pinning, with fake panes standing in for hou.Pane.
403
404 This is the one bit of pure-Qt UI code with logic worth testing: the
405 handles used to be pinned from pane geometry Houdini had not laid out yet,
406 so a dragged handle trailed its boundary and was left behind at the end.
407 Needs an offscreen QApplication; skipped when Qt cannot start.
408 """
409 print("split handles")
410 os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
411 try:
412 from PySide6 import QtWidgets
413 from PySide6.QtCore import QRect
414 app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
415 except Exception as e:
416 print(f" skip (no Qt: {type(e).__name__})")
417 return
418
419 import hc.hcsplithandles as sh
420
421 class FakePane:
422 _next = [1]
423
424 def __init__(self, rect, children=None):
425 self.rect = rect
426 self.children = children or []
427 self._id = FakePane._next[0]
428 FakePane._next[0] += 1
429
430 def id(self):
431 return self._id
432
433 def qtScreenGeometry(self):
434 return self.rect
435
436 def getSplitChild(self, i):
437 if i < len(self.children):
438 return self.children[i]
439 raise IndexError(i)
440
441 main = QtWidgets.QWidget()
442 main.setGeometry(0, 0, 1000, 800)
443 main.show()
444
445 def centre(handle):
446 # `main` is a real top-level widget wherever the WM put it, so compare
447 # in the screen space _pin computes in.
448 return main.mapToGlobal(handle.geometry().center())
449
450 left = FakePane(QRect(0, 0, 400, 800))
451 right = FakePane(QRect(400, 0, 600, 800))
452 split = FakePane(QRect(0, 0, 1000, 800), [left, right])
453
454 mgr = sh.HCSplitHandles.__new__(sh.HCSplitHandles)
455 mgr.main, mgr.handles, mgr._filter, mgr._tracker = main, [], None, None
456 handle = sh._SplitHandle(main, split, horizontal_boundary=False, manager=mgr)
457 mgr.handles.append(handle)
458
459 def pins_to_the_boundary():
460 mgr._pin(handle)
461 x = centre(handle).x()
462 assert abs(x - 400) <= 1, f"centred at {x}, expected ~400"
463 return f"centre x={x}, boundary at 400"
464
465 check("pins to the boundary", pins_to_the_boundary)
466
467 def follows_a_boundary_moved_elsewhere():
468 before = handle.geometry()
469 left.rect = QRect(0, 0, 700, 800)
470 right.rect = QRect(700, 0, 300, 800)
471 mgr._pin(handle)
472 assert handle.geometry() != before, "handle did not follow the boundary"
473 x = centre(handle).x()
474 assert abs(x - 700) <= 1, f"centred at {x}, expected ~700"
475 return f"followed 400 -> {x}"
476
477 check("follows a boundary moved elsewhere", follows_a_boundary_moved_elsewhere)
478
479 def idle_tick_does_nothing():
480 mgr._pin(handle)
481 before = handle.geometry()
482 calls = []
483 original = handle.setGeometry
484 handle.setGeometry = lambda *a: (calls.append(a), original(*a))[1]
485 for _ in range(10):
486 mgr._pin(handle)
487 handle.setGeometry = original
488 assert not calls, f"setGeometry called {len(calls)}x with nothing changed"
489 assert handle.geometry() == before
490 return "10 ticks, 0 widget writes"
491
492 check("unchanged tick is free", idle_tick_does_nothing)
493
494 def dragging_handle_is_left_alone():
495 handle._origin = object()
496 assert handle.isDragging()
497 left.rect = QRect(0, 0, 200, 800)
498 right.rect = QRect(200, 0, 800, 800)
499 before = handle.geometry()
500 mgr._pin_all()
501 assert handle.geometry() == before, "_pin_all fought a handle mid-drag"
502 handle._origin = None
503 mgr._pin_all()
504 x = centre(handle).x()
505 assert abs(x - 200) <= 1, f"centred at {x}, expected ~200"
506 return "skipped mid-drag, pinned on release"
507
508 check("mid-drag handle is skipped", dragging_handle_is_left_alone)
509
510 def handle_is_a_native_window():
511 """Without its own X window the handle can be seen but never clicked.
512
513 Houdini renders its UI into one QOpenGLWidget backed by a native
514 QWindow, and on xcb a native window owns mouse input across its area.
515 A plain QWidget sibling painted on top receives nothing -- the press
516 goes to the GL surface, where Houdini hit-tests its own split bars.
517 """
518 from PySide6.QtCore import Qt
519 assert handle.testAttribute(Qt.WA_NativeWindow), \
520 "split handle is not a native window; clicks will fall through to Houdini"
521 assert handle.internalWinId(), "native window was never created"
522 return f"native, winId set"
523
524 check("handle owns an X window", handle_is_a_native_window)
525
526 def horizontal_boundary():
527 top = FakePane(QRect(0, 0, 1000, 300))
528 bottom = FakePane(QRect(0, 300, 1000, 500))
529 vsplit = FakePane(QRect(0, 0, 1000, 800), [top, bottom])
530 h = sh._SplitHandle(main, vsplit, horizontal_boundary=True, manager=mgr)
531 mgr.handles.append(h)
532 mgr._pin(h)
533 y = centre(h).y()
534 assert abs(y - 300) <= 1, f"centred at y={y}, expected ~300"
535 g = h.geometry()
536 assert g.width() > g.height(), "a horizontal boundary wants a wide handle"
537 return f"centre y={y}, {g.width()}x{g.height()}"
538
539 check("horizontal boundary", horizontal_boundary)
540
541
542
543 def check_nodegraph_hooks():
544 """nodegraphhooks subclasses Houdini classes it cannot import here.
545
546 nodegraphbase is only importable inside Houdini's UI, so this reads both
547 sources with ast instead. It exists because dropping an argument was
548 invisible every other way: _PendingSelectionSyncAction lost the __init__
549 that supplied `delay`, its base requires it with no default, and the
550 resulting TypeError came out of createEventHandler on every mousedown.
551 Houdini discards the event when the hook raises, so clicking a node did
552 nothing -- with no traceback anywhere the test suite could see.
553 """
554 print("nodegraph hooks")
555 import ast
556
557 hfs = os.environ.get("HFS", "/opt/hfs")
558 base_src = Path(hfs) / "houdini" / "python3.13libs" / "nodegraphbase.py"
559 hooks_src = ROOT / "python3.13libs" / "nodegraphhooks.py"
560
561 if not base_src.is_file():
562 print(f" skip (no nodegraphbase.py at {base_src})")
563 return
564
565 def classes(tree):
566 return {n.name: n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)}
567
568 def required_args(cls_node):
569 """Positional params of __init__ with no default, excluding self."""
570 for item in cls_node.body:
571 if isinstance(item, ast.FunctionDef) and item.name == "__init__":
572 args = item.args.args[1:]
573 return len(args) - len(item.args.defaults), True
574 return None, False # no __init__ of its own
575
576 base_classes = classes(ast.parse(base_src.read_text()))
577 hooks_tree = ast.parse(hooks_src.read_text())
578 hook_classes = classes(hooks_tree)
579
580 def base_of(node):
581 for b in node.bases:
582 if isinstance(b, ast.Attribute):
583 return b.attr
584 if isinstance(b, ast.Name):
585 return b.id
586 return None
587
588 def verify(name, node, base_name):
589 needed, own = required_args(node)
590 if not own:
591 needed, _ = required_args(base_classes[base_name])
592 assert needed is not None, f"cannot determine {base_name}.__init__ arity"
593
594 calls = [n for n in ast.walk(hooks_tree)
595 if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
596 and n.func.id == name]
597 assert calls, f"{name} is never instantiated"
598 for call in calls:
599 given = len(call.args) + len(call.keywords)
600 assert given >= needed, (
601 f"{name}(...) at line {call.lineno} passes {given} argument(s) "
602 f"but {base_name}.__init__ requires {needed}"
603 )
604 return f"{len(calls)} call site(s), {needed} required arg(s) from {base_name}"
605
606 subclasses = [(n, node, base_of(node)) for n, node in hook_classes.items()
607 if base_of(node) in base_classes]
608 if not subclasses:
609 print(" skip (no nodegraphbase subclasses found)")
610 return
611 for name, node, base_name in subclasses:
612 # Through check(), so a mismatch is a reported failure rather than a
613 # traceback that takes the rest of the suite with it.
614 check(f"{name} constructor arity",
615 lambda n=name, nd=node, b=base_name: verify(n, nd, b))
616
617 def one_mouseup_completer():
618 """Houdini finishes pending actions with `for action in list: if
619 action.completeAction(ev): list.remove(action)` -- removal while
620 iterating, which skips the entry after the removed one. Two hc actions
621 completing on the same mouseup never both ran on it: the drop swap
622 lingered behind the grid sweep and fired on a later mouseup with a
623 stale start position, so swaps only worked some of the time. Whatever
624 runs after a mouse action has to share one class."""
625 completers = []
626 for name, node in hook_classes.items():
627 for item in node.body:
628 if isinstance(item, ast.FunctionDef) and item.name == "completeAction":
629 if "mouseup" in ast.unparse(item):
630 completers.append(name)
631 assert len(completers) == 1, \
632 f"{len(completers)} pending actions complete on mouseup: {completers}"
633 return f"only {completers[0]} completes on mouseup"
634
635 check("one mouseup completer", one_mouseup_completer)
636
637
638 def check_open_recent():
639 print("open recent")
640 from hc import hcsession
641
642 def history_parses_newest_first():
643 text = "HIP\n{\n/a/one.hip\n/b/two.hip\n/a/one.hip\n/c/three.hip\n}\nOTL\n{\n/x/lib.hda\n}\n"
644 got = hcsession.parseFileHistory(text)
645 assert got == ["/c/three.hip", "/a/one.hip", "/b/two.hip"], got
646 assert hcsession.parseFileHistory("") == []
647 return "newest first, deduplicated, OTL block ignored"
648
649 check("file.history parser", history_parses_newest_first)
650
651 def recent_files_exist():
652 files = HCSession().recentHipFiles()
653 assert all(Path(f).is_file() for f in files), files
654 return f"{len(files)} recent hips on disk"
655
656 check("recentHipFiles() only lists existing files", recent_files_exist)
657
658
659 def check_status_circle():
660 """The status circle's menu is built from the main-menu XML files with
661 everything Python cannot run filtered out."""
662 print("status circle")
663 from hc import hcstatusbar
664 import lxml.etree as ET
665
666 def state_is_wellformed():
667 state, minutes = hcstatusbar.hipState()
668 assert state in ("saved", "unsaved", "autosave"), state
669 assert isinstance(minutes, int)
670 return f"{state}, {minutes} min"
671
672 check("hipState()", state_is_wellformed)
673
674 def menu_documents_are_runnable():
675 files = hcstatusbar.mainMenuXMLFiles()
676 assert files, "no MainMenuCommon.xml on the path"
677 total = 0
678 for path in files:
679 xml = hcstatusbar._menuDocument(path)
680 root = ET.fromstring(xml)
681 assert root.tag == "menuDocument" and root[0].tag == "menu", path
682 ids = [a.get("id") for a in root.iter("actionItem")]
683 assert set(ids) <= set(hcstatusbar.ACTION_SYMBOLS), f"{path}: unmapped {ids}"
684 assert not list(root.iter("toggleItem")), f"{path}: toggleItem left in"
685 total += len(list(root.iter("scriptItem"))) + len(ids)
686 base = ET.parse([f for f in files if f.startswith(hou.getenv("HFS"))][0]).getroot()
687 declared = {a.get("id") for a in base.iter("actionItem")}
688 missing = set(hcstatusbar.ACTION_SYMBOLS) - declared
689 assert not missing, f"mapped symbols not in Houdini's menu: {sorted(missing)}"
690 return f"{total} runnable items across {len(files)} files"
691
692 check("menu XML filtered to runnable items", menu_documents_are_runnable)
693
694 def circle_is_a_native_window():
695 """Same lesson as the split handles: over Houdini's GL window a plain
696 child widget is painted but never clicked."""
697 from PySide6.QtCore import Qt
698 circle = hcstatusbar.StatusCircle()
699 try:
700 assert circle.testAttribute(Qt.WA_NativeWindow), "status circle is not a native window"
701 assert circle.internalWinId(), "native window was never created"
702 assert not circle.mask().isEmpty(), "no window mask; native corners would show black"
703 finally:
704 circle.deleteLater()
705 return "native, winId set, masked"
706
707 check("circle owns an X window", circle_is_a_native_window)
708
709
710 def check_geometry():
711 """The visible-geometry walk, after HCNode was removed from under it."""
712 print("geometry")
713
714 obj = hou.node("/obj")
715 shown_a = obj.createNode("geo", "chk_a")
716 shown_a.createNode("box").setDisplayFlag(True)
717 shown_b = obj.createNode("geo", "chk_b")
718 shown_b.createNode("box").setDisplayFlag(True)
719 shown_b.parmTuple("t").set((10, 0, 0))
720 hidden = obj.createNode("geo", "chk_hidden")
721 hidden.createNode("box").setDisplayFlag(True)
722 hidden.setDisplayFlag(False)
723 camera = obj.createNode("cam", "chk_cam")
724
725 def category_handles_leaves():
726 # HCNode.childCat() called .name() on childTypeCategory() with no guard,
727 # so asking a SOP what it contains raised AttributeError on None.
728 assert childCategory(obj) == "Object"
729 assert childCategory(shown_a) == "Sop"
730 leaf = shown_a.children()[0]
731 assert childCategory(leaf) == "", "a childless node should report ''"
732 return "Object / Sop / '' for a leaf"
733
734 check("childCategory", category_handles_leaves)
735
736 visible = collect_visible_nodes(obj.children())
737
738 def walk_skips_hidden_and_cameras():
739 parents = {n.parent().name() for n in visible}
740 assert "chk_hidden" not in parents, "hidden object contributed geometry"
741 assert "chk_cam" not in parents, "camera contributed geometry"
742 assert parents >= {"chk_a", "chk_b"}, f"displayed objects missing: {parents}"
743 return f"{len(visible)} display nodes, hidden and camera skipped"
744
745 check("collect_visible_nodes", walk_skips_hidden_and_cameras)
746
747 def transform_flag_is_honoured():
748 world = merged_visible_geo(visible, apply_world_transform=True)
749 local = merged_visible_geo(visible, apply_world_transform=False)
750 assert len(world.points()) == len(local.points()), "point counts diverged"
751 # chk_b sits at x=10; only the world-space merge should reach it.
752 assert world.boundingBox().maxvec()[0] > 9, "world transform not applied"
753 assert local.boundingBox().maxvec()[0] < 2, "world transform applied when off"
754 return f"{len(world.points())} points merged, transform opt-in"
755
756 check("merged_visible_geo", transform_flag_is_honoured)
757
758 for node in (shown_a, shown_b, hidden, camera):
759 node.destroy()
760
761
762 def check_node_ops():
763 print("node operations")
764
765 def replace_rewires():
766 """The rewire in HCNetworkEditor.replaceNode, minus the UI."""
767 geo = hou.node("/obj").createNode("geo")
768 source = geo.createNode("box")
769 old = geo.createNode("mountain")
770 sink = geo.createNode("null")
771 old.setInput(0, source)
772 sink.setInput(0, old)
773
774 new = geo.createNode("twist")
775 new.setPosition(old.position())
776 for index, upstream in enumerate(old.inputs()):
777 if upstream is not None:
778 new.setInput(index, upstream)
779 for downstream in list(old.outputs()):
780 for conn in downstream.inputConnections():
781 if conn.inputNode() == old:
782 downstream.setInput(conn.inputIndex(), new)
783 new.setDisplayFlag(old.isDisplayFlagSet())
784 new.setRenderFlag(old.isRenderFlagSet())
785 new.bypass(old.isBypassed())
786 new.setTemplateFlag(old.isTemplateFlagSet())
787 new.setColor(old.color())
788 old_tag = old.userData("hc_custom_color")
789 if old_tag is None:
790 new.destroyUserData("hc_custom_color", must_exist=False)
791 else:
792 new.setUserData("hc_custom_color", old_tag)
793 old.destroy()
794
795 assert sink.inputs()[0] == new, "downstream lost its input"
796 assert new.inputs()[0] == source, "upstream was not reconnected"
797 assert len(geo.children()) == 3, "the replaced node outlived the swap"
798 geo.destroy()
799 return "inputs and outputs preserved"
800
801 check("replaceNode rewire", replace_rewires)
802
803 def update_mode_toggles():
804 before = hou.updateModeSetting()
805 target = (hou.updateMode.AutoUpdate if before == hou.updateMode.Manual
806 else hou.updateMode.Manual)
807 hou.setUpdateMode(target)
808 after = hou.updateModeSetting()
809 hou.setUpdateMode(before)
810 assert after == target, "update mode did not change"
811 return f"{before.name()} -> {after.name()}"
812
813 check("toggleUpdateMode", update_mode_toggles)
814
815 def snap_lands_on_grid():
816 """OnCreated.py and HCNetworkEditor must agree on where the grid is."""
817 node_graph = HCSettings().nodeGraph()
818 step_x = max(node_graph.get("grid_x_step", 2.0), 0.25)
819 step_y = max(node_graph.get("grid_y_step", 1.0), 0.25)
820 offset_x = node_graph.get("node_center_offset_x", 0.5)
821 offset_y = node_graph.get("node_center_offset_y", 0.15)
822
823 position = hou.Vector2(3.7, 2.3)
824 snapped = hou.Vector2(
825 round((position[0] + offset_x) / step_x) * step_x - offset_x,
826 round((position[1] + offset_y) / step_y) * step_y - offset_y,
827 )
828 for center, step, axis in (
829 (snapped[0] + offset_x, step_x, "x"),
830 (snapped[1] + offset_y, step_y, "y"),
831 ):
832 assert abs(center / step - round(center / step)) < 1e-9, \
833 f"{axis} center is off grid"
834 return f"step ({step_x}, {step_y}): {tuple(position)} -> {tuple(snapped)}"
835
836 check("new-node grid snap", snap_lands_on_grid)
837
838 def snap_prefs_follow_the_setting():
839 """Houdini's Snap to Grid only captures within `snapradius`, so hard
840 snapping is that radius pushed past half a grid step, with node-to-node
841 snapping off so it cannot win instead. Off must hand back Houdini's
842 own defaults, not leave the wide radius behind."""
843 from hc import hcnetworkeditor as ne
844
845 setting = hcschema.lookup(("node_graph", "grid_snap"))
846 assert setting is not None and setting.kind == "bool", "grid_snap is not a bool setting"
847 assert setting.default is True, "grid_snap must default on"
848
849 class FakePane:
850 def id(self):
851 return 987654
852
853 class FakeTab:
854 def __init__(self):
855 self.prefs = {}
856
857 def pane(self):
858 return FakePane()
859
860 def setPref(self, name, value):
861 self.prefs[name] = value
862
863 max_half_step = max(setting.range[1] for setting in (
864 hcschema.lookup(("node_graph", "grid_x_step")),
865 hcschema.lookup(("node_graph", "grid_y_step")))) / 2.0
866 assert float(ne.SNAP_PREFS_HARD["snapradius"]) > max_half_step, \
867 "hard snap radius does not cover the widest grid step"
868 assert ne.SNAP_PREFS_HARD["dosnapping"] == "0", "node snapping must be off in hard mode"
869
870 original = HCSettings.nodeGraph
871 seen = {}
872 try:
873 for hard in (True, False):
874 HCSettings.nodeGraph = lambda _self, hard=hard: {"grid_snap": hard}
875 tab = FakeTab()
876 editor = ne.HCNetworkEditor(tab)
877 editor._syncSnapPrefs()
878 wanted = ne.SNAP_PREFS_HARD if hard else ne.SNAP_PREFS_SOFT
879 assert tab.prefs == wanted, f"grid_snap={hard}: wrote {tab.prefs}, wanted {wanted}"
880 # Same wanted set again must not write: this runs on every event.
881 tab.prefs.clear()
882 editor._syncSnapPrefs()
883 assert tab.prefs == {}, f"grid_snap={hard}: rewrote prefs with nothing changed"
884 seen[hard] = wanted["snapradius"]
885 finally:
886 HCSettings.nodeGraph = original
887 return f"radius {seen[True]} on, {seen[False]} off; no rewrite when unchanged"
888
889 check("snap prefs follow grid_snap", snap_prefs_follow_the_setting)
890
891 def sweep_snaps_the_network():
892 """The post-action sweep nodegraphhooks queues: every off-grid node in
893 the displayed network lands on the grid, nodes already there are left
894 alone, and it is gated on grid_snap."""
895 from hc import hcnetworkeditor as ne
896
897 geo = hou.node("/obj").createNode("geo")
898 try:
899 nodes = [geo.createNode("null") for _ in range(3)]
900 offsets = (hou.Vector2(0.37, 0.21), hou.Vector2(-1.13, 0.48), hou.Vector2(0, 0))
901
902 class FakePane:
903 def id(self):
904 return 987655
905
906 class FakeTab:
907 def pane(self):
908 return FakePane()
909
910 def setPref(self, name, value):
911 pass
912
913 def pwd(self):
914 return geo
915
916 editor = ne.HCNetworkEditor(FakeTab())
917 for node, offset in zip(nodes, offsets):
918 editor.snapToGrid(node)
919 node.setPosition(node.position() + offset)
920 off_before = sum(not editor._isOnGrid(n.position()) for n in nodes)
921 assert off_before == 2, f"expected 2 nodes off grid to start, got {off_before}"
922
923 original = HCSettings.gridSnapEnabled
924 try:
925 HCSettings.gridSnapEnabled = lambda _self: False
926 assert editor.sweepToGrid() == 0, "sweep ran with grid_snap off"
927 HCSettings.gridSnapEnabled = lambda _self: True
928 moved = editor.sweepToGrid()
929 finally:
930 HCSettings.gridSnapEnabled = original
931 assert moved == 2, f"sweep moved {moved} nodes, wanted 2"
932 assert all(editor._isOnGrid(n.position()) for n in nodes), "a node is still off grid"
933 assert editor.sweepToGrid() == 0, "a second sweep still moved nodes"
934 return "2 of 3 snapped, none on the second pass, nothing with grid_snap off"
935 finally:
936 geo.destroy()
937
938 check("post-action sweep", sweep_snaps_the_network)
939
940 def drop_swaps_positions():
941 """swapDroppedNode: a node dragged into another node's cell sends
942 that node to the cell it left. Same cell only, nearest wins when
943 cells overlap, wiring untouched, gated on drop_swap."""
944 from hc import hcnetworkeditor as ne
945
946 geo = hou.node("/obj").createNode("geo")
947 try:
948 a = geo.createNode("null", "a")
949 b = geo.createNode("null", "b")
950 c = geo.createNode("null", "c")
951 b.setInput(0, a)
952
953 class FakePane:
954 def id(self):
955 return 987658
956
957 class FakeTab:
958 def pane(self):
959 return FakePane()
960
961 def setPref(self, name, value):
962 pass
963
964 def pwd(self):
965 return geo
966
967 def flashMessage(self, *args):
968 pass
969
970 editor = ne.HCNetworkEditor(FakeTab())
971 step = editor._gridStep()
972 for node, cell in ((a, (0, 0)), (b, (1, 0)), (c, (3, 0))):
973 node.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]))
974 editor.snapToGrid(node)
975 a_start, b_start, c_start = a.position(), b.position(), c.position()
976
977 # Simulate the drag: a lands exactly on b's cell.
978 a.setPosition(b_start)
979 assert editor.swapDroppedNode(a.path(), (a_start[0], a_start[1])) == b, "b was not displaced"
980 assert b.position().isAlmostEqual(a_start), f"b went to {b.position()}, not {a_start}"
981 assert a.position().isAlmostEqual(b_start), "the dragged node must stay where it was dropped"
982 # a fed b, so they trade places in the chain: b now feeds a.
983 assert a.inputs() == (b,) and not b.inputs(), \
984 f"linked pair did not swap places: a<-{a.inputs()}, b<-{b.inputs()}"
985 assert c.position().isAlmostEqual(c_start), "a bystander moved"
986
987 # No movement, or a drop on an empty cell, swaps nothing.
988 assert editor.swapDroppedNode(a.path(), (b_start[0], b_start[1])) is None, "swapped without a move"
989 a.setPosition(hou.Vector2(c_start[0] - 2 * step[0], c_start[1]))
990 assert editor.swapDroppedNode(a.path(), (b_start[0], b_start[1])) is None, "swapped on an empty cell"
991
992 original = HCSettings.dropSwapEnabled
993 try:
994 HCSettings.dropSwapEnabled = lambda _self: False
995 a.setPosition(c_start)
996 assert editor.swapDroppedNode(a.path(), (b_start[0], b_start[1])) is None, "swapped with drop_swap off"
997 finally:
998 HCSettings.dropSwapEnabled = original
999 return "a onto b: b takes a's cell and a's place in the chain; no-ops for no move, empty cell, setting off"
1000 finally:
1001 geo.destroy()
1002
1003 check("drop to swap", drop_swaps_positions)
1004
1005 def linked_swap_trades_chain_places():
1006 """P -> X -> Y -> Q with a side output X -> Z and a second input W -> Y:
1007 dropping Y onto X must give P -> Y -> X -> Q, Y -> Z, W -> X. Unlinked
1008 nodes only trade cells, and a node that cannot take the inputs it
1009 would inherit leaves the wiring alone."""
1010 from hc import hcnetworkeditor as ne
1011
1012 geo = hou.node("/obj").createNode("geo")
1013 try:
1014 P, W, Z, Q = (geo.createNode("null", n) for n in ("P", "W", "Z", "Q"))
1015 X = geo.createNode("merge", "X")
1016 Y = geo.createNode("merge", "Y")
1017 X.setInput(0, P)
1018 Y.setInput(0, X)
1019 Y.setInput(1, W)
1020 Z.setInput(0, X)
1021 Q.setInput(0, Y)
1022
1023 class FakePane:
1024 def id(self):
1025 return 987659
1026
1027 class FakeTab:
1028 def __init__(self):
1029 self.flashed = []
1030
1031 def pane(self):
1032 return FakePane()
1033
1034 def setPref(self, name, value):
1035 pass
1036
1037 def pwd(self):
1038 return geo
1039
1040 def flashMessage(self, image, message, duration):
1041 self.flashed.append(message)
1042
1043 tab = FakeTab()
1044 editor = ne.HCNetworkEditor(tab)
1045 step = editor._gridStep()
1046 for node, cell in ((P, (0, 2)), (X, (0, 1)), (Y, (0, 0)), (Q, (0, -1)),
1047 (W, (1, 1)), (Z, (1, 0))):
1048 node.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]))
1049 editor.snapToGrid(node)
1050 x_start, y_start = X.position(), Y.position()
1051
1052 def wiring(node):
1053 return sorted((c.inputIndex(), c.inputItem().name()) for c in node.inputConnections())
1054
1055 Y.setPosition(x_start) # the drag: Y dropped onto X
1056 assert editor.swapDroppedNode(Y.path(), (y_start[0], y_start[1])) == X
1057 assert X.position().isAlmostEqual(y_start), "X did not take Y's cell"
1058 assert wiring(Y) == [(0, "P")], f"Y should inherit X's input P, has {wiring(Y)}"
1059 assert wiring(X) == [(0, "Y"), (1, "W")], f"X should take Y's place, has {wiring(X)}"
1060 assert wiring(Z) == [(0, "Y")], f"X's side output should now come from Y, has {wiring(Z)}"
1061 assert wiring(Q) == [(0, "X")], f"Y's output should now come from X, has {wiring(Q)}"
1062 assert not tab.flashed, f"unexpected message: {tab.flashed}"
1063
1064 # Unlinked: W onto Z trades cells only.
1065 w_start, z_start = W.position(), Z.position()
1066 W.setPosition(z_start)
1067 assert editor.swapDroppedNode(W.path(), (w_start[0], w_start[1])) == Z
1068 assert Z.position().isAlmostEqual(w_start)
1069 assert wiring(Z) == [(0, "Y")] and wiring(X) == [(0, "Y"), (1, "W")], "unlinked swap touched wiring"
1070
1071 # Infeasible: Q (one input) onto X (which now has two inputs) would
1072 # hand Q two inputs. Cells swap, wiring stays, and it says why.
1073 q_start, x_now = Q.position(), X.position()
1074 Q.setPosition(x_now)
1075 assert editor.swapDroppedNode(Q.path(), (q_start[0], q_start[1])) == X
1076 assert X.position().isAlmostEqual(q_start), "cells should still swap"
1077 assert wiring(X) == [(0, "Y"), (1, "W")] and wiring(Q) == [(0, "X")], "infeasible swap changed wiring"
1078 assert tab.flashed and "too few inputs" in tab.flashed[-1], f"no explanation: {tab.flashed}"
1079 return "P->Y->X->Q with Y->Z and W->X; unlinked pair keeps wiring; infeasible pair explained"
1080 finally:
1081 geo.destroy()
1082
1083 check("linked swap trades chain places", linked_swap_trades_chain_places)
1084
1085 def keyboard_step_swaps_too():
1086 """translateSelectedNodes (the alt+hjkl moves): a lone selected node
1087 stepped onto a neighbour's cell swaps with it like a drop does; two
1088 nodes stepping onto empty cells just move."""
1089 from hc import hcnetworkeditor as ne
1090
1091 geo = hou.node("/obj").createNode("geo")
1092 try:
1093 a = geo.createNode("null", "a")
1094 b = geo.createNode("null", "b")
1095 c = geo.createNode("null", "c")
1096 b.setInput(0, a)
1097
1098 class FakePane:
1099 def id(self):
1100 return 987660
1101
1102 class FakeTab:
1103 def __init__(self):
1104 self.view = hou.BoundingRect(-20.0, -20.0, 20.0, 20.0)
1105
1106 def pane(self):
1107 return FakePane()
1108
1109 def setPref(self, name, value):
1110 pass
1111
1112 def pwd(self):
1113 return geo
1114
1115 def itemRect(self, node):
1116 pos = node.position()
1117 return hou.BoundingRect(pos[0], pos[1], pos[0] + 1.0, pos[1] + 0.3)
1118
1119 def visibleBounds(self):
1120 return hou.BoundingRect(self.view)
1121
1122 def setVisibleBounds(self, bounds, *args):
1123 self.view = hou.BoundingRect(bounds)
1124
1125 def flashMessage(self, *args):
1126 pass
1127
1128 editor = ne.HCNetworkEditor(FakeTab())
1129 editor.updateCurrentNodeOverlay = lambda force=False: None
1130 step = editor._gridStep()
1131 for node, cell in ((a, (0, 1)), (b, (0, 0)), (c, (2, 0))):
1132 node.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]))
1133 editor.snapToGrid(node)
1134 a_start, b_start, c_start = a.position(), b.position(), c.position()
1135
1136 a.setSelected(True, clear_all_selected=True)
1137 editor.translateSelectedNodes("down") # a steps onto b
1138 assert a.position().isAlmostEqual(b_start), f"a should be on b's old cell, is at {a.position()}"
1139 assert b.position().isAlmostEqual(a_start), f"b should take a's old cell, is at {b.position()}"
1140 assert a.inputs() == (b,) and not b.inputs(), "linked pair did not trade chain places"
1141 assert c.position().isAlmostEqual(c_start), "a bystander moved"
1142
1143 # Two nodes stepping onto empty cells: both just move.
1144 a.setSelected(True, clear_all_selected=True)
1145 b.setSelected(True)
1146 a_pos, b_pos = a.position(), b.position()
1147 editor.translateSelectedNodes("right")
1148 assert a.position().isAlmostEqual(a_pos + hou.Vector2(step[0], 0.0))
1149 assert b.position().isAlmostEqual(b_pos + hou.Vector2(step[0], 0.0))
1150 assert c.position().isAlmostEqual(c_start), "a group move swapped with a bystander"
1151 return "lone step onto a neighbour swaps cells and chain; a group step onto empty cells only moves"
1152 finally:
1153 geo.destroy()
1154
1155 check("keyboard step swaps too", keyboard_step_swaps_too)
1156
1157 def group_step_reorders_chain():
1158 """A selection stepped onto a bystander sends it to the cell the
1159 group freed and through the chain: P -> a -> b -> c -> Q with {a, b}
1160 stepped down onto c gives P -> c -> a -> b -> Q, c in a's old cell.
1161 Runs are per line: {a, b} stepped right onto c and d, one each in
1162 their rows, swaps the pairs independently."""
1163 from hc import hcnetworkeditor as ne
1164
1165 geo = hou.node("/obj").createNode("geo")
1166 try:
1167 P, a, b, c, Q = (geo.createNode("null", n) for n in ("P", "a", "b", "c", "Q"))
1168 a.setInput(0, P); b.setInput(0, a); c.setInput(0, b); Q.setInput(0, c)
1169
1170 class FakePane:
1171 def id(self):
1172 return 987661
1173
1174 class FakeTab:
1175 def __init__(self):
1176 self.view = hou.BoundingRect(-20.0, -20.0, 20.0, 20.0)
1177
1178 def pane(self):
1179 return FakePane()
1180
1181 def setPref(self, name, value):
1182 pass
1183
1184 def pwd(self):
1185 return geo
1186
1187 def itemRect(self, node):
1188 pos = node.position()
1189 return hou.BoundingRect(pos[0], pos[1], pos[0] + 1.0, pos[1] + 0.3)
1190
1191 def visibleBounds(self):
1192 return hou.BoundingRect(self.view)
1193
1194 def setVisibleBounds(self, bounds, *args):
1195 self.view = hou.BoundingRect(bounds)
1196
1197 def flashMessage(self, *args):
1198 pass
1199
1200 editor = ne.HCNetworkEditor(FakeTab())
1201 editor.updateCurrentNodeOverlay = lambda force=False: None
1202 step = editor._gridStep()
1203
1204 def place(node, cell):
1205 node.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]))
1206 editor.snapToGrid(node)
1207
1208 for node, cell in ((P, (0, 4)), (a, (0, 3)), (b, (0, 2)), (c, (0, 1)), (Q, (0, 0))):
1209 place(node, cell)
1210 start = {n: n.position() for n in (P, a, b, c, Q)}
1211 a.setSelected(True, clear_all_selected=True)
1212 b.setSelected(True)
1213 editor.translateSelectedNodes("down")
1214 assert a.position().isAlmostEqual(start[b]) and b.position().isAlmostEqual(start[c]), "the group did not step"
1215 assert c.position().isAlmostEqual(start[a]), f"c should take a's old cell, is at {c.position()}"
1216 assert P.position().isAlmostEqual(start[P]) and Q.position().isAlmostEqual(start[Q]), "a bystander moved"
1217 chain = [Q]
1218 while chain[-1].inputs() and chain[-1].inputs()[0] is not None:
1219 chain.append(chain[-1].inputs()[0])
1220 assert [n.name() for n in chain] == ["Q", "b", "a", "c", "P"], [n.name() for n in chain]
1221 assert not P.inputs() and P.outputs() == (c,), "P lost its output"
1222
1223 # Two rows, one bystander each: each run of one swaps with its own.
1224 d = geo.createNode("null", "d")
1225 for node, cell in ((a, (0, 1)), (b, (0, 0)), (c, (1, 1)), (d, (1, 0)), (P, (5, 5)), (Q, (5, 6))):
1226 place(node, cell)
1227 for n in (a, b, c, d, P, Q):
1228 for i in range(len(n.inputs())):
1229 n.setInput(i, None)
1230 c.setInput(0, a); d.setInput(0, b)
1231 a.setSelected(True, clear_all_selected=True)
1232 b.setSelected(True)
1233 a_pos, b_pos, c_pos, d_pos = a.position(), b.position(), c.position(), d.position()
1234 editor.translateSelectedNodes("right")
1235 assert a.position().isAlmostEqual(c_pos) and c.position().isAlmostEqual(a_pos), "row 1 did not swap"
1236 assert b.position().isAlmostEqual(d_pos) and d.position().isAlmostEqual(b_pos), "row 0 did not swap"
1237 assert a.inputs() == (c,) and b.inputs() == (d,) and not c.inputs() and not d.inputs(), "rows did not trade chain places"
1238 return "{a, b} onto c: c takes a's cell and the head of the chain; rows swap independently"
1239 finally:
1240 geo.destroy()
1241
1242 check("group step reorders the chain", group_step_reorders_chain)
1243
1244 def excise_heals_the_chain():
1245 """exciseNode (alt+x): the current node leaves its chain, its
1246 consumers take its feed, and it steps to the nearest free cell on
1247 its right, still current. With nothing feeding it the consumers are
1248 unplugged."""
1249 from hc import hcnetworkeditor as ne
1250
1251 geo = hou.node("/obj").createNode("geo")
1252 try:
1253 P, N, Q, R, S = (geo.createNode("null", n) for n in ("P", "N", "Q", "R", "S"))
1254 N.setInput(0, P); Q.setInput(0, N); R.setInput(0, N)
1255
1256 class FakePane:
1257 def id(self):
1258 return 987662
1259
1260 class FakeTab:
1261 def pane(self):
1262 return FakePane()
1263
1264 def setPref(self, name, value):
1265 pass
1266
1267 def pwd(self):
1268 return geo
1269
1270 def flashMessage(self, *args):
1271 pass
1272
1273 editor = ne.HCNetworkEditor(FakeTab())
1274 step = editor._gridStep()
1275
1276 def place(node, cell):
1277 node.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]))
1278 editor.snapToGrid(node)
1279
1280 for node, cell in ((P, (0, 3)), (N, (0, 2)), (S, (1, 2)), (Q, (0, 1)), (R, (1, 1))):
1281 place(node, cell)
1282 two_right = N.position() + hou.Vector2(2 * step[0], 0.0)
1283 N.setCurrent(True, clear_all_selected=True)
1284 editor.exciseNode()
1285 assert Q.inputs() == (P,) and R.inputs() == (P,), "consumers did not take N's feed"
1286 assert not N.inputs() and not N.outputs(), "N still wired"
1287 assert N.position().isAlmostEqual(two_right), f"N should skip S's cell, is at {N.position()}"
1288 assert N.isCurrent() and geo.selectedChildren() == (N,), "N is not the current node"
1289
1290 P.setCurrent(True, clear_all_selected=True)
1291 editor.exciseNode()
1292 assert Q.inputs() == () and R.inputs() == (), "consumers of an unfed node were not unplugged"
1293 return "P -> N -> {Q, R} became P -> {Q, R}, N two cells right past S; unfed P unplugs its consumers"
1294 finally:
1295 geo.destroy()
1296
1297 check("excise heals the chain", excise_heals_the_chain)
1298
1299 def pans_reach_the_editor():
1300 """hou.NetworkEditor.setVisibleBounds drops a change that keeps the
1301 zoom unless set_center_when_scale_rejected is passed (the docs say
1302 the reverse; measured live). Every pan in the package funnels through
1303 setBounds, so that one call must pass it -- with it missing, the
1304 cursor follow, centerOnCursor and translateView were all no-ops."""
1305 from hc import hcnetworkeditor as ne
1306
1307 class FakePane:
1308 def id(self):
1309 return 987656
1310
1311 class FakeTab:
1312 def __init__(self):
1313 self.calls = []
1314 self.view = hou.BoundingRect(0.0, 0.0, 10.0, 5.0)
1315
1316 def pane(self):
1317 return FakePane()
1318
1319 def setPref(self, name, value):
1320 pass
1321
1322 def visibleBounds(self):
1323 return hou.BoundingRect(self.view)
1324
1325 def setVisibleBounds(self, bounds, *args):
1326 self.calls.append((hou.BoundingRect(bounds), args))
1327 self.view = hou.BoundingRect(bounds)
1328
1329 tab = FakeTab()
1330 editor = ne.HCNetworkEditor(tab)
1331 editor.updateCurrentNodeOverlay = lambda force=False: None
1332
1333 # A cursor rect just past the right edge: a pure pan, zoom unchanged.
1334 editor._frameRectInView(hou.BoundingRect(11.0, 2.0, 12.0, 3.0))
1335 assert len(tab.calls) == 1, f"expected one pan, got {len(tab.calls)}"
1336 bounds, args = tab.calls[0]
1337 assert args and args[-1] is True, \
1338 f"pan called setVisibleBounds{(bounds,) + args}: the same-zoom flag is missing"
1339 assert abs(bounds.size()[0] - 10.0) < 1e-9, "a follow pan must not change the zoom"
1340 assert bounds.max()[0] >= 12.0, f"view {bounds} does not reach the cursor"
1341 return "same-zoom flag passed; follow pan keeps zoom and reaches the cursor"
1342
1343 check("pans reach the editor", pans_reach_the_editor)
1344
1345 def initial_cursor_anchors_on_the_editor_current_node():
1346 """After a hip load no child has the current flag, but the editor still
1347 names the file's current node; the initial cursor must anchor there,
1348 not on the node nearest the view centre."""
1349 from hc import hcnetworkeditor as ne
1350
1351 geo = hou.node("/obj").createNode("geo")
1352 try:
1353 near = geo.createNode("null", "near")
1354 far = geo.createNode("null", "far")
1355 near.setPosition(hou.Vector2(0.0, 0.0))
1356 far.setPosition(hou.Vector2(20.0, 20.0))
1357 assert not far.isCurrent() and not near.isCurrent(), "a fresh node is already current"
1358
1359 class FakePane:
1360 def id(self):
1361 return 987657
1362
1363 class FakeTab:
1364 def __init__(self, current):
1365 self.current = current
1366
1367 def pane(self):
1368 return FakePane()
1369
1370 def setPref(self, name, value):
1371 pass
1372
1373 def pwd(self):
1374 return geo
1375
1376 def currentNode(self):
1377 return self.current
1378
1379 def visibleBounds(self):
1380 # Centred on `near`, so the nearest-node fallback would pick it.
1381 return hou.BoundingRect(-5.0, -5.0, 5.0, 5.0)
1382
1383 state = ne.HCNetworkEditor(FakeTab(far))._initialHcnetcursorState()
1384 rect = ne.HCNetworkEditor(FakeTab(far))._hcnetcursorRectFromState(state)
1385 assert rect.contains(far.position() + hou.Vector2(0.5, 0.15)), \
1386 f"cursor {rect} is not over the editor's current node at {far.position()}"
1387
1388 # The editor naming the network itself, or a node elsewhere, is no anchor.
1389 for stale in (geo, hou.node("/obj")):
1390 state = ne.HCNetworkEditor(FakeTab(stale))._initialHcnetcursorState()
1391 rect = ne.HCNetworkEditor(FakeTab(stale))._hcnetcursorRectFromState(state)
1392 assert rect.contains(near.position() + hou.Vector2(0.5, 0.15)), \
1393 f"with editor current {stale.path()} the cursor went to {rect}, not the nearest node"
1394 return "anchors on the editor's current child; falls back to nearest otherwise"
1395 finally:
1396 geo.destroy()
1397
1398 check("initial cursor anchor after load", initial_cursor_anchors_on_the_editor_current_node)
1399
1400 def pwd_is_a_real_node():
1401 """HCPathTab.pwd() returned an HCNode with no path(), so path() -- and
1402 the Show Path Message command through it -- raised AttributeError."""
1403 import inspect
1404 from hc import HCPathTab
1405 source = inspect.getsource(HCPathTab.pwd)
1406 assert "HCNode" not in source, "pwd() is wrapping again"
1407 node = hou.node("/obj")
1408 assert callable(node.path) and node.path() == "/obj"
1409 return "pwd() hands back a hou.Node"
1410
1411 check("pwd returns a hou.Node", pwd_is_a_real_node)
1412
1413
1414 def check_panel():
1415 print("settings panel")
1416
1417 def pypanel_name_matches():
1418 """The interface name lives in two files. If they drift, the pypanel
1419 still registers and the tab still opens from the pane tab menu, but
1420 HCSession.openSettings() silently stops finding it and floats a second
1421 copy on every invocation."""
1422 import xml.etree.ElementTree as ET
1423 from hc.hcsettings import HCSettingsPanel
1424 path = ROOT / "python_panels" / "hc_settings.pypanel"
1425 assert path.exists(), f"missing {path}"
1426 names = [e.get("name") for e in ET.parse(path).getroot().iter("interface")]
1427 assert HCSettingsPanel.INTERFACE_NAME in names, \
1428 f"INTERFACE_NAME {HCSettingsPanel.INTERFACE_NAME!r} not in {names}"
1429 return f"{HCSettingsPanel.INTERFACE_NAME!r} declared in both"
1430
1431 check("pypanel interface name", pypanel_name_matches)
1432
1433 def panel_is_not_a_dialog():
1434 """A QDialog reparented into a pane tab keeps dialog behaviour (it can
1435 swallow Esc and close itself out of the tab). onCreateInterface() has
1436 to hand Houdini a plain QWidget."""
1437 from PySide6.QtWidgets import QDialog, QWidget
1438 from hc.hcsettings import HCSettingsPanel
1439 assert issubclass(HCSettingsPanel, QWidget), "panel is not a QWidget"
1440 assert not issubclass(HCSettingsPanel, QDialog), "panel is a QDialog again"
1441 return "HCSettingsPanel is a plain QWidget"
1442
1443 check("panel base class", panel_is_not_a_dialog)
1444
1445
1446 def check_node_colors():
1447 """The rule updateNodeColors() enforces: HC owns a node's color only while
1448 the node still wears the color HC last wrote to it."""
1449 print("node colors")
1450
1451 from hc.hcsettings import colorsMatch, formatHex, parseHex
1452 settings = HCSettings()
1453
1454 def hex_round_trips():
1455 assert parseHex("#607070") == parseHex("607070"), "the # is not optional"
1456 assert formatHex(parseHex("#607070")) == "#607070", "round trip lost the value"
1457 assert formatHex(hou.Color((1.0, 0.0, 0.0))) == "#ff0000", "hou.Color not handled"
1458 for junk in ("", "#ff", "blue", "#gggggg", None):
1459 assert parseHex(junk) is None, f"{junk!r} parsed as a color"
1460 return "parse/format agree, junk rejected"
1461
1462 check("hex helpers", hex_round_trips)
1463
1464 def every_color_default_parses():
1465 """A `color` whose default is not a color hands the panel, and every
1466 caller, a fallback that is itself broken."""
1467 found = []
1468
1469 def walk(schema, path):
1470 for key, value in schema.items():
1471 if isinstance(value, dict):
1472 walk(value, path + (key,))
1473 elif value.kind == "color":
1474 assert parseHex(value.default) is not None, \
1475 f"{'.'.join(path + (key,))} default {value.default!r} is not a color"
1476 found.append(".".join(path + (key,)))
1477
1478 walk(hcschema.SCHEMA, ())
1479 assert found, "no color settings declared"
1480 return ", ".join(found)
1481
1482 check("color defaults", every_color_default_parses)
1483
1484 def fallback_is_per_setting():
1485 """Each color falls back to its own declared default, not a shared one."""
1486 arrow = ("node_graph", "current_node_arrow_color")
1487 node = ("node_graph", "node_color")
1488 assert settings.colorHex(*arrow) != settings.colorHex(*node), \
1489 "two colors resolved to the same value"
1490 for path in (arrow, node):
1491 declared = hcschema.lookup(path).default
1492 # A key absent from the file must still resolve to its own default.
1493 assert formatHex(parseHex(declared)) == settings.colorHex(*path), \
1494 f"{path[-1]} did not resolve to its declared default"
1495 # The arrow color has to still be the value it was hardcoded to.
1496 assert colorsMatch(settings.color(*arrow), hou.Color((0.38, 0.56, 0.56))), \
1497 "the arrow color moved when it was pulled into the schema"
1498 return f"{settings.colorHex(*arrow)} arrow, {settings.colorHex(*node)} node"
1499
1500 check("per-setting color fallback", fallback_is_per_setting)
1501
1502 def bad_hex_falls_back_to_schema():
1503 # Not to a second hardcoded color: the fallback has to be the value the
1504 # settings panel shows for a missing key.
1505 schema_default = hcschema.lookup(("node_graph", "node_color")).default
1506 assert HCSettings._merged(
1507 HCSettings.DEFAULTS, {"node_graph": {}}
1508 )["node_graph"]["node_color"] == schema_default, "defaults drifted"
1509 assert settings.nodeColorHex() == formatHex(settings.nodeColorRGB()), \
1510 "nodeColorHex and nodeColorRGB disagree"
1511 return f"schema default {schema_default}"
1512
1513 check("nodeColor fallback", bad_hex_falls_back_to_schema)
1514
1515 RED = hou.Color((1.0, 0.0, 0.0))
1516 GREEN = hou.Color((0.0, 1.0, 0.0))
1517
1518 @nodeColoring(True)
1519 def only_unchanged_nodes_are_recolored():
1520 default = settings.nodeColor()
1521 default_hex = settings.nodeColorHex()
1522 geo = hou.node("/obj").createNode("geo")
1523
1524 managed = geo.createNode("box")
1525 managed.setUserData("hc_custom_color", default_hex)
1526 managed.setColor(default)
1527
1528 hand = geo.createNode("box")
1529 hand.setUserData("hc_custom_color", default_hex)
1530 hand.setColor(RED) # recolored since HC last wrote it
1531
1532 untagged = geo.createNode("box")
1533 untagged.destroyUserData("hc_custom_color", must_exist=False)
1534 untagged.setColor(GREEN)
1535
1536 legacy = geo.createNode("box")
1537 legacy.setUserData("hc_custom_color", "1") # pre-record tag
1538 legacy.setColor(GREEN)
1539
1540 blank(HCSession).updateNodeColors()
1541
1542 assert colorsMatch(managed.color(), default), "managed node was not maintained"
1543 assert colorsMatch(hand.color(), RED), "hand-picked color was reverted"
1544 assert colorsMatch(untagged.color(), GREEN), "untagged node was recolored"
1545 assert colorsMatch(legacy.color(), default), "legacy '1' tag was not adopted"
1546 assert legacy.userData("hc_custom_color") == default_hex, \
1547 "legacy tag was not upgraded to the color record"
1548
1549 geo.destroy()
1550 return "hand-picked colors survive, legacy tags adopted"
1551
1552 check("updateNodeColors ownership", only_unchanged_nodes_are_recolored)
1553
1554 @nodeColoring(True)
1555 def second_pass_writes_nothing():
1556 """Every setColor marks the hip modified, and 456.py runs this on load."""
1557 geo = hou.node("/obj").createNode("geo")
1558 node = geo.createNode("box")
1559 node.setUserData("hc_custom_color", "1")
1560 node.setColor(RED)
1561
1562 session = blank(HCSession)
1563 first = session.updateNodeColors()
1564 second = session.updateNodeColors()
1565 assert first >= 1, "the node needing a recolor was not counted"
1566 assert second == 0, f"{second} redundant write(s) on an unchanged scene"
1567
1568 geo.destroy()
1569 return "idempotent: no writes when nothing changed"
1570
1571 check("updateNodeColors is idempotent", second_pass_writes_nothing)
1572
1573 @nodeColoring(True)
1574 def opting_out_is_reversible():
1575 """Set Node Colors / Reset Node Colors, minus the UI.
1576
1577 Dropping the tag has to be undoable by hand, or coloring one node is a
1578 one-way door out of the setting.
1579 """
1580 default = settings.nodeColor()
1581 default_hex = settings.nodeColorHex()
1582 geo = hou.node("/obj").createNode("geo")
1583 node = geo.createNode("box")
1584 session = blank(HCSession)
1585
1586 # Set Node Colors: an explicit color, tag dropped.
1587 node.setColor(RED)
1588 node.destroyUserData("hc_custom_color", must_exist=False)
1589 session.updateNodeColors()
1590 assert colorsMatch(node.color(), RED), "opting out did not stick"
1591
1592 # Reset Node Colors: default color, tag re-recorded.
1593 node.setColor(default)
1594 node.setUserData("hc_custom_color", default_hex)
1595 session.updateNodeColors()
1596 assert colorsMatch(node.color(), default), "reset node was not maintained"
1597 assert node.userData("hc_custom_color") == default_hex, "reset lost the tag"
1598
1599 geo.destroy()
1600 return "opt out, then opt back in"
1601
1602 check("resetNodeColors round trip", opting_out_is_reversible)
1603
1604 @nodeColoring(True)
1605 def recolor_is_one_undo():
1606 """A scene-wide recolor must cost one ctrl-Z, not one per node."""
1607 if not hou.undos.areEnabled():
1608 return "skipped: undos disabled in this interpreter"
1609
1610 geo = hou.node("/obj").createNode("geo")
1611 nodes = []
1612 for _ in range(3):
1613 node = geo.createNode("box")
1614 node.setUserData("hc_custom_color", "1")
1615 node.setColor(RED)
1616 nodes.append(node)
1617
1618 session = blank(HCSession)
1619 hou.undos.clear()
1620 session.updateNodeColors()
1621
1622 labels = hou.undos.undoLabels()
1623 assert len(labels) == 1, f"{len(labels)} undo entries for one recolor: {labels}"
1624
1625 # And an unchanged scene must not leave an empty entry behind.
1626 session.updateNodeColors()
1627 assert hou.undos.undoLabels() == labels, \
1628 f"a no-op pass pushed an undo entry: {hou.undos.undoLabels()}"
1629
1630 hou.undos.performUndo()
1631 assert all(colorsMatch(n.color(), RED) for n in nodes), \
1632 "one undo did not revert every node"
1633
1634 geo.destroy()
1635 return f"3 nodes, 1 undo entry ({labels[0]})"
1636
1637 check("updateNodeColors undo grouping", recolor_is_one_undo)
1638
1639
1640 def check_current_node():
1641 """HCNetworkEditor.currentNode() has to answer with the node that is
1642 current *now*, inside the displayed network.
1643
1644 hou.NetworkEditor.currentNode() answers with the editor's last-known
1645 current node instead. It keeps naming a node after that node stops being
1646 current, and it falls back to the network itself when the editor has no
1647 current child there. So Rename Node and the flag toggles could act on
1648 whatever had been current a moment ago, and at /obj -- where the fallback
1649 applies -- the off-screen arrow never drew at all, its guard
1650 `current.parent() != pwd()` reducing to `pwd().parent() != pwd()`.
1651
1652 The staleness lives in the editor, so it cannot be reproduced without a
1653 pane. What is checkable here is the rule the fix rests on: read isCurrent()
1654 off the children, and answer None when none of them is current. currentNode()
1655 only touches hou_tab.pwd(), so a stub tab is enough to drive it.
1656 """
1657 print("current node")
1658
1659 class _StubTab:
1660 def __init__(self, node):
1661 self._node = node
1662
1663 def pwd(self):
1664 return self._node
1665
1666 def finds_the_current_child():
1667 geo = hou.node("/obj").createNode("geo")
1668 a = geo.createNode("box")
1669 b = geo.createNode("sphere")
1670 editor = blank(HCNetworkEditor)
1671 editor.hou_tab = _StubTab(geo)
1672
1673 b.setCurrent(True, clear_all_selected=True)
1674 found = editor.currentNode()
1675 assert found == b, f"expected the current child, got {found}"
1676 assert found != geo, "handed back the network itself"
1677
1678 a.setCurrent(True, clear_all_selected=True)
1679 assert editor.currentNode() == a, "did not follow the current node"
1680
1681 geo.destroy()
1682 return "follows the current child"
1683
1684 check("currentNode finds the child", finds_the_current_child)
1685
1686 def current_implies_selected():
1687 """currentNode() looks through selectedChildren() first, which is only
1688 a fast path as long as the current node is always in there."""
1689 geo = hou.node("/obj").createNode("geo")
1690 a = geo.createNode("box")
1691 b = geo.createNode("sphere")
1692
1693 a.setCurrent(True, clear_all_selected=True)
1694 assert a.isSelected(), "setCurrent did not select"
1695
1696 # Selecting another node moves current with it...
1697 b.setSelected(True)
1698 assert b.isCurrent() and not a.isCurrent(), "current did not follow selection"
1699
1700 # ...and deselecting clears it, so there is no current-but-unselected
1701 # node for the fast path to miss.
1702 b.setSelected(False)
1703 assert not b.isCurrent(), "a deselected node stayed current"
1704
1705 for node in (a, b):
1706 node.setCurrent(True, clear_all_selected=True)
1707 assert node in geo.selectedChildren(), (
1708 "the current node is not in selectedChildren -- the fast path "
1709 "in HCNetworkEditor.currentNode() is no longer sufficient"
1710 )
1711
1712 geo.destroy()
1713 return "current is always selected, so the fast path suffices"
1714
1715 check("current implies selected", current_implies_selected)
1716
1717 def empty_network_is_none():
1718 geo = hou.node("/obj").createNode("geo")
1719 editor = blank(HCNetworkEditor)
1720 editor.hou_tab = _StubTab(geo)
1721 assert editor.currentNode() is None, \
1722 "an empty network reported a current node"
1723 geo.destroy()
1724 return "None, not the network"
1725
1726 check("currentNode on an empty network", empty_network_is_none)
1727
1728 def nothing_current_is_none():
1729 """The headless stand-in for the editor's staleness: children exist,
1730 none of them is current, so the answer is None rather than a leftover."""
1731 geo = hou.node("/obj").createNode("geo")
1732 node = geo.createNode("box")
1733 editor = blank(HCNetworkEditor)
1734 editor.hou_tab = _StubTab(geo)
1735
1736 node.setCurrent(True, clear_all_selected=True)
1737 assert editor.currentNode() == node, "precondition: the node is current"
1738
1739 node.setSelected(False) # also clears current
1740 assert not node.isCurrent(), "precondition: nothing is current now"
1741 assert editor.currentNode() is None, \
1742 "answered with a node that is no longer current"
1743
1744 geo.destroy()
1745 return "None once nothing is current"
1746
1747 check("currentNode goes None", nothing_current_is_none)
1748
1749 def no_network_is_none():
1750 """pwd() can be None -- seen live while floating pane tabs were being
1751 created and closed -- and the overlay reached currentNode() through
1752 the event hook, which raised on every event until the editor had a
1753 network again."""
1754 editor = blank(HCNetworkEditor)
1755 editor.hou_tab = _StubTab(None)
1756 assert editor.currentNode() is None, "answered without a network"
1757 return "None without a network"
1758
1759 check("currentNode without a network", no_network_is_none)
1760
1761 def overlay_skips_without_network():
1762 """The stub has pwd() and nothing else: touching the editor to draw
1763 raises AttributeError, so returning quietly is the only way through."""
1764 editor = blank(HCNetworkEditor)
1765 editor.hou_tab = _StubTab(None)
1766 editor.updateCurrentNodeOverlay()
1767 editor.updateCurrentNodeOverlay(force=True)
1768 return "draws nothing"
1769
1770 check("overlay without a network", overlay_skips_without_network)
1771
1772 def hook_skips_without_network():
1773 """The event hook lets the event through untouched rather than
1774 reading a network the editor has not got. nodegraphhooks cannot be
1775 imported without hou.ui (Houdini's nodegraphbase needs it at import),
1776 so this reads the source: the guard has to come before the hook
1777 wraps the editor."""
1778 source = (ROOT / "python3.13libs" / "nodegraphhooks.py").read_text()
1779 body = source[source.index("def createEventHandler("):]
1780 guard = body.find("editor.pwd() is None")
1781 wrap = body.find("HCNetworkEditor(editor)")
1782 assert guard != -1, "the hook no longer checks for a missing network"
1783 assert wrap != -1 and guard < wrap, "the hook wraps the editor before checking its network"
1784 return "guard precedes the wrapper"
1785
1786 check("hook without a network", hook_skips_without_network)
1787
1788 def guards_stay_gone():
1789 """The `current.parent()` guards only fired in the fallback case, and
1790 threw the answer away when they did. currentNode() now returns a child
1791 of pwd() or None, so there is nothing left for them to check."""
1792 source = (ROOT / "python3.13libs" / "hc" / "hcnetworkeditor.py").read_text()
1793 for gone in ("current.parent() != ed.pwd()",
1794 "current.parent() == self.hou_tab.pwd()"):
1795 assert gone not in source, f"the guard {gone!r} is back"
1796 # One reader of the editor's answer is allowed: _editorCurrentChild,
1797 # the post-load fallback anchor for the hcnetcursor, which documents
1798 # that it is reading a possibly stale value and only uses it when it
1799 # is a child of pwd(). Nothing else may bypass the wrapper.
1800 import ast
1801 tree = ast.parse(source)
1802 readers = set()
1803 for func in ast.walk(tree):
1804 if isinstance(func, ast.FunctionDef):
1805 if "self.hou_tab.currentNode()" in ast.unparse(func):
1806 readers.add(func.name)
1807 assert readers <= {"_editorCurrentChild"}, \
1808 f"callers bypass the wrapper and get the editor's stale answer: {sorted(readers)}"
1809 return "only _editorCurrentChild reads the editor's answer"
1810
1811 check("guards stay gone", guards_stay_gone)
1812
1813
1814 def check_disable_switches():
1815 """Both features have to be switchable off, all the way off.
1816
1817 hcnetcursor was already declared and already honoured by the drawing code,
1818 but nodegraphhooks kept moving the invisible cursor and kept swallowing the
1819 unmodified f key -- so "off" left f doing nothing instead of Houdini's
1820 frame-selection.
1821 """
1822 print("disable switches")
1823
1824 settings = HCSettings()
1825
1826 def both_are_declared():
1827 for key in ("node_coloring", "hcnetcursor"):
1828 setting = hcschema.lookup(("node_graph", key))
1829 assert setting is not None, f"node_graph.{key} is not declared"
1830 assert setting.kind == "bool", f"{key} is {setting.kind}, not a checkbox"
1831 assert setting.default is True, f"{key} defaults off -- it must default on"
1832 return "node_coloring and hcnetcursor, both bool, both default on"
1833
1834 check("declared and default on", both_are_declared)
1835
1836 def coloring_off_is_a_no_op():
1837 geo = hou.node("/obj").createNode("geo")
1838 node = geo.createNode("box")
1839 node.setUserData("hc_custom_color", "1") # would be recolored
1840 node.setColor(hou.Color((1.0, 0.0, 0.0)))
1841
1842 session = blank(HCSession)
1843 with nodeColoring(False):
1844 assert session.updateNodeColors() == 0, "recolored while switched off"
1845 assert node.userData("hc_custom_color") == "1", "rewrote the tag while off"
1846
1847 # And the tag is still there, so turning it back on resumes.
1848 with nodeColoring(True):
1849 assert session.updateNodeColors() >= 1, "did not resume when switched on"
1850 geo.destroy()
1851 return "no writes while off, resumes after"
1852
1853 check("node_coloring off", coloring_off_is_a_no_op)
1854
1855 def hooks_gate_on_the_setting():
1856 """The hook runs per UI event and cannot be driven without a pane, so
1857 read the structure instead: every hcnetcursor call it makes has to sit
1858 under an `if` that tests the setting."""
1859 import ast
1860
1861 source = (ROOT / "python3.13libs" / "nodegraphhooks.py").read_text()
1862 tree = ast.parse(source)
1863
1864 def calls_under(node):
1865 """Names of hcnetcursor methods called anywhere beneath node."""
1866 out = set()
1867 for sub in ast.walk(node):
1868 if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute):
1869 if "hcnetcursor" in sub.func.attr.lower():
1870 out.add(sub.func.attr)
1871 return out
1872
1873 cursor_calls = calls_under(tree)
1874 # updateCurrentNodeOverlay is deliberately ungated: it is what removes
1875 # the cursor image when the setting is turned off, and it also draws
1876 # the off-screen arrow, which has nothing to do with the cursor.
1877 must_gate = {"moveHcnetcursorToPosition", "fitHcnetcursorToSelectedNodes",
1878 "frameHcnetcursor", "refreshHcnetcursor"}
1879 present = cursor_calls & must_gate
1880 assert present, f"no cursor calls found to check, only {sorted(cursor_calls)}"
1881
1882 gated = set()
1883 for node in ast.walk(tree):
1884 # Calls inside `if ...hcnetcursorEnabled...:`
1885 if isinstance(node, ast.If) and "hcnetcursorEnabled" in ast.unparse(node.test):
1886 gated |= calls_under(node)
1887 # ...or anywhere in a function that opens with an early-return guard.
1888 if isinstance(node, ast.FunctionDef):
1889 for stmt in node.body:
1890 if (isinstance(stmt, ast.If)
1891 and "hcnetcursorEnabled" in ast.unparse(stmt.test)
1892 and any(isinstance(b, ast.Return) for b in stmt.body)):
1893 gated |= calls_under(node)
1894 break
1895
1896 # refreshHcnetcursor sits in the modifier-change branch and is a redraw,
1897 # not a cursor move -- it is allowed through like updateCurrentNodeOverlay.
1898 required = present - {"refreshHcnetcursor"}
1899 missing = required - gated
1900 assert not missing, f"not gated on hcnetcursorEnabled: {sorted(missing)}"
1901 return "gated: " + ", ".join(sorted(required))
1902
1903 check("hcnetcursor off", hooks_gate_on_the_setting)
1904
1905 def accessors_read_the_file():
1906 for name in ("nodeColoringEnabled", "hcnetcursorEnabled"):
1907 assert getattr(settings, name)() in (True, False), f"{name} is not a bool"
1908 return "both accessors return bools"
1909
1910 check("accessors", accessors_read_the_file)
1911
1912
1913 def check_cursor_image():
1914 """The network cursor picture is painted on demand, in the set colour."""
1915 print("network cursor image")
1916 import tempfile
1917 from hc import hcnetcursorimage
1918
1919 def painted_to_spec():
1920 from PySide6.QtGui import QImage
1921 with tempfile.TemporaryDirectory() as tmp:
1922 path = os.path.join(tmp, "hcnetcursor_3x2_a1b2c3.png")
1923 hcnetcursorimage.paint(path, 600, 200, "#a1b2c3")
1924 image = QImage(path)
1925 assert (image.width(), image.height()) == (600, 200), \
1926 f"{image.width()}x{image.height()}"
1927 edge = image.pixelColor(2, 100)
1928 assert edge.name() == "#a1b2c3" and edge.alpha() == 255, \
1929 f"edge {edge.name()} a={edge.alpha()}"
1930 inside = image.pixelColor(300, 100)
1931 assert inside.alpha() == 0, f"centre alpha {inside.alpha()}"
1932 just_in = image.pixelColor(hcnetcursorimage.BORDER, 100)
1933 assert just_in.alpha() == 0, "outline wider than BORDER"
1934 return "600x200, 5px outline, transparent inside"
1935
1936 check("painted to spec", painted_to_spec)
1937
1938 def cached_per_colour():
1939 a = hcnetcursorimage.image_file(1, 1, "#d8b34b")
1940 b = hcnetcursorimage.image_file(1, 1, "#D8B34B")
1941 c = hcnetcursorimage.image_file(1, 1, "#000000")
1942 assert a == b, "colour case made a second file"
1943 assert a != c, "different colours share a file"
1944 assert os.path.exists(a) and os.path.exists(c)
1945 assert hcnetcursorimage.image_file(1.99, 0.99, "#d8b34b").endswith("_199x99_d8b34b.png"), \
1946 "picture not sized by the box in units"
1947 assert hcnetcursorimage.image_file(40, 1, "#d8b34b").endswith("_2400x100_d8b34b.png"), \
1948 "oversize cursor not clamped"
1949 return os.path.dirname(a)
1950
1951 check("one file per size and colour", cached_per_colour)
1952
1953 def old_paths_recognised():
1954 """Hips saved with the cursor showing still name the retired asset dir."""
1955 assert hcnetcursorimage.is_cursor_image(
1956 "/x/hou-control/config/hcnetcursor_assets/hcnetcursor_2x1.png")
1957 assert hcnetcursorimage.is_cursor_image(
1958 hcnetcursorimage.image_file(1, 1, "#d8b34b"))
1959 assert not hcnetcursorimage.is_cursor_image("/x/backdrop.png")
1960 assert not (ROOT / "config" / "hcnetcursor_assets").exists(), \
1961 "static asset set is still checked in"
1962 return "old and new paths both filtered out"
1963
1964 check("stale cursor images are filtered", old_paths_recognised)
1965
1966 def settings_declared():
1967 for key in ("hcnetcursor", "hcnetcursor_color", "hcnetcursor_margin"):
1968 setting = hcschema.lookup(("node_graph", key))
1969 assert setting is not None, f"{key} missing"
1970 assert setting.group == "Network Cursor", f"{key} in {setting.group!r}"
1971 assert hcschema.lookup(("node_graph", "hcnetcursor")).label == "Network Cursor"
1972 settings = HCSettings()
1973 assert settings.hcnetcursorColorHex().startswith("#")
1974 low, high = hcschema.lookup(("node_graph", "hcnetcursor_margin")).range
1975 assert low <= settings.hcnetcursorMargin() <= high
1976 return f"colour {settings.hcnetcursorColorHex()}, margin {settings.hcnetcursorMargin()}"
1977
1978 check("cursor settings", settings_declared)
1979
1980
1981 def check_view_regions():
1982 """What hcviewregions sends the compositor for one window."""
1983 print("view regions")
1984 from hc.hcviewregions import HCViewRegions
1985
1986 def empty_is_drag_nowhere():
1987 """No view panes must not be sent as `clear`: to the compositor that
1988 word restores the whole-window drag, which is what turned a swipe
1989 over the HC Panel into a row pick."""
1990 args = HCViewRegions.wire([])
1991 assert args == ["0,0,0,0"], args
1992 assert "clear" not in args
1993 return "zero-area rect, not clear"
1994
1995 check("no panes sends a zero-area rect", empty_is_drag_nowhere)
1996
1997 def rects_are_integers():
1998 args = HCViewRegions.wire([(10.6, 20.2, 300.9, 400.1), (0, 0, 5, 5)])
1999 assert args == ["10,20,300,400", "0,0,5,5"], args
2000 return "x,y,w,h per rect"
2001
2002 check("rect wire format", rects_are_integers)
2003
2004 def publishes_are_reaped():
2005 """A `ccectl` child nobody waited on sat as a zombie under
2006 houdini-bin for sixteen minutes (2026-09-15): CPython reaps a dropped
2007 Popen only when the next one is made, and the next publish is the
2008 next layout change. Stand in for ccectl with a script that exits and
2009 one that hangs, and drive publish/reap by hand."""
2010 import os
2011 import stat
2012 import tempfile
2013 import time
2014 from hc import hcviewregions
2015
2016 def stand_in(tmp, name, body):
2017 path = os.path.join(tmp, name)
2018 with open(path, "w") as f:
2019 f.write("#!/bin/sh\n" + body + "\n")
2020 os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
2021 return path
2022
2023 def zombie(proc):
2024 try:
2025 with open(f"/proc/{proc.pid}/stat") as f:
2026 return f.read().rsplit(")", 1)[1].split()[0] == "Z"
2027 except OSError:
2028 return False
2029
2030 def drain(vr):
2031 deadline = time.monotonic() + 5
2032 while vr.reap() and time.monotonic() < deadline:
2033 time.sleep(0.01)
2034
2035 with tempfile.TemporaryDirectory() as tmp:
2036 vr = HCViewRegions()
2037 vr._exe = stand_in(tmp, "ccectl-exits", "exit 0")
2038 vr.publish(1, [])
2039 assert len(vr._pending) == 1, vr._pending
2040 proc = vr._pending[0][0]
2041 drain(vr)
2042 assert not vr._pending, "finished publish not reaped"
2043 assert proc.returncode == 0, proc.returncode
2044 assert not zombie(proc), "reaped child is still a zombie"
2045
2046 # A hung child is left alone until it outlives PUBLISH_TIMEOUT,
2047 # then killed and collected on a later reap.
2048 vr._exe = stand_in(tmp, "ccectl-hangs", "sleep 60")
2049 vr.publish(2, [])
2050 proc, started = vr._pending[0]
2051 assert vr.reap(started + 1) == 1, "live child dropped"
2052 assert proc.poll() is None, "live child killed early"
2053 vr.reap(started + hcviewregions.PUBLISH_TIMEOUT + 1)
2054 drain(vr)
2055 assert not vr._pending, "killed child never collected"
2056 assert proc.returncode not in (None, 0), proc.returncode
2057 assert not zombie(proc), "killed child is still a zombie"
2058 return "poll on tick, kill after timeout"
2059
2060 check("publishes are reaped", publishes_are_reaped)
2061
2062
2063 def check_startup_script():
2064 """scripts/123.py reads hc_settings.json without importing hc.
2065
2066 That is deliberate -- an exception in a startup script costs the whole
2067 launch -- but it means the file carries its own fallbacks instead of going
2068 through HCSettings and its DEFAULTS merge, and those can drift. They had:
2069 123.py fell back to default_autosave_state=False while the schema declared
2070 True. Importing the module here would run the startup logic, so read the
2071 literals out of the source instead.
2072 """
2073 print("startup script")
2074
2075 import ast
2076
2077 def fallbacks_match_the_schema():
2078 path = ROOT / "scripts" / "123.py"
2079 tree = ast.parse(path.read_text())
2080 declared = None
2081 for node in tree.body:
2082 if isinstance(node, ast.Assign) and any(
2083 getattr(t, "id", None) == "_DEFAULTS" for t in node.targets):
2084 declared = ast.literal_eval(node.value)
2085 assert declared, "123.py no longer declares a _DEFAULTS dict"
2086
2087 for key, value in declared.items():
2088 setting = hcschema.lookup(("startup", key))
2089 assert setting is not None, f"startup.{key} is not declared in hcschema"
2090 assert setting.default == value, (
2091 f"123.py falls back to startup.{key}={value!r}, "
2092 f"hcschema says {setting.default!r}"
2093 )
2094 return f"{len(declared)} fallback(s) agree with hcschema"
2095
2096 check("123.py fallbacks", fallbacks_match_the_schema)
2097
2098 def prompt_is_gated_on_the_setting():
2099 """The dialog must not be constructed when show_prompt is off."""
2100 source = (ROOT / "scripts" / "123.py").read_text()
2101 assert 'show_prompt' in source, "123.py does not read show_prompt"
2102 assert source.count("StartupPrompt(last_file)") == 1, \
2103 "more than one path constructs the dialog"
2104 # The only construction site has to sit under the gate.
2105 gate = source.index("if show_prompt:")
2106 assert gate < source.index("StartupPrompt(last_file)"), \
2107 "the dialog is built before the show_prompt gate"
2108 return "the dialog is built only under the gate"
2109
2110 check("startup prompt gate", prompt_is_gated_on_the_setting)
2111
2112 def post_load_script_calls_real_methods():
2113 """scripts/456.py runs after every hip load and, like 123.py, cannot
2114 be imported here without running it. Every HCSession method it names
2115 must exist -- a rename would only surface as a traceback on the next
2116 file open -- and the hcnetcursor reset must be among them: without it
2117 the node under the cursor sat unhighlighted after a load."""
2118 path = ROOT / "scripts" / "456.py"
2119 tree = ast.parse(path.read_text())
2120 called = set()
2121 for node in ast.walk(tree):
2122 if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
2123 and isinstance(node.func.value, ast.Call)
2124 and getattr(node.func.value.func, "id", None) == "HCSession"):
2125 called.add(node.func.attr)
2126 assert called, "456.py no longer calls HCSession()"
2127 missing = sorted(name for name in called if not callable(getattr(HCSession, name, None)))
2128 assert not missing, f"456.py calls HCSession methods that do not exist: {missing}"
2129 assert "resetHcnetcursorsDeferred" in called, "456.py does not reset the hcnetcursor after a load"
2130 return "calls " + ", ".join(sorted(called))
2131
2132 check("456.py calls real methods", post_load_script_calls_real_methods)
2133
2134
2135 def main():
2136 check_settings()
2137 check_panel()
2138 check_commands()
2139 check_state()
2140 check_chrome()
2141 check_split_handles()
2142 check_nodegraph_hooks()
2143 check_open_recent()
2144 check_status_circle()
2145 check_geometry()
2146 check_node_ops()
2147 check_node_colors()
2148 check_current_node()
2149 check_disable_switches()
2150 check_cursor_image()
2151 check_view_regions()
2152 check_startup_script()
2153 print(f"\n{passed} passed, {failed} failed")
2154 return 1 if failed else 0
2155
2156
2157 if __name__ == "__main__":
2158 sys.exit(main())