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