mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 07:33:45 -06:00
chore: merge proven cards and one vocabulary
This commit is contained in:
commit
27b945ba4c
19 files changed
+1609
-255
No files matched your search
@@ -41,7 +41,7 @@ Every option, and what the installer detects and writes, is on the [installer pa
|
||||
|
||||
## Download BIOS packs
|
||||
|
||||
A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file. At a terminal it offers to install everything or to choose systems, cores and regions, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.
|
||||
A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **Choose systems**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.
|
||||
|
||||
A pack over 2 GB comes in several parts (`Pack.part1of2.zip`, `Pack.part2of2.zip`), and every part is needed. Each part is an ordinary ZIP: extract them all into the same folder.
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
|
||||
# the site's build rewrites this line there (scripts/sitebootstrap.py).
|
||||
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
$defaultInstallSha256 = "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7"
|
||||
$defaultInstallSha256 = "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+611
-163
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
|
||||
# curl or wget stop on a cut transfer before the hash is read: the site's
|
||||
# build rewrites this line there (scripts/sitebootstrap.py).
|
||||
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
DEFAULT_INSTALL_SHA256="67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7"
|
||||
DEFAULT_INSTALL_SHA256="665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 939291,
|
||||
"sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7",
|
||||
"size": 961225,
|
||||
"sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -71,8 +71,8 @@
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 992468,
|
||||
"sha256": "06761ebb41ef22fa2c4b64c5e27fab5ed5508dbcd63c5a634527d03a7cdaa7d1"
|
||||
"size": 1014402,
|
||||
"sha256": "cdd06d24d30ad6b564d8a1595b906d50ce05d23093121cc7e5d91b5acd8e08ab"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
@@ -92,8 +92,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 939291,
|
||||
"sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7",
|
||||
"size": 961225,
|
||||
"sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 939291,
|
||||
"sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7",
|
||||
"size": 961225,
|
||||
"sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
|
||||
@@ -188,6 +188,57 @@ def card_marker_rows() -> list[list[str]]:
|
||||
return rows
|
||||
|
||||
|
||||
# The folder the line menu example names: a home that is no one's.
|
||||
MENU_FOLDER = "/home/me/.config/retroarch/system"
|
||||
|
||||
|
||||
def line_menu() -> str:
|
||||
"""The line menu of a RetroArch run, g then Enter pressed, as install.py
|
||||
prints it for the RetroArch list (install/retroarch.json): the very
|
||||
function line mode runs, its questions answered here. The page carried
|
||||
a copy typed by hand, whose keys and counts drifted from the installer
|
||||
(code_audit_22, owner's note on which-pack)."""
|
||||
import contextlib # noqa: PLC0415
|
||||
import io # noqa: PLC0415
|
||||
import json # noqa: PLC0415
|
||||
import os # noqa: PLC0415
|
||||
from unittest import mock # noqa: PLC0415
|
||||
|
||||
manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8"))
|
||||
files = [*manifest["files"], *(manifest.get("case_variants") or [])]
|
||||
folder = Path(MENU_FOLDER)
|
||||
out = io.StringIO()
|
||||
answers = iter(["g", ""])
|
||||
|
||||
def answer(prompt: str) -> str:
|
||||
given = next(answers)
|
||||
print(f"{prompt}{given}".rstrip())
|
||||
return given
|
||||
|
||||
invocation = _install.Invocation()
|
||||
invocation.os_type = "linux"
|
||||
environment = {key: value for key, value in os.environ.items()
|
||||
if key not in (_install.OFFLINE_VARIABLE, "RETROBIOS_BOOTSTRAP")}
|
||||
with mock.patch.dict(os.environ, environment, clear=True), \
|
||||
mock.patch.object(_install, "_INVOCATION", invocation), \
|
||||
mock.patch.object(_install, "_ask", answer), \
|
||||
mock.patch.object(_install, "folder_warnings", return_value=[]), \
|
||||
contextlib.redirect_stdout(out):
|
||||
cut = [False]
|
||||
|
||||
def command(choice: object) -> str:
|
||||
invocation.chosen["retroarch"] = (
|
||||
(_install.chosen_flags(choice) if choice is not None else [])
|
||||
+ ([("--no-game-data", None)] if cut[0] else []))
|
||||
return invocation.command("retroarch", folder)
|
||||
|
||||
_install._prompt_custom_selection(
|
||||
"retroarch", files, manifest.get("omitted_files") or [], None, "RetroArch",
|
||||
False, manifest.get("data_directories") or [], folder,
|
||||
_install.records_selection(files), None, manifest, False, command, cut)
|
||||
return "```\n" + out.getvalue().strip("\n") + "\n```"
|
||||
|
||||
|
||||
def render(page: str) -> str:
|
||||
"""The page with each marked table written from install.py."""
|
||||
writers = {
|
||||
@@ -198,6 +249,7 @@ def render(page: str) -> str:
|
||||
"route-keys": lambda _body: route_keys(),
|
||||
"card-markers": lambda _body: _table(["On the card", "System", "Files go to"],
|
||||
card_marker_rows()),
|
||||
"line-menu": lambda _body: line_menu(),
|
||||
}
|
||||
seen = []
|
||||
|
||||
|
||||
@@ -39,6 +39,18 @@ from siteguides import install_command
|
||||
from verify import verify_platform
|
||||
|
||||
|
||||
def _installer_words() -> object:
|
||||
"""install.py read as a module, for the words its screen shows: the
|
||||
README named a line menu the one-liner never opens."""
|
||||
import importlib.util # noqa: PLC0415
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"install", Path(__file__).resolve().parent.parent / "install.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
# The repository's profiles wherever the script is run from: a bare
|
||||
# "emulators" read the working directory, and a run elsewhere counted 0 cores.
|
||||
_EMULATORS_DIR = str(Path(__file__).resolve().parent.parent / "emulators")
|
||||
@@ -582,7 +594,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
|
||||
"",
|
||||
"## Download BIOS packs",
|
||||
"",
|
||||
"A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file. At a terminal it offers to install everything or to choose systems, cores and regions, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.",
|
||||
"A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **" + _installer_words().TUI_SYSTEMS_ROW + "**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.",
|
||||
"",
|
||||
"A pack over 2 GB comes in several parts (`Pack.part1of2.zip`,"
|
||||
" `Pack.part2of2.zip`), and every part is needed. Each part is an"
|
||||
|
||||
+21
-16
@@ -3934,11 +3934,22 @@ MISSING_IN_PLACE = "Reported missing anyway? `--check` names a wrong file."
|
||||
# page shows in full, so the setup page links it.
|
||||
SAFE_LINK = "[Is it safe?](../index.md#is-it-safe)"
|
||||
|
||||
# The pass line of "How to check". `--check` prints "All files up to date."
|
||||
# only when no file is missing or wrong (install.py, the args.check branch
|
||||
# after check_local). "0 wrong hash" alone is also what an empty folder
|
||||
# prints: 0/14508 present (0 verified, 0 wrong hash).
|
||||
CHECK_PASS_LINE = "All good when it says `All files up to date.`"
|
||||
def first_screen_words() -> list[str]:
|
||||
"""What a beginner sees when the command runs, the screen's own words
|
||||
read from install.py: the one-liner opens the full screen, and the page
|
||||
showed a line menu (Enter, i, c, q) nobody saw, whose keys had changed
|
||||
since."""
|
||||
installer = _installer()
|
||||
return [f"The command opens a screen. **{installer.TUI_INSTALL_ROW}** gets every "
|
||||
f"file, **{installer.TUI_SYSTEMS_ROW}** fewer."]
|
||||
|
||||
|
||||
def check_pass_line() -> str:
|
||||
"""The pass line of "How to check": the words `--check` prints only when
|
||||
no file is missing or wrong (install.py EVERYTHING_IN_PLACE), read from
|
||||
the installer. Typed here, it kept "All files up to date." after the
|
||||
installer said "Everything is in place." (tui_audit_15 P3-10)."""
|
||||
return f"All good when it says `{_installer().EVERYTHING_IN_PLACE}`"
|
||||
|
||||
# Under "From your computer": a device with no network installs from the ZIP
|
||||
# (`--from-pack`). The id is the one wiki/installer.md gives its section.
|
||||
@@ -4099,7 +4110,8 @@ def only_some_consoles(name: str, prefix: str = "") -> list[str]:
|
||||
"""The way to a smaller download, in the words Get your files uses."""
|
||||
return [
|
||||
f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}"
|
||||
"#only-some-consoles), or press **c** in the command.",
|
||||
f"#only-some-consoles), or choose **{_installer().TUI_SYSTEMS_ROW}** in the "
|
||||
"command.",
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -4260,7 +4272,7 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
|
||||
"Add `--check` to the command. It changes nothing.",
|
||||
"",
|
||||
*terminal_tabs(guide.run, check_args, steps=False),
|
||||
CHECK_PASS_LINE,
|
||||
check_pass_line(),
|
||||
"",
|
||||
_check_sentence(display, cov["mode"], cov["config"], no_list),
|
||||
"",
|
||||
@@ -11397,7 +11409,7 @@ def generate_which_pack() -> str:
|
||||
"## Only some consoles?",
|
||||
"",
|
||||
"Pick them here, and get the files and the command. Or run the command "
|
||||
"below and press **c** when it asks.",
|
||||
f"below and choose **{_installer().TUI_SYSTEMS_ROW}**.",
|
||||
"",
|
||||
"<FilePicker />",
|
||||
"",
|
||||
@@ -11410,14 +11422,7 @@ def generate_which_pack() -> str:
|
||||
"On Batocera, Recalbox and other game systems, run it on the device. "
|
||||
"Your setup's page shows how.",
|
||||
"",
|
||||
"The command asks what to install. Press **Enter** for everything.",
|
||||
"",
|
||||
"```text title=\"What it asks\" frame=\"terminal\"",
|
||||
" Enter) install everything",
|
||||
" i) only what the installed cores read",
|
||||
" c) choose systems, cores and regions",
|
||||
" q) quit",
|
||||
"```",
|
||||
*first_screen_words(),
|
||||
"",
|
||||
*trust_box(""),
|
||||
"**Check your folder later.** Add `--check`. It changes nothing.",
|
||||
|
||||
@@ -109,6 +109,25 @@ class InstallerOutputExample(unittest.TestCase):
|
||||
r"files \(([\d.]+ [KMG]B)\)", menu.group(3))
|
||||
self.assertEqual(games.groups(), split.groups()[2:])
|
||||
|
||||
def test_the_samples_say_what_the_installer_prints_for_the_list(self):
|
||||
"""tui_audit_15 P3-15: both samples said 6.6 GB where the same list
|
||||
prints 6.7 GB, the size updated by hand from its rounded parts. The
|
||||
head is the installer's own tally of the list, its second spellings
|
||||
and data files in it, as a run on Linux counts them."""
|
||||
import install
|
||||
|
||||
text = _page("installer.md")
|
||||
manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8"))
|
||||
counts = install.tally([*manifest["files"], *(manifest.get("case_variants") or [])])
|
||||
trees = manifest.get("data_directories") or []
|
||||
data = sum(len(tree["files"]) for tree in trees)
|
||||
data_bytes = sum(meta["size"] for tree in trees for meta in tree["files"].values())
|
||||
total = f"{counts.files + data} files"
|
||||
size = install.format_size(counts.size + data_bytes)
|
||||
parts = install.parts_words(counts, data, data_bytes, 0)
|
||||
self.assertIn(f"\n {total} ({size})\n {parts}.\n", text)
|
||||
self.assertIn(f"\nRetroArch: {total}, {size}.\n {parts}.\n", text)
|
||||
|
||||
def assertSplitAddsUp(self, text: str, count: str, size: str) -> None:
|
||||
"""The run counted 8069 files where its menu counted 3259 BIOS files
|
||||
and 5089 game data files: the parts add up to the total they divide,
|
||||
|
||||
@@ -3737,7 +3737,7 @@ class TheInstallerSaysWhatItFound(unittest.TestCase):
|
||||
(dest / "lynxboot.img").write_bytes(b"corrupt")
|
||||
proc = self._run("--dest", str(dest), "--system", "atari-lynx", "--check")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("wrong hash: lynxboot.img", proc.stdout)
|
||||
self.assertIn("wrong copy: lynxboot.img", proc.stdout)
|
||||
self.assertIn("without --check", proc.stdout)
|
||||
|
||||
def test_a_read_only_folder_is_said_once(self):
|
||||
@@ -4202,7 +4202,7 @@ class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun):
|
||||
"--check", "--no-input")
|
||||
self.assertEqual(proc.returncode, 1, proc.stderr)
|
||||
listing = proc.stdout.split("to download:\n", 1)[1].splitlines()
|
||||
self.assertEqual(listing[0].strip(), "wrong hash: scph5501.bin")
|
||||
self.assertEqual(listing[0].strip(), "wrong copy: scph5501.bin")
|
||||
|
||||
def test_a_dry_run_counts_the_data_trees_and_ends_on_its_last_line(self):
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
||||
@@ -4441,7 +4441,7 @@ class StandaloneEmulatorsWithoutAPlatformAreServed(unittest.TestCase, _LoopbackR
|
||||
self.assertTrue(all(name.startswith("scph") for name in names), names)
|
||||
proc = _install_run(self.env, "--no-input", "--check")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("All files up to date.", proc.stdout)
|
||||
self.assertIn("Everything is in place.", proc.stdout)
|
||||
|
||||
def test_no_emulator_folder_keeps_the_hint(self):
|
||||
self.duckstation.rmdir()
|
||||
|
||||
@@ -391,7 +391,7 @@ class ReviewCases(unittest.TestCase):
|
||||
out = buf.getvalue()
|
||||
self.assertNotIn("Done.", out)
|
||||
self.assertIn("12 files not downloaded", out)
|
||||
self.assertIn("3 files already up to date", out)
|
||||
self.assertIn("3 files already in place", out)
|
||||
|
||||
def test_quiet_refuses_modes_it_would_silence(self):
|
||||
for mode in ("--check", "--dry-run", "--list-platforms", "--list-systems"):
|
||||
@@ -547,7 +547,7 @@ class AFolderThisUserCannotEnter(unittest.TestCase):
|
||||
os.chmod(locked, 0o755)
|
||||
self.assertNotIn("Traceback", proc.stderr)
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
||||
self.assertIn("wrong hash: pcsx/extra.bin", proc.stdout)
|
||||
self.assertIn("wrong copy: pcsx/extra.bin", proc.stdout)
|
||||
self.assertIn("could not be read (Permission denied)", proc.stdout)
|
||||
self.assertIn("missing: scph5501.bin", proc.stdout)
|
||||
|
||||
@@ -937,7 +937,7 @@ class TheEndOfARunPaintsItsStatusWords(unittest.TestCase):
|
||||
("failure", "1 file failed"), ("failure", "failed:"))),
|
||||
((2, 1, 0, 0, False), {},
|
||||
(("success", "Done"), ("success", "2 files installed"),
|
||||
("success", "1 file already up to date"))),
|
||||
("success", "1 file already in place"))),
|
||||
((1, 0, 0, 0, False), {"offline": 2},
|
||||
(("warning", "Finished with files missing"), ("warning", "2 not fetched")))):
|
||||
with self.subTest(counts=counts):
|
||||
@@ -1159,10 +1159,14 @@ class NotFoundWithAClosedFolder(unittest.TestCase):
|
||||
asked = []
|
||||
install._DETECTION_REFUSED[:] = [(home / ".config" / "EmuDeck", "Permission denied")]
|
||||
self.addCleanup(install._DETECTION_REFUSED.clear)
|
||||
# The drives of the machine running the tests are not read, and the
|
||||
# question is not printed into the test output.
|
||||
with mock.patch.dict(os.environ, {"HOME": str(home)}), \
|
||||
mock.patch.object(install, "_mounted_cards", return_value=[]), \
|
||||
mock.patch.object(install, "_choose_platform", return_value="retroarch"), \
|
||||
mock.patch.object(install, "_ask",
|
||||
lambda prompt: asked.append(prompt) or str(home / "b")):
|
||||
lambda prompt: asked.append(prompt) or str(home / "b")), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
install._prompt_manual_platform("linux")
|
||||
self.assertEqual(len(asked), 1)
|
||||
self.assertNotIn("no default on this system", asked[0])
|
||||
|
||||
@@ -132,7 +132,7 @@ class CheckNamesWhyAFileIsRefused(Scratch):
|
||||
self.complete("scph5501.bin")
|
||||
self.place("scph5501.bin", JP)
|
||||
out = self.check()
|
||||
self.assertIn("wrong hash: scph5501.bin", out)
|
||||
self.assertIn("wrong copy: scph5501.bin", out)
|
||||
self.assertIn("another region: the contents of scph5500.bin (japan)", out)
|
||||
|
||||
def test_another_revision_names_the_core_that_reads_it(self):
|
||||
@@ -663,7 +663,7 @@ class OfflineMakesNoRequest(Scratch):
|
||||
self.assertEqual((self.dest / "scph5501.bin").read_bytes(), US)
|
||||
self.assertFalse((self.dest / "gba_bios.bin").exists())
|
||||
# Missing, never failed (code_audit_15 P3-1): no FAILED line per file.
|
||||
self.assertIn("3 not fetched (--offline)", proc.stdout + proc.stderr)
|
||||
self.assertIn("3 still missing", proc.stdout + proc.stderr)
|
||||
self.assertNotIn("FAILED", proc.stdout + proc.stderr)
|
||||
self.assertIn("without --offline", proc.stdout + proc.stderr)
|
||||
self.assertNotIn("trying again", proc.stderr)
|
||||
@@ -801,9 +801,11 @@ class OfflineLeavesFilesMissing(Scratch):
|
||||
sources="4 files copied from old, 14696 still missing.")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertNotIn("Done", text)
|
||||
self.assertIn("Finished with files missing. 4 files installed, 14696 not fetched "
|
||||
"(--offline).", text)
|
||||
self.assertIn("14696 still missing", text)
|
||||
# One count, the sources line as the head, as the screen's result
|
||||
# (tui_audit_15 P3-8).
|
||||
self.assertIn("Finished with files missing. 4 files copied from old, 14696 still "
|
||||
"missing.", text)
|
||||
self.assertEqual(text.count("14696"), 1)
|
||||
|
||||
def test_nothing_installed_is_code_2(self):
|
||||
code, text = self.outcome(offline=14700)
|
||||
@@ -843,7 +845,7 @@ class OfflineLeavesFilesMissing(Scratch):
|
||||
proc = self.run_installer("--offline", "--manifest", str(self.list), "--no-input")
|
||||
# Its files are missing, as the BIOS files --offline leaves out are,
|
||||
# with or without questions.
|
||||
self.assertIn("bluemsx: 0 already up to date, 1 need the internet (--offline)",
|
||||
self.assertIn("bluemsx: 0 already in place, 1 need the internet (--offline)",
|
||||
proc.stdout)
|
||||
self.assertNotIn("could not read", proc.stderr)
|
||||
self.assertNotIn("no longer in the source", proc.stdout)
|
||||
@@ -867,8 +869,8 @@ class OneOfflineVocabulary(Scratch):
|
||||
"--from", str(self.old()), "--no-input")
|
||||
out = proc.stdout + proc.stderr
|
||||
self.assertEqual(proc.returncode, 1, out)
|
||||
self.assertIn("Finished with files missing. 1 file installed, 3 not fetched "
|
||||
"(--offline).", out)
|
||||
self.assertIn(f"Finished with files missing. 1 file copied from {self.root / 'old'}, "
|
||||
"3 still missing.", out)
|
||||
self.assertIn(f"They are not in {self.root / 'old'}", out)
|
||||
for said in ("Finished with errors", "files failed", "retry",
|
||||
"copy them from your old card"):
|
||||
@@ -961,7 +963,7 @@ class AuditFifteenOffline(Scratch):
|
||||
(1, 3, []))
|
||||
self.assertNotIn("FAILED", proc.stderr)
|
||||
self.assertNotIn("downloading", proc.stderr)
|
||||
self.assertIn("1 file installed, 3 not fetched (--offline)", proc.stderr)
|
||||
self.assertIn(f"1 file copied from {old}, 3 still missing", proc.stderr)
|
||||
download = next(event for event in events if event["event"] == "download")
|
||||
self.assertEqual(download["files"], 1)
|
||||
# The screen reads that summary as missing files, not failures.
|
||||
|
||||
@@ -1625,9 +1625,9 @@ class SomethingElseAtADestination(unittest.TestCase):
|
||||
proc = repo.run("--check")
|
||||
self.assertEqual(proc.returncode, 1, proc.stderr)
|
||||
self.assertNotIn("Traceback", proc.stderr)
|
||||
self.assertIn(" wrong hash: scph5500.bin\n a folder sits where this "
|
||||
self.assertIn(" wrong copy: scph5500.bin\n a folder sits where this "
|
||||
"file goes: move it away, then run again", proc.stdout)
|
||||
self.assertIn(" wrong hash: scph5501.bin\n could not be read "
|
||||
self.assertIn(" wrong copy: scph5501.bin\n could not be read "
|
||||
"(Permission denied)", proc.stdout)
|
||||
proc = repo.run("--yes")
|
||||
self.assertEqual(proc.returncode, 1, proc.stderr)
|
||||
|
||||
+745
-12
@@ -5102,7 +5102,8 @@ class EndToEnd(unittest.TestCase):
|
||||
term.send("\r")
|
||||
term.wait("Enter) install everything")
|
||||
term.send("\r")
|
||||
term.wait("installed")
|
||||
# Installed by the first run, in place for the second.
|
||||
term.wait("Done.")
|
||||
term.close()
|
||||
self.assertNotIn(b"\x1b", term.raw)
|
||||
|
||||
@@ -6756,6 +6757,21 @@ class AuditSevenScreens(unittest.TestCase):
|
||||
self.assertIn("1 wrong copy: backed up, replaced.", view)
|
||||
|
||||
|
||||
def games_part(card: Path) -> Path:
|
||||
"""An ArkOS EASYROMS (dArkOS game_systems.txt: themes, launchimages
|
||||
with loading.jpg) or an AmberELEC GAMES (autostart.sh: gamedata,
|
||||
homebrew with gamelist.xml), console folders beside them."""
|
||||
marks = (("gamedata/retroarch", "homebrew") if card.name.startswith("GAMES")
|
||||
else ("themes/es-theme-nes-box", "launchimages"))
|
||||
for folder in ("bios", "nes", "gba", "psx", *marks):
|
||||
(card / folder).mkdir(parents=True)
|
||||
if card.name.startswith("GAMES"):
|
||||
(card / "homebrew" / "gamelist.xml").write_text("<gameList/>\n")
|
||||
else:
|
||||
(card / "launchimages" / "loading.jpg").write_bytes(b"\xff\xd8")
|
||||
return card
|
||||
|
||||
|
||||
class _Decided(Exception):
|
||||
"""Raised where line mode has chosen its platforms and folders."""
|
||||
|
||||
@@ -7550,7 +7566,9 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings,
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
install._install(args, real)
|
||||
self.assertIn("Done. 2 files installed", real.getvalue())
|
||||
# Every file from one folder: one count, as the screen's result
|
||||
# (tui_audit_15 P3-8).
|
||||
self.assertIn(f"Done. 2 files copied from {backup}.", real.getvalue())
|
||||
self.assertIn("Installed: 1 BIOS file and 1 game data file.", real.getvalue())
|
||||
|
||||
# 2026-10-27, CI run 40541886959: the run said 8069 files, the menu
|
||||
@@ -7578,8 +7596,10 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings,
|
||||
self.assertIn("RetroArch: 4 files, 123 B.\n 1 BIOS file (3 B) and 3 game data files "
|
||||
"(120 B).", text)
|
||||
self.assertIn("g) Game data: kept (3 files, 120 B): leave it out", text)
|
||||
self.assertIn(" 4 files (123 B)\n 1 BIOS file (3 B) and 3 game data files (120 B).",
|
||||
text)
|
||||
# Said once, by the menu: the run head repeated it right under it
|
||||
# (tui_audit_15 P3-9).
|
||||
after = text[text.index("q) quit"):text.index("Checking existing files")]
|
||||
self.assertNotIn("4 files (123 B)", after)
|
||||
self.assertIn("0 in place, 4 missing, 0 wrong copies", text)
|
||||
self.assertIn("4 files (123 B)", text[text.index("Checking existing files"):])
|
||||
|
||||
@@ -8290,7 +8310,13 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings,
|
||||
text, _asked = self.line_run(_args(**args), ["c", "", ""])
|
||||
self.assertNotRegex(text, r"Narrowed (\d+) to \1 files")
|
||||
text, _asked = self.line_run(_args(**args), ["c", "1", ""])
|
||||
self.assertIn("Narrowed 4 to 3 files", text)
|
||||
# The menu counted the choice again before Enter: said once.
|
||||
self.assertIn("Counted again for your choices", text)
|
||||
self.assertNotIn("Narrowed", text)
|
||||
args = _args(manifest=self.counted_list(), dest=self.here, declared_only=False,
|
||||
dry_run=True, no_input=True, system=["doom"])
|
||||
text, _asked = self.line_run(args, [])
|
||||
self.assertRegex(text, r"Narrowed 4 to \d files")
|
||||
|
||||
def test_the_dry_run_names_systems_by_their_titles(self):
|
||||
"""tui_audit_12 P3-13: --dry-run listed microsoft-msx where the list
|
||||
@@ -10862,7 +10888,10 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase):
|
||||
path = "/home/moi/abcdefghijklmnopqrstuvw/.config/retroarch/system"
|
||||
width = len(path) + 1
|
||||
lines = install._wrap(f"BIOS folder: {path}", width, whole="text")
|
||||
self.assertEqual(lines, ["BIOS folder:", path])
|
||||
# Broken after a separator, the rest at the one indent of every
|
||||
# folder line (tui_audit_15 P3-12): never inside a name.
|
||||
self.assertEqual(lines, ["BIOS folder: /home/moi/abcdefghijklmnopqrstuvw/.config/",
|
||||
" retroarch/system"])
|
||||
# A command keeps the option with its value, the path whole where
|
||||
# the line holds them (tui_audit_14 P3-8).
|
||||
command = install._wrap(f" python3 install.py --dest {path}", width + 10,
|
||||
@@ -12248,12 +12277,10 @@ class AuditTwentyOneAndFourteen(unittest.TestCase):
|
||||
self.assertIn("About 0 files, 0 B, for 1 of 3 systems, 3 need the internet", text)
|
||||
|
||||
def easyroms(self, name: str = "EASYROMS") -> Path:
|
||||
"""An ArkOS games part as a computer mounts it: bios/ and console
|
||||
folders, no file naming the system."""
|
||||
card = self.root / "media" / name
|
||||
for folder in ("bios", "nes", "gba", "psx"):
|
||||
(card / folder).mkdir(parents=True)
|
||||
return card
|
||||
"""A handheld's games part as a computer mounts it, with the folders
|
||||
its system writes there (install._GAMES_PART_MARKERS): ArkOS's
|
||||
EASYROMS, AmberELEC's GAMES."""
|
||||
return games_part(self.root / "media" / name)
|
||||
|
||||
def test_an_arkos_card_is_found_and_comes_first(self):
|
||||
"""tui_audit_14 P2-3: an EASYROMS partition mounted, "Nothing was
|
||||
@@ -12337,6 +12364,7 @@ class AuditTwentyOneAndFourteen(unittest.TestCase):
|
||||
|
||||
class Empty:
|
||||
name = "RetroArch_BIOS_Pack.zip"
|
||||
members: dict = {}
|
||||
|
||||
@staticmethod
|
||||
def find(entry: dict) -> None:
|
||||
@@ -12557,5 +12585,710 @@ class AuditTwentyOneAndFourteen(unittest.TestCase):
|
||||
self.assertIn(f"BIOS folder: {card / 'bios'}", text)
|
||||
|
||||
|
||||
|
||||
class AuditTwentyTwoAndFifteen(unittest.TestCase):
|
||||
"""code_audit_22 and tui_audit_15: a drive is a handheld's card only by
|
||||
the files its system writes, one settle from the frame shown, one
|
||||
vocabulary and one count per run, and the focus after an action."""
|
||||
|
||||
setUp = LineModeAndScreenDecideAlike.setUp
|
||||
line_run = LineModeAndScreenDecideAlike.line_run
|
||||
listing = LineModeAndScreenDecideAlike.listing
|
||||
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
|
||||
receive = staticmethod(AuditTwentyAndThirteen.receive)
|
||||
game_manifest = AuditTwentyAndThirteen.game_manifest
|
||||
game_events = AuditTwentyAndThirteen.game_events
|
||||
game_plan = AuditTwentyAndThirteen.game_plan
|
||||
game_listing = AuditTwentyOneAndFourteen.game_listing
|
||||
screen = staticmethod(AuditTwentyOneAndFourteen.screen)
|
||||
|
||||
def drive(self, name: str, folders: tuple) -> Path:
|
||||
root = self.root / "media" / name
|
||||
for folder in folders:
|
||||
(root / folder).mkdir(parents=True)
|
||||
return root
|
||||
|
||||
def first_screen(self, mounts: list) -> install.TuiState:
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
|
||||
mock.patch.object(install, "_mount_points", return_value=frozenset()), \
|
||||
mock.patch.object(install, "detect_platforms", return_value=[]), \
|
||||
mock.patch.object(install, "detect_frontends", return_value=[]), \
|
||||
mock.patch.object(install.Path, "home", return_value=self.root / "home"):
|
||||
state, _effects = install.tui_start(_args(), "linux")
|
||||
return state
|
||||
|
||||
def folder_screen(self, mounts: list) -> tuple[install.TuiState, str, list]:
|
||||
"""RetroArch chosen on the first screen: (state, Which folder? at
|
||||
80x40, its rows)."""
|
||||
state = self.first_screen(mounts)
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
|
||||
mock.patch.object(install, "_mount_points", return_value=frozenset()):
|
||||
install.tui_update(state, Key("enter"))
|
||||
self.assertEqual(state.screen, "folder")
|
||||
return state, self.screen(state, 80, 40), install.tui_rows(state)
|
||||
|
||||
def test_a_data_drive_or_a_stick_is_no_card(self):
|
||||
"""code_audit_22 P2-1: a data drive holding bios/, snes/ and Music/
|
||||
was "a card in the reader", and three presses of Enter put the list
|
||||
on it. A stick with bios/ alone is no card either: both are listed,
|
||||
never under Enter, and a plain line says why."""
|
||||
data = self.drive("DATA", ("bios", "snes", "Music"))
|
||||
stick = self.drive("STICK", ("bios",))
|
||||
mounts = [data, stick]
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
|
||||
mock.patch.object(install, "_mount_points", return_value=frozenset()):
|
||||
self.assertEqual(install.unnamed_cards(), [])
|
||||
state = self.first_screen(mounts)
|
||||
self.assertEqual(state.unnamed, [])
|
||||
self.assertIn("Nothing was found here", self.screen(state))
|
||||
state, text, rows = self.folder_screen(mounts)
|
||||
self.assertNotIn(rows[0].value, (data / "bios", stick / "bios"))
|
||||
self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a "
|
||||
"handheld's card: listed, not chosen.", text)
|
||||
self.assertIn(data / "bios", [row.value for row in rows])
|
||||
text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True,
|
||||
declared_only=False), ["1", ""], mounts=mounts)
|
||||
self.assertNotIn("in the reader", text)
|
||||
self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a "
|
||||
"handheld's card: listed, not chosen.", text)
|
||||
self.assertIn("Enter for the default above", asked[-2])
|
||||
self.assertNotIn(f"BIOS folder: {data / 'bios'}", text)
|
||||
|
||||
def test_the_layouts_handhelds_write_are_cards(self):
|
||||
"""ArkOS's EASYROMS, AmberELEC's GAMES and EmuELEC's EEROMS, each
|
||||
with the folders its own image writes there."""
|
||||
arkos = games_part(self.root / "media" / "EASYROMS")
|
||||
amber = games_part(self.root / "media" / "GAMES")
|
||||
emuelec = self.drive("EEROMS", ("bios", "nes", "bezels"))
|
||||
(emuelec / "bezels" / "default.cfg").write_text("")
|
||||
mounts = [arkos, amber, emuelec]
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
|
||||
mock.patch.object(install, "_mount_points", return_value=frozenset()):
|
||||
self.assertEqual(install.unnamed_cards(), mounts)
|
||||
self.assertEqual([install._games_part_system(mount) for mount in mounts],
|
||||
["ArkOS", "AmberELEC", "EmuELEC"])
|
||||
# One of these the system calls fixed is a disk, not a card.
|
||||
with mock.patch.object(install, "_drive_not_removable", return_value=True):
|
||||
self.assertEqual(install.unnamed_cards(), [])
|
||||
|
||||
def test_a_card_beside_a_data_drive_comes_first(self):
|
||||
"""code_audit_22 P2-1: the ArkOS card and the data drive mounted,
|
||||
the data drive sorted first and took Enter. The card comes first in
|
||||
both modes, the drive listed after it with its note."""
|
||||
data = self.drive("DATA", ("bios", "snes", "Music"))
|
||||
card = games_part(self.root / "media" / "EASYROMS")
|
||||
mounts = [data, card]
|
||||
state = self.first_screen(mounts)
|
||||
self.assertEqual(state.unnamed, [card])
|
||||
self.assertIn("A card is in the reader (EASYROMS): choose its system", self.screen(state))
|
||||
state, text, rows = self.folder_screen(mounts)
|
||||
self.assertEqual(rows[0].value, card / "bios")
|
||||
self.assertIn("DATA has a bios folder, and nothing on it shows a handheld's card", text)
|
||||
text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True,
|
||||
declared_only=False), ["1", ""], mounts=mounts)
|
||||
self.assertIn("A card is in the reader (EASYROMS): choose its system.", text)
|
||||
self.assertIn(f"The card in the reader keeps them in its bios folder: {card / 'bios'}.",
|
||||
text)
|
||||
self.assertIn(f"1) EASYROMS: bios (card or drive) ({card / 'bios'})", text)
|
||||
self.assertIn("DATA has a bios folder, and nothing on it shows a handheld's card", text)
|
||||
self.assertIn(f"BIOS folder: {card / 'bios'}", text)
|
||||
|
||||
def test_two_cards_are_spoken_of_in_the_plural(self):
|
||||
"""code_audit_22 P3-2: "Cards are in the reader (DATA, EASYROMS):
|
||||
choose its system", then "The card in the reader" of one of two."""
|
||||
arkos = games_part(self.root / "media" / "EASYROMS")
|
||||
amber = games_part(self.root / "media" / "GAMES")
|
||||
mounts = [arkos, amber]
|
||||
state = self.first_screen(mounts)
|
||||
self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems",
|
||||
self.screen(state))
|
||||
state, text, rows = self.folder_screen(mounts)
|
||||
self.assertIn("EASYROMS keeps them in its bios folder, first below.", text)
|
||||
text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True,
|
||||
declared_only=False), ["1", ""], mounts=mounts)
|
||||
self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems.", text)
|
||||
self.assertIn(f"EASYROMS keeps them in its bios folder: {arkos / 'bios'}.", text)
|
||||
self.assertNotIn("The card in the reader", text)
|
||||
|
||||
def test_a_fixed_partition_is_told_from_a_card(self):
|
||||
"""code_audit_22 P2-1: every mount under /media and /mnt, and on
|
||||
Windows every drive letter, was a card. A whole partition of a disk
|
||||
sysfs calls fixed is not, a USB or MMC one is, a folder bound in
|
||||
place says nothing, and Windows answers by GetDriveTypeW."""
|
||||
sys_root = self.root / "sys"
|
||||
devices = sys_root / "devices"
|
||||
for disk, part, removable in (("pci0/nvme0n1", "nvme0n1p2", "0"),
|
||||
("pci0/usb2/2-1/sdb", "sdb1", "0"),
|
||||
("mmc0/mmcblk0", "mmcblk0p1", "0"),
|
||||
("pci0/sdc", "sdc1", "1")):
|
||||
(devices / disk / part).mkdir(parents=True)
|
||||
(devices / disk / part / "partition").write_text("1\n")
|
||||
(devices / disk / "removable").write_text(f"{removable}\n")
|
||||
block = sys_root / "dev" / "block"
|
||||
block.mkdir(parents=True)
|
||||
for number, target in (("259:2", "pci0/nvme0n1/nvme0n1p2"),
|
||||
("8:17", "pci0/usb2/2-1/sdb/sdb1"),
|
||||
("179:1", "mmc0/mmcblk0/mmcblk0p1"),
|
||||
("8:33", "pci0/sdc/sdc1")):
|
||||
(block / number).symlink_to(devices / target)
|
||||
mountinfo = "\n".join([
|
||||
"40 1 259:2 / /media/me/DATA rw - ext4 /dev/nvme0n1p2 rw",
|
||||
"41 1 8:17 / /media/me/EASYROMS rw - vfat /dev/sdb1 rw",
|
||||
"42 1 179:1 / /media/me/GAMES rw - exfat /dev/mmcblk0p1 rw",
|
||||
"43 1 8:33 / /media/me/STICK rw - vfat /dev/sdc1 rw",
|
||||
"44 1 259:2 /home/me/cards/X /media/me/BOUND rw - ext4 /dev/nvme0n1p2 rw",
|
||||
"45 1 0:74 / /mnt/d rw - 9p D:\\134 rw"])
|
||||
told = {name: install._fixed_partition(Path(point), mountinfo, sys_root)
|
||||
for name, point in (("DATA", "/media/me/DATA"), ("USB", "/media/me/EASYROMS"),
|
||||
("MMC", "/media/me/GAMES"), ("STICK", "/media/me/STICK"),
|
||||
("BOUND", "/media/me/BOUND"), ("WSL", "/mnt/d"),
|
||||
("unmounted", "/media/me/NONE"))}
|
||||
self.assertEqual(told, {"DATA": True, "USB": False, "MMC": False, "STICK": False,
|
||||
"BOUND": False, "WSL": False, "unmounted": False})
|
||||
for kind, fixed in ((2, False), (3, True), (4, True)):
|
||||
kernel = types.SimpleNamespace(GetDriveTypeW=lambda root, kind=kind: kind)
|
||||
fake = types.SimpleNamespace(windll=types.SimpleNamespace(kernel32=kernel))
|
||||
with mock.patch.object(install.os, "name", "nt"), \
|
||||
mock.patch.dict(sys.modules, {"ctypes": fake}):
|
||||
self.assertEqual(install._drive_not_removable(PureWindowsPath("D:\\")), fixed)
|
||||
|
||||
|
||||
# install.py whose tui_view takes SLOW seconds on the first frame of each
|
||||
# screen, as a slower device takes to lay one out (code_audit_22 P3-1).
|
||||
SLOW_VIEW = """
|
||||
import os, sys, time
|
||||
sys.argv[0] = {install!r}
|
||||
sys.path.insert(0, {repo!r})
|
||||
import install
|
||||
slow = float(os.environ["SLOW"])
|
||||
real, seen = install.tui_view, [None]
|
||||
def view(state, cols, rows):
|
||||
key = (state.screen, len(state.stack))
|
||||
if key != seen[0]:
|
||||
seen[0] = key
|
||||
time.sleep(slow)
|
||||
return real(state, cols, rows)
|
||||
install.tui_view = view
|
||||
sys.exit(install.main())
|
||||
"""
|
||||
|
||||
|
||||
@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
|
||||
"screen tests need pyte (pip install pyte==0.8.2) and a pty")
|
||||
class TheSettleRunsFromTheFrameShown(unittest.TestCase):
|
||||
"""code_audit_22 P3-1: the settle was stamped before the layout, so on
|
||||
a slow one the second click of a double click on Options' Back reached
|
||||
the plan's Quit."""
|
||||
|
||||
repo = EndToEnd.repo
|
||||
bar_entry = TapsOnAPtySeeFirst.bar_entry
|
||||
|
||||
def test_a_double_click_on_back_stays_on_the_plan_after_a_slow_layout(self):
|
||||
repo = self.repo()
|
||||
wrapper = repo.root / "slowview.py"
|
||||
wrapper.write_text(SLOW_VIEW.format(install=str(REPO_ROOT / "install.py"),
|
||||
repo=str(REPO_ROOT)))
|
||||
env = {**self.env, "SLOW": "0.5"}
|
||||
term = Pty([], env, 80, 24, program=[sys.executable, str(wrapper)])
|
||||
self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9))
|
||||
term.wait("Where do you play?")
|
||||
term.send("\r")
|
||||
term.wait("> Install")
|
||||
term.settle()
|
||||
term.send("o")
|
||||
term.wait("RetroBIOS: Options")
|
||||
term.settle()
|
||||
time.sleep(1.0)
|
||||
col, line = self.bar_entry(term, "Back")
|
||||
report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True)
|
||||
os.write(term.fd, report.encode())
|
||||
time.sleep(0.2)
|
||||
os.write(term.fd, report.encode())
|
||||
time.sleep(1.0)
|
||||
screen = term.settle(0.6)
|
||||
self.assertNotIn("Quit?", screen)
|
||||
self.assertIn("RetroBIOS: RetroArch", screen.splitlines()[0])
|
||||
self.assertEqual(repo.installed(), [])
|
||||
term.send("q")
|
||||
if "Quit?" in term.settle():
|
||||
term.send("\r")
|
||||
term.close()
|
||||
|
||||
|
||||
class OnePowerShellRule(unittest.TestCase):
|
||||
"""code_audit_22 P3-5: the words of a command asked RETROBIOS_BOOTSTRAP
|
||||
or the detected system, its line ends RETROBIOS_BOOTSTRAP or os.name:
|
||||
with RETROBIOS_OS=windows on Linux one command had PowerShell's quotes
|
||||
and sh's line ends, and pasted into either shell it broke."""
|
||||
|
||||
def test_the_words_and_the_line_ends_read_one_rule(self):
|
||||
cases = ((None, "windows", True), ("sh", "windows", False), ("ps1", "linux", True),
|
||||
(None, "linux", False), ("sh", "linux", False))
|
||||
for bootstrap, os_type, powershell in cases:
|
||||
with self.subTest(bootstrap=bootstrap, os_type=os_type):
|
||||
hint = install.Invocation()
|
||||
hint.os_type, hint.options = os_type, [("--system", "it's")]
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if key != "RETROBIOS_BOOTSTRAP"}
|
||||
if bootstrap:
|
||||
env["RETROBIOS_BOOTSTRAP"] = bootstrap
|
||||
with mock.patch.dict(os.environ, env, clear=True):
|
||||
self.assertEqual(hint.powershell(), powershell)
|
||||
text = hint.command("retroarch", Path("/home/me/My Games/bios"))
|
||||
# Line mode's wrap reads the run's Invocation, the
|
||||
# screen's the one that made its command.
|
||||
with mock.patch.object(install, "_INVOCATION", hint):
|
||||
wrapped = install._command_wrap(text, 40)
|
||||
drawn = [str(line) for line in install._text_lines(
|
||||
[install._command_line(text, hint)], 40)]
|
||||
for lines in (wrapped, drawn):
|
||||
lines = [line for line in lines if line.strip()]
|
||||
ends = {line.rstrip()[-1] for line in lines[:-1]}
|
||||
self.assertEqual(ends, {"`" if powershell else "\\"})
|
||||
words = " ".join(lines)
|
||||
self.assertIn("'it''s'" if powershell else shlex.quote("it's"), words)
|
||||
|
||||
|
||||
class EachDataTreeReadOnce(unittest.TestCase):
|
||||
"""code_audit_22 P3-3: the count line, the check, the events and the
|
||||
install each read and hashed every data tree in place, twice per run
|
||||
on an SD card through a reader."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
self.dest = self.root / "bios"
|
||||
bios = b"bios bytes"
|
||||
(self.dest / "gba_bios.bin").parent.mkdir(parents=True)
|
||||
(self.dest / "gba_bios.bin").write_bytes(bios)
|
||||
trees = []
|
||||
for ref, files, held in (("dolphin-sys", {"Sys/a.bin": b"a", "Sys/b.bin": b"bb"}, 2),
|
||||
("ppsspp", {"assets/c.bin": b"ccc", "assets/d.bin": b"d"}, 1)):
|
||||
for rel, data in list(files.items())[:held]:
|
||||
(self.dest / ref / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(self.dest / ref / rel).write_bytes(data)
|
||||
trees.append({"ref": ref, "dest": ref, "type": "zip",
|
||||
"url": f"https://example.invalid/{ref}.zip",
|
||||
"systems": ["nintendo-gba"],
|
||||
"files": {rel: {"sha1": hashlib.sha1(data).hexdigest(),
|
||||
"size": len(data)} for rel, data in files.items()}})
|
||||
self.list = self.root / "retroarch.json"
|
||||
self.list.write_text(json.dumps({
|
||||
"manifest_version": 2, "platform": "retroarch", "data_directories": trees,
|
||||
"files": [{"dest": "gba_bios.bin", "sha1": hashlib.sha1(bios).hexdigest(),
|
||||
"size": len(bios), "repo_path": "bios/gba_bios.bin", "cores": None,
|
||||
"systems": ["nintendo-gba"]}]}))
|
||||
|
||||
def test_each_tree_is_hashed_once_per_run(self):
|
||||
real = install.data_directory_state
|
||||
for mode in ("check", "events", "dry_run", "yes"):
|
||||
with self.subTest(mode=mode):
|
||||
calls = []
|
||||
|
||||
def counted(entry, bios_path, calls=calls):
|
||||
calls.append(entry["ref"])
|
||||
return real(entry, bios_path)
|
||||
|
||||
given = {"events": True, "check": True} if mode == "events" else {mode: True}
|
||||
stream = io.StringIO() if mode == "events" else None
|
||||
with mock.patch.object(install, "data_directory_state", counted), \
|
||||
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()), \
|
||||
contextlib.redirect_stderr(io.StringIO()), \
|
||||
contextlib.suppress(SystemExit):
|
||||
try:
|
||||
install._install(_args(platform="retroarch", dest=self.dest,
|
||||
manifest=self.list, no_input=True,
|
||||
declared_only=False, **given), io.StringIO())
|
||||
finally:
|
||||
install.LOCAL.reset()
|
||||
self.assertEqual(sorted(calls), ["dolphin-sys", "ppsspp"])
|
||||
|
||||
|
||||
class OneVocabularyOneCount(unittest.TestCase):
|
||||
"""code_audit_22 P3-4, tui_audit_15 P3-6 to P3-10: line mode said "in
|
||||
place" then "up to date", "wrong copies" then "wrong hash", counted
|
||||
twice, left the data files out of its last line, and the screen focused
|
||||
Install for a pack holding none of the files."""
|
||||
|
||||
setUp = LineModeAndScreenDecideAlike.setUp
|
||||
line_run = LineModeAndScreenDecideAlike.line_run
|
||||
listing = LineModeAndScreenDecideAlike.listing
|
||||
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
|
||||
screen = staticmethod(AuditTwentyOneAndFourteen.screen)
|
||||
|
||||
def two_files(self) -> tuple[Path, Path, Path]:
|
||||
"""A list of two files, the first in place, the second in an old
|
||||
folder: (list, BIOS folder, old folder)."""
|
||||
files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]),
|
||||
self.entry("scph5501.bin", b"psx bytes", ["sony-playstation"])]
|
||||
listing = self.listing("retroarch", files)
|
||||
dest = self.root / "bios"
|
||||
dest.mkdir()
|
||||
(dest / "gba_bios.bin").write_bytes(b"gba")
|
||||
old = self.root / "OLD"
|
||||
old.mkdir()
|
||||
(old / "scph5501.bin").write_bytes(b"psx bytes")
|
||||
return listing, dest, old
|
||||
|
||||
def run_line(self, listing: Path, dest: Path, answers: list, **given) -> str:
|
||||
from_ = given.pop("from_", None)
|
||||
args = _args(manifest=listing, dest=dest, declared_only=False, **given)
|
||||
if from_:
|
||||
given["from_"] = from_
|
||||
if "from_" in given:
|
||||
setattr(args, "from", given.pop("from_"))
|
||||
# Offline as the variable makes it: no request leaves the test. The
|
||||
# end of the run is written to the stdout the run was given.
|
||||
given_answers = iter(answers)
|
||||
|
||||
def answer(prompt: str) -> str:
|
||||
try:
|
||||
return next(given_answers)
|
||||
except StopIteration:
|
||||
raise _Decided(prompt) from None
|
||||
|
||||
install.LOCAL.reset()
|
||||
self.addCleanup(install.LOCAL.reset)
|
||||
invocation = install.Invocation()
|
||||
invocation.os_type = "linux"
|
||||
invocation.record(args)
|
||||
end = io.StringIO()
|
||||
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \
|
||||
mock.patch.object(install, "_INVOCATION", invocation), \
|
||||
mock.patch.object(install, "_mounted_cards", return_value=[]), \
|
||||
mock.patch.object(install, "detect_os", return_value="linux"), \
|
||||
mock.patch.object(install.sys.stdin, "isatty", return_value=True), \
|
||||
mock.patch.object(install, "_is_terminal", return_value=True), \
|
||||
mock.patch.object(install, "_ask", side_effect=answer), \
|
||||
contextlib.redirect_stdout(io.StringIO()) as out, \
|
||||
contextlib.redirect_stderr(io.StringIO()) as err, \
|
||||
contextlib.suppress(_Decided, SystemExit):
|
||||
install._install(args, end)
|
||||
return out.getvalue() + err.getvalue() + end.getvalue()
|
||||
|
||||
def test_the_line_mode_words_are_the_screens(self):
|
||||
listing, dest, old = self.two_files()
|
||||
(old / "scph5501.bin").rename(dest / "scph5501.bin")
|
||||
for given in ({"no_input": True}, {"check": True}, {"dry_run": True}):
|
||||
with self.subTest(**given):
|
||||
text = self.run_line(listing, dest, [""], offline=True, **given)
|
||||
self.assertIn("2 in place, 0 missing, 0 wrong copies", text)
|
||||
self.assertIn("Everything is in place", text)
|
||||
self.assertNotIn("up to date", text)
|
||||
self.assertNotIn("0 files installed", text)
|
||||
state = checked_state()
|
||||
state.stack = ["result"]
|
||||
state.summary = {"installed": 0, "up_to_date": 2, "pending": 0, "kept": [],
|
||||
"failed": []}
|
||||
state.code = 0
|
||||
_title, lines = install.tui_outcome(state)
|
||||
self.assertEqual(lines[0], "Everything is in place.")
|
||||
|
||||
def test_check_names_a_wrong_copy_as_such(self):
|
||||
listing, dest, _old = self.two_files()
|
||||
(dest / "scph5501.bin").write_bytes(b"other")
|
||||
text = self.run_line(listing, dest, [], check=True, offline=True)
|
||||
self.assertIn("1 in place, 0 missing, 1 wrong copy", text)
|
||||
self.assertIn("wrong copy: scph5501.bin", text)
|
||||
self.assertNotIn("wrong hash", text)
|
||||
self.assertNotIn("A file with a", text)
|
||||
|
||||
def test_line_mode_with_from_counts_once(self):
|
||||
"""tui_audit_15 P3-8 and P3-9: no found count before Enter, the
|
||||
plan said again after it, Copying twice, the result's count twice,
|
||||
and no command on a Finished with files missing."""
|
||||
listing, dest, old = self.two_files()
|
||||
(dest / "gba_bios.bin").unlink()
|
||||
text = self.run_line(listing, dest, [""], from_=[old], offline=True)
|
||||
menu, after = text.split("\nChecking existing files...", 1)
|
||||
self.assertIn("RetroArch: 1 found in OLD, 1 needs the internet.", menu)
|
||||
self.assertNotIn("2 files (", after)
|
||||
self.assertEqual(after.count("Copying"), 1, after)
|
||||
self.assertIn(f"Finished with files missing. 1 file copied from {old}, "
|
||||
"1 still missing.", after)
|
||||
self.assertNotIn("installed,", after)
|
||||
self.assertIn("Same choice without questions:", after)
|
||||
# Every file found: one count, as the screen's "22 files copied
|
||||
# from packx."
|
||||
(dest / "scph5501.bin").unlink()
|
||||
(old / "gba_bios.bin").write_bytes(b"gba")
|
||||
text = self.run_line(listing, dest, [""], from_=[old], offline=True)
|
||||
menu, after = text.split("\nChecking existing files...", 1)
|
||||
self.assertIn("RetroArch: 2 found in OLD, nothing to download.", menu)
|
||||
self.assertIn(f"Done. 2 files copied from {old}.", after)
|
||||
self.assertNotIn("files installed", after)
|
||||
|
||||
def test_the_last_offline_line_counts_the_data_files(self):
|
||||
"""tui_audit_15 P3-6: "0 in place, 10994 missing", then "Nothing
|
||||
installed: 8358 files need the internet"."""
|
||||
listing, dest, _old = self.two_files()
|
||||
listed = json.loads(listing.read_text())
|
||||
listed["data_directories"] = [{
|
||||
"ref": "bluemsx", "dest": "", "type": "zip",
|
||||
"url": "https://example.invalid/blueMSX.zip", "systems": ["nintendo-gba"],
|
||||
"files": {f"Machines/{n}.txt": {"sha1": hashlib.sha1(b"%d" % n).hexdigest(),
|
||||
"size": 1} for n in range(2)}}]
|
||||
listing.write_text(json.dumps(listed))
|
||||
text = self.run_line(listing, dest, [""], offline=True)
|
||||
self.assertIn("1 in place, 3 missing, 0 wrong copies", text)
|
||||
self.assertIn("Nothing installed: 3 files need the internet", text)
|
||||
|
||||
def test_a_pack_holding_none_offers_no_install(self):
|
||||
"""tui_audit_15 P3-7: a data tree whose folder is the BIOS folder
|
||||
itself (blueMSX) made any pack hold a data file: Install focused,
|
||||
and the result counted 8252 failed beside 10888 missing."""
|
||||
pack = self.root / "RetroArch_BIOS_Pack.zip"
|
||||
with zipfile.ZipFile(pack, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps({"files": []}))
|
||||
archive.writestr("other-system.bin", b"x")
|
||||
state = checked_state()
|
||||
state.check = {**state.check, "data_missing": 2, "data_bytes": 2}
|
||||
state.manifest = {**MANIFEST, "data_directories": [
|
||||
{"ref": "bluemsx", "dest": "", "files": {
|
||||
"Machines/a.txt": {"sha1": "0" * 40, "size": 1},
|
||||
"Machines/b.txt": {"sha1": "0" * 40, "size": 1}}}]}
|
||||
install.LOCAL.reset()
|
||||
self.addCleanup(install.LOCAL.reset)
|
||||
install.LOCAL.pack = install.PackSource([pack])
|
||||
rows = install.tui_rows(state)
|
||||
self.assertNotIn("install", [row.action for row in rows])
|
||||
self.assertEqual(rows[state.focus.get("plan", 0)].action, "fromcard")
|
||||
text = self.screen(state)
|
||||
self.assertIn("RetroArch_BIOS_Pack.zip holds none of these 6 files", text)
|
||||
self.assertIn("1 in place, 5 missing, 1 wrong copy", text)
|
||||
|
||||
|
||||
class FocusAfterAnAction(unittest.TestCase):
|
||||
"""tui_audit_15 P3-1, P3-3, P3-4 and P3-5: one tap on the focused
|
||||
Install row armed nothing visible, the result's Put RetroBIOS on this
|
||||
card was cut at 40x12, the Game data row lost the focus once changed,
|
||||
and a choice already in place opened on Options."""
|
||||
|
||||
@staticmethod
|
||||
def row_of(lines: list, action: str) -> int:
|
||||
return next(index for index, line in enumerate(lines)
|
||||
if getattr(line, "mark", None) and line.mark[0] == "row"
|
||||
and line.mark[2][0] == action)
|
||||
|
||||
def test_one_tap_on_the_focused_install_row_arms_it_visibly(self):
|
||||
for cols, rows in ((80, 24), (40, 12)):
|
||||
with self.subTest(size=(cols, rows)):
|
||||
state = checked_state()
|
||||
state.mouse = True
|
||||
lines = drawn(state, cols, rows)
|
||||
row = self.row_of(lines, "install")
|
||||
self.assertTrue(str(lines[row]).lstrip().startswith("> Install"))
|
||||
self.assertEqual(tap(state, 4, row), [])
|
||||
armed = drawn(state, cols, rows)
|
||||
self.assertIn(install.TUI_ARMED_SHORT, str(armed[-1]))
|
||||
self.assertIn(install.TUI_ARMED_SHORT, str(armed[row]))
|
||||
self.assertEqual(armed[row].style, "focus")
|
||||
painted = install.tui_paint(str(armed[-1]), "hint", True)
|
||||
self.assertIn(f"{install.COLOUR_ROLES['accent']}m", painted)
|
||||
# The second tap, on the row or on the bar, installs.
|
||||
self.assertEqual(tap(state, 4, row), [("run", "install")])
|
||||
|
||||
def test_a_row_armed_then_the_bar_installs_and_a_key_disarms(self):
|
||||
state = checked_state()
|
||||
state.mouse = True
|
||||
lines = drawn(state, 80, 24)
|
||||
tap(state, 4, self.row_of(lines, "install"))
|
||||
drawn(state, 80, 24)
|
||||
self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "install")])
|
||||
state = checked_state()
|
||||
state.mouse = True
|
||||
lines = drawn(state, 80, 24)
|
||||
tap(state, 4, self.row_of(lines, "install"))
|
||||
install.tui_update(state, Key("down"))
|
||||
install.tui_update(state, Key("up"))
|
||||
self.assertNotIn(install.TUI_ARMED_SHORT, " ".join(map(str, drawn(state, 80, 24))))
|
||||
|
||||
def test_put_retrobios_on_this_card_fits_40x12(self):
|
||||
"""The result offering the app, drawn and tapped as the loop does
|
||||
(tui_view, tui_hits, tui_mouse), at 40x12 and 80x24: no line cut
|
||||
inside a word, the armed bar whole, one Exit on the bar and the
|
||||
help naming B as the bar does."""
|
||||
root, bios = _app_card(self, "muOS")
|
||||
for cols, rows in ((40, 12), (80, 24)):
|
||||
with self.subTest(size=(cols, rows)):
|
||||
state = _installed_state(bios)
|
||||
state.mouse = True
|
||||
with mock.patch.object(install, "_tui_app_ready", return_value=True):
|
||||
lines = drawn(state, cols, rows)
|
||||
text = [str(line).rstrip() for line in lines]
|
||||
row = self.row_of(lines, "addapp")
|
||||
offer = text[row].strip()
|
||||
self.assertIn(offer, ("Put RetroBIOS on this card",
|
||||
install.APP_OFFER))
|
||||
self.assertEqual(text[-1].split(), ["A/Enter", "Exit"]
|
||||
if cols >= install.TUI_LONG_BAR_WIDTH else ["A", "Exit"])
|
||||
# One tap focuses the row: a second puts the app on the
|
||||
# card, which the row and the bar say, B kept.
|
||||
tap(state, 4, row)
|
||||
armed = [str(line).rstrip() for line in drawn(state, cols, rows)]
|
||||
self.assertTrue(armed[row].lstrip().startswith("> Put"))
|
||||
self.assertIn("Exit", armed[-1])
|
||||
self.assertIn("B/Esc" if cols >= install.TUI_LONG_BAR_WIDTH else "B",
|
||||
armed[-1].split())
|
||||
self.assertIn(f"{install.TUI_ARMED_SHORT} B", armed[-1])
|
||||
self.assertTrue(armed[row].strip().endswith(install.TUI_ARMED_SHORT),
|
||||
armed[row])
|
||||
for line in (*armed, *text):
|
||||
self.assertLessEqual(install.cell_width(line), cols)
|
||||
self.assertFalse(line.endswith(("aga", "fro", "(update f")), line)
|
||||
state.tapped = None
|
||||
install.tui_update(state, Key("f1"))
|
||||
help_text = " ".join(" ".join(str(line).split())
|
||||
for line in drawn(state, cols, rows))
|
||||
self.assertIn("Exit", help_text)
|
||||
self.assertNotIn("Backspace Quit", help_text)
|
||||
install.tui_update(state, Key("f1"))
|
||||
|
||||
def test_the_game_data_row_keeps_the_focus_when_changed(self):
|
||||
audit = AuditTwentyAndThirteen()
|
||||
state = audit.game_plan()
|
||||
rows = install.tui_rows(state)
|
||||
state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "gamedata")
|
||||
for left_out, expected in ((True, "Game data: left out"), (False, "Game data: kept")):
|
||||
self.assertEqual(install.tui_update(state, Key("enter")), [("run", "check")])
|
||||
audit.receive(state, audit.game_events(audit.game_manifest(), left_out=left_out))
|
||||
rows = install.tui_rows(state)
|
||||
self.assertEqual(rows[state.focus["plan"]].label, expected)
|
||||
|
||||
def test_a_choice_already_in_place_opens_on_choose_systems(self):
|
||||
state = checked_state()
|
||||
state.focus["plan"] = next(i for i, row in enumerate(install.tui_rows(state))
|
||||
if row.action == "systems")
|
||||
install.tui_update(state, Key("enter"))
|
||||
self.assertEqual(state.screen, "systems")
|
||||
state.ticked_before = set(state.ticked)
|
||||
state.ticked = {"nintendo-famicom-disk"}
|
||||
with mock.patch.object(install, "_choice_in_place", return_value="in place"):
|
||||
install.tui_update(state, Key("esc"))
|
||||
self.assertEqual(state.screen, "plan")
|
||||
rows = install.tui_rows(state)
|
||||
self.assertEqual(rows[state.focus["plan"]].label, "Choose systems")
|
||||
|
||||
|
||||
class TextThatFits(unittest.TestCase):
|
||||
"""tui_audit_15 P3-2, P3-12, P3-13 and P3-11: a pack's name cut in a
|
||||
command, folder lines at two indents, the 40x12 plan dropping the
|
||||
version note, and the --from plan without its kinds."""
|
||||
|
||||
def test_a_pack_path_keeps_its_name_whole(self):
|
||||
pack = "/home/user/Downloads/RetroArch_BIOS_Pack.zip"
|
||||
text = ("curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --platform "
|
||||
f"retroarch --dest /media/moi/ONION/BIOS --from-pack {pack} --offline")
|
||||
for width in (38, 50, 78):
|
||||
with self.subTest(width=width), \
|
||||
mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}):
|
||||
lines = install._command_wrap(text, width)
|
||||
joined = " ".join(lines)
|
||||
self.assertIn("RetroArch_BIOS_Pack.zip", joined)
|
||||
self.assertNotRegex(joined, r"\.\.\.[^/ ]*Pack\.zip")
|
||||
for line in lines:
|
||||
self.assertLessEqual(install.cell_width(line), width, line)
|
||||
|
||||
def test_a_folder_on_its_own_line_keeps_one_indent(self):
|
||||
base = "/home/me/very/long/folder/names/for/an/android/storage/RetroArch"
|
||||
items = [f"BIOS folder: {base}/system",
|
||||
"1 file read from RetroArch's save directory goes to a folder per core:",
|
||||
f" {base}/saves"]
|
||||
for width in (38, 70, 78):
|
||||
for whole in (False, True):
|
||||
with self.subTest(width=width, whole=whole):
|
||||
lines = [str(line) for line in install._text_lines(items, width, whole)]
|
||||
folders = [line for line in lines if "/" in line
|
||||
and not line.startswith("BIOS folder:")]
|
||||
self.assertTrue(folders)
|
||||
for line in folders:
|
||||
self.assertEqual(len(line) - len(line.lstrip(" ")), 2, lines)
|
||||
|
||||
def test_the_40x12_plan_keeps_the_version_note(self):
|
||||
state = checked_state()
|
||||
state.dest = Path("/home/moi/retroarch_system/tmp/tui_audit15/rb2/bios")
|
||||
install.tui_receive(state, ("event", state.run_id, {
|
||||
"event": "version", "platform": "retroarch", "list_version": "1.22.2",
|
||||
"installed_version": "1.21.0",
|
||||
"message": "This list was built for RetroArch 1.22.2, this machine runs 1.21.0. "
|
||||
"Some files may differ from what this version reads."}))
|
||||
state.run = "check"
|
||||
narrow = " ".join(" ".join(str(line).split())
|
||||
for line in install.tui_view(state, 40, 12))
|
||||
self.assertIn("List for RetroArch 1.22.2, this is 1.21.0.", narrow)
|
||||
self.assertIn("BIOS folder:", narrow)
|
||||
wide = " ".join(" ".join(str(line).split()) for line in install.tui_view(state, 80, 24))
|
||||
self.assertIn("This list was built for RetroArch 1.22.2, this machine runs 1.21.0.", wide)
|
||||
|
||||
|
||||
class TheFromPlanSaysItsKinds(unittest.TestCase):
|
||||
"""tui_audit_15 P3-11: every file found in the folder, the plan named
|
||||
the Game data row and said neither the kinds nor what game data is."""
|
||||
|
||||
def test_the_kinds_and_the_definition_stay(self):
|
||||
audit = AuditTwentyAndThirteen()
|
||||
state = audit.game_plan()
|
||||
folder = "/media/moi/packx"
|
||||
state.from_folders = [Path(folder)]
|
||||
state.found = [found(event["dest"], folder) for event in state.files
|
||||
if event["state"] != "present"]
|
||||
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
|
||||
text = " ".join(" ".join(str(line).split())
|
||||
for line in install.tui_view(state, 80, 24))
|
||||
self.assertIn("found in packx, nothing to download", text)
|
||||
self.assertIn("2 BIOS files", text)
|
||||
self.assertIn("3 game data files", text)
|
||||
self.assertIn("Game data: files a game or a game engine reads", text)
|
||||
self.assertNotIn("Not in packx", text)
|
||||
|
||||
|
||||
class TheSystemsQuestionFits(unittest.TestCase):
|
||||
"""code_audit_22 P3-6, tui_audit_15 P3-14: the size column was placed
|
||||
for the longest size, so 60 of 66 systems took two lines and 26 still
|
||||
passed 80 columns."""
|
||||
|
||||
def question(self, cols: int) -> list[str]:
|
||||
manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text())
|
||||
files = manifest["files"]
|
||||
systems = install.systems_in_order(install.listed_systems(files), files, manifest,
|
||||
None)
|
||||
out = io.StringIO()
|
||||
with mock.patch.object(install.shutil, "get_terminal_size",
|
||||
return_value=os.terminal_size((cols, 24))), \
|
||||
mock.patch.object(install, "_ask", return_value=""), \
|
||||
contextlib.redirect_stdout(out):
|
||||
install._choose_narrowing(files, [], manifest, None)
|
||||
text = out.getvalue()
|
||||
# Up to the question that follows it, the cores' or none.
|
||||
ends = [at for at in (text.find("\nCores:"), text.find("\nThe platform's own"))
|
||||
if at >= 0]
|
||||
return (text[text.index("Systems:"):min(ends, default=len(text))].splitlines(),
|
||||
systems, files)
|
||||
|
||||
def test_the_question_fits_80_and_40_columns(self):
|
||||
for cols in (80, 40):
|
||||
with self.subTest(cols=cols):
|
||||
lines, systems, files = self.question(cols)
|
||||
numbered = [line for line in lines if re.match(r" +\d+\) ", line)]
|
||||
self.assertEqual(len(numbered), len(systems))
|
||||
for line in lines:
|
||||
self.assertLessEqual(install.cell_width(line), cols, line)
|
||||
shared = sum(1 for system in systems if "shared" in install._system_sized(
|
||||
[f for f in files if install._chosen(f, {system})]))
|
||||
# One line a system, a second for its shared files: the
|
||||
# 80-column question took 127 lines for 66 systems.
|
||||
limit = len(systems) + shared + 10 if cols == 80 else 2 * len(systems) + 12
|
||||
self.assertLessEqual(len(lines), limit)
|
||||
|
||||
def test_a_row_that_fits_keeps_one_line(self):
|
||||
lines, _systems, _files = self.question(80)
|
||||
row = next(index for index, line in enumerate(lines) if "Amstrad CPC" in line)
|
||||
self.assertIn("4 files, 112.0 KB", lines[row])
|
||||
self.assertTrue(re.match(r" +\d+\) ", lines[row + 1]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -167,7 +167,7 @@ class GetYourFilesOrder(unittest.TestCase):
|
||||
text = _page("which-pack.md")
|
||||
order = [text.index(mark) for mark in (
|
||||
"## Pick your setup", "| Setup | Runs on | BIOS folder | ZIP |", "<FilePicker",
|
||||
"## Not in the list? One command finds it", "Press **Enter** for everything",
|
||||
"## Not in the list? One command finds it", "The command opens a screen",
|
||||
"<summary>Is it safe?</summary>",
|
||||
)]
|
||||
self.assertEqual(order, sorted(order))
|
||||
|
||||
@@ -158,7 +158,9 @@ class SetupPagesOfferPartOfThePack(unittest.TestCase):
|
||||
with self.subTest(platform=key):
|
||||
text = _guide_part(_page(f"platforms/{key}.md"))
|
||||
self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text)
|
||||
self.assertIn("or press **c** in the command.", text)
|
||||
# The screen's own row: the line menu's c is not what the
|
||||
# one-liner opens.
|
||||
self.assertIn("or choose **Choose systems** in the command.", text)
|
||||
|
||||
def test_the_anchor_exists(self):
|
||||
self.assertIn("## Only some consoles?", _page("which-pack.md"))
|
||||
|
||||
@@ -193,8 +193,13 @@ class IphoneAndIpad(unittest.TestCase):
|
||||
|
||||
|
||||
class WhatItAsks(unittest.TestCase):
|
||||
def test_the_prompt_block_is_framed_as_a_terminal(self):
|
||||
self.assertIn('```text title="What it asks" frame="terminal"', _page("which-pack.md"))
|
||||
def test_the_page_says_the_screen_not_a_line_menu(self):
|
||||
# The one-liner opens the full screen: a "What it asks" block of the
|
||||
# line menu showed keys a beginner never sees (owner's note).
|
||||
text = _page("which-pack.md")
|
||||
self.assertNotIn('title="What it asks"', text)
|
||||
for line in generate_site.first_screen_words():
|
||||
self.assertIn(line, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -104,16 +104,17 @@ class GetPagesGiveTheSetupsOwnRoutes(unittest.TestCase):
|
||||
class CheckNeedsTheFiles(unittest.TestCase):
|
||||
def test_the_pass_line_is_what_the_installer_prints_only_when_complete(self):
|
||||
installer = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
printed = re.search(r"`([^`]+)`", generate_site.CHECK_PASS_LINE)[1]
|
||||
self.assertIn(f'print("\\n {printed}")', installer)
|
||||
self.assertNotIn("wrong hash", generate_site.CHECK_PASS_LINE)
|
||||
printed = re.search(r"`([^`]+)`", generate_site.check_pass_line())[1]
|
||||
self.assertEqual(printed, generate_site._installer().EVERYTHING_IN_PLACE)
|
||||
self.assertIn('print(f"\\n {EVERYTHING_IN_PLACE}")', installer)
|
||||
self.assertNotIn("wrong hash", generate_site.check_pass_line())
|
||||
|
||||
def test_no_setup_page_passes_an_empty_folder(self):
|
||||
for name in SETUPS:
|
||||
text = _page(f"platforms/{name}.md")
|
||||
with self.subTest(setup=name):
|
||||
self.assertNotIn("All good when you see `0 wrong hash`", text)
|
||||
self.assertIn(generate_site.CHECK_PASS_LINE, text)
|
||||
self.assertIn(generate_site.check_pass_line(), text)
|
||||
|
||||
|
||||
class ARouteWithoutANetwork(unittest.TestCase):
|
||||
@@ -236,5 +237,57 @@ class HomeAndWhichPack(unittest.TestCase):
|
||||
self.assertIn(word, meta[1].split())
|
||||
|
||||
|
||||
class TheSiteSaysWhatTheInstallerShows(unittest.TestCase):
|
||||
"""which-pack showed a line menu (Enter, i, c, q) the one-liner never
|
||||
opens, typed by hand, its keys out of date. The site, the README and
|
||||
the wiki now take the screen's words and the line menu from install.py."""
|
||||
|
||||
MENU_LINES = ("Enter) install everything", "i) only what the installed cores read",
|
||||
"c) choose systems, cores and regions")
|
||||
|
||||
def test_which_pack_describes_the_screen_in_the_installers_words(self):
|
||||
installer = generate_site._installer()
|
||||
text = _page("which-pack.md")
|
||||
for line in generate_site.first_screen_words():
|
||||
self.assertIn(line, text)
|
||||
self.assertIn(f"**{installer.TUI_INSTALL_ROW}**", text)
|
||||
self.assertIn(f"**{installer.TUI_SYSTEMS_ROW}**", text)
|
||||
for line in self.MENU_LINES:
|
||||
self.assertNotIn(line, text)
|
||||
self.assertNotIn("press **c**", text)
|
||||
|
||||
def test_the_plan_rows_are_the_words_the_pages_use(self):
|
||||
installer = generate_site._installer()
|
||||
state = installer.TuiState(os_type="linux", home=Path("/home/me"))
|
||||
state.stack = ["plan"]
|
||||
state.check = {"files": 2, "present": 0, "missing": 2, "wrong_hash": 0, "need": 2,
|
||||
"bytes_needed": 10}
|
||||
state.systems = [{"system": name, "missing": 1, "wrong_hash": 0, "present": 0,
|
||||
"files": 1, "bytes_needed": 5} for name in ("a", "b")]
|
||||
state.manifest = {"files": [{"dest": "a.bin", "systems": ["a"]},
|
||||
{"dest": "b.bin", "systems": ["b"]}]}
|
||||
state.ticked = {"a", "b"}
|
||||
state.checked_flags = installer.tui_flags(state)
|
||||
labels = [row.label for row in installer.tui_rows(state)]
|
||||
self.assertEqual(labels[0], installer.TUI_INSTALL_ROW)
|
||||
self.assertIn(installer.TUI_SYSTEMS_ROW, labels)
|
||||
|
||||
def test_the_readme_and_the_wiki_take_the_same_words(self):
|
||||
installer = generate_site._installer()
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
self.assertIn(f"fewer under **{installer.TUI_SYSTEMS_ROW}**", readme)
|
||||
self.assertNotIn("offers to install everything or to choose systems", readme)
|
||||
source = (REPO_ROOT / "scripts" / "generate_readme.py").read_text(encoding="utf-8")
|
||||
self.assertIn("_installer_words().TUI_SYSTEMS_ROW", source)
|
||||
import generate_installer_doc # noqa: PLC0415
|
||||
|
||||
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
|
||||
self.assertIn(generate_installer_doc.line_menu(), page)
|
||||
menu = page.split("<!-- generated: line-menu -->", 1)[1]
|
||||
outside = page.replace(menu.split("<!-- /generated: line-menu -->", 1)[0], "")
|
||||
for line in self.MENU_LINES:
|
||||
self.assertNotIn(line, outside)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+45
-26
@@ -273,7 +273,9 @@ and the row a screen opens on (**Install** on Confirm) is never chosen by one
|
||||
tap. The entries of the bottom line press their key, **Exit** on the result
|
||||
too. One that writes files with no screen after it to look first, **Install**
|
||||
on the plan and **Put it on the card**, takes two taps: the first adds
|
||||
`click or tap again` to it in the accent colour, the second presses it.
|
||||
`click or tap again` to it in the accent colour (`tap again` where the line
|
||||
has no room), the second presses it. A first tap on the row it acts on arms
|
||||
it the same way, on the row and on the bottom line.
|
||||
**Install** on Confirm, which is that look, Quit, Continue and Close take one,
|
||||
so installing or quitting never takes more than two. A row of
|
||||
Preferred regions stays where it is when it is ticked, its number giving the
|
||||
@@ -281,8 +283,8 @@ order, which the list shows sorted the next time it opens. The line that says
|
||||
which rows are in view shows the next page, and the wheel scrolls a list or a
|
||||
text without moving the cursor unless it would leave the screen. Nothing
|
||||
happens until the button or the finger comes up, and only where it went down,
|
||||
on the screen drawn when it went down. A new screen takes no press for its
|
||||
first 400 ms: a double click is two presses at most 400 ms (GNOME) to 500 ms
|
||||
on the screen drawn when it went down. A new screen takes no press for the
|
||||
first 400 ms after it is on the screen, however long it took to lay out: a double click is two presses at most 400 ms (GNOME) to 500 ms
|
||||
(Windows, macOS) apart and the new screen is drawn on the first release, so
|
||||
the second tap of a double tap that changes the screen does nothing on the
|
||||
new one, and a tap made later acts. The right and middle buttons
|
||||
@@ -322,8 +324,8 @@ file list is downloaded once and handed to each run.
|
||||
3. Prints the file count and total size, then a safety notice counting the
|
||||
entries the collection cannot serve, how many of those the platform marks
|
||||
required, and why. At a terminal, the
|
||||
[full-screen interface](#the-full-screen-interface) then offers to install
|
||||
everything or to choose systems, cores and regions, and `--plain` asks the
|
||||
[full-screen interface](#the-full-screen-interface) then offers **Install**
|
||||
for everything and **Choose systems** for fewer, and `--plain` asks the
|
||||
same as questions (see below).
|
||||
4. Hashes what is already in the destination and reports how many entries are
|
||||
present, verified, or present with the wrong contents.
|
||||
@@ -391,7 +393,7 @@ A RetroArch dry run into an empty folder printed this on 2026-10-27:
|
||||
```
|
||||
Fetching file index for retroarch...
|
||||
106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves
|
||||
10994 files (6.6 GB)
|
||||
10994 files (6.7 GB)
|
||||
3269 BIOS files (4.1 GB), 5089 game data files (2.5 GB) and 2636 data files (38.5 MB).
|
||||
Not installable: 37 files (1 required, not found: 37).
|
||||
|
||||
@@ -528,7 +530,7 @@ The same command with `--check` reads the folder and writes nothing:
|
||||
Checking existing files...
|
||||
14481 in place, 0 missing, 0 wrong copies
|
||||
|
||||
All files up to date.
|
||||
Everything is in place.
|
||||
```
|
||||
|
||||
The files the collection does not hold are listed after it again, as long as
|
||||
@@ -544,7 +546,7 @@ core opens it:
|
||||
|
||||
```
|
||||
3 files to download:
|
||||
wrong hash: scph5501.bin
|
||||
wrong copy: scph5501.bin
|
||||
another region: the contents of scph5500.bin (japan)
|
||||
opened at /home/me/.config/retroarch/system/scph5501.bin by beetle_psx, pcsx_rearmed
|
||||
missing: gba_bios.bin
|
||||
@@ -700,25 +702,38 @@ the plan screen of the [full-screen interface](#the-full-screen-interface)
|
||||
offers **Install**, **Choose systems** and **Options**. With `--plain`, or where
|
||||
the full screen cannot open (a terminal smaller than 40x12, `TERM=dumb`), the
|
||||
same choice comes as questions after detection. A RetroArch run with `--plain`
|
||||
printed this on 2026-10-28, `g` then pressed:
|
||||
prints this for the RetroArch list, `g` then pressed (written from install.py
|
||||
by `scripts/generate_installer_doc.py`):
|
||||
|
||||
<!-- generated: line-menu -->
|
||||
|
||||
```
|
||||
RetroArch: 10994 files, 6.6 GB.
|
||||
RetroArch: 10994 files, 6.7 GB.
|
||||
3269 BIOS files (4.1 GB), 5089 game data files (2.5 GB) and 2636 data files (38.5 MB).
|
||||
Game data: files a game or a game engine reads, of no use to the other cores. Data files: the files an emulator reads from a data folder of its own, such as Dolphin's Sys and PPSSPP's assets.
|
||||
BIOS folder: /home/me/.config/retroarch/system
|
||||
106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves
|
||||
Enter) install everything
|
||||
c) choose systems, cores and regions
|
||||
f) copy from a folder or an old card
|
||||
g) Game data: kept (5089 files, 2.5 GB): leave it out
|
||||
q) quit
|
||||
>
|
||||
> g
|
||||
|
||||
Counted again for your choices. Nothing is written before Enter.
|
||||
|
||||
RetroArch: 5905 files, 4.1 GB.
|
||||
RetroArch: 5905 files, 4.2 GB.
|
||||
3269 BIOS files (4.1 GB) and 2636 data files (38.5 MB). 5089 game data files left out.
|
||||
Game data: files a game or a game engine reads, of no use to the other cores. Data files: the files an emulator reads from a data folder of its own, such as Dolphin's Sys and PPSSPP's assets.
|
||||
BIOS folder: /home/me/.config/retroarch/system
|
||||
Same choice without questions: python3 install.py --platform retroarch --dest /home/me/.config/retroarch/system --no-game-data
|
||||
Enter) install everything
|
||||
c) choose systems, cores and regions
|
||||
f) copy from a folder or an old card
|
||||
g) Game data: left out (5089 files, 2.5 GB): keep it
|
||||
q) quit
|
||||
>
|
||||
```
|
||||
<!-- /generated: line-menu -->
|
||||
|
||||
A list that marks its game data counts three kinds of file apart, on the plan,
|
||||
in this menu and on the result (`Installed: 3269 BIOS files, 5089 game data
|
||||
@@ -733,9 +748,8 @@ count follows the systems chosen, as `g` counts them, and a choice that holds
|
||||
no game data has no row. Given `--no-game-data`, neither the menu nor the plan
|
||||
offers the game data back.
|
||||
|
||||
`i) only what the installed cores read: 87 files, 43.8 MB` appears for
|
||||
RetroArch, Lakka and RetroPie when cores are found installed, here a RetroArch
|
||||
with Beetle PSX HW, Snes9x, mGBA, Genesis Plus GX and FCEUmm.
|
||||
An `i` entry, with the count and size of only what the installed cores read,
|
||||
appears for RetroArch, Lakka and RetroPie when cores are found installed.
|
||||
It keeps what those cores read and drops the systems none of them emulates,
|
||||
the rule `--installed-cores` applies.
|
||||
|
||||
@@ -908,8 +922,8 @@ what the options name, everything by default.
|
||||
<!-- /generated: options -->
|
||||
|
||||
`--check` reads the same file list, hashes the destination and exits, with
|
||||
code 1 when a file is missing or does not match and 0 when every one does. Files
|
||||
with a wrong hash are listed first and in full, then the missing ones whose
|
||||
code 1 when a file is missing or does not match and 0 when every one does. Wrong
|
||||
copies are listed first and in full, then the missing ones whose
|
||||
reason it found, then the first ten other missing ones (`--verbose` lists them
|
||||
all). See [Why a file is refused](#why-a-file-is-refused). `--dry-run` counts the data trees in its total. Without
|
||||
it, a file already present with the expected hash is left untouched, and one
|
||||
@@ -1181,13 +1195,18 @@ muOS, ArkOS and the others) opens the setup list instead, filtered to the
|
||||
setups that read it, RetroArch first: Enter on it asks RetroArch's folder. At
|
||||
that question a drive is numbered by the folder its system reads (`ONION:
|
||||
BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never
|
||||
by its root, which no setup reads. A drive no file names that holds a `bios`
|
||||
folder and console folders (`nes`, `gba`, `psx`) is a handheld's card whose
|
||||
own files sit on a part a computer does not open, ArkOS's `EASYROMS` and
|
||||
AmberELEC's `GAMES`: with nothing found here the first screen and line mode
|
||||
say `A card is in the reader (EASYROMS): choose its system`, and its `bios`
|
||||
folder comes first at the folder question, under Enter, before this
|
||||
computer's usual folder. Another system's card or SHARE drive, and a
|
||||
by its root, which no setup reads. A handheld's card whose own files sit on a
|
||||
part a computer does not open is told by the folders its system writes beside
|
||||
`bios` on the games part: `themes` and `launchimages` on ArkOS's `EASYROMS`,
|
||||
`gamedata` and `homebrew` on AmberELEC's `GAMES`, `bezels/default.cfg` on
|
||||
EmuELEC's `EEROMS`. A disk Windows or Linux calls fixed is never one. With
|
||||
nothing found here the first screen and line mode say `A card is in the reader
|
||||
(EASYROMS): choose its system` (two cards: `choose their systems`), and its
|
||||
`bios` folder comes first at the folder question, under Enter, before this
|
||||
computer's usual folder. A drive with a `bios` folder and none of those
|
||||
folders, a data drive or a stick, is numbered with a note (`DATA has a bios
|
||||
folder, and nothing on it shows a handheld's card: listed, not chosen.`) and
|
||||
never taken under Enter. Another system's card or SHARE drive, and a
|
||||
drive two systems fit, are left out, and a line names them: each is reached
|
||||
through its own setup. A folder typed is read from the current
|
||||
one when it is not a full path, and named in full after. `q` quits there too
|
||||
@@ -1608,7 +1627,7 @@ fields, the installer refuses to narrow rather than install everything.
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `0` | everything asked for is installed or already up to date |
|
||||
| `0` | everything asked for is installed or already in place |
|
||||
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit |
|
||||
| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question, the terminal keeping `RetroBIOS: stopped. Nothing was installed.` before an install. Run the same command again to carry on |
|
||||
|
||||
Reference in new issue
Block a user