chore: merge the install record from main

This commit is contained in:
Abdessamad Derraz committed 2026-11-10 13:49:24 +01:00
commit ccd819ac97
29 files changed
+2068 -149

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 = "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712"
$defaultInstallSha256 = "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147"
# The pin above refuses any changed byte: this cap only stops a body that
# cannot be the installer (a sign-in portal's page) before it is hashed.
$maximumInstallerBytes = 8MB
+841 -46
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="456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712"
DEFAULT_INSTALL_SHA256="37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147"
# The pin above refuses any changed byte: this cap only stops a body that
# cannot be the installer (a sign-in portal's page) before it is hashed.
MAX_INSTALLER_BYTES=8388608
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 1162452,
"sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712",
"size": 1201450,
"sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 1216077,
"sha256": "b9e623839a8cf71d929a860b53a6c4c82cbfde551151912e1aba0347532be965"
"size": 1255075,
"sha256": "23393eb6b88f7a7875bd034e79c02d81e80409c9403290bb96a75166bf7a60b6"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 1162452,
"sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712",
"size": 1201450,
"sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 1162452,
"sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712",
"size": 1201450,
"sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147",
"executable": false
},
{
+57
View File
@@ -38,6 +38,7 @@
"downloaded",
"summary",
"app",
"record",
"end"
]
}
@@ -127,6 +128,9 @@
{
"$ref": "#/$defs/app"
},
{
"$ref": "#/$defs/record"
},
{
"$ref": "#/$defs/end"
}
@@ -1091,6 +1095,59 @@
}
}
},
"record": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"platform",
"folder",
"path",
"status",
"choice",
"date",
"files",
"message"
],
"properties": {
"event": {
"const": "record"
},
"platform": {
"type": "string"
},
"folder": {
"type": "string"
},
"path": {
"type": "string"
},
"status": {
"enum": [
"applied",
"written",
"unreadable",
"not_written"
]
},
"choice": {
"type": "array",
"items": {
"type": "string"
}
},
"date": {
"type": "string"
},
"files": {
"type": "integer",
"minimum": 0
},
"message": {
"type": "string"
}
}
},
"end": {
"type": "object",
"additionalProperties": false,
+3 -2
View File
@@ -37,10 +37,11 @@ KEY_TYPES: dict[str, dict] = {
**dict.fromkeys((
"installer", "os", "os_name", "stage", "reason", "platform", "message",
"folder", "dest", "system", "name", "bios_dir", "path", "member",
"list_version", "installed_version", "board", "core",
"list_version", "installed_version", "board", "core", "date",
), _TEXT),
"mode": {"enum": ["install", "check", "dry-run", "list"]},
"state": {"enum": ["present", "wrong_hash", "missing"]},
"status": {"enum": ["applied", "written", "unreadable", "not_written"]},
**dict.fromkeys((
"files", "files_done", "files_total", "bytes", "bytes_done", "bytes_total",
"bytes_local", "bytes_network", "size", "present", "missing", "wrong_hash",
@@ -48,7 +49,7 @@ KEY_TYPES: dict[str, dict] = {
"pending", "code", "games", "recognised", "not_games",
), _COUNT),
**dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied", "info"), _FLAG),
**dict.fromkeys(("systems", "other_drives", "dests", "written"), _NAMES),
**dict.fromkeys(("systems", "other_drives", "dests", "written", "choice"), _NAMES),
**dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept", "found",
"fallback"), _RECORDS),
}
+4 -4
View File
@@ -238,9 +238,9 @@ def render(record: dict, platforms_dir: str, whats_new: str,
folders = _extract_folders(record, platforms_dir)
tag = str(record.get("tag", ""))
lines = [
"### Get your BIOS",
"### Get the BIOS files",
"",
"Paste this in a terminal. It finds your setup and installs the missing BIOS files.",
"Paste this in a terminal. It finds the setup and installs the missing BIOS files.",
"",
"**Linux, macOS, Steam Deck**",
"```sh",
@@ -252,11 +252,11 @@ def render(record: dict, platforms_dir: str, whats_new: str,
install_command("powershell"),
"```",
"",
f"**On a handheld**: [get the app for your system]({SITE}{HANDHELD_PAGE}).",
f"**On a handheld**: [get the app for the system]({SITE}{HANDHELD_PAGE}).",
"",
"### Full packs",
"",
"Large downloads, for a device without internet. Extract into your BIOS folder.",
"Large downloads, for a device without internet. Extract into the BIOS folder.",
"",
]
if any(len(pack["assets"]) > 1 for pack in packs.values()):
+41
View File
@@ -1064,3 +1064,44 @@
^[[0;1;35mA/Enter^[[0m Back
=== plan-record
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
For nintendo-gba.
^[[0;7;35m > Install^[[0m
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help
+13
View File
@@ -336,3 +336,16 @@
^[[0;2m Shortened to fit this window.^[[0m
^[[0;1;35mA^[[0m Back
=== plan-record
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
BIOS folder:
^[[0;1m~/.config/retroarch/system^[[0m
^[[0;7;35m > Install^[[0m
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
^[[0;2m 1-4 of 8^[[0m
^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help
+16
View File
@@ -414,3 +414,19 @@
^[[0;1;35mA^[[0m Back
=== plan-record
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
BIOS folder:
^[[0;1m~/.config/retroarch/system^[[0m
For nintendo-gba.
^[[0;7;35m > Install^[[0m
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help
+25
View File
@@ -648,3 +648,28 @@
^[[0;1;35mA/Enter^[[0m Back
=== plan-record
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
For nintendo-gba.
^[[0;7;35m > Install^[[0m
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help
+40
View File
@@ -0,0 +1,40 @@
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A/Enter Install B/Esc Back Y/o Options ? Help
+12
View File
@@ -0,0 +1,12 @@
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install B Back Y Options ? Help
+15
View File
@@ -0,0 +1,15 @@
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options ? Help
+24
View File
@@ -0,0 +1,24 @@
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A/Enter Install B/Esc Back Y/o Options ? Help
+54
View File
@@ -1183,3 +1183,57 @@
A Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install B Back Y Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
+54
View File
@@ -1183,3 +1183,57 @@
A Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install B Back Y Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
+54
View File
@@ -1183,3 +1183,57 @@
A Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install B Back Y Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
+54
View File
@@ -1348,3 +1348,57 @@
Enter Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
Enter Install Esc Back o Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
Enter Install Esc Back o Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
Enter Install Esc Back o Options ? Help
+54
View File
@@ -1286,3 +1286,57 @@
A Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install R2 Back L2 Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install R2 Back L2 Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A Install R2 Back L2 Options
+54
View File
@@ -1402,3 +1402,57 @@
Start Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
Start Install B Back
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
Start Install B Back
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
Start Install B Back
+1 -1
View File
@@ -1697,7 +1697,7 @@ class InstallerVariablesAreDocumented(unittest.TestCase):
--dry-run, and no page named it or the folder it defaults to."""
# Set by the full-screen interface for the runs it starts, never by a user.
INTERNAL = {"RETROBIOS_TUI_LIST"}
INTERNAL = {"RETROBIOS_TUI_LIST", "RETROBIOS_TUI_RECORDED"}
def test_every_variable_install_py_reads_is_in_the_table(self):
source = (ROOT / "install.py").read_text(encoding="utf-8")
+16 -2
View File
@@ -6954,10 +6954,19 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
# The last transfer: every byte came over the network.
self.assertEqual([e for e in events if e["event"] == "transfer"][-1],
{"event": "transfer", "bytes_local": 0, "bytes_network": 245})
record = next(e for e in events if e["event"] == "record")
# What the run placed, and its choice, the whole list: written
# right before the summary.
self.assertEqual({key: record[key] for key in ("folder", "status", "choice", "files")},
{"folder": str(self.bios), "status": "written",
"choice": ["--all-files"], "files": 3})
self.assertEqual(record["path"], str(self.home / install.BACKUP_DIR
/ f"{self.bios.name}.install.json"))
self.assertEqual([e for e in events if e["event"] not in ("progress", "transfer")],
self._head("install", installer) + [
{"event": "download", "platform": "retroarch", "files": 3, "bytes": 245},
*done,
record,
{"event": "summary", "mode": "install", "installed": 3, "up_to_date": 1,
"pending": 0, "failed": [], "skipped": self.SKIPPED, "left_out": [],
"kept": [{"folder": kept[0]["folder"], "files": 1, "bytes": 11}],
@@ -7565,7 +7574,11 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree):
"--no-input", "--jobs", "1", "--verbose")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual((self.bios / "b.bin").read_bytes(), self.FILES["bios/A/b.bin"])
runs = list((self.home / install.BACKUP_DIR).iterdir())
# The install record sits beside the run folders, a file of its own.
held = list((self.home / install.BACKUP_DIR).iterdir())
runs = [path for path in held if path.is_dir()]
self.assertEqual(sorted(path.name for path in held if not path.is_dir()),
[f"{self.bios.name}.install.json"])
self.assertEqual(len(runs), 1)
self.assertIn("Kept 1 file already there (11 B), which the collection's copies "
f"replaced, in {runs[0]}", proc.stdout)
@@ -7594,7 +7607,8 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree):
again = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios),
"--no-input", "--jobs", "1")
self.assertEqual(again.returncode, 0, again.stderr)
self.assertEqual(len(list((self.home / install.BACKUP_DIR).iterdir())), 2)
self.assertEqual(len([path for path in (self.home / install.BACKUP_DIR).iterdir()
if path.is_dir()]), 2)
+315 -34
View File
@@ -25,6 +25,7 @@ import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
@@ -41,15 +42,17 @@ ESC = "\x1b"
# (keys to start the install or reach the answer, keys in all). One system
# is reached through None and the filter, the other device's folder typed
# as ~/bios.
# as ~/bios. With one setup found the screen opens on its plan: one key
# fewer, and one more for another device, whose Esc goes back to the
# setups. The second run of one system takes its install record's choice.
JOURNEY_KEYS = {
"first_install": (2, 3),
"card_with_games": (2, 3),
"update": (2, 3),
"one_system": (14, 15),
"one_system_again": (2, 3),
"what_is_missing": (5, 6),
"another_device": (19, 20),
"first_install": (1, 2),
"card_with_games": (1, 2),
"update": (1, 2),
"one_system": (13, 14),
"one_system_again": (1, 2),
"what_is_missing": (4, 5),
"another_device": (20, 21),
}
# Words each journey's screens show by default, summed over the screens it
@@ -58,13 +61,15 @@ JOURNEY_KEYS = {
# word: its length is the machine's. A change that adds words raises a
# number here and says why.
JOURNEY_WORDS = {
"first_install": 63,
"card_with_games": 106,
"update": 79,
"one_system": 193,
"one_system_again": 68,
"what_is_missing": 223,
"another_device": 230,
"first_install": 47,
"card_with_games": 87,
"update": 70,
"one_system": 179,
"one_system_again": 67,
"what_is_missing": 207,
# The plan of the setup found comes first now (33 words), the setups
# one Esc away, and the last plan names Change where (2 words).
"another_device": 265,
}
# Which setup? names every setup, Preview every file: lists, not prose.
SCREEN_CEILING = 90
@@ -75,7 +80,9 @@ LINE_TEXT = {
"card_with_games": (4, 29),
"update": (5, 36),
"one_system": (4, 18),
"what_is_missing": (3, 15),
# After one system installed, the check counts the install record's
# choice: one line says so, and how to count every file.
"what_is_missing": (4, 25),
"another_device": (4, 18),
}
@@ -221,10 +228,9 @@ class Journeys(unittest.TestCase):
repo = Repository(self)
dest = repo.retroarch_here()
term = self.start(repo)
screen = term.wait("Where to install?", timeout=40)
self.assertIn("> RetroArch", screen)
self.press(term, "\r")
term.wait("files to download", timeout=40)
# The one setup found: its plan first.
screen = term.wait("files to download", timeout=40)
self.assertIn("Change where", screen)
self.press(term, "\r")
started = self.keys
self.assertEqual(self.finish(term), 0)
@@ -241,8 +247,6 @@ class Journeys(unittest.TestCase):
(card / "ROMS" / "GBA").mkdir(parents=True)
(card / "ROMS" / "GBA" / "unknown.gba").write_bytes(b"not in any catalog")
term = self.start(repo, env={"JOURNEY_CARD": str(card)})
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
screen = term.wait("For the games on this card: yes", timeout=60)
self.assertIn("For the 1 game on this card.", screen)
self.press(term, "\r")
@@ -265,8 +269,6 @@ class Journeys(unittest.TestCase):
changed["size"] += 1
repo.write_list()
term = self.start(repo)
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
term.wait("1 file to download", timeout=40)
self.press(term, "\r")
started = self.keys
@@ -278,8 +280,6 @@ class Journeys(unittest.TestCase):
repo = Repository(self)
dest = repo.retroarch_here()
term = self.start(repo)
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
term.wait("files to download", timeout=40)
for _step in range(12):
if "> Choose systems" in term.settle():
@@ -298,24 +298,25 @@ class Journeys(unittest.TestCase):
self.assertEqual(self.finish(term), 0)
self.counted("one_system", started, self.keys)
self.assertEqual(written(dest), ["gba_bios.bin"])
# The next release: the earlier choice is not kept, and two keys
# install the whole list.
# The next release changes its file: the plan opens on the same
# choice, read from the install record, and one key installs it.
changed = next(e for e in repo.listed["files"] if e["dest"] == "gba_bios.bin")
changed["size"] += 1
repo.write_list()
term = self.start(repo)
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
term.wait("files to download", timeout=40)
screen = term.wait("1 file to download", timeout=40)
self.assertRegex(screen, r"Same choice as on \d+ [A-Z][a-z]{2}: yes")
self.press(term, "\r")
started = self.keys
self.assertEqual(self.finish(term), 0)
self.counted("one_system_again", started, self.keys)
self.assertEqual(written(dest), sorted(e["dest"] for e in repo.listed["files"]))
self.assertEqual(written(dest), ["gba_bios.bin"])
self.assertEqual((dest / "gba_bios.bin").stat().st_size, changed["size"])
def test_what_is_missing(self):
repo = Repository(self)
repo.retroarch_here()
term = self.start(repo)
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
term.wait("files to download", timeout=40)
self.press(term, "o")
term.wait("Each row is an option")
@@ -332,6 +333,9 @@ class Journeys(unittest.TestCase):
repo = Repository(self)
repo.retroarch_here()
term = self.start(repo)
# The plan of the one setup found first: Esc goes back to the setups.
term.wait("files to download", timeout=40)
self.press(term, ESC)
term.wait("Where to install?", timeout=40)
for _step in range(12):
if "> Another setup or folder" in term.settle():
@@ -478,5 +482,282 @@ class LineModeJourneys(unittest.TestCase):
self.run_line(repo, *base, "--check", "--verbose").stdout)
def events(proc: subprocess.CompletedProcess, kind: str | None = None) -> list[dict]:
said = [json.loads(line) for line in proc.stdout.splitlines() if line.startswith("{")]
return [event for event in said if kind is None or event["event"] == kind]
class InstallRecord(unittest.TestCase):
"""What an install placed, recorded beside the BIOS folder, and its
choice taken by the next run when nothing on the command line chose."""
def setUp(self):
self.repo = Repository(self)
self.dest = self.repo.root / "retroarch" / "system"
self.record = self.dest.parent / install.BACKUP_DIR / "system.install.json"
def run_line(self, *args: str, dest: Path | None = None) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
"--dest", str(dest or self.dest), *args],
env=self.repo.env, capture_output=True, text=True, timeout=120, check=False,
stdin=subprocess.DEVNULL)
def dests(self, proc: subprocess.CompletedProcess) -> list[str]:
return sorted(event["dest"] for event in events(proc, "file"))
def everything(self) -> list[str]:
return sorted(entry["dest"] for entry in self.repo.listed["files"])
def test_the_record_holds_each_file_written_and_its_hash(self):
proc = self.run_line("--system", "nintendo-gba", "--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
said = events(proc, "record")
self.assertEqual([event["status"] for event in said], ["written"])
self.assertEqual(said[0]["choice"], ["--system", "nintendo-gba"])
self.assertEqual((said[0]["path"], said[0]["files"]), (str(self.record), 1))
# Before the summary, its keys those of EVENT_KEYS.
kinds = [event["event"] for event in events(proc)]
self.assertEqual(kinds.index("record") + 1, kinds.index("summary"))
self.assertEqual(list(said[0])[1:], list(install.EVENT_KEYS["record"]))
kept = json.loads(self.record.read_text(encoding="utf-8"))
files = kept["setups"]["retroarch"]["files"]
self.assertEqual([item["dest"] for item in files], ["gba_bios.bin"])
self.assertEqual(files[0]["sha1"], hashlib.sha1(
(self.dest / "gba_bios.bin").read_bytes()).hexdigest())
self.assertEqual((files[0]["from"], files[0]["root"]), ("download", ""))
self.assertEqual(kept["folder"], str(self.dest))
# Nothing of it in the BIOS folder, which some cores read whole.
self.assertEqual(written(self.dest), ["gba_bios.bin"])
def test_the_next_run_takes_the_recorded_choice(self):
self.run_line("--system", "nintendo-gba", "--yes")
(self.dest / "gba_bios.bin").unlink()
proc = self.run_line("--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stderr)
kinds = [event["event"] for event in events(proc)]
self.assertLess(kinds.index("record"), kinds.index("choices"))
applied = events(proc, "record")[0]
self.assertEqual((applied["status"], applied["choice"]),
("applied", ["--system", "nintendo-gba"]))
self.assertEqual(self.dests(proc), ["gba_bios.bin"])
self.assertEqual(written(self.dest), ["gba_bios.bin"])
(self.dest / "gba_bios.bin").unlink()
proc = self.run_line("--yes")
self.assertRegex(proc.stdout, r"Installing 1 file \(.*\) for RetroArch, same choice "
r"as on \d+ [A-Z][a-z]{2}\.")
def test_the_command_line_replaces_the_record(self):
self.run_line("--system", "nintendo-gba", "--yes")
self.assertEqual(self.dests(self.run_line("--system", "sega-megadrive", "--check",
"--events")),
sorted(e["dest"] for e in self.repo.listed["files"]
if "sega-megadrive" in e.get("systems", [])))
whole = self.run_line("--all-files", "--check", "--events")
self.assertEqual(self.dests(whole), self.everything())
self.assertEqual(events(whole, "record"), [])
def test_a_record_that_cannot_be_read_is_said_once_and_ignored(self):
self.dest.mkdir(parents=True)
self.record.parent.mkdir()
self.record.write_text("{not json", encoding="utf-8")
proc = self.run_line("--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stderr)
said = events(proc, "record")
self.assertEqual([event["status"] for event in said], ["unreadable", "written"])
self.assertIn("is damaged, so it is ignored.", said[0]["message"])
# Said once to a person (standard error under --events), once as an event.
self.assertEqual(proc.stderr.count("so it is ignored"), 1)
self.assertEqual(self.dests(proc), self.everything())
# Replaced by a record that reads.
self.assertEqual(json.loads(self.record.read_text())["schema"], install.RECORD_SCHEMA)
for text in ('{"schema": 99, "setups": {}}', '{"schema": 1, "setups": {"retroarch": 3}}',
"\xff\xfe"):
with self.subTest(record=text):
self.record.write_bytes(text.encode("latin-1"))
proc = self.run_line("--check", "--events")
self.assertEqual(events(proc, "record")[0]["status"], "unreadable")
self.assertEqual(self.dests(proc), self.everything())
def test_a_record_for_another_folder_is_not_applied(self):
self.run_line("--system", "nintendo-gba", "--yes")
# A sibling folder shares the holder, never the record.
other = self.dest.parent / "bios"
proc = self.run_line("--check", "--events", dest=other)
self.assertEqual(events(proc, "record"), [])
self.assertEqual(self.dests(proc), self.everything())
# The record copied under the sibling's name still names the first.
shutil.copy(self.record, self.record.with_name("bios.install.json"))
proc = self.run_line("--check", "--events", dest=other)
self.assertEqual(events(proc, "record"), [])
self.assertEqual(self.dests(proc), self.everything())
# The record of another setup in the same folder is not this one's.
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "batocera",
"--dest", str(self.dest), "--check", "--events"],
env=self.repo.env, capture_output=True, text=True, timeout=120, check=False,
stdin=subprocess.DEVNULL)
self.assertEqual(events(proc, "record"), [])
self.assertEqual(self.dests(proc), self.everything())
def test_line_mode_chooses_what_the_screen_chooses(self):
"""The plan's choice from the record is the same set of files on the
screen and in line mode: the screen's options, given to a run, and a
run given none."""
self.run_line("--system", "nintendo-gba", "--yes")
(self.dest / "gba_bios.bin").unlink()
state = install.TuiState()
state.platform, state.dest = "retroarch", self.dest
install._tui_record_load(state)
self.assertTrue(install.tui_record_kept(state))
flags = [flag if value is None else f"{flag}={value}"
for flag, value in install.tui_flags(state)]
screen = self.run_line(*flags, "--dry-run", "--events")
line = self.run_line("--dry-run", "--events")
self.assertEqual(self.dests(screen), ["gba_bios.bin"])
self.assertEqual(self.dests(line), self.dests(screen))
self.assertEqual(events(line, "check")[0]["bytes_needed"],
events(screen, "check")[0]["bytes_needed"])
def record_text(folder: Path, choice: list[str], **setups: object) -> str:
"""A record of folder whose retroarch setup chose choice."""
setup = {"written": "2026-11-03T12:00:00Z", "list": {}, "choice": choice,
"files": [{"dest": "gba_bios.bin", "root": "", "sha1": "0" * 40, "size": 1,
"from": "download"}]}
return json.dumps({"schema": install.RECORD_SCHEMA, "installer": "x", "folder": str(folder),
"setups": {"retroarch": setup, **setups}})
class RecordReview(unittest.TestCase):
"""A record is data another program or a person may have written: what
it holds is checked before any of it is used or printed."""
setUp = InstallRecord.setUp
run_line = InstallRecord.run_line
dests = InstallRecord.dests
everything = InstallRecord.everything
def write(self, choice: list[str], **setups: object) -> None:
self.dest.mkdir(parents=True, exist_ok=True)
self.record.parent.mkdir(exist_ok=True)
self.record.write_text(record_text(self.dest, choice, **setups), encoding="utf-8")
def test_a_word_outside_the_choice_rejects_the_whole_record(self):
for choice in (["--system", "nintendo-gba", "--from", "/mnt/x"],
["--manifest", "http://example.invalid/list.json"],
["--system", "nintendo-gba", "--dest", "/etc"],
["--system", "--yes"], ["--system", "a\nb"], ["nintendo-gba"]):
with self.subTest(choice=choice):
self.write(choice)
proc = self.run_line("--check", "--events")
said = events(proc, "record")
self.assertEqual([event["status"] for event in said], ["unreadable"], said)
self.assertEqual(self.dests(proc), self.everything())
text = proc.stdout + proc.stderr
for word in ("/mnt/x", "example.invalid", "/etc"):
self.assertNotIn(word, text)
# The screen reads it through the same function.
state = install.TuiState()
state.platform, state.dest = "retroarch", self.dest
install._tui_record_load(state)
self.assertIsNone(state.record)
self.assertEqual(install.tui_flags(state), [])
def test_a_recorded_games_folder_stays_beside_the_bios_folder(self):
parent = self.dest.parent
self.assertTrue(install.games_folder_kept(str(parent / "roms"), self.dest))
self.assertTrue(install.games_folder_kept(str(parent), self.dest))
for value in ("/etc", str(parent.parent / "other"), "roms", "\\\\server\\share",
"//server/share", ""):
with self.subTest(value=value):
self.assertFalse(install.games_folder_kept(value, self.dest))
self.write(["--for-games", "/etc"])
proc = self.run_line("--check", "--events")
self.assertEqual(events(proc, "record")[0]["status"], "unreadable")
self.assertEqual(self.dests(proc), self.everything())
def test_a_recorded_option_that_cannot_apply_is_left_out(self):
"""A run nobody typed options for went on failing: the list has no
cores, no categories and no board list, and a region is unknown."""
for choice, typed in ((["--system", "nintendo-gba", "--region", "atlantis"],
["--region", "atlantis"]),
(["--system", "nintendo-gba", "--emulator", "nosuch"],
["--emulator", "nosuch"]),
(["--system", "nintendo-gba", "--installed-cores"],
["--installed-cores"]),
(["--system", "nintendo-gba", "--target", "nosuch"],
["--target", "nosuch"]),
(["--system", "nintendo-gba", "--no-game-data"],
["--no-game-data"])):
with self.subTest(choice=choice):
self.write(choice)
proc = self.run_line("--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual(proc.stderr.count("Left out of the recorded choice"), 1,
proc.stderr)
self.assertEqual(self.dests(proc), ["gba_bios.bin"])
# Written again without it: the next run says nothing.
kept = json.loads(self.record.read_text())["setups"]["retroarch"]["choice"]
self.assertEqual(kept, ["--system", "nintendo-gba"])
# Typed, it is refused as before.
self.assertNotEqual(self.run_line(*typed, "--check").returncode, 0)
def test_a_date_no_clock_can_show_is_an_unknown_day(self):
if not hasattr(time, "tzset"):
self.skipTest("no tzset")
zone = os.environ.get("TZ")
def back() -> None:
if zone is None:
os.environ.pop("TZ", None)
else:
os.environ["TZ"] = zone
time.tzset()
self.addCleanup(back)
for tz, written in (("UTC+5", "0001-01-01T00:00:00Z"),
("UTC-5", "9999-12-31T23:59:59Z")):
with self.subTest(written=written):
os.environ["TZ"] = tz
time.tzset()
self.assertEqual(install.record_day({"written": written}), "")
def test_a_record_that_is_no_file_is_never_read(self):
self.dest.mkdir(parents=True)
self.record.parent.mkdir()
os.mkfifo(self.record)
proc = self.run_line("--check", "--events")
self.assertIn("it is not a file", events(proc, "record")[0]["message"])
self.assertEqual(self.dests(proc), self.everything())
self.record.unlink()
with open(self.record, "wb") as handle:
handle.truncate(install.MAX_RECORD_BYTES + 1)
self.assertIn("larger than any record", install.load_record(self.dest).problem)
def test_a_newer_record_is_left_as_it_is(self):
self.dest.mkdir(parents=True)
self.record.parent.mkdir()
newer = json.dumps({"schema": install.RECORD_SCHEMA + 1, "setups": {"x": {}}})
self.record.write_text(newer, encoding="utf-8")
proc = self.run_line("--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual([event["status"] for event in events(proc, "record")],
["unreadable", "not_written"])
self.assertEqual(self.record.read_text(encoding="utf-8"), newer)
self.assertEqual(proc.stderr.count("written by a newer installer"), 1)
def test_a_bad_setup_is_dropped_and_the_others_kept(self):
good = {"written": "2026-11-03T12:00:00Z", "list": {}, "choice": ["--all-files"],
"files": []}
self.write(["--system", "nintendo-gba"], lakka=good, batocera={"choice": 3})
path, _setup, problem = install.write_record(self.dest, "retropie", {}, ["--all-files"],
[])
self.assertEqual((path, problem), (self.record, ""))
kept = json.loads(self.record.read_text())["setups"]
self.assertEqual(sorted(kept), ["lakka", "retroarch", "retropie"])
self.assertEqual(kept["retroarch"]["choice"], ["--system", "nintendo-gba"])
if __name__ == "__main__":
unittest.main()
+224 -44
View File
@@ -378,6 +378,14 @@ def screen_text(lines) -> str:
return re.sub(r"(?<=[/\\]) ", "", " ".join(" ".join(lines).split()))
def listed(state: install.TuiState) -> install.TuiState:
"""The setups found, as Back shows them from the plan the screen opens
on when it found one setup."""
if state.opened_on_plan and state.screen != "setup":
install.tui_update(state, Key("esc"))
return state
def fresh_state() -> install.TuiState:
state = install.TuiState(os_type="linux", home=HOME)
state.setups = [("retroarch", SYSTEM_DIR, "RetroArch"),
@@ -761,7 +769,9 @@ class Flow(unittest.TestCase):
mock.patch.object(install, "detect_platforms", return_value=[]), \
mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(), "linux")
self.assertEqual((state.screen, effects), ("setup", []))
# The one setup found: its plan first, the list one Back away.
self.assertEqual((state.screen, effects), ("checking", [("fetch", "minui")]))
self.assertEqual(listed(state).screen, "setup")
label = f"MinUI SD card ({card.name})"
self.assertEqual(state.setups, [("minui", card, label)])
self.assertEqual([row.label for row in install.tui_rows(state)],
@@ -1032,9 +1042,55 @@ def golden_states() -> dict:
told = golden_states_result()
told.stack.append("details")
states["result-details"] = told
states["plan-record"] = record_state()
return states
def record_setup(choice: list[str] | None = None) -> dict:
"""A record kept on 3 November of this year: the plan says 3 Nov in
any year it runs."""
year = time.localtime().tm_year
return {"written": f"{year}-11-03T12:00:00Z", "list": {"version": "v1", "generated": ""},
"choice": ["--system", "nintendo-gba"] if choice is None else choice,
"files": [{"dest": "gba_bios.bin", "root": "", "sha1": "0" * 40, "size": 16384,
"from": "download"}]}
def record_state(setup: dict | None = None) -> install.TuiState:
"""The plan of a RetroArch folder whose record kept Game Boy Advance,
its file changed by a release since, opened on the one setup found."""
setup = record_setup() if setup is None else setup
state = fresh_state()
state.setups = state.setups[:1]
manifest = json.loads(json.dumps(MANIFEST))
manifest["files"][2]["sha1"] = "1" * 40
with mock.patch.object(install, "setup_record",
return_value=(setup, SYSTEM_DIR.parent / "x", "")), \
mock.patch.object(install.Path, "exists", return_value=True), \
mock.patch.object(install, "found_setups",
return_value=(state.setups, set())), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_DETECTION_REFUSED", []), \
mock.patch.object(install.Path, "home", return_value=HOME):
state, effects = install.tui_start(_args(), "linux")
assert effects == [("fetch", "retroarch")], effects
# Drawn as the other screens are, whatever the shell running the tests.
state.home, state.pad = HOME, "both"
effects = install.tui_receive(state, ("manifest", "retroarch", manifest))
assert effects == [("run", "check")], effects
gba = [event for event in FILES if event["dest"] == "gba_bios.bin"]
systems = [event for event in SYSTEMS if event["system"] == "nintendo-gba"]
check = {**CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0,
"bytes_needed": 16384, "not_installable": 0}
for event in [*gba, *systems, check]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={
"free": 50 * 1024 ** 3, "writable": True, "cores": ["pcsx_rearmed"]}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
return state
def golden_states_result() -> install.TuiState:
"""The result golden_states draws, a file failed and one kept."""
result = checked_state()
@@ -1075,6 +1131,132 @@ def result_states() -> dict:
}
class RecordedPlan(unittest.TestCase):
"""The plan of a folder with an install record: opened on the one setup
found, on the recorded choice, one press from installing it again."""
def row(self, state: install.TuiState, action: str) -> install.TuiRow:
return next(row for row in install.tui_rows(state) if row.action == action)
def test_opens_on_the_plan_of_the_one_setup_found(self):
state = record_state()
self.assertTrue(state.opened_on_plan)
self.assertEqual(state.stack, ["setup", "plan"])
self.assertEqual(install.tui_rows(state)[0].action, "install")
press(state, "end", "up", "up", "up")
self.assertEqual(install.tui_rows(state)[state.focus["plan"]].label, "Change where")
press(state, "enter")
self.assertEqual(state.screen, "setup")
def test_the_notes_of_the_setups_screen_are_on_the_plan(self):
"""Opened on the plan, the ES-DE note and the setup's label, which
only the setups screen showed, were never seen."""
setups = fresh_state().setups[:1]
with mock.patch.object(install, "found_setups", return_value=(setups, set())), \
mock.patch.object(install, "detect_frontends", return_value=["esde"]), \
mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_DETECTION_REFUSED", []), \
mock.patch.object(install.Path, "exists", return_value=True):
state, _effects = install.tui_start(_args(), "linux")
self.assertTrue(state.opened_on_plan)
install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
for event in [*FILES, *SYSTEMS, CHECK]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={
"free": 50 * 1024 ** 3, "writable": True}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
state.pad = "both"
plan = screen_text(install.tui_view(state, 80, 24))
self.assertIn("1 note on this machine: Details.", plan)
self.assertNotIn(install.TUI_ESDE_NOTE, plan)
# The plan's budget, plus the note line (6 words) and Change where (2).
self.assertLessEqual(screen_words(install.tui_view(state, 80, 24)),
SCREEN_WORDS["plan"][2] + 8)
state.focus["plan"] = [row.action for row in install.tui_rows(state)].index("details")
press(state, "enter")
details = screen_text(install.tui_view(state, 120, 40))
self.assertIn(screen_text([install.TUI_ESDE_NOTE]), details)
self.assertIn("Found here: RetroArch.", details)
def test_several_setups_open_the_list(self):
with mock.patch.object(install, "found_setups", return_value=(
fresh_state().setups, set())), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_DETECTION_REFUSED", []):
state, effects = install.tui_start(_args(), "linux")
self.assertEqual((state.screen, effects, state.opened_on_plan), ("setup", [], False))
def test_one_press_installs_the_recorded_choice(self):
state = record_state()
self.assertEqual(self.row(state, "record").label, "Same choice as on 3 Nov: yes")
self.assertIn("For nintendo-gba.", screen_text(
install.tui_view(state, 80, 24)))
self.assertEqual(press(state, "enter"), [("run", "install")])
argv = install.tui_argv(state, "install", "py", "install.py")
self.assertIn("--system=nintendo-gba", argv)
self.assertNotIn("--all-files", argv)
def test_no_takes_the_whole_list_and_yes_brings_it_back(self):
state = record_state()
state.focus["plan"] = install.tui_rows(state).index(self.row(state, "record"))
with mock.patch.object(install, "games_probe", return_value=None):
effects = press(state, "enter")
self.assertEqual(effects, [("run", "check")])
flags = install.tui_flags(state)
self.assertIn(("--all-files", None), flags)
self.assertFalse([flag for flag in flags if flag[0] == "--system"])
self.assertFalse(install.tui_record_kept(state))
for event in [*FILES, *SYSTEMS, CHECK]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={
"free": 50 * 1024 ** 3, "writable": True}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
self.assertEqual(self.row(state, "record").label, "Same choice as on 3 Nov: no")
self.assertEqual(install.tui_rows(state)[state.focus["plan"]].action, "record")
self.assertEqual(press(state, "enter"), [("run", "check")])
self.assertIn(("--system", "nintendo-gba"), install.tui_flags(state))
self.assertTrue(install.tui_record_kept(state))
def test_the_command_line_choice_wins(self):
with mock.patch.object(install, "setup_record",
return_value=(record_setup(), SYSTEM_DIR, "")), \
mock.patch.object(install.Path, "exists", return_value=True):
state, _effects = install.tui_start(
_args(platform="retroarch", dest=str(SYSTEM_DIR), system=["sony-playstation"]),
"linux", detect=False)
self.assertEqual(install._tui_system_flags(state), [("--system", "sony-playstation")])
self.assertFalse(install.tui_record_kept(state))
self.assertEqual(install._record_rows(state), [])
def test_a_record_that_cannot_be_read_is_said_once(self):
problem = "The install record /x/system.install.json is damaged"
install._RECORD_SAID.discard("/x/system.install.json")
with mock.patch.object(install, "setup_record",
return_value=(None, Path("/x/system.install.json"), problem)), \
mock.patch.object(install.Path, "exists", return_value=True):
state, _effects = install.tui_start(
_args(platform="retroarch", dest=str(SYSTEM_DIR)), "linux", detect=False)
self.assertEqual(str(state.status), f"{problem}, so it is ignored.")
state.status, state.record_for = "", None
install._tui_record_load(state)
self.assertEqual(state.status, "")
self.assertIsNone(state.record)
self.assertNotIn("--all-files", str(install.tui_flags(state)))
def test_the_install_row_says_the_size_the_headline_says(self):
"""14930 files to download, 6.8 GB stood above Install 6.6 GB: the
data files counted in one, the copies in the other."""
big = 2 * 1024 ** 3
state = checked_state()
state.files[0] = {**state.files[0], "size": big}
state.check = {**state.check, "bytes_needed": state.check["bytes_needed"] + big,
"data_missing": 3, "data_bytes": 300 * 1024 ** 2}
headline = str(install._plan_lines(state.check, state.dest, state)[0])
size = headline.rsplit(", ", 1)[1]
self.assertEqual(install.tui_rows(state)[0].label, f"Install {size}")
class Views(unittest.TestCase):
"""Every screen as text, at the sizes a handheld and a desktop show."""
@@ -2387,6 +2569,9 @@ class CardsSpaceAndConfirm(unittest.TestCase):
mock.patch.object(install, "detect_platforms", return_value=[]), \
mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(), "linux")
self.assertEqual(effects, [("fetch", "retroarch")])
self.assertEqual(state.dest, root / "MUOS" / "bios")
listed(state)
label = f"muOS SD card ({root.name})"
self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)])
text = screen_text(install.tui_view(state, 120, 40))
@@ -4527,6 +4712,14 @@ class WindowsHasNoPosixModules(unittest.TestCase):
# --- End to end, on a pseudo-terminal -------------------------------------
def on_setups(term: Pty) -> str:
"""The setups found, one Back from the plan the screen opens on when it
found one setup: the first screen as it was before."""
term.wait("> Install")
term.send("\x1b")
return term.wait("Where to install?")
class Pty:
"""install.py on a pseudo-terminal, its screen read back through pyte."""
@@ -4925,14 +5118,13 @@ class EndToEnd(unittest.TestCase):
self.assertAppOnCard(root, "KNULLI")
self.assertIn("--add-app", term.after())
def test_three_presses_install_everything(self):
def test_two_presses_install_everything(self):
repo = self.repo()
term = self.start()
screen = term.wait("Where to install?")
self.assertIn("> RetroArch", screen)
term.send("\r")
# The one setup found here: its plan first.
screen = term.wait("files to download")
self.assertIn("5 files to download, 30 B", screen)
self.assertIn("Change where", screen)
self.assertIn("> Install", screen)
term.send("\r")
term.wait("5 files installed.")
@@ -4979,8 +5171,6 @@ class EndToEnd(unittest.TestCase):
self.assertIn("6 files installed", proc.stdout)
else:
term = self.start()
term.wait("Where to install?")
term.send("\r")
screen = term.wait("files to download")
self.assertIn("6 files to download", screen)
# The second folder the run writes to, named on the
@@ -5159,7 +5349,7 @@ class EndToEnd(unittest.TestCase):
def test_ctrl_c_on_the_first_screen(self):
self.repo()
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
term.send("\x03")
self.assertEqual(term.close(), 130)
self.assertIn(b"\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l", term.raw)
@@ -5169,7 +5359,7 @@ class EndToEnd(unittest.TestCase):
self.repo()
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
mark = len(term.raw)
os.kill(term.pid, signal.SIGTSTP)
end = time.monotonic() + 10
@@ -5182,7 +5372,7 @@ class EndToEnd(unittest.TestCase):
self.assertIn(b"\x1b[?25h\x1b[?1049l", term.raw[mark:])
mark = len(term.raw)
os.kill(term.pid, signal.SIGCONT)
term.wait("A/Enter Continue")
term.wait("A/Enter Install")
end = time.monotonic() + 5
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
@@ -5194,11 +5384,11 @@ class EndToEnd(unittest.TestCase):
"""The terminal gone, the farewell has nowhere to go: the code is
129 (SIGHUP), not 120 from the flush failing at exit."""
self.repo()
for screen_name, keys_first in (("Where to install?", ()),
("files to download", ("\r",))):
for screen_name, keys_first in (("files to download", ()),
("Where to install?", ("\x1b",))):
with self.subTest(screen=screen_name):
term = self.start()
term.wait("Where to install?")
term.wait("files to download")
term.send(*keys_first)
term.wait(screen_name)
os.close(term.fd)
@@ -5247,12 +5437,12 @@ class EndToEnd(unittest.TestCase):
self.repo()
self.env[install.TUI_IDLE_ENV] = "1"
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
self.assertEqual(term.close(), install.QUIT_CODE)
self.assertIn("No button pressed, closing.", term.after())
# A key pressed, the screen stays: the person is there.
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
term.send(DOWN)
end = time.monotonic() + 2.5
while time.monotonic() < end:
@@ -5282,7 +5472,7 @@ class EndToEnd(unittest.TestCase):
with self.subTest(env=env):
self.env.update(env)
term = self.start(cols=40, rows=12)
term.wait("Where to install?")
on_setups(term)
screen = term.wait(bar)
self.assertEqual(screen.splitlines()[-1].strip(), bar)
term.send("q")
@@ -5293,7 +5483,7 @@ class EndToEnd(unittest.TestCase):
def test_resize_redraws(self):
self.repo()
term = self.start(cols=40, rows=15)
term.wait("Where to install?")
on_setups(term)
term.resize(100, 30)
term.send(DOWN)
screen = term.wait("> Another setup or folder")
@@ -5379,7 +5569,7 @@ class EndToEnd(unittest.TestCase):
self.env.pop("NO_COLOR", None)
self.env.update({"TERM": "xterm", **env})
term = self.start()
screen = term.wait("Where to install?")
screen = on_setups(term)
self.assertIn("> RetroArch", screen)
screens[name] = screen
# pyte reads the colours drawn: RetroBIOS in the accent.
@@ -5612,7 +5802,7 @@ class TheTerminalComesBack(unittest.TestCase):
def ended(self, how) -> Pty:
self.repo()
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
# Every way out below starts with the clicks asked for, written
# right after the frame.
term.settle()
@@ -5672,7 +5862,7 @@ class TheTerminalComesBack(unittest.TestCase):
job control it stops, and fg brings the screen back."""
term = self.shell()
self.typed(term, f"{shlex.quote(sys.executable)} {REPO_ROOT / 'install.py'} --tui")
term.wait("Where to install?")
term.wait("> Install")
mark = len(term.raw)
term.send("\x1a")
end = time.monotonic() + 10
@@ -5687,7 +5877,7 @@ class TheTerminalComesBack(unittest.TestCase):
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
self.assertIn(b"\x1b[?1049h", term.raw[mark:])
term.wait("Where to install?")
term.wait("> Install")
self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO)
# The clicks come back with the screen.
term.settle()
@@ -5709,7 +5899,7 @@ class TheTerminalComesBack(unittest.TestCase):
term = Pty(["--tui"], self.env, program=program)
self.addCleanup(lambda term=term: term.status is None
and os.killpg(term.pid, 9))
term.wait("Where to install?")
term.wait("> Install")
term.send("\x1a")
screen = term.wait("Ctrl-Z is ignored")
self.assertIn("no shell started this installer", " ".join(screen.split()))
@@ -5765,8 +5955,6 @@ class TheTerminalComesBack(unittest.TestCase):
self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9))
term.wait("PROMPT$")
self.typed(term, f"sh {REPO_ROOT / 'install.sh'} --jobs 1")
term.wait("Where to install?")
term.send("\r")
term.wait("to download")
term.send("\r")
term.wait("Installing")
@@ -10803,7 +10991,7 @@ class MouseOnAPty(unittest.TestCase):
def test_taps_choose_a_row_and_press_the_bar(self):
self.repo()
term = self.start()
term.wait("Where to install?")
on_setups(term)
term.settle()
self.assertIn(install._MOUSE_ON.encode(), term.raw)
row = self.line_of(term, "Another setup")
@@ -11455,8 +11643,6 @@ class TapsOnAPty(unittest.TestCase):
def test_taps_on_systems_tick_and_write_nothing(self):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
row = self.line_of(term, "Choose systems")
self.click(term, 20, row)
@@ -11482,8 +11668,6 @@ class TapsOnAPty(unittest.TestCase):
def test_the_result_is_left_by_a_tap_on_exit(self):
self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("files to download")
row = self.line_of(term, "> Install")
self.click(term, 20, row)
@@ -12365,8 +12549,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
def test_one_tap_on_the_bar_install_installs_nothing(self):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
term.settle()
self.click(term, *self.bar_entry(term, "Install"))
@@ -12380,7 +12562,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
def test_a_double_tap_on_continue_neither_quits_nor_installs(self):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
on_setups(term)
term.settle()
col, line = self.bar_entry(term, "Continue")
report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True)
@@ -12402,7 +12584,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
with self.subTest(gap=gap):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
on_setups(term)
term.settle()
time.sleep(install.TUI_TAP_SETTLE)
col, line = self.bar_entry(term, "Continue")
@@ -12422,8 +12604,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
def test_continue_then_install_in_one_cell_stops_on_confirm(self):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
row = self.line_of(term, "Choose systems")
self.click(term, 20, row)
@@ -12465,8 +12645,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
repo = self.repo(GameDataRepository)
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
for _step in range(8):
if "> Choose systems" in term.settle():
@@ -12508,8 +12686,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
def test_two_taps_on_a_region_untick_it_where_it_was(self):
self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
term.send("o")
term.wait("Preferred regions")
@@ -12662,8 +12838,10 @@ class ChoiceStateInBothModes(unittest.TestCase):
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install.Path, "home", return_value=home):
state, _effects = install.tui_start(_args(), "linux")
text = self.screen(state)
# Named on the first screen, its plan one Enter away.
# Its plan first, the setups one Back away.
self.assertEqual(state.dest, card / "bios")
text = self.screen(listed(state))
# Named on the setups, its plan one Enter away.
self.assertIn(f"> ArkOS SD card ({name}", text)
self.assertNotIn("Nothing was found here", text)
install.tui_update(state, Key("enter"))
@@ -12977,7 +13155,7 @@ class DriveIsACardByItsFiles(unittest.TestCase):
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
return listed(state)
def folder_screen(self, mounts: list) -> tuple[install.TuiState, str, list]:
"""RetroArch chosen on the first screen: (state, Which folder? at
@@ -13176,8 +13354,6 @@ class TheSettleRunsFromTheFrameShown(unittest.TestCase):
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 to install?")
term.send("\r")
term.wait("> Install")
term.settle()
term.send("o")
@@ -14949,6 +15125,10 @@ SCREEN_WORDS = {
"quit": (14, 14, 12, 12), "help": (24, 26, 26, 26), "plan-games": (36, 46, 54, 54),
"games": (36, 45, 44, 44), "plan-details": (30, 30, 29, 29),
"result-details": (30, 30, 19, 19),
# The plan's words, plus the recorded choice by name, its row (the one
# press back to the whole list) and Change where (back to the setups,
# the plan having opened on the one found).
"plan-record": (38, 42, 42, 42),
}
+1 -1
View File
@@ -347,7 +347,7 @@ class TheReleaseNotesNameTheSetupsWithoutAZip(unittest.TestCase):
# The installer's handheld section, which opens on every download.
handheld = text.index(f"{SITE}wiki/installer/#on-a-handheld-the-retrobios-port")
packs = text.index("### Full packs")
self.assertLess(text.index("### Get your BIOS"), command)
self.assertLess(text.index("### Get the BIOS files"), command)
self.assertLess(command, handheld)
self.assertLess(handheld, packs)
self.assertLess(packs, text.index("| Platform"))
+17
View File
@@ -186,6 +186,23 @@ class SitePronouns(unittest.TestCase):
hits = _hits(files)
self.assertEqual(hits, [], "\n".join(hits))
def test_release_notes(self):
"""The release page is public text too. Rendered from the committed
record, and once more with a pack in parts, its own paragraph."""
sys.path.insert(0, str(ROOT / "scripts"))
import release_notes
import release_record
record = release_record.load_record(ROOT / "release.json")
first = next(iter(record["packs"]))
parted = {**record, "packs": {**record["packs"], first: {
**record["packs"][first], "assets": ["a.part1of2.zip", "a.part2of2.zip"]}}}
platforms = str(ROOT / "platforms")
hits = [f"release notes:{number}: {line}"
for sample in (record, parted)
for number, line in pronouns(release_notes.render(sample, platforms, "x"))]
self.assertEqual(hits, [], "\n".join(hits))
def test_every_allowed_quote_says_why(self):
for quoted, reason in {**NAMES, **INSTALLER, **SOURCE}.items():
with self.subTest(quoted=quoted):
+2 -2
View File
@@ -68,7 +68,7 @@ On Batocera, Recalbox and similar setups, see
## 2. Check the setup it found
It opens a menu:
With one setup, it opens on **Install**. Otherwise:
```
RetroBIOS: Where to install?
@@ -91,7 +91,7 @@ touch screen, tap a row twice to open it, once to tick a box.
Press Enter on **Install**. **Choose systems** picks the consoles to
keep.
**Game data** (only a game reads it) has its own row.
**Game data** has its own row.
**Old card?** In a card reader, choose **Copy the BIOS files from an old
card or a folder**, then the card.
+67 -3
View File
@@ -169,8 +169,14 @@ Enter checks the BIOS folder when nothing can be copied.
## The full-screen interface { #the-full-screen-interface }
Run at a terminal, the installer opens a full screen that a keyboard or a
controller drives. Three presses of A or Enter install everything the setup
reads: the setup found here, the plan, then the result. The plan screen says
controller drives. With one setup found here, it opens on that setup's plan,
and two presses of A or Enter install everything it reads: the plan, then the
result. **Change where** (or B) goes back to the setups, which open first when
none or several are found. What that screen would have noted (ES-DE found, the
setup's name) is under **Details**, one plan line saying so. After an install, the plan of the same folder opens
on the same choice (**Same choice as on 10 Nov: yes**, the
[install record](#the-install-record)), and **no** takes the whole list, or the
games of the card when it holds some. The plan screen says
what it installs, its size and where, before anything is written. **Details**
(`d`) adds the rest: how many files are in place, the free space, the files
left out and why.
@@ -371,6 +377,8 @@ file list is downloaded once and handed to each run.
that archive in use and downloads its own copy. A file already there with other contents
is moved aside first, see [Files already in the folder](#files-you-already-had).
8. Copies into standalone emulator directories, only with `--standalone-copies`.
9. Writes the [install record](#the-install-record): each file it placed, with
its SHA-1, and the choice it made.
The file list names the version of the platform it was built for (Batocera
43.1, RetroBat 8.3.1). Where the system writes its own version in a file, the
@@ -444,6 +452,61 @@ ones, grouped by system with the emulators that read them, and point at the
emulator pages. They do not change the exit code of `--check`, since no run of
the installer can fetch them.
## The install record { #the-install-record }
An install that places files writes what it placed in a record beside the BIOS
folder: `retrobios-backup/<folder name>.install.json`, in the same holder as
the [backups](#files-you-already-had). A folder with nothing writable beside it
(a drive's root) keeps it in `retrobios-backup` inside the folder. It is never
loose in the BIOS folder, which DuckStation, SwanStation and Rustation read
whole. On a card it travels with the card.
The record is JSON, one entry per setup:
```json
{"schema": 1, "installer": "48e57b2f26e2",
"folder": "/home/me/.config/retroarch/system",
"setups": {"retroarch": {
"written": "2026-11-10T09:48:36Z",
"list": {"version": "v1.22.2", "generated": "2026-11-10T01:52:59Z"},
"choice": ["--system", "nintendo-gba"],
"files": [{"dest": "gba_bios.bin", "root": "", "sha1": "4176d5fc...",
"size": 16384, "from": "download"}]}}}
```
`files` holds each file this run and the earlier ones wrote, with the SHA-1
checked before it was moved into place: `root` is empty for the BIOS folder,
`save` for the save directory and `data` for a data tree's member, and `from`
says `download`, `copy` (this machine, `--from`) or `pack`. A file that was
already in place is not added: the record names only what the installer wrote.
`choice` is the options of the run that decided what to install, `--all-files`
for the whole list.
The next run for the same setup and folder takes that choice when nothing on
the command line chose: line mode installs the same systems
(`Installing 1 file (16.0 KB) for RetroArch, same choice as on 10 Nov.`), and
the full screen opens on it (**Same choice as on 10 Nov: yes**). Any option of
the choice (`--system`, `--core`, `--region`, `--for-games`, `--no-game-data`,
`--target`, `--every-board`, `--emulator`, `--installed-cores`, `--declared-only`)
replaces it, `--all-files` installs the whole list, and a run over several
setups or from a file list (`--manifest`) never reads it. `--check` and
`--dry-run` read it too, and say so. To forget it, delete the file.
A record is read only when it is a regular file of at most 16 MiB whose
choice holds nothing but the options above and their values. A games folder
(`--for-games FOLDER`) is kept only under the folder that holds the BIOS
folder, so a record never sends the games scan to another drive or a network
share: any other folder is recorded as `--for-games` alone, the card's own
games. A record that breaks any of this, or cannot be read, is said once and
ignored, and the next install writes a new one, keeping the other setups'
entries that read. One written by a newer installer is left as it is. A
recorded option the list can no longer apply (a region, an emulator, a board,
the installed cores, game data on a list that marks none) is left out in one
line, `Left out of the recorded choice: --target (...)`, and the run goes on:
typed on the command line, the same option is refused as before. A record kept
for a folder of another name (copied or renamed with it) is not applied. With `--events` the record
gives a `record` event, see [Machine-readable events](#machine-readable-events).
## Reading a run { #reading-a-run }
<!-- generated: run-date -->
@@ -1902,11 +1965,12 @@ other schemas under `api/v1/schemas/`.
| `downloaded` | `dest`, `ok`, `reason`, `size`, `files_done`, `files_total`, `bytes_done`, `bytes_total` | Each finished file, downloaded or copied from where a `found` event named it. `reason` says why one failed, and is empty otherwise |
| `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `left_out`, `kept`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply, `left_out` lists `{category, label, files, dests}` for what `--no-game-data` left out, and is empty without it. `kept` lists `{folder, files, bytes}`, one per backup folder, for the files in place the run moved aside before replacing them (with `--dry-run`, would move), and is empty when none. Not written for a listing |
| `app` | `system`, `folder`, `ok`, `reason`, `dests`, `written`, `kept` | After `summary`, with `--add-app` or a yes to its question: the RetroBIOS app on the card of `system` whose root is `folder`. `dests` lists the app's files below that root, `written` the ones this run wrote (empty when each was in place), `kept` the backup folders as in `summary`. `ok` is false, with `reason`, when the card takes no app (before anything is written, code 2), when the install did not finish, or when a file could not be fetched, verified or written (code 1) |
| `record` | `platform`, `folder`, `path`, `status`, `choice`, `date`, `files`, `message` | The [install record](#the-install-record) of `folder`, kept at `path`. `status` is `applied` before `choices` when the run took its choice, nothing on the command line having chosen, `written` before `summary` once an install placed files, `unreadable` when it cannot be read (said once, ignored), or `not_written` when it could not be saved, the install itself unharmed. `choice` is the recorded options as words, `date` when it was written (UTC), `files` how many files it holds for the platform |
| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) |
<!-- /generated: events -->
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version), never a key of an
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version, `record` with the install record), never a key of an
existing kind: a reader skips a kind it does not know. An option refused
before the run starts (`install.py: error:`, code 2) writes
no event at all. A program reads a missing `end` as that case, or as a