diff --git a/src/block_reference.py b/src/block_reference.py index 2be80d5..3754d45 100644 --- a/src/block_reference.py +++ b/src/block_reference.py @@ -20,9 +20,10 @@ import re from pathlib import Path from typing import Any -import yaml from jinja2 import Environment, FileSystemLoader +from src import catalog as _catalog_mod + logger = logging.getLogger(__name__) # 템플릿 디렉토리 @@ -101,32 +102,18 @@ RELATION_CATEGORY_MAP: dict[str, list[str]] = { # ══════════════════════════════════════ -# 카탈로그 로딩 (mtime 캐싱) +# 카탈로그 로딩 (IMP-27: src.catalog 공유 로더 위임) # ══════════════════════════════════════ -_catalog_cache: dict[str, Any] = {"data": None, "mtime": 0} - def _load_catalog() -> list[dict]: - """catalog.yaml 로드 (mtime 캐싱).""" - path = TEMPLATES_DIR / "catalog.yaml" - mtime = path.stat().st_mtime - if _catalog_cache["data"] is not None and _catalog_cache["mtime"] == mtime: - return _catalog_cache["data"] - - data = yaml.safe_load(path.read_text(encoding="utf-8")) - blocks = data.get("blocks", []) - _catalog_cache["data"] = blocks - _catalog_cache["mtime"] = mtime - return blocks + """catalog.yaml blocks list (IMP-27: shared loader delegation).""" + return _catalog_mod.load_blocks() def _get_block_by_id(block_id: str) -> dict | None: - """블록 ID로 카탈로그 엔트리 조회.""" - for b in _load_catalog(): - if b["id"] == block_id: - return b - return None + """블록 ID로 카탈로그 엔트리 조회 (IMP-27: shared loader delegation).""" + return _catalog_mod.get_block_by_id(block_id) # ══════════════════════════════════════ diff --git a/src/block_selector.py b/src/block_selector.py index 39b18ac..29a53ef 100644 --- a/src/block_selector.py +++ b/src/block_selector.py @@ -5,24 +5,18 @@ AI에게 불가능한 선택지를 주지 않는다 (Beautiful.ai 원칙). 주요 함수: - select_block_candidates(): topic + 컨테이너 → 물리적으로 가능한 후보 2-4개 -- load_catalog(): catalog.yaml 로딩 + 캐싱 +- load_catalog(): catalog.yaml 로딩 + 캐싱 (IMP-27: src.catalog 공유 로더 위임) """ from __future__ import annotations import logging -from pathlib import Path from typing import Any -import yaml - +from src import catalog as _catalog_mod from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER logger = logging.getLogger(__name__) -CATALOG_PATH = Path("templates/catalog.yaml") -_catalog_cache: dict | None = None -_catalog_mtime: float = 0.0 - # ────────────────────────────────────── # relation_type → 블록 카테고리 매핑 (Napkin.ai 방식) @@ -52,35 +46,16 @@ BLOCKS_FORCING_FORMAT_CHANGE = { # ────────────────────────────────────── -# catalog.yaml 로딩 (mtime 캐시) +# catalog.yaml 로딩 (IMP-27: src.catalog 공유 로더 위임) # ────────────────────────────────────── def load_catalog() -> dict: - """catalog.yaml을 로딩한다. mtime 기반 캐싱.""" - global _catalog_cache, _catalog_mtime - - if not CATALOG_PATH.exists(): - logger.error(f"catalog.yaml 미발견: {CATALOG_PATH}") - return {"blocks": []} - - current_mtime = CATALOG_PATH.stat().st_mtime - if _catalog_cache is not None and current_mtime == _catalog_mtime: - return _catalog_cache - - with open(CATALOG_PATH, encoding="utf-8") as f: - _catalog_cache = yaml.safe_load(f) - _catalog_mtime = current_mtime - - block_count = len(_catalog_cache.get("blocks", [])) - logger.info(f"[Q-2] catalog.yaml 로딩: {block_count}개 블록") - return _catalog_cache + """catalog.yaml root dict (IMP-27: shared loader delegation).""" + return _catalog_mod.load_root_catalog() def _get_block_by_id(block_id: str, catalog: dict) -> dict | None: - """catalog에서 블록 ID로 검색.""" - for block in catalog.get("blocks", []): - if block.get("id") == block_id: - return block - return None + """catalog-injected 블록 ID 조회 (IMP-27: shared loader delegation).""" + return _catalog_mod.get_block_by_id(block_id, catalog) # ────────────────────────────────────── diff --git a/src/catalog.py b/src/catalog.py new file mode 100644 index 0000000..ecce48e --- /dev/null +++ b/src/catalog.py @@ -0,0 +1,76 @@ +"""IMP-27: Shared catalog.yaml loader (single file-read + mtime cache). + +Phase Q evolution 중 block_reference, block_selector, renderer 가 각각 templates/ +catalog.yaml 을 읽고 mtime 캐시하던 중복을 한 곳으로 통합한다. call-site +signature 는 그대로 유지되며, 각 wrapper 는 본 모듈의 결과를 자신이 약속하는 +형태(list[dict] / root dict / id→path projection)로 변환만 수행한다. + +Functions: + load_root_catalog() -> dict : raw catalog dict (matches block_selector contract) + load_blocks() -> list[dict] : root_catalog.get("blocks", []) projection + get_block_by_id(block_id, catalog=None) -> dict | None + get_catalog_mtime() -> float : current cached mtime (renderer projection key) +""" +from __future__ import annotations + +import logging +from pathlib import Path + +import yaml + +logger = logging.getLogger(__name__) + +CATALOG_PATH = Path(__file__).parent.parent / "templates" / "catalog.yaml" + +_catalog_cache: dict | None = None +_catalog_mtime: float = 0.0 + + +def load_root_catalog() -> dict: + """Load templates/catalog.yaml as root dict, with mtime caching. + + Missing file → logs warning and returns ``{"blocks": []}`` (matches the + pre-IMP-27 behavior of block_selector.load_catalog and renderer._load_catalog_map). + """ + global _catalog_cache, _catalog_mtime + + if not CATALOG_PATH.exists(): + logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}") + return {"blocks": []} + + current_mtime = CATALOG_PATH.stat().st_mtime + if _catalog_cache is not None and current_mtime == _catalog_mtime: + return _catalog_cache + + with open(CATALOG_PATH, encoding="utf-8") as f: + _catalog_cache = yaml.safe_load(f) + _catalog_mtime = current_mtime + + block_count = len((_catalog_cache or {}).get("blocks", [])) + logger.info(f"[catalog] load: {block_count} blocks") + return _catalog_cache + + +def load_blocks() -> list[dict]: + """Return blocks list (= root_catalog.get('blocks', [])).""" + return load_root_catalog().get("blocks", []) + + +def get_block_by_id(block_id: str, catalog: dict | None = None) -> dict | None: + """Locate a block entry by id. + + ``catalog=None`` → uses shared loader. caller-supplied catalog dict is + accepted as-is so the existing block_selector contract (catalog-injected) + keeps working unchanged. + """ + if catalog is None: + catalog = load_root_catalog() + for block in catalog.get("blocks", []): + if block.get("id") == block_id: + return block + return None + + +def get_catalog_mtime() -> float: + """Current cached mtime (renderer projection caches key off this).""" + return _catalog_mtime diff --git a/src/renderer.py b/src/renderer.py index 70cd3fc..549f4b0 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -13,86 +13,76 @@ from collections import OrderedDict from pathlib import Path from typing import Any -import yaml from jinja2 import Environment, FileSystemLoader +from src import catalog as _catalog_mod + logger = logging.getLogger(__name__) TEMPLATES_DIR = Path(__file__).parent.parent / "templates" STATIC_DIR = Path(__file__).parent.parent / "static" -CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml" # 카테고리 검색 순서 BLOCK_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"] -# catalog.yaml에서 id → template 경로 매핑 로드 (BF-10: mtime 체크로 자동 갱신) +# id → template 경로 매핑 (IMP-27: src.catalog 공유 로더 위임, renderer-local projection cache) _CATALOG_MAP: dict[str, str] | None = None -_CATALOG_MTIME: float = 0.0 +_CATALOG_MAP_MTIME: float = 0.0 + +# Phase R: variant별 template 경로 캐시 (renderer-local projection) +_CATALOG_VARIANT_MAP: dict[str, str] | None = None +_CATALOG_VARIANT_MAP_MTIME: float = 0.0 + def _load_catalog_map() -> dict[str, str]: - """catalog.yaml에서 블록 id → template 경로 매핑을 로드한다. + """블록 id → template 경로 projection (IMP-27: src.catalog 공유 로더 위임). - 파일 수정시간(mtime)을 확인하여, 변경 시에만 재로드한다. + catalog 파일 읽기와 mtime 캐싱은 ``src.catalog`` 가 단독 소유. 본 함수는 + 그 결과를 ``id → template`` 형태로 변환한 renderer-local projection 캐시만 + 유지하며, projection 무효화는 ``src.catalog.get_catalog_mtime()`` 키잉. """ - global _CATALOG_MAP, _CATALOG_MTIME + global _CATALOG_MAP, _CATALOG_MAP_MTIME - current_mtime = CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0 + blocks = _catalog_mod.load_blocks() + current_mtime = _catalog_mod.get_catalog_mtime() - if _CATALOG_MAP is not None and _CATALOG_MTIME == current_mtime: - return _CATALOG_MAP # 파일 변경 없음 → 캐시 재사용 + if _CATALOG_MAP is not None and _CATALOG_MAP_MTIME == current_mtime: + return _CATALOG_MAP - # 변경 감지 또는 첫 로드 → 새로 읽기 - _CATALOG_MTIME = current_mtime + _CATALOG_MAP_MTIME = current_mtime _CATALOG_MAP = {} - if CATALOG_PATH.exists(): - try: - with open(CATALOG_PATH, encoding="utf-8") as f: - catalog = yaml.safe_load(f) - for block in catalog.get("blocks", []): - block_id = block.get("id", "") - template = block.get("template", "") - if block_id and template: - _CATALOG_MAP[block_id] = template - logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑") - except Exception as e: - logger.warning(f"catalog.yaml 로드 실패: {e}") - else: - logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}") + for block in blocks: + block_id = block.get("id", "") + template = block.get("template", "") + if block_id and template: + _CATALOG_MAP[block_id] = template + logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑") return _CATALOG_MAP -# Phase R: variant별 template 경로 캐시 -_CATALOG_VARIANT_MAP: dict[str, str] | None = None - - def _load_catalog_map_with_variants() -> dict[str, str]: - """catalog.yaml에서 variant별 template 경로 매핑을 로드한다. + """variant별 template 경로 projection (IMP-27: src.catalog 공유 로더 위임). - 키: "block_id--variant_id" → 값: template 경로 + 키: "block_id--variant_id" → 값: template 경로. """ - global _CATALOG_VARIANT_MAP + global _CATALOG_VARIANT_MAP, _CATALOG_VARIANT_MAP_MTIME - # _load_catalog_map이 이미 캐시 관리하므로 같은 mtime 사용 - _load_catalog_map() # 캐시 갱신 보장 + blocks = _catalog_mod.load_blocks() + current_mtime = _catalog_mod.get_catalog_mtime() - if _CATALOG_VARIANT_MAP is not None and _CATALOG_MTIME == (CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0): + if _CATALOG_VARIANT_MAP is not None and _CATALOG_VARIANT_MAP_MTIME == current_mtime: return _CATALOG_VARIANT_MAP + _CATALOG_VARIANT_MAP_MTIME = current_mtime _CATALOG_VARIANT_MAP = {} - if CATALOG_PATH.exists(): - try: - with open(CATALOG_PATH, encoding="utf-8") as f: - catalog = yaml.safe_load(f) - for block in catalog.get("blocks", []): - block_id = block.get("id", "") - for variant in block.get("variants", []): - vid = variant.get("id", "default") - vtemplate = variant.get("template", "") - if vid != "default" and vtemplate: - _CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate - except Exception as e: - logger.warning(f"catalog variant 로드 실패: {e}") + for block in blocks: + block_id = block.get("id", "") + for variant in block.get("variants", []): + vid = variant.get("id", "default") + vtemplate = variant.get("template", "") + if vid != "default" and vtemplate: + _CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate return _CATALOG_VARIANT_MAP diff --git a/tests/test_catalog_shared_loader.py b/tests/test_catalog_shared_loader.py new file mode 100644 index 0000000..7f269f3 --- /dev/null +++ b/tests/test_catalog_shared_loader.py @@ -0,0 +1,421 @@ +"""IMP-27: Shared catalog loader tests (u1). + +Validates that ``src.catalog`` provides a single file-read + mtime cache and +that its four public functions honor the documented contracts. + +Note: ``templates/catalog.yaml`` was deleted in cc2f434 (legacy block library +cleanup). The shared loader still preserves the loader contract for any +remaining Phase Q call sites; tests use a fixture catalog via monkeypatch so +they are independent of the deleted production file. + +Delegation tests for block_reference / block_selector / renderer wrappers are +added in u2 / u3 / u4. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + + +FIXTURE_CATALOG = { + "blocks": [ + { + "id": "fixture-block-a", + "category": "emphasis", + "template": "blocks/emphasis/fixture-block-a.html", + }, + { + "id": "fixture-block-b", + "category": "cards", + "template": "blocks/cards/fixture-block-b.html", + "variants": [ + {"id": "default", "template": "blocks/cards/fixture-block-b.html"}, + {"id": "compact", "template": "blocks/cards/fixture-block-b--compact.html"}, + ], + }, + ] +} + + +def _reset_catalog_module(): + import src.catalog as catalog_mod + catalog_mod._catalog_cache = None + catalog_mod._catalog_mtime = 0.0 + + +def _reset_renderer_projection_cache(): + """IMP-27 u4: clear renderer-local projection caches between tests.""" + import src.renderer as renderer_mod + renderer_mod._CATALOG_MAP = None + renderer_mod._CATALOG_MAP_MTIME = 0.0 + renderer_mod._CATALOG_VARIANT_MAP = None + renderer_mod._CATALOG_VARIANT_MAP_MTIME = 0.0 + + +@pytest.fixture +def fixture_catalog_path(tmp_path, monkeypatch): + """Point src.catalog at a tmp catalog.yaml fixture and reset its cache.""" + import src.catalog as catalog_mod + + fixture_path = tmp_path / "catalog.yaml" + fixture_path.write_text(yaml.safe_dump(FIXTURE_CATALOG), encoding="utf-8") + monkeypatch.setattr(catalog_mod, "CATALOG_PATH", fixture_path) + _reset_catalog_module() + _reset_renderer_projection_cache() + return fixture_path + + +def test_load_root_catalog_returns_root_dict(fixture_catalog_path): + from src import catalog + + root = catalog.load_root_catalog() + assert isinstance(root, dict) + assert "blocks" in root + assert len(root["blocks"]) == 2 + + +def test_load_blocks_returns_list_of_block_dicts(fixture_catalog_path): + from src import catalog + + blocks = catalog.load_blocks() + assert isinstance(blocks, list) + assert len(blocks) == 2 + assert all(isinstance(b, dict) for b in blocks) + assert all("id" in b for b in blocks) + + +def test_get_block_by_id_without_catalog_arg(fixture_catalog_path): + from src import catalog + + found = catalog.get_block_by_id("fixture-block-a") + assert found is not None + assert found["id"] == "fixture-block-a" + assert found["category"] == "emphasis" + + +def test_get_block_by_id_with_catalog_arg_preserves_block_selector_contract(fixture_catalog_path): + from src import catalog + + root = catalog.load_root_catalog() + found = catalog.get_block_by_id("fixture-block-b", catalog=root) + assert found is not None + assert found["id"] == "fixture-block-b" + + +def test_get_block_by_id_unknown_returns_none(fixture_catalog_path): + from src import catalog + + assert catalog.get_block_by_id("__nonexistent_block_id__") is None + + +def test_mtime_cache_does_single_file_read(fixture_catalog_path, monkeypatch): + import src.catalog as catalog_mod + + read_count = {"n": 0} + real_safe_load = catalog_mod.yaml.safe_load + + def counting_safe_load(stream): + read_count["n"] += 1 + return real_safe_load(stream) + + monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load) + + catalog_mod.load_root_catalog() + catalog_mod.load_root_catalog() + catalog_mod.load_blocks() + catalog_mod.get_block_by_id("fixture-block-a") + + assert read_count["n"] == 1, ( + f"Expected exactly one yaml.safe_load call on cold cache, " + f"got {read_count['n']}" + ) + + +def test_mtime_change_triggers_reload(fixture_catalog_path, monkeypatch): + import os + import src.catalog as catalog_mod + + read_count = {"n": 0} + real_safe_load = catalog_mod.yaml.safe_load + + def counting_safe_load(stream): + read_count["n"] += 1 + return real_safe_load(stream) + + monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load) + + catalog_mod.load_root_catalog() + assert read_count["n"] == 1 + + # Forcibly advance file mtime and verify cache invalidates. + original_mtime = fixture_catalog_path.stat().st_mtime + os.utime(fixture_catalog_path, (original_mtime + 10, original_mtime + 10)) + + catalog_mod.load_root_catalog() + assert read_count["n"] == 2 + + +def test_get_catalog_mtime_matches_file_after_load(fixture_catalog_path): + from src import catalog + + catalog.load_root_catalog() + actual = fixture_catalog_path.stat().st_mtime + assert catalog.get_catalog_mtime() == actual + + +def test_get_catalog_mtime_is_zero_before_first_load(monkeypatch, tmp_path): + import src.catalog as catalog_mod + monkeypatch.setattr(catalog_mod, "CATALOG_PATH", tmp_path / "unused.yaml") + _reset_catalog_module() + + assert catalog_mod.get_catalog_mtime() == 0.0 + + +def test_load_root_catalog_missing_file_returns_empty_blocks(monkeypatch, tmp_path): + import src.catalog as catalog_mod + + missing_path = tmp_path / "no_such_catalog.yaml" + monkeypatch.setattr(catalog_mod, "CATALOG_PATH", missing_path) + _reset_catalog_module() + + root = catalog_mod.load_root_catalog() + assert root == {"blocks": []} + assert catalog_mod.load_blocks() == [] + + +# ────────────────────────────────────────────────────────── +# u2: block_reference delegation tests +# ────────────────────────────────────────────────────────── + + +def test_block_reference_load_catalog_returns_list_of_blocks(fixture_catalog_path): + """block_reference._load_catalog preserves list[dict] contract via delegation.""" + from src import block_reference + + blocks = block_reference._load_catalog() + assert isinstance(blocks, list) + assert len(blocks) == 2 + assert all(isinstance(b, dict) for b in blocks) + assert {b["id"] for b in blocks} == {"fixture-block-a", "fixture-block-b"} + + +def test_block_reference_get_block_by_id_no_arg_signature(fixture_catalog_path): + """block_reference._get_block_by_id preserves no-catalog-argument contract.""" + from src import block_reference + + found = block_reference._get_block_by_id("fixture-block-a") + assert found is not None + assert found["id"] == "fixture-block-a" + assert found["category"] == "emphasis" + + assert block_reference._get_block_by_id("__nonexistent__") is None + + +def test_block_reference_shares_cache_with_shared_loader(fixture_catalog_path, monkeypatch): + """block_reference wrappers must hit the shared mtime cache, not a private copy.""" + import src.catalog as catalog_mod + from src import block_reference + + read_count = {"n": 0} + real_safe_load = catalog_mod.yaml.safe_load + + def counting_safe_load(stream): + read_count["n"] += 1 + return real_safe_load(stream) + + monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load) + + block_reference._load_catalog() + block_reference._get_block_by_id("fixture-block-a") + catalog_mod.load_root_catalog() + + assert read_count["n"] == 1, ( + f"block_reference wrappers should share the catalog cache; " + f"got {read_count['n']} reads" + ) + + +def test_block_reference_has_no_private_catalog_cache(fixture_catalog_path): + """IMP-27 u2 guard: block_reference must not retain a module-level _catalog_cache.""" + from src import block_reference + + assert not hasattr(block_reference, "_catalog_cache"), ( + "block_reference._catalog_cache must be removed by u2 — " + "loader is delegated to src.catalog" + ) + + +# ────────────────────────────────────────────────────────── +# u3: block_selector delegation tests +# ────────────────────────────────────────────────────────── + + +def test_block_selector_load_catalog_returns_root_dict(fixture_catalog_path): + """block_selector.load_catalog preserves root-dict contract via delegation.""" + from src import block_selector + + root = block_selector.load_catalog() + assert isinstance(root, dict) + assert "blocks" in root + assert isinstance(root["blocks"], list) + assert {b["id"] for b in root["blocks"]} == {"fixture-block-a", "fixture-block-b"} + + +def test_block_selector_get_block_by_id_catalog_injected_signature(fixture_catalog_path): + """block_selector._get_block_by_id preserves catalog-injected signature.""" + from src import block_selector + + catalog_dict = block_selector.load_catalog() + found = block_selector._get_block_by_id("fixture-block-b", catalog_dict) + assert found is not None + assert found["id"] == "fixture-block-b" + assert found["category"] == "cards" + + assert block_selector._get_block_by_id("__nonexistent__", catalog_dict) is None + + +def test_block_selector_shares_cache_with_shared_loader(fixture_catalog_path, monkeypatch): + """block_selector wrappers must hit the shared mtime cache, not a private copy.""" + import src.catalog as catalog_mod + from src import block_selector + + read_count = {"n": 0} + real_safe_load = catalog_mod.yaml.safe_load + + def counting_safe_load(stream): + read_count["n"] += 1 + return real_safe_load(stream) + + monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load) + + block_selector.load_catalog() + block_selector._get_block_by_id("fixture-block-a", block_selector.load_catalog()) + catalog_mod.load_root_catalog() + + assert read_count["n"] == 1, ( + f"block_selector wrappers should share the catalog cache; " + f"got {read_count['n']} reads" + ) + + +def test_block_selector_has_no_private_catalog_cache(fixture_catalog_path): + """IMP-27 u3 guard: block_selector must not retain module-level cache/mtime/CATALOG_PATH.""" + from src import block_selector + + assert not hasattr(block_selector, "_catalog_cache"), ( + "block_selector._catalog_cache must be removed by u3 — " + "loader is delegated to src.catalog" + ) + assert not hasattr(block_selector, "_catalog_mtime"), ( + "block_selector._catalog_mtime must be removed by u3" + ) + assert not hasattr(block_selector, "CATALOG_PATH"), ( + "block_selector.CATALOG_PATH must be removed by u3 — " + "path lives in src.catalog only" + ) + + +# ────────────────────────────────────────────────────────── +# u4: renderer delegation tests +# ────────────────────────────────────────────────────────── + + +def test_renderer_load_catalog_map_returns_id_to_template_dict(fixture_catalog_path): + """renderer._load_catalog_map preserves id → template projection contract.""" + from src import renderer + + mapping = renderer._load_catalog_map() + assert isinstance(mapping, dict) + assert mapping["fixture-block-a"] == "blocks/emphasis/fixture-block-a.html" + assert mapping["fixture-block-b"] == "blocks/cards/fixture-block-b.html" + + +def test_renderer_load_catalog_map_with_variants_returns_compound_key_dict(fixture_catalog_path): + """renderer._load_catalog_map_with_variants preserves 'id--variant' → template projection.""" + from src import renderer + + vmap = renderer._load_catalog_map_with_variants() + assert isinstance(vmap, dict) + # 'default' variants must be excluded (preserves pre-IMP-27 behavior). + assert "fixture-block-b--default" not in vmap + # Non-default variants must be present with the compound key. + assert vmap["fixture-block-b--compact"] == "blocks/cards/fixture-block-b--compact.html" + + +def test_renderer_shares_cache_with_shared_loader(fixture_catalog_path, monkeypatch): + """renderer projections must read through src.catalog, never opening the file directly.""" + import src.catalog as catalog_mod + from src import renderer + + read_count = {"n": 0} + real_safe_load = catalog_mod.yaml.safe_load + + def counting_safe_load(stream): + read_count["n"] += 1 + return real_safe_load(stream) + + monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load) + + renderer._load_catalog_map() + renderer._load_catalog_map_with_variants() + catalog_mod.load_root_catalog() + + assert read_count["n"] == 1, ( + f"renderer projections should share the catalog cache; " + f"got {read_count['n']} reads" + ) + + +def test_renderer_projection_invalidates_when_shared_mtime_changes( + fixture_catalog_path, monkeypatch +): + """IMP-27 u4 contract: renderer projection cache keyed off src.catalog.get_catalog_mtime.""" + import os + + import src.catalog as catalog_mod + from src import renderer + + first_map = renderer._load_catalog_map() + first_id_to_path = dict(first_map) + + # Rewrite the fixture file with a different block id and bump mtime so the + # shared cache (and therefore the renderer projection) must rebuild. + new_catalog = { + "blocks": [ + { + "id": "fixture-block-c", + "category": "headers", + "template": "blocks/headers/fixture-block-c.html", + }, + ] + } + fixture_catalog_path.write_text(yaml.safe_dump(new_catalog), encoding="utf-8") + original_mtime = fixture_catalog_path.stat().st_mtime + os.utime(fixture_catalog_path, (original_mtime + 10, original_mtime + 10)) + # Force src.catalog to drop its in-memory cache so the next call re-reads. + catalog_mod._catalog_cache = None + catalog_mod._catalog_mtime = 0.0 + + second_map = renderer._load_catalog_map() + assert "fixture-block-c" in second_map + assert "fixture-block-a" not in second_map + assert first_id_to_path != second_map + + +def test_renderer_has_no_private_catalog_path_or_yaml(fixture_catalog_path): + """IMP-27 u4 guard: renderer must not retain its own file-read state.""" + from src import renderer + + assert not hasattr(renderer, "CATALOG_PATH"), ( + "renderer.CATALOG_PATH must be removed by u4 — path lives in src.catalog only" + ) + assert not hasattr(renderer, "yaml"), ( + "renderer.yaml import must be removed by u4 — file-read is delegated to src.catalog" + ) + assert not hasattr(renderer, "_CATALOG_MTIME"), ( + "renderer._CATALOG_MTIME (legacy single mtime) must be removed by u4 — " + "projection caches now key off src.catalog.get_catalog_mtime() via " + "_CATALOG_MAP_MTIME / _CATALOG_VARIANT_MAP_MTIME" + )