Files
chemenu/tools/chemenu/tests/test_doctor.py
T
torben 576df2cddd
CI / verify (push) Successful in 52s
Release / release (push) Successful in 37s
feat: MCP-Leseserver, Bibliotheksgrenze, Haertung des Lesepfads, Publish-Remote-Gate scharf (2.4.0)
Files changed:
- .gitea/workflows/ci.yml
- CHANGES.md
- README.md
- VERSION
- instructions/mcp-read-server.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/api.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/lint.py
- tools/chemenu/commands/search.py
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/config.py
- tools/chemenu/corpus_cache.py
- tools/chemenu/errors.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/lint_core.py
- tools/chemenu/mcp/__init__.py
- tools/chemenu/mcp/__main__.py
- tools/chemenu/mcp/server.py
- tools/chemenu/page.py
- tools/chemenu/search/filters.py
- tools/chemenu/search/registry.py
- tools/chemenu/search/ripgrep.py
- tools/chemenu/search/service.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_api.py
- tools/chemenu/tests/test_corpus_cache.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_frontmatter_io.py
- tools/chemenu/tests/test_instructions_cmd.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_search.py
- tools/chemenu/type_resolver.py
- tools/chemenu/types_core.py
- tools/requirements-mcp.txt
2026-09-02 07:19:32 +02:00

305 lines
12 KiB
Python

"""Tests for `wikitool doctor`: a healthy instance reports all OK/WARN and
never FAIL, and each check independently reports FAIL when its precondition
is missing."""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
from chemenu import config
from chemenu.commands import doctor, instructions_cmd
def _git(root: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True)
@pytest.fixture
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A minimal, fully-configured wiki instance: a git repo with identity,
the required stage contracts, one kb collection, generated files, and a
published skill."""
root = tmp_path
kb = root / "kb"
for sub in ("entities", "concepts", "sources", "comparisons"):
(kb / sub).mkdir(parents=True)
(kb / sub / "COLLECTION.md").write_text(f"# {sub}\n", encoding="utf-8")
(kb / "index.md").write_text("# Index\n", encoding="utf-8")
(kb / "log.md").write_text("# Log\n", encoding="utf-8")
(kb / "provenance.md").write_text("# Provenance\n", encoding="utf-8")
(kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8")
(root / "VERSION").write_text("0.1.0\n", encoding="utf-8")
(root / "USER.md").write_text("# USER.md - Fixture\n", encoding="utf-8")
(root / "SOUL.md").write_text("# SOUL.md - Fixture\n", encoding="utf-8")
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md",
"instructions/CONTRACT.md", "types/type-spec.md"):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("# contract\n", encoding="utf-8")
instructions = root / "instructions"
(instructions / "wiki-demo").mkdir(parents=True)
(instructions / "wiki-demo" / "SKILL.md").write_text(
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n", encoding="utf-8"
)
monkeypatch.setattr(config, "ROOT", root)
monkeypatch.setattr(config, "KB_DIR", kb)
monkeypatch.setattr(config, "INDEX_FILE", kb / "index.md")
monkeypatch.setattr(config, "LOG_FILE", kb / "log.md")
monkeypatch.setattr(config, "PROVENANCE_FILE", kb / "provenance.md")
monkeypatch.setattr(config, "INSTRUCTIONS_DIR", instructions)
monkeypatch.setattr(config, "AGENTS_SKILLS_DIR", root / ".agents" / "skills")
monkeypatch.setattr(config, "CLAUDE_SKILLS_DIR", root / ".claude" / "skills")
monkeypatch.setenv("WIKI_AUTHOR", "Fixture Author")
monkeypatch.delenv("WIKITOOL_SESSION_ID", raising=False)
_git(root, "init", "-b", "main")
_git(root, "config", "user.name", "Fixture Author")
_git(root, "config", "user.email", "fixture@example.com")
instructions_cmd.sync(force=False)
return root
def _status(checks, name):
return next(c.status for c in checks if c.name == name)
def test_healthy_instance_has_no_fail(instance):
checks = doctor.run_doctor()
assert not any(c.status == "FAIL" for c in checks)
assert _status(checks, "structure") == "OK"
assert _status(checks, "generated-files") == "OK"
assert _status(checks, "skills") == "OK"
assert _status(checks, "author") == "OK"
assert _status(checks, "git-identity") == "OK"
def test_healthy_instance_warns_on_missing_remote_and_session_id(instance):
checks = doctor.run_doctor()
assert _status(checks, "git-remote") == "WARN"
assert _status(checks, "session-id") == "WARN"
def test_stack_version_is_reported(instance):
checks = doctor.run_doctor()
assert _status(checks, "stack-version") == "OK"
detail = next(c.detail for c in checks if c.name == "stack-version")
assert "0.1.0" in detail and "development tree" in detail
def test_a_stamped_instance_reports_itself_as_a_distribution(instance):
(config.ROOT / ".wikitool-release.json").write_text(
'{"version": "0.1.0", "exported_at": "2026-08-29"}', encoding="utf-8"
)
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
assert "distribution" in detail and "2026-08-29" in detail
def test_a_missing_version_warns_rather_than_fails(instance):
"""Instances exported before the stack was versioned are still perfectly
functional - they just cannot answer `version check`."""
(config.ROOT / "VERSION").unlink()
checks = doctor.run_doctor()
assert _status(checks, "stack-version") == "WARN"
assert not any(c.status == "FAIL" for c in checks)
def test_a_malformed_version_fails(instance):
(config.ROOT / "VERSION").write_text("v1\n", encoding="utf-8")
assert _status(doctor.run_doctor(), "stack-version") == "FAIL"
def test_kb_version_warns_when_the_content_is_undeclared(instance):
checks = doctor.run_doctor()
assert _status(checks, "kb-version") == "WARN"
assert not any(c.status == "FAIL" for c in checks)
def test_kb_version_is_ok_when_it_matches_the_machinery(instance):
(config.ROOT / ".wikitool-kb.json").write_text(
'{"schema": 1, "kb_version": "0.1.0", "applied": []}', encoding="utf-8"
)
assert _status(doctor.run_doctor(), "kb-version") == "OK"
def test_kb_version_warns_while_a_migration_is_outstanding(instance):
"""The normal, transient state in the middle of an upgrade - a WARN that
names the chain, not a fault."""
(config.ROOT / "VERSION").write_text("2.0.0\n", encoding="utf-8")
(config.ROOT / ".wikitool-kb.json").write_text(
'{"schema": 1, "kb_version": "1.0.0", "applied": []}', encoding="utf-8"
)
migrations = config.INSTRUCTIONS_DIR / "migrations"
migrations.mkdir(parents=True, exist_ok=True)
(migrations / "2.0.0-retype.md").write_text(
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
encoding="utf-8",
)
checks = doctor.run_doctor()
assert _status(checks, "kb-version") == "WARN"
assert "outstanding" in next(c.detail for c in checks if c.name == "kb-version")
assert not any(c.status == "FAIL" for c in checks)
def test_an_unreadable_kb_state_fails(instance):
(config.ROOT / ".wikitool-kb.json").write_text("{broken", encoding="utf-8")
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
def test_no_collections_at_all_fails_structure(instance):
"""Removing one collection is a legitimate state - collections are
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
Having none at all means the kb layer never got its Areas populated."""
import shutil
for sub in ("entities", "concepts", "sources", "comparisons"):
shutil.rmtree(config.KB_DIR / sub)
checks = doctor.run_doctor()
assert _status(checks, "structure") == "FAIL"
def test_missing_stage_contract_fails_structure(instance):
(config.ROOT / "raw" / "CONTRACT.md").unlink()
checks = doctor.run_doctor()
assert _status(checks, "structure") == "FAIL"
def test_personalization_is_ok_when_both_files_are_filled(instance):
assert _status(doctor.run_doctor(), "personalization") == "OK"
def test_missing_personalization_fails(instance):
"""`USER.md`/`SOUL.md` are read every session, so an instance without them
runs a generic agent against a wiki built for one person."""
(config.ROOT / "SOUL.md").unlink()
checks = doctor.run_doctor()
assert _status(checks, "personalization") == "FAIL"
assert "SOUL.md" in next(c.detail for c in checks if c.name == "personalization")
def test_a_renamed_but_unfilled_template_fails(instance):
"""The failure mode a plain existence check would miss: the file is
present and answers nothing."""
(config.ROOT / "USER.md").write_text(
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# USER.md - <Name>\n", encoding="utf-8"
)
checks = doctor.run_doctor()
assert _status(checks, "personalization") == "FAIL"
assert "template" in next(c.detail for c in checks if c.name == "personalization")
def test_environment_is_ok_when_absent(instance):
"""The file is optional, so absence is a healthy end state - a FAIL here
would make it mandatory through the back door."""
assert not (config.ROOT / config.ENVIRONMENT_FILE).exists()
assert _status(doctor.run_doctor(), "environment") == "OK"
def test_environment_is_ok_when_filled(instance):
(config.ROOT / config.ENVIRONMENT_FILE).write_text(
"# ENVIRONMENT.md - Fixture\n\n- Harness: none\n", encoding="utf-8"
)
assert _status(doctor.run_doctor(), "environment") == "OK"
def test_a_renamed_but_unfilled_environment_template_warns(instance):
"""Present, loaded into every session, and answering nothing - worse than
absent, which is at least honest. A WARN, not a FAIL: the fix may well be
to delete the file again."""
(config.ROOT / config.ENVIRONMENT_FILE).write_text(
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# ENVIRONMENT.md - <Instanz>\n", encoding="utf-8"
)
checks = doctor.run_doctor()
assert _status(checks, "environment") == "WARN"
assert "template" in next(c.detail for c in checks if c.name == "environment")
def _remotes_detail(checks) -> str:
return next(c.detail for c in checks if c.name == "publish-remotes")
def test_publish_remotes_says_not_armed_when_the_file_is_absent(instance):
"""Absence stays OK - a single-remote checkout has nothing to protect - but
the line has to say the gate is off. AGENTS.md lists it among the limits
enforced in code, so "no .wikitool-remotes.json" alone leaves a reader
trusting a safeguard that is not running."""
checks = doctor.run_doctor()
assert _status(checks, "publish-remotes") == "OK"
assert "not armed" in _remotes_detail(checks)
def test_publish_remotes_says_armed_when_the_file_lists_a_target(instance):
(config.ROOT / config.PUBLISH_REMOTES_FILENAME).write_text(
'{ "schema": 1, "allowed_push_urls": ["ssh://git@example.net/one.git"] }',
encoding="utf-8",
)
checks = doctor.run_doctor()
assert _status(checks, "publish-remotes") == "OK"
detail = _remotes_detail(checks)
assert "armed" in detail and "not armed" not in detail
def test_publish_remotes_warns_on_several_remotes_without_an_allowlist(instance):
"""The shape a private instance has once it adds the public upstream."""
_git(config.ROOT, "remote", "add", "origin", "ssh://git@example.net/mine.git")
_git(config.ROOT, "remote", "add", "upstream", "ssh://git@example.net/theirs.git")
checks = doctor.run_doctor()
assert _status(checks, "publish-remotes") == "WARN"
assert "not armed" in _remotes_detail(checks)
def test_missing_generated_file_fails(instance):
config.LOG_FILE.unlink()
checks = doctor.run_doctor()
assert _status(checks, "generated-files") == "FAIL"
def test_unpublished_skills_fail(instance):
import shutil
shutil.rmtree(config.AGENTS_SKILLS_DIR)
shutil.rmtree(config.CLAUDE_SKILLS_DIR)
checks = doctor.run_doctor()
assert _status(checks, "skills") == "FAIL"
def test_no_author_fails(instance, monkeypatch):
monkeypatch.delenv("WIKI_AUTHOR", raising=False)
monkeypatch.setattr(config, "default_author", lambda: None)
checks = doctor.run_doctor()
assert _status(checks, "author") == "FAIL"
def test_not_a_git_repo_fails(tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path)
checks = doctor.run_doctor()
assert _status(checks, "git-repo") == "FAIL"
def test_doctor_command_exits_nonzero_only_on_fail(instance, capsys):
import typer
doctor.doctor_command(json_out=False) # no FAIL - must not raise
out = capsys.readouterr().out
assert "OK" in out
with pytest.raises(typer.Exit) as excinfo:
config.LOG_FILE.unlink()
doctor.doctor_command(json_out=False)
assert excinfo.value.exit_code == 1
def test_doctor_json_is_machine_readable(instance, capsys):
import json
doctor.doctor_command(json_out=True)
rows = json.loads(capsys.readouterr().out)
assert any(row["name"] == "structure" for row in rows)
assert all({"name", "status", "detail", "fix"} <= row.keys() for row in rows)