Files
olivv-cs-ComfyUI-FunPack/core/tests/test_widgets.py
T
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
2026-10-25 13:11:03 +00:00

220 lines
8.8 KiB
Python

"""A node's inputs, as a form.
Read against REAL nodes from ComfyUI's registry rather than hand-written
declarations: the shape of INPUT_TYPES is ComfyUI's to define, and a fixture
written from memory is a fixture that agrees with whatever was remembered.
"""
import pytest
from core import widgets
@pytest.fixture(autouse=True)
def _needs_comfy(comfyui):
"""Imports nodes."""
@pytest.fixture(autouse=True)
def _registry():
"""ComfyUI's own nodes, plus FunUI's, and PUT BACK afterwards.
The same two steps the dev server takes, through the same function, because
a pipeline made of FunUI nodes cannot be described by a registry that only
holds ComfyUI's.
Restored because NODE_CLASS_MAPPINGS is a module-level dict shared by the
whole session: installing into it and walking away left every later test
running against a registry this file had changed, and twenty-three of them
failed somewhere else entirely. This project has been bitten by exactly that
before.
"""
import nodes as comfy_nodes
from core import nodes as funui_nodes
before = dict(comfy_nodes.NODE_CLASS_MAPPINGS)
before_names = dict(comfy_nodes.NODE_DISPLAY_NAME_MAPPINGS)
funui_nodes.install_into(comfy_nodes.NODE_CLASS_MAPPINGS,
comfy_nodes.NODE_DISPLAY_NAME_MAPPINGS)
try:
yield
finally:
comfy_nodes.NODE_CLASS_MAPPINGS.clear()
comfy_nodes.NODE_CLASS_MAPPINGS.update(before)
comfy_nodes.NODE_DISPLAY_NAME_MAPPINGS.clear()
comfy_nodes.NODE_DISPLAY_NAME_MAPPINGS.update(before_names)
@pytest.mark.parametrize("node,widget_names,socket_names", [
# A text box is a widget even though STRING is upper case -- deciding by
# case classified every prompt in the pipeline as a socket nobody could
# type in.
("CLIPTextEncode", ["text"], ["clip"]),
("EmptyLatentImage", ["width", "height", "batch_size"], []),
("CheckpointLoaderSimple", ["ckpt_name"], []),
("SaveImage", ["filename_prefix"], ["images"]),
])
def test_what_is_typed_and_what_is_wired(node, widget_names, socket_names):
described = widgets.describe(node)
assert described, f"{node} is not installed"
assert [w["name"] for w in described["widgets"]] == widget_names
assert [s["name"] for s in described["sockets"]] == socket_names
def test_a_number_carries_the_bounds_a_form_needs():
described = widgets.describe("KSampler")
steps = next(w for w in described["widgets"] if w["name"] == "steps")
assert steps["type"] == "INT"
assert steps["default"] == 20
assert steps["min"] >= 1 and steps["max"] > steps["min"]
assert steps["tooltip"], "the node explains itself and the form dropped it"
def test_a_combo_carries_its_choices():
described = widgets.describe("KSampler")
sampler = next(w for w in described["widgets"] if w["name"] == "sampler_name")
assert sampler["type"] == widgets.COMBO
assert "euler" in sampler["choices"]
def test_an_empty_combo_is_still_a_combo():
"""A file picker on a machine with no files. Rendering a select with nothing
in it is right; calling it a socket, or dropping it, is not."""
described = widgets.describe("CheckpointLoaderSimple")
ckpt = next(w for w in described["widgets"] if w["name"] == "ckpt_name")
assert ckpt["type"] == widgets.COMBO
assert isinstance(ckpt["choices"], list)
def test_required_and_optional_are_both_described_and_told_apart():
described = widgets.describe("KSampler")
every = described["widgets"] + described["sockets"]
assert all("required" in item for item in every)
assert any(item["required"] for item in every)
def test_a_node_nobody_installed_is_null_rather_than_an_error():
assert widgets.describe("NoSuchNodeAnywhere") is None
def test_output_names_label_a_multi_output_node_for_a_wiring_picker():
described = widgets.describe("FunUIDiffusionModelLoader")
assert described["outputs"] == ["MODEL", "STRING"]
assert described["output_names"] == ["model", "status"]
def test_a_node_with_no_return_names_falls_back_to_its_types():
"""RETURN_NAMES is optional even on a well-formed node -- ComfyUI's own
frontend labels an unnamed output after its type, and a wiring picker
should read the same way rather than showing a blank."""
described = widgets.describe("KSampler")
assert described["output_names"] == described["outputs"] == ["LATENT"]
def test_asking_about_several_says_which_are_absent():
found = widgets.describe_all(["KSampler", "NoSuchNodeAnywhere", "KSampler"])
assert set(found) == {"KSampler", "NoSuchNodeAnywhere"}
assert found["KSampler"] and found["NoSuchNodeAnywhere"] is None
def test_every_node_in_the_default_pipeline_can_be_described():
"""The pipeline is what the window edits, so a slot nothing can describe is
a slot nobody can fill in."""
from modules.system.pipeline import DEFAULT
for slot in DEFAULT:
described = widgets.describe(slot["node"])
assert described, f"{slot['id']} points at {slot['node']}, which cannot be described"
def test_a_node_whose_declaration_raises_is_absent_not_fatal(monkeypatch):
import nodes as comfy_nodes
class Explodes:
@classmethod
def INPUT_TYPES(cls):
raise RuntimeError("the declaration is upside down")
monkeypatch.setitem(comfy_nodes.NODE_CLASS_MAPPINGS, "Explodes", Explodes)
assert widgets.describe("Explodes") is None
# --- searching for a node to put in a slot ---------------------------------
def test_searching_finds_a_node_by_its_class_name():
found = widgets.search("KSampler")
names = [entry["node"] for entry in found["nodes"]]
assert "KSampler" in names
# Exact first. Typing the whole name and being handed KSamplerAdvanced is
# the picker being unhelpful about the one thing it was told.
assert names[0] == "KSampler"
def test_searching_finds_a_node_by_its_category():
found = widgets.search("latent")
assert found["nodes"], "nothing matched a category every install has"
assert all("latent" in f"{e['node']}{e['title']}{e['category']}".lower()
for e in found["nodes"])
def test_a_search_says_how_many_it_did_not_show():
"""A picker that shows forty of four hundred and says nothing implies forty
is all there is."""
everything = widgets.search("", limit=10)
assert len(everything["nodes"]) == 10
assert everything["total"] > 10
def test_nothing_matching_is_an_empty_answer_rather_than_everything():
found = widgets.search("zzz-no-node-is-called-this")
assert found == {"nodes": [], "total": 0}
def test_a_found_node_carries_what_a_picker_shows():
entry = widgets.search("KSampler")["nodes"][0]
assert set(entry) == {"node", "title", "category", "outputs"}
assert entry["outputs"] == ["LATENT"]
def test_funuis_own_nodes_are_findable_too():
"""The registry is one registry: a picker that could not offer FunUI's own
nodes would make the built-in pipeline unrepairable from the window."""
names = [e["node"] for e in widgets.search("FunUI", limit=200)["nodes"]]
assert "FunUISampler" in names
# --- the two spellings of a combo ------------------------------------------
def test_a_v3_combo_carries_its_choices():
"""ComfyUI spells a combo two ways and both are current.
The old one puts the choices where the type goes -- (["a", "b"], {...}) --
and the V3 schema puts the string "COMBO" there with the choices under
`options`. Reading only the first found no choices on ANY V3 node, and the
window said "no files of this kind were found" over dropdowns that had
seven entries. It was believable, which is what made it bad.
"""
described = widgets.describe("FunUIDiffusionModelLoader")
weights = next(w for w in described["widgets"] if w["name"] == "weight_dtype")
assert "fp16" in weights["choices"]
assert weights["default"] == "default"
def test_an_old_style_combo_still_carries_its_choices():
described = widgets.describe("KSampler")
scheduler = next(w for w in described["widgets"] if w["name"] == "scheduler")
assert "normal" in scheduler["choices"]
def test_every_combo_in_the_default_pipeline_offers_something_or_is_genuinely_empty():
"""A combo with no choices is a real state -- a file picker on a machine
with no files -- so this asserts the ones that cannot be empty are not."""
from modules.system.pipeline import DEFAULT
for slot in DEFAULT:
for widget in widgets.describe(slot["node"])["widgets"]:
if widget["type"] != widgets.COMBO:
continue
assert "choices" in widget, f"{slot['id']}.{widget['name']} has no choices key"
if "name" in widget["name"] or widget["name"].endswith("_name"):
continue # a file picker; may be empty
assert widget["choices"], f"{slot['id']}.{widget['name']} offers nothing"