Files
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
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
2026-10-25 13:11:03 +00:00

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