mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
1953 lines
87 KiB
Python
1953 lines
87 KiB
Python
"""The installer's full-screen interface.
|
|
|
|
The interface is a model, a pure update on keys and events, and pure views:
|
|
most of it is tested here without a terminal. The end-to-end cases run the
|
|
real install.py --tui on a pseudo-terminal read back with pyte, against a
|
|
loopback copy of the repository, and skip when pyte is not installed (CI
|
|
installs it).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import fcntl
|
|
import functools
|
|
import hashlib
|
|
import http.server
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import select
|
|
import shutil
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import termios
|
|
import threading
|
|
import time
|
|
import unittest
|
|
import warnings
|
|
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_selection import FakeRepository, _QuietHandler # noqa: E402
|
|
|
|
try:
|
|
import pyte
|
|
except ImportError:
|
|
pyte = None
|
|
|
|
try:
|
|
import pty
|
|
except ImportError:
|
|
pty = None
|
|
|
|
try:
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
except ImportError:
|
|
given = None
|
|
|
|
GOLDEN = REPO_ROOT / "tests" / "fixtures" / "tui"
|
|
SIZES = ((40, 12), (40, 15), (80, 24), (120, 40))
|
|
UP, DOWN, RIGHT, LEFT = "\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D"
|
|
Key = install.TuiKey
|
|
|
|
|
|
def keys(*names: str) -> list:
|
|
return [Key("char", name[5:]) if name.startswith("char:") else Key(name) for name in names]
|
|
|
|
|
|
def _args(**given) -> argparse.Namespace:
|
|
"""The namespace main() parses, every option at its default."""
|
|
values = dict(
|
|
platform=None, dest=None, target=None, every_board=False, check=False,
|
|
list_platforms=False, list_targets=False, jobs=8, verbose=False,
|
|
standalone_copies=False, emulator=None, installed_cores=False, save_dir=None,
|
|
cores_dir=None, system=None, core=None, region=None, no_game_data=False,
|
|
list_systems=False, list_cores=False, romm_firmware=None, list_emulators=False,
|
|
no_input=False, dry_run=False, yes=False, quiet=False, events=False,
|
|
restore=None, from_pack=None, manifest=None, tui=False, plain=False,
|
|
)
|
|
values.update(given)
|
|
return argparse.Namespace(**values)
|
|
|
|
|
|
class KeyDecoding(unittest.TestCase):
|
|
def feed(self, *chunks: str, flush: bool = True) -> list:
|
|
decoder = install.KeyDecoder()
|
|
found = []
|
|
for chunk in chunks:
|
|
found += decoder.feed(chunk)
|
|
if flush:
|
|
found += decoder.flush()
|
|
return found
|
|
|
|
def test_every_key_of_the_table(self):
|
|
table = {
|
|
"\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left",
|
|
"\x1bOA": "up", "\x1bOB": "down", "\x1bOC": "right", "\x1bOD": "left",
|
|
"\x1b[5~": "pgup", "\x1b[6~": "pgdn", "\x1b[H": "home", "\x1b[F": "end",
|
|
"\x1b[1~": "home", "\x1b[4~": "end", "\x1b[7~": "home", "\x1b[8~": "end",
|
|
"\x1bOH": "home", "\x1bOF": "end", "\x1bOP": "f1", "\x1b[11~": "f1",
|
|
"\x1b[1;2A": "shift-up", "\x1b[1;2B": "shift-down", "\x1b[3~": "delete",
|
|
"\r": "enter", "\n": "enter", "\x1bOM": "enter", "\x7f": "backspace",
|
|
"\x08": "backspace", "\t": "tab", "\x03": "ctrl-c", "\x04": "ctrl-d",
|
|
"\x1b": "esc",
|
|
}
|
|
for sequence, name in table.items():
|
|
with self.subTest(sequence=sequence):
|
|
self.assertEqual(self.feed(sequence), [Key(name)])
|
|
|
|
def test_text_keys(self):
|
|
self.assertEqual(self.feed("q /?jké"), keys("char:q", "char: ", "char:/", "char:?",
|
|
"char:j", "char:k", "char:é"))
|
|
|
|
def test_two_arrows_in_one_read_are_two_moves(self):
|
|
self.assertEqual(self.feed("\x1b[B\x1b[B"), keys("down", "down"))
|
|
|
|
def test_a_sequence_split_across_reads_is_one_key(self):
|
|
for cut in range(1, 4):
|
|
with self.subTest(cut=cut):
|
|
sequence = "\x1b[5~"
|
|
self.assertEqual(self.feed(sequence[:cut], sequence[cut:]), keys("pgup"))
|
|
|
|
def test_esc_waits_for_the_rest_then_stands_alone(self):
|
|
decoder = install.KeyDecoder()
|
|
self.assertEqual(decoder.feed("\x1b"), [])
|
|
self.assertTrue(decoder.waiting)
|
|
self.assertEqual(decoder.flush(), keys("esc"))
|
|
self.assertFalse(decoder.waiting)
|
|
|
|
def test_esc_then_a_letter(self):
|
|
self.assertEqual(self.feed("\x1bq"), keys("esc", "char:q"))
|
|
self.assertEqual(self.feed("\x1b\x1b[A"), keys("esc", "up"))
|
|
|
|
def test_unknown_sequences_are_dropped_not_typed(self):
|
|
for sequence in ("\x1b[25~", "\x1b[1;5A", "\x1b[M !!", "\x1b[<0;10;5M", "\x1b[?1;2c",
|
|
"\x1bOZ"):
|
|
with self.subTest(sequence=sequence):
|
|
self.assertEqual(self.feed(sequence + "x"), keys("char:x"))
|
|
|
|
def test_an_incomplete_sequence_is_dropped_at_the_timeout(self):
|
|
self.assertEqual(self.feed("\x1b[1"), [])
|
|
|
|
def test_a_paste_is_one_piece_without_its_newlines(self):
|
|
self.assertEqual(self.feed("\x1b[200~/media/sd\ncard\r\n\x1b[201~"),
|
|
[Key("paste", "/media/sdcard")])
|
|
self.assertEqual(self.feed("\x1b[200~/a/b\x1b[2", "01~\r"),
|
|
[Key("paste", "/a/b"), Key("enter")])
|
|
|
|
def test_controls_nobody_bound_are_ignored(self):
|
|
self.assertEqual(self.feed("\x00\x1a\x1cx"), keys("char:x"))
|
|
|
|
def test_windows_scan_codes(self):
|
|
for prefix in ("\x00", "\xe0"):
|
|
for code, name in (("H", "up"), ("P", "down"), ("K", "left"), ("M", "right"),
|
|
("I", "pgup"), ("Q", "pgdn"), ("G", "home"), ("O", "end"),
|
|
(";", "f1")):
|
|
with self.subTest(prefix=prefix, code=code):
|
|
pending = iter([code])
|
|
self.assertEqual(install.decode_windows_key(prefix, pending.__next__),
|
|
Key(name))
|
|
unread = iter(())
|
|
for char, name in (("\r", "enter"), ("\x1b", "esc"), ("\x08", "backspace"),
|
|
("\x03", "ctrl-c"), ("\t", "tab")):
|
|
self.assertEqual(install.decode_windows_key(char, unread.__next__), Key(name))
|
|
self.assertEqual(install.decode_windows_key("a", unread.__next__), Key("char", "a"))
|
|
self.assertIsNone(install.decode_windows_key("\xe0", iter(["\x86"]).__next__))
|
|
|
|
@unittest.skipUnless(given, "the decoder fuzz needs hypothesis")
|
|
def test_any_input_decodes_the_same_however_it_is_split(self):
|
|
alphabet = st.sampled_from(["\x1b", "[", "O", "A", "B", "5", "~", "1", ";", "2",
|
|
"200~", "201~", "\r", "x", "M", "\x03", "<", " "])
|
|
|
|
@settings(max_examples=400, deadline=None)
|
|
@given(st.lists(alphabet, max_size=30), st.data())
|
|
def check(parts, data):
|
|
text = "".join(parts)
|
|
whole = self.feed(text)
|
|
cuts = sorted(data.draw(st.lists(st.integers(0, len(text)), max_size=5)))
|
|
chunks = [text[a:b] for a, b in zip([0, *cuts], [*cuts, len(text)])]
|
|
self.assertEqual(self.feed(*chunks), whole)
|
|
for key in whole:
|
|
if key.name == "char":
|
|
self.assertNotEqual(key.text, "\x1b")
|
|
|
|
check()
|
|
|
|
|
|
class TextWidth(unittest.TestCase):
|
|
def test_wide_and_combining_characters(self):
|
|
self.assertEqual(install.cell_width("PlayStation"), 11)
|
|
self.assertEqual(install.cell_width("ファミコン"), 10)
|
|
self.assertEqual(install.cell_width("e\u0301"), 1)
|
|
|
|
def test_fit_never_cuts_a_character(self):
|
|
self.assertEqual(install.fit("ファミコン", 5), "ファ")
|
|
self.assertEqual(install.cell_width(install.fit("aファ", 2)), 1)
|
|
|
|
def test_middle_keeps_both_ends(self):
|
|
path = "/run/media/user/SDCARD/Bios/PS"
|
|
cut = install.middle(path, 20)
|
|
self.assertEqual(install.cell_width(cut), 20)
|
|
self.assertTrue(cut.startswith("/run/med"))
|
|
self.assertTrue(cut.endswith("Bios/PS"))
|
|
self.assertIn("...", cut)
|
|
self.assertEqual(install.middle("short", 20), "short")
|
|
|
|
|
|
class StartConditions(unittest.TestCase):
|
|
class _Tty(io.StringIO):
|
|
def __init__(self, fd: int) -> None:
|
|
super().__init__()
|
|
self.fd = fd
|
|
|
|
def isatty(self) -> bool:
|
|
return True
|
|
|
|
def fileno(self) -> int:
|
|
return self.fd
|
|
|
|
def setUp(self):
|
|
self.master, self.slave = os.openpty()
|
|
self.addCleanup(os.close, self.master)
|
|
self.addCleanup(os.close, self.slave)
|
|
self.size(80, 24)
|
|
self.tty = self._Tty(self.slave)
|
|
|
|
def size(self, cols: int, rows: int) -> None:
|
|
fcntl.ioctl(self.slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
|
|
def blocker(self, env=None, **given) -> str:
|
|
return install.tui_blocker(_args(**given), self.tty, self.tty,
|
|
{"TERM": "xterm"} if env is None else env)
|
|
|
|
def test_an_install_at_a_terminal_opens_it(self):
|
|
self.assertEqual(self.blocker(), "")
|
|
self.assertEqual(self.blocker(platform="retroarch", system=["x"], region=["us"]), "")
|
|
|
|
def test_flags_that_keep_the_line_questions(self):
|
|
for flag in ("check", "dry_run", "list_platforms", "list_targets", "list_systems",
|
|
"list_cores", "list_emulators", "events", "quiet", "no_input", "yes"):
|
|
with self.subTest(flag=flag):
|
|
self.assertIn("runs without questions", self.blocker(**{flag: True}))
|
|
self.assertIn("--restore", self.blocker(restore=Path("x")))
|
|
|
|
def test_dumb_or_missing_term(self):
|
|
self.assertIn("TERM", self.blocker(env={"TERM": "dumb"}))
|
|
self.assertIn("TERM", self.blocker(env={}))
|
|
|
|
def test_not_a_terminal(self):
|
|
args = _args()
|
|
self.assertIn("input", install.tui_blocker(args, io.StringIO(), self.tty,
|
|
{"TERM": "xterm"}))
|
|
self.assertIn("output", install.tui_blocker(args, self.tty, io.StringIO(),
|
|
{"TERM": "xterm"}))
|
|
|
|
def test_too_small_and_unsized(self):
|
|
self.size(39, 30)
|
|
self.assertIn("39x30", self.blocker())
|
|
# script(1) fed from a pipe never sizes its terminal.
|
|
self.size(0, 0)
|
|
self.assertIn("0x0", self.blocker())
|
|
|
|
def test_a_windows_console_without_escape_sequences(self):
|
|
# Legacy conhost, PowerShell ISE: no virtual terminal, the questions run.
|
|
with mock.patch.dict(sys.modules, {"msvcrt": mock.Mock()}), \
|
|
mock.patch.object(install.os, "name", "nt"), \
|
|
mock.patch.object(install, "_enable_ansi", return_value=False):
|
|
self.assertIn("escape sequences", self.blocker(env={}))
|
|
with mock.patch.dict(sys.modules, {"msvcrt": mock.Mock()}), \
|
|
mock.patch.object(install.os, "name", "nt"), \
|
|
mock.patch.object(install, "_enable_ansi", return_value=True):
|
|
self.assertEqual(self.blocker(env={}), "")
|
|
|
|
def test_plain_mode(self):
|
|
self.assertTrue(install.plain_requested(_args(plain=True), {}))
|
|
self.assertTrue(install.plain_requested(_args(), {"RETROBIOS_PLAIN": "1"}))
|
|
self.assertTrue(install.plain_requested(_args(), {"ACCESSIBLE": "1"}))
|
|
self.assertFalse(install.plain_requested(_args(), {"ACCESSIBLE": "0",
|
|
"RETROBIOS_PLAIN": ""}))
|
|
|
|
def test_tui_refused_with_a_reason(self):
|
|
for extra, message in ((["--check"], "--check runs without questions"),
|
|
(["--plain"], "two different interfaces"),
|
|
([], "not a terminal")):
|
|
with self.subTest(extra=extra):
|
|
proc = install_run("--tui", *extra)
|
|
self.assertEqual(proc.returncode, 2)
|
|
self.assertIn(message, proc.stderr)
|
|
self.assertNotIn("\x1b", proc.stdout + proc.stderr)
|
|
|
|
|
|
def install_run(*options: str, env: dict | None = None):
|
|
import subprocess
|
|
|
|
return subprocess.run(
|
|
[sys.executable, str(REPO_ROOT / "install.py"), *options], capture_output=True,
|
|
text=True, timeout=60, check=False, stdin=subprocess.DEVNULL,
|
|
env={**os.environ, **(env or {})},
|
|
)
|
|
|
|
|
|
# A repository-like state, as the events of a check fill it.
|
|
MANIFEST = {
|
|
"manifest_version": 2, "platform": "retroarch", "categorized": True,
|
|
"system_cores": {"sony-playstation": ["pcsx_rearmed"]},
|
|
"files": [
|
|
{"dest": "scph5500.bin", "size": 524288, "systems": ["sony-playstation"],
|
|
"regions": ["japan"], "cores": None},
|
|
{"dest": "scph5501.bin", "size": 524288, "systems": ["sony-playstation"],
|
|
"regions": ["north-america"], "cores": None},
|
|
{"dest": "gba_bios.bin", "size": 16384, "systems": ["nintendo-gba"], "cores": None},
|
|
{"dest": "pcsx/extra.bin", "size": 4, "systems": ["sony-playstation"],
|
|
"cores": ["pcsx_rearmed"]},
|
|
{"dest": "fc/disksys.rom", "size": 8192, "systems": ["nintendo-famicom-disk"],
|
|
"cores": ["mesen"]},
|
|
],
|
|
}
|
|
SYSTEMS = [
|
|
{"event": "system", "platform": "retroarch", "system": "nintendo-famicom-disk",
|
|
"name": "ファミコン", "files": 1, "present": 1, "missing": 0, "wrong_hash": 0,
|
|
"bytes_needed": 0},
|
|
{"event": "system", "platform": "retroarch", "system": "nintendo-gba",
|
|
"name": "gba", "files": 1, "present": 0, "missing": 1, "wrong_hash": 0,
|
|
"bytes_needed": 16384},
|
|
{"event": "system", "platform": "retroarch", "system": "sony-playstation",
|
|
"name": "sony-playstation", "files": 3, "present": 0, "missing": 2, "wrong_hash": 1,
|
|
"bytes_needed": 1048580},
|
|
]
|
|
CHECK = {"event": "check", "platform": "retroarch", "name": "RetroArch",
|
|
"bios_dir": "/home/user/.config/retroarch/system", "files": 5, "present": 1,
|
|
"missing": 3, "wrong_hash": 1, "bytes_needed": 1064964, "data_missing": 0,
|
|
"not_installable": 2}
|
|
FILES = [
|
|
{"event": "file", "platform": "retroarch", "dest": "scph5500.bin",
|
|
"systems": ["sony-playstation"], "size": 524288, "state": "missing"},
|
|
{"event": "file", "platform": "retroarch", "dest": "scph5501.bin",
|
|
"systems": ["sony-playstation"], "size": 524288, "state": "wrong_hash"},
|
|
{"event": "file", "platform": "retroarch", "dest": "pcsx/extra.bin",
|
|
"systems": ["sony-playstation"], "size": 4, "state": "missing"},
|
|
{"event": "file", "platform": "retroarch", "dest": "gba_bios.bin",
|
|
"systems": ["nintendo-gba"], "size": 16384, "state": "missing"},
|
|
]
|
|
HOME = Path("/home/user")
|
|
SYSTEM_DIR = HOME / ".config" / "retroarch" / "system"
|
|
|
|
|
|
def fresh_state() -> install.TuiState:
|
|
state = install.TuiState(os_type="linux", home=HOME)
|
|
state.setups = [("retroarch", SYSTEM_DIR, "RetroArch"),
|
|
("batocera", Path("/userdata/bios"), "KNULLI (Batocera files)")]
|
|
return state
|
|
|
|
|
|
def checked_state(facts: dict | None = None) -> install.TuiState:
|
|
"""The plan screen of a RetroArch folder with files missing."""
|
|
state = fresh_state()
|
|
state.stack = ["setup"]
|
|
with mock.patch.object(install.Path, "exists", return_value=True):
|
|
effects = install.tui_update(state, Key("enter"))
|
|
assert effects == [("fetch", "retroarch")], effects
|
|
effects = install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
|
|
assert effects == [("run", "check")], effects
|
|
for event in [*FILES, *SYSTEMS, CHECK]:
|
|
install.tui_receive(state, ("event", state.run_id, event))
|
|
with mock.patch.object(install, "_tui_folder_facts", return_value=facts or {
|
|
"free": 50 * 1024 ** 3, "writable": True, "cores": ["pcsx_rearmed"]}):
|
|
install.tui_receive(state, ("ended", state.run_id, 1, []))
|
|
return state
|
|
|
|
|
|
def press(state: install.TuiState, *pressed) -> list:
|
|
effects = []
|
|
for key in pressed:
|
|
effects += install.tui_update(state, Key("char", key) if isinstance(key, str)
|
|
and len(key) == 1 else Key(key))
|
|
return effects
|
|
|
|
|
|
class Flow(unittest.TestCase):
|
|
"""update() on keys and events: what each choice starts and passes on."""
|
|
|
|
def test_three_presses_install_everything(self):
|
|
state = checked_state()
|
|
self.assertEqual(state.screen, "plan")
|
|
self.assertEqual(press(state, "enter"), [("run", "install")])
|
|
self.assertEqual(state.screen, "installing")
|
|
argv = install.tui_argv(state, "install", "py", "install.py")
|
|
self.assertEqual(argv, ["py", "install.py", "--platform=retroarch",
|
|
f"--dest={SYSTEM_DIR}", "--events", "--yes"])
|
|
for event in ({"event": "download", "platform": "retroarch", "files": 4,
|
|
"bytes": 1064964},
|
|
{"event": "downloaded", "dest": "gba_bios.bin", "ok": True, "reason": "",
|
|
"size": 16384, "files_done": 1, "files_total": 4,
|
|
"bytes_done": 16384, "bytes_total": 1064964},
|
|
{"event": "summary", "mode": "install", "installed": 4, "up_to_date": 1,
|
|
"pending": 0, "failed": [], "skipped": [], "left_out": [],
|
|
"kept": [], "interrupted": False}):
|
|
install.tui_receive(state, ("event", state.run_id, event))
|
|
install.tui_receive(state, ("ended", state.run_id, 0, []))
|
|
self.assertEqual(state.screen, "result")
|
|
press(state, "enter")
|
|
self.assertEqual(state.exit_code, 0)
|
|
|
|
def test_the_check_runs_with_the_command_line_choices(self):
|
|
state = fresh_state()
|
|
state.passthrough = [("--target", "switch")]
|
|
state.cli_systems = ["psx"]
|
|
state.regions = ["north-america"]
|
|
state.platform, state.dest = "retroarch", SYSTEM_DIR
|
|
state.manifest, state.manifest_for = MANIFEST, "retroarch"
|
|
self.assertEqual(install._start_check(state), [("run", "check")])
|
|
self.assertEqual(install.tui_argv(state, "check", "py", "i.py")[4:], [
|
|
"--target=switch", "--system=psx", "--region=north-america",
|
|
"--check", "--events"])
|
|
|
|
def test_one_system_goes_through_the_dry_run(self):
|
|
state = checked_state()
|
|
press(state, "down", "enter")
|
|
self.assertEqual(state.screen, "systems")
|
|
self.assertEqual(state.ticked, {"nintendo-gba", "sony-playstation"})
|
|
self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system")
|
|
press(state, "n")
|
|
self.assertEqual(state.ticked, set())
|
|
self.assertEqual(press(state, "enter"), [])
|
|
self.assertIn("No system is ticked", state.status)
|
|
focus = next(i for i, row in enumerate(install.tui_rows(state))
|
|
if row.value == "nintendo-gba")
|
|
state.focus["systems"] = focus
|
|
press(state, "right")
|
|
self.assertEqual(press(state, "enter"), [("run", "confirm")])
|
|
self.assertEqual(state.screen, "confirm")
|
|
self.assertIn("--system=nintendo-gba", install.tui_argv(state, "confirm", "py", "i.py"))
|
|
self.assertIn("--dry-run", install.tui_argv(state, "confirm", "py", "i.py"))
|
|
dry = {**CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0,
|
|
"bytes_needed": 16384}
|
|
install.tui_receive(state, ("event", state.run_id, dry))
|
|
install.tui_receive(state, ("ended", state.run_id, 0, []))
|
|
self.assertEqual(state.screen, "confirm")
|
|
self.assertEqual(press(state, "enter"), [("run", "install")])
|
|
argv = install.tui_argv(state, "install", "py", "i.py")
|
|
self.assertEqual(argv[4:], ["--system=nintendo-gba", "--events", "--yes"])
|
|
|
|
def test_a_choice_that_changes_nothing_skips_the_confirmation(self):
|
|
state = checked_state()
|
|
state.jobs = 4
|
|
self.assertEqual(press(state, "enter"), [("run", "confirm")])
|
|
install.tui_receive(state, ("event", state.run_id, dict(CHECK)))
|
|
effects = install.tui_receive(state, ("ended", state.run_id, 0, []))
|
|
self.assertEqual(effects, [("run", "install")])
|
|
self.assertEqual(state.screen, "installing")
|
|
|
|
def test_options_set_the_flags_they_name(self):
|
|
state = checked_state()
|
|
press(state, "o")
|
|
self.assertEqual(state.screen, "options")
|
|
rows = install.tui_rows(state)
|
|
self.assertEqual([row.right for row in rows], [
|
|
"--region", "--no-game-data", "--installed-cores", "--core", "--jobs", "--dest",
|
|
"--dry-run", ""])
|
|
press(state, "enter")
|
|
self.assertEqual(state.screen, "regions")
|
|
# Ticked in the order of preference, then moved up.
|
|
regions = [row.value for row in install.tui_rows(state)]
|
|
self.assertEqual(regions, ["japan", "north-america"])
|
|
press(state, "enter", "down", "enter")
|
|
self.assertEqual(state.regions, ["japan", "north-america"])
|
|
press(state, "left")
|
|
self.assertEqual(state.regions, ["north-america", "japan"])
|
|
self.assertEqual(state.focus["regions"], 0)
|
|
press(state, "shift-down")
|
|
self.assertEqual(state.regions, ["japan", "north-america"])
|
|
press(state, "esc", "down", "enter", "down", "right", "down", "down", "right", "right")
|
|
self.assertTrue(state.no_game_data)
|
|
self.assertTrue(state.installed_only)
|
|
self.assertEqual(state.jobs, 10)
|
|
argv = install.tui_argv(state, "install", "py", "i.py")
|
|
self.assertEqual(argv[4:], ["--region=japan,north-america", "--no-game-data",
|
|
"--installed-cores", "--jobs=10", "--events", "--yes"])
|
|
self.assertIn("--region japan,north-america --no-game-data", install.tui_command(state))
|
|
|
|
def test_cores_checklist(self):
|
|
state = checked_state()
|
|
press(state, "o")
|
|
state.focus["options"] = 3
|
|
press(state, "enter")
|
|
self.assertEqual(state.screen, "cores")
|
|
self.assertEqual([row.value for row in install.tui_rows(state)][1:],
|
|
["mesen", "pcsx_rearmed"])
|
|
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, [])
|
|
press(state, "/", "p", "c")
|
|
self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["pcsx_rearmed"])
|
|
press(state, "enter", "right", "enter")
|
|
self.assertEqual(state.screen, "options")
|
|
self.assertEqual(install.tui_flags(state), [("--core", "pcsx_rearmed")])
|
|
|
|
def test_preview_is_a_dry_run(self):
|
|
state = checked_state()
|
|
press(state, "o")
|
|
state.focus["options"] = 6
|
|
self.assertEqual(press(state, "enter"), [("run", "preview")])
|
|
self.assertEqual(install.tui_argv(state, "preview", "py", "i.py")[-2:],
|
|
["--dry-run", "--events"])
|
|
for event in [*FILES, CHECK]:
|
|
install.tui_receive(state, ("event", state.run_id, event))
|
|
install.tui_receive(state, ("ended", state.run_id, 0, []))
|
|
text = "\n".join(install.tui_view(state, 80, 24))
|
|
self.assertIn("4 files to download, 1.0 MB. Nothing is written.", text)
|
|
self.assertIn("wrong copy", text)
|
|
press(state, "esc")
|
|
self.assertEqual(state.screen, "options")
|
|
|
|
def test_back_and_quit(self):
|
|
state = fresh_state()
|
|
press(state, "esc")
|
|
self.assertEqual(state.screen, "quit")
|
|
press(state, "esc")
|
|
self.assertEqual(state.screen, "setup")
|
|
press(state, "esc", "enter")
|
|
self.assertEqual(state.exit_code, 130)
|
|
state = checked_state()
|
|
self.assertEqual(press(state, "q"), [])
|
|
self.assertEqual(state.exit_code, 130)
|
|
|
|
def test_ctrl_c_stops_an_install_once_then_kills(self):
|
|
state = checked_state()
|
|
press(state, "enter")
|
|
self.assertEqual(press(state, "ctrl-c"), [("stop",)])
|
|
self.assertTrue(state.stopping)
|
|
self.assertEqual(press(state, "ctrl-c"), [("kill",)])
|
|
summary = {"event": "summary", "mode": "install", "installed": 1, "up_to_date": 1,
|
|
"pending": 3, "failed": [], "skipped": [], "left_out": [], "kept": [],
|
|
"interrupted": True}
|
|
install.tui_receive(state, ("event", state.run_id,
|
|
{"event": "download", "platform": "retroarch",
|
|
"files": 4, "bytes": 1064964}))
|
|
install.tui_receive(state, ("event", state.run_id, summary))
|
|
install.tui_receive(state, ("ended", state.run_id, 130, []))
|
|
title, lines = install.tui_outcome(state)
|
|
self.assertEqual(title, "Stopped")
|
|
self.assertEqual(lines[0], "Stopped: 1 of 4 installed, run again to finish.")
|
|
press(state, "enter")
|
|
self.assertEqual(state.exit_code, 130)
|
|
|
|
def test_back_from_the_check_stops_it(self):
|
|
state = fresh_state()
|
|
with mock.patch.object(install.Path, "exists", return_value=True):
|
|
press(state, "enter")
|
|
install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
|
|
self.assertTrue(state.running)
|
|
self.assertEqual(press(state, "esc"), [("kill",)])
|
|
self.assertEqual(state.screen, "setup")
|
|
install.tui_receive(state, ("ended", state.run_id - 1, 1, []))
|
|
self.assertEqual(state.screen, "setup")
|
|
|
|
def test_ctrl_c_elsewhere_ends_at_once(self):
|
|
state = fresh_state()
|
|
self.assertEqual(press(state, "ctrl-c"), [])
|
|
self.assertEqual(state.exit_code, 130)
|
|
state = checked_state()
|
|
state.running = True
|
|
self.assertEqual(press(state, "ctrl-d"), [("kill",)])
|
|
|
|
def test_the_folder_path(self):
|
|
state = fresh_state()
|
|
press(state, "down", "down", "enter")
|
|
self.assertEqual(state.screen, "platforms")
|
|
self.assertEqual(install.tui_rows(state)[state.focus["platforms"]].value, "retroarch")
|
|
press(state, "/", "b", "a", "t", "o")
|
|
self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["batocera"])
|
|
press(state, "enter", "enter")
|
|
self.assertEqual(state.screen, "folder")
|
|
self.assertEqual(state.platform, "batocera")
|
|
state.focus["folder"] = 2
|
|
press(state, "enter")
|
|
self.assertEqual(state.screen, "path")
|
|
state.path_text = ""
|
|
press(state, "paste")
|
|
install.tui_update(state, Key("paste", '"~/my bios"'))
|
|
with mock.patch.object(install.Path, "exists", return_value=False), \
|
|
mock.patch.object(install, "_nearest_folder", return_value=HOME):
|
|
press(state, "enter")
|
|
self.assertEqual(state.screen, "create")
|
|
self.assertEqual(state.dest, Path(os.path.expanduser("~/my bios")))
|
|
press(state, "down", "enter")
|
|
self.assertEqual(state.screen, "folder")
|
|
|
|
def test_typed_paths(self):
|
|
with mock.patch.dict(os.environ, {"CARD": "/media/sd"}):
|
|
self.assertEqual(install.typed_path(' "$CARD/Bios" '), Path("/media/sd/Bios"))
|
|
self.assertEqual(install.typed_path("'C:\\RetroBat\\bios'"), Path("C:\\RetroBat\\bios"))
|
|
self.assertIsNone(install.typed_path('""'))
|
|
|
|
def test_the_browser_lists_folders_and_goes_up(self):
|
|
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
|
self.addCleanup(shutil.rmtree, root, True)
|
|
for name in ("system", ".config", "Bios"):
|
|
(root / name).mkdir()
|
|
(root / "file.bin").write_bytes(b"")
|
|
state = fresh_state()
|
|
state.platform = "retroarch"
|
|
state.stack = ["folder"]
|
|
state.folder_default = root / "system"
|
|
with mock.patch.object(install, "_mounted_cards", return_value=[]):
|
|
press(state, "down", "enter")
|
|
self.assertEqual(state.screen, "browse")
|
|
self.assertEqual(state.browse_dir, root / "system")
|
|
self.assertEqual([row.label for row in install.tui_rows(state)],
|
|
["Use this folder", ".."])
|
|
press(state, "down", "enter")
|
|
self.assertEqual(state.browse_dir, root)
|
|
labels = [row.label for row in install.tui_rows(state)]
|
|
self.assertEqual(labels, ["Use this folder", "..", "Bios/", "system/", ".config/"])
|
|
self.assertEqual(install.tui_rows(state)[state.focus["browse"]].label, "system/")
|
|
effects = press(state, "home", "enter")
|
|
self.assertEqual(effects, [("fetch", "retroarch")])
|
|
self.assertEqual(state.dest, root)
|
|
|
|
def test_cards_are_listed_first(self):
|
|
card = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
|
self.addCleanup(shutil.rmtree, card, True)
|
|
(card / ".system").mkdir()
|
|
(card / ".system" / "version.txt").write_text("NextUI-20250101\n")
|
|
with mock.patch.object(install, "_mounted_cards", return_value=[card]):
|
|
rows, error = install._browse_rows(REPO_ROOT / "tests")
|
|
self.assertEqual(error, "")
|
|
self.assertEqual(rows[2].label, f"{card} (NextUI card)")
|
|
|
|
def test_a_mounted_card_is_a_setup_of_its_own(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")
|
|
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=[]):
|
|
state, effects = install.tui_start(_args(), "linux")
|
|
self.assertEqual((state.screen, effects), ("setup", []))
|
|
self.assertEqual(state.setups, [("minui", card, "MinUI SD card")])
|
|
self.assertEqual([row.label for row in install.tui_rows(state)],
|
|
["MinUI SD card", "Another setup or folder"])
|
|
|
|
def test_an_error_offers_another_try(self):
|
|
state = fresh_state()
|
|
with mock.patch.object(install.Path, "exists", return_value=True):
|
|
press(state, "enter")
|
|
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 connection", ["no"], 1))
|
|
press(state, "down", "down", "enter")
|
|
self.assertEqual(state.exit_code, 1)
|
|
|
|
def test_a_check_that_writes_no_check_event_is_an_error(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,
|
|
["Error: /x is inside a NextUI card."]))
|
|
self.assertEqual(state.screen, "error")
|
|
self.assertEqual(state.error, ["Error: /x is inside a NextUI card."])
|
|
|
|
def test_events_of_an_abandoned_run_are_ignored(self):
|
|
state = checked_state()
|
|
press(state, "o")
|
|
state.focus["options"] = 6
|
|
press(state, "enter")
|
|
old = state.run_id
|
|
self.assertEqual(press(state, "esc"), [("kill",)])
|
|
install.tui_receive(state, ("event", old, dict(CHECK)))
|
|
install.tui_receive(state, ("ended", old, 0, []))
|
|
self.assertIsNone(state.dry)
|
|
self.assertEqual(state.screen, "options")
|
|
|
|
def test_help_and_a_small_window(self):
|
|
state = fresh_state()
|
|
press(state, "?")
|
|
self.assertTrue(state.help)
|
|
self.assertIn("Any key closes", install.tui_view(state, 80, 24)[-1])
|
|
press(state, "down")
|
|
self.assertFalse(state.help)
|
|
self.assertEqual(state.focus.get("setup", 0), 0)
|
|
state.cols, state.rows = 30, 10
|
|
press(state, "enter")
|
|
self.assertEqual(state.screen, "setup")
|
|
lines = install.tui_view(state, 30, 10)
|
|
self.assertEqual([line.strip() for line in lines if line.strip()],
|
|
["Make the window larger"])
|
|
|
|
|
|
class PlanWarnings(unittest.TestCase):
|
|
def test_a_folder_that_cannot_take_the_files_says_so_first(self):
|
|
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("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)])
|
|
|
|
|
|
class Widgets(unittest.TestCase):
|
|
def systems_state(self, count: int) -> install.TuiState:
|
|
state = checked_state()
|
|
state.systems = [{"event": "system", "system": f"system-{n:03d}", "name": f"s{n}",
|
|
"files": 1, "present": 0, "missing": 1, "wrong_hash": 0,
|
|
"bytes_needed": 10} for n in range(count)]
|
|
state.ticked = {row["system"] for row in state.systems}
|
|
state.stack.append("systems")
|
|
return state
|
|
|
|
def test_a_long_list_scrolls_and_says_where(self):
|
|
state = self.systems_state(112)
|
|
press(state, "end")
|
|
lines = install.tui_view(state, 40, 15)
|
|
self.assertIn("> [x] system-111", "\n".join(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")
|
|
self.assertEqual(state.focus["systems"], 0)
|
|
|
|
def test_up_wraps_only_in_short_menus(self):
|
|
state = fresh_state()
|
|
press(state, "up")
|
|
self.assertEqual(state.focus["setup"], 2)
|
|
state = self.systems_state(20)
|
|
press(state, "up")
|
|
self.assertEqual(state.focus["systems"], 0)
|
|
|
|
def test_filter_ignores_case_and_accents(self):
|
|
state = self.systems_state(3)
|
|
state.systems.append({"system": "sega-genesis", "name": "Mégadrive", "files": 1,
|
|
"present": 0, "missing": 1, "wrong_hash": 0,
|
|
"bytes_needed": 3})
|
|
press(state, "/", "M", "E", "G")
|
|
self.assertEqual([row.value for row in install.tui_rows(state)][3:], ["sega-genesis"])
|
|
press(state, "esc")
|
|
self.assertEqual(state.filters["systems"], "")
|
|
self.assertEqual(state.screen, "systems")
|
|
press(state, "/", "z", "z", "enter")
|
|
self.assertIn("No system matches 'zz'.", "\n".join(install.tui_view(state, 80, 24)))
|
|
# Esc clears a kept filter first, and goes back only then.
|
|
press(state, "esc")
|
|
self.assertEqual((state.screen, state.filters["systems"]), ("systems", ""))
|
|
press(state, "esc")
|
|
self.assertEqual(state.screen, "plan")
|
|
|
|
def test_all_and_none(self):
|
|
state = self.systems_state(5)
|
|
press(state, "n")
|
|
self.assertEqual(state.ticked, set())
|
|
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):
|
|
state = checked_state()
|
|
state.stack.append("systems")
|
|
state.files.append({"event": "file", "dest": "shared.bin", "size": 1024 * 1024,
|
|
"systems": ["nintendo-gba", "sony-playstation"],
|
|
"state": "missing"})
|
|
footer = install.tui_view(state, 80, 24)[-2]
|
|
self.assertEqual(footer.strip(), "2 of 3 systems: 5 files, 2.0 MB")
|
|
state.ticked = {"nintendo-gba"}
|
|
footer = install.tui_view(state, 80, 24)[-2]
|
|
self.assertEqual(footer.strip(), "1 of 3 systems: 2 files, 1.0 MB")
|
|
|
|
|
|
def golden_states() -> dict:
|
|
"""One state per screen, filled as a run fills it."""
|
|
states = {}
|
|
states["setup"] = fresh_state()
|
|
states["setup"].notes = ["ES-DE found: its BIOS files go to each emulator, not to "
|
|
"ES-DE itself."]
|
|
platforms = fresh_state()
|
|
platforms.stack = ["setup", "platforms"]
|
|
platforms.focus["platforms"] = 1
|
|
states["platforms"] = platforms
|
|
folder = fresh_state()
|
|
folder.platform, folder.stack = "retroarch", ["platforms", "folder"]
|
|
folder.folder_default = SYSTEM_DIR
|
|
states["folder"] = folder
|
|
card = fresh_state()
|
|
card.platform, card.stack = "nextui", ["platforms", "folder"]
|
|
states["folder-card"] = card
|
|
browse = fresh_state()
|
|
browse.platform, browse.stack = "retroarch", ["folder", "browse"]
|
|
browse.browse_dir = HOME / ".config" / "retroarch"
|
|
browse.browse_rows = [
|
|
install.TuiRow("use", "Use this folder"), install.TuiRow("up", "..", value=HOME),
|
|
install.TuiRow("dir", "/media/user/SDCARD (NextUI card)",
|
|
value=Path("/media/user/SDCARD")),
|
|
install.TuiRow("dir", "cores/", value=HOME / "cores"),
|
|
install.TuiRow("dir", "system/", value=SYSTEM_DIR)]
|
|
states["browse"] = browse
|
|
path = fresh_state()
|
|
path.platform, path.stack, path.path_text = "retroarch", ["folder", "path"], "~/bios"
|
|
states["path"] = path
|
|
create = fresh_state()
|
|
create.platform, create.dest, create.stack = "retroarch", HOME / "bios" / "new", ["create"]
|
|
create.facts = {"nearest": HOME}
|
|
states["create"] = create
|
|
checking = fresh_state()
|
|
checking.platform, checking.dest, checking.stack = "retroarch", SYSTEM_DIR, ["checking"]
|
|
checking.manifest, checking.started, checking.now = MANIFEST, 10.0, 14.2
|
|
states["checking"] = checking
|
|
states["plan"] = checked_state()
|
|
done = checked_state()
|
|
done.check = {**CHECK, "present": 5, "missing": 0, "wrong_hash": 0, "bytes_needed": 0,
|
|
"not_installable": 0}
|
|
states["plan-in-place"] = done
|
|
systems = checked_state()
|
|
systems.stack.append("systems")
|
|
systems.focus["systems"] = 3
|
|
states["systems"] = systems
|
|
options = checked_state()
|
|
options.stack.append("options")
|
|
options.regions = ["north-america", "japan"]
|
|
states["options"] = options
|
|
regions = checked_state()
|
|
regions.stack += ["options", "regions"]
|
|
regions.regions = ["north-america"]
|
|
states["regions"] = regions
|
|
cores = checked_state()
|
|
cores.stack += ["options", "cores"]
|
|
cores.cores = ["pcsx_rearmed"]
|
|
states["cores"] = cores
|
|
preview = checked_state()
|
|
preview.stack += ["options", "preview"]
|
|
preview.dry, preview.dry_files = dict(CHECK), list(FILES)
|
|
states["preview"] = preview
|
|
command = checked_state()
|
|
command.stack += ["options", "command"]
|
|
command.ticked = {"nintendo-gba"}
|
|
states["command"] = command
|
|
confirm = checked_state()
|
|
confirm.stack.append("confirm")
|
|
confirm.ticked = {"nintendo-gba"}
|
|
confirm.dry = {**CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0,
|
|
"bytes_needed": 16384, "not_installable": 0}
|
|
states["confirm"] = confirm
|
|
installing = checked_state()
|
|
installing.stack.append("installing")
|
|
installing.run, installing.running = "install", True
|
|
installing.download = {"event": "download", "files": 4, "bytes": 1064964}
|
|
installing.samples, installing.now = [(100.0, 12672), (104.0, 540672)], 104.0
|
|
installing.progress = {"files_done": 2, "files_total": 4, "bytes_done": 540672,
|
|
"bytes_total": 1064964}
|
|
installing.finished = [("gba_bios.bin", True, ""), ("scph5500.bin", False, "HTTP 404")]
|
|
states["installing"] = installing
|
|
result = checked_state()
|
|
result.stack.append("result")
|
|
result.code = 1
|
|
result.log = "/tmp/retrobios-x/install.log"
|
|
result.download = installing.download
|
|
result.summary = {
|
|
"event": "summary", "mode": "install", "installed": 3, "up_to_date": 1, "pending": 1,
|
|
"failed": [{"dest": "scph5500.bin", "reason": "HTTP 404"}],
|
|
"skipped": [{"reason": "not_found", "label": "not found", "files": 2, "required": 1}],
|
|
"left_out": [], "kept": [{"folder": str(HOME / ".config/retroarch/retrobios-backup/"
|
|
"2026-10-23_1200"), "files": 1, "bytes": 4}],
|
|
"interrupted": False}
|
|
states["result"] = result
|
|
error = fresh_state()
|
|
error.stack, error.run = ["setup", "error"], "check"
|
|
error.error = [" Could not fetch the file list for retroarch after 3 attempts: "
|
|
"[Errno -2] Name or service not known",
|
|
" Address: https://raw.githubusercontent.com/Abdess/retrobios/main/"
|
|
"install/retroarch.json"]
|
|
states["error"] = error
|
|
quitting = fresh_state()
|
|
quitting.stack = ["setup", "quit"]
|
|
states["quit"] = quitting
|
|
helped = checked_state()
|
|
helped.help = True
|
|
states["help"] = helped
|
|
return states
|
|
|
|
|
|
class Views(unittest.TestCase):
|
|
"""Every screen as text, at the sizes a handheld and a desktop show."""
|
|
|
|
def test_goldens(self):
|
|
update = os.environ.get("RETROBIOS_UPDATE_GOLDENS") == "1"
|
|
GOLDEN.mkdir(parents=True, exist_ok=True)
|
|
for name, state in golden_states().items():
|
|
for cols, rows in SIZES:
|
|
with self.subTest(screen=name, size=f"{cols}x{rows}"):
|
|
state.cols, state.rows = cols, rows
|
|
lines = install.tui_view(state, cols, rows)
|
|
self.assertEqual(len(lines), rows)
|
|
for line in lines:
|
|
self.assertLessEqual(install.cell_width(line), cols, line)
|
|
self.assertTrue(all(ord(c) < 128 or c in "ファミコン" for c in line),
|
|
line)
|
|
self.assertLess(install.cell_width(lines[-1]), cols)
|
|
text = "\n".join(line.rstrip() for line in lines) + "\n"
|
|
path = GOLDEN / f"{name}-{cols}x{rows}.txt"
|
|
if update:
|
|
path.write_text(text, encoding="utf-8")
|
|
self.assertEqual(text, path.read_text(encoding="utf-8"))
|
|
|
|
def test_the_hint_bar_names_the_primary_action_first(self):
|
|
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", "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)
|
|
focused = [line for line in lines if getattr(line, "style", "") == "focus"]
|
|
self.assertEqual(len(focused), 1)
|
|
self.assertTrue(focused[0].lstrip().startswith("> RetroArch"))
|
|
|
|
|
|
class Rendering(unittest.TestCase):
|
|
def test_only_changed_lines_are_written(self):
|
|
out = io.StringIO()
|
|
terminal = install._TuiTerminal.__new__(install._TuiTerminal)
|
|
terminal.stdout, terminal.encoding, terminal.frame = out, "ascii", []
|
|
state = fresh_state()
|
|
terminal.draw(install.tui_view(state, 40, 12), 40)
|
|
first = out.getvalue()
|
|
self.assertTrue(first.startswith("\x1b[H\x1b[2J"))
|
|
press(state, "down")
|
|
out.seek(0)
|
|
out.truncate()
|
|
terminal.draw(install.tui_view(state, 40, 12), 40)
|
|
changed = out.getvalue()
|
|
self.assertEqual(changed.count("\x1b[7m"), 1)
|
|
before = install.tui_view(fresh_state(), 40, 12)
|
|
after = install.tui_view(state, 40, 12)
|
|
moved = [f"\x1b[{i + 1};1H" for i, (a, b) in enumerate(zip(before, after)) if a != b]
|
|
self.assertEqual(changed.count(";1H"), len(moved))
|
|
for move in moved:
|
|
self.assertIn(move, changed)
|
|
|
|
def test_text_the_terminal_cannot_encode_is_replaced(self):
|
|
out = io.StringIO()
|
|
terminal = install._TuiTerminal.__new__(install._TuiTerminal)
|
|
terminal.stdout, terminal.encoding, terminal.frame = out, "ascii", []
|
|
terminal.write("ファ")
|
|
self.assertEqual(out.getvalue(), "??")
|
|
|
|
|
|
class TheSectionStandsApart(unittest.TestCase):
|
|
def test_no_name_of_the_installer_is_defined_again(self):
|
|
"""_run and _system_label were defined twice: the later one won, and
|
|
the installer's listing called the interface's."""
|
|
import ast
|
|
|
|
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
|
start = source[:source.index("# Full-screen interface\n#")].count("\n") + 1
|
|
seen: dict = {}
|
|
for node in ast.parse(source).body:
|
|
names = [node.name] if isinstance(node, (ast.FunctionDef, ast.ClassDef)) else [
|
|
target.id for target in getattr(node, "targets", [])
|
|
if isinstance(target, ast.Name)]
|
|
for name in names:
|
|
seen.setdefault(name, []).append(node.lineno)
|
|
twice = {name: lines for name, lines in seen.items()
|
|
if len(lines) > 1 and max(lines) >= start}
|
|
self.assertEqual(twice, {})
|
|
|
|
|
|
class ControlCharacters(unittest.TestCase):
|
|
"""Names from the file list, the folders on disk and the installer's own
|
|
words reach the screen: an escape sequence among them would drive the
|
|
terminal (clear it, or set the clipboard with OSC 52)."""
|
|
|
|
HOSTILE = "a\x1b[2Jb\x1b]52;c;x\x07c\rd\ne\x7ff\x9bg\x85h"
|
|
|
|
def test_the_cleaning(self):
|
|
self.assertEqual(install.screen_text(self.HOSTILE), "a?[2Jb?]52;c;x?c?d?e?f?g?h")
|
|
self.assertEqual(install.screen_text("ファミコン é"), "ファミコン é")
|
|
|
|
def assertClean(self, lines) -> None:
|
|
for line in lines:
|
|
self.assertIsNone(re.search(r"[\x00-\x1f\x7f-\x9f]", line), repr(line))
|
|
|
|
def test_a_file_name_in_the_preview(self):
|
|
state = checked_state()
|
|
state.stack += ["options", "preview"]
|
|
state.dry = dict(CHECK)
|
|
state.dry_files = [{**FILES[0], "dest": "\x1b[2Jevil.bin"}]
|
|
lines = install.tui_view(state, 80, 24)
|
|
self.assertClean(lines)
|
|
self.assertIn("?[2Jevil.bin", "\n".join(lines))
|
|
|
|
def test_a_folder_name_in_the_browser(self):
|
|
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
|
self.addCleanup(shutil.rmtree, root, True)
|
|
(root / "x\x1b]52;c;x\x07").mkdir()
|
|
state = fresh_state()
|
|
state.platform, state.stack = "retroarch", ["folder", "browse"]
|
|
with mock.patch.object(install, "_mounted_cards", return_value=[]):
|
|
install._browse_to(state, root)
|
|
lines = install.tui_view(state, 80, 24)
|
|
self.assertClean(lines)
|
|
self.assertIn("x?]52;c;x?/", "\n".join(lines))
|
|
|
|
def test_the_child_tail_on_the_error_screen(self):
|
|
state = fresh_state()
|
|
state.stack, state.run = ["setup", "error"], "check"
|
|
state.error = ["\x1b[31mError\x1b[0m: \x07bad\r"]
|
|
self.assertClean(install.tui_view(state, 40, 15))
|
|
|
|
def test_the_renderer_guards_whatever_reaches_it(self):
|
|
out = io.StringIO()
|
|
terminal = install._TuiTerminal.__new__(install._TuiTerminal)
|
|
terminal.stdout, terminal.encoding = out, "utf-8"
|
|
terminal.frame = [("", "")] * 3
|
|
terminal.draw(["x", "\x1b]52;c;x\x07", "z"], 40)
|
|
self.assertNotIn("\x1b]", out.getvalue())
|
|
self.assertNotIn("\x07", out.getvalue())
|
|
|
|
def test_the_farewell_is_clean(self):
|
|
state = checked_state()
|
|
state.dest = Path("/media/\x1b[2J")
|
|
state.stack.append("result")
|
|
state.code = 0
|
|
state.summary = {"installed": 1, "failed": [{"dest": "\x1b]0;t\x07x", "reason": "\x1b"}]}
|
|
self.assertClean(install.tui_farewell(state))
|
|
|
|
|
|
class SmallFixes(unittest.TestCase):
|
|
def test_a_command_line_region_the_list_lacks(self):
|
|
state = checked_state()
|
|
state.cli_regions = ["europe"]
|
|
state.regions = list(state.cli_regions)
|
|
state.stack += ["options", "regions"]
|
|
self.assertEqual([row.value for row in install.tui_rows(state)],
|
|
["europe", "japan", "north-america"])
|
|
press(state, "enter")
|
|
self.assertEqual(state.regions, [])
|
|
self.assertIn("europe", [row.value for row in install.tui_rows(state)])
|
|
press(state, "enter")
|
|
self.assertEqual(state.regions, ["europe"])
|
|
|
|
def test_stay_after_esc_on_a_direct_check_checks_again(self):
|
|
state = fresh_state()
|
|
state.stack = []
|
|
state.manifest, state.manifest_for = MANIFEST, "retroarch"
|
|
install._choose(state, "retroarch", Path(REPO_ROOT))
|
|
self.assertEqual(state.screen, "checking")
|
|
self.assertEqual(press(state, "esc"), [("kill",)])
|
|
self.assertEqual(state.screen, "quit")
|
|
self.assertEqual(press(state, "down", "enter"), [("run", "check")])
|
|
self.assertEqual((state.stack, state.running), (["checking"], True))
|
|
press(state, "esc")
|
|
self.assertEqual(press(state, "esc"), [("run", "check")])
|
|
|
|
def test_speed_ignores_a_resumed_partial(self):
|
|
state = checked_state()
|
|
press(state, "enter")
|
|
mega = 1024 * 1024
|
|
state.now = 9.0
|
|
install.tui_receive(state, ("event", state.run_id, {
|
|
"event": "download", "platform": "retroarch", "files": 2, "bytes": 1000 * mega}))
|
|
# The first event carries the 500 MB already on disk.
|
|
for when, done in ((10.0, 500 * mega), (11.0, 501 * mega), (12.0, 502 * mega)):
|
|
state.now = when
|
|
install.tui_receive(state, ("event", state.run_id, {
|
|
"event": "progress", "files_done": 0, "files_total": 2,
|
|
"bytes_done": done, "bytes_total": 1000 * mega}))
|
|
text = "\n".join(install.tui_view(state, 80, 24))
|
|
self.assertIn("1.0 MB/s, 8:18 left", text)
|
|
|
|
def test_numbers_from_before_the_options_say_so(self):
|
|
state = checked_state()
|
|
state.stack.append("systems")
|
|
self.assertNotIn("before your options", "\n".join(install.tui_view(state, 80, 24)))
|
|
state.regions = ["japan"]
|
|
footer = "\n".join(install.tui_view(state, 80, 24)[-3:])
|
|
self.assertIn("before your options", footer)
|
|
|
|
def test_a_cores_folder_implies_the_installed_cores(self):
|
|
state = checked_state()
|
|
state.passthrough = [("--cores-dir", "/opt/cores")]
|
|
state.installed_only = True
|
|
state.stack.append("options")
|
|
row = next(r for r in install.tui_rows(state) if r.action == "installed")
|
|
self.assertEqual(row.label, "Installed cores only: yes, implied by --cores-dir")
|
|
state.focus["options"] = install.tui_rows(state).index(row)
|
|
press(state, "enter", "left")
|
|
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"]
|
|
state.dry, state.dry_files = dict(CHECK), list(FILES) * 10
|
|
state.cols, state.rows = 40, 15
|
|
press(state, "end")
|
|
last = install.tui_view(state, 40, 15)
|
|
press(state, "up")
|
|
self.assertNotEqual(install.tui_view(state, 40, 15), last)
|
|
|
|
def test_a_paste_slower_than_the_escape_wait_stays_one_piece(self):
|
|
decoder = install.KeyDecoder()
|
|
self.assertEqual(decoder.feed("\x1b[200~/media/"), [])
|
|
self.assertEqual(decoder.flush(), [])
|
|
self.assertTrue(decoder.waiting)
|
|
self.assertEqual(decoder.feed("sd\x1b[201~"), [Key("paste", "/media/sd")])
|
|
|
|
def test_values_cannot_be_read_as_options(self):
|
|
state = checked_state()
|
|
state.dest = Path("-rf")
|
|
state.cores = ["-x"]
|
|
argv = install.tui_argv(state, "install", "py", "i.py")
|
|
self.assertIn("--dest=-rf", argv)
|
|
self.assertIn("--core=-x", argv)
|
|
self.assertIn("--platform=retroarch", argv)
|
|
|
|
def test_the_second_ctrl_c_is_announced_and_the_result_stays_honest(self):
|
|
state = checked_state()
|
|
press(state, "enter")
|
|
install.tui_receive(state, ("event", state.run_id, {
|
|
"event": "download", "platform": "retroarch", "files": 4, "bytes": 100}))
|
|
install.tui_receive(state, ("event", state.run_id, {
|
|
"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 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, []))
|
|
title, lines = install.tui_outcome(state)
|
|
self.assertEqual((title, state.code), ("Stopped", 130))
|
|
self.assertEqual(lines[0], "Stopped: 1 of 4 installed, run again to finish.")
|
|
|
|
def test_systems_show_the_platform_names(self):
|
|
state = checked_state()
|
|
state.systems.append({"event": "system", "system": "sega-saturn",
|
|
"name": "Sega - Saturn", "files": 1, "present": 0,
|
|
"missing": 1, "wrong_hash": 0, "bytes_needed": 5})
|
|
state.ticked.add("sega-saturn")
|
|
state.stack.append("systems")
|
|
wide = "\n".join(install.tui_view(state, 120, 40))
|
|
self.assertIn("[x] Sega - Saturn", wide)
|
|
self.assertIn("sega-saturn", wide)
|
|
narrow = "\n".join(install.tui_view(state, 40, 15))
|
|
self.assertIn("[x] Sega - Saturn", narrow)
|
|
self.assertNotIn("sega-saturn", narrow)
|
|
press(state, "/", "s", "a", "t", "u")
|
|
self.assertEqual([row.value for row in install.tui_rows(state)][3:], ["sega-saturn"])
|
|
|
|
|
|
class CleanupSurvivesABrokenTerminal(unittest.TestCase):
|
|
"""A write that fails (EIO when the window closed, EPIPE) on the way out
|
|
left the terminal raw and the handlers installed."""
|
|
|
|
class Tty(io.StringIO):
|
|
broken = False
|
|
|
|
def __init__(self, fd: int) -> None:
|
|
super().__init__()
|
|
self.fd = fd
|
|
|
|
def isatty(self) -> bool:
|
|
return True
|
|
|
|
def fileno(self) -> int:
|
|
return self.fd
|
|
|
|
def write(self, text: str) -> int:
|
|
if CleanupSurvivesABrokenTerminal.Tty.broken:
|
|
raise OSError(5, "Input/output error")
|
|
return super().write(text)
|
|
|
|
def test_termios_and_handlers_come_back(self):
|
|
import signal
|
|
|
|
master, slave = os.openpty()
|
|
self.addCleanup(os.close, master)
|
|
self.addCleanup(os.close, slave)
|
|
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
|
|
before = termios.tcgetattr(slave)
|
|
handlers = {n: signal.getsignal(n) for n in (signal.SIGINT, signal.SIGTERM)}
|
|
tty = self.Tty(slave)
|
|
real_draw = install._TuiTerminal.draw
|
|
|
|
def breaking_draw(terminal, lines, cols):
|
|
terminal.frame = []
|
|
real_draw(terminal, lines, cols)
|
|
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"), \
|
|
mock.patch.object(sys, "stdin", tty), mock.patch.object(sys, "stdout", tty), \
|
|
mock.patch.object(sys, "stderr", tty):
|
|
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."""
|
|
|
|
class FakeTerminal:
|
|
left = False
|
|
|
|
def __init__(self, *_streams) -> None:
|
|
self.frame = []
|
|
|
|
def enter(self) -> None:
|
|
pass
|
|
|
|
def leave(self) -> None:
|
|
Crash.FakeTerminal.left = True
|
|
|
|
def size(self) -> tuple:
|
|
return 80, 24
|
|
|
|
def draw(self, lines, cols) -> None:
|
|
pass
|
|
|
|
def keys(self, timeout):
|
|
raise RuntimeError("a defect")
|
|
|
|
def test_the_terminal_is_restored_and_one_line_printed(self):
|
|
args = _args()
|
|
out, err = io.StringIO(), io.StringIO()
|
|
with mock.patch.object(install, "_TuiTerminal", self.FakeTerminal), \
|
|
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"), \
|
|
mock.patch.object(sys, "stdout", out), mock.patch.object(sys, "stderr", err):
|
|
code = install.run_tui(args)
|
|
self.assertEqual(code, 1)
|
|
self.assertTrue(self.FakeTerminal.left)
|
|
lines = err.getvalue().splitlines()
|
|
self.assertEqual(len(lines), 1, lines)
|
|
self.assertIn("stopped on an unexpected error", lines[0])
|
|
log = Path(lines[0].rsplit(" ", 1)[-1].rstrip("."))
|
|
self.addCleanup(shutil.rmtree, log.parent, True)
|
|
self.assertIn("RuntimeError: a defect", log.read_text())
|
|
self.assertNotIn("Traceback", err.getvalue() + out.getvalue())
|
|
|
|
|
|
# --- End to end, on a pseudo-terminal -------------------------------------
|
|
|
|
|
|
class Pty:
|
|
"""install.py on a pseudo-terminal, its screen read back through pyte."""
|
|
|
|
def __init__(self, args: list[str], env: dict, cols: int = 80, rows: int = 24) -> None:
|
|
self.screen = pyte.Screen(cols, rows)
|
|
self.stream = pyte.ByteStream(self.screen)
|
|
self.raw = b""
|
|
with warnings.catch_warnings():
|
|
# The child replaces itself at once: the fork-with-threads hazard
|
|
# (a lock held by the loopback server thread) never reaches it.
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
self.pid, self.fd = pty.fork()
|
|
if self.pid == 0:
|
|
os.execve(sys.executable, [sys.executable, str(REPO_ROOT / "install.py"), *args],
|
|
env)
|
|
self.resize(cols, rows)
|
|
self.status: int | None = None
|
|
|
|
def resize(self, cols: int, rows: int) -> None:
|
|
fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
self.screen.resize(rows, cols)
|
|
|
|
def _pump(self, wait: float) -> bool:
|
|
if not select.select([self.fd], [], [], wait)[0]:
|
|
return True
|
|
try:
|
|
chunk = os.read(self.fd, 65536)
|
|
except OSError:
|
|
return False
|
|
if not chunk:
|
|
return False
|
|
self.raw += chunk
|
|
self.stream.feed(chunk)
|
|
return True
|
|
|
|
def text(self) -> str:
|
|
return "\n".join(line.rstrip() for line in self.screen.display)
|
|
|
|
def wait(self, needle: str, timeout: float = 20.0) -> str:
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
if needle in self.text():
|
|
return self.text()
|
|
if not self._pump(0.05):
|
|
break
|
|
if needle in self.text():
|
|
return self.text()
|
|
raise AssertionError(f"{needle!r} never shown:\n{self.text()}")
|
|
|
|
def send(self, *keys: str) -> None:
|
|
for key in keys:
|
|
os.write(self.fd, key.encode())
|
|
# The interface waits 40 ms to tell Esc from an arrow.
|
|
time.sleep(0.15)
|
|
|
|
def close(self, timeout: float = 30.0) -> int:
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
|
if pid:
|
|
while self._pump(0.05) and select.select([self.fd], [], [], 0)[0]:
|
|
pass
|
|
os.close(self.fd)
|
|
self.status = os.WEXITSTATUS(status)
|
|
return self.status
|
|
self._pump(0.05)
|
|
os.kill(self.pid, 9)
|
|
raise AssertionError(f"still running:\n{self.text()}")
|
|
|
|
def after(self) -> str:
|
|
"""What the terminal keeps once the full screen is gone."""
|
|
return self.raw.split(b"\x1b[?1049l")[-1].decode("utf-8", "replace")
|
|
|
|
|
|
class _SlowHandler(_QuietHandler):
|
|
"""Serves each file after a pause, so a test can stop a download midway."""
|
|
|
|
def do_GET(self) -> None: # noqa: N802, D102
|
|
if self.path.startswith("/bios/"):
|
|
time.sleep(0.6)
|
|
super().do_GET()
|
|
|
|
|
|
class GameDataRepository(FakeRepository):
|
|
"""The fake repository with a categorized list and one slow file per file."""
|
|
|
|
def __init__(self, slow: bool = False) -> None:
|
|
super().__init__()
|
|
self.slow = slow
|
|
|
|
def __enter__(self) -> GameDataRepository:
|
|
super().__enter__()
|
|
path = self.root / "install" / "retroarch.json"
|
|
manifest = json.loads(path.read_text())
|
|
manifest["categorized"] = True
|
|
payload = b"a game's data"
|
|
(self.root / "bios" / "game.dat").write_bytes(payload)
|
|
manifest["files"].append({
|
|
"dest": "game.dat", "sha1": hashlib.sha1(payload).hexdigest(),
|
|
"sha256": hashlib.sha256(payload).hexdigest(), "size": len(payload),
|
|
"repo_path": "bios/game.dat", "cores": None, "systems": ["nintendo-gba"],
|
|
"category": "game_data"})
|
|
path.write_text(json.dumps(manifest))
|
|
if self.slow:
|
|
self.httpd.shutdown()
|
|
self.httpd.server_close()
|
|
handler = functools.partial(_SlowHandler, directory=str(self.root))
|
|
self.httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
return self
|
|
|
|
|
|
@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
|
|
"screen tests need pyte (pip install pyte==0.8.2) and a pty")
|
|
class EndToEnd(unittest.TestCase):
|
|
"""The real install.py --tui, keys in and screens out."""
|
|
|
|
def repo(self, cls=FakeRepository, **options) -> FakeRepository:
|
|
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
|
repo = cls(**options)
|
|
repo.__enter__()
|
|
self.addCleanup(repo.__exit__, None, None, None)
|
|
home = repo.root / "home"
|
|
cfg = home / ".config" / "retroarch"
|
|
cfg.mkdir(parents=True)
|
|
repo.dest.mkdir()
|
|
(cfg / "retroarch.cfg").write_text(f'system_directory = "{repo.dest}"\n')
|
|
self.env = {**repo.env(), "HOME": str(home), "TERM": "xterm",
|
|
"RETROBIOS_OS": "linux", "XDG_CONFIG_HOME": str(home / ".config")}
|
|
self.env.pop("NO_COLOR", None)
|
|
return repo
|
|
|
|
def start(self, *args: str, cols: int = 80, rows: int = 24) -> Pty:
|
|
term = Pty(list(args), self.env, cols, rows)
|
|
self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9))
|
|
return term
|
|
|
|
def test_three_presses_install_everything(self):
|
|
repo = self.repo()
|
|
term = self.start()
|
|
screen = term.wait("Where do you play?")
|
|
self.assertIn("> RetroArch", screen)
|
|
term.send("\r")
|
|
screen = term.wait("files to download")
|
|
self.assertIn("5 files to download, 30 B", screen)
|
|
self.assertIn("> Install", screen)
|
|
term.send("\r")
|
|
term.wait("5 files installed.")
|
|
term.send("\r")
|
|
self.assertEqual(term.close(), 0)
|
|
self.assertEqual(len(repo.installed()), 5)
|
|
after = term.after()
|
|
self.assertIn("RetroBIOS: Done", after)
|
|
self.assertIn("5 files installed.", after)
|
|
self.assertIn("--platform retroarch --dest", after)
|
|
# The cursor and the main screen come back before the result is printed.
|
|
self.assertIn(b"\x1b[?25h\x1b[?1049l", term.raw)
|
|
|
|
def test_one_system_from_the_checklist(self):
|
|
repo = self.repo()
|
|
term = self.start("--platform", "retroarch", "--dest", str(repo.dest), cols=40, rows=15)
|
|
term.wait("> Install")
|
|
term.send(DOWN, "\r")
|
|
term.wait("Choose systems")
|
|
# D-pad and A only: up to None, A, down to the second system, Right.
|
|
term.send(UP, UP, "\r", DOWN, DOWN, DOWN, RIGHT)
|
|
screen = term.wait("1 of 2 systems")
|
|
self.assertIn("> [x] sony-playstation", screen)
|
|
term.send("\r")
|
|
screen = term.wait("> Install")
|
|
self.assertIn("3 files to download", screen)
|
|
term.send("\r")
|
|
term.wait("3 files installed.")
|
|
term.send("\r")
|
|
self.assertEqual(term.close(), 0)
|
|
self.assertEqual(repo.installed(), ["pcsx/extra.bin", "scph5500.bin", "scph5501.bin"])
|
|
self.assertIn("--system sony-playstation", term.after().replace("\n", " "))
|
|
|
|
def test_options_region_and_game_data(self):
|
|
repo = self.repo(GameDataRepository)
|
|
term = self.start("--platform", "retroarch", "--dest", str(repo.dest))
|
|
term.wait("6 files to download")
|
|
term.send("o")
|
|
screen = term.wait("Each row is an option")
|
|
self.assertIn("--no-game-data", screen)
|
|
term.send("\r")
|
|
term.wait("Preferred regions")
|
|
term.send(DOWN, "\r", "\x1b")
|
|
screen = term.wait("Preferred regions: North America")
|
|
term.send(DOWN, RIGHT, "\x1b")
|
|
# Back on the plan, the check runs again under the new options.
|
|
screen = term.wait("4 files to download")
|
|
self.assertIn("> Install", screen)
|
|
term.send("\r")
|
|
screen = term.wait("installed.")
|
|
self.assertIn("Left out as game data: 1 file.", screen)
|
|
term.send("\r")
|
|
self.assertEqual(term.close(), 0)
|
|
self.assertEqual(repo.installed(), ["gba_bios.bin", "mgba/extra.bin",
|
|
"pcsx/extra.bin", "scph5501.bin"])
|
|
|
|
def test_preview_writes_nothing(self):
|
|
repo = self.repo()
|
|
term = self.start("--platform", "retroarch", "--dest", str(repo.dest))
|
|
term.wait("> Install")
|
|
term.send("o", "G", UP, "\r")
|
|
screen = term.wait("Nothing is written.")
|
|
self.assertIn("missing", screen)
|
|
self.assertIn("scph5501.bin", screen)
|
|
term.send("\x1b", "\x1b", "q")
|
|
self.assertEqual(term.close(), 130)
|
|
self.assertEqual(repo.installed(), [])
|
|
self.assertIn("Nothing was installed.", term.after())
|
|
|
|
def test_ctrl_c_during_the_download(self):
|
|
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")
|
|
term.send("\x03")
|
|
screen = term.wait("Stopped:")
|
|
term.send("\r")
|
|
self.assertEqual(term.close(), 130)
|
|
# 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()
|
|
term = self.start()
|
|
term.wait("Where do you play?")
|
|
term.send("\x03")
|
|
self.assertEqual(term.close(), 130)
|
|
self.assertIn(b"\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l", term.raw)
|
|
|
|
def test_a_stop_from_outside_gives_the_terminal_back(self):
|
|
import signal
|
|
|
|
self.repo()
|
|
term = self.start()
|
|
term.wait("Where do you play?")
|
|
mark = len(term.raw)
|
|
os.kill(term.pid, signal.SIGTSTP)
|
|
end = time.monotonic() + 10
|
|
stopped = False
|
|
while time.monotonic() < end and not stopped:
|
|
term._pump(0.05)
|
|
pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED)
|
|
stopped = bool(pid) and os.WIFSTOPPED(status)
|
|
self.assertTrue(stopped)
|
|
self.assertIn(b"\x1b[?25h\x1b[?1049l", term.raw[mark:])
|
|
mark = len(term.raw)
|
|
os.kill(term.pid, signal.SIGCONT)
|
|
term.wait("A/Enter Continue")
|
|
end = time.monotonic() + 5
|
|
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
|
|
term._pump(0.05)
|
|
self.assertIn(b"\x1b[?1049h", term.raw[mark:])
|
|
term.send("q")
|
|
self.assertEqual(term.close(), 130)
|
|
|
|
def test_resize_redraws(self):
|
|
self.repo()
|
|
term = self.start(cols=40, rows=15)
|
|
term.wait("Where do you play?")
|
|
term.resize(100, 30)
|
|
term.send(DOWN)
|
|
screen = term.wait("> Another setup or folder")
|
|
self.assertIn("B/Esc Quit", screen.splitlines()[29])
|
|
term.resize(30, 10)
|
|
term.wait("Make the window larger")
|
|
term.resize(40, 12)
|
|
term.wait("Where do you play?")
|
|
term.send("q")
|
|
self.assertEqual(term.close(), 130)
|
|
|
|
def test_plain_mode_keeps_the_line_questions(self):
|
|
self.repo()
|
|
for env in ({"RETROBIOS_PLAIN": "1"}, {"ACCESSIBLE": "1"}):
|
|
with self.subTest(env=env):
|
|
self.env.update(env)
|
|
term = self.start()
|
|
term.wait("Enter) install everything")
|
|
term.send("q\r")
|
|
term.close()
|
|
self.assertNotIn(b"\x1b[?1049h", term.raw)
|
|
for name in env:
|
|
self.env.pop(name)
|
|
|
|
def test_dumb_terminal_writes_no_escape(self):
|
|
self.repo()
|
|
self.env["TERM"] = "dumb"
|
|
term = self.start()
|
|
term.wait("Enter) install everything")
|
|
term.send("q\r")
|
|
term.close()
|
|
self.assertNotIn(b"\x1b", term.raw)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|