mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
* fix(website): keep text inside small screens and tidy small type and spacing From a layout audit of the built pages: - Long terminal prompts and code-header buttons wrap instead of being cut off at 320px (WCAG 1.4.10). - Headings and table cells break long words, so the 1.4.12 text-spacing test no longer widens pages past 320px. - Header and footer links get a 24px minimum height (2.5.8). - Labels that rendered under 12px now render at 12px or more, and inline code never drops under 0.75rem. - The home page's hero and definition rows share one column gap and align on the first baseline, and stacked boxes share one corner size. - Highlight chips get room around the text, and the active run in a step listing is one band instead of a box per line. Colors are unchanged. 🤖 Generated with Claude Code (Sonnet 5.5) 🦊 * feat(website): follow the system dark mode The site was light only, so a reader with a dark system still got a white page. Add one prefers-color-scheme: dark block to the stylesheet that swaps the six color variables to Catppuccin Mocha (MIT) and overrides the fixed colors that would break on a dark page: selection, buttons, tinted highlights, flow nodes and arrows, and a visible border on the agent terminal. Add a dark theme-color tag beside the light one in the page template and the 17 pages, and a Dark column to the style guide's color table. Light colors are unchanged. The SVG figures follow in the next commit; the blog page's inline strokes, the dinosaur game and the social card are left as they are. 🤖 Generated with Claude Code (Sonnet 5.5) 🦊 * feat(website): give the five SVG figures a dark scheme Pages load the figures with <img>, so page CSS cannot reach inside them. Each figure now carries its own @media (prefers-color-scheme: dark) block of attribute-selector overrides that map every light color to a Catppuccin Mocha color, with one shared map across all five. No markup, labels, shapes or positions change, and nothing uses !important. Four figures also paint a mantle canvas so "Open figure" works on its own; analyst-thinking.svg stays transparent, since it sits on the page rather than in a figure box. 🤖 Generated with Claude Code (Sonnet 5.5) 🦊 * chore(website): refresh translation source references The theme-color tags move the page lines that the catalogs cite, so python3 scripts/website.py translations extract rewrites the source references in messages.pot and the 18 PO files. No message text changes. The same run moves the "Main navigation" entry to its new position in each catalog. 🤖 Generated with Claude Code (Sonnet 5.5) 🦊 * fix(website): keep inline step highlights inline The full-width band for the active step now applies only to code-step blocks. The inline parameter highlights in the dx-ball and th04 signatures stay inline again. 🤖 Generated with Claude Code (Opus 5.5) 🦊 * test(website): check every page offers light and dark theme colors Each authored reading page, and its built copy in every language, must carry one theme-color tag for light and one for dark. The stylesheet must keep its dark color scheme block. On the commit before dark mode, this test fails on every page. 🤖 Generated with Claude Code (Opus 5.5) 🦊 * fix(website): draw the dinosaur game in the page colors In dark mode the game box turned dark, but the game still drew with its light colors, so the dinosaur, cacti and text fell to about 2:1 contrast. The canvas now reads five colors from CSS variables on .dino-stage. Light values are the old ones, so light mode draws the same pixels. Dark values are Catppuccin Mocha: 8.3:1 for the dinosaur, 7.9:1 for cacti and text, 7.6:1 for the crash color. The moved lines in dino-demo.js shift the translation catalog references, so the catalogs are refreshed too (reference comments only). 🤖 Generated with Claude Code (Opus 5.5) 🦊 * fix(website): recolor the Touhou flywheel arrows in dark mode The inline flywheel SVG on the Touhou article hard-codes its stroke colors, so on the dark page its connectors fell to about 2:1. Two dark-only rules map those strokes to the page accent and muted colors (7.8:1 and 7.4:1). Light mode is unchanged. 🤖 Generated with Claude Code (Opus 5.5) 🦊 * fix(website): redraw the dinosaur game when the system theme changes The canvas reads its colors only when it draws, so an idle, paused or crashed game kept the old palette after a theme switch. Each demo now redraws on a prefers-color-scheme change. The added lines shift the translation catalog references, so the catalogs are refreshed too (reference comments only). 🤖 Generated with Claude Code (Opus 5.5) 🦊 --------- Co-authored-by: Claude Sonnet 5.5 <sonnet@cassie-pc> Co-authored-by: Claude Opus 5.5 <opus@cassie-pc>
423 lines
22 KiB
Python
423 lines
22 KiB
Python
"""Exercise automatic route discovery and mistakes that break search metadata."""
|
||
|
||
import contextlib
|
||
import importlib.util
|
||
import io
|
||
import json
|
||
from pathlib import Path
|
||
import shutil
|
||
import tempfile
|
||
from threading import Thread
|
||
import unittest
|
||
from http.server import ThreadingHTTPServer
|
||
from urllib.error import HTTPError
|
||
from urllib.request import urlopen
|
||
import xml.etree.ElementTree as ET
|
||
|
||
import polib
|
||
from website_i18n import Document, build_translations, checked_catalog, extract_catalogs, languages, reading_pages, read_runtime
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
|
||
def load_script(name):
|
||
"""Load a repository script without executing its command-line entrypoint."""
|
||
spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / f"{name}.py")
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
PREPARE = load_script("prepare-website")
|
||
VERIFY = load_script("verify-website")
|
||
WEBSITE = load_script("website")
|
||
|
||
|
||
class WebsiteSearchChecks(unittest.TestCase):
|
||
"""Use authored pages and temporary fixtures to check the publishing boundary."""
|
||
|
||
def test_sitemap_tracks_added_moved_removed_and_nonindexable_pages(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
site = Path(directory)
|
||
|
||
def page(name, head="", body=""):
|
||
path = site / name
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(f"<html><head>{head}</head><body>{body}</body></html>")
|
||
return path
|
||
|
||
page("index.html")
|
||
nested = page("guides/new/index.html")
|
||
page(
|
||
"examples/fixture/index.html",
|
||
'<meta name="robots" content="NOINDEX, follow">',
|
||
)
|
||
page("draft.html", '<meta name="googlebot" content="none">')
|
||
page("code & notes.html")
|
||
page("visible/index.html", body='<meta name="robots" content="noindex">')
|
||
(site / "asset.json").write_text("{}")
|
||
|
||
def urls():
|
||
with contextlib.redirect_stdout(io.StringIO()):
|
||
PREPARE.prepare_sitemap(site)
|
||
sitemap = ET.parse(site / "sitemap.xml")
|
||
return {node.text for node in sitemap.findall(".//{*}loc")}
|
||
|
||
self.assertEqual(
|
||
urls(),
|
||
{
|
||
"https://rea.tools/",
|
||
"https://rea.tools/guides/new/",
|
||
"https://rea.tools/code%20%26%20notes.html",
|
||
"https://rea.tools/visible/",
|
||
},
|
||
)
|
||
nested.rename(site / "moved.html")
|
||
self.assertIn("https://rea.tools/moved.html", urls())
|
||
self.assertNotIn("https://rea.tools/guides/new/", urls())
|
||
(site / "moved.html").unlink()
|
||
self.assertNotIn("https://rea.tools/moved.html", urls())
|
||
|
||
def test_verifier_rejects_wrong_canonicals_stale_sitemaps_and_missing_previews(
|
||
self,
|
||
):
|
||
cases = (
|
||
(
|
||
"index.html",
|
||
'rel="canonical" href="https://rea.tools/"',
|
||
'rel="canonical" href="https://morluto.github.io/rea/"',
|
||
"head canonical",
|
||
),
|
||
(
|
||
"showcase/aegis/index.html",
|
||
'rel="canonical" href="https://rea.tools/showcase/aegis/"',
|
||
'rel="canonical" href="https://rea.tools/"',
|
||
"head canonical",
|
||
),
|
||
(
|
||
"examples/notes-electron/index.html",
|
||
'content="noindex"',
|
||
'content="index"',
|
||
"must have a head noindex",
|
||
),
|
||
(
|
||
"sitemap.xml",
|
||
"https://rea.tools/faq/",
|
||
"https://rea.tools/deleted/",
|
||
"each indexable page once",
|
||
),
|
||
(
|
||
"faq/index.html",
|
||
'name="twitter:card" content="summary_large_image"',
|
||
'name="twitter:card" content="summary"',
|
||
"twitter:card",
|
||
),
|
||
)
|
||
for name, old, new, message in cases:
|
||
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
|
||
site = Path(directory) / "public"
|
||
shutil.copytree(ROOT / "website/_site", site)
|
||
path = site / name
|
||
source = path.read_text()
|
||
self.assertIn(old, source)
|
||
path.write_text(source.replace(old, new, 1))
|
||
self.assertTrue(
|
||
any(message in error for error in VERIFY.check_site(site)[3])
|
||
)
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
site = Path(directory) / "public"
|
||
shutil.copytree(ROOT / "website/_site", site)
|
||
(site / "assets/social-card.png").unlink()
|
||
self.assertIn(
|
||
"Social preview PNG is missing; run scripts/prepare-website.py",
|
||
VERIFY.check_site(site)[3],
|
||
)
|
||
|
||
|
||
class WebsiteMaintenanceChecks(unittest.TestCase):
|
||
"""Exercise contributor edits and the static preview through real consumers."""
|
||
|
||
def test_shared_edits_preserve_body_and_catch_drift_before_rewriting(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
root = Path(directory)
|
||
for name in ("public", "templates"):
|
||
shutil.copytree(ROOT / "website" / name, root / "website" / name)
|
||
page = root / "website/public/blog/touhou-reconstruction/index.html"
|
||
original = page.read_text()
|
||
body = original.split('<main id="main"', 1)[1].split("</main>", 1)[0]
|
||
template = root / "website/templates/header.html.tmpl"
|
||
template.write_text(template.read_text().replace("Reverse Engineer Anything", "REA test navigation"))
|
||
with self.assertRaisesRegex(ValueError, "Shared HTML is stale"):
|
||
WEBSITE.sync_pages(root, check=True)
|
||
self.assertEqual(page.read_text(), original)
|
||
WEBSITE.sync_pages(root)
|
||
updated = page.read_text()
|
||
self.assertIn("REA test navigation", updated)
|
||
self.assertEqual(updated.split('<main id="main"', 1)[1].split("</main>", 1)[0], body)
|
||
self.assertEqual(WEBSITE.sync_pages(root), [])
|
||
page.write_text(updated.replace(' <!-- website:header:end -->', '', 1))
|
||
with self.assertRaisesRegex(ValueError, "needs one header region"):
|
||
WEBSITE.sync_pages(root)
|
||
|
||
def test_new_page_escapes_metadata_stays_a_draft_and_joins_sitemap_when_ready(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
root = Path(directory)
|
||
for name in ("public", "templates"):
|
||
shutil.copytree(ROOT / "website" / name, root / "website" / name)
|
||
site = root / "website/public"
|
||
WEBSITE.sync_pages(root)
|
||
shutil.copyfile(ROOT / "website/_site/assets/social-card.png", site / "assets/social-card.png")
|
||
PREPARE.prepare_notes_example(site)
|
||
page = WEBSITE.new_page(root, "blog/new-example", 'A <test> & "result"', 'Check "quotes" & <markup>.')
|
||
source = page.read_text()
|
||
self.assertIn('href="../../blog/" aria-current="page"', source)
|
||
self.assertIn('href="../../assets/styles.css"', source)
|
||
self.assertIn("A <test> & "result"", source)
|
||
with self.assertRaisesRegex(ValueError, "Page already exists"):
|
||
WEBSITE.new_page(root, "blog/new-example", "Another page", "Description")
|
||
with self.assertRaises(ValueError):
|
||
WEBSITE.new_page(root, "../outside", "Title", "Description")
|
||
WEBSITE.sync_pages(root, check=True)
|
||
PREPARE.prepare_sitemap(site)
|
||
self.assertNotIn("https://rea.tools/blog/new-example/", (site / "sitemap.xml").read_text())
|
||
page.write_text(source.replace(' <meta name="robots" content="noindex" />\n', ''))
|
||
PREPARE.prepare_sitemap(site)
|
||
self.assertIn("https://rea.tools/blog/new-example/", (site / "sitemap.xml").read_text())
|
||
self.assertEqual(VERIFY.check_site(site)[3], [])
|
||
|
||
def test_links_in_prompts_fragments_and_copy_controls_are_verified(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
site = Path(directory) / "public"
|
||
shutil.copytree(ROOT / "website/_site", site)
|
||
page = site / "faq/index.html"
|
||
original = page.read_text()
|
||
cases = (
|
||
('<a href="https://rea.tools/deleted/">Broken</a>', "missing local target"),
|
||
('<code>https://rea.tools/guides/native/#deleted</code>', "missing HTML fragment"),
|
||
('<code>https://morluto.github.io/rea/examples/notes-web/</code>', "obsolete website URL"),
|
||
('<button data-copy="deleted">Copy</button>', "missing copy target"),
|
||
('<a href="https://[">Malformed</a>', "invalid URL"),
|
||
)
|
||
for inserted, message in cases:
|
||
with self.subTest(message=message):
|
||
page.write_text(original.replace("</main>", inserted + "</main>", 1))
|
||
self.assertTrue(any(message in error for error in VERIFY.check_site(site)[3]))
|
||
|
||
def test_repository_links_reject_missing_files_and_old_markdown_anchors(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
root = Path(directory)
|
||
(root / "website/public").mkdir(parents=True)
|
||
(root / "docs").mkdir()
|
||
(root / "docs/guide.md").write_text("# Guide\n## Set up REA\n")
|
||
page = root / "website/README.md"
|
||
page.write_text("[Guide](../docs/guide.md#set-up-rea)\n")
|
||
(root / "website/.venv").mkdir()
|
||
(root / "website/.venv/vendor.md").write_text("[File](missing.md)")
|
||
self.assertEqual(VERIFY.check_repository_links(root), [])
|
||
page.write_text("[Guide](../docs/guide.md#removed)\n[File](../docs/removed.md)\n")
|
||
errors = VERIFY.check_repository_links(root)
|
||
self.assertTrue(any("missing Markdown fragment" in error for error in errors))
|
||
self.assertTrue(any("missing repository target" in error for error in errors))
|
||
|
||
def test_preview_preserves_pages_prefix_for_assets_and_directory_redirects(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
site = Path(directory)
|
||
(site / "guide").mkdir()
|
||
(site / "guide/index.html").write_text("<h1>Guide</h1>")
|
||
(site / "asset.css").write_text("body { color: black; }")
|
||
for prefix in ("/", "/rea/"):
|
||
with self.subTest(prefix=prefix), ThreadingHTTPServer(("127.0.0.1", 0), WEBSITE.preview_handler(site, prefix)) as server:
|
||
worker = Thread(target=server.serve_forever, daemon=True)
|
||
worker.start()
|
||
try:
|
||
base = f"http://127.0.0.1:{server.server_port}"
|
||
with urlopen(base + prefix + "guide?test=1") as response:
|
||
self.assertEqual(response.url, base + prefix + "guide/?test=1")
|
||
self.assertEqual(response.read(), b"<h1>Guide</h1>")
|
||
with urlopen(base + prefix + "asset.css") as response:
|
||
self.assertIn(b"color: black", response.read())
|
||
with self.assertRaises(HTTPError) as error:
|
||
urlopen(base + prefix + "missing")
|
||
self.assertEqual(error.exception.code, 404)
|
||
if prefix != "/":
|
||
with self.assertRaises(HTTPError) as error:
|
||
urlopen(base + "/guide/")
|
||
self.assertEqual(error.exception.code, 404)
|
||
finally:
|
||
server.shutdown()
|
||
worker.join()
|
||
|
||
|
||
class WebsiteTranslationChecks(unittest.TestCase):
|
||
"""Exercise source edits, translated evidence and language editions at their consumers."""
|
||
|
||
@contextlib.contextmanager
|
||
def translated_project(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
root = Path(directory)
|
||
for name in ("public", "templates", "locales"):
|
||
shutil.copytree(ROOT / "website" / name, root / "website" / name)
|
||
inventory = [item for item in languages(ROOT) if item["code"] in {"en", "zh-CN", "ar"}]
|
||
(root / "website/locales/languages.json").write_text(json.dumps(inventory))
|
||
for item in inventory:
|
||
(root / item["readme"]).write_text("# REA\n")
|
||
WEBSITE.sync_pages(root)
|
||
extract_catalogs(root)
|
||
yield root
|
||
|
||
def test_source_change_preserves_review_and_excludes_stale_translation_from_search(self):
|
||
with self.translated_project() as root:
|
||
path = root / "website/locales/zh-CN/messages.po"
|
||
catalog = polib.pofile(path)
|
||
old = catalog.find("How do I install REA?", msgctxt="page:faq/index.html")
|
||
old.tcomment = "Reviewed installation wording."
|
||
old_translation = old.msgstr
|
||
catalog.save(path)
|
||
page = root / "website/public/faq/index.html"
|
||
page.write_text(page.read_text().replace("How do I install REA?", "How do I install REA today?"))
|
||
with self.assertRaisesRegex(ValueError, "Translation sources changed"):
|
||
extract_catalogs(root, check=True)
|
||
extract_catalogs(root)
|
||
updated = polib.pofile(path)
|
||
archived = next(entry for entry in updated if entry.obsolete and entry.msgid == old.msgid)
|
||
self.assertEqual((archived.msgstr, archived.tcomment), (old_translation, old.tcomment))
|
||
new = updated.find("How do I install REA today?", msgctxt=old.msgctxt)
|
||
self.assertEqual(new.msgstr, "")
|
||
build_translations(root, root / "website/_site")
|
||
site = root / "website/_site"
|
||
PREPARE.prepare_sitemap(site)
|
||
fallback = VERIFY.HtmlReferences()
|
||
fallback.feed((site / "zh-CN/faq/index.html").read_text())
|
||
self.assertTrue(fallback.translation_fallback)
|
||
self.assertTrue(fallback.noindex())
|
||
self.assertEqual(fallback.lang, "en")
|
||
self.assertNotIn("https://rea.tools/zh-CN/faq/", (site / "sitemap.xml").read_text())
|
||
self.assertNotIn('hreflang="zh-CN"', (site / "faq/index.html").read_text())
|
||
new.msgstr = "现在如何安装 REA?"
|
||
new.flags.append("fuzzy")
|
||
updated.save(path)
|
||
build_translations(root, site)
|
||
self.assertIn('data-translation-fallback="true"', (site / "zh-CN/faq/index.html").read_text())
|
||
new.flags.remove("fuzzy")
|
||
updated.save(path)
|
||
build_translations(root, site)
|
||
PREPARE.prepare_sitemap(site)
|
||
result = (site / "zh-CN/faq/index.html").read_text()
|
||
self.assertIn("现在如何安装 REA?", result)
|
||
self.assertNotIn("data-translation-fallback", result)
|
||
self.assertIn('href="../assets/styles.css"', (site / "zh-CN/index.html").read_text())
|
||
self.assertIn('hreflang="zh-CN"', (site / "faq/index.html").read_text())
|
||
self.assertIn("https://rea.tools/zh-CN/faq/", (site / "sitemap.xml").read_text())
|
||
|
||
def test_broken_placeholders_and_inline_markup_fail_before_replacing_preview(self):
|
||
with self.translated_project() as root:
|
||
site = root / "website/_site"
|
||
build_translations(root, site)
|
||
before = (site / "zh-CN/index.html").read_bytes()
|
||
path = root / "website/locales/zh-CN/messages.po"
|
||
catalog = polib.pofile(path)
|
||
entry = catalog.find("Already know RE? {ph0}Skip to analysis guides →{ph1}", msgctxt="page:index.html")
|
||
original = entry.msgstr
|
||
for bad, reason in (("丢失链接", "changed placeholders"), ("{ph1}错误的链接{ph0}", "invalid inline markup")):
|
||
with self.subTest(reason=reason):
|
||
entry.msgstr = bad
|
||
catalog.save(path)
|
||
with self.assertRaisesRegex(ValueError, reason):
|
||
build_translations(root, site)
|
||
self.assertEqual((site / "zh-CN/index.html").read_bytes(), before)
|
||
entry.msgstr = original
|
||
catalog.save(path)
|
||
(root / "website/locales/languages.json").unlink()
|
||
with self.assertRaisesRegex(ValueError, "Missing languages.json"):
|
||
build_translations(root, site)
|
||
self.assertEqual((site / "zh-CN/index.html").read_bytes(), before)
|
||
|
||
def test_runtime_message_drift_and_page_specific_completeness(self):
|
||
with self.translated_project() as root:
|
||
script = root / "website/public/assets/dino-demo.js"
|
||
original = script.read_text()
|
||
script.write_text(original.replace('t("Pause")', 't("Pause game")'))
|
||
self.assertIn("Pause game", read_runtime(root))
|
||
with self.assertRaisesRegex(ValueError, "Translation sources changed"):
|
||
extract_catalogs(root, check=True)
|
||
script.write_text(original.replace('t("Pause")', 't(dynamicMessage)'))
|
||
with self.assertRaisesRegex(ValueError, "one literal message"):
|
||
read_runtime(root)
|
||
script.write_text(original)
|
||
path = root / "website/locales/zh-CN/messages.po"
|
||
catalog = polib.pofile(path)
|
||
catalog.find("Pause", msgctxt="runtime").msgstr = ""
|
||
catalog.save(path)
|
||
site = root / "website/_site"
|
||
build_translations(root, site)
|
||
self.assertIn('data-translation-fallback="true"', (site / "zh-CN/examples/dino-lab/index.html").read_text())
|
||
self.assertNotIn('data-translation-fallback="true"', (site / "zh-CN/faq/index.html").read_text())
|
||
|
||
def test_all_readme_editions_preserve_code_targets_downloads_and_reciprocal_routes(self):
|
||
site = ROOT / "website/_site"
|
||
english_pages = reading_pages(ROOT / "website/public")
|
||
for language in languages(ROOT):
|
||
code = language["code"]
|
||
for original in english_pages:
|
||
relative = original.relative_to(ROOT / "website/public")
|
||
path = site / relative if code == "en" else site / code / relative
|
||
with self.subTest(language=code, page=str(relative)):
|
||
source = original.read_text()
|
||
translated = path.read_text()
|
||
def evidence(html):
|
||
doc = Document(html)
|
||
return sorted(doc.source[node.start:node.end] for node in doc.nodes if node.tag in {"pre", "code", "kbd"} or node.attrs.get("translate") == "no")
|
||
self.assertEqual(evidence(source), evidence(translated))
|
||
parser = VERIFY.HtmlReferences()
|
||
parser.feed(translated)
|
||
self.assertEqual(parser.lang, "en" if parser.translation_fallback else code)
|
||
if code != "en" and not parser.translation_fallback:
|
||
self.assertEqual(parser.direction, language["dir"])
|
||
if parser.translation_fallback:
|
||
self.assertTrue(parser.noindex())
|
||
self.assertNotIn(code, parser.alternates)
|
||
else:
|
||
self.assertEqual(parser.alternates[code], WEBSITE.page_url(site, path))
|
||
english = VERIFY.HtmlReferences()
|
||
english.feed((site / relative).read_text())
|
||
self.assertEqual(parser.alternates, english.alternates)
|
||
self.assertTrue(set(parser.copy_targets) <= parser.ids)
|
||
for fixture in WEBSITE.NOTES_FIXTURES:
|
||
self.assertEqual((site / fixture).read_bytes(), (ROOT / "website/public" / fixture).read_bytes())
|
||
with ThreadingHTTPServer(("127.0.0.1", 0), WEBSITE.preview_handler(site, "/rea/")) as server:
|
||
worker = Thread(target=server.serve_forever, daemon=True)
|
||
worker.start()
|
||
try:
|
||
base = f"http://127.0.0.1:{server.server_port}/rea/"
|
||
with urlopen(base + "ar/faq?test=1") as response:
|
||
self.assertEqual(response.url, base + "ar/faq/?test=1")
|
||
self.assertIn(b'lang="ar"', response.read())
|
||
with urlopen(base + "assets/i18n.js") as response:
|
||
self.assertIn(b"export function t", response.read())
|
||
with urlopen(base + "examples/notes-example.zip") as response:
|
||
self.assertEqual(response.read(), (site / "examples/notes-example.zip").read_bytes())
|
||
finally:
|
||
server.shutdown()
|
||
worker.join()
|
||
|
||
def test_every_reading_page_offers_light_and_dark_theme_colors(self):
|
||
site = ROOT / "website/_site"
|
||
english_pages = reading_pages(ROOT / "website/public")
|
||
self.assertTrue(english_pages)
|
||
for language in languages(ROOT):
|
||
code = language["code"]
|
||
for original in english_pages:
|
||
relative = original.relative_to(ROOT / "website/public")
|
||
for path in {original, site / relative if code == "en" else site / code / relative}:
|
||
with self.subTest(language=code, page=str(path.relative_to(ROOT))):
|
||
doc = Document(path.read_text())
|
||
media = sorted(node.attrs.get("media") for node in doc.nodes if node.tag == "meta" and node.attrs.get("name") == "theme-color")
|
||
self.assertEqual(media, ["(prefers-color-scheme: dark)", "(prefers-color-scheme: light)"])
|
||
styles = (ROOT / "website/public/assets/styles.css").read_text()
|
||
self.assertTrue("@media (prefers-color-scheme: dark)" in styles, "styles.css has no dark color scheme block")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|