feat: name the save folder and the pack on screen

The plan and the result now say how many files go to the save folder and
where, from a new saves event. With --from-pack they say the files come
from the pack, not a download. A stop asked for (Start+Select, SIGHUP)
ends on the plain outcome, without the log's last lines.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 22:34:24 +02:00
1 parent e003828e26
commit 3db8ddae2b
4 files changed
+104 -4

No files matched your search

+38 -3
View File
@@ -546,6 +546,11 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
# size of a file no copy was found for, or of no known size (a folder,
# a ZIP that would not open). member as in found, reason in words.
"unreadable": ("platform", "folder", "path", "member", "reason"),
# The files of the list read from the frontend's save directory, and
# where they go (<system> and <core> stand for a folder per system or
# per core), once that directory is known. Not written when it is not:
# the summary's skipped then counts them (no_save_directory).
"saves": ("platform", "files", "folder"),
"download": ("platform", "files", "bytes"),
"progress": ("files_done", "files_total", "bytes_done", "bytes_total"),
# Right after each progress event, and once when the files of a
@@ -4074,6 +4079,8 @@ def place_save_entries(
where += f"{os.sep}<core>"
print(f" {_plural(len(saved), 'file')} read from {display}'s save directory "
f"go to {where}")
EVENTS.emit("saves", platform=str(manifest.get("platform", "")),
files=len(saved), folder=where)
return files, []
print(f" {_plural(len(saved), 'file')} read from {display}'s save directory "
f"left out: {place}")
@@ -10081,6 +10088,8 @@ class TuiState:
self.idle_closed = False
# The check run's summary: what it left out and why.
self.check_summary: dict | None = None
# The saves event of the check: the files that go to the save folder.
self.saves: dict | None = None
# The confirm screen waits for A even when the plan is unchanged:
# it was opened by Space, which never installs by itself.
self.must_confirm: bool = False
@@ -10634,7 +10643,7 @@ def _start_run(state: TuiState, run: str) -> list[tuple]:
# dropped the --system the command line gave.
state.check, state.files, state.systems, state.reading = None, [], [], None
state.found, state.scanned, state.unreadable = [], [], []
state.check_summary = None
state.check_summary = state.saves = None
state.checked_flags = tui_flags(state)
elif run in ("confirm", "preview"):
state.dry, state.dry_files, state.dry_summary = None, [], None
@@ -11259,6 +11268,8 @@ def _take_event(state: TuiState, event: dict) -> None:
state.scanned.append(event)
elif kind == "unreadable" and state.run == "check":
state.unreadable.append(event)
elif kind == "saves" and state.run == "check":
state.saves = event
elif kind == "found" and state.run == "install":
state.local_dests[event["dest"]] = (event["folder"], event["reason"])
elif state.run == "check":
@@ -11590,6 +11601,11 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
elif need and fetch and offline:
headline = (f"{_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the "
"internet, which --offline does not use")
elif need and fetch and LOCAL.pack is not None:
# Nothing comes from the network: "to download" on a handheld with
# no Wi-Fi read as a failure on its way.
headline = (f"{_plural(fetch, 'file')} to install from the pack, "
f"{format_size(fetch_bytes)}")
elif need and fetch:
headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}"
elif need:
@@ -11614,6 +11630,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
lines.append("You cannot write to this folder: choose another one.")
lines.append(f"{check['present']} in place, {check['missing']} missing, "
f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}")
if check is state.check:
lines += [_styled(line, "path") for line in _saves_lines(state)]
found_in = _RETROARCH_CONFIGS.get(dest) if dest is not None else None
if found_in:
lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path"))
@@ -11651,6 +11669,18 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
return lines[:2] + offline_lines + lines[2:]
def _saves_lines(state: TuiState) -> list[str]:
"""Where the files read from the frontend's save directory go: a second
folder this run writes to, named before anything is written."""
if not state.saves or not state.saves.get("files"):
return []
name = display_name(state.platform) if state.platform else "the frontend"
count = state.saves["files"]
return [f"{_plural(count, 'file')} read from {name}'s save directory "
f"{'goes' if count == 1 else 'go'} to "
f"{_short_path(Path(state.saves['folder']), state.home)}"]
def _unplaced_lines(summary: dict | None, state: TuiState) -> list[str]:
"""The files read from the frontend's save directory that the run left
out, that directory being unknown: said, never silent."""
@@ -12102,7 +12132,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
head = f"The installer stopped with code {state.code}."
elif installed:
title = "Done"
head = f"{_plural(installed, 'file')} installed."
source = " from the pack" if LOCAL.pack is not None else ""
head = f"{_plural(installed, 'file')} installed{source}."
if up_to_date:
head += f" {_plural(up_to_date, 'file')} already in place."
else:
@@ -12113,6 +12144,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
if sources:
lines.append(sources)
lines.append(f"BIOS folder: {state.dest}")
if installed + up_to_date:
lines += _saves_lines(state)
if space:
lines.append("Free some space, or run again and choose another folder.")
causes: dict[str, list[str]] = {}
@@ -12131,7 +12164,9 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
shown += 1
if shown > 20:
lines.append(f" ... and {shown - 20} more")
if not summary and not space:
if not summary and not space and state.code != 130:
# A stop asked for (Ctrl-C, Start+Select, the window closed) is said
# by the line above: the log's last lines are for a run that died.
lines += [f" {line}" for line in state.tail[-8:]]
run = Invocation()
run.os_type = state.os_type
+29
View File
@@ -21,6 +21,7 @@
"declared",
"found",
"unreadable",
"saves",
"download",
"progress",
"transfer",
@@ -64,6 +65,9 @@
{
"$ref": "#/$defs/unreadable"
},
{
"$ref": "#/$defs/saves"
},
{
"$ref": "#/$defs/download"
},
@@ -486,6 +490,31 @@
}
}
},
"saves": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"platform",
"files",
"folder"
],
"properties": {
"event": {
"const": "saves"
},
"platform": {
"type": "string"
},
"files": {
"type": "integer",
"minimum": 0
},
"folder": {
"type": "string"
}
}
},
"download": {
"type": "object",
"additionalProperties": false,
+36 -1
View File
@@ -2205,6 +2205,33 @@ class AuditRoundThree(unittest.TestCase):
# P2-1: a card too small for the download.
def test_a_stop_asked_for_says_only_the_outcome(self):
"""Start+Select (SIGTERM) or SIGHUP stops the child before it writes
a summary: the farewell then listed the log's last lines."""
state = checked_state()
state.download = {"files": 50}
state.finished = [("scph5501.bin", True, "")]
state.tail = [" [2/50] scph5501.bin ok",
" Stopping. Files already installed are kept."]
for code, shown in ((130, False), (1, True)):
with self.subTest(code=code):
state.code = code
_title, lines = install.tui_outcome(state)
self.assertEqual(any("[2/50]" in line for line in lines), shown, lines)
def test_a_pack_installs_from_the_pack_not_the_network(self):
"""With --from-pack and no Wi-Fi, the plan said "to download", which
on a handheld with no network read as a failure on its way."""
state = checked_state()
with mock.patch.object(install.LOCAL, "pack", object()):
headline = str(install.tui_view(state, 80, 24)[0])
plan = "\n".join(str(line) for line in install.tui_view(state, 80, 24))
state.summary = {"installed": 3, "failed": [], "up_to_date": 0}
title, lines = install.tui_outcome(state)
self.assertIn("to install from the pack", plan, headline)
self.assertNotIn("to download", plan)
self.assertEqual(lines[0], "3 files installed from the pack.")
def test_p2_1_another_folder_comes_first_when_space_is_short(self):
state = checked_state({"free": 1024, "writable": True, "cores": []})
self.assertEqual([row.label for row in install.tui_rows(state)[:2]],
@@ -2974,8 +3001,13 @@ class EndToEnd(unittest.TestCase):
term.send("\r")
screen = term.wait("files to download")
self.assertIn("6 files to download", screen)
# The second folder the run writes to, named before
# anything is written, and again on the result.
self.assertIn("1 file read from RetroArch's save directory goes to "
"~/MySaves", screen)
term.send("\r")
term.wait("6 files installed.")
screen = term.wait("6 files installed.")
self.assertIn("save directory goes to ~/MySaves", screen)
term.send("\r")
self.assertEqual(term.close(), 0)
results.append(written(repo, saves))
@@ -3074,6 +3106,9 @@ class EndToEnd(unittest.TestCase):
after = term.after()
self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", after)
self.assertNotIn("Nothing was installed", after)
# The plain outcome only: the log's last lines are for a run that died.
self.assertIsNone(re.search(r"\[\d+/6\]", after), after)
self.assertNotIn("Stopping.", after)
time.sleep(1)
self.assertEqual(len(repo.installed()), on_disk)
+1
View File
@@ -1304,6 +1304,7 @@ other schemas under `api/v1/schemas/`.
| `declared` | `platform`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `dests` | Right after `check`, the same counts over the files the platform's own list names (`cores: null`), and their `dests` in the order of the `file` events. A setup that checks its own list reads its answer here, with or without `--declared-only` |
| `found` | `platform`, `dest`, `folder`, `path`, `member`, `reason` | Each file to install that this machine already holds, after `check` and before anything is written, in `--check` and `--dry-run` too. `folder` is the `--from` folder as given, or the `bios_dir` of the check when the BIOS folder holds the file at another place, and then `reason` says where in words (`nested folder: ...` for a misplaced file, `the same file is at ...` for one the list installs there too). `path` is the file, and `member` the ZIP member holding the bytes when `path` is an archive, an empty string otherwise. Its copy is reported by a `downloaded` event |
| `unreadable` | `platform`, `folder`, `path`, `member`, `reason` | Each file in a `--from` folder that could not be read and may be one the run still needs, right after the `found` events: its size is that of a file no copy was found for, or unknown (a folder that could not be listed, a ZIP that would not open). `member` names the ZIP member, or is empty. `reason` says why in words (`Permission denied`, `it is encrypted, a password is needed`) |
| `saves` | `platform`, `files`, `folder` | After `detect` and before `checking`, when the list holds files read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `<system>` and `<core>` stand for one folder per system or per core. When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) |
| `download` | `platform`, `files`, `bytes` | Before the downloads start. Counts the files copied from this machine too |
| `progress` | `files_done`, `files_total`, `bytes_done`, `bytes_total` | At most every half second while bytes arrive |
| `transfer` | `bytes_local`, `bytes_network` | Right after each `progress` event, and once when the files of a `download` event are done. It splits `bytes_done`: `bytes_local` came from this machine (a copy found by `--from` or in the BIOS folder, the pack, a partial copy resumed), `bytes_network` from the network. A download speed is `bytes_network` over the time elapsed |