mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 04:39:06 -05:00
A profile pinned to one platform's build (pin_frozen) answers after the emulator, as a frozen_snapshot does: Flycast now answers dc bios missing ahead of Reicast and Flycast 2021 LE. terms weighs 30, so the version table answers which mame version first, and the gate now asks for rank 1 there. The search field test names the five fields, since round 4 item 2.4 put the title meta on the h1.
393 lines
18 KiB
Python
393 lines
18 KiB
Python
"""Round 3, 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 hash functions and the table rule run
|
|
under node and skip without it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import unittest
|
|
import zlib
|
|
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"
|
|
PLUGIN = WEBSITE / "src" / "plugins" / "rehype-stack-tables.mjs"
|
|
UNBROKEN = WEBSITE / "src" / "plugins" / "ec-unbroken-tokens.mjs"
|
|
LOGOS = REPO_ROOT / "docs_assets" / "logos"
|
|
|
|
# Shown at 40 px in a tile and beside a setup's title: twice that for a
|
|
# sharp mark on a dense screen.
|
|
LOGO_PIXELS = 80
|
|
TAB_ROOTS = ("", "platforms", "explore", "wiki", "contributing")
|
|
|
|
|
|
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 _title_block(html: str) -> str:
|
|
"""What PageTitle draws: from the main column to the end of the h1."""
|
|
end = html.index("</h1>", html.index('id="_top"'))
|
|
return html[html.rfind("<main", 0, end):end]
|
|
|
|
|
|
def _tables(html: str) -> list[str]:
|
|
return re.findall(r"<table\b.*?</table>", html, re.S)
|
|
|
|
|
|
def _headers(table: str) -> list[str]:
|
|
head = re.search(r"<thead>(.*?)</thead>", table, re.S)
|
|
cells = re.findall(r"<th\b[^>]*>(.*?)</th>", head.group(1), re.S) if head else []
|
|
return [re.sub(r"<[^>]+>", "", cell).strip() for cell in cells]
|
|
|
|
|
|
def _node_module(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 _webp_size(data: bytes) -> tuple[int, int]:
|
|
"""Width and height from a WebP header (VP8, VP8L or VP8X)."""
|
|
if data[:4] != b"RIFF" or data[8:12] != b"WEBP":
|
|
raise ValueError("not a WebP file")
|
|
chunk = data[12:16]
|
|
if chunk == b"VP8 ":
|
|
width, height = struct.unpack("<HH", data[26:30])
|
|
return width & 0x3FFF, height & 0x3FFF
|
|
if chunk == b"VP8L":
|
|
bits = int.from_bytes(data[21:25], "little")
|
|
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
if chunk == b"VP8X":
|
|
return int.from_bytes(data[24:27], "little") + 1, int.from_bytes(data[27:30], "little") + 1
|
|
raise ValueError(f"unknown WebP chunk {chunk!r}")
|
|
|
|
|
|
class TitleLogoOnAPlate(unittest.TestCase):
|
|
"""The RetroArch and Batocera marks are black: beside the title they
|
|
vanished on the dark theme, and the image had no width (Lighthouse
|
|
unsized-images, V3, T-F3)."""
|
|
|
|
def test_the_component_draws_the_tile_square(self):
|
|
title = _read(COMPONENTS / "PageTitle.astro")
|
|
self.assertIn('width={MARK} height={MARK}', title)
|
|
self.assertIn("const MARK = 40;", title)
|
|
style = title[title.index(".rb-title-mark {"):]
|
|
style = style[:style.index("}")]
|
|
for rule in ("width: 40px;", "height: 40px;", "padding: 3px;", "border: 1px solid var(--rb-border);",
|
|
"border-radius: 6px;", "background: #fff;", "object-fit: contain;"):
|
|
with self.subTest(rule=rule):
|
|
self.assertIn(rule, style)
|
|
|
|
def test_every_setup_page_has_a_sized_mark(self):
|
|
index = json.loads(_read(WEBSITE / "public" / "data" / "picker" / "index.json"))
|
|
for entry in index:
|
|
with self.subTest(setup=entry["platform"]):
|
|
block = _title_block(_built(f"platforms/{entry['platform']}"))
|
|
self.assertIn('class="rb-title-mark', block)
|
|
for image in re.findall(r"<img\b[^>]*>", block):
|
|
self.assertIn('width="40"', image)
|
|
self.assertIn('height="40"', image)
|
|
|
|
def test_a_setup_with_no_logo_gets_its_initials(self):
|
|
block = _title_block(_built("platforms/myminui"))
|
|
self.assertNotIn("<img", block)
|
|
self.assertRegex(block, r"<svg class=\"rb-title-mark[^>]*>.*?>MM</text>")
|
|
|
|
|
|
class NoLevelBadgeOnATabRoot(unittest.TestCase):
|
|
def test_tab_roots(self):
|
|
for page in TAB_ROOTS:
|
|
with self.subTest(page=page or "home"):
|
|
self.assertNotIn('class="rb-level ', _title_block(_built(page)))
|
|
|
|
|
|
class FooterLicenceMatchesTheLicenceFile(unittest.TestCase):
|
|
"""The footer said MIT covers the tooling. LICENSE names the metadata too."""
|
|
|
|
def test_footer(self):
|
|
self.assertIn("metadata", _read(REPO_ROOT / "LICENSE").split("MIT License")[0])
|
|
html = _built("data")
|
|
footer = re.sub(r"<[^>]+>", "", html[html.index('class="rb-footer'):])
|
|
self.assertIn("MIT for the tooling and metadata.", footer)
|
|
|
|
|
|
class WideTablesStackAndFiltersAreBuilt(unittest.TestCase):
|
|
"""Level 2 and 3 tables overflowed a phone (emulator summary, gaps,
|
|
verification modes, emulators index, data: V5, J8). The filter box,
|
|
added after paint, pushed the table down: CLS 0.066 on
|
|
gaps-files-to-source (T-F10)."""
|
|
|
|
def test_every_wide_table_stacks(self):
|
|
for page in ("emulators/duckstation", "gaps", "gaps-files-to-source", "emulators", "data",
|
|
"wiki/verification-modes"):
|
|
html = (DIST / page / "index.html")
|
|
if not html.is_file():
|
|
continue
|
|
for table in _tables(_read(html)):
|
|
if len(_headers(table)) < 4:
|
|
continue
|
|
with self.subTest(page=page, header=_headers(table)):
|
|
self.assertIn('data-rb-stack="true"', table.split(">", 1)[0])
|
|
|
|
def test_long_tables_get_their_labels_at_runtime(self):
|
|
"""Labels on every cell of a long stacked table were 122 KB of
|
|
emulators/mame and took it over the 600,000-byte page budget."""
|
|
self.assertIn("if (kind === 'kv' || rows.length >= RECORD_ROWS) return;", _read(PLUGIN))
|
|
source = _read(SITE_TS)
|
|
self.assertIn("const labelCells = (table: HTMLTableElement) => {", source)
|
|
self.assertIn("labelCells(table);", source)
|
|
html = _built("emulators/mame")
|
|
self.assertLess(len(html.encode()), 600_000)
|
|
|
|
def test_the_filter_box_is_in_the_html(self):
|
|
html = _built("gaps-files-to-source")
|
|
boxes = re.findall(r'<div class="rb-table-filter">(.*?)</div>\s*<table', html, re.S)
|
|
self.assertTrue(boxes)
|
|
for box in boxes:
|
|
self.assertRegex(box, r'<label for="rb-table-filter-\d+">Filter [^<]+</label>')
|
|
self.assertRegex(box, r'<input id="rb-table-filter-\d+" type="search"')
|
|
self.assertRegex(box, r'aria-live="polite">[\d,]+ rows</span>')
|
|
source = _read(SITE_TS)
|
|
self.assertNotIn("controls.className = 'rb-table-filter'", source)
|
|
self.assertIn("controls?.matches('.rb-table-filter')", source)
|
|
|
|
def test_every_long_table_has_its_box(self):
|
|
html = _built("systems")
|
|
for table in _tables(html):
|
|
rows = len(re.findall(r"<tr\b", table.split("<tbody>", 1)[-1]))
|
|
if rows < 20:
|
|
continue
|
|
start = html.index(table)
|
|
self.assertIn('class="rb-table-filter"', html[max(0, start - 600):start])
|
|
|
|
def test_the_placeholder_names_the_columns(self):
|
|
"""Every filter offered "Name, platform, system, hash...", over the
|
|
console list too, which has no hash (V small)."""
|
|
self.assertNotIn("Name, platform, system, hash", _read(SITE_TS))
|
|
cases = [["File", "Region", "Required", "Hash"], ["Console", "Files"], ["System", "MD5", "Size"]]
|
|
out = _node_module(
|
|
f"import {{ filterPlaceholder }} from {json.dumps(PLUGIN.as_uri())};"
|
|
f"console.log(JSON.stringify({json.dumps(cases)}.map(filterPlaceholder)));"
|
|
)
|
|
self.assertEqual(json.loads(out), ["File, region, required, hash...", "Console, files...", "System, MD5, size..."])
|
|
html = _built("systems")
|
|
self.assertRegex(html, r'placeholder="Console[^"]*\.\.\."')
|
|
self.assertNotRegex(html, r'placeholder="[^"]*hash[^"]*"')
|
|
|
|
|
|
class HashLookupGivesOneVerdict(unittest.TestCase):
|
|
"""The lookup read "24 matches", "Setups that list it (12)" over 17 rows,
|
|
and emulators by id. ?md5= showed the empty form (N8, J7)."""
|
|
|
|
def test_parameters(self):
|
|
source = _read(SITE_TS)
|
|
self.assertIn("const HASH_PARAMS = ['hash', 'md5', 'crc32', 'sha256'];", source)
|
|
self.assertIn("HASH_PARAMS.map((key) => params.get(key))", source)
|
|
|
|
def test_counts_say_what_they_count(self):
|
|
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(listers.size, 'setup lists', 'setups list')", source)
|
|
self.assertIn("'file in the collection', 'files in the collection'", source)
|
|
|
|
def test_emulators_by_name(self):
|
|
source = _read(SITE_TS)
|
|
self.assertIn("json(`api/v1/emulators/${owner}.json`)", source)
|
|
self.assertIn("profile.emulator || profile.display_name || owner", source)
|
|
profile = json.loads(_read(DIST / "api" / "v1" / "emulators" / "duckstation.json")) \
|
|
if (DIST / "api" / "v1" / "emulators" / "duckstation.json").is_file() else None
|
|
if profile is None:
|
|
self.skipTest("no built site")
|
|
self.assertEqual(profile["items"][0]["profile"]["emulator"], "DuckStation")
|
|
|
|
def test_a_console_page_card_reads_its_manufacturer_file(self):
|
|
"""Every collection match on a console page (systems/sony-playstation/)
|
|
asked for api/v1/systems/sony-playstation.json, which does not exist:
|
|
the verdict lost the file's path."""
|
|
source = _read(SITE_TS)
|
|
self.assertIn("api/v1/systems/${words.slice(0, size).join('-')}.json", source)
|
|
api = DIST / "api" / "v1" / "systems"
|
|
if not api.is_dir():
|
|
self.skipTest("no built site")
|
|
self.assertFalse((api / "sony-playstation.json").exists())
|
|
self.assertTrue((api / "sony.json").is_file())
|
|
|
|
def test_no_match_leads_somewhere(self):
|
|
source = _read(SITE_TS)
|
|
self.assertIn("link('contributing/', 'add it')", source)
|
|
anchor = re.search(r"const WANTED = 'provenance/#([^']+)';", source)
|
|
self.assertIsNotNone(anchor)
|
|
self.assertIn(f'id="{anchor[1]}"', _built("provenance"))
|
|
self.assertNotIn("No file the site knows has this hash.", source)
|
|
|
|
|
|
class HashAFileHere(unittest.TestCase):
|
|
"""Find a file asked for a hash only: hashing meant another tool (J7)."""
|
|
|
|
def test_the_chooser(self):
|
|
source = _read(SITE_TS)
|
|
self.assertIn("input.type = 'file';", source)
|
|
self.assertIn("It is hashed on this device. Nothing is uploaded.", source)
|
|
self.assertIn("crypto.subtle.digest(name, buffer)", source)
|
|
self.assertIn("['SHA-1', 'SHA-256']", source)
|
|
self.assertIn("lookUp(status, result, [found.sha1, found.md5, found.crc32, found.sha256])", source)
|
|
|
|
def test_md5_and_crc32_match_python(self):
|
|
source = _read(SITE_TS)
|
|
block = source[source.index("// Hashing a file on this device"):source.index("type FileHashes")]
|
|
sizes = [0, 1, 55, 56, 63, 64, 65, 119, 120, 128, 1000, 70_001]
|
|
out = _node_module(block + (
|
|
f"console.log(JSON.stringify({json.dumps(sizes)}.map((n) => {{"
|
|
"const bytes = new Uint8Array(n);"
|
|
"for (let i = 0; i < n; i += 1) bytes[i] = (i * 31 + 7) & 0xff;"
|
|
"return [md5(bytes), crc32(bytes)]; })));"
|
|
))
|
|
for size, (md5, crc) in zip(sizes, json.loads(out)):
|
|
data = bytes((i * 31 + 7) & 0xFF for i in range(size))
|
|
with self.subTest(size=size):
|
|
self.assertEqual(md5, hashlib.md5(data).hexdigest())
|
|
self.assertEqual(crc, f"{zlib.crc32(data):08x}")
|
|
|
|
|
|
class SearchFoldsPagesUnderAHash(unittest.TestCase):
|
|
"""Under "Look up this hash" came 14 unrelated pages: Magnavox for
|
|
E490.WAV, Philips for cdi490a.zip (N8)."""
|
|
|
|
def test_fold(self):
|
|
source = _read(SITE_TS)
|
|
self.assertIn("toggle.textContent = 'Pages that mention these characters';", source)
|
|
self.assertIn("toggle.setAttribute('aria-expanded', 'false');", source)
|
|
header = _read(COMPONENTS / "Header.astro")
|
|
self.assertIn(
|
|
".pagefind-ui__drawer[data-rb-hash]:not([data-rb-pages-shown]) .pagefind-ui__results-area { display: none; }",
|
|
header,
|
|
)
|
|
|
|
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, consoles: 10, terms: 30 },", config)
|
|
self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'terms']);", _read(SITE_TS))
|
|
|
|
|
|
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.assertNotIn("scrollIntoView", source)
|
|
self.assertIn("osTabs();", source)
|
|
|
|
|
|
class CommandsNeverBreakInsideAWord(unittest.TestCase):
|
|
"""`--` / `platform retroarch` at 1440 px, `--` / `system` and
|
|
`nintendo-` / `64dd` on a phone: a browser breaks a line after a hyphen,
|
|
whatever overflow-wrap says."""
|
|
|
|
def test_the_words_are_wrapped_and_the_text_kept(self):
|
|
# Shiki cuts `--platform` into two tokens: the span has to hold both.
|
|
line = {"type": "element", "tagName": "div", "properties": {"className": ["code"]}, "children": [
|
|
{"type": "element", "tagName": "span", "properties": {"style": "a"}, "children": [
|
|
{"type": "text", "value": "sh -s "}]},
|
|
{"type": "element", "tagName": "span", "properties": {"style": "b"}, "children": [
|
|
{"type": "text", "value": "--"}]},
|
|
{"type": "element", "tagName": "span", "properties": {"style": "c"}, "children": [
|
|
{"type": "text", "value": "platform retroarch --system nintendo-64dd"}]},
|
|
]}
|
|
out = json.loads(_node_module(
|
|
f"import {{ unbreakWords }} from {json.dumps(UNBROKEN.as_uri())};"
|
|
f"console.log(JSON.stringify(unbreakWords({json.dumps(line)})));"
|
|
))
|
|
|
|
def text(node: dict) -> str:
|
|
return node.get("value", "") + "".join(text(child) for child in node.get("children", []))
|
|
|
|
self.assertEqual("".join(text(n) for n in out), "sh -s --platform retroarch --system nintendo-64dd")
|
|
words = [text(n) for n in out if n.get("properties", {}).get("className") == ["rb-nb"]]
|
|
self.assertEqual(words, ["-s", "--platform", "--system", "nintendo-64dd"])
|
|
# The colours of each token survive the cut.
|
|
platform = next(n for n in out if text(n) == "--platform")
|
|
self.assertEqual([c["properties"]["style"] for c in platform["children"]], ["b", "c"])
|
|
|
|
def test_registered(self):
|
|
config = _read(CONFIG)
|
|
self.assertIn("import unbrokenTokens from './src/plugins/ec-unbroken-tokens.mjs';", config)
|
|
self.assertIn("plugins: [unbrokenTokens()],", config)
|
|
self.assertIn("baseStyles: `.${CLASS} { white-space: nowrap; }`", _read(UNBROKEN))
|
|
picker = _read(COMPONENTS / "FilePicker.astro")
|
|
self.assertIn("showCommand(cmd, `${INSTALL} --platform", picker)
|
|
self.assertIn(".rb-picker .rb-pick-cmd .rb-nb { white-space: nowrap; }", picker)
|
|
self.assertIn("navigator.clipboard?.writeText(cmd.textContent ?? '')", picker)
|
|
|
|
def test_copy_gives_the_rendered_text(self):
|
|
"""The copy button's text is the block's text: the spans add nothing.
|
|
Expressive Code leaves a leading comment line out of the copy."""
|
|
import html as html_lib
|
|
|
|
page = _built("platforms/retroarch")
|
|
figures = re.findall(r"<figure\b.*?</figure>", page, re.S)
|
|
self.assertTrue(figures)
|
|
self.assertIn('<span class="rb-nb">', page)
|
|
for figure in figures:
|
|
copied = re.search(r'data-code="([^"]*)"', figure)
|
|
if not copied:
|
|
continue
|
|
lines = re.findall(r'<div class="ec-line">(.*?)</div></div>', figure, re.S)
|
|
shown = "\n".join(html_lib.unescape(re.sub(r"<[^>]+>", "", line)) for line in lines)
|
|
code = html_lib.unescape(copied[1]).replace("\x7f", "\n")
|
|
with self.subTest(code=code[:60]):
|
|
self.assertTrue(shown.endswith(code), (shown, code))
|
|
|
|
|
|
class LogosAtTheirDisplaySize(unittest.TestCase):
|
|
"""136 KiB of the home page was 96 px PNG logos shown at 40 px (T-F7)."""
|
|
|
|
def test_raster_logos_are_small_webp(self):
|
|
manifest = json.loads(_read(LOGOS / "sources.json"))["images"]
|
|
self.assertTrue(manifest)
|
|
for entry in manifest:
|
|
path = LOGOS / entry["file"]
|
|
with self.subTest(file=entry["file"]):
|
|
self.assertTrue(path.is_file())
|
|
self.assertIn(path.suffix, (".webp", ".svg"))
|
|
if path.suffix == ".webp":
|
|
self.assertEqual(_webp_size(path.read_bytes()), (LOGO_PIXELS, LOGO_PIXELS))
|
|
self.assertEqual(sorted(p.name for p in LOGOS.glob("*.png")), [])
|
|
script = _read(REPO_ROOT / "scripts" / "fetch_site_images.py")
|
|
self.assertIn(f"LOGO_SIZE = {LOGO_PIXELS}", script)
|
|
self.assertIn('square_image(data, LOGO_SIZE, "WEBP")', script)
|
|
|
|
def test_pages_point_at_the_copies(self):
|
|
html = _built("platforms/emudeck")
|
|
self.assertIn('src="/retrobios/assets/logos/emudeck.webp"', html)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|