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).
284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""Pack-layout structure guards (plan 2026-09-08: nodes/ + src/ separation).
|
|
|
|
`nodes/` — exclusively ComfyUI node definitions (schema + execute wiring).
|
|
`src/` — exclusively engines/implementation.
|
|
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
|
|
|
|
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",
|
|
]
|
|
|
|
# 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):
|
|
"""The entry must define NO schemas itself — nodes come from .nodes."""
|
|
src = _entry_src()
|
|
assert "from .nodes import" in src, (
|
|
"entry __init__.py must import node classes from the .nodes package"
|
|
)
|
|
assert "io.Schema(" not in src, (
|
|
"entry __init__.py must not define schemas — node definitions "
|
|
"live in nodes/ (pack layout plan 2026-09-08)"
|
|
)
|
|
assert "define_schema" not in src, (
|
|
"entry __init__.py must not define node classes"
|
|
)
|
|
|
|
def test_entry_node_list_is_8_classes(self):
|
|
"""get_node_list() must return exactly the 8 registered classes."""
|
|
src = _entry_src()
|
|
start = src.index("async def get_node_list")
|
|
body = src[start:]
|
|
marker = "return ["
|
|
bracket = body.index(marker) + len(marker) - 1
|
|
close = body.index("]", bracket)
|
|
listed = [c.strip() for c in body[bracket + 1:close].split(",") if c.strip()]
|
|
assert sorted(listed) == sorted(ALL_NODE_CLASSES), (
|
|
f"get_node_list must return exactly {sorted(ALL_NODE_CLASSES)}, "
|
|
f"got {sorted(listed)}"
|
|
)
|
|
|
|
def test_nodes_init_exports_all_classes(self):
|
|
"""import nodes (flat, mock-comfy env) exposes all 8 node classes."""
|
|
import nodes # repo root on sys.path via conftest
|
|
|
|
for cls in ALL_NODE_CLASSES:
|
|
assert hasattr(nodes, cls), (
|
|
f"nodes/__init__.py must re-export {cls}; missing"
|
|
)
|
|
assert getattr(nodes, cls) is not None
|
|
|
|
def test_entry_registers_qwen2d_patch_on_load(self):
|
|
"""on_load keeps installing the Qwen2D VAE patch (entry's only job
|
|
besides registration)."""
|
|
src = _entry_src()
|
|
assert "install_qwen2d_patch" in src
|
|
assert "async def on_load" in src
|
|
|
|
def test_pack_node_modules_exist(self):
|
|
"""Every expected node module file is present in nodes/."""
|
|
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)
|