chore: merge old card copy in the interface

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 17:11:15 +02:00
commit 5d8d7ce371
14 files changed
+326 -55

No files matched your search

+1 -1
View File
@@ -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 }
+131 -25
View File
@@ -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()))
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+18 -8
View File
@@ -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)
+161 -11
View File
@@ -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()
+6 -1
View File
@@ -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.