"""이긴 후보의 **실격**을 걸러 낸다 — 판정 위반 게이트 1단계 (2026-09-20).

## 무엇이 있었나

판정 팩은 `hard_violations` 를 「그 컷을 **못 쓰게** 만드는 실격 결함만」
이라고 규정한다. 그런데 선정은 「둘 중 나은 것」을 고를 뿐이라, **둘 다
못 쓸 때 멈추는 자리가 없었다.** 실측: 이 화 239샷 중 이긴 후보에 실격이
달린 샷 **81**.

★뿌리는 점수 가중치가 아니다(내 첫 진단은 틀렸다 — 설계 문서 §13).
 **「상대 순위」와 「최종 사용 가능」이 분리되지 않은 것**이다.

## 계약

    ① 허용 후보 집합을 먼저 만든다   (실격이 빈 후보)
    ② 그 안에서 **기존 순위**로 고른다  ← 있으면 **무료 재선택**
    ③ 하나도 없으면 unresolved        ← 정상 확정으로 안 내보낸다

재롤(유료)은 이 단계 밖이다 — 여기서는 **이미 산 후보**만 다룬다.
"""
from __future__ import annotations

import pytest

from app.modules.pipeline.multiroll_select import (
    GATE_CLEAN,
    GATE_INCOMPLETE,
    GATE_NOT_APPLICABLE,
    GATE_RESELECTED,
    GATE_UNRESOLVED,
    apply_winner_violation_gate,
    gate_admissible_labels,
)

LABELS = ["A", "B"]


def _jr(**per_label):
    """{'A': ['위반문장'], 'B': []} → judge_result 모양."""
    return {"readings": [{"label": k, "hard_violations": v}
                         for k, v in per_label.items()]}


def _run(jr, *, selected="A", ranking=("A", "B"), applicable=True):
    rec: dict = {}
    out = apply_winner_violation_gate(
        record=rec, judge_result=jr, labels=LABELS,
        selected=selected, ranking=list(ranking), applicable=applicable)
    return out, rec["gate"]


# ── 허용 후보 판정 ──────────────────────────────────────────────────

def test_only_an_empty_violation_array_is_admissible():
    adm, unchecked = gate_admissible_labels(_jr(A=["x"], B=[]), LABELS)
    assert adm == ["B"] and unchecked == []


def test_a_missing_row_is_unchecked_not_admissible():
    """★행이 없으면 「실격이 없다」가 아니라 **「아무도 안 봤다」**.

    `_validate_judge_shape` 가 `readings` 를 안 보므로 행이 통째로 빠질
    수 있다. 그것을 통과로 읽으면 안 본 것을 합격시킨다.
    """
    adm, unchecked = gate_admissible_labels({"readings": [
        {"label": "A", "hard_violations": ["x"]}]}, LABELS)
    assert adm == [] and unchecked == ["B"]


def test_no_readings_at_all_means_everything_unchecked():
    for jr in ({}, {"readings": None}, {"readings": "?"}):
        assert gate_admissible_labels(jr, LABELS) == ([], LABELS)


def test_blank_strings_do_not_count_as_violations():
    """★이것은 **정규화 계약**이다 — 「배열 길이만 본다」보다 넓다(Codex).

    빈 문자열만 든 배열은 「실격을 적었다」로 세지 않는다. 그 차이를
    여기 적어 둔다.
    """
    adm, _ = gate_admissible_labels(_jr(A=["", "  "], B=["x"]), LABELS)
    assert adm == ["A"]


# ── 게이트 종착 ────────────────────────────────────────────────────

def test_a_clean_winner_passes_untouched():
    out, g = _run(_jr(A=[], B=["x"]))
    assert out == "A" and g["outcome"] == GATE_CLEAN


def test_a_disqualified_winner_is_swapped_for_an_admissible_candidate():
    """★**이미 산 후보**로 바꾼다 — 새 그림을 사지 않는다."""
    out, g = _run(_jr(A=["세 번째 손"], B=[]))
    assert out == "B"
    assert g["outcome"] == GATE_RESELECTED
    assert g["initial_selected"] == "A" and g["selected"] == "B"


def test_when_both_are_disqualified_it_is_unresolved():
    out, g = _run(_jr(A=["x"], B=["y"]))
    assert g["outcome"] == GATE_UNRESOLVED
    assert out == "A", "unresolved 여도 초기 선정을 그대로 돌려준다"
    assert g["admissible"] == []


def test_an_unchecked_candidate_cannot_rescue_a_bad_winner():
    """미검사 후보로 **바꾸지 않는다** — 안 본 것을 답으로 쓰지 않는다.

    ★종착은 `incomplete` 다(Codex ㉠) — 「전부 못 쓴다」가 아니라
     「아직 다 안 봤다」. 교체를 안 하는 것과 종착은 다른 축이다.
    """
    out, g = _run({"readings": [{"label": "A", "hard_violations": ["x"]}]})
    assert out == "A", "미검사 후보로 바꿨다"
    assert g["outcome"] == GATE_INCOMPLETE
    assert g["unchecked"] == ["B"]


def test_reselection_follows_the_existing_ranking():
    """허용 후보가 둘이면 **기존 순위**를 지킨다 — 새 기준을 만들지 않는다."""
    jr = {"readings": [{"label": l, "hard_violations": []}
                       for l in ("A", "B", "C")]}
    rec: dict = {}
    out = apply_winner_violation_gate(
        record=rec, judge_result={"readings": [
            {"label": "A", "hard_violations": ["x"]},
            {"label": "B", "hard_violations": []},
            {"label": "C", "hard_violations": []}]},
        labels=["A", "B", "C"], selected="A",
        ranking=["A", "C", "B"], applicable=True)
    assert out == "C", "순위에서 앞선 허용 후보를 안 골랐다"
    assert jr  # 사용하지 않는 변수 경고 방지


# ── 비대상 갈래 ────────────────────────────────────────────────────

def test_a_non_applicable_branch_is_recorded_not_passed():
    """★비대상을 **「검사 통과」로 표시하지 않는다** (Codex)."""
    out, g = _run(_jr(A=["x"], B=["y"]), applicable=False)
    assert out == "A", "비대상인데 선택을 바꿨다"
    assert g["outcome"] == GATE_NOT_APPLICABLE
    assert g["outcome"] != GATE_CLEAN


def test_the_outcome_is_always_recorded():
    """이 helper 를 **부른** 모든 갈래가 종착을 남긴다.

    ★범위(Codex 정정): 「언제나」는 **이 함수를 탄 경우**다. `_sel` 재사용
     갈래(`multiroll_select:1660-1683`)는 이보다 **앞에서 반환**하므로
     OFF 로 완주한 옛 record 에는 종착이 안 붙는다. 소비자를 연결할 때
     캐시 반환도 종착을 보게 해야 한다.
    """
    for jr, app in ((_jr(A=[], B=[]), True), (_jr(A=["x"], B=[]), True),
                    (_jr(A=["x"], B=["y"]), True), ({}, True),
                    (_jr(A=[], B=[]), False)):
        _, g = _run(jr, applicable=app)
        assert g["outcome"] in (GATE_CLEAN, GATE_RESELECTED,
                                GATE_UNRESOLVED, GATE_INCOMPLETE,
                                GATE_NOT_APPLICABLE)
        assert g["policy"]


# ── 배선 ───────────────────────────────────────────────────────────

def test_the_gate_runs_before_the_selection_is_materialised():
    """기록과 `_sel` 물질화가 **같은 값**을 봐야 한다."""
    import inspect
    import pathlib

    from app.modules.pipeline import multiroll_select as mod

    src = pathlib.Path(inspect.getfile(mod)).read_text(encoding="utf-8")
    i = src.find("selected = apply_winner_violation_gate(")
    j = src.find('"selected": selected,\n            "ranking": ranking,')
    k = src.find("shutil.copy(_roll_path(out_stem, selected)")
    assert 0 < i < j < k, "게이트가 기록·물질화보다 뒤에 있다"


def test_the_policy_is_not_folded_into_the_generation_fingerprint():
    """★★정책을 **생성 지문에 접지 않는다** (2026-09-20 Codex ③).

    내 첫 판은 접었다. 그러면 정책을 켜는 것만으로 대상 샷 전부가 지문
    불일치가 되어 `_clear_outputs` → **롤부터 다시 산다**(이 화 실측:
    표준 137샷). 그런데 게이트가 하는 일은 **이미 산 후보 중 무엇을
    쓰느냐**다 — 같은 후보를 버릴 이유가 없다.

    ★「재평가해야 함」과 「롤부터 사야 함」은 다른 축이다.
    """
    import inspect

    from app.modules.pipeline import multiroll_select as mod

    src = inspect.getsource(mod.run_multiroll_select)
    i = src.find("fingerprint = compute_input_fingerprint(")
    assert i > 0
    assert "GATE_POLICY_VERSION" not in src[:i], (
        "게이트 정책이 아직 생성 지문 조립에 들어간다 — 켜는 순간 "
        "대상 전부를 다시 산다")


def test_the_policy_identity_is_always_recorded():
    """★그 대신 **`gate` 가 정책 신원을 항상 적는다**.

    같은 A 라도 「허용된 A」와 「unresolved 인 A」는 정책 상태가 다르다.
    `clean` 이어도 「어느 정책으로 · 무엇을 보고」 허용했는지는 바뀐다.
    """
    out, g = _run(_jr(A=[], B=[]))
    assert out == "A" and g["outcome"] == GATE_CLEAN
    assert g["policy"] and g["applicable"] is True
    ev = g["evidence"]
    assert ev["labels"] == LABELS
    assert len(ev["readings_digest"]) == 16


def test_a_different_judgement_gets_a_different_evidence_id():
    """근거가 다르면 **신원도 다르다** — 같은 clean 이어도 구별된다."""
    _, g1 = _run(_jr(A=[], B=[]))
    _, g2 = _run(_jr(A=[], B=["x"]))
    assert (g1["evidence"]["readings_digest"]
            != g2["evidence"]["readings_digest"])


def test_the_non_target_branch_says_so_explicitly():
    """★비대상은 **명시**한다 — 「판정 없음」과 구별이 안 되면 소비자가
    미검증을 합격으로 읽는다."""
    _, g = _run(_jr(A=["x"]), applicable=False)
    assert g["outcome"] == GATE_NOT_APPLICABLE and g["applicable"] is False


def _run_branch_calls():
    """서비스의 `_run_branch` 호출을 **구문으로** 읽는다.

    돌려주는 것: (기록 키 소스, `gate` 인자 소스 or None) 의 목록.
    문자열 개수로 세지 않는다 — 갈래가 늘면 개수는 늘지만, 물어야 할
    것은 **어느 갈래가 켜져 있나**다.
    """
    import ast
    import inspect
    import pathlib

    from app.services import still_recipe_service as svc

    src = pathlib.Path(inspect.getfile(svc)).read_text(encoding="utf-8")
    tree = ast.parse(src)
    out = []
    for node in ast.walk(tree):
        if not (isinstance(node, ast.Call)
                and isinstance(node.func, ast.Name)
                and node.func.id == "_run_branch"):
            continue
        rec_key = (ast.get_source_segment(src, node.args[2])
                   if len(node.args) > 2 else "")
        gate = None
        for kw in node.keywords:
            if kw.arg == "gate":
                gate = ast.get_source_segment(src, kw.value)
        out.append((rec_key or "", gate))
    return out


def test_the_shared_function_does_not_default_to_on():
    """★공용 함수에 **기본 ON 을 박지 않는다**."""
    import inspect
    import pathlib

    from app.modules.pipeline import multiroll_select as ms

    ms_src = pathlib.Path(inspect.getfile(ms)).read_text(encoding="utf-8")
    assert "winner_gate_applicable: bool = False," in ms_src, (
        "공용 함수의 기본값이 False 가 아니다")


def test_the_wiring_lives_in_one_place():
    """★배선은 **한 자리**다 — 호출 자리마다 적으면 하나만 고쳐진다.

    호출 자리는 「대상인가」(`gate=`)만 말하고, 재판정기·상한·문안은
    `_run_branch` 가 정한다.
    """
    import inspect
    import pathlib

    from app.services import still_recipe_service as svc

    src = pathlib.Path(inspect.getfile(svc)).read_text(encoding="utf-8")
    assert src.count('variant_kw["winner_gate_applicable"]') == 1
    assert src.count("winner_gate_applicable=") == 0, (
        "호출 자리에서 직접 켠다 — 배선이 두 곳으로 갈렸다")
    assert src.count('variant_kw["gate_rejudge_fn"]') == 1


def test_the_ab_branches_never_turn_it_on():
    """★★conti A/B 두 갈래는 **절대 대상이 아니다**.

    갈래마다 outer 선택 **전에** 각각 재롤하면 「샷당 1장」이 아니라
    **최대 2장**이 된다. 사용자 계약이 1장이다.
    """
    bad = [k for k, g in _run_branch_calls()
           if ("ab_conti" in k or "ab_noconti" in k) and g not in (None, "False")]
    assert not bad, f"★A/B 갈래에 게이트를 켰다: {bad} — 샷당 2장이 된다"


def test_the_named_branches_are_on():
    """★표준·confined·bgfirst 는 **대상이다** (2026-09-20 ③ 확장).

    셋 다 기록 키가 본체 `tag` 라, 여기서 세는 것은 「`tag` 로 도는 갈래
    가운데 켜진 것이 몇인가」다. 넷 중 셋이 아니라 **넷 다** 켜져 있어야
    한다(표준 · confined · bgfirst 체인단독 · bgfirst 2택1).
    """
    on = [k for k, g in _run_branch_calls() if g == "True"]
    assert len(on) == 4, f"켜진 갈래 수가 다르다: {on}"
    assert all(k == "tag" for k in on), f"본체 키가 아닌 갈래가 있다: {on}"


# ── 입력 검사 — **행이 있다고 「확인했다」가 아니다** (Codex) ────────

@pytest.mark.parametrize("bad", [
    pytest.param({"label": "A"}, id="칸_없음"),
    pytest.param({"label": "A", "hard_violations": None}, id="null"),
    pytest.param({"label": "A", "hard_violations": "x"}, id="문자열"),
    pytest.param({"label": "A", "hard_violations": 0}, id="숫자"),
])
def test_a_row_without_a_proper_array_is_unchecked(bad):
    """`(… or [])` 로 받으면 이것들이 조용히 **「실격 없음」**이 된다."""
    adm, unchecked = gate_admissible_labels({"readings": [bad]}, LABELS)
    assert "A" not in adm, f"{bad} 를 허용으로 셌다"
    assert "A" in unchecked


def test_a_clean_duplicate_row_does_not_erase_an_unchecked_one():
    """같은 라벨의 다른 행이 미검사면 **빈 배열이 그것을 지우지 않는다**."""
    adm, unchecked = gate_admissible_labels({"readings": [
        {"label": "A"},                      # 미검사
        {"label": "A", "hard_violations": []},
    ]}, LABELS)
    assert "A" not in adm
    assert "A" in unchecked


def test_only_unchecked_is_incomplete_not_a_quality_failure():
    """★「판정을 못 읽었다」를 「모두 실격」으로 바꾸지 않는다 (Codex)."""
    _, g = _run({"readings": []})
    assert g["outcome"] == GATE_INCOMPLETE
    assert g["outcome"] != GATE_UNRESOLVED


def test_a_mixed_case_is_incomplete_not_all_disqualified():
    """★A=실격·B=**미검사** 는 「전부 못 쓴다」가 아니다 (Codex ㉠).

    B 를 아직 모르는데 `unresolved` 로 닫으면 품질 재롤로 보내진다.
    그때 필요한 것은 **판정 복구**이지 새 그림이 아니다.
    """
    _, g = _run({"readings": [{"label": "A", "hard_violations": ["x"]}]})
    assert g["outcome"] == GATE_INCOMPLETE
    assert g["unchecked"] == ["B"]


def test_an_admissible_candidate_wins_even_if_another_is_unchecked():
    """허용 후보가 **이미 있으면** 다른 후보의 미검사가 그것을 막지 않는다."""
    out, g = _run({"readings": [
        {"label": "A", "hard_violations": ["x"]},
        {"label": "B", "hard_violations": []},
        {"label": "C"}]}, )
    assert out == "B" and g["outcome"] == GATE_RESELECTED


def test_a_real_disqualification_is_still_unresolved():
    """판정이 실제로 있었고 모두 실격이면 그것은 **품질** 문제다."""
    _, g = _run(_jr(A=["x"], B=["y"]))
    assert g["outcome"] == GATE_UNRESOLVED


# ── 소비자 — 실격 그림이 **다음 샷의 권위**가 되면 안 된다 ──────────

def test_an_unresolved_shot_is_not_used_as_the_next_shots_anchor(monkeypatch):
    """★확정을 막는 것만으로는 **늦다** (Codex BLOCK).

    `still_recipe_service:3095` 가 `{prev_tag}_sel.png` 가 있으면 바로
    참조로 붙인다 — 파일은 남아 있으므로 이 검사가 없으면 실격 그림이
    후속 생성의 권위가 된다.
    """
    from app.core import config
    from app.modules.pipeline.multiroll_select import GATE_UNRESOLVED
    from app.services.still_recipe_service import gate_blocks_prev_anchor

    # ★게이트가 **켜진** 판을 잰다 — 꺼져 있으면 언제나 안 막는 것이
    #  OFF 계약이고, 그것은 아래 별도 시험이 본다.
    monkeypatch.setattr(config.settings, "still_winner_gate_enabled", True,
                        raising=False)

    class _R:
        def __init__(self, data):
            self.data = data

    assert gate_blocks_prev_anchor(
        _R({"S1sh1": {"gate": {"outcome": GATE_UNRESOLVED}}}), "S1sh1")
    # ★`incomplete` **도 막는다** (Codex BLOCK 2) — 「품질 결함이 확인되지
    #  않았다」는 「사용해도 된다」가 아니다.
    assert gate_blocks_prev_anchor(
        _R({"S1sh1": {"gate": {"outcome": GATE_INCOMPLETE}}}), "S1sh1")
    # 판정이 **적힌** 것만 통과한다
    for g in ({"outcome": GATE_CLEAN}, {"outcome": GATE_NOT_APPLICABLE}):
        assert not gate_blocks_prev_anchor(_R({"S1sh1": {"gate": g}}), "S1sh1")
    # ★★**판정이 없는 앞 샷도 앵커가 아니다** (2026-09-20 Codex).
    #
    #  내 첫 판은 「기록이 없으면 안 막는다」를 여기 **정답으로 잠갔다**.
    #  그런데 켜진 판에서는 **모든 갈래가** 판정을 적는다 — 비대상도
    #  `not_applicable` 로 적는다. 그러니 칸이 없다는 것은 「비대상이라
    #  안 적었다」가 아니라 **아직 안 봤다**는 뜻이다. 실제로 `record` 가
    #  없는 완료 샷은 몸통에 안 들어와 판정이 안 붙는다 — 그 그림이 뒤
    #  샷의 권위가 되면 아무도 안 본 것이 연쇄로 번진다.
    #  ★verify 는 그것을 미판정으로 붙잡는다. 앵커 쪽만 통과시키면 두
    #   소비자가 어긋난다.
    for g in ({}, {"outcome": ""}, None, "?"):
        assert gate_blocks_prev_anchor(_R({"S1sh1": {"gate": g}}), "S1sh1")
    assert gate_blocks_prev_anchor(_R({}), "S1sh1")


def test_an_unreadable_record_blocks_when_the_gate_is_on(monkeypatch):
    """★게이트가 **켜진** 판에서 못 읽는 것은 **검사 불가**이지 합격이
    아니다 (Codex). 켜 놓고 확인을 못 했으면 앵커로 쓰지 않는다."""
    from app.core import config
    from app.services.still_recipe_service import gate_blocks_prev_anchor

    class _Boom:
        @property
        def data(self):
            raise RuntimeError("못 읽는다")

    monkeypatch.setattr(config.settings, "still_winner_gate_enabled", True,
                        raising=False)
    assert gate_blocks_prev_anchor(_Boom(), "S1sh1")


def test_the_gate_off_run_never_blocks(monkeypatch):
    """★꺼져 있으면 **언제나 안 막는다** — 옛 기록이 주행을 세우지 않는다.

    docstring 에만 적어 두고 코드가 안 지키던 자리였다(Codex).
    """
    from app.core import config
    from app.modules.pipeline.multiroll_select import GATE_UNRESOLVED
    from app.services.still_recipe_service import (
        gate_blocks_prev_anchor,
        gate_holds,
    )

    monkeypatch.setattr(config.settings, "still_winner_gate_enabled", False,
                        raising=False)

    class _R:
        data = {"S1sh1": {"gate": {"outcome": GATE_UNRESOLVED}}}

    assert not gate_holds({"gate": {"outcome": GATE_UNRESOLVED}})
    assert not gate_blocks_prev_anchor(_R(), "S1sh1")

    class _Boom:
        @property
        def data(self):
            raise RuntimeError("못 읽는다")

    assert not gate_blocks_prev_anchor(_Boom(), "S1sh1"), (
        "꺼져 있는데 읽기 실패로 막았다")


def test_a_blocked_prev_is_not_bypassed_through_the_db_primary():
    """★파일만 막으면 **우회된다** (Codex).

    `_sel` 파일을 안 쓰게 해도 바로 아래 `else` 가 **DB 대표**를 앵커로
    집는다. 「기존 파일·DB 대표로 조용히 우회하는 것도 새 정책을 통과했다는
    증거가 아니다.」 그래서 검사가 **두 갈래보다 앞**에 있어야 하고,
    막히면 그 샷은 **의존 대기**(미완료)로 간다 — 앵커 없이 그리면
    연결이 깨진 그림을 사게 되기 때문이다.
    """
    import inspect
    import pathlib

    from app.services import still_recipe_service as mod

    src = pathlib.Path(inspect.getfile(mod)).read_text(encoding="utf-8")
    i = src.find("gate_blocks_prev_anchor(records, prev_tag)")
    j = src.find('cand = recipe_dir / f"{prev_tag}_sel.png"')
    k = src.find("ImageAsset.still_id == prev_sid")
    assert 0 < i < j, "검사가 파일 갈래보다 뒤에 있다"
    assert i < k, "검사가 DB 대표 갈래보다 뒤에 있다 — 우회된다"
    # 막히면 **계속 진행하지 않는다**
    seg = src[i:i + 1400]          # 영속 블록이 사이에 있다
    assert "gate_unresolved_tags.append(tag)" in seg, (
        "막힌 샷을 미완료로 안 센다")
    assert '"outcome": GATE_DEPENDENCY,' in seg, (
        "의존 대기를 기록에 안 남긴다 — 다음 샷이 옛 기록을 읽는다")
    assert "continue" in seg, "막힌 샷이 앵커 없이 계속 그려진다"


# ── 바깥 스텝 지문 ─────────────────────────────────────────────────

def test_the_outer_step_hash_carries_the_policy_when_on():
    """★레버만 켜면 **바깥 스텝이 같은 해시로 SKIP** 한다 (Codex BLOCK).

    그러면 샷별 fingerprint 까지 내려가지도 않아 이 수정이 완료된 화에서
    **한 번도 안 돈다**. OFF 는 기존 해시 그대로여야 한다.
    """
    import inspect
    import pathlib

    from app.core.steps import image_steps

    src = pathlib.Path(inspect.getfile(image_steps)).read_text(
        encoding="utf-8")
    i = src.find('payload["winner_gate_policy"]')
    assert i > 0, "바깥 스텝 해시에 정책이 안 접힌다"
    head = src[max(0, i - 400):i]
    assert "still_winner_gate_enabled" in head, (
        "켜진 판에서만 접어야 한다 — OFF 는 기존 해시 유지")


def test_the_plain_flip_branch_refuses_the_gate():
    """★정순 관찰만 싣는 갈래에서는 게이트를 **거부**한다 (Codex ㉣).

    그 갈래는 `judge_fwd` 의 readings 만 쓴다 — 역순의 실격이 빠진다.
    「실격이 빈 후보만 허용」을 절반만 보고 내릴 수는 없다. 지금 실행
    경로는 여기 안 오지만, 주석만 두면 나중에 켤 때 **안 본 위반이
    통과**한다.
    """
    import inspect
    import pathlib

    from app.modules.pipeline import multiroll_select as mod

    src = pathlib.Path(inspect.getfile(mod)).read_text(encoding="utf-8")
    # ★가드는 **한 장도 사기 전**이어야 한다 (Codex) — 롤·판정을 다 산
    #  뒤에 막으면 그 돈이 이미 나갔다.
    i = src.find("if winner_gate_applicable and not getattr(")
    assert i > 0, "입구 가드가 없다"
    assert "raise ValueError" in src[i:i + 400], "주석만 있고 막지 않는다"
    # ★`validate_regeneration_contract` 는 정의도 있으므로 **호출** 자리를
    #  본다(`rfind`). 가드는 그 호출과 첫 생성보다 앞이어야 한다.
    j = src.rfind("    validate_regeneration_contract(")
    k = src.find("_mark_spend_attempt_once()")
    assert i < j, "가드가 계약 검증 호출보다 뒤에 있다"
    assert i < k, "가드가 유료 구간 진입보다 뒤에 있다 — 이미 샀다"
