Files
Claude aef082964d fix(axes): say when a $var.AXIS read is empty because the pick has no tag
When the picked option carried no tag on a declared accepts axis, the run
warned "$outfit has no 'SHOES' axis — is that tag group marked
'accepts'?", sending users to fix a group that was fine. Wildcards now
record which accepts axes they declare (`__wp_axis_decl__`, carried across
nodes and held seeds like `__wp_axes__`), so the resolver can tell the
cases apart: an untagged pick becomes an info note, and a genuinely
unknown axis names the axes that do exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxeNnfTMTyaKQwCYWA6frg
2026-09-25 21:25:26 +00:00

133 lines
5.0 KiB
Python

"""`$outfit.SHOES` reads the tag rolled at pick time — it never re-rolls.
That is the whole consistency guarantee: two modules, or two chained nodes,
read the same stored value rather than each drawing their own.
"""
import random
from engine.modules import build_resolve_ctx
from engine.syntax import resolve_text
def _ctx(axes, decl=None):
c = {
"__wp_rng__": random.Random(0),
"__wp_warnings__": [],
"__wp_axes__": axes,
"outfit": "white t-shirt",
}
if decl is not None:
c["__wp_axis_decl__"] = decl
return c
def test_single_pick_axis_reads_the_rolled_tag():
rc = build_resolve_ctx(_ctx({"outfit": {"SHOES": "sandals"}}), surface="combine")
assert resolve_text("wearing $outfit.SHOES", rc) == "wearing sandals"
def test_multi_pick_bare_read_joins():
rc = build_resolve_ctx(
_ctx({"outfit": [{"SHOES": "sandals"}, {"SHOES": "sneakers"}]}),
surface="combine",
)
assert resolve_text("$outfit.SHOES", rc) == "sandals, sneakers"
def test_multi_pick_index_selects_one_in_either_order():
rc = build_resolve_ctx(
_ctx({"outfit": [{"SHOES": "sandals"}, {"SHOES": "sneakers"}]}),
surface="combine",
)
assert resolve_text("$outfit.1.SHOES", rc) == "sneakers"
assert resolve_text("$outfit.SHOES.1", rc) == "sneakers"
def test_multi_pick_index_keeps_its_slot_when_a_pick_has_no_axis_tag():
# Pick 0 ("robe") carries no SHOES tag. `.1` must still mean pick 1, the
# same pick `$outfit.1` names, not slide down onto pick 0's empty slot.
c = _ctx({"outfit": [{}, {"SHOES": "boots"}]})
rc = build_resolve_ctx(c, surface="combine")
assert resolve_text("$outfit.0.SHOES", rc) == ""
assert resolve_text("$outfit.1.SHOES", rc) == "boots"
assert resolve_text("$outfit.SHOES", rc) == "boots"
assert not any(w["type"] == "unknown_tag_axis" for w in c["__wp_warnings__"])
def test_multi_pick_index_out_of_range_is_empty():
rc = build_resolve_ctx(
_ctx({"outfit": [{"SHOES": "sandals"}]}), surface="combine",
)
assert resolve_text("$outfit.4.SHOES", rc) == ""
def test_single_pick_behaves_as_a_one_element_list():
# Mirrors deref_var_value's contract so the two accessors agree.
rc = build_resolve_ctx(_ctx({"outfit": {"SHOES": "sandals"}}), surface="combine")
assert resolve_text("$outfit.0.SHOES", rc) == "sandals"
assert resolve_text("$outfit.9.SHOES", rc) == ""
def test_reading_the_same_axis_twice_gives_one_answer():
# The point of rolling at pick time. A lazy roll would make these differ.
rc = build_resolve_ctx(_ctx({"outfit": {"SHOES": "sandals"}}), surface="combine")
assert resolve_text("$outfit.SHOES / $outfit.SHOES", rc) == "sandals / sandals"
def test_unknown_axis_renders_empty_and_warns():
c = _ctx({"outfit": {"SHOES": "sandals"}})
rc = build_resolve_ctx(c, surface="combine")
assert resolve_text("$outfit.BELTS", rc) == ""
assert any(w["type"] == "unknown_tag_axis" for w in c["__wp_warnings__"])
def test_axis_on_a_binding_that_rolled_nothing_warns():
c = _ctx({})
rc = build_resolve_ctx(c, surface="combine")
assert resolve_text("$outfit.SHOES", rc) == ""
assert any(w["type"] == "unknown_tag_axis" for w in c["__wp_warnings__"])
def test_declared_axis_the_pick_has_no_tag_on_is_an_info_note():
# The reported confusion: the pick ("robe") simply carries no SHOES tag,
# and the warning asked whether SHOES was marked accepts, which it was.
c = _ctx({"outfit": {}}, decl={"outfit": ["SHOES"]})
rc = build_resolve_ctx(c, surface="combine")
assert resolve_text("$outfit.SHOES", rc) == ""
[w] = c["__wp_warnings__"]
assert w["type"] == "axis_untagged_pick"
assert w["severity"] == "info"
assert "has no 'SHOES' tag" in w["message"]
assert "accepts" not in w["message"]
def test_multi_pick_with_no_tag_on_any_pick_is_an_info_note():
c = _ctx({"outfit": [{}, {}]}, decl={"outfit": ["SHOES"]})
rc = build_resolve_ctx(c, surface="combine")
assert resolve_text("$outfit.SHOES", rc) == ""
assert [w["type"] for w in c["__wp_warnings__"]] == ["axis_untagged_pick"]
def test_undeclared_axis_names_the_axes_that_do_exist():
c = _ctx({"outfit": {"SHOES": "sandals"}}, decl={"outfit": ["SHOES"]})
rc = build_resolve_ctx(c, surface="combine")
assert resolve_text("$outfit.BELTS", rc) == ""
[w] = c["__wp_warnings__"]
assert w["type"] == "unknown_tag_axis"
assert w["severity"] == "warn"
assert "SHOES" in w["message"]
def test_wildcard_with_no_accepts_groups_asks_about_accepts():
c = _ctx({"outfit": {}}, decl={"outfit": []})
rc = build_resolve_ctx(c, surface="combine")
assert resolve_text("$outfit.SHOES", rc) == ""
[w] = c["__wp_warnings__"]
assert w["type"] == "unknown_tag_axis"
assert "no accepts groups" in w["message"]
def test_plain_var_read_is_untouched():
rc = build_resolve_ctx(_ctx({"outfit": {"SHOES": "sandals"}}), surface="combine")
assert resolve_text("$outfit", rc) == "white t-shirt"