Files changed: - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/commands/doctor.py - tools/chemenu/tasks/superproductivity.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_superproductivity.py - tools/chemenu/tests/test_task_cmd.py
1041 lines
45 KiB
Python
1041 lines
45 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, {"server": "up", "rendererReady": 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:
|
|
# The app's real envelope (v19.1.0, Gitea #162).
|
|
if code < 400:
|
|
envelope = {"ok": True, "data": payload}
|
|
else:
|
|
envelope = {"ok": False, "error": {"code": "ERROR", "message": str(payload)}}
|
|
body = json.dumps(envelope).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"]
|
|
|
|
|
|
def _tree_snapshot(root: Path) -> dict[str, bytes]:
|
|
return {str(p.relative_to(root)): p.read_bytes() for p in sorted(root.rglob("*")) if p.is_file()}
|
|
|
|
|
|
def test_new_refuses_a_forbidden_character_and_writes_nothing(monkeypatch, kb_dir):
|
|
before = _tree_snapshot(kb_dir)
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "A: B", "--set", "entity_type=system",
|
|
])
|
|
assert result.exit_code == 1
|
|
assert "':'" in result.output
|
|
assert _tree_snapshot(kb_dir) == before
|
|
|
|
|
|
def test_new_refuses_a_slash_and_creates_no_directory(monkeypatch, kb_dir):
|
|
before = sorted(p for p in kb_dir.rglob("*"))
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "A/B", "--set", "entity_type=system",
|
|
])
|
|
assert result.exit_code == 1
|
|
assert sorted(kb_dir.rglob("*")) == before
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["Index", "index", "collection", "COM¹", "CON", "Trailing.", ""])
|
|
def test_new_refuses_reserved_and_malformed_names(monkeypatch, kb_dir, name):
|
|
before = _tree_snapshot(kb_dir)
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", name, "--set", "entity_type=system",
|
|
])
|
|
assert result.exit_code == 1, result.output
|
|
assert "cannot be a page title" in result.output
|
|
assert _tree_snapshot(kb_dir) == before
|
|
|
|
|
|
def test_new_checks_the_prefixed_title_not_the_bare_name(monkeypatch, kb_dir):
|
|
"""`CON` is a device name, `Source - CON` is not."""
|
|
monkeypatch.setenv("WIKI_AUTHOR", "Torben")
|
|
_fixture_raw_file(monkeypatch, kb_dir, "raw/notes/con.md")
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "source", "--name", "CON",
|
|
"--set", "source_type=notes", "--set", "raw_files=raw/notes/con.md",
|
|
"--set", "fidelity=verbatim", "--set", "authority=reporting",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
assert (kb_dir / "sources/notes/Source - CON.md").exists()
|
|
|
|
|
|
def test_new_refuses_a_title_that_differs_only_by_case(monkeypatch, kb_dir):
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", "AURORA", "--set", "entity_type=tool",
|
|
])
|
|
assert result.exit_code == 1
|
|
assert "aurora" in result.output
|
|
assert not list(kb_dir.rglob("AURORA.md"))
|
|
|
|
|
|
def test_new_refuses_an_nfd_title_against_an_nfc_page(monkeypatch, kb_dir):
|
|
import unicodedata
|
|
|
|
from chemenu.frontmatter_io import write_page
|
|
|
|
nfc = unicodedata.normalize("NFC", "Café")
|
|
write_page(
|
|
kb_dir / "entities/tools" / f"{nfc}.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": []},
|
|
f"\n# {nfc}\n",
|
|
)
|
|
result = _invoke_new(monkeypatch, kb_dir, [
|
|
"new", "entity", "--name", unicodedata.normalize("NFD", "Café"),
|
|
"--set", "entity_type=system",
|
|
])
|
|
assert result.exit_code == 1
|
|
assert "already exists" in result.output
|
|
|
|
|
|
def test_new_project_with_a_bad_title_never_reaches_the_tracker(monkeypatch, kb_dir):
|
|
"""The tracker project is created before the page, so the title has to be
|
|
refused first - or a refused page would leave a project behind."""
|
|
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", "A: B", "--set", "responsibility=haus",
|
|
])
|
|
assert result.exit_code == 1, result.output
|
|
assert "cannot be a page title" in result.output
|
|
assert handler_cls.posted is False
|
|
assert not list(kb_dir.rglob("A: B.md"))
|
|
|
|
|
|
@pytest.mark.parametrize("name, existing", [("gates", "gates.md"), ("contract", "CONTRACT.md")])
|
|
def test_new_instruction_never_overwrites_an_existing_file(monkeypatch, tmp_path, name, existing):
|
|
"""`root: repo` types are outside the title namespace of kb/, but writing
|
|
over a file there is the same loss (found by reproducing #155)."""
|
|
(tmp_path / "instructions").mkdir()
|
|
original = "# original, hand-written\n"
|
|
(tmp_path / "instructions" / existing).write_text(original, encoding="utf-8")
|
|
|
|
result = _invoke_new_instruction(monkeypatch, tmp_path, ["new", "instruction", "--name", name])
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "already exists" in result.output
|
|
assert (tmp_path / "instructions" / existing).read_text(encoding="utf-8") == original
|
|
assert sorted(p.name for p in (tmp_path / "instructions").iterdir()) == [existing]
|
|
|
|
|
|
def test_new_instruction_applies_the_title_rule_too(monkeypatch, tmp_path):
|
|
result = _invoke_new_instruction(monkeypatch, tmp_path, ["new", "instruction", "--name", "a:b"])
|
|
assert result.exit_code == 1
|
|
assert list((tmp_path / "instructions").iterdir()) == []
|