From 0fa4552daeb3f022db317589d57cf42255076749 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:37:24 +0100 Subject: [PATCH] fix: refuse only the axis a list lacks The MinUI family's lists record cores and regions and no system: every narrowing was refused with a remedy that fetched the same list. Each option is now refused by the axis missing, with what works, and a pack names its platform's list only when that list records the axis. Line mode names what c chooses and skips a question with one answer, and Options leaves out parallel downloads when nothing is downloaded. --- install.ps1 | 2 +- install.py | 136 ++++++++++++++++++++++-------- install.sh | 2 +- install/launchers/index.json | 16 ++-- schemas/pack-manifest.schema.json | 6 ++ scripts/generate_pack.py | 18 +++- tests/test_install_cores.py | 38 ++++++--- tests/test_install_selection.py | 10 ++- tests/test_install_tui.py | 135 ++++++++++++++++++++++++++++- wiki/installer.md | 14 ++- 10 files changed, 314 insertions(+), 63 deletions(-) diff --git a/install.ps1 b/install.ps1 index 1b6d71810..ac037e590 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d" + $defaultInstallSha256 = "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index ae78496b2..38b6d8dfc 100644 --- a/install.py +++ b/install.py @@ -4000,9 +4000,31 @@ def _chosen(entry: dict, wanted: set[str]) -> bool: return bool(wanted & set(systems)) +def recorded_axes(files: list[dict], omitted: list[dict] = ()) -> frozenset: + """What a file list lets --system, --core and --region narrow by: its + systems, its cores, its regions. A list from an older release records + none. The MinUI family's records cores and regions and no system: all + three were refused on it, by a remedy that fetched the same list + (tui_audit_11 P2-1).""" + entries = [*files, *omitted] + systems = any("systems" in entry for entry in entries) + if not systems and not any("regions" in entry for entry in entries): + # Systems and regions came in one release: a list with neither is + # older, its cores read by nothing that narrows. + return frozenset() + # A current list records the regions of the files a region selects, and + # none when it has no such file: --region then keeps every file, as the + # pack built with it does. + axes = {"regions", *(["systems"] if systems else [])} + if any(copy.get("cores") for entry in files + for copy in [entry, *entry.get("alternatives", []), *entry.get("slot_choices", [])]): + axes.add("cores") + return frozenset(axes) + + def records_selection(files: list[dict]) -> bool: - """Whether a manifest carries what narrowing reads. Older ones do not.""" - return any("systems" in entry for entry in files) + """Whether a manifest records anything narrowing reads. Older ones do not.""" + return bool(recorded_axes(files)) def available_choices(files: list[dict], omitted: list[dict]) -> dict[str, list[str]]: @@ -7235,9 +7257,12 @@ def _prompt_custom_selection( print(f" Enter) install {whole}") if installed is not None and current != INSTALLED_ONLY: print(f" i) only what the installed cores read: {_sized(installed[0])}") + # What c asks, named: "systems, cores and regions" was offered with + # a pack that records systems only (tui_audit_11 P3-10). + axes = narrowing_words(files, omitted) if choosable else "" + choosable = bool(axes) if choosable: - print(" c) choose systems, cores and regions" - + (" again" if current is not given else "")) + print(f" c) choose {axes}" + (" again" if current is not given else "")) # The screen's "Copy the BIOS files from your old card or a folder". copying = not dry_run if copying: @@ -7312,11 +7337,27 @@ def _print_menu_head( print(f" {line}") +def narrowing_words(files: list[dict], omitted: list[dict]) -> str: + """What c chooses among for these files, in words, "" for nothing: a + question with one answer is not asked (tui_audit_11 P3-10).""" + axes = recorded_axes(files, omitted) + choices = available_choices(files, omitted) + named = [word for word, axis, enough in ( + ("systems", "systems", len(listed_systems(files)) > 1), + ("cores", "cores", len(choices["cores"]) > 1), + ("regions", "regions", bool(choices["regions"]))) if axis in axes and enough] + return f"{', '.join(named[:-1])} and {named[-1]}" if len(named) > 1 else "".join(named) + + def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | None, bios_path: Path | None) -> tuple[list[str], list[str], list[str]]: """Systems, cores and regions asked one after the other, the systems - titled and ordered as the screen's Choose systems lists them.""" - systems_listed = systems_in_order(listed_systems(files), files, manifest, bios_path) + titled and ordered as the screen's Choose systems lists them. A question + with one answer is skipped: "Cores: 1) azahar" was asked after the 3DS + alone was chosen.""" + listed = listed_systems(files) if "systems" in recorded_axes(files, omitted) else [] + systems_listed = (systems_in_order(listed, files, manifest, bios_path) + if len(listed) > 1 else []) if OTHER_SYSTEMS in systems_listed: print(f"\n'{OTHER_TITLE}' holds what cores load for systems this platform") print("does not list, such as game engines and extra computers.") @@ -7332,7 +7373,7 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N cores: list[str] = [] core_names = available_choices(kept, kept_omitted)["cores"] - if core_names: + 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.") cores = _pick("Cores:", [ @@ -7567,6 +7608,28 @@ def _older_list_advice(plat: str = "") -> str: "installer again to fetch the current one.") +def _narrowing_advice(plat: str, axis: str, flag: str, recorded: frozenset, + pack_alone: bool) -> str: + """What to do when a list or a pack records no axis: only a remedy + that changes the outcome. The pack's remedy named install/minui.json, + refused the same way, and the list's fetched the same list again.""" + works = [name for name, field in (("--system", "systems"), ("--core", "cores"), + ("--region", "regions")) if field in recorded] + rest = f"Run it without {flag}" + (f", or narrow with {' and '.join(works)}" + if works else "") + "." + if pack_alone: + listed = LOCAL.pack.list_records if LOCAL.pack is not None else None + if listed is not None and axis not in listed: + # The pack says its platform's own list lacks the axis too. + return f" {display_name(plat)}'s own file list records no {axis} either. {rest}" + when = "which records them" if listed is not None else f"when that list records {axis}" + return f" Add --manifest FILE, a copy of install/{plat}.json, {when}. {rest}" + if recorded: + # A current list: the platform has no such axis. + return f" {rest}" + return _older_list_advice(plat) + + def _narrow_to_emulators( names: list[str], manifest: dict, plat: str, files: list[dict], omitted: list[dict], @@ -7613,25 +7676,26 @@ def _requested_narrowing( """The systems, cores and regions named on the command line, checked.""" if not (args.system or args.core or args.region): return None - if not records_selection(files): - # Without the fields, a region filter would match nothing and install - # everything: refused rather than ignored. - print( - f"Error: the {plat} file list records no systems or regions, so " - "--system, --core and --region cannot narrow it.", - file=sys.stderr, - ) - print(_older_list_advice(plat), file=sys.stderr) - sys.exit(1) - if LOCAL.pack is not None and LOCAL.manifest is None: + pack_alone = LOCAL.pack is not None and LOCAL.manifest is None + recorded = recorded_axes(files, omitted) + if pack_alone: # A pack records the systems of its files, not their cores or # regions: either filter would keep everything, and is refused. - for flag, given in (("--core", args.core), ("--region", args.region)): - if given: - print(f"Error: {LOCAL.pack.name} records the systems of its files, not " - f"what {flag} narrows by.", file=sys.stderr) - print(_older_list_advice(plat), file=sys.stderr) - sys.exit(1) + recorded = recorded & {"systems"} + asked = [(flag, axis) for flag, given, axis in ( + ("--system", args.system, "systems"), ("--core", args.core, "cores"), + ("--region", args.region, "regions")) if given] + for flag, axis in asked: + if axis in recorded: + continue + # Without the field, a filter would match nothing and install + # everything: refused rather than ignored, by the axis the list + # lacks, with what works on it. + source = LOCAL.pack.name if pack_alone else f"the {plat} file list" + print(f"Error: {source} records no {axis}, so {flag} cannot narrow it.", + file=sys.stderr) + print(_narrowing_advice(plat, axis, flag, recorded, pack_alone), file=sys.stderr) + sys.exit(1) choices = available_choices(files, omitted) try: systems = resolve_choices( @@ -7780,6 +7844,10 @@ class PackSource: self.members: dict[str, tuple[zipfile.ZipFile, zipfile.ZipInfo]] = {} # The system titles and names the pack's list records, if any. self.names: dict[str, dict] = {} + # What its platform's install list narrows by (systems, cores, + # regions), None for a pack that does not say: the remedy for an + # option the pack cannot apply names that list only when it can. + self.list_records: frozenset | None = None try: for path in self.files: archive = zipfile.ZipFile(path) @@ -7867,6 +7935,9 @@ class PackSource: # A pack is a file anyone can hand over: an id or a name holding a # control character would reach the terminal as an escape, and is # dropped. + records = data.get("list_records") + if isinstance(records, list) and all(isinstance(axis, str) for axis in records): + self.list_records = frozenset(records) & {"systems", "cores", "regions"} for key in ("system_titles", "system_names"): held = data.get(key) if isinstance(held, dict): @@ -10260,9 +10331,9 @@ def _read_from_folder(label: str) -> FolderIndex: return index -def _refuse_closed(dest: Path, named: str, then: str = "") -> None: +def _refuse_closed(dest: Path, named: str) -> None: """End the run, with its name, on a folder above dest this user cannot - open. then is a line said after the error, which it continues.""" + open.""" closed = closed_folder(dest) if closed is None: return @@ -10271,8 +10342,6 @@ def _refuse_closed(dest: Path, named: str, then: str = "") -> None: print(f"Error: {screen_text(message)}. Give this user access to it, run as " f"its owner, or choose another folder than the one {named}.", file=sys.stderr) - if then: - print(f" {then}", file=sys.stderr) sys.exit(1) @@ -11478,9 +11547,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: named = (f"used by default, since {display_name(name)} was not found" if path in assumed_dests else f"{found_in[0]} names" if found_in else "detected here") - _refuse_closed(path, f"{named} (name another with --dest)", - "Nothing was installed. Name one platform with --platform " - "to install it alone." if len(platforms) > 1 else "") + # One setup by now: two found without questions stopped above. + _refuse_closed(path, f"{named} (name another with --dest)") total_downloaded = 0 total_up_to_date = 0 @@ -13221,7 +13289,7 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: *([TuiRow("install", "Install anyway")] if rows else [])] # --system given: the choice stays offered, the list widened when it is # opened. With one system named it was neither shown nor changeable. - if records_selection((state.manifest or {}).get("files", [])) and ( + if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and ( len(state.systems) > 1 or state.cli_systems): rows.append(TuiRow("systems", "Choose systems")) rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder")) @@ -13341,8 +13409,10 @@ def _option_rows(state: TuiState) -> list[TuiRow]: # The interface said "Name it with --save-dir" and had no row for it. rows.append(TuiRow("savedir", f"Save folder: {_save_folder_words(state)}", "--save-dir", style="path")) + if not LOCAL.offline: + # From a pack, or offline, nothing is downloaded (tui_audit_11 P3-16). + rows.append(TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs")) 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", style="path"), diff --git a/install.sh b/install.sh index 8bb0d655b..28022ef1d 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="f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d" +DEFAULT_INSTALL_SHA256="00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 7912945eb..250dd18c8 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": 790778, - "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", + "size": 794767, + "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 843327, - "sha256": "edf0948281063d1b9abf271f778a4486adf83b18924965eff68308e1540f25ab" + "size": 847316, + "sha256": "56cac10af6b7afaac0d1b3c0964d54caeabda46e75d35580dcedda264e2daac8" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 790778, - "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", + "size": 794767, + "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 790778, - "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", + "size": 794767, + "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", "executable": false }, { diff --git a/schemas/pack-manifest.schema.json b/schemas/pack-manifest.schema.json index e836ccd33..c2c27eb96 100644 --- a/schemas/pack-manifest.schema.json +++ b/schemas/pack-manifest.schema.json @@ -43,6 +43,12 @@ "system_names": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } + }, + "list_records": { + "description": "What the platform's install list narrows by: the installer names that list as the remedy for --core or --region given with the pack alone only when it records them.", + "type": "array", + "uniqueItems": true, + "items": { "enum": ["systems", "cores", "regions"] } } } } diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 69b057a27..e68e1bbf6 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -4759,11 +4759,25 @@ def pack_systems(install_manifest: dict) -> dict: ("the file list comes from an older release") and offered no Choose systems, the route of a handheld without Wi-Fi. """ + files = install_manifest.get("files", []) + entries = [*files, *install_manifest.get("omitted_files", [])] + # What the install list narrows by, the installer's recorded_axes: with + # the pack alone it names that list as the way to --core and --region + # only when the list records them. It named install/misterfpga.json for + # --core, a list that records no core. + records = {"systems"} if any("systems" in entry for entry in entries) else set() + if any(copy.get("cores") for entry in files + for copy in [entry, *entry.get("alternatives", []), + *entry.get("slot_choices", [])]): + records.add("cores") + if records or any("regions" in entry for entry in entries): + records.add("regions") return { "files": {entry["dest"]: list(entry.get("systems") or []) - for entry in install_manifest.get("files", []) if "systems" in entry}, + for entry in files if "systems" in entry}, "system_titles": dict(install_manifest.get("system_titles") or {}), "system_names": dict(install_manifest.get("system_names") or {}), + "list_records": sorted(records), } @@ -4779,6 +4793,8 @@ def annotate_pack_systems(manifest: dict, systems: dict | None) -> None: for key in ("system_titles", "system_names"): if systems.get(key): manifest[key] = systems[key] + if "list_records" in systems: + manifest["list_records"] = systems["list_records"] def inject_manifest(zip_path: str, manifest: dict) -> None: diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index edd7c07df..beb738b11 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -370,18 +370,28 @@ class RefusedBeforeAnyInstall(unittest.TestCase): (tmp / "retroarch.cfg").write_text("") (tmp / "cores").mkdir() (tmp / "cores" / "mgba_libretro.so").write_bytes(b"") - with mock.patch.object(install, "detect_os", return_value="linux"), \ - mock.patch.object(install, "detect_frontends", return_value=[]), \ - mock.patch.object(install, "detect_platforms", return_value=detected), \ - mock.patch.object(install, "fetch_manifest") as fetch, \ - mock.patch.object(sys, "argv", ["install.py", "--installed-cores", "--no-input"]), \ - contextlib.redirect_stdout(io.StringIO()), \ - contextlib.redirect_stderr(io.StringIO()) as err: - with self.assertRaises(SystemExit) as stop: - install.main() - self.assertEqual(stop.exception.code, 1) - self.assertIn("batocera ships its cores", err.getvalue()) - fetch.assert_not_called() + # Two setups found stop a run without questions before either is + # read (code_audit_18 P2-3): the refusal is checked with Batocera + # named, and with both found nothing is fetched either. + for argv, said in ( + (["--platform", "batocera", "--dest", str(tmp / "bios")], + "batocera ships its cores"), + ([], "2 setups were found here")): + with self.subTest(argv=argv), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=detected), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "fetch_manifest") as fetch, \ + mock.patch.object(sys, "argv", ["install.py", "--installed-cores", + "--no-input", *argv]), \ + contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as stop: + install.main() + self.assertEqual(stop.exception.code, 1) + self.assertIn(said, err.getvalue()) + fetch.assert_not_called() class AnUnknownTargetStopsTheRunFirst(unittest.TestCase): @@ -393,12 +403,14 @@ class AnUnknownTargetStopsTheRunFirst(unittest.TestCase): self.addCleanup(shutil.rmtree, tmp, True) detected = [("emudeck", tmp / "bios"), ("retroarch", tmp / "system")] lists = {"emudeck": {"steamos": {"cores": []}}, "retroarch": {"x86_64": {"cores": []}}} + # Listed for both platforms: a listing reads every list found. with mock.patch.object(install, "detect_os", return_value="linux"), \ mock.patch.object(install, "detect_frontends", return_value=[]), \ mock.patch.object(install, "detect_platforms", return_value=detected), \ mock.patch.object(install, "fetch_targets", side_effect=lists.get), \ mock.patch.object(install, "fetch_manifest") as fetch, \ - mock.patch.object(sys, "argv", ["install.py", "--target", "steamos", "--no-input"]), \ + mock.patch.object(sys, "argv", ["install.py", "--target", "steamos", + "--list-systems", "--no-input"]), \ contextlib.redirect_stdout(io.StringIO()), \ contextlib.redirect_stderr(io.StringIO()) as err: with self.assertRaises(SystemExit) as stop: diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 142ed304a..69423b294 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -612,7 +612,8 @@ class TheOneLinerNarrows(unittest.TestCase): with FakeRepository(selection=False) as repo: proc = repo.run("--region", "us") self.assertEqual(proc.returncode, 1) - self.assertIn("records no systems or regions", proc.stderr) + self.assertIn("records no regions, so --region cannot narrow it", proc.stderr) + self.assertIn("older release", proc.stderr) self.assertEqual(repo.installed(), []) def test_a_pinned_ref_is_named_as_the_way_out(self): @@ -675,18 +676,19 @@ class TheOneLinerNarrows(unittest.TestCase): @unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal") def test_the_custom_mode_at_a_terminal(self): - """Typed answers: customise, PlayStation only, no core, US first.""" + """Typed answers: customise, PlayStation only, US first. Its one + core is not asked about (tui_audit_11 P3-10).""" with FakeRepository() as repo: command = " ".join(shlex.quote(part) for part in repo.command()) proc = subprocess.run( ["script", "-qec", command, "/dev/null"], - input="c\n2\n\n2\n\n", env=repo.env(), capture_output=True, + input="c\n2\n2\n\n", env=repo.env(), capture_output=True, text=True, timeout=60, check=False, ) out = proc.stdout self.assertEqual(proc.returncode, 0, out + proc.stderr) self.assertIn("choose systems, cores and regions", out) - self.assertIn("pcsx_rearmed", out) + self.assertNotIn("Cores:", out) self.assertIn("--system sony-playstation --region north-america", out) self.assertEqual(repo.installed(), ["pcsx/extra.bin", "scph5501.bin"]) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index a2c15f480..82632ca43 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -7432,7 +7432,7 @@ class AuditSeventeenAndTen(unittest.TestCase): with contextlib.redirect_stderr(err), self.assertRaises(SystemExit): install._requested_narrowing(_args(system=["nintendo-gba"]), "retroarch", install.LOCAL.pack.manifest("retroarch")["files"], []) - self.assertIn("add --manifest FILE, a copy of install/retroarch.json", err.getvalue()) + self.assertIn("Add --manifest FILE, a copy of install/retroarch.json", err.getvalue()) self.assertNotIn("older release", err.getvalue()) def test_a_pack_cannot_write_escapes_through_its_system_names(self): @@ -8660,6 +8660,139 @@ class AuditEighteenAndEleven(unittest.TestCase): ["Onion SD card (ONION, no BIOS folder)", "Onion SD card (OLDONION, 6 BIOS files)"]) + # tui_audit_11 P2-1: a list or a pack that records some axes only. + + def minui(self, plat: str = "minui", systems: bool = False, cores: bool = True, + regions: bool = True) -> tuple[Path, Path, dict]: + """A list of plat's shape (the MinUI family records cores and regions, + no system), the pack built from it and the files' payloads.""" + from test_install_offline import GBA, JP, US # noqa: PLC0415 + + payloads = {"Bios/MGBA/gba_bios.bin": (GBA, "nintendo-gba", "mgba", None), + "Bios/PS/scph5501.bin": (US, "sony-playstation", "pcsx_rearmed", + "north-america"), + "Bios/PS/scph5500.bin": (JP, "sony-playstation", "pcsx_rearmed", "japan")} + files = [] + for dest, (data, system, core, region) in payloads.items(): + entry = {"dest": dest, "sha1": hashlib.sha1(data).hexdigest(), "size": len(data), + "repo_path": f"bios/{dest}", "cores": [core] if cores else None} + if systems: + entry["systems"] = [system] + if regions and region: + entry.update(regions=[region], region_groups=["ps"]) + files.append(entry) + listing = self.root / f"{plat}.json" + install_list = {"manifest_version": 2, "platform": plat, "files": files} + listing.write_text(json.dumps(install_list)) + sys.path.insert(0, str(REPO_ROOT / "scripts")) + self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts")) + import generate_pack # noqa: PLC0415 + + pack_list = {"files": [{"path": dest, "sha1": entry["sha1"], "size": entry["size"]} + for dest, entry in zip(payloads, files)]} + generate_pack.annotate_pack_systems(pack_list, generate_pack.pack_systems(install_list)) + name = {"minui": "MinUI", "misterfpga": "MiSTer_FPGA"}[plat] + pack = self.root / f"{name}_v1_BIOS_Pack.zip" + with zipfile.ZipFile(pack, "w") as archive: + for dest, (data, *_rest) in payloads.items(): + archive.writestr(dest, data) + archive.writestr("manifest.json", json.dumps(pack_list)) + return listing, pack, pack_list + + def narrowed(self, plat: str, **given) -> tuple[int, str, list]: + """(exit code, what was said, the files kept) of a --dry-run narrowed + as given.""" + dest = self.root / "dest" + if not dest.exists(): + make_card(dest, "MinUI") + stream = io.StringIO() + code = 0 + with mock.patch.object(install.EVENTS, "stream", stream), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()) as err: + try: + install._install(_args(platform=plat, dest=dest, dry_run=True, events=True, + no_input=True, declared_only=False, **given), + io.StringIO()) + except SystemExit as stop: + code = stop.code + finally: + install.LOCAL.reset() + kept = [json.loads(line)["dest"] for line in stream.getvalue().splitlines() + if '"event": "file"' in line] + return code, out.getvalue() + err.getvalue(), kept + + def test_a_list_without_systems_narrows_by_what_it_records(self): + """tui_audit_11 P2-1: install/minui.json records cores and regions, + no system, and all three options were refused with a remedy that + fetched the same list.""" + listing, _pack, _listed = self.minui() + code, text, kept = self.narrowed("minui", manifest=listing, core=["mgba"]) + self.assertEqual(code, 0, text) + self.assertEqual(kept, ["Bios/MGBA/gba_bios.bin"]) + code, text, kept = self.narrowed("minui", manifest=listing, region=["us"]) + self.assertEqual(code, 0, text) + self.assertNotIn("Bios/PS/scph5500.bin", kept) + code, text, _kept = self.narrowed("minui", manifest=listing, + system=["nintendo-gba"]) + self.assertEqual(code, 1) + self.assertIn("records no systems, so --system cannot narrow it", text) + self.assertIn("Run it without --system, or narrow with --core and --region", text) + self.assertNotIn("one-line installer", text) + # The remedy followed: what it names works. + code, text, _kept = self.narrowed("minui", manifest=listing, core=["mgba"], + region=["us"]) + self.assertEqual(code, 0, text) + + def test_a_pack_names_its_list_only_when_that_list_records_it(self): + """tui_audit_11 P2-1: a MinUI pack sent --core to install/minui.json, + refused the same way, and a MiSTer pack to install/misterfpga.json, + which records no core. The pack now records what its list narrows + by, and each remedy changes the outcome.""" + listing, pack, listed = self.minui() + self.assertEqual(listed["list_records"], ["cores", "regions"]) + code, text, _kept = self.narrowed("minui", from_pack=[pack], core=["mgba"]) + self.assertEqual(code, 1) + self.assertIn("Add --manifest FILE, a copy of install/minui.json, which records them", + text) + code, text, kept = self.narrowed("minui", from_pack=[pack], manifest=listing, + core=["mgba"]) + self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) + mister_list, mister, listed = self.minui("misterfpga", systems=True, cores=False, + regions=False) + self.assertEqual(listed["list_records"], ["regions", "systems"]) + code, text, _kept = self.narrowed("misterfpga", from_pack=[mister], core=["x"]) + self.assertEqual(code, 1) + self.assertIn("MiSTer FPGA's own file list records no cores either. Run it without " + "--core, or narrow with --system.", text) + self.assertNotIn("--manifest", text) + code, text, kept = self.narrowed("misterfpga", from_pack=[mister], + system=["nintendo-gba"]) + self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) + self.assertTrue(mister_list.exists()) + + def test_line_mode_offers_what_can_be_chosen(self): + """tui_audit_11 P3-10: "c) choose systems, cores and regions" with a + pack that records systems only, and "Cores: 1) azahar" asked with + one answer.""" + _listing, _pack, listed = self.minui("misterfpga", systems=True, cores=False, + regions=False) + files = [{"dest": f["path"], "sha1": f["sha1"], "size": f["size"], + "repo_path": "x", "systems": f["systems"]} for f in listed["files"]] + self.assertEqual(install.narrowing_words(files, []), "systems") + listing, _pack, _listed = self.minui() + minui = json.loads(listing.read_text())["files"] + self.assertEqual(install.narrowing_words(minui, []), "cores and regions") + one_core = [{**entry, "systems": ["nintendo-3ds"], "cores": ["azahar"]} + for entry in minui[:1]] + with mock.patch.object(install, "_ask", side_effect=["", ""]) as asked, \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._choose_narrowing(one_core, [], {}, None) + self.assertNotIn("Cores:", out.getvalue()) + self.assertEqual(asked.call_count, 0) + def test_the_card_marker_table_is_written_from_the_rules(self): """code_audit_18 P3-6, tui_audit_11 P2-2: the table left out the SHARE drives. It is generated: every card rule and SHARE drive file diff --git a/wiki/installer.md b/wiki/installer.md index 782bd92ad..779cc430f 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -615,7 +615,9 @@ It keeps what those cores read and drops the systems none of them emulates, the rule `--installed-cores` applies. `c` walks through three numbered lists, each answered with numbers such as -`1,4,7-9`, or Enter to keep everything on that axis. +`1,4,7-9`, or Enter to keep everything on that axis. The letter names the +lists it asks (`c) choose systems` with a MiSTer pack), and a list with one +answer is not asked. - **Systems.** The consoles and computers the platform declares, titled and ordered as the full screen's Choose systems lists them, the id `--system` @@ -637,6 +639,16 @@ without questions, every other option of the run in it, for instance --system sony-playstation --region north-america`. Enter then installs, and `c` chooses again. +An option is refused by the axis the list does not record, with what works +instead. The MinUI, NextUI and MyMinUI lists record cores and regions and no +system: `--core` and `--region` narrow them, `--system` is refused with +`Run it without --system, or narrow with --core and --region`. A pack records +the systems of its files and what its platform's list narrows by: with the +pack alone, `--core` or `--region` names that list (`--manifest FILE`, a copy +of `install/minui.json`) only when it records them, and MiSTer FPGA's pack +says its list records no cores. A list from an older release records none of +the three, and the one-line installer fetches the current one. + The same choices on the command line: ```bash