246 lines
10 KiB
Python
246 lines
10 KiB
Python
"""CPU-only execution-copy planning; no ComfyUI imports or prompt execution."""
|
|
import copy
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from image_list.execution_plan import prepare_image_list_execution
|
|
from image_list.runtime import (
|
|
CONTEXT_OUT,
|
|
END_CLASS,
|
|
IMAGE_OUT,
|
|
START_CLASS,
|
|
build_worker_prompt,
|
|
validate_image_list_regions,
|
|
)
|
|
|
|
|
|
class OutputNode:
|
|
OUTPUT_NODE = True
|
|
|
|
|
|
class SchemaOutputNode:
|
|
OUTPUT_NODE = False # V3's schema, not this fallback, is authoritative.
|
|
|
|
@classmethod
|
|
def GET_NODE_INFO_V1(cls):
|
|
return {"output_node": True, "output": ("IMAGE",)}
|
|
|
|
|
|
class ModelLoader:
|
|
RETURN_TYPES = ("MODEL", "CLIP", "VAE")
|
|
|
|
|
|
@pytest.fixture
|
|
def registry(monkeypatch):
|
|
classes = {
|
|
END_CLASS: SchemaOutputNode,
|
|
"SaveImage": OutputNode,
|
|
"PreviewImage": OutputNode,
|
|
"OutputUtility": OutputNode,
|
|
"SchemaUtility": SchemaOutputNode,
|
|
"ModelLoader": ModelLoader,
|
|
}
|
|
monkeypatch.setitem(sys.modules, "nodes", types.SimpleNamespace(NODE_CLASS_MAPPINGS=classes))
|
|
return classes
|
|
|
|
|
|
def node(class_type, **inputs):
|
|
return {"class_type": class_type, "inputs": inputs}
|
|
|
|
|
|
def graph():
|
|
return {
|
|
"loader": node("ModelLoader"),
|
|
"source": node("ImageSource"),
|
|
"start": node(START_CLASS, images=["source", 0]),
|
|
"process": node("FrameOperation", image=["start", IMAGE_OUT], model=["loader", 0]),
|
|
"end": node(END_CLASS, images=["process", 0], context=["start", CONTEXT_OUT]),
|
|
"save": node("SaveImage", images=["end", 0]),
|
|
}
|
|
|
|
|
|
def test_discards_multistep_previews_but_preserves_other_output_roots(registry):
|
|
prompt = graph()
|
|
prompt.update({
|
|
"debug-transform": node("DebugTransform", images=["start", IMAGE_OUT]),
|
|
"debug-preview": node("PreviewImage", images=["debug-transform", 0]),
|
|
"process-preview": node("PreviewImage", images=["process", 0]),
|
|
"unrelated-source": node("ImageSource"),
|
|
"unrelated-preview": node("PreviewImage", images=["unrelated-source", 0]),
|
|
"upstream-preview": node("PreviewImage", images=["source", 0]),
|
|
"post-transform": node("FrameOperation", images=["end", 0]),
|
|
"post-preview": node("PreviewImage", images=["post-transform", 0]),
|
|
})
|
|
before = copy.deepcopy(prompt)
|
|
with pytest.raises(ValueError, match="cross-boundary"):
|
|
validate_image_list_regions(prompt)
|
|
prepared, targets = prepare_image_list_execution(prompt)
|
|
assert set(prepared) == set(prompt) - {"debug-transform", "debug-preview", "process-preview"}
|
|
assert targets == ["end", "save", "unrelated-preview", "upstream-preview", "post-preview"]
|
|
validate_image_list_regions(prepared, explicit_targets=True)
|
|
assert prompt == before
|
|
|
|
|
|
@pytest.mark.parametrize("class_type", ["OutputUtility", "SchemaUtility"])
|
|
def test_required_output_utility_keeps_complete_dependency_chain(registry, class_type):
|
|
prompt = graph()
|
|
prompt.update({
|
|
"utility-config": node("Constant", value=7),
|
|
"utility-first": node(class_type, images=["process", 0], config=["utility-config", 0]),
|
|
"utility-second": node(class_type, images=["utility-first", 0]),
|
|
"result": node("FrameOperation", image=["utility-second", 0]),
|
|
"discarded-preview": node("PreviewImage", images=["utility-first", 0]),
|
|
})
|
|
prompt["end"]["inputs"]["images"] = ["result", 0]
|
|
before = copy.deepcopy(prompt)
|
|
registered = dict(registry)
|
|
prepared, targets = prepare_image_list_execution(prompt)
|
|
assert set(prepared) == set(prompt) - {"discarded-preview"}
|
|
assert targets == ["end", "save"]
|
|
with pytest.raises(ValueError, match="Output node"):
|
|
validate_image_list_regions(prepared)
|
|
region = validate_image_list_regions(prepared, explicit_targets=True)[0]
|
|
worker = build_worker_prompt(prepared, region, "token", "context", 0, "http://master.invalid")
|
|
assert set(worker) == set(prepared) - {"source", "save"}
|
|
assert worker["utility-second"]["inputs"] == {"images": ["utility-first", 0]}
|
|
assert prompt == before
|
|
assert registry == registered
|
|
assert OutputNode.OUTPUT_NODE is True
|
|
assert SchemaOutputNode.OUTPUT_NODE is False
|
|
assert SchemaOutputNode.GET_NODE_INFO_V1()["output_node"] is True
|
|
|
|
|
|
@pytest.mark.parametrize("source", ["start", "process"])
|
|
@pytest.mark.parametrize("direct", [False, True])
|
|
def test_internal_bypass_into_post_end_computation_is_rejected(registry, source, direct):
|
|
prompt = graph()
|
|
prompt["post-process"] = node("Composite", images=["end", 0])
|
|
if not direct:
|
|
prompt["bypass"] = node("FrameOperation", image=[source, 0])
|
|
source = "bypass"
|
|
prompt["post-process"]["inputs"]["reference"] = [source, 0]
|
|
prompt["save"]["inputs"]["images"] = ["post-process", 0]
|
|
before = copy.deepcopy(prompt)
|
|
with pytest.raises(ValueError, match="cross-boundary"):
|
|
prepare_image_list_execution(prompt)
|
|
assert prompt == before
|
|
|
|
|
|
def test_sequential_namespaced_regions_and_intermediate_output_are_preserved(registry):
|
|
prompt = graph()
|
|
prompt.update({
|
|
"next-start": node(START_CLASS, images=["end", 0]),
|
|
"next-process": node("SchemaUtility", images=["next-start", IMAGE_OUT], model=["loader", 0]),
|
|
"next-end": node(END_CLASS, images=["next-process", 0], context=["next-start", CONTEXT_OUT]),
|
|
"next-save": node("SaveImage", images=["next-end", 0]),
|
|
"first-debug": node("PreviewImage", images=["process", 0]),
|
|
"second-debug": node("PreviewImage", images=["next-process", 0]),
|
|
})
|
|
renamed = {key: f"group:7/{key}" for key in prompt}
|
|
for spec in prompt.values():
|
|
for name, value in spec["inputs"].items():
|
|
if isinstance(value, list):
|
|
spec["inputs"][name] = [renamed[value[0]], value[1]]
|
|
prompt = {renamed[key]: spec for key, spec in prompt.items()}
|
|
prepared, targets = prepare_image_list_execution(prompt)
|
|
assert set(prepared) == set(prompt) - {renamed["first-debug"], renamed["second-debug"]}
|
|
assert targets == [renamed[name] for name in ("end", "save", "next-end", "next-save")]
|
|
regions = validate_image_list_regions(prepared, explicit_targets=True)
|
|
assert {(r["start_id"], r["end_id"]) for r in regions} == {
|
|
(renamed["start"], renamed["end"]), (renamed["next-start"], renamed["next-end"]),
|
|
}
|
|
second = next(r for r in regions if r["start_id"] == renamed["next-start"])
|
|
worker = build_worker_prompt(prepared, second, "token", "context", 0, "http://master.invalid")
|
|
assert set(worker) == {renamed[name] for name in ("loader", "next-start", "next-process", "next-end")}
|
|
|
|
|
|
def test_shared_loader_and_its_independent_output_root_are_preserved(registry):
|
|
registry["ModelLoader"] = type("OutputLoader", (ModelLoader, OutputNode), {})
|
|
prompt = graph()
|
|
prompt["source"]["inputs"]["model"] = ["loader", 0]
|
|
prompt["independent-save"] = node("SaveImage", model=["loader", 0])
|
|
prepared, targets = prepare_image_list_execution(prompt)
|
|
assert prepared == prompt
|
|
assert targets == ["loader", "end", "save", "independent-save"]
|
|
region = validate_image_list_regions(prepared, explicit_targets=True)[0]
|
|
assert "loader" in region["node_ids"]
|
|
assert "loader" not in region["branch_ids"]
|
|
|
|
|
|
@pytest.mark.parametrize("unsafe_class", [None, type("ListLoader", (), {
|
|
"RETURN_TYPES": ("MODEL",), "OUTPUT_IS_LIST": (True,),
|
|
})])
|
|
def test_unknown_or_list_producing_shared_loader_still_fails_closed(registry, unsafe_class):
|
|
if unsafe_class is None:
|
|
registry.pop("ModelLoader")
|
|
else:
|
|
registry["ModelLoader"] = unsafe_class
|
|
prompt = graph()
|
|
prompt["source"]["inputs"]["model"] = ["loader", 0]
|
|
prompt["debug"] = node("PreviewImage", images=["process", 0])
|
|
with pytest.raises(ValueError, match="bypasses Start.*loader"):
|
|
prepare_image_list_execution(prompt)
|
|
|
|
|
|
def test_upstream_per_item_bypass_is_not_hidden_by_preview_pruning(registry):
|
|
prompt = graph()
|
|
prompt["process"]["inputs"]["reference"] = ["source", 0]
|
|
prompt["debug"] = node("PreviewImage", images=["process", 0])
|
|
with pytest.raises(ValueError, match="bypasses Start.*source"):
|
|
prepare_image_list_execution(prompt)
|
|
|
|
|
|
@pytest.mark.parametrize("nested", [False, True])
|
|
def test_overlapping_or_nested_regions_cannot_be_discarded_as_side_branches(registry, nested):
|
|
prompt = graph()
|
|
prompt["other-start"] = node(START_CLASS, images=["process" if nested else "source", 0])
|
|
prompt["other-process"] = node("FrameOperation", image=["other-start", IMAGE_OUT], reference=["process", 0])
|
|
prompt["other-end"] = node(END_CLASS, images=["other-process", 0], context=["other-start", CONTEXT_OUT])
|
|
with pytest.raises(ValueError, match="Nested/ambiguous|cross-boundary|ambiguous processing"):
|
|
prepare_image_list_execution(prompt)
|
|
|
|
|
|
@pytest.mark.parametrize("invalid", ["missing-end", "wrong-context", "missing-images", "unrelated-images"])
|
|
def test_pairing_and_result_validation_cannot_be_pruned_away(registry, invalid):
|
|
prompt = graph()
|
|
if invalid == "missing-end":
|
|
del prompt["end"]
|
|
elif invalid == "wrong-context":
|
|
prompt["end"]["inputs"]["context"] = ["source", 0]
|
|
elif invalid == "missing-images":
|
|
del prompt["end"]["inputs"]["images"]
|
|
else:
|
|
prompt["end"]["inputs"]["images"] = ["source", 0]
|
|
before = copy.deepcopy(prompt)
|
|
with pytest.raises(ValueError):
|
|
prepare_image_list_execution(prompt)
|
|
assert prompt == before
|
|
|
|
|
|
def test_execution_copy_is_deep_and_repeated_preparation_is_stable(registry):
|
|
prompt = graph()
|
|
prompt["process"]["_meta"] = {"title": "Original", "tags": ["saved"]}
|
|
before = copy.deepcopy(prompt)
|
|
prepared, targets = prepare_image_list_execution(prompt)
|
|
prepared_again, targets_again = prepare_image_list_execution(prepared)
|
|
assert prepared_again == prepared
|
|
assert targets_again == targets
|
|
prepared["start"]["inputs"]["images"][0] = "changed"
|
|
prepared["process"]["_meta"]["tags"].append("execution-only")
|
|
assert prompt == before
|
|
assert prepared_again == before
|
|
|
|
|
|
def test_v3_false_output_flag_overrides_legacy_true(registry):
|
|
registry["NotOutput"] = type("NotOutput", (), {
|
|
"OUTPUT_NODE": True,
|
|
"GET_NODE_INFO_V1": classmethod(lambda cls: {"output_node": False}),
|
|
})
|
|
prompt = graph()
|
|
prompt["not-output"] = node("NotOutput", images=["end", 0])
|
|
prepared, targets = prepare_image_list_execution(prompt)
|
|
assert prepared == prompt
|
|
assert targets == ["end", "save"]
|