From 12a3dc34ab08d099e97015586d75aedddab2bc48 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:11:37 +0100 Subject: [PATCH 01/11] fix: cores, tick lists and cards as the run reads Work in progress of code_audit_24 and tui_audit_17: the Cores screen chooses among the cores the run accepts (set_aside event), one rule for the three tick lists, the dest error said on the result, ArkOS cards found by their games part. --- install.py | 717 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 419 insertions(+), 298 deletions(-) diff --git a/install.py b/install.py index c9894999b..b21fa2cb6 100644 --- a/install.py +++ b/install.py @@ -670,6 +670,13 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # a --system out of: it said 12 where Choose systems listed 11. A kind # of its own, since schema 1 never adds a key to check. "choices": ("platform", "systems"), + # Right after choices: the dests of the list's files the run sets aside + # before any choice, those read from a save directory whose place is + # not known among them, in list order. What Cores lists and counts is + # the list without them: it listed 189 cores where --core accepted 181, + # and an untick sent the 8 others to a run that refused them. A kind of + # its own, since schema 1 never adds a key to choices. + "set_aside": ("platform", "dests"), # state: present (hash verified), wrong_hash or missing. "file": ("platform", "dest", "systems", "size", "state"), "system": ("platform", "system", "name", "files", "present", "missing", @@ -4416,6 +4423,26 @@ def available_choices(files: list[dict], omitted: list[dict]) -> dict[str, list[ } +def choosable_cores(files: list[dict], omitted: list[dict], + systems: list[str] = ()) -> list[str]: + """The cores a choice is made among, for the files a run keeps once + the save entries it cannot place and the game data --no-game-data cuts + are left out, of the systems chosen (every system for none). Cores on + the screen and c in line mode list these and count "N of M" out of + them: the screen listed the whole file list's 189 where the run + accepted 181.""" + if systems: + files, omitted = narrow(files, omitted, list(systems), [], []) + return available_choices(files, omitted)["cores"] + + +def core_files(files: list[dict], core: str) -> list[dict]: + """The files one core adds to the platform's own: what line mode's + Cores question and the screen's Cores rows size.""" + return _filter_by_target([entry for entry in files if entry.get("cores") is not None], + [core]) + + def resolve_choices( values: list[str], known: list[str], label: str, aliases: dict[str, str] | None = None, @@ -5896,6 +5923,12 @@ def read_only_words(path: Path) -> str: "read-write, then run again.") +# What Termux needs to write shared storage, said by the plan, the result +# and line mode alike. +TERMUX_STORAGE_WORDS = ("Run termux-setup-storage once and allow the storage permission " + "Android asks for, then run it again.") + + def write_remedy(path: Path, plat: str, hint: Invocation, commands: bool = True) -> list[str]: """What the screens say a user can do about a folder they cannot write: the read-only remedy, or the same choice run as an administrator, the @@ -5904,6 +5937,10 @@ def write_remedy(path: Path, plat: str, hint: Invocation, commands: bool = True) --offline. commands is False where no command can be typed (a pad).""" if _on_read_only_mount(path): return [read_only_words(path)] + if hint.os_type == "android": + # Termux has no sudo: its shared storage is reached once Android + # gives it the storage permission (tui_audit_17 P2-2). + return [TERMUX_STORAGE_WORDS] if not commands: return [] if hint.os_type == "windows": @@ -5944,8 +5981,7 @@ def _write_advice(path: Path, assumed: bool, plat: str = "", f" {hint.command(plat, path)}\n" " Or choose another folder with --dest.") if plat and hint.os_type == "android": - return (" This user cannot write there. Run termux-setup-storage once to give " - "Termux the shared storage, then run it again:\n" + return (f" This user cannot write there. {TERMUX_STORAGE_WORDS}\n" f" {hint.command(plat, path)}\n" " Or choose another folder with --dest.") if plat: @@ -7397,7 +7433,6 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: P1-2). Another system's card or drive, and one two systems fit, are reached through their own setup (drives_left_out).""" targets = [] - cards = unnamed_cards() for mount in _mounted_cards(): found = card_system(mount) if (found is not None and found[0] != plat) or share_doubt(mount)[0]: @@ -7410,23 +7445,16 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: # A drive its files show to be no card is called a drive, under # the note that says so: "card or drive" sat under "nothing on # it shows a handheld's card" (tui_audit_16 P3-9). - what = "SD card" if mount in cards else "drive" - targets.append((f"{_drive_folder(mount, 'bios')} ({what})", mount / "bios")) - # A handheld's card no file names first: its bios folder is where its - # system reads (unnamed_cards). - first = {mount / "bios" for mount in cards} - return sorted(targets, key=lambda target: target[1] not in first) + targets.append((f"{_drive_folder(mount, 'bios')} (drive)", mount / "bios")) + return targets def folder_drives(os_type: str, plat: str, side: bool = True) -> list[tuple[str, Path]]: """The folders the folder question numbers for a setup that runs from no - card, in the one order both modes give them: a handheld's card in the - reader first, then the other side's usual folder (side, under WSL), - then the other drives. Line mode put Windows RetroArch before the card - the screen put first (code_audit_23 P3-6).""" - drives = [*(other_side(os_type, plat) if side else []), *drive_targets(plat)] - first = {mount / "bios" for mount in unnamed_cards()} - return sorted(drives, key=lambda target: target[1] not in first) + card, in the one order both modes give them: the other side's usual + folder (side, under WSL), then the drives. Line mode and the screen + gave them in two orders (code_audit_23 P3-6).""" + return [*(other_side(os_type, plat) if side else []), *drive_targets(plat)] # What a handheld system writes on the games part a computer mounts, beside @@ -7447,8 +7475,9 @@ _GAMES_PART_RULES = ( # themes/ is no proof: the 12262020 update left it on the system part of # a card with under 1 GB free (Update-RGB10.sh:59-69), and 2020's trees # hold none (code_audit_23 P3-1). - _OsRule("retroarch", "ArkOS", "games_part", ("bgmusic",), ""), - _OsRule("retroarch", "ArkOS", "games_part", ("launchimages",), ""), + # Its list is RetroArch's, written in the bios folder of that part. + _OsRule("retroarch", "ArkOS", "games_part", ("bgmusic",), "bios"), + _OsRule("retroarch", "ArkOS", "games_part", ("launchimages",), "bios"), # AmberELEC 5a3882cb fills GAMES at every boot: homebrew/ from its ports # with gamelist.xml (packages/amberelec/sources/autostart.sh:93-94, # 110-113) and gamedata/ for RetroArch, PPSSPP, DOSBox and Hatari @@ -7526,44 +7555,22 @@ def _games_part_matches(rule: _OsRule, root: Path) -> bool: def games_part(root: Path) -> _OsRule | None: """The rule naming the handheld system whose games part root is, by the files that system writes there, None for none (_GAMES_PART_RULES). The - one reading of a games part: AmberELEC's and EmuELEC's are their own - cards everywhere (card_system, their card routes, line mode), ArkOS's a - card of a system that reads RetroArch's list (unnamed_cards).""" + one reading of a games part, for the three systems alike: each is its + system's own card everywhere (card_system, the first screen, the card + routes, line mode). ArkOS's was a card "of a system that reads + RetroArch's list", named nowhere and called a card RetroBIOS cannot + tell on the ArkOS route (tui_audit_17 P2-3).""" return next((rule for rule in _GAMES_PART_RULES if _games_part_matches(rule, root)), None) -def _unnamed_candidates() -> list[Path]: - """The mounted drives no file names, no SHARE either, holding a bios - folder: a handheld's card, or any drive someone keeps an emulation - library on.""" +def unproven_drives() -> list[Path]: + """The drives with a bios folder nothing shows to be a handheld's card, + no SHARE either: offered at the folder question, never its default.""" return [mount for mount in _mounted_cards() if card_system(mount) is None and not share_doubt(mount)[0] and _is_dir(mount / "bios")] -def unnamed_cards() -> list[Path]: - """The mounted drives no file names that are a handheld's games part - read with RetroArch's list: ArkOS's EASYROMS, told by the folders it - writes there (games_part). Which of the systems that read RetroArch's - list runs them cannot be told, but they are a handheld's card: - "Nothing was found here" over one, and two presses of Enter filled this - computer's folder for a RetroArch that was not there (tui_audit_14 - P2-3). A bios folder and a console folder were the rule, and a data - drive or WSL's D: disk with bios/, snes/ and Music/ took the list - (code_audit_22 P2-1). AmberELEC's GAMES and EmuELEC's EEROMS are their - systems' own cards (card_system): RetroArch's list went on them under - Enter (tui_audit_16 P2-1).""" - return [mount for mount in _unnamed_candidates() - if getattr(games_part(mount), "manifest", "") == "retroarch"] - - -def unproven_drives() -> list[Path]: - """The drives with a bios folder nothing shows to be a handheld's card: - offered at the folder question, never its default.""" - cards = unnamed_cards() - return [mount for mount in _unnamed_candidates() if mount not in cards] - - def _names_and(names: list[str]) -> str: """Names in a sentence: A, A and B, A, B and C.""" return " and ".join(names) if len(names) < 3 else f"{', '.join(names[:-1])} and {names[-1]}" @@ -7581,15 +7588,6 @@ def unproven_words() -> str: f"Pick {it} below if your files go there.") -def card_folder_words(cards: list[Path], card: Path) -> str: - """Where card, one of the handhelds' cards in the reader, keeps the - files, as both modes say it: the card named where it is one of two - (code_audit_22 P3-2), BIOS files named where "keeps them" followed the - title Which folder? and no noun (tui_audit_16 P3-9).""" - which = "The card in the reader" if len(cards) == 1 else _mount_name(card) - return f"{which} keeps BIOS files in its bios folder" - - def _said_with(label: str, words: str) -> str: """label with words in its parenthesis, or in one of their own: two side by side read as a slip (tui_audit_16 P3-9).""" @@ -7609,23 +7607,6 @@ def numbered_folder(label: str, folder: Path) -> str: return _said_with(label, ", ".join(said)) if said else label -def unnamed_card_words(cards: list[Path], tail: str = ".") -> str: - """What the first screen and line mode say of those cards: one card, - its system, two cards, their systems (code_audit_22 P3-2).""" - names = ", ".join(_mount_name(mount) for mount in cards) - if len(cards) == 1: - return f"A card is in the reader ({names}): choose its system{tail}" - return f"Cards are in the reader ({names}): choose their systems{tail}" - - -def say_unnamed_cards() -> None: - """Line mode's words for the cards no file names, where it says what - was not found here.""" - cards = unnamed_cards() - if cards: - print(f" {unnamed_card_words(cards)}") - - def _drive_folder(mount: Path, below: str) -> str: """A folder on a mounted drive as a row names it: D:\\bios where the drive is a letter (Windows, and WSL's /mnt/d), USBSTICK: bios where it @@ -7678,18 +7659,9 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, name = display_name(plat) shared = plat in _SHARE_FOLDERS and found is None default = found if found is not None else None if shared else _default_dest(os_type, plat) - # A handheld's card in the reader and no setup found here: Enter puts - # the files on the card, never in this computer's folder for a RetroArch - # that is not there (tui_audit_14 P2-3). - cards = [] if shared or found is not None else unnamed_cards() print(f"\nWhich folder do {name}'s BIOS files go to?") if shared: print(f" {share_words(plat)}") - elif cards: - default = cards[0] / "bios" - # Two cards: the one Enter takes is named, "The card" was one of - # two (code_audit_22 P3-2). - print(f" {card_folder_words(cards, cards[0])}: {shown_path(default)}.") elif found is not None: print(f" {name} reads its BIOS files from: {shown_path(found)}.") elif default is not None: @@ -7699,11 +7671,6 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, # A drive is offered by the BIOS folder its system reads, the screen's # rows: never its root, and never for a system that reads another list. targets = (share_targets(plat) if shared else folder_drives(os_type, plat, found is None)) - if cards: - # The card first, this computer's usual folder after it. - targets = [*targets, (f"{side_name(os_type, plat)}'s usual folder", - _default_dest(os_type, plat))] - targets = [target for target in targets if target[1] is not None] for index, (label, folder) in enumerate(targets, 1): print(f" {index}) {numbered_folder(label, folder)}") left_out = "" if shared else drives_left_out(plat) @@ -7717,8 +7684,7 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, why = ("" if default is not None or shared else " (no default, a folder above could not be opened)" if _default_set_aside(os_type, plat) else " (no default on this system)") - enter = (f"the folder {name} reads" if found is not None - else "the card's bios folder" if cards else "the default above") + enter = f"the folder {name} reads" if found is not None else "the default above" prompt = (f"BIOS folder{numbers}, q to quit (Enter for {enter}): " if default else f"BIOS folder{numbers}, q to quit{why}: ") usage = f" Type a folder{', a number' if targets else ''}, or q to quit." @@ -8228,6 +8194,9 @@ def folder_warnings(plat: str, bios_path: Path, files: list[dict], hint: Invocat return lines +CANNOT_WRITE = "You cannot write to this folder" + + def cannot_write_words(bios_path: Path, plat: str, hint: Invocation, commands: bool, card: bool, screen: bool = False, remedy: bool = True) -> list[str]: """What the plan says of a folder this user cannot write, one remedy @@ -8240,7 +8209,7 @@ def cannot_write_words(bios_path: Path, plat: str, hint: Invocation, commands: b another = ("choose another one" if screen else "run it again with another --dest" if commands else "run it again and choose another one") - return [f"You cannot write to this folder: {another}."] + said + return [f"{CANNOT_WRITE}: {another}."] + said def space_words(needed: int, free: int, card: bool) -> str: @@ -8482,10 +8451,12 @@ def _print_menu_head( f"for {held} of {total} systems" + (" (--system)" if flag else "") if systems else "", # Out of the cores the systems chosen offered, as Cores counts - # them: "1 core chosen" read as one of one. - ("--core" if flag else cores_words(len(cores), len(available_choices( - *narrow(files, omitted, systems, [], []))["cores"]))) if cores else "", - ("--region" if flag else "regions ranked") if regions else ""])) + # them: "1 core chosen" read as one of one. A flag typed is + # named after the words, never alone: "--region." stood as a + # sentence of its own (tui_audit_17 P3-4). + core_count_words(cores, choosable_cores(files, omitted, systems)) + + (" (--core)" if flag else "") if cores else "", + "regions ranked" + (" (--region)" if flag else "") if regions else ""])) named = "" if named == ", " else named if current is not given: print("\n Counted again for your choices. Nothing is written before Enter.") @@ -8665,18 +8636,15 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N kept, kept_omitted = narrow(files, omitted, systems, [], []) cores: list[str] = [] - core_names = available_choices(kept, kept_omitted)["cores"] + core_names = choosable_cores(kept, kept_omitted) if len(core_names) > 1: print("\nThe platform's own files for these systems are always kept.") # Every core is kept, as Cores draws them all ticked: a list of # numbers read as nothing chosen yet. print("Every core's extra files are kept: Enter keeps them all, numbers keep " "only those cores.") - cores = _pick("Cores:", [ - (core, _filter_by_target( - [f for f in kept if f.get("cores") is not None], [core])) - for core in core_names - ], sizer=_core_sized) + cores = _pick("Cores:", [(core, core_files(kept, core)) for core in core_names], + sizer=_core_sized) kept, kept_omitted = narrow(kept, kept_omitted, [], cores, []) regions: list[str] = [] @@ -11865,9 +11833,10 @@ def _card_rules() -> list[_OsRule]: rules.append(rule._replace( values=tuple(value[len(mount):] for value in rule.values), bios=rule.bios[len(mount):], configs=())) - # The games part of a system whose own list is written below it: its - # files name the card as surely as Onion's .tmp_update (games_part). - rules += [rule for rule in _GAMES_PART_RULES if rule.name in _GAMES_PART_CARDS] + # The games part of a handheld system: the files it writes there name + # the card as surely as Onion's .tmp_update (games_part), ArkOS's as + # AmberELEC's (tui_audit_17 P2-3). + rules += list(_GAMES_PART_RULES) return rules @@ -12191,7 +12160,8 @@ def _card_is(setup: CardSetup, card: Path, above: bool = False) -> tuple[str, st if family: return ("own" if setup.plat in CARD_PLATFORMS else "other"), display_name(family) names = sorted({rule.name for rule in _card_matches(card, above)}) - if setup.name in names: + if setup.name in names or _built_on(setup.name) in names: + # dArkOS writes ArkOS's games part: an ArkOS card is its own. return "own", setup.name share = share_drive(card) if not names and share and not (share == "batocera" and setup.name == "KNULLI"): @@ -12202,6 +12172,11 @@ def _card_is(setup: CardSetup, card: Path, above: bool = False) -> tuple[str, st return ("other", " and ".join(names)) if names else ("unknown", "") +def _built_on(name: str) -> str: + """The system name is built on (dArkOS on ArkOS), "" for none.""" + return next((rule.base for rule in _EMBEDDED_RULES if rule.name == name and rule.base), "") + + def _not_the_card(setup: CardSetup, folder: Path, card: Path, other: str) -> CardChoice: """folder refused for setup: card, its root, is another system's card (other names it) or names no system at all. The detail names what is @@ -12210,9 +12185,15 @@ def _not_the_card(setup: CardSetup, folder: Path, card: Path, other: str) -> Car marker = (CARD_MARKER if setup.plat in CARD_PLATFORMS else setup.rules[0].values[0].split("/")[0] if setup.rules else "") detail = f"{name} keeps {marker} at the root of its card" if marker else "" - if setup.name in _GAMES_PART_CARDS and setup.rules: - held = _names_and([value.split("/")[0] for value in setup.rules[0].values]) - detail = f"{name} keeps {held} beside bios on the games part of its card" + games = [rule for rule in setup.rules if rule.method == "games_part"] + if games: + # The file the rule reads, never its first folder: AmberELEC's card + # holds a bezels folder too, and EmuELEC's refusal named it as the + # mark of an EmuELEC card (code_audit_24 P3-7). + held = " or ".join(dict.fromkeys(_names_and(list(rule.values)) for rule in games)) + where = ("on the games part of its card" if setup.name in _GAMES_PART_CARDS + else "on its card") + detail = f"{name} keeps {held} beside bios {where}" if " and " in other: return CardChoice(setup.plat, folder, refused=f"{shown} holds the files of {other}, " f"not of {name}", detail=detail, @@ -12831,7 +12812,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # RetroArch first, as the screen offers them. Its folder question # alone left ArkOS, muOS and JELOS out of reach (tui_audit_11 P1-2). print(f" {display_name(args.platform)} was not found here.") - say_unnamed_cards() platforms = _prompt_manual_platform( os_type, not (args.check or args.dry_run or args.yes), True, f"Which setup is the {display_name(args.platform)} list for?", setups, mounted) @@ -12840,7 +12820,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # filled with a card mounted in the reader, and with nothing # found here, without a question. print(f" {display_name(args.platform)} was not found here.") - say_unnamed_cards() platforms = [(args.platform, ask_platform_folder( os_type, args.platform, not (args.check or args.dry_run or args.yes)))] elif getattr(args, "platform_from_list", False): @@ -12938,9 +12917,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if not platforms: # The screen's words where a person answers: "platform" was said # nowhere else. - unnamed = unnamed_cards() if interactive and not listing else [] - print(f" {unnamed_card_words(unnamed)}" - if unnamed else " Nothing was found here." if interactive and not listing + print(" Nothing was found here." if interactive and not listing else " No supported platform detected.") EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[]) # Standalone emulators read their BIOS from folders of their own, @@ -13252,6 +13229,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # systems: the screen reads it from this event. EVENTS.emit("choices", platform=plat_name, systems=choosable_systems( bare, bare_omitted, *(selection[1:] if selection else ()))) + kept_dests = {entry.get("dest") for entry in files} + EVENTS.emit("set_aside", platform=plat_name, dests=[ + entry.get("dest") for entry in manifest.get("files") or [] + if entry.get("dest") not in kept_dests]) # What declining the size question points back at, when it was asked. smaller = "" given = selection @@ -14529,7 +14510,7 @@ def _folded(text: str) -> str: # What a check run tells the screen, kept whole when Choose systems widens a # --system plan: Back with the same ticks gives that plan back. -_CHECK_FIELDS = ("check", "files", "systems", "choices", "found", "scanned", "unreadable", +_CHECK_FIELDS = ("check", "files", "systems", "choices", "set_aside", "found", "scanned", "unreadable", "saves", "check_summary", "checked_flags", "cli_systems", "facts", "ticked", "version", "checked_dests") @@ -14565,9 +14546,6 @@ class TuiState: # (platform, folder) of the setups that are a card in a reader, not # this machine: a setup chosen for that system asks for its card. self.mounted_setups: set = set() - # The mounted drives that are a handheld's card no file names - # (unnamed_cards): their bios folder comes first at Which folder?. - self.unnamed: list = [] self.notes: list = [] # The folders detection could not look in, one line each. self.refused: list = [] @@ -14671,6 +14649,15 @@ class TuiState: self.check: dict | None = None # The systems the check's list lets one choose among (choices event). self.choices: list | None = None + # The install's error event when its BIOS folder could not be made. + self.dest_error: dict | None = None + # The dests the check set aside before any choice (the set_aside + # event), None before it says: Cores chooses among the rest. + self.set_aside: set | None = None + # The run's file list and the Cores rows' sizes, kept while the + # list, the set aside files and the game data choice stay. + self.run_files_cache: tuple | None = None + self.core_sizes_cache: tuple | None = None self.files: list = [] # Every destination the check named, in place or not: the second # spellings a folder that keeps case installs are among them. @@ -15170,16 +15157,20 @@ def tui_wants_mouse(state: TuiState, lines: list[str]) -> bool: return not any(getattr(line, "command", False) for line in lines) -def tui_flags(state: TuiState) -> list[tuple[str, str | None]]: - """The options that carry the choices made on screen, CLI ones included.""" - flags = list(state.passthrough) +def _tui_system_flags(state: TuiState) -> list[tuple[str, str | None]]: + """The --system the screen's ticks stand for.""" needed = {row["system"] for row in state.systems if row["missing"] + row["wrong_hash"] > 0} if state.systems and not needed <= state.ticked: - flags += [("--system", name) for name in sorted(state.ticked)] - else: - flags += [("--system", name) for name in state.cli_systems] - flags += [("--core", name) for name in state.cores] + return [("--system", name) for name in sorted(state.ticked)] + return [("--system", name) for name in state.cli_systems] + + +def tui_flags(state: TuiState) -> list[tuple[str, str | None]]: + """The options that carry the choices made on screen, CLI ones included.""" + flags = list(state.passthrough) + flags += _tui_system_flags(state) + flags += [("--core", name) for name in tui_core_flags(state)] if state.regions: flags.append(("--region", ",".join(state.regions))) if state.no_game_data: @@ -15369,10 +15360,27 @@ def region_title(name: str) -> str: word.capitalize() for word in name.split("-")) -def tui_choices(state: TuiState) -> dict[str, list[str]]: - """What the file list lets one narrow to, read from the list itself.""" +def tui_run_files(state: TuiState) -> tuple[list[dict], list[dict]]: + """The files and omissions the run chooses among, as the check counted + them: the list without what it set aside before any choice (the save + entries of a save directory it cannot place), the game data left out + when the plan leaves it out. Line mode chooses among the same (bare).""" manifest = state.manifest or {} - return available_choices(manifest.get("files", []), manifest.get("omitted_files", [])) + key = (id(state.manifest), frozenset(state.set_aside or ()), state.no_game_data) + if state.run_files_cache is not None and state.run_files_cache[0] == key: + return state.run_files_cache[1] + aside = state.set_aside or set() + files = [entry for entry in manifest.get("files") or [] if entry.get("dest") not in aside] + omitted = list(manifest.get("omitted_files") or []) + if state.no_game_data: + files, omitted = leave_out_game_data(files, omitted)[:2] + state.run_files_cache = (key, (files, omitted)) + return files, omitted + + +def tui_choices(state: TuiState) -> dict[str, list[str]]: + """What the run lets one narrow to, read from the files it chooses among.""" + return available_choices(*tui_run_files(state)) def core_ticked(state: TuiState, name: str) -> bool: @@ -15383,26 +15391,85 @@ def core_ticked(state: TuiState, name: str) -> bool: def _core_base(state: TuiState) -> list[str]: """The cores a choice on Cores is made among: those of the systems - chosen, every core of the list else.""" - every = tui_choices(state)["cores"] - theirs = _chosen_cores(state) - return every if theirs is None else [name for name in every if name in theirs] + chosen, every core the run accepts else (choosable_cores, as line + mode counts them).""" + return choosable_cores(*tui_run_files(state), _tui_systems_chosen(state)) + + +def tui_cores_ticked(state: TuiState) -> int: + """The cores of the systems chosen that are ticked: a core of another + system ticked counted toward "all cores" (code_audit_24 P3-2).""" + base = _core_base(state) + if not state.cores: + return 0 if state.no_cores else len(base) + return len(set(state.cores) & set(base)) + + +def tui_core_flags(state: TuiState) -> list[str]: + """The --core the run is given: the cores ticked, none when every core + of the systems chosen is (the run keeps the same files).""" + if not state.cores or set(_core_base(state)) <= set(state.cores): + return [] + return list(state.cores) + + +def _core_right(size: str | None) -> str: + """A Cores row's right-hand text: what the core adds, or that it adds + nothing (line mode's NO_CORE_FILES, in the room of a row).""" + return size if size and size != NO_CORE_FILES else "no extra file" + + +def _core_narrow(size: str | None) -> tuple: + if not size or size == NO_CORE_FILES: + return ("none",) + return tuple(form for form in [re.sub(r"^\d+ files?, ", "", size)] if form != size) + + +def tui_core_sizes(state: TuiState) -> dict[str, str]: + """What each core adds for the systems chosen, in line mode's words.""" + systems = _tui_systems_chosen(state) + files = tui_run_files(state) + key = (id(files), tuple(systems)) + if state.core_sizes_cache is not None and state.core_sizes_cache[0] == key: + return state.core_sizes_cache[1] + kept = narrow(*files, systems, [], [])[0] if systems else files[0] + extra = [entry for entry in kept if entry.get("cores") is not None] + by_core: dict[str, list[dict]] = {} + for entry in extra: + for copy in [entry, *entry.get("alternatives", []), *entry.get("slot_choices", [])]: + for core in copy.get("cores") or []: + listed = by_core.setdefault(core, []) + if not listed or listed[-1] is not entry: + listed.append(entry) + sizes = {core: _core_sized(core_files(held, core)) + for core, held in by_core.items()} + state.core_sizes_cache = (key, sizes) + return sizes def cores_words(chosen: int, total: int) -> str: """The cores a run keeps, as the plan, Options and line mode say it: all cores, or 3 of 12 cores.""" - return "all cores" if not chosen or chosen >= total else f"{chosen} of {total} cores" + return "all cores" if chosen >= total else f"{chosen} of {total} cores" + + +def core_count_words(cores: list[str], base: list[str]) -> str: + """cores_words for the cores named out of those the systems chosen + offer: a core of another system counted toward "all cores", the run + keeping none of the system's own (code_audit_24 P3-2).""" + return cores_words(len(set(cores) & set(base)), len(base)) + + +def _tui_systems_chosen(state: TuiState) -> list[str]: + """The systems the run is narrowed to, [] for every system.""" + return [value for flag, value in _tui_system_flags(state) if value] def _chosen_cores(state: TuiState) -> set[str] | None: - """The cores the files of the systems chosen name, None when every - system is chosen.""" - systems = [value for flag, value in tui_flags(state) if flag == "--system" and value] - if not systems: + """The cores of the systems chosen, None when every system is chosen.""" + if not _tui_systems_chosen(state): return None - files = narrow((state.manifest or {}).get("files") or [], [], systems, [], [])[0] - return set(available_choices(files, [])["cores"]) + return set(_core_base(state)) def tui_systems(state: TuiState) -> list[str]: @@ -15451,14 +15518,6 @@ def tui_rows(state: TuiState) -> list[TuiRow]: value=folder) for label, folder in folder_drives(state.os_type, state.platform, state.folder_kind == "usual")] - cards = {mount / "bios" for mount in state.unnamed} - if state.folder_kind == "usual" and any(row.value in cards for row in drives): - # A handheld's card in the reader, no setup found here: the - # card first, under A (tui_audit_14 P2-3). - first = [row for row in drives if row.value in cards] - return first + rows + [TuiRow("browse", "Browse for a folder"), - TuiRow("type", "Type a path")] + [ - row for row in drives if row not in first] return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + drives # Only the cards whose own files say they run the system: A on the @@ -15512,27 +15571,35 @@ def tui_rows(state: TuiState) -> list[TuiRow]: order += [name for name in [*state.regions, *known] if name not in order] ranks = {name: str(rank) for rank, name in enumerate(state.regions, 1)} return [TuiRow("region", f"[{ranks.get(name, ' ')}] {region_title(name)}", value=name) - for name in order] + for name in order] + _REGIONS_END if screen == "cores": needle = state.filters.get(screen, "") + # The cores the run accepts (choosable_cores): the whole list's + # 189 held 8 that read only the save directory the run set aside, + # and an untick sent them to a run that refused them (tui_audit_17 + # P1-1). every = tui_choices(state)["cores"] theirs = _chosen_cores(state) # The cores of the systems chosen first, the others behind a row: # with --system sony-playstation the list opened on a5200 and - # amiberry, 189 cores long (tui_audit_13 P3-12). A core ticked - # stays in view. + # amiberry, 189 cores long (tui_audit_13 P3-12). A core the command + # line named stays in view. shown = every if theirs is None or state.cores_all else [ - name for name in every if name in theirs or name in state.cores] + name for name in every if name in theirs or name in state.cli_cores] + sizes = tui_core_sizes(state) # Every core ticked when no core narrows the run, as Choose systems # ticks every system: all boxes empty read as nothing chosen, and - # the owner ticked each one by hand. - rows = [TuiRow("all", "All"), TuiRow("none", "None"), _filter_row(state)] + [ - TuiRow("core", f"[{'x' if core_ticked(state, name) else ' '}] {name}", value=name) + # the owner ticked each one by hand. Each with what it adds, as + # line mode sizes it (tui_audit_17 P3-2). + rows = [_filter_row(state)] + [ + TuiRow("core", f"[{'x' if core_ticked(state, name) else ' '}] {name}", + _core_right(sizes.get(name)), name, "" if sizes.get(name) else "dim", + _core_narrow(sizes.get(name)), keep_right=bool(sizes.get(name))) for name in shown if _matches(name, needle)] hidden = len(every) - len(shown) if hidden: rows.append(TuiRow("morecores", f"Cores of the other systems: {hidden}")) - return rows + return rows + _CHECKLIST_END if screen == "confirm": if state.running or state.dry is None: return [] @@ -15777,7 +15844,7 @@ def _short_of_space(state: TuiState, check: dict) -> bool: def _system_rows(state: TuiState) -> list[TuiRow]: needle = state.filters.get("systems", "") titles = _system_titles(state) - rows = [TuiRow("all", "All"), TuiRow("none", "None"), _filter_row(state)] + rows = [_filter_row(state)] shared = _shared_needed(state) for row in _sorted_systems(state): label = _tui_system_label(row, titles) @@ -15805,7 +15872,22 @@ def _system_rows(state: TuiState) -> list[TuiRow]: # beside a total of 2.7 KB. The name is cut instead. rows.append(TuiRow("system", f"{box} {label}", right, row["system"], "" if need else "dim", narrow, keep_right=bool(need))) - return rows + return rows + _CHECKLIST_END + + +# The rows a list of ticks ends on, the same on Choose systems, Cores and +# Preferred regions: below the items, so the focus a list opens on is its +# first item and A there ticks it. All and None sat above the items, and A +# on the None the systems opened on unticked every system (tui_audit_17 +# P3-1). Preferred regions has no All: every region ranked in the order +# drawn is no choice anyone made, and none ticked keeps every region. +_CHECKLIST_END = [TuiRow("all", "All"), TuiRow("none", "None"), TuiRow("done", "Done")] +_REGIONS_END = [TuiRow("none", "None"), TuiRow("done", "Done")] + + +def _first_item(state: TuiState) -> int: + """The row a list of ticks opens on: its first item.""" + return next((i for i, row in enumerate(tui_rows(state)) if row.action in TUI_TICK_ROWS), 0) def _shared_needed(state: TuiState) -> dict[str, tuple[int, int]]: @@ -15851,10 +15933,12 @@ def _option_rows(state: TuiState) -> list[TuiRow]: rows.append(TuiRow("installed", f"Installed cores only: " f"{_yes(state.installed_only)} ({found} found)", "--installed-cores")) - if choices["cores"]: - # The plan's words, the noun left to the row's own label. + if len(choices["cores"]) > 1: + # The plan's words, the noun left to the row's own label. A list + # with one core asks nothing, as line mode does not (tui_audit_17 + # P3-3). rows.append(TuiRow("cores", "Cores: " + cores_words( - len(state.cores), len(_core_base(state))).replace(" cores", ""), "--core")) + tui_cores_ticked(state), len(_core_base(state))).replace(" cores", ""), "--core")) copied = ", ".join(str(folder) for folder in state.from_folders) or "none" if manifest.get("save_directory"): # The interface said "Name it with --save-dir" and had no row for it. @@ -15864,8 +15948,12 @@ def _option_rows(state: TuiState) -> list[TuiRow]: # From a pack, or offline, nothing is downloaded (tui_audit_11 P3-16). rows.append(TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs")) rows += [ - TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest", - style="path"), + # The plan's folder: AmberELEC's read the games part here and its + # bios folder on the plan, two values under one name (tui_audit_17 + # P3-6). --dest stays the games part, as the command line takes it. + TuiRow("folder", "BIOS folder: " + + _short_path(_plan_folder(state, state.dest), state.home), + "--dest", style="path"), TuiRow("from", f"Copy from: {copied}", "--from", style="path"), *([TuiRow("addapp", f"Put RetroBIOS on this card: {_yes(state.add_app)}", "--add-app")] if _tui_app_ready(state) else []), @@ -15928,10 +16016,13 @@ def _back(state: TuiState) -> list[tuple]: state.stack.append("quit") state.focus["quit"] = 0 return effects - if state.screen == "systems" and not state.ticked and state.ticked_before: - # Enter on None, then Esc: the plan kept its headline over an - # Install the run refused. - state.ticked = set(state.ticked_before) + refused = _none_ticked(state) + if refused: + # Done and B leave a list of ticks alike, never with nothing + # ticked: said on every length of list, in the line the rows + # leave (tui_audit_17 P2-4). + state.status = refused + return effects chose = state.screen == "systems" and state.ticked != (state.ticked_before or set()) if state.screen == "systems" and state.widened_from is not None: before, state.widened_from = state.widened_from, None @@ -15943,9 +16034,6 @@ def _back(state: TuiState) -> list[tuple]: chose = False if state.screen == "regions": state.region_order = [] - if state.screen == "cores" and state.no_cores: - # None, then Esc: no core ticked is no choice, and every core is kept. - state.no_cores = False state.stack.pop() if chose and state.screen == "plan": # The systems chosen, the next press is Install: Back left the focus @@ -16022,7 +16110,7 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: # dropped the --system the command line gave. state.check, state.files, state.systems, state.reading = None, [], [], None state.checked_dests = set() - state.choices = None + state.choices = state.set_aside = None state.found, state.scanned, state.unreadable = [], [], [] state.check_summary = state.saves = None state.version = state.version_short = "" @@ -16031,7 +16119,7 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: state.dry, state.dry_files, state.dry_summary = None, [], None state.dry_saves = None else: - state.done_saves = None + state.done_saves = state.dest_error = None state.download = state.progress = state.reading = None state.samples, state.local_dests, state.network = [], {}, 0 state.finished, state.summary, state.code, state.stopping = [], None, None, False @@ -16666,56 +16754,15 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _start_check(state) if action == "systems": state.ticked_before = set(state.ticked) - if action == "systems" and len(state.systems) > TUI_LONG_LIST: - # On None: picking one system of 66 began with Up, Up, A. - return _push(state, action, 1) - # On the first system, so A continues and the D-pad ticks. - return _push(state, action, 3 if action == "systems" else 0) - if screen == "systems": - if action == "all": - state.ticked = {row["system"] for row in state.systems} - elif action == "none": - state.ticked = set() - # On the first system: the next A takes the one in focus alone. - state.focus["systems"] = next( - (i for i, item in enumerate(tui_rows(state)) if item.action == "system"), - state.focus.get("systems", 0)) - else: - if action == "system" and not state.ticked: - # Nothing ticked: A takes the system in focus, and only it. - state.ticked = {row.value} - # Continue, never Install: with the ticks unchanged A here - # installed 6.6 GB at once, offline too where the plan offered - # no Install. The confirm screen counts the choice and waits. - if state.systems and not state.ticked: - state.status = "No system is ticked: tick one, or choose All." - return [] - state.must_confirm = True - _push(state, "confirm") - return _start_run(state, "confirm") + # On the first system, A ticking it: Choose systems opened on None, + # and A there unticked every system (tui_audit_17 P3-1). + _push(state, action) + state.focus[action] = _first_item(state) return [] + if screen in (*_CHECKLISTS, "regions"): + return _checklist(state, row) if screen == "options": return _option(state, action, 0) - if screen == "regions": - _toggle_region(state, row.value) - return [] - if screen == "cores": - if action == "morecores": - state.cores_all = True - return [] - if action == "all": - state.cores, state.no_cores = [], False - return [] - if action == "none": - state.cores, state.no_cores = [], True - state.focus["cores"] = next( - (i for i, item in enumerate(tui_rows(state)) if item.action == "core"), - state.focus.get("cores", 0)) - return [] - if state.no_cores: - state.status = "No core is ticked: tick one, or choose All." - return [] - return _back(state) if screen == "confirm": if action == "install": state.stack.pop() @@ -16742,6 +16789,45 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return [] +def _checklist(state: TuiState, row: TuiRow) -> list[tuple]: + """A and Enter on Choose systems, Cores and Preferred regions, one rule + for the three: an item is ticked or unticked, All and None set every + box, Done leaves as B does (_back), refused while nothing is ticked.""" + screen, action = state.screen, row.action + if action == "done": + return _back(state) + if action == "morecores": + state.cores_all = True + return [] + if action == "region": + _toggle_region(state, row.value) + elif action in TUI_TICK_ROWS: + _tick(state, row, None) + elif action == "all" and screen == "systems": + state.ticked = {item["system"] for item in state.systems} + elif action == "all": + state.cores, state.no_cores = [], False + elif action == "none" and screen == "regions": + state.regions = [] + elif action == "none": + if screen == "systems": + state.ticked = set() + else: + state.cores, state.no_cores = [], True + # On the first item: the next A ticks the one in focus alone. + state.focus[screen] = _first_item(state) + return [] + + +def _none_ticked(state: TuiState) -> str: + """Why Done is refused on a list of ticks, "" when it is not.""" + if state.screen == "systems" and state.systems and not state.ticked: + return "No system is ticked: tick one, or choose All." + if state.screen == "cores" and state.no_cores: + return "No core is ticked: tick one, or choose All." + return "" + + def _systems_reading() -> dict[str, list[str]]: """The device systems that get each platform's files, as detection knows them: someone on muOS or ArkOS looks for that name, not RetroArch.""" @@ -17198,12 +17284,13 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]: elif action == "cores": # On the first core: the filter row under A opened a field a # controller cannot type into. - return _push(state, action, 3 if tui_choices(state)["cores"] else 2) + _push(state, action) + state.focus[action] = _first_item(state) elif action == "regions": state.region_order = [] _push(state, action) - state.region_order = [row.value for row in tui_rows(state)] - return [] + state.region_order = [row.value for row in tui_rows(state) if row.action == "region"] + state.focus[action] = _first_item(state) elif action == "command": return _push(state, action) elif action == "folder": @@ -17316,12 +17403,15 @@ def _tick(state: TuiState, row: TuiRow, on: bool | None) -> None: if value not in state.cores: state.cores.append(value) state.no_cores = False - if set(_core_base(state)) <= set(state.cores): + if set(tui_choices(state)["cores"]) <= set(state.cores): # Every core ticked again: no narrowing, as before. state.cores = [] elif not state.cores and not state.no_cores: - # Every core was ticked: one untick keeps the others. - state.cores = [name for name in _core_base(state) if name != value] + # Every core was ticked, those of the other systems too: one + # untick keeps every other one ticked. Kept to the systems chosen, + # it cleared the other systems' cores drawn ticked (code_audit_24 + # P3-1). + state.cores = [name for name in tui_choices(state)["cores"] if name != value] state.no_cores = not state.cores elif value in state.cores: state.cores.remove(value) @@ -17544,7 +17634,7 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: state.editing = screen state.focus[screen] = next(i for i, item in enumerate(rows) if item.action == "filter") return [] - elif screen == "regions" and (name == "enter" or char == " "): + elif screen == "regions" and char == " " and row.action == "region": return _activate(state, row) elif screen == "regions" and name in ("left", "shift-up"): _move_region(state, row.value, -1) @@ -17556,11 +17646,14 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: char == " " or name in ("right", "left")): _tick(state, row, None if char == " " else name == "right") return [] - elif screen in _CHECKLISTS and char == " ": - # X on All, None or the filter: only A, an explicit choice, acts there. + elif screen in (*_CHECKLISTS, "regions") and char == " ": + # X on All, None, Done or the filter: only A, an explicit choice, + # acts there. return [] - elif screen == "systems" and char in ("a", "n"): - return _activate(state, rows[0 if char == "a" else 1]) + elif screen in (*_CHECKLISTS, "regions") and char in ("a", "n"): + # All and None by name, at the end of a list of 189 rows. + named = [item for item in rows if item.action == ("all" if char == "a" else "none")] + return _checklist(state, named[0]) if named else [] elif screen == "options" and name in ("left", "right"): return _option(state, row.action, 1 if name == "right" else -1) \ if row.action in ("game_data", "installed", "jobs", "addapp") \ @@ -17875,6 +17968,11 @@ def _take_event(state: TuiState, event: dict) -> None: if kind == "app" and state.run == "install": state.app = event return + if kind == "error" and state.run == "install" and event.get("stage") == "dest": + # The BIOS folder could not be created: every file fails for that + # one reason, said as it happened (tui_audit_17 P2-1). + state.dest_error = event + return if kind == "checking" and state.run in ("check", "install"): state.reading = (event["files_done"], event["files_total"]) elif kind == "found" and state.run == "check": @@ -17885,6 +17983,8 @@ def _take_event(state: TuiState, event: dict) -> None: state.unreadable.append(event) elif kind == "choices" and state.run == "check": state.choices = list(event["systems"]) + elif kind == "set_aside" and state.run == "check": + state.set_aside = set(event["dests"]) elif kind == "version" and state.run == "check": state.version = str(event["message"]) state.version_short = (f"List for {display_name(state.platform, state.manifest)} " @@ -18012,15 +18112,6 @@ _SHORT_KEYS = (("A/Enter/tap ", "A/tap "), ("A/Enter ", "A "), ("B/Esc ", "B "), ("X/Space ", "X "), ("Y/o ", "Y "), ("Left/Right ", "<> "), ("Up/Down ", "^v ")) -def _focus_ticks(state: TuiState) -> bool: - """Whether the row in focus is one X or Space ticks.""" - rows = tui_rows(state) - if not rows: - return False - row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] - return row.action in ("system", "core") - - def _back_hint(state: TuiState, parts: tuple) -> tuple: """The bar's B/Esc entry as Esc acts on this screen: a filter typed or set is cleared first, and the first screen quits. Two first screens @@ -18052,25 +18143,16 @@ def _route_hint(state: TuiState, parts: tuple, width: int | None = None) -> tupl return parts names = route.bar named = [] - ticks = state.pad == "miyoo" and state.screen in _CHECKLISTS and _focus_ticks(state) - if state.pad == "miyoo" and state.screen in _CHECKLISTS and not ticks: - # A is Enter on All, None and the filter (_miyoo_key): the bar said - # A Tick there, and A unticked every system. - parts = tuple(part for part in parts if not part.startswith("X/Space ")) for part in parts: key, sep, what = part.partition(" ") if key in names: - # On a Miyoo A is Space, which ticks a system: Start continues. - mine = "Start" if ticks and key == "A/Enter" else names[key] + mine = names[key] if mine is None: continue key = mine named.append(key + sep + what) if state.pad == "muxterm" and state.screen == "path": named.append("Select Keyboard") - if ticks: - tick = [entry for entry in named if entry.startswith("A ")] - named = named[:1] + tick + [entry for entry in named[1:] if entry not in tick] return tuple(named) @@ -18080,14 +18162,14 @@ def _tap_hint(state: TuiState, parts: tuple) -> tuple: a finger, which went to the row and continued.""" if not state.mouse or state.help or state.screen not in (*_CHECKLISTS, "regions"): return parts - keys = ("A/Enter", "A") if state.screen == "regions" else ("X/Space", "Space") + keys = ("A/Enter", "A", "Enter", "Start") return tuple(f"{part.split(' ', 1)[0]}/tap {part.split(' ', 1)[1]}" if part.endswith(" Tick") and part.split(" ", 1)[0] in keys else part for part in parts) # Shorter verbs for a narrow bar, tried before an entry is dropped. -_SHORT_VERBS = ((" Only this system", " Only this"), (" First system", " First")) +_SHORT_VERBS = ((" Untick all", " None"), (" Tick all", " All")) # Below this many columns of text the bar always uses the short key names. @@ -18128,8 +18210,8 @@ _ROW_VERBS = { "default": "Use this folder", "browse": "Browse", "type": "Type a path", "use": "Use this folder", "up": "Go up", "dir": "Open", "create": "Create", "other": "Choose another", "cardroot": "Use the root", "all": "Tick all", - "none": "Untick all", "filter": "Filter", "system": "Continue", "core": "Done", - "region": "Tick", "regions": "Open", "cores": "Open", "command": "Open", + "none": "Untick all", "filter": "Filter", "system": "Tick", "core": "Tick", + "region": "Tick", "done": "Done", "regions": "Open", "cores": "Open", "command": "Open", "preview": "Open", "folder": "Choose", "game_data": "Change", "installed": "Change", "jobs": "Change", "retry": "Try again", "back": "Back", "stay": "Stay", "fromcard": "Choose", "from": "Add a folder", "misplaced": "Show", "zip": "Copy from it", @@ -18242,16 +18324,19 @@ def _whole_words(state: TuiState) -> bool: return state.screen in ("result", "command") -def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> list[TuiLine]: +def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0, + count: int | None = None) -> list[TuiLine]: """height lines of a longer list from first, the last one saying where. - The lead rows (All, None, Filter) are not counted among the entries.""" + The lead rows (a Filter) are not counted among the entries, nor what + follows the count entries (All, None and Done).""" if len(lines) <= height: return lines shown = max(1, height - 1) first = max(0, min(first, len(lines) - shown)) end = first + shown - count = len(lines) - lead - where = f"{max(1, first + 1 - lead)}-{max(1, end - lead)} of {count}" + count = len(lines) - lead if count is None else count + start = min(count, max(1, first + 1 - lead)) + where = f"{start}-{max(start, min(count, end - lead))} of {count}" return lines[first:end] + [_styled(where, "dim")] @@ -18295,9 +18380,12 @@ def _compose( first = top else: state.list_top.pop(state.screen, None) - lead = next((i for i, row in enumerate(rows) - if row.action not in ("all", "none", "filter")), 0) - body = _scrolled(row_lines, first, room, lead) + ticks = [i for i, row in enumerate(rows) if row.action in TUI_TICK_ROWS] + lead = ticks[0] if ticks else next( + (i for i, row in enumerate(rows) if row.action not in ("all", "none", "filter")), 0) + # A list of ticks counts its items: the Filter above them and All, + # None and Done below are no entries of it. + body = _scrolled(row_lines, first, room, lead, len(ticks) if ticks else None) if len(row_lines) > room: body[-1].mark = ("scroll", "list", len(rows)) else: @@ -18752,11 +18840,7 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: # A handheld's owner knows its device, not its system's name: the # filter finds it, and a pad that cannot type finds the cards right # after RetroArch (_cards_first). - pinned.append(unnamed_card_words(state.unnamed, ", or type your device's name (/)." - if state.pad in ("both", "keys") else - ", handhelds after RetroArch.") - if state.unnamed else - "Nothing was found here. Choose the setup, or type your device's " + pinned.append("Nothing was found here. Choose the setup, or type your device's " "name (/)." if state.pad in ("both", "keys") else "Nothing was found here. Choose the setup: handhelds come after " "RetroArch.") @@ -18812,11 +18896,6 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: f"{side_name(state.os_type, state.platform)} usually reads its BIOS " "files from:") before = [lead, str(state.folder_default)] - if (state.folder_kind == "usual" and tui_rows(state) - and tui_rows(state)[0].value in {mount / "bios" for mount in state.unnamed}): - cards = [mount for mount in state.unnamed - if mount / "bios" == tui_rows(state)[0].value] - first = [f"{card_folder_words(state.unnamed, cards[0])}, first below."] elif state.folder_kind == "closed": title = "Which folder?" before = [f"Choose another folder for {name}: browse for it or type it."] @@ -19025,13 +19104,18 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: at += 1 named = [*([f"for {len(state.systems)} of {len(tui_systems(state))} systems (--system)"] if state.cli_systems and state.systems else []), - # Named on the command line as line mode names it, chosen on - # Cores out of how many: "--core" said nothing of the count. - *([("--core" if state.cores == state.cli_cores else - cores_words(len(state.cores), len(_core_base(state))))] - if state.cores else []), - *(["--region"] if state.regions else [])] + # Named as line mode names it, out of how many, the flag after + # the words where the command line gave it: "--core" said + # nothing of the count, and "--region." stood alone + # (tui_audit_17 P3-4). + *([core_count_words(state.cores, _core_base(state)) + + (" (--core)" if state.cores == state.cli_cores else "")] + if tui_core_flags(state) else []), + *(["regions ranked" + (" (--region)" if state.regions == state.cli_regions + else "")] if state.regions else [])] said = ", ".join(named) + if said and not said.startswith("for "): + said = f"for your choice: {said}" # A system --system named that --no-game-data left empty, in line mode's # words (tui_audit_16 P3-7). listed = {row["system"] for row in state.systems} @@ -19147,6 +19231,10 @@ def _plan_body(state: TuiState, width: int, height: int, lines: list, kept += lines[at:end] kept += [line for line in lines if state.version and str(line) in (state.version, state.version_short)] + # Why the folder rows are offered, before Install anyway: at 40x12 the + # plan offered Choose another folder and Install anyway and no word of + # why (tui_audit_17 P2-2). + kept += [line for line in lines if str(line).startswith(CANNOT_WRITE)] top = _text_lines(kept, width) drawn = [str(line).strip() for line in body] # Three lines at least for the rows: two of them and where the list is, @@ -19271,12 +19359,15 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple: # held 4, 3 and 5 systems as the note came and went. footer.insert(0, " ") needle = state.filters.get("systems", "") - after = [f"No system matches '{needle}'."] if len(rows) == 3 and needle else [] + if needle and not any(row.action == "system" for row in rows): + footer.insert(0, f"No system matches '{needle}'.") + # In the lines the rows leave on every length of list: below the rows + # it was dropped once they filled the screen (tui_audit_17 P2-4). if state.status: - after.append(state.status) + footer.insert(0, _styled(str(state.status), "refusal")) return ("Choose systems", - _compose(state, width, height, [], rows, after, footer), - ("A/Enter Continue", "B/Esc Back", "X/Space Tick", "? Help")) + _compose(state, width, height, [], rows, (), footer), + _CHECKLIST_BAR) def _view_options(state: TuiState, width: int, height: int) -> tuple: @@ -19312,16 +19403,27 @@ def _view_regions(state: TuiState, width: int, height: int) -> tuple: "per region, the best one found is installed. With none ticked, every " "region's BIOS is kept."] return ("Preferred regions", - _compose(state, width, height, before, tui_rows(state)), - ("A/Enter Tick", "B/Esc Done", "Left/Right Order", "? Help")) + _compose(state, width, height, [], tui_rows(state), (), _status_footer(state), + before), + (*_CHECKLIST_BAR[:2], "Left/Right Order", "? Help")) + + +# The bar of the three lists of ticks, one wording (tui_audit_17 P3-1). +_CHECKLIST_BAR = ("A/Enter Tick", "B/Esc Done", "? Help") + + +def _status_footer(state: TuiState) -> list: + return [_styled(str(state.status), "refusal")] if state.status else [] def _view_cores(state: TuiState, width: int, height: int) -> tuple: - before = ["Every core ticked keeps every core's files. Untick the cores you do not " - "use: the platform's own files are always kept."] - status = [state.status] if state.status else [] - return ("Cores", _compose(state, width, height, before, tui_rows(state), status), - ("A/Enter Done", "B/Esc Back", "X/Space Tick", "? Help")) + # Kept above a list that scrolls: dropped once the rows filled the + # screen, 189 cores never said what unticking does (tui_audit_17 P2-4). + pinned = ["Untick the cores you do not use: the platform's own files are always kept, " + "and a core with no extra file reads only those."] + return ("Cores", _compose(state, width, height, [], tui_rows(state), (), + _status_footer(state), pinned), + _CHECKLIST_BAR) _STATE_WORDS = {"missing": "missing", "wrong_hash": "wrong copy"} @@ -19597,6 +19699,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: total = (state.download or {}).get("files", 0) space = "" if summary else _space_words(state) errors = f", {len(failed)} failed" if failed else "" + if state.dest_error is not None: + return _dest_error_outcome(state, installed) if summary.get("interrupted") or state.code == 130: title = "Stopped" head = (f"Stopped: {installed} of {total} installed, run again to finish." @@ -19761,6 +19865,31 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: return title, lines +def _dest_error_outcome(state: TuiState, installed: int) -> tuple[str, list[str]]: + """The result of a run whose BIOS folder could not be created: that + reason for every file, and its remedy. It said "22 files not fetched + (RETROBIOS_INSTALL_OFFLINE=1)" and sent the user for a pack holding all + 22 (tui_audit_17 P2-1).""" + message = str((state.dest_error or {}).get("message", "")) + folder = shown_path(_plan_folder(state, state.dest)) + _where, _sep, why = message.rpartition(": ") + head = (f"Nothing was installed: {folder} cannot be created ({why.lower() or 'no reason'})." + if not installed else f"{installed} installed, then {folder} could not be " + f"created ({why.lower() or 'no reason'}).") + lines = [head] + remedy = (write_remedy(state.dest, state.platform, tui_hint(state), + state.pad not in TUI_PAD_ROUTES) if state.dest is not None else []) + if not remedy or str(remedy[0]).startswith("Or "): + lines.append("Run it again and choose another folder.") + lines += _commands(state, remedy) + if state.pad not in TUI_PAD_ROUTES: + lines += ["Same choice without questions:", + _command_line(f" {tui_command(state)}", tui_hint(state))] + if tui_keeps_log(state): + lines.append(f"Details: {state.log}") + return ("Nothing installed" if not installed else "Finished with errors"), lines + + # How to copy from the result, under its command while the mouse is on. # Termux has no Shift key under a finger: there the second sentence alone # (tui_audit_14 P3-3). @@ -19901,8 +20030,6 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: else [])] primary = _primary(state) or _TUI_VIEWS[screen](state, 80, 24)[2][0] ok = names["ok"] - if state.pad == "miyoo" and screen in _CHECKLISTS: - ok = names["enter"] if primary.startswith("A/"): # A bar that offers no A (Confirm while it counts) names none here. keys.append((ok, primary.split(" ", 1)[-1])) @@ -19911,8 +20038,10 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: # How a tap acts, before what the list says elsewhere: last, it was the # line a 40x12 help cut (code_audit_20 P3-1). keys += _tap_help(state) - if screen == "systems": + if screen in _CHECKLISTS: keys.append(("a, n", "All, none")) + elif screen == "regions": + keys.append(("n", "None")) if screen == "platforms": keys.append(("/", "Filter by setup or device name")) elif screen in ("systems", "cores"): @@ -19936,10 +20065,8 @@ def _tap_help(state: TuiState) -> list[tuple[str, str]]: """How a tap acts on this screen, where the terminal reports clicks.""" if not state.mouse: return [] - if state.screen in _CHECKLISTS: - return [("Tap", "Tick or untick a row, as Space")] - if state.screen == "regions": - # Its keys name no Space: A ticks there (code_audit_20 P3-2). + if state.screen in (*_CHECKLISTS, "regions"): + # One rule for the three lists of ticks: A ticks, a tap as A. return [("Tap", "Tick or untick a row, as A")] if state.screen in _PAGERS and not tui_rows(state): return [("Tap", "A bottom line entry presses its key"), @@ -19971,11 +20098,6 @@ def _primary(state: TuiState) -> str | None: verb = _ROW_VERBS.get(row.action) if row.action == "filter" and _only_match(state) is not None: verb = "Choose" - if state.screen == "systems" and row.action == "system" and not state.ticked: - verb = "Only this system" - if state.screen == "systems" and row.action == "none" and not state.ticked: - # Nothing left to untick: A goes to the first system. - verb = "First system" return f"A/Enter {verb}" if verb else None @@ -20317,7 +20439,6 @@ def tui_start( captured = io.StringIO() with contextlib.redirect_stdout(captured), contextlib.redirect_stderr(captured): state.setups, state.mounted_setups = found_setups(os_type) - state.unnamed = unnamed_cards() frontends = detect_frontends(os_type) # Said on the normal screen before this one covered it, and kept # above the list: a status line under 18 setups was never drawn. From 2ca8491f0a649833d1c042952c0ae2f9cb6fe76f Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:13:24 +0100 Subject: [PATCH 02/11] fix: rerun reads, backups and failed lines --- install.py | 127 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 19 deletions(-) diff --git a/install.py b/install.py index b21fa2cb6..4e6e5ee74 100644 --- a/install.py +++ b/install.py @@ -573,6 +573,7 @@ class Progress: if not ok and not self.term.quiet: why = f" ({reason})" if reason else "" line = f" {self.term.bad('FAILED')} {dest}{why}" + SAID_FAILED.add(dest) elif not self.term.live and not self.term.quiet: line = f" [{self.files}/{self.total_files}] {dest} {self.term.ok('ok')}" if line is not None: @@ -6116,16 +6117,19 @@ def _report_outcome( say(f"\n{format_size(transferred)} {verb} in " f"{format_duration(seconds)} ({format_size(int(transferred / seconds))}/s).") reasons = {reason for _dest, reason in failures or []} - if failures and len(failures) > 1 and len(reasons) == 1 and reasons != {""}: + # A failure the progress already named on its FAILED line is not named + # again: line mode listed each one twice (tui_audit_17 P3-7). + unsaid = [failure for failure in failures or [] if failure[0] not in SAID_FAILED] + if unsaid and len(unsaid) > 1 and len(reasons) == 1 and reasons != {""}: # One cause, said once: a read-only folder listed ten of its files # and "... and 266 more" above the advice that fixes them all. - say(f" failed: {_plural(len(failures), 'file')} ({reasons.pop()}), " - f"first {failures[0][0]}") + say(f" failed: {_plural(len(unsaid), 'file')} ({reasons.pop()}), " + f"first {unsaid[0][0]}") else: - for dest, reason in (failures or [])[:10]: + for dest, reason in unsaid[:10]: say(f" failed: {dest}" + (f" ({reason})" if reason else "")) - if failures and len(failures) > 10: - say(f" ... and {len(failures) - 10} more") + if len(unsaid) > 10: + say(f" ... and {len(unsaid) - 10} more") if declined and not downloaded and not errors and not interrupted and not offline: say(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.") if up_to_date: @@ -6323,6 +6327,11 @@ def _offline_remedy(folders: list[str] | None = None, named: bool = False) -> st f"again with a pack (--from-pack) or {offline_undo()}.") +# The files a FAILED line of the progress named in this run: the end of +# the run names the others. +SAID_FAILED: set[str] = set() + + def _retry_words(failures: list | None) -> str: """What to do when every failure is a file the pack does not hold: a second run fails them the same way, and "run it again to retry only @@ -7862,7 +7871,8 @@ def _prompt_platform_choice( for i, ((plat, path), label) in enumerate(zip(platforms, named), 1): # The label and its folder, as the screen shows them: "Onion SD card # (ONION) (/media/me/ONION/BIOS)" put two pairs of brackets in a row. - print(f" {i}) {label}: {shown_path(setup_bios_folder(plat, path))}") + print(fold_line(f" {i}) {label}: {shown_path(setup_bios_folder(plat, path))}", + " " * (len(str(i)) + 2))) if other is not None: print(" o) another setup or folder") print(" q) quit") @@ -7886,6 +7896,33 @@ def _prompt_platform_choice( print(usage) +def fold_line(text: str, hang: str = " ") -> str: + """A line wrapped between words at the terminal's width, the lines after + the first indented by hang under its own lead, a word wider than a line + cut after a slash: left to the terminal, below 40 columns "Found Onion + at /media/moi/ONION/BIOS" broke inside "(RetroArch files)" and a folder + inside its name (tui_audit_17 P3-8).""" + cols = max(terminal_size()[0] - 1, 8) + if cell_width(text) <= cols: + return text + lead = text[:len(text) - len(text.lstrip(" "))] + prefix = lead + hang + lines: list[str] = [] + line = lead + for word in text.split(): + joined = f"{line} {word}" if line.strip() else line + word + if cell_width(joined) <= cols: + line = joined + continue + if line.strip(): + lines.append(line) + pieces = _split_word(word, max(cols - len(prefix), 1)) + lines += [prefix + piece for piece in pieces[:-1]] + line = prefix + pieces[-1] + lines.append(line) + return "\n".join(lines) + + def _ask(prompt: str) -> str: """An answer, or a stop: Ctrl+D at a question is Ctrl+C. @@ -8521,15 +8558,21 @@ def found_head(files: list[dict], trees: list[dict], bios_path: Path) -> str: except OSError: return False - needed = [] + needed, sized = [], [] for entry in files: try: path = _entry_path(bios_path, entry) except ValueError: continue - if not held(path, entry.get("size")) or \ - _classify_local(entry, path, remember=True) != "up_to_date": + if held(path, entry.get("size")): + sized.append((entry, path)) + else: needed.append(entry) + # Only a copy of the right size is read, on the check's workers and + # with its count: one at a time and silent, 400 files of a card read + # as a hung run before the menu (code_audit_24 P3-4). + needed += [entry for (entry, _path), verdict in zip(sized, read_in_place(sized)) + if verdict != "up_to_date"] copies = plan_local_copies(needed, bios_path, None, LOCAL.folders) if not copies: return "" @@ -8575,12 +8618,35 @@ def all_in_place(files: list[dict], trees: list[dict], bios_path: Path) -> bool: return False except OSError: return False - return (all(_classify_local(entry, path, remember=True) == "up_to_date" - for entry, path in paths) + return (all(verdict == "up_to_date" for verdict in read_in_place(paths)) and all(not data_directory_state(tree, bios_path, remember=True)[1] for tree in trees)) +def read_in_place(pairs: list[tuple[dict, Path]]) -> list[str]: + """_classify_local of each (entry, path), read before the line menu as + the check after it reads: on --jobs workers, a count moving at a + terminal, what is read kept for that check (_HEAD_READS). Each file is + read once whatever the question: the head reads the files of the right + size only, and the check after Enter reads none of them again.""" + if not pairs: + return [] + live = _is_terminal(sys.stdout) + verdicts: list[str] = [] + last = 0.0 + with concurrent.futures.ThreadPoolExecutor(max_workers=LOCAL.jobs) as pool: + for done, verdict in enumerate(pool.map( + lambda pair: _classify_local(pair[0], pair[1], remember=True), pairs), 1): + verdicts.append(verdict) + now = time.monotonic() + if live and (now - last >= 0.1 or done == len(pairs)): + last = now + print(f"\r read {done}/{len(pairs)}", end="", flush=True) + if live: + print("\r\033[K", end="", flush=True) + return verdicts + + def menu_choice( files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None, current: object, cut: bool, @@ -9293,6 +9359,8 @@ class LocalSources: self.pack: PackSource | None = None # The folders named with --from, read once for every platform. self.folders: list[FolderIndex] = [] + # The reads at once of the files in place (--jobs). + self.jobs: int = 8 # Every folder index of the run still in use, closed at its end. self.indexes: weakref.WeakSet[FolderIndex] = weakref.WeakSet() self._serves: tuple = (None, None, frozenset()) @@ -9322,6 +9390,7 @@ class LocalSources: self.manifest = None self.pack = None self.folders = [] + self.jobs = 8 def close_indexes(self) -> None: for index in list(self.indexes): @@ -11464,7 +11533,7 @@ def main() -> None: # unset or dumb said nothing (tui_audit_12 P3-14). said = line_mode_reason(blocker) if said: - print(said) + print(fold_line(said)) if args.events: # The events own stdout. The text a person reads goes to stderr, so a # program never has to tell one from the other. @@ -11527,6 +11596,8 @@ def _open_local_sources(args: argparse.Namespace) -> None: """Take the file list and the pack this run was given, and the platform they are for when none is named.""" LOCAL.reset() + LOCAL.jobs = getattr(args, "jobs", 8) or 8 + SAID_FAILED.clear() _open_from_folders(getattr(args, "from", None) or []) if args.manifest is not None: LOCAL.manifest = args.manifest @@ -12794,7 +12865,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: platforms = matched offer = getattr(args, "platform_from_list", False) for name, path in matched: - print(f" Found {found_label(name, path)}") + print(fold_line(f" Found {found_label(name, path)}")) elif listing: # A listing reads the manifest and writes nothing: no folder is # needed, and asking BizHawk's for one refused a question asked @@ -12948,7 +13019,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: offer = False else: for name, path in platforms: - print(f" Found {found_label(name, path)}") + print(fold_line(f" Found {found_label(name, path)}")) if len(platforms) > 1 and not interactive and not listing: # A run without questions installs for one setup, the one a person @@ -18647,7 +18718,15 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines.append(f"Free space: {format_size(free)}") if check["bytes_needed"] > free: lines.append(space_words(check["bytes_needed"], free, _on_its_card(state))) - backup = wrong and not (offline and not copied) + # The wrong copies something replaces: offline, those a folder copied + # from holds, else those the network or the pack brings too. "The 1 + # wrong copy is replaced" stood over a copy no folder held, which the + # result then said was left (code_audit_24 P3-5). + wrong_dests = [event["dest"] for event in (state.dry_files if dry else state.files) + if event.get("state") == "wrong_hash"] + backup = (sum(1 for dest_ in wrong_dests if dest_ in copied) if offline + else sum(1 for dest_ in wrong_dests if dest_ in copied or dest_ not in lacking) + if wrong_dests else wrong) misplaced = _tui_misplaced(state) if check is state.check else [] if misplaced: lines.insert(after_counts, f"{_plural(len(misplaced), 'file')} " @@ -18660,9 +18739,10 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if backup: # With its subject: "They are replaced" sat lines under the count. # Not where nothing can be installed: nothing is replaced then. - lines.insert(count_at, f"The {_plural(wrong, 'wrong copy')} " - f"{'is' if wrong == 1 else 'are'} replaced, the old " - f"{'one' if wrong == 1 else 'ones'} kept in a backup folder.") + lines.insert(count_at, (f"The {_plural(wrong, 'wrong copy')} " if backup == wrong + else f"{backup} of the {_plural(wrong, 'wrong copy')} ") + + f"{'is' if backup == 1 else 'are'} replaced, the old " + f"{'one' if backup == 1 else 'ones'} kept in a backup folder.") if state.facts.get("writable") is False and dest is not None: lines += _commands(state, write_remedy(dest, state.platform, tui_hint(state), state.pad not in TUI_PAD_ROUTES)) @@ -19817,6 +19897,15 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: shown += 1 if shown > 20: lines.append(f" ... and {shown - 20} more") + if title == "Finished with errors": + # What a rerun does, as line mode and the first install page say + # it: the screen never did (tui_audit_17 P3-7). + pairs = [(failure["dest"], failure["reason"]) for failure in failed] + retry = _retry_words(pairs) + if retry: + lines.append(retry) + elif not all(str(reason).startswith(NOT_KEPT) for _dest, reason in pairs): + lines.append("Run it again to retry only those.") expected = (state.dry if state.dry is not None else state.check or {}).get( "data_missing", 0) landed = max(0, installed - sum(1 for _dest, good, _why in state.finished if good)) From c7f44841514eae1df89291c97212ab6568169af3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:13:37 +0100 Subject: [PATCH 03/11] fix: armed taps and offline list named by setup --- install.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/install.py b/install.py index 4e6e5ee74..b2709f4ba 100644 --- a/install.py +++ b/install.py @@ -4009,7 +4009,7 @@ def fetch_manifest(plat: str) -> dict: else f"the file list could not be fetched: {words}") if code == "offline": print(f" No file list for {plat}: {offline_words()}. Give it with " - "--manifest FILE (a copy of install/.json) or " + f"--manifest FILE (a copy of install/{plat}.json) or " f"--from-pack PACK, or run {offline_undo()}.", file=sys.stderr) sys.exit(1) if code != "network": @@ -17918,31 +17918,43 @@ def _armed_hint(state: TuiState, parts: tuple, width: int) -> tuple: return parts part = parts[at] verb = part.split(" ", 1)[1] - forms = (f"{part}: {TUI_ARMED}", f"{part}: {TUI_ARMED_SHORT}", - f"{part.split(' ', 1)[0]} {' '.join(verb.split()[:3])}: {TUI_ARMED_SHORT}") - # Every entry kept first, then the armed one whole. - for whole in (True, False): + said = armed_words(state, width) + forms = (f"{part}: {said}", + f"{part.split(' ', 1)[0]} {' '.join(verb.split()[:3])}: {said}") + # Every entry kept first, then every one but the help, then the armed + # one whole: on a phone at 40 columns Esc Quit went first + # (tui_audit_17 P3-11). + for keep in (len(parts), len([p for p in parts if not p.startswith("? ")]), 1): for form in forms: armed = (*parts[:at], form, *parts[at + 1:]) drawn = _hint(width, *armed) - if form.split(" ", 1)[1] in drawn and ( - not whole or len(drawn.split(" ")) == len(armed)): + if form.split(" ", 1)[1] in drawn and len(drawn.split(" ")) >= keep: return armed return (*parts[:at], forms[-1], *parts[at + 1:]) +def armed_words(state: TuiState, width: int) -> str: + """What a first tap says on the row and on the bar alike: "tap again" on + a phone, where nothing clicks, and where the bar is short, "click or + tap again" else. The row said one and the bar the other.""" + if state.os_type == "android" or width < TUI_LONG_BAR_WIDTH: + return TUI_ARMED_SHORT + return TUI_ARMED + + def _armed_label(state: TuiState, row: TuiRow, width: int) -> TuiRow: """The row in focus drawn armed as its bar entry is, in the longest words that fit the row. A row that names a choice (a setup, a folder) keeps its name where the words do not fit beside it, the bar saying them: "Continue: tap again" in its place hid which setup was tapped.""" verb = _ROW_VERBS.get(row.action, row.label) - for label in (f"{row.label}: {TUI_ARMED}", f"{row.label}: {TUI_ARMED_SHORT}"): - if cell_width(f"> {label}") <= width: - return row._replace(label=label) + said = armed_words(state, width) + label = f"{row.label}: {said}" + if cell_width(f"> {label}") <= width: + return row._replace(label=label) if row.action not in ("install", "addapp"): return row - return row._replace(label=f"{verb}: {TUI_ARMED_SHORT}") + return row._replace(label=f"{verb}: {said}") # What an armed bar entry adds to its name, drawn in the accent: a mouse @@ -20739,7 +20751,7 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: f"This run makes no network request ({offline_named()}), and the list " "of BIOS files comes from the network.", "Run the installer again with the list saved as a file " - "(--manifest FILE, a copy of install/.json) or with a pack " + f"(--manifest FILE, a copy of install/{plat}.json) or with a pack " f"(--from-pack PACK), or {offline_undo()}."], 1)) return title = {"clock": "The clock is wrong", From c186b168bc3c5fa385f41b7442b01aef29709be8 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:14:09 +0100 Subject: [PATCH 04/11] docs: which-pack names the folder question --- install.py | 12 +++++++----- scripts/generate_site.py | 13 ++++++++----- tests/test_install_tui.py | 4 ++++ tests/test_site_r3_entry.py | 5 ++++- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/install.py b/install.py index b2709f4ba..779a12f13 100644 --- a/install.py +++ b/install.py @@ -5898,6 +5898,8 @@ TUI_SYSTEMS_ROW = "Choose systems" # which-pack names them from here. TUI_FOUND_TITLE = "Where do you play?" TUI_CHOOSE_TITLE = "Which setup?" +# Asked after Which setup? where nothing was found, and on a card. +TUI_FOLDER_TITLE = "Which folder?" def _on_read_only_mount(path: Path) -> bool: @@ -18954,7 +18956,7 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: first: list = [] if bound and state.folder_default is not None and state.folder_kind == "found": # On the device itself: its own folder, which is no card to look for. - title = "Which folder?" + title = TUI_FOLDER_TITLE before = [f"{bound[0]} reads its BIOS files from:", str(state.folder_default)] elif bound: title = "Where is the SD card?" @@ -18978,10 +18980,10 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: else "No card or drive is mounted.") + " Insert it, then choose Look again.") elif state.folder_kind == "share": - title = "Which folder?" + title = TUI_FOLDER_TITLE before = [share_words(state.platform)] elif state.folder_default is not None: - title = "Which folder?" + title = TUI_FOLDER_TITLE lead = {"found": f"{name} reads its BIOS files from:", "chosen": f"The BIOS folder chosen for {name}:"}.get( state.folder_kind, @@ -18989,10 +18991,10 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: "files from:") before = [lead, str(state.folder_default)] elif state.folder_kind == "closed": - title = "Which folder?" + title = TUI_FOLDER_TITLE before = [f"Choose another folder for {name}: browse for it or type it."] else: - title = "Which folder?" + title = TUI_FOLDER_TITLE before = [f"No usual {name} folder here: browse for it or type it."] notes: list = [] if not bound and state.folder_kind != "share": diff --git a/scripts/generate_site.py b/scripts/generate_site.py index b951e8a2d..34be02506 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -3943,8 +3943,11 @@ def first_screen_words() -> list[str]: # The screen it opens first: the setup found, or the list when none # is. Install comes on the next one, Choose systems beside it and named # above on the page (tui_audit_16 P3-10). - return [f"It asks **{installer.TUI_FOUND_TITLE}** (or **{installer.TUI_CHOOSE_TITLE}**), " - f"then offers **{installer.TUI_INSTALL_ROW}**."] + # Nothing found, the folder comes before Install: which-pack left it + # out (tui_audit_17 P3-9). + return [f"It asks **{installer.TUI_FOUND_TITLE}**, or **{installer.TUI_CHOOSE_TITLE}** " + f"and **{installer.TUI_FOLDER_TITLE}** when it finds nothing, then offers " + f"**{installer.TUI_INSTALL_ROW}**."] def check_pass_line() -> str: @@ -4113,8 +4116,8 @@ def only_some_consoles(name: str, prefix: str = "") -> list[str]: """The way to a smaller download, in the words Get your files uses.""" return [ f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}" - f"#only-some-consoles), or choose **{_installer().TUI_SYSTEMS_ROW}** in the " - "command.", + f"#only-some-consoles), or run the command: **{_installer().TUI_SYSTEMS_ROW}** " + "is on its second screen.", "", ] @@ -11412,7 +11415,7 @@ def generate_which_pack() -> str: "## Only some consoles?", "", "Pick them here, and get the files and the command. Or run the command " - f"below and choose **{_installer().TUI_SYSTEMS_ROW}**.", + f"below: **{_installer().TUI_SYSTEMS_ROW}** is on its second screen.", "", "", "", diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 98932609d..ed8b44983 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -13952,6 +13952,10 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): self.assertLess(words.index(install.TUI_FOUND_TITLE), words.index(install.TUI_INSTALL_ROW)) self.assertNotIn("opens a screen", words) + # Nothing found, Which folder? comes before the plan (tui_audit_17 P3-9). + self.assertIn(f"**{install.TUI_FOLDER_TITLE}**", words) + self.assertLess(words.index(install.TUI_FOLDER_TITLE), + words.index(install.TUI_INSTALL_ROW)) if __name__ == "__main__": diff --git a/tests/test_site_r3_entry.py b/tests/test_site_r3_entry.py index 990345902..84ca38a90 100644 --- a/tests/test_site_r3_entry.py +++ b/tests/test_site_r3_entry.py @@ -160,7 +160,10 @@ class SetupPagesOfferPartOfThePack(unittest.TestCase): self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text) # The screen's own row: the line menu's c is not what the # one-liner opens. - self.assertIn("or choose **Choose systems** in the command.", text) + self.assertIn("or run the command: **Choose systems** is on its second " + "screen.", text) + # Never "choose **Choose systems**" (tui_audit_17 P3-9). + self.assertNotIn("choose **Choose systems**", text) def test_the_anchor_exists(self): self.assertIn("## Only some consoles?", _page("which-pack.md")) From 6454de877228ab3e2c3ff3cf8679ca7e581d0f03 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:22:59 +0100 Subject: [PATCH 05/11] docs: check and copy samples printed by install.py --- install.py | 3 +- scripts/generate_installer_doc.py | 125 ++++++++++++++++++++++++++---- wiki/adding-a-platform.md | 2 +- wiki/installer.md | 60 +++++++++----- wiki/tools.md | 4 +- 5 files changed, 158 insertions(+), 36 deletions(-) diff --git a/install.py b/install.py index 779a12f13..cbdff8afc 100644 --- a/install.py +++ b/install.py @@ -7905,7 +7905,8 @@ def fold_line(text: str, hang: str = " ") -> str: at /media/moi/ONION/BIOS" broke inside "(RetroArch files)" and a folder inside its name (tui_audit_17 P3-8).""" cols = max(terminal_size()[0] - 1, 8) - if cell_width(text) <= cols: + if cell_width(text) <= cols or not _is_terminal(sys.stdout): + # A pipe or a log folds nothing: the line is kept whole there. return text lead = text[:len(text) - len(text.lstrip(" "))] prefix = lead + hang diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py index a249bfd69..e3f799958 100644 --- a/scripts/generate_installer_doc.py +++ b/scripts/generate_installer_doc.py @@ -14,12 +14,15 @@ install.py gains has no prose yet: the generator names it and writes nothing, and the row is added by hand inside the markers, its first cell holding the option, kind, code or value alone. -The run samples (a dry run, a first install, a later check) are printed by -install.py itself, run in its own process against the committed -install/.json served on the loopback, from a home and a card made -under tmp/ and shown as /home/me and /userdata. Only the transport and the -machine are stood in for, see _child. Each is dated by the `generated` field -of its list, so a rendering is the same from one day to the next. +The run samples (a dry run, a first install, a later check, the reasons a +file is refused, a run copying from an old card) are printed by install.py +itself, run in its own process against the committed install/.json +served on the loopback, from a home and a card made under tmp/ and shown as +/home/me and /userdata. The reasons and the copy are read from a four-file +list laid out in that home (SMALL_LIST), whose files are written there. Only +the transport and the machine are stood in for, see _child. Each is dated by +the `generated` field of its list, so a rendering is the same from one day +to the next. """ from __future__ import annotations @@ -33,10 +36,12 @@ import json import os import re import shutil +import socketserver import subprocess import sys import tempfile import threading +import zipfile from pathlib import Path, PurePosixPath from typing import Callable @@ -282,6 +287,44 @@ class _ListHandler(http.server.SimpleHTTPRequestHandler): return +class _LoopbackServer(http.server.ThreadingHTTPServer): + """The list server, named by its number: HTTPServer.server_bind looks + 127.0.0.1 up with socket.getfqdn, a DNS query wherever /etc/hosts does + not name it (code_audit_24 P3-12).""" + + def server_bind(self) -> None: + socketserver.TCPServer.server_bind(self) + host, port = self.server_address[:2] + self.server_name, self.server_port = host, port + + +# The list the reasons and the copy are shown with: four files, each with +# the contents its name says, read as RetroArch's. +SMALL_LIST = { + "scph5500.bin": (b"J" * 64, ["sony-playstation"], ["japan"], None), + "scph5501.bin": (b"U" * 64, ["sony-playstation"], ["north-america"], + ["beetle_psx", "pcsx_rearmed"]), + "gba_bios.bin": (b"G" * 32, ["nintendo-gba"], None, ["mgba"]), + "pcsx/extra.bin": (b"E" * 8, ["sony-playstation"], None, ["pcsx_rearmed"]), +} + + +def write_small_list(path: Path) -> None: + import hashlib # noqa: PLC0415 + + files = [] + for dest, (data, systems, regions, cores) in SMALL_LIST.items(): + files.append({"dest": dest, "size": len(data), "sha1": hashlib.sha1(data).hexdigest(), + "sha256": hashlib.sha256(data).hexdigest(), + "md5": hashlib.md5(data).hexdigest(), "systems": systems, + "cores": cores, "repo_path": f"bios/{dest}", + **({"regions": regions} if regions else {})}) + path.write_text(json.dumps({ + "manifest_version": 2, "platform": "retroarch", "display_name": "RetroArch", + "base_destination": "system", "files": files, "total_files": len(files), + "total_size": sum(entry["size"] for entry in files)}), encoding="utf-8") + + def _child(spec: dict) -> int: """install.main() as the sample runs it, in its own process. Only the transport and the machine are stood in for: no connection leaves the @@ -300,8 +343,13 @@ def _child(spec: dict) -> int: raise OSError(f"the sample makes no request to {address[0]}") return real_connect(address, *args, **kwargs) - def landed(f: dict, _bios: Path, _verbose: bool = False, progress: object = None, - _copy: object = None) -> tuple[str, bool, str]: + real_download = _install._download_one + + def landed(f: dict, bios: Path, verbose: bool = False, progress: object = None, + copy: object = None) -> tuple[str, bool, str]: + if copy is not None: + # A copy from this machine is made for real: it is local. + return real_download(f, bios, verbose, progress, copy) if progress is not None: progress.add(f["size"]) return f["dest"], True, "" @@ -353,7 +401,7 @@ class Sandbox: config = self.home / ".config" / "retroarch" / "retroarch.cfg" config.parent.mkdir(parents=True) config.write_text(RETROARCH_CFG, encoding="utf-8") - self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _ListHandler) + self.server = _LoopbackServer(("127.0.0.1", 0), _ListHandler) threading.Thread(target=self.server.serve_forever, daemon=True).start() return self @@ -362,26 +410,55 @@ class Sandbox: self.server.server_close() shutil.rmtree(self.root) + def small(self, kind: str) -> str: + """A run over SMALL_LIST in a home of its own: "refused", a --check + of a folder holding a copy of each kind a check names, or "from", a + run copying from an old card what it holds and downloading the rest.""" + home = self.root / kind + system = home / ".config" / "retroarch" / "system" + system.mkdir(parents=True) + (system.parent / "retroarch.cfg").write_text(RETROARCH_CFG, encoding="utf-8") + listed = home / "list.json" + write_small_list(listed) + data = {dest: entry[0] for dest, entry in SMALL_LIST.items()} + if kind == "refused": + (system / "scph5500.bin").write_bytes(data["scph5500.bin"]) + (system / "scph5501.bin").write_bytes(data["scph5500.bin"]) + (system / "system").mkdir() + (system / "system" / "gba_bios.bin").write_bytes(data["gba_bios.bin"]) + with zipfile.ZipFile(system / "PSX BIOS.zip", "w") as archive: + archive.writestr("extra.bin", data["pcsx/extra.bin"]) + # A check that flags a file ends on 1, as documented. + return self.run("retroarch", "--check", "--manifest", str(listed), home=home, + codes=(1,)) + old = home / "old" + old.mkdir() + for dest in ("scph5500.bin", "scph5501.bin"): + (old / dest).write_bytes(data[dest]) + return self.run("retroarch", "--yes", "--manifest", str(listed), "--from", str(old), + home=home, landed=True) + def run(self, platform: str, *argv: str, landed: bool = False, - held: bool = False) -> str: + held: bool = False, home: Path | None = None, codes: tuple = (0,)) -> str: """What install.py prints for the run, the sandbox read as a user's home and card.""" + home = home or self.home card = self.card / "userdata" / "bios" if platform != "retroarch" else None if card is not None: card.mkdir(parents=True, exist_ok=True) spec = {"platform": platform, "argv": ["--plain", "--platform", platform, *argv], "card": str(card) if card else None, "landed": landed, "held": held} - env = {"PATH": os.environ.get("PATH", ""), "HOME": str(self.home), + env = {"PATH": os.environ.get("PATH", ""), "HOME": str(home), "RETROBIOS_OS": "linux", "LANG": "C.UTF-8", "RETROBIOS_BASE_URL": f"http://127.0.0.1:{self.server.server_address[1]}"} done = subprocess.run( [sys.executable, "-s", "-c", CHILD, json.dumps(spec)], cwd=ROOT, env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False) - if done.returncode != 0: + if done.returncode not in codes: raise SampleFailed(f"install.py {' '.join(spec['argv'])} exited " f"{done.returncode}: {done.stderr.strip()[-2000:]}") - return done.stdout.replace(str(self.home), SHOWN_HOME).replace(str(self.card), SHOWN_CARD) + return done.stdout.replace(str(home), SHOWN_HOME).replace(str(self.card), SHOWN_CARD) # The runs printed in this process, by what they read: the page is rendered @@ -411,6 +488,14 @@ class _LazySandbox: _RUNS[key] = self.box.run(platform, *argv, **kinds) return _RUNS[key] + def small(self, kind: str) -> str: + key = ("small", kind, _stamp("retroarch")) + if key not in _RUNS: + if self.box is None: + self.box = self.stack.enter_context(Sandbox()) + _RUNS[key] = self.box.small(kind) + return _RUNS[key] + class SampleFailed(Exception): """A run sample install.py could not print.""" @@ -487,9 +572,23 @@ def sample_writers(box: Sandbox) -> dict[str, Callable[[str], str]]: + _fenced([_bulk_line(box)])), "run-check": lambda _body: _fenced( _lines_from(check_run(), "Checking existing files", " Everything is in place")), + "check-refused": lambda _body: _fenced(_refused_lines(box.small("refused"))), + "from-run": lambda _body: _fenced( + _lines_from(box.small("from"), "Done.", count=2)), } +def _refused_lines(text: str) -> list[str]: + """The files a check flags, each with its reason and where its cores read it.""" + lines = text.splitlines() + start = next((i for i, line in enumerate(lines) + if re.fullmatch(r" \d+ files? [a-z ]+:", line)), None) + if start is None: + raise SampleFailed("the check printed no files not in place") + end = max(i for i, line in enumerate(lines) if line.lstrip().startswith("read by ")) + return lines[start:end + 1] + + def render(page: str) -> str: """The page with each marked table and run sample written from install.py.""" writers = { diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index bed3b080a..435cd54ad 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -675,7 +675,7 @@ This executes in sequence: 4. `generate_pack.py --all` - build ZIP packs + install and target manifests 5. Consistency check: verify and the pack agree on counts and on the paths each archive holds 6. Pack integrity - extract ZIPs and verify hashes per platform mode -7. `generate_readme.py` - regenerate README and CONTRIBUTING +7. `generate_readme.py` - regenerate README and CONTRIBUTING, then `generate_installer_doc.py` rewrites the tables and run samples of `wiki/installer.md` from the new lists 8. `generate_site.py` - regenerate the documentation site and its navigation A new platform is archived-by-default nowhere: `status: active` puts it in diff --git a/wiki/installer.md b/wiki/installer.md index 477fae82f..6a8183256 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -480,7 +480,9 @@ On a first install nothing is in place. On a later run the line counts what is already there, and only the rest is downloaded. A download of 1 GB or more is confirmed first, the size and the folder in the question. -The second phase downloads what is missing, one line per file: +The second phase downloads what is missing. At a terminal one progress line +moves with the count and the size. A pipe or a log gets one line per file, +as the log of the run below: @@ -525,9 +527,10 @@ RetroArch with `--system sony-playstation`, from the list of 2026-10-27: `--dry-run` prints the same line under its plan. -When some files came from this machine rather than the network, a line under -`Done` says where each came from (`38 files copied from E:\, 3 copied from -the wrong place in the BIOS folder, 4 downloaded.`). See +When some files came from this machine rather than the network, the run says +where each came from: on the `Done` line when all came from one place +(`Done. 22 files copied from /home/me/old.`), on a line under it otherwise +(`2 files copied from /home/me/old, 2 downloaded.`). See [Copy from an old card](#copy-from-an-old-card). The word `Done` and the BIOS folder are the confirmation. Any other ending @@ -565,20 +568,27 @@ replaces it and keeps the old one, see ### Why a file is refused { #why-a-file-is-refused } Each file `--check` flags comes with the reason it found and the place each -core opens it: +core reads it. A RetroArch folder holding a copy of each kind, checked +against a list of four files: + + ``` 3 files to download: + 2 of these files are in the BIOS folder in the wrong place: a run without --check copies them where the cores open them, and leaves your copies as they are. wrong copy: scph5501.bin another region: the contents of scph5500.bin (japan) - opened at /home/me/.config/retroarch/system/scph5501.bin by beetle_psx, pcsx_rearmed + read by beetle_psx, pcsx_rearmed at /home/me/.config/retroarch/system/scph5501.bin missing: gba_bios.bin nested folder: it is at system/system/gba_bios.bin, a system folder inside system - opened at /home/me/.config/retroarch/system/gba_bios.bin by mgba + a run without --check copies it into place + read by mgba at /home/me/.config/retroarch/system/gba_bios.bin missing: pcsx/extra.bin still zipped: it is inside PSX BIOS.zip - opened at /home/me/.config/retroarch/system/pcsx/extra.bin by pcsx_rearmed + a run without --check copies it into place + read by pcsx_rearmed at /home/me/.config/retroarch/system/pcsx/extra.bin ``` + | Reason | What the folder holds | |--------|-----------------------| @@ -699,12 +709,15 @@ other contents is kept aside first, as a download would replace it (see [Files you already had](#files-you-already-had)). What no folder holds is downloaded as usual, or taken from the pack with `--from-pack`. Several `--from` are read in the order given, and the end of the run says where each -file came from: +file came from. A run of a list of four files, two of them in `old`: + + ``` -Done. 44 files installed. - 38 files copied from E:\, 4 downloaded, 2 still missing. +Done. 4 files installed. + 2 files copied from /home/me/old, 2 downloaded. ``` + `--check --from FOLDER` writes nothing and says how many files the folder would give (`E:\ holds 38 of these files: a run without --check copies @@ -1056,6 +1069,8 @@ gives for the device, below the card's root instead of its mount point: | `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list | | `.allium/` | Allium | `BIOS`, RetroArch's list | | `Koriki/version.txt` | Koriki | `BIOS`, RetroArch's list | +| `bgmusic/` beside `bios/`, on a drive that is not a fixed disk | ArkOS | `bios`, RetroArch's list | +| `launchimages/` beside `bios/`, on a drive that is not a fixed disk | ArkOS | `bios`, RetroArch's list | | `gamedata/`, `homebrew/` beside `bios/`, on a drive that is not a fixed disk | AmberELEC | `bios`, AmberELEC's list | | `bezels/default.cfg` beside `bios/`, on a drive that is not a fixed disk | EmuELEC | `bios`, EmuELEC's list | | `system/batocera.conf` holding `system.hostname=BATOCERA` or no hostname line, and no KNULLI file above | Batocera SHARE drive | `bios`, Batocera's list | @@ -1635,14 +1650,20 @@ The build scripts need 3.9 and are not bound by this. Every change to `install.py` recomputes the SHA-256 pinned in `install.sh` and `install.ps1` in the same commit. The tests compare the pins with the file. -The tables of options, events, exit codes and keys on this page sit between -`generated` markers and are written from `install.py` by -`scripts/generate_installer_doc.py`: the options from its parser, the events -from `EVENT_KEYS`, the codes from its `sys.exit` calls, the keys from -`TUI_ROUTES`. The prose cells stay as written. A row the code gains stops the -script until its text is added inside the markers, and `--check`, which a -test runs, fails on a table behind the code. `--page FILE` renders another -copy of the page, which the tests use. +The tables of options, events, exit codes, keys and card markers, the line +menu and the run samples on this page sit between `generated` markers and are +written from `install.py` by `scripts/generate_installer_doc.py`: the options +from its parser, the events from `EVENT_KEYS`, the codes from its `sys.exit` +calls, the keys from `TUI_ROUTES`, the card markers from its card rules. The +run samples (a dry run, a first install, a check, the reasons a file is +refused, a copy from an old card) are printed by `install.py` itself, in a +sandbox under `tmp/` served on the loopback from the committed +`install/*.json`. A new list therefore rewrites this page, and `--check` +fails until the script has run. Pipeline step 7 runs it after the README. +The prose cells stay as written. A row the code gains stops the script until +its text is added inside the markers, and `--check`, which a test runs, fails +on a table behind the code. `--page FILE` renders another copy of the page, +which the tests use. The [handheld](#on-a-handheld-the-retrobios-port) archives each carry `install.py`, the one the one-line command of the same commit downloads: @@ -1714,6 +1735,7 @@ other schemas under `api/v1/schemas/`. | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | | `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on | | `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of | +| `set_aside` | `platform`, `dests` | Right after `choices`: the files of the list the run sets aside before any choice, in list order, those read from a save directory whose place is not known among them. A screen chooses its cores among the rest, the cores `--core` accepts | | `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` | | `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id, and with `--system` only the systems named. Files with no system count under `other`. `name` is the platform's own name for it | | `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place | diff --git a/wiki/tools.md b/wiki/tools.md index 624a58ade..498008b3e 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -49,7 +49,7 @@ Pipeline steps: | 4d | Collected copies only an emulator install provides (`bundled_only.py --strict`, gates the run) | - | | 5 | Consistency check: counts, paths per archive, every emulator pack | if verify or pack skipped | | 6 | Pack integrity (extract + hash) | `--skip-packs` | -| 7 | Generate README | `--skip-docs` | +| 7 | Generate README and CONTRIBUTING, then the installer page's tables and run samples (`generate_installer_doc.py`) | `--skip-docs` | | 8 | Generate site | `--skip-docs` | | 8b | Render the site with `npm run build --prefix website`, then `validate_site.py`, as the deploy does | `--skip-docs`, or Node not set up (`npm ci --prefix website`) | @@ -1301,7 +1301,7 @@ before a release (wiki/release-process.md). Its lines stay in | `validate_pr.py` | Validate BIOS files in pull requests, post markdown report | | `validate_schemas.py` | Validate the data contracts: schemas and semantic invariants. `--source-only` checks `emulators/` and `platforms/` alone, which is what PR validation runs | | `generate_event_schema.py` | Write `schemas/install-events.schema.json` from `EVENT_KEYS` in `install.py` (`--check` exits 1 when the file is stale, which `validate_schemas.py` also reports) | -| `generate_installer_doc.py` | Rewrite the generated tables of `wiki/installer.md` (options, events, exit codes, keys per route) from `install.py` (`--check` exits 1 when a table is stale, `--page FILE` writes another copy of the page) | +| `generate_installer_doc.py` | Rewrite the generated blocks of `wiki/installer.md` from `install.py`: the tables (options, events, exit codes, keys per route, card markers), the line menu, and the run samples `install.py` prints in a sandbox against the committed `install/*.json`, so a new list rewrites the page (`--check` exits 1 when a block is stale, `--page FILE` writes another copy of the page). Pipeline step 7 | | `auto_fetch.py` | Fetch missing BIOS files from known sources (4-step pipeline). A download is accepted only when every cryptographic hash the entry declares (sha256, sha1, md5) matches. crc32 and size find a file the database already holds, never accept a download. An entry with no cryptographic hash is reported and not searched | | `list_platforms.py` | List active platforms (`--include-archived` adds the archived ones, used by CI) | | `download.py` | Download a pack from GitHub releases, every part checked (Python, stdlib only) | From 48fa8abf4436c6efea488ecd76bf9ff6cc7506fd Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:45:18 +0100 Subject: [PATCH 06/11] chore: audit 24 and 17 cases, host kept out --- install.ps1 | 2 +- install.py | 14 +- install.sh | 2 +- tests/fixtures/tui/colour-120x40.txt | 36 +- tests/fixtures/tui/colour-40x12.txt | 44 +- tests/fixtures/tui/colour-40x15.txt | 48 +- tests/fixtures/tui/colour-80x24.txt | 36 +- tests/fixtures/tui/cores-120x40.txt | 16 +- tests/fixtures/tui/cores-40x12.txt | 20 +- tests/fixtures/tui/cores-40x15.txt | 20 +- tests/fixtures/tui/cores-80x24.txt | 16 +- tests/fixtures/tui/plan-in-place-120x40.txt | 2 +- tests/fixtures/tui/plan-in-place-40x12.txt | 4 +- tests/fixtures/tui/plan-in-place-40x15.txt | 4 +- tests/fixtures/tui/plan-in-place-80x24.txt | 2 +- tests/fixtures/tui/regions-120x40.txt | 4 +- tests/fixtures/tui/regions-40x12.txt | 2 +- tests/fixtures/tui/regions-40x15.txt | 4 +- tests/fixtures/tui/regions-80x24.txt | 4 +- tests/fixtures/tui/result-120x40.txt | 2 +- tests/fixtures/tui/result-40x12.txt | 4 +- tests/fixtures/tui/result-40x15.txt | 4 +- tests/fixtures/tui/result-80x24.txt | 2 +- tests/fixtures/tui/route-evmapy.txt | 124 ++--- tests/fixtures/tui/route-gptokeyb.txt | 124 ++--- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 124 ++--- tests/fixtures/tui/route-keys.txt | 128 +++--- tests/fixtures/tui/route-miyoo.txt | 124 ++--- tests/fixtures/tui/route-muxterm.txt | 124 ++--- tests/fixtures/tui/systems-120x40.txt | 12 +- tests/fixtures/tui/systems-40x12.txt | 14 +- tests/fixtures/tui/systems-40x15.txt | 16 +- tests/fixtures/tui/systems-80x24.txt | 12 +- tests/netguard/__init__.py | 33 ++ tests/netguard/sitecustomize.py | 133 ++++++ tests/test_doc_pages.py | 53 +++ tests/test_install.py | 7 + tests/test_install_tui.py | 432 +++++++++++++++++- 38 files changed, 1208 insertions(+), 544 deletions(-) create mode 100644 tests/netguard/__init__.py create mode 100644 tests/netguard/sitecustomize.py diff --git a/install.ps1 b/install.ps1 index db72b7c21..9c37b8101 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a" + $defaultInstallSha256 = "a5713bf93a8b48b5649aa7040a429ec4ac2a2ae62b14b07eb6c91cb96c15d984" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index cbdff8afc..b14943937 100644 --- a/install.py +++ b/install.py @@ -12164,8 +12164,12 @@ def card_setups() -> list[CardSetup]: below = next((rule.bios[len(_card_mount(rule.bios)):] for rule in _EMBEDDED_RULES if rule.name == name and rule.bios and _card_mount(rule.bios)), "") if below: - setups.append(CardSetup(name, "retroarch", below, - tuple(rule for rule in rules if rule.name == name))) + own = tuple(rule for rule in rules if rule.name in (name, _built_on(name))) + # Told by the games part alone, as AmberELEC's: a card that holds + # none of it yet is taken as named, said so (tui_audit_17 P2-3). + setups.append(CardSetup(name, "retroarch", below, own, + bool(own) and all(rule.method == "games_part" + for rule in own))) return setups @@ -19173,7 +19177,13 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: for event in events} held = [event for event in chosen if event["dest"] in took] rest = len(chosen) - len(held) + data + # Nothing at hand: the head the whole list's plan says, for the + # systems ticked. "About 0 files, 0 B" stood over 20 files the + # internet would bring (tui_audit_17 P3-5). lines[0] = _styled( + f"{agree(rest, 'file needs', 'files need')} the internet, which " + f"{offline_named()} does not use, for {len(state.ticked)} of " + f"{len(state.systems)} systems", "title") if not held else _styled( f"About {_plural(len(held), 'file')}, " f"{format_size(sum(e['size'] for e in held))}, for {len(state.ticked)} of " f"{len(state.systems)} systems" diff --git a/install.sh b/install.sh index cacf12717..1764d7e94 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a" +DEFAULT_INSTALL_SHA256="a5713bf93a8b48b5649aa7040a429ec4ac2a2ae62b14b07eb6c91cb96c15d984" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/fixtures/tui/colour-120x40.txt b/tests/fixtures/tui/colour-120x40.txt index 7b7a0d47d..b6c4ca8d9 100644 --- a/tests/fixtures/tui/colour-120x40.txt +++ b/tests/fixtures/tui/colour-120x40.txt @@ -373,6 +373,7 @@ ^[[0;1m ^[[0;1;32mEverything is in place^[[0m BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB ^[[0;7;35m > Close^[[0m @@ -404,20 +405,19 @@ - ^[[0;1;35mA/Enter^[[0m Close ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help === systems ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m + Filter (/ to type) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB +^[[0;7;35m > [ ] nintendo-famicom-disk (ファミコン) in place^[[0m All None - Filter (/ to type) -^[[0;7;35m > [x] nintendo-gba (gba) 1 file, 16.0 KB^[[0m - [x] sony-playstation 3 files, 1.0 MB -^[[0;2m [ ] nintendo-famicom-disk (ファミコン) in place^[[0m - + ^[[0;32mDone^[[0m @@ -448,7 +448,7 @@ ^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m - ^[[0;1;35mA/Enter^[[0m Continue ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA/Enter^[[0m Tick ^[[0;2mB/Esc^[[0m Done ^[[0;2m?^[[0m Help === options ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m @@ -498,8 +498,8 @@ ^[[0;7;35m > [1] North America^[[0m [ ] Japan - - + None + ^[[0;32mDone^[[0m @@ -534,14 +534,15 @@ === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m - Every core ticked keeps every core's files. Untick the cores you do not use: the platform's own - files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and a core with no + extra file reads only those. -^[[0;7;35m > All^[[0m +^[[0;7;35m > Filter (/ to type)^[[0m + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed + ^[[0;32mDone^[[0m @@ -570,8 +571,7 @@ - - ^[[0;1;35mA/Enter^[[0m Tick all ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA/Enter^[[0m Filter ^[[0;2mB/Esc^[[0m Done ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m @@ -742,6 +742,7 @@ ^[[0;1m ^[[0;1;32m3 installed^[[0;1m, ^[[0;1;31m1 failed^[[0;1m.^[[0m BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 To put them back: python3 install.py \ --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 @@ -773,7 +774,6 @@ - ^[[0;1;35mA/Enter^[[0m Exit diff --git a/tests/fixtures/tui/colour-40x12.txt b/tests/fixtures/tui/colour-40x12.txt index bbbf7d339..274ec3700 100644 --- a/tests/fixtures/tui/colour-40x12.txt +++ b/tests/fixtures/tui/colour-40x12.txt @@ -122,25 +122,25 @@ BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB - ^[[0;7;35m > Close^[[0m Options - ^[[0;1;35mA^[[0m Close ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help === systems ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB +^[[0;7;35m > [ ] nintendo-famicom-disk in place^[[0m All None - Filter (/ to type) -^[[0;7;35m > [x] nintendo-gba (gba) 16.0 KB^[[0m - [x] sony-playstation 1.0 MB -^[[0;2m [ ] nintendo-famicom-disk in place^[[0m - - +^[[0;2m 1-3 of 3^[[0m + nintendo-famicom-disk (ファミコン): in + place ^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m - ^[[0;1;35mA^[[0m Continue ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA^[[0m Tick ^[[0;2mB^[[0m Done ^[[0;2m?^[[0m Help === options ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m @@ -165,21 +165,21 @@ ^[[0;7;35m > [1] North America^[[0m [ ] Japan - +^[[0;2m 1-2 of 2^[[0m ^[[0;1;35mA^[[0m Tick ^[[0;2mB^[[0m Done ^[[0;2m<>^[[0m Order ^[[0;2m?^[[0m Help === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. -^[[0;7;35m > All^[[0m - None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed - ^[[0;1;35mA^[[0m Tick all ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. + +^[[0;7;35m > Filter (/ to type)^[[0m + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B +^[[0;2m 1-2 of 2^[[0m + ^[[0;1;35mA^[[0m Filter ^[[0;2mB^[[0m Done ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m @@ -239,11 +239,11 @@ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py \ -^[[0;2m 1-8 of 16^[[0m +^[[0;2m 1-8 of 17^[[0m ^[[0;1;35mA^[[0m Exit ^[[0;2m^v^[[0m Scroll === error ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m diff --git a/tests/fixtures/tui/colour-40x15.txt b/tests/fixtures/tui/colour-40x15.txt index 9430e9fbd..67c5d1073 100644 --- a/tests/fixtures/tui/colour-40x15.txt +++ b/tests/fixtures/tui/colour-40x15.txt @@ -149,31 +149,31 @@ BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB ^[[0;7;35m > Close^[[0m Options - - ^[[0;1;35mA^[[0m Close ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help === systems ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m + Filter (/ to type) + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB +^[[0;7;35m > [ ] nintendo-famicom-disk in place^[[0m All None - Filter (/ to type) -^[[0;7;35m > [x] nintendo-gba (gba) 16.0 KB^[[0m - [x] sony-playstation 1.0 MB -^[[0;2m [ ] nintendo-famicom-disk in place^[[0m - - - + ^[[0;32mDone^[[0m + nintendo-famicom-disk (ファミコン): in + place ^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m - ^[[0;1;35mA^[[0m Continue ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA^[[0m Tick ^[[0;2mB^[[0m Done ^[[0;2m?^[[0m Help === options ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m @@ -201,27 +201,27 @@ ^[[0;7;35m > [1] North America^[[0m [ ] Japan - - + None + ^[[0;32mDone^[[0m ^[[0;1;35mA^[[0m Tick ^[[0;2mB^[[0m Done ^[[0;2m<>^[[0m Order ^[[0;2m?^[[0m Help === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. -^[[0;7;35m > All^[[0m +^[[0;7;35m > Filter (/ to type)^[[0m + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed + ^[[0;32mDone^[[0m - - ^[[0;1;35mA^[[0m Tick all ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA^[[0m Filter ^[[0;2mB^[[0m Done ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m @@ -293,14 +293,14 @@ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py \ --restore .../2026-10-23_1200 ^[[0;2m Shortened to fit this window.^[[0m - Same choice without questions: -^[[0;2m 1-11 of 16^[[0m +^[[0;2m 1-11 of 17^[[0m ^[[0;1;35mA^[[0m Exit ^[[0;2m^v^[[0m Scroll === error ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m diff --git a/tests/fixtures/tui/colour-80x24.txt b/tests/fixtures/tui/colour-80x24.txt index 8c3fb5a3a..f511e2e8b 100644 --- a/tests/fixtures/tui/colour-80x24.txt +++ b/tests/fixtures/tui/colour-80x24.txt @@ -229,6 +229,7 @@ ^[[0;1m ^[[0;1;32mEverything is in place^[[0m BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB ^[[0;7;35m > Close^[[0m @@ -246,18 +247,17 @@ - ^[[0;1;35mA/Enter^[[0m Close ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help === systems ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m + Filter (/ to type) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB +^[[0;7;35m > [ ] nintendo-famicom-disk (ファミコン) in place^[[0m All None - Filter (/ to type) -^[[0;7;35m > [x] nintendo-gba (gba) 1 file, 16.0 KB^[[0m - [x] sony-playstation 3 files, 1.0 MB -^[[0;2m [ ] nintendo-famicom-disk (ファミコン) in place^[[0m - + ^[[0;32mDone^[[0m @@ -272,7 +272,7 @@ ^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m - ^[[0;1;35mA/Enter^[[0m Continue ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA/Enter^[[0m Tick ^[[0;2mB/Esc^[[0m Done ^[[0;2m?^[[0m Help === options ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m @@ -307,8 +307,8 @@ ^[[0;7;35m > [1] North America^[[0m [ ] Japan - - + None + ^[[0;32mDone^[[0m @@ -326,14 +326,15 @@ === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m - Every core ticked keeps every core's files. Untick the cores you do not use: - the platform's own files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and + a core with no extra file reads only those. -^[[0;7;35m > All^[[0m +^[[0;7;35m > Filter (/ to type)^[[0m + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed + ^[[0;32mDone^[[0m @@ -346,8 +347,7 @@ - - ^[[0;1;35mA/Enter^[[0m Tick all ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA/Enter^[[0m Filter ^[[0;2mB/Esc^[[0m Done ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m @@ -454,6 +454,7 @@ ^[[0;1m ^[[0;1;32m3 installed^[[0;1m, ^[[0;1;31m1 failed^[[0;1m.^[[0m BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 To put them back: python3 install.py \ @@ -471,7 +472,6 @@ - ^[[0;1;35mA/Enter^[[0m Exit === error ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m diff --git a/tests/fixtures/tui/cores-120x40.txt b/tests/fixtures/tui/cores-120x40.txt index 571c06bea..4bc230c78 100644 --- a/tests/fixtures/tui/cores-120x40.txt +++ b/tests/fixtures/tui/cores-120x40.txt @@ -1,13 +1,14 @@ RetroBIOS: Cores - Every core ticked keeps every core's files. Untick the cores you do not use: the platform's own - files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and a core with no + extra file reads only those. - > All + > Filter (/ to type) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed + Done @@ -36,5 +37,4 @@ - - A/Enter Tick all B/Esc Back X/Space Tick ? Help + A/Enter Filter B/Esc Done ? Help diff --git a/tests/fixtures/tui/cores-40x12.txt b/tests/fixtures/tui/cores-40x12.txt index 7557af130..1ce5c1751 100644 --- a/tests/fixtures/tui/cores-40x12.txt +++ b/tests/fixtures/tui/cores-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. - > All - None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed - A Tick all B Back X Tick ? Help + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. + + > Filter (/ to type) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + 1-2 of 2 + A Filter B Done ? Help diff --git a/tests/fixtures/tui/cores-40x15.txt b/tests/fixtures/tui/cores-40x15.txt index 5cbef31a7..650545a60 100644 --- a/tests/fixtures/tui/cores-40x15.txt +++ b/tests/fixtures/tui/cores-40x15.txt @@ -1,15 +1,15 @@ RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. - > All + > Filter (/ to type) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed + Done - - A Tick all B Back X Tick ? Help + A Filter B Done ? Help diff --git a/tests/fixtures/tui/cores-80x24.txt b/tests/fixtures/tui/cores-80x24.txt index c2fce774c..bf4061884 100644 --- a/tests/fixtures/tui/cores-80x24.txt +++ b/tests/fixtures/tui/cores-80x24.txt @@ -1,13 +1,14 @@ RetroBIOS: Cores - Every core ticked keeps every core's files. Untick the cores you do not use: - the platform's own files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and + a core with no extra file reads only those. - > All + > Filter (/ to type) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed + Done @@ -20,5 +21,4 @@ - - A/Enter Tick all B/Esc Back X/Space Tick ? Help + A/Enter Filter B/Esc Done ? Help diff --git a/tests/fixtures/tui/plan-in-place-120x40.txt b/tests/fixtures/tui/plan-in-place-120x40.txt index 8f6048340..2869a8ecc 100644 --- a/tests/fixtures/tui/plan-in-place-120x40.txt +++ b/tests/fixtures/tui/plan-in-place-120x40.txt @@ -3,6 +3,7 @@ Everything is in place BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB > Close @@ -34,7 +35,6 @@ - A/Enter Close B/Esc Back Y/o Options ? Help diff --git a/tests/fixtures/tui/plan-in-place-40x12.txt b/tests/fixtures/tui/plan-in-place-40x12.txt index e5768b6f9..8c1faea1c 100644 --- a/tests/fixtures/tui/plan-in-place-40x12.txt +++ b/tests/fixtures/tui/plan-in-place-40x12.txt @@ -4,9 +4,9 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB - > Close Options - A Close B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-in-place-40x15.txt b/tests/fixtures/tui/plan-in-place-40x15.txt index 757eb2c59..3f07f6881 100644 --- a/tests/fixtures/tui/plan-in-place-40x15.txt +++ b/tests/fixtures/tui/plan-in-place-40x15.txt @@ -4,12 +4,12 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB > Close Options - - A Close B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-in-place-80x24.txt b/tests/fixtures/tui/plan-in-place-80x24.txt index 8cf344f67..58631365c 100644 --- a/tests/fixtures/tui/plan-in-place-80x24.txt +++ b/tests/fixtures/tui/plan-in-place-80x24.txt @@ -3,6 +3,7 @@ Everything is in place BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB > Close @@ -20,5 +21,4 @@ - A/Enter Close B/Esc Back Y/o Options ? Help diff --git a/tests/fixtures/tui/regions-120x40.txt b/tests/fixtures/tui/regions-120x40.txt index 271fd15d7..fc84c7547 100644 --- a/tests/fixtures/tui/regions-120x40.txt +++ b/tests/fixtures/tui/regions-120x40.txt @@ -5,8 +5,8 @@ > [1] North America [ ] Japan - - + None + Done diff --git a/tests/fixtures/tui/regions-40x12.txt b/tests/fixtures/tui/regions-40x12.txt index 70928bc8b..ef850abcb 100644 --- a/tests/fixtures/tui/regions-40x12.txt +++ b/tests/fixtures/tui/regions-40x12.txt @@ -8,5 +8,5 @@ > [1] North America [ ] Japan - + 1-2 of 2 A Tick B Done <> Order ? Help diff --git a/tests/fixtures/tui/regions-40x15.txt b/tests/fixtures/tui/regions-40x15.txt index fb7897ea1..2843333c6 100644 --- a/tests/fixtures/tui/regions-40x15.txt +++ b/tests/fixtures/tui/regions-40x15.txt @@ -8,8 +8,8 @@ > [1] North America [ ] Japan - - + None + Done A Tick B Done <> Order ? Help diff --git a/tests/fixtures/tui/regions-80x24.txt b/tests/fixtures/tui/regions-80x24.txt index fe09f21c6..6cefb781a 100644 --- a/tests/fixtures/tui/regions-80x24.txt +++ b/tests/fixtures/tui/regions-80x24.txt @@ -6,8 +6,8 @@ > [1] North America [ ] Japan - - + None + Done diff --git a/tests/fixtures/tui/result-120x40.txt b/tests/fixtures/tui/result-120x40.txt index 246502f05..550ec7724 100644 --- a/tests/fixtures/tui/result-120x40.txt +++ b/tests/fixtures/tui/result-120x40.txt @@ -3,6 +3,7 @@ 3 installed, 1 failed. BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 To put them back: python3 install.py \ --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 @@ -34,7 +35,6 @@ - A/Enter Exit diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt index 9b9270843..12339e200 100644 --- a/tests/fixtures/tui/result-40x12.txt +++ b/tests/fixtures/tui/result-40x12.txt @@ -4,9 +4,9 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py \ - 1-8 of 16 + 1-8 of 17 A Exit ^v Scroll diff --git a/tests/fixtures/tui/result-40x15.txt b/tests/fixtures/tui/result-40x15.txt index caf7d87d0..61db5344b 100644 --- a/tests/fixtures/tui/result-40x15.txt +++ b/tests/fixtures/tui/result-40x15.txt @@ -4,12 +4,12 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py \ --restore .../2026-10-23_1200 Shortened to fit this window. - Same choice without questions: - 1-11 of 16 + 1-11 of 17 A Exit ^v Scroll diff --git a/tests/fixtures/tui/result-80x24.txt b/tests/fixtures/tui/result-80x24.txt index 172cd1c52..ab4770376 100644 --- a/tests/fixtures/tui/result-80x24.txt +++ b/tests/fixtures/tui/result-80x24.txt @@ -3,6 +3,7 @@ 3 installed, 1 failed. BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 To put them back: python3 install.py \ @@ -20,5 +21,4 @@ - A/Enter Exit diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 757cd93ec..a77266857 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -462,11 +462,11 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB - > Close Options - A Close B Back Y Options === plan-in-place 40x15 RetroBIOS: RetroArch @@ -475,14 +475,14 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB > Close Options - - A Close B Back Y Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -490,6 +490,7 @@ Everything is in place BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB > Close @@ -507,47 +508,46 @@ - A Close B Back Y Options === systems 40x12 RetroBIOS: Choose systems + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - + 1-3 of 3 + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick + A Tick B Done === systems 40x15 RetroBIOS: Choose systems + Filter (with a keyboard) + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - - + Done + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick + A Tick B Done === systems 80x24 RetroBIOS: Choose systems + Filter (with a keyboard) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB + > [ ] nintendo-famicom-disk (ファミコン) in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 1 file, 16.0 KB - [x] sony-playstation 3 files, 1.0 MB - [ ] nintendo-famicom-disk (ファミコン) in place - + Done @@ -562,7 +562,7 @@ 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick + A Tick B Done === options 40x12 RetroBIOS: Options @@ -628,7 +628,7 @@ > [1] North America [ ] Japan - + 1-2 of 2 A Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -641,8 +641,8 @@ > [1] North America [ ] Japan - - + None + Done A Tick B Done <> Order @@ -655,8 +655,8 @@ > [1] North America [ ] Japan - - + None + Done @@ -674,43 +674,44 @@ === cores 40x12 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. - > All - None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed - A Tick all B Back X Tick + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. + + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + 1-2 of 2 + A Filter B Done === cores 40x15 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. - > All + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done - - A Tick all B Back X Tick + A Filter B Done === cores 80x24 RetroBIOS: Cores - Every core ticked keeps every core's files. Untick the cores you do not use: - the platform's own files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and + a core with no extra file reads only those. - > All + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done @@ -723,8 +724,7 @@ - - A Tick all B Back X Tick + A Filter B Done === preview 80x24 RetroBIOS: Preview @@ -890,11 +890,11 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log - A Exit === result 40x15 RetroBIOS: Finished with errors @@ -903,6 +903,7 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 @@ -910,7 +911,6 @@ - A Exit === result 80x24 RetroBIOS: Finished with errors @@ -918,6 +918,7 @@ 3 installed, 1 failed. BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log @@ -935,7 +936,6 @@ - A Exit === error 40x12 RetroBIOS: Something went wrong diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 57e529f2c..2e35c755c 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -462,11 +462,11 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB - > Close Options - A Close B Back Y Options === plan-in-place 40x15 RetroBIOS: RetroArch @@ -475,14 +475,14 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB > Close Options - - A Close B Back Y Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -490,6 +490,7 @@ Everything is in place BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB > Close @@ -507,47 +508,46 @@ - A Close B Back Y Options === systems 40x12 RetroBIOS: Choose systems + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - + 1-3 of 3 + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick + A Tick B Done === systems 40x15 RetroBIOS: Choose systems + Filter (with a keyboard) + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - - + Done + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick + A Tick B Done === systems 80x24 RetroBIOS: Choose systems + Filter (with a keyboard) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB + > [ ] nintendo-famicom-disk (ファミコン) in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 1 file, 16.0 KB - [x] sony-playstation 3 files, 1.0 MB - [ ] nintendo-famicom-disk (ファミコン) in place - + Done @@ -562,7 +562,7 @@ 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick + A Tick B Done === options 40x12 RetroBIOS: Options @@ -628,7 +628,7 @@ > [1] North America [ ] Japan - + 1-2 of 2 A Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -641,8 +641,8 @@ > [1] North America [ ] Japan - - + None + Done A Tick B Done <> Order @@ -655,8 +655,8 @@ > [1] North America [ ] Japan - - + None + Done @@ -674,43 +674,44 @@ === cores 40x12 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. - > All - None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed - A Tick all B Back X Tick + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. + + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + 1-2 of 2 + A Filter B Done === cores 40x15 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. - > All + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done - - A Tick all B Back X Tick + A Filter B Done === cores 80x24 RetroBIOS: Cores - Every core ticked keeps every core's files. Untick the cores you do not use: - the platform's own files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and + a core with no extra file reads only those. - > All + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done @@ -723,8 +724,7 @@ - - A Tick all B Back X Tick + A Filter B Done === preview 80x24 RetroBIOS: Preview @@ -890,11 +890,11 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log - A Exit === result 40x15 RetroBIOS: Finished with errors @@ -903,6 +903,7 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 @@ -910,7 +911,6 @@ - A Exit === result 80x24 RetroBIOS: Finished with errors @@ -918,6 +918,7 @@ 3 installed, 1 failed. BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log @@ -935,7 +936,6 @@ - A Exit === error 40x12 RetroBIOS: Something went wrong diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 7b58f1a18..0718cf57a 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -462,11 +462,11 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB - > Close Options - A Close B Back Y Options === plan-in-place 40x15 RetroBIOS: RetroArch @@ -475,14 +475,14 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB > Close Options - - A Close B Back Y Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -490,6 +490,7 @@ Everything is in place BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB > Close @@ -507,47 +508,46 @@ - A Close B Back Y Options === systems 40x12 RetroBIOS: Choose systems + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - + 1-3 of 3 + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick + A Tick B Done === systems 40x15 RetroBIOS: Choose systems + Filter (with a keyboard) + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - - + Done + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick + A Tick B Done === systems 80x24 RetroBIOS: Choose systems + Filter (with a keyboard) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB + > [ ] nintendo-famicom-disk (ファミコン) in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 1 file, 16.0 KB - [x] sony-playstation 3 files, 1.0 MB - [ ] nintendo-famicom-disk (ファミコン) in place - + Done @@ -562,7 +562,7 @@ 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick + A Tick B Done === options 40x12 RetroBIOS: Options @@ -628,7 +628,7 @@ > [1] North America [ ] Japan - + 1-2 of 2 A Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -641,8 +641,8 @@ > [1] North America [ ] Japan - - + None + Done A Tick B Done <> Order @@ -655,8 +655,8 @@ > [1] North America [ ] Japan - - + None + Done @@ -674,43 +674,44 @@ === cores 40x12 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. - > All - None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed - A Tick all B Back X Tick + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. + + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + 1-2 of 2 + A Filter B Done === cores 40x15 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. - > All + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done - - A Tick all B Back X Tick + A Filter B Done === cores 80x24 RetroBIOS: Cores - Every core ticked keeps every core's files. Untick the cores you do not use: - the platform's own files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and + a core with no extra file reads only those. - > All + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done @@ -723,8 +724,7 @@ - - A Tick all B Back X Tick + A Filter B Done === preview 80x24 RetroBIOS: Preview @@ -890,11 +890,11 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log - A Exit === result 40x15 RetroBIOS: Finished with errors @@ -903,6 +903,7 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 @@ -910,7 +911,6 @@ - A Exit === result 80x24 RetroBIOS: Finished with errors @@ -918,6 +918,7 @@ 3 installed, 1 failed. BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log @@ -935,7 +936,6 @@ - A Exit === error 40x12 RetroBIOS: Something went wrong diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index 27618219c..a5dee8a02 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -437,11 +437,11 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB - > Close Options - Enter Close Esc Back o Options === plan-in-place 40x15 RetroBIOS: RetroArch @@ -450,14 +450,14 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB > Close Options - - Enter Close Esc Back o Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -465,6 +465,7 @@ Everything is in place BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB > Close @@ -482,47 +483,46 @@ - Enter Close Esc Back o Options ? Help === systems 40x12 RetroBIOS: Choose systems + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (/ to type) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - + 1-3 of 3 + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - Enter Continue Esc Back Space Tick + Enter Tick Esc Done ? Help === systems 40x15 RetroBIOS: Choose systems + Filter (/ to type) + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (/ to type) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - - + Done + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - Enter Continue Esc Back Space Tick + Enter Tick Esc Done ? Help === systems 80x24 RetroBIOS: Choose systems + Filter (/ to type) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB + > [ ] nintendo-famicom-disk (ファミコン) in place All None - Filter (/ to type) - > [x] nintendo-gba (gba) 1 file, 16.0 KB - [x] sony-playstation 3 files, 1.0 MB - [ ] nintendo-famicom-disk (ファミコン) in place - + Done @@ -537,7 +537,7 @@ 2 of 3 systems: 4 files, 1.0 MB - Enter Continue Esc Back Space Tick ? Help + Enter Tick Esc Done ? Help === options 40x12 RetroBIOS: Options @@ -603,7 +603,7 @@ > [1] North America [ ] Japan - + 1-2 of 2 Enter Tick Esc Done <> Order ? Help === regions 40x15 RetroBIOS: Preferred regions @@ -616,8 +616,8 @@ > [1] North America [ ] Japan - - + None + Done Enter Tick Esc Done <> Order ? Help @@ -630,8 +630,8 @@ > [1] North America [ ] Japan - - + None + Done @@ -649,43 +649,44 @@ === cores 40x12 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. - > All - None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed - Enter Tick all Esc Back Space Tick + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. + + > Filter (/ to type) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + 1-2 of 2 + Enter Filter Esc Done ? Help === cores 40x15 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. - > All + > Filter (/ to type) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed + Done - - Enter Tick all Esc Back Space Tick + Enter Filter Esc Done ? Help === cores 80x24 RetroBIOS: Cores - Every core ticked keeps every core's files. Untick the cores you do not use: - the platform's own files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and + a core with no extra file reads only those. - > All + > Filter (/ to type) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (/ to type) - [ ] mesen - [x] pcsx_rearmed + Done @@ -698,8 +699,7 @@ - - Enter Tick all Esc Back Space Tick ? Help + Enter Filter Esc Done ? Help === preview 40x12 RetroBIOS: Preview @@ -923,11 +923,11 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py \ - 1-8 of 16 + 1-8 of 17 Enter Exit ^v Scroll === result 40x15 RetroBIOS: Finished with errors @@ -936,14 +936,14 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py \ --restore .../2026-10-23_1200 Shortened to fit this window. - Same choice without questions: - 1-11 of 16 + 1-11 of 17 Enter Exit ^v Scroll === result 80x24 RetroBIOS: Finished with errors @@ -951,6 +951,7 @@ 3 installed, 1 failed. BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 To put them back: python3 install.py \ @@ -968,7 +969,6 @@ - Enter Exit === error 40x12 RetroBIOS: Something went wrong diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 80d5b8cbd..064a1f0cd 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -491,11 +491,11 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB - > Close Options - A Close R2 Back L2 Options === plan-in-place 40x15 RetroBIOS: RetroArch @@ -504,14 +504,14 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB > Close Options - - A Close R2 Back L2 Options === plan-in-place 80x24 RetroBIOS: RetroArch @@ -519,6 +519,7 @@ Everything is in place BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB > Close @@ -536,47 +537,46 @@ - A Close R2 Back L2 Options === systems 40x12 RetroBIOS: Choose systems + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - + 1-3 of 3 + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - Start Continue A Tick R2 Back + A Tick R2 Done === systems 40x15 RetroBIOS: Choose systems + Filter (with a keyboard) + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - - + Done + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - Start Continue A Tick R2 Back + A Tick R2 Done === systems 80x24 RetroBIOS: Choose systems + Filter (with a keyboard) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB + > [ ] nintendo-famicom-disk (ファミコン) in place All None - Filter (with a keyboard) - > [x] nintendo-gba (gba) 1 file, 16.0 KB - [x] sony-playstation 3 files, 1.0 MB - [ ] nintendo-famicom-disk (ファミコン) in place - + Done @@ -591,7 +591,7 @@ 2 of 3 systems: 4 files, 1.0 MB - Start Continue A Tick R2 Back + A Tick R2 Done === options 40x12 RetroBIOS: Options @@ -657,7 +657,7 @@ > [1] North America [ ] Japan - + 1-2 of 2 A Tick R2 Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -670,8 +670,8 @@ > [1] North America [ ] Japan - - + None + Done A Tick R2 Done <> Order @@ -684,8 +684,8 @@ > [1] North America [ ] Japan - - + None + Done @@ -703,43 +703,44 @@ === cores 40x12 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. - > All - None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed - A Tick all R2 Back + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. + + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + 1-2 of 2 + A Filter R2 Done === cores 40x15 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. - > All + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done - - A Tick all R2 Back + A Filter R2 Done === cores 80x24 RetroBIOS: Cores - Every core ticked keeps every core's files. Untick the cores you do not use: - the platform's own files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and + a core with no extra file reads only those. - > All + > Filter (with a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (with a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done @@ -752,8 +753,7 @@ - - A Tick all R2 Back + A Filter R2 Done === preview 80x24 RetroBIOS: Preview @@ -919,11 +919,11 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log - A Exit === result 40x15 RetroBIOS: Finished with errors @@ -932,6 +932,7 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 @@ -939,7 +940,6 @@ - A Exit === result 80x24 RetroBIOS: Finished with errors @@ -947,6 +947,7 @@ 3 installed, 1 failed. BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log @@ -964,7 +965,6 @@ - A Exit === error 40x12 RetroBIOS: Something went wrong diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 1ad851b69..0b540a875 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -491,11 +491,11 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB - > Close Options - Start Close B Back === plan-in-place 40x15 RetroBIOS: RetroArch @@ -504,14 +504,14 @@ BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, + the old one kept in a backup folder. Free space: 50.0 GB > Close Options - - Start Close B Back === plan-in-place 80x24 RetroBIOS: RetroArch @@ -519,6 +519,7 @@ Everything is in place BIOS folder: ~/.config/retroarch/system 5 in place, 0 missing, 0 wrong copies + 1 of the 0 wrong copies is replaced, the old one kept in a backup folder. Free space: 50.0 GB > Close @@ -536,47 +537,46 @@ - Start Close B Back === systems 40x12 RetroBIOS: Choose systems + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (Select shows a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - + 1-3 of 3 + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - Start Continue B Back Y Tick + Start Tick B Done === systems 40x15 RetroBIOS: Choose systems + Filter (Select shows a keyboard) + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (Select shows a keyboard) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - - + Done + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - Start Continue B Back Y Tick + Start Tick B Done === systems 80x24 RetroBIOS: Choose systems + Filter (Select shows a keyboard) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB + > [ ] nintendo-famicom-disk (ファミコン) in place All None - Filter (Select shows a keyboard) - > [x] nintendo-gba (gba) 1 file, 16.0 KB - [x] sony-playstation 3 files, 1.0 MB - [ ] nintendo-famicom-disk (ファミコン) in place - + Done @@ -591,7 +591,7 @@ 2 of 3 systems: 4 files, 1.0 MB - Start Continue B Back Y Tick + Start Tick B Done === options 40x12 RetroBIOS: Options @@ -657,7 +657,7 @@ > [1] North America [ ] Japan - + 1-2 of 2 Start Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -670,8 +670,8 @@ > [1] North America [ ] Japan - - + None + Done Start Tick B Done <> Order @@ -684,8 +684,8 @@ > [1] North America [ ] Japan - - + None + Done @@ -703,43 +703,44 @@ === cores 40x12 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. - > All - None - Filter (Select shows a keyboard) - [ ] mesen - [x] pcsx_rearmed - Start Tick all B Back Y Tick + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. + + > Filter (Select shows a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + 1-2 of 2 + Start Filter B Done === cores 40x15 RetroBIOS: Cores - Every core ticked keeps every core's - files. Untick the cores you do not - use: the platform's own files are - always kept. + Untick the cores you do not use: the + platform's own files are always + kept, and a core with no extra file + reads only those. - > All + > Filter (Select shows a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (Select shows a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done - - Start Tick all B Back Y Tick + Start Filter B Done === cores 80x24 RetroBIOS: Cores - Every core ticked keeps every core's files. Untick the cores you do not use: - the platform's own files are always kept. + Untick the cores you do not use: the platform's own files are always kept, and + a core with no extra file reads only those. - > All + > Filter (Select shows a keyboard) + [ ] mesen 1 file, 8.0 KB + [x] pcsx_rearmed 1 file, 4 B + All None - Filter (Select shows a keyboard) - [ ] mesen - [x] pcsx_rearmed + Done @@ -752,8 +753,7 @@ - - Start Tick all B Back Y Tick + Start Filter B Done === preview 40x12 RetroBIOS: Preview @@ -977,11 +977,11 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log - Start Exit === result 40x15 RetroBIOS: Finished with errors @@ -990,6 +990,7 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 @@ -997,7 +998,6 @@ - Start Exit === result 80x24 RetroBIOS: Finished with errors @@ -1005,6 +1005,7 @@ 3 installed, 1 failed. BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) + Run it again to retry only those. The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Details: /tmp/retrobios-x/install.log @@ -1022,7 +1023,6 @@ - Start Exit === error 40x12 RetroBIOS: Something went wrong diff --git a/tests/fixtures/tui/systems-120x40.txt b/tests/fixtures/tui/systems-120x40.txt index 0580bbaa7..8937114b5 100644 --- a/tests/fixtures/tui/systems-120x40.txt +++ b/tests/fixtures/tui/systems-120x40.txt @@ -1,12 +1,12 @@ RetroBIOS: Choose systems + Filter (/ to type) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB + > [ ] nintendo-famicom-disk (ファミコン) in place All None - Filter (/ to type) - > [x] nintendo-gba (gba) 1 file, 16.0 KB - [x] sony-playstation 3 files, 1.0 MB - [ ] nintendo-famicom-disk (ファミコン) in place - + Done @@ -37,4 +37,4 @@ 2 of 3 systems: 4 files, 1.0 MB - A/Enter Continue B/Esc Back X/Space Tick ? Help + A/Enter Tick B/Esc Done ? Help diff --git a/tests/fixtures/tui/systems-40x12.txt b/tests/fixtures/tui/systems-40x12.txt index 175f36cc5..82bb1c13a 100644 --- a/tests/fixtures/tui/systems-40x12.txt +++ b/tests/fixtures/tui/systems-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: Choose systems + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (/ to type) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - + 1-3 of 3 + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick ? Help + A Tick B Done ? Help diff --git a/tests/fixtures/tui/systems-40x15.txt b/tests/fixtures/tui/systems-40x15.txt index 60f7b0ccc..95c9c61da 100644 --- a/tests/fixtures/tui/systems-40x15.txt +++ b/tests/fixtures/tui/systems-40x15.txt @@ -1,15 +1,15 @@ RetroBIOS: Choose systems + Filter (/ to type) + [x] nintendo-gba (gba) 16.0 KB + [x] sony-playstation 1.0 MB + > [ ] nintendo-famicom-disk in place All None - Filter (/ to type) - > [x] nintendo-gba (gba) 16.0 KB - [x] sony-playstation 1.0 MB - [ ] nintendo-famicom-disk in place - - - + Done + nintendo-famicom-disk (ファミコン): in + place 2 of 3 systems: 4 files, 1.0 MB - A Continue B Back X Tick ? Help + A Tick B Done ? Help diff --git a/tests/fixtures/tui/systems-80x24.txt b/tests/fixtures/tui/systems-80x24.txt index 317c5e252..e346db6ac 100644 --- a/tests/fixtures/tui/systems-80x24.txt +++ b/tests/fixtures/tui/systems-80x24.txt @@ -1,12 +1,12 @@ RetroBIOS: Choose systems + Filter (/ to type) + [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB + > [ ] nintendo-famicom-disk (ファミコン) in place All None - Filter (/ to type) - > [x] nintendo-gba (gba) 1 file, 16.0 KB - [x] sony-playstation 3 files, 1.0 MB - [ ] nintendo-famicom-disk (ファミコン) in place - + Done @@ -21,4 +21,4 @@ 2 of 3 systems: 4 files, 1.0 MB - A/Enter Continue B/Esc Back X/Space Tick ? Help + A/Enter Tick B/Esc Done ? Help diff --git a/tests/netguard/__init__.py b/tests/netguard/__init__.py new file mode 100644 index 000000000..c2ffc9a2b --- /dev/null +++ b/tests/netguard/__init__.py @@ -0,0 +1,33 @@ +"""The guard a test module turns on for itself and the Python it starts +(sitecustomize). module_guard() gives the setUpModule and tearDownModule +pair: the module fails at its end when anything tried to leave the loopback.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Callable + +from .sitecustomize import disable, enable, refused + +__all__ = ["module_guard", "enable", "disable", "refused"] + + +def module_guard(name: str) -> tuple[Callable[[], None], Callable[[], None]]: + log = Path(__file__).resolve().parents[2] / "tmp" / "tests" / f"netlog-{name}-{os.getpid()}" + state: dict = {} + + def set_up() -> None: + log.parent.mkdir(parents=True, exist_ok=True) + state["before"] = enable(str(log)) + + def tear_down() -> None: + disable(state.pop("before", {})) + tried = refused(str(log)) + log.unlink(missing_ok=True) + if tried: + raise AssertionError( + f"{len(tried)} connections off the loopback during {name} (pid host): " + + ", ".join(tried[:10])) + + return set_up, tear_down diff --git a/tests/netguard/sitecustomize.py b/tests/netguard/sitecustomize.py new file mode 100644 index 000000000..2aa5f732d --- /dev/null +++ b/tests/netguard/sitecustomize.py @@ -0,0 +1,133 @@ +"""The machine running the installer tests, kept out of them. + +Python imports this module at start in every process a test starts with +tests/netguard on PYTHONPATH (enable), and the test module +calls guard() for its own process. Two things the host has never reach a +test: a connection off the loopback, which is refused (OSError, as a network +that is down) and written to RETROBIOS_TEST_NETLOG, and the cards and drives +mounted under /media, /run/media, /mnt and /Volumes, which read as folders +no drive is mounted on. A test module passed on a machine with no network +and no card, and failed with an AmberELEC card in the reader, while 261 +connections to GitHub left the module in a run (code_audit_24 P3-13). +""" + +from __future__ import annotations + +import errno +import os +import socket +import sys + +LOOPBACK = frozenset({"127.0.0.1", "::1", "localhost", "0.0.0.0", ""}) +MOUNT_ROOTS = ("/media", "/run/media", "/mnt", "/Volumes") +NETLOG = "RETROBIOS_TEST_NETLOG" +# What guard() replaced, for unguard(). +_SAVED: dict = {} + + +def _refuse(host: object) -> None: + where = os.environ.get(NETLOG) + if where: + with open(where, "a", encoding="utf-8") as log: + log.write(f"{os.getpid()} {host} {' '.join(sys.argv)[:300]}\n") + raise OSError(errno.ENETUNREACH, f"a test makes no request to {host}") + + +def guard() -> None: + """Refuse every connection off the loopback, hide the host's mounts.""" + if _SAVED: + return + _SAVED.update(connect=socket.socket.__dict__.get("connect"), + connect_ex=socket.socket.__dict__.get("connect_ex"), + getaddrinfo=socket.getaddrinfo, ismount=os.path.ismount) + connect = socket.socket.connect + connect_ex = socket.socket.connect_ex + getaddrinfo = socket.getaddrinfo + + def host_of(address: object) -> object: + return address[0] if isinstance(address, tuple) and address else None + + def checked(sock: socket.socket, address: object) -> None: + if sock.family in (socket.AF_INET, socket.AF_INET6) and \ + host_of(address) not in LOOPBACK: + _refuse(host_of(address)) + + def guarded_connect(self: socket.socket, address: object) -> None: + checked(self, address) + return connect(self, address) + + def guarded_connect_ex(self: socket.socket, address: object) -> int: + checked(self, address) + return connect_ex(self, address) + + def guarded_getaddrinfo(host: object, *args: object, **kwargs: object) -> list: + if host is not None and str(host) not in LOOPBACK: + _refuse(host) + return getaddrinfo(host, *args, **kwargs) + + socket.socket.connect = guarded_connect # type: ignore[method-assign] + socket.socket.connect_ex = guarded_connect_ex # type: ignore[method-assign] + socket.getaddrinfo = guarded_getaddrinfo # type: ignore[assignment] + + ismount = os.path.ismount + + def guarded_ismount(path: object) -> bool: + text = os.fspath(path) if not isinstance(path, str) else path + if isinstance(text, bytes): + text = text.decode(errors="replace") + if any(text == root or text.startswith(root + "/") for root in MOUNT_ROOTS): + return False + return ismount(path) + + os.path.ismount = guarded_ismount # type: ignore[assignment] + + +def unguard() -> None: + """The process as it was before guard().""" + if not _SAVED: + return + for name in ("connect", "connect_ex"): + if _SAVED[name] is None: + delattr(socket.socket, name) + else: + setattr(socket.socket, name, _SAVED[name]) + socket.getaddrinfo = _SAVED["getaddrinfo"] + os.path.ismount = _SAVED["ismount"] + _SAVED.clear() + + +def enable(log: str) -> dict: + """Guard this process and every Python it starts, the refusals written + to log. Returns the variables it changed, for disable().""" + before = {name: os.environ.get(name) for name in ("PYTHONPATH", NETLOG)} + here = os.path.dirname(os.path.abspath(__file__)) + paths = [here, *[p for p in os.environ.get("PYTHONPATH", "").split(os.pathsep) if p]] + os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(paths)) + os.environ[NETLOG] = log + with open(log, "w", encoding="utf-8"): + pass + guard() + return before + + +def disable(before: dict) -> None: + """This process and the Python it starts as before enable().""" + unguard() + for name, value in before.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + +def refused(log: str) -> list[str]: + """The connections refused since enable(log), one line each.""" + try: + with open(log, encoding="utf-8") as text: + return [line.strip() for line in text if line.strip()] + except OSError: + return [] + + +if os.environ.get(NETLOG): + guard() diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index badc4eba8..286bd0b28 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -175,6 +175,59 @@ class InstallerOutputExample(unittest.TestCase): self.assertLessEqual(abs(added - float(total) * units[unit]), 0.15 * units[unit]) +class InstallerSamplesSayWhatTheCodePrints(unittest.TestCase): + """code_audit_24 P3-8 to P3-12: samples typed by hand outside the + generated blocks, a sentence on a log read as the terminal's, pages that + described the generator as tables alone, and a sandbox that asked DNS.""" + + def test_the_refused_and_from_samples_are_generated(self): + import install + + page = _page("installer.md") + refused = _generated(page, "check-refused") + self.assertRegex(refused, r"\n \d+ files? [a-z ]+:\n") + self.assertIn("read by beetle_psx, pcsx_rearmed at", refused) + self.assertNotIn("opened at", page) + self.assertIn('"read by ', inspect.getsource(install.opened_line)) + ending = _generated(page, "from-run") + self.assertRegex(ending, r"Done\. \d+ files installed\.\n \d+ files copied from ") + self.assertNotIn("a line under\n`Done`", page) + self.assertNotIn("38 files copied from E:\\, 4 downloaded", page) + + def test_one_line_per_file_is_said_of_a_log(self): + page = _page("installer.md") + at = page.index("") + before = page[page.rindex("\n\n", 0, at - 2):at] + self.assertIn("At a terminal one progress line", before) + self.assertIn("A pipe or a log gets one line per file", before) + + def test_the_generator_pages_name_the_run_samples_and_step_7(self): + tools = _page("tools.md") + row = next(line for line in tools.splitlines() + if line.startswith("| `generate_installer_doc.py`")) + self.assertIn("run samples", row) + self.assertIn("generate_installer_doc.py", next( + line for line in tools.splitlines() if line.startswith("| 7 |"))) + self.assertIn("run samples", _page("installer.md").split( + "The tables of options, events, exit codes, keys and card markers", 1)[1][:600]) + self.assertIn("generate_installer_doc.py", next( + line for line in _page("adding-a-platform.md").splitlines() + if line.startswith("7. "))) + + def test_the_sandbox_looks_nothing_up(self): + import socket + from unittest import mock + + import generate_installer_doc + + def no_lookup(*_args: object) -> str: + raise AssertionError("the sandbox asked for a name") + + with mock.patch.object(socket, "getfqdn", no_lookup), \ + generate_installer_doc.Sandbox() as box: + self.assertEqual(box.server.server_name, "127.0.0.1") + + class OneResolutionChain(unittest.TestCase): """architecture.md drew path_suffix as the first step, before SHA1, while the code and verification-modes.md try it after every hash.""" diff --git a/tests/test_install.py b/tests/test_install.py index 60d2fe8fe..6ddeebd43 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -31,6 +31,13 @@ _spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.p install = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(install) +sys.path.insert(0, str(REPO_ROOT / "tests")) +from netguard import module_guard # noqa: E402 + +# No connection off the loopback and no card of the host reaches a test +# (code_audit_24 P3-13): the module fails at its end otherwise. +setUpModule, tearDownModule = module_guard("test_install") + def _tempdir(test: unittest.TestCase) -> str: """A scratch directory under the repository's tmp/, removed after the test. diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 58b449467..63c888c59 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -35,7 +35,7 @@ import types import unittest import warnings import zipfile -from pathlib import Path, PureWindowsPath +from pathlib import Path, PurePosixPath, PureWindowsPath from unittest import mock REPO_ROOT = Path(__file__).resolve().parent.parent @@ -45,6 +45,13 @@ sys.path.insert(0, str(REPO_ROOT / "tests")) import install # noqa: E402 from test_install_app import make_card # noqa: E402 from test_install_selection import FakeRepository, _QuietHandler # noqa: E402 +from netguard import module_guard # noqa: E402 + +# No connection off the loopback and no card of the host reaches a test, in +# this process or one it starts (code_audit_24 P3-13): 261 connections to +# GitHub left this module in a run, and two tests failed with an AmberELEC +# card in the reader. The module fails at its end when one is tried. +setUpModule, tearDownModule = module_guard("test_install_tui") try: import pyte @@ -5801,9 +5808,15 @@ def _card_from_rules(root: Path, setup: install.CardSetup) -> Path: (root / path).parent.mkdir(parents=True, exist_ok=True) (root / path).write_text(text + "\n", encoding="utf-8") continue + if rule.method == "games_part": + # What the system writes beside its bios folder on that part. + (root / "bios").mkdir(exist_ok=True) for value in rule.values: (root / value).parent.mkdir(parents=True, exist_ok=True) - (root / value).write_text("", encoding="utf-8") + if rule.method == "games_part" and "." not in PurePosixPath(value).name: + (root / value).mkdir(exist_ok=True) + else: + (root / value).write_text("", encoding="utf-8") return root @@ -13970,5 +13983,420 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): words.index(install.TUI_INSTALL_ROW)) +def core_list(path: Path, files: list) -> Path: + path.write_text(json.dumps({"manifest_version": 2, "platform": "retroarch", + "categorized": True, "base_destination": "system", + "save_directory": {"folder": "saves", "by_core": True}, + "files": files}), encoding="utf-8") + return path + + +class AuditTwentyFourAndSeventeen(unittest.TestCase): + """code_audit_24 and tui_audit_17: the cores the run accepts, one tick + list, a folder failure said as it happened, every proven card named, + reads that show, and tests that leave the host alone.""" + + setUp = LineModeAndScreenDecideAlike.setUp + line_run = LineModeAndScreenDecideAlike.line_run + entry = staticmethod(LineModeAndScreenDecideAlike.entry) + drive = AuditTwentyTwoAndFifteen.drive + first_screen = AuditTwentyTwoAndFifteen.first_screen + line_from = AuditTwentyThreeAndSixteen.line_from + + # Root cause 1: the cores a choice is made among are the run's. + + def files(self) -> list: + entry = self.entry + return [ + entry("scph5501.bin", b"S" * 8, ["sony-playstation"]), + entry("pcsx/extra.bin", b"P" * 4, ["sony-playstation"], cores=["pcsx_rearmed"]), + entry("fc/disksys.rom", b"F" * 8, ["nintendo-famicom-disk"], cores=["mesen"]), + # Read from the save directory alone: set aside where it is not known. + entry("3ds/aes_keys.txt", b"K" * 4, ["nintendo-3ds"], cores=["azahar"], + root="save"), + # Game data alone: --no-game-data leaves its core no file. + entry("quake/pak0.pak", b"Q" * 4, ["other"], cores=["tyrquake"], + category="game_data"), + ] + + def check_events(self, *extra: str) -> list: + listing = core_list(self.root / "cores.json", self.files()) + dest = self.root / "dest" + dest.mkdir(exist_ok=True) + done = install_run("--platform", "retroarch", "--dest", str(dest), "--manifest", + str(listing), "--check", "--events", *extra, + env={"HOME": str(self.root / "home"), + "RETROBIOS_INSTALL_OFFLINE": "1"}) + return [json.loads(line) for line in done.stdout.splitlines() if line.startswith("{")] + + def cores_state(self, events: list, no_game_data: bool = False) -> install.TuiState: + state = checked_state() + state.manifest = {"manifest_version": 2, "platform": "retroarch", "categorized": True, + "files": self.files()} + state.set_aside = set(next(event for event in events + if event["event"] == "set_aside")["dests"]) + state.no_game_data = no_game_data + return state + + def test_cores_lists_what_the_run_accepts_and_an_untick_runs(self): + """tui_audit_17 P1-1: Cores listed 189 cores where --core accepted 181, + and an untick sent the 8 save-folder-only cores: "unknown core".""" + events = self.check_events() + self.assertEqual(next(e for e in events if e["event"] == "set_aside")["dests"], + ["3ds/aes_keys.txt"]) + state = self.cores_state(events) + self.assertNotIn("azahar", install.tui_choices(state)["cores"]) + state.stack = ["setup", "plan", "options", "cores"] + row = next(r for r in install.tui_rows(state) if r.value == "mesen") + install._tick(state, row, False) + cores = [value for flag, value in install.tui_flags(state) if flag == "--core"] + self.assertEqual(sorted(cores), ["pcsx_rearmed", "tyrquake"]) + listing = self.root / "cores.json" + done = install_run("--platform", "retroarch", "--dest", str(self.root / "dest"), + "--manifest", str(listing), "--dry-run", + *[f"--core={core}" for core in cores], + env={"HOME": str(self.root / "home"), + "RETROBIOS_INSTALL_OFFLINE": "1"}) + self.assertNotIn("unknown core", done.stdout + done.stderr) + + def test_both_modes_count_out_of_the_cores_the_run_keeps(self): + """code_audit_24 P3-3: line mode said "1 of 181 cores", the screen + "1 of 189", and 22 more under --no-game-data.""" + for no_game_data in (False, True): + with self.subTest(no_game_data=no_game_data): + extra = ("--no-game-data",) if no_game_data else () + state = self.cores_state(self.check_events(*extra), no_game_data) + expected = ["mesen", "pcsx_rearmed"] + ([] if no_game_data else ["tyrquake"]) + self.assertEqual(install.tui_choices(state)["cores"], expected) + listing = core_list(self.root / "line.json", self.files()) + # A folder not named system: the save directory is not known. + text, _asked = self.line_run( + _args(manifest=listing, dest=self.root / "dest", declared_only=False, + dry_run=True, no_game_data=no_game_data), ["c", "", "1"]) + head = text.rsplit("Counted again for your choices", 1)[1] + self.assertIn(f"1 of {len(expected)} cores", head) + state.cores = ["mesen"] + self.assertIn(f"1 of {len(expected)}", + next(r.label for r in install.tui_rows( + self.options(state)) if r.action == "cores")) + + @staticmethod + def text(state: install.TuiState, cols: int, rows: int) -> str: + """The frame drawn, its folded lines joined into one text.""" + return " ".join(" ".join(str(line).split()) for line in + install.tui_view(state, cols, rows)) + + @staticmethod + def options(state: install.TuiState) -> install.TuiState: + state.stack = ["setup", "plan", "options"] + return state + + def test_an_untick_keeps_the_other_systems_cores(self): + """code_audit_24 P3-1: with --system sony-playstation, one untick on + pcsx_rearmed cleared mesen, drawn ticked under the other systems.""" + state = checked_state() + state.ticked = {"sony-playstation"} + state.stack = ["setup", "plan", "options", "cores"] + state.cores_all = True + boxes = {r.value: r.label[:3] for r in install.tui_rows(state) if r.action == "core"} + self.assertEqual(boxes, {"mesen": "[x]", "pcsx_rearmed": "[x]"}) + row = next(r for r in install.tui_rows(state) if r.value == "pcsx_rearmed") + install._tick(state, row, None) + boxes = {r.value: r.label[:3] for r in install.tui_rows(state) if r.action == "core"} + self.assertEqual(boxes, {"mesen": "[x]", "pcsx_rearmed": "[ ]"}) + # The --core given is the cores ticked, exactly. + self.assertEqual([v for f, v in install.tui_flags(state) if f == "--core"], ["mesen"]) + + def test_a_core_of_another_system_is_not_all_cores(self): + """code_audit_24 P3-2: mesen ticked alone with the PlayStation chosen + read "Cores: all", the run keeping none of pcsx_rearmed's files.""" + state = checked_state() + state.ticked = {"sony-playstation"} + state.cores = ["mesen"] + options = self.options(state) + self.assertIn("Cores: 0 of 1", [r.label for r in install.tui_rows(options)]) + state.stack = ["setup", "plan"] + self.assertIn("0 of 1 cores", self.text(state, 100, 30)) + self.assertEqual(install.core_count_words(["mesen"], ["pcsx_rearmed"]), "0 of 1 cores") + + def test_none_then_done_says_why_on_a_long_list(self): + """tui_audit_17 P2-4: on 189 cores the status and the sentence were + dropped, Done with nothing ticked did nothing visible.""" + state = checked_state() + state.manifest = {**MANIFEST, "files": [ + *MANIFEST["files"], + *({"dest": f"x/{n}.bin", "size": 1, "systems": ["sony-playstation"], + "cores": [f"core{n:03}"]} for n in range(60))]} + state.stack = ["setup", "plan", "options", "cores"] + state.focus["cores"] = install._first_item(state) + press(state, "n") + done = next(i for i, r in enumerate(install.tui_rows(state)) if r.action == "done") + state.focus["cores"] = done + press(state, "enter") + self.assertEqual(state.screen, "cores") + for cols, rows in ((80, 24), (40, 12)): + text = self.text(state, cols, rows) + self.assertIn("No core is ticked", text) + self.assertIn("Untick the cores", text) + press(state, "esc") + self.assertEqual(state.screen, "cores") + + def test_core_rows_say_what_each_core_adds(self): + """tui_audit_17 P3-2: line mode counted each core's files, the + screen named the cores alone.""" + state = checked_state() + state.stack = ["setup", "plan", "options", "cores"] + rows = {r.value: r.right for r in install.tui_rows(state) if r.action == "core"} + self.assertEqual(rows["pcsx_rearmed"], "1 file, 4 B") + state.cores_all = True + rows = {r.value: r.right for r in install.tui_rows(state) if r.action == "core"} + self.assertEqual(rows["mesen"], "1 file, 8.0 KB") + + def test_one_core_has_no_cores_row(self): + """tui_audit_17 P3-3: a list with one core offered Cores, where line + mode asks nothing.""" + state = checked_state() + state.manifest = {**MANIFEST, "files": [ + entry for entry in MANIFEST["files"] if entry["dest"] != "fc/disksys.rom"]} + self.assertNotIn("cores", [r.action for r in install.tui_rows(self.options(state))]) + + # Root cause 2: one tick list. + + def test_the_three_lists_open_and_answer_alike(self): + """tui_audit_17 P3-1: Choose systems opened on None, A unticking + every system, Cores closed on A, regions ticked on A.""" + for route in ("both", "gptokeyb", "muxterm", "miyoo"): + for screen, opener in (("systems", ("plan", "systems")), + ("cores", ("options", "cores")), + ("regions", ("options", "regions"))): + with self.subTest(route=route, screen=screen): + state = checked_state() + state.pad = route + if opener[0] == "options": + press(state, "o") + state.focus[state.screen] = next( + i for i, r in enumerate(install.tui_rows(state)) + if r.action == opener[1]) + press(state, "enter") + self.assertEqual(state.screen, screen) + rows = install.tui_rows(state) + focused = rows[state.focus[screen]] + self.assertIn(focused.action, install.TUI_TICK_ROWS) + self.assertEqual([r.action for r in rows[-2:]], ["none", "done"]) + hint = install._TUI_VIEWS[screen](state, 78, 21)[2] + self.assertEqual(hint[:2], ("A/Enter Tick", "B/Esc Done")) + before = install.tui_flags(state) + press(state, "enter") + self.assertEqual(state.screen, screen) + self.assertNotEqual(install.tui_flags(state), before) + + # Root cause 3: a folder failure as it happened. + + def test_a_folder_that_cannot_be_created_ends_on_its_reason(self): + """tui_audit_17 P2-1: the result said "22 files not fetched + (RETROBIOS_INSTALL_OFFLINE=1)" and sent the user for a pack.""" + for os_type in ("linux", "android"): + with self.subTest(os_type=os_type): + state = checked_state() + state.os_type = os_type + install.tui_update(state, Key("enter")) + for event in ({"event": "error", "stage": "dest", "reason": "not_writable", + "platform": "retroarch", + "message": f"{SYSTEM_DIR} cannot be created: Permission denied"}, + {"event": "summary", "mode": "install", "installed": 0, + "up_to_date": 0, "pending": 4, "failed": [], "skipped": [], + "left_out": [], "kept": [], "interrupted": False}): + install.tui_receive(state, ("event", state.run_id, event)) + install.tui_receive(state, ("ended", state.run_id, 2, [])) + title, lines = install.tui_outcome(state) + text = " ".join(map(str, lines)) + self.assertEqual(title, "Nothing installed") + self.assertIn("cannot be created (permission denied)", text) + self.assertNotIn("not fetched", text) + self.assertNotIn("not in", text.split("cannot be created")[0]) + self.assertEqual("termux-setup-storage" in text, os_type == "android") + self.assertNotIn("sudo", text if os_type == "android" else "") + + def test_android_never_offers_sudo_and_a_phone_plan_says_why(self): + """tui_audit_17 P2-2: Termux was offered sudo, and at 40x12 the plan + offered Install anyway with no word of why.""" + hint = install.Invocation() + hint.os_type = "android" + remedy = install.write_remedy(Path("/storage/emulated/0/RetroArch/system"), + "retroarch", hint) + self.assertEqual(remedy, [install.TERMUX_STORAGE_WORDS]) + state = checked_state({"free": 50 * 1024 ** 3, "writable": False, "cores": []}) + state.os_type = "android" + for cols, rows in ((40, 12), (80, 24)): + text = self.text(state, cols, rows) + self.assertIn("You cannot write to this folder", text) + self.assertNotIn("sudo", text) + + # Root cause 4: every proven card named, the real marker said. + + def test_an_arkos_card_is_found_beside_an_amberelec_card(self): + """tui_audit_17 P2-3, code_audit_24 P3-6: an ArkOS card its bgmusic + or launchimages prove was named nowhere, and its route said it + could not tell it.""" + games = games_part(self.root / "media" / "GAMES") + for marker in ("bgmusic", "launchimages"): + with self.subTest(marker=marker): + ark = self.drive(f"ARK_{marker}", ("bios", "nes", marker)) + self.assertEqual(install.card_system(ark), ("retroarch", ark / "bios", "ArkOS")) + for name in ("ArkOS", "dArkOS"): + setup = install.card_setup(name, "retroarch") + self.assertEqual(install._card_is(setup, ark)[0], "own", name) + state = self.first_screen([games, ark]) + text = self.text(state, 80, 24) + self.assertIn(f"ArkOS SD card (ARK_{marker})", text) + self.assertIn("AmberELEC SD card (GAMES)", text) + + def test_the_emuelec_refusal_names_its_file(self): + """code_audit_24 P3-7: the refusal named bezels, which AmberELEC's + card holds too.""" + games = games_part(self.root / "media" / "GAMES") + (games / "bezels").mkdir() + setup = install.card_setup("EmuELEC", "emuelec") + choice = install._not_the_card(setup, games, games, "AmberELEC") + self.assertIn("bezels/default.cfg", choice.detail) + + # Root cause 5: the reads before the menu show. + + def test_the_reads_before_the_menu_show_a_count(self): + """code_audit_24 P3-4: a rerun read every file before the menu, one at + a time and with nothing on screen.""" + payload = b"R" * 16 + (self.here / "a.bin").write_bytes(payload) + listing = core_list(self.root / "read.json", + [self.entry("a.bin", payload, ["sony-playstation"])]) + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False), []) + self.assertIn("\r read 1/1", text) + self.assertLess(text.index("read 1/1"), text.index("every one in place")) + + # Root cause 6: every other item. + + def test_offline_the_plan_promises_no_replacement_it_cannot_make(self): + """code_audit_24 P3-5: offline, the plan said a wrong copy no folder + holds "is replaced".""" + state = checked_state() + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text = self.text(state, 100, 30) + self.assertIn("1 wrong copy", text) + self.assertNotIn("wrong copy is replaced", text) + + def test_options_name_the_plans_bios_folder(self): + """tui_audit_17 P3-6: AmberELEC's Options read the games part, its + plan the bios folder below it.""" + state = checked_state() + state.manifest = {**MANIFEST, "files": [ + {**entry, "dest": f"bios/{entry['dest']}"} for entry in MANIFEST["files"]]} + state.dest = Path("/media/me/GAMES") + row = next(r for r in install.tui_rows(self.options(state)) if r.action == "folder") + self.assertEqual(row.label, "BIOS folder: /media/me/GAMES/bios") + + def test_finished_with_errors_says_a_rerun_retries(self): + """tui_audit_17 P3-7: the screen never said a rerun retries the files + that failed, and line mode named each failure twice.""" + state = checked_state() + install.tui_update(state, Key("enter")) + install.tui_receive(state, ("event", state.run_id, { + "event": "summary", "mode": "install", "installed": 3, "up_to_date": 0, + "pending": 1, "failed": [{"dest": "gba_bios.bin", "reason": "timed out"}], + "skipped": [], "left_out": [], "kept": [], "interrupted": False})) + install.tui_receive(state, ("ended", state.run_id, 1, [])) + title, lines = install.tui_outcome(state) + self.assertEqual(title, "Finished with errors") + self.assertIn("Run it again to retry only those.", lines) + # Line mode: the FAILED line of the progress names it, the end not again. + install.SAID_FAILED.clear() + install.SAID_FAILED.add("gba_bios.bin") + self.addCleanup(install.SAID_FAILED.clear) + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out), \ + contextlib.suppress(SystemExit): + install._report_outcome([Path("/b")], 3, 0, 1, 0, False, + failures=[("gba_bios.bin", "timed out")]) + self.assertNotIn("gba_bios.bin", out.getvalue()) + self.assertIn("Run the same command again to retry only those.", out.getvalue()) + + def test_below_40_columns_no_word_is_broken(self): + """tui_audit_17 P3-8: the reason line, Found and Install for were + left to the terminal, which broke "/media/moi/" and "ONION/BIOS".""" + with mock.patch.object(install, "terminal_size", return_value=(39, 11)), \ + mock.patch.object(install, "_is_terminal", return_value=True): + for text in (" Found Onion at /media/moi/ONION/BIOS (RetroArch files)", + "The window is 39x11: questions come line by line " + "(40x12 for the full screen)."): + lines = install.fold_line(text).split("\n") + self.assertTrue(all(len(line) <= 38 for line in lines), lines) + self.assertEqual(" ".join(" ".join(lines).split()), " ".join(text.split())) + + def test_the_narrowing_line_is_a_sentence(self): + """tui_audit_17 P3-4: the plan read "--region." on a line alone.""" + state = checked_state() + state.regions = ["japan"] + text = self.text(state, 100, 30) + self.assertIn("For your choice: regions ranked.", text) + self.assertNotIn(" --region.", text) + + def test_offline_choice_of_systems_is_never_about_0_files(self): + """tui_audit_17 P3-5: "About 0 files, 0 B, for 10 of 11 systems".""" + state = checked_state() + state.ticked = {"sony-playstation"} + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text = self.text(state, 100, 30) + self.assertNotIn("About 0 files", text) + self.assertIn("need the internet", text) + + def test_a_phone_bar_after_a_tap_keeps_quit_and_one_wording(self): + """tui_audit_17 P3-11: at 40 columns on Android the armed bar dropped + Esc Quit and said "click", the row "tap again".""" + state = fresh_state() + state.os_type = "android" + state.mouse = True + state.stack = ["setup"] + install.tui_view(state, 40, 12) + state.tapped = install._tapped_row(state) + frame = install.tui_view(state, 40, 12) + text = "\n".join(map(str, frame)) + self.assertNotIn("click", text) + self.assertIn("tap again", text) + self.assertIn("Esc Quit", str(frame[-1])) + + def test_the_offline_screen_names_the_setups_list(self): + """tui_audit_17 P3-12: the offline screen said install/.json + for AmberELEC, chosen one screen before.""" + inbox: queue.Queue = queue.Queue() + install.LOCAL.reset() + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + install._tui_fetch("amberelec", inbox, io.StringIO()) + message = inbox.get_nowait() + self.assertIn("install/amberelec.json", " ".join(message[3])) + + # Root cause 7: the tests leave the host alone. + + def test_no_connection_leaves_the_loopback_nor_a_card_comes_in(self): + """code_audit_24 P3-13: 261 connections to GitHub left this module, + and tests failed with an AmberELEC card in the reader.""" + import socket # noqa: PLC0415 + + log = Path(os.environ["RETROBIOS_TEST_NETLOG"]) + before = log.read_text(encoding="utf-8") + self.addCleanup(log.write_text, before, encoding="utf-8") + with self.assertRaisesRegex(OSError, "makes no request"): + socket.create_connection(("192.0.2.1", 80), timeout=1) + child = subprocess.run( + [sys.executable, "-c", "import os, socket\n" + "print(os.path.ismount('/media/x'), os.path.ismount('/mnt/d'))\n" + "socket.create_connection(('192.0.2.1', 80), 1)"], + capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(child.stdout.strip(), "False False") + self.assertIn("makes no request", child.stderr) + self.assertEqual(len(log.read_text(encoding="utf-8").splitlines()), + len(before.splitlines()) + 2) + self.assertFalse(os.path.ismount("/media/x")) + + if __name__ == "__main__": unittest.main() From d8ee7439f5d94069cc8dfb92d0c706c9b46e9050 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:21:22 +0100 Subject: [PATCH 07/11] chore: tests follow the tick lists and found cards --- install.py | 15 +- tests/test_install_tui.py | 386 ++++++++++++++++++++++---------------- 2 files changed, 236 insertions(+), 165 deletions(-) diff --git a/install.py b/install.py index b14943937..ad918de5f 100644 --- a/install.py +++ b/install.py @@ -7904,9 +7904,11 @@ def fold_line(text: str, hang: str = " ") -> str: cut after a slash: left to the terminal, below 40 columns "Found Onion at /media/moi/ONION/BIOS" broke inside "(RetroArch files)" and a folder inside its name (tui_audit_17 P3-8).""" - cols = max(terminal_size()[0] - 1, 8) - if cell_width(text) <= cols or not _is_terminal(sys.stdout): - # A pipe or a log folds nothing: the line is kept whole there. + width = terminal_size()[0] + cols = max(width - 1, 8) + if cell_width(text) <= cols or width >= TUI_MIN_COLS or not _is_terminal(sys.stdout): + # A pipe or a log folds nothing, and from 40 columns a folder is left + # whole for the terminal to fold, copied as one line. return text lead = text[:len(text) - len(text.lstrip(" "))] prefix = lead + hang @@ -8626,6 +8628,11 @@ def all_in_place(files: list[dict], trees: list[dict], bios_path: Path) -> bool: for tree in trees)) +def _live_stdout() -> bool: + """Whether the standard output draws a moving line (Terminal.live).""" + return Terminal(sys.stdout).live + + def read_in_place(pairs: list[tuple[dict, Path]]) -> list[str]: """_classify_local of each (entry, path), read before the line menu as the check after it reads: on --jobs workers, a count moving at a @@ -8634,7 +8641,7 @@ def read_in_place(pairs: list[tuple[dict, Path]]) -> list[str]: size only, and the check after Enter reads none of them again.""" if not pairs: return [] - live = _is_terminal(sys.stdout) + live = _live_stdout() verdicts: list[str] = [] last = 0.0 with concurrent.futures.ThreadPoolExecutor(max_workers=LOCAL.jobs) as pool: diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 63c888c59..245730517 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -472,9 +472,12 @@ class Flow(unittest.TestCase): focus = next(i for i, row in enumerate(install.tui_rows(state)) if row.value == "nintendo-gba") state.focus["systems"] = focus - # Nothing ticked: Enter takes the system in focus alone. - self.assertEqual(press(state, "enter"), [("run", "confirm")]) + # A ticks the system in focus, B goes back to the plan, and Install + # there counts the choice first (tui_audit_17 P3-1). + self.assertEqual(press(state, "enter", "esc"), []) self.assertEqual(state.ticked, {"nintendo-gba"}) + self.assertEqual(state.screen, "plan") + self.assertEqual(press(state, "enter"), [("run", "confirm")]) self.assertEqual(state.screen, "confirm") self.assertIn("--system=nintendo-gba", install.tui_argv(state, "confirm", "py", "i.py")) self.assertIn("--dry-run", install.tui_argv(state, "confirm", "py", "i.py")) @@ -507,7 +510,7 @@ class Flow(unittest.TestCase): press(state, "enter") self.assertEqual(state.screen, "regions") # Ticked in the order of preference, then moved up. - regions = [row.value for row in install.tui_rows(state)] + regions = [row.value for row in install.tui_rows(state) if row.action == "region"] self.assertEqual(regions, ["japan", "north-america"]) press(state, "enter", "down", "enter") self.assertEqual(state.regions, ["japan", "north-america"]) @@ -516,7 +519,7 @@ class Flow(unittest.TestCase): # The rank moves, the row stays where it is, under the focus. self.assertEqual(state.focus["regions"], 1) self.assertEqual([row.label for row in install.tui_rows(state)], - ["[2] Japan", "[1] North America"]) + ["[2] Japan", "[1] North America", "None", "Done"]) press(state, "shift-down") self.assertEqual(state.regions, ["japan", "north-america"]) press(state, "esc", "down", "enter", "down", "right", "down", "down", "right", "right") @@ -534,8 +537,8 @@ class Flow(unittest.TestCase): state.focus["options"] = 3 press(state, "enter") self.assertEqual(state.screen, "cores") - self.assertEqual([row.value for row in install.tui_rows(state)][3:], - ["mesen", "pcsx_rearmed"]) + self.assertEqual([row.value for row in install.tui_rows(state) + if row.action == "core"], ["mesen", "pcsx_rearmed"]) self.assertEqual(install.tui_rows(state)[state.focus["cores"]].value, "mesen") # Every core ticked while none narrows the run: one untick keeps # the others, ticked back it is every core again. @@ -544,8 +547,11 @@ class Flow(unittest.TestCase): press(state, "right") self.assertEqual(state.cores, []) press(state, "/", "p", "c") - self.assertEqual([row.value for row in install.tui_rows(state)][3:], ["pcsx_rearmed"]) - press(state, "enter", "left", "enter") + self.assertEqual([row.value for row in install.tui_rows(state) + if row.action == "core"], ["pcsx_rearmed"]) + # Enter ends the typing on the first match, Left unticks it, and B + # clears the filter, then leaves. + press(state, "enter", "left", "esc", "esc") self.assertEqual(state.screen, "options") self.assertEqual(install.tui_flags(state), [("--core", "mesen")]) @@ -804,8 +810,9 @@ class Widgets(unittest.TestCase): state = self.systems_state(112) press(state, "end") lines = install.tui_view(state, 40, 15) - self.assertIn("> [x] system-111", "\n".join(lines)) - # The All, None and Filter rows are not counted among the systems. + self.assertIn("> Done", "\n".join(lines)) + self.assertIn("[x] system-111", "\n".join(lines)) + # The Filter, All, None and Done rows are not counted among the systems. self.assertTrue(any(re.fullmatch(r" \d+-112 of 112", line) for line in lines), lines) press(state, "pgup") self.assertLess(state.focus["systems"], 114) @@ -826,7 +833,8 @@ class Widgets(unittest.TestCase): "present": 0, "missing": 1, "wrong_hash": 0, "bytes_needed": 3}) press(state, "/", "M", "E", "G") - self.assertEqual([row.value for row in install.tui_rows(state)][3:], ["sega-genesis"]) + self.assertEqual([row.value for row in install.tui_rows(state) + if row.action == "system"], ["sega-genesis"]) press(state, "esc") self.assertEqual(state.filters["systems"], "") self.assertEqual(state.screen, "systems") @@ -844,7 +852,8 @@ class Widgets(unittest.TestCase): self.assertEqual(state.ticked, set()) press(state, "a") self.assertEqual(len(state.ticked), 5) - state.focus["systems"] = 1 + state.focus["systems"] = next(i for i, row in enumerate(install.tui_rows(state)) + if row.action == "none") # X on None does nothing: only A, an explicit choice, unticks all. press(state, " ") self.assertEqual(len(state.ticked), 5) @@ -1453,8 +1462,8 @@ class SmallFixes(unittest.TestCase): state.cli_regions = ["europe"] state.regions = list(state.cli_regions) state.stack += ["options", "regions"] - self.assertEqual([row.value for row in install.tui_rows(state)], - ["europe", "japan", "north-america"]) + self.assertEqual([row.value for row in install.tui_rows(state) + if row.action == "region"], ["europe", "japan", "north-america"]) press(state, "enter") self.assertEqual(state.regions, []) self.assertIn("europe", [row.value for row in install.tui_rows(state)]) @@ -1584,7 +1593,8 @@ class SmallFixes(unittest.TestCase): self.assertIn("[x] Sega - Saturn", narrow) self.assertNotIn("sega-saturn", narrow) press(state, "/", "s", "a", "t", "u") - self.assertEqual([row.value for row in install.tui_rows(state)][3:], ["sega-saturn"]) + self.assertEqual([row.value for row in install.tui_rows(state) + if row.action == "system"], ["sega-saturn"]) class CleanupSurvivesABrokenTerminal(unittest.TestCase): @@ -1690,8 +1700,8 @@ class AuditRoundTwo(unittest.TestCase): self.assertEqual(state.filters["systems"], "") self.assertEqual(self.focused(state).value, "sony-playstation") self.assertEqual(state.ticked, {"nintendo-gba"}) - self.assertIn("A/Enter Continue", install.tui_view(state, 80, 24)[-1]) - state.focus["systems"] = 2 + self.assertIn("A/Enter Tick", install.tui_view(state, 80, 24)[-1]) + state.focus["systems"] = 0 press(state, " ") self.assertEqual(state.editing, "") self.assertEqual(state.ticked, {"nintendo-gba"}) @@ -1702,7 +1712,8 @@ class AuditRoundTwo(unittest.TestCase): press(state, "down") self.assertTrue(install.tui_view(state, 80, 24)[-1].strip().startswith("A/Enter Open")) state = self.systems() - state.focus["systems"] = 0 + state.focus["systems"] = next(i for i, row in enumerate(install.tui_rows(state)) + if row.action == "all") self.assertIn("A/Enter Tick all", install.tui_view(state, 80, 24)[-1]) def test_p2_2_b_esc_and_q_stop_an_install(self): @@ -1717,7 +1728,7 @@ class AuditRoundTwo(unittest.TestCase): def test_p2_3_options_read_the_plan_again(self): state = self.systems() press(state, "n") - state.focus["systems"] = 3 + state.focus["systems"] = 1 press(state, "right") ticked = set(state.ticked) press(state, "esc", "o") @@ -1880,7 +1891,7 @@ class AuditRoundTwo(unittest.TestCase): def test_p2_11_a_narrow_bar_keeps_the_keys_to_finish(self): state = self.systems() self.assertEqual(install.tui_view(state, 40, 15)[-1].strip(), - "A Continue B Back X Tick ? Help") + "A Tick B Done ? Help") def test_p2_12_a_device_name_finds_its_setup(self): state = fresh_state() @@ -2270,9 +2281,10 @@ class AuditRoundThree(unittest.TestCase): # Only a marker on the part its BIOS folder is on names the card: # plumOS's and the Anbernic firmware's are on another part. for setup in install.card_setups(): - if setup.rules: - with self.subTest(system=setup.name): - self.assertEqual(rules[setup.name].bios, setup.below) + for rule in setup.rules: + with self.subTest(system=setup.name, rule=rule.values): + self.assertEqual(rule.bios, setup.below) + self.assertIn("ArkOS", rules) self.assertEqual(install.DEFAULT_DESTS["knulli"], Path("/userdata") / "bios") self.assertEqual(install.DEFAULT_DESTS["rocknix"], Path("/storage") / "roms/bios") @@ -3427,8 +3439,10 @@ class OneSystemInFewPresses(unittest.TestCase): # The buttons each route sends, as the keys the interface reads. PADS = { - "gptokeyb": {"A": "enter", "X": " ", "Start": "enter", "R1": "pgdn"}, - "muxterm": {"A": "enter", "X": " ", "Start": "enter", "R1": "right"}, + "gptokeyb": {"A": "enter", "B": "esc", "X": " ", "Start": "enter", "R1": "pgdn", + "L1": "pgup"}, + "muxterm": {"A": "enter", "B": "esc", "X": " ", "Start": "enter", "R1": "right", + "L1": "left"}, } def long_list(self, pad: str) -> install.TuiState: @@ -3448,17 +3462,22 @@ class OneSystemInFewPresses(unittest.TestCase): return install.tui_update(state, Key("char", name) if len(name) == 1 else Key(name)) def go_to(self, state, pad: str, target: str, paging: bool) -> int: - """Presses from the focus to the target system, as a person does.""" + """Presses from the focus to the target system, or to the row of that + action (none), as a person does.""" count = 0 while True: rows = install.tui_rows(state) focus = state.focus.get("systems", 0) - index = next(i for i, row in enumerate(rows) if row.value == target) + index = next(i for i, row in enumerate(rows) + if target in (row.value, row.action)) if index == focus: return count # A page is the rows drawn (tui_audit_9 P2-2). page = install._page_step(state) - button = "R1" if paging and index - focus >= page else "down" + if index > focus: + button = "R1" if paging and index - focus >= page else "down" + else: + button = "L1" if paging and focus - index >= page else "up" self.press_button(state, pad, button) count += 1 @@ -3471,23 +3490,18 @@ class OneSystemInFewPresses(unittest.TestCase): self.press_button(state, pad, button) count += 1 self.assertEqual(state.screen, "systems") + # On its first system (tui_audit_17 P3-1), None at the list's end. + self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system") paging = pad != "muxterm" or fast - if fast: - self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "none") - self.press_button(state, pad, "A") - count += 1 - count += self.go_to(state, pad, "sys-45", paging) - effects = self.press_button(state, pad, "A") - count += 1 - else: - state.focus["systems"] = 3 - for button in ("up", "up", "A"): - self.press_button(state, pad, button) - count += 1 - count += self.go_to(state, pad, "sys-45", paging) - self.press_button(state, pad, "X") - effects = self.press_button(state, pad, "Start") - count += 2 + count += self.go_to(state, pad, "none", paging) + self.press_button(state, pad, "A") + count += 1 + count += self.go_to(state, pad, "sys-45", paging) + # A ticks it, B goes back to the plan, A there counts the choice. + self.press_button(state, pad, "A") + self.press_button(state, pad, "B") + effects = self.press_button(state, pad, "A") + count += 3 self.assertEqual(state.ticked, {"sys-45"}) self.assertEqual(effects, [("run", "confirm")]) # Install on the confirm screen, then Exit on the result. @@ -3499,10 +3513,10 @@ class OneSystemInFewPresses(unittest.TestCase): # focus, and muxterm had no page key). # A page is the rows drawn since tui_audit_9 P2-2, ten here where it # was nine: sys-45 is four pages and five Downs away, not five pages. - self.assertEqual(self.presses("gptokeyb", fast=False), 19) - self.assertEqual(self.presses("gptokeyb", fast=True), 16) - self.assertEqual(self.presses("muxterm", fast=False), 55) - self.assertEqual(self.presses("muxterm", fast=True), 16) + # None now at the list's end (tui_audit_17 P3-1): reached by pages. + self.assertEqual(self.presses("gptokeyb", fast=True), self.presses("gptokeyb", False)) + self.assertLessEqual(self.presses("gptokeyb", fast=True), 22) + self.assertLessEqual(self.presses("muxterm", fast=True), 22) def test_a_on_a_system_with_none_ticked_takes_it_alone(self): state = self.long_list("keys") @@ -3510,19 +3524,21 @@ class OneSystemInFewPresses(unittest.TestCase): state.ticked = set() state.focus["systems"] = 5 text = install.tui_view(state, 80, 24)[-1] - self.assertIn("Enter Only this system", text) + self.assertIn("Enter Tick", text) focused = install.tui_rows(state)[5].value - self.assertEqual(press(state, "enter"), [("run", "confirm")]) + self.assertEqual(press(state, "enter"), []) self.assertEqual(state.ticked, {focused}) def test_enter_still_continues_with_the_ticks_as_they_are(self): state = self.long_list("keys") state.stack.append("systems") state.focus["systems"] = 5 - # tui_audit_6 P2-1: Continue opens Confirm, which waits for A. - self.assertEqual(press(state, "enter"), [("run", "confirm")]) - self.assertEqual((state.screen, state.must_confirm), ("confirm", True)) - self.assertEqual(len(state.ticked), 66) + # tui_audit_6 P2-1: nothing runs from the list. A unticks the system + # in focus (tui_audit_17 P3-1), B goes back to the plan. + self.assertEqual(press(state, "enter"), []) + self.assertEqual((state.screen, len(state.ticked)), ("systems", 65)) + press(state, "esc") + self.assertEqual(state.screen, "plan") def test_left_and_right_page_inside_muxterm_only(self): for pad in ("muxterm", "gptokeyb_muxterm", "gptokeyb"): @@ -3681,12 +3697,16 @@ class AuditFive(unittest.TestCase): state = self.long_list() everything = set(state.ticked) press(state, "down", "enter") - self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "none") - press(state, "enter") + # The list opens on its first system, None at its end (tui_audit_17 P3-1). + self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system") + press(state, "n") self.assertEqual(state.ticked, set()) - press(state, "up", "up") - self.assertEqual(install._primary(state), "A/Enter First system") + self.assertEqual(install._primary(state), "A/Enter Tick") + # Done with nothing ticked is refused, and says why. press(state, "esc") + self.assertEqual(state.screen, "systems") + self.assertIn("No system is ticked", " ".join(install.tui_view(state, 80, 24))) + press(state, "a", "esc") self.assertEqual(state.screen, "plan") self.assertEqual(state.ticked, everything) text = " ".join(install.tui_view(state, 80, 24)) @@ -3758,7 +3778,7 @@ class AuditFive(unittest.TestCase): state.files = [{"event": "file", "dest": "mame.zip", "size": 10, "systems": ["sys-00", "sys-01"]}] state.stack.append("systems") - state.focus["systems"] = 3 + state.focus["systems"] = install._first_item(state) lines = install.tui_view(state, 40, 12) self.assertIn(" Shared, counted once", lines) @@ -3778,12 +3798,12 @@ class AuditFive(unittest.TestCase): state.pad = "miyoo" press(state, "down", "enter") state.ticked = set() - state.focus["systems"] = 3 + state.focus["systems"] = install._first_item(state) bar = install.tui_view(state, 40, 15)[-1] - self.assertIn("A Tick", bar) - self.assertTrue(bar.strip().startswith("Start Only this")) - # tui_audit_6 P3-9.1: Back was the entry given up at 40 columns. - self.assertIn("R2 Back", bar) + # A is Space on a Miyoo, and ticks as Start does (tui_audit_17 P3-1). + self.assertTrue(bar.strip().startswith("A Tick")) + # tui_audit_6 P3-9.1: the way out was the entry given up at 40 columns. + self.assertIn("R2 Done", bar) def test_muxterm_help_names_no_home_key(self): state = self.long_list() @@ -4805,13 +4825,17 @@ class EndToEnd(unittest.TestCase): term.wait("> Install") term.send(DOWN, "\r") term.wait("Choose systems") - # D-pad and A only: up to None, A (on the first system), down to the - # second system, Right. - term.send(UP, UP, "\r", DOWN, RIGHT) - screen = term.wait("1 of 2 systems") - self.assertIn("> [x] sony-playstation", screen) + # D-pad and A only: the list opens on its first system, A unticks + # it, B goes back to the plan (tui_audit_17 P3-1). term.send("\r") - screen = term.wait("> Install") + screen = term.wait("1 of 2 systems") + self.assertIn("> [ ] nintendo-gba", screen) + self.assertIn("[x] sony-playstation", screen) + term.send("\x1b") + term.wait("for 1 of 2 systems") + # Install counts the choice on Confirm first. + term.send("\r") + screen = term.wait("files to download") self.assertIn("3 files to download", screen) term.send("\r") term.wait("3 files installed.") @@ -6028,14 +6052,14 @@ class AuditSix(unittest.TestCase): state = checked_state() press(state, "down", "enter") self.assertEqual(state.screen, "systems") - state.focus["systems"] = 3 - self.assertEqual(install._primary(state), "A/Enter Continue") - self.assertEqual(press(state, "enter"), [("run", "confirm")]) - self.assertEqual(state.screen, "confirm") - # The confirm run's plan is the same: it still waits for A. - install.tui_receive(state, ("event", state.run_id, dict(CHECK))) - self.assertEqual(install.tui_receive(state, ("ended", state.run_id, 0, [])), []) - self.assertEqual(state.screen, "confirm") + # A ticks there, and Done goes back to the plan: nothing runs from + # the list (tui_audit_17 P3-1). + self.assertEqual(install._primary(state), "A/Enter Tick") + self.assertEqual(press(state, "enter"), []) + self.assertEqual(state.screen, "systems") + self.assertEqual(press(state, "enter"), []) + self.assertEqual(press(state, "esc"), []) + self.assertEqual(state.screen, "plan") def test_offline_with_nothing_on_hand_runs_nothing_from_the_list(self): state = checked_state() @@ -6043,9 +6067,13 @@ class AuditSix(unittest.TestCase): state.focus["plan"] = [row.action for row in install.tui_rows(state)].index( "systems") press(state, "enter") - state.focus["systems"] = 3 - effects = press(state, "enter") - self.assertEqual(effects, [("run", "confirm")]) + # A ticks: the list runs nothing (tui_audit_17 P3-1). The way to + # Confirm is Space on the plan's Install. + self.assertEqual(press(state, "enter", "esc"), []) + state.focus["plan"] = 0 + state.must_confirm = True + install._push(state, "confirm") + install._start_run(state, "confirm") install.tui_receive(state, ("event", state.run_id, dict(CHECK))) for event in FILES: install.tui_receive(state, ("event", state.run_id, dict(event))) @@ -6277,12 +6305,12 @@ class AuditSix(unittest.TestCase): "systems": ["sys-00", "sys-01"]}] state.stack.append("systems") footers = set() - for focus in range(3, 12): + for focus in range(1, 10): state.focus["systems"] = focus lines = install.tui_view(state, 40, 12) footers.add(next(i for i, line in enumerate(lines) if "of 66 systems:" in line)) self.assertEqual(len(footers), 1, footers) - state.focus["systems"] = 3 + state.focus["systems"] = 1 self.assertIn(" +2 shared: counted once", install.tui_view(state, 40, 12)) @@ -8021,10 +8049,12 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.assertIn(option, command) self.assertNotIn("Copying", text) self.assertEqual(list(self.here.iterdir()), []) - # The screen: Choose systems, A on one system, then Confirm. + # The screen: Choose systems, A ticks, B back to the plan, whose + # Install counts the choice on Confirm first. state = checked_state() press(state, "down", "enter") self.assertEqual(state.screen, "systems") + press(state, "enter", "esc") effects = press(state, "enter") self.assertEqual((state.screen, effects), ("confirm", [("run", "confirm")])) @@ -8282,7 +8312,10 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, """tui_audit_10 P3-13: "Detecting platform..." and "No supported platform found here", where the screen says setup and "Nothing was found here.\"""" - text, _asked = self.line_run(_args(declared_only=False), []) + # The standalone emulators' lists come from the network: none here + # (code_audit_24 P3-13), and none found. + with mock.patch.object(install, "_install_standalone_folders", return_value=False): + text, _asked = self.line_run(_args(declared_only=False), []) self.assertIn("Looking for setups here...", text) # Said once, the question alone after it (tui_audit_16 P3-2). self.assertEqual(text.count("Nothing was found here."), 1) @@ -9575,10 +9608,11 @@ class AuditSixteenAndNineScreens(unittest.TestCase): state = checked_state() state.pad = "miyoo" state.stack.append("systems") - state.focus["systems"] = 1 + state.focus["systems"] = next(i for i, row in enumerate(install.tui_rows(state)) + if row.action == "none") bar = install.tui_view(state, 40, 12)[-1] self.assertNotIn("A Tick", bar) - state.focus["systems"] = 3 + state.focus["systems"] = install._first_item(state) self.assertIn("A Tick", install.tui_view(state, 40, 12)[-1]) def test_system_given_is_said_and_widened(self): @@ -9893,9 +9927,10 @@ class AuditEighteenAndEleven(_CardFixtures, unittest.TestCase): """P3-11.""" state = checked_state() state.cli_systems, state.cores, state.regions = ["sony-playstation"], ["x"], ["us"] - state.cli_cores = ["x"] + state.cli_cores, state.cli_regions = ["x"], ["us"] text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) - self.assertIn("(--system), --core, --region.", text) + # The flag after the words, never alone (tui_audit_17 P3-4). + self.assertIn("(--system), 0 of 1 cores (--core), regions ranked (--region).", text) def test_pc_and_short_prefixes_find_the_computer_setups(self): """P3-17.""" @@ -10311,7 +10346,7 @@ class MouseOnEveryScreen(unittest.TestCase): self.assertEqual(effects, []) self.assertEqual(state.stack, base.stack) again = drawn(state, cols, rows) - self.assertIn(install.TUI_ARMED, str(again[-1])) + self.assertIn(install.TUI_ARMED_SHORT, str(again[-1])) at = next(first for row, first, _end, mark in state.hits.targets if mark == target) effects = tap(state, at, line) @@ -10712,10 +10747,10 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase): def test_the_help_and_the_bar_say_how_to_tap(self): state = self.systems_state() bar = str(install.tui_view(state, 80, 24)[-1]) - self.assertIn("X/Space/tap Tick", bar) + self.assertIn("A/Enter/tap Tick", bar) state.help = True text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) - self.assertIn("Tap Tick or untick a row, as Space", text) + self.assertIn("Tap Tick or untick a row, as A", text) self.assertIn("Any key or a tap closes this help", text) plan = checked_state() plan.mouse, plan.help = True, True @@ -10729,7 +10764,7 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase): if row.action == "system") drawn(state, 80, 24) ticked = set(state.ticked) - tap(state, bar_line.index("X/Space/tap"), len(lines) - 1) + tap(state, bar_line.index("A/Enter/tap"), len(lines) - 1) self.assertNotEqual(state.ticked, ticked) # No mouse, no word of a tap. plain = checked_state() @@ -11175,7 +11210,7 @@ class TapsOnAPty(unittest.TestCase): self.click(term, 20, row) term.wait("RetroBIOS: Choose systems") bar = term.settle().splitlines()[-1] - self.assertIn("X/Space/tap Tick", bar) + self.assertIn("A/Enter/tap Tick", bar) row = self.line_of(term, "sony-playstation") self.click(term, 20, row) self.assertIn("[ ] sony-playstation", term.settle()) @@ -11399,8 +11434,9 @@ class AuditTwentyAndThirteen(unittest.TestCase): values = [row.value for row in install.tui_rows(state) if row.action == "core"] self.assertEqual(values, ["pcsx_rearmed"]) rows = install.tui_rows(state) - self.assertEqual(rows[-1].label, "Cores of the other systems: 1") - state.focus["cores"] = len(rows) - 1 + more = next(i for i, row in enumerate(rows) if row.action == "morecores") + self.assertEqual(rows[more].label, "Cores of the other systems: 1") + state.focus["cores"] = more install.tui_update(state, Key("enter")) self.assertEqual([row.value for row in install.tui_rows(state) if row.action == "core"], ["mesen", "pcsx_rearmed"]) @@ -11556,7 +11592,7 @@ class AuditTwentyAndThirteen(unittest.TestCase): self.assertIn("? Help", bar) state.help = True text = " ".join(" ".join(install.tui_view(state, 40, 12)).split()) - self.assertIn("Tap Tick or untick a row, as Space", text) + self.assertIn("Tap Tick or untick a row, as A", text) def test_one_spelling_of_a_folder_on_the_plan_and_the_result(self): """tui_audit_13 P3-3: "/home...i_audit13/rb/bios" on the plan, the @@ -11950,9 +11986,11 @@ class DoubleTapsAcrossScreens(unittest.TestCase): slow = self.plan_on("systems") self.double_tap(slow, install.TUI_TAP_SETTLE + 0.2) self.assertEqual(once.screen, "systems") - # On Choose systems the first entry goes on to Confirm: the slow - # tap pressed it. - self.assertEqual(slow.screen, "confirm") + # On Choose systems the first entry ticks: the slow tap pressed it + # once more, and the box is back as it was (tui_audit_17 P3-1). + self.assertEqual(slow.screen, "systems") + self.assertEqual(slow.ticked, once.ticked ^ { + install.tui_rows(once)[once.focus["systems"]].value}) def test_a_release_whose_press_came_before_the_frame_does_nothing(self): state = self.plan_on("systems") @@ -12020,6 +12058,16 @@ class TwoTapsOnlyWhereFilesAreWritten(unittest.TestCase): install.tui_update(state, Key("enter")) self.assertEqual(state.screen, "systems") lines = drawn(state, 80, 24) + # A tap on the bar's first entry ticks (tui_audit_17 P3-1), B goes + # back, and Install counts the choice on Confirm first. + self.assertEqual(tap(state, 2, len(lines) - 1), []) + self.assertEqual(state.screen, "systems") + lines = drawn(state, 80, 24) + self.assertEqual(tap(state, str(lines[-1]).index("B/Esc"), len(lines) - 1), []) + self.assertEqual(state.screen, "plan") + lines = drawn(state, 80, 24) + state.focus["plan"] = 0 + lines = drawn(state, 80, 24) self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "confirm")]) self.assertEqual(state.screen, "confirm") install.tui_receive(state, ("event", state.run_id, CHECK)) @@ -12124,10 +12172,20 @@ class TapsOnAPtySeeFirst(unittest.TestCase): row = self.line_of(term, "sony-playstation") self.click(term, 20, row) term.settle() - col, line = self.bar_entry(term, "Continue") + col, line = self.bar_entry(term, "Done") + report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True) + # A double tap: the first goes back to the plan, the second lands on + # its Back inside the settle time and does nothing (tui_audit_14 + # P2-1). + time.sleep(install.TUI_TAP_SETTLE) + term.send(report + report) + screen = term.wait("for 1 of 2 systems") + screen = term.settle(0.6) + self.assertIn("RetroBIOS: RetroArch", screen) + self.assertEqual(repo.installed(), []) + # Install on the plan counts the choice on Confirm, which waits. + col, line = self.bar_entry(term, "Install") report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True) - # A double tap: the first opens Confirm, the second lands on its - # Install inside the settle time and does nothing (tui_audit_14 P2-1). time.sleep(install.TUI_TAP_SETTLE) term.send(report + report) term.wait("Counted again for your choices") @@ -12135,6 +12193,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase): self.assertIn("RetroBIOS: Confirm", screen) self.assertEqual(repo.installed(), []) # A tap made later installs: Confirm is the look before the install. + time.sleep(install.TUI_TAP_SETTLE) term.send(report) term.wait("files installed") self.assertNotEqual(repo.installed(), []) @@ -12317,7 +12376,9 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): self.assertNotIn("Run the installer again", text) state.ticked = {"sony-playstation"} text = self.screen(state, 100, 40) - self.assertIn("About 0 files, 0 B, for 1 of 3 systems, 3 need the internet", text) + self.assertIn("3 files need the internet, which RETROBIOS_INSTALL_OFFLINE=1 does not " + "use, for 1 of 3 systems", text) + self.assertNotIn("About 0 files", text) def easyroms(self, name: str = "EASYROMS") -> Path: """A handheld's games part as a computer mounts it, with the folders @@ -12339,15 +12400,14 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): mock.patch.object(install, "detect_frontends", return_value=[]), \ mock.patch.object(install.Path, "home", return_value=home): state, _effects = install.tui_start(_args(), "linux") - self.assertEqual(state.unnamed, [card]) text = self.screen(state) - self.assertIn(f"A card is in the reader ({name}): choose its system", - text) + # Named on the first screen, its plan one Enter away + # (tui_audit_17 P2-3). + self.assertIn(f"> ArkOS SD card ({name}", text) self.assertNotIn("Nothing was found here", text) install.tui_update(state, Key("enter")) - self.assertEqual(state.screen, "folder") - rows = install.tui_rows(state) - self.assertEqual(rows[0].value, card / "bios") + self.assertEqual(state.dest, card / "bios") + self.assertEqual(state.platform, "retroarch") install.tui_update(state, Key("enter")) self.assertEqual(state.dest, card / "bios") shutil.rmtree(card) @@ -12618,13 +12678,11 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): def test_line_mode_puts_the_arkos_card_first(self): card = self.easyroms() text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, - declared_only=False), ["1", ""], mounts=[card]) - self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) + declared_only=False), [""], mounts=[card]) + # Found as the screen finds it, under Enter (tui_audit_17 P2-3). + self.assertIn(f"Found ArkOS at {card / 'bios'} (RetroArch files)", text) self.assertNotIn("Nothing was found here", text) - self.assertIn(f"The card in the reader keeps BIOS files in its bios folder: " - f"{card / 'bios'}.", text) - self.assertIn(f"1) EASYROMS: bios (SD card, {card / 'bios'})", text) - self.assertIn("Enter for the card's bios folder", asked[-2]) + self.assertIn("Enter for ArkOS SD card (EASYROMS)", asked[0]) self.assertIn(f"BIOS folder: {card / 'bios'}", text) @@ -12680,9 +12738,9 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): mounts = [data, stick] with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ mock.patch.object(install, "_mount_points", return_value=frozenset()): - self.assertEqual(install.unnamed_cards(), []) + self.assertEqual(install.unproven_drives(), [data, stick]) state = self.first_screen(mounts) - self.assertEqual(state.unnamed, []) + self.assertEqual(state.setups, []) self.assertIn("Nothing was found here", self.screen(state)) state, text, rows = self.folder_screen(mounts) self.assertNotIn(rows[0].value, (data / "bios", stick / "bios")) @@ -12713,15 +12771,15 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): mounts = [arkos, amber, emuelec] with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ mock.patch.object(install, "_mount_points", return_value=frozenset()): - self.assertEqual(install.unnamed_cards(), [arkos]) self.assertEqual([install.games_part(mount).name for mount in mounts], ["ArkOS", "AmberELEC", "EmuELEC"]) + # The three are their systems' own cards (tui_audit_17 P2-3). self.assertEqual([install.card_system(mount) for mount in mounts], - [None, ("amberelec", amber, "AmberELEC"), + [("retroarch", arkos / "bios", "ArkOS"), + ("amberelec", amber, "AmberELEC"), ("emuelec", emuelec, "EmuELEC")]) # One of these the system calls fixed is a disk, not a card. with mock.patch.object(install, "_drive_not_removable", return_value=True): - self.assertEqual(install.unnamed_cards(), []) self.assertEqual([install.card_system(mount) for mount in mounts], [None, None, None]) @@ -12733,18 +12791,16 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): card = games_part(self.root / "media" / "EASYROMS") mounts = [data, card] state = self.first_screen(mounts) - self.assertEqual(state.unnamed, [card]) - self.assertIn("A card is in the reader (EASYROMS): choose its system", self.screen(state)) - state, text, rows = self.folder_screen(mounts) - self.assertEqual(rows[0].value, card / "bios") - self.assertIn(self.UNPROVEN_ONE, text) + # The card is the setup found, under Enter (tui_audit_17 P2-3). + text = self.screen(state) + self.assertIn("> ArkOS SD card (EASYROMS", text) + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + install.tui_update(state, Key("enter")) + self.assertEqual(state.dest, card / "bios") text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, - declared_only=False), ["1", ""], mounts=mounts) - self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) - self.assertIn(f"The card in the reader keeps BIOS files in its bios folder: " - f"{card / 'bios'}.", text) - self.assertIn(f"1) EASYROMS: bios (SD card, {card / 'bios'})", text) - self.assertIn(self.UNPROVEN_ONE, text) + declared_only=False), [""], mounts=mounts) + self.assertIn("Enter for ArkOS SD card (EASYROMS)", asked[0]) self.assertIn(f"BIOS folder: {card / 'bios'}", text) def test_two_cards_are_spoken_of_in_the_plural(self): @@ -12754,15 +12810,16 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): second = games_part(self.root / "media" / "ROMS2") mounts = [arkos, second] state = self.first_screen(mounts) - self.assertIn("Cards are in the reader (EASYROMS, ROMS2): choose their systems", - self.screen(state)) - state, text, rows = self.folder_screen(mounts) - self.assertIn("EASYROMS keeps BIOS files in its bios folder, first below.", text) + # Each card its folders prove is a setup found, by its own name + # (tui_audit_17 P2-3). + text = self.screen(state) + self.assertIn("ArkOS SD card (EASYROMS", text) + self.assertIn("ArkOS SD card (ROMS2", text) text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, - declared_only=False), ["1", ""], mounts=mounts) - self.assertIn("Cards are in the reader (EASYROMS, ROMS2): choose their systems.", text) - self.assertIn(f"EASYROMS keeps BIOS files in its bios folder: {arkos / 'bios'}.", text) - self.assertNotIn("The card in the reader", text) + declared_only=False), [], mounts=mounts) + self.assertIn("ArkOS SD card (EASYROMS)", text) + self.assertIn("ArkOS SD card (ROMS2)", text) + self.assertNotIn("in the reader", text) def test_a_fixed_partition_is_told_from_a_card(self): """code_audit_22 P2-1: every mount under /media and /mnt, and on @@ -13492,14 +13549,16 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): mounts = [*cards, data, music] with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ mock.patch.object(install, "_mount_points", return_value=frozenset()): - self.assertEqual(install.unnamed_cards(), cards) + self.assertEqual([install.card_system(card) for card in cards], + [("retroarch", card / "bios", "ArkOS") for card in cards]) self.assertEqual([install.games_part(card).name for card in cards], ["ArkOS"] * len(cards)) self.assertIsNone(install.games_part(data)) self.assertIsNone(install.games_part(music)) self.assertEqual(install.unproven_drives(), [data]) with mock.patch.object(install, "_drive_not_removable", return_value=True): - self.assertEqual(install.unnamed_cards(), []) + self.assertEqual([install.card_system(card) for card in cards], + [None] * len(cards)) # Root cause 2: counts and states that tell the truth. @@ -13747,7 +13806,9 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): for cols in (80, 39, 30): with self.subTest(cols=cols): with mock.patch.object(install.shutil, "get_terminal_size", - return_value=os.terminal_size((cols, 11))): + return_value=os.terminal_size((cols, 11))), \ + mock.patch.object(install, "_install_standalone_folders", + return_value=False): text, _asked = self.line_run(_args(declared_only=False), []) block = text.split("Which setup?", 1)[1].split(" q) quit", 1)[0] lines = [line for line in block.splitlines() if line.strip()] @@ -13760,27 +13821,29 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): def test_on_wsl_the_card_comes_first_in_both_modes(self): """code_audit_23 P3-6: line mode numbered Windows RetroArch 1) and - the card 2), where the screen put the card first.""" - card = games_part(self.root / "media" / "EASYROMS") + the card 2), where the screen put the card first. One order for + both: the other side first, then the drives (an ArkOS card is now a + setup found, tui_audit_17 P2-3).""" + data = self.drive("DATA", ("bios", "snes")) windows = Path("/mnt/c/RetroArch-Win64/system") side = [("Windows RetroArch: C:\\RetroArch-Win64\\system", windows)] - with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + with mock.patch.object(install, "_mounted_cards", return_value=[data]), \ mock.patch.object(install, "_mount_points", return_value=frozenset()), \ mock.patch.object(install, "other_side", return_value=side), \ mock.patch.object(install, "_ask", return_value="q"), \ contextlib.redirect_stdout(io.StringIO()) as out, \ contextlib.suppress(SystemExit): install.ask_platform_folder("wsl", "retroarch") - state = self.first_screen([card]) - self.assertRegex(out.getvalue(), r"\n 1\) EASYROMS: bios \(SD card") - self.assertRegex(out.getvalue(), r"\n 2\) Windows RetroArch") - with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + state = self.first_screen([data]) + self.assertRegex(out.getvalue(), r"\n 1\) Windows RetroArch") + self.assertRegex(out.getvalue(), r"\n 2\) DATA: bios \(drive") + with mock.patch.object(install, "_mounted_cards", return_value=[data]), \ mock.patch.object(install, "_mount_points", return_value=frozenset()), \ mock.patch.object(install, "other_side", return_value=side): state.os_type = "wsl" install.tui_update(state, Key("enter")) rows = [row for row in install.tui_rows(state) if row.action == "drive"] - self.assertEqual([row.value for row in rows], [card / "bios", windows]) + self.assertEqual([row.value for row in rows], [windows, data / "bios"]) def test_nothing_found_is_said_once(self): """tui_audit_16 P3-2: "Nothing was found here." twice, and after "A @@ -13789,7 +13852,8 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): self.assertEqual(text.count("Nothing was found here."), 1) card = games_part(self.root / "media" / "EASYROMS") text, _asked = self.line_run(_args(declared_only=False), [], mounts=[card]) - self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) + # The card its folders prove is found (tui_audit_17 P2-3). + self.assertIn("ArkOS SD card (EASYROMS)", text) self.assertNotIn("Nothing was found here", text) def test_the_bios_only_drive_note_is_one_plain_sentence(self): @@ -13798,15 +13862,13 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): them" after a title.""" data = self.drive("DATA", ("bios", "snes", "Music")) stick = self.drive("STICK", ("bios",)) - card = games_part(self.root / "media" / "EASYROMS") - mounts = [card, data, stick] + mounts = [data, stick] state, text, rows = self.folder_screen(mounts) self.assertIn("DATA and STICK have a bios folder but no folder a handheld system " "writes. Pick one below if your files go there.", text) - self.assertIn("The card in the reader keeps BIOS files in its bios folder", text) labels = [row.label for row in rows] self.assertIn("DATA: bios (drive)", labels) - self.assertIn("EASYROMS: bios (SD card)", labels) + self.assertIn("STICK: bios (drive)", labels) line, _asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, declared_only=False), ["1", ""], mounts=mounts) for said in (text, line): @@ -13873,7 +13935,7 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): at 40x12 dropped the folder "Use this folder" stands for.""" data = self.drive("DATA", ("bios", "snes", "Music")) stick = self.drive("STICK", ("bios",)) - for mounts in ([data, stick], [games_part(self.root / "media" / "EASYROMS"), data]): + for mounts in ([data, stick], [data]): with self.subTest(mounts=[mount.name for mount in mounts]): state, _text, _rows = self.folder_screen(mounts) with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ @@ -13916,7 +13978,7 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): press(state, " ") self.assertEqual(self.boxes(state), {"mesen": "[x]", "pcsx_rearmed": "[x]"}) self.assertEqual(state.cores, []) - press(state, " ", "enter") + press(state, " ", "esc") self.assertEqual(state.screen, "options") row = next(row for row in install.tui_rows(state) if row.action == "cores") self.assertEqual(row.label, "Cores: 1 of 2") @@ -13929,6 +13991,7 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): state.focus["cores"] = next(i for i, row in enumerate(rows) if row.action == "none") press(state, "enter") self.assertEqual(self.boxes(state), {"mesen": "[ ]", "pcsx_rearmed": "[ ]"}) + state.focus["cores"] = next(i for i, row in enumerate(rows) if row.action == "done") press(state, "enter") self.assertEqual(state.screen, "cores") self.assertIn("No core is ticked", str(state.status)) @@ -13955,7 +14018,7 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): self.assertIn("Every core's extra files are kept: Enter keeps them all", text) head = text.rsplit("Counted again for your choices", 1)[1] self.assertIn("1 of 2 cores", head) - self.assertEqual(install.cores_words(0, 2), "all cores") + self.assertEqual(install.cores_words(0, 2), "0 of 2 cores") self.assertEqual(install.cores_words(2, 2), "all cores") # Root cause 6: the pages say what the installer shows. @@ -14269,8 +14332,9 @@ class AuditTwentyFourAndSeventeen(unittest.TestCase): (self.here / "a.bin").write_bytes(payload) listing = core_list(self.root / "read.json", [self.entry("a.bin", payload, ["sony-playstation"])]) - text, _asked = self.line_run(_args(manifest=listing, dest=self.here, - declared_only=False), []) + with mock.patch.object(install, "_live_stdout", return_value=True): + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False), []) self.assertIn("\r read 1/1", text) self.assertLess(text.index("read 1/1"), text.index("every one in place")) From 2478c2a96ccc6c092c3d96ea5f1139a5db903b17 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:24:51 +0100 Subject: [PATCH 08/11] fix: tick lists wrap, tests follow the new keys --- install.ps1 | 2 +- install.py | 4 +++- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install_tui.py | 30 ++++++++++++++++++++---------- 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/install.ps1 b/install.ps1 index 9c37b8101..b8c036982 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "a5713bf93a8b48b5649aa7040a429ec4ac2a2ae62b14b07eb6c91cb96c15d984" + $defaultInstallSha256 = "5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index ad918de5f..6ff0322db 100644 --- a/install.py +++ b/install.py @@ -17686,7 +17686,9 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: row = rows[focus] last = len(rows) - 1 page = _page_step(state) if key.name in ("pgup", "pgdn") else 1 - wrap = len(rows) < 10 + # A list of ticks wraps at any length: None and Done, at its end, are + # a few presses up from its first item on a pad (tui_audit_17 P3-1). + wrap = len(rows) < 10 or screen in (*_CHECKLISTS, "regions") name = key.name if name == "up" or char == "k": focus = last if wrap and focus == 0 else max(0, focus - 1) diff --git a/install.sh b/install.sh index 1764d7e94..d8296c900 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="a5713bf93a8b48b5649aa7040a429ec4ac2a2ae62b14b07eb6c91cb96c15d984" +DEFAULT_INSTALL_SHA256="5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 19f5a91c4..31d0df546 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 984900, - "sha256": "403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a", + "size": 996363, + "sha256": "5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1038077, - "sha256": "49b9de6afa23afda4f6c786fa0968c36af1bd2ec9b52cb0927df4c527dcc701c" + "size": 1049540, + "sha256": "a7a9c2f9b7d018d0aca34731957a13d9d8ab22ea747381e6750d45abbfb70390" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 984900, - "sha256": "403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a", + "size": 996363, + "sha256": "5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 984900, - "sha256": "403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a", + "size": 996363, + "sha256": "5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 245730517..802d09079 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3493,7 +3493,10 @@ class OneSystemInFewPresses(unittest.TestCase): # On its first system (tui_audit_17 P3-1), None at the list's end. self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system") paging = pad != "muxterm" or fast - count += self.go_to(state, pad, "none", paging) + # Up from the first item wraps to the list's end: Done, then None. + while install.tui_rows(state)[state.focus["systems"]].action != "none": + self.press_button(state, pad, "up") + count += 1 self.press_button(state, pad, "A") count += 1 count += self.go_to(state, pad, "sys-45", paging) @@ -3513,10 +3516,11 @@ class OneSystemInFewPresses(unittest.TestCase): # focus, and muxterm had no page key). # A page is the rows drawn since tui_audit_9 P2-2, ten here where it # was nine: sys-45 is four pages and five Downs away, not five pages. - # None now at the list's end (tui_audit_17 P3-1): reached by pages. - self.assertEqual(self.presses("gptokeyb", fast=True), self.presses("gptokeyb", False)) - self.assertLessEqual(self.presses("gptokeyb", fast=True), 22) - self.assertLessEqual(self.presses("muxterm", fast=True), 22) + # None now at the list's end (tui_audit_17 P3-1), three presses up + # from the first system: the list wraps. fast pages down to sys-45. + self.assertEqual(self.presses("gptokeyb", fast=True), 21) + self.assertEqual(self.presses("muxterm", fast=False), 57) + self.assertEqual(self.presses("muxterm", fast=True), 21) def test_a_on_a_system_with_none_ticked_takes_it_alone(self): state = self.long_list("keys") @@ -4832,7 +4836,7 @@ class EndToEnd(unittest.TestCase): self.assertIn("> [ ] nintendo-gba", screen) self.assertIn("[x] sony-playstation", screen) term.send("\x1b") - term.wait("for 1 of 2 systems") + term.wait("About 3 files") # Install counts the choice on Confirm first. term.send("\r") screen = term.wait("files to download") @@ -12070,7 +12074,11 @@ class TwoTapsOnlyWhereFilesAreWritten(unittest.TestCase): lines = drawn(state, 80, 24) self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "confirm")]) self.assertEqual(state.screen, "confirm") - install.tui_receive(state, ("event", state.run_id, CHECK)) + # Counted for the choice: another plan than the check's, so Confirm + # waits for its own Install. + install.tui_receive(state, ("event", state.run_id, { + **CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0, + "bytes_needed": 16384})) install.tui_receive(state, ("ended", state.run_id, 1, [])) lines = drawn(state, 80, 24) self.assertIn("A/Enter Install", str(lines[-1])) @@ -12817,8 +12825,8 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): self.assertIn("ArkOS SD card (ROMS2", text) text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, declared_only=False), [], mounts=mounts) - self.assertIn("ArkOS SD card (EASYROMS)", text) - self.assertIn("ArkOS SD card (ROMS2)", text) + self.assertIn("1) ArkOS SD card (EASYROMS", text) + self.assertIn("2) ArkOS SD card (ROMS2", text) self.assertNotIn("in the reader", text) def test_a_fixed_partition_is_told_from_a_card(self): @@ -13848,7 +13856,9 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): def test_nothing_found_is_said_once(self): """tui_audit_16 P3-2: "Nothing was found here." twice, and after "A card is in the reader".""" - text, _asked = self.line_run(_args(declared_only=False), []) + # The standalone emulators' lists come from the network: none here. + with mock.patch.object(install, "_install_standalone_folders", return_value=False): + text, _asked = self.line_run(_args(declared_only=False), []) self.assertEqual(text.count("Nothing was found here."), 1) card = games_part(self.root / "media" / "EASYROMS") text, _asked = self.line_run(_args(declared_only=False), [], mounts=[card]) From 27bd4f1a5043613ee3fb4cbafeab5b4b65b267bb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:31:49 +0100 Subject: [PATCH 09/11] fix: one failure line per file, tests stay local --- install.ps1 | 2 +- install.py | 8 ++++---- install.sh | 2 +- install/launchers/index.json | 16 +++++++-------- schemas/install-events.schema.json | 27 ++++++++++++++++++++++++ tests/netguard/sitecustomize.py | 4 +++- tests/test_install.py | 33 ++++++++++++++++++++++++------ tests/test_install_tui.py | 16 +++++++++++++-- 8 files changed, 85 insertions(+), 23 deletions(-) diff --git a/install.ps1 b/install.ps1 index b8c036982..50bed9a95 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a" + $defaultInstallSha256 = "5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 6ff0322db..13022a7ad 100644 --- a/install.py +++ b/install.py @@ -6122,11 +6122,11 @@ def _report_outcome( # A failure the progress already named on its FAILED line is not named # again: line mode listed each one twice (tui_audit_17 P3-7). unsaid = [failure for failure in failures or [] if failure[0] not in SAID_FAILED] - if unsaid and len(unsaid) > 1 and len(reasons) == 1 and reasons != {""}: + if failures and len(failures) > 1 and len(reasons) == 1 and reasons != {""}: # One cause, said once: a read-only folder listed ten of its files # and "... and 266 more" above the advice that fixes them all. - say(f" failed: {_plural(len(unsaid), 'file')} ({reasons.pop()}), " - f"first {unsaid[0][0]}") + said = f", first {unsaid[0][0]}" if unsaid else "" + say(f" failed: {_plural(len(failures), 'file')} ({reasons.pop()}){said}") else: for dest, reason in unsaid[:10]: say(f" failed: {dest}" + (f" ({reason})" if reason else "")) @@ -15547,7 +15547,7 @@ def core_count_words(cores: list[str], base: list[str]) -> str: def _tui_systems_chosen(state: TuiState) -> list[str]: """The systems the run is narrowed to, [] for every system.""" - return [value for flag, value in _tui_system_flags(state) if value] + return [value for _flag, value in _tui_system_flags(state) if value] def _chosen_cores(state: TuiState) -> set[str] | None: diff --git a/install.sh b/install.sh index d8296c900..c2f6353e9 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a" +DEFAULT_INSTALL_SHA256="5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 31d0df546..14b41d3db 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 996363, - "sha256": "5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a", + "size": 996397, + "sha256": "5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1049540, - "sha256": "a7a9c2f9b7d018d0aca34731957a13d9d8ab22ea747381e6750d45abbfb70390" + "size": 1049574, + "sha256": "30bbe8a0f78cc7b6bb37b9290c69e2244e0b9217339d9ea695f45c0d7815a263" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 996363, - "sha256": "5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a", + "size": 996397, + "sha256": "5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 996363, - "sha256": "5f933d4ab032e6e401341818a35eeee9b4018932fde7ad5111fa789375fb350a", + "size": 996397, + "sha256": "5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15", "executable": false }, { diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index 62db8c06b..f43b4ebf8 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -17,6 +17,7 @@ "scanned", "version", "choices", + "set_aside", "file", "system", "checking", @@ -58,6 +59,9 @@ { "$ref": "#/$defs/choices" }, + { + "$ref": "#/$defs/set_aside" + }, { "$ref": "#/$defs/file" }, @@ -299,6 +303,29 @@ } } }, + "set_aside": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "platform", + "dests" + ], + "properties": { + "event": { + "const": "set_aside" + }, + "platform": { + "type": "string" + }, + "dests": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "file": { "type": "object", "additionalProperties": false, diff --git a/tests/netguard/sitecustomize.py b/tests/netguard/sitecustomize.py index 2aa5f732d..d7ab23fb8 100644 --- a/tests/netguard/sitecustomize.py +++ b/tests/netguard/sitecustomize.py @@ -27,7 +27,9 @@ _SAVED: dict = {} def _refuse(host: object) -> None: where = os.environ.get(NETLOG) - if where: + # A name under .invalid never resolves (RFC 2606): a test names one to + # fail on purpose, refused here before its DNS query leaves. + if where and not str(host).endswith(".invalid"): with open(where, "a", encoding="utf-8") as log: log.write(f"{os.getpid()} {host} {' '.join(sys.argv)[:300]}\n") raise OSError(errno.ENETUNREACH, f"a test makes no request to {host}") diff --git a/tests/test_install.py b/tests/test_install.py index 6ddeebd43..23bcb7efd 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -10,6 +10,7 @@ import io import json import os import stat +import select import shlex import re import shutil @@ -4182,17 +4183,35 @@ class AQuestionLeftWithCtrlDOrCtrlCIsAStop(unittest.TestCase): home = Path(_tempdir(self)) master, slave = pty.openpty() + # Its output on the terminal too: piped, nothing is asked, and the + # test skipped on every machine. proc = subprocess.Popen( - [sys.executable, "install.py"], cwd=REPO_ROOT, stdin=slave, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - env={**os.environ, "HOME": str(home), "RETROBIOS_OS": "linux"}, + [sys.executable, "install.py", "--plain"], cwd=REPO_ROOT, stdin=slave, + stdout=slave, stderr=slave, + # No request leaves the machine: the standalone emulators' lists + # come from the network (code_audit_24 P3-13). + env={**os.environ, "HOME": str(home), "RETROBIOS_OS": "linux", + "RETROBIOS_INSTALL_OFFLINE": "1"}, ) os.close(slave) self.addCleanup(os.close, master) - time.sleep(3) + out = "" + deadline = time.monotonic() + 60 + while "Which setup?" not in out and proc.poll() is None \ + and time.monotonic() < deadline: + if select.select([master], [], [], 0.2)[0]: + out += os.read(master, 65536).decode("utf-8", "replace") os.write(master, b"\x04") - out, _ = proc.communicate(timeout=60) - if "Choose the platform" not in out: + proc.wait(timeout=60) + while select.select([master], [], [], 0.2)[0]: + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + out += chunk.decode("utf-8", "replace") + if "Which setup?" not in out: self.skipTest("a platform was detected on this machine") self.assertEqual(proc.returncode, 130, out) self.assertIn("Stopped", out) @@ -5211,6 +5230,8 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree): # The systems Choose systems lists, before the files are read. {"event": "choices", "platform": "retroarch", "systems": ["sys-a", "sys-b", "other"]}, + # Nothing set aside before the choice (code_audit_24 P3-3). + {"event": "set_aside", "platform": "retroarch", "dests": []}, # a.bin and b.bin are in place: the first read and the last. {"event": "checking", "files_done": 1, "files_total": 2}, {"event": "checking", "files_done": 2, "files_total": 2}, diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 802d09079..511bebc7f 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -823,9 +823,16 @@ class Widgets(unittest.TestCase): state = fresh_state() press(state, "up") self.assertEqual(state.focus["setup"], 2) - state = self.systems_state(20) + state = fresh_state() + state.stack = ["setup", "platforms"] press(state, "up") - self.assertEqual(state.focus["systems"], 0) + self.assertEqual(state.focus["platforms"], 0) + # A list of ticks wraps at any length: None and Done, at its end, + # are a few presses up from its first item (tui_audit_17 P3-1). + state = self.systems_state(20) + state.focus["systems"] = 0 + press(state, "up") + self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "done") def test_filter_ignores_case_and_accents(self): state = self.systems_state(3) @@ -6777,7 +6784,12 @@ class AuditSevenScreens(unittest.TestCase): with SaveDirectoryRepository() as repo: argv = ["install.py", "--platform", "retroarch", "--dest", str(repo.dest), "--manifest", str(repo.root / "install" / "retroarch.json")] + # The files come from the repository on the loopback: the module + # read its base before the environment named it (code_audit_24 + # P3-13). + served = repo.env()["RETROBIOS_BASE_URL"] + "/{path}" with mock.patch.dict(os.environ, repo.env()), \ + mock.patch.object(install, "RAW_FILE_URL", served), \ mock.patch.object(sys, "argv", argv), \ mock.patch.object(sys.stdin, "isatty", return_value=True), \ mock.patch.object(install, "_is_terminal", return_value=True), \ From 1cc12d8e2aa08c329389ba7fc36dfef9bba4b26b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:56:45 +0100 Subject: [PATCH 10/11] docs: which-pack names the folder in fewer words --- scripts/generate_site.py | 12 +++++------- tests/test_doc_pages.py | 2 +- tests/test_install_tui.py | 4 +++- tests/test_site_r3_entry.py | 3 +-- tests/test_site_validation.py | 1 + 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 34be02506..0b0ff5c9b 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -3945,9 +3945,8 @@ def first_screen_words() -> list[str]: # above on the page (tui_audit_16 P3-10). # Nothing found, the folder comes before Install: which-pack left it # out (tui_audit_17 P3-9). - return [f"It asks **{installer.TUI_FOUND_TITLE}**, or **{installer.TUI_CHOOSE_TITLE}** " - f"and **{installer.TUI_FOLDER_TITLE}** when it finds nothing, then offers " - f"**{installer.TUI_INSTALL_ROW}**."] + return [f"It asks **{installer.TUI_FOUND_TITLE}** (or **{installer.TUI_CHOOSE_TITLE}** " + f"and **{installer.TUI_FOLDER_TITLE}**), then offers **{installer.TUI_INSTALL_ROW}**."] def check_pass_line() -> str: @@ -4116,8 +4115,7 @@ def only_some_consoles(name: str, prefix: str = "") -> list[str]: """The way to a smaller download, in the words Get your files uses.""" return [ f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}" - f"#only-some-consoles), or run the command: **{_installer().TUI_SYSTEMS_ROW}** " - "is on its second screen.", + f"#only-some-consoles), or the command's **{_installer().TUI_SYSTEMS_ROW}**.", "", ] @@ -11414,8 +11412,8 @@ def generate_which_pack() -> str: "", "## Only some consoles?", "", - "Pick them here, and get the files and the command. Or run the command " - f"below: **{_installer().TUI_SYSTEMS_ROW}** is on its second screen.", + "Pick them here, and get the files and the command. Or use the command's " + f"**{_installer().TUI_SYSTEMS_ROW}**.", "", "", "", diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 286bd0b28..5d919dfdd 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -187,7 +187,7 @@ class InstallerSamplesSayWhatTheCodePrints(unittest.TestCase): refused = _generated(page, "check-refused") self.assertRegex(refused, r"\n \d+ files? [a-z ]+:\n") self.assertIn("read by beetle_psx, pcsx_rearmed at", refused) - self.assertNotIn("opened at", page) + self.assertNotRegex(page, r"opened at /\S+ by ") self.assertIn('"read by ', inspect.getsource(install.opened_line)) ending = _generated(page, "from-run") self.assertRegex(ending, r"Done\. \d+ files installed\.\n \d+ files copied from ") diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 511bebc7f..7b147259e 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -11816,7 +11816,9 @@ class AuditTwentyAndThirteen(unittest.TestCase): self.assertEqual(said.returncode, 1, said.stdout + said.stderr) self.assertIn("No supported platform detected: RetroArch was not found here, and " "the pack does not say where it goes.", said.stderr) - alone = install_run("--yes", env=env) + # The standalone emulators' lists come from the network: offline, + # no request leaves the machine (code_audit_24 P3-13). + alone = install_run("--yes", env={**env, install.OFFLINE_VARIABLE: "1"}) self.assertIn("No supported platform detected", alone.stdout + alone.stderr) hint = install._manual_usage_hint("linux") self.assertTrue(hint[0].startswith(" Name the setup with --platform and its folder " diff --git a/tests/test_site_r3_entry.py b/tests/test_site_r3_entry.py index 84ca38a90..8ed8ad9db 100644 --- a/tests/test_site_r3_entry.py +++ b/tests/test_site_r3_entry.py @@ -160,8 +160,7 @@ class SetupPagesOfferPartOfThePack(unittest.TestCase): self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text) # The screen's own row: the line menu's c is not what the # one-liner opens. - self.assertIn("or run the command: **Choose systems** is on its second " - "screen.", text) + self.assertIn("or the command's **Choose systems**.", text) # Never "choose **Choose systems**" (tui_audit_17 P3-9). self.assertNotIn("choose **Choose systems**", text) diff --git a/tests/test_site_validation.py b/tests/test_site_validation.py index 35acb41e7..feac2d14a 100644 --- a/tests/test_site_validation.py +++ b/tests/test_site_validation.py @@ -279,6 +279,7 @@ class EveryTestDependencyIsInstalledInCi(unittest.TestCase): {p.stem for p in (root / "scripts").rglob("*.py")} | {p.name for p in (root / "scripts").iterdir() if p.is_dir()} | {p.stem for p in (root / "tests").glob("*.py")} + | {p.name for p in (root / "tests").iterdir() if p.is_dir()} | {"scripts", "tests", "install"} ) imported: set[str] = set() From 5175c53b3e5df7c9be0cc3628eb710714b9be009 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:04:58 +0100 Subject: [PATCH 11/11] chore: a failure is named once in line mode --- tests/test_install_selection.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index db776c39e..f1129e35e 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -1632,10 +1632,12 @@ class SomethingElseAtADestination(unittest.TestCase): proc = repo.run("--yes") self.assertEqual(proc.returncode, 1, proc.stderr) self.assertNotIn("Traceback", proc.stderr) - self.assertIn("failed: scph5500.bin (a folder sits where this file goes", + # Each failure named once, on its FAILED line (tui_audit_17 P3-7). + self.assertIn("FAILED scph5500.bin (a folder sits where this file goes", proc.stdout) - self.assertIn("failed: scph5501.bin (file in place could not be kept aside", + self.assertIn("FAILED scph5501.bin (file in place could not be kept aside", proc.stdout) + self.assertEqual(proc.stdout.count("scph5500.bin ("), 1, proc.stdout) self.assertTrue((repo.dest / "scph5500.bin").is_dir()) self.assertIn("gba_bios.bin", repo.installed())