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