ComfyUI/tests-unit/comfy_api_test/test_dynamic_slot.py
Jedrzej Kosinski d91c1d8d48 DynamicSlot: typed option dispatch driven by TypeResolver
Replaces the old connected/unconnected fixed-child DynamicSlot with a
type-keyed option list. Each Option declares a 'when' condition (None,
io.AnyType, a single ComfyType, a list, or a MultiType.Input) and the
child inputs revealed when that condition matches the slot's resolved
upstream type.

Selection happens at schema-finalization time using live_input_types
computed by TypeResolver, so API-only workflows (no frontend) get the
same expansion the UI would.

- _io.py: redesign DynamicSlot.Input / Option; auto-derive slotType as
  the union of all non-None when sets; expose it via as_dict so the
  frontend knows what types are accepted; the class io_type stays
  COMFY_DYNAMICSLOT_V3 as the parse-time dispatch tag.
- type_resolver.py: return the auto-derived _slot_io_type for
  DynamicSlot.Input; document the AnyType (*) limitation.
- execution.py: validate links into a DynamicSlot against slotType,
  not the dispatch tag COMFY_DYNAMICSLOT_V3.
- tests: new test_dynamic_slot.py + regression coverage in
  test_type_resolver.py.

Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501
Co-authored-by: Amp <amp@ampcode.com>
2026-06-01 17:56:32 -07:00

267 lines
9.0 KiB
Python

"""Unit tests for the redesigned ``DynamicSlot`` with type-keyed options."""
import pytest
from comfy_api.latest import _io as io
def _opt(when, ids=None):
"""Build an Option whose inputs are placeholder String widgets named after ids."""
ids = ids or []
inputs = [io.String.Input(name) for name in ids]
return io.DynamicSlot.Option(when=when, inputs=inputs)
# ---------------------------------------------------------------------------
# Option.when normalization
# ---------------------------------------------------------------------------
def test_option_when_none():
o = _opt(None, ["a"])
assert o._when_types is None
assert o.as_dict()["when"] is None
def test_option_when_single_type():
o = _opt(io.Image)
assert o._when_types == frozenset({"IMAGE"})
assert o.as_dict()["when"] == ["IMAGE"]
def test_option_when_anytype():
o = _opt(io.AnyType)
assert o._when_types == frozenset({"*"})
assert o.as_dict()["when"] == ["*"]
def test_option_when_list():
o = _opt([io.Image, io.Mask])
assert o._when_types == frozenset({"IMAGE", "MASK"})
# list form sorted for stable serialization
assert o.as_dict()["when"] == ["IMAGE", "MASK"]
def test_option_when_multitype_input():
mt = io.MultiType.Input("x", types=[io.Image, io.Latent])
o = _opt(mt)
assert o._when_types == frozenset({"IMAGE", "LATENT"})
def test_option_when_empty_list_rejected():
with pytest.raises(ValueError, match="when=\\[\\]"):
io.DynamicSlot.Option(when=[], inputs=[])
def test_option_when_garbage_rejected():
with pytest.raises(ValueError, match="when must be"):
io.DynamicSlot.Option(when="IMAGE", inputs=[])
def test_option_when_list_with_non_comfytype_rejected():
with pytest.raises(ValueError, match="list entries"):
io.DynamicSlot.Option(when=[io.Image, "MASK"], inputs=[])
# ---------------------------------------------------------------------------
# DynamicSlot.Input construction and serialization
# ---------------------------------------------------------------------------
def test_input_requires_at_least_one_option():
with pytest.raises(ValueError, match="at least one Option"):
io.DynamicSlot.Input("x", options=[])
def test_input_requires_non_none_option():
with pytest.raises(ValueError, match="non-None `when`"):
io.DynamicSlot.Input("x", options=[_opt(None, ["a"])])
def test_input_auto_derives_slot_type():
inp = io.DynamicSlot.Input("x", options=[
_opt(io.Image, ["a"]),
_opt(io.Mask, ["b"]),
_opt(None, ["c"]),
])
# Declared order preserved across non-None options; None contributes nothing.
# Note: get_io_type() intentionally still returns the dynamic class io_type
# (COMFY_DYNAMICSLOT_V3) so parse_class_inputs dispatches into the expander.
# The auto-derived slot type is exposed via the `slotType` field of as_dict()
# and via the private `_slot_io_type` attribute (used by the type resolver).
assert inp._slot_io_type == "IMAGE,MASK"
d = inp.as_dict()
assert d["slotType"] == "IMAGE,MASK"
assert len(d["options"]) == 3
def test_input_includes_anytype_in_slot_type():
inp = io.DynamicSlot.Input("x", options=[
_opt(io.Image, ["a"]),
_opt(io.AnyType, ["b"]),
])
assert inp._slot_io_type == "IMAGE,*"
def test_input_get_all_dedups_inputs_by_id():
inp = io.DynamicSlot.Input("x", options=[
_opt(io.Image, ["shared", "image_only"]),
_opt(io.Mask, ["shared", "mask_only"]),
])
ids = [i.id for i in inp.get_all()]
assert ids == ["shared", "image_only", "mask_only"]
# ---------------------------------------------------------------------------
# Option selection
# ---------------------------------------------------------------------------
def _select(options, live_input_types, has_link, finalized_id="x"):
"""Convenience wrapper that runs the dispatch through the dict form (post-as_dict)."""
serialized = [o.as_dict() for o in options]
return io.DynamicSlot._select_option(
serialized, live_input_types, finalized_id, has_link
)
def test_select_unconnected_picks_none_option():
options = [_opt(io.Image, ["img_widgets"]), _opt(None, ["empty_widgets"])]
sel = _select(options, {}, has_link=False)
assert sel is not None
assert sel["when"] is None
def test_select_unconnected_with_no_none_option_returns_none():
options = [_opt(io.Image, ["x"])]
assert _select(options, {}, has_link=False) is None
def test_select_concrete_type_match():
options = [
_opt(io.Image, ["a"]),
_opt(io.Mask, ["b"]),
_opt(io.AnyType, ["c"]),
]
sel = _select(options, {"x": "MASK"}, has_link=True)
assert sel["when"] == ["MASK"]
def test_select_anytype_matches_wildcard_resolved():
options = [_opt(io.Image, ["a"]), _opt(io.AnyType, ["c"])]
sel = _select(options, {"x": "*"}, has_link=True)
assert sel["when"] == ["*"]
def test_select_anytype_does_not_match_concrete():
options = [_opt(io.AnyType, ["c"])]
# MASK isn't in any option's set; AnyType only matches "*". No expansion.
assert _select(options, {"x": "MASK"}, has_link=True) is None
def test_select_first_match_wins():
options = [
_opt([io.Image, io.Mask], ["both"]),
_opt(io.Image, ["image_only"]),
]
# Resolved IMAGE matches both; first option wins.
sel = _select(options, {"x": "IMAGE"}, has_link=True)
assert sel["inputs"]
# The "both" option's first input is named "both"
first_input_id = next(iter(sel["inputs"]["required"].keys()))
assert first_input_id == "both"
def test_select_multitype_upstream_intersects_option_set():
"""When upstream declares MultiType like 'IMAGE,MASK', any option that
intersects with that set matches (first wins)."""
options = [
_opt(io.Latent, ["latent_only"]),
_opt(io.Mask, ["mask_only"]),
]
sel = _select(options, {"x": "IMAGE,MASK"}, has_link=True)
assert sel["when"] == ["MASK"]
def test_select_missing_resolved_falls_through_to_anytype():
"""If live_input_types lacks an entry for this slot but a link exists,
we treat it as '*' (resolver default for unresolvable links)."""
options = [_opt(io.Image, ["a"]), _opt(io.AnyType, ["c"])]
sel = _select(options, {}, has_link=True)
assert sel["when"] == ["*"]
# ---------------------------------------------------------------------------
# End-to-end expansion via _expand_schema_for_dynamic
# ---------------------------------------------------------------------------
def test_expand_unconnected_path():
"""An unconnected slot with a `when=None` option expands that option's children."""
inp = io.DynamicSlot.Input("x", options=[
_opt(io.Image, ["image_widget"]),
_opt(None, ["empty_widget"]),
])
d = inp.as_dict()
value = (io.DynamicSlot.io_type, d)
out_dict = {
"required": {}, "optional": {}, "hidden": {},
"dynamic_paths": {}, "dynamic_paths_default_value": {},
}
io.DynamicSlot._expand_schema_for_dynamic(
out_dict=out_dict,
live_inputs={}, # no entry for "x" → unconnected
value=value,
input_type="optional",
curr_prefix=["x"],
live_input_types=None,
)
# The slot itself is always advertised in the caller's bucket.
assert "x" in out_dict["optional"]
# Children land in their own buckets (required by default) with
# parent-prefixed ids.
assert "x.empty_widget" in out_dict["required"]
assert "x.image_widget" not in out_dict["required"]
def test_expand_typed_path():
"""A connected slot expands the matching type's children."""
inp = io.DynamicSlot.Input("x", options=[
_opt(io.Image, ["image_widget"]),
_opt(io.Mask, ["mask_widget"]),
])
d = inp.as_dict()
value = (io.DynamicSlot.io_type, d)
out_dict = {
"required": {}, "optional": {}, "hidden": {},
"dynamic_paths": {}, "dynamic_paths_default_value": {},
}
io.DynamicSlot._expand_schema_for_dynamic(
out_dict=out_dict,
live_inputs={"x": ["src_node", 0]}, # link present
value=value,
input_type="optional",
curr_prefix=["x"],
live_input_types={"x": "MASK"},
)
assert "x" in out_dict["optional"]
assert "x.mask_widget" in out_dict["required"]
assert "x.image_widget" not in out_dict["required"]
def test_expand_unmatched_concrete_still_advertises_slot():
"""Resolved type not in any option → no children, but the slot itself stays."""
inp = io.DynamicSlot.Input("x", options=[_opt(io.Image, ["image_widget"])])
d = inp.as_dict()
value = (io.DynamicSlot.io_type, d)
out_dict = {
"required": {}, "optional": {}, "hidden": {},
"dynamic_paths": {}, "dynamic_paths_default_value": {},
}
io.DynamicSlot._expand_schema_for_dynamic(
out_dict=out_dict,
live_inputs={"x": ["src_node", 0]},
value=value,
input_type="optional",
curr_prefix=["x"],
live_input_types={"x": "LATENT"},
)
assert "x" in out_dict["optional"]
assert "x.image_widget" not in out_dict["required"]