Preserve original LoRA behavior and verify real inference

This commit is contained in:
Dr.Lt.Data
2026-11-01 01:26:32 +09:00
parent 66c9f61c33
commit 215a71bcbb
10 changed files with 432 additions and 26 deletions
+1
View File
@@ -16,4 +16,5 @@ impact_subpack
!tests/wildcards/samples/test_encoding_emoji.txt
!tests/wildcards/samples/test_encoding_special.txt
!tests/wildcards/lora_samples/*.txt
!tests/wildcards/model_samples/*.txt
!example_workflows/wildcards/styles.txt
+33
View File
@@ -16,6 +16,7 @@ that setup does not extend the supported import scope.
| `tests/test_e2e_switches.py` | Create and wire nodes; import prepared graphs and UI-created JSON, reload Chrome, delete connected nodes, reconnect and click Run | Slot identities and typed links, list order, non-default selection and routing, fresh output after reconnecting, output preservation when a Reroute disconnects during loading |
| `tests/test_e2e_widget_catalog.py` | Load the UI-created catalog and pan to each node at 100% zoom | Every registered node exactly once; live widget names, order, and values match the fixed baseline under positional and named restoration |
| `tests/test_e2e_lora_wildcard_source.py` | Search for the node, select non-default values, native Export and file import | Delayed creation responses cannot overwrite saved widget names/order/values or category options in either restoration mode |
| `tests/e2e_lora_wildcard_inference.py` | Import a prepared graph, choose modes, Run, native Export and reload | Real SD1.5 LoRA loading and BREAK conditioning match core-node reference images pixel-for-pixel; four modes preserve output count/order/text and native saved widgets |
| `tests/e2e_tutorials.py` | Import the original Switch tutorial PNGs, change selection widgets, save and reimport JSON | Every selected branch and final output matches direct core-node image results; both selection modes retain the final preview |
| `tests/e2e_regional.py` | Import the regional img2img tutorial and prepared variants, click Run | Exact color masks, output differs from a VAE roundtrip, each regional prompt affects its own region more than distant pixels, browser previews match server images |
| `tests/e2e_conditionings.py` | Import conditioning and regional-prompt graphs, click Run, save and reimport through the file input | Combine/Concat results match core ComfyUI nodes; regional prompts retain both items in order; dynamic slots and links survive loading |
@@ -84,6 +85,38 @@ assertions; keep the tests and trusted fixtures unchanged. Restore the fix
before final catalog verification. Catalog recording uses the ordinary Full
Cache setup below, not the LoRA sample directory.
For real LoRA inference, configure `custom_wildcards` as
`tests/wildcards/model_samples` and restart the isolated server. Reuse local
models with `--models-directory`: the script requires
`v1-5-pruned-emaonly-fp16.safetensors` under checkpoints and
`SD1.5/etc/add_detail.safetensors` plus `SD1.5/etc/detailmaker.safetensors` under
loras. It downloads no assets. The reference uses core LoRA loaders, two CLIP
encodings joined by ConditioningConcat, and identical sampler settings. Cases
include a repeated LoRA, signed/scientific weights, literal prompt syntax,
trailing text, an extra LoRA, all four selection modes, and native reload.
Unpatched and distinct-LoRA images must differ so parity cannot pass with
silently ignored patches or duplicated batch entries.
For exact pixel comparisons, start the server with `--deterministic
--force-fp32 --fp32-vae --disable-dynamic-vram --use-pytorch-cross-attention`.
Default FP16/dynamic-VRAM output is not a bitwise pixel oracle for this test.
Reference text enters CLIP through a linked string to avoid the frontend's
dynamic-prompt widget changing literal braces before submission.
```bash
python tests/e2e_lora_wildcard_inference.py --base-url http://127.0.0.1:18188 \
--artifacts /tmp/impact-lora-inference
```
The separate model integration check compares every conditioning tensor and
all MODEL/CLIP patch tensors and strengths with core nodes, and verifies that
the original inputs remain unpatched. It uses default ComfyUI model settings
and the same local assets, without a running server. Only unused HTTP route
registration is substituted. It performs no sampling or browser actions:
```bash
python tests/integration_lora_wildcard_tensors.py --comfy-root /path/to/ComfyUI
```
## Server setup
Use an isolated checkout/copy and a separate ComfyUI user/output directory.
+3 -3
View File
@@ -10,7 +10,7 @@ The optional sample is [example_workflows/wildcards/styles.txt](../example_workf
- **Dynamic Category Mapping**: Automatically populates the `category` dropdown by scanning all `.txt` files in the `custom_wildcards/` directory.
- **Dynamic Options Update**: Changing the `category` in the UI dynamically fetches and updates the available LoRAs in the `lora_wildcard` dropdown.
- **LoRA Loading & Encoding**: Uses Impact Pack's existing wildcard/LoRA processor, including wildcard expansion, multiple LoRAs, block weights and `BREAK`. Text after a LoRA tag is preserved.
- **LoRA Loading & Encoding**: Applies LoRA tags in order using ComfyUI's LoRA loader, then encodes the cleaned prompt with `BREAK` support. Text and additional LoRAs after the first tag are preserved. Repeated LoRAs are applied separately. Prompt text such as `__color__` or `{red|blue}` remains literal, matching the original node.
- **Cleanup**: Strips `<lora:...>` tags from the final text prompt before encoding.
- **Outputs for File Saving**: Provides `lora_name` (clean filename) and `name` (label) outputs, which can be connected directly to `SaveImage` as a filename prefix.
@@ -28,9 +28,9 @@ The optional sample is [example_workflows/wildcards/styles.txt](../example_workf
- **category** (COMBO): Dropdown containing the text file names (categories) from the `custom_wildcards/` directory.
- **lora_wildcard** (COMBO): The LoRA entries inside the selected category file.
- **mode** (COMBO): Select between `fixed`, `random`, `sequential`, or `batch`.
- **strength** (FLOAT): Overrides the model and CLIP weights of the entry's first LoRA (Default: `0.8`). Other LoRAs in the entry or base prompt retain their declared weights. The first LoRA's block-weight/loader options are preserved.
- **strength** (FLOAT): Overrides the model and CLIP weights of the entry's first LoRA (Default: `0.8`). Other LoRAs in the entry or base prompt retain their declared numeric weights. Block-weight options are not supported by this node.
- **base_prompt** (STRING): Multiline text input for the base prompt.
- **seed** (INT): Seed used for random selection, sequential index and wildcard expansion. Image sampling uses the sampler's own seed.
- **seed** (INT): Seed used for random selection and sequential index. Image sampling uses the sampler's own seed.
## Outputs
+108 -19
View File
@@ -1,6 +1,8 @@
import os
import re
import random
import logging
import folder_paths
import nodes
from aiohttp import web
from server import PromptServer
@@ -26,7 +28,7 @@ def parse_linhas_wildcard(caminho):
entries = []
counts = {}
for line in wildcards.load_txt_wildcard(caminho):
loras = wildcards.extract_lora_values(line)
loras, _ = extrair_e_limpar_loras(line)
if not loras or not loras[0][0]:
continue
name = loras[0][0].strip()
@@ -39,19 +41,19 @@ def parse_linhas_wildcard(caminho):
def listar_categorias_wildcard():
root = config.get_config()["custom_wildcards"]
categories = sorted(os.path.splitext(name)[0] for name in os.listdir(root)
if name.endswith(".txt") and achar_arquivo_wildcard(name))
try:
categories = sorted(os.path.splitext(name)[0] for name in os.listdir(root)
if name.endswith(".txt") and achar_arquivo_wildcard(name))
except OSError as error:
logging.warning(f"[Impact Wildcard] Error listing custom_wildcards: {error}")
categories = []
return categories or ["styles", "poses"]
def entry_with_strength(entry, strength):
def replace(match):
parts = match.group(1).split(":")
name = parts.pop(0).strip()
for _ in range(2):
if parts and wildcards.is_numeric_string(parts[0]):
parts.pop(0)
return "<lora:" + ":".join([name, str(strength), str(strength), *parts]) + ">"
name = match.group(1).split(":", 1)[0].strip()
return f"<lora:{name}:{strength}:{strength}>"
return _LORA_RE.sub(replace, entry, count=1)
@@ -61,14 +63,102 @@ def nome_arquivo_limpo(nome, fallback="no_lora"):
return re.sub(r"\s+", "_", name).strip("_") or fallback
def clean_prompt(text):
text = re.sub(r",\s*,", ",", text)
return re.sub(r"\s+", " ", text).strip().strip(",").strip()
def resolver_caminho_lora(nome):
"""Locates the .safetensors (or other supported formats) in the ComfyUI cache, resolving subfolders."""
if not nome.endswith((".safetensors", ".ckpt", ".pt")):
nome_com_ext = nome + ".safetensors"
else:
nome_com_ext = nome
todos_loras = folder_paths.get_filename_list("loras")
class _PromptEncoder:
def encode(self, clip, text):
return nodes.CLIPTextEncode().encode(clip, clean_prompt(text))
# Attempt 1: Exact match or relative path
for lora in todos_loras:
lora_norm = lora.replace("\\", "/")
nome_norm = nome_com_ext.replace("\\", "/")
if lora_norm == nome_norm or lora_norm.endswith("/" + nome_norm):
return lora
# Attempt 2: Filename only (ignores subfolders)
nome_basico = os.path.basename(nome_com_ext)
for lora in todos_loras:
if os.path.basename(lora) == nome_basico:
return lora
return None
def extrair_e_limpar_loras(prompt):
"""Scans the prompt for <lora:NAME:weight> or <lora:NAME:model_w:clip_w> tags,
returning the list of LoRAs to load and cleaning the tags from the text."""
if not prompt:
return [], ""
pattern = r"<lora:([^>]+)>"
matches = re.findall(pattern, prompt)
loras_extraidos = []
for match in matches:
parts = match.split(":")
if len(parts) >= 1:
nome_lora = parts[0].strip()
weight_model = 1.0
weight_clip = 1.0
if len(parts) >= 2:
try:
weight_model = float(parts[1].strip())
weight_clip = weight_model
except ValueError:
pass
if len(parts) >= 3:
try:
weight_clip = float(parts[2].strip())
except ValueError:
pass
loras_extraidos.append((nome_lora, weight_model, weight_clip))
# Clean the tags from the prompt
prompt_limpo = re.sub(pattern, "", prompt)
# Remove duplicate/trailing commas and extra spaces
prompt_limpo = re.sub(r",\s*,", ",", prompt_limpo)
prompt_limpo = re.sub(r"\s+", " ", prompt_limpo).strip()
prompt_limpo = prompt_limpo.strip(",").strip()
return loras_extraidos, prompt_limpo
def aplicar_loras_e_codificar(model, clip, loras_a_aplicar, prompt_limpo):
"""Loads LoRAs sequentially and encodes the prompt using CLIPTextEncode (supporting BREAK)."""
loader = nodes.LoraLoader()
# 1. Load LoRAs onto the model and CLIP
for nome_lora, w_model, w_clip in loras_a_aplicar:
caminho_resolvido = resolver_caminho_lora(nome_lora)
if caminho_resolvido:
logging.info(f"[Impact Wildcard] Applying LoRA: {caminho_resolvido} (model={w_model}, clip={w_clip})")
model, clip = loader.load_lora(model, clip, caminho_resolvido, w_model, w_clip)
else:
logging.warning(f"[Impact Wildcard] WARNING: LoRA '{nome_lora}' could not be found in ComfyUI.")
# 2. Split by BREAK and encode (for composite conditioning / ImpactWildcardEncode compatibility)
secoes = [x.strip() for x in prompt_limpo.split("BREAK")]
secoes = [x for x in secoes if x != ""]
if not secoes:
secoes = [""]
encoder = nodes.CLIPTextEncode()
result_conditioning = None
for secao in secoes:
logging.info(f"[Impact Wildcard] Encoding prompt: '{secao}'")
cond = encoder.encode(clip, secao)[0]
if result_conditioning is not None:
result_conditioning = nodes.ConditioningConcat().concat(result_conditioning, cond)[0]
else:
result_conditioning = cond
return model, clip, result_conditioning
@PromptServer.instance.routes.get("/impact/get_items")
@@ -139,10 +229,9 @@ class LoraWildcardSource:
for label, entry, name in selected:
prompt = ", ".join(part.strip().strip(",") for part in
(base_prompt, entry_with_strength(entry, strength)) if part.strip())
processed = []
result_model, result_clip, conditioning = wildcards.process_with_loras(
prompt, model, clip, clip_encoder=_PromptEncoder(), seed=seed, processed=processed)
values = (result_model, result_clip, conditioning, clean_prompt(processed[1]), nome_arquivo_limpo(name), label)
loras, text = extrair_e_limpar_loras(prompt)
result_model, result_clip, conditioning = aplicar_loras_e_codificar(model, clip, loras, text)
values = (result_model, result_clip, conditioning, text, nome_arquivo_limpo(name), label)
for output, value in zip(outputs, values):
output.append(value)
return outputs
+2
View File
@@ -514,6 +514,8 @@ def process_comment_out(text):
def process(text, seed=None):
text = process_comment_out(text)
if seed is not None:
random.seed(seed)
random_gen = np.random.default_rng(seed)
local_wildcard_dict = get_wildcard_dict()
+14
View File
@@ -68,6 +68,20 @@ contract through Chrome search, editing, native Export and file import in both
restoration modes. Configure its isolated server with
`tests/wildcards/lora_samples`; see the E2E guide.
`python3 tests/e2e_lora_wildcard_inference.py --artifacts /tmp/impact-lora-inference`
checks all four modes and native workflow reload against core LoRA/CLIP/sampler
nodes using real SD1.5 models. Configure the isolated server with
`tests/wildcards/model_samples` and the models listed in the script docstring.
It compares every output pixel, output text, list length and entry order;
browser previews must also match server images. This is separate from the
model-free catalog and delayed-response tests.
Use the deterministic FP32 server options in the E2E guide for pixel equality.
`python3 tests/integration_lora_wildcard_tensors.py --comfy-root /path/to/ComfyUI`
separately compares every conditioning tensor and MODEL/CLIP LoRA patch tensor
and weight with core nodes under ComfyUI's default model-loading settings.
It also verifies that the input models retain no patches. Only unused HTTP
route registration is substituted; model operations are real.
## Test Suites
### test_encoding.sh (15 tests)
+139
View File
@@ -0,0 +1,139 @@
"""Compare LoraWildcardSource with core LoRA/CLIP/sampler nodes in Chrome.
Requires v1-5-pruned-emaonly-fp16.safetensors and SD1.5/etc/{add_detail,
detailmaker}.safetensors. Configure custom_wildcards as wildcards/model_samples.
Uses real models and HTTP execution, with no model or route substitutions.
For exact pixels, start the server with --deterministic --force-fp32 --fp32-vae
--disable-dynamic-vram --use-pytorch-cross-attention.
"""
import hashlib
import json
from e2e_ui import browser_session, options
from test_e2e_widget_catalog import export_workflow, full_zoom, show_node, widget_state
LORAS = ["SD1.5/etc/add_detail", "SD1.5/etc/detailmaker"]
BASE = "a {red|blue} flower, __color__, <lora: SD1.5/etc/add_detail : +1e-1 : 2e-1> BREAK garden"
FIRST_SECTION = "a {red|blue} flower, __color__,"
LAST_SECTIONS = ["garden, petals, dew", "garden, leaves, mist"]
def workflow():
graph = {}
def add(kind, **inputs):
node_id = str(len(graph) + 1)
graph[node_id] = {"class_type": kind, "inputs": inputs}
return node_id
checkpoint = add("CheckpointLoaderSimple", ckpt_name="v1-5-pruned-emaonly-fp16.safetensors")
negative = add("CLIPTextEncode", clip=[checkpoint, 1], text="")
latent = add("EmptyLatentImage", width=128, height=128, batch_size=1)
# A linked string bypasses CLIPTextEncode's frontend dynamic-prompt widget.
# The reference must receive the same literal text as the source node.
literal = add("PrimitiveString", value=FIRST_SECTION)
def preview(model, conditioning):
sampled = add("KSampler", model=model, positive=conditioning, negative=[negative, 0],
latent_image=[latent, 0], seed=12345, steps=4, cfg=7.0,
sampler_name="euler", scheduler="normal", denoise=1.0)
decoded = add("VAEDecode", samples=[sampled, 0], vae=[checkpoint, 2])
return add("PreviewImage", images=[decoded, 0])
def encode(clip, last):
first = add("CLIPTextEncode", clip=clip, text=[literal, 0])
second = add("CLIPTextEncode", clip=clip, text=last)
return add("ConditioningConcat", conditioning_to=[first, 0], conditioning_from=[second, 0])
source = add("LoraWildcardSource", model=[checkpoint, 0], clip=[checkpoint, 1], category="models",
lora_wildcard=LORAS[0], mode="batch", strength=0.6, base_prompt=BASE, seed=1)
actual = preview([source, 0], [source, 2])
text = add("PreviewAny", source=[source, 3])
names = add("PreviewAny", source=[source, 5])
references = []
for index, lora in enumerate(LORAS):
base = add("LoraLoader", model=[checkpoint, 0], clip=[checkpoint, 1],
lora_name=LORAS[0] + ".safetensors", strength_model=0.1, strength_clip=0.2)
selected = add("LoraLoader", model=[base, 0], clip=[base, 1],
lora_name=lora + ".safetensors", strength_model=0.6, strength_clip=0.6)
if index == 0:
selected = add("LoraLoader", model=[selected, 0], clip=[selected, 1],
lora_name=LORAS[1] + ".safetensors", strength_model=-0.15, strength_clip=0.2)
conditioning = encode([selected, 1], LAST_SECTIONS[index])
references.append(preview([selected, 0], [conditioning, 0]))
plain = encode([checkpoint, 1], LAST_SECTIONS[0])
unpatched = preview([checkpoint, 0], [plain, 0])
return graph, source, actual, text, names, references, unpatched
def main():
args = options(__doc__)
assert args.artifacts, "Pass --artifacts to retain the native workflow and results"
graph, source, actual, text, names, references, unpatched = workflow()
results = []
with browser_session(args) as ui:
ui.page.keyboard.press("Escape")
ui.new_workflow()
ui.load_workflow({"name": "lora-inference-api.json", "mimeType": "application/json",
"buffer": json.dumps(graph).encode()}, len(graph))
full_zoom(ui)
for node_id, node in graph.items():
if node["class_type"] != "KSampler":
continue
show_node(ui, node_id)
point = ui.app.evaluate("""(app, id) => {
const node = app.graph.getNodeById(id);
const widget = node.widgets.find(w => w.name === 'control_after_generate');
return app.canvasPosToClientPos([node.pos[0] + node.size[0] / 2,
node.pos[1] + widget.last_y + 10]);
}""", node_id)
ui.page.mouse.click(*point)
ui.page.get_by_role("menuitem", name="fixed", exact=True).click()
show_node(ui, source)
ui.choose("LoraWildcardSource", "control_after_generate", "fixed")
before = widget_state(ui, source)
saved = args.artifacts / "inference-workflow.json"
export_workflow(ui, saved)
def check(label, expected):
entry = ui.run()
# Offscreen previews load lazily; fit all outputs into the view.
ui.page.get_by_role("button", name="Fit View (.)", exact=True).click()
images = ui.images(entry)
assert len(images[actual]) == len(expected)
for image, index in zip(images[actual], expected):
reference = images[references[index]][0]
assert image.size == reference.size == (128, 128)
assert image.tobytes() == reference.tobytes(), f"{label}: item {index} differs from core nodes"
assert images[references[0]][0].tobytes() != images[references[1]][0].tobytes()
assert images[references[0]][0].tobytes() != images[unpatched][0].tobytes()
assert entry["outputs"][text]["text"] == [FIRST_SECTION + " BREAK " + LAST_SECTIONS[i] for i in expected]
assert entry["outputs"][names]["text"] == [LORAS[i] for i in expected]
hashes = [hashlib.sha256(image.tobytes()).hexdigest() for image in images[actual]]
for i, image in enumerate(images[actual]):
image.save(args.artifacts / f"{label}-{i}.png")
results.append({"case": label, "expected_entries": expected, "pixel_sha256": hashes})
print(f"PASS: {label}, {len(expected)} output(s), exact core-node pixel parity", flush=True)
full_zoom(ui)
show_node(ui, source)
check("batch", [0, 1])
ui.new_workflow()
ui.load_workflow(saved, len(graph))
full_zoom(ui)
show_node(ui, source)
assert widget_state(ui, source) == before
check("native-reload", [0, 1])
ui.choose("LoraWildcardSource", "mode", "fixed")
ui.choose("LoraWildcardSource", "lora_wildcard", LORAS[1])
check("fixed", [1])
ui.choose("LoraWildcardSource", "mode", "sequential")
check("sequential", [1])
ui.choose("LoraWildcardSource", "mode", "random")
check("random", [0])
(args.artifacts / "results.json").write_text(json.dumps(results, indent=2) + "\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,70 @@
"""Compare real LoRA patches and conditioning tensors with core ComfyUI nodes.
Run separately from pytest's stubbed wildcard tests. Requires the same models
as e2e_lora_wildcard_inference.py. Only unused HTTP route registration is
substituted; checkpoint loading, patching and CLIP encoding use real ComfyUI.
"""
import argparse
import gc
from pathlib import Path
import sys
from types import SimpleNamespace
def same_values(first, second, torch):
if isinstance(first, torch.Tensor):
return isinstance(second, torch.Tensor) and torch.equal(first, second)
if isinstance(first, (list, tuple)):
return type(first) is type(second) and len(first) == len(second) and all(
same_values(a, b, torch) for a, b in zip(first, second))
if isinstance(first, dict):
return first.keys() == second.keys() and all(same_values(first[k], second[k], torch) for k in first)
if hasattr(first, "weights"):
return type(first) is type(second) and same_values(first.weights, second.weights, torch)
return first == second
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--comfy-root", type=Path, required=True)
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(args.comfy_root.resolve()), str(root / "modules")]
import torch
import nodes
import comfy.model_management
from aiohttp import web
from server import PromptServer
PromptServer.instance = SimpleNamespace(routes=web.RouteTableDef())
from impact import config, wildcard_source
from e2e_lora_wildcard_inference import BASE, FIRST_SECTION, LAST_SECTIONS, LORAS
config.cached_config = {"custom_wildcards": str(root / "tests/wildcards/model_samples")}
with torch.inference_mode():
model, clip, vae = nodes.CheckpointLoaderSimple().load_checkpoint("v1-5-pruned-emaonly-fp16.safetensors")
result = wildcard_source.LoraWildcardSource().build(model, clip, "models", LORAS[0], "batch", 0.6, BASE, 1)
assert [len(output) for output in result] == [2] * 6
for index, last in enumerate(LAST_SECTIONS):
reference_model, reference_clip = nodes.LoraLoader().load_lora(
model, clip, LORAS[0] + ".safetensors", 0.1, 0.2)
reference_model, reference_clip = nodes.LoraLoader().load_lora(
reference_model, reference_clip, LORAS[index] + ".safetensors", 0.6, 0.6)
if index == 0:
reference_model, reference_clip = nodes.LoraLoader().load_lora(
reference_model, reference_clip, LORAS[1] + ".safetensors", -0.15, 0.2)
first = nodes.CLIPTextEncode().encode(reference_clip, FIRST_SECTION)[0]
second = nodes.CLIPTextEncode().encode(reference_clip, last)[0]
conditioning = nodes.ConditioningConcat().concat(first, second)[0]
assert same_values(result[2][index], conditioning, torch), f"Conditioning differs: {index}"
assert same_values(result[0][index].patches, reference_model.patches, torch), f"MODEL patches differ: {index}"
assert same_values(result[1][index].patcher.patches, reference_clip.patcher.patches, torch), f"CLIP patches differ: {index}"
assert result[3][index] == FIRST_SECTION + " BREAK " + last
print(f"PASS: entry {index}, exact conditioning and all MODEL/CLIP patch tensors and strengths", flush=True)
assert model.patches == {} and clip.patcher.patches == {}, "Input models were mutated"
comfy.model_management.unload_all_models()
del result, reference_model, reference_clip, model, clip, vae
gc.collect()
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
petals, <lora:SD1.5/etc/add_detail:0.7>, dew, <lora:SD1.5/etc/detailmaker:-0.15:0.2>
leaves, <lora:SD1.5/etc/detailmaker:0.7>, mist
+60 -4
View File
@@ -17,14 +17,15 @@ from impact import config, wildcards
ROOT = Path(__file__).resolve().parents[2]
def test_shipped_examples_preserve_existing_wildcard_results(tmp_path, monkeypatch):
@pytest.mark.parametrize("cache_limit", [0, 50], ids=["on_demand", "full_cache"])
def test_shipped_examples_preserve_existing_wildcard_results(tmp_path, monkeypatch, cache_limit):
bundled = tmp_path / "wildcards"
bundled.mkdir()
(bundled / "styles.txt").write_text("watercolor\n", encoding="utf-8")
custom = tmp_path / "custom_wildcards"
custom.mkdir()
monkeypatch.setattr(config, "cached_config", {
"custom_wildcards": str(custom), "wildcard_cache_limit_mb": 50,
"custom_wildcards": str(custom), "wildcard_cache_limit_mb": cache_limit,
})
monkeypatch.setattr(wildcards, "wildcards_path", str(bundled))
for name in ("wildcard_dict", "available_wildcards", "loaded_wildcards"):
@@ -121,7 +122,7 @@ def test_full_entry_and_extra_loras_survive_encoding(source, model_calls):
(custom / "styles.txt").write_text("portrait, <lora:A:0.7>, __color__, <lora:B:-0.3:0.4> BREAK landscape")
result = build(module, base_prompt="base, <lora:Base:0.2:0.3>")
assert model_calls == [("Base.safetensors", 0.2, 0.3), ("A.safetensors", 0.8, 0.8), ("B.safetensors", -0.3, 0.4)]
assert all(word in result[3][0] for word in ("portrait", "blue", "landscape"))
assert all(word in result[3][0] for word in ("portrait", "__color__", "landscape"))
assert "<lora:" not in result[3][0]
assert len(result[2][0]) == 2
@@ -138,7 +139,7 @@ def test_empty_category_still_processes_base_prompt_loras(source, model_calls):
module, _ = source
result = build(module, base_prompt="__color__, <lora:Base:0.2:0.3>")
assert model_calls == [("Base.safetensors", 0.2, 0.3)]
assert "blue" in result[3][0] and "<lora:" not in result[3][0]
assert result[3] == ["__color__"]
def test_pose_category_uses_the_same_file_in_ui_and_execution(source, model_calls):
@@ -199,3 +200,58 @@ def test_input_names_and_order_remain_compatible(source):
assert list(module.LoraWildcardSource.INPUT_TYPES()["required"]) == [
"model", "clip", "category", "lora_wildcard", "mode", "strength", "base_prompt", "seed",
]
@pytest.mark.parametrize("tag,expected", [
("<lora:A:0.2>", ("A.safetensors", 0.2, 0.2)),
("<lora: Base : 0.2 : 0.3>", ("Base.safetensors", 0.2, 0.3)),
("<lora:Base:1e-2:2e-2>", ("Base.safetensors", 0.01, 0.02)),
("<lora:Base:+0.2:+0.3>", ("Base.safetensors", 0.2, 0.3)),
])
def test_base_loras_keep_original_weights_and_repeated_applications(source, model_calls, tag, expected):
module, custom = source
(custom / "styles.txt").write_text("portrait, <lora:A:0.7>\n")
result = build(module, base_prompt="base, " + tag)
assert model_calls == [expected, ("A.safetensors", 0.8, 0.8)]
assert result[0] == [(expected[0], "A.safetensors")]
assert result[1] == result[0]
def test_prompt_syntax_remains_literal(source, model_calls):
module, custom = source
(custom / "styles.txt").write_text("portrait, <lora:A:0.7>\n")
prompt = "a {red|blue} vase, __color__"
result = build(module, base_prompt=prompt)
assert result[3] == [prompt + ", portrait"]
assert result[2] == [[prompt + ", portrait"]]
def test_model_resolution_requires_filename_boundary(source, model_calls, monkeypatch):
import folder_paths
module, custom = source
(custom / "styles.txt").write_text("portrait, <lora:A:0.7>\n")
monkeypatch.setattr(folder_paths, "get_filename_list", lambda kind: ["aaa/NotA.safetensors", "zzz/A.safetensors"])
build(module)
assert model_calls == [("zzz/A.safetensors", 0.8, 0.8)]
def test_break_keeps_original_section_punctuation(source, model_calls):
module, custom = source
(custom / "styles.txt").write_text("portrait, <lora:A:0.7>\n")
result = build(module, base_prompt="flowers, BREAK sky")
assert result[3] == ["flowers, BREAK sky, portrait"]
assert result[2] == [["flowers,", "sky, portrait"]]
@pytest.mark.parametrize("unavailable", ["missing", "permission"])
def test_unavailable_directory_does_not_break_node_schema(source, monkeypatch, unavailable):
module, custom = source
if unavailable == "missing":
custom.rmdir()
else:
def denied(path):
raise PermissionError(path)
monkeypatch.setattr(module.os, "listdir", denied)
schema = module.LoraWildcardSource.INPUT_TYPES()["required"]
assert schema["category"][0] == ["styles", "poses"]
assert schema["lora_wildcard"][0] == ["(styles.txt not found)"]