From 44f94ab703589eaff9272a64fa71004ea874ea1d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:04:56 +0100 Subject: [PATCH 1/4] fix: ask a card setup for its own card A card system chosen on a computer, from --platform, the setup list, its filter or the line-mode menu, always asks for its card. The card screen lists only the cards whose files say they run that system, offers Look again, and checks a browsed, typed or --dest folder as --dest is checked. The line-mode Card root prompt lists the mounted drives, asks again on a missing or foreign root, and asks before creating several folders. On the device the detected folder is offered, and the bound card is forgotten when another setup is chosen. --- install.ps1 | 2 +- install.py | 344 ++++++++++++++---- install.sh | 2 +- install/launchers/index.json | 16 +- tests/fixtures/tui/folder-card-120x40.txt | 4 +- tests/fixtures/tui/folder-card-40x12.txt | 6 +- tests/fixtures/tui/folder-card-40x15.txt | 6 +- tests/fixtures/tui/folder-card-80x24.txt | 4 +- tests/fixtures/tui/route-evmapy.txt | 16 +- tests/fixtures/tui/route-gptokeyb.txt | 16 +- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 16 +- tests/fixtures/tui/route-keys.txt | 16 +- tests/fixtures/tui/route-miyoo.txt | 16 +- tests/fixtures/tui/route-muxterm.txt | 16 +- tests/test_install_tui.py | 302 ++++++++++++++- 15 files changed, 629 insertions(+), 153 deletions(-) diff --git a/install.ps1 b/install.ps1 index cb5c5f372..f5e2631ba 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06" + $defaultInstallSha256 = "8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 94048a73e..f9586c597 100644 --- a/install.py +++ b/install.py @@ -2617,11 +2617,12 @@ def did_you_mean( return f"{listing} lists all {len(known)}." -def normalize_platform(name: str) -> str: +def normalize_platform(name: str, say: bool = True) -> str: """Return the canonical platform id for user-supplied input. The site writes MiSTer FPGA and RetroArch: letters and digits decide, - as they do for verify.py and generate_pack.py. + as they do for verify.py and generate_pack.py. say is False where the + name is read again later, which says it once. """ key = _name_key(name) for plat in AVAILABLE_PLATFORMS: @@ -2631,8 +2632,9 @@ def normalize_platform(name: str) -> str: # refused: a system named there stands for the list it gets. for rule in _EMBEDDED_RULES: if key and rule.manifest and key == _name_key(rule.name): - print(f" {rule.name} gets the {display_name(rule.manifest)} file list: " - f"--platform {rule.manifest}.") + if say: + print(f" {rule.name} gets the {display_name(rule.manifest)} file list: " + f"--platform {rule.manifest}.") return rule.manifest print(f"Unknown platform '{name.strip()}'. " + did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms"), @@ -6275,10 +6277,16 @@ def _choose_platform(title: str, default: str = "") -> str: "or q to quit.") -def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]: - """Ask for a platform and a BIOS directory when nothing was detected.""" +def _prompt_manual_platform(os_type: str, ask: bool = True) -> 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))] default_dest = _default_dest(os_type, plat) raw = "" while not raw: @@ -9257,7 +9265,7 @@ def _open_local_sources(args: argparse.Namespace) -> None: + (f", {parts} parts" if parts > 1 or _PART_FILE.match(pack.files[0].name) else "")) spelled = pack.platforms() if args.platform is not None: - plat = normalize_platform(args.platform) + plat = normalize_platform(args.platform, say=False) if spelled and plat not in spelled: print(f"Error: {pack.name} is a pack for " f"{' and '.join(display_name(n) for n in spelled)}, not " @@ -9495,18 +9503,31 @@ def _ends_with(path: Path, relative: str) -> bool: return len(parts) >= len(tail) and parts[len(parts) - len(tail):] == tail -def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: - """The file list and folder for a --dest that names a handheld's card. +class CardChoice(NamedTuple): + """Where the files of a folder named for a card go, and what is said: a + note, or why the folder is not the card of the system named.""" + + plat: str + folder: Path + note: str = "" + refused: str = "" + # The system's folder below its card, and the file that names the card. + below: str = "" + marker: str = "" + + +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 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 on the command line whose card this is not - is refused with the folder it reads. + never reads. A system named whose card this is not is refused with the + folder it reads. """ found = card_system(dest) if found and (not plat or found[0] == plat) and found[1] != dest: - print(f" {found[2]} card: {display_name(found[0])} files, in {found[1]}") - return found[0], found[1] + 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) @@ -9516,23 +9537,48 @@ def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: if rule is None or found: if len({(match.manifest, match.bios) for match in matched}) > 1: names = " and ".join(sorted({match.name for match in matched})) - print(f" {screen_text(str(dest))} holds the files of {names}: installing into " - "it as named. Name the card's BIOS folder with --dest to write there.") - return plat, dest + 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): - return plat, dest + return CardChoice(plat, dest) if any(match.name == rule.name for match in matched): # A card two systems claim, the one named among them. - print(f" {rule.name} card: {display_name(plat)} files, in {dest / rule.bios}") - return plat, dest / rule.bios + 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] - print(f"Error: {dest} is not a {rule.name} card: it holds no {marker}. " - f"{rule.name} reads its BIOS files from {rule.bios} on its card: name the card's " - f"root (the folder holding {marker}) or that folder: --dest /{rule.bios}", - file=sys.stderr) - sys.exit(1) + 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) + + +def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: + """The file list and folder for a --dest that names a handheld's card, + 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}", + file=sys.stderr) + sys.exit(1) + if choice.note: + print(f" {choice.note}") + return choice.plat, choice.folder + + +def card_root_above(dest: Path) -> tuple[str, Path] | None: + """(platform, root) of the MinUI-family card dest is inside, when dest is + a folder below its root and not the root.""" + if card_platform(dest) is not None: + return None + for parent in list(dest.absolute().parents)[:2]: + holder = card_platform(parent) + if holder: + return holder, parent + return None def _check_card_root(plat: str, dest: Path) -> None: @@ -9541,16 +9587,13 @@ def _check_card_root(plat: str, dest: Path) -> None: The file list writes Bios// below --dest: given the card's Bios folder, every file would land in Bios/Bios//, which no core reads. """ + above = card_root_above(dest) + if above: + print(f"Error: {dest} is inside a {display_name(above[0])} card. " + f"Name the card's root: --dest {above[1]}", file=sys.stderr) + sys.exit(1) found = card_platform(dest) - if found is None: - for parent in list(dest.absolute().parents)[:2]: - holder = card_platform(parent) - if holder: - print(f"Error: {dest} is inside a {display_name(holder)} card. " - f"Name the card's root: --dest {parent}", file=sys.stderr) - sys.exit(1) - return - if found != plat: + if found is not None and found != plat: print(f" Note: this card holds {display_name(found)} ({CARD_MARKER}), and the " f"{display_name(plat)} file list was asked for.") @@ -9579,13 +9622,50 @@ def mounted_cards_of(system: str, plat: str) -> list[Path]: if any(rule.name == system for rule in _card_matches(mount))] -def card_install_dest(named: str, plat: str, interactive: bool) -> Path: +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. + + 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. + """ + 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) + if not _is_dir(card): + return CardChoice(plat, root, refused=f"{card} does not exist, or is not a folder") + 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)} " + 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) + + +def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True) -> Path: """The folder for a card-bound system named without --dest, on a machine that is not the device. One mounted card that runs it is taken. At a terminal the card's root is - asked for, never this machine's own folder. Without questions the run - stops on the --dest that names the card. + 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. """ system, below = card_bound(named, plat) or (display_name(plat), "") cards = mounted_cards_of(system, plat) @@ -9606,21 +9686,27 @@ def card_install_dest(named: str, plat: str, interactive: bool) -> Path: 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.") - raw = "" - while not raw: + "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)})") + while True: raw = _ask("Card root (Ctrl+C to stop): ") - root = _typed_folder(raw) - if plat in CARD_PLATFORMS: - _check_card_root(plat, root) - return root - folder = card_dest(named, plat, root)[1] - if folder == root and below and not _ends_with(root, below): - # A KNULLI share or a ROCKNIX card read as a list's name: its - # folder below the root, as the device reads it. - return root / below - return folder + 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) + if choice.refused: + print(f" {choice.refused}. Type the root of the {system} card.") + continue + if choice.note: + print(f" {choice.note}") + _missing_dest(choice.folder, ask, "typed") + return choice.folder def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str: @@ -9759,8 +9845,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: elif bound is not None: # Its files go on its SD card, which is never this machine's # own folder. - platforms = [(args.platform, card_install_dest(named, args.platform, - interactive))] + platforms = [(args.platform, card_install_dest( + named, args.platform, interactive, + not (args.check or args.dry_run or args.yes)))] elif args.platform in PORTABLE_ONLY: # A portable emulator reads the folder beside its executable: # any default would be a folder it never opens. @@ -9828,9 +9915,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for line in _manual_usage_hint(os_type, asked): print(line) sys.exit(1) - platforms = _prompt_manual_platform(os_type) + platforms = _prompt_manual_platform( + os_type, not (args.check or args.dry_run or args.yes)) plat, typed = platforms[0] - if typed != _default_dest(os_type, plat): + # 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") else: @@ -10814,6 +10903,8 @@ class TuiState: # (system, BIOS folder below the card root) when the setup asked for # runs from an SD card: the folder screen offers the mounted cards. self.card_bound: tuple[str, str] | None = None + # The mounted cards of that system, as last looked for. + self.cards: list | None = None # (width and text, lines) of the last pager wrapped. self.pager_cache: tuple | None = None self.path_text: str = "" @@ -11220,18 +11311,41 @@ def tui_rows(state: TuiState) -> list[TuiRow]: rows = [_filter_row(state)] for plat in AVAILABLE_PLATFORMS: named = _setup_matches(plat, systems.get(plat, []), needle) - if named is not None: + 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(named or systems.get(plat, [])), 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 if screen == "folder": rows = [TuiRow("default", "Use this folder")] if state.folder_default else [] - if _tui_card_bound(state): - rows += [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount) - for mount in _mounted_cards()] - return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + bound = _tui_card_bound(state) + if not bound: + return rows + [TuiRow("browse", "Browse for a folder"), + TuiRow("type", "Type a path")] + # Only the cards whose own files say they run the system: A on the + # first mount wrote Onion's layout on a muOS card. + mounts = (state.cards if state.cards is not None + else mounted_cards_of(bound[0], state.platform)) + cards = [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount) + for mount in mounts] + rows += cards + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + # On the device itself its folder is found, and no card is looked for. + here = state.folder_default is not None and state.folder_kind == "found" + return rows + ([] if cards or here else [TuiRow("rescan", "Look again")]) if screen == "browse": return list(state.browse_rows) if screen == "create": @@ -11566,9 +11680,54 @@ def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: return _start_check(state) +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.""" + bound = _tui_card_bound(state) + if bound is None or (state.platform in CARD_PLATFORMS and card_root_above(folder)): + 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 "".""" + 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." + + +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.""" + 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"): + return [] + state.folder_default, state.folder_kind = None, "usual" + return _push(state, "folder") + card = card_system(folder) + 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) + + def _open_folder_screen(state: TuiState, default: Path | None, kind: str = "usual") -> list[tuple]: state.folder_default, state.folder_kind = default, kind + # The cards are looked for when the screen opens and on Look again: a + # card inserted meanwhile moved the rows under the focus. + state.cards = None + bound = _tui_card_bound(state) + if bound: + state.cards = mounted_cards_of(bound[0], state.platform) refused = kind == "chosen" and (state.facts.get("writable") is False or _short_of_space(state, state.check or {})) # A folder just refused as full or read-only: Browse, not Use this @@ -11577,7 +11736,11 @@ def _open_folder_screen(state: TuiState, default: Path | None, def _mount_name(mount: Path) -> str: - """How a person names a mounted card: E:\\ on Windows, its label elsewhere.""" + """How a person names a mounted card: E:\\ on Windows, its label elsewhere. + Under WSL /mnt/d is the Windows D: drive, which "d" did not say.""" + if (_WSL["active"] and mount.parent == Path(_WSL["root"]) and len(mount.name) == 1 + and mount.name.isalpha()): + return f"{mount.name.upper()}:" return mount.name or str(mount) @@ -11740,14 +11903,22 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if action == "another": return _push(state, "platforms", _platform_focus(state, "retroarch")) plat, path, _label = state.setups[row.value] + # A setup found here is its folder: a card looked at and backed + # out of described the RetroArch chosen next as that card. + state.card_bound = None return _choose(state, plat, path) if screen == "platforms": + plat, system = row.value if action == "cardsystem" else (row.value, row.value) + state.platform = plat + state.card_bound = card_bound(system, plat) if state.dest_given is not None: - return _choose(state, row.value, state.dest_given) - state.platform = row.value - state.card_bound = card_bound(row.value, row.value) + return _choose_card(state, state.dest_given) if state.card_bound: - return _open_folder_screen(state, None) + # 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) + 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. @@ -11758,9 +11929,16 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if screen == "folder": if action == "default": return _choose(state, state.platform, state.folder_default) - if action == "card": - below = (_tui_card_bound(state) or ("", ""))[1] - return _choose(state, state.platform, row.value / below if below else row.value) + if action in ("card", "rescan"): + if action == "rescan": + state.cards = mounted_cards_of(_tui_card_bound(state)[0], state.platform) + cards = [index for index, item in enumerate(tui_rows(state)) + if item.action == "card"] + state.focus["folder"] = cards[0] if cards else state.focus.get("folder", 0) + state.status = ("" if cards else f"Still no {_tui_card_bound(state)[0]} card " + "is mounted.") + return [] + return _choose_card(state, row.value) if action == "browse": state.browse_for = "dest" _push(state, "browse") @@ -11784,7 +11962,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: # The check runs again with it: the plan then says what it holds. return _start_check(state) if action == "use": - return _choose(state, state.platform, state.browse_dir) + return _choose_card(state, state.browse_dir) if action == "up" and state.browse_dir is not None: if row.value == state.browse_dir: _browse_to(state, None if os.name == "nt" else state.browse_dir) @@ -12147,8 +12325,12 @@ 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 + return [] state.stack.pop() - return _choose(state, state.platform, path) + return _choose_card(state, path) return [] @@ -12889,12 +13071,22 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: def _view_folder(state: TuiState, width: int, height: int) -> tuple: name = display_name(state.platform) bound = _tui_card_bound(state) - if bound: + if bound and state.folder_default is not None and state.folder_kind == "found": + # On the device itself: its own folder, which is no card to look for. + title = "Which folder?" + 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" + (", the one holding .system." if state.platform in CARD_PLATFORMS else ".")] + 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.") elif state.folder_default is not None: title = "Which folder?" lead = {"found": f"{name} reads its BIOS files from:", @@ -13707,7 +13899,17 @@ def tui_start( if line.strip() and line.strip() not in unsaid] if plat and args.dest is not None: state.stack = [] - return state, _choose(state, plat, Path(args.dest)) + 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: + 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. + 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." + return state, _open_folder_screen(state, None) + return state, _choose(state, choice.plat, choice.folder) if plat: bound = card_bound(args.platform, plat) # A system that runs from its card is its card or the device, never diff --git a/install.sh b/install.sh index 66a5029b2..e13e81dda 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="000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06" +DEFAULT_INSTALL_SHA256="8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index baf3ec412..1d4f2da09 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": 636292, - "sha256": "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06", + "size": 646794, + "sha256": "8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 688841, - "sha256": "65c0ff1debcc384c1a6da526e4c92505a1628036cc4c7f51928d5253fbd07484" + "size": 699343, + "sha256": "42226d45d6a31c86b726b1af321e043f9894f9993d8c6de0f16cc84c04a2a1ed" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 636292, - "sha256": "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06", + "size": 646794, + "sha256": "8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 636292, - "sha256": "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06", + "size": 646794, + "sha256": "8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727", "executable": false }, { diff --git a/tests/fixtures/tui/folder-card-120x40.txt b/tests/fixtures/tui/folder-card-120x40.txt index 8019406eb..cc6fe0e7a 100644 --- a/tests/fixtures/tui/folder-card-120x40.txt +++ b/tests/fixtures/tui/folder-card-120x40.txt @@ -2,11 +2,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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-40x12.txt b/tests/fixtures/tui/folder-card-40x12.txt index e704b9fa0..685c39f1c 100644 --- a/tests/fixtures/tui/folder-card-40x12.txt +++ b/tests/fixtures/tui/folder-card-40x12.txt @@ -3,10 +3,10 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 A/Enter Browse B/Esc Back ? Help diff --git a/tests/fixtures/tui/folder-card-40x15.txt b/tests/fixtures/tui/folder-card-40x15.txt index 9d517731c..07df2e20b 100644 --- a/tests/fixtures/tui/folder-card-40x15.txt +++ b/tests/fixtures/tui/folder-card-40x15.txt @@ -3,12 +3,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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-80x24.txt b/tests/fixtures/tui/folder-card-80x24.txt index 1d8c2ad33..29ccce655 100644 --- a/tests/fixtures/tui/folder-card-80x24.txt +++ b/tests/fixtures/tui/folder-card-80x24.txt @@ -2,11 +2,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 4e6ede706..8e345f3e6 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 A Browse B Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 9eb848541..fb8dbe626 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 A Browse B Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 9eb848541..fb8dbe626 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 A Browse B Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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/route-keys.txt b/tests/fixtures/tui/route-keys.txt index 3d65eeba5..e63c23a8a 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 Enter Browse Esc Back ? Help === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 0453c4cd9..c47dc92a2 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 A Browse R2 Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index f8c4759d4..098956fd3 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -166,12 +166,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 Start Browse B Back === folder-card 40x15 RetroBIOS: Where is the SD card? @@ -179,12 +179,12 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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 @@ -194,11 +194,11 @@ NextUI keeps its BIOS files on its SD card. Choose the card's root 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/test_install_tui.py b/tests/test_install_tui.py index 183d44cef..c2c1ad264 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -40,6 +40,7 @@ sys.path.insert(0, str(REPO_ROOT)) sys.path.insert(0, str(REPO_ROOT / "tests")) import install # noqa: E402 +from test_install_app import make_card # noqa: E402 from test_install_selection import FakeRepository, _QuietHandler # noqa: E402 try: @@ -1294,6 +1295,20 @@ class ControlCharacters(unittest.TestCase): class SmallFixes(unittest.TestCase): + def test_a_system_name_is_said_once_with_a_pack(self): + """tui_audit_5 P3-8.1: "Onion gets the RetroArch file list" was + printed by the pack check, then again where the platform is read.""" + pack = mock.Mock(files=[Path("RetroArch_BIOS_Pack.zip")]) + pack.name = "RetroArch_BIOS_Pack.zip" + pack.platforms.return_value = ["retroarch"] + args = argparse.Namespace(manifest=None, from_pack=Path("p.zip"), platform="onion", + **{"from": None}) + with mock.patch.object(install, "PackSource", return_value=pack), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._open_local_sources(args) + self.assertNotIn("Onion gets", out.getvalue()) + install.LOCAL.reset() + def test_a_command_line_region_the_list_lacks(self): state = checked_state() state.cli_regions = ["europe"] @@ -1727,7 +1742,8 @@ class AuditRoundTwo(unittest.TestCase): state = fresh_state() state.stack = ["platforms"] press(state, "/", "m", "u", "o", "s") - self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["retroarch"]) + self.assertEqual([row.value for row in install.tui_rows(state)][1:], + [("retroarch", "muOS")]) def test_p2_13_a_long_preview_wraps_only_what_is_in_view(self): state = checked_state() @@ -2130,6 +2146,11 @@ class AuditRoundThree(unittest.TestCase): def test_p1_1_a_drive_letter_names_the_card(self): self.assertEqual(install._mount_name(PureWindowsPath("E:\\")), "E:\\") self.assertEqual(install._mount_name(Path("/media/me/MUOS")), "MUOS") + # tui_audit_5 P3-2: under WSL the card screen listed "d (card or drive)". + with mock.patch.dict(install._WSL, {"active": True}): + self.assertEqual(install._mount_name(Path("/mnt/d")), "D:") + self.assertEqual(install._mount_name(Path("/mnt/wslg")), "wslg") + self.assertEqual(install._mount_name(Path("/mnt/d")), "d") def test_p1_1_on_the_device_its_card_is_not_offered_twice(self): root = self.card(self.CARDS["muOS"][0]) @@ -2849,23 +2870,201 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): self.assertEqual(install.card_install_dest("knulli", "knulli", True), knulli / "bios") - def test_the_interface_opens_the_card_screen_with_the_cards_first(self): + def ask(self, *answers: str): + """_ask answering in turn, and the prompts it was asked.""" + given = iter(answers) + asked: list = [] + + def answer(prompt: str) -> str: + asked.append(prompt) + return next(given) + return mock.patch.object(install, "_ask", side_effect=answer), asked + + def test_a_typed_root_that_does_not_exist_is_asked_again(self): + """code_audit_12 P2-3: a typo four folders deep was created and + filled, and a menu answer (8) became the card.""" + onion = self.card(self.ONION) + typo = REPO_ROOT / "tmp" / "no-such" / "media" / "NEXT" + patcher, asked = self.ask("8", str(typo), str(onion)) + with mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ + contextlib.redirect_stdout(io.StringIO()) as out: + 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.assertFalse((REPO_ROOT / "tmp" / "no-such").exists()) + + def test_a_typed_root_that_is_no_card_of_that_system_is_asked_again(self): + """code_audit_12 P3-4: a folder that was no Onion card ended the run.""" + blank, onion = self.card({"roms/a": ""}), self.card(self.ONION) + patcher, asked = self.ask(str(blank), str(onion)) + with mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ + 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()) + + def test_a_minui_root_is_a_minui_card(self): blank = self.card({"roms/a": ""}) - (blank / "BIOS").mkdir() + minui = self.card({".system/version.txt": "NextUI-20250101\n", "Bios/x": ""}) + patcher, asked = self.ask(str(minui / "Bios"), str(blank), str(minui)) + with mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ + contextlib.redirect_stdout(io.StringIO()) as out: + 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()) + + 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.""" + 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]), \ + 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()) + + def test_creating_several_folders_on_the_card_is_asked(self): + root = self.card({"roms/a.txt": ""}) + rocknix = root / "R" + rocknix.mkdir() + patcher, asked = self.ask(str(rocknix), "n") + 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]) + self.assertFalse((rocknix / "roms").exists()) + + def test_the_menu_asks_a_card_system_for_its_card(self): + """code_audit_12 P3-3: ROCKNIX picked from the menu on a PC was + offered /storage/roms/bios.""" + card = self.card({"roms/a": ""}) + make_card(card, "ROCKNIX") + patcher, asked = self.ask(str(card)) + with mock.patch.object(install, "_choose_platform", return_value="rocknix"), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), patcher, \ + contextlib.redirect_stdout(io.StringIO()): + chosen = install._prompt_manual_platform("linux") + self.assertEqual(chosen, [("rocknix", card / "roms" / "bios")]) + self.assertEqual(asked, ["Card root (Ctrl+C to stop): "]) + + def test_a_card_of_another_system_is_not_offered(self): + """tui_audit_5 P1-1: A on the first mount wrote Onion's BIOS/ on a + muOS card, which reads MUOS/bios.""" + muos, blank = self.card({"MUOS/bios/x": ""}), self.card({"roms/a": ""}) found = [("retroarch", SYSTEM_DIR)] - with mock.patch.object(install, "_mounted_cards", return_value=[blank]), \ + with mock.patch.object(install, "_mounted_cards", return_value=[muos, blank]), \ mock.patch.object(install, "detect_platforms", return_value=found), \ mock.patch.object(install, "detect_frontends", return_value=[]): state, effects = install.tui_start(_args(platform="onion"), "linux") self.assertEqual((state.screen, effects), ("folder", [])) rows = install.tui_rows(state) text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) - effects = install.tui_update(state, Key("enter")) - self.assertEqual([row.action for row in rows], ["card", "browse", "type"]) + self.assertEqual([row.action for row in rows], ["browse", "type", "rescan"]) + self.assertEqual(state.focus["folder"], 0) self.assertIn("Where is the SD card?", text) - self.assertIn("Onion keeps its BIOS files on its SD card", text) - self.assertEqual(effects, [("fetch", "retroarch")]) - self.assertEqual(state.dest, blank / "BIOS") + self.assertIn("No Onion card is mounted. Insert it, then choose Look again.", text) + + def test_only_the_cards_of_that_system_are_listed(self): + first, second = self.card(self.ONION), self.card(self.ONION) + muos = self.card({"MUOS/bios/x": ""}) + state = fresh_state() + state.stack = ["setup", "platforms"] + state.filters["platforms"] = "onion" + state.focus["platforms"] = 1 + with mock.patch.object(install, "_mounted_cards", return_value=[muos, first, second]): + press(state, "enter") + self.assertEqual(state.screen, "folder") + rows = install.tui_rows(state) + install.tui_update(state, Key("enter")) + self.assertEqual([(row.action, row.value) for row in rows][:2], + [("card", first), ("card", second)]) + self.assertNotIn("rescan", [row.action for row in rows]) + self.assertEqual(state.dest, first / "BIOS") + # Titled as the card found by itself (tui_audit_5 P3-7). + self.assertEqual(install.detected_os("retroarch", first / "BIOS"), "Onion") + + def test_look_again_finds_the_card_inserted(self): + onion = self.card(self.ONION) + mounts: list = [] + with mock.patch.object(install, "_mounted_cards", side_effect=lambda: list(mounts)), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(platform="onion"), "linux") + press(state, "down", "down", "enter") + self.assertEqual(state.status, "Still no Onion card is mounted.") + mounts.append(onion) + press(state, "enter") + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus["folder"]].action, "card") + self.assertEqual(state.status, "") + + def test_a_folder_that_is_no_card_of_that_system_is_refused(self): + blank = self.card({"roms/a": ""}) + muos = self.card({"MUOS/bios/x": ""}) + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(platform="onion"), "linux") + press(state, "enter") + self.assertEqual(state.screen, "browse") + 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.assertIsNone(state.dest) + # A muOS card gets the folder muOS reads, as --dest does. + install._browse_to(state, muos) + press(state, "enter") + self.assertEqual(state.dest, muos / "MUOS" / "bios") + + def test_a_typed_card_root_is_checked_on_the_path_screen(self): + 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="rocknix"), "linux") + press(state, "down", "enter") + self.assertEqual(state.screen, "path") + 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) + + def test_a_dest_that_is_no_card_of_that_system_is_refused(self): + blank = self.card({"roms/a": ""}) + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, effects = install.tui_start(_args(platform="onion", dest=str(blank)), + "linux") + self.assertEqual((effects, state.screen), ([], "folder")) + self.assertIn("is not a Onion 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 + nothing.""" + onion = self.card(self.ONION) + state = checked_state() + state.platform, state.card_bound = "retroarch", ("Onion", "BIOS") + state.dest = onion / "BIOS" + press(state, "o") + rows = install.tui_rows(state) + state.focus["options"] = [row.action for row in rows].index("folder") + press(state, "enter") + with mock.patch.object(install, "_mounted_cards", return_value=[]): + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertEqual(state.screen, "folder") + self.assertIn("Where is the SD card?", text) + self.assertIn("The BIOS folder chosen for Onion:", text) + self.assertEqual(state.folder_default, onion / "BIOS") def test_the_interface_takes_the_one_onion_card_mounted(self): onion = self.card(self.ONION) @@ -2914,17 +3113,22 @@ class TheSetupFilterKnowsDevices(unittest.TestCase): state = fresh_state() state.stack = ["platforms"] state.filters["platforms"] = needle - return [row for row in install.tui_rows(state) if row.action == "platform"] + return [row for row in install.tui_rows(state) + if row.action in ("platform", "cardsystem")] def test_each_device_finds_every_setup_it_runs(self): for needle, setups in self.TYPED.items(): with self.subTest(needle=needle): - self.assertEqual({row.value for row in self.rows(needle)}, setups) + self.assertEqual({row.value[0] if row.action == "cardsystem" else row.value + for row in self.rows(needle)}, setups) def test_the_right_column_names_what_matched(self): - retroarch = {row.value: row for row in self.rows("miyoo")}["retroarch"] - self.assertEqual(retroarch.right.split(", "), - ["spruceOS", "Onion", "Allium", "Koriki", "MiyooCFW"]) + rows = self.rows("miyoo") + retroarch = {row.value: row for row in rows}["retroarch"] + self.assertEqual(retroarch.right, "MiyooCFW") + self.assertEqual([row.label for row in rows if row.action == "cardsystem"], + ["spruceOS, SD card", "Onion, SD card", "Allium, SD card", + "Koriki, SD card"]) 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) @@ -2943,6 +3147,76 @@ class TheSetupFilterKnowsDevices(unittest.TestCase): tuple(str(name) for name in system["devices"])) +class ACardSetupIsOneDesign(unittest.TestCase): + """code_audit_12 P2-1 to P3-2, tui_audit_5 P1-2: a setup that runs from + its SD card, chosen on a computer, always asks for that card.""" + + def platforms(self, needle: str = "") -> install.TuiState: + state = fresh_state() + state.stack = ["setup", "platforms"] + state.filters["platforms"] = needle + state.focus["platforms"] = 1 + return state + + def test_a_handheld_typed_in_the_filter_asks_for_its_card(self): + for needle in ("onion", "miyoo mini", "rg35xx"): + 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") + self.assertEqual((effects, state.screen), ([], "folder")) + self.assertEqual(state.card_bound, install.card_bound(row.value[1], "retroarch")) + 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) + + def test_retroarch_typed_is_still_this_computer(self): + state = self.platforms("retroarch") + press(state, "enter") + self.assertIsNone(state.card_bound) + self.assertEqual(state.folder_default, SYSTEM_DIR) + + def test_a_card_backed_out_of_is_forgotten(self): + state = fresh_state() + state.stack = ["setup"] + state.focus["setup"] = 2 + press(state, "enter") + state.focus["platforms"] = install._platform_focus(state, "rocknix") + with mock.patch.object(install, "_mounted_cards", return_value=[]): + press(state, "enter") + self.assertEqual(state.card_bound, ("ROCKNIX", "roms/bios")) + press(state, "esc", "esc") + self.assertEqual(state.screen, "setup") + state.focus["setup"] = 0 + with mock.patch.object(install.Path, "exists", return_value=True): + press(state, "enter") + self.assertIsNone(state.card_bound) + self.assertEqual((state.platform, state.dest), ("retroarch", SYSTEM_DIR)) + install._open_folder_screen(state, state.dest, "chosen") + title = install.tui_view(state, 80, 24)[0] + self.assertIn("Which folder?", title) + self.assertNotIn("card", [row.action for row in install.tui_rows(state)]) + + def test_on_the_device_its_own_folder_is_offered(self): + share = Path("/userdata/bios") + state = fresh_state() + state.setups = [("knulli", share, "KNULLI")] + state.stack = ["setup", "platforms"] + state.focus["platforms"] = install._platform_focus(state, "knulli") + with mock.patch.dict(install._DETECTED_OS, {("knulli", share): "KNULLI"}), \ + mock.patch.object(install, "_mounted_cards", return_value=[]): + press(state, "enter") + rows = [row.action for row in install.tui_rows(state)] + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertEqual(rows, ["default", "browse", "type"]) + self.assertEqual((state.folder_default, state.folder_kind), (share, "found")) + self.assertIn("Which folder? KNULLI reads its BIOS files from: /userdata/bios", text) + self.assertNotIn("No KNULLI card is mounted", text) + + class OneSystemInFewPresses(unittest.TestCase): """tui_audit_4 P3-10.11: one system with a controller took 15 presses on the fixture list, about 60 on muxterm, which has no page key.""" From fe1e98917bfaaee5dfb32dc1a2f61c7cffcdfe85 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:17:26 +0100 Subject: [PATCH 2/4] fix: offer only what an install can do Back from the systems list with nothing ticked keeps the ticks it opened with, and None says First system once nothing is left to untick. Confirm under --offline with nothing on hand has no Install, and the line mode no longer asks to download what --offline never fetches. The line mode counts the data files before its question. Screen words: the checking clock starts at zero, a pack installs from the pack, the copies found are said on a line of their own, the screen of misplaced files says the copies stay, the save folder gets a line of its own, the shared note is one line, a size keeps its unit, the same save folder remedy on both screens, A Tick on the Miyoo bar, no Home key in the help of a pad, and a system name said once with a pack. --- install.ps1 | 2 +- install.py | 125 ++++++++++++++++----- install.sh | 2 +- install/launchers/index.json | 16 +-- tests/fixtures/tui/route-miyoo.txt | 12 +- tests/test_install_from.py | 2 +- tests/test_install_tui.py | 175 +++++++++++++++++++++++++++-- tests/test_save_root.py | 2 +- 8 files changed, 284 insertions(+), 52 deletions(-) diff --git a/install.ps1 b/install.ps1 index f5e2631ba..89272e0d0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727" + $defaultInstallSha256 = "d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index f9586c597..fa9ba6105 100644 --- a/install.py +++ b/install.py @@ -4220,7 +4220,7 @@ def place_save_entries( if place.by_core: where += f"{os.sep}" print(f" {_plural(len(saved), 'file')} read from {display}'s save directory " - f"go to {where}") + f"{'goes' if len(saved) == 1 else 'go'} to {where}") EVENTS.emit("saves", platform=str(manifest.get("platform", "")), files=len(saved), folder=where) return files, [] @@ -6512,7 +6512,7 @@ INSTALLED_ONLY = "installed" def _prompt_custom_selection( plat: str, files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None = None, - name: str = "", dry_run: bool = False, + name: str = "", dry_run: bool = False, data_trees: list[dict] | None = None, ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. @@ -6521,7 +6521,15 @@ def _prompt_custom_selection( installed read) holds, or the three choices. Each empty list keeps everything on its axis. """ - print(f"\n{name or display_name(plat)}: {_sized(files)}.") + trees = data_trees or [] + data = sum(len(entry.get("files") or {}) for entry in trees) + data_size = sum(meta.get("size", 0) for entry in trees + for meta in (entry.get("files") or {}).values()) + 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.") # A dry run installs nothing: "install everything" was asked before it. print(" Enter) list everything, nothing is written" if dry_run else " Enter) install everything") @@ -8234,7 +8242,7 @@ def _check_copy_lines( if fixed: verb = "is" if fixed == 1 else "are" them = "it" if fixed == 1 else "them" - lines.append(f" {fixed} of these files {verb} in the BIOS folder at the wrong place: " + lines.append(f" {fixed} of these files {verb} in the BIOS folder in the wrong place: " f"a run without --check copies {them} where the cores open {them}, and " f"leaves your {'copy' if fixed == 1 else 'copies'} as " f"{'it is' if fixed == 1 else 'they are'}.") @@ -10189,6 +10197,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: selection = _prompt_custom_selection( plat_name, files, omitted_files, installed, display_name(plat_name, manifest), args.dry_run, + manifest.get("data_directories") or [], ) if selection is None: smaller = (" Nothing downloaded. To install less, run the same command " @@ -10387,9 +10396,23 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: continue if to_download: - dl_size = sum(f.get("size", 0) for f in to_download) fetched = [f for f in to_download if id(f) not in copies] + if interactive and not args.yes and _offline_only() and fetched: + # The screen offers no Install for these: the question asked + # to download 6.6 GB that --offline never fetches. + 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.") + files_pending += 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) fetch_size = _bytes(fetched) + if not to_download: + total_up_to_date += len(up_to_date) + continue if ( interactive and not args.yes @@ -10818,6 +10841,21 @@ def _split_word(word: str, room: int) -> list[str]: return pieces + [word] if word else pieces +_UNIT = re.compile(r"(B|KB|MB|GB)\b") + + +def _glued_words(text: str) -> list[str]: + """The words of text, a size kept with its unit: "5.1" / "KB" was + broken across two lines of a 40-column headline.""" + words: list[str] = [] + for word in text.split(): + if words and _UNIT.match(word) and re.fullmatch(r"\(?\d+(\.\d+)?", words[-1]): + words[-1] += f" {word}" + else: + words.append(word) + return words + + def _wrap(text: str, width: int, indent: str = " ") -> list[str]: """Lines of at most width cells, the ones after the first indented.""" if cell_width(text) <= width: @@ -10825,7 +10863,7 @@ def _wrap(text: str, width: int, indent: str = " ") -> list[str]: lead = text[:len(text) - len(text.lstrip(" "))] lines: list[str] = [] line = "" - for word in text.split(): + for word in _glued_words(text): for piece in _split_word(word, width - len(lead + indent)): prefix = lead + indent if lines else lead longer = f"{line} {piece}" if line else piece @@ -10884,7 +10922,9 @@ class TuiState: self.status: str = "" self.cols: int = 80 self.rows: int = 24 - self.now: float = 0.0 + # A check started before the loop's first tick counted the + # machine's uptime. + self.now: float = time.monotonic() # (platform, folder, label) for each setup found here. self.setups: list = [] self.notes: list = [] @@ -10964,6 +11004,9 @@ class TuiState: self.must_confirm: bool = False # Choices, each one an option of the command line. self.ticked: set = set() + # The ticks the systems list opened with: Back with none ticked + # brings them back, nothing having been chosen. + self.ticked_before: set | None = None self.cli_systems: list = [] self.regions: list = [] self.cli_regions: list = [] @@ -11375,6 +11418,9 @@ def tui_rows(state: TuiState) -> list[TuiRow]: if screen == "confirm": if state.running or state.dry is None: return [] + if _offline_only() and not _dry_copies(state): + # The plan's rule: Install would only fail every file. + return [TuiRow("back", "Back")] return [TuiRow("install", "Install"), TuiRow("back", "Back")] if screen == "error": rows = ([TuiRow("retry", "Try again")] @@ -11429,6 +11475,13 @@ def _plan_copies(state: TuiState, check: dict) -> list[str]: return [dest for dest in local if dest in sizes] +def _dry_copies(state: TuiState) -> list[str]: + """The files the confirm or preview run would install that this + machine holds.""" + local = {event["dest"] for event in state.found} + return [event["dest"] for event in state.dry_files if event["dest"] in local] + + def _short_of_space(state: TuiState, check: dict) -> bool: free = state.facts.get("free") return free is not None and check.get("bytes_needed", 0) > free @@ -11566,6 +11619,10 @@ def _back(state: TuiState) -> list[tuple]: state.stack.append("quit") state.focus["quit"] = 0 return effects + if state.screen == "systems" and not state.ticked and state.ticked_before: + # Enter on None, then Esc: the plan kept its headline over an + # Install the run refused. + state.ticked = set(state.ticked_before) state.stack.pop() if state.screen == "checking" and not state.running: # Stay after Esc on a check started from the command line: a @@ -11995,6 +12052,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _open_from_browser(state) if action in ("misplaced", "unreadable"): return _push(state, action) + if action == "systems": + state.ticked_before = set(state.ticked) if action == "systems" and len(state.systems) > TUI_LONG_LIST: # On None: picking one system of 66 began with Up, Up, A. return _push(state, action, 1) @@ -12687,6 +12746,9 @@ def _route_hint(state: TuiState, parts: tuple) -> tuple: named.append(key + sep + what) if state.pad == "muxterm" and state.screen == "path": named.append("Select Keyboard") + if state.pad == "miyoo" and state.screen in _CHECKLISTS: + tick = [entry for entry in named if entry.startswith("A ")] + named = named[:1] + tick + [entry for entry in named[1:] if entry not in tick] return tuple(named) @@ -12724,6 +12786,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", } @@ -12955,15 +13018,18 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines.append("You cannot write to this folder: choose another one.") 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: # 47 to download above 49 missing and 1 wrong copy: the 3 found on - # this machine are the difference, said where the sum is read. - counts += (f": {len(copied)} of them {'is' if len(copied) == 1 else 'are'} " - f"copied from this machine, {fetch} downloaded") - lines.append(counts) - saves = (state.saves if check is state.check + # 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")) + after_counts = len(lines) + saves =(state.saves if check is state.check else state.dry_saves if check is state.dry else None) - lines += [_styled(line, "path") for line in _saves_lines(state, saves)] + saves_lines = _saves_lines(state, saves) + lines += [_styled(line, "path" if index else "") for index, line in enumerate(saves_lines)] found_in = _retroarch_config_at(dest) if dest is not None else None if found_in: lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path")) @@ -12980,7 +13046,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: f"{'one' if wrong == 1 else 'ones'} kept in a backup folder.") misplaced = _tui_misplaced(state) if check is state.check else [] if misplaced: - lines.insert(3, f"{_plural(len(misplaced), 'file')} " + lines.insert(after_counts, f"{_plural(len(misplaced), 'file')} " f"{'is' if len(misplaced) == 1 else 'are'} in the wrong place, " f"{'it' if len(misplaced) == 1 else 'they'} will be copied where the " "emulators read " @@ -13003,7 +13069,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines += _unavailable_lines(state) if check is state.check: lines += _unplaced_lines(state.check_summary, state, - "Choose it in Options, Save folder.") + "Choose it in Options, Save folder (--save-dir).") return lines[:2] + offline_lines + lines[2:] @@ -13015,13 +13081,16 @@ def _saves_lines(state: TuiState, saves: dict | None) -> list[str]: return [] name = display_name(state.platform) if state.platform else "the frontend" count = saves["files"] + # The folder alone is cut in the middle: cut with the sentence, it + # lost "directory goes to" even at 80 columns. return [f"{_plural(count, 'file')} read from {name}'s save directory " - f"{'goes' if count == 1 else 'go'} to " - f"{_short_path(Path(saves['folder']), state.home)}"] + f"{'goes' if count == 1 else 'go'} to:", + _short_path(Path(saves['folder']), state.home)] def _unplaced_lines(summary: dict | None, state: TuiState, - remedy: str = "Name it with --save-dir.") -> list[str]: + remedy: str = "Choose it in Options, Save folder (--save-dir), " + "next time.") -> list[str]: """The files read from the frontend's save directory that the run left out, that directory being unknown: said, never silent.""" count = sum(group.get("files", 0) for group in (summary or {}).get("skipped") or [] @@ -13244,8 +13313,7 @@ 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} of these files are shared with other systems, " - "counted once in the total.") + footer.insert(0, f"+{count} shared: counted once") needle = state.filters.get("systems", "") after = [f"No system matches '{needle}'."] if len(rows) == 3 and needle else [] if state.status: @@ -13301,7 +13369,9 @@ _STATE_WORDS = {"missing": "missing", "wrong_hash": "wrong copy"} def _pager_items(state: TuiState) -> list: """The text of a screen that scrolls, the same for its view and its keys.""" if state.screen == "misplaced": - return [f"{event['dest']}: {event['reason']}" for event in _tui_misplaced(state)] + return ["They are copied where the emulators read them. Your copies stay where " + "they are.", ""] + [f"{event['dest']}: {event['reason']}" + for event in _tui_misplaced(state)] if state.screen == "unreadable": return [f"{event['path']}" + (f", {event['member']}" if event["member"] else "") + f": {event['reason']}" for event in state.unreadable] @@ -13338,7 +13408,7 @@ def _view_unreadable(state: TuiState, width: int, height: int) -> tuple: def _view_misplaced(state: TuiState, width: int, height: int) -> tuple: - return ("Files in the wrong place", _pager(state, width, height, _pager_items(state)), + return ("Files in the wrong place", _pager(state, width, height, _pager_items(state), 2), ("A/Enter Back", "Up/Down Scroll")) @@ -13451,7 +13521,8 @@ def _tui_sources(state: TuiState, installed: int, failed: int) -> str: name = _last_name(folder) folders[name] = folders.get(name, 0) + 1 copied = fixed + sum(folders.values()) - return sources_line(list(folders.items()), fixed, max(0, installed - copied), failed) + return sources_line(list(folders.items()), fixed, max(0, installed - copied), failed, + "from the pack" if LOCAL.pack is not None else "downloaded") def _unavailable(state: TuiState) -> tuple[list[dict], int]: @@ -13551,7 +13622,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: ok = {dest for dest, good, _why in state.finished if good} if any(reason and dest in ok for dest, (_folder, reason) in state.local_dests.items()): # A fix copies: the misplaced files are the user's, never moved. - lines.append("Your copies at the wrong place were left as they are.") + lines.append("Your copies in the wrong place were left as they are.") lines.append(f"BIOS folder: {state.dest}") if installed + up_to_date: lines += _saves_lines(state, state.done_saves) @@ -13716,7 +13787,8 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: keys = [(names["move"], "Move")] if len(tui_rows(state)) > TUI_LONG_LIST or screen in _PAGERS: keys += [*([(names["page"], "Page")] if names["page"] else []), - ("Home/End, g/G", "First, last")] + *([("Home/End, g/G", "First, last")] if state.pad in ("both", "keys") + else [])] primary = _primary(state) or _TUI_VIEWS[screen](state, 80, 24)[2][0] ok = names["ok"] if state.pad == "miyoo" and screen in _CHECKLISTS: @@ -13755,6 +13827,9 @@ def _primary(state: TuiState) -> str | None: verb = _ROW_VERBS.get(row.action) if state.screen == "systems" and row.action == "system" and not state.ticked: verb = "Only this system" + if state.screen == "systems" and row.action == "none" and not state.ticked: + # Nothing left to untick: A goes to the first system. + verb = "First system" return f"A/Enter {verb}" if verb else None diff --git a/install.sh b/install.sh index e13e81dda..bbf111048 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="8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727" +DEFAULT_INSTALL_SHA256="d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 1d4f2da09..a747233fa 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": 646794, - "sha256": "8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727", + "size": 650749, + "sha256": "d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 699343, - "sha256": "42226d45d6a31c86b726b1af321e043f9894f9993d8c6de0f16cc84c04a2a1ed" + "size": 703298, + "sha256": "bb54c96f4ae4850f997cc925b2958b21477be655a658b6bc3551d1aef68fdbb5" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 646794, - "sha256": "8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727", + "size": 650749, + "sha256": "d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 646794, - "sha256": "8833256124ffdfa38865e7b065f2720ec20997e1a91ba340eab4067e31b20727", + "size": 650749, + "sha256": "d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464", "executable": false }, { diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index c47dc92a2..10ea786ff 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -550,7 +550,7 @@ 2 of 3 systems: 4 files, 1.0 MB - Start Continue R2 Back A Tick + Start Continue A Tick R2 Back === systems 40x15 RetroBIOS: Choose systems @@ -566,7 +566,7 @@ 2 of 3 systems: 4 files, 1.0 MB - Start Continue R2 Back A Tick + Start Continue A Tick R2 Back === systems 80x24 RetroBIOS: Choose systems @@ -591,7 +591,7 @@ 2 of 3 systems: 4 files, 1.0 MB - Start Continue R2 Back A Tick + Start Continue A Tick R2 Back === options 40x12 RetroBIOS: Options @@ -712,7 +712,7 @@ - Start Filter R2 Back A Tick + Start Filter A Tick R2 Back === cores 40x15 RetroBIOS: Cores @@ -728,7 +728,7 @@ - Start Filter R2 Back A Tick + Start Filter A Tick R2 Back === cores 80x24 RetroBIOS: Cores @@ -753,7 +753,7 @@ - Start Filter R2 Back A Tick + Start Filter A Tick R2 Back === preview 40x12 RetroBIOS: Preview diff --git a/tests/test_install_from.py b/tests/test_install_from.py index ef445ca50..d6bd96476 100644 --- a/tests/test_install_from.py +++ b/tests/test_install_from.py @@ -438,7 +438,7 @@ class MisplacedInTheBiosFolder(Scratch): for name, data in PAYLOAD.items(): self.place(f"system/{name}", data) out = self.check() - self.assertIn("4 of these files are in the BIOS folder at the wrong place: a run " + self.assertIn("4 of these files are in the BIOS folder in the wrong place: a run " "without --check copies them where the cores open them, and leaves " "your copies as they are.", out) self.assertFalse((self.dest / "scph5501.bin").exists()) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index c2c1ad264..e885eb234 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2044,7 +2044,7 @@ class CopyFromAnOldCard(unittest.TestCase): 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. " - "Your copies at the wrong place were left as they are.", text) + "Your copies in the wrong place were left as they are.", text) def test_a_local_copy_is_no_download_speed(self): state = checked_state() @@ -2432,7 +2432,7 @@ class AuditRoundThree(unittest.TestCase): self.assertEqual(rows[three].right, "1 file, 100 B + 2 shared") state.focus["systems"] = three text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) - self.assertIn("2 of these files are shared with other systems, counted once", text) + self.assertIn("+2 shared: counted once", text) # The line mode counts the same files apart. self.assertIn("and 2 files, 500 B shared with other systems", install._system_sized([ {"dest": "panafz1.bin", "size": 100, "systems": ["3do"]}, @@ -2767,11 +2767,12 @@ class AuditRoundFour(unittest.TestCase): plan = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) # The plan names the row that sets it, the result the option a # command carries. - self.assertIn(line + "Choose it in Options, Save folder.", plan) + self.assertIn(line + "Choose it in Options, Save folder (--save-dir).", plan) state.stack.append("result") state.code = 0 state.summary = {**summary, "mode": "install", "installed": 5} - self.assertIn(line + "Name it with --save-dir.", install.tui_outcome(state)[1]) + self.assertIn(line + "Choose it in Options, Save folder (--save-dir), next time.", + install.tui_outcome(state)[1]) class TheSaveFolderHasARow(unittest.TestCase): @@ -3441,6 +3442,162 @@ class OfflineInTheInterface(unittest.TestCase): self.assertIn("--offline", install.tui_command(state)) +class AuditFive(unittest.TestCase): + """tui_audit_5 P2-1, P2-2 and its P3 points.""" + + def long_list(self) -> install.TuiState: + state = OneSystemInFewPresses().long_list("keys") + state.cols, state.rows = 80, 24 + return state + + def test_back_from_none_keeps_the_ticks_it_opened_with(self): + state = self.long_list() + everything = set(state.ticked) + press(state, "down", "enter") + self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "none") + press(state, "enter") + self.assertEqual(state.ticked, set()) + press(state, "up", "up") + self.assertEqual(install._primary(state), "A/Enter First system") + press(state, "esc") + self.assertEqual(state.screen, "plan") + self.assertEqual(state.ticked, everything) + text = " ".join(install.tui_view(state, 80, 24)) + self.assertNotIn("No system is ticked", text) + + def test_back_after_a_choice_keeps_the_choice(self): + state = self.long_list() + press(state, "down", "enter", "enter") + press(state, " ") + chosen = set(state.ticked) + press(state, "esc") + self.assertEqual((state.screen, state.ticked), ("plan", chosen)) + + def test_confirm_offline_with_nothing_on_hand_has_no_install(self): + state = checked_state() + state.stack.append("confirm") + state.dry = dict(CHECK) + state.dry_files = [dict(event, state="missing") for event in FILES] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + rows = [row.action for row in install.tui_rows(state)] + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertEqual(rows, ["back"]) + self.assertIn("Copy them from your old card or a folder", text) + state.found = [{"event": "found", "platform": "retroarch", + "dest": FILES[0]["dest"], "folder": "/media/old", + "path": "/media/old/x", "member": "", "reason": ""}] + self.assertEqual(install.tui_rows(state)[0].action, "install") + + def test_the_checking_clock_starts_at_zero(self): + state = install.TuiState() + self.assertLess(abs(state.now - time.monotonic()), 5) + + def test_a_pack_installs_from_the_pack(self): + state = checked_state() + state.found = [{"event": "found", "platform": "retroarch", "dest": FILES[0]["dest"], + "folder": "/media/old", "path": "/media/old/x", "member": "", + "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) + self.assertNotIn("downloaded", text) + self.assertNotIn("of them", text) + state.finished = [(FILES[0]["dest"], True, ""), (FILES[1]["dest"], True, "")] + state.local_dests = {FILES[0]["dest"]: ("/media/old", "misplaced")} + self.assertIn("1 from the pack", install._tui_sources(state, 2, 0)) + + def test_the_wrong_place_screen_says_the_copies_stay(self): + state = checked_state() + state.stack.append("misplaced") + self.assertEqual(install._pager_items(state)[0], + "They are copied where the emulators read them. Your copies stay " + "where they are.") + + def test_the_save_folder_is_a_line_of_its_own(self): + state = checked_state() + saves = {"files": 1, "folder": str(HOME / ".config" / "retroarch" / "saves" / "")} + lines = install._saves_lines(state, saves) + self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to:", + "~/.config/retroarch/saves/"]) + + def test_the_shared_note_is_one_line(self): + state = self.long_list() + state.cols, state.rows = 40, 12 + state.files = [{"event": "file", "dest": "mame.zip", "size": 10, + "systems": ["sys-00", "sys-01"]}] + state.stack.append("systems") + state.focus["systems"] = 3 + lines = install.tui_view(state, 40, 12) + self.assertIn(" +1 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}]} + state = checked_state() + lines = install._unplaced_lines(summary, state) + self.assertIn("Options, Save folder (--save-dir)", lines[0]) + + def test_a_size_keeps_its_unit(self): + for width in range(20, 45): + for line in install._wrap("2 files to install from the pack, 5.1 KB", width): + self.assertNotEqual(line.strip(), "KB") + + def test_the_miyoo_bar_names_the_tick(self): + state = self.long_list() + state.pad = "miyoo" + press(state, "down", "enter") + state.ticked = set() + 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")) + + def test_muxterm_help_names_no_home_key(self): + state = self.long_list() + state.pad = "muxterm" + press(state, "down", "enter") + keys = [key for key, _what in install._help_lines(state)] + self.assertNotIn("Home/End, g/G", keys) + state.pad = "keys" + self.assertIn("Home/End, g/G", [key for key, _what in install._help_lines(state)]) + + +class LineModeOffline(unittest.TestCase): + """tui_audit_5 P2-3 and P3-8.4: the line mode under --offline asked to + download, and said nothing of the data files.""" + + def test_nothing_is_offered_that_cannot_come(self): + with FakeRepository() as repo: + env = {**repo.env(), "TERM": "xterm", install.OFFLINE_VARIABLE: "1"} + term = Pty(["--plain", "--manifest", str(repo.root / "install" / "retroarch.json"), + "--dest", str(repo.dest), "--offline"], env) + term.wait("q) quit") + term.send("\r") + term.close() + out = term.raw.decode("utf-8", "replace") + self.assertIn("--offline: only the files found on this machine are installed.", out) + self.assertIn("5 files need the internet, which --offline does not use.", out) + self.assertNotIn("Downloading", out) + self.assertNotIn("failed", out.lower().replace("not failed", "")) + + def test_the_data_files_are_counted_before_the_question(self): + files = [{"dest": "a.bin", "size": 1024}] + trees = [{"ref": "t", "files": {"x": {"size": 2048}, "y": {"size": 2048}}}] + with mock.patch.object(install, "_ask", return_value=""), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._prompt_custom_selection("retroarch", files, [], data_trees=trees) + self.assertIn("RetroArch: 1 file, 1.0 KB, and 2 data files (4.0 KB).", out.getvalue()) + + def test_one_save_file_goes(self): + files = [{"dest": "keys.txt", "root": "save", "size": 1}] + place = install.SavePlace(Path("/saves"), False) + with mock.patch.object(install, "save_directory_for", return_value=place), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install.place_save_entries(files, Path("/bios"), {}, None, "RetroArch") + self.assertIn("1 file read from RetroArch's save directory goes to /saves", + out.getvalue()) + + class NarrowSystemNames(unittest.TestCase): """At 40 columns the size column took half the line: Game Boy, Game Boy Advance and Game Boy Color all read "Nintendo Gam".""" @@ -3554,7 +3711,7 @@ class TheSaveFolderLineFollowsTheChoice(unittest.TestCase): SAVES = {"event": "saves", "platform": "retroarch", "files": 1, "folder": "/home/user/MySaves/"} - LINE = "1 file read from RetroArch's save directory goes to ~/MySaves/" + LINE = "1 file read from RetroArch's save directory goes to: ~/MySaves/" def said(self, lines) -> str: return " ".join(" ".join(lines).split()) @@ -4356,11 +4513,11 @@ class EndToEnd(unittest.TestCase): self.assertIn("6 files to download", screen) # The second folder the run writes to, named before # anything is written, and again on the result. - self.assertIn("1 file read from RetroArch's save directory goes to " - "~/MySaves", screen) + self.assertIn("1 file read from RetroArch's save directory goes to:\n" + " ~/MySaves", screen) term.send("\r") screen = term.wait("6 files installed.") - self.assertIn("save directory goes to ~/MySaves", screen) + self.assertIn("save directory goes to:\n ~/MySaves", screen) term.send("\r") self.assertEqual(term.close(), 0) results.append(written(repo, saves)) @@ -4741,7 +4898,7 @@ class ThePlanNumbersAddUp(unittest.TestCase): state.found = [found("scph5500.bin", str(SYSTEM_DIR), "one folder too deep")] screen = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) self.assertIn("3 files to download", screen) - self.assertIn("1 in place, 3 missing, 1 wrong copy: 1 of them is copied from this " + self.assertIn("1 in place, 3 missing, 1 wrong copy 4 to install: 1 copied from this " "machine, 3 downloaded", screen) diff --git a/tests/test_save_root.py b/tests/test_save_root.py index e3cc58f81..0b1ae378a 100644 --- a/tests/test_save_root.py +++ b/tests/test_save_root.py @@ -736,7 +736,7 @@ class TheInstallerWritesThem(unittest.TestCase): b"keys below the save directory", ) self.assertFalse((app / "system" / "Azahar").exists()) - self.assertIn("read from RetroArch's save directory go to", proc.stdout) + self.assertIn("read from RetroArch's save directory goes to", proc.stdout) def test_where_save_dir_points(self): repo = _Repository(self, self.FILES) From 92ee80f40bd2487dcb9193955b7dcb1059b4099a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:31:40 +0100 Subject: [PATCH 3/4] docs: describe the card screen and None The installer page says what a card system named on a computer does (Where is the SD card?, Look again, the Card root prompt), that Enter on None unticks every system and Back keeps the ticks, and that Space continues outside a list of ticks. KNULLI and ROCKNIX no longer have a computer default. The first-install page names the card screen. --- wiki/first-install.md | 9 +++++---- wiki/installer.md | 22 +++++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/wiki/first-install.md b/wiki/first-install.md index b2ef663ec..8f61e7a76 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -11,8 +11,8 @@ From an empty BIOS folder to games that start. ## 1. Open a terminal and paste -A **terminal** is a window where you type commands. Paste the line for your -system, then press Enter. +A **terminal** is a window where you type commands. Paste your system's +line, then press Enter. @@ -81,9 +81,10 @@ It opens a menu: - **Right folder?** Press Enter. - **Wrong folder?** Choose **Another setup or folder**, then browse to yours. -- **A handheld's SD card in a reader?** It is listed, as **muOS SD card**. +- **A handheld's SD card?** Choose it, or its system under **Another setup + or folder**: **Where is the SD card?** -Arrows move, Enter chooses, Esc goes back. A handheld's A and B do the same. +Arrows move, Enter chooses, Esc goes back, as A and B on a handheld. ## 3. Install diff --git a/wiki/installer.md b/wiki/installer.md index ed96aee68..4ab99b424 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -132,7 +132,8 @@ much space is free, before anything is written. The other choices sit one press away. **Choose systems** ticks the consoles you play, with the total to download under the list. A long list opens on **None**: -A there unticks every system, and A on a system then installs that one alone. **Options** sets the +A or Enter there unticks every system, and A on a system then installs that one alone. Back +with nothing ticked keeps the ticks the list opened with. **Options** sets the preferred regions, game data, the installed cores only, the cores, the parallel downloads, the folder and, for a list with files read from the frontend's save directory, that directory. Each row names the option it sets, and **Show the @@ -144,7 +145,7 @@ files that folder holds and how many are left to download. After an install on a muOS, KNULLI or ROCKNIX card, the result offers **Put RetroBIOS on this card (update from the console later)**, and **Options** has the same row ([On a handheld](#on-a-handheld-the-retrobios-port)). Files the BIOS -folder holds at the wrong place are counted on the plan, and **Show the files +folder holds in the wrong place are counted on the plan, and **Show the files in the wrong place** lists each one with its reason. Back on the plan after a change of options, the folder is checked again, so its numbers are the exact ones for the new choice. Ticked systems get their exact count before the @@ -155,7 +156,7 @@ install starts. | Move | Up and Down, `k` and `j` | D-pad Up and Down | | Page | PgUp and PgDn | L1 and R1 | | First, last | Home and End, `g` and `G` | | -| Continue, open, choose | Enter, Space | A, Start | +| Continue, open, choose | Enter, and Space outside a list of ticks | A, Start | | Tick, untick | Space, Right, Left | X, D-pad Right and Left | | Back | Esc, Backspace | B, Select | | Options | `o`, Tab | Y | @@ -903,8 +904,8 @@ When a platform named with `--platform` is not found on the machine, the destination falls back to the directory its detection looks for: `/userdata/bios` for Batocera, `/recalbox/share/bios` for Recalbox, `/storage/system` for Lakka, `~/retrodeck` for RetroDECK, `~/Emulation/bios` -for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms/bios` for ROCKNIX, -`/userdata/bios` for KNULLI, `/storage/roms` for AmberELEC and EmuELEC, `/media/fat/games` for MiSTer, +for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms` for AmberELEC and +EmuELEC, `/media/fat/games` for MiSTer, `~/RetroPie/BIOS` for RetroPie, `/romm/library/bios` for RomM, `C:\RetroBat\bios` for RetroBat, and RetroArch's own default system directory for RetroArch. BizHawk has no such folder: it reads `Firmware` @@ -912,8 +913,15 @@ beside its executable wherever the archive was extracted, so a forced `--platform bizhawk` that finds no install stops and asks for `--dest`. MinUI, NextUI and MyMinUI keep their files on the SD card. Run on the device, the installer finds the card root. From a computer the card is mounted on, a -forced `--platform minui` stops and asks for the card's root as `--dest`, and a `--dest` that is a folder inside such a -card (its `Bios/`) is refused with the root to name instead. On Android a forced `--platform retroarch` falls back to +forced `--platform minui` uses the one MinUI card mounted. Otherwise the screen +opens **Where is the SD card?**, which lists only the cards of that system and +offers **Look again**, and `--plain` asks `Card root`, listing the mounted +drives by number. A root that does not exist is asked again, and so is a +folder without the card's own files for MinUI, NextUI, MyMinUI, Onion, muOS +and spruceOS. Without questions (`--no-input`) the run stops with a `--dest` +example. The same holds for Onion, muOS, spruceOS, KNULLI and ROCKNIX, also +when chosen from the setup list or its filter. A `--dest` that is a folder +inside such a card (its `Bios/`) is refused with the root to name instead. On Android a forced `--platform retroarch` falls back to `/RetroArch/system` instead, a home directory there belonging to Termux rather than to the frontend. Pass `--dest` to say where instead of relying on that. When detection met a folder this user cannot open, under the From 7abe5b830940d8770f0f3e23cfc76f64fd633f97 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:45:57 +0100 Subject: [PATCH 4/4] fix: say offline files are not downloaded The line mode under --offline, with files that need the internet, ended on Done with 0 files installed. It now counts them as not downloaded and says --offline is why, not an answer. --- install.ps1 | 2 +- install.py | 4 +++- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install_tui.py | 4 +++- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/install.ps1 b/install.ps1 index 89272e0d0..21305373e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464" + $defaultInstallSha256 = "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index fa9ba6105..ecb3c6a77 100644 --- a/install.py +++ b/install.py @@ -5403,7 +5403,7 @@ def _report_outcome( 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, " - "as answered.") + + ("which --offline does not do." if _offline_only() else "as answered.")) print(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.") return kept = _kept_lines() @@ -10405,7 +10405,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: "--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. files_pending += len(fetched) + total_declined += 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) diff --git a/install.sh b/install.sh index bbf111048..eeee0e727 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="d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464" +DEFAULT_INSTALL_SHA256="b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index a747233fa..fb20109e6 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": 650749, - "sha256": "d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464", + "size": 650926, + "sha256": "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 703298, - "sha256": "bb54c96f4ae4850f997cc925b2958b21477be655a658b6bc3551d1aef68fdbb5" + "size": 703475, + "sha256": "1928a1ee174ab57d2890fa7d668a34fa996637669446bb6ad4329fa0fc3d76dc" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 650749, - "sha256": "d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464", + "size": 650926, + "sha256": "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 650749, - "sha256": "d59defaa5802d2d7b73b4b02e693e9592618803b308eb625a6f436b8a5388464", + "size": 650926, + "sha256": "b7eec15ba746bbaaaea471396964a83a255a4a1c4bd51316fbdb0ff62a065169", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index e885eb234..02d7a0128 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3578,7 +3578,9 @@ class LineModeOffline(unittest.TestCase): self.assertIn("--offline: only the files found on this machine are installed.", out) self.assertIn("5 files need the internet, which --offline does not use.", out) self.assertNotIn("Downloading", out) - self.assertNotIn("failed", out.lower().replace("not failed", "")) + self.assertNotIn("failed", out.lower()) + self.assertIn("Nothing installed: 5 files not downloaded, which --offline does not do.", + out.replace("\r\n", " ").replace("\n", " ")) def test_the_data_files_are_counted_before_the_question(self): files = [{"dest": "a.bin", "size": 1024}]