From faa5ca89d0e61ddcdada2fa4775182fbb20ed8c6 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:03:03 +0100 Subject: [PATCH] chore: count the keys of the five journeys Each journey of the full screen runs on a pty against a loopback list and asserts its key count: a first install (2 to start, 3 in all), a muOS card holding a game (2, 3, its games read since the fix), an update (2, 3), one system then the same again (14, 15, then 2, 3 for the whole list), what is missing (5, 6), a folder for another device (19, 20). --- tests/test_install_journeys.py | 306 +++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/test_install_journeys.py diff --git a/tests/test_install_journeys.py b/tests/test_install_journeys.py new file mode 100644 index 000000000..e87080a22 --- /dev/null +++ b/tests/test_install_journeys.py @@ -0,0 +1,306 @@ +"""The five journeys of the full screen, keys counted from launch. + +Measured on 2026-11-09: a first install with RetroArch found, a muOS card +in a reader holding a game, an update after a release, an install narrowed +to one system then run again, a look at what is missing, and a folder for +another device. Each test drives the real install.py --tui on a +pseudo-terminal against a loopback repository, every key counted, typed +letters included. A journey that takes another number of keys than +JOURNEY_KEYS fails: a change that shortens one lowers its count here, and +one that lengthens it has to say so here too. +""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import functools +import hashlib +import http.server +import json +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +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 +import test_install_games as games # noqa: E402 +from test_install_tui import DOWN, UP, Pty, pty, pyte # noqa: E402 + +END = "\x1b[F" +ESC = "\x1b" + +# (keys to start the install or reach the answer, keys in all). One system +# is reached through None and the filter, the other device's folder typed +# as ~/bios. +JOURNEY_KEYS = { + "first_install": (2, 3), + "card_with_games": (2, 3), + "update": (2, 3), + "one_system": (14, 15), + "one_system_again": (2, 3), + "what_is_missing": (5, 6), + "another_device": (19, 20), +} + +# Runs install.py with the mounted drives a reader would show, none or the +# card named: the drives of the machine running the tests (WSL's /mnt/d) +# changed the screens. Only the screen's own process looks for cards: the +# runs it starts are given --platform and --dest. +_WITH_CARDS = ( + "import os, sys\n" + "from pathlib import Path\n" + "sys.argv[0] = {script!r}\n" + "sys.path.insert(0, os.path.dirname(sys.argv[0]))\n" + "import install\n" + "card = os.environ.get('JOURNEY_CARD')\n" + "install._mounted_cards = lambda: [Path(card)] if card else []\n" + "install._line_buffered_stdout()\n" + "install.main()\n" +) + + +class _Quiet(http.server.SimpleHTTPRequestHandler): + def log_message(self, format, *args): # noqa: A002 - stdlib signature + return + + +class Repository: + """The games fixture of test_install_games served on loopback: a + RetroArch list, the same list for Batocera, the games index with a + muOS setup, and every payload.""" + + def __init__(self, test: unittest.TestCase) -> None: + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + test.addCleanup(shutil.rmtree, self.root, True) + self.site = self.root / "site" + (self.site / "install" / "games").mkdir(parents=True) + (self.site / "bios").mkdir() + self.listed = games.manifest() + self.write_list() + muos = dict(games.SETUP, setup="muos", name="muOS", + roms_root={"path": "/mnt/mmc/ROMS", "from_bios": "../../ROMS"}) + retroarch = dict(games.SETUP, roms_root={"path": None, "from_bios": None}) + games.write_index(self.site / "install" / "games", setup=retroarch, + more_setups={"muos": muos}) + handler = functools.partial(_Quiet, directory=str(self.site)) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + test.addCleanup(server.server_close) + test.addCleanup(server.shutdown) + self.home = self.root / "home" + self.home.mkdir() + self.env = {**os.environ, "TERM": "xterm", "HOME": str(self.home), + "RETROBIOS_OS": "linux", "XDG_CONFIG_HOME": str(self.home / ".config"), + "RETROBIOS_BASE_URL": f"http://127.0.0.1:{server.server_address[1]}", + "RETROBIOS_CACHE": str(self.root / "cache")} + for name in ("DISPLAY", "WAYLAND_DISPLAY", "SSH_CONNECTION", "SSH_TTY", "NO_COLOR", + install.TUI_PAD_ENV, install.OFFLINE_VARIABLE, *install._STEAM_MARKERS): + self.env.pop(name, None) + + def write_list(self) -> None: + for entry in self.listed["files"]: + payload = entry["dest"].encode().ljust(entry["size"], b".")[:entry["size"]] + entry["sha1"] = hashlib.sha1(payload).hexdigest() + entry["sha256"] = hashlib.sha256(payload).hexdigest() + (self.site / entry["repo_path"]).write_bytes(payload) + for plat in ("retroarch", "batocera"): + (self.site / "install" / f"{plat}.json").write_text( + json.dumps({**self.listed, "platform": plat})) + + def retroarch_here(self) -> Path: + """A RetroArch whose configuration names its system folder.""" + dest = self.root / "retroarch" / "system" + dest.mkdir(parents=True) + config = self.home / ".config" / "retroarch" + config.mkdir(parents=True) + (config / "retroarch.cfg").write_text(f'system_directory = "{dest}"\n') + return dest + + +def written(folder: Path) -> list[str]: + return sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file()) + + +@unittest.skipUnless(pyte and pty, "the journeys need pyte and a pty") +class Journeys(unittest.TestCase): + def start(self, repo: Repository, *args: str, env: dict | None = None) -> Pty: + program = [sys.executable, "-c", + _WITH_CARDS.format(script=str(REPO_ROOT / "install.py"))] + term = Pty(list(args), {**repo.env, **(env or {})}, 80, 24, program=program) + self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9)) + self.keys = 0 + return term + + def press(self, term: Pty, *keys: str) -> None: + """Each key sent, a typed word counted letter by letter.""" + for key in keys: + if len(key) > 1 and not key.startswith(ESC): + for letter in key: + term.send(letter) + self.keys += 1 + else: + term.send(key) + self.keys += 1 + + def counted(self, journey: str, started: int, total: int) -> None: + self.assertEqual((started, total), JOURNEY_KEYS[journey], + f"{journey} takes {(started, total)} keys now: longer is a step " + "back, shorter lowers JOURNEY_KEYS") + + def finish(self, term: Pty) -> int: + term.wait("A/Enter Exit", timeout=60) + self.press(term, "\r") + return term.close() + + def test_first_install_with_retroarch_found(self): + repo = Repository(self) + dest = repo.retroarch_here() + term = self.start(repo) + screen = term.wait("Where do you play?", timeout=40) + self.assertIn("> RetroArch", screen) + self.press(term, "\r") + term.wait("files to download", timeout=40) + self.press(term, "\r") + started = self.keys + self.assertEqual(self.finish(term), 0) + self.counted("first_install", started, self.keys) + self.assertEqual(written(dest), sorted(e["dest"] for e in repo.listed["files"])) + + def test_a_muos_card_holding_a_game(self): + """The screen passes --platform=retroarch to its runs: the card's + games were not read, and the plan offered the whole list.""" + repo = Repository(self) + card = repo.root / "MUOS" + bios = card / "MUOS" / "bios" + bios.mkdir(parents=True) + (card / "ROMS" / "GBA").mkdir(parents=True) + (card / "ROMS" / "GBA" / "unknown.gba").write_bytes(b"not in any catalog") + term = self.start(repo, env={"JOURNEY_CARD": str(card)}) + term.wait("Where do you play?", timeout=40) + self.press(term, "\r") + screen = term.wait("For the games on this card: yes", timeout=60) + self.assertIn("For the 1 game on this card.", screen) + self.press(term, "\r") + started = self.keys + self.assertEqual(self.finish(term), 0) + self.counted("card_with_games", started, self.keys) + self.assertEqual(written(bios), ["gba_bios.bin"]) + + def test_an_update_after_a_release(self): + repo = Repository(self) + dest = repo.retroarch_here() + first = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch", + "--dest", str(dest), "--all-files", "--yes"], + env=repo.env, capture_output=True, text=True, timeout=120, check=False, + stdin=subprocess.DEVNULL) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + # The release changes one file. + changed = next(e for e in repo.listed["files"] if e["dest"] == "md_eu.bin") + changed["size"] += 1 + repo.write_list() + term = self.start(repo) + term.wait("Where do you play?", timeout=40) + self.press(term, "\r") + term.wait("1 file to download", timeout=40) + self.press(term, "\r") + started = self.keys + self.assertEqual(self.finish(term), 0) + self.counted("update", started, self.keys) + self.assertEqual((dest / "md_eu.bin").stat().st_size, changed["size"]) + + def test_one_system_then_the_same_again(self): + repo = Repository(self) + dest = repo.retroarch_here() + term = self.start(repo) + term.wait("Where do you play?", timeout=40) + self.press(term, "\r") + term.wait("files to download", timeout=40) + for _step in range(12): + if "> Choose systems" in term.settle(): + break + self.press(term, DOWN) + self.press(term, "\r") + term.wait("RetroBIOS: Choose systems") + # None, the filter, the system ticked, Esc clearing the filter, + # Esc for Done. + self.press(term, "n", "/", "gba", "\r", "\r", ESC, ESC) + term.wait("for 1 of", timeout=20) + self.press(term, "\r") + term.wait("Counted again for your choices", timeout=40) + self.press(term, "\r") + started = self.keys + self.assertEqual(self.finish(term), 0) + self.counted("one_system", started, self.keys) + self.assertEqual(written(dest), ["gba_bios.bin"]) + # The next release: the earlier choice is not kept, and two keys + # install the whole list. + term = self.start(repo) + term.wait("Where do you play?", timeout=40) + self.press(term, "\r") + term.wait("files to download", timeout=40) + self.press(term, "\r") + started = self.keys + self.assertEqual(self.finish(term), 0) + self.counted("one_system_again", started, self.keys) + self.assertEqual(written(dest), sorted(e["dest"] for e in repo.listed["files"])) + + def test_what_is_missing(self): + repo = Repository(self) + repo.retroarch_here() + term = self.start(repo) + term.wait("Where do you play?", timeout=40) + self.press(term, "\r") + term.wait("files to download", timeout=40) + self.press(term, "o") + term.wait("Each row is an option") + self.press(term, END, UP) + term.wait("> Preview the file list") + self.press(term, "\r") + term.wait("md_eu.bin", timeout=40) + started = self.keys + self.press(term, "q") + self.assertEqual(term.close(), 2) + self.counted("what_is_missing", started, self.keys) + + def test_a_folder_for_another_device(self): + repo = Repository(self) + repo.retroarch_here() + term = self.start(repo) + term.wait("Where do you play?", timeout=40) + for _step in range(12): + if "> Another setup or folder" in term.settle(): + break + self.press(term, DOWN) + self.press(term, "\r") + term.wait("Which setup?") + # The filter's Enter takes the one setup it leaves. + self.press(term, "/", "bato", "\r") + term.wait("Which folder?", timeout=40) + self.press(term, DOWN, "\r") + term.wait("RetroBIOS: Type a path") + self.press(term, "~/bios", "\r") + term.wait("Create the folder?") + self.press(term, "\r") + screen = term.wait("files to download", timeout=40) + self.assertIn("RetroBIOS: Batocera", screen) + self.press(term, "\r") + started = self.keys + self.assertEqual(self.finish(term), 0) + self.counted("another_device", started, self.keys) + self.assertEqual(written(repo.home / "bios"), + sorted(e["dest"] for e in repo.listed["files"])) + + +if __name__ == "__main__": + unittest.main()