diff --git a/install.py b/install.py index cc258bf0..74148f58 100644 --- a/install.py +++ b/install.py @@ -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 ( and 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}" 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 diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index 13debfab..a9cb3fc5 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -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, diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 8233fc59..cccddfa2 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -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) diff --git a/wiki/installer.md b/wiki/installer.md index a3efbc96..14eac483 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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 `` and `` 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 |