Files
olivv-cs-ComfyUI-FunPack/movie_editor/tests/test_builder.py
T
digital-garbageandClaude Opus 5 39533dfde4 anchor_shift: remove the shift, keep only the head cut
The two-pass shift regrew the frames it dropped, and the invented ending
consistently came out with worse or missing movement — not worth paying
steps for. What is left is the part that works: generate the scene exactly
as normal, anchor pinned at full strength the whole way, then cut the
opening off the finished clip. Nothing is regrown, so the scene comes out
that much shorter and the audio is cropped to match.

Six knobs collapse into one INT, cut_opening_frames (0 = off), in the
Editor under Chain Sampler ▸ Timing & Seed rather than Experimental.
Gone with the shift: the mid-schedule split, the rewind re-noise
(_renoise_to_sigma), the tail refill modes, the fresh-audio reset, and the
schedule_context/continue_from_state plumbing that existed only so a split
scene would not re-time the sampler inside each half.

second_pass is untouched — it never used any of that machinery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:36:13 +03:00

413 lines
23 KiB
Python

"""Tests for the code graph builder (fixed core + slot wiring + auto-wire)."""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from movie_editor.backend import builder # noqa: E402
OI = {
"FunPackStudio": {
"input": {"required": {"rating": [["a", "b"], {"default": "a"}],
"studio_settings": ["STRING", {"default": "{}"}],
"adjustments": ["STRING", {"default": "[]"}]},
"optional": {"model": ["MODEL"], "clip": ["CLIP"], "source_image": ["IMAGE"],
"latent": ["LATENT"],
"positive_prompt": ["STRING", {"forceInput": True}],
"negative_prompt": ["STRING", {"forceInput": True}],
"refinement_key_input": ["STRING", {"forceInput": True}]}},
"output": ["MODEL", "CONDITIONING", "CONDITIONING", "INT", "SAMPLER", "SIGMAS",
"SAMPLER", "SIGMAS", "IMAGE", "STRING", "STRING", "STRING", "LATENT"],
"output_name": ["model", "modified_positive", "negative", "seed", "high_pass_sampler",
"high_pass_sigmas", "low_pass_sampler", "low_pass_sigmas", "loss_graph",
"status", "training_info", "encoded_prompts", "video_latent"]},
"LTXVConditioning": {"input": {"required": {"positive": ["CONDITIONING"], "negative": ["CONDITIONING"],
"frame_rate": ["FLOAT", {"default": 25}]}},
"output": ["CONDITIONING", "CONDITIONING"], "output_name": ["positive", "negative"]},
"FunPackLTXAVSceneChainSampler": {
"input": {"required": {"model": ["MODEL"], "vae": ["VAE"], "positive": ["CONDITIONING"],
"negative": ["CONDITIONING"], "sampler": ["SAMPLER"], "sigmas": ["SIGMAS"],
"seed": ["INT", {"default": 1}], "latent_template": ["LATENT"],
"num_frames_per_scene": ["INT", {"default": 97}]}},
"output": ["LATENT", "IMAGE"], "output_name": ["latent", "images"]},
"LTXVConcatAVLatent": {"input": {"required": {"video_latent": ["LATENT"], "audio_latent": ["LATENT"]}},
"output": ["LATENT"]},
"LTXVSeparateAVLatent": {"input": {"required": {"av_latent": ["LATENT"]}},
"output": ["LATENT", "LATENT"], "output_name": ["video_latent", "audio_latent"]},
"LTXVAudioVAEDecode": {"input": {"required": {"samples": ["LATENT"], "audio_vae": ["VAE"]}}, "output": ["AUDIO"]},
"NormalizeAudioLoudness": {"input": {"required": {"audio": ["AUDIO"], "target": ["INT", {"default": -30}]}},
"output": ["AUDIO"]},
"VHS_VideoCombine": {"input": {"required": {"images": ["IMAGE"], "frame_rate": ["FLOAT", {"default": 30}]},
"optional": {"audio": ["AUDIO"]}}, "output": ["VHS_FILENAMES"]},
"FunPackSaveRefinementLatent": {"input": {"required": {"latent": ["LATENT"], "refinement_key": ["STRING"]}},
"output": ["LATENT", "STRING"]},
"FunPackRefinementKeyLoader": {"input": {"required": {"key": ["STRING", {"default": "k"}]}},
"output": ["STRING", "STRING"], "output_name": ["refinement_key", "status"]},
"PrimitiveStringMultiline": {"input": {"required": {"value": ["STRING", {"default": ""}]}}, "output": ["STRING"]},
"PrimitiveInt": {"input": {"required": {"value": ["INT", {"default": 0}]}}, "output": ["INT"]},
"PrimitiveFloat": {"input": {"required": {"value": ["FLOAT", {"default": 0.0}]}}, "output": ["FLOAT"]},
"LTXFloatToInt": {"input": {"required": {"a": ["FLOAT", {"default": 0}]}}, "output": ["INT"]},
# slot nodes
"UnetLoader": {"input": {"required": {"unet_name": [["m.safetensors"]]}}, "output": ["MODEL"]},
"ClipLoader": {"input": {"required": {"clip_name": [["c.safetensors"]]}}, "output": ["CLIP"]},
"VaeLoader": {"input": {"required": {"vae_name": [["v.safetensors"]]}}, "output": ["VAE"]},
"LoadImage": {"input": {"required": {"image": [["a.png"]]}}, "output": ["IMAGE", "MASK"]},
"ImgProc": {"input": {"required": {"length": ["INT", {"default": 97}], "vae": ["VAE"], "image": ["IMAGE"]}},
"output": ["LATENT", "LATENT", "IMAGE"], "output_name": ["latent", "Latent", "output_image"]},
"UpscaleModelLoader": {"input": {"required": {"model_name": [["x4.pth"]]}}, "output": ["UPSCALE_MODEL"],
"output_name": ["UPSCALE_MODEL"]},
"ImageUpscaleWithModel": {"input": {"required": {"upscale_model": ["UPSCALE_MODEL"], "image": ["IMAGE"]}},
"output": ["IMAGE"], "output_name": ["IMAGE"]},
"LoraLoader": {"input": {"required": {"model": ["MODEL"], "clip": ["CLIP"],
"lora_name": [["x.safetensors"]],
"strength_model": ["FLOAT", {"default": 1.0}],
"strength_clip": ["FLOAT", {"default": 1.0}]}},
"output": ["MODEL", "CLIP"], "output_name": ["MODEL", "CLIP"]},
# Real shape of LTXICLoRALoaderModelOnly: a MODEL passthrough plus a FLOAT the graph
# never reads. strength_model is a widget, so no connection input matches that FLOAT.
"LTXICLoRALoaderModelOnly": {
"input": {"required": {"model": ["MODEL"],
"lora_name": [["x.safetensors"]],
"strength_model": ["FLOAT", {"default": 1.0}]}},
"output": ["MODEL", "FLOAT"], "output_name": ["model", "latent_downscale_factor"]},
}
PARAMS = {"prompt": "scene one", "seed": 42, "num_frames_per_scene": 121, "frame_rate": 24}
def test_core_skeleton_links_and_params():
graph, report = builder.build(OI, {"slots": []}, PARAMS)
# every fixed-core node emitted with correct class
for cid, cls in builder.CORE.items():
assert graph[cid]["class_type"] == cls
# internal links
assert graph["sampler"]["inputs"]["model"] == ["studio", 0]
assert graph["cond"]["inputs"]["positive"] == ["studio", 1]
assert graph["cond"]["inputs"]["negative"] == ["studio", 2]
assert graph["concat"]["inputs"]["video_latent"] == ["studio", 12]
assert graph["sampler"]["inputs"]["latent_template"] == ["concat", 0]
assert graph["separate"]["inputs"]["av_latent"] == ["sampler", 0]
assert graph["audiodec"]["inputs"]["samples"] == ["separate", 1]
assert graph["vhs"]["inputs"]["images"] == ["sampler", 1]
assert graph["vhs"]["inputs"]["audio"] == ["normaudio", 0]
# param overrides
assert graph["pos"]["inputs"]["value"] == "scene one"
assert graph["frames"]["inputs"]["value"] == 121
assert graph["fps"]["inputs"]["value"] == 24
assert graph["sampler"]["inputs"]["seed"] == 42
# with no slots, the open ports are unsatisfied
assert any("FunPackStudio" in u and ".model" in u for u in report["unsatisfied"])
def test_run_output_gets_unique_temp_prefix():
# Each build (= one generation run) must give the VHS preview node its OWN filename_prefix
# so a later run can't reuse/overwrite an earlier run's temp file — otherwise a previously
# rendered scene stops playing once the next scene generates. save_output stays False (temp).
g1, _ = builder.build(OI, {"slots": []}, PARAMS)
g2, _ = builder.build(OI, {"slots": []}, PARAMS)
p1 = g1["vhs"]["inputs"]["filename_prefix"]
p2 = g2["vhs"]["inputs"]["filename_prefix"]
assert p1.startswith("funpack_preview_")
assert p2.startswith("funpack_preview_")
assert p1 != p2 # distinct per run
assert g1["vhs"]["inputs"]["save_output"] is False
def test_explicit_wires_and_autowire():
models = {"full_control": True, "slots": [
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "m.safetensors"},
"wires": {"MODEL": "port:FunPackStudio.model"}},
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
{"id": "v", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
{"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {"IMAGE": "node:ip:image"}},
{"id": "ip", "node_class": "ImgProc", "inputs": {"length": 121},
"wires": {"latent": "port:FunPackStudio.latent",
"Latent": "port:LTXVConcatAVLatent.audio_latent",
"output_image": "port:FunPackStudio.source_image"}},
]}
graph, report = builder.build(OI, models, PARAMS)
# explicit slot->core wires
assert graph["studio"]["inputs"]["model"] == ["slot_u", 0]
assert graph["studio"]["inputs"]["clip"] == ["slot_c", 0]
assert graph["studio"]["inputs"]["latent"] == ["slot_ip", 0]
assert graph["studio"]["inputs"]["source_image"] == ["slot_ip", 2]
assert graph["concat"]["inputs"]["audio_latent"] == ["slot_ip", 1]
# explicit slot->slot wire (LoadImage -> ImgProc.image)
assert graph["slot_ip"]["inputs"]["image"] == ["slot_li", 0]
# auto-wire by unique type: single VAE feeds both VAE consumers
assert graph["sampler"]["inputs"]["vae"] == ["slot_v", 0]
assert graph["audiodec"]["inputs"]["audio_vae"] == ["slot_v", 0]
# slot widget value carried through
assert graph["slot_ip"]["inputs"]["length"] == 121
# a complete config has nothing unsatisfied / ambiguous
assert report["unsatisfied"] == []
assert report["ambiguous"] == []
def test_stale_combo_value_coerced_to_live_choice():
# "old.safetensors" was renamed/removed; the live spec only has "m.safetensors".
models = {"full_control": True, "slots": [
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "old.safetensors"},
"wires": {"MODEL": "port:FunPackStudio.model"}},
]}
graph, _ = builder.build(OI, models, PARAMS)
assert graph["slot_u"]["inputs"]["unet_name"] == "m.safetensors"
def test_linked_inputs_drive_multiple_node_values():
models = {
"slots": [
{"id": "a", "node_class": "ImgProc", "inputs": {"length": 50}, "wires": {}},
{"id": "b", "node_class": "ImgProc", "inputs": {"length": 50}, "wires": {}},
],
"links": [
{"id": "L1", "name": "size", "kind": "int", "value": 121,
"members": [{"slotId": "a", "input": "length"}, {"slotId": "b", "input": "length"}]},
],
}
graph, _ = builder.build(OI, models, PARAMS)
assert graph["slot_a"]["inputs"]["length"] == 121
assert graph["slot_b"]["inputs"]["length"] == 121
def test_editor_driven_link_pulls_from_params():
models = {
"slots": [{"id": "a", "node_class": "ImgProc", "inputs": {"length": 50}, "wires": {}}],
"links": [{"id": "L", "name": "len", "source": "editor", "editor_key": "num_frames_per_scene",
"members": [{"slotId": "a", "input": "length"}]}],
}
graph, _ = builder.build(OI, models, PARAMS) # PARAMS num_frames_per_scene = 121
assert graph["slot_a"]["inputs"]["length"] == 121
def test_media_injects_loadimage_wired_to_target():
models = {"slots": [{"id": "ip", "node_class": "ImgProc", "inputs": {}, "wires": {}}]}
media = {"filename": "funpack_movie_x.png", "target": "node:ip:image"}
graph, report = builder.build(OI, models, PARAMS, media=media)
assert graph["media_load"]["class_type"] == "LoadImage"
assert graph["media_load"]["inputs"]["image"] == "funpack_movie_x.png"
assert graph["slot_ip"]["inputs"]["image"] == ["media_load", 0]
def test_optional_open_ports_do_not_block():
# loaders + vae + audio handled, no image source: source_image/latent optional -> ok
models = {"full_control": True, "slots": [
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
{"id": "v", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
{"id": "ip", "node_class": "ImgProc", "inputs": {},
"wires": {"latent": "port:FunPackStudio.latent", "Latent": "port:LTXVConcatAVLatent.audio_latent"}},
{"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {"IMAGE": "node:ip:image"}},
]}
graph, report = builder.build(OI, models, PARAMS)
assert report["blocking"] == [] # source_image/latent optional, everything required satisfied
def test_ambiguous_type_is_reported_not_guessed():
models = {"slots": [
{"id": "v1", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
{"id": "v2", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
]}
graph, report = builder.build(OI, models, PARAMS)
assert not isinstance(graph["sampler"]["inputs"].get("vae"), list) # left unwired
assert any("FunPackLTXAVSceneChainSampler" in a and ".vae" in a for a in report["ambiguous"])
def test_multitype_widget_input_does_not_block_and_keeps_its_value():
# LTXVEmptyLatentAudio.frame_rate arrives as a V3 MultiType widget ("FLOAT,INT" with a
# widgetType hint). It is a field, not a socket: it must not demand a source (nothing
# outputs "FLOAT,INT", so the run was blocked) and its value must reach the graph.
oi = dict(OI)
oi["LTXVEmptyLatentAudio"] = {
"input": {"required": {
"frames_number": ["INT", {"default": 97}],
"frame_rate": ["FLOAT,INT", {"widgetType": "INT", "default": 25}],
"audio_vae": ["VAE"],
}},
"output": ["LATENT"], "output_name": ["Latent"]}
models = {"full_control": True, "slots": [
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
{"id": "v", "node_class": "VaeLoader", "inputs": {},
"wires": {"VAE": "port:FunPackLTXAVSceneChainSampler.vae"}},
{"id": "av", "node_class": "VaeLoader", "inputs": {},
"wires": {"VAE": ["node:ae:audio_vae", "port:LTXVAudioVAEDecode.audio_vae"]}},
{"id": "ae", "node_class": "LTXVEmptyLatentAudio", "inputs": {"frame_rate": 30},
"wires": {"Latent": "port:LTXVConcatAVLatent.audio_latent"}},
]}
graph, report = builder.build(oi, models, PARAMS)
assert report["blocking"] == []
assert not any("frame_rate" in m for m in report["unsatisfied"] + report["ambiguous"])
assert graph["slot_ae"]["inputs"]["frame_rate"] == 30 # user value, not a link
assert graph["slot_ae"]["inputs"]["frames_number"] == 97 # default still emitted
assert graph["slot_ae"]["inputs"]["audio_vae"] == ["slot_av", 0]
def _loaders():
return [
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}},
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}},
{"id": "v", "node_class": "VaeLoader", "inputs": {}, "wires": {}},
]
def test_upscale_before_export_wires_cleanly_and_loader_autofeeds():
# The user's scenario: tap decoded frames -> ImageUpscaleWithModel -> replace export frames,
# with an UpscaleModelLoader that has no explicit output wire (it should still auto-feed).
models = {"full_control": True, "slots": _loaders() + [
{"id": "um", "node_class": "UpscaleModelLoader", "inputs": {}, "wires": {}},
{"id": "up", "node_class": "ImageUpscaleWithModel", "inputs": {},
"input_sources": {"image": "core:sampler:1"}, # decoded video frames
"wires": {"IMAGE": "port:VHS_VideoCombine.images"}}, # replace the export frames
]}
graph, report = builder.build(OI, models, PARAMS)
assert graph["slot_up"]["inputs"]["image"] == ["sampler", 1] # decoded frames in
assert graph["vhs"]["inputs"]["images"] == ["slot_up", 0] # upscaled frames out to export
assert graph["slot_up"]["inputs"]["upscale_model"] == ["slot_um", 0] # loader auto-feeds
assert builder._find_cycle(graph) is None
assert not any("dependency cycle" in m.lower() for m in report["blocking"]) # no cycle block
def test_cycle_from_default_source_image_wire_is_dropped():
# An image_processing node consumes the decoded frames AND carries the role-default wire back
# to Studio · source_image — that loops. The default wire must be dropped, the user's source kept.
models = {"full_control": True, "slots": _loaders() + [
{"id": "ip", "node_class": "ImgProc", "role": "image_processing", "inputs": {},
"input_sources": {"image": "core:sampler:1", "vae": "out:v:VAE"},
"wires": {"output_image": "port:FunPackStudio.source_image"}}, # == role default → droppable
]}
graph, report = builder.build(OI, models, PARAMS)
assert builder._find_cycle(graph) is None # no cycle handed to ComfyUI
assert graph["slot_ip"]["inputs"]["image"] == ["sampler", 1] # user's source preserved
src_img = graph["studio"]["inputs"].get("source_image")
assert not (isinstance(src_img, list) and src_img[0] == "slot_ip") # looping default dropped
assert any("dependency cycle" in m.lower() for m in report["auto_wired"])
def test_all_explicit_cycle_is_reported_not_silently_built():
# Two nodes feeding each other through explicit input sources — nothing droppable, so it must
# surface as a clear blocking error rather than a graph ComfyUI rejects opaquely.
models = {"full_control": True, "slots": _loaders() + [
{"id": "a", "node_class": "ImgProc", "inputs": {},
"input_sources": {"image": "out:b:output_image", "vae": "out:v:VAE"}, "wires": {}},
{"id": "b", "node_class": "ImgProc", "inputs": {},
"input_sources": {"image": "out:a:output_image", "vae": "out:v:VAE"}, "wires": {}},
]}
graph, report = builder.build(OI, models, PARAMS)
assert any("dependency cycle" in m.lower() for m in report["blocking"])
def test_extract_widgets_handles_control_and_links():
nd = OI["FunPackLTXAVSceneChainSampler"]
# seed(+control), num_frames, then the rest are not declared widgets here, so map stops
vals = [999, "randomize", 97]
got = builder.extract_widgets(nd, vals)
assert got["seed"] == 999 # control_after_generate ("randomize") skipped
assert got["num_frames_per_scene"] == 97
# forceInput STRINGs are sockets, never widgets
studio = builder.extract_widgets(OI["FunPackStudio"], ["a", "{\"x\":1}", "[]", "ignored"])
assert studio == {"rating": "a", "studio_settings": "{\"x\":1}", "adjustments": "[]"}
# dict widget store (VHS) drops non-input keys
d = builder.extract_widgets(OI["VHS_VideoCombine"], {"frame_rate": 30, "videopreview": {"x": 1}})
assert d == {"frame_rate": 30}
def test_movie_editor_scene_ratings_in_studio_settings():
import json
params = {
**PARAMS,
"studio_inputs": {
"rating": "__funpack_continue__",
"_movie_editor_scene_ratings": [
{"index": 0, "rating": "Perfect"},
{"index": 2, "rating": "Missing action"},
],
},
}
graph, _report = builder.build(OI, {"slots": []}, params)
settings = json.loads(graph["studio"]["inputs"]["studio_settings"])
assert settings["refiner"]["split_by_transitions"] is True
assert settings["refiner"]["movie_editor_scene_ratings"] == [
{"index": 0, "rating": "Perfect"},
{"index": 2, "rating": "Missing action"},
]
assert "_movie_editor_scene_ratings" not in graph["studio"]["inputs"]
def test_refinement_key_injected_into_keyloader():
graph, _ = builder.build(OI, {"slots": []}, {**PARAMS, "refinement_key": "charA"})
assert graph["keyloader"]["inputs"]["key_name"] == "charA"
# combo forced to none so the typed key_name wins over any reference-seeded value
assert graph["keyloader"]["inputs"]["refinement_key"] == "-None-"
def test_refinement_key_defaults_when_absent():
graph, _ = builder.build(OI, {"slots": []}, PARAMS)
assert graph["keyloader"]["inputs"]["key_name"] == "default"
def test_bypassed_lora_drops_node_and_passes_model_clip_through():
models = {"full_control": True, "slots": [
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "m.safetensors"}, "wires": {}},
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {}},
{"id": "lora", "node_class": "LoraLoader", "bypassed": True,
"inputs": {"lora_name": "x.safetensors", "strength_model": 0.8, "strength_clip": 0.8,
"model": ["slot_u", 0], "clip": ["slot_c", 0]},
"wires": {"MODEL": "port:FunPackStudio.model", "CLIP": "port:FunPackStudio.clip"}},
]}
graph, report = builder.build(OI, models, PARAMS)
# the LoRA node itself is gone
assert "slot_lora" not in graph
# Studio's model/clip wire straight to the original loaders, skipping the LoRA
assert graph["studio"]["inputs"]["model"] == ["slot_u", 0]
assert graph["studio"]["inputs"]["clip"] == ["slot_c", 0]
assert any("LoraLoader bypassed" in w for w in report["wired"])
def test_non_bypassed_lora_stays_in_graph():
models = {"full_control": True, "slots": [
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "m.safetensors"}, "wires": {}},
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {}},
{"id": "lora", "node_class": "LoraLoader",
"inputs": {"lora_name": "x.safetensors", "model": ["slot_u", 0], "clip": ["slot_c", 0]},
"wires": {"MODEL": "port:FunPackStudio.model", "CLIP": "port:FunPackStudio.clip"}},
]}
graph, _ = builder.build(OI, models, PARAMS)
assert "slot_lora" in graph
assert graph["studio"]["inputs"]["model"] == ["slot_lora", 0]
assert graph["studio"]["inputs"]["clip"] == ["slot_lora", 1]
def test_bypass_ignores_outputs_nothing_is_wired_to():
"""LTXICLoRALoaderModelOnly emits a FLOAT (latent_downscale_factor) next to its MODEL,
and nothing in the editor's graph reads it. Demanding a matching input for an output
that feeds nothing refused a bypass that is unambiguous for every link that exists —
only CONSUMED outputs need a passthrough."""
models = {"full_control": True, "slots": [
{"id": "u", "node_class": "UnetLoader", "inputs": {"unet_name": "m.safetensors"}, "wires": {}},
{"id": "ic", "node_class": "LTXICLoRALoaderModelOnly", "bypassed": True,
"inputs": {"lora_name": "x.safetensors", "strength_model": 0.8, "model": ["slot_u", 0]},
"wires": {"MODEL": "port:FunPackStudio.model"}},
]}
graph, report = builder.build(OI, models, PARAMS)
assert "slot_ic" not in graph
assert graph["studio"]["inputs"]["model"] == ["slot_u", 0]
assert not any("bypass" in b for b in report["blocking"])
assert any("LTXICLoRALoaderModelOnly bypassed" in w for w in report["wired"])
def test_bypass_on_node_with_no_matching_input_blocks_generation():
# VaeLoader has no connection_input at all, so there's nothing to pass an output through.
# A bypass a user explicitly asked for must never be silently ignored — generation blocks
# with a clear reason instead of quietly leaving the node active.
models = {"full_control": True, "slots": [
{"id": "v", "node_class": "VaeLoader", "bypassed": True, "inputs": {}, "wires": {}},
]}
graph, report = builder.build(OI, models, PARAMS)
assert "slot_v" in graph
assert any("bypass needs exactly one input" in u for u in report["unsatisfied"])
assert any("bypass needs exactly one input" in b for b in report["blocking"])