.*?', + body + "\n ", base, "article-body") + return base + + +def build_news_page(meta, body, base): + """news pages (main.news-article).""" + title = html_mod.escape(meta["title"]) + base = _sub1(r".*?", f"{title}{_title_suffix(base, title)}", base, "title") + base = _sub1(r"

.*?

", f"

{title}

", base, "news h1") + base = _sub1(r'', + f'', base, "news date") + hero = render_hero(meta.get("hero"), "news-article-hero") + if hero: + base = _sub1(r'
.*?
', hero, base, "news hero") + base = _sub1(r'
.*?', + body + "\n ", base, "news body") + return base + + +def build_entry(meta, body, base_html): + if meta["type"] == "news": + return build_news_page(meta, body, base_html) + return build_article_page(meta, body, base_html) + + +def _template_html(meta): + """Self-template: use the page's own current file as the chrome skeleton.""" + out = output_path(meta) + if not os.path.exists(out): + raise FileNotFoundError( + f"no template page for {meta['lang']}/{meta['type']}/{meta['slug']} " + f"(new-page templates are handled in a later unit)") + with open(out, encoding="utf-8") as f: + return f.read() + + +def run(write=False, check=False): + built = 0 + mismatches = [] + for meta, body, path in iter_entries(): + base = _template_html(meta) + rendered = build_entry(meta, body, base) + out = output_path(meta) + if check: + with open(out, encoding="utf-8") as f: + current = f.read() + if _normalize(rendered) != _normalize(current): + mismatches.append(os.path.relpath(out, ROOT).replace(os.sep, "/")) + if write: + with open(out, "w", encoding="utf-8", newline="\n") as f: + f.write(rendered) + built += 1 + + print(f"Rendered {built} pages from content/.") + if check: + if mismatches: + print(f"\n{len(mismatches)} page(s) differ from the current site " + f"(semantic comparison):") + for m in mismatches: + print(f" ~ {m}") + else: + print("All rendered pages are equivalent to the current site. [OK]") + return built, mismatches + + +# ----------------------------------------------------- normalization (for --check) + +def _normalize(html_text): + """Collapse insignificant whitespace and canonicalise attribute order so the + comparison ignores formatting-only differences (per plan R5).""" + from bs4 import BeautifulSoup + soup = BeautifulSoup(html_text, "html.parser") + for tag in soup.find_all(True): + if tag.attrs: + tag.attrs = {k: tag.attrs[k] for k in sorted(tag.attrs)} + text = str(soup) + text = re.sub(r">\s+<", "><", text) # drop whitespace between tags + text = re.sub(r"\s+", " ", text) # collapse runs of whitespace + return text.strip() + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--write", action="store_true", help="write the detail pages") + ap.add_argument("--check", action="store_true", + help="compare rendered output to the current pages (no write)") + args = ap.parse_args() + if not (args.write or args.check): + args.check = True + run(write=args.write, check=args.check) diff --git a/tools/tests/test_build_content.py b/tools/tests/test_build_content.py new file mode 100644 index 00000000..420098b1 --- /dev/null +++ b/tools/tests/test_build_content.py @@ -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("- МСОС", 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", 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", 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)