Files
DumiFlex-ComfyUI-Wildcard-P…/tests/engine/test_injector_handler.py
T
DumiFlex f8ac25f443 chore: pre-merge polish — kind-aware chips, injector + debug improvements, engine fixes
Omnibus polish landing the remaining unstaged work on the branch:

frontend
  - kind-aware RefChip — @{uuid} refs targeting non-wildcard library
    modules (constraint, combine, derivation, fixed_values, bundle) now
    render with the matching kind color + PrimeIcon instead of falling
    through as unresolved wildcards. cascade/resolveChip exposes
    resolveModuleChip; preview-resolver caches snap.type as kind;
    RichTextPreview accepts a uuid-to-kind map; RefChip honours the
    new moduleKind prop.
  - DebugViewer derives uuid-to-kind from trace entries so engine
    warnings (e.g. constraint_never_applied carrying @{constraint_id})
    render the constraint id as a colored constraint chip.
  - Injector node refactor: two-tier socket (template_subject /
    subject row pairs) + general-template row model; binding modal
    handles general rows; row reorder + cap; rows collapsed by
    default; sockets gain tooltips.
  - SPA misc: useStarterSet rebuilds cascade reverse-dep index on
    catalog refresh so just-created starters surface in cascade
    impact dialogs; tokens / resolveTokens / EmptyState height /
    AppLayout sidebar count live-refresh / ModuleListView mid-cols.

engine
  - constraint_handler: 3 dedup bugs in carrier-claim + classify the
    never_applied warning.
  - pipeline: detect via-nested constraint carriers; carrier-claim
    failsafe + clearer never_applied warning text.
  - syntax/resolve: nested @{uuid#name} optional segment recognised.

tests
  - DebugViewer / Injector / RefChip / resolveChip / preview-resolver
    test suites extended for the new behaviour.
  - Engine + integration tests adjusted for constraint dedup + new
    carrier path + nested-ref parsing.
2026-05-28 12:53:23 +03:00

431 lines
16 KiB
Python

"""Tests for WP_ContextInjector node handler."""
import json
from wp_nodes.injector_node import WPContextInjector
from wp_nodes.types import ContextPayload
def _empty_rows() -> str:
return json.dumps({"version": 1, "rows": []})
def test_empty_rows_forwards_upstream_ctx():
upstream = ContextPayload(context={"existing": "value"}, debug={}, internals={})
out = WPContextInjector.execute(wp_rows=_empty_rows(), upstream=upstream)
assert out.values[0].context == {"existing": "value"}
def _row(slot_name: str, binding: str, enabled: bool = True, internal: bool = False) -> dict:
return {
"_uid": f"uid_{slot_name}",
"slot_name": slot_name,
"binding": binding,
"enabled": enabled,
"internal": internal,
}
def test_enabled_named_connected_row_writes_to_ctx():
rows = json.dumps({"version": 1, "rows": [_row("input_0", "seed_phrase")]})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="hello world")
assert out.values[0].context["seed_phrase"] == "hello world"
def test_disabled_row_skipped():
rows = json.dumps({
"version": 1,
"rows": [_row("input_0", "seed_phrase", enabled=False)],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="ignored")
assert "seed_phrase" not in out.values[0].context
def test_empty_binding_row_skipped():
rows = json.dumps({"version": 1, "rows": [_row("input_0", "")]})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="ignored")
assert out.values[0].context == {}
def test_disconnected_slot_skipped():
rows = json.dumps({"version": 1, "rows": [_row("input_0", "seed_phrase")]})
out = WPContextInjector.execute(wp_rows=rows, upstream=None)
assert "seed_phrase" not in out.values[0].context
def test_non_primitive_value_stringified():
rows = json.dumps({"version": 1, "rows": [_row("input_0", "obj")]})
class Foo:
def __repr__(self) -> str:
return "<Foo instance>"
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0=Foo())
assert out.values[0].context["obj"] == "<Foo instance>"
def test_primitives_pass_through_unchanged():
rows = json.dumps({
"version": 1,
"rows": [
_row("input_0", "as_int"),
_row("input_1", "as_float"),
_row("input_2", "as_bool"),
_row("input_3", "as_str"),
],
})
out = WPContextInjector.execute(
wp_rows=rows, upstream=None,
input_0=42, input_1=7.5, input_2=True, input_3="text",
)
assert out.values[0].context["as_int"] == 42
assert out.values[0].context["as_float"] == 7.5
assert out.values[0].context["as_bool"] is True
assert out.values[0].context["as_str"] == "text"
def test_invalid_binding_chars_skipped_with_warning():
rows = json.dumps({"version": 1, "rows": [_row("input_0", "has spaces")]})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="value")
assert out.values[0].context == {}
warnings = out.values[0].debug.get("__wp_warnings__", [])
assert any(w.get("type") == "injector_invalid_binding" for w in warnings)
def test_reserved_binding_skipped_with_warning():
rows = json.dumps({"version": 1, "rows": [_row("input_0", "_meta")]})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="value")
assert out.values[0].context == {}
warnings = out.values[0].debug.get("__wp_warnings__", [])
assert any(w.get("type") == "injector_reserved_binding" for w in warnings)
def test_valid_bindings_with_underscore_inside_pass():
rows = json.dumps({"version": 1, "rows": [_row("input_0", "my_var")]})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="ok")
assert out.values[0].context["my_var"] == "ok"
def test_internal_flag_appends_to_internal_keys_set():
rows = json.dumps({
"version": 1,
"rows": [_row("input_0", "cfg", internal=True)],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0=7.5)
assert out.values[0].context["cfg"] == 7.5
keys = out.values[0].context.get("__wp_internal_keys__", [])
assert "cfg" in keys
def test_internal_flag_rides_internal_flags_for_assembler():
# The PromptAssembler strips on `internals["__wp_internal_flags__"]`, so an
# internal injector row must populate it (not just `__wp_internal_keys__`)
# or the var leaks into the rendered prompt.
rows = json.dumps({
"version": 1,
"rows": [_row("input_0", "cfg", internal=True), _row("input_1", "shown")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0=7.5, input_1="x")
flags = out.values[0].internals.get("__wp_internal_flags__", {})
assert flags.get("cfg") is True
assert "shown" not in flags
def test_non_internal_row_does_not_pollute_internal_keys():
rows = json.dumps({"version": 1, "rows": [_row("input_0", "public_var")]})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="x")
keys = out.values[0].context.get("__wp_internal_keys__", [])
assert "public_var" not in keys
def test_each_written_row_emits_trace_entry():
rows = json.dumps({
"version": 1,
"rows": [
_row("input_0", "a"),
_row("input_1", "b", internal=True),
],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="x", input_1="y")
trace = out.values[0].debug.get("__wp_trace__", [])
inj = [t for t in trace if t.get("node") == "WP_ContextInjector"]
assert len(inj) == 2
bindings = sorted(t.get("binding") for t in inj)
assert bindings == ["a", "b"]
assert any(t.get("internal") is True for t in inj)
# ─── Phase 6: per-row template substitution ──────────────────────────────────
def _row_with_template(slot_name: str, binding: str, template: str | None) -> dict:
return {
"_uid": f"uid_{slot_name}",
"slot_name": slot_name,
"binding": binding,
"enabled": True,
"internal": False,
"template": template,
}
def test_template_substitutes_self_slot_value():
rows = json.dumps({
"version": 1,
"rows": [_row_with_template("input_0", "phrase", "i love $input_0")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="puppies")
assert out.values[0].context["phrase"] == "i love puppies"
def test_socket_row_template_only_substitutes_own_slot():
# Two-tier model: a SOCKET row's template may reference ONLY its own
# socket. A `$input_0` ref from the row bound to input_1 is out of
# scope and stays literal — rows resolve top-to-bottom, so an early
# row must not read a later socket. (Use a GENERAL row to compose
# across sockets.)
rows = json.dumps({
"version": 1,
"rows": [
_row("input_0", "raw_name"),
_row_with_template("input_1", "greeting", "hello $input_0 / $input_1"),
],
})
out = WPContextInjector.execute(
wp_rows=rows, upstream=None, input_0="Alice", input_1="Bob"
)
# Only $input_1 (own slot) resolved; $input_0 left literal.
assert out.values[0].context["greeting"] == "hello $input_0 / Bob"
def test_template_unknown_ref_left_literal():
rows = json.dumps({
"version": 1,
"rows": [_row_with_template("input_0", "phrase", "value=$missing")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="x")
assert out.values[0].context["phrase"] == "value=$missing"
def test_template_double_dollar_escapes_to_literal():
rows = json.dumps({
"version": 1,
"rows": [_row_with_template("input_0", "phrase", "$$5.00 paid by $input_0")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="Bob")
assert out.values[0].context["phrase"] == "$5.00 paid by Bob"
def test_socket_row_template_renders_even_when_own_slot_unwired():
# A socket row's template path does NOT require its own slot to be
# wired — the row still renders. The own-slot ref resolves to empty
# (`slot_values.get(slot_name, "")`) when unwired.
rows = json.dumps({
"version": 1,
"rows": [_row_with_template("input_1", "shout", "[$input_1] IS HERE")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None)
assert out.values[0].context["shout"] == "[] IS HERE"
def test_template_empty_or_whitespace_falls_back_to_pass_through():
rows = json.dumps({
"version": 1,
"rows": [
_row_with_template("input_0", "a", ""),
_row_with_template("input_1", "b", " "),
],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="raw_a", input_1="raw_b")
assert out.values[0].context["a"] == "raw_a"
assert out.values[0].context["b"] == "raw_b"
def test_template_boolean_substitution_matches_python_str():
# Booleans render as Python's str(bool) -> "True"/"False" so the
# template path's output agrees with the pass-through path's
# downstream stringification semantics. Consistent casing avoids
# the "i love False" / "i love false" disparity when a row toggles
# between template + pass-through.
rows = json.dumps({
"version": 1,
"rows": [_row_with_template("input_0", "flag", "is_on=$input_0")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0=True)
assert out.values[0].context["flag"] == "is_on=True"
out2 = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0=False)
assert out2.values[0].context["flag"] == "is_on=False"
def test_template_trace_kind_marks_template_row():
rows = json.dumps({
"version": 1,
"rows": [_row_with_template("input_0", "phrase", "hi $input_0")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="x")
trace = out.values[0].debug.get("__wp_trace__", [])
template_traces = [t for t in trace if t.get("binding") == "phrase"]
assert template_traces
assert template_traces[0]["type"] == "str(template)"
def test_trace_carries_written_value_for_debug_viewer():
# Debug's trace tab needs the written value alongside the binding
# so the value column isn't blank for injector rows. Engine
# modules use `writes: [{value}]` for the same purpose; injector
# emits a flat top-level `value` (one write per row).
rows = json.dumps({
"version": 1,
"rows": [
_row("input_0", "a"),
_row_with_template("input_1", "b", "$input_1 boom"),
],
})
out = WPContextInjector.execute(
wp_rows=rows, upstream=None, input_0="alpha", input_1=42,
)
trace = out.values[0].debug.get("__wp_trace__", [])
by_binding = {t.get("binding"): t for t in trace}
assert by_binding["a"]["value"] == "alpha"
assert by_binding["b"]["value"] == "42 boom"
# ─── General-template rows (two-tier row model) ──────────────────────────────
def _general_row(
binding: str, template: str | None, enabled: bool = True, internal: bool = False
) -> dict:
return {
"_uid": f"gen_{binding}",
"kind": "general",
"binding": binding,
"template": template,
"enabled": enabled,
"internal": internal,
}
def test_general_row_resolves_against_sockets_and_socket_row_bindings():
# Socket row input_0 has a template bound to $test; a general row
# composes from BOTH $test (a socket-row output) AND $input_0 (the
# raw socket). This is the worked example from the spec.
rows = json.dumps({
"version": 1,
"rows": [
_row_with_template("input_0", "test", "i love $input_0"),
_general_row("combo", "$test, also $input_0"),
],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="puppies")
assert out.values[0].context["test"] == "i love puppies"
assert out.values[0].context["combo"] == "i love puppies, also puppies"
def test_general_row_runs_after_all_socket_rows():
# Even though the general row is listed FIRST in the array, it
# resolves AFTER the socket rows so it can read their bindings.
rows = json.dumps({
"version": 1,
"rows": [
_general_row("combo", "$a + $b"),
_row("input_0", "a"),
_row("input_1", "b"),
],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="X", input_1="Y")
assert out.values[0].context["combo"] == "X + Y"
def test_general_row_reads_earlier_general_row_binding():
# General rows resolve in order, so a later general row can read an
# earlier general row's binding.
rows = json.dumps({
"version": 1,
"rows": [
_row("input_0", "base"),
_general_row("first", "$base!"),
_general_row("second", "$first?"),
],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="hi")
assert out.values[0].context["first"] == "hi!"
assert out.values[0].context["second"] == "hi!?"
def test_general_row_survives_socket_disconnect_via_raw_ref():
# A general row referencing a socket whose wire is gone resolves the
# missing socket to empty (it's not in slot_values), but the row
# still produces a value — durability of the general row.
rows = json.dumps({
"version": 1,
"rows": [_general_row("note", "prefix=[$input_0]")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None)
# $input_0 not wired → out of scope → left literal.
assert out.values[0].context["note"] == "prefix=[$input_0]"
def test_general_row_needs_binding_and_template():
# A general row with no binding, or no template, writes nothing.
rows = json.dumps({
"version": 1,
"rows": [
_general_row("", "$input_0"), # missing binding
_general_row("only_binding", ""), # missing template
_general_row("only_binding2", None), # null template
],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="x")
assert out.values[0].context == {}
def test_general_row_honors_internal_flag():
rows = json.dumps({
"version": 1,
"rows": [
_row("input_0", "shown"),
_general_row("secret", "$input_0-derived", internal=True),
],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="v")
assert out.values[0].context["secret"] == "v-derived"
flags = out.values[0].internals.get("__wp_internal_flags__", {})
assert flags.get("secret") is True
assert "shown" not in flags
def test_general_row_disabled_skipped():
rows = json.dumps({
"version": 1,
"rows": [_general_row("nope", "$input_0", enabled=False)],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="x")
assert "nope" not in out.values[0].context
def test_general_row_reserved_binding_warns():
rows = json.dumps({
"version": 1,
"rows": [_general_row("_reserved", "$input_0")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="x")
assert out.values[0].context == {}
warnings = out.values[0].debug.get("__wp_warnings__", [])
assert any(w.get("type") == "injector_reserved_binding" for w in warnings)
def test_general_row_does_not_leak_internal_ctx_keys_into_template_scope():
# Internal bookkeeping keys (`__wp_*`) carried from upstream must NOT
# be exposed as `$__wp_...` refs in a general row's merged scope.
upstream = ContextPayload(
context={"existing": "up", "__wp_internal_keys__": ["existing"]},
debug={},
internals={},
)
rows = json.dumps({
"version": 1,
"rows": [_general_row("combo", "$existing")],
})
out = WPContextInjector.execute(wp_rows=rows, upstream=upstream, input_0="x")
assert out.values[0].context["combo"] == "up"