Files
JasonHoku-ComfyUI-Ultimate-…/tests/test_workflow_sweep_mixed.py
T
Jason HokuandClaude Fable 5.1 725cb79fd4 feat(sweep): mixed standard + Workflow Sweep runs; SaveWEBM/AnimatedWEBP/AnimatedPNG frame sources (Roadmap 32)
- one Generator run may hold standard configs AND workflow-sweep entries: the sweep is validated
  first (job 1 + busy check, nothing registered), the standard loop runs, then start_sweep launches
  into the same session with one global job numbering (Start At Job counts standard jobs first).
  An interrupt in the loop never starts the sweep; meta.configs_json keeps the full Builder list
  (_standard_run_meta/_completion_payload helpers; next_phase on the completion event; the
  dashboard keeps the ETA bar up with "Workflow sweep next…" and clears the 30 s hide timer on
  any later progress event)
- frame sources: FRAME_SINKS table (VHS_VideoCombine, SaveWEBM, SaveAnimatedWEBP, SaveAnimatedPNG)
  feeding the CreateVideo splice; fps read per sink, clamped to CreateVideo's 1-120 with a plan
  warning; find_output_nodes fallback names; Builder label via framesFps
- tests: 33 python (mixed routing, Start At Job translation, interrupt, pre-flight busy/invalid,
  post-loop busy, three sink fixtures, clamping, VHS golden unchanged), 2 node, 2 Playwright

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

172 lines
7.0 KiB
Python

"""Mixed runs: standard configs + Workflow Sweep entries in ONE Generator run (standard loop first, then the sweep)."""
import json
import os
import sys
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "tests"))
from test_workflow_sweep_generator import ( # noqa: E402,F401
PKG, _FakeBridge, runner, sampler_node, wf_config)
STD = {"model_type": "checkpoint", "model": "x"}
class _BadBridge(_FakeBridge):
async def validate(self, prompt, partial):
return False, {"type": "prompt_outputs_failed_validation", "message": "nope"}, {}
@pytest.fixture
def env(sampler_node, tmp_path, monkeypatch):
import folder_paths
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(tmp_path), raising=False)
monkeypatch.setattr(folder_paths, "get_filename_list", lambda t: ["a.safetensors"], raising=False)
monkeypatch.setattr(runner, "TICK", 0.05)
monkeypatch.setattr(sampler_node, "get_html_template", lambda *a, **k: "<html>")
monkeypatch.setattr(sys.modules["comfy"], "model_management", sys.modules["comfy.model_management"], raising=False)
runner.reset_registry()
bridge = _FakeBridge()
runner.set_bridge(bridge)
calls = []
state = {"total": 3, "hook": None, "raise": None}
go = sys.modules[f"{PKG}.generation_orchestrator"]
def fake_loop(self, *args, **kwargs):
calls.append({"args": args, "kwargs": kwargs, "active": runner._active_sweep()})
kwargs["run_info"]["total_jobs"] = state["total"]
if state["hook"]:
state["hook"]()
if state["raise"]:
raise state["raise"]
return ("<loop>",)
monkeypatch.setattr(go, "run_generation_loop", fake_loop, raising=False)
yield {"calls": calls, "state": state, "bridge": bridge, "mod": sampler_node}
sw = runner._active_sweep()
if sw is not None:
runner.cancel(sw.id)
if sw.thread:
sw.thread.join(5)
runner.set_bridge(None)
runner.reset_registry()
def cfg(configs, **settings):
s = {"session_name": "mixed_s", "start_at_job": 0}
s.update(settings)
return json.dumps({"configs": configs, "_session_settings": s})
def run(env, configs, **settings):
return env["mod"].SamplerGridTester().run_tests(cfg(configs, **settings), unique_id="9")
def test_split_configs_keeps_order_dict_or_list(sampler_node):
a, w1, b, w2 = dict(STD, name="a"), wf_config(), dict(STD, name="b"), wf_config()
std, wf = sampler_node._split_configs([a, w1, b, w2])
assert std == [a, b] and wf == [w1, w2]
assert sampler_node._split_configs({"configs": [w1, a]}) == ([a], [w1])
assert sampler_node._split_configs(None) == ([], [])
def test_mixed_run_loop_first_then_sweep_registered(env):
out = run(env, [STD, wf_config()])
assert out == ("<loop>",)
assert len(env["calls"]) == 1
call = env["calls"][0]
assert call["active"] is None # nothing registered while the standard loop runs
kw = call["kwargs"]
assert json.loads(call["args"][8]) == [STD] # configs_json = standard entries only
full = json.loads(kw["meta_configs_json"])
assert [c["model_type"] for c in full] == ["checkpoint", "workflow"]
assert kw["next_phase"] == "sweep"
sw = runner._active_sweep()
assert sw is not None and sw.session == "mixed_s" and sw.origin_node == "9" and len(sw.jobs) == 2
def test_mixed_run_keeps_builder_order_in_meta(env):
run(env, [wf_config(), STD])
full = json.loads(env["calls"][0]["kwargs"]["meta_configs_json"])
assert [c["model_type"] for c in full] == ["workflow", "checkpoint"]
@pytest.mark.parametrize("sa,expected", [(0, 0), (2, 0), (3, 0), (4, 1), (6, 3)])
def test_start_at_job_is_global_standard_first(env, sa, expected):
run(env, [STD, wf_config()], start_at_job=sa)
(sw,) = list(runner._sweeps.values())
assert sw.settings["start_at_job"] == expected
def test_start_at_one_past_standard_skips_nothing(env):
run(env, [STD, wf_config()], start_at_job=4)
sw = runner._active_sweep()
assert not [j for j in sw.jobs if j["status"] == "skipped"]
def test_overwrite_and_random_seeds_reach_the_sweep(env):
run(env, [STD, wf_config()], overwrite_existing=True, add_random_seeds_to_gens=1)
sw = runner._active_sweep()
assert sw.settings["overwrite_existing"] is True
assert len(sw.jobs) == 3 # 2 seeds + 1 derived extra seed
def test_interrupt_during_standard_phase_registers_no_sweep(env):
from comfy.model_management import InterruptProcessingException
env["state"]["raise"] = InterruptProcessingException()
with pytest.raises(InterruptProcessingException):
run(env, [STD, wf_config()])
assert runner._active_sweep() is None and not runner._sweeps
def test_preflight_busy_refuses_before_any_standard_job(env):
other = wf_config()
other["workflow_bindings"][0]["values"] = [5, 6]
runner.start_sweep([other], {"session_name": "mixed_s"}, origin_node="1")
with pytest.raises(RuntimeError, match="NOT started"):
run(env, [STD, wf_config()])
assert env["calls"] == []
def test_preflight_validation_failure_refuses_before_any_standard_job(env):
runner.set_bridge(_BadBridge())
with pytest.raises(ValueError, match="nothing ran"):
run(env, [STD, wf_config()])
assert env["calls"] == []
def test_sweep_busy_after_the_loop_says_standard_jobs_were_saved(env):
other = wf_config()
other["workflow_bindings"][0]["values"] = [5, 6]
env["state"]["hook"] = lambda: runner.start_sweep([other], {"session_name": "mixed_s"}, origin_node="1")
with pytest.raises(RuntimeError, match="Standard jobs finished"):
run(env, [STD, wf_config()])
def test_preflight_sweep_registers_nothing(env):
info = runner.preflight_sweep([wf_config()], {"session_name": "pf"}, origin_node="1")
assert info["total"] == 2 and info["to_run"] == 2
assert not runner._sweeps and env["bridge"].enqueued == []
def test_busy_conflict_ignores_an_identical_sweep(env):
sw = runner.start_sweep([wf_config()], {"session_name": "bc"}, origin_node="1")
fps = [j["fingerprint"] for j in sw.jobs]
assert runner.busy_conflict("bc", fps) is None
assert runner.busy_conflict("bc", ["other"]) is sw
assert runner.busy_conflict("elsewhere", fps) is sw
def test_pure_run_helpers():
go = sys.modules[f"{PKG}.generation_orchestrator"]
meta = go._standard_run_meta({"meta": {"rejected_preview_fps": ["a"]}}, "p", "n", "m", 1, 2, "FULL", "[[1,1]]",
"webp", "as_written", False, 8, 0.5, None, {"x": 1})
assert meta["configs_json"] == "FULL" and meta["rejected_preview_fps"] == ["a"] and meta["x"] == 1
assert meta["preview_pass"] == {"enabled": False, "steps": 8, "scale": 0.5, "cfg_override": None}
done = go._completion_payload("1", "s", 3, 1.234, "0h 0m 5s", 3)
assert done["complete"] is True and "next_phase" not in done and done["avg_duration"] == 1.2
assert go._completion_payload("1", "s", 3, 1.0, "x", 3, "sweep")["next_phase"] == "sweep"