Files
JasonHoku-ComfyUI-Ultimate-…/tests/test_workflow_sweep_expand.py
T
Jason HokuandClaude Fable 5.1 e5312812d9 feat(workflow-sweep): template parsing, binding expansion and prompt substitution
Pure-Python core of Workflow Sweep: parse an API-format workflow, suggest bindings,
expand them into jobs (cache-friendly axis order, resume fingerprints), substitute
into the template and append the collector node. Builder state emits kind=workflow
arrays, config_utils.expand_configs dispatches model_type=workflow, and
manifest_utils.append_items adds disk-fresh manifest appends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 00:08:16 -08:00

243 lines
11 KiB
Python

"""Workflow Sweep: binding expansion (product, pairing, seeds, LoRA slots, resolution, ordering)."""
import json
import os
import sys
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import workflow_sweep as ws # noqa: E402
from config_utils import expand_configs # noqa: E402
def load(name):
with open(os.path.join(ROOT, "tests", "fixtures", name), "r", encoding="utf-8") as f:
return json.load(f)
def moss_entry(bindings, **extra):
e = {"model_type": "workflow", "name": "Config 1", "workflow": load("image_moss_image_t2i.json"),
"workflow_source": "image_moss_image_t2i.json", "workflow_output": {"node_id": "38", "output_index": 0},
"workflow_run_other_outputs": False, "workflow_bindings": bindings}
e.update(extra)
return e
def sdxl_entry(bindings, **extra):
e = {"model_type": "workflow", "name": "SDXL", "workflow": load("sdxl_default_api.json"),
"workflow_output": {"node_id": "8", "output_index": 0}, "workflow_bindings": bindings}
e.update(extra)
return e
def lit(bid, nid, inp, values, **kw):
return {"id": bid, "kind": "literal", "targets": [{"node_id": nid, "input": inp}], "values": values, **kw}
def prompt_binding(kind, nid, inp):
return {"id": kind, "kind": kind, "targets": [{"node_id": nid, "input": inp}]}
def test_product_counts_and_substitution():
entry = moss_entry([lit("b1", "32", "cfg", [2.5, 4, 6]), lit("b2", "32", "seed", [1, 2])])
jobs = ws.expand_workflow_entry(entry)
assert len(jobs) == 6
assert {(j["cfg"], j["seed"]) for j in jobs} == {(c, s) for c in (2.5, 4, 6) for s in (1, 2)}
# cfg 4 is coerced to the template's float type, seed stays int
assert all(isinstance(j["cfg"], float) and isinstance(j["seed"], int) for j in jobs)
assert len({j["wf_fingerprint"] for j in jobs}) == 6
def test_known_fields_filled_from_template_when_unbound():
j = ws.expand_workflow_entry(moss_entry([lit("b1", "32", "cfg", [3])]))[0]
assert j["sampler"] == "er_sde" and j["scheduler"] == "simple" and j["steps"] == 25
assert j["vae"] == "flux2-vae.safetensors" and j["model_type"] == "workflow" and j["media_type"] == "image"
assert j["wf_output"] == "38:0" and j["lora"] == "None" and j["lora_expanded"] == "None"
assert j["wf_params"] == {"KSampler.cfg": 3.0}
def test_empty_axis_is_an_error():
with pytest.raises(ValueError, match="no values"):
ws.expand_workflow_entry(moss_entry([lit("b1", "32", "cfg", [])]))
def test_no_bindings_yields_single_job():
assert len(ws.expand_workflow_entry(moss_entry([]))) == 1
def test_coercion_rules():
assert ws.coerce_like(7.0, 7) == 7 and isinstance(ws.coerce_like(7.0, 7), int)
assert ws.coerce_like("7", 1) == 7
assert ws.coerce_like(4, 2.5) == 4.0 and isinstance(ws.coerce_like(4, 2.5), float)
assert ws.coerce_like("false", True) is False and ws.coerce_like("true", False) is True
assert ws.coerce_like(5, "euler") == "5"
assert ws.coerce_like("x", ["1", 0]) == "x" # template was a link: untouched
def test_unbound_value_type_stable_in_fingerprint():
a = ws.expand_workflow_entry(moss_entry([lit("b1", "32", "steps", [20])]))[0]["wf_fingerprint"]
b = ws.expand_workflow_entry(moss_entry([lit("b1", "32", "steps", [20.0])]))[0]["wf_fingerprint"]
assert a == b
def test_prompt_pairing_rule():
both = [prompt_binding("positive", "26", "value"), prompt_binding("negative", "37", "text")]
# 2 positive x 1 negative -> product
assert len(ws.expand_workflow_entry(moss_entry(both, positive=["a", "b"], negative="x"))) == 2
# equal lengths > 1 -> 1-to-1 pairing
jobs = ws.expand_workflow_entry(moss_entry(both, positive=["a", "b"], negative=["x", "y"]))
assert sorted((j["positive"], j["negative"]) for j in jobs) == [("a", "x"), ("b", "y")]
# unequal -> product
assert len(ws.expand_workflow_entry(moss_entry(both, positive=["a", "b", "c"], negative=["x", "y"]))) == 6
def test_prompt_groups_cartesian_like_standard_configs():
entry = moss_entry([prompt_binding("positive", "26", "value")], positive=[["a", "b"], ["1", "2"]])
assert sorted(j["positive"] for j in ws.expand_workflow_entry(entry)) == ["a, 1", "a, 2", "b, 1", "b, 2"]
def test_prompt_binding_without_prompts_errors_with_fallback_option():
entry = moss_entry([prompt_binding("positive", "26", "value")])
with pytest.raises(ValueError, match="no positive prompts"):
ws.expand_workflow_entry(entry)
jobs = ws.expand_workflow_entry(entry, None, ["fallback prompt"], [""])
assert jobs[0]["positive"] == "fallback prompt"
def test_unbound_prompts_come_from_template():
j = ws.expand_workflow_entry(moss_entry([lit("b1", "32", "cfg", [3])]))[0]
assert j["positive"].startswith("A bimbo girl") # text of template node 26
assert j["negative"] == "worst quality, bad anatomy, watermark, jpeg artifact"
def test_extra_seeds_appended_to_seed_axis():
jobs = ws.expand_workflow_entry(moss_entry([lit("b1", "32", "seed", [1, 2])]), extra_seeds=[777, 1])
assert sorted(j["seed"] for j in jobs) == [1, 2, 777] # duplicate seed 1 dropped
def test_extra_seeds_without_seed_axis_warn_and_name_inputs():
plan = ws.plan_workflow_entry(moss_entry([lit("b1", "32", "cfg", [3, 4])]), extra_seeds=[5])
assert len(plan["jobs"]) == 2
assert any("KSampler.seed" in w for w in plan["warnings"])
def test_lora_slot_mapping_and_none():
b = {"id": "l", "kind": "lora", "slots": [{"node_id": "10"}], "values": ["x.safetensors:0.8", "None"]}
plan = ws.plan_workflow_entry(sdxl_entry([b]))
by_lora = {j["lora"]: j for j in plan["jobs"]}
assert set(by_lora) == {"x.safetensors:0.8", "None"}
subs = {(n, i): v for n, i, v in by_lora["x.safetensors:0.8"]["wf_subs"]}
assert subs[("10", "lora_name")] == "x.safetensors"
assert subs[("10", "strength_model")] == 0.8 and subs[("10", "strength_clip")] == 0.8
none_subs = {(n, i): v for n, i, v in by_lora["None"]["wf_subs"]}
assert none_subs[("10", "strength_model")] == 0.0 and none_subs[("10", "strength_clip")] == 0.0
assert ("10", "lora_name") not in none_subs # name left as in the template
def test_lora_weight_arrays_expand():
b = {"id": "l", "kind": "lora", "slots": [{"node_id": "10"}], "values": ["x.safetensors:[0.5, 1.0]:1.0"]}
jobs = ws.expand_workflow_entry(sdxl_entry([b]))
assert sorted(j["lora"] for j in jobs) == ["x.safetensors:0.5:1.0", "x.safetensors:1.0:1.0"]
def test_lora_too_many_parts_errors_and_second_slot_zeroed():
wf = load("sdxl_default_api.json")
wf["11"] = {"inputs": {"lora_name": "n.safetensors", "strength_model": 1.0, "strength_clip": 1.0,
"model": ["10", 0], "clip": ["10", 1]}, "class_type": "LoraLoader",
"_meta": {"title": "Load LoRA"}}
wf["3"]["inputs"]["model"] = ["11", 0]
wf["6"]["inputs"]["clip"] = ["11", 1]
wf["7"]["inputs"]["clip"] = ["11", 1]
slots = [{"node_id": "10"}, {"node_id": "11"}]
ok = ws.expand_workflow_entry(sdxl_entry([{"id": "l", "kind": "lora", "slots": slots,
"values": ["a.safetensors:1:1"]}], workflow=wf))
s = {(n, i): v for n, i, v in ok[0]["wf_subs"]}
assert s[("10", "lora_name")] == "a.safetensors" and s[("11", "strength_model")] == 0.0
with pytest.raises(ValueError, match="stacks 2 LoRAs but only 1"):
ws.expand_workflow_entry(sdxl_entry([{"id": "l", "kind": "lora", "slots": [{"node_id": "10"}],
"values": ["a.safetensors + b.safetensors"]}]))
def test_resolution_split():
b = {"id": "r", "kind": "resolution", "width": {"node_id": "5", "input": "width"},
"height": {"node_id": "5", "input": "height"}, "values": ["1024x1024", "832x1216"]}
jobs = ws.expand_workflow_entry(sdxl_entry([b]))
assert sorted((j["width"], j["height"]) for j in jobs) == [(832, 1216), (1024, 1024)]
with pytest.raises(ValueError, match="WxH"):
ws.expand_workflow_entry(sdxl_entry([dict(b, values=["big"])]))
def test_linked_input_needs_override():
with pytest.raises(ValueError, match="override link"):
ws.expand_workflow_entry(moss_entry([lit("b", "38", "samples", ["x"])]))
replaced = ws.expand_workflow_entry(moss_entry([lit("b", "38", "samples", ["x"], override_link=True)]))
assert replaced[0]["wf_subs"] == [["38", "samples", "x"]]
assert len(ws.expand_workflow_entry(moss_entry([lit("b", "31", "clip_name", ["a", "b"])]))) == 2
def test_two_bindings_writing_same_input_error():
with pytest.raises(ValueError, match="both write"):
ws.expand_workflow_entry(moss_entry([lit("a", "32", "cfg", [1]), lit("b", "32", "cfg", [2])]))
def test_shared_targets_get_same_value():
b = {"id": "s", "kind": "literal", "field": "seed",
"targets": [{"node_id": "32", "input": "seed"}, {"node_id": "28", "input": "sampling_mode.seed"}],
"values": [9]}
j = ws.expand_workflow_entry(moss_entry([b]))[0]
assert {(n, i) for n, i, _ in j["wf_subs"]} == {("32", "seed"), ("28", "sampling_mode.seed")}
def test_binding_off_the_output_path_warns():
wf = load("sdxl_default_api.json")
wf["20"] = {"inputs": {"text": "orphan", "clip": ["4", 1]}, "class_type": "CLIPTextEncode",
"_meta": {"title": "orphan"}}
plan = ws.plan_workflow_entry(sdxl_entry([lit("b", "20", "text", ["a", "b"])], workflow=wf))
assert any("no effect" in w for w in plan["warnings"])
def test_cleared_field_still_substitutes_but_feeds_no_axis_field():
j = ws.expand_workflow_entry(moss_entry([lit("b", "32", "cfg", [9], field=None)]))[0]
assert j["wf_params"] == {"KSampler.cfg": 9.0}
assert j["wf_subs"] == [["32", "cfg", 9.0]]
def test_axis_order_loaders_outermost_and_adjacent():
bindings = [lit("s", "3", "cfg", [1, 2]),
lit("m", "4", "ckpt_name", ["a.safetensors", "b.safetensors"]),
{"id": "r", "kind": "resolution", "width": {"node_id": "5", "input": "width"},
"height": {"node_id": "5", "input": "height"}, "values": ["512x512", "768x768"]}]
plan = ws.plan_workflow_entry(sdxl_entry(bindings))
labels = [a["label"] for a in plan["axes"]]
assert labels[0] == "Load Checkpoint.ckpt_name"
assert labels[-1] == "KSampler.cfg"
models = [j["model"] for j in plan["jobs"]]
assert len(plan["jobs"]) == 8
# loader values adjacent: exactly one change a -> b across all 8 jobs
assert sum(1 for x, y in zip(models, models[1:]) if x != y) == 1
def test_job_cap():
with pytest.raises(ValueError, match="more than"):
ws.expand_workflow_entry(moss_entry([lit("b", "32", "seed", list(range(300))),
lit("d", "29", "unet_name", [f"m{i}" for i in range(600)])]))
def test_expand_configs_branch_matches_direct_expansion():
entry = moss_entry([lit("b1", "32", "cfg", [2.5, 4])])
out = expand_configs([entry], [""], [""], [1.0], 0, [], None)
assert [j["wf_fingerprint"] for j in out] == [j["wf_fingerprint"] for j in ws.expand_workflow_entry(entry)]
def test_mixed_standard_and_workflow_rejected_by_planner():
with pytest.raises(ValueError, match="cannot be mixed"):
ws.plan_workflow_configs([moss_entry([]), {"model_type": "checkpoint"}])
def test_plan_workflow_configs_templates_and_order():
plan = ws.plan_workflow_configs([moss_entry([lit("b", "32", "cfg", [1, 2])]), sdxl_entry([])])
assert len(plan["jobs"]) == 3 and len(plan["templates"]) == 2
assert plan["jobs"][2]["wf_template_hash"] in plan["templates"]