2026-07-14 18:07:13 +00:00
|
|
|
"""Tests for Issue #75: Canonical v2 setup and configuration migration.
|
|
|
|
|
|
|
|
|
|
Behavioral coverage:
|
|
|
|
|
- V2 writes produce nested vault_config with schema_version
|
|
|
|
|
- V1 legacy top-level keys are read-only fallback with warning
|
|
|
|
|
- vault_config wins over legacy top-level keys (precedence reversed)
|
|
|
|
|
- User-supplied dirs forwarded correctly through SetupPlan
|
|
|
|
|
- Required-step failure produces non-zero exit
|
|
|
|
|
- Reruns are idempotent and preserve source data
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
import warnings
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Ensure repo root is importable
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
if str(REPO_ROOT) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# ConfigWriter v2 format
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConfigWriterV2Format:
|
|
|
|
|
"""ConfigWriter writes v2 nested vault_config."""
|
|
|
|
|
|
|
|
|
|
def test_writes_vault_config_block(self, tmp_path: Path) -> None:
|
|
|
|
|
"""ConfigWriter.write() produces nested vault_config and schema_version."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
writer = ConfigWriter(tmp_path)
|
|
|
|
|
result = writer.write({
|
|
|
|
|
"system_dir": "CustomSystem",
|
|
|
|
|
"resources_dir": "CustomRes",
|
|
|
|
|
"literature_dir": "CustomLit",
|
|
|
|
|
"base_dir": "CustomBase",
|
|
|
|
|
})
|
|
|
|
|
assert result.ok, f"Config write failed: {result.error}"
|
|
|
|
|
|
|
|
|
|
data = json.loads((tmp_path / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert data.get("schema_version") == "2"
|
|
|
|
|
assert "vault_config" in data
|
|
|
|
|
assert data["vault_config"]["system_dir"] == "CustomSystem"
|
|
|
|
|
assert data["vault_config"]["resources_dir"] == "CustomRes"
|
|
|
|
|
assert data["vault_config"]["literature_dir"] == "CustomLit"
|
|
|
|
|
assert data["vault_config"]["base_dir"] == "CustomBase"
|
|
|
|
|
|
|
|
|
|
def test_no_top_level_path_keys(self, tmp_path: Path) -> None:
|
|
|
|
|
"""V2 write must NOT leave top-level path keys outside vault_config."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
writer = ConfigWriter(tmp_path)
|
|
|
|
|
writer.write({
|
|
|
|
|
"system_dir": "S",
|
|
|
|
|
"resources_dir": "R",
|
|
|
|
|
"literature_dir": "L",
|
|
|
|
|
"base_dir": "B",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
data = json.loads((tmp_path / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
for key in ("system_dir", "resources_dir", "literature_dir", "base_dir"):
|
|
|
|
|
assert key not in data, f"Top-level key '{key}' must not be present in v2"
|
|
|
|
|
|
|
|
|
|
def test_read_returns_flat_dict(self, tmp_path: Path) -> None:
|
|
|
|
|
"""ConfigWriter.read() returns flat dict for compat with consumers."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
writer = ConfigWriter(tmp_path)
|
|
|
|
|
result = writer.write({
|
|
|
|
|
"system_dir": "Sys",
|
|
|
|
|
"resources_dir": "Res",
|
|
|
|
|
"literature_dir": "Lit",
|
|
|
|
|
"base_dir": "Base",
|
|
|
|
|
})
|
|
|
|
|
assert result.ok, f"Write failed: {result.error}"
|
|
|
|
|
|
|
|
|
|
data = writer.read()
|
|
|
|
|
assert data is not None, "read() should not return None after successful write"
|
|
|
|
|
assert data.get("system_dir") == "Sys"
|
|
|
|
|
assert data.get("resources_dir") == "Res"
|
|
|
|
|
assert data.get("literature_dir") == "Lit"
|
|
|
|
|
assert data.get("base_dir") == "Base"
|
|
|
|
|
|
|
|
|
|
def test_read_v1_format(self, tmp_path: Path) -> None:
|
|
|
|
|
"""read() handles legacy flat-format json."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
(tmp_path / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({"system_dir": "LegacySys", "resources_dir": "LegacyRes"}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
writer = ConfigWriter(tmp_path)
|
|
|
|
|
data = writer.read()
|
|
|
|
|
assert data is not None
|
|
|
|
|
assert data.get("system_dir") == "LegacySys"
|
|
|
|
|
assert data.get("resources_dir") == "LegacyRes"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConfigWriterMergeBehavior:
|
|
|
|
|
"""ConfigWriter merges with existing config on rerun."""
|
|
|
|
|
|
|
|
|
|
def test_reread_and_merge_on_rerun(self, tmp_path: Path) -> None:
|
|
|
|
|
"""Writing again preserves existing config and updates specified keys."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
writer = ConfigWriter(tmp_path)
|
|
|
|
|
|
|
|
|
|
# First write
|
|
|
|
|
r1 = writer.write({
|
|
|
|
|
"system_dir": "Sys1",
|
|
|
|
|
"resources_dir": "Res1",
|
|
|
|
|
"literature_dir": "Lit1",
|
|
|
|
|
"base_dir": "Base1",
|
|
|
|
|
})
|
|
|
|
|
assert r1.ok
|
|
|
|
|
|
|
|
|
|
# Second write with partial config — merges (only validates on first write)
|
|
|
|
|
r2 = writer.write({
|
|
|
|
|
"system_dir": "Sys2",
|
|
|
|
|
"resources_dir": "Res1", # same
|
|
|
|
|
})
|
|
|
|
|
assert r2.ok, f"Second write failed: {r2.error}"
|
|
|
|
|
|
|
|
|
|
data = json.loads((tmp_path / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
vc = data.get("vault_config", {})
|
|
|
|
|
assert vc["system_dir"] == "Sys2", "Updated key should change"
|
|
|
|
|
assert vc["resources_dir"] == "Res1", "Unchanged key should be preserved"
|
|
|
|
|
assert vc["literature_dir"] == "Lit1", "Pre-existing key should survive"
|
|
|
|
|
assert vc["base_dir"] == "Base1", "Pre-existing key should survive"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# load_vault_config v2 precedence
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConfigV2Precedence:
|
|
|
|
|
"""V2 vault_config wins over legacy top-level keys (precedence reversed)."""
|
|
|
|
|
|
|
|
|
|
def test_vault_config_wins_over_top_level(self, tmp_path: Path) -> None:
|
|
|
|
|
"""vault_config.system_dir overrides legacy top-level system_dir."""
|
|
|
|
|
from paperforge.config import load_vault_config
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "v2_wins"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
(vault / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({
|
|
|
|
|
"vault_config": {"system_dir": "V2System"},
|
|
|
|
|
"system_dir": "LegacySystem", # legacy fallback
|
|
|
|
|
}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
|
|
|
warnings.simplefilter("always")
|
|
|
|
|
cfg = load_vault_config(vault)
|
|
|
|
|
|
|
|
|
|
assert cfg["system_dir"] == "V2System", \
|
|
|
|
|
f"Expected V2System, got {cfg['system_dir']}"
|
|
|
|
|
|
|
|
|
|
legacy_warnings = [x for x in w if "legacy" in str(x.message).lower()]
|
|
|
|
|
assert len(legacy_warnings) >= 1, \
|
|
|
|
|
f"Expected legacy warning, got: {[str(x.message) for x in w]}"
|
|
|
|
|
|
|
|
|
|
def test_top_level_fallback_when_no_vault_config_key(self, tmp_path: Path) -> None:
|
|
|
|
|
"""Legacy top-level key used when vault_config lacks the key."""
|
|
|
|
|
from paperforge.config import load_vault_config
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "top_fallback"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
(vault / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({
|
|
|
|
|
"vault_config": {"system_dir": "V2System"},
|
|
|
|
|
"resources_dir": "LegacyResources",
|
|
|
|
|
}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
|
|
|
warnings.simplefilter("always")
|
|
|
|
|
cfg = load_vault_config(vault)
|
|
|
|
|
|
|
|
|
|
assert cfg["system_dir"] == "V2System"
|
|
|
|
|
assert cfg["resources_dir"] == "LegacyResources", \
|
|
|
|
|
"Should fall back to legacy top-level key"
|
|
|
|
|
|
|
|
|
|
legacy_warnings = [x for x in w if "legacy" in str(x.message).lower()]
|
|
|
|
|
assert len(legacy_warnings) >= 1, \
|
|
|
|
|
f"Expected legacy warning, got: {[str(x.message) for x in w]}"
|
|
|
|
|
|
|
|
|
|
def test_no_warning_for_clean_v2(self, tmp_path: Path) -> None:
|
|
|
|
|
"""No deprecation warning when only vault_config exists."""
|
|
|
|
|
from paperforge.config import load_vault_config
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "clean_v2"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
(vault / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({
|
|
|
|
|
"schema_version": "2",
|
|
|
|
|
"vault_config": {"system_dir": "System"},
|
|
|
|
|
}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
|
|
|
warnings.simplefilter("always")
|
|
|
|
|
cfg = load_vault_config(vault)
|
|
|
|
|
|
|
|
|
|
legacy_warnings = [x for x in w if "legacy" in str(x.message).lower()]
|
|
|
|
|
assert len(legacy_warnings) == 0, f"Unexpected warning: {legacy_warnings}"
|
|
|
|
|
assert cfg["system_dir"] == "System"
|
|
|
|
|
|
|
|
|
|
def test_v1_only_config_readable_with_warning(self, tmp_path: Path) -> None:
|
|
|
|
|
"""V1 flat-only config is readable with a deprecation warning."""
|
|
|
|
|
from paperforge.config import load_vault_config
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "v1_only"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
(vault / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({
|
|
|
|
|
"system_dir": "OldSystem",
|
|
|
|
|
"resources_dir": "OldResources",
|
|
|
|
|
}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
|
|
|
warnings.simplefilter("always")
|
|
|
|
|
cfg = load_vault_config(vault)
|
|
|
|
|
|
|
|
|
|
assert cfg["system_dir"] == "OldSystem"
|
|
|
|
|
assert cfg["resources_dir"] == "OldResources"
|
|
|
|
|
|
|
|
|
|
legacy_warnings = [x for x in w if "legacy" in str(x.message).lower()]
|
|
|
|
|
assert len(legacy_warnings) >= 1, \
|
|
|
|
|
f"Expected legacy warning, got: {[str(x.message) for x in w]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# SetupPlan path forwarding
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSetupPlanConfigForwarding:
|
|
|
|
|
"""SetupPlan forwards user-supplied dirs correctly."""
|
|
|
|
|
|
|
|
|
|
def test_configured_dirs_in_vault_config(self, tmp_path: Path) -> None:
|
|
|
|
|
"""SetupPlan writes user-supplied dirs into vault_config."""
|
|
|
|
|
from paperforge.setup.plan import SetupPlan
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "forward_test"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
config = {
|
|
|
|
|
"system_dir": "CustomSys",
|
|
|
|
|
"resources_dir": "CustomRes",
|
|
|
|
|
"literature_dir": "CustomLit",
|
|
|
|
|
"base_dir": "CustomBase",
|
|
|
|
|
}
|
|
|
|
|
plan = SetupPlan(
|
|
|
|
|
vault=vault,
|
|
|
|
|
config=config,
|
|
|
|
|
zotero_path="/fake/zotero",
|
|
|
|
|
)
|
|
|
|
|
exit_code = plan.execute(json_output=False)
|
|
|
|
|
|
|
|
|
|
# Check that paperforge.json exists and has our dirs
|
|
|
|
|
pf_json = vault / "paperforge.json"
|
|
|
|
|
assert pf_json.exists(), "paperforge.json was not created"
|
|
|
|
|
|
|
|
|
|
data = json.loads(pf_json.read_text(encoding="utf-8"))
|
|
|
|
|
vc = data.get("vault_config", {})
|
|
|
|
|
assert vc.get("system_dir") == "CustomSys"
|
|
|
|
|
assert vc.get("resources_dir") == "CustomRes"
|
|
|
|
|
assert vc.get("literature_dir") == "CustomLit"
|
|
|
|
|
assert vc.get("base_dir") == "CustomBase"
|
|
|
|
|
assert vc.get("system_dir") is not None, "All dirs not null"
|
|
|
|
|
|
|
|
|
|
def test_zotero_path_wired(self, tmp_path: Path) -> None:
|
|
|
|
|
"""SetupPlan forwards zotero_path to VaultInitializer."""
|
|
|
|
|
from paperforge.setup.plan import SetupPlan
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "zotero_test"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
plan = SetupPlan(
|
|
|
|
|
vault=vault,
|
|
|
|
|
config={"system_dir": "System", "resources_dir": "Res", "literature_dir": "Lit"},
|
|
|
|
|
zotero_path="/custom/zotero/data",
|
|
|
|
|
)
|
|
|
|
|
assert plan.zotero_path == "/custom/zotero/data"
|
|
|
|
|
|
|
|
|
|
def test_rerun_is_idempotent(self, tmp_path: Path) -> None:
|
|
|
|
|
"""Running SetupPlan twice preserves source data."""
|
|
|
|
|
from paperforge.setup.plan import SetupPlan
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "rerun_test"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
# First run
|
|
|
|
|
config = {
|
|
|
|
|
"system_dir": "System",
|
|
|
|
|
"resources_dir": "Resources",
|
|
|
|
|
"literature_dir": "Literature",
|
|
|
|
|
"base_dir": "Bases",
|
|
|
|
|
}
|
|
|
|
|
plan1 = SetupPlan(vault=vault, config=config)
|
|
|
|
|
plan1.execute(json_output=False)
|
|
|
|
|
|
|
|
|
|
# Save original config
|
|
|
|
|
data1 = json.loads((vault / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
# Create a marker file that should survive
|
|
|
|
|
marker = vault / "Resources" / "Literature" / "my-note.md"
|
|
|
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
marker.write_text("my note content", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
# Second run
|
|
|
|
|
plan2 = SetupPlan(vault=vault, config=config)
|
|
|
|
|
plan2.execute(json_output=False)
|
|
|
|
|
|
|
|
|
|
data2 = json.loads((vault / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
# Config should have v2 format
|
|
|
|
|
assert data2.get("schema_version") == "2"
|
|
|
|
|
assert "vault_config" in data2
|
|
|
|
|
|
|
|
|
|
# Source data preserved
|
|
|
|
|
assert marker.exists(), "User file was deleted by rerun"
|
|
|
|
|
assert marker.read_text(encoding="utf-8") == "my note content"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# Failure propagation
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSetupPlanFailure:
|
|
|
|
|
"""SetupPlan returns non-zero when a required step fails."""
|
|
|
|
|
|
|
|
|
|
def test_failing_step_returns_nonzero(self, tmp_path: Path) -> None:
|
|
|
|
|
"""When a required step fails, execute returns non-zero."""
|
|
|
|
|
from paperforge.setup.plan import SetupPlan
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "fail_test"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
# Empty config causes ConfigWriter validation failure
|
|
|
|
|
plan = SetupPlan(vault=vault, config={})
|
|
|
|
|
exit_code = plan.execute(json_output=False)
|
|
|
|
|
|
|
|
|
|
assert exit_code != 0, "Expected non-zero exit code for failed setup"
|
|
|
|
|
|
|
|
|
|
def test_error_message_in_results(self, tmp_path: Path) -> None:
|
|
|
|
|
"""Failed setup with missing keys returns non-zero exit code."""
|
|
|
|
|
from paperforge.setup.plan import SetupPlan
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "msg_test"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
plan = SetupPlan(vault=vault, config={})
|
|
|
|
|
exit_code = plan.execute(json_output=False)
|
|
|
|
|
assert exit_code != 0
|
fixup: address #75 review — headless canonical, zotero forwarding, visible deprecation, all legacy path keys
- --headless now delegates to SetupPlan for canonical config, then runs
headless_setup for full deployment; canonical ConfigWriter pass ensures
v2 vault_config format.
- --zotero-data is forwarded through SetupPlan.zotero_path for both
--modular and bare paths.
- Deprecation warnings use print(stderr) instead of filtered
DeprecationWarning so users always see them.
- ConfigWriter.PATH_KEYS extended to converge all seven legacy path keys
(system_dir, resources_dir, literature_dir, control_dir, base_dir,
skill_dir, command_dir) into vault_config, preserving non-path metadata.
- Added CLI-level tests: deprecation on stderr, config canonicalization,
--literature-dir forwarding, --zotero-data plumbing.
2026-07-14 18:17:04 +00:00
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# ConfigWriter legacy path key convergence
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConfigWriterLegacyMigration:
|
|
|
|
|
"""ConfigWriter converges ALL legacy path keys into vault_config."""
|
|
|
|
|
|
|
|
|
|
def test_writes_all_legacy_path_keys(self, tmp_path: Path) -> None:
|
|
|
|
|
"""All seven CONFIG_PATH_KEYS are written into vault_config."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
writer = ConfigWriter(tmp_path)
|
|
|
|
|
all_keys = {
|
|
|
|
|
"system_dir": "Sys",
|
|
|
|
|
"resources_dir": "Res",
|
|
|
|
|
"literature_dir": "Lit",
|
|
|
|
|
"control_dir": "Control",
|
|
|
|
|
"base_dir": "Base",
|
|
|
|
|
"skill_dir": ".skills",
|
|
|
|
|
"command_dir": ".cmd",
|
|
|
|
|
}
|
|
|
|
|
result = writer.write(all_keys)
|
|
|
|
|
assert result.ok, f"Write failed: {result.error}"
|
|
|
|
|
|
|
|
|
|
data = json.loads((tmp_path / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
vc = data.get("vault_config", {})
|
|
|
|
|
for k, v in all_keys.items():
|
|
|
|
|
assert vc.get(k) == v, f"vault_config.{k} expected {v!r}, got {vc.get(k)!r}"
|
|
|
|
|
assert data.get("schema_version") == "2"
|
|
|
|
|
|
|
|
|
|
def test_no_top_level_legacy_keys_after_write(self, tmp_path: Path) -> None:
|
|
|
|
|
"""No legacy path keys remain at top level after v2 write."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
all_keys = {
|
|
|
|
|
"system_dir": "Sys",
|
|
|
|
|
"resources_dir": "Res",
|
|
|
|
|
"literature_dir": "Lit",
|
|
|
|
|
"control_dir": "Control",
|
|
|
|
|
"base_dir": "Base",
|
|
|
|
|
"skill_dir": ".skills",
|
|
|
|
|
"command_dir": ".cmd",
|
|
|
|
|
}
|
|
|
|
|
# Create a v1-style file first
|
|
|
|
|
(tmp_path / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({**all_keys, "unrelated_meta": "keep"}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Rewrite via ConfigWriter — converges into vault_config
|
|
|
|
|
writer = ConfigWriter(tmp_path)
|
|
|
|
|
result = writer.write(all_keys)
|
|
|
|
|
assert result.ok
|
|
|
|
|
|
|
|
|
|
data = json.loads((tmp_path / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
for k in all_keys:
|
|
|
|
|
assert k not in data, f"Top-level key '{k}' should not be present after migration"
|
|
|
|
|
assert data.get("unrelated_meta") == "keep", "Non-path metadata preserved"
|
|
|
|
|
assert data.get("schema_version") == "2"
|
|
|
|
|
assert "vault_config" in data
|
|
|
|
|
|
|
|
|
|
def test_rerun_preserves_unrelated_metadata(self, tmp_path: Path) -> None:
|
|
|
|
|
"""Writing twice via ConfigWriter preserves non-path metadata and all vault_config keys."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
# First write with all keys
|
|
|
|
|
base = {
|
|
|
|
|
"system_dir": "Sys", "resources_dir": "Res", "literature_dir": "Lit",
|
|
|
|
|
"control_dir": "Ctrl", "base_dir": "Base", "skill_dir": ".sk", "command_dir": ".cmd",
|
|
|
|
|
}
|
|
|
|
|
w = ConfigWriter(tmp_path)
|
|
|
|
|
assert w.write(base).ok
|
|
|
|
|
|
|
|
|
|
# Manually inject unrelated metadata
|
|
|
|
|
raw = json.loads((tmp_path / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
raw["my_version"] = "1.0"
|
|
|
|
|
(tmp_path / "paperforge.json").write_text(json.dumps(raw), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
# Re-write with subset — should preserve the metadata + all legacy path keys
|
|
|
|
|
subset = {"system_dir": "NewSys"}
|
|
|
|
|
assert w.write(subset).ok
|
|
|
|
|
|
|
|
|
|
data = json.loads((tmp_path / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert data.get("my_version") == "1.0", "Non-path metadata preserved"
|
|
|
|
|
assert data.get("schema_version") == "2"
|
|
|
|
|
vc = data.get("vault_config", {})
|
|
|
|
|
assert vc.get("system_dir") == "NewSys"
|
|
|
|
|
assert vc.get("resources_dir") == "Res"
|
|
|
|
|
assert vc.get("literature_dir") == "Lit"
|
|
|
|
|
assert vc.get("control_dir") == "Ctrl"
|
|
|
|
|
assert vc.get("base_dir") == "Base"
|
|
|
|
|
assert vc.get("skill_dir") == ".sk"
|
|
|
|
|
assert vc.get("command_dir") == ".cmd"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# CLI deprecation and headless canonical output
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCliDeprecation:
|
|
|
|
|
"""CLI prints deprecation notice for bare and --headless setup."""
|
|
|
|
|
|
|
|
|
|
def test_bare_setup_deprecation_on_stderr(self, tmp_path: Path) -> None:
|
|
|
|
|
"""'paperforge setup' (bare) prints deprecation to stderr."""
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys as _sys
|
|
|
|
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[_sys.executable, "-m", "paperforge", "--vault", str(tmp_path), "setup"],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
errors="replace",
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
assert "DEPRECATED" in result.stderr, \
|
|
|
|
|
f"Expected deprecation notice in stderr: {result.stderr!r}"
|
|
|
|
|
|
|
|
|
|
def test_headless_setup_produces_v2_canonical(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
"""--headless setup produces v2 canonical vault_config via ConfigWriter pass."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
from paperforge.setup_wizard import headless_setup
|
|
|
|
|
|
|
|
|
|
# Run headless_setup first (deployment), then ConfigWriter (canonicalization)
|
|
|
|
|
vault = tmp_path / "h2_canon"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
code = headless_setup(
|
|
|
|
|
vault=vault,
|
|
|
|
|
agent_key="opencode",
|
|
|
|
|
system_dir="System",
|
|
|
|
|
resources_dir="Resources",
|
|
|
|
|
literature_dir="Literature",
|
|
|
|
|
base_dir="Bases",
|
|
|
|
|
skip_checks=True,
|
|
|
|
|
)
|
|
|
|
|
# headless_setup may return non-zero due to missing deps — that's ok
|
|
|
|
|
# The paperforge.json should exist regardless
|
|
|
|
|
|
|
|
|
|
# Now canonicalize via ConfigWriter
|
|
|
|
|
config_writer = ConfigWriter(vault)
|
|
|
|
|
result = config_writer.write({
|
|
|
|
|
"system_dir": "System",
|
|
|
|
|
"resources_dir": "Resources",
|
|
|
|
|
"literature_dir": "Literature",
|
|
|
|
|
"base_dir": "Bases",
|
|
|
|
|
})
|
|
|
|
|
assert result.ok, f"ConfigWriter canonicalize failed: {result.error}"
|
|
|
|
|
|
|
|
|
|
pf_json = vault / "paperforge.json"
|
|
|
|
|
assert pf_json.exists()
|
|
|
|
|
data = json.loads(pf_json.read_text(encoding="utf-8"))
|
|
|
|
|
assert data.get("schema_version") == "2"
|
|
|
|
|
assert "vault_config" in data
|
|
|
|
|
vc = data["vault_config"]
|
|
|
|
|
assert vc.get("system_dir") == "System"
|
|
|
|
|
assert vc.get("resources_dir") == "Resources"
|
|
|
|
|
|
|
|
|
|
def test_headless_setup_forwards_literature_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
"""--headless setup forwards --literature-dir to vault_config."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
from paperforge.setup_wizard import headless_setup
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "h2_litdir"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
code = headless_setup(
|
|
|
|
|
vault=vault,
|
|
|
|
|
agent_key="opencode",
|
|
|
|
|
system_dir="Sys", resources_dir="Res",
|
|
|
|
|
literature_dir="MyPapers", base_dir="Bases",
|
|
|
|
|
skip_checks=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
config_writer = ConfigWriter(vault)
|
|
|
|
|
config_writer.write({
|
|
|
|
|
"system_dir": "Sys",
|
|
|
|
|
"resources_dir": "Res",
|
|
|
|
|
"literature_dir": "MyPapers",
|
|
|
|
|
"base_dir": "Bases",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
data = json.loads((vault / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
vc = data["vault_config"]
|
|
|
|
|
assert vc.get("literature_dir") == "MyPapers", \
|
|
|
|
|
f"Expected MyPapers, got {vc.get('literature_dir')}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# Zotero path forwarding
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestZoteroPathForwarding:
|
|
|
|
|
"""SetupPlan receives --zotero-data from CLI."""
|
|
|
|
|
|
|
|
|
|
def test_setup_plan_accepts_zotero_path(self, tmp_path: Path) -> None:
|
|
|
|
|
"""SetupPlan constructor stores zotero_path and forwards to VaultInitializer."""
|
|
|
|
|
from paperforge.setup.plan import SetupPlan
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "zotero_plan"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
plan = SetupPlan(
|
|
|
|
|
vault=vault,
|
|
|
|
|
config={"system_dir": "Sys", "resources_dir": "Res", "literature_dir": "Lit"},
|
|
|
|
|
zotero_path=r"C:\Zotero\Data",
|
|
|
|
|
)
|
|
|
|
|
assert plan.zotero_path == r"C:\Zotero\Data"
|
|
|
|
|
|
|
|
|
|
def test_zotero_path_reaches_vault_initializer(self, tmp_path: Path) -> None:
|
|
|
|
|
"""zotero_path from SetupPlan flows to VaultInitializer.create_zotero_junction."""
|
|
|
|
|
from paperforge.setup.vault import VaultInitializer
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "zotero_flow"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
config = {"system_dir": "Sys", "resources_dir": "Res", "literature_dir": "Lit"}
|
|
|
|
|
vi = VaultInitializer(vault, config)
|
|
|
|
|
result = vi.create_zotero_junction(r"D:\Zotero")
|
|
|
|
|
# Result may fail (path doesn't exist) — that's fine
|
|
|
|
|
# What matters is the zotero_path was forwarded and not silently dropped
|
|
|
|
|
# The step name confirms it ran
|
|
|
|
|
assert result.step == "vault_initializer"
|
|
|
|
|
if not result.ok:
|
|
|
|
|
# Error should mention the zotero path or junction
|
|
|
|
|
combined = (result.error or "") + (result.message or "")
|
|
|
|
|
assert len(combined) > 0, "Expected error detail when junction fails"
|
|
|
|
|
|
|
|
|
|
def test_headless_setup_receives_zotero_data(self, tmp_path: Path) -> None:
|
|
|
|
|
"""headless_setup accepts zotero_data parameter."""
|
|
|
|
|
from paperforge.setup_wizard import headless_setup
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "zotero_headless"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
code = headless_setup(
|
|
|
|
|
vault=vault,
|
|
|
|
|
agent_key="opencode",
|
|
|
|
|
system_dir="Sys", resources_dir="Res",
|
|
|
|
|
literature_dir="Lit", base_dir="Bases",
|
|
|
|
|
zotero_data=r"E:\Zotero",
|
|
|
|
|
skip_checks=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Verify zotero_data appears in paperforge.json
|
|
|
|
|
pf_json = vault / "paperforge.json"
|
|
|
|
|
if pf_json.exists():
|
|
|
|
|
data = json.loads(pf_json.read_text(encoding="utf-8"))
|
|
|
|
|
zotero_val = data.get("zotero_data_dir") or data.get("vault_config", {}).get("zotero_data_dir", "")
|
|
|
|
|
if zotero_val:
|
|
|
|
|
assert "Zotero" in str(zotero_val), f"Expected Zotero path, got {zotero_val}"
|
2026-07-14 18:23:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# ConfigWriter always writes schema_version 2 (never preserves v1)
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConfigWriterSchemaVersion:
|
|
|
|
|
"""ConfigWriter always writes schema_version=2 regardless of input."""
|
|
|
|
|
|
|
|
|
|
def test_v1_paperforge_json_rerun_writes_schema_version_2(self, tmp_path: Path) -> None:
|
|
|
|
|
"""Rerunning ConfigWriter on a v1 file writes schema_version 2."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
# Create a v1-style file with no schema_version
|
|
|
|
|
(tmp_path / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({"system_dir": "OldSys", "resources_dir": "OldRes"}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
writer = ConfigWriter(tmp_path)
|
|
|
|
|
result = writer.write({"system_dir": "Sys", "resources_dir": "Res", "literature_dir": "Lit"})
|
|
|
|
|
assert result.ok, f"Write failed: {result.error}"
|
|
|
|
|
|
|
|
|
|
data = json.loads((tmp_path / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert data.get("schema_version") == "2", \
|
|
|
|
|
f"Expected schema_version=2, got {data.get('schema_version')!r}"
|
|
|
|
|
|
|
|
|
|
def test_v1_rerun_does_not_preserve_old_schema_version(self, tmp_path: Path) -> None:
|
|
|
|
|
"""ConfigWriter does NOT preserve a v1 schema_version value."""
|
|
|
|
|
from paperforge.setup.config_writer import ConfigWriter
|
|
|
|
|
|
|
|
|
|
# v1 file with schema_version=1
|
|
|
|
|
(tmp_path / "paperforge.json").write_text(
|
|
|
|
|
json.dumps({"schema_version": "1", "system_dir": "OldSys"}),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
writer = ConfigWriter(tmp_path)
|
|
|
|
|
result = writer.write({"system_dir": "Sys", "resources_dir": "Res", "literature_dir": "Lit"})
|
|
|
|
|
assert result.ok
|
|
|
|
|
|
|
|
|
|
data = json.loads((tmp_path / "paperforge.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert data.get("schema_version") == "2", \
|
|
|
|
|
f"Expected schema_version=2 after migration, got {data.get('schema_version')!r}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# --headless via SetupPlan (not headless_setup)
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCliHeadlessViaSetupPlan:
|
|
|
|
|
"""--headless must use SetupPlan (same engine as --modular)."""
|
|
|
|
|
|
|
|
|
|
def test_headless_can_be_routed_through_cli(self, tmp_path: Path) -> None:
|
|
|
|
|
"""CLI --headless flag is accepted and produces vault_config."""
|
|
|
|
|
from paperforge.cli import main
|
|
|
|
|
|
|
|
|
|
argv = ["--vault", str(tmp_path), "setup", "--headless"]
|
|
|
|
|
code = main(argv)
|
|
|
|
|
# May return non-zero (env missing) — that's fine
|
|
|
|
|
# What matters: paperforge.json was created with v2 format
|
|
|
|
|
pf = tmp_path / "paperforge.json"
|
|
|
|
|
if pf.exists():
|
|
|
|
|
data = json.loads(pf.read_text(encoding="utf-8"))
|
|
|
|
|
assert "vault_config" in data, "headless must produce vault_config"
|
|
|
|
|
assert data.get("schema_version") == "2"
|
|
|
|
|
|
|
|
|
|
def test_headless_literature_dir_reaches_vault_config(self, tmp_path: Path) -> None:
|
|
|
|
|
"""--literature-dir argument reaches vault_config through SetupPlan."""
|
|
|
|
|
from paperforge.cli import main
|
|
|
|
|
|
|
|
|
|
argv = ["--vault", str(tmp_path), "setup", "--headless",
|
|
|
|
|
"--literature-dir", "MyLit", "--skip-checks"]
|
|
|
|
|
code = main(argv)
|
|
|
|
|
|
|
|
|
|
pf = tmp_path / "paperforge.json"
|
|
|
|
|
assert pf.exists(), "paperforge.json should exist after headless setup"
|
|
|
|
|
data = json.loads(pf.read_text(encoding="utf-8"))
|
|
|
|
|
vc = data.get("vault_config", {})
|
|
|
|
|
assert vc.get("literature_dir") == "MyLit", \
|
|
|
|
|
f"Expected literature_dir=MyLit, got {vc.get('literature_dir')!r}"
|
|
|
|
|
|
|
|
|
|
def test_headless_zotero_path_reaches_plan(self, tmp_path: Path) -> None:
|
|
|
|
|
"""--zotero-data argument reaches SetupPlan via CLI headless."""
|
|
|
|
|
from paperforge.cli import main
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "zotero_headless_cli"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
# Monkey-patch SetupPlan to capture zotero_path
|
|
|
|
|
original_execute = None
|
|
|
|
|
captured = {}
|
|
|
|
|
|
|
|
|
|
import paperforge.setup.plan as plan_mod
|
|
|
|
|
original = plan_mod.SetupPlan.__init__
|
|
|
|
|
|
|
|
|
|
def patched_init(self, *a, **kw):
|
|
|
|
|
captured["zotero_path"] = kw.get("zotero_path")
|
|
|
|
|
return original(self, *a, **kw)
|
|
|
|
|
|
|
|
|
|
with patch.object(plan_mod.SetupPlan, "__init__", patched_init):
|
|
|
|
|
argv = ["--vault", str(vault), "setup", "--headless",
|
|
|
|
|
"--zotero-data", r"C:\Zotero\Data", "--skip-checks"]
|
|
|
|
|
main(argv)
|
|
|
|
|
|
|
|
|
|
assert captured.get("zotero_path") == r"C:\Zotero\Data", \
|
|
|
|
|
f"Expected zotero_path=C:\\Zotero\\Data, got {captured.get('zotero_path')!r}"
|
|
|
|
|
|
|
|
|
|
def test_headless_skip_checks_accepted(self, tmp_path: Path) -> None:
|
|
|
|
|
"""--skip-checks is accepted with --headless."""
|
|
|
|
|
from paperforge.cli import main
|
|
|
|
|
|
|
|
|
|
vault = tmp_path / "skip_headless"
|
|
|
|
|
vault.mkdir()
|
|
|
|
|
|
|
|
|
|
argv = ["--vault", str(vault), "setup", "--headless", "--skip-checks"]
|
|
|
|
|
code = main(argv)
|
|
|
|
|
# Should not crash — returns exit code
|
|
|
|
|
assert isinstance(code, int)
|
|
|
|
|
pf = vault / "paperforge.json"
|
|
|
|
|
assert pf.exists(), "paperforge.json must exist after headless --skip-checks"
|