"""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"