"""팩이 **요구한 칸**이 심판까지 실제로 닿나. ★유료 0.

## 왜 이 시험이 있나 (2026-08-31 실측 사고)

사용자 지적 —「니 말이 맞아. 너무 협소하게 판단해 VLM이 판단할 수 없어.
절대로 사람도 잘 못하는데.」— 를 닫으려고 팩 `4.202608311430` 에
`coarse_type_label` 을 **required** 로 넣고 `kind_name_of` 가 그것만 쓰게
고쳤다. 시험도 통과했다.

그런데 **유료 판 12개 대상 전부 label 이 `None`** 이었다. 왜냐하면 —

    모델 raw          46/46 ○   ← 모델은 다 냈다
    resolve_rows      38/38 ○
    reduce_episode    38/38 ○
    targets_from       0/12 ★   ← **여기서 안 옮겼다**

`targets_from` 이 손으로 고른 칸 목록으로 새 dict 를 짓는데 거기 이 칸이
없었다. fallback 이 조용히 `surface_form` 을 줘서, 심판은 「됫박」이라는
**표기 그대로**를 받았다 — 막으려던 바로 그것이다. 그 대상은 참조를 못 구했다.

★★**칸을 더하면 그 칸을 복사하는 줄을 찾아라.** 이 시험은 파이프를
**끝에서 끝까지** 걸어 그것을 잡는다.
"""
from __future__ import annotations

import pytest

from app.modules.pipeline import grounding_chunk as gc
from tools.grounding_audit import ref_canary as rc

#: ★★얼어붙은 판독이 **어느 팩으로** 산 것인가. 여기 명시한다.
#:  팩을 올리면 획득 신원이 바뀌어 그 장부를 못 읽고, 시험은 **다시 사지
#:  않고 선다**(그것이 맞는 동작이다). 그래서 냉동 주행은 제 팩을 못박는다 —
#:  이 값을 올리려면 그 팩으로 **새로 사야** 한다.
FROZEN_PACK = "4.202608311430"

#: 팩이 required 로 요구하고 **심판·검색이 실제로 쓰는** 칸.
#:  ★여기 이름을 더하면 아래 파이프 시험이 그 칸도 끝까지 따라간다.
MUST_REACH_TARGET = ("coarse_type_label", "surface_form", "visual_brief",
                     "owner_type")


class TestThePackRequiresIt:
    def test_the_schema_demands_the_label(self):
        sch = gc.build_chunk_payload(["scene-1"], {"scene-1": "글"}, "W",
                                     shot_catalog=[])["schema"]
        import json

        s = json.dumps(sch, ensure_ascii=False)
        assert "coarse_type_label" in s
        assert '"coarse_type_label"' in s


class TestItSurvivesEveryStage:
    """★★한 단계씩 걸어 **어디서 사라지는지** 알 수 있게 한다."""

    @staticmethod
    def _row():
        return {"local_id": "c0#1", "owner_type": "prop",
                "surface_form": "됫박", "coarse_type_label": "곡식 됫박",
                "visual_brief": "네모난 나무 그릇",
                "hard_to_generate": True, "viewers_would_notice": True,
                "occurrences": [], "shot_appearance_ids": [],
                "shot_binding_status": "bound_complete",
                "search_terms_native": [], "language_lock_native": "ko",
                "evidence_quotes": []}

    def test_targets_from_carries_it(self):
        reduced = {"rows": [self._row()], "registered": {}, "part_of": []}
        got = rc.targets_from(reduced)
        assert got, "★대상이 안 나왔다 — 시험이 죽었다"
        for k in MUST_REACH_TARGET:
            assert str(got[0].get(k) or "").strip(), \
                f"★`targets_from` 이 {k} 를 안 옮긴다"

    def test_the_judge_gets_the_label_not_the_surface_form(self):
        reduced = {"rows": [self._row()], "registered": {}, "part_of": []}
        t = rc.targets_from(reduced)[0]
        assert rc.kind_name_of(t) == "곡식 됫박"
        assert rc.kind_name_of(t) != t["surface_form"], \
            "★심판이 표기 그대로를 받는다 — 막으려던 바로 그것이다"

    def test_the_fallback_is_the_only_way_to_the_surface_form(self):
        """★positive control — fallback 자체는 남긴다(옛 팩으로 산 행 때문에).

        다만 **그것이 켜졌다는 것**이 곧 결함 신호다.
        """
        t = {"surface_form": "됫박", "coarse_type_label": ""}
        assert rc.kind_name_of(t) == "됫박"

    def test_the_search_still_gets_the_appearance(self):
        """★부류는 심판에게, 겉모습은 **검색에** — 갈라진 채로 닿나."""
        reduced = {"rows": [self._row()], "registered": {}, "part_of": []}
        t = rc.targets_from(reduced)[0]
        out = rc.brief_outbound(t, world_facts="W", source_text="S",
                                narrow=False)
        assert "네모난 나무 그릇" in out["user"], "★검색이 겉모습을 못 받는다"
        assert "곡식 됫박" in out["user"], "★검색이 부류 이름을 못 받는다"
        assert "네모난 나무 그릇" not in rc.kind_name_of(t), \
            "★심판에게 겉모습이 간다"


class TestTheFrozenRunShowsTheDefectIsGone:
    """★★얼어붙은 실제 판독으로 **끝에서 끝까지** 걸어 본다.

    합성 행 하나로는 「내가 넣은 것이 나온다」밖에 못 본다. 실제 모델 산출로
    걸어야 조립 어느 칸에서 새는지 잡는다.
    """

    def test_every_real_target_carries_the_label(self):
        import json
        from pathlib import Path

        from tools.grounding_audit import cc_runner as rr
        from app.modules.pipeline import grounding_shot_catalog as sc
        from tests.grounding.fixtures import period_episode as ep

        J = (Path(__file__).resolve().parents[3] / "artifact"
             / "20260831_period_canary" / "v11.json")
        if not J.exists():
            pytest.skip("얼어붙은 판독이 없다")
        segs = ep.segment_texts()
        plan, cats = [], {}
        for n, b in enumerate(ep.bundles()):
            ids = [f"scene-{i}" for i in b]
            cat = sc.build_catalog(ep.shot_scenes(), ids)
            cats[f"c{n}"] = cat
            plan.append({"chunk_id": f"c{n}", "segment_ids": ids,
                         "payload": gc.build_chunk_payload(
                             ids, segs, ep.WORLD_FACTS, version=FROZEN_PACK,
                             shot_catalog=cat)})
        try:
            got = rr.replay(J, world_facts=ep.WORLD_FACTS, plan=plan,
                            segments=segs, shot_catalogs=cats)
        except LookupError as exc:
            # ★처리 계약 4(2026-09-02, 검증된 자리만 남기는 격리)가 **행 집합**을 바꿨다 —
            #  옛 장부의 merge 응답은 옛 행 집합의 것이라 신원이 안 맞는다. 재채점은 사지
            #  않는다(맞는 정책). 다시 얼리려면 merge 호출 1회를 사람이 승인해야 한다.
            pytest.skip(f"얼어붙은 장부에 이번 행 집합의 merge 응답이 없다 — {exc}")
        ts = rc.targets_from(got["reduced"])
        assert ts, "★대상이 0개 — 시험이 죽었다"
        missing = [t["surface_form"] for t in ts
                   if not str(t.get("coarse_type_label") or "").strip()]
        assert not missing, \
            f"★{len(missing)}/{len(ts)} 대상이 부류 이름 없이 심판에게 간다: " \
            f"{missing[:4]}"

    def test_no_target_hands_the_surface_form_to_the_judge(self):
        import json
        from pathlib import Path

        from tools.grounding_audit import cc_runner as rr
        from app.modules.pipeline import grounding_shot_catalog as sc
        from tests.grounding.fixtures import period_episode as ep

        J = (Path(__file__).resolve().parents[3] / "artifact"
             / "20260831_period_canary" / "v11.json")
        if not J.exists():
            pytest.skip("얼어붙은 판독이 없다")
        segs = ep.segment_texts()
        plan, cats = [], {}
        for n, b in enumerate(ep.bundles()):
            ids = [f"scene-{i}" for i in b]
            cat = sc.build_catalog(ep.shot_scenes(), ids)
            cats[f"c{n}"] = cat
            plan.append({"chunk_id": f"c{n}", "segment_ids": ids,
                         "payload": gc.build_chunk_payload(
                             ids, segs, ep.WORLD_FACTS, version=FROZEN_PACK,
                             shot_catalog=cat)})
        try:
            got = rr.replay(J, world_facts=ep.WORLD_FACTS, plan=plan,
                            segments=segs, shot_catalogs=cats)
        except LookupError as exc:
            # ★처리 계약 4(2026-09-02, 검증된 자리만 남기는 격리)가 **행 집합**을 바꿨다 —
            #  옛 장부의 merge 응답은 옛 행 집합의 것이라 신원이 안 맞는다. 재채점은 사지
            #  않는다(맞는 정책). 다시 얼리려면 merge 호출 1회를 사람이 승인해야 한다.
            pytest.skip(f"얼어붙은 장부에 이번 행 집합의 merge 응답이 없다 — {exc}")
        for t in rc.targets_from(got["reduced"]):
            assert rc.kind_name_of(t) == t["coarse_type_label"], \
                f"★{t['surface_form']!r} 가 fallback 으로 떨어졌다"
