Files
olivv-cs-ComfyUI-FunPack/core/tests/test_schema.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

334 lines
14 KiB
Python

"""Validation refuses rather than repairs.
Every case here is a module that would otherwise load and then behave in a way
nobody declared -- a panel rendering the wrong control, a headless run with an
invented default, a row that can never appear.
"""
import pytest
from core.contract import CONTRACT_VERSION, ModuleSpec
from core.schema import SchemaError, validate
def announce(**overrides):
base = {
"id": "audio_clock", "title": "Audio clock", "mount": "generation.timing",
"settings": {"enabled": {"type": "bool", "default": True, "label": "On"}},
}
base.update(overrides)
return base
def test_a_valid_declaration_becomes_a_spec():
spec = validate(announce())
assert isinstance(spec, ModuleSpec)
assert spec.id == "audio_clock"
assert spec.stage == "sampling"
assert spec.status == "experimental"
def test_defaults_come_from_the_same_declaration_the_panel_renders():
# This is the whole point of one declaration doing three jobs: a headless
# run and a rendered panel cannot disagree about a starting value.
spec = validate(announce(settings={
"enabled": {"type": "bool", "default": True, "label": "On"},
"strength": {"type": "float", "default": 0.65, "min": 0, "max": 1, "label": "Strength"},
}))
assert spec.defaults() == {"enabled": True, "strength": 0.65}
@pytest.mark.parametrize("missing", ["id", "title", "mount"])
def test_the_essentials_are_required(missing):
raw = announce()
del raw[missing]
with pytest.raises(SchemaError, match=missing):
validate(raw)
def test_a_blank_id_is_not_an_id():
with pytest.raises(SchemaError):
validate(announce(id=" "))
# --- types and renderers ---------------------------------------------------
def test_unknown_type_is_refused():
with pytest.raises(SchemaError, match="unknown type"):
validate(announce(settings={"x": {"type": "colour", "default": "#fff", "label": "C"}}))
def test_unknown_ui_hint_is_refused():
# Accepting it would render something other than what was asked for.
with pytest.raises(SchemaError, match="not available for a bool"):
validate(announce(settings={"x": {"type": "bool", "default": True, "label": "X", "ui": "slider"}}))
def test_a_valid_ui_hint_passes():
spec = validate(announce(settings={"x": {"type": "bool", "default": True, "label": "X", "ui": "toggle"}}))
assert spec.settings["x"]["ui"] == "toggle"
def test_enum_may_ask_for_the_wheel():
# How a module gets a radial picker without writing any JavaScript.
spec = validate(announce(settings={"x": {
"type": "enum", "default": "a", "label": "X", "ui": "wheel",
"options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}],
}}))
assert spec.settings["x"]["ui"] == "wheel"
# --- defaults --------------------------------------------------------------
def test_a_setting_without_a_default_is_refused():
with pytest.raises(SchemaError, match="no default"):
validate(announce(settings={"x": {"type": "float", "min": 0, "max": 1, "label": "X"}}))
@pytest.mark.parametrize("spec", [
{"type": "bool", "default": "yes", "label": "X"},
{"type": "int", "default": 1.5, "label": "X"},
{"type": "float", "default": "0.5", "label": "X"},
{"type": "text", "default": 3, "label": "X"},
{"type": "bool", "default": 1, "label": "X"},
])
def test_a_default_of_the_wrong_type_is_refused(spec):
with pytest.raises(SchemaError):
validate(announce(settings={"x": spec}))
def test_a_default_outside_its_own_bounds_is_refused():
with pytest.raises(SchemaError, match="below its min"):
validate(announce(settings={"x": {"type": "float", "default": -1, "min": 0, "max": 1, "label": "X"}}))
with pytest.raises(SchemaError, match="above its max"):
validate(announce(settings={"x": {"type": "float", "default": 9, "min": 0, "max": 1, "label": "X"}}))
def test_inverted_bounds_are_refused():
with pytest.raises(SchemaError, match="above max"):
validate(announce(settings={"x": {"type": "int", "default": 0, "min": 5, "max": 1, "label": "X"}}))
def test_a_non_positive_step_is_refused():
with pytest.raises(SchemaError, match="step"):
validate(announce(settings={"x": {"type": "float", "default": 0.5, "step": 0, "label": "X"}}))
# --- content is text -------------------------------------------------------
@pytest.mark.parametrize("key", ["label", "hint", "unit"])
def test_content_must_be_text(key):
# The UI renders these as text and will not interpret anything else.
with pytest.raises(SchemaError, match=key):
validate(announce(settings={"x": {"type": "bool", "default": True, key: {"markup": "<b>"}}}))
def test_option_labels_must_be_text_and_values_scalar():
with pytest.raises(SchemaError, match="label"):
validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X",
"options": [{"value": "a", "label": ["a"]}]}}))
with pytest.raises(SchemaError, match="scalar"):
validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X",
"options": [{"value": {"a": 1}, "label": "A"}]}}))
# --- enums -----------------------------------------------------------------
def test_an_enum_needs_options():
with pytest.raises(SchemaError, match="no options"):
validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X", "options": []}}))
def test_an_enum_default_must_be_one_of_its_options():
with pytest.raises(SchemaError, match="not one of its options"):
validate(announce(settings={"x": {"type": "enum", "default": "z", "label": "X",
"options": [{"value": "a", "label": "A"}]}}))
def test_duplicate_option_values_are_refused():
with pytest.raises(SchemaError, match="duplicate"):
validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X",
"options": [{"value": "a", "label": "A"}, {"value": "a", "label": "B"}]}}))
# --- conditional visibility ------------------------------------------------
def test_when_may_only_name_siblings():
# A condition on a key that does not exist is always false, so the row would
# simply never appear -- a typo that reads as a deliberate decision.
with pytest.raises(SchemaError, match="does not declare"):
validate(announce(settings={
"a": {"type": "bool", "default": True, "label": "A"},
"b": {"type": "bool", "default": True, "label": "B", "when": {"nope": True}},
}))
def test_when_may_not_name_itself():
with pytest.raises(SchemaError, match="itself"):
validate(announce(settings={"a": {"type": "bool", "default": True, "label": "A", "when": {"a": True}}}))
def test_a_valid_when_survives_into_the_spec():
spec = validate(announce(settings={
"enabled": {"type": "bool", "default": True, "label": "On"},
"strength": {"type": "float", "default": 0.5, "label": "S", "when": {"enabled": True}},
}))
assert spec.settings["strength"]["when"] == {"enabled": True}
# --- the rest of the announcement ------------------------------------------
def test_unknown_stage_is_refused():
with pytest.raises(SchemaError, match="unknown stage"):
validate(announce(stage="whenever"))
def test_unknown_status_is_refused():
with pytest.raises(SchemaError, match="unknown status"):
validate(announce(status="probably-fine"))
def test_category_is_optional_and_defaults_to_uncategorised():
spec = validate(announce())
assert spec.category == ""
def test_unknown_category_is_refused():
with pytest.raises(SchemaError, match="unknown category"):
validate(announce(category="vibes"))
def test_a_known_category_is_kept():
spec = validate(announce(category="guidance"))
assert spec.category == "guidance"
def test_relations_must_be_lists_of_ids():
with pytest.raises(SchemaError, match="after"):
validate(announce(after="audio_clock")) # a bare string is a common slip
with pytest.raises(SchemaError, match="requires"):
validate(announce(requires=[1, 2]))
def test_the_manifest_carries_what_the_ui_needs():
spec = validate(announce(requires=["audio_stream"], after=["x"], stage="latent"))
manifest = spec.to_manifest()
assert manifest["id"] == "audio_clock"
assert manifest["requires"] == ["audio_stream"]
assert manifest["stage"] == "latent"
assert manifest["category"] == ""
assert "source" not in manifest, "the import path is core's business, not the browser's"
def test_the_contract_has_a_version():
assert isinstance(CONTRACT_VERSION, int)
# --- the door nobody was watching ------------------------------------------
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
def test_a_modules_own_default_cannot_be_non_finite(bad):
"""The override path refused NaN while a module's own default sailed
through -- and a default is the value MOST runs use, because a setting
nobody names never reaches the override check at all. `sigma > nan` is
False at every step, so it becomes a permanent silent no-op."""
with pytest.raises(SchemaError, match="finite"):
validate({"id": "m", "title": "M", "mount": "x", "settings": {
"strength": {"type": "float", "default": bad, "min": 0.0, "max": 1.0,
"label": "S"}}})
@pytest.mark.parametrize("bound", ["min", "max"])
def test_a_non_finite_bound_is_refused_too(bound):
"""An infinite bound makes the range check meaningless in one direction."""
settings = {"strength": {"type": "float", "default": 0.5, "label": "S",
bound: float("inf")}}
with pytest.raises(SchemaError, match="finite"):
validate({"id": "m", "title": "M", "mount": "x", "settings": settings})
def test_an_ordinary_default_still_passes():
spec = validate({"id": "m", "title": "M", "mount": "x", "settings": {
"strength": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "label": "S"}}})
assert spec.defaults() == {"strength": 0.5}
def test_the_manifest_names_a_modules_nodes_by_id(comfyui):
from modules.conditioning import shot_camera
from core import routes
found = next(m for m in routes.manifest()["modules"] if m["id"] == shot_camera.ID)
assert found["nodes"] == ["FunUIShotCamera"]
def test_a_node_whose_schema_fails_does_not_take_the_module_list_down():
class Bad:
@classmethod
def define_schema(cls):
raise RuntimeError("schema boom")
@classmethod
def execute(cls):
pass
@classmethod
def GET_SCHEMA(cls):
return cls.define_schema()
assert validate(announce(nodes=[Bad])).to_manifest()["nodes"] == ["Bad"]
def test_uses_is_a_list_of_capability_names():
assert validate(announce(uses=["taste_store"])).to_manifest()["uses"] == ["taste_store"]
with pytest.raises(SchemaError, match="uses"):
validate(announce(uses="taste_store"))
def test_the_taste_key_serves_the_store_and_every_module_reading_it_says_so(comfyui):
import re
from pathlib import Path
from core import routes
found = {m["id"]: m for m in routes.manifest()["modules"]}
assert found["taste"]["serves"] == ["modifier", "taste_store"]
root = Path(__file__).resolve().parents[2] / "modules"
readers = {p.parent.name for p in root.rglob("*.py") if "/tests/" not in str(p)
and re.search(r'ask\("taste_store"', p.read_text())}
declared = {spec.source.split(".")[-1] for spec in routes.modules().specs.values() if "taste_store" in spec.uses}
assert readers and readers <= declared, readers - declared
def test_a_module_that_skips_the_store_in_some_mode_says_when_it_asks(comfyui):
"""`ask("taste_store")` behind `None if manual else` means the Taste key is idle in that mode: declared, so it can hide."""
import re
from core import routes
for spec in routes.modules().specs.values():
if "taste_store" not in spec.uses:
continue
import importlib
src = importlib.import_module(spec.source).__file__
skips = bool(re.search(r'None if manual else [^\n]*ask\("taste_store"', open(src).read()))
assert bool(spec.uses_when) == skips, (spec.id, spec.uses_when, skips)
def test_uses_when_must_name_a_declared_setting():
with pytest.raises(SchemaError, match="uses_when"):
validate(announce(uses=["taste_store"], uses_when={"nope": "x"}))
assert validate(announce(uses=["taste_store"], uses_when={"enabled": True})).to_manifest()["uses_when"] == {"enabled": True}
def test_the_manifest_names_where_settings_live(comfyui):
from core import routes
assert {"node": "FunUIModifierSettings", "input": "settings"} in routes.manifest()["sinks"]
def test_a_condition_on_a_choice_must_name_one_of_its_options():
mode = {"type": "enum", "default": "manual", "label": "Mode",
"options": [{"value": "manual", "label": "Manual"}, {"value": "learned", "label": "Learned"}]}
with pytest.raises(SchemaError, match="'learn' for 'mode' is not one of its options"):
validate(announce(settings={"mode": mode}, uses=["taste_store"], uses_when={"mode": "learn"}))
assert validate(announce(settings={"mode": mode}, uses=["taste_store"], uses_when={"mode": ["learned"]}))
def test_roles_a_module_acts_through_reach_the_manifest():
assert validate(announce(roles=["assets.source_image"])).to_manifest()["roles"] == ["assets.source_image"]
with pytest.raises(SchemaError, match="roles"):
validate(announce(roles="assets.source_image"))