"""G4.5a Spatial-rules lift — unit tests (spec §6.1, RO-12 binding).

본 테스트는 G4.5a (2026-05-05) 의 spec/plan 에 정의된 lift 동작을 unit test
로 검증한다. v19 system.md 의 3 prose section (Rule F / Rule G / Rule J)
이 `build_render_strategy()` 의 신규 sibling nested dict
`spatial_consistency` (4 sub-key — camera_frame_rule (7 keys, RO-3 —
core_principles 4-entry list) / fg_bg_shared_anchor_rule (6 keys) /
primary_framing_rule (5 keys + nested) / rationale_summary) + G4.4 base 2 →
G4.5a 5 constraints 로 single source 가 되었는지, _card_metadata
envelope-sibling 이 G4.5a 4 신규 lift_status / 3 rule_source key 로
확장됐는지, 그리고 strict shape validator 들 (further nested
`_assert_primary_framing_rule_shape`) 이 누락 키를 fail-fast 하는지 확인한다.

Coverage (~53 distinct test functions per plan Phase 5 Task 5.1 table —
RO-12 binding raised by Plan-R3-R-fix-iter-1):
  - 1-2   spatial_consistency 4 top-level sub-key shape
  - 3-11  camera_frame_rule (7 keys + 4 core_principles + counts)
  - 12-15 fg_bg_shared_anchor_rule (6 keys + counts)
  - 16-23 primary_framing_rule (5 keys + nested + cross-ref RO-6)
  - 27-32 constraints 5 strings (G4.4 base 2 carry + 3 spatial inline)
  - 33-34 spatial_consistency builder-static invariant (Trap #14)
  - 35-37 _card_metadata G4.5a 4 신규 lift_status + 3 신규 rule_source keys
  - 38    hash invariance (G4.2 R1-B1 / G4.4 carry)
  - 39-40 hash drift selectors (content vs lift_status only)
  - 41-44 _assert_spatial_consistency_shape() strict validator coverage
  - 45-49 _assert_primary_framing_rule_shape() further nested helper
  - 51    None staging silent-fallback ban (G4.1 R1-I1 carry)
  - 52    empty camera_direction valid with static subfields
  - 53    view_consistency.mixing_forbidden bool literal (G4.4 carry regression)

Reference: docs/superpowers/specs/2026-05-05-g4.5a-spatial-rules-lift-design.md
            docs/superpowers/plans/2026-05-05-g4.5a-spatial-rules-lift-implementation.md
"""
from __future__ import annotations

import copy
from typing import Any, Dict, List, Optional

import pytest

from app.core.errors import AppError
from app.core.steps.render_prompt_card import (
    _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL,
    _SPATIAL_CAMERA_FRAME_RULE_REQUIRED_KEYS,
    _SPATIAL_CAMERA_HIGH_TOKENS,
    _SPATIAL_CAMERA_HIP_TOKENS,
    _SPATIAL_CAMERA_LOW_TOKENS,
    _SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS,
    _SPATIAL_CONSISTENCY_REQUIRED_KEYS,
    _SPATIAL_FG_BG_SEPARATION_TOKENS,
    _SPATIAL_FG_BG_SHARED_ANCHOR_RULE_REQUIRED_KEYS,
    _SPATIAL_FRAME_EDGE_POSITION_TOKENS,
    _SPATIAL_INTERACTION_VERBS,
    _SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS,
    _SPATIAL_SHARED_ANCHOR_KEYWORDS,
    _SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS,
    _assert_primary_framing_rule_shape,
    _assert_spatial_consistency_shape,
    assert_card_shape,
    build_render_prompt_card,
    build_render_strategy,
    compute_card_hash,
    compute_render_strategy_snapshot_hash,
)


# ──────────────────────────────────────────────────────────────────────────
# Fixture (G4.1 함정 1 / G4.2 plan-R1-I4 / G4.3/G4.4 carry — _DEFAULT_SENTINEL
# pattern). `staging or {}` / `shot_info or {}` 류 silent absorb 패턴
# 절대 금지 — production drift cascade 의 origin (Wave subagent hardcoded
# empty trap, G4.1+G4.2+G4.3+G4.4 carry).
# ──────────────────────────────────────────────────────────────────────────

_DEFAULT_SENTINEL = object()


_DEFAULT_SHOT_INFO: Dict[str, Any] = {
    "shot_index": 4,
    "camera_direction": "medium shot of the doorway",
    "primary_subject": "the observer at the doorway",
}
_DEFAULT_STAGING: Dict[str, Any] = {
    "camera_direction": "medium shot of the doorway",
    "framing_scale": "medium",
    "lighting_mood": "warm dim",
    # Area C (2026-05-12) — build_id_policy 가 staging.key_bg_elements 에서
    # reproduction surface 적용 여부 derive. 빈 list = 비-재현면 shot.
    "key_bg_elements": [],
    # Area #1 W5 (2026-05-16) — shot_staging v12 top-level required field.
    # helper SOT graceful empty (default policy id_and_outlook_required 가
    # visible_entities 에 적용).
    "subject_reference_policy": [],
}
_DEFAULT_SEG: Dict[str, Any] = {
    "index": 12,
    "text": "scene segment text content here",
}


@pytest.fixture(autouse=True)
def _공간규칙_걷기_끔(monkeypatch: pytest.MonkeyPatch) -> None:
    """이 파일은 **규칙 본문의 모양**을 검사한다 — 걷기와 다른 축이다.

    ★2026-08-26: `card_spatial_rules_scoped_enabled` 기본값이 True 가 되면서
     기본 fixture(framing=medium)에서 `close_framing_rules` 가 걷혀 필수 키
     검사 4건이 깨졌다. 그 시험들이 지키려는 것은 **전문이 있을 때의 계약**
     이므로 여기서는 걷기를 끈다. 켠 갈래는 `test_card_spatial_scope.py` 가
     따로 못박는다.
    """
    from app.core.config import settings

    monkeypatch.setattr(
        settings, "card_spatial_rules_scoped_enabled", False, raising=False)


def _make_render_strategy(
    *,
    seg: Any = _DEFAULT_SENTINEL,
    shot_info: Any = _DEFAULT_SENTINEL,
    staging: Any = _DEFAULT_SENTINEL,
    perception_mode: Optional[str] = None,
) -> Dict[str, Any]:
    """build_render_strategy() wrapper. None / {} / default 명시 구분.

    Sentinel 의도 (G4.1+G4.2+G4.3+G4.4 carry):
      - staging=None → builder 가 staging_not_applicable=False 면 AppError
      - staging=_DEFAULT_SENTINEL (default) → realistic non-empty default
        dict (production-shaped fixture)
    `staging or {}` 류 silent absorb 패턴 절대 금지.
    """
    if seg is _DEFAULT_SENTINEL:
        seg = copy.deepcopy(_DEFAULT_SEG)
    if shot_info is _DEFAULT_SENTINEL:
        shot_info = copy.deepcopy(_DEFAULT_SHOT_INFO)
    if staging is _DEFAULT_SENTINEL:
        staging = copy.deepcopy(_DEFAULT_STAGING)
    return build_render_strategy(
        seg=seg,
        shot_info=shot_info,
        staging=staging,
        perception_mode=perception_mode,
    )


def _make_full_card(
    *,
    seg: Any = _DEFAULT_SENTINEL,
    shot_info: Any = _DEFAULT_SENTINEL,
    staging: Any = _DEFAULT_SENTINEL,
    visible_entities: Any = _DEFAULT_SENTINEL,
    outlook_pairs: Any = _DEFAULT_SENTINEL,
    perception_mode: Optional[str] = None,
    bg_id: Optional[str] = "cb_test_001",
    bg_owned: Any = _DEFAULT_SENTINEL,
    bg_camera_meta: Any = _DEFAULT_SENTINEL,
    bg_guide: Optional[str] = "general view",
    is_close_framing: bool = False,
    background_mode_on: bool = True,
    fixed_elements: Any = _DEFAULT_SENTINEL,
    previous_shot_refs: Any = _DEFAULT_SENTINEL,
    forward_zoom_targets: Any = _DEFAULT_SENTINEL,
    scene_index: int = 12,
    shot_index: int = 4,
) -> Dict[str, Any]:
    """build_render_prompt_card() entire call — G4.5a sentinel-aware fixture.

    Carry priority (Wave 4 carry per plan §5.1):
      G4.4 helper carry 후 G4.5a 신규 sub-field 가 추가된 확장 helper.
      _DEFAULT_SENTINEL pattern + None vs [] vs default 셋 명시 구분.

    Trap #14 carry: helper 는 항상 spatial_consistency 4 sub-key + 3 sub-rule
    채움 (production-shaped fixture). Trap #2 carry: hardcoded `[]` 또는
    누락 fixture 절대 금지.
    """
    if seg is _DEFAULT_SENTINEL:
        seg = copy.deepcopy(_DEFAULT_SEG)
    if shot_info is _DEFAULT_SENTINEL:
        shot_info = {
            "shot_index": shot_index,
            "camera_direction": (
                "extreme close-up of hand"
                if is_close_framing
                else "medium shot of doorway"
            ),
            "primary_subject": "the observer at the doorway",
        }
    if staging is _DEFAULT_SENTINEL:
        staging = {
            "camera_direction": (
                "extreme close-up of hand"
                if is_close_framing
                else "medium shot of doorway"
            ),
            "framing_scale": "close" if is_close_framing else "medium",
            "lighting_mood": "warm dim",
            # Area C (2026-05-12) — required by build_id_policy. 빈 list =
            # 비-재현면 shot (applies=False).
            "key_bg_elements": [],
            # Area #1 W5 (2026-05-16) — shot_staging v12 top-level required
            # field. helper SOT graceful empty.
            "subject_reference_policy": [],
        }
    if visible_entities is _DEFAULT_SENTINEL:
        visible_entities = ["C01", "C02"]
    if outlook_pairs is _DEFAULT_SENTINEL:
        outlook_pairs = [
            {"character_id": "C01", "outlook_id": "O01"},
            {"character_id": "C02", "outlook_id": "O02"},
        ]
    if bg_owned is _DEFAULT_SENTINEL:
        bg_owned = ["door", "window"]
    if bg_camera_meta is _DEFAULT_SENTINEL:
        bg_camera_meta = {
            "camera_position": "south",
            "camera_height": "eye-level",
            "lens_hint": "35mm",
            "framing_notes": "wide",
        }
    if fixed_elements is _DEFAULT_SENTINEL:
        fixed_elements = [
            {
                "element_id": "fe_test_001",
                "element_type": "character_pose",
                "character_name": "C01",
                "description": "An Asian man stands by the doorway",
                "applies_to_shots": [
                    {"scene_index": scene_index, "shot_index": shot_index}
                ],
                "element_scope": "full",  # Area #4 W3 — required field
                "source_facts": ["scene segment text mentions doorway"],
                "visual_inferences": ["doorway implies threshold pose"],
                "creative_decisions": ["pose mirrors prior shot continuity"],
                "confidence": "high",
            },
        ]
    if previous_shot_refs is _DEFAULT_SENTINEL:
        previous_shot_refs = [
            {
                "scene_index": scene_index,
                "shot_index": shot_index - 1,
                "ref_usage": "exact_background",
            },
        ]
    if forward_zoom_targets is _DEFAULT_SENTINEL:
        forward_zoom_targets = [
            {
                "scene_index": scene_index,
                "shot_index": shot_index + 1,
                "description": "close-up reveal",
            },
        ]
    return build_render_prompt_card(
        scene_index=scene_index,
        shot_index=shot_index,
        seg=seg,
        shot_info=shot_info,
        visible_entities=visible_entities,
        outlook_pairs=outlook_pairs,
        perception_mode=perception_mode,
        staging=staging,
        bg_id=bg_id,
        bg_owned=bg_owned,
        bg_camera_meta=bg_camera_meta,
        bg_guide=bg_guide,
        is_close_framing=is_close_framing,
        background_mode_on=background_mode_on,
        fixed_elements=fixed_elements,
        previous_shot_refs=previous_shot_refs,
        forward_zoom_targets=forward_zoom_targets,
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 1-2 — spatial_consistency 4 top-level sub-key shape.
# ──────────────────────────────────────────────────────────────────────────


def test_render_strategy_has_spatial_consistency() -> None:
    """test 1 — render_strategy 반환에 spatial_consistency dict 존재 + 4
    required keys."""
    rs = _make_render_strategy()
    assert "spatial_consistency" in rs, (
        "render_strategy missing 'spatial_consistency' — G4.5a Wave 1-A "
        "producer wiring broken"
    )
    sc = rs["spatial_consistency"]
    assert isinstance(sc, dict)
    expected = set(_SPATIAL_CONSISTENCY_REQUIRED_KEYS)
    assert set(sc.keys()) >= expected, (
        f"spatial_consistency missing required keys "
        f"{sorted(expected - set(sc.keys()))!r}"
    )


def test_spatial_consistency_required_keys_superset() -> None:
    """test 2 — spatial_consistency 의 4 required key (Override O-5 superset)."""
    rs = _make_render_strategy()
    returned_keys = set(rs["spatial_consistency"].keys())
    expected = {
        "camera_frame_rule",
        "fg_bg_shared_anchor_rule",
        "primary_framing_rule",
        "rationale_summary",
    }
    assert returned_keys >= expected, (
        f"spatial_consistency keys not superset of expected — missing "
        f"{sorted(expected - returned_keys)!r}"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 3-11 — camera_frame_rule (7 keys + 4 core_principles + counts).
# RO-3 binding — core_principles 4-entry list verbatim from v19 line 170-175.
# PR4-B3 binding — NO cross-card ref in camera_frame_rule.
# ──────────────────────────────────────────────────────────────────────────


def test_camera_frame_rule_required_keys_superset() -> None:
    """test 3 — camera_frame_rule 7 required keys (RO-3 binding)."""
    rs = _make_render_strategy()
    cfr = rs["spatial_consistency"]["camera_frame_rule"]
    assert isinstance(cfr, dict)
    expected = set(_SPATIAL_CAMERA_FRAME_RULE_REQUIRED_KEYS)
    returned = set(cfr.keys())
    assert returned >= expected, (
        f"camera_frame_rule keys not superset — missing "
        f"{sorted(expected - returned)!r}"
    )
    # Spec-explicit 7 keys (RO-3).
    spec_expected = {
        "applies_when",
        "consistency_check_summary",
        "core_principles",
        "forbidden_combinations",
        "recommended_phrasings",
        "self_check_steps",
        "rationale_summary",
    }
    assert returned >= spec_expected, (
        f"camera_frame_rule missing spec-explicit keys "
        f"{sorted(spec_expected - returned)!r}"
    )


def test_camera_frame_rule_core_principles_count() -> None:
    """test 4 — RO-3 binding — core_principles list-len == 4."""
    rs = _make_render_strategy()
    cp = rs["spatial_consistency"]["camera_frame_rule"]["core_principles"]
    assert isinstance(cp, list)
    assert len(cp) == 4, (
        f"camera_frame_rule.core_principles len != 4 (got {len(cp)}) — "
        f"RO-3 binding (v19 line 170-175 carry)"
    )


def test_camera_frame_rule_core_principles_low_camera_substring() -> None:
    """test 5 — RO-3 binding — core_principles[0] contains 'low camera'."""
    rs = _make_render_strategy()
    cp = rs["spatial_consistency"]["camera_frame_rule"]["core_principles"]
    assert "low camera" in cp[0], (
        f"core_principles[0] missing 'low camera' substring — {cp[0]!r}"
    )


def test_camera_frame_rule_core_principles_overhead_substring() -> None:
    """test 6 — RO-3 binding — core_principles[1] contains 'overhead'."""
    rs = _make_render_strategy()
    cp = rs["spatial_consistency"]["camera_frame_rule"]["core_principles"]
    assert "overhead" in cp[1], (
        f"core_principles[1] missing 'overhead' substring — {cp[1]!r}"
    )


def test_camera_frame_rule_core_principles_hip_height_substring() -> None:
    """test 7 — RO-3 binding — core_principles[2] contains 'hip-height'."""
    rs = _make_render_strategy()
    cp = rs["spatial_consistency"]["camera_frame_rule"]["core_principles"]
    assert "hip-height" in cp[2], (
        f"core_principles[2] missing 'hip-height' substring — {cp[2]!r}"
    )


def test_camera_frame_rule_core_principles_full_body_substring() -> None:
    """test 8 — RO-3 binding — core_principles[3] contains 'full body'."""
    rs = _make_render_strategy()
    cp = rs["spatial_consistency"]["camera_frame_rule"]["core_principles"]
    assert "full body" in cp[3], (
        f"core_principles[3] missing 'full body' substring — {cp[3]!r}"
    )


def test_camera_frame_rule_forbidden_combinations_count() -> None:
    """test 9 — forbidden_combinations list-len == 4."""
    rs = _make_render_strategy()
    fc = rs["spatial_consistency"]["camera_frame_rule"]["forbidden_combinations"]
    assert isinstance(fc, list)
    assert len(fc) == 4, (
        f"camera_frame_rule.forbidden_combinations len != 4 (got {len(fc)})"
    )


def test_camera_frame_rule_recommended_phrasings_count() -> None:
    """test 10 — recommended_phrasings list-len == 4."""
    rs = _make_render_strategy()
    rp = rs["spatial_consistency"]["camera_frame_rule"]["recommended_phrasings"]
    assert isinstance(rp, list)
    assert len(rp) == 4, (
        f"camera_frame_rule.recommended_phrasings len != 4 (got {len(rp)})"
    )


def test_camera_frame_rule_self_check_steps_count() -> None:
    """test 11 — self_check_steps list-len == 4."""
    rs = _make_render_strategy()
    scs = rs["spatial_consistency"]["camera_frame_rule"]["self_check_steps"]
    assert isinstance(scs, list)
    assert len(scs) == 4, (
        f"camera_frame_rule.self_check_steps len != 4 (got {len(scs)})"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 12-15 — fg_bg_shared_anchor_rule (6 keys + counts).
# ──────────────────────────────────────────────────────────────────────────


def test_fg_bg_shared_anchor_rule_required_keys_superset() -> None:
    """test 12 — fg_bg_shared_anchor_rule 6 required keys (Override O-5
    superset)."""
    rs = _make_render_strategy()
    fb = rs["spatial_consistency"]["fg_bg_shared_anchor_rule"]
    assert isinstance(fb, dict)
    expected = set(_SPATIAL_FG_BG_SHARED_ANCHOR_RULE_REQUIRED_KEYS)
    returned = set(fb.keys())
    assert returned >= expected, (
        f"fg_bg_shared_anchor_rule keys not superset — missing "
        f"{sorted(expected - returned)!r}"
    )
    # Spec-explicit 6 keys.
    spec_expected = {
        "applies_when",
        "shared_anchor_required",
        "forbidden_phrasings",
        "recommended_keywords",
        "self_check_steps",
        "rationale_summary",
    }
    assert returned >= spec_expected, (
        f"fg_bg_shared_anchor_rule missing spec-explicit keys "
        f"{sorted(spec_expected - returned)!r}"
    )


def test_fg_bg_shared_anchor_rule_forbidden_phrasings_count() -> None:
    """test 13 — forbidden_phrasings list-len == 3."""
    rs = _make_render_strategy()
    fp = rs["spatial_consistency"]["fg_bg_shared_anchor_rule"][
        "forbidden_phrasings"
    ]
    assert isinstance(fp, list)
    assert len(fp) == 3, (
        f"fg_bg_shared_anchor_rule.forbidden_phrasings len != 3 (got {len(fp)})"
    )


def test_fg_bg_shared_anchor_rule_recommended_keywords_count() -> None:
    """test 14 — recommended_keywords list-len == 5."""
    rs = _make_render_strategy()
    rk = rs["spatial_consistency"]["fg_bg_shared_anchor_rule"][
        "recommended_keywords"
    ]
    assert isinstance(rk, list)
    assert len(rk) == 5, (
        f"fg_bg_shared_anchor_rule.recommended_keywords len != 5 (got {len(rk)})"
    )


def test_fg_bg_shared_anchor_rule_self_check_steps_count() -> None:
    """test 15 — self_check_steps list-len == 4."""
    rs = _make_render_strategy()
    scs = rs["spatial_consistency"]["fg_bg_shared_anchor_rule"][
        "self_check_steps"
    ]
    assert isinstance(scs, list)
    assert len(scs) == 4, (
        f"fg_bg_shared_anchor_rule.self_check_steps len != 4 (got {len(scs)})"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 16-23 — primary_framing_rule (5 keys + nested + cross-ref RO-6).
# ──────────────────────────────────────────────────────────────────────────


def test_primary_framing_rule_required_keys_superset() -> None:
    """test 16 — primary_framing_rule 5 required keys (Override O-5 superset)."""
    rs = _make_render_strategy()
    pf = rs["spatial_consistency"]["primary_framing_rule"]
    assert isinstance(pf, dict)
    expected = set(_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS)
    returned = set(pf.keys())
    assert returned >= expected, (
        f"primary_framing_rule keys not superset — missing "
        f"{sorted(expected - returned)!r}"
    )
    # Spec-explicit 5 keys.
    spec_expected = {
        "applies_when",
        "close_framing_rules",
        "wide_medium_rules",
        "self_check_steps",
        "rationale_summary",
    }
    assert returned >= spec_expected, (
        f"primary_framing_rule missing spec-explicit keys "
        f"{sorted(spec_expected - returned)!r}"
    )


def test_primary_framing_rule_close_framing_rules_required_subkeys() -> None:
    """test 20 — Area #1 W5 rename — close_framing_rules 4 required sub-key
    (incl subject_reference_policy_cross_ref; W3 가 body_part_focus_cross_ref
    → subject_reference_policy_cross_ref rename)."""
    rs = _make_render_strategy()
    cfr = rs["spatial_consistency"]["primary_framing_rule"][
        "close_framing_rules"
    ]
    assert isinstance(cfr, dict)
    expected = set(_SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS)
    returned = set(cfr.keys())
    assert returned >= expected, (
        f"close_framing_rules missing sub-keys {sorted(expected - returned)!r}"
    )
    spec_expected = {
        "primary_subject_only_fully_visible",
        "other_entities_appearance_options",
        "forbidden_combinations",
        "subject_reference_policy_cross_ref",
    }
    assert returned >= spec_expected, (
        f"close_framing_rules missing spec-explicit sub-keys "
        f"{sorted(spec_expected - returned)!r}"
    )


def test_primary_framing_rule_close_framing_other_entities_options_count() -> None:
    """test 21 — close_framing_rules.other_entities_appearance_options
    list-len == 3."""
    rs = _make_render_strategy()
    options = rs["spatial_consistency"]["primary_framing_rule"][
        "close_framing_rules"
    ]["other_entities_appearance_options"]
    assert isinstance(options, list)
    assert len(options) == 3, (
        f"close_framing_rules.other_entities_appearance_options len != 3 "
        f"(got {len(options)})"
    )


def test_primary_framing_rule_close_framing_forbidden_combinations_count() -> None:
    """test 22 — close_framing_rules.forbidden_combinations list-len == 2."""
    rs = _make_render_strategy()
    fc = rs["spatial_consistency"]["primary_framing_rule"][
        "close_framing_rules"
    ]["forbidden_combinations"]
    assert isinstance(fc, list)
    assert len(fc) == 2, (
        f"close_framing_rules.forbidden_combinations len != 2 (got {len(fc)})"
    )


# test 23 폐기 (Area #1 W5, 2026-05-16): RO-6 paired drift 차단 (close_framing_rules
# body_part_focus_cross_ref + id_policy.body_part_focus_rule 양쪽 literal 검증) —
# 두 항목 모두 W3 가 rename + 폐기. cross-ref 이름은 subject_reference_policy_cross_ref
# 로 rename, paired literal value 도 id_policy.subject_reference_policy 로 변경.
# paired-drift 차단은 더 이상 의미 없음.


def test_primary_framing_rule_wide_medium_rules_required_subkeys() -> None:
    """test 24 — Area #1 W5 rename — wide_medium_rules 4 required sub-key
    (incl subject_reference_policy_cross_ref; W3 가 body_part_focus_cross_ref
    → subject_reference_policy_cross_ref rename)."""
    rs = _make_render_strategy()
    wmr = rs["spatial_consistency"]["primary_framing_rule"]["wide_medium_rules"]
    assert isinstance(wmr, dict)
    expected = set(_SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS)
    returned = set(wmr.keys())
    assert returned >= expected, (
        f"wide_medium_rules missing sub-keys {sorted(expected - returned)!r}"
    )
    spec_expected = {
        "primary_and_secondary_both_visible",
        "body_part_close_up_forbidden",
        "fg_bg_separation_requires_shared_anchor",
        "subject_reference_policy_cross_ref",
    }
    assert returned >= spec_expected, (
        f"wide_medium_rules missing spec-explicit sub-keys "
        f"{sorted(spec_expected - returned)!r}"
    )


# test 25 폐기 (Area #1 W5, 2026-05-16): RO-6 paired drift 차단 (wide_medium_rules) —
# close 와 동일 이유. cross-ref + literal 모두 rename + 폐기.


def test_primary_framing_rule_self_check_steps_count() -> None:
    """test 26 — primary_framing_rule.self_check_steps list-len == 4."""
    rs = _make_render_strategy()
    scs = rs["spatial_consistency"]["primary_framing_rule"]["self_check_steps"]
    assert isinstance(scs, list)
    assert len(scs) == 4, (
        f"primary_framing_rule.self_check_steps len != 4 (got {len(scs)})"
    )


# ──────────────────────────────────────────────────────────────────────────
# test 27 폐기 (Area #1 W5, 2026-05-16): RO-6 paired drift 차단 — cross-ref
# key 자체와 paired literal value 모두 W3 가 rename + 폐기. paired-drift
# 차단은 더 이상 의미 없음. subject_reference_policy_cross_ref 기준
# paired-drift 검증이 필요하면 별도 wave 에서 재작성.
# ──────────────────────────────────────────────────────────────────────────


# ──────────────────────────────────────────────────────────────────────────
# Test 28 — PR4-B3 binding — NO cross-ref in camera_frame_rule.
# ──────────────────────────────────────────────────────────────────────────


def test_camera_frame_rule_no_cross_card_ref() -> None:
    """test 28 — PR4-B3 binding — camera_frame_rule 안 cross-card paired
    substring 'id_policy.subject_reference_policy' 등장 금지 (Rule F lift 는
    cross-card ref 가 primary_framing_rule 에만 위치).

    Area #1 W5 (2026-05-16): paired literal value rename
    (`id_policy.body_part_focus_rule` → `id_policy.subject_reference_policy`).
    """
    rs = _make_render_strategy()
    cfr = rs["spatial_consistency"]["camera_frame_rule"]
    LITERAL = "id_policy.subject_reference_policy"
    # Walk all string values within camera_frame_rule.
    def _walk(value: Any) -> List[str]:
        out = []
        if isinstance(value, str):
            out.append(value)
        elif isinstance(value, list):
            for item in value:
                out.extend(_walk(item))
        elif isinstance(value, dict):
            for v in value.values():
                out.extend(_walk(v))
        return out

    all_strs = _walk(cfr)
    for s in all_strs:
        assert LITERAL not in s, (
            f"camera_frame_rule contains forbidden cross-ref substring "
            f"{LITERAL!r} (PR4-B3 binding — cross-ref must live in "
            f"primary_framing_rule only) — {s!r}"
        )
    # Area #1 W5 carry: 옛 literal (body_part_focus_rule) 도 등장 금지 (regression guard).
    LEGACY_LITERAL = "id_policy.body_part_focus_rule"
    for s in all_strs:
        assert LEGACY_LITERAL not in s, (
            f"camera_frame_rule contains legacy cross-ref substring "
            f"{LEGACY_LITERAL!r} — W3 rename 후 등장 시 stale wording residue: {s!r}"
        )


# ──────────────────────────────────────────────────────────────────────────
# Tests 29-32 — constraints 5 strings (G4.4 base 2 carry + 3 spatial inline).
# ──────────────────────────────────────────────────────────────────────────


def test_constraints_grew_to_five_strings() -> None:
    """test 29 — render_strategy.constraints len == 5 (G4.4 base 2 → G4.5a 5)."""
    rs = _make_render_strategy()
    constraints = rs["constraints"]
    assert len(constraints) == 5, (
        f"render_strategy.constraints len != 5 (got {len(constraints)}): "
        f"{constraints!r}"
    )


def test_constraints_string_1_still_moment_carry() -> None:
    """test 30 — constraints[0] G4.4 carry verbatim string equality."""
    rs = _make_render_strategy()
    expected = "one prompt captures one still moment"
    assert rs["constraints"][0] == expected, (
        f"constraints[0] G4.4 carry drifted — got {rs['constraints'][0]!r}"
    )


def test_constraints_string_2_view_mix_carry() -> None:
    """test 31 — constraints[1] G4.4 carry verbatim string equality."""
    rs = _make_render_strategy()
    expected = (
        "do not combine full-body framing and body-part close-up in one frame"
    )
    assert rs["constraints"][1] == expected, (
        f"constraints[1] G4.4 carry drifted — got {rs['constraints'][1]!r}"
    )


def test_constraints_string_3_camera_frame_inline() -> None:
    """test 32 — constraints[2] inline camera_frame_rule substrings."""
    rs = _make_render_strategy()
    c2 = rs["constraints"][2]
    assert "camera position and frame-edge" in c2, (
        f"constraints[2] missing 'camera position and frame-edge' — {c2!r}"
    )
    assert "render_strategy.spatial_consistency.camera_frame_rule" in c2, (
        f"constraints[2] missing card path reference — {c2!r}"
    )


def test_constraints_string_4_fg_bg_anchor_inline() -> None:
    """test 33 — constraints[3] inline fg_bg_shared_anchor_rule substrings."""
    rs = _make_render_strategy()
    c3 = rs["constraints"][3]
    assert "fg/bg separation" in c3, (
        f"constraints[3] missing 'fg/bg separation' — {c3!r}"
    )
    assert (
        "render_strategy.spatial_consistency.fg_bg_shared_anchor_rule" in c3
    ), (
        f"constraints[3] missing card path reference — {c3!r}"
    )


def test_constraints_string_5_framing_scale_inline() -> None:
    """test 34 — constraints[4] inline primary_framing_rule substrings."""
    rs = _make_render_strategy()
    c4 = rs["constraints"][4]
    assert "framing_scale binds entity rendering" in c4, (
        f"constraints[4] missing 'framing_scale binds entity rendering' — {c4!r}"
    )
    assert (
        "render_strategy.spatial_consistency.primary_framing_rule" in c4
    ), (
        f"constraints[4] missing card path reference — {c4!r}"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 35-36 — spatial_consistency builder-static invariant (Trap #14 / RO-9
# carry). Wave subagent fixture trap 회피 — empty inputs 에서도 4 sub-field
# 모두 builder-static 출력. Empty list usage HERE is INTENTIONAL — validates
# builder-static contract (NOT the Wave subagent fixture trap pattern).
# ──────────────────────────────────────────────────────────────────────────


def test_spatial_consistency_4_subfields_independent_of_input() -> None:
    """test 35 — Trap #14 — staging=None + staging_not_applicable=True 입력에도
    4 sub-field 모두 builder-static 출력. RO-9 invariant."""
    rs_na = build_render_strategy(
        seg=copy.deepcopy(_DEFAULT_SEG),
        shot_info={
            "shot_index": 4,
            "camera_direction": "",
            "primary_subject": "",
            "staging_not_applicable": True,
        },
        staging=None,
        perception_mode=None,
    )
    sc = rs_na["spatial_consistency"]
    # 4 sub-key 모두 present.
    assert "camera_frame_rule" in sc
    assert "fg_bg_shared_anchor_rule" in sc
    assert "primary_framing_rule" in sc
    assert "rationale_summary" in sc
    # constraints len == 5 (builder-static).
    assert len(rs_na["constraints"]) == 5


def test_spatial_consistency_4_subfields_deep_equality_input_independent() -> None:
    """test 36 — RO-9 binding — 두 출력 (staging=None NA / staging=default)
    의 spatial_consistency + constraints 모두 deep equality (입력 무관 builder
    static — `if staging:` 류 truthy guard 안 배치 절대 금지)."""
    rs_na = build_render_strategy(
        seg=copy.deepcopy(_DEFAULT_SEG),
        shot_info={
            "shot_index": 4,
            "camera_direction": "",
            "primary_subject": "",
            "staging_not_applicable": True,
        },
        staging=None,
        perception_mode=None,
    )
    rs_full = _make_render_strategy()
    assert rs_na["spatial_consistency"] == rs_full["spatial_consistency"], (
        "spatial_consistency input-dependent (RO-9 invariant violated) — "
        "builder must be static (Trap #14)"
    )
    assert rs_na["constraints"] == rs_full["constraints"], (
        "constraints list input-dependent (RO-9 invariant violated)"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 37-39 — _card_metadata G4.5a 4 신규 lift_status + 3 신규 rule_source
# keys + hash invariance.
# ──────────────────────────────────────────────────────────────────────────


def test_card_metadata_lift_status_includes_spatial_keys() -> None:
    """test 37 — Override O-9 — 4 G4.5a lift_status keys all present + True
    (canonical names — abbrev 금지)."""
    card = _make_full_card()
    lift_status = card["_card_metadata"]["lift_status"]
    for key in (
        "spatial_camera_frame_lifted",
        "spatial_fg_bg_shared_anchor_lifted",
        "spatial_primary_framing_lifted",
        "spatial_constraints_extended",
    ):
        assert key in lift_status, (
            f"G4.5a lift_status key {key!r} missing — got "
            f"{sorted(lift_status.keys())!r}"
        )
        assert lift_status[key] is True, (
            f"G4.5a {key!r} expected True, got {lift_status[key]!r}"
        )


def test_card_metadata_rule_source_includes_spatial_keys() -> None:
    """test 38 — _card_metadata.rule_source 3 신규 G4.5a keys present."""
    card = _make_full_card()
    rs_meta = card["_card_metadata"]["rule_source"]
    for key in (
        "camera_frame_rule",
        "fg_bg_shared_anchor_rule",
        "primary_framing_rule",
    ):
        assert key in rs_meta, (
            f"G4.5a rule_source key {key!r} missing — got "
            f"{sorted(rs_meta.keys())!r}"
        )


def test_card_metadata_excluded_from_hash_after_g4_5a_keys_added() -> None:
    """test 39 — G4.2 R1-B1 + G4.3/G4.4 carry — adding 4 G4.5a lift_status
    keys + mutating `_card_metadata.lift_status` does NOT change
    `compute_card_hash()` result (envelope-sibling hash exclusion)."""
    card_a = _make_full_card()
    h_a = compute_card_hash(card_a)
    card_b = copy.deepcopy(card_a)
    # Mutate G4.5a-specific lift_status booleans (canonical names).
    card_b["_card_metadata"]["lift_status"][
        "spatial_camera_frame_lifted"
    ] = False
    card_b["_card_metadata"]["lift_status"][
        "spatial_fg_bg_shared_anchor_lifted"
    ] = False
    card_b["_card_metadata"]["lift_status"][
        "spatial_primary_framing_lifted"
    ] = False
    card_b["_card_metadata"]["lift_status"][
        "spatial_constraints_extended"
    ] = False
    # Mutate G4.5a rule_source.
    card_b["_card_metadata"]["rule_source"][
        "camera_frame_rule"
    ] = "MUTATED"
    # Add an extra free-form key.
    card_b["_card_metadata"]["custom_g4_5a_extra"] = "diagnostic-only"
    assert compute_card_hash(card_b) == h_a, (
        "card hash drifted on `_card_metadata.lift_status` mutation — "
        "R1-B1 (envelope-sibling hash exclusion) broken for G4.5a keys"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 40-41 — hash drift selectors (content vs lift_status only).
# ──────────────────────────────────────────────────────────────────────────


def test_card_hash_drifts_on_spatial_consistency_change() -> None:
    """test 40 — render_strategy.spatial_consistency.camera_frame_rule.
    forbidden_combinations[0] 1-char tail change → hash drift."""
    card_a = _make_full_card()
    h_a = compute_card_hash(card_a)
    card_b = copy.deepcopy(card_a)
    fc = card_b["render_strategy"]["spatial_consistency"][
        "camera_frame_rule"
    ]["forbidden_combinations"]
    fc[0] = fc[0] + "X"
    h_b = compute_card_hash(card_b)
    assert h_a != h_b, (
        "card hash did not drift on render_strategy.spatial_consistency."
        "camera_frame_rule.forbidden_combinations content change — "
        "verify_completion would mark this clean"
    )


def test_card_hash_stable_on_spatial_lift_status_only_change() -> None:
    """test 41 — _card_metadata.lift_status.spatial_camera_frame_lifted toggle
    → hash 동일 (envelope-sibling hash exclusion)."""
    card_a = _make_full_card()
    h_a = compute_card_hash(card_a)
    card_b = copy.deepcopy(card_a)
    card_b["_card_metadata"]["lift_status"][
        "spatial_camera_frame_lifted"
    ] = False
    h_b = compute_card_hash(card_b)
    assert h_a == h_b, (
        "card hash drifted on _card_metadata.lift_status.spatial_camera_"
        "frame_lifted toggle — R1-B1 envelope-sibling hash exclusion broken"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 42-45 — _assert_spatial_consistency_shape() strict validator.
# ──────────────────────────────────────────────────────────────────────────


def test_assert_spatial_consistency_shape_passes_full() -> None:
    """test 42 — full spatial_consistency dict passes shape validator without
    raising."""
    rs = _make_render_strategy()
    # Should not raise.
    _assert_spatial_consistency_shape(rs["spatial_consistency"], where="test")


def test_assert_spatial_consistency_shape_fails_missing_camera_frame_rule() -> None:
    """test 43 — _assert_spatial_consistency_shape fails when camera_frame_rule
    missing OR camera_frame_rule has missing required key (RO-3)."""
    rs = _make_render_strategy()
    sc_missing = copy.deepcopy(rs["spatial_consistency"])
    sc_missing.pop("camera_frame_rule")
    with pytest.raises(AppError):
        _assert_spatial_consistency_shape(sc_missing, where="test")
    # Each of 7 required keys removed → AppError.
    for required_key in (
        "applies_when",
        "consistency_check_summary",
        "core_principles",
        "forbidden_combinations",
        "recommended_phrasings",
        "self_check_steps",
        "rationale_summary",
    ):
        sc_partial = copy.deepcopy(rs["spatial_consistency"])
        sc_partial["camera_frame_rule"].pop(required_key, None)
        with pytest.raises(AppError):
            _assert_spatial_consistency_shape(sc_partial, where="test")


def test_assert_spatial_consistency_shape_fails_missing_fg_bg_anchor_rule() -> None:
    """test 44 — _assert_spatial_consistency_shape fails when
    fg_bg_shared_anchor_rule missing OR has missing required key."""
    rs = _make_render_strategy()
    sc_missing = copy.deepcopy(rs["spatial_consistency"])
    sc_missing.pop("fg_bg_shared_anchor_rule")
    with pytest.raises(AppError):
        _assert_spatial_consistency_shape(sc_missing, where="test")
    # Each of 6 required keys removed → AppError.
    for required_key in (
        "applies_when",
        "shared_anchor_required",
        "forbidden_phrasings",
        "recommended_keywords",
        "self_check_steps",
        "rationale_summary",
    ):
        sc_partial = copy.deepcopy(rs["spatial_consistency"])
        sc_partial["fg_bg_shared_anchor_rule"].pop(required_key, None)
        with pytest.raises(AppError):
            _assert_spatial_consistency_shape(sc_partial, where="test")


def test_assert_spatial_consistency_shape_fails_missing_primary_framing_rule() -> None:
    """test 45 — _assert_spatial_consistency_shape fails when
    primary_framing_rule missing (further nested helper invocation)."""
    rs = _make_render_strategy()
    sc_missing = copy.deepcopy(rs["spatial_consistency"])
    sc_missing.pop("primary_framing_rule")
    with pytest.raises(AppError):
        _assert_spatial_consistency_shape(sc_missing, where="test")


# ──────────────────────────────────────────────────────────────────────────
# Tests 46-48 — _assert_primary_framing_rule_shape() further nested helper
# (RO-9 / PR4-I5 binding).
# ──────────────────────────────────────────────────────────────────────────


def test_assert_primary_framing_rule_shape_validates_close_framing_rules() -> None:
    """test 47 — RO-6 / PR4-I5 binding — _assert_primary_framing_rule_shape
    fails when close_framing_rules missing 4 sub-key (incl
    subject_reference_policy_cross_ref; Area #1 W5 rename).
    """
    rs = _make_render_strategy()
    pf = copy.deepcopy(rs["spatial_consistency"]["primary_framing_rule"])
    for required_key in (
        "primary_subject_only_fully_visible",
        "other_entities_appearance_options",
        "forbidden_combinations",
        "subject_reference_policy_cross_ref",
    ):
        pf_partial = copy.deepcopy(pf)
        pf_partial["close_framing_rules"].pop(required_key, None)
        with pytest.raises(AppError):
            _assert_primary_framing_rule_shape(pf_partial, where="test")


def test_assert_primary_framing_rule_shape_validates_wide_medium_rules() -> None:
    """test 48 — RO-6 / PR4-I5 binding — _assert_primary_framing_rule_shape
    fails when wide_medium_rules missing 4 sub-key (incl
    subject_reference_policy_cross_ref; Area #1 W5 rename).
    """
    rs = _make_render_strategy()
    pf = copy.deepcopy(rs["spatial_consistency"]["primary_framing_rule"])
    for required_key in (
        "primary_and_secondary_both_visible",
        "body_part_close_up_forbidden",
        "fg_bg_separation_requires_shared_anchor",
        "subject_reference_policy_cross_ref",
    ):
        pf_partial = copy.deepcopy(pf)
        pf_partial["wide_medium_rules"].pop(required_key, None)
        with pytest.raises(AppError):
            _assert_primary_framing_rule_shape(pf_partial, where="test")


def test_assert_primary_framing_rule_shape_validates_cross_ref_strings() -> None:
    """test 49 — RO-6 / PR4-I5 — paired-substring 누락 시 AppError. Both
    close_framing_rules + wide_medium_rules subject_reference_policy_cross_ref
    must contain `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (Area #1 W5: rename
    cross-ref key, literal value 도 `id_policy.subject_reference_policy` 로 rename).
    """
    rs = _make_render_strategy()
    pf = copy.deepcopy(rs["spatial_consistency"]["primary_framing_rule"])
    # Mutate close_framing_rules.subject_reference_policy_cross_ref to drop literal.
    pf_close_drift = copy.deepcopy(pf)
    pf_close_drift["close_framing_rules"]["subject_reference_policy_cross_ref"] = (
        "no paired literal here"
    )
    with pytest.raises(AppError):
        _assert_primary_framing_rule_shape(pf_close_drift, where="test")
    # Mutate wide_medium_rules.subject_reference_policy_cross_ref similarly.
    pf_wm_drift = copy.deepcopy(pf)
    pf_wm_drift["wide_medium_rules"]["subject_reference_policy_cross_ref"] = (
        "drift literal here"
    )
    with pytest.raises(AppError):
        _assert_primary_framing_rule_shape(pf_wm_drift, where="test")


# ──────────────────────────────────────────────────────────────────────────
# Tests 51-52 — input validation (silent-fallback ban + empty camera).
# ──────────────────────────────────────────────────────────────────────────


def test_render_strategy_camera_direction_none_raises() -> None:
    """test 51 — staging=None + staging_not_applicable=False → AppError
    (G4.1 R1-I1 carry — silent fallback ban)."""
    with pytest.raises(AppError):
        build_render_strategy(
            seg=copy.deepcopy(_DEFAULT_SEG),
            shot_info={
                "shot_index": 4,
                "camera_direction": "medium shot",
                "primary_subject": "the observer",
                # staging_not_applicable absent (default False) → must raise.
            },
            staging=None,
            perception_mode=None,
        )


def test_render_strategy_camera_direction_empty_string_valid_with_static_subfields() -> None:
    """test 52 — staging.camera_direction="" (explicit empty) → 4 sub-field
    모두 present (builder-static 보장)."""
    rs = build_render_strategy(
        seg=copy.deepcopy(_DEFAULT_SEG),
        shot_info={
            "shot_index": 4,
            "camera_direction": "",
            "primary_subject": "the observer",
        },
        staging={
            "camera_direction": "",
            "framing_scale": "medium",
            "lighting_mood": "warm dim",
        },
        perception_mode=None,
    )
    sc = rs["spatial_consistency"]
    assert "camera_frame_rule" in sc
    assert "fg_bg_shared_anchor_rule" in sc
    assert "primary_framing_rule" in sc
    assert "rationale_summary" in sc


# ──────────────────────────────────────────────────────────────────────────
# Test 53 — view_consistency.mixing_forbidden bool literal (G4.4 carry
# regression — G4.5a 변경 0 검증).
# ──────────────────────────────────────────────────────────────────────────


def test_render_strategy_view_consistency_mixing_forbidden_is_bool_true() -> None:
    """test 53 — G4.4 carry regression — continuity_elements_used.
    view_consistency.mixing_forbidden is True AND type(...) is bool. G4.5a
    가 G4.4 영역 변경 0 검증."""
    card = _make_full_card()
    mixing_forbidden = card["continuity_elements_used"]["view_consistency"][
        "mixing_forbidden"
    ]
    assert mixing_forbidden is True and type(mixing_forbidden) is bool, (
        f"mixing_forbidden must be bool literal True — got "
        f"type={type(mixing_forbidden).__name__} value={mixing_forbidden!r}"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 54-58 — render_strategy structural invariants (PR4-B6 / RO-7 /
# 5-field envelope).
# ──────────────────────────────────────────────────────────────────────────


def test_render_strategy_8_base_keys_preserved() -> None:
    """test 54 — PR4-B6 binding — G4.1+G4.4 base 8 sibling key 보존
    (G4.5a 가 spatial_consistency NEW sibling 만 추가)."""
    rs = _make_render_strategy()
    base_keys = {
        "mode",
        "primary_subject",
        "framing_scale",
        "moment_lock",
        "camera_direction",
        "lighting_mood",
        "perception_mode",
        "constraints",
    }
    assert base_keys <= set(rs.keys()), (
        f"render_strategy missing G4.1+G4.4 base keys "
        f"{sorted(base_keys - set(rs.keys()))!r}"
    )


def test_render_strategy_spatial_consistency_is_sibling() -> None:
    """test 55 — RO-7 binding — spatial_consistency 가 render_strategy 의
    NEW sibling (NOT new top-level field, NOT nested under another sub-key)."""
    rs = _make_render_strategy()
    assert "spatial_consistency" in rs, (
        "spatial_consistency must be sibling of render_strategy base keys"
    )
    assert isinstance(rs["spatial_consistency"], dict)


def test_envelope_top_level_5_fields_unchanged() -> None:
    """test 56 — 5-field envelope invariant — envelope top-level 7 contract
    fields (schema_version + shot_key + 5 semantic) 변경 0 (사용자 binding —
    새 top-level field 도입 절대 금지)."""
    card = _make_full_card()
    expected_top_level = {
        "schema_version",
        "shot_key",
        "render_strategy",
        "id_policy",
        "background_binding",
        "continuity_elements_used",
        "asset_requirements",
    }
    card_keys_no_metadata = set(card.keys()) - {"_card_metadata"}
    assert expected_top_level <= card_keys_no_metadata, (
        f"envelope top-level missing required fields "
        f"{sorted(expected_top_level - card_keys_no_metadata)!r}"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 57-59 — compute_render_strategy_snapshot_hash (PR4-B4 / PR4-I2).
# ──────────────────────────────────────────────────────────────────────────


def test_compute_render_strategy_snapshot_hash_returns_16_hex() -> None:
    """test 57 — compute_render_strategy_snapshot_hash returns 16-char
    lowercase hex sha256 prefix."""
    card = _make_full_card()
    h = compute_render_strategy_snapshot_hash(card)
    assert isinstance(h, str)
    assert len(h) == 16, f"hash len != 16 (got {len(h)})"
    assert all(c in "0123456789abcdef" for c in h), (
        f"hash contains non-hex char — {h!r}"
    )


def test_compute_render_strategy_snapshot_hash_deterministic() -> None:
    """test 58 — compute_render_strategy_snapshot_hash deterministic on same
    input."""
    card = _make_full_card()
    h_a = compute_render_strategy_snapshot_hash(card)
    h_b = compute_render_strategy_snapshot_hash(card)
    assert h_a == h_b


def test_compute_render_strategy_snapshot_hash_changes_on_spatial_change() -> None:
    """test 59 — compute_render_strategy_snapshot_hash drifts on
    spatial_consistency content change (canary pinning sensitivity)."""
    card_a = _make_full_card()
    h_a = compute_render_strategy_snapshot_hash(card_a)
    card_b = copy.deepcopy(card_a)
    card_b["render_strategy"]["spatial_consistency"]["camera_frame_rule"][
        "forbidden_combinations"
    ][0] += "X"
    h_b = compute_render_strategy_snapshot_hash(card_b)
    assert h_a != h_b, (
        "compute_render_strategy_snapshot_hash did not drift on "
        "spatial_consistency content change"
    )


def test_NOT_compute_spatial_consistency_snapshot_hash() -> None:
    """test 60 — PR4-I2 binding — narrower naming
    `compute_spatial_consistency_snapshot_hash` is BANNED. Only the broader
    `compute_render_strategy_snapshot_hash` exists."""
    from app.core.steps import render_prompt_card as rpc_module

    assert not hasattr(rpc_module, "compute_spatial_consistency_snapshot_hash"), (
        "module exposes BANNED narrower 'compute_spatial_consistency_snapshot_"
        "hash' — PR4-I2 binding violated"
    )
    assert hasattr(rpc_module, "compute_render_strategy_snapshot_hash"), (
        "module missing compute_render_strategy_snapshot_hash"
    )


# ──────────────────────────────────────────────────────────────────────────
# Test 61 — assert_card_shape end-to-end (full envelope-level shape valid).
# ──────────────────────────────────────────────────────────────────────────


def test_assert_card_shape_passes_with_g4_5a_card() -> None:
    """test 61 — full G4.5a card passes assert_card_shape() including the
    integrated _assert_spatial_consistency_shape() inline check."""
    card = _make_full_card()
    # Should not raise.
    assert_card_shape(card, where="test")


def test_assert_card_shape_fails_when_spatial_consistency_missing() -> None:
    """test 62 — assert_card_shape raises AppError when
    render_strategy.spatial_consistency missing (G4.5a inline integration)."""
    card = _make_full_card()
    card_drift = copy.deepcopy(card)
    card_drift["render_strategy"].pop("spatial_consistency")
    with pytest.raises(AppError):
        assert_card_shape(card_drift, where="test")
