diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 11e4ce83..4fdd5b4a 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -4272,7 +4272,9 @@ def generate_systems_index(manufacturers: dict) -> str: "as the data of a game, rather than the BIOS of a machine."), ): rows = sorted( - ((_console_display(mfr, console), mfr, console, len(files)) + ((_folder_emulator(console)[0] if group == "programs" + else _section_label(mfr, console) if mfr == "Arcade" + else _console_display(mfr, console), mfr, console, len(files)) for mfr, consoles in manufacturers.items() if kind(mfr) == group for console, files in consoles.items()), key=lambda row: (row[0].casefold(), row[1].casefold(), row[2]), @@ -4282,6 +4284,19 @@ def generate_systems_index(manufacturers: dict) -> str: lines += [f"## {heading}", ""] if note: lines += [note, ""] + if group == "programs": + # Each folder by the emulator's name, linked to its page: the + # rows read a7800 and azaharplus beside console names, and + # every maker cell said Other. + lines += ["| Emulator | Files |", "|---------|------:|"] + for name, mfr, console, count in rows: + href = console_href(_slug(mfr), console).removeprefix("systems/") + key = _folder_emulator(console)[1] + cell = name.replace("|", "-") + cell = f"[{cell}](../emulators/{key}.md)" if key else cell + lines.append(f"| {cell} | [{_count(count, 'file')}]({href}) |") + lines.append("") + continue lines += [ # The count before the maker: on a phone the last column is # the one scrolled off, and most names carry the maker already. @@ -4297,6 +4312,23 @@ def generate_systems_index(manufacturers: dict) -> str: return "\n".join(lines) + "\n" +def _folder_emulator(folder: str) -> tuple[str, str]: + """The display name and profile of the emulator a folder under Other is + named after, by its profile key or its name, else the folder and "". + """ + profiles = _profiles_on_disk() + if folder in profiles and profiles[folder].get("type") not in ("alias", "test"): + return str(profiles[folder].get("emulator") or folder), folder + for key in sorted(profiles): + profile = profiles[key] + if profile.get("type") in ("alias", "test"): + continue + names = {str(profile.get(field) or "").casefold() for field in ("emulator", "display_name")} + if folder.casefold() in names: + return str(profile.get("emulator") or key), key + return folder, "" + + _PROVENANCE_LABELS = {"redump": "Redump", "no-intro": "No-Intro", "tosec": "TOSEC"} _PROVENANCE_HOMES = { @@ -4667,13 +4699,20 @@ def _emulator_links(sha1: str, consumers: FileConsumers) -> list[str]: entries = getattr(consumers, "emulator_entries", {}).get(sha1, {}) files_of = getattr(consumers, "emulator_files", {}) links = [] - for emulator in sorted(consumers.emulators.get(sha1, ())): + # By the name the emulator's page carries: 19,499 card links read + # melonds_ds where its page says melonDS DS. + named = sorted(((_emulator_display(e), e) for e in consumers.emulators.get(sha1, ())), + key=lambda pair: (pair[0].casefold(), pair[1])) + for display, emulator in named: index = entries.get(emulator) anchor = (_emulator_card_anchor(emulator, files_of[emulator], index) if index is not None and emulator in files_of else "") page = emulator_card_page(emulator, anchor) if anchor else emulator fragment = f"#{anchor}" if anchor else "" - links.append(f"[{emulator}](../emulators/{page}.md{fragment})") + # The card's facts are raw HTML: "Game & Watch" is escaped, and a + # bracket would end the link the facts are parsed from. + text = html.escape(display, quote=False).replace("[", "[").replace("]", "]") + links.append(f"[{text}](../emulators/{page}.md{fragment})") return links @@ -4688,17 +4727,23 @@ def _installed_places(sha1: str, consumers: FileConsumers, declared: set[str]) - return sorted(places, key=lambda place: place[0].casefold()) -def _places_text(places: list[Place]) -> str: - """Setups as "Lakka: scph1000.bin", the name linked to its page and the - destination as text: a code chip per path made the line a row of - boxes.""" +def _places_lines(places: list[Place]) -> list[str]: + """Setups grouped by where they put the file, one line per place: + "`scph5501.bin`: Batocera, EmuDeck". Each setup repeating the name + took five lines where one says it. A setup that names no place (EmuDeck + matches by MD5) is listed on a line of its own, last.""" def plain(text: str) -> str: return html.escape(text, quote=False).replace("`", "`") - return ", ".join( - f"[{name}]({href})" + (": " + " and ".join(plain(d) for d in dests) if dests else "") - for name, href, dests in places - ) + by_place: dict[tuple[str, ...], list[str]] = {} + for name, href, dests in places: + by_place.setdefault(tuple(dests), []).append(f"[{name}]({href})") + ordered = sorted(by_place.items(), key=lambda item: (not item[0], -len(item[1]), item[0])) + return [ + (" and ".join(f"{plain(d)}" for d in dests) + ": " if dests else "") + + ", ".join(links) + for dests, links in ordered + ] def _inline_card_html(text: str) -> str: @@ -4799,10 +4844,12 @@ def _source_order(source: str) -> tuple[bool, str]: def _romset_line(f: dict) -> str: """Which MAME and FBNeo releases a collected archive's contents match. - MAME and FBNeo check the members of an archive, by CRC32, never its - own bytes: the set works with a release whose recipe lists the same - members. Empty when no recipe names the archive or the file is not - on disk. + MAME and FBNeo check the members of an archive by CRC32, never its own + bytes or its member names: a set works with a release when it holds + every CRC32 the release's recipe lists, extra members or not. The + main neogeo.zip, which every MAME from 0.190 to 0.289 loads, read "No + match" when names had to agree and no member could be extra. Empty + when no recipe names the archive or the file is not on disk. """ recipes = _romset_releases().get(str(f.get("name", ""))) path = _REPO_ROOT / str(f.get("path", "")) @@ -4815,14 +4862,42 @@ def _romset_line(f: dict) -> str: except (OSError, zipfile.BadZipFile) as exc: print(f" WARNING: {path}: {exc}", file=sys.stderr) return "" - matched: dict[str, list[Release]] = {} + crcs = {crc for _name, crc in held} + same: dict[str, list[Release]] = {} + holds: dict[str, list[Release]] = {} + # source -> (fewest CRC32s missing, the latest release missing that many) + closest: dict[str, tuple[int, Release]] = {} for members, source, agreed in recipes: + missing = len({crc for _name, crc in members} - crcs) if members == held: - matched.setdefault(source, []).extend(agreed) - if not matched: + same.setdefault(source, []).extend(agreed) + elif not missing: + holds.setdefault(source, []).extend(agreed) + elif agreed: + latest = max(agreed) + best = closest.get(source) + if best is None or (missing, -latest[0]) < (best[0], -best[1][0]): + closest[source] = (missing, latest) + + def runs(found: dict[str, list[Release]], skip: dict[str, list[Release]]) -> str: + return " and ".join( + f"{_ROMSET_SOURCES.get(source, source)} {_release_runs(kept)}" + for source in sorted(found, key=_source_order) + for kept in [[r for r in found[source] if r not in skip.get(source, [])]] if kept) + + said = [] + if same: + said.append(f"Same contents as {runs(same, {})}") + if holds and runs(holds, same): + said.append(f"Holds every ROM of {runs(holds, same)}") + if said: + return ". ".join(said) + if not closest: return f"No match in {_romset_coverage()}" - return ", ".join(f"{_ROMSET_SOURCES.get(source, source)} {_release_runs(matched[source])}" - for source in sorted(matched, key=_source_order)) + return "No match: missing " + ", ".join( + f"{_count(missing, 'ROM')} of {_ROMSET_SOURCES.get(source, source)} {release[1]}" + for source, (missing, release) in sorted(closest.items(), + key=lambda item: _source_order(item[0]))) @functools.lru_cache(maxsize=1) @@ -4895,17 +4970,16 @@ def _system_file_card( facts: list[tuple[str, list[str], bool]] = [] if plats: # Each link lands on the platform's system section, and says where - # that platform puts the file. The site search reads this line: it - # names the file as each setup names it, and with the name only in - # its title the card ranked below every guide citing scph5501 as an - # example. + # that platform puts the file. Out of the search: the card's title + # weighs the most a word can, and the list made the excerpt of + # every card a run of setup names and paths. facts.append(("On the setup's list", - [_places_text(_platform_places(sha1_full, consumers))], True)) + _places_lines(_platform_places(sha1_full, consumers)), False)) if installed: # The packs that carry the file for their cores: a RetroBat user # looking up scph5501.bin found every platform but RetroBat, whose # pack installs it for six cores. - facts.append(("Packed for its emulators by", [_places_text(installed)], False)) + facts.append(("Packed for its emulators by", _places_lines(installed), False)) if emus: facts.append(("Emulators", [", ".join(_emulator_links(sha1_full, consumers))], False)) romset = _romset_line(f) @@ -5106,11 +5180,12 @@ def system_install_choices(coverages: dict) -> dict[str, dict[str, list[tuple[st SYSTEM_PAGE_BUDGET = 300_000 # What the sections kept on a manufacturer's summary may weigh once # rendered, estimated from their Markdown. Fitted on the Nintendo, Sega and -# Commodore summaries: highlighted install commands grow about nine times, -# cards 1.7 times. The page budget is 600 KB of HTML, and the frame around -# the content (head, navigation, footer) is about 45 KB. +# Commodore summaries: highlighted install commands grow about eleven times +# since each carries its setup's name, cards 1.7 times. The page budget is +# 600 KB of HTML, and the frame around the content (head, navigation, +# footer) is about 45 KB. CARD_HTML_RATIO = 1.7 -INSTALL_HTML_RATIO = 9 +INSTALL_HTML_RATIO = 11 SYSTEM_SUMMARY_HTML = 500_000 # A system card: whether it is a variant, its lines, its sha1. @@ -5131,22 +5206,25 @@ def _reading_entries(sha1: str, consumers: FileConsumers) -> list[dict]: _NO_PRIORITY = 1 << 30 -def _card_rank(f: dict, consumers: FileConsumers) -> tuple: - """Where a file's card sits in its console: the files an emulator - requires first, then by the emulators' search order (smallest first), - then a required file by how many emulators read it, then by how many - setups list the file, then by name. +def _card_rank(f: dict, consumers: FileConsumers, start: dict[str, int] | None = None) -> tuple: + """Where a file's card sits in its console: the files the section's + start line names first, in its order (``start``, sha1 -> place), then + the files an emulator requires, then by the emulators' search order + (smallest first), then a required file by how many emulators read it, + then by how many setups list the file, then by name. By byte order the PlayStation section opened on LibPS.exe and PSX_DTLH2000.bin, capitals first, and scph5501.bin was 28th. Without the reader count the Neo Geo section opened on aes.zip, which geolith - alone reads. + alone reads. Without the start line's order, "first below" named + neogeo.zip over a section opening on sfix.sfix. """ sha1 = str(f.get("sha1", "")) entries = _reading_entries(sha1, consumers) priorities = [e["priority"] for e in entries if isinstance(e.get("priority"), int)] required = any(e.get("required", True) for e in entries) return ( + (start or {}).get(sha1, _NO_PRIORITY), not required, min(priorities, default=_NO_PRIORITY), -len(entries) if required else 0, @@ -5165,12 +5243,20 @@ def _console_cards( consumers: FileConsumers, anchored: set[str], by_name: bool = False, + start: list[str] | None = None, ) -> list[Card]: """Each card of one console, primaries then variants, with whether it - is a variant. ``by_name`` orders by name alone, for a console cut into - parts named by their first and last file.""" + is a variant. ``start`` is the sha1s the start line names, whose cards + come first in its order. ``by_name`` orders the rest by name alone, + for a console cut into parts named by their first and last file: the + PlayStation 2 opened on cheats_ni.zip, its BIOS far below.""" searchable = manufacturer not in GAME_DATA_TOPS - order = _by_name if by_name else (lambda f: _card_rank(f, consumers)) + place = {sha1: index for index, sha1 in enumerate(start or [])} + + def order(f: dict) -> tuple: + if by_name: + return (place.get(str(f.get("sha1", "")), _NO_PRIORITY), *_by_name(f)) + return _card_rank(f, consumers, place) main_files = [f for f in files if "/.variants/" not in f["path"]] variant_files = [f for f in files if "/.variants/" in f["path"]] cards = [(False, _system_file_card(f, consumers, anchored, searchable), str(f.get("sha1", ""))) @@ -5308,29 +5394,55 @@ class _StartGroup: # Files an emulator reads when present and replaces when absent. self.optional: list[str] = [] self.emulators: list[str] = [] + # Each file an emulator requires, by its visible name, with the + # emulators requiring it and the name as the line writes it. + self.requires: dict[str, set[str]] = {} + self.written: dict[str, str] = {} + # Visible name -> the sha1 of the file it names. + self.sha1s: dict[str, str] = {} + # Emulators that replace a missing file with their own code. + self.hle: set[str] = set() + # Files an emulator reads when present and runs the same without. + self.extra: list[str] = [] + + def require(self, name: str, emulator: str) -> None: + self.written.setdefault(_visible(name), name) + self.requires.setdefault(_visible(name), set()).add(emulator) def absorb(self, other: _StartGroup) -> None: for region, names in other.by_region.items(): for name in names: _add_name(self.by_region.setdefault(region, []), name) for mine, theirs in ((self.plain, other.plain), (self.optional, other.optional), - (self.emulators, other.emulators)): + (self.extra, other.extra), (self.emulators, other.emulators)): for name in theirs: _add_name(mine, name) + for visible, emulators in other.requires.items(): + self.written.setdefault(visible, other.written[visible]) + self.requires.setdefault(visible, set()).update(emulators) + for visible, sha1 in other.sha1s.items(): + self.sha1s.setdefault(visible, sha1) + self.hle.update(other.hle) + + def requirers(self) -> set[str]: + return set().union(*self.requires.values()) if self.requires else set() def _start_groups( files: list[dict], consumers: FileConsumers, section: tuple[str, str] | None ) -> dict[str | None, _StartGroup]: """The files of a section an emulator reads, by the system they are - read for. An entry whose system leads to another section is that - section's: the DS section named five DSi files. A file the system - requires but filed under Arcade, where sets serve every machine, is - named too, linked to its card: the Neo Geo BIOS. One filed under - another console is that console's, whatever system an emulator files - it under (BizHawk reads the Mega CD BIOS for its Genesis), and one - under Other is a single emulator's, its shaders and fonts. A file the - emulator ships with is no file to fetch.""" + read for. An entry is the section's when one of its systems leads + here: the DS section named five DSi files, and the NES one FBNeo's + YM2608 sample, read for a system with no section of its own. An entry + that names no system, in a profile of several, reads the section's own + console. A file the system requires but filed under + Arcade, where sets serve every machine, is named too, linked to its + card: the Neo Geo BIOS. One filed under another console is that + console's, whatever system an emulator files it under (BizHawk reads + the Mega CD BIOS for its Genesis), and one under Other is a single + emulator's, its shaders and fonts. A file the emulator ships with is + no file to fetch.""" from siterender import _heading_name, _manufacturer_slug, system_name, system_section # noqa: PLC0415 here = (_manufacturer_slug(section[0]), section[1]) if section else None @@ -5343,15 +5455,15 @@ def _start_groups( if entry.get("bundled"): continue system = None - if here is not None: - places = {s: system_section(s) for s in _entry_systems(emulator, entry)} - system = next((s for s, place in places.items() if place == here), None) - if system is None and any(places.values()): + systems = _entry_systems(emulator, entry) + if here is not None and systems: + system = next((s for s in systems if system_section(s) == here), None) + if system is None: continue kept.setdefault(sha1, []).append((emulator, entry, system)) candidates = list(primaries) + known = (getattr(consumers, "_db", None) or {}).get("files", {}) if here is not None: - known = (getattr(consumers, "_db", None) or {}).get("files", {}) for sha1, emulator, entry, system in _entries_by_section(consumers).get(here, []): record = known.get(sha1) parts = str((record or {}).get("path", "")).split("/") @@ -5363,25 +5475,56 @@ def _start_groups( kept.setdefault(sha1, []).append((emulator, entry, system)) groups: dict[str | None, _StartGroup] = {} - for f in sorted(candidates, key=lambda f: _card_rank(f, consumers)): + ranked = sorted(candidates, key=lambda f: _card_rank(f, consumers)) + rendered: dict[str, str] = {} + for f in ranked: sha1 = str(f.get("sha1", "")) pairs = kept.get(sha1, []) name = _code(_setup_name(f, consumers, [(e, entry) for e, entry, _s in pairs])) if f.get("elsewhere"): name = f"[{name}]({markup.site_href(f'files/?sha1={sha1}')})" - for emulator, entry, system in pairs: + rendered[sha1] = name + # A member read inside its archive is the archive's to name: FinalBurn + # Neo reads sfix.sfix inside neogeo.zip, and the line had it read + # sfix.sfix instead of neogeo.zip. + archives = {_visible(name): (name, sha1) for sha1, name in reversed(rendered.items())} + by_name = (getattr(consumers, "_db", None) or {}).get("indexes", {}).get("by_name", {}) + + def archive_named(archive: str) -> tuple[str, str]: + """The archive as the line names it, linked to its card when the + collection holds one, with that card's sha1.""" + if archive in archives: + return archives[archive] + found = by_name.get(archive) or [] + found = [found] if isinstance(found, str) else sorted(found) + found.sort(key=lambda s: "/.variants/" in str(known.get(s, {}).get("path", ""))) + if not found: + return _code(archive), "" + return f"[{_code(archive)}]({markup.site_href(f'files/?sha1={found[0]}')})", "" + + for f in ranked: + sha1 = str(f.get("sha1", "")) + for emulator, entry, system in kept.get(sha1, []): + name, named = rendered[sha1], sha1 + if entry.get("archive"): + name, named = archive_named(str(entry["archive"])) try: key = system_name(system) if system else None except KeyError: key = None group = groups.setdefault(key, _StartGroup()) + if named: + group.sha1s.setdefault(_visible(name), named) if emulator not in group.emulators: group.emulators.append(emulator) if entry.get("hle_fallback"): _add_name(group.optional, name) + group.hle.add(emulator) continue if not entry.get("required", True): + _add_name(group.extra, name) continue + group.require(name, emulator) regions = {str(r) for r in entry.get("region") or []} for names in [group.by_region.setdefault(r, []) for r in regions] or [group.plain]: _add_name(names, name) @@ -5404,20 +5547,75 @@ def _start_with( ``section`` is the (manufacturer, console) the files are under: an entry read for another console's system is left to that console. Read from the profiles' system, required, hle_fallback and region - fields, never typed per system. A system whose emulators all run - without its files says so. A section where no emulator reads a file - gets no line. + fields, never typed per system. A system most of whose emulators run + without its files says so, and names the few that require one. A + section where no emulator reads a file gets no line. """ + return _start(files, consumers, first_below, section)[0] + + +def _start_order( + files: list[dict], consumers: FileConsumers, section: tuple[str, str] | None = None, +) -> list[str]: + """The sha1s of the files a section's start line names, in its order: + the cards come in that order, so "first below" is true.""" + return _start(files, consumers, True, section)[1] + + +def _start( + files: list[dict], consumers: FileConsumers, first_below: bool, + section: tuple[str, str] | None, +) -> tuple[list[str], list[str]]: + """The start line and the sha1s it names, in order.""" groups = _start_groups(files, consumers, section) - blocks = {key: _group_start(group, first_below) for key, group in groups.items()} - blocks = {key: block for key, block in blocks.items() if block} + sha1s: dict[str, str] = {} + for group in groups.values(): + for visible, sha1 in group.sha1s.items(): + sha1s.setdefault(visible, sha1) + held = {str(f.get("sha1", "")) for f in files} + lines = _lead_file(groups) + if not lines: + lines = _group_lines(groups, first_below, held, sha1s) + order: list[str] = [] + for line in lines: + for visible in re.findall(r"`([^`]*)`", line): + sha1 = sha1s.get(visible) + if sha1 and sha1 not in order: + order.append(sha1) + return lines, order + + +def _group_lines( + groups: dict[str | None, _StartGroup], first_below: bool, held: set[str], + sha1s: dict[str, str], +) -> list[str]: + """One block per system, as the section opens on them.""" + keys = sorted(groups, key=lambda k: str(k).casefold()) + blocks: dict[str | None, list[tuple[str, str]]] = {} + for key in keys: + block = _group_start(groups[key], first_below and not blocks) + if block and first_below and not blocks: + # "First below" holds only when the first file named is a card + # of this page: the Neo Geo BIOS is filed under Arcade. + first = re.search(r"`([^`]*)`", block[0][1]) + if first and sha1s.get(first[1]) not in held: + block = _group_start(groups[key], False) + if block: + blocks[key] = block if not blocks: return [] if len(blocks) == 1: return _start_lines(next(iter(blocks.values()))) + if all(lead == "*" for block in blocks.values() for lead, _text in block): + # No machine of the section needs a file: the SNES said "Start with" + # over two lines that each said no file is needed. + lines = ["**No file needed** on any of these machines:", ""] + lines += [f"- **{key}**: {text.lstrip(': ')}" + for key, block in blocks.items() for _lead, text in block] + return [*lines, ""] lines = ["**Start with** the files of your machine:", ""] - for key in sorted(blocks, key=lambda k: str(k).casefold()): - for lead, text in blocks[key]: + for key, block in blocks.items(): + for lead, text in block: if lead == "*": lines.append(f"- **{key}**: no file needed{text}") continue @@ -5425,12 +5623,95 @@ def _start_with( return [*lines, ""] +def _display_names(emulators: set[str] | list[str]) -> list[str]: + return sorted({_emulator_display(e) for e in emulators}, key=str.casefold) + + +def _names_counted(names: list[str], most: int = 3, noun: str = "") -> str: + """Names as "a, b and c", the ones past ``most`` counted: "a, b, c and + 12 more". One past ``most`` is named, not counted as "1 more".""" + if len(names) > most + 1: + more = f"{len(names) - most} more" + (f" {noun}" if noun else "") + return f"{', '.join(names[:most])} and {more}" + return _said_as_list(names) + + +def _lead_file(groups: dict[str | None, _StartGroup]) -> list[str]: + """A section of several systems where most emulators require one file: + that file first, and the emulators that read another instead. The Neo + Geo section listed three machines, and neogeo.zip, which 26 emulators + read, came after a file ares alone reads.""" + if len(groups) < 2: + return [] + emulators: set[str] = set() + requires: dict[str, set[str]] = {} + written: dict[str, str] = {} + for group in groups.values(): + emulators.update(group.emulators) + for visible, readers in group.requires.items(): + requires.setdefault(visible, set()).update(readers) + written.setdefault(visible, group.written[visible]) + if not requires: + return [] + top = max(requires, key=lambda v: len(requires[v])) + readers = requires[top] + if 2 * len(readers) <= len(emulators): + return [] + zipped = " Keep it zipped." if top.casefold().endswith(".zip") else "" + names = _display_names(readers) + said = (_names_counted(names, 2, "emulators") if len(names) > 2 + else _said_as_list(names)) + lines = [f"**Start with** {written[top]}.{zipped} {said} read{'s' if len(names) == 1 else ''} it.", + ""] + # The emulators that require other files and not this one, by the + # files they require. + others: dict[tuple[str, ...], set[str]] = {} + for emulator in emulators - readers: + theirs = tuple(written[v] for v in requires if emulator in requires[v]) + if theirs: + others.setdefault(theirs, set()).add(emulator) + sentences = [] + for files, who in sorted(others.items(), key=lambda item: (-len(item[1]), item[0])): + names = _display_names(who) + verb = "reads" if len(names) == 1 else "read" + sentences.append(f"{_names_counted(names)} {verb} {_said_as_list(list(files))} instead.") + if sentences: + lines += [" ".join(sentences), ""] + return lines + + def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]]: """One system's start as (lead, text) pairs. The lead is the regions a line is for, "" the files every region needs, "*" the line of a system - that runs without any file.""" + that runs without any file. + + A file is a start file when the emulators requiring it outweigh those + that run without it. The Game Boy opened on dmg_boot.bin, which Emux + GB alone of 13 requires, under a home page that says the Game Boy + needs no BIOS.""" from region import _PARENT # noqa: PLC0415 + def listed(names: list[str], below: bool = first_below) -> str: + if len(names) <= START_WITH_NAMES: + return ", ".join(names) + # The first three by rank, the rest counted: "the 9 files" named + # none, and the Neo Geo BIOS everyone reads is filed under Arcade. + return f"{', '.join(names[:3])} and {_count(len(names) - 3, 'more file')}" + ( + ", first below" if below else "") + + requirers = group.requirers() + hle = group.hle - requirers + running = set(group.emulators) - requirers + # One that replaces the file with its own code runs, with less + # accuracy, and counts half: most PlayStation cores have such a + # fallback, and a reader still starts with the BIOS. + if requirers and 2 * len(requirers) <= 2 * len(running - hle) + len(hle): + free = _display_names(running) + needing = _display_names(requirers) + verb = "requires" if len(needing) == 1 else "require" + return [("*", f" with {_names_counted(free)}. Only {_names_counted(needing)} {verb} " + f"{listed(list(group.written.values()), False)}.")] + order = _region_order() # A territory its region answers the same way adds nothing: Brazil # under Latin America. @@ -5446,14 +5727,6 @@ def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]] else: regional[(*same, region)] = regional.pop(same) - def listed(names: list[str]) -> str: - if len(names) <= START_WITH_NAMES: - return ", ".join(names) - # The first three by rank, the rest counted: "the 9 files" named - # none, and the Neo Geo BIOS everyone reads is filed under Arcade. - return f"{', '.join(names[:3])} and {_count(len(names) - 3, 'more file')}" + ( - ", first below" if first_below else "") - out = [(", ".join(_region_word(r) for r in regions), listed(regional[regions])) for regions in sorted(regional, key=lambda rs: (order.get(rs[0], len(order)), rs))] # A name a region line gives already is no "also". @@ -5464,6 +5737,11 @@ def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]] if not out and group.optional: out.append(("*", f"{_without_files(group.emulators)}. " f"For full accuracy: {listed(group.optional)}.")) + elif not out and group.extra: + # The Mega Drive's emulators read the TMSS BIOS when it is there: + # the section said nothing under a home page saying none is needed. + out.append(("*", f"{_without_files(group.emulators)}. " + f"Optional: {listed(group.extra, False)}.")) return out @@ -5510,11 +5788,12 @@ def _parts(cards: list[Card], budget: int, sizes: list[int] | None = None) -> li return parts -def _console_heading(manufacturer: str, console: str) -> str: +def _console_heading(manufacturer: str, console: str, label: str = "") -> str: """A console's heading, its name alone. An icon on 79 of 544 headings read as missing art on the rest, set the text at two positions on one - page and faded out on one theme or the other.""" - return f"## {console} {{ #{console_anchor(manufacturer, console)} }}" + page and faded out on one theme or the other. ``label`` is the text + when it differs from the folder, the id staying the folder's.""" + return f"## {label or console} {{ #{console_anchor(manufacturer, console)} }}" def _romset_note(consoles: dict[str, list]) -> list[str]: @@ -5533,6 +5812,146 @@ _NEXT_FROM_SYSTEMS = [ "[Every emulator](../emulators/index.md), and the files each one reads", ] +# The arcade boards the system pages give a section, by the console key +# their section goes by (the board's name) -> the board's system id. Set by +# arcade_board_sections() before the first system page is written. +_BOARD_CONSOLES: dict[str, str] = {} +# The Arcade folder of files no board claims: the folder is named after +# its maker, which said nothing about what it holds. +OTHER_ARCADE = ("Arcade", "Arcade") +OTHER_ARCADE_LABEL = "Other arcade files" + + +def arcade_board_sections( + manufacturers: dict[str, dict[str, list]], + installs: dict[str, dict[str, list[tuple[str, str]]]], +) -> tuple[dict[str, dict[str, list]], dict[str, dict[str, list[tuple[str, str]]]]]: + """The Arcade page grouped by board, and each board's install commands. + + The Arcade sections were the folders of bios/Arcade/: NAOMI, PGM and + ZN-1 had no section, and their names stayed plain on 568 emulator + pages. Each archive goes to the board siterender.arcade_board() reads + from the profiles, never from its folder, and a folder named after a + board (Namco System 246) joins it. Files no board claims stay in their + folders. Each board is recorded (siterender.record_board_section) before + any page links one, and a system's install commands follow its link to + the board's section. Call after reset_console_pages().""" + from siterender import ( # noqa: PLC0415 + arcade_board, + arcade_boards, + record_board_section, + system_section, + ) + + _BOARD_CONSOLES.clear() + arcade = manufacturers.get("Arcade") + if not arcade: + return manufacturers, installs + names = arcade_boards() + by_name = {name.casefold(): board for board, name in names.items()} + boards: dict[str, list] = {} + folders: dict[str, list] = {} + seen: dict[str, set[str]] = {} + for console in sorted(arcade): + joined = by_name.get(console.casefold()) + for f in arcade[console]: + board = joined or arcade_board(str(f.get("sha1", ""))) + if board is None: + folders.setdefault(console, []).append(f) + continue + # One card per content: a copy under Arcade and one under MAME + # are the same file. + if str(f.get("sha1", "")) in seen.setdefault(board, set()): + continue + seen[board].add(str(f.get("sha1", ""))) + boards.setdefault(board, []).append(f) + for board in sorted(boards): + record_board_section(board) + _BOARD_CONSOLES[names[board]] = board + regrouped = {**folders, **{names[board]: files for board, files in boards.items()}} + + moved: dict[str, dict[str, list[tuple[str, str]]]] = {} + for slug, consoles in installs.items(): + for console, pairs in consoles.items(): + for platform, sys_id in pairs: + section = system_section(sys_id) + if section and section[0] == "arcade" and section[1] in _BOARD_CONSOLES: + slug_to, console_to = section + else: + slug_to, console_to = slug, console + moved.setdefault(slug_to, {}).setdefault(console_to, []).append((platform, sys_id)) + return {**manufacturers, "Arcade": regrouped}, moved + + +def _section_label(manufacturer: str, console: str) -> str: + """The heading a section shows.""" + return OTHER_ARCADE_LABEL if (manufacturer, console) == OTHER_ARCADE else console + + +def _part_name(manufacturer: str, console: str) -> str: + """What a part's title calls the files: "Arcade files a to b".""" + return "Arcade" if (manufacturer, console) == OTHER_ARCADE else console + + +def _system_front_matter( + parent: tuple[str, str] | None = None, + prev: tuple[str, str] | None = None, + following: tuple[str, str] | None = None, + weight: float | None = None, + consoles: list[str] | None = None, +) -> list[str]: + """The front matter of a system page: the page it belongs to, the + parts before and after it (label, site path), its search weight and + the consoles it holds, which the search reads as a field.""" + lines = _parent_front_matter(parent) + for key, link in (("prev", prev), ("next", following)): + if link: + lines += [f"{key}:", f" label: {json.dumps(link[0])}", f" link: {json.dumps(link[1])}"] + if weight is not None: + lines.append(f"searchWeight: {weight}") + if consoles: + lines += ["consoles:", *(f" - {json.dumps(name)}" for name in consoles)] + return ["---", *lines, "---", ""] if lines else [] + + +def _part_weight() -> float: + """The search weight of a part page: its cards keep their own weight.""" + return SEARCH_WEIGHT.get("part", DEMOTED_WEIGHT) + + +@functools.lru_cache(maxsize=None) +def _manifest_systems(platform: str) -> frozenset[str]: + """The system ids a platform's install manifest records: --system + refuses any other.""" + manifest = _REPO_ROOT / "install" / f"{platform}.json" + if not manifest.is_file(): + return frozenset() + document = json.loads(manifest.read_text(encoding="utf-8")) + return frozenset( + str(system) + for entry in (document.get("files") or []) + (document.get("omitted_files") or []) + for system in entry.get("systems") or [] + ) + + +def _install_pairs( + declared: list[tuple[str, str]], lead: str | None, consumers: FileConsumers, +) -> list[tuple[str, str]]: + """A section's install commands: the setups whose system leads here, + then each setup that files the section's first file under a system of + its own (RetroArch files neogeo.zip under arcade), with that id.""" + pairs = list(declared) + have = {platform for platform, _system in pairs} + for platform, systems in sorted(getattr(consumers, "platform_systems", {}) + .get(lead or "", {}).items()): + if platform in have: + continue + recorded = [s for s in sorted(systems) if s in _manifest_systems(platform)] + if recorded: + pairs.append((platform, recorded[0])) + have.add(platform) + return sorted(pairs, key=lambda pair: (_platform_display(pair[0]).casefold(), pair[1])) + def system_pages( manufacturer: str, @@ -5549,43 +5968,85 @@ def system_pages( systems//# still lands, and site.js sends a card anchor the page no longer holds to the card's new place. The pages each card and console went to are recorded for every other link - (siterender.record_console_pages). + (siterender.record_console_pages). An arcade board has a page of its + own, systems/arcade-/, under a heading of that id. """ + from siterender import board_slug # noqa: PLC0415 + slug = _slug(manufacturer) - json_link = (f"[JSON](../api/v1/systems/{urllib.parse.quote(slug)}.json)") + installs = installs or {} + boards = {console: _BOARD_CONSOLES[console] for console in consoles + if manufacturer == "Arcade" and console in _BOARD_CONSOLES} + json_next = (f"[Every hash of these files, as JSON](../api/v1/systems/" + f"{urllib.parse.quote(slug)}.json)") + # Boards first on the Arcade page, then the folders of sets for many + # machines. Such a folder starts nowhere: its line named konamigv.zip, + # sys573.zip and 97 more, files of boards with lines of their own. + ordered = sorted(consoles, key=lambda c: (bool(boards) and c not in boards, c)) + + def start_of(console: str, first_below: bool = True) -> list[str]: + if boards and console not in boards: + return [] + return _start_with(consoles[console], consumers, first_below, (manufacturer, console)) + + starts = {console: [] if boards and console not in boards + else _start_order(consoles[console], consumers, (manufacturer, console)) + for console in consoles} + + def cards_of(console: str, anchors: set[str], by_name: bool = False) -> list[Card]: + return _console_cards(manufacturer, consoles[console], consumers, anchors, + by_name, starts[console]) + + def install_block(console: str) -> list[str]: + lead = starts[console][0] if starts[console] else None + return _console_install_block( + _section_label(manufacturer, console), + _install_pairs(installs.get(console, []), lead, consumers)) + def all_sections() -> dict[str, list[Card]]: anchors: set[str] = set() - return {console: _console_cards(manufacturer, consoles[console], consumers, anchors) - for console in sorted(consoles)} + return {console: cards_of(console, anchors) for console in ordered} + + # One line, then the legend closed: four paragraphs said the same on + # every maker's page before its first console. + intro = f"Every BIOS file for {manufacturer} machines, one card per file." + legend = search_excluded(markup.details("note", "How to read a card", [ + "A card gives the file's fingerprints, the emulators that read it, the setups " + "that list it and where to download it. A file can have several versions, one " + "per region or revision: the main one comes first, the others under " + "\"Variants\". To install the files of one console, open its \"Install\" box.", + "", + "**On the setup's list** names the setups whose own BIOS list has the file, by " + "the name each gives it. **Packed for its emulators by** names the setups whose " + "pack adds it because one of their emulators reads it.", + ])) + # The consoles a page answers for, a search field: machines only. An + # emulator's folder under Other put that page first for "dolphin", and + # a console with a page of its own is that page's to answer for. + machines = [console for console in ordered + if manufacturer not in ("Other", *GAME_DATA_TOPS) + and (manufacturer != "Arcade" or console in boards)] + + def consoles_front(kept: set[str] | None = None) -> list[str]: + return _system_front_matter(consoles=[ + _section_label(manufacturer, c) for c in machines if kept is None or c in kept]) + + front = consoles_front() - intro = ( - f"Every BIOS file the collection holds for {manufacturer} machines, one " - "card per file. A card gives the file's fingerprints, the emulators that " - "read it, the setups that list it and where to download it. A file can " - "have several versions, one per region or revision: the main one comes " - "first, the others under \"Variants\". To install the files of one " - "console, open its \"Install\" box." - ) - # The two setup lines of a card differ, and only a code comment said how. - key = ( - "On a card, **On the setup's list** names the setups whose own BIOS " - "list has the file. **Packed for its emulators by** names the setups " - "whose pack adds it because one of their emulators reads it." - ) def whole_page(sections: dict[str, list[Card]]) -> str: lines = [ - f"# {manufacturer}", "", intro, "", key, "", *_romset_note(consoles), - f"The files below by console, with every hash, are also published as {json_link}.", - "", *_icon_symbol(), + *front, f"# {manufacturer}", "", intro, "", *legend, *_romset_note(consoles), + *_icon_symbol(), ] for console, cards in sections.items(): - lines.append(_console_heading(manufacturer, console)) + lines.append(_console_heading(manufacturer, console, + _section_label(manufacturer, console))) lines.append("") - lines.extend(_start_with(consoles[console], consumers, section=(manufacturer, console))) - lines.extend(_console_install_block(console, (installs or {}).get(console, []))) + lines.extend(start_of(console)) + lines.extend(install_block(console)) lines.extend(_cards_markdown(cards)) lines.append("") - lines.extend(_next_block(_NEXT_FROM_SYSTEMS)) + lines.extend(_next_block([*_NEXT_FROM_SYSTEMS, json_next])) lines.extend(["", _date_line()]) return "\n".join(lines) + "\n" @@ -5606,18 +6067,20 @@ def system_pages( # page, which the card weights never counted. A page is also cut when # its estimate as it renders passes the summary's. with markup.using("mkdocs"): - boxes = {console: sum(len(line) + 1 for line in _console_install_block( - console, (installs or {}).get(console, []))) for console in consoles} + boxes = {console: sum(len(line) + 1 for line in install_block(console)) + for console in consoles} rendered = {console: CARD_HTML_RATIO * weights[console] + INSTALL_HTML_RATIO * boxes[console] for console in weights} - if len(measured_text) <= budget and sum(rendered.values()) <= SYSTEM_SUMMARY_HTML: + if (not boards and len(measured_text) <= budget + and sum(rendered.values()) <= SYSTEM_SUMMARY_HTML): text = whole_page(all_sections()) if markup.starlight() else measured_text return {slug: (text, consoles)} - # The heaviest sections move to pages of their own until what stays on - # the manufacturer's page is half the budget: a section of one file - # keeps its place, and a reader does not open 91 pages to browse Other. - moved: set[str] = set() + # Every board has a page of its own. Then the heaviest sections move + # to pages of their own until what stays on the manufacturer's page is + # half the budget: a section of one file keeps its place, and a reader + # does not open 91 pages to browse Other. + moved: set[str] = set(boards) for console in sorted(weights, key=lambda c: (-weights[c], c)): if sum(w for c, w in weights.items() if c not in moved) <= budget // 2: break @@ -5630,96 +6093,136 @@ def system_pages( pages: dict[str, tuple[str, dict[str, list]]] = {} summary = [ - f"# {manufacturer}", "", intro, "", key, "", *_romset_note(consoles), - f"The files below by console, with every hash, are also published as {json_link}. " - f"The largest sections have a page of their own, linked from their heading.", - "", *_icon_symbol(), + *consoles_front(set(consoles) - moved), f"# {manufacturer}", "", intro, "", *legend, + *_romset_note(consoles), + *_icon_symbol(), ] summary_anchors: set[str] = set() - for console in sorted(consoles): - summary.append(_console_heading(manufacturer, console)) + parent = (manufacturer, f"/systems/{slug}/") + for console in ordered: + label = _section_label(manufacturer, console) + summary.append(_console_heading(manufacturer, console, label)) summary.append("") if console not in moved: - summary.extend(_start_with(consoles[console], consumers, section=(manufacturer, console))) - summary.extend(_console_install_block(console, (installs or {}).get(console, []))) - summary.extend(_cards_markdown( - _console_cards(manufacturer, consoles[console], consumers, summary_anchors))) + summary.extend(start_of(console)) + summary.extend(install_block(console)) + summary.extend(_cards_markdown(cards_of(console, summary_anchors))) summary.append("") continue # Cards are rendered again for their own page: the anchors a page # holds are its own. def cut(by_name: bool) -> list[list[Card]]: - cards = _console_cards(manufacturer, consoles[console], consumers, set(), by_name) + cards = cards_of(console, set(), by_name) with markup.using("mkdocs"): - sizes = [card_weight(card) for _variant, card, _sha1 in _console_cards( - manufacturer, consoles[console], consumers, set(), by_name)] - return _parts(cards, budget, sizes) + sizes = [card_weight(card) for _variant, card, _sha1 in cards_of( + console, set(), by_name)] + parts = _parts(cards, budget, sizes) + if len(parts) < 2: + return parts + # Even parts: cut at budget, the PlayStation 2 was 137 cards + # and 4. The same number of parts, each near the mean. + share = -(-sum(sizes) // len(parts)) + even = _parts(cards, share, sizes) + while len(even) > len(parts): + share += max(1, share // 50) + even = _parts(cards, share, sizes) + return even # One page keeps the required files first. Parts are named by their - # first and last file, so a console cut in parts goes by name. + # first and last file, so a console cut in parts goes by name, the + # files the start line names first. parts = cut(False) if len(parts) > 1: parts = cut(True) - base = f"{slug}-{_slugify_anchor(console) or 'files'}" + board = boards.get(console) + base = (f"arcade-{board_slug(board)}" if board + else f"{slug}-{_slugify_anchor(console) or 'files'}") stems = [base if index == 0 else f"{base}-{index + 1}" for index in range(len(parts))] + clash = set(stems) & set(pages) + if clash: + raise RuntimeError(f"two system sections take one page: {sorted(clash)}") record_console_pages(slug, console, stems, [ [sha1 for _variant, _card, sha1 in part] for part in parts ]) - title = f"{manufacturer}: {console}" + lead = set(starts[console]) + # A part by the files it holds: "Arcade: Arcade, part 3 of 5" said + # nothing a search result could use. + titles = ([f"{manufacturer}: {label}"] if len(stems) == 1 else [ + "{} files {} to {}".format(_part_name(manufacturer, console), *_part_ends(part, lead)) + for part in parts]) count = _count(len(consoles[console]), "file") - summary.extend(_start_with(consoles[console], consumers, first_below=False, - section=(manufacturer, console))) + summary.extend(start_of(console, False)) if len(stems) == 1: - summary.append(f"{count}, on [their own page]({stems[0]}.md).") + own = "its" if len(consoles[console]) == 1 else "their" + summary.append(f"{count}, on [{own} own page]({stems[0]}.md).") else: summary.append(f"{count}, on {len(stems)} pages by file name:") summary.append("") for index, (stem, part) in enumerate(zip(stems, parts)): - summary.append(f"- [Part {index + 1}]({stem}.md): {_part_range(part)}") + summary.append(f"- [Part {index + 1}]({stem}.md): {_part_range(part, lead)}") summary.append("") for index, (stem, part) in enumerate(zip(stems, parts)): - heading = title if len(stems) == 1 else f"{title}, part {index + 1} of {len(stems)}" + neighbours = [(titles[i], f"/systems/{stems[i]}/") + for i in (index - 1, index + 1) if 0 <= i < len(stems)] lines = [ - f"# {heading}", "", - f"Part of [{manufacturer}]({slug}.md), in [Systems](index.md). " - f"Every hash of these files is also published as {json_link}.", + *_system_front_matter( + parent, + prev=neighbours[0] if index > 0 else None, + following=neighbours[-1] if index + 1 < len(stems) else None, + weight=_part_weight() if len(stems) > 1 else None, + consoles=[label] if index == 0 and console in machines else None), + f"# {titles[index]}", "", + f"Part of [{manufacturer}]({slug}.md), in [Systems](index.md).", "", *_icon_symbol(), ] if len(stems) > 1: others = ", ".join( f"[part {i + 1}]({other}.md)" for i, other in enumerate(stems) if i != index) - lines.extend([f"This part holds {_part_range(part)}. The others: {others}.", ""]) + lines.extend([f"This part holds {_part_range(part, lead)}. The others: {others}.", + ""]) + if board: + # The heading a board's links land on, above its start line. + lines.extend([f"## {label} {{ #{board_slug(board)} }}", ""]) if index == 0: - lines.extend(_start_with(consoles[console], consumers, first_below=len(stems) == 1, - section=(manufacturer, console))) - lines.extend([key, ""]) + lines.extend(start_of(console, len(stems) == 1)) + lines.extend(legend) lines.extend(_romset_note({console: consoles[console]})) - lines.extend(_console_install_block(console, (installs or {}).get(console, []))) - lines.extend(["## Files", ""]) + lines.extend(install_block(console)) + if not board: + lines.extend(["## Files", ""]) lines.extend(_cards_markdown(part)) lines.append("") lines.extend(_next_block([f"[{manufacturer}]({slug}.md), every section"] - + _NEXT_FROM_SYSTEMS)) + + _NEXT_FROM_SYSTEMS + [json_next])) lines.extend(["", _date_line()]) held = {sha1 for _variant, _card, sha1 in part} pages[stem] = ("\n".join(lines) + "\n", {console: [ f for f in consoles[console] if str(f.get("sha1", "")) in held]}) - summary.extend(_next_block(_NEXT_FROM_SYSTEMS)) + summary.extend(_next_block([*_NEXT_FROM_SYSTEMS, json_next])) summary.extend(["", _date_line()]) kept = {console: files for console, files in consoles.items() if console not in moved} return {slug: ("\n".join(summary) + "\n", kept), **pages} -def _part_range(part: list[Card]) -> str: - """The first and last file names of a part, as the cards show them.""" +def _part_ends(part: list[Card], lead: set[str] | frozenset[str] = frozenset()) -> tuple[str, str]: + """The first and last file names of a part, as the cards show them. + The files a start line puts first are left out when the part holds + others: the range is the run by name that follows them.""" + named = [card for card in part if card[2] not in lead] or part names = [] - for _variant, card, _sha1 in part: + for _variant, card, _sha1 in named: # An HTML heading on the site, a Markdown one in the size yardstick. match = re.search(r"`([^`]*)`|(.*?)", card[2]) names.append(match[1] if match[1] is not None else html.unescape(match[2])) - if len(names) == 1: - return f"`{names[0]}`" - return f"{_count(len(names), 'file')}, `{names[0]}` to `{names[-1]}`" + return names[0], names[-1] + + +def _part_range(part: list[Card], lead: set[str] | frozenset[str] = frozenset()) -> str: + """The first and last file names of a part, with its count.""" + first, last = _part_ends(part, lead) + if len(part) == 1: + return f"`{first}`" + return f"{_count(len(part), 'file')}, `{first}` to `{last}`" def generate_system_page( @@ -6509,14 +7012,18 @@ def _ps_quote(value: str) -> str: def _console_install_block(console: str, declared: list[tuple[str, str]]) -> list[str]: """Each platform's command for one console, by the system id it gives it, - for a terminal and for PowerShell.""" + for a terminal and for PowerShell, each under its setup's name: RetroBat + had two lines and nothing said which was for the Neo Geo.""" if not declared: return [] - sh = [install_command("sh", f"--platform {platform} --system {shlex.quote(system)}") - for platform, system in declared] - ps = [install_command("powershell", f"--platform {platform} --system {_ps_quote(system)}") - for platform, system in declared] - return search_excluded(markup.details("tip", f"Install the {console} files", [ + sh = [line for platform, system in declared for line in ( + f"# {_platform_display(platform)}", + install_command("sh", f"--platform {platform} --system {shlex.quote(system)}"))] + ps = [line for platform, system in declared for line in ( + f"# {_platform_display(platform)}", + install_command("powershell", f"--platform {platform} --system {_ps_quote(system)}"))] + # The name last: "Install the Atari System 1 files" read as a count. + return search_excluded(markup.details("tip", f"Install the files of {console}", [ "Each platform names the system its own way.", "", "Linux, macOS, Steam Deck:", @@ -9234,10 +9741,6 @@ def _generate(args: argparse.Namespace) -> None: set_console_sections(manufacturers, program_names(profiles)) from siterender import link_coverage, system_ids # noqa: PLC0415 - linked, total = link_coverage( - system_ids(profiles, [cov["config"] for cov in coverages.values()])) - print(f" {linked} of {total} system ids linked") - print("Generating system pages...") prime_system_icons({ @@ -9246,14 +9749,21 @@ def _generate(args: argparse.Namespace) -> None: for console in consoles }) + # The arcade boards are recorded before any page links one, the + # system pages first among them. + reset_console_pages() + sections, console_installs = arcade_board_sections( + manufacturers, system_install_choices(coverages)) + linked, total = link_coverage( + system_ids(profiles, [cov["config"] for cov in coverages.values()])) + print(f" {linked} of {total} system ids linked") + write_if_changed( - str(docs / "systems" / "index.md"), generate_systems_index(manufacturers) + str(docs / "systems" / "index.md"), generate_systems_index(sections) ) - console_installs = system_install_choices(coverages) (docs / "files").mkdir(parents=True, exist_ok=True) write_if_changed(str(docs / "files" / "index.md"), generate_files_page()) - reset_console_pages() - for mfr, consoles in manufacturers.items(): + for mfr, consoles in sections.items(): slug = mfr.lower().replace(" ", "-") pages = system_pages(mfr, consoles, consumers, console_installs.get(slug)) for stem, (page, held) in pages.items(): @@ -9263,7 +9773,8 @@ def _generate(args: argparse.Namespace) -> None: write_if_changed(str(docs / "systems" / f"{stem}.md"), page) write_single_item( public / "api" / "v1" / "systems" / f"{slug}.json", "systems", - system_export_item(slug, mfr, consoles), + # The published data keeps the collection's folders. + system_export_item(slug, mfr, manufacturers[mfr]), str(db.get("generated_at") or _timestamp()), _source_commit(), ) clash = split_system_stems() & {_slug(m) for m in manufacturers} diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py index cedc664b..a3d0108c 100644 --- a/tests/test_site_exports.py +++ b/tests/test_site_exports.py @@ -1056,7 +1056,7 @@ class EveryPageLinksItsOwnData(unittest.TestCase): "path": "bios/Sony/PlayStation/a.bin"} page = generate_site.generate_system_page( "Sony", {"PlayStation": [entry]}, FileConsumers({"files": {}})) - self.assertIn("[JSON](../api/v1/systems/sony.json)", page) + self.assertIn(" as JSON](../api/v1/systems/sony.json)", page) item = generate_site.system_export_item("sony", "Sony", {"PlayStation": [entry]}) self.assertEqual(item["consoles"]["PlayStation"][0]["sha1"], "a" * 40) diff --git a/tests/test_site_r2_systems.py b/tests/test_site_r2_systems.py index 70b44ba0..9fece26d 100644 --- a/tests/test_site_r2_systems.py +++ b/tests/test_site_r2_systems.py @@ -87,8 +87,18 @@ class TheConsoleBiosComesFirst(unittest.TestCase): names = [re.search(r"(.*?)", card[2])[1] for _v, card, _s in cards] self.assertEqual(names, sorted(names, key=str.casefold)) + def required_only(self) -> types.SimpleNamespace: + """The BIOS readers alone: an emulator reading only an optional + file runs without the BIOS, and two of four would say so.""" + return _consumers({ + "4" * 40: [{"name": "scph5501.bin", "required": True, "priority": 2, + "region": ["north-america"]}], + "5" * 40: [{"name": "scph5500.bin", "required": True, "priority": 5, + "region": ["japan"]}], + }) + def test_the_section_opens_on_the_bios_by_region(self): - lines = gs._start_with(self.files, self.consumers) + lines = gs._start_with(self.files, self.required_only()) self.assertEqual(lines[0], "**Start with** the BIOS for the region of your games:") self.assertIn("- **North America**: `scph5501.bin`", lines) self.assertIn("- **Japan**: `scph5500.bin`", lines) @@ -113,7 +123,7 @@ class TheConsoleBiosComesFirst(unittest.TestCase): self.assertEqual(gs._start_with([_file("a.bin", "a")], _consumers({})), []) def test_the_page_carries_the_line_under_the_console(self): - page = gs.generate_system_page("Sony", {"PlayStation": self.files}, self.consumers) + page = gs.generate_system_page("Sony", {"PlayStation": self.files}, self.required_only()) heading = page.index("## PlayStation") self.assertLess(heading, page.index("**Start with**")) self.assertLess(page.index("**Start with**"), page.index('class="rb-sys-file"')) @@ -156,23 +166,19 @@ class TheCardLinesSayWhatTheyMean(unittest.TestCase): def test_labels_and_display_names(self): text = "\n".join(self.lines) - self.assertIn("
On the setup's list
Batocera: scph5501.bin
", - text) - self.assertIn("
Packed for its emulators by
RetroBat: bios/scph5501.bin
", text) + # Grouped by where the setups put the file (round 4, 6.9). + self.assertIn("
On the setup's list
scph5501.bin: Batocera
", text) + self.assertIn("
Packed for its emulators by
bios/scph5501.bin: " + "RetroBat
", text) self.assertNotIn("Platforms:", text) self.assertNotIn("Also installed by", text) - def test_the_setup_list_is_searched_and_the_rest_is_not(self): + def test_the_facts_are_out_of_the_search(self): + # The setup list made every card's excerpt a run of names and + # paths (round 4, V6): the card's title is what the search reads. lists = [line for line in self.lines if line.startswith("', '
']) - searched = "\n".join(self.lines).split('
', 1)[1].split("
")[0] - self.assertIn("On the setup's list", searched) - self.assertNotIn("SHA1", searched) - # The label stays out of the excerpt: the search ran the two - # together, "On the setup's listAmberELEC". - self.assertIn("
On the setup's list
", searched) + self.assertEqual(lists, ['
']) def test_the_page_defines_both_lines(self): page = gs.generate_system_page("Sony", {"PlayStation": [_file("scph5501.bin", "4")]}, @@ -246,7 +252,7 @@ class TheSystemsIndexListsConsoles(unittest.TestCase): # A program's folder under Other is named alone, in its own table. consoles = page.split("## Emulator folders")[0] self.assertNotIn("ScummVM", consoles) - self.assertIn("| [ScummVM](other.md#scummvm) | 1 | [Other](other.md) |", + self.assertIn("| [ScummVM](../emulators/scummvm.md) | [1 file](other.md#scummvm) |", page.split("## Emulator folders")[1]) self.assertNotIn("TheXTech", consoles) self.assertIn("| [TheXTech](game-engines.md#thextech)", page) diff --git a/tests/test_site_r3_systems.py b/tests/test_site_r3_systems.py index 3bd7c105..816a8e07 100644 --- a/tests/test_site_r3_systems.py +++ b/tests/test_site_r3_systems.py @@ -137,12 +137,12 @@ class ArcadeSetsAreNamedWhereverFiled(unittest.TestCase): "system": "snk-neogeo-mvs"})], }, db_files=[self.aes, neogeo, mega_cd, shader]) - def test_one_line_per_system_and_the_arcade_set_linked(self) -> None: + def test_the_file_most_emulators_read_leads_and_the_arcade_set_is_linked(self) -> None: lines = gs._start_with([self.aes], self.consumers, section=("SNK", "Neo Geo")) - self.assertEqual(lines[0], "**Start with** the files of your machine:") link = f"[`neogeo.zip`](/retrobios/files/?sha1={'2' * 40})" - self.assertIn(f"- **SNK Neo Geo MVS**: {link}", lines) - self.assertIn("- **SNK Neo Geo AES**: `aes.zip`", lines) + self.assertEqual(lines[0], f"**Start with** {link}. Keep it zipped. " + "mame0, mame1 and mame2 read it.") + self.assertIn("Geolith reads `aes.zip` instead.", lines) def test_another_consoles_file_or_an_emulators_folder_is_left_out(self) -> None: text = "\n".join(gs._start_with([self.aes], self.consumers, section=("SNK", "Neo Geo"))) @@ -190,13 +190,12 @@ class CardsSayWhichReleaseTheSetMatches(unittest.TestCase): return "\n".join(gs._system_file_card(f, _consumers({}))) def test_the_matching_releases_as_runs(self) -> None: - self.assertIn("
Romset
MAME 0.190 to 0.195, 0.220, FBNeo 1.0.0.03
", - self.card()) + self.assertIn("
Romset
Same contents as MAME 0.190 to 0.195, 0.220 and " + "FBNeo 1.0.0.03
", self.card()) - def test_no_match_names_what_was_compared(self) -> None: + def test_no_match_counts_what_is_missing(self) -> None: self.releases["set.zip"] = self.releases["set.zip"][2:] - with mock.patch.object(gs, "_romset_coverage", return_value="MAME 0.190 to 0.289"): - self.assertIn("
No match in MAME 0.190 to 0.289
", self.card()) + self.assertIn("
No match: missing 1 ROM of MAME 0.289
", self.card()) def test_no_recipe_no_line(self) -> None: self.releases = {} @@ -256,7 +255,8 @@ class TheBuiltPages(unittest.TestCase): page = (DIST / "systems" / "nintendo" / "index.html").read_text(encoding="utf-8") start = page.index('id="nintendo-ds"') section = page[start:page.index('id="nintendo-dsi"')] - opening = section[:section.index('class="rb-sys-file"')] + # The section may have moved to a page of its own: no card follows. + opening = section.split('class="rb-sys-file"')[0] self.assertRegex(opening, r"No file needed|Start with") self.assertNotRegex(opening.casefold(), r"dsi_|bios7i|bios9i") diff --git a/tests/test_site_r4_systems.py b/tests/test_site_r4_systems.py new file mode 100644 index 00000000..40529d3f --- /dev/null +++ b/tests/test_site_r4_systems.py @@ -0,0 +1,317 @@ +"""System pages and file cards (round 4, package 6). + +The Game Boy, NES and SNES sections said "Start with" a file one core of +sixteen requires, under a home page saying they need none. The romset line +said no MAME release matched sets MAME loads. The Neo Geo opened on files +ares alone reads, and RetroArch had no install line. The Arcade page was +sorted by storage folder: NAOMI, PGM and ZN-1 had no section, and their +names were plain on every emulator page. Cards named emulators by id, part +pages had no parent and no name, and four paragraphs came before the first +console. +""" + +from __future__ import annotations + +import json +import re +import sys +import types +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import generate_site as gs # noqa: E402 +from sitestarlight import toc_slug # noqa: E402 + +DIST = REPO_ROOT / "website" / "dist" +CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs" + + +def _file(name: str, char: str, path: str = "") -> dict: + return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 4096, + "path": path or f"bios/Nintendo/Game Boy/{name}"} + + +def _consumers(readers: dict[str, list[tuple[str, dict]]], + systems: dict[str, dict[str, set[str]]] | None = None) -> types.SimpleNamespace: + """Consumers where each sha1 is read by the given (emulator, entry).""" + files_of: dict[str, list] = {} + entries: dict[str, dict[str, int]] = {} + emulators: dict[str, set[str]] = {} + for sha1, read_by in readers.items(): + for emulator, entry in read_by: + files_of.setdefault(emulator, []).append(entry) + entries.setdefault(sha1, {})[emulator] = len(files_of[emulator]) - 1 + emulators.setdefault(sha1, set()).add(emulator) + return types.SimpleNamespace( + platforms={}, emulators=emulators, emulator_entries=entries, + emulator_files=files_of, installed={}, platform_systems=systems or {}, + platform_destinations={}, _db={"files": {}}, + ) + + +def _page(path: Path) -> str: + if not path.is_file(): + raise unittest.SkipTest(f"{path.relative_to(REPO_ROOT)} not built") + return path.read_text(encoding="utf-8") + + +def _opening(html: str, anchor: str) -> str: + """The text of the first paragraph under a heading.""" + start = html.index("

", html.index(f'id="{anchor}"')) + return re.sub(r"<[^>]+>", "", html[start:html.index("

", start)]).strip() + + +class AFileFewRequireIsNoStartFile(unittest.TestCase): + """6.1: one requiring emulator decided the line for the console.""" + + def test_one_of_four_says_no_file_needed_and_names_it(self) -> None: + boot = _file("dmg_boot.bin", "1") + optional = {"required": False, "system": "nintendo-gb"} + consumers = _consumers({"1" * 40: [ + ("emux_gb", {"name": "dmg_boot.bin", "required": True}), + ("gambatte", optional), ("mgba", optional), ("sameboy", optional)]}) + line = gs._start_with([boot], consumers)[0] + self.assertTrue(line.startswith("**No file needed** with "), line) + self.assertRegex(line, r"Only \S.* requires `dmg_boot.bin`\.$") + + def test_most_requiring_keeps_the_start_line(self) -> None: + bios = _file("scph5501.bin", "2") + required = {"name": "scph5501.bin", "required": True} + consumers = _consumers({"2" * 40: [ + ("a", required), ("b", required), ("c", {"required": False, "hle_fallback": True})]}) + self.assertEqual(gs._start_with([bios], consumers)[0], "**Start with**: `scph5501.bin`.") + + def test_an_entry_read_for_a_system_with_no_section_is_dropped(self) -> None: + sample = _file("ym2608_adpcm_rom.bin", "3", "bios/Nintendo/NES/ym2608_adpcm_rom.bin") + consumers = _consumers({"3" * 40: [ + ("fbneo", {"name": "ym2608_adpcm_rom.bin", "required": True, + "system": "no-such-system-anywhere"})]}) + self.assertEqual(gs._start_with([sample], consumers, section=("Nintendo", "NES")), []) + + def test_the_home_usually_not_consoles_open_on_no_file_needed(self) -> None: + home = _page(DIST / "index.html") + match = re.search(r"Usually not for ([^<.]+)", home) + self.assertIsNotNone(match, "the home page no longer says Usually not") + names = re.split(r", | and ", match[1]) + sections = {"NES": ("nintendo", "nes"), "Super Nintendo": ("nintendo", "snes"), + "Mega Drive": ("sega", "mega-drive"), "Game Boy": ("nintendo", "game-boy"), + "Game Boy Advance": ("nintendo", "game-boy-advance")} + for name in names: + self.assertIn(name, sections, f"map {name!r} to its section") + page, anchor = sections[name] + html = _page(DIST / "systems" / page / "index.html") + self.assertTrue(_opening(html, anchor).startswith("No file needed"), name) + + +class TheRomsetLineMatchesByCrc(unittest.TestCase): + """6.2: a set with an extra BIOS revision read "No match".""" + + def test_the_main_neogeo_set_names_every_mame_release(self) -> None: + db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8")) + main = next((f for sha1, f in db["files"].items() if sha1.startswith("447e2375")), None) + if main is None or not (REPO_ROOT / main["path"]).is_file(): + self.skipTest("the main neogeo.zip is not in this checkout") + line = gs._romset_line(main) + self.assertIn("Holds every ROM of MAME 0.190 to 0.289", line) + self.assertNotIn("No match", line) + + +class TheNeoGeoOpensOnItsSet(unittest.TestCase): + """6.3: the set 26 emulators read leads, RetroArch gets its command.""" + + def test_the_lead_names_the_exceptions(self) -> None: + neogeo = _file("neogeo.zip", "4", "bios/SNK/Neo Geo/neogeo.zip") + epo = _file("neo-epo.sp1", "5", "bios/SNK/Neo Geo/neo-epo.sp1") + mvs = {"name": "neogeo.zip", "required": True, "system": "snk-neogeo-mvs"} + consumers = _consumers({ + "4" * 40: [(f"mame{n}", mvs) for n in range(4)], + "5" * 40: [("ares", {"name": "neo-epo.sp1", "required": True, + "system": "snk-neogeo"})], + }) + lines = gs._start_with([neogeo, epo], consumers, section=("SNK", "Neo Geo")) + self.assertTrue(lines[0].startswith("**Start with** `neogeo.zip`. Keep it zipped. ")) + self.assertIn("ares reads `neo-epo.sp1` instead.", lines) + cards = gs._console_cards("SNK", [epo, neogeo], consumers, set(), + start=gs._start_order([neogeo, epo], consumers, + ("SNK", "Neo Geo"))) + self.assertIn("neogeo.zip", cards[0][1][2]) + + def test_a_setup_filing_the_set_elsewhere_gets_its_command(self) -> None: + consumers = _consumers({}, systems={"a" * 40: {"retroarch": {"arcade"}}}) + pairs = gs._install_pairs([("batocera", "snk-neogeo")], "a" * 40, consumers) + self.assertIn(("retroarch", "arcade"), pairs) + block = "\n".join(gs._console_install_block("Neo Geo", pairs)) + self.assertIn("# RetroArch\n", block) + + def test_the_built_section(self) -> None: + html = _page(DIST / "systems" / "snk" / "index.html") + section = html[html.index('id="neo-geo"'):] + self.assertTrue(_opening(html, "neo-geo").startswith("Start with neogeo.zip")) + self.assertIn("--platform retroarch --system arcade", re.sub(r"<[^>]+>", "", section)) + + +BOARDS = ("Sega NAOMI", "Sammy Atomiswave", "Capcom ZN-1", "IGS PolyGame Master", + "Nintendo PlayChoice-10", "SNK Hyper Neo Geo 64") + + +class EachArcadeBoardHasASection(unittest.TestCase): + """6.4: the Arcade page was cut by storage folder.""" + + def test_each_board_heads_its_page(self) -> None: + for name in BOARDS: + slug = toc_slug(name) + html = _page(DIST / "systems" / f"arcade-{slug}" / "index.html") + self.assertRegex(html, rf']*id="{re.escape(slug)}"', name) + + def test_every_board_on_an_emulator_systems_row_is_a_link(self) -> None: + if not (DIST / "emulators").is_dir(): + self.skipTest("site not built") + plain = [] + for page in sorted((DIST / "emulators").glob("*/index.html")): + row = re.search(r"Systems\s*(.*?)", page.read_text(encoding="utf-8"), + re.S) + if not row: + continue + text = re.sub(r"]*>.*?", "", row[1]) + plain += [(page.parent.name, name) for name in BOARDS if name in text] + self.assertEqual(plain, []) + + def test_the_arcade_page_lists_the_boards_first(self) -> None: + md = _page(CONTENT / "systems" / "arcade.md") + headings = re.findall(r"^## (.+?) \{", md, re.M) + self.assertIn("Sega NAOMI", headings) + self.assertLess(headings.index("Sega NAOMI"), headings.index(gs.OTHER_ARCADE_LABEL)) + + +class CardsNameEmulatorsByName(unittest.TestCase): + """6.6: 19,499 card links read the profile id.""" + + def test_the_display_name_is_the_link_text(self) -> None: + sha1 = "6" * 40 + consumers = _consumers({sha1: [("melonds_ds", {"name": "bios7.bin"})]}) + link = gs._emulator_links(sha1, consumers)[0] + self.assertTrue(link.startswith("[melonDS DS]("), link) + + def test_no_built_card_link_reads_an_id(self) -> None: + if not (DIST / "systems").is_dir(): + self.skipTest("site not built") + profiles = gs._profiles_on_disk() + bad = set() + for page in (DIST / "systems").glob("*/index.html"): + for emulator, text in re.findall( + r'([^<]+)', + page.read_text(encoding="utf-8")): + display = str((profiles.get(emulator) or {}).get("emulator") or emulator) + if text == emulator and display != emulator: + bad.add(emulator) + self.assertEqual(sorted(bad), []) + + +class PartPagesKnowWhereTheyAre(unittest.TestCase): + def tearDown(self) -> None: + # system_pages records where consoles went, for every later page. + gs.reset_console_pages() + + """6.7: 128 system pages had no parent, parts were "part 3 of 5".""" + + def test_every_part_has_a_parent_and_a_name(self) -> None: + if not (CONTENT / "systems").is_dir(): + self.skipTest("pages not generated") + orphans, numbered = [], [] + for page in sorted((CONTENT / "systems").glob("*.md*")): + text = page.read_text(encoding="utf-8") + if "Part of " in text and "\nparent:" not in text.split("\n---\n", 1)[0]: + orphans.append(page.name) + if re.search(r"^title: .*part \d+ of \d+", text, re.M | re.I): + numbered.append(page.name) + self.assertEqual(orphans, []) + self.assertEqual(numbered, []) + + def test_parts_are_even_and_linked_to_their_neighbours(self) -> None: + files = [_file(f"rom{i:02}.bin", "0123456789"[i], f"bios/Sony/PlayStation 2/rom{i:02}.bin") + for i in range(10)] + consumers = _consumers({}) + with gs.markup.using("mkdocs"): + one = sum(len(line) + 1 for line in gs._system_file_card(files[0], consumers)) + pages = gs.system_pages("Sony", {"PlayStation 2": files}, consumers, budget=one * 7) + parts = [stem for stem in pages if stem != "sony"] + self.assertEqual(len(parts), 2) + counts = [pages[stem][0].count('class="rb-sys-file"') for stem in parts] + self.assertEqual(counts, [5, 5]) + first = pages[parts[0]][0] + self.assertIn("parent:\n label: \"Sony\"\n link: \"/systems/sony/\"", first) + self.assertIn(f"next:\n label: \"PlayStation 2 files rom05.bin to rom09.bin\"\n" + f" link: \"/systems/{parts[1]}/\"", first) + self.assertIn("# PlayStation 2 files rom00.bin to rom04.bin", first) + self.assertIn(f"searchWeight: {gs._part_weight()}", first) + + +def _consoles(page: str) -> list[str]: + """The consoles: front matter of a generated page.""" + front = page.split("\n---\n", 1)[0] if page.startswith("---\n") else "" + block = re.search(r"^consoles:\n((?: - .*\n?)+)", front + "\n", re.M) + return [json.loads(line[4:]) for line in block[1].splitlines()] if block else [] + + +class ConsolesFieldNamesMachinesOnce(unittest.TestCase): + def tearDown(self) -> None: + # system_pages records where consoles went, for every later page. + gs.reset_console_pages() + + """The consoles search field: only machine sections, and a console + with a page of its own is that page's, or "dolphin" found Other and + "playstation bios" found Sony before the PlayStation page.""" + + def test_no_emulator_folder_is_a_console(self) -> None: + for maker in ("Other", "Game Engines"): + page = gs.system_pages(maker, {"dolphin": [_file("a.bin", "a")]}, _consumers({})) + self.assertEqual(_consoles(page[gs._slug(maker)][0]), [], maker) + + def test_a_console_with_its_own_page_is_left_to_it(self) -> None: + big = [_file(f"r{i:02}.bin", "0123456789"[i], f"bios/Sony/PlayStation/r{i:02}.bin") + for i in range(10)] + small = [_file("pocket.bin", "p", "bios/Sony/PocketStation/pocket.bin")] + consumers = _consumers({}) + with gs.markup.using("mkdocs"): + one = sum(len(line) + 1 for line in gs._system_file_card(big[0], consumers)) + pages = gs.system_pages("Sony", {"PlayStation": big, "PocketStation": small}, + consumers, budget=one * 12) + self.assertIn("sony-playstation", pages) + self.assertEqual(_consoles(pages["sony"][0]), ["PocketStation"]) + self.assertEqual(_consoles(pages["sony-playstation"][0]), ["PlayStation"]) + + +class OneLineBeforeTheFirstConsole(unittest.TestCase): + """6.8: four paragraphs, word for word on every maker's page.""" + + def test_the_intro_is_one_line_and_the_legend_closed(self) -> None: + page = gs.generate_system_page("SNK", {"Neo Geo": [_file("a.bin", "a")]}, _consumers({})) + body = page.split("# SNK\n", 1)[1] + before = body[:body.index("## Neo Geo")] + self.assertTrue(before.lstrip().startswith( + "Every BIOS file for SNK machines, one card per file.\n")) + legend = before[before.index("
"):] + self.assertIn("How to read a card", legend) + self.assertNotIn(" open>", legend) + self.assertNotIn("JSON", before) + self.assertIn("as JSON](../api/v1/systems/snk.json)", page.split("## Next steps")[1]) + + +class SetupsGroupedByPlace(unittest.TestCase): + """6.9: each setup repeated the name it gives the file.""" + + def test_one_line_per_place(self) -> None: + places = [("Batocera", "b.md", ["scph5501.bin"]), ("KNULLI", "k.md", ["scph5501.bin"]), + ("RetroDECK", "r.md", ["bios/scph5501.bin"]), ("EmuDeck", "e.md", [])] + self.assertEqual(gs._places_lines(places), [ + "scph5501.bin: [Batocera](b.md), [KNULLI](k.md)", + "bios/scph5501.bin: [RetroDECK](r.md)", + "[EmuDeck](e.md)", + ]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_site_render.py b/tests/test_site_render.py index dabf9d9a..28cf2efb 100644 --- a/tests/test_site_render.py +++ b/tests/test_site_render.py @@ -160,11 +160,11 @@ class FileCardsLinkTheReadingEntry(unittest.TestCase): 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)", + "[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)"]) + ["[BlastEm](../emulators/blastem.md#file-tmss-md-d0fd0c16)"]) class DirectoryMembersNameTheirEmulator(unittest.TestCase): @@ -193,7 +193,7 @@ class DirectoryMembersNameTheirEmulator(unittest.TestCase): 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)"]) + ["[Aleph One](../emulators/alephone.md#file-marathon-plugins)"]) def test_the_directory_card_points_at_its_copies(self): import generate_site @@ -360,12 +360,12 @@ class TheSearchSkipsLinkLists(unittest.TestCase): self.assertIn("a.bin", lines[2]) text = "\n".join(lines) searched, _sep, skipped = text.partition('
') - for label in ("MD5", "Emulators", "Packed for its emulators by"): + for label in ("MD5", "Emulators", "Packed for its emulators by", "On the setup's list"): self.assertIn(f"
{label}
", skipped) self.assertNotIn(f">{label}", searched) - # The platforms line names the file as each setup does: the site - # search reads it, so a file name finds its card. - self.assertRegex(searched, r'
\n]*>On the setup\'s list') + # The card's title is what the search reads (round 4, V6): the setup + # list made every excerpt a run of names and paths. + self.assertNotIn('
', searched) def test_a_game_data_card_skips_its_name_too(self): import generate_site diff --git a/tests/test_site_system_links.py b/tests/test_site_system_links.py index 33843c44..76521ff3 100644 --- a/tests/test_site_system_links.py +++ b/tests/test_site_system_links.py @@ -318,7 +318,11 @@ class EverySystemLinkLandsOnItsSection(unittest.TestCase): self.assertIn(page, self.headings) if match["anchor"]: self.assertIn(match["anchor"], self.headings[page]) - _slug, console = self.siterender.system_section(sys_id) + slug, console = self.siterender.system_section(sys_id) + # The Arcade folder of sets no board claims is headed by + # what it holds, its id staying the folder's. + if slug == "arcade": + console = gs._section_label("Arcade", console) self.assertEqual(self.headings[page][match["anchor"]], console) self.assertGreater(linked, 200) diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 9370fb50..9909b8f0 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_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_p0`, `test_site_r4_search`, `test_site_r4_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_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_p0`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_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