2026-04-24 08:15:30 +00:00
|
|
|
"""Tests for paperforge.config resolver contract.
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
"""
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Fixtures
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
@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(
|
2026-04-27 11:49:36 +00:00
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"vault_config": {
|
|
|
|
|
"system_dir": "CustomSystem",
|
|
|
|
|
"resources_dir": "CustomResources",
|
|
|
|
|
"literature_dir": "CustomLiterature",
|
|
|
|
|
"control_dir": "CustomControl",
|
|
|
|
|
"base_dir": "CustomBases",
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
),
|
2026-04-23 03:35:52 +00:00
|
|
|
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(
|
2026-04-27 11:49:36 +00:00
|
|
|
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",
|
2026-04-23 03:35:52 +00:00
|
|
|
},
|
2026-04-27 11:49:36 +00:00
|
|
|
ensure_ascii=False,
|
|
|
|
|
),
|
2026-04-23 03:35:52 +00:00
|
|
|
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
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-05-07 11:00:04 +00:00
|
|
|
def test_default_system_dir_is_System():
|
|
|
|
|
"""Built-in default for system_dir must be 'System'."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import DEFAULT_CONFIG
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-05-07 11:00:04 +00:00
|
|
|
assert DEFAULT_CONFIG["system_dir"] == "System"
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
|
2026-05-07 11:00:04 +00:00
|
|
|
def test_default_resources_dir_is_Resources():
|
|
|
|
|
"""Built-in default for resources_dir must be 'Resources'."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import DEFAULT_CONFIG
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-05-07 11:00:04 +00:00
|
|
|
assert DEFAULT_CONFIG["resources_dir"] == "Resources"
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_literature_dir():
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import DEFAULT_CONFIG
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
assert DEFAULT_CONFIG["literature_dir"] == "Literature"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_control_dir():
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import DEFAULT_CONFIG
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
assert DEFAULT_CONFIG["control_dir"] == "LiteratureControl"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_base_dir():
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import DEFAULT_CONFIG
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-05-07 11:00:04 +00:00
|
|
|
assert DEFAULT_CONFIG["base_dir"] == "Bases"
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_skill_dir():
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import DEFAULT_CONFIG
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
assert DEFAULT_CONFIG["skill_dir"] == ".opencode/skills"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_command_dir():
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import DEFAULT_CONFIG
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
assert DEFAULT_CONFIG["command_dir"] == ".opencode/command"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# ENV_KEYS coverage — CONF-01
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
def test_env_keys_has_all_required_overrides():
|
|
|
|
|
"""All PAPERFORGE_* env vars must be registered in ENV_KEYS."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import ENV_KEYS
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
required = {
|
|
|
|
|
"PAPERFORGE_VAULT",
|
|
|
|
|
"PAPERFORGE_SYSTEM_DIR",
|
|
|
|
|
"PAPERFORGE_RESOURCES_DIR",
|
2026-05-07 11:00:04 +00:00
|
|
|
"PAPERFORGE_LITERATURE_DIR",
|
2026-04-23 03:35:52 +00:00
|
|
|
"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
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
def test_env_overrides_nested_json(env_dict):
|
|
|
|
|
"""PAPERFORGE_SYSTEM_DIR overrides nested vault_config.system_dir (CONF-01)."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import load_vault_config
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
assert (
|
|
|
|
|
cfg["system_dir"] == "EnvSystem"
|
|
|
|
|
), f"Expected 'EnvSystem' from PAPERFORGE_SYSTEM_DIR, got '{cfg['system_dir']}'"
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
# Cleanup
|
|
|
|
|
import shutil
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
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)."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import load_vault_config
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
assert (
|
|
|
|
|
cfg["system_dir"] == "OverrideSystem"
|
|
|
|
|
), f"Expected 'OverrideSystem' from explicit override, got '{cfg['system_dir']}'"
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
import shutil
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
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)."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import load_vault_config
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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)."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import load_vault_config
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
vault = tmp_path / "vault_legacy"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
(vault / "paperforge.json").write_text(
|
2026-04-27 11:49:36 +00:00
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"vault_config": {"system_dir": "NestedSystem"},
|
|
|
|
|
"system_dir": "LegacySystem",
|
|
|
|
|
}
|
|
|
|
|
),
|
2026-04-23 03:35:52 +00:00
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
cfg = load_vault_config(vault)
|
2026-04-27 11:49:36 +00:00
|
|
|
assert cfg["system_dir"] == "LegacySystem", f"Expected 'LegacySystem' from top-level key, got '{cfg['system_dir']}'"
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_defaults_used_when_no_json(tmp_path: Path):
|
|
|
|
|
"""When no paperforge.json exists, defaults are returned."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import load_vault_config
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
vault = tmp_path / "vault_empty"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
cfg = load_vault_config(vault)
|
2026-05-07 11:00:04 +00:00
|
|
|
assert cfg["system_dir"] == "System"
|
|
|
|
|
assert cfg["resources_dir"] == "Resources"
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# paperforge_paths key inventory — CONF-02, CONF-03
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
def test_paperforge_paths_returns_exact_keys(tmp_path: Path):
|
|
|
|
|
"""paperforge_paths() must return exactly the required user-facing keys."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import paperforge_paths
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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",
|
2026-04-27 13:12:29 +00:00
|
|
|
"zotero_dir",
|
2026-04-23 03:35:52 +00:00
|
|
|
"resources",
|
|
|
|
|
"literature",
|
|
|
|
|
"control",
|
|
|
|
|
"library_records",
|
|
|
|
|
"bases",
|
|
|
|
|
"worker_script",
|
|
|
|
|
"skill_dir",
|
2026-05-16 14:38:43 +00:00
|
|
|
"pf_deep_script",
|
2026-05-09 14:46:56 +00:00
|
|
|
# ── v2.2: canonical locations below paperforge/ ──
|
|
|
|
|
"config",
|
|
|
|
|
"index",
|
2026-04-23 03:35:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import paperforge_paths
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import paperforge_paths
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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
|
2026-04-24 16:02:53 +00:00
|
|
|
assert paths["worker_script"].name == "__init__.py"
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
|
2026-05-16 14:38:43 +00:00
|
|
|
def test_paperforge_paths_includes_pf_deep_script(tmp_path: Path):
|
|
|
|
|
"""pf_deep_script key must point to pf_deep.py."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import paperforge_paths
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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)
|
2026-05-16 14:38:43 +00:00
|
|
|
assert "pf_deep_script" in paths
|
|
|
|
|
assert paths["pf_deep_script"].name == "pf_deep.py"
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# paths_as_strings — JSON serializable output
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
def test_paths_as_strings_returns_string_values():
|
|
|
|
|
"""paths_as_strings must return dict[str, str] with all values as strings."""
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
from paperforge.config import paths_as_strings
|
|
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
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"),
|
2026-05-16 14:38:43 +00:00
|
|
|
"pf_deep_script": Path("/some/vault/.opencode/skills/paperforge/scripts/pf_deep.py"),
|
2026-04-23 03:35:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-27 11:49:36 +00:00
|
|
|
|
2026-04-23 03:35:52 +00:00
|
|
|
def test_resolve_vault_precedence_explicit_first(tmp_path: Path):
|
|
|
|
|
"""resolve_vault returns explicit cli_vault first."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import resolve_vault
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import resolve_vault
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import resolve_vault
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
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."""
|
2026-04-24 08:15:30 +00:00
|
|
|
from paperforge.config import resolve_vault
|
2026-04-23 03:35:52 +00:00
|
|
|
|
|
|
|
|
result = resolve_vault(cli_vault=None, env={}, cwd=tmp_path)
|
|
|
|
|
assert result == tmp_path
|
2026-05-03 14:37:56 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# schema_version — CONF-01
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_config_has_schema_version():
|
|
|
|
|
"""DEFAULT_CONFIG must contain schema_version key with value '2'."""
|
|
|
|
|
from paperforge.config import DEFAULT_CONFIG
|
|
|
|
|
|
|
|
|
|
assert "schema_version" in DEFAULT_CONFIG
|
|
|
|
|
assert DEFAULT_CONFIG["schema_version"] == "2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_keys_includes_schema_version():
|
|
|
|
|
"""CONFIG_KEYS must include schema_version (auto-derived from DEFAULT_CONFIG)."""
|
|
|
|
|
from paperforge.config import CONFIG_KEYS
|
|
|
|
|
|
|
|
|
|
assert "schema_version" in CONFIG_KEYS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_paperforge_schema_version_defaults_to_1(tmp_path: Path):
|
|
|
|
|
"""get_paperforge_schema_version returns 1 when no schema_version in paperforge.json."""
|
|
|
|
|
from paperforge.config import get_paperforge_schema_version
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "no_schema"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
(vault / "paperforge.json").write_text("{}", encoding="utf-8")
|
|
|
|
|
assert get_paperforge_schema_version(vault) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_paperforge_schema_version_reads_2(tmp_path: Path):
|
|
|
|
|
"""get_paperforge_schema_version returns 2 when schema_version is '2'."""
|
|
|
|
|
import json
|
2026-06-04 16:04:51 +00:00
|
|
|
|
2026-05-03 14:37:56 +00:00
|
|
|
from paperforge.config import get_paperforge_schema_version
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "schema_v2"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
(vault / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({"schema_version": "2"}), encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
assert get_paperforge_schema_version(vault) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_vault_config_excludes_schema_version(tmp_path: Path):
|
|
|
|
|
"""load_vault_config output must NOT contain schema_version key."""
|
|
|
|
|
from paperforge.config import load_vault_config
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "exclude_schema"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
(vault / "paperforge.json").write_text(
|
|
|
|
|
'{"schema_version": "2"}', encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
cfg = load_vault_config(vault)
|
|
|
|
|
assert "schema_version" not in cfg
|
2026-05-03 14:39:45 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# migrate_paperforge_json — CONF-02 legacy migration
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migrate_legacy_top_level_keys(tmp_path: Path):
|
|
|
|
|
"""Legacy top-level path keys are migrated to vault_config block."""
|
|
|
|
|
import json
|
2026-06-04 16:04:51 +00:00
|
|
|
|
2026-05-03 14:39:45 +00:00
|
|
|
from paperforge.config import migrate_paperforge_json
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "legacy_migrate"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
data = {
|
|
|
|
|
"system_dir": "OldSystem",
|
2026-05-03 14:41:13 +00:00
|
|
|
"literature_dir": "OldLit",
|
2026-05-03 14:39:45 +00:00
|
|
|
"version": "1.4",
|
|
|
|
|
"vault_config": {
|
|
|
|
|
"system_dir": "99_System",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
(vault / "paperforge.json").write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
assert migrate_paperforge_json(vault) is True
|
|
|
|
|
|
|
|
|
|
result = json.loads((vault / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert "system_dir" not in result, "top-level key removed"
|
2026-05-03 14:41:13 +00:00
|
|
|
assert "literature_dir" not in result, "top-level key removed"
|
|
|
|
|
# system_dir already exists in vault_config — existing value preserved
|
|
|
|
|
assert result["vault_config"]["system_dir"] == "99_System", "existing vault_config preserved"
|
|
|
|
|
# literature_dir is only at top-level (gap in vault_config) — filled from top-level
|
|
|
|
|
assert result["vault_config"]["literature_dir"] == "OldLit", "top-level fills vault_config gap"
|
2026-05-03 14:39:45 +00:00
|
|
|
assert result["schema_version"] == "2"
|
|
|
|
|
assert (vault / "paperforge.json.bak").exists(), "backup created"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migrate_idempotent(tmp_path: Path):
|
|
|
|
|
"""Already-migrated files return False (no-op)."""
|
|
|
|
|
import json
|
2026-06-04 16:04:51 +00:00
|
|
|
|
2026-05-03 14:39:45 +00:00
|
|
|
from paperforge.config import migrate_paperforge_json
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "already_migrated"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
(vault / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({"vault_config": {"system_dir": "99_System"}, "schema_version": "2"}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert migrate_paperforge_json(vault) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migrate_no_file(tmp_path: Path):
|
|
|
|
|
"""No paperforge.json returns False."""
|
|
|
|
|
from paperforge.config import migrate_paperforge_json
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "no_file"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
assert migrate_paperforge_json(vault) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migrate_non_path_keys_survive(tmp_path: Path):
|
|
|
|
|
"""Non-path top-level keys survive migration in output root."""
|
|
|
|
|
import json
|
2026-06-04 16:04:51 +00:00
|
|
|
|
2026-05-03 14:39:45 +00:00
|
|
|
from paperforge.config import migrate_paperforge_json
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "non_path_survive"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
data = {
|
|
|
|
|
"system_dir": "OldSystem",
|
|
|
|
|
"version": "1.4",
|
|
|
|
|
"agent_platform": "opencode",
|
|
|
|
|
"vault_config": {},
|
|
|
|
|
}
|
|
|
|
|
(vault / "paperforge.json").write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
migrate_paperforge_json(vault)
|
|
|
|
|
|
|
|
|
|
result = json.loads((vault / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert "version" in result
|
|
|
|
|
assert result["version"] == "1.4"
|
|
|
|
|
assert "agent_platform" in result
|
|
|
|
|
assert result["agent_platform"] == "opencode"
|
|
|
|
|
assert result["vault_config"]["system_dir"] == "OldSystem"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migrate_no_vault_config_block_creates_it(tmp_path: Path):
|
|
|
|
|
"""Top-level keys without vault_config block create a new vault_config."""
|
|
|
|
|
import json
|
2026-06-04 16:04:51 +00:00
|
|
|
|
2026-05-03 14:39:45 +00:00
|
|
|
from paperforge.config import migrate_paperforge_json
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "creates_vc"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
data = {
|
|
|
|
|
"system_dir": "OldSystem",
|
|
|
|
|
"resources_dir": "OldRes",
|
|
|
|
|
"literature_dir": "OldLit",
|
|
|
|
|
"version": "1.4",
|
|
|
|
|
}
|
|
|
|
|
(vault / "paperforge.json").write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
migrate_paperforge_json(vault)
|
|
|
|
|
|
|
|
|
|
result = json.loads((vault / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert "vault_config" in result
|
|
|
|
|
assert result["vault_config"]["system_dir"] == "OldSystem"
|
|
|
|
|
assert result["vault_config"]["resources_dir"] == "OldRes"
|
|
|
|
|
assert result["vault_config"]["literature_dir"] == "OldLit"
|
|
|
|
|
assert result["schema_version"] == "2"
|