"""`entity_t2i` 가 partial 에서 재개하면 **빠진 것만** 산다 — 실제 canary CP 위에서, 유료 0.

Codex acceptance (2026-09-02 재리뷰):
    3. partial 실물 모양(기완료 12 + LP 셋 누락)으로 resume 하면 기존 12 는 재호출 0,
       LP 셋은 첫 호출에서 각각 통과해 총 3호출, 최종 15/15 completed.
    4. 성공한 LP 의 저장 metadata_json 도 canonical neutral shape.

★가짜 LLM 은 실측과 같은 **틀린 모양**(visual_identity 에 dict)을 돌려준다 — 그래도
정규화 뒤 검증을 지나야 한다. canary 산출이 이 기계에 없으면 skip.
"""
from __future__ import annotations

import json
import shutil
from pathlib import Path

import pytest

from app.core.config import settings
from app.core.steps import entity_steps as es

PID = "8e2e65b7-e910-4b12-b081-c23de0affab5"
EID = "9e64c302-82e6-4407-b560-8c70c2ab7192"
NEEDED = ("entity_detail", "entity_t2i", "visual_world_rules", "grounding_chunk",
          "entity_merge", "scene_save")


FROZEN = Path(__file__).resolve().parent / "fixtures" / "canary_cp"


def _partial_from_completed(cp: dict, missing: int = 3) -> dict:
    """★portable fixture — 얼린 completed CP(15/15)에서 location_part 셋을 빼
    canary ① 의 partial(12/15 · 실패 3) 모양을 **파생**한다. 이름이 아니라
    entity_type 으로 고른다."""
    import copy
    d = copy.deepcopy(cp)
    done = d["data"]["completed"]
    lp_keys = [k for k, v in done.items() if (v or {}).get("entity_type") == "location_part"]
    assert len(lp_keys) >= missing, lp_keys
    for k in lp_keys[:missing]:
        done.pop(k)
    d["data"] = {"completed": done, **{k: v for k, v in d["data"].items() if k != "completed"}}
    d["status"] = "partial"
    d["completed_count"] = len(done)
    d["failed_count"] = d["applicable_count"] - len(done)
    return d


@pytest.fixture
def world(tmp_path, monkeypatch):
    """★얼린 CP 사본(portable)을 쓴다 — live 가 CP 를 completed 로 바꿔도 시험이 안 사라진다."""
    root = tmp_path / "projects"
    dst = root / PID / "checkpoints" / "episodes" / EID
    for step in NEEDED:
        src = FROZEN / f"{step}.json"
        if src.is_file():
            (dst / step).mkdir(parents=True, exist_ok=True)
            shutil.copy(src, dst / step / "manifest.json")
    t2i = dst / "entity_t2i" / "manifest.json"
    cp = json.loads(t2i.read_text(encoding="utf-8"))
    if cp.get("status") != "partial":
        cp = _partial_from_completed(cp)
        t2i.write_text(json.dumps(cp, ensure_ascii=False), encoding="utf-8")
    monkeypatch.setattr(settings, "projects_dir", str(root))
    before = json.loads(t2i.read_text(encoding="utf-8"))
    assert before["status"] == "partial" and before["applicable_count"] - before["completed_count"] == 3
    return dst, before


def _fake_llm_that_invents_a_dict(calls):
    def _call(*, step, system_prompt, user_prompt, response_schema, **kw):
        calls.append({"step": step, "user": user_prompt})
        return {"t2i_prompt": "a fixed fixture of the place, rough conti style",
                "description": "(무시됨)", "visual_traits": [],
                # ★실측과 같은 틀린 모양 — location_part 인데 dict 를 지어낸다
                "metadata_json": {"location": None,
                                  "visual_identity": {"made_up": True}}}
    return _call


def _step(dst: Path, monkeypatch) -> "es.EntityT2iStep":
    """★production 스텝 객체 — 생성자가 하는 것 중 파일 경로만 그대로, DB 는 없다."""
    from app.core.step_manifest import get_manifest_dict

    s = es.EntityT2iStep.__new__(es.EntityT2iStep)
    s.step_id = "entity_t2i"
    s.project_id, s.episode_id = PID, EID
    s.db = None
    s.project_config = {"grounding_mode": "v2_chunk"}
    s.manifest = get_manifest_dict("entity_t2i")
    s.run_id = "test"
    s.opik_context = {}
    s._cp_dir = dst / "entity_t2i"
    monkeypatch.setattr(s, "update_progress", lambda *a, **k: None, raising=False)
    monkeypatch.setattr(s, "build_opik_metadata", lambda *a, **k: {}, raising=False)
    return s


class TestResumeBuysOnlyTheMissingOnes:
    def test_three_calls_fifteen_completed_neutral_shape(self, world, monkeypatch):
        dst, before = world
        done_before = set(before["data"]["completed"].keys())
        missing_before = before["applicable_count"] - before["completed_count"]
        assert missing_before == 3, before["completed_count"]

        calls = []
        monkeypatch.setattr(es, "call_structured", _fake_llm_that_invents_a_dict(calls))
        s = _step(dst, monkeypatch)

        got = s._execute(mode="resume")

        assert len(calls) == 3, f"★빠진 셋만 사야 한다 — {len(calls)}"
        assert got["completed_count"] == 15 and got["applicable_count"] == 15
        assert got["failed_count"] == 0
        after = got["data"]["completed"]
        assert done_before <= set(after), "★기존 완료분이 사라졌다"
        new_keys = set(after) - done_before
        assert len(new_keys) == 3
        for k in new_keys:
            row = after[k]
            assert row["entity_type"] == "location_part", row["entity_type"]
            assert row["metadata_json"] == {"location": None, "visual_identity": None}, row["metadata_json"]
            assert row["t2i_prompt"]

    def test_the_kept_twelve_are_byte_identical(self, world, monkeypatch):
        """★기존 완료분은 다시 안 사고 **그대로** 이고 간다."""
        dst, before = world
        monkeypatch.setattr(es, "call_structured", _fake_llm_that_invents_a_dict([]))
        s = _step(dst, monkeypatch)
        got = s._execute(mode="resume")
        for k, v in before["data"]["completed"].items():
            assert got["data"]["completed"][k] == v, k


class TestWithoutTheFoldTheDefectComesBack:
    def test_positive_control_unfolded_dict_fails_all_three(self, world, monkeypatch):
        """★양성 대조 — 정규화를 빼면 실측 그대로 셋이 3회씩 실패해 partial 로 남는다."""
        import time as _time
        from app.core import entity_metadata as em

        dst, before = world
        monkeypatch.setattr(em, "normalize_metadata_for_type", lambda _t, md: md)
        monkeypatch.setattr(_time, "sleep", lambda *_a, **_k: None)
        calls = []
        monkeypatch.setattr(es, "call_structured", _fake_llm_that_invents_a_dict(calls))
        got = _step(dst, monkeypatch)._execute(mode="resume")
        assert got["completed_count"] == 12 and got["failed_count"] == 3
        assert len(calls) == 9, f"★셋 × 3회 = 9 — {len(calls)}"


# ── 상세가 빠진 것은 `entity_merge` 에서 메운다 (2026-09-21) ────────

class TestABlankDescriptionIsFilledFromMerge:
    """★★`entity_detail` 이 한 묶음을 통째로 못 내면 **정본이 빈 채로** 간다.

    실측(컨트리로드 3판): 다섯 묶음 중 한 호출이 50개 중 1개만 쓰고 끝냈다
    (`finish_reason=stop`, 재시도 3회 동일). 그 48개는 `description`·
    `visual_traits` 가 빈 채로 DB 정본까지 내려갔다.

    그게 왜 그림을 깨나 — 소품 참조 문안은 `{entity_description}` 하나로
    끝나는 템플릿이고(`prop_ref.md`), `ref_image_pipeline` 은 **템플릿이
    있으면 `t2i_prompt` 를 안 쓴다**. 설명이 비면 「무엇을 그릴지 없는
    상품 사진 지시」를 산다. 장소 설명은 시대 조사 입력으로도 간다.

    ★**다시 만들지 않는다.** `t2i_prompt` 는 이미 있고 쓸 만하다 — 깨진
     것은 설명 칸뿐이라 그 칸만 메운다(LLM 호출 0).
    ★모델이 지어낸 말로 메우지 않는다. 메우는 것은 **앞 단계가 대본에서
     뽑아 둔 것**(`entity_merge`)이고 출처를 `description_source` 로 남긴다.
    ★처음 판은 「빈 기록을 다시 만든다」였는데 **내 시험이 잡았다** —
     고정물에 이미 있던 빈 기록까지 다시 사서 넷이 아니라 여섯을 샀다.
     빈 칸을 메우는 것과 다시 사는 것은 다른 일이다.
    """

    #: 심어 넣을 원자료 — 고정물의 `entity_merge` 는 설명이 **전부 비어**
    #: 있다(그 프로젝트는 상세가 `entity_detail` 에서만 온다). 그래서
    #: 고정물에 기대면 이 계약을 못 잰다 — 첫 판이 그래서 **되살려 빨간불
    #: 네 축이 하나도 안 났다**. 대신 시험이 직접 심는다.
    MERGE_DESC = "merge 가 대본에서 뽑아 둔 설명"
    MERGE_TRAITS = ["merge 특징 하나", "merge 특징 둘"]
    #: 멀쩡한 기록에 심는 것 — **덮이면 안 된다**
    HEALTHY_MERGE_DESC = "이것으로 덮으면 안 된다"

    @classmethod
    def _blank_one(cls, dst: Path, *, seed_merge: bool = True):
        """완료 기록 하나의 설명을 **지우고**, merge 에 원자료를 심는다."""
        p = dst / "entity_t2i" / "manifest.json"
        cp = json.loads(p.read_text(encoding="utf-8"))
        done = cp["data"]["completed"]
        key = next(k for k, v in done.items()
                   if str((v or {}).get("description") or "").strip())
        victim = dict(done[key])
        victim["description"] = ""
        victim["visual_traits"] = []
        done[key] = victim
        p.write_text(json.dumps(cp, ensure_ascii=False), encoding="utf-8")

        # ★**멀쩡한 것에도 하나 심는다.** 안 심으면 「설명이 있으면 손대지
        #  않는다」와 「원자료가 없으면 그대로 둔다」 두 축이 **빨간불이 안
        #  난다** — 대체 원천이 비어 있어 메우기 블록이 통째로 안 돌기
        #  때문이다(실측으로 잡았다).
        healthy_key = next(
            k for k, v in done.items()
            if k != key and str((v or {}).get("description") or "").strip())
        healthy = done[healthy_key]

        mp = dst / "entity_merge" / "manifest.json"
        merge = json.loads(mp.read_text(encoding="utf-8"))
        rows = merge["data"].setdefault("props", [])

        def _row(e, desc):
            return {
                "short_id": str(e.get("short_id") or ""),
                "name": str(e.get("name") or ""),
                "entity_type": str(e.get("entity_type") or ""),
                "description": desc,
                # ★문자열로 저장된 판도 있다 — 그 모양 그대로 심는다
                "visual_traits": json.dumps(cls.MERGE_TRAITS,
                                            ensure_ascii=False),
            }

        rows.append(_row(healthy, cls.HEALTHY_MERGE_DESC))
        if seed_merge:
            rows.append(_row(victim, cls.MERGE_DESC))
        mp.write_text(json.dumps(merge, ensure_ascii=False), encoding="utf-8")
        return key, victim

    def test_a_blank_entry_is_filled_from_merge(self, world, monkeypatch):
        """★빈 설명을 **merge 원자료로** 메운다."""
        dst, _ = world
        key, victim = self._blank_one(dst)
        monkeypatch.setattr(
            es, "call_structured", _fake_llm_that_invents_a_dict([]))
        got = _step(dst, monkeypatch)._execute(mode="resume")

        row = got["data"]["completed"][key]
        assert row["description"] == self.MERGE_DESC, (
            "★빈 설명을 merge 원자료로 안 메웠다")
        assert row["description_source"] == "entity_merge", (
            "★어디서 온 설명인지 안 남겼다 — 감사가 안 된다")
        assert row["visual_traits"] == self.MERGE_TRAITS, (
            "★문자열로 저장된 특징을 목록으로 안 풀었다")
        assert row["t2i_prompt"] == victim["t2i_prompt"], (
            "★문안까지 갈아 치웠다 — 메우는 것은 설명 칸뿐이다")

    def test_filling_buys_nothing(self, world, monkeypatch):
        """★★**다시 사지 않는다** — 메우기는 LLM 호출 0 이다.

        원래 빠져 있던 셋만 사고, 내가 비운 하나는 **안 산다**.
        """
        dst, _ = world
        key, victim = self._blank_one(dst)
        calls = []
        monkeypatch.setattr(
            es, "call_structured", _fake_llm_that_invents_a_dict(calls))
        _step(dst, monkeypatch)._execute(mode="resume")
        assert len(calls) == 3, (
            f"★빈 칸을 메우면서 다시 샀다 — {len(calls)} (빠진 셋만이어야 한다)")

    def test_a_filled_entry_is_left_alone(self, world, monkeypatch):
        """★상세가 있으면 **손대지 않는다** — 이 길은 빈 칸에만 닿는다."""
        dst, before = world
        # ★내가 **비운 그것**은 당연히 바뀐다 — 비교 대상에서 뺀다.
        #  (첫 판에 이걸 안 빼서 내 시험이 스스로 빨간불을 냈다.)
        victim_key, _ = self._blank_one(dst)
        monkeypatch.setattr(
            es, "call_structured", _fake_llm_that_invents_a_dict([]))
        got = _step(dst, monkeypatch)._execute(mode="resume")
        for k, v in before["data"]["completed"].items():
            if k == victim_key:
                continue
            if str((v or {}).get("description") or "").strip():
                assert got["data"]["completed"][k] == v, f"멀쩡한 {k} 가 바뀌었다"

    def test_without_a_source_the_blank_stays_blank(self, world, monkeypatch):
        """★원자료가 없으면 **그대로 둔다** — 없는 것을 지어내지 않는다."""
        dst, _ = world
        key, victim = self._blank_one(dst, seed_merge=False)

        calls = []
        monkeypatch.setattr(
            es, "call_structured", _fake_llm_that_invents_a_dict(calls))
        got = _step(dst, monkeypatch)._execute(mode="resume")
        row = got["data"]["completed"][key]
        assert row["description"] == "", "원자료가 없는데 무언가로 메웠다"
        assert "description_source" not in row, (
            "★원자료가 없는데 출처를 `entity_merge` 로 적었다 — 기록이 거짓")
        assert len(calls) == 3, f"★원자료가 없는데 다시 샀다 — {len(calls)}"


# ── 이름이 같다고 **같은 개체가 아니다** (Codex BLOCK 2026-09-21) ──────

class TestTheSourceMustBeTheSameEntity:
    """★★★빈 칸을 **이름으로** 메우면 남의 설명이 내 정본에 실린다.

    이 저장소는 같은 (이름, 종류)를 가진 **다른 short_id** 를 지원한다 —
    실측으로 「흙바닥」 `location_part` 가 둘이었고, 그것을 하나로 접었다가
    canon 이 안 생겨 `part_of` 동기화가 섰다
    (`ENTITY_INSTANCE_IDENTITY_CONTRACT_VERSION` 주석).

    빈 칸 메우기는 LLM 호출이 0 이라 **돈은 안 나가지만**, 신원이 어긋난
    설명을 `description_source="entity_merge"` 라는 도장까지 찍어 정본에
    내려보낸다. 기록이 거짓이 되는 자리다.

    ★그리고 merge 행은 종류를 **행의 칸이 아니라 배열로** 말하는 판이
     있다 — 그때는 갈래 표(`_ENTITY_KEYS`)가 소유 종류를 준다.
    """

    OTHER_DESC = "같은 이름 다른 개체의 설명 — 이것이 실리면 신원이 어긋난다"
    SAME_LANE_DESC = "제 갈래에서 온 설명"
    OTHER_LANE_DESC = "다른 갈래에서 온 설명 — 이것이 실리면 종류를 틀렸다"

    @staticmethod
    def _lane_of(etype: str) -> str:
        from app.modules.pipeline.grounding_carry import ENTITY_KEY_TO_OWNER
        return next(k for k, o in ENTITY_KEY_TO_OWNER if o == etype)

    @staticmethod
    def _another_lane(etype: str) -> str:
        from app.modules.pipeline.grounding_carry import ENTITY_KEY_TO_OWNER
        return next(k for k, o in ENTITY_KEY_TO_OWNER if o != etype)

    #: ★대상에게 **고정물에 없는 이름·신원**을 준다. 안 그러면 고정물
    #:  merge 에 이미 있는 그 엔티티의 행(설명이 비어 있다)이 후보로
    #:  같이 세어져, 내가 심은 행과 **모호**가 되거나 short_id 로 먼저
    #:  잡혀 버린다 — 재려던 축이 아닌 것으로 빨간불이 난다.
    LONE_NAME = "이 시험만 쓰는 이름"
    LONE_SID = "ZZ99"

    @classmethod
    def _blank_and_seed(cls, dst: Path, rows_by_lane, *, keep_sid=True):
        """완료 기록 하나를 **비우고**, `rows_by_lane(victim)` 를 merge 에 심는다."""
        p = dst / "entity_t2i" / "manifest.json"
        cp = json.loads(p.read_text(encoding="utf-8"))
        done = cp["data"]["completed"]
        key = next(k for k, v in done.items()
                   if str((v or {}).get("description") or "").strip())
        victim = dict(done[key])
        victim["description"] = ""
        victim["visual_traits"] = []
        victim["name"] = cls.LONE_NAME
        victim["short_id"] = cls.LONE_SID if keep_sid else ""
        done[key] = victim
        p.write_text(json.dumps(cp, ensure_ascii=False), encoding="utf-8")

        mp = dst / "entity_merge" / "manifest.json"
        merge = json.loads(mp.read_text(encoding="utf-8"))
        for lane, rows in rows_by_lane(victim).items():
            merge["data"].setdefault(lane, []).extend(rows)
        mp.write_text(json.dumps(merge, ensure_ascii=False), encoding="utf-8")
        return key, victim

    def _run(self, dst, monkeypatch):
        calls = []
        monkeypatch.setattr(
            es, "call_structured", _fake_llm_that_invents_a_dict(calls))
        got = _step(dst, monkeypatch)._execute(mode="resume")
        return got, calls

    def test_a_namesake_with_another_short_id_does_not_donate(
            self, world, monkeypatch):
        """★★대상도 원자료도 제 short_id 가 있는데 **서로 다르면** 안 쓴다."""
        dst, _ = world

        def _rows(v):
            assert str(v.get("short_id") or ""), (
                "★고정물 기록에 short_id 가 없으면 이 축을 못 잰다")
            return {self._lane_of(str(v.get("entity_type") or "")): [{
                "short_id": str(v.get("short_id")) + "X",   # ★다른 개체
                "name": v.get("name"),
                "entity_type": v.get("entity_type"),
                "description": self.OTHER_DESC,
                "visual_traits": [],
            }]}

        key, _victim = self._blank_and_seed(dst, _rows)
        got, calls = self._run(dst, monkeypatch)

        row = got["data"]["completed"][key]
        assert row["description"] != self.OTHER_DESC, (
            "★이름이 같다고 **다른 short_id** 의 설명을 내 정본에 넣었다")
        assert row["description"] == "", "★모르면 비워 둬야 한다"
        assert "description_source" not in row, (
            "★안 메웠는데 출처 도장을 찍었다")
        assert len(calls) == 3, f"★빠진 셋 말고 더 샀다 — {len(calls)}"

    def test_two_namesakes_without_ids_leave_it_blank(
            self, world, monkeypatch):
        """★원자료가 **모호하면** 비워 둔다 — 둘 중 하나를 고르지 않는다."""
        dst, _ = world

        def _rows(v):
            lane = self._lane_of(str(v.get("entity_type") or ""))
            base = {"name": v.get("name"),
                    "entity_type": v.get("entity_type"),
                    "visual_traits": []}
            return {lane: [{**base, "description": self.SAME_LANE_DESC},
                           {**base, "description": self.OTHER_DESC}]}

        key, _victim = self._blank_and_seed(dst, _rows, keep_sid=False)
        got, _calls = self._run(dst, monkeypatch)

        row = got["data"]["completed"][key]
        assert row["description"] == "", (
            f"★같은 이름 둘 중 하나를 골라 실었다 — {row['description']!r}")
        assert "description_source" not in row

    def test_a_row_without_a_type_gets_it_from_its_lane(
            self, world, monkeypatch):
        """★★종류 칸이 없는 옛 merge 행은 **배열이 곧 종류**다.

        같은 이름이 다른 갈래에도 있으면 이름 단독으로는 못 가른다 —
        갈래에서 소유 종류를 얻어야 제 것을 집는다.
        """
        dst, _ = world

        def _rows(v):
            etype = str(v.get("entity_type") or "")
            # ★두 행 다 `entity_type` 칸이 **없다** — 갈래만 다르다
            return {
                self._lane_of(etype): [
                    {"name": v.get("name"),
                     "description": self.SAME_LANE_DESC,
                     "visual_traits": []}],
                self._another_lane(etype): [
                    {"name": v.get("name"),
                     "description": self.OTHER_LANE_DESC,
                     "visual_traits": []}],
            }

        key, _victim = self._blank_and_seed(dst, _rows, keep_sid=False)
        got, _calls = self._run(dst, monkeypatch)

        row = got["data"]["completed"][key]
        assert row["description"] == self.SAME_LANE_DESC, (
            f"★갈래에서 종류를 못 얻었다 — {row['description']!r}")
        assert row["description_source"] == "entity_merge"

    def test_traits_that_are_already_there_are_not_overwritten(
            self, world, monkeypatch):
        """★설명만 비고 **특징은 멀쩡하면** 특징을 지우지 않는다."""
        dst, _ = world
        p = dst / "entity_t2i" / "manifest.json"
        cp = json.loads(p.read_text(encoding="utf-8"))
        done = cp["data"]["completed"]
        key = next(k for k, v in done.items()
                   if str((v or {}).get("description") or "").strip())
        kept = ["원래 있던 특징"]
        victim = {**done[key], "description": "", "visual_traits": kept,
                  "name": self.LONE_NAME, "short_id": self.LONE_SID}
        done[key] = victim
        p.write_text(json.dumps(cp, ensure_ascii=False), encoding="utf-8")

        mp = dst / "entity_merge" / "manifest.json"
        merge = json.loads(mp.read_text(encoding="utf-8"))
        lane = self._lane_of(str(victim.get("entity_type") or ""))
        merge["data"].setdefault(lane, []).append({
            "short_id": victim.get("short_id"),
            "name": victim.get("name"),
            "entity_type": victim.get("entity_type"),
            "description": self.SAME_LANE_DESC,
            "visual_traits": ["merge 가 덮으려 한 특징"],
        })
        mp.write_text(json.dumps(merge, ensure_ascii=False), encoding="utf-8")

        got, _calls = self._run(dst, monkeypatch)
        row = got["data"]["completed"][key]
        assert row["description"] == self.SAME_LANE_DESC, "★설명은 메워야 한다"
        assert row["visual_traits"] == kept, (
            f"★멀쩡한 특징을 원자료로 갈아치웠다 — {row['visual_traits']}")

    def test_only_another_type_exists_so_it_stays_blank(
            self, world, monkeypatch):
        """★★종류가 다르면 **이름이 같아도** 다른 개체다.

        갈래에서 종류를 얻어 놓고 마지막에 이름 단독으로 내려가면 그
        종류를 다시 버리는 것이다 — 장소 설명이 부분 장소 정본에 실린다
        (Codex BLOCK 2026-09-21 첫째).
        """
        dst, _ = world

        def _rows(v):
            etype = str(v.get("entity_type") or "")
            # ★**다른 갈래에만** 있다. 종류 칸도 없어서 갈래가 곧 종류다.
            return {self._another_lane(etype): [
                {"name": v.get("name"),
                 "description": self.OTHER_LANE_DESC,
                 "visual_traits": []}]}

        key, _victim = self._blank_and_seed(dst, _rows, keep_sid=False)
        got, _calls = self._run(dst, monkeypatch)

        row = got["data"]["completed"][key]
        assert row["description"] == "", (
            f"★다른 종류의 설명을 이름만 보고 실었다 — {row['description']!r}")
        assert "description_source" not in row

    def test_a_source_without_a_description_still_counts_as_a_candidate(
            self, world, monkeypatch):
        """★★「어느 개체인가」와 「쓸 설명이 있는가」는 **다른 물음**이다.

        같은 (이름, 종류)에 short_id 없는 행이 둘인데 하나만 설명이
        있으면, 빈 행을 후보에서 지우는 순간 **하나로 세어져** 모호성
        봉인이 안 걸린다. 그러면 둘 중 하나를 골라 실어 버린다
        (Codex BLOCK 2026-09-21 둘째).
        """
        dst, _ = world

        def _rows(v):
            lane = self._lane_of(str(v.get("entity_type") or ""))
            base = {"name": v.get("name"),
                    "entity_type": v.get("entity_type"),
                    "visual_traits": []}
            return {lane: [
                {**base, "description": ""},            # ★설명 없는 후보
                {**base, "description": self.OTHER_DESC},
            ]}

        key, _victim = self._blank_and_seed(dst, _rows, keep_sid=False)
        got, _calls = self._run(dst, monkeypatch)

        row = got["data"]["completed"][key]
        assert row["description"] == "", (
            f"★후보가 둘인데 하나를 골라 실었다 — {row['description']!r}")
        assert "description_source" not in row
