Files
chemenu/tools/chemenu/tests/test_api.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

185 lines
7.4 KiB
Python

"""Tests for the in-process library boundary (Gitea #31).
Two properties, and neither is about the values coming back:
1. A caller can point Chemenu at a corpus tree and **no path of the checkout
this package lives in is read**. That is what "library" means here, and it
is what could not be asserted before: `config.KB_DIR` was bound at import
time, so a caller repointing `ROOT` was still answered out of the developer's
own `kb/`.
2. The surface is read-only **structurally**. `chemenu.api` imports nothing
under `chemenu.commands`, so `new`, `publish` and the rest are not reachable
from it - not filtered out of it.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
from chemenu import config
from chemenu.api import Corpus
from chemenu.errors import ChemenuError, ValidationError
from chemenu.types_core import UnknownType
@pytest.fixture
def foreign_corpus(tmp_path: Path) -> Path:
"""A corpus tree that is not this checkout, with one findable page."""
root = tmp_path / "elsewhere"
kb = root / "kb" / "entities"
kb.mkdir(parents=True)
(root / "kb" / "entities" / "COLLECTION.md").write_text("# entities\n", encoding="utf-8")
(kb / "Peregrine.md").write_text(
"---\ntype: types/entity.md\nentity_type: system\nconfidence: 0.42\n"
"summary: A system that exists only in this fixture.\n---\n\n"
"# Peregrine\n\nPeregrine is the fixture's own system.\n",
encoding="utf-8",
)
return root
def test_a_corpus_can_be_named_and_is_the_one_that_answers(foreign_corpus):
result = Corpus(foreign_corpus).search("Peregrine")
assert [hit["title"] for hit in result["results"]] == ["Peregrine"]
assert result["results"][0]["path"] == "kb/entities/Peregrine.md"
def test_no_path_of_this_checkout_is_read_while_a_foreign_root_is_set(foreign_corpus):
"""The acceptance criterion, asserted rather than argued.
`Path.read_text` and `Path.rglob` are the two ways a page reaches the
reader; both are watched, and any access under the real repository root
fails the test. Before the root resolution was made lazy this test could not
pass: `config.KB_DIR` was already bound to this checkout's `kb/`.
"""
package_root = config._PACKAGE_ROOT
trespasses: list[str] = []
real_read_text = Path.read_text
real_rglob = Path.rglob
def watched_read_text(self, *args, **kwargs):
_note(self)
return real_read_text(self, *args, **kwargs)
def watched_rglob(self, *args, **kwargs):
_note(self)
return real_rglob(self, *args, **kwargs)
def _note(path: Path) -> None:
try:
path.resolve().relative_to(package_root)
except ValueError:
return
trespasses.append(str(path))
monkey = pytest.MonkeyPatch()
monkey.setattr(Path, "read_text", watched_read_text)
monkey.setattr(Path, "rglob", watched_rglob)
try:
Corpus(foreign_corpus).search("Peregrine")
Corpus(foreign_corpus).search(predicates=["confidence<0.6"])
finally:
monkey.undo()
assert trespasses == []
def test_the_env_var_points_the_default_corpus(foreign_corpus, monkeypatch):
"""`CHEMENU_ROOT` exists for a caller that *is* a whole process and has
nothing to pass an argument through."""
monkeypatch.setenv(config.ENV_ROOT, str(foreign_corpus))
assert Corpus().root == foreign_corpus.resolve()
assert Corpus().kb_dir == foreign_corpus.resolve() / "kb"
def test_without_the_env_var_the_root_is_this_checkout(monkeypatch):
"""The default has to be unchanged, or `tools/wikitool` moves under
everyone's feet."""
monkeypatch.delenv(config.ENV_ROOT, raising=False)
assert config.resolve_root() == config._PACKAGE_ROOT
def test_an_explicit_argument_beats_the_env_var(foreign_corpus, tmp_path, monkeypatch):
"""A caller serving two corpora cannot tell them apart with a process-wide
variable, so the argument has to win."""
monkeypatch.setenv(config.ENV_ROOT, str(tmp_path / "somewhere-else"))
assert Corpus(foreign_corpus).root == foreign_corpus.resolve()
def test_derived_paths_follow_the_root_instead_of_lagging_behind(foreign_corpus, monkeypatch):
"""The failure that made the old shape worse than the limitation: `ROOT`
moved and `KB_DIR` did not, so a caller believed it was working on the
target tree while reading this one."""
monkeypatch.setattr(config, "ROOT", foreign_corpus)
assert config.KB_DIR == foreign_corpus / "kb"
assert config.RAW_DIR == foreign_corpus / "raw"
assert config.INDEX_FILE == foreign_corpus / "kb" / "index.md"
def test_validation_errors_are_raised_not_exited(foreign_corpus):
corpus = Corpus(foreign_corpus)
with pytest.raises(ValidationError):
corpus.search()
with pytest.raises(ValidationError):
corpus.search("x", predicates=["not a predicate"])
with pytest.raises(UnknownType):
corpus.describe_type("no-such-type")
# One class to catch, whatever went wrong.
with pytest.raises(ChemenuError):
corpus.describe_type("no-such-type")
def test_the_read_surface_cannot_reach_a_write_command():
"""Structural, not filtered: import `chemenu.api` in a clean interpreter and
nothing under `chemenu.commands` is loaded, so there is no `publish` to
call. Run out-of-process because this suite has already imported the CLI."""
code = (
"import sys, chemenu.api;"
"print([m for m in sys.modules if m.startswith('chemenu.commands')]);"
"print([m for m in sys.modules if m in ('typer', 'rich', 'click')])"
)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=config._PACKAGE_ROOT / "tools",
capture_output=True,
text=True,
check=True,
)
commands_loaded, cli_loaded = result.stdout.strip().splitlines()
assert commands_loaded == "[]", f"api pulled in command modules: {commands_loaded}"
assert cli_loaded == "[]", f"api pulled in the CLI head: {cli_loaded}"
def test_every_answer_carries_the_revision_it_was_computed_from(foreign_corpus):
"""A stale checkout answers confidently and wrongly otherwise. Outside git
there is no commit, and the stamp says so rather than inventing one."""
corpus = Corpus(foreign_corpus)
result = corpus.search("Peregrine")
assert result["commit"] is None and result["as_of"]
subprocess.run(["git", "init", "-b", "main"], cwd=foreign_corpus, check=True,
capture_output=True)
for key, value in (("user.name", "Fixture"), ("user.email", "f@example.com")):
subprocess.run(["git", "config", key, value], cwd=foreign_corpus, check=True,
capture_output=True)
subprocess.run(["git", "add", "-A"], cwd=foreign_corpus, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "corpus"], cwd=foreign_corpus, check=True,
capture_output=True)
stamped = Corpus(foreign_corpus).search("Peregrine")
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=foreign_corpus,
capture_output=True, text=True, check=True).stdout.strip()
assert stamped["commit"] == head
def test_lint_and_status_answer_from_the_named_corpus(foreign_corpus):
corpus = Corpus(foreign_corpus)
assert corpus.lint()["page_count"] == 1
status = corpus.status()
assert status["pages"] == 1
assert status["collections"] == {"entities": 1}