test: structure-guard suite — nodes/ role separation + WMNodes pin + loader regression

S5 of the 2026-09-08 layout plan: tests/test_structure.py grows the full
guard set — nodes/ exclusively node classes, src/ schema-free, category
WMNodes/image pinned per module, no legacy *_node.py in src/, git-ls-files
grep guard for old category strings, and the ComfyUI loader regression
(replicates load_custom_node spec_from_file_location mechanics, executes the
real entry, asserts comfy_entrypoint → 8-class get_node_list + on_load
installs the Qwen2D patch).
This commit is contained in:
WildAi
2026-09-08 21:23:38 +03:00
parent 1eef30a242
commit 3bc0a679f2
2 changed files with 194 additions and 3 deletions
+1 -1
View File
@@ -366,7 +366,7 @@ Restart ComfyUI. No further dependency installation is required.
## ▓ Changelog
### v2.15.0 — 2026-09-08
- **Restructured the pack layout + unified the node category.** All node definitions now live in a dedicated `nodes/` folder (`nodes/dype.py`, `sega.py`, `spa.py`, `hap.py`, `hap_calibrate.py`, `freescale.py`, `pixelrush.py`, `hiflow.py`); `src/` holds engines/implementation only and the pack `__init__.py` just registers the extension. All 8 nodes moved to the single **`WMNodes/image`** menu category (previously split across `model_patches/position_encoding` and `image/upscaling`). No node ids, inputs, defaults, or behavior changed — workflows keep loading. Also merges PR #41 (FreeScale fp16 antialiased-bicubic crash fix).
- **Restructured the pack layout + unified the node category.** All node definitions now live in a dedicated `nodes/` folder (`nodes/dype.py`, `sega.py`, `spa.py`, `hap.py`, `hap_calibrate.py`, `freescale.py`, `pixelrush.py`, `hiflow.py`); `src/` holds engines/implementation only and the pack `__init__.py` just registers the extension. All 8 nodes moved to the single **`WMNodes/image`** menu category (previously split across two menu paths). No node ids, inputs, defaults, or behavior changed — workflows keep loading. Also merges PR #41 (FreeScale fp16 antialiased-bicubic crash fix).
### v2.14.1 — 2026-09-07
- **Fixed HiFlow Krea2/Qwen-Image noising crash** (user-reported `torch.cat` size mismatch, "Expected size 1 but got size 16"): the v2.12.1 model-space noising called the model's `process_latent_in` on the 4D core tensor, but Wan21's per-channel mean/std stats are shaped `[1,C,1,1,1]` — a 4D tensor against 5D stats **broadcasts silently to `[B,C,C,H,W]` garbage** (the model reads T=16=channels). The node now wraps the noising conversions ndim-transparently: unsqueeze → convert in true 5D model space → squeeze back, so the cascade's σ-mix runs on 4D tensors with correctly-normalized values. The node-test mock now uses Wan21-faithful stats (replicating the broadcast hazard — the earlier affine mock masked the bug class).
+193 -2
View File
@@ -7,13 +7,20 @@ Entry `__init__.py` — extension registration only.
These guards are text/AST-based where the entry module cannot be imported in
the GPU-free test env, and runtime-based where the mock-comfy conftest makes
live imports possible (same split as test_hap_node.py / test_hiflow_node.py).
The loader regression test replicates ComfyUI's ``load_custom_node``
(``spec_from_file_location`` on the pack ``__init__.py``, executed as a
module in ``sys.modules``) — the mechanism verified empirically 2026-09-08.
Markers: @pytest.mark.unit
"""
from __future__ import annotations
import asyncio
import importlib.util
import pathlib
import sys
import types
import pytest
@@ -21,23 +28,74 @@ PROJECT_ROOT = pathlib.Path(__file__).parent.parent
ENTRY = PROJECT_ROOT / "__init__.py"
NODES_DIR = PROJECT_ROOT / "nodes"
NODES_INIT = NODES_DIR / "__init__.py"
SRC_DIR = PROJECT_ROOT / "src"
ALL_NODE_CLASSES = [
"DyPE_FLUX", "SEGA", "SPA", "HAP",
"HAPCalibrate", "PixelRushNode", "FreeScaleNode", "HiFlowNode",
]
# Node modules that should exist once every layout-plan step has landed.
# S1 ships dype/sega/spa/hap; the *_node moves append their entries (S2/S3).
# Expected node modules once every layout-plan step has landed.
EXPECTED_NODE_MODULES = [
"dype", "sega", "spa", "hap",
"hap_calibrate", "freescale", "pixelrush", "hiflow",
]
_OLD_CATEGORIES = ("model_patches/position_encoding", "image/upscaling")
def _entry_src() -> str:
return ENTRY.read_text(encoding="utf-8")
def _fake_mod(name, **attrs):
"""Additive-only mock module (pattern from tests/test_qwen2d_vae.py):
never replaces the root conftest's comfy mocks, registers in sys.modules
and wires the parent attribute."""
if name in sys.modules:
return sys.modules[name]
m = types.ModuleType(name)
for k, v in attrs.items():
setattr(m, k, v)
sys.modules[name] = m
parent, _, child = name.rpartition(".")
if parent and parent in sys.modules:
setattr(sys.modules[parent], child, m)
return m
def _ensure_entry_comfy_chain():
"""The pack entry imports src.qwen2d_vae_patch, which imports the comfy
chain (comfy.sd, comfy.model_management, ...). Install additive mocks
so the loader replica can execute the entry in the GPU-free env."""
_fake_mod("comfy.sd", VAE=type("VAE", (), {}))
_fake_mod("comfy.model_management",
is_amd=lambda: False, dtype_size=lambda d: 4,
OOM_EXCEPTION=RuntimeError)
_fake_mod("comfy.utils")
_fake_mod("comfy.ops", disable_weight_init=object)
_fake_mod("comfy.ldm")
_fake_mod("comfy.ldm.modules")
_fake_mod("comfy.ldm.modules.diffusionmodules")
_fake_mod("comfy.ldm.modules.diffusionmodules.model",
vae_attention=lambda *a, **k: None)
def _load_entry_via_comfy_loader():
"""Replicate ComfyUI nodes.load_custom_node for a DIRECTORY pack:
spec_from_file_location(sys_module_name, <dir>/__init__.py), module in
sys.modules, exec. Returns the loaded module (caller pops sys.modules)."""
sys_module_name = str(PROJECT_ROOT).replace(".", "_x_")
module_spec = importlib.util.spec_from_file_location(
sys_module_name, str(PROJECT_ROOT / "__init__.py")
)
_ensure_entry_comfy_chain()
module = importlib.util.module_from_spec(module_spec)
sys.modules[sys_module_name] = module
module_spec.loader.exec_module(module)
return module
@pytest.mark.unit
class TestStructureGuard:
def test_entry_imports_from_nodes_package(self):
@@ -90,3 +148,136 @@ class TestStructureGuard:
for name in EXPECTED_NODE_MODULES:
mod = NODES_DIR / f"{name}.py"
assert mod.exists(), f"nodes/{name}.py missing (layout plan step incomplete)"
def test_nodes_dir_exclusively_nodes(self):
"""Every nodes/*.py module defines an io.ComfyNode subclass; src/
never defines a node schema."""
for name in EXPECTED_NODE_MODULES:
src = (NODES_DIR / f"{name}.py").read_text(encoding="utf-8")
assert "io.ComfyNode" in src or "_ComfyNodeBase = io.ComfyNode" in src, (
f"nodes/{name}.py must define an io.ComfyNode node class"
)
for py in SRC_DIR.rglob("*.py"):
src = py.read_text(encoding="utf-8")
assert "io.Schema(" not in src, (
f"engine file {py.name} must not define node schemas — "
f"node definitions live in nodes/ (layout plan 2026-09-08)"
)
def test_category_wmnodes_everywhere(self):
"""Every node module pins category=\"WMNodes/image\"."""
for name in EXPECTED_NODE_MODULES:
src = (NODES_DIR / f"{name}.py").read_text(encoding="utf-8")
assert 'category="WMNodes/image"' in src, (
f"nodes/{name}.py must use category=\"WMNodes/image\""
)
def test_no_category_strings_in_engines(self):
"""No category strings (old or new) may appear in src/."""
for py in SRC_DIR.rglob("*.py"):
src = py.read_text(encoding="utf-8")
for cat in _OLD_CATEGORIES + ("WMNodes/image",):
assert cat not in src, f"{py.name} mentions category {cat!r}"
def test_no_legacy_node_files_in_src(self):
"""src/ holds no *_node.py leftovers."""
leftovers = sorted(str(p.name) for p in SRC_DIR.glob("*_node.py"))
assert not leftovers, f"legacy node modules remain in src/: {leftovers}"
@pytest.mark.unit
class TestCategoryGrepGuard:
def test_no_old_category_strings_repo_wide(self):
"""No tracked file mentions the old category paths. Uses git
ls-files so untracked/ignored files never mask a miss."""
import subprocess
out = subprocess.run(
["git", "ls-files"], cwd=str(PROJECT_ROOT),
capture_output=True, text=True, check=True,
).stdout.splitlines()
offenders = []
for rel in out:
if rel.replace("\\", "/") == "tests/test_structure.py":
continue # this guard holds the reference strings
if not rel.endswith((".py", ".md", ".toml", ".yml", ".json")):
continue
path = PROJECT_ROOT / rel
if not path.exists():
continue
text = path.read_text(encoding="utf-8", errors="replace")
for cat in _OLD_CATEGORIES:
if cat in text:
offenders.append(f"{rel}: {cat}")
assert not offenders, f"old category strings remain: {offenders}"
@pytest.mark.unit
class TestComfyLoaderRegression:
"""Replicates ComfyUI's load_custom_node for a DIRECTORY pack and pins
that the pack keeps loading through that exact mechanism."""
def test_entry_exposes_v3_contract(self):
"""Loader mechanics: module loads, exports comfy_entrypoint +
DyPEExtension."""
sys_module_name = str(PROJECT_ROOT).replace(".", "_x_")
try:
module = _load_entry_via_comfy_loader()
assert hasattr(module, "comfy_entrypoint"), (
"pack entry must export comfy_entrypoint() for the V3 loader"
)
assert hasattr(module, "DyPEExtension"), (
"pack entry must export the DyPEExtension class"
)
finally:
sys.modules.pop(sys_module_name, None)
def test_entrypoint_returns_8_node_extension(self):
"""comfy_entrypoint() → get_node_list() yields the 8 node classes
(runtime import through loader mechanics, mock-comfy env)."""
sys_module_name = str(PROJECT_ROOT).replace(".", "_x_")
try:
module = _load_entry_via_comfy_loader()
ext = asyncio.run(module.comfy_entrypoint())
nodes_list = asyncio.run(ext.get_node_list())
names = sorted(c.__name__ for c in nodes_list)
assert names == sorted(ALL_NODE_CLASSES), (
f"loaded pack registers {names}, expected {sorted(ALL_NODE_CLASSES)}"
)
finally:
sys.modules.pop(sys_module_name, None)
def test_extension_on_load_installs_qwen2d_patch(self):
"""on_load() must install the Qwen2D VAE patch (monkeypatched to
observe the call; the real patcher is engine-level and covered by
test_qwen2d_vae.py)."""
sys_module_name = str(PROJECT_ROOT).replace(".", "_x_")
try:
module = _load_entry_via_comfy_loader()
ext = asyncio.run(module.comfy_entrypoint())
called = {}
import src.qwen2d_vae_patch as qwen_patch
orig = qwen_patch.install_qwen2d_patch
def spy():
called["yes"] = True
return orig()
# The loaded entry module resolved install_qwen2d_patch into ITS
# namespace at import time (from .src.qwen2d_vae_patch import);
# on_load() looks the name up there. Patch BOTH namespaces.
qwen_patch.install_qwen2d_patch = spy
entry_ns_orig = getattr(module, "install_qwen2d_patch", None)
module.install_qwen2d_patch = spy
try:
asyncio.run(ext.on_load())
finally:
qwen_patch.install_qwen2d_patch = orig
if entry_ns_orig is not None:
module.install_qwen2d_patch = entry_ns_orig
assert called.get("yes") is True, (
"extension on_load() did not call install_qwen2d_patch"
)
finally:
sys.modules.pop(sys_module_name, None)