Files changed: - CHANGES.md - VERSION - instructions/wiki-lint/SKILL.md - kb/CONTRACT.md - tools/CONTRACT.md - tools/chemenu/commands/lint.py - tools/chemenu/commands/page_ops.py - tools/chemenu/kb_scan.py - tools/chemenu/lint_core.py - tools/chemenu/tests/test_kb_scan.py - tools/chemenu/tests/test_lint.py - tools/chemenu/tests/test_page_ops.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
560 lines
25 KiB
Python
560 lines
25 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
from chemenu.commands import page_ops
|
|
from chemenu.frontmatter_io import read_page, write_page
|
|
from chemenu.provenance import cite_id
|
|
|
|
|
|
@pytest.fixture
|
|
def patched_wiki(kb_dir, monkeypatch):
|
|
"""Point config.KB_DIR at the fixture wiki so the commands never touch
|
|
the real one."""
|
|
monkeypatch.setattr("chemenu.config.KB_DIR", kb_dir)
|
|
return kb_dir
|
|
|
|
|
|
def test_retarget_body_preserves_alias_and_anchor():
|
|
body = "See [[Old]], [[Old|the old one]], [[Old#Setup]], and [[Other]]. ^[[Old]]"
|
|
result = page_ops.retarget_body(body, "Old", "New")
|
|
assert result == (
|
|
"See [[New]], [[New|the old one]], [[New#Setup]], and [[Other]]. ^[[New]]"
|
|
)
|
|
|
|
|
|
def test_retarget_body_ignores_partial_title_matches():
|
|
assert page_ops.retarget_body("[[Old Thing]]", "Old", "New") == "[[Old Thing]]"
|
|
|
|
|
|
def test_retarget_body_repoints_a_wrapped_link_onto_one_line():
|
|
body = "See [[Old\n Title|the old one]] and [[Old\nTitle#Setup]]."
|
|
result = page_ops.retarget_body(body, "Old Title", "New")
|
|
assert result == "See [[New|the old one]] and [[New#Setup]]."
|
|
assert page_ops.body_references(body, "Old Title") == 2
|
|
|
|
|
|
def test_strip_link_bullets_removes_only_bare_bullets():
|
|
body = (
|
|
"## Relationships\n\n"
|
|
"- **uses:** [[Gone]]\n"
|
|
"- [[Gone]]\n"
|
|
"- [[Kept]]\n"
|
|
"- Some prose about [[Gone]] that carries a claim.\n"
|
|
)
|
|
result = page_ops.strip_link_bullets(body, "Gone")
|
|
assert "- **uses:** [[Gone]]" not in result
|
|
assert "- [[Kept]]" in result
|
|
assert "Some prose about [[Gone]] that carries a claim." in result
|
|
|
|
|
|
def test_strip_link_bullets_leaves_footnote_definition_lines_alone():
|
|
"""A `[^id]: [[Title]]` definition line does not start with `-`, so it
|
|
must never be mistaken for a `- [[Title]]` See Also bullet."""
|
|
body = (
|
|
"## Definition\n\nSome prose [^s-gone].\n\n"
|
|
"## Footnotes\n\n[^s-gone]: [[Gone]]\n"
|
|
)
|
|
result = page_ops.strip_link_bullets(body, "Gone")
|
|
assert "[^s-gone]: [[Gone]]" in result
|
|
|
|
|
|
def test_retarget_cite_ids_renames_slug_derived_id_and_its_references():
|
|
old_id = cite_id("Old Title")
|
|
new_id = cite_id("New Title")
|
|
body = (
|
|
f"## Definition\n\nFirst [^{old_id}] and again [^{old_id}].\n\n"
|
|
f"## Footnotes\n\n[^{old_id}]: [[New Title]]\n"
|
|
)
|
|
result = page_ops.retarget_cite_ids(body, "Old Title", "New Title")
|
|
assert f"[^{new_id}]: [[New Title]]" in result
|
|
assert result.count(f"[^{new_id}]") == 3 # two refs + one definition
|
|
assert old_id not in result
|
|
|
|
|
|
def test_retarget_cite_ids_leaves_hand_picked_ids_alone():
|
|
"""An id that was never derived from `old`'s slug (a hand-picked or
|
|
collision-suffixed one) must not be touched by a rename of `old`."""
|
|
body = "## Definition\n\nFact [^s-custom].\n\n## Footnotes\n\n[^s-custom]: [[New Title]]\n"
|
|
result = page_ops.retarget_cite_ids(body, "Old Title", "New Title")
|
|
assert result == body
|
|
|
|
|
|
def test_retarget_cite_ids_is_noop_without_a_footnotes_block():
|
|
body = "## Definition\n\nNo citations here.\n"
|
|
assert page_ops.retarget_cite_ids(body, "Old Title", "New Title") == body
|
|
|
|
|
|
def test_rename_refreshes_stale_slug_derived_cite_id(patched_wiki):
|
|
old_id = cite_id("Borealis")
|
|
write_page(
|
|
patched_wiki / "concepts/uses-borealis.md",
|
|
{
|
|
"type": "types/concept.md", "concept_type": "protocol",
|
|
"tags": [], "created": "2026-07-25", "modified": "2026-07-25",
|
|
"related": [], "sources": [],
|
|
},
|
|
f"\n# uses-borealis\n\nRuns on it [^{old_id}].\n\n## Footnotes\n\n[^{old_id}]: [[Borealis]]\n",
|
|
)
|
|
|
|
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=False)
|
|
|
|
_frontmatter, body = read_page(patched_wiki / "concepts/uses-borealis.md")
|
|
new_id = cite_id("borealis-ws")
|
|
assert f"[^{new_id}]: [[borealis-ws]]" in body
|
|
assert f"[^{old_id}]" not in body
|
|
|
|
|
|
def test_rename_updates_body_links_and_frontmatter(patched_wiki):
|
|
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=False)
|
|
|
|
assert (patched_wiki / "entities/systems/borealis-ws.md").exists()
|
|
assert not (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
|
|
frontmatter, body = read_page(patched_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["related"] == ["borealis-ws"]
|
|
assert "[[borealis-ws]]" in body
|
|
assert "[[Borealis]]" not in body
|
|
|
|
|
|
def test_rename_rewrites_the_pages_own_h1(patched_wiki):
|
|
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=False)
|
|
_frontmatter, body = read_page(patched_wiki / "entities/systems/borealis-ws.md")
|
|
assert "# borealis-ws" in body
|
|
assert "# Borealis\n" not in body
|
|
|
|
|
|
def test_rename_dry_run_writes_nothing(patched_wiki):
|
|
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=True)
|
|
assert (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
frontmatter, _body = read_page(patched_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["related"] == ["Borealis"]
|
|
|
|
|
|
def test_rename_rejects_missing_page(patched_wiki):
|
|
"""Neither side is a page, so repointing would only move the dangling ref."""
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rename_command(old="Ghost", new="Whatever", dry_run=False)
|
|
|
|
|
|
def test_rename_repoints_references_to_an_existing_page(patched_wiki):
|
|
"""The cleanup mode: `related:` says `act_runner` but the page is
|
|
`Act Runner`. Nothing moves on disk - only the references change."""
|
|
write_page(
|
|
patched_wiki / "entities/tools/gdeploy.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
|
|
"created": "2026-07-25", "modified": "2026-07-25",
|
|
"related": ["borealis"], "sources": []},
|
|
"\n# gdeploy\n\n## See Also\n\n- [[borealis]]\n",
|
|
)
|
|
page_ops.rename_command(old="borealis", new="Borealis", dry_run=False)
|
|
|
|
frontmatter, body = read_page(patched_wiki / "entities/tools/gdeploy.md")
|
|
assert frontmatter["related"] == ["Borealis"]
|
|
assert "[[Borealis]]" in body
|
|
assert (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
|
|
|
|
def test_rename_reference_only_mode_requires_the_target_to_exist(patched_wiki):
|
|
write_page(
|
|
patched_wiki / "entities/tools/gdeploy.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
|
|
"created": "2026-07-25", "modified": "2026-07-25",
|
|
"related": ["ghost"], "sources": []},
|
|
"\n# gdeploy\n",
|
|
)
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rename_command(old="ghost", new="also-not-a-page", dry_run=False)
|
|
|
|
|
|
def test_rename_rejects_existing_target(patched_wiki):
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rename_command(old="Borealis", new="aurora", dry_run=False)
|
|
|
|
|
|
def test_rename_rejects_identical_titles(patched_wiki):
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rename_command(old="Borealis", new="Borealis", dry_run=False)
|
|
|
|
|
|
def test_rename_fixes_a_dangling_source_reference(patched_wiki):
|
|
"""The real-world case: pages cited `Source - X.md` when the page was
|
|
`Source - X`, and nothing detected it."""
|
|
write_page(
|
|
patched_wiki / "entities/tools/gdeploy.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
|
|
"created": "2026-07-25", "modified": "2026-07-25", "related": [],
|
|
"sources": ["Source - Aurora"]},
|
|
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
|
|
)
|
|
page_ops.rename_command(old="Source - Aurora", new="Source - Aurora Notes", dry_run=False)
|
|
frontmatter, _body = read_page(patched_wiki / "entities/tools/gdeploy.md")
|
|
assert frontmatter["sources"] == ["Source - Aurora Notes"]
|
|
|
|
|
|
def test_rename_stops_before_renaming_the_file_if_a_reference_write_fails(patched_wiki, monkeypatch):
|
|
"""A write failing partway through must not still rename the target file -
|
|
that would leave some references pointing at the new title and others
|
|
(the failed ones) still pointing at the old one, with the file already
|
|
moved out from under the old references."""
|
|
from chemenu.commands import page_ops as page_ops_module
|
|
|
|
real_write_page = page_ops_module.write_page
|
|
|
|
def flaky_write_page(path, frontmatter, body):
|
|
if path.name == "aurora.md":
|
|
raise OSError("disk full")
|
|
return real_write_page(path, frontmatter, body)
|
|
|
|
monkeypatch.setattr(page_ops_module, "write_page", flaky_write_page)
|
|
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rename_command(old="Borealis", new="borealis-ws", dry_run=False)
|
|
|
|
assert (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
assert not (patched_wiki / "entities/systems/borealis-ws.md").exists()
|
|
|
|
|
|
def test_rm_refuses_referenced_page_without_yes(patched_wiki):
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rm_command(page_title="Borealis", yes=False, dry_run=False)
|
|
assert (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
|
|
def _gdeploy_wrapping_a_link_to_source_aurora(kb) -> None:
|
|
write_page(
|
|
kb / "entities/tools/gdeploy.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
|
|
"created": "2026-07-25", "modified": "2026-07-25", "related": [],
|
|
"sources": []},
|
|
"\n# gdeploy\n\n## Description\n\nNotes in [[Source -\n Aurora]] nightly.\n",
|
|
)
|
|
|
|
|
|
def test_rm_refuses_a_page_referenced_only_by_a_wrapped_link(patched_wiki, capsys):
|
|
"""Before the fold, the wrapped link was no reference at all, and the page
|
|
went without the `--yes` the inbound list is there to require."""
|
|
_gdeploy_wrapping_a_link_to_source_aurora(patched_wiki)
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rm_command(page_title="Source - Aurora", yes=False, dry_run=False)
|
|
assert (patched_wiki / "sources/notes/Source - Aurora.md").exists()
|
|
assert "- gdeploy" in capsys.readouterr().out
|
|
|
|
|
|
def test_rename_repoints_a_wrapped_link(patched_wiki):
|
|
_gdeploy_wrapping_a_link_to_source_aurora(patched_wiki)
|
|
page_ops.rename_command(old="Source - Aurora", new="Source - Aurora Notes", dry_run=False)
|
|
_frontmatter, body = read_page(patched_wiki / "entities/tools/gdeploy.md")
|
|
assert "Notes in [[Source - Aurora Notes]] nightly." in body
|
|
|
|
|
|
def test_rm_deletes_and_delinks_with_yes(patched_wiki):
|
|
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False)
|
|
|
|
assert not (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
frontmatter, body = read_page(patched_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["related"] == []
|
|
assert "[[Borealis]]" not in body
|
|
|
|
|
|
def test_rm_stops_before_deleting_the_file_if_a_delink_write_fails(patched_wiki, monkeypatch):
|
|
from chemenu.commands import page_ops as page_ops_module
|
|
|
|
real_write_page = page_ops_module.write_page
|
|
|
|
def flaky_write_page(path, frontmatter, body):
|
|
if path.name == "aurora.md":
|
|
raise OSError("disk full")
|
|
return real_write_page(path, frontmatter, body)
|
|
|
|
monkeypatch.setattr(page_ops_module, "write_page", flaky_write_page)
|
|
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False)
|
|
|
|
assert (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
|
|
|
|
def test_rm_of_unreferenced_page_needs_no_confirmation(patched_wiki):
|
|
write_page(
|
|
patched_wiki / "entities/tools/isolated.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
|
|
"created": "2026-07-25", "modified": "2026-07-25", "related": [],
|
|
"sources": []},
|
|
"\n# isolated\n\n## Description\n\nNothing links here.\n",
|
|
)
|
|
page_ops.rm_command(page_title="isolated", yes=False, dry_run=False)
|
|
assert not (patched_wiki / "entities/tools/isolated.md").exists()
|
|
|
|
|
|
def test_rm_leaves_prose_references_and_reports_them(patched_wiki, capsys):
|
|
"""Removing a claim that cites the page is an editorial call, so `rm`
|
|
reports those references instead of deleting the sentence."""
|
|
write_page(
|
|
patched_wiki / "entities/tools/gdeploy.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
|
|
"created": "2026-07-25", "modified": "2026-07-25", "related": [],
|
|
"sources": []},
|
|
"\n# gdeploy\n\n## Description\n\nRuns on [[Borealis]] nightly.\n",
|
|
)
|
|
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=False)
|
|
|
|
_frontmatter, body = read_page(patched_wiki / "entities/tools/gdeploy.md")
|
|
assert "Runs on [[Borealis]] nightly." in body
|
|
assert "remaining [[Borealis]] reference(s)" in capsys.readouterr().out
|
|
|
|
|
|
def test_rm_dry_run_writes_nothing(patched_wiki):
|
|
page_ops.rm_command(page_title="Borealis", yes=True, dry_run=True)
|
|
assert (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
frontmatter, _body = read_page(patched_wiki / "entities/systems/aurora.md")
|
|
assert frontmatter["related"] == ["Borealis"]
|
|
|
|
|
|
def test_inbound_pages_sees_frontmatter_only_references(patched_wiki):
|
|
from chemenu.kb_scan import load_kb_pages
|
|
|
|
write_page(
|
|
patched_wiki / "entities/tools/gdeploy.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
|
|
"created": "2026-07-25", "modified": "2026-07-25",
|
|
"related": ["Modbus"], "sources": []},
|
|
"\n# gdeploy\n\n## Description\n\nNo body link at all.\n",
|
|
)
|
|
assert "gdeploy" in page_ops.inbound_pages(load_kb_pages(patched_wiki), "Modbus")
|
|
|
|
|
|
# --- move --------------------------------------------------------------
|
|
|
|
|
|
def _write_misplaced(kb: Path, relative: str, title: str, entity_type: str) -> None:
|
|
write_page(
|
|
kb / relative / f"{title}.md",
|
|
{
|
|
"type": "types/entity.md", "entity_type": entity_type, "tags": [],
|
|
"created": "2026-07-25", "modified": "2026-07-25",
|
|
"related": [], "sources": [],
|
|
},
|
|
f"\n# {title}\n",
|
|
)
|
|
|
|
|
|
def test_move_relocates_a_page_to_its_computed_directory(patched_wiki):
|
|
_write_misplaced(patched_wiki, "entities/systems", "misplaced-tool", "tool")
|
|
page_ops.move_command(page_title="misplaced-tool", reconcile=False, dry_run=False)
|
|
assert not (patched_wiki / "entities/systems/misplaced-tool.md").exists()
|
|
assert (patched_wiki / "entities/tools/misplaced-tool.md").exists()
|
|
frontmatter, body = read_page(patched_wiki / "entities/tools/misplaced-tool.md")
|
|
assert frontmatter["entity_type"] == "tool"
|
|
assert body.strip() == "# misplaced-tool"
|
|
|
|
|
|
def test_move_dry_run_writes_nothing(patched_wiki):
|
|
_write_misplaced(patched_wiki, "entities/systems", "misplaced-tool", "tool")
|
|
page_ops.move_command(page_title="misplaced-tool", reconcile=False, dry_run=True)
|
|
assert (patched_wiki / "entities/systems/misplaced-tool.md").exists()
|
|
assert not (patched_wiki / "entities/tools/misplaced-tool.md").exists()
|
|
|
|
|
|
def test_move_is_a_noop_when_already_at_its_computed_location(patched_wiki, capsys):
|
|
page_ops.move_command(page_title="aurora", reconcile=False, dry_run=False)
|
|
assert "already at its computed location" in capsys.readouterr().out
|
|
assert (patched_wiki / "entities/systems/aurora.md").exists()
|
|
|
|
|
|
def test_move_rejects_missing_page(patched_wiki):
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.move_command(page_title="No Such Page", reconcile=False, dry_run=False)
|
|
|
|
|
|
def test_move_requires_exactly_one_of_page_or_reconcile(patched_wiki):
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.move_command(page_title=None, reconcile=False, dry_run=False)
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.move_command(page_title="aurora", reconcile=True, dry_run=False)
|
|
|
|
|
|
def test_move_refuses_when_the_destination_already_exists(patched_wiki):
|
|
"""A pre-existing duplicate-title situation: two files share the stem
|
|
`dup`, so `load_kb_pages` (see its own docstring) surfaces only the one
|
|
that sorts last by path - `entities/zzz-wrong/dup.md` - as the tracked
|
|
page `dup`. Its computed destination, `entities/tools/dup.md`, is
|
|
occupied by the other, untracked half of the collision. `move` must
|
|
refuse rather than silently overwrite it."""
|
|
_write_misplaced(patched_wiki, "entities/tools", "dup", "tool")
|
|
(patched_wiki / "entities/zzz-wrong").mkdir()
|
|
_write_misplaced(patched_wiki, "entities/zzz-wrong", "dup", "tool")
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.move_command(page_title="dup", reconcile=False, dry_run=False)
|
|
assert (patched_wiki / "entities/tools/dup.md").exists()
|
|
assert (patched_wiki / "entities/zzz-wrong/dup.md").exists()
|
|
|
|
|
|
def test_move_reconcile_moves_every_misplaced_page(patched_wiki, capsys):
|
|
_write_misplaced(patched_wiki, "entities/systems", "misplaced-tool", "tool")
|
|
_write_misplaced(patched_wiki, "entities/systems", "misplaced-person", "person")
|
|
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
|
|
|
|
assert (patched_wiki / "entities/tools/misplaced-tool.md").exists()
|
|
assert (patched_wiki / "entities/people/misplaced-person.md").exists()
|
|
assert not (patched_wiki / "entities/systems/misplaced-tool.md").exists()
|
|
assert not (patched_wiki / "entities/systems/misplaced-person.md").exists()
|
|
# Already-correct pages are left alone.
|
|
assert (patched_wiki / "entities/systems/aurora.md").exists()
|
|
|
|
capsys.readouterr()
|
|
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
|
|
assert "Nothing to move" in capsys.readouterr().out
|
|
|
|
|
|
def test_move_reconcile_dry_run_writes_nothing(patched_wiki):
|
|
_write_misplaced(patched_wiki, "entities/systems", "misplaced-tool", "tool")
|
|
page_ops.move_command(page_title=None, reconcile=True, dry_run=True)
|
|
assert (patched_wiki / "entities/systems/misplaced-tool.md").exists()
|
|
|
|
|
|
def test_move_removes_the_directory_it_empties(patched_wiki):
|
|
"""The real-world case (Gitea #57): a page nested a level below its area
|
|
(`entities/projects/<owner>/*.md`) is also misplaced by `find_misplaced` -
|
|
its computed directory is `entities/projects`, one level up from where it
|
|
sits - so `move` fixes the nesting as a side effect of fixing the
|
|
placement, and must not leave the now-empty `<owner>/` directory behind."""
|
|
_write_misplaced(patched_wiki, "entities/projects/someowner", "nested-tool", "project")
|
|
page_ops.move_command(page_title="nested-tool", reconcile=False, dry_run=False)
|
|
assert (patched_wiki / "entities/projects/nested-tool.md").exists()
|
|
assert not (patched_wiki / "entities/projects/someowner").exists()
|
|
|
|
|
|
def test_move_does_not_remove_a_directory_that_still_holds_pages(patched_wiki):
|
|
_write_misplaced(patched_wiki, "entities/systems", "misplaced-tool", "tool")
|
|
page_ops.move_command(page_title="misplaced-tool", reconcile=False, dry_run=False)
|
|
assert (patched_wiki / "entities/systems").is_dir()
|
|
assert (patched_wiki / "entities/systems/aurora.md").exists()
|
|
|
|
|
|
def test_move_reconcile_removes_every_directory_it_empties(patched_wiki):
|
|
_write_misplaced(patched_wiki, "entities/projects/kfchou", "wiki-skills", "project")
|
|
_write_misplaced(
|
|
patched_wiki, "entities/projects/vanillaflava", "wiki-skills-vanillaflava", "project"
|
|
)
|
|
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
|
|
assert (patched_wiki / "entities/projects/wiki-skills.md").exists()
|
|
assert (patched_wiki / "entities/projects/wiki-skills-vanillaflava.md").exists()
|
|
assert not (patched_wiki / "entities/projects/kfchou").exists()
|
|
assert not (patched_wiki / "entities/projects/vanillaflava").exists()
|
|
assert not (patched_wiki / "entities/tools/misplaced-tool.md").exists()
|
|
|
|
|
|
def _page_at(kb, relative, title):
|
|
_write_misplaced(kb, relative, title, "tool")
|
|
|
|
|
|
@pytest.mark.parametrize("dry_run", [False, True])
|
|
@pytest.mark.parametrize("new", ["A: B", "A/B", "Index", "COM¹", "Trailing.", "AURORA", "Aurora"])
|
|
def test_rename_refuses_an_unportable_or_colliding_target(patched_wiki, dry_run, new):
|
|
"""Also under `--dry-run`: a dry run must not promise a rename the real
|
|
run refuses."""
|
|
before = {p: p.read_bytes() for p in patched_wiki.rglob("*.md")}
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rename_command(old="Borealis", new=new, dry_run=dry_run)
|
|
assert {p: p.read_bytes() for p in patched_wiki.rglob("*.md")} == before
|
|
|
|
|
|
def test_rename_may_change_only_the_case_of_the_page_itself(patched_wiki):
|
|
page_ops.rename_command(old="Borealis", new="BOREALIS", dry_run=False)
|
|
assert (patched_wiki / "entities/systems/BOREALIS.md").exists()
|
|
assert not (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
|
|
|
|
def test_rename_dry_run_accepts_a_case_only_change(patched_wiki):
|
|
page_ops.rename_command(old="Borealis", new="BOREALIS", dry_run=True)
|
|
assert (patched_wiki / "entities/systems/Borealis.md").exists()
|
|
|
|
|
|
def test_rename_is_the_remedy_for_a_page_that_breaks_the_rule(patched_wiki):
|
|
"""`--from` is never checked, or lint's finding would have no fix."""
|
|
_page_at(patched_wiki, "entities/tools", "CON")
|
|
page_ops.rename_command(old="CON", new="Console", dry_run=False)
|
|
assert (patched_wiki / "entities/tools/Console.md").exists()
|
|
assert not (patched_wiki / "entities/tools/CON.md").exists()
|
|
|
|
|
|
def test_rename_refuses_a_target_file_that_is_not_a_page(patched_wiki):
|
|
"""A stack file beside the page is a collision even though it is no page."""
|
|
(patched_wiki / "entities/systems/COLLECTION.md").write_text("contract", encoding="utf-8")
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rename_command(old="Borealis", new="collection", dry_run=False)
|
|
|
|
|
|
def test_move_refuses_a_destination_that_differs_only_by_case(patched_wiki):
|
|
_page_at(patched_wiki, "entities/tools", "Dup")
|
|
(patched_wiki / "entities/zzz-wrong").mkdir()
|
|
_page_at(patched_wiki, "entities/zzz-wrong", "dup")
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.move_command(page_title="dup", reconcile=False, dry_run=False)
|
|
assert (patched_wiki / "entities/tools/Dup.md").exists()
|
|
assert (patched_wiki / "entities/zzz-wrong/dup.md").exists()
|
|
|
|
|
|
def test_move_reconcile_skips_a_destination_that_differs_only_by_case(patched_wiki, capsys):
|
|
_page_at(patched_wiki, "entities/tools", "Dup")
|
|
(patched_wiki / "entities/zzz-wrong").mkdir()
|
|
_page_at(patched_wiki, "entities/zzz-wrong", "dup")
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
|
|
assert (patched_wiki / "entities/zzz-wrong/dup.md").exists()
|
|
assert (patched_wiki / "entities/tools/Dup.md").exists()
|
|
|
|
|
|
def _tools_title(units: int) -> str:
|
|
"""A title whose `kb/entities/tools/<title>.md` path is `units` long."""
|
|
return "t" * (units - len("kb/entities/tools/") - len(".md"))
|
|
|
|
|
|
@pytest.mark.parametrize("dry_run", [False, True])
|
|
def test_rename_refuses_a_target_over_the_path_budget(patched_wiki, dry_run):
|
|
_page_at(patched_wiki, "entities/tools", "Short")
|
|
before = {p: p.read_bytes() for p in patched_wiki.rglob("*.md")}
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.rename_command(old="Short", new=_tools_title(161), dry_run=dry_run)
|
|
assert {p: p.read_bytes() for p in patched_wiki.rglob("*.md")} == before
|
|
|
|
|
|
def test_rename_accepts_a_target_at_the_path_budget(patched_wiki):
|
|
_page_at(patched_wiki, "entities/tools", "Short")
|
|
page_ops.rename_command(old="Short", new=_tools_title(160), dry_run=False)
|
|
assert (patched_wiki / "entities/tools" / f"{_tools_title(160)}.md").exists()
|
|
|
|
|
|
def test_rename_away_from_a_page_over_the_budget_is_allowed(patched_wiki):
|
|
"""`--from` is never checked: the rename is the remedy for a long page."""
|
|
_page_at(patched_wiki, "entities/tools", _tools_title(170))
|
|
page_ops.rename_command(old=_tools_title(170), new="Short", dry_run=False)
|
|
assert (patched_wiki / "entities/tools/Short.md").exists()
|
|
|
|
|
|
def test_move_refuses_a_destination_over_the_path_budget(patched_wiki):
|
|
# kb/entities/systems/<t>.md is 2 longer than kb/entities/tools/<t>.md, so a
|
|
# title that fits in tools/ is over the budget once the page moves to systems/.
|
|
title = "t" * (160 - len("kb/entities/tools/") - len(".md"))
|
|
_write_misplaced(patched_wiki, "entities/tools", title, "system")
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.move_command(page_title=title, reconcile=False, dry_run=False)
|
|
assert (patched_wiki / "entities/tools" / f"{title}.md").exists()
|
|
assert not (patched_wiki / "entities/systems" / f"{title}.md").exists()
|
|
|
|
|
|
def test_move_reconcile_skips_a_destination_over_the_budget_and_names_it(patched_wiki, capsys):
|
|
title = "t" * (160 - len("kb/entities/tools/") - len(".md"))
|
|
_write_misplaced(patched_wiki, "entities/tools", title, "system")
|
|
_write_misplaced(patched_wiki, "entities/tools", "fine-system", "system")
|
|
page_ops.move_command(page_title=None, reconcile=True, dry_run=True)
|
|
out = capsys.readouterr().out
|
|
assert "fine-system" in out and title in out and "characters long" in out
|
|
assert "would move 1 page(s)" in out
|
|
|
|
with pytest.raises(typer.Exit):
|
|
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
|
|
assert (patched_wiki / "entities/systems/fine-system.md").exists()
|
|
assert (patched_wiki / "entities/tools" / f"{title}.md").exists()
|