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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user