"""CARRIED 가 **그 샷에 보이는 인물만** 말한다 (2026-08-27, 감사 1-B).

한 프롬프트 안에서 두 절이 맞섰다:

    PEOPLE: … they must be one of: 연희 — never anyone else …
    CARRIED STATE (persist exactly …): … the old man remains lying …,
            with Minsu crouched beside him.

실측(대조 4/4 통과한 판정기): 골목 끝 7샷 중 4샷 · 큰 판 표본 302샷 중
68샷(23%)이 목록 밖 인물을 말한다. 08-26 reve 변환 반려 사유
「같은 두 남성이 여러 번 반복」이 그 결과다.

★**자가신고는 계약이 아니다** (표기 저작 v3 에서 배운 것). 모델에게
「이 사람은 이 샷에 보인다」를 신고시키지 않는다. 모델은 **누구의 무슨
상태**만 말하고, 화면 안인지는 **코드가 그 샷의 visible entity id 로**
정한다.

★**끝점을 재는 시험을 먼저 쓴다.** 표기 저작 판에서 저작만 재고 「됐다」
 고 했다가 조립이 산출을 전량 버리는 것을 놓쳤다.
"""

import pytest

from app.modules.pipeline.shot_continuity_author import (
    CARRIED_CONTRACT_KEY,
    CARRIED_CONTRACT_SCOPED,
    LegacyCarriedContract,
    carried_text_for_shot,
    pack_has_people_contract,
    row_has_people_contract,
)

V3 = {
    CARRIED_CONTRACT_KEY: CARRIED_CONTRACT_SCOPED,
    "objects_en": "The toolbox stays open on the workbench.",
    "people": [
        {"character_ko": "연희", "character_short_id": "C01",
         "state_en": "Yeonhui still carries her canvas bag.",
         "basis_ko": "근거"},
        {"character_ko": "민수", "character_short_id": "C02",
         "state_en": "Minsu is still in his navy work clothes.",
         "basis_ko": "근거"},
    ],
    "offscreen_effects_en": "A wedge of light from the corridor falls"
                            " across the floor.",
    "basis_ko": "근거",
}
V2 = {"carried_en": "The toolbox stays open; Minsu is still in his navy"
                    " work clothes.", "basis_ko": "근거"}


# ── 갈래를 무엇이 정하는가 ───────────────────────────────────────────

def test_the_pack_version_decides_what_the_author_produces():
    assert not pack_has_people_contract("2")
    assert pack_has_people_contract("3")


def test_the_data_decides_how_a_stored_row_is_read():
    """★**팩 selector 로 저장된 값을 판단하면 안 된다.**

    selector 는 지금 도는 코드의 값이고 체크포인트는 옛 판에서 만들어졌을
    수 있다. 둘이 어긋나면 옛 한 칸을 새 계약처럼 읽어 엉뚱한 값을
    프롬프트에 싣는다 (2026-08-27 Codex 판정).
    """
    assert row_has_people_contract(V3)
    assert not row_has_people_contract(V2)
    assert not row_has_people_contract({})
    assert not row_has_people_contract(None)
    assert not row_has_people_contract({CARRIED_CONTRACT_KEY: "무언가"})


# ── 인물 샷: 보이는 사람만 ───────────────────────────────────────────

def test_only_the_people_visible_in_this_shot_reach_the_prompt():
    """★이 판의 표적. C02(민수)가 그 샷에 없으면 그 줄이 안 나간다."""
    got = carried_text_for_shot(
        V3, visible_short_ids={"C01"}, bg_only=False)

    assert "Yeonhui still carries her canvas bag" in got
    assert "Minsu" not in got, (
        "그 샷에 없는 인물의 상태가 프롬프트에 실렸다 — 「연희만」과 "
        "「민수도 여전히 있다」가 같은 글에 나간다")


def test_every_visible_person_reaches_the_prompt():
    got = carried_text_for_shot(
        V3, visible_short_ids={"C01", "C02"}, bg_only=False)
    assert "Yeonhui still carries" in got
    assert "Minsu is still in his navy" in got


def test_objects_always_reach_the_prompt():
    for vis in ({"C01"}, set()):
        got = carried_text_for_shot(V3, visible_short_ids=vis, bg_only=False)
        assert "The toolbox stays open" in got, (
            "사물은 인물 목록과 무관하게 유지돼야 한다")


def test_offscreen_effects_reach_the_prompt():
    """화면 밖 **원인**은 안 그리되 그 **결과**는 화면 안에 있다."""
    got = carried_text_for_shot(V3, visible_short_ids=set(), bg_only=False)
    assert "wedge of light" in got


def test_the_order_is_fixed():
    """조립 끝점이 넷이라 순서가 흔들리면 지문이 샷마다 달라진다."""
    got = carried_text_for_shot(
        V3, visible_short_ids={"C01", "C02"}, bg_only=False)
    assert (got.index("toolbox") < got.index("Yeonhui")
            < got.index("Minsu") < got.index("wedge"))


# ── 무인 샷: 사람 칸은 통째로 빠진다 ─────────────────────────────────

def test_a_plate_shot_gets_no_person_at_all():
    """무인 샷 882개 중 212개(24%)에 사람 얘기가 나가던 자리."""
    got = carried_text_for_shot(
        V3, visible_short_ids={"C01", "C02"}, bg_only=True)
    assert "The toolbox stays open" in got
    assert "wedge of light" in got
    assert "Yeonhui" not in got and "Minsu" not in got, (
        "무인 샷에 사람 상태가 나갔다")


def test_a_plate_shot_with_only_people_yields_nothing():
    """★붙일 것이 없으면 **절 자체가 안 나가야** 한다 — 빈 머리말만 남는
    프롬프트는 모델에게 지시가 아니라 잡음이다."""
    only_people = {
        CARRIED_CONTRACT_KEY: CARRIED_CONTRACT_SCOPED,
        "objects_en": "", "offscreen_effects_en": "",
        "people": [{"character_ko": "연희", "character_short_id": "C01",
                    "state_en": "Yeonhui waits by the door.",
                    "basis_ko": ""}]}
    assert carried_text_for_shot(
        only_people, visible_short_ids={"C01"}, bg_only=True) == ""


# ── 옛 값 — 새 그림을 사는 자리에서는 멈춘다 ─────────────────────────

def test_a_legacy_row_stops_before_a_new_image_is_bought():
    """★「옛 그림은 이미 나왔다」와 「옛 값이 앞으로 안 쓰인다」는 다르다.

    `scene_image` 나 콘티만 force 하면 옛 한 칸으로 **새 그림을 다시
    산다** — 그러면 이 판이 막으려던 것이 그대로 재발한다.
    """
    for bg in (False, True):
        with pytest.raises(LegacyCarriedContract) as e:
            carried_text_for_shot(V2, visible_short_ids={"C01"}, bg_only=bg)
        assert "force" in str(e.value), "무엇을 하라는지가 없다"


def test_a_legacy_row_can_still_be_read_for_audit():
    """이미 만들어진 자산을 열어 보는 것은 막지 않는다."""
    got = carried_text_for_shot(
        V2, visible_short_ids={"C01"}, bg_only=False, strict=False)
    assert got == V2["carried_en"]


def test_an_empty_legacy_row_does_not_stop_anything():
    """빈 값은 옛 계약이라도 위험하지 않다 — 붙일 것이 없다."""
    assert carried_text_for_shot(
        {"carried_en": "", "basis_ko": "x"},
        visible_short_ids=set(), bg_only=False) == ""


def test_an_empty_row_yields_nothing():
    assert carried_text_for_shot(
        {}, visible_short_ids=set(), bg_only=False) == ""
    assert carried_text_for_shot(
        None, visible_short_ids=set(), bg_only=False) == ""


# ── 신원을 무엇으로 맞추나 ───────────────────────────────────────────

def test_a_person_without_a_resolved_id_is_not_injected():
    """★이름이 정본 명단에 정확히 일치하지 않으면 id 가 안 붙는다.

    그때 「모르겠으니 넣는다」로 가면 이 판이 막으려던 것이 그대로
    돌아온다. **못 맞추면 안 넣는다** — 연속성은 기록에 남는다.
    """
    row = {CARRIED_CONTRACT_KEY: CARRIED_CONTRACT_SCOPED,
           "objects_en": "", "offscreen_effects_en": "",
           "people": [{"character_ko": "연희", "character_short_id": None,
                       "state_en": "Yeonhui still carries her bag.",
                       "basis_ko": ""}]}
    assert carried_text_for_shot(
        row, visible_short_ids={"C01"}, bg_only=False) == ""


def test_matching_is_by_id_not_by_name():
    """이름이 같아도 id 로 맞춘다 — 글자 부분매칭 금지 규칙."""
    row = {CARRIED_CONTRACT_KEY: CARRIED_CONTRACT_SCOPED,
           "objects_en": "", "offscreen_effects_en": "",
           "people": [{"character_ko": "연희", "character_short_id": "C01",
                       "state_en": "Yeonhui waits.", "basis_ko": ""}]}
    assert carried_text_for_shot(
        row, visible_short_ids={"C99"}, bg_only=False) == ""
    assert "Yeonhui waits" in carried_text_for_shot(
        row, visible_short_ids={"C01"}, bg_only=False)


def test_a_malformed_person_entry_is_skipped_not_crashed():
    row = {CARRIED_CONTRACT_KEY: CARRIED_CONTRACT_SCOPED,
           "objects_en": "The door is ajar.", "offscreen_effects_en": "",
           "people": ["문자열", None, {"character_short_id": "C01"}]}
    assert carried_text_for_shot(
        row, visible_short_ids={"C01"}, bg_only=False) == "The door is ajar."


@pytest.mark.parametrize("bg_only", [False, True])
def test_the_filter_never_returns_a_bare_heading(bg_only):
    """어떤 입력에서도 「머리말만 있고 본문이 빈」 문자열은 안 나온다."""
    for row in ({}, {CARRIED_CONTRACT_KEY: CARRIED_CONTRACT_SCOPED},
                {CARRIED_CONTRACT_KEY: CARRIED_CONTRACT_SCOPED,
                 "objects_en": "   ", "people": [],
                 "offscreen_effects_en": ""}):
        got = carried_text_for_shot(
            row, visible_short_ids=set(), bg_only=bg_only)
        assert got == "" and got.strip() == got


# ── 네 조립부가 **다 같은 함수**를 타는가 ────────────────────────────

def test_no_assembly_site_reads_the_raw_value_itself():
    """★같은 계약을 네 곳에 손으로 쓰면 한 곳이 빠진다.

    이 시험은 「CARRIED 원값을 직접 꺼내 쓰는 자리」가 남았는지 본다.
    `carried_text_for_shot` 을 태우는 시험만으로는, 조립부가 그 함수를
    안 부르면 여전히 초록이다 (표기 저작 판에서 실제로 겪었다).
    """
    import ast
    import pathlib

    app = pathlib.Path(__file__).resolve().parents[2] / "app"
    stray = []
    for p in sorted(app.rglob("*.py")):
        src = p.read_text(encoding="utf-8", errors="ignore")
        if "carried_en" not in src:
            continue
        try:
            tree = ast.parse(src)
        except SyntaxError:
            continue
        for node in ast.walk(tree):
            if not (isinstance(node, ast.Call)
                    and isinstance(node.func, ast.Attribute)
                    and node.func.attr == "get"
                    and node.args
                    and isinstance(node.args[0], ast.Constant)
                    and node.args[0].value == "carried_en"):
                continue
            rel = str(p.relative_to(app.parent))
            stray.append(f"{rel}:{node.lineno}")

    allowed = {
        # 저작·계약 본체 — 여기가 값을 만들고 갈래를 정한다
        "app/modules/pipeline/shot_continuity_author.py",
    }
    left = [x for x in stray if x.rsplit(":", 1)[0] not in allowed]
    assert not left, (
        "CARRIED 원값을 필터 없이 직접 꺼내 쓰는 자리가 남았다 — "
        f"{sorted(set(left))}")


# ── 끝점: 저작 → 저장 → 조립 → 나가는 프롬프트 ───────────────────────
#
# ★조립 함수만 태우고 「됐다」고 말하지 않는다. 표기 저작 판에서 저작만
#  재고 조립이 산출을 전량 버리는 것을 놓쳤다.

def _author(items, roster, selector="3"):
    """저작기를 통째로 태운다 — 모델만 가짜."""
    from app.modules.pipeline.shot_continuity_author import (
        run_shot_continuity,
    )

    def fake(step, sys_prompt, user, schema, **kw):
        if step.endswith("_pose_canon"):
            return {"immobile": []}
        if step.endswith("_carried"):
            return {"items": items}
        return {"movement_en": "", "figures_en": "", "state_en": ""}

    return run_shot_continuity(
        shots=[{"scene_index": 1, "shot_index": 2, "description": "d"}],
        scene_texts={1: "본문"}, scene_headings={1: "제목"},
        char_name_to_short=roster, prompt_version=selector,
        call_structured_fn=fake)


def test_author_output_reaches_the_outgoing_prompt():
    """★저작 → 저장 → 조립 → **나가는 글**까지 한 번에 태운다."""
    from app.modules.pipeline.shot_continuity_author import carried_clause_for

    out = _author([{
        "shot": "S1sh2",
        "objects_en": "The toolbox stays open.",
        "people": [
            {"character_ko": "연희", "state_en": "Yeonhui holds a bag.",
             "basis_ko": "근거"},
            {"character_ko": "민수", "state_en": "Minsu waits outside.",
             "basis_ko": "근거"}],
        "offscreen_effects_en": "", "basis_ko": "근거",
    }], {"연희": "C01", "민수": "C02"})

    row = out["carried"]["S1sh2"]
    assert row[CARRIED_CONTRACT_KEY] == CARRIED_CONTRACT_SCOPED
    assert [p["character_short_id"] for p in row["people"]] == ["C01", "C02"]

    got = carried_clause_for(
        out["carried"], "S1sh2", {},
        visible_short_ids={"C01"}, bg_only=False)
    assert "Yeonhui holds a bag" in got
    assert "Minsu" not in got, "그 샷에 없는 인물이 끝까지 실려 나갔다"


def test_a_name_outside_the_roster_never_reaches_the_prompt():
    """정본 명단에 없는 이름은 id 가 안 붙고, 붙지 않으면 안 나간다."""
    from app.modules.pipeline.shot_continuity_author import carried_clause_for

    out = _author([{
        "shot": "S1sh2", "objects_en": "The door is ajar.",
        "people": [{"character_ko": "지나가던 사람",
                    "state_en": "A passer-by lingers.", "basis_ko": "근거"}],
        "offscreen_effects_en": "", "basis_ko": "근거",
    }], {"연희": "C01"})

    assert out["carried"]["S1sh2"]["people"][0]["character_short_id"] is None
    got = carried_clause_for(
        out["carried"], "S1sh2", {},
        visible_short_ids={"C01"}, bg_only=False)
    assert got == "The door is ajar."


def test_the_old_pack_still_produces_the_old_shape():
    """옛 팩은 옛 계약 그대로 — 갈래가 팩 버전으로 갈린다."""
    out = _author([{"shot": "S1sh2", "carried_en": "x", "basis_ko": "b"}],
                  {"연희": "C01"}, selector="2")
    row = out["carried"]["S1sh2"]
    assert row == {"carried_en": "x", "basis_ko": "b"}
    assert not row_has_people_contract(row)


# ── 바깥 재개 관문 ───────────────────────────────────────────────────

def test_the_contract_change_is_folded_into_the_step_identity():
    """★샷별 지문만 바꾸면 그 스텝이 **샷 루프 전에** 통째로 건너뛴다.

    이 저장소가 실제로 겪었다. 계약이 바뀌면 스텝 신원도 바뀌어야 한다.
    """
    from app.core.step_manifest import STEP_MANIFEST
    from app.core.steps import shot_conti_light_step, shot_continuity_step

    assert shot_continuity_step.PROMPT_VERSION == "3"
    assert (STEP_MANIFEST["shot_continuity"]["schema_version"]
            == shot_continuity_step.SCHEMA_VERSION == 2), (
        "저작 계약이 바뀌었는데 스텝 신원이 그대로다")
    assert (STEP_MANIFEST["shot_conti_light"]["schema_version"]
            == shot_conti_light_step.SCHEMA_VERSION == 4)


def test_the_conti_step_declares_what_it_now_consumes():
    """콘티가 `visible_entities` 를 계약상 직접 읽는다 — 순서만 믿지 않는다."""
    from app.core.step_manifest import STEP_MANIFEST

    dep = STEP_MANIFEST["shot_conti_light"]["depends_on"]
    assert "scene_detail" in dep, (
        "`scene_detail` 을 다시 돌려도 콘티가 낡은 채로 남는다")
    assert "shot_continuity" in dep


def test_the_still_pipeline_still_depends_on_continuity():
    from app.core.step_manifest import STEP_MANIFEST

    assert "shot_continuity" in (
        STEP_MANIFEST["scene_image_pipeline"]["depends_on"])


# ── Codex BLOCK 2건 (2026-08-27 재리뷰) ──────────────────────────────

def test_the_roster_actually_reaches_the_carried_call():
    """★팩이 「명단이 입력된다」고 말하면 **실제로 줘야 한다.**

    v3 carried 도 `character_ko` 로 답하고 코드가 정확 일치로 short_id 를
    붙인다. 명단을 안 보여 주면 표기가 흔들려 매핑이 None 이 되고
    **화면 안 인물의 유효한 상태가 조용히 버려진다.**

    ★조립 함수만 태우면 안 보이는 자리다 — **나가는 user 문자열**을 잰다.
    """
    from app.modules.pipeline.shot_continuity_author import (
        run_shot_continuity,
    )

    seen = {}

    def fake(step, sys_prompt, user, schema, **kw):
        seen[step] = user
        if step.endswith("_pose_canon"):
            return {"immobile": []}
        if step.endswith("_carried"):
            return {"items": [{"shot": "S1sh2", "objects_en": "",
                               "people": [], "offscreen_effects_en": "",
                               "basis_ko": ""}]}
        return {"movement_en": "", "figures_en": "", "state_en": ""}

    run_shot_continuity(
        shots=[{"scene_index": 1, "shot_index": 2, "description": "d"}],
        scene_texts={1: "본문"}, scene_headings={1: "제목"},
        char_name_to_short={"연희": "C01", "민수": "C02"},
        prompt_version="3", call_structured_fn=fake)

    ca = seen["shot_continuity_carried"]
    assert "등장인물 정본 명단" in ca, (
        "carried 저작이 명단을 못 본다 — 팩 문안이 거짓말이 된다")
    assert "- 연희" in ca and "- 민수" in ca
    # pose_canon 은 원래도 받고 있었다 — 함께 지킨다
    assert "등장인물 정본 명단" in seen["shot_continuity_pose_canon"]


def test_the_old_pack_gets_no_roster_in_carried():
    """옛 팩 문안은 명단을 말하지 않는다 — 조립 바이트를 흔들지 않는다."""
    from app.modules.pipeline.shot_continuity_author import (
        run_shot_continuity,
    )

    seen = {}

    def fake(step, sys_prompt, user, schema, **kw):
        seen[step] = user
        if step.endswith("_pose_canon"):
            return {"immobile": []}
        if step.endswith("_carried"):
            return {"items": [{"shot": "S1sh2", "carried_en": "",
                               "basis_ko": ""}]}
        return {"movement_en": "", "figures_en": "", "carried_en": ""}

    run_shot_continuity(
        shots=[{"scene_index": 1, "shot_index": 2, "description": "d"}],
        scene_texts={1: "본문"}, scene_headings={1: "제목"},
        char_name_to_short={"연희": "C01"},
        prompt_version="1", call_structured_fn=fake)
    assert "등장인물 정본 명단" not in seen["shot_continuity_carried"]


def test_no_lookup_key_is_built_in_the_wrong_tag_shape():
    """★★**키가 틀려 lane 은 연속성이 통째로 안 나가고 있었다.**

    생산자는 `tag_of` = `S{si}sh{shi}` 로 적는데 lane 갈래만
    `S{si}_Shot{shi}` 로 찾았다(main 부터). 체크포인트 실측: 키 258개 중
    `_Shot` 모양 **0개** — `pose_clauses`·`pose_fix`·`carried` 셋이 전부
    빈 값이었다.

    ★**그 모양 자체는 잘못이 아니다.** 저장소 전역 59곳에 쓰이는데
     거의 다 **로그·오류 메시지의 사람이 읽는 라벨**이다:

        f"shot_dependency_t2i cp S{si}_Shot{shi} … missing"
        where=f"detail_steps._build_render_prompt_card_dict S{si}_Shot{shi}"

     두 체계가 섞여 있는 것이 뿌리이고, lane 이 표시용 모양을 **조회
     키로** 쓴 것이 결함이다. 그래서 `.get(...)` 의 인자만 본다.
    """
    import ast
    import pathlib

    app = pathlib.Path(__file__).resolve().parents[2] / "app"

    def _is_wrong_shape(node) -> bool:
        return isinstance(node, ast.JoinedStr) and any(
            isinstance(v, ast.Constant) and isinstance(v.value, str)
            and "_Shot" in v.value for v in node.values)

    stray = []
    for p in sorted(app.rglob("*.py")):
        try:
            tree = ast.parse(p.read_text(encoding="utf-8", errors="ignore"))
        except SyntaxError:
            continue
        for n in ast.walk(tree):
            if not (isinstance(n, ast.Call)
                    and isinstance(n.func, ast.Attribute)
                    and n.func.attr == "get" and n.args):
                continue
            if _is_wrong_shape(n.args[0]):
                stray.append(f"{p.relative_to(app.parent)}:{n.lineno}")
    assert not stray, (
        "표시용 태그 모양(`S{si}_Shot{shi}`)을 **조회 키로** 쓰는 자리가 "
        f"남았다 — 생산자는 `S{{si}}sh{{shi}}` 다: {stray}")


def test_the_lane_path_imports_what_it_calls():
    """★함수 안 import 누락은 **호출 시점까지 안 드러난다.**

    lane 갈래가 `tag_of` 를 부르면서 import 를 안 했다 — `try` 안이라
    조용히 failed 로 남았을 자리다.
    """
    import ast
    import pathlib

    src = (pathlib.Path(__file__).resolve().parents[2] / "app" / "core"
           / "steps" / "shot_conti_light_step.py").read_text(encoding="utf-8")
    tree = ast.parse(src)
    module_names = set()
    for n in tree.body:
        if isinstance(n, (ast.Import, ast.ImportFrom)):
            module_names |= {a.asname or a.name for a in n.names}

    for fn in ast.walk(tree):
        if not isinstance(fn, ast.FunctionDef):
            continue
        local = set(module_names)
        for n in ast.walk(fn):
            if isinstance(n, (ast.Import, ast.ImportFrom)):
                local |= {a.asname or a.name for a in n.names}
            elif isinstance(n, ast.Assign):
                local |= {t.id for t in n.targets
                          if isinstance(t, ast.Name)}
        called = {n.func.id for n in ast.walk(fn)
                  if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
        for name in ("tag_of", "carried_clause_for", "build_pose_clauses"):
            if name in called:
                assert name in local, (
                    f"{fn.name} 이 {name} 를 부르는데 import 가 없다")


# ── 자체 리뷰 반영 (2026-08-27) ──────────────────────────────────────

def test_an_empty_pose_fix_does_not_erase_the_authored_state():
    """★검열 정화 뒤 모델이 빈 문자열을 내면 **저작본이 사라졌다.**

    `call_structured` 는 Tier 1 이 걸리면 입력을 씻어 다시 부른다. 폭력
    묘사가 씻겨 나가면 `state_en: ""` 이 오고, 키가 있으니 schema 도
    통과한다. 옛 계약의 `fix.x or base.x` 가 그 자리를 막고 있었다.
    """
    from app.modules.pipeline.shot_continuity_author import (
        run_shot_continuity,
    )

    def fake(step, sys_prompt, user, schema, **kw):
        if step.endswith("_pose_canon"):
            return {"immobile": [{"character_ko": "연희",
                                  "pose_en": "slumped", "shots": ["S1sh2"],
                                  "basis_ko": "b"}]}
        if step.endswith("_carried"):
            return {"items": [{
                "shot": "S1sh2", "objects_en": "",
                "people": [{"character_ko": "연희",
                            "state_en": "Yeonhui still holds the knife.",
                            "basis_ko": "b"}],
                "offscreen_effects_en": "", "basis_ko": "b"}]}
        return {"movement_en": "", "figures_en": "", "state_en": ""}

    out = run_shot_continuity(
        shots=[{"scene_index": 1, "shot_index": 2, "description": "d"}],
        scene_texts={1: "본문"}, scene_headings={1: "제목"},
        char_name_to_short={"연희": "C01"}, prompt_version="3",
        call_structured_fn=fake)

    assert out["carried"]["S1sh2"]["people"][0]["state_en"] == (
        "Yeonhui still holds the knife."), (
        "교정본이 비었는데 저작본을 덮어써 연속성이 사라졌다")


def test_the_v2_pack_gets_no_roster_in_carried():
    """★이미 발행된 팩의 **조립 바이트를 흔들지 않는다.**

    `_NAME_ROSTER_PACKS` 는 pose_canon 기준이라 v2 도 들어 있는데, v2 의
    carried 문안은 명단을 한 마디도 안 한다 — 붙이면 모델이 쓸 줄 모르는
    블록을 받고 옛 체크포인트를 만든 프롬프트와 달라진다.
    """
    from app.modules.pipeline.shot_continuity_author import (
        run_shot_continuity,
    )

    seen = {}

    def fake(step, sys_prompt, user, schema, **kw):
        seen[step] = user
        if step.endswith("_pose_canon"):
            return {"immobile": []}
        if step.endswith("_carried"):
            return {"items": [{"shot": "S1sh2", "carried_en": "x",
                               "basis_ko": "b"}]}
        return {"movement_en": "", "figures_en": "", "carried_en": ""}

    for sel in ("1", "2"):
        seen.clear()
        run_shot_continuity(
            shots=[{"scene_index": 1, "shot_index": 2, "description": "d"}],
            scene_texts={1: "본문"}, scene_headings={1: "제목"},
            char_name_to_short={"연희": "C01"}, prompt_version=sel,
            call_structured_fn=fake)
        assert "등장인물 정본 명단" not in seen["shot_continuity_carried"], (
            f"v{sel} carried 에 명단이 딸려 갔다 — 조립 바이트가 바뀐다")
    # pose_canon 은 v2 에서 원래 받는다 — 그것까지 끄면 안 된다
    assert "등장인물 정본 명단" in seen["shot_continuity_pose_canon"]


def test_the_pack_content_is_folded_into_the_step_identity():
    """★**버전 문자열만 접으면 팩 내용을 고쳐도 안 돈다.**

    이 판에서 실제로 그랬다 — 팩을 낸 뒤 규칙 한 줄을 더했는데
    `config_hash` 가 그대로였다. 그 팩으로 이미 저작한 판은 재개해도 새
    규칙을 한 번도 못 보고, 사람은 반영됐다고 믿는다.
    """
    import ast
    import pathlib

    src = (pathlib.Path(__file__).resolve().parents[2] / "app" / "core"
           / "steps" / "shot_continuity_step.py").read_text(encoding="utf-8")
    keys = {n.value for n in ast.walk(ast.parse(src))
            if isinstance(n, ast.Constant) and isinstance(n.value, str)}
    assert "prompt_pack_content" in keys, "팩 내용이 스텝 신원에 안 접혔다"

    from app.core.steps.shot_continuity_step import (
        PROMPT_VERSION, _pack_content_hash,
    )
    from app.modules.pipeline.shot_continuity_author import (
        resolve_prompt_version,
    )

    got = _pack_content_hash(resolve_prompt_version(PROMPT_VERSION))
    assert got and got != _pack_content_hash("없는-팩"), (
        "팩이 달라도 같은 해시가 나온다")


def test_people_that_cannot_be_attached_are_reported():
    """★드롭은 옳지만 **조용하면 안 된다.**

    셋을 구분할 수 없다 — 정말 사람이 없는 샷 / VE 가 아직 안 만들어진
    샷(실측 3,750개 중 124개) / 변형 인물이라 id 가 안 맞는 샷.
    """
    import logging

    from app.modules.pipeline.shot_continuity_author import carried_clause_for

    row = {CARRIED_CONTRACT_KEY: CARRIED_CONTRACT_SCOPED,
           "objects_en": "the door is ajar", "offscreen_effects_en": "",
           "people": [
               {"character_ko": "민숙", "character_short_id": "C02",
                "state_en": "X", "basis_ko": ""},
               {"character_ko": "행인", "character_short_id": None,
                "state_en": "Y", "basis_ko": ""}]}

    seen = []

    class _Grab(logging.Handler):
        def emit(self, record):
            seen.append(record.getMessage())

    lg = logging.getLogger(
        "app.modules.pipeline.shot_continuity_author")
    h = _Grab()
    lg.addHandler(h)
    lg.setLevel(logging.INFO)
    try:
        got = carried_clause_for({"S1sh1": row}, "S1sh1", {},
                                 visible_short_ids={"C14"}, bg_only=False)
    finally:
        lg.removeHandler(h)

    assert got == "the door is ajar"
    joined = "\n".join(seen)
    assert "민숙" in joined and "행인" in joined, (
        f"안 붙인 인물이 로그에 안 남았다: {seen}")


def test_char_sids_reads_the_type_off_the_row():
    """엔티티 지도를 한 번 더 타지 않는다 — 링크 없는 인물이 빠진다."""
    from app.modules.pipeline.shot_continuity_author import char_sids_of

    ve = [{"id": "e1", "short_id": "C01", "entity_type": "character"},
          {"id": "e2", "short_id": "P01", "entity_type": "prop"},
          {"id": "e3", "short_id": "", "entity_type": "character"},
          "문자열"]
    assert char_sids_of(ve) == {"C01"}
    assert char_sids_of([]) == set()
    assert char_sids_of(None) == set()


def test_every_step_class_owns_the_methods_it_must_implement():
    """★★**시험 34개가 초록인데 클래스가 통째로 비어 있었다.**

    팩 내용 해시 helper 를 `class` 선언 **뒤에** top-level 로 넣었더니,
    그 아래 `    def _config_hash` 부터가 helper 의 **중첩 함수**가 됐다.
    문법은 통과하고 import 도 되지만 클래스는 빈 껍데기다 — 실행하면
    base 의 `NotImplementedError` 로 끝난다(2026-08-27 Codex BLOCK).

    ★조립 함수를 태우는 시험은 이것을 못 잡는다. **클래스가 그 메서드를
     정말 소유하는지**는 따로 봐야 한다.
    """
    import importlib
    import inspect
    import pkgutil

    import app.core.steps as steps_pkg
    from app.core.step_runner import StepRunner

    missing = []
    for mod in pkgutil.iter_modules(steps_pkg.__path__):
        m = importlib.import_module(f"app.core.steps.{mod.name}")
        for _, cls in inspect.getmembers(m, inspect.isclass):
            if (not issubclass(cls, StepRunner) or cls is StepRunner
                    or cls.__module__ != m.__name__):
                continue
            # 상속으로 받는 것은 괜찮다 — **아무도 안 가진 것**이 문제다
            if not any("_execute" in vars(k) for k in cls.__mro__
                       if k is not StepRunner):
                missing.append(f"{m.__name__}.{cls.__name__}")
    assert not missing, (
        "스텝 클래스가 `_execute` 를 안 가졌다 — 들여쓰기가 밀려 메서드가 "
        f"다른 함수 안으로 들어갔을 수 있다: {missing}")
