lllin000_PaperForge/tests/test_setup_wizard.py
Research Assistant a10bf1fdbd test(merge-gate): add upgrade migration and index path integrity tests
- test OCR state machine: isolate runtime (no ambient tokens), fix retry exhaustion and full cycle tests
- test migration: verify no-index flat note migration, legacy flag promotion including false overrides
- test migration: verify non-canonical filename migration via frontmatter title
- test migration: verify already-migrated workspace reconciliation from legacy records
- test asset_index: verify fulltext/deep-reading paths only advertised when files exist
2026-05-07 21:52:16 +08:00

512 lines
20 KiB
Python

"""Unit tests for PaperForge setup wizard standalone functions.
Covers: AGENT_CONFIGS, EnvChecker (check_python, check_vault, check_dependencies,
get_exports_dir, _find_zotero), CheckResult, and _find_vault.
Test strategy:
- Textual App and StepScreen classes require a running terminal and are NOT tested here.
- Focus on standalone detection/validation logic that can run without Textual.
- Use monkeypatch for environment/platform-dependent tests.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from unittest.mock import patch
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))
# Import standalone functions and classes (not Textual-dependent)
from paperforge.setup_wizard import AGENT_CONFIGS, CheckResult, EnvChecker, _find_vault
# ===================================================================
# AGENT_CONFIGS
# ===================================================================
class TestAgentConfigs:
"""Agent platform configuration completeness."""
def test_has_all_expected_agents(self) -> None:
"""Verify all 9 agent platforms are configured."""
expected_agents = {
"opencode", "claude", "codex", "cursor", "windsurf",
"github_copilot", "cline", "augment", "trae",
}
assert set(AGENT_CONFIGS.keys()) == expected_agents
def test_each_agent_has_name_and_skill_dir(self) -> None:
"""Every agent config must have name and skill_dir."""
for key, cfg in AGENT_CONFIGS.items():
assert "name" in cfg, f"{key} missing name"
assert "skill_dir" in cfg, f"{key} missing skill_dir"
assert isinstance(cfg["name"], str) and cfg["name"], f"{key} has empty name"
assert isinstance(cfg["skill_dir"], str) and cfg["skill_dir"], f"{key} has empty skill_dir"
def test_opencode_skill_dir_is_dot_opencode(self) -> None:
"""OpenCode skill dir must be .opencode/skills."""
assert AGENT_CONFIGS["opencode"]["skill_dir"] == ".opencode/skills"
def test_cursor_skill_dir_is_dot_cursor(self) -> None:
assert AGENT_CONFIGS["cursor"]["skill_dir"] == ".cursor/skills"
def test_claude_skill_dir_is_dot_claude(self) -> None:
assert AGENT_CONFIGS["claude"]["skill_dir"] == ".claude/skills"
def test_opencode_has_command_dir(self) -> None:
"""OpenCode is the only agent with command_dir."""
assert AGENT_CONFIGS["opencode"].get("command_dir") == ".opencode/command"
def test_agent_config_format_field(self):
"""All agents must have a format field."""
for key, cfg in AGENT_CONFIGS.items():
assert "format" in cfg, f"{key} missing format field"
assert cfg["format"] in {"skill_directory", "flat_command", "rules_file"}, f"{key} invalid format"
def test_agent_config_prefix_field(self):
"""All agents must have a prefix field."""
for key, cfg in AGENT_CONFIGS.items():
assert "prefix" in cfg, f"{key} missing prefix field"
assert cfg["prefix"] in {"/", "$"}, f"{key} invalid prefix"
def test_codex_is_codex(self):
"""Codex entry must have correct configuration."""
assert "codex" in AGENT_CONFIGS
cfg = AGENT_CONFIGS["codex"]
assert cfg["name"] == "Codex"
assert cfg["format"] == "skill_directory"
assert cfg["prefix"] == "$"
assert cfg["skill_dir"] == ".codex/skills"
def test_cline_format_is_rules_file(self):
"""Cline must use rules_file format."""
cfg = AGENT_CONFIGS["cline"]
assert cfg["format"] == "rules_file"
assert cfg["skill_dir"] == ".clinerules"
def test_opencode_format_is_flat_command(self):
"""OpenCode must use flat_command format."""
cfg = AGENT_CONFIGS["opencode"]
assert cfg["format"] == "flat_command"
assert cfg["command_dir"] == ".opencode/command"
# ===================================================================
# CheckResult
# ===================================================================
class TestCheckResult:
"""CheckResult data class."""
def test_default_state(self) -> None:
r = CheckResult("Test Check")
assert r.name == "Test Check"
assert r.passed is False
assert r.detail == ""
assert r.action_required is False
def test_can_set_passed(self) -> None:
r = CheckResult("X")
r.passed = True
assert r.passed is True
def test_can_set_detail(self) -> None:
r = CheckResult("X")
r.detail = "Some detail"
assert r.detail == "Some detail"
def test_can_set_action_required(self) -> None:
r = CheckResult("X")
r.action_required = True
assert r.action_required is True
# ===================================================================
# EnvChecker
# ===================================================================
class TestEnvCheckerInit:
"""EnvChecker initialization."""
def test_init_with_vault_path(self, tmp_path: Path) -> None:
checker = EnvChecker(tmp_path)
assert checker.vault == tmp_path
assert checker.system_dir == "System"
assert set(checker.results.keys()) == {"python", "vault", "zotero", "bbt", "json"}
def test_results_are_checkresult_instances(self, tmp_path: Path) -> None:
checker = EnvChecker(tmp_path)
for key, result in checker.results.items():
assert isinstance(result, CheckResult), f"{key} is not CheckResult"
assert result.name, f"{key} has empty name"
def test_get_exports_dir(self, tmp_path: Path) -> None:
checker = EnvChecker(tmp_path)
expected = tmp_path / "System" / "PaperForge" / "exports"
assert checker.get_exports_dir() == expected
def test_get_exports_dir_custom_system_dir(self, tmp_path: Path) -> None:
checker = EnvChecker(tmp_path)
checker.system_dir = "CustomSystem"
expected = tmp_path / "CustomSystem" / "PaperForge" / "exports"
assert checker.get_exports_dir() == expected
class TestEnvCheckerCheckPython:
"""EnvChecker.check_python()"""
def test_python_version_passes(self, tmp_path: Path) -> None:
checker = EnvChecker(tmp_path)
result = checker.check_python()
# Should pass on any modern Python (>= 3.8)
assert result.passed is True
assert "Python" in result.detail
def test_python_version_in_detail(self, tmp_path: Path) -> None:
checker = EnvChecker(tmp_path)
result = checker.check_python()
v = sys.version_info
assert f"{v.major}.{v.minor}" in result.detail
class TestEnvCheckerCheckVault:
"""EnvChecker.check_vault()"""
def test_missing_directories_fails(self, tmp_path: Path) -> None:
"""Empty vault should fail vault structure check."""
checker = EnvChecker(tmp_path)
result = checker.check_vault()
assert result.passed is False
assert result.action_required is True
assert "missing" in result.detail.lower() or "缺少" in result.detail
def test_existing_directories_passes(self, tmp_path: Path) -> None:
"""Vault with correct structure should pass."""
(tmp_path / "System" / "PaperForge" / "exports").mkdir(parents=True)
(tmp_path / "System" / "PaperForge" / "ocr").mkdir(parents=True)
checker = EnvChecker(tmp_path)
result = checker.check_vault()
assert result.passed is True
def test_partial_directories_fails(self, tmp_path: Path) -> None:
"""Vault with only one required dir should fail."""
(tmp_path / "System" / "PaperForge" / "exports").mkdir(parents=True)
checker = EnvChecker(tmp_path)
result = checker.check_vault()
assert result.passed is False
class TestEnvCheckerCheckDependencies:
"""EnvChecker.check_dependencies()"""
def test_installed_deps_pass(self, tmp_path: Path) -> None:
"""If requests/pymupdf/PIL are installed, this should pass (in CI/test env)."""
checker = EnvChecker(tmp_path)
result = checker.check_dependencies()
# In CI these should be installed since they're test deps
# Accept either pass or fail — what matters is the result is valid
assert isinstance(result.passed, bool)
assert isinstance(result.detail, str)
def test_missing_dep_fails(self, tmp_path: Path) -> None:
"""Simulate a missing import to verify failure path."""
checker = EnvChecker(tmp_path)
with patch("builtins.__import__", side_effect=ImportError("missing")):
result = checker.check_dependencies()
assert result.passed is False
assert result.action_required is True
class TestEnvCheckerFindZotero:
"""EnvChecker._find_zotero() — platform-dependent search."""
def test_manual_path_is_used_when_valid(self, tmp_path: Path) -> None:
"""If a manual path is provided and exists, it should be returned."""
checker = EnvChecker(tmp_path)
fake_zotero = tmp_path / "zotero.exe"
fake_zotero.write_text("fake binary", encoding="utf-8")
result = checker._find_zotero(manual_path=fake_zotero)
assert result == fake_zotero
def test_manual_path_none_falls_through(self, tmp_path: Path) -> None:
"""When manual_path is None, search logic runs (likely returns None on CI)."""
checker = EnvChecker(tmp_path)
result = checker._find_zotero()
# On CI there's no Zotero, so result should be None
# This test just validates the fallthrough doesn't crash
if result is not None:
assert result.exists()
class TestFindVault:
"""_find_vault() — standalone vault discovery."""
def test_finds_vault_with_paperforge_json(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify _find_vault detects paperforge.json in current or parent dirs."""
vault = tmp_path / "my_vault"
vault.mkdir(parents=True)
(vault / "paperforge.json").write_text("{}", encoding="utf-8")
monkeypatch.chdir(vault)
result = _find_vault()
assert result is not None
assert result == vault
def test_no_paperforge_json_returns_none(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify _find_vault returns None when no paperforge.json exists."""
monkeypatch.chdir(tmp_path)
result = _find_vault()
assert result is None
def test_finds_in_parent_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify _find_vault walks up parent directories."""
parent = tmp_path / "parent"
parent.mkdir()
(parent / "paperforge.json").write_text("{}", encoding="utf-8")
child = parent / "subdir" / "deeper"
child.mkdir(parents=True)
monkeypatch.chdir(child)
result = _find_vault()
assert result is not None
# Should resolve to the same path
assert result.resolve() == parent.resolve()
class TestEnvCheckerCheckJson:
"""EnvChecker.check_json() — JSON export detection."""
def test_no_exports_dir_fails(self, tmp_path: Path) -> None:
checker = EnvChecker(tmp_path)
result = checker.check_json()
assert result.passed is False
def test_valid_json_exports_pass(self, tmp_path: Path) -> None:
exports_dir = tmp_path / "System" / "PaperForge" / "exports"
exports_dir.mkdir(parents=True)
(exports_dir / "test.json").write_text(
json.dumps({"items": [{"key": "TEST"}]}),
encoding="utf-8",
)
checker = EnvChecker(tmp_path)
result = checker.check_json()
assert result.passed is True
assert "JSON" in result.detail
def test_invalid_json_fails(self, tmp_path: Path) -> None:
exports_dir = tmp_path / "System" / "PaperForge" / "exports"
exports_dir.mkdir(parents=True)
(exports_dir / "bad.json").write_text("{invalid}", encoding="utf-8")
checker = EnvChecker(tmp_path)
result = checker.check_json()
assert result.passed is False
def test_empty_exports_fails(self, tmp_path: Path) -> None:
exports_dir = tmp_path / "System" / "PaperForge" / "exports"
exports_dir.mkdir(parents=True)
checker = EnvChecker(tmp_path)
result = checker.check_json()
assert result.passed is False
# ===================================================================
# Integration smoke tests for headless_setup
# ===================================================================
def test_headless_setup_claude_skill_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Claude Code uses skill_directory format — creates .claude/skills/pf-deep/SKILL.md etc."""
from paperforge.setup_wizard import headless_setup
import paperforge.setup_wizard as sw
def patched_check_deps(self) -> CheckResult:
r = CheckResult("Dependencies")
r.passed = True
r.detail = "mocked"
return r
monkeypatch.setattr(sw.EnvChecker, "check_dependencies", patched_check_deps)
rv = headless_setup(
vault=tmp_path,
agent_key="claude",
system_dir="99_System",
resources_dir="03_Resources",
literature_dir="Literature",
control_dir="LiteratureControl",
base_dir="05_Bases",
skip_checks=True,
)
assert rv == 0, f"claude install failed with code {rv}"
pf_deep = tmp_path / ".claude" / "skills" / "pf-deep"
assert pf_deep.exists(), f"pf-deep skill dir not created: {pf_deep}"
assert (pf_deep / "SKILL.md").exists(), "SKILL.md not created"
assert (pf_deep / "scripts" / "ld_deep.py").exists(), "ld_deep.py not created"
assert (pf_deep / "prompt_deep_subagent.md").exists(), "prompt not created"
assert (pf_deep / "chart-reading" / "INDEX.md").exists(), "chart INDEX.md not created"
pf_paper = tmp_path / ".claude" / "skills" / "pf-paper"
assert pf_paper.exists(), f"pf-paper skill dir not created: {pf_paper}"
assert (pf_paper / "SKILL.md").exists(), "pf-paper SKILL.md not created"
def test_headless_setup_opencode_flat_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""OpenCode uses flat_command format - creates .opencode/command/pf-deep.md."""
from paperforge.setup_wizard import headless_setup
import paperforge.setup_wizard as sw
def patched_check_deps(self) -> CheckResult:
r = CheckResult("Dependencies")
r.passed = True
r.detail = "mocked"
return r
monkeypatch.setattr(sw.EnvChecker, "check_dependencies", patched_check_deps)
rv = headless_setup(
vault=tmp_path,
agent_key="opencode",
system_dir="99_System",
resources_dir="03_Resources",
literature_dir="Literature",
control_dir="LiteratureControl",
base_dir="05_Bases",
skip_checks=True,
)
assert rv == 0, f"opencode install failed with code {rv}"
cmd_dir = tmp_path / ".opencode" / "command"
assert cmd_dir.exists(), f"command dir not created: {cmd_dir}"
pf_deep = cmd_dir / "pf-deep.md"
assert pf_deep.exists(), f"pf-deep.md not created: {pf_deep}"
pf_paper = cmd_dir / "pf-paper.md"
assert pf_paper.exists(), f"pf-paper.md not created: {pf_paper}"
def test_headless_setup_codex_skill_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Codex uses skill_directory format with $ prefix."""
from paperforge.setup_wizard import headless_setup
import paperforge.setup_wizard as sw
def patched_check_deps(self) -> CheckResult:
r = CheckResult("Dependencies")
r.passed = True
r.detail = "mocked"
return r
monkeypatch.setattr(sw.EnvChecker, "check_dependencies", patched_check_deps)
rv = headless_setup(
vault=tmp_path,
agent_key="codex",
system_dir="99_System",
resources_dir="03_Resources",
literature_dir="Literature",
control_dir="LiteratureControl",
base_dir="05_Bases",
skip_checks=True,
)
assert rv == 0, f"codex install failed with code {rv}"
pf_deep = tmp_path / ".codex" / "skills" / "pf-deep"
assert pf_deep.exists(), f"pf-deep skill dir not created: {pf_deep}"
assert (pf_deep / "SKILL.md").exists(), "SKILL.md not created"
assert (pf_deep / "scripts" / "ld_deep.py").exists(), "ld_deep.py not created"
assert (pf_deep / "prompt_deep_subagent.md").exists(), "prompt not created"
def test_headless_setup_cline_rules_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Cline uses rules_file format - creates .clinerules directory with skill content."""
from paperforge.setup_wizard import headless_setup
import paperforge.setup_wizard as sw
def patched_check_deps(self) -> CheckResult:
r = CheckResult("Dependencies")
r.passed = True
r.detail = "mocked"
return r
monkeypatch.setattr(sw.EnvChecker, "check_dependencies", patched_check_deps)
rv = headless_setup(
vault=tmp_path,
agent_key="cline",
system_dir="99_System",
resources_dir="03_Resources",
literature_dir="Literature",
control_dir="LiteratureControl",
base_dir="05_Bases",
skip_checks=True,
)
assert rv == 0, f"cline install failed with code {rv}"
clinerules = tmp_path / ".clinerules"
assert clinerules.exists(), f".clinerules dir not created: {clinerules}"
pf_deep = clinerules / "pf-deep"
assert pf_deep.exists(), f"pf-deep subdir not created under .clinerules"
assert (pf_deep / "scripts" / "ld_deep.py").exists(), "ld_deep.py not created under .clinerules"
def test_headless_setup_preserves_existing_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Setup should create missing files without overwriting existing user content."""
from paperforge.setup_wizard import headless_setup
import paperforge.setup_wizard as sw
def patched_check_deps(self) -> CheckResult:
r = CheckResult("Dependencies")
r.passed = True
r.detail = "mocked"
return r
monkeypatch.setattr(sw.EnvChecker, "check_dependencies", patched_check_deps)
docs_file = tmp_path / "docs" / "setup-guide.md"
docs_file.parent.mkdir(parents=True)
docs_file.write_text("user doc\n", encoding="utf-8")
agents_file = tmp_path / "AGENTS.md"
agents_file.write_text("custom agents\n", encoding="utf-8")
env_file = tmp_path / "99_System" / "PaperForge" / ".env"
env_file.parent.mkdir(parents=True)
env_file.write_text("PADDLEOCR_API_TOKEN=keep-me\n", encoding="utf-8")
plugin_main = tmp_path / ".obsidian" / "plugins" / "paperforge" / "main.js"
plugin_main.parent.mkdir(parents=True)
plugin_main.write_text("custom plugin\n", encoding="utf-8")
existing_note = tmp_path / "03_Resources" / "Literature" / "existing.md"
existing_note.parent.mkdir(parents=True)
existing_note.write_text("keep note\n", encoding="utf-8")
rv = headless_setup(
vault=tmp_path,
agent_key="opencode",
paddleocr_key="new-token",
system_dir="99_System",
resources_dir="03_Resources",
literature_dir="Literature",
control_dir="LiteratureControl",
base_dir="05_Bases",
skip_checks=True,
)
assert rv == 0
assert docs_file.read_text(encoding="utf-8") == "user doc\n"
assert agents_file.read_text(encoding="utf-8") == "custom agents\n"
env_text = env_file.read_text(encoding="utf-8")
assert "PADDLEOCR_API_TOKEN=keep-me" in env_text
assert "PADDLEOCR_API_TOKEN=new-token" not in env_text
assert plugin_main.read_text(encoding="utf-8") == "custom plugin\n"
assert existing_note.read_text(encoding="utf-8") == "keep note\n"