diff --git a/install.ps1 b/install.ps1 index 2fdb5701b..8d82cdd0d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab" + $defaultInstallSha256 = "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 722821d28..5776b0233 100644 --- a/install.py +++ b/install.py @@ -39,6 +39,7 @@ import subprocess import sys import tarfile import tempfile +import textwrap import threading import time import traceback @@ -295,12 +296,14 @@ def _os_default_dests(os_type: str) -> dict[str, Path]: "retroarch": home / ".config" / "retroarch" / "system", } + # Set when the run is interrupted, so queued downloads end instead of draining # the pool: every future is submitted before the first one completes. _stop = threading.Event() # Set once a run starts writing: Ctrl+C before that has nothing to carry on. _writing = threading.Event() + class _Interrupted(Exception): """Raised inside a worker once the run has been stopped.""" @@ -912,11 +915,28 @@ def _host_path(text: str) -> Path: return Path(text) +def shown_path(path: Path | str) -> str: + """A path as the person names it: under WSL, /mnt/d/BIOS is D:\\BIOS, the + drive the card row calls D:.""" + text = str(path) + root = _WSL["root"].rstrip("/") + "/" + match = (re.match(rf"^{re.escape(root)}([a-z])(?:/(.*))?$", text) + if _WSL["active"] else None) + if match: + return f"{match.group(1).upper()}:\\" + (match.group(2) or "").replace("/", "\\") + return text + + def _wsl_dest(args: argparse.Namespace) -> None: - """--dest D:\\ as WSL opens it: the screen names that card D:, and the - letter typed was refused as a folder that does not exist.""" - if args.dest is not None: - args.dest = _host_path(str(args.dest)) + """Every path option as WSL opens it: the screen names a card D:, and + D:\\ given to --dest was refused as a folder that does not exist, then + E:\\ given to --from once --dest took it.""" + for name in ("dest", "save_dir", "cores_dir"): + if getattr(args, name, None) is not None: + setattr(args, name, _host_path(str(getattr(args, name)))) + for name in ("from", "from_pack"): + if getattr(args, name, None): + setattr(args, name, [_host_path(str(path)) for path in getattr(args, name)]) def _enter_wsl() -> None: @@ -4237,6 +4257,7 @@ def place_save_entries( unplaced = [ {"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name, "system": "", "required": False, "reason": "no_save_directory", + "frontend": display, "cores": entry.get("cores"), "root": "save", **({"core_folder": entry["core_folder"]} if entry.get("core_folder") else {})} for entry in saved @@ -5131,7 +5152,7 @@ def _retry_delay(attempt: int, retry_after: str | None) -> float: def _locations(paths: list[Path]) -> str: """Where the run wrote, naming every destination when it served several.""" - return ", ".join(str(p) for p in paths) if paths else "nowhere" + return ", ".join(shown_path(p) for p in paths) if paths else "nowhere" # A write refused by the file system: retrying cannot change it, and the @@ -5222,7 +5243,9 @@ class Invocation: ("--no-game-data", args.no_game_data), ("--declared-only", args.declared_only), ("--standalone-copies", args.standalone_copies), - ("--offline", getattr(args, "offline", False)), + # RETROBIOS_INSTALL_OFFLINE too: sudo does not pass it on, and the + # command it was told to run fetched what the run had left out. + ("--offline", getattr(args, "offline", False) or offline_requested()), ("--check", args.check), ("--dry-run", args.dry_run), ): @@ -5399,6 +5422,15 @@ def _report_outcome( that failed do, 1 when some were installed and 2 when none were, the codes the full screen's run gives. """ + # A file --offline left out is not a failure, whether the run asked or + # not: without questions it was "14698 files failed", and "Run the same + # command again to retry only those", which cannot fetch them. + left = [failure for failure in failures or [] if isinstance(failure[1], OfflineReason)] + if left: + failures = [failure for failure in failures or [] + if not isinstance(failure[1], OfflineReason)] + errors = max(0, errors - len(left)) + offline += len(left) if transferred and seconds > 0: verb = "copied from the pack" if LOCAL.pack is not None else "downloaded" print(f"\n{format_size(transferred)} {verb} in " @@ -5418,7 +5450,14 @@ def _report_outcome( print(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.") print(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.") return - kept = _kept_lines() + # Left out for an unknown save directory, said with that reason in + # every ending: "the collection does not hold it" blamed the collection. + unplaced = [entry for entry in omitted_entries or [] + if entry.get("reason") == "no_save_directory"] + kept = _kept_lines() + ([ + f"{_plural(len(unplaced), 'file')} read from " + f"{unplaced[0].get('frontend') or 'the frontend'}'s save directory left out: " + "its place is not known. Name it with --save-dir next time."] if unplaced else []) if interrupted: print(f"\nStopped. {_plural(downloaded, 'file')} installed.") for line in kept: @@ -5445,9 +5484,6 @@ def _report_outcome( "(or delete them), then run again.") if any(isinstance(reason, NetworkReason) for _dest, reason in failures or []): advice.append(NETWORK_FAILURE_ADVICE) - if any(isinstance(reason, OfflineReason) for _dest, reason in failures or []): - advice.append(" These files come from the network, which --offline does not " - "use: run without --offline, or name a pack with --from-pack.") paths = paths + [folder for folder in elsewhere if folder not in paths] if offline: @@ -5456,14 +5492,22 @@ def _report_outcome( print(f"\nNothing installed: {_plural(offline, 'file')} " f"{'needs' if offline == 1 else 'need'} the internet, which --offline " "does not use.") + for line in kept: + print(line) print(f" {remedy}") if up_to_date: print(f"{_plural(up_to_date, 'file')} already up to date at " f"{_locations(paths)}.") sys.exit(2) failed = f", {errors} failed" if errors else "" - print(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, " - f"{offline} not fetched (--offline){failed}.") + if downloaded: + print(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, " + f"{offline} not fetched (--offline){failed}.") + else: + # Nothing written: "Finished with files missing. 0 files installed" + # read as a part done. + print(f"\nNothing installed: {_plural(offline, 'file')} not fetched " + f"(--offline){failed}.") if sources: print(f" {sources}") print(f"Location: {_locations(paths)}") @@ -5505,25 +5549,48 @@ def _report_outcome( print(f"Location: {_locations(paths)}") for line in kept: print(line) - if omitted: - for line in _omission_lines(omitted, omitted_entries or []): + if omitted > len(unplaced): + for line in _omission_lines(omitted - len(unplaced), + [entry for entry in omitted_entries or [] + if entry.get("reason") != "no_save_directory"]): print(line) for line in hints or []: print(line) print("To check this later, run the same command with --check.") -def _offline_remedy() -> str: +def _offline_remedy(folders: list[str] | None = None) -> str: """Where files --offline does not fetch can still come from: the - folders --from named are not suggested again.""" - if LOCAL.folders: - named = ", ".join(screen_text(index.label) for index in LOCAL.folders) + folders --from named (folders, on the screen) are not suggested again. + One wording for line mode, a run without questions, --check and the + screen.""" + labels = (folders if folders is not None + else [index.label for index in LOCAL.folders]) + if labels: + named = ", ".join(screen_text(shown_path(label)) for label in labels) return (f"They are not in {named}: run the installer again with a pack " "(--from-pack) or without --offline.") return ("Copy them from your old card or a folder (--from), or run the installer " "again with a pack (--from-pack) or without --offline.") +def _check_fetch_line(fetch: int, beside: str) -> str: + """What --check says a run without it does with the files not in place: + offline, those it cannot fetch are said so, not "run it to fetch them".""" + if _offline_only(): + if not fetch: + return " Run the same command without --check to copy them into place." + return (f" {_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the " + f"internet, which --offline does not use. {_offline_remedy()}") + return (" Run the same command without --check to fetch them; a file with a wrong " + f"hash is replaced, and the one in place kept in {BACKUP_DIR} beside {beside}.") + + +def _offline_failure(reason: str) -> bool: + """Whether a failure a run reported is a file --offline left out.""" + return OFFLINE_WORDS in reason or "makes no network request" in reason + + def _omission_lines(count: int, entries: list[dict]) -> list[str]: """What was skipped, the files an emulator cannot start without first. @@ -5648,9 +5715,10 @@ def _print_plan( # internet". one = len(to_download) + tree_files == 1 print(f"\n{' and '.join(parts)} {'needs' if one else 'need'} the internet, which " - f"--offline does not use. Without --offline they would go to {bios_path}:") + "--offline does not use. Without --offline they would go to " + f"{shown_path(bios_path)}:") elif parts: - print(f"\nWould download {' and '.join(parts)} to {bios_path}:") + print(f"\nWould download {' and '.join(parts)} to {shown_path(bios_path)}:") for line in copied: print(line) if not parts: @@ -6154,9 +6222,7 @@ def _install_standalone_folders( break if args.check: if pending: - print("\n Run the same command without --check to fetch them; " - "a file with a wrong hash is replaced, and the one in place " - f"kept in {BACKUP_DIR} beside its folder.") + print("\n" + _check_fetch_line(pending, "its folder")) sys.exit(1) return True if args.dry_run: @@ -6310,14 +6376,43 @@ def _line_setups() -> list[tuple[str, str]]: platforms, then each system that runs from its SD card, by its own name, as the screen lists them. Onion was reachable only as RetroArch, whose folder on a computer is the computer's.""" - choices = [(plat, display_name(plat) + (", SD card" if card_setup(plat, plat) else "")) - for plat in AVAILABLE_PLATFORMS] - choices += [(setup.name, f"{setup.name}, SD card ({display_name(setup.plat)} files)") - for setup in sorted(card_setups(), key=lambda item: item.name.casefold()) - if setup.name != display_name(setup.plat)] + def devices(name: str) -> str: + # The devices that run it, three at most: the name its owner knows. + names = list(_SETUP_DEVICES.get(name, ())) + more = f", +{len(names) - 3}" if len(names) > 3 else "" + return ", ".join(names[:3]) + more + + choices = [] + for plat in AVAILABLE_PLATFORMS: + label = display_name(plat) + if card_setup(plat, plat): + label += ", SD card" + (f" ({devices(plat)})" if devices(plat) else "") + choices.append((plat, label)) + for setup in sorted(card_setups(), key=lambda item: item.name.casefold()): + if setup.name != display_name(setup.plat): + shown = devices(setup.name) or f"{display_name(setup.plat)} files" + choices.append((setup.name, f"{setup.name}, SD card ({shown})")) return choices +def _line_matches(choices: list[tuple[str, str]], typed: str) -> list[int]: + """The indexes of the choices a typed device or system name finds, with + the matcher of the screen's filter: "miyoo" was refused, the line menu + knowing only setup names.""" + systems = _systems_reading() + cards = {setup.name for setup in card_setups()} + found = [] + for index, (name, _label) in enumerate(choices): + if name in AVAILABLE_PLATFORMS: + others = [system for system in systems.get(name, []) if system not in cards] + named = _setup_matches(name, others, typed) + else: + named = _system_matches(name, typed) + if named is not None: + found.append(index) + return found + + def _choose_platform(title: str, default: str = "") -> str: """One setup from the numbered list, Enter taking the default: a platform id, or the name of a system that runs from its SD card, which @@ -6327,7 +6422,7 @@ def _choose_platform(title: str, default: str = "") -> str: for i, (_name, label) in enumerate(choices, 1): print(f" {i}) {label}") print(" q) quit") - print() + print("Type a number, a setup's name, or your device's name (Miyoo Mini, R36S).") prompt = f"> [Enter for {display_name(default)}] " if default else "> " while True: choice = _ask(prompt) @@ -6341,8 +6436,17 @@ def _choose_platform(title: str, default: str = "") -> str: for name, _label in choices: if key and key in (_name_key(name), _name_key(display_name(name))): return name - print(f" Type a number from 1 to {len(choices)}, a platform name, " - "or q to quit.") + found = _line_matches(choices, choice) if key else [] + if len(found) == 1: + print(f" {choices[found[0]][1]}") + return choices[found[0]][0] + if found: + print(f" {len(found)} setups run on '{choice.strip()}':") + for index in found: + print(f" {index + 1}) {choices[index][1]}") + continue + print(f" Type a number from 1 to {len(choices)}, a setup name or your " + "device's name, or q to quit.") def _prompt_manual_platform(os_type: str, ask: bool = True, @@ -6588,6 +6692,7 @@ def _prompt_custom_selection( plat: str, files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None = None, name: str = "", dry_run: bool = False, data_trees: list[dict] | None = None, + bios_path: Path | None = None, ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. @@ -6603,33 +6708,53 @@ def _prompt_custom_selection( print(f"\n{name or display_name(plat)}: {_sized(files)}" + (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "") + ".") - offline = _offline_only() - if offline: - # Said before the menu: "install everything" was offered, then - # nothing came and the run said Done. - print(" --offline: only the files found on this machine are installed" - + ("." if LOCAL.folders else ", and no --from names a folder to copy " - "them from.")) - # A dry run installs nothing: "install everything" was asked before it. - print(" Enter) list everything, nothing is written" if dry_run - else " Enter) install what is found on this machine" if offline - else " Enter) install everything") - if installed is not None: - print(f" i) only what the installed cores read: {_sized(installed[0])}") - print(" c) choose systems, cores and regions") - print(" q) quit") + if bios_path is not None: + # Named before anything is written: line mode said where only after + # Done, where the screen names it on every plan. + print(f" BIOS folder: {shown_path(bios_path)}") while True: + offline = _offline_only() + if offline: + # Said before the menu: "install everything" was offered, then + # nothing came and the run said Done. + print(" --offline: only the files found on this machine are installed" + + ("." if LOCAL.folders else ", and no --from names a folder to copy " + "them from.")) + # A dry run installs nothing: "install everything" was asked before it. + # Offline with nothing to copy from, nothing can be installed but + # what is in place already. + if dry_run: + print(" Enter) list everything, nothing is written") + elif offline and LOCAL.folders: + print(" Enter) install what is found in " + + ", ".join(screen_text(index.label) for index in LOCAL.folders)) + elif offline: + print(" Enter) check the BIOS folder, nothing can be downloaded") + else: + print(" Enter) install everything") + if installed is not None: + print(f" i) only what the installed cores read: {_sized(installed[0])}") + print(" c) choose systems, cores and regions") + # The screen's "Copy the BIOS files from your old card or a folder". + copying = not dry_run + if copying: + print(" f) copy from a folder or an old card") + print(" q) quit") + keys = ["", "q", "c", *(["i"] if installed is not None else []), + *(["f"] if copying else [])] answer = _ask("> ").lower() + while answer not in keys: + print(f" Type {', '.join(['Enter', *keys[2:]])} or q.") + answer = _ask("> ").lower() if answer == "": return None if answer == "q": _quit() - if answer == "i" and installed is not None: + if answer == "i": return INSTALLED_ONLY if answer == "c": break - keys = "Enter, i, c or q" if installed is not None else "Enter, c or q" - print(f" Type {keys}.") + _ask_from_folder() choices = available_choices(files, omitted) if OTHER_SYSTEMS in choices["systems"]: @@ -9342,6 +9467,7 @@ def _open_local_sources(args: argparse.Namespace) -> None: named = local_manifest_platform(args.manifest) if args.platform is None and named: args.platform = named + args.platform_from_list = True print(f" File list: {args.manifest} ({display_name(named)})") if not args.from_pack: return @@ -9425,17 +9551,39 @@ def _open_from_folders(folders: list[Path]) -> None: EVENTS.emit("error", stage="from", reason=reason, platform="", message=message) print(f"Error: {screen_text(message)}.", file=sys.stderr) sys.exit(1) - index = FolderIndex(Path(os.path.expanduser(label)), label) - LOCAL.folders.append(index) - print(f" Copying BIOS files from: {screen_text(label)}") - if index.truncated: - print(f" Read the first {MAX_SCANNED_FILES:,} files of {screen_text(label)} " - "only. Name the folder that holds the BIOS files to search it all.") - for drive in index.other_drives: - print(f" Did not go into {screen_text(str(drive))}: it is another drive. Name " - "it with --from to read it too.") - EVENTS.emit("scanned", folder=label, files=index.files, truncated=index.truncated, - other_drives=[str(drive) for drive in index.other_drives]) + _read_from_folder(label) + + +def _ask_from_folder() -> None: + """A folder to copy from, asked at the menu: the screen offers it, the + line menu had no way to it.""" + while True: + raw = _ask("Folder, old card or ZIP to copy from (Enter to go back): ").strip() + if not raw: + return + label = str(_typed_folder(raw)) + problem = from_folder_problem(label) + if problem is None: + _read_from_folder(label) + _INVOCATION.options.append(("--from", label)) + return + print(f" {screen_text(label)} {problem[1]}.") + + +def _read_from_folder(label: str) -> FolderIndex: + """Read a folder to copy BIOS files from, one from_folder_problem took.""" + index = FolderIndex(Path(os.path.expanduser(label)), label) + LOCAL.folders.append(index) + print(f" Copying BIOS files from: {screen_text(shown_path(label))}") + if index.truncated: + print(f" Read the first {MAX_SCANNED_FILES:,} files of {screen_text(label)} " + "only. Name the folder that holds the BIOS files to search it all.") + for drive in index.other_drives: + print(f" Did not go into {screen_text(str(drive))}: it is another drive. Name " + "it with --from to read it too.") + EVENTS.emit("scanned", folder=label, files=index.files, truncated=index.truncated, + other_drives=[str(drive) for drive in index.other_drives]) + return index def _refuse_closed(dest: Path, named: str, then: str = "") -> None: @@ -9534,6 +9682,7 @@ def _card_mount(path: str) -> str: """The card mount a device path lies under, "" for none.""" return next((mount for mount in _CARD_MOUNTS if path.startswith(mount)), "") + # Systems whose card a computer sees as their data partition, by the file # their own build writes there, paths below that partition. KNULLI's is # /userdata: S12populateshare:15,105 copies datainit there, and every board's @@ -9626,6 +9775,13 @@ class CardChoice(NamedTuple): short: str = "" # The system's folder below its card. below: str = "" + # The card's root, when the folder refused lies inside it. + root: Path | None = None + # The card found and where its files go, said where nothing else names + # the folder (line mode): the screen's plan names it. + found: str = "" + # The system whose card the folder refused is, when one is. + other: str = "" class CardSetup(NamedTuple): @@ -9663,6 +9819,15 @@ def card_setups() -> list[CardSetup]: if plat in CARD_PLATFORMS] setups += [CardSetup(rule.name, rule.manifest, rule.bios, (rule,), True) for rule in _SHARE_RULES] + # AmberELEC and EmuELEC mount their card's games part as /storage/roms, + # the folder their list is written below: AmberELEC 5a3882cb fs-resize + # formats it exFAT as GAMES (packages/sysutils/busybox/scripts/ + # fs-resize:64), autostart.sh:93 fills it as "the GAMES volume". EmuELEC + # d4c7b2f1 makes EEROMS (fs-resize:127-140), mount_romfs.sh:10-14 mounts + # it there. A computer sees that part's root. No file either writes + # there names it. + setups += [CardSetup(name, plat, "") for name, plat in _STANDIN_MANIFESTS.items() + if name in ("AmberELEC", "EmuELEC")] for name in _systems_reading().get("retroarch", []): 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)), "") @@ -9710,7 +9875,7 @@ def _not_the_card(setup: CardSetup, folder: Path, card: Path, other: str) -> Car if other: return CardChoice(setup.plat, folder, refused=f"{card} is {_a(other)} card, not " f"{_a(name)} card", short=f"That is {_a(other)} card, not " - f"{_a(name)} card.", below=setup.below) + f"{_a(name)} card.", below=setup.below, other=other) marker = (CARD_MARKER if setup.plat in CARD_PLATFORMS else setup.rules[0].values[0].split("/")[0]) return CardChoice(setup.plat, folder, refused=f"{card} is not {_a(name)} SD card: no " @@ -9719,6 +9884,18 @@ def _not_the_card(setup: CardSetup, folder: Path, card: Path, other: str) -> Car below=setup.below) +def _refusal_words(choice: CardChoice, setup: CardSetup) -> str: + """Why a card typed or numbered in line mode is refused, and what to do: + another system's card is the top folder of a card, only not this one.""" + detail = f" {choice.detail}." if choice.detail else "" + if choice.other: + return (f"{choice.refused}.{detail} Insert the {setup.name} card, or choose " + f"{choice.other} for that one.") + if choice.root is not None: + return f"{choice.refused}.{detail} Type its root: {shown_path(choice.root)}" + return f"{choice.refused}.{detail} Type the top folder of the {setup.name} card." + + def _unknown_card_note(setup: CardSetup, card: Path, folder: Path) -> str: """Said when a card nothing names is taken for setup's.""" if setup.fresh: @@ -9730,58 +9907,123 @@ def _unknown_card_note(setup: CardSetup, card: Path, folder: Path) -> str: def _card_and_below(setup: CardSetup, path: Path) -> tuple[Path, bool]: """(card root, whether path is the BIOS folder below it) for a folder - named for setup: its BIOS folder named outright stands for its card.""" + named for setup: its BIOS folder named outright stands for its card. + + A folder whose own files name a system is that card, whatever its + name: an Onion card mounted as BIOS was read as a BIOS folder, its + parent checked as the card and refused.""" below = setup.below - if below and _ends_with(path, below): - return path.parents[len(PurePosixPath(below).parts) - 1], True + depth = len(PurePosixPath(below).parts) + if (below and _ends_with(path, below) and _card_is(setup, path)[0] == "unknown" + and not any(_card_boundary(folder) + for folder in [path, *list(path.parents)[:depth - 1]])): + return path.parents[depth - 1], True return path, False -def card_choice(named: str, plat: str, dest: Path) -> CardChoice: - """The file list and folder for a --dest that may be a handheld's card. +def _card_boundary(folder: Path) -> bool: + """Whether folder is where a drive is mounted: no folder above it is on + the same card. /media/me holds a card mounted as MUOS, and is no muOS + card for it.""" + return os.path.ismount(folder) or folder in _mounted_cards() - The card's root is where the user points, its BIOS folder is below it: - `--platform muos --dest /media/me/SD1` wrote to the root, which muOS - never reads. A card of another system is refused for every card system: - KNULLI took a muOS card and wrote its layout into it. A folder nothing - names is refused for a system whose card always carries its files, and - is taken as named for one that fills its card on first start or leaves - no file there that names it. The folder screen and the platform rows - read --dest here too, so the same --dest gives the same folder. + +def _card_above(setup: CardSetup, card: Path) -> tuple[str, str, Path] | None: + """(kind, name, root) of the card a folder nothing names lies inside, + when one of the folders above it, up to the drive's mount point, is a + card: ROCKNIX's roms/bios named for KNULLI read as a KNULLI card not + started once.""" + folder = card.absolute() + for parent in list(folder.parents)[:3]: + if _card_boundary(folder): + return None + kind, what = _card_is(setup, parent) + if kind != "unknown": + return kind, what, parent + folder = parent + return None + + +def _list_card_choice(plat: str, dest: Path) -> CardChoice: + """card_choice for a list named, not a card: a card's root it recognises + is read as that card, with the list's own system, and another system's + card is said, the user having named both.""" + found = card_system(dest) + if found and (not plat or found[0] == plat) and found[1] != dest: + return CardChoice(found[0], found[1], + found=f"{found[2]} card: {display_name(found[0])} files, " + f"in {found[1]}") + if found and plat and found[0] != plat: + return CardChoice(plat, dest, f"Note: {dest} is {_a(found[2])} card, which reads " + f"the {display_name(found[0])} file list in {found[1]}: " + f"--platform {_name_key(found[2])} --dest {dest}. The " + f"{display_name(plat)} list goes into it as named.") + matched = [] if found else _card_matches(dest) + if len({(match.manifest, match.bios) for match in matched}) > 1: + names = " and ".join(sorted({match.name for match in matched})) + return CardChoice(plat, dest, f"{screen_text(str(dest))} holds the files of " + f"{names}: installing into it as named. Name the card's BIOS " + "folder with --dest to write there.") + return CardChoice(plat, dest) + + +def card_choice(named: str, plat: str, root: Path) -> CardChoice: + """The file list and BIOS folder for a folder named for what --platform + or a row of the setup list names, or why it is not that system's card. + + The one reading of a card's root, wherever it is named: --dest, the + setup list with --dest, a card listed, browsed or typed on the folder + screen, a card numbered or typed in line mode. Two readings gave a blank + stick named for KNULLI two folders, the root with --dest and bios below + it typed. The card's root is where the user points, its BIOS folder is + below it: `--platform muos --dest /media/me/SD1` wrote to the root, + which muOS never reads. A card of another system is refused for every + card system: KNULLI took a muOS card and wrote its layout into it. A + folder nothing names is refused for a system whose card always carries + its files (the MinUI family among them), and gets the system's folder + and a note for one that fills its card on first start or leaves no + file there that names it. A folder that does not exist is refused: a + typo four folders deep was created and filled. """ setup = card_setup(named, plat) if setup is None: - # A list named, not a card: a card's root it recognises is read as - # that card, with the list's own system. - found = card_system(dest) - if found and (not plat or found[0] == plat) and found[1] != dest: - return CardChoice(found[0], found[1], - f"{found[2]} card: {display_name(found[0])} files, in {found[1]}") - matched = [] if found else _card_matches(dest) - if len({(match.manifest, match.bios) for match in matched}) > 1: - names = " and ".join(sorted({match.name for match in matched})) - return CardChoice(plat, dest, f"{screen_text(str(dest))} holds the files of " - f"{names}: installing into it as named. Name the card's BIOS " - "folder with --dest to write there.") - return CardChoice(plat, dest) + return _list_card_choice(plat, root) # A folder its device reads (/run/muos/storage/bios): on the device. - if dest.as_posix() in {rule.bios for rule in _EMBEDDED_RULES if rule.name == setup.name}: - return CardChoice(plat, dest) - card, inside = _card_and_below(setup, dest) + if root.as_posix() in {rule.bios for rule in _EMBEDDED_RULES if rule.name == setup.name}: + return CardChoice(plat, root) + card, inside = _card_and_below(setup, root) + if not _is_dir(card): + return CardChoice(plat, root, refused=f"{card} does not exist, or is not a folder", + short="That folder does not exist.", below=setup.below) + if plat in CARD_PLATFORMS: + above = card_root_above(root) + if above: + name = _a(display_name(above[0])) + return CardChoice(plat, root, refused=f"{root} is inside {name} card, whose " + f"root is {above[1]}", short=f"That is inside {name} card.", + root=above[1]) kind, what = _card_is(setup, card) + if kind == "unknown": + holder = _card_above(setup, card) + if holder and holder[0] == "other": + return _not_the_card(setup, root, holder[2], holder[1]) + if holder: + return CardChoice(plat, root, refused=f"{root} is inside the {setup.name} card, " + f"whose root is {holder[2]}", short=f"That is inside " + f"{_a(setup.name)} card.", below=setup.below, root=holder[2]) + folder = root if inside or not setup.below else card / setup.below if kind == "other": - return _not_the_card(setup, dest, card, what) - if inside or plat in CARD_PLATFORMS: - return CardChoice(plat, dest) - folder = dest / setup.below + return _not_the_card(setup, root, card, what) if kind == "own": - return CardChoice(plat, folder, f"{setup.name} card: {display_name(plat)} files, " - f"in {folder}") + note = ("" if what == setup.name else f"Note: this card holds {what} " + f"({CARD_MARKER}), and the {display_name(plat)} file list was asked for.") + found = (f"{setup.name} card: {display_name(plat)} files, in {folder}" + if folder != root else "") + return CardChoice(plat, folder, note, below=setup.below, found=found) if not setup.fresh and setup.known(): - return _not_the_card(setup, dest, card, "") - return CardChoice(plat, dest, f"Nothing on {dest} says it is {_a(setup.name)} card: " - f"installing into it as named. {setup.name}'s folder on its card is " - f"{setup.below}: --dest /{setup.below}", below=setup.below) + return _not_the_card(setup, root, card, "") + return CardChoice(plat, folder, _unknown_card_note(setup, card, folder), + below=setup.below) def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: @@ -9790,12 +10032,21 @@ def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: choice = card_choice(named, plat, dest) if choice.refused: detail = f" {choice.detail}." if choice.detail else "" - where = f" or that folder: --dest /{choice.below}" if choice.below else "" - print(f"Error: {choice.refused}.{detail} Name the card's root{where}", - file=sys.stderr) + where = (f": --dest {choice.root}" if choice.root + else f", or that folder: --dest /{choice.below}" if choice.below + else "") + message = f"{choice.refused}.{detail} Name the card's root{where}" + # A script reading the stream learned code 1 and no reason. + EVENTS.emit("error", stage="detect", reason="not_found", platform=choice.plat, + message=message) + print(f"Error: {message}", file=sys.stderr) sys.exit(1) - if choice.note: - print(f" {choice.note}") + for line in (choice.found, choice.note): + if line: + print(f" {line}") + system = card_bound(named, plat) + if system is not None: + _DETECTED_OS.setdefault((choice.plat, choice.folder), system[0]) return choice.plat, choice.folder @@ -9811,23 +10062,6 @@ def card_root_above(dest: Path) -> tuple[str, Path] | None: return None -def _check_card_root(plat: str, dest: Path) -> None: - """Refuse a folder inside the card where its root is meant. - - The file list writes Bios// below --dest: given the card's Bios - folder, every file would land in Bios/Bios//, which no core reads. - """ - above = card_root_above(dest) - if above: - print(f"Error: {dest} is inside {_a(display_name(above[0]))} card. " - f"Name the card's root: --dest {above[1]}", file=sys.stderr) - sys.exit(1) - found = card_platform(dest) - if found is not None and found != plat: - print(f" Note: this card holds {display_name(found)} ({CARD_MARKER}), and the " - f"{display_name(plat)} file list was asked for.") - - def card_bound(named: str, plat: str) -> tuple[str, str] | None: """(system name, BIOS folder below the card root) when what --platform named runs from an SD card, else None. @@ -9853,44 +10087,6 @@ def mounted_cards_of(system: str, plat: str) -> list[Path]: return [mount for mount in _mounted_cards() if _card_is(setup, mount)[0] == wanted] -def card_root_folder(named: str, plat: str, root: Path) -> CardChoice: - """The BIOS folder for root, chosen as the card of the card system - named, or why root is not that card. - - The one check of a card's root, for a card listed, browsed or typed on - the folder screen and for the line question. A typed root was taken as - it was: a typo four folders deep was created and filled, a menu answer - (8) became a relative card, a muOS card typed for KNULLI got KNULLI's - layout. Each is refused here, with its reason, for the question to be - asked again. --dest is read by card_choice, with the same identity. - """ - setup = card_setup(named, plat) - if setup is None: - return CardChoice(plat, root) - card, inside = _card_and_below(setup, root) - if not _is_dir(card): - return CardChoice(plat, root, refused=f"{card} does not exist, or is not a folder", - short="That folder does not exist.") - if plat in CARD_PLATFORMS: - above = card_root_above(root) - if above: - name = _a(display_name(above[0])) - return CardChoice(plat, root, refused=f"{root} is inside {name} card, whose " - f"root is {above[1]}", short=f"That is inside {name} card.") - kind, what = _card_is(setup, card) - folder = root if inside or not setup.below else card / setup.below - if kind == "other": - return _not_the_card(setup, root, card, what) - if kind == "own": - note = ("" if what == setup.name else f"Note: this card holds {what} " - f"({CARD_MARKER}), and the {display_name(plat)} file list was asked for.") - return CardChoice(plat, folder, note, below=setup.below) - if not setup.fresh and setup.known(): - return _not_the_card(setup, root, card, "") - return CardChoice(plat, folder, _unknown_card_note(setup, card, folder), - below=setup.below) - - def card_line(mount: Path) -> str: """A mounted drive in the line questions, named as the screen names it, its path after: D: (Onion card, /mnt/d).""" @@ -9914,13 +10110,17 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True cards = mounted_cards_of(system, plat) if setup.known() and len(cards) == 1: dest = cards[0] / setup.below if setup.below else cards[0] - print(f" {system} card found at {cards[0]}: {display_name(plat)} files, in {dest}") + print(f" {system} card found at {shown_path(cards[0])}: {display_name(plat)} " + f"files, in {shown_path(dest)}") + _DETECTED_OS.setdefault((plat, dest), system) return dest - example = (f"--platform {named.strip() or plat} --dest /media/$USER/ " + # Pasted, the name of a system in words is two arguments: its key is one. + word = named.strip() if _name_key(named) == named.strip().lower() else _name_key(named) + example = (f"--platform {word or plat} --dest /media/$USER/ " "(Windows: --dest E:\\)") if setup.known() and len(cards) > 1: print(f" {len(cards)} {system} cards are mounted: " - + ", ".join(str(card) for card in cards) + ".", file=sys.stderr) + + ", ".join(_mount_name(card) for card in cards) + ".", file=sys.stderr) if not interactive: EVENTS.emit("error", stage="detect", reason="not_found", platform=plat, message=f"{system} keeps its BIOS files on its SD card, and no " @@ -9934,7 +10134,8 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True print(f" {system} keeps its BIOS files {where}, not on this machine. " + ("" if setup.known() else f"RetroBIOS cannot tell {_a(system)} card by its files. ") - + "Mount the card, then type its top folder" + (" or its number:" if cards else ".")) + + ("Type its number or its top folder:" if cards + else "Mount the card, then type its top folder.")) for index, mount in enumerate(cards, 1): print(f" {index}) {card_line(mount)}") numbers = "1" if len(cards) == 1 else f"1 to {len(cards)}" @@ -9947,13 +10148,16 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True print(f" Type {numbers}, or a folder.") continue root = cards[int(raw) - 1] if chosen else _typed_folder(raw) - choice = card_root_folder(system, plat, root) + choice = card_choice(system, plat, root) if choice.refused: - detail = f" {choice.detail}." if choice.detail else "" - print(f" {choice.refused}.{detail} Type the top folder of the {system} card.") + print(f" {_refusal_words(choice, setup)}") continue + # The card picked, echoed: the run went on to "RetroArch: 23 files" + # with no word of which card. + print(f" {_mount_name(root)}: {system} files go to {shown_path(choice.folder)}") if choice.note: print(f" {choice.note}") + _DETECTED_OS.setdefault((choice.plat, choice.folder), system) if _missing_dest(choice.folder, ask, "typed", again=True): return choice.folder @@ -10078,13 +10282,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: assumed_dests: set[Path] = set() named = args.platform or "" if args.platform: - args.platform = normalize_platform(args.platform) + args.platform = normalize_platform(args.platform, say=args.verbose) if args.dest: plat, args.dest = card_dest(named, args.platform or "", args.dest) args.platform = plat or args.platform if args.platform and args.dest: - if args.platform in CARD_PLATFORMS: - _check_card_root(args.platform, args.dest) platforms = [(args.platform, args.dest)] elif args.platform: print("Detecting platform...") @@ -10427,6 +10629,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: args.emulator, manifest, plat_name, files, omitted_files ) + # Before the menu: it offered 23 files, then 22 were checked, the + # one read from an unknown save directory left out. + files, unplaced = place_save_entries( + files, bios_path, manifest, args.save_dir, display_name(plat_name, manifest) + ) + if unplaced: + total_omitted += len(unplaced) + omitted_seen.extend(unplaced) + # What --core resolves against, for advice that names one. core_choices = set(available_choices(files, omitted_files)["cores"]) selection = _requested_narrowing(args, plat_name, files, omitted_files, manifest) @@ -10445,8 +10656,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) selection = _prompt_custom_selection( plat_name, files, omitted_files, installed, - display_name(plat_name, manifest), args.dry_run, - manifest.get("data_directories") or [], + detected_os(plat_name, bios_path) or display_name(plat_name, manifest), + args.dry_run, manifest.get("data_directories") or [], bios_path, ) if selection is None: smaller = (" Nothing downloaded. To install less, run the same command " @@ -10518,13 +10729,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: f"under {library}") bios_path = library - files, unplaced = place_save_entries( - files, bios_path, manifest, args.save_dir, display_name(plat_name, manifest) - ) - if unplaced: - total_omitted += len(unplaced) - omitted_seen.extend(unplaced) - print("\nChecking existing files...") to_download, up_to_date, mismatched = check_local( files, bios_path, jobs=args.jobs, term=term @@ -10598,15 +10802,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # before the hundreds of files it holds. for line in split_pack_lines(bios_path): print(line) - print(f"\n {_plural(len(to_download), 'file')} to download:") + offline = _offline_only() + print(f"\n {_plural(len(to_download), 'file')} " + + ("not in place:" if offline else "to download:")) for line in check_lines(missing, mismatched, files, manifest, bios_path, args.verbose, up_to_date, bios_index, copies): print(line) for line in unreadable_lines(unread, args.verbose): print(line) - print(" Run the same command without --check to fetch them; " - "a file with a wrong hash is replaced, and the one in place " - f"kept in {BACKUP_DIR} beside the BIOS folder.") + print(_check_fetch_line(len(to_download) - len(copies), "the BIOS folder")) check_pending += len(to_download) else: print("\n All files up to date.") @@ -10649,9 +10853,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if interactive and not args.yes and _offline_only() and fetched: # The screen offers no Install for these: the question asked # to download 6.6 GB that --offline never fetches. + # The remedy once, in the summary: it was printed twice in a row. print(f"\n {_plural(len(fetched), 'file')} " f"{'needs' if len(fetched) == 1 else 'need'} the internet, which " - f"--offline does not use. {_offline_remedy()}") + "--offline does not use.") # Not done: the summary counts them as missing. files_pending += len(fetched) total_offline += len(fetched) @@ -11160,7 +11365,7 @@ def _short_path(path: Path | None, home: Path) -> str: if home_text not in ("", "/", ".") and (text == home_text or text.startswith(home_text + os.sep)): return "~" + text[len(home_text):] - return text + return shown_path(text) def _folded(text: str) -> str: @@ -11213,6 +11418,9 @@ class TuiState: # (system, BIOS folder below the card root) when the setup asked for # runs from an SD card: the folder screen offers the mounted cards. self.card_bound: tuple[str, str] | None = None + # What card_choice said of the card chosen (a stick nothing names + # taken for KNULLI), kept from Create the folder? to the plan. + self.card_note: str = "" # The mounted cards of that system, as last looked for. self.cards: list | None = None # (width and text, lines) of the last pager wrapped. @@ -11518,6 +11726,7 @@ def _app_offer(state: TuiState) -> bool: return (state.screen == "result" and state.app is None and not state.app_running and _tui_installed(state) and _tui_app_ready(state)) + class TuiRow(NamedTuple): """One row a screen lists: what Enter does on it, and what it shows.""" @@ -11577,6 +11786,10 @@ def _filter_row(state: TuiState) -> TuiRow: how = {"muxterm": "Select shows a keyboard", "gptokeyb": "with a keyboard", "gptokeyb_muxterm": "with a keyboard", "evmapy": "with a keyboard", "miyoo": "with a keyboard"}.get(state.pad, "/ to type") + if state.screen == "platforms": + # A device name finds its setups: said where it is typed. + return TuiRow("filter", "Filter: setup or device (/)" if how == "/ to type" + else f"Filter ({how})", style="dim") return TuiRow("filter", f"Filter ({how})", style="dim") @@ -11881,7 +12094,15 @@ def _back(state: TuiState) -> list[tuple]: # Enter on None, then Esc: the plan kept its headline over an # Install the run refused. state.ticked = set(state.ticked_before) + chose = state.screen == "systems" and state.ticked != (state.ticked_before or set()) state.stack.pop() + if chose and state.screen == "plan": + # The systems chosen, the next press is Install: Back left the focus + # on Choose systems. + rows = tui_rows(state) + state.focus["plan"] = next((index for index, row in enumerate(rows) + if row.action == "install"), + state.focus.get("plan", 0)) if state.screen == "checking" and not state.running: # Stay after Esc on a check started from the command line: a # Checking screen nothing feeds counted seconds forever. @@ -11960,6 +12181,7 @@ def _closed_status(state: TuiState, closed: tuple[Path, str]) -> str: def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: + state.card_note = "" closed = closed_folder(dest) if closed is not None: # Nothing can be checked or written there: the folder is asked @@ -12006,7 +12228,7 @@ def _card_folder(state: TuiState, folder: Path) -> CardChoice | None: return None if state.folder_kind == "found" and state.folder_default is not None: return None - return card_root_folder(bound[0], state.platform, folder) + return card_choice(bound[0], state.platform, folder) def _card_status(state: TuiState, choice: CardChoice, given: bool = False) -> None: @@ -12014,9 +12236,20 @@ def _card_status(state: TuiState, choice: CardChoice, given: bool = False) -> No 40-column screen shows whole, the folder and what was looked for on it after, shown where there is room and in the help. given is a folder --dest names, which no choice here can change.""" - then = ("--dest names it: choose another setup." if given - else "Choose the card's top folder.") - state.status = f"{choice.short or choice.refused + '.'} {then}" + if given: + then = "--dest names it: choose another setup." + short = choice.short or choice.refused + "." + elif choice.other: + # The top folder of a card, only another system's: "Choose the + # card's top folder" sent the user back to the same one. + bound = _tui_card_bound(state) + then = (f"Insert the {bound[0] if bound else 'right'} card, or go back and choose " + f"{choice.other}.") + short = f"That is {_a(choice.other)} card." + else: + then = "Choose the card's top folder." + short = choice.short or choice.refused + "." + state.status = f"{short} {then}" state.status_more = f"{choice.refused}." + (f" {choice.detail}." if choice.detail else "") @@ -12043,13 +12276,22 @@ def _choose_card(state: TuiState, folder: Path) -> list[tuple]: return [] state.folder_default, state.folder_kind = None, "usual" return _push(state, "folder") - card = card_system(folder) + return _take_card(state, choice, folder) + + +def _take_card(state: TuiState, choice: CardChoice, root: Path) -> list[tuple]: + """_choose for the folder card_choice gave root, its note kept on the + screens up to the plan: --dest, the setup list with --dest and a folder + picked here said nothing of a stick no file names, where line mode did.""" + card = card_system(root) if card and card[1] == choice.folder: # Titled as the same card found by itself: "Onion (RetroArch files)". _DETECTED_OS.setdefault((choice.plat, choice.folder), card[2]) + elif state.card_bound: + # A card no file names, chosen as ArkOS's: titled ArkOS, not RetroArch. + _DETECTED_OS.setdefault((choice.plat, choice.folder), state.card_bound[0]) effects = _choose(state, choice.plat, choice.folder) - if choice.note and not state.status: - state.status = choice.note + state.card_note = choice.note return effects @@ -12189,10 +12431,16 @@ def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = No and row.label == f"{row.value.name}/" and not _os_folder(row.value, folder)), 0) if not focus and not state.status: - state.status = ("This is your home folder: no setup reads its BIOS files " - "here. Go back to type a path." if folder == state.home - else "This is the root of a drive: no setup reads its BIOS " - "files here. Go back to type a path.") + state.status = ("No setup reads BIOS files in your home folder." if + folder == state.home else + "No setup reads BIOS files at a drive's root.") + if (folder is not None and state.browse_for == "from" and came_from is None + and folder in (state.home, Path(folder.anchor))): + # The old card is most often a mounted one: the docs say the copy + # browser opens on them, and A on Use this folder read all of HOME. + mounts = _mounted_cards() + focus = next((index for index, row in enumerate(state.browse_rows) + if row.action == "dir" and row.value in mounts), focus) for index, row in enumerate(state.browse_rows): if came_from is not None and row.action == "dir" and row.value == came_from: focus = index @@ -12231,6 +12479,11 @@ def _tui_folder_facts(state: TuiState) -> dict: def _activate(state: TuiState, row: TuiRow) -> list[tuple]: screen, action = state.screen, row.action if action == "filter": + only = _only_match(state) + if only is not None: + # One setup matches: A chooses it, a second press found nothing + # more to do. + return _activate(state, only) state.editing = screen return [] if screen == "setup": @@ -12253,7 +12506,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: _card_status(state, choice, given=True) state.platform, state.card_bound = "", None return [] - return _choose(state, choice.plat, choice.folder) + return _take_card(state, choice, state.dest_given) if state.card_bound: # On the device itself, the folder detection found there: a # KNULLI was asked for its own card. A card in a reader is @@ -12290,11 +12543,18 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: # A card is asked for: the drives come first, and the home # folder's "go back to type a path" sent the user away from # the cards listed under it. - drives = [index for index, item in enumerate(state.browse_rows) - if item.action == "dir" and item.value in _mounted_cards()] + # Its own cards first: the muOS card listed first was focused + # for Onion. + bound = _tui_card_bound(state) + own = mounted_cards_of(bound[0], state.platform) + mounts = _mounted_cards() + drives = ([index for index, item in enumerate(state.browse_rows) + if item.action == "dir" and item.value in own] + or [index for index, item in enumerate(state.browse_rows) + if item.action == "dir" and item.value in mounts]) if drives: state.focus["browse"] = drives[0] - state.status = "Choose the card below, or open the folder that holds it." + state.status = f"Choose the {bound[0]} card below." return [] state.path_text = str(state.folder_default or "") state.path_selected = bool(state.path_text) @@ -12429,10 +12689,10 @@ _SETUP_DEVICES: dict[str, tuple[str, ...]] = { "RGB10"), "muOS": ("RG35XX H", "RG35XX Plus", "RG35XX SP", "RG35XX 2024", "RG34XX H", "RG40XX H", "RG40XX V", "RG28XX H", "RGCUBEXX", "TrimUI Brick", "TrimUI Smart Pro"), - "Onion": ("Miyoo Mini", "Miyoo Mini+"), + "Onion": ("Miyoo Mini", "Miyoo Mini Plus"), "spruceOS": ("Miyoo A30", "Miyoo Flip", "Miyoo Mini", "Miyoo Mini Plus", "Miyoo Mini Flip", "TrimUI Smart Pro", "TrimUI Brick", - "Anbernic RG35XX Plus", "RG40XX H", "Powkiddy RGB30"), + "RG35XX Plus", "RG40XX H", "Powkiddy RGB30"), "Allium": ("Miyoo Mini", "Miyoo Mini Plus", "Miyoo Mini Flip"), "ArchR": ("R36S", "R33S", "R36S Soysauce", "Odroid Go Advance", "Odroid Go Super", "RG351V", "RG351M", "GameForce Chi", "MagicX XU10", "Powkiddy RGB10", @@ -12490,7 +12750,10 @@ def _device_needle(needle: str) -> str: """The filter text with a model's short form spelled out: owners write RP5 for the Retroid Pocket 5, as the store names it.""" short = re.fullmatch(r"\s*rp\s*(\d+)\s*", needle, re.IGNORECASE) - return f"Retroid Pocket {short.group(1)}" if short else needle + if short: + return f"Retroid Pocket {short.group(1)}" + # One spelling in the lists, Miyoo Mini Plus: the box writes Mini+. + return re.sub(r"\s*\+", " Plus", needle) def _setup_matches(plat: str, systems: list[str], needle: str) -> list[str] | None: @@ -12538,13 +12801,18 @@ def _platform_rows(state: TuiState) -> list[TuiRow]: Every card system is a row of its own that asks for its card, with or without the filter, from card_setups(): RetroArch's row listed muOS, JELOS and fourteen more, and A on it led an R36S owner to this - computer's RetroArch folder. RetroArch's row is this computer's. + computer's RetroArch folder. RetroArch's row is this computer's. A card + row names the devices that run it, the name a handheld's owner knows: + "Onion, SD card" beside "RetroArch files" said nothing to a Miyoo Mini + owner. With nothing found here the card rows come right after RetroArch + (_cards_first). """ needle = state.filters.get("platforms", "") setups = card_setups() cards = {setup.name for setup in setups} systems = _systems_reading() - rows = [_filter_row(state)] + desktop: list[TuiRow] = [] + handheld: list[tuple[str, TuiRow]] = [] for plat in AVAILABLE_PLATFORMS: others = [name for name in systems.get(plat, []) if name not in cards] named = _setup_matches(plat, others, needle) @@ -12554,12 +12822,14 @@ def _platform_rows(state: TuiState) -> list[TuiRow]: label = display_name(plat) if setup and _device_setup(state, plat, setup.name) is None: label += ", SD card" - # The systems the filter named, else every system its list stands - # for that is no card: AmberELEC's PAN4ELEC. - shown = named or others - rows.append(TuiRow("platform", label, ", ".join(shown), plat, - narrow=_comma_forms(shown))) - for setup in sorted(setups, key=lambda item: item.name.casefold()): + # The systems and devices the filter named, else a card's devices. + shown = named or (list(_SETUP_DEVICES.get(plat, ())) if setup else []) + row = TuiRow("platform", label, ", ".join(shown), plat, narrow=_comma_forms(shown)) + if setup: + handheld.append((label, row)) + else: + desktop.append(row) + for setup in setups: if setup.name == display_name(setup.plat): continue named = _system_matches(setup.name, needle) @@ -12568,15 +12838,35 @@ def _platform_rows(state: TuiState) -> list[TuiRow]: label = setup.name if _device_setup(state, setup.plat, setup.name) is None: label += ", SD card" - rows.append(TuiRow("cardsystem", label, ", ".join(named) - or f"{display_name(setup.plat)} files", (setup.plat, setup.name), - narrow=_comma_forms(named))) - return rows + shown = named or list(_SETUP_DEVICES.get(setup.name, ())) + handheld.append((label, TuiRow("cardsystem", label, ", ".join(shown) + or f"{display_name(setup.plat)} files", + (setup.plat, setup.name), + narrow=_comma_forms(shown)))) + handheld.sort(key=lambda item: item[0].casefold()) + cards_rows = [row for _label, row in handheld] + if _cards_first(state): + lead = [row for row in desktop if row.value == "retroarch"] + rest = [row for row in desktop if row.value != "retroarch"] + return [_filter_row(state), *lead, *cards_rows, *rest] + return [_filter_row(state), *desktop, *cards_rows] + + +def _cards_first(state: TuiState) -> bool: + """Whether the card rows follow RetroArch: nothing was found here, so + this is a computer a handheld's card is prepared on, or a console the + detection does not know. A pad cannot type in the filter, and Onion was + row 30 of 34: 29 presses of Down. Right after RetroArch it is row 19, + and four presses with L1/R1 at 40x12. A row that opened a list of the + cards cost one more press than the list it opened.""" + return not state.setups def _platform_focus(state: TuiState, plat: str) -> int: """The platform list's row for plat, below its filter row.""" - return 1 + AVAILABLE_PLATFORMS.index(plat) if plat in AVAILABLE_PLATFORMS else 1 + rows = _platform_rows(state) + return next((index for index, row in enumerate(rows) + if row.action == "platform" and row.value == plat), 1) def _option(state: TuiState, action: str, step: int) -> list[tuple]: @@ -12717,6 +13007,14 @@ def _edit_text(text: str, key: TuiKey, selected: bool = False) -> str | None: return None +def _only_match(state: TuiState) -> TuiRow | None: + """The one setup the filter leaves, when it leaves one.""" + if state.screen != "platforms" or not state.filters.get("platforms"): + return None + rows = [row for row in tui_rows(state) if row.action != "filter"] + return rows[0] if len(rows) == 1 else None + + def _filter_key(state: TuiState, key: TuiKey) -> list[tuple]: """Typing in a list's filter. It never ticks or unticks anything, and focus never lands on All or None: the next A or X there undid a choice.""" @@ -12732,6 +13030,9 @@ def _filter_key(state: TuiState, key: TuiKey) -> list[tuple]: state.filters[screen] = "" state.editing = "" rows = tui_rows(state) + only = _only_match(state) + if key.name == "enter" and only is not None: + return _activate(state, only) if key.name in ("enter", "down") and len(rows) > filter_row + 1: state.focus[screen] = filter_row + 1 else: @@ -13396,8 +13697,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: headline = (f"{_plural(data, 'data file')} to fetch{data_size}" if data else "Everything is in place") wrong = check["wrong_hash"] - offline_lines = (["Copy them from your old card or a folder, or run the installer " - "again with a pack (--from-pack) or without --offline."] + offline_lines = ([_offline_remedy([_last_name(str(folder)) + for folder in state.from_folders])] if offline and fetch else []) # The folder comes second: on a 40x12 screen it is what is left once # the rows are drawn, and nothing is written before it is seen. @@ -13436,7 +13737,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if wrong and not (offline and not copied): # With its subject: "They are replaced" sat lines under the count. # Not where nothing can be installed: nothing is replaced then. - lines.append(f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " + # Under the count it explains: last, a 40x12 plan cut it. + lines.insert(after_counts, f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " f"{'is' if wrong == 1 else 'are'} replaced, the old " f"{'one' if wrong == 1 else 'ones'} kept in a backup folder.") misplaced = _tui_misplaced(state) if check is state.check else [] @@ -13456,6 +13758,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: # the folder is named so root writes here. hint = Invocation() hint.os_type = state.os_type + hint.options = [("--offline", None)] if offline_requested() else [] lines.append("Or run it as root, this folder named: " + hint.command(state.platform, dest, elevated=True)) if check is state.check: @@ -13465,6 +13768,13 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if check is state.check: lines += _unplaced_lines(state.check_summary, state, "Choose it in Options, Save folder (--save-dir).") + if check is state.check and state.from_folders: + # A folder copied from that holds none of them: the plan came back + # the same, still saying to copy from a folder. + held = {Path(folder).absolute() for folder in elsewhere} + offline_lines = [f"{_last_name(str(folder))} holds none of these files." + for folder in state.from_folders + if Path(folder).absolute() not in held] + offline_lines # On Confirm the remedy comes right after the headline: at 40x12 it was # what got cut, Back left alone below it. at = 1 if check is state.dry else 2 @@ -13503,7 +13813,7 @@ def _unplaced_lines(summary: dict | None, state: TuiState, def _view_setup(state: TuiState, width: int, height: int) -> tuple: rows = tui_rows(state) focus = rows[min(state.focus.get("setup", 0), len(rows) - 1)] - after = ([f"BIOS folder: {state.setups[focus.value][1]}"] + after = ([f"BIOS folder: {shown_path(state.setups[focus.value][1])}"] if focus.action == "setup" else []) before = ["Found on this machine:" if state.setups else "Nothing was found here."] status = [state.status] if state.status else [] @@ -13525,8 +13835,13 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: if state.dest_given is None and not state.setups and len(state.stack) == 1: # The first screen when nothing was found: at 40x15 its 18 rows # left no line to say so, or what to do. - pinned.append("Nothing was found here: choose the setup that will use the " - "BIOS files.") + # 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("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.") before = [] needle = state.filters.get("platforms", "") after = [f"No setup matches '{needle}'."] if len(rows) == 1 and needle else [] @@ -13584,7 +13899,13 @@ def _status_after(state: TuiState) -> list[str]: """The status under a screen's rows, its full form after it.""" if not state.status: return [] - return [state.status] + ([state.status_more] if state.status_more else []) + if not state.status_more: + return [state.status] + # Said once: the full form repeated the short one under it. The help + # holds it, and a pad, with no button for the help, reads it here. + if state.pad in ("both", "keys"): + return [f"{state.status} ? says more."] + return [state.status, state.status_more] def _view_browse(state: TuiState, width: int, height: int) -> tuple: @@ -13625,11 +13946,13 @@ def _view_path(state: TuiState, width: int, height: int) -> tuple: def _view_create(state: TuiState, width: int, height: int) -> tuple: - before = [f"{state.dest} does not exist.", + before = [f"{shown_path(state.dest)} does not exist.", f"The nearest folder that does: {state.facts.get('nearest', '')}"] if state.facts.get("risky"): before.append("Every folder of this path would be made: check its start before " "creating it.") + if state.card_note: + before.append(state.card_note) return ("Create the folder?", _compose(state, width, height, before, tui_rows(state)), ("A/Enter Choose", "B/Esc Back")) @@ -13684,12 +14007,23 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: f"{format_size(sum(e['size'] for e in chosen))}, for " f"{len(state.ticked)} of {len(state.systems)} systems", "title") lines[2:2] = choice + if state.card_note and state.card_note != state.status: + # Under the folder it is about: a stick no file names, taken for + # KNULLI, read as a KNULLI card. + lines.insert(2, state.card_note) if state.status: lines.append(state.status) rows = tui_rows(state) if width < 54: rows = [row._replace(label="Copy from your old card or a folder") if row.action == "fromcard" else row for row in rows] + # One line: in two, a 40x12 plan dropped it, "1 wrong copy" left + # alone to say a file would be replaced. + wrong = check.get("wrong_hash", 0) + long_form = f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " + lines = [_styled(f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}: backed up, " + "replaced.") if str(line).startswith(long_form) else line + for line in lines] # A muOS card gets RetroArch's files: the title says both. system = detected_os(state.platform, state.dest) if state.dest is not None else "" name = display_name(state.platform, state.manifest) @@ -13857,7 +14191,11 @@ def _view_command(state: TuiState, width: int, height: int) -> tuple: def _view_confirm(state: TuiState, width: int, height: int) -> tuple: if state.running or state.dry is None: - lines: list = ["Counting the files for these choices..."] + # Nothing to install yet: "A/Enter Install" sat over a press that + # did nothing, then a screen with Back alone. + return ("Confirm", _compose(state, width, height, + ["Counting the files for these choices..."], []), + ("B/Esc Back", "? Help")) else: # Why this screen: the numbers above are counted again for the # systems and options chosen, the last look before anything is @@ -14020,7 +14358,8 @@ def _unavailable_lines(state: TuiState) -> list[str]: if games: lines.append(f"Game data not available here, read by " f"{'its game' if len(games) == 1 else 'their games'}: " - f"{named(games)}. Copy it from your own copy of the game.") + f"{named(games)}. Copy {'it' if len(games) == 1 else 'them'} from " + f"your own copy of the game.") return lines @@ -14031,13 +14370,24 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: # reported finished is what is installed. installed = summary.get("installed", sum(1 for _dest, ok, _why in state.finished if ok)) failed = summary.get("failed") or [] + # Left out by --offline: missing, never failed, as line mode says it. + left = [failure for failure in failed if _offline_failure(failure["reason"])] + failed = [failure for failure in failed if not _offline_failure(failure["reason"])] up_to_date = summary.get("up_to_date", 0) total = (state.download or {}).get("files", 0) space = "" if summary else _space_words(state) + errors = f", {len(failed)} failed" if failed else "" if summary.get("interrupted") or state.code == 130: title = "Stopped" head = (f"Stopped: {installed} of {total} installed, run again to finish." if total else "Stopped. Run again to carry on: what is done stays done.") + elif left and installed: + title = "Finished with files missing" + head = f"{installed} installed, {len(left)} not fetched (--offline){errors}." + elif left: + title = "Nothing installed" + head = (f"Nothing was installed: {_plural(len(left), 'file')} not fetched " + f"(--offline){errors}.") elif failed and installed: title = "Finished with errors" head = f"{installed} installed, {len(failed)} failed." @@ -14063,14 +14413,17 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: title = "Done" head = "Everything is in place." lines = [head] - sources = _tui_sources(state, installed, len(failed)) + if left: + lines.append(_offline_remedy([_short_path(Path(folder), state.home) + for folder in state.from_folders])) + sources = _tui_sources(state, installed, len(failed) + len(left)) if sources: lines.append(sources) ok = {dest for dest, good, _why in state.finished if good} if any(reason and dest in ok for dest, (_folder, reason) in state.local_dests.items()): # A fix copies: the misplaced files are the user's, never moved. lines.append("Your copies in the wrong place were left as they are.") - lines.append(f"BIOS folder: {state.dest}") + lines.append(f"BIOS folder: {shown_path(state.dest)}") if installed + up_to_date: lines += _saves_lines(state, state.done_saves) if space: @@ -14240,13 +14593,19 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: ok = names["ok"] if state.pad == "miyoo" and screen in _CHECKLISTS: ok = names["enter"] - keys.append((ok, primary.split(" ", 1)[-1])) + if primary.startswith("A/"): + # A bar that offers no A (Confirm while it counts) names none here. + keys.append((ok, primary.split(" ", 1)[-1])) if screen in _CHECKLISTS: keys.append((names["tick"], "Tick, untick")) if screen == "systems": keys.append(("a, n", "All, none")) - if screen in ("platforms", "systems", "cores"): - keys.append(("/", "Filter (Esc clears)")) + if screen == "platforms": + keys.append(("/", "Filter by setup or device name")) + elif screen in ("systems", "cores"): + keys.append(("/", "Filter")) + if screen in ("platforms", "systems", "cores") and state.filters.get(screen): + keys.append((names["esc"], "Clear the filter")) if screen == "regions": keys.append(("Left/Right", "Move a region up or down the order")) if screen == "options": @@ -14272,6 +14631,8 @@ def _primary(state: TuiState) -> str | None: return None row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] 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: @@ -14285,17 +14646,23 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple: column = min(22, max(cell_width(key) for key, _what in keys) + 2) # Two spaces at least: a key as wide as the column touched its text # ("Select Keyboard on or off" on muxterm). - lines = [f"{_pad(key, column)}{what}" if cell_width(key) <= column - 2 - else f"{key} {what}" for key, what in keys] + lines = [] + for key, what in keys: + if cell_width(key) > column - 2: + lines.append(f"{key} {what}") + continue + # Wrapped under its own column: wrapped by the screen, a long text + # ran back to the left edge ("/ Filter (Esc clears)" at 40x12). + words = textwrap.wrap(what, max(8, width - column)) or [""] + lines.append(f"{_pad(key, column)}{words[0]}") + lines += [" " * column + word for word in words[1:]] if state.screen in ("setup", "plan", "browse", "folder") and state.dest is not None: - lines += ["", f"BIOS folder: {state.dest}"] + lines += ["", f"BIOS folder: {shown_path(state.dest)}"] if state.status_more: lines += ["", state.status_more] return ("Keys", _compose(state, width, height, lines, []), ("Any key closes this help",)) - - def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: """The screen as rows lines of at most cols cells, from the state alone.""" if cols < TUI_MIN_COLS or rows < TUI_MIN_ROWS: @@ -14319,6 +14686,7 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: lines.append(_styled(fit(" " + _hint(width, *hint), cols - 1), "dim")) return lines + def _setup_label(plat: str, path: Path) -> str: system = detected_os(plat, path) name = display_name(plat) @@ -14394,7 +14762,7 @@ def tui_start( if args.manifest is not None: state.manifest_path = Path(args.manifest).absolute() state.manifest_given = True - plat = normalize_platform(args.platform) if args.platform else "" + plat = normalize_platform(args.platform, say=False) if args.platform else "" if detect: captured = io.StringIO() with contextlib.redirect_stdout(captured), contextlib.redirect_stderr(captured): @@ -14426,22 +14794,23 @@ def tui_start( state.stack = [] state.platform, state.card_bound = plat, card_bound(args.platform, plat) dest = Path(args.dest) - if state.card_bound is None: - return state, _choose(state, plat, dest) # Read as the line mode reads --dest: an Onion card's root gets its - # BIOS folder, another system's card is refused. + # BIOS folder, another system's card is refused, a list named for + # another system's card is said. choice = card_choice(args.platform, plat, dest) if choice.refused: _card_status(state, choice) return state, _open_folder_screen(state, None) - return state, _choose(state, choice.plat, choice.folder) + return state, _take_card(state, choice, dest) if plat: bound = card_bound(args.platform, plat) # A system that runs from its card is its card or the device, never # this machine's own folder for the same list. matched = [setup for setup in state.setups if setup[0] == plat and (bound is None or detected_os(setup[0], setup[1]) == bound[0])] - if len(matched) == 1: + # Named by the file list alone, a lone card found is asked about + # first, as without the list: its folder was the first screen. + if len(matched) == 1 and not getattr(args, "platform_from_list", False): state.stack = [] return state, _choose(state, plat, matched[0][1]) if matched: diff --git a/install.sh b/install.sh index d7fabfa68..25dc09b43 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab" +DEFAULT_INSTALL_SHA256="2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index e1c34015f..419af635b 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": 673359, - "sha256": "2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab", + "size": 692187, + "sha256": "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 725908, - "sha256": "da7fa84e69575d43fbf529c053f43f8f665963c19e25ed44923183a072ed919d" + "size": 744736, + "sha256": "17daef144acc01204912de201bbb6bbf8996d1a866a37074e7cbaffe879e911b" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 673359, - "sha256": "2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab", + "size": 692187, + "sha256": "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 673359, - "sha256": "2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab", + "size": 692187, + "sha256": "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9", "executable": false }, { diff --git a/platforms/_guides.yml b/platforms/_guides.yml index 2730cc33f..3b5706151 100644 --- a/platforms/_guides.yml +++ b/platforms/_guides.yml @@ -160,13 +160,13 @@ handheld_guide: - name: Onion folder: '`BIOS` at the root of the card' folder_source: https://onionui.github.io/docs/emulators - devices: [Miyoo Mini, Miyoo Mini+] + devices: [Miyoo Mini, Miyoo Mini Plus] devices_source: https://github.com/OnionUI/Onion - name: spruceOS folder: '`BIOS` at the root of the card' folder_source: https://github.com/spruceUI/spruceOS/wiki/10.-Adding-BIOS devices: [Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, - TrimUI Smart Pro, TrimUI Brick, Anbernic RG35XX Plus, RG40XX H, Powkiddy RGB30] + TrimUI Smart Pro, TrimUI Brick, RG35XX Plus, RG40XX H, Powkiddy RGB30] devices_source: https://github.com/spruceUI/spruceOS/wiki/Supported-Devices - name: Allium folder: '`BIOS` at the root of the card' diff --git a/tests/fixtures/tui/plan-120x40.txt b/tests/fixtures/tui/plan-120x40.txt index 25e7bbce7..3e51059e0 100644 --- a/tests/fixtures/tui/plan-120x40.txt +++ b/tests/fixtures/tui/plan-120x40.txt @@ -3,8 +3,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/plan-40x12.txt b/tests/fixtures/tui/plan-40x12.txt index 80132bf29..c4c967a9d 100644 --- a/tests/fixtures/tui/plan-40x12.txt +++ b/tests/fixtures/tui/plan-40x12.txt @@ -3,7 +3,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder diff --git a/tests/fixtures/tui/plan-40x15.txt b/tests/fixtures/tui/plan-40x15.txt index 5f7240348..fff88bc27 100644 --- a/tests/fixtures/tui/plan-40x15.txt +++ b/tests/fixtures/tui/plan-40x15.txt @@ -3,13 +3,13 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-80x24.txt b/tests/fixtures/tui/plan-80x24.txt index 6b26353e0..5a583340e 100644 --- a/tests/fixtures/tui/plan-80x24.txt +++ b/tests/fixtures/tui/plan-80x24.txt @@ -3,8 +3,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/platforms-120x40.txt b/tests/fixtures/tui/platforms-120x40.txt index 066b357cd..344f372e9 100644 --- a/tests/fixtures/tui/platforms-120x40.txt +++ b/tests/fixtures/tui/platforms-120x40.txt @@ -2,7 +2,7 @@ Choose the setup that will use the BIOS files. - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -10,31 +10,31 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 Anbernic stock firmware, SD card RetroArch files - ArchR, SD card RetroArch files - ArkOS, SD card RetroArch files - CrossMix-OS, SD card RetroArch files - dArkOS, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, Odroid Go Super, RG351V, +6 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go JELOS, SD card RetroArch files - KOLOS, SD card RetroArch files - Koriki, SD card RetroArch files - MiyooCFW, SD card RetroArch files - muOS, SD card RetroArch files - Onion, SD card RetroArch files - plumOS, SD card RetroArch files - RetroOZ, SD card RetroArch files - spruceOS, SD card RetroArch files - UnofficialOS, SD card RetroArch files + KNULLI, SD card Anbernic RG-Arc-S, RG28XX, RG34XX, RG35XX Original, RG35XX Plus, RG35XX H, +16 + KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, Powkiddy RK2023, RGB30, RGB10 Max 3 + Koriki, SD card Miyoo Mini + MinUI, SD card Anbernic, Miyoo, TrimUI + MiyooCFW, SD card BittBoy, Pocket Go, PowKiddy Q90, PowKiddy V90, PowKiddy Q20 Mini, Sup M3, +1 + muOS, SD card RG35XX H, RG35XX Plus, RG35XX SP, RG35XX 2024, RG34XX H, RG40XX H, RG40XX V, +4 + MyMinUI, SD card Anbernic, Miyoo + NextUI, SD card TrimUI Brick, TrimUI Smart Pro + Onion, SD card Miyoo Mini, Miyoo Mini Plus + plumOS, SD card MagicX XU20 V32 + RetroOZ, SD card OGS, OGA, RGB10Max, RGB10Max2 + ROCKNIX, SD card Anbernic RG351, RG353, RG503, RG552, RG 55G1, RG ARC, RG35XX 2024, +38 + spruceOS, SD card Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, +5 + UnofficialOS, SD card R35S, R36S, RG351M, RG351P, RG351V, RG353M, RG353P, RG353V, RG503, +5 A/Enter Choose B/Esc Back / Filter ? Help diff --git a/tests/fixtures/tui/platforms-40x12.txt b/tests/fixtures/tui/platforms-40x12.txt index 08d03c876..715542018 100644 --- a/tests/fixtures/tui/platforms-40x12.txt +++ b/tests/fixtures/tui/platforms-40x12.txt @@ -1,6 +1,6 @@ RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox diff --git a/tests/fixtures/tui/platforms-40x15.txt b/tests/fixtures/tui/platforms-40x15.txt index f41c417a0..d0ebd799c 100644 --- a/tests/fixtures/tui/platforms-40x15.txt +++ b/tests/fixtures/tui/platforms-40x15.txt @@ -1,6 +1,6 @@ RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -8,8 +8,8 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose B Back / Filter ? Help diff --git a/tests/fixtures/tui/platforms-80x24.txt b/tests/fixtures/tui/platforms-80x24.txt index 1f0f566b1..eb0d2967b 100644 --- a/tests/fixtures/tui/platforms-80x24.txt +++ b/tests/fixtures/tui/platforms-80x24.txt @@ -1,6 +1,6 @@ RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -8,17 +8,17 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A/Enter Choose B/Esc Back / Filter ? Help diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 02b389947..d64b120b2 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose B Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose B Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install B Back Y Options === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 9d7abb3ac..9ca9597f3 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose B Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose B Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install B Back Y Options === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 9d7abb3ac..9ca9597f3 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose B Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose B Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install B Back Y Options === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index c47fe74ab..40d31955f 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -55,7 +55,7 @@ === platforms 40x12 RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -68,7 +68,7 @@ === platforms 40x15 RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -76,15 +76,15 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 Enter Choose Esc Back / Filter === platforms 80x24 RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 Enter Choose Esc Back / Filter ? Help === folder 40x12 @@ -382,7 +382,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -395,15 +395,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + Enter Install Esc Back o Options === plan 80x24 RetroBIOS: RetroArch @@ -411,8 +411,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 5c12696fa..8356becf5 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose R2 Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose R2 Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install R2 Back L2 Options === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 8ac8657a4..c29516cdb 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 Start Choose B Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 Start Choose B Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + Start Install B Back === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/test_install.py b/tests/test_install.py index c31218d2f..a51b6825f 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4249,7 +4249,7 @@ class DestAloneFollowsWhatIsFoundHere(unittest.TestCase): plat, folder = install._platform_for_dest("linux", root, True) self.assertEqual((plat, folder), ("retroarch", root)) self.assertIn(f"{root} is not an Onion SD card", out.getvalue()) - self.assertIn("Onion, SD card (RetroArch files)", out.getvalue()) + self.assertIn("Onion, SD card (Miyoo Mini, Miyoo Mini Plus)", out.getvalue()) def test_nothing_found_is_retroarch_said_aloud(self): plat, out = self._pick([]) diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index 459e8218d..74dcff817 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -663,7 +663,7 @@ class OfflineMakesNoRequest(Scratch): self.assertEqual((self.dest / "scph5501.bin").read_bytes(), US) self.assertFalse((self.dest / "gba_bios.bin").exists()) self.assertIn("no network request", proc.stdout + proc.stderr) - self.assertIn("run without --offline", proc.stdout + proc.stderr) + self.assertIn("without --offline", proc.stdout + proc.stderr) self.assertNotIn("trying again", proc.stderr) def test_modes_that_need_the_network_refuse_it(self): @@ -843,5 +843,98 @@ class OfflineLeavesFilesMissing(Scratch): self.assertNotIn("no longer in the source", proc.stdout) +class OneOfflineVocabulary(Scratch): + """code_audit_14 P3-1 to P3-4, tui_audit_7 P3-4: --offline said one + thing at a terminal and another without questions, on the screen and + under --check.""" + + def old(self) -> Path: + old = self.root / "old" + old.mkdir() + (old / "scph5501.bin").write_bytes(US) + return old + + def test_a_run_without_questions_says_missing_not_failed(self): + """P3-2: "Finished with errors ... 3 files failed" and "Run the + same command again to retry only those".""" + proc = self.run_installer("--offline", "--manifest", str(self.list), + "--from", str(self.old()), "--no-input") + out = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, 1, out) + self.assertIn("Finished with files missing. 1 file installed, 3 not fetched " + "(--offline).", out) + self.assertIn(f"They are not in {self.root / 'old'}", out) + for said in ("Finished with errors", "files failed", "retry", + "copy them from your old card"): + self.assertNotIn(said, out) + + def test_nothing_installed_is_said_so(self): + """P3-3: "Finished with files missing. 0 files installed".""" + failures = [("a.bin", install.NOT_WRITABLE), ("b.bin", install.NOT_WRITABLE)] + with contextlib.redirect_stdout(io.StringIO()) as out, \ + self.assertRaises(SystemExit) as stop: + install._report_outcome([self.dest], 0, 0, 2, 0, False, failures=failures, + offline=20) + self.assertEqual(stop.exception.code, 2) + self.assertIn("Nothing installed: 20 files not fetched (--offline), 2 failed.", + out.getvalue()) + self.assertNotIn("0 files installed", out.getvalue()) + + def test_the_command_suggested_keeps_the_offline_setting(self): + """P3-3: sudo does not pass RETROBIOS_INSTALL_OFFLINE on.""" + import test_install_tui as tui + + args = tui._args(platform="retroarch", declared_only=False, offline=False) + hint = install.Invocation() + with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + hint.record(args) + self.assertIn("--offline", hint.command("retroarch", self.dest, elevated=True)) + + def test_check_offline_says_what_cannot_come(self): + """P3-4: "Run the same command without --check to fetch them".""" + proc = self.run_installer("--check", "--offline", "--manifest", str(self.list)) + out = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, 1, out) + self.assertNotIn("to fetch them", out) + self.assertIn("4 files need the internet, which --offline does not use.", out) + self.assertIn("Copy them from your old card or a folder (--from)", out) + + def test_the_screen_says_missing_not_failed(self): + """P3-2 on the full screen: "Finished with errors, 20 failed".""" + import test_install_tui as tui + + reason = f"not fetched: {install.OFFLINE_WORDS}" + state = tui._installed_state(self.dest, 1, [{"dest": f"f{n}.bin", "reason": reason} + for n in range(20)]) + state.summary["installed"] = 2 + state.from_folders = [self.root / "from1"] + title, lines = install.tui_outcome(state) + self.assertEqual(title, "Finished with files missing") + text = " ".join(lines) + self.assertIn("2 installed, 20 not fetched (--offline).", text) + self.assertIn("They are not in", text) + self.assertNotIn("failed", text) + state.summary["installed"] = 0 + self.assertEqual(install.tui_outcome(state)[0], "Nothing installed") + + def test_the_line_menu_copies_from_a_folder(self): + """tui_audit_7 P3-4: no way to a folder, and an install of nothing + offered.""" + old = self.old() + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + answers = iter(["f", str(old), ""]) + with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + unittest.mock.patch.object(install, "_ask", lambda _prompt: next(answers)), \ + contextlib.redirect_stdout(io.StringIO()) as out: + self.assertIsNone(install._prompt_custom_selection("retroarch", [], [])) + text = out.getvalue() + self.assertIn("Enter) check the BIOS folder, nothing can be downloaded", text) + self.assertIn("f) copy from a folder or an old card", text) + self.assertIn(f"Enter) install what is found in {old}", text) + self.assertEqual([index.label for index in install.LOCAL.folders], [str(old)]) + self.assertNotIn("install everything", text) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index cd337436a..a714fd8e5 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -594,7 +594,8 @@ class Flow(unittest.TestCase): self.assertEqual(install.tui_rows(state)[state.focus["platforms"]].value, "retroarch") press(state, "/", "b", "a", "t", "o") self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["batocera"]) - press(state, "enter", "enter") + # tui_audit_7 P3-9.2: the one match is chosen with the first Enter. + press(state, "enter") self.assertEqual(state.screen, "folder") self.assertEqual(state.platform, "batocera") state.focus["folder"] = 2 @@ -2194,9 +2195,13 @@ class AuditRoundThree(unittest.TestCase): "MUOS at the root of its card.", proc.stderr) self.assertIn("--dest /MUOS/bios", proc.stderr) self.assertEqual(list(root.iterdir()), [root / "roms"]) - # Its BIOS folder named outright is taken as given. - self.assertEqual(install.card_dest("muos", "retroarch", root / "MUOS" / "bios"), - ("retroarch", root / "MUOS" / "bios")) + # Its BIOS folder named outright is its card's, checked as the card + # is: on a folder with no MUOS it is refused, as typed it was. + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + install.card_dest("muos", "retroarch", root / "MUOS" / "bios") + card = self.card({"MUOS/bios/": ""}) + self.assertEqual(install.card_dest("muos", "retroarch", card / "MUOS" / "bios"), + ("retroarch", card / "MUOS" / "bios")) # On the device, the folder muOS binds it to. bound = Path("/run/muos/storage/bios") self.assertEqual(install.card_dest("muos", "retroarch", bound), ("retroarch", bound)) @@ -2212,9 +2217,10 @@ class AuditRoundThree(unittest.TestCase): with self.assertRaises(SystemExit), \ contextlib.redirect_stderr(io.StringIO()) as err: install.card_dest("muos", "retroarch", stray) - self.assertIn("is not a muOS SD card", err.getvalue()) - self.assertEqual(install.card_dest("muos", "retroarch", root / "muos" / "BIOS"), - ("retroarch", root / "muos" / "BIOS")) + self.assertIn("does not exist", err.getvalue()) + card = self.card({"MUOS/bios/": ""}) + self.assertEqual(install.card_dest("muos", "retroarch", card / "muos" / "BIOS"), + ("retroarch", card / "muos" / "BIOS")) def test_p1_1_a_card_two_systems_claim_says_so(self): root = self.card({"MUOS/": "", "spruce/": ""}) @@ -3042,8 +3048,9 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): # without a word. install._browse_to(state, muos) press(state, "enter") - self.assertEqual(state.status, "That is a muOS card, not an Onion card. Choose " - "the card's top folder.") + # tui_audit_7 P3-1: it is a card's top folder, of another card. + self.assertEqual(state.status, "That is a muOS card. Insert the Onion card, or " + "go back and choose muOS.") self.assertIsNone(state.dest) def test_a_typed_card_root_is_checked_on_the_path_screen(self): @@ -3614,7 +3621,9 @@ class LineModeOffline(unittest.TestCase): out = term.raw.decode("utf-8", "replace") self.assertIn("--offline: only the files found on this machine are installed, and no " "--from names a folder to copy them from.", out) - self.assertIn("Enter) install what is found on this machine", out) + # tui_audit_7 P3-4: nothing to copy from, nothing to install. + self.assertIn("Enter) check the BIOS folder, nothing can be downloaded", out) + self.assertIn("f) copy from a folder or an old card", out) self.assertIn("5 files need the internet, which --offline does not use.", out) self.assertNotIn("Downloading", out) self.assertNotIn("failed", out.lower()) @@ -3945,10 +3954,9 @@ class AGoesIntoASubfolder(unittest.TestCase): with mock.patch.object(install, "_mounted_cards", return_value=[self.card]): install._browse_to(self.state, self.home) self.assertEqual(self.focused().action, "use") - self.assertIn("This is your home folder", self.state.status) + self.assertIn("No setup reads BIOS files in your home folder", self.state.status) screen = " ".join(" ".join(install.tui_view(self.state, 80, 24)).split()) - self.assertIn("This is your home folder: no setup reads its BIOS files here.", - screen) + self.assertIn("No setup reads BIOS files in your home folder.", screen) def test_on_windows_the_other_drives_are_passed_over(self): """The card rows are drive roots there (E:\\): at D:\\ the first @@ -5222,8 +5230,9 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase): for index, setup in enumerate(self.setups): with self.subTest(system=setup.name): card = _card_from_rules(self.root / f"card{index}", setup) - want = (_setup_folder(setup, card) if setup.known() or not setup.below - else card) + # code_audit_14 P2-1: a card no file names gets its folder + # below the root with --dest too, as typed. + want = _setup_folder(setup, card) with contextlib.redirect_stdout(io.StringIO()): by_dest = install.card_dest(setup.name, setup.plat, card) self.assertEqual(by_dest, (setup.plat, want)) @@ -5244,7 +5253,7 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase): self.assertEqual(state.dest, want) # Its card chosen as a card's root: its BIOS folder below it. if setup.known(): - choice = install.card_root_folder(setup.name, setup.plat, card) + choice = install.card_choice(setup.name, setup.plat, card) self.assertEqual((choice.refused, choice.folder), ("", _setup_folder(setup, card))) @@ -5261,7 +5270,7 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase): and setup.plat in install.CARD_PLATFORMS): continue with self.subTest(system=setup.name, card=name): - choice = install.card_root_folder(setup.name, setup.plat, card) + choice = install.card_choice(setup.name, setup.plat, card) self.assertIn(f"is {install._a(name)} card, not " f"{install._a(setup.name)} card", choice.refused) self.assertTrue(install.card_choice(setup.name, setup.plat, @@ -5276,7 +5285,7 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase): blank.mkdir() for setup in self.setups: with self.subTest(system=setup.name): - choice = install.card_root_folder(setup.name, setup.plat, blank) + choice = install.card_choice(setup.name, setup.plat, blank) if setup.known() and not setup.fresh: self.assertIn(f"is not {install._a(setup.name)} SD card", choice.refused) else: @@ -5462,7 +5471,7 @@ class AuditSix(unittest.TestCase): rows = install.tui_rows(state) self.assertEqual(state.screen, "browse") self.assertEqual(rows[state.focus["browse"]].value, card) - self.assertEqual(state.status, "Choose the card below, or open the folder that holds it.") + self.assertEqual(state.status, "Choose the Onion card below.") def test_a_number_outside_the_list_is_said(self): """tui_audit_6 P3-1: 9 was read as a folder.""" @@ -5551,5 +5560,488 @@ class AuditSix(unittest.TestCase): self.assertIn(" +2 shared: counted once", install.tui_view(state, 40, 12)) +class AuditSevenOneCardReading(unittest.TestCase): + """code_audit_14 P2-1 to P3-12 and tui_audit_7 P2-5, P3-1: one reading + of a card's root, said the same on the screen and in line mode.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + patcher = mock.patch.dict(install._DETECTED_OS, {}) + patcher.start() + self.addCleanup(patcher.stop) + + def blank(self, name: str = "USBSTICK") -> Path: + stick = self.root / name + (stick / "photos").mkdir(parents=True) + return stick + + def start(self, **given) -> install.TuiState: + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(**given), "linux") + return state + + def test_a_blank_card_gets_one_folder_and_one_note_everywhere(self): + """code_audit_14 P2-1, tui_audit_7 P2-5: --dest kept the stick's + root, typed it got bios below it, and the screen said nothing.""" + stick = self.blank() + for named, plat, below in (("knulli", "knulli", "bios"), + ("rocknix", "rocknix", "roms/bios"), + ("ArkOS", "retroarch", "bios")): + with self.subTest(system=named): + with contextlib.redirect_stdout(io.StringIO()) as out: + by_dest = install.card_dest(named, plat, stick) + self.assertEqual(by_dest, (plat, stick / below)) + note = install.card_choice(named, plat, stick).note + self.assertTrue(note) + self.assertIn(note, out.getvalue()) + state = self.start(platform=named, dest=str(stick)) + self.assertEqual(state.dest, stick / below) + self.assertEqual(state.card_note, note) + # Kept on Create the folder? and on the plan. + self.assertEqual(state.screen, "create") + self.assertIn(note.split(":")[0], + " ".join(" ".join(install.tui_view(state, 120, 40)).split())) + install.tui_update(state, Key("enter")) + self.assertEqual(state.card_note, note) + + def test_the_minui_family_refuses_a_blank_dest(self): + """code_audit_14 P2-2: taken with --dest, refused typed.""" + stick = self.blank() + for plat in ("minui", "nextui", "myminui"): + with self.subTest(platform=plat): + choice = install.card_choice(plat, plat, stick) + self.assertIn("SD card", choice.refused) + with self.assertRaises(SystemExit), \ + contextlib.redirect_stderr(io.StringIO()): + install.card_dest(plat, plat, stick) + + def test_a_card_mounted_as_its_bios_folder_is_the_card(self): + """code_audit_14 P3-7: an Onion card mounted as BIOS was refused, + ROCKNIX's roms/bios was taken for KNULLI, a muOS card's folder + too.""" + onion = self.root / "BIOS" + make_card(onion, "Onion") + choice = install.card_choice("Onion", "retroarch", onion) + self.assertEqual((choice.refused, choice.folder), ("", onion / "BIOS")) + knulli = self.root / "k" / "bios" + make_card(knulli, "KNULLI") + self.assertEqual(install.card_choice("knulli", "knulli", knulli).folder, + knulli / "bios") + rocknix = self.root / "ROCKNIX" + make_card(rocknix, "ROCKNIX") + refused = install.card_choice("knulli", "knulli", rocknix / "roms" / "bios") + self.assertIn("is a ROCKNIX card, not a KNULLI card", refused.refused) + muos = self.root / "MUOS" + make_card(muos, "muOS") + refused = install.card_choice("knulli", "knulli", muos / "MUOS" / "bios") + self.assertIn("is a muOS card", refused.refused) + + def test_no_folder_above_a_mounted_drive_is_its_card(self): + """A card mounted as MUOS beside a stick: /media/me, which holds + it, is no muOS card for the stick.""" + stick = self.blank() + (self.root / "MUOS" / "bios").mkdir(parents=True) + with mock.patch.object(install, "_mounted_cards", + return_value=[stick, self.root / "MUOS"]): + choice = install.card_choice("knulli", "knulli", stick) + self.assertEqual((choice.refused, choice.folder), ("", stick / "bios")) + bios = self.root / "mnt" / "bios" + (bios / "x").mkdir(parents=True) + with mock.patch.object(install, "_mounted_cards", return_value=[bios]): + # A drive mounted at a folder named bios is the card itself. + self.assertEqual(install._card_and_below( + install.card_setup("knulli", "knulli"), bios), (bios, False)) + + def test_a_refused_dest_says_why_in_the_events(self): + """code_audit_14 P3-8: code 1 and no reason in the stream.""" + stick = self.blank() + proc = install_run("--events", "--platform", "onion", "--dest", str(stick), + "--manifest", str(REPO_ROOT / "install" / "retroarch.json"), + "--dry-run", env={install.OFFLINE_VARIABLE: "1"}) + self.assertEqual(proc.returncode, 1, proc.stderr) + events = [json.loads(line) for line in proc.stdout.splitlines()] + error = next(event for event in events if event["event"] == "error") + self.assertEqual((error["stage"], error["reason"]), ("detect", "not_found")) + self.assertIn("is not an Onion SD card", error["message"]) + self.assertEqual(events[-1], {"event": "end", "code": 1}) + table = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + self.assertIn("not the card of the system named", table) + + def test_a_card_no_file_names_is_titled_by_its_system(self): + """code_audit_14 P3-9: an ArkOS card chosen read as RetroArch.""" + stick = self.blank() + state = self.start(platform="ArkOS", dest=str(stick)) + self.assertEqual(install.detected_os("retroarch", state.dest), "ArkOS") + + def test_a_list_named_for_another_systems_card_says_so(self): + """code_audit_14 P3-10: --platform batocera on an Onion card.""" + onion = self.root / "ONION" + make_card(onion, "Onion") + choice = install.card_choice("batocera", "batocera", onion) + self.assertEqual((choice.refused, choice.folder), ("", onion)) + self.assertIn("is an Onion card", choice.note) + self.assertIn("--platform onion", choice.note) + state = self.start(platform="batocera", dest=str(onion)) + self.assertEqual(state.card_note, choice.note) + + def test_amberelec_and_emuelec_are_asked_for_their_card(self): + """code_audit_14 P3-11: "Use this folder" on /storage/roms of a PC.""" + for plat, name in (("amberelec", "AmberELEC"), ("emuelec", "EmuELEC")): + with self.subTest(system=name): + setup = install.card_setup(plat, plat) + self.assertEqual((setup.name, setup.below), (name, "")) + state = self.start(platform=plat) + self.assertEqual(state.screen, "folder") + self.assertIsNone(state.folder_default) + self.assertIn("Where is the SD card?", + " ".join(install.tui_view(state, 80, 24))) + stick = self.blank(f"{name}STICK") + choice = install.card_choice(plat, plat, stick) + self.assertEqual(choice.folder, stick) + self.assertIn("cannot tell", choice.note) + + def test_install_py_has_two_blank_lines_around_each_definition(self): + """code_audit_14 P3-12: E305 after _card_mount, and the same slip + before _Interrupted, TuiRow and _setup_label (pycodestyle E302, + E303, E305 at top level).""" + import ast + + lines = (REPO_ROOT / "install.py").read_text(encoding="utf-8").splitlines() + body = ast.parse("\n".join(lines)).body + + def blank_before(node: ast.stmt) -> int: + # The longest run of blank lines between the code above and the + # statement, its decorators and comments in between, as + # pycodestyle counts them. + index = min([node.lineno] + [item.lineno for item in + getattr(node, "decorator_list", [])]) - 2 + runs, run = [], 0 + while index >= 0 and (not lines[index].strip() or lines[index].startswith("#")): + if lines[index].strip(): + runs.append(run) + run = 0 + else: + run += 1 + index -= 1 + return max(runs + [run]) + + definitions = (ast.FunctionDef, ast.ClassDef, ast.AsyncFunctionDef) + for before, after in zip(body, body[1:]): + count = blank_before(after) + with self.subTest(line=after.lineno): + if isinstance(after, definitions): + self.assertEqual(count, 2) + elif isinstance(before, definitions): + self.assertEqual(count, 2) + + def test_a_refusal_is_said_once_and_another_card_names_its_remedy(self): + """tui_audit_7 P3-1: said twice, and "Choose the card's top folder" + for the top folder of another card.""" + stick = self.blank() + state = self.start(platform="onion") + state.pad = "keys" + install._card_status(state, install.card_choice("Onion", "retroarch", stick)) + after = install._status_after(state) + self.assertEqual(len(after), 1) + self.assertTrue(after[0].endswith("? says more.")) + state.help = True + self.assertIn("Onion keeps .tmp_update", + " ".join(" ".join(install.tui_view(state, 80, 24)).split())) + state.help = False + muos = self.root / "MUOS" + make_card(muos, "muOS") + install._card_status(state, install.card_choice("Onion", "retroarch", muos)) + self.assertEqual(state.status, "That is a muOS card. Insert the Onion card, or go " + "back and choose muOS.") + with mock.patch.object(install, "_ask", side_effect=[str(muos), str(stick), + KeyboardInterrupt]), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + self.assertRaises(KeyboardInterrupt): + install.card_install_dest("Onion", "retroarch", True) + self.assertIn("Insert the Onion card, or choose muOS for that one.", out.getvalue()) + + def test_line_mode_names_the_card_picked_and_its_folder(self): + """tui_audit_7 P2-3, P3-5: no echo of the card picked, "Mount the + card" under two mounted, /mnt/d where the list says D:.""" + cards = [self.root / "ONION", self.root / "ONION2"] + for card in cards: + make_card(card, "Onion") + with mock.patch.object(install, "_ask", return_value="2"), \ + mock.patch.object(install, "_mounted_cards", return_value=cards), \ + mock.patch.object(install, "_missing_dest", return_value=True), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()): + dest = install.card_install_dest("Onion", "retroarch", True) + self.assertEqual(dest, cards[1] / "BIOS") + text = out.getvalue() + self.assertIn("Type its number or its top folder:", text) + self.assertNotIn("Mount the card", text) + self.assertIn(f"ONION2: Onion files go to {cards[1] / 'BIOS'}", text) + self.assertEqual(install.detected_os("retroarch", dest), "Onion") + with mock.patch.object(install, "_ask", return_value=""), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._prompt_custom_selection("retroarch", [], [], name="Onion", + bios_path=dest) + self.assertIn("Onion: 0 files", out.getvalue()) + self.assertIn(f"BIOS folder: {dest}", out.getvalue()) + proc = install_run("--platform", "onion", "--dest", str(cards[0]), "--dry-run", + "--manifest", str(REPO_ROOT / "install" / "retroarch.json"), + env={install.OFFLINE_VARIABLE: "1"}) + self.assertNotIn("gets the RetroArch file list", proc.stdout) + + def test_wsl_names_the_folder_by_its_drive_letter(self): + """tui_audit_7 P3-6, code_audit_14 P3-5.""" + with mock.patch.dict(install._WSL, {"active": True, "root": "/mnt"}): + self.assertEqual(install._short_path(Path("/mnt/d/BIOS"), HOME), "D:\\BIOS") + self.assertEqual(install.shown_path(Path("/mnt/d")), "D:\\") + args = _args(dest=Path("D:\\"), save_dir=Path("F:\\saves")) + setattr(args, "from", [Path("E:\\")]) + args.from_pack = [Path("E:\\pack.zip")] + install._wsl_dest(args) + self.assertEqual((args.dest, getattr(args, "from"), args.from_pack, args.save_dir), + (Path("/mnt/d"), [Path("/mnt/e")], [Path("/mnt/e/pack.zip")], + Path("/mnt/f/saves"))) + with mock.patch.dict(install._WSL, {"active": False}): + self.assertEqual(install._short_path(Path("/mnt/d/BIOS"), HOME), "/mnt/d/BIOS") + + def test_a_multi_word_system_example_can_be_pasted(self): + """code_audit_14 P3-6.""" + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit): + install.card_install_dest("Anbernic stock firmware", "retroarch", False) + self.assertIn("--platform anbernicstockfirmware --dest", err.getvalue()) + + +class AuditSevenScreens(unittest.TestCase): + """tui_audit_7 P2-1 to P3-9: what each screen offers and says.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + + def setups(self, pad: str = "keys") -> install.TuiState: + state = install.TuiState(os_type="linux", home=HOME) + state.pad = pad + state.stack = ["platforms"] + state.focus["platforms"] = 1 + return state + + def test_a_handheld_is_found_by_its_device_in_few_presses(self): + """P2-1: Onion was row 30 of 34, its row said "RetroArch files".""" + state = self.setups("gptokeyb") + rows = install.tui_rows(state) + labels = [row.label for row in rows] + self.assertEqual(labels[1], "RetroArch") + cards = [index for index, row in enumerate(rows) if row.label.endswith(", SD card")] + # One block, right after RetroArch. + self.assertEqual(cards, list(range(2, 2 + len(cards)))) + onion = labels.index("Onion, SD card") + self.assertIn("Miyoo Mini", rows[onion].right) + # At 40x12 a page is 6 rows: L1/R1 then Down, then A. + page = 12 - 6 + presses = (onion - 1) // page + (onion - 1) % page + 1 + self.assertLessEqual(presses, 4) + text = " ".join(install.tui_view(state, 40, 12)) + self.assertIn("handhelds come after RetroArch", " ".join(text.split())) + state.pad = "keys" + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("type your device's name (/)", text) + self.assertIn("Filter by setup or device name", + [what for _key, what in install._help_lines(state)]) + # Something found here: the list keeps its order. + found = fresh_state() + found.stack = ["setup", "platforms"] + self.assertEqual([row.label for row in install.tui_rows(found)][2], "Batocera") + + def test_line_mode_takes_a_device_name(self): + """P2-1: "miyoo" refused at the line menu.""" + with mock.patch.object(install, "_ask", side_effect=["bittboy"]), \ + contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(install._choose_platform("Which?"), "MiyooCFW") + self.assertIn("your device's name", out.getvalue()) + choices = install._line_setups() + onion = [name for name, _label in choices].index("Onion") + 1 + with mock.patch.object(install, "_ask", side_effect=["miyoo mini+", str(onion)]), \ + contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(install._choose_platform("Which?"), "Onion") + self.assertIn("setups run on 'miyoo mini+'", out.getvalue()) + self.assertIn(f"{onion}) Onion, SD card (Miyoo Mini, Miyoo Mini Plus)", out.getvalue()) + + def test_one_spelling_per_device_and_one_press_for_one_match(self): + """P3-9.2, P3-9.3, P3-9.4.""" + for name, devices in install._SETUP_DEVICES.items(): + with self.subTest(system=name): + self.assertFalse(any("+" in device for device in devices)) + self.assertFalse(any(device.startswith("Anbernic RG35XX") + for device in install._SETUP_DEVICES["spruceOS"])) + state = self.setups() + state.filters["platforms"] = "mini+" + self.assertIn("Onion, SD card", [row.label for row in install.tui_rows(state)]) + state.filters["platforms"] = "koriki" + state.focus["platforms"] = 0 + self.assertEqual(install._primary(state), "A/Enter Choose") + with mock.patch.object(install, "_mounted_cards", return_value=[]): + press(state, "enter") + self.assertEqual(state.screen, "folder") + self.assertEqual(state.card_bound[0], "Koriki") + rows = {row.value: row for row in install.tui_rows(self.setups())} + self.assertNotIn("PAN4ELEC", rows["amberelec"].right) + + def test_help_rows_keep_their_column_at_40(self): + """P3-9.1: "/ Filter (Esc clears)" ran back to the left edge.""" + for pad in ("gptokeyb", "gptokeyb_muxterm", "miyoo", "evmapy"): + with self.subTest(pad=pad): + state = self.setups(pad) + state.filters["platforms"] = "x" + state.help = True + lines = [line[1:] for line in install.tui_view(state, 40, 12)[2:-1] + if line.strip()] + column = lines[0].index("Move") + for line in lines: + self.assertTrue(line[:column].strip() == "" or line[column - 2:column] + == " ", line) + + def test_copy_from_a_folder_with_none_says_so(self): + """P2-2: the plan came back the same.""" + state = checked_state() + old = self.root / "old" + old.mkdir() + state.from_folders = [old] + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("old holds none of these files.", text) + state.found = [found("gba_bios.bin", str(old))] + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertNotIn("holds none", text) + stick = self.root / "STICK" + stick.mkdir() + state.home = self.root + with mock.patch.object(install, "_mounted_cards", return_value=[stick]), \ + mock.patch.object(install, "_listed", return_value=[]): + install._open_from_browser(state) + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus["browse"]].value, stick) + + def test_offline_plan_names_the_folder_already_copied_from(self): + """code_audit_14 P3-1.""" + state = checked_state() + state.from_folders = [self.root / "from1"] + state.found = [found("gba_bios.bin", str(self.root / "from1"))] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text = " ".join(" ".join(install.tui_view(state, 100, 40)).split()) + self.assertIn("They are not in from1", text) + self.assertNotIn("Copy them from your old card", text) + + def test_the_card_browser_focuses_the_card_asked_for(self): + """P3-2: the muOS card, listed first, was focused for Onion.""" + muos, onion, second = self.root / "A_MUOS", self.root / "B_ONION", self.root / "C" + make_card(muos, "muOS") + make_card(onion, "Onion") + make_card(second, "Onion") + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(platform="onion"), "linux") + state.home = self.root + state.focus["folder"] = [row.action for row in install.tui_rows(state)].index( + "browse") + with mock.patch.object(install, "_mounted_cards", + return_value=[muos, onion, second]): + press(state, "enter") + self.assertEqual(install.tui_rows(state)[state.focus["browse"]].value, onion) + self.assertEqual(state.status, "Choose the Onion card below.") + for line in install.tui_view(state, 40, 12): + self.assertNotIn("Go back", line) + + def test_confirm_offers_no_install_while_it_counts(self): + """P3-3.""" + state = checked_state() + state.stack.append("confirm") + state.running, state.dry = True, None + self.assertEqual(install.tui_view(state, 80, 24)[-1].split(), ["B/Esc", "Back", "?", + "Help"]) + self.assertNotIn("Install", [what for _key, what in install._help_lines(state)]) + + def test_back_after_choosing_systems_lands_on_install(self): + """P3-8.""" + state = checked_state() + state.focus["plan"] = [row.action for row in install.tui_rows(state)].index("systems") + press(state, "enter") + self.assertEqual(state.screen, "systems") + state.focus["systems"] = next(index for index, row in + enumerate(install.tui_rows(state)) + if row.action == "system") + press(state, " ", "esc") + self.assertEqual(state.screen, "plan") + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus["plan"]].action, "install") + + def test_a_lone_card_opens_the_first_screen_with_a_list(self): + """P3-7: with --manifest, the card's folder was the first screen.""" + onion = self.root / "ONIONB" + make_card(onion, "Onion") + shutil.rmtree(onion / "BIOS") + args = _args(platform="retroarch", manifest=str(REPO_ROOT / "install" / + "retroarch.json")) + args.platform_from_list = True + with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.dict(install._DETECTED_OS, {}): + state, effects = install.tui_start(args, "linux") + self.assertEqual((state.screen, effects), ("setup", [])) + + def test_a_file_left_for_its_save_directory_is_said_so(self): + """P2-4: the menu counted 23, 22 were checked, and the end blamed + the collection for the file whose save directory is unknown.""" + menus = [] + + def menu(_plat, files, *_rest, **_named): + menus.append([entry["dest"] for entry in files]) + return None + + with SaveDirectoryRepository() as repo: + argv = ["install.py", "--platform", "retroarch", "--dest", str(repo.dest), + "--manifest", str(repo.root / "install" / "retroarch.json")] + with mock.patch.dict(os.environ, repo.env()), \ + mock.patch.object(sys, "argv", argv), \ + mock.patch.object(sys.stdin, "isatty", return_value=True), \ + mock.patch.object(install, "_is_terminal", return_value=True), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install, "records_selection", return_value=True), \ + mock.patch.object(install, "_prompt_custom_selection", menu), \ + mock.patch.object(install, "_ask", return_value="y"), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()): + try: + install.main() + except SystemExit: + pass + finally: + install.LOCAL.reset() + self.assertEqual(len(menus), 1) + self.assertNotIn("Azahar/Azahar/sysdata/keys.txt", menus[0]) + text = out.getvalue() + self.assertIn("1 file read from RetroArch's save directory left out: its place is " + "not known. Name it with --save-dir next time.", text) + self.assertNotIn("the collection does not hold", text) + + def test_plural_game_data_and_the_backup_line_at_40x12(self): + """P3-9.5, P3-9.6.""" + state = checked_state() + games = [{"dest": f"g{index}.dat", "system": "", "category": install.GAME_DATA} + for index in range(2)] + with mock.patch.object(install, "_unavailable", return_value=(games, 0)): + self.assertIn("Copy them from", " ".join(install._unavailable_lines(state))) + with mock.patch.object(install, "_unavailable", return_value=(games[:1], 0)): + self.assertIn("Copy it from", " ".join(install._unavailable_lines(state))) + view = " ".join(" ".join(install.tui_view(state, 40, 12)).split()) + self.assertIn("1 wrong copy: backed up, replaced.", view) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_minui_platform.py b/tests/test_minui_platform.py index baa345125..c75d47b1e 100644 --- a/tests/test_minui_platform.py +++ b/tests/test_minui_platform.py @@ -11,7 +11,9 @@ profiles, placed below that folder. from __future__ import annotations +import contextlib import hashlib +import io import json import os import shutil @@ -406,9 +408,11 @@ class InstallerReadsTheCard(unittest.TestCase): (self.root / ".system" / "version.txt").write_text("MinUI-20251127-0\n") (self.root / "Bios").mkdir() - with self.assertRaises(SystemExit): - install._check_card_root("minui", self.root / "Bios") - install._check_card_root("minui", self.root) + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + install.card_dest("minui", "minui", self.root / "Bios") + self.assertEqual(install.card_dest("minui", "minui", self.root), + ("minui", self.root)) + class TheSitePageOfACardPlatform(unittest.TestCase): """A platform that names no file showed "0/0 Coverage (0%)" and an empty