diff --git a/README.md b/README.md index 90365bf8b..2ece31100 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Every option, and what the installer detects and writes, is on the [installer pa ## Download BIOS packs -A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file. At a terminal it offers to install everything or to choose systems, cores and regions, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below. +A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **Choose systems**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below. A pack over 2 GB comes in several parts (`Pack.part1of2.zip`, `Pack.part2of2.zip`), and every part is needed. Each part is an ordinary ZIP: extract them all into the same folder. diff --git a/install.ps1 b/install.ps1 index eaf69702f..44f854f60 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 = "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7" + $defaultInstallSha256 = "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 47ddc9a84..06df81429 100644 --- a/install.py +++ b/install.py @@ -361,9 +361,8 @@ COLOUR_ROLES = { # The words a status takes its role from, wherever it is said. A count of # zero is no news and stays plain: "0 missing" is not a warning. _STATUS_WORDS = tuple((re.compile(pattern), role) for pattern, role in ( - (r"(? bool: - return os.environ.get("RETROBIOS_BOOTSTRAP") == "ps1" or self.os_type == "windows" + """Whether the command is typed into PowerShell: its words and its + line ends both read this (_continuation). The bootstrap names its + shell, the system decides without one.""" + bootstrap = os.environ.get("RETROBIOS_BOOTSTRAP") + if bootstrap in ("ps1", "sh"): + return bootstrap == "ps1" + return (self.os_type or ("windows" if os.name == "nt" else "")) == "windows" def launcher(self, elevated: bool = False) -> str: """The command without its options, as the user typed it.""" @@ -5786,6 +5791,14 @@ class Invocation: _INVOCATION = Invocation() +# What a run says when every file is in place, on the screen and in line +# mode: the site's "How to check" quotes it (scripts/generate_site.py). +EVERYTHING_IN_PLACE = "Everything is in place." +# The plan's two rows a beginner reads first: the README and the site name +# them from here, so they say what the screen shows. +TUI_INSTALL_ROW = "Install" +TUI_SYSTEMS_ROW = "Choose systems" + def _on_read_only_mount(path: Path) -> bool: """Whether the file system holding path, or the folder it would go in, @@ -5962,6 +5975,10 @@ def _report_outcome( codes the full screen's run gives. term paints the status words. """ say = _status_print(term) + # The command making the same choice ends every result the screen ends + # with it: "Finished with files missing" gave none (tui_audit_15 P3-8). + same_lines = ([f"Same choice without questions: {screen_text(same_choice)}"] + if same_choice else []) # A file --offline left out is not a failure, whether the run asked or # not: without questions it was "14698 files failed", and "Run the same # command again to retry only those", which cannot fetch them. @@ -5988,7 +6005,8 @@ def _report_outcome( say(f" ... and {len(failures) - 10} more") if declined and not downloaded and not errors and not interrupted and not offline: say(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.") - say(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.") + if up_to_date: + say(f"{_plural(up_to_date, 'file')} already in place at {_locations(paths)}.") for note in notes or []: say(note) return @@ -6049,11 +6067,19 @@ def _report_outcome( for line in kept: say(line) if up_to_date: - say(f"{_plural(up_to_date, 'file')} already up to date at " + say(f"{_plural(up_to_date, 'file')} already in place at " f"{_locations(paths)}.") + for line in same_lines: + say(line) sys.exit(2) failed = f", {errors} failed" if errors else "" - if downloaded: + if downloaded and sources and not errors: + # One count, the remedy under it, as the screen's result: the + # count came twice, "installed" then "copied" (tui_audit_15 + # P3-8). + say(f"\nFinished with files missing. {sources}") + sources = "" + elif downloaded: say(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, " f"{offline} not fetched ({offline_named()}){failed}.") else: @@ -6069,7 +6095,7 @@ def _report_outcome( # read as that one file. say(f" {remedy}") say(f"BIOS folder: {_locations(paths)}") - for line in kept + advice: + for line in kept + advice + same_lines: say(line) sys.exit(1 if downloaded else 2) @@ -6089,6 +6115,8 @@ def _report_outcome( say(line) say(_retry_words(failures) or "Run the same command again once that is resolved. " "Files already installed are kept.") + for line in same_lines: + say(line) sys.exit(2) if errors: @@ -6106,11 +6134,23 @@ def _report_outcome( say(retry) elif not all(str(reason).startswith(NOT_KEPT) for _dest, reason in failures or []): say("Run the same command again to retry only those.") + for line in same_lines: + say(line) sys.exit(1) - # No zero count: "0 files already up to date" said nothing. - say(f"\nDone. {_plural(downloaded, 'file')} installed" - + (f", {_plural(up_to_date, 'file')} already up to date." if up_to_date else ".")) + # No zero count, the screen's words: "Done. 0 files installed, 22 + # files already up to date" beside its "Everything is in place" + # (code_audit_22 P3-4). + in_place = f" {_plural(up_to_date, 'file')} already in place." if up_to_date else "" + if not downloaded: + say(f"\nDone. {EVERYTHING_IN_PLACE}") + elif sources and "," not in sources and not KEPT.summary(): + # Every file from one place: "22 files installed." over "22 files + # copied from packx." said one count twice (tui_audit_15 P3-8). + say(f"\nDone. {sources}{in_place}") + sources = "" + else: + say(f"\nDone. {_plural(downloaded, 'file')} installed.{in_place}") if sources: say(f" {sources}") if installed_kinds: @@ -6129,7 +6169,8 @@ def _report_outcome( if same_choice: # After the questions, as the screen's result ends: "the same # command" was one that asked them (tui_audit_11 P3-21). - say(f"Same choice without questions: {screen_text(same_choice)}") + for line in same_lines: + say(line) say("To check this later, run that command with --check.") return say("To check this later, run the same command with --check.") @@ -6177,15 +6218,15 @@ def _check_fetch_line(fetch: int, beside: str, lacking: int = 0) -> str: return (f" {LOCAL.pack.name} holds none of them: a newer pack, or a run " "without --from-pack, installs them.") return (f" Run the same command without --check to copy {held} of them from " - f"{LOCAL.pack.name}. A file with a wrong hash is replaced, and the one in " + f"{LOCAL.pack.name}. A wrong copy is replaced, and the one in " f"place kept in {BACKUP_DIR} beside {beside}.") if _offline_only(): if not fetch: return " Run the same command without --check to copy them into place." return (f" {_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the " f"internet, which {offline_named()} does not use. {_offline_remedy()}") - return (" Run the same command without --check to fetch them. A file with a wrong " - f"hash is replaced, and the one in place kept in {BACKUP_DIR} beside {beside}.") + 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}.") def _offline_failure(reason: str) -> bool: @@ -6367,7 +6408,7 @@ def _print_plan( # said of a run that makes no network request. verb = "copy" if LOCAL.pack is not None else "download" if not parts and not copied: - print(f"\n All files up to date. A real run would {verb} nothing.") + print(f"\n {EVERYTHING_IN_PLACE} A real run would {verb} nothing.") return if parts and _offline_only(): # --offline fetches none of them: "Would download" promised a @@ -6644,15 +6685,19 @@ def data_directory_state(entry: dict, bios_path: Path) -> tuple[Path, dict[str, return root, needed, present -def install_data_directory(entry: dict, bios_path: Path) -> dict: +def install_data_directory(entry: dict, bios_path: Path, + state: tuple[Path, dict[str, dict], int] | None = None) -> dict: """Rebuild one data tree from its source archive, file by file verified. + state is the tree's data_directory_state when the run already read it: + each read hashes every file in place (code_audit_22 P3-3). Returns counts: present, installed, changed (the source no longer holds the recorded bytes), absent (not in the archive), error, the reason the archive could not be read, and not_kept, the (path, reason) of each file in place that differs and could not be moved aside, left as it is. """ - root, needed, present = data_directory_state(entry, bios_path) + root, needed, present = state or data_directory_state(entry, bios_path) + needed = dict(needed) counts = {"present": present, "installed": 0, "changed": 0, "absent": 0, "error": "", "not_kept": []} if not needed: @@ -6863,7 +6908,7 @@ def _install_standalone_folders( up_to_date_count += len(up_to_date) if args.check: for entry in mismatched: - print(f" wrong hash: {entry['dest']}") + print(f" wrong copy: {entry['dest']}") wrong = {id(entry) for entry in mismatched} missing = [entry for entry in to_download if id(entry) not in wrong] for entry in missing[:10] if not args.verbose else missing: @@ -6871,7 +6916,7 @@ def _install_standalone_folders( if not args.verbose and len(missing) > 10: print(f" ... and {len(missing) - 10} more missing (--verbose lists them all)") if not to_download: - print(" All files up to date.") + print(f" {EVERYTHING_IN_PLACE}") pending += len(to_download) continue if args.dry_run: @@ -6879,7 +6924,7 @@ def _install_standalone_folders( verbose=args.verbose) continue if not to_download: - print(" All files up to date.") + print(f" {EVERYTHING_IN_PLACE}") continue size = sum(entry.get("size", 0) for entry in to_download) _check_free_space(folder, size, code=1 if downloaded else 2) @@ -7249,37 +7294,137 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: return sorted(targets, key=lambda target: target[1] not in cards) -# The folders a handheld card's games part holds, one per console: the -# names people type for a system (SYSTEM_SHORT_NAMES) and the ArkOS and -# AmberELEC roms folders those miss. -_CONSOLE_FOLDERS = frozenset({*SYSTEM_SHORT_NAMES, "arcade", "atari2600", "atarilynx", - "fbneo", "gamegear", "mame", "mastersystem", "megadrive", - "neogeo", "pcengine", "psp", "segacd", "wonderswan"}) +# 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")), + # 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")), + # EmuELEC d4c7b2f1 copies its default bezels to EEROMS at every boot + # (packages/sx05re/emuelec/bin/emuelec_autostart.sh:78-81). + ("EmuELEC", ("bezels/default.cfg",)), +) + +# GetDriveTypeW: DRIVE_FIXED, DRIVE_REMOTE, DRIVE_CDROM and DRIVE_RAMDISK, +# none of them a card in a reader. +_NOT_REMOVABLE_TYPES = (3, 4, 5, 6) + + +def _drive_not_removable(mount: Path) -> bool: + """Whether the system says mount is a drive no card sits in: a fixed + disk, a network drive or a disc on Windows (GetDriveTypeW), a whole + partition of a disk the kernel calls fixed elsewhere (_fixed_partition). + False where it says nothing.""" + if os.name == "nt": + try: + import ctypes # noqa: PLC0415 + kind = ctypes.windll.kernel32.GetDriveTypeW(str(mount)) # type: ignore[attr-defined] + except (AttributeError, OSError): + return False + return kind in _NOT_REMOVABLE_TYPES + try: + text = Path("/proc/self/mountinfo").read_text(encoding="utf-8", errors="replace") + except OSError: + return False + return _fixed_partition(mount, text, Path("/sys")) + + +def _fixed_partition(mount: Path, mountinfo: str, sys_root: Path) -> bool: + """Whether mountinfo mounts a whole partition at mount whose disk sysfs + calls fixed: removable at 0, neither on USB nor an MMC card. False for + a folder bound in place, which is no whole drive, and for WSL's + /mnt/d, a Windows drive Linux cannot classify.""" + for line in mountinfo.splitlines(): + fields = line.split(" ") + if len(fields) < 5: + continue + point = re.sub(r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), fields[4]) + if point != str(mount): + continue + # Field 4 is the folder of the filesystem mounted there: "/" for a + # whole partition, a folder for a bind mount (proc(5)). + device = sys_root / "dev" / "block" / fields[2] + if fields[3] != "/" or not _exists(device): + return False + try: + real = device.resolve() + disk = real.parent if _exists(real / "partition") else real + if "/usb" in real.as_posix() or disk.name.startswith("mmcblk"): + return False + return (disk / "removable").read_text(encoding="ascii").strip() == "0" + except OSError: + return False + 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 _unnamed_candidates() -> list[Path]: + """The mounted drives no file names, no SHARE either, holding a bios + folder: a handheld's card, or any drive someone keeps an emulation + library on.""" + return [mount for mount in _mounted_cards() + if card_system(mount) is None and not share_doubt(mount)[0] + and _is_dir(mount / "bios")] def unnamed_cards() -> list[Path]: - """The mounted drives no file names whose root holds a bios folder and - console folders: ArkOS's EASYROMS, AmberELEC's GAMES. Which system runs - them cannot be told (their own files are on a part a computer does not - open), 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).""" - cards = [] - for mount in _mounted_cards(): - if (card_system(mount) is not None or share_doubt(mount)[0] - or not _is_dir(mount / "bios")): - continue - if any(name.casefold() in _CONSOLE_FOLDERS and _is_dir(mount / name) - for name in _listed(mount)): - cards.append(mount) - return cards + """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 + 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).""" + return [mount for mount in _unnamed_candidates() + if _games_part_system(mount) and not _drive_not_removable(mount)] + + +def unproven_drives() -> list[Path]: + """The drives with a bios folder nothing shows to be a handheld's card: + offered at the folder question, never its default.""" + cards = unnamed_cards() + return [mount for mount in _unnamed_candidates() if mount not in cards] + + +def unproven_words() -> str: + """The plain note on those drives, "" for none.""" + 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.") def unnamed_card_words(cards: list[Path], tail: str = ".") -> str: - """What the first screen and line mode say of those cards.""" + """What the first screen and line mode say of those cards: one card, + its system, two cards, their systems (code_audit_22 P3-2).""" names = ", ".join(_mount_name(mount) for mount in cards) - what = "A card is" if len(cards) == 1 else "Cards are" - return f"{what} in the reader ({names}): choose its system{tail}" + if len(cards) == 1: + return f"A card is in the reader ({names}): choose its system{tail}" + return f"Cards are in the reader ({names}): choose their systems{tail}" def say_unnamed_cards() -> None: @@ -7351,8 +7496,10 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, print(f" {share_words(plat)}") elif cards: default = cards[0] / "bios" - print(f" The card in the reader keeps them in its bios folder: " - f"{shown_path(default)}.") + # 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)}.") elif found is not None: print(f" {name} reads its BIOS files from: {shown_path(found)}.") elif default is not None: @@ -7381,6 +7528,9 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, left_out = "" if shared else drives_left_out(plat) if left_out: print(f" {left_out}") + unproven = "" if shared else unproven_words() + if unproven: + print(f" {unproven}") numbers = "" if not targets else (" or 1" if len(targets) == 1 else f" or 1 to {len(targets)}") why = ("" if default is not None or shared @@ -7642,37 +7792,70 @@ def _system_sized(files: list[dict]) -> str: return f"{_sized(own)}, and {_sized(shared)} shared with other systems" +# The shared part of a size in fewer words, where the whole one does not +# fit beside its system (_size_lines), said once under the list. +_SHARED_LONG = " shared with other systems" +_SHARED_SHORT = " shared" +SHARED_NOTE = "Shared: files other systems read too." + + +def _size_lines(size: str, room: int, words: int = 0) -> list[str]: + """A size in lines of at most room cells: whole, else its own part and + its shared part apart. words shortens the shared part: 0 says "and + 1689 files, 334.2 MB shared with other systems", 1 "... shared", 2 + "+1689 files, 334.2 MB shared". 26 of 66 sizes were folded by an + 80-column terminal, and a row that fitted on one line took two + (code_audit_22 P3-6).""" + if words: + size = size.replace(_SHARED_LONG, _SHARED_SHORT) + if words > 1: + size = size.replace(", and ", ", +") + if cell_width(size) <= room: + return [size] + 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, "") + + def _pick( title: str, rows: list[tuple[str, list[dict]]], ordered: bool = False, sizer: Callable[[list[dict]], str] = _sized, labels: dict | None = None, - note: str = "", + note: str = "", short: dict | None = None, ) -> list[str]: """Numbered list of names. An empty answer keeps every one. - sizer says what each name holds, labels how each one is shown, note - what the list needs said, under it. + sizer says what each name holds, labels how each one is shown, short + in fewer words where those take fewer lines (a system's title without + its id at 40 columns), note what the list needs said, under it. """ if not rows: return [] print(f"\n{title}") - width = len(str(len(rows))) shown = {name: screen_text((labels or {}).get(name, name)) for name, _files in rows} sizes = {name: sizer(files) for name, files in rows} - # The sizes in one column, as wide as the terminal leaves the names: the - # longest label set it for every row, and 53 of 66 rows were folded at - # 80 columns (code_audit_21 P3-9). A longer label takes its own line. - lead = 2 + width + 2 - room = (shutil.get_terminal_size((80, 24)).columns - 1 - lead - 2 - - max(cell_width(size) for size in sizes.values())) - name_width = min(max(cell_width(label) for label in shown.values()), max(16, room)) - for index, (name, _files) in enumerate(rows, 1): - if cell_width(shown[name]) > name_width: - print(f" {index:>{width}}) {shown[name]}") - print(f"{' ' * (lead + name_width)} {sizes[name]}") - continue - print(f" {index:>{width}}) {_pad(shown[name], name_width)} {sizes[name]}") - if note: - print(f"\n{note}") + # 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) + # 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). + briefs = [shown] + ([{name: screen_text(short.get(name, name)) for name, _files in rows}] + if short else []) + layouts = [(len(lines) * (10 if brief is shown else 11), words, lines) + for brief in briefs for words in range(3) + for lines in [_pick_lines(rows, brief, sizes, cols, words)]] + _cost, words, lines = min(layouts, key=lambda layout: layout[:2]) + for line in lines: + print(line) + notes = [*([SHARED_NOTE] if words and any(_SHARED_SHORT in size for size in sizes.values()) + else []), *([note] if note else [])] + 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, ""))) 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: ") @@ -7689,6 +7872,43 @@ def _pick( return [rows[number - 1][0] for number in chosen] +def _pick_lines(rows: list[tuple[str, list[dict]]], shown: dict, sizes: dict, + cols: int, words: int = 0) -> list[str]: + """The numbered rows of a line question, cols wide at most. The sizes + stand in one column placed for the labels, where every size of one + part fits beside the longest label that leaves it room: the column was + placed for the longest size, and every row but five took two lines + (code_audit_22 P3-6, tui_audit_15 P3-14). A row keeps one line where + its label and its size fit, a longer size goes on under its column, and + a label too long for the column takes its own line.""" + width = len(str(len(rows))) + lead = 2 + width + 2 + plain = [cell_width(size) for size in sizes.values() if "shared" not in size] + least = max([*plain, 16]) if plain else 16 + labels = sorted(cell_width(label) for label in shown.values()) + name_width = max([label for label in labels if lead + label + 2 + least <= cols] + or [0]) + column = lead + name_width + 2 + lines = [] + for index, (name, _files) in enumerate(rows, 1): + label, size = shown[name], sizes[name] + number = f" {index:>{width}}) " + if name_width and cell_width(label) <= name_width: + parts = _size_lines(size, cols - column, words) + 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 + 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, ""))] + 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 + + def listed_systems(files: list[dict]) -> list[str]: """The systems Choose systems lists for these files, in id order: each one a file belongs to, and the other systems for a file that names @@ -7712,7 +7932,7 @@ def choosable_systems(files: list[dict], omitted: list[dict], def emptied_words(names: list[str], game_data: bool) -> str: """What a choice that keeps no file says, on the plan and in line mode, - in place of "All files up to date": Sega Dreamcast holds only game data.""" + in place of "Everything is in place": Sega Dreamcast holds only game data.""" subject = (", ".join(names[:-1]) + " and " + names[-1]) if len(names) > 1 else ( names[0] if names else "This choice") verb = "hold" if len(names) > 1 else "holds" @@ -8035,9 +8255,14 @@ def _print_menu_head( print(f" BIOS folder: {shown_path(shown_bios_folder(bios_path, files))}") return # One total, data files in it, the kinds on the line under it adding up - # to it, as the screen's headline (code_audit_21 P3-8). - print(f"\n{name or display_name(plat)}: {_plural(counts.files + data, 'file')}, " - f"{format_size(counts.size + data_size)}" + named + ".") + # to it, as the screen's headline (code_audit_21 P3-8). With --from, + # what the folders hold first, as the screen's plan says it: nothing + # said how many were found before Enter (tui_audit_15 P3-8). + found = (found_head(shown, kept_trees, bios_path) + if LOCAL.folders and bios_path is not None else "") + print(f"\n{name or display_name(plat)}: " + (found or ( + f"{_plural(counts.files + data, 'file')}, " + f"{format_size(counts.size + data_size)}")) + named + ".") 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) @@ -8060,6 +8285,53 @@ def _print_menu_head( 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.""" + + def held(path: Path, size: object) -> bool: + try: + return _is_file(path) and path.stat().st_size == size + except OSError: + return False + + needed = [] + for entry in files: + try: + path = _entry_path(bios_path, entry) + except ValueError: + continue + if not held(path, entry.get("size")): + needed.append(entry) + copies = plan_local_copies(needed, bios_path, None, LOCAL.folders) + if not copies: + return "" + by_folder: dict[str, int] = {} + for copy in copies.values(): + 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) + 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)}" + for label, count in by_folder.items()) + 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}, {rest} to download ({format_size(_bytes(left) + data_bytes)})" + + def menu_choice( files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None, current: object, cut: bool, @@ -8108,7 +8380,8 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N else f"{title} {system}" systems = _pick("Systems:", [ (system, [f for f in files if _chosen(f, {system})]) for system in systems_listed - ], sizer=_system_sized, labels=labels, note=note) + ], sizer=_system_sized, labels=labels, note=note, + short={system: system_title(system, manifest) for system in systems_listed}) kept, kept_omitted = narrow(files, omitted, systems, [], []) cores: list[str] = [] @@ -8817,9 +9090,20 @@ def pack_lacks(entries: list[dict]) -> list[dict]: def pack_holds_trees(data_dirs: list[dict]) -> bool: """Whether the pack given to this run holds a file of any of these data - trees, which an install takes from it.""" - return LOCAL.pack is not None and any( - next(LOCAL.pack.tree(entry.get("dest", "")), None) is not None for entry in data_dirs) + trees, which an install takes from it. A file of the tree, by its path: + a member below the tree's folder was the test, and blueMSX's tree, + whose folder is the BIOS folder itself, held every BIOS file of any + pack. One holding none of the files offered and focused Install, and + its result counted 8252 failed beside 10888 missing (tui_audit_15 + P3-7).""" + if LOCAL.pack is None: + return False + names = LOCAL.pack.members + for entry in data_dirs: + lead = f"{str(entry.get('dest') or '').rstrip('/')}/" if entry.get("dest") else "" + if any(f"{lead}{rel}" in names for rel in entry.get("files") or {}): + return True + return False def pack_lacks_words(count: int, total: int) -> str: @@ -9835,7 +10119,7 @@ def check_lines( return rows for entry in mismatched: - lines += block("wrong hash", entry) + lines += block("wrong copy", entry) for entry in explained: lines += block("missing", entry) for entry in shown: @@ -9990,11 +10274,10 @@ def copy_plan_lines( elif key == "=": head = (f"Would copy {sized} the BIOS folder already holds at another " "place of the list:" if planned else - f"Copying {sized} the BIOS folder already holds at another place " - "of the list.") + f"{sized} the BIOS folder already holds at another place of the list:") else: head = (f"Would copy {sized} from {shown_path(key)}:" if planned else - f"Copying {sized} from {shown_path(key)}, each one checked by hash.") + f"{sized} found in {shown_path(key)}, each one checked by hash:") lines.append(f"\n {head}" if planned else f" {head}") for entry in chosen if every else chosen[:10]: copy = copies[id(entry)] @@ -10212,6 +10495,7 @@ def no_python_words(system: str) -> str: APP_OFFER = "Put RetroBIOS on this card (update from the console later)" +APP_OFFER_SHORT = "Put RetroBIOS on this card" class CardApp(NamedTuple): @@ -12712,8 +12996,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: before = len(files) files, omitted_files = narrow(files, omitted_files, *selection) unplaced = narrow([], unplaced, *selection)[1] - if len(files) != before: + if len(files) != before and not menu: # Enter for every system narrows nothing: "Narrowed 23 to 23". + # The menu counted the choice again before Enter. print(f" Narrowed {before} to {len(files)} files") if (cut and cut[0]) or args.no_game_data: # Left out at g or by --no-game-data, from what the choice kept: @@ -12757,19 +13042,21 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: head_data = sum(len(entry.get("files") or {}) for entry in data_dirs) head_bytes = sum(meta.get("size", 0) for entry in data_dirs for meta in (entry.get("files") or {}).values()) - print(f" {_plural(run_counts.files + head_data, 'file')} " - f"({format_size(run_counts.size + head_bytes)})") - categorized_list = bool(manifest.get("categorized")) - kinds = run_counts if categorized_list else tally( - {"size": entry.get("size")} for entry in files) - parts = parts_words(kinds, head_data, head_bytes, game_data_cut) - if parts: - print(f" {parts}.") - defined = kinds_definition(categorized_list and bool(run_counts.games or game_data_cut), - bool(head_data)) - if defined and not menu: - # The menu head defined them already. - print(f" {defined}") + if not menu: + # An answered menu said these lines right above: the run head + # repeated them under it (tui_audit_15 P3-9). + print(f" {_plural(run_counts.files + head_data, 'file')} " + f"({format_size(run_counts.size + head_bytes)})") + categorized_list = bool(manifest.get("categorized")) + kinds = run_counts if categorized_list else tally( + {"size": entry.get("size")} for entry in files) + parts = parts_words(kinds, head_data, head_bytes, game_data_cut) + if parts: + print(f" {parts}.") + defined = kinds_definition( + categorized_list and bool(run_counts.games or game_data_cut), bool(head_data)) + if defined: + print(f" {defined}") # A choice that keeps no file says so where "All files up to date" # was said of it (tui_audit_13 P3-11). empty_choice = "" if run_counts.files or not (selection or game_data_cut) else ( @@ -12827,8 +13114,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # P3-10). The data files the head counts are among them, as the # screen counts them (tui_audit_14 P3-7). wrong = len(mismatched) - trees_missing = sum(len(data_directory_state(entry, bios_path)[1]) - for entry in data_dirs) + # Each data tree read and hashed once in the run: the count here, + # 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] + 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, " @@ -12864,9 +13154,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: accepted=core_choices, ) - data_states = [ - (entry, data_directory_state(entry, bios_path)) for entry in data_dirs - ] if args.check or EVENTS.on else [] if EVENTS.on: _emit_check( plat_name, manifest, bios_path, files, up_to_date, mismatched, @@ -12909,7 +13196,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: len(lacking))) check_pending += len(to_download) else: - print("\n All files up to date.") + print(f"\n {EVERYTHING_IN_PLACE}") absent = _absent_required(omitted_files, bios_path) # The collection cannot supply these, so they leave the exit # code alone, but a user asking why a game will not start needs @@ -12928,7 +13215,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if args.dry_run: files_pending += len(to_download) - trees = [(entry, data_directory_state(entry, bios_path)[1]) for entry in data_dirs] + trees = [(entry, state[1]) for entry, state in data_states] # A data file in place with other contents is kept like a BIOS file. kept = plan_kept(mismatched + [ {"dest": f"{entry['dest']}/{rel}" if entry.get("dest") else rel} @@ -12983,6 +13270,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: fetch_size = _bytes([f for f in fetched if id(f) not in lacking]) if not to_download: total_up_to_date += len(up_to_date) + if _offline_only(): + for _entry, (_root, needed, present) in data_states: + total_up_to_date += present + total_offline += len(needed) + files_pending += len(needed) continue if ( interactive @@ -13097,7 +13389,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: total_interrupted = True break else: - print("\n All files up to date.") + print(f"\n {EVERYTHING_IN_PLACE}") total_up_to_date += len(up_to_date) @@ -13106,15 +13398,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # checked against the hash the list records. if data_dirs and not total_interrupted: print("\nData directories:") - for entry in data_dirs: - counts = install_data_directory(entry, bios_path) + for entry, state in data_states: + counts = install_data_directory(entry, bios_path, state) total_downloaded += counts["installed"] data_landed += counts["installed"] total_up_to_date += counts["present"] if isinstance(counts["error"], OfflineReason): # Left out as the BIOS files above are: counted missing, # not as one failure per tree. - print(f" {entry['ref']}: {counts['present']} already up to date, " + print(f" {entry['ref']}: {counts['present']} already in place, " f"{counts['absent']} need the internet ({offline_named()})") total_offline += counts["absent"] files_pending += counts["absent"] @@ -13124,7 +13416,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # absent: "no longer in the source (the pack has them)" # said so of 218 files no source was asked for. line = f" {entry['ref']}: {counts['installed']} installed, " \ - f"{counts['present']} already up to date" + f"{counts['present']} already in place" if counts["changed"] or counts["absent"]: # From a pack, the pack is the source that lacks them. line += (f", {counts['changed'] + counts['absent']} not in " @@ -13613,19 +13905,20 @@ _SHELL_WORD = re.compile(r"""(?:'[^']*'|"[^"]*"|[^\s'"]+|['"])+""") def _path_tail(text: str, width: int) -> str: """A path cut to its last folders that fit after ".../", as a person - reads it on a narrow screen (".../retroarch/system"), a word that is no - path cut in its middle.""" + reads it on a narrow screen (".../retroarch/system"). Its last name is + kept whole however long: cut in its middle, RetroArch_BIOS_Pack.zip + read "Pack.zip" (tui_audit_15 P3-2).""" if cell_width(text) <= width: return text separator = "\\" if "\\" in text and "/" not in text else "/" parts = text.rstrip(separator).split(separator) - tail = "" - for part in reversed(parts): - longer = f"{part}{separator}{tail}" if tail else part + tail = parts[-1] + for part in reversed(parts[:-1]): + longer = f"{part}{separator}{tail}" if cell_width(f"...{separator}{longer}") > width: break tail = longer - return f"...{separator}{tail}" if tail else middle(text, width) + return f"...{separator}{tail}" def _command_groups(text: str) -> list[str]: @@ -13643,13 +13936,16 @@ def _command_groups(text: str) -> list[str]: return groups -def _continuation() -> str: +def _continuation(powershell: bool | None = None) -> str: """The character that ends a line of a command the shell reads on: a - backtick in PowerShell, a backslash in sh.""" - return "`" if os.environ.get("RETROBIOS_BOOTSTRAP") == "ps1" or os.name == "nt" else "\\" + backtick in PowerShell, a backslash in sh. Asked as the command's words + ask it: with RETROBIOS_OS=windows elsewhere one command had + PowerShell's quotes and sh's line ends (code_audit_22 P3-5).""" + return "`" if (_INVOCATION.powershell() if powershell is None else powershell) else "\\" -def _command_wrap(text: str, width: int, indent: str = " ") -> list[str]: +def _command_wrap(text: str, width: int, indent: str = " ", + powershell: bool | None = None) -> list[str]: """A command too wide for width, broken between its shell words, each line but the last ending with the continuation the shell accepts, so a copy of the lines runs as the command does. A line one group wide that @@ -13658,7 +13954,7 @@ def _command_wrap(text: str, width: int, indent: str = " ") -> list[str]: terminal, it read as broken inside a word (tui_audit_14 P3-8). A web address is never cut.""" lead = text[:len(text) - len(text.lstrip(" "))] - tail = " " + _continuation() + tail = " " + _continuation(powershell) groups = _command_groups(text) lines: list[str] = [] line = "" @@ -13671,6 +13967,7 @@ def _command_wrap(text: str, width: int, indent: str = " ") -> list[str]: prefix = fewer return prefix + done + end + fitted: list[str] = [] for number, group in enumerate(groups): end = 0 if number == len(groups) - 1 else len(tail) room = width - cell_width(lead + indent) - end @@ -13682,8 +13979,17 @@ def _command_wrap(text: str, width: int, indent: str = " ") -> list[str]: keep = cell_width(option) + 1 if option else 0 quote = value[0] if value[:1] in "'\"" and value[-1:] == value[:1] else "" bare = value[1:-1] if quote else value - group = (f"{option} " if option else "") + quote + _path_tail( - bare, max(8, room - keep - 2 * len(quote))) + quote + short = quote + _path_tail(bare, max(8, room - keep - 2 * len(quote))) + quote + if option and cell_width(short) > room - keep: + # The option and the name do not fit together: the value + # on a line of its own, its name whole. + fitted += [option, quote + _path_tail(bare, max(8, room - 2 * len(quote))) + + quote] + continue + group = (f"{option} " if option else "") + short + fitted.append(group) + for number, group in enumerate(fitted): + end = 0 if number == len(fitted) - 1 else len(tail) prefix = lead + indent if lines else lead longer = f"{line} {group}" if line else group if not line or cell_width(prefix + longer + (tail if end else "")) <= width: @@ -13699,7 +14005,8 @@ def _command_wrap(text: str, width: int, indent: str = " ") -> list[str]: TUI_SHORTENED = "Shortened to fit this window." -def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[str]: +def _wrap(text: str, width: int, indent: str = " ", whole: str = "", + powershell: bool | None = None) -> list[str]: """Lines of at most width cells, the ones after the first indented. whole keeps every word whole, a longer one alone on a line wider than @@ -13710,11 +14017,16 @@ def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[st if cell_width(text) <= width: return [text] if whole == "command": - return _command_wrap(text, width, indent) + return _command_wrap(text, width, indent, powershell) lead = text[:len(text) - len(text.lstrip(" "))] lines: list[str] = [] line = "" words = _glued_words(text) + if len(words) == 1 and _is_path_word(text.strip()): + # A folder on a line of its own goes on at its own indent: the + # rest of a save folder sat two cells under its start, the BIOS + # folder's at its label's (tui_audit_15 P3-12). + indent = "" def close(done: str) -> str: # A line one word wide that fits without the indent loses it: one @@ -13735,8 +14047,12 @@ def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[st # command's words stay whole, copied as one. room = width - len(lead + indent) # A word that fits on a line of its own stays whole there (close). + # A path only where it fits at the indent its line has: dropped to + # fit, a folder stood at one indent and its neighbour at another + # (tui_audit_15 P3-12). + alone = lead + indent if (lines or line) and _is_path_word(word) else lead pieces = ([word] if whole == "command" or (whole and not _is_path_word(word)) - or cell_width(lead + word) <= width else _split_word(word, room)) + or cell_width(alone + word) <= width else _split_word(word, room)) if len(pieces) > 1 and _is_path_word(word): # The path starts on the line it follows, broken at a separator # that fits there: "BIOS folder:" stood alone above its folder. @@ -13777,6 +14093,9 @@ class TuiLine(str): # A line of a command, however it was wrapped: the mouse is left to the # terminal on a frame showing one (tui_wants_mouse). command = False + # Whether the command it holds is typed into PowerShell, from the + # Invocation that made it: its line ends follow its words. + powershell: bool | None = None def _styled(text: str, style: str = "") -> TuiLine: @@ -13785,6 +14104,13 @@ def _styled(text: str, style: str = "") -> TuiLine: return line +def _command_line(text: str, hint: Invocation) -> TuiLine: + """A line holding a command hint made, wrapped with hint's shell.""" + line = _styled(text, "command") + line.powershell = hint.powershell() + return line + + def _bar_keys() -> set[str]: """Every name a key or a button takes on the bar, on every route.""" keys = {"Enter", "Esc", "Space", "Start", "Select", "Ctrl-C", "?", "/", "X/Space/tap", @@ -13805,7 +14131,7 @@ def _bar_spans(text: str, first: str) -> list[tuple[int, int, str]]: spans = [] for number, entry in enumerate(re.finditer(r"\S+(?: \S+)*", text)): key = entry.group().split(" ", 1)[0] - if entry.group().endswith(TUI_ARMED): + if entry.group().endswith(TUI_ARMED_SHORT): # Armed by a first tap: the whole entry stands out, a plain # ": tap again" looked like no change at all (tui_audit_14 P3-2). spans.append((entry.start(), entry.end(), first)) @@ -13988,6 +14314,10 @@ class TuiState: # Choose systems asked on a plan --system narrowed: the systems are # opened once the check of every system is in. self.widen: bool = False + # The plan row a recount started from, focused again once it is in: + # the plan came back on Install, and a second Enter to undo the + # Game data change opened Confirm (tui_audit_15 P3-4). + self.refocus: str = "" # The --system plan Choose systems widened, by _CHECK_FIELDS. self.widened_from: dict | None = None # (files read, files to read) while the check hashes what is in place. @@ -14032,6 +14362,8 @@ class TuiState: # release than the one this machine runs, said on the plan and the # result as line mode says it. self.version: str = "" + # The note in one line, for a plan with no room for its sentences. + self.version_short: str = "" # The same of the confirm or preview run, and of the install: a # choice of systems can leave the save file out, or keep it. self.dry_saves: dict | None = None @@ -14873,8 +15205,12 @@ def tui_rows(state: TuiState) -> list[TuiRow]: if screen == "quit": return [TuiRow("quit", "Quit"), TuiRow("stay", "Stay")] if screen == "result" and _app_offer(state): - # Exit first: the app is put only when it is chosen. - return [TuiRow("exit", "Exit"), TuiRow("addapp", APP_OFFER)] + # Exit first: the app is put only when it is chosen. Its short + # form where the whole one does not fit: cut inside "from" at 40 + # columns (tui_audit_15 P3-3). + width = min(state.cols or TUI_MAX_WIDTH, TUI_MAX_WIDTH) - 2 + offer = APP_OFFER if cell_width(f"> {APP_OFFER}") <= width else APP_OFFER_SHORT + return [TuiRow("exit", "Exit"), TuiRow("addapp", offer)] return [] @@ -14907,7 +15243,7 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: if (state.emptied and state.systems and not state.ticked) or _choice_in_place(state): # The choice emptied by an option: Choose systems first, never an # Install that would refuse or take the whole list. - rows = [TuiRow("systems", "Choose systems")] + rows = [TuiRow("systems", TUI_SYSTEMS_ROW)] game_data = _game_data_row(state) return rows + ([game_data] if game_data is not None else []) + [ TuiRow("options", "Options"), TuiRow("quit", "Quit")] @@ -14918,7 +15254,8 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: lacking = set(_tui_pack_lacks(state, [event["dest"] for event in state.files])) size = check.get("bytes_needed", 0) - sum(event["size"] for event in state.files if event["dest"] in lacking) - label = f"Install {format_size(size)}" if size >= CONFIRM_FROM_BYTES else "Install" + label = (f"{TUI_INSTALL_ROW} {format_size(size)}" if size >= CONFIRM_FROM_BYTES + else TUI_INSTALL_ROW) rows = [TuiRow("install", label)] if _offline_only() and not _plan_copies(state, check): # Nothing here to copy, and the network refused: Install would @@ -14954,7 +15291,7 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: rows.append(copy) if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and ( len(state.systems) > 1 or state.cli_systems): - rows.append(TuiRow("systems", "Choose systems")) + rows.append(TuiRow("systems", TUI_SYSTEMS_ROW)) game_data = _game_data_row(state) if game_data is not None: rows.append(game_data) @@ -15205,6 +15542,19 @@ def _save_folder_words(state: TuiState) -> str: return "not known" +def _focus_plan(state: TuiState, action: str) -> None: + """The plan's focus on the row doing action. A plan that keeps no file, + a choice emptied or already in place, opens on Choose systems: the + focus kept its index, and Enter opened Options (tui_audit_15 P3-5).""" + actions = [row.action for row in tui_rows(state)] + keeps_none = _choice_in_place(state) or (state.emptied and state.systems + and not state.ticked) + if action in actions: + state.focus["plan"] = actions.index(action) + elif keeps_none and "systems" in actions: + state.focus["plan"] = actions.index("systems") + + def _push(state: TuiState, screen: str, focus: int = 0) -> list[tuple]: state.stack.append(screen) state.focus[screen] = focus @@ -15245,10 +15595,7 @@ def _back(state: TuiState) -> list[tuple]: if chose and state.screen == "plan": # The systems chosen, the next press is Install: Back left the focus # on Choose systems. - rows = tui_rows(state) - state.focus["plan"] = next((index for index, row in enumerate(rows) - if row.action == "install"), - state.focus.get("plan", 0)) + _focus_plan(state, "install") if state.screen == "checking" and not state.running: # Stay after Esc on a check started from the command line: a # Checking screen nothing feeds counted seconds forever. @@ -15323,7 +15670,7 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: state.choices = None state.found, state.scanned, state.unreadable = [], [], [] state.check_summary = state.saves = None - state.version = "" + state.version = state.version_short = "" state.checked_flags = tui_flags(state) elif run in ("confirm", "preview"): state.dry, state.dry_files, state.dry_summary = None, [], None @@ -15340,6 +15687,7 @@ def _start_check(state: TuiState) -> list[tuple]: """Check the chosen folder, after fetching the file list once, the systems ticked kept (_keep_choice).""" _keep_choice(state) + state.refocus = "" if "plan" in state.stack: del state.stack[state.stack.index("plan"):] while state.stack and state.stack[-1] in ("checking", "error", "create"): @@ -15950,7 +16298,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: # the systems ticked kept: the check set them back to every # system, and the install wrote all eight (tui_audit_13 P1-1). state.no_game_data = not state.no_game_data - return _start_check(state) + effects = _start_check(state) + state.refocus = "gamedata" + return effects if action == "systems" and state.cli_systems: # The check read only what --system named: it is read again for # every system, those named ticked. Its exact plan is kept, to @@ -16932,7 +17282,9 @@ def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: _tick(state, row, None) return [] elif (max(0, min(state.focus.get(screen, 0), len(rows) - 1)) == index - and state.tapped == seen): + and (state.tapped == seen or _armed(state) is not None)): + # A second tap, on the row or on the bar entry that armed it. + state.tapped = None return tui_update(state, TuiKey("enter")) state.status = state.status_more = "" state.focus[screen] = index @@ -16966,7 +17318,8 @@ def _bar_commits(state: TuiState, key: TuiKey) -> bool: def _bar_tap(state: TuiState, key: TuiKey) -> list[tuple]: """A tap on a bar entry. One that writes files (_bar_commits) is drawn - armed by the first tap and pressed by the second on the same screen.""" + armed by the first tap and pressed by the second on the same screen, + on the bar or on the row it acts on.""" if key.name == "ctrl-c": if state.run == "install" and state.running: return tui_interrupt(state) @@ -16975,28 +17328,81 @@ def _bar_tap(state: TuiState, key: TuiKey) -> list[tuple]: return tui_quit(state) if not _bar_commits(state, key): return tui_update(state, key) - armed = ("bar", state.screen, len(state.stack), key) - if state.tapped != armed: - state.tapped = armed + if _armed(state) != key: + state.tapped = ("bar", state.screen, len(state.stack), key) return [] state.tapped = None return tui_update(state, key) -def _armed_hint(state: TuiState, parts: tuple) -> tuple: - """The bar with the entry a first tap armed saying that a second one - presses it.""" +def _tapped_row(state: TuiState) -> tuple: + """What a tap on the row in focus records (tui_mouse).""" + rows = tui_rows(state) + if not rows: + return () + row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] + return (state.screen, len(state.stack), row.action, row.value) + + +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 + on the focused Install row was counted and drawn nowhere + (tui_audit_15 P3-1).""" tapped = state.tapped - if (state.help or not tapped or tapped[0] != "bar" or tapped[1] != state.screen - or tapped[2] != len(state.stack) or not _bar_commits(state, tapped[3])): + if state.help or not tapped: + return None + if tapped[0] == "bar": + armed = (tapped[1] == state.screen and tapped[2] == len(state.stack) + 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 + + +def _armed_hint(state: TuiState, parts: tuple, width: int) -> tuple: + """The bar with the entry a first tap armed saying that a second one + presses it, in the longest words the bar keeps whole: at 40 columns + "click or tap again" was cut inside its last word, and the bar's B + went with it (tui_audit_15 P3-3).""" + key = _armed(state) + if key is None: return parts - return tuple(f"{part}: {TUI_ARMED}" if _bar_click(state, part.split(" ", 1)[0]) == tapped[3] - and " " in part else part for part in parts) + at = next((index for index, part in enumerate(parts) if " " in part + and _bar_click(state, part.split(" ", 1)[0]) == key), None) + if at is None: + return parts + part = parts[at] + verb = part.split(" ", 1)[1] + forms = (f"{part}: {TUI_ARMED}", f"{part}: {TUI_ARMED_SHORT}", + f"{part.split(' ', 1)[0]} {' '.join(verb.split()[:3])}: {TUI_ARMED_SHORT}") + # Every entry kept first, then the armed one whole. + for whole in (True, False): + for form in forms: + armed = (*parts[:at], form, *parts[at + 1:]) + drawn = _hint(width, *armed) + if form.split(" ", 1)[1] in drawn and ( + not whole or len(drawn.split(" ")) == len(armed)): + return armed + return (*parts[:at], forms[-1], *parts[at + 1:]) + + +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.""" + 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}"): + if cell_width(f"> {label}") <= width: + return row._replace(label=label) + return row._replace(label=f"{verb}: {TUI_ARMED_SHORT}") # What an armed bar entry adds to its name, drawn in the accent: a mouse -# clicks and a finger taps, and the screen cannot tell which it was. +# clicks and a finger taps, and the screen cannot tell which it was. The +# short form where the long one does not fit. TUI_ARMED = "click or tap again" +TUI_ARMED_SHORT = "tap again" def _wheel(state: TuiState, hits: TuiHits, step: int) -> list[tuple]: @@ -17098,6 +17504,9 @@ def _take_event(state: TuiState, event: dict) -> None: state.choices = list(event["systems"]) elif kind == "version" and state.run == "check": state.version = str(event["message"]) + state.version_short = (f"List for {display_name(state.platform, state.manifest)} " + f"{event['list_version']}, this is " + f"{event['installed_version']}.") elif kind == "saves" and state.run == "check": state.saves = event elif kind == "saves" and state.run in ("confirm", "preview"): @@ -17193,6 +17602,8 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: state.keep_ticked = None state.facts = _tui_folder_facts(state) _replace(state, "plan") + refocus, state.refocus = state.refocus, "" + _focus_plan(state, refocus) if state.widen: state.widen = False plan = tui_rows(state) @@ -17414,7 +17825,7 @@ def _text_lines(items: list, width: int, whole: bool = False) -> list[TuiLine]: folder = str(item).lstrip(" ").startswith(_FOLDER_LABEL) # A folder on its own line wraps at its own indent. pieces = _wrap(str(item), width, "" if style == "path" and str(item).startswith(" ") - else " ", whole=kind) + else " ", whole=kind, powershell=getattr(item, "powershell", None)) if command and "..." in "".join(pieces) and "..." not in str(item): # Its path cut to fit: said under it, never as part of it. pieces.append(TUI_SHORTENED) @@ -17478,7 +17889,9 @@ 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 - row_lines = [_row_line(row, index == focus, width) for index, row in enumerate(rows)] + armed = rows and _armed(state) is not None and rows[focus].action in ("install", "addapp") + 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)): line.mark = ("row", index, (row.action, row.value)) # What L1/R1 move by: the rows drawn. Six fewer than the window's height @@ -17866,7 +18279,7 @@ def _commands(state: TuiState, lines: list[str]) -> list[str]: between shell words, and the mouse left to the terminal over them.""" hint = tui_hint(state) launchers = (hint.launcher(), hint.launcher(True)) - return [_styled(line, "command") if any(launcher in line for launcher in launchers) + return [_command_line(line, hint) if any(launcher in line for launcher in launchers) else line for line in lines] @@ -18012,7 +18425,11 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: before = [lead, str(state.folder_default)] if (state.folder_kind == "usual" and tui_rows(state) and tui_rows(state)[0].value in {mount / "bios" for mount in state.unnamed}): - before = ["The card in the reader keeps them in its bios folder, first below. " + 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)] elif state.folder_kind == "closed": title = "Which folder?" @@ -18023,6 +18440,8 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: 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")) @@ -18215,7 +18634,14 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: # Some were found in a folder: the kinds split those the headline # says need the internet, named as such (tui_audit_14 P3-7). took = {event["dest"] for events in found.values() for event in events} - kinds_of = [event for event in kinds_of if event["dest"] not in took] + rest = [event for event in kinds_of if event["dest"] not in took] + if rest or data: + kinds_of = rest + else: + # Every file found: the kinds are those of the files copied, + # and game data is defined as the pack's plan defines it. The + # --from plan said neither (tui_audit_15 P3-11). + found = {} parts, defined = _kinds_lines(state, kinds_of, data, data_size, whole=bool(found)) if _choice_in_place(state) or (state.emptied and state.systems and not state.ticked): # Nothing of the choice is left to install: a split of the whole @@ -18281,11 +18707,13 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: def _plan_body(state: TuiState, width: int, height: int, lines: list, - rows: list[TuiRow]) -> list[TuiLine]: + rows: list[TuiRow], short: bool = False) -> list[TuiLine]: """The plan's text and rows. Where both do not fit, the headline, the whole BIOS folder and the version note stay and the rows scroll under them: at 40x12 the plan named ~/.var/app/ as the folder, and dropped the - note that the list is for another version (tui_audit_14 P3-6).""" + note that the list is for another version (tui_audit_14 P3-6). With + no room for its sentences the note is said in one line (short): at + 40x12 the RetroBat note was still dropped (tui_audit_15 P3-13).""" body = _compose(state, width, height, lines, rows) at = next((index for index, line in enumerate(lines) if str(line).startswith(_FOLDER_LABEL.rstrip())), None) @@ -18296,13 +18724,19 @@ def _plan_body(state: TuiState, width: int, height: int, lines: list, while end < len(lines) and getattr(lines[end], "style", "") == "folder": end += 1 kept += lines[at:end] - kept += [line for line in lines if state.version and str(line) == state.version] + kept += [line for line in lines if state.version + and str(line) in (state.version, state.version_short)] top = _text_lines(kept, width) drawn = [str(line).strip() for line in body] # Three lines at least for the rows: two of them and where the list is, # the blank line above them given up first. - if (all(str(line).strip() in drawn for line in top) - or len(top) + min(len(rows), 3) > height): + if all(str(line).strip() in drawn for line in top): + return body + if len(top) + min(len(rows), 3) > height: + if not short and state.version_short and state.version in map(str, lines): + return _plan_body(state, width, height, [ + _styled(state.version_short) if str(line) == state.version else line + for line in lines], rows, True) return body gap = [_styled("")] if len(top) + 1 + min(len(rows), 3) <= height else [] return top + gap + _compose(state, width, height - len(top) - len(gap), [], rows) @@ -18479,7 +18913,8 @@ def _pager_items(state: TuiState) -> list: if state.screen == "result": return tui_outcome(state)[1] if state.screen == "command": - return ["The same choice without questions:", "", _styled(tui_command(state), "command")] + return ["The same choice without questions:", "", + _command_line(tui_command(state), tui_hint(state))] if state.running: items = ["Counting the files for these choices..."] elif state.dry is None: @@ -18767,7 +19202,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: head += f" {_plural(up_to_date, 'file')} already in place." else: title = "Done" - head = "Everything is in place." + head = EVERYTHING_IN_PLACE lines = [head] sources = _tui_sources(state, installed, len(failed) + left) if left and installed and sources: @@ -18864,8 +19299,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: lines.append(f"The files it replaced are kept in {kept['folder']}") if typed: # Broken between its words only, never inside one (_wrap). - lines.append(_styled(f"To put them back: {run.launcher()} --restore " - f"{run.word(str(kept['folder']))}", "command")) + lines.append(_command_line(f"To put them back: {run.launcher()} --restore " + f"{run.word(str(kept['folder']))}", run)) if summary and not summary.get("interrupted") and installed + up_to_date: lines += _unavailable_lines(state) for group in summary.get("left_out") or []: @@ -18873,7 +19308,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: lines += _unplaced_lines(summary, state) lines += _tui_app_lines(state) if typed: - lines += ["Same choice without questions:", _styled(f" {tui_command(state)}", "command")] + lines += ["Same choice without questions:", + _command_line(f" {tui_command(state)}", tui_hint(state))] if tui_keeps_log(state): lines.append(f"Details: {state.log}") if state.mouse and typed: @@ -18932,8 +19368,12 @@ def _view_result(state: TuiState, width: int, height: int) -> tuple: lines[0] = _styled(lines[0], "title") rows = tui_rows(state) if rows: + focused = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] + # Exit in focus: A says it, and B saying it again named one action + # twice (tui_audit_15 P3-3). return (title, _compose(state, width, height, lines, rows), - ("A/Enter Choose", "B/Esc Exit")) + ("A/Enter Choose",) if focused.action == "exit" + else ("A/Enter Choose", "B/Esc Exit")) if state.app_running: return (title, _pager(state, width, height, lines), ("Writing to the card...",)) return (title, _pager(state, width, height, lines), @@ -19044,7 +19484,8 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: keys.append(("Left/Right", "Change a value")) if screen in ("plan", "systems", "confirm"): keys.append((names["options"], "Options")) - back = "Quit" if len(state.stack) == 1 else "Back" + # The result's bar calls B Exit: the help called it Quit. + back = "Exit" if screen == "result" else "Quit" if len(state.stack) == 1 else "Back" keys += [(names["back"], back), ("q, Ctrl-C", "Quit"), *kill, *_route_extras(state), *_help_key(state)] return keys @@ -19187,7 +19628,7 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: # At 40 columns "/tap" pushed "? Help" off the bar: the help, which # says how a tap acts, keeps its entry (code_audit_20 P3-1). tapped = routed - hint = _armed_hint(state, tapped) + hint = _armed_hint(state, tapped, width) lines = [_styled(fit(f" RetroBIOS: {title}", cols), "head"), _styled("")] # A long line is drawn whole, the terminal folding it into the rows # after it (_text_lines): cut to the width, a copied path lost its end. @@ -20155,10 +20596,17 @@ def run_tui(args: argparse.Namespace) -> int: state.now = time.monotonic() lines = tui_view(state, cols, rows) # A click is read against the frame it was made on. + before = state.hits state.hits = tui_hits(state, lines) # Before the frame: a frame on screen takes clicks as it shows. terminal.set_mouse(tui_wants_mouse(state, lines)) terminal.draw(lines, cols) + if before is None or state.hits.shown != before.shown: + # A new screen's settle runs from when it is on the screen, + # after its layout and its write: the second click of a + # double click waited out a slow layout in the input and + # acted on the screen it had never seen (code_audit_22 P3-1). + state.hits = state.hits._replace(shown=time.monotonic()) for key in terminal.keys(TUI_FRAME): if key.name == "ctrl-z": # ISIG is off, so Ctrl-Z comes as a key: it stops what diff --git a/install.sh b/install.sh index cb52eff70..64784c6ca 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="67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7" +DEFAULT_INSTALL_SHA256="665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 4f0d7be40..89fb6a611 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": 939291, - "sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7", + "size": 961225, + "sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 992468, - "sha256": "06761ebb41ef22fa2c4b64c5e27fab5ed5508dbcd63c5a634527d03a7cdaa7d1" + "size": 1014402, + "sha256": "cdd06d24d30ad6b564d8a1595b906d50ce05d23093121cc7e5d91b5acd8e08ab" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 939291, - "sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7", + "size": 961225, + "sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 939291, - "sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7", + "size": 961225, + "sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511", "executable": false }, { diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py index ef443d2d2..803804ea3 100644 --- a/scripts/generate_installer_doc.py +++ b/scripts/generate_installer_doc.py @@ -188,6 +188,57 @@ def card_marker_rows() -> list[list[str]]: return rows +# The folder the line menu example names: a home that is no one's. +MENU_FOLDER = "/home/me/.config/retroarch/system" + + +def line_menu() -> str: + """The line menu of a RetroArch run, g then Enter pressed, as install.py + prints it for the RetroArch list (install/retroarch.json): the very + function line mode runs, its questions answered here. The page carried + a copy typed by hand, whose keys and counts drifted from the installer + (code_audit_22, owner's note on which-pack).""" + import contextlib # noqa: PLC0415 + import io # noqa: PLC0415 + import json # noqa: PLC0415 + import os # noqa: PLC0415 + from unittest import mock # noqa: PLC0415 + + manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8")) + files = [*manifest["files"], *(manifest.get("case_variants") or [])] + folder = Path(MENU_FOLDER) + out = io.StringIO() + answers = iter(["g", ""]) + + def answer(prompt: str) -> str: + given = next(answers) + print(f"{prompt}{given}".rstrip()) + return given + + invocation = _install.Invocation() + invocation.os_type = "linux" + environment = {key: value for key, value in os.environ.items() + if key not in (_install.OFFLINE_VARIABLE, "RETROBIOS_BOOTSTRAP")} + with mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(_install, "_INVOCATION", invocation), \ + mock.patch.object(_install, "_ask", answer), \ + mock.patch.object(_install, "folder_warnings", return_value=[]), \ + contextlib.redirect_stdout(out): + cut = [False] + + def command(choice: object) -> str: + invocation.chosen["retroarch"] = ( + (_install.chosen_flags(choice) if choice is not None else []) + + ([("--no-game-data", None)] if cut[0] else [])) + return invocation.command("retroarch", folder) + + _install._prompt_custom_selection( + "retroarch", files, manifest.get("omitted_files") or [], None, "RetroArch", + False, manifest.get("data_directories") or [], folder, + _install.records_selection(files), None, manifest, False, command, cut) + return "```\n" + out.getvalue().strip("\n") + "\n```" + + def render(page: str) -> str: """The page with each marked table written from install.py.""" writers = { @@ -198,6 +249,7 @@ def render(page: str) -> str: "route-keys": lambda _body: route_keys(), "card-markers": lambda _body: _table(["On the card", "System", "Files go to"], card_marker_rows()), + "line-menu": lambda _body: line_menu(), } seen = [] diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index 44bc9f558..cf3a1fea1 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -39,6 +39,18 @@ from siteguides import install_command from verify import verify_platform +def _installer_words() -> object: + """install.py read as a module, for the words its screen shows: the + README named a line menu the one-liner never opens.""" + import importlib.util # noqa: PLC0415 + + spec = importlib.util.spec_from_file_location( + "install", Path(__file__).resolve().parent.parent / "install.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + # The repository's profiles wherever the script is run from: a bare # "emulators" read the working directory, and a run elsewhere counted 0 cores. _EMULATORS_DIR = str(Path(__file__).resolve().parent.parent / "emulators") @@ -582,7 +594,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json" "", "## Download BIOS packs", "", - "A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file. At a terminal it offers to install everything or to choose systems, cores and regions, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.", + "A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **" + _installer_words().TUI_SYSTEMS_ROW + "**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.", "", "A pack over 2 GB comes in several parts (`Pack.part1of2.zip`," " `Pack.part2of2.zip`), and every part is needed. Each part is an" diff --git a/scripts/generate_site.py b/scripts/generate_site.py index f8578ed85..b94916ace 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -3934,11 +3934,22 @@ MISSING_IN_PLACE = "Reported missing anyway? `--check` names a wrong file." # page shows in full, so the setup page links it. SAFE_LINK = "[Is it safe?](../index.md#is-it-safe)" -# The pass line of "How to check". `--check` prints "All files up to date." -# only when no file is missing or wrong (install.py, the args.check branch -# after check_local). "0 wrong hash" alone is also what an empty folder -# prints: 0/14508 present (0 verified, 0 wrong hash). -CHECK_PASS_LINE = "All good when it says `All files up to date.`" +def first_screen_words() -> list[str]: + """What a beginner sees when the command runs, the screen's own words + read from install.py: the one-liner opens the full screen, and the page + 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."] + + +def check_pass_line() -> str: + """The pass line of "How to check": the words `--check` prints only when + no file is missing or wrong (install.py EVERYTHING_IN_PLACE), read from + the installer. Typed here, it kept "All files up to date." after the + installer said "Everything is in place." (tui_audit_15 P3-10).""" + return f"All good when it says `{_installer().EVERYTHING_IN_PLACE}`" # Under "From your computer": a device with no network installs from the ZIP # (`--from-pack`). The id is the one wiki/installer.md gives its section. @@ -4099,7 +4110,8 @@ def only_some_consoles(name: str, prefix: str = "") -> list[str]: """The way to a smaller download, in the words Get your files uses.""" return [ f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}" - "#only-some-consoles), or press **c** in the command.", + f"#only-some-consoles), or choose **{_installer().TUI_SYSTEMS_ROW}** in the " + "command.", "", ] @@ -4260,7 +4272,7 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: "Add `--check` to the command. It changes nothing.", "", *terminal_tabs(guide.run, check_args, steps=False), - CHECK_PASS_LINE, + check_pass_line(), "", _check_sentence(display, cov["mode"], cov["config"], no_list), "", @@ -11397,7 +11409,7 @@ def generate_which_pack() -> str: "## Only some consoles?", "", "Pick them here, and get the files and the command. Or run the command " - "below and press **c** when it asks.", + f"below and choose **{_installer().TUI_SYSTEMS_ROW}**.", "", "", "", @@ -11410,14 +11422,7 @@ def generate_which_pack() -> str: "On Batocera, Recalbox and other game systems, run it on the device. " "Your setup's page shows how.", "", - "The command asks what to install. Press **Enter** for everything.", - "", - "```text title=\"What it asks\" frame=\"terminal\"", - " Enter) install everything", - " i) only what the installed cores read", - " c) choose systems, cores and regions", - " q) quit", - "```", + *first_screen_words(), "", *trust_box(""), "**Check your folder later.** Add `--check`. It changes nothing.", diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 2a262004f..34303b8cc 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -109,6 +109,25 @@ class InstallerOutputExample(unittest.TestCase): r"files \(([\d.]+ [KMG]B)\)", menu.group(3)) self.assertEqual(games.groups(), split.groups()[2:]) + def test_the_samples_say_what_the_installer_prints_for_the_list(self): + """tui_audit_15 P3-15: both samples said 6.6 GB where the same list + prints 6.7 GB, the size updated by hand from its rounded parts. The + head is the installer's own tally of the list, its second spellings + and data files in it, as a run on Linux counts them.""" + import install + + text = _page("installer.md") + manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8")) + counts = install.tally([*manifest["files"], *(manifest.get("case_variants") or [])]) + trees = manifest.get("data_directories") or [] + data = sum(len(tree["files"]) for tree in trees) + data_bytes = sum(meta["size"] for tree in trees for meta in tree["files"].values()) + total = f"{counts.files + data} files" + size = install.format_size(counts.size + data_bytes) + parts = install.parts_words(counts, data, data_bytes, 0) + self.assertIn(f"\n {total} ({size})\n {parts}.\n", text) + self.assertIn(f"\nRetroArch: {total}, {size}.\n {parts}.\n", text) + def assertSplitAddsUp(self, text: str, count: str, size: str) -> None: """The run counted 8069 files where its menu counted 3259 BIOS files and 5089 game data files: the parts add up to the total they divide, diff --git a/tests/test_install.py b/tests/test_install.py index de1ec5fc7..af3648697 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3737,7 +3737,7 @@ class TheInstallerSaysWhatItFound(unittest.TestCase): (dest / "lynxboot.img").write_bytes(b"corrupt") proc = self._run("--dest", str(dest), "--system", "atari-lynx", "--check") self.assertEqual(proc.returncode, 1) - self.assertIn("wrong hash: lynxboot.img", proc.stdout) + self.assertIn("wrong copy: lynxboot.img", proc.stdout) self.assertIn("without --check", proc.stdout) def test_a_read_only_folder_is_said_once(self): @@ -4202,7 +4202,7 @@ class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun): "--check", "--no-input") self.assertEqual(proc.returncode, 1, proc.stderr) listing = proc.stdout.split("to download:\n", 1)[1].splitlines() - self.assertEqual(listing[0].strip(), "wrong hash: scph5501.bin") + self.assertEqual(listing[0].strip(), "wrong copy: scph5501.bin") def test_a_dry_run_counts_the_data_trees_and_ends_on_its_last_line(self): proc = _install_run(self.env, "--platform", "retroarch", "--dest", @@ -4441,7 +4441,7 @@ class StandaloneEmulatorsWithoutAPlatformAreServed(unittest.TestCase, _LoopbackR self.assertTrue(all(name.startswith("scph") for name in names), names) proc = _install_run(self.env, "--no-input", "--check") self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) - self.assertIn("All files up to date.", proc.stdout) + self.assertIn("Everything is in place.", proc.stdout) def test_no_emulator_folder_keeps_the_hint(self): self.duckstation.rmdir() diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index de86e7928..4a7306aa0 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -391,7 +391,7 @@ class ReviewCases(unittest.TestCase): out = buf.getvalue() self.assertNotIn("Done.", out) self.assertIn("12 files not downloaded", out) - self.assertIn("3 files already up to date", out) + self.assertIn("3 files already in place", out) def test_quiet_refuses_modes_it_would_silence(self): for mode in ("--check", "--dry-run", "--list-platforms", "--list-systems"): @@ -547,7 +547,7 @@ class AFolderThisUserCannotEnter(unittest.TestCase): os.chmod(locked, 0o755) self.assertNotIn("Traceback", proc.stderr) self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) - self.assertIn("wrong hash: pcsx/extra.bin", proc.stdout) + self.assertIn("wrong copy: pcsx/extra.bin", proc.stdout) self.assertIn("could not be read (Permission denied)", proc.stdout) self.assertIn("missing: scph5501.bin", proc.stdout) @@ -937,7 +937,7 @@ class TheEndOfARunPaintsItsStatusWords(unittest.TestCase): ("failure", "1 file failed"), ("failure", "failed:"))), ((2, 1, 0, 0, False), {}, (("success", "Done"), ("success", "2 files installed"), - ("success", "1 file already up to date"))), + ("success", "1 file already in place"))), ((1, 0, 0, 0, False), {"offline": 2}, (("warning", "Finished with files missing"), ("warning", "2 not fetched")))): with self.subTest(counts=counts): @@ -1159,10 +1159,14 @@ class NotFoundWithAClosedFolder(unittest.TestCase): asked = [] install._DETECTION_REFUSED[:] = [(home / ".config" / "EmuDeck", "Permission denied")] self.addCleanup(install._DETECTION_REFUSED.clear) + # The drives of the machine running the tests are not read, and the + # question is not printed into the test output. with mock.patch.dict(os.environ, {"HOME": str(home)}), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ mock.patch.object(install, "_choose_platform", return_value="retroarch"), \ mock.patch.object(install, "_ask", - lambda prompt: asked.append(prompt) or str(home / "b")): + lambda prompt: asked.append(prompt) or str(home / "b")), \ + contextlib.redirect_stdout(io.StringIO()): install._prompt_manual_platform("linux") self.assertEqual(len(asked), 1) self.assertNotIn("no default on this system", asked[0]) diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index 23285cb55..762d0180f 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -132,7 +132,7 @@ class CheckNamesWhyAFileIsRefused(Scratch): self.complete("scph5501.bin") self.place("scph5501.bin", JP) out = self.check() - self.assertIn("wrong hash: scph5501.bin", out) + self.assertIn("wrong copy: scph5501.bin", out) self.assertIn("another region: the contents of scph5500.bin (japan)", out) def test_another_revision_names_the_core_that_reads_it(self): @@ -663,7 +663,7 @@ class OfflineMakesNoRequest(Scratch): self.assertEqual((self.dest / "scph5501.bin").read_bytes(), US) self.assertFalse((self.dest / "gba_bios.bin").exists()) # Missing, never failed (code_audit_15 P3-1): no FAILED line per file. - self.assertIn("3 not fetched (--offline)", proc.stdout + proc.stderr) + self.assertIn("3 still missing", proc.stdout + proc.stderr) self.assertNotIn("FAILED", proc.stdout + proc.stderr) self.assertIn("without --offline", proc.stdout + proc.stderr) self.assertNotIn("trying again", proc.stderr) @@ -801,9 +801,11 @@ class OfflineLeavesFilesMissing(Scratch): sources="4 files copied from old, 14696 still missing.") self.assertEqual(code, 1) self.assertNotIn("Done", text) - self.assertIn("Finished with files missing. 4 files installed, 14696 not fetched " - "(--offline).", text) - self.assertIn("14696 still missing", text) + # One count, the sources line as the head, as the screen's result + # (tui_audit_15 P3-8). + self.assertIn("Finished with files missing. 4 files copied from old, 14696 still " + "missing.", text) + self.assertEqual(text.count("14696"), 1) def test_nothing_installed_is_code_2(self): code, text = self.outcome(offline=14700) @@ -843,7 +845,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 up to date, 1 need the internet (--offline)", + self.assertIn("bluemsx: 0 already in place, 1 need the internet (--offline)", proc.stdout) self.assertNotIn("could not read", proc.stderr) self.assertNotIn("no longer in the source", proc.stdout) @@ -867,8 +869,8 @@ class OneOfflineVocabulary(Scratch): "--from", str(self.old()), "--no-input") out = proc.stdout + proc.stderr self.assertEqual(proc.returncode, 1, out) - self.assertIn("Finished with files missing. 1 file installed, 3 not fetched " - "(--offline).", out) + self.assertIn(f"Finished with files missing. 1 file copied from {self.root / 'old'}, " + "3 still missing.", out) self.assertIn(f"They are not in {self.root / 'old'}", out) for said in ("Finished with errors", "files failed", "retry", "copy them from your old card"): @@ -961,7 +963,7 @@ class AuditFifteenOffline(Scratch): (1, 3, [])) self.assertNotIn("FAILED", proc.stderr) self.assertNotIn("downloading", proc.stderr) - self.assertIn("1 file installed, 3 not fetched (--offline)", proc.stderr) + self.assertIn(f"1 file copied from {old}, 3 still missing", proc.stderr) download = next(event for event in events if event["event"] == "download") self.assertEqual(download["files"], 1) # The screen reads that summary as missing files, not failures. diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 29d3e2ad7..db776c39e 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -1625,9 +1625,9 @@ class SomethingElseAtADestination(unittest.TestCase): proc = repo.run("--check") self.assertEqual(proc.returncode, 1, proc.stderr) self.assertNotIn("Traceback", proc.stderr) - self.assertIn(" wrong hash: scph5500.bin\n a folder sits where this " + self.assertIn(" wrong copy: scph5500.bin\n a folder sits where this " "file goes: move it away, then run again", proc.stdout) - self.assertIn(" wrong hash: scph5501.bin\n could not be read " + self.assertIn(" wrong copy: scph5501.bin\n could not be read " "(Permission denied)", proc.stdout) proc = repo.run("--yes") self.assertEqual(proc.returncode, 1, proc.stderr) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 58c61895f..999f5f3c2 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -5102,7 +5102,8 @@ class EndToEnd(unittest.TestCase): term.send("\r") term.wait("Enter) install everything") term.send("\r") - term.wait("installed") + # Installed by the first run, in place for the second. + term.wait("Done.") term.close() self.assertNotIn(b"\x1b", term.raw) @@ -6756,6 +6757,21 @@ class AuditSevenScreens(unittest.TestCase): self.assertIn("1 wrong copy: backed up, replaced.", view) +def games_part(card: Path) -> Path: + """An ArkOS EASYROMS (dArkOS game_systems.txt: themes, launchimages + with loading.jpg) or an AmberELEC GAMES (autostart.sh: gamedata, + homebrew with gamelist.xml), console folders beside them.""" + marks = (("gamedata/retroarch", "homebrew") if card.name.startswith("GAMES") + else ("themes/es-theme-nes-box", "launchimages")) + for folder in ("bios", "nes", "gba", "psx", *marks): + (card / folder).mkdir(parents=True) + if card.name.startswith("GAMES"): + (card / "homebrew" / "gamelist.xml").write_text("\n") + else: + (card / "launchimages" / "loading.jpg").write_bytes(b"\xff\xd8") + return card + + class _Decided(Exception): """Raised where line mode has chosen its platforms and folders.""" @@ -7550,7 +7566,9 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, contextlib.redirect_stdout(io.StringIO()), \ contextlib.redirect_stderr(io.StringIO()): install._install(args, real) - self.assertIn("Done. 2 files installed", real.getvalue()) + # Every file from one folder: one count, as the screen's result + # (tui_audit_15 P3-8). + self.assertIn(f"Done. 2 files copied from {backup}.", real.getvalue()) self.assertIn("Installed: 1 BIOS file and 1 game data file.", real.getvalue()) # 2026-10-27, CI run 40541886959: the run said 8069 files, the menu @@ -7578,8 +7596,10 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.assertIn("RetroArch: 4 files, 123 B.\n 1 BIOS file (3 B) and 3 game data files " "(120 B).", text) self.assertIn("g) Game data: kept (3 files, 120 B): leave it out", text) - self.assertIn(" 4 files (123 B)\n 1 BIOS file (3 B) and 3 game data files (120 B).", - text) + # Said once, by the menu: the run head repeated it right under it + # (tui_audit_15 P3-9). + after = text[text.index("q) quit"):text.index("Checking existing files")] + self.assertNotIn("4 files (123 B)", after) self.assertIn("0 in place, 4 missing, 0 wrong copies", text) self.assertIn("4 files (123 B)", text[text.index("Checking existing files"):]) @@ -8290,7 +8310,13 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, text, _asked = self.line_run(_args(**args), ["c", "", ""]) self.assertNotRegex(text, r"Narrowed (\d+) to \1 files") text, _asked = self.line_run(_args(**args), ["c", "1", ""]) - self.assertIn("Narrowed 4 to 3 files", text) + # The menu counted the choice again before Enter: said once. + self.assertIn("Counted again for your choices", text) + self.assertNotIn("Narrowed", text) + args = _args(manifest=self.counted_list(), dest=self.here, declared_only=False, + dry_run=True, no_input=True, system=["doom"]) + text, _asked = self.line_run(args, []) + self.assertRegex(text, r"Narrowed 4 to \d files") def test_the_dry_run_names_systems_by_their_titles(self): """tui_audit_12 P3-13: --dry-run listed microsoft-msx where the list @@ -10862,7 +10888,10 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase): path = "/home/moi/abcdefghijklmnopqrstuvw/.config/retroarch/system" width = len(path) + 1 lines = install._wrap(f"BIOS folder: {path}", width, whole="text") - self.assertEqual(lines, ["BIOS folder:", path]) + # Broken after a separator, the rest at the one indent of every + # folder line (tui_audit_15 P3-12): never inside a name. + self.assertEqual(lines, ["BIOS folder: /home/moi/abcdefghijklmnopqrstuvw/.config/", + " retroarch/system"]) # A command keeps the option with its value, the path whole where # the line holds them (tui_audit_14 P3-8). command = install._wrap(f" python3 install.py --dest {path}", width + 10, @@ -12248,12 +12277,10 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): self.assertIn("About 0 files, 0 B, for 1 of 3 systems, 3 need the internet", text) def easyroms(self, name: str = "EASYROMS") -> Path: - """An ArkOS games part as a computer mounts it: bios/ and console - folders, no file naming the system.""" - card = self.root / "media" / name - for folder in ("bios", "nes", "gba", "psx"): - (card / folder).mkdir(parents=True) - return card + """A handheld's games part as a computer mounts it, with the folders + its system writes there (install._GAMES_PART_MARKERS): 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 @@ -12337,6 +12364,7 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): class Empty: name = "RetroArch_BIOS_Pack.zip" + members: dict = {} @staticmethod def find(entry: dict) -> None: @@ -12557,5 +12585,710 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): self.assertIn(f"BIOS folder: {card / 'bios'}", text) + +class AuditTwentyTwoAndFifteen(unittest.TestCase): + """code_audit_22 and tui_audit_15: a drive is a handheld's card only by + the files its system writes, one settle from the frame shown, one + vocabulary and one count per run, and the focus after an action.""" + + setUp = LineModeAndScreenDecideAlike.setUp + line_run = LineModeAndScreenDecideAlike.line_run + listing = LineModeAndScreenDecideAlike.listing + entry = staticmethod(LineModeAndScreenDecideAlike.entry) + receive = staticmethod(AuditTwentyAndThirteen.receive) + game_manifest = AuditTwentyAndThirteen.game_manifest + game_events = AuditTwentyAndThirteen.game_events + game_plan = AuditTwentyAndThirteen.game_plan + game_listing = AuditTwentyOneAndFourteen.game_listing + screen = staticmethod(AuditTwentyOneAndFourteen.screen) + + def drive(self, name: str, folders: tuple) -> Path: + root = self.root / "media" / name + for folder in folders: + (root / folder).mkdir(parents=True) + return root + + def first_screen(self, mounts: list) -> install.TuiState: + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + 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.Path, "home", return_value=self.root / "home"): + state, _effects = install.tui_start(_args(), "linux") + return state + + def folder_screen(self, mounts: list) -> tuple[install.TuiState, str, list]: + """RetroArch chosen on the first screen: (state, Which folder? at + 80x40, its rows).""" + state = self.first_screen(mounts) + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "folder") + return state, self.screen(state, 80, 40), install.tui_rows(state) + + def test_a_data_drive_or_a_stick_is_no_card(self): + """code_audit_22 P2-1: a data drive holding bios/, snes/ and Music/ + was "a card in the reader", and three presses of Enter put the list + on it. A stick with bios/ alone is no card either: both are listed, + never under Enter, and a plain line says why.""" + data = self.drive("DATA", ("bios", "snes", "Music")) + stick = self.drive("STICK", ("bios",)) + mounts = [data, stick] + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + self.assertEqual(install.unnamed_cards(), []) + state = self.first_screen(mounts) + self.assertEqual(state.unnamed, []) + 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(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("Enter for the default above", asked[-2]) + self.assertNotIn(f"BIOS folder: {data / 'bios'}", text) + + 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.""" + arkos = games_part(self.root / "media" / "EASYROMS") + amber = games_part(self.root / "media" / "GAMES") + emuelec = self.drive("EEROMS", ("bios", "nes", "bezels")) + (emuelec / "bezels" / "default.cfg").write_text("") + 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], + ["ArkOS", "AmberELEC", "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(), []) + + def test_a_card_beside_a_data_drive_comes_first(self): + """code_audit_22 P2-1: the ArkOS card and the data drive mounted, + the data drive sorted first and took Enter. The card comes first in + both modes, the drive listed after it with its note.""" + data = self.drive("DATA", ("bios", "snes", "Music")) + card = games_part(self.root / "media" / "EASYROMS") + mounts = [data, card] + state = self.first_screen(mounts) + self.assertEqual(state.unnamed, [card]) + 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) + 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"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] + state = self.first_screen(mounts) + self.assertIn("Cards are in the reader (EASYROMS, GAMES): 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) + 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.assertNotIn("The card in the reader", text) + + def test_a_fixed_partition_is_told_from_a_card(self): + """code_audit_22 P2-1: every mount under /media and /mnt, and on + Windows every drive letter, was a card. A whole partition of a disk + sysfs calls fixed is not, a USB or MMC one is, a folder bound in + place says nothing, and Windows answers by GetDriveTypeW.""" + sys_root = self.root / "sys" + devices = sys_root / "devices" + for disk, part, removable in (("pci0/nvme0n1", "nvme0n1p2", "0"), + ("pci0/usb2/2-1/sdb", "sdb1", "0"), + ("mmc0/mmcblk0", "mmcblk0p1", "0"), + ("pci0/sdc", "sdc1", "1")): + (devices / disk / part).mkdir(parents=True) + (devices / disk / part / "partition").write_text("1\n") + (devices / disk / "removable").write_text(f"{removable}\n") + block = sys_root / "dev" / "block" + block.mkdir(parents=True) + for number, target in (("259:2", "pci0/nvme0n1/nvme0n1p2"), + ("8:17", "pci0/usb2/2-1/sdb/sdb1"), + ("179:1", "mmc0/mmcblk0/mmcblk0p1"), + ("8:33", "pci0/sdc/sdc1")): + (block / number).symlink_to(devices / target) + mountinfo = "\n".join([ + "40 1 259:2 / /media/me/DATA rw - ext4 /dev/nvme0n1p2 rw", + "41 1 8:17 / /media/me/EASYROMS rw - vfat /dev/sdb1 rw", + "42 1 179:1 / /media/me/GAMES rw - exfat /dev/mmcblk0p1 rw", + "43 1 8:33 / /media/me/STICK rw - vfat /dev/sdc1 rw", + "44 1 259:2 /home/me/cards/X /media/me/BOUND rw - ext4 /dev/nvme0n1p2 rw", + "45 1 0:74 / /mnt/d rw - 9p D:\\134 rw"]) + told = {name: install._fixed_partition(Path(point), mountinfo, sys_root) + for name, point in (("DATA", "/media/me/DATA"), ("USB", "/media/me/EASYROMS"), + ("MMC", "/media/me/GAMES"), ("STICK", "/media/me/STICK"), + ("BOUND", "/media/me/BOUND"), ("WSL", "/mnt/d"), + ("unmounted", "/media/me/NONE"))} + self.assertEqual(told, {"DATA": True, "USB": False, "MMC": False, "STICK": False, + "BOUND": False, "WSL": False, "unmounted": False}) + for kind, fixed in ((2, False), (3, True), (4, True)): + kernel = types.SimpleNamespace(GetDriveTypeW=lambda root, kind=kind: kind) + fake = types.SimpleNamespace(windll=types.SimpleNamespace(kernel32=kernel)) + with mock.patch.object(install.os, "name", "nt"), \ + mock.patch.dict(sys.modules, {"ctypes": fake}): + self.assertEqual(install._drive_not_removable(PureWindowsPath("D:\\")), fixed) + + +# install.py whose tui_view takes SLOW seconds on the first frame of each +# screen, as a slower device takes to lay one out (code_audit_22 P3-1). +SLOW_VIEW = """ +import os, sys, time +sys.argv[0] = {install!r} +sys.path.insert(0, {repo!r}) +import install +slow = float(os.environ["SLOW"]) +real, seen = install.tui_view, [None] +def view(state, cols, rows): + key = (state.screen, len(state.stack)) + if key != seen[0]: + seen[0] = key + time.sleep(slow) + return real(state, cols, rows) +install.tui_view = view +sys.exit(install.main()) +""" + + +@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"), + "screen tests need pyte (pip install pyte==0.8.2) and a pty") +class TheSettleRunsFromTheFrameShown(unittest.TestCase): + """code_audit_22 P3-1: the settle was stamped before the layout, so on + a slow one the second click of a double click on Options' Back reached + the plan's Quit.""" + + repo = EndToEnd.repo + bar_entry = TapsOnAPtySeeFirst.bar_entry + + def test_a_double_click_on_back_stays_on_the_plan_after_a_slow_layout(self): + repo = self.repo() + wrapper = repo.root / "slowview.py" + wrapper.write_text(SLOW_VIEW.format(install=str(REPO_ROOT / "install.py"), + repo=str(REPO_ROOT))) + env = {**self.env, "SLOW": "0.5"} + term = Pty([], env, 80, 24, program=[sys.executable, str(wrapper)]) + self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9)) + term.wait("Where do you play?") + term.send("\r") + term.wait("> Install") + term.settle() + term.send("o") + term.wait("RetroBIOS: Options") + term.settle() + time.sleep(1.0) + col, line = self.bar_entry(term, "Back") + report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True) + os.write(term.fd, report.encode()) + time.sleep(0.2) + os.write(term.fd, report.encode()) + time.sleep(1.0) + screen = term.settle(0.6) + self.assertNotIn("Quit?", screen) + self.assertIn("RetroBIOS: RetroArch", screen.splitlines()[0]) + self.assertEqual(repo.installed(), []) + term.send("q") + if "Quit?" in term.settle(): + term.send("\r") + term.close() + + +class OnePowerShellRule(unittest.TestCase): + """code_audit_22 P3-5: the words of a command asked RETROBIOS_BOOTSTRAP + or the detected system, its line ends RETROBIOS_BOOTSTRAP or os.name: + with RETROBIOS_OS=windows on Linux one command had PowerShell's quotes + and sh's line ends, and pasted into either shell it broke.""" + + def test_the_words_and_the_line_ends_read_one_rule(self): + cases = ((None, "windows", True), ("sh", "windows", False), ("ps1", "linux", True), + (None, "linux", False), ("sh", "linux", False)) + for bootstrap, os_type, powershell in cases: + with self.subTest(bootstrap=bootstrap, os_type=os_type): + hint = install.Invocation() + hint.os_type, hint.options = os_type, [("--system", "it's")] + env = {key: value for key, value in os.environ.items() + if key != "RETROBIOS_BOOTSTRAP"} + if bootstrap: + env["RETROBIOS_BOOTSTRAP"] = bootstrap + with mock.patch.dict(os.environ, env, clear=True): + self.assertEqual(hint.powershell(), powershell) + text = hint.command("retroarch", Path("/home/me/My Games/bios")) + # Line mode's wrap reads the run's Invocation, the + # screen's the one that made its command. + with mock.patch.object(install, "_INVOCATION", hint): + wrapped = install._command_wrap(text, 40) + drawn = [str(line) for line in install._text_lines( + [install._command_line(text, hint)], 40)] + for lines in (wrapped, drawn): + lines = [line for line in lines if line.strip()] + ends = {line.rstrip()[-1] for line in lines[:-1]} + self.assertEqual(ends, {"`" if powershell else "\\"}) + words = " ".join(lines) + self.assertIn("'it''s'" if powershell else shlex.quote("it's"), words) + + +class EachDataTreeReadOnce(unittest.TestCase): + """code_audit_22 P3-3: the count line, the check, the events and the + install each read and hashed every data tree in place, twice per run + on an SD card through a reader.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + self.dest = self.root / "bios" + bios = b"bios bytes" + (self.dest / "gba_bios.bin").parent.mkdir(parents=True) + (self.dest / "gba_bios.bin").write_bytes(bios) + trees = [] + for ref, files, held in (("dolphin-sys", {"Sys/a.bin": b"a", "Sys/b.bin": b"bb"}, 2), + ("ppsspp", {"assets/c.bin": b"ccc", "assets/d.bin": b"d"}, 1)): + for rel, data in list(files.items())[:held]: + (self.dest / ref / rel).parent.mkdir(parents=True, exist_ok=True) + (self.dest / ref / rel).write_bytes(data) + trees.append({"ref": ref, "dest": ref, "type": "zip", + "url": f"https://example.invalid/{ref}.zip", + "systems": ["nintendo-gba"], + "files": {rel: {"sha1": hashlib.sha1(data).hexdigest(), + "size": len(data)} for rel, data in files.items()}}) + self.list = self.root / "retroarch.json" + self.list.write_text(json.dumps({ + "manifest_version": 2, "platform": "retroarch", "data_directories": trees, + "files": [{"dest": "gba_bios.bin", "sha1": hashlib.sha1(bios).hexdigest(), + "size": len(bios), "repo_path": "bios/gba_bios.bin", "cores": None, + "systems": ["nintendo-gba"]}]})) + + def test_each_tree_is_hashed_once_per_run(self): + real = install.data_directory_state + for mode in ("check", "events", "dry_run", "yes"): + with self.subTest(mode=mode): + calls = [] + + def counted(entry, bios_path, calls=calls): + calls.append(entry["ref"]) + return real(entry, bios_path) + + given = {"events": True, "check": True} if mode == "events" else {mode: True} + stream = io.StringIO() if mode == "events" else None + with mock.patch.object(install, "data_directory_state", counted), \ + mock.patch.object(install.EVENTS, "stream", stream), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()), \ + contextlib.suppress(SystemExit): + try: + install._install(_args(platform="retroarch", dest=self.dest, + manifest=self.list, no_input=True, + declared_only=False, **given), io.StringIO()) + finally: + install.LOCAL.reset() + self.assertEqual(sorted(calls), ["dolphin-sys", "ppsspp"]) + + +class OneVocabularyOneCount(unittest.TestCase): + """code_audit_22 P3-4, tui_audit_15 P3-6 to P3-10: line mode said "in + place" then "up to date", "wrong copies" then "wrong hash", counted + twice, left the data files out of its last line, and the screen focused + Install for a pack holding none of the files.""" + + setUp = LineModeAndScreenDecideAlike.setUp + line_run = LineModeAndScreenDecideAlike.line_run + listing = LineModeAndScreenDecideAlike.listing + entry = staticmethod(LineModeAndScreenDecideAlike.entry) + screen = staticmethod(AuditTwentyOneAndFourteen.screen) + + def two_files(self) -> tuple[Path, Path, Path]: + """A list of two files, the first in place, the second in an old + folder: (list, BIOS folder, old folder).""" + files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]), + self.entry("scph5501.bin", b"psx bytes", ["sony-playstation"])] + listing = self.listing("retroarch", files) + dest = self.root / "bios" + dest.mkdir() + (dest / "gba_bios.bin").write_bytes(b"gba") + old = self.root / "OLD" + old.mkdir() + (old / "scph5501.bin").write_bytes(b"psx bytes") + return listing, dest, old + + def run_line(self, listing: Path, dest: Path, answers: list, **given) -> str: + from_ = given.pop("from_", None) + args = _args(manifest=listing, dest=dest, declared_only=False, **given) + if from_: + given["from_"] = from_ + if "from_" in given: + setattr(args, "from", given.pop("from_")) + # Offline as the variable makes it: no request leaves the test. The + # end of the run is written to the stdout the run was given. + given_answers = iter(answers) + + def answer(prompt: str) -> str: + try: + return next(given_answers) + except StopIteration: + raise _Decided(prompt) from None + + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + invocation = install.Invocation() + invocation.os_type = "linux" + invocation.record(args) + end = io.StringIO() + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + mock.patch.object(install, "_INVOCATION", invocation), \ + mock.patch.object(install, "_mounted_cards", 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, end) + return out.getvalue() + err.getvalue() + end.getvalue() + + def test_the_line_mode_words_are_the_screens(self): + listing, dest, old = self.two_files() + (old / "scph5501.bin").rename(dest / "scph5501.bin") + for given in ({"no_input": True}, {"check": True}, {"dry_run": True}): + with self.subTest(**given): + text = self.run_line(listing, dest, [""], offline=True, **given) + self.assertIn("2 in place, 0 missing, 0 wrong copies", text) + self.assertIn("Everything is in place", text) + self.assertNotIn("up to date", text) + self.assertNotIn("0 files installed", text) + state = checked_state() + state.stack = ["result"] + state.summary = {"installed": 0, "up_to_date": 2, "pending": 0, "kept": [], + "failed": []} + state.code = 0 + _title, lines = install.tui_outcome(state) + self.assertEqual(lines[0], "Everything is in place.") + + def test_check_names_a_wrong_copy_as_such(self): + listing, dest, _old = self.two_files() + (dest / "scph5501.bin").write_bytes(b"other") + text = self.run_line(listing, dest, [], check=True, offline=True) + self.assertIn("1 in place, 0 missing, 1 wrong copy", text) + self.assertIn("wrong copy: scph5501.bin", text) + self.assertNotIn("wrong hash", text) + self.assertNotIn("A file with a", text) + + def test_line_mode_with_from_counts_once(self): + """tui_audit_15 P3-8 and P3-9: no found count before Enter, the + plan said again after it, Copying twice, the result's count twice, + and no command on a Finished with files missing.""" + listing, dest, old = self.two_files() + (dest / "gba_bios.bin").unlink() + text = self.run_line(listing, dest, [""], from_=[old], offline=True) + menu, after = text.split("\nChecking existing files...", 1) + self.assertIn("RetroArch: 1 found in OLD, 1 needs the internet.", menu) + self.assertNotIn("2 files (", after) + self.assertEqual(after.count("Copying"), 1, after) + self.assertIn(f"Finished with files missing. 1 file copied from {old}, " + "1 still missing.", after) + self.assertNotIn("installed,", after) + self.assertIn("Same choice without questions:", after) + # Every file found: one count, as the screen's "22 files copied + # from packx." + (dest / "scph5501.bin").unlink() + (old / "gba_bios.bin").write_bytes(b"gba") + text = self.run_line(listing, dest, [""], from_=[old], offline=True) + menu, after = text.split("\nChecking existing files...", 1) + self.assertIn("RetroArch: 2 found in OLD, nothing to download.", menu) + self.assertIn(f"Done. 2 files copied from {old}.", after) + self.assertNotIn("files installed", after) + + def test_the_last_offline_line_counts_the_data_files(self): + """tui_audit_15 P3-6: "0 in place, 10994 missing", then "Nothing + installed: 8358 files need the internet".""" + listing, dest, _old = self.two_files() + listed = json.loads(listing.read_text()) + listed["data_directories"] = [{ + "ref": "bluemsx", "dest": "", "type": "zip", + "url": "https://example.invalid/blueMSX.zip", "systems": ["nintendo-gba"], + "files": {f"Machines/{n}.txt": {"sha1": hashlib.sha1(b"%d" % n).hexdigest(), + "size": 1} for n in range(2)}}] + listing.write_text(json.dumps(listed)) + text = self.run_line(listing, dest, [""], offline=True) + self.assertIn("1 in place, 3 missing, 0 wrong copies", text) + self.assertIn("Nothing installed: 3 files need the internet", text) + + def test_a_pack_holding_none_offers_no_install(self): + """tui_audit_15 P3-7: a data tree whose folder is the BIOS folder + itself (blueMSX) made any pack hold a data file: Install focused, + and the result counted 8252 failed beside 10888 missing.""" + pack = self.root / "RetroArch_BIOS_Pack.zip" + with zipfile.ZipFile(pack, "w") as archive: + archive.writestr("manifest.json", json.dumps({"files": []})) + archive.writestr("other-system.bin", b"x") + state = checked_state() + state.check = {**state.check, "data_missing": 2, "data_bytes": 2} + state.manifest = {**MANIFEST, "data_directories": [ + {"ref": "bluemsx", "dest": "", "files": { + "Machines/a.txt": {"sha1": "0" * 40, "size": 1}, + "Machines/b.txt": {"sha1": "0" * 40, "size": 1}}}]} + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + install.LOCAL.pack = install.PackSource([pack]) + rows = install.tui_rows(state) + self.assertNotIn("install", [row.action for row in rows]) + self.assertEqual(rows[state.focus.get("plan", 0)].action, "fromcard") + text = self.screen(state) + self.assertIn("RetroArch_BIOS_Pack.zip holds none of these 6 files", text) + self.assertIn("1 in place, 5 missing, 1 wrong copy", text) + + +class FocusAfterAnAction(unittest.TestCase): + """tui_audit_15 P3-1, P3-3, P3-4 and P3-5: one tap on the focused + Install row armed nothing visible, the result's Put RetroBIOS on this + card was cut at 40x12, the Game data row lost the focus once changed, + and a choice already in place opened on Options.""" + + @staticmethod + def row_of(lines: list, action: str) -> int: + return next(index for index, line in enumerate(lines) + if getattr(line, "mark", None) and line.mark[0] == "row" + and line.mark[2][0] == action) + + def test_one_tap_on_the_focused_install_row_arms_it_visibly(self): + for cols, rows in ((80, 24), (40, 12)): + with self.subTest(size=(cols, rows)): + state = checked_state() + state.mouse = True + lines = drawn(state, cols, rows) + row = self.row_of(lines, "install") + self.assertTrue(str(lines[row]).lstrip().startswith("> Install")) + self.assertEqual(tap(state, 4, row), []) + armed = drawn(state, cols, rows) + self.assertIn(install.TUI_ARMED_SHORT, str(armed[-1])) + self.assertIn(install.TUI_ARMED_SHORT, str(armed[row])) + self.assertEqual(armed[row].style, "focus") + painted = install.tui_paint(str(armed[-1]), "hint", True) + self.assertIn(f"{install.COLOUR_ROLES['accent']}m", painted) + # The second tap, on the row or on the bar, installs. + self.assertEqual(tap(state, 4, row), [("run", "install")]) + + def test_a_row_armed_then_the_bar_installs_and_a_key_disarms(self): + state = checked_state() + state.mouse = True + lines = drawn(state, 80, 24) + tap(state, 4, self.row_of(lines, "install")) + drawn(state, 80, 24) + self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "install")]) + state = checked_state() + state.mouse = True + lines = drawn(state, 80, 24) + tap(state, 4, self.row_of(lines, "install")) + install.tui_update(state, Key("down")) + install.tui_update(state, Key("up")) + self.assertNotIn(install.TUI_ARMED_SHORT, " ".join(map(str, drawn(state, 80, 24)))) + + def test_put_retrobios_on_this_card_fits_40x12(self): + """The result offering the app, drawn and tapped as the loop does + (tui_view, tui_hits, tui_mouse), at 40x12 and 80x24: no line cut + inside a word, the armed bar whole, one Exit on the bar and the + help naming B as the bar does.""" + root, bios = _app_card(self, "muOS") + for cols, rows in ((40, 12), (80, 24)): + with self.subTest(size=(cols, rows)): + state = _installed_state(bios) + state.mouse = True + with mock.patch.object(install, "_tui_app_ready", return_value=True): + lines = drawn(state, cols, rows) + text = [str(line).rstrip() for line in lines] + row = self.row_of(lines, "addapp") + offer = text[row].strip() + self.assertIn(offer, ("Put RetroBIOS on this card", + install.APP_OFFER)) + self.assertEqual(text[-1].split(), ["A/Enter", "Exit"] + if cols >= install.TUI_LONG_BAR_WIDTH else ["A", "Exit"]) + # One tap focuses the row: a second puts the app on the + # card, which the row and the bar say, B kept. + tap(state, 4, row) + armed = [str(line).rstrip() for line in drawn(state, cols, rows)] + self.assertTrue(armed[row].lstrip().startswith("> Put")) + self.assertIn("Exit", armed[-1]) + self.assertIn("B/Esc" if cols >= install.TUI_LONG_BAR_WIDTH else "B", + armed[-1].split()) + self.assertIn(f"{install.TUI_ARMED_SHORT} B", armed[-1]) + self.assertTrue(armed[row].strip().endswith(install.TUI_ARMED_SHORT), + armed[row]) + for line in (*armed, *text): + self.assertLessEqual(install.cell_width(line), cols) + self.assertFalse(line.endswith(("aga", "fro", "(update f")), line) + state.tapped = None + install.tui_update(state, Key("f1")) + help_text = " ".join(" ".join(str(line).split()) + for line in drawn(state, cols, rows)) + self.assertIn("Exit", help_text) + self.assertNotIn("Backspace Quit", help_text) + install.tui_update(state, Key("f1")) + + def test_the_game_data_row_keeps_the_focus_when_changed(self): + audit = AuditTwentyAndThirteen() + state = audit.game_plan() + rows = install.tui_rows(state) + state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "gamedata") + for left_out, expected in ((True, "Game data: left out"), (False, "Game data: kept")): + self.assertEqual(install.tui_update(state, Key("enter")), [("run", "check")]) + audit.receive(state, audit.game_events(audit.game_manifest(), left_out=left_out)) + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus["plan"]].label, expected) + + def test_a_choice_already_in_place_opens_on_choose_systems(self): + state = checked_state() + state.focus["plan"] = next(i for i, row in enumerate(install.tui_rows(state)) + if row.action == "systems") + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "systems") + state.ticked_before = set(state.ticked) + state.ticked = {"nintendo-famicom-disk"} + with mock.patch.object(install, "_choice_in_place", return_value="in place"): + install.tui_update(state, Key("esc")) + self.assertEqual(state.screen, "plan") + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus["plan"]].label, "Choose systems") + + +class TextThatFits(unittest.TestCase): + """tui_audit_15 P3-2, P3-12, P3-13 and P3-11: a pack's name cut in a + command, folder lines at two indents, the 40x12 plan dropping the + version note, and the --from plan without its kinds.""" + + def test_a_pack_path_keeps_its_name_whole(self): + pack = "/home/user/Downloads/RetroArch_BIOS_Pack.zip" + text = ("curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --platform " + f"retroarch --dest /media/moi/ONION/BIOS --from-pack {pack} --offline") + for width in (38, 50, 78): + with self.subTest(width=width), \ + mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}): + lines = install._command_wrap(text, width) + joined = " ".join(lines) + self.assertIn("RetroArch_BIOS_Pack.zip", joined) + self.assertNotRegex(joined, r"\.\.\.[^/ ]*Pack\.zip") + for line in lines: + self.assertLessEqual(install.cell_width(line), width, line) + + def test_a_folder_on_its_own_line_keeps_one_indent(self): + base = "/home/me/very/long/folder/names/for/an/android/storage/RetroArch" + items = [f"BIOS folder: {base}/system", + "1 file read from RetroArch's save directory goes to a folder per core:", + f" {base}/saves"] + for width in (38, 70, 78): + for whole in (False, True): + with self.subTest(width=width, whole=whole): + lines = [str(line) for line in install._text_lines(items, width, whole)] + folders = [line for line in lines if "/" in line + and not line.startswith("BIOS folder:")] + self.assertTrue(folders) + for line in folders: + self.assertEqual(len(line) - len(line.lstrip(" ")), 2, lines) + + def test_the_40x12_plan_keeps_the_version_note(self): + state = checked_state() + state.dest = Path("/home/moi/retroarch_system/tmp/tui_audit15/rb2/bios") + install.tui_receive(state, ("event", state.run_id, { + "event": "version", "platform": "retroarch", "list_version": "1.22.2", + "installed_version": "1.21.0", + "message": "This list was built for RetroArch 1.22.2, this machine runs 1.21.0. " + "Some files may differ from what this version reads."})) + state.run = "check" + narrow = " ".join(" ".join(str(line).split()) + for line in install.tui_view(state, 40, 12)) + self.assertIn("List for RetroArch 1.22.2, this is 1.21.0.", narrow) + self.assertIn("BIOS folder:", narrow) + wide = " ".join(" ".join(str(line).split()) for line in install.tui_view(state, 80, 24)) + self.assertIn("This list was built for RetroArch 1.22.2, this machine runs 1.21.0.", wide) + + +class TheFromPlanSaysItsKinds(unittest.TestCase): + """tui_audit_15 P3-11: every file found in the folder, the plan named + the Game data row and said neither the kinds nor what game data is.""" + + def test_the_kinds_and_the_definition_stay(self): + audit = AuditTwentyAndThirteen() + state = audit.game_plan() + folder = "/media/moi/packx" + state.from_folders = [Path(folder)] + state.found = [found(event["dest"], folder) for event in state.files + if event["state"] != "present"] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text = " ".join(" ".join(str(line).split()) + for line in install.tui_view(state, 80, 24)) + self.assertIn("found in packx, nothing to download", text) + self.assertIn("2 BIOS files", text) + self.assertIn("3 game data files", text) + self.assertIn("Game data: files a game or a game engine reads", text) + self.assertNotIn("Not in packx", text) + + +class TheSystemsQuestionFits(unittest.TestCase): + """code_audit_22 P3-6, tui_audit_15 P3-14: the size column was placed + for the longest size, so 60 of 66 systems took two lines and 26 still + passed 80 columns.""" + + def question(self, cols: int) -> list[str]: + manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text()) + files = manifest["files"] + systems = install.systems_in_order(install.listed_systems(files), files, manifest, + None) + 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(files, [], manifest, None) + text = out.getvalue() + # Up to the question that follows it, the cores' or none. + 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(), + systems, files) + + def test_the_question_fits_80_and_40_columns(self): + for cols in (80, 40): + with self.subTest(cols=cols): + lines, systems, files = self.question(cols) + numbered = [line for line in lines if re.match(r" +\d+\) ", line)] + self.assertEqual(len(numbered), len(systems)) + for line in lines: + self.assertLessEqual(install.cell_width(line), cols, line) + shared = sum(1 for system in systems if "shared" in install._system_sized( + [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 + self.assertLessEqual(len(lines), limit) + + def test_a_row_that_fits_keeps_one_line(self): + lines, _systems, _files = self.question(80) + row = next(index for index, line in enumerate(lines) if "Amstrad CPC" in line) + self.assertIn("4 files, 112.0 KB", lines[row]) + self.assertTrue(re.match(r" +\d+\) ", lines[row + 1])) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_site_r2_entry.py b/tests/test_site_r2_entry.py index 7f7984f60..5a59b451f 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/tests/test_site_r3_entry.py b/tests/test_site_r3_entry.py index 20484abf0..990345902 100644 --- a/tests/test_site_r3_entry.py +++ b/tests/test_site_r3_entry.py @@ -158,7 +158,9 @@ class SetupPagesOfferPartOfThePack(unittest.TestCase): with self.subTest(platform=key): text = _guide_part(_page(f"platforms/{key}.md")) self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text) - self.assertIn("or press **c** in the command.", text) + # The screen's own row: the line menu's c is not what the + # one-liner opens. + self.assertIn("or choose **Choose systems** in the command.", text) def test_the_anchor_exists(self): self.assertIn("## Only some consoles?", _page("which-pack.md")) diff --git a/tests/test_site_r4_entry.py b/tests/test_site_r4_entry.py index a0285496c..ec4d423dc 100644 --- a/tests/test_site_r4_entry.py +++ b/tests/test_site_r4_entry.py @@ -193,8 +193,13 @@ class IphoneAndIpad(unittest.TestCase): class WhatItAsks(unittest.TestCase): - def test_the_prompt_block_is_framed_as_a_terminal(self): - self.assertIn('```text title="What it asks" frame="terminal"', _page("which-pack.md")) + def test_the_page_says_the_screen_not_a_line_menu(self): + # The one-liner opens the full screen: a "What it asks" block of the + # line menu showed keys a beginner never sees (owner's note). + text = _page("which-pack.md") + self.assertNotIn('title="What it asks"', text) + for line in generate_site.first_screen_words(): + self.assertIn(line, text) if __name__ == "__main__": diff --git a/tests/test_site_r5_entry.py b/tests/test_site_r5_entry.py index c4df0ac70..262cb905c 100644 --- a/tests/test_site_r5_entry.py +++ b/tests/test_site_r5_entry.py @@ -104,16 +104,17 @@ class GetPagesGiveTheSetupsOwnRoutes(unittest.TestCase): class CheckNeedsTheFiles(unittest.TestCase): def test_the_pass_line_is_what_the_installer_prints_only_when_complete(self): installer = (REPO_ROOT / "install.py").read_text(encoding="utf-8") - printed = re.search(r"`([^`]+)`", generate_site.CHECK_PASS_LINE)[1] - self.assertIn(f'print("\\n {printed}")', installer) - self.assertNotIn("wrong hash", generate_site.CHECK_PASS_LINE) + printed = re.search(r"`([^`]+)`", generate_site.check_pass_line())[1] + self.assertEqual(printed, generate_site._installer().EVERYTHING_IN_PLACE) + self.assertIn('print(f"\\n {EVERYTHING_IN_PLACE}")', installer) + self.assertNotIn("wrong hash", generate_site.check_pass_line()) def test_no_setup_page_passes_an_empty_folder(self): for name in SETUPS: text = _page(f"platforms/{name}.md") with self.subTest(setup=name): self.assertNotIn("All good when you see `0 wrong hash`", text) - self.assertIn(generate_site.CHECK_PASS_LINE, text) + self.assertIn(generate_site.check_pass_line(), text) class ARouteWithoutANetwork(unittest.TestCase): @@ -236,5 +237,57 @@ class HomeAndWhichPack(unittest.TestCase): self.assertIn(word, meta[1].split()) +class TheSiteSaysWhatTheInstallerShows(unittest.TestCase): + """which-pack showed a line menu (Enter, i, c, q) the one-liner never + opens, typed by hand, its keys out of date. The site, the README and + the wiki now take the screen's words and the line menu from install.py.""" + + MENU_LINES = ("Enter) install everything", "i) only what the installed cores read", + "c) choose systems, cores and regions") + + def test_which_pack_describes_the_screen_in_the_installers_words(self): + installer = generate_site._installer() + text = _page("which-pack.md") + for line in generate_site.first_screen_words(): + self.assertIn(line, text) + self.assertIn(f"**{installer.TUI_INSTALL_ROW}**", text) + self.assertIn(f"**{installer.TUI_SYSTEMS_ROW}**", text) + for line in self.MENU_LINES: + self.assertNotIn(line, text) + self.assertNotIn("press **c**", text) + + def test_the_plan_rows_are_the_words_the_pages_use(self): + installer = generate_site._installer() + state = installer.TuiState(os_type="linux", home=Path("/home/me")) + state.stack = ["plan"] + state.check = {"files": 2, "present": 0, "missing": 2, "wrong_hash": 0, "need": 2, + "bytes_needed": 10} + state.systems = [{"system": name, "missing": 1, "wrong_hash": 0, "present": 0, + "files": 1, "bytes_needed": 5} for name in ("a", "b")] + state.manifest = {"files": [{"dest": "a.bin", "systems": ["a"]}, + {"dest": "b.bin", "systems": ["b"]}]} + state.ticked = {"a", "b"} + state.checked_flags = installer.tui_flags(state) + labels = [row.label for row in installer.tui_rows(state)] + self.assertEqual(labels[0], installer.TUI_INSTALL_ROW) + self.assertIn(installer.TUI_SYSTEMS_ROW, labels) + + def test_the_readme_and_the_wiki_take_the_same_words(self): + installer = generate_site._installer() + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn(f"fewer under **{installer.TUI_SYSTEMS_ROW}**", readme) + self.assertNotIn("offers to install everything or to choose systems", readme) + source = (REPO_ROOT / "scripts" / "generate_readme.py").read_text(encoding="utf-8") + self.assertIn("_installer_words().TUI_SYSTEMS_ROW", source) + import generate_installer_doc # noqa: PLC0415 + + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + self.assertIn(generate_installer_doc.line_menu(), page) + menu = page.split("", 1)[1] + outside = page.replace(menu.split("", 1)[0], "") + for line in self.MENU_LINES: + self.assertNotIn(line, outside) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index 6ef13fd26..3304fe810 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -273,7 +273,9 @@ 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 too. One that writes files with no screen after it to look first, **Install** on the plan and **Put it on the card**, takes two taps: the first adds -`click or tap again` to it in the accent colour, the second presses it. +`click or tap again` to it in the accent colour (`tap again` where the line +has no room), the second presses it. A first tap on the row it acts on arms +it the same way, on the row and on the bottom line. **Install** on Confirm, which is that look, Quit, Continue and Close take one, so installing or quitting never takes more than two. A row of Preferred regions stays where it is when it is ticked, its number giving the @@ -281,8 +283,8 @@ order, which the list shows sorted the next time it opens. The line that says which rows are in view shows the next page, and the wheel scrolls a list or a text without moving the cursor unless it would leave the screen. Nothing happens until the button or the finger comes up, and only where it went down, -on the screen drawn when it went down. A new screen takes no press for its -first 400 ms: a double click is two presses at most 400 ms (GNOME) to 500 ms +on the screen drawn when it went down. A new screen takes no press for the +first 400 ms after it is on the screen, however long it took to lay out: a double click is two presses at most 400 ms (GNOME) to 500 ms (Windows, macOS) apart and the new screen is drawn on the first release, so the second tap of a double tap that changes the screen does nothing on the new one, and a tap made later acts. The right and middle buttons @@ -322,8 +324,8 @@ file list is downloaded once and handed to each run. 3. Prints the file count and total size, then a safety notice counting the entries the collection cannot serve, how many of those the platform marks required, and why. At a terminal, the - [full-screen interface](#the-full-screen-interface) then offers to install - everything or to choose systems, cores and regions, and `--plain` asks the + [full-screen interface](#the-full-screen-interface) then offers **Install** + for everything and **Choose systems** for fewer, and `--plain` asks the same as questions (see below). 4. Hashes what is already in the destination and reports how many entries are present, verified, or present with the wrong contents. @@ -391,7 +393,7 @@ A RetroArch dry run into an empty folder printed this on 2026-10-27: ``` Fetching file index for retroarch... 106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves - 10994 files (6.6 GB) + 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). Not installable: 37 files (1 required, not found: 37). @@ -528,7 +530,7 @@ The same command with `--check` reads the folder and writes nothing: Checking existing files... 14481 in place, 0 missing, 0 wrong copies - All files up to date. + Everything is in place. ``` The files the collection does not hold are listed after it again, as long as @@ -544,7 +546,7 @@ core opens it: ``` 3 files to download: - wrong hash: scph5501.bin + wrong copy: scph5501.bin another region: the contents of scph5500.bin (japan) opened at /home/me/.config/retroarch/system/scph5501.bin by beetle_psx, pcsx_rearmed missing: gba_bios.bin @@ -700,25 +702,38 @@ the plan screen of the [full-screen interface](#the-full-screen-interface) offers **Install**, **Choose systems** and **Options**. With `--plain`, or where the full screen cannot open (a terminal smaller than 40x12, `TERM=dumb`), the same choice comes as questions after detection. A RetroArch run with `--plain` -printed this on 2026-10-28, `g` then pressed: +prints this for the RetroArch list, `g` then pressed (written from install.py +by `scripts/generate_installer_doc.py`): + + ``` -RetroArch: 10994 files, 6.6 GB. +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 - 106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves Enter) install everything c) choose systems, cores and regions f) copy from a folder or an old card g) Game data: kept (5089 files, 2.5 GB): leave it out q) quit -> +> g + Counted again for your choices. Nothing is written before Enter. -RetroArch: 5905 files, 4.1 GB. +RetroArch: 5905 files, 4.2 GB. 3269 BIOS files (4.1 GB) and 2636 data files (38.5 MB). 5089 game data files left out. + 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 + Same choice without questions: python3 install.py --platform retroarch --dest /home/me/.config/retroarch/system --no-game-data + Enter) install everything + c) choose systems, cores and regions + f) copy from a folder or an old card + g) Game data: left out (5089 files, 2.5 GB): keep it + q) quit +> ``` + A list that marks its game data counts three kinds of file apart, on the plan, in this menu and on the result (`Installed: 3269 BIOS files, 5089 game data @@ -733,9 +748,8 @@ count follows the systems chosen, as `g` counts them, and a choice that holds no game data has no row. Given `--no-game-data`, neither the menu nor the plan offers the game data back. -`i) only what the installed cores read: 87 files, 43.8 MB` appears for -RetroArch, Lakka and RetroPie when cores are found installed, here a RetroArch -with Beetle PSX HW, Snes9x, mGBA, Genesis Plus GX and FCEUmm. +An `i` entry, with the count and size of only what the installed cores read, +appears for RetroArch, Lakka and RetroPie when cores are found installed. It keeps what those cores read and drops the systems none of them emulates, the rule `--installed-cores` applies. @@ -908,8 +922,8 @@ what the options name, everything by default. `--check` reads the same file list, hashes the destination and exits, with -code 1 when a file is missing or does not match and 0 when every one does. Files -with a wrong hash are listed first and in full, then the missing ones whose +code 1 when a file is missing or does not match and 0 when every one does. Wrong +copies are listed first and in full, then the missing ones whose reason it found, then the first ten other missing ones (`--verbose` lists them all). See [Why a file is refused](#why-a-file-is-refused). `--dry-run` counts the data trees in its total. Without it, a file already present with the expected hash is left untouched, and one @@ -1181,13 +1195,18 @@ muOS, ArkOS and the others) opens the setup list instead, filtered to the setups that read it, RetroArch first: Enter on it asks RetroArch's folder. At 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 drive no file names that holds a `bios` -folder and console folders (`nes`, `gba`, `psx`) is a handheld's card whose -own files sit on a part a computer does not open, ArkOS's `EASYROMS` and -AmberELEC's `GAMES`: with nothing found here the first screen and line mode -say `A card is in the reader (EASYROMS): choose its system`, and its `bios` -folder comes first at the folder question, under Enter, before this -computer's usual folder. Another system's card or SHARE drive, and a +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 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 @@ -1608,7 +1627,7 @@ fields, the installer refuses to narrow rather than install everything. | Code | Meaning | |------|---------| -| `0` | everything asked for is installed or already up to date | +| `0` | everything asked for is installed or already in place | | `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card | | `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit | | `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question, the terminal keeping `RetroBIOS: stopped. Nothing was installed.` before an install. Run the same command again to carry on |