Files
chemenu/tools/chemenu/tests/test_new_page.py
T
torben dd885db625
CI / verify (push) Successful in 1m39s
Release / release (push) Successful in 37s
docs: layout comments name all four shipped layout types; #150 changelog pointer and test docstring corrected
Files changed:
- CHANGES.md
- VERSION
- tools/chemenu/commands/new_page.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/type_resolver.py
2026-09-26 23:05:47 +02:00

923 lines
40 KiB
Python

import contextlib
import http.server
import json
import re
import threading
from pathlib import Path
import pytest
from chemenu import cli_contract
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()
_TASKS_THRESHOLDS = {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5,
}
_API_TOKEN = "test-token"
def _write_tasks_config(root, base_url: str) -> None:
"""A minimal `.wikitool-tasks.json` pointing the superproductivity
provider's `access: "api"` path at `base_url` (Gitea #133) - `new
project` only ever has a write path on that access mode. Duplicated
locally rather than imported since test files in this suite do not
import each other (see `instructions/dev/testing-conventions.md` for the
isolation this mirrors one layer up)."""
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "api", "api_base_url": base_url, "api_token": _API_TOKEN},
}),
encoding="utf-8",
)
def _write_tasks_config_snapshot(root, backups_dir) -> None:
"""The other access path (Gitea #133) - used only by the tests below
that confirm `new project` refuses entirely against it."""
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
)
def _write_sp_backup(root, project_titles: list[str]) -> Path:
"""A Super Productivity backup snapshot, named the way `electron/backup.ts`
actually names it (`YYYY-MM-DD_HHmmss.json`, Gitea #133), whose `project`
entity state holds one project per title in `project_titles` and nothing
else. Used only by the `access: "snapshot"` refusal tests - that path
never reaches a tracker write regardless of what this file contains."""
backups_dir = root / "backups"
backups_dir.mkdir(exist_ok=True)
projects = {
f"p{i}": {"id": f"p{i}", "title": title, "created": 1700000000000}
for i, title in enumerate(project_titles)
}
(backups_dir / "2026-01-01_000000.json").write_text(
json.dumps({
"project": {"ids": list(projects), "entities": projects},
"task": {"ids": [], "entities": {}},
"tag": {"ids": [], "entities": {}},
}),
encoding="utf-8",
)
return backups_dir
def _make_api_handler(state: dict):
class Handler(http.server.BaseHTTPRequestHandler):
posted = False
def do_GET(self): # noqa: N802 - stdlib method name
if self.path == "/health":
self._reply(200, {"ok": True})
return
if self.headers.get("Authorization") != f"Bearer {_API_TOKEN}":
self._reply(401, {"error": "unauthorized"})
return
if self.path == "/projects":
self._reply(200, state["projects"])
return
if self.path in ("/tasks", "/tags"):
self._reply(200, [])
return
self._reply(404, {"error": "not found"})
def do_POST(self): # noqa: N802
# `new project` must never attempt this on the API path either -
# there is no POST /projects endpoint upstream (Gitea #124/#133).
Handler.posted = True
self._reply(404, {"error": "no such endpoint (fixture)"})
def _reply(self, code: int, payload) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # silence stderr noise during the test run
pass
return Handler
@contextlib.contextmanager
def _api_server(project_titles: list[str]):
"""A stub Super Productivity local REST API serving just enough of
`GET /projects`/`/tasks`/`/tags` for `find_project`/`create_project`'s
own preflight - the only thing `new project`'s tracker step reads."""
state = {"projects": [
{"id": f"p{i}", "title": title, "created": 1700000000000, "taskIds": [], "backlogTaskIds": []}
for i, title in enumerate(project_titles)
]}
handler_cls = _make_api_handler(state)
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server, handler_cls
finally:
server.shutdown()
thread.join(timeout=2)
def _base_url(server: http.server.HTTPServer) -> str:
return f"http://127.0.0.1:{server.server_address[1]}"
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():
# `comparison` has no `layout:` at all (and no `subtype_field:` to have one
# for) - `types/source.md` no longer qualifies, since Gitea #66 gave it a
# `layout:` covering every enum value.
assert _page_subdir(None, "types/comparison.md") is None
assert _page_subdir("anything", "types/comparison.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", "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 "# gateway.example.net" in body
def test_new_project_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
"""Gitea #123: `state:` is required with a schema `default: active`, so it
must materialize even though the caller never sets it - the same rule
`--set entity_type=...`'s required fields already follow."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Testvorhaben", "--set", "responsibility=haus",
])
assert result.exit_code == 0, result.output
path = kb_dir / "gtd/haus/Testvorhaben.md"
assert path.exists()
fm, body = read_page(path)
assert fm["type"] == "types/project.md"
assert fm["state"] == "active"
assert fm["responsibility"] == "haus"
assert "# Testvorhaben" in body
for heading in ("Ziel", "Kontext", "Beteiligte", "Status", "Entscheidungen", "Gelerntes"):
assert f"## {heading}" in body
def test_new_project_requires_responsibility(monkeypatch, kb_dir):
"""Gitea #126's own AC: `--responsibility` (via `--set responsibility=...`)
is required. `types/project.schema.yaml` already lists it in `required:`
(#123) with no `default:`, so this needs no new code - only a test that
the generic schema-required refusal actually covers it for `project`."""
result = _invoke_new(monkeypatch, kb_dir, ["new", "project", "--name", "Ohne Bereich"])
assert result.exit_code == 1
assert not list(kb_dir.rglob("Ohne Bereich.md"))
def test_new_project_refuses_a_responsibility_outside_the_enum(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Badvorhaben", "--set", "responsibility=nichtexistent",
])
assert result.exit_code == 1
assert not list(kb_dir.rglob("Badvorhaben.md"))
# --- Gitea #126: `new project` and the task tracker --------------------------
def test_new_project_says_explicitly_when_no_tracker_is_configured(monkeypatch, kb_dir):
"""No `.wikitool-tasks.json` at all - the page-only state is legitimate
(#126's own AC) but must be said, not left implicit."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Ohne Tracker", "--set", "responsibility=haus",
])
assert result.exit_code == 0, result.output
assert "no task tracker configured" in result.output
assert (kb_dir / "gtd/haus/Ohne Tracker.md").exists()
def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypatch, kb_dir):
"""Super Productivity can never create a project itself (Gitea #124) -
the first attempt against a free name must exit 42 and create nothing on
either side, and never attempt a POST (Gitea #133: no such endpoint
exists on the API path either)."""
root = kb_dir.parent
with _api_server([]) as (server, handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 42
assert "NEEDS USER CLEARANCE" in result.output
assert "--resume" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
assert handler_cls.posted is False
def test_new_project_refuses_a_tracker_collision_without_resume(monkeypatch, kb_dir):
"""The tracker already has this name (case-insensitively) and --resume
was not passed - #126's AC: refuse, name the collision, create nothing."""
root = kb_dir.parent
with _api_server(["kueche renovieren"]) as (server, _handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 1
assert "already exists" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypatch, kb_dir):
"""The retry half of the HumanInterventionRequired dance (#126's own
"Schritt 2"): once a human has created the tracker project by hand, a
re-run with --resume must verify it via the read path and continue to
page creation instead of treating it as a collision."""
root = kb_dir.parent
with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 0, result.output
assert "already existed" in result.output
assert (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monkeypatch, kb_dir):
"""--resume against a tracker that still does not have the project must
read the same as a fresh attempt - the same HumanInterventionRequired
message again, not a silent pass-through (#126's own wording: "wirft das
Kommando dieselbe HumanInterventionRequired-Meldung erneut")."""
root = kb_dir.parent
with _api_server([]) as (server, _handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 42
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
"""Gitea #133: `access: "snapshot"` is read-only from here - `new
project` creates neither a tracker project nor a page and exits 1, not
42 (nothing is waiting on a human's clearance, the command simply cannot
do this from a snapshot instance), pointing at an access: "api" one."""
root = kb_dir.parent
backups_dir = _write_sp_backup(root, [])
_write_tasks_config_snapshot(root, backups_dir)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 1
assert "access: 'api'" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_resume_also_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
"""--resume changes nothing about the snapshot-access refusal (Gitea
#133's own AC) - even against a tracker that already has the project."""
root = kb_dir.parent
backups_dir = _write_sp_backup(root, ["Kueche renovieren"])
_write_tasks_config_snapshot(root, backups_dir)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 1
assert "access: 'api'" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_resume_flag_is_refused_for_any_type_other_than_project(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "Irrelevant", "--set", "entity_type=tool", "--resume",
])
assert result.exit_code == 1
assert "--resume" in result.output
assert not list(kb_dir.rglob("Irrelevant.md"))
def test_new_project_never_leaves_only_the_page_when_the_write_fails(monkeypatch, kb_dir):
"""#126's own atomicity AC, forced: step 3 (the file write) fails after
step 2 (the tracker side) already stands - here, an already-existing
tracker project confirmed via --resume. The outcome must never be "only
the page" - here it is neither, since the write itself never lands."""
import chemenu.commands.new_page as new_page
root = kb_dir.parent
def _boom(path, frontmatter, body):
raise OSError("disk full (fixture)")
monkeypatch.setattr(new_page, "write_page", _boom)
with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 1
assert "already confirmed to exist" in " ".join(result.output.split())
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_entity_still_materializes_empty_arrays_for_unset_optional_fields(monkeypatch, kb_dir):
"""Gitea #109 stops materializing an optional field's schema `default:`,
but `tags`/`related`/`sources` are optional arrays with no `default:` at
all - they must keep landing as `[]`, not disappear. Their absence would
make `_apply_template_variables` fall back to the filter suffix rendered
literally (`{related|bullets}` -> the word "bullets" left in the body)."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "Bare", "--set", "entity_type=tool",
])
assert result.exit_code == 0, result.output
fm, body = read_page(kb_dir / "entities/tools/Bare.md")
assert fm["tags"] == []
assert fm["related"] == []
assert fm["sources"] == []
assert "bullets" not 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 is no longer a Typer flag default - it comes from the
schema's own `default:`, so omitting it 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"
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", "source_type=notes",
"--set", "raw_files=raw/notes/gateway.example.net.md",
"--set", "fidelity=verbatim", "--set", "authority=reporting",
"--set", "entities=aurora,Borealis",
])
assert result.exit_code == 0, result.output
# Gitea #66: `types/source.md` now declares a `layout:`, so `source_type:
# notes` lands in its area - the same placement rule an entity or concept
# already follows.
path = kb_dir / "sources/notes/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"
assert "[[aurora]]" in body
assert "[[Borealis]]" in body
def test_new_source_rejects_missing_source_type(monkeypatch, kb_dir):
"""Gitea #66: `source_type` lost its `default: notes` - `new` must refuse
rather than silently picking a value, and must not write a file or create
a directory while refusing."""
monkeypatch.setenv("WIKI_AUTHOR", "Torben") # isolate the refusal from the author check
_fixture_raw_file(monkeypatch, kb_dir, "raw/notes/gateway.example.net.md")
result = _invoke_new(monkeypatch, kb_dir, [
"new", "source", "--name", "no-source-type",
"--set", "raw_files=raw/notes/gateway.example.net.md",
"--set", "fidelity=verbatim", "--set", "authority=reporting",
])
assert result.exit_code != 0
assert "source_type" in result.output
# No page written anywhere under sources/ - not even into an area that
# legitimately pre-exists (the fixture wiki's own `sources/notes/`).
assert not list(kb_dir.glob("sources/**/Source - no-source-type.md"))
def test_new_source_rejects_missing_capture_fields(monkeypatch, kb_dir):
"""Gitea #67: `fidelity`/`authority` have no `default:` and are not in
`required:` either (a required field would be boundary-crossing, see
version-parts.md) - the refusal is enforced by `new` itself instead."""
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", "no-capture-fields",
"--set", "source_type=notes",
"--set", "raw_files=raw/notes/gateway.example.net.md",
])
assert result.exit_code != 0
assert "fidelity" in result.output
assert "authority" in result.output
assert not list(kb_dir.glob("sources/**/Source - no-capture-fields.md"))
def test_new_source_rejects_unknown_as_a_capture_value(monkeypatch, kb_dir):
"""`unknown` is backfill-only - only `wikitool touch` may write it, on a
page predating this rule (Gitea #67)."""
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", "unknown-capture",
"--set", "source_type=notes",
"--set", "raw_files=raw/notes/gateway.example.net.md",
"--set", "fidelity=unknown", "--set", "authority=reporting",
])
assert result.exit_code != 0
assert "unknown" in result.output
assert not list(kb_dir.glob("sources/**/Source - unknown-capture.md"))
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", "source_type=notes",
"--set", "raw_files=raw/notes/gateway.example.net.md",
"--set", "fidelity=verbatim", "--set", "authority=reporting",
])
assert result.exit_code == 0, result.output
fm, _body = read_page(kb_dir / "sources/notes/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_in_its_subtype_area(monkeypatch, kb_dir):
"""Gitea #59: `types/concept.md` declares a `layout:` now, so a new concept
reaches its area with nothing else asked of the author - the same rule that
has always placed an entity. Nothing about `new` changed to make this true;
the type-spec did."""
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/patterns/Event Sourcing.md").exists()
assert not (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
# --- the `new` record's own path notes match what `new` actually writes
# (Gitea #150) ---
#
# Coupled to the record's notes text, not to a path hand-copied into the
# test - the existing per-type tests above (e.g.
# `test_new_concept_creates_page_in_its_subtype_area`) assert a path against
# the *code*, so a note that drifted out of sync with the code would still
# pass every one of them. This reads the note itself and turns it into the
# pattern it promises, so a drift like #150's (the `concept`/`source` notes
# lost their `<subdir>` when `layout:` was added to their type-specs) fails
# here even if every code-level path test stays green.
def _path_pattern_from_note(notes: str, name: str) -> re.Pattern:
"""The first backtick-quoted span in a `new` variant's notes is its path
template. `<Name>` becomes the literal name under test; any other
`<...>` placeholder (a type-spec-computed subdirectory) becomes a
wildcard segment. A note with no placeholder at all (`comparison`, which
genuinely has none) is matched as the literal path it names. The leading
`kb/` is stripped: the actual path under test is already relative to
`kb_dir`, which *is* that root."""
template = re.search(r"`([^`]+)`", notes).group(1)
assert template.startswith("kb/"), template
template = template[len("kb/"):]
pattern = "".join(
re.escape(name) if part == "<Name>"
else r"[^/]+" if re.fullmatch(r"<[^>]+>", part)
else re.escape(part)
for part in re.split(r"(<[^>]+>)", template)
)
return re.compile(pattern + r"\Z")
_NEW_PATH_VARIANTS = [
v for v in cli_contract.get("new").synopsis if v.notes.startswith("Writes `kb/")
]
def _new_variant_notes(usage_prefix: str) -> str:
for variant in _NEW_PATH_VARIANTS:
if variant.usage.startswith(usage_prefix):
return variant.notes
raise AssertionError(f"no `new` record variant starts with {usage_prefix!r}")
def test_every_writes_kb_variant_has_a_path_case():
"""A future variant whose notes promise a `kb/` path but that nobody
parametrized below would otherwise go unchecked - same failure shape as
#150 itself, one level up."""
covered_prefixes = ("new entity ", "new concept ", "new source ", "new comparison ", "new project ")
uncovered = [
v.usage for v in _NEW_PATH_VARIANTS
if not any(v.usage.startswith(p) for p in covered_prefixes)
]
assert uncovered == []
def test_new_entity_path_matches_its_record_note(monkeypatch, kb_dir):
name = "Widget"
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", name, "--set", "entity_type=tool",
])
assert result.exit_code == 0, result.output
pattern = _path_pattern_from_note(_new_variant_notes("new entity "), name)
written = next(kb_dir.glob(f"entities/**/{name}.md"))
assert pattern.match(str(written.relative_to(kb_dir)))
def test_new_concept_path_matches_its_record_note(monkeypatch, kb_dir):
name = "Event Sourcing"
result = _invoke_new(monkeypatch, kb_dir, [
"new", "concept", "--name", name, "--set", "concept_type=pattern",
])
assert result.exit_code == 0, result.output
pattern = _path_pattern_from_note(_new_variant_notes("new concept "), name)
written = next(kb_dir.glob(f"concepts/**/{name}.md"))
assert pattern.match(str(written.relative_to(kb_dir)))
def test_new_source_path_matches_its_record_note_and_its_usage_is_runnable(monkeypatch, kb_dir):
"""Also #150's second finding: the `usage` line itself omitted
`source_type`/`fidelity`/`authority` and could never succeed as written.
The call below passes exactly the fields the corrected `usage` names,
with valid values for its placeholders - it does not parse the `usage`
string itself."""
monkeypatch.setenv("WIKI_AUTHOR", "Torben")
name = "gateway.example.net"
_fixture_raw_file(monkeypatch, kb_dir, "raw/notes/gateway.example.net.md")
result = _invoke_new(monkeypatch, kb_dir, [
"new", "source", "--name", name,
"--set", "source_type=notes",
"--set", "raw_files=raw/notes/gateway.example.net.md",
"--set", "fidelity=verbatim", "--set", "authority=reporting",
])
assert result.exit_code == 0, result.output
pattern = _path_pattern_from_note(_new_variant_notes("new source "), name)
written = next(kb_dir.glob(f"sources/**/Source - {name}.md"))
assert pattern.match(str(written.relative_to(kb_dir)))
def test_new_comparison_path_matches_its_record_note(monkeypatch, kb_dir):
"""`comparison` has no `layout:`, so its note names a literal path with
no placeholder - the name used here is exactly what that literal names,
which is what makes this case a genuine check rather than a tautology:
a `layout:` added to `types/comparison.md` without updating the note
would make the literal not match the now-nested actual path."""
name = "X vs Y"
result = _invoke_new(monkeypatch, kb_dir, [
"new", "comparison", "--name", name, "--set", "entities=aurora,Borealis",
])
assert result.exit_code == 0, result.output
pattern = _path_pattern_from_note(_new_variant_notes("new comparison "), name)
written = kb_dir / "comparisons" / f"{name}.md"
assert written.exists()
assert pattern.match(str(written.relative_to(kb_dir)))
def test_new_project_path_matches_its_record_note(monkeypatch, kb_dir):
name = "Testvorhaben"
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", name, "--set", "responsibility=haus",
])
assert result.exit_code == 0, result.output
pattern = _path_pattern_from_note(_new_variant_notes("new project "), name)
written = next(kb_dir.glob(f"gtd/**/{name}.md"))
assert pattern.match(str(written.relative_to(kb_dir)))
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"}, "weight": {"type": "number"}}}
parsed = parse_set_fields(
["raw_files=raw/a.md", "raw_files=raw/b, with comma.md", "weight=0.5", "weight=0.9"],
schema,
)
assert parsed["raw_files"] == ["raw/a.md", "raw/b", "with comma.md"]
assert parsed["weight"] == 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", "source_type=notes",
"--set", "raw_files=raw/notes/Versioning, CI-CD.md",
"--set", "fidelity=verbatim", "--set", "authority=reporting",
])
assert result.exit_code == 1
assert "splitting the value on commas" in result.output
assert "never rename the raw file" in result.output
def _invoke_new_instruction(monkeypatch, tmp_path, args):
"""Invoke `new` for a `root: repo` type. `instruction` resolves its
`base_dir:` against `config.ROOT`, not `config.KB_DIR` - unlike
`_invoke_new`'s callers, patching `KB_DIR` alone would leave the scaffold
writing into this checkout's real `instructions/` (Gitea #109's fixture
note). Repointing `ROOT` pulls `TYPES_DIR` along with it, so
`use_shipped_type_specs` restores the real, shipped type-specs."""
import chemenu.config as config
from chemenu.cli import app
from chemenu.tests.conftest import use_shipped_type_specs
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
(tmp_path / "instructions").mkdir(parents=True, exist_ok=True)
return runner.invoke(app, args)
def test_new_instruction_omits_migration_only_default(monkeypatch, tmp_path):
"""Gitea #109: `obligation:` is a migration-only field (`instructions/
migrations/*`) with a schema `default:` but no `required:` entry. The
scaffold must not materialize it into an ordinary instruction."""
result = _invoke_new_instruction(monkeypatch, tmp_path, [
"new", "instruction", "--name", "probe",
])
assert result.exit_code == 0, result.output
fm, _body = read_page(tmp_path / "instructions/probe.md")
assert "obligation" not in fm
def test_new_instruction_explicit_obligation_is_still_written(monkeypatch, tmp_path):
"""The rule only suppresses the *implicit* default - an explicit
`--set obligation=offered` (as when hand-scaffolding a migration
document) must still land in the frontmatter."""
result = _invoke_new_instruction(monkeypatch, tmp_path, [
"new", "instruction", "--name", "probe-migration",
"--set", "obligation=offered",
])
assert result.exit_code == 0, result.output
fm, _body = read_page(tmp_path / "instructions/probe-migration.md")
assert fm["obligation"] == "offered"
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",
"--set", "fidelity=verbatim", "--set", "authority=reporting",
])
assert result.exit_code == 0, result.output
frontmatter, _ = read_page(kb_dir / "sources/notes/Source - Comma Source.md")
assert frontmatter["raw_files"] == ["raw/notes/Versioning, CI-CD.md"]