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] 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."""