A ref that resolves to nothing (placeholder or deleted module, a filter with no match, an empty option) left a double space or a doubled comma in wildcard values and editor previews. Tidy the seam in both the engine resolver and its TS mirror; text away from the seam is kept as written. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEU7uBVCJn1isN2RowbhhT
282 lines
9.2 KiB
Python
282 lines
9.2 KiB
Python
"""Tests for CombineHandler — syntax-aware template fill from runtime context."""
|
|
import random
|
|
|
|
import pytest
|
|
|
|
from engine.modules.combine_handler import CombineHandler
|
|
|
|
|
|
def _ctx(**extra):
|
|
"""Return a minimal valid runtime ctx dict with rng + warnings."""
|
|
return {
|
|
"__wp_rng__": random.Random(0),
|
|
"__wp_warnings__": [],
|
|
**extra,
|
|
}
|
|
|
|
|
|
def test_handler_type_id_is_combine():
|
|
assert CombineHandler.type_id == "combine"
|
|
|
|
|
|
def test_validate_payload_accepts_well_formed():
|
|
CombineHandler.validate_payload({
|
|
"template": "$name, a $age-year-old",
|
|
"output_var": "subject_phrase",
|
|
"input_vars": ["name", "age"],
|
|
})
|
|
|
|
|
|
def test_validate_payload_rejects_non_string_template():
|
|
with pytest.raises(ValueError, match="template"):
|
|
CombineHandler.validate_payload({
|
|
"template": 123,
|
|
"output_var": "x",
|
|
})
|
|
|
|
|
|
def test_validate_payload_rejects_blank_output_var():
|
|
with pytest.raises(ValueError, match="output_var"):
|
|
CombineHandler.validate_payload({"template": "x", "output_var": ""})
|
|
|
|
|
|
def test_validate_payload_rejects_invalid_identifier():
|
|
with pytest.raises(ValueError, match="identifier"):
|
|
CombineHandler.validate_payload({
|
|
"template": "x", "output_var": "1bad-name",
|
|
})
|
|
|
|
|
|
def test_validate_payload_rejects_non_list_input_vars():
|
|
with pytest.raises(ValueError, match="input_vars"):
|
|
CombineHandler.validate_payload({
|
|
"template": "x", "output_var": "y", "input_vars": "abc",
|
|
})
|
|
|
|
|
|
def test_resolve_substitutes_known_vars():
|
|
# resolve_text returns "" for unknown vars; resolve does not write back to ctx.
|
|
ctx = _ctx(name="Alice", age="30")
|
|
out = CombineHandler.resolve(
|
|
{
|
|
"template": "$name, a $age-year-old",
|
|
"output_var": "subject",
|
|
},
|
|
instance={},
|
|
ctx=ctx,
|
|
)
|
|
assert out == {"subject": "Alice, a 30-year-old"}
|
|
|
|
|
|
def test_resolve_leaves_unknown_var_empty_and_warns():
|
|
# resolve_text emits "" for unbound $var tokens. Engine also emits
|
|
# a `unknown_var` warning so the user gets a light signal that the
|
|
# combine template references a var nothing upstream has bound —
|
|
# the missing var collapses silently into empty string otherwise.
|
|
ctx = _ctx(name="Bob")
|
|
out = CombineHandler.resolve(
|
|
{"template": "$name and $missing", "output_var": "out"},
|
|
instance={},
|
|
ctx=ctx,
|
|
)
|
|
assert out == {"out": "Bob and "}
|
|
warnings = [w for w in ctx["__wp_warnings__"] if w["type"] == "unknown_var"]
|
|
assert len(warnings) == 1
|
|
assert warnings[0]["severity"] == "warn"
|
|
assert warnings[0]["detail"]["name"] == "missing"
|
|
assert warnings[0]["detail"]["surface"] == "combine"
|
|
|
|
|
|
def test_resolve_does_not_warn_on_bound_var():
|
|
# Sanity check the new warning doesn't fire for vars that ARE present.
|
|
ctx = _ctx(name="Bob")
|
|
CombineHandler.resolve(
|
|
{"template": "hi $name", "output_var": "out"},
|
|
instance={},
|
|
ctx=ctx,
|
|
)
|
|
assert all(w["type"] != "unknown_var" for w in ctx["__wp_warnings__"])
|
|
|
|
|
|
def test_resolve_inline_pick():
|
|
# {a|b|c} syntax works in combine surface.
|
|
ctx = _ctx()
|
|
out = CombineHandler.resolve(
|
|
{"template": "{hello|hi}", "output_var": "greeting"},
|
|
instance={},
|
|
ctx=ctx,
|
|
)
|
|
assert out["greeting"] in {"hello", "hi"}
|
|
|
|
|
|
def test_resolve_rejects_malformed_payload():
|
|
with pytest.raises(ValueError):
|
|
CombineHandler.resolve(
|
|
{"template": 5, "output_var": "x"}, instance={}, ctx=_ctx(),
|
|
)
|
|
|
|
|
|
def test_resolve_via_dispatcher_after_import():
|
|
from engine.modules import resolve_module
|
|
snap = {
|
|
"type": "combine",
|
|
"payload": {"template": "hi $name", "output_var": "greeting"},
|
|
"instance": {},
|
|
}
|
|
ctx = _ctx(name="Carol")
|
|
out = resolve_module(snap, ctx=ctx)
|
|
assert out == {"greeting": "hi Carol"}
|
|
|
|
|
|
def test_combine_handler_uses_resolve_text_with_combine_surface():
|
|
# $var + {a|b} both resolved; result bound to output_var.
|
|
ctx = {
|
|
"__wp_rng__": random.Random(42),
|
|
"__wp_warnings__": [],
|
|
"style": "anime",
|
|
}
|
|
out = CombineHandler.resolve(
|
|
{"template": "$style {a|b}", "output_var": "result"},
|
|
instance={},
|
|
ctx=ctx,
|
|
)
|
|
assert out["result"].startswith("anime ")
|
|
assert out["result"][-1] in ("a", "b")
|
|
|
|
|
|
def test_combine_handler_ref_emits_empty_with_warning():
|
|
"""@{uuid} in combine surface is lenient — emits empty string + warning."""
|
|
ctx = {
|
|
"__wp_rng__": random.Random(42),
|
|
"__wp_warnings__": [],
|
|
"__wp_catalog__": {
|
|
"a4f7b2e1": {
|
|
"type": "wildcard",
|
|
"var_binding": "color",
|
|
"options": [{"value": "red", "weight": 1}],
|
|
},
|
|
},
|
|
}
|
|
out = CombineHandler.resolve(
|
|
{"template": "x @{a4f7b2e1} y", "output_var": "result"},
|
|
instance={},
|
|
ctx=ctx,
|
|
)
|
|
assert out["result"] == "x y"
|
|
assert any(w["type"] == "ref_out_of_surface" for w in 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_combine_reads_template_override():
|
|
"""instance.template_override wins over payload.template — same
|
|
precedence pattern wildcard's option_weights and fixed_values'
|
|
values_overrides use."""
|
|
payload = {
|
|
"template": "library template",
|
|
"output_var": "result",
|
|
"input_vars": [],
|
|
}
|
|
instance = {"template_override": "instance template"}
|
|
ctx = _make_seedctx(seed=0)
|
|
out = CombineHandler.resolve(payload, instance, ctx)
|
|
assert out == {"result": "instance template"}
|
|
|
|
|
|
def test_combine_falls_back_to_payload_template_when_override_null():
|
|
payload = {
|
|
"template": "library template",
|
|
"output_var": "result",
|
|
"input_vars": [],
|
|
}
|
|
instance = {"template_override": None}
|
|
ctx = _make_seedctx(seed=0)
|
|
out = CombineHandler.resolve(payload, instance, ctx)
|
|
assert out == {"result": "library template"}
|
|
|
|
|
|
def test_combine_locked_seed_makes_alternation_deterministic():
|
|
"""Same locked_seed + same template = same {a|b|c} output across runs."""
|
|
payload = {
|
|
"template": "{a|b|c|d|e|f|g|h}",
|
|
"output_var": "pick",
|
|
"input_vars": [],
|
|
}
|
|
out1 = CombineHandler.resolve(payload, {"locked_seed": 12345}, _make_seedctx(0))
|
|
out2 = CombineHandler.resolve(payload, {"locked_seed": 12345}, _make_seedctx(0))
|
|
assert out1 == out2
|
|
|
|
|
|
def test_combine_different_locked_seeds_can_yield_different_picks():
|
|
"""Sanity check that the seed actually affects RNG (statistical)."""
|
|
payload = {
|
|
"template": "{a|b|c|d|e|f|g|h}",
|
|
"output_var": "pick",
|
|
"input_vars": [],
|
|
}
|
|
picks = set()
|
|
for seed in range(20):
|
|
out = CombineHandler.resolve(payload, {"locked_seed": seed}, _make_seedctx(0))
|
|
picks.add(out["pick"])
|
|
assert len(picks) > 1
|
|
|
|
|
|
def test_combine_unlocked_uses_chain_seed():
|
|
"""Without locked_seed, RNG derives from ctx __wp_node_seed__."""
|
|
payload = {
|
|
"template": "{a|b|c|d|e|f|g|h}",
|
|
"output_var": "pick",
|
|
"input_vars": [],
|
|
}
|
|
ctx_a = _make_seedctx(seed=11111)
|
|
out_a = CombineHandler.resolve(payload, {}, ctx_a)
|
|
assert out_a["pick"] in {"a", "b", "c", "d", "e", "f", "g", "h"}
|
|
|
|
|
|
def test_combine_variable_binding_override_rebinds_output():
|
|
"""instance.variable_binding rebinds the produced var (mirrors
|
|
wildcard precedence). Engine writes to the override name, not
|
|
payload.output_var, so downstream consumers reading $renamed
|
|
pick up the value."""
|
|
payload = {
|
|
"template": "hello",
|
|
"output_var": "lib_out",
|
|
"input_vars": [],
|
|
}
|
|
instance = {"variable_binding": "renamed"}
|
|
out = CombineHandler.resolve(payload, instance, _make_seedctx(0))
|
|
assert out == {"renamed": "hello"}
|
|
|
|
|
|
def test_combine_variable_binding_strips_leading_dollar():
|
|
"""User can write `$foo` or `foo` in the modal — both bind to `foo`."""
|
|
payload = {"template": "x", "output_var": "lib_out", "input_vars": []}
|
|
out = CombineHandler.resolve(payload, {"variable_binding": "$foo"}, _make_seedctx(0))
|
|
assert out == {"foo": "x"}
|
|
|
|
|
|
def test_combine_empty_variable_binding_falls_back_to_payload():
|
|
"""Empty / whitespace / None variable_binding → engine reads
|
|
payload.output_var. Mirrors the modal's collapse-to-null UX."""
|
|
payload = {"template": "x", "output_var": "lib_out", "input_vars": []}
|
|
out_empty = CombineHandler.resolve(payload, {"variable_binding": ""}, _make_seedctx(0))
|
|
out_none = CombineHandler.resolve(payload, {"variable_binding": None}, _make_seedctx(0))
|
|
out_ws = CombineHandler.resolve(payload, {"variable_binding": " "}, _make_seedctx(0))
|
|
assert out_empty == {"lib_out": "x"}
|
|
assert out_none == {"lib_out": "x"}
|
|
assert out_ws == {"lib_out": "x"}
|