Files
JasonHoku-ComfyUI-Ultimate-…/tests/test_state_to_configs_json.py
T
Jason HokuandClaude Opus 4.7 4fb4fea5ed fix: lock Compare Strengths to single-bracket form (no Cartesian)
When the user types '0.5, 1.0' they want 2 outputs (lora at 0.5, lora
at 1.0 — both model and clip mirroring), not 4 outputs (Cartesian
m×c). The bug was that we wrote both _model AND _clip side-channel
arrays, so Python emitted 'name:[m]:[c]' which the orchestrator
correctly interpreted as Cartesian.

JS: collapse the two-input compare section to a single 'Strengths:'
input. Never write _clip to lora_weight_arrays.

Python: process_lora_array now emits 'name:[m_arr]' (no clip part)
when only _model array is present. The generator's existing
parse_lora_definition defaults clip=model when the third segment
is missing, giving N configs with locked semantics.

Drops the Cartesian path entirely. Backward compat retained: if a
saved workflow has both arrays (legacy), the dual-bracket form is
still emitted so the orchestrator can Cartesian-expand as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:27:39 -07:00

203 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Golden tests for UltimateConfigBuilder.state_to_configs_json.
This is the single transformer that both the runtime (generate_config) and
the preview endpoint (/configbuilder/preview) call. Drift between preview
and runtime is impossible by construction because both call this function.
"""
import json
from config_builder_node import UltimateConfigBuilder
def make_state(loras=None, lora_weight_arrays=None, lora_bypass_states=None,
models=None, samplers=None, schedulers=None, steps="20", cfg="7.0",
extra_array_fields=None):
"""Build a minimal valid state with one config_array for tests."""
arr = {
"name": "Test",
"samplers": samplers or ["euler"],
"schedulers": schedulers or ["normal"],
"steps": steps,
"cfg": cfg,
"models": models or ["None"],
"vaes": ["None"],
"text_encoders": [],
"clip_type": "stable_diffusion",
"gguf_options": {},
"loras": loras if loras is not None else ["None"],
"lora_omit_triggers": [],
"lora_triggerwords_append_settings": {},
"lora_bypass_states": lora_bypass_states or {},
"lora_strength_lock": {},
"lora_weight_arrays": lora_weight_arrays or {},
"model_bypass_states": {},
"vae_bypass_states": {},
"te_bypass_states": {},
"combine": False,
"positive_prompt_groups": [],
"negative_prompt": "",
"use_custom_prompts": False,
"model_prompt_prefix": "",
"model_prompt_suffix": "",
"attention_modes": ["default"],
}
if extra_array_fields:
arr.update(extra_array_fields)
return {
"session_name": "test_session",
"include_none": False,
"global_positive_groups": [],
"global_negative": "",
"config_arrays": [arr],
}
def test_lora_strength_arrays_render_as_brackets():
"""REGRESSION: Compare Strengths emits single-bracket form for locked semantics.
Bug history:
- 2026-04-28 (morning): JS preview emitted brackets but Python emitted
scalar 1.00:1.00 because Python ignored lora_weight_arrays side-channel.
- 2026-04-28 (afternoon): Both sides emitted "name:[m]:[c]" (two arrays),
which the orchestrator interpreted as Cartesian N×M configs instead of
N. Locked-mode fix: emit only "name:[m]" (no clip part) so the orchestrator
expands to N configs each with model=clip=value.
State setup uses ONLY lora_weight_arrays[name + "_model"] (no _clip), which
is the data shape the Builder UI now writes for compare strengths.
"""
state = make_state(
loras=["Sexy-IL-v11/:1.00:1.00"],
lora_weight_arrays={
"Sexy-IL-v11/_model": [0, 1, 2, 5, 10],
},
)
json_str = UltimateConfigBuilder.state_to_configs_json(state)
parsed = json.loads(json_str)
loras = [c["lora"] for c in parsed["configs"]]
# Locked semantics (default): single bracket array, no clip part.
# The orchestrator expands "name:[a,b,c]" to N configs each with
# model=clip=value (parse_lora_definition defaults clip to model
# when the third segment is missing).
expected = "Sexy-IL-v11/:[0, 1, 2, 5, 10]"
assert any(l == expected for l in loras), \
f"Expected exact lora string {expected!r} in output. Got: {loras}"
def test_single_lora_passes_through_verbatim():
"""A single LoRA with scalar strengths shows up unchanged in configs_json."""
state = make_state(loras=["myLora:0.80:0.50"])
json_str = UltimateConfigBuilder.state_to_configs_json(state)
parsed = json.loads(json_str)
assert len(parsed["configs"]) == 1, parsed["configs"]
assert parsed["configs"][0]["lora"] == "myLora:0.80:0.50"
def test_multiple_loras_combine_with_plus_separator():
"""combine: True (forced) joins multiple stackable loras with ' + '.
Note: process_lora_array forces combine=True regardless of the field's
value in the config_array (see config_builder_node.py 'combine = True').
"""
state = make_state(loras=["lora1:0.5:1.00", "lora2:0.7:1.00"])
json_str = UltimateConfigBuilder.state_to_configs_json(state)
parsed = json.loads(json_str)
assert parsed["configs"][0]["lora"] == "lora1:0.5:1.00 + lora2:0.7:1.00"
def test_folder_reference_left_unexpanded():
"""Folder refs ('myFolder/') stay literal — orchestrator expands at runtime."""
state = make_state(loras=["myFolder/"])
json_str = UltimateConfigBuilder.state_to_configs_json(state)
parsed = json.loads(json_str)
lora = parsed["configs"][0]["lora"]
assert lora == "myFolder/", f"Folder ref should not be expanded here, got: {lora}"
def test_folder_with_strength_array_not_combined_with_other_loras():
"""A folder ref with strength arrays must keep its trailing '/' AND
bracket form, AND must NOT be combined with stackable loras (the combine
block uses 'name part ends with /' to detect folder refs)."""
state = make_state(
loras=["myFolder/", "actualLora:0.5:1.00"],
lora_weight_arrays={
"myFolder/_model": [0.5, 0.8, 1.0],
},
)
json_str = UltimateConfigBuilder.state_to_configs_json(state)
parsed = json.loads(json_str)
# Multiple loras (one folder, one stackable, no stacking happens
# because there's only one stackable), so the per-config "lora" field
# is a LIST — not a combined string.
lora = parsed["configs"][0]["lora"]
assert isinstance(lora, list), f"Expected list of separate loras, got: {lora!r}"
folder_entry = [s for s in lora if s.startswith("myFolder/")]
assert folder_entry, f"Folder ref missing: {lora}"
assert "[0.5, 0.8, 1.0]" in folder_entry[0], f"Bracket form missing: {folder_entry[0]}"
assert "actualLora:0.5:1.00" in lora, f"Stackable lora missing: {lora}"
def test_bypassed_lora_is_excluded():
"""A LoRA listed in lora_bypass_states with True is filtered out."""
state = make_state(
loras=["lora1:0.5:1.00", "lora2:0.7:1.00"],
lora_bypass_states={"lora1": True},
)
json_str = UltimateConfigBuilder.state_to_configs_json(state)
parsed = json.loads(json_str)
lora = parsed["configs"][0]["lora"]
# Only lora2 survives the bypass filter.
assert lora == "lora2:0.7:1.00", f"Expected only lora2 to survive bypass, got: {lora}"
def test_ltx_video_config_model_type_propagates():
"""An LTX video config_array (model with type='ltx_video') reaches
configs_json with model_type=='ltx_video'.
See project memory: model_type lives PER-MODEL inside config_array.models[i].
"""
state = make_state(
models=[{"path": "ltx-2.3.safetensors", "type": "ltx_video"}],
extra_array_fields={
"ltx_video": {
"clip_models": ["gemma_3_12B.safetensors", "ltx-2.3_text_projection.safetensors"],
"vae_video": "ltx_video_vae.safetensors",
"vae_audio": "ltx_audio_vae.safetensors",
"latent_upscaler": "ltx_latent_upscaler.safetensors",
"duration_seconds": 4,
"frame_rate": 24,
"sampler_stage1": "euler",
"sigmas_stage1": "1.0,0.5,0.0",
"image_strength_stage1": 0.0,
"sampler_stage2": "euler",
"sigmas_stage2": "0.5,0.25,0.0",
"image_strength_stage2": 0.0,
"img_compression": 75,
"input_image": "",
"audio_mode": "on",
},
},
)
json_str = UltimateConfigBuilder.state_to_configs_json(state)
parsed = json.loads(json_str)
cfg = parsed["configs"][0]
assert cfg["model_type"] == "ltx_video", f"Expected model_type=ltx_video, got: {cfg.get('model_type')!r}"
def test_legacy_dual_array_preserves_cartesian_form():
"""Backward compat: if a workflow was saved with separate _model and _clip
arrays (pre-2026-04-28-pm format), the bracket-fold should still emit
"name:[m]:[c]" so the orchestrator can Cartesian-expand it. New UI never
writes _clip, but legacy state should still round-trip correctly."""
state = make_state(
loras=["legacyLora:1:1"],
lora_weight_arrays={
"legacyLora_model": [0.5, 1.0],
"legacyLora_clip": [0.7, 1.0],
},
)
json_str = UltimateConfigBuilder.state_to_configs_json(state)
parsed = json.loads(json_str)
loras = [c["lora"] for c in parsed["configs"]]
expected = "legacyLora:[0.5, 1.0]:[0.7, 1.0]"
assert any(l == expected for l in loras), \
f"Expected legacy dual-array form {expected!r}. Got: {loras}"