From a4faf536dd9e0718a8a2bb8b39665c046264558c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:34:55 +0100 Subject: [PATCH 1/3] fix: a games part is its system's card Closes every finding of code_audit_23 and tui_audit_16: AmberELEC and EmuELEC cards found as theirs, ArkOS cards told by bgmusic or launchimages, counts that agree and read a copy of the right size before calling it in place, one terminal width, line mode in the screen's order and words, a tap shown armed, and which-pack naming the first screen. --- install.ps1 | 2 +- install.py | 715 +++++++++++++++++++++++------- install.sh | 2 +- install/launchers/index.json | 16 +- scripts/generate_installer_doc.py | 12 +- scripts/generate_site.py | 9 +- tests/test_doc_pages.py | 16 +- tests/test_install.py | 22 +- tests/test_install_offline.py | 10 +- tests/test_install_tui.py | 664 +++++++++++++++++++++++++-- tests/test_site_r2_entry.py | 2 +- wiki/installer.md | 41 +- 12 files changed, 1257 insertions(+), 254 deletions(-) diff --git a/install.ps1 b/install.ps1 index 44f854f60..37ec12874 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511" + $defaultInstallSha256 = "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 06df81429..e81a361ed 100644 --- a/install.py +++ b/install.py @@ -485,6 +485,21 @@ def format_duration(seconds: float) -> str: return f"{hours}:{minutes:02}:{secs:02}" if hours else f"{minutes}:{secs:02}" +# The fewest columns a line question lays itself out in: below it a row is +# all number, and the terminal folds what is printed whatever it is. +LINE_MIN_COLS = 20 + + +def terminal_size() -> tuple[int, int]: + """(columns, lines) of the terminal line mode prints to, the one reading + every layout of it makes. Python 3.8 gives a pty of no size as 0 and 0, + where 3.11 falls back to 80 and 24: a list laid out in 0 columns never + ended, the plan printed an empty heading, and the progress line lost + every figure but its percentage (code_audit_23 P3-5).""" + size = shutil.get_terminal_size((80, 24)) + return max(size.columns or 80, LINE_MIN_COLS), size.lines or 24 + + class Progress: """Bytes and files across the parallel downloads, shown as they move. @@ -598,7 +613,7 @@ class Progress: # line the cursor is on, so a wrapped line left a ghost per redraw # on an 80-column console. The bar gives way first, then the least # telling figures. - width = shutil.get_terminal_size((80, 24)).columns - 1 + width = terminal_size()[0] - 1 while True: tail = " ".join(parts) room = width - len(tail) - 6 @@ -4734,8 +4749,45 @@ def say_saves(plat: str, files: list[dict], omitted: list[dict], bios_path: Path print(f" {line}") -def _classify_local(entry: dict, dest: Path) -> str: - """Return 'up_to_date' or 'mismatched' for a file already on disk.""" +# The digests the line menu's head read of the files in place, by path and +# by what stat says of the file, kept for the check that follows Enter: the +# head named a wrong copy of the right size in place (code_audit_23 P3-2), +# and a card read twice is read at the speed of its reader. Emptied once +# that check has read them (head_reads_done). +_HEAD_READS: dict[tuple, dict[str, str]] = {} + + +def _stat_key(path: Path) -> tuple | None: + """What tells a file apart from the same path rewritten: None for none.""" + try: + info = path.stat() + except OSError: + return None + return (str(path), info.st_size, info.st_mtime_ns, info.st_ctime_ns, info.st_ino) + + +def _read_digests(path: Path, algorithms: tuple[str, ...], remember: bool = False) -> dict: + """_digest_file, answered from what the head read when the file is the + same one, and remembered for the check when remember is set.""" + key = _stat_key(path) + known = _HEAD_READS.get((key, algorithms)) if key is not None else None + if known is not None: + return known + digests = _digest_file(path, algorithms) + if remember and key is not None: + _HEAD_READS[(key, algorithms)] = digests + return digests + + +def head_reads_done() -> None: + """The check after the head has read what it holds: nothing more is + answered from it, a file the run writes being read again.""" + _HEAD_READS.clear() + + +def _classify_local(entry: dict, dest: Path, remember: bool = False) -> str: + """Return 'up_to_date' or 'mismatched' for a file already on disk. + remember keeps what was read for the check after the line menu.""" expected = { name: entry.get(name, "").lower() for name in ("sha256", "sha1") @@ -4754,7 +4806,7 @@ def _classify_local(entry: dict, dest: Path) -> str: if not expected: return "up_to_date" try: - actual = _digest_file(dest, tuple(expected)) + actual = _read_digests(dest, tuple(expected), remember) except OSError: # Unreadable (no permission, a read error on a failing card): its # contents are not known to be the right ones. @@ -5700,6 +5752,22 @@ _VALUED_OPTIONS = ( # The options a choice at the questions sets, which replace the command's. _CHOICE_OPTIONS = ("--system", "--core", "--region") +# The order every command given back writes its options in: the list, what +# narrows it, where files are copied from, then how the run goes. The screen +# wrote --manifest before --from and line mode after it, for one choice +# (tui_audit_16 P3-14). An option not named here comes after them. +_COMMAND_ORDER = ( + "--manifest", "--from-pack", "--system", "--core", "--emulator", "--region", + "--target", "--no-game-data", "--installed-cores", "--cores-dir", "--every-board", + "--declared-only", "--standalone-copies", "--save-dir", "--jobs", "--from", + "--add-app", "--offline", "--check", "--dry-run", +) + + +def _command_rank(option: tuple[str, str | None]) -> int: + flag = option[0] + return _COMMAND_ORDER.index(flag) if flag in _COMMAND_ORDER else len(_COMMAND_ORDER) + class Invocation: """How this run was started, so that a hint gives the command back whole. @@ -5782,7 +5850,7 @@ class Invocation: # the systems of the first run and of the second. replaced = {*_CHOICE_OPTIONS, *(flag for flag, _value in self.chosen[plat])} options = [option for option in options if option[0] not in replaced] - for flag, value in [*options, *self.chosen.get(plat, [])]: + for flag, value in sorted([*options, *self.chosen.get(plat, [])], key=_command_rank): if flag in without: continue words += [flag] if value is None else [flag, self.word(value)] @@ -5798,6 +5866,10 @@ EVERYTHING_IN_PLACE = "Everything is in place." # them from here, so they say what the screen shows. TUI_INSTALL_ROW = "Install" TUI_SYSTEMS_ROW = "Choose systems" +# The first screen's title: a setup found here, or none, and the list. +# which-pack names them from here. +TUI_FOUND_TITLE = "Where do you play?" +TUI_CHOOSE_TITLE = "Which setup?" def _on_read_only_mount(path: Path) -> bool: @@ -5892,8 +5964,20 @@ def _write_advice(path: Path, assumed: bool, plat: str = "", def _plural(count: int, word: str) -> str: - """Count and noun, so a run of one file does not read as machine output.""" - return f"{count} {word}" if count == 1 else f"{count} {word}s" + """Count and noun, so a run of one file does not read as machine output: + 1 file, 2 files, 1 wrong copy, 2 wrong copies.""" + if count == 1: + return f"{count} {word}" + if word.endswith("y") and word[-2:-1] not in tuple("aeiou"): + return f"{count} {word[:-1]}ies" + return f"{count} {word}s" + + +def agree(count: int, one: str, many: str) -> str: + """The verb a count takes: "1 needs", "2 need". Each count said with a + verb reads it here: "1 need the internet" stood on the plan + (code_audit_23 P3-4).""" + return f"{count} {one if count == 1 else many}" class OfflineReason(str): @@ -5960,11 +6044,14 @@ def _report_outcome( same_choice: str = "", term: Terminal | None = None, notes: list[str] | None = None, + missing: Tally | None = None, ) -> None: """Close the run on what happened, and leave with a status that says so. kinds is the count of the files installed (tally) and of the data tree files, given for a list marking its game data, said apart. notes are lines every ending repeats (a list built for another version). + missing is the count of the files --offline left out, by kind, given as + kinds is. A run that installed nothing used to end on the word Done and a zero exit, which reads as success to a person and to a script alike. offline counts @@ -6060,8 +6147,8 @@ def _report_outcome( if offline: remedy = _offline_remedy() if not downloaded and not errors: - say(f"\nNothing installed: {_plural(offline, 'file')} " - f"{'needs' if offline == 1 else 'need'} the internet, which " + say(f"\nNothing installed: {agree(offline, 'file needs', 'files need')} " + "the internet, which " f"{offline_named()} does not use.") say(f" {remedy}") for line in kept: @@ -6073,10 +6160,10 @@ def _report_outcome( say(line) sys.exit(2) failed = f", {errors} failed" if errors else "" - if downloaded and sources and not errors: + if downloaded and sources: # One count, the remedy under it, as the screen's result: the # count came twice, "installed" then "copied" (tui_audit_15 - # P3-8). + # P3-8). The failed files are in it. say(f"\nFinished with files missing. {sources}") sources = "" elif downloaded: @@ -6091,6 +6178,11 @@ def _report_outcome( say(f" {sources}") if installed_kinds: say(installed_kinds) + if missing is not None and missing.bios and missing.games: + # The kinds still missing, as the screen's result says them, + # before the remedy: "They are not in OLD" followed the files + # installed (tui_audit_16 P3-3). + say(f" Still missing: {installed_words(missing.bios, missing.games)}.") # Under the files it is about: after the save-directory line, "They" # read as that one file. say(f" {remedy}") @@ -6120,10 +6212,12 @@ def _report_outcome( sys.exit(2) if errors: - say(f"\nFinished with errors. {_plural(downloaded, 'file')} installed, " + # One count: "42 files installed, 2 files failed." over "38 files + # copied from E:\, 4 downloaded, 2 still missing." said it twice, + # the failed files as missing ones (tui_audit_16 P3-11). + say(f"\nFinished with errors. {sources}" if sources else + f"\nFinished with errors. {_plural(downloaded, 'file')} installed, " f"{_plural(errors, 'file')} failed.") - if sources: - say(f" {sources}") if installed_kinds: say(installed_kinds) say(f"BIOS folder: {_locations(paths)}") @@ -6223,7 +6317,7 @@ def _check_fetch_line(fetch: int, beside: str, lacking: int = 0) -> str: if _offline_only(): if not fetch: return " Run the same command without --check to copy them into place." - return (f" {_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the " + return (f" {agree(fetch, 'file needs', 'files need')} the " f"internet, which {offline_named()} does not use. {_offline_remedy()}") return (" Run the same command without --check to fetch them. A wrong copy is " f"replaced, and the one in place kept in {BACKUP_DIR} beside {beside}.") @@ -6264,6 +6358,19 @@ def game_data_missing(names: str, count: int, folders: list[Path]) -> str: f"{'it' if one else 'them'} from your own copy of the game{where}.") +def not_installable_words(count: int, required: int, reasons: dict[str, int]) -> str: + """The line under the index count for the files the list leaves out: + "(1 required, not found: 37)" read as a sum (tui_audit_16 P3-6).""" + need = ("" if not required else ", required" if count == 1 + else f", {required} of them required") + if set(reasons) == {"not_found"}: + return (f"Not installable: {agree(count, 'file is', 'files are')} not in the " + f"collection{need}.") + why = ", ".join(f"{OMISSION_REASONS.get(reason, reason)} ({number})" + for reason, number in sorted(reasons.items())) + return f"Not installable: {_plural(count, 'file')}{need}. Why: {why}." + + def _omission_lines(count: int, entries: list[dict], bios_path: Path | None = None) -> list[str]: """What was skipped, the files an emulator cannot start without first. @@ -6441,7 +6548,7 @@ def _print_plan( for entry in entries[:shown]] if shown is not None and len(entries) > shown: listing.append(f" ... and {len(entries) - shown} more") - screen = shutil.get_terminal_size((80, 24)).lines + screen = terminal_size()[1] if verbose or len(listing) + len(trees) + PLAN_FRAME_LINES <= screen: for line in listing: print(line) @@ -6666,8 +6773,10 @@ def _archive_members( yield rel, member.size, (lambda member=member: tf.extractfile(member)) -def data_directory_state(entry: dict, bios_path: Path) -> tuple[Path, dict[str, dict], int]: - """(tree root, files missing or wrong, files already right).""" +def data_directory_state(entry: dict, bios_path: Path, + remember: bool = False) -> tuple[Path, dict[str, dict], int]: + """(tree root, files missing or wrong, files already right). remember + keeps what was read for the check after the line menu (_HEAD_READS).""" root = _destination_path(bios_path, entry["dest"]) if entry.get("dest") else bios_path.resolve() needed: dict[str, dict] = {} present = 0 @@ -6675,7 +6784,7 @@ def data_directory_state(entry: dict, bios_path: Path) -> tuple[Path, dict[str, target = _destination_path(root, rel) try: held = (target.is_file() and target.stat().st_size == meta["size"] - and _sha1_file(target) == meta["sha1"]) + and _read_digests(target, ("sha1",), remember)["sha1"] == meta["sha1"]) except OSError: held = False if held: @@ -7162,8 +7271,18 @@ def _choose_platform(title: str, default: str = "", setups: list | None = None, # the screen's focus falls (_platform_focus). default = choices[0][0] if choices else "" print(f"\n{title}") + # Wrapped at the width between words, its devices under its name: left + # to the terminal, "Miyoo Flip" was folded inside "Miy" and "oo" below + # 40 columns (tui_audit_16 P3-15). + cols = terminal_size()[0] - 1 + width = len(str(len(choices))) for i, (_name, label) in enumerate(choices, 1): - print(f" {i}) {label}") + number = f" {i:>{width}}) " + pieces = _no_widow(_wrap(label, max(cols - len(number), 8), ""), + max(cols - len(number), 8)) + print(number + pieces[0]) + for piece in pieces[1:]: + print(" " * len(number) + piece) print(" q) quit") print("Type a number, a setup's name, or your device's name (Miyoo Mini, R36S).") usage = (f" Type a number from 1 to {len(choices)}, a setup name or your " @@ -7278,6 +7397,7 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: P1-2). Another system's card or drive, and one two systems fit, are reached through their own setup (drives_left_out).""" targets = [] + cards = unnamed_cards() for mount in _mounted_cards(): found = card_system(mount) if (found is not None and found[0] != plat) or share_doubt(mount)[0]: @@ -7287,32 +7407,58 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: what = f"{found[2]} {_card_word(found)}" targets.append((f"{_drive_folder(mount, below)} ({what})", found[1])) else: - targets.append((f"{_drive_folder(mount, 'bios')} (card or drive)", mount / "bios")) + # A drive its files show to be no card is called a drive, under + # the note that says so: "card or drive" sat under "nothing on + # it shows a handheld's card" (tui_audit_16 P3-9). + what = "SD card" if mount in cards else "drive" + targets.append((f"{_drive_folder(mount, 'bios')} ({what})", mount / "bios")) # A handheld's card no file names first: its bios folder is where its # system reads (unnamed_cards). - cards = {mount / "bios" for mount in unnamed_cards()} - return sorted(targets, key=lambda target: target[1] not in cards) + first = {mount / "bios" for mount in cards} + return sorted(targets, key=lambda target: target[1] not in first) + + +def folder_drives(os_type: str, plat: str, side: bool = True) -> list[tuple[str, Path]]: + """The folders the folder question numbers for a setup that runs from no + card, in the one order both modes give them: a handheld's card in the + reader first, then the other side's usual folder (side, under WSL), + then the other drives. Line mode put Windows RetroArch before the card + the screen put first (code_audit_23 P3-6).""" + drives = [*(other_side(os_type, plat) if side else []), *drive_targets(plat)] + first = {mount / "bios" for mount in unnamed_cards()} + return sorted(drives, key=lambda target: target[1] not in first) # What a handheld system writes on the games part a computer mounts, beside # its bios folder: a drive holding these is that card, and a data drive or # a stick with an emulation library holds none of them (code_audit_22 P2-1). -# Each set is (system, folders and files that must all be there). -_GAMES_PART_MARKERS = ( - # ArkOS and dArkOS mount EASYROMS as /roms. dArkOS daecf488 creates every - # folder of game_systems.txt on it (finishing_touches.sh:420-430), - # themes (game_systems.txt:108) and launchimages (game_systems.txt:54), - # and copies loading.jpg there (finishing_touches.sh:471-472). ArkOS - # dedf4278 updates both in place (Update-RG351MP.sh:1003,1443). - ("ArkOS", ("themes", "launchimages")), +# Each rule's values must all be there, with bios/, on a drive the system +# does not call fixed (games_part). One system may have several rules: any +# one of them is its proof. +_GAMES_PART_RULES = ( + # ArkOS and dArkOS mount EASYROMS as /roms. Every default tree ArkOS + # ships for it holds bgmusic/, from the first one (christianhaitian/arkos + # 9664c869, 2020-11-30, defaultromsfolderstructure.tar) through the + # per-device trees of c14afbe5 (rg351p, rgb10, rk2020) to f41b9485, and + # an RG351P card older than them gets it from the 11232020 update + # (Update-RG351P.sh:238-242). launchimages/ is in every tree from + # 495eb210 (2020-12-16) on. dArkOS daecf488 creates both + # (game_systems.txt:23,54, finishing_touches.sh:288-290,420-430). + # themes/ is no proof: the 12262020 update left it on the system part of + # a card with under 1 GB free (Update-RGB10.sh:59-69), and 2020's trees + # hold none (code_audit_23 P3-1). + _OsRule("retroarch", "ArkOS", "games_part", ("bgmusic",), ""), + _OsRule("retroarch", "ArkOS", "games_part", ("launchimages",), ""), # AmberELEC 5a3882cb fills GAMES at every boot: homebrew/ from its ports # with gamelist.xml (packages/amberelec/sources/autostart.sh:93-94, # 110-113) and gamedata/ for RetroArch, PPSSPP, DOSBox and Hatari - # (autostart.sh:123-145). - ("AmberELEC", ("gamedata", "homebrew")), + # (autostart.sh:123-145). Its list is written below that part's root. + _OsRule(_STANDIN_MANIFESTS["AmberELEC"], "AmberELEC", "games_part", + ("gamedata", "homebrew"), ""), # EmuELEC d4c7b2f1 copies its default bezels to EEROMS at every boot # (packages/sx05re/emuelec/bin/emuelec_autostart.sh:78-81). - ("EmuELEC", ("bezels/default.cfg",)), + _OsRule(_STANDIN_MANIFESTS["EmuELEC"], "EmuELEC", "games_part", + ("bezels/default.cfg",), ""), ) # GetDriveTypeW: DRIVE_FIXED, DRIVE_REMOTE, DRIVE_CDROM and DRIVE_RAMDISK, @@ -7367,13 +7513,23 @@ def _fixed_partition(mount: Path, mountinfo: str, sys_root: Path) -> bool: return False -def _games_part_system(mount: Path) -> str: - """The handheld system whose games part mount is, as the files it - writes there say, "" for none (_GAMES_PART_MARKERS).""" - if not _is_dir(mount / "bios"): - return "" - return next((name for name, markers in _GAMES_PART_MARKERS - if all(_exists(mount / marker) for marker in markers)), "") +def _games_part_matches(rule: _OsRule, root: Path) -> bool: + """Whether root is the games part rule's system writes: its bios folder + and every value of the rule on root's own drive, on a drive the system + does not call fixed.""" + return (_is_dir(root / "bios") + and all(_exists(root / value) and _on_the_drive(root, value) + for value in rule.values) + and not _drive_not_removable(root)) + + +def games_part(root: Path) -> _OsRule | None: + """The rule naming the handheld system whose games part root is, by the + files that system writes there, None for none (_GAMES_PART_RULES). The + one reading of a games part: AmberELEC's and EmuELEC's are their own + cards everywhere (card_system, their card routes, line mode), ArkOS's a + card of a system that reads RetroArch's list (unnamed_cards).""" + return next((rule for rule in _GAMES_PART_RULES if _games_part_matches(rule, root)), None) def _unnamed_candidates() -> list[Path]: @@ -7386,18 +7542,19 @@ def _unnamed_candidates() -> list[Path]: def unnamed_cards() -> list[Path]: - """The mounted drives no file names that are a handheld's games part: - ArkOS's EASYROMS, AmberELEC's GAMES, EmuELEC's EEROMS, each told by the - folders its system writes there (_GAMES_PART_MARKERS), on a drive the - system does not call fixed. Which of the systems that read RetroArch's + """The mounted drives no file names that are a handheld's games part + read with RetroArch's list: ArkOS's EASYROMS, told by the folders it + writes there (games_part). Which of the systems that read RetroArch's list runs them cannot be told, but they are a handheld's card: "Nothing was found here" over one, and two presses of Enter filled this computer's folder for a RetroArch that was not there (tui_audit_14 P2-3). A bios folder and a console folder were the rule, and a data drive or WSL's D: disk with bios/, snes/ and Music/ took the list - (code_audit_22 P2-1).""" + (code_audit_22 P2-1). AmberELEC's GAMES and EmuELEC's EEROMS are their + systems' own cards (card_system): RetroArch's list went on them under + Enter (tui_audit_16 P2-1).""" return [mount for mount in _unnamed_candidates() - if _games_part_system(mount) and not _drive_not_removable(mount)] + if getattr(games_part(mount), "manifest", "") == "retroarch"] def unproven_drives() -> list[Path]: @@ -7407,15 +7564,49 @@ def unproven_drives() -> list[Path]: return [mount for mount in _unnamed_candidates() if mount not in cards] +def _names_and(names: list[str]) -> str: + """Names in a sentence: A, A and B, A, B and C.""" + return " and ".join(names) if len(names) < 3 else f"{', '.join(names[:-1])} and {names[-1]}" + + def unproven_words() -> str: - """The plain note on those drives, "" for none.""" + """The plain note on those drives, "" for none: what was looked for on + them, and what the reader does (tui_audit_16 P3-9).""" drives = unproven_drives() if not drives: return "" - names = ", ".join(_mount_name(mount) for mount in drives) - has, it = ("has", "it") if len(drives) == 1 else ("have", "them") - return (f"{names} {has} a bios folder, and nothing on {it} shows a handheld's card: " - "listed, not chosen.") + names = _names_and([_mount_name(mount) for mount in drives]) + has, it = ("has", "it") if len(drives) == 1 else ("have", "one") + return (f"{names} {has} a bios folder but no folder a handheld system writes. " + f"Pick {it} below if your files go there.") + + +def card_folder_words(cards: list[Path], card: Path) -> str: + """Where card, one of the handhelds' cards in the reader, keeps the + files, as both modes say it: the card named where it is one of two + (code_audit_22 P3-2), BIOS files named where "keeps them" followed the + title Which folder? and no noun (tui_audit_16 P3-9).""" + which = "The card in the reader" if len(cards) == 1 else _mount_name(card) + return f"{which} keeps BIOS files in its bios folder" + + +def _said_with(label: str, words: str) -> str: + """label with words in its parenthesis, or in one of their own: two side + by side read as a slip (tui_audit_16 P3-9).""" + return f"{label[:-1]}, {words})" if label.endswith(")") else f"{label} ({words})" + + +def numbered_folder(label: str, folder: Path) -> str: + """A folder numbered at the folder question: its row's label, then the + folder and whether it exists in one parenthesis. "DATA: bios (card or + drive) (/media/me/DATA/bios)" put two side by side (tui_audit_16 + P3-9). A label that spells its folder (the Windows side's) is not + followed by it again. Said of a folder that is missing, as the screen + says it (code_audit_21 P3-7).""" + shown = shown_path(folder) + said = [] if shown in label else [shown] + said += [] if _is_dir(folder) else ["does not exist yet"] + return _said_with(label, ", ".join(said)) if said else label def unnamed_card_words(cards: list[Path], tail: str = ".") -> str: @@ -7498,8 +7689,7 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, default = cards[0] / "bios" # Two cards: the one Enter takes is named, "The card" was one of # two (code_audit_22 P3-2). - which = "The card in the reader" if len(cards) == 1 else _mount_name(cards[0]) - print(f" {which} keeps them in its bios folder: {shown_path(default)}.") + print(f" {card_folder_words(cards, cards[0])}: {shown_path(default)}.") elif found is not None: print(f" {name} reads its BIOS files from: {shown_path(found)}.") elif default is not None: @@ -7508,23 +7698,14 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, f"{state}.") # A drive is offered by the BIOS folder its system reads, the screen's # rows: never its root, and never for a system that reads another list. - targets = (share_targets(plat) if shared else - [*(other_side(os_type, plat) if found is None else []), *drive_targets(plat)]) + targets = (share_targets(plat) if shared else folder_drives(os_type, plat, found is None)) if cards: # The card first, this computer's usual folder after it. targets = [*targets, (f"{side_name(os_type, plat)}'s usual folder", _default_dest(os_type, plat))] targets = [target for target in targets if target[1] is not None] for index, (label, folder) in enumerate(targets, 1): - # Said of a folder that is missing, as the screen says it: "created" - # was said of folders nobody had created (code_audit_21 P3-7). - missing = "" if _is_dir(folder) else "does not exist yet" - # A label that spells its folder (the Windows side's) is not - # followed by it again. - shown = shown_path(folder) - print(f" {index}) {label}" + ( - f" ({shown}{', ' + missing if missing else ''})" if shown not in label - else f" ({missing})" if missing else "")) + print(f" {index}) {numbered_folder(label, folder)}") left_out = "" if shared else drives_left_out(plat) if left_out: print(f" {left_out}") @@ -7815,8 +7996,24 @@ def _size_lines(size: str, room: int, words: int = 0) -> list[str]: for joint in (", and ", ", +", ", all "): own, found, shared = size.partition(joint) if found: - return [*_wrap(f"{own},", room, ""), *_wrap(found[2:] + shared, room, "")] - return _wrap(size, room, "") + return [*_no_widow(_wrap(f"{own},", room, ""), room), + *_no_widow(_wrap(found[2:] + shared, room, ""), room)] + return _no_widow(_wrap(size, room, ""), room) + + +def _no_widow(lines: list[str], room: int) -> list[str]: + """Wrapped lines whose last one is not a word alone: "shared" stood on a + line of its own under its size at 40 columns (tui_audit_16 P3-5). The + word before it goes down with it where both fit.""" + if len(lines) < 2 or len(_glued_words(lines[-1])) > 1: + return lines + # A size goes down with its unit: "1.4" / "MB shared" split it. + words = _glued_words(lines[-2]) + lead = lines[-2][:len(lines[-2]) - len(lines[-2].lstrip(" "))] + moved = f"{words[-1]} {lines[-1].strip()}" + if len(words) < 2 or cell_width(moved) > room: + return lines + return [*lines[:-2], lead + " ".join(words[:-1]), moved] def _pick( @@ -7835,9 +8032,10 @@ def _pick( print(f"\n{title}") shown = {name: screen_text((labels or {}).get(name, name)) for name, _files in rows} sizes = {name: sizer(files) for name, files in rows} - # Python 3.8 gives a pty of no size as 0 columns, where 3.11 falls back: - # a list laid out in 0 columns never ended. - cols = max(shutil.get_terminal_size((80, 24)).columns or 80, 20) + # One cell short of the terminal: a line as wide as it was folded by + # some terminals, the next row then starting on its last cell + # (tui_audit_16 P3-5). + cols = terminal_size()[0] - 1 # The fewest lines, the whole words where they cost no line: one # wording for the whole list, the id dropped where it takes a tenth # more lines (40 columns). @@ -7854,8 +8052,8 @@ def _pick( if notes: # At the terminal's width too: the note on the other systems passed # 40 columns. - print("\n" + "\n".join(piece for text in notes for line in text.split("\n") - for piece in _wrap(line, cols - 1, ""))) + print("\n" + "\n".join(piece for text in notes + for piece in _no_widow(_wrap(text, cols, ""), cols))) hint = "order of preference, e.g. 2,1" if ordered else "e.g. 1,3-5" while True: text = _ask(f"Numbers ({hint}), Enter for all, q to quit: ") @@ -7898,12 +8096,17 @@ def _pick_lines(rows: list[tuple[str, list[dict]]], shown: dict, sizes: dict, lines.append(f"{number}{_pad(label, name_width)} {parts[0]}") lines += [f"{' ' * column}{part}" for part in parts[1:]] continue - whole = _size_lines(size, cols, words)[0] if words else size + # The size beside the label only where all of it fits there: its + # first part alone left "21 files, 1.4 MB," with its shared files + # unsaid (tui_audit_16 P3-5). + fitted = _size_lines(size, cols, words) + whole = fitted[0] if len(fitted) == 1 else size if cell_width(f"{number}{label} {whole}") <= cols: lines.append(f"{number}{label} {whole}") continue lines += [f"{number}{part}" if not at else f"{' ' * lead}{part}" - for at, part in enumerate(_wrap(label, cols - lead, ""))] + for at, part in enumerate(_no_widow(_wrap(label, cols - lead, ""), + cols - lead))] at = column if name_width and cols - column >= least else lead lines += [f"{' ' * at}{part}" for part in _size_lines(size, cols - at, words)] return lines @@ -7940,6 +8143,25 @@ def emptied_words(names: list[str], game_data: bool) -> str: return f"{subject} {verb} {why}: nothing is left to install." +def game_data_only(named: list[str], files: list[dict]) -> list[str]: + """The systems named with --system whose files are all game data, of + the list with its game data: --no-game-data leaves them empty.""" + return [system for system in named + if any(_chosen(entry, {system}) for entry in files) + and all(entry.get("category") == GAME_DATA for entry in files + if _chosen(entry, {system}))] + + +def game_data_only_words(titles: list[str]) -> str: + """Said on the plan and in line mode's head of those systems, before the + question: "For 1 of 8 systems (--system)" stood for two systems named + and said nothing of the other (tui_audit_16 P3-7). "" for none.""" + if not titles: + return "" + verb = "holds" if len(titles) == 1 else "hold" + return f"{_names_and(titles)} {verb} only game data: left out (--no-game-data)." + + def system_title(system: str, manifest: dict | None) -> str: """A system as the screen's Choose systems names it (_tui_system_label).""" names = ((manifest or {}).get("system_names") or {}).get(system) @@ -8096,6 +8318,7 @@ def _prompt_custom_selection( given: tuple[list[str], list[str], list[str]] | None = None, manifest: dict | None = None, narrowed: bool = False, command: Callable[[object], str] | None = None, cut: list[bool] | None = None, + note: str = "", ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. @@ -8118,11 +8341,26 @@ def _prompt_custom_selection( Enter installs. c installed as soon as the cores were answered, "Enter for all" read as a choice. narrowed says the list was narrowed before the menu (--target, --no-game-data): the data trees kept are then those - of the systems kept, as the plan and --dry-run count them. + of the systems kept, as the plan and --dry-run count them. note is said + under the head (game_data_only_words). """ trees = data_trees or [] current = given toggled = False + if bios_path is not None and not dry_run and current != INSTALLED_ONLY: + kept = menu_choice(files, omitted, installed, current, False)[0] + kept_trees = select_data_directories({"data_directories": trees}, kept, + narrowed or current is not None) + if kept and all_in_place(kept, kept_trees, bios_path): + # Read before the menu, as the screen reads before it asks: a + # rerun was offered an install that installs nothing, then told + # so twice (tui_audit_16 P3-4). The check after it reads none + # of it again (_HEAD_READS). + print(f"\n{name or display_name(plat)}: " + f"{_plural(len(kept) + sum(len(t.get('files') or {}) for t in kept_trees), 'file')}" + ", every one in place: nothing to install.") + print(f" BIOS folder: {shown_path(shown_bios_folder(bios_path, kept))}") + return current while True: cutting = bool(cut and cut[0]) shown, shown_omitted = ((leave_out_game_data(files, omitted)[:2]) @@ -8143,7 +8381,8 @@ def _prompt_custom_selection( _print_menu_head(plat, shown, shown_omitted, installed, name, trees, bios_path, current, object() if toggled else given, manifest, narrowed, menu_choice(files, omitted, installed, current, cutting), - games.games if cutting else 0, emptied) + games.games if cutting else 0, emptied, + note if current is given else "") if (current is not given or toggled) and command is not None and not emptied: print(f" Same choice without questions: {screen_text(command(current))}") offline = _offline_only() @@ -8213,7 +8452,7 @@ def _print_menu_head( installed: tuple[list[dict], list[dict]] | None, name: str, trees: list[dict], bios_path: Path | None, current: object, given: object, manifest: dict | None, narrowed: bool, chosen: tuple[list[dict], list[dict]] | None = None, - cut_games: int = 0, emptied: str = "", + cut_games: int = 0, emptied: str = "", note: str = "", ) -> None: """The plan above the line menu: what the choice holds, its folder, what stands in its way and where the save files go, as the screen's plan. @@ -8263,6 +8502,8 @@ def _print_menu_head( print(f"\n{name or display_name(plat)}: " + (found or ( f"{_plural(counts.files + data, 'file')}, " f"{format_size(counts.size + data_size)}")) + named + ".") + if note: + print(f" {note}") categorized = bool((manifest or {}).get("categorized")) kinds = counts if categorized else tally({"size": e.get("size")} for e in shown) parts = parts_words(kinds, data, data_size, cut_games) @@ -8283,15 +8524,22 @@ def _print_menu_head( # screen name it: "Onion's" before the menu, "RetroArch's" after. for line in save_lines(shown, kept_omitted, bios_path, display_name(plat, manifest)): print(f" {line}") + # The required files the collection cannot supply, named as the screen's + # plan names them (tui_audit_16 P3-6). + for line in unavailable_lines([entry for entry in kept_omitted if entry.get("required")], + manifest, bios_path, (manifest or {}).get("files") or shown): + print(f" {line}") def found_head(files: list[dict], trees: list[dict], bios_path: Path) -> str: """What the --from folders hold of the files not in place, and what is left, in the screen plan's words: "6 found in OLD, 16 need the - internet". "" when they hold none of them. A file in place is one of - the right size: the run hashes what is in place once, after Enter - (check_local, data_directory_state), and a card read twice is read at - the speed of its reader.""" + internet". "" when they hold none of them. A file of the wrong size is + not in place. One of the right size is read, as check_local reads it: + judged by its size, a wrong copy of the right size was in place, and + "nothing to download" stood over a run that left it (code_audit_23 + P3-2). What is read here is kept for the check after Enter + (_HEAD_READS): a card read twice is read at the speed of its reader.""" def held(path: Path, size: object) -> bool: try: @@ -8305,7 +8553,8 @@ def found_head(files: list[dict], trees: list[dict], bios_path: Path) -> str: path = _entry_path(bios_path, entry) except ValueError: continue - if not held(path, entry.get("size")): + if not held(path, entry.get("size")) or \ + _classify_local(entry, path, remember=True) != "up_to_date": needed.append(entry) copies = plan_local_copies(needed, bios_path, None, LOCAL.folders) if not copies: @@ -8315,12 +8564,9 @@ def found_head(files: list[dict], trees: list[dict], bios_path: Path) -> str: by_folder[copy.index.label] = by_folder.get(copy.index.label, 0) + 1 data, data_bytes = 0, 0 for tree in trees: - root = (_destination_path(bios_path, tree["dest"]) if tree.get("dest") - else bios_path) - for rel, meta in (tree.get("files") or {}).items(): - if not held(_destination_path(root, rel), meta.get("size")): - data += 1 - data_bytes += meta.get("size", 0) + _root, wanted, _present = data_directory_state(tree, bios_path, remember=True) + data += len(wanted) + data_bytes += sum(meta.get("size", 0) for meta in wanted.values()) left = [entry for entry in needed if id(entry) not in copies] rest = len(left) + data where = ", ".join(f"{count} found in {_last_name(label)}" @@ -8328,10 +8574,39 @@ def found_head(files: list[dict], trees: list[dict], bios_path: Path) -> str: if not rest: return f"{where}, nothing to download" if _offline_only(): - return f"{where}, {rest} {'needs' if rest == 1 else 'need'} the internet" + return f"{where}, {agree(rest, 'needs', 'need')} the internet" return f"{where}, {rest} to download ({format_size(_bytes(left) + data_bytes)})" +def all_in_place(files: list[dict], trees: list[dict], bios_path: Path) -> bool: + """Whether every file and every data tree file is in place, read as the + check reads it. Sizes first: a folder missing a file is told without a + read. What is read is kept for the check (_HEAD_READS).""" + paths = [] + for entry in files: + try: + path = _entry_path(bios_path, entry) + if not _is_file(path) or path.stat().st_size != entry.get("size"): + return False + except (OSError, ValueError): + return False + paths.append((entry, path)) + for tree in trees: + root = (_destination_path(bios_path, tree["dest"]) if tree.get("dest") + else bios_path.resolve()) + for rel, meta in (tree.get("files") or {}).items(): + target = _destination_path(root, rel) + try: + if not _is_file(target) or target.stat().st_size != meta.get("size"): + return False + except OSError: + return False + return (all(_classify_local(entry, path, remember=True) == "up_to_date" + for entry, path in paths) + and all(not data_directory_state(tree, bios_path, remember=True)[1] + for tree in trees)) + + def menu_choice( files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None, current: object, cut: bool, @@ -8370,7 +8645,9 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N if len(listed) > 1 else []) # Said under the list it explains, before the question: above # "Systems:" it read as a heading (tui_audit_13 P3-13). - note = (f"'{OTHER_TITLE}' holds what cores load for systems this platform\n" + # One sentence wrapped at the width: its line break, placed for a + # shorter title, left "platform" alone on a line (tui_audit_16 P3-5). + note = (f"'{OTHER_TITLE}' holds what cores load for systems this platform " "does not list, such as game engines and extra computers." if OTHER_SYSTEMS in systems_listed else "") labels = {} @@ -9857,18 +10134,20 @@ def readers(entry: dict, manifest: dict) -> list[str]: def opened_line(entry: dict, manifest: dict, bios_path: Path) -> str: - """Where the emulators read an entry, and which ones.""" + """Where the emulators read an entry, and which ones: "read by mgba at + ...", "expected at ..." where the list names none. "opened at" under + "missing:" read as a file that was opened (tui_audit_16 P3-12).""" try: where = _entry_path(bios_path, entry) except ValueError: where = bios_path / str(entry.get("dest")) cores = readers(entry, manifest) if not cores: - return f"opened at {where}" + return f"expected at {where}" shown = ", ".join(cores[:SHOWN_CORES]) if len(cores) > SHOWN_CORES: shown += f" and {len(cores) - SHOWN_CORES} more" - return f"opened at {where} by {shown}" + return f"read by {shown} at {where}" def _known_destinations(files: list[dict]) -> set[str]: @@ -10348,22 +10627,27 @@ def emit_unreadable(plat: str, found: list[Unreadable]) -> None: def sources_line( folders: list[tuple[str, int]], fixed: int, downloaded: int, missing: int, - fetched: str = "downloaded", shared: int = 0, + fetched: str = "downloaded", shared: int = 0, wrong: int = 0, failed: int = 0, ) -> str: """Where the files of a run that copied some came from, in words: "38 files copied from E:\\, 4 downloaded, 2 still missing." A file found in the wrong place of the BIOS folder is "copied", as the plan says: - "fixed" read as a wrong copy, of files that were missing.""" + "fixed" read as a wrong copy, of files that were missing. wrong counts + the wrong copies left in place, apart from missing: "1 still missing" + was said of a file --check counts as a wrong copy (code_audit_23 P3-3). + failed counts the files a run tried and could not write or fetch.""" parts = [(count, f"copied from {screen_text(shown_path(label))}") for label, count in folders] parts.append((fixed, "copied from the wrong place in the BIOS folder")) parts.append((shared, "copied from another place of the BIOS folder")) if not any(count for count, _what in parts): return "" - parts += [(downloaded, fetched), (missing, "still missing")] + parts += [(downloaded, fetched), (failed, "failed"), (missing, "still missing")] words = [] for count, what in parts: if count: words.append(f"{_plural(count, 'file') if not words else count} {what}") + if wrong: + words.append(f"{_plural(wrong, 'wrong copy')} left") return ", ".join(words) + "." @@ -11575,10 +11859,15 @@ def _card_rules() -> list[_OsRule]: rules.append(rule._replace( values=tuple(value[len(mount):] for value in rule.values), bios=rule.bios[len(mount):], configs=())) + # The games part of a system whose own list is written below it: its + # files name the card as surely as Onion's .tmp_update (games_part). + rules += [rule for rule in _GAMES_PART_RULES if rule.name in _GAMES_PART_CARDS] return rules def _card_rule_matches(rule: _OsRule, root: Path) -> bool: + if rule.method == "games_part": + return _games_part_matches(rule, root) if rule.method == "file_matches": path, pattern = rule.values try: @@ -11816,10 +12105,12 @@ def card_setups() -> list[CardSetup]: # formats it exFAT as GAMES (packages/sysutils/busybox/scripts/ # fs-resize:64), autostart.sh:93 fills it as "the GAMES volume". EmuELEC # d4c7b2f1 makes EEROMS (fs-resize:127-140), mount_romfs.sh:10-14 mounts - # it there. A computer sees that part's root. No file either writes - # there names it. - setups += [CardSetup(name, plat, "") for name, plat in _STANDIN_MANIFESTS.items() - if name in _GAMES_PART_CARDS] + # it there. A computer sees that part's root, told by what each writes + # there at every boot (_GAMES_PART_RULES): a card not started once + # holds none of it yet, as a KNULLI card before its first start. + setups += [CardSetup(name, plat, "", tuple(rule for rule in rules if rule.name == name), + True) + for name, plat in _STANDIN_MANIFESTS.items() if name in _GAMES_PART_CARDS] for name in _systems_reading().get("retroarch", []): below = next((rule.bios[len(_card_mount(rule.bios)):] for rule in _EMBEDDED_RULES if rule.name == name and rule.bios and _card_mount(rule.bios)), "") @@ -11862,8 +12153,13 @@ def setup_bios_folder(plat: str, path: Path) -> Path: """The BIOS folder a setup found is announced with, before its list is read: a MinUI card's Bios below its root, the folder found else. The first screen and line mode's Found named /media/me/MINUI, the plan - then /media/me/MINUI/Bios.""" - return path / "Bios" if plat in CARD_PLATFORMS else path + then /media/me/MINUI/Bios. An AmberELEC or EmuELEC games part, whose + list writes bios/ below its root (shown_bios_folder), is announced by + that bios folder.""" + if plat in CARD_PLATFORMS: + return path / "Bios" + rule = games_part(path) if plat in _STANDIN_MANIFESTS.values() else None + return path / "bios" if rule is not None and rule.manifest == plat else path def card_setup(named: str, plat: str) -> CardSetup | None: @@ -11908,6 +12204,9 @@ def _not_the_card(setup: CardSetup, folder: Path, card: Path, other: str) -> Car marker = (CARD_MARKER if setup.plat in CARD_PLATFORMS else setup.rules[0].values[0].split("/")[0] if setup.rules else "") detail = f"{name} keeps {marker} at the root of its card" if marker else "" + if setup.name in _GAMES_PART_CARDS and setup.rules: + held = _names_and([value.split("/")[0] for value in setup.rules[0].values]) + detail = f"{name} keeps {held} beside bios on the games part of its card" if " and " in other: return CardChoice(setup.plat, folder, refused=f"{shown} holds the files of {other}, " f"not of {name}", detail=detail, @@ -12657,8 +12956,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print(line) sys.exit(1) # A folder typed, or a card's root, is checked there. + # Said once, by the detection line above: "Nothing was found + # here." came twice, and after "A card is in the reader" + # (tui_audit_16 P3-2). platforms = _prompt_manual_platform( - os_type, not (args.check or args.dry_run or args.yes), check_typed=True) + os_type, not (args.check or args.dry_run or args.yes), check_typed=True, + title="Which setup?") offer = False else: for name, path in platforms: @@ -12712,6 +13015,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: total_declined = 0 # Files --offline left out at a terminal: missing, never "as answered". total_offline = 0 + # Of those, the wrong copies left in place: the result names them apart + # from the files still missing, as --check does (code_audit_23 P3-3). + total_wrong_left = 0 + # The BIOS list's files it left out, wrong copies aside: the result + # counts them by kind. + offline_left: list[dict] = [] check_pending = 0 total_verified = 0 files_pending = 0 @@ -12974,6 +13283,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: bool(target or args.installed_cores or args.declared_only or len(files) + game_data_cut + len(unplaced) != listed), command_for, cut, + game_data_only_words([system_title(system, manifest) for system in + game_data_only(given[0] if given else [], files)]) + if args.no_game_data else "", ) if selection is None and LOCAL.pack is not None: smaller = " Nothing copied." @@ -13074,14 +13386,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for entry in omitted_files: reason = entry.get("reason", "unknown") reasons[reason] = reasons.get(reason, 0) + 1 - reason_summary = ", ".join( - f"{OMISSION_REASONS.get(reason, reason)}: {count}" - for reason, count in sorted(reasons.items()) - ) - print( - f" Not installable: {_plural(len(omitted_files), 'file')} " - f"({required_omitted} required, {reason_summary})." - ) + print(f" {not_installable_words(len(omitted_files), required_omitted, reasons)}") total_omitted += len(omitted_files) # Named for the end of the run: ROCKNIX's own list leaves them out, # and the end said "the frontend's save directory". @@ -13118,11 +13423,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # the check, the events, the plan and the install read this # (code_audit_22 P3-3). data_states = [(entry, data_directory_state(entry, bios_path)) for entry in data_dirs] + head_reads_done() trees_missing = sum(len(state[1]) for _entry, state in data_states) trees_held = head_data - trees_missing print(f" {len(up_to_date) + trees_held} in place, " f"{run_counts.files - present + trees_missing} missing, " - f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") + f"{_plural(wrong, 'wrong copy')}") # A partial copy left by an interrupted run is useless once the file # is in place: it goes. @@ -13249,6 +13555,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # Not done: the summary counts them as missing. files_pending += len(fetched) total_offline += len(fetched) + wrong_ids = {id(entry) for entry in mismatched} + total_wrong_left += sum(1 for f in fetched if id(f) in wrong_ids) + offline_left += [f for f in fetched if id(f) not in wrong_ids] to_download = [f for f in to_download if id(f) in copies] fetched = [] # What the pack does not hold is said before a size is promised: @@ -13388,7 +13697,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if interrupted: total_interrupted = True break - else: + elif len(platforms) > 1: + # One setup's run ends on "Done. Everything is in place.": said + # here too, it came twice (tui_audit_16 P3-4). print(f"\n {EVERYTHING_IN_PLACE}") total_up_to_date += len(up_to_date) @@ -13407,7 +13718,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # Left out as the BIOS files above are: counted missing, # not as one failure per tree. print(f" {entry['ref']}: {counts['present']} already in place, " - f"{counts['absent']} need the internet ({offline_named()})") + f"{agree(counts['absent'], 'needs', 'need')} the internet " + f"({offline_named()})") total_offline += counts["absent"] files_pending += counts["absent"] continue @@ -13500,23 +13812,28 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: same_choice=(_INVOCATION.command(*platforms[0]) if interactive and len(platforms) == 1 else ""), sources=_run_sources(bios_indexes, total_downloaded, - total_errors + total_offline), + total_offline - total_wrong_left, total_wrong_left, + total_errors), offline=total_offline, term=term, notes=version_notes, + missing=tally(offline_left) if categorized else None, ) finish_app(args, platforms, app_cards, interactive and not total_declined) -def _run_sources(bios_indexes: list[FolderIndex], installed: int, failed: int) -> str: - """The end line saying which folder gave how many of the files.""" +def _run_sources(bios_indexes: list[FolderIndex], installed: int, missing: int, + wrong: int = 0, failed: int = 0) -> str: + """The end line saying which folder gave how many of the files: missing + the files left out, wrong the wrong copies left in place, failed the + files the run could not write or fetch.""" folders = [(index.label, index.copied) for index in LOCAL.folders] fixed = sum(index.fixed for index in bios_indexes) shared = sum(index.copied for index in bios_indexes) fetched = installed - fixed - shared - sum(count for _label, count in folders) - return sources_line(folders, fixed, max(fetched, 0), failed, + return sources_line(folders, fixed, max(fetched, 0), missing, "copied from the pack" if LOCAL.pack is not None else "downloaded", - shared) + shared, wrong, failed) # Full-screen interface @@ -15098,11 +15415,11 @@ def tui_rows(state: TuiState) -> list[TuiRow]: # Every row whose folder is missing says so, in both modes: the # Windows row alone did (code_audit_21 P3-7). drives = [] if state.folder_kind == "share" else [ - TuiRow("drive", label + ("" if _is_dir(folder) else " (does not exist yet)"), + TuiRow("drive", label if _is_dir(folder) else _said_with(label, + "does not exist yet"), value=folder) - for label, folder in [*(other_side(state.os_type, state.platform) - if state.folder_kind == "usual" else []), - *drive_targets(state.platform)]] + for label, folder in folder_drives(state.os_type, state.platform, + state.folder_kind == "usual")] cards = {mount / "bios" for mount in state.unnamed} if state.folder_kind == "usual" and any(row.value in cards for row in drives): # A handheld's card in the reader, no setup found here: the @@ -17346,9 +17663,11 @@ def _tapped_row(state: TuiState) -> tuple: def _armed(state: TuiState) -> TuiKey | None: """The key a first tap armed on this screen, a second tap pressing it: - a bar entry that writes files, or the row in focus it acts on. A tap + a bar entry that writes files, or the row in focus a tap chose. A tap on the focused Install row was counted and drawn nowhere - (tui_audit_15 P3-1).""" + (tui_audit_15 P3-1), and one on any other focused row too: the row a + beginner taps first gave no sign the tap was seen (tui_audit_16 P3-8). + A tick is no choice: a tap ticks it at once.""" tapped = state.tapped if state.help or not tapped: return None @@ -17357,7 +17676,8 @@ def _armed(state: TuiState) -> TuiKey | None: and _bar_commits(state, tapped[3])) return tapped[3] if armed else None enter = TuiKey("enter") - return enter if tapped == _tapped_row(state) and _bar_commits(state, enter) else None + return (enter if tapped == _tapped_row(state) and tapped[2] not in TUI_TICK_ROWS + and not state.editing else None) def _armed_hint(state: TuiState, parts: tuple, width: int) -> tuple: @@ -17389,12 +17709,15 @@ def _armed_hint(state: TuiState, parts: tuple, width: int) -> tuple: def _armed_label(state: TuiState, row: TuiRow, width: int) -> TuiRow: """The row in focus drawn armed as its bar entry is, in the longest - words that fit the row.""" + words that fit the row. A row that names a choice (a setup, a folder) + keeps its name where the words do not fit beside it, the bar saying + them: "Continue: tap again" in its place hid which setup was tapped.""" verb = _ROW_VERBS.get(row.action, row.label) - for label in (f"{row.label}: {TUI_ARMED}", f"{row.label}: {TUI_ARMED_SHORT}", - f"{verb}: {TUI_ARMED_SHORT}"): + for label in (f"{row.label}: {TUI_ARMED}", f"{row.label}: {TUI_ARMED_SHORT}"): if cell_width(f"> {label}") <= width: return row._replace(label=label) + if row.action not in ("install", "addapp"): + return row return row._replace(label=f"{verb}: {TUI_ARMED_SHORT}") @@ -17656,9 +17979,15 @@ def _back_hint(state: TuiState, parts: tuple) -> tuple: return tuple(word if part in ("B/Esc Back", "B/Esc Quit") else part for part in parts) -def _route_hint(state: TuiState, parts: tuple) -> tuple: - """The bar's entries named as the run's input names them.""" - route = TUI_ROUTES.get(state.pad) +def _route_hint(state: TuiState, parts: tuple, width: int | None = None) -> tuple: + """The bar's entries named as the run's input names them. On Android + with no pad route named (Termux on a phone), a bar too narrow for both + names keeps the key's: it said "A Continue" where a phone has an Enter + key and no A button, and the a key does nothing (tui_audit_16 P3-13). + The help still names both.""" + pad = ("keys" if state.pad == "both" and state.os_type == "android" + and width is not None and width < TUI_LONG_BAR_WIDTH else state.pad) + route = TUI_ROUTES.get(pad) if route is None or state.help: return parts names = route.bar @@ -17889,7 +18218,7 @@ def _compose( room -= len(top_lines) return top_lines + _compose(state, width, room, before, rows, after) + footer_lines focus = max(0, min(state.focus.get(state.screen, 0), len(rows) - 1)) if rows else 0 - armed = rows and _armed(state) is not None and rows[focus].action in ("install", "addapp") + armed = bool(rows) and _armed(state) is not None row_lines = [_row_line(_armed_label(state, row, width) if armed and index == focus else row, index == focus, width) for index, row in enumerate(rows)] for index, (row, line) in enumerate(zip(rows, row_lines)): @@ -18075,13 +18404,12 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: where = ", ".join(f"{len(events)} found in {_last_name(folder)}" for folder, events in elsewhere.items()) rest = fetch + data - headline = (f"{where}, {rest} need the internet ({offline_named()})" + headline = (f"{where}, {agree(rest, 'needs', 'need')} the internet ({offline_named()})" if rest and offline else f"{where}, {rest} to download ({format_size(fetch_bytes + data_bytes)})" if rest else f"{where}, nothing to download") elif need and fetch and offline: - both = fetch + data > 1 - headline = (f"{_plural(fetch + data, 'file')} {'need' if both else 'needs'} the " + headline = (f"{agree(fetch + data, 'file needs', 'files need')} the " f"internet, which {offline_named()} does not use") elif need and fetch and LOCAL.pack is not None and copied: # One count: "21 files to install from the pack" above "23 to @@ -18146,7 +18474,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: # and "10888 need the internet" sat over "8252 missing" (tui_audit_14 # P3-7). counts = (f"{check['present']} in place, {check['missing'] + data} missing, " - f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") + f"{_plural(wrong, 'wrong copy')}") # A first install's missing files are its normal state, not a warning. lines.append(_styled(counts, "quiet") if not check["present"] and not wrong else counts) # The backup line goes right under the count it explains, before the @@ -18184,7 +18512,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if backup: # With its subject: "They are replaced" sat lines under the count. # Not where nothing can be installed: nothing is replaced then. - lines.insert(count_at, f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " + lines.insert(count_at, f"The {_plural(wrong, 'wrong copy')} " f"{'is' if wrong == 1 else 'are'} replaced, the old " f"{'one' if wrong == 1 else 'ones'} kept in a backup folder.") if state.facts.get("writable") is False and dest is not None: @@ -18346,7 +18674,7 @@ def _view_setup(state: TuiState, width: int, height: int) -> tuple: notes = [TUI_ESDE_SHORT if note == TUI_ESDE_NOTE else note for note in state.notes] body = _compose(state, width, height, before, rows, after + status + notes, pinned=state.refused) - return ("Where do you play?", body, ("A/Enter Continue", "B/Esc Quit", "? Help")) + return (TUI_FOUND_TITLE, body, ("A/Enter Continue", "B/Esc Quit", "? Help")) def _view_platforms(state: TuiState, width: int, height: int) -> tuple: @@ -18355,7 +18683,7 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: title = "Whose files go there?" before = [f"Choose the setup that reads {_short_path(state.dest_given, state.home)}."] else: - title = "Which setup?" + title = TUI_CHOOSE_TITLE before = ["Choose the setup that will use the BIOS files."] pinned = list(state.refused) if state.dest_given is None and not state.setups and len(state.stack) == 1: @@ -18387,6 +18715,7 @@ 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) + first: list = [] if bound and state.folder_default is not None and state.folder_kind == "found": # On the device itself: its own folder, which is no card to look for. title = "Which folder?" @@ -18427,24 +18756,43 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: and tui_rows(state)[0].value in {mount / "bios" for mount in state.unnamed}): cards = [mount for mount in state.unnamed if mount / "bios" == tui_rows(state)[0].value] - which = ("The card in the reader" if len(state.unnamed) == 1 - else _mount_name(cards[0])) - before = [f"{which} keeps them in its bios folder, first below. " - f"{lead}", str(state.folder_default)] + first = [f"{card_folder_words(state.unnamed, cards[0])}, first below."] elif state.folder_kind == "closed": title = "Which folder?" before = [f"Choose another folder for {name}: browse for it or type it."] else: title = "Which folder?" before = [f"No usual {name} folder here: browse for it or type it."] + notes: list = [] if not bound and state.folder_kind != "share": - left_out = drives_left_out(state.platform) - before += [left_out] if left_out else [] - unproven = unproven_words() - before += [unproven] if unproven else [] - return (title, _compose(state, width, height, before, tui_rows(state), _status_after(state), - pinned=state.refused), - ("A/Enter Choose", "B/Esc Back", "? Help")) + notes = [note for note in (drives_left_out(state.platform), unproven_words()) if note] + body = _fitted_compose(state, width, height, first, before, notes, + tui_rows(state), _status_after(state), state.refused) + return (title, body, ("A/Enter Choose", "B/Esc Back", "? Help")) + + +def _fitted_compose(state: TuiState, width: int, height: int, first: list, core: list, + notes: list, rows: list[TuiRow], after: list, pinned: list) -> list: + """_compose with the text core always shown whole above the rows: the + notes go first, the last one first, then the lines before the core, + then a folder that does not fit its lines is drawn on one, cut in its + middle. At 40x12 a note took the room and the folder Use this folder + takes went with it (tui_audit_16 P3-1).""" + def shown(body: list, before: list) -> bool: + top = _text_lines(list(pinned), width) + wanted = len(_text_lines(before, width)) + (len(top) + 1 if top else 0) + drawn = next((index for index, line in enumerate(body) + if (getattr(line, "mark", None) or ("",))[0] == "row"), len(body)) + return drawn >= wanted + candidates = [first + core + notes[:keep] for keep in range(len(notes), -1, -1)] + candidates.append(list(core)) + if core: + candidates.append([*core[:-1], _styled(str(core[-1]), "path")]) + for before in candidates: + body = _compose(state, width, height, before, rows, after, pinned=pinned) + if shown(body, before): + return body + return body def _status_after(state: TuiState) -> list[str]: @@ -18595,7 +18943,7 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: f"About {_plural(len(held), 'file')}, " f"{format_size(sum(e['size'] for e in held))}, for {len(state.ticked)} of " f"{len(state.systems)} systems" - + (f", {rest} {'needs' if rest == 1 else 'need'} the internet " + + (f", {agree(rest, 'needs', 'need')} the internet " f"({offline_named()})" if rest else ""), "title") if not rest: # The remedy was for files of the whole list: none of the @@ -18619,6 +18967,13 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: if state.cli_systems and state.systems else []), *(["--core"] if state.cores else []), *(["--region"] if state.regions else [])] said = ", ".join(named) + # A system --system named that --no-game-data left empty, in line mode's + # words (tui_audit_16 P3-7). + listed = {row["system"] for row in state.systems} + emptied_named = game_data_only_words([ + system_title(system, state.manifest) for system in game_data_only( + [name for name in state.cli_systems if name not in listed], + (state.manifest or {}).get("files") or [])]) if state.no_game_data else "" # Named on the command line: said in line mode's words, and widened by # Choose systems. --core and --region were said in line mode alone # (tui_audit_11 P3-11). @@ -18652,7 +19007,8 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: f"{parts}") lines[at:at] = [*([state.card_note] if state.card_note and state.card_note != state.status else []), - *([f"{said[0].upper()}{said[1:]}."] if said else []), *choice, + *([f"{said[0].upper()}{said[1:]}."] if said else []), + *([emptied_named] if emptied_named else []), *choice, *([parts] if parts else [])] if state.version: # Before the free space, which gives way first: appended last, the @@ -18687,8 +19043,8 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: # One line: in two, a 40x12 plan dropped it, "1 wrong copy" left # alone to say a file would be replaced. wrong = check.get("wrong_hash", 0) - long_form = f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " - lines = [_styled(f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}: backed up, " + long_form = f"The {_plural(wrong, 'wrong copy')} " + lines = [_styled(f"{_plural(wrong, 'wrong copy')}: backed up, " "replaced.") if str(line).startswith(long_form) else line for line in lines] # Above the count it explains, which gives way first: the folder @@ -19060,7 +19416,8 @@ def _space_words(state: TuiState) -> str: return f"The {'card' if card else 'drive'} is full: {match[1]} needed, {match[2]} free." -def _tui_sources(state: TuiState, installed: int, failed: int) -> str: +def _tui_sources(state: TuiState, installed: int, missing: int, wrong: int = 0, + failed: int = 0) -> str: """Where the installed files came from, as the line mode says it: "6 files copied from /media/me/OLD, 42 downloaded, 2 still missing." """ ok = {dest for dest, good, _why in state.finished if good} @@ -19076,8 +19433,9 @@ 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, - "from the pack" if LOCAL.pack is not None else "downloaded") + return sources_line(list(folders.items()), fixed, max(0, installed - copied), missing, + "from the pack" if LOCAL.pack is not None else "downloaded", + wrong=wrong, failed=failed) def _unavailable(state: TuiState) -> tuple[list[dict], int]: @@ -19103,21 +19461,29 @@ def _unavailable_lines(state: TuiState) -> list[str]: """The files that cannot be supplied and stop an emulator, by name and system: "1 file ... an emulator needs it" named neither.""" needed, _others = _unavailable(state) + return unavailable_lines(needed, state.manifest, state.dest, + (state.manifest or {}).get("files") or []) + + +def unavailable_lines(needed: list[dict], manifest: dict | None, dest: Path | None, + files: list[dict]) -> list[str]: + """The screen's plan and line mode's head say these sentences of the + required files the collection cannot supply: line mode counted "1 + required, not found: 37" and never named the file or its folder + (tui_audit_16 P3-6).""" if not needed: return [] - titles = _system_titles(state) - rows = {row["system"]: row for row in state.systems} def system(name: str) -> str: - label = _tui_system_label(rows.get(name) or {"system": name}, titles) + label = system_title(name, manifest) # An id no title names (jazz-jackrabbit-2) read as a raw key. return label.replace("-", " ").title() if label == name else label # Named below the BIOS folder the plan names: bios/Machines/... under a # BIOS folder that is bios/ read as a second one. lead = "" - if state.dest is not None and _plan_folder(state, state.dest) != state.dest: - lead = _plan_folder(state, state.dest).name + "/" + if dest is not None and shown_bios_folder(dest, files) != dest: + lead = shown_bios_folder(dest, files).name + "/" def where(dest: str) -> str: return dest[len(lead):] if lead and dest.startswith(lead) else dest @@ -19139,7 +19505,7 @@ def _unavailable_lines(state: TuiState) -> list[str]: f"{named(firmware)}. Your own copy goes in the BIOS folder.") if games: lines.append(game_data_missing(named(games), len(games), [ - (state.dest / entry["dest"]).parent for entry in games] if state.dest else [])) + (dest / entry["dest"]).parent for entry in games] if dest else [])) return lines @@ -19204,8 +19570,16 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: title = "Done" head = EVERYTHING_IN_PLACE lines = [head] - sources = _tui_sources(state, installed, len(failed) + left) - if left and installed and sources: + ok_dests = {dest for dest, good, _why in state.finished if good} + missing = ([failure["dest"] for failure in offline_failed] + or [event["dest"] for event in state.files + if event["dest"] not in ok_dests][:left]) if left else [] + # A wrong copy left in place is no missing file: --check counts it apart, + # and the result said "1 still missing" of it (code_audit_23 P3-3). + wrong_dests = {event["dest"] for event in state.files if event.get("state") == "wrong_hash"} + wrong_left = sum(1 for dest in missing if dest in wrong_dests) + sources = _tui_sources(state, installed, left - wrong_left, wrong_left, len(failed)) + if (left or failed) and installed and sources: # One count, then the remedy, as line mode says it: the count came # twice, the remedy between them (tui_audit_11 P3-18). lines[0] = sources @@ -19221,11 +19595,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: # What is still missing by kind, as the copies are counted: 6 of the # 16 were game data, and the old card's result said 16 files # (tui_audit_13 P3-14). - ok_dests = {dest for dest, good, _why in state.finished if good} - missing = ([failure["dest"] for failure in offline_failed] - or [event["dest"] for event in state.files - if event["dest"] not in ok_dests][:left]) - apart = _tui_tally(state, [{"dest": dest, "size": 0} for dest in missing]) + apart = _tui_tally(state, [{"dest": dest, "size": 0} for dest in missing + if dest not in wrong_dests]) if _tui_categories(state) and apart.bios and apart.games: lines.append(f"Still missing: {installed_words(apart.bios, apart.games)}.") # Each folder spelled in full, as the result names its others. @@ -19621,7 +19992,7 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: # Typing: Space and ? go into the filter, so the bar names the two # keys that end it, not Tick and Help. hint = ("Enter Done", "Esc Clear") - routed = _route_hint(state, _back_hint(state, hint)) + routed = _route_hint(state, _back_hint(state, hint), width) tapped = _tap_hint(state, routed) if (tapped != routed and "? Help" in _hint(width, *routed) and "? Help" not in _hint(width, *tapped)): diff --git a/install.sh b/install.sh index 64784c6ca..26f947c7e 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511" +DEFAULT_INSTALL_SHA256="86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 89fb6a611..bd040ac20 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": 961225, - "sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511", + "size": 981280, + "sha256": "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1014402, - "sha256": "cdd06d24d30ad6b564d8a1595b906d50ce05d23093121cc7e5d91b5acd8e08ab" + "size": 1034457, + "sha256": "92d78b81459583ea9ac00546e28f3118e815ac302c0f180e882b44a16e5bcb53" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 961225, - "sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511", + "size": 981280, + "sha256": "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 961225, - "sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511", + "size": 981280, + "sha256": "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d", "executable": false }, { diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py index 803804ea3..611ca54cf 100644 --- a/scripts/generate_installer_doc.py +++ b/scripts/generate_installer_doc.py @@ -161,8 +161,13 @@ def _marker(rule: _install._OsRule) -> str: path, pattern = rule.values line = re.sub(r"\\s\*\$$", "", re.sub(r"^(\^|\\A)", "", pattern)).replace("\\", "") return f"`{path}` holding the line `{line}`" - return ", ".join(f"`{value}`" if PurePosixPath(value).suffix else f"`{value}/`" - for value in rule.values) + named = ", ".join(f"`{value}`" if PurePosixPath(value).suffix else f"`{value}/`" + for value in rule.values) + if rule.method == "games_part": + # Told on the games part beside its bios folder, on a drive the + # system does not call fixed (install.games_part). + return f"{named} beside `bios/`, on a drive that is not a fixed disk" + return named def _list_words(plat: str) -> str: @@ -173,7 +178,8 @@ def _list_words(plat: str) -> str: def card_marker_rows() -> list[list[str]]: """The files that name a card or a SHARE drive, in the order they are read: install.py's card rules, then its SHARE drive files.""" - rows = [[_marker(rule), rule.name, f"`{rule.bios}`, {_list_words(rule.manifest)}"] + rows = [[_marker(rule), rule.name, + f"`{rule.bios or 'bios'}`, {_list_words(rule.manifest)}"] for rule in _install._card_rules()] hostname = "system.hostname=BATOCERA" for plat, marker in _install._SHARE_DRIVES: diff --git a/scripts/generate_site.py b/scripts/generate_site.py index b94916ace..bbca89d58 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -3940,8 +3940,13 @@ def first_screen_words() -> list[str]: showed a line menu (Enter, i, c, q) nobody saw, whose keys had changed since.""" installer = _installer() - return [f"The command opens a screen. **{installer.TUI_INSTALL_ROW}** gets every " - f"file, **{installer.TUI_SYSTEMS_ROW}** fewer."] + # The screen it opens first: the setup found, or the list when none + # is. Install and Choose systems come on the next one (tui_audit_16 + # P3-10). + return [f"The command asks **{installer.TUI_FOUND_TITLE}** with the setup it found, " + f"or **{installer.TUI_CHOOSE_TITLE}** with a list when it finds none. Then " + f"**{installer.TUI_INSTALL_ROW}** gets every file, " + f"**{installer.TUI_SYSTEMS_ROW}** only the consoles you pick."] def check_pass_line() -> str: diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 34303b8cc..bb89cdd0d 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -90,12 +90,15 @@ class InstallerOutputExample(unittest.TestCase): intro, run = re.search(r"(?s)\n([^\n]*)\n\n```\n(Fetching file index.*?)```", text).groups() self.assertRegex(intro, r"\d{4}-\d{2}-\d{2}", "an output example says when it was taken") manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8")) - omitted = re.search(r"Not installable: \d+ files? \(\d+ required, (.*)\)\.", run) + # The line as the installer writes it (not_installable_words), or + # its earlier "(1 required, not found: 37)" form. + omitted = re.search(r"Not installable: \d+ files? (?:\(\d+ required, (.*)\)\.|(?:is|are) " + r"not in the collection|.*Why: (.*)\.)", run) if manifest.get("omitted_files"): self.assertIsNotNone(omitted, "the manifest omits files and the run shows no line for it") - if omitted: - for part in omitted.group(1).split(", "): - self.assertIn(part.rsplit(":", 1)[0], install.OMISSION_REASONS.values()) + if omitted and (omitted.group(1) or omitted.group(2)): + for part in (omitted.group(1) or omitted.group(2)).split(", "): + self.assertIn(re.split(r":| \(", part)[0], install.OMISSION_REASONS.values()) count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", run).groups() self.assertIn(f"0 in place, {count} missing", run) self.assertSplitAddsUp(run, count, size) @@ -772,11 +775,12 @@ class FirstInstallWalkthrough(unittest.TestCase): self.assertIn(f"Done. {count} files installed", step4) self.assertIn(f"{count} in place, 0 missing", step5) manifest = json.loads((ROOT / "install" / "batocera.json").read_text(encoding="utf-8")) - omitted = re.search(r"Not installable: (\d+) files \((\d+) required", step3) + omitted = re.search(r"Not installable: (\d+) files (?:\((\d+) required|.*?, (\d+) of them " + r"required)", step3) if manifest.get("omitted_files"): self.assertIsNotNone(omitted, "the manifest omits files and the run shows no line for it") if omitted: - total, required = map(int, omitted.groups()) + total, required = int(omitted.group(1)), int(omitted.group(2) or omitted.group(3)) first, needed = install._omission_lines( total, [{"required": True, "dest": str(i)} for i in range(required)] )[:2] diff --git a/tests/test_install.py b/tests/test_install.py index af3648697..60d2fe8fe 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1806,7 +1806,27 @@ class TestDetectedPlatformPrompt(unittest.TestCase): class TestManualPlatformPrompt(unittest.TestCase): - """With nothing detected, an interactive run asks instead of exiting.""" + """With nothing detected, an interactive run asks instead of exiting. + + Every test runs on no drive and a home of its own: two read the host's + drives and home, and failed with a card in the reader (code_audit_23 + P3-7).""" + + def setUp(self): + home = Path(_tempdir(self)) + for patcher in (unittest.mock.patch.object(install, "_mounted_cards", return_value=[]), + unittest.mock.patch.object(install, "_mount_points", + return_value=frozenset()), + unittest.mock.patch.object(install.Path, "home", return_value=home), + unittest.mock.patch.dict(os.environ, {"HOME": str(home)}), + unittest.mock.patch("sys.stdout", io.StringIO())): + patcher.start() + self.addCleanup(patcher.stop) + + def test_the_host_is_not_read(self): + self.assertEqual(install._mounted_cards(), []) + self.assertEqual(install._default_dest("linux", "retroarch"), + Path.home() / ".config" / "retroarch" / "system") def test_number_picks_the_platform_and_default_directory(self): answers = iter(["1", ""]) diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index 762d0180f..deaf72b81 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -234,15 +234,17 @@ class CheckNamesWhyAFileIsRefused(Scratch): self.complete("scph5501.bin", "pcsx/extra.bin") out = self.check() here = self.dest.resolve() - self.assertIn(f"opened at {here / 'scph5501.bin'} by beetle_psx, pcsx_rearmed", out) - self.assertIn(f"opened at {here / 'pcsx' / 'extra.bin'} by beetle_psx, pcsx_rearmed", + self.assertIn(f"read by beetle_psx, pcsx_rearmed at {here / 'scph5501.bin'}", out) + self.assertIn(f"read by beetle_psx, pcsx_rearmed at {here / 'pcsx' / 'extra.bin'}", out) + # Never "opened" of a file that is not there (tui_audit_16 P3-12). + self.assertNotIn("opened at", out) def test_a_complete_folder_says_nothing_more(self): self.complete() proc = self.run_installer("--check", "--manifest", str(self.list)) self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) - self.assertNotIn("opened at", proc.stdout) + self.assertNotIn("read by", proc.stdout) class ArchivesInTheBiosFolder(Scratch): @@ -845,7 +847,7 @@ class OfflineLeavesFilesMissing(Scratch): proc = self.run_installer("--offline", "--manifest", str(self.list), "--no-input") # Its files are missing, as the BIOS files --offline leaves out are, # with or without questions. - self.assertIn("bluemsx: 0 already in place, 1 need the internet (--offline)", + self.assertIn("bluemsx: 0 already in place, 1 needs the internet (--offline)", proc.stdout) self.assertNotIn("could not read", proc.stderr) self.assertNotIn("no longer in the source", proc.stdout) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 999f5f3c2..96602a56e 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2537,10 +2537,11 @@ class AuditRoundThree(unittest.TestCase): state.summary = {"installed": 4, "up_to_date": 0, "failed": [ {"dest": "fc/disksys.rom", "reason": "HTTP 404"}]} _title, lines = install.tui_outcome(state) - self.assertEqual(lines[1], "2 files copied from OLD, 1 copied from the wrong place " - "in the BIOS folder, 1 downloaded, 1 still missing.") + # One count, the failed file said as failed (tui_audit_16 P3-11). + self.assertEqual(lines[0], "2 files copied from OLD, 1 copied from the wrong place " + "in the BIOS folder, 1 downloaded, 1 failed.") # The line mode's own sentence, from the same counts. - self.assertEqual(lines[1], install.sources_line([("OLD", 2)], 1, 1, 1)) + self.assertEqual(lines[0], install.sources_line([("OLD", 2)], 1, 1, 0, failed=1)) def test_p2_3_a_run_that_copied_nothing_adds_no_line(self): state = install.TuiState() @@ -5100,9 +5101,13 @@ class EndToEnd(unittest.TestCase): term = self.start() term.wait("Enter for RetroArch") term.send("\r") - term.wait("Enter) install everything") - term.send("\r") - # Installed by the first run, in place for the second. + if value is None: + # In place since the first run: no menu, nothing asked + # (tui_audit_16 P3-4). + term.wait("every one in place: nothing to install") + else: + term.wait("Enter) install everything") + term.send("\r") term.wait("Done.") term.close() self.assertNotIn(b"\x1b", term.raw) @@ -6411,7 +6416,11 @@ class AuditSevenOneCardReading(unittest.TestCase): stick = self.blank(f"{name}STICK") choice = install.card_choice(plat, plat, stick) self.assertEqual(choice.folder, stick) - self.assertIn("cannot tell", choice.note) + # Its card is told by what it writes at every boot: a stick + # holding none of it is one not started yet, never "cannot + # tell" (tui_audit_16 P2-1). + self.assertIn(f"says it is {install._a(name)} card yet", choice.note) + self.assertNotIn("cannot tell", choice.note) def test_install_py_has_two_blank_lines_around_each_definition(self): """code_audit_14 P3-12: E305 after _card_mount, and the same slip @@ -7438,8 +7447,9 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.assertRaises(SystemExit): install.ask_platform_folder("linux", "retroarch") text = out.getvalue() - self.assertIn(f"1) EASYROMS: bios (card or drive) ({stick / 'bios'})", text) - self.assertIn(f"2) ONION: BIOS (Onion SD card) ({onion / 'BIOS'})", text) + # One parenthesis a row (tui_audit_16 P3-9). + self.assertIn(f"1) EASYROMS: bios (drive, {stick / 'bios'})", text) + self.assertIn(f"2) ONION: BIOS (Onion SD card, {onion / 'BIOS'})", text) self.assertNotIn("3)", text) self.assertIn("Not offered here, each reads its own system's files: SHARE (Batocera " f"SHARE drive: --platform batocera --dest {share}), KNULLI (KNULLI SD " @@ -8259,7 +8269,9 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, found here.\"""" text, _asked = self.line_run(_args(declared_only=False), []) self.assertIn("Looking for setups here...", text) - self.assertIn("Nothing was found here. Which setup?", text) + # Said once, the question alone after it (tui_audit_16 P3-2). + self.assertEqual(text.count("Nothing was found here."), 1) + self.assertIn("\nWhich setup?\n", text) self.assertNotIn("Detecting platform", text) self.assertNotIn("No supported platform", text) state = install.TuiState(os_type="linux", home=HOME) @@ -11633,7 +11645,10 @@ class AuditTwentyAndThirteen(unittest.TestCase): with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}): _title, lines = install.tui_outcome(state) - self.assertIn("Still missing: 2 BIOS files and 1 game data file.", lines) + # The wrong copy the check found is left in place: named as one, + # never counted as missing (code_audit_23 P3-3). + self.assertIn("1 file copied from OLD, 2 still missing, 1 wrong copy left.", lines) + self.assertIn("Still missing: 1 BIOS file and 1 game data file.", lines) # Root cause 6: every other word. @@ -12278,15 +12293,16 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): def easyroms(self, name: str = "EASYROMS") -> Path: """A handheld's games part as a computer mounts it, with the folders - its system writes there (install._GAMES_PART_MARKERS): ArkOS's + its system writes there (install._GAMES_PART_RULES): ArkOS's EASYROMS, AmberELEC's GAMES.""" return games_part(self.root / "media" / name) def test_an_arkos_card_is_found_and_comes_first(self): """tui_audit_14 P2-3: an EASYROMS partition mounted, "Nothing was found here", and two presses of Enter filled this computer's - ~/.config/retroarch/system for a RetroArch that was not there.""" - for name in ("EASYROMS", "GAMES"): + ~/.config/retroarch/system for a RetroArch that was not there. An + AmberELEC GAMES is AmberELEC's own card (tui_audit_16 P2-1).""" + for name in ("EASYROMS",): with self.subTest(card=name): card = self.easyroms(name) home = self.root / "home" @@ -12562,15 +12578,14 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): text = out.getvalue() self.assertNotIn(", created)", text) self.assertNotIn("(created)", text) - self.assertIn(f"D: bios (card or drive) ({drives[0] / 'bios'}, does not exist yet)", text) - self.assertIn(f"E: bios (card or drive) ({drives[1] / 'bios'})\n", text) + self.assertIn(f"D: bios (drive, {drives[0] / 'bios'}, does not exist yet)", text) + self.assertIn(f"E: bios (drive, {drives[1] / 'bios'})\n", text) state = fresh_state() state.platform = "retroarch" with mock.patch.object(install, "_mounted_cards", return_value=drives): install._open_folder_screen(state, SYSTEM_DIR) labels = [row.label for row in install.tui_rows(state) if row.action == "drive"] - self.assertEqual(labels, ["D: bios (card or drive) (does not exist yet)", - "E: bios (card or drive)"]) + self.assertEqual(labels, ["D: bios (drive, does not exist yet)", "E: bios (drive)"]) def test_line_mode_puts_the_arkos_card_first(self): card = self.easyroms() @@ -12578,9 +12593,9 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): declared_only=False), ["1", ""], mounts=[card]) self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) self.assertNotIn("Nothing was found here", text) - self.assertIn(f"The card in the reader keeps them in its bios folder: {card / 'bios'}.", - text) - self.assertIn(f"1) EASYROMS: bios (card or drive) ({card / 'bios'})", text) + self.assertIn(f"The card in the reader keeps BIOS files in its bios folder: " + f"{card / 'bios'}.", text) + self.assertIn(f"1) EASYROMS: bios (SD card, {card / 'bios'})", text) self.assertIn("Enter for the card's bios folder", asked[-2]) self.assertIn(f"BIOS folder: {card / 'bios'}", text) @@ -12643,20 +12658,26 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): self.assertIn("Nothing was found here", self.screen(state)) state, text, rows = self.folder_screen(mounts) self.assertNotIn(rows[0].value, (data / "bios", stick / "bios")) - self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a " - "handheld's card: listed, not chosen.", text) + self.assertIn(self.UNPROVEN_TWO, text) self.assertIn(data / "bios", [row.value for row in rows]) text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, declared_only=False), ["1", ""], mounts=mounts) self.assertNotIn("in the reader", text) - self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a " - "handheld's card: listed, not chosen.", text) + self.assertIn(self.UNPROVEN_TWO, text) self.assertIn("Enter for the default above", asked[-2]) self.assertNotIn(f"BIOS folder: {data / 'bios'}", text) + # The note on drives with a bios folder and no handheld's folder. + UNPROVEN_TWO = ("DATA and STICK have a bios folder but no folder a handheld system " + "writes. Pick one below if your files go there.") + UNPROVEN_ONE = ("DATA has a bios folder but no folder a handheld system writes. Pick " + "it below if your files go there.") + def test_the_layouts_handhelds_write_are_cards(self): """ArkOS's EASYROMS, AmberELEC's GAMES and EmuELEC's EEROMS, each - with the folders its own image writes there.""" + with the folders its own image writes there. ArkOS's reads + RetroArch's list, the two others are their systems' own cards + (tui_audit_16 P2-1).""" arkos = games_part(self.root / "media" / "EASYROMS") amber = games_part(self.root / "media" / "GAMES") emuelec = self.drive("EEROMS", ("bios", "nes", "bezels")) @@ -12664,12 +12685,17 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): mounts = [arkos, amber, emuelec] with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ mock.patch.object(install, "_mount_points", return_value=frozenset()): - self.assertEqual(install.unnamed_cards(), mounts) - self.assertEqual([install._games_part_system(mount) for mount in mounts], + self.assertEqual(install.unnamed_cards(), [arkos]) + self.assertEqual([install.games_part(mount).name for mount in mounts], ["ArkOS", "AmberELEC", "EmuELEC"]) + self.assertEqual([install.card_system(mount) for mount in mounts], + [None, ("amberelec", amber, "AmberELEC"), + ("emuelec", emuelec, "EmuELEC")]) # One of these the system calls fixed is a disk, not a card. with mock.patch.object(install, "_drive_not_removable", return_value=True): self.assertEqual(install.unnamed_cards(), []) + self.assertEqual([install.card_system(mount) for mount in mounts], + [None, None, None]) def test_a_card_beside_a_data_drive_comes_first(self): """code_audit_22 P2-1: the ArkOS card and the data drive mounted, @@ -12683,31 +12709,31 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): self.assertIn("A card is in the reader (EASYROMS): choose its system", self.screen(state)) state, text, rows = self.folder_screen(mounts) self.assertEqual(rows[0].value, card / "bios") - self.assertIn("DATA has a bios folder, and nothing on it shows a handheld's card", text) + self.assertIn(self.UNPROVEN_ONE, text) text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, declared_only=False), ["1", ""], mounts=mounts) self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) - self.assertIn(f"The card in the reader keeps them in its bios folder: {card / 'bios'}.", - text) - self.assertIn(f"1) EASYROMS: bios (card or drive) ({card / 'bios'})", text) - self.assertIn("DATA has a bios folder, and nothing on it shows a handheld's card", text) + self.assertIn(f"The card in the reader keeps BIOS files in its bios folder: " + f"{card / 'bios'}.", text) + self.assertIn(f"1) EASYROMS: bios (SD card, {card / 'bios'})", text) + self.assertIn(self.UNPROVEN_ONE, text) self.assertIn(f"BIOS folder: {card / 'bios'}", text) def test_two_cards_are_spoken_of_in_the_plural(self): """code_audit_22 P3-2: "Cards are in the reader (DATA, EASYROMS): choose its system", then "The card in the reader" of one of two.""" arkos = games_part(self.root / "media" / "EASYROMS") - amber = games_part(self.root / "media" / "GAMES") - mounts = [arkos, amber] + second = games_part(self.root / "media" / "ROMS2") + mounts = [arkos, second] state = self.first_screen(mounts) - self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems", + self.assertIn("Cards are in the reader (EASYROMS, ROMS2): choose their systems", self.screen(state)) state, text, rows = self.folder_screen(mounts) - self.assertIn("EASYROMS keeps them in its bios folder, first below.", text) + self.assertIn("EASYROMS keeps BIOS files in its bios folder, first below.", text) text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, declared_only=False), ["1", ""], mounts=mounts) - self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems.", text) - self.assertIn(f"EASYROMS keeps them in its bios folder: {arkos / 'bios'}.", text) + self.assertIn("Cards are in the reader (EASYROMS, ROMS2): choose their systems.", text) + self.assertIn(f"EASYROMS keeps BIOS files in its bios folder: {arkos / 'bios'}.", text) self.assertNotIn("The card in the reader", text) def test_a_fixed_partition_is_told_from_a_card(self): @@ -13280,8 +13306,16 @@ class TheSystemsQuestionFits(unittest.TestCase): [f for f in files if install._chosen(f, {system})])) # One line a system, a second for its shared files: the # 80-column question took 127 lines for 66 systems. - limit = len(systems) + shared + 10 if cols == 80 else 2 * len(systems) + 12 + # At 40 columns a shared size takes a line more: its first + # part alone was printed beside a long name, the shared + # files unsaid (tui_audit_16 P3-5). + limit = (len(systems) + shared + 10 if cols == 80 + else 2 * len(systems) + shared + 12) self.assertLessEqual(len(lines), limit) + for line, after in zip(lines, lines[1:] + [""]): + if line.rstrip().endswith(","): + self.assertTrue(after.startswith(" ") and not re.match( + r" +\d+\) ", after), (cols, line, after)) def test_a_row_that_fits_keeps_one_line(self): lines, _systems, _files = self.question(80) @@ -13290,5 +13324,555 @@ class TheSystemsQuestionFits(unittest.TestCase): self.assertTrue(re.match(r" +\d+\) ", lines[row + 1])) +class AuditTwentyThreeAndSixteen(unittest.TestCase): + """code_audit_23 and tui_audit_16: a card its markers prove is that + system's card everywhere, counts and states that tell the truth, one + width for every layout, line mode's order and words as the screen's, + and a tap that shows it was seen.""" + + setUp = LineModeAndScreenDecideAlike.setUp + line_run = LineModeAndScreenDecideAlike.line_run + listing = LineModeAndScreenDecideAlike.listing + entry = staticmethod(LineModeAndScreenDecideAlike.entry) + both = LineModeAndScreenDecideAlike.both + drive = AuditTwentyTwoAndFifteen.drive + first_screen = AuditTwentyTwoAndFifteen.first_screen + folder_screen = AuditTwentyTwoAndFifteen.folder_screen + game_listing = AuditTwentyOneAndFourteen.game_listing + game_manifest = AuditTwentyAndThirteen.game_manifest + receive = staticmethod(AuditTwentyAndThirteen.receive) + screen = staticmethod(AuditTwentyOneAndFourteen.screen) + + def amberelec(self, name: str = "GAMES") -> Path: + return games_part(self.root / "media" / name) + + def emuelec(self, name: str = "EEROMS") -> Path: + card = self.drive(name, ("bios", "nes", "bezels")) + (card / "bezels" / "default.cfg").write_text("") + return card + + def line_from(self, args: argparse.Namespace, answers: list, + folders: list[Path] = ()) -> tuple[str, list]: + """(text, prompts) of line mode at a terminal, the result included, + the folders --from names read first, as main() reads them.""" + given, asked = iter(answers), [] + + def answer(prompt: str) -> str: + asked.append(prompt) + try: + return next(given) + except StopIteration: + raise _Decided(prompt) from None + + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + if folders: + setattr(args, "from", [str(folder) for folder in folders]) + invocation = install.Invocation() + invocation.os_type = "linux" + invocation.record(args) + stream = io.StringIO() + with mock.patch.object(install, "_INVOCATION", invocation), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install.sys.stdin, "isatty", return_value=True), \ + mock.patch.object(install, "_is_terminal", return_value=True), \ + mock.patch.object(install, "_ask", side_effect=answer), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + contextlib.suppress(_Decided, SystemExit): + install._install(args, stream) + return out.getvalue() + err.getvalue() + stream.getvalue(), asked + + # Root cause 1: a card its markers prove is that system's card. + + def test_a_games_part_is_its_systems_card_in_both_modes(self): + """tui_audit_16 P2-1: the GAMES card AmberELEC's folders prove, and + the EEROMS card EmuELEC's prove, read "A card is in the reader: + choose its system" with RetroArch focused, and two presses of Enter + put RetroArch's list on them.""" + for plat, name, make in (("amberelec", "AmberELEC", self.amberelec), + ("emuelec", "EmuELEC", self.emuelec)): + with self.subTest(system=name): + card = make() + state = self.first_screen([card]) + text = self.screen(state) + self.assertIn(f"{name} SD card ({card.name})", text) + self.assertNotIn("in the reader", text) + self.assertIn(f"BIOSfolder:{card / 'bios'}", "".join(text.split())) + (line, screen, said) = self.both([card], []) + self.assertEqual(line, (plat, card)) + self.assertEqual(screen, (plat, card)) + self.assertIn(f"Found {name}", said) + shutil.rmtree(card) + + def test_their_own_routes_take_the_card_they_prove(self): + """tui_audit_16 P2-1: --platform amberelec said it "cannot tell an + AmberELEC card by its files" of the card holding gamedata/ and + homebrew/, and line mode offered no Enter for it.""" + for plat, name, make in (("amberelec", "AmberELEC", self.amberelec), + ("emuelec", "EmuELEC", self.emuelec)): + with self.subTest(system=name): + card = make() + stick = self.drive(f"{name}STICK", ("bios",)) + with mock.patch.object(install, "_mounted_cards", return_value=[card, stick]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + self.assertEqual(install.mounted_cards_of(name, plat), [card]) + self.assertEqual(install.blank_cards_of(name, plat), [stick]) + choice = install.card_choice(name, plat, card) + self.assertEqual((choice.refused, choice.folder), ("", card)) + self.assertNotIn("cannot tell", choice.note) + (line, screen, said) = self.both([card, stick], [], platform=plat) + self.assertEqual(line, (plat, card)) + self.assertEqual(screen, (plat, card)) + self.assertIn(f"Found {name} at {card / 'bios'}", said) + self.assertNotIn("cannot tell", said) + # Asked for its card, the one its files prove is offered. + with mock.patch.object(install, "_mounted_cards", return_value=[card, stick]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest(name, plat, True, False) + self.assertEqual(dest, card) + self.assertIn(f"{name} card found at {card.name}", out.getvalue()) + with mock.patch.object(install, "_mounted_cards", return_value=[card, stick]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + state = screen_keys(_args(platform=plat, declared_only=False), []) + view = " ".join(install.tui_view(state, 80, 24)) + self.assertNotIn("cannot tell", view) + shutil.rmtree(card) + shutil.rmtree(stick) + + def test_an_arkos_card_from_any_year_is_a_card(self): + """code_audit_23 P3-1: ArkOS's default trees of 2020 hold no themes/ + (9664c869 none of themes and launchimages, 495eb210 and c14afbe5 + launchimages only), and the 12262020 update skipped the themes of a + card with under 1 GB free. Every tree holds bgmusic/. A data drive + holding neither stays no card.""" + layouts = { + "ARK_9664c869": ("bios", "bgmusic", "ports", "snes", "System Volume Information"), + "ARK_495eb210": ("bios", "bgmusic", "launchimages", "ports", "snes"), + "ARK_c14afbe5": ("bios", "bgmusic", "launchimages", "psx"), + "ARK_f41b9485": ("bios", "bgmusic", "launchimages", "themes", "backup", "nes"), + "ARK_FULL1226": ("bios", "launchimages", "nes"), + } + cards = [self.drive(name, folders) for name, folders in layouts.items()] + data = self.drive("DATA", ("bios", "snes", "Music", "themes")) + music = self.drive("MUSIC", ("bgmusic", "launchimages")) + mounts = [*cards, data, music] + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + self.assertEqual(install.unnamed_cards(), cards) + self.assertEqual([install.games_part(card).name for card in cards], + ["ArkOS"] * len(cards)) + self.assertIsNone(install.games_part(data)) + self.assertIsNone(install.games_part(music)) + self.assertEqual(install.unproven_drives(), [data]) + with mock.patch.object(install, "_drive_not_removable", return_value=True): + self.assertEqual(install.unnamed_cards(), []) + + # Root cause 2: counts and states that tell the truth. + + def wrong_copy_run(self, answers: list) -> tuple[str, list, Path]: + """An offline run whose BIOS folder holds a wrong copy of the right + size of a.bin, a folder named with --from holding b.bin.""" + good_a, good_b = b"A" * 64, b"B" * 64 + listing = self.listing("retroarch", [self.entry("a.bin", good_a, ["nintendo-gba"]), + self.entry("b.bin", good_b, ["nintendo-gba"])]) + dest = self.root / "dest" + dest.mkdir() + (dest / "a.bin").write_bytes(b"Z" * 64) + old = self.root / "old" + old.mkdir() + (old / "b.bin").write_bytes(good_b) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text, asked = self.line_from(_args(manifest=listing, dest=dest, platform="retroarch", + declared_only=False), answers, [old]) + return text, asked, dest + + def test_the_head_never_calls_a_wrong_copy_in_place(self): + """code_audit_23 P3-2: a wrong copy of the right size was in place + for the head, which said "1 found in old, nothing to download" over + a run that left it.""" + text, _asked, dest = self.wrong_copy_run([""]) + self.assertIn("RetroArch: 1 found in old, 1 needs the internet.", text) + self.assertNotIn("nothing to download", text) + self.assertEqual((dest / "a.bin").read_bytes(), b"Z" * 64) + + def test_a_wrong_copy_left_is_said_as_one(self): + """code_audit_23 P3-3: the result said "1 still missing" of a file + --check counts as a wrong copy.""" + text, _asked, _dest = self.wrong_copy_run([""]) + self.assertIn("Finished with files missing.", text) + self.assertIn("1 file copied from", text) + self.assertIn(", 1 wrong copy left.", text) + self.assertNotIn("still missing", text.rsplit("Finished", 1)[1]) + + def test_a_count_agrees_with_its_verb(self): + """code_audit_23 P3-4: "1 need the internet" on the plan, and on + line mode's data tree line.""" + self.assertEqual(install.agree(1, "needs", "need"), "1 needs") + self.assertEqual(install.agree(2, "needs", "need"), "2 need") + self.assertEqual(install._plural(2, "wrong copy"), "2 wrong copies") + self.assertEqual(install._plural(1, "wrong copy"), "1 wrong copy") + self.assertEqual(install._plural(2, "day"), "2 days") + state = checked_state() + folder = "/media/OLD" + state.from_folders = [Path(folder)] + state.found = [found(dest, folder) for dest in ("scph5500.bin", "pcsx/extra.bin", + "gba_bios.bin")] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text = self.screen(state, 100, 30) + self.assertIn("3 found in OLD, 1 needs the internet", text) + source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") + self.assertNotRegex(source, r"\} need the internet") + + def test_line_mode_says_what_is_still_missing_before_the_remedy(self): + """tui_audit_16 P3-3: "They are not in OLD" followed "Installed: 6 + BIOS files", where the screen puts "Still missing:" between them.""" + bios, game = b"B" * 32, b"G" * 32 + listing = self.listing("retroarch", [ + self.entry("a.bin", bios, ["nintendo-gba"]), + self.entry("b.bin", b"C" * 32, ["nintendo-gba"]), + self.entry("g.dat", game, ["sega-dreamcast"], category="game_data")], + categorized=True) + dest = self.root / "dest" + dest.mkdir() + old = self.root / "OLD" + old.mkdir() + (old / "a.bin").write_bytes(bios) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text, _asked = self.line_from(_args(manifest=listing, dest=dest, + platform="retroarch", declared_only=False), + [""], [old]) + end = text.rsplit("Finished with files missing", 1)[-1] + self.assertIn("Still missing: 1 BIOS file and 1 game data file.", end) + self.assertLess(end.index("Still missing:"), end.index(f"They are not in {old}")) + self.assertLess(end.index("Installed:"), end.index("Still missing:")) + + def test_a_rerun_with_everything_in_place_offers_nothing(self): + """tui_audit_16 P3-4: line mode offered "Enter) install what is + found", then said "Everything is in place." twice.""" + data = b"A" * 64 + listing = self.listing("retroarch", [self.entry("a.bin", data, ["nintendo-gba"])]) + dest = self.root / "dest" + dest.mkdir() + (dest / "a.bin").write_bytes(data) + text, asked = self.line_from(_args(manifest=listing, dest=dest, platform="retroarch", + declared_only=False), []) + self.assertEqual(asked, []) + self.assertNotIn("Enter)", text) + self.assertIn("RetroArch: 1 file, every one in place: nothing to install.", text) + self.assertEqual(text.count(install.EVERYTHING_IN_PLACE), 1) + + def test_line_mode_names_the_files_it_cannot_supply(self): + """tui_audit_16 P3-6: "Not installable: 37 files (1 required, not + found: 37)" read as a sum, and line mode never named the file the + screen names, nor its folder.""" + listing = self.listing("retroarch", [ + self.entry("a.bin", b"A" * 8, ["nintendo-gba"])], categorized=True, + omitted_files=[ + {"dest": "jazz2/Source/Anims.j2a", "name": "Anims.j2a", + "system": "jazz-jackrabbit-2", "required": True, "reason": "not_found", + "category": "game_data"}, + {"dest": "x.bin", "name": "x.bin", "system": "nintendo-gba", + "required": False, "reason": "not_found"}]) + dest = self.root / "dest" + dest.mkdir() + text, _asked = self.line_run(_args(manifest=listing, dest=dest, platform="retroarch", + declared_only=False, dry_run=True), []) + self.assertIn("Not available here: jazz2/Source/Anims.j2a (Jazz Jackrabbit 2), game " + "data its game reads. Copy it from your own copy of the game into " + f"{dest / 'jazz2' / 'Source'}{os.sep}.", text) + self.assertEqual(install.not_installable_words(37, 1, {"not_found": 37}), + "Not installable: 37 files are not in the collection, 1 of them " + "required.") + self.assertEqual(install.not_installable_words(1, 1, {"not_found": 1}), + "Not installable: 1 file is not in the collection, required.") + self.assertIn("Why: no save directory (2), not found (3).", + install.not_installable_words(5, 0, {"not_found": 3, + "no_save_directory": 2})) + + def test_a_system_emptied_by_no_game_data_is_named(self): + """tui_audit_16 P3-7: --system nintendo-ds,sega-dreamcast with + --no-game-data: "For 1 of 8 systems (--system)", Dreamcast unsaid + until after Enter.""" + manifest = self.game_manifest() + words = (f"{install.system_title('sega-dreamcast', manifest)} holds only game data: " + "left out (--no-game-data).") + listing = self.game_listing() + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False, dry_run=True, + system=["nintendo-ds", "sega-dreamcast"], + no_game_data=True), []) + head = text.split("Enter)", 1)[0] + self.assertIn(words, head) + state = install.TuiState(os_type="linux", home=HOME) + state.manifest = manifest + state.no_game_data = True + state.cli_systems = ["nintendo-ds", "sega-dreamcast"] + state.systems = [{"system": "nintendo-ds", "name": "Nintendo DS", "files": 1, + "present": 0, "missing": 1, "wrong_hash": 0, "bytes_needed": 1}] + state.ticked = {"nintendo-ds"} + state.dest = self.here + state.platform = "retroarch" + state.check = {**CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0} + state.stack = ["plan"] + self.assertIn(words, self.screen(state, 100, 40)) + + def test_a_run_with_failures_counts_once(self): + """tui_audit_16 P3-11: "Done. 44 files installed." then "38 files + copied from E:\\, 4 downloaded, 2 still missing." gave two counts, + the two that failed as missing. Each mode says one, failures named.""" + out = io.StringIO() + sources = install.sources_line([("/media/OLD", 38)], 0, 4, 0, failed=2) + self.assertEqual(sources, "38 files copied from /media/OLD, 4 downloaded, 2 failed.") + with contextlib.redirect_stdout(out), contextlib.suppress(SystemExit): + install._report_outcome([self.here], 42, 0, 2, 0, False, + failures=[("x.bin", "timed out"), ("y.bin", "timed out")], + sources=sources) + text = out.getvalue() + self.assertIn(f"Finished with errors. {sources}", text) + self.assertNotIn("42 files installed", text) + self.assertNotIn("still missing", text) + state = checked_state() + state.from_folders = [Path("/media/OLD")] + state.local_dests = {"gba_bios.bin": ("/media/OLD", "")} + state.finished = [("gba_bios.bin", True, ""), ("scph5500.bin", False, "timed out")] + state.stack, state.run = ["result"], "install" + state.summary = {"event": "summary", "mode": "install", "installed": 1, + "up_to_date": 0, "pending": 1, + "failed": [{"dest": "scph5500.bin", "reason": "timed out"}], + "skipped": [], "left_out": [], "kept": [], "interrupted": False} + title, lines = install.tui_outcome(state) + self.assertEqual(title, "Finished with errors") + self.assertEqual(lines[0], "1 file copied from OLD, 1 failed.") + self.assertNotIn("1 installed, 1 failed.", lines) + + def test_check_never_says_a_missing_file_was_opened(self): + """tui_audit_16 P3-12: "missing: nintendo-ds_0.bin" then "opened at + ..." read as a file that was opened.""" + entry = {"dest": "a.bin", "systems": ["nintendo-gba"], "cores": None} + self.assertEqual(install.opened_line(entry, {}, self.here), + f"expected at {self.here / 'a.bin'}") + named = {**entry, "cores": ["mgba"]} + self.assertEqual(install.opened_line(named, {}, self.here), + f"read by mgba at {self.here / 'a.bin'}") + + # Root cause 3: one width with a floor. + + def test_a_terminal_of_no_size_has_the_fallback(self): + """code_audit_23 P3-5: Python 3.8 gives a pty of no size as 0 and 0. + The plan printed "0 systems, the largest first:" and the progress + line lost every figure but its percentage.""" + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((0, 0))): + self.assertEqual(install.terminal_size(), (80, 24)) + out = io.StringIO() + term = types.SimpleNamespace(live=True, quiet=False, stream=out, + bold=str, dim=str) + progress = install.Progress(1, 1400, term) + progress.bytes = progress.files = 1400 + progress.files = 1 + progress._draw() + self.assertIn("(1/1 files)", out.getvalue()) + self.assertIn("[####", out.getvalue()) + with contextlib.redirect_stdout(io.StringIO()) as plan: + install._print_plan([], self.here, term, copied=[" 1 file copied"]) + self.assertNotIn("the largest first", plan.getvalue()) + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((8, 3))): + self.assertEqual(install.terminal_size(), (install.LINE_MIN_COLS, 3)) + + def systems_question(self, cols: int) -> list[str]: + manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text()) + out = io.StringIO() + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((cols, 24))), \ + mock.patch.object(install, "_ask", return_value=""), \ + contextlib.redirect_stdout(out): + install._choose_narrowing(manifest["files"], [], manifest, None) + text = out.getvalue() + ends = [at for at in (text.find("\nCores:"), text.find("\nThe platform's own")) + if at >= 0] + return text[text.index("Systems:"):min(ends, default=len(text))].splitlines() + + def test_the_systems_question_stays_inside_the_terminal(self): + """tui_audit_16 P3-5: 6 lines of 81 cells at 80 columns, 16 of 41 at + 40, "shared" and "platform" alone on a line.""" + for cols in (100, 80, 60, 40, 30): + with self.subTest(cols=cols): + lines = self.systems_question(cols) + for line in lines: + self.assertLess(install.cell_width(line), cols, line) + if line.strip() and not line.endswith(":"): + self.assertTrue(len(install._glued_words(line)) > 1 + or re.match(r" *\d+\) ", line), (cols, line)) + note = " ".join(line.strip() for line in lines[lines.index( + next(line for line in lines if line.startswith("'Other"))):]) + self.assertIn("this platform does not list", note) + + def test_the_setup_list_never_folds_inside_a_word(self): + """tui_audit_16 P3-15: at 39 columns "Miy" / "oo Flip".""" + for cols in (80, 39, 30): + with self.subTest(cols=cols): + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((cols, 11))): + text, _asked = self.line_run(_args(declared_only=False), []) + block = text.split("Which setup?", 1)[1].split(" q) quit", 1)[0] + lines = [line for line in block.splitlines() if line.strip()] + self.assertTrue(lines) + for line in lines: + self.assertLess(install.cell_width(line), cols, line) + self.assertIn("Miyoo Flip", " ".join(" ".join(lines).split())) + + # Root cause 4: line mode in the screen's order and words. + + def test_on_wsl_the_card_comes_first_in_both_modes(self): + """code_audit_23 P3-6: line mode numbered Windows RetroArch 1) and + the card 2), where the screen put the card first.""" + card = games_part(self.root / "media" / "EASYROMS") + windows = Path("/mnt/c/RetroArch-Win64/system") + side = [("Windows RetroArch: C:\\RetroArch-Win64\\system", windows)] + with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "other_side", return_value=side), \ + mock.patch.object(install, "_ask", return_value="q"), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.suppress(SystemExit): + install.ask_platform_folder("wsl", "retroarch") + state = self.first_screen([card]) + self.assertRegex(out.getvalue(), r"\n 1\) EASYROMS: bios \(SD card") + self.assertRegex(out.getvalue(), r"\n 2\) Windows RetroArch") + with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "other_side", return_value=side): + state.os_type = "wsl" + install.tui_update(state, Key("enter")) + rows = [row for row in install.tui_rows(state) if row.action == "drive"] + self.assertEqual([row.value for row in rows], [card / "bios", windows]) + + def test_nothing_found_is_said_once(self): + """tui_audit_16 P3-2: "Nothing was found here." twice, and after "A + card is in the reader".""" + text, _asked = self.line_run(_args(declared_only=False), []) + self.assertEqual(text.count("Nothing was found here."), 1) + card = games_part(self.root / "media" / "EASYROMS") + text, _asked = self.line_run(_args(declared_only=False), [], mounts=[card]) + self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) + self.assertNotIn("Nothing was found here", text) + + def test_the_bios_only_drive_note_is_one_plain_sentence(self): + """tui_audit_16 P3-9: "listed, not chosen", "(card or drive)" under + a note saying no card, two parentheses side by side, and "keeps + them" after a title.""" + data = self.drive("DATA", ("bios", "snes", "Music")) + stick = self.drive("STICK", ("bios",)) + card = games_part(self.root / "media" / "EASYROMS") + mounts = [card, data, stick] + state, text, rows = self.folder_screen(mounts) + self.assertIn("DATA and STICK have a bios folder but no folder a handheld system " + "writes. Pick one below if your files go there.", text) + self.assertIn("The card in the reader keeps BIOS files in its bios folder", text) + labels = [row.label for row in rows] + self.assertIn("DATA: bios (drive)", labels) + self.assertIn("EASYROMS: bios (SD card)", labels) + line, _asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, + declared_only=False), ["1", ""], mounts=mounts) + for said in (text, line): + self.assertNotIn("listed, not chosen", said) + self.assertNotIn("card or drive", said) + self.assertNotIn(") (", said) + self.assertNotIn("keeps them", said) + + def test_the_same_choice_is_one_command_in_both_modes(self): + """tui_audit_16 P3-14: the screen wrote --manifest before --from, + line mode after it.""" + hint = install.Invocation() + hint.os_type = "linux" + hint.options = [("--from", "/media/OLD"), ("--offline", None), + ("--manifest", "/x/list.json"), ("--system", "a")] + self.assertEqual(hint.command("retroarch", "/dest"), + "python3 install.py --platform retroarch --dest /dest --manifest " + "/x/list.json --system a --from /media/OLD --offline") + state = checked_state() + state.manifest_given, state.manifest_path = True, Path("/x/list.json") + state.from_folders = [Path("/media/OLD")] + args = _args(manifest="/x/list.json", platform="retroarch", dest=SYSTEM_DIR, + declared_only=False, offline=False) + setattr(args, "from", ["/media/OLD"]) + line = install.Invocation() + line.os_type = "linux" + line.record(args) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + line.options.append(("--offline", None)) + self.assertEqual(install.tui_command(state), line.command("retroarch", SYSTEM_DIR)) + + # Root cause 5: interaction. + + def test_a_tap_on_a_focused_row_shows_it_was_seen(self): + """tui_audit_16 P3-8: one tap on the focused Onion row showed + nothing, a second continued.""" + state = fresh_state() + state.stack = ["setup"] + state.mouse = True + lines = drawn(state, 80, 24) + row = next(index for index, line in enumerate(lines) + if str(line).lstrip().startswith("> ")) + self.assertEqual(tap(state, 4, row), []) + lines = drawn(state, 80, 24) + self.assertIn(f"A/Enter Continue: {install.TUI_ARMED}", str(lines[-1])) + self.assertIn(f"RetroArch: {install.TUI_ARMED}", str(lines[row])) + self.assertNotEqual(tap(state, 4, row), []) + + def test_a_phone_bar_names_the_enter_key(self): + """tui_audit_16 P3-13: Termux at 40 columns said "A Continue", and + the a key does nothing.""" + state = install.TuiState(os_type="android", home=HOME) + state.pad = install.tui_pad_route("android", {}) + self.assertEqual(state.pad, "both") + bar = str(install.tui_view(state, 40, 12)[-1]) + self.assertIn("Enter", bar) + self.assertNotRegex(bar, r"(^| )A ") + self.assertIn("A/Enter", str(install.tui_view(state, 80, 24)[-1])) + state.pad = "gptokeyb" + self.assertRegex(str(install.tui_view(state, 40, 12)[-1]), r"(^| )A ") + + def test_which_folder_keeps_its_folder_at_40_by_12(self): + """tui_audit_16 P3-1: with two drives holding bios, Which folder? + at 40x12 dropped the folder "Use this folder" stands for.""" + data = self.drive("DATA", ("bios", "snes", "Music")) + stick = self.drive("STICK", ("bios",)) + for mounts in ([data, stick], [games_part(self.root / "media" / "EASYROMS"), data]): + with self.subTest(mounts=[mount.name for mount in mounts]): + state, _text, _rows = self.folder_screen(mounts) + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + small = " ".join(str(line) for line in install.tui_view(state, 40, 12)) + folder = str(state.folder_default) + self.assertIn("reads its BIOS files", small) + self.assertIn(folder[-12:], small) + + # Root cause 6: the pages say what the installer shows. + + def test_which_pack_names_the_first_screen(self): + """tui_audit_16 P3-10: which-pack said the command opens a screen + where Install and Choose systems are, the second screen.""" + sys.path.insert(0, str(REPO_ROOT / "scripts")) + self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts")) + import generate_site # noqa: PLC0415 + + words = " ".join(generate_site.first_screen_words()) + found, nothing = fresh_state(), install.TuiState(os_type="linux", home=HOME) + found.stack = ["setup"] + self.assertIn(install.tui_view(found, 80, 24)[0].split(": ", 1)[1].strip(), words) + self.assertIn(install.tui_view(nothing, 80, 24)[0].split(": ", 1)[1].strip(), words) + self.assertLess(words.index(install.TUI_FOUND_TITLE), + words.index(install.TUI_INSTALL_ROW)) + self.assertNotIn("opens a screen", words) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_site_r2_entry.py b/tests/test_site_r2_entry.py index 5a59b451f..cda36db3f 100644 --- a/tests/test_site_r2_entry.py +++ b/tests/test_site_r2_entry.py @@ -167,7 +167,7 @@ class GetYourFilesOrder(unittest.TestCase): text = _page("which-pack.md") order = [text.index(mark) for mark in ( "## Pick your setup", "| Setup | Runs on | BIOS folder | ZIP |", "Is it safe?", )] self.assertEqual(order, sorted(order)) diff --git a/wiki/installer.md b/wiki/installer.md index 3304fe810..cd21730d0 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -210,7 +210,9 @@ in the names of what is held. On a computer it names the keys (`Enter Install`, launcher (`A Install`, `B Back`, see `RETROBIOS_PAD` below), and `?` shows both the buttons and the keys. Where neither is known, in SteamOS game mode or on a console no launcher named, it gives both spellings (`A/Enter`, `B/Esc`), -and a narrow screen shortens them to `A`, `B` and `X`. On the +and a narrow screen shortens them to `A`, `B` and `X`. In Termux on Android, +with no launcher named, a narrow screen keeps the keys (`Enter`, `Esc`) instead, +a phone having an Enter key and no A button. On the regions screen, Left and Right move the region under the cursor up and down the order of preference. During the download, a stop waits for the files in progress, and a second one stops at once. Select+Start on a handheld ends the @@ -267,7 +269,8 @@ says why. A mouse or a touch screen works too, beside the keys and buttons, in a terminal that reports clicks: those of Linux, macOS, WSL and Termux. A click or a tap on a box (a system, a core, a region) ticks or unticks it, as Space -does. On any other row it moves the cursor there, and a second one on that +does. On any other row it moves the cursor there and adds `click or tap +again` to the row and the bottom line, and a second one on that row does what Enter does: one row of a terminal is far smaller than a finger, and the row a screen opens on (**Install** on Confirm) is never chosen by one tap. The entries of the bottom line press their key, **Exit** on the result @@ -712,6 +715,7 @@ RetroArch: 10994 files, 6.7 GB. 3269 BIOS files (4.1 GB), 5089 game data files (2.5 GB) and 2636 data files (38.5 MB). Game data: files a game or a game engine reads, of no use to the other cores. Data files: the files an emulator reads from a data folder of its own, such as Dolphin's Sys and PPSSPP's assets. BIOS folder: /home/me/.config/retroarch/system + Not available here: jazz2/Source/Anims.j2a (Jazz Jackrabbit 2), game data its game reads. Copy it from your own copy of the game into /home/me/.config/retroarch/system/jazz2/Source/. Enter) install everything c) choose systems, cores and regions f) copy from a folder or an old card @@ -1028,6 +1032,8 @@ gives for the device, below the card's root instead of its mount point: | `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list | | `.allium/` | Allium | `BIOS`, RetroArch's list | | `Koriki/version.txt` | Koriki | `BIOS`, RetroArch's list | +| `gamedata/`, `homebrew/` beside `bios/`, on a drive that is not a fixed disk | AmberELEC | `bios`, AmberELEC's list | +| `bezels/default.cfg` beside `bios/`, on a drive that is not a fixed disk | EmuELEC | `bios`, EmuELEC's list | | `system/batocera.conf` holding `system.hostname=BATOCERA` or no hostname line, and no KNULLI file above | Batocera SHARE drive | `bios`, Batocera's list | | `system/recalbox.conf` | Recalbox SHARE drive | `bios`, Recalbox's list | | `system/batocera.conf` with any other hostname, and no KNULLI file above, or the files of two SHARE drives | named by neither: the run says which systems it could be | the folder of the system named with `--platform` | @@ -1166,9 +1172,10 @@ folder inside another system's card (ROCKNIX's `roms/bios` named for KNULLI). A card whose own files name it is that card whatever its name: an Onion card mounted as `BIOS` gets `BIOS/BIOS`. One without the system's own files is refused for the MinUI family, Onion, muOS, spruceOS, CrossMix-OS, Allium and -Koriki. KNULLI and ROCKNIX fill their card when they first start, and no file -on the card names ArkOS, dArkOS, JELOS, KOLOS, UnofficialOS, ArchR, RetroOZ, -plumOS, MiyooCFW, AmberELEC, EmuELEC or the Anbernic firmware: such a folder +Koriki. KNULLI, ROCKNIX, AmberELEC and EmuELEC fill their card when they first +start (AmberELEC's `gamedata` and `homebrew`, EmuELEC's `bezels/default.cfg`), +and no file on the card names ArkOS, dArkOS, JELOS, KOLOS, UnofficialOS, ArchR, +RetroOZ, plumOS, MiyooCFW or the Anbernic firmware: such a folder gets the system's folder below it, and a note says so, kept on **Create the folder?** and the plan. No to creating its folders asks for the card again. Under WSL a drive letter (`D:`, `D:\`) is that Windows drive, typed or given @@ -1197,16 +1204,20 @@ that question a drive is numbered by the folder its system reads (`ONION: BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never by its root, which no setup reads. A handheld's card whose own files sit on a part a computer does not open is told by the folders its system writes beside -`bios` on the games part: `themes` and `launchimages` on ArkOS's `EASYROMS`, -`gamedata` and `homebrew` on AmberELEC's `GAMES`, `bezels/default.cfg` on -EmuELEC's `EEROMS`. A disk Windows or Linux calls fixed is never one. With -nothing found here the first screen and line mode say `A card is in the reader -(EASYROMS): choose its system` (two cards: `choose their systems`), and its -`bios` folder comes first at the folder question, under Enter, before this -computer's usual folder. A drive with a `bios` folder and none of those -folders, a data drive or a stick, is numbered with a note (`DATA has a bios -folder, and nothing on it shows a handheld's card: listed, not chosen.`) and -never taken under Enter. Another system's card or SHARE drive, and a +`bios` on the games part: `bgmusic` or `launchimages` on ArkOS's `EASYROMS` +(every ArkOS card has one of them from its first images, `themes` only since +an update a full card skipped), `gamedata` and `homebrew` on AmberELEC's +`GAMES`, `bezels/default.cfg` on EmuELEC's `EEROMS`. A disk Windows or Linux +calls fixed is never one. AmberELEC's and EmuELEC's card is found as theirs +(`AmberELEC SD card (GAMES)` under **Found on this machine**), in both modes +and on their own setup's card question, and gets their list. An ArkOS card +reads RetroArch's list: with nothing found here the first screen and line mode +say `A card is in the reader (EASYROMS): choose its system` (two cards: `choose +their systems`), and its `bios` folder comes first at the folder question, +under Enter, before this computer's usual folder. A drive with a `bios` folder +and none of those folders, a data drive or a stick, is numbered with a note +(`DATA has a bios folder but no folder a handheld system writes. Pick it below +if your files go there.`) and never taken under Enter. Another system's card or SHARE drive, and a drive two systems fit, are left out, and a line names them: each is reached through its own setup. A folder typed is read from the current one when it is not a full path, and named in full after. `q` quits there too From ea214d9c017063c547a7335b41c5767e645cddcc Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:51:14 +0100 Subject: [PATCH 2/3] fix: every core ticked while none narrows the run Cores drew every box empty when no core narrows the run, read as nothing chosen. Every core is now drawn ticked, an untick keeps the others, All and None work as on Choose systems, and the plan, Options and line mode say all cores or N of M cores. A run with failures counts once, the failed files said as failed. --- install.ps1 | 2 +- install.py | 101 +++++++++++++++--- install.sh | 2 +- install/launchers/index.json | 16 +-- tests/fixtures/tui/colour-120x40.txt | 14 +-- tests/fixtures/tui/colour-40x12.txt | 20 ++-- tests/fixtures/tui/colour-40x15.txt | 20 ++-- tests/fixtures/tui/colour-80x24.txt | 16 +-- tests/fixtures/tui/cores-120x40.txt | 12 +-- tests/fixtures/tui/cores-40x12.txt | 16 +-- tests/fixtures/tui/cores-40x15.txt | 16 +-- tests/fixtures/tui/cores-80x24.txt | 12 +-- tests/fixtures/tui/regions-120x40.txt | 2 +- tests/fixtures/tui/regions-40x12.txt | 4 +- tests/fixtures/tui/regions-40x15.txt | 4 +- tests/fixtures/tui/regions-80x24.txt | 4 +- tests/fixtures/tui/route-evmapy.txt | 56 +++++----- tests/fixtures/tui/route-gptokeyb.txt | 56 +++++----- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 56 +++++----- tests/fixtures/tui/route-keys.txt | 56 +++++----- tests/fixtures/tui/route-miyoo.txt | 56 +++++----- tests/fixtures/tui/route-muxterm.txt | 56 +++++----- tests/test_install_from.py | 4 +- tests/test_install_tui.py | 89 +++++++++++++-- wiki/installer.md | 6 +- 25 files changed, 424 insertions(+), 272 deletions(-) diff --git a/install.ps1 b/install.ps1 index 37ec12874..db72b7c21 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d" + $defaultInstallSha256 = "403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index e81a361ed..c9894999b 100644 --- a/install.py +++ b/install.py @@ -8481,7 +8481,10 @@ def _print_menu_head( named = ", " + ", ".join(filter(None, [ f"for {held} of {total} systems" + (" (--system)" if flag else "") if systems else "", - ("--core" if flag else f"{_plural(len(cores), 'core')} chosen") if cores else "", + # Out of the cores the systems chosen offered, as Cores counts + # them: "1 core chosen" read as one of one. + ("--core" if flag else cores_words(len(cores), len(available_choices( + *narrow(files, omitted, systems, [], []))["cores"]))) if cores else "", ("--region" if flag else "regions ranked") if regions else ""])) named = "" if named == ", " else named if current is not given: @@ -8665,7 +8668,10 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N core_names = available_choices(kept, kept_omitted)["cores"] if len(core_names) > 1: print("\nThe platform's own files for these systems are always kept.") - print("The extra files each core loads can be narrowed.") + # Every core is kept, as Cores draws them all ticked: a list of + # numbers read as nothing chosen yet. + print("Every core's extra files are kept: Enter keeps them all, numbers keep " + "only those cores.") cores = _pick("Cores:", [ (core, _filter_by_target( [f for f in kept if f.get("cores") is not None], [core])) @@ -14620,6 +14626,11 @@ class TuiState: self.region_order: list[str] = [] # The cores of every system listed, after "Cores of the other systems". self.cores_all: bool = False + # None pressed on Cores: no core ticked, which no option says + # (state.cores empty is every core). Done is refused until one is. + self.no_cores: bool = False + # The cores --core named, as line mode names them on its head. + self.cli_cores: list = [] # The names of the systems ticked before a check counted again, and # those of them that hold nothing under its options. self.ticked_names: dict[str, str] = {} @@ -15364,6 +15375,26 @@ def tui_choices(state: TuiState) -> dict[str, list[str]]: return available_choices(manifest.get("files", []), manifest.get("omitted_files", [])) +def core_ticked(state: TuiState, name: str) -> bool: + """Whether Cores draws name ticked: every core while none narrows the + run (state.cores empty), else those chosen.""" + return name in state.cores or (not state.cores and not state.no_cores) + + +def _core_base(state: TuiState) -> list[str]: + """The cores a choice on Cores is made among: those of the systems + chosen, every core of the list else.""" + every = tui_choices(state)["cores"] + theirs = _chosen_cores(state) + return every if theirs is None else [name for name in every if name in theirs] + + +def cores_words(chosen: int, total: int) -> str: + """The cores a run keeps, as the plan, Options and line mode say it: + all cores, or 3 of 12 cores.""" + return "all cores" if not chosen or chosen >= total else f"{chosen} of {total} cores" + + def _chosen_cores(state: TuiState) -> set[str] | None: """The cores the files of the systems chosen name, None when every system is chosen.""" @@ -15492,8 +15523,11 @@ def tui_rows(state: TuiState) -> list[TuiRow]: # stays in view. shown = every if theirs is None or state.cores_all else [ name for name in every if name in theirs or name in state.cores] - rows = [_filter_row(state)] + [ - TuiRow("core", f"[{'x' if name in state.cores else ' '}] {name}", value=name) + # Every core ticked when no core narrows the run, as Choose systems + # ticks every system: all boxes empty read as nothing chosen, and + # the owner ticked each one by hand. + rows = [TuiRow("all", "All"), TuiRow("none", "None"), _filter_row(state)] + [ + TuiRow("core", f"[{'x' if core_ticked(state, name) else ' '}] {name}", value=name) for name in shown if _matches(name, needle)] hidden = len(every) - len(shown) if hidden: @@ -15818,8 +15852,9 @@ def _option_rows(state: TuiState) -> list[TuiRow]: f"{_yes(state.installed_only)} ({found} found)", "--installed-cores")) if choices["cores"]: - chosen = f"{len(state.cores)} chosen" if state.cores else "all" - rows.append(TuiRow("cores", f"Cores: {chosen}", "--core")) + # The plan's words, the noun left to the row's own label. + rows.append(TuiRow("cores", "Cores: " + cores_words( + len(state.cores), len(_core_base(state))).replace(" cores", ""), "--core")) copied = ", ".join(str(folder) for folder in state.from_folders) or "none" if manifest.get("save_directory"): # The interface said "Name it with --save-dir" and had no row for it. @@ -15908,6 +15943,9 @@ def _back(state: TuiState) -> list[tuple]: chose = False if state.screen == "regions": state.region_order = [] + if state.screen == "cores" and state.no_cores: + # None, then Esc: no core ticked is no choice, and every core is kept. + state.no_cores = False state.stack.pop() if chose and state.screen == "plan": # The systems chosen, the next press is Install: Back left the focus @@ -16665,6 +16703,18 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if action == "morecores": state.cores_all = True return [] + if action == "all": + state.cores, state.no_cores = [], False + return [] + if action == "none": + state.cores, state.no_cores = [], True + state.focus["cores"] = next( + (i for i, item in enumerate(tui_rows(state)) if item.action == "core"), + state.focus.get("cores", 0)) + return [] + if state.no_cores: + state.status = "No core is ticked: tick one, or choose All." + return [] return _back(state) if screen == "confirm": if action == "install": @@ -17148,7 +17198,7 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]: elif action == "cores": # On the first core: the filter row under A opened a field a # controller cannot type into. - return _push(state, action, 1 if tui_choices(state)["cores"] else 0) + return _push(state, action, 3 if tui_choices(state)["cores"] else 2) elif action == "regions": state.region_order = [] _push(state, action) @@ -17252,7 +17302,7 @@ def _tick(state: TuiState, row: TuiRow, on: bool | None) -> None: if state.screen == "systems" and row.action == "system": chosen = state.ticked elif state.screen == "cores" and row.action == "core": - chosen = set(state.cores) + chosen = {name for name in tui_choices(state)["cores"] if core_ticked(state, name)} else: return value = row.value @@ -17262,10 +17312,20 @@ def _tick(state: TuiState, row: TuiRow, on: bool | None) -> None: state.ticked.add(value) else: state.ticked.discard(value) - elif want and value not in state.cores: - state.cores.append(value) - elif not want and value in state.cores: + elif want: + if value not in state.cores: + state.cores.append(value) + state.no_cores = False + if set(_core_base(state)) <= set(state.cores): + # Every core ticked again: no narrowing, as before. + state.cores = [] + elif not state.cores and not state.no_cores: + # Every core was ticked: one untick keeps the others. + state.cores = [name for name in _core_base(state) if name != value] + state.no_cores = not state.cores + elif value in state.cores: state.cores.remove(value) + state.no_cores = not state.cores def _edit_text(text: str, key: TuiKey, selected: bool = False) -> str | None: @@ -18965,7 +19025,12 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: at += 1 named = [*([f"for {len(state.systems)} of {len(tui_systems(state))} systems (--system)"] if state.cli_systems and state.systems else []), - *(["--core"] if state.cores else []), *(["--region"] if state.regions else [])] + # Named on the command line as line mode names it, chosen on + # Cores out of how many: "--core" said nothing of the count. + *([("--core" if state.cores == state.cli_cores else + cores_words(len(state.cores), len(_core_base(state))))] + if state.cores else []), + *(["--region"] if state.regions else [])] said = ", ".join(named) # A system --system named that --no-game-data left empty, in line mode's # words (tui_audit_16 P3-7). @@ -19241,16 +19306,21 @@ def _middle_label(row: TuiRow, room: int) -> TuiRow: def _view_regions(state: TuiState, width: int, height: int) -> tuple: + # An order, not a set: ticked all, it would rank regions nobody ranked. + # None ticked keeps every region, said so. before = ["Tick the regions to keep, best first. Where a system has one BIOS " - "per region, the best one found is installed."] + "per region, the best one found is installed. With none ticked, every " + "region's BIOS is kept."] return ("Preferred regions", _compose(state, width, height, before, tui_rows(state)), ("A/Enter Tick", "B/Esc Done", "Left/Right Order", "? Help")) def _view_cores(state: TuiState, width: int, height: int) -> tuple: - before = ["Tick the cores you use. With none ticked, every core's files are kept."] - return ("Cores", _compose(state, width, height, before, tui_rows(state)), + before = ["Every core ticked keeps every core's files. Untick the cores you do not " + "use: the platform's own files are always kept."] + status = [state.status] if state.status else [] + return ("Cores", _compose(state, width, height, before, tui_rows(state), status), ("A/Enter Done", "B/Esc Back", "X/Space Tick", "? Help")) @@ -20233,6 +20303,7 @@ def tui_start( state.cores = list(dict.fromkeys( token.strip() for value in args.core or [] for token in value.split(",") if token.strip())) + state.cli_cores = list(state.cores) state.no_game_data = state.cli_no_game_data = bool(args.no_game_data) state.installed_only = bool(args.installed_cores) state.jobs = args.jobs diff --git a/install.sh b/install.sh index 26f947c7e..cacf12717 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d" +DEFAULT_INSTALL_SHA256="403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index bd040ac20..19f5a91c4 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": 981280, - "sha256": "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d", + "size": 984900, + "sha256": "403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1034457, - "sha256": "92d78b81459583ea9ac00546e28f3118e815ac302c0f180e882b44a16e5bcb53" + "size": 1038077, + "sha256": "49b9de6afa23afda4f6c786fa0968c36af1bd2ec9b52cb0927df4c527dcc701c" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 981280, - "sha256": "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d", + "size": 984900, + "sha256": "403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 981280, - "sha256": "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d", + "size": 984900, + "sha256": "403b6e8a7efcd24858b0f46ffa19c2b9012cdeef149768729d8e48d0b68a296a", "executable": false }, { diff --git a/tests/fixtures/tui/colour-120x40.txt b/tests/fixtures/tui/colour-120x40.txt index 376973b2e..7b7a0d47d 100644 --- a/tests/fixtures/tui/colour-120x40.txt +++ b/tests/fixtures/tui/colour-120x40.txt @@ -494,7 +494,7 @@ ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found - is installed. + is installed. With none ticked, every region's BIOS is kept. ^[[0;7;35m > [1] North America^[[0m [ ] Japan @@ -534,9 +534,12 @@ === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: the platform's own + files are always kept. -^[[0;7;35m > Filter (/ to type)^[[0m +^[[0;7;35m > All^[[0m + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed @@ -568,10 +571,7 @@ - - - - ^[[0;1;35mA/Enter^[[0m Filter ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA/Enter^[[0m Tick all ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m diff --git a/tests/fixtures/tui/colour-40x12.txt b/tests/fixtures/tui/colour-40x12.txt index b1eac105c..bbbf7d339 100644 --- a/tests/fixtures/tui/colour-40x12.txt +++ b/tests/fixtures/tui/colour-40x12.txt @@ -160,26 +160,26 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. ^[[0;7;35m > [1] North America^[[0m [ ] Japan - ^[[0;1;35mA^[[0m Tick ^[[0;2mB^[[0m Done ^[[0;2m<>^[[0m Order ^[[0;2m?^[[0m Help === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m - Tick the cores you use. With none - ticked, every core's files are kept. - -^[[0;7;35m > Filter (/ to type)^[[0m + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. +^[[0;7;35m > All^[[0m + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed - - - - ^[[0;1;35mA^[[0m Filter ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA^[[0m Tick all ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m diff --git a/tests/fixtures/tui/colour-40x15.txt b/tests/fixtures/tui/colour-40x15.txt index a1914ccc5..9430e9fbd 100644 --- a/tests/fixtures/tui/colour-40x15.txt +++ b/tests/fixtures/tui/colour-40x15.txt @@ -196,7 +196,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. ^[[0;7;35m > [1] North America^[[0m [ ] Japan @@ -204,24 +205,23 @@ - ^[[0;1;35mA^[[0m Tick ^[[0;2mB^[[0m Done ^[[0;2m<>^[[0m Order ^[[0;2m?^[[0m Help === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m - Tick the cores you use. With none - ticked, every core's files are kept. + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. -^[[0;7;35m > Filter (/ to type)^[[0m +^[[0;7;35m > All^[[0m + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed - - - - - ^[[0;1;35mA^[[0m Filter ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA^[[0m Tick all ^[[0;2mB^[[0m Back ^[[0;2mX^[[0m Tick ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m diff --git a/tests/fixtures/tui/colour-80x24.txt b/tests/fixtures/tui/colour-80x24.txt index 2501614e0..8c3fb5a3a 100644 --- a/tests/fixtures/tui/colour-80x24.txt +++ b/tests/fixtures/tui/colour-80x24.txt @@ -302,7 +302,8 @@ ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m Tick the regions to keep, best first. Where a system has one BIOS per region, - the best one found is installed. + the best one found is installed. With none ticked, every region's BIOS is + kept. ^[[0;7;35m > [1] North America^[[0m [ ] Japan @@ -321,14 +322,16 @@ - ^[[0;1;35mA/Enter^[[0m Tick ^[[0;2mB/Esc^[[0m Done ^[[0;2mLeft/Right^[[0m Order ^[[0;2m?^[[0m Help === cores ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: + the platform's own files are always kept. -^[[0;7;35m > Filter (/ to type)^[[0m +^[[0;7;35m > All^[[0m + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed @@ -344,10 +347,7 @@ - - - - ^[[0;1;35mA/Enter^[[0m Filter ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help + ^[[0;1;35mA/Enter^[[0m Tick all ^[[0;2mB/Esc^[[0m Back ^[[0;2mX/Space^[[0m Tick ^[[0;2m?^[[0m Help === preview ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m diff --git a/tests/fixtures/tui/cores-120x40.txt b/tests/fixtures/tui/cores-120x40.txt index d7a27d39c..571c06bea 100644 --- a/tests/fixtures/tui/cores-120x40.txt +++ b/tests/fixtures/tui/cores-120x40.txt @@ -1,8 +1,11 @@ RetroBIOS: Cores - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: the platform's own + files are always kept. - > Filter (/ to type) + > All + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed @@ -34,7 +37,4 @@ - - - - A/Enter Filter B/Esc Back X/Space Tick ? Help + A/Enter Tick all B/Esc Back X/Space Tick ? Help diff --git a/tests/fixtures/tui/cores-40x12.txt b/tests/fixtures/tui/cores-40x12.txt index 0a09139e1..7557af130 100644 --- a/tests/fixtures/tui/cores-40x12.txt +++ b/tests/fixtures/tui/cores-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. - - > Filter (/ to type) + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. + > All + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed - - - - A Filter B Back X Tick ? Help + A Tick all B Back X Tick ? Help diff --git a/tests/fixtures/tui/cores-40x15.txt b/tests/fixtures/tui/cores-40x15.txt index 0015d8e99..5cbef31a7 100644 --- a/tests/fixtures/tui/cores-40x15.txt +++ b/tests/fixtures/tui/cores-40x15.txt @@ -1,15 +1,15 @@ RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. - > Filter (/ to type) + > All + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed - - - - - A Filter B Back X Tick ? Help + A Tick all B Back X Tick ? Help diff --git a/tests/fixtures/tui/cores-80x24.txt b/tests/fixtures/tui/cores-80x24.txt index 1c98e53b5..c2fce774c 100644 --- a/tests/fixtures/tui/cores-80x24.txt +++ b/tests/fixtures/tui/cores-80x24.txt @@ -1,8 +1,11 @@ RetroBIOS: Cores - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: + the platform's own files are always kept. - > Filter (/ to type) + > All + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed @@ -18,7 +21,4 @@ - - - - A/Enter Filter B/Esc Back X/Space Tick ? Help + A/Enter Tick all B/Esc Back X/Space Tick ? Help diff --git a/tests/fixtures/tui/regions-120x40.txt b/tests/fixtures/tui/regions-120x40.txt index 17076bca5..271fd15d7 100644 --- a/tests/fixtures/tui/regions-120x40.txt +++ b/tests/fixtures/tui/regions-120x40.txt @@ -1,7 +1,7 @@ RetroBIOS: Preferred regions Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found - is installed. + is installed. With none ticked, every region's BIOS is kept. > [1] North America [ ] Japan diff --git a/tests/fixtures/tui/regions-40x12.txt b/tests/fixtures/tui/regions-40x12.txt index 077ec3344..70928bc8b 100644 --- a/tests/fixtures/tui/regions-40x12.txt +++ b/tests/fixtures/tui/regions-40x12.txt @@ -3,10 +3,10 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan - A Tick B Done <> Order ? Help diff --git a/tests/fixtures/tui/regions-40x15.txt b/tests/fixtures/tui/regions-40x15.txt index ed7a42e45..fb7897ea1 100644 --- a/tests/fixtures/tui/regions-40x15.txt +++ b/tests/fixtures/tui/regions-40x15.txt @@ -3,7 +3,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan @@ -11,5 +12,4 @@ - A Tick B Done <> Order ? Help diff --git a/tests/fixtures/tui/regions-80x24.txt b/tests/fixtures/tui/regions-80x24.txt index f34ca81a9..fe09f21c6 100644 --- a/tests/fixtures/tui/regions-80x24.txt +++ b/tests/fixtures/tui/regions-80x24.txt @@ -1,7 +1,8 @@ RetroBIOS: Preferred regions Tick the regions to keep, best first. Where a system has one BIOS per region, - the best one found is installed. + the best one found is installed. With none ticked, every region's BIOS is + kept. > [1] North America [ ] Japan @@ -20,5 +21,4 @@ - A/Enter Tick B/Esc Done Left/Right Order ? Help diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index dea4a502d..757cd93ec 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -623,12 +623,12 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan - A Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -636,7 +636,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan @@ -644,13 +645,13 @@ - A Tick B Done <> Order === regions 80x24 RetroBIOS: Preferred regions Tick the regions to keep, best first. Where a system has one BIOS per region, - the best one found is installed. + the best one found is installed. With none ticked, every region's BIOS is + kept. > [1] North America [ ] Japan @@ -669,43 +670,45 @@ - A Tick B Done Left/Right Order === cores 40x12 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. - - > Filter (with a keyboard) + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed - - - - A Filter B Back X Tick + A Tick all B Back X Tick === cores 40x15 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. - > Filter (with a keyboard) + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed - - - - - A Filter B Back X Tick + A Tick all B Back X Tick === cores 80x24 RetroBIOS: Cores - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: + the platform's own files are always kept. - > Filter (with a keyboard) + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed @@ -721,10 +724,7 @@ - - - - A Filter B Back X Tick + A Tick all B Back X Tick === preview 80x24 RetroBIOS: Preview diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index be42befe4..57e529f2c 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -623,12 +623,12 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan - A Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -636,7 +636,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan @@ -644,13 +645,13 @@ - A Tick B Done <> Order === regions 80x24 RetroBIOS: Preferred regions Tick the regions to keep, best first. Where a system has one BIOS per region, - the best one found is installed. + the best one found is installed. With none ticked, every region's BIOS is + kept. > [1] North America [ ] Japan @@ -669,43 +670,45 @@ - A Tick B Done Left/Right Order === cores 40x12 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. - - > Filter (with a keyboard) + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed - - - - A Filter B Back X Tick + A Tick all B Back X Tick === cores 40x15 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. - > Filter (with a keyboard) + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed - - - - - A Filter B Back X Tick + A Tick all B Back X Tick === cores 80x24 RetroBIOS: Cores - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: + the platform's own files are always kept. - > Filter (with a keyboard) + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed @@ -721,10 +724,7 @@ - - - - A Filter B Back X Tick + A Tick all B Back X Tick === preview 80x24 RetroBIOS: Preview diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index cd39e4a38..7b58f1a18 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -623,12 +623,12 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan - A Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -636,7 +636,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan @@ -644,13 +645,13 @@ - A Tick B Done <> Order === regions 80x24 RetroBIOS: Preferred regions Tick the regions to keep, best first. Where a system has one BIOS per region, - the best one found is installed. + the best one found is installed. With none ticked, every region's BIOS is + kept. > [1] North America [ ] Japan @@ -669,43 +670,45 @@ - A Tick B Done Left/Right Order === cores 40x12 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. - - > Filter (with a keyboard) + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed - - - - A Filter B Back X Tick + A Tick all B Back X Tick === cores 40x15 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. - > Filter (with a keyboard) + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed - - - - - A Filter B Back X Tick + A Tick all B Back X Tick === cores 80x24 RetroBIOS: Cores - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: + the platform's own files are always kept. - > Filter (with a keyboard) + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed @@ -721,10 +724,7 @@ - - - - A Filter B Back X Tick + A Tick all B Back X Tick === preview 80x24 RetroBIOS: Preview diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index 189f0f71e..27618219c 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -598,12 +598,12 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan - Enter Tick Esc Done <> Order ? Help === regions 40x15 RetroBIOS: Preferred regions @@ -611,7 +611,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan @@ -619,13 +620,13 @@ - Enter Tick Esc Done <> Order ? Help === regions 80x24 RetroBIOS: Preferred regions Tick the regions to keep, best first. Where a system has one BIOS per region, - the best one found is installed. + the best one found is installed. With none ticked, every region's BIOS is + kept. > [1] North America [ ] Japan @@ -644,43 +645,45 @@ - Enter Tick Esc Done Left/Right Order ? Help === cores 40x12 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. - - > Filter (/ to type) + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. + > All + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed - - - - Enter Filter Esc Back Space Tick + Enter Tick all Esc Back Space Tick === cores 40x15 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. - > Filter (/ to type) + > All + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed - - - - - Enter Filter Esc Back Space Tick + Enter Tick all Esc Back Space Tick === cores 80x24 RetroBIOS: Cores - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: + the platform's own files are always kept. - > Filter (/ to type) + > All + None + Filter (/ to type) [ ] mesen [x] pcsx_rearmed @@ -696,10 +699,7 @@ - - - - Enter Filter Esc Back Space Tick ? Help + Enter Tick all Esc Back Space Tick ? Help === preview 40x12 RetroBIOS: Preview diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 5b9098ff5..80d5b8cbd 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -652,12 +652,12 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan - A Tick R2 Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -665,7 +665,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan @@ -673,13 +674,13 @@ - A Tick R2 Done <> Order === regions 80x24 RetroBIOS: Preferred regions Tick the regions to keep, best first. Where a system has one BIOS per region, - the best one found is installed. + the best one found is installed. With none ticked, every region's BIOS is + kept. > [1] North America [ ] Japan @@ -698,43 +699,45 @@ - A Tick R2 Done Left/Right Order === cores 40x12 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. - - > Filter (with a keyboard) + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed - - - - A Filter R2 Back + A Tick all R2 Back === cores 40x15 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. - > Filter (with a keyboard) + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed - - - - - A Filter R2 Back + A Tick all R2 Back === cores 80x24 RetroBIOS: Cores - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: + the platform's own files are always kept. - > Filter (with a keyboard) + > All + None + Filter (with a keyboard) [ ] mesen [x] pcsx_rearmed @@ -750,10 +753,7 @@ - - - - A Filter R2 Back + A Tick all R2 Back === preview 80x24 RetroBIOS: Preview diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 05260a80f..1ad851b69 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -652,12 +652,12 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan - Start Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -665,7 +665,8 @@ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found is - installed. + installed. With none ticked, every + region's BIOS is kept. > [1] North America [ ] Japan @@ -673,13 +674,13 @@ - Start Tick B Done <> Order === regions 80x24 RetroBIOS: Preferred regions Tick the regions to keep, best first. Where a system has one BIOS per region, - the best one found is installed. + the best one found is installed. With none ticked, every region's BIOS is + kept. > [1] North America [ ] Japan @@ -698,43 +699,45 @@ - Start Tick B Done Left/Right Order === cores 40x12 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. - - > Filter (Select shows a keyboard) + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. + > All + None + Filter (Select shows a keyboard) [ ] mesen [x] pcsx_rearmed - - - - Start Filter B Back Y Tick + Start Tick all B Back Y Tick === cores 40x15 RetroBIOS: Cores - Tick the cores you use. With none - ticked, every core's files are kept. + Every core ticked keeps every core's + files. Untick the cores you do not + use: the platform's own files are + always kept. - > Filter (Select shows a keyboard) + > All + None + Filter (Select shows a keyboard) [ ] mesen [x] pcsx_rearmed - - - - - Start Filter B Back Y Tick + Start Tick all B Back Y Tick === cores 80x24 RetroBIOS: Cores - Tick the cores you use. With none ticked, every core's files are kept. + Every core ticked keeps every core's files. Untick the cores you do not use: + the platform's own files are always kept. - > Filter (Select shows a keyboard) + > All + None + Filter (Select shows a keyboard) [ ] mesen [x] pcsx_rearmed @@ -750,10 +753,7 @@ - - - - Start Filter B Back Y Tick + Start Tick all B Back Y Tick === preview 40x12 RetroBIOS: Preview diff --git a/tests/test_install_from.py b/tests/test_install_from.py index 2ddb97e7e..ff28c52c7 100644 --- a/tests/test_install_from.py +++ b/tests/test_install_from.py @@ -148,7 +148,7 @@ class CopyFromAnOldCard(FromFolder): proc = self.install() self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) self.assertEqual(self.installed(), {"scph5500.bin": JP, "pcsx/extra.bin": EXTRA}) - self.assertIn(f"2 files copied from {self.old}, 2 still missing.", proc.stdout) + self.assertIn(f"2 files copied from {self.old}, 2 failed.", proc.stdout) def test_a_link_out_of_the_folder_is_not_followed(self): outside = self.root / "elsewhere" @@ -196,7 +196,7 @@ class CopyFromAnOldCard(FromFolder): (repo.root / "bios" / "mgba_extra.bin").unlink() proc = repo.run("--from", str(old), "--no-input") self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) - self.assertIn(f"2 files copied from {old}, 2 downloaded, 1 still missing.", + self.assertIn(f"2 files copied from {old}, 2 downloaded, 1 failed.", proc.stdout) self.assertEqual(sorted(repo.installed()), sorted( name for name in payloads if name != "mgba/extra.bin")) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 96602a56e..f4cf5099a 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -527,18 +527,20 @@ class Flow(unittest.TestCase): state.focus["options"] = 3 press(state, "enter") self.assertEqual(state.screen, "cores") - self.assertEqual([row.value for row in install.tui_rows(state)][1:], + self.assertEqual([row.value for row in install.tui_rows(state)][3:], ["mesen", "pcsx_rearmed"]) self.assertEqual(install.tui_rows(state)[state.focus["cores"]].value, "mesen") + # Every core ticked while none narrows the run: one untick keeps + # the others, ticked back it is every core again. press(state, " ") - self.assertEqual(state.cores, ["mesen"]) - press(state, "left") + self.assertEqual(state.cores, ["pcsx_rearmed"]) + press(state, "right") self.assertEqual(state.cores, []) press(state, "/", "p", "c") - self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["pcsx_rearmed"]) - press(state, "enter", "right", "enter") + self.assertEqual([row.value for row in install.tui_rows(state)][3:], ["pcsx_rearmed"]) + press(state, "enter", "left", "enter") self.assertEqual(state.screen, "options") - self.assertEqual(install.tui_flags(state), [("--core", "pcsx_rearmed")]) + self.assertEqual(install.tui_flags(state), [("--core", "mesen")]) def test_preview_is_a_dry_run(self): state = checked_state() @@ -13855,6 +13857,81 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): self.assertIn("reads its BIOS files", small) self.assertIn(folder[-12:], small) + # The owner's note: Cores drew every box empty when no core narrows the + # run, read as "nothing is ticked, tick them all". + + def cores_screen(self, **given) -> install.TuiState: + state = checked_state() + state.cores = list(given.get("cores", [])) + state.cli_cores = list(state.cores) + press(state, "o") + state.focus["options"] = next(index for index, row in enumerate( + install.tui_rows(state)) if row.action == "cores") + press(state, "enter") + self.assertEqual(state.screen, "cores") + return state + + @staticmethod + def boxes(state: install.TuiState) -> dict: + return {row.value: row.label[:3] for row in install.tui_rows(state) + if row.action == "core"} + + def test_cores_open_every_one_ticked(self): + state = self.cores_screen() + self.assertEqual(self.boxes(state), {"mesen": "[x]", "pcsx_rearmed": "[x]"}) + self.assertEqual(install.tui_rows(state)[state.focus["cores"]].value, "mesen") + self.assertEqual(install.tui_flags(state), []) + + def test_one_untick_narrows_to_the_others(self): + state = self.cores_screen() + press(state, " ") + self.assertEqual(self.boxes(state), {"mesen": "[ ]", "pcsx_rearmed": "[x]"}) + self.assertEqual(install.tui_flags(state), [("--core", "pcsx_rearmed")]) + press(state, " ") + self.assertEqual(self.boxes(state), {"mesen": "[x]", "pcsx_rearmed": "[x]"}) + self.assertEqual(state.cores, []) + press(state, " ", "enter") + self.assertEqual(state.screen, "options") + row = next(row for row in install.tui_rows(state) if row.action == "cores") + self.assertEqual(row.label, "Cores: 1 of 2") + state.stack = ["setup", "plan"] + self.assertIn("1 of 2 cores", " ".join(install.tui_view(state, 100, 30))) + + def test_none_then_all_as_the_systems_list(self): + state = self.cores_screen() + rows = install.tui_rows(state) + state.focus["cores"] = next(i for i, row in enumerate(rows) if row.action == "none") + press(state, "enter") + self.assertEqual(self.boxes(state), {"mesen": "[ ]", "pcsx_rearmed": "[ ]"}) + press(state, "enter") + self.assertEqual(state.screen, "cores") + self.assertIn("No core is ticked", str(state.status)) + state.focus["cores"] = next(i for i, row in enumerate(rows) if row.action == "all") + press(state, "enter") + self.assertEqual(self.boxes(state), {"mesen": "[x]", "pcsx_rearmed": "[x]"}) + self.assertEqual(install.tui_flags(state), []) + + def test_core_ticks_only_the_cores_it_names(self): + state = self.cores_screen(cores=["pcsx_rearmed"]) + self.assertEqual(self.boxes(state), {"mesen": "[ ]", "pcsx_rearmed": "[x]"}) + state.stack = ["setup", "plan"] + self.assertIn("--core", " ".join(install.tui_view(state, 100, 30))) + + def test_both_modes_count_the_cores_kept_alike(self): + listing = self.listing("retroarch", [ + self.entry("a.bin", b"A" * 8, ["nintendo-gba"]), + self.entry("m.bin", b"M" * 8, ["nintendo-gba"], cores=["mgba"]), + self.entry("v.bin", b"V" * 8, ["nintendo-gba"], cores=["vba_next"]), + self.entry("p.bin", b"P" * 8, ["sony-playstation"])]) + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False, dry_run=True), + ["c", "", "2"]) + self.assertIn("Every core's extra files are kept: Enter keeps them all", text) + head = text.rsplit("Counted again for your choices", 1)[1] + self.assertIn("1 of 2 cores", head) + self.assertEqual(install.cores_words(0, 2), "all cores") + self.assertEqual(install.cores_words(2, 2), "all cores") + # Root cause 6: the pages say what the installer shows. def test_which_pack_names_the_first_screen(self): diff --git a/wiki/installer.md b/wiki/installer.md index cd21730d0..19531cdeb 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -770,7 +770,11 @@ answer is not asked. chosen too, as `generate_pack.py --system` does. - **Cores.** The platform's own BIOS list for the chosen systems is always kept. This narrows only the extra files each emulator core loads beyond it. - Asked only when the chosen systems have such files. + Asked only when the chosen systems have such files. Every core is kept + until numbers name some, and the head then says how many of how many + (`1 of 2 cores`). The full screen's **Cores** opens with every core + ticked, as Choose systems does: untick the ones you do not use, **All** + and **None** tick or untick them all. - **Regions.** Asked only when a chosen system has one BIOS per region. The numbers give an order of preference: `2,1` keeps the second region where a file for it exists, and the first one elsewhere. From 8e811582c12a0eb4dbfcd366ddbf6e0a7058a838 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:22:16 +0100 Subject: [PATCH 3/3] fix: which-pack names the first screen in brief The sentence fits the page's 400-word ceiling and its 20-word sentences. A test reading the site generator skips under Python 3.8, as the others do, and the core plan test names the cores --core gave. --- scripts/generate_site.py | 10 ++++------ tests/test_install_tui.py | 3 +++ tests/test_site_r2_entry.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index bbca89d58..b951e8a2d 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -3941,12 +3941,10 @@ def first_screen_words() -> list[str]: since.""" installer = _installer() # The screen it opens first: the setup found, or the list when none - # is. Install and Choose systems come on the next one (tui_audit_16 - # P3-10). - return [f"The command asks **{installer.TUI_FOUND_TITLE}** with the setup it found, " - f"or **{installer.TUI_CHOOSE_TITLE}** with a list when it finds none. Then " - f"**{installer.TUI_INSTALL_ROW}** gets every file, " - f"**{installer.TUI_SYSTEMS_ROW}** only the consoles you pick."] + # is. Install comes on the next one, Choose systems beside it and named + # above on the page (tui_audit_16 P3-10). + return [f"It asks **{installer.TUI_FOUND_TITLE}** (or **{installer.TUI_CHOOSE_TITLE}**), " + f"then offers **{installer.TUI_INSTALL_ROW}**."] def check_pass_line() -> str: diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index f4cf5099a..98932609d 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -9880,6 +9880,7 @@ class AuditEighteenAndEleven(_CardFixtures, unittest.TestCase): """P3-11.""" state = checked_state() state.cli_systems, state.cores, state.regions = ["sony-playstation"], ["x"], ["us"] + state.cli_cores = ["x"] text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) self.assertIn("(--system), --core, --region.", text) @@ -13934,6 +13935,8 @@ class AuditTwentyThreeAndSixteen(unittest.TestCase): # Root cause 6: the pages say what the installer shows. + @unittest.skipIf(sys.version_info < (3, 9), + "the site generator needs Python 3.9") def test_which_pack_names_the_first_screen(self): """tui_audit_16 P3-10: which-pack said the command opens a screen where Install and Choose systems are, the second screen.""" diff --git a/tests/test_site_r2_entry.py b/tests/test_site_r2_entry.py index cda36db3f..c70bc16da 100644 --- a/tests/test_site_r2_entry.py +++ b/tests/test_site_r2_entry.py @@ -167,7 +167,7 @@ class GetYourFilesOrder(unittest.TestCase): text = _page("which-pack.md") order = [text.index(mark) for mark in ( "## Pick your setup", "| Setup | Runs on | BIOS folder | ZIP |", "Is it safe?", )] self.assertEqual(order, sorted(order))