fix: one vocabulary and one count in both modes

Line mode says the screen's words for the files in place, missing or to
replace, counts the data files in its last line, names what the --from
folders hold before Enter, says Copying and the result's count once, and ends
every result with the same-choice command. Each data tree is hashed once per
run. A wrapped command reads one rule for PowerShell, a pack path keeps its
file name, folder lines keep one indent, and the systems question fits its
terminal. On the screen a tap on the focused Install row arms it, the Game
data row keeps the focus, a choice in place opens on Choose systems, a pack
holding none of the files offers no Install, and the 40x12 plan keeps its
version note.
This commit is contained in:
Abdessamad Derraz committed 2026-10-28 14:02:46 +01:00
1 parent 9911321227
commit 9314edb0be
11 files changed
+1004 -175

No files matched your search

+1 -1
View File
@@ -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 = "478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa"
$defaultInstallSha256 = "bb21ce2eb80fe6eab353a84e0c43ddfbb6c98ca5f60fa301fbd505b1124b80f1"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+448 -132
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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="478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa"
DEFAULT_INSTALL_SHA256="bb21ce2eb80fe6eab353a84e0c43ddfbb6c98ca5f60fa301fbd505b1124b80f1"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 944874,
"sha256": "478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa",
"size": 960659,
"sha256": "bb21ce2eb80fe6eab353a84e0c43ddfbb6c98ca5f60fa301fbd505b1124b80f1",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 998051,
"sha256": "d39926472bdc1ef05c1c16b058b135cbd988b336e4a5d222b5f918e6663d9d32"
"size": 1013836,
"sha256": "057753a6d5f2c4d2058d225e9f3207bde8d88f80dcd975c511d752b6689946c2"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 944874,
"sha256": "478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa",
"size": 960659,
"sha256": "bb21ce2eb80fe6eab353a84e0c43ddfbb6c98ca5f60fa301fbd505b1124b80f1",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 944874,
"sha256": "478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa",
"size": 960659,
"sha256": "bb21ce2eb80fe6eab353a84e0c43ddfbb6c98ca5f60fa301fbd505b1124b80f1",
"executable": false
},
{
+19
View File
@@ -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,
+3 -3
View File
@@ -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()
+3 -3
View File
@@ -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):
+11 -9
View File
@@ -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.
+2 -2
View File
@@ -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)
+496 -6
View File
@@ -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)
@@ -7565,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
@@ -7593,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"):])
@@ -8305,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
@@ -10877,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,
@@ -12350,6 +12364,7 @@ class AuditTwentyOneAndFourteen(unittest.TestCase):
class Empty:
name = "RetroArch_BIOS_Pack.zip"
members: dict = {}
@staticmethod
def find(entry: dict) -> None:
@@ -12800,5 +12815,480 @@ class TheSettleRunsFromTheFrameShown(unittest.TestCase):
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()
+12 -10
View File
@@ -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
@@ -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
@@ -703,7 +705,7 @@ same choice comes as questions after detection. A RetroArch run with `--plain`
printed this on 2026-10-28, `g` then pressed:
```
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
@@ -908,8 +910,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
@@ -1613,7 +1615,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 |