Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
314 lines
14 KiB
Python
314 lines
14 KiB
Python
"""The settings card: what goes on it, and what deliberately does not.
|
|
|
|
The card exists to answer "which model was that?" months later, so the tests
|
|
that matter are about honesty -- a wired input must not be printed as if
|
|
someone typed it, and a value that is a list (a LoRA stack, say) must come
|
|
out as rows rather than as a wall of JSON.
|
|
|
|
Ported from v4's movie_editor/tests/test_settings_card.py, cut down the same
|
|
way core/settings_card.py itself was: no FunUIStudio/Chain-Sampler shape to
|
|
test here, since v5's pipeline has no such nodes -- every slot is walked the
|
|
same generic way, so there is one section kind, not several.
|
|
"""
|
|
import json
|
|
|
|
from core import settings_card as sc
|
|
|
|
HOST = {
|
|
"python": "3.12.4",
|
|
"comfyui": "0.30.1",
|
|
"torch": {"version": "2.12.0+cu128", "cuda": "12.8", "attention": "SageAttention 3.0"},
|
|
"gpus": [{"name": "RTX PRO 6000", "vram_gb": 95.0, "capability": "sm_120"}],
|
|
}
|
|
|
|
|
|
def _rows(report, title):
|
|
return dict(next(s for s in report["sections"] if s["title"] == title)["rows"])
|
|
|
|
|
|
# --- what is on the card --------------------------------------------------------------
|
|
|
|
|
|
def test_full_filenames_are_not_abbreviated():
|
|
"""A truncated checkpoint name is the one thing that makes the card useless."""
|
|
name = "MiniMax-H3-fl2va-bf16-Q6_K-a-really-long-name.gguf"
|
|
rep = sc.collect(
|
|
[{"id": "model", "node": "FunUIDiffusionModelLoader",
|
|
"inputs": {"model_name": name, "attention": "sage3"}}], HOST)
|
|
assert _rows(rep, "model")["model_name"] == name
|
|
|
|
|
|
def test_host_carries_torch_cuda_and_attention():
|
|
rep = sc.collect([], HOST)
|
|
host = dict(rep["host"])
|
|
assert host["PyTorch"] == "2.12.0+cu128"
|
|
assert host["CUDA"] == "12.8"
|
|
assert host["Attention"] == "SageAttention 3.0"
|
|
assert "sm_120" in host["GPU"] and "95.0 GB" in host["GPU"]
|
|
|
|
|
|
def test_a_cpu_only_box_says_so_rather_than_showing_nothing():
|
|
"""An absent GPU row reads as 'the probe failed'. Naming the absence does not."""
|
|
rep = sc.collect([], {"torch": {}, "gpus": []})
|
|
assert "none visible" in dict(rep["host"])["GPU"]
|
|
|
|
|
|
def test_lists_come_out_as_rows_and_settings():
|
|
"""A funui_list widget's value is one JSON string. Printing it raw would
|
|
technically be complete and practically unreadable."""
|
|
rows = json.dumps([{"lora": "h3_turbo.safetensors", "strength": 1.0, "on": True},
|
|
{"lora": "style.safetensors", "strength": 0.65, "on": False}])
|
|
rep = sc.collect(
|
|
[{"id": "l", "node": "FunUILoraLoader", "inputs": {"lora_list": rows}}], HOST)
|
|
text = " ".join(v for _, v in rep["sections"][0]["rows"])
|
|
assert "h3_turbo.safetensors" in text and "strength=1.0" in text
|
|
assert "style.safetensors" in text and "strength=0.65" in text
|
|
assert "on=off" in text # a disabled row is still on the card, marked disabled
|
|
|
|
|
|
def test_an_empty_list_says_none_instead_of_vanishing():
|
|
rep = sc.collect([{"id": "l", "node": "X", "inputs": {"lora_list": "[]"}}], HOST)
|
|
assert any("(none)" in v for _, v in rep["sections"][0]["rows"])
|
|
|
|
|
|
def test_a_string_that_merely_looks_like_json_is_left_alone():
|
|
"""A prompt beginning with '[' must not be mangled into fake list rows."""
|
|
rep = sc.collect([{"id": "n", "node": "X", "inputs": {"text": "[not json at all"}}], HOST)
|
|
assert _rows(rep, "n")["text"] == "[not json at all"
|
|
|
|
|
|
def test_custom_nodes_appear_with_their_class():
|
|
"""The class is what someone reproducing this has to go and install."""
|
|
rep = sc.collect([{"id": "n1", "node": "ImageUpscaleWithModel",
|
|
"inputs": {"factor": 2.0}}], HOST)
|
|
assert rep["sections"][0]["node_class"] == "ImageUpscaleWithModel"
|
|
assert _rows(rep, "n1")["factor"] == "2.0"
|
|
|
|
|
|
def test_a_group_prefixes_the_section_title():
|
|
rep = sc.collect([{"id": "clip", "node": "X", "group": "Loaders", "inputs": {}}], HOST)
|
|
assert rep["sections"][0]["title"] == "Loaders · clip"
|
|
|
|
|
|
# --- what must NOT be presented as a typed value --------------------------------------
|
|
|
|
|
|
def test_a_wired_input_is_marked_wired_not_its_stale_widget_value():
|
|
"""v5 encodes a wire directly in the input's own value -- [source_id, index] -- rather
|
|
than a separate input_sources/wires lookup. Printing it as a typed value would be a
|
|
confident lie: the widget behind a connected socket keeps its last typed value, and
|
|
generation ignores it."""
|
|
rep = sc.collect(
|
|
[{"id": "up", "node": "X", "inputs": {}},
|
|
{"id": "down", "node": "Y", "inputs": {"model": ["up", 0], "steps": 8}}], HOST)
|
|
rows = _rows(rep, "down")
|
|
assert "wired from up" in rows["model"]
|
|
assert rows["steps"] == "8"
|
|
|
|
|
|
def test_control_after_generate_is_dropped():
|
|
"""It is a UI affordance, not a setting -- it says nothing about the render."""
|
|
rep = sc.collect(
|
|
[{"id": "a", "node": "X", "inputs": {"seed": 42, "control_after_generate": "randomize"}}],
|
|
HOST)
|
|
assert "control_after_generate" not in _rows(rep, "a")
|
|
|
|
|
|
def test_booleans_read_as_on_off_not_python():
|
|
rep = sc.collect(
|
|
[{"id": "a", "node": "X", "inputs": {"sla": True, "fp16_accumulation": False}}], HOST)
|
|
rows = _rows(rep, "a")
|
|
assert rows["sla"] == "on" and rows["fp16_accumulation"] == "off"
|
|
|
|
|
|
# --- bounding what an unbounded client can force this route to render ------------------
|
|
|
|
|
|
def test_a_very_long_value_is_truncated_not_rendered_whole():
|
|
"""extensive_testing found a single 200k-char value forced a 77,400px-tall
|
|
image and a multi-second render -- this machine's own GPU box, reachable
|
|
over a rental's plain-http IP with no auth, same as every other pipeline
|
|
route. A card exists to be a compact summary; nobody reads a 200k-char
|
|
row on it, so truncating is a real limit, not a corner cut short."""
|
|
rep = sc.collect([{"id": "a", "node": "X", "inputs": {"text": "x" * 200_000}}], HOST)
|
|
value = _rows(rep, "a")["text"]
|
|
assert len(value) < 500
|
|
assert "more chars" in value
|
|
|
|
|
|
def test_a_pipeline_with_an_unreasonable_slot_count_is_capped_not_rendered_whole():
|
|
slots = [{"id": f"s{i}", "node": "X", "inputs": {"a": "v"}} for i in range(5000)]
|
|
rep = sc.collect(slots, HOST)
|
|
total_rows = sum(len(s["rows"]) for s in rep["sections"])
|
|
assert total_rows <= sc._MAX_ROWS + 1 # +1 for the truncation note's own row
|
|
assert "omitted" in rep["sections"][-1]["rows"][0][1]
|
|
|
|
|
|
def test_a_funui_list_with_an_unreasonable_row_count_is_capped():
|
|
import json
|
|
rows = json.dumps([{"i": i} for i in range(5000)])
|
|
rep = sc.collect([{"id": "l", "node": "X", "inputs": {"lora_list": rows}}], HOST)
|
|
text = " ".join(v for _, v in rep["sections"][0]["rows"])
|
|
assert "more rows omitted" in text
|
|
|
|
|
|
def test_many_funui_lists_on_one_slot_still_respect_the_card_wide_cap():
|
|
"""extensive_testing round 2: the per-card check used to fire once per
|
|
SLOT, so one slot with many funui_list inputs -- each individually
|
|
capped at _MAX_ROWS by _rows_from_list -- blew the card-wide budget by a
|
|
large multiple before the next slot's check ever ran. 50 inputs x 2000
|
|
rows each used to produce 100,050 total rows from a single slot."""
|
|
import json
|
|
big_list = json.dumps([{"i": i} for i in range(2000)])
|
|
inputs = {f"list_{n}": big_list for n in range(50)}
|
|
rep = sc.collect([{"id": "s", "node": "X", "inputs": inputs}], HOST)
|
|
total_rows = sum(len(s["rows"]) for s in rep["sections"])
|
|
assert total_rows <= sc._MAX_ROWS + 5
|
|
assert "truncated" in rep["sections"][-1]["title"]
|
|
|
|
|
|
def test_render_png_stays_fast_even_when_both_caps_are_individually_honored():
|
|
"""extensive_testing round 2: a request that honors BOTH _MAX_ROWS and
|
|
_MAX_VALUE_CHARS exactly -- 2000 rows of 400-char, space-free values --
|
|
still cost ~14s and ~10MB, because nothing bounded their product. This is
|
|
the test the round-1 fix should have had: it actually calls render_png,
|
|
not just collect."""
|
|
import time
|
|
slots = [{"id": f"s{i}", "node": "X", "inputs": {"a": "x" * sc._MAX_VALUE_CHARS}}
|
|
for i in range(2000)]
|
|
rep = sc.collect(slots, HOST)
|
|
start = time.monotonic()
|
|
png = sc.render_png(rep, "dark")
|
|
elapsed = time.monotonic() - start
|
|
assert elapsed < 5.0
|
|
assert len(png) < 5_000_000
|
|
|
|
|
|
def test_a_funui_list_row_with_many_keys_does_not_blow_up_the_embed():
|
|
"""extensive_testing round 3: _short() bounded each KEY's value but not
|
|
the JOINED row -- a dict with 150 keys x 400-char values produced one
|
|
row string with no cap of its own. Row count and per-key length both
|
|
stayed under budget while the report JSON embedded in the PNG's tEXt
|
|
chunk (which scales with it) reached 123MB, with zero truncation
|
|
declared anywhere on the card."""
|
|
import json
|
|
import time
|
|
entry = {f"k{i}": "x" * sc._MAX_VALUE_CHARS for i in range(150)}
|
|
big_list = json.dumps([entry for _ in range(2000)])
|
|
rep = sc.collect([{"id": "s", "node": "X", "inputs": {"list_1": big_list}}], HOST)
|
|
text = " ".join(v for _, v in rep["sections"][0]["rows"])
|
|
assert len(text) < 2_000_000
|
|
start = time.monotonic()
|
|
png = sc.render_png(rep, "dark")
|
|
elapsed = time.monotonic() - start
|
|
assert elapsed < 5.0
|
|
assert len(png) < 5_000_000
|
|
|
|
|
|
def test_many_empty_input_slots_with_huge_ids_do_not_blow_up_the_embed():
|
|
"""extensive_testing round 4: a slot with no inputs contributes zero
|
|
rows, so total_rows never bounded how many SLOTS get processed -- 3000
|
|
slots with empty inputs and 4000-char id/group strings reached 193.6s
|
|
render time and a 12.1MB embedded JSON, with total_rows at 0 the whole
|
|
way (every existing row-based cap stayed dormant). Also covers
|
|
project_name, which reached the "title" block the same uncapped way."""
|
|
import time
|
|
slots = [{"id": "X" * 4000, "group": "Y" * 4000, "node": "N", "inputs": {}}
|
|
for _ in range(600)]
|
|
rep = sc.collect(slots, HOST, project_name="P" * 4000)
|
|
assert len(rep["project"]) < 500
|
|
start = time.monotonic()
|
|
png = sc.render_png(rep, "dark")
|
|
elapsed = time.monotonic() - start
|
|
assert elapsed < 5.0
|
|
assert len(png) < 5_000_000
|
|
|
|
from PIL import Image
|
|
embedded = Image.open(__import__("io").BytesIO(png)).text["funui_settings"]
|
|
assert len(embedded) < 2_000_000
|
|
|
|
|
|
def test_a_huge_input_name_is_capped_not_embedded_whole():
|
|
"""The `inputs` dict's KEYS are caller data too, not just its values --
|
|
found during round-4 cleanup by re-reading collect()'s row loop, not by
|
|
an attacker report. Every row carries `name` verbatim into the drawn
|
|
label and the embedded JSON, so it needs the same cap as any value."""
|
|
rep = sc.collect([{"id": "s", "node": "X", "inputs": {"k" * 50_000: "v"}}], HOST)
|
|
key = rep["sections"][0]["rows"][0][0]
|
|
assert len(key) < 500
|
|
|
|
|
|
def test_a_huge_funui_list_dict_key_is_capped_not_just_its_value():
|
|
"""Same gap, one level deeper: _rows_from_list joins `f"{k}={_short(v)}"`
|
|
per dict key in a funui_list row -- _short() bounded v but not k, so a
|
|
huge key still got fully materialized into the join before the final
|
|
_short() on the joined string ever ran."""
|
|
rows = json.dumps([{("k" * 500_000): "v"}])
|
|
rep = sc.collect([{"id": "l", "node": "X", "inputs": {"lora_list": rows}}], HOST)
|
|
text = " ".join(v for _, v in rep["sections"][0]["rows"])
|
|
assert len(text) < 2000
|
|
|
|
|
|
def test_render_png_stays_fast_with_many_slots_all_at_the_head_field_cap():
|
|
"""extensive_testing round 5: row VALUES are wrapped to card width before
|
|
drawing, but "head" block titles (built from slot id/group/node, each
|
|
individually _cap()-allowed up to 400 chars) were drawn as one unwrapped
|
|
string -- PIL's render cost scales with string length regardless of
|
|
visible width, so this cost 20.5s even with every documented cap
|
|
honored and slot count at exactly _MAX_ROWS."""
|
|
import time
|
|
slots = [{"id": "I" * 400, "group": "G" * 400, "node": "N" * 400, "inputs": {}}
|
|
for _ in range(sc._MAX_ROWS)]
|
|
rep = sc.collect(slots, HOST)
|
|
start = time.monotonic()
|
|
png = sc.render_png(rep, "dark")
|
|
elapsed = time.monotonic() - start
|
|
assert elapsed < 5.0
|
|
assert len(png) < 5_000_000
|
|
|
|
|
|
def test_render_png_stays_fast_when_one_slot_has_many_large_lists():
|
|
"""The Finding B composition gap, carried all the way through to the
|
|
actual render (67.8s / 123MB before the fix)."""
|
|
import json
|
|
import time
|
|
big_list = json.dumps([{"i": i} for i in range(2000)])
|
|
inputs = {f"list_{n}": big_list for n in range(50)}
|
|
rep = sc.collect([{"id": "s", "node": "X", "inputs": inputs}], HOST)
|
|
start = time.monotonic()
|
|
png = sc.render_png(rep, "dark")
|
|
elapsed = time.monotonic() - start
|
|
assert elapsed < 5.0
|
|
assert len(png) < 5_000_000
|
|
|
|
|
|
# --- rendering --------------------------------------------------------------------------
|
|
|
|
|
|
def test_render_png_produces_a_real_png_with_the_report_embedded():
|
|
rep = sc.collect(
|
|
[{"id": "model", "node": "FunUIDiffusionModelLoader",
|
|
"inputs": {"model_name": "h3.safetensors"}}], HOST, project_name="My project")
|
|
png = sc.render_png(rep, "dark")
|
|
assert png[:8] == b"\x89PNG\r\n\x1a\n"
|
|
|
|
from PIL import Image
|
|
img = Image.open(__import__("io").BytesIO(png))
|
|
assert img.format == "PNG"
|
|
embedded = json.loads(img.text["funui_settings"])
|
|
assert embedded["project"] == "My project"
|
|
assert embedded["sections"][0]["rows"] == [("model_name", "h3.safetensors")] or \
|
|
embedded["sections"][0]["rows"] == [["model_name", "h3.safetensors"]]
|
|
|
|
|
|
def test_light_theme_is_a_real_choice_not_a_silent_fallback():
|
|
rep = sc.collect([], HOST)
|
|
dark = sc.render_png(rep, "dark")
|
|
light = sc.render_png(rep, "light")
|
|
assert dark != light
|
|
|
|
|
|
def test_an_unknown_theme_falls_back_to_dark_rather_than_crashing():
|
|
rep = sc.collect([], HOST)
|
|
sc.render_png(rep, "not-a-real-theme") # must not raise
|