diff --git a/scripts/generate_site.py b/scripts/generate_site.py index a07d2b6f..f60c3d43 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -4656,12 +4656,9 @@ def _emulator_intro(emu_name: str, profile: dict) -> str: The page opened on a twelve-row table: "Type", "Version", "Checked by". DuckStation needs one PlayStation BIOS of any name, and the reader had to infer it from the "Files found by" row.""" - import siterender # noqa: PLC0415 - names: list[str] = [] for sys_id in profile.get("systems") or []: - page = siterender._system_page_map.get(sys_id) or siterender._page_by_norm(sys_id) - label = page[1] if page else str(sys_id) + label = system_name(sys_id) if label not in names: names.append(label) files = [f for f in profile.get("files") or [] if isinstance(f, dict)] @@ -6675,6 +6672,7 @@ def _generate(args: argparse.Namespace) -> None: _system_pages_from_data(manufacturers, coverages, db, profiles) ) print(f" {len(_system_page_map)} system IDs mapped to pages") + set_console_sections(manufacturers, program_names(profiles)) print("Generating system pages...") @@ -6864,11 +6862,10 @@ def _generate(args: argparse.Namespace) -> None: json.dumps(sitestarlight.sidebar(nav, present), indent=1) + "\n") write_if_changed(str(generated / "lastmod.json"), json.dumps(dict(sorted(lastmod.items())), indent=1) + "\n") - mfr_names = {mfr.lower().replace(" ", "-"): mfr for mfr in manufacturers} - titles = {} - for sys_id, (slug, console) in _system_page_map.items(): - mfr = mfr_names.get(slug, "") - titles[sys_id] = console if console.startswith(mfr) else f"{mfr} {console}" + system_ids = set(_system_page_map) | { + sys_id for cov in coverages.values() for sys_id in cov["config"].get("systems", {}) + } + titles = {sys_id: system_name(sys_id) for sys_id in sorted(system_ids)} sitestarlight.picker_data(_REPO_ROOT / "install", public / "data" / "picker", titles) total_pages = len(present) print(f"\nGenerated {total_pages} pages in {args.docs_dir}/") @@ -6903,6 +6900,9 @@ from siterender import ( # noqa: E402,F401 _system_page_map, _system_link, set_system_page_map, + set_console_sections, + program_names, + system_name, card_page, console_href, record_console_pages, diff --git a/scripts/siterender.py b/scripts/siterender.py index 16d48ec9..25c5dc48 100644 --- a/scripts/siterender.py +++ b/scripts/siterender.py @@ -306,3 +306,568 @@ def _by_mode(value: str | dict, mode: str) -> str: if isinstance(value, dict): return str(value.get(mode) or "") if mode else "" return str(value or "") + + +# System display names. A system is named by the heading of the console +# section that is its own: the folder its id spells, under the maker the id +# names when two makers file a console of that name. The page a system's +# files were voted onto is not its section and never names it. That vote +# called nintendo-gba "Nintendo Game Boy" and camplynx "Arcade". A system +# with no section of its own is named in SYSTEM_NAMES, and a system in +# neither stops the build. + +# Folders that file copies by the emulator or board that loads them. Their +# headings name a program, not a system. +_PROGRAM_TOPS = frozenset({"Other", "Arcade"}) + +# Normalized id -> the console sections spelled that way. +_console_sections: dict[str, set[tuple[str, str]]] = {} +# Emulator and engine names. A heading spelled like one (Game Engines > +# Solarus, ZC > ZC210) files that program's copies and names no system. +_program_names: set[str] = set() + + +def program_names(profiles: dict[str, dict]) -> set[str]: + """Every name an emulator profile goes by: its key, its emulator and + display names.""" + names = {str(key) for key in profiles} + for profile in profiles.values(): + names.update( + str(profile[field]) for field in ("emulator", "display_name") + if profile.get(field) + ) + return names + + +def set_console_sections( + sections: dict[str, dict] | set[tuple[str, str]], + programs: set[str] | frozenset[str] = frozenset(), +) -> None: + """Install the console sections the system pages carry. + + ``sections`` maps each manufacturer to its consoles, as the system pages + group them, or is a set of (manufacturer, console) pairs. ``programs`` + are the emulator names, which never name a system. + """ + from common import _norm_system_id + + pairs = ( + {(mfr, console) for mfr, consoles in sections.items() for console in consoles} + if isinstance(sections, dict) else set(sections) + ) + _console_sections.clear() + for mfr, console in pairs: + spelled = console.lower().replace(" ", "-") + slug = mfr.lower().replace(" ", "-") + for key in {_norm_system_id(f"{slug}-{spelled}"), _norm_system_id(spelled)}: + _console_sections.setdefault(key, set()).add((mfr, console)) + _program_names.clear() + _program_names.update(name.casefold() for name in programs) + _sections_installed[0] = True + + +_sections_installed = [False] +_REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _ensure_console_sections() -> None: + """Read the sections from the repository when no generator installed + them, so a page rendered on its own names systems as the site does.""" + if _sections_installed[0]: + return + import json + + from common import load_emulator_profiles + from generate_site import _group_by_manufacturer + + with open(_REPO_ROOT / "database.json", encoding="utf-8") as handle: + db = json.load(handle) + profiles = load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False) + set_console_sections(_group_by_manufacturer(db), program_names(profiles)) + + +def _own_section(sys_id: str) -> tuple[str, str] | None: + """The console section that is this id's own, or None. + + Of two sections spelled alike (Atari and Camputers both file a Lynx), + the one under the maker the id names is kept. Two left is no answer, + and neither is a section that files a program's copies. + """ + from common import _norm_system_id + + _ensure_console_sections() + places = _console_sections.get(_norm_system_id(sys_id), set()) + if len(places) > 1: + places = { + (mfr, console) for mfr, console in places + if sys_id.lower().startswith(mfr.lower().replace(" ", "-") + "-") + } + if len(places) != 1: + return None + mfr, console = next(iter(places)) + # A folder under a program's top, named like its own page (Dinothawr) or + # like an emulator holds a program's files, not a system's. + if ( + mfr in _PROGRAM_TOPS + or console.casefold() == mfr.casefold() + or _heading_name(mfr, console).casefold() in _program_names + ): + return None + return mfr, console + + +def _heading_name(mfr: str, console: str) -> str: + """A console heading read out of its page: the maker in front, unless + the heading already starts with it or the page is a game publisher's.""" + from common import GAME_DATA_TOPS + + if mfr in GAME_DATA_TOPS: + return console + if console.casefold().startswith(mfr.split()[0].casefold()): + return console + return f"{mfr} {console}" + + +def system_name(sys_id: str) -> str: + """The display name of a system id, the only one a page prints.""" + sys_id = str(sys_id) + section = _own_section(sys_id) + if section is not None: + return _heading_name(*section) + name = SYSTEM_NAMES.get(sys_id) + if name is None: + raise KeyError( + f"system {sys_id!r} has no console section of its own and no " + "entry in siterender.SYSTEM_NAMES" + ) + return name + + +# Systems without a console section of their own. +SYSTEM_NAMES: dict[str, str] = { + "2ship": "The Legend of Zelda: Majora's Mask", + "64dd": "Nintendo 64DD", + "aarch64": "ARM 64-bit (AArch64)", + "acclaim-zn1": "Acclaim ZN-1", + "adrift": "ADRIFT", + "advsys": "AdvSys", + "ags": "Adventure Game Studio games", + "agt": "Adventure Game Toolkit", + "alan": "Alan", + "alg-laserdisc": "American Laser Games laserdisc", + "allied-leisure-pinball": "Allied Leisure pinball", + "alpha": "DEC Alpha", + "andamiro-pumpitup": "Andamiro Pump It Up", + "apf-m1000": "APF M-1000", + "apfm1000": "APF M-1000", + "apple-iie": "Apple IIe", + "apple-ios": "Apple iOS", + "apple2": "Apple II", + "apple2gs": "Apple IIGS", + "arcade": "Arcade games", + "arcade-laserdisc": "Laserdisc arcade games", + "arcadia-systems": "Arcadia Systems Multi Select", + "arduboy": "Arduboy", + "aristocrat-mk5": "Aristocrat MK5", + "aristocrat-mk6": "Aristocrat MK6", + "aristocrat-mk7": "Aristocrat MK7", + "arm": "ARM", + "astrocde": "Bally Astrocade", + "atari-falcon": "Atari Falcon", + "atari-jaguarcd": "Atari Jaguar CD", + "atari-ste": "Atari STE", + "atari-system1": "Atari System 1", + "atari-tt": "Atari TT", + "atari-zn1": "Atari ZN-1", + "atlus-zn1": "Atlus ZN-1", + "atom": "Acorn Atom", + "bandai-playdia": "Bandai Playdia", + "bandai-wswan": "Bandai WonderSwan", + "bandai-wswanc": "Bandai WonderSwan Color", + "barcrest-mpu4": "Barcrest MPU4", + "bbc-master-128": "Acorn BBC Master 128", + "bbc-master-compact": "Acorn BBC Master Compact", + "bbc-master-et": "Acorn BBC Master ET", + "bbc-micro-b": "Acorn BBC Micro Model B", + "bbc-micro-bplus": "Acorn BBC Micro Model B+", + "bbk-longman-4980": "BBK Longman A4980", + "bbk-longman-4988": "BBK Longman A4988", + "bennugd": "BennuGD games", + "bermuda-syndrome": "Bermuda Syndrome game", + "brezzasoft-crystal": "BrezzaSoft Crystal System", + "bshift": "Half-Life: Blue Shift", + "bsyndrome": "Bermuda Syndrome game", + "caesar3": "Caesar III", + "cambridge-z88": "Cambridge Computer Z88", + "camplynx": "Camputers Lynx", + "capcom-cps1": "Capcom CPS-1", + "capcom-cps2": "Capcom CPS-2", + "capcom-cps3": "Capcom CPS-3", + "capcom-zn1": "Capcom ZN-1", + "capcom-zn2": "Capcom ZN-2", + "casio-pv1000": "Casio PV-1000", + "catacomb-3d": "Catacomb 3-D", + "catacomb-adventure": "Catacomb Adventure Series", + "cave-cv1000": "Cave CV1000", + "cave-story": "Cave Story", + "cavestory": "Cave Story", + "celeste-classic": "Celeste Classic", + "century-cvs": "Century CVS", + "ces-galaxy-games": "CES Galaxy Games", + "cgenie": "EACA Colour Genie", + "chex": "Chex Quest", + "chihiro": "Sega Chihiro", + "chip8": "CHIP-8", + "coco3": "Tandy Color Computer 3", + "commanderx16": "Commander X16", + "commodore-c64-supercpu": "Commodore 64 with SuperCPU", + "commodore-cbm-ii": "Commodore CBM-II", + "commodore-cd32": "Commodore Amiga CD32", + "commodore-cdtv": "Commodore CDTV", + "commodore-cubo": "CD Express Cubo CD32", + "commodore-pet": "Commodore PET", + "coolbar": "Coolbar feature phones", + "cps1": "Capcom CPS-1", + "cps2": "Capcom CPS-2", + "cps3": "Capcom CPS-3", + "crvision": "VTech CreatiVision", + "cstrike": "Counter-Strike", + "ctf": "Quake II: Capture the Flag", + "czero": "Counter-Strike: Condition Zero", + "czeror": "Counter-Strike: Condition Zero Deleted Scenes", + "dark-forces": "Star Wars: Dark Forces", + "dark-forces-ii": "Star Wars Jedi Knight: Dark Forces II", + "deco-cassette": "Data East DECO Cassette System", + "descent2": "Descent II", + "dingoo-a320": "Dingoo A320", + "dinothawr": "Dinothawr game", + "discrete-arcade": "Discrete logic arcade games", + "dmc": "Deathmatch Classic", + "doom": "Doom", + "doom3-bfg": "Doom 3: BFG Edition", + "dos": "MS-DOS", + "dragon32": "Dragon 32", + "dragon64": "Dragon 64", + "droidworks": "Star Wars: DroidWorks", + "duke-nukem-2": "Duke Nukem II", + "duke3d": "Duke Nukem 3D", + "dusklight": "The Legend of Zelda: Twilight Princess", + "efo-cedar-magnet": "EFO Cedar Magnet System", + "eighting-zn1": "Eighting ZN-1", + "elektor-tv-games-computer": "Elektor TV Games Computer", + "emerson-arcadia-2001": "Emerson Arcadia 2001", + "enemy-territory-quake-wars": "Enemy Territory: Quake Wars", + "enemyterritory": "Wolfenstein: Enemy Territory", + "enterprise-128": "Enterprise 128", + "enterprise-64": "Enterprise 64", + "entex-advision": "Entex Adventure Vision", + "epoch-scv": "Epoch Super Cassette Vision", + "epochcv": "Epoch Cassette Vision", + "eternal-computer": "Eternal Software Initiative computer", + "exelvision-exl100": "Exelvision EXL 100", + "exhumed": "PowerSlave", + "exidy-maxaflex": "Exidy Max-A-Flex", + "fallout": "Fallout", + "famicom": "Nintendo Famicom", + "flash": "Adobe Flash", + "flashback": "Flashback", + "fm77av": "Fujitsu FM77AV", + "fpinball": "Future Pinball tables", + "fruitmachine": "Fruit machines", + "fury": "Ion Fury game", + "gaelco-powervr": "Gaelco PowerVR boards", + "galaksija": "Galaksija computer", + "game": "Picross puzzle (libretro sample)", + "gamecom": "Tiger Game.com", + "gameplan-mpu1": "Game Plan MPU-1", + "gamepock": "Epoch Game Pocket Computer", + "gametank": "GameTank console", + "gba": "Nintendo Game Boy Advance", + "gearbox": "Half-Life: Opposing Force", + "ghostship": "Super Mario 64", + "glulx": "Glulx", + "gottlieb-system1": "Gottlieb System 1", + "half-life-2": "Half-Life 2", + "handheld-electronic": "Nintendo Game & Watch", + "heart-of-darkness": "Heart of Darkness", + "heretic": "Heretic", + "heroes-of-might-and-magic-2": "Heroes of Might and Magic II", + "hexen": "Hexen", + "hexen2": "Hexen II", + "hppa": "HP PA-RISC", + "hugo": "Hugo", + "hyper-neogeo64": "SNK Hyper Neo Geo 64", + "ibm-at": "IBM PC/AT", + "ibm-pcjr": "IBM PCjr", + "ibm-ps1": "IBM PS/1", + "ibm-xt": "IBM PC XT", + "icarus-starship-command-simulator": "Icarus Starship Command Simulator", + "igs-pgm": "IGS PolyGame Master", + "imax-macs": "I'Max MACS", + "interact": "Interact Home Computer", + "interton-vc4000": "Interton VC 4000", + "itech-eagle": "Incredible Technologies Eagle", + "jacl": "JACL", + "jedi-academy": "Star Wars Jedi Knight: Jedi Academy", + "jedi-outcast": "Star Wars Jedi Knight II: Jedi Outcast", + "jediacademy": "Star Wars Jedi Knight: Jedi Academy", + "jediknight2": "Star Wars Jedi Knight II: Jedi Outcast", + "jupiter-ace": "Jupiter Cantab Jupiter Ace", + "konami-bubble-system": "Konami Bubble System", + "konami-bubblesystem": "Konami Bubble System", + "konami-bubsys": "Konami Bubble System", + "konami-endeavour": "Konami Endeavour", + "konami-gq": "Konami GQ", + "konami-gv": "Konami GV", + "konami-gx": "Konami GX", + "konami-python": "Konami Python", + "konami-python2": "Konami Python 2", + "konami-twinkle": "Konami Twinkle", + "konami-viper": "Konami Viper", + "lambda-8300": "Lambda 8300", + "laserdisc-arcade": "Laserdisc arcade games", + "level9": "Level 9", + "lowresnx": "LowRes NX programs", + "lynx": "Atari Lynx", + "m68k": "Motorola 68000", + "magnetic-scrolls": "Magnetic Scrolls", + "marty": "Fujitsu FM Towns Marty", + "mega-cd": "Sega Mega-CD", + "mega-cd-32x": "Sega Mega-CD 32X", + "mega-duck": "Welback Mega Duck", + "mega-ld": "Sega Mega LD", + "megacd": "Sega Mega-CD", + "microdigital-tk85": "Microdigital TK85", + "microsoft-msx2": "MSX2", + "microsoft-xbox-360": "Microsoft Xbox 360", + "midway-mcr": "Midway MCR", + "mips": "MIPS", + "mips64": "MIPS64", + "miuchiz": "Miuchiz", + "mohaa": "Medal of Honor: Allied Assault", + "mohaa-breakthrough": "Medal of Honor: Allied Assault Breakthrough", + "mohaa-spearhead": "Medal of Honor: Allied Assault Spearhead", + "msx1": "MSX", + "msx2": "MSX2", + "msx2+": "MSX2+", + "msx2plus": "MSX2+", + "msxturboR": "MSX turbo R", + "msxturbor": "MSX turbo R", + "mysteries-of-the-sith": "Star Wars Jedi Knight: Mysteries of the Sith", + "myvision": "Nichibutsu My Vision", + "mz2000": "Sharp MZ-2000", + "mz2500": "Sharp MZ-2500", + "mz700": "Sharp MZ-700", + "mz800": "Sharp MZ-800", + "mz80k": "Sharp MZ-80K", + "nam": "NAM", + "namco-na1": "Namco NA-1", + "namco-nb1": "Namco NB-1", + "namco-system11": "Namco System 11", + "namco-system246": "Namco System 246", + "namco-system256": "Namco System 256", + "namco-system357": "Namco System 357", + "namco-system573": "Konami System 573", + "naomi2": "Sega NAOMI 2", + "napalm": "NAPALM", + "nec-pc-80": "NEC PC-8001", + "nec-supergrafx": "NEC SuperGrafx", + "neo-geo-pocket-color": "SNK Neo Geo Pocket Color", + "neogeo64": "SNK Hyper Neo Geo 64", + "ngp": "SNK Neo Geo Pocket", + "ngpc": "SNK Neo Geo Pocket Color", + "nichibutsu-hrdvd": "Nichibutsu High Rate DVD", + "nintendo-64dd": "Nintendo 64DD", + "nintendo-aleck64": "Nintendo Aleck64", + "nintendo-ds": "Nintendo DS", + "nintendo-dsi": "Nintendo DSi", + "nintendo-fds": "Nintendo Famicom Disk System", + "nintendo-gb": "Nintendo Game Boy", + "nintendo-gba": "Nintendo Game Boy Advance", + "nintendo-gbc": "Nintendo Game Boy Color", + "nintendo-nss": "Nintendo Super System", + "nintendo-playch10": "Nintendo PlayChoice-10", + "nintendo-sfcbox": "Nintendo Super Famicom Box", + "nintendo-sgb": "Nintendo Super Game Boy", + "nintendo-virtualboy": "Nintendo Virtual Boy", + "nintendo-wii-arcade": "Nintendo Wii arcade boards", + "nmk16": "NMK16", + "oberon": "Project Oberon", + "olivetti-pc128": "Olivetti Prodest PC 128", + "omicron": "Omicron cartridges", + "onscripter": "ONScripter visual novels", + "openbor": "OpenBOR games", + "opengoal": "Jak and Daxter", + "oric": "Tangerine Oric", + "oricatmos": "Oric Atmos", + "outlaws": "Outlaws", + "palm-os": "Palm OS", + "pc-engine-ld": "NEC PC Engine LD-ROM2", + "pc60": "NEC PC-6001", + "pc80": "NEC PC-8001", + "pce-tourvision": "TourVision", + "pdark": "Perfect Dark", + "pegasus": "Aamber Pegasus", + "pgm": "IGS PolyGame Master", + "philips-p2000t": "Philips P2000T", + "philips-vg5k": "Philips VG 5000", + "picocomputer-6502": "Picocomputer 6502 programs", + "pocket-challenge-v2": "Benesse Pocket Challenge V2", + "powerslave": "PowerSlave", + "ppc": "PowerPC", + "ppc64": "PowerPC 64-bit", + "prey": "Prey", + "prince-of-persia": "Prince of Persia", + "ps4": "Sony PlayStation 4", + "pv1000": "Casio PV-1000", + "quadrilateral-cowboy": "Quadrilateral Cowboy", + "quake2": "Quake II", + "quake3": "Quake III Arena", + "quake4": "Quake 4", + "realrtcw": "Return to Castle Wolfenstein (RealRTCW)", + "recel-system3": "Recel System III", + "redneck": "Redneck Rampage", + "ricochet": "Ricochet", + "ringo-r470": "Ringo R470", + "riscv": "RISC-V", + "riscv32": "RISC-V 32-bit", + "riscv64": "RISC-V 64-bit", + "rogue": "Quake II: Ground Zero", + "rott": "Rise of the Triad", + "rpgmaker": "RPG Maker games", + "s390x": "IBM z/Architecture (s390x)", + "sam-coupe": "MGT SAM Coupe", + "sammy-atomiswave": "Sammy Atomiswave", + "sammy-medal": "Sammy medal games", + "science-of-cambridge-mk14": "Science of Cambridge MK14", + "scott-adams": "Scott Adams adventures", + "scott-adams-plus": "Scott Adams Plus adventures", + "scummvm": "ScummVM games", + "scv": "Epoch Super Cassette Vision", + "sdlpal": "The Legend of Sword and Fairy", + "sega-atomiswave": "Sammy Atomiswave", + "sega-dreamcast-arcade": "Sega Dreamcast-based arcade boards", + "sega-genesis": "Sega Genesis", + "sega-isgsm": "Sega ISG Selection Master", + "sega-mega-cd": "Sega Mega-CD", + "sega-megacd": "Sega Mega-CD", + "sega-megaplay": "Sega Mega Play", + "sega-megatech": "Sega Mega-Tech", + "sega-model2": "Sega Model 2", + "sega-naomi": "Sega NAOMI", + "sega-naomi2": "Sega NAOMI 2", + "sega-naomigd": "Sega NAOMI GD-ROM", + "sega-outrun": "OutRun", + "sega-pico": "Sega Pico", + "sega-sg1000": "Sega SG-1000", + "sega-shootingzone": "Sega Shooting Zone", + "sega-system-sp": "Sega System SP", + "sega-system16b": "Sega System 16B", + "sega-systemsp": "Sega System SP", + "sega-vmu": "Sega Dreamcast VMU", + "serious-sam": "Serious Sam", + "sg-1000": "Sega SG-1000", + "sielectronics-y2": "SiElectronics System Y2", + "sinclair-ql": "Sinclair QL", + "sinclair-spectrum": "Sinclair ZX Spectrum", + "sinclair-zx-spectrum": "Sinclair ZX Spectrum", + "sinclair-zx-spectrum-next": "ZX Spectrum Next", + "sinclair-zx80-81": "Sinclair ZX80 and ZX81", + "sinclair-zxspectrum": "Sinclair ZX Spectrum", + "skin-deep": "Skin Deep", + "smbx": "Super Mario Bros. X", + "sms": "Sega Master System", + "snk-neogeo-aes": "SNK Neo Geo AES", + "snk-neogeo-mvs": "SNK Neo Geo MVS", + "snk-ngp": "SNK Neo Geo Pocket", + "snk-ngpc": "SNK Neo Geo Pocket Color", + "soh": "The Legend of Zelda: Ocarina of Time", + "sonic-1-2013": "Sonic the Hedgehog (2013)", + "sonic-2-2013": "Sonic the Hedgehog 2 (2013)", + "sonic-mania": "Sonic Mania", + "soniccd": "Sonic CD", + "sony-psp": "Sony PlayStation Portable", + "sparc": "SPARC", + "sparc64": "SPARC64", + "spectravideo": "Spectravideo", + "star-control-2": "Star Control II", + "starship": "Star Fox 64", + "strife": "Strife", + "sunplus-native32": "Sunplus Native32", + "sunplus-spmp8000": "Sunplus SPMP8000", + "super-famicom": "Nintendo Super Famicom", + "super-kaneko-nova": "Kaneko Super Nova System", + "super-mario-war": "Super Mario War game", + "superacan": "Funtech Super A'Can", + "supergrafx": "NEC SuperGrafx", + "supergrafx-cd": "NEC SuperGrafx CD", + "solarus": "Solarus games", + "supertux": "SuperTux game", + "supertuxkart": "SuperTuxKart game", + "supracan": "Funtech Super A'Can", + "symbian": "Symbian OS", + "tab-silverball": "TAB Austria Silverball", + "tads": "TADS", + "taito-cchip": "Taito C-Chip", + "taito-fx1": "Taito FX-1", + "taito-gnet": "Taito G-NET", + "taito-type-x": "Taito Type X", + "taito-typezero": "Taito Type Zero", + "tamagotchi-p1": "Bandai Tamagotchi P1", + "tandy-1000": "Tandy 1000", + "taylormade": "TaylorMade", + "teamarena": "Quake III: Team Arena", + "tecmo-tps": "Tecmo TPS", + "tfc": "Team Fortress Classic", + "the-dark-mod": "The Dark Mod", + "thomson-mo6": "Thomson MO6", + "thomson-to7": "Thomson TO7", + "thomson-to770": "Thomson TO7/70", + "thomson-to8": "Thomson TO8", + "thomson-to8d": "Thomson TO8D", + "thomson-to9": "Thomson TO9", + "thomson-to9plus": "Thomson TO9+", + "tic80": "TIC-80 cartridges", + "tiger-game-com": "Tiger Game.com", + "timex-tc2048": "Timex Computer 2048", + "timex-ts1500": "Timex Sinclair 1500", + "timex-ts2068": "Timex Sinclair 2068", + "tomb-raider": "Tomb Raider", + "tomb-raider-1": "Tomb Raider", + "tomb-raider-2": "Tomb Raider II", + "transport-tycoon-deluxe": "Transport Tycoon Deluxe", + "tyrian": "Tyrian", + "ur-quan-masters": "Star Control II", + "urban-terror": "Urban Terror", + "uzebox": "Uzebox", + "vaporspec": "VaporSpec cartridges", + "vc4000": "Interton VC 4000", + "vg5k": "Philips VG 5000", + "vgmplay": "Video game music files", + "videobrain": "Umtech VideoBrain", + "videosystem-zn1": "Video System ZN-1", + "vircon32": "Vircon32 cartridges", + "virtuality-su2000": "Virtuality SU2000", + "vm-labs-nuon": "VM Labs Nuon", + "vpinball": "Visual Pinball tables", + "vvvvvv": "VVVVVV game", + "watara-supervision": "Watara Supervision", + "wonderswan-color": "Bandai WonderSwan Color", + "ww2gi": "World War II GI", + "x86": "x86 PC", + "x86-64": "x86-64 PC", + "xatrix": "Quake II: The Reckoning", + "xrick": "Rick Dangerous", + "ym2608": "Yamaha YM2608", + "zc210": "Zelda Classic", + "zeldaclassic": "Zelda Classic", + "zn1-acclaim": "Acclaim ZN-1", + "zn1-atlus": "Atlus ZN-1", + "zn1-capcom": "Capcom ZN-1", + "zn1-taito": "Taito ZN-1", + "zn1-tecmo": "Tecmo ZN-1", + "zn1-video-system": "Video System ZN-1", + "zn2-capcom": "Capcom ZN-2", + "zx-spectrum": "Sinclair ZX Spectrum", + "zx-spectrum-128": "Sinclair ZX Spectrum 128", +} diff --git a/tests/test_site_r2_p0.py b/tests/test_site_r2_p0.py new file mode 100644 index 00000000..9dd6f1b1 --- /dev/null +++ b/tests/test_site_r2_p0.py @@ -0,0 +1,120 @@ +"""One display name per system id (round 2, package P0). + +An emulator page listed the folders a system's files were voted onto: +nintendo-gba read "Nintendo Game Boy", camplynx "Arcade", and MAME +"emulated FBNeo". siterender.system_name names a system from the console +section that is its own, then from SYSTEM_NAMES, and raises otherwise. +database.json carries no system id: its files reach a page through their +console folders, so the ids to name are the platforms' and the profiles'. +""" + +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import siterender # noqa: E402 +from common import ( # noqa: E402 + list_registered_platforms, + load_emulator_profiles, + load_platform_config, +) +from generate_site import _group_by_manufacturer # noqa: E402 + + +def _profile_system_ids(profiles: dict[str, dict]) -> set[str]: + ids: set[str] = set() + for profile in profiles.values(): + ids.update(str(sys_id) for sys_id in profile.get("systems") or []) + for entry in profile.get("files") or []: + if not isinstance(entry, dict): + continue + if entry.get("system"): + ids.add(str(entry["system"])) + ids.update(str(sys_id) for sys_id in entry.get("systems") or []) + return ids + + +class SystemNames(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + with open(REPO_ROOT / "database.json", encoding="utf-8") as handle: + db = json.load(handle) + cls.manufacturers = _group_by_manufacturer(db) + cls.profiles = load_emulator_profiles( + str(REPO_ROOT / "emulators"), skip_aliases=False) + siterender.set_console_sections( + cls.manufacturers, siterender.program_names(cls.profiles)) + platforms_dir = str(REPO_ROOT / "platforms") + cls.platform_ids = { + sys_id + for name in list_registered_platforms(platforms_dir, include_archived=True) + for sys_id in load_platform_config(name, platforms_dir).get("systems", {}) + } + cls.profile_ids = _profile_system_ids(cls.profiles) + + def _all_ids(self) -> set[str]: + return self.platform_ids | self.profile_ids + + def test_every_system_id_has_a_name(self) -> None: + unnamed = [] + for sys_id in sorted(self._all_ids()): + try: + name = siterender.system_name(sys_id) + except KeyError: + unnamed.append(sys_id) + continue + self.assertTrue(name.strip(), sys_id) + self.assertEqual(unnamed, [], "add these to siterender.SYSTEM_NAMES") + + def test_no_name_is_a_manufacturer_page_or_an_emulator(self) -> None: + taken = {name.casefold() for name in self.manufacturers} + taken |= {name.casefold() for name in siterender.program_names(self.profiles)} + clashes = sorted( + (sys_id, siterender.system_name(sys_id)) + for sys_id in self._all_ids() + if siterender.system_name(sys_id).casefold() in taken + ) + self.assertEqual(clashes, []) + + def test_table_entries_are_reached(self) -> None: + """A table entry an own section shadows is never read, and an entry + no platform or profile names is a leftover.""" + shadowed = sorted( + sys_id for sys_id in siterender.SYSTEM_NAMES + if siterender._own_section(sys_id) is not None + ) + self.assertEqual(shadowed, []) + unused = sorted(set(siterender.SYSTEM_NAMES) - self._all_ids()) + self.assertEqual(unused, []) + + def test_names_that_read_a_folder_vote(self) -> None: + self.assertEqual(siterender.system_name("nintendo-gba"), "Nintendo Game Boy Advance") + self.assertEqual(siterender.system_name("camplynx"), "Camputers Lynx") + self.assertEqual(siterender.system_name("atari-lynx"), "Atari Lynx") + self.assertEqual(siterender.system_name("arcade"), "Arcade games") + self.assertEqual(siterender.system_name("sega-naomi"), "Sega NAOMI") + + def test_own_section_names_from_its_heading(self) -> None: + self.assertEqual(siterender.system_name("nintendo-snes"), "Nintendo SNES") + self.assertEqual(siterender.system_name("sony-playstation"), "Sony PlayStation") + + def test_an_unknown_id_raises(self) -> None: + with self.assertRaises(KeyError): + siterender.system_name("no-such-system-anywhere") + + def test_mame_names_systems_not_emulators(self) -> None: + names = {siterender.system_name(s) for s in self.profiles["mame"].get("systems") or []} + self.assertNotIn("FBNeo", names) + self.assertNotIn("Arcade", names) + self.assertFalse(names & siterender.program_names(self.profiles)) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index f3bf2fc1..353e260c 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -56,7 +56,7 @@ Every module in `tests/`, by the area it guards. | Handheld port | stubbed PortMaster dialogs, fake installer | `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_check_freshness`, `test_dedup`, `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_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_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `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_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `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_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `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_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture