diff --git a/install.ps1 b/install.ps1 index 21305373e..7dac4409e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169" + $defaultInstallSha256 = "aebd38c69bc524b655e592ae0407ec6095696b846370e2c726275db8bfd050f4" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index ecb3c6a77..c395709c4 100644 --- a/install.py +++ b/install.py @@ -912,6 +912,13 @@ def _host_path(text: str) -> Path: return Path(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)) + + def _enter_wsl() -> None: """Read Windows paths through drvfs, and the Windows variables detection needs, which WSL does not pass to Linux (only those named in WSLENV).""" @@ -5380,11 +5387,17 @@ def _report_outcome( platform_of: dict[Path, str] | None = None, hints: list[str] | None = None, sources: str = "", + offline: int = 0, ) -> None: """Close the run on what happened, and leave with a status that says so. A run that installed nothing used to end on the word Done and a zero exit, - which reads as success to a person and to a script alike. + which reads as success to a person and to a script alike. offline counts + the files --offline left out at a terminal, kept apart from a "no" to + the size question: counted with it, 14,696 files missing ended on Done + and exit 0 once 4 came from this machine. They end the run as files + that failed do, 1 when some were installed and 2 when none were, the + codes the full screen's run gives. """ if transferred and seconds > 0: verb = "copied from the pack" if LOCAL.pack is not None else "downloaded" @@ -5401,9 +5414,8 @@ def _report_outcome( print(f" failed: {dest}" + (f" ({reason})" if reason else "")) if failures and len(failures) > 10: print(f" ... and {len(failures) - 10} more") - if declined and not downloaded and not errors and not interrupted: - print(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, " - + ("which --offline does not do." if _offline_only() else "as answered.")) + if declined and not downloaded and not errors and not interrupted and not offline: + 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() @@ -5438,6 +5450,28 @@ def _report_outcome( "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: + remedy = _offline_remedy() + if not downloaded and not errors: + print(f"\nNothing installed: {_plural(offline, 'file')} " + f"{'needs' if offline == 1 else 'need'} the internet, which --offline " + "does not use.") + 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 sources: + print(f" {sources}") + print(f"Location: {_locations(paths)}") + for line in kept + advice: + print(line) + print(f" {remedy}") + sys.exit(1 if downloaded else 2) + if errors and not downloaded: print(f"\nNothing was installed. {_plural(errors, 'file')} failed.") if failures and all(isinstance(r, (NetworkReason, OfflineReason)) @@ -5479,6 +5513,17 @@ def _report_outcome( print("To check this later, run the same command with --check.") +def _offline_remedy() -> 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) + 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 _omission_lines(count: int, entries: list[dict]) -> list[str]: """What was skipped, the files an emulator cannot start without first. @@ -5597,7 +5642,14 @@ def _print_plan( if not parts and not copied: print("\n All files up to date. A real run would download nothing.") return - if parts: + if parts and _offline_only(): + # --offline fetches none of them: "Would download" promised a + # transfer the run never makes, where the screen says "need the + # 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}:") + elif parts: print(f"\nWould download {' and '.join(parts)} to {bios_path}:") for line in copied: print(line) @@ -6253,11 +6305,27 @@ def format_size(n: int) -> str: return f"{n / (1024 * 1024 * 1024):.1f} GB" +def _line_setups() -> list[tuple[str, str]]: + """(name typed back, label) of each choice of the line menus: the + 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)] + return choices + + def _choose_platform(title: str, default: str = "") -> str: - """One platform from the numbered list, Enter taking the default.""" + """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 + normalize_platform turns into its list.""" + choices = _line_setups() print(f"\n{title}") - for i, name in enumerate(AVAILABLE_PLATFORMS, 1): - print(f" {i}) {display_name(name)}") + for i, (_name, label) in enumerate(choices, 1): + print(f" {i}) {label}") print(" q) quit") print() prompt = f"> [Enter for {display_name(default)}] " if default else "> " @@ -6267,26 +6335,28 @@ def _choose_platform(title: str, default: str = "") -> str: return default if choice.lower() == "q": _quit() - if choice.isdigit() and 1 <= int(choice) <= len(AVAILABLE_PLATFORMS): - return AVAILABLE_PLATFORMS[int(choice) - 1] + if choice.isdigit() and 1 <= int(choice) <= len(choices): + return choices[int(choice) - 1][0] key = _name_key(choice) - for plat in AVAILABLE_PLATFORMS: - if key and key in (_name_key(plat), _name_key(display_name(plat))): - return plat - print(f" Type a number from 1 to {len(AVAILABLE_PLATFORMS)}, a platform name, " + 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.") -def _prompt_manual_platform(os_type: str, ask: bool = True) -> list[tuple[str, Path]]: +def _prompt_manual_platform(os_type: str, ask: bool = True, + check_typed: bool = False) -> list[tuple[str, Path]]: """Ask for a platform and a BIOS directory when nothing was detected. A system that runs from its card gets the card's root asked for, as --platform rocknix does: /storage/roms/bios was offered on a PC. """ - plat = _choose_platform("No supported platform found here. Choose the one to " - "install for:", "retroarch") - if card_bound(plat, plat) is not None: - return [(plat, card_install_dest(plat, plat, True, ask))] + named = _choose_platform("No supported platform found here. Choose the one to " + "install for:", "retroarch") + plat = normalize_platform(named, say=False) + if card_bound(named, plat) is not None: + return [(plat, card_install_dest(named, plat, True, ask))] default_dest = _default_dest(os_type, plat) raw = "" while not raw: @@ -6299,7 +6369,11 @@ def _prompt_manual_platform(os_type: str, ask: bool = True) -> list[tuple[str, P if default_dest is not None: break if raw: - return [(plat, _typed_folder(raw))] + typed = _typed_folder(raw) + if check_typed: + # A card's root is checked where it is asked, a typed folder here. + _missing_dest(typed, ask, "typed") + return [(plat, typed)] state = "" if default_dest.exists() else ". It does not exist yet and will be created" print(f" {display_name(plat)}'s default BIOS folder: {default_dest}{state}.") return [(plat, default_dest)] @@ -6315,7 +6389,8 @@ def _typed_folder(raw: str) -> Path: text = raw.strip() if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"": text = text[1:-1].strip() - return Path(os.path.expandvars(os.path.expanduser(text))) + # Under WSL, D: and D:\ are the Windows drive the screen calls D:. + return _host_path(os.path.expandvars(os.path.expanduser(text))) # Platforms with no install location of their own: the BIOS folder sits @@ -6528,10 +6603,16 @@ 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 "") + ".") - if _offline_only(): - print(" --offline: only the files found on this machine are installed.") + 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])}") @@ -8371,9 +8452,11 @@ def sources_line( fetched: str = "downloaded", shared: int = 0, ) -> str: """Where the files of a run that copied some came from, in words: - "38 files copied from E:\\, 4 downloaded, 2 still missing." """ + "38 files copied from E:\\, 4 downloaded, 2 still missing." A file found + in the wrong place of the BIOS folder is "copied", as the plan says: + "fixed" read as a wrong copy, of files that were missing.""" parts = [(count, f"copied from {screen_text(label)}") for label, count in folders] - parts.append((fixed, "fixed from copies already in the BIOS folder")) + parts.append((fixed, "copied from the wrong place in the BIOS folder")) parts.append((shared, "copied from another place of the BIOS folder")) if not any(count for count, _what in parts): return "" @@ -9371,7 +9454,8 @@ def _refuse_closed(dest: Path, named: str, then: str = "") -> None: sys.exit(1) -def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> None: +def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest", + again: bool = False) -> bool: """Say that a --dest folder does not exist before anything goes there. A typo (~/.config/retroarh/system) was created with every missing @@ -9380,10 +9464,12 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> No made yet and is created. Several make a path that may be mistyped: at a terminal the run asks first, and the answer defaults to no. A folder above it this user cannot open ends the run with its name. + With again, no returns False, for the folder to be asked again: no to + a card's folder ended the run where it meant "not that card". """ _refuse_closed(dest, named) if dest.exists(): - return + return True nearest = dest.absolute() while not nearest.exists() and nearest.parent != nearest: nearest = nearest.parent @@ -9392,9 +9478,13 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> No if missing > 1 and ask: answer = _ask(f" Create {_plural(missing, 'folder')} under {nearest}? [y/N] ") if not answer.strip().lower().startswith("y"): + if again: + print(" Not created.") + return False print(f" Nothing installed. Check the folder {named}, then run " "the command again.") sys.exit(2) + return True def _no_core_found(plat: str, bios_path: Path, searched: list[Path]) -> None: @@ -9430,9 +9520,19 @@ def card_platform(root: Path) -> str | None: # A system that runs from its SD card names the card by where the device -# mounts it: on a computer the card's root stands in for that mount point, -# and its device rule then reads the same below the root. -_CARD_MOUNTS = ("/mnt/SDCARD/", "/mnt/mmc/") +# mounts the part of it that holds the BIOS folder: on a computer that +# part's root stands in for the mount point, and its device rule then reads +# the same below the root. The specific mounts come before /mnt/, where +# MiyooCFW mounts its MAIN part. The folders below them are the ones +# _guides.yml handheld_guide gives each system, and a test holds the two +# equal. +_CARD_MOUNTS = ("/mnt/SDCARD/", "/mnt/plumos-user/", "/mnt/mmc/", "/storage/", "/roms/", + "/mnt/") + + +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 @@ -9458,9 +9558,10 @@ def _card_rules() -> list[_OsRule]: for rule in _EMBEDDED_RULES: if rule.method != "file_exists" or not rule.manifest or not rule.bios: continue - mount = next((m for m in _CARD_MOUNTS if rule.bios.startswith(m) - and all(value.startswith(m) for value in rule.values)), "") - if mount: + # Only a file on the part its BIOS folder is on names the card: one + # on its system part (/etc/main, /mnt/vendor) is not on it. + mount = _card_mount(rule.bios) + if mount and all(value.startswith(mount) for value in rule.values): rules.append(rule._replace( values=tuple(value[len(mount):] for value in rule.values), bios=rule.bios[len(mount):], configs=())) @@ -9518,49 +9619,169 @@ class CardChoice(NamedTuple): plat: str folder: Path note: str = "" + # Why the folder is not the card, with its path, and what was looked + # for on it. short says it without the path, for a 40-column screen. refused: str = "" - # The system's folder below its card, and the file that names the card. + detail: str = "" + short: str = "" + # The system's folder below its card. below: str = "" - marker: str = "" + + +class CardSetup(NamedTuple): + """A system that runs from its SD card, as a computer sees that card.""" + + name: str + plat: str + # Its BIOS folder below the card's root, "" for the root itself. + below: str + # The rules that tell its card by the files on it: none when every file + # that names the system is on another part of the card (/etc, /usr). + rules: tuple = () + # A card its rules do not match may still be its card: a share the + # system fills when it first starts. + fresh: bool = False + + def known(self) -> bool: + """Whether the files on a card can say it is this system's.""" + return self.plat in CARD_PLATFORMS or bool(self.rules) + + +def card_setups() -> list[CardSetup]: + """Every system that runs from its SD card, with the folder its device + reads below the card's root. + + The MinUI family is told by its card's version.txt, KNULLI and ROCKNIX + by the share they fill, and each system that gets RetroArch's list by + its device rule, whose BIOS folder lies on one of _CARD_MOUNTS. The + setup list, its filter, --platform, the line questions and every check + of a card read this list: a second one kept by hand left UnofficialOS, + ArkOS and MiyooCFW on this computer's RetroArch folder. + """ + rules = _card_rules() + setups = [CardSetup(display_name(plat), plat, "") for plat in AVAILABLE_PLATFORMS + if plat in CARD_PLATFORMS] + setups += [CardSetup(rule.name, rule.manifest, rule.bios, (rule,), True) + for rule in _SHARE_RULES] + 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)), "") + if below: + setups.append(CardSetup(name, "retroarch", below, + tuple(rule for rule in rules if rule.name == name))) + return setups + + +def card_setup(named: str, plat: str) -> CardSetup | None: + """The card setup a name stands for with plat's list: a system by its + own name, or a list that is one (MinUI). RetroArch names a list, not a + card.""" + key = _name_key(named) + return next((setup for setup in card_setups() if setup.plat == plat and ( + plat in CARD_PLATFORMS or (key and key == _name_key(setup.name)))), None) + + +def _a(name: str) -> str: + """A name with its article: an Onion, a muOS.""" + return f"{'an' if name[:1].lower() in 'aeiou' else 'a'} {name}" + + +def _card_is(setup: CardSetup, card: Path) -> tuple[str, str]: + """("own", name) when the files on card say it runs setup's system, + ("other", names) when they name another one, ("unknown", "") when they + name none. A MinUI card for NextUI's list is its own: one layout.""" + family = card_platform(card) + if family: + return ("own" if setup.plat in CARD_PLATFORMS else "other"), display_name(family) + names = sorted({rule.name for rule in _card_matches(card)}) + if setup.name in names: + return "own", setup.name + return ("other", " and ".join(names)) if names else ("unknown", "") + + +def _not_the_card(setup: CardSetup, folder: Path, card: Path, other: str) -> CardChoice: + """folder refused for setup: card, its root, is another system's card + (other names it) or names no system at all.""" + name = setup.name + if " and " in other: + return CardChoice(setup.plat, folder, refused=f"{card} holds the files of {other}, " + f"not of {name}", short=f"That card holds {other} files.", + below=setup.below) + 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) + 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 " + f"{name} files on it", detail=f"{name} keeps {marker} at the root " + "of its card", short=f"Not {_a(name)} SD card: no {name} files on it.", + below=setup.below) + + +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: + return (f"Nothing on {card} says it is {_a(setup.name)} card yet (not started " + f"once?): the files go to {folder}") + return (f"RetroBIOS cannot tell {_a(setup.name)} card by its files: the files go " + f"to {folder}") + + +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.""" + below = setup.below + if below and _ends_with(path, below): + return path.parents[len(PurePosixPath(below).parts) - 1], True + return path, False def card_choice(named: str, plat: str, dest: Path) -> CardChoice: - """The file list and folder for a folder that may be a handheld's card. + """The file list and folder for a --dest that may be a handheld's card. 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 system named whose card this is not is refused with the - folder it reads. + 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. """ - 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]}") - # Only a system named by its own name: --platform retroarch or knulli - # says which list, not which card. - key = _name_key(named) - rule = next((rule for rule in _card_rules() if key and key == _name_key(rule.name) - and key != _name_key(display_name(rule.manifest))), None) - matched = [] if found else _card_matches(dest) - if rule is None or found: + 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) - # Its folder on the card, or one its device reads (/run/muos/storage/bios). - own = {device.bios for device in _EMBEDDED_RULES if device.name == rule.name} - if dest.as_posix() in own or _ends_with(dest, rule.bios): + # 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) - if any(match.name == rule.name for match in matched): - # A card two systems claim, the one named among them. - return CardChoice(plat, dest / rule.bios, f"{rule.name} card: {display_name(plat)} " - f"files, in {dest / rule.bios}") - marker = rule.values[0].split("/")[0] - return CardChoice(plat, dest, refused=f"{dest} is not a {rule.name} card: it holds no " - f"{marker}. {rule.name} reads its BIOS files from {rule.bios} on its " - "card", below=rule.bios, marker=marker) + card, inside = _card_and_below(setup, dest) + kind, what = _card_is(setup, card) + 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 + if kind == "own": + return CardChoice(plat, folder, f"{setup.name} card: {display_name(plat)} files, " + f"in {folder}") + 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) def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: @@ -9568,8 +9789,9 @@ def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: the run ended on a folder that is not the card of the system named.""" choice = card_choice(named, plat, dest) if choice.refused: - print(f"Error: {choice.refused}: name the card's root (the folder holding " - f"{choice.marker}) or that folder: --dest /{choice.below}", + 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) sys.exit(1) if choice.note: @@ -9597,7 +9819,7 @@ def _check_card_root(plat: str, dest: Path) -> None: """ above = card_root_above(dest) if above: - print(f"Error: {dest} is inside a {display_name(above[0])} card. " + 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) @@ -9614,55 +9836,65 @@ def card_bound(named: str, plat: str) -> tuple[str, str] | None: default folder is the computer's own RetroArch: the files went there, which Onion never reads. --platform retroarch names a list, not a card. """ - if plat in CARD_PLATFORMS: - return display_name(plat), "" - key = _name_key(named) - rule = next((rule for rule in _card_rules() - if key and rule.manifest == plat and key == _name_key(rule.name)), None) - return (rule.name, rule.bios) if rule else None + setup = card_setup(named, plat) + return (setup.name, setup.below) if setup else None def mounted_cards_of(system: str, plat: str) -> list[Path]: - """The mounted cards whose own files say they run system.""" + """The mounted drives offered as system's card: those whose own files + say they run it, or, for a system no file on its card names, those no + other system claims.""" + setup = card_setup(system, plat) + if setup is None: + return [] if plat in CARD_PLATFORMS: return [mount for mount in _mounted_cards() if card_platform(mount) == plat] - return [mount for mount in _mounted_cards() - if any(rule.name == system for rule in _card_matches(mount))] + wanted = "own" if setup.known() else "unknown" + 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 a root chosen as the card of a card-bound system, - or why root is not that card. + """The BIOS folder for root, chosen as the card of the card system + named, or why root is not that card. - 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, and a folder that - was no Onion card ended the run. Each is refused here, with its reason, - for the question to be asked again. + 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. """ - system, below = card_bound(named, plat) or (display_name(plat), "") - # The card is mounted, its BIOS folder may not be made yet. - card = (root.parents[len(PurePosixPath(below).parts) - 1] - if below and _ends_with(root, below) else root) + 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") + 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: - return CardChoice(plat, root, refused=f"{root} is inside a " - f"{display_name(above[0])} card, whose root is {above[1]}") - found = card_platform(root) - if found is None: - return CardChoice(plat, root, refused=f"{root} holds no {CARD_MARKER}: it is " - f"not a {system} card") - note = ("" if found == plat else f"Note: this card holds {display_name(found)} " + 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, root, note) - choice = card_choice(named, plat, root) - if choice.refused or choice.folder != root or not below or _ends_with(root, below): - return choice - # A KNULLI share or a ROCKNIX card read as a list's name: its folder - # below the root, as the device reads it. - return choice._replace(folder=root / below) + 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).""" + return f"{_mount_name(mount)} ({_card_what(mount)}, {mount})" def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True) -> Path: @@ -9671,19 +9903,22 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True One mounted card that runs it is taken. At a terminal the card's root is asked for, never this machine's own folder, and asked again until it is - that card. Without questions the run stops on the --dest that names the - card. ask is whether creating several missing folders is asked first, - as it is for --dest. + that card. Only that system's cards are listed, as on the screen: a muOS + card listed for Onion was taken, and the run became a muOS install. + Without questions the run stops on the --dest that names the card. ask + is whether creating several missing folders is asked first, as it is + for --dest; no to it asks for the card again. """ - system, below = card_bound(named, plat) or (display_name(plat), "") + setup = card_setup(named, plat) or CardSetup(display_name(plat), plat, "") + system = setup.name cards = mounted_cards_of(system, plat) - if len(cards) == 1: - dest = cards[0] / below if below else cards[0] + 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}") return dest example = (f"--platform {named.strip() or plat} --dest /media/$USER/ " "(Windows: --dest E:\\)") - if len(cards) > 1: + 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) if not interactive: @@ -9694,30 +9929,36 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True f"Mount the card and name its root with --dest, for example: {example}", file=sys.stderr) sys.exit(1) - # The drives the interface lists, each with what its own files say. - mounts = _mounted_cards() - print(f" {system} keeps its BIOS files on its SD card, not on this machine. " - "Mount the card, then type its root folder" - + (" or its number:" if mounts else ".")) - for index, mount in enumerate(mounts, 1): - print(f" {index}) {mount} ({_card_what(mount)})") + where = (f"on its SD card, in its {setup.below} folder" if setup.below + else "on its SD card") + 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 ".")) + 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)}" while True: raw = _ask("Card root (Ctrl+C to stop): ") if not raw: continue - root = (mounts[int(raw) - 1] if raw.isdigit() and 1 <= int(raw) <= len(mounts) - else _typed_folder(raw)) - choice = card_root_folder(named, plat, root) + chosen = raw.strip().isdigit() and 1 <= int(raw) <= len(cards) + if cards and raw.strip().isdigit() and not chosen and not _is_dir(_typed_folder(raw)): + 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) if choice.refused: - print(f" {choice.refused}. Type the root of the {system} card.") + detail = f" {choice.detail}." if choice.detail else "" + print(f" {choice.refused}.{detail} Type the top folder of the {system} card.") continue if choice.note: print(f" {choice.note}") - _missing_dest(choice.folder, ask, "typed") - return choice.folder + if _missing_dest(choice.folder, ask, "typed", again=True): + return choice.folder -def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str: +def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> tuple[str, Path]: """The file list for a --dest given without --platform. A folder says where, not for whom, and a card is often prepared on a PC @@ -9730,15 +9971,24 @@ def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str: if card: print(f" File list: {display_name(card)} (the card's {CARD_MARKER}). " f"Same choice without detection: --platform {card}") - return card + return card, dest print("Detecting platform...") found = list(dict.fromkeys(name for name, _path in detect_platforms(os_type))) default = found[0] if len(found) == 1 else "" if found else "retroarch" - if interactive: - plat = _choose_platform(f"Which platform's files go to {dest}?", default) + while interactive: + named = _choose_platform(f"Which platform's files go to {dest}?", default) + plat = normalize_platform(named, say=False) + # A system that runs from its card checks dest as --platform does. + choice = card_choice(named, plat, dest) + if choice.refused: + detail = f" {choice.detail}." if choice.detail else "" + print(f" {choice.refused}.{detail} Choose another.") + continue + if choice.note: + print(f" {choice.note}") print(f" File list: {display_name(plat)}. Same choice without questions: " - f"--platform {plat}") - return plat + f"--platform {_name_key(named) if named != plat else plat}") + return plat, choice.folder if not default: print(f"Error: {' and '.join(display_name(n) for n in found)} found here. " f"Name the platform {dest} is for with --platform.", file=sys.stderr) @@ -9746,7 +9996,7 @@ def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str: why = "the platform found here" if found else "nothing was detected here" print(f" File list: {display_name(default)} ({why}). For another platform's " "list, add --platform (--list-platforms names them).") - return default + return default, dest def _unknown_target(target: str, plat: str, targets: dict) -> None: @@ -9801,6 +10051,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: os_type = detect_os() if os_type == "wsl": _enter_wsl() + _wsl_dest(args) _open_local_sources(args) # Early exit for listing @@ -9892,8 +10143,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: assumed_dests.add(default_dest) elif args.dest: print(f" Using destination: {args.dest}") - platforms = [(_platform_for_dest(os_type, args.dest, interactive and not listing), - args.dest)] + plat, args.dest = _platform_for_dest(os_type, args.dest, interactive and not listing) + platforms = [(plat, args.dest)] else: print("Detecting platform...") frontends = detect_frontends(os_type) @@ -9923,13 +10174,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for line in _manual_usage_hint(os_type, asked): print(line) sys.exit(1) + # A folder typed, or a card's root, is checked there. platforms = _prompt_manual_platform( - os_type, not (args.check or args.dry_run or args.yes)) - plat, typed = platforms[0] - # A card's root was asked for, and its folders checked, there. - if typed != _default_dest(os_type, plat) and card_bound(plat, plat) is None: - _missing_dest(typed, not (args.check or args.dry_run or args.yes), - "typed") + os_type, not (args.check or args.dry_run or args.yes), check_typed=True) else: for name, path in platforms: print(f" Found {found_label(name, path)}") @@ -9983,6 +10230,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: total_bytes = 0 total_seconds = 0.0 total_declined = 0 + # Files --offline left out at a terminal: missing, never "as answered". + total_offline = 0 check_pending = 0 total_verified = 0 files_pending = 0 @@ -10402,12 +10651,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # to download 6.6 GB that --offline never fetches. print(f"\n {_plural(len(fetched), 'file')} " f"{'needs' if len(fetched) == 1 else 'need'} the internet, which " - "--offline does not use. Copy them from your old card or a folder " - "(--from), or run the installer again with a pack (--from-pack) or " - "without --offline.") - # Not done: the summary says they were not downloaded. + f"--offline does not use. {_offline_remedy()}") + # Not done: the summary counts them as missing. files_pending += len(fetched) - total_declined += len(fetched) + total_offline += len(fetched) to_download = [f for f in to_download if id(f) in copies] fetched = [] dl_size = sum(f.get("size", 0) for f in to_download) @@ -10501,12 +10748,25 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: counts = install_data_directory(entry, bios_path) total_downloaded += counts["installed"] total_up_to_date += counts["present"] - line = f" {entry['ref']}: {counts['installed']} installed, " \ - f"{counts['present']} already up to date" - if counts["changed"] or counts["absent"]: - line += (f", {counts['changed'] + counts['absent']} no longer in " - "the source (the pack has them)") - print(line) + if (isinstance(counts["error"], OfflineReason) and interactive + and not args.yes): + # Left out as the BIOS files above are: counted missing, + # not as one failure per tree. + print(f" {entry['ref']}: {counts['present']} already up to date, " + f"{counts['absent']} need the internet (--offline)") + total_offline += counts["absent"] + files_pending += counts["absent"] + continue + if not counts["error"]: + # An archive that could not be read leaves every file + # absent: "no longer in the source (the pack has them)" + # said so of 218 files no source was asked for. + line = f" {entry['ref']}: {counts['installed']} installed, " \ + f"{counts['present']} already up to date" + if counts["changed"] or counts["absent"]: + line += (f", {counts['changed'] + counts['absent']} no longer in " + "the source (the pack has them)") + print(line) if counts["error"]: print(f" {entry['ref']}: could not read " f"{LOCAL.pack.name if LOCAL.pack is not None else entry['url']}: " @@ -10579,7 +10839,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: omitted_entries=omitted_seen, platform_of={path: plat for plat, path in platforms}, hints=bulk_lines, - sources=_run_sources(bios_indexes, total_downloaded, total_errors), + sources=_run_sources(bios_indexes, total_downloaded, + total_errors + total_offline), + offline=total_offline, ) finish_app(args, platforms, app_cards, interactive and not total_declined) @@ -10922,6 +11184,9 @@ class TuiState: self.editing: str = "" self.help: bool = False self.status: str = "" + # What the status says in full, shown under it where there is room + # and in the help: the folder refused and what was looked for. + self.status_more: str = "" self.cols: int = 80 self.rows: int = 24 # A check started before the loop's first tick counted the @@ -10929,6 +11194,9 @@ class TuiState: self.now: float = time.monotonic() # (platform, folder, label) for each setup found here. self.setups: list = [] + # (platform, folder) of the setups that are a card in a reader, not + # this machine: a setup chosen for that system asks for its card. + self.mounted_setups: set = set() self.notes: list = [] # The folders detection could not look in, one line each. self.refused: list = [] @@ -11351,30 +11619,7 @@ def tui_rows(state: TuiState) -> list[TuiRow]: for index, (_plat, _path, label) in enumerate(state.setups)] return rows + [TuiRow("another", "Another setup or folder")] if screen == "platforms": - needle = state.filters.get(screen, "") - systems = _systems_reading() - rows = [_filter_row(state)] - for plat in AVAILABLE_PLATFORMS: - named = _setup_matches(plat, systems.get(plat, []), needle) - if named is None: - continue - # A system that runs from its SD card, named or run by a device - # typed, is a row of its own that asks for the card: "onion" on - # RetroArch's row led to this computer's RetroArch folder. - cards = [name for name in named - if name in systems.get(plat, []) and card_bound(name, plat)] - others = [name for name in named if name not in cards] - if not named or others: - # The systems the filter named, those a device typed runs: - # "miyoo" on RetroArch read "muOS, JELOS, KOLOS...". - rows.append(TuiRow("platform", display_name(plat), - ", ".join(others or systems.get(plat, [])), plat)) - for name in cards: - devices = [device for device in _SETUP_DEVICES.get(name, ()) - if _matches(device, _device_needle(needle))] - rows.append(TuiRow("cardsystem", f"{name}, SD card", ", ".join(devices) - or f"{display_name(plat)} files", (plat, name))) - return rows + return _platform_rows(state) if screen == "folder": rows = [TuiRow("default", "Use this folder")] if state.folder_default else [] bound = _tui_card_bound(state) @@ -11428,8 +11673,10 @@ def tui_rows(state: TuiState) -> list[TuiRow]: rows = ([TuiRow("retry", "Try again")] if state.run == "check" and state.error_title and state.error_title != TUI_OFFLINE_TITLE else []) - return rows + [TuiRow("back", "Choose another setup or folder" if state.run == "check" - else "Back"), + # Its own verb on the bar: "A/Enter Back" under "Choose another + # setup or folder". + return rows + [TuiRow("elsewhere", "Choose another setup or folder") + if state.run == "check" else TuiRow("back", "Back"), TuiRow("quit", "Quit")] if screen == "quit": return [TuiRow("quit", "Quit"), TuiRow("stay", "Stay")] @@ -11507,20 +11754,29 @@ def _system_rows(state: TuiState) -> list[TuiRow]: right = "in place" elif not count: right = f"{_plural(need, 'file')}, {format_size(row['bytes_needed'])}" - narrow = (format_size(row["bytes_needed"]),) + narrow = (format_size(row["bytes_needed"]), _compact_size(row["bytes_needed"])) elif not own: # Two spellings of shared, never cut: "+ 28 sh." and ", shared" # sat beside "+ 1689 shared" on one 40-column screen. right = f"{_plural(need, 'file')}, {format_size(size)}, all shared" - narrow = (f"{format_size(size)}, all shared", format_size(size)) + narrow = (f"{format_size(size)}, all shared", format_size(size), + _compact_size(size)) else: right = f"{_plural(own, 'file')}, {format_size(own_size)} + {count} shared" - narrow = (f"{format_size(own_size)} + {count} shared", format_size(own_size)) + narrow = (f"{format_size(own_size)} + {count} shared", format_size(own_size), + _compact_size(own_size)) rows.append(TuiRow("system", f"{box} {label}", right, row["system"], "" if need else "dim", narrow)) return rows +def _compact_size(size: int) -> str: + """A size in whole units, for a row too narrow for its decimal: at 40 + columns Nintendo Game Boy Advance lost its size, 16 KB fitting.""" + number, unit = format_size(size).split(" ") + return f"{round(float(number))} {unit}" + + def _shared_needed(state: TuiState) -> dict[str, tuple[int, int]]: """Per system, the files to download that other systems carry too, and their size: `3DO 1704 files, 349.0 MB` read as the 3DO BIOS, when 1689 @@ -11742,32 +11998,48 @@ def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: def _card_folder(state: TuiState, folder: Path) -> CardChoice | None: """What a folder chosen for a setup that runs from its SD card resolves to, as a card root typed in line mode does: None when no card is asked - for, or when the folder is inside a MinUI card, whose root _choose - offers.""" + for, on the device itself, whose folder is taken as typed (a KNULLI got + bios added to it), or when the folder is inside a MinUI card, whose + root _choose offers.""" bound = _tui_card_bound(state) if bound is None or (state.platform in CARD_PLATFORMS and card_root_above(folder)): 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) -def _card_refusal(state: TuiState, folder: Path) -> str: - """Why folder is not the card the chosen setup runs from, or "".""" +def _card_status(state: TuiState, choice: CardChoice, given: bool = False) -> None: + """Say why a folder is not the card: one short sentence first, which a + 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}" + state.status_more = f"{choice.refused}." + (f" {choice.detail}." if choice.detail else "") + + +def _card_refusal(state: TuiState, folder: Path) -> bool: + """Whether folder is refused as the card the chosen setup runs from, + said in the status when it is.""" choice = _card_folder(state, folder) if choice is None or not choice.refused: - return "" - return f"{choice.refused}. Choose the root of the {_tui_card_bound(state)[0]} card." + return False + _card_status(state, choice) + return True def _choose_card(state: TuiState, folder: Path) -> list[tuple]: - """_choose for a folder picked on the folder, browse or path screen, or - given with --dest: for a setup that runs from its SD card, the card's - own BIOS folder, and a folder that is not its card refused in words.""" + """_choose for a folder picked on the folder, browse or path screen: for + a setup that runs from its SD card, the card's own BIOS folder, and a + folder that is not its card refused in words.""" choice = _card_folder(state, folder) if choice is None: return _choose(state, state.platform, folder) if choice.refused: - state.status = _card_refusal(state, folder) - if state.stack and state.screen in ("folder", "browse", "path", "platforms"): + _card_status(state, choice) + if state.stack and state.screen in ("folder", "browse", "path"): return [] state.folder_default, state.folder_kind = None, "usual" return _push(state, "folder") @@ -11775,7 +12047,10 @@ def _choose_card(state: TuiState, folder: Path) -> list[tuple]: 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]) - return _choose(state, choice.plat, choice.folder) + effects = _choose(state, choice.plat, choice.folder) + if choice.note and not state.status: + state.status = choice.note + return effects def _open_folder_screen(state: TuiState, default: Path | None, @@ -11938,7 +12213,7 @@ def typed_path(text: str) -> Path | None: text = text.strip().strip("\"'").strip() if not text: return None - return Path(os.path.expanduser(os.path.expandvars(text))) + return _host_path(os.path.expanduser(os.path.expandvars(text))) def _tui_folder_facts(state: TuiState) -> dict: @@ -11971,17 +12246,26 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: state.platform = plat state.card_bound = card_bound(system, plat) if state.dest_given is not None: - return _choose_card(state, state.dest_given) + # Read as --platform with --dest reads it, for the same folder: + # ROCKNIX's row added roms/bios to a --dest the line mode kept. + choice = card_choice(system, plat, state.dest_given) + if choice.refused: + _card_status(state, choice, given=True) + state.platform, state.card_bound = "", None + return [] + return _choose(state, choice.plat, choice.folder) if state.card_bound: # On the device itself, the folder detection found there: a - # KNULLI was asked for its own card. - found = next((path for name, path, _label in state.setups if name == plat - and detected_os(name, path) == state.card_bound[0]), None) + # KNULLI was asked for its own card. A card in a reader is + # looked for, as any card is. + found = _device_setup(state, plat, state.card_bound[0]) return _open_folder_screen(state, found, "found" if found else "usual") # The folder detected here before the built-in one: the built-in # default was offered as where RetroArch reads, when the - # retroarch.cfg found here names another. - found = next((path for plat, path, _label in state.setups if plat == row.value), None) + # retroarch.cfg found here names another. A card in a reader is + # its own system's, never this computer's RetroArch. + found = next((path for plat, path, _label in state.setups if plat == row.value + and (plat, path) not in state.mounted_setups), None) if found is not None: return _open_folder_screen(state, found, "found") return _open_folder_screen(state, _default_dest(state.os_type, row.value)) @@ -12002,6 +12286,15 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: state.browse_for = "dest" _push(state, "browse") _browse_to(state, _nearest_folder(state.folder_default or state.home)) + if _tui_card_bound(state) and state.folder_kind != "found": + # 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()] + if drives: + state.focus["browse"] = drives[0] + state.status = "Choose the card below, or open the folder that holds it." return [] state.path_text = str(state.folder_default or "") state.path_selected = bool(state.path_text) @@ -12074,7 +12367,15 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if action == "system" and not state.ticked: # Nothing ticked: A takes the system in focus, and only it. state.ticked = {row.value} - return _begin_install(state) + # Continue, never Install: with the ticks unchanged A here + # installed 6.6 GB at once, offline too where the plan offered + # no Install. The confirm screen counts the choice and waits. + if state.systems and not state.ticked: + state.status = "No system is ticked: tick one, or choose All." + return [] + state.must_confirm = True + _push(state, "confirm") + return _start_run(state, "confirm") return [] if screen == "options": return _option(state, action, 0) @@ -12205,6 +12506,74 @@ def _setup_matches(plat: str, systems: list[str], needle: str) -> list[str] | No return devices + named or None +def _system_matches(name: str, needle: str) -> list[str] | None: + """What the filter names for a card system: [] when its name does, the + devices that run it that match, None when nothing does.""" + if not needle or _matches(name, needle): + return [] + needle = _device_needle(needle) + return [device for device in _SETUP_DEVICES.get(name, ()) + if _matches(device, needle)] or None + + +def _comma_forms(names: list[str]) -> tuple: + """Shorter forms of a list for a narrow screen, each cut at a comma: + "Miyoo Mini, +2" where "Miyoo ...s" said nothing.""" + return tuple(", ".join(names[:keep]) + f", +{len(names) - keep}" + for keep in range(len(names) - 1, 0, -1)) + + +def _device_setup(state: TuiState, plat: str, system: str) -> Path | None: + """The folder detection found for system on this machine itself, never + a card mounted in a reader: on a computer a card is looked for.""" + return next((path for name, path, _label in state.setups if name == plat + and (name, path) not in state.mounted_setups + and detected_os(name, path) == system), None) + + +def _platform_rows(state: TuiState) -> list[TuiRow]: + """The setup list: each platform, then each system that runs from its + SD card, with what the filter named. + + 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. + """ + needle = state.filters.get("platforms", "") + setups = card_setups() + cards = {setup.name for setup in setups} + systems = _systems_reading() + rows = [_filter_row(state)] + for plat in AVAILABLE_PLATFORMS: + others = [name for name in systems.get(plat, []) if name not in cards] + named = _setup_matches(plat, others, needle) + if named is None: + continue + setup = card_setup(plat, plat) + 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()): + if setup.name == display_name(setup.plat): + continue + named = _system_matches(setup.name, needle) + if named is None: + continue + 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 + + 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 @@ -12386,9 +12755,7 @@ def _path_key(state: TuiState, key: TuiKey) -> list[tuple]: if closed is not None: state.status = _closed_status(state, closed) return [] - refused = _card_refusal(state, path) - if refused: - state.status = refused + if _card_refusal(state, path): return [] state.stack.pop() return _choose_card(state, path) @@ -12440,6 +12807,10 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: if state.app_running: # The app is being written to the card: it finishes first. return [] + if not (key.name == "f1" or (key.name == "char" and key.text == "?") + and state.screen != "path"): + # The help says it in full: the status is the short form. + state.status_more = "" state.status = "" screen = state.screen if screen == "path": @@ -12754,6 +13125,10 @@ def _route_hint(state: TuiState, parts: tuple) -> tuple: return tuple(named) +# Shorter verbs for a narrow bar, tried before an entry is dropped. +_SHORT_VERBS = ((" Only this system", " Only this"), (" First system", " First")) + + def _hint(width: int, *parts: str) -> str: """The keys that matter now, short names first given up, then whole entries from the right.""" @@ -12766,6 +13141,10 @@ def _hint(width: int, *parts: str) -> str: if part.startswith(long_name): part = short_name + part[len(long_name):] short.append(part) + if cell_width(" ".join(short)) > width: + # A verb shortened before an entry is given up: Back went first. + for verb, brief in _SHORT_VERBS: + short = [part.replace(verb, brief) for part in short] text = "" for part in short: longer = f"{text} {part}" if text else part @@ -12788,7 +13167,7 @@ _ROW_VERBS = { "jobs": "Change", "retry": "Try again", "back": "Back", "stay": "Stay", "fromcard": "Choose", "from": "Add a folder", "misplaced": "Show", "zip": "Copy from it", "unreadable": "Show", "exit": "Exit", "addapp": "Put it on the card", - "cardsystem": "Choose", "rescan": "Look again", + "cardsystem": "Choose", "rescan": "Look again", "elsewhere": "Choose", } @@ -12985,8 +13364,11 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: else f"{where}, {fetch} to download ({format_size(fetch_bytes)})" if fetch else f"{where}, nothing to download") elif need and fetch and offline: - headline = (f"{_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the " - "internet, which --offline does not use") + headline = _plural(fetch, "file") + elif need and fetch and LOCAL.pack is not None and copied: + # One count: "21 files to install from the pack" above "23 to + # install", and the result said 23 came from the pack, then 21. + headline = f"{_plural(need, 'file')} to install, {format_size(check['bytes_needed'])}" elif need and fetch and LOCAL.pack is not None: # Nothing comes from the network: "to download" on a handheld with # no Wi-Fi read as a failure on its way. @@ -13000,7 +13382,14 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: # beside "6.6 GB" said nothing of what they weigh. data_size = (f" ({format_size(check['data_bytes'])})" if data and "data_bytes" in check else "") - if need: + if need and fetch and offline and not elsewhere: + # The data files need the internet too: written after the reason, + # they read as if they were on hand. + both = fetch + data > 1 + headline += ((f" and {_plural(data, 'data file')}{data_size}" if data else "") + + f" {'need' if both else 'needs'} the internet, which --offline does " + "not use") + elif need: if data: headline += f", and {_plural(data, 'data file')}{data_size}" if not need: @@ -13021,14 +13410,17 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: counts = (f"{check['present']} in place, {check['missing']} missing, " f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") lines.append(counts) - if copied and fetch: + if copied and fetch and LOCAL.pack is not None: + lines.append(f"{fetch} from the pack, {len(copied)} copied from this machine") + elif copied and fetch and not offline: # 47 to download above 49 missing and 1 wrong copy: the 3 found on # this machine are the difference, on a line of its own ("0 wrong - # copies: 2 of them" read as two of the zero). - lines.append(f"{need} to install: {len(copied)} copied from this machine, {fetch} " - + ("from the pack" if LOCAL.pack is not None else "downloaded")) + # copies: 2 of them" read as two of the zero). Offline the headline + # says it: "3 downloaded" sat under "3 need the internet". + lines.append(f"{need} to install: {len(copied)} copied from this machine, " + f"{fetch} downloaded") after_counts = len(lines) - saves =(state.saves if check is state.check + saves = (state.saves if check is state.check else state.dry_saves if check is state.dry else None) saves_lines = _saves_lines(state, saves) lines += [_styled(line, "path" if index else "") for index, line in enumerate(saves_lines)] @@ -13041,8 +13433,9 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if check["bytes_needed"] > free: lines.append(f"Not enough free space: {format_size(check['bytes_needed'])} " "needed. Free some, or choose another folder.") - if wrong: + 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'} " f"{'is' if wrong == 1 else 'are'} replaced, the old " f"{'one' if wrong == 1 else 'ones'} kept in a backup folder.") @@ -13072,7 +13465,10 @@ 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).") - return lines[:2] + offline_lines + lines[2:] + # 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 + return lines[:at] + offline_lines + lines[at:] def _saves_lines(state: TuiState, saves: dict | None) -> list[str]: @@ -13148,16 +13544,25 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: before = [f"{bound[0]} reads its BIOS files from:", str(state.folder_default)] elif bound: title = "Where is the SD card?" - before = [f"{bound[0]} keeps its BIOS files on its SD card. Choose the card's " - "root folder" + setup = card_setup(bound[0], state.platform) + where = (f"on its SD card, in its {bound[1]} folder" if bound[1] + else "at the root of its SD card") + before = [f"{bound[0]} keeps its BIOS files {where}. Choose the card's top folder" + (", the one holding .system." if state.platform in CARD_PLATFORMS else ".")] + known = setup is None or setup.known() + if not known: + # Its own files are on a part of the card a computer does not + # open: the drives no other system claims are offered. + before.append(f"RetroBIOS cannot tell {_a(bound[0])} card by its files: " + "check that it is the right card.") if state.folder_default is not None: # Use this folder takes it: named, not left to guess. before += [f"The BIOS folder chosen for {bound[0]}:", str(state.folder_default)] if any(row.action == "rescan" for row in tui_rows(state)): - before.append(f"No {bound[0]} card is mounted. Insert it, then choose " - "Look again.") + before.append((f"No {bound[0]} card is mounted." if known + else "No card or drive is mounted.") + + " Insert it, then choose Look again.") elif state.folder_default is not None: title = "Which folder?" lead = {"found": f"{name} reads its BIOS files from:", @@ -13170,15 +13575,21 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: else: title = "Which folder?" before = [f"No usual {name} folder here: browse for it or type it."] - after = [state.status] if state.status else [] - return (title, _compose(state, width, height, before, tui_rows(state), after, + return (title, _compose(state, width, height, before, tui_rows(state), _status_after(state), pinned=state.refused), ("A/Enter Choose", "B/Esc Back", "? Help")) +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 []) + + def _view_browse(state: TuiState, width: int, height: int) -> tuple: where = "Drives" if state.browse_dir is None else middle(str(state.browse_dir), width) - after = [state.status] if state.status else [] + after = _status_after(state) if state.browse_for == "from": after.insert(0, "Choose the old card, a backup folder or a ZIP: its BIOS files " "are copied, each one checked, nothing on it is changed.") @@ -13195,11 +13606,20 @@ def _view_path(state: TuiState, width: int, height: int) -> tuple: text = state.path_text + "_" if cell_width(text) > width - 2: text = "..." + fit(text[::-1], width - 5)[::-1] - lines = [f"The folder for {display_name(state.platform)}:", _styled(f"> {text}", "focus"), - "", ("Typing or pasting replaces it. " if state.path_selected else "") - + "Ctrl-U clears. Quotes are removed, ~ and variables are expanded."] + bound = _tui_card_bound(state) + on_card = bound is not None and state.folder_kind != "found" + # Read as the card's top folder, and said so: "The folder for + # RetroArch" asked for Onion's card. + prompt = (f"The top folder of the {bound[0]} SD card:" if on_card + else f"The folder for {display_name(state.platform)}:") + lines = [prompt, _styled(f"> {text}", "focus"), ""] if state.status: - lines.append(state.status) + # Said before the hint, which it replaces: at 40x12 the refusal + # was drawn below the last line and Enter seemed to do nothing. + lines += _status_after(state) + else: + lines.append(("Typing or pasting replaces it. " if state.path_selected else "") + + "Ctrl-U clears. Quotes are removed, ~ and variables are expanded.") return ("Type a path", _compose(state, width, height, lines, []), ("Enter Use this folder", "Esc Back", "Ctrl-U Clear")) @@ -13216,7 +13636,7 @@ def _view_create(state: TuiState, width: int, height: int) -> tuple: def _view_cardroot(state: TuiState, width: int, height: int) -> tuple: name = display_name(state.facts.get("card", "")) - before = [f"{state.dest} is inside a {name} card. {name} reads its BIOS files under " + before = [f"{state.dest} is inside {_a(name)} card. {name} reads its BIOS files under " f"the card's root: {state.facts.get('card_root', '')}"] return ("Use the card root?", _compose(state, width, height, before, tui_rows(state)), ("A/Enter Choose", "B/Esc Back")) @@ -13254,7 +13674,16 @@ def _choice_line(state: TuiState) -> list[str]: def _view_plan(state: TuiState, width: int, height: int) -> tuple: check = state.check or {} lines = _plan_lines(check, state.dest, state) - lines[2:2] = _choice_line(state) + choice = _choice_line(state) + if choice and state.systems and check is state.check and not _offline_only(): + # The headline counts the systems ticked, as the list just did: at + # 40x12 the line below it was dropped, and the plan showed the + # total of all of them. + chosen = _ticked_files(state) + lines[0] = _styled(f"About {_plural(len(chosen), 'file')}, " + 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.status: lines.append(state.status) rows = tui_rows(state) @@ -13315,7 +13744,14 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple: footer.insert(0, OTHER_HELP) elif "shared" in focused.right: count = _shared_needed(state)[focused.value][0] - footer.insert(0, f"+{count} shared: counted once") + # "+19" read as 19 more on top of a system whose 19 are all shared. + footer.insert(0, f"+{count} shared: counted once" if "all shared" not in focused.right + else "Shared, counted once" if count == 1 + else f"All {count} shared, counted once") + elif _shared_needed(state): + # Its line kept when the focus leaves a shared system: the page + # held 4, 3 and 5 systems as the note came and went. + footer.insert(0, " ") needle = state.filters.get("systems", "") after = [f"No system matches '{needle}'."] if len(rows) == 3 and needle else [] if state.status: @@ -13426,8 +13862,14 @@ def _view_confirm(state: TuiState, width: int, height: int) -> tuple: # Why this screen: the numbers above are counted again for the # systems and options chosen, the last look before anything is # written. - lines = ["Counted again for your choices. Nothing is written before Install.", - *_plan_lines(state.dry, state.dest, state)] + lines = _plan_lines(state.dry, state.dest, state) + if any(row.action == "install" for row in tui_rows(state)): + lines.insert(0, "Counted again for your choices. Nothing is written before " + "Install.") + if not any(row.action == "install" for row in tui_rows(state)) and state.dry is not None: + # Back alone: one Back on the bar, and no word of an Install. + return ("Confirm", _compose(state, width, height, lines, tui_rows(state)), + ("A/Enter Back", "? Help")) return ("Confirm", _compose(state, width, height, lines, tui_rows(state)), ("A/Enter Install", "B/Esc Back", "? Help")) @@ -13610,7 +14052,10 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: head = f"The installer stopped with code {state.code}." elif installed: title = "Done" - source = " from the pack" if LOCAL.pack is not None else "" + # With the sources line below, the count alone: "23 installed from + # the pack" sat above "21 from the pack". + source = (" from the pack" if LOCAL.pack is not None + and not _tui_sources(state, installed, 0) else "") head = f"{_plural(installed, 'file')} installed{source}." if up_to_date: head += f" {_plural(up_to_date, 'file')} already in place." @@ -13844,6 +14289,8 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple: else f"{key} {what}" for key, what in keys] if state.screen in ("setup", "plan", "browse", "folder") and state.dest is not None: lines += ["", f"BIOS folder: {state.dest}"] + if state.status_more: + lines += ["", state.status_more] return ("Keys", _compose(state, width, height, lines, []), ("Any key closes this help",)) @@ -13966,6 +14413,7 @@ def tui_start( for card, mount in cards: if card and (card[0], card[1]) not in found and card[2] not in here: _DETECTED_OS.setdefault((card[0], card[1]), card[2]) + state.mounted_setups.add((card[0], card[1])) state.setups.append((card[0], card[1], f"{card[2]} SD card ({_mount_name(mount)})")) state.setups = tell_apart(state.setups) @@ -13978,13 +14426,13 @@ 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 or plat in CARD_PLATFORMS: + if state.card_bound is None: return state, _choose(state, plat, dest) - # Read as the line mode reads --dest: a muOS card named for Onion - # gets MUOS/bios, a folder that is no Onion card is refused. + # Read as the line mode reads --dest: an Onion card's root gets its + # BIOS folder, another system's card is refused. choice = card_choice(args.platform, plat, dest) if choice.refused: - state.status = f"{choice.refused}. Choose the root of the {state.card_bound[0]} card." + _card_status(state, choice) return state, _open_folder_screen(state, None) return state, _choose(state, choice.plat, choice.folder) if plat: @@ -14628,6 +15076,7 @@ def _tui_startup(args: argparse.Namespace) -> tuple[TuiState, list[tuple]] | Non os_type = _INVOCATION.os_type or detect_os() if os_type == "wsl": _enter_wsl() + _wsl_dest(args) # The --from folders are read by each check, not here: indexing a large # card held the first frame back. Only whether each can be read is asked. folders = getattr(args, "from") or [] diff --git a/install.sh b/install.sh index eeee0e727..4d116b4c2 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="b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169" +DEFAULT_INSTALL_SHA256="aebd38c69bc524b655e592ae0407ec6095696b846370e2c726275db8bfd050f4" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index fb20109e6..bc0d09858 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": 650926, - "sha256": "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169", + "size": 673355, + "sha256": "aebd38c69bc524b655e592ae0407ec6095696b846370e2c726275db8bfd050f4", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 703475, - "sha256": "1928a1ee174ab57d2890fa7d668a34fa996637669446bb6ad4329fa0fc3d76dc" + "size": 725904, + "sha256": "ec454bce0a07adfd46f7791804c18e69d568b294c2d0f224541887fc2431ba52" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 650926, - "sha256": "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169", + "size": 673355, + "sha256": "aebd38c69bc524b655e592ae0407ec6095696b846370e2c726275db8bfd050f4", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 650926, - "sha256": "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169", + "size": 673355, + "sha256": "aebd38c69bc524b655e592ae0407ec6095696b846370e2c726275db8bfd050f4", "executable": false }, { diff --git a/tests/fixtures/tui/error-120x40.txt b/tests/fixtures/tui/error-120x40.txt index 5e8181426..8b5ba15de 100644 --- a/tests/fixtures/tui/error-120x40.txt +++ b/tests/fixtures/tui/error-120x40.txt @@ -37,4 +37,4 @@ - A/Enter Back B/Esc Back + A/Enter Choose B/Esc Back diff --git a/tests/fixtures/tui/error-40x12.txt b/tests/fixtures/tui/error-40x12.txt index 73f17a02a..224cf89d2 100644 --- a/tests/fixtures/tui/error-40x12.txt +++ b/tests/fixtures/tui/error-40x12.txt @@ -9,4 +9,4 @@ retroarch.json > Choose another setup or folder Quit - A/Enter Back B/Esc Back + A/Enter Choose B/Esc Back diff --git a/tests/fixtures/tui/error-40x15.txt b/tests/fixtures/tui/error-40x15.txt index 37d3614b8..df4e6f1c3 100644 --- a/tests/fixtures/tui/error-40x15.txt +++ b/tests/fixtures/tui/error-40x15.txt @@ -12,4 +12,4 @@ Quit - A/Enter Back B/Esc Back + A/Enter Choose B/Esc Back diff --git a/tests/fixtures/tui/error-80x24.txt b/tests/fixtures/tui/error-80x24.txt index bed745e39..b9103cfa2 100644 --- a/tests/fixtures/tui/error-80x24.txt +++ b/tests/fixtures/tui/error-80x24.txt @@ -21,4 +21,4 @@ - A/Enter Back B/Esc Back + A/Enter Choose B/Esc Back diff --git a/tests/fixtures/tui/folder-card-120x40.txt b/tests/fixtures/tui/folder-card-120x40.txt index cc6fe0e7a..b1104649b 100644 --- a/tests/fixtures/tui/folder-card-120x40.txt +++ b/tests/fixtures/tui/folder-card-120x40.txt @@ -1,7 +1,7 @@ RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the one holding - .system. + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top folder, the one + holding .system. No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder diff --git a/tests/fixtures/tui/folder-card-40x12.txt b/tests/fixtures/tui/folder-card-40x12.txt index 685c39f1c..8a348dfe2 100644 --- a/tests/fixtures/tui/folder-card-40x12.txt +++ b/tests/fixtures/tui/folder-card-40x12.txt @@ -1,11 +1,11 @@ RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. - > Browse for a folder Type a path Look again diff --git a/tests/fixtures/tui/folder-card-40x15.txt b/tests/fixtures/tui/folder-card-40x15.txt index 07df2e20b..c791e4241 100644 --- a/tests/fixtures/tui/folder-card-40x15.txt +++ b/tests/fixtures/tui/folder-card-40x15.txt @@ -1,8 +1,9 @@ RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. @@ -11,5 +12,4 @@ Look again - A/Enter Browse B/Esc Back ? Help diff --git a/tests/fixtures/tui/folder-card-80x24.txt b/tests/fixtures/tui/folder-card-80x24.txt index 29ccce655..a66126046 100644 --- a/tests/fixtures/tui/folder-card-80x24.txt +++ b/tests/fixtures/tui/folder-card-80x24.txt @@ -1,7 +1,7 @@ RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the - one holding .system. + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top + folder, the one holding .system. No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder diff --git a/tests/fixtures/tui/platforms-120x40.txt b/tests/fixtures/tui/platforms-120x40.txt index 2641ecbf6..066b357cd 100644 --- a/tests/fixtures/tui/platforms-120x40.txt +++ b/tests/fixtures/tui/platforms-120x40.txt @@ -3,38 +3,38 @@ Choose the setup that will use the BIOS files. Filter (/ to type) - > RetroArch muOS, JELOS, KOLOS, Uno...Anbernic stock firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie AmberELEC PAN4ELEC EmuELEC - KNULLI - MinUI - NextUI - MyMinUI - - - - - - - - - - - - - - - - + KNULLI, SD card + MinUI, SD card + NextUI, SD card + MyMinUI, SD card + Allium, SD card RetroArch files + 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 + 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 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 d012896fb..08d03c876 100644 --- a/tests/fixtures/tui/platforms-40x12.txt +++ b/tests/fixtures/tui/platforms-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: Which setup? Filter (/ to type) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - 1-7 of 18 + 1-7 of 34 A Choose B Back / Filter ? Help diff --git a/tests/fixtures/tui/platforms-40x15.txt b/tests/fixtures/tui/platforms-40x15.txt index a72ff3d88..f41c417a0 100644 --- a/tests/fixtures/tui/platforms-40x15.txt +++ b/tests/fixtures/tui/platforms-40x15.txt @@ -1,15 +1,15 @@ RetroBIOS: Which setup? Filter (/ to type) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk - 1-10 of 18 + 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 e1769a0a1..1f0f566b1 100644 --- a/tests/fixtures/tui/platforms-80x24.txt +++ b/tests/fixtures/tui/platforms-80x24.txt @@ -1,24 +1,24 @@ RetroBIOS: Which setup? - Choose the setup that will use the BIOS files. - Filter (/ to type) - > RetroArch muOS, JELOS, KOLOS...nic stock firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie AmberELEC PAN4ELEC EmuELEC - KNULLI - MinUI - NextUI - MyMinUI + KNULLI, SD card + MinUI, SD card + NextUI, SD card + MyMinUI, SD card + Allium, SD card RetroArch files + 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 8e345f3e6..02b389947 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -56,55 +56,55 @@ RetroBIOS: Which setup? Filter (with a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - 1-7 of 18 + 1-7 of 34 A Choose B Back === platforms 40x15 RetroBIOS: Which setup? Filter (with a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk - 1-10 of 18 + 1-10 of 34 A Choose B Back === platforms 80x24 RetroBIOS: Which setup? - Choose the setup that will use the BIOS files. - Filter (with a keyboard) - > RetroArch muOS, JELOS, KOLOS...nic stock firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie AmberELEC PAN4ELEC EmuELEC - KNULLI - MinUI - NextUI - MyMinUI + KNULLI, SD card + MinUI, SD card + NextUI, SD card + MyMinUI, SD card + Allium, SD card RetroArch files + 1-19 of 34 A Choose B Back === folder 40x12 RetroBIOS: Which folder? @@ -163,12 +163,12 @@ === folder-card 40x12 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. - > Browse for a folder Type a path Look again @@ -176,9 +176,10 @@ === folder-card 40x15 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. @@ -187,13 +188,12 @@ Look again - A Browse B Back === folder-card 80x24 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the - one holding .system. + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top + folder, the one holding .system. No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder @@ -1036,7 +1036,7 @@ retroarch.json > Choose another setup or folder Quit - A Back B Back + A Choose B Back === error 40x15 RetroBIOS: Something went wrong @@ -1052,7 +1052,7 @@ Quit - A Back B Back + A Choose B Back === error 80x24 RetroBIOS: Something went wrong @@ -1077,7 +1077,7 @@ - A Back B Back + A Choose B Back === quit 40x12 RetroBIOS: Quit? diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index fb8dbe626..9d7abb3ac 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -56,55 +56,55 @@ RetroBIOS: Which setup? Filter (with a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - 1-7 of 18 + 1-7 of 34 A Choose B Back === platforms 40x15 RetroBIOS: Which setup? Filter (with a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk - 1-10 of 18 + 1-10 of 34 A Choose B Back === platforms 80x24 RetroBIOS: Which setup? - Choose the setup that will use the BIOS files. - Filter (with a keyboard) - > RetroArch muOS, JELOS, KOLOS...nic stock firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie AmberELEC PAN4ELEC EmuELEC - KNULLI - MinUI - NextUI - MyMinUI + KNULLI, SD card + MinUI, SD card + NextUI, SD card + MyMinUI, SD card + Allium, SD card RetroArch files + 1-19 of 34 A Choose B Back === folder 40x12 RetroBIOS: Which folder? @@ -163,12 +163,12 @@ === folder-card 40x12 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. - > Browse for a folder Type a path Look again @@ -176,9 +176,10 @@ === folder-card 40x15 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. @@ -187,13 +188,12 @@ Look again - A Browse B Back === folder-card 80x24 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the - one holding .system. + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top + folder, the one holding .system. No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder @@ -1036,7 +1036,7 @@ retroarch.json > Choose another setup or folder Quit - A Back B Back + A Choose B Back === error 40x15 RetroBIOS: Something went wrong @@ -1052,7 +1052,7 @@ Quit - A Back B Back + A Choose B Back === error 80x24 RetroBIOS: Something went wrong @@ -1077,7 +1077,7 @@ - A Back B Back + A Choose B Back === quit 40x12 RetroBIOS: Quit? diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index fb8dbe626..9d7abb3ac 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -56,55 +56,55 @@ RetroBIOS: Which setup? Filter (with a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - 1-7 of 18 + 1-7 of 34 A Choose B Back === platforms 40x15 RetroBIOS: Which setup? Filter (with a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk - 1-10 of 18 + 1-10 of 34 A Choose B Back === platforms 80x24 RetroBIOS: Which setup? - Choose the setup that will use the BIOS files. - Filter (with a keyboard) - > RetroArch muOS, JELOS, KOLOS...nic stock firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie AmberELEC PAN4ELEC EmuELEC - KNULLI - MinUI - NextUI - MyMinUI + KNULLI, SD card + MinUI, SD card + NextUI, SD card + MyMinUI, SD card + Allium, SD card RetroArch files + 1-19 of 34 A Choose B Back === folder 40x12 RetroBIOS: Which folder? @@ -163,12 +163,12 @@ === folder-card 40x12 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. - > Browse for a folder Type a path Look again @@ -176,9 +176,10 @@ === folder-card 40x15 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. @@ -187,13 +188,12 @@ Look again - A Browse B Back === folder-card 80x24 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the - one holding .system. + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top + folder, the one holding .system. No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder @@ -1036,7 +1036,7 @@ retroarch.json > Choose another setup or folder Quit - A Back B Back + A Choose B Back === error 40x15 RetroBIOS: Something went wrong @@ -1052,7 +1052,7 @@ Quit - A Back B Back + A Choose B Back === error 80x24 RetroBIOS: Something went wrong @@ -1077,7 +1077,7 @@ - A Back B Back + A Choose B Back === quit 40x12 RetroBIOS: Quit? diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index e63c23a8a..c47fe74ab 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -56,55 +56,55 @@ RetroBIOS: Which setup? Filter (/ to type) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - 1-7 of 18 + 1-7 of 34 Enter Choose Esc Back / Filter === platforms 40x15 RetroBIOS: Which setup? Filter (/ to type) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk - 1-10 of 18 + 1-10 of 34 Enter Choose Esc Back / Filter === platforms 80x24 RetroBIOS: Which setup? - Choose the setup that will use the BIOS files. - Filter (/ to type) - > RetroArch muOS, JELOS, KOLOS...nic stock firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie AmberELEC PAN4ELEC EmuELEC - KNULLI - MinUI - NextUI - MyMinUI + KNULLI, SD card + MinUI, SD card + NextUI, SD card + MyMinUI, SD card + Allium, SD card RetroArch files + 1-19 of 34 Enter Choose Esc Back / Filter ? Help === folder 40x12 RetroBIOS: Which folder? @@ -163,12 +163,12 @@ === folder-card 40x12 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. - > Browse for a folder Type a path Look again @@ -176,9 +176,10 @@ === folder-card 40x15 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. @@ -187,13 +188,12 @@ Look again - Enter Browse Esc Back ? Help === folder-card 80x24 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the - one holding .system. + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top + folder, the one holding .system. No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder @@ -982,7 +982,7 @@ retroarch.json > Choose another setup or folder Quit - Enter Back Esc Back + Enter Choose Esc Back === error 40x15 RetroBIOS: Something went wrong @@ -998,7 +998,7 @@ Quit - Enter Back Esc Back + Enter Choose Esc Back === error 80x24 RetroBIOS: Something went wrong @@ -1023,7 +1023,7 @@ - Enter Back Esc Back + Enter Choose Esc Back === quit 40x12 RetroBIOS: Quit? diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 10ea786ff..5c12696fa 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -56,55 +56,55 @@ RetroBIOS: Which setup? Filter (with a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - 1-7 of 18 + 1-7 of 34 A Choose R2 Back === platforms 40x15 RetroBIOS: Which setup? Filter (with a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk - 1-10 of 18 + 1-10 of 34 A Choose R2 Back === platforms 80x24 RetroBIOS: Which setup? - Choose the setup that will use the BIOS files. - Filter (with a keyboard) - > RetroArch muOS, JELOS, KOLOS...nic stock firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie AmberELEC PAN4ELEC EmuELEC - KNULLI - MinUI - NextUI - MyMinUI + KNULLI, SD card + MinUI, SD card + NextUI, SD card + MyMinUI, SD card + Allium, SD card RetroArch files + 1-19 of 34 A Choose R2 Back === folder 40x12 RetroBIOS: Which folder? @@ -163,12 +163,12 @@ === folder-card 40x12 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. - > Browse for a folder Type a path Look again @@ -176,9 +176,10 @@ === folder-card 40x15 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. @@ -187,13 +188,12 @@ Look again - A Browse R2 Back === folder-card 80x24 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the - one holding .system. + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top + folder, the one holding .system. No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder @@ -1036,7 +1036,7 @@ retroarch.json > Choose another setup or folder Quit - A Back R2 Back + A Choose R2 Back === error 40x15 RetroBIOS: Something went wrong @@ -1052,7 +1052,7 @@ Quit - A Back R2 Back + A Choose R2 Back === error 80x24 RetroBIOS: Something went wrong @@ -1077,7 +1077,7 @@ - A Back R2 Back + A Choose R2 Back === quit 40x12 RetroBIOS: Quit? diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 098956fd3..8ac8657a4 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -56,55 +56,55 @@ RetroBIOS: Which setup? Filter (Select shows a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - 1-7 of 18 + 1-7 of 34 Start Choose B Back === platforms 40x15 RetroBIOS: Which setup? Filter (Select shows a keyboard) - > RetroArch muOS, JE...firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk - 1-10 of 18 + 1-10 of 34 Start Choose B Back === platforms 80x24 RetroBIOS: Which setup? - Choose the setup that will use the BIOS files. - Filter (Select shows a keyboard) - > RetroArch muOS, JELOS, KOLOS...nic stock firmware + > RetroArch Batocera Recalbox RetroBat EmuDeck Lakka RetroDECK - ROCKNIX + ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie AmberELEC PAN4ELEC EmuELEC - KNULLI - MinUI - NextUI - MyMinUI + KNULLI, SD card + MinUI, SD card + NextUI, SD card + MyMinUI, SD card + Allium, SD card RetroArch files + 1-19 of 34 Start Choose B Back === folder 40x12 RetroBIOS: Which folder? @@ -163,12 +163,12 @@ === folder-card 40x12 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. - > Browse for a folder Type a path Look again @@ -176,9 +176,10 @@ === folder-card 40x15 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD - card. Choose the card's root folder, - the one holding .system. + NextUI keeps its BIOS files at the + root of its SD card. Choose the + card's top folder, the one holding + .system. No NextUI card is mounted. Insert it, then choose Look again. @@ -187,13 +188,12 @@ Look again - Start Browse B Back === folder-card 80x24 RetroBIOS: Where is the SD card? - NextUI keeps its BIOS files on its SD card. Choose the card's root folder, the - one holding .system. + NextUI keeps its BIOS files at the root of its SD card. Choose the card's top + folder, the one holding .system. No NextUI card is mounted. Insert it, then choose Look again. > Browse for a folder @@ -1036,7 +1036,7 @@ retroarch.json > Choose another setup or folder Quit - Start Back B Back + Start Choose B Back === error 40x15 RetroBIOS: Something went wrong @@ -1052,7 +1052,7 @@ Quit - Start Back B Back + Start Choose B Back === error 80x24 RetroBIOS: Something went wrong @@ -1077,7 +1077,7 @@ - Start Back B Back + Start Choose B Back === quit 40x12 RetroBIOS: Quit? diff --git a/tests/test_install.py b/tests/test_install.py index f064dc032..c31218d2f 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4230,10 +4230,27 @@ class DestAloneFollowsWhatIsFoundHere(unittest.TestCase): unittest.mock.patch("builtins.input", lambda _prompt="": answer), \ contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): try: - return install._platform_for_dest("linux", Path("/media/card"), interactive), out.getvalue() + plat, folder = install._platform_for_dest("linux", Path("/media/card"), + interactive) + self.assertEqual(folder, Path("/media/card")) + return plat, out.getvalue() except SystemExit as exc: return exc, out.getvalue() + def test_a_card_system_chosen_checks_the_folder(self): + """code_audit_13 P2-3: the menu offered no card system, and one + chosen now reads --dest as --platform does.""" + root = Path(_tempdir(self)) + answers = iter(["Onion", "retroarch"]) + out = io.StringIO() + with unittest.mock.patch.object(install, "detect_platforms", return_value=[]), \ + unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \ + contextlib.redirect_stdout(out): + 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()) + def test_nothing_found_is_retroarch_said_aloud(self): plat, out = self._pick([]) self.assertEqual(plat, "retroarch") diff --git a/tests/test_install_from.py b/tests/test_install_from.py index d6bd96476..75651b95a 100644 --- a/tests/test_install_from.py +++ b/tests/test_install_from.py @@ -379,7 +379,7 @@ class MisplacedInTheBiosFolder(Scratch): for name, data in PAYLOAD.items(): self.assertEqual((self.dest / name).read_bytes(), data) self.assertEqual((self.dest / "system" / name).read_bytes(), data) - self.assertIn("4 files fixed from copies already in the BIOS folder.", proc.stdout) + self.assertIn("4 files copied from the wrong place in the BIOS folder.", proc.stdout) self.assertIn("nested folder: it is at system/system/scph5501.bin", proc.stdout) def test_a_pack_extracted_into_a_folder_of_its_own_is_fixed(self): @@ -405,7 +405,7 @@ class MisplacedInTheBiosFolder(Scratch): self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) self.assertEqual((self.dest / "gba_bios.bin").read_bytes(), GBA) self.assertEqual((self.dest / "GBA_BIOS.BIN").read_bytes(), GBA) - self.assertIn("1 file fixed from copies already in the BIOS folder.", proc.stdout) + self.assertIn("1 file copied from the wrong place in the BIOS folder.", proc.stdout) def test_a_file_left_in_its_zip_is_extracted(self): self.complete("gba_bios.bin") @@ -1008,10 +1008,10 @@ class TheSummaryLine(unittest.TestCase): def test_one_file_and_fixes(self): self.assertEqual(install.sources_line([("/old", 1)], fixed=3, downloaded=0, missing=0), - "1 file copied from /old, 3 fixed from copies already in the " + "1 file copied from /old, 3 copied from the wrong place in the " "BIOS folder.") self.assertEqual(install.sources_line([], fixed=2, downloaded=1, missing=0), - "2 files fixed from copies already in the BIOS folder, " + "2 files copied from the wrong place in the BIOS folder, " "1 downloaded.") def test_nothing_copied_is_no_line(self): diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index 8fdc48e7e..459e8218d 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -779,5 +779,69 @@ class ADryRunOpensOnItsTotals(Scratch): self.assertTrue(any("sys00/file0.bin" in line for line in plan)) + +class OfflineLeavesFilesMissing(Scratch): + """code_audit_13 P1-1, P3-7, P3-8: the files --offline leaves out at a + terminal were counted with a "no" to the size question. Once one file + came from this machine, 14,696 missing ended on Done and exit 0.""" + + def outcome(self, **counts) -> tuple[int, str]: + out = io.StringIO() + with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as stop: + install._report_outcome([self.dest], counts.get("installed", 0), 0, + counts.get("errors", 0), 0, False, + sources=counts.get("sources", ""), + offline=counts["offline"]) + return stop.exception.code, out.getvalue() + + def test_some_installed_is_not_done(self): + code, text = self.outcome(installed=4, offline=14696, + sources="4 files copied from old, 14696 still missing.") + self.assertEqual(code, 1) + self.assertNotIn("Done", text) + self.assertIn("Finished with files missing. 4 files installed, 14696 not fetched " + "(--offline).", text) + self.assertIn("14696 still missing", text) + + def test_nothing_installed_is_code_2(self): + code, text = self.outcome(offline=14700) + self.assertEqual(code, 2) + self.assertIn("Nothing installed: 14700 files need the internet, which --offline " + "does not use.", text) + # Nothing was up to date: the line said "0 files already up to date". + self.assertNotIn("up to date", text) + + def test_the_folder_given_is_not_suggested_again(self): + old = self.root / "old" + old.mkdir() + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + install.LOCAL.folders = [install.FolderIndex(old)] + _code, text = self.outcome(offline=3) + self.assertIn(f"They are not in {old}", text) + self.assertNotIn("(--from)", text) + + def test_a_dry_run_offline_says_no_download(self): + proc = self.run_installer("--offline", "--dry-run", "--manifest", str(self.list)) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertNotIn("Would download", proc.stdout) + self.assertIn("4 files (", proc.stdout) + self.assertIn("need the internet, which --offline does not use. Without --offline " + "they would go to", proc.stdout) + + def test_an_archive_not_read_is_only_its_error(self): + listed = manifest() + listed["data_directories"] = [{ + "ref": "bluemsx", "dest": "Machines", "type": "zip", + "url": "https://example.invalid/blueMSX.zip", "systems": ["nintendo-gba"], + "files": {"a.txt": {"sha1": _sha1(b"a"), "size": 1}}, + }] + self.list.write_text(json.dumps(listed)) + self.complete() + proc = self.run_installer("--offline", "--manifest", str(self.list), "--no-input") + self.assertIn("bluemsx: could not read", proc.stderr) + self.assertNotIn("no longer in the source", proc.stdout) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 02d7a0128..e13757994 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -696,7 +696,7 @@ class Flow(unittest.TestCase): self.assertIn("RetroBIOS: No connection", install.tui_view(state, 80, 24)[0]) self.assertEqual(state.screen, "error") self.assertEqual([row.action for row in install.tui_rows(state)], - ["retry", "back", "quit"]) + ["retry", "elsewhere", "quit"]) self.assertEqual(press(state, "enter"), [("fetch", "retroarch")]) install.tui_receive(state, ("manifest_failed", "retroarch", "No connection", ["no"], 1)) press(state, "down", "down", "enter") @@ -1598,8 +1598,9 @@ class AuditRoundTwo(unittest.TestCase): self.assertEqual(state.ticked, ticked) for cols, rows in ((80, 24), (40, 15)): text = "\n".join(install.tui_view(state, cols, rows)) - self.assertIn("3 files to download, 600 B", text) - self.assertIn("For your 1 of 3 systems", text) + # tui_audit_6 P3-4: the headline counts the ticked systems. + self.assertIn("About 1 file, 16.0 KB, for 1 of 3 systems", " ".join(text.split())) + self.assertNotIn("3 files to download", text) def test_p2_4_no_network_is_said_at_once(self): import socket @@ -1622,7 +1623,7 @@ class AuditRoundTwo(unittest.TestCase): press(state, "enter") install.tui_receive(state, (kind, plat, title, lines, code)) self.assertEqual([row.action for row in install.tui_rows(state)], - ["retry", "back", "quit"]) + ["retry", "elsewhere", "quit"]) def test_p2_4_the_clock_says_so(self): import ssl @@ -2043,7 +2044,7 @@ class CopyFromAnOldCard(unittest.TestCase): state.local_dests = {"scph5500.bin": (str(SYSTEM_DIR), "nested folder: bios/a")} state.finished = [("scph5500.bin", True, ""), ("gba_bios.bin", True, "")] text = " ".join(" ".join(install.tui_outcome(state)[1]).split()) - self.assertIn("1 file fixed from copies already in the BIOS folder, 1 downloaded. " + self.assertIn("1 file copied from the wrong place in the BIOS folder, 1 downloaded. " "Your copies in the wrong place were left as they are.", text) def test_a_local_copy_is_no_download_speed(self): @@ -2119,12 +2120,12 @@ class AuditRoundThree(unittest.TestCase): rules = {rule.name: rule for rule in install._card_rules()} self.assertLessEqual({"muOS", "Onion", "spruceOS", "CrossMix-OS", "Allium", "Koriki", "KNULLI", "ROCKNIX"}, set(rules)) - device = {rule.name: rule for rule in install._EMBEDDED_RULES - if rule.values and rule.values[0].startswith(install._CARD_MOUNTS)} - for name, rule in device.items(): - if rule.bios and rule.manifest: - with self.subTest(system=name): - self.assertTrue(rule.bios.endswith("/" + rules[name].bios)) + # Only a marker on the part its BIOS folder is on names the card: + # plumOS's and the Anbernic firmware's are on another part. + for setup in install.card_setups(): + if setup.rules: + with self.subTest(system=setup.name): + self.assertEqual(rules[setup.name].bios, setup.below) self.assertEqual(install.DEFAULT_DESTS["knulli"], Path("/userdata") / "bios") self.assertEqual(install.DEFAULT_DESTS["rocknix"], Path("/storage") / "roms/bios") @@ -2189,7 +2190,8 @@ class AuditRoundThree(unittest.TestCase): root = self.card({"roms/": ""}) proc = install_run("--platform", "muos", "--dest", str(root), "--yes") self.assertEqual(proc.returncode, 1) - self.assertIn(f"{root} is not a muOS card: it holds no MUOS.", proc.stderr) + self.assertIn(f"{root} is not a muOS SD card: no muOS files on it. muOS keeps " + "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. @@ -2210,7 +2212,7 @@ 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 card", err.getvalue()) + self.assertIn("is not a muOS SD card", err.getvalue()) self.assertEqual(install.card_dest("muos", "retroarch", root / "muos" / "BIOS"), ("retroarch", root / "muos" / "BIOS")) @@ -2385,8 +2387,8 @@ class AuditRoundThree(unittest.TestCase): state.summary = {"installed": 4, "up_to_date": 0, "failed": [ {"dest": "fc/disksys.rom", "reason": "HTTP 404"}]} _title, lines = install.tui_outcome(state) - self.assertEqual(lines[1], "2 files copied from OLD, 1 fixed from copies " - "already in the BIOS folder, 1 downloaded, 1 still missing.") + self.assertEqual(lines[1], "2 files copied from OLD, 1 copied from the wrong place " + "in the BIOS folder, 1 downloaded, 1 still missing.") # The line mode's own sentence, from the same counts. self.assertEqual(lines[1], install.sources_line([("OLD", 2)], 1, 1, 1)) @@ -2862,7 +2864,7 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): contextlib.redirect_stdout(io.StringIO()) as out: dest = install.card_install_dest("onion", "retroarch", interactive=True) self.assertEqual(dest, onion / "BIOS") - self.assertIn("Mount the card, then type its root folder.", out.getvalue()) + self.assertIn("Mount the card, then type its top folder.", out.getvalue()) ask.assert_called_once() knulli = self.card({"roms/a": ""}) with mock.patch.object(install, "_mounted_cards", return_value=[]), \ @@ -2892,8 +2894,8 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): dest = install.card_install_dest("onion", "retroarch", interactive=True) self.assertEqual(dest, onion / "BIOS") self.assertEqual(len(asked), 3) - self.assertIn("8 does not exist, or is not a folder. Type the root of the Onion card.", - out.getvalue()) + self.assertIn("8 does not exist, or is not a folder. Type the top folder of the Onion " + "card.", out.getvalue()) self.assertFalse((REPO_ROOT / "tmp" / "no-such").exists()) def test_a_typed_root_that_is_no_card_of_that_system_is_asked_again(self): @@ -2904,7 +2906,8 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): contextlib.redirect_stdout(io.StringIO()) as out: dest = install.card_install_dest("onion", "retroarch", interactive=True) self.assertEqual((dest, len(asked)), (onion / "BIOS", 2)) - self.assertIn(f"{blank} is not a Onion card: it holds no .tmp_update.", out.getvalue()) + self.assertIn(f"{blank} is not an Onion SD card: no Onion files on it. Onion keeps " + ".tmp_update at the root of its card.", out.getvalue()) def test_a_minui_root_is_a_minui_card(self): blank = self.card({"roms/a": ""}) @@ -2915,31 +2918,45 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): dest = install.card_install_dest("nextui", "nextui", interactive=True) self.assertEqual((dest, len(asked)), (minui, 3)) self.assertIn(f"is inside a NextUI card, whose root is {minui}", out.getvalue()) - self.assertIn("holds no .system/version.txt: it is not a NextUI card", out.getvalue()) + self.assertIn("is not a NextUI SD card: no NextUI files on it. NextUI keeps " + ".system/version.txt at the root of its card.", out.getvalue()) def test_the_mounted_drives_are_listed_and_taken_by_number(self): """tui_audit_5 P3-8.4: the screen lists the mounted cards, the - prompt did not.""" + prompt did not. tui_audit_6 P3-8, code_audit_13 P3-6: it listed + every drive, a muOS card beside Onion's question, and the muOS card + taken by number made the run a muOS install.""" muos, blank = self.card({"MUOS/bios/x": ""}), self.card({"roms/a": ""}) - patcher, _asked = self.ask("2") - with mock.patch.object(install, "_mounted_cards", return_value=[muos, blank]), \ + first, second = (self.card({"system/batocera.conf": "system.hostname=KNULLI\n"}) + for _card in range(2)) + patcher, asked = self.ask("3", "2") + with mock.patch.object(install, "_mounted_cards", + return_value=[muos, first, blank, second]), \ patcher, contextlib.redirect_stdout(io.StringIO()) as out: dest = install.card_install_dest("knulli", "knulli", interactive=True) - self.assertEqual(dest, blank / "bios") - self.assertIn(f"2) {blank} (card or drive)", out.getvalue()) - self.assertIn(f"1) {muos} (muOS card)", out.getvalue()) + self.assertEqual((dest, len(asked)), (second / "bios", 2)) + self.assertIn(f"1) {first.name} (KNULLI card, {first})", out.getvalue()) + self.assertIn(f"2) {second.name} (KNULLI card, {second})", out.getvalue()) + self.assertNotIn(str(muos), out.getvalue()) + self.assertNotIn(str(blank), out.getvalue()) + self.assertIn("Type 1 to 2, or a folder.", out.getvalue()) def test_creating_several_folders_on_the_card_is_asked(self): + """tui_audit_6 P3-8: N to the question ended the whole run where it + meant "not that card".""" root = self.card({"roms/a.txt": ""}) rocknix = root / "R" rocknix.mkdir() - patcher, asked = self.ask(str(rocknix), "n") + card = self.card({"roms/a": ""}) + make_card(card, "ROCKNIX") + patcher, asked = self.ask(str(rocknix), "n", str(card)) with mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ - contextlib.redirect_stdout(io.StringIO()), \ - self.assertRaises(SystemExit) as stop: - install.card_install_dest("rocknix", "rocknix", interactive=True) - self.assertEqual(stop.exception.code, 2) - self.assertIn("[y/N]", asked[-1]) + contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest("rocknix", "rocknix", interactive=True) + self.assertEqual(dest, card / "roms" / "bios") + self.assertIn("[y/N]", asked[1]) + self.assertEqual(asked[2], "Card root (Ctrl+C to stop): ") + self.assertIn("Not created.", out.getvalue()) self.assertFalse((rocknix / "roms").exists()) def test_the_menu_asks_a_card_system_for_its_card(self): @@ -3018,13 +3035,16 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): install._browse_to(state, blank) effects = press(state, "enter") self.assertEqual((effects, state.screen), ([], "browse")) - self.assertIn(f"{blank} is not a Onion card: it holds no .tmp_update.", - state.status) + self.assertIn("Not an Onion SD card: no Onion files on it.", state.status) + self.assertIn(f"{blank} is not an Onion SD card", state.status_more) self.assertIsNone(state.dest) - # A muOS card gets the folder muOS reads, as --dest does. + # tui_audit_6 P3-8: a muOS card made the run a muOS install + # without a word. install._browse_to(state, muos) press(state, "enter") - self.assertEqual(state.dest, muos / "MUOS" / "bios") + self.assertEqual(state.status, "That is a muOS card, not an Onion card. Choose " + "the card's top folder.") + self.assertIsNone(state.dest) def test_a_typed_card_root_is_checked_on_the_path_screen(self): with mock.patch.object(install, "_mounted_cards", return_value=[]), \ @@ -3036,8 +3056,8 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): state.path_text = str(REPO_ROOT / "tmp" / "no-such-card") effects = press(state, "enter") self.assertEqual((effects, state.screen), ([], "path")) - self.assertIn("does not exist, or is not a folder. Choose the root of the " - "ROCKNIX card.", state.status) + self.assertEqual("That folder does not exist. Choose the card's top folder.", + state.status) def test_a_dest_that_is_no_card_of_that_system_is_refused(self): blank = self.card({"roms/a": ""}) @@ -3047,7 +3067,7 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): state, effects = install.tui_start(_args(platform="onion", dest=str(blank)), "linux") self.assertEqual((effects, state.screen), ([], "folder")) - self.assertIn("is not a Onion card", state.status) + self.assertIn("Not an Onion SD card", state.status) def test_change_folder_names_the_folder_it_reuses(self): """code_audit_12 P3-1: Use this folder on the card screen named @@ -3125,14 +3145,22 @@ class TheSetupFilterKnowsDevices(unittest.TestCase): def test_the_right_column_names_what_matched(self): rows = self.rows("miyoo") - retroarch = {row.value: row for row in rows}["retroarch"] - self.assertEqual(retroarch.right, "MiyooCFW") + # tui_audit_6 P1-1, code_audit_13 P2-3: MiyooCFW stayed on the + # RetroArch row, which opened this computer's folder. + self.assertNotIn("retroarch", [row.value for row in rows if row.action == "platform"]) self.assertEqual([row.label for row in rows if row.action == "cardsystem"], - ["spruceOS, SD card", "Onion, SD card", "Allium, SD card", - "Koriki, SD card"]) + ["Allium, SD card", "Koriki, SD card", "MiyooCFW, SD card", + "Onion, SD card", "spruceOS, SD card"]) + spruce = {row.label: row for row in rows}["spruceOS, SD card"] + self.assertEqual(spruce.right, "Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, " + "Miyoo Mini Flip") + # Cut at a comma, never inside a name ("Miyoo ...s"). + self.assertIn("Miyoo A30, Miyoo Flip, +3", spruce.narrow) + line = str(install._row_line(spruce, False, 60)) + self.assertTrue(line.rstrip().endswith(("+1", "+2", "+3", "+4")), line) deck = {row.value: row for row in self.rows("steam deck")} self.assertEqual(deck["retroarch"].right, "Steam Deck") - self.assertIn("muOS", {row.value: row for row in self.rows("")}["retroarch"].right) + self.assertEqual({row.value: row for row in self.rows("")}["retroarch"].right, "") def test_the_handheld_lists_are_the_guides(self): try: @@ -3160,16 +3188,17 @@ class ACardSetupIsOneDesign(unittest.TestCase): return state def test_a_handheld_typed_in_the_filter_asks_for_its_card(self): - for needle in ("onion", "miyoo mini", "rg35xx"): + for needle in ("onion", "miyoo mini", "rg35xx", "r36s", "arkos", "anbernic"): with self.subTest(needle=needle): state = self.platforms(needle) with mock.patch.object(install, "_mounted_cards", return_value=[]): row = install.tui_rows(state)[1] effects = press(state, "enter") text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) - self.assertEqual(row.action, "cardsystem") + plat, name = row.value if row.action == "cardsystem" else (row.value,) * 2 self.assertEqual((effects, state.screen), ([], "folder")) - self.assertEqual(state.card_bound, install.card_bound(row.value[1], "retroarch")) + self.assertIsNotNone(state.card_bound) + self.assertEqual(state.card_bound, install.card_bound(name, plat)) self.assertIn("Where is the SD card?", text) self.assertNotIn("default", [r.action for r in install.tui_rows(state)]) self.assertNotIn(str(SYSTEM_DIR), text) @@ -3313,7 +3342,9 @@ class OneSystemInFewPresses(unittest.TestCase): state = self.long_list("keys") state.stack.append("systems") state.focus["systems"] = 5 - self.assertEqual(press(state, "enter"), [("run", "install")]) + # tui_audit_6 P2-1: Continue opens Confirm, which waits for A. + self.assertEqual(press(state, "enter"), [("run", "confirm")]) + self.assertEqual((state.screen, state.must_confirm), ("confirm", True)) self.assertEqual(len(state.ticked), 66) def test_left_and_right_page_inside_muxterm_only(self): @@ -3499,8 +3530,10 @@ class AuditFive(unittest.TestCase): "reason": "misplaced"}] with mock.patch.object(install.LOCAL, "pack", mock.Mock()): text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) - self.assertIn("1 copied from this machine,", text) - self.assertIn("from the pack", text) + # tui_audit_6 P3-3: one count, "4 files to install" above "3 from + # the pack", where "3 to install from the pack" sat over "4". + self.assertIn("4 files to install, 1.0 MB", text) + self.assertIn("3 from the pack, 1 copied from this machine", text) self.assertNotIn("downloaded", text) self.assertNotIn("of them", text) state.finished = [(FILES[0]["dest"], True, ""), (FILES[1]["dest"], True, "")] @@ -3529,7 +3562,7 @@ class AuditFive(unittest.TestCase): state.stack.append("systems") state.focus["systems"] = 3 lines = install.tui_view(state, 40, 12) - self.assertIn(" +1 shared: counted once", lines) + self.assertIn(" Shared, counted once", lines) def test_the_save_folder_remedy_is_the_same_on_both_screens(self): summary = {"skipped": [{"reason": "no_save_directory", "files": 1}]} @@ -3550,7 +3583,9 @@ class AuditFive(unittest.TestCase): state.focus["systems"] = 3 bar = install.tui_view(state, 40, 15)[-1] self.assertIn("A Tick", bar) - self.assertTrue(bar.strip().startswith("Start Only this system")) + self.assertTrue(bar.strip().startswith("Start Only this")) + # tui_audit_6 P3-9.1: Back was the entry given up at 40 columns. + self.assertIn("R2 Back", bar) def test_muxterm_help_names_no_home_key(self): state = self.long_list() @@ -3573,14 +3608,20 @@ class LineModeOffline(unittest.TestCase): "--dest", str(repo.dest), "--offline"], env) term.wait("q) quit") term.send("\r") - term.close() + code = term.close() out = term.raw.decode("utf-8", "replace") - self.assertIn("--offline: only the files found on this machine are installed.", out) + 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) self.assertIn("5 files need the internet, which --offline does not use.", out) self.assertNotIn("Downloading", out) self.assertNotIn("failed", out.lower()) - self.assertIn("Nothing installed: 5 files not downloaded, which --offline does not do.", - out.replace("\r\n", " ").replace("\n", " ")) + # tui_audit_6 P2-3, code_audit_13 P1-1: nothing installed ended on + # exit 0, where the screen's run gives 2. + self.assertIn("Nothing installed: 5 files need the internet, which --offline does " + "not use.", out.replace("\r\n", " ").replace("\n", " ")) + self.assertNotIn("already up to date", out) + self.assertEqual(code, 2) def test_the_data_files_are_counted_before_the_question(self): files = [{"dest": "a.bin", "size": 1024}] @@ -5030,5 +5071,483 @@ class AFocusesNoSystemFolder(unittest.TestCase): self.assertEqual(row.action, "dir") + +def _card_from_rules(root: Path, setup: install.CardSetup) -> Path: + """A card as setup's system leaves it, written from the rules that + tell it: a test that names each marker by hand misses the next one.""" + root.mkdir(parents=True, exist_ok=True) + if setup.plat in install.CARD_PLATFORMS: + (root / ".system").mkdir(exist_ok=True) + prefix = next(prefix for name, _marker, prefix in install._CARD_RULES + if name == setup.plat) + (root / install.CARD_MARKER).write_text(f"{prefix}20250101\n", encoding="utf-8") + for rule in setup.rules[:1]: + if rule.method == "file_matches": + path, pattern = rule.values + text = re.sub(r"\\(.)", r"\1", pattern.strip("^$").replace(r"\s*", "")) + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_text(text + "\n", encoding="utf-8") + continue + for value in rule.values: + (root / value).parent.mkdir(parents=True, exist_ok=True) + (root / value).write_text("", encoding="utf-8") + return root + + +def _setup_folder(setup: install.CardSetup, card: Path) -> Path: + return card / setup.below if setup.below else card + + +class EveryCardSetupGoesToItsCard(unittest.TestCase): + """code_audit_13 P2-3, tui_audit_6 P1-1: the card setups were fixed one + by one, and UnofficialOS, ArchR, ArkOS, MiyooCFW and the Anbernic + firmware stayed on the RetroArch row, which listed them and opened this + computer's folder. Each system that runs from its SD card, from every + way in, goes to its card.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + self.setups = install.card_setups() + + def test_one_list_holds_every_card_system(self): + names = {setup.name for setup in self.setups} + self.assertLessEqual({rule.name for rule in install._card_rules()}, names) + self.assertLessEqual(set(install._systems_reading()["retroarch"]), names) + self.assertLessEqual({install.display_name(plat) for plat in install.CARD_PLATFORMS}, + names) + self.assertLessEqual({"Onion", "spruceOS", "muOS", "JELOS", "ROCKNIX", "KNULLI", + "ArkOS", "dArkOS", "UnofficialOS", "ArchR", "MiyooCFW", + "Anbernic stock firmware", "Allium", "Koriki", "CrossMix-OS", + "MinUI", "NextUI", "MyMinUI"}, names) + self.assertEqual(len(names), len(self.setups)) + # A system the guide writes is one of them. + try: + import yaml + except ImportError: + return + guides = yaml.safe_load((REPO_ROOT / "platforms" / "_guides.yml").read_text()) + for system in guides["handheld_guide"]["systems"]: + with self.subTest(system=system["name"]): + self.assertIn(system["name"], names) + + def test_its_folder_is_the_one_the_guide_names(self): + try: + import yaml + except ImportError: + self.skipTest("pyyaml is not installed") + guides = yaml.safe_load((REPO_ROOT / "platforms" / "_guides.yml").read_text()) + below = {setup.name: setup.below for setup in self.setups} + for system in guides["handheld_guide"]["systems"]: + folder = re.search(r"`([^`]+)`", system.get("folder", "")) + if folder: + with self.subTest(system=system["name"]): + self.assertTrue(folder.group(1).endswith(below[system["name"]]), + (folder.group(1), below[system["name"]])) + + def platforms(self, needle: str = "") -> install.TuiState: + state = fresh_state() + state.stack = ["setup", "platforms"] + state.filters["platforms"] = needle + return state + + def test_the_setup_list_has_a_row_for_each_without_the_filter(self): + state = self.platforms() + rows = install.tui_rows(state) + self.assertEqual({row.value: row for row in rows if row.action == "platform"}[ + "retroarch"].right, "") + for setup in self.setups: + with self.subTest(system=setup.name): + state = self.platforms() + rows = install.tui_rows(state) + index = next(i for i, row in enumerate(rows) + if row.label == f"{setup.name}, SD card") + state.focus["platforms"] = index + with mock.patch.object(install, "_mounted_cards", return_value=[]): + effects = press(state, "enter") + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertEqual((effects, state.screen), ([], "folder")) + self.assertEqual(state.card_bound, (setup.name, setup.below)) + self.assertIsNone(state.folder_default) + self.assertIn("Where is the SD card?", text) + self.assertNotIn(str(SYSTEM_DIR), text) + + def test_its_name_in_the_filter_finds_its_row(self): + for setup in self.setups: + with self.subTest(system=setup.name): + rows = install.tui_rows(self.platforms(setup.name)) + self.assertIn(f"{setup.name}, SD card", [row.label for row in rows]) + + def test_platform_named_on_a_computer_asks_for_the_card(self): + for setup in self.setups: + with self.subTest(system=setup.name): + plat = install.normalize_platform(setup.name, say=False) + self.assertEqual(plat, setup.plat) + self.assertEqual(install.card_bound(setup.name, plat), + (setup.name, setup.below)) + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit) as stop: + install.card_install_dest(setup.name, plat, interactive=False) + self.assertEqual(stop.exception.code, 1) + self.assertIn(f"{setup.name} keeps its BIOS files on its SD card", + err.getvalue()) + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[ + ("retroarch", SYSTEM_DIR)]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, effects = install.tui_start(_args(platform=setup.name), "linux") + self.assertEqual((effects, state.screen), ([], "folder")) + self.assertEqual(state.card_bound, (setup.name, setup.below)) + self.assertIsNone(state.folder_default) + + def test_the_line_menu_offers_it_and_asks_for_its_card(self): + offered = [name for name, _label in install._line_setups()] + for setup in self.setups: + with self.subTest(system=setup.name): + key = setup.plat if setup.name == install.display_name(setup.plat) \ + else setup.name + self.assertIn(key, offered) + card = _card_from_rules(self.root / f"menu{offered.index(key)}", setup) + with mock.patch.object(install, "_choose_platform", return_value=key), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_ask", return_value=str(card)), \ + contextlib.redirect_stdout(io.StringIO()): + chosen = install._prompt_manual_platform("linux", ask=False) + self.assertEqual(chosen, [(setup.plat, _setup_folder(setup, card))]) + + def test_its_card_gives_one_folder_whichever_way_it_is_named(self): + 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) + with contextlib.redirect_stdout(io.StringIO()): + by_dest = install.card_dest(setup.name, setup.plat, card) + self.assertEqual(by_dest, (setup.plat, want)) + 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=setup.name, dest=str(card)), "linux") + self.assertEqual(state.dest, want) + # --dest alone, then the setup's row: the folder of the + # same --dest (code_audit_13 P2-2). + state, _effects = install.tui_start(_args(dest=str(card)), "linux") + if state.screen == "platforms": + rows = install.tui_rows(state) + label = (f"{setup.name}, SD card") + state.focus["platforms"] = [row.label for row in rows].index(label) + press(state, "enter") + 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) + self.assertEqual((choice.refused, choice.folder), + ("", _setup_folder(setup, card))) + + def test_another_systems_card_is_refused_for_each(self): + """code_audit_13 P2-1, tui_audit_6 P2-5: KNULLI and ROCKNIX took a + muOS or an Onion card, and wrote their layout into it.""" + cards = {} + for name in ("muOS", "Onion", "MinUI"): + setup = next(item for item in self.setups if item.name == name) + cards[name] = _card_from_rules(self.root / name, setup) + for setup in self.setups: + for name, card in cards.items(): + if name == setup.name or (name == "MinUI" + 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) + 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, + card).refused) + with mock.patch.object(install, "_mounted_cards", + return_value=list(cards.values())): + self.assertNotIn(card, install.mounted_cards_of(setup.name, + setup.plat)) + + def test_a_blank_drive_is_refused_where_the_card_always_names_its_system(self): + blank = self.root / "BLANK" + blank.mkdir() + for setup in self.setups: + with self.subTest(system=setup.name): + choice = install.card_root_folder(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: + self.assertEqual((choice.refused, choice.folder), + ("", _setup_folder(setup, blank))) + self.assertTrue(choice.note) + + +class AuditSix(unittest.TestCase): + """tui_audit_6 and code_audit_13: what remains of each finding.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + + def onion(self, name: str = "ONION") -> Path: + card = self.root / name + make_card(card, "Onion") + return card + + def test_continue_never_installs_without_confirm(self): + """P2-1: A on a system, the ticks unchanged, installed at once, + offline too where the plan had no Install.""" + state = checked_state() + press(state, "down", "enter") + self.assertEqual(state.screen, "systems") + state.focus["systems"] = 3 + self.assertEqual(install._primary(state), "A/Enter Continue") + self.assertEqual(press(state, "enter"), [("run", "confirm")]) + self.assertEqual(state.screen, "confirm") + # The confirm run's plan is the same: it still waits for A. + install.tui_receive(state, ("event", state.run_id, dict(CHECK))) + self.assertEqual(install.tui_receive(state, ("ended", state.run_id, 0, [])), []) + self.assertEqual(state.screen, "confirm") + + def test_offline_with_nothing_on_hand_runs_nothing_from_the_list(self): + state = checked_state() + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + state.focus["plan"] = [row.action for row in install.tui_rows(state)].index( + "systems") + press(state, "enter") + state.focus["systems"] = 3 + effects = press(state, "enter") + self.assertEqual(effects, [("run", "confirm")]) + install.tui_receive(state, ("event", state.run_id, dict(CHECK))) + for event in FILES: + install.tui_receive(state, ("event", state.run_id, dict(event))) + install.tui_receive(state, ("ended", state.run_id, 0, [])) + self.assertEqual([row.action for row in install.tui_rows(state)], ["back"]) + view = install.tui_view(state, 40, 12) + text = " ".join(" ".join(view).split()) + self.assertEqual(state.screen, "confirm") + # code_audit_13 P3-3: Back alone, no word of an Install. + self.assertNotIn("before Install", text) + self.assertNotIn("backup folder", text) + self.assertIn("Copy them from your old card", text) + self.assertEqual(view[-1].strip(), "A/Enter Back ? Help") + + def test_a_refusal_shows_at_40x12_on_type_a_path(self): + """P2-2: Enter seemed to do nothing, the refusal drawn below the + last line.""" + blank = self.root / "USBSTICK" + blank.mkdir() + state = fresh_state() + state.stack = ["setup", "folder", "path"] + state.platform, state.card_bound = "retroarch", ("Onion", "BIOS") + state.path_text = str(blank) + press(state, "enter") + self.assertEqual(state.screen, "path") + text = " ".join(" ".join(install.tui_view(state, 40, 12)).split()) + self.assertIn("Not an Onion SD card: no Onion files on it.", text) + self.assertNotIn("Ctrl-U clears", text) + # P3-1: no hidden folder named in the short form, the detail after. + self.assertNotIn(".tmp_update", state.status) + self.assertIn(".tmp_update", state.status_more) + # code_audit_13 P3-5: the prompt asks for the card. + self.assertIn("The top folder of the Onion SD card:", text) + + def test_the_help_says_the_refusal_in_full(self): + state = fresh_state() + state.stack = ["setup", "folder"] + state.platform, state.card_bound = "retroarch", ("Onion", "BIOS") + blank = self.root / "STICK" + blank.mkdir() + with mock.patch.object(install, "_mounted_cards", return_value=[]): + install._choose_card(state, blank) + press(state, "?") + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("STICK is not an Onion SD card: no Onion files on it. Onion keeps " + ".tmp_update at the root of its card.", text) + + def test_wsl_drive_letters_are_taken_as_typed(self): + """P2-4: the screen says D:, and D: typed was refused.""" + with mock.patch.dict(install._WSL, {"active": True}): + for typed in ("D:", "D:\\", "d:\\", "\"D:\\\""): + with self.subTest(typed=typed): + self.assertEqual(install._typed_folder(typed), Path("/mnt/d")) + self.assertEqual(install.typed_path(typed), Path("/mnt/d")) + self.assertEqual(install._typed_folder("E:\\bios"), Path("/mnt/e/bios")) + args = _args(dest=Path("D:\\")) + install._wsl_dest(args) + self.assertEqual(args.dest, Path("/mnt/d")) + card = Path("/mnt/d") + with mock.patch.object(install, "_card_what", return_value="Onion card"): + self.assertEqual(install.card_line(card), "D: (Onion card, /mnt/d)") + self.assertEqual(install._typed_folder("D:"), Path("D:")) + + def test_a_dest_refused_on_the_setup_list_names_dest(self): + """code_audit_13 P3-2: "Choose the root of the Onion card", where + --dest fixes the folder, and the refused system stayed chosen.""" + blank = self.root / "USBSTICK" + blank.mkdir() + 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(dest=str(blank)), "linux") + state.filters["platforms"] = "onion" + state.focus["platforms"] = 1 + self.assertEqual(install.tui_rows(state)[1].label, "Onion, SD card") + self.assertEqual(press(state, "enter"), []) + self.assertEqual(state.screen, "platforms") + self.assertIn("--dest names it: choose another setup.", state.status) + self.assertNotIn("Choose the card's top folder", state.status) + self.assertEqual((state.platform, state.card_bound), ("", None)) + + def test_on_the_device_a_folder_typed_is_taken_as_typed(self): + """code_audit_13 P3-1: a KNULLI got bios added to what was typed.""" + share = self.root / "userdata" / "share" + share.mkdir(parents=True) + state = fresh_state() + state.stack = ["setup", "platforms"] + state.platform, state.card_bound = "knulli", ("KNULLI", "bios") + install._open_folder_screen(state, Path("/userdata/bios"), "found") + state.stack.append("path") + state.path_text = str(share) + text = " ".join(install.tui_view(state, 80, 24)) + self.assertIn("The folder for KNULLI:", text) + with mock.patch.object(install, "_mounted_cards", return_value=[]): + press(state, "enter") + self.assertEqual(state.dest, share) + + def test_a_card_in_a_reader_asks_for_its_card_once(self): + """code_audit_13 P3-9, tui_audit_6 P3-7: "Which folder? Onion reads + its BIOS files from" on a computer, the same card listed twice.""" + first, second = self.onion("D"), self.onion("E") + with mock.patch.object(install, "_mounted_cards", return_value=[first, second]), \ + mock.patch.object(install, "detect_platforms", + return_value=[("retroarch", SYSTEM_DIR)]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(), "linux") + self.assertEqual(len(state.setups), 3) + state.focus["setup"] = len(state.setups) + press(state, "enter") + state.filters["platforms"] = "onion" + state.focus["platforms"] = 1 + press(state, "enter") + rows = install.tui_rows(state) + title = install.tui_view(state, 80, 24)[0] + self.assertIn("Where is the SD card?", title) + self.assertEqual([(row.action, row.value) for row in rows][:2], + [("card", first), ("card", second)]) + self.assertNotIn("default", [row.action for row in rows]) + # The RetroArch row stays this computer's, never the card's folder. + state.stack = ["setup", "platforms"] + state.filters["platforms"] = "" + state.focus["platforms"] = install._platform_focus(state, "retroarch") + with mock.patch.object(install, "_mounted_cards", return_value=[first, second]): + press(state, "enter") + self.assertEqual(state.folder_default, SYSTEM_DIR) + + def test_browsing_for_a_card_starts_on_the_drives(self): + """tui_audit_6 P3-2: the browser opened on the home folder, Use this + folder focused, saying to go back, the cards listed under it.""" + card = self.onion() + state = fresh_state() + state.stack = ["setup", "platforms"] + state.platform, state.card_bound = "retroarch", ("Onion", "BIOS") + with mock.patch.object(install, "_mounted_cards", return_value=[card]): + install._open_folder_screen(state, None) + state.focus["folder"] = [row.action for row in install.tui_rows(state)].index( + "browse") + press(state, "enter") + 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.") + + def test_a_number_outside_the_list_is_said(self): + """tui_audit_6 P3-1: 9 was read as a folder.""" + card, other = self.onion(), self.onion("SECOND") + answers = iter(["9", "0", "2"]) + with mock.patch.object(install, "_mounted_cards", return_value=[card, other]), \ + mock.patch.object(install, "_ask", side_effect=lambda _p: next(answers)), \ + contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest("onion", "retroarch", True) + self.assertEqual(dest, other / "BIOS") + self.assertEqual(out.getvalue().count("Type 1 to 2, or a folder."), 2) + + def test_the_articles_read(self): + self.assertEqual([install._a(name) for name in ("Onion", "Allium", "ArkOS", "muOS", + "KNULLI")], + ["an Onion", "an Allium", "an ArkOS", "a muOS", "a KNULLI"]) + + def test_every_card_row_reads_the_same(self): + """tui_audit_6 P3-6: KNULLI, ROCKNIX and the MinUI family asked for + their card without saying so.""" + state = fresh_state() + state.stack = ["setup", "platforms"] + labels = {row.value: row.label for row in install.tui_rows(state) + if row.action == "platform"} + for plat in ("knulli", "rocknix", "minui", "nextui", "myminui"): + self.assertEqual(labels[plat], f"{install.display_name(plat)}, SD card") + self.assertEqual(labels["retroarch"], "RetroArch") + # On the device itself it is that device's setup, not a card. + share = Path("/userdata/bios") + state.setups.append(("knulli", share, "KNULLI")) + with mock.patch.dict(install._DETECTED_OS, {("knulli", share): "KNULLI"}): + labels = {row.value: row.label for row in install.tui_rows(state) + if row.action == "platform"} + self.assertEqual(labels["knulli"], "KNULLI") + + def test_offline_plan_counts_agree(self): + """code_audit_13 P3-4: "3 downloaded" under "3 need the internet"; + tui_audit_6 P3-9.2: the data files read as if on hand.""" + state = checked_state() + state.found = [{"event": "found", "platform": "retroarch", "dest": FILES[0]["dest"], + "folder": "/media/old", "path": "/media/old/x", "member": "", + "reason": ""}] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertNotIn("downloaded", text) + state.found = [] + state.check = dict(state.check, data_missing=2636, data_bytes=40 * 1024 * 1024) + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("4 files and 2636 data files (40.0 MB) need the internet, which " + "--offline does not use", text) + + def test_the_error_row_and_the_bar_agree(self): + """tui_audit_6 P3-9.3: "Choose another setup or folder" over + "A/Enter Back".""" + state = checked_state() + state.run = "check" + install._fail(state, ["went wrong"], 1) + state.focus["error"] = 0 + self.assertEqual(install.tui_rows(state)[0].label, "Choose another setup or folder") + self.assertEqual(install._primary(state), "A/Enter Choose") + press(state, "enter") + self.assertNotEqual(state.screen, "error") + + def test_a_long_name_keeps_a_size_at_40_columns(self): + """tui_audit_6 P3-5: Nintendo Game Boy Advance lost its size.""" + row = install.TuiRow("system", "[x] Nintendo Game Boy Advance", "1 file, 16.0 KB", + "nintendo-gba", narrow=("16.0 KB", install._compact_size(16384))) + self.assertTrue(str(install._row_line(row, False, 38)).rstrip().endswith("16 KB")) + + def test_the_shared_note_keeps_its_line(self): + """tui_audit_6 P3-5: the page held 4, 3 and 5 systems as the note + came and went, and "+19" read as 19 more.""" + state = OneSystemInFewPresses().long_list("keys") + state.files = [{"event": "file", "dest": "mame.zip", "size": 10, + "systems": ["sys-00", "sys-01"]}, + {"event": "file", "dest": "two.zip", "size": 10, + "systems": ["sys-00", "sys-01"]}] + state.stack.append("systems") + footers = set() + for focus in range(3, 12): + state.focus["systems"] = focus + lines = install.tui_view(state, 40, 12) + footers.add(next(i for i, line in enumerate(lines) if "of 66 systems:" in line)) + self.assertEqual(len(footers), 1, footers) + state.focus["systems"] = 3 + self.assertIn(" +2 shared: counted once", install.tui_view(state, 40, 12)) + + if __name__ == "__main__": unittest.main()