177c7e9ce8
Files changed: - .gitea/workflows/ci.yml - AGENTS.md - CHANGES.md - VERSION - instructions/CONTRACT.md - instructions/link-taxonomy.md - instructions/migrations/4.0.0-link-taxonomy.md - instructions/setup-instance.md - kb/CONTRACT.md - kb/CONVENTIONS.md - kb/CONVENTIONS.md.template - kb/comparisons/COLLECTION.md - kb/concepts/COLLECTION.md - kb/entities/COLLECTION.md - kb/sources/COLLECTION.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/blocks.py - tools/chemenu/cli.py - tools/chemenu/commands/cite_cmd.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/links_cmd.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/page_ops.py - tools/chemenu/commands/run_budget.py - tools/chemenu/commands/xref.py - tools/chemenu/conventions.py - tools/chemenu/corpus_diff.py - tools/chemenu/frontmatter_io.py - tools/chemenu/kb_collections.py - tools/chemenu/kb_state.py - tools/chemenu/links.py - tools/chemenu/lint_core.py - tools/chemenu/provenance.py - tools/chemenu/sections.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_blocks.py - tools/chemenu/tests/test_cite_cmd.py - tools/chemenu/tests/test_conventions.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_migrate_cmd.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_pipeline_l0.py - tools/chemenu/tests/test_types_cmd.py - tools/chemenu/tests/test_xref.py - types/concept.schema.yaml - types/entity.md - types/entity.schema.yaml - types/instruction.schema.yaml - types/type-spec.md - work/link-taxonomy-migration/README.md - work/link-taxonomy-migration/plan.md
358 lines
15 KiB
Python
358 lines
15 KiB
Python
from chemenu.commands._util import coerce_set_value, parse_set_fields
|
|
from chemenu.commands.new_page import _page_subdir
|
|
from chemenu.frontmatter_io import read_page
|
|
from typer.testing import CliRunner
|
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
def _invoke_new(monkeypatch, kb_dir, args):
|
|
"""Invoke the CLI against a temporary fixture kb/.
|
|
|
|
Only KB_DIR needs patching: a type's `base_dir:` is kb-root-relative
|
|
and resolved against config.KB_DIR, so page placement follows the
|
|
fixture automatically instead of needing a patched constant per type.
|
|
"""
|
|
import chemenu.config as config
|
|
from chemenu.cli import app
|
|
|
|
monkeypatch.setattr(config, "KB_DIR", kb_dir)
|
|
return runner.invoke(app, args)
|
|
|
|
|
|
def test_contract_only_type_cannot_be_instantiated(monkeypatch, kb_dir):
|
|
"""`lint-report` declares no `base_dir:` because its artifacts are written to
|
|
`reports/`, outside kb/. Scaffolding one as a page must fail with a readable
|
|
error rather than a traceback or a file in an invented directory."""
|
|
result = _invoke_new(monkeypatch, kb_dir, ["new", "lint-report", "--name", "Nope"])
|
|
assert result.exit_code == 1
|
|
assert "base_dir" in result.output
|
|
assert not list(kb_dir.rglob("Nope.md"))
|
|
|
|
|
|
def test_page_subdir_reads_layout_from_type_spec():
|
|
assert _page_subdir("tool", "types/entity.md") == "tools"
|
|
assert _page_subdir("technology", "types/entity.md") == "technologies"
|
|
|
|
|
|
def test_page_subdir_falls_back_for_unmapped_subtype():
|
|
"""A subtype absent from the type-spec's layout: falls back to
|
|
`<subtype>s`, matching the previous hand-maintained behavior."""
|
|
assert _page_subdir("gadget", "types/entity.md") == "gadgets"
|
|
|
|
|
|
def test_page_subdir_is_none_for_types_without_layout():
|
|
assert _page_subdir(None, "types/concept.md") is None
|
|
assert _page_subdir("anything", "types/concept.md") is None
|
|
|
|
|
|
def test_coerce_set_value_uses_declared_schema_type():
|
|
assert coerce_set_value("a,b", {"type": "array"}) == ["a", "b"]
|
|
assert coerce_set_value("0.9", {"type": "number"}) == 0.9
|
|
assert coerce_set_value("tool", {"type": "string"}) == "tool"
|
|
# Unknown field (no schema entry) passes through as a string and is then
|
|
# caught by additionalProperties: false during validation.
|
|
assert coerce_set_value("x", None) == "x"
|
|
|
|
|
|
def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "gateway.example.net",
|
|
"--set", "entity_type=system", "--set", "tags=gateway,firewall",
|
|
"--set", "related=Borealis", "--set", "confidence=0.9",
|
|
"--set", "provenance=general",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
path = kb_dir / "entities/systems/gateway.example.net.md"
|
|
assert path.exists()
|
|
fm, body = read_page(path)
|
|
assert fm["type"] == "types/entity.md"
|
|
assert fm["entity_type"] == "system"
|
|
assert fm["tags"] == ["gateway", "firewall"]
|
|
assert fm["related"] == ["Borealis"]
|
|
assert fm["confidence"] == 0.9
|
|
assert "# gateway.example.net" in body
|
|
|
|
|
|
def test_a_scaffolded_body_carries_no_tool_owned_region(monkeypatch, kb_dir):
|
|
"""A template must not scaffold the links or footnotes regions. They are
|
|
generated between markers from frontmatter and re-rendered on every write,
|
|
so a scaffolded copy would be a section the author may not edit and the tool
|
|
would replace anyway - and, before the markers existed, a second one it
|
|
appended beside."""
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "passerelle", "--set", "entity_type=system",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
_fm, body = read_page(kb_dir / "entities/systems/passerelle.md")
|
|
assert "wikitool:links" not in body
|
|
assert "wikitool:footnotes" not in body
|
|
assert "{section." not in body
|
|
|
|
|
|
def test_new_entity_applies_schema_declared_defaults(monkeypatch, kb_dir):
|
|
"""provenance and confidence are no longer Typer flag defaults - they
|
|
come from the schema's own `default:`, so omitting them still yields a
|
|
valid page."""
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "Defaulted", "--set", "entity_type=tool",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
fm, _body = read_page(kb_dir / "entities/tools/Defaulted.md")
|
|
assert fm["provenance"] == "general"
|
|
assert fm["confidence"] == 0.5
|
|
|
|
|
|
def test_new_entity_rejects_name_collision(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "aurora", "--set", "entity_type=system",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
def test_new_entity_rejects_invalid_entity_type(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "X", "--set", "entity_type=bogus",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
def test_new_entity_rejects_missing_required_field(monkeypatch, kb_dir):
|
|
"""entity_type is required by the schema; omitting it now fails at
|
|
schema-validation time rather than Typer argument-parsing time."""
|
|
result = _invoke_new(monkeypatch, kb_dir, ["new", "entity", "--name", "X"])
|
|
assert result.exit_code != 0
|
|
assert "entity_type" in result.output
|
|
|
|
|
|
def test_new_rejects_unknown_type_name(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, ["new", "bogus", "--name", "X"])
|
|
assert result.exit_code != 0
|
|
assert "No type-spec named 'bogus'" in result.output
|
|
|
|
|
|
def test_new_entity_rejects_invalid_type_path(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--type", "bogus", "--name", "X", "--set", "entity_type=system",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
def test_new_entity_rejects_invalid_provenance(monkeypatch, kb_dir):
|
|
"""provenance validity comes solely from the schema's enum."""
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "X", "--set", "entity_type=system",
|
|
"--set", "provenance=bogus",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
def test_new_rejects_malformed_set_pair(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "X", "--set", "entity_type",
|
|
])
|
|
assert result.exit_code != 0
|
|
assert "field=value" in result.output
|
|
|
|
|
|
def test_new_rejects_unknown_frontmatter_field(monkeypatch, kb_dir):
|
|
"""additionalProperties: false means a typo'd --set field is rejected by
|
|
the schema rather than silently written."""
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "X", "--set", "entity_type=tool",
|
|
"--set", "notafield=value",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
def _fixture_raw_file(monkeypatch, kb_dir, relative: str) -> None:
|
|
"""`_check_raw_files_exist` resolves `raw_files:` against `config.ROOT`,
|
|
which `_invoke_new` never patches (only `KB_DIR` needs it - see its
|
|
docstring, and this deliberately doesn't touch it for every other test).
|
|
Patching `ROOT` to the fixture root too and writing the referenced file
|
|
there keeps these two tests self-contained, instead of depending on a
|
|
real file in this checkout's own `raw/` - which a contentless
|
|
distribution does not have."""
|
|
import chemenu.config as config
|
|
|
|
from chemenu.tests.conftest import use_shipped_type_specs
|
|
|
|
monkeypatch.setattr(config, "ROOT", kb_dir.parent)
|
|
use_shipped_type_specs(monkeypatch)
|
|
path = kb_dir.parent / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("raw fixture content\n", encoding="utf-8")
|
|
|
|
|
|
def test_new_source_prefixes_title_and_prefills_related_entities(monkeypatch, kb_dir):
|
|
monkeypatch.setenv("WIKI_AUTHOR", "Torben")
|
|
_fixture_raw_file(monkeypatch, kb_dir, "raw/notes/gateway.example.net.md")
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "source", "--name", "gateway.example.net",
|
|
"--set", "raw_files=raw/notes/gateway.example.net.md",
|
|
"--set", "entities=aurora,Borealis",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
path = kb_dir / "sources/Source - gateway.example.net.md"
|
|
assert path.exists()
|
|
fm, body = read_page(path)
|
|
assert fm["type"] == "types/source.md"
|
|
assert fm["entities"] == ["aurora", "Borealis"]
|
|
assert fm["author"] == "Torben" # WIKI_AUTHOR override
|
|
assert fm["source_type"] == "notes" # schema default
|
|
assert "[[aurora]]" in body
|
|
assert "[[Borealis]]" in body
|
|
|
|
|
|
def test_new_source_author_falls_back_to_git_config(monkeypatch, kb_dir):
|
|
"""No WIKI_AUTHOR set - default_author() falls back to `git config
|
|
user.name`, run with cwd=config.ROOT.
|
|
|
|
The fixture root is made a real repo with a *local* user.name, so the
|
|
assertion is about the fallback and not about whoever happens to run the
|
|
suite: an earlier version leaned on the machine's global git config and
|
|
failed in CI, where the job container has none.
|
|
"""
|
|
import subprocess
|
|
|
|
root = kb_dir.parent
|
|
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=root, check=True)
|
|
subprocess.run(["git", "config", "user.name", "Fixture Author"],
|
|
cwd=root, check=True)
|
|
|
|
monkeypatch.delenv("WIKI_AUTHOR", raising=False)
|
|
_fixture_raw_file(monkeypatch, kb_dir, "raw/notes/gateway.example.net.md")
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "source", "--name", "git-config-author",
|
|
"--set", "raw_files=raw/notes/gateway.example.net.md",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
fm, _body = read_page(kb_dir / "sources/Source - git-config-author.md")
|
|
assert fm["author"] == "Fixture Author"
|
|
|
|
|
|
def test_new_source_fails_hard_without_any_author(monkeypatch, kb_dir):
|
|
"""Neither WIKI_AUTHOR nor a resolvable git config user.name - `new`
|
|
must fail loudly instead of stamping a placeholder author."""
|
|
import chemenu.config as config
|
|
|
|
monkeypatch.delenv("WIKI_AUTHOR", raising=False)
|
|
monkeypatch.setattr(config, "default_author", lambda: None)
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "source", "--name", "no-author",
|
|
"--set", "raw_files=raw/notes/gateway.example.net.md",
|
|
])
|
|
assert result.exit_code == 1
|
|
assert "author" in result.output.lower()
|
|
assert not (kb_dir / "sources/Source - no-author.md").exists()
|
|
|
|
|
|
def test_new_source_rejects_invalid_source_type(monkeypatch, kb_dir):
|
|
"""source_type validity comes solely from the schema's enum now."""
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "source", "--name", "X",
|
|
"--set", "raw_files=raw/notes/gateway.example.net.md",
|
|
"--set", "source_type=bogus",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
def test_new_concept_creates_page(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "concept", "--name", "Event Sourcing", "--set", "concept_type=pattern",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
assert (kb_dir / "concepts/Event Sourcing.md").exists()
|
|
|
|
|
|
def test_new_concept_rejects_invalid_concept_type(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "concept", "--name", "X", "--set", "concept_type=bogus",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
def test_new_concept_rejects_invalid_provenance(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "concept", "--name", "X", "--set", "concept_type=pattern",
|
|
"--set", "provenance=bogus",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
def test_new_comparison_renders_table_columns(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "comparison", "--name", "A vs B", "--set", "entities=aurora,Borealis",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
fm, body = read_page(kb_dir / "comparisons/A vs B.md")
|
|
assert fm["entities"] == ["aurora", "Borealis"]
|
|
# Table columns are rendered by the generic table_* template filters.
|
|
assert "[[aurora]] | [[Borealis]]" in body
|
|
|
|
|
|
def test_new_comparison_rejects_single_entity(monkeypatch, kb_dir):
|
|
"""minItems: 2 is enforced by the schema itself - there is no separate
|
|
hand-written cardinality check any more."""
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "comparison", "--name", "Solo", "--set", "entities=aurora",
|
|
])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
def test_repeated_set_appends_for_array_fields():
|
|
"""The separator-free way to pass an element containing a comma. The
|
|
schema type decides: only array fields append."""
|
|
schema = {"properties": {"raw_files": {"type": "array"}, "confidence": {"type": "number"}}}
|
|
parsed = parse_set_fields(
|
|
["raw_files=raw/a.md", "raw_files=raw/b, with comma.md", "confidence=0.5", "confidence=0.9"],
|
|
schema,
|
|
)
|
|
assert parsed["raw_files"] == ["raw/a.md", "raw/b", "with comma.md"]
|
|
assert parsed["confidence"] == 0.9
|
|
|
|
|
|
def test_repeated_set_with_escaped_comma_keeps_one_element():
|
|
schema = {"properties": {"raw_files": {"type": "array"}}}
|
|
parsed = parse_set_fields([r"raw_files=raw/notes/Versioning\, CI-CD.md"], schema)
|
|
assert parsed["raw_files"] == ["raw/notes/Versioning, CI-CD.md"]
|
|
|
|
|
|
def test_raw_files_error_points_at_the_comma_split(monkeypatch, kb_dir, raw_dir):
|
|
"""The original error named a path nobody had typed - half of one, cut at a
|
|
comma - with nothing saying where the other half went."""
|
|
import chemenu.config as config
|
|
|
|
# This test is not about authorship; supply an identity so it cannot
|
|
# depend on the caller's git config (see the test-hardening issue).
|
|
monkeypatch.setenv("WIKI_AUTHOR", "Fixture Author")
|
|
|
|
monkeypatch.setattr(config, "ROOT", kb_dir.parent)
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "source", "--name", "Split Path",
|
|
"--set", "raw_files=raw/notes/Versioning, CI-CD.md",
|
|
])
|
|
assert result.exit_code == 1
|
|
assert "splitting the value on commas" in result.output
|
|
assert "never rename the raw file" in result.output
|
|
|
|
|
|
def test_source_page_accepts_a_raw_file_whose_name_has_a_comma(monkeypatch, kb_dir, raw_dir):
|
|
import chemenu.config as config
|
|
|
|
# This test is not about authorship; supply an identity so it cannot
|
|
# depend on the caller's git config (see the test-hardening issue).
|
|
monkeypatch.setenv("WIKI_AUTHOR", "Fixture Author")
|
|
|
|
(raw_dir / "notes" / "Versioning, CI-CD.md").write_text("# notes\n", encoding="utf-8")
|
|
monkeypatch.setattr(config, "ROOT", kb_dir.parent)
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "source", "--name", "Comma Source",
|
|
"--set", r"raw_files=raw/notes/Versioning\, CI-CD.md",
|
|
"--set", "source_type=notes",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
frontmatter, _ = read_page(kb_dir / "sources/Source - Comma Source.md")
|
|
assert frontmatter["raw_files"] == ["raw/notes/Versioning, CI-CD.md"]
|