From 59ef4fbccfd0fcbdfb482ac0e2c188d8dc0e77f5 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:58:55 +0200 Subject: [PATCH] chore: test the round 4 search package --- tests/test_site_r4_search.py | 259 +++++++++++++++++++++++++++++++++++ wiki/testing-guide.md | 2 +- 2 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 tests/test_site_r4_search.py diff --git a/tests/test_site_r4_search.py b/tests/test_site_r4_search.py new file mode 100644 index 00000000..188b2aa4 --- /dev/null +++ b/tests/test_site_r4_search.py @@ -0,0 +1,259 @@ +"""Search (round 4, package 3). + +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"', 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
\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() diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 6e833970..5fd67254 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -57,7 +57,7 @@ Every module in `tests/`, by the area it guards. | Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_from`, `test_install_offline`, `test_install_selection`, `test_install_tui` | | Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` | | 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_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `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_r4_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_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_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_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `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_r4_p0`, `test_site_r4_search`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_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_workflow_paths` | ## Test architecture