mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-15 06:23:47 -06:00
chore: merge integ into fix-launch-names
This commit is contained in:
commit
a6aea81c17
36 files changed
+2347
-101
No files matched your search
@@ -636,5 +636,74 @@ class ContributorChecksFetchThePlatformsFiles(unittest.TestCase):
|
||||
self.assertIn("export_native.py --all --refresh-cache", _read("wiki/testing-guide.md"))
|
||||
|
||||
|
||||
class ArchiveRecipesAreCountedFromTheRecipes(unittest.TestCase):
|
||||
"""Every archive has a recipe in recipes/archives.json: the README and
|
||||
the site's page say how many rebuild exact and canonical, counted from
|
||||
that file, never typed."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
import archive_build # noqa: PLC0415
|
||||
|
||||
records = archive_build.load_ledger().values()
|
||||
self.counts = {
|
||||
"total": len(records),
|
||||
"exact": sum(r["rebuild"] == "exact" for r in records),
|
||||
"canonical": sum(r["rebuild"] == "canonical" for r in records),
|
||||
}
|
||||
self.assertEqual(archive_build.rebuild_counts(), self.counts)
|
||||
self.assertEqual(self.counts["exact"] + self.counts["canonical"], self.counts["total"])
|
||||
|
||||
def test_the_readme_line(self) -> None:
|
||||
from generate_readme import archive_line # noqa: PLC0415
|
||||
|
||||
line = archive_line(self.counts)
|
||||
self.assertIn(line, _read("README.md").splitlines())
|
||||
self.assertIn(f"**{self.counts['total']:,} archives**", line)
|
||||
self.assertIn(f"{self.counts['exact']:,} byte for byte", line)
|
||||
self.assertIn(f"{self.counts['canonical']:,} with the same files inside", line)
|
||||
self.assertIn(f"({SITE_URL}wiki/archive-recipes/)", line)
|
||||
|
||||
def test_the_page_counts(self) -> None:
|
||||
import generate_site # noqa: PLC0415
|
||||
|
||||
page = _read("wiki/archive-recipes.md")
|
||||
self.assertEqual(generate_site.with_archive_counts(page, self.counts), page,
|
||||
"wiki/archive-recipes.md counts are stale: run scripts/generate_site.py")
|
||||
for line in generate_site.archive_recipe_lines(self.counts):
|
||||
self.assertIn(line, page)
|
||||
|
||||
def test_the_page_command_is_the_tools(self) -> None:
|
||||
command = re.search(r"python scripts/romset_recipes.py (.+)", _read("wiki/archive-recipes.md"))
|
||||
source = _read("scripts/romset_recipes.py")
|
||||
for flag in re.findall(r"--[a-z-]+", command.group(1)):
|
||||
with self.subTest(flag=flag):
|
||||
self.assertIn(f'"{flag}"', source)
|
||||
|
||||
|
||||
class TheReadmeBadges(unittest.TestCase):
|
||||
"""One row of four badges under the links, each linking its source."""
|
||||
|
||||
EXPECTED = (
|
||||
'<p align="center">'
|
||||
'<a href="https://pypi.org/project/retrobios/"><img src="https://img.shields.io/pypi/v/retrobios" alt="PyPI version"></a> '
|
||||
'<a href="https://pypi.org/project/retrobios/"><img src="https://img.shields.io/pypi/pyversions/retrobios" alt="Python versions"></a> '
|
||||
f'<a href="{REPO}/actions/workflows/validate.yml"><img src="{REPO}/actions/workflows/validate.yml/badge.svg?branch=main" alt="Validation"></a> '
|
||||
'<a href="LICENSE"><img src="https://img.shields.io/github/license/Abdess/retrobios" alt="License"></a>'
|
||||
"</p>"
|
||||
)
|
||||
|
||||
def test_exactly_these_four(self) -> None:
|
||||
readme = _read("README.md")
|
||||
self.assertEqual(len(re.findall(r"<img src=\"https://(?:img\.shields\.io|github\.com)/", readme)), 4)
|
||||
lines = readme.splitlines()
|
||||
self.assertIn(self.EXPECTED, lines)
|
||||
links = next(i for i, line in enumerate(lines) if ">Get the files</a>" in line)
|
||||
self.assertEqual(lines[links + 2], self.EXPECTED)
|
||||
|
||||
def test_the_workflow_and_the_license_exist(self) -> None:
|
||||
self.assertTrue((REPO_ROOT / ".github" / "workflows" / "validate.yml").is_file())
|
||||
self.assertTrue((REPO_ROOT / "LICENSE").is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1183,6 +1183,23 @@ class TestE2E(unittest.TestCase):
|
||||
recalbox = {"cores": ["mame0278"], "systems": {}}
|
||||
self.assertEqual(resolve_platform_cores(recalbox, profiles), {"mame0278"})
|
||||
|
||||
def test_a_platform_s_own_libretro_build_comes_with_its_retroarch(self):
|
||||
"""ROCKNIX ships yabadabadoo with its RetroArch and names it nowhere
|
||||
else: a list naming retroarch takes the libretro builds whose
|
||||
built_by names that platform, and no other platform's."""
|
||||
profiles = {
|
||||
"yabause": {"type": "libretro", "systems": ["saturn"], "files": []},
|
||||
"yabadabadoo": {"type": "libretro", "built_by": ["rocknix"],
|
||||
"systems": ["saturn"], "files": []},
|
||||
}
|
||||
rocknix = {"_key": "rocknix", "cores": ["retroarch"], "systems": {}}
|
||||
self.assertEqual(resolve_platform_cores(rocknix, profiles),
|
||||
{"yabause", "yabadabadoo"})
|
||||
other = {"_key": "batocera", "cores": ["retroarch"], "systems": {}}
|
||||
self.assertEqual(resolve_platform_cores(other, profiles), {"yabause"})
|
||||
everyone = {"_key": "retroarch", "cores": "all_libretro", "systems": {}}
|
||||
self.assertEqual(resolve_platform_cores(everyone, profiles), {"yabause"})
|
||||
|
||||
def test_resolve_cores_explicit_list(self):
|
||||
"""Explicit cores list matches against profile dict keys."""
|
||||
config = {"cores": ["fbneo", "opera"], "systems": {"arcade": {"files": []}}}
|
||||
|
||||
@@ -366,6 +366,13 @@ HOME = Path("/home/user")
|
||||
SYSTEM_DIR = HOME / ".config" / "retroarch" / "system"
|
||||
|
||||
|
||||
def screen_text(lines) -> str:
|
||||
"""The screen as one line of words, a path broken after a separator
|
||||
joined again: a folder under the checkout is as long as the checkout's
|
||||
own path, and a deep clone wrapped it across two lines."""
|
||||
return re.sub(r"(?<=[/\\]) ", "", " ".join(" ".join(lines).split()))
|
||||
|
||||
|
||||
def fresh_state() -> install.TuiState:
|
||||
state = install.TuiState(os_type="linux", home=HOME)
|
||||
state.setups = [("retroarch", SYSTEM_DIR, "RetroArch"),
|
||||
@@ -1856,7 +1863,7 @@ class TicksBarAndPaths(unittest.TestCase):
|
||||
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))
|
||||
text = screen_text(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)
|
||||
@@ -2343,7 +2350,7 @@ class CardsSpaceAndConfirm(unittest.TestCase):
|
||||
state, effects = install.tui_start(_args(), "linux")
|
||||
label = f"muOS SD card ({root.name})"
|
||||
self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)])
|
||||
text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
|
||||
text = screen_text(install.tui_view(state, 120, 40))
|
||||
self.assertIn(f"> {label}", text)
|
||||
self.assertIn(f"BIOS folder: {root / 'MUOS' / 'bios'}", text)
|
||||
(root / "MUOS" / "bios").mkdir()
|
||||
@@ -4135,7 +4142,7 @@ class AFolderThisUserCannotOpen(unittest.TestCase):
|
||||
self.dest = self.lock / "system"
|
||||
|
||||
def said(self, state: install.TuiState) -> str:
|
||||
return " ".join(" ".join(install.tui_view(state, 200, 40)).split())
|
||||
return screen_text(install.tui_view(state, 200, 40))
|
||||
|
||||
def test_typed_it_stays_on_the_path_screen(self):
|
||||
state = fresh_state()
|
||||
@@ -6507,7 +6514,7 @@ class OneCardReading(unittest.TestCase):
|
||||
self.assertEqual(state.screen, "checking" if one else "create")
|
||||
if not one:
|
||||
self.assertIn(note.split(":")[0],
|
||||
" ".join(" ".join(install.tui_view(state, 120, 40)).split()))
|
||||
screen_text(install.tui_view(state, 120, 40)))
|
||||
install.tui_update(state, Key("enter"))
|
||||
self.assertEqual(state.card_note, note)
|
||||
|
||||
@@ -11117,7 +11124,7 @@ class TapsGameDataAndExitCodes(_CardFixtures, unittest.TestCase):
|
||||
_title, lines = install.tui_outcome(state)
|
||||
self.assertIn(f"BIOS folder: {card / 'Bios'}", lines)
|
||||
state.stack = ["checking"]
|
||||
self.assertIn(str(card / "Bios"), " ".join(install.tui_view(state, 120, 40)))
|
||||
self.assertIn(str(card / "Bios"), screen_text(install.tui_view(state, 120, 40)))
|
||||
|
||||
# Root cause 7: colour roles read the words.
|
||||
|
||||
@@ -11263,7 +11270,7 @@ class TapsGameDataAndExitCodes(_CardFixtures, unittest.TestCase):
|
||||
folder = next(i for i, line in enumerate(lines) if line.startswith("BIOS folder:"))
|
||||
named = next(i for i, line in enumerate(lines) if line.startswith("Named by"))
|
||||
# Nothing between the folder, wrapped or not, and what named it.
|
||||
self.assertTrue(all(line.startswith("/") for line in lines[folder + 1:named]), lines)
|
||||
self.assertEqual("".join(lines[folder:named]), f"BIOS folder: {state.dest}", lines)
|
||||
self.assertLess(named, next(i for i, line in enumerate(lines)
|
||||
if line.startswith(("For ", "Copy them"))))
|
||||
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
"""EmuELEC's launcher layer read from its packages.
|
||||
|
||||
Each fixture is a cut-down copy of the lines an extractor reads at EmuELEC
|
||||
v4.8 (02b45fe53b85c45c75dbad8e44a648092dec2e72). The records must name the
|
||||
core emuelecRunEmu.sh dispatches, carry the names in the order the code
|
||||
tries them, point at the fixture's own lines, and a construct that is gone
|
||||
must stop the scrape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
|
||||
|
||||
import sys
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from scraper import emuelec_launchers as el # noqa: E402
|
||||
from scraper.emuelec_scraper import launcher_revision # noqa: E402
|
||||
from scraper.shell_launchers import LauncherPatternMissing, RepoTree # noqa: E402
|
||||
|
||||
EMU = el.EMULATORS
|
||||
|
||||
|
||||
def text(block: str) -> str:
|
||||
return textwrap.dedent(block).lstrip("\n")
|
||||
|
||||
|
||||
RUN_EMU = text(
|
||||
"""
|
||||
if [[ "${EMULATOR}" = "libretro" ]]; then
|
||||
EMU="${CORE}_libretro"
|
||||
fi
|
||||
if [[ "${EMULATOR}" = "retrorun" ]]; then
|
||||
EMU="${CORE}_libretro"
|
||||
fi
|
||||
MIDI_OUTPUT=$(get_ee_setting "ra_midi_output" "${PLATFORM}" "${BASEROMNAME}")
|
||||
if [[ ! -z "${MIDI_OUTPUT}" ]]; then
|
||||
emuelec-utils set_midi_source "${MIDI_OUTPUT}" "${EMULATOR}"
|
||||
fi
|
||||
if [ ${EMU} == "freej2me_libretro" ]; then
|
||||
freej2me.sh
|
||||
|
||||
JAVA_HOME='/storage/roms/bios/jdk'
|
||||
export JAVA_HOME
|
||||
fi
|
||||
case ${PLATFORM} in
|
||||
"atarist")
|
||||
if [ "${EMU}" = "HATARISA" ]; then
|
||||
RUNTHIS='${TBASH} hatari.start "${ROMNAME}"'
|
||||
fi
|
||||
;;
|
||||
"dreamcast"|"naomi")
|
||||
if [ "${EMU}" = "flycastsa" ]; then
|
||||
RUNTHIS='${TBASH} flycast.sh "${ROMNAME}"'
|
||||
elif [ "${EMU}" = "flycast_dojo" ]; then
|
||||
RUNTHIS='flycastdojo.sh "${ROMNAME}"'
|
||||
fi
|
||||
;;
|
||||
"psx")
|
||||
if [ "${EMU}" = "duckstation" ]; then
|
||||
RUNTHIS='${TBASH} duckstation.sh "${ROMNAME}"'
|
||||
fi
|
||||
;;
|
||||
"mame")
|
||||
if [ "${EMU}" = "AdvanceMame" ]; then
|
||||
RUNTHIS='${TBASH} advmame.sh "${PLATFORM}" "${ROMNAME}"'
|
||||
fi
|
||||
;;
|
||||
"n64")
|
||||
if [ "${EMU}" = "rice" ]; then
|
||||
RUNTHIS='${TBASH} m64p.sh "${ROMNAME}"'
|
||||
elif [ "${EMU}" = "glide64mk2" ]; then
|
||||
RUNTHIS='${TBASH} m64p.sh "${ROMNAME}" m64p_gl64mk2'
|
||||
fi
|
||||
;;
|
||||
"scummvm")
|
||||
if [ "${EMU}" = "SCUMMVMSA" ]; then
|
||||
RUNTHIS='${TBASH} scummvm.start sa "${ROMNAME}"'
|
||||
fi
|
||||
;;
|
||||
"wii"|"gamecube")
|
||||
if [ "${EMU}" = "dolphin" ]; then
|
||||
RUNTHIS='${TBASH} dolphin.sh "${ROMNAME}"'
|
||||
fi
|
||||
;;
|
||||
"pico8")
|
||||
RUNTHIS='${TBASH} pico8.sh "${ROMNAME}"'
|
||||
;;
|
||||
"intellivision")
|
||||
if [ "${EMU}" = "jzintv" ]; then
|
||||
RUNTHIS='jzintv.sh "${ROMNAME}"'
|
||||
fi
|
||||
;;
|
||||
"saturn")
|
||||
if [ "${EMU}" = "yabasanshiroSA" ]; then
|
||||
RUNTHIS='yabasanshiro.sh "${ROMNAME}"'
|
||||
elif [ "${EMU}" = "yabasanshiroSA1_5" ]; then
|
||||
RUNTHIS='yabasanshiro1_5.sh "${ROMNAME}"'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
case ${PLATFORM} in
|
||||
"arcade"|"mame")
|
||||
if [ "$EMU" = "mame_libretro" ]; then
|
||||
mame.sh
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
RUNTHIS='${RABIN} ${VERBOSE} ${RA_ARGS} -L /tmp/cores/${EMU}.so --config ${RACONF} "${ROMNAME}"'
|
||||
RUNTHIS="retrorun"
|
||||
RUNTHIS+=' ${CMD_ROTATE} --triggers -g -d /storage/roms/bios /tmp/cores/${EMU}.so "${ROMNAME}"'
|
||||
"""
|
||||
)
|
||||
|
||||
UTILS = text(
|
||||
"""
|
||||
function set_midi_source()
|
||||
{
|
||||
case ${midi_source} in
|
||||
fluidsynth)
|
||||
fluidsynth -is /storage/roms/bios/sc/SC-55.sf2 &
|
||||
;;
|
||||
timidity)
|
||||
timidity -iA --module 1 &
|
||||
;;
|
||||
mt32)
|
||||
mt32d -o 2 -i 24 -f /roms/bios &
|
||||
;;
|
||||
esac
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
RETROARCH_MK = text(
|
||||
"""
|
||||
sed -i -e "s/# savestate_directory =/savestate_directory =\\/storage\\/roms\\/savestates/" ${INSTALL}/etc/retroarch.cfg
|
||||
sed -i -e "s/# system_directory =/system_directory =\\/storage\\/roms\\/bios/" ${INSTALL}/etc/retroarch.cfg
|
||||
"""
|
||||
)
|
||||
|
||||
FREEJ2ME_SH = text(
|
||||
"""
|
||||
install_java
|
||||
|
||||
if [[ -f "/storage/roms/bios/jdk/eeversion" ]]; then
|
||||
cp -rf /usr/lib/libretro/freej2me-lr.jar ${HOME}/roms/bios
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
"""
|
||||
)
|
||||
|
||||
PICO8_SH = text(
|
||||
"""
|
||||
if [[ -e "/storage/roms/bios/pico-8" ]]; then
|
||||
mkdir -p /emuelec/bin/pico-8
|
||||
cp -rf /storage/roms/bios/pico-8/* /emuelec/bin/pico-8
|
||||
rm -rf /storage/roms/bios/pico-8
|
||||
chmod +x /emuelec/bin/pico-8/pico8_dyn
|
||||
fi
|
||||
if [[ "${CART}" == *"/splore"* ]]; then
|
||||
/emuelec/bin/pico-8/pico8_dyn -splore -home /emuelec/configs/pico-8
|
||||
else
|
||||
/emuelec/bin/pico-8/pico8_dyn -run "${CART}" -home /emuelec/configs/pico-8
|
||||
fi
|
||||
"""
|
||||
)
|
||||
|
||||
FLYCAST_SH = text(
|
||||
"""
|
||||
if [ ! -L "/storage/.local/share/flycast" ]; then
|
||||
mkdir -p "/storage/roms/bios/dc"
|
||||
rm -rf "/storage/.local/share/flycast"
|
||||
ln -sf "/storage/roms/bios/dc" "/storage/.local/share/flycast"
|
||||
fi
|
||||
"""
|
||||
)
|
||||
|
||||
FLYCASTDOJO_SH = text(
|
||||
"""
|
||||
LOCAL_DATA="/storage/roms/bios/dc_dojo"
|
||||
LOCAL_HOME="/storage/.local/share/flycast-dojo"
|
||||
if [ ! -L "${LOCAL_HOME}" ]; then
|
||||
rm -rf "${LOCAL_HOME}"
|
||||
ln -sf "${LOCAL_DATA}" "${LOCAL_HOME}"
|
||||
fi
|
||||
"""
|
||||
)
|
||||
|
||||
AMIBERRY_MK = text(
|
||||
"""
|
||||
cp -a ${PKG_DIR}/config/* ${INSTALL}/usr/config/amiberry/
|
||||
ln -s /storage/roms/bios ${INSTALL}/usr/config/amiberry/kickstarts
|
||||
"""
|
||||
)
|
||||
|
||||
TIMIDITY_MK = text(
|
||||
"""
|
||||
ln -sf /storage/roms/bios/timidity/freepats ${INSTALL}/usr/config/timidity/freepats
|
||||
ln -sf /storage/.config/timidity/freepats.cfg ${INSTALL}/etc/timidity/freepats.cfg
|
||||
"""
|
||||
)
|
||||
|
||||
DUCKSTATION_INI = text(
|
||||
"""
|
||||
[BIOS]
|
||||
SearchDirectory = /storage/roms/bios
|
||||
PathNTSCU =
|
||||
PathNTSCJ =
|
||||
PathPAL =
|
||||
"""
|
||||
)
|
||||
|
||||
DUCKSTATION_MK = text(
|
||||
"""
|
||||
cp -rf ${PKG_DIR}/config/* ${INSTALL}/usr/config/emuelec/configs/duckstation
|
||||
"""
|
||||
)
|
||||
|
||||
DUCKSTATION_SH = text(
|
||||
"""
|
||||
CONFIG_DIR="/emuelec/configs/duckstation"
|
||||
duckstation-nogui -fullscreen -settings "${CONFIG_DIR}/settings.ini" -- "${1}"
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def yabasanshiro_sh(program: str) -> str:
|
||||
return text(
|
||||
f"""
|
||||
BIOS=""
|
||||
HLEBIOS=$(get_ee_setting hlebios saturn "${{ROMNAME}}")
|
||||
if [ "${{HLEBIOS}}" != 1 ]; then
|
||||
if [ -e "/storage/roms/bios/saturn_bios.bin" ]; then
|
||||
BIOS="-b /storage/roms/bios/saturn_bios.bin"
|
||||
else
|
||||
BIOS=""
|
||||
fi
|
||||
fi
|
||||
{{ {program} -r 2 -i "${{1}}" ${{BIOS}}; }} > /emuelec/logs/emuelec.log 2>&1
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
JZINTV_SH = text(
|
||||
"""
|
||||
exec jzintv -f1 -z "$RES" -p /storage/roms/bios/ "$ROM" $KBD_OPT
|
||||
"""
|
||||
)
|
||||
|
||||
M64P_SH = text(
|
||||
"""
|
||||
BIOSDIR="/storage/roms/bios/Mupen64plus"
|
||||
IPLROM="${BIOSDIR}/N64DD IPLROM (J).n64"
|
||||
[ ! -f "${IPLROM}" ] && IPLROM="${BIOSDIR}/IPL.n64"
|
||||
if [ -f "${GAMEDIR}/${GAMENAME}.ndd" ] && [ -f "${IPLROM}" ]; then
|
||||
RENMODE="1"
|
||||
else
|
||||
IPLROM=""
|
||||
fi
|
||||
mupen64plus --fullscreen --dd-ipl-rom "${IPLROM}" --dd-disk "${DDDISK}" "${FILE}" ;;
|
||||
"""
|
||||
)
|
||||
|
||||
HATARI_START = text(
|
||||
"""
|
||||
HATARI_TMP_CONFIG="$HATARI_TMP_DIR"/.hatari-temp.cfg
|
||||
cp $HATARI_CONFIG_DIR/Atari-ST-default.cfg "$HATARI_TMP_CONFIG"
|
||||
hatari --configfile "$HATARI_TMP_CONFIG" --disk-a "$1"
|
||||
"""
|
||||
)
|
||||
|
||||
HATARI_CFG = text(
|
||||
"""
|
||||
[HardDisk]
|
||||
szHardDiskDirectory = /storage/.hatari
|
||||
|
||||
[ROM]
|
||||
szTosImageFileName = /storage/roms/bios/tos.img
|
||||
szCartridgeImageFileName =
|
||||
"""
|
||||
)
|
||||
|
||||
HATARI_MK = text(
|
||||
"""
|
||||
cp -R ${PKG_DIR}/config/* ${INSTALL}/usr/config/hatari
|
||||
"""
|
||||
)
|
||||
|
||||
ADVMAME_MK = text(
|
||||
"""
|
||||
if [ "${DEVICE}" == "OdroidGoAdvance" ]; then
|
||||
cp -r ${PKG_DIR}/config/advmame.rc_oga ${INSTALL}/usr/config/emuelec/configs/advmame/advmame.rc
|
||||
else
|
||||
cp -r ${PKG_DIR}/config/advmame.rc ${INSTALL}/usr/config/emuelec/configs/advmame/advmame.rc
|
||||
fi
|
||||
"""
|
||||
)
|
||||
|
||||
ADVMAME_RC = text(
|
||||
"""
|
||||
dir_rom /storage/roms/mame
|
||||
dir_sample /storage/roms/bios/advmame/samples
|
||||
"""
|
||||
)
|
||||
|
||||
ADVMAME_SH = text(
|
||||
"""
|
||||
SDL_AUDIODRIVER=alsa advmame ${ARG} -quiet
|
||||
"""
|
||||
)
|
||||
|
||||
MAME_SH = text(
|
||||
"""
|
||||
if [ ! -f "/storage/roms/bios/mame/hash/fmtowns_cd.xml" ]; then
|
||||
mkdir -p /storage/roms/bios/mame/hash
|
||||
cp -rf "/usr/config/emuelec/configs/mame/hash/"fm* "/storage/roms/bios/mame/hash"
|
||||
fi
|
||||
if [ ! -f "/storage/roms/bios/mame/ini/mame.ini" ]; then
|
||||
mkdir -p /storage/roms/bios/mame/ini
|
||||
cp -rf "/usr/config/emuelec/configs/mame/ini/"mame* "/storage/roms/bios/mame/ini"
|
||||
fi
|
||||
if [ ! -f "/storage/.config/retroarch/config/MAME/MAME.opt" ]; then
|
||||
cp -rf "/usr/config/emuelec/configs/mame/MAME/"MAME* "/storage/.config/retroarch/config/MAME"
|
||||
fi
|
||||
"""
|
||||
)
|
||||
|
||||
MAME_INI = text(
|
||||
"""
|
||||
rompath /storage/roms/bios/mame/bios;/storage/roms/mame;/storage/roms/arcade
|
||||
samplepath /storage/roms/bios/mame/samples
|
||||
cfg_directory /storage/roms/bios/mame/mame/cfg
|
||||
"""
|
||||
)
|
||||
|
||||
MAME_OPT = text(
|
||||
"""
|
||||
mame_boot_to_bios = "disabled"
|
||||
mame_read_config = "enabled"
|
||||
"""
|
||||
)
|
||||
|
||||
SCUMMVM_START = text(
|
||||
"""
|
||||
CONFIG_DIR="/storage/.config/scummvm"
|
||||
if [ -d "/storage/.config/scummvm/extra" ]; then
|
||||
EXTRA="--extrapath=/storage/.config/scummvm/extra"
|
||||
fi
|
||||
case $1 in
|
||||
"sa")
|
||||
scummvm --fullscreen --joystick=0 $EXTRA "${GAME}"
|
||||
;;
|
||||
"libretro")
|
||||
retroarch -L /tmp/cores/scummvm_libretro.so --config ${RATMPCONF} "${GAME}"
|
||||
;;
|
||||
"add")
|
||||
scummvm --add --path="/storage/roms/scummvm" --recursive
|
||||
cp ${CONFIG_DIR}/scummvm.ini /storage/roms/bios/scummvm.ini
|
||||
;;
|
||||
esac
|
||||
"""
|
||||
)
|
||||
|
||||
DOLPHIN_SH = text(
|
||||
"""
|
||||
XDG_CONFIG_HOME=/emuelec/configs XDG_DATA_HOME=/storage/roms/dolphin /usr/bin/dolphin-emu-nogui -p fbdev "${1}"
|
||||
"""
|
||||
)
|
||||
|
||||
FILES = {
|
||||
el.RUN_EMU: RUN_EMU,
|
||||
el.UTILS: UTILS,
|
||||
el.RETROARCH_MK: RETROARCH_MK,
|
||||
f"{el.LIBRETRO}/freej2me/scripts/freej2me.sh": FREEJ2ME_SH,
|
||||
f"{el.BIN}/pico8.sh": PICO8_SH,
|
||||
f"{EMU}/flycastsa/scripts/flycast.sh": FLYCAST_SH,
|
||||
f"{EMU}/flycast-dojo/scripts/flycastdojo.sh": FLYCASTDOJO_SH,
|
||||
f"{EMU}/amiberry/package.mk": AMIBERRY_MK,
|
||||
f"{EMU}/amiberry-lite/package.mk": AMIBERRY_MK.replace("amiberry/", "amiberry-lite/"),
|
||||
f"{el.SX05RE}/tools/sound/timidity/package.mk": TIMIDITY_MK,
|
||||
f"{EMU}/duckstation/config/settings.ini": DUCKSTATION_INI,
|
||||
f"{EMU}/duckstation/package.mk": DUCKSTATION_MK,
|
||||
f"{EMU}/duckstation/scripts/duckstation.sh": DUCKSTATION_SH,
|
||||
el.YABASANSHIRO_SCRIPTS[0]: yabasanshiro_sh("yabasanshiro"),
|
||||
el.YABASANSHIRO_SCRIPTS[1]: yabasanshiro_sh("yabasanshiro1_5"),
|
||||
f"{EMU}/jzintv/scripts/jzintv.sh": JZINTV_SH,
|
||||
f"{EMU}/mupen64plussa/mupen64plussa-core/m64p.sh": M64P_SH,
|
||||
f"{EMU}/hatarisa/scripts/hatari.start": HATARI_START,
|
||||
f"{EMU}/hatarisa/config/conf/Atari-ST-default.cfg": HATARI_CFG,
|
||||
f"{EMU}/hatarisa/package.mk": HATARI_MK,
|
||||
f"{EMU}/advancemame/package.mk": ADVMAME_MK,
|
||||
f"{EMU}/advancemame/config/advmame.rc": ADVMAME_RC,
|
||||
f"{EMU}/advancemame/config/advmame.rc_oga": ADVMAME_RC,
|
||||
f"{EMU}/advancemame/bin/advmame.sh": ADVMAME_SH,
|
||||
f"{el.LIBRETRO}/mame/scripts/mame.sh": MAME_SH,
|
||||
f"{el.LIBRETRO}/mame/config/ini/mame.ini": MAME_INI,
|
||||
f"{el.LIBRETRO}/mame/config/MAME/MAME.opt": MAME_OPT,
|
||||
f"{EMU}/scummvmsa/bin/scummvm.start": SCUMMVM_START,
|
||||
f"{EMU}/dolphinSA/scripts/dolphin.sh": DOLPHIN_SH,
|
||||
}
|
||||
|
||||
|
||||
def tree(**changes: str | None) -> RepoTree:
|
||||
files = dict(FILES)
|
||||
for path, content in changes.items():
|
||||
if content is None:
|
||||
files.pop(path, None)
|
||||
else:
|
||||
files[path] = content
|
||||
return RepoTree.from_files(files)
|
||||
|
||||
|
||||
def records(extractor, **changes: str | None) -> list[dict]:
|
||||
built = tree(**changes)
|
||||
return [r.entry() for r in extractor(built, el.Runs(built))]
|
||||
|
||||
|
||||
def ref(path: str, lines: str) -> str:
|
||||
return f"{path}:{lines}"
|
||||
|
||||
|
||||
class Dispatch(unittest.TestCase):
|
||||
def test_each_script_is_named_after_the_core_that_starts_it(self):
|
||||
runs = el.Runs(tree())
|
||||
self.assertEqual(runs.emulator("flycast.sh"), "flycastsa")
|
||||
self.assertEqual(runs.emulator("flycastdojo.sh"), "flycast_dojo")
|
||||
self.assertEqual(runs.emulator("m64p.sh"), "rice, glide64mk2")
|
||||
self.assertEqual(runs.emulator("mame.sh"), "mame")
|
||||
self.assertEqual(runs.emulator("freej2me.sh"), "freej2me")
|
||||
|
||||
def test_an_arm_without_a_core_test_names_its_platform(self):
|
||||
self.assertEqual(el.Runs(tree()).emulator("pico8.sh"), "pico8")
|
||||
|
||||
def test_a_script_no_longer_started_raises(self):
|
||||
runs = el.Runs(tree())
|
||||
with self.assertRaises(LauncherPatternMissing):
|
||||
runs.of("gone.sh")
|
||||
|
||||
def test_a_dispatch_without_any_script_raises(self):
|
||||
with self.assertRaises(LauncherPatternMissing):
|
||||
el.Runs(tree(**{el.RUN_EMU: "echo nothing\n"}))
|
||||
|
||||
|
||||
class LibretroRecords(unittest.TestCase):
|
||||
def test_retroarch_system_directory_comes_from_the_sed(self):
|
||||
(record,) = records(el.retroarch)
|
||||
self.assertEqual(record["search_dir"], "/storage/roms/bios")
|
||||
self.assertEqual(record["kind"], "passes_path")
|
||||
self.assertEqual(
|
||||
record["source_ref"], f"{ref(el.RETROARCH_MK, '2')}, {ref(el.RUN_EMU, '80')}"
|
||||
)
|
||||
|
||||
def test_retrorun_is_handed_the_folder_with_d(self):
|
||||
(record,) = records(el.retrorun)
|
||||
self.assertEqual(
|
||||
(record["emulator"], record["search_dir"], record["passes_to"]),
|
||||
("retrorun", "/storage/roms/bios", "retrorun -d"),
|
||||
)
|
||||
self.assertEqual(record["source_ref"], ref(el.RUN_EMU, "4, 81-82"))
|
||||
|
||||
def test_freej2me_gets_java_home_and_its_jar_copied(self):
|
||||
java, jar = records(el.freej2me)
|
||||
self.assertEqual(java["passes_to"], "environment JAVA_HOME")
|
||||
self.assertEqual(java["search_dir"], "/storage/roms/bios/jdk")
|
||||
self.assertEqual(jar["kind"], "copies")
|
||||
self.assertEqual(jar["names"], ["freej2me-lr.jar"])
|
||||
self.assertEqual(jar["passes_to"], "${HOME}/roms/bios")
|
||||
self.assertEqual(jar["depends_on_option"], "/storage/roms/bios/jdk/eeversion exists")
|
||||
|
||||
def test_mame_copies_its_own_files_and_reads_only_path_options(self):
|
||||
found = records(el.mame)
|
||||
copies = [r for r in found if r["kind"] == "copies"]
|
||||
self.assertEqual([r["names"] for r in copies], [["fm*"], ["mame*"]])
|
||||
folders = [(r["passes_to"], r["search_dir"]) for r in found if r["kind"] == "passes_path"]
|
||||
self.assertEqual(
|
||||
folders,
|
||||
[
|
||||
("mame.ini rompath", "/storage/roms/bios/mame/bios"),
|
||||
("mame.ini samplepath", "/storage/roms/bios/mame/samples"),
|
||||
],
|
||||
)
|
||||
self.assertTrue(all(r["depends_on_option"] == "mame_read_config = enabled"
|
||||
for r in found if r["kind"] == "passes_path"))
|
||||
|
||||
def test_mame_without_the_read_config_option_raises(self):
|
||||
with self.assertRaises(LauncherPatternMissing):
|
||||
records(el.mame, **{f"{el.LIBRETRO}/mame/config/MAME/MAME.opt": "x = 1\n"})
|
||||
|
||||
|
||||
class StandaloneRecords(unittest.TestCase):
|
||||
def test_pico8_moves_the_folder_and_runs_the_binary(self):
|
||||
(record,) = records(el.pico8)
|
||||
self.assertEqual(record["kind"], "moves")
|
||||
self.assertEqual(record["names"], ["pico8_dyn"])
|
||||
self.assertEqual(record["search_dir"], "/storage/roms/bios/pico-8")
|
||||
self.assertEqual(record["passes_to"], "/emuelec/bin/pico-8")
|
||||
self.assertEqual(
|
||||
record["source_ref"],
|
||||
f"{ref(el.RUN_EMU, '57-58')}, {ref(f'{el.BIN}/pico8.sh', '1-5, 8-10')}",
|
||||
)
|
||||
|
||||
def test_pico8_without_the_removal_is_not_a_move(self):
|
||||
changed = PICO8_SH.replace(" rm -rf /storage/roms/bios/pico-8\n", "")
|
||||
with self.assertRaises(LauncherPatternMissing):
|
||||
records(el.pico8, **{f"{el.BIN}/pico8.sh": changed})
|
||||
|
||||
def test_links_from_scripts_and_packages(self):
|
||||
found = records(el.links)
|
||||
self.assertEqual(
|
||||
[(r["emulator"], r["search_dir"]) for r in found],
|
||||
[
|
||||
("flycastsa", "/storage/roms/bios/dc"),
|
||||
("flycast_dojo", "/storage/roms/bios/dc_dojo"),
|
||||
("amiberry", "/storage/roms/bios"),
|
||||
("amiberry-lite", "/storage/roms/bios"),
|
||||
("timidity", "/storage/roms/bios/timidity/freepats"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
found[1]["source_ref"],
|
||||
f"{ref(el.RUN_EMU, '26-27')}, "
|
||||
f"{ref(f'{EMU}/flycast-dojo/scripts/flycastdojo.sh', '1-5')}",
|
||||
)
|
||||
self.assertEqual(
|
||||
found[2]["passes_to"], "symlink /usr/config/amiberry/kickstarts -> /storage/roms/bios"
|
||||
)
|
||||
|
||||
def test_a_script_without_a_bios_link_raises(self):
|
||||
with self.assertRaises(LauncherPatternMissing):
|
||||
records(el.links, **{f"{EMU}/flycastsa/scripts/flycast.sh": "flycast \"${1}\"\n"})
|
||||
|
||||
def test_duckstation_scans_the_folder_its_config_names(self):
|
||||
(record,) = records(el.duckstation)
|
||||
self.assertEqual((record["kind"], record["search_dir"]), ("scans_dir", "/storage/roms/bios"))
|
||||
|
||||
def test_duckstation_with_a_fixed_bios_path_raises(self):
|
||||
changed = DUCKSTATION_INI.replace("PathPAL =", "PathPAL = /storage/roms/bios/scph5502.bin")
|
||||
with self.assertRaises(LauncherPatternMissing):
|
||||
records(el.duckstation, **{f"{EMU}/duckstation/config/settings.ini": changed})
|
||||
|
||||
def test_yabasanshiro_passes_the_bios_unless_hle_is_chosen(self):
|
||||
new, old = records(el.yabasanshiro)
|
||||
self.assertEqual((new["emulator"], old["emulator"]), ("yabasanshiroSA", "yabasanshiroSA1_5"))
|
||||
self.assertEqual(new["names"], ["saturn_bios.bin"])
|
||||
self.assertEqual(new["passes_to"], "yabasanshiro -b")
|
||||
self.assertEqual(old["passes_to"], "yabasanshiro1_5 -b")
|
||||
self.assertEqual(new["depends_on_option"], "hlebios != 1")
|
||||
self.assertIs(new["fails_when_missing"], False)
|
||||
|
||||
def test_jzintv_is_handed_the_bios_folder(self):
|
||||
(record,) = records(el.jzintv)
|
||||
self.assertEqual((record["search_dir"], record["passes_to"]), ("/storage/roms/bios", "jzintv -p"))
|
||||
|
||||
def test_mupen64plus_tries_its_ipl_names_in_order(self):
|
||||
(record,) = records(el.mupen64plus)
|
||||
self.assertEqual(record["kind"], "names_searched")
|
||||
self.assertEqual(record["names"], ["N64DD IPLROM (J).n64", "IPL.n64"])
|
||||
self.assertEqual(record["search_dir"], "/storage/roms/bios/Mupen64plus")
|
||||
self.assertEqual(record["passes_to"], "mupen64plus --dd-ipl-rom")
|
||||
self.assertEqual(record["depends_on_option"], '-f "${GAMEDIR}/${GAMENAME}.ndd"')
|
||||
|
||||
def test_mupen64plus_without_a_fallback_raises(self):
|
||||
changed = M64P_SH.replace('[ ! -f "${IPLROM}" ] && IPLROM="${BIOSDIR}/IPL.n64"\n', "")
|
||||
with self.assertRaises(LauncherPatternMissing):
|
||||
records(el.mupen64plus, **{f"{EMU}/mupen64plussa/mupen64plussa-core/m64p.sh": changed})
|
||||
|
||||
def test_hatari_reads_the_tos_named_by_its_default_config(self):
|
||||
(record,) = records(el.hatari)
|
||||
self.assertEqual(record["names"], ["tos.img"])
|
||||
self.assertEqual(
|
||||
record["passes_to"], "Atari-ST-default.cfg [ROM] szTosImageFileName (--configfile)"
|
||||
)
|
||||
|
||||
def test_advancemame_one_record_when_every_device_agrees(self):
|
||||
(record,) = records(el.advancemame)
|
||||
self.assertEqual(record["search_dir"], "/storage/roms/bios/advmame/samples")
|
||||
self.assertNotIn("device", record)
|
||||
|
||||
def test_advancemame_devices_that_disagree_raise(self):
|
||||
changed = ADVMAME_RC.replace("bios/advmame", "bios/other")
|
||||
with self.assertRaises(LauncherPatternMissing):
|
||||
records(el.advancemame, **{f"{EMU}/advancemame/config/advmame.rc_oga": changed})
|
||||
|
||||
def test_scummvm_extrapath_and_the_ini_copied_for_the_core(self):
|
||||
extra, ini = records(el.scummvm)
|
||||
self.assertEqual(extra["passes_to"], "scummvm --extrapath")
|
||||
self.assertEqual(extra["depends_on_option"], "/storage/.config/scummvm/extra exists")
|
||||
self.assertEqual((ini["emulator"], ini["build"]), ("scummvm", "libretro"))
|
||||
self.assertEqual(ini["search_dir"], "/storage/.config/scummvm")
|
||||
self.assertEqual(ini["passes_to"], "/storage/roms/bios/scummvm.ini")
|
||||
|
||||
def test_dolphin_data_home(self):
|
||||
(record,) = records(el.dolphin)
|
||||
self.assertEqual(record["passes_to"], "environment XDG_DATA_HOME")
|
||||
self.assertEqual(record["search_dir"], "/storage/roms/dolphin")
|
||||
|
||||
def test_midi_sources_that_read_the_bios_folder(self):
|
||||
found = records(el.midi)
|
||||
self.assertEqual(
|
||||
[(r["emulator"], r["kind"], r["names"], r["search_dir"]) for r in found],
|
||||
[
|
||||
("fluidsynth", "fixed_name", ["sc/SC-55.sf2"], "/storage/roms/bios"),
|
||||
("mt32d", "passes_path", [], "/roms/bios"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(found[0]["depends_on_option"], "ra_midi_output = fluidsynth")
|
||||
self.assertEqual(
|
||||
found[1]["source_ref"], f"{ref(el.RUN_EMU, '7-9')}, {ref(el.UTILS, '1, 10-11')}"
|
||||
)
|
||||
|
||||
def test_every_extractor_runs_on_the_fixtures(self):
|
||||
found = el.extract_launchers(tree())
|
||||
self.assertEqual(len(found), 26)
|
||||
|
||||
|
||||
class Revision(unittest.TestCase):
|
||||
def test_the_revision_is_the_one_the_bios_list_is_read_at(self):
|
||||
url = (
|
||||
"https://raw.githubusercontent.com/EmuELEC/EmuELEC/v4.8/"
|
||||
"packages/sx05re/emuelec/bin/batocera/batocera-systems"
|
||||
)
|
||||
self.assertEqual(launcher_revision(url), "v4.8")
|
||||
|
||||
def test_a_url_without_a_revision_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
launcher_revision("https://example.org/batocera-systems")
|
||||
|
||||
|
||||
class PlatformRecords(unittest.TestCase):
|
||||
"""The launchers platforms/emuelec.yml carries, as the scraper wrote them."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.config = yaml.safe_load((REPO_ROOT / "platforms" / "emuelec.yml").read_text())
|
||||
|
||||
def test_the_platform_carries_launchers(self):
|
||||
self.assertTrue(self.config.get("launchers"))
|
||||
|
||||
def test_every_reference_is_in_the_emuelec_packages(self):
|
||||
for record in self.config["launchers"]:
|
||||
for part in record["source_ref"].split(", "):
|
||||
if ":" in part:
|
||||
self.assertTrue(part.startswith(f"{el.SX05RE}/"), record)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -50,6 +50,55 @@ class TheGuideLeadsToTheFewestFiles(unittest.TestCase):
|
||||
self.assertTrue(target.is_file() or (target / "index.html").is_file(), url)
|
||||
|
||||
|
||||
class TheGuideDrivesTheInstaller(unittest.TestCase):
|
||||
"""What an agent needs to run the installer for a user: the package
|
||||
command, the plan as JSON, the exit codes, and plan, consent, act. Each
|
||||
fact is held to install.py, so the guide cannot drift from it."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
import generate_installer_doc # noqa: PLC0415
|
||||
|
||||
self.doc = generate_installer_doc
|
||||
self.text = siteagents.llms_txt([("retroarch", "RetroArch")])
|
||||
self.section = self.text.split("\n## Driving the installer for a user\n", 1)[1].split("\n## ", 1)[0]
|
||||
|
||||
def test_the_exit_codes_are_the_installer_page_table(self) -> None:
|
||||
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
|
||||
table = page.split("<!-- generated: exit-codes -->", 1)[1].split("<!-- /generated: exit-codes -->")[0]
|
||||
in_table = [int(code) for code in re.findall(r"^\| `(\d+)` \|", table, re.M)]
|
||||
self.assertEqual(in_table, self.doc.exit_codes())
|
||||
self.assertEqual([code for code, _ in siteagents.EXIT_CODES], in_table)
|
||||
listed = [int(code) for code in re.findall(r"^ - `(\d+)`: ", self.section, re.M)]
|
||||
self.assertEqual(listed, in_table)
|
||||
|
||||
def test_the_event_schema_is_the_installers(self) -> None:
|
||||
import json # noqa: PLC0415
|
||||
|
||||
self.assertEqual(siteagents.EVENT_SCHEMA, self.doc._install.EVENT_SCHEMA)
|
||||
schema = json.loads((REPO_ROOT / "schemas" / "install-events.schema.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(schema["$id"], siteagents.SITE + siteagents.EVENTS_SCHEMA_PATH)
|
||||
self.assertIn(f"event schema {siteagents.EVENT_SCHEMA} ", self.section)
|
||||
|
||||
def test_every_option_and_event_named_exists(self) -> None:
|
||||
options = {flag for action in self.doc._install.build_parser()._actions
|
||||
for flag in action.option_strings}
|
||||
for flag in set(re.findall(r"(?<![\w-])(--[a-z][a-z-]*)", self.section)):
|
||||
with self.subTest(flag=flag):
|
||||
self.assertIn(flag, options)
|
||||
keys = self.doc._install.EVENT_KEYS
|
||||
self.assertIn("state", keys["file"])
|
||||
self.assertIn("bytes_needed", keys["check"])
|
||||
self.assertIn("pending", keys["summary"])
|
||||
self.assertEqual(tuple(keys["end"]), ("code",))
|
||||
|
||||
def test_the_package_commands_and_the_pattern(self) -> None:
|
||||
self.assertIn("`uvx retrobios --for-games --required-only --dry-run`", self.section)
|
||||
self.assertIn("`pipx run retrobios`", self.section)
|
||||
self.assertIn("`--dry-run --events`", self.section)
|
||||
steps = re.findall(r"^\d\. (\w+)", self.section, re.M)
|
||||
self.assertEqual(steps, ["Plan", "Consent", "Act"])
|
||||
|
||||
|
||||
class TheSkillIsAnAgentSkill(unittest.TestCase):
|
||||
"""ARD reserves text/markdown for agent skills (Lighthouse warns on a
|
||||
plain Markdown entry): the setup steps are published as one."""
|
||||
|
||||
@@ -41,6 +41,7 @@ START_PAGES = {
|
||||
# Reference pages written for anyone, held to the Start here budgets.
|
||||
START_LEVEL_PAGES = {
|
||||
"wiki/measurements.md": 300,
|
||||
"wiki/archive-recipes.md": 160,
|
||||
}
|
||||
# Level 1: the guide part of each setup page, above its Reference heading.
|
||||
SETUP_CEILING = 220
|
||||
|
||||
@@ -956,7 +956,8 @@ class EventsTranscript(unittest.TestCase):
|
||||
self.assertEqual(list(event), ["event", *keys[event["event"]]])
|
||||
self.assertEqual(events[0]["event"], "start")
|
||||
self.assertEqual(events[0]["mode"], "check")
|
||||
self.assertEqual(events[-1], {"event": "end", "code": 1})
|
||||
# --check that finds files missing ends on 3 (the Exit status table).
|
||||
self.assertEqual(events[-1], {"event": "end", "code": 3})
|
||||
|
||||
|
||||
class OneOptionsTable(unittest.TestCase):
|
||||
|
||||
Reference in new issue
Block a user