Files
DumiFlex-ComfyUI-Wildcard-P…/tests/engine/modules/test_fixed_values_handler.py
T

283 lines
11 KiB
Python

"""Tests for FixedValuesHandler — emits one mapping per named value."""
import random as _random
import pytest
from engine.modules.fixed_values_handler import FixedValuesHandler
def _payload(values):
return {"values": values}
def test_resolve_returns_all_values():
payload = _payload([
{"id": "v1", "name": "$lens", "value": "85mm"},
{"id": "v2", "name": "$angle", "value": "wide"},
])
out = FixedValuesHandler.resolve(payload, instance={}, ctx=None)
assert out == {"$lens": "85mm", "$angle": "wide"}
def test_resolve_empty_values_returns_empty_dict():
out = FixedValuesHandler.resolve(_payload([]), instance={}, ctx=None)
assert out == {}
def test_resolve_filters_by_enabled_options():
payload = _payload([
{"id": "v1", "name": "$a", "value": "1"},
{"id": "v2", "name": "$b", "value": "2"},
])
out = FixedValuesHandler.resolve(
payload, instance={"enabled_options": ["v2"]}, ctx=None,
)
assert out == {"$b": "2"}
def test_resolve_skips_blank_names():
payload = _payload([
{"id": "v1", "name": "", "value": "x"},
{"id": "v2", "name": " ", "value": "y"},
{"id": "v3", "name": "$ok", "value": "z"},
])
out = FixedValuesHandler.resolve(payload, instance={}, ctx=None)
assert out == {"$ok": "z"}
def test_resolve_handles_missing_value_key():
"""A value without 'value' resolves to empty string."""
payload = _payload([{"id": "v1", "name": "$x"}])
out = FixedValuesHandler.resolve(payload, instance={}, ctx=None)
assert out == {"$x": ""}
def test_resolve_coerces_value_to_string():
payload = _payload([{"id": "v1", "name": "$n", "value": 42}])
out = FixedValuesHandler.resolve(payload, instance={}, ctx=None)
assert out == {"$n": "42"}
def test_handler_type_id_is_fixed_values():
assert FixedValuesHandler.type_id == "fixed_values"
def test_resolve_via_dispatcher_after_import():
"""Importing engine.modules auto-registers FixedValuesHandler."""
from engine.modules import resolve_module
snap = {
"type": "fixed_values",
"payload": _payload([{"id": "v1", "name": "$lens", "value": "85mm"}]),
"instance": {},
}
out = resolve_module(snap, ctx=None)
assert out == {"$lens": "85mm"}
# ── Two-tier override path (mirrors wildcard's instance overrides) ──
def test_resolve_uses_instance_values_overrides_when_present():
"""Library-tracked fixed_values: edits go to `instance.values_overrides`,
payload.values stays as the immutable library snapshot. Engine reads
overrides when present; only falls through to library values otherwise."""
payload = _payload([
{"id": "v1", "name": "$lens", "value": "85mm"}, # library default
{"id": "v2", "name": "$angle", "value": "wide"}, # library default
])
overrides = [
{"id": "v1", "name": "$lens", "value": "50mm"}, # user edited
{"id": "v2", "name": "$angle", "value": "wide"}, # unchanged but copied
]
out = FixedValuesHandler.resolve(
payload, instance={"values_overrides": overrides}, ctx=None,
)
assert out == {"$lens": "50mm", "$angle": "wide"}
def test_resolve_falls_back_to_payload_when_overrides_empty():
"""Empty overrides list = no effective override → use payload.values.
Edge case for the modal's "reset to library" path which clears
overrides; engine must NOT treat the empty list as "hide all values"."""
payload = _payload([{"id": "v1", "name": "$x", "value": "1"}])
out = FixedValuesHandler.resolve(
payload, instance={"values_overrides": []}, ctx=None,
)
assert out == {"$x": "1"}
def test_resolve_overrides_can_add_entries_not_in_library_payload():
"""Full-replacement semantics — overrides aren't a delta map. The
user can add new entries via the modal that don't exist in the
library row, and they must surface."""
payload = _payload([{"id": "v1", "name": "$a", "value": "1"}])
overrides = [
{"id": "v1", "name": "$a", "value": "1"},
{"id": "v_new", "name": "$b", "value": "2"}, # new — not in library
]
out = FixedValuesHandler.resolve(
payload, instance={"values_overrides": overrides}, ctx=None,
)
assert out == {"$a": "1", "$b": "2"}
def test_resolve_overrides_can_drop_library_entries():
"""If user removes an entry in the modal, override list excludes it.
Engine must not surface the dropped name from library payload."""
payload = _payload([
{"id": "v1", "name": "$a", "value": "1"},
{"id": "v2", "name": "$b", "value": "2"},
])
overrides = [{"id": "v1", "name": "$a", "value": "1"}] # $b removed
out = FixedValuesHandler.resolve(
payload, instance={"values_overrides": overrides}, ctx=None,
)
assert out == {"$a": "1"}
def test_resolve_ignores_non_list_overrides():
"""Defensive — malformed `values_overrides` (string, dict) is ignored,
library payload still resolves. Cheap insurance against legacy
workflow JSON or future shape drift."""
payload = _payload([{"id": "v1", "name": "$x", "value": "1"}])
out = FixedValuesHandler.resolve(
payload, instance={"values_overrides": "not a list"}, ctx=None,
)
assert out == {"$x": "1"}
def test_resolve_emits_warning_when_overrides_malformed():
"""Pre-fix: a malformed `values_overrides` (string/dict from an old
SPA save or hand-edited JSON) silently fell back to library payload.
User's edits dropped without explanation. Now the engine emits a
`fixed_values_overrides_malformed` warning so the fallback is
visible in WP_Debug."""
payload = _payload([{"id": "v1", "name": "$x", "value": "1"}])
ctx = {"__wp_warnings__": []}
FixedValuesHandler.resolve(
payload, instance={"values_overrides": {"id": "v1"}}, ctx=ctx,
)
warnings = [
w for w in ctx["__wp_warnings__"]
if w["type"] == "fixed_values_overrides_malformed"
]
assert len(warnings) == 1
assert warnings[0]["detail"]["got_type"] == "dict"
def test_resolve_no_warning_when_overrides_absent():
"""Sanity: the malformed-warning path must NOT fire when overrides
are simply absent (the common case). Pre-fix this test would have
flagged my over-eager check that fires on `None`."""
payload = _payload([{"id": "v1", "name": "$x", "value": "1"}])
ctx = {"__wp_warnings__": []}
FixedValuesHandler.resolve(payload, instance={}, ctx=ctx)
assert ctx["__wp_warnings__"] == []
# ── Phase: combine v2 + syntax parity cycle ──────────────────────────
def _make_seedctx(seed: int = 0) -> dict:
"""Build a ctx dict with chain seed + max_ref_depth so build_resolve_ctx
composes a valid ResolveContext."""
return {
"__wp_rng__": _random.Random(0),
"__wp_node_seed__": seed,
"__wp_warnings__": [],
"__wp_max_ref_depth__": 8,
"__wp_catalog__": {},
"__wp_developer_mode__": False,
}
def test_fixed_values_resolves_alternation_per_value():
payload = {"values": [
{"id": "v1", "name": "color", "value": "{red|blue|green}"},
]}
out = FixedValuesHandler.resolve(payload, {"locked_seed": 42}, _make_seedctx(0))
assert out["color"] in {"red", "blue", "green"}
def test_fixed_values_locked_seed_deterministic():
"""Same locked_seed across calls = same {a|b|c} resolution per value."""
payload = {"values": [
{"id": "v1", "name": "color", "value": "{red|blue|green|yellow}"},
{"id": "v2", "name": "shape", "value": "{circle|square|tri}"},
]}
out1 = FixedValuesHandler.resolve(payload, {"locked_seed": 99}, _make_seedctx(0))
out2 = FixedValuesHandler.resolve(payload, {"locked_seed": 99}, _make_seedctx(0))
assert out1 == out2
def test_fixed_values_resolves_escape():
"""$$ → literal $ via resolve_text on fixed_values surface."""
payload = {"values": [{"id": "v1", "name": "price", "value": "$$5.99"}]}
out = FixedValuesHandler.resolve(payload, {}, _make_seedctx(0))
assert out["price"] == "$5.99"
def test_fixed_values_var_warns_renders_literal():
"""$var on fixed_values surface: warn + render literal $name (lenient)."""
payload = {"values": [{"id": "v1", "name": "msg", "value": "see $other"}]}
ctx = _make_seedctx(0)
out = FixedValuesHandler.resolve(payload, {}, ctx)
assert out["msg"] == "see $other"
warnings = ctx["__wp_warnings__"]
assert any(w.get("type") == "var_out_of_surface" for w in warnings)
def test_fixed_values_values_overrides_path_preserved():
"""Existing instance.values_overrides precedence still works after the rewrite."""
payload = {"values": [{"id": "v1", "name": "key", "value": "from_lib"}]}
instance = {"values_overrides": [
{"id": "v1", "name": "key", "value": "from_instance"},
]}
out = FixedValuesHandler.resolve(payload, instance, _make_seedctx(0))
assert out == {"key": "from_instance"}
# ── Task 14: e2e test sweep ──────────────────────────────────────────
def test_fixed_values_locked_seed_idempotent_across_repeated_resolves():
"""Same locked_seed + same payload = same {a|b|c} resolution
across repeated `resolve` calls. Idempotency property is
load-bearing for the seed-lock UX: lock the seed, the next 100
queues all produce the same value (for that lock) until user
changes the seed.
Regression guard against the RNG accidentally consuming module
state between calls (would break the "lock and forget" promise).
"""
payload = {"values": [
{"id": "v1", "name": "color", "value": "{red|blue|green|yellow|orange}"},
{"id": "v2", "name": "shape", "value": "{circle|square|triangle|hexagon}"},
{"id": "v3", "name": "tone", "value": "{warm|cool|neutral}"},
]}
instance = {"locked_seed": 1234}
# 5 fresh ctx + handler calls — seeds derived deterministically
# so all five must agree.
runs = [
FixedValuesHandler.resolve(payload, instance, _make_seedctx(99))
for _ in range(5)
]
assert all(r == runs[0] for r in runs), (
f"Expected all runs equal, got: {runs}"
)
def test_fixed_values_ref_raises_ref_out_of_surface():
"""`@{uuid}` refs must not resolve in fixed_values surface — engine
raises RefOutOfSurfaceError. Tested via strict ctx so the engine
actually raises (lenient mode warns instead, which is covered by
the var_out_of_surface test above).
"""
from engine.modules import build_resolve_ctx
from engine.syntax import resolve_text
from engine.syntax.types import RefOutOfSurfaceError
raw_ctx = _make_seedctx(0)
resolve_ctx = build_resolve_ctx(raw_ctx, surface="fixed_values", strict=True)
with pytest.raises(RefOutOfSurfaceError):
resolve_text("see @{abcdef12} ref", resolve_ctx)