mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 07:33:45 -06:00
Merging the card and platform branches left two SHORT_HASH definitions, 8 and 12. The later one silently won, so the platform rows showed 12 characters while their tests expected 8. One constant now, 12, the card anchor's length. The copy-button selector skips buttons that carry their own data-copy, and the tests follow the merged markup.
305 lines
14 KiB
Python
305 lines
14 KiB
Python
"""File cards and install boxes read at a glance (density round, 2026-10-27).
|
|
|
|
A system page wrote every setup's install command for each console, twice:
|
|
12 long lines for the PSP. A file card showed four full hashes, the
|
|
collection path and the catalogue records before the next file. Now a box
|
|
shows one command for the setup picked, and a card shows the name, the
|
|
size, the download, a short SHA1, the setups and the emulators. The rest
|
|
sits in closed folds, every fact still on the page.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
|
|
import generate_site as gs # noqa: E402
|
|
import sitemarkup as markup # noqa: E402
|
|
from cross_reference import FileConsumers # noqa: E402
|
|
|
|
DIST = REPO_ROOT / "website" / "dist"
|
|
SITE_TS = REPO_ROOT / "website" / "src" / "scripts" / "site.ts"
|
|
|
|
SHA1 = "30a33f3651f8e1b0967a5dfe6ff714baebcce41a"
|
|
MD5 = "866855cc330b9b95cc69135fb7b41d38"
|
|
CRC32 = "7b57fa78"
|
|
SHA256 = "3ab7fcf6956d0281ada94d501fc6d1cfa8fc034f139e25fed6c829f723d92a95"
|
|
|
|
|
|
class _Visible(HTMLParser):
|
|
"""Words shown before any fold is opened: text outside closed details
|
|
(their summary counts), hidden elements, scripts and styles."""
|
|
|
|
VOID = {"br", "img", "input", "wbr", "hr", "meta", "link", "source"}
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(convert_charrefs=True)
|
|
self.stack: list[tuple[str, object]] = []
|
|
self.hidden = 0
|
|
self.words: list[str] = []
|
|
|
|
def handle_starttag(self, tag: str, attrs: list) -> None:
|
|
if tag in self.VOID:
|
|
return
|
|
names = dict(attrs)
|
|
if tag == "summary" and self.stack and self.stack[-1] == ("details", True):
|
|
self.hidden -= 1
|
|
self.stack.append((tag, "shown"))
|
|
return
|
|
hides = tag in ("script", "style") or "hidden" in names or (
|
|
tag == "details" and "open" not in names)
|
|
self.hidden += hides
|
|
self.stack.append((tag, hides))
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
while self.stack:
|
|
name, hides = self.stack.pop()
|
|
if hides == "shown":
|
|
self.hidden += 1
|
|
elif hides:
|
|
self.hidden -= 1
|
|
if name == tag:
|
|
break
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if not self.hidden:
|
|
self.words += data.split()
|
|
|
|
|
|
def visible_words(html_text: str) -> list[str]:
|
|
parser = _Visible()
|
|
parser.feed(html_text)
|
|
return parser.words
|
|
|
|
|
|
def _main(page: str) -> str:
|
|
text = (DIST / page / "index.html").read_text(encoding="utf-8")
|
|
return text[text.index("<main"):text.index("</main>")]
|
|
|
|
|
|
def _card_consumers(sha1: str) -> types.SimpleNamespace:
|
|
return types.SimpleNamespace(
|
|
platforms={sha1: {"batocera", "retroarch"}}, emulators={sha1: {"ppsspp"}},
|
|
platform_systems={sha1: {"batocera": {"sony-psp"}, "retroarch": {"sony-psp"}}},
|
|
platform_destinations={sha1: {"batocera": {"psp/ppge_atlas.zim"},
|
|
"retroarch": {"PPSSPP/ppge_atlas.zim"}}},
|
|
installed={sha1: {"knulli": {"PPSSPP/ppge_atlas.zim"}}},
|
|
emulator_entries={}, emulator_files={},
|
|
)
|
|
|
|
|
|
def _card() -> dict:
|
|
return {"name": "ppge_atlas.zim", "sha1": SHA1, "md5": MD5, "crc32": CRC32,
|
|
"sha256": SHA256, "size": 666539,
|
|
"path": "bios/Sony/PlayStation Portable/ppge_atlas.zim",
|
|
"provenance": {"tosec": {"name": "ppge_atlas (PPSSPP).zim"}}}
|
|
|
|
|
|
class ASystemCardShowsTheAnswerFirst(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.lines = gs._system_file_card(_card(), _card_consumers(SHA1))
|
|
self.text = "\n".join(self.lines)
|
|
self.fold = self.text[self.text.index("<details"):self.text.index("</details>")]
|
|
self.shown = self.text.replace(self.fold, "")
|
|
|
|
def test_the_open_part(self) -> None:
|
|
for fact in ("<code>ppge_atlas.zim</code>", ">650.9 KB<", ">Download</a>",
|
|
"<dt>On the setup's list</dt>", "<dt>Packed for its emulators by</dt>",
|
|
"<dt>Emulators</dt>", f'<dt>SHA1</dt><dd class="rb-hash">{SHA1[:12]}</dd>'):
|
|
self.assertIn(fact, self.shown)
|
|
|
|
def test_every_hash_whole_in_one_fold_with_the_rest(self) -> None:
|
|
for label, value in (("SHA1", SHA1), ("MD5", MD5), ("CRC32", CRC32), ("SHA256", SHA256)):
|
|
self.assertIn(f'<dt>{label}</dt><dd class="rb-hash">{value}</dd>', self.fold)
|
|
shown = " ".join(visible_words(self.text))
|
|
for value in (MD5, CRC32, SHA256, SHA1):
|
|
self.assertNotIn(value, shown)
|
|
self.assertIn("<dt>Collection path</dt>", self.fold)
|
|
self.assertIn("<dt>Catalogued</dt>", self.fold)
|
|
self.assertEqual(self.text.count("<details"), 1)
|
|
self.assertIn("<summary>Hashes, collection path and catalogues</summary>", self.fold)
|
|
|
|
def test_the_fold_is_out_of_the_search(self) -> None:
|
|
self.assertTrue(self.fold.startswith("<details data-pagefind-ignore>\n<summary>"))
|
|
|
|
def test_no_fact_is_lost(self) -> None:
|
|
# The yardstick list a page is cut by holds every fact: the
|
|
# Starlight card holds each of its values.
|
|
with markup.using("mkdocs"):
|
|
yardstick = "\n".join(gs._system_file_card(_card(), _card_consumers(SHA1)))
|
|
for label, values in re.findall(r"(?m)^- ([^:]+): (.*)$", yardstick):
|
|
self.assertIn(f"<dt>{label}</dt>", self.text)
|
|
for value in re.findall(r"[0-9a-f]{8,64}|`[^`]+`", values):
|
|
self.assertIn(value.strip("`"), self.text, label)
|
|
|
|
def test_a_card_reads_in_few_words(self) -> None:
|
|
# Measured on this card: 31 words open, 40 before the round.
|
|
self.assertLessEqual(len(visible_words(self.text)), min(40, int(31 * 1.05)))
|
|
|
|
|
|
class AnEmulatorCardFoldsItsHashes(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
entry = {"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246",
|
|
"sha1": "b05def971d8ec59f346f2d9ac21fb742e3eb6917", "size": 524288,
|
|
"required": True, "note": "Read at boot.", "priority": 5,
|
|
"validation": ["md5"], "source_ref": "src/core/bios.cpp:47"}
|
|
profile = {"emulator": "DuckStation", "files": [entry], "systems": ["sony-playstation"]}
|
|
self.text = "\n".join(gs._render_emulator_file(
|
|
entry, profile, None, profile["files"], lambda _f: True, anchor="file-scph5501-bin"))
|
|
self.fold = self.text[self.text.index("<details"):self.text.index("</details>")]
|
|
self.shown = self.text.replace(self.fold, "")
|
|
|
|
def test_a_short_hash_open_and_every_hash_folded(self) -> None:
|
|
# On the size's line.
|
|
self.assertIn('- Size: 512.0 KB, SHA1 <span class="rb-hash" '
|
|
'data-copy="b05def971d8ec59f346f2d9ac21fb742e3eb6917">b05def971d8e</span>',
|
|
self.shown)
|
|
self.assertIn('<code class="rb-hash" data-copy="">490f666e1afb15b7362b406ed1cea246</code>',
|
|
self.fold)
|
|
self.assertNotIn("490f666e1afb15b7362b406ed1cea246", self.shown)
|
|
|
|
def test_notes_open_the_rest_folded(self) -> None:
|
|
self.assertIn("Read at boot.", self.shown)
|
|
for fact in ("Priority: 5", "Validation: md5", "Source:"):
|
|
self.assertIn(fact, self.fold)
|
|
self.assertNotIn(fact, self.shown)
|
|
self.assertIn("<summary>Hashes, priority, validation and more</summary>", self.fold)
|
|
|
|
def test_the_markdown_yardstick_is_unchanged(self) -> None:
|
|
with markup.using("mkdocs"):
|
|
entry = {"name": "a.bin", "md5": "b" * 32, "size": 4}
|
|
text = "\n".join(gs._render_emulator_file(
|
|
entry, {"files": [entry]}, None, [entry], lambda _f: True))
|
|
self.assertNotIn("<details", text)
|
|
self.assertIn("- MD5: ", text)
|
|
|
|
|
|
PAIRS = [("emudeck", "sony-psp"), ("retroarch", "sony-psp"), ("retrobat", "Neo Geo")]
|
|
|
|
|
|
class AnInstallBoxShowsOneCommand(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.text = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS))
|
|
|
|
def test_one_command_per_tab_for_the_setup_picked(self) -> None:
|
|
tabs = self.text[self.text.index("<Tabs"):self.text.index("</Tabs>")]
|
|
self.assertEqual(re.findall(r'<TabItem label="([^"]+)"', tabs),
|
|
["Windows", "macOS", "Linux", "Steam Deck"])
|
|
self.assertEqual(tabs.count("--platform"), 4)
|
|
self.assertEqual(tabs.count("--platform emudeck --system sony-psp"), 4)
|
|
|
|
def test_the_setup_list_carries_every_command(self) -> None:
|
|
select = re.search(r"<select>(.*?)</select>", self.text).group(1)
|
|
options = re.findall(r'<option value="([^"]+)" data-sh="([^"]+)" data-ps="([^"]+)">', select)
|
|
self.assertEqual([value for value, _sh, _ps in options], ["emudeck", "retroarch", "retrobat"])
|
|
self.assertIn(("retrobat", "--platform retrobat --system 'Neo Geo'",
|
|
"--platform retrobat --system 'Neo Geo'"), options)
|
|
|
|
def test_without_script_the_list_of_every_command_shows(self) -> None:
|
|
self.assertIn('<div class="rb-install" data-rb-install="" hidden>', self.text)
|
|
listed = self.text[self.text.index('<details class="rb-install-all" open>'):]
|
|
for platform, _system in PAIRS:
|
|
self.assertEqual(listed.count(f"--platform {platform} --system"), 2, platform)
|
|
self.assertIn("install.sh | sh -s -- --platform retrobat --system 'Neo Geo'</code>", listed)
|
|
self.assertIn("install.ps1))) --platform retrobat --system 'Neo Geo'</code>", listed)
|
|
# The URL is no link inside the command, and the break adds nothing.
|
|
self.assertIn("https://<wbr>raw.githubusercontent.com", listed)
|
|
|
|
def test_one_setup_keeps_its_two_blocks(self) -> None:
|
|
text = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS[:1]))
|
|
self.assertNotIn("<select", text)
|
|
self.assertIn('```sh title="Terminal"', text)
|
|
self.assertIn('```powershell title="PowerShell"', text)
|
|
|
|
def test_the_yardstick_is_unchanged(self) -> None:
|
|
# The budgets that cut a system page are measured on this Markdown:
|
|
# a box that changed it would move cards to other pages.
|
|
with markup.using("mkdocs"):
|
|
text = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS))
|
|
self.assertNotIn("<select", text)
|
|
self.assertEqual(text.count("# RetroBat"), 2)
|
|
self.assertEqual(text.count("install.sh | sh"), 3)
|
|
|
|
|
|
class TheSetupListLooksLikeAList(unittest.TestCase):
|
|
"""The setup list had a rule of its own without the arrow, and read as
|
|
a text field at 390 and 1440 px."""
|
|
|
|
def test_the_select_takes_the_site_arrow(self) -> None:
|
|
css = (REPO_ROOT / "website" / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8")
|
|
shared = re.search(r"\.sl-markdown-content select \{([^}]*)\}", css)
|
|
self.assertIsNotNone(shared)
|
|
self.assertIn("appearance: none", shared.group(1))
|
|
self.assertIn("var(--rb-select-arrow)", shared.group(1))
|
|
# No rule for the install box's select overrides it.
|
|
self.assertNotRegex(css, r"rb-install-setup select")
|
|
# The arrow is defined for both themes.
|
|
self.assertEqual(len(re.findall(r"--rb-select-arrow:", css)), 2)
|
|
# The box's select sits in Markdown content, where the shared rule applies.
|
|
box = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS))
|
|
self.assertIn('<label class="rb-install-setup">Your setup <select>', box)
|
|
|
|
def test_the_built_select_shows_the_arrow(self) -> None:
|
|
if not (DIST / "systems").is_dir():
|
|
self.skipTest("no built site")
|
|
css = "".join(path.read_text(encoding="utf-8") for path in (DIST / "_astro").glob("*.css"))
|
|
self.assertNotIn("rb-install-setup select", css)
|
|
self.assertRegex(css, r"\.sl-markdown-content select\{[^}]*--rb-select-arrow")
|
|
|
|
|
|
class TheScriptWiresBoth(unittest.TestCase):
|
|
def test_pickers_and_copy_buttons_run(self) -> None:
|
|
source = SITE_TS.read_text(encoding="utf-8")
|
|
self.assertIn("\ninstallPickers();\n", source)
|
|
self.assertIn("\ncopyButtons();\n", source)
|
|
self.assertIn("const SETUP_KEY = 'rb-setup';", source)
|
|
self.assertIn("const COPYABLE = '[data-copy]:not(button), .rb-sys-file dd.rb-hash';", source)
|
|
# A platform row's button carries its own data-copy: never given a second button.
|
|
self.assertIn("const own = button.dataset.copy;", source)
|
|
|
|
|
|
# Visible words of each built page: measured before the round and after it,
|
|
# the ceiling being the after value with 5 % of room.
|
|
WORD_CEILINGS = {
|
|
"systems/sony": (2012, 1407),
|
|
"systems/sony-playstation": (2709, 2130),
|
|
"systems/nintendo": (3598, 2941),
|
|
"systems/sega": (4151, 3370),
|
|
"emulators/duckstation": (1188, 1117),
|
|
"emulators/yaps2": (3159, 2655),
|
|
}
|
|
|
|
|
|
class BuiltPagesReadInFewerWords(unittest.TestCase):
|
|
def test_visible_words_under_the_ceiling(self) -> None:
|
|
if not (DIST / "systems").is_dir():
|
|
self.skipTest("no built site")
|
|
for page, (before, after) in WORD_CEILINGS.items():
|
|
with self.subTest(page=page):
|
|
ceiling = min(before, int(after * 1.05))
|
|
self.assertLessEqual(len(visible_words(_main(page))), ceiling)
|
|
|
|
def test_a_built_card_holds_its_hashes(self) -> None:
|
|
if not (DIST / "systems").is_dir():
|
|
self.skipTest("no built site")
|
|
main = _main("systems/sony")
|
|
card = main[main.index('id="sha1-30a33f3651f8"'):]
|
|
card = card[:card.index('class="rb-sys-file"')]
|
|
self.assertRegex(card, r'<dd class="rb-hash">30a33f3651f8</dd>')
|
|
self.assertIn('<details data-pagefind-ignore', card)
|
|
self.assertIn(">30a33f3651f8e1b0967a5dfe6ff714baebcce41a</dd>", card)
|
|
# No link inside a command of the list: the URL is text.
|
|
listed = main[main.index("rb-install-all"):]
|
|
self.assertNotRegex(listed[:listed.index("</details>")], r"<code[^>]*>[^<]*<a ")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|