Files
BehzadandClaude Opus 5 26b02e86f0 feat(picker): copy prompt, actionable errors, and a library lane worth reading
Release 1.1.0. Everything here came out of using the style picker and finding
the same shape of problem in several places: the connector showed the REST
API's own words to someone who cannot act on them, or showed nothing at all.

Errors, in one place. `friendly_error`/`short_error` (styleref_api.py) rewrite
an API failure as something a node graph can act on — the Login node's Sign in
button, the Search styles… dialog, generate-it-once for a 409 — and Load and
Apply re-raise through them. The raw 401 told users to send an
`Authorization: Bearer` header into ComfyUI's red error window, which has no
buttons at all. `short_error` is the same failure at widget width; the
"loaded style" line used to render the whole paragraph uncut, because the
length guard sat on the success branch only.

The picker:
- Sign in moved to the title row. It changes what the whole dialog can show,
  so it is not a property of one list.
- "My styles" hides itself when empty, and its rows now carry the thumbnail,
  category and tags a gallery row carries — with visibility (Private /
  Link-shared / Public) where a gallery row carries its author. A name on a
  blank square sat directly above real cards.
- The gallery lane drops styles you own, so a published style of yours appears
  once rather than in both lanes.
- No matches now offers /styles/create instead of a dead end.
- ↓/↑ walk the rows, Enter takes the top hit, Esc closes; query, category and
  sort survive between opens.

Load hides `search`/`category` when the dialog is available — they are the
headless fallback, and next to a working button they are the same duplication
the Login node's `action` dropdown was removed for. Picking also clears the
query, which otherwise made every later execution run a search it did not need.

Apply gains a Copy prompt button. Four of its six targets exist to be copied
out of ComfyUI, and the only way to read one in full was to wire up a Preview
Any node. The warning badge now matches any text encoder, not the exact class
`CLIPTextEncode` — SDXL, SD3 and Flux ship their own, so it did nothing on most
real graphs.

Reference Images no longer fails the queue when a style has no inspiration
images: it feeds IPAdapter or a preview, never the sampler, so it reports and
returns an empty batch. `max_images: 0` mutes it without rewiring.

Needs the matching StyleRef-Webapp deploy: the library lane's category, tags
and heroImage come from /api/v1/me/styles, and its search now scores the same
fields the gallery search scores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:22:01 +02:00

805 lines
30 KiB
Python

"""Node behaviour tests. HTTP is mocked throughout — no network in CI."""
from __future__ import annotations
import importlib.util
import math
import os
import pytest
import styleref_api as api
from styleref_api import StyleRefError
from styleref_nodes import apply as apply_node
from styleref_nodes import load as load_node
from styleref_nodes.login import StyleRefLogin
from styleref_style import make_style
STYLE = make_style(
ref="abc-123",
name="Real Moments",
author="@styleref",
url="https://styleref.io/share/abc-123",
sections={
"guardrails": {"values": {"avoided_visuals": "lens flares; film grain"}},
"colors": {"values": {"color_palette": {"solidColors": [{"hex": "#FFFFFF"}]}}},
},
)
@pytest.fixture(autouse=True)
def clear_cache():
load_node._CACHE.clear()
load_node._GENERATION.clear()
yield
load_node._CACHE.clear()
load_node._GENERATION.clear()
# ── pack registration ────────────────────────────────────────────────────────
def test_pack_registers_exactly_the_five_nodes():
"""
Extraction moved to the web app: the pack registers
Load/Apply/Facets/ReferenceImages/Login and nothing else — an Extract node
here means the removal regressed.
"""
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
spec = importlib.util.spec_from_file_location("styleref_pack", os.path.join(root, "__init__.py"))
pack = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pack)
assert set(pack.NODE_CLASS_MAPPINGS) == {
"StyleRefLoad",
"StyleRefApply",
"StyleRefFacets",
"StyleRefReferenceImages",
"StyleRefLogin",
}
assert set(pack.NODE_DISPLAY_NAME_MAPPINGS) == set(pack.NODE_CLASS_MAPPINGS)
# ── Apply: target mapping ────────────────────────────────────────────────────
def test_targets_are_exactly_the_web_apps_copy_list_in_order():
"""
The target list mirrors the web app's copy-box format list exactly — same
entries, same order — so a style means the same thing here as it does on
styleref.io. The web app's list is the source of truth; this test is what
catches the two drifting apart.
"""
assert list(apply_node.TARGETS) == [
"ai_tools",
"style_md",
"flux",
"midjourney",
"diffusion",
"json",
]
assert apply_node.TARGET_FORMATS == {
"ai_tools": "default",
"style_md": "stylemd",
"flux": "flux",
"midjourney": "midjourney",
"diffusion": "diffusion",
"json": "json",
}
def test_only_sampler_formats_escape_the_copy_out_badge():
"""flux and diffusion are the only targets that belong in a CLIP encoder."""
assert set(apply_node.COPY_OUT_TARGETS) == {"ai_tools", "style_md", "midjourney", "json"}
assert set(apply_node.TARGETS) - set(apply_node.COPY_OUT_TARGETS) == {"flux", "diffusion"}
assert set(apply_node.DOCUMENT_TARGETS) == {"style_md", "json"}
def test_every_advertised_target_has_a_format():
assert set(apply_node.TARGETS) == set(apply_node.TARGET_FORMATS)
# ── Apply: composition ───────────────────────────────────────────────────────
def test_subject_is_substituted_into_the_servers_slot():
"""
The compiled spec marks where the subject belongs for that target. Using the
slot beats any client-side ordering guess.
"""
style_text = f"Style: photography. {apply_node.SUBJECT_PLACEHOLDER}. Muted palette."
out = apply_node.compose_prompt("a lighthouse", style_text)
assert "a lighthouse" in out
assert apply_node.SUBJECT_PLACEHOLDER not in out
def test_placeholder_never_survives_into_a_prompt():
"""
The regression that matters: a leaked placeholder is encoded as literal text,
so the model is told to draw the words "YOUR SUBJECT".
"""
style_text = f"A photo. {apply_node.SUBJECT_PLACEHOLDER}. Natural light."
for subject in ("a lighthouse", "", " "):
assert "YOUR SUBJECT" not in apply_node.compose_prompt(subject, style_text)
def test_stripping_the_placeholder_leaves_clean_punctuation():
stripped = apply_node.strip_subject_placeholder(
f"A photo. {apply_node.SUBJECT_PLACEHOLDER}. Natural light."
)
assert stripped == "A photo. Natural light."
def test_subject_comes_first_when_there_is_no_slot():
"""Fallback ordering: early tokens carry the most weight."""
out = apply_node.compose_prompt("a lighthouse", "muted palette")
assert out.startswith("a lighthouse")
def test_subject_first_can_be_flipped():
out = apply_node.compose_prompt("a lighthouse", "muted palette", subject_first=False)
assert out.startswith("muted palette")
def test_composition_handles_an_empty_side():
assert apply_node.compose_prompt("", "style only") == "style only"
assert apply_node.compose_prompt("subject only", "") == "subject only"
def test_negative_merges_guardrails_and_extra_only():
"""
No injected boilerplate: the style's guardrails are the authority, and
anything generic belongs in extra_negative, where the user can see it.
"""
negative = apply_node.compose_negative(STYLE, "extra thing")
assert "lens flares" in negative
assert "extra thing" in negative
assert "watermark" not in negative, "no hidden base-negative boilerplate"
def test_negative_is_the_normalized_guardrail_terms_without_extra():
assert apply_node.compose_negative(STYLE, "") == "lens flares, film grain"
def test_negative_is_empty_for_a_style_without_guardrails():
assert apply_node.compose_negative(make_style(ref="x", name="Bare"), "") == ""
# ── Apply: caching ───────────────────────────────────────────────────────────
def test_compile_requests_the_raw_form(monkeypatch):
"""
The metadata header and attribution line are markdown for humans; sent to a
text encoder they are just wasted, misleading tokens.
"""
seen = {}
def fake_get(ref, fmt="default", compact=False, raw=False, sections=None):
seen["raw"] = raw
return "spec"
monkeypatch.setattr(api, "get_style_text", fake_get)
apply_node.compile_style(dict(STYLE, compiled={}), "flux", False)
assert seen["raw"] is True
def test_compile_keeps_the_header_for_style_md(monkeypatch):
"""STYLE.md is a document — its metadata header is part of it, not noise."""
seen = {}
def fake_get(ref, fmt="default", compact=False, raw=False, sections=None):
seen["fmt"], seen["raw"] = fmt, raw
return "# My Style — STYLE.md"
monkeypatch.setattr(api, "get_style_text", fake_get)
apply_node.compile_style(dict(STYLE, compiled={}), "style_md", False)
assert seen["fmt"] == "stylemd"
assert seen["raw"] is False
def test_style_md_target_ships_the_document_untouched(monkeypatch):
"""A subject must not be prepended to a STYLE.md document."""
doc = "# My Style\n\n## Colors\n- warm"
monkeypatch.setattr(api, "get_style_text", lambda *a, **k: doc)
node = apply_node.StyleRefApply()
out = node.apply(dict(STYLE, compiled={}), "a lighthouse", "style_md")
positive, _negative, _attr = out["result"]
assert positive == doc
def test_compiled_output_is_cached_per_target(monkeypatch):
"""Re-queueing with a new subject must not re-fetch an unchanged style."""
calls = {"n": 0}
def fake_get(ref, fmt="default", compact=False, raw=False, sections=None):
calls["n"] += 1
return f"compiled-{fmt}"
monkeypatch.setattr(api, "get_style_text", fake_get)
style = dict(STYLE, compiled={})
assert apply_node.compile_style(style, "flux", False) == "compiled-flux"
assert apply_node.compile_style(style, "flux", False) == "compiled-flux"
assert calls["n"] == 1, "second call should hit the cache"
# A different target is a genuinely different compilation.
apply_node.compile_style(style, "diffusion", False)
assert calls["n"] == 2
# …and so is compact, which is why it is part of the key.
apply_node.compile_style(style, "flux", True)
assert calls["n"] == 3
def test_apply_rejects_a_non_style_input():
node = apply_node.StyleRefApply()
with pytest.raises(StyleRefError, match="not a StyleRef style"):
node.apply({"nonsense": True}, "subject", "flux")
def test_apply_rejects_an_unloaded_style():
node = apply_node.StyleRefApply()
with pytest.raises(StyleRefError, match="No style is loaded"):
node.apply(make_style(ref="", name="(none)"), "subject", "flux")
# ── Apply: node-body feedback ────────────────────────────────────────────────
def test_apply_returns_ui_text_and_unchanged_result_tuple(monkeypatch):
"""The ui.text preview must not change the outputs' order or arity."""
monkeypatch.setattr(api, "get_style_text", lambda *a, **k: "muted palette, soft light")
node = apply_node.StyleRefApply()
out = node.apply(dict(STYLE, compiled={}), "a lighthouse", "diffusion")
assert set(out) == {"ui", "result"}
assert isinstance(out["ui"]["text"][0], str)
positive, negative, attribution_line = out["result"]
assert positive.startswith("a lighthouse")
assert negative == "lens flares, film grain"
assert "Real Moments" in attribution_line
def test_token_estimate_is_chars_over_four():
assert apply_node.estimate_tokens("x" * 400) == 100
def test_preview_shows_estimate_and_compact_hint_for_diffusion():
long_prompt = "word " * 200 # ~250 tokens
preview = apply_node.preview_text(long_prompt, "diffusion", compact=False)
assert "tokens (diffusion)" in preview
assert "consider `compact`" in preview
def test_preview_hint_is_absent_for_flux_and_when_already_compact():
long_prompt = "word " * 200
assert "consider" not in apply_node.preview_text(long_prompt, "flux", compact=False)
assert "consider" not in apply_node.preview_text(long_prompt, "diffusion", compact=True)
def test_preview_truncates_the_prompt_body():
preview = apply_node.preview_text("x" * 1000, "flux", compact=False)
first_line = preview.split("\n")[0]
assert len(first_line) <= apply_node.PREVIEW_CHARS + 1 # + ellipsis
# ── Load ─────────────────────────────────────────────────────────────────────
def test_load_requires_a_ref_or_a_search():
node = load_node.StyleRefLoad()
with pytest.raises(StyleRefError, match="No style reference"):
node.load("", "")
def test_fetch_style_caches_by_ref(monkeypatch):
calls = {"n": 0}
def fake_spec(ref, etag=None):
calls["n"] += 1
return {"name": "Cached", "sections": {}}, "https://styleref.io/share/abc", 'W/"e1"'
monkeypatch.setattr(api, "get_style_spec", fake_spec)
load_node.fetch_style("abc")
load_node.fetch_style("abc")
assert calls["n"] == 1
load_node.fetch_style("abc", use_cache=False)
assert calls["n"] == 2
def test_bump_refresh_drops_the_cache_and_dirties_is_changed(monkeypatch):
"""
The ↻ Refresh style button POSTs /styleref/refresh → bump_refresh(). That
must both forget the cached style AND change IS_CHANGED's key, or ComfyUI
would serve its own cached outputs without ever calling the node.
"""
calls = {"n": 0}
def fake_spec(ref, etag=None):
calls["n"] += 1
return {"name": "Fresh", "sections": {}}, None, 'W/"e1"'
monkeypatch.setattr(api, "get_style_spec", fake_spec)
load_node.fetch_style("abc")
load_node.fetch_style("abc")
assert calls["n"] == 1
key_before = load_node.StyleRefLoad.IS_CHANGED("abc")
load_node.bump_refresh("abc")
key_after = load_node.StyleRefLoad.IS_CHANGED("abc")
assert key_before != key_after
load_node.fetch_style("abc")
assert calls["n"] == 2
# No further bump → cached again.
load_node.fetch_style("abc")
assert calls["n"] == 2
def test_attribution_url_comes_from_the_servers_canonical_header(monkeypatch):
"""
The share URL is the server's, not a client guess: `{SITE_URL}/share/{ref}`
is only right for a bare share slug and breaks for an id or a URL ref.
"""
monkeypatch.setattr(
api,
"get_style_spec",
lambda ref, etag=None: ({"name": "Warm", "sections": {}}, "https://styleref.io/share/real-slug", None),
)
style = load_node.fetch_style("@ada/Warm Editorial")
assert style["url"] == "https://styleref.io/share/real-slug"
def test_private_refs_are_tagged_as_library(monkeypatch):
# Even when the server hands back a canonical URL, a private style is not
# attributed to a public link.
monkeypatch.setattr(
api,
"get_style_spec",
lambda ref, etag=None: ({"name": "Mine", "sections": {}}, "https://styleref.io/styles/abc", None),
)
style = load_node.fetch_style("Mine")
assert style["source"] == "library"
assert style["url"] is None
def test_load_rewrites_a_never_generated_409_for_comfyui(monkeypatch):
"""
The published contract says connectors render the 409 message verbatim.
That message tells the user to open styleref.io and Generate — true, but it
cannot mention the Search styles… dialog, which is where this user already
saw the style marked "not generated". The connector adds that half and keeps
the instruction; the status and code survive so the picker can still label it.
"""
def raise_409(ref, etag=None):
raise StyleRefError(
"This StyleRef hasn't been generated yet — open it on styleref.io and "
"Generate it first.",
status=409,
code="styleref_not_generated",
)
monkeypatch.setattr(api, "get_style_spec", raise_409)
load_node._CACHE.clear()
with pytest.raises(StyleRefError) as caught:
load_node.StyleRefLoad().load("Never Generated")
assert "never been generated" in caught.value.message
assert "Search styles…" in caught.value.message
assert caught.value.status == 409
assert caught.value.code == "styleref_not_generated"
def test_load_rewrites_a_401_into_something_clickable(monkeypatch):
"""
The exact defect this exists for: the API's 401 body reached the red error
window telling the user to set an HTTP header. There is no header to set in
a node graph — there is a Sign in button.
"""
def raise_401(ref, etag=None):
raise StyleRefError(
"Sign in to StyleRef to load this style. Run `npx styleref login`. "
"Setup guide: https://docs.styleref.io/for-ai-agents/mcp/…",
status=401,
)
monkeypatch.setattr(api, "get_style_spec", raise_401)
load_node._CACHE.clear()
with pytest.raises(StyleRefError) as caught:
load_node.StyleRefLoad().load("abc")
assert "StyleRef Login" in caught.value.message
assert "npx styleref login" not in caught.value.message
assert caught.value.status == 401, "the status must survive for /styleref/resolve"
def test_apply_rewrites_an_expired_session(monkeypatch):
"""A session can die between Load and Apply — same rewrite, same reason."""
monkeypatch.setattr(
api,
"get_style_text",
lambda *a, **k: (_ for _ in ()).throw(
StyleRefError("Send an Authorization: Bearer header.", status=401)
),
)
style = make_style(ref="abc", name="Warm")
with pytest.raises(StyleRefError) as caught:
apply_node.StyleRefApply().apply(style, "a lighthouse", "diffusion")
assert "Sign in" in caught.value.message
assert "Authorization" not in caught.value.message
def test_load_returns_ui_text_and_result_tuple(monkeypatch):
"""The load confirmation renders in the node body."""
monkeypatch.setattr(
api, "get_style_spec", lambda ref, etag=None: ({"name": "Warm", "sections": {}}, None, None)
)
node = load_node.StyleRefLoad()
out = node.load("abc")
assert set(out) == {"ui", "result"}
assert "Loaded" in out["ui"]["text"][0]
style, results = out["result"]
assert style["name"] == "Warm"
assert results == ""
def test_load_never_fetches_style_text(monkeypatch):
"""
STYLE.md is a target on Apply, not an output on Load — so Load has no
style_doc output and must never spend the extra text-fetch call.
"""
monkeypatch.setattr(
api, "get_style_spec", lambda ref, etag=None: ({"name": "Warm", "sections": {}}, None, None)
)
monkeypatch.setattr(
api,
"get_style_text",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("Load must not fetch text")),
)
load_node.StyleRefLoad().load("abc")
assert len(load_node.StyleRefLoad.RETURN_NAMES) == 2
def test_load_is_an_output_node():
"""A search-only Load must be queueable alone and still display."""
assert load_node.StyleRefLoad.OUTPUT_NODE is True
def test_search_results_render_slugs_to_copy():
text = load_node.format_search_results(
{"styles": [{"slug": "abc-1", "name": "Warm", "author": "@ada", "tags": ["warm", "film"]}]}
)
assert "abc-1" in text and "Warm" in text and "@ada" in text
assert "style_ref" in text, "instructions must name the field as it is named now"
def test_empty_search_suggests_categories():
text = load_node.format_search_results({"styles": [], "availableCategories": ["Photography"]})
assert "Photography" in text
assert "gallery" in text
def test_search_failure_does_not_sink_the_run(monkeypatch):
"""A bad search must not fail a graph whose ref is perfectly valid."""
monkeypatch.setattr(
api, "search_styles", lambda *a, **k: (_ for _ in ()).throw(StyleRefError("boom"))
)
monkeypatch.setattr(api, "get_style_spec", lambda ref, etag=None: ({"name": "Fine", "sections": {}}, None, None))
node = load_node.StyleRefLoad()
out = node.load("abc", "query that fails")
style, results = out["result"]
assert style["name"] == "Fine"
assert "Search failed" in results
def test_search_category_is_forwarded(monkeypatch):
"""The vanilla category combo must reach the API; `any` must not."""
seen = {}
def fake_search(query, category=None, sort=None, limit=12):
seen["category"] = category
return {"styles": []}
monkeypatch.setattr(api, "search_styles", fake_search)
monkeypatch.setattr(api, "get_style_spec", lambda ref, etag=None: ({"name": "X", "sections": {}}, None, None))
node = load_node.StyleRefLoad()
node.load("abc", "warm", category="Photography")
assert seen["category"] == "Photography"
node.load("abc", "warm", category="any")
assert seen["category"] is None
# ── IS_CHANGED contracts ─────────────────────────────────────────────────────
def test_load_is_changed_is_stable_when_cached():
key1 = load_node.StyleRefLoad.IS_CHANGED("abc", "q", "any", True)
key2 = load_node.StyleRefLoad.IS_CHANGED("abc", "q", "any", True)
assert key1 == key2
def test_load_is_changed_reports_dirty_without_cache():
value = load_node.StyleRefLoad.IS_CHANGED("abc", use_cache=False)
assert isinstance(value, float) and math.isnan(value)
def test_login_is_always_dirty():
"""A cached "signed out" status would be wrong the moment a sign-in succeeded."""
value = StyleRefLogin.IS_CHANGED()
assert isinstance(value, float) and math.isnan(value)
def test_apply_has_no_is_changed_override():
"""
Apply's dirtiness must come from its inputs alone. An IS_CHANGED override
returning NaN here is the credit-burn/api-hammer class of bug —
the node would re-run on every queue.
"""
assert "IS_CHANGED" not in vars(apply_node.StyleRefApply)
# ── Apply: sections filter ───────────────────────────────────────────────────
def test_sections_filter_reaches_the_api_and_the_cache_key(monkeypatch):
calls = []
def fake_get(ref, fmt="default", compact=False, raw=False, sections=None):
calls.append(sections)
return f"compiled:{sections}"
monkeypatch.setattr(api, "get_style_text", fake_get)
style = dict(STYLE, compiled={})
apply_node.compile_style(style, "diffusion", False, "colors, light_shadow")
assert calls[-1] == "colors,light_shadow"
# Same trimmed sections → cache hit; different sections → new compile.
apply_node.compile_style(style, "diffusion", False, "colors,light_shadow")
assert len(calls) == 1
apply_node.compile_style(style, "diffusion", False, "")
assert len(calls) == 2
assert calls[-1] is None
# ── Facets: full schema coverage ─────────────────────────────────────────────
def test_facets_outputs_are_exactly_the_schema_sections_plus_custom_items():
"""
Facets mirrors the style board one-to-one — the schema's section list in
schema order, plus the six custom style items, and NOTHING derived (no
palette/primary_color/width/height/negative).
"""
from styleref_nodes import facets as facets_node
assert facets_node.SCHEMA_SECTIONS[0] == "output_format"
assert facets_node.SCHEMA_SECTIONS[-1] == "guardrails"
assert "inspiration_images" not in facets_node.SCHEMA_SECTIONS, (
"images are the Reference Images node's job"
)
names = facets_node.StyleRefFacets.RETURN_NAMES
assert names == tuple(facets_node.SCHEMA_SECTIONS) + tuple(facets_node.CUSTOM_ITEM_NAMES)
for derived in ("palette", "primary_color", "width", "height", "negative", "color_count"):
assert derived not in names
assert len(names) == len(facets_node.StyleRefFacets.RETURN_TYPES)
assert len(names) == len(facets_node.StyleRefFacets.OUTPUT_TOOLTIPS)
def test_facets_split_returns_one_value_per_output(monkeypatch):
from styleref_nodes import facets as facets_node
node = facets_node.StyleRefFacets()
out = node.split(STYLE)
assert len(out) == len(facets_node.StyleRefFacets.RETURN_NAMES)
by_name = dict(zip(facets_node.StyleRefFacets.RETURN_NAMES, out))
assert "avoided visuals: lens flares; film grain" in by_name["guardrails"]
assert "color palette" in by_name["colors"]
assert by_name["custom_style_item_3"] == ""
# ── Reference Images node ────────────────────────────────────────────────────
def _style_with_images(urls):
return make_style(
ref="img-style",
name="Imgs",
sections={
"inspiration_images": {
"values": {"inspiration_images": [{"url": u} for u in urls]}
}
},
)
def _png_bytes(width, height, color=(255, 0, 0)):
import io
from PIL import Image
buffer = io.BytesIO()
Image.new("RGB", (width, height), color).save(buffer, format="PNG")
return buffer.getvalue()
def test_reference_images_batches_and_counts(monkeypatch):
from styleref_nodes import reference_images as ri
ri._IMAGE_CACHE.clear()
blobs = {"https://a/1.png": _png_bytes(64, 32), "https://a/2.png": _png_bytes(100, 80)}
monkeypatch.setattr(api, "request_raw", lambda url, **k: (blobs[url], {}))
node = ri.StyleRefReferenceImages()
batch, count = node.fetch(_style_with_images(list(blobs)))["result"]
assert count == 2
# One batch = one resolution: the first image sets it, the rest resize.
assert batch.shape == (2, 32, 64, 3)
# IMAGE must be a torch tensor — PreviewImage/SaveImage call .cpu() on it.
# torch is not in the pack's dev extras, so this asserts only where it is
# installed; the node falls back to the ndarray otherwise.
torch = pytest.importorskip("torch")
assert isinstance(batch, torch.Tensor)
assert batch.dtype is torch.float32
assert float(batch.min()) >= 0.0 and float(batch.max()) <= 1.0
def test_reference_images_downloads_anonymously_and_caches(monkeypatch):
"""A bearer token must never go to storage hosts; repeats hit the cache."""
from styleref_nodes import reference_images as ri
ri._IMAGE_CACHE.clear()
calls = []
def fake_raw(url, anonymous=False, **_k):
calls.append(anonymous)
return _png_bytes(8, 8), {}
monkeypatch.setattr(api, "request_raw", fake_raw)
node = ri.StyleRefReferenceImages()
node.fetch(_style_with_images(["https://a/1.png"]))
node.fetch(_style_with_images(["https://a/1.png"]))
assert calls == [True], "one anonymous download, then the cache"
def test_reference_images_reports_missing_images_without_failing():
"""
A style with no inspiration images must not sink the queue.
This node feeds IPAdapter or a preview, never the sampler, so raising here
used to stop a render the user actually wanted over a decorative branch of
the graph. It reports in the node body and returns an empty batch instead.
"""
from styleref_nodes import reference_images as ri
out = ri.StyleRefReferenceImages().fetch(make_style(ref="x", name="Bare"))
batch, count = out["result"]
assert count == 0
assert batch.shape[0] == 0, "an empty batch, never a placeholder frame"
assert "no inspiration images" in out["ui"]["text"][0]
def test_reference_images_max_zero_mutes_the_node(monkeypatch):
"""max_images=0 is the mute switch — no downloads, no error, no images."""
from styleref_nodes import reference_images as ri
ri._IMAGE_CACHE.clear()
monkeypatch.setattr(
api, "request_raw", lambda *a, **k: pytest.fail("must not download when muted")
)
out = ri.StyleRefReferenceImages().fetch(
_style_with_images(["https://a/1.png"]), max_images=0
)
batch, count = out["result"]
assert count == 0
assert batch.shape[0] == 0
assert "max_images is 0" in out["ui"]["text"][0]
# ── Load: save_to_library ────────────────────────────────────────────────────
def test_save_to_library_saves_once_per_session(monkeypatch):
calls = {"n": 0}
monkeypatch.setattr(
api, "get_style_spec", lambda ref, etag=None: ({"name": "Warm", "sections": {}}, None, None)
)
def fake_save(ref):
calls["n"] += 1
return {"saved": True, "alreadySaved": False}
monkeypatch.setattr(api, "save_style", fake_save)
load_node._SAVED.clear()
node = load_node.StyleRefLoad()
out = node.load("abc", save_to_library=True)
assert "Saved to your library" in out["ui"]["text"][0]
node.load("abc", save_to_library=True)
assert calls["n"] == 1, "idempotent server-side, but don't spam it either"
load_node._SAVED.clear()
def test_save_failure_does_not_sink_the_run(monkeypatch):
monkeypatch.setattr(
api, "get_style_spec", lambda ref, etag=None: ({"name": "Warm", "sections": {}}, None, None)
)
monkeypatch.setattr(
api, "save_style", lambda ref: (_ for _ in ()).throw(StyleRefError("Sign in first"))
)
load_node._SAVED.clear()
out = load_node.StyleRefLoad().load("abc", save_to_library=True)
style, _results = out["result"]
assert style["name"] == "Warm"
assert "Could not save" in out["ui"]["text"][0]
def test_own_styles_are_not_re_saved(monkeypatch):
# A private style canonicalises to the builder — that, not the ref, is what
# marks it as the caller's own.
monkeypatch.setattr(
api,
"get_style_spec",
lambda ref, etag=None: ({"name": "Mine", "sections": {}}, "https://styleref.io/styles/abc", None),
)
monkeypatch.setattr(
api, "save_style", lambda ref: (_ for _ in ()).throw(AssertionError("must not be called"))
)
out = load_node.StyleRefLoad().load("Mine", save_to_library=True)
assert "Already your own style" in out["ui"]["text"][0]
# ── Load: ETag revalidation ──────────────────────────────────────────────────
def test_cache_off_revalidates_with_the_stored_etag(monkeypatch):
"""use_cache=False means "re-check", not "re-download": a 304 keeps the
cached style — including its compiled-prompt cache."""
seen = []
def fake_spec(ref, etag=None):
seen.append(etag)
if etag == 'W/"e1"':
return None, None, etag # 304
return {"name": "Warm", "sections": {}}, None, 'W/"e1"'
monkeypatch.setattr(api, "get_style_spec", fake_spec)
first = load_node.fetch_style("abc")
first["compiled"]["diffusion:full:"] = "cached-compilation"
again = load_node.fetch_style("abc", use_cache=False)
assert seen == [None, 'W/"e1"']
assert again is first, "304 must keep the exact cached payload"
assert again["compiled"]["diffusion:full:"] == "cached-compilation"
def test_changed_style_replaces_the_cache_on_revalidation(monkeypatch):
def fake_spec(ref, etag=None):
if etag is None:
return {"name": "V1", "sections": {}}, None, 'W/"e1"'
return {"name": "V2", "sections": {}}, None, 'W/"e2"'
monkeypatch.setattr(api, "get_style_spec", fake_spec)
load_node.fetch_style("abc")
fresh = load_node.fetch_style("abc", use_cache=False)
assert fresh["name"] == "V2"