chore: merge main into the launchers

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 16:42:00 +02:00
commit 0369c75dfe
317 files changed
+22514 -2652

No files matched your search

+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Open B/Esc Back ? Help
A/Enter Use this folder B/Esc Back ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
system/
A/Enter Open B/Esc Back ? Help
A Use this folder B Back ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Open B/Esc Back ? Help
A Use this folder B Back ? Help
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Open B/Esc Back ? Help
A/Enter Use this folder B/Esc Back ? Help
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Done B/Esc Back X/Space Tick ? Help
A/Enter Filter B/Esc Back X/Space Tick ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Done B/Esc Back X/Space Tick
A Filter B Back X Tick ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Done B/Esc Back X/Space Tick
A Filter B Back X Tick ? Help
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Done B/Esc Back X/Space Tick ? Help
A/Enter Filter B/Esc Back X/Space Tick ? Help
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Choose B/Esc Back
A/Enter Create B/Esc Back
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Choose B/Esc Back
A/Enter Create B/Esc Back
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Choose B/Esc Back
A/Enter Create B/Esc Back
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Choose B/Esc Back
A/Enter Create B/Esc Back
+3 -3
View File
@@ -4,8 +4,7 @@
known
Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/retroarch.json
> Try again
Choose another setup or folder
> Choose another setup or folder
Quit
@@ -37,4 +36,5 @@
A/Enter Choose B/Esc Back
A/Enter Back B/Esc Back
+6 -6
View File
@@ -3,10 +3,10 @@
Could not fetch the file list for
retroarch after 3 attempts: [Errno
-2] Name or service not known
> Try again
Choose another setup or folder
Address:
https://raw.githubusercontent.com/
Abdess/retrobios/main/install/
retroarch.json
> Choose another setup or folder
Quit
A/Enter Choose B/Esc Back
A/Enter Back B/Esc Back
+3 -3
View File
@@ -8,8 +8,8 @@
Abdess/retrobios/main/install/
retroarch.json
> Try again
Choose another setup or folder
> Choose another setup or folder
Quit
A/Enter Choose B/Esc Back
A/Enter Back B/Esc Back
+3 -3
View File
@@ -5,8 +5,7 @@
Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/
retroarch.json
> Try again
Choose another setup or folder
> Choose another setup or folder
Quit
@@ -21,4 +20,5 @@
A/Enter Choose B/Esc Back
A/Enter Back B/Esc Back
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Use this folder B/Esc Back ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Choose B/Esc Back ? Help
A Use this folder B Back ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Choose B/Esc Back ? Help
A Use this folder B Back ? Help
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Use this folder B/Esc Back ? Help
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Browse B/Esc Back ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Browse B/Esc Back ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Browse B/Esc Back ? Help
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Browse B/Esc Back ? Help
+2 -2
View File
@@ -1,8 +1,6 @@
RetroBIOS: Keys
Up/Down, k/j Move
PgUp/PgDn, L1/R1 Page
Home/End, g/G First, last
A/Enter, Start Install
Y, o, Tab Options
B/Esc, Backspace Back
@@ -34,6 +32,8 @@
+2 -2
View File
@@ -1,12 +1,12 @@
RetroBIOS: Keys
Up/Down, k/j Move
PgUp/PgDn, L1/R1 Page
Home/End, g/G First, last
A/Enter, Start Install
Y, o, Tab Options
B/Esc, Backspace Back
q, Ctrl-C Quit
?, F1 This help
BIOS folder:
/home/user/.config/retroarch/system
Any key closes this help
+2 -2
View File
@@ -1,8 +1,6 @@
RetroBIOS: Keys
Up/Down, k/j Move
PgUp/PgDn, L1/R1 Page
Home/End, g/G First, last
A/Enter, Start Install
Y, o, Tab Options
B/Esc, Backspace Back
@@ -12,4 +10,6 @@
BIOS folder:
/home/user/.config/retroarch/system
Any key closes this help
+2 -2
View File
@@ -1,8 +1,6 @@
RetroBIOS: Keys
Up/Down, k/j Move
PgUp/PgDn, L1/R1 Page
Home/End, g/G First, last
A/Enter, Start Install
Y, o, Tab Options
B/Esc, Backspace Back
@@ -21,4 +19,6 @@
Any key closes this help
+2 -2
View File
@@ -4,7 +4,7 @@
528.0 KB of 1.0 MB
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
B, Ctrl-C or Start+Select stops. What is done stays done.
FAILED scph5500.bin (HTTP 404)
ok gba_bios.bin
@@ -37,4 +37,4 @@
Ctrl-C / Start+Select stops
B/Esc Stop ? Help
+3 -3
View File
@@ -4,9 +4,9 @@
528.0 KB of 1.0 MB
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
B, Ctrl-C or Start+Select stops. What
is done stays done.
FAILED scph5500.bin (HTTP 404)
ok gba_bios.bin
Ctrl-C / Start+Select stops
B/Esc Stop ? Help
+3 -3
View File
@@ -4,12 +4,12 @@
528.0 KB of 1.0 MB
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
B, Ctrl-C or Start+Select stops. What
is done stays done.
FAILED scph5500.bin (HTTP 404)
ok gba_bios.bin
Ctrl-C / Start+Select stops
B/Esc Stop ? Help
+2 -2
View File
@@ -4,7 +4,7 @@
528.0 KB of 1.0 MB
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
B, Ctrl-C or Start+Select stops. What is done stays done.
FAILED scph5500.bin (HTTP 404)
ok gba_bios.bin
@@ -21,4 +21,4 @@
Ctrl-C / Start+Select stops
B/Esc Stop ? Help
+2 -2
View File
@@ -2,7 +2,7 @@
Each row is an option of the command line, named on the right.
> Preferred regions: north-america, japan --region
> Preferred regions: North America, Japan --region
Leave out game data: no --no-game-data
Installed cores only: no (1 found) --installed-cores
Cores: all --core
@@ -37,4 +37,4 @@
A/Enter Change B/Esc Back Left/Right Set ? Help
A/Enter Open B/Esc Back Left/Right Set ? Help
+3 -3
View File
@@ -1,12 +1,12 @@
RetroBIOS: Options
> Preferred regions: north-america, ja
> Preferred regions: North America, Ja
Leave out game data: no
Installed cores only: no (1 found)
Cores: all
Parallel downloads: 8
BIOS folder: ~/.config/retroarch/sys
BIOS folder: ~/.config...ch/system
Preview the file list
Show the command line
A/Enter Change B/Esc Back
A Open B Back <> Set ? Help
+3 -3
View File
@@ -1,15 +1,15 @@
RetroBIOS: Options
> Preferred regions: north-america, ja
> Preferred regions: North America, Ja
Leave out game data: no
Installed cores only: no (1 found)
Cores: all
Parallel downloads: 8
BIOS folder: ~/.config/retroarch/sys
BIOS folder: ~/.config...ch/system
Preview the file list
Show the command line
Sets --region
A/Enter Change B/Esc Back
A Open B Back <> Set ? Help
+2 -2
View File
@@ -2,7 +2,7 @@
Each row is an option of the command line, named on the right.
> Preferred regions: north-america, japan --region
> Preferred regions: North America, Japan --region
Leave out game data: no --no-game-data
Installed cores only: no (1 found) --installed-cores
Cores: all --core
@@ -21,4 +21,4 @@
A/Enter Change B/Esc Back Left/Right Set ? Help
A/Enter Open B/Esc Back Left/Right Set ? Help
+2 -2
View File
@@ -3,7 +3,7 @@
The folder for RetroArch:
> ~/bios_
Quotes are removed, ~ and variables are expanded.
Ctrl-U clears. Quotes are removed, ~ and variables are expanded.
@@ -37,4 +37,4 @@
Enter Use this folder Esc Back
Enter Use this folder Esc Back Ctrl-U Clear
+2 -2
View File
@@ -3,8 +3,8 @@
The folder for RetroArch:
> ~/bios_
Quotes are removed, ~ and variables
are expanded.
Ctrl-U clears. Quotes are removed, ~
and variables are expanded.
+2 -2
View File
@@ -3,8 +3,8 @@
The folder for RetroArch:
> ~/bios_
Quotes are removed, ~ and variables
are expanded.
Ctrl-U clears. Quotes are removed, ~
and variables are expanded.
+2 -2
View File
@@ -3,7 +3,7 @@
The folder for RetroArch:
> ~/bios_
Quotes are removed, ~ and variables are expanded.
Ctrl-U clears. Quotes are removed, ~ and variables are expanded.
@@ -21,4 +21,4 @@
Enter Use this folder Esc Back
Enter Use this folder Esc Back Ctrl-U Clear
+1 -1
View File
@@ -9,4 +9,4 @@
Choose systems
Options
Quit
A/Enter Install B/Esc Back
A Install B Back Y Options ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
Choose systems
Options
Quit
A/Enter Install B/Esc Back
A Install B Back Y Options ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
> Close
Options
A/Enter Close B/Esc Back Y/o Options
A Close B Back Y Options ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Close B/Esc Back Y/o Options
A Close B Back Y Options ? Help
+2 -2
View File
@@ -3,7 +3,7 @@
Choose the setup that will use the BIOS files.
Filter (/ to type)
> RetroArch
> RetroArch muOS, JELOS, KOLOS, Uno...Anbernic stock firmware
Batocera
Recalbox
RetroBat
@@ -15,7 +15,7 @@
BizHawk
MiSTer FPGA
RetroPie
AmberELEC
AmberELEC PAN4ELEC
EmuELEC
KNULLI
MinUI
+3 -3
View File
@@ -1,12 +1,12 @@
RetroBIOS: Which setup?
Filter (/ to type)
> RetroArch
> RetroArch muOS, JE...firmware
Batocera
Recalbox
RetroBat
EmuDeck
Lakka
RetroDECK
1-8 of 19
A/Enter Choose B/Esc Back / Filter
1-7 of 18
A Choose B Back / Filter ? Help
+3 -3
View File
@@ -1,7 +1,7 @@
RetroBIOS: Which setup?
Filter (/ to type)
> RetroArch
> RetroArch muOS, JE...firmware
Batocera
Recalbox
RetroBat
@@ -11,5 +11,5 @@
ROCKNIX
RomM
BizHawk
1-11 of 19
A/Enter Choose B/Esc Back / Filter
1-10 of 18
A Choose B Back / Filter ? Help
+2 -2
View File
@@ -3,7 +3,7 @@
Choose the setup that will use the BIOS files.
Filter (/ to type)
> RetroArch
> RetroArch muOS, JELOS, KOLOS...nic stock firmware
Batocera
Recalbox
RetroBat
@@ -15,7 +15,7 @@
BizHawk
MiSTer FPGA
RetroPie
AmberELEC
AmberELEC PAN4ELEC
EmuELEC
KNULLI
MinUI
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Choose B/Esc Stay
A/Enter Quit B/Esc Stay
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Choose B/Esc Stay
A/Enter Quit B/Esc Stay
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Choose B/Esc Stay
A/Enter Quit B/Esc Stay
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Choose B/Esc Stay
A/Enter Quit B/Esc Stay
+2 -2
View File
@@ -3,8 +3,8 @@
Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found
is installed.
> [1] north-america
[ ] japan
> [1] North America
[ ] Japan
+3 -3
View File
@@ -5,8 +5,8 @@
region, the best one found is
installed.
> [1] north-america
[ ] japan
> [1] North America
[ ] Japan
A/Enter Tick B/Esc Done
A Tick B Done <> Order ? Help
+3 -3
View File
@@ -5,11 +5,11 @@
region, the best one found is
installed.
> [1] north-america
[ ] japan
> [1] North America
[ ] Japan
A/Enter Tick B/Esc Done
A Tick B Done <> Order ? Help
+2 -2
View File
@@ -3,8 +3,8 @@
Tick the regions to keep, best first. Where a system has one BIOS per region,
the best one found is installed.
> [1] north-america
[ ] japan
> [1] North America
[ ] Japan
+1 -1
View File
@@ -8,5 +8,5 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-8 of 20
1-4 of 9
A/Enter Exit Up/Down Scroll
+1 -1
View File
@@ -11,5 +11,5 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-11 of 20
1-5 of 9
A/Enter Exit Up/Down Scroll
+2 -2
View File
@@ -2,8 +2,8 @@
Found on this machine:
> RetroArch ~/.config/retroarch/system
KNULLI (Batocera files) /userdata/bios
> RetroArch
KNULLI (Batocera files)
Another setup or folder
BIOS folder: /home/user/.config/retroarch/system
+2 -2
View File
@@ -2,8 +2,8 @@
Found on this machine:
> RetroArch ~/.config/retroarch/system
KNULLI (Batocera files) /userdata/bios
> RetroArch
KNULLI (Batocera files)
Another setup or folder
BIOS folder: /home/user/.config/retroarch/system
+1 -1
View File
@@ -9,4 +9,4 @@
2 of 3 systems: 4 files, 1.0 MB
A/Enter Continue B/Esc Back
A Continue B Back X Tick ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
2 of 3 systems: 4 files, 1.0 MB
A/Enter Continue B/Esc Back
A Continue B Back X Tick ? Help
+3
View File
@@ -5049,6 +5049,9 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
{"event": "detect", "os": "linux", "os_name": install._os_name(),
"platforms": [{"platform": "retroarch", "name": "RetroArch",
"bios_dir": str(self.bios)}]},
# a.bin and b.bin are in place: the first read and the last.
{"event": "checking", "files_done": 1, "files_total": 2},
{"event": "checking", "files_done": 2, "files_total": 2},
{"event": "file", "platform": "retroarch", "dest": "a.bin",
"systems": ["sys-a"], "size": 100, "state": "present"},
{"event": "file", "platform": "retroarch", "dest": "b.bin",
+491
View File
@@ -0,0 +1,491 @@
"""BIOS files the user already has, found by their contents.
A user who wiped the card a handheld came with asks where its BIOS files went,
and one who copied the folder from a Mac, unzipped a pack into bios/bios/ or
renamed scph1001.bin in capitals read "missing" for a file sitting in the
folder. --from FOLDER copies what an old card, a backup or a Downloads folder
holds, and an install puts the files it finds misplaced in the BIOS folder
where the cores open them. Every file is recognised by its hash, copied and
never moved, and checked again once written.
"""
from __future__ import annotations
import contextlib
import copy
import io
import json
import subprocess
import sys
import unittest
import zipfile
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
sys.path.insert(0, str(REPO_ROOT / "tests"))
import install # noqa: E402
from test_install_offline import ( # noqa: E402
EXTRA, GBA, JP, PAYLOAD, US, Scratch, _entry, _offline_env,
)
from test_install_selection import FakeRepository # noqa: E402
def _zip(path: Path, members: dict[str, bytes]) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
for name, data in members.items():
archive.writestr(name, data)
return path
def _snapshot(folder: Path) -> dict[str, tuple[bytes, int]]:
"""Every file below folder, with its bytes and modification time."""
return {p.relative_to(folder).as_posix(): (p.read_bytes(), p.stat().st_mtime_ns)
for p in folder.rglob("*") if p.is_file() and not p.is_symlink()}
def _events(proc: subprocess.CompletedProcess) -> list[dict]:
return [json.loads(line) for line in proc.stdout.splitlines() if line.startswith("{")]
class FromFolder(Scratch):
def setUp(self):
super().setUp()
self.old = self.root / "old card"
self.old.mkdir()
def keep(self, relative: str, data: bytes) -> Path:
path = self.old / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
def install(self, *options: str) -> subprocess.CompletedProcess:
return self.run_installer("--manifest", str(self.list), "--from", str(self.old),
"--no-input", *options)
def installed(self) -> dict[str, bytes]:
if not self.dest.exists():
return {}
return {p.relative_to(self.dest).as_posix(): p.read_bytes()
for p in self.dest.rglob("*") if p.is_file()}
class CopyFromAnOldCard(FromFolder):
def test_files_in_nested_folders_and_under_other_names_are_copied(self):
self.keep("BIOS/psx/SCPH5501.BIN", US)
self.keep("Roms/psx/japan.bin", JP)
self.keep("a/b/c/gba.rom", GBA)
self.keep("pcsx/extra.bin", EXTRA)
before = _snapshot(self.old)
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual(self.installed(), PAYLOAD)
self.assertIn(f"4 files copied from {self.old}.", proc.stdout)
self.assertEqual(_snapshot(self.old), before, "the old card is only read")
def test_members_of_a_zip_are_copied(self):
_zip(self.old / "Downloads" / "psx bios.zip",
{"PSX/scph5501.bin": US, "PSX/scph5500.bin": JP, "readme.txt": b"hi"})
self.keep("gba_bios.bin", GBA)
self.keep("pcsx/extra.bin", EXTRA)
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual(self.installed(), PAYLOAD)
def test_a_zip_given_alone_is_read_as_a_folder(self):
archive = _zip(self.root / "backup.zip", {f"system/{k}": v for k, v in PAYLOAD.items()})
proc = self.run_installer("--manifest", str(self.list), "--from", str(archive),
"--no-input")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual(self.installed(), PAYLOAD)
self.assertIn(f"4 files copied from {archive}.", proc.stdout)
def test_an_archive_the_list_wants_whole_is_copied_whole(self):
inner = io.BytesIO()
with zipfile.ZipFile(inner, "w") as archive:
archive.writestr("sp-s2.sp1", b"neo geo bios" * 10)
neogeo = inner.getvalue()
self.list.write_text(json.dumps({
"manifest_version": 2, "platform": "retroarch",
"files": [_entry("neogeo.zip", neogeo, ["snk-neogeo"]),
_entry("fbneo/neogeo.zip", neogeo, ["snk-neogeo"])],
}))
self.keep("arcade/neogeo (1).zip", neogeo)
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual(self.installed(), {"neogeo.zip": neogeo, "fbneo/neogeo.zip": neogeo})
def test_an_archive_inside_an_archive_is_copied(self):
inner = io.BytesIO()
with zipfile.ZipFile(inner, "w") as archive:
archive.writestr("sp-s2.sp1", b"neo geo bios" * 10)
neogeo = inner.getvalue()
self.list.write_text(json.dumps({
"manifest_version": 2, "platform": "retroarch",
"files": [_entry("neogeo.zip", neogeo, ["snk-neogeo"])],
}))
_zip(self.old / "RetroArch_BIOS_Pack.zip", {"neogeo.zip": neogeo})
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual(self.installed(), {"neogeo.zip": neogeo})
def test_an_empty_file_and_a_same_size_file_are_not_taken(self):
""""bios folder is 0kb" after flashing Garlic: a name is no proof."""
self.keep("scph5501.bin", b"")
self.keep("gba_bios.bin", b"x" * len(GBA))
self.keep("scph5500.bin", JP)
self.keep("pcsx/extra.bin", EXTRA)
proc = self.install()
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertEqual(self.installed(), {"scph5500.bin": JP, "pcsx/extra.bin": EXTRA})
self.assertIn(f"2 files copied from {self.old}, 2 still missing.", proc.stdout)
def test_a_link_out_of_the_folder_is_not_followed(self):
outside = self.root / "elsewhere"
outside.mkdir()
(outside / "scph5501.bin").write_bytes(US)
(outside / "gba").mkdir()
(outside / "gba" / "gba_bios.bin").write_bytes(GBA)
try:
(self.old / "scph5501.bin").symlink_to(outside / "scph5501.bin")
(self.old / "gba").symlink_to(outside / "gba", target_is_directory=True)
except (OSError, NotImplementedError):
self.skipTest("this system makes no symbolic link")
self.keep("scph5500.bin", JP)
proc = self.install()
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertEqual(self.installed(), {"scph5500.bin": JP})
def test_a_link_inside_the_folder_is_read(self):
self.keep("real/scph5501.bin", US)
try:
(self.old / "scph5501.bin").symlink_to(self.old / "real" / "scph5501.bin")
except (OSError, NotImplementedError):
self.skipTest("this system makes no symbolic link")
index = install.FolderIndex(self.old)
self.assertEqual(len(index.by_size[len(US)]), 2)
def test_mac_and_system_files_are_never_read(self):
"""A Mac writes ._name beside every file it copies to a FAT card,
and Spotlight keeps an index there: neither is a file a person put."""
self.keep("._scph5501.bin", US)
self.keep(".Spotlight-V100/Store-V2/scph5500.bin", JP)
self.keep("System Volume Information/gba_bios.bin", GBA)
self.keep("pcsx/extra.bin", EXTRA)
proc = self.install()
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertEqual(self.installed(), {"pcsx/extra.bin": EXTRA})
def test_the_rest_is_downloaded_and_the_summary_says_where_each_came_from(self):
with FakeRepository() as repo:
old = repo.root / "old"
payloads = FakeRepository.PAYLOADS
(old / "BIOS").mkdir(parents=True)
(old / "BIOS" / "SCPH5500.BIN").write_bytes(payloads["scph5500.bin"][0])
(old / "gba.bin").write_bytes(payloads["gba_bios.bin"][0])
(repo.root / "bios" / "mgba_extra.bin").unlink()
proc = repo.run("--from", str(old), "--no-input")
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn(f"2 files copied from {old}, 2 downloaded, 1 still missing.",
proc.stdout)
self.assertEqual(sorted(repo.installed()), sorted(
name for name in payloads if name != "mgba/extra.bin"))
def test_two_folders_are_read_in_turn(self):
second = self.root / "backup"
(second / "x").mkdir(parents=True)
(second / "x" / "gba_bios.bin").write_bytes(GBA)
(second / "scph5501.bin").write_bytes(US)
self.keep("scph5501.bin", US)
self.keep("scph5500.bin", JP)
self.keep("pcsx/extra.bin", EXTRA)
proc = self.install("--from", str(second))
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual(self.installed(), PAYLOAD)
self.assertIn(f"3 files copied from {self.old}, 1 copied from {second}.", proc.stdout)
def test_check_names_what_the_folder_would_give_and_writes_nothing(self):
self.keep("BIOS/SCPH5501.BIN", US)
self.keep("gba_bios.bin", GBA)
proc = self.run_installer("--check", "--manifest", str(self.list), "--from",
str(self.old))
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn(f"{self.old} holds 2 of these files: a run without --check "
"copies them.", proc.stdout)
self.assertIn(f"in {self.old}: BIOS/SCPH5501.BIN", proc.stdout)
self.assertFalse(self.dest.exists())
def test_dry_run_counts_the_copies_and_writes_nothing(self):
self.keep("BIOS/SCPH5501.BIN", US)
self.keep("gba_bios.bin", GBA)
proc = self.install("--dry-run")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn(f"Would copy 2 files ({install.format_size(len(US) + len(GBA))}) "
f"from {self.old}", proc.stdout)
self.assertIn("Would download 2 files", proc.stdout)
self.assertFalse(self.dest.exists())
self.assertFalse((self.root / install.BACKUP_DIR).exists())
def test_a_folder_that_does_not_exist_is_refused(self):
proc = self.run_installer("--manifest", str(self.list), "--from",
str(self.root / "nowhere"))
self.assertEqual(proc.returncode, 1)
self.assertIn("does not exist", proc.stderr)
self.assertFalse(self.dest.exists())
def test_a_listing_refuses_the_option(self):
proc = self.run_installer("--manifest", str(self.list), "--from", str(self.old),
"--list-systems")
self.assertEqual(proc.returncode, 2)
self.assertIn("--from", proc.stderr)
def test_restore_refuses_the_option(self):
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--restore", str(self.old),
"--from", str(self.old)],
env=_offline_env(), capture_output=True, text=True, timeout=60, check=False,
stdin=subprocess.DEVNULL)
self.assertEqual(proc.returncode, 2)
self.assertIn("takes no --from", proc.stderr)
class CaseTwinsOnAFolderThatFoldsCase(FromFolder):
"""An ext4 card holds font.bin and FONT.BIN, two files. On an exFAT
card or a Windows folder they are one name: the copy kept is the one
the list installs, found by its hash, never the other by its name."""
def test_the_hash_decides_which_twin_lands(self):
first, second = b"first font" * 9, b"second font" * 9
self.list.write_text(json.dumps({
"manifest_version": 2, "platform": "retroarch",
"files": [_entry("sys/font.bin", first, ["x"]),
_entry("SYS/FONT.BIN", second, ["x"])],
}))
self.keep("sys/FONT.BIN", first)
self.keep("sys/font.bin", second)
out = io.StringIO()
argv = ["install.py", "--platform", "retroarch", "--dest", str(self.dest),
"--manifest", str(self.list), "--from", str(self.old), "--no-input"]
with mock.patch.object(install, "keeps_case", return_value=False), \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch("sys.argv", argv), contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(io.StringIO()):
try:
install.main()
except SystemExit as exc:
self.assertIn(exc.code, (0, None), out.getvalue())
self.addCleanup(install.LOCAL.reset)
self.assertEqual(self.installed(), {"sys/font.bin": first})
self.assertIn("1 file copied from", out.getvalue())
class EventsForAFolder(FromFolder):
def test_each_file_found_is_an_event_before_it_is_copied(self):
self.keep("BIOS/SCPH5501.BIN", US)
_zip(self.old / "gba.zip", {"gba_bios.bin": GBA})
proc = self.install("--events")
events = _events(proc)
self.assertEqual(proc.returncode, 1, proc.stderr)
for event in events:
self.assertEqual(list(event), ["event", *install.EVENT_KEYS[event["event"]]])
found = sorted((e for e in events if e["event"] == "found"), key=lambda e: e["dest"])
self.assertEqual(found, [
{"event": "found", "platform": "retroarch", "dest": "gba_bios.bin",
"folder": str(self.old), "path": str((self.old / "gba.zip").resolve()),
"member": "gba_bios.bin", "reason": ""},
{"event": "found", "platform": "retroarch", "dest": "scph5501.bin",
"folder": str(self.old),
"path": str((self.old / "BIOS" / "SCPH5501.BIN").resolve()),
"member": "", "reason": ""},
])
kinds = [e["event"] for e in events]
self.assertLess(kinds.index("found"), kinds.index("download"))
copied = {e["dest"] for e in events if e["event"] == "downloaded" and e["ok"]}
self.assertEqual(copied, {"gba_bios.bin", "scph5501.bin"})
summary = next(e for e in events if e["event"] == "summary")
self.assertEqual(summary["installed"], 2)
def test_a_check_writes_the_same_events_and_nothing_else(self):
self.keep("BIOS/SCPH5501.BIN", US)
proc = self.run_installer("--check", "--events", "--manifest", str(self.list),
"--from", str(self.old))
events = _events(proc)
self.assertEqual([e["dest"] for e in events if e["event"] == "found"],
["scph5501.bin"])
self.assertNotIn("download", [e["event"] for e in events])
self.assertFalse(self.dest.exists())
def test_a_folder_that_does_not_exist_is_an_error_event(self):
proc = self.run_installer("--events", "--manifest", str(self.list), "--from",
str(self.root / "nowhere"))
events = _events(proc)
error = next(e for e in events if e["event"] == "error")
self.assertEqual((error["stage"], error["reason"]), ("from", "not_found"))
self.assertEqual(events[-1], {"event": "end", "code": 1})
class TheInterfaceReadsAFoundEvent(unittest.TestCase):
"""The full-screen interface drives the engine through --events. A
found event, which an install writes for a misplaced file even without
--from, leaves its screens as they were."""
FOUND = {"event": "found", "platform": "retroarch", "dest": "scph5501.bin",
"folder": "/bios", "path": "/bios/bios/scph5501.bin", "member": "",
"reason": "nested folder: it is at bios/bios/scph5501.bin"}
def test_every_run_ignores_it(self):
for run in ("check", "confirm", "preview", "install"):
with self.subTest(run=run):
state = install.TuiState()
state.run, state.run_id, state.running = run, 3, True
before = copy.deepcopy(vars(state))
self.assertEqual(install.tui_receive(state, ("event", 3, dict(self.FOUND))),
[])
self.assertEqual(vars(state), before)
class MisplacedInTheBiosFolder(Scratch):
"""F12: the files are in the BIOS folder, at a place no core opens."""
def install(self, *options: str) -> subprocess.CompletedProcess:
return self.run_installer("--manifest", str(self.list), "--no-input", *options)
def test_a_nested_bios_folder_is_fixed_and_left_in_place(self):
for name, data in PAYLOAD.items():
self.place(f"system/{name}", data)
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
for name, data in PAYLOAD.items():
self.assertEqual((self.dest / name).read_bytes(), data)
self.assertEqual((self.dest / "system" / name).read_bytes(), data)
self.assertIn("4 files fixed from copies already in the BIOS folder.", proc.stdout)
self.assertIn("nested folder: it is at system/system/scph5501.bin", proc.stdout)
def test_a_pack_extracted_into_a_folder_of_its_own_is_fixed(self):
for name, data in PAYLOAD.items():
self.place(f"RetroArch_BIOS_Pack/{name}", data)
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("one folder too deep: it is at RetroArch_BIOS_Pack/scph5501.bin",
proc.stdout)
self.assertEqual((self.dest / "pcsx" / "extra.bin").read_bytes(), EXTRA)
def test_a_name_in_capitals_is_fixed_where_case_counts(self):
probe = self.root / "CaseProbe"
probe.write_text("x")
if (self.root / "caseprobe").exists():
self.skipTest("this filesystem does not keep case")
self.complete("gba_bios.bin")
self.place("GBA_BIOS.BIN", GBA)
check = self.check()
self.assertIn("case of the name: the folder holds GBA_BIOS.BIN, the cores open "
"gba_bios.bin", check)
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual((self.dest / "gba_bios.bin").read_bytes(), GBA)
self.assertEqual((self.dest / "GBA_BIOS.BIN").read_bytes(), GBA)
self.assertIn("1 file fixed from copies already in the BIOS folder.", proc.stdout)
def test_a_file_left_in_its_zip_is_extracted(self):
self.complete("gba_bios.bin")
_zip(self.dest / "GBA BIOS.zip", {"bios/gba_bios.bin": GBA})
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual((self.dest / "gba_bios.bin").read_bytes(), GBA)
self.assertTrue((self.dest / "GBA BIOS.zip").is_file())
def test_a_file_above_the_folder_is_fixed(self):
self.complete("gba_bios.bin")
(self.root / "gba_bios.bin").write_bytes(GBA)
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual((self.dest / "gba_bios.bin").read_bytes(), GBA)
self.assertTrue((self.root / "gba_bios.bin").is_file())
def test_a_wrong_file_in_place_is_kept_before_the_right_bytes_take_its_place(self):
self.complete("gba_bios.bin")
wrong = b"y" * len(GBA)
self.place("gba_bios.bin", wrong)
self.place("old/gba_bios.bin", GBA)
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual((self.dest / "gba_bios.bin").read_bytes(), GBA)
kept = list((self.root / install.BACKUP_DIR).rglob("gba_bios.bin"))
self.assertEqual([p.read_bytes() for p in kept], [wrong])
def test_check_says_a_run_puts_it_in_place(self):
for name, data in PAYLOAD.items():
self.place(f"system/{name}", data)
out = self.check()
self.assertIn("4 of these files are in the BIOS folder at the wrong place: a run "
"without --check copies them where the cores open them, and leaves "
"your copies as they are.", out)
self.assertFalse((self.dest / "scph5501.bin").exists())
def test_dry_run_counts_them_and_writes_nothing(self):
for name, data in PAYLOAD.items():
self.place(f"system/{name}", data)
before = _snapshot(self.dest)
proc = self.install("--dry-run")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("Would fix 4 files", proc.stdout)
self.assertEqual(_snapshot(self.dest), before)
def test_a_mac_file_alone_is_named(self):
self.complete("gba_bios.bin")
self.place("._gba_bios.bin", b"\x00\x05\x16\x07" + b"\x00" * 60)
self.assertIn("only a Mac's ._ file: ._gba_bios.bin holds Finder data, not the "
"file itself", self.check())
def test_a_copy_at_another_place_of_the_list_is_no_mistake(self):
"""np2kai/2608_bd.wav and np2/2608_bd.wav hold the same bytes: the
second is copied from the first, and neither is called misplaced."""
probe = self.root / "CaseProbe"
probe.write_text("x")
if (self.root / "caseprobe").exists():
self.skipTest("this filesystem does not keep case")
data = json.loads(self.list.read_text())
twin = next(f for f in data["files"] if f["dest"] == "gba_bios.bin")
data["case_variants"] = [{**twin, "dest": "twin/gba_bios.bin"}]
self.list.write_text(json.dumps(data))
self.complete()
check = self.check()
self.assertIn("a run without --check copies it from gba_bios.bin", check)
self.assertNotIn("wrong place", check)
proc = self.install()
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual((self.dest / "twin" / "gba_bios.bin").read_bytes(), GBA)
self.assertIn("1 file copied from another place of the BIOS folder.", proc.stdout)
self.assertNotIn("fixed", proc.stdout)
def test_a_folder_with_nothing_misplaced_says_nothing(self):
with FakeRepository() as repo:
proc = repo.run("--no-input")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertNotIn("fixed", proc.stdout)
self.assertNotIn("copied from", proc.stdout)
class TheSummaryLine(unittest.TestCase):
def test_the_parts_in_words(self):
line = install.sources_line([("E:\\", 38)], fixed=0, downloaded=4, missing=2)
self.assertEqual(line, "38 files copied from E:\\, 4 downloaded, 2 still missing.")
def test_one_file_and_fixes(self):
self.assertEqual(install.sources_line([("/old", 1)], fixed=3, downloaded=0, missing=0),
"1 file copied from /old, 3 fixed from copies already in the "
"BIOS folder.")
self.assertEqual(install.sources_line([], fixed=2, downloaded=1, missing=0),
"2 files fixed from copies already in the BIOS folder, "
"1 downloaded.")
def test_nothing_copied_is_no_line(self):
self.assertEqual(install.sources_line([("/old", 0)], 0, 5, 0), "")
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -1093,6 +1093,19 @@ class ThePlatformsOwnSystemNamesAreAccepted(unittest.TestCase):
self.assertIn('"system_names": platform_system_names(config, list(pack_systems))',
source)
def test_every_listed_system_gets_the_title_the_site_prints(self):
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from generate_pack import system_titles # noqa: PLC0415
files = [{"dest": "a.bin", "systems": ["sony-playstation", "nintendo-gba"]}]
omitted = [{"dest": "b.bin", "systems": ["3do"]}]
self.assertEqual(system_titles(files, omitted), {
"3do": "3DO",
"nintendo-gba": "Nintendo Game Boy Advance",
"sony-playstation": "Sony PlayStation",
})
self.assertEqual(system_titles([{"dest": "c.bin", "systems": ["sys-a"]}]), {})
def test_a_native_name_narrows_and_lists(self):
with FakeRepository() as repo:
path = repo.root / "install" / "retroarch.json"
+410 -17
View File
@@ -484,7 +484,8 @@ class Flow(unittest.TestCase):
self.assertEqual(state.screen, "cores")
self.assertEqual([row.value for row in install.tui_rows(state)][1:],
["mesen", "pcsx_rearmed"])
press(state, "down", " ")
self.assertEqual(install.tui_rows(state)[state.focus["cores"]].value, "mesen")
press(state, " ")
self.assertEqual(state.cores, ["mesen"])
press(state, "left")
self.assertEqual(state.cores, [])
@@ -644,13 +645,14 @@ class Flow(unittest.TestCase):
state = fresh_state()
with mock.patch.object(install.Path, "exists", return_value=True):
press(state, "enter")
install.tui_receive(state, ("manifest_failed", "retroarch",
install.tui_receive(state, ("manifest_failed", "retroarch", "No connection",
[" Could not fetch the file list for retroarch", ""], 1))
self.assertIn("RetroBIOS: No connection", install.tui_view(state, 80, 24)[0])
self.assertEqual(state.screen, "error")
self.assertEqual([row.action for row in install.tui_rows(state)],
["retry", "back", "quit"])
self.assertEqual(press(state, "enter"), [("fetch", "retroarch")])
install.tui_receive(state, ("manifest_failed", "retroarch", ["no"], 1))
install.tui_receive(state, ("manifest_failed", "retroarch", "No connection", ["no"], 1))
press(state, "down", "down", "enter")
self.assertEqual(state.exit_code, 1)
@@ -697,7 +699,13 @@ class PlanWarnings(unittest.TestCase):
state = checked_state({"free": 1024, "writable": False, "cores": []})
text = " ".join(install.tui_view(state, 120, 40))
self.assertIn("Not enough free space for this download", text)
self.assertIn("This folder cannot be written", text)
self.assertIn("You cannot write to this folder: choose another one, or run the "
"installer with sudo.", text)
rows = install.tui_rows(state)
self.assertEqual([row.action for row in rows[:2]], ["folder", "install"])
self.assertEqual(state.focus.get("plan", 0), 0)
self.assertEqual(press(state, "enter"), [])
self.assertEqual(state.screen, "folder")
self.assertNotIn("--installed-cores", [row.right for row in install._option_rows(state)])
@@ -716,7 +724,8 @@ class Widgets(unittest.TestCase):
press(state, "end")
lines = install.tui_view(state, 40, 15)
self.assertIn("> [x] system-111", "\n".join(lines))
self.assertTrue(any(re.fullmatch(r" \d+-115 of 115", line) for line in lines), lines)
# The All, None and Filter rows are not counted among the systems.
self.assertTrue(any(re.fullmatch(r" \d+-112 of 112", line) for line in lines), lines)
press(state, "pgup")
self.assertLess(state.focus["systems"], 114)
press(state, "home")
@@ -755,7 +764,10 @@ class Widgets(unittest.TestCase):
press(state, "a")
self.assertEqual(len(state.ticked), 5)
state.focus["systems"] = 1
# X on None does nothing: only A, an explicit choice, unticks all.
press(state, " ")
self.assertEqual(len(state.ticked), 5)
press(state, "enter")
self.assertEqual(state.ticked, set())
def test_running_total_counts_each_file_once(self):
@@ -909,8 +921,8 @@ class Views(unittest.TestCase):
for name, state in golden_states().items():
with self.subTest(screen=name):
hint = install.tui_view(state, 40, 15)[-1].strip()
self.assertTrue(hint.startswith(("A/Enter", "Enter", "Ctrl-C", "Any key")),
hint)
self.assertTrue(hint.startswith(("A/Enter", "A ", "Enter", "Ctrl-C", "Any key",
"B/Esc Stop")), hint)
def test_focus_is_marked_without_colour(self):
lines = install.tui_view(fresh_state(), 80, 24)
@@ -1092,6 +1104,19 @@ class SmallFixes(unittest.TestCase):
self.assertTrue(state.installed_only)
self.assertNotIn("--installed-cores", [f for f, _v in install.tui_flags(state)])
def test_the_end_of_a_wrapped_pager_is_reachable(self):
state = checked_state()
state.stack.append("result")
state.code, state.summary = 0, {"installed": 3}
state.dest = Path("/media/" + "long-folder-name/" * 8)
state.cols, state.rows = 40, 12
press(state, "end")
lines = install.tui_view(state, 40, 12)
self.assertIn("python3 install.py", "\n".join(lines))
top = state.focus["result"]
press(state, "up")
self.assertEqual(state.focus["result"], top - 1)
def test_end_then_up_in_a_pager(self):
state = checked_state()
state.stack += ["options", "preview"]
@@ -1127,7 +1152,7 @@ class SmallFixes(unittest.TestCase):
"event": "downloaded", "dest": "a.bin", "ok": True, "reason": "", "size": 1,
"files_done": 1, "files_total": 4, "bytes_done": 1, "bytes_total": 100}))
press(state, "ctrl-c")
self.assertIn("Press Ctrl-C again to quit now.",
self.assertIn("Press B or Ctrl-C again to stop now.",
" ".join(install.tui_view(state, 120, 40)))
self.assertEqual(press(state, "ctrl-c"), [("kill",)])
install.tui_receive(state, ("ended", state.run_id, -9, []))
@@ -1192,7 +1217,10 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
CleanupSurvivesABrokenTerminal.Tty.broken = True
self.addCleanup(setattr, CleanupSurvivesABrokenTerminal.Tty, "broken", False)
work = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, work, True)
with mock.patch.object(install._TuiTerminal, "draw", breaking_draw), \
mock.patch.object(install.tempfile, "mkdtemp", return_value=str(work)), \
mock.patch.object(install, "tui_start", return_value=(fresh_state(), [])), \
mock.patch.object(install, "_open_local_sources"), \
mock.patch.object(install, "detect_os", return_value="linux"), \
@@ -1201,11 +1229,353 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
code = install.run_tui(_args())
self.assertIs(sys.stdout, tty)
self.assertEqual(code, 1)
# The crash keeps its log, and nothing else of the run.
self.assertEqual(sorted(path.name for path in work.iterdir()), ["error.log"])
self.assertEqual(termios.tcgetattr(slave), before)
for number, handler in handlers.items():
self.assertEqual(signal.getsignal(number), handler)
class AuditRoundTwo(unittest.TestCase):
"""tui_audit_1.md, each finding held where it was found."""
def systems(self) -> install.TuiState:
state = checked_state()
press(state, "down", "enter")
self.assertEqual(state.screen, "systems")
return state
def focused(self, state: install.TuiState) -> install.TuiRow:
rows = install.tui_rows(state)
return rows[state.focus[state.screen]]
def test_p1_3_the_filter_never_changes_the_ticks(self):
state = self.systems()
press(state, "n")
state.focus["systems"] = next(i for i, row in enumerate(install.tui_rows(state))
if row.value == "nintendo-gba")
press(state, "right")
self.assertEqual(state.ticked, {"nintendo-gba"})
press(state, "/", "p", "l", "a", "y")
self.assertEqual(self.focused(state).action, "filter")
press(state, "enter")
# Enter lands on the first match, never on All or None.
self.assertEqual(self.focused(state).value, "sony-playstation")
press(state, " ")
self.assertEqual(state.ticked, {"nintendo-gba", "sony-playstation"})
press(state, " ")
press(state, "esc")
# The filter is cleared, the row in focus stays in focus, and A there
# continues instead of ticking everything.
self.assertEqual(state.filters["systems"], "")
self.assertEqual(self.focused(state).value, "sony-playstation")
self.assertEqual(state.ticked, {"nintendo-gba"})
self.assertIn("A/Enter Continue", install.tui_view(state, 80, 24)[-1])
state.focus["systems"] = 2
press(state, " ")
self.assertEqual(state.editing, "")
self.assertEqual(state.ticked, {"nintendo-gba"})
def test_p2_2_the_bar_names_what_a_does_on_the_focused_row(self):
state = checked_state()
self.assertTrue(install.tui_view(state, 80, 24)[-1].strip().startswith("A/Enter Install"))
press(state, "down")
self.assertTrue(install.tui_view(state, 80, 24)[-1].strip().startswith("A/Enter Open"))
state = self.systems()
state.focus["systems"] = 0
self.assertIn("A/Enter Tick all", install.tui_view(state, 80, 24)[-1])
def test_p2_2_b_esc_and_q_stop_an_install(self):
for key in ("esc", "backspace", "q"):
with self.subTest(key=key):
state = checked_state()
press(state, "enter")
self.assertEqual(press(state, key), [("stop",)])
self.assertTrue(state.stopping)
self.assertEqual(press(state, key), [("kill",)])
def test_p2_3_options_read_the_plan_again(self):
state = self.systems()
press(state, "n")
state.focus["systems"] = 3
press(state, "right")
ticked = set(state.ticked)
press(state, "esc", "o")
self.assertEqual(state.screen, "options")
state.focus["options"] = 1
press(state, "enter")
self.assertTrue(state.no_game_data)
effects = press(state, "esc")
self.assertEqual(effects, [("run", "check")])
self.assertIn("--no-game-data", [f for f, _v in state.checked_flags])
self.assertNotIn("--system", [f for f, _v in state.checked_flags])
for event in [*FILES, *SYSTEMS, {**CHECK, "missing": 2, "bytes_needed": 600}]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
self.assertEqual(state.screen, "plan")
self.assertEqual(state.ticked, ticked)
for cols, rows in ((80, 24), (40, 15)):
text = "\n".join(install.tui_view(state, cols, rows))
self.assertIn("3 files to download, 600 B", text)
self.assertIn("For your 1 of 3 systems", text)
def test_p2_4_no_network_is_said_at_once(self):
import socket
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
inbox = __import__("queue").Queue()
started = time.monotonic()
with mock.patch.object(install, "MANIFEST_URL",
f"http://127.0.0.1:{port}/install/{{platform}}.json"):
install._tui_fetch("retroarch", inbox, io.StringIO())
self.assertLess(time.monotonic() - started, 5)
kind, plat, title, lines, code = inbox.get_nowait()
self.assertEqual((kind, title, code), ("manifest_failed", "No connection", 1))
self.assertIn("could not be downloaded: the server refused the connection", lines[0])
self.assertNotIn("--", " ".join(lines))
state = fresh_state()
with mock.patch.object(install.Path, "exists", return_value=True):
press(state, "enter")
install.tui_receive(state, (kind, plat, title, lines, code))
self.assertEqual([row.action for row in install.tui_rows(state)],
["retry", "back", "quit"])
def test_p2_4_the_clock_says_so(self):
import ssl
inbox = __import__("queue").Queue()
error = urllib_error(ssl.SSLCertVerificationError("certificate is not yet valid"))
words = install.network_words(error, today=install.datetime.date(1980, 1, 1))
with mock.patch.object(install, "_fetch_json", side_effect=error), \
mock.patch.object(install, "network_words", return_value=words):
install._tui_fetch("retroarch", inbox, io.StringIO())
_kind, _plat, title, lines, _code = inbox.get_nowait()
self.assertEqual(title, "The clock is wrong")
self.assertIn("The clock reads 1980-01-01", lines[0])
def test_p2_5_one_line_per_cause(self):
state = checked_state()
state.stack.append("result")
state.code = 2
state.summary = {"installed": 0, "up_to_date": 0, "failed": [
{"dest": f"f{n}.bin", "reason": "folder not writable"} for n in range(50)]}
_title, lines = install.tui_outcome(state)
self.assertEqual([line for line in lines if "failed" in line],
["Nothing was installed: 50 files failed.",
" 50 files failed (folder not writable), the first f0.bin"])
def test_p2_6_a_folder_inside_a_card_offers_its_root(self):
card = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, card, True)
(card / ".system").mkdir()
(card / ".system" / "version.txt").write_text("MinUI-20250101\n")
(card / "Bios").mkdir()
state = fresh_state()
state.stack = ["folder"]
self.assertEqual(install._choose(state, "minui", card / "Bios"), [])
self.assertEqual(state.screen, "cardroot")
text = " ".join(line.strip() for line in install.tui_view(state, 120, 40))
self.assertIn(f"under the card's root: {card}", text)
self.assertEqual(press(state, "enter"), [("fetch", "minui")])
self.assertEqual(state.dest, card)
def test_p2_6_the_banner_is_not_an_error_line(self):
state = fresh_state()
state.platform, state.dest = "retroarch", SYSTEM_DIR
state.manifest, state.manifest_for = MANIFEST, "retroarch"
install._start_check(state)
install.tui_receive(state, ("ended", state.run_id, 1,
["RetroBIOS 532f0b8d96e0", "Error: refused."]))
self.assertEqual(state.error, ["Error: refused."])
self.assertNotIn("retry", [row.action for row in install.tui_rows(state)])
def test_p2_7_a_typed_path_replaces_the_one_offered(self):
state = fresh_state()
state.platform, state.stack = "retroarch", ["folder"]
state.folder_default = SYSTEM_DIR
state.focus["folder"] = 2
press(state, "enter")
self.assertEqual((state.screen, state.path_selected), ("path", True))
install.tui_update(state, Key("paste", '"/media/sd card/bios"'))
self.assertEqual(state.path_text, '"/media/sd card/bios"')
install.tui_update(state, Key("ctrl-u"))
self.assertEqual(state.path_text, "")
install.tui_update(state, Key("paste", '/media/x"'))
self.assertEqual(install.typed_path(state.path_text), Path("/media/x"))
def test_p2_7_a_path_from_the_root_is_not_created_on_a(self):
state = fresh_state()
state.stack = ["folder"]
install._choose(state, "retroarch", Path("/no/such/deep/folder"))
self.assertEqual(state.screen, "create")
self.assertEqual(self.focused(state).action, "other")
self.assertIn("Every folder of this path would be made",
" ".join(install.tui_view(state, 120, 40)))
near = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, near, True)
install._choose(state, "retroarch", near / "bios")
self.assertEqual(self.focused(state).action, "create")
def test_p2_9_two_setups_of_one_name(self):
flatpak = HOME / ".var/app/org.libretro.RetroArch/config/retroarch/system"
setups = install.tell_apart([("retroarch", flatpak, "RetroArch"),
("retroarch", SYSTEM_DIR, "RetroArch"),
("emudeck", HOME / "Emulation/bios", "EmuDeck")])
self.assertEqual([label for _p, _f, label in setups],
["RetroArch (Flatpak)", "RetroArch (retroarch/system)", "EmuDeck"])
state = fresh_state()
state.setups = setups
self.assertIn("RetroArch (Flatpak)", "\n".join(install.tui_view(state, 40, 15)))
# A Flatpak whose config names a system folder outside its sandbox.
elsewhere = HOME / "bios"
config = HOME / ".var/app/org.libretro.RetroArch/config/retroarch/retroarch.cfg"
with mock.patch.dict(install._RETROARCH_CONFIGS, {elsewhere: (config, config.parent)}):
setups = install.tell_apart([("retroarch", elsewhere, "RetroArch"),
("retroarch", SYSTEM_DIR, "RetroArch")])
self.assertEqual(setups[0][2], "RetroArch (Flatpak)")
def test_p2_10_the_check_counts_while_it_reads(self):
state = fresh_state()
state.platform, state.dest = "retroarch", SYSTEM_DIR
state.manifest, state.manifest_for = MANIFEST, "retroarch"
install._start_check(state)
install.tui_receive(state, ("event", state.run_id,
{"event": "checking", "files_done": 120, "files_total": 400}))
self.assertIn("Reading the files already in the folder: 120 of 400",
"\n".join(install.tui_view(state, 80, 24)))
def test_p2_11_a_narrow_bar_keeps_the_keys_to_finish(self):
state = self.systems()
self.assertEqual(install.tui_view(state, 40, 15)[-1].strip(),
"A Continue B Back X Tick ? Help")
def test_p2_12_a_device_name_finds_its_setup(self):
state = fresh_state()
state.stack = ["platforms"]
press(state, "/", "m", "u", "o", "s")
self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["retroarch"])
def test_p2_13_a_long_preview_wraps_only_what_is_in_view(self):
state = checked_state()
state.stack += ["options", "preview"]
state.dry = dict(CHECK)
state.dry_files = [{**FILES[0], "dest": f"file{n:05d}.bin"} for n in range(8000)]
state.cols, state.rows = 40, 15
real = install._text_lines
calls = []
def counted(items, width):
calls.append(len(items))
return real(items, width)
with mock.patch.object(install, "_text_lines", counted):
press(state, "end")
lines = install.tui_view(state, 40, 15)
self.assertLess(sum(calls), 40)
self.assertIn("file07999.bin", "\n".join(lines))
def test_p2_1_titles_and_regions(self):
state = checked_state()
state.manifest = {**MANIFEST, "system_titles": {"nintendo-gba": "Game Boy Advance"}}
state.stack.append("systems")
text = "\n".join(install.tui_view(state, 40, 15))
self.assertIn("[x] Game Boy Advance", text)
self.assertEqual(install.region_title("asia-ntsc"), "Asia (NTSC)")
self.assertEqual(install.region_title("north-america"), "North America")
def test_p3_jobs_stop_at_the_ends_and_q_keeps_the_error_code(self):
state = checked_state()
state.jobs = 1
install._option(state, "jobs", -1)
self.assertEqual(state.jobs, 1)
state.jobs = 32
install._option(state, "jobs", 1)
self.assertEqual(state.jobs, 32)
state = fresh_state()
state.stack, state.error_code = ["setup", "error"], 2
press(state, "q")
self.assertEqual(state.exit_code, 2)
def test_p3_ascii_keeps_the_columns(self):
self.assertEqual(install.ascii_text("Édition ファミ"), "Edition ??????")
def test_p3_a_refused_tui_says_why_without_the_usage(self):
proc = install_run("--tui")
self.assertEqual(proc.returncode, 2)
self.assertEqual(proc.stderr.strip().splitlines(),
["install.py: error: --tui cannot open the full-screen interface "
"here: the input is not a terminal"])
def urllib_error(reason: BaseException):
import urllib.error
return urllib.error.URLError(reason)
class WorkFolders(unittest.TestCase):
"""Every run's folder of logs goes, but the one a failure names."""
class Terminal:
state_to = None
def __init__(self, *_streams) -> None:
self.frame = []
def enter(self) -> None:
pass
def leave(self) -> None:
pass
def size(self) -> tuple:
return 80, 24
def draw(self, lines, cols) -> None:
pass
def keys(self, timeout):
WorkFolders.Terminal.state_to(self)
return [Key("enter")]
def run_to(self, code: int) -> tuple:
work = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, work, True)
state = checked_state()
state.stack.append("result")
state.code = code
state.log = str(work / "install.log")
state.summary = {"installed": 1, "failed": [] if code == 0 else [
{"dest": "a.bin", "reason": "HTTP 404"}]}
WorkFolders.Terminal.state_to = lambda _t: None
out = io.StringIO()
with mock.patch.object(install, "_TuiTerminal", self.Terminal), \
mock.patch.object(install, "tui_start", return_value=(state, [])), \
mock.patch.object(install, "_open_local_sources"), \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(install.tempfile, "mkdtemp", return_value=str(work)), \
mock.patch.object(sys, "stdout", out), mock.patch.object(sys, "stderr", out):
self.assertEqual(install.run_tui(_args()), code)
return work, out.getvalue()
def test_a_success_leaves_nothing(self):
work, out = self.run_to(0)
self.assertFalse(work.exists())
self.assertNotIn("Details:", out)
def test_a_failure_keeps_the_log_it_names(self):
work, out = self.run_to(1)
self.assertTrue(work.exists())
self.assertIn(f"Details: {work / 'install.log'}", out)
def test_a_stop_leaves_nothing(self):
work, out = self.run_to(130)
self.assertFalse(work.exists())
class Crash(unittest.TestCase):
"""An unexpected error ends on one sentence and a log, never a traceback."""
@@ -1441,13 +1811,11 @@ class EndToEnd(unittest.TestCase):
term.send("\r")
term.wait("Preferred regions")
term.send(DOWN, "\r", "\x1b")
screen = term.wait("Preferred regions: north-america")
screen = term.wait("Preferred regions: North America")
term.send(DOWN, RIGHT, "\x1b")
screen = term.wait("> Install")
self.assertIn("Your choices: --region north-america --no-game-data", screen)
term.send("\r")
# Back on the plan, the check runs again under the new options.
screen = term.wait("4 files to download")
self.assertIn("RetroBIOS: Confirm", screen)
self.assertIn("> Install", screen)
term.send("\r")
screen = term.wait("installed.")
self.assertIn("Left out as game data: 1 file.", screen)
@@ -1477,11 +1845,36 @@ class EndToEnd(unittest.TestCase):
term.wait("1 of 6 files")
term.send("\x03")
screen = term.wait("Stopped:")
self.assertRegex(screen, r"Stopped: \d of 6 installed, run again to finish\.")
term.send("\r")
self.assertEqual(term.close(), 130)
self.assertLess(len(repo.installed()), 6)
self.assertIn("RetroBIOS: Stopped", term.after())
# The count said is the count on disk: "6 of 6" after a stop at 1
# passed a regex and was false.
on_disk = len(repo.installed())
self.assertLess(on_disk, 6)
self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", screen)
self.assertIn(f"Stopped: {on_disk} of 6 installed", term.after())
def test_sigterm_during_the_download_is_a_stop(self):
"""Start+Select on a handheld is gptokeyb's pkill: SIGTERM, SIGKILL 3 s on."""
import signal
repo = self.repo(GameDataRepository, slow=True)
term = self.start("--platform", "retroarch", "--dest", str(repo.dest), "--jobs", "1")
term.wait("> Install")
term.send("\r")
term.wait("1 of 6 files")
started = time.monotonic()
os.kill(term.pid, signal.SIGTERM)
code = term.close()
self.assertLess(time.monotonic() - started, 3)
self.assertEqual(code, 130)
on_disk = len(repo.installed())
self.assertGreaterEqual(on_disk, 1)
after = term.after()
self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", after)
self.assertNotIn("Nothing was installed", after)
time.sleep(1)
self.assertEqual(len(repo.installed()), on_disk)
def test_ctrl_c_on_the_first_screen(self):
self.repo()
@@ -1524,7 +1917,7 @@ class EndToEnd(unittest.TestCase):
term.resize(100, 30)
term.send(DOWN)
screen = term.wait("> Another setup or folder")
self.assertIn("A/Enter Continue", screen.splitlines()[29])
self.assertIn("B/Esc Quit", screen.splitlines()[29])
term.resize(30, 10)
term.wait("Make the window larger")
term.resize(40, 12)
+377 -36
View File
@@ -387,6 +387,14 @@ class TestAnchorBlock(unittest.TestCase):
self.assertEqual(result.status, "CHANGED")
self.assertEqual(result.start, line)
def test_a_file_rewritten_line_for_line_is_changed_not_gone(self):
# fheroes2 version.txt:1 went from 1.0.11 to 1.1.17. With no
# unchanged line left the file read GONE and held the whole recale.
result = anchor_block(["1.0.11"], ["1.1.17"], 1, 1)
self.assertEqual((result.status, result.start, result.end), ("CHANGED", 1, 1))
result = anchor_block(["a = 1", "b = 2"], ["a = 3", "b = 4"], 2, 2)
self.assertEqual((result.status, result.start), ("CHANGED", 2))
def test_gone_when_content_vanished(self):
result = anchor_block(["a", "unique marker", "b"], ["x"] * 200, 2, 2)
self.assertEqual(result.status, "GONE")
@@ -1173,6 +1181,12 @@ class TestBuildReport(unittest.TestCase):
profile_sync.upstream._http_text,
profile_sync.upstream.list_gitlinks,
profile_sync.upstream.current_slug,
profile_sync.upstream.fork_base,
)
self.fork_bases: dict[str, str] = {}
profile_sync.upstream.fork_base = (
lambda repo, sha, fork, fork_sha, cache_dir, offline=False:
self.fork_bases.get(fork_sha)
)
self.current_slugs: dict[str, str] = {}
profile_sync.upstream.current_slug = (
@@ -1235,6 +1249,7 @@ class TestBuildReport(unittest.TestCase):
profile_sync.upstream._http_text,
profile_sync.upstream.list_gitlinks,
profile_sync.upstream.current_slug,
profile_sync.upstream.fork_base,
) = self._orig
self.tmp.cleanup()
@@ -2723,6 +2738,21 @@ class TestWriteDryRun(unittest.TestCase):
self.assertNotIn("source_commit", output)
self.assertEqual(self.path.read_text(), before)
def test_a_bump_that_leaves_stale_prose_is_rolled_back(self):
"""dolphin-mmjr2, hatari, xenios: the bump moved upstream_commit only,
and prose the report had judged in the fork fell under the new
upstream pin. The guard had read it under the old pins."""
from unittest import mock
before = self.path.read_text()
answers = iter([[], ["notes: a.c:10 (a.c:10 changed)"]])
with mock.patch.object(
profile_sync, "realign_prose", side_effect=lambda *a, **k: next(answers)
), contextlib.redirect_stderr(io.StringIO()) as err:
self._run(self._args(rebase_refs=True, dry_run=False), self._shifted())
self.assertEqual(self.path.read_text(), before)
self.assertIn("nothing was written", err.getvalue())
def test_a_planned_bump_reads_the_prose_the_rebase_would_have_left(self):
"""The pin is blocked by prose until the rebase moves it.
@@ -2812,17 +2842,30 @@ class TestDeliberatelyFrozenPin(TestBuildReport):
source = profile.get("source")
if isinstance(source, str):
by_source.setdefault(source.rstrip("/").lower(), []).append(name)
# A version a platform builds (built_by: mame0258, mame2014) is not
# the same program as the repository's other cores: mame2015-libretro
# builds mame2014, mess2015 and cdi2015, three system sets. And a
# live build can add a system its frozen revision lacked
# (advancemame's coh1002v, after advmame-1.4), so the frozen one's
# systems are a subset, not an equal set.
pairs = 0
for names in by_source.values():
frozen = [n for n in names if str(profiles[n].get("pin_frozen") or "").strip()]
live = [n for n in names if n not in frozen]
frozen = [
n for n in names
if str(profiles[n].get("pin_frozen") or "").strip()
and not profiles[n].get("built_by")
]
live = [
n for n in names
if n not in frozen and not str(profiles[n].get("pin_frozen") or "").strip()
]
for old in frozen:
for new in live:
pairs += 1
with self.subTest(frozen=old, live=new):
self.assertEqual(
sorted(profiles[old].get("systems") or []),
sorted(profiles[new].get("systems") or []),
self.assertLessEqual(
set(profiles[old].get("systems") or []),
set(profiles[new].get("systems") or []),
)
if (old, new) not in builds_differ:
self.assertEqual(profiles[old].get("type"), profiles[new].get("type"))
@@ -2961,6 +3004,34 @@ class TestRebaseWaitsForThePin(unittest.TestCase):
self.assertFalse(profile_sync.pending_recale(report))
self.assertEqual(rebase_refs(self.path, report), ["a.c:10-12 -> a.c:30-32"])
def test_chained_shifts_are_judged_at_their_own_entry(self):
# tr1x: cut2 508->531, cut3 531->552, cut4 552->575. Once written,
# cut3 and cut4 read as pending on the values cut2 and cut3 took.
text = (
'emulator: Test\nsource: "https://github.com/o/n"\n'
'source_commit: "pin"\nfiles:\n'
+ "".join(
f' - name: "cut{n}.mp3"\n source_ref: "g.json5:{line}"\n'
for n, line in ((2, 508), (3, 531), (4, 552))
)
)
self.path.write_text(text, encoding="utf-8")
entries = [
EntryReport(
f"cut{n}.mp3", f"g.json5:{old}", "SHIFTED",
[PartResult(RefPart("g.json5", old, old), "SHIFTED", None, new, new, [])],
"ref", f"files[cut{n}.mp3].source_ref",
)
for n, old, new in ((2, 508, 531), (3, 531, 552), (4, 552, 575))
]
report = self._report(entries, {"SHIFTED": 3})
self.assertEqual(len(rebase_refs(self.path, report)), 3)
written = self.path.read_text()
self.assertIn('source_ref: "g.json5:575"', written)
self.assertEqual(
profile_sync.pending_recale(report, text=written), 0
)
class TestBumpCommit(unittest.TestCase):
def setUp(self):
@@ -2999,6 +3070,24 @@ class TestBumpCommit(unittest.TestCase):
self.assertEqual(profile_sync.pending_recale(report), 0)
self.assertTrue(bump_commit(self.path, report))
def test_refused_on_a_build_a_platform_compiles(self):
# mame0258 documents the MAME Recalbox builds: libretro/mame HEAD
# is a later release, and every ref still anchors there.
self.path.write_text(SAMPLE + "built_by: [recalbox]\n", encoding="utf-8")
part = PartResult(
RefPart("a.c", 10, 12, "a.c:10-12"), "ANCHORED", None, 10, 12, []
)
report = ProfileReport(
name="p", repo="o/n", pin="pin", head="newhead",
entries=[
EntryReport("a.bin", "a.c:10-12, (just a note)", "ANCHORED", [part])
],
counts={"ANCHORED": 1},
)
with contextlib.redirect_stderr(io.StringIO()):
self.assertFalse(bump_commit(self.path, report))
self.assertNotIn("newhead", self.path.read_text())
def test_refused_when_no_ref_reads_the_pinned_repository(self):
"""gam4980: every bare libretro.c ref anchored in upstream, and the
bump wrote the fork's HEAD over refs that never described it."""
@@ -3134,6 +3223,7 @@ class TestCheckVersion(unittest.TestCase):
"tagsha" if tag == "v3.0.0" else None
)
)
profile_sync.upstream.tag_commit = profile_sync.upstream.resolve_tag_commit
def tearDown(self):
(
@@ -3268,6 +3358,44 @@ class TestDetectNewFiles(unittest.TestCase):
found, _ = detect_new_files(['#include "driver.cpp"'], set())
self.assertEqual(found, [])
def test_a_printf_conversion_is_not_part_of_the_name(self):
# sdl2trs src/trs_options.c:864-866 builds "%slevel2.rom" and the
# report named slevel2.rom.
lines = [
'snprintf(romfile1, sizeof(romfile1), "%slevel2.rom", ROM_PATH);',
'snprintf(name, 64, "%02dcart.bin", slot);',
]
found, _ = detect_new_files(lines, set(), path="src/trs_options.c")
self.assertEqual(found, [(1, "level2.rom"), (2, "cart.bin")])
def test_a_quoted_name_with_spaces_is_read_whole(self):
# virtualjaguar libretro.c:5815-5816 opens "Jaguar CD BIOS.rom",
# reported as BIOS.rom. A sentence still yields the name it holds.
lines = [
'snprintf(path, n, "%s%c%s", dir, slash, "Jaguar CD BIOS.rom");',
'log("Please insert bios.bin first");',
'message(STATUS "Packing patches.zip")',
'#define ROM ".bin.hex.iii.rom"',
]
found, _ = detect_new_files(lines, set(), path="libretro.c")
self.assertEqual(
found,
[(1, "Jaguar CD BIOS.rom"), (2, "bios.bin"), (3, "patches.zip")],
)
def test_an_identifier_in_code_is_not_a_file(self):
# pcee2 reads config keys EE.bios and IOP.bios and a field psm_s.pal.
lines = [
"cfg.EE.bios = load(path);",
"if (psm_s.pal) {",
"// loads boot.rom from the system directory",
'open("ipl.rom");',
]
found, _ = detect_new_files(lines, set(), path="src/core/config.cpp")
self.assertEqual(found, [(3, "boot.rom"), (4, "ipl.rom")])
found, _ = detect_new_files(["Copy EE.bios here"], set(), path="README.md")
self.assertEqual(found, [(1, "EE.bios")])
class TestWatchHashes(unittest.TestCase):
def test_finds_new_sha1(self):
@@ -3565,6 +3693,37 @@ class TestCollectCitations(unittest.TestCase):
citation = collect_citations(document)[0]
self.assertEqual(citation.ref, "x.c:100")
def test_spaced_lone_number_closing_the_citation_joins_the_run(self):
# vkquake wrote `(Quake/bgmusic.c:29, 321).` and cemu a list of ten
# lines of .gitlab-ci.yml: only the first number was ever compared.
cases = {
"music (Quake/bgmusic.c:29, 321).\n": [(29, 29), (321, 321)],
"(ref: a/b.cpp:1586-1633, 2646).\n": [(1586, 1633), (2646, 2646)],
"jobs in .gitlab-ci.yml:110, 116, 139-140\n": [
(110, 110), (116, 116), (139, 140)
],
"read at x.c:5, 6; then y\n": [(5, 5), (6, 6)],
}
for text, spans in cases.items():
citation = collect_citations({"notes": text})[0]
self.assertEqual(
[(p.start, p.end) for p in citation.parts], spans, text
)
line = text.splitlines()[0]
self.assertEqual(
[line[lo:hi] for lo, hi in citation.spans],
[p.raw for p in citation.parts],
)
def test_spaced_number_list_followed_by_a_word_stays_prose(self):
for text in (
"It reads x.c:5, 6, 7 players at once.\n",
"It reads x.c:5, 6 and 7 at once.\n",
"Lines 3, 4 and 5, version 2, 3 players.\n",
):
refs = [c.ref for c in collect_citations({"notes": text})]
self.assertEqual(refs, ["x.c:5"] if "x.c" in text else [], text)
def test_declared_repository_name_is_not_external(self):
document = {
"upstream": "https://github.com/o/mednafen",
@@ -3611,6 +3770,13 @@ files:
source_ref: "a.c:10-12"
'''
# One run written twice in a folded scalar: no physical line carries the
# folded sentence, and the run alone is not unique.
FOLDED_TWICE = PROSE_SAMPLE.replace(
"files:",
"twice: >\n Read at (q.c:7) first\n and again at (q.c:7) later.\nfiles:",
)
class TestRebaseProse(unittest.TestCase):
def setUp(self):
@@ -3678,6 +3844,18 @@ class TestRebaseProse(unittest.TestCase):
self.assertIn("(a.c:10)", self.path.read_text())
def test_ambiguous_sentence_location_is_left_alone(self):
self.path.write_text(FOLDED_TWICE, encoding="utf-8")
part = PartResult(RefPart("q.c", 7, 7, "7"), "SHIFTED", None, 9, 9, [])
applied = rebase_refs(
self.path, self._report([self._prose_entry("q.c:7", [part], "twice")])
)
self.assertEqual(applied, [])
self.assertEqual(self.path.read_text(), FOLDED_TWICE)
def test_one_sentence_in_two_fields_moves_in_both(self):
# emucorex cites CMakeLists.txt:706 in notes and in exclusion_note on
# identical lines: neither was unique in the file, one was left, and
# the pin refused to follow.
text = PROSE_SAMPLE.replace(
"files:",
'quirks: |\n The loader appends dc/ to the system directory '
@@ -3685,11 +3863,37 @@ class TestRebaseProse(unittest.TestCase):
)
self.path.write_text(text, encoding="utf-8")
part = PartResult(RefPart("a.c", 10, 10, "10"), "SHIFTED", None, 14, 14, [])
applied = rebase_refs(
self.path, self._report([self._prose_entry("a.c:10", [part])])
report = self._report([
self._prose_entry("a.c:10", [part]),
self._prose_entry("a.c:10", [part], "quirks"),
])
self.assertEqual(
rebase_refs(self.path, report),
["notes: a.c:10 -> a.c:14", "quirks: a.c:10 -> a.c:14"],
)
self.assertEqual(applied, [])
self.assertEqual(self.path.read_text(), text)
written = self.path.read_text()
self.assertEqual(written, text.replace("(a.c:10)", "(a.c:14)"))
self.assertEqual(profile_sync.pending_recale(report, text=written), 0)
self.assertTrue(bump_commit(self.path, report))
def test_a_refused_recale_names_the_citation_and_writes_nothing(self):
# The structured ref could move, the prose could not: the run
# printed nothing, wrote nothing, and the pin stayed.
self.path.write_text(FOLDED_TWICE, encoding="utf-8")
prose = PartResult(RefPart("q.c", 7, 7, "7"), "SHIFTED", None, 9, 9, [])
structured = PartResult(RefPart("a.c", 10, 12), "SHIFTED", None, 14, 16, [])
report = self._report([
self._prose_entry("q.c:7", [prose], "twice"),
EntryReport("a.bin", "a.c:10-12", "SHIFTED", [structured]),
])
err = io.StringIO()
with contextlib.redirect_stderr(err):
self.assertEqual(rebase_refs(self.path, report), [])
self.assertIn(
"p: not recaled, cannot move twice: q.c:7 (0 lines carry its sentence)",
err.getvalue(),
)
self.assertEqual(self.path.read_text(), FOLDED_TWICE)
def test_folded_scalar_moves_through_its_run_token(self):
text = PROSE_SAMPLE.replace(
@@ -3776,14 +3980,12 @@ class TestPendingProse(unittest.TestCase):
self.assertTrue(bump_commit(self.path, report))
def test_unwritable_prose_blocks_the_pin(self):
text = PROSE_SAMPLE.replace(
"files:",
'quirks: |\n The loader appends dc/ to the system directory '
'(a.c:10) and reads\n nothing else.\nfiles:',
)
self.path.write_text(text, encoding="utf-8")
part = PartResult(RefPart("a.c", 10, 10, "10"), "SHIFTED", None, 14, 14, [])
self.path.write_text(FOLDED_TWICE, encoding="utf-8")
part = PartResult(RefPart("q.c", 7, 7, "7"), "SHIFTED", None, 9, 9, [])
report = self._report([part])
report.entries[0] = EntryReport(
"twice", "q.c:7", "SHIFTED", [part], "prose", "twice"
)
self.assertEqual(rebase_refs(self.path, report), [])
self.assertFalse(bump_commit(self.path, report))
@@ -3832,24 +4034,46 @@ class TestWrappedProse(unittest.TestCase):
}
self.assertEqual(runs, {"src/config.rs:76-78,95-99": True})
def test_a_wrapped_run_that_moves_holds_the_pin(self):
def _report(self, ref, parts):
return ProfileReport(
name="p", repo="o/n", pin="pin", head="newhead",
entries=[EntryReport("adam", ref, "SHIFTED", parts, "prose", "adam")],
counts={"SHIFTED": 1},
)
def test_a_wrapped_run_moves_on_both_lines_and_the_pin_follows(self):
# gearcoleco put its four ranges on the line after `libretro.cpp:`,
# and the run held the pin with nothing written and nothing said.
parts = [
PartResult(RefPart("libretro.cpp", 2372, 2373, "2372-2373"),
"SHIFTED", None, 2381, 2382, []),
PartResult(RefPart("libretro.cpp", 2429, 2433, "2429-2433"),
"SHIFTED", None, 2438, 2442, []),
]
entry = EntryReport(
"adam", "libretro.cpp:2372-2373, 2429-2433", "SHIFTED", parts,
"prose", "adam",
report = self._report("libretro.cpp:2372-2373, 2429-2433", parts)
self.assertEqual(len(rebase_refs(self.path, report)), 1)
text = self.path.read_text()
self.assertIn("(libretro.cpp:\n 2381-2382, 2438-2442) and by", text)
self.assertTrue(bump_commit(self.path, report))
def test_a_break_before_the_colon_moves_the_next_line(self):
# playdiaemu wrote `.../src/content.rs` and `:111-199` one line apart.
text = PROSE_SAMPLE.replace(
"files:",
"adam: |\n ZIP archives from the game path (src/content.rs\n"
" :111-199). No other file is opened.\nfiles:",
)
report = ProfileReport(
name="p", repo="o/n", pin="pin", head="newhead",
entries=[entry], counts={"SHIFTED": 1},
self.path.write_text(text, encoding="utf-8")
part = PartResult(RefPart("src/content.rs", 111, 199, "111-199"),
"CHANGED", None, 111, 204, [])
report = self._report("src/content.rs:111-199", [part])
report.counts = {"CHANGED": 1}
self.assertEqual(
rebase_refs(self.path, report, accept_changed=True),
["adam: src/content.rs:111-199 -> src/content.rs:111-204"],
)
self.assertEqual(rebase_refs(self.path, report), [])
self.assertFalse(bump_commit(self.path, report))
self.assertEqual(self.path.read_text(), self.TEXT)
self.assertIn("(src/content.rs\n :111-204). No", self.path.read_text())
self.assertTrue(bump_commit(self.path, report, accept_changed=True))
class TestOpcodeCache(unittest.TestCase):
@@ -3902,19 +4126,41 @@ class TestCommentCitations(unittest.TestCase):
[c.ref for c in profile_sync.collect_citations(document, self.TEXT)],
)
def test_a_comment_run_that_moves_holds_the_pin(self):
field = profile_sync.comment_citations(self.TEXT)[0].field
def _report(self, index, part):
field = profile_sync.comment_citations(self.TEXT)[index].field
return ProfileReport(
name="p", repo="o/n", pin="pin", head="newhead",
entries=[EntryReport("comment", f"{part.part.path}:{part.part.raw}",
part.status, [part], "prose", field)],
counts={part.status: 1},
)
def test_a_comment_run_moves_and_the_pin_follows(self):
# amiarcadia `# da.h:3-5` and mame2003_plus `core_options.c:223-236`
# held the pin, and --rebase-refs wrote nothing and said nothing.
part = PartResult(RefPart("src/cpu.c", 3212, 3451, "3212-3451"),
"SHIFTED", None, 3268, 3507, [])
report = ProfileReport(
name="p", repo="o/n", pin="pin", head="newhead",
entries=[EntryReport("comment", "src/cpu.c:3212-3451", "SHIFTED",
[part], "prose", field)],
counts={"SHIFTED": 1},
report = self._report(0, part)
self.assertEqual(
rebase_refs(self.path, report),
["comment line 10: src/cpu.c:3212-3451 -> src/cpu.c:3268-3507"],
)
self.assertEqual(rebase_refs(self.path, report), [])
self.assertFalse(bump_commit(self.path, report))
self.assertEqual(self.path.read_text(), self.TEXT)
self.assertEqual(
self.path.read_text(),
self.TEXT.replace("src/cpu.c:3212-3451", "src/cpu.c:3268-3507"),
)
self.assertTrue(bump_commit(self.path, report))
def test_a_renamed_comment_path_is_rewritten(self):
# bennugd `libretro.c:887-910` in a comment: RENAMED to
# libretro/libretro.c, same lines, and nothing moved.
part = PartResult(RefPart("z.c", 9, 9, "9"), "RENAMED", "src/z.c", 9, 9, [])
report = self._report(1, part)
self.assertEqual(
rebase_refs(self.path, report), ["comment line 16: z.c:9 -> src/z.c:9"]
)
self.assertIn('"a.c:10-12" # see src/z.c:9', self.path.read_text())
self.assertTrue(bump_commit(self.path, report))
class TestRealignProse(unittest.TestCase):
@@ -4512,6 +4758,19 @@ class TestEmulatorsDirStatus(unittest.TestCase):
Path(root, "a.yml").write_text("emulator: a\n")
self.assertTrue(profile_sync.emulators_dir_is_dirty(root))
def test_only_the_selected_profiles_count(self):
# A loop of --emulator X --rebase-refs refused every profile after
# the first one it wrote.
with tempfile.TemporaryDirectory() as root:
self._git("init", "-q", cwd=root)
Path(root, "a.yml").write_text("emulator: a\n")
Path(root, "b.yml").write_text("emulator: b\n")
self._git("add", "a.yml", "b.yml", cwd=root)
self._git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "p", cwd=root)
Path(root, "a.yml").write_text("emulator: a2\n")
self.assertTrue(profile_sync.emulators_dir_is_dirty(root, ["a"]))
self.assertFalse(profile_sync.emulators_dir_is_dirty(root, ["b"]))
def test_a_tree_outside_any_repository_is_not_clean(self):
with tempfile.TemporaryDirectory() as root, mock.patch.dict(
os.environ, {"GIT_CEILING_DIRECTORIES": str(Path(root).parent)}
@@ -4550,6 +4809,88 @@ class UpstreamVendoredAsASubmodule(unittest.TestCase):
self.assertEqual(views[1].head, "nesbox-tip")
class RealignReadsThePathAsWritten(unittest.TestCase):
"""hatarib cites hatari/src/tos.c, a path of hatariB. When only
upstream_commit moved, realign stripped the hatari/ prefix, read
hatari/hatari's src/tos.c and proposed moving a correct citation."""
def test_an_unmoved_repository_carrying_the_path_keeps_it(self):
from unittest import mock # noqa: PLC0415
fork = profile_sync.upstream.make_repo("github.com", "b", "hatariB")
up = profile_sync.upstream.make_repo("github.com", "hatari", "hatari")
trees = {(fork, "fork-pin"): {"hatari/src/tos.c"},
(up, "old"): {"src/tos.c"}, (up, "new"): {"src/tos.c"}}
def fetch(repo, sha, path, *a, **k):
return ["line"] * 40 if path in trees.get((repo, sha), set()) else None
part = profile_sync.RefPart("hatari/src/tos.c", 10, 10, "10")
with mock.patch.object(profile_sync.upstream, "fetch_file", side_effect=fetch), \
mock.patch.object(profile_sync, "_resolve_at",
return_value=("found", ("src/tos.c", ["x"] * 40))):
result = profile_sync._realign_part(
part, [(up, "old", "new")], "cache", True,
pinned=[(fork, "fork-pin"), (up, "new")],
)
self.assertIsNone(result)
class RealignGivesASharedPathToTheFirstRepository(unittest.TestCase):
"""freej2me-plus and hex007/freej2me both hold src/libretro/: with only
upstream_commit moved, the fork's citations were read in upstream."""
def test_the_source_declared_first_keeps_it(self):
from unittest import mock # noqa: PLC0415
fork = profile_sync.upstream.make_repo("github.com", "t", "freej2me-plus")
up = profile_sync.upstream.make_repo("github.com", "hex007", "freej2me")
part = profile_sync.RefPart("src/libretro/freej2me_libretro.c", 39, 46, "39-46")
with mock.patch.object(
profile_sync.upstream, "fetch_file", return_value=["line"] * 99
), mock.patch.object(
profile_sync, "_resolve_at", return_value=("found", (part.path, ["x"] * 99))
):
result = profile_sync._realign_part(
part, [(up, "eb4ac87", "8b9bc8a")], "cache", True,
pinned=[(fork, "3933e75"), (up, "8b9bc8a")],
)
self.assertIsNone(result)
class UpstreamForkedByThePort(unittest.TestCase):
"""snes9x, kronos and np2kai pinned upstream_commit on the merge base of
upstream with the fork. Compared to upstream's own HEAD, a bump moved it
past anything the port carries."""
def _views(self, base):
from unittest import mock # noqa: PLC0415
fork = profile_sync.upstream.make_repo("github.com", "f", "port")
self.up = profile_sync.upstream.make_repo("github.com", "o", "up")
self.fork = fork
views = [
profile_sync.RepoView(fork, "src-pin", "src-head", "source_commit", "source"),
profile_sync.RepoView(self.up, "old-base", "up-tip", "upstream_commit", "upstream"),
]
with mock.patch.object(
profile_sync.upstream, "fork_base", return_value=base
) as asked:
followed = profile_sync._follow_fork_base(views, "cache", True)
return followed, asked
def test_the_upstream_head_is_the_fork_base(self):
views, asked = self._views("new-base")
self.assertEqual(views[1].head, "new-base")
self.assertEqual(
asked.call_args.args[:4], (self.up, "up-tip", self.fork, "src-head")
)
def test_a_port_outside_the_network_keeps_the_tip(self):
views, _ = self._views(None)
self.assertEqual(views[1].head, "up-tip")
class OfflineCacheMissIsNotGone(TestBuildReport):
"""Offline, a file the cache never stored came back as None and the
report read "GONE (absent at HEAD, no rename found)" for files that
+74
View File
@@ -910,5 +910,79 @@ class TestCurrentSlug(unittest.TestCase):
)
class TestNewestTag(unittest.TestCase):
"""GitHub's REST tag list is sorted by name: nesoid lists 2.5 before
2.5-4, so the first entry was reported as the latest tag."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.repo = make_repo("github.com", "o", "n")
self.saved = (upstream._github_graphql, upstream.list_tags)
upstream.list_tags = lambda repo, cache, offline=False: ["2.5", "2.5-4"]
def tearDown(self):
upstream._github_graphql, upstream.list_tags = self.saved
self.tmp.cleanup()
def test_commit_date_order_wins(self):
upstream._github_graphql = lambda *a: {
"data": {"repository": {"refs": {"nodes": [{"name": "2.5-4"}]}}}
}
self.assertEqual(upstream.newest_tag(self.repo, self.tmp.name), "2.5-4")
def test_without_a_token_the_forge_order_stays(self):
upstream._github_graphql = lambda *a: None
self.assertEqual(upstream.newest_tag(self.repo, self.tmp.name), "2.5")
class TestNamedTagCommit(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.saved = upstream._http_json
def tearDown(self):
upstream._http_json = self.saved
self.tmp.cleanup()
def test_gitlab_tag_is_asked_by_name(self):
asked = []
def answer(url):
asked.append(url)
return {"name": "1.2.35", "commit": {"id": "c74fc36"}}
upstream._http_json = answer
repo = parse_repo("https://gitlab.com/jengun/sdltrs")
self.assertEqual(upstream.tag_commit(repo, "1.2.35", self.tmp.name), "c74fc36")
self.assertTrue(asked[0].endswith("/repository/tags/1.2.35"))
class TestForkBase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.saved = upstream._http_json
def tearDown(self):
upstream._http_json = self.saved
self.tmp.cleanup()
def test_the_merge_base_github_names(self):
asked = []
def answer(url):
asked.append(url)
return {"merge_base_commit": {"sha": "base"}, "status": "diverged"}
upstream._http_json = answer
up, fork = make_repo("github.com", "o", "up"), make_repo("github.com", "f", "port")
self.assertEqual(upstream.fork_base(up, "tip", fork, "head", self.tmp.name), "base")
self.assertTrue(asked[0].endswith("/repos/o/up/compare/tip...f:port:head"))
def test_no_network_is_none(self):
upstream._http_json = lambda url: None
up, fork = make_repo("github.com", "o", "up"), make_repo("github.com", "f", "port")
self.assertIsNone(upstream.fork_base(up, "tip", fork, "head", self.tmp.name))
if __name__ == "__main__":
unittest.main()