diff --git a/install.ps1 b/install.ps1 index 38055c5f..0a3b78fc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "163a216c1b09e8d788d4de6148194705813d4c6b8a956c84a70a267205f2c25d" + $defaultInstallSha256 = "bcbde4c289b32c02ada78001b75274a87b125c1efd3d8fb1064d7f5482ef3997" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 87dce8fa..7f816c98 100644 --- a/install.py +++ b/install.py @@ -8966,6 +8966,14 @@ class TuiState: self.keep_ticked: set | None = None # (files read, files to read) while the check hashes what is in place. self.reading: tuple | None = None + # Folders to copy BIOS files from (--from), and what the check found. + self.from_folders: list = [] + self.found: list = [] + # What the browser is choosing: the BIOS folder, or one to copy from. + self.browse_for: str = "dest" + # Files an install copies from this machine, whose bytes are no download. + self.local_dests: set = set() + self.local_done: int = 0 # The child run in progress: check, confirm, preview or install. self.run: str = "" self.running: bool = False @@ -9057,8 +9065,10 @@ def tui_blocker( return (f"the terminal is {cols}x{rows}, smaller than " f"{TUI_MIN_COLS}x{TUI_MIN_ROWS}") if os.name == "nt": + import importlib # noqa: PLC0415 + try: - import msvcrt # noqa: F401, PLC0415 + importlib.import_module("msvcrt") except ImportError: return "this Python has no msvcrt to read keys" if not _enable_ansi(): @@ -9094,6 +9104,7 @@ def tui_flags(state: TuiState) -> list[tuple[str, str | None]]: flags.append(("--installed-cores", None)) if state.jobs != 8: flags.append(("--jobs", str(state.jobs))) + flags += [("--from", str(folder)) for folder in state.from_folders] return flags @@ -9135,7 +9146,7 @@ class TuiRow(NamedTuple): # Screens that scroll text instead of listing rows. -_PAGERS = ("preview", "command", "result") +_PAGERS = ("preview", "command", "result", "misplaced") _CHECKLISTS = ("systems", "cores") @@ -9270,9 +9281,13 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: rows = [TuiRow("install", label)] if state.facts.get("writable") is False: # Every file would fail alike: the way out is another folder. - rows = [TuiRow("folder", "Choose another folder"), TuiRow("install", "Install anyway")] + rows = [TuiRow("folder", "Choose another folder"), TuiRow("install", "Install anyway"), + *rows[1:]] if records_selection((state.manifest or {}).get("files", [])) and len(state.systems) > 1: rows.append(TuiRow("systems", "Choose systems")) + rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder")) + if _tui_misplaced(state): + rows.append(TuiRow("misplaced", "Show the files in the wrong place")) return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")] @@ -9323,10 +9338,12 @@ def _option_rows(state: TuiState) -> list[TuiRow]: if choices["cores"]: chosen = f"{len(state.cores)} chosen" if state.cores else "all" rows.append(TuiRow("cores", f"Cores: {chosen}", "--core")) + copied = ", ".join(str(folder) for folder in state.from_folders) or "none" rows += [ TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs"), TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest", style="path"), + TuiRow("from", f"Copy from: {copied}", "--from"), TuiRow("preview", "Preview the file list", "--dry-run"), TuiRow("command", "Show the command line"), ] @@ -9397,12 +9414,13 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: # The systems of an earlier check go first: ticks read against them # dropped the --system the command line gave. state.check, state.files, state.systems, state.reading = None, [], [], None + state.found = [] state.checked_flags = tui_flags(state) elif run in ("confirm", "preview"): state.dry, state.dry_files, state.dry_summary = None, [], None else: state.download = state.progress = state.reading = None - state.samples = [] + state.samples, state.local_dests, state.local_done = [], set(), 0 state.finished, state.summary, state.code, state.stopping = [], None, None, False return [("run", run)] @@ -9479,8 +9497,9 @@ def _mounted_cards() -> list[Path]: return found -def _browse_rows(folder: Path | None) -> tuple[list[TuiRow], str]: - """A directory's rows for the browser, cards first, and what went wrong.""" +def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow], str]: + """A directory's rows for the browser, cards first, and what went wrong. + With zips, the ZIP files too: a backup is often one archive.""" if folder is None: drives = [Path(f"{letter}:\\") for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if os.path.exists(f"{letter}:\\")] @@ -9492,17 +9511,21 @@ def _browse_rows(folder: Path | None) -> tuple[list[TuiRow], str]: what = f"{display_name(plat)} card" if plat else "card or drive" rows.append(TuiRow("dir", f"{card} ({what})", value=card)) try: - entries = [entry for entry in os.scandir(folder) if entry.is_dir()] + entries = list(os.scandir(folder)) except OSError as exc: return rows, f"Cannot open this folder: {exc.strerror or exc}" entries.sort(key=lambda entry: (entry.name.startswith("."), entry.name.casefold())) - rows += [TuiRow("dir", f"{entry.name}/", value=Path(entry.path)) for entry in entries] + rows += [TuiRow("dir", f"{entry.name}/", value=Path(entry.path)) + for entry in entries if entry.is_dir()] + if zips: + rows += [TuiRow("zip", entry.name, value=Path(entry.path)) for entry in entries + if entry.is_file() and entry.name.lower().endswith(".zip")] return rows, "" def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = None) -> None: state.browse_dir = folder - state.browse_rows, state.status = _browse_rows(folder) + state.browse_rows, state.status = _browse_rows(folder, state.browse_for == "from") focus = 0 for index, row in enumerate(state.browse_rows): if came_from is not None and row.action == "dir" and row.value == came_from: @@ -9558,6 +9581,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if action == "default": return _choose(state, state.platform, state.folder_default) if action == "browse": + state.browse_for = "dest" _push(state, "browse") _browse_to(state, _nearest_folder(state.folder_default or state.home)) return [] @@ -9565,6 +9589,13 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: state.path_selected = bool(state.path_text) return _push(state, "path") if screen == "browse": + if action in ("use", "zip") and state.browse_for == "from": + chosen = state.browse_dir if action == "use" else row.value + if chosen not in state.from_folders: + state.from_folders.append(chosen) + state.browse_for = "dest" + # The check runs again with it: the plan then says what it holds. + return _start_check(state) if action == "use": return _choose(state, state.platform, state.browse_dir) if action == "up" and state.browse_dir is not None: @@ -9595,6 +9626,10 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return tui_quit(state) if action == "folder": return _open_folder_screen(state, state.dest) + if action == "fromcard": + return _open_from_browser(state) + if action == "misplaced": + return _push(state, "misplaced") # On the first system, so A continues and the D-pad ticks. return _push(state, action, 3 if action == "systems" else 0) if screen == "systems": @@ -9662,12 +9697,27 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]: return _push(state, action) elif action == "folder": return _open_folder_screen(state, state.dest) + elif action == "from" and step < 0: + if state.from_folders: + state.from_folders.pop() + return _start_check(state) + elif action == "from": + return _open_from_browser(state) elif action == "preview": _push(state, "preview") return _start_run(state, "preview") return [] +def _open_from_browser(state: TuiState) -> list[tuple]: + """The browser, to name an old card, a backup or a ZIP to copy from.""" + state.browse_for = "from" + _push(state, "browse") + start = state.from_folders[-1].parent if state.from_folders else state.home + _browse_to(state, _nearest_folder(start)) + return [] + + def _toggle_region(state: TuiState, name: str) -> None: if name in state.regions: state.regions.remove(name) @@ -9913,7 +9963,8 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: return _activate(state, rows[0 if char == "a" else 1]) elif screen == "options" and name in ("left", "right"): return _option(state, row.action, 1 if name == "right" else -1) \ - if row.action in ("game_data", "installed", "jobs") else [] + if row.action in ("game_data", "installed", "jobs") \ + or (row.action == "from" and name == "left") else [] elif name == "enter" or char == " ": return _activate(state, row) state.focus[screen] = focus @@ -9924,6 +9975,10 @@ def _take_event(state: TuiState, event: dict) -> None: kind = event.get("event") if kind == "checking" and state.run in ("check", "install"): state.reading = (event["files_done"], event["files_total"]) + elif kind == "found" and state.run == "check": + state.found.append(event) + elif kind == "found" and state.run == "install": + state.local_dests.add(event["dest"]) elif state.run == "check": if kind == "check": state.check = event @@ -9943,9 +9998,13 @@ def _take_event(state: TuiState, event: dict) -> None: state.download = event elif kind in ("progress", "downloaded"): state.progress = event + if kind == "downloaded" and event["dest"] in state.local_dests: + # A copy from an old card or a folder: its bytes come from + # this machine, and a speed counting them was not the network's. + state.local_done += event["size"] state.samples = [sample for sample in state.samples if sample[0] >= state.now - 10] + [ - (state.now, event["bytes_done"])] + (state.now, event["bytes_done"] - state.local_done)] if kind == "downloaded": state.finished.append((event["dest"], event["ok"], event["reason"])) elif kind == "summary": @@ -10037,6 +10096,7 @@ _ROW_VERBS = { "region": "Tick", "regions": "Open", "cores": "Open", "command": "Open", "preview": "Open", "folder": "Choose", "game_data": "Change", "installed": "Change", "jobs": "Change", "retry": "Try again", "back": "Back", "stay": "Stay", + "fromcard": "Choose", "from": "Add a folder", "misplaced": "Show", "zip": "Copy from it", } @@ -10120,18 +10180,46 @@ def _pager(state: TuiState, width: int, height: int, items: list) -> list[TuiLin return lines + [_styled(f"{first + 1}-{max(first + 1, whole)} of {len(items)}", "dim")] +def _tui_misplaced(state: TuiState) -> list[dict]: + """The files the BIOS folder holds at another place, each with its reason.""" + return [event for event in state.found if event["reason"]] + + +def _tui_found_elsewhere(state: TuiState) -> dict[str, list[dict]]: + """The files a --from folder holds, by the folder as it was named.""" + found: dict[str, list[dict]] = {} + for event in state.found: + if not event["reason"]: + found.setdefault(event["folder"], []).append(event) + return found + + def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: need = _needed(check) data = check.get("data_missing", 0) + # A file found on this machine is copied, not downloaded: the count and + # the size to download leave it out, from the check's own events. + local = {event["dest"] for event in state.found} if check is state.check else set() + sizes = {event["dest"]: event["size"] for event in state.files} + copied = [dest_ for dest_ in local if dest_ in sizes] + fetch = max(0, need - len(copied)) + fetch_bytes = max(0, check["bytes_needed"] - sum(sizes[dest_] for dest_ in copied)) + elsewhere = _tui_found_elsewhere(state) if check is state.check else {} + if elsewhere: + where = ", ".join(f"{len(events)} found in {folder}" + for folder, events in elsewhere.items()) + headline = (f"{where}, {fetch} to download ({format_size(fetch_bytes)})" + if fetch else f"{where}, nothing to download") + elif need and fetch: + headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}" + elif need: + headline = f"{_plural(len(copied), 'file')} to copy from this machine" if need: - headline = (f"{_plural(need, 'file')} to download, " - f"{format_size(check['bytes_needed'])}") if data: headline += f", and {_plural(data, 'data file')}" - elif data: - headline = f"{_plural(data, 'data file')} to fetch" - else: - headline = "Everything is in place" + if not need: + headline = (f"{_plural(data, 'data file')} to fetch" if data + else "Everything is in place") wrong = check["wrong_hash"] lines: list = [ _styled(headline, "title"), @@ -10147,6 +10235,13 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: "choose another folder in Options.") if wrong: lines.append("They are replaced, and the old copies are kept in a backup folder.") + misplaced = _tui_misplaced(state) if check is state.check else [] + if misplaced: + lines.insert(1, f"{_plural(len(misplaced), 'file')} " + f"{'is' if len(misplaced) == 1 else 'are'} in the wrong place, " + f"{'it' if len(misplaced) == 1 else 'they'} will be copied where the " + "emulators read " + f"{'it' if len(misplaced) == 1 else 'them'}.") if state.facts.get("writable") is False: lines.insert(1, "You cannot write to this folder: choose another one, or run " + ("the installer as an administrator." if state.os_type == "windows" @@ -10202,7 +10297,10 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: def _view_browse(state: TuiState, width: int, height: int) -> tuple: where = "Drives" if state.browse_dir is None else middle(str(state.browse_dir), width) after = [state.status] if state.status else [] - return ("Browse for a folder", + if state.browse_for == "from": + after.insert(0, "Choose the old card, a backup folder or a ZIP: its BIOS files " + "are copied, each one checked, nothing on it is changed.") + return ("Copy from" if state.browse_for == "from" else "Browse for a folder", _compose(state, width, height, [where], tui_rows(state), after), ("A/Enter Open", "B/Esc Back", "? Help")) @@ -10273,6 +10371,9 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: if state.status: lines.append(state.status) rows = tui_rows(state) + if width < 54: + rows = [row._replace(label="Copy from your old card or a folder") + if row.action == "fromcard" else row for row in rows] return (display_name(state.platform, state.manifest), _compose(state, width, height, lines, rows), ("A/Enter Install", "B/Esc Back", "Y/o Options", "? Help")) @@ -10357,6 +10458,8 @@ _STATE_WORDS = {"missing": "missing", "wrong_hash": "wrong copy"} def _pager_items(state: TuiState) -> list: """The text of a screen that scrolls, the same for its view and its keys.""" + if state.screen == "misplaced": + return [f"{event['dest']}: {event['reason']}" for event in _tui_misplaced(state)] if state.screen == "result": return tui_outcome(state)[1] if state.screen == "command": @@ -10381,6 +10484,11 @@ def _view_preview(state: TuiState, width: int, height: int) -> tuple: ("A/Enter Back", "Up/Down Scroll")) +def _view_misplaced(state: TuiState, width: int, height: int) -> tuple: + return ("Files in the wrong place", _pager(state, width, height, _pager_items(state)), + ("A/Enter Back", "Up/Down Scroll")) + + def _view_command(state: TuiState, width: int, height: int) -> tuple: return ("Command line", _pager(state, width, height, _pager_items(state)), ("A/Enter Back",)) @@ -10413,9 +10521,10 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple: lines.append(_bar(min(done / total, 1.0) if total else 1.0, width)) lines.append(f"{format_size(done)} of {format_size(total)}") window = [sample for sample in state.samples if sample[0] >= state.now - 5] - start, base = window[0] if window else (state.now, done) + fetched = done - state.local_done + start, base = window[0] if window else (state.now, fetched) seconds = state.now - start - speed = (done - base) / seconds if seconds >= 1 else 0 + speed = (fetched - base) / seconds if seconds >= 1 else 0 if speed > 0: lines.append(f"{format_size(int(speed))}/s, " f"{format_duration((total - done) / speed)} left") @@ -10544,7 +10653,7 @@ _TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = { "options": _view_options, "regions": _view_regions, "cores": _view_cores, "preview": _view_preview, "command": _view_command, "confirm": _view_confirm, "installing": _view_installing, "result": _view_result, "error": _view_error, - "quit": _view_quit, "cardroot": _view_cardroot, + "quit": _view_quit, "cardroot": _view_cardroot, "misplaced": _view_misplaced, } @@ -10665,10 +10774,6 @@ def _tui_passthrough(args: argparse.Namespace) -> list[tuple[str, str | None]]: flags.append((flag, str(Path(value).absolute()))) flags += [("--emulator", name) for name in args.emulator or []] flags += [("--from-pack", str(Path(pack).absolute())) for pack in args.from_pack or []] - # The handheld launchers start --tui --from the other card: without it here - # the runs downloaded what that card holds. - flags += [("--from", str(Path(folder).absolute())) - for folder in getattr(args, "from", None) or []] for flag in ("--every-board", "--standalone-copies", "--verbose"): if getattr(args, flag[2:].replace("-", "_")): flags.append((flag, None)) @@ -10683,6 +10788,7 @@ def tui_start( state.cli_systems = list(args.system or []) state.regions = resolve_regions(args.region) if args.region else [] state.cli_regions = list(state.regions) + state.from_folders = [Path(folder).absolute() for folder in getattr(args, "from") or []] state.cores = list(dict.fromkeys( token.strip() for value in args.core or [] for token in value.split(",") if token.strip())) diff --git a/install.sh b/install.sh index c46a1c1d..98cf7f82 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="163a216c1b09e8d788d4de6148194705813d4c6b8a956c84a70a267205f2c25d" +DEFAULT_INSTALL_SHA256="bcbde4c289b32c02ada78001b75274a87b125c1efd3d8fb1064d7f5482ef3997" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/fixtures/tui/options-120x40.txt b/tests/fixtures/tui/options-120x40.txt index c1ef19f0..b3b47fd2 100644 --- a/tests/fixtures/tui/options-120x40.txt +++ b/tests/fixtures/tui/options-120x40.txt @@ -8,6 +8,7 @@ Cores: all --core Parallel downloads: 8 --jobs BIOS folder: ~/.config/retroarch/system --dest + Copy from: none --from Preview the file list --dry-run Show the command line @@ -34,7 +35,6 @@ - A/Enter Open B/Esc Back Left/Right Set ? Help diff --git a/tests/fixtures/tui/options-40x12.txt b/tests/fixtures/tui/options-40x12.txt index dc4891b3..81190984 100644 --- a/tests/fixtures/tui/options-40x12.txt +++ b/tests/fixtures/tui/options-40x12.txt @@ -6,7 +6,7 @@ Cores: all Parallel downloads: 8 BIOS folder: ~/.config...ch/system + Copy from: none Preview the file list Show the command line - A Open B Back <> Set ? Help diff --git a/tests/fixtures/tui/options-40x15.txt b/tests/fixtures/tui/options-40x15.txt index b86d8de3..1508976a 100644 --- a/tests/fixtures/tui/options-40x15.txt +++ b/tests/fixtures/tui/options-40x15.txt @@ -6,10 +6,10 @@ Cores: all Parallel downloads: 8 BIOS folder: ~/.config...ch/system + Copy from: none Preview the file list Show the command line Sets --region - A Open B Back <> Set ? Help diff --git a/tests/fixtures/tui/options-80x24.txt b/tests/fixtures/tui/options-80x24.txt index 7ff142b9..041866a9 100644 --- a/tests/fixtures/tui/options-80x24.txt +++ b/tests/fixtures/tui/options-80x24.txt @@ -8,6 +8,7 @@ Cores: all --core Parallel downloads: 8 --jobs BIOS folder: ~/.config/retroarch/system --dest + Copy from: none --from Preview the file list --dry-run Show the command line @@ -20,5 +21,4 @@ - A/Enter Open B/Esc Back Left/Right Set ? Help diff --git a/tests/fixtures/tui/plan-120x40.txt b/tests/fixtures/tui/plan-120x40.txt index 50ecadac..b4fabf6b 100644 --- a/tests/fixtures/tui/plan-120x40.txt +++ b/tests/fixtures/tui/plan-120x40.txt @@ -9,6 +9,7 @@ > Install Choose systems + Copy the BIOS files from your old card or a folder Options Quit @@ -34,7 +35,6 @@ - A/Enter Install B/Esc Back Y/o Options ? Help diff --git a/tests/fixtures/tui/plan-40x12.txt b/tests/fixtures/tui/plan-40x12.txt index e6157c28..b0fbb0d5 100644 --- a/tests/fixtures/tui/plan-40x12.txt +++ b/tests/fixtures/tui/plan-40x12.txt @@ -4,9 +4,9 @@ 1 in place, 3 missing, 1 wrong copy BIOS folder: ~/.config/retroarch/system - Free space: 50.0 GB > Install Choose systems + Copy from your old card or a folder Options Quit A Install B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-40x15.txt b/tests/fixtures/tui/plan-40x15.txt index ccdc512e..94738418 100644 --- a/tests/fixtures/tui/plan-40x15.txt +++ b/tests/fixtures/tui/plan-40x15.txt @@ -7,9 +7,9 @@ Free space: 50.0 GB They are replaced, and the old copies are kept in a backup folder. - > Install Choose systems + Copy from your old card or a folder Options Quit A Install B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-80x24.txt b/tests/fixtures/tui/plan-80x24.txt index 9adc90f8..0e320fc2 100644 --- a/tests/fixtures/tui/plan-80x24.txt +++ b/tests/fixtures/tui/plan-80x24.txt @@ -9,6 +9,7 @@ > Install Choose systems + Copy the BIOS files from your old card or a folder Options Quit @@ -20,5 +21,4 @@ - A/Enter Install B/Esc Back Y/o Options ? Help diff --git a/tests/test_install_from.py b/tests/test_install_from.py index 34a592ef..056c3c8f 100644 --- a/tests/test_install_from.py +++ b/tests/test_install_from.py @@ -331,20 +331,30 @@ class EventsForAFolder(FromFolder): class TheInterfaceReadsAFoundEvent(unittest.TestCase): """The full-screen interface drives the engine through --events. A found event, which an install writes for a misplaced file even without - --from, leaves its screens as they were.""" + --from, is what the plan counts after a check and what the download + speed leaves out during an install. Elsewhere it changes nothing.""" FOUND = {"event": "found", "platform": "retroarch", "dest": "scph5501.bin", "folder": "/bios", "path": "/bios/bios/scph5501.bin", "member": "", "reason": "nested folder: it is at bios/bios/scph5501.bin"} - def test_every_run_ignores_it(self): - for run in ("check", "confirm", "preview", "install"): + def receive(self, run: str) -> tuple: + state = install.TuiState() + state.run, state.run_id, state.running = run, 3, True + before = copy.deepcopy(vars(state)) + self.assertEqual(install.tui_receive(state, ("event", 3, dict(self.FOUND))), []) + return state, before + + def test_a_check_counts_it_and_an_install_copies_it(self): + state, _before = self.receive("check") + self.assertEqual(state.found, [self.FOUND]) + state, _before = self.receive("install") + self.assertEqual(state.local_dests, {"scph5501.bin"}) + + def test_a_dry_run_ignores_it(self): + for run in ("confirm", "preview"): with self.subTest(run=run): - state = install.TuiState() - state.run, state.run_id, state.running = run, 3, True - before = copy.deepcopy(vars(state)) - self.assertEqual(install.tui_receive(state, ("event", 3, dict(self.FOUND))), - []) + state, before = self.receive(run) self.assertEqual(vars(state), before) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 9d207ee2..2bebdce9 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -75,6 +75,7 @@ def _args(**given) -> argparse.Namespace: no_input=False, dry_run=False, yes=False, quiet=False, events=False, restore=None, from_pack=None, manifest=None, tui=False, plain=False, ) + values["from"] = None values.update(given) return argparse.Namespace(**values) @@ -400,18 +401,22 @@ class Flow(unittest.TestCase): self.assertEqual(state.exit_code, 0) def test_every_run_reads_the_folders_given_with_from(self): - """--tui --from OLD: the check and the install both copy from OLD.""" + """--tui --from OLD: the check and the install both copy from OLD, + each folder named once.""" args = _args(**{"from": [Path("old-card"), Path("backup.zip")], "from_pack": [Path("pack.zip")]}) - flags = install._tui_passthrough(args) - self.assertIn(("--from", str(Path("old-card").absolute())), flags) - self.assertIn(("--from", str(Path("backup.zip").absolute())), flags) - self.assertIn(("--from-pack", str(Path("pack.zip").absolute())), flags) + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + started, _effects = install.tui_start(args, "linux") state = checked_state() - state.passthrough = flags + state.passthrough = started.passthrough + state.from_folders = started.from_folders for run in ("check", "install"): - self.assertIn(f"--from={Path('old-card').absolute()}", - install.tui_argv(state, run, "py", "i.py")) + argv = install.tui_argv(state, run, "py", "i.py") + for folder in ("old-card", "backup.zip"): + self.assertEqual(argv.count(f"--from={Path(folder).absolute()}"), 1, argv) + self.assertIn(f"--from-pack={Path('pack.zip').absolute()}", argv) def test_the_check_runs_with_the_command_line_choices(self): state = fresh_state() @@ -468,7 +473,7 @@ class Flow(unittest.TestCase): rows = install.tui_rows(state) self.assertEqual([row.right for row in rows], [ "--region", "--no-game-data", "--installed-cores", "--core", "--jobs", "--dest", - "--dry-run", ""]) + "--from", "--dry-run", ""]) press(state, "enter") self.assertEqual(state.screen, "regions") # Ticked in the order of preference, then moved up. @@ -512,7 +517,7 @@ class Flow(unittest.TestCase): def test_preview_is_a_dry_run(self): state = checked_state() press(state, "o") - state.focus["options"] = 6 + state.focus["options"] = 7 self.assertEqual(press(state, "enter"), [("run", "preview")]) self.assertEqual(install.tui_argv(state, "preview", "py", "i.py")[-2:], ["--dry-run", "--events"]) @@ -683,7 +688,7 @@ class Flow(unittest.TestCase): def test_events_of_an_abandoned_run_are_ignored(self): state = checked_state() press(state, "o") - state.focus["options"] = 6 + state.focus["options"] = 7 press(state, "enter") old = state.run_id self.assertEqual(press(state, "esc"), [("kill",)]) @@ -1590,6 +1595,121 @@ class WorkFolders(unittest.TestCase): self.assertFalse(work.exists()) +def found(dest: str, folder: str, reason: str = "") -> dict: + return {"event": "found", "platform": "retroarch", "dest": dest, "folder": folder, + "path": f"{folder}/{dest}", "member": "", "reason": reason} + + +class CopyFromAnOldCard(unittest.TestCase): + """--from in the interface: a row on the plan, the browser, the counts.""" + + def recheck(self, state: install.TuiState, events: list) -> None: + for event in [*FILES, *SYSTEMS, *events, CHECK]: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value={ + "free": 50 * 1024 ** 3, "writable": True, "cores": []}): + install.tui_receive(state, ("ended", state.run_id, 1, [])) + + def test_the_plan_offers_it_and_the_browser_adds_the_folder(self): + old = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, old, True) + (old / "bios").mkdir() + (old / "backup.zip").write_bytes(b"PK") + state = checked_state() + row = next(i for i, r in enumerate(install.tui_rows(state)) if r.action == "fromcard") + self.assertEqual(install.tui_rows(state)[row].label, + "Copy the BIOS files from your old card or a folder") + state.focus["plan"] = row + state.home = old + with mock.patch.object(install, "_mounted_cards", return_value=[]): + press(state, "enter") + self.assertEqual((state.screen, state.browse_for), ("browse", "from")) + labels = [r.label for r in install.tui_rows(state)] + self.assertEqual(labels, ["Use this folder", "..", "bios/", "backup.zip"]) + self.assertIn("Choose the old card", "\n".join(install.tui_view(state, 80, 24))) + effects = press(state, "enter") + self.assertEqual(effects, [("run", "check")]) + self.assertEqual(state.from_folders, [old]) + self.assertIn(f"--from={old}", install.tui_argv(state, "check", "py", "i.py")) + self.recheck(state, [found("scph5500.bin", str(old)), found("gba_bios.bin", str(old))]) + self.assertEqual(state.screen, "plan") + text = "".join("".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("".join(f"2 found in {old}, 2 to download (512.0 KB)".split()), text) + # A second folder from the options: --from twice. + press(state, "o") + state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state)) + if r.action == "from") + self.assertEqual(install.tui_rows(state)[state.focus["options"]].right, "--from") + with mock.patch.object(install, "_mounted_cards", return_value=[]): + press(state, "enter") + # It opens beside the first folder: the second card sits next to it. + self.assertEqual(state.browse_dir, old.parent) + install._browse_to(state, old) + state.focus["browse"] = 3 + self.assertEqual(press(state, "enter"), [("run", "check")]) + self.assertEqual(state.from_folders, [old, old / "backup.zip"]) + self.assertEqual([flag for flag in install.tui_argv(state, "check", "py", "i.py") + if flag.startswith("--from=")], + [f"--from={old}", f"--from={old / 'backup.zip'}"]) + + def test_left_takes_the_last_folder_back(self): + state = checked_state() + state.from_folders = [Path("/media/old")] + state.stack.append("options") + state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state)) + if r.action == "from") + self.assertEqual(press(state, "left"), [("run", "check")]) + self.assertEqual(state.from_folders, []) + + def test_the_command_line_folders_are_kept(self): + with mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "_mounted_cards", return_value=[]): + args = _args(platform="retroarch", dest=SYSTEM_DIR) + setattr(args, "from", [Path("/media/old")]) + state, _effects = install.tui_start(args, "linux") + self.assertEqual(state.from_folders, [Path("/media/old")]) + self.assertIn(("--from", "/media/old"), install.tui_flags(state)) + + def test_files_in_the_wrong_place_are_said_and_listed(self): + state = checked_state() + state.platform, state.dest = "retroarch", SYSTEM_DIR + install._start_check(state) + self.recheck(state, [found("scph5500.bin", str(SYSTEM_DIR), + "nested folder: bios/scph5500.bin")]) + text = " ".join(line.strip() for line in install.tui_view(state, 120, 40)) + self.assertIn("1 file is in the wrong place, it will be copied where the emulators " + "read it.", text) + row = next(i for i, r in enumerate(install.tui_rows(state)) if r.action == "misplaced") + state.focus["plan"] = row + press(state, "enter") + self.assertEqual(state.screen, "misplaced") + self.assertIn("scph5500.bin: nested folder: bios/scph5500.bin", + "\n".join(install.tui_view(state, 80, 24))) + press(state, "esc") + self.assertEqual(state.screen, "plan") + + def test_a_local_copy_is_no_download_speed(self): + state = checked_state() + press(state, "enter") + mega = 1024 * 1024 + events = [(9.0, {"event": "found", **found("big.bin", "/media/old")}), + (9.0, {"event": "download", "platform": "retroarch", "files": 2, + "bytes": 300 * mega}), + (10.0, {"event": "downloaded", "dest": "big.bin", "ok": True, "reason": "", + "size": 200 * mega, "files_done": 1, "files_total": 2, + "bytes_done": 200 * mega, "bytes_total": 300 * mega}), + (11.0, {"event": "progress", "files_done": 1, "files_total": 2, + "bytes_done": 201 * mega, "bytes_total": 300 * mega}), + (12.0, {"event": "progress", "files_done": 1, "files_total": 2, + "bytes_done": 202 * mega, "bytes_total": 300 * mega})] + for when, event in events: + state.now = when + install.tui_receive(state, ("event", state.run_id, event)) + text = "\n".join(install.tui_view(state, 80, 24)) + self.assertIn("1.0 MB/s, 1:38 left", text) + + class Crash(unittest.TestCase): """An unexpected error ends on one sentence and a log, never a traceback.""" @@ -1890,6 +2010,36 @@ class EndToEnd(unittest.TestCase): time.sleep(1) self.assertEqual(len(repo.installed()), on_disk) + def test_copy_from_an_old_card(self): + repo = self.repo() + old = Path(self.env["HOME"]) / "oldcard" + (old / "bios").mkdir(parents=True) + for name in ("scph5500.bin", "scph5501.bin", "gba_bios.bin"): + payload = FakeRepository.PAYLOADS[name][0] + (old / "bios" / name).write_bytes(payload) + term = self.start("--platform", "retroarch", "--dest", str(repo.dest)) + term.wait("5 files to download") + term.send(DOWN, DOWN, "\r") + term.wait("Choose the old card") + for _ in range(12): + if "> oldcard/" in term.text(): + break + term.send(DOWN) + term.send("\r") + term.wait("> Use this folder") + term.send("\r") + screen = term.wait("found in") + # The folder wraps at a slash: compared without the spaces. + self.assertIn("".join(f"3 found in {old}, 2 to download (8 B)".split()), + "".join(screen.split())) + # Checked again, the plan opens on Install. + term.send("\r") + term.wait("files installed.") + term.send("\r") + self.assertEqual(term.close(), 0) + self.assertEqual(len(repo.installed()), 5) + self.assertIn(f"--from {old}", term.after().replace("\n", " ")) + def test_ctrl_c_on_the_first_screen(self): self.repo() term = self.start() diff --git a/wiki/installer.md b/wiki/installer.md index a1d269aa..54d49a93 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -92,7 +92,12 @@ play, with the total to download under the list. **Options** sets the preferred regions, game data, the installed cores only, the cores, the parallel downloads and the folder. Each row names the option it sets, and **Show the command line** gives the same choice as one command for a script. **Preview the -file list** shows what would be downloaded and writes nothing. Back on the plan +file list** shows what would be downloaded and writes nothing. **Copy the BIOS +files from your old card or a folder** opens the folder browser, mounted cards +first, and adds [`--from`](#copy-from-an-old-card): the plan then says how many +files that folder holds and how many are left to download. Files the BIOS +folder holds at the wrong place are counted on the plan, and **Show the files +in the wrong place** lists each one with its reason. Back on the plan after a change of options, the folder is checked again, so its numbers are the exact ones for the new choice. Ticked systems get their exact count before the install starts.