576df2cddd
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
185 lines
7.4 KiB
Python
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}
|