diff --git a/scripts/generate_site.py b/scripts/generate_site.py index ac9c8b872..445ca2c7f 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -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. diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index a4498a0fd..c8a5e805c 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -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.""" diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index ed017799a..a4920322b 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -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. diff --git a/tests/test_prose_style.py b/tests/test_prose_style.py index fe622e977..490b63b83 100644 --- a/tests/test_prose_style.py +++ b/tests/test_prose_style.py @@ -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 diff --git a/tests/test_site_r4_p0.py b/tests/test_site_arcade_boards.py similarity index 100% rename from tests/test_site_r4_p0.py rename to tests/test_site_arcade_boards.py diff --git a/tests/test_site_r2_gate.py b/tests/test_site_built_gate.py similarity index 97% rename from tests/test_site_r2_gate.py rename to tests/test_site_built_gate.py index 2ee703a42..c1957ab2f 100644 --- a/tests/test_site_r2_gate.py +++ b/tests/test_site_built_gate.py @@ -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. diff --git a/tests/test_site_components.py b/tests/test_site_components.py new file mode 100644 index 000000000..8fa5cf85d --- /dev/null +++ b/tests/test_site_components.py @@ -0,0 +1,1053 @@ +"""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. + +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. + +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. + +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 json +import re +import shutil +import subprocess +import unittest +from pathlib import Path +import hashlib +import struct +import zlib +import html +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from common import list_release_pack_platforms # noqa: E402 + + +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"", html, re.S) + + +def _headers(table: str) -> list[str]: + head = re.search(r"(.*?)", table, re.S) + cells = re.findall(r"]*>(.*?)", 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"]*>", 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'', html, re.S) + self.assertIsNotNone(nav) + labels = [re.sub(r"<[^>]+>", "", a).strip() for a in re.findall(r"]*>.*?", 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("', 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'