"""취합에 보여 주는 관찰이 **받을 수 있는 칸만** 담는가 (2026-08-27, 2-D).

## 무엇을 잡으려는 것인가 — 254 중 76건(30%)이 여기서 죽었다

DB 실측(14일):

    still_recipe_critique_compose   성공 178 · **실패 76**
    사유 1위 `schema violation: Additional properties are not allowed
             ('severity' was unexpected)`

관찰 스키마는 `{issue_ko, severity}` 를 내고 **`severity` 가 required** 다.
그 JSON 을 통째로 취합 입력에 넣는데, GQ 취합 스키마는
`{fix_en, issue_ko, needs_regeneration, unfixable}` 에
`additionalProperties: False` 다.

★**모델은 입력에 있는 칸을 출력에도 넣는다.** 그게 자연스러운 행동이고,
 그때마다 스키마가 거부해 유료 왕복이 버려진다(`max_retry=1` 이라
 재질의도 한 번 더 산다).

## 왜 스키마를 넓히지 않았나 — ★거동이 통째로 바뀐다

취합 스키마에 `severity` 를 받게 하면 GQ 경로 이슈에 severity 가 실리고,
`multiroll_select` 의 심각도 게이트(`if any("severity" in issue …)`)가
**GQ 에서도 걸리기 시작한다.** 지금 GQ 는 major 도 편집 대상인데 갑자기
critical 만 남는다.

실측으로 확인한 것이다(2026-08-27): `composition_critique` 이 돈 200샷이
**전부 `main sev없음`** 이라 게이트가 한 번도 안 걸렸다.
"""
from __future__ import annotations


def _schema(*names):
    return {"properties": {"issues": {"items": {
        "type": "object", "additionalProperties": False,
        "properties": {n: {"type": "string"} for n in names}}}}}


def test_a_field_the_compose_schema_cannot_take_is_dropped():
    """★이 판의 핵심 — `severity` 가 나가는 파트에서 사라지는가."""
    from app.modules.pipeline.multiroll_gemini import observations_part

    part = observations_part(
        [{"issue_ko": "문이 가운데 있다", "severity": "major"}],
        _schema("issue_ko", "fix_en"))
    assert "severity" not in part["text"], (
        "취합이 못 받는 칸이 그대로 나간다 — 모델이 따라 내고 "
        "스키마가 거부해 유료 왕복이 버려진다")
    assert "문이 가운데 있다" in part["text"], "관찰 본문까지 지웠다"


def test_a_field_the_schema_does_take_survives():
    """반대편 — 받을 수 있는 칸은 **안 지운다.**

    ★이게 없으면 위 시험이 「전부 지운다」로도 통과한다.
    """
    from app.modules.pipeline.multiroll_gemini import observations_part

    part = observations_part(
        [{"issue_ko": "A", "severity": "major"}],
        _schema("issue_ko", "severity", "fix_en"))
    assert "severity" in part["text"]
    assert "major" in part["text"]


def test_an_unreadable_schema_does_not_empty_the_observations():
    """★허용 목록을 못 읽으면 **거르지 않는다.**

    빈 집합으로 걸러 관찰을 통째로 비우면 취합이 볼 것이 없어진다 —
    「덜 잡는 도구가 0 을 내면 없다로 읽힌다」의 반대 방향 사고다.
    """
    from app.modules.pipeline.multiroll_gemini import observations_part

    part = observations_part([{"issue_ko": "A", "severity": "major"}], {})
    assert "issue_ko" in part["text"] and "severity" in part["text"]


def test_the_header_is_preserved_for_the_two_inspector_path():
    """★G+G46 경로는 관찰자가 **둘**이라 그 사실을 말해 준다.

    같은 거르기를 쓰면서 문구를 바꿔 버리면 나가는 프롬프트가 달라진다.
    """
    from app.modules.pipeline.multiroll_gemini import observations_part

    head = "OBSERVATIONS (concatenated from TWO independent"
    part = observations_part([{"issue_ko": "A"}], _schema("issue_ko"),
                             header=head)
    assert part["text"].startswith(head)


def test_the_default_header_is_unchanged():
    """기본 문구를 안 건드렸는지 — 두 경로가 byte-identical 이어야 한다."""
    from app.modules.pipeline.multiroll_gemini import observations_part

    part = observations_part([{"issue_ko": "A"}], _schema("issue_ko"))
    assert part["text"].startswith(
        "OBSERVATIONS (from a separate visual inspector — verify "
        "each against the photograph before adopting it):")


def test_non_dict_entries_are_skipped():
    from app.modules.pipeline.multiroll_gemini import observations_part

    part = observations_part(
        ["쓰레기", {"issue_ko": "A"}, None], _schema("issue_ko"))
    assert "쓰레기" not in part["text"] and '"A"' in part["text"]


def test_the_real_pair_of_schemas_actually_disagreed():
    """★실물 확인 — 이 결함이 **지어낸 것이 아니다.**

    프로덕션 두 스키마를 실제로 불러 어긋남을 본다. 시험이 자기가 만든
    가짜 스키마만 보면 「고쳤다」가 현실과 무관해진다.
    """
    from app.modules.pipeline.multiroll_select import (
        build_gq_observe_schema, build_critique_schema)

    obs = build_gq_observe_schema()["properties"]["observations"]["items"]
    comp = build_critique_schema(
        with_severity=False)["properties"]["issues"]["items"]
    extra = set(obs["properties"]) - set(comp["properties"])
    assert comp.get("additionalProperties") is False
    assert "severity" in extra, (
        "관찰과 취합이 이제 어긋나지 않는다면 이 시험을 다시 볼 것 — "
        "거르기가 필요 없어졌거나 스키마가 바뀐 것이다")


def test_the_call_sites_use_the_helper_not_a_raw_dump():
    """★조립부가 **헬퍼를 부르는지** 본다.

    한 자리만 고치고 다른 자리에 날 dump 가 남으면 그 경로는 계속
    죽는다 — 「조립하는 자리를 시험하고 나가는 것을 쟀다고 말하지 마라」.
    """
    import inspect
    from app.modules.pipeline import multiroll_gemini as mg

    src = inspect.getsource(mg)
    assert "json.dumps(observations" not in src, (
        "관찰을 날것으로 실어 보내는 자리가 남아 있다")
    assert src.count("observations_part(") >= 4, (
        "헬퍼 정의 1 + 호출 3 이어야 한다")


def test_the_qk_path_loses_nothing_so_the_rebind_survives():
    """★QK 는 `severity` 재결속을 **실제로 쓴다** — 거기선 안 지워야 한다.

    QK 취합 스키마는 `with_severity=True` 라 `severity` 를 허용 칸에
    갖는다. 그러니 이 거르기가 QK 관찰에서 아무것도 안 지운다.

    ★그리고 `observation_index` 는 **관찰이 내는 칸이 아니다** —
     관찰 스키마는 `{issue_ko, severity}` 뿐이고, 인덱스는 **배열의
     위치**다. 거르기가 위치를 건드릴 수 없다.

    이 시험이 빨강이면 스키마가 바뀐 것이고, 그때 재결속이 여전히
    도는지 다시 봐야 한다.
    """
    from app.modules.pipeline.multiroll_gemini import observations_part
    from app.modules.pipeline.multiroll_select import (
        build_gq_observe_schema, build_critique_schema)

    obs_props = (build_gq_observe_schema()["properties"]["observations"]
                 ["items"]["properties"])
    assert "observation_index" not in obs_props, (
        "관찰이 인덱스를 칸으로 내기 시작했다 — 거르기가 그것을 "
        "지우는지 다시 볼 것")

    sample = [{k: "x" for k in obs_props}]
    qk = observations_part(sample, build_critique_schema(with_severity=True))
    for k in obs_props:
        assert f'"{k}"' in qk["text"], (
            f"QK 경로에서 {k!r} 이 사라졌다 — severity 재결속이 깨진다")

    gq = observations_part(sample, build_critique_schema(with_severity=False))
    assert '"severity"' not in gq["text"], (
        "GQ 경로에서 severity 가 안 걸러진다 — 이 수리의 목적이 사라졌다")
