1815d91cdf
A discussion leaves behind a directory of markdown somewhere outside this repo, and the only durable home for it is the Gitea wiki. Getting it there by hand means re-deriving the same three things every time: what each file should be called, where it goes, and whether the page already exists. Two skills wrap that, along the split the repo already uses. `skills/page` is domain, offline, stdlib-only, and knows nothing about Gitea. It imports a directory into a space under `tmp/wiki/`, titles every file, records the result in `.pages.json`, and writes the index. `skills/wiki` is the bridge — `wikimap.py` translates, and the transport is `_gitea.py`, the same one the issue side uses. There is no second transport, and `tea` has no wiki subcommand to offer one. The Gitea wiki is flat, and that fact shapes everything There are no directories. A title of `a/b` is stored as one file named `a%2Fb.md`, and Gitea escapes it by rules of its own: space becomes `-`, `/` becomes `%2F`, and a literal `-` forces a trailing `.-` marker so the two stay distinct. `Chain decisions — DC` under two levels of prefix comes back as `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC`. So `sub_url` is the identity, it is read back from whatever the API returned, and it is never constructed. One built by hand that is almost right does not fail — it creates a second page and abandons the first. And a real subdirectory committed into a wiki's git repository is a ghost: the file exists, the API and the web UI do not see it. `folder/page.md` in this repo's own wiki is one. Nothing here clones a wiki repo. A title is a decision, not a derivation Titles come from the first heading, because there is no mechanical route from `03-q-01-do-we-know-the-chain-participant-by-name.md` to `Q-01. Do We Know the Chain Participant by Name`. But they are derived exactly once. A re-import replaces bodies and keeps titles, so editing a heading cannot rename a published page — which would not rename it, it would publish a second one. `--retitle` opts in. It finds the prior entry by `source` rather than by path, because the path is derived from the title and a retitle moves it; looked up by path the page would read as new and the next push would duplicate it. The old file goes, `sub_url` comes along, and `pushed` is cleared — a rename can leave the body byte-identical, and push decides by body hash alone, so a stale hash would skip the rename forever. Ordering is a `NN-` file-name prefix and never reaches the title. `00-` means "this is the directory's own page", and that page is named for the directory, not for its own heading: a child's title has to extend its parent's exactly, and `ideas/00-intro.md` opens with "Ideas for chain business requirements". Path collisions are reported and never resolved. Picking a winner is how a discussion loses a document. The index is navigation, not decoration Nothing draws a tree from flat titles. `page_index.py` writes one as an ordinary page, nested by title depth rather than by manifest path order — those disagree, since on disk `Top/System.md` sorts before `Top/Ideas/Scale.md` while in the hierarchy System is a child and Scale a grandchild. A parent with no page of its own still gets a node, so its children are not hidden. Links use `sub_url` when there is one and Gitea's `[[Title|label]]` syntax when there is not, so the order is push, rebuild, push. The same stances as the issue store, for the same reasons Pull overwrites, push is additive and never deletes, change detection is one hash and there is no drift model. A page with no `sub_url` has never been published, and that is a durable state. Issues gain a `wiki:` field holding page titles — titles, not URLs, so the reference stays in the domain. It already round-trips as a foreign key; this documents it. Verified against a live Gitea 1.26.1: create, update with a message, unchanged-skip, prefix-filtered pull, byte-identical round trip, and the per-page revision history carrying the operator's own words. The probe pages were deleted afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
524 lines
23 KiB
Python
524 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
How a directory of markdown becomes a page tree, and that the tree survives a
|
|
round trip through the wiki layer's bookkeeping.
|
|
|
|
python3 -m unittest discover -s tests -v
|
|
|
|
Stdlib unittest, no third-party anything. `skills/*/scripts/` are not packages,
|
|
so the modules under test are imported by path.
|
|
|
|
Nothing here touches tmp/wiki/. The subprocess cases build a throwaway
|
|
repository in a temp directory — a `.git` marker, a copy of both script layers,
|
|
a directory of fixture artifacts — and run the real scripts inside it. That is
|
|
the only honest way to test behaviour that depends on where a script is run
|
|
from, and it keeps the developer's own cache out of the blast radius.
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
PAGE_SCRIPTS = os.path.join(REPO, "skills", "page", "scripts")
|
|
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
|
|
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
|
|
|
sys.path.insert(0, PAGE_SCRIPTS)
|
|
sys.path.insert(0, WIKI_SCRIPTS)
|
|
import page # noqa: E402
|
|
import wikimap # noqa: E402
|
|
|
|
|
|
# The fixture mirrors the shape a real discussion leaves behind: numbered files
|
|
# for ordering, a `00-` file standing in for its directory, headings that no
|
|
# mechanical rule could derive from the file names.
|
|
FIXTURE = {
|
|
"handoff.md": "# handoff — notification chains\n\nEntry point.\n",
|
|
"ideas/00-intro.md": "# Ideas for chain business requirements\n\nFlat list.\n",
|
|
"ideas/02-chain-core.md": "## Chain core\n\n- **B-01.** Something.\n",
|
|
"ideas/01-relations.md": "## Relations\n\nHow they relate.\n",
|
|
"questions/00-intro.md": "# Questions\n\nOpen questions.\n",
|
|
"questions/03-q-01-do-we-know-the-participant.md":
|
|
"## Q-01. Do We Know the Chain Participant by Name\n\n**Question.** …\n",
|
|
"notes/plain.md": "No heading here, only prose.\n",
|
|
}
|
|
|
|
|
|
def build_artifacts(root):
|
|
for rel, text in FIXTURE.items():
|
|
p = os.path.join(root, rel.replace("/", os.sep))
|
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
with open(p, "w", encoding="utf-8") as f:
|
|
f.write(text)
|
|
return root
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# names, titles, order — pure
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestNames(unittest.TestCase):
|
|
|
|
def test_order_comes_from_a_numeric_prefix(self):
|
|
self.assertEqual(page.order_of("02-chain-core.md"), 2)
|
|
self.assertEqual(page.order_of("00-intro.md"), 0)
|
|
self.assertIsNone(page.order_of("handoff.md"))
|
|
|
|
def test_zero_is_an_order_and_not_a_missing_one(self):
|
|
"""`00-` means "this is the directory's own page", so the difference
|
|
between 0 and None decides where a page lands in the tree."""
|
|
self.assertIsNot(page.order_of("00-intro.md"), None)
|
|
|
|
def test_the_prefix_never_reaches_the_title(self):
|
|
self.assertEqual(page.title_from_name("02-chain-core.md"), "Chain core")
|
|
|
|
def test_only_the_first_letter_is_raised(self):
|
|
"""Title-casing would wreck every name that already knows how it is
|
|
spelled."""
|
|
self.assertEqual(page.title_from_name("sqlc-and-APNs.md"), "Sqlc and APNs")
|
|
|
|
def test_a_heading_beats_a_file_name(self):
|
|
text = "## Q-01. Do We Know the Chain Participant by Name\n"
|
|
self.assertEqual(page.title_from_body(text),
|
|
"Q-01. Do We Know the Chain Participant by Name")
|
|
|
|
def test_only_the_first_heading_counts(self):
|
|
self.assertEqual(page.title_from_body("# One\n\n## Two\n"), "One")
|
|
|
|
def test_a_heading_after_prose_is_a_section_not_a_name(self):
|
|
self.assertIsNone(page.title_from_body("Prose first.\n\n# Late\n"))
|
|
|
|
def test_markup_is_stripped_from_a_title(self):
|
|
"""A page list does not render markdown, so inline code in a heading is
|
|
noise in the name."""
|
|
self.assertEqual(page.sanitize_title("Inventory — `P-NN`"),
|
|
"Inventory — P-NN")
|
|
|
|
def test_a_slash_in_a_heading_does_not_invent_hierarchy(self):
|
|
self.assertEqual(page.sanitize_title("Send/receive timing"),
|
|
"Send-receive timing")
|
|
|
|
|
|
class TestPaths(unittest.TestCase):
|
|
|
|
def test_a_title_becomes_one_path_component_per_segment(self):
|
|
self.assertEqual(page.path_for_title("Simple Chains/Ideas/Chain core"),
|
|
os.path.join("Simple-Chains", "Ideas", "Chain-core.md"))
|
|
|
|
def test_shell_hostile_characters_leave_the_path_but_not_the_title(self):
|
|
title = "Simple Chains/Don't send to this one"
|
|
self.assertEqual(page.path_for_title(title),
|
|
os.path.join("Simple-Chains", "Dont-send-to-this-one.md"))
|
|
self.assertIn("'", title)
|
|
|
|
def test_an_empty_title_still_produces_a_file(self):
|
|
self.assertEqual(page.path_for_title(""), "untitled.md")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# importing
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestPlanImport(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.src = build_artifacts(os.path.join(self.tmp.name, "artifacts"))
|
|
self.pages, self.collisions = page.plan_import(self.src, "Simple Chains")
|
|
self.titles = {p["title"] for p in self.pages}
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_nothing_collides(self):
|
|
self.assertEqual(self.collisions, [])
|
|
|
|
def test_an_order_zero_file_becomes_the_directorys_own_page(self):
|
|
self.assertIn("Simple Chains/Ideas", self.titles)
|
|
|
|
def test_that_page_is_named_for_the_directory_not_its_heading(self):
|
|
"""`ideas/00-intro.md` opens with "Ideas for chain business
|
|
requirements". A child's title must extend its parent's exactly, and no
|
|
child would ever be prefixed by that."""
|
|
self.assertNotIn("Simple Chains/Ideas for chain business requirements",
|
|
self.titles)
|
|
|
|
def test_every_child_extends_its_parents_title(self):
|
|
self.assertIn("Simple Chains/Ideas/Chain core", self.titles)
|
|
self.assertIn("Simple Chains/Questions/"
|
|
"Q-01. Do We Know the Chain Participant by Name",
|
|
self.titles)
|
|
|
|
def test_a_file_without_a_heading_falls_back_to_its_name(self):
|
|
self.assertIn("Simple Chains/Notes/Plain", self.titles)
|
|
|
|
def test_the_prefix_hangs_everything_under_one_title(self):
|
|
self.assertTrue(all(t.startswith("Simple Chains/") for t in self.titles))
|
|
|
|
def test_numeric_prefixes_order_siblings(self):
|
|
ideas = [p for p in self.pages
|
|
if p["title"].startswith("Simple Chains/Ideas/")]
|
|
self.assertEqual([p["title"].split("/")[-1] for p in ideas],
|
|
["Relations", "Chain core"])
|
|
|
|
def test_a_collision_is_reported_and_not_resolved(self):
|
|
"""Two headings that sanitize to one path. Picking a winner is how a
|
|
discussion loses a document."""
|
|
d = os.path.join(self.tmp.name, "clash")
|
|
os.makedirs(d)
|
|
for name, heading in (("a.md", "# Send timing"), ("b.md", "# Send/timing")):
|
|
with open(os.path.join(d, name), "w") as f:
|
|
f.write(heading + "\n")
|
|
_, collisions = page.plan_import(d)
|
|
self.assertEqual(len(collisions), 1)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# the index
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestIndex(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.m = page.blank_manifest("s")
|
|
for title, order in (("Top", None),
|
|
("Top/Ideas", 0),
|
|
("Top/Ideas/Relations", 1),
|
|
("Top/Ideas/Chain core", 2),
|
|
("Top/Zeta", None),
|
|
("Top/Parked/Decisions", None)):
|
|
self.m["pages"][page.path_for_title(title)] = page.entry(title, order)
|
|
|
|
def test_nesting_follows_titles_not_manifest_path_order(self):
|
|
"""On disk `Top/Zeta.md` sorts before `Top/Ideas/Chain-core.md`; in the
|
|
hierarchy Zeta is a child and Chain core a grandchild."""
|
|
body = page.render_index(self.m, "Top")
|
|
lines = [l for l in body.splitlines() if l.strip().startswith("- ")
|
|
or l.strip().startswith(" - ")]
|
|
ideas = next(i for i, l in enumerate(lines) if "|Ideas]]" in l)
|
|
core = next(i for i, l in enumerate(lines) if "Chain core]]" in l)
|
|
zeta = next(i for i, l in enumerate(lines) if "|Zeta]]" in l)
|
|
self.assertLess(ideas, core)
|
|
self.assertLess(core, zeta)
|
|
|
|
def test_a_parent_with_no_page_still_holds_its_children(self):
|
|
"""Nothing is published at `Top/Parked`; dropping it would hide
|
|
Decisions entirely."""
|
|
body = page.render_index(self.m, "Top")
|
|
self.assertIn("- [[Top/Parked|Parked]]", body)
|
|
self.assertIn(" - [[Top/Parked/Decisions|Decisions]]", body)
|
|
|
|
def test_an_unpublished_page_is_linked_by_wiki_syntax(self):
|
|
self.assertIn("[[Top/Ideas|Ideas]]", page.render_index(self.m, "Top"))
|
|
|
|
def test_a_published_page_is_linked_by_its_sub_url(self):
|
|
"""sub_url is the only address Gitea guarantees, and it appears only
|
|
after a push — so rebuilding the index after publishing upgrades the
|
|
links."""
|
|
rel = page.path_for_title("Top/Ideas")
|
|
self.m["pages"][rel]["sub_url"] = "Top%2FIdeas"
|
|
self.assertIn("- [Ideas](Top%2FIdeas)", page.render_index(self.m, "Top"))
|
|
|
|
def test_the_prefix_itself_is_not_listed_inside_its_own_index(self):
|
|
self.assertNotIn("|Top]]", page.render_index(self.m, "Top"))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# the manifest
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestManifest(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_a_missing_manifest_loads_blank(self):
|
|
m = page.load_manifest("a/b", self.tmp.name)
|
|
self.assertEqual(m["pages"], {})
|
|
|
|
def test_wiki_bookkeeping_survives_a_round_trip(self):
|
|
"""The domain never reads sub_url, and must never drop it either — a
|
|
lost sub_url is a duplicate page on the next push."""
|
|
m = page.blank_manifest("a/b")
|
|
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X", pushed="deadbeef")
|
|
page.save_manifest(m, self.tmp.name)
|
|
back = page.load_manifest("a/b", self.tmp.name)
|
|
self.assertEqual(back["pages"]["X.md"]["sub_url"], "X")
|
|
self.assertEqual(back["pages"]["X.md"]["pushed"], "deadbeef")
|
|
self.assertEqual(back["pages"]["X.md"]["order"], 1)
|
|
|
|
def test_domain_keys_are_written_first(self):
|
|
"""The manifest lands in a diff on every sync; a readable one gets
|
|
checked."""
|
|
m = page.blank_manifest("a/b")
|
|
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X")
|
|
with open(page.save_manifest(m, self.tmp.name), encoding="utf-8") as f:
|
|
raw = f.read()
|
|
self.assertLess(raw.index('"title"'), raw.index('"sub_url"'))
|
|
|
|
def test_children_of_is_a_prefix_test_and_not_a_substring_one(self):
|
|
m = page.blank_manifest("s")
|
|
for t in ("Top", "Top/A", "Topaz", "Topaz/B"):
|
|
m["pages"][page.path_for_title(t)] = page.entry(t)
|
|
got = {e["title"] for _, e in page.children_of(m, "Top")}
|
|
self.assertEqual(got, {"Top", "Top/A"})
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# md <-> wiki JSON
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestWikiMap(unittest.TestCase):
|
|
|
|
def test_a_body_survives_encode_and_decode(self):
|
|
text = "# Заголовок — DC\n\n- [x] пункт\n"
|
|
self.assertEqual(wikimap.decode({"content_base64": wikimap.encode(text)}),
|
|
text)
|
|
|
|
def test_an_empty_page_decodes_to_an_empty_string(self):
|
|
"""A page that exists with no body is a real state; the caller writing
|
|
a file should not have to tell it from a missing key."""
|
|
self.assertEqual(wikimap.decode({}), "")
|
|
self.assertEqual(wikimap.decode({"content_base64": None}), "")
|
|
|
|
def test_from_payload_takes_the_address_gitea_returned(self):
|
|
got = wikimap.from_payload({
|
|
"title": "A/B", "sub_url": "A%2FB.-", "html_url": "https://x/A%2FB.-",
|
|
"last_commit": {"sha": "abc", "author": {"date": "2026-08-10T11:15:39Z"}},
|
|
})
|
|
self.assertEqual(got["sub_url"], "A%2FB.-")
|
|
self.assertEqual(got["sha"], "abc")
|
|
self.assertEqual(got["remote-updated"], "2026-08-10T11:15:39Z")
|
|
|
|
def test_a_sub_url_goes_into_the_endpoint_verbatim(self):
|
|
"""Gitea hands it back already escaped; re-encoding it produces a path
|
|
that resolves to nothing."""
|
|
self.assertEqual(
|
|
wikimap.page_endpoint("repos/o/r", "A%2FB.-"),
|
|
"repos/o/r/wiki/page/A%2FB.-")
|
|
|
|
def test_prefix_matching_needs_a_separator(self):
|
|
self.assertTrue(wikimap.matches_prefix("Top", "Top"))
|
|
self.assertTrue(wikimap.matches_prefix("Top/A", "Top"))
|
|
self.assertFalse(wikimap.matches_prefix("Topaz", "Top"))
|
|
|
|
def test_an_empty_prefix_matches_everything(self):
|
|
self.assertTrue(wikimap.matches_prefix("anything", ""))
|
|
|
|
def test_a_payload_carries_the_operators_message(self):
|
|
p = wikimap.new_payload("A/B", "body", "why it changed")
|
|
self.assertEqual(p["message"], "why it changed")
|
|
self.assertEqual(wikimap.decode(p), "body")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# the scripts, in a throwaway repository
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestImportScript(unittest.TestCase):
|
|
"""The real scripts, run as subprocesses inside a scratch repo."""
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.root = self.tmp.name
|
|
os.makedirs(os.path.join(self.root, ".git"))
|
|
for layer in ("page", "wiki"):
|
|
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
|
os.path.join(self.root, "skills", layer, "scripts"),
|
|
ignore=shutil.ignore_patterns("__pycache__"))
|
|
shutil.copytree(SYNC_SCRIPTS,
|
|
os.path.join(self.root, "skills", "sync", "scripts"),
|
|
ignore=shutil.ignore_patterns("__pycache__"))
|
|
self.src = build_artifacts(os.path.join(self.root, "artifacts"))
|
|
self.scripts = os.path.join(self.root, "skills", "page", "scripts")
|
|
self.space = os.path.join(self.root, "tmp", "wiki", "s")
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def run_script(self, name, *args, cwd=None):
|
|
return subprocess.run(
|
|
[sys.executable, os.path.join(self.scripts, name)] + list(args),
|
|
capture_output=True, text=True, cwd=cwd or self.root)
|
|
|
|
def manifest(self):
|
|
with open(os.path.join(self.space, ".pages.json"), encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
def do_import(self, *extra):
|
|
return self.run_script("page_import.py", "--from", self.src,
|
|
"--space", "s", "--prefix", "Top", *extra)
|
|
|
|
def test_dry_run_writes_nothing(self):
|
|
r = self.do_import("--dry-run")
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertFalse(os.path.exists(self.space))
|
|
|
|
def test_import_writes_the_tree_and_the_manifest(self):
|
|
self.assertEqual(self.do_import().returncode, 0)
|
|
self.assertTrue(os.path.isfile(
|
|
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
|
|
self.assertIn("Top/Ideas/Chain core",
|
|
{e["title"] for e in self.manifest()["pages"].values()})
|
|
|
|
def test_creating_a_space_is_announced(self):
|
|
"""Nothing creates a store as a silent side effect of a write — that is
|
|
how a typo in --space makes a second one nobody notices."""
|
|
self.assertIn("created space", self.do_import().stderr)
|
|
|
|
def test_the_cache_is_found_from_a_subdirectory(self):
|
|
"""The anchor is the script's own location, not cwd. A `cd` outlives
|
|
the command that ran it."""
|
|
self.do_import()
|
|
deep = os.path.join(self.src, "ideas")
|
|
r = self.run_script("page_ls.py", "--space", "s", cwd=deep)
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertIn("Chain core", r.stdout)
|
|
|
|
def test_a_reimport_keeps_the_title_and_the_wiki_bookkeeping(self):
|
|
self.do_import()
|
|
m = self.manifest()
|
|
rel = "Top/Ideas/Chain-core.md"
|
|
m["pages"][rel]["sub_url"] = "Top%2FIdeas%2FChain-core"
|
|
with open(os.path.join(self.space, ".pages.json"), "w") as f:
|
|
json.dump(m, f)
|
|
|
|
# The heading changes. Without the manifest that would rename a
|
|
# published page, which does not rename it — it publishes a second one.
|
|
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
|
|
f.write("## A completely different heading\n\nchanged\n")
|
|
self.do_import()
|
|
|
|
after = self.manifest()["pages"][rel]
|
|
self.assertEqual(after["title"], "Top/Ideas/Chain core")
|
|
self.assertEqual(after["sub_url"], "Top%2FIdeas%2FChain-core")
|
|
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
|
|
self.assertIn("A completely different heading", f.read())
|
|
|
|
def test_retitle_moves_the_page_and_keeps_its_address(self):
|
|
"""A retitle changes the path, so the entry has to be found by source.
|
|
Found by path it would look new, and the next push would publish a
|
|
duplicate beside the page it was meant to rename."""
|
|
self.do_import()
|
|
m = self.manifest()
|
|
m["pages"]["Top/Ideas/Chain-core.md"]["sub_url"] = "Top%2FIdeas%2FChain-core"
|
|
m["pages"]["Top/Ideas/Chain-core.md"]["pushed"] = "deadbeef"
|
|
with open(os.path.join(self.space, ".pages.json"), "w") as f:
|
|
json.dump(m, f)
|
|
|
|
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
|
|
f.write("## Chain core, renamed\n")
|
|
self.do_import("--retitle")
|
|
|
|
pages = self.manifest()["pages"]
|
|
self.assertNotIn("Top/Ideas/Chain-core.md", pages)
|
|
moved = pages["Top/Ideas/Chain-core-renamed.md"]
|
|
self.assertEqual(moved["title"], "Top/Ideas/Chain core, renamed")
|
|
self.assertEqual(moved["sub_url"], "Top%2FIdeas%2FChain-core")
|
|
self.assertFalse(os.path.exists(
|
|
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
|
|
|
|
def test_a_rename_makes_the_next_push_send_the_page(self):
|
|
"""The body can be byte-identical after a rename, and push decides by
|
|
body hash alone — so a stale `pushed` would skip the rename forever."""
|
|
self.do_import()
|
|
m = self.manifest()
|
|
rel = "Top/Ideas/Chain-core.md"
|
|
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
|
|
body = f.read()
|
|
m["pages"][rel]["sub_url"] = "x"
|
|
m["pages"][rel]["pushed"] = __import__("hashlib").sha1(
|
|
body.encode()).hexdigest()
|
|
with open(os.path.join(self.space, ".pages.json"), "w") as f:
|
|
json.dump(m, f)
|
|
|
|
src = os.path.join(self.src, "ideas", "02-chain-core.md")
|
|
with open(src, encoding="utf-8") as f:
|
|
text = f.read()
|
|
with open(src, "w") as f:
|
|
f.write(text.replace("## Chain core", "## Chain core renamed"))
|
|
self.do_import("--retitle")
|
|
|
|
moved = self.manifest()["pages"]["Top/Ideas/Chain-core-renamed.md"]
|
|
self.assertNotIn("pushed", moved)
|
|
|
|
def test_ls_reports_an_unpublished_page_as_local(self):
|
|
self.do_import()
|
|
r = self.run_script("page_ls.py", "--space", "s")
|
|
self.assertIn("local", r.stdout)
|
|
self.assertNotIn("synced", r.stdout)
|
|
|
|
def test_ls_distinguishes_a_missing_space_from_an_empty_one(self):
|
|
r = self.run_script("page_ls.py", "--space", "nope")
|
|
self.assertNotEqual(r.returncode, 0)
|
|
self.assertIn("no such space", r.stderr)
|
|
|
|
def test_index_is_written_as_an_ordinary_page(self):
|
|
self.do_import()
|
|
r = self.run_script("page_index.py", "--space", "s", "--prefix", "Top")
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertIn("Top.md", self.manifest()["pages"])
|
|
with open(os.path.join(self.space, "Top.md"), encoding="utf-8") as f:
|
|
body = f.read()
|
|
self.assertIn("- [[Top/Ideas|Ideas]]", body)
|
|
self.assertIn(" - [[Top/Ideas/Chain core|Chain core]]", body)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# the layering rule, mechanically
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestLayering(unittest.TestCase):
|
|
|
|
def test_the_page_layer_is_stdlib_only(self):
|
|
"""skills/page must keep working with skills/wiki deleted — so no
|
|
transport, and above all no subprocess, in the domain layer."""
|
|
imported = set()
|
|
for name in sorted(os.listdir(PAGE_SCRIPTS)):
|
|
if not name.endswith(".py"):
|
|
continue
|
|
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
|
|
for line in f:
|
|
if line.startswith(("import ", "from ")):
|
|
imported.add(line.split()[1].split(".")[0])
|
|
foreign = imported - {"page"} - sys.stdlib_module_names
|
|
self.assertEqual(foreign, set(),
|
|
"non-stdlib import in the page layer: %s"
|
|
% ", ".join(sorted(foreign)))
|
|
self.assertNotIn("subprocess", imported)
|
|
|
|
def test_the_page_layer_never_mentions_a_tracker(self):
|
|
"""A sub_url, a login, an HTTP verb in skills/page means the concept is
|
|
in the wrong layer."""
|
|
banned = ("tea api", "_gitea", "GITEA_LOGIN", "content_base64")
|
|
for name in sorted(os.listdir(PAGE_SCRIPTS)):
|
|
if not name.endswith(".py"):
|
|
continue
|
|
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
|
|
body = f.read()
|
|
for word in banned:
|
|
self.assertNotIn(word, body,
|
|
"%s mentions %r" % (name, word))
|
|
|
|
def test_wikimap_is_pure(self):
|
|
"""The translation layer holds no transport and no I/O: give it a
|
|
payload, get a page; give it a page, get a request body. Checked on the
|
|
imports, not on the prose — the docstring names the things it refuses
|
|
to do."""
|
|
with open(os.path.join(WIKI_SCRIPTS, "wikimap.py")) as f:
|
|
imported = {line.split()[1].split(".")[0] for line in f
|
|
if line.startswith(("import ", "from "))}
|
|
self.assertEqual(imported, {"base64"},
|
|
"wikimap.py imports more than the translation needs")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|