283 lines
11 KiB
Python
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)
|