Add VAE encode/decode option nodes across legacy and Comfy v3 exports, with tooltip and registration coverage. Preserve cloned model graph composition for differential diffusion in tiled and regional sampling paths.
202 lines
7.2 KiB
Python
202 lines
7.2 KiB
Python
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
|
# Copyright (C) 2026 Artificial Sweetener and contributors
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
"""Coverage tests for ComfyUI node tooltip metadata."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from collections.abc import Mapping
|
|
from types import ModuleType
|
|
from typing import Any, Protocol, cast
|
|
|
|
import pytest
|
|
|
|
from simple_syrup.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
|
from simple_syrup.nodes_v3.encode_prompt_batch_with_prompt_control import (
|
|
EncodePromptBatchWithPromptControl,
|
|
)
|
|
from simple_syrup.nodes_v3.scale_factor import ScaleFactorV3
|
|
from simple_syrup.nodes_v3.simple_load_checkpoint import SimpleLoadCheckpointV3
|
|
from simple_syrup.nodes_v3.tile_and_tag_segs import TileAndTagSEGSV3
|
|
from simple_syrup.nodes_v3.vae_decode_options import VAEDecodeOptionsV3
|
|
from simple_syrup.nodes_v3.vae_encode_options import VAEEncodeOptionsV3
|
|
from simple_syrup.nodes_v3.wd14_tagger_loader import WD14TaggerLoaderV3
|
|
|
|
|
|
class _LegacyNode(Protocol):
|
|
"""Protocol for legacy ComfyUI node declarations."""
|
|
|
|
DESCRIPTION: str
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]:
|
|
"""Return ComfyUI legacy input metadata."""
|
|
|
|
|
|
class _V3Node(Protocol):
|
|
"""Protocol for Comfy v3 node schema declarations."""
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> Any:
|
|
"""Return a Comfy v3 schema object."""
|
|
|
|
|
|
class _FakeFolderPaths(ModuleType):
|
|
"""Small folder_paths fake for deterministic v3 loader schemas."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Create deterministic ComfyUI filename lists."""
|
|
|
|
super().__init__("folder_paths")
|
|
self.models_dir = "<COMFY_ROOT>\\models"
|
|
self.user_directory = "<COMFY_ROOT>\\user"
|
|
self._files = {
|
|
"checkpoints": ["model.safetensors"],
|
|
"vae": ["manual_vae.safetensors"],
|
|
"vae_approx": [],
|
|
}
|
|
|
|
def get_filename_list(self, folder_name: str) -> list[str]:
|
|
"""Return deterministic filenames for a model folder."""
|
|
|
|
return self._files[folder_name]
|
|
|
|
|
|
def test_legacy_nodes_provide_tooltip_metadata() -> None:
|
|
"""All exported legacy nodes expose descriptions and field-level help."""
|
|
|
|
for node_id, raw_node_class in NODE_CLASS_MAPPINGS.items():
|
|
node_class = cast(_LegacyNode, raw_node_class)
|
|
assert node_id in NODE_DISPLAY_NAME_MAPPINGS
|
|
description = getattr(node_class, "DESCRIPTION", None)
|
|
assert isinstance(description, str) and description.strip(), (
|
|
f"{node_id} is missing DESCRIPTION."
|
|
)
|
|
|
|
input_types = node_class.INPUT_TYPES()
|
|
assert isinstance(input_types, Mapping), f"{node_id} INPUT_TYPES is invalid."
|
|
for section_name in ("required", "optional", "hidden"):
|
|
section = input_types.get(section_name, {})
|
|
assert isinstance(section, Mapping), (
|
|
f"{node_id} {section_name} inputs must be a mapping."
|
|
)
|
|
for field_name, declaration in section.items():
|
|
if section_name == "hidden" and _legacy_hidden_sentinel(declaration):
|
|
continue
|
|
assert _tooltip_from_legacy_declaration(declaration), (
|
|
f"{node_id} {section_name}.{field_name} is missing tooltip "
|
|
"metadata."
|
|
)
|
|
|
|
|
|
def test_legacy_named_outputs_provide_tooltips() -> None:
|
|
"""All named legacy outputs provide matching output tooltip metadata."""
|
|
|
|
for node_id, node_class in NODE_CLASS_MAPPINGS.items():
|
|
return_names = getattr(node_class, "RETURN_NAMES", None)
|
|
if return_names is None:
|
|
continue
|
|
|
|
output_tooltips = getattr(node_class, "OUTPUT_TOOLTIPS", None)
|
|
assert isinstance(output_tooltips, tuple), (
|
|
f"{node_id} is missing OUTPUT_TOOLTIPS."
|
|
)
|
|
assert len(output_tooltips) == len(return_names), (
|
|
f"{node_id} OUTPUT_TOOLTIPS must match RETURN_NAMES length."
|
|
)
|
|
for output_name, tooltip in zip(return_names, output_tooltips, strict=True):
|
|
assert isinstance(tooltip, str) and tooltip.strip(), (
|
|
f"{node_id} output.{output_name} is missing tooltip metadata."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"schema_class",
|
|
[
|
|
SimpleLoadCheckpointV3,
|
|
ScaleFactorV3,
|
|
TileAndTagSEGSV3,
|
|
VAEDecodeOptionsV3,
|
|
VAEEncodeOptionsV3,
|
|
WD14TaggerLoaderV3,
|
|
EncodePromptBatchWithPromptControl,
|
|
],
|
|
)
|
|
def test_v3_nodes_provide_tooltip_metadata(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
schema_class: type[_V3Node],
|
|
) -> None:
|
|
"""All supported v3 schemas expose descriptions and field-level help."""
|
|
|
|
monkeypatch.setitem(sys.modules, "folder_paths", _FakeFolderPaths())
|
|
|
|
schema = schema_class.define_schema()
|
|
assert isinstance(schema.description, str) and schema.description.strip(), (
|
|
f"{schema.node_id} v3 schema is missing description."
|
|
)
|
|
for input_item in schema.inputs:
|
|
tooltip = getattr(input_item, "tooltip", None)
|
|
assert isinstance(tooltip, str) and tooltip.strip(), (
|
|
f"{schema.node_id} input.{input_item.id} is missing tooltip metadata."
|
|
)
|
|
for output in schema.outputs:
|
|
tooltip = getattr(output, "tooltip", None)
|
|
assert isinstance(tooltip, str) and tooltip.strip(), (
|
|
f"{schema.node_id} output.{output.id} is missing tooltip metadata."
|
|
)
|
|
|
|
|
|
def test_high_impact_tooltips_explain_direction_and_units() -> None:
|
|
"""Important numeric controls explain units or practical direction."""
|
|
|
|
detail_node = cast(
|
|
_LegacyNode,
|
|
NODE_CLASS_MAPPINGS["SimpleSyrup.DetailSEGSByScaleFactor"],
|
|
)
|
|
detail_inputs = detail_node.INPUT_TYPES()["required"]
|
|
denoise = _tooltip_from_legacy_declaration(detail_inputs["denoise"])
|
|
feather = _tooltip_from_legacy_declaration(detail_inputs["feather"])
|
|
assert "lower" in denoise.lower() and "higher" in denoise.lower()
|
|
assert "pixels" in feather.lower()
|
|
|
|
tiled_node = cast(
|
|
_LegacyNode,
|
|
NODE_CLASS_MAPPINGS["SimpleSyrup.KSamplerTiledDiffusion"],
|
|
)
|
|
tiled_inputs = tiled_node.INPUT_TYPES()["required"]
|
|
overlap = _tooltip_from_legacy_declaration(tiled_inputs["latent_tile_overlap"])
|
|
batch_size = _tooltip_from_legacy_declaration(
|
|
tiled_inputs["latent_tile_batch_size"]
|
|
)
|
|
assert "overlap" in overlap.lower() and "seams" in overlap.lower()
|
|
assert "memory" in batch_size.lower()
|
|
|
|
|
|
def _tooltip_from_legacy_declaration(declaration: object) -> str:
|
|
"""Return a legacy ComfyUI field tooltip or an empty string."""
|
|
|
|
if not isinstance(declaration, tuple) or len(declaration) < 2:
|
|
return ""
|
|
options = declaration[1]
|
|
if not isinstance(options, dict):
|
|
return ""
|
|
tooltip: Any = options.get("tooltip")
|
|
if not isinstance(tooltip, str):
|
|
return ""
|
|
return tooltip.strip()
|
|
|
|
|
|
def _legacy_hidden_sentinel(declaration: object) -> bool:
|
|
"""Return whether a declaration is a Comfy legacy hidden input sentinel."""
|
|
|
|
return isinstance(declaration, str) and declaration in {
|
|
"PROMPT",
|
|
"DYNPROMPT",
|
|
"EXTRA_PNGINFO",
|
|
"UNIQUE_ID",
|
|
"AUTH_TOKEN_COMFY_ORG",
|
|
"API_KEY_COMFY_ORG",
|
|
}
|