"""★검색·받기 문은 「중앙이 끝났나」가 아니라 **남은 outbound 소비자가 있나**로 연다 (Codex 2026-09-03 07:20 · 실측 f7cc45c576c0 5판 dry).

소비자 목록은 공장(make_search/make_download) 옆의 OUTBOUND_CONSUMER_STEPS 한 곳. 새 소비자가 공장을 import 하면서 목록에 없으면 AST 시험이 선다."""
from __future__ import annotations

import ast
from pathlib import Path

from app.modules.pipeline.grounding_outbound_consumers import OUTBOUND_CONSUMER_STEPS
from tools.grounding_audit import canary_run as cr

TABLE = {"counted": 480, "search": 120, "download": 240}


class TestTheDoorsFollowRemainingConsumers:
    def test_central_done_but_outdoor_supplement_remaining_opens_the_doors(self, monkeypatch):
        monkeypatch.setattr(cr, "completed_steps_of", lambda rid, statuses=(): ["reference_acquisition"])
        got = cr.outbound_doors_on_resume("r", TABLE, reopen=())
        assert got["search"] == 120 and got["download"] == 240
        assert got["★outbound_consumers"] == ["outdoor_structure_form_reference"]

    def test_all_consumers_done_or_not_applicable_closes_the_doors(self, monkeypatch):
        monkeypatch.setattr(cr, "completed_steps_of", lambda rid, statuses=(): list(OUTBOUND_CONSUMER_STEPS))
        got = cr.outbound_doors_on_resume("r", TABLE, reopen=())
        assert got["search"] == 0 and got["download"] == 0 and got["★outbound_consumers"] == []

    def test_a_reopened_consumer_opens_the_doors_even_when_done(self, monkeypatch):
        monkeypatch.setattr(cr, "completed_steps_of", lambda rid, statuses=(): list(OUTBOUND_CONSUMER_STEPS))
        got = cr.outbound_doors_on_resume("r", TABLE, reopen=["reference_acquisition"])
        assert got["search"] == 120 and got["★outbound_consumers"] == ["reference_acquisition"]


class TestTheConsumerContractIsComplete:
    def test_every_step_importing_the_search_or_download_factory_is_listed(self):
        """★AST 잠금 — 이름·문자열이 아니라 import 로 본다."""
        root = Path(__file__).resolve().parents[2] / "app" / "core" / "steps"
        importers = set()
        for p in root.glob("*.py"):
            tree = ast.parse(p.read_text(encoding="utf-8"))
            for node in ast.walk(tree):
                if isinstance(node, ast.ImportFrom) and (node.module or "").endswith("reference_acquisition_step"):
                    if any(a.name in ("make_search", "make_download") for a in node.names):
                        importers.add(p.stem)
        step_ids = {name[:-5] if name.endswith("_step") else name for name in importers}
        step_ids.discard("reference_acquisition")
        missing = sorted(step_ids - set(OUTBOUND_CONSUMER_STEPS))
        assert not missing, f"공장을 부르는데 소비자 계약에 없다: {missing}"
        assert "reference_acquisition" in OUTBOUND_CONSUMER_STEPS


    def test_a_consumer_outside_the_closure_does_not_open_the_doors(self, monkeypatch):
        """scene_detail 닫힘엔 야외 스텝이 없다 — 돌지 않는 소비자로 문을 열지 않는다."""
        monkeypatch.setattr(cr, "completed_steps_of", lambda rid, statuses=(): ["reference_acquisition"])
        got = cr.outbound_doors_on_resume("r", TABLE, reopen=(), applied=["reference_acquisition", "scene_detail"])
        assert got["search"] == 0 and got["★outbound_consumers"] == []


    def test_the_contract_module_is_light_enough_to_read_before_the_isolation_lock(self):
        """★canary 가 자물쇠 전에 읽는다 — 이 모듈을 import 해도 app.core.database 가 올라오면 안 된다 (실측 07:41)."""
        import subprocess, sys
        code = ("import sys; import app.modules.pipeline.grounding_outbound_consumers as m; "
                "print('app.core.database' in sys.modules, tuple(m.OUTBOUND_CONSUMER_STEPS))")
        out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=str(Path(__file__).resolve().parents[2]))
        assert out.returncode == 0, out.stderr[-400:]
        assert out.stdout.strip().startswith("False"), out.stdout
