diff --git a/docs_assets/extra.css b/docs_assets/extra.css index d302614c..dbc36106 100644 --- a/docs_assets/extra.css +++ b/docs_assets/extra.css @@ -109,6 +109,24 @@ font-weight: 700; } +/* A card's title is a heading so the search lands on it, set as body text */ +.md-typeset .rb-file-title { + margin: 0 0 0.3rem; + font-size: inherit; + font-weight: 400; + letter-spacing: normal; + scroll-margin-top: 4rem; +} + +.md-typeset .rb-file-title code { + font-weight: 700; +} + +/* Lines the search skips continue the card's list */ +.rb-search-skip > ul { + margin-top: 0.1rem; +} + .rb-file-entry ul, .rb-sys-file ul { margin: 0.3rem 0 0; @@ -265,8 +283,7 @@ .rb-permalink:focus { opacity: 1; } -.rb-file-entry:target, -.rb-sys-file:target { +.rb-file-entry:has(> .rb-file-title:target), +.rb-sys-file:has(> .rb-file-title:target) { background: var(--rb-surface); - scroll-margin-top: 4rem; } diff --git a/mkdocs.yml b/mkdocs.yml index 9c521968..87fdda72 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,6 +82,9 @@ markdown_extensions: - tables - toc: permalink: true + # File cards are level 4 headings: the search lands on them, and a + # page of a hundred cards keeps a readable table of contents. + toc_depth: 3 - pymdownx.details - pymdownx.highlight: anchor_linenums: true diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index 31999f42..03df6cd5 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -27,6 +27,7 @@ from common import ( ProfileSelectionError, _norm_system_id, directory_members, + expand_directory_entries, get_mame_clone_map, list_emulator_profiles, list_registered_platforms, @@ -341,6 +342,10 @@ class FileConsumers: # Where each platform puts the file in its BIOS directory. self.platform_destinations: dict[str, dict[str, set[str]]] = {} self.emulators: dict[str, set[str]] = {} + # The entry that reads each file, by its position in the profile's + # files: a link lands on that entry's card, not the page top. + self.emulator_entries: dict[str, dict[str, int]] = {} + self.emulator_files: dict[str, list] = {} # Where each platform's pack installs the file, its cores' files # included: the platform's own list names a fraction of them. self.installed: dict[str, dict[str, set[str]]] = {} @@ -401,14 +406,49 @@ class FileConsumers: """The collection path the database records for a file.""" return str(self._db.get("files", {}).get(sha1, {}).get("path", "")) + def directory_members(self, entry: dict) -> dict[str, str]: + """{destination: sha1} of the files a ``type: directory`` entry reads.""" + if entry.get("type") != "directory": + return {} + return { + member["path"]: member["sha1"] + for member in expand_directory_entries([entry], self._db) + if member is not entry + } + def add_emulators(self, profiles: dict[str, dict]) -> None: + """Each file a profile entry resolves to, a directory's members included. + + A directory entry was resolved by its own name and found nothing: + 11,976 files under alephone's Plugins/, cdogs' and thextech's trees + named no emulator on their card. + """ for name, profile in profiles.items(): if profile.get("type") == "alias": continue - for entry in profile.get("files", []): - sha1 = self.sha1_of(entry, entry.get("path") or "", owner=name) - if sha1: - self.emulators.setdefault(sha1, set()).add(name) + files = profile.get("files") or [] + self.emulator_files[name] = files + # A file a profile names keeps its own entry over the directory + # that also holds it. + order = sorted( + (i for i, entry in enumerate(files) if isinstance(entry, dict)), + key=lambda i: files[i].get("type") == "directory", + ) + for index in order: + entry = files[index] + members = self.directory_members(entry) + if members: + found = [ + self.sha1_of({**entry, "name": dest, "path": dest, "sha1": sha1}, + dest, owner=name) + for dest, sha1 in members.items() + ] + else: + found = [self.sha1_of(entry, entry.get("path") or "", owner=name)] + for sha1 in found: + if sha1: + self.emulators.setdefault(sha1, set()).add(name) + self.emulator_entries.setdefault(sha1, {}).setdefault(name, index) def entry_source(f: dict, index: dict) -> str | None: diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 315b67e8..0caabb92 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -145,13 +145,13 @@ def _system_folder( return None -def _build_system_page_map_from_data( +def _system_pages_from_data( manufacturers: dict, coverages: dict, db: dict, profiles: dict | None = None, -) -> None: - """Build system_id -> (manufacturer_slug, console_name) mapping. +) -> dict[str, tuple[str, str]]: + """The system_id -> (manufacturer_slug, console_name) table. Uses platform file paths to trace system_id -> bios directory -> manufacturer page. """ @@ -189,10 +189,11 @@ def _build_system_page_map_from_data( console_norm[key] = entry # Map system_id -> (manufacturer, console) via platform file entries + pages: dict[str, tuple[str, str]] = {} for cov in coverages.values(): config = cov["config"] for sys_id, system in config.get("systems", {}).items(): - if sys_id in _system_page_map: + if sys_id in pages: continue # Strategy 1: the folder holding the files the system declares, # found by content, and most votes win. A bare name stands in only @@ -200,18 +201,18 @@ def _build_system_page_map_from_data( place = _system_folder(system.get("files", []), db, name_consoles) if place is not None: mfr, console = place - _system_page_map[sys_id] = (mfr.lower().replace(" ", "-"), console) + pages[sys_id] = (mfr.lower().replace(" ", "-"), console) continue # Strategy 2: fuzzy match system_id against console directory names if sys_id in console_norm: - _system_page_map[sys_id] = console_norm[sys_id] + pages[sys_id] = console_norm[sys_id] else: # Try partial match: "nintendo-wii" matches "Wii" under "Nintendo" parts = sys_id.split("-") for i in range(len(parts)): suffix = "-".join(parts[i:]) if suffix in console_norm: - _system_page_map[sys_id] = console_norm[suffix] + pages[sys_id] = console_norm[suffix] break # Systems only emulator profiles name (sega-naomi, commodore-pet): placed @@ -220,7 +221,7 @@ def _build_system_page_map_from_data( for profile in (profiles or {}).values(): systems = [str(sid) for sid in profile.get("systems") or []] for sys_id in systems: - if sys_id in _system_page_map: + if sys_id in pages: continue files = [ entry for entry in profile.get("files") or [] @@ -230,9 +231,10 @@ def _build_system_page_map_from_data( place = _system_folder(files, db, name_consoles) if place is not None: mfr, console = place - _system_page_map[sys_id] = (mfr.lower().replace(" ", "-"), console) + pages[sys_id] = (mfr.lower().replace(" ", "-"), console) elif sys_id in console_norm: - _system_page_map[sys_id] = console_norm[sys_id] + pages[sys_id] = console_norm[sys_id] + return pages @@ -398,7 +400,7 @@ def generate_home( "", " Platform lists are checked against emulator source code, file " "by file where a profile exists ([how far that reaches](gaps.md)). " - "Documentation and metadata can drift from actual runtime behavior, " + "Documentation and metadata can differ from actual runtime behavior, " "so the source is the primary reference.", "", " 1. **Upstream emulator source**: what the original project " @@ -1315,17 +1317,20 @@ def collection_dat(db: dict, generated_at: str) -> str: import xml.etree.ElementTree as ET # noqa: PLC0415 games: dict[str, list[dict]] = {} - for entry in db.get("files", {}).values(): - parts = str(entry.get("path", "")).split("/") - if len(parts) < 3 or parts[0] != "bios": - continue - folder = parts[1:3] if len(parts) > 3 else parts[1:2] - # A backslash is how a Logiqx set name writes a subfolder: named - # "Sony - PlayStation", the set was looked for beside Sony/, and an - # audit of a copy of bios/ found 330 sets missing. - games.setdefault("\\".join(folder), []).append( - {**entry, "_rom": "\\".join(parts[1 + len(folder):])} - ) + files = db.get("files", {}) + # Every copy, not the one path the database keeps: an audit of bios/ + # read the 2,502 others as unknown files, and a fix moved them away + # from the engines that read them by path. + for sha1, paths in collection_paths(db).items(): + for path in sorted(paths): + parts = path.split("/") + folder = parts[1:3] if len(parts) > 3 else parts[1:2] + # A backslash is how a Logiqx set name writes a subfolder: named + # "Sony - PlayStation", the set was looked for beside Sony/, and an + # audit of a copy of bios/ found 330 sets missing. + games.setdefault("\\".join(folder), []).append( + {**files[sha1], "_rom": "\\".join(parts[1 + len(folder):])} + ) root = ET.Element("datafile") header = ET.SubElement(root, "header") version = generated_at[:10].replace("-", "") @@ -1736,16 +1741,19 @@ def _render_platform_systems( sys_link = _system_link(sys_id, "../") anchor = sys_id.replace(" ", "-") + # Every system is a heading with the same anchor in both layouts: + # emulator cards link here, and the site search splits a page at its + # headings. Folded into blocks under one heading, every hash pasted + # in the search led to the systems overview of a 100-block page. if use_collapsible: status_tag = "OK" if ok_count == total else _count(total - ok_count, "issue") - lines.append(f'') - lines.append(f'??? note "{sys_id} ({ok_count}/{total} - {status_tag})"') + lines.append(f"### {sys_link} {{ #{anchor} }}") + lines.append("") + lines.append(f'??? note "{_count(total, "file")} ({status_tag})"') lines.append("") pad = " " else: - # The same anchor in both layouts: emulator cards link here. - lines.append(f'') - lines.append(f"## {sys_link}") + lines.append(f"## {sys_link} {{ #{anchor} }}") lines.append("") pad = "" @@ -1816,7 +1824,7 @@ def _render_platform_systems( return lines -# Platforms with a corrected file published by export_native.py. +# Platforms with an updated file published by export_native.py. _EXPORTED: set[str] = set() @@ -2039,11 +2047,37 @@ def data_directory_revisions(data_dir: Path = _REPO_ROOT / "data") -> dict[str, return json.loads(path.read_text(encoding="utf-8")) +def page_data_directories(name: str, profiles: dict) -> list[dict]: + """The data directories an emulator page lists. + + Its own, then those of the aliases that point at it: an alias has no + page, and the 90 FreeIntv overlays its profile declares were linked to + emulators/freeintv_ts_overlay/, a page that does not exist. + """ + trees = [t for t in profiles[name].get("data_directories") or [] if isinstance(t, dict)] + refs = {str(t.get("ref", "")) for t in trees} + for alias in sorted(profiles): + profile = profiles[alias] + if profile.get("type") != "alias" or profile.get("alias_of") != name: + continue + for tree in profile.get("data_directories") or []: + ref = str(tree.get("ref", "")) + if isinstance(tree, dict) and ref not in refs: + refs.add(ref) + trees.append(tree) + return trees + + def _data_member_owners(profiles: dict) -> dict[str, str]: - """The emulator page that lists each data directory, the first by name.""" + """The emulator page that lists each data directory, the first by name. + + Only a profile that has a page can own one. + """ owners: dict[str, str] = {} for name in sorted(profiles): - for tree in profiles[name].get("data_directories") or []: + if profiles[name].get("type") in ("alias", "test"): + continue + for tree in page_data_directories(name, profiles): owners.setdefault(str(tree.get("ref", "")), name) return owners @@ -2081,6 +2115,28 @@ def file_location_items( return sorted(items, key=lambda item: item["sha1"]) +def collection_paths(db: dict) -> dict[str, set[str]]: + """Every path under bios/ that holds each file, by sha1. + + The database keeps one path per content. The others are the full tails + by_path_suffix records that name a file on disk: RPG Maker, engine + trees and the copies filed under several consoles keep 2,502 of them. + """ + files = db.get("files", {}) + bios_dir = Path(str(db.get("bios_dir") or "bios")) + paths: dict[str, set[str]] = {} + for sha1, entry in files.items(): + path = str(entry.get("path", "")) + if path.startswith("bios/") and path.count("/") >= 2: + paths.setdefault(sha1, set()).add(path) + for tail, sha1s in db.get("indexes", {}).get("by_path_suffix", {}).items(): + if tail.count("/") < 2 or len(sha1s) != 1 or sha1s[0] not in files: + continue + if (bios_dir / tail).is_file(): + paths.setdefault(sha1s[0], set()).add(f"bios/{tail}") + return paths + + def _group_by_manufacturer(db: dict) -> dict[str, dict[str, list]]: """Group files by manufacturer -> console -> files. @@ -2229,7 +2285,7 @@ def generate_provenance_page( 'orange (TOSEC) where ' "TOSEC flags the dump as bad, overdumped, hacked, cracked or fixed. " "Arcade ROM sets and engine data " - "sit outside what those catalogs index, so they are left out of the " + "sit outside what those catalogs index, so they are excluded from the " "ratio rather than counted as failures.", "", "## What the badge means", @@ -2425,6 +2481,36 @@ def _platform_links(sha1: str, consumers: FileConsumers) -> list[str]: return links +_card_anchors: dict[str, tuple[list, list[str]]] = {} + + +def _emulator_card_anchor(emulator: str, files: list, index: int) -> str: + """The id of a profile entry's card on its emulator page.""" + cached = _card_anchors.get(emulator) + if cached is None or cached[0] is not files: + cached = _card_anchors[emulator] = (files, emulator_file_anchors(files)) + return cached[1][index] if 0 <= index < len(cached[1]) else "" + + +def _emulator_links(sha1: str, consumers: FileConsumers) -> list[str]: + """Each emulator reading the file, linked to the entry that reads it. + + The platform links landed on their system section, and the emulator + links on the page top: duckstation's scph5501.bin entry, with the line + that accepts it, sat 260 lines down a page of 105 entries. + """ + entries = getattr(consumers, "emulator_entries", {}).get(sha1, {}) + files_of = getattr(consumers, "emulator_files", {}) + links = [] + for emulator in sorted(consumers.emulators.get(sha1, ())): + index = entries.get(emulator) + anchor = (_emulator_card_anchor(emulator, files_of[emulator], index) + if index is not None and emulator in files_of else "") + fragment = f"#{anchor}" if anchor else "" + links.append(f"[{emulator}](../emulators/{emulator}.md{fragment})") + return links + + def _installed_links(sha1: str, consumers: FileConsumers, declared: set[str]) -> list[str]: """Each pack installing the file that its platform's list does not name, with the place the pack gives it.""" @@ -2457,22 +2543,18 @@ def _system_file_card( anchor = "" else: anchored.add(anchor) - id_attr = f' id="{anchor}"' if anchor else "" - lines = [f'
', ""] - # The anchor is this page's, the second link is the file's own address, - # resolved by content wherever its card moves. + lines = ['
', ""] + # The heading's own link is this page's, the second link is the file's + # own address, resolved by content wherever its card moves. permalink = ( - f' #' f' permalink' ) if anchor else "" - lines.append(f'**`{name}`** {size}{permalink}') + lines.append(_card_title( + f'{_code(name)} {size}{permalink}', + anchor, + )) lines.append("") - if f.get("path"): - # The card named a file, its hashes and provenance, but not where to - # get it: a user missing scph5501.bin found the reference and no way - # to the bytes. - lines.append(f"- Collection path: {_code(f['path'])} ([download]({_fetch_url(f['path'])}))") # Every hash in full: the site search indexes text, not a title # attribute, so a pasted md5 or crc32 (what clrmamepro, RomVault or an # emulator log prints) found nothing behind a 12-character prefix. @@ -2480,6 +2562,16 @@ def _system_file_card( ("CRC32", f.get("crc32")), ("SHA256", f.get("sha256"))): if value and value != "unknown": lines.append(f'- {label}: {_code(value)}') + if emus: + lines.append(f"- Emulators: {', '.join(_emulator_links(sha1_full, consumers))}") + provenance = f.get("provenance", {}) + if provenance: + lines.extend(_provenance_lines(provenance, "../")) + if f.get("path"): + # The card named a file, its hashes and provenance, but not where to + # get it: a user missing scph5501.bin found the reference and no way + # to the bytes. + lines.append(f"- Collection path: {_code(f['path'])} ([download]({_fetch_url(f['path'])}))") if plats: # Each link lands on the platform's system section, and says where # that platform puts the file: from the card, retrodeck sent the @@ -2491,15 +2583,53 @@ def _system_file_card( # looking up scph5501.bin found every platform but RetroBat, whose # pack installs it for six cores. lines.append(f"- Also installed by: {', '.join(installed)}") - if emus: - lines.append(f"- Emulators: {', '.join(_emulator_link(e, '../') for e in emus)}") - provenance = f.get("provenance", {}) - if provenance: - lines.extend(_provenance_lines(provenance, "../")) + lines[2:] = _search_skipped_apart(lines[2:]) lines.extend(["", "
", ""]) return lines +# Card lines the site search leaves out. They list where a file goes and +# who cites it, links to other pages that index the same names, and they +# were 6.6 of the 24.7 MB the browser downloads and indexes before the first +# result. The name, the hashes, the entry's path and the notes stay +# searchable. +SEARCH_SKIPPED = ( + "- Platforms:", "- Also installed by:", "- Source:", "- Asked for by:", + "- Collection path:", "- Collection copy:", "- Collection copies:", + "- Installed by the platform's own package on:", + "- Not in the platform's package", "- Never opened on these platforms", +) + + +def _search_skipped_apart(lines: list[str]) -> list[str]: + """Card list lines, each run the search skips in a block of its own.""" + out: list[str] = [] + skipping = False + for line in lines: + skip = line.startswith(SEARCH_SKIPPED) or (skipping and line.startswith(" ")) + if skip and not skipping: + out.extend(["", '
', ""]) + elif skipping and not skip: + out.extend(["", "
", ""]) + skipping = skip + out.append(line) + if skipping: + out.extend(["", "
", ""]) + return out + + +def _card_title(text: str, anchor: str) -> str: + """A file card's heading, so the site search lands on the card. + + The search splits a page at its headings: a card titled in bold sent a + pasted md5 to the top of a page of a hundred cards. Level 4 stays out + of the table of contents (toc_depth 3), and a card without an anchor + keeps the id the toc gives it. + """ + attributes = f"#{anchor} .rb-file-title" if anchor else ".rb-file-title" + return f"#### {text} {{ {attributes} }}" + + def generate_system_page( manufacturer: str, consoles: dict[str, list], @@ -2792,14 +2922,13 @@ def _render_emulator_file( badges = _file_badges(f, in_repo) badge_str = " ".join(badges) - id_attr = f' id="{anchor}"' if anchor else "" - lines.append(f'
') + lines.append('
') + lines.append("") + lines.append(_card_title(f"{_code(fname)} {badge_str}", anchor)) lines.append("") - permalink = f' #' if anchor else "" - lines.append(f"**`{fname}`** {badge_str}{permalink}") if desc: - lines.append(f"
{_escape_tags(str(desc))}") - lines.append("") + lines.extend([_escape_tags(str(desc)), ""]) + body_start = len(lines) details = [] if fpath and fpath != fname: @@ -2871,7 +3000,7 @@ def _render_emulator_file( details.append("Installed by the platform's own package on: " + ", ".join(sorted(f["installed_on"]))) if f.get("not_bundled_on"): - details.append("Left out by the platform's package, so the pack brings it, on: " + details.append("Not in the platform's package, so the pack brings it, on: " + ", ".join(sorted(f["not_bundled_on"]))) if f.get("unread_on"): details.append("Never opened on these platforms, whose launcher names other files: " @@ -2886,6 +3015,8 @@ def _render_emulator_file( details.append( f"Source: {_source_ref_markdown(profile, source_ref)}" ) + if consumers and f.get("type") == "directory": + details.extend(_directory_copies(f, consumers)) if consumers: sha1 = consumers.sha1_of(f, f.get("path") or "", owner=owner) if sha1 and in_repo: @@ -2949,12 +3080,34 @@ def _render_emulator_file( lines.append(f" - `{c.get('name', '')}`{extra}") else: lines.append(f" - {c}") + lines[body_start:] = _search_skipped_apart(lines[body_start:]) lines.append("") lines.append("
") lines.append("") return lines +def _directory_copies(entry: dict, consumers: FileConsumers) -> list[str]: + """What the collection holds under a directory entry, and where. + + The card said "in repo" and linked none of the files: alephone's + Plugins/ holds 393 of them, each with its own card on a system page. + """ + members = consumers.directory_members(entry) + held = sorted({consumers.path_of(sha1) for sha1 in members.values()} - {""}) + if not held: + return [] + folder = os.path.commonpath(held) if len(held) > 1 else os.path.dirname(held[0]) + parts = folder.split("/") + where = _code(folder + "/") + if len(parts) >= 3 and parts[0] == "bios": + page = urllib.parse.quote(parts[1].lower().replace(" ", "-")) + where = f"[{where}](../systems/{page}.md#{_slugify_anchor(parts[2])})" + tree = f"{REPO_URL}/tree/main/{urllib.parse.quote(folder)}" + return [f"Collection copies: {_count(len(members), 'file')} under {where} " + f"([browse]({tree}))"] + + def _render_metadata_rows(profile: dict) -> list[str]: """Scalar profile fields, one table row each. @@ -3065,6 +3218,22 @@ def _plain_value(value: object) -> str: return _escape_tags(str(value)) +def _structured_field_text(profile: dict) -> Callable[[object, object], str]: + """Scalars of a structured block, citations linked at the profile's pin. + + The file entries and platform details linked their source_ref, and the + analysis and firmware blocks printed 186 of them as plain code. + """ + def text(key: object, value: object) -> str: + if key == "source_ref" and isinstance(value, str): + return _source_ref_markdown(profile, value) + if isinstance(value, str): + return link_prose_citations(profile, _field_text(key, value)) + return _field_text(key, value) + + return text + + def _render_structured_blocks(profile: dict) -> list[str]: """Every remaining structured field, as a collapsible section.""" lines: list[str] = [] @@ -3100,7 +3269,8 @@ def _render_structured_blocks(profile: dict) -> list[str]: lines.append(f'???+ abstract "{label}"') lines.append("") start = len(lines) - _render_yaml_value(lines, val, indent=4) + _render_yaml_value(lines, val, indent=4, + field_text=_structured_field_text(profile)) lines[start:] = [_escape_tags(line) for line in lines[start:]] lines.append("") @@ -3225,7 +3395,9 @@ def generate_emulator_page( consumers: FileConsumers | None = None, data_names: set[str] | None = None, data_registry: dict | None = None, + data_dirs: list[dict] | None = None, ) -> str: + """One emulator's page. ``data_dirs`` defaults to the profile's own.""" if profile.get("type") == "alias": parent = profile.get("alias_of", profile.get("bios_identical_to", "unknown")) return ( @@ -3263,7 +3435,8 @@ def generate_emulator_page( str(notes_raw).strip() if notes_raw and not isinstance(notes_raw, dict) else "" ) exclusion = profile.get("exclusion_note", "") - data_dirs = profile.get("data_directories", []) + if data_dirs is None: + data_dirs = profile.get("data_directories", []) lines = [ f"# {emu_name}", @@ -3815,8 +3988,9 @@ def generate_gap_analysis( "only authority for its files.", "", "The same comparison drives `scripts/exporter/`: each platform's " - "corrected list can be regenerated in its native format " - "(System.dat, es_bios.xml, batocera-systems.json, ...).", + "own file can be regenerated in its native format with these " + "differences applied (System.dat, es_bios.xml, " + "batocera-systems.json, ...).", "", ]) @@ -3837,7 +4011,9 @@ def generate_gap_analysis( gap_report = build_emulator_gap_report(profiles, coverages, db, data_names) report_all = gap_report - src_totals: dict[str, int] = {"bios": 0, "data": 0, "large_file": 0, "missing": 0} + src_totals: dict[str, int] = { + "bios": 0, "data": 0, "large_file": 0, "bundled": 0, "missing": 0, + } total_undeclared = 0 emulator_gaps = [] @@ -3845,8 +4021,8 @@ def generate_gap_analysis( if data["gaps"] == 0: continue total_undeclared += data["gaps"] - for key in src_totals: - src_totals[key] += data.get(f"gap_{key}", 0) + for key, count in _gap_sources(data).items(): + src_totals[key] += count emulator_gaps.append((emu_name, data)) @@ -3864,9 +4040,10 @@ def generate_gap_analysis( "## Core complement", "", f"Files loaded by emulators but not declared by any platform. " - f"{total_undeclared:,} files across {len(emulator_gaps)} emulators, " + f"{total_undeclared:,} files across {_count(len(emulator_gaps), 'emulator')}, " f"{total_available:,} available ({pct_available}), " - f"{src_totals['missing']} to source.", + f"{src_totals['bundled']:,} installed with their emulator, " + f"{src_totals['missing']:,} to source.", "", "This counts every profiled emulator, including those no platform " "ships yet, so it reaches past what the pack tables above measure: " @@ -3882,17 +4059,20 @@ def generate_gap_analysis( f"| data/ | {src_totals['data']} | Data directories (buildbot, GitHub) |", f"| release | {src_totals['large_file']} " "| GitHub release assets (large files) |", + f"| bundled | {src_totals['bundled']} " + "| Installed with the emulator's own package, not collected |", f"| missing | {src_totals['missing']} | Not available, needs sourcing |", "", "### Per emulator", "", - "| Emulator | Undeclared | bios | data | release | Missing |", - "|----------|----------:|-----:|-----:|--------:|--------:|", + "| Emulator | Undeclared | bios | data | release | Bundled | Missing |", + "|----------|----------:|-----:|-----:|--------:|--------:|--------:|", ]) for emu_name, data in sorted(emulator_gaps, key=lambda x: -x[1]["gaps"]): display = data["emulator"] - m = data.get("gap_missing", 0) + sources = _gap_sources(data) + m = sources["missing"] missing_str = ( f'{m}' if m > 0 @@ -3901,9 +4081,10 @@ def generate_gap_analysis( lines.append( f"| [{display}](emulators/{emu_name}.md) " f"| {data['gaps']} " - f"| {data.get('gap_bios', 0)} " - f"| {data.get('gap_data', 0)} " - f"| {data.get('gap_large_file', 0)} " + f"| {sources['bios']} " + f"| {sources['data']} " + f"| {sources['large_file']} " + f"| {sources['bundled']} " f"| {missing_str} |" ) lines.append("") @@ -4047,6 +4228,41 @@ def generate_gap_analysis( +def _core_scope_note(config: dict, profiles: dict) -> str: + """" (all libretro)" when the platform resolves every libretro core. + + Read from what resolve_platform_cores returns, not from the shape of + ``cores:``: RetroPie and RetroDECK name ``retroarch`` in a list, which + pulls in every libretro core as ``all_libretro`` does, and the label + showed for RetroArch and Lakka only. + """ + every_libretro = resolve_platform_cores({"cores": "all_libretro"}, profiles) + resolved = resolve_platform_cores(config, profiles) + if not every_libretro or not every_libretro <= resolved: + return "" + others = len(resolved - every_libretro) + if not others: + return " (all libretro)" + return f" (all libretro, plus {_count(others, 'other core')})" + + +def _gap_sources(data: dict) -> dict[str, int]: + """Where each undeclared file of one emulator comes from. + + A file the emulator's own package installs is not one to find. The + report's gap_missing counts it, and the gaps page announced 1,356 files + to source where 609 are, with a red badge on emulators that have none. + """ + counts = {"bios": 0, "data": 0, "large_file": 0, "bundled": 0, "missing": 0} + for gap in data.get("gap_details") or []: + source = gap.get("source", "") + if source == "missing" and gap.get("bundled"): + source = "bundled" + if source in counts: + counts[source] += 1 + return counts + + def _render_xref_by_platform( coverages: dict, unique: dict ) -> list[str]: @@ -4056,7 +4272,6 @@ def _render_xref_by_platform( cov = coverages[pname] display = cov["platform"] config = cov["config"] - platform_cores = config.get("cores", []) lines.append(f'??? abstract "{display}"') lines.append("") @@ -4075,10 +4290,7 @@ def _render_xref_by_platform( if key in unique } - if platform_cores == "all_libretro": - lines.append(f" **{len(matched)} cores** (all libretro)") - else: - lines.append(f" **{len(matched)} cores**") + lines.append(f" **{len(matched)} cores**{_core_scope_note(config, unique)}") lines.append("") lines.append(" | Core | Classification | Systems | Files | Upstream |") @@ -4865,7 +5077,9 @@ def _generate(args: argparse.Namespace) -> None: # Build system_id -> manufacturer page map (needed by all generators) print("Building system cross-reference map...") manufacturers = _group_by_manufacturer(db) - _build_system_page_map_from_data(manufacturers, coverages, db, profiles) + set_system_page_map( + _system_pages_from_data(manufacturers, coverages, db, profiles) + ) print(f" {len(_system_page_map)} system IDs mapped to pages") export_reports = load_export_reports() @@ -4938,7 +5152,8 @@ def _generate(args: argparse.Namespace) -> None: ) + "\n", ) page = generate_emulator_page( - name, profile, db, consumers, suppl_names, data_registry + name, profile, db, consumers, suppl_names, data_registry, + data_dirs=page_data_directories(name, profiles), ) PAGE_DATA[f"emulators/{name}.md"] = emulator_structured_data(profile) write_if_changed(str(docs / "emulators" / f"{name}.md"), page) @@ -5088,6 +5303,9 @@ markdown_extensions: - tables - toc: permalink: true + # File cards are level 4 headings: the search lands on them, and a + # page of a hundred cards keeps a readable table of contents. + toc_depth: 3 - pymdownx.details - pymdownx.highlight: anchor_linenums: true @@ -5157,10 +5375,12 @@ from sitesources import ( # noqa: E402,F401 from siterender import ( # noqa: E402,F401 _by_mode, _count, + _field_text, _render_yaml_value, _slugify_anchor, _system_page_map, _system_link, + set_system_page_map, _platform_link, _emulator_link, _fmt_size, diff --git a/scripts/siterender.py b/scripts/siterender.py index 33f3afc6..3f769501 100644 --- a/scripts/siterender.py +++ b/scripts/siterender.py @@ -2,12 +2,18 @@ from __future__ import annotations -import re - -from pathlib import Path -from concurrent.futures import ThreadPoolExecutor import json +import re +import sys +import urllib.error +import urllib.parse import urllib.request +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from artifacts import write_text_atomic + WIKI_SRC_DIR = "wiki" # manually maintained wiki sources SYSTEM_ICON_BASE = "https://raw.githubusercontent.com/libretro/retroarch-assets/master/xmb/systematic/png" ICON_CACHE_PATH = Path(".cache") / "system_icons.json" @@ -67,6 +73,27 @@ def _icon_name(manufacturer: str, console_name: str) -> str: def _icon_url(icon_name: str) -> str: return f"{SYSTEM_ICON_BASE}/{urllib.parse.quote(icon_name)}.png" +def _read_icon_cache() -> dict[str, bool]: + """The recorded icon checks, empty when none were recorded. + + An unreadable file is reported, not taken for an empty cache in silence. + """ + try: + text = ICON_CACHE_PATH.read_text(encoding="utf-8") + except FileNotFoundError: + return {} + except OSError as exc: + print(f" WARNING: cannot read {ICON_CACHE_PATH}: {exc}", file=sys.stderr) + return {} + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + print(f" WARNING: {ICON_CACHE_PATH} is not valid JSON ({exc}), " + "icons are checked again", file=sys.stderr) + return {} + return {str(k): bool(v) for k, v in data.items()} if isinstance(data, dict) else {} + + def prime_system_icons(names: set[str]) -> None: """Record which system icons upstream actually serves. @@ -74,14 +101,7 @@ def prime_system_icons(names: set[str]) -> None: cannot be checked stay unavailable: the site never links an image it has not seen answer. """ - cached: dict[str, bool] = {} - if ICON_CACHE_PATH.exists(): - try: - with open(ICON_CACHE_PATH) as f: - cached = json.load(f) - except (json.JSONDecodeError, OSError): - cached = {} - + cached = _read_icon_cache() unknown = sorted(n for n in names if n not in cached) if unknown: def check(name: str) -> tuple[str, bool | None]: @@ -100,13 +120,22 @@ def prime_system_icons(names: set[str]) -> None: return name, None print(f"Checking {len(unknown)} system icons...") + learned: dict[str, bool] = {} with ThreadPoolExecutor(max_workers=8) as pool: for name, ok in pool.map(check, unknown): if ok is not None: - cached[name] = ok - ICON_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(ICON_CACHE_PATH, "w") as f: - json.dump(cached, f, indent=2, sort_keys=True) + learned[name] = ok + cached.update(learned) + if learned: + # Another run may have recorded names meanwhile: merge with what + # the file holds now, and replace it whole so a reader never sees + # a half-written cache. + merged = {**_read_icon_cache(), **learned} + ICON_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + write_text_atomic( + str(ICON_CACHE_PATH), + json.dumps(merged, indent=2, sort_keys=True) + "\n", + ) _icon_available.update(cached) missing = sum(1 for n in names if not cached.get(n)) @@ -133,8 +162,27 @@ def _slugify_anchor(text: str) -> str: slug = slug.strip("-") return slug -_norm_page_cache: dict[str, tuple[str, str] | None] = {} -_norm_page_size = -1 +_norm_page_map: dict[str, tuple[str, str] | None] = {} + + +def set_system_page_map(mapping: dict[str, tuple[str, str]]) -> None: + """Install the system id -> page table every link on the site reads. + + The table replaces the previous one, and the index of other spellings is + derived from it here. Kept anywhere else, it outlived a table of the same + size and linked to the pages of the previous one. + """ + from common import _norm_system_id + + _system_page_map.clear() + _system_page_map.update(mapping) + _norm_page_map.clear() + for known, page in _system_page_map.items(): + key = _norm_system_id(known) + if _norm_page_map.get(key, page) != page: + _norm_page_map[key] = None + else: + _norm_page_map[key] = page def _page_by_norm(sys_id: str) -> tuple[str, str] | None: @@ -144,19 +192,9 @@ def _page_by_norm(sys_id: str) -> tuple[str, str] | None: keyed by the platforms' spellings: 2,431 of 4,195 cards had no link. A spelling two pages share leads nowhere rather than to either. """ - global _norm_page_size from common import _norm_system_id - if _norm_page_size != len(_system_page_map): - _norm_page_cache.clear() - for known, page in _system_page_map.items(): - key = _norm_system_id(known) - if _norm_page_cache.get(key, page) != page: - _norm_page_cache[key] = None - else: - _norm_page_cache[key] = page - _norm_page_size = len(_system_page_map) - return _norm_page_cache.get(_norm_system_id(sys_id)) + return _norm_page_map.get(_norm_system_id(sys_id)) def _system_link(sys_id: str, prefix: str = "") -> str: @@ -175,9 +213,10 @@ def _field_text(key: object, value: object) -> str: elif value is None: text = "none" else: - text = str(value) - if len(text) > 200: - text = text[:200] + "..." + # One line: a folded note's second line started at column 0 and + # left the admonition it was written in. Never cut: 34 notes lost + # their end, and a cut citation links to another line. + text = " ".join(str(value).split()) return f"`{text}`" if key == "source_ref" else text @@ -186,12 +225,19 @@ def _field_label(key: object) -> str: return str(key).replace("_", " ") -def _render_yaml_value(lines: list[str], val: object, indent: int = 4) -> None: +FieldText = Callable[[object, object], str] + + +def _render_yaml_value( + lines: list[str], val: object, indent: int = 4, + field_text: FieldText = _field_text, +) -> None: """Render any YAML value as a nested markdown list. Every level is a list item and its children a sublist four spaces in. A key written as a paragraph with its children indented below it read as an indented code block: the clownmdemu page showed the raw asterisks. + ``field_text`` renders each scalar, so a page can link a source_ref. """ pad = " " * indent if isinstance(val, dict): @@ -199,27 +245,32 @@ def _render_yaml_value(lines: list[str], val: object, indent: int = 4) -> None: if isinstance(v, (dict, list)) and v: lines.append(f"{pad}- **{_field_label(k)}:**") lines.append("") - _render_yaml_value(lines, v, indent + 4) + _render_yaml_value(lines, v, indent + 4, field_text) else: - lines.append(f"{pad}- **{_field_label(k)}:** {_field_text(k, v)}") + lines.append(f"{pad}- **{_field_label(k)}:** {field_text(k, v)}") lines.append("") elif isinstance(val, list): for item in val: if isinstance(item, dict): - parts = [ - f"{_field_label(ik)}: {_field_text(ik, iv)}" - for ik, iv in item.items() - if not isinstance(iv, (dict, list)) - ] - lines.append(f"{pad}- {', '.join(parts)}") + # Scalars on the item's line, lists and maps as its children: + # cemu's firmware titles lost the files each title carries. + scalars = {ik: iv for ik, iv in item.items() + if not (isinstance(iv, (dict, list)) and iv)} + nested = {ik: iv for ik, iv in item.items() if ik not in scalars} + parts = [f"{_field_label(ik)}: {field_text(ik, iv)}" + for ik, iv in scalars.items()] + lines.append(f"{pad}- {', '.join(parts)}".rstrip()) + if nested: + lines.append("") + _render_yaml_value(lines, nested, indent + 4, field_text) else: - lines.append(f"{pad}- {_field_text(None, item)}") + lines.append(f"{pad}- {field_text(None, item)}") lines.append("") elif isinstance(val, str) and "\n" in val: for line in val.split("\n"): lines.append(f"{pad}{line}") else: - lines.append(f"{pad}{_field_text(None, val)}") + lines.append(f"{pad}{field_text(None, val)}") def _platform_link(name: str, display: str, prefix: str = "") -> str: diff --git a/scripts/sitesources.py b/scripts/sitesources.py index 7d9e0912..f1a62110 100644 --- a/scripts/sitesources.py +++ b/scripts/sitesources.py @@ -6,6 +6,7 @@ was written against, so a permalink is pinned, never guessed.""" from __future__ import annotations import re +import urllib.parse import urllib.request import upstream @@ -48,6 +49,47 @@ def _repo_pins(profile: dict, field: str, label: str) -> list[tuple[str, str, st if raw.get(key) ] +# Forges the pages link into but upstream.py does not read from. Each is +# reached by browser only, so the Repo carries no API or raw base. +_BROWSE_ONLY = { + "voidpoint.io": "gitlab", + "bitbucket.org": "bitbucket", + "sourceforge.net": "sourceforge", + "archive.softwareheritage.org": "swh", +} + + +def _browse_repo(url: str) -> upstream.Repo | None: + """The repository a profile URL names, on any forge the pages can link. + + Profiles pinned on SourceForge, voidpoint.io, Bitbucket or Software + Heritage showed no revision and no linked citation: parse_repo knows + only the forges profile_sync fetches from. + """ + repo = upstream.parse_repo(url) + if repo is not None or not url: + return repo + parts = urllib.parse.urlsplit(url.strip()) + family = _BROWSE_ONLY.get(parts.netloc) + segments = [seg for seg in parts.path.split("/") if seg] + if family == "gitlab": + if "-" in segments: + segments = segments[: segments.index("-")] + if len(segments) < 2: + return None + owner, name = "/".join(segments[:-1]), segments[-1] + elif family == "bitbucket" and len(segments) >= 2: + owner, name = segments[0], segments[1] + elif family == "sourceforge" and len(segments) >= 3 and segments[0] == "p": + owner, name = segments[1], segments[2] + elif family == "swh": + # A revision URL names no project: the pin alone addresses the tree. + owner, name = "softwareheritage", "revision" + else: + return None + return upstream.Repo(parts.netloc, family, "", "", owner, name.removesuffix(".git")) + + def _forge_sources(profile: dict, label: str = "") -> list[tuple[upstream.Repo, str]]: """Supported source repositories and immutable revisions for a profile.""" candidates: list[tuple[upstream.Repo, str]] = [] @@ -56,7 +98,7 @@ def _forge_sources(profile: dict, label: str = "") -> list[tuple[upstream.Repo, for field in ("source", "upstream"): for value, pin, fallback in _repo_pins(profile, field, label): - repo = upstream.parse_repo(value) + repo = _browse_repo(value) if repo is None: continue repo_key = (repo.host, repo.slug) @@ -77,6 +119,19 @@ def _source_permalink(repo: upstream.Repo, pin: str, path: str, """Forge-specific browser URL for one file at one immutable revision.""" quoted_path = urllib.parse.quote(path, safe="/@:+-._~") base = f"https://{repo.host}/{repo.owner}/{repo.name}" + if repo.family == "bitbucket": + url = f"{base}/src/{pin}/{quoted_path}" + if start is not None: + url += f"#lines-{start}" + (f":{end}" if end is not None and end != start else "") + return url + if repo.family == "sourceforge": + # The Allura code browser takes no line range: the file at the pin. + return f"https://{repo.host}/p/{repo.owner}/{repo.name}/ci/{pin}/tree/{quoted_path}" + if repo.family == "swh": + url = f"https://{repo.host}/browse/revision/{pin}/?path={quoted_path}" + if start is not None: + url += f"#L{start}" + (f"-L{end}" if end is not None and end != start else "") + return url if repo.family == "github": url = f"{base}/blob/{pin}/{quoted_path}" elif repo.family == "gitlab": @@ -99,6 +154,12 @@ def _commit_url(repo: upstream.Repo, pin: str) -> str: base = f"https://{repo.host}/{repo.owner}/{repo.name}" if repo.family == "gitlab": return f"{base}/-/commit/{pin}" + if repo.family == "bitbucket": + return f"{base}/commits/{pin}" + if repo.family == "sourceforge": + return f"https://{repo.host}/p/{repo.owner}/{repo.name}/ci/{pin}/" + if repo.family == "swh": + return f"https://{repo.host}/browse/revision/{pin}/" return f"{base}/commit/{pin}" @@ -109,7 +170,8 @@ def revision_markdown(profile: dict) -> str: refers to sat in the YAML alone, for 178 profiles. """ links = [ - f"[{repo.slug}@{pin[:12]}]({_commit_url(repo, pin)})" + f"[{'Software Heritage' if repo.family == 'swh' else repo.slug}@{pin[:12]}]" + f"({_commit_url(repo, pin)})" for repo, pin in _forge_sources(profile) ] branch = profile.get("source_branch") @@ -143,7 +205,7 @@ def role_sources(profile: dict, label: str = "") -> dict[str, tuple[upstream.Rep found: dict[str, tuple[upstream.Repo, str]] = {} for field in ("source", "upstream"): for value, pin, _fallback in _repo_pins(profile, field, label): - repo = upstream.parse_repo(value) + repo = _browse_repo(value) if repo is not None and pin and field not in found: found[field] = (repo, pin) return found diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 2b0501c0..0c392b3a 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -4175,8 +4175,9 @@ class AFileCardSaysAllItKnows(unittest.TestCase): text = "\n".join(_render_emulator_file( entry, {"files": [entry]}, None, [entry], lambda f: True, "file-neogeo-zip", )) - self.assertIn('id="file-neogeo-zip"', text) - self.assertIn('href="#file-neogeo-zip"', text) + # The card is a heading with this id, linked by the heading's own + # permalink. + self.assertIn("{ #file-neogeo-zip .rb-file-title }", text) self.assertIn("Standalone path: `Firmware/neogeo.zip`", text) self.assertIn("`m11.bin`", text) self.assertIn("CRC32 `0000000b`", text) @@ -4894,7 +4895,7 @@ class VersionAnchorsFollowContent(unittest.TestCase): "raze", {"emulator": "Raze", "type": "standalone", "files": files}, {"files": {}, "indexes": {}}, ) - return re.findall(r'id="(file-[^"]+)"', page) + return re.findall(r"\{ #(file-[^ ]+) \.rb-file-title \}", page) def test_inserting_a_version_keeps_the_others(self): a = {"name": "DUKE3D.GRP", "path": "DUKE3D.GRP", "crc32": "bbc9ce44"} diff --git a/tests/test_site_cores.py b/tests/test_site_cores.py index 0be1f5fe..b60fb636 100644 --- a/tests/test_site_cores.py +++ b/tests/test_site_cores.py @@ -44,5 +44,30 @@ class SiteCoreCounts(unittest.TestCase): self.assertEqual(shown[cov["platform"]], expected) +class AllLibretroLabel(unittest.TestCase): + """RetroPie and RetroDECK pull in every libretro core by naming retroarch + in a list, and the label read "(all libretro)" for RetroArch and Lakka + only, the two whose cores: is the string.""" + + PROFILES = { + "a": {"type": "libretro"}, + "b": {"type": "standalone + libretro"}, + "sa": {"type": "standalone"}, + } + + def test_a_list_naming_retroarch_is_labelled(self): + note = generate_site._core_scope_note( + {"cores": ["retroarch", "sa"]}, self.PROFILES) + self.assertEqual(note, " (all libretro, plus 1 other core)") + + def test_the_string_form_is_labelled(self): + note = generate_site._core_scope_note({"cores": "all_libretro"}, self.PROFILES) + self.assertEqual(note, " (all libretro)") + + def test_a_selection_is_not(self): + self.assertEqual( + generate_site._core_scope_note({"cores": ["a", "sa"]}, self.PROFILES), "") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_site_render.py b/tests/test_site_render.py new file mode 100644 index 00000000..8f7dd3b1 --- /dev/null +++ b/tests/test_site_render.py @@ -0,0 +1,415 @@ +"""Renderings shared by the site pages, and the icon cache they read. + +No network: icon checks are answered by a stub. +""" + +from __future__ import annotations + +import json +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 siterender # noqa: E402 + + +class _Served: + status = 200 + + def __enter__(self) -> "_Served": + return self + + def __exit__(self, *exc: object) -> None: + return None + + +class IconCacheIsReplacedWhole(unittest.TestCase): + """The cache was truncated then dumped in place: a concurrent reader saw + half a file, took it for empty, and an offline run then wrote ``{}`` + over every recorded icon.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.path = Path(tmp.name) / "system_icons.json" + patcher = mock.patch.object(siterender, "ICON_CACHE_PATH", self.path) + patcher.start() + self.addCleanup(patcher.stop) + saved = dict(siterender._icon_available) + self.addCleanup(lambda: (siterender._icon_available.clear(), + siterender._icon_available.update(saved))) + + def test_an_offline_run_leaves_an_unreadable_cache_untouched(self): + self.path.write_text('{"Sega - Mega Drive": tr') + offline = mock.patch.object( + siterender.urllib.request, "urlopen", + side_effect=urllib.error.URLError("offline")) + with offline, mock.patch("sys.stderr"), mock.patch("builtins.print"): + siterender.prime_system_icons({"Sega - Mega Drive"}) + self.assertEqual(self.path.read_text(), '{"Sega - Mega Drive": tr') + + def test_names_another_run_recorded_meanwhile_are_kept(self): + self.path.write_text(json.dumps({"A - One": True})) + + def answer(*_args: object, **_kwargs: object) -> _Served: + # A second build records its own check while this one runs. + self.path.write_text(json.dumps({"A - One": True, "C - Three": False})) + return _Served() + + with mock.patch.object(siterender.urllib.request, "urlopen", side_effect=answer), \ + mock.patch("builtins.print"): + siterender.prime_system_icons({"A - One", "B - Two"}) + self.assertEqual(json.loads(self.path.read_text()), + {"A - One": True, "B - Two": True, "C - Three": False}) + self.assertEqual(sorted(p.name for p in self.path.parent.iterdir()), + ["system_icons.json"]) + + def test_the_write_goes_through_a_scratch_file(self): + with mock.patch.object(siterender.urllib.request, "urlopen", + return_value=_Served()), \ + mock.patch.object(siterender, "write_text_atomic", + wraps=siterender.write_text_atomic) as atomic, \ + mock.patch("builtins.print"): + siterender.prime_system_icons({"B - Two"}) + atomic.assert_called_once() + + +class EveryPageAddressesPlatformsAsPeers(unittest.TestCase): + """The exports page was held to neutral wording, and the gaps page next + to it still called each platform's file a "corrected list". Reads the + text every site generator writes, docstrings and bare identifiers aside.""" + + WORDS = ("their file", "theirs", "of ours", "corrected", "left out", "drift") + SOURCES = ("generate_site.py", "siterender.py", "sitesources.py", "siteexports.py") + + def test_no_page_text_judges_another_project(self): + import ast + import re + + found = [] + for name in self.SOURCES: + tree = ast.parse((REPO_ROOT / "scripts" / name).read_text(encoding="utf-8")) + docstrings = { + id(node.body[0].value) for node in ast.walk(tree) + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, + ast.AsyncFunctionDef)) + and ast.get_docstring(node, clean=False) is not None + } + for node in ast.walk(tree): + if (not isinstance(node, ast.Constant) or not isinstance(node.value, str) + or id(node) in docstrings or re.fullmatch(r"\w+", node.value)): + continue + text = node.value.lower() + found += [f"{name}:{node.lineno} {word!r}" + for word in self.WORDS if word in text] + self.assertEqual(found, []) + + +class BundledFilesAreNotToSource(unittest.TestCase): + """The gaps page summed the report's gap_missing, bundled files included: + 1,356 files to source where 609 were, and a red badge of 13 on ZQuest + Classic, whose 13 missing files all come with its own package.""" + + def test_the_summary_and_the_row_count_only_what_must_be_found(self): + import generate_site + + gap = {"required": True, "source_ref": "x", "in_repo": False} + report = {"zq": { + "emulator": "ZQuest Classic", "gaps": 4, "gap_missing": 3, "gap_bios": 1, + "unsourceable": [], + "gap_details": [ + {**gap, "name": "a.dll", "source": "missing", "bundled": True}, + {**gap, "name": "b.dll", "source": "missing", "bundled": True}, + {**gap, "name": "c.bin", "source": "missing"}, + {**gap, "name": "d.bin", "source": "bios", "in_repo": True}, + ], + }} + page = generate_site.generate_gap_analysis( + {"zq": {"emulator": "ZQuest Classic", "files": []}}, {}, + {"files": {}, "indexes": {}}, gap_report=report) + self.assertIn("2 installed with their emulator, 1 to source.", page) + self.assertIn("| missing | 1 | Not available, needs sourcing |", page) + self.assertIn("| [ZQuest Classic](emulators/zq.md) | 4 | 1 | 0 | 0 | 2 | " + '1 |', page) + + +class StructuredBlocks(unittest.TestCase): + PIN = "1e0de94dc7e669c0925a22c0fccf6cdc837af0a0" + + def profile(self, block: str, value: object) -> dict: + return {"source": "https://github.com/libretro/blastem", + "source_commit": self.PIN, block: value} + + def render(self, profile: dict) -> str: + import generate_site + + return "\n".join(generate_site._render_structured_blocks(profile)) + + def test_a_source_ref_in_a_block_is_a_pinned_link(self): + """186 citations in analysis and firmware blocks were plain code.""" + page = self.render(self.profile("analysis", {"tmss": { + "source_ref": "libblastem.c:355-359"}})) + self.assertIn( + f"[`libblastem.c:355-359`](https://github.com/libretro/blastem/blob/" + f"{self.PIN}/libblastem.c#L355-L359)", page) + + def test_a_list_inside_a_list_item_is_shown(self): + """cemu's firmware titles lost the files each title carries.""" + page = self.render(self.profile("firmware_titles", [ + {"id": "0005001b-10042400", "name": "Shared fonts", + "content": ["CafeCn.ttf", "CafeKr.ttf"]}])) + self.assertIn(" - id: 0005001b-10042400, name: Shared fonts\n", page) + self.assertIn(" - **content:**", page) + self.assertIn(" - CafeKr.ttf", page) + + def test_a_folded_note_stays_inside_its_block(self): + page = self.render(self.profile("analysis", {"tmss": { + "note": "The core points\nsystem/tmss_path at the system directory."}})) + self.assertIn("- **note:** The core points system/tmss_path at the", page) + body = page.split("\n")[1:] + self.assertEqual([line for line in body if line and not line.startswith(" ")], []) + + def test_a_long_note_is_not_cut(self): + note = "word " * 80 + "end." + page = self.render(self.profile("analysis", {"x": {"note": note}})) + self.assertIn("end.", page) + + +class FileCardsLinkTheReadingEntry(unittest.TestCase): + def test_an_emulator_link_lands_on_its_entry(self): + """The platform links landed on their section, the emulator links on + the top of a page of a hundred entries.""" + import generate_site + from cross_reference import FileConsumers + + consumers = FileConsumers({"files": {}}) + files = [{"name": "scph1001.bin"}, {"name": "scph5501.bin"}, + {"name": "tmss.md", "sha1": "d0fd0c16" + "0" * 32}, + {"name": "tmss.md", "sha1": "aa" * 20}] + consumers.emulators = {"s": {"duckstation", "other"}, "t": {"blastem"}} + consumers.emulator_entries = {"s": {"duckstation": 1}, "t": {"blastem": 2}} + consumers.emulator_files = {"duckstation": files, "blastem": files} + self.assertEqual(generate_site._emulator_links("s", consumers), [ + "[duckstation](../emulators/duckstation.md#file-scph5501-bin)", + "[other](../emulators/other.md)", + ]) + self.assertEqual(generate_site._emulator_links("t", consumers), + ["[blastem](../emulators/blastem.md#file-tmss-md-d0fd0c16)"]) + + +class DirectoryMembersNameTheirEmulator(unittest.TestCase): + """The site resolved a ``type: directory`` entry by its own name: 11,976 + files under alephone's Plugins/, cdogs' and thextech's trees had no + emulator on their card, and the directory card linked none of them.""" + + SHA1 = "c63078a6c9ccaca7339a726a5d39410983933070" + + @classmethod + def setUpClass(cls) -> None: + from common import build_zip_contents_index, load_database, load_emulator_profiles + from cross_reference import FileConsumers + + cls.db = load_database(str(REPO_ROOT / "database.json")) + if cls.SHA1 not in cls.db.get("files", {}): + raise unittest.SkipTest("the collection does not hold the alephone plugin") + cls.profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["alephone"] + cls.consumers = FileConsumers(cls.db, build_zip_contents_index(cls.db)) + cls.consumers.add_emulators({"alephone": cls.profile}) + + def test_a_member_names_the_emulator_and_its_directory_entry(self): + import generate_site + + files = self.profile["files"] + index = self.consumers.emulator_entries[self.SHA1]["alephone"] + self.assertEqual(files[index].get("type"), "directory") + self.assertEqual(generate_site._emulator_links(self.SHA1, self.consumers), + ["[alephone](../emulators/alephone.md#file-marathon-plugins)"]) + + def test_the_directory_card_points_at_its_copies(self): + import generate_site + + entry = next(f for f in self.profile["files"] + if f.get("type") == "directory" and f.get("path") == "Marathon/Plugins/") + line = generate_site._directory_copies(entry, self.consumers)[0] + self.assertRegex(line, r"^Collection copies: \d+ files under " + r"\[`bios/Bungie/Marathon/Plugins/`\]\(\.\./systems/bungie\.md#") + + +class AnAliasTreeIsListedOnItsParentsPage(unittest.TestCase): + """The 90 FreeIntv overlays were linked to the page of the alias that + declares them, which is never generated: every permalink was a 404.""" + + PROFILES = { + "freeintv": {"type": "libretro"}, + "freeintv_ts_overlay": { + "type": "alias", "alias_of": "freeintv", + "data_directories": [{"ref": "freeintv-overlays", + "destination": "freeintv_overlays"}], + }, + } + + def test_the_permalink_goes_to_a_page_that_exists(self): + import generate_site + + members = {"freeintv-overlays": {"a.png": {"sha1": "02" * 20}}} + items = generate_site.file_location_items({"files": {}}, self.PROFILES, members) + self.assertEqual(items, [{"sha1": "02" * 20, + "url": "emulators/freeintv/#sha1-020202020202"}]) + + def test_the_parent_page_lists_the_tree(self): + import generate_site + + trees = generate_site.page_data_directories("freeintv", self.PROFILES) + self.assertEqual([t["ref"] for t in trees], ["freeintv-overlays"]) + + +class ForgesUpstreamDoesNotRead(unittest.TestCase): + """Profiles pinned on SourceForge, voidpoint.io, Bitbucket or Software + Heritage showed no revision, and their citations were plain code.""" + + def links(self, url: str, ref: str, **extra: str) -> tuple[str, str]: + import sitesources + + profile = {"source": url, "source_commit": "f6c00044" + "0" * 32, **extra} + return (sitesources.revision_markdown(profile), + sitesources._source_ref_markdown(profile, ref)) + + def test_bitbucket(self): + revision, ref = self.links("https://bitbucket.org/linuxwolf6/splitwolf", + "id_lwmp.cpp:1831-1868", source_branch="scrubbed") + pin = "f6c00044" + "0" * 32 + self.assertEqual(revision, f"[linuxwolf6/splitwolf@{pin[:12]}]" + f"(https://bitbucket.org/linuxwolf6/splitwolf/commits/{pin}), " + "branch `scrubbed`") + self.assertIn(f"(https://bitbucket.org/linuxwolf6/splitwolf/src/{pin}/" + "id_lwmp.cpp#lines-1831:1868)", ref) + + def test_a_gitlab_instance(self): + _revision, ref = self.links("https://voidpoint.io/terminx/eduke32", + "source/duke3d/src/common.cpp:36") + self.assertIn("https://voidpoint.io/terminx/eduke32/-/blob/f6c00044", ref) + self.assertIn("/source/duke3d/src/common.cpp#L36)", ref) + + def test_sourceforge(self): + revision, ref = self.links("https://sourceforge.net/p/dgen/dgen", "rc.cpp:12") + self.assertIn("(https://sourceforge.net/p/dgen/dgen/ci/f6c00044", revision) + self.assertRegex(ref, r"\(https://sourceforge\.net/p/dgen/dgen/ci/f6c00044\w+/tree/rc\.cpp\)") + + def test_software_heritage(self): + revision, ref = self.links( + "https://archive.softwareheritage.org/browse/revision/f6c00044/", + "app/src/os.cpp:35-36") + self.assertTrue(revision.startswith("[Software Heritage@f6c000440000]")) + self.assertIn("/browse/revision/f6c00044" + "0" * 32 + "/?path=app/src/os.cpp#L35-L36)", ref) + + +class TheDatHoldsEveryCopy(unittest.TestCase): + """The DAT held one path per content, the database's: auditing bios/ + with it flagged the 2,502 other copies (RPG Maker, engine trees, a dump + filed under two consoles) as unknown, and a fix moved them away.""" + + def test_a_second_copy_is_a_rom_of_its_own_folder(self): + import generate_site + + sha1 = "9526ef63" + "0" * 32 + with tempfile.TemporaryDirectory() as tmp: + for rel in ("Atari/ST/tos104.img", "Atari/ST/tos104uk.img"): + (Path(tmp) / rel).parent.mkdir(parents=True, exist_ok=True) + (Path(tmp) / rel).write_bytes(b"x") + db = { + "bios_dir": tmp, + "files": {sha1: {"path": "bios/Atari/ST/tos104.img", "sha1": sha1, + "size": 1, "name": "tos104.img"}}, + "indexes": {"by_path_suffix": { + "Atari/ST/tos104.img": [sha1], "ST/tos104.img": [sha1], + "Atari/ST/tos104uk.img": [sha1], "ST/tos104uk.img": [sha1], + }}, + } + dat = generate_site.collection_dat(db, "2026-10-21T00:00:00Z") + self.assertIn('', lines) + self.assertIn('??? note "1 file (OK)"', lines) + + def test_an_emulator_card_is_a_heading_with_its_anchor(self): + import generate_site + + entry = {"name": "scph5501.bin", "required": True} + lines = generate_site._render_emulator_file( + entry, {"files": [entry]}, None, [entry], lambda f: True, "file-scph5501-bin") + self.assertTrue(lines[2].startswith("#### `scph5501.bin` ")) + self.assertTrue(lines[2].endswith("{ #file-scph5501-bin .rb-file-title }")) + + def test_a_system_card_is_a_heading_with_its_anchor(self): + import generate_site + from cross_reference import FileConsumers + + entry = {"name": "a.bin", "sha1": "a" * 40, "md5": "b" * 32, "size": 3} + lines = generate_site._system_file_card(entry, FileConsumers({"files": {}})) + self.assertTrue(lines[2].startswith("#### `a.bin` ")) + self.assertTrue(lines[2].endswith("{ #sha1-aaaaaaaaaaaa .rb-file-title }")) + + +class TheSearchSkipsLinkLists(unittest.TestCase): + """The search index was 24.7 MB, read and indexed before the first + result. Lists of platforms, copies and source citations on every card + made 6.6 MB of it, names already indexed on their own pages.""" + + def test_a_system_card_keeps_its_hashes_and_skips_its_platforms(self): + import generate_site + from cross_reference import FileConsumers + + consumers = FileConsumers({"files": {}}) + sha1 = "a" * 40 + consumers.platforms = {sha1: {"retroarch"}} + consumers.installed = {sha1: {"batocera": {"bios/a.bin"}}} + consumers.emulators = {sha1: {"duckstation"}} + entry = {"name": "a.bin", "sha1": sha1, "md5": "b" * 32, "size": 3} + text = "\n".join(generate_site._system_file_card(entry, consumers)) + searched, skipped = text.split('
') + self.assertIn("- MD5:", searched) + self.assertIn("- Emulators:", searched) + self.assertIn("- Platforms:", skipped) + self.assertIn("- Also installed by:", skipped) + self.assertEqual(text.count("
"), 2) + + def test_runs_alternate_in_card_order(self): + import generate_site + + lines = ["- SHA1: x", "- Source: `a.c:1`", "- Platforms: p", + "- Destination: `d`", "- Contents (1 members):", " - `m`"] + out = [line for line in generate_site._search_skipped_apart(lines) if line] + self.assertEqual(out, [ + "- SHA1: x", + '
', + "- Source: `a.c:1`", "- Platforms: p", "
", + "- Destination: `d`", "- Contents (1 members):", " - `m`", + ]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_site_system_links.py b/tests/test_site_system_links.py index c4433ed6..17f05e3d 100644 --- a/tests/test_site_system_links.py +++ b/tests/test_site_system_links.py @@ -69,31 +69,40 @@ class OtherSpellingsOfOneSystem(unittest.TestCase): self.assertEqual(gs._system_emulators("sega-mega-drive", index), ["genesis_plus_gx"]) self.assertEqual(gs._system_emulators("nintendo-snes", index), []) + def _install(self, mapping: dict) -> None: + import siterender + + saved = dict(siterender._system_page_map) + self.addCleanup(siterender.set_system_page_map, saved) + siterender.set_system_page_map(mapping) + def test_a_profile_spelling_links_to_the_platform_page(self): import siterender - saved = dict(siterender._system_page_map) - self.addCleanup(lambda: (siterender._system_page_map.clear(), - siterender._system_page_map.update(saved))) - siterender._system_page_map.clear() - siterender._system_page_map["sega-mega-drive"] = ("sega", "Mega Drive") + self._install({"sega-mega-drive": ("sega", "Mega Drive")}) self.assertIn("systems/sega.md#mega-drive", siterender._system_link("sega-megadrive")) - siterender._system_page_map["megadrive"] = ("other", "Elsewhere") + self._install({"sega-mega-drive": ("sega", "Mega Drive"), + "megadrive": ("other", "Elsewhere")}) self.assertEqual(siterender._system_link("sega-megadrive"), "sega-megadrive") - def test_a_system_only_profiles_name_is_placed_by_their_files(self): + def test_a_table_of_the_same_size_replaces_the_other_spellings(self): + """The index of other spellings was kept while the table kept its + length, so a second table of one entry linked to the first one's page.""" import siterender - saved = dict(siterender._system_page_map) - self.addCleanup(lambda: (siterender._system_page_map.clear(), - siterender._system_page_map.update(saved))) - siterender._system_page_map.clear() + self._install({"sega-mega-drive": ("sega", "Mega Drive")}) + self.assertIn("systems/sega.md#", siterender._system_link("sega-megadrive")) + self._install({"sega-mega-drive": ("other", "Elsewhere")}) + self.assertEqual(siterender._system_link("sega-megadrive"), + "[sega-megadrive](systems/other.md#elsewhere)") + + def test_a_system_only_profiles_name_is_placed_by_their_files(self): manufacturers = {"Commodore": {"PET": [{"name": "basic4.bin"}]}} profiles = {"vice_xpet": {"systems": ["commodore-pet"], "files": [{"name": "basic4.bin"}]}} - gs._build_system_page_map_from_data( + pages = gs._system_pages_from_data( manufacturers, {}, {"files": {}, "indexes": {}}, profiles) - self.assertEqual(siterender._system_page_map["commodore-pet"], ("commodore", "PET")) + self.assertEqual(pages["commodore-pet"], ("commodore", "PET")) class ACopyIsOnItsOwnFoldersPage(unittest.TestCase): @@ -134,7 +143,7 @@ class AnIdNamesOneCardPerPage(unittest.TestCase): "path": "bios/Nintendo/DS/a.bin"} page = gs.generate_system_page( "Nintendo", {"DS": [entry], "DSi": [entry]}, FileConsumers({"files": {}})) - self.assertEqual(page.count('id="sha1-aaaaaaaaaaaa"'), 1) + self.assertEqual(page.count("#sha1-aaaaaaaaaaaa .rb-file-title"), 1) self.assertEqual(page.count("rb-sys-file"), 2) diff --git a/wiki/architecture.md b/wiki/architecture.md index ab066c99..08ee1071 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -66,6 +66,16 @@ dist/ generated packs (gitignored) `platforms/`, `emulators/`, `database.json`, and the `wiki/` sources. Documentation changes belong in `wiki/` or in the generator. +The site search splits each page at its headings and sends a result to the +heading above the match. Every file card is therefore a level 4 heading with +the card's anchor, and every platform system a heading of its own: a hash +pasted in the search opens that card or that system, not the top of the page. +Level 4 stays out of the table of contents (`toc_depth: 3`). The lines that +list other pages (platforms, packs, source citations, collection paths) sit +in a `data-search-exclude` block, since the browser downloads and indexes the +whole search index before it shows a first result. File names, hashes, the +paths an emulator reads and the notes stay searchable. + ## Data flow ``` diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 3cc4d2a6..45aaf5ea 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -44,7 +44,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_selection` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_diff`, `test_truth_layout` | | Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `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_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` | -| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_cores`, `test_site_exports`, `test_site_platform_hashes`, `test_site_system_links`, `test_site_validation`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_cores`, `test_site_exports`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture