From 6b0b16b1dd9eb5631cfd65afafd511ebd1ea20e1 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Thu, 23 Apr 2026 11:35:52 +0800 Subject: [PATCH] test(01-01): add failing test for config resolver contract - Tests DEFAULT_CONFIG defaults (99_System, 03_Resources, Literature, etc.) - Tests ENV_KEYS has all required PAPERFORGE_* overrides - Tests load_vault_config precedence: overrides > env > JSON > defaults - Tests nested vault_config honored per CONF-04 - Tests top-level keys override nested for backward compat per CONF-04 - Tests paperforge_paths returns exactly required user-facing keys - Tests paths are absolute Path objects - Tests worker_script and ld_deep_script keys present - Tests paths_as_strings returns JSON-serializable dict[str,str] - Tests resolve_vault precedence: cli > env > json search > cwd RED phase: module not yet implemented, all tests fail as expected. --- tests/test_config.py | 448 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..fbe8fea7 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,448 @@ +"""Tests for paperforge_lite.config resolver contract. + +These tests prove: +- CONF-01: Env vars override JSON values +- CONF-02: paperforge_paths returns required key inventory +- CONF-03: All consumers use the same resolver +- CONF-04: Top-level and nested paperforge.json keys are both honored +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def minimal_vault(tmp_path: Path) -> Path: + """A vault with no paperforge.json.""" + return tmp_path + + +@pytest.fixture +def vault_with_nested_config(tmp_path: Path) -> Path: + """A vault with nested vault_config block.""" + vault = tmp_path / "vault_nested" + vault.mkdir() + pf = vault / "paperforge.json" + pf.write_text( + json.dumps({ + "vault_config": { + "system_dir": "CustomSystem", + "resources_dir": "CustomResources", + "literature_dir": "CustomLiterature", + "control_dir": "CustomControl", + "base_dir": "CustomBases", + } + }, ensure_ascii=False), + encoding="utf-8", + ) + return vault + + +@pytest.fixture +def vault_with_top_level_config(tmp_path: Path) -> Path: + """A vault with legacy top-level keys that should override nested for backward compat.""" + vault = tmp_path / "vault_legacy" + vault.mkdir() + pf = vault / "paperforge.json" + pf.write_text( + json.dumps({ + "vault_config": { + "system_dir": "NestedSystem", + "resources_dir": "NestedResources", + }, + # Legacy top-level keys — these take precedence per CONF-04 + "system_dir": "LegacySystem", + "resources_dir": "LegacyResources", + "literature_dir": "LegacyLiterature", + "control_dir": "LegacyControl", + "base_dir": "LegacyBases", + }, ensure_ascii=False), + encoding="utf-8", + ) + return vault + + +@pytest.fixture +def populated_vault(tmp_path: Path) -> Path: + """A vault with realistic directory structure.""" + vault = tmp_path / "vault_populated" + vault.mkdir() + system = vault / "99_System" + system.mkdir() + pf_dir = system / "PaperForge" + pf_dir.mkdir(parents=True) + (pf_dir / "exports").mkdir() + (pf_dir / "ocr").mkdir() + resources = vault / "03_Resources" + resources.mkdir() + literature = resources / "Literature" + literature.mkdir(parents=True) + control = resources / "LiteratureControl" + control.mkdir(parents=True) + control_records = control / "library-records" + control_records.mkdir(parents=True) + bases = vault / "05_Bases" + bases.mkdir() + skills = vault / ".opencode" / "skills" + skills.mkdir(parents=True) + command = vault / ".opencode" / "command" + command.mkdir(parents=True) + return vault + + +@pytest.fixture +def env_dict() -> dict[str, str]: + """Empty env dict for tests that control env injection.""" + return {} + + +# --------------------------------------------------------------------------- +# DEFAULT_CONFIG tests — truths from must_haves +# --------------------------------------------------------------------------- + +def test_default_system_dir_is_99_System(): + """Built-in default for system_dir must be '99_System'.""" + from paperforge_lite.config import DEFAULT_CONFIG + assert DEFAULT_CONFIG["system_dir"] == "99_System" + + +def test_default_resources_dir_is_03_Resources(): + """Built-in default for resources_dir must be '03_Resources'.""" + from paperforge_lite.config import DEFAULT_CONFIG + assert DEFAULT_CONFIG["resources_dir"] == "03_Resources" + + +def test_default_literature_dir(): + from paperforge_lite.config import DEFAULT_CONFIG + assert DEFAULT_CONFIG["literature_dir"] == "Literature" + + +def test_default_control_dir(): + from paperforge_lite.config import DEFAULT_CONFIG + assert DEFAULT_CONFIG["control_dir"] == "LiteratureControl" + + +def test_default_base_dir(): + from paperforge_lite.config import DEFAULT_CONFIG + assert DEFAULT_CONFIG["base_dir"] == "05_Bases" + + +def test_default_skill_dir(): + from paperforge_lite.config import DEFAULT_CONFIG + assert DEFAULT_CONFIG["skill_dir"] == ".opencode/skills" + + +def test_default_command_dir(): + from paperforge_lite.config import DEFAULT_CONFIG + assert DEFAULT_CONFIG["command_dir"] == ".opencode/command" + + +# --------------------------------------------------------------------------- +# ENV_KEYS coverage — CONF-01 +# --------------------------------------------------------------------------- + +def test_env_keys_has_all_required_overrides(): + """All PAPERFORGE_* env vars must be registered in ENV_KEYS.""" + from paperforge_lite.config import ENV_KEYS + required = { + "PAPERFORGE_VAULT", + "PAPERFORGE_SYSTEM_DIR", + "PAPERFORGE_RESOURCES_DIR", + "PAPERFORGE_LITERATURE_DIR", + "PAPERFORGE_CONTROL_DIR", + "PAPERFORGE_BASE_DIR", + "PAPERFORGE_SKILL_DIR", + "PAPERFORGE_COMMAND_DIR", + } + env_values = set(ENV_KEYS.values()) + assert required.issubset(env_values), f"Missing env keys: {required - env_values}" + + +# --------------------------------------------------------------------------- +# load_vault_config precedence — CONF-01, CONF-04 +# --------------------------------------------------------------------------- + +def test_env_overrides_nested_json(env_dict): + """PAPERFORGE_SYSTEM_DIR overrides nested vault_config.system_dir (CONF-01).""" + from paperforge_lite.config import load_vault_config + + # Create a vault with nested config + vault = Path("test_vault_env_override") + vault.mkdir(exist_ok=True) + (vault / "paperforge.json").write_text( + json.dumps({"vault_config": {"system_dir": "NestedSystem"}}), + encoding="utf-8", + ) + + # Env overrides JSON + env_dict["PAPERFORGE_SYSTEM_DIR"] = "EnvSystem" + cfg = load_vault_config(vault, env=env_dict) + + assert cfg["system_dir"] == "EnvSystem", ( + f"Expected 'EnvSystem' from PAPERFORGE_SYSTEM_DIR, got '{cfg['system_dir']}'" + ) + + # Cleanup + import shutil + shutil.rmtree(vault, ignore_errors=True) + + +def test_explicit_overrides_win_over_env(env_dict): + """Explicit overrides passed to load_vault_config win over env vars (CONF-01).""" + from paperforge_lite.config import load_vault_config + + vault = Path("test_vault_explicit") + vault.mkdir(exist_ok=True) + (vault / "paperforge.json").write_text("{}", encoding="utf-8") + + env_dict["PAPERFORGE_SYSTEM_DIR"] = "EnvSystem" + overrides = {"system_dir": "OverrideSystem"} + cfg = load_vault_config(vault, env=env_dict, overrides=overrides) + + assert cfg["system_dir"] == "OverrideSystem", ( + f"Expected 'OverrideSystem' from explicit override, got '{cfg['system_dir']}'" + ) + + import shutil + shutil.rmtree(vault, ignore_errors=True) + + +def test_nested_vault_config_is_honored(tmp_path: Path): + """Nested vault_config keys are honored when present (CONF-04).""" + from paperforge_lite.config import load_vault_config + + vault = tmp_path / "vault_nested" + vault.mkdir() + (vault / "paperforge.json").write_text( + json.dumps({"vault_config": {"system_dir": "NestedSystem"}}), + encoding="utf-8", + ) + + cfg = load_vault_config(vault) + assert cfg["system_dir"] == "NestedSystem" + + +def test_top_level_keys_override_nested_for_backward_compat(tmp_path: Path): + """Top-level paperforge.json keys override nested vault_config (CONF-04 backward compat).""" + from paperforge_lite.config import load_vault_config + + vault = tmp_path / "vault_legacy" + vault.mkdir() + (vault / "paperforge.json").write_text( + json.dumps({ + "vault_config": {"system_dir": "NestedSystem"}, + "system_dir": "LegacySystem", + }), + encoding="utf-8", + ) + + cfg = load_vault_config(vault) + assert cfg["system_dir"] == "LegacySystem", ( + f"Expected 'LegacySystem' from top-level key, got '{cfg['system_dir']}'" + ) + + +def test_defaults_used_when_no_json(tmp_path: Path): + """When no paperforge.json exists, defaults are returned.""" + from paperforge_lite.config import load_vault_config + + vault = tmp_path / "vault_empty" + vault.mkdir() + + cfg = load_vault_config(vault) + assert cfg["system_dir"] == "99_System" + assert cfg["resources_dir"] == "03_Resources" + + +# --------------------------------------------------------------------------- +# paperforge_paths key inventory — CONF-02, CONF-03 +# --------------------------------------------------------------------------- + +def test_paperforge_paths_returns_exact_keys(tmp_path: Path): + """paperforge_paths() must return exactly the required user-facing keys.""" + from paperforge_lite.config import paperforge_paths + + vault = tmp_path / "vault_paths" + vault.mkdir() + + # Create required directory structure + (vault / "99_System" / "PaperForge" / "exports").mkdir(parents=True) + (vault / "99_System" / "PaperForge" / "ocr").mkdir(parents=True) + (vault / "03_Resources" / "Literature").mkdir(parents=True) + (vault / "03_Resources" / "LiteratureControl" / "library-records").mkdir(parents=True) + (vault / "05_Bases").mkdir(parents=True) + (vault / ".opencode" / "skills").mkdir(parents=True) + (vault / ".opencode" / "command").mkdir(parents=True) + + paths = paperforge_paths(vault) + + required_keys = { + "vault", + "system", + "paperforge", + "exports", + "ocr", + "resources", + "literature", + "control", + "library_records", + "bases", + "worker_script", + "skill_dir", + "ld_deep_script", + } + + actual_keys = set(paths.keys()) + missing = required_keys - actual_keys + extra = actual_keys - required_keys + + assert not missing, f"Missing required path keys: {sorted(missing)}" + assert not extra, f"Extra path keys not in spec: {sorted(extra)}" + + +def test_paperforge_paths_values_are_absolute(tmp_path: Path): + """All path values returned must be absolute Path objects.""" + from paperforge_lite.config import paperforge_paths + + vault = tmp_path / "vault_absolute" + vault.mkdir() + (vault / "99_System" / "PaperForge" / "exports").mkdir(parents=True) + (vault / "99_System" / "PaperForge" / "ocr").mkdir(parents=True) + (vault / "03_Resources" / "Literature").mkdir(parents=True) + (vault / "03_Resources" / "LiteratureControl" / "library-records").mkdir(parents=True) + (vault / "05_Bases").mkdir(parents=True) + (vault / ".opencode" / "skills").mkdir(parents=True) + (vault / ".opencode" / "command").mkdir(parents=True) + + paths = paperforge_paths(vault) + for name, path in paths.items(): + assert path.is_absolute(), f"Path '{name}' is not absolute: {path}" + + +def test_paperforge_paths_includes_worker_script(tmp_path: Path): + """worker_script key must point to literature_pipeline.py.""" + from paperforge_lite.config import paperforge_paths + + vault = tmp_path / "vault_ws" + vault.mkdir() + (vault / "99_System" / "PaperForge" / "exports").mkdir(parents=True) + (vault / "99_System" / "PaperForge" / "ocr").mkdir(parents=True) + (vault / "03_Resources" / "Literature").mkdir(parents=True) + (vault / "03_Resources" / "LiteratureControl" / "library-records").mkdir(parents=True) + (vault / "05_Bases").mkdir(parents=True) + (vault / ".opencode" / "skills").mkdir(parents=True) + (vault / ".opencode" / "command").mkdir(parents=True) + + paths = paperforge_paths(vault) + assert "worker_script" in paths + assert paths["worker_script"].name == "literature_pipeline.py" + + +def test_paperforge_paths_includes_ld_deep_script(tmp_path: Path): + """ld_deep_script key must point to ld_deep.py.""" + from paperforge_lite.config import paperforge_paths + + vault = tmp_path / "vault_ld" + vault.mkdir() + (vault / "99_System" / "PaperForge" / "exports").mkdir(parents=True) + (vault / "99_System" / "PaperForge" / "ocr").mkdir(parents=True) + (vault / "03_Resources" / "Literature").mkdir(parents=True) + (vault / "03_Resources" / "LiteratureControl" / "library-records").mkdir(parents=True) + (vault / "05_Bases").mkdir(parents=True) + (vault / ".opencode" / "skills").mkdir(parents=True) + (vault / ".opencode" / "command").mkdir(parents=True) + + paths = paperforge_paths(vault) + assert "ld_deep_script" in paths + assert paths["ld_deep_script"].name == "ld_deep.py" + + +# --------------------------------------------------------------------------- +# paths_as_strings — JSON serializable output +# --------------------------------------------------------------------------- + +def test_paths_as_strings_returns_string_values(): + """paths_as_strings must return dict[str, str] with all values as strings.""" + from paperforge_lite.config import paths_as_strings + from pathlib import Path + + paths = { + "vault": Path("/some/vault"), + "system": Path("/some/vault/99_System"), + "paperforge": Path("/some/vault/99_System/PaperForge"), + "exports": Path("/some/vault/99_System/PaperForge/exports"), + "ocr": Path("/some/vault/99_System/PaperForge/ocr"), + "resources": Path("/some/vault/03_Resources"), + "literature": Path("/some/vault/03_Resources/Literature"), + "control": Path("/some/vault/03_Resources/LiteratureControl"), + "library_records": Path("/some/vault/03_Resources/LiteratureControl/library-records"), + "bases": Path("/some/vault/05_Bases"), + "worker_script": Path("/some/vault/99_System/PaperForge/worker/scripts/literature_pipeline.py"), + "skill_dir": Path("/some/vault/.opencode/skills"), + "ld_deep_script": Path("/some/vault/.opencode/skills/literature-qa/scripts/ld_deep.py"), + } + + result = paths_as_strings(paths) + + assert isinstance(result, dict) + for key, value in result.items(): + assert isinstance(value, str), f"Expected str for '{key}', got {type(value).__name__}" + + # Verify JSON serializable + json.dumps(result) + + +# --------------------------------------------------------------------------- +# resolve_vault precedence +# --------------------------------------------------------------------------- + +def test_resolve_vault_precedence_explicit_first(tmp_path: Path): + """resolve_vault returns explicit cli_vault first.""" + from paperforge_lite.config import resolve_vault + + vault_a = tmp_path / "vault_a" + vault_b = tmp_path / "vault_b" + vault_a.mkdir() + vault_b.mkdir() + + result = resolve_vault(cli_vault=vault_a, env={}, cwd=vault_b) + assert result == vault_a + + +def test_resolve_vault_precedence_env_second(tmp_path: Path): + """resolve_vault returns PAPERFORGE_VAULT when no explicit cli_vault.""" + from paperforge_lite.config import resolve_vault + + vault = tmp_path / "vault_env" + vault.mkdir() + + result = resolve_vault(cli_vault=None, env={"PAPERFORGE_VAULT": str(vault)}, cwd=tmp_path) + assert result == vault + + +def test_resolve_vault_precedence_json_search_third(tmp_path: Path): + """resolve_vault falls back to scanning cwd for paperforge.json.""" + from paperforge_lite.config import resolve_vault + + vault = tmp_path / "vault_json" + vault.mkdir() + (vault / "paperforge.json").write_text("{}", encoding="utf-8") + + result = resolve_vault(cli_vault=None, env={}, cwd=vault) + assert result == vault + + +def test_resolve_vault_precedence_cwd_last(tmp_path: Path): + """resolve_vault falls back to cwd when no explicit, env, or json.""" + from paperforge_lite.config import resolve_vault + + result = resolve_vault(cli_vault=None, env={}, cwd=tmp_path) + assert result == tmp_path