refactor!: drop the wiki and page layers
The plugin is issues and nothing else now. `skills/page` (the page-tree domain) and `skills/wiki` (its bridge to a Gitea wiki) are gone, and with them the issue domain's `wiki:` field — page titles were the only thing that tied the two domains together, and a field the tracker has no column for never came back from a pull anyway. What is left is the shape AGENTS.md already claimed for the rest of the repo: one domain, one bridge, one transport. The docs, the plugin manifest, and tea-runner's skill table now say so too, and test_payload_root walks the one script directory that remains. Also removes openspec/config.yaml; nothing in the repo referenced it. 378 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,522 +0,0 @@
|
||||
#!/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"))
|
||||
# auth is in the list because the transport resolves the login pin
|
||||
# through skills/auth/scripts/pin.py — one search order, one module.
|
||||
for layer in ("page", "wiki", "sync", "auth"):
|
||||
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
||||
os.path.join(self.root, "skills", layer, "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()
|
||||
+12
-13
@@ -27,7 +27,6 @@ import unittest
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
|
||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||
|
||||
sys.path.insert(0, SYNC_SCRIPTS)
|
||||
@@ -44,7 +43,7 @@ FAKE_TEA = '''#!%s
|
||||
import json, os, sys
|
||||
with open(os.path.join(os.environ["TEA_CALL_LOG"], "calls.txt"), "a") as f:
|
||||
f.write("\\t".join(sys.argv[1:]) + "\\n")
|
||||
sys.stdout.write(json.dumps({"id": 1, "name": "created", "sub_url": "Page"})
|
||||
sys.stdout.write(json.dumps({"id": 1, "name": "created"})
|
||||
if "-X" in sys.argv else "[]")
|
||||
'''
|
||||
|
||||
@@ -223,21 +222,21 @@ class TestOnePlaceForEveryCaller(unittest.TestCase):
|
||||
def hits(self, needle, skip_transport=False):
|
||||
"""Every `layer/script.py:line` mentioning `needle`."""
|
||||
out = []
|
||||
for d in (SYNC_SCRIPTS, WIKI_SCRIPTS):
|
||||
layer = os.path.basename(os.path.dirname(d))
|
||||
for name in sorted(os.listdir(d)):
|
||||
if not name.endswith(".py") or (skip_transport and name == "_gitea.py"):
|
||||
continue
|
||||
with open(os.path.join(d, name)) as f:
|
||||
for n, line in enumerate(f, 1):
|
||||
if needle in line:
|
||||
out.append("%s/%s:%d" % (layer, name, n))
|
||||
d = SYNC_SCRIPTS
|
||||
layer = os.path.basename(os.path.dirname(d))
|
||||
for name in sorted(os.listdir(d)):
|
||||
if not name.endswith(".py") or (skip_transport and name == "_gitea.py"):
|
||||
continue
|
||||
with open(os.path.join(d, name)) as f:
|
||||
for n, line in enumerate(f, 1):
|
||||
if needle in line:
|
||||
out.append("%s/%s:%d" % (layer, name, n))
|
||||
return out
|
||||
|
||||
def test_no_caller_chooses_where_its_payload_goes(self):
|
||||
"""Whatever the answer is, it has to be the same for all of them —
|
||||
payload files scattered across two stores and a wiki space is the
|
||||
state this replaced."""
|
||||
payload files scattered across the stores of whichever command wrote
|
||||
them is the state this replaced."""
|
||||
self.assertEqual(self.hits("out_root"), [],
|
||||
"a caller still picks a payload directory of its own")
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
`wiki:` is a domain field, and the parser now agrees with the format.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
The bug: `references/format.md` put `wiki:` in the table of domain fields,
|
||||
between `depends` and `origin`, and `issue.py` had never heard of it. The
|
||||
field fell into `extra` and rendered with the foreign keys — sorted in beside
|
||||
`branch`, `gitea`, `synced`, `url`, i.e. AFTER the sync fields, which the same
|
||||
document forbids one line further down. A list written without brackets parsed
|
||||
as a single string, and nothing could set the field but a text editor.
|
||||
|
||||
These tests pin the resolution: implemented in the domain, rendered among the
|
||||
domain fields, parsed as a list in both forms, and reachable from the command
|
||||
line. The layer rule rides along — a title is a name for a document, so the
|
||||
field carries titles and this layer never resolves one.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||
|
||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||
import issue # noqa: E402
|
||||
|
||||
TITLES = ["Simple Chains/Ideas/Chain core", "Simple Chains/Ideas/Transport"]
|
||||
|
||||
SYNCED = """\
|
||||
---
|
||||
id: wire-sqlc-appclick
|
||||
state: open
|
||||
labels: [type/task]
|
||||
assignees: []
|
||||
milestone: none
|
||||
depends: [migrate-schema]
|
||||
wiki: [Simple Chains/Ideas/Chain core]
|
||||
origin: gitea
|
||||
branch: feat/wire-sqlc
|
||||
gitea: claude-skills/tea#42
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
---
|
||||
# Wire sqlc into the appclick repo layer
|
||||
|
||||
## Summary
|
||||
Тело роли не играет.
|
||||
"""
|
||||
|
||||
|
||||
class TestTheFieldIsInTheDomain(unittest.TestCase):
|
||||
|
||||
def test_it_is_a_domain_key_and_a_list_key(self):
|
||||
self.assertIn("wiki", issue.DOMAIN_KEYS)
|
||||
self.assertIn("wiki", issue.LIST_KEYS)
|
||||
|
||||
def test_it_renders_between_depends_and_origin(self):
|
||||
"""`format.md` states the order and says domain fields render first.
|
||||
The old behavior put it after the sync fields."""
|
||||
order = issue.DOMAIN_KEYS
|
||||
self.assertEqual(order[order.index("depends") + 1], "wiki")
|
||||
self.assertEqual(order[order.index("wiki") + 1], "origin")
|
||||
|
||||
def test_it_survives_a_round_trip_among_the_domain_fields(self):
|
||||
iss = issue.Issue.from_text(SYNCED, id="wire-sqlc-appclick")
|
||||
self.assertEqual(iss.wiki, ["Simple Chains/Ideas/Chain core"])
|
||||
self.assertNotIn("wiki", iss.extra)
|
||||
|
||||
text = iss.to_text()
|
||||
keys = [line.split(":", 1)[0]
|
||||
for line in text.splitlines()[1:]
|
||||
if line != "---" and ":" in line]
|
||||
keys = keys[:keys.index("origin") + 1]
|
||||
self.assertEqual(keys[-3:], ["depends", "wiki", "origin"])
|
||||
self.assertLess(keys.index("wiki"), keys.index("origin"))
|
||||
|
||||
again = issue.Issue.from_text(text, id="wire-sqlc-appclick")
|
||||
self.assertEqual(again.wiki, iss.wiki)
|
||||
|
||||
def test_a_bracketless_list_is_still_a_list(self):
|
||||
"""Without membership in LIST_KEYS this parsed as one string —
|
||||
`wiki: A, B` became the single title "A, B"."""
|
||||
text = SYNCED.replace("wiki: [Simple Chains/Ideas/Chain core]",
|
||||
"wiki: %s" % ", ".join(TITLES))
|
||||
self.assertEqual(issue.Issue.from_text(text).wiki, TITLES)
|
||||
|
||||
def test_the_bracketed_form_parses_the_same_way(self):
|
||||
text = SYNCED.replace("wiki: [Simple Chains/Ideas/Chain core]",
|
||||
"wiki: [%s]" % ", ".join(TITLES))
|
||||
self.assertEqual(issue.Issue.from_text(text).wiki, TITLES)
|
||||
|
||||
def test_an_absent_field_is_an_empty_list_and_renders_as_one(self):
|
||||
text = "\n".join(l for l in SYNCED.splitlines()
|
||||
if not l.startswith("wiki:"))
|
||||
iss = issue.Issue.from_text(text)
|
||||
self.assertEqual(iss.wiki, [])
|
||||
self.assertIn("wiki: []", iss.to_text())
|
||||
|
||||
def test_the_titles_are_carried_verbatim(self):
|
||||
"""A title with a slash in it is one title — the slash is hierarchy
|
||||
inside the name, not a path this layer walks."""
|
||||
iss = issue.Issue(id="x", title="X", wiki=TITLES)
|
||||
self.assertIn("wiki: [%s]" % ", ".join(TITLES), iss.to_text())
|
||||
|
||||
def test_the_domain_still_knows_nothing_about_a_wiki_it_could_reach(self):
|
||||
"""The layer rule: titles only. No page path, no sub_url, no HTTP."""
|
||||
with open(os.path.join(ISSUE_SCRIPTS, "issue.py")) as f:
|
||||
body = f.read()
|
||||
for banned in ("sub_url", "content_base64", "urllib"):
|
||||
self.assertNotIn(banned, body)
|
||||
imports = [l for l in body.splitlines()
|
||||
if l.startswith("import ") or l.startswith("from ")]
|
||||
self.assertNotIn("import subprocess", imports)
|
||||
|
||||
|
||||
class TestIssueNewCanSetIt(unittest.TestCase):
|
||||
"""The script run for real, in a throwaway store — never the developer's."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-wiki-field-")
|
||||
self.out = os.path.join(os.path.realpath(self._tmp.name), "issues")
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
|
||||
def new(self, *args):
|
||||
p = subprocess.run(
|
||||
[sys.executable, os.path.join(ISSUE_SCRIPTS, "issue_new.py"),
|
||||
"--type", "task", "--title", "Write the chain core up",
|
||||
"--out", self.out] + list(args),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(p.returncode, 0, p.stdout + p.stderr)
|
||||
with open(os.path.join(self.out, "write-the-chain-core-up.md")) as f:
|
||||
return f.read()
|
||||
|
||||
def test_the_flag_repeats_into_a_list(self):
|
||||
text = self.new("--wiki", TITLES[0], "--wiki", TITLES[1])
|
||||
self.assertIn("wiki: [%s]" % ", ".join(TITLES), text)
|
||||
self.assertEqual(issue.Issue.from_text(text).wiki, TITLES)
|
||||
|
||||
def test_without_the_flag_the_field_is_present_and_empty(self):
|
||||
self.assertIn("wiki: []", self.new())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user