24cd221b21
Files changed: - CHANGES.md - VERSION - kb/entities/projects/Chemenu.md - kb/log.md - tools/chemenu/commands/_util.py - tools/chemenu/commands/cite_cmd.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/log_append.py - tools/chemenu/commands/page_ops.py - tools/chemenu/commands/provenance_cmd.py - tools/chemenu/commands/raw_cmd.py - tools/chemenu/commands/run_budget.py - tools/chemenu/commands/touch.py - tools/chemenu/commands/xref.py - tools/chemenu/frontmatter_io.py - tools/chemenu/lint_core.py - tools/chemenu/tests/test_log_append.py - tools/chemenu/tests/test_source_hygiene.py - tools/chemenu/tests/test_touch.py - tools/chemenu/tests/test_type_resolver.py - tools/chemenu/type_resolver.py - tools/wikitool - types/type-spec.md - types/type-spec.schema.yaml
277 lines
11 KiB
Python
277 lines
11 KiB
Python
import datetime
|
|
|
|
import pytest
|
|
import typer
|
|
from typer.testing import CliRunner
|
|
|
|
from chemenu import config
|
|
from chemenu.commands.touch import touch_command
|
|
from chemenu.frontmatter_io import read_page
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def touch_wiki(kb_dir, monkeypatch):
|
|
monkeypatch.setattr(config, "KB_DIR", kb_dir)
|
|
return kb_dir
|
|
|
|
|
|
def _touch(**overrides):
|
|
"""Call the Typer callback with every option supplied.
|
|
|
|
A callback invoked directly from a test receives `OptionInfo` objects for
|
|
whatever the caller leaves out, so the defaults live here instead of being
|
|
repeated in each test - and a new option costs one line rather than one per
|
|
call site. Same hazard `dist_cmd` avoids by keeping its logic beside the
|
|
wrapper.
|
|
"""
|
|
kwargs = dict(
|
|
page_title=None,
|
|
summary=None,
|
|
provenance=None,
|
|
date=None,
|
|
set_fields=None,
|
|
add_fields=None,
|
|
remove_fields=None,
|
|
no_date=False,
|
|
dry_run=False,
|
|
)
|
|
kwargs.update(overrides)
|
|
return touch_command(**kwargs)
|
|
|
|
|
|
def test_touch_bumps_modified(touch_wiki):
|
|
_touch(page_title="aurora")
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
# Unquoted YAML dates round-trip as date objects, matching the rest of kb/.
|
|
assert str(frontmatter["modified"]) == datetime.date.today().isoformat()
|
|
|
|
|
|
def test_touch_updates_summary_and_provenance(touch_wiki):
|
|
_touch(
|
|
page_title="aurora", summary="Now with a better summary", provenance="mixed",
|
|
date="2026-08-13",
|
|
)
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["summary"] == "Now with a better summary"
|
|
assert frontmatter["provenance"] == "mixed"
|
|
assert str(frontmatter["modified"]) == "2026-08-13"
|
|
|
|
|
|
def test_touch_rejects_invalid_provenance(touch_wiki):
|
|
"""Schema validation runs before the write, so a bad value can never land
|
|
on disk the way a hand-edit could."""
|
|
with pytest.raises(typer.Exit):
|
|
_touch(page_title="aurora", provenance="hearsay")
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert "provenance" not in frontmatter or frontmatter["provenance"] != "hearsay"
|
|
|
|
|
|
def test_touch_dry_run_writes_nothing(touch_wiki):
|
|
path = touch_wiki / "entities/systems/aurora.md"
|
|
before = path.read_text(encoding="utf-8")
|
|
_touch(page_title="aurora", summary="ignored", dry_run=True)
|
|
assert path.read_text(encoding="utf-8") == before
|
|
|
|
|
|
def test_touch_fails_on_unknown_page(touch_wiki):
|
|
with pytest.raises(typer.Exit):
|
|
_touch(page_title="Nope")
|
|
|
|
|
|
def test_touch_uses_date_field_for_source_pages(touch_wiki):
|
|
"""Source pages declare `date:`, not `modified:` - the field comes from
|
|
the type's schema rather than a hardcoded name."""
|
|
_touch(page_title="Source - Aurora", date="2026-08-13")
|
|
frontmatter, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
|
|
assert str(frontmatter["date"]) == "2026-08-13"
|
|
assert "modified" not in frontmatter
|
|
|
|
|
|
def test_touch_leaves_a_sources_publication_date_alone(touch_wiki):
|
|
"""A source's `date:` is the publication date of the raw material, not a
|
|
record of when the page was last edited. Auto-bumping it to today replaced a
|
|
fact about the world and left the page contradicting the date printed in its
|
|
own body - so it moves only on an explicit --date."""
|
|
before, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
|
|
_touch(page_title="Source - Aurora", summary="Neue Zusammenfassung")
|
|
frontmatter, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
|
|
assert frontmatter["summary"] == "Neue Zusammenfassung"
|
|
assert str(frontmatter["date"]) == str(before["date"])
|
|
|
|
|
|
# --- --set / --add / --remove -------------------------------------------------
|
|
|
|
|
|
def test_set_replaces_a_field_new_wrote_once(touch_wiki):
|
|
"""The defect this exists for: `tags:` was writable at `new` and never
|
|
again, so a mistyped list was permanent."""
|
|
_touch(page_title="aurora", set_fields=["tags=k8s,storage"])
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["tags"] == ["k8s", "storage"]
|
|
|
|
|
|
def test_set_replaces_rather_than_merging(touch_wiki):
|
|
before, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert before["tags"] == ["server"]
|
|
_touch(page_title="aurora", set_fields=["tags=only-this"])
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["tags"] == ["only-this"]
|
|
|
|
|
|
def test_repeated_set_appends_within_one_call(touch_wiki):
|
|
"""Same rule as `new --set`: the separator-free way to pass an element
|
|
containing a comma."""
|
|
_touch(page_title="aurora", set_fields=["tags=a", "tags=b"])
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["tags"] == ["a", "b"]
|
|
|
|
|
|
def test_set_honours_the_comma_escape(touch_wiki):
|
|
_touch(page_title="aurora", set_fields=[r"tags=one\, two,three"])
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["tags"] == ["one, two", "three"]
|
|
|
|
|
|
def test_add_extends_without_naming_the_whole_list(touch_wiki):
|
|
_touch(page_title="aurora", add_fields=["tags=monitoring"])
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["tags"] == ["server", "monitoring"]
|
|
|
|
|
|
def test_add_is_idempotent(touch_wiki):
|
|
_touch(page_title="aurora", add_fields=["tags=server"])
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["tags"] == ["server"]
|
|
|
|
|
|
def test_remove_drops_an_element(touch_wiki):
|
|
_touch(page_title="aurora", add_fields=["tags=temporary"])
|
|
_touch(page_title="aurora", remove_fields=["tags=temporary"])
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["tags"] == ["server"]
|
|
|
|
|
|
def test_remove_of_an_absent_element_succeeds_and_says_so(touch_wiki, capsys):
|
|
"""Idempotent like `xref remove`, but never silent: a silent no-op looks
|
|
exactly like a successful removal, which is how a typo hides."""
|
|
_touch(page_title="aurora", remove_fields=["tags=never-there"])
|
|
out = capsys.readouterr().out
|
|
assert "not present, nothing removed" in out
|
|
assert "never-there" in out
|
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["tags"] == ["server"]
|
|
|
|
|
|
def test_add_refuses_a_scalar_field(touch_wiki):
|
|
with pytest.raises(typer.Exit):
|
|
_touch(page_title="aurora", add_fields=["summary=more"])
|
|
|
|
|
|
def test_page_reference_fields_are_refused_and_name_xref(touch_wiki, capsys):
|
|
"""`xref` maintains the reverse direction and the body bullets; a bare
|
|
frontmatter write would leave the other half stale."""
|
|
for field in ("related", "sources", "entities", "concepts"):
|
|
with pytest.raises(typer.Exit):
|
|
_touch(page_title="aurora", set_fields=[f"{field}=Borealis"])
|
|
assert "xref" in capsys.readouterr().out
|
|
|
|
|
|
def test_type_is_refused_with_its_owner(touch_wiki, capsys):
|
|
with pytest.raises(typer.Exit):
|
|
_touch(page_title="aurora", set_fields=["type=types/concept.md"])
|
|
assert "page-lifecycle" in capsys.readouterr().out
|
|
|
|
|
|
def test_confidence_base_flag_no_longer_exists(touch_wiki, monkeypatch):
|
|
"""The confidence mechanism is gone (Gitea #60): `--confidence-base` is not
|
|
a denylisted field owned elsewhere, it simply does not exist as an option
|
|
any more - refused by the CLI parser itself, before `touch_command` runs."""
|
|
import chemenu.config as cfg
|
|
from chemenu.cli import app
|
|
|
|
monkeypatch.setattr(cfg, "KB_DIR", touch_wiki)
|
|
result = runner.invoke(app, ["touch", "--page", "aurora", "--confidence-base", "0.9"])
|
|
assert result.exit_code != 0
|
|
assert "confidence-base" in result.output.lower() or "no such option" in result.output.lower()
|
|
|
|
|
|
def test_unknown_field_lists_what_the_page_actually_has(touch_wiki, capsys):
|
|
"""A typo, not a routing problem - so the message answers 'what did I mean'
|
|
rather than naming another command."""
|
|
with pytest.raises(typer.Exit):
|
|
_touch(page_title="aurora", set_fields=["tag=k8s"])
|
|
out = capsys.readouterr().out
|
|
assert "declares no field 'tag'" in out
|
|
assert "tags" in out
|
|
|
|
|
|
def test_set_validates_against_the_schema_before_writing(touch_wiki):
|
|
path = touch_wiki / "entities/systems/aurora.md"
|
|
before = path.read_text(encoding="utf-8")
|
|
with pytest.raises(typer.Exit):
|
|
_touch(page_title="aurora", set_fields=["entity_type=not-a-real-entity-type"])
|
|
assert path.read_text(encoding="utf-8") == before
|
|
|
|
|
|
def test_set_raw_files_checks_the_path_exists(touch_wiki, raw_dir, monkeypatch):
|
|
"""`touch` writes this field now, so it owes the same filesystem check
|
|
`new` does - the schema cannot express it."""
|
|
monkeypatch.setattr(config, "ROOT", touch_wiki.parent)
|
|
with pytest.raises(typer.Exit):
|
|
_touch(page_title="Source - Aurora", set_fields=["raw_files=raw/notes/absent.md"])
|
|
|
|
|
|
def test_set_raw_files_accepts_an_existing_path_with_a_comma(touch_wiki, raw_dir, monkeypatch):
|
|
"""The repair the whole issue started from: a raw file moved, and the page
|
|
pointing at it has to follow without anyone editing frontmatter."""
|
|
monkeypatch.setattr(config, "ROOT", touch_wiki.parent)
|
|
(raw_dir / "notes" / "Versioning, CI-CD.md").write_text("# notes\n", encoding="utf-8")
|
|
_touch(
|
|
page_title="Source - Aurora",
|
|
set_fields=[r"raw_files=raw/notes/Versioning\, CI-CD.md"],
|
|
)
|
|
frontmatter, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
|
|
assert frontmatter["raw_files"] == ["raw/notes/Versioning, CI-CD.md"]
|
|
|
|
|
|
def test_dry_run_covers_set_too(touch_wiki):
|
|
path = touch_wiki / "entities/systems/aurora.md"
|
|
before = path.read_text(encoding="utf-8")
|
|
_touch(page_title="aurora", set_fields=["tags=nope"], dry_run=True)
|
|
assert path.read_text(encoding="utf-8") == before
|
|
|
|
|
|
# --- Capture fields: fill-once, not a UNSETTABLE denylist (Gitea #67) --------
|
|
|
|
|
|
def test_capture_field_is_writable_while_absent(touch_wiki):
|
|
"""The fixture 'Source - Aurora' predates #67 and carries no `fidelity:`
|
|
yet - the backfill path `touch --set fidelity=unknown` (or a real value)
|
|
must succeed exactly once."""
|
|
_touch(page_title="Source - Aurora", set_fields=["fidelity=unknown"])
|
|
frontmatter, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
|
|
assert frontmatter["fidelity"] == "unknown"
|
|
|
|
|
|
def test_capture_field_is_refused_once_already_set(touch_wiki, capsys):
|
|
_touch(page_title="Source - Aurora", set_fields=["authority=reporting"])
|
|
with pytest.raises(typer.Exit):
|
|
_touch(page_title="Source - Aurora", set_fields=["authority=opinion"])
|
|
out = capsys.readouterr().out
|
|
assert "capture field" in out
|
|
assert "raw accept --replaces" in out
|
|
frontmatter, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
|
|
assert frontmatter["authority"] == "reporting"
|
|
|
|
|
|
def test_capture_field_set_to_the_same_value_again_is_a_no_op(touch_wiki):
|
|
"""Not idempotent by accident: `_apply_set` already no-ops an unchanged
|
|
value before the write, and the fill-once refusal only fires on an actual
|
|
*change* - re-running the exact same backfill value must not error."""
|
|
_touch(page_title="Source - Aurora", set_fields=["fidelity=verbatim"])
|
|
_touch(page_title="Source - Aurora", set_fields=["fidelity=verbatim"])
|
|
frontmatter, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
|
|
assert frontmatter["fidelity"] == "verbatim"
|