A scene picture a video-only pipeline cannot use is named in Generate. CHANGELOG Brownian wording and Veda GPU labels match the code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tw9fjVsBg3ixjnT1Fgdy12
380 lines
16 KiB
Python
380 lines
16 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"))
|
|
|
|
|
|
def test_a_hidden_setting_with_a_bad_value_does_not_stop_the_run():
|
|
from types import SimpleNamespace
|
|
from core import schema
|
|
settings = {"method": {"type": "enum", "default": "plain", "options": [{"value": "plain"}, {"value": "file"}]},
|
|
"file": {"type": "enum", "default": "a.pth", "options": [{"value": "a.pth"}], "when": {"method": "file"}}}
|
|
spec = SimpleNamespace(id="m", settings=settings, defaults=lambda: {k: v["default"] for k, v in settings.items()})
|
|
clean, problems = schema.check_values(spec, {"method": "plain", "file": "other.safetensors"})
|
|
assert problems == [] and clean["file"] == "a.pth"
|
|
clean, problems = schema.check_values(spec, {"method": "file", "file": "other.safetensors"})
|
|
assert problems and "m.file must be one of" in problems[0]
|
|
|
|
|
|
def test_a_removed_enum_option_is_mapped_forward_when_saved_settings_are_read():
|
|
from types import SimpleNamespace
|
|
from core import schema
|
|
s = {"m": {"type": "enum", "default": "a", "options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}], "renamed_options": {"gone": "b"}}}
|
|
spec = SimpleNamespace(id="x", settings=s, defaults=lambda: {"m": "a"})
|
|
assert schema.check_values(spec, {"m": "gone"}) == ({"m": "b"}, [])
|
|
assert schema.check_values(spec, {"m": "other"})[1] # an unknown value is still a problem
|
|
with pytest.raises(schema.SchemaError):
|
|
schema.validate_setting("m", {**s["m"], "renamed_options": {"a": "b"}}, s) # a live option cannot be "removed"
|
|
with pytest.raises(schema.SchemaError):
|
|
schema.validate_setting("m", {**s["m"], "renamed_options": {"gone": "zzz"}}, s)
|
|
|
|
|
|
def test_two_stages_saved_trilinear_loads_as_the_default_method():
|
|
from core import schema
|
|
from modules.sampling import two_stage
|
|
spec = two_stage.SETTINGS["resize"]
|
|
assert "trilinear" not in [o["value"] for o in spec["options"]]
|
|
fake = type("S", (), {"id": "two_stage", "settings": two_stage.SETTINGS, "defaults": lambda self: {k: v["default"] for k, v in two_stage.SETTINGS.items()}})()
|
|
clean, problems = schema.check_values(fake, {"enabled": True, "resize": "trilinear"})
|
|
assert problems == [] and clean["resize"] == "bicubic"
|
|
|
|
|
|
def test_a_file_choice_that_is_gone_from_its_list_is_kept_and_said_not_refused():
|
|
from types import SimpleNamespace
|
|
from core import schema
|
|
s = {"f": {"type": "enum", "default": "", "keep_missing": True, "options": [{"value": "", "label": "None"}], "label": "File"},
|
|
"g": {"type": "enum", "default": "", "options": [{"value": "", "label": "None"}], "label": "Other"}}
|
|
spec = SimpleNamespace(id="x", settings=s, defaults=lambda: {"f": "", "g": ""})
|
|
clean, problems = schema.check_values(spec, {"f": "removed.png"})
|
|
assert problems == [] and clean["f"] == "removed.png"
|
|
assert schema.check_values(spec, {"g": "removed.png"})[1] # only a setting that declares it is spared
|