git.lucas.co / hou-control
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())