diff --git a/docs_assets/logos/lakka.svg b/docs_assets/logos/lakka.svg index fc89ee3c..ca13e9b3 100644 --- a/docs_assets/logos/lakka.svg +++ b/docs_assets/logos/lakka.svg @@ -1,36 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/docs_assets/logos/retroarch.svg b/docs_assets/logos/retroarch.svg index a35b434a..a7f0e558 100644 --- a/docs_assets/logos/retroarch.svg +++ b/docs_assets/logos/retroarch.svg @@ -1,12 +1 @@ - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/docs_assets/logos/retrodeck.svg b/docs_assets/logos/retrodeck.svg index 185f9d63..f5e30ab2 100644 --- a/docs_assets/logos/retrodeck.svg +++ b/docs_assets/logos/retrodeck.svg @@ -1,53 +1 @@ - - + \ No newline at end of file diff --git a/scripts/fetch_site_images.py b/scripts/fetch_site_images.py index 783f911c..740eff63 100644 --- a/scripts/fetch_site_images.py +++ b/scripts/fetch_site_images.py @@ -10,7 +10,8 @@ them. The site build only reads those copies (``sitediagrams.localize_images``). Raster logos are normalised to an 80 px square WebP, twice the 40 px they are shown at, and system icons to a 48 px PNG, so every tile has the same -weight. SVG is kept as published. +weight. SVG is minified by svgo, and a raster an SVG embeds is cut to the +tile size. Normalising needs Pillow. This is a maintenance tool run by hand when a logo changes, not a build step: the build stays on the standard library and PyYAML. @@ -22,8 +23,11 @@ and PyYAML. from __future__ import annotations import argparse +import base64 import io import json +import re +import subprocess import sys import urllib.error import urllib.parse @@ -79,6 +83,44 @@ def safe_svg(data: bytes, url: str) -> bytes: return data +# A raster inside an SVG logo: RetroDECK's icon carried a 441 x 334 PNG, +# 9 KiB of a mark drawn 40 px wide. It never needs more pixels than the +# whole tile. +EMBEDDED_RASTER = re.compile(rb'data:image/(?:png|jpeg|webp);base64,([A-Za-z0-9+/=\s]+)') + + +def shrink_embedded(data: bytes) -> bytes: + """Each raster an SVG embeds, at most LOGO_SIZE pixels on its longest side.""" + + def shrink(match: re.Match[bytes]) -> bytes: + try: + from PIL import Image + except ImportError as exc: + raise SystemExit("Pillow is needed to normalise raster images: pip install Pillow") from exc + image = Image.open(io.BytesIO(base64.b64decode(re.sub(rb"\s", b"", match.group(1))))).convert("RGBA") + if max(image.size) <= LOGO_SIZE: + return match.group(0) + image.thumbnail((LOGO_SIZE, LOGO_SIZE), Image.LANCZOS) + out = io.BytesIO() + image.save(out, "PNG", optimize=True) + return b"data:image/png;base64," + base64.b64encode(out.getvalue()) + + return EMBEDDED_RASTER.sub(shrink, data) + + +# Editor markup (Inkscape's namedview, sodipodi attributes, metadata) was +# most of the bytes of two logos. svgo comes with the site's packages. +SVGO = ROOT / "website" / "node_modules" / ".bin" / "svgo" + + +def minify_svg(data: bytes) -> bytes: + if not SVGO.is_file(): + raise SystemExit("svgo is needed to minify SVG logos: npm ci --prefix website") + result = subprocess.run([str(SVGO), "--multipass", "-i", "-", "-o", "-"], + input=data, capture_output=True, check=True) + return result.stdout + + def write_manifest(directory: Path, note: str, entries: list[dict]) -> None: entries.sort(key=lambda entry: entry["file"]) document = {"note": note, "images": entries} @@ -101,7 +143,7 @@ def fetch_logos() -> None: download = f"{url}?s={LOGO_SIZE * 2}" if AVATAR_HOST in url else url data = fetch(download) if is_svg(data): - file_name, payload = f"{name}.svg", safe_svg(data, url) + file_name, payload = f"{name}.svg", minify_svg(shrink_embedded(safe_svg(data, url))) else: file_name, payload = f"{name}.webp", square_image(data, LOGO_SIZE, "WEBP") for stale in LOGO_DIR.glob(f"{name}.*"): diff --git a/tests/test_site_r2_components.py b/tests/test_site_r2_components.py index 0d037a22..07cbbe09 100644 --- a/tests/test_site_r2_components.py +++ b/tests/test_site_r2_components.py @@ -52,7 +52,7 @@ class TablesStackWhenBuilt(unittest.TestCase): def test_the_plugin_is_registered_and_the_runtime_rule_is_gone(self): config = _read(CONFIG) self.assertIn("import stackTables from './src/plugins/rehype-stack-tables.mjs'", config) - self.assertIn("hastPlugins: [stackTables()]", config) + self.assertIn("hastPlugins: [stackTables(), ", config) self.assertNotIn("stackActionTable", _read(SITE_TS)) def test_setup_tables_carry_the_layout_in_the_html(self): diff --git a/tests/test_site_r3_components.py b/tests/test_site_r3_components.py index 0aafcc5a..00b8caee 100644 --- a/tests/test_site_r3_components.py +++ b/tests/test_site_r3_components.py @@ -213,7 +213,7 @@ class HashLookupGivesOneVerdict(unittest.TestCase): source = _read(SITE_TS) self.assertNotIn("'match' : 'matches'", source) self.assertIn("count(platforms.length, 'declaration', 'declarations')} by ${count(setups.size, 'setup', 'setups')}", source) - self.assertIn("count(setups.size, 'setup lists', 'setups list')", source) + self.assertIn("count(listers.size, 'setup lists', 'setups list')", source) self.assertIn("'file in the collection', 'files in the collection'", source) def test_emulators_by_name(self): @@ -291,14 +291,16 @@ class SearchFoldsPagesUnderAHash(unittest.TestCase): def test_search_fields_are_weighted_and_never_shown(self): config = _read(CONFIG) - self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5 },", config) - self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices']);", _read(SITE_TS)) + self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5, consoles: 10, terms: 10 },", config) + self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'terms']);", _read(SITE_TS)) -class OsTabsKeepTheChoiceInView(unittest.TestCase): - def test_the_row_scrolls_not_the_page(self): +class OsTabsNeverScrollThePage(unittest.TestCase): + """The tabs wrap since round 4 (retrobios.css): the script picks the + visitor's tab and never scrolls the page.""" + + def test_no_page_scroll(self): source = _read(SITE_TS) - self.assertIn("list.scrollLeft", source) self.assertNotIn("scrollIntoView", source) self.assertIn("osTabs();", source) diff --git a/tests/test_site_r4_components.py b/tests/test_site_r4_components.py new file mode 100644 index 00000000..40a6fac5 --- /dev/null +++ b/tests/test_site_r4_components.py @@ -0,0 +1,266 @@ +"""Round 4, package 2: components and runtime. + +Source checks always run. Checks on the built site read website/dist and +skip when no build is there. The rules written in TypeScript or in a build +plugin run under node and skip without it. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +WEBSITE = REPO_ROOT / "website" +DIST = WEBSITE / "dist" +COMPONENTS = WEBSITE / "src" / "components" +SITE_TS = WEBSITE / "src" / "scripts" / "site.ts" +CONFIG = WEBSITE / "astro.config.mjs" +SHORT_CODE = WEBSITE / "src" / "plugins" / "rehype-short-code.mjs" +LOGOS = REPO_ROOT / "docs_assets" / "logos" + +# The four hashes of one dump, bios/Sony/PlayStation/scph5501.bin. +SCPH5501 = { + "md5": "490f666e1afb15b7362b406ed1cea246", + "sha1": "0555c6fae8906f3f09baf5988f00e55f88e9f30b", + "crc32": "8d8cb7e4", + "sha256": "11052b6499e466bbf0a709b1f9cb6834a9418e66680387912451e971cf8a1fef", +} +ANDROID_UA = ("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _built(page: str) -> str: + path = DIST / page / "index.html" + if not path.is_file(): + raise unittest.SkipTest(f"no built site at {path}") + return _read(path) + + +def _node(script: str) -> str: + node = shutil.which("node") + if not node: + raise unittest.SkipTest("node is missing") + result = subprocess.run( + [node, "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, check=True, cwd=WEBSITE, + ) + return result.stdout + + +def _short_unmarked(html: str) -> list[str]: + """Inline code of 30 characters or fewer without rb-short, outside pre.""" + body = re.sub(r"", "", html, flags=re.S) + found = [] + for attributes, text in re.findall(r"]*)?>([^<]*)", body): + plain = re.sub(r"&(?:#\d+|#x[0-9a-f]+|[a-z]+);", "_", text, flags=re.I) + if 0 < len(plain) <= 30 and "rb-short" not in (attributes or ""): + found.append(text) + return found + + +class OneVerdictWhateverHash(unittest.TestCase): + """2.1: the SHA1 of scph5501.bin found 4 setups, its MD5 12, its SHA256 + none (V1, J4). A setup's list writes one hash of a file.""" + + def test_the_other_hashes_are_read(self): + source = _read(SITE_TS) + self.assertIn("const heldHashes = (file: Held) =>", source) + self.assertIn("await lookupValues(others)", source) + self.assertIn("item.sha1 ?? files.find((file) => file && heldHashes(file).includes(item.value))?.sha1", source) + self.assertIn("(fileOf(declared) ?? item.sha1) === item.sha1", source) + + def test_the_verdict_names_the_other_name(self): + source = _read(SITE_TS) + self.assertIn("'another name' : 'other names'}: `", source) + self.assertNotIn("under the ${otherNames.length === 1 ? 'name' : 'names'}", source) + + def test_entries_match_on_any_hash_of_the_file(self): + source = _read(SITE_TS) + self.assertIn("const HASH_FIELDS = ['sha1', 'md5', 'crc32', 'sha256'];", source) + self.assertIn("declaredEntry(item, valuesOf(item))", source) + self.assertIn("readerEntry(reader.get(item.owner ?? ''), item, valuesOf(item))", source) + + def test_what_the_renderer_reads_is_built(self): + """Each hash finds the collection record with its SHA1, and the + manufacturer file gives the other three, so every paste reads the + same four shards.""" + api = DIST / "api" / "v1" + if not (api / "lookup").is_dir(): + self.skipTest("no built site") + for value in SCPH5501.values(): + with self.subTest(value=value): + shard = json.loads(_read(api / "lookup" / f"{value[:2]}.json"))["items"] + held = [i for i in shard if i["value"] == value and i["layer"] == "collection"] + self.assertEqual([i["sha1"] for i in held], [SCPH5501["sha1"]]) + self.assertIn("/systems/sony-playstation/", held[0]["url"]) + consoles = json.loads(_read(api / "systems" / "sony.json"))["items"][0]["consoles"] + card = next(f for files in consoles.values() for f in files if f["sha1"] == SCPH5501["sha1"]) + self.assertEqual({k: card[k] for k in SCPH5501}, SCPH5501) + + +class TheVisitorsTab(unittest.TestCase): + """2.2: on an Android phone the tabs opened on Windows (V4, T-F1).""" + + def _rule(self) -> str: + source = _read(SITE_TS) + return source[source.index("const OS_TAB_RULES"):source.index("// End of the OS tab rule.")] + + def test_labels_from_the_user_agent(self): + cases = [ + [ANDROID_UA, ""], + ["Mozilla/5.0 (X11; Linux x86_64) Valve Steam GameOverlay/1729", "Linux"], + ["Mozilla/5.0 (X11; Linux x86_64; SteamOS) Firefox/130.0", ""], + ["Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) Safari/605.1.15", ""], + ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0", "Windows"], + ["Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0", "Linux"], + ["Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Safari/604.1", ""], + ["Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) Chrome/130.0", "Chrome OS"], + ] + out = _node(self._rule() + f"console.log(JSON.stringify({json.dumps(cases)}.map(([ua, p]) => osTabLabel(ua, p))));") + self.assertEqual(json.loads(out), [ + "Android (Termux)", "Steam Deck", "Steam Deck", "macOS", "Windows", "Linux", "", "", + ]) + + def test_labels_are_tab_labels(self): + guides = _read(REPO_ROOT / "platforms" / "_guides.yml") + for label in re.findall(r"\[/[^/]+/i, '([^']+)'\]", self._rule()): + with self.subTest(label=label): + self.assertIn(f"label: {label}\n", guides) + + def test_a_saved_choice_wins_and_the_guess_is_not_saved(self): + source = _read(SITE_TS) + block = source[source.index("const osTabs = () =>"):source.index("// The sidebar lists")] + self.assertIn("savedTab(OS_SYNC_KEY)", block) + self.assertIn("set.switchTab(set.tabs[index], index, false)", block) + self.assertNotIn("localStorage.setItem", source) + self.assertNotIn("keepTabInView", source) + + +class TheSidebarShowsTheCurrentPage(unittest.TestCase): + """2.3: the current link sat at y 1364 in a 900 px window (V5).""" + + def test_the_pane_scrolls_never_the_page(self): + source = _read(SITE_TS) + block = source[source.index("const showCurrentPage = () =>"):source.index("// A glossary term")] + self.assertIn("pane.scrollTop +=", block) + self.assertIn("pane.clientHeight / 3", block) + self.assertIn("addEventListener('toggle'", block) + self.assertNotIn("scrollIntoView", source) + self.assertIn("showCurrentPage();", source) + + +class ChromeOutOfTheIndex(unittest.TestCase): + """2.4: excerpts opened on the title and closed on the licence line, and + "bios" matched every page through the footer (N2, V6).""" + + def test_components(self): + title = _read(COMPONENTS / "PageTitle.astro") + self.assertIn('