diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index cdfe359f..173f00c4 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -225,13 +225,14 @@ def autogrow_slot_example(self) -> str: return ", ".join(first) + ", …" @property - def autogrow_element_type(self) -> str | None: - """The socket type every grown slot of this autogrow input carries — - the single input the schema ``template`` declares (``IMAGE`` for - ``model.images``, ``VIDEO`` for ``model.reference_videos``). Every - group in the production catalog declares exactly one template input; - a template with none (or a non-autogrow port) reads as ``None``, which - callers treat as "accept the source type". + def autogrow_template_input_spec(self) -> Any | None: + """The single inner ``INPUT_TYPES`` spec this autogrow group's schema + ``template`` declares — the spec the server copies into every grown + slot (``["IMAGE", {}]`` for ``model.images``). + + ``None`` when there is nothing to read: a non-autogrow port, a template + with no ``input`` block (an older/partial catalog capture), or one whose + sections declare no input carrying a usable socket type. """ t = self.options.template if not self.is_autogrow or not isinstance(t, dict): @@ -249,9 +250,48 @@ def autogrow_element_type(self) -> str | None: for spec in section_def.values(): type_id = spec[0] if isinstance(spec, (list, tuple)) and spec else spec if isinstance(type_id, str) and type_id: - return type_id + return spec return None + @property + def autogrow_element_type(self) -> str | None: + """The socket type every grown slot of this autogrow input carries — + the single input the schema ``template`` declares (``IMAGE`` for + ``model.images``, ``VIDEO`` for ``model.reference_videos``). Every + group in the production catalog declares exactly one template input; + a template with none (or a non-autogrow port) reads as ``None``, which + callers treat as "accept the source type". + """ + spec = self.autogrow_template_input_spec + if spec is None: + return None + return spec[0] if isinstance(spec, (list, tuple)) and spec else spec + + def autogrow_slot_port(self, slot_key: str, *, required: bool) -> Port | None: + """A :class:`Port` for one grown slot of this group, parsed from the + template's inner input — the same input the server copies into the + node's expanded schema under this ``slot_key`` + (``Autogrow._expand_schema_for_dynamic``). + + Slot keys never appear in ``/object_info``'s own input list, so nothing + in ``Morphism.inputs`` types them; validation synthesizes one of these + per present, template-declared key so a slot gets the *same* edge, + shape and catalog checks as an ordinary input. Everything the inner + spec declares comes along — ``enum_values``/``enum_declared`` so a COMBO + template is membership-checked, ``options`` so a ranged ``INT`` template + is range-checked. + + ``None`` when :attr:`autogrow_template_input_spec` reads nothing (a + non-autogrow port, or a template with no inner input): with no declared + inner type there is nothing to check a slot against, and inventing one + would be the reject-what-you-cannot-read mistake the sibling autogrow + accessors all avoid. + """ + spec = self.autogrow_template_input_spec + if spec is None: + return None + return _port_from_spec(slot_key, spec, required) + @property def autogrow_limits(self) -> tuple[int, int | None]: """``(min, max)`` slots for this autogrow input, exactly as the frontend @@ -1781,9 +1821,32 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: # _check_dynamic_combos already covers it. Sub-keys under an # unresolved selection keep the old generic checks. dyn_port_names = {p.name for p in m.inputs if p.is_dynamic_combo} - dyn_errors, dyn_warnings, dyn_valid_keys, dyn_unresolved = _check_dynamic_combos( + dyn_errors, dyn_warnings, dyn_valid_keys, dyn_unresolved, dyn_slot_ports = _check_dynamic_combos( node_id, class_type, m, node_inputs ) + # Autogrow slot keys (`images.image0`, `model.images.image_1`, …) + # are absent from `port_by_name` — object_info declares the GROUP, + # never its grown slots — so the per-input loop below had no type to + # check them against: a link-valued slot skipped the + # `edge_type_mismatch` comparison and a plain value fell out at + # `if port is None: continue`, before any shape or catalog check. + # An INT wired into an IMAGE slot validated clean here and was then + # hard-rejected by the server as `return_type_mismatch`. Synthesize + # one Port per present, template-declared slot key from the group's + # own template input so those keys route through exactly the same + # branches as any other input. + slot_ports: dict[str, Port] = {} + for base, agp in autogrow_ports.items(): + matched = {k for k in node_inputs if k.startswith(f"{base}.")} + # Only the keys the group actually grows: `images.bogus` is not + # an input of the node, so it gets no synthesized diagnostic. + # Where the catalog names no slots there is nothing to filter + # against and the historical prefix match stands. + declared = agp.autogrow_declared_slot_keys(matched) + slot_ports.update(_autogrow_slot_ports(base, agp, matched if declared is None else declared)) + # Nested groups are typed by the dynamic-combo walk, which is the + # only pass that knows which option expanded them. + slot_ports.update(dyn_slot_ports) for input_name, value in node_inputs.items(): if ( "." in input_name @@ -1858,7 +1921,7 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: # (iii) type compatibility — advisory only. # ComfyUI allows cross-type wiring via reroutes, converters, # and wildcard ports; the server is the authoritative validator. - port = port_by_name.get(input_name) + port = port_by_name.get(input_name) or slot_ports.get(input_name) if port is not None: src_type = src_m.outputs[out_idx].type dst_type = port.type @@ -1888,7 +1951,7 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: ) continue - port = port_by_name.get(input_name) + port = port_by_name.get(input_name) or slot_ports.get(input_name) if port is None: continue # Shape check (hard error) @@ -2413,24 +2476,29 @@ def _check_dynamic_combos( Also flags present dotted keys that match no sub-input of the resolved selection — a warning, not an error, because the server ignores extra - keys; that pass lives in ``_unknown_dotted_key_warnings``. Returns ``(errors, warnings, valid_keys, unresolved)`` — the caller + keys; that pass lives in ``_unknown_dotted_key_warnings``. Returns + ``(errors, warnings, valid_keys, unresolved, slot_ports)`` — the caller uses ``valid_keys``/``unresolved`` to exempt stale (server-ignored) dynamic sub-keys from the generic edge checks, which would otherwise - hard-error on e.g. a dangling link left over from a previous selection. + hard-error on e.g. a dangling link left over from a previous selection, + and ``slot_ports`` to type the autogrow slot keys a resolved option + expanded — this walk is the only pass that knows which option expanded + them, so the driver loop cannot derive them for itself. """ errors: list[dict] = [] warnings: list[dict] = [] present = node_inputs dynamic_ports = [p for p in m.inputs if p.is_dynamic_combo] if not dynamic_ports: - return errors, warnings, set(), set() + return errors, warnings, set(), set(), {} valid_keys: set[str] = set() unresolved: set[str] = set() resolved: dict[str, Any] = {} + slot_ports: dict[str, Port] = {} for port in dynamic_ports: e, w, v, u = _check_dynamic_combo_input( - node_id, class_type, port.name, port.raw_spec, port.required, present, resolved + node_id, class_type, port.name, port.raw_spec, port.required, present, resolved, slot_ports=slot_ports ) errors.extend(e) warnings.extend(w) @@ -2438,7 +2506,7 @@ def _check_dynamic_combos( unresolved |= u warnings.extend(_unknown_dotted_key_warnings(node_id, present, dynamic_ports, valid_keys, unresolved, resolved)) - return errors, warnings, valid_keys, unresolved + return errors, warnings, valid_keys, unresolved, slot_ports def _unknown_dotted_key_warnings( @@ -2527,6 +2595,7 @@ def _check_dynamic_combo_input( present: dict, resolved: dict[str, Any], depth: int = 0, + slot_ports: dict[str, Port] | None = None, ) -> tuple[list[dict], list[dict], set[str], set[str]]: """One dynamic-combo input: resolve its selected option, check its sub-inputs. @@ -2537,6 +2606,13 @@ def _check_dynamic_combo_input( (mutated) records ``name -> selected key`` for every combo level that DID resolve, so the caller can attribute a stray key to the deepest resolved prefix. + + ``slot_ports`` is the second mutated accumulator, filled the same way and + for the same reason: an autogrow group nested in a resolved option grows + slot keys the top-level driver has no way to type on its own, so this walk + records a synthesized :class:`Port` per present slot key + (:func:`_autogrow_slot_ports`) for the driver's edge/shape/catalog checks. + Optional so the direct callers in the tests need not supply one. """ errors: list[dict] = [] warnings: list[dict] = [] @@ -2666,7 +2742,7 @@ def _check_dynamic_combo_input( dotted = f"{name}.{sub_name}" valid_keys.add(dotted) e, w, v, u = _check_dynamic_combo_sub( - node_id, class_type, dotted, sub_spec, sub_required, present, resolved, depth + node_id, class_type, dotted, sub_spec, sub_required, present, resolved, depth, slot_ports ) errors.extend(e) warnings.extend(w) @@ -2684,19 +2760,24 @@ def _check_dynamic_combo_sub( present: dict, resolved: dict[str, Any], depth: int, + slot_ports: dict[str, Port] | None = None, ) -> tuple[list[dict], list[dict], set[str], set[str]]: """Presence + shape + catalog checks for one expanded sub-input. The sub-input spec is a plain ``INPUT_TYPES`` entry, so it goes through the same ``_parse_input_spec`` / :class:`Port` machinery as a top-level input and inherits identical shape and enum/range semantics. + + ``slot_ports`` is the mutated accumulator described on + :func:`_check_dynamic_combo_input`: an autogrow sub-input records one + synthesized :class:`Port` per slot key it grows into it. """ port = _port_from_spec(dotted, sub_spec, sub_required) if port.is_dynamic_combo: # Nested dynamic combo: its own selector/presence rules apply one level down. return _check_dynamic_combo_input( - node_id, class_type, dotted, sub_spec, sub_required, present, resolved, depth + 1 + node_id, class_type, dotted, sub_spec, sub_required, present, resolved, depth + 1, slot_ports ) if port.is_autogrow: @@ -2761,6 +2842,12 @@ def _check_dynamic_combo_sub( # against, so the historical prefix match stands. declared = port.autogrow_declared_slot_keys(matched) valid = matched if declared is None else declared + # Type each of those keys off the group's template input, so the driver + # loop edge/shape/catalog-checks a nested slot exactly as it does a + # top-level one. Only the valid keys: an undeclared `model.images.bogus` + # stays an `unknown_input` warning and gets no synthesized diagnostic. + if slot_ports is not None: + slot_ports.update(_autogrow_slot_ports(dotted, port, valid)) # The valid keys stay valid even when the count is short, so the slots # that ARE wired don't regress into `unknown_input` noise on top of the # count error. These errors flow into `dyn_errors`, which the driver @@ -2917,6 +3004,38 @@ def _autogrow_slot_errors(node_id: str, field: str, port: Port, slots: set[str], return [shortfall] if shortfall else [] +def _autogrow_slot_ports(field: str, port: Port, slots: Iterable[str]) -> dict[str, Port]: + """One synthesized :class:`Port` per present slot key of one autogrow group + — shared by the top-level driver loop and the dynamic-combo-nested path so a + slot is typed the same way at every nesting depth. + + ``slots`` must already be the keys the group actually grows + (:meth:`Port.autogrow_declared_slot_keys`, or the historical prefix match + where the catalog names none): a key the template never declares is not an + input of the node at all, so synthesizing a Port for it would invent a + diagnostic about something the server ignores. + + ``required`` per slot is the server's own answer — the specific names it + places in the expanded schema's ``required`` section + (:meth:`Port.autogrow_required_slot_names` over + :attr:`Port.autogrow_effective_min`), not merely the first ``min`` keys the + prompt happens to wire. It stays ``False`` where the catalog names no slots + for the same reason the count check falls back there: nothing declared them. + + Empty for a group whose template declares no inner input — there is no type + to check a slot against, so it keeps the pre-existing "no port, no + diagnostic" behaviour rather than guessing one. + """ + names = port.autogrow_required_slot_names(port.autogrow_effective_min) + required_keys = set() if names is None else {f"{field}.{name}" for name in names} + out: dict[str, Port] = {} + for key in slots: + slot_port = port.autogrow_slot_port(key, required=key in required_keys) + if slot_port is not None: + out[key] = slot_port + return out + + def _check_autogrow_required(node_id: str, autogrow_ports: dict[str, Port], node_data: dict) -> list[dict]: """Autogrow inputs wired with fewer slots than the server requires. diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index cbc76040..36fca4b4 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -4623,3 +4623,305 @@ def test_no_output_node_at_all_does_not_double_report(self, graph: Graph): assert any(e["code"] == "prompt_no_outputs" for e in result["errors"]) warns = [w for w in result["warnings"] if w["code"] == "node_not_reachable_from_output"] assert warns == [], "prompt_no_outputs already says it; don't pile on" + + +class TestAutogrowSlotPorts: + """Autogrow slot keys (``images.image0``, ``model.images.image_1``, …) are + type-, shape- and catalog-checked exactly as ordinary inputs are. + + ``/object_info`` declares the GROUP and never its grown slots, so a slot key + never entered ``port_by_name``: a link-valued one got ``dangling_edge`` / + ``output_index_out_of_range`` but skipped the ``edge_type_mismatch`` + comparison, and a plain-valued one fell out at ``if port is None: continue`` + before any shape or catalog check. An ``INT`` wired into an ``IMAGE`` slot + validated clean here and was then hard-rejected by the server as + ``return_type_mismatch`` (``execution.validate_inputs`` → + ``validate_node_input`` on the expanded ``template_input``). Validation now + synthesizes one :class:`Port` per present, template-declared slot key from + the group's own template input. + + ``Grow`` mirrors the live ``BatchImagesNode.images`` + (``prefix: image``, ``min: 1``, ``max: 50``, inner ``IMAGE``). + """ + + INFO = { + "IntSrc": { + "input": {"required": {}}, + "input_order": {"required": []}, + "output": ["INT"], + "output_name": ["value"], + "display_name": "IntSrc", + "python_module": "nodes", + }, + "ImgSrc": { + "input": {"required": {}}, + "input_order": {"required": []}, + "output": ["IMAGE"], + "output_name": ["image"], + "display_name": "ImgSrc", + "python_module": "nodes", + }, + "Grow": { + "input": { + "required": { + "images": [ + "COMFY_AUTOGROW_V3", + { + "template": { + "input": {"required": {"image": ["IMAGE", {}]}}, + "prefix": "image", + "min": 1, + "max": 50, + } + }, + ] + } + }, + "input_order": {"required": ["images"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "display_name": "Grow", + "python_module": "nodes", + }, + # Template inner type COMBO: the slot value is a catalog choice, so the + # synthesized port must inherit `enum_values`/`enum_declared`. + "GrowCombo": { + "input": { + "required": { + "opts": [ + "COMFY_AUTOGROW_V3", + { + "template": { + "input": {"required": {"choice": ["COMBO", {"options": ["a", "b"]}]}}, + "prefix": "opt", + "min": 0, + } + }, + ] + } + }, + "input_order": {"required": ["opts"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "display_name": "GrowCombo", + "python_module": "nodes", + }, + # Template inner type INT with a declared range: the synthesized port + # must inherit `options` so the range check applies to the slot. + "GrowInt": { + "input": { + "required": { + "counts": [ + "COMFY_AUTOGROW_V3", + { + "template": { + "input": {"required": {"count": ["INT", {"min": 1, "max": 10}]}}, + "prefix": "n", + "min": 0, + } + }, + ] + } + }, + "input_order": {"required": ["counts"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "display_name": "GrowInt", + "python_module": "nodes", + }, + # A template carrying no inner input at all: nothing declares the slot's + # type, so no port is synthesized and the historical "no port, no + # diagnostic" behaviour stands. + "GrowEmpty": { + "input": { + "required": { + "blanks": [ + "COMFY_AUTOGROW_V3", + {"template": {"input": {}, "prefix": "blank", "min": 0}}, + ] + } + }, + "input_order": {"required": ["blanks"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "display_name": "GrowEmpty", + "python_module": "nodes", + }, + "Sink": { + "input": {"required": {"image": ["IMAGE", {}]}}, + "input_order": {"required": ["image"]}, + "output": [], + "output_name": [], + "output_node": True, + "display_name": "Sink", + "python_module": "nodes", + }, + } + + def _graph(self) -> Graph: + return Graph.from_object_info(self.INFO) + + def _wf(self, class_type: str, inputs: dict) -> dict: + """`class_type` fed by an INT producer (`"0"`) and an IMAGE producer + (`"1"`), consumed by an output node so the node is output-reachable (the + server prunes anything else, and the range checks are gated on that). + Producers `inputs` doesn't reference are dropped, so an unused one + doesn't add `node_not_reachable_from_output` noise to the assertions.""" + used = {str(v[0]) for v in inputs.values() if isinstance(v, list) and len(v) == 2} + producers = { + "0": {"class_type": "IntSrc", "inputs": {}}, + "1": {"class_type": "ImgSrc", "inputs": {}}, + } + return { + **{k: v for k, v in producers.items() if k in used}, + "2": {"class_type": class_type, "inputs": inputs}, + "3": {"class_type": "Sink", "inputs": {"image": ["2", 0]}}, + } + + # -- edges ------------------------------------------------------------ + + def test_int_source_into_an_image_slot_is_a_type_mismatch(self): + """The reported defect, top level: an INT output wired into + ``Grow.images.image0`` (template inner type ``IMAGE``) used to validate + clean and be hard-rejected by the server.""" + result = self._graph().validate_workflow(self._wf("Grow", {"images.image0": ["0", 0]})) + warns = [w for w in result["warnings"] if w["code"] == "edge_type_mismatch"] + assert len(warns) == 1 + assert warns[0]["node_id"] == "2" + assert warns[0]["field"] == "images.image0" + assert "expects IMAGE but IntSrc[0] produces INT" in warns[0]["message"] + # Advisory, as every other edge check is — slot keys inherit the + # existing severities rather than introducing a new one. + assert result["errors"] == [] + assert result["valid"] is True + + def test_correctly_wired_slot_stays_clean(self): + result = self._graph().validate_workflow(self._wf("Grow", {"images.image0": ["1", 0]})) + assert result["valid"] is True, result["errors"] + assert result["warnings"] == [] + + def test_undeclared_slot_key_gets_no_synthesized_diagnostic(self): + """``images.bogus`` is not a name the template grows, so the server + never places it in the node's schema — no port is synthesized for it and + it draws no edge diagnostic (nor a crash). The top level has no + unknown-input check, so it stays silent there; the nested path keeps + reporting ``unknown_input`` (see ``test_nested_autogrow.py``).""" + result = self._graph().validate_workflow( + self._wf("Grow", {"images.image0": ["1", 0], "images.bogus": ["0", 0]}) + ) + assert result["errors"] == [] + assert result["warnings"] == [] + + def test_dangling_slot_edge_keeps_its_existing_error(self): + """Regression pin: the checks a slot key ALREADY got must not change.""" + result = self._graph().validate_workflow(self._wf("Grow", {"images.image0": ["99", 0]})) + err = next(e for e in result["errors"] if e["code"] == "dangling_edge") + assert err["field"] == "images.image0" + + # -- shape / catalog -------------------------------------------------- + + def test_combo_template_slot_value_is_membership_checked(self): + """A COMBO template makes each slot a catalog choice; the synthesized + port inherits the option list, so an unknown value is the same + ``unknown_enum_value`` error any declared COMBO input gets.""" + result = self._graph().validate_workflow(self._wf("GrowCombo", {"opts.opt0": "zzz"})) + errs = [e for e in result["errors"] if e["code"] == "unknown_enum_value"] + assert len(errs) == 1 + assert errs[0]["node_id"] == "2" + assert errs[0]["field"] == "opts.opt0" + + def test_combo_template_slot_value_in_the_enum_is_clean(self): + result = self._graph().validate_workflow(self._wf("GrowCombo", {"opts.opt0": "a"})) + assert result["valid"] is True, result["errors"] + assert result["warnings"] == [] + + def test_ranged_int_template_slot_value_is_range_checked(self): + """A ranged INT template makes each slot range-checked: ``min: 1`` with + a slot value of ``0`` is the same ``below_min`` error a declared INT + input gets.""" + result = self._graph().validate_workflow(self._wf("GrowInt", {"counts.n0": 0})) + errs = [e for e in result["errors"] if e["code"] == "below_min"] + assert len(errs) == 1 + assert errs[0]["field"] == "counts.n0" + + def test_int_template_slot_shape_is_checked(self): + result = self._graph().validate_workflow(self._wf("GrowInt", {"counts.n0": "five"})) + errs = [e for e in result["errors"] if e["code"] == "shape_mismatch"] + assert len(errs) == 1 + assert errs[0]["field"] == "counts.n0" + + def test_template_with_no_inner_input_synthesizes_nothing(self): + """Nothing declares the slot's type, so there is nothing to check it + against — no port, no diagnostic, no crash.""" + result = self._graph().validate_workflow( + self._wf("GrowEmpty", {"blanks.blank0": ["0", 0], "blanks.blank1": "anything"}) + ) + assert result["errors"] == [] + assert result["warnings"] == [] + + # -- the synthesized ports themselves --------------------------------- + + def test_autogrow_slot_port_reads_the_template_input(self): + graph = self._graph() + grow = graph.node("Grow") + assert grow is not None + port = next(p for p in grow.inputs if p.name == "images") + slot = port.autogrow_slot_port("images.image0", required=True) + assert slot is not None + assert (slot.name, slot.type, slot.required) == ("images.image0", "IMAGE", True) + + def test_autogrow_slot_port_is_none_without_a_template_input(self): + graph = self._graph() + empty = graph.node("GrowEmpty") + sink = graph.node("Sink") + assert empty is not None and sink is not None + blanks = next(p for p in empty.inputs if p.name == "blanks") + assert blanks.autogrow_slot_port("blanks.blank0", required=False) is None + # A non-autogrow port has no template to read at all. + image = next(p for p in sink.inputs if p.name == "image") + assert image.autogrow_slot_port("image.x", required=False) is None + + def test_slot_ports_mark_the_servers_required_slot_names_required(self): + """``required`` per slot is the server's own answer — the names it + places in the expanded schema's ``required`` section (``names[:min]``), + not merely the first ``min`` keys the prompt happens to wire.""" + from comfy_cli.cql.engine import _autogrow_slot_ports + + graph = self._graph() + grow = graph.node("Grow") + assert grow is not None + port = next(p for p in grow.inputs if p.name == "images") + ports = _autogrow_slot_ports("images", port, ["images.image0", "images.image1"]) + # `min: 1` with the inner input in the template's own `required` + # section, so only slot 0 is required. + assert ports["images.image0"].required is True + assert ports["images.image1"].required is False + + def test_slot_ports_inherit_the_template_enum_and_options(self): + from comfy_cli.cql.engine import _autogrow_slot_ports + + graph = self._graph() + combo, ranged = graph.node("GrowCombo"), graph.node("GrowInt") + assert combo is not None and ranged is not None + opts = next(p for p in combo.inputs if p.name == "opts") + slot = _autogrow_slot_ports("opts", opts, ["opts.opt0"])["opts.opt0"] + assert slot.type == "COMBO" + assert slot.enum_values == ["a", "b"] + assert slot.enum_declared is True + counts = next(p for p in ranged.inputs if p.name == "counts") + int_slot = _autogrow_slot_ports("counts", counts, ["counts.n0"])["counts.n0"] + assert (int_slot.options.min, int_slot.options.max) == (1, 10) + + def test_slot_ports_skip_keys_the_template_never_declares(self): + """The filter is the caller's (``autogrow_declared_slot_keys``), but the + helper must not invent a port for a key it is handed either — it types + exactly the keys it is given, and the driver only ever hands it declared + ones.""" + from comfy_cli.cql.engine import _autogrow_slot_ports + + graph = self._graph() + grow = graph.node("Grow") + assert grow is not None + port = next(p for p in grow.inputs if p.name == "images") + assert port.autogrow_declared_slot_keys(["images.image0", "images.bogus"]) == {"images.image0"} + assert set(_autogrow_slot_ports("images", port, ["images.image0"])) == {"images.image0"} diff --git a/tests/comfy_cli/cql/test_nested_autogrow.py b/tests/comfy_cli/cql/test_nested_autogrow.py index d432c2ab..16e58755 100644 --- a/tests/comfy_cli/cql/test_nested_autogrow.py +++ b/tests/comfy_cli/cql/test_nested_autogrow.py @@ -251,3 +251,114 @@ def test_production_min_zero_group_stays_lenient(graph): assert port.autogrow_template_required is True assert port.autogrow_limits[0] == 0 assert port.autogrow_effective_min == 0 + + +# --------------------------------------------------------------------------- # +# (E) slot keys are type/shape/catalog-checked like any other input +# --------------------------------------------------------------------------- # + + +def _nano_banana_inputs(option_index: int = 0) -> dict: + """Every widget ``GeminiNanoBanana2V2`` requires for one model option, at + its schema defaults — everything except the ``images`` autogrow slots.""" + info = json.loads(FIXTURE.read_text()) + node = info["GeminiNanoBanana2V2"]["input"]["required"] + option = node["model"][1]["options"][option_index] + + def default_of(spec): + opts = spec[1] if isinstance(spec, list) and len(spec) > 1 and isinstance(spec[1], dict) else {} + if "default" in opts: + return opts["default"] + return (opts.get("options") or [""])[0] + + inputs = {"model": option["key"]} + inputs.update({k: default_of(v) for k, v in node.items() if k != "model"}) + inputs.update( + {f"model.{k}": default_of(v) for k, v in option["inputs"].get("required", {}).items() if k != "images"} + ) + return inputs + + +def _nano_banana_workflow(extra: dict) -> dict: + """``GeminiNanoBanana2V2`` fed by a ``LoadImage``, terminating in a + ``SaveImage``. ``LoadImage`` is the mis-wire source these tests need: + output ``[0]`` is ``IMAGE`` and output ``[1]`` is ``MASK``, so the same node + supplies both the correct and the wrongly-typed edge.""" + return { + "9": {"class_type": "LoadImage", "inputs": {"image": "example.png"}}, + "1": {"class_type": "GeminiNanoBanana2V2", "inputs": {**_nano_banana_inputs(), **extra}}, + "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0], "filename_prefix": "out"}}, + } + + +def _batch_images_workflow(extra: dict) -> dict: + """The ticket's top-level artifact: the production ``BatchImagesNode``, + whose ``images`` group declares ``prefix: image``, ``min: 1``, ``max: 50`` + and an inner ``IMAGE`` input.""" + return { + "9": {"class_type": "LoadImage", "inputs": {"image": "example.png"}}, + "1": {"class_type": "BatchImagesNode", "inputs": {**extra}}, + "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0], "filename_prefix": "out"}}, + } + + +def test_top_level_slot_key_edge_is_type_checked(graph): + """The defect: ``BatchImagesNode.images.image0`` never entered + ``port_by_name`` (object_info declares the GROUP, never its grown slots), so + a link into it skipped the ``edge_type_mismatch`` comparison entirely and a + wrongly-typed edge validated clean — while the server hard-rejects it as + ``return_type_mismatch``. ``LoadImage[1]`` is ``MASK``; the slot's template + input is ``IMAGE``.""" + result = graph.validate_workflow(_batch_images_workflow({"images.image0": ["9", 1]})) + warns = [w for w in result["warnings"] if w["code"] == "edge_type_mismatch"] + assert len(warns) == 1 + assert warns[0]["node_id"] == "1" + assert warns[0]["field"] == "images.image0" + assert "expects IMAGE" in warns[0]["message"] + # Advisory, as every other edge check is until BE-10311 promotes them. + assert result["errors"] == [] + + +def test_top_level_correctly_wired_slot_stays_clean(graph): + result = graph.validate_workflow(_batch_images_workflow({"images.image0": ["9", 0]})) + assert result["valid"] is True, result["errors"] + assert result["warnings"] == [] + + +def test_top_level_undeclared_slot_key_gets_no_synthesized_diagnostic(graph): + """``images.bogus`` is not a name the template grows, so the server does not + place it in the node's schema at all — no Port is synthesized for it and it + draws no edge-type diagnostic (and, at the top level, no unknown-input one + either: that check does not exist there).""" + result = graph.validate_workflow(_batch_images_workflow({"images.image0": ["9", 0], "images.bogus": ["9", 1]})) + assert [w["code"] for w in result["warnings"]] == [] + assert result["errors"] == [] + + +def test_nested_slot_key_edge_is_type_checked(graph): + """The same defect one level down, on the fixture's ``GeminiNanoBanana2V2``: + ``model.images.image_1``'s template input is ``IMAGE``, and the nested walk + accepted every slot key wholesale without ever typing it.""" + result = graph.validate_workflow(_nano_banana_workflow({"model.images.image_1": ["9", 1]})) + warns = [w for w in result["warnings"] if w["code"] == "edge_type_mismatch"] + assert len(warns) == 1 + assert warns[0]["node_id"] == "1" + assert warns[0]["field"] == "model.images.image_1" + assert "expects IMAGE but LoadImage[1] produces MASK" in warns[0]["message"] + assert result["errors"] == [] + + +def test_nested_correctly_wired_slot_stays_clean(graph): + result = graph.validate_workflow(_nano_banana_workflow({"model.images.image_1": ["9", 0]})) + assert result["valid"] is True, result["errors"] + assert result["warnings"] == [] + + +def test_nested_undeclared_slot_key_is_still_unknown_input(graph): + """Post-#842 behaviour preserved: a key the template never declares stays + out of the synthesized ports AND out of the valid-key set, so it keeps + reporting ``unknown_input`` rather than gaining an edge diagnostic about a + slot the server ignores.""" + result = graph.validate_workflow(_nano_banana_workflow({"model.images.bogus": ["9", 1]})) + assert [(w["code"], w["field"]) for w in result["warnings"]] == [("unknown_input", "model.images.bogus")] + assert result["errors"] == []