feat(cms): U2 render content/ into detail pages (characterization-verified)
Add tools/build_content.py: renders every content/ entry into its blog / news / event detail page by self-templating the page's own chrome and replacing only content-derived regions (title, article header, hero, body, and — for new pages only — the scrollspy). Faithful reproduction, proven against the live site: - body_format: html fragments injected verbatim, so guide-boxes, inline figures and event galleries render unchanged. - scrollspy sidebars are preserved (labels are hand-curated, shorter than the H2s); only generated when a page has none. - <title> suffix derived per page, so EN/SI " - MSOS", MK " - МСОС", and suffix-less event titles all reproduce exactly. - hero /images/… rewritten to the page's ../../../ depth. - ASCII-only console output (no cp1252 crash on Windows/CI). tools/tests/test_build_content.py: 8 characterization tests; the core one renders all 51 pages and asserts each is semantically equivalent (whitespace/attr-order-insensitive) to the current page — the R5 no-regression guarantee. Does NOT overwrite the committed pages; whether generated pages are committed vs built fresh in CI is decided in U4/U5. Run: python tools/build_content.py --check # verify equivalence python tools/build_content.py --write # emit pages (for U4) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9ddd0f88ef
commit
596290acf8
3 files changed
+379
No files matched your search
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Characterization tests for tools/build_content.py (U2).
|
||||
|
||||
The core guarantee (plan R5): rendering every content/ entry reproduces the
|
||||
CURRENT live detail page, compared semantically (whitespace- and
|
||||
attribute-order-insensitive). This is what protects against SEO/layout
|
||||
regressions when the build takes over page generation.
|
||||
|
||||
Run directly: python tools/tests/test_build_content.py
|
||||
Or with pytest: pytest tools/tests/test_build_content.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ROOT = os.path.dirname(TOOLS)
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
import build_content as bc # noqa: E402
|
||||
|
||||
|
||||
def _render(out_rel):
|
||||
"""Render the content entry whose output path ends with out_rel."""
|
||||
for meta, body, _path in bc.iter_entries():
|
||||
if bc.output_path(meta).replace(os.sep, "/").endswith(out_rel):
|
||||
base = bc._template_html(meta)
|
||||
return meta, bc.build_entry(meta, body, base)
|
||||
raise AssertionError(f"no content entry maps to {out_rel}")
|
||||
|
||||
|
||||
def _current(out_rel):
|
||||
with open(os.path.join(ROOT, out_rel), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class ReproducesEverySite(unittest.TestCase):
|
||||
def test_all_pages_semantically_equivalent(self):
|
||||
"""Every rendered page must equal the current page (semantic compare)."""
|
||||
_built, mismatches = bc.run(check=True, write=False)
|
||||
self.assertEqual(mismatches, [], f"pages diverged from the live site: {mismatches}")
|
||||
|
||||
|
||||
class TitleSuffixFaithful(unittest.TestCase):
|
||||
def test_cyrillic_suffix_preserved(self):
|
||||
# MK pages use ' - МСОС' (Cyrillic); it must survive, not be dropped.
|
||||
meta, rendered = _render("mk/projects/macedonian-student-night-in-ljubljana/index.html")
|
||||
self.assertIn("- МСОС</title>", rendered) # - МСОС
|
||||
|
||||
def test_no_suffix_stays_absent(self):
|
||||
# Some event pages have no ' - MSOS' suffix; we must not invent one.
|
||||
_meta, rendered = _render("en/projects/morning-coffee-in-front-of-ctk/index.html")
|
||||
self.assertNotIn("- MSOS</title>", rendered)
|
||||
|
||||
|
||||
class RichContentSurvives(unittest.TestCase):
|
||||
def test_event_gallery_and_lightbox_render(self):
|
||||
_meta, rendered = _render("en/projects/paint-and-wine-at-sunset/index.html")
|
||||
self.assertIn("event-gallery", rendered)
|
||||
self.assertIn("eg-thumb", rendered)
|
||||
|
||||
def test_blog_guidebox_and_scrollspy_render(self):
|
||||
_meta, rendered = _render("en/blog/how-to-open-slovenian-bank-account/index.html")
|
||||
self.assertIn("guide-box", rendered)
|
||||
self.assertIn('class="scrollspy-nav"', rendered)
|
||||
|
||||
|
||||
class ScrollspyPreservesCuratedLabels(unittest.TestCase):
|
||||
def test_hand_shortened_labels_not_overwritten(self):
|
||||
# The body H2 is long; the sidebar label was shortened by hand. The
|
||||
# rendered page must keep the curated short label, not the H2 text.
|
||||
_meta, rendered = _render("en/projects/macedonian-student-night-in-ljubljana/index.html")
|
||||
self.assertIn(">Idea to Movement</a>", rendered)
|
||||
|
||||
|
||||
class HeroPathDepth(unittest.TestCase):
|
||||
def test_hero_src_uses_relative_depth(self):
|
||||
_meta, rendered = _render("en/blog/how-to-open-slovenian-bank-account/index.html")
|
||||
# Hero must resolve from the page's depth, not a root-absolute path.
|
||||
self.assertIn('src="../../../images/', rendered)
|
||||
self.assertNotIn('src="/images/', rendered)
|
||||
|
||||
|
||||
class NewsStructure(unittest.TestCase):
|
||||
def test_news_uses_news_article_body(self):
|
||||
_meta, rendered = _render("en/news/proof-of-means-updated-2026/index.html")
|
||||
self.assertIn('class="news-article-body"', rendered)
|
||||
self.assertIn('class="news-article-date"', rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in new issue
Block a user