Files
Claude cd99ef360c fix(syntax): close the gap an empty @{} ref leaves behind
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
2026-09-29 18:03:16 +00:00

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"}