mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-06 01:53:56 -06:00
refactor: name site tests by subject
The site test modules were cut by review pass (test_site_r2_css to test_site_r5_wiki): the passes of one subject become one module, and the modules that stood alone take the name of what they hold. The installer test classes named after a review take the name of what they test. One test left --manifest set for the tests after it, which the new order showed: it now resets the sources.
This commit is contained in:
1 parent
964812f977
commit
ccec5486e5
50 files changed
+7676
-8352
No files matched your search
@@ -3248,7 +3248,7 @@ def with_setup_table(page: str, coverages: dict, registry: dict | None = None) -
|
||||
|
||||
# A platform's file lists are split by the share of a page they render to,
|
||||
# in thousandths: their elements against validate_site's MAX_PAGE_ELEMENTS,
|
||||
# their bytes against the 600,000 of test_site_r2_gate, whichever is
|
||||
# their bytes against the 600,000 of test_site_built_gate, whichever is
|
||||
# larger. Measured in Markdown characters, the lists split anew whenever
|
||||
# their markup changed: rows written as tables moved 38 more systems to
|
||||
# pages of their own.
|
||||
|
||||
@@ -945,7 +945,7 @@ class OneOfflineVocabulary(Scratch):
|
||||
self.assertNotIn("install everything", text)
|
||||
|
||||
|
||||
class AuditFifteenOffline(Scratch):
|
||||
class OfflineAndPackLeaveOut(Scratch):
|
||||
"""What --offline and a
|
||||
pack leave out, and what a folder copied from holds, said as such."""
|
||||
|
||||
|
||||
+44
-41
@@ -1707,7 +1707,7 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
|
||||
self.assertEqual(signal.getsignal(number), handler)
|
||||
|
||||
|
||||
class AuditRoundTwo(unittest.TestCase):
|
||||
class TicksBarAndPaths(unittest.TestCase):
|
||||
"""Ticks, the key bar, the network clock and typed paths."""
|
||||
|
||||
def systems(self) -> install.TuiState:
|
||||
@@ -2273,7 +2273,7 @@ class CopyFromAnOldCard(unittest.TestCase):
|
||||
self.assertIn("1.0 MB/s, 1:38 left", text)
|
||||
|
||||
|
||||
class AuditRoundThree(unittest.TestCase):
|
||||
class CardsSpaceAndConfirm(unittest.TestCase):
|
||||
"""Each card by its own files, Space on install, and a short card."""
|
||||
|
||||
CARDS = {
|
||||
@@ -2957,7 +2957,7 @@ class AReadErrorIsNotAClosedWindow(unittest.TestCase):
|
||||
os.close(master)
|
||||
|
||||
|
||||
class AuditRoundFour(unittest.TestCase):
|
||||
class SaveDirectoryLeftOut(unittest.TestCase):
|
||||
"""Files left out of the save directory are said."""
|
||||
|
||||
UNPLACED = {"reason": "no_save_directory", "label": "no save directory", "files": 2,
|
||||
@@ -3730,7 +3730,7 @@ class OfflineInTheInterface(unittest.TestCase):
|
||||
self.assertIn("--offline", install.tui_command(state))
|
||||
|
||||
|
||||
class AuditFive(unittest.TestCase):
|
||||
class BackTicksAndSaveFolder(unittest.TestCase):
|
||||
"""Going back keeps the ticks, and the save folder is said once."""
|
||||
|
||||
def long_list(self) -> install.TuiState:
|
||||
@@ -6077,7 +6077,7 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase):
|
||||
self.assertTrue(choice.note)
|
||||
|
||||
|
||||
class AuditSix(unittest.TestCase):
|
||||
class ConfirmRefusalsAndCardRows(unittest.TestCase):
|
||||
"""Confirm before an install, refusals said in full, and card rows."""
|
||||
|
||||
def setUp(self):
|
||||
@@ -6357,7 +6357,7 @@ class AuditSix(unittest.TestCase):
|
||||
self.assertIn(" +2 shared: counted once", install.tui_view(state, 40, 12))
|
||||
|
||||
|
||||
class AuditSevenOneCardReading(unittest.TestCase):
|
||||
class OneCardReading(unittest.TestCase):
|
||||
"""One reading
|
||||
of a card's root, said the same on the screen and in line mode."""
|
||||
|
||||
@@ -6620,7 +6620,7 @@ class AuditSevenOneCardReading(unittest.TestCase):
|
||||
self.assertIn("--platform anbernicstockfirmware --dest", err.getvalue())
|
||||
|
||||
|
||||
class AuditSevenScreens(unittest.TestCase):
|
||||
class WhatEachScreenOffers(unittest.TestCase):
|
||||
"""What each screen offers and says."""
|
||||
|
||||
def setUp(self):
|
||||
@@ -8583,7 +8583,7 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings,
|
||||
self.assertEqual(out.getvalue().count(install.shown_path(folder)), 1)
|
||||
|
||||
|
||||
class AuditSeventeenAndTen(unittest.TestCase):
|
||||
class PacksWordingAndDocs(unittest.TestCase):
|
||||
"""What the screen, the packs, the
|
||||
wording and the docs answer, line mode's parity being in
|
||||
LineModeAndScreenDecideAlike."""
|
||||
@@ -9035,7 +9035,7 @@ class OneDeviceRegistry(unittest.TestCase):
|
||||
.rstrip().endswith("TrimUI Smar..."))
|
||||
|
||||
|
||||
class AuditFifteenAndEightScreens(unittest.TestCase):
|
||||
class CardFoldersAndNarrowBars(unittest.TestCase):
|
||||
"""Card folders, refusals and bars at 40 columns, as the screens show
|
||||
them."""
|
||||
|
||||
@@ -9273,7 +9273,7 @@ def _screen_text(state: install.TuiState, cols: int = 80, rows: int = 24) -> str
|
||||
return "\n".join(line.rstrip() for line in install.tui_view(state, cols, rows))
|
||||
|
||||
|
||||
class AuditSixteenAndNineLineMode(unittest.TestCase):
|
||||
class LineModeDecisions(unittest.TestCase):
|
||||
"""Line mode read other decisions than the
|
||||
screen (a drive's root for Batocera, no other setup once one was found,
|
||||
no Enter default, a folder question with no way out)."""
|
||||
@@ -9439,6 +9439,9 @@ class AuditSixteenAndNineLineMode(unittest.TestCase):
|
||||
listing = self.root / "batocera.json"
|
||||
listing.write_text(json.dumps({"manifest_version": 2, "platform": "batocera",
|
||||
"files": []}))
|
||||
# --manifest sets the process-wide sources: a later test fetching
|
||||
# the list must not find this one.
|
||||
self.addCleanup(install.LOCAL.reset)
|
||||
events = io.StringIO()
|
||||
with mock.patch.object(install, "detect_platforms", return_value=[]), \
|
||||
mock.patch.object(install, "detect_os", return_value="linux"), \
|
||||
@@ -9455,7 +9458,7 @@ class AuditSixteenAndNineLineMode(unittest.TestCase):
|
||||
self.assertEqual(kinds[-1], "error")
|
||||
|
||||
|
||||
class AuditSixteenAndNineScreens(unittest.TestCase):
|
||||
class ScreenRowsAndFilters(unittest.TestCase):
|
||||
"""Rows, filters and help as the screens draw them."""
|
||||
|
||||
def setUp(self):
|
||||
@@ -9773,7 +9776,7 @@ class EdgesTheMutantsFound(unittest.TestCase):
|
||||
|
||||
|
||||
|
||||
class AuditEighteenAndEleven(_CardFixtures, unittest.TestCase):
|
||||
class CardNamesAndHelp(_CardFixtures, unittest.TestCase):
|
||||
"""What the cards, the screens and the
|
||||
docs answer, line mode's parity being in LineModeAndScreenDecideAlike."""
|
||||
|
||||
@@ -10680,7 +10683,7 @@ def _game_state() -> install.TuiState:
|
||||
return state
|
||||
|
||||
|
||||
class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase):
|
||||
class TapsGameDataAndExitCodes(_CardFixtures, unittest.TestCase):
|
||||
"""What the screens answer, line mode's
|
||||
parity being in LineModeAndScreenDecideAlike."""
|
||||
|
||||
@@ -11258,7 +11261,7 @@ class TapsOnAPty(unittest.TestCase):
|
||||
self.assertGreater(term.raw.rfind(b"\x1b[?1000l"), mark)
|
||||
|
||||
|
||||
class AuditTwentyAndThirteen(unittest.TestCase):
|
||||
class OneChoicePerRun(unittest.TestCase):
|
||||
"""One choice for the run, taps that
|
||||
never commit what was not seen, and the words that fit."""
|
||||
|
||||
@@ -12276,7 +12279,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
|
||||
term.close()
|
||||
|
||||
|
||||
class AuditTwentyOneAndFourteen(unittest.TestCase):
|
||||
class ChoiceStateInBothModes(unittest.TestCase):
|
||||
"""One choice state in both modes, the
|
||||
cards found first, packs that hold nothing, and words that fit."""
|
||||
|
||||
@@ -12284,10 +12287,10 @@ class AuditTwentyOneAndFourteen(unittest.TestCase):
|
||||
line_run = LineModeAndScreenDecideAlike.line_run
|
||||
listing = LineModeAndScreenDecideAlike.listing
|
||||
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
|
||||
receive = staticmethod(AuditTwentyAndThirteen.receive)
|
||||
game_manifest = AuditTwentyAndThirteen.game_manifest
|
||||
game_events = AuditTwentyAndThirteen.game_events
|
||||
game_plan = AuditTwentyAndThirteen.game_plan
|
||||
receive = staticmethod(OneChoicePerRun.receive)
|
||||
game_manifest = OneChoicePerRun.game_manifest
|
||||
game_events = OneChoicePerRun.game_events
|
||||
game_plan = OneChoicePerRun.game_plan
|
||||
|
||||
@staticmethod
|
||||
def screen(state: install.TuiState, cols: int = 80, rows: int = 24) -> str:
|
||||
@@ -12695,7 +12698,7 @@ class AuditTwentyOneAndFourteen(unittest.TestCase):
|
||||
|
||||
|
||||
|
||||
class AuditTwentyTwoAndFifteen(unittest.TestCase):
|
||||
class DriveIsACardByItsFiles(unittest.TestCase):
|
||||
"""A drive is a handheld's card only by
|
||||
the files its system writes, one settle from the frame shown, one
|
||||
vocabulary and one count per run, and the focus after an action."""
|
||||
@@ -12704,12 +12707,12 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase):
|
||||
line_run = LineModeAndScreenDecideAlike.line_run
|
||||
listing = LineModeAndScreenDecideAlike.listing
|
||||
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
|
||||
receive = staticmethod(AuditTwentyAndThirteen.receive)
|
||||
game_manifest = AuditTwentyAndThirteen.game_manifest
|
||||
game_events = AuditTwentyAndThirteen.game_events
|
||||
game_plan = AuditTwentyAndThirteen.game_plan
|
||||
game_listing = AuditTwentyOneAndFourteen.game_listing
|
||||
screen = staticmethod(AuditTwentyOneAndFourteen.screen)
|
||||
receive = staticmethod(OneChoicePerRun.receive)
|
||||
game_manifest = OneChoicePerRun.game_manifest
|
||||
game_events = OneChoicePerRun.game_events
|
||||
game_plan = OneChoicePerRun.game_plan
|
||||
game_listing = ChoiceStateInBothModes.game_listing
|
||||
screen = staticmethod(ChoiceStateInBothModes.screen)
|
||||
|
||||
def drive(self, name: str, folders: tuple) -> Path:
|
||||
root = self.root / "media" / name
|
||||
@@ -13034,7 +13037,7 @@ class OneVocabularyOneCount(unittest.TestCase):
|
||||
line_run = LineModeAndScreenDecideAlike.line_run
|
||||
listing = LineModeAndScreenDecideAlike.listing
|
||||
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
|
||||
screen = staticmethod(AuditTwentyOneAndFourteen.screen)
|
||||
screen = staticmethod(ChoiceStateInBothModes.screen)
|
||||
|
||||
def two_files(self) -> tuple[Path, Path, Path]:
|
||||
"""A list of two files, the first in place, the second in an old
|
||||
@@ -13265,7 +13268,7 @@ class FocusAfterAnAction(unittest.TestCase):
|
||||
install.tui_update(state, Key("f1"))
|
||||
|
||||
def test_the_game_data_row_keeps_the_focus_when_changed(self):
|
||||
audit = AuditTwentyAndThirteen()
|
||||
audit = OneChoicePerRun()
|
||||
state = audit.game_plan()
|
||||
rows = install.tui_rows(state)
|
||||
state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "gamedata")
|
||||
@@ -13346,7 +13349,7 @@ class TheFromPlanSaysItsKinds(unittest.TestCase):
|
||||
the Game data row and said neither the kinds nor what game data is."""
|
||||
|
||||
def test_the_kinds_and_the_definition_stay(self):
|
||||
audit = AuditTwentyAndThirteen()
|
||||
audit = OneChoicePerRun()
|
||||
state = audit.game_plan()
|
||||
folder = "/media/moi/packx"
|
||||
state.from_folders = [Path(folder)]
|
||||
@@ -13415,7 +13418,7 @@ class TheSystemsQuestionFits(unittest.TestCase):
|
||||
self.assertTrue(re.match(r" +\d+\) ", lines[row + 1]))
|
||||
|
||||
|
||||
class AuditTwentyThreeAndSixteen(unittest.TestCase):
|
||||
class ProvenCardsAndCounts(unittest.TestCase):
|
||||
"""A card its markers prove is that
|
||||
system's card everywhere, counts and states that tell the truth, one
|
||||
width for every layout, line mode's order and words as the screen's,
|
||||
@@ -13426,13 +13429,13 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase):
|
||||
listing = LineModeAndScreenDecideAlike.listing
|
||||
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
|
||||
both = LineModeAndScreenDecideAlike.both
|
||||
drive = AuditTwentyTwoAndFifteen.drive
|
||||
first_screen = AuditTwentyTwoAndFifteen.first_screen
|
||||
folder_screen = AuditTwentyTwoAndFifteen.folder_screen
|
||||
game_listing = AuditTwentyOneAndFourteen.game_listing
|
||||
game_manifest = AuditTwentyAndThirteen.game_manifest
|
||||
receive = staticmethod(AuditTwentyAndThirteen.receive)
|
||||
screen = staticmethod(AuditTwentyOneAndFourteen.screen)
|
||||
drive = DriveIsACardByItsFiles.drive
|
||||
first_screen = DriveIsACardByItsFiles.first_screen
|
||||
folder_screen = DriveIsACardByItsFiles.folder_screen
|
||||
game_listing = ChoiceStateInBothModes.game_listing
|
||||
game_manifest = OneChoicePerRun.game_manifest
|
||||
receive = staticmethod(OneChoicePerRun.receive)
|
||||
screen = staticmethod(ChoiceStateInBothModes.screen)
|
||||
|
||||
def amberelec(self, name: str = "GAMES") -> Path:
|
||||
return games_part(self.root / "media" / name)
|
||||
@@ -14066,7 +14069,7 @@ def core_list(path: Path, files: list) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
class AuditTwentyFourAndSeventeen(unittest.TestCase):
|
||||
class CoresListAndFolderFailures(unittest.TestCase):
|
||||
"""The cores the run accepts, one tick
|
||||
list, a folder failure said as it happened, every proven card named,
|
||||
reads that show, and tests that leave the host alone."""
|
||||
@@ -14074,9 +14077,9 @@ class AuditTwentyFourAndSeventeen(unittest.TestCase):
|
||||
setUp = LineModeAndScreenDecideAlike.setUp
|
||||
line_run = LineModeAndScreenDecideAlike.line_run
|
||||
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
|
||||
drive = AuditTwentyTwoAndFifteen.drive
|
||||
first_screen = AuditTwentyTwoAndFifteen.first_screen
|
||||
line_from = AuditTwentyThreeAndSixteen.line_from
|
||||
drive = DriveIsACardByItsFiles.drive
|
||||
first_screen = DriveIsACardByItsFiles.first_screen
|
||||
line_from = ProvenCardsAndCounts.line_from
|
||||
|
||||
# Root cause 1: the cores a choice is made among are the run's.
|
||||
|
||||
|
||||
@@ -261,6 +261,19 @@ class NoReviewTags(unittest.TestCase):
|
||||
offenders.append(f"{path.relative_to(ROOT)}:{number}")
|
||||
self.assertEqual(offenders, [], "\n".join(offenders[:20]))
|
||||
|
||||
def test_tests_are_named_by_subject(self):
|
||||
"""Test modules and classes were named after the review that wrote
|
||||
them (test_site_r4_p0, a class per review number). A name says what
|
||||
the test holds."""
|
||||
rounds = sorted(p.name for p in (ROOT / "tests").glob("test_*_r[0-9]_*.py"))
|
||||
self.assertEqual(rounds, [])
|
||||
classes = []
|
||||
for path in sorted((ROOT / "tests").glob("test_*.py")):
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if re.match(r"class Audit[A-Z]", line):
|
||||
classes.append(f"{path.name}:{number}")
|
||||
self.assertEqual(classes, [])
|
||||
|
||||
|
||||
class ProfilesStateFactsAboutOtherProjects(unittest.TestCase):
|
||||
"""A profile says what another project's file declares and what the code
|
||||
|
||||
File renamed without changes.
@@ -1,12 +1,12 @@
|
||||
"""Gate of the second site round, read from the built site (website/dist).
|
||||
"""The merged site, read from the built site (website/dist).
|
||||
|
||||
Each package of the round was checked on its own. These checks hold the
|
||||
merged site to what the round set out to fix: no sidebar label a number, no
|
||||
Each part of the site is checked on its own elsewhere. These checks hold
|
||||
the merged site to what they fix: no sidebar label a number, no
|
||||
"Next" tip, a sidebar label that is the page's own title, a card's link icon
|
||||
kept out of the heading a search indexes, crumbs that lead to a tab or a
|
||||
section, phone tables stacked where the reading level asks for it, and every
|
||||
page under 15,000 elements and 600 KB of HTML. The integration items of the
|
||||
round are held too: the platform logo beside the title, a section for every
|
||||
page under 15,000 elements and 600 KB of HTML. Items that cross pages
|
||||
are held too: the platform logo beside the title, a section for every
|
||||
letter of the emulators index, system links to the section of the system's
|
||||
own name, dates in words and the moved getting-started page.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File renamed without changes.
@@ -0,0 +1,454 @@
|
||||
"""The stylesheet, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: a red focus ring
|
||||
and hover, Starlight's 35 px h2, tinted disclosure blocks, striped asides,
|
||||
the search result file dump, lifting shadows, a sidebar without hierarchy,
|
||||
two indigos, permalinks hidden on touch screens, OS tabs cut at 320 px, a
|
||||
column that moves on pages without a TOC.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: commands allowed
|
||||
to break inside any word on a phone, a sidebar whose top links outsize its
|
||||
group labels, badge links cut to a few pixels by the
|
||||
next row, 12 px hashes, Get your files tables each sizing their own columns.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: OS tabs on one
|
||||
hidden scrolling row, stacked table cells 16 px apart with labels drawn over
|
||||
the next row, short flags split across lines, lone card links 19 px high, a
|
||||
card label column too narrow for its longest label.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: detection tables
|
||||
whose last column sat past the page at 1440 px, a header search box, title
|
||||
and "On this page" that moved 12 px between a guide and a console page,
|
||||
stacked rows 136 px tall for four short cells, a line 4 px under a button,
|
||||
a heading touching its first line, the browser's own choice list, and a
|
||||
crumb separator free to end a line alone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CSS_PATH = ROOT / "website" / "src" / "styles" / "retrobios.css"
|
||||
|
||||
# Page backgrounds Starlight 0.42.6 draws under our tokens, read with
|
||||
# getComputedStyle on a built page (light: white, dark: gray-7 hsl(224 10% 10%)).
|
||||
PAGE_BG = {"light": "#ffffff", "dark": "#17181c"}
|
||||
# Text Starlight draws on a filled button (--sl-color-black in each theme).
|
||||
ON_FILL = {"light": "#ffffff", "dark": "#17181c"}
|
||||
|
||||
|
||||
def _strip_comments(css: str) -> str:
|
||||
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
|
||||
|
||||
def _blocks(css: str) -> list[tuple[str, str]]:
|
||||
"""Innermost (selector, body) pairs, media queries flattened."""
|
||||
return [(sel.strip(), body) for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)]
|
||||
|
||||
|
||||
def _theme_tokens(css: str, selector: str) -> dict[str, str]:
|
||||
for sel, body in _blocks(css):
|
||||
if sel == selector:
|
||||
return dict(re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", body))
|
||||
raise AssertionError(f"no block {selector}")
|
||||
|
||||
|
||||
def _rules(css: str, selector_part: str) -> list[str]:
|
||||
return [body for sel, body in _blocks(css) if selector_part in sel]
|
||||
|
||||
|
||||
def _luminance(hex_colour: str) -> float:
|
||||
value = hex_colour.lstrip("#")
|
||||
channels = [int(value[i : i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||
linear = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
|
||||
|
||||
def _ratio(a: str, b: str) -> float:
|
||||
la, lb = sorted((_luminance(a), _luminance(b)), reverse=True)
|
||||
return (la + 0.05) / (lb + 0.05)
|
||||
|
||||
|
||||
class ThemeStylesheet(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
dark = _theme_tokens(cls.css, ":root")
|
||||
light = {**dark, **_theme_tokens(cls.css, ":root[data-theme='light']")}
|
||||
cls.tokens = {"dark": dark, "light": light}
|
||||
|
||||
def resolve(self, theme: str, token: str) -> str:
|
||||
value = self.tokens[theme][token].strip()
|
||||
while value.startswith("var("):
|
||||
value = self.tokens[theme][value[4:-1].strip()].strip()
|
||||
return value
|
||||
|
||||
def test_focus_ring_is_the_link_colour_not_red(self):
|
||||
focus = _rules(self.css, ":focus-visible")
|
||||
self.assertTrue(focus)
|
||||
for body in focus:
|
||||
self.assertIn("2px solid var(--rb-link)", body)
|
||||
self.assertNotIn("accent", body.replace("--rb-link", ""))
|
||||
ring = next(b for s, b in _blocks(self.css) if s.startswith(":where(a, button"))
|
||||
self.assertIn("outline-offset: 2px", ring)
|
||||
self.assertNotIn("#b42318", self.css)
|
||||
self.assertNotIn("--rb-accent", self.css)
|
||||
|
||||
def test_button_hover_stays_in_its_own_colour(self):
|
||||
self.assertNotRegex(self.css, r"\.rb-button:is\(:hover,\s*:focus\)")
|
||||
primary = _rules(self.css, ".rb-button-primary:hover")
|
||||
self.assertTrue(primary)
|
||||
self.assertIn("var(--rb-link-strong)", primary[0])
|
||||
self.assertEqual(self.resolve("light", "--rb-link-strong"), "#1d2f73")
|
||||
self.assertEqual(self.resolve("dark", "--rb-link-strong"), "#c9d2ff")
|
||||
outline = [b for s, b in _blocks(self.css) if s.endswith(".rb-button:hover")]
|
||||
self.assertIn("var(--sl-color-accent-low)", outline[0])
|
||||
|
||||
def test_heading_scale(self):
|
||||
root = self.tokens["dark"]
|
||||
self.assertEqual(root["--sl-text-h1"], "2.25rem")
|
||||
self.assertEqual(root["--sl-text-h2"], "1.5rem")
|
||||
self.assertEqual(root["--sl-text-h3"], "1.2rem")
|
||||
self.assertEqual(root["--sl-text-h4"], "1rem")
|
||||
self.assertRegex(self.css, r"@media \(max-width: 50rem\) \{\s*:root \{ --sl-text-h1: 1\.875rem; \}")
|
||||
self.assertRegex(self.css, r"level-h2, h2\) \{ margin-top: 2\.75rem; \}")
|
||||
self.assertRegex(self.css, r"level-h3, h3:not\(\.rb-file-title\)\) \{ margin-top: 1\.75rem; \}")
|
||||
|
||||
def test_asides_are_neutral_boxes(self):
|
||||
neutral = _rules(self.css, ".starlight-aside:is(.starlight-aside--note, .starlight-aside--tip)")
|
||||
joined = "".join(neutral)
|
||||
self.assertIn("--sl-color-asides-border: var(--rb-border)", joined)
|
||||
self.assertIn("background: var(--rb-surface)", joined)
|
||||
self.assertIn("display: none", joined)
|
||||
box = _rules(self.css, ".sl-markdown-content .starlight-aside")[0]
|
||||
self.assertIn("border: 1px solid", box)
|
||||
|
||||
def test_disclosure_blocks_are_neutral(self):
|
||||
self.assertNotIn("rb-details-tip", self.css)
|
||||
self.assertNotIn("--sl-color-blue", self.css)
|
||||
base = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content details.rb-details")
|
||||
self.assertIn("background: transparent", base)
|
||||
self.assertIn("var(--rb-border)", base)
|
||||
self.assertIn("0.95rem", _rules(self.css, "details.rb-details > summary")[0])
|
||||
|
||||
def test_search_hides_the_file_list(self):
|
||||
self.assertIn("display: none", _rules(self.css, "site-search .pagefind-ui__result-tags")[0])
|
||||
|
||||
def test_no_lifting_shadow_on_pagination_or_menu(self):
|
||||
self.assertIn("box-shadow: none", _rules(self.css, ".pagination-links a")[0])
|
||||
self.assertIn("font-size: 1.05rem", _rules(self.css, ".pagination-links .link-title")[0])
|
||||
menu = next(b for s, b in _blocks(self.css) if s == ".sl-menu-button")
|
||||
self.assertIn("box-shadow: none", menu)
|
||||
self.assertIn("background: transparent", menu)
|
||||
self.assertIn("1px solid var(--rb-border)", menu)
|
||||
|
||||
def test_sidebar_hierarchy(self):
|
||||
label = _rules(self.css, ".top-level summary .large")[0]
|
||||
self.assertIn("var(--sl-text-xs)", label)
|
||||
self.assertIn("var(--rb-text-secondary)", label)
|
||||
current = _rules(self.css, "a[aria-current='page']")[0]
|
||||
self.assertIn("background: var(--sl-color-accent-low)", current)
|
||||
self.assertIn("color: var(--sl-color-accent-high)", current)
|
||||
self.assertNotIn("border", current)
|
||||
|
||||
def test_one_indigo(self):
|
||||
for theme in ("light", "dark"):
|
||||
with self.subTest(theme=theme):
|
||||
self.assertEqual(self.resolve(theme, "--sl-color-accent"), self.resolve(theme, "--rb-link"))
|
||||
self.assertEqual(self.resolve(theme, "--sl-color-text-accent"), self.resolve(theme, "--rb-link"))
|
||||
|
||||
def test_permalinks_visible_without_hover(self):
|
||||
self.assertRegex(self.css, r"@media \(hover: none\) \{\s*\.rb-permalink \{ opacity: 1; \}")
|
||||
permalink = next(b for s, b in _blocks(self.css) if s == ".rb-permalink")
|
||||
self.assertIn("min-height: 24px", permalink)
|
||||
|
||||
# The row wraps, every tab on screen
|
||||
def test_os_tabs_stay_reachable(self):
|
||||
tablist = _rules(self.css, "starlight-tabs [role='tablist']")[0]
|
||||
self.assertIn("flex-wrap: wrap", tablist)
|
||||
self.assertIn("overflow-x: visible", tablist)
|
||||
self.assertIn("white-space: nowrap", _rules(self.css, "starlight-tabs [role='tab']")[0])
|
||||
|
||||
def test_page_without_toc_keeps_the_column(self):
|
||||
self.assertIn("--__toc-width: initial", _rules(self.css, ":root[data-has-sidebar]:not([data-has-toc])")[0])
|
||||
# One pane rule for every page with a sidebar, "On this page"
|
||||
# or not, placed from the grid width.
|
||||
pane = _rules(self.css, ":root[data-has-sidebar] .main-pane")[0]
|
||||
self.assertIn("--sl-content-margin-inline: max(0rem, 100% - var(--rb-grid-width)) auto", pane)
|
||||
|
||||
def test_card_pages(self):
|
||||
cards = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content :is(.rb-sys-file, .rb-file-entry)")
|
||||
self.assertIn("content-visibility: auto", cards)
|
||||
self.assertIn("contain-intrinsic-size: auto 320px", cards)
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)", _rules(self.css, "body:has(.rb-sys-file, .rb-file-entry)")[0])
|
||||
self.assertEqual(_theme_tokens(self.css, ":root")["--rb-grid-width"], "54rem")
|
||||
facts = _rules(self.css, "dl.rb-facts")[0]
|
||||
self.assertIn("grid-template-columns: 11rem minmax(0, 1fr)", facts)
|
||||
self.assertRegex(self.css, r"@media \(max-width: 30rem\) \{\s*\.sl-markdown-content dl\.rb-facts \{ grid-template-columns: minmax\(0, 1fr\)")
|
||||
|
||||
def test_long_values_wrap_between_words(self):
|
||||
table = _rules(self.css, ".rb-meta-card table")[0]
|
||||
self.assertIn("table-layout: fixed", table)
|
||||
self.assertIn("display: table", table)
|
||||
self.assertNotRegex(self.css, r"\.rb-meta-card td \{ overflow-wrap: anywhere")
|
||||
self.assertIn("overflow-wrap: anywhere", _rules(self.css, ".rb-meta-card td code")[0])
|
||||
self.assertIn("width: 40%", "".join(_rules(self.css, ".rb-meta-card td:first-child")))
|
||||
upstream = _rules(self.css, "#by-upstream-emulator")[0]
|
||||
self.assertIn("max-width: 22rem", upstream)
|
||||
|
||||
def test_two_column_tables_stack_label_over_value(self):
|
||||
self.assertIn("content: none", _rules(self.css, "table[data-rb-stack='kv'] td::before")[0])
|
||||
self.assertTrue(_rules(self.css, "table[data-rb-stack='kv'] td:first-child"))
|
||||
|
||||
def test_contrast_meets_wcag_aa_in_both_themes(self):
|
||||
for theme in ("light", "dark"):
|
||||
bg = PAGE_BG[theme]
|
||||
pairs = [
|
||||
("link", self.resolve(theme, "--rb-link"), bg, 4.5),
|
||||
("focus ring", self.resolve(theme, "--rb-link"), bg, 3.0),
|
||||
("primary button", ON_FILL[theme], self.resolve(theme, "--rb-link"), 4.5),
|
||||
("primary hover", ON_FILL[theme], self.resolve(theme, "--rb-link-strong"), 4.5),
|
||||
("outline hover", self.resolve(theme, "--rb-link"), self.resolve(theme, "--sl-color-accent-low"), 4.5),
|
||||
("sidebar current", self.resolve(theme, "--sl-color-accent-high"), self.resolve(theme, "--sl-color-accent-low"), 4.5),
|
||||
("secondary text", self.resolve(theme, "--rb-text-secondary"), bg, 4.5),
|
||||
("link on surface", self.resolve(theme, "--rb-link"), self.resolve(theme, "--rb-surface"), 4.5),
|
||||
]
|
||||
for label, fg, back, need in pairs:
|
||||
with self.subTest(theme=theme, pair=label):
|
||||
self.assertGreaterEqual(_ratio(fg, back), need, f"{fg} on {back}")
|
||||
|
||||
|
||||
class DeviceTilesAndHeaderLogo(unittest.TestCase):
|
||||
"""The device tiles had no style once merged: every line underlined and one
|
||||
tall block per setup on a phone. The header logo was sized from the whole
|
||||
header, tab row included, and grew to 84 px."""
|
||||
|
||||
def test_a_tile_is_one_compact_link(self):
|
||||
css = CSS_PATH.read_text(encoding="utf-8")
|
||||
self.assertRegex(css, r"a\.rb-tile \{[^}]*grid-template-columns: 40px 1fr")
|
||||
self.assertRegex(css, r"a\.rb-tile \{[^}]*text-decoration: none")
|
||||
|
||||
def test_the_header_logo_follows_the_top_bar(self):
|
||||
css = CSS_PATH.read_text(encoding="utf-8")
|
||||
self.assertIn("header .site-title img { height: calc(4rem - 2 * var(--sl-nav-pad-y))", css)
|
||||
|
||||
|
||||
# The Get your files tables: no class reaches them, the Markdown table is
|
||||
# matched by the cells the generator labels.
|
||||
PACKS = "table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])"
|
||||
|
||||
|
||||
class CommandsSidebarAndPackTables(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
dark = _theme_tokens(cls.css, ":root")
|
||||
light = {**dark, **_theme_tokens(cls.css, ":root[data-theme='light']")}
|
||||
cls.tokens = {"dark": dark, "light": light}
|
||||
|
||||
def test_commands_never_break_inside_a_word(self):
|
||||
blocks = _rules(self.css, ".expressive-code pre > code")
|
||||
self.assertTrue(blocks)
|
||||
for body in blocks:
|
||||
self.assertIn("white-space: pre-wrap", body, "commands keep wrapping")
|
||||
self.assertNotIn("overflow-wrap: anywhere", body)
|
||||
for selector in (".rb-facts code", ".rb-hash code", ".rb-meta-card td code"):
|
||||
with self.subTest(selector=selector):
|
||||
self.assertIn("overflow-wrap: anywhere", _rules(self.css, selector)[0])
|
||||
|
||||
def test_sidebar_labels_above_links(self):
|
||||
label = _rules(self.css, ".top-level summary .large")[0]
|
||||
# 13 px grey semibold, no uppercase: the house style refuses small caps.
|
||||
for declaration in ("font-size: var(--sl-text-xs)", "font-weight: 600", "color: var(--rb-text-secondary)"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, label)
|
||||
links = next(b for s, b in _blocks(self.css) if s == "#starlight__sidebar .top-level a")
|
||||
self.assertIn("font-size: var(--sl-text-sm)", links)
|
||||
self.assertIn("font-weight: 400", links)
|
||||
|
||||
def test_badge_links_keep_a_24px_target(self):
|
||||
dd = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content .rb-facts dd")
|
||||
self.assertIn("line-height: 24px", dd)
|
||||
badge = _rules(self.css, ".rb-facts :is(.rb-badge:has(> a), a.rb-badge)")[0]
|
||||
for declaration in ("display: inline-flex", "align-items: center", "min-height: 24px", "margin-block: 2px"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, badge)
|
||||
|
||||
def test_hashes_are_13px_at_every_width(self):
|
||||
hashes = [b for s, b in _blocks(self.css) if ".rb-hash" in s and "font-size" in b]
|
||||
self.assertEqual(len(hashes), 1, "a second size for .rb-hash, on phones")
|
||||
self.assertIn("font-size: 0.8125rem", hashes[0])
|
||||
short = _rules(self.css, "td code[title]")[0]
|
||||
self.assertIn("white-space: nowrap", short)
|
||||
self.assertIn("underline dotted", short)
|
||||
self.assertIn("min-width: 12rem", _rules(self.css, "td.rb-reason")[0])
|
||||
|
||||
def test_pack_tables_share_their_columns(self):
|
||||
base = next(b for s, b in _blocks(self.css) if PACKS in s and "table-layout" in b)
|
||||
self.assertIn("table-layout: fixed", base)
|
||||
self.assertIn("width: 100%", base)
|
||||
widths = {s.rsplit("th:nth-child(", 1)[1].split(")")[0]: b for s, b in _blocks(self.css)
|
||||
if PACKS in s and "th:nth-child(" in s}
|
||||
self.assertEqual({k: v.strip() for k, v in widths.items()},
|
||||
{"1": "width: 18%;", "2": "width: 20%;", "3": "width: 40%;", "4": "width: 22%;"})
|
||||
parts = next(b for s, b in _blocks(self.css) if PACKS in s and s.endswith(":is(a, .rb-button)"))
|
||||
self.assertIn("white-space: nowrap", parts)
|
||||
|
||||
def test_secondary_text_on_the_sidebar_meets_aa(self):
|
||||
for theme in ("light", "dark"):
|
||||
with self.subTest(theme=theme):
|
||||
fg = self.tokens[theme]["--rb-text-secondary"].strip()
|
||||
self.assertGreaterEqual(_ratio(fg, PAGE_BG[theme]), 4.5)
|
||||
|
||||
|
||||
STACK = ".sl-markdown-content table[data-rb-stack] td"
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
bodies = [b for s, b in _blocks(css) if s == selector]
|
||||
if not bodies:
|
||||
raise AssertionError(f"no block {selector}")
|
||||
return "".join(bodies)
|
||||
|
||||
|
||||
class TabsCellsAndCardLinks(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.raw = CSS_PATH.read_text(encoding="utf-8")
|
||||
cls.css = _strip_comments(cls.raw)
|
||||
|
||||
def test_os_tabs_wrap_and_never_hide(self):
|
||||
tablist = _block(self.css, "starlight-tabs [role='tablist']")
|
||||
for declaration in ("flex-wrap: wrap", "overflow-x: visible", "row-gap: 0.25rem"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, tablist)
|
||||
self.assertNotIn("scrollbar-width: none", self.css)
|
||||
self.assertNotIn("::-webkit-scrollbar", self.css)
|
||||
|
||||
def test_stacked_cells_close_up_and_labels_push_rows(self):
|
||||
cell = _block(self.css, STACK)
|
||||
# A 7.1rem column and 1.35 lines.
|
||||
for declaration in ("margin-top: 0", "display: flow-root", "line-height: 1.35",
|
||||
"padding: 0 0.6rem 0 7.1rem"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, cell)
|
||||
label = _block(self.css, STACK + "::before")
|
||||
self.assertIn("position: static", label)
|
||||
self.assertIn("float: left", label)
|
||||
self.assertNotIn("position: absolute", label)
|
||||
# The label sits in the cell's left padding.
|
||||
self.assertIn("margin: 0 0.6rem 0 -7.1rem", label)
|
||||
|
||||
def test_short_inline_code_never_splits(self):
|
||||
self.assertIn("white-space: pre", _block(self.css, ".sl-markdown-content :not(pre) > code.rb-short"))
|
||||
self.assertIn("white-space: break-spaces", _block(self.css, ".sl-markdown-content :not(pre) > code"))
|
||||
self.assertIn("white-space: nowrap", _block(self.css, ".sl-markdown-content table.rb-options td:first-child"))
|
||||
# A stacked value column is narrow: short code there moves down whole
|
||||
# and splits only when wider than the column, never pushing the page.
|
||||
stacked = _block(self.css, STACK + " code.rb-short")
|
||||
for declaration in ("display: inline-block", "max-width: 100%", "overflow-wrap: anywhere"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, stacked)
|
||||
# Commands in code blocks keep wrapping on a phone.
|
||||
self.assertIn("white-space: pre-wrap", _block(self.css, ".sl-markdown-content .expressive-code pre > code"))
|
||||
|
||||
def test_lone_card_links_are_24px_targets(self):
|
||||
dd = _block(self.css, ".sl-markdown-content .rb-facts dd")
|
||||
self.assertIn("min-height: 24px", dd)
|
||||
self.assertIn("line-height: 24px", dd)
|
||||
link = _block(self.css, ".sl-markdown-content .rb-facts dd > a")
|
||||
self.assertIn("display: inline-block", link)
|
||||
self.assertIn("min-height: 24px", link)
|
||||
|
||||
def test_card_labels_fit_and_lookup_labels_match(self):
|
||||
facts = _rules(self.css, "dl.rb-facts")[0]
|
||||
self.assertIn("grid-template-columns: 11rem minmax(0, 1fr)", facts)
|
||||
self.assertIn("font-weight: 600", _block(self.css, ".sl-markdown-content .rb-file-lookup label"))
|
||||
|
||||
|
||||
def _declarations(body: str) -> dict[str, str]:
|
||||
return {k.strip(): v.strip() for k, v in re.findall(r"([\w-]+)\s*:\s*([^;]+);", body)}
|
||||
|
||||
|
||||
class TablesHeaderAndRowHeights(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
def test_long_code_in_a_cell_breaks_to_fit_its_column(self):
|
||||
self.assertIn("overflow-wrap: anywhere",
|
||||
_block(self.css, ".sl-markdown-content table td code:not(.rb-short)"))
|
||||
last = _block(self.css, ".sl-markdown-content table:not([data-rb-stack='kv']) "
|
||||
"td:last-child:not(:first-child):has(code:not(.rb-short))")
|
||||
self.assertIn("min-width: 9rem", last)
|
||||
# A flag or a short name in a cell still never splits.
|
||||
self.assertIn("white-space: pre", _block(self.css, ".sl-markdown-content :not(pre) > code.rb-short"))
|
||||
|
||||
def test_one_grid_for_every_page(self):
|
||||
self.assertEqual(_theme_tokens(self.css, ":root").get("--rb-grid-width"), "54rem")
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)",
|
||||
_block(self.css, "body:has(.rb-sys-file, .rb-file-entry)"))
|
||||
# The header places its search box from the grid, not the page's column.
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)",
|
||||
_block(self.css, ":root[data-has-sidebar] header.header"))
|
||||
toc = _block(self.css, ":root[data-has-sidebar] .right-sidebar-container")
|
||||
pane = _block(self.css, ":root[data-has-sidebar] .main-pane")
|
||||
for body in (toc, pane):
|
||||
self.assertIn("var(--rb-grid-width)", body)
|
||||
self.assertNotIn("var(--sl-content-width)", body)
|
||||
# Every column starts on the grid's edge, the narrow one included.
|
||||
self.assertIn("--sl-content-margin-inline: max(0rem, 100% - var(--rb-grid-width)) auto", pane)
|
||||
# The pane rule covers pages with and without "On this page" alike.
|
||||
self.assertFalse([s for s, _ in _blocks(self.css) if s.endswith(":not([data-has-toc]) .main-pane")])
|
||||
|
||||
def test_stacked_rows_are_short(self):
|
||||
cell = _declarations(_block(self.css, STACK))
|
||||
self.assertEqual(cell["font-size"], "0.875rem")
|
||||
self.assertEqual(cell["line-height"], "1.35")
|
||||
label = _declarations(_block(self.css, STACK + "::before"))
|
||||
self.assertEqual(label["width"], "max-content")
|
||||
self.assertEqual(label["min-width"], "6.5rem")
|
||||
self.assertEqual(label["max-width"], "9rem")
|
||||
self.assertEqual(label["font-size"], "0.8125rem")
|
||||
# Values start in the column of the shortest label: the label's
|
||||
# negative margin is the cell's left padding.
|
||||
indent = cell["padding"].split()[3]
|
||||
self.assertEqual(label["margin"].split()[3], f"-{indent}")
|
||||
self.assertEqual(indent, "7.1rem")
|
||||
self.assertIn("padding: 0.3rem 0", _block(self.css, ".sl-markdown-content table[data-rb-stack] tr"))
|
||||
# A two-column list keeps body text.
|
||||
self.assertIn("font-size: 1rem", _block(self.css, ".sl-markdown-content table[data-rb-stack='kv'] td"))
|
||||
|
||||
def test_spacing_after_a_button_and_a_search_marker(self):
|
||||
self.assertIn("margin-top: 1rem",
|
||||
_block(self.css, ".sl-markdown-content :is(.rb-button, p:has(> .rb-button)) + p"))
|
||||
self.assertIn("margin-top: var(--sl-content-gap-y)",
|
||||
_block(self.css, ".sl-markdown-content span[data-pagefind-meta]:empty + :is(p, ul, ol, table, div)"))
|
||||
|
||||
def test_choice_list_matches_the_filter_box(self):
|
||||
select = _declarations(_block(self.css, ".sl-markdown-content :is(.rb-file-lookup, .rb-picker) select,\n"
|
||||
".sl-markdown-content select"))
|
||||
box = _declarations(_block(self.css, ".rb-table-filter input"))
|
||||
for prop in ("border", "border-radius", "min-height"):
|
||||
with self.subTest(prop=prop):
|
||||
self.assertEqual(select[prop], box[prop])
|
||||
self.assertEqual(select["appearance"], "none")
|
||||
self.assertIn("var(--rb-select-arrow)", select["background"])
|
||||
dark = _theme_tokens(self.css, ":root")["--rb-select-arrow"]
|
||||
light = _theme_tokens(self.css, ":root[data-theme='light']")["--rb-select-arrow"]
|
||||
# The arrow is drawn in each theme's secondary text colour.
|
||||
for arrow, tokens in ((dark, _theme_tokens(self.css, ":root")),
|
||||
(light, _theme_tokens(self.css, ":root[data-theme='light']"))):
|
||||
colour = tokens["--rb-text-secondary"].lstrip("#")
|
||||
self.assertIn(f"%23{colour}", arrow)
|
||||
|
||||
def test_crumb_separator_stays_with_its_label(self):
|
||||
self.assertIn("white-space: nowrap", _block(self.css, ".rb-breadcrumbs li"))
|
||||
self.assertIn("white-space: normal", _block(self.css, ".rb-breadcrumbs li > :is(a, span)"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,760 @@
|
||||
"""Emulator pages.
|
||||
|
||||
DuckStation's page opened on "It reads 105 files, 3 of them required" and
|
||||
read as "get all three", its three BIOS were cards 12 to 14 under one
|
||||
heading, its header said "Official ports" and "duckstation, Duckstation",
|
||||
and every system was an id ("sony-playstation").
|
||||
|
||||
NetherSX2 counted three interface fonts its app ships among "4 required"
|
||||
files and opened its file list on the project's counts. AetherSX2's summary
|
||||
held an empty Region column that pushed Required off a phone. No MAME or
|
||||
FinalBurn page said which romset its sets must match. DuckStation's page was
|
||||
59 phone screens of optional cards, and MAME's index put 13,300 elements on
|
||||
one page.
|
||||
|
||||
AetherSX2's page offered a command that answered "No supported platform
|
||||
detected" and a file to get by one name, while the app reads any PS2 BIOS
|
||||
by its contents. FinalBurn Neo and MAME 2003-Plus named no romset under
|
||||
their intro. MAME's card pages and the data directory pages did not say
|
||||
where they sit. melonDS marked its DSi files "required" above its DS files.
|
||||
The `00_example` template was listed as an emulator.
|
||||
|
||||
61 profiles cite the folder their app reads its data from (data_root) and
|
||||
no page printed it: AetherSX2's step said "inside the app's own data
|
||||
folder", ten pages named the field itself, and every install block offered
|
||||
Linux, Windows and Android commands, for MAME4droid an Android-only app.
|
||||
215 pages printed `--emulator X` for a name no published manifest takes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
import tempfile
|
||||
import importlib.util
|
||||
import json
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import siterender # noqa: E402
|
||||
import validate_site # noqa: E402
|
||||
|
||||
|
||||
EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
with open(REPO_ROOT / "emulators" / f"{name}.yml", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle)
|
||||
|
||||
|
||||
def _page(name: str, profile: dict) -> str:
|
||||
return gs.generate_emulator_page(name, profile, EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _files(count: int, **fields: object) -> list[dict]:
|
||||
return [{"name": f"f{i:03d}.bin", **fields} for i in range(count)]
|
||||
|
||||
|
||||
class TheIntroSaysWhatIsNeeded(unittest.TestCase):
|
||||
def test_one_bios_per_region(self):
|
||||
profile = _profile("duckstation")
|
||||
intro = gs._emulator_intro("DuckStation", profile)
|
||||
self.assertIn(
|
||||
"It picks the BIOS for your game's region: scph5500.bin (Japan), "
|
||||
"scph5501.bin (North America and Latin America) or scph5502.bin "
|
||||
"(Europe and Oceania).", intro)
|
||||
others = len(profile["files"]) - 3
|
||||
self.assertIn(f"The {others} other files are optional.", intro)
|
||||
self.assertNotIn("3 of them required", intro)
|
||||
self.assertIn("DuckStation emulates Sony PlayStation.", intro)
|
||||
|
||||
def test_required_files_without_regions_keep_the_count(self):
|
||||
intro = gs._emulator_intro("X", {"files": [
|
||||
{"name": "a.bin", "required": True}, {"name": "b.bin"}]})
|
||||
self.assertIn("It reads 2 files, 1 of them required", intro)
|
||||
|
||||
def test_several_machines_say_who_needs_a_required_file(self):
|
||||
intro = gs._emulator_intro("X", {
|
||||
"systems": ["sony-playstation", "sega-saturn"],
|
||||
"files": [{"name": "a.bin", "required": True}]})
|
||||
self.assertIn("A required file is needed only by the machines that use it.", intro)
|
||||
one = gs._emulator_intro("X", {
|
||||
"systems": ["sony-playstation"], "files": [{"name": "a.bin", "required": True}]})
|
||||
self.assertNotIn("machines that use it", one)
|
||||
|
||||
|
||||
class TheHeaderReadsAsWords(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = _page("duckstation", _profile("duckstation"))
|
||||
|
||||
def test_singular_classification(self):
|
||||
self.assertIn("| Classification | Official port |", self.page)
|
||||
|
||||
def test_cores_once_per_spelling(self):
|
||||
# duckstation-sa is ROCKNIX's name for the standalone build.
|
||||
self.assertIn("| Cores | duckstation, duckstation-sa |", self.page)
|
||||
|
||||
def test_systems_by_name(self):
|
||||
row = next(line for line in self.page.splitlines() if line.startswith("| Systems |"))
|
||||
self.assertIn("Sony PlayStation", row)
|
||||
self.assertNotIn("[sony-playstation]", row)
|
||||
|
||||
def test_region_badge_is_labelled(self):
|
||||
self.assertIn(">Region: Japan</span>", self.page)
|
||||
self.assertNotIn('rb-badge-muted">japan</span>', self.page)
|
||||
|
||||
def test_profile_version_names_the_pinned_build(self):
|
||||
# DuckStation tags builds v0.1-<commits since tag 0.1>, and the
|
||||
# pin sits past the last such tag: git describe's form names both.
|
||||
self.assertRegex(str(_profile("duckstation")["core_version"]),
|
||||
r"^v0\.1-\d+(-\d+-g[0-9a-f]{7})?$")
|
||||
|
||||
def test_the_second_spelling_is_a_platform_name(self):
|
||||
# The lookup is case-sensitive and EmuELEC launches "Duckstation":
|
||||
# the page hides the duplicate, the profile keeps it.
|
||||
with open(REPO_ROOT / "platforms" / "emuelec.yml", encoding="utf-8") as handle:
|
||||
emuelec = yaml.safe_load(handle)
|
||||
self.assertIn("Duckstation", emuelec["cores"])
|
||||
self.assertIn("Duckstation", _profile("duckstation")["cores"])
|
||||
|
||||
|
||||
class RequiredFilesComeFirst(unittest.TestCase):
|
||||
def test_sections_in_need_order(self):
|
||||
files = [
|
||||
{"name": "data.pak", "category": "game_data", "required": True},
|
||||
{"name": "opt.bin"},
|
||||
{"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16},
|
||||
]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
heads = [line for line in page.splitlines() if line.startswith("## ")]
|
||||
self.assertEqual(heads[:4], ["## Files", "## Required", "## Optional", "## Game data"])
|
||||
self.assertLess(page.index("## Required"), page.index("req.bin"))
|
||||
self.assertLess(page.index("req.bin"), page.index("## Optional"))
|
||||
self.assertLess(page.index("## Game data"), page.index("data.pak"))
|
||||
|
||||
def test_the_table_of_contents_turns_on(self):
|
||||
page = _page("duckstation", _profile("duckstation"))
|
||||
self.assertGreater(len(re.findall(r"^## ", page, re.M)), 2)
|
||||
|
||||
def test_rows_carry_region_and_hash(self):
|
||||
files = _files(gs.EMULATOR_TABLE_MIN - 1) + [
|
||||
{"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16}]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
required = page[page.index("## Required"):page.index("## Optional")]
|
||||
self.assertIn('id="file-req-bin', required)
|
||||
self.assertIn("Region: Japan", required)
|
||||
self.assertIn(f'MD5\u00a0<code title="{"ab" * 16}">{"ab" * 6}</code>', required)
|
||||
self.assertNotIn("f000.bin", required)
|
||||
|
||||
def test_no_table_for_a_few_cards(self):
|
||||
page = _page("x", {"type": "standalone", "files": _files(2)})
|
||||
self.assertNotIn('class="rb-entries"', page)
|
||||
self.assertIn('class="rb-file-entry"', page)
|
||||
|
||||
def test_a_split_page_puts_required_on_the_first_part(self):
|
||||
count = gs.EMULATOR_CARDS_PER_PAGE * 2
|
||||
files = _files(count) + [{"name": "last.bin", "required": True}]
|
||||
parts = gs.emulator_parts("big", files)
|
||||
self.assertIn(count, parts[0][1])
|
||||
pages = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
|
||||
"files": files}, {"files": {}})
|
||||
main = pages["big"]
|
||||
self.assertLess(main.index("## Required"), main.index("## Optional"))
|
||||
self.assertLess(main.index("last.bin"), main.index("f000.bin"))
|
||||
self.assertIn("## Required", pages["big-files-1"])
|
||||
|
||||
|
||||
class TheSummaryLineIsProse(unittest.TestCase):
|
||||
def test_no_pipes_and_one_number_format(self):
|
||||
files = _files(1563, category="bios_zip") + [
|
||||
{"name": "a.pak", "category": "game_data"}, {"name": "b.pak", "category": "game_data"}]
|
||||
for f in files[:164]:
|
||||
f["required"] = True
|
||||
text = "\n".join(gs._emulator_file_summary(files))
|
||||
self.assertNotIn(" | ", text)
|
||||
self.assertNotIn("1399", text)
|
||||
self.assertIn("1,401 optional", text)
|
||||
self.assertIn("By kind: 1,563 BIOS ZIPs and 2 game data.", text)
|
||||
|
||||
def test_category_badge_in_words(self):
|
||||
badges = " ".join(gs._file_badges({"name": "a", "category": "game_data"}, True))
|
||||
self.assertIn(">game data</span>", badges)
|
||||
|
||||
|
||||
class TheIndexNamesEmulatorsAndSystems(unittest.TestCase):
|
||||
def test_columns_and_counts(self):
|
||||
# Pages another test split off would send the console elsewhere.
|
||||
siterender.reset_console_pages()
|
||||
self.addCleanup(siterender.reset_console_pages)
|
||||
saved = dict(siterender._system_page_map)
|
||||
self.addCleanup(siterender.set_system_page_map, saved)
|
||||
siterender.set_system_page_map({"sony-playstation": ("sony", "PlayStation")})
|
||||
page = gs.generate_emulators_index({
|
||||
"duckstation": {"emulator": "DuckStation", "core_classification": "official_port",
|
||||
"systems": ["sony-playstation"], "files": _files(3)},
|
||||
})
|
||||
self.assertIn("| Emulator | Kind | Systems | Files |", page)
|
||||
self.assertIn("\n## D\n", page)
|
||||
self.assertNotIn("| Engine |", page)
|
||||
self.assertNotIn(" -- ", page)
|
||||
row = next(line for line in page.splitlines() if line.startswith("| [DuckStation]"))
|
||||
self.assertIn(f"[{siterender.system_name('sony-playstation')}]", row)
|
||||
self.assertIn("(../systems/sony.md#playstation)", row)
|
||||
self.assertNotIn("sony-playstation |", row)
|
||||
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _emulator_page(name: str, profile: dict | None = None) -> str:
|
||||
return gs.generate_emulator_page(name, profile or _profile(name), EMPTY_DB, None, set())
|
||||
|
||||
|
||||
class EachArcadePageNamesItsRomset(unittest.TestCase):
|
||||
def test_the_romset_by_profile(self):
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003")), "MAME 0.78")
|
||||
# The app's version is 1.38.1, its MAME is 0.289.
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame4droid-2024")), "MAME 0.289")
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003_plus")), "MAME 0.78 (plus backports)")
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbneo")).startswith("FinalBurn Neo "))
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbalpha2012")).startswith("FB Alpha "))
|
||||
self.assertEqual(gs.emulator_romset(_profile("duckstation")), "")
|
||||
# HBMAME publishes no MAME release number.
|
||||
self.assertEqual(gs.emulator_romset(_profile("hbmame")), "")
|
||||
|
||||
def test_a_row_and_a_line_under_the_intro(self):
|
||||
page = _emulator_page("mame2003")
|
||||
self.assertIn(f"| Romset | [MAME 0.78]({gs.ROMSET_FAQ}) |", page)
|
||||
self.assertNotIn("| MAME version |", _emulator_page("mame2016"))
|
||||
line = page.index("Its BIOS and game sets must come from the MAME 0.78 romset.")
|
||||
self.assertLess(line, page.index("About this profile"))
|
||||
self.assertIn(f"[Which version each core reads]({gs.ROMSET_FAQ})", page)
|
||||
|
||||
def test_the_faq_answer_exists(self):
|
||||
faq = (REPO_ROOT / "wiki" / "technical-faq.md").read_text(encoding="utf-8")
|
||||
self.assertIn("## Which MAME version do the arcade BIOS sets match?", faq)
|
||||
|
||||
def test_no_line_without_bios_zips(self):
|
||||
self.assertNotIn("must come from the", _emulator_page("duckstation"))
|
||||
|
||||
|
||||
class WhatTheAppShipsIsNoFileToGet(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.profile = _profile("nethersx2")
|
||||
self.page = _emulator_page("nethersx2", self.profile)
|
||||
|
||||
def test_the_badge(self):
|
||||
font = next(f for f in self.profile["files"] if f["name"] == "Roboto-Regular.ttf")
|
||||
badges = " ".join(gs._file_badges(font, False))
|
||||
self.assertIn(">comes with the app</span>", badges)
|
||||
self.assertNotIn(">required</span>", badges)
|
||||
self.assertNotIn(">missing</span>", badges)
|
||||
# Installed by one build only: the other still reads it from the folder.
|
||||
dual = " ".join(gs._file_badges({"name": "a", "bundled": True, "mode": "standalone",
|
||||
"required": True}, False))
|
||||
self.assertIn(">required</span>", dual)
|
||||
self.assertIn("installed with the standalone emulator", dual)
|
||||
|
||||
def test_counts_leave_them_out(self):
|
||||
shipped = sum(1 for f in self.profile["files"] if gs.comes_with_app(f))
|
||||
self.assertGreater(shipped, 0)
|
||||
summary = self.page[self.page.index("## Files"):]
|
||||
self.assertIn(f"{shipped} more files come with the app.", summary)
|
||||
self.assertIn(": 1 required,", summary)
|
||||
# Found by content: one BIOS of any name, said as steps.
|
||||
self.assertIn("NetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", self.page)
|
||||
|
||||
def test_their_own_section_after_the_others(self):
|
||||
heads = re.findall(r"^## (.+)$", self.page, re.M)
|
||||
self.assertEqual(heads[heads.index("Files") + 1:heads.index("Files") + 3],
|
||||
["Required", "Optional"])
|
||||
self.assertIn("Comes with the app", heads)
|
||||
section = self.page[self.page.index("## Required"):self.page.index("## Optional")]
|
||||
self.assertNotIn("Roboto-Regular.ttf", section)
|
||||
|
||||
def test_the_collection_counts_go_under_the_summary(self):
|
||||
held = self.page.index("<p><small>In this collection:")
|
||||
self.assertLess(held, self.page.index("## Required"))
|
||||
self.assertNotIn("in repo", self.page[self.page.index("## Files"):held])
|
||||
|
||||
def test_the_folder_is_the_apps_own(self):
|
||||
self.assertIn("| BIOS directory | `bios/`, inside the app's own data folder |", self.page)
|
||||
|
||||
|
||||
class TheSummaryTableFitsItsContent(unittest.TestCase):
|
||||
def test_an_empty_region_column_goes(self):
|
||||
rows = gs._summary_table(_files(3, md5="ab" * 16), [0, 1, 2], lambda _i, s: s)
|
||||
self.assertNotIn("Region", rows[0])
|
||||
|
||||
def test_a_constant_required_column_goes(self):
|
||||
rows = gs._summary_table(_files(3, required=True), [0, 1, 2], lambda _i, s: s)
|
||||
self.assertEqual(rows[0], "| File |")
|
||||
|
||||
def test_hashes_are_short_with_the_whole_value_in_the_title(self):
|
||||
md5 = "0123456789abcdef" * 2
|
||||
cell = gs._hash_short({"md5": md5})
|
||||
self.assertEqual(cell, f'MD5 <code title="{md5}">{md5[:12]}</code>')
|
||||
|
||||
def test_the_split_index_holds_each_id_on_its_link(self):
|
||||
files = _files(gs.EMULATOR_CARDS_PER_PAGE + 1, category="bios_zip")
|
||||
main = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
|
||||
"files": files}, {"files": {}})["big"]
|
||||
anchor = gs.emulator_file_anchors(files)[0]
|
||||
self.assertIn(f'<a id="{anchor}" href="/retrobios/emulators/big-files-1/#{anchor}">', main)
|
||||
self.assertNotIn(f'<span id="{anchor}"></span>', main)
|
||||
|
||||
|
||||
class LongPagesFold(unittest.TestCase):
|
||||
def test_each_entry_is_listed_once_as_a_row(self):
|
||||
files = _files(gs.EMULATOR_TABLE_MIN) + [{"name": "req.bin", "required": True}]
|
||||
page = _emulator_page("x", {"type": "standalone", "files": files})
|
||||
self.assertNotIn("| File |", page)
|
||||
self.assertNotIn(f"<summary>{gs.EMULATOR_TABLE_MIN} files</summary>", page)
|
||||
self.assertEqual(page.count('<h3 id="file-'), len(files))
|
||||
required = page[page.index("## Required"):page.index("## Optional")]
|
||||
self.assertEqual(required.count('<h3 id="file-'), 1)
|
||||
self.assertIn('class="rb-entries"', required)
|
||||
|
||||
def test_a_few_cards_stay_open(self):
|
||||
page = _emulator_page("x", {"type": "standalone", "files": _files(2)})
|
||||
self.assertNotIn("<summary>2 files</summary>", page)
|
||||
|
||||
def test_the_profile_card_folds(self):
|
||||
page = _emulator_page("duckstation")
|
||||
start = page.index("<summary>About this profile</summary>")
|
||||
self.assertLess(start, page.index('<div class="rb-meta-card">'))
|
||||
self.assertLess(page.index("| Type |"), page.index("</details>", start))
|
||||
|
||||
|
||||
class DataDirectoryPagesStayOutOfSearch(unittest.TestCase):
|
||||
def test_front_matter(self):
|
||||
saved = dict(gs._DATA_MEMBERS), dict(gs._DATA_READERS)
|
||||
self.addCleanup(lambda: (gs._DATA_MEMBERS.clear(), gs._DATA_MEMBERS.update(saved[0]),
|
||||
gs._DATA_READERS.clear(), gs._DATA_READERS.update(saved[1])))
|
||||
gs._DATA_MEMBERS["dolphin-sys"] = {"a.bin": {"sha1": "a" * 40, "size": 1}}
|
||||
gs._DATA_READERS["dolphin-sys"] = [("dolphin-mmjr", "Dolphin MMJR"), ("dolphin", "Dolphin")]
|
||||
page = gs._data_tree_page("dolphin-sys", {}, [("data-dolphin-sys", ["a.bin"])], 0)
|
||||
front = yaml.safe_load(page.split("---\n")[1])
|
||||
self.assertEqual(front["search"], "exclude")
|
||||
self.assertEqual(front["parent"], {"label": "Dolphin", "link": "dolphin.md"})
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "data-dolphin-sys").is_dir(), "site not built")
|
||||
def test_the_built_page_is_not_indexed(self):
|
||||
html = (DIST / "emulators" / "data-dolphin-sys" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertNotIn("data-pagefind-body", html)
|
||||
|
||||
|
||||
class AnElementBudget(unittest.TestCase):
|
||||
def test_validate_site_reports_a_crowded_page(self):
|
||||
self.assertEqual(validate_site.MAX_PAGE_ELEMENTS, 10_000)
|
||||
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
page = Path(tmp) / "crowded" / "index.html"
|
||||
page.parent.mkdir()
|
||||
page.write_text("<html><body>" + "<i></i>" * 10_001
|
||||
+ "<script>" + "<b>" * 5 + "</script></body></html>")
|
||||
issues = validate_site.validate_site(Path(tmp))
|
||||
self.assertTrue(any("elements, over the 10,000 budget" in issue for issue in issues))
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "mame" / "index.html").is_file(), "site not built")
|
||||
def test_mame_is_under_it(self):
|
||||
page = validate_site._parse_page(DIST / "emulators" / "mame" / "index.html")
|
||||
self.assertLess(page.elements, validate_site.MAX_PAGE_ELEMENTS)
|
||||
|
||||
|
||||
def _pages(name: str, profile: dict | None = None) -> dict[str, str]:
|
||||
return gs.emulator_pages(name, profile or _profile(name), EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _front(page: str) -> dict:
|
||||
assert page.startswith("---\n"), page[:80]
|
||||
return yaml.safe_load(page[4:page.index("\n---\n", 4)])
|
||||
|
||||
|
||||
class AnAppThatReadsAnyBios(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = _pages("aethersx2")["aethersx2"]
|
||||
|
||||
def test_the_intro_says_what_to_do(self):
|
||||
intro = self.page[self.page.index("# AetherSX2"):self.page.index("About this profile")]
|
||||
self.assertIn("AetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", intro)
|
||||
# The folder itself, per system the app runs on.
|
||||
self.assertRegex(intro, r"\n1\. .+\n2\. Put it in this folder:")
|
||||
self.assertIn("`Android/data/xyz.aethersx2.android/files/bios/`", intro)
|
||||
self.assertIn("Any file name works. The app reads what the file holds.", intro)
|
||||
self.assertNotIn("It reads", intro)
|
||||
|
||||
def test_the_card_names_an_example(self):
|
||||
self.assertIn("a Sony PlayStation 2 BIOS (example: <code>ps2-0230a-20080220.bin</code>)",
|
||||
self.page)
|
||||
|
||||
def test_no_command_that_cannot_place_the_file(self):
|
||||
# Only through a setup that launches the app, on its
|
||||
# device, never as if the app on its own were found.
|
||||
install = self.page[self.page.index("Install this emulator's files"):]
|
||||
install = install[:install.index("</details>")]
|
||||
self.assertRegex(install, r"With [^:]+: the installer finds")
|
||||
self.assertEqual(set(re.findall(r'<TabItem label="([^"]+)">', install)), {"On the device"})
|
||||
nether = _pages("nethersx2")["nethersx2"]
|
||||
if "--emulator nethersx2" in nether:
|
||||
self.assertIn("With ", nether)
|
||||
|
||||
def test_a_core_keeps_its_command(self):
|
||||
# LRPS2 runs in RetroArch, whose BIOS folder the installer finds.
|
||||
page = _pages("lrps2")["lrps2"]
|
||||
self.assertIn("--emulator lrps2", page)
|
||||
self.assertIn("2. Put it in `pcsx2/bios/`, inside your BIOS folder.", page)
|
||||
self.assertIn("a Sony PlayStation 2 BIOS, any file name", page)
|
||||
|
||||
def test_only_found_by_content_with_one_bios(self):
|
||||
# DuckStation picks the BIOS of the game's region: its intro stays.
|
||||
self.assertIsNone(gs.any_name_bios(_profile("duckstation")))
|
||||
self.assertIsNone(gs.any_name_bios(_profile("melonds")))
|
||||
self.assertIsNotNone(gs.any_name_bios(_profile("pcsx2")))
|
||||
|
||||
def test_the_download_links_the_collection_card(self):
|
||||
steps = gs._any_name_steps("X", _profile("aethersx2"),
|
||||
gs.any_name_bios(_profile("aethersx2")),
|
||||
"../systems/sony.md#sha1-abc")
|
||||
self.assertIn("1. Download [`ps2-0230a-20080220.bin`](../systems/sony.md#sha1-abc).", steps)
|
||||
|
||||
|
||||
class EveryArcadeCoreNamesItsRomset(unittest.TestCase):
|
||||
def test_finalburn_and_mame_2003_plus(self):
|
||||
for name, romset in (("fbneo", f"FinalBurn Neo {_profile('fbneo')['core_version']}"),
|
||||
("mame2003_plus", "MAME 0.78 (plus backports)")):
|
||||
page = _pages(name)[name]
|
||||
line = page.index(f"Its BIOS and game sets must come from the {romset} romset.")
|
||||
self.assertLess(line, page.index("About this profile"), name)
|
||||
|
||||
|
||||
class PartsKnowWhereTheySit(unittest.TestCase):
|
||||
def test_emulator_card_parts(self):
|
||||
pages = _pages("mame")
|
||||
parts = sorted((stem for stem in pages if stem.startswith("mame-files-")),
|
||||
key=lambda stem: int(stem.rsplit("-", 1)[1]))
|
||||
self.assertGreater(len(parts), 2)
|
||||
for index, stem in enumerate(parts):
|
||||
front = _front(pages[stem])
|
||||
self.assertEqual(front["parent"], {"label": "MAME", "link": "mame.md"})
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertEqual(front["prev"]["link"],
|
||||
f"{parts[index - 1]}.md" if index else "mame.md")
|
||||
if index + 1 < len(parts):
|
||||
self.assertEqual(front["next"]["link"], f"{parts[index + 1]}.md")
|
||||
else:
|
||||
self.assertIs(front["next"], False)
|
||||
|
||||
def test_data_directory_parts(self):
|
||||
ref = "x-sys"
|
||||
members = {f"f{i:05d}": {"size": 1, "sha1": f"{i:040x}"}
|
||||
for i in range(gs.DATA_TREE_ROWS_PER_PAGE * 2 + 1)}
|
||||
gs._DATA_MEMBERS[ref] = members
|
||||
gs._DATA_READERS[ref] = [("x", "X")]
|
||||
try:
|
||||
pages = gs.data_tree_pages(ref, members)
|
||||
self.assertEqual(len(pages), 3)
|
||||
middle = _front(gs._data_tree_page(ref, {}, pages, 1))
|
||||
finally:
|
||||
del gs._DATA_MEMBERS[ref], gs._DATA_READERS[ref]
|
||||
self.assertEqual(middle["parent"], {"label": "X", "link": "x.md"})
|
||||
self.assertEqual(middle["prev"]["link"], f"{pages[0][0]}.md")
|
||||
self.assertEqual(middle["next"]["link"], f"{pages[2][0]}.md")
|
||||
self.assertEqual(middle["search"], "exclude")
|
||||
|
||||
|
||||
class ARequiredFileSaysForWhichSystem(unittest.TestCase):
|
||||
def test_melonds_dsi_files(self):
|
||||
page = _pages("melonds")["melonds"]
|
||||
row = page[page.index('id="file-dsi-nand-bin"'):]
|
||||
row = row[:row.index("</tr>")]
|
||||
self.assertIn(">required for Nintendo DSi</span>", row)
|
||||
self.assertNotIn(">required</span>", page)
|
||||
|
||||
def test_one_system_says_required(self):
|
||||
# The section heading says it: the rows under it repeat nothing.
|
||||
page = _pages("aethersx2")["aethersx2"]
|
||||
self.assertIn("## Required", page)
|
||||
self.assertNotIn("required for", page)
|
||||
|
||||
|
||||
class TheTemplateIsNoEmulator(unittest.TestCase):
|
||||
def test_off_the_index_and_the_search(self):
|
||||
profiles = {"00_example": _profile("00_example"), "melonds": _profile("melonds")}
|
||||
index = gs.generate_emulators_index(profiles)
|
||||
self.assertNotIn("00_example", index)
|
||||
self.assertIn("melonds.md", index)
|
||||
# A test profile gets no page at all (generate_site's public_profiles).
|
||||
self.assertEqual(_profile("00_example")["type"], "test")
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "aethersx2" / "index.html").is_file(), "site not built")
|
||||
class TheBuiltEmulatorPages(unittest.TestCase):
|
||||
def _html(self, path: str) -> str:
|
||||
return (DIST / path / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
def test_aethersx2_downloads_from_the_collection(self):
|
||||
page = self._html("emulators/aethersx2")
|
||||
self.assertRegex(page, r'Download <a href="[^"]*/systems/sony[^"]*#sha1-[0-9a-f]{12}"')
|
||||
# The command only where a setup launches AetherSX2 itself (KNULLI,
|
||||
# ROCKNIX), never through RetroArch's folder, which the app never reads.
|
||||
self.assertIn("With ROCKNIX or KNULLI:", page)
|
||||
self.assertNotIn("RetroArch", page.split("Install this emulator", 1)[1].split("</details>", 1)[0])
|
||||
|
||||
def test_the_index_leaves_the_template_out(self):
|
||||
# 00_example is `type: test`: no page, no index row.
|
||||
self.assertNotIn("00_example", self._html("emulators"))
|
||||
self.assertIn('data-pagefind-body', self._html("emulators/melonds"))
|
||||
self.assertFalse((DIST / "emulators" / "00_example").exists())
|
||||
|
||||
def test_mame_parts_have_a_parent_crumb(self):
|
||||
page = self._html("emulators/mame-files-3")
|
||||
crumbs = re.search(r'<nav[^>]*rb-breadcrumbs.*?</nav>', page, re.S)
|
||||
self.assertIsNotNone(crumbs)
|
||||
self.assertIn(">MAME<", crumbs.group(0))
|
||||
|
||||
|
||||
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
EMULATORS = CONTENT / "emulators"
|
||||
|
||||
|
||||
def _profiles_with_data_root() -> dict[str, dict]:
|
||||
found = {}
|
||||
for path in sorted((REPO_ROOT / "emulators").glob("*.yml")):
|
||||
profile = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if isinstance(profile, dict) and isinstance(profile.get("data_root"), dict):
|
||||
found[path.stem] = profile
|
||||
return found
|
||||
|
||||
|
||||
def _built_page(name: str) -> str:
|
||||
for suffix in (".mdx", ".md"):
|
||||
path = EMULATORS / f"{name}{suffix}"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
def _generated() -> bool:
|
||||
return EMULATORS.is_dir() and any(EMULATORS.iterdir())
|
||||
|
||||
|
||||
def _install_section(page: str) -> str:
|
||||
match = re.search(r"<summary>Install this emulator's files</summary>(.*?)</details>",
|
||||
page, re.S)
|
||||
return match[1] if match else ""
|
||||
|
||||
|
||||
def _tab_labels(text: str) -> set[str]:
|
||||
return set(re.findall(r'<TabItem label="([^"]+)">', text))
|
||||
|
||||
|
||||
def _entries(profile: dict) -> list[dict]:
|
||||
return [f for f in profile.get("files") or [] if isinstance(f, dict)]
|
||||
|
||||
|
||||
def _alternatives(root: str) -> list[str]:
|
||||
return [part.strip().rstrip("/") for part in root.split(", else ")]
|
||||
|
||||
|
||||
class FolderOnlyTable(unittest.TestCase):
|
||||
"""The entries whose joined path is not the app's folder: one table in
|
||||
the generator, each row with its reason, and it cannot rot."""
|
||||
|
||||
def test_every_row_names_a_profile_and_entries_that_exist(self) -> None:
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, reason in gs.FOLDER_ONLY:
|
||||
with self.subTest(profile=profile_name, patterns=patterns):
|
||||
self.assertIn(profile_name, roots, "no profile with data_root by that name")
|
||||
self.assertTrue(reason.strip())
|
||||
entries = _entries(roots[profile_name])
|
||||
for pattern in patterns:
|
||||
self.assertTrue(
|
||||
any(gs.folder_only_matches(pattern, f) for f in entries),
|
||||
f"{pattern!r} names no entry of {profile_name}")
|
||||
self.assertLessEqual(set(keys), set(roots[profile_name]["data_root"]))
|
||||
|
||||
def test_a_listed_entry_is_never_joined_to_the_root(self) -> None:
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
|
||||
profile = roots[profile_name]
|
||||
for entry in _entries(profile):
|
||||
if not any(gs.folder_only_matches(p, entry) for p in patterns):
|
||||
continue
|
||||
raw = {str(entry.get(k) or "") for k in ("path", "standalone_path")} - {""}
|
||||
for key in keys or tuple(profile["data_root"]):
|
||||
with self.subTest(profile=profile_name, entry=entry.get("name"), key=key):
|
||||
self.assertEqual(gs.standalone_place(profile_name, profile, entry, key), "")
|
||||
self.assertEqual(gs.standalone_place(profile_name, profile, entry), "")
|
||||
block = "\n".join(gs.data_folder_block(profile_name, profile))
|
||||
for root in _alternatives(str(profile["data_root"][key]["path"])):
|
||||
for place in raw:
|
||||
self.assertNotIn(f"{root}/{place}", block)
|
||||
|
||||
def test_built_pages_print_no_joined_path_for_a_listed_entry(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
|
||||
profile = roots[profile_name]
|
||||
pages = [p.read_text(encoding="utf-8")
|
||||
for p in EMULATORS.glob(f"{profile_name}*.md*")]
|
||||
for entry in _entries(profile):
|
||||
if not any(gs.folder_only_matches(p, entry) for p in patterns):
|
||||
continue
|
||||
for key in keys or tuple(profile["data_root"]):
|
||||
for root in _alternatives(str(profile["data_root"][key]["path"])):
|
||||
for place in {entry.get("path"), entry.get("standalone_path")} - {None, ""}:
|
||||
joined = f"{root}/{place}"
|
||||
for text in pages:
|
||||
self.assertNotIn(joined, text, profile_name)
|
||||
|
||||
|
||||
class DataFolderPrinted(unittest.TestCase):
|
||||
def test_every_profile_prints_each_of_its_folders(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
for name, profile in _profiles_with_data_root().items():
|
||||
if profile.get("type") in ("alias", "test"):
|
||||
continue
|
||||
page = _built_page(name)
|
||||
with self.subTest(profile=name):
|
||||
self.assertIn('id="data-folder"', page)
|
||||
for root in profile["data_root"].values():
|
||||
for alternative in _alternatives(str(root["path"])):
|
||||
self.assertIn(alternative, page)
|
||||
|
||||
def test_ryujinx_keys_sit_in_system(self) -> None:
|
||||
block = "\n".join(gs.data_folder_block("ryujinx", _profile("ryujinx")))
|
||||
self.assertIn("`%APPDATA%/Ryujinx/system/`", block)
|
||||
self.assertIn("`~/Library/Application Support/Ryujinx/system/`", block)
|
||||
self.assertEqual(_tab_labels(block), {"Windows", "macOS", "Linux"})
|
||||
|
||||
def test_aethersx2_step_names_the_android_folder(self) -> None:
|
||||
profile = _profile("aethersx2")
|
||||
steps = gs._any_name_steps("AetherSX2", profile, gs.any_name_bios(profile), "", "aethersx2")
|
||||
self.assertIn("2. Put it in this folder:", steps)
|
||||
self.assertIn("`Android/data/xyz.aethersx2.android/files/bios/`, on the internal storage.",
|
||||
steps)
|
||||
self.assertNotIn("app's own data folder", steps)
|
||||
|
||||
def test_one_system_is_a_line_not_a_tab(self) -> None:
|
||||
block = "\n".join(gs.data_folder_block("mame4droid", _profile("mame4droid")))
|
||||
self.assertTrue(block.startswith("On Android: `Android/data/com.seleuco.mame4droid/files/`"))
|
||||
self.assertNotIn("<Tabs", block)
|
||||
|
||||
def test_an_entry_found_by_name_or_shipped_is_not_placed(self) -> None:
|
||||
profile = {"type": "standalone", "data_root": {"linux": {"path": "~/x", "source_ref": "a"}}}
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a.bin"}), "")
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "a", "bundled": True}),
|
||||
"")
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "b/a"}), "b/a")
|
||||
dual = {**profile, "type": "standalone + libretro"}
|
||||
self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "core/a"}), "")
|
||||
self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "c/a",
|
||||
"standalone_path": "a"}), "a")
|
||||
|
||||
def test_the_metadata_card_has_a_row_per_system(self) -> None:
|
||||
rows = gs._render_metadata_rows(_profile("cxbx-reloaded"))
|
||||
self.assertIn("| Data folder (Windows) | `%APPDATA%/Cxbx-Reloaded/` |", rows)
|
||||
self.assertFalse(any("Data folder (Linux)" in row for row in rows))
|
||||
|
||||
def test_no_page_names_the_field(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
named = sorted(str(p.relative_to(CONTENT)) for p in CONTENT.rglob("*.md*")
|
||||
if "data_root" in p.read_text(encoding="utf-8")
|
||||
and not p.relative_to(CONTENT).as_posix().startswith("wiki/profiling"))
|
||||
self.assertEqual(named, [])
|
||||
|
||||
|
||||
class InstallBlockWhereItWorks(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
|
||||
cls.install = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.install)
|
||||
cls.accepted: dict[str, set[str]] = {}
|
||||
for path in sorted((REPO_ROOT / "install").glob("*.json")):
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
names = cls.install.emulator_names(manifest, manifest.get("files") or [],
|
||||
manifest.get("omitted_files") or [])
|
||||
for alias, profile in names.items():
|
||||
cls.accepted.setdefault(alias, set()).add(profile)
|
||||
|
||||
def test_every_printed_command_names_this_profile_in_a_manifest(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
for path in sorted(EMULATORS.glob("*.md*")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for name in set(re.findall(r"--emulator ([\w.+-]+)", text)):
|
||||
with self.subTest(page=path.name, name=name):
|
||||
self.assertIn(path.stem, self.accepted.get(name, set()))
|
||||
|
||||
def test_no_os_tab_outside_the_data_root(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
labels = {key: gs.TERMINALS[key][0] for key in gs.TERMINALS}
|
||||
for name, profile in _profiles_with_data_root().items():
|
||||
allowed = {labels["device"]}
|
||||
for key in profile["data_root"]:
|
||||
allowed |= {labels[t] for t in gs._DATA_ROOT_TERMINALS[key]}
|
||||
with self.subTest(profile=name):
|
||||
self.assertLessEqual(_tab_labels(_install_section(_built_page(name))), allowed)
|
||||
|
||||
def test_android_only_app_gets_no_desktop_command(self) -> None:
|
||||
self.assertEqual(gs._install_block("mame4droid", _profile("mame4droid")), [])
|
||||
command, setups, oses = gs.emulator_install_route("cxbx-reloaded", _profile("cxbx-reloaded"))
|
||||
self.assertEqual(oses, ("windows",))
|
||||
|
||||
def test_a_lone_app_is_named_with_the_setup_that_launches_it(self) -> None:
|
||||
text = "\n".join(gs._install_block("ryujinx", _profile("ryujinx")))
|
||||
self.assertRegex(text, r"With [^:]*EmuDeck[^:]*: the installer finds")
|
||||
self.assertNotIn("Android (Termux)", text)
|
||||
self.assertNotIn("RetroArch", text)
|
||||
|
||||
def test_a_dual_app_says_what_the_command_fills(self) -> None:
|
||||
text = "\n".join(gs._install_block("duckstation", _profile("duckstation")))
|
||||
self.assertIn("For DuckStation inside RetroArch, Batocera or another setup.", text)
|
||||
|
||||
def test_no_files_no_command(self) -> None:
|
||||
self.assertEqual(gs._install_block("beetle_wswan", _profile("beetle_wswan")), [])
|
||||
|
||||
def test_tabs_follow_the_keys_given(self) -> None:
|
||||
tabs = "\n".join(gs.install_commands("--emulator x", oses=("linux", "steamdeck")))
|
||||
self.assertEqual(_tab_labels(tabs), {"Linux", "Steam Deck"})
|
||||
self.assertIn('syncKey="os"', tabs)
|
||||
blocks = "\n".join(gs.install_commands("--emulator x"))
|
||||
self.assertIn("Windows (PowerShell):", blocks)
|
||||
|
||||
|
||||
class SmallFixes(unittest.TestCase):
|
||||
def test_structured_blocks_closed_below_the_files(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
page = _built_page("cemu")
|
||||
self.assertLess(page.index("## Files"), page.index("<summary>Firmware titles</summary>"))
|
||||
self.assertNotRegex(page, r"<details[^>]*open>\s*<summary>Firmware titles")
|
||||
|
||||
def test_region_choice_summary(self) -> None:
|
||||
lines = gs._emulator_file_summary(_profile("duckstation")["files"])
|
||||
self.assertIn("One of 3 is required, for your games' region.", lines[0])
|
||||
self.assertNotIn("3 required", lines[0])
|
||||
|
||||
def test_no_emulator_part_says_where_it_sits(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
parts = sorted(EMULATORS.glob("*-files-*.md*"))
|
||||
if not parts:
|
||||
self.skipTest("no split emulator page")
|
||||
said = [p.name for p in parts if "\nPart of [" in p.read_text(encoding="utf-8")]
|
||||
self.assertEqual(said, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
"""What the round 4 packages left between them once merged.
|
||||
"""What separate fixes left between them once merged.
|
||||
|
||||
A data directory page printed its readers as a Python list, every board
|
||||
page's breadcrumb read "Arcade / Arcade", the fba2012 section was headed
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Navigation.
|
||||
"""Navigation and search.
|
||||
|
||||
The 404 page's sidebar read "0 1 2 3 4". A sidebar group label was a link
|
||||
to its first page ("Handhelds and TV boxes" opened AmberELEC), and a crumb
|
||||
@@ -7,8 +7,18 @@ Previous and next ran from Sony to Sord and from one tab into another,
|
||||
and disagreed with the page's own "Next" list. The Reference tab was a flat
|
||||
list of fifteen. These tests read the generator and, where it is built,
|
||||
the site.
|
||||
"""
|
||||
|
||||
"playstation bios" found five setups' lists for the console before its own
|
||||
page: the pages a setup's list moves a console to weighed 1.5, three times a
|
||||
reference page. The file names a system page holds were the text of a hidden
|
||||
span, so the search showed them as the excerpt. Start here looped: First
|
||||
install's "Next steps" pointed back to Get your files, Is it legal? to Common
|
||||
questions, and both pages had the same page as previous and next. A part
|
||||
page had no crumb to the page it belongs to and nothing marked in the
|
||||
sidebar. "psx" or "dc" found nothing, though every platform files the
|
||||
console under that name. These tests read the generator and, where they
|
||||
are built, the pages and the site.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -19,20 +29,24 @@ import tempfile
|
||||
import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
import ast
|
||||
from html import unescape
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import sitestarlight # noqa: E402
|
||||
from common import list_registered_platforms, load_platform_config, load_platform_registry # noqa: E402
|
||||
from common import ( # noqa: E402
|
||||
list_registered_platforms,
|
||||
load_platform_config,
|
||||
load_platform_registry,
|
||||
)
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
ROUTE = REPO_ROOT / "website" / "src" / "routeData.ts"
|
||||
TAB_ROOTS = {"/retrobios/", "/retrobios/platforms/", "/retrobios/explore/",
|
||||
"/retrobios/wiki/", "/retrobios/contributing/"}
|
||||
|
||||
|
||||
def _nav() -> list:
|
||||
platforms = REPO_ROOT / "platforms"
|
||||
registry = load_platform_registry(str(platforms))
|
||||
@@ -47,24 +61,18 @@ def _nav() -> list:
|
||||
"mame": {"emulator": "MAME", "systems": ["arcade"]},
|
||||
}
|
||||
return generate_site.generate_nav(coverages, manufacturers, profiles, registry)
|
||||
|
||||
|
||||
def _targets(value: object) -> set[str]:
|
||||
if isinstance(value, str):
|
||||
return {value}
|
||||
if isinstance(value, list):
|
||||
return set().union(*(_targets(v) for v in value)) if value else set()
|
||||
return set().union(*(_targets(v) for v in value.values()))
|
||||
|
||||
|
||||
def _present(nav: list) -> set[str]:
|
||||
return {t for t in _targets(nav) if not t.startswith("http")}
|
||||
|
||||
|
||||
# The setups index writes one `## <label>` per device group, so its anchors
|
||||
# follow the labels in platforms/_guides.yml instead of a copy of them.
|
||||
from siteguides import DEVICE_GROUPS # noqa: E402
|
||||
|
||||
|
||||
def _index_anchors(markdown: str) -> set[str]:
|
||||
"""The heading ids a generated page carries once written."""
|
||||
_title, title_id, body = sitestarlight.split_title(markdown)
|
||||
@@ -293,7 +301,7 @@ class PlatformShareCards(unittest.TestCase):
|
||||
def test_other_pages_keep_their_section_card(self):
|
||||
self.assertEqual(sitestarlight.og_image("platforms/index.md", "setup"),
|
||||
f"{sitestarlight.SITE_URL}assets/og/setup.png")
|
||||
# A manufacturer page has its own card since round 3 (6.6). One
|
||||
# A manufacturer page has its own card. One
|
||||
# without a rendered card keeps its section's.
|
||||
self.assertEqual(sitestarlight.og_image("systems/sony.md", "explore"),
|
||||
f"{sitestarlight.SITE_URL}assets/og/systems/sony.png")
|
||||
@@ -352,7 +360,7 @@ def _read(path: Path) -> _Page:
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
class BuiltSite(unittest.TestCase):
|
||||
class BuiltNavigation(unittest.TestCase):
|
||||
def test_the_404_sidebar_names_pages(self):
|
||||
page = _read(DIST / "404.html")
|
||||
self.assertTrue(page.sidebar)
|
||||
@@ -389,5 +397,224 @@ class BuiltSite(unittest.TestCase):
|
||||
self.assertEqual(data.get("/emulators/mednafen_psx/"), "/emulators/beetle_psx/")
|
||||
|
||||
|
||||
SEARCH_GATE = REPO_ROOT / "website" / "tools" / "search_gate.py"
|
||||
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
|
||||
CONTENT_CONFIG = REPO_ROOT / "website" / "src" / "content.config.ts"
|
||||
START_HERE = [
|
||||
"/retrobios/", "/retrobios/wiki/what-are-bios-files/", "/retrobios/which-pack/",
|
||||
"/retrobios/wiki/first-install/", "/retrobios/wiki/troubleshooting/",
|
||||
"/retrobios/wiki/faq/", "/retrobios/wiki/legal/", "/retrobios/wiki/glossary/",
|
||||
"/retrobios/platforms/",
|
||||
]
|
||||
|
||||
|
||||
def _front(text: str) -> str:
|
||||
end = text.find("\n---\n", 4)
|
||||
return text[4:end] if text.startswith("---\n") and end != -1 else ""
|
||||
|
||||
|
||||
def _moved_platform_pages() -> list[Path]:
|
||||
platforms = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||
folder = DOCS / "platforms"
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
# A guide page sits beside the setups (platforms/linux-handhelds) without
|
||||
# being a list moved off one of them.
|
||||
from siteguides import HANDHELD_GUIDE # noqa: PLC0415
|
||||
|
||||
guides = {Path(HANDHELD_GUIDE.path).stem} if HANDHELD_GUIDE else set()
|
||||
return [path for path in sorted(folder.glob("*.md*"))
|
||||
if path.stem not in platforms and path.stem != "index" and path.stem not in guides]
|
||||
|
||||
|
||||
class MetaFields(unittest.TestCase):
|
||||
def test_file_names_are_an_attribute_not_text(self) -> None:
|
||||
body = ('<h3 class="rb-file-title"><code>scph5501.bin</code></h3>'
|
||||
'<h3 class="rb-file-title"><code>a,b.bin</code></h3>')
|
||||
meta = sitestarlight.file_names_meta(body)
|
||||
# Each name as its parts, each part once.
|
||||
self.assertIn('data-pagefind-meta="files:a b bin scph5501"', meta)
|
||||
self.assertRegex(meta, r"<span [^>]*hidden></span>")
|
||||
|
||||
def test_a_field_escapes_its_value(self) -> None:
|
||||
meta = sitestarlight.search_field("aliases", ['a"b', "c&d"])
|
||||
self.assertIn('data-pagefind-meta="aliases:a"b c&d"', meta)
|
||||
|
||||
def test_an_empty_field_writes_nothing(self) -> None:
|
||||
self.assertEqual(sitestarlight.search_field("aliases", []), "")
|
||||
self.assertEqual(sitestarlight.file_names_meta("<p>no card</p>"), "")
|
||||
|
||||
def test_system_pages_carry_the_platforms_names(self) -> None:
|
||||
page = DOCS / "systems" / "sony-playstation.md"
|
||||
if not page.is_file():
|
||||
self.skipTest("site pages not generated")
|
||||
text = page.read_text(encoding="utf-8")
|
||||
match = re.search(r'data-pagefind-meta="aliases:([^"]*)"', text)
|
||||
self.assertIsNotNone(match, "no aliases field on the PlayStation page")
|
||||
self.assertIn("psx", unescape(match[1]).split())
|
||||
# The Dreamcast section's page: its own, or Sega's when it fits there.
|
||||
own = DOCS / "systems" / "sega-dreamcast.md"
|
||||
dreamcast = (own if own.is_file() else DOCS / "systems" / "sega.md").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertRegex(dreamcast, r'data-pagefind-meta="aliases:[^"]*\bdc\b')
|
||||
|
||||
|
||||
class SystemPartsSitUnderTheirMaker(unittest.TestCase):
|
||||
"""The crumbs say where a system part sits, read from its parent: field.
|
||||
The "Part of" line under them said it again."""
|
||||
|
||||
def test_every_part_names_its_maker_as_parent(self) -> None:
|
||||
pages = sorted((DOCS / "systems").glob("*.md"))
|
||||
if not pages:
|
||||
self.skipTest("site pages not generated")
|
||||
makers = {page.stem for page in pages}
|
||||
orphans = []
|
||||
for page in pages:
|
||||
text = page.read_text(encoding="utf-8")
|
||||
front = text.split("\n---\n", 1)[0]
|
||||
parent = re.search(r"^parent:\n label: .+\n link: /retrobios/systems/([a-z0-9-]+)/$",
|
||||
front, re.M)
|
||||
if parent is None:
|
||||
continue
|
||||
if parent[1] not in makers:
|
||||
orphans.append(page.name)
|
||||
self.assertNotIn("\nPart of [", text, page.name)
|
||||
self.assertEqual(orphans, [])
|
||||
|
||||
|
||||
class MovedPlatformPages(unittest.TestCase):
|
||||
def test_a_setups_list_for_one_console_is_a_reference(self) -> None:
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
block = source[source.index("for stem, page in moved.items():"):]
|
||||
block = block[: block.index("PAGE_DATA[f\"platforms/{name}.md\"]")]
|
||||
self.assertIn('SEARCH_DEMOTED.add(f"platforms/{stem}.md")', block)
|
||||
self.assertIn('level="reference"', block)
|
||||
self.assertIn('search="exclude"', block)
|
||||
self.assertIn("parent=", block)
|
||||
|
||||
def test_generated_pages_weigh_as_references_and_name_their_setup(self) -> None:
|
||||
pages = _moved_platform_pages()
|
||||
if not pages:
|
||||
self.skipTest("site pages not generated")
|
||||
for path in pages:
|
||||
front = _front(path.read_text(encoding="utf-8"))
|
||||
with self.subTest(page=path.name):
|
||||
self.assertIn("\nlevel: reference\n", f"\n{front}\n")
|
||||
self.assertIn("\nsearchWeight: 0.5\n", f"\n{front}\n")
|
||||
# The title alone was indexed, and it outranked the console.
|
||||
self.assertIn("\npagefind: false\n", f"\n{front}\n")
|
||||
self.assertRegex(front, r"parent:\n label: .+\n link: /retrobios/platforms/[a-z0-9_-]+/\n")
|
||||
|
||||
def test_parent_link_resolves_against_the_page(self) -> None:
|
||||
_target, text = sitestarlight.finalize_page(
|
||||
"platforms/retroarch-sony-playstation.md", "# RetroArch: Sony PlayStation\n\nBody.\n",
|
||||
level="reference", weight=0.5, browser_title="x", description="d",
|
||||
structured={}, terms={}, parent={"label": "RetroArch", "link": "retroarch.md"},
|
||||
)
|
||||
self.assertIn("parent:\n label: RetroArch\n link: /retrobios/platforms/retroarch/\n", text)
|
||||
|
||||
def test_the_schema_accepts_parent(self) -> None:
|
||||
self.assertRegex(CONTENT_CONFIG.read_text(encoding="utf-8"),
|
||||
r"parent: z\.object\(\{ label: z\.string\(\), link: z\.string\(\) \}\)")
|
||||
|
||||
|
||||
class ArcadeGroup(unittest.TestCase):
|
||||
def test_arcade_is_not_a_console_or_computer(self) -> None:
|
||||
nav = generate_site._system_nav({"Sony": {}, "Arcade": {}, "Sega": {}})
|
||||
groups = {next(iter(entry)): entry[next(iter(entry))] for entry in nav if isinstance(entry, dict)}
|
||||
self.assertEqual(groups["Arcade"], [{"Arcade": "systems/arcade.md"}])
|
||||
self.assertNotIn({"Arcade": "systems/arcade.md"}, groups["Consoles and computers"])
|
||||
|
||||
|
||||
class RouteMiddleware(unittest.TestCase):
|
||||
def test_next_steps_link_is_kept_only_forward(self) -> None:
|
||||
source = ROUTE.read_text(encoding="utf-8")
|
||||
self.assertIn("target <= position", source)
|
||||
|
||||
def test_parent_is_a_crumb_and_marked(self) -> None:
|
||||
source = ROUTE.read_text(encoding="utf-8")
|
||||
self.assertIn("route.entry.data.parent", source)
|
||||
self.assertIn("markCurrent(route.sidebar", source)
|
||||
|
||||
|
||||
def _gate_queries() -> dict:
|
||||
"""The search gate's QUERIES, read from its source: the tool is run
|
||||
with node beside it, not imported as a package."""
|
||||
tree = ast.parse(SEARCH_GATE.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "QUERIES":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("no QUERIES in search_gate.py")
|
||||
|
||||
|
||||
class SearchGateQueries(unittest.TestCase):
|
||||
def test_the_audits_queries_are_gated(self) -> None:
|
||||
for query in ("playstation bios", "dreamcast bios", "neo geo bios", "bios not found",
|
||||
"offline install", "r36s", "wonderswan color", "dc bios missing",
|
||||
"which mame version", "psx bios"):
|
||||
self.assertIn(query, _gate_queries())
|
||||
|
||||
def test_prefixes_match_a_consoles_own_page(self) -> None:
|
||||
pages, _rank = _gate_queries()["playstation bios"]
|
||||
self.assertTrue(any("systems/sony-playstation/".startswith(p) for p in pages))
|
||||
|
||||
|
||||
def _html(url: str) -> str:
|
||||
rel = url[len("/retrobios/"):]
|
||||
path = DIST / rel / "index.html"
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
|
||||
def _pagination(text: str) -> tuple[str | None, str | None]:
|
||||
start = text.find('<div class="pagination-links')
|
||||
if start == -1:
|
||||
return None, None
|
||||
links = text[start:text.find("</footer>", start)]
|
||||
prev = re.search(r'<a href="([^"]+)"[^>]*rel="prev"', links)
|
||||
nxt = re.search(r'<a href="([^"]+)"[^>]*rel="next"', links)
|
||||
return (prev[1] if prev else None), (nxt[1] if nxt else None)
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
class BuiltSearchAndCrumbs(unittest.TestCase):
|
||||
def test_next_from_home_reads_start_here_once(self) -> None:
|
||||
seen, url = [], "/retrobios/"
|
||||
while url and url not in seen and len(seen) < 20:
|
||||
seen.append(url)
|
||||
url = _pagination(_html(url))[1]
|
||||
self.assertEqual(seen[: len(START_HERE)], START_HERE)
|
||||
self.assertEqual(len(seen), len(set(seen)))
|
||||
|
||||
def test_no_page_has_the_same_previous_and_next(self) -> None:
|
||||
same = []
|
||||
for path in DIST.rglob("index.html"):
|
||||
prev, nxt = _pagination(path.read_text(encoding="utf-8"))
|
||||
if prev and prev == nxt:
|
||||
same.append(str(path.relative_to(DIST)))
|
||||
self.assertEqual(same, [])
|
||||
|
||||
def test_search_fields_hold_no_text(self) -> None:
|
||||
texts = []
|
||||
for path in (DIST / "systems").rglob("index.html"):
|
||||
# The search fields are attributes on empty spans. The title
|
||||
# meta reads the visible h1.
|
||||
for match in re.finditer(r'<(\w+)[^>]*data-pagefind-meta="(?:files|aliases|devices|consoles|terms):'
|
||||
r'[^>]*>([^<]*)</\1>',
|
||||
path.read_text(encoding="utf-8")):
|
||||
if match[2].strip():
|
||||
texts.append(str(path.relative_to(DIST)))
|
||||
self.assertEqual(texts, [])
|
||||
|
||||
def test_a_setups_console_page_names_its_setup(self) -> None:
|
||||
text = _html("/retrobios/platforms/retroarch-sony-playstation/")
|
||||
if not text:
|
||||
self.skipTest("no RetroArch PlayStation page")
|
||||
crumbs = re.search(r'<nav[^>]*class="[^"]*rb-breadcrumbs[^"]*"[^>]*>(.*?)</nav>', text, re.S)
|
||||
self.assertIsNotNone(crumbs)
|
||||
self.assertIn('href="/retrobios/platforms/retroarch/"', crumbs[1])
|
||||
self.assertRegex(text, r'<a href="/retrobios/platforms/retroarch/"[^>]*aria-current="page"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Round 2, package 2: the site's components and runtime.
|
||||
|
||||
Source checks always run. Checks on the built site read website/dist and
|
||||
skip when no build is there. The stacking rule runs the plugin under node
|
||||
and skips without node or the site's node_modules.
|
||||
"""
|
||||
|
||||
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"
|
||||
PLUGIN = WEBSITE / "src" / "plugins" / "rehype-stack-tables.mjs"
|
||||
|
||||
|
||||
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 _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]
|
||||
|
||||
|
||||
class TablesStackWhenBuilt(unittest.TestCase):
|
||||
"""The setup tables of Get your files were 591, 503 and 511 px wide in a
|
||||
358 px phone column: site.ts stacked them only once they neared the
|
||||
screen, so the ZIP column first painted off-screen."""
|
||||
|
||||
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.assertNotIn("stackActionTable", _read(SITE_TS))
|
||||
|
||||
def test_setup_tables_carry_the_layout_in_the_html(self):
|
||||
html = _built("which-pack")
|
||||
setups = [t for t in _tables(html) if _headers(t) == ["Setup", "Runs on", "BIOS folder", "ZIP"]]
|
||||
self.assertGreaterEqual(len(setups), 3)
|
||||
for table in setups:
|
||||
with self.subTest(table=table[:80]):
|
||||
self.assertIn('data-rb-stack="true"', table.split(">", 1)[0])
|
||||
cells = re.findall(r"<td\b[^>]*>", table)
|
||||
self.assertTrue(cells)
|
||||
for cell in cells:
|
||||
self.assertRegex(cell, r'data-label="(Setup|Runs on|BIOS folder|ZIP)"')
|
||||
|
||||
def test_two_column_tables_are_label_and_value(self):
|
||||
html = _built("platforms/retroarch")
|
||||
pairs = [t for t in _tables(html) if len(_headers(t)) == 2]
|
||||
self.assertTrue(pairs)
|
||||
for table in pairs:
|
||||
self.assertIn('data-rb-stack="kv"', table.split(">", 1)[0])
|
||||
|
||||
def test_the_rule_reads_the_table(self):
|
||||
node = shutil.which("node")
|
||||
if not node or not (WEBSITE / "node_modules").is_dir():
|
||||
self.skipTest("node or the site's node_modules is missing")
|
||||
cases = {
|
||||
# A setup added after the release: no ZIP link, the header still names one.
|
||||
"zip-header": {"labels": ["Setup", "Runs on", "BIOS folder", "ZIP"], "rows": 5, "action": False},
|
||||
"action": {"labels": ["A", "B", "C"], "rows": 40, "action": True},
|
||||
"pair": {"labels": ["Where", "BIOS folder"], "rows": 3, "action": False},
|
||||
"short-start": {"labels": ["System", "Runs", "BIOS folder"], "rows": 8, "action": False, "level": "start"},
|
||||
"long-setup": {"labels": ["System", "Files", "Status"], "rows": 97, "action": False, "level": "setup"},
|
||||
"reference": {"labels": ["A", "B", "C"], "rows": 5, "action": False, "level": "reference"},
|
||||
# Four columns do not fit a phone column, on any page.
|
||||
"wide": {"labels": ["File", "Region", "Required", "Hash"], "rows": 105, "action": False, "level": "reference"},
|
||||
# A hash never breaks: three columns with one overflow a phone (NetherSX2).
|
||||
"hashes": {"labels": ["File", "Required", "Hash"], "rows": 40, "action": False, "level": "reference"},
|
||||
}
|
||||
script = (
|
||||
f"import {{ stackKind }} from {json.dumps(PLUGIN.as_uri())};"
|
||||
f"const cases = {json.dumps(cases)};"
|
||||
"console.log(JSON.stringify(Object.fromEntries(Object.entries(cases).map(([k, v]) => [k, stackKind(v)]))));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[node, "--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, check=True, cwd=WEBSITE,
|
||||
)
|
||||
self.assertEqual(json.loads(result.stdout), {
|
||||
"zip-header": "true", "action": "true", "pair": "kv",
|
||||
"short-start": "true", "long-setup": None, "reference": None, "wide": "true",
|
||||
"hashes": "true",
|
||||
})
|
||||
|
||||
|
||||
class ScrollingTablesHaveTheirOwnName(unittest.TestCase):
|
||||
"""Every scrolling table was the region "Data table, scrollable table"
|
||||
(axe landmark-unique on which-pack, retroarch, gaps)."""
|
||||
|
||||
def test_no_bare_fallback_label(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertNotIn("'Data table'", source)
|
||||
self.assertIn("`${tableName(table)}, scrollable table`", source)
|
||||
self.assertIn("`${heading}, table ${count}`", source)
|
||||
|
||||
|
||||
class CommandBlocksHaveNoWindowDots(unittest.TestCase):
|
||||
def test_the_dots_are_off_in_the_theme(self):
|
||||
self.assertIn("terminalTitlebarDotsOpacity: '0'", _read(CONFIG))
|
||||
css = DIST / "_astro"
|
||||
if not css.is_dir():
|
||||
self.skipTest("no built site")
|
||||
values = {m for f in css.glob("ec.*.css") for m in re.findall(r"--ec-frm-trmTtbDotsOpa:([^;]+)", _read(f))}
|
||||
self.assertEqual(values, {"0"})
|
||||
|
||||
|
||||
class RedirectsComeFromTheGenerator(unittest.TestCase):
|
||||
def test_config_reads_the_file_and_builds_without_it(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("./src/generated/redirects.json", config)
|
||||
self.assertIn("if (error.code === 'ENOENT') return {};", config)
|
||||
self.assertIn("redirects: readRedirects(),", config)
|
||||
# Astro does not put the base before the new address.
|
||||
self.assertIn("BASE + to.replace(/^\\//, '')", config)
|
||||
|
||||
|
||||
class SearchAnswersAHashAndShowsNoFileDump(unittest.TestCase):
|
||||
"""Searching the scph5501 MD5 opened on Data model, results carried a
|
||||
second "#_top" title, and a system page showed its 120 file names."""
|
||||
|
||||
def test_runtime(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("Look up this hash", source)
|
||||
self.assertIn("files/?hash=${value}", source)
|
||||
self.assertIn("endsWith('#_top')", source)
|
||||
self.assertIn("UNSHOWN_FIELDS.has(tag.dataset.pagefindUiMeta ?? '')", source)
|
||||
# Pagefind's display rules beat the hidden attribute without this.
|
||||
self.assertIn("#starlight__search [hidden] { display: none !important; }", _read(COMPONENTS / "Header.astro"))
|
||||
|
||||
|
||||
class SectionTabsInTheHeader(unittest.TestCase):
|
||||
def test_registered_and_built(self):
|
||||
self.assertIn("Header: './src/components/Header.astro'", _read(CONFIG))
|
||||
html = _built("which-pack")
|
||||
nav = re.search(r'<nav class="rb-tabs[^"]*"[^>]*>(.*?)</nav>', html, re.S)
|
||||
self.assertIsNotNone(nav)
|
||||
labels = [re.sub(r"<[^>]+>", "", a).strip() for a in re.findall(r"<a\b[^>]*>.*?</a>", nav.group(1), re.S)]
|
||||
self.assertEqual(labels, ["Start here", "Your setup", "Explore", "Reference", "Contribute"])
|
||||
self.assertEqual(nav.group(1).count('aria-current="true"'), 1)
|
||||
self.assertRegex(nav.group(1), r'aria-current="true"[^>]*>\s*Start here')
|
||||
|
||||
|
||||
def _title_block(html: str) -> str:
|
||||
"""What PageTitle draws: from the main column to the h1."""
|
||||
end = html.index('id="_top"')
|
||||
return html[html.rfind("<main", 0, end):end]
|
||||
|
||||
|
||||
class LevelBadgeOnlyWhereItAddsSomething(unittest.TestCase):
|
||||
def test_badge_follows_the_crumb(self):
|
||||
# A tab root: the header tab already says it.
|
||||
self.assertNotIn('class="rb-level ', _title_block(_built("platforms")))
|
||||
# Under the "Start here" or "Your setup" crumb the badge repeated it.
|
||||
for page in ("which-pack", "platforms/batocera"):
|
||||
with self.subTest(page=page):
|
||||
block = _title_block(_built(page))
|
||||
self.assertIn('aria-label="Breadcrumb"', block)
|
||||
self.assertNotIn('class="rb-level ', block)
|
||||
|
||||
|
||||
class TitleLogo(unittest.TestCase):
|
||||
def test_front_matter_field(self):
|
||||
self.assertIn("logo: z.string().optional()", _read(WEBSITE / "src" / "content.config.ts"))
|
||||
title = _read(COMPONENTS / "PageTitle.astro")
|
||||
self.assertIn('<img class="rb-title-mark" src={logoSrc} alt="" width={MARK} height={MARK} />', title)
|
||||
|
||||
|
||||
class FooterSaysWhoAndWhen(unittest.TestCase):
|
||||
def test_release_and_maintainer(self):
|
||||
tag = json.loads(_read(REPO_ROOT / "release.json"))["tag"]
|
||||
html = _built("which-pack")
|
||||
footer = html[html.index('class="rb-footer'):]
|
||||
self.assertIn("https://github.com/Abdess/retrobios/issues/new", footer)
|
||||
self.assertIn(f"/releases/tag/{tag}", footer)
|
||||
match = re.fullmatch(r"v(\d{4})\.(\d{2})\.(\d{2})", tag)
|
||||
if match:
|
||||
self.assertIn(f'<time datetime="{match[1]}-{match[2]}-{match[3]}"', footer)
|
||||
self.assertRegex(re.sub(r"<[^>]+>", "", footer), r"Abdess on GitHub\. Report a problem\.")
|
||||
|
||||
|
||||
class FindAFileGivesAVerdict(unittest.TestCase):
|
||||
def test_lookup_renderer(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("'Held in the collection at '", source)
|
||||
self.assertIn("['Setup', 'Name as declared', 'Destination', 'Hash matched', 'Required']", source)
|
||||
self.assertIn("api/v1/platforms/${item.owner}.json", source)
|
||||
self.assertIn("api/v1/systems/${words.slice(0, size).join('-')}.json", source)
|
||||
style = _read(COMPONENTS / "MarkdownContent.astro")
|
||||
self.assertIn("max-width: 40rem;", style)
|
||||
self.assertIn("width: 100%;", style)
|
||||
|
||||
|
||||
class Picker(unittest.TestCase):
|
||||
def test_choices(self):
|
||||
source = _read(COMPONENTS / "FilePicker.astro")
|
||||
self.assertIn("box.checked = preset ? preset.includes(choice.id) : false;", source)
|
||||
self.assertIn(".sort((a, b) => a.title.localeCompare(b.title, 'en'))", source)
|
||||
self.assertIn('placeholder="Type a console"', source)
|
||||
self.assertIn("filter.focus()", source)
|
||||
self.assertIn("n.toLocaleString('en')", source)
|
||||
self.assertNotRegex(source, r"`\$\{files\.length\} files")
|
||||
self.assertIn("<figcaption>Terminal</figcaption>", source)
|
||||
self.assertIn("flex-direction: column;", source)
|
||||
# The size gap is the release against the current list, from the
|
||||
# release record reduced to the registry's release_pack setups.
|
||||
self.assertIn("import release from '../generated/release-advertised.json';", source)
|
||||
self.assertIn("It was built from the list of that day.", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Round 2 theme: the stylesheet fixes of package 1, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: a red focus ring
|
||||
and hover, Starlight's 35 px h2, tinted disclosure blocks, striped asides,
|
||||
the search result file dump, lifting shadows, a sidebar without hierarchy,
|
||||
two indigos, permalinks hidden on touch screens, OS tabs cut at 320 px, a
|
||||
column that moves on pages without a TOC.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CSS_PATH = ROOT / "website" / "src" / "styles" / "retrobios.css"
|
||||
|
||||
# Page backgrounds Starlight 0.42.6 draws under our tokens, read with
|
||||
# getComputedStyle on a built page (light: white, dark: gray-7 hsl(224 10% 10%)).
|
||||
PAGE_BG = {"light": "#ffffff", "dark": "#17181c"}
|
||||
# Text Starlight draws on a filled button (--sl-color-black in each theme).
|
||||
ON_FILL = {"light": "#ffffff", "dark": "#17181c"}
|
||||
|
||||
|
||||
def _strip_comments(css: str) -> str:
|
||||
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
|
||||
|
||||
def _blocks(css: str) -> list[tuple[str, str]]:
|
||||
"""Innermost (selector, body) pairs, media queries flattened."""
|
||||
return [(sel.strip(), body) for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)]
|
||||
|
||||
|
||||
def _theme_tokens(css: str, selector: str) -> dict[str, str]:
|
||||
for sel, body in _blocks(css):
|
||||
if sel == selector:
|
||||
return dict(re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", body))
|
||||
raise AssertionError(f"no block {selector}")
|
||||
|
||||
|
||||
def _rules(css: str, selector_part: str) -> list[str]:
|
||||
return [body for sel, body in _blocks(css) if selector_part in sel]
|
||||
|
||||
|
||||
def _luminance(hex_colour: str) -> float:
|
||||
value = hex_colour.lstrip("#")
|
||||
channels = [int(value[i : i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||
linear = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
|
||||
|
||||
def _ratio(a: str, b: str) -> float:
|
||||
la, lb = sorted((_luminance(a), _luminance(b)), reverse=True)
|
||||
return (la + 0.05) / (lb + 0.05)
|
||||
|
||||
|
||||
class ThemeStylesheet(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
dark = _theme_tokens(cls.css, ":root")
|
||||
light = {**dark, **_theme_tokens(cls.css, ":root[data-theme='light']")}
|
||||
cls.tokens = {"dark": dark, "light": light}
|
||||
|
||||
def resolve(self, theme: str, token: str) -> str:
|
||||
value = self.tokens[theme][token].strip()
|
||||
while value.startswith("var("):
|
||||
value = self.tokens[theme][value[4:-1].strip()].strip()
|
||||
return value
|
||||
|
||||
# 1.1
|
||||
def test_focus_ring_is_the_link_colour_not_red(self):
|
||||
focus = _rules(self.css, ":focus-visible")
|
||||
self.assertTrue(focus)
|
||||
for body in focus:
|
||||
self.assertIn("2px solid var(--rb-link)", body)
|
||||
self.assertNotIn("accent", body.replace("--rb-link", ""))
|
||||
ring = next(b for s, b in _blocks(self.css) if s.startswith(":where(a, button"))
|
||||
self.assertIn("outline-offset: 2px", ring)
|
||||
self.assertNotIn("#b42318", self.css)
|
||||
self.assertNotIn("--rb-accent", self.css)
|
||||
|
||||
def test_button_hover_stays_in_its_own_colour(self):
|
||||
self.assertNotRegex(self.css, r"\.rb-button:is\(:hover,\s*:focus\)")
|
||||
primary = _rules(self.css, ".rb-button-primary:hover")
|
||||
self.assertTrue(primary)
|
||||
self.assertIn("var(--rb-link-strong)", primary[0])
|
||||
self.assertEqual(self.resolve("light", "--rb-link-strong"), "#1d2f73")
|
||||
self.assertEqual(self.resolve("dark", "--rb-link-strong"), "#c9d2ff")
|
||||
outline = [b for s, b in _blocks(self.css) if s.endswith(".rb-button:hover")]
|
||||
self.assertIn("var(--sl-color-accent-low)", outline[0])
|
||||
|
||||
# 1.3
|
||||
def test_heading_scale(self):
|
||||
root = self.tokens["dark"]
|
||||
self.assertEqual(root["--sl-text-h1"], "2.25rem")
|
||||
self.assertEqual(root["--sl-text-h2"], "1.5rem")
|
||||
self.assertEqual(root["--sl-text-h3"], "1.2rem")
|
||||
self.assertEqual(root["--sl-text-h4"], "1rem")
|
||||
self.assertRegex(self.css, r"@media \(max-width: 50rem\) \{\s*:root \{ --sl-text-h1: 1\.875rem; \}")
|
||||
self.assertRegex(self.css, r"level-h2, h2\) \{ margin-top: 2\.75rem; \}")
|
||||
self.assertRegex(self.css, r"level-h3, h3:not\(\.rb-file-title\)\) \{ margin-top: 1\.75rem; \}")
|
||||
|
||||
# 1.2
|
||||
def test_asides_are_neutral_boxes(self):
|
||||
neutral = _rules(self.css, ".starlight-aside:is(.starlight-aside--note, .starlight-aside--tip)")
|
||||
joined = "".join(neutral)
|
||||
self.assertIn("--sl-color-asides-border: var(--rb-border)", joined)
|
||||
self.assertIn("background: var(--rb-surface)", joined)
|
||||
self.assertIn("display: none", joined)
|
||||
box = _rules(self.css, ".sl-markdown-content .starlight-aside")[0]
|
||||
self.assertIn("border: 1px solid", box)
|
||||
|
||||
# 1.4
|
||||
def test_disclosure_blocks_are_neutral(self):
|
||||
self.assertNotIn("rb-details-tip", self.css)
|
||||
self.assertNotIn("--sl-color-blue", self.css)
|
||||
base = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content details.rb-details")
|
||||
self.assertIn("background: transparent", base)
|
||||
self.assertIn("var(--rb-border)", base)
|
||||
self.assertIn("0.95rem", _rules(self.css, "details.rb-details > summary")[0])
|
||||
|
||||
# 1.5
|
||||
def test_search_hides_the_file_list(self):
|
||||
self.assertIn("display: none", _rules(self.css, "site-search .pagefind-ui__result-tags")[0])
|
||||
|
||||
# 1.6
|
||||
def test_no_lifting_shadow_on_pagination_or_menu(self):
|
||||
self.assertIn("box-shadow: none", _rules(self.css, ".pagination-links a")[0])
|
||||
self.assertIn("font-size: 1.05rem", _rules(self.css, ".pagination-links .link-title")[0])
|
||||
menu = next(b for s, b in _blocks(self.css) if s == ".sl-menu-button")
|
||||
self.assertIn("box-shadow: none", menu)
|
||||
self.assertIn("background: transparent", menu)
|
||||
self.assertIn("1px solid var(--rb-border)", menu)
|
||||
|
||||
# 1.7
|
||||
def test_sidebar_hierarchy(self):
|
||||
label = _rules(self.css, ".top-level summary .large")[0]
|
||||
self.assertIn("var(--sl-text-xs)", label)
|
||||
self.assertIn("var(--rb-text-secondary)", label)
|
||||
current = _rules(self.css, "a[aria-current='page']")[0]
|
||||
self.assertIn("background: var(--sl-color-accent-low)", current)
|
||||
self.assertIn("color: var(--sl-color-accent-high)", current)
|
||||
self.assertNotIn("border", current)
|
||||
|
||||
# 1.8
|
||||
def test_one_indigo(self):
|
||||
for theme in ("light", "dark"):
|
||||
with self.subTest(theme=theme):
|
||||
self.assertEqual(self.resolve(theme, "--sl-color-accent"), self.resolve(theme, "--rb-link"))
|
||||
self.assertEqual(self.resolve(theme, "--sl-color-text-accent"), self.resolve(theme, "--rb-link"))
|
||||
|
||||
# 1.9
|
||||
def test_permalinks_visible_without_hover(self):
|
||||
self.assertRegex(self.css, r"@media \(hover: none\) \{\s*\.rb-permalink \{ opacity: 1; \}")
|
||||
permalink = next(b for s, b in _blocks(self.css) if s == ".rb-permalink")
|
||||
self.assertIn("min-height: 24px", permalink)
|
||||
|
||||
# 1.10, then round 4 item 1.1: the row wraps, every tab on screen
|
||||
def test_os_tabs_stay_reachable(self):
|
||||
tablist = _rules(self.css, "starlight-tabs [role='tablist']")[0]
|
||||
self.assertIn("flex-wrap: wrap", tablist)
|
||||
self.assertIn("overflow-x: visible", tablist)
|
||||
self.assertIn("white-space: nowrap", _rules(self.css, "starlight-tabs [role='tab']")[0])
|
||||
|
||||
# 1.11
|
||||
def test_page_without_toc_keeps_the_column(self):
|
||||
self.assertIn("--__toc-width: initial", _rules(self.css, ":root[data-has-sidebar]:not([data-has-toc])")[0])
|
||||
# Round 5: one pane rule for every page with a sidebar, "On this page"
|
||||
# or not, placed from the grid width.
|
||||
pane = _rules(self.css, ":root[data-has-sidebar] .main-pane")[0]
|
||||
self.assertIn("--sl-content-margin-inline: max(0rem, 100% - var(--rb-grid-width)) auto", pane)
|
||||
|
||||
# 1.12, 1.14
|
||||
def test_card_pages(self):
|
||||
cards = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content :is(.rb-sys-file, .rb-file-entry)")
|
||||
self.assertIn("content-visibility: auto", cards)
|
||||
self.assertIn("contain-intrinsic-size: auto 320px", cards)
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)", _rules(self.css, "body:has(.rb-sys-file, .rb-file-entry)")[0])
|
||||
self.assertEqual(_theme_tokens(self.css, ":root")["--rb-grid-width"], "54rem")
|
||||
facts = _rules(self.css, "dl.rb-facts")[0]
|
||||
self.assertIn("grid-template-columns: 11rem minmax(0, 1fr)", facts)
|
||||
self.assertRegex(self.css, r"@media \(max-width: 30rem\) \{\s*\.sl-markdown-content dl\.rb-facts \{ grid-template-columns: minmax\(0, 1fr\)")
|
||||
|
||||
# 1.13
|
||||
def test_long_values_wrap_between_words(self):
|
||||
table = _rules(self.css, ".rb-meta-card table")[0]
|
||||
self.assertIn("table-layout: fixed", table)
|
||||
self.assertIn("display: table", table)
|
||||
self.assertNotRegex(self.css, r"\.rb-meta-card td \{ overflow-wrap: anywhere")
|
||||
self.assertIn("overflow-wrap: anywhere", _rules(self.css, ".rb-meta-card td code")[0])
|
||||
self.assertIn("width: 40%", "".join(_rules(self.css, ".rb-meta-card td:first-child")))
|
||||
upstream = _rules(self.css, "#by-upstream-emulator")[0]
|
||||
self.assertIn("max-width: 22rem", upstream)
|
||||
|
||||
# 1.15
|
||||
def test_two_column_tables_stack_label_over_value(self):
|
||||
self.assertIn("content: none", _rules(self.css, "table[data-rb-stack='kv'] td::before")[0])
|
||||
self.assertTrue(_rules(self.css, "table[data-rb-stack='kv'] td:first-child"))
|
||||
|
||||
def test_contrast_meets_wcag_aa_in_both_themes(self):
|
||||
for theme in ("light", "dark"):
|
||||
bg = PAGE_BG[theme]
|
||||
pairs = [
|
||||
("link", self.resolve(theme, "--rb-link"), bg, 4.5),
|
||||
("focus ring", self.resolve(theme, "--rb-link"), bg, 3.0),
|
||||
("primary button", ON_FILL[theme], self.resolve(theme, "--rb-link"), 4.5),
|
||||
("primary hover", ON_FILL[theme], self.resolve(theme, "--rb-link-strong"), 4.5),
|
||||
("outline hover", self.resolve(theme, "--rb-link"), self.resolve(theme, "--sl-color-accent-low"), 4.5),
|
||||
("sidebar current", self.resolve(theme, "--sl-color-accent-high"), self.resolve(theme, "--sl-color-accent-low"), 4.5),
|
||||
("secondary text", self.resolve(theme, "--rb-text-secondary"), bg, 4.5),
|
||||
("link on surface", self.resolve(theme, "--rb-link"), self.resolve(theme, "--rb-surface"), 4.5),
|
||||
]
|
||||
for label, fg, back, need in pairs:
|
||||
with self.subTest(theme=theme, pair=label):
|
||||
self.assertGreaterEqual(_ratio(fg, back), need, f"{fg} on {back}")
|
||||
|
||||
|
||||
|
||||
|
||||
class DeviceTilesAndHeaderLogo(unittest.TestCase):
|
||||
"""The device tiles had no style once merged: every line underlined and one
|
||||
tall block per setup on a phone. The header logo was sized from the whole
|
||||
header, tab row included, and grew to 84 px."""
|
||||
|
||||
def test_a_tile_is_one_compact_link(self):
|
||||
css = CSS_PATH.read_text(encoding="utf-8")
|
||||
self.assertRegex(css, r"a\.rb-tile \{[^}]*grid-template-columns: 40px 1fr")
|
||||
self.assertRegex(css, r"a\.rb-tile \{[^}]*text-decoration: none")
|
||||
|
||||
def test_the_header_logo_follows_the_top_bar(self):
|
||||
css = CSS_PATH.read_text(encoding="utf-8")
|
||||
self.assertIn("header .site-title img { height: calc(4rem - 2 * var(--sl-nav-pad-y))", css)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Emulator pages say what is needed first, in names.
|
||||
|
||||
DuckStation's page opened on "It reads 105 files, 3 of them required" and
|
||||
read as "get all three", its three BIOS were cards 12 to 14 under one
|
||||
heading, its header said "Official ports" and "duckstation, Duckstation",
|
||||
and every system was an id ("sony-playstation").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import siterender # noqa: E402
|
||||
|
||||
EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
with open(REPO_ROOT / "emulators" / f"{name}.yml", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle)
|
||||
|
||||
|
||||
def _page(name: str, profile: dict) -> str:
|
||||
return gs.generate_emulator_page(name, profile, EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _files(count: int, **fields: object) -> list[dict]:
|
||||
return [{"name": f"f{i:03d}.bin", **fields} for i in range(count)]
|
||||
|
||||
|
||||
class TheIntroSaysWhatIsNeeded(unittest.TestCase):
|
||||
def test_one_bios_per_region(self):
|
||||
profile = _profile("duckstation")
|
||||
intro = gs._emulator_intro("DuckStation", profile)
|
||||
self.assertIn(
|
||||
"It picks the BIOS for your game's region: scph5500.bin (Japan), "
|
||||
"scph5501.bin (North America and Latin America) or scph5502.bin "
|
||||
"(Europe and Oceania).", intro)
|
||||
others = len(profile["files"]) - 3
|
||||
self.assertIn(f"The {others} other files are optional.", intro)
|
||||
self.assertNotIn("3 of them required", intro)
|
||||
self.assertIn("DuckStation emulates Sony PlayStation.", intro)
|
||||
|
||||
def test_required_files_without_regions_keep_the_count(self):
|
||||
intro = gs._emulator_intro("X", {"files": [
|
||||
{"name": "a.bin", "required": True}, {"name": "b.bin"}]})
|
||||
self.assertIn("It reads 2 files, 1 of them required", intro)
|
||||
|
||||
def test_several_machines_say_who_needs_a_required_file(self):
|
||||
intro = gs._emulator_intro("X", {
|
||||
"systems": ["sony-playstation", "sega-saturn"],
|
||||
"files": [{"name": "a.bin", "required": True}]})
|
||||
self.assertIn("A required file is needed only by the machines that use it.", intro)
|
||||
one = gs._emulator_intro("X", {
|
||||
"systems": ["sony-playstation"], "files": [{"name": "a.bin", "required": True}]})
|
||||
self.assertNotIn("machines that use it", one)
|
||||
|
||||
|
||||
class TheHeaderReadsAsWords(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = _page("duckstation", _profile("duckstation"))
|
||||
|
||||
def test_singular_classification(self):
|
||||
self.assertIn("| Classification | Official port |", self.page)
|
||||
|
||||
def test_cores_once_per_spelling(self):
|
||||
# duckstation-sa is ROCKNIX's name for the standalone build.
|
||||
self.assertIn("| Cores | duckstation, duckstation-sa |", self.page)
|
||||
|
||||
def test_systems_by_name(self):
|
||||
row = next(line for line in self.page.splitlines() if line.startswith("| Systems |"))
|
||||
self.assertIn("Sony PlayStation", row)
|
||||
self.assertNotIn("[sony-playstation]", row)
|
||||
|
||||
def test_region_badge_is_labelled(self):
|
||||
self.assertIn(">Region: Japan</span>", self.page)
|
||||
self.assertNotIn('rb-badge-muted">japan</span>', self.page)
|
||||
|
||||
def test_profile_version_names_the_pinned_build(self):
|
||||
# DuckStation tags builds v0.1-<commits since tag 0.1>, and the
|
||||
# pin sits past the last such tag: git describe's form names both.
|
||||
self.assertRegex(str(_profile("duckstation")["core_version"]),
|
||||
r"^v0\.1-\d+(-\d+-g[0-9a-f]{7})?$")
|
||||
|
||||
def test_the_second_spelling_is_a_platform_name(self):
|
||||
# The lookup is case-sensitive and EmuELEC launches "Duckstation":
|
||||
# the page hides the duplicate, the profile keeps it.
|
||||
with open(REPO_ROOT / "platforms" / "emuelec.yml", encoding="utf-8") as handle:
|
||||
emuelec = yaml.safe_load(handle)
|
||||
self.assertIn("Duckstation", emuelec["cores"])
|
||||
self.assertIn("Duckstation", _profile("duckstation")["cores"])
|
||||
|
||||
|
||||
class RequiredFilesComeFirst(unittest.TestCase):
|
||||
def test_sections_in_need_order(self):
|
||||
files = [
|
||||
{"name": "data.pak", "category": "game_data", "required": True},
|
||||
{"name": "opt.bin"},
|
||||
{"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16},
|
||||
]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
heads = [line for line in page.splitlines() if line.startswith("## ")]
|
||||
self.assertEqual(heads[:4], ["## Files", "## Required", "## Optional", "## Game data"])
|
||||
self.assertLess(page.index("## Required"), page.index("req.bin"))
|
||||
self.assertLess(page.index("req.bin"), page.index("## Optional"))
|
||||
self.assertLess(page.index("## Game data"), page.index("data.pak"))
|
||||
|
||||
def test_the_table_of_contents_turns_on(self):
|
||||
page = _page("duckstation", _profile("duckstation"))
|
||||
self.assertGreater(len(re.findall(r"^## ", page, re.M)), 2)
|
||||
|
||||
def test_rows_carry_region_and_hash(self):
|
||||
files = _files(gs.EMULATOR_TABLE_MIN - 1) + [
|
||||
{"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16}]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
required = page[page.index("## Required"):page.index("## Optional")]
|
||||
self.assertIn('id="file-req-bin', required)
|
||||
self.assertIn("Region: Japan", required)
|
||||
self.assertIn(f'MD5\u00a0<code title="{"ab" * 16}">{"ab" * 6}</code>', required)
|
||||
self.assertNotIn("f000.bin", required)
|
||||
|
||||
def test_no_table_for_a_few_cards(self):
|
||||
page = _page("x", {"type": "standalone", "files": _files(2)})
|
||||
self.assertNotIn('class="rb-entries"', page)
|
||||
self.assertIn('class="rb-file-entry"', page)
|
||||
|
||||
def test_a_split_page_puts_required_on_the_first_part(self):
|
||||
count = gs.EMULATOR_CARDS_PER_PAGE * 2
|
||||
files = _files(count) + [{"name": "last.bin", "required": True}]
|
||||
parts = gs.emulator_parts("big", files)
|
||||
self.assertIn(count, parts[0][1])
|
||||
pages = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
|
||||
"files": files}, {"files": {}})
|
||||
main = pages["big"]
|
||||
self.assertLess(main.index("## Required"), main.index("## Optional"))
|
||||
self.assertLess(main.index("last.bin"), main.index("f000.bin"))
|
||||
self.assertIn("## Required", pages["big-files-1"])
|
||||
|
||||
|
||||
class TheSummaryLineIsProse(unittest.TestCase):
|
||||
def test_no_pipes_and_one_number_format(self):
|
||||
files = _files(1563, category="bios_zip") + [
|
||||
{"name": "a.pak", "category": "game_data"}, {"name": "b.pak", "category": "game_data"}]
|
||||
for f in files[:164]:
|
||||
f["required"] = True
|
||||
text = "\n".join(gs._emulator_file_summary(files))
|
||||
self.assertNotIn(" | ", text)
|
||||
self.assertNotIn("1399", text)
|
||||
self.assertIn("1,401 optional", text)
|
||||
self.assertIn("By kind: 1,563 BIOS ZIPs and 2 game data.", text)
|
||||
|
||||
def test_category_badge_in_words(self):
|
||||
badges = " ".join(gs._file_badges({"name": "a", "category": "game_data"}, True))
|
||||
self.assertIn(">game data</span>", badges)
|
||||
|
||||
|
||||
class TheIndexNamesEmulatorsAndSystems(unittest.TestCase):
|
||||
def test_columns_and_counts(self):
|
||||
# Pages another test split off would send the console elsewhere.
|
||||
siterender.reset_console_pages()
|
||||
self.addCleanup(siterender.reset_console_pages)
|
||||
saved = dict(siterender._system_page_map)
|
||||
self.addCleanup(siterender.set_system_page_map, saved)
|
||||
siterender.set_system_page_map({"sony-playstation": ("sony", "PlayStation")})
|
||||
page = gs.generate_emulators_index({
|
||||
"duckstation": {"emulator": "DuckStation", "core_classification": "official_port",
|
||||
"systems": ["sony-playstation"], "files": _files(3)},
|
||||
})
|
||||
self.assertIn("| Emulator | Kind | Systems | Files |", page)
|
||||
self.assertIn("\n## D\n", page)
|
||||
self.assertNotIn("| Engine |", page)
|
||||
self.assertNotIn(" -- ", page)
|
||||
row = next(line for line in page.splitlines() if line.startswith("| [DuckStation]"))
|
||||
self.assertIn(f"[{siterender.system_name('sony-playstation')}]", row)
|
||||
self.assertIn("(../systems/sony.md#playstation)", row)
|
||||
self.assertNotIn("sony-playstation |", row)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,330 +0,0 @@
|
||||
"""Entry pages and guides.
|
||||
|
||||
The home page said the command "writes only in your BIOS folder" while
|
||||
install.py writes the entries a manifest files under `root: save` into the
|
||||
frontend's save folder. It ended on a tinted "Next" box, a raw ISO stamp and
|
||||
a setup count the diagrams gave one lower. Get your files offered the same
|
||||
choice three times, a Batocera page sent a beginner to "a terminal on the
|
||||
device" with no way to open one, and command blocks had no Windows form or
|
||||
no title.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import generate_readme # noqa: E402
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import siteguides # noqa: E402
|
||||
import sitemarkup # noqa: E402
|
||||
from common import load_platform_config, load_platform_registry # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
|
||||
GUIDES_YAML = (REPO_ROOT / "platforms" / "_guides.yml").read_text(encoding="utf-8")
|
||||
UNTITLED_FENCE = re.compile(r"^\s*```(?:sh|powershell)\s*$", re.M)
|
||||
# A path a reader types: a network share, a home or root path, a drive letter.
|
||||
ABSOLUTE_PATH = re.compile(
|
||||
r"(?<![\w/@:}>])(?:\\\\[^\s,)]+|smb://[^\s,)]+|~/[^\s,)]*|/[A-Za-z][^\s,)]*|[A-Z]:\\[^\s,)]*)"
|
||||
)
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
sitepages.require()
|
||||
return sitepages.page(name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _body(text: str) -> str:
|
||||
"""A page without its front matter."""
|
||||
return text.split("\n---\n", 1)[1] if text.startswith("---\n") else text
|
||||
|
||||
|
||||
def _guide_part(text: str) -> str:
|
||||
match = re.search(r"^## Reference\b", text, re.M)
|
||||
return text[: match.start()] if match else text
|
||||
|
||||
|
||||
class TrustItemsSayWhereItWrites(unittest.TestCase):
|
||||
def test_no_only_in_the_bios_folder_while_a_manifest_writes_saves(self):
|
||||
saves = [
|
||||
path.name for path in sorted((REPO_ROOT / "install").glob("*.json"))
|
||||
if any(entry.get("root") == "save"
|
||||
for entry in json.loads(path.read_text(encoding="utf-8")).get("files", []))
|
||||
]
|
||||
self.assertTrue(saves, "no manifest files an entry under root: save")
|
||||
text = " ".join(siteguides.TRUST_ITEMS).lower()
|
||||
self.assertNotIn("only in your bios folder", text)
|
||||
self.assertIn("save folder", text)
|
||||
self.assertIn("--restore", text)
|
||||
|
||||
def test_home_shows_the_same_items(self):
|
||||
home = _page("index.md")
|
||||
for item in siteguides.TRUST_ITEMS:
|
||||
self.assertIn(f"- {item}", home)
|
||||
self.assertNotIn("only in your BIOS folder", home)
|
||||
|
||||
|
||||
class NextStepsIsAPlainList(unittest.TestCase):
|
||||
def test_the_helpers(self):
|
||||
block = "\n".join(generate_site._next_block(["[A](a.md)", "[B](b.md)"]))
|
||||
self.assertEqual(block, "## Next steps\n\n- [A](a.md)\n- [B](b.md)\n")
|
||||
self.assertIn("## Next steps", generate_site.reference_next())
|
||||
self.assertNotIn(":::", generate_site.reference_next())
|
||||
|
||||
def test_entry_pages(self):
|
||||
for name in ("which-pack.md", "contributing.md", "platforms/batocera.md",
|
||||
"platforms/retroarch.md"):
|
||||
with self.subTest(page=name):
|
||||
text = _page(name)
|
||||
self.assertNotIn(":::tip[Next]", text)
|
||||
self.assertIn("## Next steps", text)
|
||||
|
||||
def test_home_has_no_next_box_and_no_stamp(self):
|
||||
home = _page("index.md")
|
||||
self.assertNotIn("Next steps", home)
|
||||
self.assertNotIn(":::tip", home)
|
||||
self.assertNotIn("Generated on", home)
|
||||
self.assertNotRegex(home, r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
|
||||
|
||||
|
||||
class DatesInWords(unittest.TestCase):
|
||||
def test_data_as_of(self):
|
||||
moment = datetime(2026, 10, 22, 22, 43, 8, tzinfo=timezone.utc)
|
||||
self.assertEqual(
|
||||
generate_site.data_as_of(moment),
|
||||
'<time datetime="2026-10-22T22:43:08Z">Data as of 22 October 2026</time>',
|
||||
)
|
||||
|
||||
def test_get_your_files_carries_no_date(self):
|
||||
self.assertNotIn("Generated on", _page("which-pack.md"))
|
||||
|
||||
|
||||
class OneSetupCount(unittest.TestCase):
|
||||
def test_every_entry_page_states_the_registry_count(self):
|
||||
count = len(REGISTRY)
|
||||
self.assertEqual(siteguides.setup_count(), count)
|
||||
self.assertIn(f"and {count - 3} more.", _page("index.md"))
|
||||
self.assertIn(f'<span class="rb-stat-value">{count}</span>', _page("explore.md"))
|
||||
|
||||
|
||||
class SetupTiles(unittest.TestCase):
|
||||
def test_one_tile_per_setup_on_home_and_get_your_files(self):
|
||||
for name in ("index.md", "which-pack.md"):
|
||||
text = _page(name)
|
||||
with self.subTest(page=name):
|
||||
extra = sum(len(tiles) for tiles in siteguides.GROUP_TILES.values())
|
||||
self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY) + extra)
|
||||
for key in REGISTRY:
|
||||
self.assertIn(f'href="/retrobios/platforms/{key}/"', text)
|
||||
|
||||
def test_get_your_files_offers_the_choice_once(self):
|
||||
text = _page("which-pack.md")
|
||||
self.assertEqual(text.count('class="rb-card"'), 0)
|
||||
self.assertEqual(text.count("<FilePicker"), 1)
|
||||
|
||||
def test_a_setup_without_a_logo_shows_its_initials(self):
|
||||
self.assertEqual(sitemarkup.initials("MyMinUI"), "MM")
|
||||
tile = "".join(sitemarkup.platform_tiles([("MyMinUI", "x.md", "", "Handhelds")]))
|
||||
self.assertIn(">MM</text>", tile)
|
||||
tile = "".join(sitemarkup.platform_tiles([("RomM", "x.md", "logo.png", "A server")]))
|
||||
self.assertIn('<img class="rb-card-logo" src="logo.png"', tile)
|
||||
|
||||
def test_a_one_setup_group_is_not_titled_with_its_own_name(self):
|
||||
home = _page("index.md")
|
||||
display = {k: load_platform_config(k, str(REPO_ROOT / "platforms")).get("platform", k)
|
||||
for k in REGISTRY}
|
||||
for group, (label, _description) in siteguides.DEVICE_GROUPS.items():
|
||||
members = [k for k in REGISTRY if REGISTRY[k].get("device_group") == group]
|
||||
if len(members) == 1:
|
||||
with self.subTest(group=group):
|
||||
self.assertNotIn(f"**{label}**", home)
|
||||
self.assertIn(f">{display[members[0]]}</strong>", home)
|
||||
|
||||
def test_hardware_is_named_and_cited(self):
|
||||
self.assertIn("Raspberry Pi", siteguides.GUIDES["batocera"].summary)
|
||||
self.assertIn("Raspberry Pi", siteguides.DEVICE_GROUPS["gaming-os"][0])
|
||||
self.assertIn("Anbernic", siteguides.DEVICE_GROUPS["handheld"][0])
|
||||
lines = GUIDES_YAML.splitlines()
|
||||
for number, line in enumerate(lines):
|
||||
if re.match(r" (runs_on|terminal):", line):
|
||||
with self.subTest(line=line.strip()):
|
||||
self.assertTrue(lines[number - 1].lstrip().startswith("#"),
|
||||
"no source cited on the line above")
|
||||
|
||||
|
||||
class GetYourFilesOrder(unittest.TestCase):
|
||||
def test_choice_then_tables_then_picker_then_command(self):
|
||||
text = _page("which-pack.md")
|
||||
order = [text.index(mark) for mark in (
|
||||
"## Pick your setup", "| Setup | Runs on | BIOS folder | ZIP |", "<FilePicker",
|
||||
"## Not in the list? One command finds it", "It asks **",
|
||||
"<summary>Is it safe?</summary>",
|
||||
)]
|
||||
self.assertEqual(order, sorted(order))
|
||||
|
||||
def test_a_setup_after_the_release_says_no_zip_yet(self):
|
||||
text = _page("which-pack.md")
|
||||
self.assertNotIn("Not in v", text)
|
||||
record = release_record.load_advertised()
|
||||
pending = [k for k in REGISTRY if REGISTRY[k].get("release_pack")
|
||||
and not release_record.pack_for(k, record)]
|
||||
if record.get("tag") and pending:
|
||||
self.assertIn("No ZIP yet. [Use the command]", text)
|
||||
self.assertIn("Setups added after the last release have no ZIP until the next one.",
|
||||
text)
|
||||
else:
|
||||
self.assertNotIn("No ZIP yet.", text)
|
||||
if any(not entry.get("release_pack") for entry in REGISTRY.values()):
|
||||
self.assertIn("No ZIP. [Use the command]", text)
|
||||
|
||||
def test_zip_advice_answers_the_usual_mistakes(self):
|
||||
text = _page("which-pack.md")
|
||||
self.assertIn("Download every part", text)
|
||||
self.assertIn("not in a new folder", text)
|
||||
self.assertIn("`neogeo.zip`", text)
|
||||
|
||||
|
||||
class GuideRoutes(unittest.TestCase):
|
||||
def test_a_device_system_opens_on_the_computer_route(self):
|
||||
text = _page("platforms/batocera.md")
|
||||
self.assertLess(text.index("## From your computer"), text.index("## On the device"))
|
||||
self.assertIn("{ #install }", "\n".join(generate_site._guide_install(
|
||||
"batocera", siteguides.GUIDES["batocera"], "## On the device { #install }")))
|
||||
self.assertIn("Unzip them all into the network folder above", text)
|
||||
|
||||
def test_a_computer_app_keeps_the_command_first(self):
|
||||
text = _page("platforms/retroarch.md")
|
||||
self.assertLess(text.index("## Install"), text.index("## Download"))
|
||||
|
||||
def test_each_device_tab_says_how_to_open_its_terminal(self):
|
||||
shared = siteguides.TERMINALS["device"][1]
|
||||
for name, guide in siteguides.GUIDES.items():
|
||||
if "device" not in guide.run:
|
||||
continue
|
||||
with self.subTest(platform=name):
|
||||
self.assertTrue(guide.terminal, "no terminal text of its own")
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
self.assertIn(guide.terminal.split(".")[0], text)
|
||||
self.assertNotIn(shared, text)
|
||||
|
||||
def test_retroarch_names_the_menu_before_the_folders(self):
|
||||
text = _page("platforms/retroarch.md")
|
||||
self.assertLess(text.index("Settings > Directory > System/BIOS"),
|
||||
text.index("| Where | BIOS folder |"))
|
||||
|
||||
def test_emudeck_names_the_card_and_ends_the_job(self):
|
||||
text = _guide_part(_page("platforms/emudeck.md"))
|
||||
self.assertIn("installed on the SD card", text)
|
||||
self.assertIn("Return to Gaming Mode", text)
|
||||
self.assertNotIn("it is on the card", text)
|
||||
|
||||
|
||||
class PackManualReadsThePageFolders(unittest.TestCase):
|
||||
def test_every_path_in_a_pack_manual_is_a_folder_of_its_page(self):
|
||||
for name, guide in siteguides.GUIDES.items():
|
||||
if not guide.pack_manual:
|
||||
continue
|
||||
known = " ".join(path.replace("`", "") for _w, path in siteguides.folder_list(name))
|
||||
known += " " + str(REGISTRY[name].get("extract_to") or "")
|
||||
for path in ABSOLUTE_PATH.findall(siteguides.pack_manual(name)):
|
||||
with self.subTest(platform=name, path=path):
|
||||
self.assertIn(path.rstrip(".:"), known)
|
||||
|
||||
def test_the_retroarch_manual_lists_the_detected_systems(self):
|
||||
manual = siteguides.pack_manual("retroarch")
|
||||
self.assertNotIn("{folders}", manual)
|
||||
self.assertIn("/run/muos/storage/bios", manual)
|
||||
self.assertIn("~/.var/app/org.libretro.RetroArch/config/retroarch/system/", manual)
|
||||
|
||||
|
||||
class DownloadNamesTheRelease(unittest.TestCase):
|
||||
def test_tag_date_checksums_and_signature(self):
|
||||
record = release_record.load_record()
|
||||
if not release_record.pack_for("batocera", record):
|
||||
self.skipTest("release.json has no Batocera pack")
|
||||
tag = record["tag"]
|
||||
lines = generate_site.release_line("batocera", record)
|
||||
self.assertTrue(lines[0].startswith(f"Pack {tag}, built "))
|
||||
self.assertIn(f"/releases/download/{tag}/SHA256SUMS.txt)", lines[0])
|
||||
self.assertIn(f"/releases/download/{tag}/SHA256SUMS.txt.sig)", lines[0])
|
||||
self.assertEqual(generate_site._release_date("v2026.10.10"), "10 October 2026")
|
||||
|
||||
def test_a_newer_platform_than_the_pack_is_named(self):
|
||||
record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
|
||||
"assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
|
||||
version = load_platform_config("retrobat", str(REPO_ROOT / "platforms"))["version"]
|
||||
lines = generate_site.release_line("retrobat", record)
|
||||
self.assertIn(f"built for RetroBat 1.0. The command installs the files for "
|
||||
f"RetroBat {version}.", lines[-1])
|
||||
|
||||
|
||||
class CommandBlocks(unittest.TestCase):
|
||||
def test_every_command_on_an_entry_page_has_a_title(self):
|
||||
names = ["index.md", "which-pack.md", "contributing.md"] + [
|
||||
f"platforms/{key}.md" for key in REGISTRY]
|
||||
for name in names:
|
||||
with self.subTest(page=name):
|
||||
self.assertIsNone(UNTITLED_FENCE.search(_guide_part(_page(name))))
|
||||
|
||||
def test_shell_lines_have_a_powershell_form(self):
|
||||
text = _page("which-pack.md")
|
||||
groups = re.findall(r"<Tabs.*?</Tabs>|<details.*?</details>", text, re.S)
|
||||
commands = [group for group in groups if "/i | sh" in group]
|
||||
self.assertGreaterEqual(len(commands), 3)
|
||||
for group in commands:
|
||||
self.assertIn("/i.ps1", group)
|
||||
block = "\n".join(generate_site._console_install_block(
|
||||
"PlayStation", [("batocera", "psx"), ("recalbox", "psx")]))
|
||||
# Every setup's options in both forms: the list of every command,
|
||||
# and the one command under the OS tabs.
|
||||
sh_args = re.findall(r"/i \| sh -s -- ([^<\n]+)", block)
|
||||
ps_args = re.findall(r"/i\.ps1\)\)\) ([^<\n]+)", block)
|
||||
self.assertEqual(sorted(set(sh_args)), sorted(set(ps_args)))
|
||||
self.assertEqual(len(set(sh_args)), 2)
|
||||
self.assertIn('```powershell title="PowerShell"', block)
|
||||
for key, guide in siteguides.GUIDES.items():
|
||||
if "windows" in guide.run:
|
||||
with self.subTest(platform=key):
|
||||
self.assertIn("/i.ps1", _guide_part(_page(f"platforms/{key}.md")))
|
||||
|
||||
|
||||
class ContributingOpensOnTheAction(unittest.TestCase):
|
||||
def test_both_generators_open_on_the_upload(self):
|
||||
for text in (generate_readme.generate_contributing(), generate_site.generate_contributing()):
|
||||
body = text.split("## Add a BIOS file", 1)[1]
|
||||
self.assertTrue(body.lstrip().startswith("Have a file the collection lacks?"))
|
||||
self.assertLess(body.index(generate_readme.UPLOAD_URL), body.index("## What happens next"))
|
||||
self.assertLess(body.index("gaps"), body.index("## What happens next"))
|
||||
self.assertLess(body.index("## What happens next"), body.index("CI hashes"))
|
||||
|
||||
def test_description_is_a_sentence(self):
|
||||
text = generate_site._page_description("", Path("contributing.md"), "Contributing")
|
||||
self.assertTrue(text.endswith("."))
|
||||
fallback = generate_site._page_description(
|
||||
"One file needs no clone and no local setup at all here:\n\n"
|
||||
"A plain sentence that describes the page well enough.",
|
||||
Path("other.md"), "Other")
|
||||
self.assertEqual(fallback, "A plain sentence that describes the page well enough.")
|
||||
|
||||
|
||||
class HomeLinksTheOtherTasks(unittest.TestCase):
|
||||
def test_troubleshooting_file_check_and_legal(self):
|
||||
home = _body(_page("index.md"))
|
||||
for target in ("/retrobios/wiki/troubleshooting/", "/retrobios/files/",
|
||||
"/retrobios/wiki/legal/"):
|
||||
self.assertIn(f"]({target})", home)
|
||||
self.assertLess(home.index("/retrobios/wiki/troubleshooting/"), home.index("## Do I need it?"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,354 +0,0 @@
|
||||
"""Reference pages.
|
||||
|
||||
A reference page cites a hash, a source line or a count, and each citation
|
||||
has to hold when an expert checks it: the reader of a pinned dump is the
|
||||
profile entry accepting its hash, a source line is a pinned link, an entry
|
||||
names the file that serves it, and the exports carry what the page shows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import siterender # noqa: E402
|
||||
from common import ( # noqa: E402
|
||||
list_registered_platforms,
|
||||
load_emulator_profiles,
|
||||
load_platform_config,
|
||||
)
|
||||
from exporter.baseline import HASH_FIELDS, _hash_values # noqa: E402
|
||||
from exporter.candidates import EntryCategories # noqa: E402
|
||||
from sitesources import launcher_ref_markdown, list_source # noqa: E402
|
||||
|
||||
PLATFORMS = str(REPO_ROOT / "platforms")
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _accepted(profile_entry: dict) -> set[str]:
|
||||
values = {v for f in HASH_FIELDS for v in _hash_values(profile_entry, f)}
|
||||
for member in profile_entry.get("contents") or []:
|
||||
if isinstance(member, dict):
|
||||
values |= {v for f in HASH_FIELDS for v in _hash_values(member, f)}
|
||||
return values
|
||||
|
||||
|
||||
class ReaderMatchedByHash(unittest.TestCase):
|
||||
"""Batocera's scph1001.bin (md5 dc2b9bf8, SCPH-1001 v2.0) cited
|
||||
DuckStation's bios.cpp:36, the line of the v2.2 dump: the reader was
|
||||
matched by name."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
|
||||
|
||||
def test_batocera_scph1001_cites_the_v20_line(self):
|
||||
config = load_platform_config("batocera", PLATFORMS)
|
||||
cats = EntryCategories(config, self.profiles)
|
||||
entry = next(e for e in config["systems"]["sony-playstation"]["files"]
|
||||
if e["name"] == "scph1001.bin")
|
||||
found = cats.explain(entry, entry.get("destination") or entry["name"],
|
||||
"sony-playstation")
|
||||
duck = next(r for r in found["read_by"] if r["emulator"] == "duckstation")
|
||||
self.assertEqual(duck["entries"], [
|
||||
{"name": "scph1001_v20.bin", "source_ref": "src/core/bios.cpp:42"}])
|
||||
|
||||
def test_every_cited_entry_accepts_the_md5_or_says_by_name(self):
|
||||
by_name = 0
|
||||
for platform in list_registered_platforms(PLATFORMS, include_archived=True):
|
||||
config = load_platform_config(platform, PLATFORMS)
|
||||
cats = EntryCategories(config, self.profiles)
|
||||
for sys_id, system in (config.get("systems") or {}).items():
|
||||
for entry in system.get("files") or []:
|
||||
md5s = set(_hash_values(entry, "md5")) | set(_hash_values(entry, "alt_md5"))
|
||||
if not md5s:
|
||||
continue
|
||||
# The same dump, named by whichever hash both sides state.
|
||||
declared = md5s | {v for f in HASH_FIELDS for v in _hash_values(entry, f)}
|
||||
found = cats.explain(entry, entry.get("destination") or entry["name"], sys_id)
|
||||
for reader in found.get("read_by", []):
|
||||
if reader["emulator"] == "launcher":
|
||||
continue
|
||||
files = self.profiles[reader["emulator"]].get("files") or []
|
||||
for cited in reader["entries"]:
|
||||
if cited.get("by") == "name":
|
||||
by_name += 1
|
||||
continue
|
||||
accepted = set().union(*(
|
||||
_accepted(f) for f in files
|
||||
if isinstance(f, dict) and f.get("name") == cited["name"]))
|
||||
with self.subTest(platform=platform, entry=entry["name"],
|
||||
emulator=reader["emulator"]):
|
||||
self.assertTrue(any(
|
||||
a == m or (29 <= len(m) < 32 and a.startswith(m))
|
||||
for m in declared for a in accepted))
|
||||
self.assertGreater(by_name, 0)
|
||||
|
||||
def test_a_contradicting_homonym_is_not_cited(self):
|
||||
profiles = {"core": {"type": "libretro", "systems": ["s"], "files": [
|
||||
{"name": "bios.bin", "md5": "1" * 32, "source_ref": "a.c:1"},
|
||||
{"name": "bios_v2.bin", "md5": "2" * 32, "source_ref": "a.c:2"},
|
||||
]}}
|
||||
config = {"cores": ["core"], "systems": {"s": {"files": []}}}
|
||||
cats = EntryCategories(config, profiles)
|
||||
found = cats.explain({"name": "bios.bin", "md5": "2" * 32}, "bios.bin", "s")
|
||||
self.assertEqual(found["read_by"][0]["entries"],
|
||||
[{"name": "bios_v2.bin", "source_ref": "a.c:2"}])
|
||||
found = cats.explain({"name": "bios.bin", "md5": "3" * 32}, "bios.bin", "s")
|
||||
self.assertEqual(found["read_by"][0]["entries"], [])
|
||||
|
||||
|
||||
class EvidenceLinksItsSource(unittest.TestCase):
|
||||
"""Platform pages printed refs as code with the role word in front:
|
||||
`source frontend/libretro.c:3864-3867`."""
|
||||
|
||||
def test_pinned_link_and_by_name(self):
|
||||
evidence = {"read_by": [{
|
||||
"emulator": "pcsx_rearmed", "proof": "name", "runs_system": True,
|
||||
"entries": [{"name": "scph1001.bin", "by": "name",
|
||||
"source_ref": "source frontend/libretro.c:3864-3867 (listed_bios[6])"}],
|
||||
}]}
|
||||
text = generate_site._category_evidence_md(evidence)
|
||||
self.assertRegex(text, r"\[`frontend/libretro\.c:3864-3867`\]\(https://github\.com/"
|
||||
r"libretro/pcsx_rearmed/blob/[0-9a-f]{40}/frontend/libretro\.c"
|
||||
r"#L3864-L3867\)")
|
||||
self.assertIn("(by name)", text)
|
||||
self.assertNotIn("`source ", text)
|
||||
|
||||
def test_launcher_record_named_with_its_lines(self):
|
||||
evidence = {"read_by": [{"emulator": "launcher", "proof": "launcher",
|
||||
"entries": [], "runs_system": True}]}
|
||||
config = load_platform_config("batocera", PLATFORMS)
|
||||
launched = generate_site._launcher_refs("batocera", config)["scph1001.bin"]
|
||||
text = generate_site._category_evidence_md(evidence, launched)
|
||||
self.assertNotIn("(by launcher)", text)
|
||||
self.assertIn("https://github.com/batocera-linux/batocera.linux/blob/batocera-43.1/"
|
||||
"package/batocera/core/batocera-configgen/", text)
|
||||
self.assertIn("duckstationGenerator.py:115-136", text)
|
||||
|
||||
def test_a_launcher_in_another_repository_is_not_linked(self):
|
||||
source = list_source("https://gitlab.com/recalbox/recalbox/-/raw/10.1.1/x/es_bios.xml")
|
||||
self.assertEqual(launcher_ref_markdown(None, "a/b.py:3"), "`b.py:3`")
|
||||
self.assertIn("gitlab.com/recalbox/recalbox/-/blob/10.1.1/a/b.py#L3",
|
||||
launcher_ref_markdown(source, "a/b.py:3"))
|
||||
|
||||
|
||||
class EachEntryNamesItsFile(unittest.TestCase):
|
||||
def test_served_held_and_missing(self):
|
||||
path, digest = next(
|
||||
(p, d) for p, d in generate_site._collection_sha1_by_path().items()
|
||||
if p.startswith("bios/") and p.count("/") >= 3)
|
||||
served = generate_site._served_line({"status": "ok", "path": path})
|
||||
self.assertRegex(served, rf"\]\(\.\./systems/[^)]+\.md#sha1-{digest[:12]}\)")
|
||||
self.assertTrue(served.startswith("Served by: "))
|
||||
self.assertIn(f"`{digest[:8]}`", served)
|
||||
self.assertEqual(generate_site._served_line({"status": "missing"}), "Not held")
|
||||
self.assertTrue(generate_site._served_line(
|
||||
{"status": "untested", "path": path}).startswith("Held, not the declared hash"))
|
||||
# Existence mode reports no path: nothing is claimed, held or not.
|
||||
self.assertEqual(generate_site._served_line({"status": "ok"}), "")
|
||||
|
||||
def test_a_long_system_list_is_cut_between_rows_by_elements(self):
|
||||
rows = [[f'<details class="rb-file"><summary>f{n}.bin</summary>x<br>y</details>']
|
||||
for n in range(4)]
|
||||
# Three elements a row: two rows fit in a budget of seven.
|
||||
runs = generate_site._blocks_in_runs(rows, 7, generate_site._element_weight)
|
||||
self.assertEqual([len(run) for run in runs], [2, 2])
|
||||
self.assertEqual(sum(runs, []), sum(rows, []))
|
||||
|
||||
|
||||
class AlsoNamedIsNotCited(unittest.TestCase):
|
||||
def test_other_system_cores_are_named_only(self):
|
||||
evidence = {"read_by": [
|
||||
{"emulator": "duckstation", "proof": "name", "runs_system": True,
|
||||
"entries": [{"name": "scph1001_v20.bin", "source_ref": "src/core/bios.cpp:42"}]},
|
||||
{"emulator": "mame", "proof": "name", "runs_system": False,
|
||||
"entries": [{"name": "x.zip", "source_ref": "src/mame/x.cpp:1", "by": "name"}]},
|
||||
]}
|
||||
text = generate_site._category_evidence_md(evidence)
|
||||
also = text.split(", also named by ", 1)[1]
|
||||
self.assertNotIn("x.cpp", also)
|
||||
self.assertIn("mame", also)
|
||||
|
||||
|
||||
class PlatformReferenceInWords(unittest.TestCase):
|
||||
def _page(self, mode: str, hash_type: str, systems: dict | None = None,
|
||||
registry: dict | None = None) -> str:
|
||||
details = [{"name": "f.bin", "status": "ok", "system": s} for s in (systems or {})]
|
||||
cov = {"platform": "P", "details": details, "total": 1, "verified": 1,
|
||||
"present": 1, "missing": 0, "untested": 0, "mode": mode,
|
||||
"config": {"hash_type": hash_type, "systems": systems or {},
|
||||
"source": "https://github.com/o/r/tree/" + "a" * 40}}
|
||||
return generate_site.generate_platform_page("p", cov, registry or {})
|
||||
|
||||
def test_hash_type_only_when_it_differs(self):
|
||||
self.assertNotIn("Hash type", self._page("md5", "md5"))
|
||||
self.assertNotIn("Hashes in the list", self._page("md5", "md5"))
|
||||
page = self._page("existence", "sha1")
|
||||
self.assertIn("| Hashes in the list | SHA-1 |", page)
|
||||
self.assertIn(">name only</span>", page)
|
||||
self.assertIn(">MD5</span>", self._page("md5", "md5"))
|
||||
self.assertIn("[r repository at commit aaaaaaaaaaaa](https://github.com/o/r/tree/"
|
||||
+ "a" * 40 + ")", page)
|
||||
|
||||
def test_systems_by_name_with_the_id_in_small_text(self):
|
||||
systems = {"sony-playstation": {"files": [{"name": "f.bin"}]}}
|
||||
page = self._page("md5", "md5", systems)
|
||||
name = siterender.system_name("sony-playstation")
|
||||
self.assertIn(f"| [{name}](#sony-playstation) <small data-pagefind-ignore>sony-playstation</small> |", page)
|
||||
heading = next(line for line in page.splitlines() if line.startswith("### ")
|
||||
and "sony-playstation" in line)
|
||||
self.assertIn(name, heading)
|
||||
self.assertTrue(heading.endswith("<small>sony-playstation</small> { #sony-playstation }"))
|
||||
|
||||
def test_logo_in_front_matter_not_floated(self):
|
||||
page = self._page("md5", "md5", registry={"p": {"logo": "https://x.test/l.png"}})
|
||||
self.assertTrue(page.startswith('---\nlogo: "https://x.test/l.png"\n---\n'))
|
||||
self.assertNotIn('align="right"', page)
|
||||
|
||||
|
||||
class GapsPageNumbers(unittest.TestCase):
|
||||
def test_badge_only_for_missing_required_firmware(self):
|
||||
self.assertNotIn("rb-badge", generate_site._ratio_cell(99, 100))
|
||||
self.assertIn('class="rb-bar"', generate_site._ratio_cell(99, 100))
|
||||
self.assertIn("rb-badge-danger", generate_site._ratio_cell(99, 100, 1))
|
||||
cov = {"details": [
|
||||
{"name": "a", "system": "s", "status": "missing", "required": True, "category": "bios"},
|
||||
{"name": "b", "system": "s", "status": "missing", "required": True,
|
||||
"category": "game_data"},
|
||||
{"name": "c", "system": "s", "status": "missing", "required": False},
|
||||
]}
|
||||
self.assertEqual(generate_site._missing_required_firmware(cov), 1)
|
||||
|
||||
def test_headings_and_header(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
body = source[source.index("def generate_gap_analysis("):]
|
||||
body = body[:body.index("\ndef ", 10)]
|
||||
self.assertIn('"# What the packs do not hold"', body)
|
||||
self.assertIn("On its BIOS list (destinations)", body)
|
||||
self.assertIn('"### Files to source"', body)
|
||||
self.assertNotIn("### Files to source (", body)
|
||||
|
||||
|
||||
class UpstreamsGrouped(unittest.TestCase):
|
||||
def test_mame_trees_are_one_project(self):
|
||||
keys = {generate_site.upstream_project(u)[0] for u in (
|
||||
"https://github.com/mamedev/mame",
|
||||
"https://github.com/mamedev/mame/tree/mame0289",
|
||||
"https://github.com/mamedev/mame/tree/mame0135u4",
|
||||
)}
|
||||
self.assertEqual(keys, {"github.com/mamedev/mame"})
|
||||
self.assertEqual(generate_site.upstream_project(
|
||||
"https://github.com/mamedev/mame/tree/mame0289")[1], "mame0289")
|
||||
|
||||
def test_nested_groups_notes_and_archives(self):
|
||||
project = generate_site.upstream_project
|
||||
self.assertEqual(
|
||||
project("https://gitlab.com/recalbox/packages/recalbox-cores/libretro-exel-hence"),
|
||||
("gitlab.com/recalbox/packages/recalbox-cores/libretro-exel-hence", ""))
|
||||
self.assertEqual(project("https://gitlab.com/jgemu/geolith/-/tree/v1"),
|
||||
("gitlab.com/jgemu/geolith", "v1"))
|
||||
self.assertEqual(
|
||||
project("https://git.9front.org/plan9front/plan9front (sys/src/games/nes/)"),
|
||||
("git.9front.org/plan9front/plan9front", "sys/src/games/nes/"))
|
||||
key, _ = project("https://archive.softwareheritage.org/browse/revision/"
|
||||
"dc20a615275f66bee20a4fd851ef0231daca4f14/")
|
||||
self.assertEqual(generate_site._upstream_label(key),
|
||||
"Software Heritage revision dc20a615275f")
|
||||
|
||||
def test_overview_and_a_page_per_platform(self):
|
||||
profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
|
||||
coverages = {}
|
||||
for name in ("batocera", "retroarch"):
|
||||
config = load_platform_config(name, PLATFORMS)
|
||||
coverages[name] = {"platform": config.get("platform", name), "config": config}
|
||||
moved: dict[str, str] = {}
|
||||
page = generate_site.generate_cross_reference(coverages, profiles, moved)
|
||||
self.assertEqual(set(moved), {"cross-reference-batocera", "cross-reference-retroarch"})
|
||||
self.assertNotIn("<details", page)
|
||||
upstream = page[page.index("## By upstream emulator"):]
|
||||
self.assertEqual(len(re.findall(r"\| \[mamedev/<wbr>mame\]\(", upstream)), 1)
|
||||
header = next(line for line in upstream.splitlines() if line.startswith("| Cores"))
|
||||
self.assertTrue(header.startswith("| Cores | Upstream |"))
|
||||
|
||||
|
||||
class ExportsComplete(unittest.TestCase):
|
||||
def test_gap_rows_once_with_their_outcome(self):
|
||||
path = next(iter(generate_site._collection_sha1_by_path()))
|
||||
detail = {"name": "x.bin", "system": "s", "status": "untested", "path": path,
|
||||
"category": "bios"}
|
||||
coverages = {"p": {"platform": "P", "details": [
|
||||
{"name": "h.xml", "system": "s", "status": "missing"},
|
||||
{"name": "h.xml", "system": "s", "status": "missing"}, detail]}}
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
rows = generate_site._gap_export_rows(coverages, {}, db)
|
||||
self.assertEqual([r["name"] for r in rows], ["h.xml", "x.bin"])
|
||||
held = rows[1]
|
||||
self.assertEqual(held["collection_path"], path)
|
||||
self.assertEqual(held["resolved_sha1"], generate_site._collection_sha1_by_path()[path])
|
||||
self.assertEqual(held["category"], "bios")
|
||||
|
||||
def test_platform_rows_carry_status_and_file(self):
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
sha1, entry = next(iter(db["files"].items()))
|
||||
coverages = {"p": {"platform": "P", "config": {"systems": {"s": {"files": [
|
||||
{"name": "a.bin"}, {"name": "b.bin"}]}}}, "details": [
|
||||
{"name": "a.bin", "system": "s", "status": "ok", "path": entry["path"],
|
||||
"category": "bios"},
|
||||
{"name": "b.bin", "system": "s", "status": "missing", "category": "game_data"}]}}
|
||||
_items, rows = generate_site._platform_export_rows(coverages, db)
|
||||
self.assertEqual((rows[0]["status"], rows[0]["resolved_sha1"]), ("ok", sha1))
|
||||
self.assertEqual(rows[0]["collection_path"], entry["path"])
|
||||
self.assertEqual((rows[1]["status"], rows[1]["category"], rows[1]["collection_path"]),
|
||||
("missing", "game_data", ""))
|
||||
|
||||
def test_commit_and_wanted_list(self):
|
||||
commit = generate_site._source_commit()
|
||||
self.assertRegex(commit, r"^[0-9a-f]{40}$")
|
||||
schema = json.loads((REPO_ROOT / "schemas" / "site-api-envelope.schema.json")
|
||||
.read_text(encoding="utf-8"))
|
||||
self.assertIn("commit", schema["properties"])
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
rows = generate_site._provenance_wanted_rows(db)
|
||||
self.assertTrue(rows)
|
||||
self.assertTrue(all(r["catalog"] and r["name"] for r in rows))
|
||||
text = generate_site._csv_document(list(generate_site.WANTED_FIELDS), rows)
|
||||
self.assertEqual(next(csv.reader(io.StringIO(text))), list(generate_site.WANTED_FIELDS))
|
||||
|
||||
|
||||
class DatesAndNames(unittest.TestCase):
|
||||
def test_snapshot_date_in_words(self):
|
||||
self.assertEqual(generate_site._date_in_words("2026-08-07T10:00:00Z"),
|
||||
'<time datetime="2026-08-07">7 August 2026</time>')
|
||||
|
||||
def test_data_page_title(self):
|
||||
self.assertIn("\n---\n\n# Data and API\n", generate_site.generate_data_page([]))
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "platforms" / "batocera" / "index.html").is_file(),
|
||||
"site not built")
|
||||
class BuiltBatoceraPage(unittest.TestCase):
|
||||
def test_served_by_and_pinned_links(self):
|
||||
main = (DIST / "platforms" / "batocera" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertTrue("batocera-systems at tag batocera-43.1" in main)
|
||||
# PlayStation's list is long enough to sit on a page of its own.
|
||||
pages = [main] + [p.read_text(encoding="utf-8") for p in
|
||||
(DIST / "platforms").glob("batocera-sony-playstation*/index.html")]
|
||||
html = "".join(pages)
|
||||
for text in ("Served by:", "scph1001_v20.bin", "https://github.com/stenzek/duckstation/blob/"):
|
||||
with self.subTest(text=text):
|
||||
self.assertTrue(text in html)
|
||||
self.assertFalse("(by launcher)" in html)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,327 +0,0 @@
|
||||
"""System pages and file cards.
|
||||
|
||||
The Sony page opened on LibPS.exe, its card titles read "scph5501.bin
|
||||
permalink" in the site search, the two setup lines of a card were defined
|
||||
only in a code comment, and the systems index named no console.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import siterender # noqa: E402
|
||||
from cross_reference import FileConsumers # noqa: E402
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _file(name: str, char: str, path: str = "") -> dict:
|
||||
return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 524288,
|
||||
"path": path or f"bios/Sony/PlayStation/{name}"}
|
||||
|
||||
|
||||
def _consumers(readers: dict[str, list[dict]], platforms: dict[str, set[str]] | None = None
|
||||
) -> types.SimpleNamespace:
|
||||
"""Consumers where each sha1 is read by the given profile entries, one
|
||||
emulator per entry."""
|
||||
files_of: dict[str, list] = {}
|
||||
entries: dict[str, dict[str, int]] = {}
|
||||
emulators: dict[str, set[str]] = {}
|
||||
for sha1, read_by in readers.items():
|
||||
for number, entry in enumerate(read_by):
|
||||
emulator = f"emu{number}-{sha1[:4]}"
|
||||
files_of[emulator] = [entry]
|
||||
entries.setdefault(sha1, {})[emulator] = 0
|
||||
emulators.setdefault(sha1, set()).add(emulator)
|
||||
return types.SimpleNamespace(
|
||||
platforms=platforms or {}, emulators=emulators, emulator_entries=entries,
|
||||
emulator_files=files_of, installed={}, platform_systems={},
|
||||
platform_destinations={},
|
||||
)
|
||||
|
||||
|
||||
class TheConsoleBiosComesFirst(unittest.TestCase):
|
||||
"""Cards were sorted by byte order: capitals first, so LibPS.exe and
|
||||
PSX_DTLH2000.bin opened the PlayStation section and scph5501.bin was
|
||||
28th."""
|
||||
|
||||
def setUp(self):
|
||||
self.files = [
|
||||
_file("LibPS.exe", "1"), _file("PSX_DTLH2000.bin", "2"),
|
||||
_file("dtlh1100.bin", "3"), _file("scph5501.bin", "4"),
|
||||
_file("scph5500.bin", "5"), _file("scph1001.bin", "6"),
|
||||
_file("openbios.bin", "7"),
|
||||
]
|
||||
self.consumers = _consumers({
|
||||
"4" * 40: [{"name": "scph5501.bin", "required": True, "priority": 2,
|
||||
"region": ["north-america"]}],
|
||||
"5" * 40: [{"name": "scph5500.bin", "required": True, "priority": 5,
|
||||
"region": ["japan"]}],
|
||||
"6" * 40: [{"name": "scph1001.bin", "required": False, "priority": 1}],
|
||||
"2" * 40: [{"name": "PSX_DTLH2000.bin", "required": False}],
|
||||
}, platforms={"6" * 40: {"a", "b"}, "3" * 40: {"a", "b", "c"}})
|
||||
|
||||
def names(self) -> list[str]:
|
||||
cards = gs._console_cards("Sony", self.files, self.consumers, set())
|
||||
return [re.search(r"<code>(.*?)</code>", card[2])[1] for _v, card, _s in cards]
|
||||
|
||||
def test_required_then_priority_then_setups_then_name(self):
|
||||
self.assertEqual(self.names(), [
|
||||
"scph5501.bin", "scph5500.bin", # required, by priority
|
||||
"scph1001.bin", # optional with a priority
|
||||
"dtlh1100.bin", # listed by three setups
|
||||
"LibPS.exe", "openbios.bin", "PSX_DTLH2000.bin", # by name, any case
|
||||
])
|
||||
|
||||
def test_a_console_cut_in_parts_goes_by_name(self):
|
||||
cards = gs._console_cards("Sony", self.files, self.consumers, set(), by_name=True)
|
||||
names = [re.search(r"<code>(.*?)</code>", card[2])[1] for _v, card, _s in cards]
|
||||
self.assertEqual(names, sorted(names, key=str.casefold))
|
||||
|
||||
def required_only(self) -> types.SimpleNamespace:
|
||||
"""The BIOS readers alone: an emulator reading only an optional
|
||||
file runs without the BIOS, and two of four would say so."""
|
||||
return _consumers({
|
||||
"4" * 40: [{"name": "scph5501.bin", "required": True, "priority": 2,
|
||||
"region": ["north-america"]}],
|
||||
"5" * 40: [{"name": "scph5500.bin", "required": True, "priority": 5,
|
||||
"region": ["japan"]}],
|
||||
})
|
||||
|
||||
def test_the_section_opens_on_the_bios_by_region(self):
|
||||
lines = gs._start_with(self.files, self.required_only())
|
||||
self.assertEqual(lines[0], "**Start with** the BIOS for the region of your games:")
|
||||
self.assertIn("- **North America**: [`scph5501.bin`](#sha1-444444444444)", lines)
|
||||
self.assertIn("- **Japan**: [`scph5500.bin`](#sha1-555555555555)", lines)
|
||||
self.assertLess(lines.index("- **North America**: [`scph5501.bin`](#sha1-444444444444)"),
|
||||
lines.index("- **Japan**: [`scph5500.bin`](#sha1-555555555555)"))
|
||||
|
||||
def test_regions_needing_the_same_file_share_a_line(self):
|
||||
consumers = _consumers({"4" * 40: [
|
||||
{"required": True, "region": ["north-america", "latin-america", "brazil"]}]})
|
||||
lines = gs._start_with([_file("scph5501.bin", "4")], consumers)
|
||||
# Brazil says nothing Latin America does not.
|
||||
self.assertIn("- **North America, Latin America**: [`scph5501.bin`](#sha1-444444444444)", lines)
|
||||
|
||||
def test_a_long_list_names_the_first_and_counts_the_rest(self):
|
||||
files = [_file(f"rom{i}.bin", "abcdefgh"[i]) for i in range(8)]
|
||||
# One emulator requires all eight: files each required by another
|
||||
# emulator are other names for one slot.
|
||||
consumers = _consumers({f["sha1"]: [{"required": True}] for f in files})
|
||||
consumers.emulator_files = {"emu": [{"required": True}] * len(files)}
|
||||
consumers.emulator_entries = {f["sha1"]: {"emu": i} for i, f in enumerate(files)}
|
||||
self.assertEqual(gs._start_with(files, consumers)[0],
|
||||
"**Start with**: [`rom0.bin`](#sha1-aaaaaaaaaaaa), [`rom1.bin`](#sha1-bbbbbbbbbbbb), "
|
||||
"[`rom2.bin`](#sha1-cccccccccccc) and 5 more files. "
|
||||
"They come first below.")
|
||||
|
||||
def test_no_required_file_no_line(self):
|
||||
self.assertEqual(gs._start_with([_file("a.bin", "a")], _consumers({})), [])
|
||||
|
||||
def test_the_page_carries_the_line_under_the_console(self):
|
||||
# Sega keeps its consoles as sections. Sony gives each a page.
|
||||
page = gs.generate_system_page("Sega", {"Saturn": self.files}, self.required_only())
|
||||
heading = page.index("## Saturn")
|
||||
self.assertLess(heading, page.index("**Start with**"))
|
||||
self.assertLess(page.index("**Start with**"), page.index('class="rb-sys-file"'))
|
||||
|
||||
|
||||
class ThePermalinkIsAnIcon(unittest.TestCase):
|
||||
"""The word "permalink" was link text inside every card title: the
|
||||
search showed "scph5501.bin permalink" and screen readers read it."""
|
||||
|
||||
def test_no_word_and_out_of_the_search(self):
|
||||
lines = gs._system_file_card(_file("scph5501.bin", "4"), FileConsumers({"files": {}}))
|
||||
title = lines[2]
|
||||
link = re.search(r'<a class="rb-permalink"[^>]*>(.*?)</a>', title)
|
||||
self.assertNotIn("permalink", re.sub(r"<[^>]+>", "", title))
|
||||
self.assertIn('aria-label="Permanent link to scph5501.bin"', link[0])
|
||||
self.assertIn("data-pagefind-ignore", link[0])
|
||||
self.assertIn('<use href="#rb-link-icon"/>', link[1])
|
||||
|
||||
def test_the_page_draws_the_icon_once(self):
|
||||
page = gs.generate_system_page(
|
||||
"Sega", {"Saturn": [_file("a.bin", "a"), _file("b.bin", "b")]},
|
||||
FileConsumers({"files": {}}))
|
||||
self.assertEqual(page.count('<symbol id="rb-link-icon"'), 1)
|
||||
self.assertEqual(page.count('href="#rb-link-icon"'), 2)
|
||||
|
||||
|
||||
class TheCardLinesSayWhatTheyMean(unittest.TestCase):
|
||||
""""Platforms:" and "Also installed by:" listed different setups, as
|
||||
lower-case ids, and only a code comment told them apart."""
|
||||
|
||||
def setUp(self):
|
||||
sha1 = "4" * 40
|
||||
self.consumers = types.SimpleNamespace(
|
||||
platforms={sha1: {"batocera"}}, emulators={},
|
||||
platform_systems={sha1: {"batocera": {"sony-playstation"}}},
|
||||
platform_destinations={sha1: {"batocera": {"scph5501.bin"}}},
|
||||
installed={sha1: {"retrobat": {"bios/scph5501.bin"}}},
|
||||
)
|
||||
self.lines = gs._system_file_card(_file("scph5501.bin", "4"), self.consumers)
|
||||
|
||||
def test_labels_and_display_names(self):
|
||||
text = "\n".join(self.lines)
|
||||
# Grouped by where the setups put the file.
|
||||
self.assertIn("<dt>On the setup's list</dt><dd><code>scph5501.bin</code>: <a href=\"../"
|
||||
"platforms/batocera.md#sony-playstation\">Batocera</a></dd>", text)
|
||||
self.assertIn("<dt>Packed for its emulators by</dt><dd><code>bios/scph5501.bin</code>: "
|
||||
"<a href=\"../platforms/retrobat.md\">RetroBat</a></dd>", text)
|
||||
self.assertNotIn("Platforms:", text)
|
||||
self.assertNotIn("Also installed by", text)
|
||||
|
||||
def test_the_facts_are_out_of_the_search(self):
|
||||
# The setup list made every card's excerpt a run of names and
|
||||
# paths: the card's title is what the search reads.
|
||||
# The facts shown open are one list, the others one fold, both
|
||||
# out of the search.
|
||||
shown = self.lines[:next(i for i, line in enumerate(self.lines) if line.startswith("<details"))]
|
||||
lists = [line for line in shown if line.startswith("<dl")]
|
||||
self.assertEqual(lists, ['<dl class="rb-facts" data-pagefind-ignore>'])
|
||||
folds = [line for line in self.lines if line.startswith("<details")]
|
||||
self.assertEqual(len(folds), 1)
|
||||
self.assertEqual(folds, ["<details data-pagefind-ignore>"])
|
||||
|
||||
def test_the_page_defines_both_lines(self):
|
||||
page = gs.generate_system_page("Sega", {"Saturn": [_file("scph5501.bin", "4")]},
|
||||
self.consumers)
|
||||
key = page.index("**On the setup's list** names")
|
||||
self.assertLess(key, page.index('class="rb-sys-file"'))
|
||||
self.assertIn("**Packed for its emulators by** names", page)
|
||||
|
||||
def test_a_file_nothing_reads_says_so(self):
|
||||
title = gs._system_file_card(_file("GameIndex.yaml", "9"), FileConsumers({"files": {}}))[2]
|
||||
self.assertIn(">No reader</span>", title)
|
||||
self.assertNotIn("No reader", self.lines[2])
|
||||
|
||||
|
||||
class FileFactsAreAGrid(unittest.TestCase):
|
||||
"""Each card was two bullet lists of code chips."""
|
||||
|
||||
def test_one_value_cell_per_label_and_plain_hashes(self):
|
||||
entry = {**_file("scph5501.bin", "4"), "crc32": "8d8cb7e4",
|
||||
"provenance": {"redump": {"name": "ps-30a.bin"},
|
||||
"tosec": {"name": "SCPH-5501 (1996).bin"}}}
|
||||
lines = gs._system_file_card(entry, FileConsumers({"files": {}}))
|
||||
text = "\n".join(lines)
|
||||
# One value cell after each label, in the open list and in the fold.
|
||||
facts = re.findall(r"<dt>[^<]*</dt><dd[^>]*>.*?</dd>", text)
|
||||
self.assertTrue(facts)
|
||||
self.assertEqual(len(facts), text.count("<dt>"))
|
||||
self.assertEqual(text.count("<dt>"), len(re.findall(r"<dd[ >]", text)))
|
||||
# The hash is the cell's text, styled by the cell: no element around it.
|
||||
self.assertIn('<dt>SHA1</dt><dd class="rb-hash">' + "4" * 40 + "</dd>", text)
|
||||
self.assertNotIn("<code class=\"rb-hash\">", text)
|
||||
self.assertNotIn('<span class="rb-hash">', text)
|
||||
catalogued = next(fact for fact in facts if fact.startswith("<dt>Catalogued"))
|
||||
self.assertIn("<br>", catalogued)
|
||||
self.assertNotRegex(text, r"(?m)^- ")
|
||||
|
||||
def test_collection_path_keeps_its_chip(self):
|
||||
text = "\n".join(gs._system_file_card(_file("a.bin", "a"), FileConsumers({"files": {}})))
|
||||
self.assertIn("<dt>Collection path</dt><dd><code>bios/Sony/PlayStation/a.bin</code>", text)
|
||||
|
||||
|
||||
class ConsoleHeadingsStartWithTheName(unittest.TestCase):
|
||||
"""The icon and a space came before the name: "Section titled
|
||||
“ PlayStation”" on five of seven Sony headings. Round 3 removed the
|
||||
icon altogether."""
|
||||
|
||||
def test_no_icon_even_when_one_exists(self):
|
||||
icon = siterender._icon_name("Sony", "PlayStation")
|
||||
with mock.patch.dict(siterender._icon_available, {icon: True}):
|
||||
self.assertEqual(gs._console_heading("Sony", "PlayStation"),
|
||||
"## PlayStation { #playstation }")
|
||||
|
||||
def test_no_icon_no_image(self):
|
||||
icon = siterender._icon_name("Nobody", "Nothing")
|
||||
with mock.patch.dict(siterender._icon_available, {icon: False}):
|
||||
self.assertEqual(gs._console_heading("Nobody", "Nothing"), "## Nothing { #nothing }")
|
||||
|
||||
|
||||
class TheSystemsIndexListsConsoles(unittest.TestCase):
|
||||
"""The index listed 74 manufacturers and no console: PlayStation had to
|
||||
be known to be under Sony."""
|
||||
|
||||
def test_one_row_per_console_by_its_name(self):
|
||||
gs.reset_console_pages()
|
||||
self.addCleanup(gs.reset_console_pages)
|
||||
groups = {
|
||||
"Sony": {"PlayStation": [_file("a.bin", "a")], "PlayStation 2": [_file("b.bin", "b")]},
|
||||
"Other": {"ScummVM": [_file("c.bin", "c")]},
|
||||
"Game Engines": {"TheXTech": [_file("d.png", "d")]},
|
||||
}
|
||||
page = gs.generate_systems_index(groups)
|
||||
self.assertIn("| [Sony PlayStation](sony.md#playstation) | 1 | [Sony](sony.md) |", page)
|
||||
self.assertIn("| [Sony PlayStation 2](sony.md#playstation-2) | 1 | [Sony](sony.md) |",
|
||||
page)
|
||||
# A program's folder under Other is named alone, in its own table.
|
||||
consoles = page.split("## Emulator folders")[0]
|
||||
self.assertNotIn("ScummVM", consoles)
|
||||
self.assertIn("| [ScummVM](../emulators/scummvm.md) | [1 file](other.md#scummvm) |",
|
||||
page.split("## Emulator folders")[1])
|
||||
self.assertNotIn("TheXTech", consoles)
|
||||
self.assertIn("| [TheXTech](game-engines.md#thextech)", page)
|
||||
|
||||
def test_rows_go_by_name_not_by_maker(self):
|
||||
groups = {"Sega": {"Saturn": [_file("a.bin", "a")]},
|
||||
"Atari": {"Lynx": [_file("b.bin", "b")]}}
|
||||
page = gs.generate_systems_index(groups)
|
||||
self.assertLess(page.index("Atari Lynx"), page.index("Sega Saturn"))
|
||||
|
||||
|
||||
class FindAFileSaysWhatAHashIs(unittest.TestCase):
|
||||
|
||||
def test_the_sentence_above_the_box(self):
|
||||
page = gs.generate_files_page()
|
||||
intro = page.split('<form class="rb-file-lookup"')[0]
|
||||
self.assertIn("A hash is a fingerprint of a file's contents", intro)
|
||||
self.assertIn("(../wiki/glossary.md)", intro)
|
||||
self.assertNotIn("**permalink**", page)
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "systems" / "sony" / "index.html").is_file(), "site not built")
|
||||
class TheBuiltSonyPage(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.text = (DIST / "systems" / "sony" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
def test_no_card_title_says_permalink(self):
|
||||
# Every Sony console has a page of its own: the cards are there.
|
||||
own = (DIST / "systems" / "sony-playstation" / "index.html").read_text(encoding="utf-8")
|
||||
titles = re.findall(r'<h3[^>]*class="rb-file-title".*?</h3>', own, re.S)
|
||||
self.assertTrue(titles)
|
||||
for title in titles:
|
||||
self.assertNotIn("permalink", re.sub(r"<[^>]+>", "", title))
|
||||
|
||||
def test_playstation_opens_on_a_bios_it_needs(self):
|
||||
section = self.text.split('id="playstation"', 1)[1].split("<h2", 1)[0]
|
||||
# The opening line follows the profiles: "Start with" when most
|
||||
# emulators require the BIOS, "No file needed with ... Only ...
|
||||
# require" when most run without it. Either way it names a BIOS and
|
||||
# links it, before the first card.
|
||||
opening = section.split('class="rb-sys-file"', 1)[0]
|
||||
self.assertRegex(opening, r'<a href="[^"]+"><code[^>]*>scph\d+\.bin</code></a>')
|
||||
# PlayStation has a page of its own, and its files open there.
|
||||
own = (DIST / "systems" / "sony-playstation" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertIn('href="/retrobios/systems/sony-playstation/"', section)
|
||||
first = re.search(r'class="rb-file-title"[^>]*><code[^>]*>(.*?)</code>', own)[1]
|
||||
self.assertNotIn(first, {"LibPS.exe", "PSX_DTLH2000.bin"})
|
||||
self.assertRegex(first, r"^scph\d+")
|
||||
|
||||
def test_headings_have_no_leading_space(self):
|
||||
self.assertNotRegex(self.text, r'<h2 id="playstation[^"]*"><img[^>]*>\s')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,388 +0,0 @@
|
||||
"""Wiki copy and the diagrams module.
|
||||
|
||||
Each class names the finding it holds: a safety line the code contradicted,
|
||||
install commands with no Windows form, a run's end described wrongly, pages
|
||||
with two names, an FAQ answer the setups' own checks contradict, a setup
|
||||
count given two ways, unreadable diagram text, and example hashes that
|
||||
answered a hash search in place of the file card.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sitediagrams # noqa: E402
|
||||
from siteguides import INSTALL_SH # noqa: E402
|
||||
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
WIKI = REPO_ROOT / "wiki"
|
||||
FENCED = re.compile(r"^```.*?^```", re.S | re.M)
|
||||
|
||||
|
||||
def _front(text: str) -> dict:
|
||||
match = re.match(r"---\n(.*?)\n---\n", text, re.S)
|
||||
return yaml.safe_load(match.group(1)) if match else {}
|
||||
|
||||
|
||||
def _h1(text: str) -> str:
|
||||
match = re.search(r"^# (.+?)(?:\s*\{[^}]*\})?\s*$", FENCED.sub("", text), re.M)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def _section(text: str, heading: str) -> str:
|
||||
start = re.search(rf"(?m)^#+ {re.escape(heading)}(?:\s*\{{[^}}]*\}})?\n", text)
|
||||
if start is None:
|
||||
raise AssertionError(f"no section {heading!r}")
|
||||
rest = text[start.end():]
|
||||
end = re.search(r"(?m)^#{1,2} ", FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest))
|
||||
return rest[: end.start()] if end else rest
|
||||
|
||||
|
||||
def _wiki(name: str) -> str:
|
||||
return (WIKI / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _built(name: str) -> str:
|
||||
sitepages.require()
|
||||
return sitepages.page(name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
START_PAGES = ("what-are-bios-files.md", "first-install.md", "troubleshooting.md",
|
||||
"faq.md", "glossary.md", "legal.md")
|
||||
|
||||
|
||||
class SafetyListMatchesTheInstaller(unittest.TestCase):
|
||||
"""C1: "It writes only in your BIOS folder", while install.py writes the
|
||||
entries marked root: save into the setup's save folder."""
|
||||
|
||||
def test_the_faq_names_the_save_folder_while_a_manifest_writes_there(self):
|
||||
saved = [
|
||||
path.name for path in sorted((REPO_ROOT / "install").glob("*.json"))
|
||||
if any(entry.get("root") == "save"
|
||||
for entry in json.loads(path.read_text(encoding="utf-8")).get("files", []))
|
||||
]
|
||||
section = _section(_wiki("faq.md"), "Is the command safe?")
|
||||
if saved:
|
||||
self.assertNotIn("only in your BIOS folder", section)
|
||||
self.assertIn("save folder", section)
|
||||
self.assertIn("retrobios-backup", section)
|
||||
self.assertIn("--restore", section)
|
||||
|
||||
|
||||
class InstallCommandsHaveEveryForm(unittest.TestCase):
|
||||
"""N7, B3, C6: troubleshooting and the FAQ gave only `curl ... | sh`.
|
||||
Every install command on a Start here page is marked for the system tabs,
|
||||
and the built page shows the PowerShell form beside it."""
|
||||
|
||||
def test_every_install_command_on_a_start_page_is_marked(self):
|
||||
for name in START_PAGES:
|
||||
text = _wiki(name)
|
||||
with self.subTest(page=name):
|
||||
for match in re.finditer(r"(?m)^```(?:sh|bash)\n(.*?)\n```", text, re.S):
|
||||
if "install.sh" not in match.group(1) or "|" not in match.group(1):
|
||||
continue
|
||||
before = text[: match.start()].rstrip("\n").rsplit("\n", 1)[-1]
|
||||
self.assertRegex(before, r"<!--\s*os-tabs\b", match.group(1))
|
||||
|
||||
def test_the_built_pages_carry_the_windows_form(self):
|
||||
for name in ("wiki/troubleshooting.md", "wiki/faq.md", "wiki/first-install.md"):
|
||||
text = _built(name)
|
||||
with self.subTest(page=name):
|
||||
self.assertIn('<TabItem label="Windows">', text)
|
||||
self.assertIn("&([scriptblock]::Create((irm", text)
|
||||
self.assertNotIn("os-tabs", text)
|
||||
self.assertIn('```powershell title="PowerShell"', text)
|
||||
self.assertIn('```sh title="Terminal"', text)
|
||||
|
||||
def test_a_windows_path_replaces_the_unix_one(self):
|
||||
page = (
|
||||
"<!-- os-tabs windows: --platform retroarch --dest E:\\x -->\n```sh\n"
|
||||
f"curl -fsSL {INSTALL_SH} \\\n | sh -s -- --platform retroarch --dest /a/b\n```\n"
|
||||
)
|
||||
text = sitediagrams.expand_commands(page)
|
||||
windows, rest = text.split('<TabItem label="macOS">', 1)
|
||||
self.assertIn("--dest E:\\x", windows)
|
||||
self.assertNotIn("/a/b", windows)
|
||||
self.assertIn("| sh -s -- --platform retroarch --dest /a/b", rest)
|
||||
|
||||
def test_a_marked_block_without_an_install_command_fails_the_build(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sitediagrams.expand_commands("<!-- os-tabs -->\n```sh\nls -l\n```\n")
|
||||
|
||||
|
||||
class FirstInstallEnds(unittest.TestCase):
|
||||
"""C7, B15: the page said `Done.` may list failures, and sent a wrong
|
||||
folder to a section with no answer for it."""
|
||||
|
||||
def test_the_wrong_folder_answer_is_on_the_screen(self):
|
||||
# The one-liner opens the full screen: its first screen has the
|
||||
# answer, a row the page names as the interface spells it.
|
||||
import install
|
||||
|
||||
section = _section(_wiki("first-install.md"), "2. Check the setup it found")
|
||||
state = install.TuiState()
|
||||
row = install.tui_rows(state)[-1].label
|
||||
self.assertIn(f"**{row}**", section)
|
||||
self.assertIn("<your setup>", section)
|
||||
self.assertNotIn("Batocera", section)
|
||||
|
||||
def test_the_diagram_follows_the_steps(self):
|
||||
text = _wiki("first-install.md")
|
||||
self.assertLess(text.index("## 5. Read the result"), text.index("<!-- image: install-flow -->"))
|
||||
|
||||
|
||||
class OneNamePerPage(unittest.TestCase):
|
||||
"""N11, C4, V22: "Common questions" in the menu opened "FAQ"."""
|
||||
|
||||
def test_no_wiki_page_names_itself_twice(self):
|
||||
for path in sorted(WIKI.glob("*.md")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
with self.subTest(page=path.name):
|
||||
self.assertNotIn("nav_title", _front(text))
|
||||
|
||||
def test_links_to_a_renamed_page_use_its_heading(self):
|
||||
names = {path.name: _h1(path.read_text(encoding="utf-8")) for path in WIKI.glob("*.md")}
|
||||
stale = {"faq.md": "FAQ", "glossary.md": "Glossary", "legal.md": "The project's legal position"}
|
||||
for path in sorted(WIKI.glob("*.md")):
|
||||
for text, target in re.findall(r"\[([^\]]+)\]\(([a-z-]+\.md)\)", path.read_text(encoding="utf-8")):
|
||||
if target in stale and target in names:
|
||||
with self.subTest(page=path.name, link=text):
|
||||
self.assertNotEqual(text, stale[target])
|
||||
|
||||
|
||||
class ArcadeFilesDependOnTheSetup(unittest.TestCase):
|
||||
"""C11, C02: "Does a BIOS work on every setup? Yes." Platforms check an
|
||||
MD5 and refuse another revision, and neogeo.zip differs by setup."""
|
||||
|
||||
def test_the_answer_is_not_a_plain_yes(self):
|
||||
section = _section(_wiki("faq.md"), "Does a BIOS work on every setup?").strip()
|
||||
self.assertFalse(section.startswith("Yes"))
|
||||
self.assertIn("neogeo.zip", section)
|
||||
|
||||
def test_the_setups_do_install_different_neogeo_zips(self):
|
||||
digests = set()
|
||||
for path in (REPO_ROOT / "install").glob("*.json"):
|
||||
for entry in json.loads(path.read_text(encoding="utf-8")).get("files", []):
|
||||
if str(entry.get("dest", "")).lower().endswith("/neogeo.zip") or entry.get("dest") == "neogeo.zip":
|
||||
digests.add(entry.get("sha1"))
|
||||
self.assertGreater(len(digests), 1)
|
||||
|
||||
def test_zip_or_unzip_is_answered(self):
|
||||
section = _section(_wiki("faq.md"), "Zip or unzip?")
|
||||
self.assertIn("neogeo.zip", section)
|
||||
|
||||
|
||||
class VerificationModesSplit(unittest.TestCase):
|
||||
"""C12, N24, F5: the page opened on "Before a game starts", ran 37
|
||||
entries of contents, and cited checker code as plain text."""
|
||||
|
||||
def test_opening(self):
|
||||
text = _wiki("verification-modes.md")
|
||||
self.assertNotIn("Before a game starts", text)
|
||||
self.assertIn("Most setups can show which BIOS files are missing.", text)
|
||||
|
||||
def test_the_project_reports_have_their_own_page(self):
|
||||
modes = _wiki("verification-modes.md")
|
||||
reports = _wiki("reading-reports.md")
|
||||
for heading in ("## Hash mismatch / UNTESTED", "## verify.py reports errors", "### Reading the output"):
|
||||
with self.subTest(heading=heading):
|
||||
self.assertNotIn(heading, modes)
|
||||
self.assertIn(heading, reports)
|
||||
self.assertEqual(_front(reports).get("level"), "reference")
|
||||
|
||||
def test_checker_links_are_pinned_to_the_revision_the_platform_names(self):
|
||||
named = " ".join(
|
||||
f"{data.get('version', '')} {data.get('source', '')}"
|
||||
for data in (yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
for p in (REPO_ROOT / "platforms").glob("*.yml"))
|
||||
if isinstance(data, dict)
|
||||
)
|
||||
links = re.findall(r"\((https://(?:github\.com|gitlab\.com)/[^)\s]+)\)", _wiki("verification-modes.md"))
|
||||
pinned = [url for url in links if re.search(r"/-?/?blob/", url)]
|
||||
self.assertGreaterEqual(len(pinned), 10)
|
||||
for url in pinned:
|
||||
revision = re.search(r"/blob/([^/]+)/", url).group(1)
|
||||
with self.subTest(url=url):
|
||||
self.assertNotIn(revision, ("main", "master", "HEAD"))
|
||||
self.assertIn(revision, named)
|
||||
|
||||
|
||||
class SetupCount(unittest.TestCase):
|
||||
"""C2: the home page said 15 more, the diagram 14 more, from two counts."""
|
||||
|
||||
def test_one_count(self):
|
||||
from common import list_registered_platforms
|
||||
|
||||
self.assertEqual(len(sitediagrams.setups()),
|
||||
len(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True)))
|
||||
|
||||
def test_every_rendered_count_agrees(self):
|
||||
"""Each "RetroArch, Batocera, EmuDeck and N more" names three setups."""
|
||||
found = set()
|
||||
for path in [*sitepages.pages()]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
found.update(int(n) for n in re.findall(r"(?:RetroArch|EmuDeck|Batocera) and (\d+) more", text))
|
||||
found.update(int(n) for n in re.findall(
|
||||
r"and (\d+) more", sitediagrams.og_card_svg("home", "<svg></svg>")))
|
||||
self.assertTrue(found)
|
||||
self.assertEqual(found, {len(sitediagrams.setups()) - 3})
|
||||
|
||||
|
||||
class Vocabulary(unittest.TestCase):
|
||||
"""C5: one thing called device, setup, frontend, platform and game menu."""
|
||||
|
||||
def test_the_glossary_defines_the_words_the_reference_uses(self):
|
||||
terms = {term for term in re.findall(r"^\*\*([^*]+)\*\*", _wiki("glossary.md"), re.M)}
|
||||
terms |= set(re.findall(r"\*\*([^*]+)\*\*", _wiki("glossary.md")))
|
||||
for word in ("Pack", "Platform", "MD5", "SHA-1", "Setup"):
|
||||
with self.subTest(word=word):
|
||||
self.assertIn(word, terms)
|
||||
|
||||
def test_start_pages_and_the_first_diagram_say_setup(self):
|
||||
for name in START_PAGES[:5]:
|
||||
text = FENCED.sub("", _wiki(name)).lower()
|
||||
with self.subTest(page=name):
|
||||
self.assertNotRegex(text, r"\bfrontends?\b")
|
||||
self.assertNotIn("game menu", text)
|
||||
for figure in ("bios-chain", "install-flow"):
|
||||
with self.subTest(figure=figure):
|
||||
self.assertNotIn("rontend", sitediagrams.figure_html(figure))
|
||||
|
||||
|
||||
class NavigationPlaces(unittest.TestCase):
|
||||
"""N14, N15, N16, T5: installer and legal in Explore, build internals in
|
||||
Reference, and a legacy Getting started page in no tab."""
|
||||
|
||||
def test_front_matter(self):
|
||||
expected = {
|
||||
"installer.md": "setup", "legal.md": "start",
|
||||
"architecture.md": "contribute", "tools.md": "contribute",
|
||||
"advanced-usage.md": "contribute", "reference-terms.md": "contribute",
|
||||
}
|
||||
for name, level in expected.items():
|
||||
with self.subTest(page=name):
|
||||
self.assertEqual(_front(_wiki(name))["level"], level)
|
||||
self.assertEqual(_front(_wiki("legal.md"))["order"], _front(_wiki("faq.md"))["order"] + 5)
|
||||
self.assertEqual(_front(_wiki("verification-modes.md"))["group"], "Checking files")
|
||||
self.assertEqual(_front(_wiki("community-tools.md"))["group"], "Data")
|
||||
self.assertFalse((WIKI / "getting-started.md").exists())
|
||||
|
||||
def test_no_next_aside_is_left(self):
|
||||
for path in sorted(WIKI.glob("*.md")):
|
||||
with self.subTest(page=path.name):
|
||||
self.assertNotIn(":::tip[Next]", path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TroubleshootingLines(unittest.TestCase):
|
||||
"""C22: "Details: When something goes wrong", a sudo line that is the
|
||||
installer's to give, and an unclear Linux line."""
|
||||
|
||||
def test_lines(self):
|
||||
text = _wiki("troubleshooting.md")
|
||||
self.assertNotIn("Details: [When something goes wrong]", text)
|
||||
self.assertIn("Other messages: [When something goes wrong]", text)
|
||||
self.assertIn("Run the command once.", text)
|
||||
for name in ("troubleshooting.md", "first-install.md"):
|
||||
with self.subTest(page=name):
|
||||
self.assertNotIn("sudo", _wiki(name))
|
||||
self.assertRegex(" ".join(_wiki(name).split()), r"[Dd]o what the message says")
|
||||
|
||||
def test_the_installer_names_what_to_run(self):
|
||||
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
advice = source[source.index("def _write_advice"):source.index("def _plural")]
|
||||
self.assertIn("hint.command(plat, path, elevated=True)", advice)
|
||||
|
||||
|
||||
class DiagramText(unittest.TestCase):
|
||||
"""V17: 11 px text in fixed-height boxes, paths broken mid-token."""
|
||||
|
||||
def test_text_reads_at_14_px_in_the_column(self):
|
||||
self.assertGreaterEqual(sitediagrams.TEXT_SIZE * sitediagrams.COLUMN / sitediagrams.WIDE, 14)
|
||||
|
||||
def test_no_box_holds_more_than_three_lines(self):
|
||||
wide = sitediagrams.WIDE - 2
|
||||
layouts = { # the box width of each wide layout: flow, grid of two, columns
|
||||
"bios-chain": (sitediagrams._bios_chain_boxes(), (wide - 26 * 3) / 4),
|
||||
"install-flow": (sitediagrams._install_boxes(), (wide - 18) / 2),
|
||||
"verification-modes": (sitediagrams._mode_boxes(), (wide - 16 * 2) / 3),
|
||||
}
|
||||
for name, (boxes, box_width) in layouts.items():
|
||||
for width in (sitediagrams.NARROW - 2, box_width):
|
||||
for box in boxes:
|
||||
with self.subTest(figure=name, box=box.title, width=width):
|
||||
self.assertLessEqual(len(sitediagrams.box_lines(box, width)[1]),
|
||||
sitediagrams.MAX_BOX_LINES)
|
||||
|
||||
def test_a_path_is_mono_on_its_own_line(self):
|
||||
html = sitediagrams.figure_html("bios-chain")
|
||||
self.assertRegex(html, r'<text [^>]*style="font-family: var\(--sl-font-mono[^>]*>/userdata/bios/</text>')
|
||||
|
||||
def test_boxes_of_a_row_share_one_height(self):
|
||||
for layout in (sitediagrams.flow, sitediagrams.columns):
|
||||
for vertical, total in ((False, sitediagrams.WIDE), (True, sitediagrams.NARROW)):
|
||||
_width, _height, body = layout(sitediagrams._bios_chain_boxes(), vertical, total)
|
||||
heights = {float(h) for h in re.findall(r'<rect [^>]*height="([\d.]+)"[^>]*class="rb-d-box',
|
||||
"".join(body))}
|
||||
with self.subTest(layout=layout.__name__, vertical=vertical):
|
||||
self.assertEqual(len(heights), 1)
|
||||
|
||||
|
||||
class ExampleHashesStayOutOfSearch(unittest.TestCase):
|
||||
"""F4: the scph5501 MD5 typed in search found four documentation pages
|
||||
and not the file card."""
|
||||
|
||||
def test_wiki_hashes_are_excluded(self):
|
||||
text = _built("wiki/verification-modes.md")
|
||||
for match in re.finditer(r"\b[0-9a-f]{32}\b", text):
|
||||
line_start = text.rfind("\n", 0, match.start())
|
||||
context = text[line_start: match.end() + 10]
|
||||
with self.subTest(hash=match.group(0)):
|
||||
self.assertIn("data-pagefind-ignore", context)
|
||||
|
||||
def test_a_fenced_hash_is_wrapped(self):
|
||||
out = sitediagrams.hide_hashes("```sh\nx 490f666e1afb15b7362b406ed1cea246\n```\n")
|
||||
self.assertTrue(out.startswith("<div data-pagefind-ignore>\n\n```sh"))
|
||||
|
||||
def test_only_wiki_pages_are_touched(self):
|
||||
page = "Hash `490f666e1afb15b7362b406ed1cea246`.\n"
|
||||
self.assertEqual(sitediagrams.prepare_page(page, Path("systems/sony.md")), page)
|
||||
self.assertIn("data-pagefind-ignore", sitediagrams.prepare_page(page, Path("wiki/x.md")))
|
||||
|
||||
|
||||
class InstallerOptionsAtAGlance(unittest.TestCase):
|
||||
"""C23: a 9,000-word page with no table at the top. The table is read
|
||||
from the parser, so it lists what --help lists."""
|
||||
|
||||
def test_every_option_of_the_parser_has_a_row(self):
|
||||
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
declared = set(re.findall(r'parser\.add_argument\(\s*"(--[a-z-]+)"', source))
|
||||
listed = {usage.split()[0].rstrip(",") for usage, _ in sitediagrams.installer_options()}
|
||||
self.assertEqual(declared | {"--help"}, listed)
|
||||
|
||||
def test_the_built_page_opens_on_a_link_per_option(self):
|
||||
text = _built("wiki/installer.md")
|
||||
glance = text.index("## Options at a glance")
|
||||
self.assertLess(glance, text.index("## One line"))
|
||||
listed = text[glance:text.index("## One line")]
|
||||
for usage, _ in sitediagrams.installer_options():
|
||||
flag = usage.split()[0].rstrip(",")
|
||||
with self.subTest(option=flag):
|
||||
self.assertIn(f"[`{flag}`](#option-{flag.lstrip('-')})", listed)
|
||||
self.assertNotIn("<!-- installer-options", text)
|
||||
# The markers of scripts/generate_installer_doc.py stay in the source.
|
||||
self.assertNotIn("generated:", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,426 +0,0 @@
|
||||
"""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: 10, devices: 5, consoles: 100, exact: 20, terms: 30 },", config)
|
||||
self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'exact', '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)
|
||||
|
||||
|
||||
class SearchConsoleOwnership(unittest.TestCase):
|
||||
"""Search Console checks the tag on every recrawl: without it the
|
||||
property goes unverified and the sitemap stops being read."""
|
||||
|
||||
def test_the_head_carries_the_verification_tag(self):
|
||||
self.assertIn("{ tag: 'meta', attrs: { name: 'google-site-verification', "
|
||||
"content: '8SueST3hNHl8_vM-OJJP0rz-ZMlI2DALLpChrwZoGMs' } }",
|
||||
CONFIG.read_text(encoding="utf-8"))
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
def test_the_home_page_serves_it(self):
|
||||
self.assertIn('<meta name="google-site-verification" '
|
||||
'content="8SueST3hNHl8_vM-OJJP0rz-ZMlI2DALLpChrwZoGMs"',
|
||||
(DIST / "index.html").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
|
||||
class EverySonyConsoleHasItsPage(unittest.TestCase):
|
||||
"""A section shares its page's search fields: "playstation 3 bios"
|
||||
found the PlayStation page before the Sony page holding the PS3."""
|
||||
|
||||
def test_sony_is_split_by_console(self):
|
||||
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
self.assertIn('CONSOLE_PAGES = frozenset({"Sony"})', source)
|
||||
self.assertIn("manufacturer in CONSOLE_PAGES", source)
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
def test_each_console_page_is_built(self):
|
||||
for slug in ("sony-playstation", "sony-playstation-2", "sony-playstation-3",
|
||||
"sony-playstation-4", "sony-playstation-portable", "sony-playstation-vita",
|
||||
"sony-pocketstation"):
|
||||
with self.subTest(page=slug):
|
||||
self.assertTrue((DIST / "systems" / slug / "index.html").is_file())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Round 3 theme: the stylesheet fixes of package 1, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: commands allowed
|
||||
to break inside any word on a phone, a sidebar whose top links outsize its
|
||||
group labels, badge links cut to a few pixels by the
|
||||
next row, 12 px hashes, Get your files tables each sizing their own columns.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tests.test_site_r2_css import CSS_PATH, PAGE_BG, _blocks, _ratio, _rules, _strip_comments, _theme_tokens
|
||||
|
||||
# The Get your files tables: no class reaches them, the Markdown table is
|
||||
# matched by the cells the generator labels.
|
||||
PACKS = "table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])"
|
||||
|
||||
|
||||
class ThemeStylesheetRound3(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
dark = _theme_tokens(cls.css, ":root")
|
||||
light = {**dark, **_theme_tokens(cls.css, ":root[data-theme='light']")}
|
||||
cls.tokens = {"dark": dark, "light": light}
|
||||
|
||||
# 1.1
|
||||
def test_commands_never_break_inside_a_word(self):
|
||||
blocks = _rules(self.css, ".expressive-code pre > code")
|
||||
self.assertTrue(blocks)
|
||||
for body in blocks:
|
||||
self.assertIn("white-space: pre-wrap", body, "commands keep wrapping")
|
||||
self.assertNotIn("overflow-wrap: anywhere", body)
|
||||
for selector in (".rb-facts code", ".rb-hash code", ".rb-meta-card td code"):
|
||||
with self.subTest(selector=selector):
|
||||
self.assertIn("overflow-wrap: anywhere", _rules(self.css, selector)[0])
|
||||
|
||||
# 1.2
|
||||
def test_sidebar_labels_above_links(self):
|
||||
label = _rules(self.css, ".top-level summary .large")[0]
|
||||
# 13 px grey semibold, no uppercase: the house style refuses small caps.
|
||||
for declaration in ("font-size: var(--sl-text-xs)", "font-weight: 600", "color: var(--rb-text-secondary)"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, label)
|
||||
links = next(b for s, b in _blocks(self.css) if s == "#starlight__sidebar .top-level a")
|
||||
self.assertIn("font-size: var(--sl-text-sm)", links)
|
||||
self.assertIn("font-weight: 400", links)
|
||||
|
||||
# 1.4
|
||||
def test_badge_links_keep_a_24px_target(self):
|
||||
dd = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content .rb-facts dd")
|
||||
self.assertIn("line-height: 24px", dd)
|
||||
badge = _rules(self.css, ".rb-facts :is(.rb-badge:has(> a), a.rb-badge)")[0]
|
||||
for declaration in ("display: inline-flex", "align-items: center", "min-height: 24px", "margin-block: 2px"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, badge)
|
||||
|
||||
# 1.5
|
||||
def test_hashes_are_13px_at_every_width(self):
|
||||
hashes = [b for s, b in _blocks(self.css) if ".rb-hash" in s and "font-size" in b]
|
||||
self.assertEqual(len(hashes), 1, "a second size for .rb-hash, on phones")
|
||||
self.assertIn("font-size: 0.8125rem", hashes[0])
|
||||
short = _rules(self.css, "td code[title]")[0]
|
||||
self.assertIn("white-space: nowrap", short)
|
||||
self.assertIn("underline dotted", short)
|
||||
self.assertIn("min-width: 12rem", _rules(self.css, "td.rb-reason")[0])
|
||||
|
||||
# 1.6
|
||||
def test_pack_tables_share_their_columns(self):
|
||||
base = next(b for s, b in _blocks(self.css) if PACKS in s and "table-layout" in b)
|
||||
self.assertIn("table-layout: fixed", base)
|
||||
self.assertIn("width: 100%", base)
|
||||
widths = {s.rsplit("th:nth-child(", 1)[1].split(")")[0]: b for s, b in _blocks(self.css)
|
||||
if PACKS in s and "th:nth-child(" in s}
|
||||
self.assertEqual({k: v.strip() for k, v in widths.items()},
|
||||
{"1": "width: 18%;", "2": "width: 20%;", "3": "width: 40%;", "4": "width: 22%;"})
|
||||
parts = next(b for s, b in _blocks(self.css) if PACKS in s and s.endswith(":is(a, .rb-button)"))
|
||||
self.assertIn("white-space: nowrap", parts)
|
||||
|
||||
def test_secondary_text_on_the_sidebar_meets_aa(self):
|
||||
for theme in ("light", "dark"):
|
||||
with self.subTest(theme=theme):
|
||||
fg = self.tokens[theme]["--rb-text-secondary"].strip()
|
||||
self.assertGreaterEqual(_ratio(fg, PAGE_BG[theme]), 4.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,197 +0,0 @@
|
||||
"""Emulator pages say what to get, and fit.
|
||||
|
||||
NetherSX2 counted three interface fonts its app ships among "4 required"
|
||||
files and opened its file list on the project's counts. AetherSX2's summary
|
||||
held an empty Region column that pushed Required off a phone. No MAME or
|
||||
FinalBurn page said which romset its sets must match. DuckStation's page was
|
||||
59 phone screens of optional cards, and MAME's index put 13,300 elements on
|
||||
one page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import validate_site # noqa: E402
|
||||
|
||||
EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _page(name: str, profile: dict | None = None) -> str:
|
||||
return gs.generate_emulator_page(name, profile or _profile(name), EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _files(count: int, **fields: object) -> list[dict]:
|
||||
return [{"name": f"f{i:03d}.bin", **fields} for i in range(count)]
|
||||
|
||||
|
||||
class EachArcadePageNamesItsRomset(unittest.TestCase):
|
||||
def test_the_romset_by_profile(self):
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003")), "MAME 0.78")
|
||||
# The app's version is 1.38.1, its MAME is 0.289.
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame4droid-2024")), "MAME 0.289")
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003_plus")), "MAME 0.78 (plus backports)")
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbneo")).startswith("FinalBurn Neo "))
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbalpha2012")).startswith("FB Alpha "))
|
||||
self.assertEqual(gs.emulator_romset(_profile("duckstation")), "")
|
||||
# HBMAME publishes no MAME release number.
|
||||
self.assertEqual(gs.emulator_romset(_profile("hbmame")), "")
|
||||
|
||||
def test_a_row_and_a_line_under_the_intro(self):
|
||||
page = _page("mame2003")
|
||||
self.assertIn(f"| Romset | [MAME 0.78]({gs.ROMSET_FAQ}) |", page)
|
||||
self.assertNotIn("| MAME version |", _page("mame2016"))
|
||||
line = page.index("Its BIOS and game sets must come from the MAME 0.78 romset.")
|
||||
self.assertLess(line, page.index("About this profile"))
|
||||
self.assertIn(f"[Which version each core reads]({gs.ROMSET_FAQ})", page)
|
||||
|
||||
def test_the_faq_answer_exists(self):
|
||||
faq = (REPO_ROOT / "wiki" / "technical-faq.md").read_text(encoding="utf-8")
|
||||
self.assertIn("## Which MAME version do the arcade BIOS sets match?", faq)
|
||||
|
||||
def test_no_line_without_bios_zips(self):
|
||||
self.assertNotIn("must come from the", _page("duckstation"))
|
||||
|
||||
|
||||
class WhatTheAppShipsIsNoFileToGet(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.profile = _profile("nethersx2")
|
||||
self.page = _page("nethersx2", self.profile)
|
||||
|
||||
def test_the_badge(self):
|
||||
font = next(f for f in self.profile["files"] if f["name"] == "Roboto-Regular.ttf")
|
||||
badges = " ".join(gs._file_badges(font, False))
|
||||
self.assertIn(">comes with the app</span>", badges)
|
||||
self.assertNotIn(">required</span>", badges)
|
||||
self.assertNotIn(">missing</span>", badges)
|
||||
# Installed by one build only: the other still reads it from the folder.
|
||||
dual = " ".join(gs._file_badges({"name": "a", "bundled": True, "mode": "standalone",
|
||||
"required": True}, False))
|
||||
self.assertIn(">required</span>", dual)
|
||||
self.assertIn("installed with the standalone emulator", dual)
|
||||
|
||||
def test_counts_leave_them_out(self):
|
||||
shipped = sum(1 for f in self.profile["files"] if gs.comes_with_app(f))
|
||||
self.assertGreater(shipped, 0)
|
||||
summary = self.page[self.page.index("## Files"):]
|
||||
self.assertIn(f"{shipped} more files come with the app.", summary)
|
||||
self.assertIn(": 1 required,", summary)
|
||||
# Found by content: one BIOS of any name, said as steps.
|
||||
self.assertIn("NetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", self.page)
|
||||
|
||||
def test_their_own_section_after_the_others(self):
|
||||
heads = re.findall(r"^## (.+)$", self.page, re.M)
|
||||
self.assertEqual(heads[heads.index("Files") + 1:heads.index("Files") + 3],
|
||||
["Required", "Optional"])
|
||||
self.assertIn("Comes with the app", heads)
|
||||
section = self.page[self.page.index("## Required"):self.page.index("## Optional")]
|
||||
self.assertNotIn("Roboto-Regular.ttf", section)
|
||||
|
||||
def test_the_collection_counts_go_under_the_summary(self):
|
||||
held = self.page.index("<p><small>In this collection:")
|
||||
self.assertLess(held, self.page.index("## Required"))
|
||||
self.assertNotIn("in repo", self.page[self.page.index("## Files"):held])
|
||||
|
||||
def test_the_folder_is_the_apps_own(self):
|
||||
self.assertIn("| BIOS directory | `bios/`, inside the app's own data folder |", self.page)
|
||||
|
||||
|
||||
class TheSummaryTableFitsItsContent(unittest.TestCase):
|
||||
def test_an_empty_region_column_goes(self):
|
||||
rows = gs._summary_table(_files(3, md5="ab" * 16), [0, 1, 2], lambda _i, s: s)
|
||||
self.assertNotIn("Region", rows[0])
|
||||
|
||||
def test_a_constant_required_column_goes(self):
|
||||
rows = gs._summary_table(_files(3, required=True), [0, 1, 2], lambda _i, s: s)
|
||||
self.assertEqual(rows[0], "| File |")
|
||||
|
||||
def test_hashes_are_short_with_the_whole_value_in_the_title(self):
|
||||
md5 = "0123456789abcdef" * 2
|
||||
cell = gs._hash_short({"md5": md5})
|
||||
self.assertEqual(cell, f'MD5 <code title="{md5}">{md5[:12]}</code>')
|
||||
|
||||
def test_the_split_index_holds_each_id_on_its_link(self):
|
||||
files = _files(gs.EMULATOR_CARDS_PER_PAGE + 1, category="bios_zip")
|
||||
main = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
|
||||
"files": files}, {"files": {}})["big"]
|
||||
anchor = gs.emulator_file_anchors(files)[0]
|
||||
self.assertIn(f'<a id="{anchor}" href="/retrobios/emulators/big-files-1/#{anchor}">', main)
|
||||
self.assertNotIn(f'<span id="{anchor}"></span>', main)
|
||||
|
||||
|
||||
class LongPagesFold(unittest.TestCase):
|
||||
def test_each_entry_is_listed_once_as_a_row(self):
|
||||
files = _files(gs.EMULATOR_TABLE_MIN) + [{"name": "req.bin", "required": True}]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
self.assertNotIn("| File |", page)
|
||||
self.assertNotIn(f"<summary>{gs.EMULATOR_TABLE_MIN} files</summary>", page)
|
||||
self.assertEqual(page.count('<h3 id="file-'), len(files))
|
||||
required = page[page.index("## Required"):page.index("## Optional")]
|
||||
self.assertEqual(required.count('<h3 id="file-'), 1)
|
||||
self.assertIn('class="rb-entries"', required)
|
||||
|
||||
def test_a_few_cards_stay_open(self):
|
||||
page = _page("x", {"type": "standalone", "files": _files(2)})
|
||||
self.assertNotIn("<summary>2 files</summary>", page)
|
||||
|
||||
def test_the_profile_card_folds(self):
|
||||
page = _page("duckstation")
|
||||
start = page.index("<summary>About this profile</summary>")
|
||||
self.assertLess(start, page.index('<div class="rb-meta-card">'))
|
||||
self.assertLess(page.index("| Type |"), page.index("</details>", start))
|
||||
|
||||
|
||||
class DataDirectoryPagesStayOutOfSearch(unittest.TestCase):
|
||||
def test_front_matter(self):
|
||||
saved = dict(gs._DATA_MEMBERS), dict(gs._DATA_READERS)
|
||||
self.addCleanup(lambda: (gs._DATA_MEMBERS.clear(), gs._DATA_MEMBERS.update(saved[0]),
|
||||
gs._DATA_READERS.clear(), gs._DATA_READERS.update(saved[1])))
|
||||
gs._DATA_MEMBERS["dolphin-sys"] = {"a.bin": {"sha1": "a" * 40, "size": 1}}
|
||||
gs._DATA_READERS["dolphin-sys"] = [("dolphin-mmjr", "Dolphin MMJR"), ("dolphin", "Dolphin")]
|
||||
page = gs._data_tree_page("dolphin-sys", {}, [("data-dolphin-sys", ["a.bin"])], 0)
|
||||
front = yaml.safe_load(page.split("---\n")[1])
|
||||
self.assertEqual(front["search"], "exclude")
|
||||
self.assertEqual(front["parent"], {"label": "Dolphin", "link": "dolphin.md"})
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "data-dolphin-sys").is_dir(), "site not built")
|
||||
def test_the_built_page_is_not_indexed(self):
|
||||
html = (DIST / "emulators" / "data-dolphin-sys" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertNotIn("data-pagefind-body", html)
|
||||
|
||||
|
||||
class AnElementBudget(unittest.TestCase):
|
||||
def test_validate_site_reports_a_crowded_page(self):
|
||||
self.assertEqual(validate_site.MAX_PAGE_ELEMENTS, 10_000)
|
||||
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
page = Path(tmp) / "crowded" / "index.html"
|
||||
page.parent.mkdir()
|
||||
page.write_text("<html><body>" + "<i></i>" * 10_001
|
||||
+ "<script>" + "<b>" * 5 + "</script></body></html>")
|
||||
issues = validate_site.validate_site(Path(tmp))
|
||||
self.assertTrue(any("elements, over the 10,000 budget" in issue for issue in issues))
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "mame" / "index.html").is_file(), "site not built")
|
||||
def test_mame_is_under_it(self):
|
||||
page = validate_site._parse_page(DIST / "emulators" / "mame" / "index.html")
|
||||
self.assertLess(page.elements, validate_site.MAX_PAGE_ELEMENTS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
"""Entry pages and guides.
|
||||
|
||||
A handheld owner found no tile for Android, ArkOS, muOS, Onion or spruceOS:
|
||||
the systems sat in a closed note with a path seen from inside the device.
|
||||
The setup version a pack fits was named only under Reference, the setup page
|
||||
offered the full pack only, Get your files listed every setup twice, the Your
|
||||
setup index ended on a table in project words, and the home page said a
|
||||
Dreamcast needs its BIOS while Flycast boots without one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import siteguides # noqa: E402
|
||||
import sitemarkup # noqa: E402
|
||||
from common import load_platform_config, load_platform_registry # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
|
||||
GUIDE = siteguides.HANDHELD_GUIDE
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
sitepages.require()
|
||||
return sitepages.page(name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _guide_part(text: str) -> str:
|
||||
"""The guide part of a setup page, glossary marks taken off."""
|
||||
match = re.search(r"^## Reference\b", text, re.M)
|
||||
text = text[: match.start()] if match else text
|
||||
return re.sub(r"</?abbr[^>]*>", "", text)
|
||||
|
||||
|
||||
def _display(key: str) -> str:
|
||||
return load_platform_config(key, str(REPO_ROOT / "platforms")).get("platform", key)
|
||||
|
||||
|
||||
class HandheldWayIn(unittest.TestCase):
|
||||
def test_android_and_linux_tiles_on_home_and_get_your_files(self):
|
||||
tiles = siteguides.GROUP_TILES.get("handheld", ())
|
||||
self.assertEqual([tile.name for tile in tiles],
|
||||
["Android", "Linux handhelds", "iPhone and iPad"])
|
||||
for name in ("index.md", "which-pack.md"):
|
||||
text = _page(name)
|
||||
with self.subTest(page=name):
|
||||
self.assertIn('href="/retrobios/platforms/retroarch/#where-the-files-go"', text)
|
||||
self.assertIn('href="/retrobios/platforms/linux-handhelds/"', text)
|
||||
self.assertIn(">Retroid, AYN Odin and other Android handhelds</span>", text)
|
||||
|
||||
def test_the_closed_note_is_gone(self):
|
||||
self.assertNotIn("Other handhelds that run RetroArch", _page("which-pack.md"))
|
||||
|
||||
def test_one_row_per_detected_system(self):
|
||||
detected = [name for name, _folder in siteguides.detected_systems(REGISTRY, GUIDE.uses)]
|
||||
self.assertEqual(sorted((s.name for s in GUIDE.systems), key=str.casefold), detected)
|
||||
text = _page(GUIDE.path)
|
||||
for system in GUIDE.systems:
|
||||
with self.subTest(system=system.name):
|
||||
self.assertIn(f"| {system.name} |", text)
|
||||
|
||||
def test_every_folder_and_device_list_is_cited(self):
|
||||
for system in GUIDE.systems:
|
||||
with self.subTest(system=system.name):
|
||||
if system.folder:
|
||||
self.assertTrue(system.folder_source.startswith("https://"))
|
||||
if system.devices:
|
||||
self.assertTrue(system.devices_source.startswith("https://"))
|
||||
for name in ("ArkOS", "muOS", "Onion", "spruceOS"):
|
||||
system = next(s for s in GUIDE.systems if s.name == name)
|
||||
with self.subTest(system=name):
|
||||
self.assertTrue(system.folder and system.devices)
|
||||
|
||||
def test_a_value_without_its_page_is_refused(self):
|
||||
with self.assertRaises(siteguides.GuideError):
|
||||
siteguides._handheld_system({"name": "Onion", "folder": "`BIOS`"}, "x")
|
||||
with self.assertRaises(siteguides.GuideError):
|
||||
siteguides._handheld_system(
|
||||
{"name": "Onion", "devices": ["Miyoo Mini"], "devices_source": "a blog"}, "x")
|
||||
|
||||
def test_the_guide_rejects_a_missing_or_unknown_system(self):
|
||||
data = {"page": "x", "title": "X", "summary": "S", "uses": "retroarch",
|
||||
"systems": [{"name": "Onion"}]}
|
||||
with self.assertRaises(siteguides.GuideError):
|
||||
siteguides._handheld_guide(data, REGISTRY, siteguides.GUIDES, "x")
|
||||
|
||||
def test_devices_are_a_search_field_with_no_text(self):
|
||||
text = _page(GUIDE.path)
|
||||
match = re.search(r'<span data-pagefind-meta="devices:([^"]*)"></span>', text)
|
||||
self.assertIsNotNone(match)
|
||||
self.assertIn("R36S", match[1])
|
||||
self.assertIn("Miyoo Mini", match[1])
|
||||
self.assertNotIn(",", match[1])
|
||||
# The names typed most often are also on the page itself.
|
||||
self.assertIn("R36S", text.split(match[0], 1)[1])
|
||||
|
||||
def test_the_guide_is_in_the_handheld_group_of_the_navigation(self):
|
||||
coverages = {key: {"platform": _display(key)} for key in REGISTRY}
|
||||
nav = generate_site.generate_nav(coverages, {}, {}, REGISTRY)
|
||||
setup = next(tab["Your setup"] for tab in nav if "Your setup" in tab)
|
||||
groups = {next(iter(item)): next(iter(item.values()))
|
||||
for item in setup if isinstance(item, dict)}
|
||||
label = siteguides.DEVICE_GROUPS["handheld"][0]
|
||||
self.assertIn({GUIDE.title: GUIDE.path}, groups[label])
|
||||
|
||||
def test_the_guide_fills_the_card_and_never_its_root(self):
|
||||
text = _page(GUIDE.path)
|
||||
self.assertIn("--platform retroarch --dest E:\\BIOS", text)
|
||||
self.assertNotRegex(text, r"--dest E:\\\s")
|
||||
|
||||
|
||||
class PackNamesItsVersion(unittest.TestCase):
|
||||
def test_the_line_under_the_release(self):
|
||||
record = release_record.load_record()
|
||||
if not release_record.pack_for("batocera", record):
|
||||
self.skipTest("release.json has no Batocera pack")
|
||||
block = generate_site._pack_buttons("batocera")
|
||||
version = generate_site._pack_version(release_record.pack_name_for("batocera", record))
|
||||
self.assertIn(f"Made for Batocera {version}. "
|
||||
+ generate_site.OTHER_VERSION, block)
|
||||
|
||||
def test_a_version_gap_is_said_once(self):
|
||||
record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
|
||||
"assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
|
||||
released = generate_site.release_line("retrobat", record)
|
||||
line = generate_site.version_line("retrobat", record, named=len(released) > 1)
|
||||
self.assertEqual(line, generate_site.OTHER_VERSION)
|
||||
|
||||
def test_the_v_prefix_is_dropped(self):
|
||||
self.assertEqual(generate_site.setup_version("retroarch", {}),
|
||||
f"RetroArch {load_platform_config('retroarch', 'platforms')['version'].lstrip('v')}")
|
||||
|
||||
def test_every_setup_page_names_a_version(self):
|
||||
"""A setup with a release ZIP names the version it was made for. One
|
||||
without says there is no ZIP: the command installs current files."""
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
text = _guide_part(_page(f"platforms/{key}.md"))
|
||||
if generate_site.has_release_pack(key):
|
||||
self.assertRegex(text, r"(?:[Mm]ade for|This pack was built for) \S")
|
||||
else:
|
||||
self.assertIn(generate_site.no_zip_line(_display(key)), text)
|
||||
|
||||
|
||||
class SetupPagesOfferPartOfThePack(unittest.TestCase):
|
||||
def test_pick_them_and_press_c(self):
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
text = _guide_part(_page(f"platforms/{key}.md"))
|
||||
self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text)
|
||||
# The screen's own row: the line menu's c is not what the
|
||||
# one-liner opens.
|
||||
self.assertIn("or the command's **Choose systems**.", text)
|
||||
# Never "choose **Choose systems**".
|
||||
self.assertNotIn("choose **Choose systems**", text)
|
||||
|
||||
def test_the_anchor_exists(self):
|
||||
self.assertIn("## Only some consoles?", _page("which-pack.md"))
|
||||
|
||||
def test_a_file_in_place_but_missing_is_answered(self):
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
self.assertIn("Reported missing anyway?",
|
||||
_guide_part(_page(f"platforms/{key}.md")))
|
||||
|
||||
|
||||
class SafetyListIsLinked(unittest.TestCase):
|
||||
def test_setup_pages_link_the_home_list(self):
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
text = _guide_part(_page(f"platforms/{key}.md"))
|
||||
self.assertIn("[Is it safe?](/retrobios/#is-it-safe)", text)
|
||||
self.assertNotIn("<summary>Is it safe?</summary>", text)
|
||||
self.assertIn("## Is it safe? { #is-it-safe }", _page("index.md"))
|
||||
|
||||
|
||||
class OfflineLine(unittest.TestCase):
|
||||
def test_under_from_your_computer(self):
|
||||
text = _page("platforms/batocera.md")
|
||||
start = text.index("## From your computer")
|
||||
line = text.index("No internet on the device?")
|
||||
self.assertLess(start, line)
|
||||
self.assertLess(line, text.index("## On the device"))
|
||||
self.assertIn("/retrobios/wiki/installer/#without-a-network-offline-install", text)
|
||||
self.assertIn("{ #without-a-network-offline-install }",
|
||||
(REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class GetYourFilesListsEachSetupOnce(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.text = _page("which-pack.md")
|
||||
|
||||
def test_the_tables_are_in_one_closed_block(self):
|
||||
block = re.search(r'<details class="rb-details[^"]*">\s*<summary>Every folder and ZIP '
|
||||
r'in one table</summary>(.*?)</details>', self.text, re.S)
|
||||
self.assertIsNotNone(block)
|
||||
outside = self.text.replace(block[0], "")
|
||||
self.assertNotIn("| Setup | Runs on | BIOS folder | ZIP |", outside)
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
self.assertEqual(block[1].count(f"| [{_display(key)}](/retrobios/platforms/{key}/) |"), 1)
|
||||
|
||||
def test_single_setup_groups_share_one_table(self):
|
||||
self.assertIn("**Other setups**", self.text)
|
||||
self.assertNotIn("**MiSTer FPGA**", self.text)
|
||||
self.assertNotIn("**Self-hosted game library**", self.text)
|
||||
|
||||
def test_no_unzip_into_without_a_zip(self):
|
||||
record = release_record.load_record()
|
||||
for line in self.text.splitlines():
|
||||
if "Unzip into" in line:
|
||||
key = re.search(r"/retrobios/platforms/([a-z0-9]+)/", line)[1]
|
||||
with self.subTest(platform=key):
|
||||
self.assertTrue(release_record.pack_for(key, record))
|
||||
|
||||
def test_parts_are_buttons_with_the_size_after(self):
|
||||
"""A row links the setup's get/ page, which lists every part, with the
|
||||
download size after the button."""
|
||||
rows = [line for line in self.text.splitlines() if ">ZIP</a>" in line]
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
self.assertRegex(row, r'<a class="rb-button" href="/retrobios/get/[a-z0-9]+/">ZIP</a>')
|
||||
self.assertRegex(row, r"</a> \(\d[\d.]* [KMG]B\) \|$")
|
||||
|
||||
def test_the_zip_note(self):
|
||||
text = re.sub(r"</?abbr[^>]*>", "", self.text)
|
||||
for line in ("A big ZIP comes in parts, like `Pack.part1of2.zip`. Download every part.",
|
||||
"Leave the ZIPs inside, like `neogeo.zip`, as they are.",
|
||||
"Their setup's page says where to unzip them."):
|
||||
self.assertIn(line, text)
|
||||
self.assertNotIn("**Unzip into** means", self.text)
|
||||
|
||||
|
||||
class YourSetupIndexInPlainWords(unittest.TestCase):
|
||||
def test_the_index_links_the_table(self):
|
||||
text = _page("platforms/index.md")
|
||||
self.assertNotIn("| Platform |", text)
|
||||
self.assertNotIn("at a glance", text.split("---", 2)[-1].lower().replace(
|
||||
"every setup at a glance", ""))
|
||||
self.assertIn("/retrobios/wiki/verification-modes/#every-setup-at-a-glance", text)
|
||||
|
||||
def test_the_table_on_verification_modes(self):
|
||||
text = _page("wiki/verification-modes.md")
|
||||
self.assertIn("| Setup | Files on its own list | Files in the ZIP | How it checks |", text)
|
||||
table = text.split("## Every setup at a glance", 1)[1].split("\n## ", 1)[0]
|
||||
self.assertNotIn("existence", table)
|
||||
self.assertIn("| name only |", table)
|
||||
self.assertIn("No list of its own", table)
|
||||
self.assertLess(text.index("## Every setup at a glance"), text.index("## Next steps"))
|
||||
|
||||
def test_insertion_keeps_next_steps_last(self):
|
||||
page = "# T\n\nBody.\n\n## Next steps\n\n- [A](a.md)\n"
|
||||
out = generate_site.with_setup_table(page, {}, {})
|
||||
self.assertTrue(out.rstrip().endswith("- [A](a.md)"))
|
||||
self.assertIn("## Every setup at a glance", out)
|
||||
|
||||
|
||||
class HomeCopy(unittest.TestCase):
|
||||
def test_dreamcast_and_the_check_link(self):
|
||||
home = re.sub(r"</?abbr[^>]*>", "", _page("index.md"))
|
||||
self.assertIn("- **Yes** for PlayStation 1 and 2, Saturn and Neo Geo.", home)
|
||||
self.assertIn("- **Better with it** for Dreamcast: some games need it.", home)
|
||||
# A question and the link that answers it: no level label, which
|
||||
# read as a broken crumb beside the Reference tab.
|
||||
self.assertNotIn("Reference:", home.split("\n---\n", 1)[-1])
|
||||
self.assertIn("- Have a file? [Check it]", home)
|
||||
self.assertIn("BIOS missing, even with the file there?", home)
|
||||
|
||||
def test_flycast_boots_without_the_dreamcast_bios(self):
|
||||
import yaml
|
||||
|
||||
profile = yaml.safe_load((REPO_ROOT / "emulators" / "flycast.yml").read_text(encoding="utf-8"))
|
||||
boot = next(f for f in profile["files"] if f.get("name") == "dc_boot.bin")
|
||||
self.assertFalse(boot.get("required"))
|
||||
self.assertTrue(boot.get("hle_fallback"))
|
||||
|
||||
|
||||
class TileLogosLoadWhenSeen(unittest.TestCase):
|
||||
def test_the_helper(self):
|
||||
tiles = [(f"S{i}", "x.md", f"{i}.png", "here") for i in range(4)]
|
||||
html = "".join(sitemarkup.platform_tiles(tiles, eager=2))
|
||||
logos = re.findall(r"<img [^>]*>", html)
|
||||
self.assertEqual(['loading="lazy"' in logo for logo in logos], [False, False, True, True])
|
||||
self.assertNotIn("loading=", "".join(sitemarkup.platform_tiles(tiles[:1], eager=1)))
|
||||
|
||||
def test_only_the_first_row_is_eager_on_entry_pages(self):
|
||||
for name in ("index.md", "which-pack.md"):
|
||||
logos = re.findall(r'<img class="rb-card-logo"[^>]*>', _page(name))
|
||||
with self.subTest(page=name):
|
||||
self.assertGreater(len(logos), sitemarkup.FIRST_ROW_TILES)
|
||||
eager = [logo for logo in logos if 'loading="lazy"' not in logo]
|
||||
self.assertEqual(eager, logos[: sitemarkup.FIRST_ROW_TILES])
|
||||
|
||||
|
||||
class DownloadLine(unittest.TestCase):
|
||||
def test_checksums_and_signature_read_as_two(self):
|
||||
record = release_record.load_record()
|
||||
if not release_record.pack_for("batocera", record):
|
||||
self.skipTest("release.json has no Batocera pack")
|
||||
line = generate_site.release_line("batocera", record)[0]
|
||||
self.assertRegex(line, r"\[SHA256SUMS\.txt\]\([^)]+\) and its \[signature\]\([^)]+\)\.$")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,252 +0,0 @@
|
||||
"""Navigation and search.
|
||||
|
||||
"playstation bios" found five setups' lists for the console before its own
|
||||
page: the pages a setup's list moves a console to weighed 1.5, three times a
|
||||
reference page. The file names a system page holds were the text of a hidden
|
||||
span, so the search showed them as the excerpt. Start here looped: First
|
||||
install's "Next steps" pointed back to Get your files, Is it legal? to Common
|
||||
questions, and both pages had the same page as previous and next. A part
|
||||
page had no crumb to the page it belongs to and nothing marked in the
|
||||
sidebar. "psx" or "dc" found nothing, though every platform files the
|
||||
console under that name. These tests read the generator and, where they
|
||||
are built, the pages and the site.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import sitestarlight # noqa: E402
|
||||
from common import list_registered_platforms # noqa: E402
|
||||
|
||||
SEARCH_GATE = REPO_ROOT / "website" / "tools" / "search_gate.py"
|
||||
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
ROUTE = REPO_ROOT / "website" / "src" / "routeData.ts"
|
||||
CONTENT_CONFIG = REPO_ROOT / "website" / "src" / "content.config.ts"
|
||||
START_HERE = [
|
||||
"/retrobios/", "/retrobios/wiki/what-are-bios-files/", "/retrobios/which-pack/",
|
||||
"/retrobios/wiki/first-install/", "/retrobios/wiki/troubleshooting/",
|
||||
"/retrobios/wiki/faq/", "/retrobios/wiki/legal/", "/retrobios/wiki/glossary/",
|
||||
"/retrobios/platforms/",
|
||||
]
|
||||
|
||||
|
||||
def _front(text: str) -> str:
|
||||
end = text.find("\n---\n", 4)
|
||||
return text[4:end] if text.startswith("---\n") and end != -1 else ""
|
||||
|
||||
|
||||
def _moved_platform_pages() -> list[Path]:
|
||||
platforms = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||
folder = DOCS / "platforms"
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
# A guide page sits beside the setups (platforms/linux-handhelds) without
|
||||
# being a list moved off one of them.
|
||||
from siteguides import HANDHELD_GUIDE # noqa: PLC0415
|
||||
|
||||
guides = {Path(HANDHELD_GUIDE.path).stem} if HANDHELD_GUIDE else set()
|
||||
return [path for path in sorted(folder.glob("*.md*"))
|
||||
if path.stem not in platforms and path.stem != "index" and path.stem not in guides]
|
||||
|
||||
|
||||
class MetaFields(unittest.TestCase):
|
||||
def test_file_names_are_an_attribute_not_text(self) -> None:
|
||||
body = ('<h3 class="rb-file-title"><code>scph5501.bin</code></h3>'
|
||||
'<h3 class="rb-file-title"><code>a,b.bin</code></h3>')
|
||||
meta = sitestarlight.file_names_meta(body)
|
||||
# Each name as its parts, each part once.
|
||||
self.assertIn('data-pagefind-meta="files:a b bin scph5501"', meta)
|
||||
self.assertRegex(meta, r"<span [^>]*hidden></span>")
|
||||
|
||||
def test_a_field_escapes_its_value(self) -> None:
|
||||
meta = sitestarlight.search_field("aliases", ['a"b', "c&d"])
|
||||
self.assertIn('data-pagefind-meta="aliases:a"b c&d"', meta)
|
||||
|
||||
def test_an_empty_field_writes_nothing(self) -> None:
|
||||
self.assertEqual(sitestarlight.search_field("aliases", []), "")
|
||||
self.assertEqual(sitestarlight.file_names_meta("<p>no card</p>"), "")
|
||||
|
||||
def test_system_pages_carry_the_platforms_names(self) -> None:
|
||||
page = DOCS / "systems" / "sony-playstation.md"
|
||||
if not page.is_file():
|
||||
self.skipTest("site pages not generated")
|
||||
text = page.read_text(encoding="utf-8")
|
||||
match = re.search(r'data-pagefind-meta="aliases:([^"]*)"', text)
|
||||
self.assertIsNotNone(match, "no aliases field on the PlayStation page")
|
||||
self.assertIn("psx", unescape(match[1]).split())
|
||||
# The Dreamcast section's page: its own, or Sega's when it fits there.
|
||||
own = DOCS / "systems" / "sega-dreamcast.md"
|
||||
dreamcast = (own if own.is_file() else DOCS / "systems" / "sega.md").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertRegex(dreamcast, r'data-pagefind-meta="aliases:[^"]*\bdc\b')
|
||||
|
||||
|
||||
class SystemPartsSitUnderTheirMaker(unittest.TestCase):
|
||||
"""The crumbs say where a system part sits, read from its parent: field.
|
||||
The "Part of" line under them said it again."""
|
||||
|
||||
def test_every_part_names_its_maker_as_parent(self) -> None:
|
||||
pages = sorted((DOCS / "systems").glob("*.md"))
|
||||
if not pages:
|
||||
self.skipTest("site pages not generated")
|
||||
makers = {page.stem for page in pages}
|
||||
orphans = []
|
||||
for page in pages:
|
||||
text = page.read_text(encoding="utf-8")
|
||||
front = text.split("\n---\n", 1)[0]
|
||||
parent = re.search(r"^parent:\n label: .+\n link: /retrobios/systems/([a-z0-9-]+)/$",
|
||||
front, re.M)
|
||||
if parent is None:
|
||||
continue
|
||||
if parent[1] not in makers:
|
||||
orphans.append(page.name)
|
||||
self.assertNotIn("\nPart of [", text, page.name)
|
||||
self.assertEqual(orphans, [])
|
||||
|
||||
|
||||
class MovedPlatformPages(unittest.TestCase):
|
||||
def test_a_setups_list_for_one_console_is_a_reference(self) -> None:
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
block = source[source.index("for stem, page in moved.items():"):]
|
||||
block = block[: block.index("PAGE_DATA[f\"platforms/{name}.md\"]")]
|
||||
self.assertIn('SEARCH_DEMOTED.add(f"platforms/{stem}.md")', block)
|
||||
self.assertIn('level="reference"', block)
|
||||
self.assertIn('search="exclude"', block)
|
||||
self.assertIn("parent=", block)
|
||||
|
||||
def test_generated_pages_weigh_as_references_and_name_their_setup(self) -> None:
|
||||
pages = _moved_platform_pages()
|
||||
if not pages:
|
||||
self.skipTest("site pages not generated")
|
||||
for path in pages:
|
||||
front = _front(path.read_text(encoding="utf-8"))
|
||||
with self.subTest(page=path.name):
|
||||
self.assertIn("\nlevel: reference\n", f"\n{front}\n")
|
||||
self.assertIn("\nsearchWeight: 0.5\n", f"\n{front}\n")
|
||||
# The title alone was indexed, and it outranked the console.
|
||||
self.assertIn("\npagefind: false\n", f"\n{front}\n")
|
||||
self.assertRegex(front, r"parent:\n label: .+\n link: /retrobios/platforms/[a-z0-9_-]+/\n")
|
||||
|
||||
def test_parent_link_resolves_against_the_page(self) -> None:
|
||||
_target, text = sitestarlight.finalize_page(
|
||||
"platforms/retroarch-sony-playstation.md", "# RetroArch: Sony PlayStation\n\nBody.\n",
|
||||
level="reference", weight=0.5, browser_title="x", description="d",
|
||||
structured={}, terms={}, parent={"label": "RetroArch", "link": "retroarch.md"},
|
||||
)
|
||||
self.assertIn("parent:\n label: RetroArch\n link: /retrobios/platforms/retroarch/\n", text)
|
||||
|
||||
def test_the_schema_accepts_parent(self) -> None:
|
||||
self.assertRegex(CONTENT_CONFIG.read_text(encoding="utf-8"),
|
||||
r"parent: z\.object\(\{ label: z\.string\(\), link: z\.string\(\) \}\)")
|
||||
|
||||
|
||||
class ArcadeGroup(unittest.TestCase):
|
||||
def test_arcade_is_not_a_console_or_computer(self) -> None:
|
||||
nav = generate_site._system_nav({"Sony": {}, "Arcade": {}, "Sega": {}})
|
||||
groups = {next(iter(entry)): entry[next(iter(entry))] for entry in nav if isinstance(entry, dict)}
|
||||
self.assertEqual(groups["Arcade"], [{"Arcade": "systems/arcade.md"}])
|
||||
self.assertNotIn({"Arcade": "systems/arcade.md"}, groups["Consoles and computers"])
|
||||
|
||||
|
||||
class RouteMiddleware(unittest.TestCase):
|
||||
def test_next_steps_link_is_kept_only_forward(self) -> None:
|
||||
source = ROUTE.read_text(encoding="utf-8")
|
||||
self.assertIn("target <= position", source)
|
||||
|
||||
def test_parent_is_a_crumb_and_marked(self) -> None:
|
||||
source = ROUTE.read_text(encoding="utf-8")
|
||||
self.assertIn("route.entry.data.parent", source)
|
||||
self.assertIn("markCurrent(route.sidebar", source)
|
||||
|
||||
|
||||
def _gate_queries() -> dict:
|
||||
"""The search gate's QUERIES, read from its source: the tool is run
|
||||
with node beside it, not imported as a package."""
|
||||
tree = ast.parse(SEARCH_GATE.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "QUERIES":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("no QUERIES in search_gate.py")
|
||||
|
||||
|
||||
class SearchGateQueries(unittest.TestCase):
|
||||
def test_the_audits_queries_are_gated(self) -> None:
|
||||
for query in ("playstation bios", "dreamcast bios", "neo geo bios", "bios not found",
|
||||
"offline install", "r36s", "wonderswan color", "dc bios missing",
|
||||
"which mame version", "psx bios"):
|
||||
self.assertIn(query, _gate_queries())
|
||||
|
||||
def test_prefixes_match_a_consoles_own_page(self) -> None:
|
||||
pages, _rank = _gate_queries()["playstation bios"]
|
||||
self.assertTrue(any("systems/sony-playstation/".startswith(p) for p in pages))
|
||||
|
||||
|
||||
def _html(url: str) -> str:
|
||||
rel = url[len("/retrobios/"):]
|
||||
path = DIST / rel / "index.html"
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
|
||||
def _pagination(text: str) -> tuple[str | None, str | None]:
|
||||
start = text.find('<div class="pagination-links')
|
||||
if start == -1:
|
||||
return None, None
|
||||
links = text[start:text.find("</footer>", start)]
|
||||
prev = re.search(r'<a href="([^"]+)"[^>]*rel="prev"', links)
|
||||
nxt = re.search(r'<a href="([^"]+)"[^>]*rel="next"', links)
|
||||
return (prev[1] if prev else None), (nxt[1] if nxt else None)
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
class BuiltSite(unittest.TestCase):
|
||||
def test_next_from_home_reads_start_here_once(self) -> None:
|
||||
seen, url = [], "/retrobios/"
|
||||
while url and url not in seen and len(seen) < 20:
|
||||
seen.append(url)
|
||||
url = _pagination(_html(url))[1]
|
||||
self.assertEqual(seen[: len(START_HERE)], START_HERE)
|
||||
self.assertEqual(len(seen), len(set(seen)))
|
||||
|
||||
def test_no_page_has_the_same_previous_and_next(self) -> None:
|
||||
same = []
|
||||
for path in DIST.rglob("index.html"):
|
||||
prev, nxt = _pagination(path.read_text(encoding="utf-8"))
|
||||
if prev and prev == nxt:
|
||||
same.append(str(path.relative_to(DIST)))
|
||||
self.assertEqual(same, [])
|
||||
|
||||
def test_search_fields_hold_no_text(self) -> None:
|
||||
texts = []
|
||||
for path in (DIST / "systems").rglob("index.html"):
|
||||
# The search fields are attributes on empty spans. The title
|
||||
# meta reads the visible h1 since round 4 item 2.4.
|
||||
for match in re.finditer(r'<(\w+)[^>]*data-pagefind-meta="(?:files|aliases|devices|consoles|terms):'
|
||||
r'[^>]*>([^<]*)</\1>',
|
||||
path.read_text(encoding="utf-8")):
|
||||
if match[2].strip():
|
||||
texts.append(str(path.relative_to(DIST)))
|
||||
self.assertEqual(texts, [])
|
||||
|
||||
def test_a_setups_console_page_names_its_setup(self) -> None:
|
||||
text = _html("/retrobios/platforms/retroarch-sony-playstation/")
|
||||
if not text:
|
||||
self.skipTest("no RetroArch PlayStation page")
|
||||
crumbs = re.search(r'<nav[^>]*class="[^"]*rb-breadcrumbs[^"]*"[^>]*>(.*?)</nav>', text, re.S)
|
||||
self.assertIsNotNone(crumbs)
|
||||
self.assertIn('href="/retrobios/platforms/retroarch/"', crumbs[1])
|
||||
self.assertRegex(text, r'<a href="/retrobios/platforms/retroarch/"[^>]*aria-current="page"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,505 +0,0 @@
|
||||
"""Reference pages and data.
|
||||
|
||||
The CSV and SQLite dropped zipped_file, so a check built from them judged
|
||||
106 good Batocera files wrong. The manifest a pack installs was nowhere on
|
||||
the site. Contracts failed their own schema, 716 documents lacked `commit`,
|
||||
no endpoint answered a system id or a buildbot core name, lookup URLs
|
||||
resolved to 404s, the data's licence went unstated, platform overviews
|
||||
named emulators as unlinked ids, gaps tables overflowed with full hashes
|
||||
and a source link went into a repository that does not hold the file.
|
||||
Tests that read the built site skip when it has not been generated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import check_platform_folder # noqa: E402
|
||||
import generate_site # noqa: E402
|
||||
import sitesources # noqa: E402
|
||||
|
||||
PUBLIC = REPO_ROOT / "website" / "public"
|
||||
API = PUBLIC / "api" / "v1"
|
||||
SITE = "https://abdess.github.io/retrobios/"
|
||||
TMP = REPO_ROOT / "tmp"
|
||||
|
||||
|
||||
def _built(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
raise unittest.SkipTest(f"{path.relative_to(REPO_ROOT)} not generated")
|
||||
return path
|
||||
|
||||
|
||||
def _verify_status(entry: dict, path: Path, mode: str) -> str:
|
||||
"""What verify.py says of one entry, the platform's native logic."""
|
||||
from verify import verify_entry_existence, verify_entry_md5, verify_entry_sha1
|
||||
|
||||
local = str(path) if path.is_file() else None
|
||||
if mode == "existence":
|
||||
return verify_entry_existence(entry, local)["status"]
|
||||
if mode == "sha1":
|
||||
return verify_entry_sha1(entry, local)["status"]
|
||||
return verify_entry_md5(entry, local)["status"]
|
||||
|
||||
|
||||
def _as_snippet(status: str) -> str:
|
||||
return {"ok": "ok", "untested": "wrong", "missing": "missing"}[status]
|
||||
|
||||
|
||||
class TheExportsKeepWhatACheckNeeds(unittest.TestCase):
|
||||
"""8.1: zipped_file and the Recalbox flags reach the CSV and SQLite."""
|
||||
|
||||
COVERAGE = {"batocera": {
|
||||
"platform": "Batocera", "details": [],
|
||||
"config": {"platform": "Batocera", "verification_mode": "md5",
|
||||
"install": {"detect": []}, "logo": "x.svg", "_key": "batocera",
|
||||
"systems": {"bbc": {"files": [
|
||||
{"name": "bbcb.zip", "destination": "bbcb.zip",
|
||||
"md5": "521a197d", "zipped_file": "basic1.rom"},
|
||||
{"name": "n.rom", "destination": "n.rom", "md5": "ab",
|
||||
"hash_match_mandatory": True, "mandatory_declared": False,
|
||||
"shared_group": "group-x"},
|
||||
]}}},
|
||||
}}
|
||||
|
||||
def test_rows_carry_the_member_and_the_flags(self):
|
||||
_items, rows = generate_site._platform_export_rows(self.COVERAGE)
|
||||
zipped, flagged = rows
|
||||
self.assertEqual(zipped["zipped_file"], "basic1.rom")
|
||||
self.assertIs(flagged["hash_match_mandatory"], True)
|
||||
self.assertIs(flagged["mandatory_declared"], False)
|
||||
self.assertEqual(flagged["shared_group"], "group-x")
|
||||
self.assertEqual(flagged["zipped_file"], "")
|
||||
|
||||
def test_the_csv_header_and_the_table_name_them(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn('"collection_path", "resolved_sha1", *ENTRY_EXPORT_FIELDS,', source)
|
||||
for field in generate_site.ENTRY_EXPORT_FIELDS:
|
||||
self.assertIn(f":{field}", source)
|
||||
for field in ("zipped_file", "hash_match_mandatory", "mandatory_declared", "curated"):
|
||||
self.assertIn(field, generate_site.ENTRY_EXPORT_FIELDS)
|
||||
|
||||
def test_the_built_csv_has_the_column(self):
|
||||
text = _built(PUBLIC / "downloads" / "platform-files.csv").read_text(encoding="utf-8")
|
||||
rows = list(csv.DictReader(io.StringIO(text)))
|
||||
batocera = [row for row in rows if row["platform_id"] == "batocera"]
|
||||
self.assertGreater(sum(1 for row in batocera if row["zipped_file"]), 300)
|
||||
with sqlite3.connect(_built(PUBLIC / "downloads" / "retrobios.sqlite")) as db:
|
||||
count = db.execute("SELECT count(*) FROM platform_files "
|
||||
"WHERE platform_id = 'batocera' AND zipped_file != ''").fetchone()[0]
|
||||
license_row = db.execute("SELECT value FROM metadata WHERE key = 'license'").fetchone()
|
||||
self.assertGreater(count, 300)
|
||||
self.assertEqual(license_row[0], generate_site.DATA_LICENSE)
|
||||
|
||||
|
||||
class AFolderCheckAgreesWithTheVerifier(unittest.TestCase):
|
||||
"""8.1 and 8.3: the published example gives verify.py's verdicts."""
|
||||
|
||||
def _folder(self, tmp: Path) -> tuple[dict, Path]:
|
||||
member = b"basic rom bytes"
|
||||
root = tmp / "bios"
|
||||
root.mkdir()
|
||||
with zipfile.ZipFile(root / "bbcb.zip", "w") as archive:
|
||||
archive.writestr("BASIC1.ROM", member)
|
||||
(root / "plain.bin").write_bytes(b"plain")
|
||||
(root / "wrong.bin").write_bytes(b"not it")
|
||||
(root / "nohash.bin").write_bytes(b"x")
|
||||
md5 = lambda data: hashlib.md5(data).hexdigest() # noqa: E731
|
||||
contract = {"verification_mode": "md5", "systems": {"s": {"files": [
|
||||
{"name": "bbcb.zip", "destination": "bbcb.zip", "md5": md5(member),
|
||||
"zipped_file": "basic1.rom"},
|
||||
{"name": "bbcb.zip", "destination": "bbcb.zip", "md5": "0" * 32,
|
||||
"zipped_file": "basic1.rom"},
|
||||
{"name": "plain.bin", "destination": "plain.bin", "md5": md5(b"plain")[:29]},
|
||||
{"name": "wrong.bin", "destination": "wrong.bin", "md5": md5(b"plain")},
|
||||
{"name": "nohash.bin", "destination": "nohash.bin"},
|
||||
{"name": "gone.bin", "destination": "gone.bin", "md5": "1" * 32},
|
||||
]}}}
|
||||
return contract, root
|
||||
|
||||
def test_each_rule_on_a_sample_folder(self):
|
||||
with tempfile.TemporaryDirectory(dir=TMP) as tmp:
|
||||
contract, root = self._folder(Path(tmp))
|
||||
platforms = Path(tmp) / "platforms"
|
||||
platforms.mkdir()
|
||||
(platforms / "sample.json").write_text(
|
||||
json.dumps({"items": [{"contract": contract}]}), encoding="utf-8")
|
||||
found = check_platform_folder.check("sample", root, platforms.as_uri() + "/")
|
||||
expected = [
|
||||
(entry["destination"], _as_snippet(_verify_status(entry, root / entry["destination"], "md5")))
|
||||
for entry in contract["systems"]["s"]["files"]
|
||||
]
|
||||
self.assertEqual(found, expected)
|
||||
self.assertEqual([v for _d, v in found], ["ok", "wrong", "ok", "wrong", "ok", "missing"])
|
||||
|
||||
def test_batocera_from_the_csv_matches_the_verifier(self):
|
||||
"""A check driven by platform-files.csv, with zipped_file, gives the
|
||||
verifier's verdict on every Batocera entry of a folder filled from
|
||||
the collection."""
|
||||
text = _built(PUBLIC / "downloads" / "platform-files.csv").read_text(encoding="utf-8")
|
||||
rows = [row for row in csv.DictReader(io.StringIO(text)) if row["platform_id"] == "batocera"]
|
||||
with tempfile.TemporaryDirectory(dir=TMP) as tmp:
|
||||
root = Path(tmp)
|
||||
for row in rows:
|
||||
source = REPO_ROOT / row["collection_path"] if row["collection_path"] else None
|
||||
target = root / row["destination"]
|
||||
if source and source.is_file() and not target.exists():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.link(source, target)
|
||||
except OSError:
|
||||
shutil.copyfile(source, target)
|
||||
held = [row for row in rows if (root / row["destination"]).is_file()]
|
||||
if len(held) < 100:
|
||||
raise unittest.SkipTest("the collection files are not in this checkout")
|
||||
# The verifier reads the platform's own entries, the check the CSV.
|
||||
contract = json.loads(_built(API / "platforms" / "batocera.json").read_text())[
|
||||
"items"][0]["contract"]
|
||||
declared = [entry for _sys, system in sorted(contract["systems"].items())
|
||||
for entry in system.get("files") or []]
|
||||
self.assertEqual([e["name"] for e in declared], [row["name"] for row in rows])
|
||||
mismatched = []
|
||||
zipped_ok = 0
|
||||
for row, platform_entry in zip(rows, declared):
|
||||
entry = {key: value for key, value in row.items() if value}
|
||||
path = root / row["destination"]
|
||||
ours = "ok" if check_platform_folder.accepts(entry, path, "md5") else (
|
||||
"wrong" if path.is_file() else "missing")
|
||||
theirs = _as_snippet(_verify_status(platform_entry, path, "md5"))
|
||||
if ours != theirs:
|
||||
mismatched.append((row["destination"], row.get("zipped_file"), ours, theirs))
|
||||
zipped_ok += ours == "ok" and bool(row["zipped_file"])
|
||||
self.assertEqual(mismatched, [])
|
||||
self.assertGreater(zipped_ok, 100)
|
||||
|
||||
def test_the_data_page_shows_the_script_itself(self):
|
||||
script = (REPO_ROOT / "scripts" / "check_platform_folder.py").read_text(encoding="utf-8")
|
||||
page = generate_site.generate_data_page([])
|
||||
self.assertIn("```python\n" + script.rstrip("\n") + "\n```", page)
|
||||
self.assertLess(len(script.splitlines()), 70)
|
||||
self.assertIn("## Check a folder for one platform", page)
|
||||
|
||||
|
||||
class TheContractsValidate(unittest.TestCase):
|
||||
"""8.4: registry keys leave the contract, every $id is its served URL."""
|
||||
|
||||
def test_every_contract_validates_against_the_platform_schema(self):
|
||||
try:
|
||||
from jsonschema import Draft202012Validator # noqa: PLC0415
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema is a tooling dependency")
|
||||
|
||||
from common import list_registered_platforms, load_platform_config # noqa: PLC0415
|
||||
|
||||
schema = json.loads((REPO_ROOT / "schemas" / "platform.schema.json").read_text())
|
||||
validator = Draft202012Validator(schema)
|
||||
platforms = str(REPO_ROOT / "platforms")
|
||||
for name in list_registered_platforms(platforms, include_archived=True):
|
||||
config = load_platform_config(name, platforms)
|
||||
contract, registry = generate_site._split_contract(config)
|
||||
with self.subTest(platform=name):
|
||||
errors = [error.message for error in validator.iter_errors(contract)]
|
||||
self.assertEqual(errors, [])
|
||||
self.assertIn("install", registry)
|
||||
self.assertNotIn("_key", registry)
|
||||
self.assertEqual(set(contract) | set(registry) | {"_key"}, set(config) | {"_key"})
|
||||
|
||||
def test_every_schema_id_is_the_address_it_is_served_at(self):
|
||||
for path in sorted((REPO_ROOT / "schemas").glob("*.schema.json")):
|
||||
schema = json.loads(path.read_text())
|
||||
with self.subTest(schema=path.name):
|
||||
self.assertEqual(schema.get("$id"), f"{SITE}api/v1/schemas/{path.name}")
|
||||
|
||||
def test_every_schema_is_published(self):
|
||||
published = {p.name for p in _built(API / "schemas").glob("*.json")}
|
||||
self.assertEqual(published, {p.name for p in (REPO_ROOT / "schemas").glob("*.schema.json")})
|
||||
|
||||
def test_the_built_api_validates(self):
|
||||
try:
|
||||
from jsonschema import Draft202012Validator # noqa: PLC0415
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema is a tooling dependency")
|
||||
|
||||
schemas = {p.name: json.loads(p.read_text()) for p in (REPO_ROOT / "schemas").glob("*.json")}
|
||||
envelope = Draft202012Validator(schemas["site-api-envelope.schema.json"])
|
||||
platform = Draft202012Validator(schemas["platform.schema.json"])
|
||||
document = json.loads(_built(API / "platforms.json").read_text())
|
||||
self.assertEqual(list(envelope.iter_errors(document)), [])
|
||||
for item in document["items"]:
|
||||
with self.subTest(platform=item["id"]):
|
||||
self.assertEqual([e.message for e in platform.iter_errors(item["contract"])], [])
|
||||
for name in ("systems.json", "cores.json", "index.json", "catalog.json"):
|
||||
with self.subTest(document=name):
|
||||
self.assertEqual(
|
||||
[e.message for e in envelope.iter_errors(json.loads((API / name).read_text()))], [])
|
||||
|
||||
|
||||
class EveryEnvelopeCarriesTheCommit(unittest.TestCase):
|
||||
"""8.5: emulators/ and systems/ lacked `commit` (716 documents)."""
|
||||
|
||||
def test_the_writers_pass_it(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn('str(db.get("generated_at") or _timestamp()), _source_commit(),\n )\n'
|
||||
" clash", source)
|
||||
block = source[source.index("public_profiles = {"):source.index("pages = emulator_pages(")]
|
||||
self.assertIn("write_single_item(", block)
|
||||
self.assertIn("_source_commit()", block)
|
||||
|
||||
def test_every_built_envelope_has_it(self):
|
||||
_built(API / "catalog.json")
|
||||
if not generate_site._source_commit():
|
||||
raise unittest.SkipTest("not a git checkout")
|
||||
lacking = [
|
||||
path.relative_to(API).as_posix() for path in sorted(API.rglob("*.json"))
|
||||
if path.parent.name not in ("schemas", "manifests", "targets")
|
||||
and path.name not in ("database.json", "stats.json", "release.json")
|
||||
and "commit" not in json.loads(path.read_text())
|
||||
]
|
||||
self.assertEqual(lacking, [])
|
||||
|
||||
|
||||
class ManifestsArePublished(unittest.TestCase):
|
||||
"""8.2: the manifest a pack installs, byte for byte, in the catalog."""
|
||||
|
||||
def test_each_manifest_is_the_repository_file(self):
|
||||
catalog = json.loads(_built(API / "catalog.json").read_text())
|
||||
listed = {item["url"]: item for item in catalog["items"]}
|
||||
for source in sorted((REPO_ROOT / "install").glob("*.json")):
|
||||
with self.subTest(manifest=source.name):
|
||||
item = listed[f"manifests/{source.name}"]
|
||||
self.assertEqual(item["sha256"], hashlib.sha256(source.read_bytes()).hexdigest())
|
||||
self.assertEqual(item["schema"], "schemas/install-manifest.schema.json")
|
||||
self.assertIn("release.json", listed)
|
||||
self.assertEqual(catalog["license"], generate_site.DATA_LICENSE)
|
||||
|
||||
def test_the_data_page_explains_the_fields_from_the_schema(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
schema = json.loads((REPO_ROOT / "schemas" / "install-manifest.schema.json").read_text())
|
||||
for field in ("dest", "repo_path", "release_asset", "cores"):
|
||||
description = schema["$defs"]["file"]["properties"][field]["description"]
|
||||
self.assertIn(f"| `{field}` | {generate_site._escape_tags(description)} |", page)
|
||||
self.assertIn("## Platform manifests", page)
|
||||
self.assertIn("**Licence:**", page)
|
||||
self.assertIn("## Hosting, rate limits and caching", page)
|
||||
# The endpoints first, the DAT below them.
|
||||
self.assertLess(page.index("## Stable JSON endpoints"), page.index("The DAT is Logiqx XML"))
|
||||
|
||||
|
||||
class AnIdLeadsToItsData(unittest.TestCase):
|
||||
"""8.6: a system by its id, a core by the name the buildbot gives it."""
|
||||
|
||||
def test_a_buildbot_name_designates_its_profile(self):
|
||||
profiles = {
|
||||
"beetle_psx": {"type": "libretro", "cores": ["mednafen_psx", "mednafen_psx_hw"]},
|
||||
"pcsx2": {"type": "standalone"},
|
||||
"lrps2": {"type": "libretro", "cores": ["pcsx2"]},
|
||||
"mednafen_psx_alias": {"type": "alias", "alias_of": "beetle_psx"},
|
||||
"probe": {"type": "test", "cores": ["probe_core"]},
|
||||
}
|
||||
items = generate_site.core_index_items(profiles)
|
||||
by_name: dict[str, list[tuple[str, bool]]] = {}
|
||||
for item in items:
|
||||
by_name.setdefault(item["name"], []).append((item["profile"], item["preferred"]))
|
||||
self.assertEqual(by_name["mednafen_psx"], [("beetle_psx", True)])
|
||||
self.assertEqual(by_name["pcsx2"], [("pcsx2", True), ("lrps2", False)])
|
||||
self.assertEqual(by_name["mednafen_psx_alias"], [("beetle_psx", True)])
|
||||
self.assertNotIn("probe_core", by_name)
|
||||
|
||||
def test_the_built_index_resolves_ids_to_sections(self):
|
||||
items = {item["id"]: item for item in
|
||||
json.loads(_built(API / "systems.json").read_text())["items"]}
|
||||
color = items["wonderswan-color"]
|
||||
self.assertEqual(color["name"], "Bandai WonderSwan Color")
|
||||
self.assertTrue(color["url"].startswith("/retrobios/systems/bandai/"), color["url"])
|
||||
self.assertTrue(items["sony-playstation"]["sha1"])
|
||||
cores = json.loads((API / "cores.json").read_text())["items"]
|
||||
self.assertIn({"name": "mednafen_psx", "profile": "beetle_psx", "preferred": True}, cores)
|
||||
|
||||
|
||||
class LookupUrlsResolve(unittest.TestCase):
|
||||
"""8.7: a record's url, joined to the shard holding it, is a page."""
|
||||
|
||||
def test_site_absolute(self):
|
||||
db = {"files": {"ab" * 20: {"path": "bios/Sony/PlayStation/scph5501.bin"}}}
|
||||
[item] = generate_site.file_location_items(db)
|
||||
self.assertTrue(item["url"].startswith("/retrobios/systems/"), item["url"])
|
||||
shard = SITE + "api/v1/lookup/ab.json"
|
||||
self.assertTrue(urllib.parse.urljoin(shard, item["url"]).startswith(SITE + "systems/"))
|
||||
|
||||
def test_each_record_names_its_system_as_the_pages_do(self):
|
||||
"""Find a file opens on "a BIOS file for Sony PlayStation": the name
|
||||
comes from system_name, never built in the browser."""
|
||||
from siterender import system_name # noqa: PLC0415
|
||||
|
||||
db = {"files": {"ab" * 20: {"path": "bios/Sony/PlayStation/scph5501.bin",
|
||||
"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246"}}}
|
||||
coverages = {"batocera": {"platform": "Batocera", "config": {"systems": {
|
||||
"sony-playstation": {"files": [{"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246"}]}}}}}
|
||||
profiles = {"duckstation": {"systems": ["sony-playstation"], "files": [
|
||||
{"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246"}]}}
|
||||
records = generate_site.file_lookup_records(
|
||||
db, coverages, profiles, generate_site.file_location_items(db))
|
||||
expected = system_name("sony-playstation")
|
||||
by_layer = {r["layer"]: r for r in records if r["algorithm"] == "md5"}
|
||||
for layer in ("collection", "platform", "emulator"):
|
||||
with self.subTest(layer=layer):
|
||||
self.assertEqual(by_layer[layer]["system_name"], expected)
|
||||
|
||||
def test_built_shards_carry_the_name(self):
|
||||
shard = json.loads(_built(API / "lookup" / "05.json").read_text())
|
||||
record = next(r for r in shard["items"] if r["value"].startswith("0555c6fae890"))
|
||||
self.assertEqual(record["system_name"], "Sony PlayStation")
|
||||
|
||||
def test_built_records_land_on_built_pages(self):
|
||||
shard = json.loads(_built(API / "lookup" / "05.json").read_text())
|
||||
dist = REPO_ROOT / "website" / "dist"
|
||||
for record in shard["items"][:200]:
|
||||
with self.subTest(url=record["url"]):
|
||||
self.assertTrue(record["url"].startswith("/retrobios/"))
|
||||
if dist.is_dir():
|
||||
page = urllib.parse.unquote(record["url"].split("#")[0].removeprefix("/retrobios/"))
|
||||
self.assertTrue((dist / page / "index.html").is_file(), page)
|
||||
|
||||
|
||||
class PlatformPagesLinkTheirSystemsAndEmulators(unittest.TestCase):
|
||||
"""8.9: the overview named emulators as ids, none of 1,463 linked."""
|
||||
|
||||
def test_an_emulator_is_named_and_linked(self):
|
||||
link = generate_site._emulator_named_link("beetle_psx", "../")
|
||||
self.assertRegex(link, r"^\[[^\]]+\]\(\.\./emulators/beetle_psx\.md\)$")
|
||||
self.assertNotEqual(link, "[beetle_psx](../emulators/beetle_psx.md)")
|
||||
|
||||
def test_the_overview_column_uses_it(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
block = source[source.index("### Systems overview"):source.index("_render_platform_systems(by_system")]
|
||||
self.assertIn("_emulator_named_link(e, \"../\")", block)
|
||||
self.assertNotIn('", ".join(sys_emus[:3])', block)
|
||||
|
||||
def test_a_moved_list_opens_on_its_console_and_names_its_parent(self):
|
||||
# Past PLATFORM_SECTION_BUDGET, so the list leaves for a page of its own.
|
||||
files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(2000)]
|
||||
moved: dict[str, str] = {}
|
||||
generate_site._render_platform_systems(
|
||||
{"sony-playstation": files}, {}, {}, ("retroarch", "RetroArch"), moved, {})
|
||||
self.assertTrue(moved)
|
||||
page = next(iter(moved.values()))
|
||||
self.assertTrue(page.startswith('---\nparent:\n label: "RetroArch"\n'
|
||||
' link: "/platforms/retroarch/"\n---\n'), page[:120])
|
||||
self.assertRegex(page, r"RetroArch's list for \[Sony PlayStation\]\(\.\./systems/")
|
||||
|
||||
def test_part_pages_of_gaps_and_cross_reference_name_their_parent(self):
|
||||
moved: dict[str, str] = {}
|
||||
generate_site._list_pages("gaps-x", "T", "intro", [], [["| a |"]], moved,
|
||||
parent=("What the packs do not hold", "/gaps/"))
|
||||
self.assertIn('parent:\n label: "What the packs do not hold"\n link: "/gaps/"', moved["gaps-x"])
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn('_parent_front_matter(("Cores by platform", "/cross-reference/"))', source)
|
||||
self.assertIn('parent=("Dump provenance", "/provenance/")', source)
|
||||
|
||||
|
||||
class TablesFitTheirColumn(unittest.TestCase):
|
||||
"""8.12 and 8.13: a hash in a cell is 12 characters, the rest in its title."""
|
||||
|
||||
def test_the_downloads_table(self):
|
||||
item = {"title": "SQLite snapshot", "url": "downloads/retrobios.sqlite",
|
||||
"media_type": "application/vnd.sqlite3", "bytes": 10, "sha256": "a" * 64}
|
||||
page = generate_site.generate_data_page([item])
|
||||
row = next(line for line in page.splitlines() if "SQLite snapshot" in line and "|" in line)
|
||||
self.assertIn(f'<code title="{"a" * 64}">{"a" * 12}</code>', row)
|
||||
self.assertNotIn("a" * 13, row.replace(f'title="{"a" * 64}"', ""))
|
||||
|
||||
def test_the_gaps_tables(self):
|
||||
problem = {"platform": "Batocera", "name": "x.bin", "status": "untested", "required": True,
|
||||
"reason": "expected md5 differs", "declared": {"md5": ["b" * 32]}, "path": ""}
|
||||
table = "\n".join(generate_site._render_problem_files([problem], db={"files": {}}))
|
||||
row = next(line for line in table.splitlines() if "x.bin" in line)
|
||||
self.assertIn(f'<code title="{"b" * 32}">{"b" * 12}</code>', row)
|
||||
self.assertIn('<span class="rb-reason">expected md5 differs</span>', row)
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn('expected = f"{algorithm} {short_hash(value)}"', source)
|
||||
self.assertIn("{_reason_cell(u['reason'])}", source)
|
||||
|
||||
|
||||
class SourceLinksGoWhereTheFileIs(unittest.TestCase):
|
||||
"""8.14: trident cited a Panda3DS path and linked it into 3dsTrident."""
|
||||
|
||||
PROFILE = {
|
||||
"emulator": "trident", "type": "libretro",
|
||||
"source": "https://github.com/DanAlexMorton/3dsTrident",
|
||||
"upstream": "https://github.com/wheremyfoodat/Panda3DS",
|
||||
"source_commit": "b" * 40,
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
sitesources._tree_paths.cache_clear()
|
||||
|
||||
def _render(self, tree: frozenset[str]) -> str:
|
||||
original = sitesources._tree_paths
|
||||
sitesources._tree_paths = lambda repo, pin: tree # type: ignore[assignment]
|
||||
try:
|
||||
return sitesources._source_ref_markdown(
|
||||
self.PROFILE, "src/emulator.cpp:234; src/core/loader/ncch.cpp:151-164")
|
||||
finally:
|
||||
sitesources._tree_paths = original
|
||||
|
||||
def test_a_path_the_tree_lacks_is_not_linked(self):
|
||||
text = self._render(frozenset({"src/emulator.cpp"}))
|
||||
self.assertIn("[`src/emulator.cpp:234`](https://github.com/DanAlexMorton/3dsTrident/", text)
|
||||
self.assertIn("`src/core/loader/ncch.cpp:151-164`", text)
|
||||
self.assertNotIn("3dsTrident/blob/" + "b" * 40 + "/src/core/loader", text)
|
||||
|
||||
def test_a_path_no_tree_confirmed_is_not_linked(self):
|
||||
"""Round 4: without a tree the declared repository took the path,
|
||||
and the deploy, which has no tree cache, linked 30 paths to a 404."""
|
||||
text = self._render(frozenset())
|
||||
self.assertNotIn("](https://", text)
|
||||
self.assertIn("`src/core/loader/ncch.cpp:151-164`", text)
|
||||
|
||||
def test_every_rendered_link_is_in_its_cached_tree(self):
|
||||
"""Offline: each link the pages render points at a path the cached
|
||||
tree of its repository holds."""
|
||||
if not sitesources.TREE_CACHE.is_dir():
|
||||
raise unittest.SkipTest("no cached tree listings")
|
||||
from common import load_emulator_profiles # noqa: PLC0415
|
||||
|
||||
link = re.compile(r"\(https://github\.com/([^/]+/[^/]+)/blob/([0-9a-f]{40})/([^#)]+)")
|
||||
checked, wrong = 0, []
|
||||
profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"), skip_aliases=False)
|
||||
for key, profile in sorted(profiles.items()):
|
||||
for entry in profile.get("files") or []:
|
||||
if not isinstance(entry, dict) or not entry.get("source_ref"):
|
||||
continue
|
||||
for slug, pin, path in link.findall(
|
||||
sitesources._source_ref_markdown(profile, entry["source_ref"])):
|
||||
import upstream # noqa: PLC0415
|
||||
|
||||
repo = upstream.parse_repo(f"https://github.com/{slug}")
|
||||
held = sitesources.tree_holds(repo, pin, urllib.parse.unquote(path))
|
||||
if held is None:
|
||||
continue
|
||||
checked += 1
|
||||
if not held:
|
||||
wrong.append((key, slug, path))
|
||||
if not checked:
|
||||
raise unittest.SkipTest("no rendered link has a cached tree")
|
||||
self.assertEqual(wrong, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,316 +0,0 @@
|
||||
"""System pages and file cards.
|
||||
|
||||
The DS section opened on five DSi files, the Game Boy Advance line printed
|
||||
BizHawk's file name where eight setups write another, the Neo Geo section
|
||||
opened on a file one emulator reads, no arcade card said which release its
|
||||
set matches, console headings carried an icon on 15 % of them, arcade boards
|
||||
sat under "Consoles and computers", and every system page shared one card.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import make_og_cards # noqa: E402
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
TMP = REPO_ROOT / "tmp"
|
||||
|
||||
|
||||
def _file(name: str, char: str, path: str) -> dict:
|
||||
return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 4096, "path": path}
|
||||
|
||||
|
||||
def _consumers(
|
||||
readers: dict[str, list[tuple[str, dict]]],
|
||||
db_files: list[dict] = (),
|
||||
destinations: dict[str, dict[str, set[str]]] | None = None,
|
||||
) -> types.SimpleNamespace:
|
||||
"""Consumers where each sha1 is read by the given (emulator, entry)."""
|
||||
files_of: dict[str, list] = {}
|
||||
entries: dict[str, dict[str, int]] = {}
|
||||
emulators: dict[str, set[str]] = {}
|
||||
for sha1, read_by in readers.items():
|
||||
for emulator, entry in read_by:
|
||||
files_of.setdefault(emulator, []).append(entry)
|
||||
entries.setdefault(sha1, {})[emulator] = len(files_of[emulator]) - 1
|
||||
emulators.setdefault(sha1, set()).add(emulator)
|
||||
return types.SimpleNamespace(
|
||||
platforms={}, emulators=emulators, emulator_entries=entries,
|
||||
emulator_files=files_of, installed={}, platform_systems={},
|
||||
platform_destinations=destinations or {},
|
||||
_db={"files": {f["sha1"]: f for f in db_files}},
|
||||
)
|
||||
|
||||
|
||||
DS = ("Nintendo", "Nintendo DS")
|
||||
|
||||
|
||||
class TheSectionStartsWithItsOwnConsole(unittest.TestCase):
|
||||
"""A file filed with the DS but read for the DSi is the DSi's."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
folder = "bios/Nintendo/Nintendo DS"
|
||||
self.files = [_file("NDS_Bios7.bin", "7", f"{folder}/NDS_Bios7.bin"),
|
||||
_file("NDS_Bios9.bin", "9", f"{folder}/NDS_Bios9.bin"),
|
||||
_file("dsi_bios7.bin", "d", f"{folder}/dsi_bios7.bin")]
|
||||
hle = {"required": False, "hle_fallback": True, "system": "nintendo-ds"}
|
||||
self.consumers = _consumers({
|
||||
"7" * 40: [("emu-a", {**hle, "name": "bios7.bin"}),
|
||||
("emu-b", {**hle, "name": "bios7.bin"})],
|
||||
"9" * 40: [("emu-a", {**hle, "name": "bios9.bin"})],
|
||||
"d" * 40: [("emu-a", {"name": "dsi_bios7.bin", "required": True,
|
||||
"system": "nintendo-dsi"})],
|
||||
})
|
||||
|
||||
def test_no_dsi_file_on_the_ds_line(self) -> None:
|
||||
text = "\n".join(gs._start_with(self.files, self.consumers, section=DS))
|
||||
self.assertNotIn("dsi", text)
|
||||
|
||||
def test_a_console_its_emulators_run_without_says_so(self) -> None:
|
||||
self.assertEqual(gs._start_with(self.files, self.consumers, section=DS), [
|
||||
"**No file needed** with emu-a or emu-b. For full accuracy: "
|
||||
"[`bios7.bin`](#sha1-777777777777), [`bios9.bin`](#sha1-999999999999).", ""])
|
||||
|
||||
def test_the_dsi_section_names_its_file(self) -> None:
|
||||
lines = gs._start_with(self.files, self.consumers, section=("Nintendo", "Nintendo DSi"))
|
||||
self.assertEqual(lines, ["**Start with**: [`dsi_bios7.bin`](#sha1-dddddddddddd).", ""])
|
||||
|
||||
def test_more_than_three_emulators_are_counted(self) -> None:
|
||||
hle = {"required": False, "hle_fallback": True, "system": "nintendo-ds"}
|
||||
consumers = _consumers({"7" * 40: [(f"emu-{n}", hle) for n in "abcd"]})
|
||||
self.assertEqual(gs._start_with(self.files[:1], consumers, section=DS)[0],
|
||||
"**No file needed**: none of its 4 emulators requires one. "
|
||||
"For full accuracy: [`NDS_Bios7.bin`](#sha1-777777777777).")
|
||||
|
||||
def test_a_shipped_file_is_no_start(self) -> None:
|
||||
consumers = _consumers({"7" * 40: [("emu-a", {"required": True, "bundled": True,
|
||||
"system": "nintendo-ds"})]})
|
||||
self.assertEqual(gs._start_with(self.files[:1], consumers, section=DS), [])
|
||||
|
||||
|
||||
class TheNameSetupsWrite(unittest.TestCase):
|
||||
def test_most_setups_win_over_the_collection_name(self) -> None:
|
||||
f = _file("GBA_bios.rom", "a", "bios/Nintendo/Game Boy Advance/GBA_bios.rom")
|
||||
consumers = _consumers(
|
||||
{"a" * 40: [("emu-a", {"name": "GBA_bios.rom", "required": True,
|
||||
"system": "nintendo-gba"})]},
|
||||
destinations={"a" * 40: {"p1": {"gba_bios.bin"}, "p2": {"bios/gba_bios.bin"},
|
||||
"p3": {"GBA_bios.rom"}}})
|
||||
self.assertEqual(gs._start_with([f], consumers, section=("Nintendo", "Game Boy Advance")),
|
||||
["**Start with**: [`gba_bios.bin`](#sha1-aaaaaaaaaaaa).", ""])
|
||||
|
||||
def test_no_setup_then_the_emulators_name(self) -> None:
|
||||
f = _file("x.rom", "b", "bios/Nintendo/Game Boy Advance/x.rom")
|
||||
consumers = _consumers({"b" * 40: [("emu-a", {"name": "gba_bios.bin", "required": True})]})
|
||||
self.assertEqual(gs._setup_name(f, consumers, [("emu-a", {"name": "gba_bios.bin"})]),
|
||||
"gba_bios.bin")
|
||||
|
||||
|
||||
class ArcadeSetsAreNamedWhereverFiled(unittest.TestCase):
|
||||
"""The Neo Geo BIOS is under Arcade: the Neo Geo section named
|
||||
aes.zip, read by one emulator, and never neogeo.zip."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.aes = _file("aes.zip", "1", "bios/SNK/Neo Geo/aes.zip")
|
||||
neogeo = _file("neogeo.zip", "2", "bios/Arcade/Arcade/neogeo.zip")
|
||||
mega_cd = _file("bios_CD_U.bin", "3", "bios/Sega/Mega CD/bios_CD_U.bin")
|
||||
shader = _file("tfx.glsl", "4", "bios/Other/emu/tfx.glsl")
|
||||
mvs = {"required": True, "system": "snk-neogeo-mvs"}
|
||||
self.consumers = _consumers({
|
||||
"1" * 40: [("geolith", {"name": "aes.zip", "required": True,
|
||||
"system": "snk-neogeo-aes"})],
|
||||
"2" * 40: [(f"mame{n}", {**mvs, "name": "neogeo.zip"}) for n in range(3)],
|
||||
"3" * 40: [("bizhawk", {"name": "bios_CD_U.bin", "required": True,
|
||||
"system": "snk-neogeo-mvs"})],
|
||||
"4" * 40: [("emu", {"name": "tfx.glsl", "required": True,
|
||||
"system": "snk-neogeo-mvs"})],
|
||||
}, db_files=[self.aes, neogeo, mega_cd, shader])
|
||||
|
||||
def test_the_file_most_emulators_read_leads_and_the_arcade_set_is_linked(self) -> None:
|
||||
lines = gs._start_with([self.aes], self.consumers, section=("SNK", "Neo Geo"))
|
||||
link = f"[`neogeo.zip`](/retrobios/files/?sha1={'2' * 40})"
|
||||
self.assertEqual(lines[0], f"**Start with** {link}. Keep it zipped. "
|
||||
"mame0, mame1 and mame2 read it.")
|
||||
self.assertIn("Geolith reads [`aes.zip`](#sha1-111111111111) instead.", lines)
|
||||
|
||||
def test_another_consoles_file_or_an_emulators_folder_is_left_out(self) -> None:
|
||||
text = "\n".join(gs._start_with([self.aes], self.consumers, section=("SNK", "Neo Geo")))
|
||||
self.assertNotIn("bios_CD_U.bin", text)
|
||||
self.assertNotIn("tfx.glsl", text)
|
||||
|
||||
def test_the_card_read_by_most_emulators_comes_first(self) -> None:
|
||||
one = _file("aes.zip", "1", "bios/SNK/Neo Geo/aes.zip")
|
||||
many = _file("sp-s3.sp1", "5", "bios/SNK/Neo Geo/sp-s3.sp1")
|
||||
consumers = _consumers({
|
||||
"1" * 40: [("geolith", {"required": True})],
|
||||
"5" * 40: [(f"emu{n}", {"required": True}) for n in range(3)],
|
||||
})
|
||||
cards = gs._console_cards("SNK", [one, many], consumers, set())
|
||||
self.assertIn("sp-s3.sp1", cards[0][1][2])
|
||||
|
||||
def test_a_long_list_names_the_first_files(self) -> None:
|
||||
files = [_file(f"rom{i}.bin", "abcdefgh"[i], f"bios/SNK/Neo Geo/rom{i}.bin")
|
||||
for i in range(8)]
|
||||
consumers = _consumers({f["sha1"]: [("emu", {"required": True})] for f in files})
|
||||
self.assertEqual(gs._start_with(files, consumers, first_below=False)[0],
|
||||
"**Start with**: [`rom0.bin`](#sha1-aaaaaaaaaaaa), [`rom1.bin`](#sha1-bbbbbbbbbbbb), "
|
||||
"[`rom2.bin`](#sha1-cccccccccccc) and 5 more files.")
|
||||
|
||||
|
||||
class CardsSayWhichReleaseTheSetMatches(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
TMP.mkdir(exist_ok=True)
|
||||
self.path = TMP / "r3_systems_set.zip"
|
||||
with zipfile.ZipFile(self.path, "w") as archive:
|
||||
archive.writestr("sp-s2.sp1", b"bios")
|
||||
crc = f"{zipfile.ZipFile(self.path).infolist()[0].CRC:08x}"
|
||||
self.releases = {"set.zip": [
|
||||
((("sp-s2.sp1", crc),), "mame", [(0, "0.190"), (1, "0.195"), (4, "0.220")]),
|
||||
((("sp-s2.sp1", crc),), "fbneo", [(2, "1.0.0.03"), (7, "1.0.0.03")]),
|
||||
((("other.bin", "00000000"),), "mame", [(9, "0.289")]),
|
||||
]}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
def card(self) -> str:
|
||||
f = {"name": "set.zip", "sha1": "e" * 40, "md5": "e" * 32, "size": 4,
|
||||
"path": str(self.path)}
|
||||
with mock.patch.object(gs, "_romset_releases", return_value=self.releases):
|
||||
return "\n".join(gs._system_file_card(f, _consumers({})))
|
||||
|
||||
def test_the_matching_releases_as_runs(self) -> None:
|
||||
self.assertIn("<dt>Romset</dt><dd>Same contents as MAME 0.190 to 0.195, 0.220 and "
|
||||
"FBNeo 1.0.0.03</dd>", self.card())
|
||||
|
||||
def test_no_match_counts_what_is_missing(self) -> None:
|
||||
self.releases["set.zip"] = self.releases["set.zip"][2:]
|
||||
self.assertIn("<dd>No match: missing 1 ROM of MAME 0.289</dd>", self.card())
|
||||
|
||||
def test_no_recipe_no_line(self) -> None:
|
||||
self.releases = {}
|
||||
self.assertNotIn("Romset", self.card())
|
||||
|
||||
def test_the_snapshots_cover_mame_and_fbneo(self) -> None:
|
||||
coverage = gs._romset_coverage()
|
||||
self.assertRegex(coverage, r"^MAME 0\.\d+ to 0\.\d+ or FBNeo [\d.]+$")
|
||||
|
||||
def test_a_page_of_sets_links_the_version_answer(self) -> None:
|
||||
f = {"name": "set.zip", "sha1": "e" * 40, "path": "bios/SNK/Neo Geo/set.zip"}
|
||||
with mock.patch.object(gs, "_romset_releases", return_value=self.releases):
|
||||
page = gs.generate_system_page("SNK", {"Neo Geo": [f]}, _consumers({}))
|
||||
self.assertIn("MAME and FBNeo sets must match the emulator's version. [Which version "
|
||||
f"each core reads]({gs.ROMSET_FAQ}).", page)
|
||||
anchor = gs.ROMSET_FAQ.split("#")[1]
|
||||
faq = (REPO_ROOT / "wiki" / "technical-faq.md").read_text(encoding="utf-8")
|
||||
self.assertIn("## Which MAME version do the arcade BIOS sets match?", faq)
|
||||
self.assertEqual(anchor, "which-mame-version-do-the-arcade-bios-sets-match")
|
||||
|
||||
|
||||
class ArcadeIsItsOwnGroup(unittest.TestCase):
|
||||
def test_arcade_rows_under_arcade(self) -> None:
|
||||
f = {"name": "a.bin", "sha1": "a" * 40, "path": "bios/x/a.bin"}
|
||||
index = gs.generate_systems_index({"Arcade": {"MAME": [f]}, "Sony": {"PlayStation": [f]}})
|
||||
arcade = index.index("## Arcade")
|
||||
self.assertLess(index.index("## Consoles and computers"), arcade)
|
||||
self.assertGreater(index.index("[MAME]"), arcade)
|
||||
self.assertLess(index.index("[Sony PlayStation]"), arcade)
|
||||
|
||||
|
||||
class EachManufacturerHasItsCard(unittest.TestCase):
|
||||
def test_a_tagline_holds_three_lines_and_counts_the_rest(self) -> None:
|
||||
names = [f"Console number {n}" for n in range(40)]
|
||||
text = make_og_cards.consoles_tagline(names)
|
||||
self.assertRegex(text, r" and \d+ more$")
|
||||
lines = make_og_cards.wrap(text, make_og_cards.TAGLINE_WIDTH, make_og_cards.TAGLINE_SIZE)
|
||||
self.assertLessEqual(len(lines), make_og_cards.TAGLINE_LINES)
|
||||
self.assertEqual(make_og_cards.consoles_tagline(["Saturn", "Dreamcast"]),
|
||||
"BIOS files for Dreamcast and Saturn")
|
||||
|
||||
def test_every_manufacturer_page_has_a_committed_card(self) -> None:
|
||||
cards = make_og_cards.system_cards()
|
||||
missing = [key for key in cards
|
||||
if not (REPO_ROOT / "docs_assets" / "og" / f"{key}.png").is_file()]
|
||||
self.assertEqual(missing, [], "run python scripts/make_og_cards.py --only systems")
|
||||
# A card left behind by a page that went: oric.png outlived its page.
|
||||
committed = (REPO_ROOT / "docs_assets" / "og" / "systems").glob("*.png")
|
||||
stale = sorted(f"systems/{path.stem}" for path in committed
|
||||
if f"systems/{path.stem}" not in cards)
|
||||
self.assertEqual(stale, [])
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "systems" / "nintendo" / "index.html").is_file(), "site not built")
|
||||
class TheBuiltPages(unittest.TestCase):
|
||||
def test_no_console_heading_carries_an_icon(self) -> None:
|
||||
with_icon = [path.relative_to(DIST).as_posix() for path in DIST.glob("systems/**/index.html")
|
||||
if re.search(r"<h2[^>]*>(?:(?!</h2>).)*<img", path.read_text(encoding="utf-8"), re.S)]
|
||||
self.assertEqual(with_icon, [])
|
||||
|
||||
def test_the_ds_section_names_no_dsi_file(self) -> None:
|
||||
page = (DIST / "systems" / "nintendo" / "index.html").read_text(encoding="utf-8")
|
||||
start = page.index('id="nintendo-ds"')
|
||||
section = page[start:page.index('id="nintendo-dsi"')]
|
||||
# The section may have moved to a page of its own: no card follows.
|
||||
opening = section.split('class="rb-sys-file"')[0]
|
||||
self.assertRegex(opening, r"No file needed|Start with")
|
||||
self.assertNotRegex(opening.casefold(), r"dsi_|bios7i|bios9i")
|
||||
|
||||
def test_the_gba_line_prints_the_setups_name(self) -> None:
|
||||
page = (DIST / "systems" / "nintendo" / "index.html").read_text(encoding="utf-8")
|
||||
section = page[page.index('id="game-boy-advance"'):page.index('id="game-boy-color"')]
|
||||
self.assertRegex(section[:section.index("class=\"rb-sys-file\"")], r"<code[^>]*>gba_bios.bin</code>")
|
||||
|
||||
def test_the_neo_geo_section_opens_on_the_neo_geo_bios(self) -> None:
|
||||
page = (DIST / "systems" / "snk" / "index.html").read_text(encoding="utf-8")
|
||||
section = page[page.index('id="neo-geo"'):]
|
||||
opening = section[:section.index('class="rb-sys-file"')]
|
||||
self.assertRegex(opening, r"<code[^>]*>neogeo.zip</code>")
|
||||
self.assertIn("Which version each core reads", page)
|
||||
|
||||
def test_the_nintendo_summary_holds_its_budget(self) -> None:
|
||||
size = (DIST / "systems" / "nintendo" / "index.html").stat().st_size
|
||||
self.assertLess(size, 540_000)
|
||||
|
||||
|
||||
class InstallBoxesCountTowardTheSummary(unittest.TestCase):
|
||||
"""Ten install boxes were 295 KB of the 583 KB Nintendo page: the
|
||||
weights that keep a section on its maker's summary counted cards only,
|
||||
and the page passed its 600 KB budget once the DS files were refiled."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
import siterender
|
||||
|
||||
siterender.reset_console_pages()
|
||||
self.addCleanup(siterender.reset_console_pages)
|
||||
self.consoles = {
|
||||
name: [_file(f"{name}{i}.bin", "0123456789"[index * 3 + i],
|
||||
f"bios/Maker/{name}/{name}{i}.bin") for i in range(3)]
|
||||
for index, name in enumerate(("Boxed", "Heavy", "Light"))
|
||||
}
|
||||
|
||||
def pages(self, box: int) -> dict:
|
||||
def install(console: str, _declared: list) -> list[str]:
|
||||
return ["x" * box] if console == "Boxed" else []
|
||||
|
||||
with mock.patch.object(gs, "_console_install_block", side_effect=install), \
|
||||
mock.patch.object(gs, "SYSTEM_SUMMARY_HTML", 50_000):
|
||||
return gs.system_pages("Maker", self.consoles, _consumers({}), budget=30_000)
|
||||
|
||||
def test_a_section_with_a_heavy_install_box_moves(self) -> None:
|
||||
pages = self.pages(20_000)
|
||||
self.assertIn("maker-boxed", pages)
|
||||
self.assertNotIn("maker-light", pages)
|
||||
|
||||
def test_a_light_install_box_stays(self) -> None:
|
||||
self.assertEqual(list(self.pages(10)), ["maker"])
|
||||
@@ -1,316 +0,0 @@
|
||||
"""Wiki copy.
|
||||
|
||||
Each class names the finding it holds: a legal page that opened on case law,
|
||||
no arcade answer where players look, a Dreamcast "yes" the Dreamcast
|
||||
emulator contradicts, an offline install no one could find, setups with no
|
||||
page of their own, link labels that named another page, an exit status that
|
||||
could not tell missing files from a failure, and FAQ answers the community
|
||||
asks for (region and language, keys and firmware). Every claim the copy
|
||||
makes about an emulator or the installer is held against the repository.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
WIKI = REPO_ROOT / "wiki"
|
||||
FENCED = re.compile(r"^```.*?^```", re.S | re.M)
|
||||
|
||||
|
||||
def _wiki(name: str) -> str:
|
||||
return (WIKI / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _section(text: str, heading: str) -> str:
|
||||
start = re.search(rf"(?m)^#+ {re.escape(heading)}(?:\s*\{{[^}}]*\}})?\n", text)
|
||||
if start is None:
|
||||
raise AssertionError(f"no section {heading!r}")
|
||||
rest = text[start.end():]
|
||||
end = re.search(r"(?m)^#{1,2} ", FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest))
|
||||
return rest[: end.start()] if end else rest
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _headings(text: str) -> list[str]:
|
||||
return re.findall(r"(?m)^#{2,3} (.+?)\s*$", FENCED.sub("", text))
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _entry(profile: dict, name: str) -> dict:
|
||||
for entry in profile.get("files", []):
|
||||
if entry.get("name") == name:
|
||||
return entry
|
||||
raise AssertionError(f"{name} not in the profile")
|
||||
|
||||
|
||||
class LegalOpensInPlainWords(unittest.TestCase):
|
||||
"""C5: Is it legal? is a Start here page and opened on case law."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("legal.md")
|
||||
self.opening = self.text.split("## The reasoning in detail", 1)[0]
|
||||
|
||||
def test_the_answer_comes_before_the_reasoning(self):
|
||||
self.assertIn("## The reasoning in detail", self.text)
|
||||
self.assertIn("It depends on your country. Nothing here is legal advice.", self.opening)
|
||||
for case in ("Connectix", "Accolade", "DMCA", "CFR", "Chamberlain", "Yuzu"):
|
||||
with self.subTest(case=case):
|
||||
self.assertNotIn(case, self.opening)
|
||||
self.assertIn(case, self.text)
|
||||
|
||||
def test_the_plain_lines_hold_against_the_detail(self):
|
||||
opening = _flat(self.opening)
|
||||
self.assertIn("encryption keys (Switch, 3DS, Wii U)", opening)
|
||||
self.assertIn("weakest part of the project's position", self.text)
|
||||
self.assertIn("issues/new", opening)
|
||||
self.assertIn("path or hash", opening)
|
||||
self.assertIn("identifying the file by path or hash", _flat(self.text))
|
||||
|
||||
def test_the_detail_sits_under_the_reasoning_heading(self):
|
||||
detail = self.text.split("## The reasoning in detail", 1)[1]
|
||||
detail = detail.split("## Asking for a file to be removed", 1)[0]
|
||||
self.assertEqual(re.findall(r"(?m)^## ", detail), [])
|
||||
self.assertIn("### Emulation and BIOS redistribution", detail)
|
||||
|
||||
def test_a_contact_section_names_only_a_channel_that_exists(self):
|
||||
section = _section(self.text, "Contact")
|
||||
self.assertIn("https://github.com/Abdess/retrobios/issues", section)
|
||||
self.assertNotIn("@", section)
|
||||
|
||||
|
||||
class ArcadeAnswerWherePlayersLook(unittest.TestCase):
|
||||
"""C3, N3: the romset answer sat in Reference only."""
|
||||
|
||||
def test_troubleshooting_answers_after_step_three(self):
|
||||
text = _wiki("troubleshooting.md")
|
||||
headings = _headings(text)
|
||||
self.assertIn("An arcade game does not start", headings)
|
||||
self.assertGreater(headings.index("An arcade game does not start"),
|
||||
headings.index("3. Check the folder and the names"))
|
||||
section = _flat(_section(text, "An arcade game does not start"))
|
||||
for words in ("neogeo.zip", "same emulator version", "MAME version", "Details:",
|
||||
"technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match",
|
||||
"Run the command once."):
|
||||
with self.subTest(words=words):
|
||||
self.assertIn(words, section)
|
||||
|
||||
def test_the_faq_links_the_technical_answer(self):
|
||||
section = _section(_wiki("faq.md"), "My arcade game fails (which MAME version?)")
|
||||
anchor = "technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match"
|
||||
self.assertIn(anchor, section)
|
||||
self.assertIn("## Which MAME version do the arcade BIOS sets match?", _wiki("technical-faq.md"))
|
||||
self.assertLessEqual(len(re.findall(r"[.!?](?:\s|$)", section)), 2)
|
||||
|
||||
def test_emulator_pages_name_the_version_the_copy_promises(self):
|
||||
for name in ("mame2003", "mame2000", "fbneo"):
|
||||
with self.subTest(profile=name):
|
||||
profile = _profile(name)
|
||||
self.assertTrue(profile.get("core_version") or profile.get("version"))
|
||||
|
||||
def test_the_check_step_holds_the_searched_words(self):
|
||||
self.assertIn("## 1. Check my BIOS folder { #checking-what-you-installed }",
|
||||
_wiki("troubleshooting.md"))
|
||||
|
||||
|
||||
class BiosFineWhatNext(unittest.TestCase):
|
||||
"""Community need 11: the page assumed the BIOS was the cause."""
|
||||
|
||||
def setUp(self):
|
||||
self.section = _flat(_section(_wiki("troubleshooting.md"),
|
||||
"The BIOS is fine, the game still fails"))
|
||||
|
||||
def test_the_three_causes(self):
|
||||
for words in ("Another emulator?", "A BIOS setting?", ".cue", ".m3u"):
|
||||
with self.subTest(words=words):
|
||||
self.assertIn(words, self.section)
|
||||
|
||||
def test_the_named_setting_is_the_core_option(self):
|
||||
# libretro/pcsx_rearmed frontend/libretro_core_options.h: key
|
||||
# pcsx_rearmed_bios, label "BIOS Selection", values Auto and HLE.
|
||||
self.assertIn("**BIOS Selection** to **Auto**", self.section)
|
||||
notes = _profile("pcsx_rearmed").get("notes", "")
|
||||
self.assertIn('pcsx_rearmed_bios = "HLE"', notes)
|
||||
|
||||
|
||||
class DreamcastIsBetterWithIt(unittest.TestCase):
|
||||
"""C7: "Yes for Dreamcast" while Flycast runs without dc_boot.bin."""
|
||||
|
||||
def test_flycast_falls_back(self):
|
||||
entry = _entry(_profile("flycast"), "dc_boot.bin")
|
||||
self.assertFalse(entry.get("required"))
|
||||
self.assertTrue(entry.get("hle_fallback"))
|
||||
|
||||
def test_no_page_says_yes_for_dreamcast(self):
|
||||
for name in ("faq.md", "what-are-bios-files.md"):
|
||||
text = _wiki(name)
|
||||
with self.subTest(page=name):
|
||||
yes = next(line for line in text.splitlines() if line.startswith("- **Yes"))
|
||||
self.assertNotIn("Dreamcast", yes)
|
||||
self.assertRegex(_flat(text), r"\*\*Better with it:?\*\*:? (for )?Dreamcast")
|
||||
|
||||
|
||||
class OfflineInstallIsFindable(unittest.TestCase):
|
||||
"""N7: no page linked the offline install, and search did not find it."""
|
||||
|
||||
ANCHOR = "without-a-network-offline-install"
|
||||
|
||||
def test_the_installer_heading_says_offline(self):
|
||||
text = _wiki("installer.md")
|
||||
self.assertIn(f"## Without a network (offline install) {{ #{self.ANCHOR} }}", text)
|
||||
# Links written before the rename still land.
|
||||
self.assertIn('<span id="without-a-network"></span>', text)
|
||||
self.assertNotIn("](#without-a-network)", text)
|
||||
|
||||
def test_the_faq_answers_next_to_the_sd_card(self):
|
||||
text = _wiki("faq.md")
|
||||
headings = _headings(text)
|
||||
self.assertEqual(headings.index("No internet on the device?"),
|
||||
headings.index("Fill an SD card from a computer?") + 1)
|
||||
section = _section(text, "No internet on the device?")
|
||||
self.assertIn("--from-pack", section)
|
||||
self.assertIn(f"installer.md#{self.ANCHOR}", section)
|
||||
|
||||
def test_from_pack_is_an_installer_option(self):
|
||||
self.assertIn('"--from-pack"', (REPO_ROOT / "install.py").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class SetupsWithoutAPage(unittest.TestCase):
|
||||
"""N9, J13: ES-DE, LaunchBox and iOS found nothing."""
|
||||
|
||||
def test_the_answer(self):
|
||||
section = _flat(_section(_wiki("faq.md"), "My setup is not in the list"))
|
||||
for words in ("--dest", "ES-DE", "LaunchBox", "iPhone"):
|
||||
with self.subTest(words=words):
|
||||
self.assertIn(words, section)
|
||||
# No folder is written: the copy names none it cannot cite. Link
|
||||
# targets are pages of this site, not folders.
|
||||
self.assertNotRegex(re.sub(r"\]\([^)]*\)", "]", section), r"[~/\\][A-Za-z]")
|
||||
|
||||
def test_the_installer_fills_the_emulators_a_launcher_starts(self):
|
||||
installer = _flat(_wiki("installer.md"))
|
||||
self.assertIn("they reference emulators rather than owning a BIOS directory", installer)
|
||||
self.assertIn("the installer fills their own BIOS folders", installer)
|
||||
|
||||
|
||||
class LinkLabelsMatchPages(unittest.TestCase):
|
||||
"""N5: "Your setup's page" landed on a page titled Get your files."""
|
||||
|
||||
def test_links_to_get_your_files_carry_its_name(self):
|
||||
for name in ("first-install.md", "troubleshooting.md", "faq.md"):
|
||||
with self.subTest(page=name):
|
||||
for label in re.findall(r"\[([^\]]+)\]\(\.\./which-pack\.md\)", _wiki(name)):
|
||||
self.assertEqual(label, "Get your files")
|
||||
|
||||
|
||||
class ExitStatusAndPinnedInstaller(unittest.TestCase):
|
||||
"""D10, J12: --check exits 1 for missing files and for failures, and a
|
||||
packager had no fixed installer to point at."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("installer.md")
|
||||
self.source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_the_check_event_tells_missing_from_failure(self):
|
||||
section = _flat(_section(self.text, "Exit status"))
|
||||
self.assertIn("read the `check` event", section)
|
||||
self.assertIn("`missing` and `wrong_hash` count what is left", section)
|
||||
fields = re.search(r'"check": \(([^)]*)\)', self.source).group(1)
|
||||
for field in ("missing", "wrong_hash"):
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(f'"{field}"', fields)
|
||||
|
||||
def test_a_packager_pins_installer_and_lists_to_one_commit(self):
|
||||
# No release carries the installer yet: the section pins a commit.
|
||||
section = _section(self.text, "One line")
|
||||
self.assertIn("raw.githubusercontent.com/Abdess/retrobios/$commit", section)
|
||||
self.assertIn("$base/install.py", section)
|
||||
self.assertIn("RETROBIOS_REF=$commit", section)
|
||||
self.assertIn('os.environ.get("RETROBIOS_REF"', self.source)
|
||||
|
||||
|
||||
class SmallCopyFixes(unittest.TestCase):
|
||||
"""C P3: the SD card example and the end line of a run."""
|
||||
|
||||
def test_the_sd_card_example_names_its_setup(self):
|
||||
section = _flat(_section(_wiki("faq.md"), "Fill an SD card from a computer?"))
|
||||
self.assertIn("For RetroArch on a Switch, with the card in drive `E:`, run:", section)
|
||||
guides = (REPO_ROOT / "platforms" / "_guides.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("where: Nintendo Switch", guides)
|
||||
self.assertIn("`retroarch/cores/system/` on the SD card", guides)
|
||||
self.assertIn("--dest E:\\retroarch\\cores\\system", section)
|
||||
|
||||
def test_the_end_is_the_shown_one(self):
|
||||
# The one-liner opens the full screen: the page names the titles
|
||||
# its result screen shows, as tui_outcome writes them.
|
||||
import install
|
||||
|
||||
section = _section(_wiki("first-install.md"), "5. Read the result")
|
||||
state = install.TuiState()
|
||||
state.stack, state.code = ["result"], 0
|
||||
for summary in ({"installed": 3, "up_to_date": 0, "failed": []},
|
||||
{"installed": 2, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]},
|
||||
{"installed": 0, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]}):
|
||||
state.summary = summary
|
||||
title, _lines = install.tui_outcome(state)
|
||||
with self.subTest(title=title):
|
||||
self.assertIn(f"**{title}**", section)
|
||||
|
||||
|
||||
class CommunityAnswers(unittest.TestCase):
|
||||
"""Community needs 6, 10 and 15: region and language, keys versus
|
||||
firmware versus BIOS, zip or unzip."""
|
||||
|
||||
def setUp(self):
|
||||
self.faq = _wiki("faq.md")
|
||||
|
||||
def test_language_comes_from_the_game(self):
|
||||
section = _flat(_section(self.faq, "Does the BIOS set the game's language?"))
|
||||
self.assertIn("comes from the game", section)
|
||||
self.assertIn("Flycast's **Language** option", section)
|
||||
|
||||
def test_keys_firmware_bios(self):
|
||||
section = _flat(_section(self.faq, "Keys, firmware or BIOS?"))
|
||||
for name, profile in (("scph5501.bin", "duckstation"), ("PS3UPDAT.PUP", "rpcs3"),
|
||||
("prod.keys", "eden")):
|
||||
with self.subTest(name=name):
|
||||
self.assertIn(f"`{name}`", section)
|
||||
_entry(_profile(profile), name)
|
||||
self.assertIn("Firmware (system NCAs) must be installed through Eden's UI",
|
||||
_profile("eden")["notes"])
|
||||
|
||||
def test_zip_or_unzip_stays(self):
|
||||
self.assertIn("neogeo.zip", _section(self.faq, "Zip or unzip?"))
|
||||
|
||||
|
||||
class StartPagesStayInBudget(unittest.TestCase):
|
||||
"""The built pages: the new answers fit the level 0 ceilings."""
|
||||
|
||||
def test_ceilings(self):
|
||||
from tests import test_site_copy
|
||||
|
||||
sitepages.require()
|
||||
for name in ("wiki/faq.md", "wiki/troubleshooting.md", "wiki/first-install.md",
|
||||
"wiki/what-are-bios-files.md"):
|
||||
with self.subTest(page=name):
|
||||
self.assertLessEqual(test_site_copy._load(name).words(),
|
||||
test_site_copy.START_PAGES[name])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,269 +0,0 @@
|
||||
"""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"<pre\b.*?</pre>", "", html, flags=re.S)
|
||||
found = []
|
||||
for attributes, text in re.findall(r"<code(\s[^>]*)?>([^<]*)</code>", 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", block)
|
||||
# The one choice the script saves is the setup picked in an install
|
||||
# box, under its own key: never the OS guess.
|
||||
self.assertEqual(re.findall(r"localStorage\.setItem\((\w+)", source), ["SETUP_KEY"])
|
||||
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('<nav class="rb-breadcrumbs" aria-label="Breadcrumb" data-pagefind-ignore>', title)
|
||||
self.assertEqual(title.count('data-pagefind-ignore data-pagefind-meta="title"'), 2)
|
||||
footer = _read(COMPONENTS / "Footer.astro")
|
||||
self.assertIn('<div class="rb-footer-root" data-pagefind-ignore>\n<Default />', footer)
|
||||
|
||||
def test_built_page(self):
|
||||
html = _built("systems/sony-playstation")
|
||||
self.assertRegex(html, r'<nav class="rb-breadcrumbs[^"]*"[^>]*data-pagefind-ignore')
|
||||
self.assertRegex(html, r'<h1 id="_top"[^>]*data-pagefind-ignore[^>]*data-pagefind-meta="title"')
|
||||
root = html.index('class="rb-footer-root')
|
||||
self.assertIn("data-pagefind-ignore", html[html.rfind("<div", 0, root):root + 80])
|
||||
self.assertGreater(html.index("MIT for the tooling"), root)
|
||||
|
||||
|
||||
class ShortCodeMarked(unittest.TestCase):
|
||||
"""2.5: `--restore` split into `--` and `restore` on the home page at
|
||||
1440 px, and 51 flags split in the installer's options table (V3)."""
|
||||
|
||||
def test_the_rule(self):
|
||||
out = _node(
|
||||
f"import {{ isShort, markRawCode, SHORT_CODE }} from {json.dumps(SHORT_CODE.as_uri())};"
|
||||
"console.log(JSON.stringify([SHORT_CODE, isShort('--restore', false), isShort('--restore', true),"
|
||||
"isShort('x'.repeat(31), false), markRawCode('<td><code>bios/a.bin</code> <code class=\"rb-hash\">ab</code></td>'),"
|
||||
"markRawCode('<pre><code>a</code></pre>')]));"
|
||||
)
|
||||
self.assertEqual(json.loads(out), [
|
||||
30, True, False, False,
|
||||
'<td><code class="rb-short">bios/a.bin</code> <code class="rb-hash">ab</code></td>',
|
||||
"<pre><code>a</code></pre>",
|
||||
])
|
||||
|
||||
def test_registered(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("hastPlugins: [stackTables(), shortCode()],", config)
|
||||
|
||||
def test_every_short_code_is_marked(self):
|
||||
for page in ("", "wiki/installer", "which-pack", "emulators/duckstation", "systems/sony", "wiki/faq"):
|
||||
with self.subTest(page=page or "home"):
|
||||
self.assertEqual(_short_unmarked(_built(page)), [])
|
||||
self.assertIn('class="rb-short"', _built(""))
|
||||
|
||||
def test_the_options_table(self):
|
||||
self.assertRegex(_built("wiki/installer"), r'<table[^>]*\sclass="(?:[^"]*\s)?rb-options[\s"]')
|
||||
|
||||
|
||||
class SearchFields(unittest.TestCase):
|
||||
"""2.6: `consoles` and `terms` weigh as a title (N3, T-F2, D4)."""
|
||||
|
||||
def test_declared_and_never_shown(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 10, devices: 5, consoles: 100, exact: 20, terms: 30 },", config)
|
||||
self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'exact', 'terms']);", _read(SITE_TS))
|
||||
|
||||
|
||||
class SearchLoadsOnFirstUse(unittest.TestCase):
|
||||
"""2.7: ui-core.js (25.8 KiB) loaded on idle on every page (T-F8)."""
|
||||
|
||||
def test_the_override(self):
|
||||
self.assertIn("Search: './src/components/Search.astro',", _read(CONFIG))
|
||||
search = _read(COMPONENTS / "Search.astro")
|
||||
self.assertNotIn("requestIdleCallback", search)
|
||||
self.assertNotIn("DOMContentLoaded", search)
|
||||
self.assertIn("for (const type of ['pointerenter', 'pointerdown', 'focus'])", search)
|
||||
self.assertIn("loadSearch()?.then(", search)
|
||||
self.assertNotIn("TODO", search)
|
||||
|
||||
|
||||
class LogosAtTheirWeight(unittest.TestCase):
|
||||
"""2.7: two logos were Inkscape files, one with a 441 x 334 PNG inside."""
|
||||
|
||||
def test_no_editor_markup_and_small_rasters(self):
|
||||
import base64
|
||||
import struct
|
||||
|
||||
for path in sorted(LOGOS.glob("*.svg")):
|
||||
text = _read(path)
|
||||
with self.subTest(logo=path.name):
|
||||
self.assertNotRegex(text, r"inkscape:|sodipodi:|<metadata")
|
||||
self.assertNotRegex(text, r"data:image/(?!png)")
|
||||
for data in re.findall(r"data:image/png;base64,([A-Za-z0-9+/=]+)", text):
|
||||
# Width and height of a PNG: the IHDR chunk, bytes 16 to 24.
|
||||
width, height = struct.unpack(">II", base64.b64decode(data)[16:24])
|
||||
self.assertLessEqual(max(width, height), 80)
|
||||
|
||||
def test_the_fetcher_writes_them_so(self):
|
||||
script = _read(REPO_ROOT / "scripts" / "fetch_site_images.py")
|
||||
self.assertIn('minify_svg(shrink_embedded(safe_svg(data, url)))', script)
|
||||
|
||||
|
||||
class CopyButtonInTheHeader(unittest.TestCase):
|
||||
"""2.10: on touch the copy button covered the end of the first line."""
|
||||
|
||||
def test_rule(self):
|
||||
source = _read(COMPONENTS / "MarkdownContent.astro")
|
||||
self.assertIn("@media (pointer: coarse)", source)
|
||||
self.assertIn(".expressive-code .frame.has-title .copy { inset-block-start:", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Round 4 theme: the stylesheet fixes of package 1, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: OS tabs on one
|
||||
hidden scrolling row, stacked table cells 16 px apart with labels drawn over
|
||||
the next row, short flags split across lines, lone card links 19 px high, a
|
||||
card label column too narrow for its longest label.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tests.test_site_r2_css import CSS_PATH, _blocks, _rules, _strip_comments
|
||||
|
||||
STACK = ".sl-markdown-content table[data-rb-stack] td"
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
bodies = [b for s, b in _blocks(css) if s == selector]
|
||||
if not bodies:
|
||||
raise AssertionError(f"no block {selector}")
|
||||
return "".join(bodies)
|
||||
|
||||
|
||||
class ThemeStylesheetRound4(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.raw = CSS_PATH.read_text(encoding="utf-8")
|
||||
cls.css = _strip_comments(cls.raw)
|
||||
|
||||
# 1.1
|
||||
def test_os_tabs_wrap_and_never_hide(self):
|
||||
tablist = _block(self.css, "starlight-tabs [role='tablist']")
|
||||
for declaration in ("flex-wrap: wrap", "overflow-x: visible", "row-gap: 0.25rem"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, tablist)
|
||||
self.assertNotIn("scrollbar-width: none", self.css)
|
||||
self.assertNotIn("::-webkit-scrollbar", self.css)
|
||||
|
||||
# 1.2
|
||||
def test_stacked_cells_close_up_and_labels_push_rows(self):
|
||||
cell = _block(self.css, STACK)
|
||||
# Round 5 tightened the values: 7.1rem column, 1.35 lines (test_site_r5_css).
|
||||
for declaration in ("margin-top: 0", "display: flow-root", "line-height: 1.35",
|
||||
"padding: 0 0.6rem 0 7.1rem"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, cell)
|
||||
label = _block(self.css, STACK + "::before")
|
||||
self.assertIn("position: static", label)
|
||||
self.assertIn("float: left", label)
|
||||
self.assertNotIn("position: absolute", label)
|
||||
# The label sits in the cell's left padding.
|
||||
self.assertIn("margin: 0 0.6rem 0 -7.1rem", label)
|
||||
|
||||
# 1.3
|
||||
def test_short_inline_code_never_splits(self):
|
||||
self.assertIn("white-space: pre", _block(self.css, ".sl-markdown-content :not(pre) > code.rb-short"))
|
||||
self.assertIn("white-space: break-spaces", _block(self.css, ".sl-markdown-content :not(pre) > code"))
|
||||
self.assertIn("white-space: nowrap", _block(self.css, ".sl-markdown-content table.rb-options td:first-child"))
|
||||
# A stacked value column is narrow: short code there moves down whole
|
||||
# and splits only when wider than the column, never pushing the page.
|
||||
stacked = _block(self.css, STACK + " code.rb-short")
|
||||
for declaration in ("display: inline-block", "max-width: 100%", "overflow-wrap: anywhere"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, stacked)
|
||||
# Commands in code blocks keep wrapping on a phone.
|
||||
self.assertIn("white-space: pre-wrap", _block(self.css, ".sl-markdown-content .expressive-code pre > code"))
|
||||
|
||||
# 1.4
|
||||
def test_lone_card_links_are_24px_targets(self):
|
||||
dd = _block(self.css, ".sl-markdown-content .rb-facts dd")
|
||||
self.assertIn("min-height: 24px", dd)
|
||||
self.assertIn("line-height: 24px", dd)
|
||||
link = _block(self.css, ".sl-markdown-content .rb-facts dd > a")
|
||||
self.assertIn("display: inline-block", link)
|
||||
self.assertIn("min-height: 24px", link)
|
||||
|
||||
# 1.5
|
||||
def test_card_labels_fit_and_lookup_labels_match(self):
|
||||
facts = _rules(self.css, "dl.rb-facts")[0]
|
||||
self.assertIn("grid-template-columns: 11rem minmax(0, 1fr)", facts)
|
||||
self.assertIn("font-weight: 600", _block(self.css, ".sl-markdown-content .rb-file-lookup label"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,186 +0,0 @@
|
||||
"""Emulator pages, round 4 (package 7).
|
||||
|
||||
AetherSX2's page offered a command that answered "No supported platform
|
||||
detected" and a file to get by one name, while the app reads any PS2 BIOS
|
||||
by its contents. FinalBurn Neo and MAME 2003-Plus named no romset under
|
||||
their intro. MAME's card pages and the data directory pages did not say
|
||||
where they sit. melonDS marked its DSi files "required" above its DS files.
|
||||
The `00_example` template was listed as an emulator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
|
||||
EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _pages(name: str, profile: dict | None = None) -> dict[str, str]:
|
||||
return gs.emulator_pages(name, profile or _profile(name), EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _front(page: str) -> dict:
|
||||
assert page.startswith("---\n"), page[:80]
|
||||
return yaml.safe_load(page[4:page.index("\n---\n", 4)])
|
||||
|
||||
|
||||
class AnAppThatReadsAnyBios(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = _pages("aethersx2")["aethersx2"]
|
||||
|
||||
def test_the_intro_says_what_to_do(self):
|
||||
intro = self.page[self.page.index("# AetherSX2"):self.page.index("About this profile")]
|
||||
self.assertIn("AetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", intro)
|
||||
# Round 5 (7.1): the folder itself, per system the app runs on.
|
||||
self.assertRegex(intro, r"\n1\. .+\n2\. Put it in this folder:")
|
||||
self.assertIn("`Android/data/xyz.aethersx2.android/files/bios/`", intro)
|
||||
self.assertIn("Any file name works. The app reads what the file holds.", intro)
|
||||
self.assertNotIn("It reads", intro)
|
||||
|
||||
def test_the_card_names_an_example(self):
|
||||
self.assertIn("a Sony PlayStation 2 BIOS (example: <code>ps2-0230a-20080220.bin</code>)",
|
||||
self.page)
|
||||
|
||||
def test_no_command_that_cannot_place_the_file(self):
|
||||
# Round 5 (7.2): only through a setup that launches the app, on its
|
||||
# device, never as if the app on its own were found.
|
||||
install = self.page[self.page.index("Install this emulator's files"):]
|
||||
install = install[:install.index("</details>")]
|
||||
self.assertRegex(install, r"With [^:]+: the installer finds")
|
||||
self.assertEqual(set(re.findall(r'<TabItem label="([^"]+)">', install)), {"On the device"})
|
||||
nether = _pages("nethersx2")["nethersx2"]
|
||||
if "--emulator nethersx2" in nether:
|
||||
self.assertIn("With ", nether)
|
||||
|
||||
def test_a_core_keeps_its_command(self):
|
||||
# LRPS2 runs in RetroArch, whose BIOS folder the installer finds.
|
||||
page = _pages("lrps2")["lrps2"]
|
||||
self.assertIn("--emulator lrps2", page)
|
||||
self.assertIn("2. Put it in `pcsx2/bios/`, inside your BIOS folder.", page)
|
||||
self.assertIn("a Sony PlayStation 2 BIOS, any file name", page)
|
||||
|
||||
def test_only_found_by_content_with_one_bios(self):
|
||||
# DuckStation picks the BIOS of the game's region: its intro stays.
|
||||
self.assertIsNone(gs.any_name_bios(_profile("duckstation")))
|
||||
self.assertIsNone(gs.any_name_bios(_profile("melonds")))
|
||||
self.assertIsNotNone(gs.any_name_bios(_profile("pcsx2")))
|
||||
|
||||
def test_the_download_links_the_collection_card(self):
|
||||
steps = gs._any_name_steps("X", _profile("aethersx2"),
|
||||
gs.any_name_bios(_profile("aethersx2")),
|
||||
"../systems/sony.md#sha1-abc")
|
||||
self.assertIn("1. Download [`ps2-0230a-20080220.bin`](../systems/sony.md#sha1-abc).", steps)
|
||||
|
||||
|
||||
class EveryArcadeCoreNamesItsRomset(unittest.TestCase):
|
||||
def test_finalburn_and_mame_2003_plus(self):
|
||||
for name, romset in (("fbneo", f"FinalBurn Neo {_profile('fbneo')['core_version']}"),
|
||||
("mame2003_plus", "MAME 0.78 (plus backports)")):
|
||||
page = _pages(name)[name]
|
||||
line = page.index(f"Its BIOS and game sets must come from the {romset} romset.")
|
||||
self.assertLess(line, page.index("About this profile"), name)
|
||||
|
||||
|
||||
class PartsKnowWhereTheySit(unittest.TestCase):
|
||||
def test_emulator_card_parts(self):
|
||||
pages = _pages("mame")
|
||||
parts = sorted((stem for stem in pages if stem.startswith("mame-files-")),
|
||||
key=lambda stem: int(stem.rsplit("-", 1)[1]))
|
||||
self.assertGreater(len(parts), 2)
|
||||
for index, stem in enumerate(parts):
|
||||
front = _front(pages[stem])
|
||||
self.assertEqual(front["parent"], {"label": "MAME", "link": "mame.md"})
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertEqual(front["prev"]["link"],
|
||||
f"{parts[index - 1]}.md" if index else "mame.md")
|
||||
if index + 1 < len(parts):
|
||||
self.assertEqual(front["next"]["link"], f"{parts[index + 1]}.md")
|
||||
else:
|
||||
self.assertIs(front["next"], False)
|
||||
|
||||
def test_data_directory_parts(self):
|
||||
ref = "x-sys"
|
||||
members = {f"f{i:05d}": {"size": 1, "sha1": f"{i:040x}"}
|
||||
for i in range(gs.DATA_TREE_ROWS_PER_PAGE * 2 + 1)}
|
||||
gs._DATA_MEMBERS[ref] = members
|
||||
gs._DATA_READERS[ref] = [("x", "X")]
|
||||
try:
|
||||
pages = gs.data_tree_pages(ref, members)
|
||||
self.assertEqual(len(pages), 3)
|
||||
middle = _front(gs._data_tree_page(ref, {}, pages, 1))
|
||||
finally:
|
||||
del gs._DATA_MEMBERS[ref], gs._DATA_READERS[ref]
|
||||
self.assertEqual(middle["parent"], {"label": "X", "link": "x.md"})
|
||||
self.assertEqual(middle["prev"]["link"], f"{pages[0][0]}.md")
|
||||
self.assertEqual(middle["next"]["link"], f"{pages[2][0]}.md")
|
||||
self.assertEqual(middle["search"], "exclude")
|
||||
|
||||
|
||||
class ARequiredFileSaysForWhichSystem(unittest.TestCase):
|
||||
def test_melonds_dsi_files(self):
|
||||
page = _pages("melonds")["melonds"]
|
||||
row = page[page.index('id="file-dsi-nand-bin"'):]
|
||||
row = row[:row.index("</tr>")]
|
||||
self.assertIn(">required for Nintendo DSi</span>", row)
|
||||
self.assertNotIn(">required</span>", page)
|
||||
|
||||
def test_one_system_says_required(self):
|
||||
# The section heading says it: the rows under it repeat nothing.
|
||||
page = _pages("aethersx2")["aethersx2"]
|
||||
self.assertIn("## Required", page)
|
||||
self.assertNotIn("required for", page)
|
||||
|
||||
|
||||
class TheTemplateIsNoEmulator(unittest.TestCase):
|
||||
def test_off_the_index_and_the_search(self):
|
||||
profiles = {"00_example": _profile("00_example"), "melonds": _profile("melonds")}
|
||||
index = gs.generate_emulators_index(profiles)
|
||||
self.assertNotIn("00_example", index)
|
||||
self.assertIn("melonds.md", index)
|
||||
# A test profile gets no page at all (generate_site's public_profiles).
|
||||
self.assertEqual(_profile("00_example")["type"], "test")
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "aethersx2" / "index.html").is_file(), "site not built")
|
||||
class TheBuiltPages(unittest.TestCase):
|
||||
def _html(self, path: str) -> str:
|
||||
return (DIST / path / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
def test_aethersx2_downloads_from_the_collection(self):
|
||||
page = self._html("emulators/aethersx2")
|
||||
self.assertRegex(page, r'Download <a href="[^"]*/systems/sony[^"]*#sha1-[0-9a-f]{12}"')
|
||||
# The command only where a setup launches AetherSX2 itself (KNULLI,
|
||||
# ROCKNIX), never through RetroArch's folder, which the app never reads.
|
||||
self.assertIn("With ROCKNIX or KNULLI:", page)
|
||||
self.assertNotIn("RetroArch", page.split("Install this emulator", 1)[1].split("</details>", 1)[0])
|
||||
|
||||
def test_the_index_leaves_the_template_out(self):
|
||||
# 00_example is `type: test`: no page, no index row.
|
||||
self.assertNotIn("00_example", self._html("emulators"))
|
||||
self.assertIn('data-pagefind-body', self._html("emulators/melonds"))
|
||||
self.assertFalse((DIST / "emulators" / "00_example").exists())
|
||||
|
||||
def test_mame_parts_have_a_parent_crumb(self):
|
||||
page = self._html("emulators/mame-files-3")
|
||||
crumbs = re.search(r'<nav[^>]*rb-breadcrumbs.*?</nav>', page, re.S)
|
||||
self.assertIsNotNone(crumbs)
|
||||
self.assertIn(">MAME<", crumbs.group(0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,206 +0,0 @@
|
||||
"""Entry pages and guides.
|
||||
|
||||
Six handheld setups have no ZIP, and three of them offered SSH as root as
|
||||
the only route while their page already named a network folder. Six rows
|
||||
of the Linux handhelds table said "The command finds it on the device" for
|
||||
devices with no terminal, while the installer held each folder with its
|
||||
source. The offline line sat on six setup pages, the home page carried a
|
||||
stray "Reference:" label, the Your setup index left out the handheld tiles,
|
||||
and an iPhone owner found one FAQ line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import siteguides # noqa: E402
|
||||
import sitemarkup # noqa: E402
|
||||
from common import load_platform_registry # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
|
||||
HANDHELDS = siteguides.HANDHELD_GUIDE
|
||||
OFFLINE_ANCHOR = "#without-a-network-offline-install"
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
sitepages.require()
|
||||
return re.sub(r"</?abbr[^>]*>", "", sitepages.page(name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _guide_part(text: str) -> str:
|
||||
match = re.search(r"^## Reference\b", text, re.M)
|
||||
return text[: match.start()] if match else text
|
||||
|
||||
|
||||
def _with_zip(name: str) -> bool:
|
||||
pack = release_record.pack_for(name, release_record.load_advertised())
|
||||
return bool(pack and pack.get("assets"))
|
||||
|
||||
|
||||
SETUPS = [name for name in siteguides.GUIDES if name in REGISTRY]
|
||||
|
||||
|
||||
class NoZipNoUnzipText(unittest.TestCase):
|
||||
def test_a_setup_without_a_zip_never_says_unzip_or_download_pack(self):
|
||||
for name in SETUPS:
|
||||
if _with_zip(name):
|
||||
continue
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
with self.subTest(setup=name):
|
||||
self.assertNotIn("Unzip the pack into", text)
|
||||
self.assertNotIn("pack holds its own", text)
|
||||
self.assertNotRegex(text, r"Download \S+ Pack")
|
||||
self.assertNotIn("/releases/download/", text)
|
||||
|
||||
def test_the_minui_family_makes_each_folder(self):
|
||||
for name in ("minui", "myminui", "nextui"):
|
||||
with self.subTest(setup=name):
|
||||
self.assertIn("The command makes each console's folder for you.",
|
||||
_page(f"platforms/{name}.md"))
|
||||
|
||||
def test_knulli_is_filled_over_the_network_with_the_samba_switch_cited(self):
|
||||
text = _guide_part(_page("platforms/knulli.md"))
|
||||
self.assertIn("## From your computer { #from-your-computer }", text)
|
||||
self.assertIn("--platform knulli --dest \\\\KNULLI\\share\\bios\n", text)
|
||||
self.assertRegex(text, r"turn on \*\*SAMBA\*\* in \*\*System Settings > Services\*\*\s*"
|
||||
r"\(\[KNULLI docs\]\(https://github\.com/knulli-cfw/knulli\.org/blob/"
|
||||
r"[0-9a-f]{40}/docs/play/add-games/network-transfer\.md")
|
||||
# The computer route comes before the SSH one.
|
||||
self.assertLess(text.index("## From your computer"), text.index("## On the device"))
|
||||
|
||||
def test_the_elec_systems_say_they_need_the_network(self):
|
||||
for name, share in (("amberelec", "\\\\AmberELEC\\roms"), ("emuelec", "\\\\EMUELEC\\roms")):
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
with self.subTest(setup=name):
|
||||
self.assertIn("Needs the device on your network.", text)
|
||||
self.assertIn(f"--platform {name} --dest {share}\n", text)
|
||||
|
||||
def test_the_network_route_needs_a_share_and_no_zip(self):
|
||||
for name in SETUPS:
|
||||
share = generate_site._network_share(name, siteguides.GUIDES[name])
|
||||
with self.subTest(setup=name):
|
||||
if _with_zip(name) or siteguides.GUIDES[name].run != ("device",):
|
||||
self.assertEqual(share, "")
|
||||
if share:
|
||||
self.assertTrue(share.startswith("\\\\"))
|
||||
self.assertFalse(share.endswith("\\"))
|
||||
|
||||
|
||||
class EveryHandheldRowSaysWhere(unittest.TestCase):
|
||||
def test_no_row_sends_the_reader_to_a_command(self):
|
||||
text = _page(HANDHELDS.path)
|
||||
self.assertNotIn("The command finds it on the device", text)
|
||||
rows = [line for line in text.splitlines() if line.startswith("| ")][1:]
|
||||
self.assertEqual(len(rows), len(HANDHELDS.systems))
|
||||
for row in rows:
|
||||
folder = row.rstrip(" |").rsplit(" | ", 1)[-1]
|
||||
with self.subTest(row=row[:40]):
|
||||
self.assertTrue("([source](https://" in folder
|
||||
or folder == generate_site.RETROARCH_FOLDER_SETTING, folder)
|
||||
|
||||
def test_the_installer_folders_are_written_with_their_source(self):
|
||||
systems = {s.name: s for s in HANDHELDS.systems}
|
||||
for name in ("Allium", "JELOS", "KOLOS", "MiyooCFW"):
|
||||
with self.subTest(system=name):
|
||||
self.assertTrue(systems[name].folder)
|
||||
self.assertRegex(systems[name].folder_source, r"/[0-9a-f]{40}/")
|
||||
|
||||
def test_a_folder_only_linux_reads_says_what_to_do(self):
|
||||
text = _page(HANDHELDS.path)
|
||||
for system in HANDHELDS.systems:
|
||||
row = next(line for line in text.splitlines() if line.startswith(f"| {system.name} |"))
|
||||
with self.subTest(system=system.name):
|
||||
self.assertEqual(generate_site.LINUX_ONLY_HINT in row, system.linux_only)
|
||||
self.assertTrue(any(s.linux_only for s in HANDHELDS.systems if s.name == "ArchR"))
|
||||
self.assertNotIn("Windows and macOS cannot open", text)
|
||||
|
||||
def test_linux_only_needs_a_folder(self):
|
||||
with self.assertRaises(siteguides.GuideError):
|
||||
siteguides._handheld_system({"name": "X", "linux_only": True}, "test")
|
||||
|
||||
def test_download_is_one_jump_under_the_lead(self):
|
||||
text = _page(HANDHELDS.path)
|
||||
self.assertIn("[Download](#download)", text)
|
||||
self.assertLess(text.index("[Download](#download)"), text.index("## Where the files go"))
|
||||
|
||||
|
||||
class OfflineLineWithEveryZip(unittest.TestCase):
|
||||
def test_every_setup_with_a_zip_links_the_offline_install(self):
|
||||
for name in SETUPS:
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
with self.subTest(setup=name):
|
||||
self.assertEqual(OFFLINE_ANCHOR in text, _with_zip(name))
|
||||
|
||||
def test_the_handheld_guide_links_it(self):
|
||||
self.assertEqual(OFFLINE_ANCHOR in _page(HANDHELDS.path), _with_zip(HANDHELDS.uses))
|
||||
|
||||
|
||||
class EntryCopy(unittest.TestCase):
|
||||
def test_no_reference_label_on_home_explore_and_the_setup_index(self):
|
||||
for name in ("index.md", "explore.md", "platforms/index.md"):
|
||||
if not sitepages.page(name).is_file():
|
||||
continue
|
||||
body = _page(name).split("\n---\n", 1)[-1]
|
||||
with self.subTest(page=name):
|
||||
self.assertNotIn("Reference: [", body)
|
||||
self.assertIn("- Have a file? [Check it](/retrobios/files/)", _page("index.md"))
|
||||
|
||||
def test_the_explore_cards_have_bare_links(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertNotIn('"Reference: [How each platform checks files]', source)
|
||||
self.assertNotIn('"Reference: [Data and API]', source)
|
||||
|
||||
def test_the_setup_index_lists_the_handheld_tiles(self):
|
||||
text = _page("platforms/index.md")
|
||||
for tile in siteguides.GROUP_TILES["handheld"]:
|
||||
target = tile.link.removesuffix(".md").replace(".md#", "/#")
|
||||
with self.subTest(tile=tile.name):
|
||||
self.assertIn(f"**[{tile.name}](/retrobios/{target}", text.replace("/)", ")"))
|
||||
self.assertIn(tile.text, text)
|
||||
|
||||
|
||||
class IphoneAndIpad(unittest.TestCase):
|
||||
def test_a_tile_in_the_handheld_group(self):
|
||||
tile = next(t for t in siteguides.GROUP_TILES["handheld"] if t.name == "iPhone and iPad")
|
||||
self.assertEqual(tile.link, "which-pack.md#iphone-and-ipad")
|
||||
for name in ("index.md", "which-pack.md"):
|
||||
with self.subTest(page=name):
|
||||
self.assertIn('href="/retrobios/which-pack/#iphone-and-ipad"', _page(name))
|
||||
self.assertEqual(sitemarkup.initials("iPhone and iPad"), "iP")
|
||||
|
||||
def test_each_app_step_is_cited_and_the_picker_replaces_the_zip(self):
|
||||
text = _page("which-pack.md")
|
||||
section = text.split("## iPhone and iPad { #iphone-and-ipad }", 1)[1].split("\n## ", 1)[0]
|
||||
self.assertIn('data-pagefind-meta="devices:iPhone iPad iOS Delta Provenance"', section)
|
||||
self.assertIn("/retrobios/which-pack/?platform=retroarch#only-some-consoles", section)
|
||||
items = [line for line in section.splitlines() if line.startswith("- ")]
|
||||
self.assertEqual([re.match(r"- \*\*(\w+)\*\*", i)[1] for i in items],
|
||||
["RetroArch", "Delta", "Provenance"])
|
||||
for item in items:
|
||||
with self.subTest(item=item[:20]):
|
||||
self.assertRegex(item, r"\(\[(docs|code)\]\(https://")
|
||||
self.assertNotIn("/get/retroarch/", section)
|
||||
|
||||
|
||||
class WhatItAsks(unittest.TestCase):
|
||||
def test_the_page_says_the_screen_not_a_line_menu(self):
|
||||
# The one-liner opens the full screen: a "What it asks" block of the
|
||||
# line menu showed keys a beginner never sees (owner's note).
|
||||
text = _page("which-pack.md")
|
||||
self.assertNotIn('title="What it asks"', text)
|
||||
for line in generate_site.first_screen_words():
|
||||
self.assertIn(line, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,367 +0,0 @@
|
||||
"""Reference pages and data.
|
||||
|
||||
The lookup answered a SHA1 without the setups that list the same file by
|
||||
MD5, the published folder check failed 134 EmuDeck entries a folder EmuDeck
|
||||
accepts, the manifest table printed `<ref>` behind a dead link and showed
|
||||
17 of 48 fields, 28 system ids held files and no page, and source links went
|
||||
to 404s on the deploy, which has no tree cache. Tests that read the built
|
||||
site skip when it has not been generated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import check_platform_folder # noqa: E402
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import sitesources # noqa: E402
|
||||
import upstream # noqa: E402
|
||||
|
||||
PUBLIC = REPO_ROOT / "website" / "public"
|
||||
API = PUBLIC / "api" / "v1"
|
||||
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
TMP = REPO_ROOT / "tmp"
|
||||
|
||||
|
||||
def _built(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
raise unittest.SkipTest(f"{path.relative_to(REPO_ROOT)} not generated")
|
||||
return path
|
||||
|
||||
|
||||
def _page(stem: str) -> str:
|
||||
for suffix in (".md", ".mdx"):
|
||||
path = DOCS / f"{stem}{suffix}"
|
||||
if path.is_file():
|
||||
return path.read_text(encoding="utf-8")
|
||||
raise unittest.SkipTest(f"{stem} not generated")
|
||||
|
||||
|
||||
class LookupRecordsUnderEveryHash(unittest.TestCase):
|
||||
"""8.1: a platform listing a file by MD5 is found by its SHA1 too."""
|
||||
|
||||
SHA1 = "a" * 40
|
||||
FILE = {"name": "bios.bin", "path": "bios/Sony/PlayStation/bios.bin", "sha1": SHA1,
|
||||
"md5": "b" * 32, "crc32": "c" * 8, "sha256": "d" * 64}
|
||||
|
||||
def records(self) -> list[dict]:
|
||||
db = {"files": {self.SHA1: self.FILE}, "indexes": {
|
||||
"by_md5": {"b" * 32: self.SHA1}, "by_crc32": {"c" * 8: self.SHA1},
|
||||
"by_sha256": {"d" * 64: self.SHA1}}}
|
||||
coverages = {"setup": {"config": {"systems": {"sony-playstation": {"files": [
|
||||
{"name": "bios.bin", "md5": "b" * 32}]}}}}}
|
||||
profiles = {"core": {"type": "libretro", "systems": ["sony-playstation"],
|
||||
"files": [{"name": "bios.bin", "crc32": "c" * 8}]}}
|
||||
with mock.patch.object(generate_site, "_console_system_names", return_value={}), \
|
||||
mock.patch.object(generate_site, "_lookup_system_name", return_value=None), \
|
||||
mock.patch.object(generate_site, "emulator_card_page", side_effect=lambda k, a: k):
|
||||
return generate_site.file_lookup_records(
|
||||
db, coverages, profiles, [{"sha1": self.SHA1, "url": "/x"}])
|
||||
|
||||
def test_each_hash_names_the_setup_and_the_emulator(self):
|
||||
records = self.records()
|
||||
for value in (self.SHA1, "b" * 32, "c" * 8, "d" * 64):
|
||||
owners = {(r["layer"], r.get("owner")) for r in records if r["value"] == value}
|
||||
self.assertIn(("platform", "setup"), owners, value)
|
||||
self.assertIn(("emulator", "core"), owners, value)
|
||||
|
||||
def test_records_carry_the_sha1_and_say_what_was_declared(self):
|
||||
for record in self.records():
|
||||
if record["layer"] in ("platform", "emulator"):
|
||||
self.assertEqual(record["sha1"], self.SHA1)
|
||||
copy = next(r for r in self.records()
|
||||
if r["layer"] == "platform" and r["value"] == self.SHA1)
|
||||
self.assertEqual(copy["declared"], "md5")
|
||||
|
||||
def test_scph5501_gives_one_answer_for_its_four_hashes(self):
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
sha1 = "0555c6fae8906f3f09baf5988f00e55f88e9f30b"
|
||||
entry = db["files"][sha1]
|
||||
_built(API / "lookup")
|
||||
answers = []
|
||||
for algorithm in ("sha1", "md5", "crc32", "sha256"):
|
||||
value = str(entry[algorithm]).lower()
|
||||
shard = json.loads((API / "lookup" / f"{value[:2]}.json").read_text())
|
||||
found = [r for r in shard["items"] if r["value"] == value]
|
||||
answers.append(tuple(sorted({(r["layer"], r.get("owner", "")) for r in found})))
|
||||
self.assertEqual(len(set(answers)), 1, answers)
|
||||
setups = {owner for layer, owner in answers[0] if layer == "platform"}
|
||||
emulators = {owner for layer, owner in answers[0] if layer == "emulator"}
|
||||
self.assertGreaterEqual(len(setups), 10)
|
||||
self.assertGreaterEqual(len(emulators), 8)
|
||||
|
||||
|
||||
class TheFolderCheckReadsEmuDecksWhitelist(unittest.TestCase):
|
||||
"""8.2: an entry with no destination names no file. EmuDeck's check
|
||||
passes the system when any top-level file of the folder has its md5."""
|
||||
|
||||
def check(self, files: dict[str, bytes]) -> list[tuple[str, str]]:
|
||||
good = b"ps1 bios bytes"
|
||||
contract = {"verification_mode": "md5", "systems": {"sony-playstation": {"files": [
|
||||
{"name": f"sony-playstation:{hashlib.md5(good).hexdigest()}", "destination": "",
|
||||
"md5": hashlib.md5(good).hexdigest()},
|
||||
{"name": "sony-playstation:" + "0" * 32, "destination": "", "md5": "0" * 32},
|
||||
]}}}
|
||||
with tempfile.TemporaryDirectory(dir=TMP) as tmp:
|
||||
root = Path(tmp) / "bios"
|
||||
(root / "sub").mkdir(parents=True)
|
||||
for name, data in files.items():
|
||||
(root / name).write_bytes(data)
|
||||
api = Path(tmp) / "api"
|
||||
api.mkdir()
|
||||
(api / "emudeck.json").write_text(json.dumps({"items": [{"contract": contract}]}))
|
||||
found = check_platform_folder.check("emudeck", root, api.as_uri() + "/")
|
||||
# The verifier's verdict on the matching entry, file by file.
|
||||
from verify import verify_entry_md5 # noqa: PLC0415
|
||||
|
||||
verdicts = {name: verify_entry_md5(contract["systems"]["sony-playstation"]["files"][0],
|
||||
str(root / name))["status"]
|
||||
for name in files if (root / name).is_file()}
|
||||
return found, verdicts
|
||||
|
||||
def test_any_name_passes(self):
|
||||
found, verdicts = self.check({"my-ps1-dump.bin": b"ps1 bios bytes"})
|
||||
self.assertEqual(found, [("sony-playstation: any file", "ok")])
|
||||
self.assertEqual(verdicts, {"my-ps1-dump.bin": "ok"})
|
||||
|
||||
def test_a_folder_without_it_is_missing(self):
|
||||
found, _verdicts = self.check({"other.bin": b"x", "sub/deep.bin": b"ps1 bios bytes"})
|
||||
self.assertEqual(found, [("sony-playstation: any file", "missing")])
|
||||
|
||||
def test_the_data_page_states_the_rule(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
self.assertIn("An entry with no `destination` (EmuDeck) names no file", page)
|
||||
self.assertLess(len((REPO_ROOT / "scripts" / "check_platform_folder.py")
|
||||
.read_text().splitlines()), 70)
|
||||
|
||||
|
||||
class ManifestFieldsAreComplete(unittest.TestCase):
|
||||
"""8.3 and 8.4."""
|
||||
|
||||
SCHEMA = json.loads((REPO_ROOT / "schemas" / "install-manifest.schema.json").read_text())
|
||||
|
||||
def test_a_url_template_is_code(self):
|
||||
rows = generate_site._schema_field_rows(self.SCHEMA["$defs"]["file"], ("repo_path",))
|
||||
self.assertIn("`https://raw.githubusercontent.com/Abdess/retrobios/<ref>/<repo_path>`",
|
||||
rows[0])
|
||||
rows = generate_site._schema_field_rows(
|
||||
{"properties": {"a": {"description": "From https://x.test/<asset>, then."}}}, ("a",))
|
||||
self.assertEqual(rows, ["| `a` | From `https://x.test/<asset>`, then. |"])
|
||||
|
||||
def test_every_field_is_described_and_shown(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
for schema in (self.SCHEMA, self.SCHEMA["$defs"]["file"]):
|
||||
for key, spec in schema["properties"].items():
|
||||
self.assertTrue(spec.get("description"), key)
|
||||
self.assertIn(f"| `{key}` |", page)
|
||||
|
||||
def test_no_link_carries_an_escaped_bracket(self):
|
||||
html = _built(DIST / "data" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertNotRegex(html, r'href="[^"]*&lt;')
|
||||
self.assertNotRegex(html, r'href="[^"]*<')
|
||||
|
||||
|
||||
class EverySystemIdWithFilesHasAPage(unittest.TestCase):
|
||||
"""8.5: tyrian, scummvm, dos and 25 more had files and a null url."""
|
||||
|
||||
def test_built_index(self):
|
||||
items = json.loads(_built(API / "systems.json").read_text())["items"]
|
||||
orphans = [item["id"] for item in items if item["sha1"] and not item["url"]]
|
||||
self.assertEqual(orphans, [])
|
||||
|
||||
def test_first_file_then_emulator(self):
|
||||
files = {"f" * 40: {"path": "bios/DOS/DOS/a.bin"}}
|
||||
with mock.patch("siterender._card_pages", {}):
|
||||
place = generate_site._first_file_place(
|
||||
"dos", ["f" * 40], files, {}, lambda path, sha1: "dos")
|
||||
self.assertEqual(place, ("DOS", "/retrobios/systems/dos/#sha1-ffffffffffff"))
|
||||
place = generate_site._first_file_place(
|
||||
"tyrian", [], files, {"opentyrian": {"systems": ["tyrian"]}}, None)
|
||||
self.assertEqual(place, ("", "/retrobios/emulators/opentyrian/"))
|
||||
|
||||
|
||||
# A source permalink of each forge family the pages write.
|
||||
_PERMALINK = re.compile(
|
||||
r"\]\(https://(?P<host>[^/]+)/(?P<slug>[^/]+/[^/]+)/"
|
||||
r"(?:blob/|-/blob/|src/commit/)(?P<pin>[0-9a-f]{40})/(?P<path>[^#)]+)")
|
||||
|
||||
|
||||
class SourceLinksOnlyWhereTheFileIs(unittest.TestCase):
|
||||
"""8.6: the deploy reads the committed record, never a tree cache."""
|
||||
|
||||
def test_an_address_in_a_binary_reads_as_one(self):
|
||||
profile = {"source": "https://github.com/o/r", "source_commit": "e" * 40}
|
||||
text = sitesources._source_ref_markdown(
|
||||
profile, "Nesoid 2.5.0 libnes.so 0x455a4-0x45694 (setter), 0x8094-0x80ac")
|
||||
self.assertEqual(text, "offset `0x455a4-0x45694` in `Nesoid 2.5.0 libnes.so`, "
|
||||
"offset `0x8094-0x80ac` in `Nesoid 2.5.0 libnes.so`")
|
||||
|
||||
def test_only_a_recorded_path_links(self):
|
||||
profile = {"source": "https://github.com/o/r", "source_commit": "e" * 40}
|
||||
repo = upstream.parse_repo("https://github.com/o/r")
|
||||
record = {sitesources.tree_key(repo, "e" * 40): frozenset({"src/a.c"})}
|
||||
with mock.patch.object(sitesources, "_verified", return_value=record):
|
||||
sitesources._tree_paths.cache_clear()
|
||||
try:
|
||||
text = sitesources._source_ref_markdown(profile, "src/a.c:5, src/b.c:7")
|
||||
finally:
|
||||
sitesources._tree_paths.cache_clear()
|
||||
self.assertIn("[`src/a.c:5`](https://github.com/o/r/blob/" + "e" * 40 + "/src/a.c#L5)", text)
|
||||
self.assertIn("`src/b.c:7`", text)
|
||||
self.assertNotIn("src/b.c#", text)
|
||||
|
||||
def test_the_writer_keeps_held_paths_only(self):
|
||||
profile = {"source": "https://github.com/o/r", "source_commit": "e" * 40,
|
||||
"notes": "see src/c.c:3", "files": [{"source_ref": "src/a.c:5, gone.c:1"}]}
|
||||
with mock.patch.object(sitesources, "_cached_tree",
|
||||
return_value=frozenset({"src/a.c", "src/c.c", "x.c"})):
|
||||
held, unread = sitesources.build_source_paths({"p": profile}, [TMP])
|
||||
self.assertEqual(held, {"github.com/o/r@" + "e" * 40: ["src/a.c", "src/c.c"]})
|
||||
self.assertEqual(unread, [])
|
||||
|
||||
def test_a_tree_no_directory_answers_for_stays_unread(self):
|
||||
# TeknoParrotUI answers 404: listed folder by folder, every listing
|
||||
# failed and the tree read as holding nothing, so a fetch without its
|
||||
# cached tree dropped its four recorded paths.
|
||||
profile = {"source": "https://github.com/o/r", "source_commit": "e" * 40,
|
||||
"files": [{"source_ref": "src/a.c:5"}]}
|
||||
with mock.patch.object(sitesources, "_cached_tree", return_value=None), \
|
||||
mock.patch.object(sitesources.upstream, "list_directory",
|
||||
side_effect=upstream.UpstreamError("HTTP 404")):
|
||||
held, unread = sitesources.build_source_paths({"p": profile}, [TMP])
|
||||
self.assertEqual(held, {})
|
||||
self.assertEqual(unread, ["github.com/o/r@" + "e" * 40])
|
||||
|
||||
def test_every_rendered_link_is_recorded(self):
|
||||
record = json.loads(sitesources.SOURCE_PATHS.read_text(encoding="utf-8"))
|
||||
pages = sorted(_built(DOCS / "emulators").rglob("*.md*")) + sorted(DOCS.glob("gaps*.md*"))
|
||||
import measurement_records # noqa: PLC0415
|
||||
|
||||
# A Tested block links a record line and its data file in this
|
||||
# repository. The records are append-only, so a file a permalink
|
||||
# names is still in the tree.
|
||||
records = set(measurement_records.tree_files(REPO_ROOT))
|
||||
checked, stray = 0, []
|
||||
for page in pages:
|
||||
for found in _PERMALINK.finditer(page.read_text(encoding="utf-8")):
|
||||
key = f"{found['host']}/{found['slug']}@{found['pin']}"
|
||||
path = urllib.parse.unquote(found["path"])
|
||||
if "/Abdess/retrobios/" in found[0]:
|
||||
if path.startswith(f"{measurement_records.RECORDS_DIR}/") and path not in records:
|
||||
stray.append((page.name, key, path))
|
||||
continue
|
||||
checked += 1
|
||||
if path not in record.get(key, ()):
|
||||
stray.append((page.name, key, path))
|
||||
self.assertGreater(checked, 1000)
|
||||
self.assertEqual(stray[:20], [])
|
||||
|
||||
def test_the_record_is_read_by_the_deploy(self):
|
||||
"""The record is committed where the build reads it, sorted."""
|
||||
record = json.loads(sitesources.SOURCE_PATHS.read_text(encoding="utf-8"))
|
||||
self.assertGreater(len(record), 300)
|
||||
for key, paths in record.items():
|
||||
self.assertRegex(key, r"^[a-z0-9.-]+/.+@[0-9a-f]{40}$")
|
||||
self.assertEqual(paths, sorted(set(paths)), key)
|
||||
|
||||
|
||||
class TheReferencePagesAnswerTheirWords(unittest.TestCase):
|
||||
"""8.7, 8.8, 8.10 and 8.13."""
|
||||
|
||||
def test_find_or_check_a_file(self):
|
||||
page = generate_site.generate_files_page()
|
||||
self.assertTrue(page.startswith(
|
||||
"---\nterms: check my dump, md5 check, verify bios file, check hash, "
|
||||
"is my bios correct, is my bios right\n---\n"))
|
||||
self.assertIn("\n# Find or check a file\n\nCheck a BIOS dump: paste its hash "
|
||||
"or choose the file.\n", page)
|
||||
|
||||
def test_the_data_page(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
self.assertTrue(page.startswith(
|
||||
"---\nterms: manifest, schema, json schema, license, check a folder, "
|
||||
"check bios folder script, rate limit, api\n---\n"))
|
||||
self.assertIn("## Hosting, rate limits and caching", page)
|
||||
self.assertIn("HTTP 429", page)
|
||||
self.assertIn("crypto.subtle.digest(\"SHA-1\"", page)
|
||||
self.assertIn("api/v1/lookup/", page)
|
||||
self.assertIn("`preferred: true`", page)
|
||||
self.assertIn("`database.json`, `stats.json`, `release.json` and the manifests are not "
|
||||
"envelopes", page)
|
||||
self.assertIn("- MinUI, MyMinUI and NextUI have no list of their own "
|
||||
"(`native_list: none`). An empty list does not mean nothing is needed.", page)
|
||||
|
||||
def test_overview_ids_and_counts_are_not_indexed(self):
|
||||
html = _built(DIST / "platforms" / "batocera" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertRegex(html, r"<small data-pagefind-ignore(=\"true\")?>sony-playstation</small>")
|
||||
self.assertRegex(html, r"<span data-pagefind-ignore(=\"true\")?>\d+/\d+</span>")
|
||||
|
||||
|
||||
class TheReleaseRecordHasAVersion(unittest.TestCase):
|
||||
"""8.9."""
|
||||
|
||||
def test_the_writer_and_the_committed_record(self):
|
||||
self.assertEqual(release_record.SCHEMA_VERSION, 1)
|
||||
source = (REPO_ROOT / "scripts" / "release_record.py").read_text(encoding="utf-8")
|
||||
self.assertIn('{"schema_version": SCHEMA_VERSION, "tag": tag, "packs": packs}', source)
|
||||
schema = json.loads((REPO_ROOT / "schemas" / "release.schema.json").read_text())
|
||||
self.assertIn("schema_version", schema["required"])
|
||||
record = json.loads((REPO_ROOT / "release.json").read_text())
|
||||
self.assertEqual(record["schema_version"], 1)
|
||||
|
||||
|
||||
class NoEmptyCoreTable(unittest.TestCase):
|
||||
"""8.11: MiSTer and BizHawk declare cores: [] and had a page of nothing."""
|
||||
|
||||
def test_own_list_platforms_get_a_sentence(self):
|
||||
coverages = {"misterfpga": {"platform": "MiSTer FPGA", "config": {"cores": [], "systems": {}}}}
|
||||
moved: dict[str, str] = {}
|
||||
page = generate_site.generate_cross_reference(coverages, {}, moved)
|
||||
self.assertEqual(moved, {})
|
||||
self.assertIn(generate_site.OWN_LIST_ONLY, page)
|
||||
|
||||
def test_built_pages(self):
|
||||
_built(DOCS / "cross-reference.md")
|
||||
for name in ("misterfpga", "bizhawk"):
|
||||
self.assertFalse((DOCS / f"cross-reference-{name}.md").exists())
|
||||
self.assertIn(generate_site.OWN_LIST_ONLY, _page(f"platforms/{name}"))
|
||||
redirects = json.loads((REPO_ROOT / "website" / "src" / "generated" /
|
||||
"redirects.json").read_text())
|
||||
self.assertEqual(redirects.get("/cross-reference-misterfpga/"), "/platforms/misterfpga/")
|
||||
|
||||
|
||||
class FilesToSourceOnAPhone(unittest.TestCase):
|
||||
"""8.12: 601 rows on one page, no way to give a file."""
|
||||
|
||||
def test_parts_of_a_hundred_rows_each_with_an_upload(self):
|
||||
_built(DOCS / "gaps-files-to-source.md")
|
||||
parts = sorted(DOCS.glob("gaps-files-to-source*.md"))
|
||||
self.assertGreater(len(parts), 1)
|
||||
for part in parts:
|
||||
rows = [line for line in part.read_text(encoding="utf-8").splitlines()
|
||||
if line.startswith("| `")]
|
||||
self.assertLessEqual(len(rows), generate_site.FILES_TO_SOURCE_PART, part.name)
|
||||
for row in rows:
|
||||
self.assertIn(f"[Have it? Upload]({generate_site.UPLOAD_URL}", row)
|
||||
|
||||
def test_the_upload_lands_in_the_console_folder(self):
|
||||
with mock.patch("siterender._own_section", return_value=("Sony", "PlayStation")):
|
||||
self.assertEqual(generate_site._upload_folder({"systems": ["sony-playstation"]}),
|
||||
"Sony/PlayStation")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,260 +0,0 @@
|
||||
"""Search.
|
||||
|
||||
The search gate listed the queries that missed and no workflow ran it: its
|
||||
browser runner could not even start from the repository root. Console and
|
||||
error words had no field to reach their page ("wonderswan color", "bios not
|
||||
found"), parts of a split page carried the console page's weight and came
|
||||
first for "which mame version", and 707 pages gave search engines one of
|
||||
five template sentences of level 3 words as their description. These tests
|
||||
read the generator, the gate, the workflow and, where it is built, the site.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import sitestarlight # noqa: E402
|
||||
import validate_site # noqa: E402
|
||||
|
||||
TOOLS = REPO_ROOT / "website" / "tools"
|
||||
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "deploy-site.yml"
|
||||
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _front(text: str) -> dict:
|
||||
return yaml.safe_load(text.split("---\n")[1])
|
||||
|
||||
|
||||
def _gate_queries() -> dict:
|
||||
tree = ast.parse((TOOLS / "search_gate.py").read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "QUERIES":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("no QUERIES in search_gate.py")
|
||||
|
||||
|
||||
def _finalize(rel: str, body: str, authored: dict) -> dict:
|
||||
_target, text = sitestarlight.finalize_page(
|
||||
rel, body, level="", weight=None, browser_title="T", description="d",
|
||||
structured={}, terms={}, authored=authored)
|
||||
return {"front": _front(text), "text": text}
|
||||
|
||||
|
||||
class TheGateRunsAndBlocks(unittest.TestCase):
|
||||
def test_the_deploy_runs_the_gate_before_the_upload(self) -> None:
|
||||
steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["build"]["steps"]
|
||||
runs = [str(step.get("run", "")) for step in steps]
|
||||
uses = [str(step.get("uses", "")) for step in steps]
|
||||
gate = next(i for i, run in enumerate(runs)
|
||||
if "website/tools/search_gate.py --site-dir website/dist" in run)
|
||||
build = next(i for i, run in enumerate(runs) if "npm run build --prefix website" in run)
|
||||
upload = next(i for i, use in enumerate(uses) if "upload-pages-artifact" in use)
|
||||
self.assertLess(build, gate)
|
||||
self.assertLess(gate, upload)
|
||||
# The driver is pinned to a version and kept out of the lockfile.
|
||||
self.assertRegex(runs[gate], r"npm install --no-save --prefix website playwright-core@\d+\.\d+\.\d+")
|
||||
|
||||
def test_no_third_party_action_and_every_action_pinned(self) -> None:
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
for repo, ref in re.findall(r"uses:\s*([\w.-]+/[\w.-]+)@(\S+)", text):
|
||||
with self.subTest(action=repo):
|
||||
self.assertTrue(repo.startswith("actions/"))
|
||||
self.assertRegex(ref, r"^[0-9a-f]{40}$")
|
||||
|
||||
def test_the_runner_resolves_from_website_and_says_what_is_missing(self) -> None:
|
||||
source = (TOOLS / "measure.mjs").read_text(encoding="utf-8")
|
||||
self.assertIn("await import('playwright-core')", source)
|
||||
self.assertIn("npm install --no-save --prefix website playwright-core", source)
|
||||
self.assertNotRegex(source, r"^import \{ chromium \}", "a static import fails before any message")
|
||||
|
||||
def test_the_gate_serves_a_build_dir_and_fails_on_a_miss(self) -> None:
|
||||
source = (TOOLS / "search_gate.py").read_text(encoding="utf-8")
|
||||
self.assertIn("--site-dir", source)
|
||||
self.assertIn("serve.mjs", source)
|
||||
self.assertIn("return 0 if answered == len(QUERIES)", source)
|
||||
|
||||
def test_the_plans_queries_are_gated(self) -> None:
|
||||
queries = _gate_queries()
|
||||
expected = {
|
||||
"playstation bios": ("systems/sony-playstation/", 1),
|
||||
"please insert playstation cd-rom": ("wiki/troubleshooting/", 3),
|
||||
"bios not found": ("wiki/troubleshooting/", 3),
|
||||
"check my dump": ("files/", 1),
|
||||
"md5 check": ("files/", 1),
|
||||
"manifest": ("data/", 3),
|
||||
"events": ("wiki/installer/", 3),
|
||||
"exit code": ("wiki/installer/", 3),
|
||||
"rate limit": ("data/", 3),
|
||||
"ios": ("wiki/faq/", 3),
|
||||
"dc bios missing": ("emulators/flycast/", 3),
|
||||
"neogeo.zip": ("systems/snk/", 1),
|
||||
"scph5501": ("systems/sony-playstation/", 3),
|
||||
}
|
||||
for query, (page, rank) in expected.items():
|
||||
with self.subTest(query=query):
|
||||
pages, gated = queries[query]
|
||||
self.assertTrue(any(page.startswith(prefix) for prefix in pages))
|
||||
self.assertLessEqual(gated, rank)
|
||||
|
||||
def test_the_home_has_a_weight_budget(self) -> None:
|
||||
source = (TOOLS / "search_gate.py").read_text(encoding="utf-8")
|
||||
self.assertIn("HOME_BUDGET_KB = 80", source)
|
||||
|
||||
|
||||
class FieldsFromFrontMatter(unittest.TestCase):
|
||||
def test_consoles_and_terms_become_search_fields(self) -> None:
|
||||
text = _finalize("systems/snk.md", "# SNK\n\nBody.\n", {
|
||||
"consoles": ["Neo Geo", "Neo Geo Pocket Color"],
|
||||
"terms": "bios not found, bios missing",
|
||||
})["text"]
|
||||
self.assertIn('data-pagefind-meta="consoles:Neo Geo Neo Geo Pocket Color"', text)
|
||||
self.assertIn('data-pagefind-meta="terms:bios not found bios missing Neo Geo BIOS '
|
||||
'Neo Geo Pocket Color BIOS"', text)
|
||||
# A field value is an attribute: none of it reaches an excerpt.
|
||||
self.assertNotRegex(text, r">[^<]*bios missing[^<]*<")
|
||||
|
||||
def test_no_search_word_is_typed_in_the_module(self) -> None:
|
||||
source = (REPO_ROOT / "scripts" / "sitestarlight.py").read_text(encoding="utf-8")
|
||||
for words in ("bios not found", "mame version", "WonderSwan"):
|
||||
self.assertNotIn(words, source)
|
||||
|
||||
def test_a_page_without_fields_gets_none(self) -> None:
|
||||
text = _finalize("wiki/legal.md", "# Legal\n\nBody.\n", {})["text"]
|
||||
self.assertNotIn("consoles:", text)
|
||||
self.assertNotIn("terms:", text)
|
||||
|
||||
|
||||
class FindOrCheckAFile(unittest.TestCase):
|
||||
""""check my dump" and "md5 check" found emulator pages first."""
|
||||
|
||||
def test_the_page_weighs_two(self) -> None:
|
||||
rel = Path("files/index.md")
|
||||
self.assertEqual(gs.search_weight(rel, gs.page_level(rel)), 2)
|
||||
|
||||
def test_its_terms_become_a_field(self) -> None:
|
||||
text = _finalize("files/index.md", "# Find or check a file\n\nBody.\n",
|
||||
{"terms": "check dump, md5 check"})["text"]
|
||||
self.assertIn('data-pagefind-meta="terms:check dump md5 check"', text)
|
||||
|
||||
def test_the_sidebar_names_it_as_its_title(self) -> None:
|
||||
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
self.assertIn('{"Find or check a file": "files/index.md"}', source)
|
||||
self.assertNotIn('{"Find a file": "files/index.md"}', source)
|
||||
|
||||
|
||||
class PartPages(unittest.TestCase):
|
||||
def test_part_weight(self) -> None:
|
||||
self.assertEqual(gs.SEARCH_WEIGHT["part"], 0.5)
|
||||
self.assertEqual(gs.search_weight(Path("systems/arcade-mame-5.md"), "part"), 0.5)
|
||||
|
||||
def test_a_part_keeps_its_own_weight_and_neighbours(self) -> None:
|
||||
"""The MAME card parts write prev, next and searchWeight: the build
|
||||
kept only parent and wrote prev: false, next: false."""
|
||||
page = (
|
||||
"---\n"
|
||||
"parent:\n label: MAME\n link: mame.md\n"
|
||||
"prev:\n label: MAME files, part 2 of 8\n link: mame-files-2.md\n"
|
||||
"next:\n label: MAME files, part 4 of 8\n link: mame-files-4.md\n"
|
||||
f"searchWeight: {gs.search_weight(Path('emulators/mame-files-3.md'), 'part')}\n"
|
||||
"---\n\n# MAME files, part 3 of 8\n\nPart of [MAME](mame.md). "
|
||||
"This part holds 196 entries, `a.zip` to `b.zip`.\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
path = Path(tmp) / "emulators" / "mame-files-3.md"
|
||||
path.parent.mkdir()
|
||||
path.write_text(page, encoding="utf-8")
|
||||
gs.PART_PAGES.add("emulators/mame-files-3.md")
|
||||
self.addCleanup(gs.PART_PAGES.discard, "emulators/mame-files-3.md")
|
||||
gs.decorate_markdown_pages(Path(tmp))
|
||||
front = _front(path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertEqual(front["prev"], {"label": "MAME files, part 2 of 8",
|
||||
"link": "/retrobios/emulators/mame-files-2/"})
|
||||
self.assertEqual(front["next"]["link"], "/retrobios/emulators/mame-files-4/")
|
||||
self.assertEqual(front["parent"]["link"], "/retrobios/emulators/mame/")
|
||||
self.assertIn("This part holds 196 entries, a.zip to b.zip", front["description"])
|
||||
|
||||
def test_built_mame_parts_state_their_neighbours(self) -> None:
|
||||
part = DOCS / "emulators" / "mame-files-3.md"
|
||||
if not part.is_file():
|
||||
self.skipTest("site pages not generated")
|
||||
front = _front(part.read_text(encoding="utf-8"))
|
||||
if "searchWeight" not in front and front.get("prev") is False:
|
||||
self.skipTest("the emulator pages package is not merged")
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertTrue(str(front["prev"]["link"]).startswith("/retrobios/emulators/"))
|
||||
|
||||
|
||||
class Descriptions(unittest.TestCase):
|
||||
def test_titles_say_bios_files(self) -> None:
|
||||
saved = dict(gs.PAGE_DATA)
|
||||
self.addCleanup(lambda: (gs.PAGE_DATA.clear(), gs.PAGE_DATA.update(saved)))
|
||||
gs.PAGE_DATA["emulators/duckstation.md"] = {"hasPart": [{"name": "scph5501.bin"}]}
|
||||
self.assertEqual(gs._browser_title(Path("emulators/duckstation.md"), "DuckStation"),
|
||||
"DuckStation BIOS files")
|
||||
self.assertEqual(gs._browser_title(Path("systems/sega.md"), "Sega"), "Sega BIOS files")
|
||||
self.assertEqual(gs._browser_title(Path("systems/sega-dreamcast.md"), "Sega: Dreamcast"),
|
||||
"Dreamcast BIOS files (Sega)")
|
||||
|
||||
def test_an_emulator_page_is_described_by_its_intro(self) -> None:
|
||||
saved = dict(gs.PAGE_DATA)
|
||||
self.addCleanup(lambda: (gs.PAGE_DATA.clear(), gs.PAGE_DATA.update(saved)))
|
||||
gs.PAGE_DATA["emulators/duckstation.md"] = {"hasPart": [{"name": "scph5501.bin"}]}
|
||||
body = ("# DuckStation\n\nDuckStation emulates Sony PlayStation. It picks the BIOS for "
|
||||
"your game's region.\n\n<details>\n\nThe installer finds your platform.\n")
|
||||
text = gs._page_description(body, Path("emulators/duckstation.md"), "DuckStation")
|
||||
self.assertTrue(text.startswith("DuckStation emulates Sony PlayStation."))
|
||||
self.assertNotIn("source-pinned", text)
|
||||
|
||||
def test_a_console_page_is_described_by_its_start_line(self) -> None:
|
||||
body = "# Sega: Dreamcast\n\nPart of Sega.\n\n**Start with**: `dc_boot.bin`, `dc_flash.bin`.\n"
|
||||
text = gs._page_description(body, Path("systems/sega-dreamcast.md"), "Sega: Dreamcast")
|
||||
self.assertEqual(text, "Dreamcast BIOS files (Sega). Start with dc_boot.bin, dc_flash.bin.")
|
||||
|
||||
def test_the_faq_is_described_by_its_questions(self) -> None:
|
||||
body = ("# Common questions\n\n## Do I need BIOS files?\n\nYes.\n\n## Which one?\n\n"
|
||||
"Run the command with --dry-run.\n\n## Zip or not?\n\nZip.\n")
|
||||
text = gs._page_description(body, Path("wiki/faq.md"), "Common questions")
|
||||
self.assertTrue(text.startswith("Common questions: Do I need BIOS files?"))
|
||||
|
||||
def test_a_written_description_wins(self) -> None:
|
||||
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
self.assertIn('authored.get("description") or _page_description(', source)
|
||||
|
||||
def test_the_check_counts_templates(self) -> None:
|
||||
pages = {}
|
||||
for n in range(12):
|
||||
page = validate_site.Page(Path(f"/s/p{n}/index.html"))
|
||||
page.h1 = f"Core {n}"
|
||||
page.title = f"Core {n} BIOS files - RetroBIOS"
|
||||
page.descriptions = [f"Core {n}: emulator firmware profile with paths."]
|
||||
pages[page.path] = page
|
||||
issues = validate_site._check_description_templates(pages, Path("/s"))
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertIn("12 pages share", issues[0])
|
||||
for page in list(pages.values())[:2]:
|
||||
page.descriptions = [f"{page.h1} emulates a console of its own {page.h1[-1]}x."]
|
||||
self.assertEqual(validate_site._check_description_templates(pages, Path("/s")), [])
|
||||
|
||||
def test_built_descriptions_are_not_templates(self) -> None:
|
||||
if not (DIST / "index.html").is_file():
|
||||
self.skipTest("site not built")
|
||||
pages = {path.resolve(): validate_site._parse_page(path) for path in DIST.rglob("*.html")}
|
||||
content = {path: page for path, page in pages.items() if not page.redirect}
|
||||
self.assertEqual(validate_site._check_description_templates(content, DIST.resolve()), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,334 +0,0 @@
|
||||
"""System pages and file cards.
|
||||
|
||||
The Game Boy, NES and SNES sections said "Start with" a file one core of
|
||||
sixteen requires, under a home page saying they need none. The romset line
|
||||
said no MAME release matched sets MAME loads. The Neo Geo opened on files
|
||||
ares alone reads, and RetroArch had no install line. The Arcade page was
|
||||
sorted by storage folder: NAOMI, PGM and ZN-1 had no section, and their
|
||||
names were plain on every emulator page. Cards named emulators by id, part
|
||||
pages had no parent and no name, and four paragraphs came before the first
|
||||
console.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
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
|
||||
from sitestarlight import toc_slug # noqa: E402
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
|
||||
|
||||
def _unlinked(text: str) -> str:
|
||||
"""A start line's wording: each file it names links to its card."""
|
||||
return re.sub(r"\[(`[^`]*`)\]\([^)]*\)", r"\1", text)
|
||||
|
||||
|
||||
def _file(name: str, char: str, path: str = "") -> dict:
|
||||
return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 4096,
|
||||
"path": path or f"bios/Nintendo/Game Boy/{name}"}
|
||||
|
||||
|
||||
def _consumers(readers: dict[str, list[tuple[str, dict]]],
|
||||
systems: dict[str, dict[str, set[str]]] | None = None) -> types.SimpleNamespace:
|
||||
"""Consumers where each sha1 is read by the given (emulator, entry)."""
|
||||
files_of: dict[str, list] = {}
|
||||
entries: dict[str, dict[str, int]] = {}
|
||||
emulators: dict[str, set[str]] = {}
|
||||
for sha1, read_by in readers.items():
|
||||
for emulator, entry in read_by:
|
||||
files_of.setdefault(emulator, []).append(entry)
|
||||
entries.setdefault(sha1, {})[emulator] = len(files_of[emulator]) - 1
|
||||
emulators.setdefault(sha1, set()).add(emulator)
|
||||
return types.SimpleNamespace(
|
||||
platforms={}, emulators=emulators, emulator_entries=entries,
|
||||
emulator_files=files_of, installed={}, platform_systems=systems or {},
|
||||
platform_destinations={}, _db={"files": {}},
|
||||
)
|
||||
|
||||
|
||||
def _page(path: Path) -> str:
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest(f"{path.relative_to(REPO_ROOT)} not built")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _opening(html: str, anchor: str) -> str:
|
||||
"""The text of the first paragraph under a heading."""
|
||||
start = html.index("<p>", html.index(f'id="{anchor}"'))
|
||||
return re.sub(r"<[^>]+>", "", html[start:html.index("</p>", start)]).strip()
|
||||
|
||||
|
||||
class AFileFewRequireIsNoStartFile(unittest.TestCase):
|
||||
"""6.1: one requiring emulator decided the line for the console."""
|
||||
|
||||
def test_one_of_four_says_no_file_needed_and_names_it(self) -> None:
|
||||
boot = _file("dmg_boot.bin", "1")
|
||||
optional = {"required": False, "system": "nintendo-gb"}
|
||||
consumers = _consumers({"1" * 40: [
|
||||
("emux_gb", {"name": "dmg_boot.bin", "required": True}),
|
||||
("gambatte", optional), ("mgba", optional), ("sameboy", optional)]})
|
||||
line = _unlinked(gs._start_with([boot], consumers)[0])
|
||||
self.assertTrue(line.startswith("**No file needed** with "), line)
|
||||
self.assertRegex(line, r"Only \S.* requires `dmg_boot.bin`\.$")
|
||||
|
||||
def test_most_requiring_keeps_the_start_line(self) -> None:
|
||||
bios = _file("scph5501.bin", "2")
|
||||
required = {"name": "scph5501.bin", "required": True}
|
||||
consumers = _consumers({"2" * 40: [
|
||||
("a", required), ("b", required), ("c", {"required": False, "hle_fallback": True})]})
|
||||
self.assertEqual(_unlinked(gs._start_with([bios], consumers)[0]), "**Start with**: `scph5501.bin`.")
|
||||
|
||||
def test_an_entry_read_for_a_system_with_no_section_is_dropped(self) -> None:
|
||||
sample = _file("ym2608_adpcm_rom.bin", "3", "bios/Nintendo/NES/ym2608_adpcm_rom.bin")
|
||||
consumers = _consumers({"3" * 40: [
|
||||
("fbneo", {"name": "ym2608_adpcm_rom.bin", "required": True,
|
||||
"system": "no-such-system-anywhere"})]})
|
||||
self.assertEqual(gs._start_with([sample], consumers, section=("Nintendo", "NES")), [])
|
||||
|
||||
def test_the_home_usually_not_consoles_open_on_no_file_needed(self) -> None:
|
||||
home = _page(DIST / "index.html")
|
||||
match = re.search(r"<strong>Usually not</strong> for ([^<.]+)", home)
|
||||
self.assertIsNotNone(match, "the home page no longer says Usually not")
|
||||
names = re.split(r", | and ", match[1])
|
||||
sections = {"NES": ("nintendo", "nes"), "Super Nintendo": ("nintendo", "snes"),
|
||||
"Mega Drive": ("sega", "mega-drive"), "Game Boy": ("nintendo", "game-boy"),
|
||||
"Game Boy Advance": ("nintendo", "game-boy-advance")}
|
||||
for name in names:
|
||||
self.assertIn(name, sections, f"map {name!r} to its section")
|
||||
page, anchor = sections[name]
|
||||
html = _page(DIST / "systems" / page / "index.html")
|
||||
self.assertTrue(_opening(html, anchor).startswith("No file needed"), name)
|
||||
|
||||
|
||||
class TheRomsetLineMatchesByCrc(unittest.TestCase):
|
||||
"""6.2: a set with an extra BIOS revision read "No match"."""
|
||||
|
||||
def test_the_main_neogeo_set_names_every_mame_release(self) -> None:
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
main = next((f for sha1, f in db["files"].items() if sha1.startswith("447e2375")), None)
|
||||
if main is None or not (REPO_ROOT / main["path"]).is_file():
|
||||
self.skipTest("the main neogeo.zip is not in this checkout")
|
||||
line = gs._romset_line(main)
|
||||
self.assertIn("Holds every ROM of MAME 0.190 to 0.289", line)
|
||||
self.assertNotIn("No match", line)
|
||||
|
||||
|
||||
class TheNeoGeoOpensOnItsSet(unittest.TestCase):
|
||||
"""6.3: the set 26 emulators read leads, RetroArch gets its command."""
|
||||
|
||||
def test_the_lead_names_the_exceptions(self) -> None:
|
||||
neogeo = _file("neogeo.zip", "4", "bios/SNK/Neo Geo/neogeo.zip")
|
||||
epo = _file("neo-epo.sp1", "5", "bios/SNK/Neo Geo/neo-epo.sp1")
|
||||
mvs = {"name": "neogeo.zip", "required": True, "system": "snk-neogeo-mvs"}
|
||||
consumers = _consumers({
|
||||
"4" * 40: [(f"mame{n}", mvs) for n in range(4)],
|
||||
"5" * 40: [("ares", {"name": "neo-epo.sp1", "required": True,
|
||||
"system": "snk-neogeo"})],
|
||||
})
|
||||
lines = [_unlinked(line) for line in gs._start_with([neogeo, epo], consumers, section=("SNK", "Neo Geo"))]
|
||||
self.assertTrue(lines[0].startswith("**Start with** `neogeo.zip`. Keep it zipped. "))
|
||||
self.assertIn("ares reads `neo-epo.sp1` instead.", lines)
|
||||
cards = gs._console_cards("SNK", [epo, neogeo], consumers, set(),
|
||||
start=gs._start_order([neogeo, epo], consumers,
|
||||
("SNK", "Neo Geo")))
|
||||
self.assertIn("neogeo.zip", cards[0][1][2])
|
||||
|
||||
def test_a_setup_filing_the_set_elsewhere_gets_its_command(self) -> None:
|
||||
consumers = _consumers({}, systems={"a" * 40: {"retroarch": {"arcade"}}})
|
||||
pairs = gs._install_pairs([("batocera", "snk-neogeo")], "a" * 40, consumers)
|
||||
self.assertIn(("retroarch", "arcade"), pairs)
|
||||
block = "\n".join(gs._console_install_block("Neo Geo", pairs))
|
||||
self.assertIn("<li>RetroArch: <code", block)
|
||||
self.assertIn("--platform retroarch --system arcade</code>", block)
|
||||
|
||||
def test_the_built_section(self) -> None:
|
||||
html = _page(DIST / "systems" / "snk" / "index.html")
|
||||
section = html[html.index('id="neo-geo"'):]
|
||||
self.assertTrue(_opening(html, "neo-geo").startswith("Start with neogeo.zip"))
|
||||
self.assertIn("--platform retroarch --system arcade", re.sub(r"<[^>]+>", "", section))
|
||||
|
||||
|
||||
BOARDS = ("Sega NAOMI", "Sammy Atomiswave", "Capcom ZN-1", "IGS PolyGame Master",
|
||||
"Nintendo PlayChoice-10", "SNK Hyper Neo Geo 64")
|
||||
|
||||
|
||||
class EachArcadeBoardHasASection(unittest.TestCase):
|
||||
"""6.4: the Arcade page was cut by storage folder."""
|
||||
|
||||
def test_each_board_heads_its_page(self) -> None:
|
||||
# The page's title names the board, no h2 says it again.
|
||||
for name in BOARDS:
|
||||
slug = toc_slug(name)
|
||||
html = _page(DIST / "systems" / f"arcade-{slug}" / "index.html")
|
||||
self.assertRegex(html, rf"<h1[^>]*>Arcade: {re.escape(name)}</h1>", name)
|
||||
self.assertNotRegex(html, rf"<h2[^>]*>{re.escape(name)}<", name)
|
||||
|
||||
def test_every_board_on_an_emulator_systems_row_is_a_link(self) -> None:
|
||||
if not (DIST / "emulators").is_dir():
|
||||
self.skipTest("site not built")
|
||||
plain = []
|
||||
for page in sorted((DIST / "emulators").glob("*/index.html")):
|
||||
row = re.search(r"<td>Systems</td>\s*<td>(.*?)</td>", page.read_text(encoding="utf-8"),
|
||||
re.S)
|
||||
if not row:
|
||||
continue
|
||||
text = re.sub(r"<a [^>]*>.*?</a>", "", row[1])
|
||||
plain += [(page.parent.name, name) for name in BOARDS if name in text]
|
||||
self.assertEqual(plain, [])
|
||||
|
||||
def test_the_arcade_page_lists_the_boards_first(self) -> None:
|
||||
md = _page(CONTENT / "systems" / "arcade.md")
|
||||
headings = re.findall(r"^## (.+?) \{", md, re.M)
|
||||
self.assertIn("Sega NAOMI", headings)
|
||||
self.assertLess(headings.index("Sega NAOMI"), headings.index(gs.OTHER_ARCADE_LABEL))
|
||||
|
||||
|
||||
class CardsNameEmulatorsByName(unittest.TestCase):
|
||||
"""6.6: 19,499 card links read the profile id."""
|
||||
|
||||
def test_the_display_name_is_the_link_text(self) -> None:
|
||||
sha1 = "6" * 40
|
||||
consumers = _consumers({sha1: [("melonds_ds", {"name": "bios7.bin"})]})
|
||||
link = gs._emulator_links(sha1, consumers)[0]
|
||||
self.assertTrue(link.startswith("[melonDS DS]("), link)
|
||||
|
||||
def test_no_built_card_link_reads_an_id(self) -> None:
|
||||
if not (DIST / "systems").is_dir():
|
||||
self.skipTest("site not built")
|
||||
profiles = gs._profiles_on_disk()
|
||||
bad = set()
|
||||
for page in (DIST / "systems").glob("*/index.html"):
|
||||
for emulator, text in re.findall(
|
||||
r'<a href="/retrobios/emulators/([^/"#]+)/[^"]*">([^<]+)</a>',
|
||||
page.read_text(encoding="utf-8")):
|
||||
display = str((profiles.get(emulator) or {}).get("emulator") or emulator)
|
||||
if text == emulator and display != emulator:
|
||||
bad.add(emulator)
|
||||
self.assertEqual(sorted(bad), [])
|
||||
|
||||
|
||||
class PartPagesKnowWhereTheyAre(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
# system_pages records where consoles went, for every later page.
|
||||
gs.reset_console_pages()
|
||||
|
||||
"""6.7: 128 system pages had no parent, parts were "part 3 of 5"."""
|
||||
|
||||
def test_every_part_has_a_parent_and_a_name(self) -> None:
|
||||
if not (CONTENT / "systems").is_dir():
|
||||
self.skipTest("pages not generated")
|
||||
orphans, numbered = [], []
|
||||
for page in sorted((CONTENT / "systems").glob("*.md*")):
|
||||
text = page.read_text(encoding="utf-8")
|
||||
# A part is titled "<maker>: <console>" or "<name> files a to b".
|
||||
title = re.search(r"^title: (.*)$", text, re.M)
|
||||
part = title and (": " in title[1] or re.search(r" files .+ to ", title[1]))
|
||||
if part and "\nparent:" not in text.split("\n---\n", 1)[0]:
|
||||
orphans.append(page.name)
|
||||
if re.search(r"^title: .*part \d+ of \d+", text, re.M | re.I):
|
||||
numbered.append(page.name)
|
||||
self.assertEqual(orphans, [])
|
||||
self.assertEqual(numbered, [])
|
||||
|
||||
def test_parts_are_even_and_linked_to_their_neighbours(self) -> None:
|
||||
files = [_file(f"rom{i:02}.bin", "0123456789"[i], f"bios/Sony/PlayStation 2/rom{i:02}.bin")
|
||||
for i in range(10)]
|
||||
consumers = _consumers({})
|
||||
with gs.markup.using("mkdocs"):
|
||||
one = sum(len(line) + 1 for line in gs._system_file_card(files[0], consumers))
|
||||
pages = gs.system_pages("Sony", {"PlayStation 2": files}, consumers, budget=one * 7)
|
||||
parts = [stem for stem in pages if stem != "sony"]
|
||||
self.assertEqual(len(parts), 2)
|
||||
counts = [pages[stem][0].count('class="rb-sys-file"') for stem in parts]
|
||||
self.assertEqual(counts, [5, 5])
|
||||
first = pages[parts[0]][0]
|
||||
self.assertIn("parent:\n label: \"Sony\"\n link: \"/systems/sony/\"", first)
|
||||
self.assertIn(f"next:\n label: \"PlayStation 2 files rom05.bin to rom09.bin\"\n"
|
||||
f" link: \"/systems/{parts[1]}/\"", first)
|
||||
self.assertIn("# PlayStation 2 files rom00.bin to rom04.bin", first)
|
||||
self.assertIn(f"searchWeight: {gs._part_weight()}", first)
|
||||
|
||||
|
||||
def _consoles(page: str) -> list[str]:
|
||||
"""The consoles: front matter of a generated page."""
|
||||
front = page.split("\n---\n", 1)[0] if page.startswith("---\n") else ""
|
||||
block = re.search(r"^consoles:\n((?: - .*\n?)+)", front + "\n", re.M)
|
||||
return [json.loads(line[4:]) for line in block[1].splitlines()] if block else []
|
||||
|
||||
|
||||
class ConsolesFieldNamesMachinesOnce(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
# system_pages records where consoles went, for every later page.
|
||||
gs.reset_console_pages()
|
||||
|
||||
"""The consoles search field: only machine sections, and a console
|
||||
with a page of its own is that page's, or "dolphin" found Other and
|
||||
"playstation bios" found Sony before the PlayStation page."""
|
||||
|
||||
def test_no_emulator_folder_is_a_console(self) -> None:
|
||||
for maker in ("Other", "Game Engines"):
|
||||
page = gs.system_pages(maker, {"dolphin": [_file("a.bin", "a")]}, _consumers({}))
|
||||
self.assertEqual(_consoles(page[gs._slug(maker)][0]), [], maker)
|
||||
|
||||
def test_a_console_with_its_own_page_is_left_to_it(self) -> None:
|
||||
big = [_file(f"r{i:02}.bin", "0123456789"[i], f"bios/Sega/Saturn/r{i:02}.bin")
|
||||
for i in range(10)]
|
||||
small = [_file("pico.bin", "p", "bios/Sega/Pico/pico.bin")]
|
||||
consumers = _consumers({})
|
||||
with gs.markup.using("mkdocs"):
|
||||
one = sum(len(line) + 1 for line in gs._system_file_card(big[0], consumers))
|
||||
# Sega splits by size. Every Sony console has a page whatever its size.
|
||||
pages = gs.system_pages("Sega", {"Saturn": big, "Pico": small},
|
||||
consumers, budget=one * 12)
|
||||
self.assertIn("sega-saturn", pages)
|
||||
self.assertEqual(_consoles(pages["sega"][0]), ["Pico"])
|
||||
self.assertEqual(_consoles(pages["sega-saturn"][0]), ["Saturn"])
|
||||
self.addCleanup(gs.reset_console_pages)
|
||||
sony = gs.system_pages("Sony", {"PlayStation": big, "PocketStation": small},
|
||||
consumers, budget=one * 12)
|
||||
self.assertEqual(_consoles(sony["sony"][0]), [])
|
||||
self.assertEqual(_consoles(sony["sony-pocketstation"][0]), ["PocketStation"])
|
||||
|
||||
|
||||
class OneLineBeforeTheFirstConsole(unittest.TestCase):
|
||||
"""6.8: four paragraphs, word for word on every maker's page."""
|
||||
|
||||
def test_the_intro_is_one_line_and_the_legend_closed(self) -> None:
|
||||
page = gs.generate_system_page("SNK", {"Neo Geo": [_file("a.bin", "a")]}, _consumers({}))
|
||||
body = page.split("# SNK\n", 1)[1]
|
||||
before = body[:body.index("## Neo Geo")]
|
||||
self.assertTrue(before.lstrip().startswith(
|
||||
"Every BIOS file for SNK machines, one card per file.\n"))
|
||||
legend = before[before.index("<section data-pagefind-ignore>"):]
|
||||
self.assertIn("<summary>How to read a card</summary>", legend)
|
||||
self.assertNotIn(" open>", legend)
|
||||
self.assertNotIn("JSON", before)
|
||||
self.assertIn("as JSON](../api/v1/systems/snk.json)", page.split("## Next steps")[1])
|
||||
|
||||
|
||||
class SetupsGroupedByPlace(unittest.TestCase):
|
||||
"""6.9: each setup repeated the name it gives the file."""
|
||||
|
||||
def test_one_line_per_place(self) -> None:
|
||||
places = [("Batocera", "b.md", ["scph5501.bin"]), ("KNULLI", "k.md", ["scph5501.bin"]),
|
||||
("RetroDECK", "r.md", ["bios/scph5501.bin"]), ("EmuDeck", "e.md", [])]
|
||||
self.assertEqual(gs._places_lines(places), [
|
||||
"<code>scph5501.bin</code>: [Batocera](b.md), [KNULLI](k.md)",
|
||||
"<code>bios/scph5501.bin</code>: [RetroDECK](r.md)",
|
||||
"[EmuDeck](e.md)",
|
||||
])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,356 +0,0 @@
|
||||
"""Wiki and release docs.
|
||||
|
||||
Each class names the finding it holds: an error message a reader types
|
||||
into search and no page that says it, an arcade answer written for
|
||||
maintainers, a subfolder setting that hides every file, FAQ labels that
|
||||
read as broken crumbs, no legal route to a BIOS, a packager who cannot pin
|
||||
the installer, a backslash in a PowerShell command, an events table with no
|
||||
sample, the installer options printed twice, and a glossary "Pack" that
|
||||
ignored parts. Every claim the copy makes is held against the repository.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import sitediagrams # noqa: E402
|
||||
from check_release import ( # noqa: E402
|
||||
INSTALLER,
|
||||
SUMS,
|
||||
Asset,
|
||||
compare_release,
|
||||
installer_files,
|
||||
)
|
||||
|
||||
WIKI = REPO_ROOT / "wiki"
|
||||
FENCED = re.compile(r"^```.*?^```", re.S | re.M)
|
||||
ARCADE_ANCHOR = "technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match"
|
||||
|
||||
|
||||
def _wiki(name: str) -> str:
|
||||
return (WIKI / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _front(text: str) -> dict:
|
||||
return yaml.safe_load(text.split("---", 2)[1])
|
||||
|
||||
|
||||
def _section(text: str, heading: str) -> str:
|
||||
start = re.search(rf"(?m)^#+ {re.escape(heading)}(?:\s*\{{[^}}]*\}})?\n", text)
|
||||
if start is None:
|
||||
raise AssertionError(f"no section {heading!r}")
|
||||
rest = text[start.end():]
|
||||
blank = FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest)
|
||||
end = re.search(r"(?m)^#{1,2} ", blank)
|
||||
return rest[: end.start()] if end else rest
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TroubleshootingSaysTheWordsOnScreen(unittest.TestCase):
|
||||
"""C6, N3: "bios not found" ranked the page 4th, and the most quoted
|
||||
on-screen message found nothing."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("troubleshooting.md")
|
||||
|
||||
def test_step_one_opens_on_the_messages(self):
|
||||
step = _flat(_section(self.text, "1. Check my BIOS folder"))
|
||||
self.assertIn("**BIOS not found**, **BIOS missing** or **firmware missing**?", step)
|
||||
self.assertLess(step.index("BIOS not found"), step.index("```"))
|
||||
|
||||
def test_each_message_is_cited(self):
|
||||
for source in ("bsx.cpp:1232", "fds.c:312", "libretro.cpp:866", "msg_hash_us.h:32805"):
|
||||
with self.subTest(source=source):
|
||||
self.assertIn(source, self.text)
|
||||
|
||||
def test_the_disc_message(self):
|
||||
section = _flat(_section(self.text, "The BIOS is fine, the game still fails"))
|
||||
self.assertIn('"Please insert PlayStation CD-ROM"?** The BIOS works.', section)
|
||||
self.assertIn("open its `.cue` or `.m3u` file, not a `.bin`", section)
|
||||
|
||||
def test_terms_for_search(self):
|
||||
terms = _front(self.text)["terms"]
|
||||
for word in ("bios not found", "bios missing", "firmware missing", "bios error"):
|
||||
with self.subTest(word=word):
|
||||
self.assertIn(word, terms)
|
||||
|
||||
|
||||
class SubfolderTrap(unittest.TestCase):
|
||||
"""C8: a BIOS setting pointed at system/dc, while Flycast adds dc/ itself."""
|
||||
|
||||
def test_step_three_names_it(self):
|
||||
step = _flat(_section(_wiki("troubleshooting.md"), "3. Check the folder and the names"))
|
||||
self.assertIn("subfolder, like `system/dc`?", step)
|
||||
self.assertIn("like `dc/` for Flycast", step)
|
||||
|
||||
def test_flycast_reads_dc(self):
|
||||
flycast = _profile("flycast")
|
||||
boot = next(e for e in flycast["files"] if e["name"] == "dc_boot.bin")
|
||||
self.assertEqual(boot["path"], "dc/dc_boot.bin")
|
||||
self.assertIn('appends "dc/" to the system directory', flycast["notes"])
|
||||
|
||||
|
||||
def _arcade_profiles() -> dict[str, dict]:
|
||||
"""The MAME and FinalBurn libretro cores whose name does not give the version."""
|
||||
found = {}
|
||||
for path in sorted((REPO_ROOT / "emulators").glob("*.yml")):
|
||||
if not re.match(r"(mame|fbneo|fbalpha)", path.stem):
|
||||
continue
|
||||
profile = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if "libretro" not in str(profile.get("type", "")):
|
||||
continue
|
||||
# Recalbox's MAME 0.258 and 0.278 carry the answer in their name.
|
||||
if _version(profile) in _retroarch_name(profile):
|
||||
continue
|
||||
found[path.stem] = profile
|
||||
return found
|
||||
|
||||
|
||||
def _retroarch_name(profile: dict) -> str:
|
||||
""""Arcade (MAME 2003)" gives "MAME 2003", "Arcade (FinalBurn Neo) (Neo-Geo)"
|
||||
gives "FinalBurn Neo (Neo-Geo)"."""
|
||||
inner = profile["display_name"].split("(", 1)[1]
|
||||
return inner.replace(")", "", 1).strip()
|
||||
|
||||
|
||||
def _version(profile: dict) -> str:
|
||||
return str(profile.get("mame_version") or profile["core_version"]).split()[0].lstrip("v")
|
||||
|
||||
|
||||
class ArcadeVersionTable(unittest.TestCase):
|
||||
"""C4, N3: the answer named profile ids and left out FinalBurn Neo."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("technical-faq.md")
|
||||
section = _section(self.text, "Which MAME version do the arcade BIOS sets match?")
|
||||
self.section = section
|
||||
self.rows = {}
|
||||
for line in section.splitlines():
|
||||
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
if (line.startswith("|") and len(cells) == 2 and not set(cells[0]) <= {"-"}
|
||||
and cells[1] != "Sets from"):
|
||||
for name in cells[0].split(", "):
|
||||
self.rows[name] = cells[1]
|
||||
|
||||
def test_the_section_opens_on_the_answer(self):
|
||||
self.assertTrue(self.section.lstrip().startswith(
|
||||
"Your game sets and BIOS sets must come from the same version as your arcade\nemulator."))
|
||||
self.assertIn("| Emulator, as RetroArch names it | Sets from |", self.section)
|
||||
self.assertLess(self.section.index("| Emulator"), self.section.index("### How this is known"))
|
||||
|
||||
def test_every_core_has_its_row_and_version(self):
|
||||
profiles = _arcade_profiles()
|
||||
self.assertGreaterEqual(len(profiles), 20)
|
||||
for key, profile in profiles.items():
|
||||
name = _retroarch_name(profile)
|
||||
with self.subTest(profile=key, name=name):
|
||||
self.assertIn(name, self.rows)
|
||||
self.assertIn(_version(profile), self.rows[name])
|
||||
|
||||
def test_no_row_without_a_core(self):
|
||||
names = {_retroarch_name(p) for p in _arcade_profiles().values()}
|
||||
self.assertEqual(set(self.rows) - names, set())
|
||||
|
||||
def test_terms_and_links(self):
|
||||
terms = _front(self.text)["terms"]
|
||||
for word in ("mame version", "romset version", "which mame"):
|
||||
with self.subTest(word=word):
|
||||
self.assertIn(word, terms)
|
||||
arcade = _section(_wiki("troubleshooting.md"), "An arcade game does not start")
|
||||
self.assertIn(ARCADE_ANCHOR, arcade)
|
||||
self.assertNotIn("emulators/index.md", arcade)
|
||||
faq = _wiki("faq.md")
|
||||
self.assertIn("## My arcade game fails (which MAME version?) "
|
||||
"{ #my-arcade-game-does-not-start }", faq)
|
||||
|
||||
|
||||
class FaqLabels(unittest.TestCase):
|
||||
"""C9, N10: "Reference:" read as a broken crumb, "E::" as a typo, iOS unfound."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("faq.md")
|
||||
|
||||
def test_no_reference_label(self):
|
||||
self.assertNotRegex(self.text, r"(?m)^Reference:")
|
||||
arcade = _section(self.text, "My arcade game fails (which MAME version?)")
|
||||
self.assertIn(f"Game sets must match the emulator's version:\n[the list]({ARCADE_ANCHOR}).",
|
||||
arcade)
|
||||
|
||||
def test_the_card_sentence(self):
|
||||
self.assertIn("For RetroArch on a Switch,\nwith the card in drive `E:`, run:", self.text)
|
||||
|
||||
def test_ios(self):
|
||||
self.assertIn("iPhone, iPad (iOS)", self.text)
|
||||
|
||||
|
||||
class CopyYourOwnBios(unittest.TestCase):
|
||||
"""C-other, need 12: no page answered the legal route."""
|
||||
|
||||
def test_each_console_has_a_guide(self):
|
||||
section = _section(_wiki("faq.md"), "Can I copy my own console's BIOS?")
|
||||
links = re.findall(r"\[([^\]]+)\]\((https://[^)]+)\)", section)
|
||||
self.assertEqual(dict(links), {
|
||||
"PCSX2": "https://pcsx2.net/docs/setup/bios/",
|
||||
"melonDS": "https://melonds.kuribo64.net/faq.php",
|
||||
})
|
||||
|
||||
|
||||
def _sums_with(names: list[str]) -> dict[str, str]:
|
||||
return {name: f"{i:064x}" for i, name in enumerate(names, 1)}
|
||||
|
||||
|
||||
class PinnedInstaller(unittest.TestCase):
|
||||
"""J9, D5: release tags are deleted and the release carried no installer."""
|
||||
|
||||
def test_the_list_holds_the_installer_and_every_file_list(self):
|
||||
names = installer_files(REPO_ROOT / "install")
|
||||
self.assertEqual(names[:3], list(INSTALLER))
|
||||
self.assertIn("retroarch.json", names)
|
||||
self.assertEqual(len(names), 3 + len(list((REPO_ROOT / "install").glob("*.json"))))
|
||||
|
||||
def test_a_release_without_the_installer_is_refused(self):
|
||||
record = {"tag": "v1", "packs": {"P.zip": {"download_size": 5, "assets": ["P.zip"]}}}
|
||||
files = ["install.py", "install.sh", "install.ps1", "retroarch.json"]
|
||||
sums = _sums_with(["P.zip", *files])
|
||||
assets = {name: Asset(5, digest) for name, digest in sums.items()}
|
||||
assets[SUMS] = Asset(1, None)
|
||||
self.assertEqual(compare_release(record, sums, assets, "v1", installer=files), [])
|
||||
|
||||
del sums["install.py"]
|
||||
del assets["install.py"]
|
||||
kinds = {(f.kind, f.name) for f in compare_release(record, sums, assets, "v1",
|
||||
installer=files)}
|
||||
self.assertEqual(kinds, {("no-installer", "install.py")})
|
||||
|
||||
def test_listed_but_not_uploaded_is_missing(self):
|
||||
record = {"tag": "v1", "packs": {}}
|
||||
sums = _sums_with(list(INSTALLER))
|
||||
assets = {name: Asset(5, digest) for name, digest in sums.items() if name != "install.sh"}
|
||||
assets[SUMS] = Asset(1, None)
|
||||
kinds = {(f.kind, f.name) for f in compare_release(record, sums, assets, "v1",
|
||||
installer=list(INSTALLER))}
|
||||
self.assertEqual(kinds, {("missing", "install.sh")})
|
||||
|
||||
def test_the_release_steps_ship_them(self):
|
||||
text = _wiki("release-process.md")
|
||||
self.assertIn("cp install.py install.sh install.ps1 install/*.json dist/", text)
|
||||
sums = re.search(r"sha256sum [^>]+> SHA256SUMS.txt", text, re.S).group(0)
|
||||
upload = re.search(r"for f in dist/SHA256SUMS.txt.*?; do", text, re.S).group(0)
|
||||
for name in (*INSTALLER, "*.json"):
|
||||
with self.subTest(name=name):
|
||||
self.assertIn(name, sums)
|
||||
for name in INSTALLER:
|
||||
with self.subTest(upload=name):
|
||||
self.assertIn(f"dist/{name}", upload)
|
||||
self.assertIn("install/*.json", upload)
|
||||
|
||||
def test_the_installer_page_pins_one_commit(self):
|
||||
# v2026.10.10 carries no install.py: until a release does, the
|
||||
# section pins a commit, never a release address.
|
||||
text = _wiki("installer.md")
|
||||
section = _flat(_section(text, "For packagers"))
|
||||
for words in ("raw.githubusercontent.com/Abdess/retrobios/$commit",
|
||||
'"$base/install/retroarch.json"', "--manifest retroarch.json",
|
||||
"sha256sum --check", "RETROBIOS_REF=$commit"):
|
||||
with self.subTest(words=words):
|
||||
self.assertIn(words, section)
|
||||
self.assertNotIn("releases/download/", section)
|
||||
self.assertNotIn("releases/latest/download/", section)
|
||||
terms = _front(text)["terms"]
|
||||
for word in ("manifest", "events", "exit code", "schema"):
|
||||
with self.subTest(term=word):
|
||||
self.assertIn(word, terms)
|
||||
|
||||
|
||||
class NoBackslashInProseCode(unittest.TestCase):
|
||||
"""D9: `irm \\| iex` showed its backslash outside a table."""
|
||||
|
||||
def test_wiki(self):
|
||||
for path in sorted(WIKI.glob("*.md")):
|
||||
text = FENCED.sub("", path.read_text(encoding="utf-8"))
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
if line.lstrip().startswith("|"):
|
||||
continue
|
||||
with self.subTest(page=path.name, line=number):
|
||||
self.assertNotRegex(line, r"`[^`]*\\\|[^`]*`")
|
||||
|
||||
|
||||
def _install_module():
|
||||
spec = importlib.util.spec_from_file_location("install_r4wiki", REPO_ROOT / "install.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class EventsTranscript(unittest.TestCase):
|
||||
"""D10: the events table had no sample of what a run writes."""
|
||||
|
||||
def test_five_lines_that_follow_the_schema(self):
|
||||
section = _section(_wiki("installer.md"), "Machine-readable events")
|
||||
block = re.search(r"```json\n(.*?)\n```", section, re.S)
|
||||
self.assertIsNotNone(block)
|
||||
lines = block.group(1).splitlines()
|
||||
self.assertEqual(len(lines), 5)
|
||||
keys = _install_module().EVENT_KEYS
|
||||
events = [json.loads(line) for line in lines]
|
||||
for event in events:
|
||||
with self.subTest(event=event["event"]):
|
||||
self.assertEqual(list(event), ["event", *keys[event["event"]]])
|
||||
self.assertEqual(events[0]["event"], "start")
|
||||
self.assertEqual(events[0]["mode"], "check")
|
||||
self.assertEqual(events[-1], {"event": "end", "code": 1})
|
||||
|
||||
|
||||
class OneOptionsTable(unittest.TestCase):
|
||||
"""T-F9: the options printed twice, the offline install 57,750 px down."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("installer.md")
|
||||
|
||||
def test_each_option_links_to_its_row(self):
|
||||
# The glance is written from the parser when the page is built.
|
||||
glance = _section(sitediagrams.expand_glance(self.text), "Options at a glance")
|
||||
table = _section(self.text, "Options")
|
||||
self.assertNotIn("installer-options", glance)
|
||||
self.assertNotIn("|", glance)
|
||||
for usage, _ in sitediagrams.installer_options():
|
||||
flag = usage.split()[0].rstrip(",")
|
||||
anchor = f"option-{flag.lstrip('-')}"
|
||||
with self.subTest(option=flag):
|
||||
self.assertIn(f"[`{flag}`](#{anchor})", glance)
|
||||
self.assertRegex(table, rf'(?m)^\| <span id="{anchor}"></span>`{flag}[ `]')
|
||||
|
||||
def test_the_offline_install_comes_early(self):
|
||||
headings = re.findall(r"(?m)^## (.+?)(?:\s*\{[^}]*\})?$", FENCED.sub("", self.text))
|
||||
self.assertLess(headings.index("Without a network (offline install)"),
|
||||
headings.index("The full-screen interface"))
|
||||
|
||||
|
||||
class GlossaryPack(unittest.TestCase):
|
||||
"""C-other: packs come in parts and leave out files the emulator ships."""
|
||||
|
||||
def test_definition(self):
|
||||
text = _flat(_wiki("glossary.md"))
|
||||
self.assertIn("**Pack** : The ZIP, sometimes in parts, with one setup's BIOS files.", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,227 +0,0 @@
|
||||
"""Round 5, 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 a build plugin run under
|
||||
node and skip without it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from common import list_release_pack_platforms # noqa: E402
|
||||
|
||||
WEBSITE = REPO_ROOT / "website"
|
||||
DIST = WEBSITE / "dist"
|
||||
COMPONENTS = WEBSITE / "src" / "components"
|
||||
SITE_TS = WEBSITE / "src" / "scripts" / "site.ts"
|
||||
PLUGINS = WEBSITE / "src" / "plugins"
|
||||
SHORT_CODE = PLUGINS / "rehype-short-code.mjs"
|
||||
STACK_TABLES = PLUGINS / "rehype-stack-tables.mjs"
|
||||
ENTITY = re.compile(r"&(?:#\d+|#x[0-9a-f]+|[a-z]+);", re.I)
|
||||
|
||||
|
||||
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", "-e", script],
|
||||
capture_output=True, text=True, check=True, cwd=WEBSITE,
|
||||
env={"NODE_DISABLE_COMPILE_CACHE": "1", "PATH": str(Path(node).parent)},
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _text(page: str) -> str:
|
||||
"""A built page's visible words: tags dropped, entities read."""
|
||||
return html.unescape(re.sub(r"<[^>]+>", "", page))
|
||||
|
||||
|
||||
def _picker_packs() -> set[str]:
|
||||
"""The setups the built picker offers a release ZIP line for."""
|
||||
page = _built("which-pack")
|
||||
match = re.search(r'data-release="([^"]*)"', page)
|
||||
if not match:
|
||||
raise AssertionError("which-pack has no picker release data")
|
||||
return set(json.loads(html.unescape(match[1]))["packs"])
|
||||
|
||||
|
||||
class PickerFollowsTheRegistry(unittest.TestCase):
|
||||
"""2.1: the picker said "The ZIP of release v2026.10.10 holds 5,804
|
||||
files" for ROCKNIX while its setup page and get/ page said "No ZIP for
|
||||
ROCKNIX" (N2, J5). The picker read the raw release.json."""
|
||||
|
||||
def test_source(self):
|
||||
source = _read(COMPONENTS / "FilePicker.astro")
|
||||
self.assertIn("import release from '../generated/release-advertised.json';", source)
|
||||
self.assertNotIn("release.json'", source)
|
||||
|
||||
def test_picker_offers_only_registry_packs(self):
|
||||
offered = _picker_packs()
|
||||
self.assertTrue(offered)
|
||||
released = set(list_release_pack_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||
self.assertLessEqual(offered, released)
|
||||
|
||||
def test_picker_setup_and_get_pages_agree(self):
|
||||
offered = _picker_packs()
|
||||
setups = json.loads(_read(DIST / "data" / "picker" / "index.json"))
|
||||
self.assertTrue(setups)
|
||||
for entry in setups:
|
||||
name = entry["platform"]
|
||||
with self.subTest(platform=name):
|
||||
get_page = _text(_built(f"get/{name}"))
|
||||
setup_page = _text(_built(f"platforms/{name}"))
|
||||
no_zip = f"No ZIP for {entry['name']}."
|
||||
self.assertEqual(name in offered, no_zip not in get_page)
|
||||
self.assertEqual(name in offered, no_zip not in setup_page)
|
||||
|
||||
|
||||
class PathsBreakAtSeparators(unittest.TestCase):
|
||||
"""2.2: `~/.var/app/org.libretro.RetroArch/config/r` then
|
||||
`etroarch/system/` on platforms/retroarch/ at 390 px (V8)."""
|
||||
|
||||
def test_the_rule(self):
|
||||
uri = json.dumps(SHORT_CODE.as_uri())
|
||||
out = _node(
|
||||
f"import {{ markRawCode, withBreaks, breaksAtSeparators }} from {uri};"
|
||||
"console.log(JSON.stringify(["
|
||||
"markRawCode('<td><code>~/.config/retroarch/system/aaaaaaaaaaa/</code> <code>bios/a.bin</code></td>'),"
|
||||
"markRawCode('<td><code class=\"rb-path\">C:\\\\RetroArch-Win64\\\\system\\\\aaaaaaaaaaaaa</code></td>'),"
|
||||
"withBreaks('a/b/'), breaksAtSeparators('x'.repeat(31), false),"
|
||||
"breaksAtSeparators('a/' + 'x'.repeat(30), true), breaksAtSeparators('a/b', false)]));"
|
||||
)
|
||||
self.assertEqual(json.loads(out), [
|
||||
'<td><code>~/<wbr>.config/<wbr>retroarch/<wbr>system/<wbr>aaaaaaaaaaa/</code>'
|
||||
' <code class="rb-short">bios/a.bin</code></td>',
|
||||
'<td><code class="rb-path">C:\\<wbr>RetroArch-Win64\\<wbr>system\\<wbr>aaaaaaaaaaaaa</code></td>',
|
||||
"a/<wbr>b/", False, False, False,
|
||||
])
|
||||
|
||||
def test_built_page(self):
|
||||
page = _built("platforms/retroarch")
|
||||
self.assertIn("~/<wbr>.var/<wbr>app/<wbr>org.libretro.RetroArch/<wbr>config/<wbr>retroarch/<wbr>system/", page)
|
||||
|
||||
def test_the_page_budget(self):
|
||||
"""Each `wbr` is an element: bluemsx held 3,097 of them on 9,369
|
||||
elements against a budget of 10,000. The build spends BREAK_BUDGET
|
||||
per page, the runtime writes the others."""
|
||||
uri = json.dumps(SHORT_CODE.as_uri())
|
||||
out = _node(
|
||||
f"import {{ markRawCode, BREAK_BUDGET }} from {uri};"
|
||||
"const room = { left: 3 };"
|
||||
"const path = '<code>aaaaaaaaaa/bbbbbbbbbb/cccccccccc/d</code>';"
|
||||
"console.log(JSON.stringify([BREAK_BUDGET, markRawCode(path, room), markRawCode(path, room), room.left]));"
|
||||
)
|
||||
budget, first, second, left = json.loads(out)
|
||||
self.assertEqual(first, "<code>aaaaaaaaaa/<wbr>bbbbbbbbbb/<wbr>cccccccccc/<wbr>d</code>")
|
||||
self.assertEqual(second, "<code>aaaaaaaaaa/bbbbbbbbbb/cccccccccc/d</code>")
|
||||
self.assertEqual(left, 0)
|
||||
if not DIST.is_dir():
|
||||
self.skipTest("no built site")
|
||||
# Link text the generator breaks itself (a forge address) is not code.
|
||||
code = re.compile(r"<code(?:\s[^>]*)?>(?:[^<]|<wbr>)*</code>")
|
||||
over = [str(path.relative_to(DIST)) for path in DIST.rglob("index.html")
|
||||
if sum(found.count("<wbr>") for found in code.findall(_read(path))) > budget]
|
||||
self.assertEqual(over, [])
|
||||
|
||||
def test_the_runtime_writes_the_rest(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("const LONG_CODE = 30;", source)
|
||||
self.assertIn("const SEPARATORS = /([/\\\\])(?!$)/g;", source)
|
||||
self.assertIn("if (unbrokenPath(code)) observer.observe(code);", source)
|
||||
self.assertIn("\nbreakPaths();\n", source)
|
||||
plugin = _read(SHORT_CODE)
|
||||
self.assertIn("export const SHORT_CODE = 30;", plugin)
|
||||
self.assertIn("const SEPARATORS = /([/\\\\])(?!$)/g;", plugin)
|
||||
|
||||
def test_every_long_path_breaks_at_run_time(self):
|
||||
"""Inline code over 30 characters holding a separator: the build
|
||||
broke it, or the runtime rule picks it (all text, no `wbr`)."""
|
||||
if not DIST.is_dir():
|
||||
self.skipTest("no built site")
|
||||
unbroken = []
|
||||
for path in sorted(DIST.rglob("index.html")):
|
||||
page = _read(path)
|
||||
if 'http-equiv="refresh"' in page or "sl-markdown-content" not in page:
|
||||
continue
|
||||
body = re.sub(r"<pre\b.*?</pre>", "", page[page.index("sl-markdown-content"):], flags=re.S)
|
||||
for text in re.findall(r"<code(?:\s[^>]*)?>((?:[^<]|<(?!/code>))*)</code>", body):
|
||||
plain = ENTITY.sub("_", re.sub(r"<wbr>", "", text))
|
||||
if len(plain) > 30 and re.search(r"[/\\]", plain[:-1]) \
|
||||
and "<wbr>" not in text and "<" in text:
|
||||
unbroken.append(f"{path.relative_to(DIST)}: {text}")
|
||||
self.assertEqual(unbroken, [])
|
||||
|
||||
def test_crumb_labels_in_a_span(self):
|
||||
source = _read(COMPONENTS / "PageTitle.astro")
|
||||
self.assertIn("<a href={crumb.href}><span>{crumb.label}</span></a> : <span>{crumb.label}</span>", source)
|
||||
crumbs = _built("platforms/retroarch")
|
||||
crumbs = crumbs[crumbs.index('class="rb-breadcrumbs'):]
|
||||
crumbs = crumbs[:crumbs.index("</ol>")]
|
||||
items = re.findall(r"<li\b[^>]*>(.*?)</li>", crumbs)
|
||||
self.assertTrue(items)
|
||||
for item in items:
|
||||
self.assertRegex(item, r"^(?:<a\b[^>]*>)?<span\b[^>]*>[^<]+</span>")
|
||||
|
||||
|
||||
class ShortStackedLabels(unittest.TestCase):
|
||||
"""2.3: "Name as declared" and "On its BIOS list (destinations)" wrap
|
||||
to two lines in the stacked label column (V9)."""
|
||||
|
||||
def test_the_rule(self):
|
||||
uri = json.dumps(STACK_TABLES.as_uri())
|
||||
out = _node(
|
||||
f"import {{ stackedLabel }} from {uri};"
|
||||
"console.log(JSON.stringify([stackedLabel('Declared as', 'Name as declared'),"
|
||||
"stackedLabel(undefined, 'Setup'), stackedLabel(' ', 'Setup')]));"
|
||||
)
|
||||
self.assertEqual(json.loads(out), ["Declared as", "Setup", "Setup"])
|
||||
|
||||
def test_plugin_and_runtime_read_the_title(self):
|
||||
plugin = _read(STACK_TABLES)
|
||||
self.assertIn("stackedLabel(cell.properties?.title, labels[column])", plugin)
|
||||
self.assertIn("ctx.setProperty(cell, 'dataLabel', stacked[column])", plugin)
|
||||
runtime = _read(SITE_TS)
|
||||
self.assertIn("cell.getAttribute('title')?.trim() || (cell.textContent?.trim() ?? '')", runtime)
|
||||
self.assertIn("[...table.querySelectorAll('thead th')].map(stackedLabel)", runtime)
|
||||
self.assertIn("'Name as declared': 'Declared as'", runtime)
|
||||
self.assertIn("cell.dataset.label = stackedLabel(head.cells[column]);", runtime)
|
||||
|
||||
|
||||
class LookupShowsTheDeclaredHash(unittest.TestCase):
|
||||
"""2.4: with the CRC32 pasted, Batocera's row read "Hash matched: CRC32",
|
||||
though Batocera declares an MD5 (J9, N10)."""
|
||||
|
||||
def test_source(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("declared?: string;", source)
|
||||
self.assertIn("const declaredHash = (item: Item) => (item.declared || item.algorithm).toUpperCase();", source)
|
||||
table = source[source.index("const setupTable"):source.index("const distinct")]
|
||||
self.assertIn("declaredHash(item),", table)
|
||||
self.assertNotIn("item.algorithm", table)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Round 5 theme: the stylesheet fixes of package 1, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: detection tables
|
||||
whose last column sat past the page at 1440 px, a header search box, title
|
||||
and "On this page" that moved 12 px between a guide and a console page,
|
||||
stacked rows 136 px tall for four short cells, a line 4 px under a button,
|
||||
a heading touching its first line, the browser's own choice list, and a
|
||||
crumb separator free to end a line alone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from tests.test_site_r2_css import CSS_PATH, _blocks, _strip_comments, _theme_tokens
|
||||
|
||||
STACK = ".sl-markdown-content table[data-rb-stack] td"
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
bodies = [b for s, b in _blocks(css) if s == selector]
|
||||
if not bodies:
|
||||
raise AssertionError(f"no block {selector}")
|
||||
return "".join(bodies)
|
||||
|
||||
|
||||
def _declarations(body: str) -> dict[str, str]:
|
||||
return {k.strip(): v.strip() for k, v in re.findall(r"([\w-]+)\s*:\s*([^;]+);", body)}
|
||||
|
||||
|
||||
class ThemeStylesheetRound5(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
# 1.1
|
||||
def test_long_code_in_a_cell_breaks_to_fit_its_column(self):
|
||||
self.assertIn("overflow-wrap: anywhere",
|
||||
_block(self.css, ".sl-markdown-content table td code:not(.rb-short)"))
|
||||
last = _block(self.css, ".sl-markdown-content table:not([data-rb-stack='kv']) "
|
||||
"td:last-child:not(:first-child):has(code:not(.rb-short))")
|
||||
self.assertIn("min-width: 9rem", last)
|
||||
# A flag or a short name in a cell still never splits.
|
||||
self.assertIn("white-space: pre", _block(self.css, ".sl-markdown-content :not(pre) > code.rb-short"))
|
||||
|
||||
# 1.2
|
||||
def test_one_grid_for_every_page(self):
|
||||
self.assertEqual(_theme_tokens(self.css, ":root").get("--rb-grid-width"), "54rem")
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)",
|
||||
_block(self.css, "body:has(.rb-sys-file, .rb-file-entry)"))
|
||||
# The header places its search box from the grid, not the page's column.
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)",
|
||||
_block(self.css, ":root[data-has-sidebar] header.header"))
|
||||
toc = _block(self.css, ":root[data-has-sidebar] .right-sidebar-container")
|
||||
pane = _block(self.css, ":root[data-has-sidebar] .main-pane")
|
||||
for body in (toc, pane):
|
||||
self.assertIn("var(--rb-grid-width)", body)
|
||||
self.assertNotIn("var(--sl-content-width)", body)
|
||||
# Every column starts on the grid's edge, the narrow one included.
|
||||
self.assertIn("--sl-content-margin-inline: max(0rem, 100% - var(--rb-grid-width)) auto", pane)
|
||||
# The pane rule covers pages with and without "On this page" alike.
|
||||
self.assertFalse([s for s, _ in _blocks(self.css) if s.endswith(":not([data-has-toc]) .main-pane")])
|
||||
|
||||
# 1.3
|
||||
def test_stacked_rows_are_short(self):
|
||||
cell = _declarations(_block(self.css, STACK))
|
||||
self.assertEqual(cell["font-size"], "0.875rem")
|
||||
self.assertEqual(cell["line-height"], "1.35")
|
||||
label = _declarations(_block(self.css, STACK + "::before"))
|
||||
self.assertEqual(label["width"], "max-content")
|
||||
self.assertEqual(label["min-width"], "6.5rem")
|
||||
self.assertEqual(label["max-width"], "9rem")
|
||||
self.assertEqual(label["font-size"], "0.8125rem")
|
||||
# Values start in the column of the shortest label: the label's
|
||||
# negative margin is the cell's left padding.
|
||||
indent = cell["padding"].split()[3]
|
||||
self.assertEqual(label["margin"].split()[3], f"-{indent}")
|
||||
self.assertEqual(indent, "7.1rem")
|
||||
self.assertIn("padding: 0.3rem 0", _block(self.css, ".sl-markdown-content table[data-rb-stack] tr"))
|
||||
# A two-column list keeps body text.
|
||||
self.assertIn("font-size: 1rem", _block(self.css, ".sl-markdown-content table[data-rb-stack='kv'] td"))
|
||||
|
||||
# 1.5
|
||||
def test_spacing_after_a_button_and_a_search_marker(self):
|
||||
self.assertIn("margin-top: 1rem",
|
||||
_block(self.css, ".sl-markdown-content :is(.rb-button, p:has(> .rb-button)) + p"))
|
||||
self.assertIn("margin-top: var(--sl-content-gap-y)",
|
||||
_block(self.css, ".sl-markdown-content span[data-pagefind-meta]:empty + :is(p, ul, ol, table, div)"))
|
||||
|
||||
def test_choice_list_matches_the_filter_box(self):
|
||||
select = _declarations(_block(self.css, ".sl-markdown-content :is(.rb-file-lookup, .rb-picker) select,\n"
|
||||
".sl-markdown-content select"))
|
||||
box = _declarations(_block(self.css, ".rb-table-filter input"))
|
||||
for prop in ("border", "border-radius", "min-height"):
|
||||
with self.subTest(prop=prop):
|
||||
self.assertEqual(select[prop], box[prop])
|
||||
self.assertEqual(select["appearance"], "none")
|
||||
self.assertIn("var(--rb-select-arrow)", select["background"])
|
||||
dark = _theme_tokens(self.css, ":root")["--rb-select-arrow"]
|
||||
light = _theme_tokens(self.css, ":root[data-theme='light']")["--rb-select-arrow"]
|
||||
# The arrow is drawn in each theme's secondary text colour.
|
||||
for arrow, tokens in ((dark, _theme_tokens(self.css, ":root")),
|
||||
(light, _theme_tokens(self.css, ":root[data-theme='light']"))):
|
||||
colour = tokens["--rb-text-secondary"].lstrip("#")
|
||||
self.assertIn(f"%23{colour}", arrow)
|
||||
|
||||
def test_crumb_separator_stays_with_its_label(self):
|
||||
self.assertIn("white-space: nowrap", _block(self.css, ".rb-breadcrumbs li"))
|
||||
self.assertIn("white-space: normal", _block(self.css, ".rb-breadcrumbs li > :is(a, span)"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,268 +0,0 @@
|
||||
"""Emulator pages.
|
||||
|
||||
61 profiles cite the folder their app reads its data from (data_root) and
|
||||
no page printed it: AetherSX2's step said "inside the app's own data
|
||||
folder", ten pages named the field itself, and every install block offered
|
||||
Linux, Windows and Android commands, for MAME4droid an Android-only app.
|
||||
215 pages printed `--emulator X` for a name no published manifest takes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
|
||||
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
EMULATORS = CONTENT / "emulators"
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _profiles_with_data_root() -> dict[str, dict]:
|
||||
found = {}
|
||||
for path in sorted((REPO_ROOT / "emulators").glob("*.yml")):
|
||||
profile = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if isinstance(profile, dict) and isinstance(profile.get("data_root"), dict):
|
||||
found[path.stem] = profile
|
||||
return found
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
for suffix in (".mdx", ".md"):
|
||||
path = EMULATORS / f"{name}{suffix}"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
def _generated() -> bool:
|
||||
return EMULATORS.is_dir() and any(EMULATORS.iterdir())
|
||||
|
||||
|
||||
def _install_section(page: str) -> str:
|
||||
match = re.search(r"<summary>Install this emulator's files</summary>(.*?)</details>",
|
||||
page, re.S)
|
||||
return match[1] if match else ""
|
||||
|
||||
|
||||
def _tab_labels(text: str) -> set[str]:
|
||||
return set(re.findall(r'<TabItem label="([^"]+)">', text))
|
||||
|
||||
|
||||
def _entries(profile: dict) -> list[dict]:
|
||||
return [f for f in profile.get("files") or [] if isinstance(f, dict)]
|
||||
|
||||
|
||||
def _alternatives(root: str) -> list[str]:
|
||||
return [part.strip().rstrip("/") for part in root.split(", else ")]
|
||||
|
||||
|
||||
class FolderOnlyTable(unittest.TestCase):
|
||||
"""The entries whose joined path is not the app's folder: one table in
|
||||
the generator, each row with its reason, and it cannot rot."""
|
||||
|
||||
def test_every_row_names_a_profile_and_entries_that_exist(self) -> None:
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, reason in gs.FOLDER_ONLY:
|
||||
with self.subTest(profile=profile_name, patterns=patterns):
|
||||
self.assertIn(profile_name, roots, "no profile with data_root by that name")
|
||||
self.assertTrue(reason.strip())
|
||||
entries = _entries(roots[profile_name])
|
||||
for pattern in patterns:
|
||||
self.assertTrue(
|
||||
any(gs.folder_only_matches(pattern, f) for f in entries),
|
||||
f"{pattern!r} names no entry of {profile_name}")
|
||||
self.assertLessEqual(set(keys), set(roots[profile_name]["data_root"]))
|
||||
|
||||
def test_a_listed_entry_is_never_joined_to_the_root(self) -> None:
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
|
||||
profile = roots[profile_name]
|
||||
for entry in _entries(profile):
|
||||
if not any(gs.folder_only_matches(p, entry) for p in patterns):
|
||||
continue
|
||||
raw = {str(entry.get(k) or "") for k in ("path", "standalone_path")} - {""}
|
||||
for key in keys or tuple(profile["data_root"]):
|
||||
with self.subTest(profile=profile_name, entry=entry.get("name"), key=key):
|
||||
self.assertEqual(gs.standalone_place(profile_name, profile, entry, key), "")
|
||||
self.assertEqual(gs.standalone_place(profile_name, profile, entry), "")
|
||||
block = "\n".join(gs.data_folder_block(profile_name, profile))
|
||||
for root in _alternatives(str(profile["data_root"][key]["path"])):
|
||||
for place in raw:
|
||||
self.assertNotIn(f"{root}/{place}", block)
|
||||
|
||||
def test_built_pages_print_no_joined_path_for_a_listed_entry(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
|
||||
profile = roots[profile_name]
|
||||
pages = [p.read_text(encoding="utf-8")
|
||||
for p in EMULATORS.glob(f"{profile_name}*.md*")]
|
||||
for entry in _entries(profile):
|
||||
if not any(gs.folder_only_matches(p, entry) for p in patterns):
|
||||
continue
|
||||
for key in keys or tuple(profile["data_root"]):
|
||||
for root in _alternatives(str(profile["data_root"][key]["path"])):
|
||||
for place in {entry.get("path"), entry.get("standalone_path")} - {None, ""}:
|
||||
joined = f"{root}/{place}"
|
||||
for text in pages:
|
||||
self.assertNotIn(joined, text, profile_name)
|
||||
|
||||
|
||||
class DataFolderPrinted(unittest.TestCase):
|
||||
def test_every_profile_prints_each_of_its_folders(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
for name, profile in _profiles_with_data_root().items():
|
||||
if profile.get("type") in ("alias", "test"):
|
||||
continue
|
||||
page = _page(name)
|
||||
with self.subTest(profile=name):
|
||||
self.assertIn('id="data-folder"', page)
|
||||
for root in profile["data_root"].values():
|
||||
for alternative in _alternatives(str(root["path"])):
|
||||
self.assertIn(alternative, page)
|
||||
|
||||
def test_ryujinx_keys_sit_in_system(self) -> None:
|
||||
block = "\n".join(gs.data_folder_block("ryujinx", _profile("ryujinx")))
|
||||
self.assertIn("`%APPDATA%/Ryujinx/system/`", block)
|
||||
self.assertIn("`~/Library/Application Support/Ryujinx/system/`", block)
|
||||
self.assertEqual(_tab_labels(block), {"Windows", "macOS", "Linux"})
|
||||
|
||||
def test_aethersx2_step_names_the_android_folder(self) -> None:
|
||||
profile = _profile("aethersx2")
|
||||
steps = gs._any_name_steps("AetherSX2", profile, gs.any_name_bios(profile), "", "aethersx2")
|
||||
self.assertIn("2. Put it in this folder:", steps)
|
||||
self.assertIn("`Android/data/xyz.aethersx2.android/files/bios/`, on the internal storage.",
|
||||
steps)
|
||||
self.assertNotIn("app's own data folder", steps)
|
||||
|
||||
def test_one_system_is_a_line_not_a_tab(self) -> None:
|
||||
block = "\n".join(gs.data_folder_block("mame4droid", _profile("mame4droid")))
|
||||
self.assertTrue(block.startswith("On Android: `Android/data/com.seleuco.mame4droid/files/`"))
|
||||
self.assertNotIn("<Tabs", block)
|
||||
|
||||
def test_an_entry_found_by_name_or_shipped_is_not_placed(self) -> None:
|
||||
profile = {"type": "standalone", "data_root": {"linux": {"path": "~/x", "source_ref": "a"}}}
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a.bin"}), "")
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "a", "bundled": True}),
|
||||
"")
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "b/a"}), "b/a")
|
||||
dual = {**profile, "type": "standalone + libretro"}
|
||||
self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "core/a"}), "")
|
||||
self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "c/a",
|
||||
"standalone_path": "a"}), "a")
|
||||
|
||||
def test_the_metadata_card_has_a_row_per_system(self) -> None:
|
||||
rows = gs._render_metadata_rows(_profile("cxbx-reloaded"))
|
||||
self.assertIn("| Data folder (Windows) | `%APPDATA%/Cxbx-Reloaded/` |", rows)
|
||||
self.assertFalse(any("Data folder (Linux)" in row for row in rows))
|
||||
|
||||
def test_no_page_names_the_field(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
named = sorted(str(p.relative_to(CONTENT)) for p in CONTENT.rglob("*.md*")
|
||||
if "data_root" in p.read_text(encoding="utf-8")
|
||||
and not p.relative_to(CONTENT).as_posix().startswith("wiki/profiling"))
|
||||
self.assertEqual(named, [])
|
||||
|
||||
|
||||
class InstallBlockWhereItWorks(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
|
||||
cls.install = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.install)
|
||||
cls.accepted: dict[str, set[str]] = {}
|
||||
for path in sorted((REPO_ROOT / "install").glob("*.json")):
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
names = cls.install.emulator_names(manifest, manifest.get("files") or [],
|
||||
manifest.get("omitted_files") or [])
|
||||
for alias, profile in names.items():
|
||||
cls.accepted.setdefault(alias, set()).add(profile)
|
||||
|
||||
def test_every_printed_command_names_this_profile_in_a_manifest(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
for path in sorted(EMULATORS.glob("*.md*")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for name in set(re.findall(r"--emulator ([\w.+-]+)", text)):
|
||||
with self.subTest(page=path.name, name=name):
|
||||
self.assertIn(path.stem, self.accepted.get(name, set()))
|
||||
|
||||
def test_no_os_tab_outside_the_data_root(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
labels = {key: gs.TERMINALS[key][0] for key in gs.TERMINALS}
|
||||
for name, profile in _profiles_with_data_root().items():
|
||||
allowed = {labels["device"]}
|
||||
for key in profile["data_root"]:
|
||||
allowed |= {labels[t] for t in gs._DATA_ROOT_TERMINALS[key]}
|
||||
with self.subTest(profile=name):
|
||||
self.assertLessEqual(_tab_labels(_install_section(_page(name))), allowed)
|
||||
|
||||
def test_android_only_app_gets_no_desktop_command(self) -> None:
|
||||
self.assertEqual(gs._install_block("mame4droid", _profile("mame4droid")), [])
|
||||
command, setups, oses = gs.emulator_install_route("cxbx-reloaded", _profile("cxbx-reloaded"))
|
||||
self.assertEqual(oses, ("windows",))
|
||||
|
||||
def test_a_lone_app_is_named_with_the_setup_that_launches_it(self) -> None:
|
||||
text = "\n".join(gs._install_block("ryujinx", _profile("ryujinx")))
|
||||
self.assertRegex(text, r"With [^:]*EmuDeck[^:]*: the installer finds")
|
||||
self.assertNotIn("Android (Termux)", text)
|
||||
self.assertNotIn("RetroArch", text)
|
||||
|
||||
def test_a_dual_app_says_what_the_command_fills(self) -> None:
|
||||
text = "\n".join(gs._install_block("duckstation", _profile("duckstation")))
|
||||
self.assertIn("For DuckStation inside RetroArch, Batocera or another setup.", text)
|
||||
|
||||
def test_no_files_no_command(self) -> None:
|
||||
self.assertEqual(gs._install_block("beetle_wswan", _profile("beetle_wswan")), [])
|
||||
|
||||
def test_tabs_follow_the_keys_given(self) -> None:
|
||||
tabs = "\n".join(gs.install_commands("--emulator x", oses=("linux", "steamdeck")))
|
||||
self.assertEqual(_tab_labels(tabs), {"Linux", "Steam Deck"})
|
||||
self.assertIn('syncKey="os"', tabs)
|
||||
blocks = "\n".join(gs.install_commands("--emulator x"))
|
||||
self.assertIn("Windows (PowerShell):", blocks)
|
||||
|
||||
|
||||
class SmallFixes(unittest.TestCase):
|
||||
def test_structured_blocks_closed_below_the_files(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
page = _page("cemu")
|
||||
self.assertLess(page.index("## Files"), page.index("<summary>Firmware titles</summary>"))
|
||||
self.assertNotRegex(page, r"<details[^>]*open>\s*<summary>Firmware titles")
|
||||
|
||||
def test_region_choice_summary(self) -> None:
|
||||
lines = gs._emulator_file_summary(_profile("duckstation")["files"])
|
||||
self.assertIn("One of 3 is required, for your games' region.", lines[0])
|
||||
self.assertNotIn("3 required", lines[0])
|
||||
|
||||
def test_no_emulator_part_says_where_it_sits(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
parts = sorted(EMULATORS.glob("*-files-*.md*"))
|
||||
if not parts:
|
||||
self.skipTest("no split emulator page")
|
||||
said = [p.name for p in parts if "\nPart of [" in p.read_text(encoding="utf-8")]
|
||||
self.assertEqual(said, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,293 +0,0 @@
|
||||
"""Entry pages, get/ and guides.
|
||||
|
||||
The get/ page of a setup with no ZIP printed one command for three
|
||||
systems, with no --dest, so it failed on the computer most readers open
|
||||
it on. "All good when you see 0 wrong hash" was also what an empty folder
|
||||
prints. Setups with no ZIP lost every route that works without a network.
|
||||
The get/ pages were titled "Download KNULLI", /get/ answered 404, and two
|
||||
Linux handheld rows sent the reader to a menu.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import siteguides # noqa: E402
|
||||
from common import load_platform_config, load_platform_registry # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
|
||||
SETUPS = [name for name in siteguides.GUIDES if name in REGISTRY]
|
||||
COMMAND = re.compile(r"^(?:curl -fsSL |&\(\[scriptblock\]|irm ).*$", re.M)
|
||||
OFFLINE_ANCHOR = "#without-a-network-offline-install"
|
||||
CARD_FAQ = "/retrobios/wiki/faq/#fill-an-sd-card-from-a-computer"
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
sitepages.require()
|
||||
return re.sub(r"</?abbr[^>]*>", "", sitepages.page(name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _guide_part(text: str) -> str:
|
||||
match = re.search(r"^## Reference\b", text, re.M)
|
||||
return text[: match.start()] if match else text
|
||||
|
||||
|
||||
def _with_zip(name: str) -> bool:
|
||||
pack = release_record.pack_for(name, release_record.load_advertised())
|
||||
return bool(pack and pack.get("assets"))
|
||||
|
||||
|
||||
def _display(name: str) -> str:
|
||||
return load_platform_config(name, str(REPO_ROOT / "platforms")).get("platform", name)
|
||||
|
||||
|
||||
def _tab_labels(text: str) -> set[str]:
|
||||
return set(re.findall(r'<TabItem label="([^"]+)"', text))
|
||||
|
||||
|
||||
class GetPagesGiveTheSetupsOwnRoutes(unittest.TestCase):
|
||||
def test_every_command_on_a_get_page_is_on_its_setup_page(self):
|
||||
for name in SETUPS:
|
||||
get = set(COMMAND.findall(_page(f"get/{name}.md")))
|
||||
setup = set(COMMAND.findall(_page(f"platforms/{name}.md")))
|
||||
with self.subTest(setup=name):
|
||||
self.assertTrue(get <= setup, sorted(get - setup))
|
||||
if not _with_zip(name):
|
||||
self.assertTrue(get, "a setup with no ZIP gets its command")
|
||||
|
||||
def test_a_card_setup_names_the_card(self):
|
||||
for name in SETUPS:
|
||||
if not siteguides.GUIDES[name].on_card:
|
||||
continue
|
||||
with self.subTest(setup=name):
|
||||
for command in COMMAND.findall(_page(f"get/{name}.md")):
|
||||
self.assertIn("--dest ", command)
|
||||
|
||||
def test_no_tab_for_a_system_the_setup_does_not_run_from(self):
|
||||
labels = {key: entry[0] for key, entry in siteguides.TERMINALS.items()}
|
||||
for name in SETUPS:
|
||||
if _with_zip(name):
|
||||
continue
|
||||
guide = siteguides.GUIDES[name]
|
||||
allowed = {labels[key] for key in guide.run}
|
||||
allowed |= {labels[key] for key in guide.card_run or ("windows", "macos", "linux")}
|
||||
if generate_site._network_share(name, guide):
|
||||
allowed.add(labels["windows"])
|
||||
with self.subTest(setup=name):
|
||||
self.assertLessEqual(_tab_labels(_page(f"get/{name}.md")), allowed)
|
||||
self.assertNotIn("Android (Termux", _page(f"get/{name}.md"))
|
||||
|
||||
def test_retrodeck_runs_from_linux_and_the_deck(self):
|
||||
text = _page("get/retrodeck.md")
|
||||
install = text.split("## Install", 1)[1].split("No network", 1)[0]
|
||||
self.assertEqual(_tab_labels(install), {"Steam Deck", "Linux"})
|
||||
|
||||
def test_one_helper_writes_both_pages(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
get_page = source.split("def generate_get_page", 1)[1].split("\ndef ", 1)[0]
|
||||
guide = source.split("def _platform_guide", 1)[1].split("\ndef ", 1)[0]
|
||||
self.assertNotIn("install_commands(", get_page)
|
||||
self.assertIn("_setup_routes(", get_page)
|
||||
self.assertIn("_setup_routes(", guide)
|
||||
|
||||
|
||||
class CheckNeedsTheFiles(unittest.TestCase):
|
||||
def test_the_pass_line_is_what_the_installer_prints_only_when_complete(self):
|
||||
installer = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
printed = re.search(r"`([^`]+)`", generate_site.check_pass_line())[1]
|
||||
self.assertEqual(printed, generate_site._installer().EVERYTHING_IN_PLACE)
|
||||
self.assertIn('print(f"\\n {EVERYTHING_IN_PLACE}")', installer)
|
||||
self.assertNotIn("wrong hash", generate_site.check_pass_line())
|
||||
|
||||
def test_no_setup_page_passes_an_empty_folder(self):
|
||||
for name in SETUPS:
|
||||
text = _page(f"platforms/{name}.md")
|
||||
with self.subTest(setup=name):
|
||||
self.assertNotIn("All good when you see `0 wrong hash`", text)
|
||||
self.assertIn(generate_site.check_pass_line(), text)
|
||||
|
||||
|
||||
class ARouteWithoutANetwork(unittest.TestCase):
|
||||
def test_every_setup_page_has_the_offline_line_or_the_card_route(self):
|
||||
for name in SETUPS:
|
||||
guide = siteguides.GUIDES[name]
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
with self.subTest(setup=name):
|
||||
if _with_zip(name):
|
||||
self.assertIn(OFFLINE_ANCHOR, text)
|
||||
elif guide.on_card:
|
||||
self.assertIn("Put the SD card in a computer.", text)
|
||||
else:
|
||||
self.assertIn("No network", text)
|
||||
self.assertNotIn(OFFLINE_ANCHOR, text)
|
||||
|
||||
def test_every_device_setup_with_no_zip_links_the_card_answer(self):
|
||||
for name in SETUPS:
|
||||
guide = siteguides.GUIDES[name]
|
||||
if _with_zip(name) or guide.run != ("device",):
|
||||
continue
|
||||
extract_to = REGISTRY[name]["extract_to"]
|
||||
for page in (f"platforms/{name}.md", f"get/{name}.md"):
|
||||
text = _page(page)
|
||||
with self.subTest(page=page):
|
||||
self.assertIn("## From your computer { #from-your-computer }", text)
|
||||
self.assertIn(CARD_FAQ, text)
|
||||
self.assertIn(f"the folder the device calls `{extract_to}`", text)
|
||||
self.assertIn(f"--platform {name} --dest /path/to/", text)
|
||||
|
||||
def test_rocknix_has_a_computer_route(self):
|
||||
text = _guide_part(_page("platforms/rocknix.md"))
|
||||
self.assertLess(text.index("## From your computer"), text.index("## On the device"))
|
||||
|
||||
def test_retropie_card_is_read_from_linux_only(self):
|
||||
self.assertEqual(siteguides.GUIDES["retropie"].card_run, ("linux",))
|
||||
text = _page("platforms/retropie.md")
|
||||
card = text.split("No network?", 1)[1].split("## On the device", 1)[0]
|
||||
self.assertEqual(_tab_labels(card), {"Linux"})
|
||||
|
||||
def test_no_zip_is_promised_for_an_unmarked_setup(self):
|
||||
for name in SETUPS:
|
||||
if REGISTRY[name].get("release_pack") is True:
|
||||
continue
|
||||
with self.subTest(setup=name):
|
||||
for page in (f"platforms/{name}.md", f"get/{name}.md"):
|
||||
text = _guide_part(_page(page))
|
||||
self.assertNotIn("/releases/download/", text)
|
||||
self.assertNotIn(OFFLINE_ANCHOR, text)
|
||||
|
||||
|
||||
class GetPagesSayWhatTheyHold(unittest.TestCase):
|
||||
def test_title_description_and_parent(self):
|
||||
for name in SETUPS:
|
||||
display = _display(name)
|
||||
text = sitepages.page(f"get/{name}.md").read_text(encoding="utf-8")
|
||||
kind = "pack" if REGISTRY[name].get("release_pack") is True else "files"
|
||||
title = (f"BIOS {kind} for {display}"
|
||||
if (REPO_ROOT / "emulators" / f"{name}.yml").is_file()
|
||||
else f"{display} BIOS {kind}")
|
||||
with self.subTest(setup=name):
|
||||
self.assertEqual(generate_site.get_page_title(name, display), title)
|
||||
self.assertIn(f"title: {title}\n", text)
|
||||
self.assertNotIn("Download ", text.split("\n---\n", 1)[0].split("title:", 1)[1]
|
||||
.split("\n", 1)[0])
|
||||
self.assertIn(f"link: /platforms/{name}/", text)
|
||||
if kind == "files":
|
||||
self.assertIn(f"The BIOS files for {display}: one command installs and "
|
||||
"checks them.", text)
|
||||
|
||||
def test_a_setup_with_no_zip_links_its_get_page(self):
|
||||
for name in SETUPS:
|
||||
if REGISTRY[name].get("release_pack") is True:
|
||||
continue
|
||||
with self.subTest(setup=name):
|
||||
title = generate_site.get_page_title(name, _display(name))
|
||||
self.assertIn(f"Link to share: [{title}](/retrobios/get/{name}/)",
|
||||
_page(f"platforms/{name}.md"))
|
||||
|
||||
def test_a_relative_folder_is_named_as_the_setups(self):
|
||||
text = _page("get/retroarch.md")
|
||||
self.assertIn("Unzip them all into RetroArch's `system` folder. "
|
||||
"[Where is it?](/retrobios/platforms/retroarch/#where-the-files-go)", text)
|
||||
self.assertIn("In RetroArch, open **Settings > Directory > System/BIOS**", text)
|
||||
self.assertIn("into RetroBat's `bios` folder", _page("get/retrobat.md"))
|
||||
self.assertNotIn("into `system/`", text)
|
||||
|
||||
|
||||
class HandheldRowsSayWhere(unittest.TestCase):
|
||||
def test_retrooz_names_its_folder_with_a_pinned_source(self):
|
||||
system = next(s for s in siteguides.HANDHELD_GUIDE.systems if s.name == "RetroOZ")
|
||||
self.assertIn("`/roms/bios`", system.folder)
|
||||
self.assertRegex(system.folder_source, r"/wiki/Home/[0-9a-f]{40}$")
|
||||
row = next(line for line in _page("platforms/linux-handhelds.md").splitlines()
|
||||
if line.startswith("| RetroOZ |"))
|
||||
self.assertNotIn(generate_site.RETROARCH_FOLDER_SETTING, row)
|
||||
|
||||
|
||||
class GetIndex(unittest.TestCase):
|
||||
def test_every_setup_is_linked_under_its_group(self):
|
||||
text = _page("get/index.md")
|
||||
for name in SETUPS:
|
||||
with self.subTest(setup=name):
|
||||
self.assertIn(f"(/retrobios/get/{name}/)", text)
|
||||
for label, _description in siteguides.DEVICE_GROUPS.values():
|
||||
self.assertIn(f"## {label}", text)
|
||||
|
||||
|
||||
class HomeAndWhichPack(unittest.TestCase):
|
||||
def test_the_home_step_does_not_promise_a_zip(self):
|
||||
text = _page("index.md")
|
||||
self.assertIn("**Run one command.** Some setups have a ZIP.", text)
|
||||
self.assertNotIn("or download a ZIP file", text)
|
||||
|
||||
def test_which_pack_answers_ios(self):
|
||||
text = _page("which-pack.md")
|
||||
meta = re.search(r'data-pagefind-meta="terms:([^"]+)"', text)
|
||||
self.assertIsNotNone(meta)
|
||||
for word in ("ios", "iphone", "ipad", "delta", "provenance"):
|
||||
self.assertIn(word, meta[1].split())
|
||||
|
||||
|
||||
class TheSiteSaysWhatTheInstallerShows(unittest.TestCase):
|
||||
"""which-pack showed a line menu (Enter, i, c, q) the one-liner never
|
||||
opens, typed by hand, its keys out of date. The site, the README and
|
||||
the wiki now take the screen's words and the line menu from install.py."""
|
||||
|
||||
MENU_LINES = ("Enter) install everything", "i) only what the installed cores read",
|
||||
"c) choose systems, cores and regions")
|
||||
|
||||
def test_which_pack_describes_the_screen_in_the_installers_words(self):
|
||||
installer = generate_site._installer()
|
||||
text = _page("which-pack.md")
|
||||
for line in generate_site.first_screen_words():
|
||||
self.assertIn(line, text)
|
||||
self.assertIn(f"**{installer.TUI_INSTALL_ROW}**", text)
|
||||
self.assertIn(f"**{installer.TUI_SYSTEMS_ROW}**", text)
|
||||
for line in self.MENU_LINES:
|
||||
self.assertNotIn(line, text)
|
||||
self.assertNotIn("press **c**", text)
|
||||
|
||||
def test_the_plan_rows_are_the_words_the_pages_use(self):
|
||||
installer = generate_site._installer()
|
||||
state = installer.TuiState(os_type="linux", home=Path("/home/me"))
|
||||
state.stack = ["plan"]
|
||||
state.check = {"files": 2, "present": 0, "missing": 2, "wrong_hash": 0, "need": 2,
|
||||
"bytes_needed": 10}
|
||||
state.systems = [{"system": name, "missing": 1, "wrong_hash": 0, "present": 0,
|
||||
"files": 1, "bytes_needed": 5} for name in ("a", "b")]
|
||||
state.manifest = {"files": [{"dest": "a.bin", "systems": ["a"]},
|
||||
{"dest": "b.bin", "systems": ["b"]}]}
|
||||
state.ticked = {"a", "b"}
|
||||
state.checked_flags = installer.tui_flags(state)
|
||||
labels = [row.label for row in installer.tui_rows(state)]
|
||||
self.assertEqual(labels[0], installer.TUI_INSTALL_ROW)
|
||||
self.assertIn(installer.TUI_SYSTEMS_ROW, labels)
|
||||
|
||||
def test_the_readme_and_the_wiki_take_the_same_words(self):
|
||||
installer = generate_site._installer()
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
self.assertIn(f"fewer under **{installer.TUI_SYSTEMS_ROW}**", readme)
|
||||
self.assertNotIn("offers to install everything or to choose systems", readme)
|
||||
source = (REPO_ROOT / "scripts" / "generate_readme.py").read_text(encoding="utf-8")
|
||||
self.assertIn("_installer_words().TUI_SYSTEMS_ROW", source)
|
||||
import generate_installer_doc # noqa: PLC0415
|
||||
|
||||
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
|
||||
self.assertIn(generate_installer_doc.line_menu(), page)
|
||||
menu = page.split("<!-- generated: line-menu -->", 1)[1]
|
||||
outside = page.replace(menu.split("<!-- /generated: line-menu -->", 1)[0], "")
|
||||
for line in self.MENU_LINES:
|
||||
self.assertNotIn(line, outside)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,266 +0,0 @@
|
||||
"""Reference pages and data.
|
||||
|
||||
The API said Lakka and RetroPie had a release pack while their pages said
|
||||
none, 50 nested manifest fields and the nine omission codes had no
|
||||
description, the lookup named 9 emulators where the card named 11, four
|
||||
served schemas and the target lists appeared nowhere on the Data page, the
|
||||
folder check exited 0 on a folder missing every file, and a system id did
|
||||
not lead to its JSON document. Tests reading the generated site skip when
|
||||
it has not been generated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
from common import list_release_pack_platforms # noqa: E402
|
||||
|
||||
PUBLIC = REPO_ROOT / "website" / "public"
|
||||
API = PUBLIC / "api" / "v1"
|
||||
GENERATED = REPO_ROOT / "website" / "src" / "generated"
|
||||
SCHEMA = json.loads((REPO_ROOT / "schemas" / "install-manifest.schema.json").read_text())
|
||||
|
||||
|
||||
def _built(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
raise unittest.SkipTest(f"{path.relative_to(REPO_ROOT)} not generated")
|
||||
return path
|
||||
|
||||
|
||||
def _released() -> set[str]:
|
||||
return set(list_release_pack_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||
|
||||
|
||||
class OneAnswerOnReleasePacks(unittest.TestCase):
|
||||
"""8.1: the API, the picker's data and the pages read the registry."""
|
||||
|
||||
def test_a_child_does_not_inherit_its_parents_pack(self):
|
||||
_contract, registry = generate_site._split_contract(
|
||||
{"_key": "lakka", "inherits": "retroarch", "release_pack": True})
|
||||
self.assertIs(registry["release_pack"], "lakka" in _released())
|
||||
_contract, registry = generate_site._split_contract({"_key": "minui"})
|
||||
self.assertNotIn("release_pack", registry)
|
||||
_contract, registry = generate_site._split_contract({"_key": "retroarch"})
|
||||
self.assertIs(registry["release_pack"], "retroarch" in _released())
|
||||
|
||||
def test_api_release_pack_set_equals_the_registry(self):
|
||||
found = set()
|
||||
for path in _built(API / "platforms").glob("*.json"):
|
||||
item = json.loads(path.read_text())["items"][0]
|
||||
if item["registry"].get("release_pack"):
|
||||
found.add(item["id"])
|
||||
self.assertEqual(found, _released())
|
||||
|
||||
def test_the_picker_file_is_the_advertised_record(self):
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir()
|
||||
else None) as folder:
|
||||
generate_site.write_release_advertised(Path(folder))
|
||||
written = json.loads((Path(folder) / "release-advertised.json").read_text())
|
||||
self.assertEqual(written, release_record.load_advertised())
|
||||
served = {p for pack in written.get("packs", {}).values() for p in pack["platforms"]}
|
||||
self.assertLessEqual(served, _released())
|
||||
|
||||
def test_the_built_picker_file(self):
|
||||
written = json.loads(_built(GENERATED / "release-advertised.json").read_text())
|
||||
self.assertEqual(written, release_record.load_advertised())
|
||||
|
||||
|
||||
def _properties(node: object, path: str = ""):
|
||||
"""(path, spec) of every property at any depth, $defs included."""
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
for key, spec in (node.get("properties") or {}).items():
|
||||
yield f"{path}{key}", spec
|
||||
yield from _properties(spec, f"{path}{key}.")
|
||||
for key in ("items", "additionalProperties"):
|
||||
yield from _properties(node.get(key), path)
|
||||
for name, spec in (node.get("$defs") or {}).items():
|
||||
yield from _properties(spec, f"#{name}.")
|
||||
for key in ("oneOf", "anyOf", "allOf"):
|
||||
for spec in node.get(key) or []:
|
||||
yield from _properties(spec, path)
|
||||
|
||||
|
||||
class EveryManifestFieldIsDescribed(unittest.TestCase):
|
||||
"""8.2: every property at any depth, and each omission code."""
|
||||
|
||||
def test_every_property_has_a_description(self):
|
||||
missing = [path for path, spec in _properties(SCHEMA)
|
||||
if isinstance(spec, dict) and not spec.get("description")]
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_each_reason_code_has_a_line(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
for path, spec in _properties(SCHEMA):
|
||||
if path.endswith(".reason"):
|
||||
self.assertEqual(len(spec["enumDescriptions"]), len(spec["enum"]), path)
|
||||
for code in spec["enum"]:
|
||||
self.assertRegex(page, rf"\n- `{code}`: \S")
|
||||
|
||||
def test_nested_tables_are_on_the_page(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
for label in ("Each entry of `omitted_files`:", "Each entry of `data_directories`:",
|
||||
"Each value of `data_directories[].files`:", "Fields of `save_directory`:",
|
||||
"Each entry of `files[].alternatives`:",
|
||||
"Each entry of `files[].slot_choices`:",
|
||||
"Each entry of `files[].declared_choices`:",
|
||||
"Each entry of `files[].absent_claims`:"):
|
||||
self.assertIn(label, page)
|
||||
for key in ("below_platform_folder", "strip_components", "by_core"):
|
||||
self.assertIn(f"`{key}`", page)
|
||||
|
||||
|
||||
class FakeConsumers:
|
||||
def __init__(self, sha1: str) -> None:
|
||||
self.emulators = {sha1: {"byname"}}
|
||||
self.emulator_entries = {sha1: {"byname": 0}}
|
||||
self.emulator_files = {"byname": [{"name": "bios.bin"}]}
|
||||
|
||||
|
||||
class LookupCountsEmulatorsLikeTheCard(unittest.TestCase):
|
||||
"""8.3: an emulator reading the file by name is in the verdict."""
|
||||
|
||||
SHA1 = "a" * 40
|
||||
|
||||
def records(self) -> list[dict]:
|
||||
db = {"files": {self.SHA1: {"name": "bios.bin", "path": "bios/Sony/PlayStation/bios.bin",
|
||||
"sha1": self.SHA1, "md5": "b" * 32}},
|
||||
"indexes": {"by_md5": {"b" * 32: self.SHA1}}}
|
||||
coverages = {"setup": {"config": {"systems": {"sony-playstation": {"files": [
|
||||
{"name": "bios.bin", "md5": "b" * 32}]}}}}}
|
||||
with mock.patch.object(generate_site, "_console_system_names", return_value={}), \
|
||||
mock.patch.object(generate_site, "_lookup_system_name", return_value=None), \
|
||||
mock.patch.object(generate_site, "emulator_card_page", side_effect=lambda k, a: k):
|
||||
return generate_site.file_lookup_records(
|
||||
db, coverages, {}, [{"sha1": self.SHA1, "url": "/x"}], None,
|
||||
FakeConsumers(self.SHA1))
|
||||
|
||||
def test_by_name_under_every_hash(self):
|
||||
records = self.records()
|
||||
for value in (self.SHA1, "b" * 32):
|
||||
named = [r for r in records if r["value"] == value and r.get("owner") == "byname"]
|
||||
self.assertEqual(len(named), 1, value)
|
||||
self.assertEqual(named[0]["declared"], "name")
|
||||
|
||||
def test_every_setup_and_emulator_record_says_what_was_declared(self):
|
||||
for record in self.records():
|
||||
if record["layer"] in ("platform", "emulator"):
|
||||
self.assertIn("declared", record)
|
||||
own = next(r for r in self.records()
|
||||
if r["layer"] == "platform" and r["value"] == "b" * 32)
|
||||
self.assertEqual(own["declared"], "md5")
|
||||
|
||||
def test_scph5501_names_the_cards_eleven(self):
|
||||
sha1 = "0555c6fae8906f3f09baf5988f00e55f88e9f30b"
|
||||
shard = json.loads(_built(API / "lookup" / f"{sha1[:2]}.json").read_text())
|
||||
owners = {r["owner"] for r in shard["items"]
|
||||
if r["value"] == sha1 and r["layer"] == "emulator"}
|
||||
self.assertGreaterEqual(len(owners), 11)
|
||||
self.assertLessEqual({"pcsx_rearmed", "pcsx1"}, owners)
|
||||
|
||||
|
||||
class TheDataPageListsWhatIsServed(unittest.TestCase):
|
||||
"""8.4 and 8.6."""
|
||||
|
||||
def test_every_schema_is_listed(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
for path in (REPO_ROOT / "schemas").glob("*.schema.json"):
|
||||
self.assertIn(f"- [`{path.name}`](api/v1/schemas/{path.name}): ", page)
|
||||
|
||||
def test_target_lists_are_published_and_catalogued(self):
|
||||
catalog = json.loads(_built(API / "catalog.json").read_text())
|
||||
listed = {item["url"] for item in catalog["items"]}
|
||||
for source in (REPO_ROOT / "install" / "targets").glob("*.json"):
|
||||
self.assertIn(f"targets/{source.name}", listed)
|
||||
self.assertEqual((API / "targets" / source.name).read_bytes(), source.read_bytes())
|
||||
|
||||
def test_declared_only_and_search_words(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
self.assertIn("With `--declared-only`, or from the `declared` event, "
|
||||
"`install.py --check` answers it too.", page)
|
||||
terms = page.split("\n", 2)[1]
|
||||
for word in ("json schema", "license", "check bios folder script"):
|
||||
self.assertIn(word, terms)
|
||||
files_terms = generate_site.generate_files_page().split("---\n", 2)[1]
|
||||
for word in ("is my bios correct", "is my bios right"):
|
||||
self.assertIn(word, files_terms)
|
||||
|
||||
|
||||
def _respond(routes: dict):
|
||||
def urlopen(url: str, *args, **kwargs):
|
||||
for suffix, body in routes.items():
|
||||
if url.endswith(suffix):
|
||||
if body is None:
|
||||
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
|
||||
return io.BytesIO(json.dumps(body).encode())
|
||||
raise AssertionError(url)
|
||||
return urlopen
|
||||
|
||||
|
||||
class TheFolderCheckExitCode(unittest.TestCase):
|
||||
"""8.5: exit 1 on a file wrong or missing, a word for an unknown platform."""
|
||||
|
||||
SCRIPT = str(REPO_ROOT / "scripts" / "check_platform_folder.py")
|
||||
CONTRACT = {"items": [{"contract": {"verification_mode": "existence", "systems": {
|
||||
"x": {"files": [{"name": "a.bin", "destination": "a.bin"}]}}}}]}
|
||||
|
||||
def run_script(self, platform: str, folder: Path, routes: dict) -> tuple[object, str]:
|
||||
err = io.StringIO()
|
||||
with mock.patch("urllib.request.urlopen", _respond(routes)), \
|
||||
mock.patch.object(sys, "argv", ["check", platform, str(folder)]), \
|
||||
mock.patch("sys.stdout", io.StringIO()), mock.patch("sys.stderr", err), \
|
||||
self.assertRaises(SystemExit) as raised:
|
||||
runpy.run_path(self.SCRIPT, run_name="__main__")
|
||||
return raised.exception.code, err.getvalue()
|
||||
|
||||
def test_missing_exits_one_and_complete_exits_zero(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
code, _err = self.run_script("p", Path(folder), {"/p.json": self.CONTRACT})
|
||||
self.assertEqual(code, 1)
|
||||
(Path(folder) / "a.bin").write_bytes(b"x")
|
||||
code, _err = self.run_script("p", Path(folder), {"/p.json": self.CONTRACT})
|
||||
self.assertEqual(code, 0)
|
||||
|
||||
def test_an_unknown_platform_names_the_known_ones(self):
|
||||
catalog = {"items": [{"url": "manifests/batocera.json"}, {"url": "stats.json"}]}
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
code, _err = self.run_script(
|
||||
"nosuch", Path(folder), {"/nosuch.json": None, "/catalog.json": catalog})
|
||||
self.assertEqual(code, "unknown platform: nosuch. Known: batocera")
|
||||
|
||||
def test_the_page_shows_the_script(self):
|
||||
script = Path(self.SCRIPT).read_text(encoding="utf-8")
|
||||
self.assertIn("```python\n" + script.rstrip("\n") + "\n```",
|
||||
generate_site.generate_data_page([]))
|
||||
|
||||
|
||||
class SystemItemsCarryTheirApi(unittest.TestCase):
|
||||
"""8.7."""
|
||||
|
||||
def test_each_api_address_is_served(self):
|
||||
items = json.loads(_built(API / "systems.json").read_text())["items"]
|
||||
playstation = next(i for i in items if i["id"] == "sony-playstation")
|
||||
self.assertEqual(playstation["api"], "/retrobios/api/v1/systems/sony.json")
|
||||
for item in items:
|
||||
if item["url"] is not None:
|
||||
self.assertIsNotNone(item["api"], item["id"])
|
||||
if item["api"]:
|
||||
self.assertTrue((PUBLIC / item["api"].removeprefix("/retrobios/")).is_file(),
|
||||
item["api"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,343 +0,0 @@
|
||||
"""System pages and boards.
|
||||
|
||||
NAOMI opened on hod2bios.zip, a set 13 of the 19 emulators listing it read
|
||||
for one game. Lines read other names for one file as files to get: "Start
|
||||
with: fdsbios.zip, disksys.rom". Twelve neogeo.zip were on five pages, the
|
||||
SNK ones closed under Variants. The Atom, two MZ computers, a sound chip and
|
||||
a toy had board pages, and the NAOMI and Atomiswave sets under the
|
||||
Dreamcast folder stayed off their boards' pages. ScummVM's files were on
|
||||
three pages, one section named ".variants". Board pages said their name
|
||||
three times before the first file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
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 siterender # noqa: E402
|
||||
|
||||
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
SYSTEMS = CONTENT / "systems"
|
||||
|
||||
|
||||
def _file(name: str, char: str, path: str = "") -> dict:
|
||||
return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 4096,
|
||||
"path": path or f"bios/Sega/NAOMI/{name}"}
|
||||
|
||||
|
||||
def _consumers(reads: dict[str, list[tuple[str, dict]]]) -> types.SimpleNamespace:
|
||||
"""Consumers where each sha1 is read by (emulator, entry) pairs, an
|
||||
emulator's entries kept in one list as a profile keeps them."""
|
||||
files_of: dict[str, list] = {}
|
||||
entries: dict[str, dict[str, int]] = {}
|
||||
emulators: dict[str, set[str]] = {}
|
||||
for sha1, read_by in reads.items():
|
||||
for emulator, entry in read_by:
|
||||
files_of.setdefault(emulator, []).append(entry)
|
||||
entries.setdefault(sha1, {})[emulator] = len(files_of[emulator]) - 1
|
||||
emulators.setdefault(sha1, set()).add(emulator)
|
||||
return types.SimpleNamespace(
|
||||
platforms={}, emulators=emulators, emulator_entries=entries,
|
||||
emulator_files=files_of, installed={}, platform_systems={},
|
||||
platform_destinations={}, _db={"files": {}},
|
||||
)
|
||||
|
||||
|
||||
def _unlinked(text: str) -> str:
|
||||
"""The wording of a start line: each file it names links to its card,
|
||||
which EachFileDownloadsFromItsCard holds."""
|
||||
return re.sub(r"\[(`[^`]*`)\]\([^)]*\)", r"\1", text)
|
||||
|
||||
|
||||
def _page(stem: str) -> str:
|
||||
path = SYSTEMS / f"{stem}.md"
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest(f"systems/{stem}.md not generated")
|
||||
return _unlinked(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _start_line(text: str) -> str:
|
||||
match = re.search(r"^\*\*(Start with|No file needed)\*\*.*$", text, re.M)
|
||||
return match[0] if match else ""
|
||||
|
||||
|
||||
def _database() -> dict:
|
||||
path = REPO_ROOT / "database.json"
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest("database.json is not built")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class AStartFileIsNoOnesExtra(unittest.TestCase):
|
||||
"""6.1: a file most of whose readers take beside another one is no
|
||||
start file, whoever requires it."""
|
||||
|
||||
def test_the_board_opens_on_its_bios_alone(self) -> None:
|
||||
naomi, hod2 = _file("naomi.zip", "1"), _file("hod2bios.zip", "2")
|
||||
both = [(f"flycast{n}", {"name": "hod2bios.zip", "required": True}) for n in range(2)]
|
||||
extra = [(f"mame{n}", {"name": "hod2bios.zip", "required": False}) for n in range(3)]
|
||||
consumers = _consumers({
|
||||
"1" * 40: [(e, {"name": "naomi.zip", "required": True}) for e, _ in both + extra],
|
||||
"2" * 40: both + extra,
|
||||
})
|
||||
self.assertEqual([_unlinked(line) for line in gs._start_with([naomi, hod2], consumers)],
|
||||
["**Start with**: `naomi.zip`.", ""])
|
||||
|
||||
def test_the_naomi_page_opens_on_naomi_zip(self) -> None:
|
||||
self.assertEqual(_start_line(_page("arcade-sega-naomi")), "**Start with**: `naomi.zip`.")
|
||||
|
||||
def test_a_file_several_require_beside_the_lead_stays(self) -> None:
|
||||
bios7, bios9 = _file("bios7.bin", "3"), _file("bios9.bin", "4")
|
||||
consumers = _consumers({
|
||||
"3" * 40: [(e, {"name": "bios7.bin", "required": True}) for e in ("a", "b")],
|
||||
"4" * 40: [(e, {"name": "bios9.bin", "required": True}) for e in ("a", "b")],
|
||||
})
|
||||
self.assertEqual(_unlinked(gs._start_with([bios7, bios9], consumers)[0]),
|
||||
"**Start with**: `bios7.bin`, `bios9.bin`.")
|
||||
|
||||
def test_some_files_each_is_not_every_file_for_one(self) -> None:
|
||||
"""The SNES said bsnes and four more require 22 files."""
|
||||
files = [_file(f"chip{n}.rom", str(n)) for n in range(1, 4)]
|
||||
reads = {f["sha1"]: [(f"free{k}", {"name": f["name"], "required": False,
|
||||
"hle_fallback": True}) for k in range(5)]
|
||||
for f in files}
|
||||
reads["1" * 40].append(("bsnes", {"name": "chip1.rom", "required": True}))
|
||||
reads["2" * 40].append(("jgenesis", {"name": "chip2.rom", "required": True}))
|
||||
reads["3" * 40].append(("jgenesis", {"name": "chip3.rom", "required": True}))
|
||||
line = gs._start_with(files, _consumers(reads))[0]
|
||||
self.assertIn("require some of these files: ", line)
|
||||
|
||||
|
||||
class OtherNamesForOneSlotReadAsAlternatives(unittest.TestCase):
|
||||
"""6.2: "Start with: fdsbios.zip, disksys.rom" read as two files."""
|
||||
|
||||
def test_the_other_name_is_read_instead(self) -> None:
|
||||
disk, fds = _file("disksys.rom", "5"), _file("fdsbios.zip", "6")
|
||||
consumers = _consumers({
|
||||
"5" * 40: [(e, {"name": "disksys.rom", "required": True})
|
||||
for e in ("mesen", "nestopia", "fceumm")],
|
||||
"6" * 40: [("fbneo", {"name": "fdsbios.zip", "required": True})],
|
||||
})
|
||||
self.assertEqual(_unlinked(gs._start_with([disk, fds], consumers)[0]),
|
||||
"**Start with**: `disksys.rom`. FinalBurn Neo reads `fdsbios.zip` instead.")
|
||||
|
||||
def test_a_card_read_under_another_name_says_both(self) -> None:
|
||||
disk, card = _file("disksys.rom", "5"), _file("long name (1986).bin", "7")
|
||||
consumers = _consumers({
|
||||
"5" * 40: [(e, {"name": "disksys.rom", "required": True}) for e in ("a", "b")],
|
||||
"7" * 40: [("c", {"name": "short.rom", "required": True})],
|
||||
})
|
||||
self.assertIn("reads `long name (1986).bin` (as `short.rom`) instead.",
|
||||
_unlinked(gs._start_with([disk, card], consumers)[0]))
|
||||
|
||||
def test_a_regional_line_names_one_file_per_reader(self) -> None:
|
||||
mega_cd = _page("sega").split("{ #mega-cd }", 1)[1].split("\n## ", 1)[0]
|
||||
regions = [line for line in mega_cd.splitlines() if line.startswith("- **")]
|
||||
self.assertTrue(regions)
|
||||
for line in regions:
|
||||
lead = line.split(". ", 1)[0]
|
||||
self.assertEqual(len(re.findall(r"`[^`]+`", lead)), 1, line)
|
||||
|
||||
def test_the_fds_line(self) -> None:
|
||||
fds = _page("nintendo").split("{ #famicom-disk-system }", 1)[1]
|
||||
self.assertRegex(_start_line(fds), r"^\*\*Start with\*\*: `disksys.rom`\. FinalBurn Neo "
|
||||
r"reads \[?`fdsbios.zip`.* instead\.$")
|
||||
|
||||
|
||||
class TheNeoGeoSetsSitWithTheNeoGeo(unittest.TestCase):
|
||||
"""6.3: every neogeo.zip has its card on SNK's pages, side by side and
|
||||
open, and the ares sentence names the card."""
|
||||
|
||||
def test_every_neogeo_zip_is_on_snk(self) -> None:
|
||||
db = _database()
|
||||
sha1s = [sha1 for sha1, f in db["files"].items() if f["name"] == "neogeo.zip"]
|
||||
held = "".join(path.read_text(encoding="utf-8") for path in SYSTEMS.glob("snk*.md"))
|
||||
if not held:
|
||||
self.skipTest("SNK pages not generated")
|
||||
self.assertEqual([s for s in sha1s if f'id="sha1-{s[:12]}"' not in held], [])
|
||||
others = "".join(path.read_text(encoding="utf-8")
|
||||
for path in SYSTEMS.glob("arcade*.md"))
|
||||
self.assertEqual([s for s in sha1s if f'id="sha1-{s[:12]}"' in others], [])
|
||||
|
||||
def test_the_sets_are_open_and_together(self) -> None:
|
||||
section = _page("snk").split("{ #neo-geo }", 1)[1].split("\n## ", 1)[0]
|
||||
titles = re.findall(r'class="rb-file-title"[^>]*><code>([^<]+)</code>', section)
|
||||
sets = [n for n, name in enumerate(titles) if name == "neogeo.zip"]
|
||||
self.assertGreater(len(sets), 1)
|
||||
self.assertEqual(sets, list(range(len(sets))))
|
||||
variants = section.split("Variants (", 1)[1] if "Variants (" in section else ""
|
||||
self.assertNotIn("<code>neogeo.zip</code>", variants)
|
||||
|
||||
def test_ares_is_told_the_card_it_reads(self) -> None:
|
||||
self.assertIn("ares reads `neo-epo.bin` and `sp-45.sp1` instead.", _page("snk"))
|
||||
|
||||
|
||||
class ABoardIsAnArcadeMachine(unittest.TestCase):
|
||||
"""6.4: computers, a sound chip and a toy had board pages."""
|
||||
|
||||
def test_no_board_has_a_parent_outside_arcade(self) -> None:
|
||||
wrong = sorted(board for board in siterender.arcade_boards()
|
||||
if board in siterender.SYSTEM_PARENTS)
|
||||
self.assertEqual(wrong, [])
|
||||
|
||||
def test_no_board_page_for_a_machine_filed_elsewhere(self) -> None:
|
||||
if not SYSTEMS.is_dir():
|
||||
self.skipTest("pages not generated")
|
||||
for stem in ("arcade-acorn-atom", "arcade-sharp-mz-2000", "arcade-sharp-mz-2500",
|
||||
"arcade-yamaha-ym2608", "arcade-miuchiz"):
|
||||
self.assertFalse((SYSTEMS / f"{stem}.md").exists(), stem)
|
||||
|
||||
def test_the_computers_sets_are_on_their_makers_page(self) -> None:
|
||||
acorn = _page("acorn")
|
||||
self.assertIn("{ #atom }", acorn)
|
||||
self.assertIn("<code>atom_gdos.zip</code>", acorn)
|
||||
cards = re.findall(r'class="rb-file-title"[^>]*><code>([^<]+)</code>', _page("sharp"))
|
||||
self.assertEqual(cards.count("mz2000.zip"), 2)
|
||||
|
||||
|
||||
class TheDreamcastBoardsAreFiledByBoard(unittest.TestCase):
|
||||
"""6.5: the NAOMI and Atomiswave sets under the Dreamcast folder."""
|
||||
|
||||
def test_the_board_sets_left_the_dreamcast(self) -> None:
|
||||
sega = _page("sega")
|
||||
dreamcast = sega.split("{ #dreamcast }", 1)[1].split("\n## ", 1)[0]
|
||||
for name in ("naomi.zip", "awbios.zip", "naomi2.zip"):
|
||||
self.assertNotIn(f"<code>{name}</code>", dreamcast)
|
||||
self.assertIn("(/retrobios/systems/arcade-sega-naomi/)", dreamcast)
|
||||
self.assertIn("(/retrobios/systems/arcade-sammy-atomiswave/)", dreamcast)
|
||||
|
||||
def test_every_naomi_set_is_on_its_board(self) -> None:
|
||||
db = _database()
|
||||
page = _page("arcade-sega-naomi")
|
||||
missing = [sha1 for sha1, f in db["files"].items()
|
||||
if f["name"] == "naomi.zip" and f'id="sha1-{sha1[:12]}"' not in page]
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_the_dreamcast_arcade_id_leads_to_naomi(self) -> None:
|
||||
siterender.reset_console_pages()
|
||||
for board in siterender.arcade_boards():
|
||||
siterender.record_board_section(board)
|
||||
try:
|
||||
self.assertEqual(siterender.system_section("sega-dreamcast-arcade"),
|
||||
("arcade", "Sega NAOMI"))
|
||||
finally:
|
||||
siterender.reset_console_pages()
|
||||
|
||||
|
||||
class EnginesGoByEngine(unittest.TestCase):
|
||||
"""6.6: no section named after a folder of variants or a file."""
|
||||
|
||||
def test_no_section_or_row_is_a_folder_or_a_file(self) -> None:
|
||||
db = _database()
|
||||
names = set(db["indexes"]["by_name"])
|
||||
index = _page("index")
|
||||
rows = re.findall(r"^\| \[([^\]]+)\]\(", index, re.M)
|
||||
headings = [h.strip() for path in SYSTEMS.glob("*.md")
|
||||
for h in re.findall(r"^## ([^{\n]+)", path.read_text(encoding="utf-8"), re.M)]
|
||||
wrong = sorted({n for n in rows + headings if n.startswith(".") or n in names})
|
||||
self.assertEqual(wrong, [])
|
||||
|
||||
def test_scummvm_has_one_page(self) -> None:
|
||||
self.assertFalse((SYSTEMS / "other-scummvm.md").exists())
|
||||
self.assertIn("## ScummVM {", _page("scummvm"))
|
||||
self.assertNotIn("](/retrobios/systems/other/#scummvm)", _page("index"))
|
||||
|
||||
def test_a_folder_named_by_a_profile_key_reads_the_engine(self) -> None:
|
||||
index = _page("index")
|
||||
self.assertIn("| [EasyRPG Player](/retrobios/systems/rpg-maker/#easyrpg) |", index)
|
||||
self.assertIn("| [SDLPAL](", index)
|
||||
|
||||
def test_moved_pages_redirect(self) -> None:
|
||||
if not SYSTEMS.is_dir():
|
||||
self.skipTest("pages not generated")
|
||||
for old, new in gs.SYSTEM_PAGES_MOVED.items():
|
||||
self.assertFalse((CONTENT / old).exists() or (CONTENT / old).with_suffix(".mdx").exists(), old)
|
||||
# A page with tabs is written as MDX.
|
||||
self.assertTrue((CONTENT / new).exists() or (CONTENT / new).with_suffix(".mdx").exists(), new)
|
||||
redirects = CONTENT.parents[1] / "generated" / "redirects.json"
|
||||
if redirects.is_file():
|
||||
table = json.loads(redirects.read_text(encoding="utf-8"))
|
||||
self.assertEqual(table.get("/systems/other-scummvm/"), "/systems/scummvm/")
|
||||
|
||||
|
||||
class OneLabelOnceOnABoardPage(unittest.TestCase):
|
||||
"""6.7, 6.8, 6.9, 6.10: the words a board or part page repeats."""
|
||||
|
||||
def test_a_label_naming_files_installs_these_files(self) -> None:
|
||||
block = "\n".join(gs._console_install_block("Other arcade files", [("mame", "arcade")]))
|
||||
self.assertIn("<summary>Install these files</summary>", block)
|
||||
block = "\n".join(gs._console_install_block("Sega NAOMI", [("mame", "arcade")]))
|
||||
self.assertIn("<summary>Install the files of Sega NAOMI</summary>", block)
|
||||
|
||||
def test_a_board_page_has_no_heading_of_its_name(self) -> None:
|
||||
page = _page("arcade-sega-naomi")
|
||||
self.assertNotRegex(page, r"^## Sega NAOMI", )
|
||||
self.assertNotIn("](/retrobios/systems/arcade-sega-naomi/#sega-naomi)",
|
||||
_page("index"))
|
||||
|
||||
def test_no_part_says_where_it_sits(self) -> None:
|
||||
if not SYSTEMS.is_dir():
|
||||
self.skipTest("pages not generated")
|
||||
said = sorted(p.name for p in SYSTEMS.glob("*.md")
|
||||
if "\nPart of [" in p.read_text(encoding="utf-8"))
|
||||
self.assertEqual(said, [])
|
||||
|
||||
def test_a_part_goes_by_the_emulators_name(self) -> None:
|
||||
titles = [re.search(r"^title: (.*)$", p.read_text(encoding="utf-8"), re.M)[1].strip("'")
|
||||
for p in SYSTEMS.glob("other-mame*.md")]
|
||||
if not titles:
|
||||
self.skipTest("pages not generated")
|
||||
self.assertEqual([t for t in titles if not t.startswith(("MAME files", "Other: MAME"))],
|
||||
[])
|
||||
|
||||
|
||||
class EachFileDownloadsFromItsCard(unittest.TestCase):
|
||||
"""The Apple II page named eight start files and gave no visible way to
|
||||
get one: the download was a "(download)" after the collection path,
|
||||
fourth of a card's facts."""
|
||||
|
||||
def test_the_card_title_holds_the_download(self) -> None:
|
||||
from cross_reference import FileConsumers # noqa: PLC0415
|
||||
|
||||
f = _file("apple2-asoft-auto.rom", "3", "bios/Apple/Apple II/apple2-asoft-auto.rom")
|
||||
title = next(line for line in gs._system_file_card(f, FileConsumers({"files": {}}))
|
||||
if "rb-file-title" in line)
|
||||
self.assertIn('class="rb-download" href="https://raw.githubusercontent.com/Abdess/'
|
||||
'retrobios/main/bios/Apple/Apple%20II/apple2-asoft-auto.rom"', title)
|
||||
self.assertIn('aria-label="Download apple2-asoft-auto.rom"', title)
|
||||
facts = "\n".join(gs._system_file_card(f, FileConsumers({"files": {}})))
|
||||
self.assertNotIn("(download)", facts)
|
||||
|
||||
def test_a_start_name_leads_to_its_card(self) -> None:
|
||||
line = gs._link_held_names("- **Apple II**: `a.rom`, `b.rom`.",
|
||||
{"a.rom": "a" * 40, "b.rom": "b" * 40}, {"a" * 40})
|
||||
self.assertEqual(line, "- **Apple II**: [`a.rom`](#sha1-aaaaaaaaaaaa), `b.rom`.")
|
||||
|
||||
def test_a_card_on_another_part_is_reached_by_its_permalink(self) -> None:
|
||||
full = next(iter(json.loads((REPO_ROOT / "database.json").read_text())["files"]))
|
||||
here = f'[`x.rom`](#sha1-{full[:12]})'
|
||||
away = gs._cards_elsewhere(here)
|
||||
self.assertEqual(away, f"[`x.rom`](/retrobios/files/?sha1={full})")
|
||||
held = here + f'\n<h3 id="sha1-{full[:12]}" class="rb-file-title">'
|
||||
self.assertEqual(gs._cards_elsewhere(held), held)
|
||||
|
||||
def test_every_start_name_on_the_built_apple_page_links(self) -> None:
|
||||
page = next(SYSTEMS.glob("apple.md*"), None)
|
||||
if page is None:
|
||||
self.skipTest("site not generated")
|
||||
section = page.read_text(encoding="utf-8").split("## Apple II {", 1)[1]
|
||||
start = section.split("<section", 1)[0]
|
||||
self.assertIn("[`apple2-asoft-auto.rom`](#sha1-33a24f5489ba)", start)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,225 +0,0 @@
|
||||
"""Wiki.
|
||||
|
||||
Each class names the finding it holds: a "no setup" command that sent
|
||||
macOS and Steam Deck files where RetroArch never reads, a level 0 rule that
|
||||
every emulator needs one exact name, FAQ answers that ended at a ZIP most
|
||||
setups lack, a packager section pointing at release assets that do not
|
||||
exist, and two copy fixes. Every claim is held against install.py, the
|
||||
setup guides or the generator that writes the page a link opens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import sitediagrams # noqa: E402
|
||||
from siteguides import INSTALL_SH # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
WIKI = REPO_ROOT / "wiki"
|
||||
FENCED = re.compile(r"^```.*?^```", re.S | re.M)
|
||||
INSTALL_PY = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _wiki(name: str) -> str:
|
||||
return (WIKI / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _front(text: str) -> dict:
|
||||
return yaml.safe_load(text.split("---", 2)[1])
|
||||
|
||||
|
||||
def _section(text: str, heading: str) -> str:
|
||||
start = re.search(rf"(?m)^#+ {re.escape(heading)}(?:\s*\{{[^}}]*\}})?\n", text)
|
||||
if start is None:
|
||||
raise AssertionError(f"no section {heading!r}")
|
||||
rest = text[start.end():]
|
||||
blank = FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest)
|
||||
end = re.search(r"(?m)^#{1,2} ", blank)
|
||||
return rest[: end.start()] if end else rest
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _guide_folders(name: str) -> dict[str, str]:
|
||||
guides = yaml.safe_load((REPO_ROOT / "platforms" / "_guides.yml").read_text(encoding="utf-8"))
|
||||
return {row["where"]: row["path"] for row in guides["guides"][name]["folders"]}
|
||||
|
||||
|
||||
class NoSetupFoundGivesEachSystemItsFolder(unittest.TestCase):
|
||||
"""C3: macOS, Linux and Steam Deck shared `~/.config/retroarch/system`."""
|
||||
|
||||
def setUp(self):
|
||||
self.section = _section(_wiki("troubleshooting.md"), "If the command fails")
|
||||
|
||||
def test_the_label_is_the_installer_message(self):
|
||||
self.assertIn("**`No supported platform detected`:**", self.section)
|
||||
self.assertIn('else " No supported platform detected.")', INSTALL_PY)
|
||||
|
||||
def test_the_lead_names_the_menu(self):
|
||||
self.assertIn("Not sure? Open RetroArch's **Settings > Directory > System/BIOS**",
|
||||
_flat(self.section))
|
||||
|
||||
def test_each_tab_carries_the_folder_retroarch_reads_there(self):
|
||||
expected = {
|
||||
# install.py's Windows default and the hint it prints.
|
||||
"Windows": "C:\\RetroArch-Win64\\system",
|
||||
"macOS": "~/Documents/RetroArch/system",
|
||||
"Linux": "~/.config/retroarch/system",
|
||||
"Steam Deck": "~/.var/app/org.libretro.RetroArch/config/retroarch/system",
|
||||
}
|
||||
self.assertIn('_host_path("C:/RetroArch-Win64/system")', INSTALL_PY)
|
||||
self.assertIn('"windows": "C:\\\\RetroArch-Win64\\\\system"', INSTALL_PY)
|
||||
self.assertIn('return Path.home() / "Documents" / "RetroArch"', INSTALL_PY)
|
||||
folders = _guide_folders("retroarch")
|
||||
self.assertEqual(folders["macOS"], f"`{expected['macOS']}/`")
|
||||
self.assertEqual(folders["Linux"], f"`{expected['Linux']}/`")
|
||||
self.assertEqual(folders["Steam Deck and Linux Flatpak"], f"`{expected['Steam Deck']}/`")
|
||||
|
||||
tabs = sitediagrams.expand_commands(self.section)
|
||||
parts = re.split(r'<TabItem label="([^"]+)">', tabs)
|
||||
bodies = dict(zip(parts[1::2], parts[2::2]))
|
||||
self.assertEqual(set(bodies), set(expected))
|
||||
for label, folder in expected.items():
|
||||
with self.subTest(tab=label):
|
||||
self.assertIn(f"--platform retroarch --dest {folder}\n", bodies[label])
|
||||
others = {f for key, f in expected.items() if key != label}
|
||||
for other in others:
|
||||
self.assertNotIn(f"--dest {other}\n", bodies[label])
|
||||
|
||||
def test_the_built_page_shows_four_folders(self):
|
||||
sitepages.require()
|
||||
built = sitepages.page("wiki/troubleshooting.md").read_text(encoding="utf-8")
|
||||
self.assertNotIn("os-tabs", built)
|
||||
for folder in ("C:\\RetroArch-Win64\\system", "~/Documents/RetroArch/system",
|
||||
"~/.config/retroarch/system",
|
||||
"~/.var/app/org.libretro.RetroArch/config/retroarch/system"):
|
||||
with self.subTest(folder=folder):
|
||||
self.assertIn(f"--dest {folder}", built)
|
||||
|
||||
|
||||
class OsTabsTakeOptionsPerSystem(unittest.TestCase):
|
||||
"""The marker gives any tab its own options, and refuses words it cannot read."""
|
||||
|
||||
PAGE = ("<!-- os-tabs windows: --dest E:\\x macos: --dest /m steamdeck: --dest /d -->\n"
|
||||
f"```sh\ncurl -fsSL {INSTALL_SH} | sh -s -- --dest /l\n```\n")
|
||||
|
||||
def test_each_named_tab_uses_its_own(self):
|
||||
parts = re.split(r'<TabItem label="([^"]+)">', sitediagrams.expand_commands(self.PAGE))
|
||||
bodies = dict(zip(parts[1::2], parts[2::2]))
|
||||
self.assertIn("--dest E:\\x\n", bodies["Windows"])
|
||||
self.assertIn("--dest /m\n", bodies["macOS"])
|
||||
self.assertIn("--dest /l\n", bodies["Linux"])
|
||||
self.assertIn("--dest /d\n", bodies["Steam Deck"])
|
||||
|
||||
def test_text_before_a_system_name_fails_the_build(self):
|
||||
page = self.PAGE.replace("os-tabs windows:", "os-tabs typo windows:")
|
||||
with self.assertRaises(ValueError):
|
||||
sitediagrams.expand_commands(page)
|
||||
|
||||
|
||||
class MostEmulatorsNotEvery(unittest.TestCase):
|
||||
"""C8: level 0 said every emulator needs one exact name."""
|
||||
|
||||
def test_the_rule_names_its_exception(self):
|
||||
section = _flat(_section(_wiki("what-are-bios-files.md"), "The name must match"))
|
||||
self.assertIn("Most emulators look for one exact name, like `scph5501.bin`.", section)
|
||||
self.assertIn("A few, like DuckStation, read any name and check what the file holds.",
|
||||
section)
|
||||
profile = yaml.safe_load((REPO_ROOT / "emulators" / "duckstation.yml").read_text(
|
||||
encoding="utf-8"))
|
||||
self.assertEqual(profile["found_by"], "content")
|
||||
|
||||
|
||||
class FaqAnswersLeadSomewhere(unittest.TestCase):
|
||||
"""C9: "Use the ZIP" for iOS, and a dead end for 12 setups without one."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("faq.md")
|
||||
self.generator = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_ios_goes_to_the_iphone_section(self):
|
||||
section = _flat(_section(self.text, "My setup is not in the list"))
|
||||
self.assertIn("iPhone, iPad (iOS): no terminal. [Pick your consoles and copy them "
|
||||
"to the app](../which-pack.md#iphone-and-ipad).", section)
|
||||
self.assertNotIn("Use the ZIP", section)
|
||||
self.assertIn('"## iPhone and iPad { #iphone-and-ipad }"', self.generator)
|
||||
|
||||
def test_antivirus_answer_covers_setups_without_a_zip(self):
|
||||
section = _flat(_section(self.text, "Windows or my antivirus blocks the command"))
|
||||
self.assertIn("Use your setup's ZIP. None? [Pick your consoles]"
|
||||
"(../which-pack.md#only-some-consoles) and download their files.",
|
||||
section)
|
||||
self.assertNotIn("if any", section)
|
||||
self.assertIn('"## Only some consoles?"', self.generator)
|
||||
|
||||
|
||||
class PackagersPinACommit(unittest.TestCase):
|
||||
"""J4: the section fetched install.py from a release that carries none."""
|
||||
|
||||
def setUp(self):
|
||||
self.section = _section(_wiki("installer.md"), "For packagers")
|
||||
|
||||
def test_the_pin_read_is_the_one_install_sh_writes(self):
|
||||
sed = re.search(r"sed -n '(s/\^DEFAULT_INSTALL_SHA256=.*?/p)'", self.section)
|
||||
self.assertIsNotNone(sed)
|
||||
bootstrap = (REPO_ROOT / "install.sh").read_text(encoding="utf-8")
|
||||
lines = re.findall(r'(?m)^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', bootstrap)
|
||||
self.assertEqual(len(lines), 1)
|
||||
|
||||
def test_the_manifest_comes_from_the_same_commit(self):
|
||||
self.assertIn('-O "$base/install/retroarch.json"', self.section)
|
||||
self.assertTrue((REPO_ROOT / "install" / "retroarch.json").is_file())
|
||||
self.assertIn("RETROBIOS_REF=$commit python3 install.py --manifest retroarch.json",
|
||||
self.section)
|
||||
self.assertIn('"--manifest",', INSTALL_PY)
|
||||
self.assertIn('"https://raw.githubusercontent.com/Abdess/retrobios/"', INSTALL_PY)
|
||||
|
||||
def test_search_words(self):
|
||||
terms = _front(_wiki("installer.md"))["terms"]
|
||||
for word in ("packager", "pin", "reproducible"):
|
||||
with self.subTest(word=word):
|
||||
self.assertIn(word, terms)
|
||||
|
||||
|
||||
class SearchWords(unittest.TestCase):
|
||||
"""Contract with the search gate: each page carries its own words."""
|
||||
|
||||
def test_troubleshooting(self):
|
||||
terms = _front(_wiki("troubleshooting.md"))["terms"]
|
||||
for word in ("check my bios folder", "check bios folder"):
|
||||
with self.subTest(word=word):
|
||||
self.assertIn(word, terms)
|
||||
|
||||
def test_legal(self):
|
||||
self.assertIn("license", _front(_wiki("legal.md"))["terms"])
|
||||
|
||||
|
||||
class SmallCopyFixes(unittest.TestCase):
|
||||
"""C-other: the RetroPie share had no name, and Mega CD two spellings."""
|
||||
|
||||
def test_retropie_share_is_named_with_its_source(self):
|
||||
section = _section(_wiki("first-install.md"), "Error: Python 3.8 or newer is required.")
|
||||
self.assertIn("share open, `\\\\RETROPIE\\bios` from Windows:", section)
|
||||
self.assertIn("samba.sh:49-52", section)
|
||||
self.assertIn("Transferring-Roms.md:29", section)
|
||||
|
||||
def test_mega_cd_is_spelled_once(self):
|
||||
for path in sorted(WIKI.glob("*.md")):
|
||||
with self.subTest(page=path.name):
|
||||
self.assertNotIn("Mega-CD", path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,12 @@
|
||||
"""Round 5, package 3: search, sidebar and head.
|
||||
"""Search, sidebar and head.
|
||||
|
||||
The search gate listed the queries that missed and no workflow ran it: its
|
||||
browser runner could not even start from the repository root. Console and
|
||||
error words had no field to reach their page ("wonderswan color", "bios not
|
||||
found"), parts of a split page carried the console page's weight and came
|
||||
first for "which mame version", and 707 pages gave search engines one of
|
||||
five template sentences of level 3 words as their description. These tests
|
||||
read the generator, the gate, the workflow and, where it is built, the site.
|
||||
|
||||
The get/ download pages sat in no tab, and the theme then numbered the tabs
|
||||
0 to 4. "snes bios" found NES.emu and never the SNES page, which answers
|
||||
@@ -9,37 +17,259 @@ emulator's source repository published a commit id as its version beside a
|
||||
placeholder description. These tests read the generator and, where it is
|
||||
built, the site.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
import json
|
||||
from html.parser import HTMLParser
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import generate_site as gs # noqa: E402
|
||||
import sitestarlight # noqa: E402
|
||||
import validate_site # noqa: E402
|
||||
import generate_site # noqa: E402
|
||||
from common import list_registered_platforms, load_emulator_profiles # noqa: E402
|
||||
from tests.test_site_r2_nav import ANCHORS, _nav, _present # noqa: E402
|
||||
from tests.test_site_nav import ANCHORS, _nav, _present # noqa: E402
|
||||
|
||||
|
||||
def _gate_queries() -> dict[str, tuple[tuple[str, ...], int]]:
|
||||
"""The gate's QUERIES, read from website/tools/search_gate.py: a tool
|
||||
script, not a module the suite installs."""
|
||||
tree = ast.parse((REPO_ROOT / "website" / "tools" / "search_gate.py").read_text(encoding="utf-8"))
|
||||
node = next(node for node in tree.body if isinstance(node, ast.AnnAssign)
|
||||
and isinstance(node.target, ast.Name) and node.target.id == "QUERIES")
|
||||
return ast.literal_eval(node.value)
|
||||
TOOLS = REPO_ROOT / "website" / "tools"
|
||||
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "deploy-site.yml"
|
||||
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _front(text: str) -> dict:
|
||||
return yaml.safe_load(text.split("---\n")[1])
|
||||
|
||||
|
||||
def _gate_queries() -> dict:
|
||||
tree = ast.parse((TOOLS / "search_gate.py").read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "QUERIES":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("no QUERIES in search_gate.py")
|
||||
|
||||
|
||||
def _finalize(rel: str, body: str, authored: dict) -> dict:
|
||||
_target, text = sitestarlight.finalize_page(
|
||||
rel, body, level="", weight=None, browser_title="T", description="d",
|
||||
structured={}, terms={}, authored=authored)
|
||||
return {"front": _front(text), "text": text}
|
||||
|
||||
|
||||
class TheGateRunsAndBlocks(unittest.TestCase):
|
||||
def test_the_deploy_runs_the_gate_before_the_upload(self) -> None:
|
||||
steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["build"]["steps"]
|
||||
runs = [str(step.get("run", "")) for step in steps]
|
||||
uses = [str(step.get("uses", "")) for step in steps]
|
||||
gate = next(i for i, run in enumerate(runs)
|
||||
if "website/tools/search_gate.py --site-dir website/dist" in run)
|
||||
build = next(i for i, run in enumerate(runs) if "npm run build --prefix website" in run)
|
||||
upload = next(i for i, use in enumerate(uses) if "upload-pages-artifact" in use)
|
||||
self.assertLess(build, gate)
|
||||
self.assertLess(gate, upload)
|
||||
# The driver is pinned to a version and kept out of the lockfile.
|
||||
self.assertRegex(runs[gate], r"npm install --no-save --prefix website playwright-core@\d+\.\d+\.\d+")
|
||||
|
||||
def test_no_third_party_action_and_every_action_pinned(self) -> None:
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
for repo, ref in re.findall(r"uses:\s*([\w.-]+/[\w.-]+)@(\S+)", text):
|
||||
with self.subTest(action=repo):
|
||||
self.assertTrue(repo.startswith("actions/"))
|
||||
self.assertRegex(ref, r"^[0-9a-f]{40}$")
|
||||
|
||||
def test_the_runner_resolves_from_website_and_says_what_is_missing(self) -> None:
|
||||
source = (TOOLS / "measure.mjs").read_text(encoding="utf-8")
|
||||
self.assertIn("await import('playwright-core')", source)
|
||||
self.assertIn("npm install --no-save --prefix website playwright-core", source)
|
||||
self.assertNotRegex(source, r"^import \{ chromium \}", "a static import fails before any message")
|
||||
|
||||
def test_the_gate_serves_a_build_dir_and_fails_on_a_miss(self) -> None:
|
||||
source = (TOOLS / "search_gate.py").read_text(encoding="utf-8")
|
||||
self.assertIn("--site-dir", source)
|
||||
self.assertIn("serve.mjs", source)
|
||||
self.assertIn("return 0 if answered == len(QUERIES)", source)
|
||||
|
||||
def test_the_plans_queries_are_gated(self) -> None:
|
||||
queries = _gate_queries()
|
||||
expected = {
|
||||
"playstation bios": ("systems/sony-playstation/", 1),
|
||||
"please insert playstation cd-rom": ("wiki/troubleshooting/", 3),
|
||||
"bios not found": ("wiki/troubleshooting/", 3),
|
||||
"check my dump": ("files/", 1),
|
||||
"md5 check": ("files/", 1),
|
||||
"manifest": ("data/", 3),
|
||||
"events": ("wiki/installer/", 3),
|
||||
"exit code": ("wiki/installer/", 3),
|
||||
"rate limit": ("data/", 3),
|
||||
"ios": ("wiki/faq/", 3),
|
||||
"dc bios missing": ("emulators/flycast/", 3),
|
||||
"neogeo.zip": ("systems/snk/", 1),
|
||||
"scph5501": ("systems/sony-playstation/", 3),
|
||||
}
|
||||
for query, (page, rank) in expected.items():
|
||||
with self.subTest(query=query):
|
||||
pages, gated = queries[query]
|
||||
self.assertTrue(any(page.startswith(prefix) for prefix in pages))
|
||||
self.assertLessEqual(gated, rank)
|
||||
|
||||
def test_the_home_has_a_weight_budget(self) -> None:
|
||||
source = (TOOLS / "search_gate.py").read_text(encoding="utf-8")
|
||||
self.assertIn("HOME_BUDGET_KB = 80", source)
|
||||
|
||||
|
||||
class FieldsFromFrontMatter(unittest.TestCase):
|
||||
def test_consoles_and_terms_become_search_fields(self) -> None:
|
||||
text = _finalize("systems/snk.md", "# SNK\n\nBody.\n", {
|
||||
"consoles": ["Neo Geo", "Neo Geo Pocket Color"],
|
||||
"terms": "bios not found, bios missing",
|
||||
})["text"]
|
||||
self.assertIn('data-pagefind-meta="consoles:Neo Geo Neo Geo Pocket Color"', text)
|
||||
self.assertIn('data-pagefind-meta="terms:bios not found bios missing Neo Geo BIOS '
|
||||
'Neo Geo Pocket Color BIOS"', text)
|
||||
# A field value is an attribute: none of it reaches an excerpt.
|
||||
self.assertNotRegex(text, r">[^<]*bios missing[^<]*<")
|
||||
|
||||
def test_no_search_word_is_typed_in_the_module(self) -> None:
|
||||
source = (REPO_ROOT / "scripts" / "sitestarlight.py").read_text(encoding="utf-8")
|
||||
for words in ("bios not found", "mame version", "WonderSwan"):
|
||||
self.assertNotIn(words, source)
|
||||
|
||||
def test_a_page_without_fields_gets_none(self) -> None:
|
||||
text = _finalize("wiki/legal.md", "# Legal\n\nBody.\n", {})["text"]
|
||||
self.assertNotIn("consoles:", text)
|
||||
self.assertNotIn("terms:", text)
|
||||
|
||||
|
||||
class FindOrCheckAFile(unittest.TestCase):
|
||||
""""check my dump" and "md5 check" found emulator pages first."""
|
||||
|
||||
def test_the_page_weighs_two(self) -> None:
|
||||
rel = Path("files/index.md")
|
||||
self.assertEqual(gs.search_weight(rel, gs.page_level(rel)), 2)
|
||||
|
||||
def test_its_terms_become_a_field(self) -> None:
|
||||
text = _finalize("files/index.md", "# Find or check a file\n\nBody.\n",
|
||||
{"terms": "check dump, md5 check"})["text"]
|
||||
self.assertIn('data-pagefind-meta="terms:check dump md5 check"', text)
|
||||
|
||||
def test_the_sidebar_names_it_as_its_title(self) -> None:
|
||||
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
self.assertIn('{"Find or check a file": "files/index.md"}', source)
|
||||
self.assertNotIn('{"Find a file": "files/index.md"}', source)
|
||||
|
||||
|
||||
class PartPages(unittest.TestCase):
|
||||
def test_part_weight(self) -> None:
|
||||
self.assertEqual(gs.SEARCH_WEIGHT["part"], 0.5)
|
||||
self.assertEqual(gs.search_weight(Path("systems/arcade-mame-5.md"), "part"), 0.5)
|
||||
|
||||
def test_a_part_keeps_its_own_weight_and_neighbours(self) -> None:
|
||||
"""The MAME card parts write prev, next and searchWeight: the build
|
||||
kept only parent and wrote prev: false, next: false."""
|
||||
page = (
|
||||
"---\n"
|
||||
"parent:\n label: MAME\n link: mame.md\n"
|
||||
"prev:\n label: MAME files, part 2 of 8\n link: mame-files-2.md\n"
|
||||
"next:\n label: MAME files, part 4 of 8\n link: mame-files-4.md\n"
|
||||
f"searchWeight: {gs.search_weight(Path('emulators/mame-files-3.md'), 'part')}\n"
|
||||
"---\n\n# MAME files, part 3 of 8\n\nPart of [MAME](mame.md). "
|
||||
"This part holds 196 entries, `a.zip` to `b.zip`.\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
path = Path(tmp) / "emulators" / "mame-files-3.md"
|
||||
path.parent.mkdir()
|
||||
path.write_text(page, encoding="utf-8")
|
||||
gs.PART_PAGES.add("emulators/mame-files-3.md")
|
||||
self.addCleanup(gs.PART_PAGES.discard, "emulators/mame-files-3.md")
|
||||
gs.decorate_markdown_pages(Path(tmp))
|
||||
front = _front(path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertEqual(front["prev"], {"label": "MAME files, part 2 of 8",
|
||||
"link": "/retrobios/emulators/mame-files-2/"})
|
||||
self.assertEqual(front["next"]["link"], "/retrobios/emulators/mame-files-4/")
|
||||
self.assertEqual(front["parent"]["link"], "/retrobios/emulators/mame/")
|
||||
self.assertIn("This part holds 196 entries, a.zip to b.zip", front["description"])
|
||||
|
||||
def test_built_mame_parts_state_their_neighbours(self) -> None:
|
||||
part = DOCS / "emulators" / "mame-files-3.md"
|
||||
if not part.is_file():
|
||||
self.skipTest("site pages not generated")
|
||||
front = _front(part.read_text(encoding="utf-8"))
|
||||
if "searchWeight" not in front and front.get("prev") is False:
|
||||
self.skipTest("the emulator pages package is not merged")
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertTrue(str(front["prev"]["link"]).startswith("/retrobios/emulators/"))
|
||||
|
||||
|
||||
class Descriptions(unittest.TestCase):
|
||||
def test_titles_say_bios_files(self) -> None:
|
||||
saved = dict(gs.PAGE_DATA)
|
||||
self.addCleanup(lambda: (gs.PAGE_DATA.clear(), gs.PAGE_DATA.update(saved)))
|
||||
gs.PAGE_DATA["emulators/duckstation.md"] = {"hasPart": [{"name": "scph5501.bin"}]}
|
||||
self.assertEqual(gs._browser_title(Path("emulators/duckstation.md"), "DuckStation"),
|
||||
"DuckStation BIOS files")
|
||||
self.assertEqual(gs._browser_title(Path("systems/sega.md"), "Sega"), "Sega BIOS files")
|
||||
self.assertEqual(gs._browser_title(Path("systems/sega-dreamcast.md"), "Sega: Dreamcast"),
|
||||
"Dreamcast BIOS files (Sega)")
|
||||
|
||||
def test_an_emulator_page_is_described_by_its_intro(self) -> None:
|
||||
saved = dict(gs.PAGE_DATA)
|
||||
self.addCleanup(lambda: (gs.PAGE_DATA.clear(), gs.PAGE_DATA.update(saved)))
|
||||
gs.PAGE_DATA["emulators/duckstation.md"] = {"hasPart": [{"name": "scph5501.bin"}]}
|
||||
body = ("# DuckStation\n\nDuckStation emulates Sony PlayStation. It picks the BIOS for "
|
||||
"your game's region.\n\n<details>\n\nThe installer finds your platform.\n")
|
||||
text = gs._page_description(body, Path("emulators/duckstation.md"), "DuckStation")
|
||||
self.assertTrue(text.startswith("DuckStation emulates Sony PlayStation."))
|
||||
self.assertNotIn("source-pinned", text)
|
||||
|
||||
def test_a_console_page_is_described_by_its_start_line(self) -> None:
|
||||
body = "# Sega: Dreamcast\n\nPart of Sega.\n\n**Start with**: `dc_boot.bin`, `dc_flash.bin`.\n"
|
||||
text = gs._page_description(body, Path("systems/sega-dreamcast.md"), "Sega: Dreamcast")
|
||||
self.assertEqual(text, "Dreamcast BIOS files (Sega). Start with dc_boot.bin, dc_flash.bin.")
|
||||
|
||||
def test_the_faq_is_described_by_its_questions(self) -> None:
|
||||
body = ("# Common questions\n\n## Do I need BIOS files?\n\nYes.\n\n## Which one?\n\n"
|
||||
"Run the command with --dry-run.\n\n## Zip or not?\n\nZip.\n")
|
||||
text = gs._page_description(body, Path("wiki/faq.md"), "Common questions")
|
||||
self.assertTrue(text.startswith("Common questions: Do I need BIOS files?"))
|
||||
|
||||
def test_a_written_description_wins(self) -> None:
|
||||
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
self.assertIn('authored.get("description") or _page_description(', source)
|
||||
|
||||
def test_the_check_counts_templates(self) -> None:
|
||||
pages = {}
|
||||
for n in range(12):
|
||||
page = validate_site.Page(Path(f"/s/p{n}/index.html"))
|
||||
page.h1 = f"Core {n}"
|
||||
page.title = f"Core {n} BIOS files - RetroBIOS"
|
||||
page.descriptions = [f"Core {n}: emulator firmware profile with paths."]
|
||||
pages[page.path] = page
|
||||
issues = validate_site._check_description_templates(pages, Path("/s"))
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertIn("12 pages share", issues[0])
|
||||
for page in list(pages.values())[:2]:
|
||||
page.descriptions = [f"{page.h1} emulates a console of its own {page.h1[-1]}x."]
|
||||
self.assertEqual(validate_site._check_description_templates(pages, Path("/s")), [])
|
||||
|
||||
def test_built_descriptions_are_not_templates(self) -> None:
|
||||
if not (DIST / "index.html").is_file():
|
||||
self.skipTest("site not built")
|
||||
pages = {path.resolve(): validate_site._parse_page(path) for path in DIST.rglob("*.html")}
|
||||
content = {path: page for path, page in pages.items() if not page.redirect}
|
||||
self.assertEqual(validate_site._check_description_templates(content, DIST.resolve()), [])
|
||||
|
||||
|
||||
QUERIES = _gate_queries()
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
ROUTE = REPO_ROOT / "website" / "src" / "routeData.ts"
|
||||
PLATFORMS = str(REPO_ROOT / "platforms")
|
||||
|
||||
@@ -334,7 +564,7 @@ def _head(path: Path) -> _Head:
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
class BuiltSite(unittest.TestCase):
|
||||
class BuiltSearchSidebarAndHead(unittest.TestCase):
|
||||
def test_no_sidebar_group_is_named_by_a_number(self):
|
||||
for path in sorted(DIST.rglob("*.html")):
|
||||
labels = _head(path).sidebar
|
||||
File renamed without changes.
@@ -113,7 +113,7 @@ class SystemLinks(unittest.TestCase):
|
||||
cls.manufacturers, siterender.program_names(profiles))
|
||||
# The system pages give every arcade board a section of its own
|
||||
# and record it. The built pages are checked by
|
||||
# the round 4 gate.
|
||||
# test_site_built_gate.
|
||||
siterender.reset_console_pages()
|
||||
# The sections as the system pages hold them: each board, the
|
||||
# sets a reviewed machine owns under that machine.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -64,7 +64,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` |
|
||||
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_bootstrap`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
|
||||
| Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
Reference in new issue
Block a user