"""Phase-2 config and external prompt pack (docs/phase-2 P1a, sections 14-15). ``config.py`` centralizes every phase-2 path/env read (no hard-coded data paths - section 15). ``prompt.py`` loads the EXTERNAL, versioned prompt pack (section 14): prompts never live in this repo; the pack carries its own version and each prompt has an individual content hash, so ``prompt_version`` can enter the response-cache composite key (D6). The minimal pack under ``tests/fixtures/prompt_pack/`` is the local fixture so CI never depends on the external prompt repository, and missing/invalid packs raise a clear ``PromptPackError`` instead of falling back silently. """ from __future__ import annotations from pathlib import Path import pytest from pf1e_simulator.config import ( ENV_LLM_API_KEY, ENV_PROMPT_PACK_DIR, env_path, env_str, llm_api_key, ) from pf1e_simulator.prompt import ( PromptPack, PromptPackError, clear_pack_cache, load_prompt_pack, ) FIXTURE_PACK = Path(__file__).resolve().parent / "fixtures" / "prompt_pack" _FIXTURE_VERSION = "0.1.0-test" _FIXTURE_PROMPT_NAME = "nl_default" def _write_pack(root: Path, *, version: str, prompts: dict[str, str]) -> None: """Write a minimal prompt pack directory (version.txt + .md).""" root.mkdir(parents=True, exist_ok=True) (root / "version.txt").write_text(version, encoding="utf-8") for name, text in prompts.items(): (root / f"{name}.md").write_text(text, encoding="utf-8") # ── config.py ────────────────────────────────────────────────────────────── def test_env_str_and_path_round_trip( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: """env_str/env_path read the environment and respect deletes.""" monkeypatch.setenv("PF1E_SIM_TEST_STR", "abc") monkeypatch.setenv("PF1E_SIM_TEST_PATH", str(tmp_path / "x")) assert env_str("PF1E_SIM_TEST_STR") == "abc" assert env_str("PF1E_SIM_TEST_UNSET") is None assert env_str("PF1E_SIM_TEST_UNSET", default="d") == "d" assert env_path("PF1E_SIM_TEST_PATH") == tmp_path / "x" assert env_path("PF1E_SIM_TEST_UNSET") is None monkeypatch.delenv("PF1E_SIM_TEST_STR") assert env_str("PF1E_SIM_TEST_STR") is None def test_llm_api_key_reads_env(monkeypatch: pytest.MonkeyPatch) -> None: """llm_api_key surfaces the dedicated env var (None when unset).""" assert llm_api_key() is None monkeypatch.setenv(ENV_LLM_API_KEY, "sk-test") assert llm_api_key() == "sk-test" # ── prompt.py — loading and validation ───────────────────────────────────── def test_load_fixture_pack() -> None: """The local fixture loads: version pinned, prompts readable by name.""" pack = load_prompt_pack(FIXTURE_PACK) assert pack.version == _FIXTURE_VERSION assert pack.root == FIXTURE_PACK text = pack.get(_FIXTURE_PROMPT_NAME) assert "tactical director" in text # The pack has exactly the fixture prompts (only .md files count). assert pack.get("nl_default") == (FIXTURE_PACK / "nl_default.md").read_text( encoding="utf-8" ) def test_prompt_version_stable_and_content_sensitive(tmp_path: Path) -> None: """prompt_version carries pack version + per-prompt content hash (D6).""" pack = load_prompt_pack(FIXTURE_PACK) a = pack.prompt_version(_FIXTURE_PROMPT_NAME) b = pack.prompt_version(_FIXTURE_PROMPT_NAME) assert a == b # deterministic within a loaded pack assert a.startswith(f"{_FIXTURE_VERSION}:{_FIXTURE_PROMPT_NAME}:") other = tmp_path / "other" _write_pack(other, version=_FIXTURE_VERSION, prompts={_FIXTURE_PROMPT_NAME: "x"}) assert load_prompt_pack(other).prompt_version(_FIXTURE_PROMPT_NAME) != a bumped = tmp_path / "bumped" _write_pack(bumped, version="1.0.0", prompts={_FIXTURE_PROMPT_NAME: "x"}) assert load_prompt_pack(bumped).prompt_version(_FIXTURE_PROMPT_NAME).startswith( "1.0.0:" ) def test_missing_prompt_raises_clear_error(tmp_path: Path) -> None: """A missing prompt name fails loudly, naming the expected file.""" other = tmp_path / "other" _write_pack(other, version="1.0.0", prompts={"other": "x"}) with pytest.raises(PromptPackError, match=r"missing.*nl_default\.md"): load_prompt_pack(other).get("nl_default") def test_missing_pack_dir_raises(tmp_path: Path) -> None: """A nonexistent pack path is a clear error, not a silent fallback.""" with pytest.raises(PromptPackError, match="not found"): load_prompt_pack(tmp_path / "nope") def test_missing_version_file_raises(tmp_path: Path) -> None: """A pack without version.txt is invalid (traceability, section 14).""" root = tmp_path / "noversion" _write_pack(root, version="", prompts={}) (root / "version.txt").unlink() with pytest.raises(PromptPackError, match="version\\.txt"): load_prompt_pack(root) def test_empty_version_file_raises(tmp_path: Path) -> None: """An empty version.txt is invalid (no untraceable pack).""" root = tmp_path / "emptyversion" _write_pack(root, version="", prompts={_FIXTURE_PROMPT_NAME: "x"}) with pytest.raises(PromptPackError, match="empty"): load_prompt_pack(root) def test_unset_pack_dir_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: """Without PF1E_PROMPT_PACK_DIR, loading raises instead of falling back.""" monkeypatch.delenv(ENV_PROMPT_PACK_DIR, raising=False) with pytest.raises(PromptPackError, match=ENV_PROMPT_PACK_DIR): load_prompt_pack() def test_pack_dir_from_env( monkeypatch: pytest.MonkeyPatch, ) -> None: """load_prompt_pack() resolves the pack through the env var (section 15).""" monkeypatch.setenv(ENV_PROMPT_PACK_DIR, str(FIXTURE_PACK)) assert load_prompt_pack().version == _FIXTURE_VERSION monkeypatch.delenv(ENV_PROMPT_PACK_DIR) def test_cache_and_clear(tmp_path: Path) -> None: """Repeated loads share the cached instance; clear_pack_cache drops it.""" root = tmp_path / "cached" _write_pack(root, version="1.0.0", prompts={_FIXTURE_PROMPT_NAME: "x"}) first = load_prompt_pack(root) assert load_prompt_pack(root) is first clear_pack_cache() assert load_prompt_pack(root) is not first def test_load_returns_prompt_pack_instance() -> None: """The loaded object is a PromptPack (typed API for the client layer).""" pack = load_prompt_pack(FIXTURE_PACK) assert isinstance(pack, PromptPack) assert pack.version == _FIXTURE_VERSION