chore: merge settled taps and found cards

This commit is contained in:
Abdessamad Derraz committed 2026-10-28 12:32:49 +01:00
commit 647952b210
36 files changed
+1690 -370

No files matched your search

+7
View File
@@ -150,6 +150,13 @@ jobs:
npm run build --prefix website
python scripts/validate_site.py --site-dir website/dist
# The one-liner the site serves: each copy pins its own installer and
# says what a refusal means for it. Validation runs these too, but in
# another workflow this one never waits for: a copy that refuses every
# install must stop the deploy, the last good pair staying served.
- name: Check the served bootstraps
run: python -m unittest tests.test_site_bootstrap
# The queries a visitor types must reach the page that answers them,
# read through the site's own search box, and the home page must stay
# under its weight budget. A miss blocks the deploy. The browser driver
+7 -3
View File
@@ -11,7 +11,12 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54"
# What a refused installer most likely means for this copy. The copy the
# site serves reads a commit's installer, which no cache serves stale,
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
# the site's build rewrites this line there (scripts/sitebootstrap.py).
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
$defaultInstallSha256 = "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
@@ -58,8 +63,7 @@
throw ($mismatch + "{0} is set in this session and decides which installer is accepted: " +
"remove it to use the installer this script names, or set it to match.") -f ($overrides -join " and ")
}
throw ($mismatch + "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.")
throw ($mismatch + $mismatchAdvice)
}
$installer = $temporary
}
+686 -169
View File
File diff suppressed because it is too large. Load diff
+7 -4
View File
@@ -18,7 +18,12 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54"
# What a refused installer most likely means for this copy. The copy the
# site serves reads a commit's installer, which no cache serves stale, and
# curl or wget stop on a cut transfer before the hash is read: the site's
# build rewrites this line there (scripts/sitebootstrap.py).
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
DEFAULT_INSTALL_SHA256="67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7"
MAX_INSTALLER_BYTES=2097152
cleanup() {
@@ -110,9 +115,7 @@ if [ -z "$INSTALLER" ] || [ ! -f "$INSTALLER" ]; then
echo " accepted: unset it to use the installer this script names, or set it to match." >&2
exit 1
fi
echo " The download was most likely cut short: try again. Within minutes of an update" >&2
echo " of this project the two files can come from caches of different ages." >&2
echo " Waiting five minutes settles that." >&2
echo " $MISMATCH_ADVICE" >&2
exit 1
fi
INSTALLER="$TEMP_INSTALLER"
+14 -14
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 910839,
"sha256": "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54",
"size": 939291,
"sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7",
"executable": false
},
{
@@ -62,8 +62,8 @@
{
"dest": "roms/ports/retrobios/tui.txt",
"repo_path": "ports/shared/tui.txt",
"size": 35599,
"sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c",
"size": 35984,
"sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1",
"executable": false
}
]
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 963631,
"sha256": "e82035ecc39b3a62898ebf7273c717c04880d2011ade0c09488924209796ab2a"
"size": 992468,
"sha256": "06761ebb41ef22fa2c4b64c5e27fab5ed5508dbcd63c5a634527d03a7cdaa7d1"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 910839,
"sha256": "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54",
"size": 939291,
"sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7",
"executable": false
},
{
@@ -141,8 +141,8 @@
{
"name": "RetroBIOS/tui.txt",
"repo_path": "ports/shared/tui.txt",
"size": 35599,
"sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c",
"size": 35984,
"sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1",
"executable": false
}
]
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 910839,
"sha256": "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54",
"size": 939291,
"sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7",
"executable": false
},
{
@@ -208,8 +208,8 @@
{
"dest": ".config/modules/retrobios/tui.txt",
"repo_path": "ports/shared/tui.txt",
"size": 35599,
"sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c",
"size": 35984,
"sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1",
"executable": false
}
]
+13 -2
View File
@@ -542,6 +542,15 @@ rb_font_restore() {
RB_FONT_BACK=""
}
# Whether a job this shell started is still running.
rb_job_running() {
local job
for job in $(jobs -rp); do
[ "$job" = "$1" ] && return 0
done
return 1
}
# The number of the console in front, which a switch has to come back to.
rb_active_vt() {
local active
@@ -558,13 +567,15 @@ rb_active_vt() {
# chvt, and whether the console really came to the front. chvt waits for the
# switch (VT_WAITACTIVE): a compositor that keeps the display would hold it
# forever, so it gets 3 s.
# forever, so it gets 3 s. The switch is done once chvt has ended too: the
# console to go back to is often in front already, and returning on that
# alone left the launcher ending before a chvt started late had run.
rb_chvt() {
local pid waited=0
$ESUDO chvt "$1" >> "$RB_LOG" 2>&1 &
pid=$!
while [ "$waited" -lt 12 ]; do
[ "$(rb_active_vt)" = "$1" ] && return 0
[ "$(rb_active_vt)" = "$1" ] && ! rb_job_running "$pid" && return 0
rb_nap 0.25
waited=$((waited + 1))
done
+34 -1
View File
@@ -15,6 +15,7 @@ Invoke-RestMethod reads any type that is neither JSON nor XML as text.
from __future__ import annotations
import hashlib
import os
import re
import subprocess
@@ -32,6 +33,22 @@ SOURCES: dict[str, tuple[str, re.Pattern[str]]] = {
SH_NAME: ("install.sh", re.compile(r'^(DEFAULT_INSTALL_URL=")[^"]*(")', re.M)),
PS1_NAME: ("install.ps1", re.compile(r'^(\s*\$defaultInstallUrl = ")[^"]*(")', re.M)),
}
# The line each bootstrap says a refused installer with, and the pin.
ADVICE: dict[str, re.Pattern[str]] = {
SH_NAME: re.compile(r'^(MISMATCH_ADVICE=")[^"]*(")', re.M),
PS1_NAME: re.compile(r'^(\s*\$mismatchAdvice = ")[^"]*(")', re.M),
}
PIN: dict[str, re.Pattern[str]] = {
SH_NAME: re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"', re.M),
PS1_NAME: re.compile(r'^\s*\$defaultInstallSha256 = "([0-9a-f]{64})"', re.M),
}
# What the served copy says of a refused installer. It names a commit's
# install.py, which never changes, and both downloaders fail on a short
# transfer before the hash is read: neither a cut download nor caches of
# two ages explain a mismatch there (code_audit_21 P3-1).
SERVED_ADVICE = ("The file was changed on its way here, most often by a proxy or an "
"antivirus that rewrites downloads. Run the command again from another "
"network, or with that web protection paused.")
def build_commit(root: Path) -> str:
@@ -52,14 +69,30 @@ def installer_url(commit: str) -> str:
def served_text(name: str, root: Path, commit: str) -> str:
"""The bootstrap served as name, its installer read from commit."""
"""The bootstrap served as name, its installer read from commit and its
advice fitted to that. A pin that is not the SHA-256 of root's
install.py stops the build: published, that copy refused every install
until the next good deploy, where a failed build keeps the last good
pair served (code_audit_21 P3-2)."""
source, line = SOURCES[name]
text = (root / source).read_text(encoding="utf-8")
pinned = PIN[name].findall(text)
if len(pinned) != 1:
raise ValueError(f"{source}: {len(pinned)} installer pins, expected one")
actual = hashlib.sha256((root / "install.py").read_bytes()).hexdigest()
if pinned[0] != actual:
raise ValueError(f"{source} pins {pinned[0][:12]}, install.py is {actual[:12]}: "
"re-pin the bootstrap before the site serves it")
served, count = line.subn(
lambda m: f"{m.group(1)}{installer_url(commit)}{m.group(2)}", text
)
if count != 1:
raise ValueError(f"{source}: {count} installer address lines, expected one")
served, count = ADVICE[name].subn(
lambda m: f"{m.group(1)}{SERVED_ADVICE}{m.group(2)}", served
)
if count != 1:
raise ValueError(f"{source}: {count} mismatch advice lines, expected one")
return served
+4 -4
View File
@@ -618,8 +618,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system --system
nintendo-gba
python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system \
--system nintendo-gba
@@ -743,8 +743,8 @@
BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m
^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404)
The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py --restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py \
--restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Same choice without questions:
python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system
Details: /tmp/retrobios-x/install.log
+6 -6
View File
@@ -9,7 +9,7 @@
BIOS folder:
^[[0;1m/home/user/.config/retroarch/system^[[0m
ES-DE found: its BIOS files go to each
ES-DE: files go to each emulator.
^[[0;1;35mA^[[0m Continue ^[[0;2mB^[[0m Quit ^[[0;2m?^[[0m Help
=== platforms
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m
@@ -198,11 +198,11 @@
The same choice without questions:
python3 install.py --platform
retroarch --dest
/home/user/.config/retroarch/system
python3 install.py \
--platform retroarch \
--dest .../retroarch/system \
--system nintendo-gba
^[[0;2m Shortened to fit this window.^[[0m
^[[0;1;35mA^[[0m Back
@@ -242,7 +242,7 @@
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
To put them back: python3 install.py \
^[[0;2m 1-8 of 16^[[0m
^[[0;1;35mA^[[0m Exit ^[[0;2m^v^[[0m Scroll
=== error
+8 -8
View File
@@ -243,11 +243,11 @@
The same choice without questions:
python3 install.py --platform
retroarch --dest
/home/user/.config/retroarch/system
python3 install.py \
--platform retroarch \
--dest .../retroarch/system \
--system nintendo-gba
^[[0;2m Shortened to fit this window.^[[0m
@@ -296,10 +296,10 @@
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
--restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py \
--restore .../2026-10-23_1200
^[[0;2m Shortened to fit this window.^[[0m
Same choice without questions:
^[[0;2m 1-11 of 16^[[0m
^[[0;1;35mA^[[0m Exit ^[[0;2m^v^[[0m Scroll
=== error
+6 -6
View File
@@ -378,8 +378,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system --system nintendo-gba
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system --system nintendo-gba
@@ -456,11 +456,11 @@
^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py --restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py \
--restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system
Details: /tmp/retrobios-x/install.log
+2 -2
View File
@@ -2,8 +2,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system --system
nintendo-gba
python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system \
--system nintendo-gba
+4 -4
View File
@@ -2,11 +2,11 @@
The same choice without questions:
python3 install.py --platform
retroarch --dest
/home/user/.config/retroarch/system
python3 install.py \
--platform retroarch \
--dest .../retroarch/system \
--system nintendo-gba
Shortened to fit this window.
A Back
+4 -4
View File
@@ -2,11 +2,11 @@
The same choice without questions:
python3 install.py --platform
retroarch --dest
/home/user/.config/retroarch/system
python3 install.py \
--platform retroarch \
--dest .../retroarch/system \
--system nintendo-gba
Shortened to fit this window.
+2 -2
View File
@@ -2,8 +2,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system --system nintendo-gba
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system --system nintendo-gba
+2 -2
View File
@@ -4,8 +4,8 @@
BIOS folder: /home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py --restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py \
--restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Same choice without questions:
python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system
Details: /tmp/retrobios-x/install.log
+1 -1
View File
@@ -7,6 +7,6 @@
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
To put them back: python3 install.py \
1-8 of 16
A Exit ^v Scroll
+4 -4
View File
@@ -7,9 +7,9 @@
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
--restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py \
--restore .../2026-10-23_1200
Shortened to fit this window.
Same choice without questions:
1-11 of 16
A Exit ^v Scroll
+4 -4
View File
@@ -5,11 +5,11 @@
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py --restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py \
--restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system
Details: /tmp/retrobios-x/install.log
+3 -3
View File
@@ -9,7 +9,7 @@
BIOS folder:
/home/user/.config/retroarch/system
ES-DE found: its BIOS files go to each
ES-DE: files go to each emulator.
A Continue B Quit
=== setup 40x15
RetroBIOS: Where do you play?
@@ -755,8 +755,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system --system nintendo-gba
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system --system nintendo-gba
+3 -3
View File
@@ -9,7 +9,7 @@
BIOS folder:
/home/user/.config/retroarch/system
ES-DE found: its BIOS files go to each
ES-DE: files go to each emulator.
A Continue B Quit
=== setup 40x15
RetroBIOS: Where do you play?
@@ -755,8 +755,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system --system nintendo-gba
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system --system nintendo-gba
+3 -3
View File
@@ -9,7 +9,7 @@
BIOS folder:
/home/user/.config/retroarch/system
ES-DE found: its BIOS files go to each
ES-DE: files go to each emulator.
A Continue B Quit
=== setup 40x15
RetroBIOS: Where do you play?
@@ -755,8 +755,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system --system nintendo-gba
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system --system nintendo-gba
+20 -20
View File
@@ -9,7 +9,7 @@
BIOS folder:
/home/user/.config/retroarch/system
ES-DE found: its BIOS files go to each
ES-DE: files go to each emulator.
Enter Continue Esc Quit ? Help
=== setup 40x15
RetroBIOS: Where do you play?
@@ -759,11 +759,11 @@
The same choice without questions:
python3 install.py --platform
retroarch --dest
/home/user/.config/retroarch/system
python3 install.py \
--platform retroarch \
--dest .../retroarch/system \
--system nintendo-gba
Shortened to fit this window.
Enter Back
@@ -772,11 +772,11 @@
The same choice without questions:
python3 install.py --platform
retroarch --dest
/home/user/.config/retroarch/system
python3 install.py \
--platform retroarch \
--dest .../retroarch/system \
--system nintendo-gba
Shortened to fit this window.
@@ -788,8 +788,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system --system nintendo-gba
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system --system nintendo-gba
@@ -926,7 +926,7 @@
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
To put them back: python3 install.py \
1-8 of 16
Enter Exit ^v Scroll
=== result 40x15
@@ -939,10 +939,10 @@
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
--restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py \
--restore .../2026-10-23_1200
Shortened to fit this window.
Same choice without questions:
1-11 of 16
Enter Exit ^v Scroll
=== result 80x24
@@ -953,11 +953,11 @@
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py --restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py \
--restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system
Details: /tmp/retrobios-x/install.log
+3 -3
View File
@@ -9,7 +9,7 @@
BIOS folder:
/home/user/.config/retroarch/system
ES-DE found: its BIOS files go to each
ES-DE: files go to each emulator.
A Continue R2 Quit
=== setup 40x15
RetroBIOS: Where do you play?
@@ -784,8 +784,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system --system nintendo-gba
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system --system nintendo-gba
+11 -11
View File
@@ -9,7 +9,7 @@
BIOS folder:
/home/user/.config/retroarch/system
ES-DE found: its BIOS files go to each
ES-DE: files go to each emulator.
Start Continue B Quit
=== setup 40x15
RetroBIOS: Where do you play?
@@ -813,11 +813,11 @@
The same choice without questions:
python3 install.py --platform
retroarch --dest
/home/user/.config/retroarch/system
python3 install.py \
--platform retroarch \
--dest .../retroarch/system \
--system nintendo-gba
Shortened to fit this window.
Start Back
@@ -826,11 +826,11 @@
The same choice without questions:
python3 install.py --platform
retroarch --dest
/home/user/.config/retroarch/system
python3 install.py \
--platform retroarch \
--dest .../retroarch/system \
--system nintendo-gba
Shortened to fit this window.
@@ -842,8 +842,8 @@
The same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system --system nintendo-gba
python3 install.py --platform retroarch \
--dest /home/user/.config/retroarch/system --system nintendo-gba
+1 -1
View File
@@ -8,5 +8,5 @@
BIOS folder:
/home/user/.config/retroarch/system
ES-DE found: its BIOS files go to each
ES-DE: files go to each emulator.
A Continue B Quit ? Help
+9 -5
View File
@@ -97,7 +97,7 @@ class InstallerOutputExample(unittest.TestCase):
for part in omitted.group(1).split(", "):
self.assertIn(part.rsplit(":", 1)[0], install.OMISSION_REASONS.values())
count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", run).groups()
self.assertIn(f"0/{count} present", run)
self.assertIn(f"0 in place, {count} missing", run)
self.assertSplitAddsUp(run, count, size)
menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)[.,]([^`]*)", text)
self.assertEqual(menu.groups()[:2], (count, size))
@@ -118,10 +118,14 @@ class InstallerOutputExample(unittest.TestCase):
r"files \(([\d.]+) ([KMG]B)\)", text)
self.assertIsNotNone(split, "a categorized list says its BIOS files and game data")
bios, bios_size, bios_unit, games, game_size, game_unit = split.groups()
self.assertEqual(int(bios) + int(games), int(count))
# The data files in the one total, the third kind (code_audit_21 P3-8).
data = re.search(r"and (\d+) data files \(([\d.]+) ([KMG]B)\)", text)
data_count, data_size, data_unit = data.groups() if data else ("0", "0", "KB")
self.assertEqual(int(bios) + int(games) + int(data_count), int(count))
units = {"KB": 1, "MB": 1024, "GB": 1024 ** 2}
total, unit = size.split()
added = (float(bios_size) * units[bios_unit] + float(game_size) * units[game_unit])
added = (float(bios_size) * units[bios_unit] + float(game_size) * units[game_unit]
+ float(data_size) * units[data_unit])
self.assertLessEqual(abs(added - float(total) * units[unit]), 0.15 * units[unit])
@@ -744,10 +748,10 @@ class FirstInstallWalkthrough(unittest.TestCase):
self.assertIn(f'title = "{ending}"', source)
self.assertNotRegex(self.text, r"Done\. \d+ files")
count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups()
self.assertIn(f"0/{count} present", step3)
self.assertIn(f"0 in place, {count} missing", step3)
self.assertIn(f"Downloading {count} files ({size})", step3)
self.assertIn(f"Done. {count} files installed", step4)
self.assertIn(f"{count}/{count} present ({count} verified", step5)
self.assertIn(f"{count} in place, 0 missing", step5)
manifest = json.loads((ROOT / "install" / "batocera.json").read_text(encoding="utf-8"))
omitted = re.search(r"Not installable: (\d+) files \((\d+) required", step3)
if manifest.get("omitted_files"):
+21 -2
View File
@@ -625,6 +625,8 @@ printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
# compositor that keeps the display (FAKE_CHVT_REFUSED) leaves chvt waiting
# in VT_WAITACTIVE and the console in front unchanged.
FAKE_CHVT = """#!/bin/sh
# A loaded machine starts the process late (FAKE_CHVT_DELAY seconds).
[ -n "$FAKE_CHVT_DELAY" ] && sleep "$FAKE_CHVT_DELAY"
printf '%s\\n' "chvt $*" >> "$FAKE_STATE/tools.log"
[ -n "$FAKE_CHVT_REFUSED" ] && exec sleep 30
[ -n "$RB_VT_ACTIVE" ] && printf 'tty%s\\n' "$1" > "$RB_VT_ACTIVE"
@@ -1509,13 +1511,13 @@ class TheBatoceraPort(unittest.TestCase):
self.assertEqual(chvt, [f"chvt {self.number}", f"chvt {self.home}"])
device.assert_console_restored()
def test_a_refused_console_switch_shows_nothing_on_an_unseen_console(self):
def test_a_refused_console_switch_shows_nothing_on_an_unseen_console(self, **slow):
"""The compositor keeps the display: chvt waits and the console in
front stays the same. The launcher switches back and stops, instead
of an interface no key reaches."""
device, ports = self.device(FAKE_PGREP="0")
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], FAKE_CHVT_REFUSED="1",
**self.console_switch(device))
**self.console_switch(device), **slow)
self.assertEqual(proc.returncode, 125)
self.assertLess(device.elapsed, 20)
self.assertEqual(device.tui_calls(), [])
@@ -1526,6 +1528,23 @@ class TheBatoceraPort(unittest.TestCase):
self.assertIn(f"console {self.number} did not come to the front", log)
self.assertNotIn("\x1bc", device.screen)
def test_the_switch_back_is_waited_for_on_a_slow_machine(self):
"""The console to go back to was already in front, so rb_chvt
returned before its chvt had even started, and the launcher ended
with the switch back still pending: under load the log missed
"chvt 2". A chvt that starts 1.2 s late made it fail every time."""
self.test_a_refused_console_switch_shows_nothing_on_an_unseen_console(
FAKE_CHVT_DELAY="1.2")
def test_a_slow_console_switch_still_comes_back(self):
device, ports = self.device(FAKE_PGREP="0")
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], FAKE_CHVT_DELAY="1.2",
**self.console_switch(device))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
chvt = [line for line in device.tools() if line.startswith("chvt")]
self.assertEqual(chvt, [f"chvt {self.number}", f"chvt {self.home}"])
self.assertEqual((device.root / "active").read_text(), f"tty{self.home}\n")
def test_xterm_that_cannot_open_hands_over_to_the_console(self):
device, ports = self.device(DISPLAY=":0", FAKE_PGREP="0")
device.tool("xterm", FAKE_TERMAPP)
+8 -5
View File
@@ -3550,7 +3550,7 @@ class ARefusedStandaloneCopyIsNamed(unittest.TestCase):
install._report_outcome([bios], 0, 1, failed, 0, False, failures=failures)
text = out.getvalue()
self.assertIn(str(target / "scph5501.bin"), text)
self.assertIn(str(target), text.split("Location")[1])
self.assertIn(str(target), text.split("BIOS folder")[-1])
self.assertIn("cannot write there", text)
@@ -4210,8 +4210,11 @@ class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun):
self.assertEqual(proc.returncode, 0, proc.stderr)
lines = [line for line in proc.stdout.splitlines() if line.strip()]
self.assertIn("Nothing was written", lines[-1])
header = next(line for line in lines if line.startswith("Would download"))
self.assertIn("data files", header)
at = next(index for index, line in enumerate(lines) if line.startswith("Would download"))
# One total on the header, the data files among the kinds under it
# (code_audit_21 P3-8).
self.assertNotIn("data files", lines[at])
self.assertIn("data files", lines[at + 1])
def test_dest_alone_says_whose_list_it_is(self):
proc = _install_run(self.env, "--dest", str(self.home / "card"), "--dry-run",
@@ -4531,7 +4534,7 @@ class ANetworkFailureNamesTheNetwork(unittest.TestCase):
self.assertEqual(raised.exception.code, 2)
text = out.getvalue()
self.assertIn("HTTPS_PROXY", text)
self.assertIn("Destination: /bios", text)
self.assertIn("BIOS folder: /bios", text)
self.assertNotIn("being written to", text)
@@ -5222,7 +5225,7 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
"interrupted": False},
{"event": "end", "code": 1},
])
self.assertIn("2/4 present (1 verified, 1 wrong hash)", proc.stderr)
self.assertIn("1 in place, 2 missing, 1 wrong copy", proc.stderr)
self.assertFalse((self.bios / "c.bin").exists())
def test_an_install_run(self):
+2 -2
View File
@@ -678,7 +678,7 @@ class AHomeThisUserCannotOpen(unittest.TestCase):
with self.subTest(run=name):
self.assertNotIn("Traceback", proc.stderr)
# A named folder needs no detection and is checked.
self.assertIn("0/5 present", runs["named"].stdout)
self.assertIn("0 in place, 5 missing", runs["named"].stdout)
self.assertIn(f"Could not look for setups: this user cannot open {home}",
runs["detected"].stderr)
@@ -811,7 +811,7 @@ class APartlyClosedHome(unittest.TestCase):
self.assertNotIn(str(home / ".config" / "retroarch" / "system"),
proc.stdout + proc.stderr)
self.assertIn(f"Found RetroArch at {system}", runs["detected"].stdout)
self.assertIn("0/5 present", runs["named"].stdout)
self.assertIn("0 in place, 5 missing", runs["named"].stdout)
self.assertNotIn("not detected", runs["named"].stdout)
events = [json.loads(line) for line in runs["events"].stdout.splitlines()]
kinds = [event["event"] for event in events]
+1 -1
View File
@@ -1459,7 +1459,7 @@ class ThePlatformsOwnListAlone(unittest.TestCase):
with self.subTest(mode=mode), FakeRepository() as repo:
proc = repo.run("--declared-only", mode)
self.assertIn("5 to 3 files", proc.stdout, proc.stderr)
self.assertIn("0/3 present", proc.stdout)
self.assertIn("0 in place, 3 missing", proc.stdout)
self.assertEqual(repo.installed(), [])
def test_it_composes_with_the_other_narrowings(self):
+682 -36
View File
@@ -771,7 +771,8 @@ class PlanWarnings(unittest.TestCase):
self.assertIn("You cannot write to this folder: choose another one.", text)
# sudo changes the home folder: the advice names this one.
self.assertIn("Or run it as root, this folder named: sudo python3 install.py "
f"--platform retroarch --dest {SYSTEM_DIR}", " ".join(text.split()))
f"--platform retroarch --dest {SYSTEM_DIR}",
" ".join(text.replace(" \\", "").split()))
rows = install.tui_rows(state)
self.assertEqual([row.action for row in rows[:2]], ["folder", "install"])
self.assertEqual(state.focus.get("plan", 0), 0)
@@ -3737,8 +3738,9 @@ class AuditFive(unittest.TestCase):
saves = {"files": 1, "folder": str(HOME / ".config" / "retroarch" / "saves" / "<core>")}
lines = install._saves_lines(state, saves)
# In words: "saves/<core>" read as a folder named <core>.
# The folder at the BIOS folder's indent (tui_audit_14 P3-9).
self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to a "
"folder per core under:", "~/.config/retroarch/saves"])
"folder per core under:", " ~/.config/retroarch/saves"])
def test_the_shared_note_is_one_line(self):
state = self.long_list()
@@ -3820,7 +3822,9 @@ class LineModeOffline(unittest.TestCase):
with mock.patch.object(install, "_ask", return_value=""), \
contextlib.redirect_stdout(io.StringIO()) as out:
install._prompt_custom_selection("retroarch", files, [], data_trees=trees)
self.assertIn("RetroArch: 1 file, 1.0 KB, and 2 data files (4.0 KB).", out.getvalue())
# One total, the kinds under it adding up to it (code_audit_21 P3-8).
self.assertIn("RetroArch: 3 files, 5.0 KB.\n 1 BIOS file (1.0 KB) and 2 data files "
"(4.0 KB).", out.getvalue())
def test_one_save_file_goes(self):
files = [{"dest": "keys.txt", "root": "save", "size": 1}]
@@ -4773,7 +4777,7 @@ class EndToEnd(unittest.TestCase):
# The second folder the run writes to, named before
# anything is written, and again on the result.
self.assertIn("1 file read from RetroArch's save directory goes to a "
"folder per core under:\n ~/MySaves", screen)
"folder per core under:\n ~/MySaves", screen)
term.send("\r")
screen = term.wait("6 files installed.")
# Spelled in full on the result, as its other folders.
@@ -6638,7 +6642,11 @@ class AuditSevenScreens(unittest.TestCase):
state.found = [found("gba_bios.bin", str(self.root / "from1"))]
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
text = " ".join(" ".join(install.tui_view(state, 100, 40)).split())
self.assertIn("They are not in from1", text)
# The rest named by kind as not in it, the remedy after
# (tui_audit_14 P3-7).
self.assertIn("Not in from1: ", text)
self.assertIn("Run the installer again with a pack (--from-pack)", text)
self.assertNotIn("They are not in", text)
self.assertNotIn("Copy them from your old card", text)
def test_the_card_browser_focuses_the_card_asked_for(self):
@@ -7572,7 +7580,7 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings,
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)
self.assertIn("0/4 present", text)
self.assertIn("0 in place, 4 missing, 0 wrong copies", text)
self.assertIn("4 files (123 B)", text[text.index("Checking existing files"):])
def test_g_counts_the_systems_chosen_and_leaves_out_what_it_said(self):
@@ -9197,7 +9205,7 @@ class AuditSixteenAndNineLineMode(unittest.TestCase):
folder = install.ask_platform_folder("linux", "batocera")
self.assertEqual(folder, share / "bios")
text = out.getvalue()
self.assertIn(f"1) SHARE: bios ({share / 'bios'}, created)", text)
self.assertIn(f"1) SHARE: bios ({share / 'bios'}, does not exist yet)", text)
self.assertNotIn("MUOS", text)
def test_the_folder_question_quits_on_q_and_says_what_to_type(self):
@@ -9960,8 +9968,12 @@ class TheAndroidResultReadsWhole(unittest.TestCase):
lines = [str(line).strip() for line in self.result(cols)]
words = [word for line in lines for word in line.split()]
self.assertIn("--restore", words)
# The command keeps it whole, to be copied as one word.
self.assertIn(full, words)
# The command keeps it whole where its line holds it, cut to
# its last folders and said so where not (tui_audit_14 P3-8).
if cols >= 60:
self.assertIn(full, words)
else:
self.assertIn(install.TUI_SHORTENED, lines)
# Prose breaks it after a separator only, never inside a name
# (tui_audit_13 P3-3): glued again, it is whole.
self.assertIn(full, re.sub(r"/ ", "/", " ".join(lines)))
@@ -9973,8 +9985,11 @@ class TheAndroidResultReadsWhole(unittest.TestCase):
state.stack.append("result")
pieces = install._text_lines([command], 38, whole=True)
joined = [str(piece).strip() for piece in pieces if piece.style != "cont"]
self.assertIn(install.BOOTSTRAP_URL, joined)
self.assertIn("'/storage/emulated/0/a b/20261027-223524'", joined)
# The address never cut, the option with its value, each line but
# the last ending with the shell's continuation.
self.assertIn(f"{install.BOOTSTRAP_URL} \\", joined)
self.assertTrue(any(line.startswith("--restore '") for line in joined), joined)
self.assertTrue(all(len(str(piece)) <= 38 for piece in pieces), pieces)
def test_a_long_line_folds_in_the_terminal(self):
terminal = install._TuiTerminal.__new__(install._TuiTerminal)
@@ -10024,8 +10039,16 @@ def drawn(state: install.TuiState, cols: int, rows: int) -> list:
return lines
def settled(state: install.TuiState) -> None:
"""The clock moved on past TUI_TAP_SETTLE since the screen was drawn."""
if state.hits is not None:
state.now = max(state.now, state.hits.shown + install.TUI_TAP_SETTLE)
def tap(state: install.TuiState, col: int, row: int, button: int = 0) -> list:
"""Press and release on one cell, columns and lines counted from 0."""
"""Press and release on one cell, columns and lines counted from 0, made
once the screen settled (TUI_TAP_SETTLE): a reader's deliberate tap."""
settled(state)
effects = install.tui_update(state, install.TuiMouse(button, col + 1, row + 1))
return effects + install.tui_update(state, install.TuiMouse(button, col + 1, row + 1,
True))
@@ -10226,13 +10249,13 @@ class MouseOnEveryScreen(unittest.TestCase):
self.assertIn(label, install._BAR_CLICKS)
effects = tap(state, first, line)
if install._bar_commits(base, target[1]):
# Install, Quit and Continue into Confirm: the
# first tap arms the entry, drawn so, the
# Install on the plan and Put it on the card:
# the first tap arms the entry, drawn so, the
# second on the same entry presses it.
self.assertEqual(effects, [])
self.assertEqual(state.stack, base.stack)
again = drawn(state, cols, rows)
self.assertIn("tap again", str(again[-1]))
self.assertIn(install.TUI_ARMED, str(again[-1]))
at = next(first for row, first, _end, mark in state.hits.targets
if mark == target)
effects = tap(state, at, line)
@@ -10258,7 +10281,9 @@ class MouseOnEveryScreen(unittest.TestCase):
lines = drawn(state, 80, 24)
row = next(i for i, line in enumerate(lines) if line.style != "focus"
and getattr(line, "mark", None) and line.mark[0] == "row")
settled(state)
install.tui_update(state, install.TuiMouse(0, 5, row + 1))
self.assertIsNotNone(state.pressed)
self.assertEqual(state.focus.get("setup", 0), 0)
# Released on another row: a swipe, which does nothing.
focused = next(i for i, line in enumerate(lines) if line.style == "focus")
@@ -10275,6 +10300,7 @@ class MouseOnEveryScreen(unittest.TestCase):
row = next(i for i, line in enumerate(lines) if line.style != "focus"
and getattr(line, "mark", None) and line.mark[0] == "row")
decoder = install.KeyDecoder()
settled(state)
for event in decoder.feed(x10(5, row + 1) + x10(5, row + 1, 3)):
install.tui_update(state, event)
self.assertEqual(state.focus["setup"], lines[row].mark[1])
@@ -10431,6 +10457,9 @@ class MouseOnAPty(unittest.TestCase):
return next(index for index, line in enumerate(term.screen.display) if needle in line)
def click(self, term: Pty, col: int, row: int) -> None:
# A deliberate tap, after the screen settled: a press sooner is the
# second of a double tap on the screen before.
time.sleep(install.TUI_TAP_SETTLE)
term.send(sgr(col + 1, row + 1), sgr(col + 1, row + 1, release=True))
def test_taps_choose_a_row_and_press_the_bar(self):
@@ -10454,6 +10483,7 @@ class MouseOnAPty(unittest.TestCase):
term.wait("Which setup?")
# Legacy reports move the focus the same way.
row = self.line_of(term, " > ") + 2
time.sleep(install.TUI_TAP_SETTLE)
term.send(x10(20, row + 1), x10(20, row + 1, 3))
term.settle()
self.assertTrue(term.screen.buffer[row][3].reverse, term.text())
@@ -10634,7 +10664,7 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase):
plan = checked_state()
plan.mouse, plan.help = True, True
text = " ".join(" ".join(install.tui_view(plan, 80, 24)).split())
self.assertIn("Tap Choose a row, tap it again to open it", text)
self.assertIn("Tap A row or Install: click or tap twice", text)
# The tick entry of the bar is pressed by a tap too.
state.help = False
lines = drawn(state, 80, 24)
@@ -10833,10 +10863,12 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase):
width = len(path) + 1
lines = install._wrap(f"BIOS folder: {path}", width, whole="text")
self.assertEqual(lines, ["BIOS folder:", path])
command = install._wrap(f" python3 install.py --dest {path}", width + 1,
# 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,
whole="command")
self.assertIn(f" {path}", command)
self.assertTrue(all(len(line) <= width + 1 for line in command), command)
self.assertIn(f" --dest {path}", command)
self.assertTrue(all(len(line) <= width + 10 for line in command), command)
def test_the_result_spells_each_folder_one_way(self):
"""tui_audit_12 P3-7: the save folder as ~/ beside full paths."""
@@ -10844,7 +10876,7 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase):
state.done_saves = {"event": "saves", "files": 2,
"folder": "/home/user/.config/retroarch/saves/<core>"}
_title, lines = install.tui_outcome(state)
self.assertIn("/home/user/.config/retroarch/saves", lines)
self.assertIn("/home/user/.config/retroarch/saves", [line.strip() for line in lines])
self.assertFalse(any(line.startswith("~") for line in lines))
def test_minui_names_the_folder_of_its_plan(self):
@@ -11451,7 +11483,7 @@ class AuditTwentyAndThirteen(unittest.TestCase):
state.check = {**state.check, "data_missing": 7, "data_bytes": 2048}
text = " ".join(" ".join(install.tui_view(state, 40, 12)).split())
self.assertIn("Game data: what a game reads, of no use to other cores. Data files: "
"what an emulator reads from its own data folder.", text)
"an emulator's own data folder.", text)
text = " ".join(" ".join(install.tui_view(state, 40, 15)).split())
self.assertIn("such as Dolphin's Sys and PPSSPP's assets.", text)
@@ -11485,7 +11517,11 @@ class AuditTwentyAndThirteen(unittest.TestCase):
while pieces[-1].endswith("/") and lines[at + len(pieces)]:
pieces.append(lines[at + len(pieces)])
spelled.append(pieces)
self.assertNotIn("...", " ".join(lines))
# A command's path may be cut to fit, said so: a folder
# in prose never is.
self.assertNotIn("...", " ".join(
line for line in lines if install.tui_command(state)[:10] not in line
and not line.startswith("--")))
self.assertTrue(all(len(piece) <= cols - 2 for piece in pieces), pieces)
self.assertEqual(spelled[0], spelled[1])
self.assertEqual("".join(spelled[0]), f"BIOS folder: {dest}")
@@ -11495,9 +11531,9 @@ class AuditTwentyAndThirteen(unittest.TestCase):
under an indent of 6."""
path = "/home/moi/retroarch_system/tmp/tui_audit13/pack/MinUI_2025-11_BIOS_Pack.zip"
self.assertEqual(len(path), 75)
lines = install._wrap(f" python3 install.py --from-pack {path}", 78,
lines = install._wrap(f" python3 install.py --from-pack {path}", 95,
whole="command")
self.assertIn(path, lines)
self.assertIn(f" --from-pack {path}", lines)
def test_the_definition_is_said_once_and_never_spliced(self):
"""tui_audit_13 P3-5."""
@@ -11710,7 +11746,9 @@ class TapsSeenBeforeTheyAct(unittest.TestCase):
row = next(i for i, line in enumerate(lines)
if getattr(line, "mark", None) and line.mark[0] == "row"
and line.mark[2][0] == "systems")
settled(state)
install.tui_update(state, install.TuiMouse(0, 5, row + 1))
self.assertIsNotNone(state.pressed)
# The screen changes under the finger before it comes up.
state.stack.append("options")
drawn(state, 80, 24)
@@ -11728,7 +11766,7 @@ class TapsSeenBeforeTheyAct(unittest.TestCase):
self.assertEqual(tap(state, 3, bar), [])
self.assertEqual(state.screen, "plan")
armed = drawn(state, 80, 24)
self.assertIn("A/Enter Install: tap again", str(armed[-1]))
self.assertIn(f"A/Enter Install: {install.TUI_ARMED}", str(armed[-1]))
self.assertEqual(tap(state, 3, bar), [("run", "install")])
def test_a_tapped_quit_quits_with_the_quit_code(self):
@@ -11739,10 +11777,8 @@ class TapsSeenBeforeTheyAct(unittest.TestCase):
lines = drawn(state, 80, 24)
self.assertFalse(str(lines[-1]).strip().startswith("Ctrl-C"))
at = str(lines[-1]).index("Ctrl-C")
tap(state, at, len(lines) - 1)
self.assertIsNone(state.exit_code)
drawn(state, 80, 24)
at = str(install.tui_view(state, 80, 24)[-1]).index("Ctrl-C")
# One tap: quitting writes nothing, and the settle time keeps a
# double tap on the screen before from reaching it.
tap(state, at, len(lines) - 1)
self.assertEqual(state.exit_code, install.QUIT_CODE)
@@ -11769,6 +11805,168 @@ class TapsSeenBeforeTheyAct(unittest.TestCase):
self.assertIn("as A", " ".join(install.tui_view(state, 80, 24)))
class DoubleTapsAcrossScreens(unittest.TestCase):
"""tui_audit_14 P2-1: the second tap of a double tap acted on the screen
the first one opened, at every gap measured (0, 80, 150 and 250 ms).
A press within TUI_TAP_SETTLE of a new screen is dropped, and a slow
deliberate second tap still acts."""
GAPS = (0.0, 0.08, 0.15, 0.25)
@staticmethod
def bar_cell(lines: list) -> tuple[int, int]:
return len(str(lines[-1])) - len(str(lines[-1]).lstrip()), len(lines) - 1
def double_tap(self, state: install.TuiState, gap: float) -> list:
"""Two taps gap seconds apart on the bar's first entry, the screen
drawn after each event as the loop draws it."""
lines = drawn(state, 80, 24)
col, row = self.bar_cell(lines)
state.now = state.hits.shown + 2.0
effects = []
for release in (False, True):
effects += install.tui_update(state, install.TuiMouse(0, col + 1, row + 1, release))
first = state.now
drawn(state, 80, 24)
state.now = first + gap
for release in (False, True):
effects += install.tui_update(state, install.TuiMouse(0, col + 1, row + 1, release))
drawn(state, 80, 24)
return effects
def one_tap(self, state: install.TuiState) -> list:
lines = drawn(state, 80, 24)
col, row = self.bar_cell(lines)
return tap(state, col, row)
def plan_on(self, action: str) -> install.TuiState:
state = checked_state()
state.mouse = True
rows = install.tui_rows(state)
state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == action)
return state
def both(self, make, gap: float) -> tuple:
once = make()
one = self.one_tap(once)
twice = make()
return one, once, self.double_tap(twice, gap), twice
def test_continue_on_the_first_screen(self):
def make() -> install.TuiState:
state = fresh_state()
state.mouse = True
return state
for gap in self.GAPS:
with self.subTest(gap=gap):
with mock.patch.object(install.Path, "exists", return_value=True):
one, once, two, twice = self.both(make, gap)
self.assertNotEqual(once.stack, ["setup"])
self.assertEqual(two, one)
self.assertEqual(twice.stack, once.stack)
def test_open_on_the_plan_keeps_the_systems_ticked(self):
for gap in self.GAPS:
with self.subTest(gap=gap):
one, once, two, twice = self.both(lambda: self.plan_on("systems"), gap)
self.assertEqual(once.screen, "systems")
self.assertEqual((twice.stack, twice.ticked), (once.stack, once.ticked))
self.assertEqual(two, one)
def test_choose_on_copy_from(self):
for gap in self.GAPS:
with self.subTest(gap=gap):
one, once, two, twice = self.both(lambda: self.plan_on("fromcard"), gap)
self.assertEqual(once.screen, "browse")
self.assertEqual((twice.stack, twice.browse_dir),
(once.stack, once.browse_dir))
self.assertEqual(two, one)
def test_a_slow_second_tap_acts(self):
once = self.plan_on("systems")
self.double_tap(once, 0.0)
slow = self.plan_on("systems")
self.double_tap(slow, install.TUI_TAP_SETTLE + 0.2)
self.assertEqual(once.screen, "systems")
# On Choose systems the first entry goes on to Confirm: the slow
# tap pressed it.
self.assertEqual(slow.screen, "confirm")
def test_a_release_whose_press_came_before_the_frame_does_nothing(self):
state = self.plan_on("systems")
lines = drawn(state, 80, 24)
col, row = self.bar_cell(lines)
state.now = state.hits.shown + 2.0
install.tui_update(state, install.TuiMouse(0, col + 1, row + 1))
# Another screen drawn between the press and its release.
state.stack.append("options")
drawn(state, 80, 24)
state.now += 1.0
self.assertEqual(install.tui_update(state, install.TuiMouse(0, col + 1, row + 1, True)),
[])
self.assertEqual(state.screen, "options")
def test_the_settle_time_is_documented(self):
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
self.assertIn(f"{round(install.TUI_TAP_SETTLE * 1000)} ms", page)
class TwoTapsOnlyWhereFilesAreWritten(unittest.TestCase):
"""tui_audit_14 P3-2 and P3-4: with the settle time in place, a second
tap is asked only of an entry that writes files with nothing to look at
after it, drawn in the accent and said in the help at every size."""
def test_install_on_the_plan_arms_in_the_accent(self):
state = checked_state()
state.mouse = True
lines = drawn(state, 80, 24)
self.assertEqual(tap(state, 2, len(lines) - 1), [])
armed = str(drawn(state, 80, 24)[-1])
entry = f"A/Enter Install: {install.TUI_ARMED}"
self.assertIn(entry, armed)
painted = install.tui_paint(armed, "hint", True)
self.assertIn(f"{install.COLOUR_ROLES['accent']}m{entry}\x1b[0m", painted)
self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "install")])
def test_quit_takes_two_activations(self):
state = fresh_state()
state.mouse = True
lines = drawn(state, 80, 24)
tap(state, str(lines[-1]).index("B/Esc"), len(lines) - 1)
self.assertEqual(state.screen, "quit")
state.focus["quit"] = next(i for i, row in enumerate(install.tui_rows(state))
if row.action == "quit")
lines = drawn(state, 80, 24)
tap(state, 2, len(lines) - 1)
self.assertEqual(state.exit_code, install.QUIT_CODE)
def test_install_from_choose_systems_takes_two_activations(self):
state = checked_state()
state.mouse = True
rows = install.tui_rows(state)
state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "systems")
install.tui_update(state, Key("enter"))
self.assertEqual(state.screen, "systems")
lines = drawn(state, 80, 24)
self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "confirm")])
self.assertEqual(state.screen, "confirm")
install.tui_receive(state, ("event", state.run_id, CHECK))
install.tui_receive(state, ("ended", state.run_id, 1, []))
lines = drawn(state, 80, 24)
self.assertIn("A/Enter Install", str(lines[-1]))
self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "install")])
def test_the_help_says_it_at_every_size(self):
for cols, rows in ((40, 12), (60, 20), (80, 24)):
with self.subTest(size=(cols, rows)):
state = checked_state()
state.mouse = True
state.help = True
text = " ".join(" ".join(str(line).split())
for line in install.tui_view(state, cols, rows))
self.assertIn("A row or Install: click or tap twice", text)
@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
"screen tests need pyte (pip install pyte==0.8.2) and a pty")
class TapsOnAPtySeeFirst(unittest.TestCase):
@@ -11791,7 +11989,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
term.wait("> Install")
term.settle()
self.click(term, *self.bar_entry(term, "Install"))
self.assertIn("tap again", term.settle().splitlines()[-1])
self.assertIn(install.TUI_ARMED, term.settle().splitlines()[-1])
self.assertEqual(repo.installed(), [])
self.click(term, *self.bar_entry(term, "Install"))
term.wait("files installed")
@@ -11816,6 +12014,30 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
term.send("\r")
self.assertIn(term.close(), (install.QUIT_CODE, 0))
def test_a_double_tap_on_continue_stays_on_what_it_opened(self):
# tui_audit_14 P2-1: at 0, 80, 150 and 250 ms the second tap pressed
# B/Esc Back on the Checking screen, back to the first screen.
for gap in (0.0, 0.08, 0.15, 0.25):
with self.subTest(gap=gap):
repo = self.repo()
term = self.start()
term.wait("Where do you play?")
term.settle()
time.sleep(install.TUI_TAP_SETTLE)
col, line = self.bar_entry(term, "Continue")
press = sgr(col + 1, line + 1)
release = sgr(col + 1, line + 1, release=True)
os.write(term.fd, (press + release).encode())
time.sleep(gap)
os.write(term.fd, (press + release).encode())
screen = term.settle(0.8)
self.assertNotIn("Where do you play?", screen)
self.assertNotIn("Quit?", screen)
term.wait("> Install")
self.assertEqual(repo.installed(), [])
term.send("q")
term.close()
def test_continue_then_install_in_one_cell_stops_on_confirm(self):
repo = self.repo()
term = self.start()
@@ -11832,17 +12054,18 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
term.settle()
col, line = self.bar_entry(term, "Continue")
report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True)
# A double tap: the first arms Continue, the second opens Confirm.
# A double tap: the first opens Confirm, the second lands on its
# Install inside the settle time and does nothing (tui_audit_14 P2-1).
time.sleep(install.TUI_TAP_SETTLE)
term.send(report + report)
term.wait("Counted again for your choices")
term.settle()
self.assertEqual(repo.installed(), [])
# Install in the same cell: one tap arms it, nothing is written.
term.send(report)
screen = term.settle(0.6)
self.assertIn("RetroBIOS: Confirm", screen)
self.assertIn("Install: tap again", screen.splitlines()[-1])
self.assertEqual(repo.installed(), [])
# A tap made later installs: Confirm is the look before the install.
term.send(report)
term.wait("files installed")
self.assertNotEqual(repo.installed(), [])
term.send("q")
term.close()
@@ -11911,5 +12134,428 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
term.close()
class AuditTwentyOneAndFourteen(unittest.TestCase):
"""code_audit_21 and tui_audit_14: one choice state in both modes, the
cards found first, packs that hold nothing, and words that fit."""
setUp = LineModeAndScreenDecideAlike.setUp
line_run = LineModeAndScreenDecideAlike.line_run
listing = LineModeAndScreenDecideAlike.listing
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
receive = staticmethod(AuditTwentyAndThirteen.receive)
game_manifest = AuditTwentyAndThirteen.game_manifest
game_events = AuditTwentyAndThirteen.game_events
game_plan = AuditTwentyAndThirteen.game_plan
@staticmethod
def screen(state: install.TuiState, cols: int = 80, rows: int = 24) -> str:
return " ".join(" ".join(str(line).split())
for line in install.tui_view(state, cols, rows))
def toggle_game_data(self, state: install.TuiState) -> None:
rows = install.tui_rows(state)
state.focus["plan"] = next(index for index, row in enumerate(rows)
if row.action == "gamedata")
self.assertEqual(install.tui_update(state, Key("enter")), [("run", "check")])
self.receive(state, self.game_events(self.game_manifest(),
left_out=state.no_game_data))
def test_the_game_data_toggle_never_unticks_a_system_for_good(self):
"""tui_audit_14 P2-2: DS and Dreamcast ticked, game data left out
then kept: Dreamcast was gone, and so was the row to turn it back."""
state = self.game_plan()
state.ticked = {"nintendo-ds", "sega-dreamcast"}
self.toggle_game_data(state)
self.assertEqual(state.ticked, {"nintendo-ds"})
self.assertIn("for 1 of 2 systems", self.screen(state))
row = next(row for row in install.tui_rows(state) if row.action == "gamedata")
self.assertEqual((row.label, row.right), ("Game data: left out", "2 files, 2.0 KB"))
self.toggle_game_data(state)
self.assertEqual(state.ticked, {"nintendo-ds", "sega-dreamcast"})
self.assertIn("for 2 of 3 systems", self.screen(state))
# Line mode, the same choice: c, the two systems, g twice.
manifest = self.game_manifest()
listing = self.listing("retroarch", [
self.entry(entry["dest"], b"x" * entry["size"], entry["systems"],
**({"category": entry["category"]} if "category" in entry else {}))
for entry in manifest["files"]], categorized=True)
text, _asked = self.line_run(_args(manifest=listing, dest=self.here,
declared_only=False, dry_run=True),
["c", "1,2", "g", "g"])
self.assertIn("for 1 of 2 systems", text)
self.assertIn("for 2 of 3 systems", text.rsplit("for 1 of 2 systems", 1)[1])
def game_listing(self) -> Path:
manifest = self.game_manifest()
return self.listing("retroarch", [
self.entry(entry["dest"], b"x" * entry["size"], entry["systems"],
**({"category": entry["category"]} if "category" in entry else {}))
for entry in manifest["files"]], categorized=True)
def test_line_mode_offers_no_enter_for_an_emptied_choice(self):
"""code_audit_21 P3-6: Dreamcast alone, game data left out: line mode
said "0 files, 0 B, for 0 of 2 systems", offered Enter and a command
that installs nothing, then refused the Enter."""
text, asked = self.line_run(_args(manifest=self.game_listing(), dest=self.here,
declared_only=False),
["c", "2", "g", ""])
menu = text.rsplit("Counted again for your choices", 1)[1]
self.assertIn("RetroArch: sega-dreamcast holds only game data: nothing is left to "
"install.", menu)
self.assertNotIn("0 files, 0 B", menu)
self.assertNotIn("Enter)", menu)
self.assertNotIn("Same choice without questions", menu)
# Enter is not an answer here: asked again, never refused after.
self.assertIn("Type c, f, g or q.", menu)
self.assertEqual(asked[-1], "> ")
def test_a_kept_choice_in_place_offers_no_install(self):
"""code_audit_21 P3-4: one system ticked, its files in place, the
plan said "About 0 files" over Install, and Confirm "Everything is
in place" over Install too."""
state = checked_state()
state.ticked = {"nintendo-famicom-disk"}
text = self.screen(state)
self.assertIn("(ファミコン) is in place: nothing is left to install.", text)
self.assertNotIn("About 0 files", text)
actions = [row.action for row in install.tui_rows(state)]
self.assertNotIn("install", actions)
self.assertEqual(actions[0], "systems")
# Confirm counted nothing to install: Close, never Install.
state.stack.append("confirm")
state.dry = {**CHECK, "present": 1, "missing": 0, "wrong_hash": 0,
"bytes_needed": 0, "files": 1}
state.dry_files = []
self.assertEqual([row.action for row in install.tui_rows(state)], ["close", "back"])
install.tui_update(state, Key("enter"))
self.assertEqual(state.exit_code, 0)
def test_offline_the_plan_counts_the_systems_ticked(self):
"""code_audit_21 P3-5: offline, one system ticked, the plan counted
every file of the list beside "For your 1 of 3 systems"."""
state = checked_state()
folder = str(self.root / "from1")
state.from_folders = [self.root / "from1"]
state.found = [found("gba_bios.bin", folder)]
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
state.ticked = {"nintendo-gba"}
text = self.screen(state, 100, 40)
self.assertIn("About 1 file, 16.0 KB, for 1 of 3 systems ", text)
self.assertNotIn("found in from1", text)
self.assertNotIn("Run the installer again", text)
state.ticked = {"sony-playstation"}
text = self.screen(state, 100, 40)
self.assertIn("About 0 files, 0 B, for 1 of 3 systems, 3 need the internet", text)
def easyroms(self, name: str = "EASYROMS") -> Path:
"""An ArkOS games part as a computer mounts it: bios/ and console
folders, no file naming the system."""
card = self.root / "media" / name
for folder in ("bios", "nes", "gba", "psx"):
(card / folder).mkdir(parents=True)
return card
def test_an_arkos_card_is_found_and_comes_first(self):
"""tui_audit_14 P2-3: an EASYROMS partition mounted, "Nothing was
found here", and two presses of Enter filled this computer's
~/.config/retroarch/system for a RetroArch that was not there."""
for name in ("EASYROMS", "GAMES"):
with self.subTest(card=name):
card = self.easyroms(name)
home = self.root / "home"
with mock.patch.object(install, "_mounted_cards", return_value=[card]), \
mock.patch.object(install, "detect_platforms", return_value=[]), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install.Path, "home", return_value=home):
state, _effects = install.tui_start(_args(), "linux")
self.assertEqual(state.unnamed, [card])
text = self.screen(state)
self.assertIn(f"A card is in the reader ({name}): choose its system",
text)
self.assertNotIn("Nothing was found here", text)
install.tui_update(state, Key("enter"))
self.assertEqual(state.screen, "folder")
rows = install.tui_rows(state)
self.assertEqual(rows[0].value, card / "bios")
install.tui_update(state, Key("enter"))
self.assertEqual(state.dest, card / "bios")
shutil.rmtree(card)
def lacking_pack(self, held: list[tuple[str, bytes]]) -> Path:
pack_dir = self.root / "downloads"
pack_dir.mkdir(exist_ok=True)
pack = pack_dir / "RetroArch_BIOS_Pack.zip"
with zipfile.ZipFile(pack, "w") as archive:
archive.writestr("manifest.json", json.dumps({"files": []}))
for dest, payload in held:
archive.writestr(dest, payload)
return pack
def test_line_mode_names_what_the_pack_lacks_before_its_plan(self):
"""code_audit_21 P3-3: --dry-run promised 23 files, --check said to
fetch them, the install counted 23 beside the size of 20 and advised
retrying files that always fail."""
files = [self.entry(f"bios{n}.bin", b"x" * (n + 1), ["nintendo-gba"])
for n in range(4)]
listing = self.listing("retroarch", files)
pack = self.lacking_pack([("bios0.bin", b"x")])
dest = self.root / "card" / "system"
common = dict(manifest=listing, from_pack=[pack], dest=dest, declared_only=False,
no_input=True)
text, _asked = self.line_run(_args(**common, dry_run=True), [])
lacks = "3 of these 4 files are not in RetroArch_BIOS_Pack.zip"
self.assertIn(lacks, text)
self.assertLess(text.index(lacks), text.index("Would copy"))
self.assertIn("Would copy 1 file (1 B) from the pack", text)
text, _asked = self.line_run(_args(**common, check=True), [])
self.assertIn(lacks, text)
self.assertNotIn("to fetch them", text)
self.assertIn("to copy 1 of them from RetroArch_BIOS_Pack.zip", text)
options = ("--platform", "retroarch", "--manifest", str(listing), "--dest", str(dest),
"--yes")
proc = install_run(*options, "--from-pack", str(pack))
text = proc.stdout + proc.stderr
self.assertIn("Copying 1 file (1 B) from RetroArch_BIOS_Pack.zip...", text)
self.assertNotIn("retry only those", text)
self.assertIn("RetroArch_BIOS_Pack.zip does not hold them: a newer pack", text)
# A pack holding none: no copy, no folder.
shutil.rmtree(dest.parent, ignore_errors=True)
proc = install_run(*options, "--from-pack", str(self.lacking_pack([])))
text = proc.stdout + proc.stderr
self.assertNotIn("Copying 0 files", text)
self.assertIn("RetroArch_BIOS_Pack.zip does not hold them", text)
self.assertFalse(dest.exists())
def test_a_pack_holding_none_offers_no_install_with_data_files(self):
"""tui_audit_14 P3-1: data files left, the pack holding none of the
files nor the trees: Install was offered and failed every file, and
the result never named the data files."""
state = checked_state()
state.check = {**state.check, "data_missing": 7, "data_bytes": 2048}
state.manifest = {**MANIFEST, "data_directories": [
{"ref": "dolphin", "dest": "dolphin-emu/Sys", "files": {}}]}
class Empty:
name = "RetroArch_BIOS_Pack.zip"
@staticmethod
def find(entry: dict) -> None:
return None
@staticmethod
def tree(prefix: str):
return iter(())
with mock.patch.object(install.LOCAL, "pack", Empty()):
actions = [row.action for row in install.tui_rows(state)]
self.assertNotIn("install", actions)
self.assertEqual(actions[0], "fromcard")
text = self.screen(state)
self.assertIn("RetroArch_BIOS_Pack.zip holds none of these 11 files", text)
# The result of an install run anyway names them.
state.stack = ["result"]
state.summary = {"installed": 0, "up_to_date": 0, "pending": 4, "kept": [],
"failed": [{"dest": event["dest"], "reason": "not in the pack"}
for event in state.files]}
state.code = 2
_title, lines = install.tui_outcome(state)
self.assertIn("7 data files not installed: not in RetroArch_BIOS_Pack.zip.", lines)
def test_a_command_at_40_columns_breaks_between_shell_words(self):
"""tui_audit_14 P3-8: the terminal folded commands inside a word,
"--platform" ended a line with its value on the next."""
words = ["--platform", "retroarch", "--dest", "/a/b/c/system", "--manifest",
"/home/moi/x.json", "--system", "nintendo-gba"]
text = "printf '%s\\n' " + " ".join(words)
with mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}):
lines = install._wrap(text, 38, whole="command")
self.assertGreater(len(lines), 1)
self.assertTrue(all(install.cell_width(line) <= 38 for line in lines), lines)
self.assertTrue(all(line.endswith(" \\") for line in lines[:-1]), lines)
for line in lines:
# An option never ends a line without its value.
self.assertFalse((line[:-2] if line.endswith(" \\") else line).rstrip().endswith(
("--platform", "--dest", "--manifest", "--system")), lines)
# The shell reads the lines as the command: the same words.
proc = subprocess.run(["sh", "-c", "\n".join(lines)], capture_output=True, text=True,
check=True)
self.assertEqual(proc.stdout.split(), words)
# A path wider than a line keeps its last folders, said so, and the
# address of the one-liner is never cut.
state = _installed_state(Path("/home/moi/retroarch_system/tmp/tui_audit14/sb/a/b/bios"))
state.summary["kept"] = [{"folder": "/home/moi/retroarch_system/tmp/tui_audit14/sb/"
"retrobios-backup/2026", "files": 1, "bytes": 1}]
with mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}):
screen = [str(line) for line in install.tui_view(state, 40, 40)]
self.assertTrue(all(install.cell_width(line) <= 40 for line in screen), screen)
self.assertIn(install.TUI_SHORTENED, " ".join(screen))
self.assertIn(install.BOOTSTRAP_URL, " ".join(screen))
state.exit_code = 0
farewell = "\n".join(install.tui_farewell(state))
self.assertIn("/home/moi/retroarch_system/tmp/tui_audit14/sb/a/b/bios", farewell)
def test_a_folder_on_its_own_line_has_one_indent(self):
"""tui_audit_14 P3-9: the save folder at the label's indent, the
BIOS folder under it, a path's last piece without its indent."""
state = checked_state()
state.dest = Path("/home/moi/retroarch_system/tmp/tui_audit14/sb/esmiyoo/home/"
".config/retroarch/system")
state.saves = {"files": 1, "folder": "/home/moi/retroarch_system/tmp/tui_audit14/"
"sb/and2/storage/RetroArch/saves/<core>"}
for cols in (40, 80):
with self.subTest(cols=cols):
lines = [str(line) for line in install.tui_view(state, cols, 40)]
folder = next(i for i, line in enumerate(lines) if "BIOS folder:" in line)
under = next(i for i, line in enumerate(lines) if "under:" in line)
for at in (folder, under):
pieces = []
for line in lines[at + 1:]:
if not line.strip() or "/" not in line or " " in line.strip():
break
pieces.append(line)
indents = {len(piece) - len(piece.lstrip()) for piece in pieces}
self.assertLessEqual(len(indents), 1, pieces)
if pieces:
self.assertEqual(indents, {3}, pieces)
def test_no_shift_where_there_is_no_shift_key(self):
"""tui_audit_14 P3-3: "Shift and a drag" on Android."""
for os_type, said in (("linux", True), ("android", False)):
with self.subTest(os_type=os_type):
state = _installed_state(SYSTEM_DIR)
state.os_type, state.mouse = os_type, True
_title, lines = install.tui_outcome(state)
self.assertEqual(any("Shift" in line for line in lines), said)
self.assertIn(install.TUI_RESULT_AGAIN, lines[-1])
state.help = True
text = " ".join(install.tui_view(state, 80, 24))
self.assertEqual("Shift+drag" in text, said)
def test_the_40x12_setup_never_ends_mid_sentence(self):
"""tui_audit_14 P3-5."""
state = fresh_state()
state.notes = [install.TUI_ESDE_NOTE]
text = [str(line).strip() for line in install.tui_view(state, 40, 12)]
self.assertIn(install.TUI_ESDE_SHORT, text)
self.assertFalse(any(line.startswith("ES-DE found") for line in text), text)
text = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
self.assertIn(install.TUI_ESDE_NOTE, text)
def test_the_40x12_plan_keeps_its_folder_and_version_note(self):
"""tui_audit_14 P3-6: the plan named ~/.var/app/ as the folder, and
the version note was absent."""
state = checked_state()
state.dest = HOME / ".var/app/org.libretro.RetroArch/config/retroarch/system"
state.version = "This list was built for RetroBat 8.3.1, this machine runs 8.2.0.1."
lines = [str(line).strip() for line in install.tui_view(state, 40, 12)]
joined = "".join(line for line in lines if line.endswith("/") or "system" in line)
self.assertIn("~/.var/app/org.libretro.RetroArch/config/retroarch/system", joined)
self.assertIn(state.version, " ".join(" ".join(lines).split()))
self.assertTrue(any(re.fullmatch(r"1-\d+ of \d+", line) for line in lines), lines)
self.assertIn("> Install", lines)
def test_one_count_said_once_in_both_modes(self):
"""tui_audit_14 P3-10: "22 files installed." over "22 files copied
from packx.", and line mode's Location, wrong hash and zero count."""
state = _installed_state(SYSTEM_DIR)
state.summary["installed"] = 5
state.finished = [(f"f{n}.bin", True, "") for n in range(5)]
state.local_dests = {f"f{n}.bin": ("/media/packx", "") for n in range(5)}
_title, lines = install.tui_outcome(state)
self.assertEqual(lines[0], "5 files copied from packx.")
self.assertNotIn("5 files installed.", lines)
files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"])]
listing = self.listing("retroarch", files)
proc = install_run("--platform", "retroarch", "--manifest", str(listing),
"--dest", str(self.here), "--check", "--offline")
self.assertIn("0 in place, 1 missing, 0 wrong copies", proc.stdout)
self.assertNotIn("present (", proc.stdout)
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
self.assertNotIn('say(f"Location', source)
self.assertNotIn("already up to date.\")\n", source.split("\nDone.", 1)[-1][:300])
def test_quit_names_stay_once(self):
"""tui_audit_14 P3-11: "A/Enter Stay B/Esc Stay"."""
state = fresh_state()
state.stack.append("quit")
state.focus["quit"] = 1
self.assertEqual(str(install.tui_view(state, 80, 24)[-1]).count("Stay"), 1)
def test_the_yes_stop_example_keeps_yes(self):
"""tui_audit_14 P3-12: a script copying the example stopped at a
question."""
files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"])]
listing = self.listing("retroarch", files)
proc = install_run("--yes", "--manifest", str(listing), "--offline",
env={"HOME": str(self.root / "home"), "RETROBIOS_OS": "linux"})
example = next(line for line in proc.stderr.splitlines()
if "--platform retroarch --dest" in line)
self.assertIn(" --yes", example)
def test_the_systems_question_fits_the_terminal(self):
"""code_audit_21 P3-9: 53 of 66 rows wider than 80 columns."""
rows = [(f"system-{n}", [{"dest": f"{n}.bin", "size": 10 ** n}]) for n in range(6)]
labels = {"system-0": "Sega Dreamcast-based arcade boards, each "
"them sega-dreamcast-arcade",
**{f"system-{n}": f"Short {n}" for n in range(1, 6)}}
with mock.patch.object(install.shutil, "get_terminal_size",
return_value=os.terminal_size((80, 24))), \
mock.patch.object(install, "_ask", return_value=""), \
contextlib.redirect_stdout(io.StringIO()) as out:
install._pick("Systems:", rows, labels=labels)
printed = out.getvalue().splitlines()
self.assertTrue(all(install.cell_width(line) <= 80 for line in printed), printed)
# The long label on its own line, its size under it in the column.
at = next(i for i, line in enumerate(printed) if "sega-dreamcast-arcade" in line)
self.assertNotIn("B", printed[at].split("arcade", 1)[1])
sizes = {line.index("1 file") for line in printed[at + 1:] if "1 file" in line}
self.assertEqual(len(sizes), 1, printed)
def test_the_save_dir_sentence_is_written_once(self):
"""code_audit_21 P3-10."""
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
self.assertEqual(source.count('"Name it with --save-dir next time."'), 1)
self.assertEqual(source.count("args.declared_only or len(files) + game_data_cut"), 1)
def test_a_missing_folder_does_not_exist_yet_on_every_row(self):
"""code_audit_21 P3-7: line mode said "(created)" of folders nobody
created, and the screen said "does not exist yet" of the Windows row
alone."""
drives = [self.root / "media" / "D", self.root / "media" / "E"]
for drive in drives:
drive.mkdir(parents=True)
(drives[1] / "bios").mkdir()
with mock.patch.object(install, "_mounted_cards", return_value=drives), \
mock.patch.object(install, "_mount_points", return_value=frozenset()), \
mock.patch.object(install, "_ask", return_value="q"), \
contextlib.redirect_stdout(io.StringIO()) as out, \
contextlib.suppress(SystemExit):
install.ask_platform_folder("linux", "retroarch")
text = out.getvalue()
self.assertNotIn(", created)", text)
self.assertNotIn("(created)", text)
self.assertIn(f"D: bios (card or drive) ({drives[0] / 'bios'}, does not exist yet)", text)
self.assertIn(f"E: bios (card or drive) ({drives[1] / 'bios'})\n", text)
state = fresh_state()
state.platform = "retroarch"
with mock.patch.object(install, "_mounted_cards", return_value=drives):
install._open_folder_screen(state, SYSTEM_DIR)
labels = [row.label for row in install.tui_rows(state) if row.action == "drive"]
self.assertEqual(labels, ["D: bios (card or drive) (does not exist yet)",
"E: bios (card or drive)"])
def test_line_mode_puts_the_arkos_card_first(self):
card = self.easyroms()
text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True,
declared_only=False), ["1", ""], mounts=[card])
self.assertIn("A card is in the reader (EASYROMS): choose its system.", text)
self.assertNotIn("Nothing was found here", text)
self.assertIn(f"The card in the reader keeps them in its bios folder: {card / 'bios'}.",
text)
self.assertIn(f"1) EASYROMS: bios (card or drive) ({card / 'bios'})", text)
self.assertIn("Enter for the card's bios folder", asked[-2])
self.assertIn(f"BIOS folder: {card / 'bios'}", text)
if __name__ == "__main__":
unittest.main()
+51 -2
View File
@@ -57,8 +57,12 @@ class ServedCopies(unittest.TestCase):
served = sitebootstrap.served_text(name, REPO_ROOT, self.commit).splitlines()
self.assertEqual(len(original), len(served))
changed = [(a, b) for a, b in zip(original, served) if a != b]
self.assertEqual(len(changed), 1, changed)
before, after = changed[0]
# The address, and the advice a refusal gives (code_audit_21
# P3-1).
self.assertEqual(len(changed), 2, changed)
advice = [pair for pair in changed if sitebootstrap.SERVED_ADVICE in pair[1]]
self.assertEqual(len(advice), 1, changed)
before, after = next(pair for pair in changed if pair not in advice)
self.assertIn("https://raw.githubusercontent.com/Abdess/retrobios/main/install.py",
before)
self.assertEqual(after, before.replace(
@@ -94,6 +98,51 @@ class ServedCopies(unittest.TestCase):
self.assertEqual(path.read_text(encoding="utf-8"),
sitebootstrap.served_text(path.name, REPO_ROOT, self.commit))
def test_the_served_copy_never_blames_a_cut_download_or_caches(self):
"""code_audit_21 P3-1: the commit-pinned copy said "cut short, try
again" and "waiting five minutes settles that", neither of which can
happen to it."""
for name in sitebootstrap.SOURCES:
with self.subTest(name=name):
text = sitebootstrap.served_text(name, REPO_ROOT, self.commit)
self.assertNotIn("cut short", text)
self.assertNotIn("five minutes", text)
self.assertIn(sitebootstrap.SERVED_ADVICE, text)
# The raw form keeps its advice: a branch's copy can be stale.
self.assertIn("cut short", (REPO_ROOT / "install.sh").read_text(encoding="utf-8"))
page = (REPO_ROOT / "wiki" / "first-install.md").read_text(encoding="utf-8")
answer = page.split("does not match its expected fingerprint.", 1)[1].split("###", 1)[0]
self.assertNotIn("cut short", answer)
self.assertIn("something changed the file", answer)
def test_a_stale_pin_stops_the_build(self):
"""code_audit_21 P3-2: a pin that is not the installer's was served,
and refused every install until the next good deploy."""
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir()
else None) as scratch:
root = Path(scratch)
for source in ("install.sh", "install.ps1"):
(root / source).write_text((REPO_ROOT / source).read_text(encoding="utf-8"),
encoding="utf-8")
(root / "install.py").write_text('print("not the pinned installer")\n',
encoding="utf-8")
for name in sitebootstrap.SOURCES:
with self.subTest(name=name), self.assertRaises(ValueError):
sitebootstrap.served_text(name, root, self.commit)
def test_deploy_site_checks_the_copies_before_it_publishes(self):
import yaml # noqa: PLC0415
steps = yaml.safe_load((REPO_ROOT / ".github/workflows/deploy-site.yml").read_text(
encoding="utf-8"))["jobs"]["build"]["steps"]
runs = [str(step.get("run", "")) for step in steps]
uses = [str(step.get("uses", "")) for step in steps]
check = next(i for i, run in enumerate(runs) if "tests.test_site_bootstrap" in run)
build = next(i for i, run in enumerate(runs) if "scripts/generate_site.py" in run)
upload = next(i for i, use in enumerate(uses) if "upload-pages-artifact" in use)
self.assertLess(build, check)
self.assertLess(check, upload)
def test_a_bootstrap_without_its_address_line_stops_the_build(self):
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir()
else None) as scratch:
+2 -2
View File
@@ -126,7 +126,7 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --check
All good:
```
109/109 present (109 verified, 0 wrong hash)
109 in place, 0 missing, 0 wrong copies
```
## Undo
@@ -159,7 +159,7 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --platform retropie -
### Error: the downloaded installer does not match its expected fingerprint.
The download was cut short. Run the command again.
Nothing ran: something changed the file. Try another network.
### Cannot create ...
+51 -27
View File
@@ -218,9 +218,15 @@ installer with a signal (SIGTERM), which stops it the same way. Either way the
result says how many files were installed, and it stays printed in the terminal
once the screen closes, with the command that repeats the run. A run that failed
keeps its log and names it, and every other run leaves nothing behind. A path
or a command on the result is never split inside a word: one longer than the
window takes a line of its own, which the terminal folds, and a command breaks
only between its arguments. Every path a run names is spelled the way its BIOS
on the result is never split inside a word: one longer than the window takes
a line of its own, which the terminal folds. A command breaks only between its
arguments, an option kept with its value, each line but the last ending with
the continuation its shell reads (a backslash in sh, a backtick in PowerShell), so
the lines copied run as one command. Where an option and its path do not fit
on one line, the path keeps its last folders after `.../`, the line under it
says it was shortened, and the terminal shows it whole after the screen
closes. On Android and on a handheld route the result does not mention Shift,
which neither has. Every path a run names is spelled the way its BIOS
folder was named, the backup folder and the save directory included
(`/sdcard/RetroArch` on Android, not the `/storage/emulated/0` it links to).
@@ -265,15 +271,21 @@ does. On any other row it moves the cursor there, and a second one on that
row does what Enter does: one row of a terminal is far smaller than a finger,
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 installs, quits or goes on to Confirm takes two taps as a row
does: the first adds `tap again` to it, the second presses it. A row of
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.
**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
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: the second tap of a double tap that
changes the screen does nothing on the new one. The right and middle buttons
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
(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
do nothing. The help and the bottom line of a list of boxes say how a tap acts.
Clicks are off in the Windows console the PowerShell command runs in, which
@@ -379,12 +391,12 @@ 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
8358 files (6.6 GB)
3269 BIOS files (4.1 GB) and 5089 game data files (2.5 GB).
10994 files (6.6 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).
Checking existing files...
0/8358 present (0 verified, 0 wrong hash)
0 in place, 10994 missing, 0 wrong copies
```
The second line divides the first, and every later count of the run (the
@@ -439,13 +451,13 @@ Fetching file index for batocera...
Not installable: 399 files (79 required, not found: 399).
Checking existing files...
0/14481 present (0 verified, 0 wrong hash)
0 in place, 14481 missing, 0 wrong copies
```
`Not installable` counts the files of the list that the collection does not
hold. They are named at the end of the run.
On a first install the present count is zero. On a later run it counts what
On a first install nothing is in place. On a later run the line counts what
is already there, and only the rest is downloaded. A download of 1 GB or more
is confirmed first (`Download 9.1 GB to /userdata/bios? [Y/n]`).
@@ -464,8 +476,8 @@ command again picks up where it stopped.
### The end of the run
```
Done. 14481 files installed, 0 files already up to date.
Location: /userdata/bios
Done. 14481 files installed.
BIOS folder: /userdata/bios
399 files were not installed: the collection does not hold them, or its copy fails their check.
Needed to start an emulator (79 files):
dark-forces (theforceengine): DARK.GOB, SOUNDS.GOB, SPRITES.GOB, TEXTURES.GOB
@@ -496,11 +508,11 @@ When some files came from this machine rather than the network, a line under
the wrong place in the BIOS folder, 4 downloaded.`). See
[Copy from an old card](#copy-from-an-old-card).
The word `Done` and a location are the confirmation. Any other ending
The word `Done` and the BIOS folder are the confirmation. Any other ending
(`Stopped`, `Finished with errors`, `Nothing was installed`) means something
was left undone, and the run says which case it was.
The lines between `Location:` and the `--check` hint name the files of the
The lines between `BIOS folder:` and the `--check` hint name the files of the
list that the collection does not hold. `Needed to start an emulator` gives,
for each system, the emulators that read it and the files they cannot start
without. On the run above, all 79 belong to games (Dark Forces, Diablo, Doom 3,
@@ -514,14 +526,14 @@ The same command with `--check` reads the folder and writes nothing:
```
Checking existing files...
14481/14481 present (14481 verified, 0 wrong hash)
14481 in place, 0 missing, 0 wrong copies
All files up to date.
```
The files the collection does not hold are listed after it again, as long as
they are absent from the folder. A number before `wrong hash` means a file is
present with contents the platform does not accept. Running the install again
they are absent from the folder. A number before `wrong copies` means a file is
in place with contents the platform does not accept. Running the install again
replaces it and keeps the old one, see
[Files you already had](#files-you-already-had).
@@ -665,7 +677,7 @@ downloaded as usual, or taken from the pack with `--from-pack`. Several
file came from:
```
Done. 44 files installed, 0 files already up to date.
Done. 44 files installed.
38 files copied from E:\, 4 downloaded, 2 still missing.
```
@@ -691,7 +703,7 @@ same choice comes as questions after detection. A RetroArch run with `--plain`
printed this on 2026-10-28, `g` then pressed:
```
RetroArch: 8358 files, 6.6 GB, and 2636 data files (38.5 MB).
RetroArch: 10994 files, 6.6 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
@@ -704,7 +716,7 @@ RetroArch: 8358 files, 6.6 GB, and 2636 data files (38.5 MB).
>
Counted again for your choices. Nothing is written before Enter.
RetroArch: 3269 files, 4.1 GB, and 2636 data files (38.5 MB).
RetroArch: 5905 files, 4.1 GB.
3269 BIOS files (4.1 GB) and 2636 data files (38.5 MB). 5089 game data files left out.
```
@@ -1169,7 +1181,13 @@ muOS, ArkOS and the others) opens the setup list instead, filtered to the
setups that read it, RetroArch first: Enter on it asks RetroArch's folder. At
that question a drive is numbered by the folder its system reads (`ONION:
BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never
by its root, which no setup reads. Another system's card or SHARE drive, and a
by its root, which no setup reads. A drive no file names that holds a `bios`
folder and console folders (`nes`, `gba`, `psx`) is a handheld's card whose
own files sit on a part a computer does not open, ArkOS's `EASYROMS` and
AmberELEC's `GAMES`: with nothing found here the first screen and line mode
say `A card is in the reader (EASYROMS): choose its system`, and its `bios`
folder comes first at the folder question, under Enter, before this
computer's usual folder. Another system's card or SHARE drive, and a
drive two systems fit, are left out, and a line names them: each is reached
through its own setup. A folder typed is read from the current
one when it is not a full path, and named in full after. `q` quits there too
@@ -1489,10 +1507,16 @@ pinned raw URL. A URL with any other scheme is refused outright.
They cap the download at 2 MB and require the SHA-256 to equal the 64 hex
characters embedded in the wrapper, `RETROBIOS_INSTALL_SHA256` when set. A
mismatch aborts with "the downloaded installer does not match its expected
fingerprint" and nothing runs. Right after an update of this project the
wrapper and `install.py` can come from caches a few minutes apart, which reads
the same way: waiting five minutes settles it. Any change to `install.py`
therefore means recomputing that pin in both wrappers.
fingerprint" and nothing runs. The copies the site serves name the commit's
`install.py`, which never changes, and both downloaders stop on a cut transfer
before the hash is read: there a mismatch is a file changed on its way, by a
proxy or an antivirus, and that is what they say. The raw form reads `main`,
whose two files can come from caches a few minutes apart right after an
update: it says to try again, and that waiting five minutes settles it. The
site build refuses to publish a copy whose pin is not the SHA-256 of the
`install.py` it names, and Deploy Site runs the bootstrap tests before it
deploys, so a failed check keeps the last good pair served. Any change to
`install.py` therefore means recomputing that pin in both wrappers.
They locate a Python 3.8 or newer interpreter (`python3` then `python` on
POSIX, `py -3` then `python3` then `python` on Windows). Without one,
+4 -4
View File
@@ -28,13 +28,13 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --check
Read the count line:
```
1/109 present (0 verified, 1 wrong hash)
0 in place, 108 missing, 1 wrong copy
```
- **`wrong hash`**: another region or model, or damaged.
- **Fewer present than the total**: files are missing.
- **`wrong copy`**: another region or model, or damaged.
- **A number before `missing`**: files are missing.
- **A line under a file**, like `still zipped`, says the fix.
- **`0 wrong hash`, all present**: go to step 3.
- **`0 missing, 0 wrong copies`**: go to step 3.
## 2. Fix the files