mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-06 01:53:56 -06:00
chore: merge tooling batch
This commit is contained in:
commit
b90f8bb0b9
116 files changed
+12868
-9741
No files matched your search
+2
-1
@@ -24,7 +24,8 @@ the most useful contribution.
|
||||
|
||||
From a clone, the file goes to `bios/Manufacturer/Console/filename`, and
|
||||
`python scripts/validate_pr.py path/to/file` names that folder for a file still
|
||||
outside `bios/`. A file over the browser's 25 MB limit goes in that way.
|
||||
outside `bios/`. Given a folder, such as an old card's BIOS folder, it checks
|
||||
every file under it. A file over the browser's 25 MB limit goes in that way.
|
||||
|
||||
## Add a platform
|
||||
|
||||
|
||||
@@ -81,12 +81,12 @@ These are the files an emulator loads from disk instead of carrying inside itsel
|
||||
|
||||
Each file is checked the way your platform checks it. Most compare a checksum, the fingerprint of a file's contents, which catches a corrupt or unexpected copy. RetroArch, Lakka and RetroPie check that the file is present under its name without reading its contents (`path_is_valid` in RetroArch's `core_info.c`), and MinUI, NextUI and MyMinUI hand each core its folder without opening a file in it: the Coverage table says which applies to you. Independently of that, the collection records five fingerprints per file, and wherever an emulator's code states an expected size or hash, that value is read from its source and rechecked here.
|
||||
|
||||
- **1,087 files** the platforms' emulators load are still to be found, and 138 more the collection does not hold and does not serve by name (retail data, per-user keys, user-filled slots), each with its reason. Both are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/)
|
||||
- **1,086 files** the platforms' emulators load are still to be found, and 138 more the collection does not hold and does not serve by name (retail data, per-user keys, user-filled slots), each with its reason. Both are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/)
|
||||
- **18 platforms** supported with platform-specific verification
|
||||
- **623 emulators** profiled from source (RetroArch cores + standalone)
|
||||
- **532 systems** handled by those emulators (NES, SNES, PlayStation, Saturn, Dreamcast, ...)
|
||||
- **19,943 files in the collection**, each with its SHA1, MD5, SHA256, CRC32 and Adler-32 fingerprints: 5,740 system files, 2,975 arcade ROM sets, 11,228 game and engine data files. That is every platform and emulator together, so no pack holds them all: each carries what its own emulators load, counted in the download table
|
||||
- **566 of 5,740 system files** matched to dump-preservation catalogs (No-Intro, Redump, TOSEC). Arcade sets and engine data fall outside what those catalogs index
|
||||
- **19,944 files in the collection**, each with its SHA1, MD5, SHA256, CRC32 and Adler-32 fingerprints: 5,741 system files, 2,975 arcade ROM sets, 11,228 game and engine data files. That is every platform and emulator together, so no pack holds them all: each carries what its own emulators load, counted in the download table
|
||||
- **566 of 5,741 system files** matched to dump-preservation catalogs (No-Intro, Redump, TOSEC). Arcade sets and engine data fall outside what those catalogs index
|
||||
- **15.7 GB** for the whole collection
|
||||
|
||||
## Supported systems
|
||||
@@ -99,23 +99,23 @@ Full list with per-file details: **[https://abdess.github.io/retrobios/](https:/
|
||||
|
||||
| Platform | On its BIOS list | Files its emulators load | Checked by |
|
||||
|----------|-----------------:|-------------------------:|------------|
|
||||
| AmberELEC | 414/434 | 4,751/4,935 | MD5 |
|
||||
| Batocera | 353/353 | 14,172/15,292 | MD5 |
|
||||
| AmberELEC | 414/434 | 4,752/4,935 | MD5 |
|
||||
| Batocera | 353/353 | 14,173/15,292 | MD5 |
|
||||
| BizHawk | 118/118 | - | SHA-1 |
|
||||
| EmuDeck | 168/168 | 8,426/8,529 | MD5 |
|
||||
| EmuDeck | 168/168 | 8,427/8,529 | MD5 |
|
||||
| EmuELEC | 544/572 | 4,786/10,215 | MD5 |
|
||||
| KNULLI | 290/290 | 9,127/15,350 | MD5 |
|
||||
| Lakka | 532/532 | 7,909/7,995 | name only |
|
||||
| Lakka | 532/532 | 7,910/7,995 | name only |
|
||||
| MiSTer FPGA | 81/81 | - | MD5 |
|
||||
| MinUI | 0/0 | 78/79 | name only |
|
||||
| MyMinUI | 0/0 | 356/367 | name only |
|
||||
| NextUI | 0/0 | 343/350 | name only |
|
||||
| ROCKNIX | 38/38 | 7,425/9,138 | MD5 |
|
||||
| ROCKNIX | 38/38 | 7,426/9,138 | MD5 |
|
||||
| Recalbox | 351/351 | 3,125/3,344 | MD5 |
|
||||
| RetroArch | 526/526 | 7,918/7,986 | name only |
|
||||
| RetroBat | 348/348 | 5,134/9,659 | MD5 |
|
||||
| RetroDECK | 2,007/2,008 | 6,705/6,993 | MD5 |
|
||||
| RetroPie * | 532/532 | 8,119/12,774 | name only |
|
||||
| RetroArch | 526/526 | 7,919/7,986 | name only |
|
||||
| RetroBat | 348/348 | 5,135/9,659 | MD5 |
|
||||
| RetroDECK | 2,007/2,008 | 6,706/6,993 | MD5 |
|
||||
| RetroPie * | 532/532 | 8,120/12,774 | name only |
|
||||
| RomM | 383/383 | 83/873 | size, then any hash |
|
||||
|
||||
Each fraction is what the pack has over what is needed, counting required and optional files alike since both ship. The first column is the BIOS list the platform publishes. The second counts files its emulators load that this list never mentions, found by reading their source code, and it is routinely several times larger. A short fraction means files are still missing, and they are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/).
|
||||
@@ -206,4 +206,4 @@ The scripts and tooling are released under the [MIT License](LICENSE).
|
||||
The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed.
|
||||
The reasoning, and where it is weakest, is in [the project's legal position](https://abdess.github.io/retrobios/wiki/legal/).
|
||||
|
||||
*Auto-generated on 2026-11-05T13:36:39Z*
|
||||
*Auto-generated on 2026-11-05T14:56:27Z*
|
||||
+8
-2
@@ -77,12 +77,18 @@ jobs:
|
||||
Snapshots refreshed by refresh_stale from the MAME and FBNeo DATs
|
||||
check_freshness reported stale, behind a green offline pipeline.
|
||||
paths: [recipes/*.json]
|
||||
- subject: "chore: refresh game catalogs"
|
||||
body: >-
|
||||
Catalogs imported again by refresh_stale from the No-Intro and
|
||||
Redump DATs check_freshness reported stale, behind a green offline
|
||||
pipeline.
|
||||
paths: [games/catalogs/*/*.json]
|
||||
- subject: "chore: regenerate collection records"
|
||||
body: >-
|
||||
Regenerated by the offline pipeline, database.json and the
|
||||
manifests computed twice with the same result.
|
||||
paths: [database.json, install/*.json, install/targets/*.json, README.md,
|
||||
CONTRIBUTING.md, website/src/data/*.json]
|
||||
paths: [database.json, install/*.json, install/targets/*.json, install/games/*.json,
|
||||
install/games/*/*.json, README.md, CONTRIBUTING.md, website/src/data/*.json]
|
||||
scratch: [dist, tmp/autorun]
|
||||
|
||||
bios-integrity:
|
||||
|
||||
Binary file not shown.
+25
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": "2026-11-05T12:13:24Z",
|
||||
"total_files": 19943,
|
||||
"total_size": 16892630154,
|
||||
"generated_at": "2026-11-05T14:56:18Z",
|
||||
"total_files": 19944,
|
||||
"total_size": 16892646538,
|
||||
"bios_dir": "bios",
|
||||
"files": {
|
||||
"738c7f5fd0c8b57ee2e87ae7a97bf8e21a821d07": {
|
||||
@@ -41982,6 +41982,16 @@
|
||||
"crc32": "596ab6d6",
|
||||
"adler32": "1c992583"
|
||||
},
|
||||
"ecb999ac1cc2fcca29bae71195741c451e747824": {
|
||||
"path": "bios/Enterprise/64-128/ide12.rom",
|
||||
"name": "ide12.rom",
|
||||
"size": 16384,
|
||||
"sha1": "ecb999ac1cc2fcca29bae71195741c451e747824",
|
||||
"md5": "ba8854e55fa32e932645d57003e4632b",
|
||||
"sha256": "31fce2d3927f40e4b33a729138f721ad45fe6051a366c19314bc6142a583f566",
|
||||
"crc32": "157ab74c",
|
||||
"adler32": "984dafb8"
|
||||
},
|
||||
"abf119cf947ea32defd08b29a8a25d75f6bd4987": {
|
||||
"path": "bios/Enterprise/64-128/tvc22_ext.rom",
|
||||
"name": "tvc22_ext.rom",
|
||||
@@ -208769,6 +208779,7 @@
|
||||
"80115adf36136f30b23c10ea2dd83bef": "c6241e1c248193108ce38b9a8e9dd33972cf47ba",
|
||||
"55af78f877a21ca45eb2df68a74fcc60": "cf12e971623a54bf8c4f891ca3a36d969f205c49",
|
||||
"22167938f142c222f40992839aa21a06": "325a5e28c2a0d896711f8829e7ff14fed5dd4103",
|
||||
"ba8854e55fa32e932645d57003e4632b": "ecb999ac1cc2fcca29bae71195741c451e747824",
|
||||
"5ce95a26ceed5bec73995d83568da9cf": "abf119cf947ea32defd08b29a8a25d75f6bd4987",
|
||||
"8c54285f541930cde766069942bad0f2": "f2572ee83d09fc08f4de4a62f101c8bb301a9505",
|
||||
"88dc7876d584f90e4106f91444ab23b7": "072c6160d4e7d406f5d8f5b1b66066c797d35561",
|
||||
@@ -236642,6 +236653,9 @@
|
||||
"hun.rom": [
|
||||
"325a5e28c2a0d896711f8829e7ff14fed5dd4103"
|
||||
],
|
||||
"ide12.rom": [
|
||||
"ecb999ac1cc2fcca29bae71195741c451e747824"
|
||||
],
|
||||
"tvc22_ext.rom": [
|
||||
"abf119cf947ea32defd08b29a8a25d75f6bd4987"
|
||||
],
|
||||
@@ -286713,6 +286727,7 @@
|
||||
"24838410": "c6241e1c248193108ce38b9a8e9dd33972cf47ba",
|
||||
"c099a5e3": "cf12e971623a54bf8c4f891ca3a36d969f205c49",
|
||||
"596ab6d6": "325a5e28c2a0d896711f8829e7ff14fed5dd4103",
|
||||
"157ab74c": "ecb999ac1cc2fcca29bae71195741c451e747824",
|
||||
"05e1c3a8": "abf119cf947ea32defd08b29a8a25d75f6bd4987",
|
||||
"79fa818c": "f2572ee83d09fc08f4de4a62f101c8bb301a9505",
|
||||
"1466aed4": "072c6160d4e7d406f5d8f5b1b66066c797d35561",
|
||||
@@ -306657,6 +306672,7 @@
|
||||
"7d4be98066acc92ad87afac84b5c3165a1e336b77ce6e002cc031296769de18f": "c6241e1c248193108ce38b9a8e9dd33972cf47ba",
|
||||
"eb81a76dc76f7d026bc4a8c216ee9c5ee717d8b8bd210d3a0a5d48e4c90009a3": "cf12e971623a54bf8c4f891ca3a36d969f205c49",
|
||||
"0226f77f7ed03d01a5c606dc42249e740d7768909d0f1a32cf78f6c9e71f7452": "325a5e28c2a0d896711f8829e7ff14fed5dd4103",
|
||||
"31fce2d3927f40e4b33a729138f721ad45fe6051a366c19314bc6142a583f566": "ecb999ac1cc2fcca29bae71195741c451e747824",
|
||||
"d35394333f2da0d5d597483d4c4af06b2492a74d0451b58ce3dad48bb5fdcfab": "abf119cf947ea32defd08b29a8a25d75f6bd4987",
|
||||
"72d17d1af7845a12e88985bb8f491a81b8657a659ecf1dc4d8028449f7318da6": "f2572ee83d09fc08f4de4a62f101c8bb301a9505",
|
||||
"eb61023d15285c9bf35f7aefe87affa9e04c11bc13be6a7ec88813a93d79a63a": "072c6160d4e7d406f5d8f5b1b66066c797d35561",
|
||||
@@ -355192,6 +355208,12 @@
|
||||
"64-128/hun.rom": [
|
||||
"325a5e28c2a0d896711f8829e7ff14fed5dd4103"
|
||||
],
|
||||
"Enterprise/64-128/ide12.rom": [
|
||||
"ecb999ac1cc2fcca29bae71195741c451e747824"
|
||||
],
|
||||
"64-128/ide12.rom": [
|
||||
"ecb999ac1cc2fcca29bae71195741c451e747824"
|
||||
],
|
||||
"Enterprise/64-128/tvc22_ext.rom": [
|
||||
"abf119cf947ea32defd08b29a8a25d75f6bd4987"
|
||||
],
|
||||
|
||||
@@ -197,6 +197,7 @@ files:
|
||||
description: Enterprise IDE controller ROM v1.2
|
||||
path: ep128emu/roms/ide12.rom
|
||||
system: enterprise-128
|
||||
size: 16384
|
||||
required: false
|
||||
note: "Loaded into segment 0x42 for hard disk content (.vhd, or a disk image whose header marks a hard disk). roms.hpp holds no built-in copy, so without the file the core throws \"ROM file not found!\" and that content does not start."
|
||||
source_ref: "source core/core.cpp:309-322, source core/core.cpp:537-546, source core/main.cpp:1055-1062, source core/main.cpp:1081-1084"
|
||||
|
||||
+16
-21
@@ -16,7 +16,7 @@
|
||||
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
|
||||
# the site's build rewrites this line there (scripts/sitebootstrap.py).
|
||||
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
$defaultInstallSha256 = "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a"
|
||||
$defaultInstallSha256 = "f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
@@ -68,35 +68,30 @@
|
||||
$installer = $temporary
|
||||
}
|
||||
|
||||
# The py launcher first, then python3 and python. The Microsoft Store
|
||||
# places a python.exe that only points at the Store: it fails the
|
||||
# version check like a missing one.
|
||||
$findPython = {
|
||||
$found = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($found) { return ,@($found, @("-3")) }
|
||||
foreach ($name in @("python3", "python")) {
|
||||
$found = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($found) { return ,@($found, @()) }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$usable = {
|
||||
param($candidate)
|
||||
if (-not $candidate) { return $false }
|
||||
$arguments = $candidate[1]
|
||||
& $candidate[0].Source @arguments -c "import sys, ssl, unicodedata, zlib; raise SystemExit(sys.version_info < (3, 8))" 2>$null | Out-Null
|
||||
& $candidate[0].Source @arguments -c "import sys; raise SystemExit(sys.version_info < (3, 8))" | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
# The py launcher first, then python3 and python, the first that
|
||||
# passes the check: a py launcher left with no usable runtime hid a
|
||||
# Python 3.12 on PATH. The Microsoft Store places a python.exe that
|
||||
# only points at the Store: it fails the check like a missing one.
|
||||
$findPython = {
|
||||
$candidates = @()
|
||||
$found = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($found) { $candidates += ,@($found, @("-3")) }
|
||||
foreach ($name in @("python3", "python")) {
|
||||
$found = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($found) { $candidates += ,@($found, @()) }
|
||||
}
|
||||
foreach ($candidate in $candidates) {
|
||||
if (& $usable $candidate) { return ,$candidate }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$needPython = "Python 3.8 or newer is required. Install it with " +
|
||||
"'winget install Python.Python.3.12' or from https://www.python.org/downloads/ " +
|
||||
"(tick 'Add python.exe to PATH'), then open a new PowerShell window and run this command again."
|
||||
$candidate = & $findPython
|
||||
if (-not $candidate) {
|
||||
if (-not (& $usable $candidate)) {
|
||||
# winget ships with Windows 10 and 11: at a console the bootstrap
|
||||
# offers to run the install the message names, then carries on.
|
||||
$winget = Get-Command winget -ErrorAction SilentlyContinue
|
||||
@@ -114,7 +109,7 @@
|
||||
$candidate = & $findPython
|
||||
}
|
||||
}
|
||||
if (-not $candidate) { throw $needPython }
|
||||
if (-not (& $usable $candidate)) { throw $needPython }
|
||||
}
|
||||
$python = $candidate[0]
|
||||
$launcher = $candidate[1]
|
||||
|
||||
+225
-259
File diff suppressed because it is too large.
Load diff
+3
-53
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
|
||||
# curl or wget stop on a cut transfer before the hash is read: the site's
|
||||
# build rewrites this line there (scripts/sitebootstrap.py).
|
||||
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
DEFAULT_INSTALL_SHA256="993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a"
|
||||
DEFAULT_INSTALL_SHA256="f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
@@ -121,29 +121,14 @@ if [ -z "$INSTALLER" ] || [ ! -f "$INSTALLER" ]; then
|
||||
INSTALLER="$TEMP_INSTALLER"
|
||||
fi
|
||||
|
||||
# A Python can be built without ssl, unicodedata or zlib, and the installer
|
||||
# needs all three: a python3 lacking ssl was chosen over a full python and
|
||||
# every download then failed as if the network were down.
|
||||
find_python() {
|
||||
PYTHON=""
|
||||
PYTHON_LACKING=""
|
||||
PYTHON_OLD=""
|
||||
for command_name in python3 python; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || continue
|
||||
if ! "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' \
|
||||
2>/dev/null; then
|
||||
found_version=$("$command_name" -c \
|
||||
'import sys; print("%d.%d.%d" % tuple(sys.version_info[:3]))' 2>/dev/null)
|
||||
if [ -n "$found_version" ]; then
|
||||
PYTHON_OLD=${PYTHON_OLD:-"$command_name here is $found_version"}
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if "$command_name" -c 'import ssl, unicodedata, zlib' 2>/dev/null; then
|
||||
if command -v "$command_name" >/dev/null 2>&1 \
|
||||
&& "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' 2>/dev/null; then
|
||||
PYTHON=$command_name
|
||||
return 0
|
||||
fi
|
||||
PYTHON_LACKING=${PYTHON_LACKING:-$command_name}
|
||||
done
|
||||
return 1
|
||||
}
|
||||
@@ -187,20 +172,6 @@ if ! find_python; then
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
if [ -z "$PYTHON" ] && [ -n "$PYTHON_LACKING" ]; then
|
||||
echo "Error: $PYTHON_LACKING is built without a module the installer needs" >&2
|
||||
echo " (ssl, unicodedata or zlib). Run it with a full Python 3.8 or newer." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$PYTHON" ] && [ -n "$PYTHON_OLD" ]; then
|
||||
# Debian Buster ships 3.7: apt answers that python3 is already the newest
|
||||
# version, and the same advice looped.
|
||||
echo "Error: Python 3.8 or newer is required, and $PYTHON_OLD." >&2
|
||||
echo " Install a newer one: from this system's packages if they offer one, by" >&2
|
||||
echo " updating the system, or from https://www.python.org/downloads/." >&2
|
||||
echo " Then run this command again." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$PYTHON" ]; then
|
||||
echo "Error: Python 3.8 or newer is required." >&2
|
||||
if [ -n "$install_command" ]; then
|
||||
@@ -212,27 +183,6 @@ if [ -z "$PYTHON" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Termux reaches shared storage, where RetroArch keeps its system folder,
|
||||
# once termux-setup-storage has run: the installer told the user to run it
|
||||
# and start again. At a terminal the bootstrap runs it here. It only asks
|
||||
# the Termux app, which shows Android's permission dialog and creates
|
||||
# ~/storage once allowed: the wait gives the user time to answer.
|
||||
case "${TERMUX_VERSION:-}:${PREFIX:-}" in
|
||||
?*:*|*com.termux*)
|
||||
if [ ! -d "$HOME/storage" ] && command -v termux-setup-storage >/dev/null 2>&1 \
|
||||
&& (: </dev/tty) 2>/dev/null; then
|
||||
echo "Android asks for the storage permission: allow it, so that the files reach" >/dev/tty
|
||||
echo "the RetroArch folder." >/dev/tty
|
||||
termux-setup-storage </dev/tty >/dev/tty 2>&1 || true
|
||||
waited=0
|
||||
while [ ! -d "$HOME/storage" ] && [ "$waited" -lt 60 ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Piped from curl, standard input is this script, not the terminal: the
|
||||
# installer would skip every question it can ask. Hand it the terminal when
|
||||
# there is one.
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"arcade":{"fbneo":{"file":"arcade/fbneo.json","gzip_size":115955,"sha256":"88961c9a6be804772baa767f6ee3a9162478c30c60fe55c8363d8fe4fbc909da","size":423362},"flycast":{"file":"arcade/flycast.json","gzip_size":1841,"sha256":"957189a6e993fda08e21c90ffc6ad94aed9ca9e6dd9ed8c7724e29db036791cb","size":5441},"mame":{"file":"arcade/mame.json","gzip_size":154527,"sha256":"b12010301293a218ecfac45706d81a26c3ac0c27eebeca7409bc8f6ce0163e9e","size":604648},"mame0258":{"file":"arcade/mame0258.json","gzip_size":141151,"sha256":"7ecff1979ed2ac123ea3ba4825dde6e2c31906415165fabefc9af93fa48a6de4","size":556059},"mame0278":{"file":"arcade/mame0278.json","gzip_size":148595,"sha256":"7881e3516f451ec83e886aaf41ebac9d60f497e1f954f49749d52890d7955cbf","size":582797},"mame2000":{"file":"arcade/mame2000.json","gzip_size":8624,"sha256":"f0d69b0872ecfc2845eeb7304543e5b17c11db82060b5514c206b6962ed08263","size":23316},"mame2003":{"file":"arcade/mame2003.json","gzip_size":17456,"sha256":"3b44e2a92c80a8f104b7781fcc1c776d025ad2cf4986638efa3ea041baa9a56d","size":48929},"mame2003_plus":{"file":"arcade/mame2003_plus.json","gzip_size":21077,"sha256":"2857283556fa49c20d8160ed113465bf0c2e643ed0b8acd2d38266a78be8e25e","size":62373},"mame2010":{"file":"arcade/mame2010.json","gzip_size":30869,"sha256":"50b1b381b4b07463f69f1f4c512dd1c98a78d4d0e9c3aeaf49b7b1b007b42152","size":96528},"mame2015":{"file":"arcade/mame2015.json","gzip_size":86990,"sha256":"afeb8c32f651421f74c6de057b0c0b837acd14c533b5160bb31f4411e0afffec","size":360117},"mame2016":{"file":"arcade/mame2016.json","gzip_size":101249,"sha256":"1f75147977d9d082a38b8f8e47a02b301f3d6e6db97c9b9282f3e7f27f26ee47","size":406190}},"containers":{"7z":{"description":"Per-file CRCs and sizes sit in the header at the end of the archive, LZMA-compressed by default (kEncodedHeader). One LZMA decode of the header, independent of the archive size, gives them. Python's lzma is an optional module.","re_read":true,"signature":{"bytes_hex":"377abcaf271c","offset":0},"source_ref":"ip7z/7zip@b88c6bc0 DOC/7zFormat.txt:171-189, DOC/7zFormat.txt:318-334, CPP/7zip/Archive/7z/7zHandlerOut.cpp:859"},"cd-sector":{"description":"User data of a CD sector. In a 2048-byte image (.iso) sector N starts at N * 2048. In a raw 2352-byte image it starts at N * 2352 plus 16 for Mode 1 and plus 24 for Mode 2 Form 1 (12 sync bytes, a 4-byte header, and for Mode 2 an 8-byte subheader).","re_read":true,"sector_size":{"iso":2048,"raw":2352},"source_ref":"stenzek/duckstation@526b4a12 src/util/iso_reader.cpp:157-171, src/util/cd_image.h:28-34","user_data_offset":{"mode1":16,"mode2":24}},"chd-v5":{"description":"MAME compressed hunks, big-endian. The header holds sizes, codecs and hashes only, never the system or the region. The overall SHA-1 is what MAME software lists record. Neither SHA-1 equals a Redump track hash: chdman hashes 2448-byte frames (subcode included, zero-filled), pads each track to 4 frames and byte-swaps audio, all tracks in one stream.","fields":{"compressors":{"encoding":"fourcc4","length":16,"offset":16},"header_length":{"encoding":"u32be","length":4,"offset":8,"value":124},"hunk_bytes":{"encoding":"u32be","length":4,"offset":56},"logical_bytes":{"encoding":"u64be","length":8,"offset":32},"map_offset":{"encoding":"u64be","length":8,"offset":40},"metadata_offset":{"encoding":"u64be","length":8,"offset":48},"overall_sha1":{"encoding":"hex","length":20,"offset":84},"parent_sha1":{"encoding":"hex","length":20,"offset":104},"raw_sha1":{"encoding":"hex","length":20,"offset":64},"unit_bytes":{"encoding":"u32be","length":4,"offset":60},"version":{"encoding":"u32be","length":4,"offset":12,"value":5}},"magic":{"bytes":"MComprHD","offset":0},"media_tags":{"CHCD":"cd","CHGD":"gd-rom","CHGT":"gd-rom","CHT2":"cd","CHTR":"cd","DVD ":"dvd","GDDD":"hard-disk"},"metadata_entry":{"description":"16-byte entry header, payload follows. Linked list from metadata_offset.","flags":{"encoding":"u8","length":1,"offset":4},"length":{"encoding":"u24be","length":3,"offset":5},"next":{"encoding":"u64be","length":8,"offset":8},"tag":{"encoding":"ascii","length":4,"offset":0}},"re_read":true,"source_ref":"mamedev/mame@f34f0250 src/lib/util/chd.h:148-162, src/lib/util/chd.cpp:2021-2061, src/lib/util/chd.cpp:2822-2831, src/lib/util/chd.h:212-235, src/lib/util/chd.cpp:1709-1749, src/tools/chdman.cpp:443-445, src/tools/chdman.cpp:2195-2203, src/lib/util/cdrom.cpp:2669-2672, src/emu/romload.cpp:1211"},"gamecube-disc":{"description":"Where disc offset 0 sits inside each container Dolphin opens.","formats":{"ciso":{"disc_header_at":32768,"magic":"CISO"},"gcz":{"disc_header_at":null,"magic_u32le":2970337281,"note":"block 0 is zlib unless bit 63 of its pointer is set"},"iso":{"disc_header_at":0,"magic":null},"rvz":{"disc_header_at":88,"magic":"RVZ\u0001","note":"first 0x80 disc bytes copied into header 2"},"wbfs":{"disc_header_at":null,"magic":"WBFS","note":"disc offset 0 at wlba[0] << wbfs_sector_shift"},"wia":{"disc_header_at":88,"magic":"WIA\u0001","note":Line truncated
|
||||
{"arcade":{"fbneo":{"file":"arcade/fbneo.json","gzip_size":115955,"sha256":"88961c9a6be804772baa767f6ee3a9162478c30c60fe55c8363d8fe4fbc909da","size":423362},"flycast":{"file":"arcade/flycast.json","gzip_size":1841,"sha256":"957189a6e993fda08e21c90ffc6ad94aed9ca9e6dd9ed8c7724e29db036791cb","size":5441},"mame":{"file":"arcade/mame.json","gzip_size":154527,"sha256":"b12010301293a218ecfac45706d81a26c3ac0c27eebeca7409bc8f6ce0163e9e","size":604648},"mame0258":{"file":"arcade/mame0258.json","gzip_size":141151,"sha256":"7ecff1979ed2ac123ea3ba4825dde6e2c31906415165fabefc9af93fa48a6de4","size":556059},"mame0278":{"file":"arcade/mame0278.json","gzip_size":148595,"sha256":"7881e3516f451ec83e886aaf41ebac9d60f497e1f954f49749d52890d7955cbf","size":582797},"mame2000":{"file":"arcade/mame2000.json","gzip_size":8624,"sha256":"f0d69b0872ecfc2845eeb7304543e5b17c11db82060b5514c206b6962ed08263","size":23316},"mame2003":{"file":"arcade/mame2003.json","gzip_size":17456,"sha256":"3b44e2a92c80a8f104b7781fcc1c776d025ad2cf4986638efa3ea041baa9a56d","size":48929},"mame2003_plus":{"file":"arcade/mame2003_plus.json","gzip_size":21077,"sha256":"2857283556fa49c20d8160ed113465bf0c2e643ed0b8acd2d38266a78be8e25e","size":62373},"mame2010":{"file":"arcade/mame2010.json","gzip_size":30869,"sha256":"50b1b381b4b07463f69f1f4c512dd1c98a78d4d0e9c3aeaf49b7b1b007b42152","size":96528},"mame2015":{"file":"arcade/mame2015.json","gzip_size":86990,"sha256":"afeb8c32f651421f74c6de057b0c0b837acd14c533b5160bb31f4411e0afffec","size":360117},"mame2016":{"file":"arcade/mame2016.json","gzip_size":101249,"sha256":"1f75147977d9d082a38b8f8e47a02b301f3d6e6db97c9b9282f3e7f27f26ee47","size":406190}},"containers":{"7z":{"description":"Per-file CRCs and sizes sit in the header at the end of the archive, LZMA-compressed by default (kEncodedHeader). One LZMA decode of the header, independent of the archive size, gives them. Python's lzma is an optional module.","re_read":true,"signature":{"bytes_hex":"377abcaf271c","offset":0},"source_ref":"ip7z/7zip@b88c6bc0 DOC/7zFormat.txt:171-189, DOC/7zFormat.txt:318-334, CPP/7zip/Archive/7z/7zHandlerOut.cpp:859"},"cd-sector":{"description":"User data of a CD sector. In a 2048-byte image (.iso) sector N starts at N * 2048. In a raw 2352-byte image it starts at N * 2352 plus 16 for Mode 1 and plus 24 for Mode 2 Form 1 (12 sync bytes, a 4-byte header, and for Mode 2 an 8-byte subheader).","re_read":true,"sector_size":{"iso":2048,"raw":2352},"source_ref":"stenzek/duckstation@526b4a12 src/util/iso_reader.cpp:157-171, src/util/cd_image.h:28-34","user_data_offset":{"mode1":16,"mode2":24}},"chd-v5":{"description":"MAME compressed hunks, big-endian. The header holds sizes, codecs and hashes only, never the system or the region. The overall SHA-1 is what MAME software lists record. Neither SHA-1 equals a Redump track hash: chdman hashes 2448-byte frames (subcode included, zero-filled), pads each track to 4 frames and byte-swaps audio, all tracks in one stream.","fields":{"compressors":{"encoding":"fourcc4","length":16,"offset":16},"header_length":{"encoding":"u32be","length":4,"offset":8,"value":124},"hunk_bytes":{"encoding":"u32be","length":4,"offset":56},"logical_bytes":{"encoding":"u64be","length":8,"offset":32},"map_offset":{"encoding":"u64be","length":8,"offset":40},"metadata_offset":{"encoding":"u64be","length":8,"offset":48},"overall_sha1":{"encoding":"hex","length":20,"offset":84},"parent_sha1":{"encoding":"hex","length":20,"offset":104},"raw_sha1":{"encoding":"hex","length":20,"offset":64},"unit_bytes":{"encoding":"u32be","length":4,"offset":60},"version":{"encoding":"u32be","length":4,"offset":12,"value":5}},"magic":{"bytes":"MComprHD","offset":0},"media_tags":{"CHCD":"cd","CHGD":"gd-rom","CHGT":"gd-rom","CHT2":"cd","CHTR":"cd","DVD ":"dvd","GDDD":"hard-disk"},"metadata_entry":{"description":"16-byte entry header, payload follows. Linked list from metadata_offset.","flags":{"encoding":"u8","length":1,"offset":4},"length":{"encoding":"u24be","length":3,"offset":5},"next":{"encoding":"u64be","length":8,"offset":8},"tag":{"encoding":"ascii","length":4,"offset":0}},"re_read":true,"source_ref":"mamedev/mame@f34f0250 src/lib/util/chd.h:148-162, src/lib/util/chd.cpp:2021-2061, src/lib/util/chd.cpp:2822-2831, src/lib/util/chd.h:212-235, src/lib/util/chd.cpp:1709-1749, src/tools/chdman.cpp:443-445, src/tools/chdman.cpp:2195-2203, src/lib/util/cdrom.cpp:2669-2672, src/emu/romload.cpp:1211"},"gamecube-disc":{"description":"Where disc offset 0 sits inside each container Dolphin opens.","formats":{"ciso":{"disc_header_at":32768,"magic":"CISO"},"gcz":{"disc_header_at":null,"magic_u32le":2970337281,"note":"block 0 is zlib unless bit 63 of its pointer is set"},"iso":{"disc_header_at":0,"magic":null},"rvz":{"disc_header_at":88,"magic":"RVZ\u0001","note":"first 0x80 disc bytes copied into header 2"},"wbfs":{"disc_header_at":null,"magic":"WBFS","note":"disc offset 0 at wlba[0] << wbfs_sector_shift"},"wia":{"disc_header_at":88,"magic":"WIA\u0001","note":Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"case_sensitive":null,"folders":[{"basis":"id","core":"opera","emulator":"retroarch","extensions":[".bin",".chd",".cue",".iso"],"fallback":["panasonic-3do|opera"],"folder":"3do","profile":"opera","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:5","systems":["panasonic-3do"]},{"basis":"native name of batocera","core":"advision","emulator":"mame","extensions":[".7z",".bin",".zip"],"fallback":["entex-advision|"],"folder":"advision","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:25","systems":["entex-advision"]},{"basis":"id","core":"puae2021","emulator":"retroarch","extensions":[".adf",".adz",".dms",".hdf",".hdz",".ipf",".lha",".m3u",".uae",".zip"],"fallback":["commodore-amiga|puae2021"],"folder":"amiga","profile":"puae2021","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:45","systems":["commodore-amiga"]},{"basis":"normalized id","core":"puae2021","emulator":"retroarch","extensions":[".chd",".cue",".iso",".lha"],"fallback":["commodore-amiga-cd32|puae2021"],"folder":"amigacd32","profile":"puae2021","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:72","systems":["commodore-amiga-cd32"]},{"basis":"normalized id","core":"crocods","emulator":"retroarch","extensions":[".7z",".cdt",".dsk",".kcr",".m3u",".sna",".tap",".voc",".zip"],"fallback":["amstrad-cpc|crocods"],"folder":"amstradcpc","profile":"crocods","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:94","systems":["amstrad-cpc"]},{"arcade":"mame2003_plus","basis":"id","core":"mame2003_plus","emulator":"retroarch","extensions":[".7z",".zip"],"fallback":["arcade|mame2003_plus"],"folder":"arcade","profile":"mame2003_plus","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:116","systems":["arcade"]},{"basis":"id","core":"arduous","emulator":"retroarch","extensions":[".hex"],"fallback":["arduboy|arduous"],"folder":"arduboy","profile":"arduous","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:153","systems":["arduboy"]},{"basis":"normalized id","core":"stella2014","emulator":"retroarch","extensions":[".7z",".a26",".bin",".zip"],"fallback":["atari-2600|stella2014"],"folder":"atari2600","profile":"stella2014","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:173","systems":["atari-2600"]},{"basis":"native name of amberelec","core":"a5200","emulator":"retroarch","extensions":[".7z",".a52",".atr",".atx",".bin",".car",".cas",".cdm",".rom",".xex",".xfd",".zip"],"fallback":["atari-5200|a5200"],"folder":"atari5200","profile":"a5200","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:200","systems":["atari-5200"]},{"basis":"id","core":"prosystem","emulator":"retroarch","extensions":[".7z",".a78",".bin",".zip"],"fallback":["atari-7800|prosystem"],"folder":"atari7800","profile":"prosystem","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:221","systems":["atari-7800"]},{"basis":"native name of amberelec","core":"atari800","emulator":"retroarch","extensions":[".7z",".a52",".atr",".atx",".bin",".car",".cas",".cdm",".rom",".xex",".xfd",".zip"],"fallback":["atari-400-800|atari800"],"folder":"atari800","profile":"atari800","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:247","systems":["atari-400-800"]},{"basis":"native name of retrodeck","core":"virtualjaguar","emulator":"retroarch","extensions":[".7z",".j64",".jag",".zip"],"fallback":["atari-jaguar|virtualjaguar"],"folder":"atarijaguar","profile":"virtualjaguar","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:292","systems":["atari-jaguar"]},{"basis":"native name of amberelec","core":"handy","emulator":"retroarch","extensions":[".7z",".lnx",".o",".zip"],"fallback":["atari-lynx|handy"],"folder":"atarilynx","profile":"handy","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:1043","systems":["atari-lynx"]},{"basis":"native name of amberelec","core":"hatari","emulator":"retroarch","extensions":[".7z",".dim",".ipf",".m3u",".msa",".st",".stx",".zip"],"fallback":["atari-st|hatari"],"folder":"atarist","profile":"hatari","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:267","systems":["atari-st"]},{"arcade":true,"basis":"native name of amberelec","core":"flycast2021le","emulator":"retrorun","extensions":[".7z",".bin",".dat",".lst",".zip"],"fallback":["sega-dreamcast-arcade|flycast2021le"],"folder":"atomiswave","profile":"flycast2021le","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:317","systems":["sega-dreamcast-arcade"]},{"basis":"systems of raze","core":"raze","emulator":"raze","extensions":[".build"],"fallback":["blood|raze","duke3d|raze","exhumed|raze","nam|raze","powerslave|raze","redneck|raze","shadow-warrior|raze","ww2gi|raze"],"folder":"build","profile":"raze","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:2855","systems":["blood","duke3d","exhumed","nam","powerslave","redneck","shadow-warrior","ww2gi"]},{"basis":"normalized idLine truncated
|
||||
{"case_sensitive":null,"folders":[{"basis":"id","core":"opera","emulator":"retroarch","extensions":[".bin",".chd",".cue",".iso"],"fallback":["panasonic-3do|opera"],"folder":"3do","profile":"opera","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:5","systems":["panasonic-3do"]},{"basis":"native name of batocera","core":"advision","emulator":"mame","extensions":[".7z",".bin",".zip"],"fallback":["entex-advision|"],"folder":"advision","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:25","systems":["entex-advision"]},{"basis":"id","core":"puae2021","emulator":"retroarch","extensions":[".adf",".adz",".dms",".hdf",".hdz",".ipf",".lha",".m3u",".uae",".zip"],"fallback":["commodore-amiga|puae2021"],"folder":"amiga","profile":"puae2021","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:45","systems":["commodore-amiga"]},{"basis":"normalized id","core":"puae2021","emulator":"retroarch","extensions":[".chd",".cue",".iso",".lha"],"fallback":["commodore-amiga-cd32|puae2021"],"folder":"amigacd32","profile":"puae2021","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:72","systems":["commodore-amiga-cd32"]},{"basis":"normalized id","core":"crocods","emulator":"retroarch","extensions":[".7z",".cdt",".dsk",".kcr",".m3u",".sna",".tap",".voc",".zip"],"fallback":["amstrad-cpc|crocods"],"folder":"amstradcpc","profile":"crocods","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:94","systems":["amstrad-cpc"]},{"arcade":"mame2003_plus","basis":"id","core":"mame2003_plus","emulator":"retroarch","extensions":[".7z",".zip"],"fallback":["arcade|mame2003_plus"],"folder":"arcade","profile":"mame2003_plus","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:116","systems":["arcade"]},{"basis":"id","core":"arduous","emulator":"retroarch","extensions":[".hex"],"fallback":["arduboy|arduous"],"folder":"arduboy","profile":"arduous","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:153","systems":["arduboy"]},{"basis":"normalized id","core":"stella2014","emulator":"retroarch","extensions":[".7z",".a26",".bin",".zip"],"fallback":["atari-2600|stella2014"],"folder":"atari2600","profile":"stella2014","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:173","systems":["atari-2600"]},{"basis":"native name of amberelec","core":"a5200","emulator":"retroarch","extensions":[".7z",".a52",".atr",".atx",".bin",".car",".cas",".cdm",".rom",".xex",".xfd",".zip"],"fallback":["atari-5200|a5200"],"folder":"atari5200","profile":"a5200","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:200","systems":["atari-5200"]},{"basis":"id","core":"prosystem","emulator":"retroarch","extensions":[".7z",".a78",".bin",".zip"],"fallback":["atari-7800|prosystem"],"folder":"atari7800","profile":"prosystem","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:221","systems":["atari-7800"]},{"basis":"native name of amberelec","core":"atari800","emulator":"retroarch","extensions":[".7z",".a52",".atr",".atx",".bin",".car",".cas",".cdm",".rom",".xex",".xfd",".zip"],"fallback":["atari-400-800|atari800"],"folder":"atari800","profile":"atari800","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:247","systems":["atari-400-800"]},{"basis":"native name of retrodeck","core":"virtualjaguar","emulator":"retroarch","extensions":[".7z",".j64",".jag",".zip"],"fallback":["atari-jaguar|virtualjaguar"],"folder":"atarijaguar","profile":"virtualjaguar","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:292","systems":["atari-jaguar"]},{"basis":"native name of amberelec","core":"handy","emulator":"retroarch","extensions":[".7z",".lnx",".o",".zip"],"fallback":["atari-lynx|handy"],"folder":"atarilynx","profile":"handy","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:1043","systems":["atari-lynx"]},{"basis":"native name of amberelec","core":"hatari","emulator":"retroarch","extensions":[".7z",".dim",".ipf",".m3u",".msa",".st",".stx",".zip"],"fallback":["atari-st|hatari"],"folder":"atarist","profile":"hatari","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:267","systems":["atari-st"]},{"arcade":true,"basis":"native name of amberelec","core":"flycast2021le","emulator":"retrorun","extensions":[".7z",".bin",".dat",".lst",".zip"],"fallback":["sega-dreamcast-arcade|flycast2021le"],"folder":"atomiswave","profile":"flycast2021le","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:317","systems":["sega-dreamcast-arcade"]},{"basis":"systems of raze","core":"raze","emulator":"raze","extensions":[".build"],"fallback":["blood|raze","duke3d|raze","exhumed|raze","nam|raze","powerslave|raze","redneck|raze","shadow-warrior|raze","ww2gi|raze"],"folder":"build","profile":"raze","source_ref":"os packages/ui/emulationstation/config/es_systems.cfg:2855","systems":["blood","duke3d","exhumed","nam","powerslave","redneck","shadow-warrior","ww2gi"]},{"basis":"normalized idLine truncated
|
||||
@@ -27,8 +27,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1093946,
|
||||
"sha256": "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a",
|
||||
"size": 1089187,
|
||||
"sha256": "f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -71,8 +71,8 @@
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 1147573,
|
||||
"sha256": "e0bc43ae6a7ce3e0db3b19392eba9dc1561db3a0c95bfe3933dd7c870bdf19a8"
|
||||
"size": 1142814,
|
||||
"sha256": "090c40f23a9c087641c9835a79a0d4181fc702865ce20e5115fe5a7ca72a31fc"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
@@ -92,8 +92,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1093946,
|
||||
"sha256": "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a",
|
||||
"size": 1089187,
|
||||
"sha256": "f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1093946,
|
||||
"sha256": "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a",
|
||||
"size": 1089187,
|
||||
"sha256": "f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
|
||||
+11
-115
@@ -511,9 +511,7 @@
|
||||
"cap32",
|
||||
"cdogs",
|
||||
"dosbox_pure",
|
||||
"duckstation",
|
||||
"easyrpg",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -526,7 +524,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -543,7 +540,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"imame4all",
|
||||
"jazz2-native",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
"mame078plus",
|
||||
@@ -601,10 +597,8 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x16emu",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -628,8 +622,8 @@
|
||||
"dosbox_pure",
|
||||
"dosbox_staging",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -641,7 +635,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -657,7 +650,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -730,7 +722,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -742,7 +733,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -767,9 +757,9 @@
|
||||
"dosbox_pure",
|
||||
"dosbox_staging",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"ecwolf",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -781,7 +771,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -790,7 +779,6 @@
|
||||
"genesisplusgx-expanded",
|
||||
"gsplus",
|
||||
"gw",
|
||||
"gzdoom",
|
||||
"handy",
|
||||
"hatari",
|
||||
"hcl",
|
||||
@@ -798,8 +786,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"iortcw",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -809,7 +795,6 @@
|
||||
"mednafen_ngp",
|
||||
"mednafen_supergrafx",
|
||||
"mednafen_wswan",
|
||||
"melonds",
|
||||
"mgba",
|
||||
"minivmac",
|
||||
"moonlight",
|
||||
@@ -841,7 +826,6 @@
|
||||
"px68k",
|
||||
"pygame",
|
||||
"quasi88",
|
||||
"raze",
|
||||
"reminiscence",
|
||||
"samcoupe",
|
||||
"same_cdi",
|
||||
@@ -875,7 +859,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -887,7 +870,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -1252,9 +1234,7 @@
|
||||
"devilutionx",
|
||||
"dice",
|
||||
"dosbox_pure",
|
||||
"duckstation",
|
||||
"easyrpg",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -1266,7 +1246,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -1282,7 +1261,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"mame078plus",
|
||||
@@ -1346,7 +1324,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -1358,7 +1335,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -1385,9 +1361,9 @@
|
||||
"dosbox_staging",
|
||||
"dosboxx",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"ecwolf",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -1400,7 +1376,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -1409,7 +1384,6 @@
|
||||
"genesisplusgx-expanded",
|
||||
"gsplus",
|
||||
"gw",
|
||||
"gzdoom",
|
||||
"handy",
|
||||
"hatari",
|
||||
"hcl",
|
||||
@@ -1417,8 +1391,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"iortcw",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -1429,7 +1401,6 @@
|
||||
"mednafen_psx",
|
||||
"mednafen_supergrafx",
|
||||
"mednafen_wswan",
|
||||
"melonds",
|
||||
"mgba",
|
||||
"minivmac",
|
||||
"moonlight",
|
||||
@@ -1461,7 +1432,6 @@
|
||||
"px68k",
|
||||
"pygame",
|
||||
"quasi88",
|
||||
"raze",
|
||||
"reminiscence",
|
||||
"samcoupe",
|
||||
"same_cdi",
|
||||
@@ -1495,7 +1465,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -1507,7 +1476,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -1534,9 +1502,9 @@
|
||||
"dosbox_staging",
|
||||
"dosboxx",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"ecwolf",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -1549,7 +1517,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -1558,15 +1525,12 @@
|
||||
"genesisplusgx-expanded",
|
||||
"gsplus",
|
||||
"gw",
|
||||
"gzdoom",
|
||||
"handy",
|
||||
"hatari",
|
||||
"hcl",
|
||||
"holani",
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"iortcw",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -1577,7 +1541,6 @@
|
||||
"mednafen_psx",
|
||||
"mednafen_supergrafx",
|
||||
"mednafen_wswan",
|
||||
"melonds",
|
||||
"mgba",
|
||||
"minivmac",
|
||||
"moonlight",
|
||||
@@ -1609,7 +1572,6 @@
|
||||
"px68k",
|
||||
"pygame",
|
||||
"quasi88",
|
||||
"raze",
|
||||
"reminiscence",
|
||||
"samcoupe",
|
||||
"same_cdi",
|
||||
@@ -1643,7 +1605,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -1655,7 +1616,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -2273,8 +2233,8 @@
|
||||
"dosbox_staging",
|
||||
"dosboxx",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -2286,7 +2246,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -2302,7 +2261,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -2377,7 +2335,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -2389,7 +2346,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -2587,9 +2543,7 @@
|
||||
"dice",
|
||||
"dosbox_pure",
|
||||
"dosbox_staging",
|
||||
"duckstation",
|
||||
"easyrpg",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -2601,7 +2555,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -2616,7 +2569,6 @@
|
||||
"holani",
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -2691,7 +2643,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -2703,7 +2654,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -3017,9 +2967,9 @@
|
||||
"dosbox_staging",
|
||||
"dosboxx",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"ecwolf",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -3031,7 +2981,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -3040,7 +2989,6 @@
|
||||
"genesisplusgx-expanded",
|
||||
"gsplus",
|
||||
"gw",
|
||||
"gzdoom",
|
||||
"handy",
|
||||
"hatari",
|
||||
"hcl",
|
||||
@@ -3048,8 +2996,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"iortcw",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -3060,7 +3006,6 @@
|
||||
"mednafen_psx",
|
||||
"mednafen_supergrafx",
|
||||
"mednafen_wswan",
|
||||
"melonds",
|
||||
"mgba",
|
||||
"minivmac",
|
||||
"moonlight",
|
||||
@@ -3094,7 +3039,6 @@
|
||||
"px68k",
|
||||
"pygame",
|
||||
"quasi88",
|
||||
"raze",
|
||||
"reminiscence",
|
||||
"samcoupe",
|
||||
"same_cdi",
|
||||
@@ -3128,7 +3072,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -3140,7 +3083,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -3823,9 +3765,7 @@
|
||||
"cap32",
|
||||
"cdogs",
|
||||
"dosbox_pure",
|
||||
"duckstation",
|
||||
"easyrpg",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -3838,7 +3778,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -3855,7 +3794,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"imame4all",
|
||||
"jazz2-native",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
"mame078plus",
|
||||
@@ -3913,10 +3851,8 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x16emu",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -3941,9 +3877,9 @@
|
||||
"dosbox_pure",
|
||||
"dosbox_staging",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"ecwolf",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -3955,7 +3891,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -3964,7 +3899,6 @@
|
||||
"genesisplusgx-expanded",
|
||||
"gsplus",
|
||||
"gw",
|
||||
"gzdoom",
|
||||
"handy",
|
||||
"hatari",
|
||||
"hcl",
|
||||
@@ -3972,8 +3906,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"iortcw",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -3983,7 +3915,6 @@
|
||||
"mednafen_ngp",
|
||||
"mednafen_supergrafx",
|
||||
"mednafen_wswan",
|
||||
"melonds",
|
||||
"mgba",
|
||||
"minivmac",
|
||||
"moonlight",
|
||||
@@ -4015,7 +3946,6 @@
|
||||
"px68k",
|
||||
"pygame",
|
||||
"quasi88",
|
||||
"raze",
|
||||
"reminiscence",
|
||||
"samcoupe",
|
||||
"same_cdi",
|
||||
@@ -4049,7 +3979,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -4061,7 +3990,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -4078,9 +4006,7 @@
|
||||
"cap32",
|
||||
"cdogs",
|
||||
"dosbox_pure",
|
||||
"duckstation",
|
||||
"easyrpg",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -4093,7 +4019,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -4110,7 +4035,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"imame4all",
|
||||
"jazz2-native",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
"mame078plus",
|
||||
@@ -4168,10 +4092,8 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x16emu",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -4195,8 +4117,8 @@
|
||||
"dosbox_pure",
|
||||
"dosbox_staging",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -4208,7 +4130,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -4224,7 +4145,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -4297,7 +4217,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -4309,7 +4228,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -4334,9 +4252,9 @@
|
||||
"dosbox_pure",
|
||||
"dosbox_staging",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"ecwolf",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -4348,7 +4266,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -4357,7 +4274,6 @@
|
||||
"genesisplusgx-expanded",
|
||||
"gsplus",
|
||||
"gw",
|
||||
"gzdoom",
|
||||
"handy",
|
||||
"hatari",
|
||||
"hcl",
|
||||
@@ -4365,8 +4281,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"iortcw",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -4376,7 +4290,6 @@
|
||||
"mednafen_ngp",
|
||||
"mednafen_supergrafx",
|
||||
"mednafen_wswan",
|
||||
"melonds",
|
||||
"mgba",
|
||||
"minivmac",
|
||||
"moonlight",
|
||||
@@ -4408,7 +4321,6 @@
|
||||
"px68k",
|
||||
"pygame",
|
||||
"quasi88",
|
||||
"raze",
|
||||
"reminiscence",
|
||||
"samcoupe",
|
||||
"same_cdi",
|
||||
@@ -4442,7 +4354,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -4454,7 +4365,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -5166,8 +5076,8 @@
|
||||
"dosbox_staging",
|
||||
"dosboxx",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -5179,7 +5089,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -5195,7 +5104,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -5270,7 +5178,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -5282,7 +5189,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -5309,9 +5215,9 @@
|
||||
"dosbox_staging",
|
||||
"dosboxx",
|
||||
"duckstation",
|
||||
"duckstation-legacy",
|
||||
"easyrpg",
|
||||
"ecwolf",
|
||||
"eduke32",
|
||||
"emuscv",
|
||||
"ep128emu-core",
|
||||
"fake08",
|
||||
@@ -5323,7 +5229,6 @@
|
||||
"fmsx",
|
||||
"freechaf",
|
||||
"freeintv",
|
||||
"fury",
|
||||
"fuse",
|
||||
"gambatte",
|
||||
"gearcoleco",
|
||||
@@ -5332,7 +5237,6 @@
|
||||
"genesisplusgx-expanded",
|
||||
"gsplus",
|
||||
"gw",
|
||||
"gzdoom",
|
||||
"handy",
|
||||
"hatari",
|
||||
"hcl",
|
||||
@@ -5340,8 +5244,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ioquake3",
|
||||
"iortcw",
|
||||
"jazz2-native",
|
||||
"lightspark",
|
||||
"lowresnx",
|
||||
"lutro",
|
||||
@@ -5352,7 +5254,6 @@
|
||||
"mednafen_psx",
|
||||
"mednafen_supergrafx",
|
||||
"mednafen_wswan",
|
||||
"melonds",
|
||||
"mgba",
|
||||
"minivmac",
|
||||
"moonlight",
|
||||
@@ -5384,7 +5285,6 @@
|
||||
"px68k",
|
||||
"pygame",
|
||||
"quasi88",
|
||||
"raze",
|
||||
"reminiscence",
|
||||
"samcoupe",
|
||||
"same_cdi",
|
||||
@@ -5418,7 +5318,6 @@
|
||||
"vice_xplus4",
|
||||
"vice_xscpu64",
|
||||
"vice_xvic",
|
||||
"vitaquake2",
|
||||
"x1",
|
||||
"x128",
|
||||
"x16emu",
|
||||
@@ -5430,7 +5329,6 @@
|
||||
"xrick",
|
||||
"xscpu64",
|
||||
"xvic",
|
||||
"yabasanshiro",
|
||||
"yquake2",
|
||||
"zc210"
|
||||
],
|
||||
@@ -6638,7 +6536,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ikemen",
|
||||
"ioquake3",
|
||||
"iortcw",
|
||||
"jazz2-native",
|
||||
"kronos",
|
||||
@@ -6849,7 +6746,6 @@
|
||||
"hurrican",
|
||||
"hypseus-singe",
|
||||
"ikemen",
|
||||
"ioquake3",
|
||||
"iortcw",
|
||||
"jazz2-native",
|
||||
"kronos",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -73,37 +73,37 @@
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_psx_hw_libretro.so.zip","file":"mednafen_psx_hw_libretro.so","last_modified":"Tue, 27 Oct 2026 05:06:00 GMT","sha256":"d16c889c9f12bd3265edc3c9d4b0e6e24c1f244d1d8d475235f6a74e65e5b10b"},"emulator":"beetle_psx","error":null,"files_opened":[{"created":false,"entry":"scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-46d676fbc1ea","install":"pack","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T21:58:05+00:00","records":["2026-10-27-01436f3dd1dd"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"a2d30046f2903132fe478661edbbf75c7d2a4767"},"staged":24,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1280 shows Now Loading with the yellow bubble","frame":1280,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"d51ab7783f8eeafc63a28f14ce39f8e2babeffb3","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/pcsx_rearmed_libretro.so.zip","file":"pcsx_rearmed_libretro.so","last_modified":"Sun, 25 Oct 2026 06:43:04 GMT","sha256":"bfda3f667f8ea8c2f5cc939b970628174b6f491ad32d7f6b143728322df46af6"},"emulator":"pcsx_rearmed","error":null,"files_opened":[{"created":false,"entry":"scph5500.bin","path":"scph5500.bin","sha1":"b05def971d8ec59f346f2d9ac21fb742e3eb6917"},{"created":false,"entry":"scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"},{"created":false,"entry":"scph5502.bin","path":"scph5502.bin","sha1":"f6bc2d1f5eb6593de7d089c425ac681d6fffd3f0"}],"findings":[],"id":"2026-10-27-74b70e46cfb2","install":"pack","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:00:07+00:00","records":["2026-10-27-258389f4d7f9"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5"},"staged":22,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 530 shows Now Loading with the yellow bubble","frame":530,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/swanstation_libretro.so.zip","file":"swanstation_libretro.so","last_modified":"Tue, 27 Oct 2026 05:20:50 GMT","sha256":"fc65bb2d662c3ad762b611a62ac578f78efd260431d3b8c090f0eb40887a77ea"},"emulator":"swanstation","error":null,"files_opened":[{"created":false,"entry":"scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-774320c8f4c5","install":"pack","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:06:57+00:00","records":["2026-10-27-d87a2259783d"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5"},"staged":160,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1300 shows Now Loading with the yellow bubble","frame":1300,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_psx_libretro.so.zip","file":"mednafen_psx_libretro.so","last_modified":"Tue, 27 Oct 2026 05:05:54 GMT","sha256":"a89200443c0afa594e85e5e77630921d5e5b04ebf9c499597f3e6f1c947b0c87"},"emulator":"beetle_psx","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-c853b2b6c77e","install":"installer","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:17:16+00:00","records":["2026-10-27-fb040cbacf85"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retroarch/installer/system/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":106},"staged":24,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1280 shows Now Loading with the yellow bubble","frame":1280,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"d51ab7783f8eeafc63a28f14ce39f8e2babeffb3","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_psx_hw_libretro.so.zip","file":"mednafen_psx_hw_libretro.so","last_modified":"Tue, 27 Oct 2026 05:06:00 GMT","sha256":"d16c889c9f12bd3265edc3c9d4b0e6e24c1f244d1d8d475235f6a74e65e5b10b"},"emulator":"beetle_psx","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-9d8798e6604f","install":"installer","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:18:42+00:00","records":["2026-10-27-cd0e6a27851a"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retroarch/installer/system/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":106},"staged":24,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1280 shows Now Loading with the yellow bubble","frame":1280,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"d51ab7783f8eeafc63a28f14ce39f8e2babeffb3","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/pcsx_rearmed_libretro.so.zip","file":"pcsx_rearmed_libretro.so","last_modified":"Sun, 25 Oct 2026 06:43:04 GMT","sha256":"bfda3f667f8ea8c2f5cc939b970628174b6f491ad32d7f6b143728322df46af6"},"emulator":"pcsx_rearmed","error":null,"files_opened":[{"created":false,"entry":"files:scph5500.bin","path":"scph5500.bin","sha1":"b05def971d8ec59f346f2d9ac21fb742e3eb6917"},{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"},{"created":false,"entry":"files:scph5502.bin","path":"scph5502.bin","sha1":"f6bc2d1f5eb6593de7d089c425ac681d6fffd3f0"}],"findings":[],"id":"2026-10-27-bf3b62c9fedc","install":"installer","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:19:50+00:00","records":["2026-10-27-eb48362178b4"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retroarch/installer/system/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":106},"staged":22,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 530 shows Now Loading with the yellow bubble","frame":530,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/swanstation_libretro.so.zip","file":"swanstation_libretro.so","last_modified":"Tue, 27 Oct 2026 05:20:50 GMT","sha256":"fc65bb2d662c3ad762b611a62ac578f78efd260431d3b8c090f0eb40887a77ea"},"emulator":"swanstation","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-6467f3050a22","install":"installer","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:21:13+00:00","records":["2026-10-27-e48762f18c7c"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retroarch/installer/system/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":106},"staged":160,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1300 shows Now Loading with the yellow bubble","frame":1300,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_psx_libretro.so.zip","file":"mednafen_psx_libretro.so","last_modified":"Tue, 27 Oct 2026 05:05:54 GMT","sha256":"a89200443c0afa594e85e5e77630921d5e5b04ebf9c499597f3e6f1c947b0c87"},"emulator":"beetle_psx","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-4a0ee7b5837b","install":"installer","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:17:16+00:00","records":["2026-10-27-fb040cbacf85"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into <dest>/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":106},"staged":24,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1280 shows Now Loading with the yellow bubble","frame":1280,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"d51ab7783f8eeafc63a28f14ce39f8e2babeffb3","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_psx_hw_libretro.so.zip","file":"mednafen_psx_hw_libretro.so","last_modified":"Tue, 27 Oct 2026 05:06:00 GMT","sha256":"d16c889c9f12bd3265edc3c9d4b0e6e24c1f244d1d8d475235f6a74e65e5b10b"},"emulator":"beetle_psx","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-188e2e1ff7f7","install":"installer","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:18:42+00:00","records":["2026-10-27-cd0e6a27851a"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into <dest>/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":106},"staged":24,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1280 shows Now Loading with the yellow bubble","frame":1280,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"d51ab7783f8eeafc63a28f14ce39f8e2babeffb3","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/pcsx_rearmed_libretro.so.zip","file":"pcsx_rearmed_libretro.so","last_modified":"Sun, 25 Oct 2026 06:43:04 GMT","sha256":"bfda3f667f8ea8c2f5cc939b970628174b6f491ad32d7f6b143728322df46af6"},"emulator":"pcsx_rearmed","error":null,"files_opened":[{"created":false,"entry":"files:scph5500.bin","path":"scph5500.bin","sha1":"b05def971d8ec59f346f2d9ac21fb742e3eb6917"},{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"},{"created":false,"entry":"files:scph5502.bin","path":"scph5502.bin","sha1":"f6bc2d1f5eb6593de7d089c425ac681d6fffd3f0"}],"findings":[],"id":"2026-10-27-b8c814c954f6","install":"installer","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:19:50+00:00","records":["2026-10-27-eb48362178b4"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into <dest>/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":106},"staged":22,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 530 shows Now Loading with the yellow bubble","frame":530,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/swanstation_libretro.so.zip","file":"swanstation_libretro.so","last_modified":"Tue, 27 Oct 2026 05:20:50 GMT","sha256":"fc65bb2d662c3ad762b611a62ac578f78efd260431d3b8c090f0eb40887a77ea"},"emulator":"swanstation","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-706540046d36","install":"installer","platform":"retroarch","platform_system":"sony-playstation","platform_version":"v1.22.2","probed_at":"2026-10-27T22:21:13+00:00","records":["2026-10-27-e48762f18c7c"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into <dest>/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":106},"staged":160,"system":"sony-playstation","target":"linux-x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1300 shows Now Loading with the yellow bubble","frame":1300,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_psx_libretro.so.zip","file":"mednafen_psx_libretro.so","last_modified":"Tue, 27 Oct 2026 05:05:54 GMT","sha256":"a89200443c0afa594e85e5e77630921d5e5b04ebf9c499597f3e6f1c947b0c87"},"emulator":"beetle_psx","error":null,"files_opened":[{"created":false,"entry":"scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-35f5003364b6","install":"pack","platform":"batocera","platform_system":"sony-playstation","platform_version":"43.1","probed_at":"2026-10-27T22:23:32+00:00","records":["2026-10-27-0959ceac43e0"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5"},"staged":10,"system":"sony-playstation","target":"x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1280 shows Now Loading with the yellow bubble","frame":1280,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"d51ab7783f8eeafc63a28f14ce39f8e2babeffb3","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/pcsx_rearmed_libretro.so.zip","file":"pcsx_rearmed_libretro.so","last_modified":"Sun, 25 Oct 2026 06:43:04 GMT","sha256":"bfda3f667f8ea8c2f5cc939b970628174b6f491ad32d7f6b143728322df46af6"},"emulator":"pcsx_rearmed","error":null,"files_opened":[{"created":false,"entry":"scph5500.bin","path":"scph5500.bin","sha1":"b05def971d8ec59f346f2d9ac21fb742e3eb6917"},{"created":false,"entry":"scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"},{"created":false,"entry":"scph5502.bin","path":"scph5502.bin","sha1":"f6bc2d1f5eb6593de7d089c425ac681d6fffd3f0"}],"findings":[],"id":"2026-10-27-2e558db8013d","install":"pack","platform":"batocera","platform_system":"sony-playstation","platform_version":"43.1","probed_at":"2026-10-27T22:24:58+00:00","records":["2026-10-27-9e53883a5566"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5"},"staged":7,"system":"sony-playstation","target":"x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 530 shows Now Loading with the yellow bubble","frame":530,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/swanstation_libretro.so.zip","file":"swanstation_libretro.so","last_modified":"Tue, 27 Oct 2026 05:20:50 GMT","sha256":"fc65bb2d662c3ad762b611a62ac578f78efd260431d3b8c090f0eb40887a77ea"},"emulator":"swanstation","error":null,"files_opened":[{"created":false,"entry":"scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-44d39a8db0d9","install":"pack","platform":"batocera","platform_system":"sony-playstation","platform_version":"43.1","probed_at":"2026-10-27T22:25:46+00:00","records":["2026-10-27-bc55bc2a98c6"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5"},"staged":258,"system":"sony-playstation","target":"x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1300 shows Now Loading with the yellow bubble","frame":1300,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_psx_libretro.so.zip","file":"mednafen_psx_libretro.so","last_modified":"Tue, 27 Oct 2026 05:05:54 GMT","sha256":"a89200443c0afa594e85e5e77630921d5e5b04ebf9c499597f3e6f1c947b0c87"},"emulator":"beetle_psx","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-e11f3e5b3130","install":"installer","platform":"batocera","platform_system":"sony-playstation","platform_version":"43.1","probed_at":"2026-10-27T22:27:45+00:00","records":["2026-10-27-f2d677e3e4fc"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/Source/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":23},"staged":10,"system":"sony-playstation","target":"x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1280 shows Now Loading with the yellow bubble","frame":1280,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"d51ab7783f8eeafc63a28f14ce39f8e2babeffb3","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/pcsx_rearmed_libretro.so.zip","file":"pcsx_rearmed_libretro.so","last_modified":"Sun, 25 Oct 2026 06:43:04 GMT","sha256":"bfda3f667f8ea8c2f5cc939b970628174b6f491ad32d7f6b143728322df46af6"},"emulator":"pcsx_rearmed","error":null,"files_opened":[{"created":false,"entry":"files:scph5500.bin","path":"scph5500.bin","sha1":"b05def971d8ec59f346f2d9ac21fb742e3eb6917"},{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"},{"created":false,"entry":"files:scph5502.bin","path":"scph5502.bin","sha1":"f6bc2d1f5eb6593de7d089c425ac681d6fffd3f0"}],"findings":[],"id":"2026-10-27-9af6c3543ef3","install":"installer","platform":"batocera","platform_system":"sony-playstation","platform_version":"43.1","probed_at":"2026-10-27T22:28:53+00:00","records":["2026-10-27-77ae5a4a34e4"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/Source/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":23},"staged":7,"system":"sony-playstation","target":"x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 530 shows Now Loading with the yellow bubble","frame":530,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/swanstation_libretro.so.zip","file":"swanstation_libretro.so","last_modified":"Tue, 27 Oct 2026 05:20:50 GMT","sha256":"fc65bb2d662c3ad762b611a62ac578f78efd260431d3b8c090f0eb40887a77ea"},"emulator":"swanstation","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-5ca58f55ce23","install":"installer","platform":"batocera","platform_system":"sony-playstation","platform_version":"43.1","probed_at":"2026-10-27T22:30:09+00:00","records":["2026-10-27-c17d693b47cf"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/Source/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":23},"staged":258,"system":"sony-playstation","target":"x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1300 shows Now Loading with the yellow bubble","frame":1300,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_psx_libretro.so.zip","file":"mednafen_psx_libretro.so","last_modified":"Tue, 27 Oct 2026 05:05:54 GMT","sha256":"a89200443c0afa594e85e5e77630921d5e5b04ebf9c499597f3e6f1c947b0c87"},"emulator":"beetle_psx","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-c47c2647917e","install":"installer","platform":"batocera","platform_system":"sony-playstation","platform_version":"43.1","probed_at":"2026-10-27T22:27:45+00:00","records":["2026-10-27-f2d677e3e4fc"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, <dest>/Source/, <dest>/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":23},"staged":10,"system":"sony-playstation","target":"x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1280 shows Now Loading with the yellow bubble","frame":1280,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"d51ab7783f8eeafc63a28f14ce39f8e2babeffb3","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/pcsx_rearmed_libretro.so.zip","file":"pcsx_rearmed_libretro.so","last_modified":"Sun, 25 Oct 2026 06:43:04 GMT","sha256":"bfda3f667f8ea8c2f5cc939b970628174b6f491ad32d7f6b143728322df46af6"},"emulator":"pcsx_rearmed","error":null,"files_opened":[{"created":false,"entry":"files:scph5500.bin","path":"scph5500.bin","sha1":"b05def971d8ec59f346f2d9ac21fb742e3eb6917"},{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"},{"created":false,"entry":"files:scph5502.bin","path":"scph5502.bin","sha1":"f6bc2d1f5eb6593de7d089c425ac681d6fffd3f0"}],"findings":[],"id":"2026-10-27-328db80ddaba","install":"installer","platform":"batocera","platform_system":"sony-playstation","platform_version":"43.1","probed_at":"2026-10-27T22:28:53+00:00","records":["2026-10-27-77ae5a4a34e4"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, <dest>/Source/, <dest>/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":23},"staged":7,"system":"sony-playstation","target":"x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 530 shows Now Loading with the yellow bubble","frame":530,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Bust-A-Move 2 - Arcade Edition (USA)","region":"north-america"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/swanstation_libretro.so.zip","file":"swanstation_libretro.so","last_modified":"Tue, 27 Oct 2026 05:20:50 GMT","sha256":"fc65bb2d662c3ad762b611a62ac578f78efd260431d3b8c090f0eb40887a77ea"},"emulator":"swanstation","error":null,"files_opened":[{"created":false,"entry":"files:scph5501.bin","path":"scph5501.bin","sha1":"0555c6fae8906f3f09baf5988f00e55f88e9f30b"}],"findings":[],"id":"2026-10-27-3443028e79fd","install":"installer","platform":"batocera","platform_system":"sony-playstation","platform_version":"43.1","probed_at":"2026-10-27T22:30:09+00:00","records":["2026-10-27-c17d693b47cf"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, <dest>/Source/, <dest>/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"44e08162359530f95bb4d34687ca7b22e01296d5","save_dir_files":23},"staged":258,"system":"sony-playstation","target":"x86_64","verdict":"boots","verdict_evidence":{"detail":"1 of 1 repetitions: frame 1300 shows Now Loading with the yellow bubble","frame":1300,"repetitions":["boots"],"rule":"repetitions_agree","sha1":"e5af1f2023d0d729ce7aabf52d8e03f75bb8d230","stages":["bios","game"]}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_saturn_libretro.so.zip","file":"mednafen_saturn_libretro.so","last_modified":"Tue, 27 Oct 2026 07:34:40 GMT","sha256":"2cef16a3444d45032ad0ae0d523cbff0e5655f4bc15eb33406c81212f54dc9c1"},"emulator":"beetle_saturn","error":null,"files_opened":[{"created":false,"entry":"sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"}],"findings":[],"id":"2026-10-27-41d8ddbf0157","install":"pack","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T22:38:01+00:00","records":["2026-10-27-b35ffbd53148"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":19,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/kronos_libretro.so.zip","file":"kronos_libretro.so","last_modified":"Tue, 27 Oct 2026 06:30:48 GMT","sha256":"ca7d0655cb4283b1749ddd2751450d2ec0fd2ea3a3686bde5c4b626cb99e06ac"},"emulator":"kronos","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-3f4e8af52ea7","install":"pack","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T22:48:37+00:00","records":["2026-10-27-c30af9f8fdf6"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":18,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabasanshiro_libretro.so.zip","file":"yabasanshiro_libretro.so","last_modified":"Tue, 27 Oct 2026 06:26:02 GMT","sha256":"d75ae811201373f0b3db69b4258f5d19ee42bfb81231af8abeb6a8389a8417ec"},"emulator":"yabasanshiro","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-01198b318217","install":"pack","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T22:53:33+00:00","records":["2026-10-27-6448739cb5b6"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":13,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabause_libretro.so.zip","file":"yabause_libretro.so","last_modified":"Tue, 27 Oct 2026 06:42:50 GMT","sha256":"974a4cc700ed7eef80b8a33abd33ed07ab317f2123a1014b78c57e043b923296"},"emulator":"yabause","error":null,"files_opened":[{"created":false,"entry":"saturn_bios.bin","path":"saturn_bios.bin","sha1":"2b8cb4f87580683eb4d760e4ed210813d667f0a2"}],"findings":[],"id":"2026-10-27-d37b3de2e780","install":"pack","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T22:57:53+00:00","records":["2026-10-27-cb23fba56e9e"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":13,"system":"sega-saturn","target":"linux-x86_64","verdict":"hang","verdict_evidence":{"detail":"1 of 1 repetitions: the last 183 sampled frames are one colour each","repetitions":["hang"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/ymir_libretro.so.zip","file":"ymir_libretro.so","last_modified":"Tue, 27 Oct 2026 05:34:56 GMT","sha256":"237d154fa6ef5caccc1d2899e0a2444947e4391a81bd241803932d1319832712"},"emulator":"ymir","error":null,"files_opened":[{"created":false,"entry":"sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"},{"created":false,"entry":"mpr-17933.bin","path":"mpr-17933.bin","sha1":"faa8ea183a6d7bbe5d4e03bb1332519800d3fbc3"},{"created":false,"entry":"saturn_bios.bin","path":"saturn_bios.bin","sha1":"2b8cb4f87580683eb4d760e4ed210813d667f0a2"},{"created":false,"entry":"cdb/cdb_rom.bin","path":"cdb/cdb_rom.bin","sha1":"b3c20fbe57cd2eb595e9edac86817e5948dccae4"}],"findings":[],"id":"2026-10-27-d956a7e3788b","install":"pack","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T22:59:16+00:00","records":["2026-10-27-7a027460d58c"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":14,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_saturn_libretro.so.zip","file":"mednafen_saturn_libretro.so","last_modified":"Tue, 27 Oct 2026 07:34:40 GMT","sha256":"2cef16a3444d45032ad0ae0d523cbff0e5655f4bc15eb33406c81212f54dc9c1"},"emulator":"beetle_saturn","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"}],"findings":[],"id":"2026-10-27-9f997c730cef","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:02:13+00:00","records":["2026-10-27-f775f43a64e5"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retroarch/installer/system/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":19,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/kronos_libretro.so.zip","file":"kronos_libretro.so","last_modified":"Tue, 27 Oct 2026 06:30:48 GMT","sha256":"ca7d0655cb4283b1749ddd2751450d2ec0fd2ea3a3686bde5c4b626cb99e06ac"},"emulator":"kronos","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-b2cf2fbcff14","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:03:18+00:00","records":["2026-10-27-26a9c1b2b281"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retroarch/installer/system/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":18,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabasanshiro_libretro.so.zip","file":"yabasanshiro_libretro.so","last_modified":"Tue, 27 Oct 2026 06:26:02 GMT","sha256":"d75ae811201373f0b3db69b4258f5d19ee42bfb81231af8abeb6a8389a8417ec"},"emulator":"yabasanshiro","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-cfca7b4e0a6d","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:05:13+00:00","records":["2026-10-27-0bd09fa85236"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retroarch/installer/system/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":13,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabause_libretro.so.zip","file":"yabause_libretro.so","last_modified":"Tue, 27 Oct 2026 06:42:50 GMT","sha256":"974a4cc700ed7eef80b8a33abd33ed07ab317f2123a1014b78c57e043b923296"},"emulator":"yabause","error":null,"files_opened":[{"created":false,"entry":"files:saturn_bios.bin","path":"saturn_bios.bin","sha1":"2b8cb4f87580683eb4d760e4ed210813d667f0a2"}],"findings":[],"id":"2026-10-27-9e1788f4d6ac","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:06:05+00:00","records":["2026-10-27-52c9e9e9b49e"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retroarch/installer/system/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":13,"system":"sega-saturn","target":"linux-x86_64","verdict":"hang","verdict_evidence":{"detail":"1 of 1 repetitions: the last 183 sampled frames are one colour each","repetitions":["hang"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/ymir_libretro.so.zip","file":"ymir_libretro.so","last_modified":"Tue, 27 Oct 2026 05:34:56 GMT","sha256":"237d154fa6ef5caccc1d2899e0a2444947e4391a81bd241803932d1319832712"},"emulator":"ymir","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"},{"created":false,"entry":"files:mpr-17933.bin","path":"mpr-17933.bin","sha1":"faa8ea183a6d7bbe5d4e03bb1332519800d3fbc3"},{"created":false,"entry":"files:saturn_bios.bin","path":"saturn_bios.bin","sha1":"2b8cb4f87580683eb4d760e4ed210813d667f0a2"},{"created":false,"entry":"files:cdb/cdb_rom.bin","path":"cdb/cdb_rom.bin","sha1":"b3c20fbe57cd2eb595e9edac86817e5948dccae4"}],"findings":[],"id":"2026-10-27-35445905168b","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:07:12+00:00","records":["2026-10-27-c672a15540e6"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retroarch/installer/system/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":14,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_saturn_libretro.so.zip","file":"mednafen_saturn_libretro.so","last_modified":"Tue, 27 Oct 2026 07:34:40 GMT","sha256":"2cef16a3444d45032ad0ae0d523cbff0e5655f4bc15eb33406c81212f54dc9c1"},"emulator":"beetle_saturn","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"}],"findings":[],"id":"2026-10-27-caad9c44c473","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:02:13+00:00","records":["2026-10-27-f775f43a64e5"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into <dest>/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":19,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/kronos_libretro.so.zip","file":"kronos_libretro.so","last_modified":"Tue, 27 Oct 2026 06:30:48 GMT","sha256":"ca7d0655cb4283b1749ddd2751450d2ec0fd2ea3a3686bde5c4b626cb99e06ac"},"emulator":"kronos","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-b59b7e0ba6ee","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:03:18+00:00","records":["2026-10-27-26a9c1b2b281"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into <dest>/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":18,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabasanshiro_libretro.so.zip","file":"yabasanshiro_libretro.so","last_modified":"Tue, 27 Oct 2026 06:26:02 GMT","sha256":"d75ae811201373f0b3db69b4258f5d19ee42bfb81231af8abeb6a8389a8417ec"},"emulator":"yabasanshiro","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-805894d64c77","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:05:13+00:00","records":["2026-10-27-0bd09fa85236"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into <dest>/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":13,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabause_libretro.so.zip","file":"yabause_libretro.so","last_modified":"Tue, 27 Oct 2026 06:42:50 GMT","sha256":"974a4cc700ed7eef80b8a33abd33ed07ab317f2123a1014b78c57e043b923296"},"emulator":"yabause","error":null,"files_opened":[{"created":false,"entry":"files:saturn_bios.bin","path":"saturn_bios.bin","sha1":"2b8cb4f87580683eb4d760e4ed210813d667f0a2"}],"findings":[],"id":"2026-10-27-f71dbe0a2d42","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:06:05+00:00","records":["2026-10-27-52c9e9e9b49e"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into <dest>/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":13,"system":"sega-saturn","target":"linux-x86_64","verdict":"hang","verdict_evidence":{"detail":"1 of 1 repetitions: the last 183 sampled frames are one colour each","repetitions":["hang"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/ymir_libretro.so.zip","file":"ymir_libretro.so","last_modified":"Tue, 27 Oct 2026 05:34:56 GMT","sha256":"237d154fa6ef5caccc1d2899e0a2444947e4391a81bd241803932d1319832712"},"emulator":"ymir","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"},{"created":false,"entry":"files:mpr-17933.bin","path":"mpr-17933.bin","sha1":"faa8ea183a6d7bbe5d4e03bb1332519800d3fbc3"},{"created":false,"entry":"files:saturn_bios.bin","path":"saturn_bios.bin","sha1":"2b8cb4f87580683eb4d760e4ed210813d667f0a2"},{"created":false,"entry":"files:cdb/cdb_rom.bin","path":"cdb/cdb_rom.bin","sha1":"b3c20fbe57cd2eb595e9edac86817e5948dccae4"}],"findings":[],"id":"2026-10-27-6ad530ef7a53","install":"installer","platform":"retroarch","platform_system":"sega-saturn","platform_version":"v1.22.2","probed_at":"2026-10-27T23:07:12+00:00","records":["2026-10-27-c672a15540e6"],"rounds":1,"schema_version":1,"source":{"installer_summary":"em, or its copy fails their check. Not available here, game data: files a game or a game engine reads, of no use to the other cores, read by its game: jazz2/Source/Anims.j2a. Copy it from your own copy of the game into <dest>/jazz2/Source/. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":10986,"path":"install/retroarch.json","sha256":"bac911bf10117fa8704822039d29a7298ad34d8c65e55b1fdb490b5bdde0f472"},"pack":{"file":"RetroArch_v1.22.2_BIOS_Pack.zip","members":10986,"sha256":"2a99c4568ea11beec6a38a7851a4bc1a122309357f601118981674d5d64dbba0","size":4337327025},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":106},"staged":14,"system":"sega-saturn","target":"linux-x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_saturn_libretro.so.zip","file":"mednafen_saturn_libretro.so","last_modified":"Tue, 27 Oct 2026 07:34:40 GMT","sha256":"2cef16a3444d45032ad0ae0d523cbff0e5655f4bc15eb33406c81212f54dc9c1"},"emulator":"beetle_saturn","error":null,"files_opened":[{"created":false,"entry":"sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"}],"findings":[],"id":"2026-10-27-4ebba9f9c0df","install":"pack","platform":"batocera","platform_system":"sega-saturn","platform_version":"43.1","probed_at":"2026-10-27T23:18:01+00:00","records":["2026-10-27-613759bd1faf"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":11,"system":"sega-saturn","target":"x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/kronos_libretro.so.zip","file":"kronos_libretro.so","last_modified":"Tue, 27 Oct 2026 06:30:48 GMT","sha256":"ca7d0655cb4283b1749ddd2751450d2ec0fd2ea3a3686bde5c4b626cb99e06ac"},"emulator":"kronos","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-2629752432de","install":"pack","platform":"batocera","platform_system":"sega-saturn","platform_version":"43.1","probed_at":"2026-10-27T23:19:16+00:00","records":["2026-10-27-89d7c65a2d30"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":11,"system":"sega-saturn","target":"x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabasanshiro_libretro.so.zip","file":"yabasanshiro_libretro.so","last_modified":"Tue, 27 Oct 2026 06:26:02 GMT","sha256":"d75ae811201373f0b3db69b4258f5d19ee42bfb81231af8abeb6a8389a8417ec"},"emulator":"yabasanshiro","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-226e22f6fa88","install":"pack","platform":"batocera","platform_system":"sega-saturn","platform_version":"43.1","probed_at":"2026-10-27T23:21:32+00:00","records":["2026-10-27-19b6b995c8b3"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":5,"system":"sega-saturn","target":"x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_saturn_libretro.so.zip","file":"mednafen_saturn_libretro.so","last_modified":"Tue, 27 Oct 2026 07:34:40 GMT","sha256":"2cef16a3444d45032ad0ae0d523cbff0e5655f4bc15eb33406c81212f54dc9c1"},"emulator":"beetle_saturn","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"}],"findings":[],"id":"2026-10-27-e20901013c15","install":"installer","platform":"batocera","platform_system":"sega-saturn","platform_version":"43.1","probed_at":"2026-10-27T23:28:01+00:00","records":["2026-10-27-af15e4bf2a6c"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/Source/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":23},"staged":11,"system":"sega-saturn","target":"x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/kronos_libretro.so.zip","file":"kronos_libretro.so","last_modified":"Tue, 27 Oct 2026 06:30:48 GMT","sha256":"ca7d0655cb4283b1749ddd2751450d2ec0fd2ea3a3686bde5c4b626cb99e06ac"},"emulator":"kronos","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-91b659e0e757","install":"installer","platform":"batocera","platform_system":"sega-saturn","platform_version":"43.1","probed_at":"2026-10-27T23:37:55+00:00","records":["2026-10-27-6cb5e0f338df"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/Source/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":23},"staged":11,"system":"sega-saturn","target":"x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabasanshiro_libretro.so.zip","file":"yabasanshiro_libretro.so","last_modified":"Tue, 27 Oct 2026 06:26:02 GMT","sha256":"d75ae811201373f0b3db69b4258f5d19ee42bfb81231af8abeb6a8389a8417ec"},"emulator":"yabasanshiro","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-7c56e8b99031","install":"installer","platform":"batocera","platform_system":"sega-saturn","platform_version":"43.1","probed_at":"2026-10-27T23:50:03+00:00","records":["2026-10-27-828497f9002d"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/Source/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":23},"staged":5,"system":"sega-saturn","target":"x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_saturn_libretro.so.zip","file":"mednafen_saturn_libretro.so","last_modified":"Tue, 27 Oct 2026 07:34:40 GMT","sha256":"2cef16a3444d45032ad0ae0d523cbff0e5655f4bc15eb33406c81212f54dc9c1"},"emulator":"beetle_saturn","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"}],"findings":[],"id":"2026-10-27-1ef12db748b4","install":"installer","platform":"batocera","platform_system":"sega-saturn","platform_version":"43.1","probed_at":"2026-10-27T23:28:01+00:00","records":["2026-10-27-af15e4bf2a6c"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, <dest>/Source/, <dest>/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":23},"staged":11,"system":"sega-saturn","target":"x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/kronos_libretro.so.zip","file":"kronos_libretro.so","last_modified":"Tue, 27 Oct 2026 06:30:48 GMT","sha256":"ca7d0655cb4283b1749ddd2751450d2ec0fd2ea3a3686bde5c4b626cb99e06ac"},"emulator":"kronos","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-6e7e85fca75c","install":"installer","platform":"batocera","platform_system":"sega-saturn","platform_version":"43.1","probed_at":"2026-10-27T23:37:55+00:00","records":["2026-10-27-6cb5e0f338df"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, <dest>/Source/, <dest>/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":23},"staged":11,"system":"sega-saturn","target":"x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabasanshiro_libretro.so.zip","file":"yabasanshiro_libretro.so","last_modified":"Tue, 27 Oct 2026 06:26:02 GMT","sha256":"d75ae811201373f0b3db69b4258f5d19ee42bfb81231af8abeb6a8389a8417ec"},"emulator":"yabasanshiro","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-5f6ba47b3c86","install":"installer","platform":"batocera","platform_system":"sega-saturn","platform_version":"43.1","probed_at":"2026-10-27T23:50:03+00:00","records":["2026-10-27-828497f9002d"],"rounds":1,"schema_version":1,"source":{"installer_summary":"tem/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/batocera/installer/system/, <dest>/Source/, <dest>/base/ and 10 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":14725,"path":"install/batocera.json","sha256":"960b377a8e03a9333bb4853e54425a0f889eecf9342c9c1aadebf3601ae27f20"},"pack":{"file":"Batocera_43.1_BIOS_Pack.zip","members":14725,"sha256":"494c82fe9aff94e0ad6bc1cad0e14f00719ca5dccf057293009659572988806a","size":6969317213},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":23},"staged":5,"system":"sega-saturn","target":"x86_64","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_saturn_libretro.so.zip","file":"mednafen_saturn_libretro.so","last_modified":"Tue, 27 Oct 2026 07:34:40 GMT","sha256":"2cef16a3444d45032ad0ae0d523cbff0e5655f4bc15eb33406c81212f54dc9c1"},"emulator":"beetle_saturn","error":null,"files_opened":[{"created":false,"entry":"sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"}],"findings":[],"id":"2026-10-27-804ca236cadc","install":"pack","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:51:45+00:00","records":["2026-10-27-07310c597c37"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":13,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/kronos_libretro.so.zip","file":"kronos_libretro.so","last_modified":"Tue, 27 Oct 2026 06:30:48 GMT","sha256":"ca7d0655cb4283b1749ddd2751450d2ec0fd2ea3a3686bde5c4b626cb99e06ac"},"emulator":"kronos","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-bb15cfe730d7","install":"pack","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:52:42+00:00","records":["2026-10-27-6bd260b246f8"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":11,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabasanshiro_libretro.so.zip","file":"yabasanshiro_libretro.so","last_modified":"Tue, 27 Oct 2026 06:26:02 GMT","sha256":"d75ae811201373f0b3db69b4258f5d19ee42bfb81231af8abeb6a8389a8417ec"},"emulator":"yabasanshiro","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-babce101aafc","install":"pack","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:52:42+00:00","records":["2026-10-27-b92d0c40b7cb"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":7,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/ymir_libretro.so.zip","file":"ymir_libretro.so","last_modified":"Tue, 27 Oct 2026 05:34:56 GMT","sha256":"237d154fa6ef5caccc1d2899e0a2444947e4391a81bd241803932d1319832712"},"emulator":"ymir","error":null,"files_opened":[{"created":false,"entry":"sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"},{"created":false,"entry":"mpr-17933.bin","path":"mpr-17933.bin","sha1":"faa8ea183a6d7bbe5d4e03bb1332519800d3fbc3"},{"created":false,"entry":"saturn_bios.bin","path":"saturn_bios.bin","sha1":"2b8cb4f87580683eb4d760e4ed210813d667f0a2"},{"created":false,"entry":"cdb/cdb_rom.bin","path":"cdb/cdb_rom.bin","sha1":"b3c20fbe57cd2eb595e9edac86817e5948dccae4"}],"findings":[],"id":"2026-10-27-19073ee28285","install":"pack","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:52:54+00:00","records":["2026-10-27-e4aee262160e"],"rounds":1,"schema_version":1,"source":{"manifest":null,"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251"},"staged":8,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_saturn_libretro.so.zip","file":"mednafen_saturn_libretro.so","last_modified":"Tue, 27 Oct 2026 07:34:40 GMT","sha256":"2cef16a3444d45032ad0ae0d523cbff0e5655f4bc15eb33406c81212f54dc9c1"},"emulator":"beetle_saturn","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"}],"findings":[],"id":"2026-10-27-0903e163b2e3","install":"installer","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:54:19+00:00","records":["2026-10-27-dcac17ec3f00"],"rounds":1,"schema_version":1,"source":{"installer_summary":".claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/base/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/devilutionx/ and 1 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":5561,"path":"install/retrobat.json","sha256":"ef2c453bbd23f10a19322b9b31f0983bfc3cdc36bcea5442f88f42b5a2eb9608"},"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":41},"staged":13,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/kronos_libretro.so.zip","file":"kronos_libretro.so","last_modified":"Tue, 27 Oct 2026 06:30:48 GMT","sha256":"ca7d0655cb4283b1749ddd2751450d2ec0fd2ea3a3686bde5c4b626cb99e06ac"},"emulator":"kronos","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-06fab3d76476","install":"installer","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:54:19+00:00","records":["2026-10-27-41b7958405f2"],"rounds":1,"schema_version":1,"source":{"installer_summary":".claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/base/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/devilutionx/ and 1 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":5561,"path":"install/retrobat.json","sha256":"ef2c453bbd23f10a19322b9b31f0983bfc3cdc36bcea5442f88f42b5a2eb9608"},"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":41},"staged":11,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabasanshiro_libretro.so.zip","file":"yabasanshiro_libretro.so","last_modified":"Tue, 27 Oct 2026 06:26:02 GMT","sha256":"d75ae811201373f0b3db69b4258f5d19ee42bfb81231af8abeb6a8389a8417ec"},"emulator":"yabasanshiro","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-1f2290908477","install":"installer","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:54:20+00:00","records":["2026-10-27-b25e77568729"],"rounds":1,"schema_version":1,"source":{"installer_summary":".claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/base/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/devilutionx/ and 1 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":5561,"path":"install/retrobat.json","sha256":"ef2c453bbd23f10a19322b9b31f0983bfc3cdc36bcea5442f88f42b5a2eb9608"},"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":41},"staged":7,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/ymir_libretro.so.zip","file":"ymir_libretro.so","last_modified":"Tue, 27 Oct 2026 05:34:56 GMT","sha256":"237d154fa6ef5caccc1d2899e0a2444947e4391a81bd241803932d1319832712"},"emulator":"ymir","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"},{"created":false,"entry":"files:mpr-17933.bin","path":"mpr-17933.bin","sha1":"faa8ea183a6d7bbe5d4e03bb1332519800d3fbc3"},{"created":false,"entry":"files:saturn_bios.bin","path":"saturn_bios.bin","sha1":"2b8cb4f87580683eb4d760e4ed210813d667f0a2"},{"created":false,"entry":"files:cdb/cdb_rom.bin","path":"cdb/cdb_rom.bin","sha1":"b3c20fbe57cd2eb595e9edac86817e5948dccae4"}],"findings":[],"id":"2026-10-27-47e23a582f90","install":"installer","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:54:32+00:00","records":["2026-10-27-9b443640216e"],"rounds":1,"schema_version":1,"source":{"installer_summary":".claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/base/, /home/moi/retroarch_system/.claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/devilutionx/ and 1 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":5561,"path":"install/retrobat.json","sha256":"ef2c453bbd23f10a19322b9b31f0983bfc3cdc36bcea5442f88f42b5a2eb9608"},"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":41},"staged":8,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/mednafen_saturn_libretro.so.zip","file":"mednafen_saturn_libretro.so","last_modified":"Tue, 27 Oct 2026 07:34:40 GMT","sha256":"2cef16a3444d45032ad0ae0d523cbff0e5655f4bc15eb33406c81212f54dc9c1"},"emulator":"beetle_saturn","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"}],"findings":[],"id":"2026-10-27-ce9ff2f7f69e","install":"installer","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:54:19+00:00","records":["2026-10-27-dcac17ec3f00"],"rounds":1,"schema_version":1,"source":{"installer_summary":".claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/, <dest>/base/, <dest>/devilutionx/ and 1 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":5561,"path":"install/retrobat.json","sha256":"ef2c453bbd23f10a19322b9b31f0983bfc3cdc36bcea5442f88f42b5a2eb9608"},"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":41},"staged":13,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/kronos_libretro.so.zip","file":"kronos_libretro.so","last_modified":"Tue, 27 Oct 2026 06:30:48 GMT","sha256":"ca7d0655cb4283b1749ddd2751450d2ec0fd2ea3a3686bde5c4b626cb99e06ac"},"emulator":"kronos","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-3c840e36bf04","install":"installer","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:54:19+00:00","records":["2026-10-27-41b7958405f2"],"rounds":1,"schema_version":1,"source":{"installer_summary":".claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/, <dest>/base/, <dest>/devilutionx/ and 1 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":5561,"path":"install/retrobat.json","sha256":"ef2c453bbd23f10a19322b9b31f0983bfc3cdc36bcea5442f88f42b5a2eb9608"},"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":41},"staged":11,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/yabasanshiro_libretro.so.zip","file":"yabasanshiro_libretro.so","last_modified":"Tue, 27 Oct 2026 06:26:02 GMT","sha256":"d75ae811201373f0b3db69b4258f5d19ee42bfb81231af8abeb6a8389a8417ec"},"emulator":"yabasanshiro","error":null,"files_opened":[],"findings":[],"id":"2026-10-27-90f89c3310ca","install":"installer","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:54:20+00:00","records":["2026-10-27-b25e77568729"],"rounds":1,"schema_version":1,"source":{"installer_summary":".claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/, <dest>/base/, <dest>/devilutionx/ and 1 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":5561,"path":"install/retrobat.json","sha256":"ef2c453bbd23f10a19322b9b31f0983bfc3cdc36bcea5442f88f42b5a2eb9608"},"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":41},"staged":7,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: retro_load_game returned false and no error pattern matched","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
{"content":{"catalog":"redump","label":"Lunar - Silver Star Story Complete (Japan)","region":"japan"},"core":{"artifact":"https://buildbot.libretro.com/nightly/linux/x86_64/latest/ymir_libretro.so.zip","file":"ymir_libretro.so","last_modified":"Tue, 27 Oct 2026 05:34:56 GMT","sha256":"237d154fa6ef5caccc1d2899e0a2444947e4391a81bd241803932d1319832712"},"emulator":"ymir","error":null,"files_opened":[{"created":false,"entry":"files:sega_101.bin","path":"sega_101.bin","sha1":"df94c5b4d47eb3cc404d88b33a8fda237eaf4720"},{"created":false,"entry":"files:mpr-17933.bin","path":"mpr-17933.bin","sha1":"faa8ea183a6d7bbe5d4e03bb1332519800d3fbc3"},{"created":false,"entry":"files:saturn_bios.bin","path":"saturn_bios.bin","sha1":"2b8cb4f87580683eb4d760e4ed210813d667f0a2"},{"created":false,"entry":"files:cdb/cdb_rom.bin","path":"cdb/cdb_rom.bin","sha1":"b3c20fbe57cd2eb595e9edac86817e5948dccae4"}],"findings":[],"id":"2026-10-27-373980ad54bf","install":"installer","platform":"retrobat","platform_system":"sega-saturn","platform_version":"8.3.1","probed_at":"2026-10-27T23:54:32+00:00","records":["2026-10-27-9b443640216e"],"rounds":1,"schema_version":1,"source":{"installer_summary":".claude/worktrees/agent-af6705283a8fc4932/tmp/pack_probe/retrobat/installer/system/, <dest>/base/, <dest>/devilutionx/ and 1 more folders. To check this later, run the same command with --check.","manifest":{"generated":"2026-10-27T10:27:58Z","pack_files":5561,"path":"install/retrobat.json","sha256":"ef2c453bbd23f10a19322b9b31f0983bfc3cdc36bcea5442f88f42b5a2eb9608"},"pack":{"file":"RetroBat_8.3.1_BIOS_Pack.zip","members":5561,"sha256":"89bdc445768e9568bd88737871f1e713df3d08fe0e191de18d228291d6e40647","size":5644690738},"repo_commit":"83712af0cea136bfc15a7088c06c27af028d3251","save_dir_files":41},"staged":8,"system":"sega-saturn","target":"windows","verdict":"unknown","verdict_evidence":{"detail":"1 of 1 repetitions: no reference frame is known for this content on this core","repetitions":["unknown"],"rule":"repetitions_agree"}}
|
||||
@@ -925,7 +925,8 @@ platforms:
|
||||
# ApiSystem.cpp:764-774). Arch R (archr-linux/Arch-R dbd45d45) ships the
|
||||
# same dict as archr-systems: lines 1-870 are identical, only the prefix
|
||||
# (/storage/roms instead of /userdata) and the es_systems.cfg path differ.
|
||||
# Many boards, no per-board core list: no target file.
|
||||
# Boards follow Batocera's Kconfig model (configs/knulli-*.board set
|
||||
# BR2_PACKAGE_BATOCERA_TARGET_*), read by knulli_targets_scraper.
|
||||
knulli:
|
||||
config: knulli.yml
|
||||
status: active
|
||||
@@ -947,8 +948,8 @@ platforms:
|
||||
by_core: false
|
||||
by_system: true
|
||||
schedule: monthly
|
||||
target_scraper: null
|
||||
target_source: null
|
||||
target_scraper: knulli_targets
|
||||
target_source: https://github.com/knulli-cfw/knulli-linux/tree/knulli-main/configs
|
||||
install:
|
||||
detect:
|
||||
# knulli-linux 57afa662: OS_NAME in os-release
|
||||
|
||||
@@ -9,7 +9,7 @@ params:
|
||||
sources:
|
||||
os:
|
||||
repo: https://github.com/AmberELEC/AmberELEC
|
||||
commit: 5a3882cbb755dcd614aea8952ff20f5a4151505b
|
||||
commit: a21975bfb1a3af68f66e07a931b994b523e1e6e2
|
||||
es:
|
||||
repo: https://github.com/AmberELEC/emulationstation
|
||||
commit: e3846751ce50fdced1f2bbd7a5bb51f4da2e7007
|
||||
|
||||
@@ -4,7 +4,7 @@ platform: amberelec
|
||||
sources:
|
||||
os:
|
||||
repo: "https://github.com/AmberELEC/AmberELEC"
|
||||
commit: 5a3882cbb755dcd614aea8952ff20f5a4151505b
|
||||
commit: a21975bfb1a3af68f66e07a931b994b523e1e6e2
|
||||
es:
|
||||
repo: "https://github.com/AmberELEC/emulationstation"
|
||||
commit: e3846751ce50fdced1f2bbd7a5bb51f4da2e7007
|
||||
|
||||
@@ -172,12 +172,34 @@ rocknix:
|
||||
amd64:
|
||||
aliases: [x86_64, pc]
|
||||
|
||||
# KNULLI reads Batocera's list above, plus its own: vaixterm is a terminal,
|
||||
# filed as the VaixTerm system's emulator (configgen-defaults.yml:669-671).
|
||||
# KNULLI's platform scraper reads Batocera's list above, plus this one. The
|
||||
# target list reads this one alone, so it repeats Batocera's names, all of
|
||||
# which KNULLI's es_systems.yml carries under the same nodes. vaixterm is a
|
||||
# terminal, filed as the VaixTerm system's emulator
|
||||
# (configgen-defaults.yml:669-671).
|
||||
knulli:
|
||||
targets:
|
||||
_default:
|
||||
remove_cores:
|
||||
- A500
|
||||
- "A500+"
|
||||
- A600
|
||||
- A1000
|
||||
- A1200
|
||||
- A3000
|
||||
- A4000
|
||||
- CD32
|
||||
- CDTV
|
||||
- flatpak
|
||||
- odcommander
|
||||
- sh
|
||||
- steam
|
||||
- wine-tkg
|
||||
- wine
|
||||
- mugen
|
||||
- gliden64
|
||||
- glide64mk2
|
||||
- rice
|
||||
- vaixterm
|
||||
|
||||
# EmuELEC and AmberELEC have no target file: the scrapers read these lists.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
platform: batocera
|
||||
source: https://github.com/batocera-linux/batocera.linux/tree/batocera-43.1
|
||||
scraped_at: '2026-10-17T22:01:01Z'
|
||||
scraped_at: '2026-11-05T10:55:18Z'
|
||||
targets:
|
||||
a3gen2:
|
||||
architecture: aarch64
|
||||
@@ -535,7 +535,7 @@ targets:
|
||||
- yquake2
|
||||
- zc210
|
||||
bcm2835:
|
||||
architecture: aarch64
|
||||
architecture: arm
|
||||
cores:
|
||||
- '81'
|
||||
- a5200
|
||||
@@ -549,9 +549,7 @@ targets:
|
||||
- cap32
|
||||
- cdogs
|
||||
- dosbox_pure
|
||||
- duckstation
|
||||
- easyrpg
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -564,7 +562,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -581,7 +578,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- imame4all
|
||||
- jazz2-native
|
||||
- lowresnx
|
||||
- lutro
|
||||
- mame078plus
|
||||
@@ -640,14 +636,12 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x16emu
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
bcm2836:
|
||||
architecture: aarch64
|
||||
architecture: arm
|
||||
cores:
|
||||
- '81'
|
||||
- A1200
|
||||
@@ -674,8 +668,8 @@ targets:
|
||||
- dosbox_pure
|
||||
- dosbox_staging
|
||||
- duckstation
|
||||
- duckstation-legacy
|
||||
- easyrpg
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -687,7 +681,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -705,7 +698,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ioquake3
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- lutro
|
||||
@@ -748,7 +740,6 @@ targets:
|
||||
- px68k
|
||||
- pygame
|
||||
- quasi88
|
||||
- rice
|
||||
- samcoupe
|
||||
- sameduck
|
||||
- scummvm
|
||||
@@ -780,7 +771,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -792,7 +782,6 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
bcm2837:
|
||||
@@ -824,9 +813,9 @@ targets:
|
||||
- dosbox_pure
|
||||
- dosbox_staging
|
||||
- duckstation
|
||||
- duckstation-legacy
|
||||
- easyrpg
|
||||
- ecwolf
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -838,7 +827,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -849,7 +837,6 @@ targets:
|
||||
- gliden64
|
||||
- gsplus
|
||||
- gw
|
||||
- gzdoom
|
||||
- handy
|
||||
- hatari
|
||||
- hcl
|
||||
@@ -857,8 +844,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ioquake3
|
||||
- iortcw
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- lutro
|
||||
@@ -868,7 +853,6 @@ targets:
|
||||
- mednafen_ngp
|
||||
- mednafen_supergrafx
|
||||
- mednafen_wswan
|
||||
- melonds
|
||||
- mgba
|
||||
- minivmac
|
||||
- moonlight
|
||||
@@ -901,9 +885,7 @@ targets:
|
||||
- px68k
|
||||
- pygame
|
||||
- quasi88
|
||||
- raze
|
||||
- reminiscence
|
||||
- rice
|
||||
- samcoupe
|
||||
- same_cdi
|
||||
- sameduck
|
||||
@@ -936,7 +918,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -948,11 +929,10 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
h3:
|
||||
architecture: aarch64
|
||||
architecture: arm
|
||||
cores:
|
||||
- '81'
|
||||
- A1200
|
||||
@@ -977,9 +957,7 @@ targets:
|
||||
- devilutionx
|
||||
- dice
|
||||
- dosbox_pure
|
||||
- duckstation
|
||||
- easyrpg
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -991,7 +969,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -1009,7 +986,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ioquake3
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- mame078plus
|
||||
@@ -1046,7 +1022,6 @@ targets:
|
||||
- prosystem
|
||||
- px68k
|
||||
- quasi88
|
||||
- rice
|
||||
- samcoupe
|
||||
- sameduck
|
||||
- scummvm
|
||||
@@ -1075,7 +1050,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -1087,7 +1061,6 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
h5:
|
||||
@@ -1121,9 +1094,9 @@ targets:
|
||||
- dosbox_staging
|
||||
- dosboxx
|
||||
- duckstation
|
||||
- duckstation-legacy
|
||||
- easyrpg
|
||||
- ecwolf
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -1136,7 +1109,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -1147,7 +1119,6 @@ targets:
|
||||
- gliden64
|
||||
- gsplus
|
||||
- gw
|
||||
- gzdoom
|
||||
- handy
|
||||
- hatari
|
||||
- hcl
|
||||
@@ -1155,8 +1126,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ioquake3
|
||||
- iortcw
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- lutro
|
||||
@@ -1167,7 +1136,6 @@ targets:
|
||||
- mednafen_psx
|
||||
- mednafen_supergrafx
|
||||
- mednafen_wswan
|
||||
- melonds
|
||||
- mgba
|
||||
- minivmac
|
||||
- moonlight
|
||||
@@ -1200,9 +1168,7 @@ targets:
|
||||
- px68k
|
||||
- pygame
|
||||
- quasi88
|
||||
- raze
|
||||
- reminiscence
|
||||
- rice
|
||||
- samcoupe
|
||||
- same_cdi
|
||||
- sameduck
|
||||
@@ -1235,7 +1201,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -1247,7 +1212,6 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
h6:
|
||||
@@ -1281,9 +1245,9 @@ targets:
|
||||
- dosbox_staging
|
||||
- dosboxx
|
||||
- duckstation
|
||||
- duckstation-legacy
|
||||
- easyrpg
|
||||
- ecwolf
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -1296,7 +1260,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -1307,15 +1270,12 @@ targets:
|
||||
- gliden64
|
||||
- gsplus
|
||||
- gw
|
||||
- gzdoom
|
||||
- handy
|
||||
- hatari
|
||||
- hcl
|
||||
- holani
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- iortcw
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- lutro
|
||||
@@ -1326,7 +1286,6 @@ targets:
|
||||
- mednafen_psx
|
||||
- mednafen_supergrafx
|
||||
- mednafen_wswan
|
||||
- melonds
|
||||
- mgba
|
||||
- minivmac
|
||||
- moonlight
|
||||
@@ -1359,9 +1318,7 @@ targets:
|
||||
- px68k
|
||||
- pygame
|
||||
- quasi88
|
||||
- raze
|
||||
- reminiscence
|
||||
- rice
|
||||
- samcoupe
|
||||
- same_cdi
|
||||
- sameduck
|
||||
@@ -1394,7 +1351,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -1406,7 +1362,6 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
h616:
|
||||
@@ -1741,7 +1696,7 @@ targets:
|
||||
- yquake2
|
||||
- zc210
|
||||
jh7110:
|
||||
architecture: aarch64
|
||||
architecture: riscv64
|
||||
cores:
|
||||
- '81'
|
||||
- a5200
|
||||
@@ -2040,7 +1995,7 @@ targets:
|
||||
- yquake2
|
||||
- zc210
|
||||
odroidxu4:
|
||||
architecture: aarch64
|
||||
architecture: arm
|
||||
cores:
|
||||
- '81'
|
||||
- A1200
|
||||
@@ -2069,8 +2024,8 @@ targets:
|
||||
- dosbox_staging
|
||||
- dosboxx
|
||||
- duckstation
|
||||
- duckstation-legacy
|
||||
- easyrpg
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -2082,7 +2037,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -2100,7 +2054,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ioquake3
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- lutro
|
||||
@@ -2145,7 +2098,6 @@ targets:
|
||||
- px68k
|
||||
- pygame
|
||||
- quasi88
|
||||
- rice
|
||||
- samcoupe
|
||||
- sameduck
|
||||
- scummvm
|
||||
@@ -2177,7 +2129,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -2189,7 +2140,6 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
qcs6490:
|
||||
@@ -2379,7 +2329,7 @@ targets:
|
||||
- yquake2
|
||||
- zc210
|
||||
rk3128:
|
||||
architecture: aarch64
|
||||
architecture: arm
|
||||
cores:
|
||||
- '81'
|
||||
- A1200
|
||||
@@ -2405,9 +2355,7 @@ targets:
|
||||
- dice
|
||||
- dosbox_pure
|
||||
- dosbox_staging
|
||||
- duckstation
|
||||
- easyrpg
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -2419,7 +2367,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -2436,7 +2383,6 @@ targets:
|
||||
- holani
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- lutro
|
||||
@@ -2481,7 +2427,6 @@ targets:
|
||||
- px68k
|
||||
- pygame
|
||||
- quasi88
|
||||
- rice
|
||||
- samcoupe
|
||||
- sameduck
|
||||
- scummvm
|
||||
@@ -2513,7 +2458,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -2525,11 +2469,10 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
rk3288:
|
||||
architecture: aarch64
|
||||
architecture: arm
|
||||
cores:
|
||||
- '81'
|
||||
- A1200
|
||||
@@ -2868,9 +2811,9 @@ targets:
|
||||
- dosbox_staging
|
||||
- dosboxx
|
||||
- duckstation
|
||||
- duckstation-legacy
|
||||
- easyrpg
|
||||
- ecwolf
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -2882,7 +2825,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -2893,7 +2835,6 @@ targets:
|
||||
- gliden64
|
||||
- gsplus
|
||||
- gw
|
||||
- gzdoom
|
||||
- handy
|
||||
- hatari
|
||||
- hcl
|
||||
@@ -2901,8 +2842,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ioquake3
|
||||
- iortcw
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- lutro
|
||||
@@ -2913,7 +2852,6 @@ targets:
|
||||
- mednafen_psx
|
||||
- mednafen_supergrafx
|
||||
- mednafen_wswan
|
||||
- melonds
|
||||
- mgba
|
||||
- minivmac
|
||||
- moonlight
|
||||
@@ -2948,9 +2886,7 @@ targets:
|
||||
- px68k
|
||||
- pygame
|
||||
- quasi88
|
||||
- raze
|
||||
- reminiscence
|
||||
- rice
|
||||
- samcoupe
|
||||
- same_cdi
|
||||
- sameduck
|
||||
@@ -2983,7 +2919,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -2995,7 +2930,6 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
rk3399:
|
||||
@@ -3709,7 +3643,7 @@ targets:
|
||||
- yquake2
|
||||
- zc210
|
||||
s812:
|
||||
architecture: aarch64
|
||||
architecture: arm
|
||||
cores:
|
||||
- '81'
|
||||
- A1200
|
||||
@@ -3738,8 +3672,8 @@ targets:
|
||||
- dosbox_staging
|
||||
- dosboxx
|
||||
- duckstation
|
||||
- duckstation-legacy
|
||||
- easyrpg
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -3751,7 +3685,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -3769,7 +3702,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ioquake3
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- lutro
|
||||
@@ -3814,7 +3746,6 @@ targets:
|
||||
- px68k
|
||||
- pygame
|
||||
- quasi88
|
||||
- rice
|
||||
- samcoupe
|
||||
- sameduck
|
||||
- scummvm
|
||||
@@ -3846,7 +3777,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -3858,7 +3788,6 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
s905:
|
||||
@@ -3892,9 +3821,9 @@ targets:
|
||||
- dosbox_staging
|
||||
- dosboxx
|
||||
- duckstation
|
||||
- duckstation-legacy
|
||||
- easyrpg
|
||||
- ecwolf
|
||||
- eduke32
|
||||
- emuscv
|
||||
- ep128emu-core
|
||||
- fake08
|
||||
@@ -3906,7 +3835,6 @@ targets:
|
||||
- fmsx
|
||||
- freechaf
|
||||
- freeintv
|
||||
- fury
|
||||
- fuse
|
||||
- gambatte
|
||||
- gearcoleco
|
||||
@@ -3917,7 +3845,6 @@ targets:
|
||||
- gliden64
|
||||
- gsplus
|
||||
- gw
|
||||
- gzdoom
|
||||
- handy
|
||||
- hatari
|
||||
- hcl
|
||||
@@ -3925,8 +3852,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ioquake3
|
||||
- iortcw
|
||||
- jazz2-native
|
||||
- lightspark
|
||||
- lowresnx
|
||||
- lutro
|
||||
@@ -3937,7 +3862,6 @@ targets:
|
||||
- mednafen_psx
|
||||
- mednafen_supergrafx
|
||||
- mednafen_wswan
|
||||
- melonds
|
||||
- mgba
|
||||
- minivmac
|
||||
- moonlight
|
||||
@@ -3970,9 +3894,7 @@ targets:
|
||||
- px68k
|
||||
- pygame
|
||||
- quasi88
|
||||
- raze
|
||||
- reminiscence
|
||||
- rice
|
||||
- samcoupe
|
||||
- same_cdi
|
||||
- sameduck
|
||||
@@ -4005,7 +3927,6 @@ targets:
|
||||
- vice_xplus4
|
||||
- vice_xscpu64
|
||||
- vice_xvic
|
||||
- vitaquake2
|
||||
- x1
|
||||
- x128
|
||||
- x16emu
|
||||
@@ -4017,7 +3938,6 @@ targets:
|
||||
- xrick
|
||||
- xscpu64
|
||||
- xvic
|
||||
- yabasanshiro
|
||||
- yquake2
|
||||
- zc210
|
||||
s905gen2:
|
||||
@@ -5315,7 +5235,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ikemen
|
||||
- ioquake3
|
||||
- iortcw
|
||||
- jazz2-native
|
||||
- kronos
|
||||
@@ -5542,7 +5461,6 @@ targets:
|
||||
- hurrican
|
||||
- hypseus-singe
|
||||
- ikemen
|
||||
- ioquake3
|
||||
- iortcw
|
||||
- jazz2-native
|
||||
- kronos
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+207
-15
@@ -40,6 +40,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import asdict, dataclass, field
|
||||
@@ -56,12 +57,14 @@ from common import ( # noqa: E402
|
||||
apply_target_overrides,
|
||||
list_registered_platforms,
|
||||
load_emulator_profiles,
|
||||
load_platform_registry,
|
||||
upstream_profile_index,
|
||||
write_text_atomic,
|
||||
yaml_load,
|
||||
)
|
||||
from exporter import discover_exporters # noqa: E402
|
||||
from exporter.baseline import build_native_model # noqa: E402
|
||||
from scripts.scraper import game_dat_importer # noqa: E402
|
||||
from scripts.scraper.redump_dat_scraper import fetch_snapshot # noqa: E402
|
||||
from scripts.scraper.targets import discover_target_scrapers # noqa: E402
|
||||
|
||||
@@ -80,7 +83,9 @@ INSTALLERS = ("install.sh", "install.ps1")
|
||||
|
||||
SHOWN_ITEMS = 4 # items named in a diff summary before ", ..."
|
||||
SHOWN_NAMES = 12 # core names named in an unresolved row before ", ..."
|
||||
AREAS = ("platforms", "native", "targets", "coreinfo", "data", "catalogs", "ci", "profiles")
|
||||
AREAS = (
|
||||
"platforms", "native", "targets", "romfolders", "coreinfo", "data", "catalogs", "ci", "profiles",
|
||||
)
|
||||
OK, STALE, UNKNOWN, ERROR, SKIPPED = "OK", "STALE", "UNKNOWN", "ERROR", "SKIPPED"
|
||||
# Every status a finding may carry, in the order the summary counts them.
|
||||
STATUSES = (STALE, UNKNOWN, ERROR, SKIPPED, OK)
|
||||
@@ -723,6 +728,119 @@ def _age_days(stamp: str, now: datetime) -> int | None:
|
||||
return (now - then).days
|
||||
|
||||
|
||||
# --- rom folders ------------------------------------------------------------
|
||||
|
||||
|
||||
def romfolder_sources(platforms_dir: Path) -> dict[str, dict]:
|
||||
"""setup -> its platforms/romfolders/_sources/<setup>.yml."""
|
||||
out = {}
|
||||
for path in sorted((platforms_dir / "romfolders" / "_sources").glob("*.yml")):
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
out[path.stem] = yaml_load(fh) or {}
|
||||
return out
|
||||
|
||||
|
||||
def _os_source(doc: dict) -> dict:
|
||||
"""The source the setup is pinned on: `os`, else the first declared.
|
||||
The others follow from it (an EmulationStation pinned by a package.mk)."""
|
||||
sources = doc.get("sources") or {}
|
||||
return sources.get("os") or next(iter(sources.values()), {})
|
||||
|
||||
|
||||
def platform_revision(setup: str, doc: dict, platforms_dir: Path) -> str:
|
||||
"""The revision the setup's own platform file was scraped at, when the
|
||||
setup is a registered platform read from the same repository; '' else."""
|
||||
path = platforms_dir / f"{setup}.yml"
|
||||
if setup not in load_platform_registry(str(platforms_dir)) or not path.is_file():
|
||||
return ""
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
source = str((yaml_load(fh) or {}).get("source") or "")
|
||||
repo = upstream.parse_repo(str(_os_source(doc).get("repo") or ""))
|
||||
if repo is None or f"/{repo.slug.lower()}/" not in source.lower() + "/":
|
||||
return ""
|
||||
return export_native.source_revision(source)
|
||||
|
||||
|
||||
def romfolder_pin_drift(setup: str, doc: dict, platforms_dir: Path) -> str | None:
|
||||
"""Why the folders and the BIOS of one platform come from two revisions,
|
||||
None when they do not or when only the forge can tell (a tag on one
|
||||
side, a commit on the other)."""
|
||||
revision = platform_revision(setup, doc, platforms_dir)
|
||||
os_source = _os_source(doc)
|
||||
tag, commit = os_source.get("tag"), str(os_source.get("commit") or "")
|
||||
if not revision or revision in (tag, commit):
|
||||
return None
|
||||
if tag or len(revision) == len(commit):
|
||||
return f"platforms/{setup}.yml was scraped at {revision[:12]}, the rom folders at {tag or commit[:12]}"
|
||||
return None
|
||||
|
||||
|
||||
def check_romfolders(platforms_dir: Path, cache_dir: str, offline: bool) -> list[Finding]:
|
||||
"""The pins of platforms/romfolders/_sources/ against what they follow.
|
||||
|
||||
A setup that is a registered platform is read at the revision its
|
||||
platform file was scraped at: the games-on-card mode otherwise installs
|
||||
the BIOS of one release for the folders of another. Any other setup is
|
||||
compared with its project's newest release, or with its branch head
|
||||
when the project publishes none. A pin is moved by hand, then
|
||||
`python -m scripts.scraper.romfolders --setup NAME` reads it again.
|
||||
"""
|
||||
findings: list[Finding] = []
|
||||
for setup, doc in romfolder_sources(platforms_dir).items():
|
||||
os_source = _os_source(doc)
|
||||
pin = str(os_source.get("tag") or str(os_source.get("commit") or "")[:12])
|
||||
drift = romfolder_pin_drift(setup, doc, platforms_dir)
|
||||
if drift:
|
||||
findings.append(Finding("romfolders", setup, STALE, local=pin, detail=drift))
|
||||
continue
|
||||
revision = platform_revision(setup, doc, platforms_dir)
|
||||
repo = upstream.parse_repo(str(os_source.get("repo") or ""))
|
||||
commit = str(os_source.get("commit") or "")
|
||||
if revision and revision in (os_source.get("tag"), commit):
|
||||
findings.append(Finding("romfolders", setup, OK, local=pin,
|
||||
detail=f"read where platforms/{setup}.yml is"))
|
||||
continue
|
||||
if offline:
|
||||
findings.append(Finding("romfolders", setup, SKIPPED, local=pin, detail="offline"))
|
||||
continue
|
||||
if repo is None or not commit:
|
||||
findings.append(Finding("romfolders", setup, UNKNOWN, local=pin, detail="no forge to ask"))
|
||||
continue
|
||||
findings.append(_romfolder_upstream(setup, repo, os_source, revision, cache_dir))
|
||||
return findings
|
||||
|
||||
|
||||
def _romfolder_upstream(
|
||||
setup: str, repo: upstream.Repo, os_source: dict, revision: str, cache_dir: str,
|
||||
) -> Finding:
|
||||
commit, tag = str(os_source["commit"]), os_source.get("tag")
|
||||
pin = str(tag or commit[:12])
|
||||
if revision:
|
||||
# The platform names a tag and the setup a commit: the tag's commit decides.
|
||||
tagged = upstream.resolve_tag_commit(repo, revision, cache_dir)
|
||||
if tagged is None:
|
||||
return Finding("romfolders", setup, UNKNOWN, local=pin, detail=f"tag {revision} not listed")
|
||||
if tagged == commit:
|
||||
return Finding("romfolders", setup, OK, local=pin, detail=f"commit of {revision}")
|
||||
return Finding("romfolders", setup, STALE, local=pin, remote=revision,
|
||||
detail=f"platforms/{setup}.yml was scraped at {revision} ({tagged[:12]})")
|
||||
pinned_at = (upstream.commit_date(repo, commit, cache_dir) or "")[:10]
|
||||
release = upstream.latest_release(repo, cache_dir)
|
||||
if release is not None and release.date:
|
||||
newer = release.tag != tag and release.date > pinned_at
|
||||
return Finding("romfolders", setup, STALE if newer else OK, local=f"{pin} ({pinned_at})",
|
||||
remote=f"{release.tag} ({release.date})",
|
||||
detail="a release is newer than the pin" if newer else "newest release")
|
||||
head = upstream.resolve_head(repo, cache_dir)
|
||||
if head is None:
|
||||
return Finding("romfolders", setup, UNKNOWN, local=pin, detail="branch head unavailable")
|
||||
if head == commit:
|
||||
return Finding("romfolders", setup, OK, local=pin, detail="branch head")
|
||||
counts = upstream.ancestry(repo, commit, head, cache_dir)
|
||||
behind = f"{counts[0]} commits behind the branch head" if counts else "the branch head moved"
|
||||
return Finding("romfolders", setup, STALE, local=pin, remote=head[:12], detail=behind)
|
||||
|
||||
|
||||
# --- core-info --------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -822,6 +940,9 @@ def check_coreinfo(
|
||||
record_path: Path = CORE_INFO_RECORD,
|
||||
) -> list[Finding]:
|
||||
blobs = core_info_blobs(cache_dir, offline)
|
||||
if blobs is None and offline:
|
||||
# Offline with no cached listing, nothing was asked of the upstream.
|
||||
return [Finding("coreinfo", "libretro-core-info", SKIPPED, detail="offline")]
|
||||
if blobs is None:
|
||||
return [Finding("coreinfo", "libretro-core-info", UNKNOWN, detail="listing unavailable")]
|
||||
names = list(blobs)
|
||||
@@ -931,9 +1052,87 @@ def check_catalogs(cache_dir: str, offline: bool) -> list[Finding]:
|
||||
findings.append(_check_tosec(offline))
|
||||
findings.append(_check_mame(cache_dir, offline))
|
||||
findings.append(_check_fbneo(cache_dir, offline))
|
||||
findings.append(_check_nointro_games(cache_dir, offline))
|
||||
findings.append(_check_redump_games(offline))
|
||||
return findings
|
||||
|
||||
|
||||
def _game_catalogs(source: str) -> list[dict]:
|
||||
"""The committed game catalogs of one source, games/catalogs/<source>/."""
|
||||
return [_snapshot(path) for path in sorted((REPO_ROOT / "games" / "catalogs" / source).glob("*.json"))]
|
||||
|
||||
|
||||
def _no_intro_asset_date(cache_dir: str, offline: bool) -> str:
|
||||
"""The day the mirror last rebuilt its No-Intro pack, empty if unlisted."""
|
||||
repo = upstream.parse_repo(NO_INTRO_MIRROR)
|
||||
payload = upstream._api( # noqa: SLF001
|
||||
f"{repo.api_base}/repos/{repo.slug}/releases/tags/{NO_INTRO_RELEASE}",
|
||||
cache_dir,
|
||||
offline,
|
||||
upstream.MOVING_TTL,
|
||||
)
|
||||
for asset in (payload.get("assets") if isinstance(payload, dict) else None) or []:
|
||||
if isinstance(asset, dict) and asset.get("name") == NO_INTRO_ASSET:
|
||||
return str(asset.get("updated_at", ""))[:10]
|
||||
return ""
|
||||
|
||||
|
||||
def _check_nointro_games(cache_dir: str, offline: bool) -> Finding:
|
||||
"""games/catalogs/no-intro/ against the pack it was read from. The pack
|
||||
holds every DAT: rebuilt after the catalogs were read, it may carry
|
||||
games the games-on-card mode does not recognise."""
|
||||
subject = "no-intro games"
|
||||
fetched = min((str(doc.get("fetched", "")) for doc in _game_catalogs("no-intro")), default="")
|
||||
if offline:
|
||||
return Finding("catalogs", subject, SKIPPED, local=fetched, detail="offline")
|
||||
updated = _no_intro_asset_date(cache_dir, offline)
|
||||
if not updated:
|
||||
return Finding("catalogs", subject, UNKNOWN, local=fetched, detail="asset not listed")
|
||||
return Finding(
|
||||
"catalogs", subject, STALE if updated > fetched else OK,
|
||||
local=fetched, remote=updated, detail=f"{NO_INTRO_ASSET} rebuilt {updated}",
|
||||
)
|
||||
|
||||
|
||||
def redump_datfile_version(code: str) -> str:
|
||||
"""The version redump.info serves for one datfile, read from the name it
|
||||
gives the download: a HEAD request, nothing downloaded."""
|
||||
url = game_dat_importer.REDUMP_DATFILE_URL.format(code=code)
|
||||
request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "retrobios/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
disposition = response.headers.get("Content-Disposition", "")
|
||||
named = game_dat_importer._DISPOSITION.search(disposition) # noqa: SLF001
|
||||
match = game_dat_importer._REDUMP_FILE.match(named["file"]) if named else None # noqa: SLF001
|
||||
if not match:
|
||||
raise ValueError(f"{url} names no datfile")
|
||||
return f"{match['date']} {match['time']}"
|
||||
|
||||
|
||||
def _check_redump_games(offline: bool) -> Finding:
|
||||
"""games/catalogs/redump/ against the datfiles redump.info serves now.
|
||||
A disc dumped since is not recognised by the games-on-card mode."""
|
||||
subject = "redump games"
|
||||
local = {doc.get("system"): str(doc.get("version", "")) for doc in _game_catalogs("redump")}
|
||||
newest = max(local.values(), default="")
|
||||
if offline:
|
||||
return Finding("catalogs", subject, SKIPPED, local=newest, detail="offline")
|
||||
changed = []
|
||||
remote_newest = ""
|
||||
for code, (_name, system, _floor) in sorted(game_dat_importer.REDUMP_DATS.items()):
|
||||
try:
|
||||
remote = redump_datfile_version(code)
|
||||
except (OSError, ValueError) as exc:
|
||||
return Finding("catalogs", subject, UNKNOWN, local=newest, detail=str(exc))
|
||||
remote_newest = max(remote_newest, remote)
|
||||
if local.get(system) != remote:
|
||||
changed.append(system)
|
||||
return Finding(
|
||||
"catalogs", subject, STALE if changed else OK, local=newest, remote=remote_newest,
|
||||
detail=(f"changed: {', '.join(changed)}" if changed else f"{len(local)} DATs"),
|
||||
items=changed,
|
||||
)
|
||||
|
||||
|
||||
def _check_redump(offline: bool) -> Finding:
|
||||
local = _snapshot(REPO_ROOT / "provenance" / "redump.json")
|
||||
dats = local.get("dats") or {}
|
||||
@@ -963,18 +1162,7 @@ def _check_no_intro(cache_dir: str, offline: bool) -> Finding:
|
||||
imported = str(local.get("imported_at", ""))
|
||||
if offline:
|
||||
return Finding("catalogs", "no-intro", SKIPPED, local=imported, detail="offline")
|
||||
repo = upstream.parse_repo(NO_INTRO_MIRROR)
|
||||
payload = upstream._api( # noqa: SLF001
|
||||
f"{repo.api_base}/repos/{repo.slug}/releases/tags/{NO_INTRO_RELEASE}",
|
||||
cache_dir,
|
||||
offline,
|
||||
upstream.MOVING_TTL,
|
||||
)
|
||||
assets = payload.get("assets") if isinstance(payload, dict) else None
|
||||
updated = ""
|
||||
for asset in assets or []:
|
||||
if isinstance(asset, dict) and asset.get("name") == NO_INTRO_ASSET:
|
||||
updated = str(asset.get("updated_at", ""))[:10]
|
||||
updated = _no_intro_asset_date(cache_dir, offline)
|
||||
if not updated:
|
||||
return Finding("catalogs", "no-intro", UNKNOWN, local=imported, detail="asset not listed")
|
||||
return Finding(
|
||||
@@ -1410,8 +1598,10 @@ def render(findings: list[Finding]) -> str:
|
||||
cols = f" {f.status:7} {f.subject:{width}}"
|
||||
if f.remote:
|
||||
cols += f" {f.local:{local_width}} -> {f.remote}"
|
||||
elif f.local:
|
||||
# An arrow pointing at nothing read as a lost value.
|
||||
elif f.local or (f.detail and local_width):
|
||||
# An arrow pointing at nothing read as a lost value. An empty
|
||||
# local value still takes its column, or the detail starts
|
||||
# where the other rows show theirs.
|
||||
cols += f" {f.local:{local_width}}"
|
||||
if f.detail:
|
||||
cols += f" {f.detail}"
|
||||
@@ -1506,6 +1696,8 @@ def main() -> int:
|
||||
)
|
||||
if "targets" in areas:
|
||||
findings.extend(check_targets(platforms_dir, workdir, profiles, args.jobs, args.offline))
|
||||
if "romfolders" in areas:
|
||||
findings.extend(check_romfolders(platforms_dir, args.cache_dir, args.offline))
|
||||
if "coreinfo" in areas:
|
||||
findings.extend(check_coreinfo(profiles, args.cache_dir, args.offline))
|
||||
if "data" in areas:
|
||||
|
||||
@@ -19,6 +19,7 @@ import os
|
||||
import sys
|
||||
import zipfile
|
||||
import zlib
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
@@ -676,6 +677,35 @@ def slot_key(f: dict, standalone: bool = False) -> tuple:
|
||||
)
|
||||
|
||||
|
||||
# Why an entry is left out of the comparison, in the order they are printed.
|
||||
SET_ASIDE = {
|
||||
"other_versions": "other versions of a slot",
|
||||
"not_read": "not read on this platform",
|
||||
"outside_scope": "outside the pack",
|
||||
}
|
||||
|
||||
|
||||
def profile_breakdown(data: dict) -> str:
|
||||
"""Where each entry of a profile went, adding up to its file count.
|
||||
|
||||
The heading said 261 files, 7 declared and 238 undeclared, and the 16
|
||||
others were nowhere: the versions a slot accepts, the entries the
|
||||
platform never opens and the ones no pack carries.
|
||||
"""
|
||||
# A report built without the per-entry tally (a fixture, a saved --json
|
||||
# of an earlier run) falls back to its row counts.
|
||||
parts = [f"{data.get('declared_files', data['platform_covered'])} declared by platforms",
|
||||
f"{data.get('undeclared_files', data['gaps'])} undeclared"]
|
||||
unsourceable = data.get("unsourceable_files", len(data.get("unsourceable") or []))
|
||||
if unsourceable:
|
||||
parts.append(f"{unsourceable} unsourceable")
|
||||
aside = data.get("set_aside") or {}
|
||||
detail = [f"{aside[key]} {label}" for key, label in SET_ASIDE.items() if aside.get(key)]
|
||||
if detail:
|
||||
parts.append(f"{sum(aside.values())} set aside ({', '.join(detail)})")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _cross_reference_profile(
|
||||
emu_name: str,
|
||||
profile: dict,
|
||||
@@ -735,6 +765,19 @@ def _cross_reference_profile(
|
||||
for f, is_standalone in layout_files:
|
||||
versions.setdefault(slot_key(f, is_standalone), []).append(f)
|
||||
|
||||
# What became of each entry, so the heading adds up: a file a platform
|
||||
# declares, one it does not, or one set aside and why. An entry read by
|
||||
# two builds keeps the first answer that compares it. Each layout lists
|
||||
# the profile's entries in their order, so a position names one entry.
|
||||
position = {id(f): i for view, _ in layouts for i, f in enumerate(view.get("files", []))}
|
||||
fates: dict[int, str] = {}
|
||||
|
||||
def fate(f: dict, what: str) -> None:
|
||||
key = position[id(f)]
|
||||
current = fates.get(key)
|
||||
if current is None or (current in SET_ASIDE and what not in SET_ASIDE):
|
||||
fates[key] = what
|
||||
|
||||
for f, is_standalone in layout_files:
|
||||
fname = f.get("name", "")
|
||||
# The destination the running build reads, as find_undeclared_files
|
||||
@@ -753,13 +796,21 @@ def _cross_reference_profile(
|
||||
if len(layouts) > 1:
|
||||
# One file read at one path by both builds counts once.
|
||||
seen_key = (effective_path.casefold(), seen_key)
|
||||
if not fname or seen_key in seen_files or unread_on(f, platform):
|
||||
if not fname:
|
||||
fate(f, "outside_scope")
|
||||
continue
|
||||
if seen_key in seen_files:
|
||||
fate(f, "other_versions")
|
||||
continue
|
||||
if unread_on(f, platform):
|
||||
fate(f, "not_read")
|
||||
continue
|
||||
|
||||
# Collect unsourceable files separately (documented, not a gap)
|
||||
unsourceable_reason = f.get("unsourceable", "")
|
||||
if unsourceable_reason:
|
||||
seen_files.add(seen_key)
|
||||
fate(f, "unsourceable")
|
||||
unsourceable_list.append({
|
||||
"name": fname,
|
||||
"required": f.get("required", False),
|
||||
@@ -769,6 +820,7 @@ def _cross_reference_profile(
|
||||
continue
|
||||
|
||||
if outside_gap_scope(f, is_standalone):
|
||||
fate(f, "outside_scope")
|
||||
continue
|
||||
|
||||
archive = f.get("archive")
|
||||
@@ -784,6 +836,7 @@ def _cross_reference_profile(
|
||||
|
||||
if in_platform:
|
||||
seen_files.add(seen_key)
|
||||
fate(f, "declared")
|
||||
covered.append({
|
||||
"name": fname,
|
||||
"path": effective_path,
|
||||
@@ -793,6 +846,7 @@ def _cross_reference_profile(
|
||||
continue
|
||||
|
||||
seen_files.add(seen_key)
|
||||
fate(f, "undeclared")
|
||||
|
||||
if archive:
|
||||
archive_key = (
|
||||
@@ -864,6 +918,7 @@ def _cross_reference_profile(
|
||||
for ag in sorted(archive_gaps.values(), key=lambda e: e["name"]):
|
||||
gaps.append(ag)
|
||||
|
||||
tally = Counter(fates.values())
|
||||
report[emu_name] = {
|
||||
"emulator": profile.get("emulator", emu_name),
|
||||
"systems": systems,
|
||||
@@ -877,6 +932,11 @@ def _cross_reference_profile(
|
||||
"gap_large_file": sum(1 for g in gaps if g["source"] == "large_file"),
|
||||
"gap_details": gaps,
|
||||
"unsourceable": unsourceable_list,
|
||||
# Entries, not rows: an archive's members are one gap row.
|
||||
"declared_files": tally["declared"],
|
||||
"undeclared_files": tally["undeclared"],
|
||||
"unsourceable_files": tally["unsourceable"],
|
||||
"set_aside": {key: tally[key] for key in SET_ASIDE},
|
||||
}
|
||||
|
||||
|
||||
@@ -977,11 +1037,7 @@ def print_report(
|
||||
|
||||
systems = ", ".join(data["systems"])
|
||||
print(f"\n{data['emulator']}" + (f" ({systems})" if systems else ""))
|
||||
print(
|
||||
f" {data['total_files']} files in profile, "
|
||||
f"{data['platform_covered']} declared by platforms, "
|
||||
f"{gaps} undeclared"
|
||||
)
|
||||
print(f" {data['total_files']} files in profile: {profile_breakdown(data)}")
|
||||
print(f" Gaps: {status}")
|
||||
|
||||
for g in data["gap_details"]:
|
||||
|
||||
@@ -55,11 +55,16 @@ def _same(declared: str, accepted: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _accepts(profile_entry: dict, declared: dict[str, set[str]]) -> bool:
|
||||
"""True when the profile entry, or a member of its archive, accepts a
|
||||
hash the platform entry declares."""
|
||||
accepted = _entry_hashes(profile_entry)
|
||||
return any(_same(d, a) for values in declared.values() for d in values for a in accepted)
|
||||
def _accepts(accepted: set[str], declared: dict[str, set[str]]) -> bool:
|
||||
"""True when the hashes a profile entry accepts, the members of its
|
||||
archive included, hold one the platform entry declares."""
|
||||
for values in declared.values():
|
||||
if values & accepted:
|
||||
return True
|
||||
cut = [d for d in values if _CUT_MD5 <= len(d) < _FULL_MD5]
|
||||
if cut and any(a.startswith(d) for d in cut for a in accepted):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _contradicts(profile_entry: dict, declared: dict[str, set[str]]) -> bool:
|
||||
@@ -366,6 +371,10 @@ class EntryCategories:
|
||||
self._guessed = not config.get("cores")
|
||||
self._romsets = sorted(key for key in examined if _loads_romsets(profiles[key]))
|
||||
self._profiles = profiles
|
||||
# The hashes each profile entry accepts, read once: every platform
|
||||
# entry with a proof walks its reader's whole profile, and a MAME
|
||||
# profile's archive members were hashed again for each.
|
||||
self._accepted: dict[int, set[str]] = {}
|
||||
# One index per core and per kind, so a proof names its reader.
|
||||
self._readers: list[tuple[str, str, ReadEvidence]] = []
|
||||
for key in sorted(examined):
|
||||
@@ -403,6 +412,12 @@ class EntryCategories:
|
||||
sorted(unresolved_platform_cores(own, profiles)),
|
||||
)
|
||||
|
||||
def _accepted_by(self, profile_entry: dict) -> set[str]:
|
||||
accepted = self._accepted.get(id(profile_entry))
|
||||
if accepted is None:
|
||||
accepted = self._accepted[id(profile_entry)] = _entry_hashes(profile_entry)
|
||||
return accepted
|
||||
|
||||
def _matching(self, key: str, kind: str, entry: dict) -> list[dict]:
|
||||
"""The profile entries of `key` that read the platform entry.
|
||||
|
||||
@@ -425,7 +440,7 @@ class EntryCategories:
|
||||
if not isinstance(f, dict) or (f.get("category") == GAME_DATA) != (kind == GAME_DATA):
|
||||
continue
|
||||
cited = {k: f[k] for k in ("name", "source_ref") if f.get(k)}
|
||||
if declared and _accepts(f, declared):
|
||||
if declared and _accepts(self._accepted_by(f), declared):
|
||||
by_hash.append(cited)
|
||||
continue
|
||||
own = {str(n).casefold() for n in (f.get("name"), *(f.get("aliases") or [])) if n}
|
||||
|
||||
@@ -34,6 +34,7 @@ import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import posixpath
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
@@ -178,12 +179,25 @@ def _platform_required(platform: str, platforms_dir: str) -> dict[tuple[str, str
|
||||
return out
|
||||
|
||||
|
||||
def _narrowed(manifest: dict, cores: list[str], cache: dict) -> list[tuple[dict, str]]:
|
||||
"""The install list under some cores, each file with its lowercased
|
||||
base name. The answer depends on the cores alone, and a platform asks
|
||||
it once per system they run."""
|
||||
key = (id(manifest), tuple(cores))
|
||||
if key not in cache:
|
||||
files, _omitted = install.narrow(
|
||||
manifest["files"], manifest.get("omitted_files", []), [], cores, [])
|
||||
cache[key] = [(entry, posixpath.basename(entry["dest"]).lower()) for entry in files]
|
||||
return cache[key]
|
||||
|
||||
|
||||
def fallback_set(
|
||||
manifest: dict,
|
||||
platform_required: dict[tuple[str, str], bool],
|
||||
profiles: Profiles,
|
||||
system: str,
|
||||
profile: str | None,
|
||||
narrowed: dict | None = None,
|
||||
) -> dict:
|
||||
"""The files an unrecognised game of one system keeps on one platform.
|
||||
|
||||
@@ -195,12 +209,10 @@ def fallback_set(
|
||||
"""
|
||||
wanted = profiles.resolver.equivalents(system)
|
||||
cores = sorted(install.designated_cores(manifest, [profile])) if profile else []
|
||||
files, _omitted = install.narrow(manifest["files"], manifest.get("omitted_files", []), [], cores, [])
|
||||
rows: list[list] = []
|
||||
for entry in files:
|
||||
for entry, base in _narrowed(manifest, cores, {} if narrowed is None else narrowed):
|
||||
systems = set(entry.get("systems") or [])
|
||||
dest = entry["dest"]
|
||||
base = Path(dest).name.lower()
|
||||
if entry.get("cores") is None:
|
||||
if not systems & wanted:
|
||||
continue
|
||||
@@ -292,6 +304,7 @@ def build_setups(
|
||||
manifests: dict[str, dict] = {}
|
||||
required: dict[str, dict] = {}
|
||||
fallbacks: dict[str, dict[str, dict]] = {}
|
||||
narrowed: dict = {}
|
||||
out: dict[str, dict] = {}
|
||||
coverage: dict[str, dict] = {}
|
||||
for setup, doc in sorted(setups.items()):
|
||||
@@ -330,7 +343,8 @@ def build_setups(
|
||||
for system in resolution.systems:
|
||||
key = f"{system}|{chosen or ''}"
|
||||
if key not in table:
|
||||
table[key] = fallback_set(manifest, required[platform], profiles, system, chosen)
|
||||
table[key] = fallback_set(manifest, required[platform], profiles, system,
|
||||
chosen, narrowed)
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
|
||||
@@ -223,8 +223,7 @@ def line_menu() -> str:
|
||||
"""The line menu of a RetroArch run, g then Enter pressed, as install.py
|
||||
prints it for the RetroArch list (install/retroarch.json): the very
|
||||
function line mode runs, its questions answered here. The page carried
|
||||
a copy typed by hand, whose keys and counts drifted from the installer
|
||||
(code_audit_22, owner's note on which-pack)."""
|
||||
a copy typed by hand, whose keys and counts drifted from the installer."""
|
||||
import io # noqa: PLC0415
|
||||
from unittest import mock # noqa: PLC0415
|
||||
|
||||
@@ -290,7 +289,7 @@ class _ListHandler(http.server.SimpleHTTPRequestHandler):
|
||||
class _LoopbackServer(http.server.ThreadingHTTPServer):
|
||||
"""The list server, named by its number: HTTPServer.server_bind looks
|
||||
127.0.0.1 up with socket.getfqdn, a DNS query wherever /etc/hosts does
|
||||
not name it (code_audit_24 P3-12)."""
|
||||
not name it."""
|
||||
|
||||
def server_bind(self) -> None:
|
||||
socketserver.TCPServer.server_bind(self)
|
||||
|
||||
@@ -929,7 +929,8 @@ the most useful contribution.
|
||||
|
||||
From a clone, the file goes to `bios/Manufacturer/Console/filename`, and
|
||||
`python scripts/validate_pr.py path/to/file` names that folder for a file still
|
||||
outside `bios/`. A file over the browser's 25 MB limit goes in that way.
|
||||
outside `bios/`. Given a folder, such as an old card's BIOS folder, it checks
|
||||
every file under it. A file over the browser's 25 MB limit goes in that way.
|
||||
|
||||
## Add a platform
|
||||
|
||||
|
||||
@@ -3325,7 +3325,7 @@ def with_setup_table(page: str, coverages: dict, registry: dict | None = None) -
|
||||
|
||||
# A platform's file lists are split by the share of a page they render to,
|
||||
# in thousandths: their elements against validate_site's MAX_PAGE_ELEMENTS,
|
||||
# their bytes against the 600,000 of test_site_r2_gate, whichever is
|
||||
# their bytes against the 600,000 of test_site_built_gate, whichever is
|
||||
# larger. Measured in Markdown characters, the lists split anew whenever
|
||||
# their markup changed: rows written as tables moved 38 more systems to
|
||||
# pages of their own.
|
||||
@@ -4053,9 +4053,9 @@ def first_screen_words() -> list[str]:
|
||||
installer = _installer()
|
||||
# The screen it opens first: the setup found, or the list when none
|
||||
# is. Install comes on the next one, Choose systems beside it and named
|
||||
# above on the page (tui_audit_16 P3-10).
|
||||
# above on the page.
|
||||
# Nothing found, the folder comes before Install: which-pack left it
|
||||
# out (tui_audit_17 P3-9).
|
||||
# out.
|
||||
return [f"It asks **{installer.TUI_FOUND_TITLE}** (or **{installer.TUI_CHOOSE_TITLE}** "
|
||||
f"and **{installer.TUI_FOLDER_TITLE}**), then offers **{installer.TUI_INSTALL_ROW}**."]
|
||||
|
||||
@@ -4064,7 +4064,7 @@ def check_pass_line() -> str:
|
||||
"""The pass line of "How to check": the words `--check` prints only when
|
||||
no file is missing or wrong (install.py EVERYTHING_IN_PLACE), read from
|
||||
the installer. Typed here, it kept "All files up to date." after the
|
||||
installer said "Everything is in place." (tui_audit_15 P3-10)."""
|
||||
installer said "Everything is in place."."""
|
||||
return f"All good when it says `{_installer().EVERYTHING_IN_PLACE}`"
|
||||
|
||||
# Under "From your computer": a device with no network installs from the ZIP
|
||||
@@ -10153,7 +10153,7 @@ def _emulator_part_page(
|
||||
for n, (stem, _e) in enumerate(parts, 1)], number - 1),
|
||||
f"# {emu_name} files, part {number} of {len(parts)}",
|
||||
"",
|
||||
# The crumbs end on the emulator, read from parent: (round 5, 7.5).
|
||||
# The crumbs end on the emulator, read from parent:.
|
||||
f"This part holds {_count(len(files), 'entry', 'entries')}, "
|
||||
f"{_code(first)} to {_code(last)}. The other parts: {others}.",
|
||||
"",
|
||||
|
||||
@@ -173,6 +173,14 @@ def manifest_entries(manifest: dict) -> dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
def installer_summary(stdout: str, system_dir: Path, save_dir: Path) -> str:
|
||||
"""The end of the installer's output, as a record keeps it. The scratch
|
||||
folders are written as the options that named them: a record read
|
||||
elsewhere has no use for the path of the machine that ran the probe."""
|
||||
text = stdout.replace(str(system_dir), "<dest>").replace(str(save_dir), "<save-dir>")
|
||||
return " ".join(text.split())[-400:]
|
||||
|
||||
|
||||
def installer_layout(platform: str, pack: Path, identity: dict, manifest_path: Path,
|
||||
root: Path, narrow: Sequence[str] = ()) -> Layout:
|
||||
"""install.py --dest on a scratch root, from the pack, offline. The
|
||||
@@ -197,7 +205,7 @@ def installer_layout(platform: str, pack: Path, identity: dict, manifest_path: P
|
||||
"sha256": file_sha(manifest_path, "sha256"),
|
||||
"generated": manifest.get("generated"), "pack_files": manifest.get("pack_files")},
|
||||
"save_dir_files": len(walk(save_dir)) if save_dir.is_dir() else 0,
|
||||
"installer_summary": " ".join(proc.stdout.split())[-400:],
|
||||
"installer_summary": installer_summary(proc.stdout, system_dir, save_dir),
|
||||
}
|
||||
return Layout("installer", system_dir, files, source)
|
||||
|
||||
|
||||
+18
-4
@@ -257,11 +257,27 @@ class ExtraResolver:
|
||||
# verify's replay: never downloads, and counts the release asset an
|
||||
# online build fetches, as it does for a platform entry.
|
||||
self.pending_assets = pending_assets
|
||||
# Each profile's entries by slot, built on first use: every extra
|
||||
# scanned its whole profile, 33 million entries in one verify --all.
|
||||
self._slots: dict[str, dict[tuple[str, str], list[dict]]] = {}
|
||||
|
||||
def alternatives(self, fe: dict) -> list[dict]:
|
||||
"""The versions the extra's own profile accepts at its slot, as
|
||||
validation.extra_alternatives finds them."""
|
||||
from validation import slot_index
|
||||
|
||||
key = fe.get("source_profile") or ""
|
||||
index = self._slots.get(key)
|
||||
if index is None:
|
||||
profile = (self.profiles or {}).get(key, {})
|
||||
index = self._slots[key] = slot_index(profile.get("files") or [])
|
||||
name = fe.get("source_name") or fe.get("name", "")
|
||||
return index.get((name, fe.get("source_path") or name), [])
|
||||
|
||||
def resolve(self, fe: dict, dest: str) -> tuple[str | None, str, str]:
|
||||
"""(local copy or None, resolver status, the core's disagreement)."""
|
||||
from packresolve import pending_release_asset, resolve_file, run_is_offline
|
||||
from validation import core_size_refusal, extra_alternatives, validated_choice
|
||||
from validation import core_size_refusal, validated_choice
|
||||
|
||||
local_path, status = resolve_file(
|
||||
fe, self.db, self.bios_dir, self.zip_contents,
|
||||
@@ -285,9 +301,7 @@ class ExtraResolver:
|
||||
)
|
||||
# The platform never reads the bytes, the core does: a copy it turns
|
||||
# away by its own size check is not its file, on any platform.
|
||||
if core_size_refusal(
|
||||
fe, local_path, extra_alternatives(fe, self.profiles)
|
||||
):
|
||||
if core_size_refusal(fe, local_path, self.alternatives(fe)):
|
||||
return None, "not_found", ""
|
||||
return local_path, status, disagreement
|
||||
|
||||
|
||||
+23
-1
@@ -405,7 +405,8 @@ def check_consistency(
|
||||
elif released is not None and not (v_names & released):
|
||||
print(f" {v_label}: {v_ok}/{v_total} (no release pack, served by the installer)")
|
||||
else:
|
||||
print(f" {v_label}: {v_ok}/{v_total} (no separate pack)")
|
||||
print(f" {v_label}: MISSING pack: verify judged {v_ok}/{v_total} "
|
||||
"but the build wrote no pack for it")
|
||||
all_ok = False
|
||||
|
||||
status = "OK" if all_ok else "FAILED"
|
||||
@@ -602,6 +603,26 @@ def _refuse_unapplied_flags(
|
||||
args.source = "full"
|
||||
|
||||
|
||||
def _refuse_offline_without_caches(
|
||||
parser: argparse.ArgumentParser, args: argparse.Namespace, platforms_dir: str = "platforms",
|
||||
) -> None:
|
||||
"""An offline run that builds packs needs the data directories cached.
|
||||
|
||||
On a fresh clone none is: --offline skips the refresh, and the run went
|
||||
through the database, verify and every pack before failing at the end
|
||||
of the build. Only an empty cache is refused here: with some cached, the
|
||||
packs decide which ones they need and the build names the rest.
|
||||
"""
|
||||
if not args.offline or args.skip_packs:
|
||||
return
|
||||
registry = load_data_dir_registry(platforms_dir)
|
||||
if registry and not any(os.path.isdir(e.get("local_cache") or "") for e in registry.values()):
|
||||
parser.error(
|
||||
"no data directory is cached and --offline skips their refresh: run "
|
||||
"python scripts/refresh_data_dirs.py once, or drop --offline"
|
||||
)
|
||||
|
||||
|
||||
def _truth_steps(args: argparse.Namespace, results: dict) -> bool:
|
||||
"""Steps 2c to 2e: truth model, its diff, the native export."""
|
||||
all_ok = True
|
||||
@@ -722,6 +743,7 @@ def main() -> None:
|
||||
)
|
||||
args = parser.parse_args()
|
||||
_refuse_unapplied_flags(parser, args)
|
||||
_refuse_offline_without_caches(parser, args)
|
||||
if not args.offline:
|
||||
# Read once and put in the environment every step inherits: the
|
||||
# scrapers and refresh_data_dirs went anonymous unless the user had
|
||||
|
||||
@@ -27,7 +27,7 @@ import zipfile
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from artifacts import file_lock as _file_lock, set_plain_mode
|
||||
from common import github_token, platform_arg, require_platform, yaml_load
|
||||
from common import github_token, platform_arg, require_platform, suggest_names, yaml_load
|
||||
|
||||
try:
|
||||
import yaml
|
||||
@@ -607,7 +607,7 @@ def main() -> None:
|
||||
|
||||
if args.key:
|
||||
if args.key not in registry:
|
||||
log.error("unknown key: %s (available: %s)", args.key, ", ".join(registry))
|
||||
log.error("unknown key: %s. %s", args.key, suggest_names([args.key], list(registry), "--dry-run"))
|
||||
raise SystemExit(1)
|
||||
allowed = registry[args.key].get("for_platforms")
|
||||
if args.platform and allowed and args.platform not in allowed:
|
||||
|
||||
@@ -416,6 +416,18 @@ def _command_for(
|
||||
"fbneo",
|
||||
"--fetch",
|
||||
]
|
||||
if subject in ("no-intro games", "redump games"):
|
||||
# The game DATs download without a browser, unlike the BIOS ones.
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"scripts.scraper.game_dat_importer",
|
||||
"--source",
|
||||
subject.split()[0],
|
||||
"--fetch",
|
||||
"--refresh",
|
||||
"--floor",
|
||||
]
|
||||
# redump / no-intro / tosec need manual DAT download.
|
||||
return None
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ def index_cache(cache_dir: str) -> dict[str, str]:
|
||||
"""Map SHA1 to cached file path for every asset in the cache."""
|
||||
index: dict[str, str] = {}
|
||||
for name in sorted(os.listdir(cache_dir)):
|
||||
# A download in progress writes `<asset>.<random>.tmp` beside the
|
||||
# cache and renames it when done: it is partial, and may be gone by
|
||||
# the time it is opened.
|
||||
if name.endswith(".tmp"):
|
||||
continue
|
||||
path = os.path.join(cache_dir, name)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
|
||||
@@ -29,27 +29,15 @@ from . import BaseTargetScraper
|
||||
|
||||
PLATFORM_NAME = "batocera"
|
||||
|
||||
GITHUB_API = "https://api.github.com/repos/batocera-linux/batocera.linux/contents"
|
||||
try:
|
||||
from ..base_scraper import github_headers
|
||||
except ImportError:
|
||||
from scraper.base_scraper import github_headers
|
||||
|
||||
RAW_BASE = "https://raw.githubusercontent.com/batocera-linux/batocera.linux/{tag}"
|
||||
|
||||
CONFIG_IN_PATH = "package/batocera/core/batocera-system/Config.in"
|
||||
ES_SYSTEMS_PATH = "package/batocera/emulationstation/batocera-es-system/es_systems.yml"
|
||||
|
||||
_TARGET_FLAG_RE = re.compile(r"^(BR2_PACKAGE_BATOCERA_TARGET_\w+)=y", re.MULTILINE)
|
||||
|
||||
# Meta-flag definition: "if COND\n\tconfig DERIVED_FLAG\n\t...\nendif"
|
||||
_META_BLOCK_RE = re.compile(
|
||||
r"^if\s+((?:[^\n]|\\\n)+?)\n" # condition (may span lines via \)
|
||||
r"(?:.*?\n)*?" # optional lines before the config
|
||||
r"\s+config\s+(BR2_PACKAGE_\w+)" # derived flag name
|
||||
r".*?^endif", # end of block
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
_IF_RE = re.compile(r"^if\s+(.*)$")
|
||||
_DEFAULT_Y_RE = re.compile(r"^\s*default\s+y\s*(?:#.*)?$")
|
||||
|
||||
|
||||
def _fetch(url: str, headers: dict | None = None) -> str:
|
||||
@@ -156,13 +144,30 @@ def _parse_meta_flags(text: str) -> list[tuple[str, str]]:
|
||||
"""Return [(derived_flag, condition_str)] from top-level if/endif blocks.
|
||||
|
||||
These define derived flags like BR2_PACKAGE_BATOCERA_TARGET_X86_64_ANY,
|
||||
BR2_PACKAGE_BATOCERA_GLES3, etc.
|
||||
BR2_PACKAGE_BATOCERA_GLES3, etc.: a config inside `if COND` that
|
||||
defaults to y is on exactly when COND holds. Read line by line: a
|
||||
condition continued with a backslash keeps its later lines, and a block
|
||||
whose config is not a package (KNULLI's BR2_KERNEL_LEGACY_3_10) cannot
|
||||
reach into the next one.
|
||||
"""
|
||||
results: list[tuple[str, str]] = []
|
||||
for m in _META_BLOCK_RE.finditer(text):
|
||||
cond = _normalise_condition(m.group(1))
|
||||
flag = m.group(2)
|
||||
results.append((flag, cond))
|
||||
condition: str | None = None
|
||||
config: str | None = None
|
||||
for line in re.sub(r"\\\n", " ", text).splitlines():
|
||||
start = _IF_RE.match(line)
|
||||
if start:
|
||||
condition, config = _normalise_condition(start.group(1)), None
|
||||
continue
|
||||
if condition is None:
|
||||
continue
|
||||
if line.startswith("endif"):
|
||||
condition = config = None
|
||||
continue
|
||||
found = _CONFIG_LINE_RE.match(line)
|
||||
if found:
|
||||
config = found.group(1)
|
||||
elif config and _DEFAULT_Y_RE.match(line):
|
||||
results.append((config, condition))
|
||||
return results
|
||||
|
||||
|
||||
@@ -174,8 +179,6 @@ BUILDROOT_KCONFIG = (
|
||||
"package/mesa3d/Config.in",
|
||||
"package/libglvnd/Config.in",
|
||||
)
|
||||
BUILDROOT_RAW = "https://raw.githubusercontent.com/batocera-linux/buildroot/{sha}"
|
||||
SYSTEM_SYMBOL = "BR2_PACKAGE_BATOCERA_SYSTEM"
|
||||
|
||||
_CONFIG_LINE_RE = re.compile(r"^\s*(?:menu)?config\s+(BR2_\w+)")
|
||||
_OWNED_SELECT_RE = re.compile(r"^\s+select\s+(BR2_\w+)(?:\s+if\s+([^#]+))?")
|
||||
@@ -217,22 +220,26 @@ def _active_symbols(
|
||||
|
||||
Run to a fixed point, as Kconfig does: a select can turn on a config
|
||||
whose own selects then fire, and a derived flag can follow from either.
|
||||
Each pass rebuilds the set from the board's own flags, so a select
|
||||
guarded by a negation (`if !BR2_PACKAGE_BATOCERA_GLES3`) that fired
|
||||
before GLES3 was derived is dropped once it is: accumulating kept
|
||||
duckstation-legacy on every GLES3 board.
|
||||
"""
|
||||
active = set(board_flags)
|
||||
while True:
|
||||
before = len(active)
|
||||
frozen = frozenset(active)
|
||||
active |= {
|
||||
base = frozenset(board_flags)
|
||||
active = base
|
||||
for _ in range(64):
|
||||
following = base | {
|
||||
derived for derived, condition in meta_rules
|
||||
if _condition_holds(condition, frozen)
|
||||
}
|
||||
active |= {
|
||||
if _condition_holds(condition, active)
|
||||
} | {
|
||||
selected
|
||||
for owner, selected, condition in rules
|
||||
if owner in frozen and _condition_holds(condition, frozen)
|
||||
if owner in active and _condition_holds(condition, active)
|
||||
}
|
||||
if len(active) == before:
|
||||
return frozenset(active)
|
||||
if following == active:
|
||||
return active
|
||||
active = frozenset(following)
|
||||
raise RuntimeError("Kconfig selects do not settle: a negated condition cycles")
|
||||
|
||||
|
||||
def _parse_es_systems(text: str) -> dict[str, list[str]]:
|
||||
@@ -279,8 +286,25 @@ def _parse_es_systems(text: str) -> dict[str, list[str]]:
|
||||
return package_to_emulators
|
||||
|
||||
|
||||
def _arch_from_flag(flag: str) -> str:
|
||||
"""Guess architecture from board flag name."""
|
||||
# Buildroot's architecture choice, as a board sets it (arch/Config.in).
|
||||
_ARCH_SYMBOLS = {
|
||||
"BR2_aarch64": "aarch64",
|
||||
"BR2_arm": "arm",
|
||||
"BR2_x86_64": "x86_64",
|
||||
"BR2_i386": "x86",
|
||||
"BR2_riscv": "riscv64",
|
||||
}
|
||||
|
||||
|
||||
def _arch_from_flag(flag: str, board_flags: set[str] | frozenset[str] = frozenset()) -> str:
|
||||
"""The board's Buildroot architecture, else a guess from its target flag.
|
||||
|
||||
KNULLI's R16, RK3128 and ATM7039 images are 32-bit ARM (BR2_arm=y):
|
||||
the target flag alone does not say so.
|
||||
"""
|
||||
for symbol, arch in _ARCH_SYMBOLS.items():
|
||||
if symbol in board_flags:
|
||||
return arch
|
||||
low = flag.lower()
|
||||
if "x86_64" in low or "zen3" in low:
|
||||
return "x86_64"
|
||||
@@ -290,35 +314,51 @@ def _arch_from_flag(flag: str) -> str:
|
||||
|
||||
|
||||
class Scraper(BaseTargetScraper):
|
||||
"""Cross-references Batocera boards, Config.in, and es_systems to build target lists."""
|
||||
"""Cross-references Batocera boards, Config.in, and es_systems to build target lists.
|
||||
|
||||
def __init__(
|
||||
self, url: str = "https://github.com/batocera-linux/batocera.linux"
|
||||
) -> None:
|
||||
super().__init__(url=url)
|
||||
A Buildroot tree built on Batocera's model (KNULLI) overrides the class
|
||||
attributes: its repository, board prefix, system Config.in, es_systems
|
||||
and the config every image turns on.
|
||||
"""
|
||||
|
||||
PLATFORM = PLATFORM_NAME
|
||||
REPO = "batocera-linux/batocera.linux"
|
||||
BOARD_PREFIX = "batocera-"
|
||||
CONFIG_IN_PATH = "package/batocera/core/batocera-system/Config.in"
|
||||
ES_SYSTEMS_PATH = "package/batocera/emulationstation/batocera-es-system/es_systems.yml"
|
||||
SYSTEM_SYMBOL = "BR2_PACKAGE_BATOCERA_SYSTEM"
|
||||
BUILDROOT_REPO = "batocera-linux/buildroot"
|
||||
|
||||
def __init__(self, url: str = "") -> None:
|
||||
super().__init__(url=url or f"https://github.com/{self.REPO}")
|
||||
self.tag = ""
|
||||
|
||||
def _raw(self, path: str) -> str:
|
||||
return f"{RAW_BASE.format(tag=self.tag)}/{path}"
|
||||
return f"https://raw.githubusercontent.com/{self.REPO}/{self.tag}/{path}"
|
||||
|
||||
def _api(self, path: str) -> str:
|
||||
return f"https://api.github.com/repos/{self.REPO}/contents/{path}?ref={self.tag}"
|
||||
|
||||
def _list_boards(self) -> list[str]:
|
||||
"""List batocera-*.board files from configs/ via GitHub API."""
|
||||
data = _fetch_json(f"{GITHUB_API}/configs?ref={self.tag}")
|
||||
"""List <prefix>*.board files from configs/ via GitHub API."""
|
||||
data = _fetch_json(self._api("configs"))
|
||||
boards = [
|
||||
item["name"]
|
||||
for item in data
|
||||
if isinstance(item, dict)
|
||||
and item.get("name", "").startswith("batocera-")
|
||||
and item.get("name", "").startswith(self.BOARD_PREFIX)
|
||||
and item.get("name", "").endswith(".board")
|
||||
] if isinstance(data, list) else []
|
||||
if not boards:
|
||||
raise RuntimeError(f"no batocera-*.board file in configs/ at {self.tag}")
|
||||
raise RuntimeError(
|
||||
f"no {self.BOARD_PREFIX}*.board file in configs/ at {self.tag}"
|
||||
)
|
||||
return boards
|
||||
|
||||
def _fetch_board(self, board_name: str) -> tuple[str | None, set[str]]:
|
||||
"""A board's target flag and every flag it sets, includes followed.
|
||||
|
||||
batocera-board.common turns on BATOCERA_SYSTEM, the config whose
|
||||
<prefix>board.common turns on the system symbol, the config whose
|
||||
selects bring in the display stack and every emulator group.
|
||||
"""
|
||||
flags: set[str] = set()
|
||||
@@ -343,24 +383,25 @@ class Scraper(BaseTargetScraper):
|
||||
return target, flags
|
||||
|
||||
def _buildroot_rules(self) -> list[tuple[str, str, str]]:
|
||||
"""Buildroot's selects for the symbols Batocera tests, at its pin."""
|
||||
pin = _fetch_json(f"{GITHUB_API}/buildroot?ref={self.tag}")
|
||||
"""Buildroot's selects for the symbols the tree tests, at its pin."""
|
||||
pin = _fetch_json(self._api("buildroot"))
|
||||
sha = pin.get("sha") if isinstance(pin, dict) else None
|
||||
if not sha:
|
||||
raise RuntimeError(f"no buildroot submodule commit at {self.tag}")
|
||||
raw = f"https://raw.githubusercontent.com/{self.BUILDROOT_REPO}/{sha}"
|
||||
rules: list[tuple[str, str, str]] = []
|
||||
for path in BUILDROOT_KCONFIG:
|
||||
rules += _parse_owned_selects(_fetch(f"{BUILDROOT_RAW.format(sha=sha)}/{path}"))
|
||||
rules += _parse_owned_selects(_fetch(f"{raw}/{path}"))
|
||||
return rules
|
||||
|
||||
def fetch_targets(self) -> dict:
|
||||
"""Build per-board emulator availability map."""
|
||||
self.tag = self.pinned_revision(PLATFORM_NAME)
|
||||
self.tag = self.pinned_revision(self.PLATFORM)
|
||||
print(" fetching board list...", file=sys.stderr)
|
||||
boards = self._list_boards()
|
||||
|
||||
print(" fetching Config.in...", file=sys.stderr)
|
||||
config_in_url = self._raw(CONFIG_IN_PATH)
|
||||
config_in_url = self._raw(self.CONFIG_IN_PATH)
|
||||
config_in_text = _fetch(config_in_url)
|
||||
if not config_in_text:
|
||||
raise RuntimeError(f"empty Config.in from {config_in_url}")
|
||||
@@ -376,7 +417,7 @@ class Scraper(BaseTargetScraper):
|
||||
rules += self._buildroot_rules()
|
||||
|
||||
print(" fetching es_systems.yml...", file=sys.stderr)
|
||||
es_systems_url = self._raw(ES_SYSTEMS_PATH)
|
||||
es_systems_url = self._raw(self.ES_SYSTEMS_PATH)
|
||||
es_text = _fetch(es_systems_url)
|
||||
if not es_text:
|
||||
raise RuntimeError(f"empty es_systems.yml from {es_systems_url}")
|
||||
@@ -390,7 +431,7 @@ class Scraper(BaseTargetScraper):
|
||||
|
||||
targets: dict[str, dict] = {}
|
||||
for board_name in sorted(boards):
|
||||
target_key = board_name.removeprefix("batocera-").removesuffix(".board")
|
||||
target_key = board_name.removeprefix(self.BOARD_PREFIX).removesuffix(".board")
|
||||
print(f" processing {target_key}...", file=sys.stderr)
|
||||
primary_flag, board_flags = self._fetch_board(board_name)
|
||||
if primary_flag is None:
|
||||
@@ -400,8 +441,8 @@ class Scraper(BaseTargetScraper):
|
||||
selected_packages = _active_symbols(board_flags, meta_rules, rules)
|
||||
# x86_wow64 builds the 32-bit Wine package the x86_64 image
|
||||
# carries: no batocera-board.common, no system, no target.
|
||||
if SYSTEM_SYMBOL not in selected_packages:
|
||||
print(f" {board_name} builds no Batocera image", file=sys.stderr)
|
||||
if self.SYSTEM_SYMBOL not in selected_packages:
|
||||
print(f" {board_name} builds no system image", file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Map selected packages to emulator names via es_systems.yml
|
||||
@@ -410,7 +451,7 @@ class Scraper(BaseTargetScraper):
|
||||
for emu in package_to_emulators.get(pkg, []):
|
||||
emulators.add(emu)
|
||||
|
||||
arch = _arch_from_flag(primary_flag)
|
||||
arch = _arch_from_flag(primary_flag, board_flags)
|
||||
targets[target_key] = {
|
||||
"architecture": arch,
|
||||
"cores": sorted(emulators),
|
||||
@@ -421,23 +462,23 @@ class Scraper(BaseTargetScraper):
|
||||
)
|
||||
|
||||
return {
|
||||
"platform": "batocera",
|
||||
"platform": self.PLATFORM,
|
||||
"source": f"{self.url}/tree/{self.tag}",
|
||||
"scraped_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"targets": targets,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def main(scraper_class: type[Scraper] = Scraper) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
allow_abbrev=False,
|
||||
description="Scrape Batocera per-board emulator targets"
|
||||
description=f"Scrape {scraper_class.PLATFORM} per-board emulator targets"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show target summary")
|
||||
parser.add_argument("--output", "-o", help="Output YAML file")
|
||||
args = parser.parse_args()
|
||||
|
||||
scraper = Scraper()
|
||||
scraper = scraper_class()
|
||||
data = scraper.fetch_targets()
|
||||
|
||||
if args.dry_run:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Scraper for KNULLI per-board emulator availability.
|
||||
|
||||
KNULLI is a Buildroot external tree on Batocera's model, read with the
|
||||
Batocera scraper's Kconfig resolution. Sources (knulli-cfw/knulli-linux, at
|
||||
the tag platforms/knulli.yml names):
|
||||
- configs/knulli-*.board -- board definitions, each sets
|
||||
BR2_PACKAGE_BATOCERA_TARGET_* and includes knulli-board.common
|
||||
- package/system/knulli-system/Config.in -- the system config, sourced by
|
||||
the root Config.in in place of Batocera's batocera-system/Config.in
|
||||
- package/emulationstation/knulli-es-system/es_systems.yml
|
||||
-- emulator requireAnyOf flag mapping
|
||||
Sources (knulli-cfw/buildroot, at the submodule commit the tag pins):
|
||||
- arch/Config.in, package/mesa3d/Config.in, package/libglvnd/Config.in
|
||||
|
||||
The *_armhf_libs boards include knulli-board.libs, which builds 32-bit
|
||||
libraries for the matching image and turns no system on: they are no target.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .batocera_targets_scraper import Scraper as BatoceraScraper
|
||||
from .batocera_targets_scraper import main as batocera_main
|
||||
|
||||
PLATFORM_NAME = "knulli"
|
||||
|
||||
|
||||
class Scraper(BatoceraScraper):
|
||||
PLATFORM = PLATFORM_NAME
|
||||
REPO = "knulli-cfw/knulli-linux"
|
||||
BOARD_PREFIX = "knulli-"
|
||||
CONFIG_IN_PATH = "package/system/knulli-system/Config.in"
|
||||
ES_SYSTEMS_PATH = "package/emulationstation/knulli-es-system/es_systems.yml"
|
||||
SYSTEM_SYMBOL = "BR2_PACKAGE_KNULLI_SYSTEM"
|
||||
BUILDROOT_REPO = "knulli-cfw/buildroot"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
batocera_main(Scraper)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -45,7 +45,7 @@ PIN: dict[str, re.Pattern[str]] = {
|
||||
# What the served copy says of a refused installer. It names a commit's
|
||||
# install.py, which never changes, and both downloaders fail on a short
|
||||
# transfer before the hash is read: neither a cut download nor caches of
|
||||
# two ages explain a mismatch there (code_audit_21 P3-1).
|
||||
# two ages explain a mismatch there.
|
||||
SERVED_ADVICE = ("The file was changed on its way here, most often by a proxy or an "
|
||||
"antivirus that rewrites downloads. Run the command again from another "
|
||||
"network, or with that web protection paused.")
|
||||
@@ -73,7 +73,7 @@ def served_text(name: str, root: Path, commit: str) -> str:
|
||||
advice fitted to that. A pin that is not the SHA-256 of root's
|
||||
install.py stops the build: published, that copy refused every install
|
||||
until the next good deploy, where a failed build keeps the last good
|
||||
pair served (code_audit_21 P3-2)."""
|
||||
pair served."""
|
||||
source, line = SOURCES[name]
|
||||
text = (root / source).read_text(encoding="utf-8")
|
||||
pinned = PIN[name].findall(text)
|
||||
|
||||
+41
-10
@@ -23,6 +23,7 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import (
|
||||
@@ -97,10 +98,13 @@ class ValidationResult:
|
||||
lines = [f"### {status} `{self.filename}`"]
|
||||
lines.append("")
|
||||
lines.append(f"- **Path**: `{self.filepath}`")
|
||||
lines.append(f"- **Size**: {self.size:,} bytes")
|
||||
lines.append(f"- **SHA1**: `{self.sha1}`")
|
||||
lines.append(f"- **MD5**: `{self.md5}`")
|
||||
lines.append(f"- **CRC32**: `{self.crc32}`")
|
||||
# A path refused before it was read has no size or digest to show:
|
||||
# empty fields read as an empty file contributed.
|
||||
if self.sha1:
|
||||
lines.append(f"- **Size**: {self.size:,} bytes")
|
||||
lines.append(f"- **SHA1**: `{self.sha1}`")
|
||||
lines.append(f"- **MD5**: `{self.md5}`")
|
||||
lines.append(f"- **CRC32**: `{self.crc32}`")
|
||||
lines.append("")
|
||||
|
||||
for status_str, message in self.checks:
|
||||
@@ -312,11 +316,17 @@ def validate_file(
|
||||
return result
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
result.add_check(False, f"File not found: {filepath}")
|
||||
result.add_check(False, f"File not found: {filepath}. A path is read from the folder "
|
||||
"the command runs in, or pass --changed to check the files "
|
||||
"changed under bios/")
|
||||
return result
|
||||
|
||||
if os.path.isdir(filepath):
|
||||
result.add_check(False, "Folder with no file in it")
|
||||
return result
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
result.add_check(False, "Not a regular file")
|
||||
result.add_check(False, "Not a regular file: a device, a pipe or a socket")
|
||||
return result
|
||||
|
||||
result.size = os.path.getsize(filepath)
|
||||
@@ -623,6 +633,26 @@ def get_changed_files() -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def expand_folders(paths: list[str]) -> list[str]:
|
||||
"""Each folder named replaced by the files under it, subfolders included.
|
||||
|
||||
A card's BIOS folder was refused as "Not a regular file", and the
|
||||
contributor had to list its files by hand. A link inside the folder
|
||||
stays listed, so it is refused like a named one, and an empty folder
|
||||
stays as itself to be reported.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for path in paths:
|
||||
if not os.path.isdir(path) or os.path.islink(path):
|
||||
out.append(path)
|
||||
continue
|
||||
found = sorted(
|
||||
str(f) for f in Path(path).rglob("*") if f.is_symlink() or f.is_file()
|
||||
)
|
||||
out.extend(found or [path])
|
||||
return out
|
||||
|
||||
|
||||
def _files_to_validate(
|
||||
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
) -> list:
|
||||
@@ -634,7 +664,7 @@ def _files_to_validate(
|
||||
if not args.changed:
|
||||
if not args.files:
|
||||
parser.error("No files specified. Use --changed or provide file paths.")
|
||||
return args.files
|
||||
return expand_folders(args.files)
|
||||
try:
|
||||
files = get_changed_files()
|
||||
except (RuntimeError, OSError) as exc:
|
||||
@@ -737,9 +767,10 @@ def main() -> None:
|
||||
for r in results:
|
||||
status = "PASS" if r.passed else "FAIL"
|
||||
print(f"\n[{status}] {r.filepath}")
|
||||
print(f" SHA1: {r.sha1}")
|
||||
print(f" MD5: {r.md5}")
|
||||
print(f" Size: {r.size:,}")
|
||||
if r.sha1:
|
||||
print(f" SHA1: {r.sha1}")
|
||||
print(f" MD5: {r.md5}")
|
||||
print(f" Size: {r.size:,}")
|
||||
for s, m in r.checks:
|
||||
marker = (
|
||||
"ok"
|
||||
|
||||
@@ -39,6 +39,22 @@ def _errors(validator: Draft202012Validator, data: object, label: str) -> list[s
|
||||
return out
|
||||
|
||||
|
||||
_SOURCES: dict[tuple[Path, int, int], object] = {}
|
||||
|
||||
|
||||
def _load_source(path: Path) -> object:
|
||||
"""A source YAML, parsed once per run whichever check reads it: the
|
||||
schema pass, the data directory refs and the layout check each parsed
|
||||
all 649 profiles again. Keyed by size and mtime too, so a file written
|
||||
again in the same process is read again."""
|
||||
stat = path.stat()
|
||||
key = (path, stat.st_mtime_ns, stat.st_size)
|
||||
if key not in _SOURCES:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
_SOURCES[key] = yaml_load(handle)
|
||||
return _SOURCES[key]
|
||||
|
||||
|
||||
def _validate_yaml_directory(
|
||||
directory: Path,
|
||||
schema_name: str,
|
||||
@@ -51,8 +67,7 @@ def _validate_yaml_directory(
|
||||
if skip_private and path.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
data = yaml_load(handle)
|
||||
data = _load_source(path)
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
out.append(f"{path.relative_to(ROOT)}: {exc}")
|
||||
continue
|
||||
@@ -273,8 +288,7 @@ def _unknown_data_dir_refs() -> list[str]:
|
||||
for path in source_yaml_paths(directory):
|
||||
if path.name.startswith("_"):
|
||||
continue
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
document = yaml_load(handle) or {}
|
||||
document = _load_source(path) or {}
|
||||
systems = document.get("systems")
|
||||
holders = [document, *(systems.values() if isinstance(systems, dict) else [])]
|
||||
for holder in holders:
|
||||
@@ -299,8 +313,7 @@ def _repository_layout_in_profiles() -> list[str]:
|
||||
"""
|
||||
out: list[str] = []
|
||||
for path in source_yaml_paths(ROOT / "emulators"):
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
document = yaml_load(handle) or {}
|
||||
document = _load_source(path) or {}
|
||||
for entry in document.get("files") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -688,6 +701,34 @@ def _games_source_errors() -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _arcade_pin_errors() -> list[str]:
|
||||
"""games/arcade/<profile>.json read at the revision and version its
|
||||
profile pins. A bump of the profile left the game-to-BIOS map of the old
|
||||
code in place, and the installer kept serving it."""
|
||||
out: list[str] = []
|
||||
for path in sorted((ROOT / "games" / "arcade").glob("*.json")):
|
||||
doc = _load_json(path)
|
||||
profile_path = ROOT / "emulators" / f"{doc.get('profile')}.yml"
|
||||
label = str(path.relative_to(ROOT))
|
||||
if not profile_path.is_file():
|
||||
out.append(f"{label}: no profile emulators/{profile_path.name}")
|
||||
continue
|
||||
profile = _load_source(profile_path) or {}
|
||||
pinned = profile.get("source_commit")
|
||||
pins = set(pinned.values()) if isinstance(pinned, dict) else {pinned}
|
||||
commit = (doc.get("source") or {}).get("commit")
|
||||
drifted = []
|
||||
if commit and commit not in pins:
|
||||
drifted.append(f"read at {commit[:12]}, the profile pins {pinned}")
|
||||
if doc.get("core_version") != profile.get("core_version"):
|
||||
drifted.append(f"core_version {doc.get('core_version')!r}, "
|
||||
f"the profile says {profile.get('core_version')!r}")
|
||||
if drifted:
|
||||
out.append(f"{label}: {'; '.join(drifted)} "
|
||||
f"(python -m scripts.scraper.arcade_parent_importer --profile {doc.get('profile')})")
|
||||
return out
|
||||
|
||||
|
||||
def _games_index_errors() -> list[str]:
|
||||
"""install/games/: each document against its kind, and the index
|
||||
against the files it lists (size and sha256)."""
|
||||
@@ -752,6 +793,7 @@ def main() -> int:
|
||||
errors.extend(_measurement_errors())
|
||||
errors.extend(_romfolder_errors())
|
||||
errors.extend(_games_source_errors())
|
||||
errors.extend(_arcade_pin_errors())
|
||||
|
||||
if not args.source_only:
|
||||
database_path = ROOT / "database.json"
|
||||
|
||||
@@ -1004,6 +1004,17 @@ def same_slot_entries(files: list, name: str, path: str) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def slot_index(files: list) -> dict[tuple[str, str], list[dict]]:
|
||||
"""A profile's entries keyed by (name, path), as same_slot_entries
|
||||
matches them: one pass, then one lookup per extra."""
|
||||
index: dict[tuple[str, str], list[dict]] = {}
|
||||
for entry in files or []:
|
||||
if isinstance(entry, dict):
|
||||
name = entry.get("name")
|
||||
index.setdefault((name, entry.get("path") or name), []).append(entry)
|
||||
return index
|
||||
|
||||
|
||||
def extra_alternatives(fe: dict, profiles: Mapping | None) -> list[dict]:
|
||||
"""The versions the extra's own profile accepts at the extra's slot."""
|
||||
if not profiles:
|
||||
|
||||
@@ -8,7 +8,7 @@ that is down) and written to RETROBIOS_TEST_NETLOG, and the cards and drives
|
||||
mounted under /media, /run/media, /mnt and /Volumes, which read as folders
|
||||
no drive is mounted on. A test module passed on a machine with no network
|
||||
and no card, and failed with an AmberELEC card in the reader, while 261
|
||||
connections to GitHub left the module in a run (code_audit_24 P3-13).
|
||||
connections to GitHub left the module in a run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -401,6 +401,30 @@ class CommittedIndex(unittest.TestCase):
|
||||
errors = list(validator.iter_errors(json.loads(path.read_text(encoding="utf-8"))))
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
@unittest.skipIf(Draft202012Validator is None, "jsonschema not installed")
|
||||
def test_each_file_is_read_at_its_profile_pin(self) -> None:
|
||||
"""A bumped profile kept the game-to-BIOS map of the old code."""
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import validate_schemas
|
||||
|
||||
self.assertEqual(validate_schemas._arcade_pin_errors(), [])
|
||||
with tempfile.TemporaryDirectory(dir=TMP_ROOT) as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "games" / "arcade").mkdir(parents=True)
|
||||
(root / "emulators").mkdir()
|
||||
(root / "emulators" / "core.yml").write_text(
|
||||
f"source_commit: {'b' * 40}\ncore_version: '0.2'\n")
|
||||
(root / "games" / "arcade" / "core.json").write_text(json.dumps(
|
||||
{"profile": "core", "core_version": "0.1", "source": {"commit": COMMIT}}))
|
||||
with mock.patch.object(validate_schemas, "ROOT", root):
|
||||
(error,) = validate_schemas._arcade_pin_errors()
|
||||
self.assertIn("read at aaaaaaaaaaaa", error)
|
||||
self.assertIn("core_version '0.1'", error)
|
||||
self.assertIn("arcade_parent_importer --profile core", error)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -214,5 +214,34 @@ class ScratchFilesGetThePlainMode(unittest.TestCase):
|
||||
self.assertEqual(unset, [])
|
||||
|
||||
|
||||
|
||||
class RestoreSkipsDownloadsInProgress(unittest.TestCase):
|
||||
"""A scratch file another run is still writing is neither hashed nor read."""
|
||||
|
||||
def test_a_scratch_promoted_mid_scan_does_not_stop_the_restore(self) -> None:
|
||||
import restore_large_files
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache = Path(tmp)
|
||||
(cache / "done.bin").write_bytes(b"whole")
|
||||
scratch = cache / "big.bin.ab12.tmp"
|
||||
scratch.write_bytes(b"part")
|
||||
real_isfile = os.path.isfile
|
||||
|
||||
def check_then_promote(path: str) -> bool:
|
||||
found = real_isfile(path)
|
||||
# The concurrent fetch renames its scratch file right after
|
||||
# the check, before the open.
|
||||
if path == str(scratch) and found:
|
||||
os.replace(scratch, cache / "big.bin")
|
||||
return found
|
||||
|
||||
with mock.patch.object(restore_large_files.os.path, "isfile",
|
||||
side_effect=check_then_promote):
|
||||
index = restore_large_files.index_cache(str(cache))
|
||||
self.assertNotIn(str(scratch), index.values())
|
||||
self.assertIn(str(cache / "done.bin"), index.values())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4427,7 +4427,7 @@ class EmulatorPagesStateTheirDataForMachines(unittest.TestCase):
|
||||
{"name": "free.bin"}],
|
||||
})
|
||||
self.assertEqual(data["about"]["codeRepository"], "https://github.com/stenzek/duckstation")
|
||||
# The release the profile describes, never the pin (round 5, 3.5).
|
||||
# The release the profile describes, never the pin.
|
||||
self.assertEqual(data["about"]["version"], "v0.1-12070")
|
||||
self.assertNotIn("description", data["about"])
|
||||
self.assertEqual(data["dateModified"], "2026-03-23")
|
||||
@@ -4560,7 +4560,12 @@ class TargetListsReadThePinnedRelease(unittest.TestCase):
|
||||
for name in ("batocera", "rocknix", "recalbox"):
|
||||
source = (ROOT / "scripts" / "scraper" / "targets" / f"{name}_targets_scraper.py").read_text()
|
||||
with self.subTest(scraper=name):
|
||||
self.assertIn("self.pinned_revision(PLATFORM_NAME)", source)
|
||||
# The Batocera scraper reads the pin of the platform it is
|
||||
# instantiated for (KNULLI subclasses it).
|
||||
self.assertTrue(
|
||||
"self.pinned_revision(PLATFORM_NAME)" in source
|
||||
or "self.pinned_revision(self.PLATFORM)" in source
|
||||
)
|
||||
self.assertNotIn("fetch_stable_tag()", source)
|
||||
self.assertNotIn("fetch_github_latest_version(", source)
|
||||
|
||||
@@ -6335,6 +6340,20 @@ class WhatOnlyAnInstallProvidesIsListed(unittest.TestCase):
|
||||
copies = bundled_only.other_copies([row], Path(tmp))
|
||||
self.assertEqual([c["path"] for c in copies], [Path(tmp, "dir2/a.bmp").as_posix()])
|
||||
|
||||
def test_the_collection_holds_no_copy_only_an_install_provides(self):
|
||||
"""The pull request checks run this suite, not the pipeline: without
|
||||
this, a copy only a bundled entry reaches merges green."""
|
||||
import bundled_only
|
||||
from common import load_database, load_emulator_profiles
|
||||
|
||||
rows = bundled_only.bundled_only(
|
||||
load_database(str(ROOT / "database.json")),
|
||||
load_emulator_profiles(str(ROOT / "emulators")),
|
||||
str(ROOT / "platforms"), ROOT / "install",
|
||||
)
|
||||
self.assertEqual([f"{r['path']} ({', '.join(r['emulators'])})" for r in rows], [],
|
||||
"remove the copy: the emulator's own install provides it")
|
||||
|
||||
|
||||
class TheApiSchemaHoldsTheFieldsItPromises(unittest.TestCase):
|
||||
"""The data page promises fields are only added within v1, and the
|
||||
|
||||
@@ -310,6 +310,15 @@ class CoreResolutionTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
cf.record_core_info({"a": "2"}, ["ghost"], record)
|
||||
|
||||
def test_offline_without_a_cached_listing_is_skipped(self):
|
||||
"""Offline, the row read UNKNOWN and failed the run as an upstream
|
||||
that could not be read, though nothing was asked of it."""
|
||||
with mock.patch.object(cf, "core_info_blobs", return_value=None):
|
||||
(row,) = cf.check_coreinfo(self.PROFILES, "", True)
|
||||
self.assertEqual((row.status, row.detail), (cf.SKIPPED, "offline"))
|
||||
(row,) = cf.check_coreinfo(self.PROFILES, "", False)
|
||||
self.assertEqual(row.status, cf.UNKNOWN)
|
||||
|
||||
def test_the_committed_record_holds_git_blobs(self):
|
||||
recorded = cf.load_core_info_record()
|
||||
self.assertIsNotNone(recorded)
|
||||
@@ -344,7 +353,74 @@ class PinParsingTests(unittest.TestCase):
|
||||
self.assertTrue(cf.specifier_allows(">=10.14", "12.0.1"))
|
||||
|
||||
|
||||
class RomFolderPins(unittest.TestCase):
|
||||
"""The rom folders of a setup were pinned by hand and nothing compared
|
||||
the pins with the platform file or the project's releases."""
|
||||
|
||||
def _tree(self, tmp: str, platform_source: str, os_source: str) -> Path:
|
||||
root = Path(tmp)
|
||||
(root / "romfolders" / "_sources").mkdir(parents=True)
|
||||
(root / "_registry.yml").write_text("platforms:\n plat: {}\n")
|
||||
(root / "plat.yml").write_text(f"source: {platform_source}\n")
|
||||
(root / "romfolders" / "_sources" / "plat.yml").write_text(
|
||||
f"sources:\n os:\n repo: https://github.com/o/r\n{os_source}")
|
||||
(root / "romfolders" / "_sources" / "other.yml").write_text(
|
||||
"sources:\n os:\n repo: https://github.com/x/y\n commit: " + "c" * 40 + "\n")
|
||||
return root
|
||||
|
||||
def test_a_platform_setup_follows_its_platform_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = self._tree(tmp, "https://raw.githubusercontent.com/o/r/v2/list.txt",
|
||||
" tag: v1\n commit: " + "a" * 40 + "\n")
|
||||
rows = {f.subject: f for f in cf.check_romfolders(root, "", True)}
|
||||
self.assertEqual(rows["plat"].status, cf.STALE)
|
||||
self.assertIn("scraped at v2, the rom folders at v1", rows["plat"].detail)
|
||||
self.assertEqual(rows["other"].status, cf.SKIPPED)
|
||||
|
||||
def test_the_same_revision_is_fresh(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = self._tree(tmp, "https://github.com/o/r/tree/" + "a" * 40,
|
||||
" commit: " + "a" * 40 + "\n")
|
||||
rows = {f.subject: f for f in cf.check_romfolders(root, "", True)}
|
||||
self.assertEqual(rows["plat"].status, cf.OK)
|
||||
|
||||
def test_a_newer_release_makes_another_setup_stale(self):
|
||||
release = cf.upstream.Release("v9", "2026-11-01", False)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = self._tree(tmp, "https://github.com/o/r/tree/" + "a" * 40,
|
||||
" commit: " + "a" * 40 + "\n")
|
||||
with mock.patch.object(cf.upstream, "commit_date", return_value="2026-10-01T00:00:00"), \
|
||||
mock.patch.object(cf.upstream, "latest_release", return_value=release):
|
||||
rows = {f.subject: f for f in cf.check_romfolders(root, "", False)}
|
||||
self.assertEqual((rows["other"].status, rows["other"].remote), (cf.STALE, "v9 (2026-11-01)"))
|
||||
|
||||
def test_the_committed_setups_agree_with_their_platform_files(self):
|
||||
platforms = REPO_ROOT / "platforms"
|
||||
drift = [d for setup, doc in cf.romfolder_sources(platforms).items()
|
||||
if (d := cf.romfolder_pin_drift(setup, doc, platforms))]
|
||||
self.assertEqual(drift, [])
|
||||
|
||||
|
||||
class CatalogParsingTests(unittest.TestCase):
|
||||
def test_game_catalogs_are_watched(self):
|
||||
"""The 38 game catalogs the games-on-card mode identifies discs by
|
||||
had no row: only the BIOS snapshots of provenance/ were compared."""
|
||||
versions = {code: "2026-01-01 00-00-00" for code in cf.game_dat_importer.REDUMP_DATS}
|
||||
versions["PSX"] = "2099-01-01 00-00-00"
|
||||
local = [{"system": system, "version": "2026-01-01 00-00-00"}
|
||||
for _n, system, _f in cf.game_dat_importer.REDUMP_DATS.values()]
|
||||
with mock.patch.object(cf, "_game_catalogs", return_value=local), \
|
||||
mock.patch.object(cf, "redump_datfile_version", side_effect=versions.get):
|
||||
row = cf._check_redump_games(False)
|
||||
self.assertEqual((row.status, row.items), (cf.STALE, ["sony-playstation"]))
|
||||
self.assertEqual(cf._check_redump_games(True).status, cf.SKIPPED)
|
||||
with mock.patch.object(cf, "_game_catalogs", return_value=[{"fetched": "2026-11-03"}]):
|
||||
for rebuilt, status in (("2026-07-08", cf.OK), ("2026-11-04", cf.STALE)):
|
||||
with mock.patch.object(cf, "_no_intro_asset_date", return_value=rebuilt):
|
||||
self.assertEqual(cf._check_nointro_games("", False).status, status)
|
||||
self.assertIn("no-intro games",
|
||||
[f.subject for f in cf.check_catalogs("", True)])
|
||||
|
||||
def test_tosec_newest_release_from_category_links(self):
|
||||
html = (
|
||||
'<a href="/downloads/category/58-2024-05-17">x</a>'
|
||||
@@ -612,6 +688,14 @@ class RowsSayAllTheyStandFor(unittest.TestCase):
|
||||
])
|
||||
self.assertNotIn("->", text)
|
||||
|
||||
def test_a_detail_starts_in_one_column(self):
|
||||
text = cf.render([
|
||||
cf.Finding("native", "amberelec", cf.STALE, detail="no longer matches"),
|
||||
cf.Finding("native", "bizhawk", cf.STALE, local="1 file(s)", detail="is not what"),
|
||||
])
|
||||
rows = [line for line in text.splitlines() if line.startswith(" STALE")]
|
||||
self.assertEqual(rows[0].index("no longer"), rows[1].index("is not what"))
|
||||
|
||||
def test_json_carries_every_unresolved_name(self):
|
||||
missing = {f"core{i:02d}": [] for i in range(50)}
|
||||
(row,) = cf._unresolved_findings("retropie", missing)
|
||||
|
||||
@@ -23,5 +23,36 @@ class DataDirRefsAreRegistered(unittest.TestCase):
|
||||
self.assertEqual(validate_schemas._unknown_data_dir_refs(), [])
|
||||
|
||||
|
||||
|
||||
class EachSourceIsParsedOnce(unittest.TestCase):
|
||||
"""The schema pass, the data directory refs and the layout check each
|
||||
parsed every profile again: 2,038 parses for about 690 files."""
|
||||
|
||||
def test_three_checks_one_parse(self):
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
import validate_schemas
|
||||
except ImportError as exc:
|
||||
self.skipTest(f"validate_schemas needs jsonschema: {exc}")
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "emulators").mkdir()
|
||||
(root / "platforms").mkdir()
|
||||
(root / "platforms" / "_data_dirs.yml").write_text("data_directories: {}\n")
|
||||
(root / "emulators" / "core.yml").write_text(
|
||||
"emulator: Core\nfiles:\n- name: a.bin\n path: .variants/a.bin\n")
|
||||
loaded = []
|
||||
real = validate_schemas.yaml_load
|
||||
with mock.patch.object(validate_schemas, "ROOT", root), \
|
||||
mock.patch.object(validate_schemas, "yaml_load",
|
||||
side_effect=lambda h: loaded.append(h.name) or real(h)):
|
||||
validate_schemas._validate_yaml_directory(root / "emulators", "emulator.schema.json")
|
||||
validate_schemas._unknown_data_dir_refs()
|
||||
layout = validate_schemas._repository_layout_in_profiles()
|
||||
self.assertEqual(sorted(Path(n).name for n in loaded), ["_data_dirs.yml", "core.yml"])
|
||||
self.assertEqual(len(layout), 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -159,12 +159,12 @@ class InstallerOutputExample(unittest.TestCase):
|
||||
"""The run counted 8069 files where its menu counted 3259 BIOS files
|
||||
and 5089 game data files: the parts add up to the total they divide,
|
||||
the sizes within the rounding of their three figures."""
|
||||
# Data files are a third kind, counted beside the two (tui_audit_12 P2-3).
|
||||
# Data files are a third kind, counted beside the two.
|
||||
split = re.search(r"(\d+) BIOS files \(([\d.]+) ([KMG]B)\)(?:,| and) (\d+) game data "
|
||||
r"files \(([\d.]+) ([KMG]B)\)", text)
|
||||
self.assertIsNotNone(split, "a categorized list says its BIOS files and game data")
|
||||
bios, bios_size, bios_unit, games, game_size, game_unit = split.groups()
|
||||
# The data files in the one total, the third kind (code_audit_21 P3-8).
|
||||
# The data files in the one total, the third kind.
|
||||
data = re.search(r"and (\d+) data files \(([\d.]+) ([KMG]B)\)", text)
|
||||
data_count, data_size, data_unit = data.groups() if data else ("0", "0", "KB")
|
||||
self.assertEqual(int(bios) + int(games) + int(data_count), int(count))
|
||||
@@ -176,7 +176,7 @@ class InstallerOutputExample(unittest.TestCase):
|
||||
|
||||
|
||||
class InstallerSamplesSayWhatTheCodePrints(unittest.TestCase):
|
||||
"""code_audit_24 P3-8 to P3-12: samples typed by hand outside the
|
||||
"""Samples typed by hand outside the
|
||||
generated blocks, a sentence on a log read as the terminal's, pages that
|
||||
described the generator as tables alone, and a sandbox that asked DNS."""
|
||||
|
||||
|
||||
@@ -42,6 +42,29 @@ def _config(cores: list[str], files: list[dict], system: str = "s") -> dict:
|
||||
"systems": {system: {"files": files}}}
|
||||
|
||||
|
||||
|
||||
class EachProfileEntryIsHashedOnce(unittest.TestCase):
|
||||
"""verify --all hashed 9,058 profile entries 2.9 million times: each
|
||||
platform entry with a proof walked its reader's profile from scratch."""
|
||||
|
||||
def test_two_platform_entries_read_one_profile_entry_once(self):
|
||||
from unittest import mock
|
||||
|
||||
import exporter.candidates as candidates
|
||||
|
||||
profiles = {"core": {"emulator": "Core", "type": "libretro", "cores": ["core"],
|
||||
"systems": ["s"],
|
||||
"files": [{"name": "a.bin", "md5": "a" * 32,
|
||||
"contents": [{"name": "m", "crc32": "12345678"}]}]}}
|
||||
cats = EntryCategories(_config(["core"], []), profiles)
|
||||
real = candidates._entry_hashes
|
||||
with mock.patch.object(candidates, "_entry_hashes", side_effect=real) as counted:
|
||||
for md5 in ("a" * 32, "a" * 29):
|
||||
self.assertEqual(cats.category({"name": "a.bin", "md5": md5}, "a.bin", "s"), BIOS)
|
||||
self.assertEqual(counted.call_count, 1)
|
||||
self.assertTrue(candidates._accepts({"a" * 32}, {"md5": {"a" * 29}}))
|
||||
self.assertFalse(candidates._accepts({"a" * 32}, {"md5": {"b" * 32}}))
|
||||
|
||||
class Category(unittest.TestCase):
|
||||
def test_each_category(self):
|
||||
cats = EntryCategories(_config(["bluemsx", "devilutionx"], []), PROFILES)
|
||||
|
||||
@@ -221,5 +221,23 @@ class CommittedData(unittest.TestCase):
|
||||
self.assertEqual(lcd.systems, [])
|
||||
|
||||
|
||||
class OneNarrowingPerCoreList(unittest.TestCase):
|
||||
"""Each (platform, system, core) key narrowed the whole install list:
|
||||
2,357 narrowings for 1,289 distinct core lists."""
|
||||
|
||||
def test_the_systems_of_one_core_share_it(self) -> None:
|
||||
from unittest import mock
|
||||
|
||||
manifest = {"files": [{"dest": "dir/SCPH5501.BIN"}, {"dest": "b.rom"}]}
|
||||
cache: dict = {}
|
||||
with mock.patch.object(gi.install, "narrow",
|
||||
return_value=(manifest["files"], [])) as narrow:
|
||||
for _system in ("sony-playstation", "sony-playstation-2"):
|
||||
rows = gi._narrowed(manifest, ["core"], cache)
|
||||
gi._narrowed(manifest, ["other"], cache)
|
||||
self.assertEqual(narrow.call_count, 2)
|
||||
self.assertEqual([base for _entry, base in rows], ["scph5501.bin", "b.rom"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -965,7 +965,7 @@ class ThePortMasterPort(unittest.TestCase):
|
||||
self.assertIn("log.txt", device.screen)
|
||||
|
||||
def test_a_quit_chosen_shows_no_message(self):
|
||||
"""code_audit_19 P3-1: a quit chosen exits 2, below 128, and the
|
||||
"""A quit chosen exits 2, below 128, and the
|
||||
first line of tui.err went into a message box waiting for A."""
|
||||
device = self.device()
|
||||
proc = device.run(["bash", self.script], FAKE_QUIT="1", FAKE_CODE="2",
|
||||
|
||||
+9
-11
@@ -35,8 +35,8 @@ _spec.loader.exec_module(install)
|
||||
sys.path.insert(0, str(REPO_ROOT / "tests"))
|
||||
from netguard import module_guard # noqa: E402
|
||||
|
||||
# No connection off the loopback and no card of the host reaches a test
|
||||
# (code_audit_24 P3-13): the module fails at its end otherwise.
|
||||
# No connection off the loopback and no card of the host reaches a test:
|
||||
# the module fails at its end otherwise.
|
||||
setUpModule, tearDownModule = module_guard("test_install")
|
||||
|
||||
|
||||
@@ -1807,7 +1807,7 @@ class TestDetectedPlatformPrompt(unittest.TestCase):
|
||||
self.assertIn("Type a number from 1 to 2, or q to quit.", out)
|
||||
|
||||
def test_enter_takes_the_first_and_there_is_no_all(self):
|
||||
"""tui_audit_9 P3-11: the screen focuses the first and has no All."""
|
||||
"""The screen focuses the first and has no All."""
|
||||
result, out = self._answer("")
|
||||
self.assertEqual(result, [self.PLATFORMS[0]])
|
||||
self.assertNotIn("All", out)
|
||||
@@ -1817,8 +1817,7 @@ class TestManualPlatformPrompt(unittest.TestCase):
|
||||
"""With nothing detected, an interactive run asks instead of exiting.
|
||||
|
||||
Every test runs on no drive and a home of its own: two read the host's
|
||||
drives and home, and failed with a card in the reader (code_audit_23
|
||||
P3-7)."""
|
||||
drives and home, and failed with a card in the reader."""
|
||||
|
||||
def setUp(self):
|
||||
home = Path(_tempdir(self))
|
||||
@@ -1869,7 +1868,7 @@ class TestManualPlatformPrompt(unittest.TestCase):
|
||||
self.assertEqual(chosen, [("lakka", install.DEFAULT_DESTS["lakka"])])
|
||||
|
||||
def test_batocera_on_a_computer_gets_no_default(self):
|
||||
"""tui_audit_8 P2-4: /userdata/bios, a folder only the Batocera
|
||||
"""/userdata/bios, a folder only the Batocera
|
||||
machine has, was offered on a computer, and Enter took it."""
|
||||
answers = iter(["batocera", "", "~/share/bios"])
|
||||
out = io.StringIO()
|
||||
@@ -4517,7 +4516,7 @@ class AQuestionLeftWithCtrlDOrCtrlCIsAStop(unittest.TestCase):
|
||||
[sys.executable, "install.py", "--plain"], cwd=REPO_ROOT, stdin=slave,
|
||||
stdout=slave, stderr=slave,
|
||||
# No request leaves the machine: the standalone emulators' lists
|
||||
# come from the network (code_audit_24 P3-13).
|
||||
# come from the network.
|
||||
env={**os.environ, "HOME": str(home), "RETROBIOS_OS": "linux",
|
||||
"RETROBIOS_INSTALL_OFFLINE": "1"},
|
||||
)
|
||||
@@ -4648,8 +4647,7 @@ class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun):
|
||||
lines = [line for line in proc.stdout.splitlines() if line.strip()]
|
||||
self.assertIn("Nothing was written", lines[-1])
|
||||
at = next(index for index, line in enumerate(lines) if line.startswith("Would download"))
|
||||
# One total on the header, the data files among the kinds under it
|
||||
# (code_audit_21 P3-8).
|
||||
# One total on the header, the data files among the kinds under it.
|
||||
self.assertNotIn("data files", lines[at])
|
||||
self.assertIn("data files", lines[at + 1])
|
||||
|
||||
@@ -4697,7 +4695,7 @@ class DestAloneFollowsWhatIsFoundHere(unittest.TestCase):
|
||||
return exc, out.getvalue()
|
||||
|
||||
def test_a_card_system_chosen_checks_the_folder(self):
|
||||
"""code_audit_13 P2-3: the menu offered no card system, and one
|
||||
"""The menu offered no card system, and one
|
||||
chosen now reads --dest as --platform does."""
|
||||
root = Path(_tempdir(self))
|
||||
answers = iter(["Onion", "retroarch"])
|
||||
@@ -5621,7 +5619,7 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
|
||||
# The systems Choose systems lists, before the files are read.
|
||||
{"event": "choices", "platform": "retroarch",
|
||||
"systems": ["sys-a", "sys-b", "other"]},
|
||||
# Nothing set aside before the choice (code_audit_24 P3-3).
|
||||
# Nothing set aside before the choice.
|
||||
{"event": "set_aside", "platform": "retroarch", "dests": []},
|
||||
# a.bin and b.bin are in place: the first read and the last.
|
||||
{"event": "checking", "files_done": 1, "files_total": 2},
|
||||
|
||||
@@ -371,7 +371,7 @@ class RefusedBeforeAnyInstall(unittest.TestCase):
|
||||
(tmp / "cores").mkdir()
|
||||
(tmp / "cores" / "mgba_libretro.so").write_bytes(b"")
|
||||
# Two setups found stop a run without questions before either is
|
||||
# read (code_audit_18 P2-3): the refusal is checked with Batocera
|
||||
# read: the refusal is checked with Batocera
|
||||
# named, and with both found nothing is fetched either.
|
||||
for argv, said in (
|
||||
(["--platform", "batocera", "--dest", str(tmp / "bios")],
|
||||
|
||||
@@ -1263,7 +1263,7 @@ class ADetectedClosedFolderSaysWhereItCameFrom(unittest.TestCase):
|
||||
self.assertIn(f"than the one {self.cfg} names (name another with --dest)", said)
|
||||
|
||||
def test_two_setups_stop_before_either_folder_is_read(self):
|
||||
"""code_audit_18 P2-3: a run without questions installs for one
|
||||
"""A run without questions installs for one
|
||||
setup, so two found stop on what to name before either folder is
|
||||
read, the closed one included."""
|
||||
proc = self.run_detected(True)
|
||||
|
||||
@@ -237,7 +237,7 @@ class CheckNamesWhyAFileIsRefused(Scratch):
|
||||
self.assertIn(f"read by beetle_psx, pcsx_rearmed at {here / 'scph5501.bin'}", out)
|
||||
self.assertIn(f"read by beetle_psx, pcsx_rearmed at {here / 'pcsx' / 'extra.bin'}",
|
||||
out)
|
||||
# Never "opened" of a file that is not there (tui_audit_16 P3-12).
|
||||
# Never "opened" of a file that is not there.
|
||||
self.assertNotIn("opened at", out)
|
||||
|
||||
def test_a_complete_folder_says_nothing_more(self):
|
||||
@@ -545,7 +545,7 @@ class FromPackInstallsOffline(Scratch):
|
||||
self.assertEqual((self.dest / "dolphin-emu" / rel).read_bytes(), data)
|
||||
|
||||
def test_the_data_trees_say_what_they_weigh(self):
|
||||
"""tui_audit_4 P3-10.8: the plan read "and 2636 data files" with no
|
||||
"""The plan read "and 2636 data files" with no
|
||||
size. The data event carries the bytes check's data_missing counts."""
|
||||
tree = {"Sys/GC/font.bin": b"font bytes", "Sys/Wii/setting.txt": b"setting"}
|
||||
listed = manifest()
|
||||
@@ -733,7 +733,7 @@ class OfflineMakesNoRequest(Scratch):
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
||||
self.assertEqual((self.dest / "scph5501.bin").read_bytes(), US)
|
||||
self.assertFalse((self.dest / "gba_bios.bin").exists())
|
||||
# Missing, never failed (code_audit_15 P3-1): no FAILED line per file.
|
||||
# Missing, never failed: no FAILED line per file.
|
||||
self.assertIn("3 still missing", proc.stdout + proc.stderr)
|
||||
self.assertNotIn("FAILED", proc.stdout + proc.stderr)
|
||||
self.assertIn("without --offline", proc.stdout + proc.stderr)
|
||||
@@ -854,7 +854,7 @@ class ADryRunOpensOnItsTotals(Scratch):
|
||||
|
||||
|
||||
class OfflineLeavesFilesMissing(Scratch):
|
||||
"""code_audit_13 P1-1, P3-7, P3-8: the files --offline leaves out at a
|
||||
"""The files --offline leaves out at a
|
||||
terminal were counted with a "no" to the size question. Once one file
|
||||
came from this machine, 14,696 missing ended on Done and exit 0."""
|
||||
|
||||
@@ -872,8 +872,7 @@ class OfflineLeavesFilesMissing(Scratch):
|
||||
sources="4 files copied from old, 14696 still missing.")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertNotIn("Done", text)
|
||||
# One count, the sources line as the head, as the screen's result
|
||||
# (tui_audit_15 P3-8).
|
||||
# One count, the sources line as the head, as the screen's result.
|
||||
self.assertIn("Finished with files missing. 4 files copied from old, 14696 still "
|
||||
"missing.", text)
|
||||
self.assertEqual(text.count("14696"), 1)
|
||||
@@ -923,7 +922,7 @@ class OfflineLeavesFilesMissing(Scratch):
|
||||
|
||||
|
||||
class OneOfflineVocabulary(Scratch):
|
||||
"""code_audit_14 P3-1 to P3-4, tui_audit_7 P3-4: --offline said one
|
||||
"""--offline said one
|
||||
thing at a terminal and another without questions, on the screen and
|
||||
under --check."""
|
||||
|
||||
@@ -934,7 +933,7 @@ class OneOfflineVocabulary(Scratch):
|
||||
return old
|
||||
|
||||
def test_a_run_without_questions_says_missing_not_failed(self):
|
||||
"""P3-2: "Finished with errors ... 3 files failed" and "Run the
|
||||
""""Finished with errors ... 3 files failed" and "Run the
|
||||
same command again to retry only those"."""
|
||||
proc = self.run_installer("--offline", "--manifest", str(self.list),
|
||||
"--from", str(self.old()), "--no-input")
|
||||
@@ -948,7 +947,7 @@ class OneOfflineVocabulary(Scratch):
|
||||
self.assertNotIn(said, out)
|
||||
|
||||
def test_nothing_installed_is_said_so(self):
|
||||
"""P3-3: "Finished with files missing. 0 files installed"."""
|
||||
""""Finished with files missing. 0 files installed"."""
|
||||
failures = [("a.bin", install.NOT_WRITABLE), ("b.bin", install.NOT_WRITABLE)]
|
||||
with contextlib.redirect_stdout(io.StringIO()) as out, \
|
||||
self.assertRaises(SystemExit) as stop:
|
||||
@@ -960,7 +959,7 @@ class OneOfflineVocabulary(Scratch):
|
||||
self.assertNotIn("0 files installed", out.getvalue())
|
||||
|
||||
def test_the_command_suggested_keeps_the_offline_setting(self):
|
||||
"""P3-3: sudo does not pass RETROBIOS_INSTALL_OFFLINE on."""
|
||||
"""Sudo does not pass RETROBIOS_INSTALL_OFFLINE on."""
|
||||
import test_install_tui as tui
|
||||
|
||||
args = tui._args(platform="retroarch", declared_only=False, offline=False)
|
||||
@@ -970,7 +969,7 @@ class OneOfflineVocabulary(Scratch):
|
||||
self.assertIn("--offline", hint.command("retroarch", self.dest, elevated=True))
|
||||
|
||||
def test_check_offline_says_what_cannot_come(self):
|
||||
"""P3-4: "Run the same command without --check to fetch them"."""
|
||||
""""Run the same command without --check to fetch them"."""
|
||||
proc = self.run_installer("--check", "--offline", "--manifest", str(self.list))
|
||||
out = proc.stdout + proc.stderr
|
||||
self.assertEqual(proc.returncode, 1, out)
|
||||
@@ -979,7 +978,7 @@ class OneOfflineVocabulary(Scratch):
|
||||
self.assertIn("Copy them from your old card or a folder (--from)", out)
|
||||
|
||||
def test_the_screen_says_missing_not_failed(self):
|
||||
"""P3-2 on the full screen: "Finished with errors, 20 failed"."""
|
||||
"""On the full screen: "Finished with errors, 20 failed"."""
|
||||
import test_install_tui as tui
|
||||
|
||||
reason = f"not fetched: {install.OFFLINE_WORDS}"
|
||||
@@ -997,7 +996,7 @@ class OneOfflineVocabulary(Scratch):
|
||||
self.assertEqual(install.tui_outcome(state)[0], "Nothing installed")
|
||||
|
||||
def test_the_line_menu_copies_from_a_folder(self):
|
||||
"""tui_audit_7 P3-4: no way to a folder, and an install of nothing
|
||||
"""No way to a folder, and an install of nothing
|
||||
offered."""
|
||||
old = self.old()
|
||||
install.LOCAL.reset()
|
||||
@@ -1015,8 +1014,8 @@ class OneOfflineVocabulary(Scratch):
|
||||
self.assertNotIn("install everything", text)
|
||||
|
||||
|
||||
class AuditFifteenOffline(Scratch):
|
||||
"""code_audit_15 P3-1, P3-5 and tui_audit_8 P3-11: what --offline and a
|
||||
class OfflineAndPackLeaveOut(Scratch):
|
||||
"""What --offline and a
|
||||
pack leave out, and what a folder copied from holds, said as such."""
|
||||
|
||||
def test_yes_offline_counts_the_rest_missing_never_failed(self):
|
||||
|
||||
@@ -390,7 +390,7 @@ class DecliningTheSizePointsAtTheChoice(unittest.TestCase):
|
||||
self.assertNotIn("see --help", out)
|
||||
|
||||
def test_options_given_keep_the_option_advice(self):
|
||||
# The menu is shown with --system too (tui_audit_9 P3-8): Enter keeps it.
|
||||
# The menu is shown with --system too: Enter keeps it.
|
||||
out = self._run(["", "n"], "--system", "sony-playstation")
|
||||
self.assertIn("--system, --core and --region install less", out)
|
||||
|
||||
@@ -677,7 +677,7 @@ class TheOneLinerNarrows(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal")
|
||||
def test_the_custom_mode_at_a_terminal(self):
|
||||
"""Typed answers: customise, PlayStation only, US first. Its one
|
||||
core is not asked about (tui_audit_11 P3-10)."""
|
||||
core is not asked about."""
|
||||
with FakeRepository() as repo:
|
||||
command = " ".join(shlex.quote(part) for part in repo.command())
|
||||
proc = subprocess.run(
|
||||
@@ -724,7 +724,7 @@ class TheOneLinerNarrows(unittest.TestCase):
|
||||
def test_q_at_every_menu_stops_on_the_quit_code(self):
|
||||
"""`install.sh && retroarch` started RetroArch after a q, which left
|
||||
with 0. A quit chosen ends on 2, nothing installed, where Ctrl+C and
|
||||
Ctrl+D leave with 130 (code_audit_19 P3-1)."""
|
||||
Ctrl+D leave with 130."""
|
||||
files = [{"dest": "a.bin", "size": 1, "systems": ["nintendo-gba"]}]
|
||||
menus = (
|
||||
lambda: install._choose_platform("Platform:"),
|
||||
@@ -1632,7 +1632,7 @@ class SomethingElseAtADestination(unittest.TestCase):
|
||||
proc = repo.run("--yes")
|
||||
self.assertEqual(proc.returncode, 1, proc.stderr)
|
||||
self.assertNotIn("Traceback", proc.stderr)
|
||||
# Each failure named once, on its FAILED line (tui_audit_17 P3-7).
|
||||
# Each failure named once, on its FAILED line.
|
||||
self.assertIn("FAILED scph5500.bin (a folder sits where this file goes",
|
||||
proc.stdout)
|
||||
self.assertIn("FAILED scph5501.bin (file in place could not be kept aside",
|
||||
|
||||
+505
-552
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import unittest
|
||||
import zipfile
|
||||
@@ -284,6 +285,27 @@ class Lock(unittest.TestCase):
|
||||
self.assertFalse(try_lock())
|
||||
|
||||
|
||||
class RecordsNameNoLocalPath(unittest.TestCase):
|
||||
"""A record is read on other machines: the probe's scratch folders are
|
||||
written as the options that named them."""
|
||||
|
||||
def test_the_summary_names_the_options(self) -> None:
|
||||
root = Path("/srv/run/tmp/pack_probe/retroarch/installer")
|
||||
out = (f"Copy it from your own copy of the game into {root / 'system'}/jazz2/Source/.\n"
|
||||
f"Saves go to {root / 'saves'}.\n")
|
||||
summary = pp.installer_summary(out, root / "system", root / "saves")
|
||||
self.assertEqual(summary, "Copy it from your own copy of the game into <dest>/jazz2/Source/. "
|
||||
"Saves go to <save-dir>.")
|
||||
|
||||
def test_no_committed_record_holds_an_absolute_path(self) -> None:
|
||||
path = REPO_ROOT / "measurements" / "pack-probes.jsonl"
|
||||
absolute = re.compile(r"(?<![\w.<>-])/(?:home|root|tmp|mnt|Users|srv)/")
|
||||
records = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||
found = [r["id"] for r in records
|
||||
if absolute.search((r.get("source") or {}).get("installer_summary", ""))]
|
||||
self.assertEqual(found, [])
|
||||
|
||||
|
||||
def collection_on_disk() -> bool:
|
||||
return (REPO_ROOT / "bios" / "Sony" / "PlayStation" / "scph5501.bin").is_file()
|
||||
|
||||
@@ -321,6 +343,7 @@ class RealLayouts(unittest.TestCase):
|
||||
self.assertEqual(lay.files["scph5501.bin"], "files:scph5501.bin")
|
||||
self.assertEqual(lay.source["manifest"]["path"], "install/retroarch.json")
|
||||
self.assertTrue(all(entry for entry in lay.files.values()), lay.files)
|
||||
self.assertNotIn(str(self.work), lay.source["installer_summary"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -237,6 +237,44 @@ class NoToolingNames(unittest.TestCase):
|
||||
self.assertEqual(offenders, [], "\n".join(offenders[:20]))
|
||||
|
||||
|
||||
class NoReviewTags(unittest.TestCase):
|
||||
"""A comment cites what the code does, never the private review that
|
||||
asked for it. About 650 comments and test texts ended on a tag such as
|
||||
"(tui review 13, P3-8)" written in its short form, which points at notes
|
||||
no clone holds. The pattern is assembled so this file does not match."""
|
||||
|
||||
TAG = re.compile(r"[a-z]+_" + "aud" + r"it_\d+|\(rounds? \d")
|
||||
|
||||
def test_the_pattern_matches_the_short_forms(self):
|
||||
for text in ("tui_" + "aud" + "it_13 P3-8", "(" + "round 5, 6.3)",
|
||||
"code_" + "aud" + "it_24"):
|
||||
with self.subTest(text=text):
|
||||
self.assertRegex(text, self.TAG)
|
||||
self.assertNotRegex("test_" + "aud" + "it_regressions", self.TAG)
|
||||
|
||||
def test_no_tracked_file_carries_one(self):
|
||||
offenders = []
|
||||
for path in _tracked():
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
if self.TAG.search(line):
|
||||
offenders.append(f"{path.relative_to(ROOT)}:{number}")
|
||||
self.assertEqual(offenders, [], "\n".join(offenders[:20]))
|
||||
|
||||
def test_tests_are_named_by_subject(self):
|
||||
"""Test modules and classes were named after the review that wrote
|
||||
them (test_site_r4_p0, a class per review number). A name says what
|
||||
the test holds."""
|
||||
rounds = sorted(p.name for p in (ROOT / "tests").glob("test_*_r[0-9]_*.py"))
|
||||
self.assertEqual(rounds, [])
|
||||
classes = []
|
||||
for path in sorted((ROOT / "tests").glob("test_*.py")):
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if re.match(r"class Audit[A-Z]", line):
|
||||
classes.append(f"{path.name}:{number}")
|
||||
self.assertEqual(classes, [])
|
||||
|
||||
|
||||
class ProfilesStateFactsAboutOtherProjects(unittest.TestCase):
|
||||
"""A profile says what another project's file declares and what the code
|
||||
does, never a verdict on that file. Ten notes and comments in ten profiles
|
||||
|
||||
@@ -120,6 +120,19 @@ class Planning(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual([f["subject"] for f in surfaced], ["redump"])
|
||||
|
||||
def test_game_catalogs_are_imported_again(self):
|
||||
jobs, _surfaced, _ = self._plan(
|
||||
finding("catalogs", "no-intro games"), finding("catalogs", "redump games"))
|
||||
self.assertEqual(
|
||||
{j.subject: " ".join(j.command[1:]) for j in jobs},
|
||||
{
|
||||
"no-intro games": "-m scripts.scraper.game_dat_importer --source no-intro"
|
||||
" --fetch --refresh --floor",
|
||||
"redump games": "-m scripts.scraper.game_dat_importer --source redump"
|
||||
" --fetch --refresh --floor",
|
||||
},
|
||||
)
|
||||
|
||||
def test_a_data_row_outside_the_registry_needs_a_human(self):
|
||||
"""refresh_data_dirs.py --key 'buildbot system' exits 1 on an unknown
|
||||
key: the row asks for a registry entry, not a refresh."""
|
||||
|
||||
@@ -167,9 +167,13 @@ class APlatformWithoutAPackFailsNoCheck(unittest.TestCase):
|
||||
import pipeline
|
||||
|
||||
verify = self.VERIFY + "Batocera: 3/3 OK [md5]\n"
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
with contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
self.assertFalse(pipeline.check_consistency(
|
||||
verify, self.PACK, pipeline.release_pack_keys(PLATFORMS)))
|
||||
# The failing line says it fails: "(no separate pack)" read like
|
||||
# the installer-only note above it.
|
||||
self.assertIn("Batocera: MISSING pack: verify judged 3/3 but the build wrote no pack for it",
|
||||
out.getvalue())
|
||||
|
||||
def test_the_pipeline_passes_the_release_platforms(self):
|
||||
source = (REPO_ROOT / "scripts" / "pipeline.py").read_text(encoding="utf-8")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Arcade boards and the ids that hold files (round 4, package P0).
|
||||
"""Arcade boards and the ids that hold files.
|
||||
|
||||
The Arcade pages were cut by storage folder, so NAOMI, ZN-1, PGM and the
|
||||
other boards had no section and stayed plain on 987 emulator rows.
|
||||
@@ -139,7 +139,7 @@ class Boards(unittest.TestCase):
|
||||
self.assertTrue(slug)
|
||||
self.assertEqual(slug, toc_slug(name))
|
||||
# The board's page is its section: no heading repeats its
|
||||
# title (round 5, 6.8).
|
||||
# title.
|
||||
self.assertEqual(
|
||||
siterender.console_href(*siterender.system_section(board)),
|
||||
f"systems/arcade-{slug}.md")
|
||||
@@ -57,8 +57,7 @@ class ServedCopies(unittest.TestCase):
|
||||
served = sitebootstrap.served_text(name, REPO_ROOT, self.commit).splitlines()
|
||||
self.assertEqual(len(original), len(served))
|
||||
changed = [(a, b) for a, b in zip(original, served) if a != b]
|
||||
# The address, and the advice a refusal gives (code_audit_21
|
||||
# P3-1).
|
||||
# The address, and the advice a refusal gives.
|
||||
self.assertEqual(len(changed), 2, changed)
|
||||
advice = [pair for pair in changed if sitebootstrap.SERVED_ADVICE in pair[1]]
|
||||
self.assertEqual(len(advice), 1, changed)
|
||||
@@ -99,7 +98,7 @@ class ServedCopies(unittest.TestCase):
|
||||
sitebootstrap.served_text(path.name, REPO_ROOT, self.commit))
|
||||
|
||||
def test_the_served_copy_never_blames_a_cut_download_or_caches(self):
|
||||
"""code_audit_21 P3-1: the commit-pinned copy said "cut short, try
|
||||
"""The commit-pinned copy said "cut short, try
|
||||
again" and "waiting five minutes settles that", neither of which can
|
||||
happen to it."""
|
||||
for name in sitebootstrap.SOURCES:
|
||||
@@ -116,7 +115,7 @@ class ServedCopies(unittest.TestCase):
|
||||
self.assertIn("something changed the file", answer)
|
||||
|
||||
def test_a_stale_pin_stops_the_build(self):
|
||||
"""code_audit_21 P3-2: a pin that is not the installer's was served,
|
||||
"""A pin that is not the installer's was served,
|
||||
and refused every install until the next good deploy."""
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir()
|
||||
else None) as scratch:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Gate of the second site round, read from the built site (website/dist).
|
||||
"""The merged site, read from the built site (website/dist).
|
||||
|
||||
Each package of the round was checked on its own. These checks hold the
|
||||
merged site to what the round set out to fix: no sidebar label a number, no
|
||||
Each part of the site is checked on its own elsewhere. These checks hold
|
||||
the merged site to what they fix: no sidebar label a number, no
|
||||
"Next" tip, a sidebar label that is the page's own title, a card's link icon
|
||||
kept out of the heading a search indexes, crumbs that lead to a tab or a
|
||||
section, phone tables stacked where the reading level asks for it, and every
|
||||
page under 15,000 elements and 600 KB of HTML. The integration items of the
|
||||
round are held too: the platform logo beside the title, a section for every
|
||||
page under 15,000 elements and 600 KB of HTML. Items that cross pages
|
||||
are held too: the platform logo beside the title, a section for every
|
||||
letter of the emulators index, system links to the section of the system's
|
||||
own name, dates in words and the moved getting-started page.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File renamed without changes.
@@ -0,0 +1,454 @@
|
||||
"""The stylesheet, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: a red focus ring
|
||||
and hover, Starlight's 35 px h2, tinted disclosure blocks, striped asides,
|
||||
the search result file dump, lifting shadows, a sidebar without hierarchy,
|
||||
two indigos, permalinks hidden on touch screens, OS tabs cut at 320 px, a
|
||||
column that moves on pages without a TOC.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: commands allowed
|
||||
to break inside any word on a phone, a sidebar whose top links outsize its
|
||||
group labels, badge links cut to a few pixels by the
|
||||
next row, 12 px hashes, Get your files tables each sizing their own columns.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: OS tabs on one
|
||||
hidden scrolling row, stacked table cells 16 px apart with labels drawn over
|
||||
the next row, short flags split across lines, lone card links 19 px high, a
|
||||
card label column too narrow for its longest label.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: detection tables
|
||||
whose last column sat past the page at 1440 px, a header search box, title
|
||||
and "On this page" that moved 12 px between a guide and a console page,
|
||||
stacked rows 136 px tall for four short cells, a line 4 px under a button,
|
||||
a heading touching its first line, the browser's own choice list, and a
|
||||
crumb separator free to end a line alone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CSS_PATH = ROOT / "website" / "src" / "styles" / "retrobios.css"
|
||||
|
||||
# Page backgrounds Starlight 0.42.6 draws under our tokens, read with
|
||||
# getComputedStyle on a built page (light: white, dark: gray-7 hsl(224 10% 10%)).
|
||||
PAGE_BG = {"light": "#ffffff", "dark": "#17181c"}
|
||||
# Text Starlight draws on a filled button (--sl-color-black in each theme).
|
||||
ON_FILL = {"light": "#ffffff", "dark": "#17181c"}
|
||||
|
||||
|
||||
def _strip_comments(css: str) -> str:
|
||||
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
|
||||
|
||||
def _blocks(css: str) -> list[tuple[str, str]]:
|
||||
"""Innermost (selector, body) pairs, media queries flattened."""
|
||||
return [(sel.strip(), body) for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)]
|
||||
|
||||
|
||||
def _theme_tokens(css: str, selector: str) -> dict[str, str]:
|
||||
for sel, body in _blocks(css):
|
||||
if sel == selector:
|
||||
return dict(re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", body))
|
||||
raise AssertionError(f"no block {selector}")
|
||||
|
||||
|
||||
def _rules(css: str, selector_part: str) -> list[str]:
|
||||
return [body for sel, body in _blocks(css) if selector_part in sel]
|
||||
|
||||
|
||||
def _luminance(hex_colour: str) -> float:
|
||||
value = hex_colour.lstrip("#")
|
||||
channels = [int(value[i : i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||
linear = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
|
||||
|
||||
def _ratio(a: str, b: str) -> float:
|
||||
la, lb = sorted((_luminance(a), _luminance(b)), reverse=True)
|
||||
return (la + 0.05) / (lb + 0.05)
|
||||
|
||||
|
||||
class ThemeStylesheet(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
dark = _theme_tokens(cls.css, ":root")
|
||||
light = {**dark, **_theme_tokens(cls.css, ":root[data-theme='light']")}
|
||||
cls.tokens = {"dark": dark, "light": light}
|
||||
|
||||
def resolve(self, theme: str, token: str) -> str:
|
||||
value = self.tokens[theme][token].strip()
|
||||
while value.startswith("var("):
|
||||
value = self.tokens[theme][value[4:-1].strip()].strip()
|
||||
return value
|
||||
|
||||
def test_focus_ring_is_the_link_colour_not_red(self):
|
||||
focus = _rules(self.css, ":focus-visible")
|
||||
self.assertTrue(focus)
|
||||
for body in focus:
|
||||
self.assertIn("2px solid var(--rb-link)", body)
|
||||
self.assertNotIn("accent", body.replace("--rb-link", ""))
|
||||
ring = next(b for s, b in _blocks(self.css) if s.startswith(":where(a, button"))
|
||||
self.assertIn("outline-offset: 2px", ring)
|
||||
self.assertNotIn("#b42318", self.css)
|
||||
self.assertNotIn("--rb-accent", self.css)
|
||||
|
||||
def test_button_hover_stays_in_its_own_colour(self):
|
||||
self.assertNotRegex(self.css, r"\.rb-button:is\(:hover,\s*:focus\)")
|
||||
primary = _rules(self.css, ".rb-button-primary:hover")
|
||||
self.assertTrue(primary)
|
||||
self.assertIn("var(--rb-link-strong)", primary[0])
|
||||
self.assertEqual(self.resolve("light", "--rb-link-strong"), "#1d2f73")
|
||||
self.assertEqual(self.resolve("dark", "--rb-link-strong"), "#c9d2ff")
|
||||
outline = [b for s, b in _blocks(self.css) if s.endswith(".rb-button:hover")]
|
||||
self.assertIn("var(--sl-color-accent-low)", outline[0])
|
||||
|
||||
def test_heading_scale(self):
|
||||
root = self.tokens["dark"]
|
||||
self.assertEqual(root["--sl-text-h1"], "2.25rem")
|
||||
self.assertEqual(root["--sl-text-h2"], "1.5rem")
|
||||
self.assertEqual(root["--sl-text-h3"], "1.2rem")
|
||||
self.assertEqual(root["--sl-text-h4"], "1rem")
|
||||
self.assertRegex(self.css, r"@media \(max-width: 50rem\) \{\s*:root \{ --sl-text-h1: 1\.875rem; \}")
|
||||
self.assertRegex(self.css, r"level-h2, h2\) \{ margin-top: 2\.75rem; \}")
|
||||
self.assertRegex(self.css, r"level-h3, h3:not\(\.rb-file-title\)\) \{ margin-top: 1\.75rem; \}")
|
||||
|
||||
def test_asides_are_neutral_boxes(self):
|
||||
neutral = _rules(self.css, ".starlight-aside:is(.starlight-aside--note, .starlight-aside--tip)")
|
||||
joined = "".join(neutral)
|
||||
self.assertIn("--sl-color-asides-border: var(--rb-border)", joined)
|
||||
self.assertIn("background: var(--rb-surface)", joined)
|
||||
self.assertIn("display: none", joined)
|
||||
box = _rules(self.css, ".sl-markdown-content .starlight-aside")[0]
|
||||
self.assertIn("border: 1px solid", box)
|
||||
|
||||
def test_disclosure_blocks_are_neutral(self):
|
||||
self.assertNotIn("rb-details-tip", self.css)
|
||||
self.assertNotIn("--sl-color-blue", self.css)
|
||||
base = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content details.rb-details")
|
||||
self.assertIn("background: transparent", base)
|
||||
self.assertIn("var(--rb-border)", base)
|
||||
self.assertIn("0.95rem", _rules(self.css, "details.rb-details > summary")[0])
|
||||
|
||||
def test_search_hides_the_file_list(self):
|
||||
self.assertIn("display: none", _rules(self.css, "site-search .pagefind-ui__result-tags")[0])
|
||||
|
||||
def test_no_lifting_shadow_on_pagination_or_menu(self):
|
||||
self.assertIn("box-shadow: none", _rules(self.css, ".pagination-links a")[0])
|
||||
self.assertIn("font-size: 1.05rem", _rules(self.css, ".pagination-links .link-title")[0])
|
||||
menu = next(b for s, b in _blocks(self.css) if s == ".sl-menu-button")
|
||||
self.assertIn("box-shadow: none", menu)
|
||||
self.assertIn("background: transparent", menu)
|
||||
self.assertIn("1px solid var(--rb-border)", menu)
|
||||
|
||||
def test_sidebar_hierarchy(self):
|
||||
label = _rules(self.css, ".top-level summary .large")[0]
|
||||
self.assertIn("var(--sl-text-xs)", label)
|
||||
self.assertIn("var(--rb-text-secondary)", label)
|
||||
current = _rules(self.css, "a[aria-current='page']")[0]
|
||||
self.assertIn("background: var(--sl-color-accent-low)", current)
|
||||
self.assertIn("color: var(--sl-color-accent-high)", current)
|
||||
self.assertNotIn("border", current)
|
||||
|
||||
def test_one_indigo(self):
|
||||
for theme in ("light", "dark"):
|
||||
with self.subTest(theme=theme):
|
||||
self.assertEqual(self.resolve(theme, "--sl-color-accent"), self.resolve(theme, "--rb-link"))
|
||||
self.assertEqual(self.resolve(theme, "--sl-color-text-accent"), self.resolve(theme, "--rb-link"))
|
||||
|
||||
def test_permalinks_visible_without_hover(self):
|
||||
self.assertRegex(self.css, r"@media \(hover: none\) \{\s*\.rb-permalink \{ opacity: 1; \}")
|
||||
permalink = next(b for s, b in _blocks(self.css) if s == ".rb-permalink")
|
||||
self.assertIn("min-height: 24px", permalink)
|
||||
|
||||
# The row wraps, every tab on screen
|
||||
def test_os_tabs_stay_reachable(self):
|
||||
tablist = _rules(self.css, "starlight-tabs [role='tablist']")[0]
|
||||
self.assertIn("flex-wrap: wrap", tablist)
|
||||
self.assertIn("overflow-x: visible", tablist)
|
||||
self.assertIn("white-space: nowrap", _rules(self.css, "starlight-tabs [role='tab']")[0])
|
||||
|
||||
def test_page_without_toc_keeps_the_column(self):
|
||||
self.assertIn("--__toc-width: initial", _rules(self.css, ":root[data-has-sidebar]:not([data-has-toc])")[0])
|
||||
# One pane rule for every page with a sidebar, "On this page"
|
||||
# or not, placed from the grid width.
|
||||
pane = _rules(self.css, ":root[data-has-sidebar] .main-pane")[0]
|
||||
self.assertIn("--sl-content-margin-inline: max(0rem, 100% - var(--rb-grid-width)) auto", pane)
|
||||
|
||||
def test_card_pages(self):
|
||||
cards = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content :is(.rb-sys-file, .rb-file-entry)")
|
||||
self.assertIn("content-visibility: auto", cards)
|
||||
self.assertIn("contain-intrinsic-size: auto 320px", cards)
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)", _rules(self.css, "body:has(.rb-sys-file, .rb-file-entry)")[0])
|
||||
self.assertEqual(_theme_tokens(self.css, ":root")["--rb-grid-width"], "54rem")
|
||||
facts = _rules(self.css, "dl.rb-facts")[0]
|
||||
self.assertIn("grid-template-columns: 11rem minmax(0, 1fr)", facts)
|
||||
self.assertRegex(self.css, r"@media \(max-width: 30rem\) \{\s*\.sl-markdown-content dl\.rb-facts \{ grid-template-columns: minmax\(0, 1fr\)")
|
||||
|
||||
def test_long_values_wrap_between_words(self):
|
||||
table = _rules(self.css, ".rb-meta-card table")[0]
|
||||
self.assertIn("table-layout: fixed", table)
|
||||
self.assertIn("display: table", table)
|
||||
self.assertNotRegex(self.css, r"\.rb-meta-card td \{ overflow-wrap: anywhere")
|
||||
self.assertIn("overflow-wrap: anywhere", _rules(self.css, ".rb-meta-card td code")[0])
|
||||
self.assertIn("width: 40%", "".join(_rules(self.css, ".rb-meta-card td:first-child")))
|
||||
upstream = _rules(self.css, "#by-upstream-emulator")[0]
|
||||
self.assertIn("max-width: 22rem", upstream)
|
||||
|
||||
def test_two_column_tables_stack_label_over_value(self):
|
||||
self.assertIn("content: none", _rules(self.css, "table[data-rb-stack='kv'] td::before")[0])
|
||||
self.assertTrue(_rules(self.css, "table[data-rb-stack='kv'] td:first-child"))
|
||||
|
||||
def test_contrast_meets_wcag_aa_in_both_themes(self):
|
||||
for theme in ("light", "dark"):
|
||||
bg = PAGE_BG[theme]
|
||||
pairs = [
|
||||
("link", self.resolve(theme, "--rb-link"), bg, 4.5),
|
||||
("focus ring", self.resolve(theme, "--rb-link"), bg, 3.0),
|
||||
("primary button", ON_FILL[theme], self.resolve(theme, "--rb-link"), 4.5),
|
||||
("primary hover", ON_FILL[theme], self.resolve(theme, "--rb-link-strong"), 4.5),
|
||||
("outline hover", self.resolve(theme, "--rb-link"), self.resolve(theme, "--sl-color-accent-low"), 4.5),
|
||||
("sidebar current", self.resolve(theme, "--sl-color-accent-high"), self.resolve(theme, "--sl-color-accent-low"), 4.5),
|
||||
("secondary text", self.resolve(theme, "--rb-text-secondary"), bg, 4.5),
|
||||
("link on surface", self.resolve(theme, "--rb-link"), self.resolve(theme, "--rb-surface"), 4.5),
|
||||
]
|
||||
for label, fg, back, need in pairs:
|
||||
with self.subTest(theme=theme, pair=label):
|
||||
self.assertGreaterEqual(_ratio(fg, back), need, f"{fg} on {back}")
|
||||
|
||||
|
||||
class DeviceTilesAndHeaderLogo(unittest.TestCase):
|
||||
"""The device tiles had no style once merged: every line underlined and one
|
||||
tall block per setup on a phone. The header logo was sized from the whole
|
||||
header, tab row included, and grew to 84 px."""
|
||||
|
||||
def test_a_tile_is_one_compact_link(self):
|
||||
css = CSS_PATH.read_text(encoding="utf-8")
|
||||
self.assertRegex(css, r"a\.rb-tile \{[^}]*grid-template-columns: 40px 1fr")
|
||||
self.assertRegex(css, r"a\.rb-tile \{[^}]*text-decoration: none")
|
||||
|
||||
def test_the_header_logo_follows_the_top_bar(self):
|
||||
css = CSS_PATH.read_text(encoding="utf-8")
|
||||
self.assertIn("header .site-title img { height: calc(4rem - 2 * var(--sl-nav-pad-y))", css)
|
||||
|
||||
|
||||
# The Get your files tables: no class reaches them, the Markdown table is
|
||||
# matched by the cells the generator labels.
|
||||
PACKS = "table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])"
|
||||
|
||||
|
||||
class CommandsSidebarAndPackTables(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
dark = _theme_tokens(cls.css, ":root")
|
||||
light = {**dark, **_theme_tokens(cls.css, ":root[data-theme='light']")}
|
||||
cls.tokens = {"dark": dark, "light": light}
|
||||
|
||||
def test_commands_never_break_inside_a_word(self):
|
||||
blocks = _rules(self.css, ".expressive-code pre > code")
|
||||
self.assertTrue(blocks)
|
||||
for body in blocks:
|
||||
self.assertIn("white-space: pre-wrap", body, "commands keep wrapping")
|
||||
self.assertNotIn("overflow-wrap: anywhere", body)
|
||||
for selector in (".rb-facts code", ".rb-hash code", ".rb-meta-card td code"):
|
||||
with self.subTest(selector=selector):
|
||||
self.assertIn("overflow-wrap: anywhere", _rules(self.css, selector)[0])
|
||||
|
||||
def test_sidebar_labels_above_links(self):
|
||||
label = _rules(self.css, ".top-level summary .large")[0]
|
||||
# 13 px grey semibold, no uppercase: the house style refuses small caps.
|
||||
for declaration in ("font-size: var(--sl-text-xs)", "font-weight: 600", "color: var(--rb-text-secondary)"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, label)
|
||||
links = next(b for s, b in _blocks(self.css) if s == "#starlight__sidebar .top-level a")
|
||||
self.assertIn("font-size: var(--sl-text-sm)", links)
|
||||
self.assertIn("font-weight: 400", links)
|
||||
|
||||
def test_badge_links_keep_a_24px_target(self):
|
||||
dd = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content .rb-facts dd")
|
||||
self.assertIn("line-height: 24px", dd)
|
||||
badge = _rules(self.css, ".rb-facts :is(.rb-badge:has(> a), a.rb-badge)")[0]
|
||||
for declaration in ("display: inline-flex", "align-items: center", "min-height: 24px", "margin-block: 2px"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, badge)
|
||||
|
||||
def test_hashes_are_13px_at_every_width(self):
|
||||
hashes = [b for s, b in _blocks(self.css) if ".rb-hash" in s and "font-size" in b]
|
||||
self.assertEqual(len(hashes), 1, "a second size for .rb-hash, on phones")
|
||||
self.assertIn("font-size: 0.8125rem", hashes[0])
|
||||
short = _rules(self.css, "td code[title]")[0]
|
||||
self.assertIn("white-space: nowrap", short)
|
||||
self.assertIn("underline dotted", short)
|
||||
self.assertIn("min-width: 12rem", _rules(self.css, "td.rb-reason")[0])
|
||||
|
||||
def test_pack_tables_share_their_columns(self):
|
||||
base = next(b for s, b in _blocks(self.css) if PACKS in s and "table-layout" in b)
|
||||
self.assertIn("table-layout: fixed", base)
|
||||
self.assertIn("width: 100%", base)
|
||||
widths = {s.rsplit("th:nth-child(", 1)[1].split(")")[0]: b for s, b in _blocks(self.css)
|
||||
if PACKS in s and "th:nth-child(" in s}
|
||||
self.assertEqual({k: v.strip() for k, v in widths.items()},
|
||||
{"1": "width: 18%;", "2": "width: 20%;", "3": "width: 40%;", "4": "width: 22%;"})
|
||||
parts = next(b for s, b in _blocks(self.css) if PACKS in s and s.endswith(":is(a, .rb-button)"))
|
||||
self.assertIn("white-space: nowrap", parts)
|
||||
|
||||
def test_secondary_text_on_the_sidebar_meets_aa(self):
|
||||
for theme in ("light", "dark"):
|
||||
with self.subTest(theme=theme):
|
||||
fg = self.tokens[theme]["--rb-text-secondary"].strip()
|
||||
self.assertGreaterEqual(_ratio(fg, PAGE_BG[theme]), 4.5)
|
||||
|
||||
|
||||
STACK = ".sl-markdown-content table[data-rb-stack] td"
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
bodies = [b for s, b in _blocks(css) if s == selector]
|
||||
if not bodies:
|
||||
raise AssertionError(f"no block {selector}")
|
||||
return "".join(bodies)
|
||||
|
||||
|
||||
class TabsCellsAndCardLinks(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.raw = CSS_PATH.read_text(encoding="utf-8")
|
||||
cls.css = _strip_comments(cls.raw)
|
||||
|
||||
def test_os_tabs_wrap_and_never_hide(self):
|
||||
tablist = _block(self.css, "starlight-tabs [role='tablist']")
|
||||
for declaration in ("flex-wrap: wrap", "overflow-x: visible", "row-gap: 0.25rem"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, tablist)
|
||||
self.assertNotIn("scrollbar-width: none", self.css)
|
||||
self.assertNotIn("::-webkit-scrollbar", self.css)
|
||||
|
||||
def test_stacked_cells_close_up_and_labels_push_rows(self):
|
||||
cell = _block(self.css, STACK)
|
||||
# A 7.1rem column and 1.35 lines.
|
||||
for declaration in ("margin-top: 0", "display: flow-root", "line-height: 1.35",
|
||||
"padding: 0 0.6rem 0 7.1rem"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, cell)
|
||||
label = _block(self.css, STACK + "::before")
|
||||
self.assertIn("position: static", label)
|
||||
self.assertIn("float: left", label)
|
||||
self.assertNotIn("position: absolute", label)
|
||||
# The label sits in the cell's left padding.
|
||||
self.assertIn("margin: 0 0.6rem 0 -7.1rem", label)
|
||||
|
||||
def test_short_inline_code_never_splits(self):
|
||||
self.assertIn("white-space: pre", _block(self.css, ".sl-markdown-content :not(pre) > code.rb-short"))
|
||||
self.assertIn("white-space: break-spaces", _block(self.css, ".sl-markdown-content :not(pre) > code"))
|
||||
self.assertIn("white-space: nowrap", _block(self.css, ".sl-markdown-content table.rb-options td:first-child"))
|
||||
# A stacked value column is narrow: short code there moves down whole
|
||||
# and splits only when wider than the column, never pushing the page.
|
||||
stacked = _block(self.css, STACK + " code.rb-short")
|
||||
for declaration in ("display: inline-block", "max-width: 100%", "overflow-wrap: anywhere"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, stacked)
|
||||
# Commands in code blocks keep wrapping on a phone.
|
||||
self.assertIn("white-space: pre-wrap", _block(self.css, ".sl-markdown-content .expressive-code pre > code"))
|
||||
|
||||
def test_lone_card_links_are_24px_targets(self):
|
||||
dd = _block(self.css, ".sl-markdown-content .rb-facts dd")
|
||||
self.assertIn("min-height: 24px", dd)
|
||||
self.assertIn("line-height: 24px", dd)
|
||||
link = _block(self.css, ".sl-markdown-content .rb-facts dd > a")
|
||||
self.assertIn("display: inline-block", link)
|
||||
self.assertIn("min-height: 24px", link)
|
||||
|
||||
def test_card_labels_fit_and_lookup_labels_match(self):
|
||||
facts = _rules(self.css, "dl.rb-facts")[0]
|
||||
self.assertIn("grid-template-columns: 11rem minmax(0, 1fr)", facts)
|
||||
self.assertIn("font-weight: 600", _block(self.css, ".sl-markdown-content .rb-file-lookup label"))
|
||||
|
||||
|
||||
def _declarations(body: str) -> dict[str, str]:
|
||||
return {k.strip(): v.strip() for k, v in re.findall(r"([\w-]+)\s*:\s*([^;]+);", body)}
|
||||
|
||||
|
||||
class TablesHeaderAndRowHeights(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
def test_long_code_in_a_cell_breaks_to_fit_its_column(self):
|
||||
self.assertIn("overflow-wrap: anywhere",
|
||||
_block(self.css, ".sl-markdown-content table td code:not(.rb-short)"))
|
||||
last = _block(self.css, ".sl-markdown-content table:not([data-rb-stack='kv']) "
|
||||
"td:last-child:not(:first-child):has(code:not(.rb-short))")
|
||||
self.assertIn("min-width: 9rem", last)
|
||||
# A flag or a short name in a cell still never splits.
|
||||
self.assertIn("white-space: pre", _block(self.css, ".sl-markdown-content :not(pre) > code.rb-short"))
|
||||
|
||||
def test_one_grid_for_every_page(self):
|
||||
self.assertEqual(_theme_tokens(self.css, ":root").get("--rb-grid-width"), "54rem")
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)",
|
||||
_block(self.css, "body:has(.rb-sys-file, .rb-file-entry)"))
|
||||
# The header places its search box from the grid, not the page's column.
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)",
|
||||
_block(self.css, ":root[data-has-sidebar] header.header"))
|
||||
toc = _block(self.css, ":root[data-has-sidebar] .right-sidebar-container")
|
||||
pane = _block(self.css, ":root[data-has-sidebar] .main-pane")
|
||||
for body in (toc, pane):
|
||||
self.assertIn("var(--rb-grid-width)", body)
|
||||
self.assertNotIn("var(--sl-content-width)", body)
|
||||
# Every column starts on the grid's edge, the narrow one included.
|
||||
self.assertIn("--sl-content-margin-inline: max(0rem, 100% - var(--rb-grid-width)) auto", pane)
|
||||
# The pane rule covers pages with and without "On this page" alike.
|
||||
self.assertFalse([s for s, _ in _blocks(self.css) if s.endswith(":not([data-has-toc]) .main-pane")])
|
||||
|
||||
def test_stacked_rows_are_short(self):
|
||||
cell = _declarations(_block(self.css, STACK))
|
||||
self.assertEqual(cell["font-size"], "0.875rem")
|
||||
self.assertEqual(cell["line-height"], "1.35")
|
||||
label = _declarations(_block(self.css, STACK + "::before"))
|
||||
self.assertEqual(label["width"], "max-content")
|
||||
self.assertEqual(label["min-width"], "6.5rem")
|
||||
self.assertEqual(label["max-width"], "9rem")
|
||||
self.assertEqual(label["font-size"], "0.8125rem")
|
||||
# Values start in the column of the shortest label: the label's
|
||||
# negative margin is the cell's left padding.
|
||||
indent = cell["padding"].split()[3]
|
||||
self.assertEqual(label["margin"].split()[3], f"-{indent}")
|
||||
self.assertEqual(indent, "7.1rem")
|
||||
self.assertIn("padding: 0.3rem 0", _block(self.css, ".sl-markdown-content table[data-rb-stack] tr"))
|
||||
# A two-column list keeps body text.
|
||||
self.assertIn("font-size: 1rem", _block(self.css, ".sl-markdown-content table[data-rb-stack='kv'] td"))
|
||||
|
||||
def test_spacing_after_a_button_and_a_search_marker(self):
|
||||
self.assertIn("margin-top: 1rem",
|
||||
_block(self.css, ".sl-markdown-content :is(.rb-button, p:has(> .rb-button)) + p"))
|
||||
self.assertIn("margin-top: var(--sl-content-gap-y)",
|
||||
_block(self.css, ".sl-markdown-content span[data-pagefind-meta]:empty + :is(p, ul, ol, table, div)"))
|
||||
|
||||
def test_choice_list_matches_the_filter_box(self):
|
||||
select = _declarations(_block(self.css, ".sl-markdown-content :is(.rb-file-lookup, .rb-picker) select,\n"
|
||||
".sl-markdown-content select"))
|
||||
box = _declarations(_block(self.css, ".rb-table-filter input"))
|
||||
for prop in ("border", "border-radius", "min-height"):
|
||||
with self.subTest(prop=prop):
|
||||
self.assertEqual(select[prop], box[prop])
|
||||
self.assertEqual(select["appearance"], "none")
|
||||
self.assertIn("var(--rb-select-arrow)", select["background"])
|
||||
dark = _theme_tokens(self.css, ":root")["--rb-select-arrow"]
|
||||
light = _theme_tokens(self.css, ":root[data-theme='light']")["--rb-select-arrow"]
|
||||
# The arrow is drawn in each theme's secondary text colour.
|
||||
for arrow, tokens in ((dark, _theme_tokens(self.css, ":root")),
|
||||
(light, _theme_tokens(self.css, ":root[data-theme='light']"))):
|
||||
colour = tokens["--rb-text-secondary"].lstrip("#")
|
||||
self.assertIn(f"%23{colour}", arrow)
|
||||
|
||||
def test_crumb_separator_stays_with_its_label(self):
|
||||
self.assertIn("white-space: nowrap", _block(self.css, ".rb-breadcrumbs li"))
|
||||
self.assertIn("white-space: normal", _block(self.css, ".rb-breadcrumbs li > :is(a, span)"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,760 @@
|
||||
"""Emulator pages.
|
||||
|
||||
DuckStation's page opened on "It reads 105 files, 3 of them required" and
|
||||
read as "get all three", its three BIOS were cards 12 to 14 under one
|
||||
heading, its header said "Official ports" and "duckstation, Duckstation",
|
||||
and every system was an id ("sony-playstation").
|
||||
|
||||
NetherSX2 counted three interface fonts its app ships among "4 required"
|
||||
files and opened its file list on the project's counts. AetherSX2's summary
|
||||
held an empty Region column that pushed Required off a phone. No MAME or
|
||||
FinalBurn page said which romset its sets must match. DuckStation's page was
|
||||
59 phone screens of optional cards, and MAME's index put 13,300 elements on
|
||||
one page.
|
||||
|
||||
AetherSX2's page offered a command that answered "No supported platform
|
||||
detected" and a file to get by one name, while the app reads any PS2 BIOS
|
||||
by its contents. FinalBurn Neo and MAME 2003-Plus named no romset under
|
||||
their intro. MAME's card pages and the data directory pages did not say
|
||||
where they sit. melonDS marked its DSi files "required" above its DS files.
|
||||
The `00_example` template was listed as an emulator.
|
||||
|
||||
61 profiles cite the folder their app reads its data from (data_root) and
|
||||
no page printed it: AetherSX2's step said "inside the app's own data
|
||||
folder", ten pages named the field itself, and every install block offered
|
||||
Linux, Windows and Android commands, for MAME4droid an Android-only app.
|
||||
215 pages printed `--emulator X` for a name no published manifest takes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
import tempfile
|
||||
import importlib.util
|
||||
import json
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import siterender # noqa: E402
|
||||
import validate_site # noqa: E402
|
||||
|
||||
|
||||
EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
with open(REPO_ROOT / "emulators" / f"{name}.yml", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle)
|
||||
|
||||
|
||||
def _page(name: str, profile: dict) -> str:
|
||||
return gs.generate_emulator_page(name, profile, EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _files(count: int, **fields: object) -> list[dict]:
|
||||
return [{"name": f"f{i:03d}.bin", **fields} for i in range(count)]
|
||||
|
||||
|
||||
class TheIntroSaysWhatIsNeeded(unittest.TestCase):
|
||||
def test_one_bios_per_region(self):
|
||||
profile = _profile("duckstation")
|
||||
intro = gs._emulator_intro("DuckStation", profile)
|
||||
self.assertIn(
|
||||
"It picks the BIOS for your game's region: scph5500.bin (Japan), "
|
||||
"scph5501.bin (North America and Latin America) or scph5502.bin "
|
||||
"(Europe and Oceania).", intro)
|
||||
others = len(profile["files"]) - 3
|
||||
self.assertIn(f"The {others} other files are optional.", intro)
|
||||
self.assertNotIn("3 of them required", intro)
|
||||
self.assertIn("DuckStation emulates Sony PlayStation.", intro)
|
||||
|
||||
def test_required_files_without_regions_keep_the_count(self):
|
||||
intro = gs._emulator_intro("X", {"files": [
|
||||
{"name": "a.bin", "required": True}, {"name": "b.bin"}]})
|
||||
self.assertIn("It reads 2 files, 1 of them required", intro)
|
||||
|
||||
def test_several_machines_say_who_needs_a_required_file(self):
|
||||
intro = gs._emulator_intro("X", {
|
||||
"systems": ["sony-playstation", "sega-saturn"],
|
||||
"files": [{"name": "a.bin", "required": True}]})
|
||||
self.assertIn("A required file is needed only by the machines that use it.", intro)
|
||||
one = gs._emulator_intro("X", {
|
||||
"systems": ["sony-playstation"], "files": [{"name": "a.bin", "required": True}]})
|
||||
self.assertNotIn("machines that use it", one)
|
||||
|
||||
|
||||
class TheHeaderReadsAsWords(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = _page("duckstation", _profile("duckstation"))
|
||||
|
||||
def test_singular_classification(self):
|
||||
self.assertIn("| Classification | Official port |", self.page)
|
||||
|
||||
def test_cores_once_per_spelling(self):
|
||||
# duckstation-sa is ROCKNIX's name for the standalone build.
|
||||
self.assertIn("| Cores | duckstation, duckstation-sa |", self.page)
|
||||
|
||||
def test_systems_by_name(self):
|
||||
row = next(line for line in self.page.splitlines() if line.startswith("| Systems |"))
|
||||
self.assertIn("Sony PlayStation", row)
|
||||
self.assertNotIn("[sony-playstation]", row)
|
||||
|
||||
def test_region_badge_is_labelled(self):
|
||||
self.assertIn(">Region: Japan</span>", self.page)
|
||||
self.assertNotIn('rb-badge-muted">japan</span>', self.page)
|
||||
|
||||
def test_profile_version_names_the_pinned_build(self):
|
||||
# DuckStation tags builds v0.1-<commits since tag 0.1>, and the
|
||||
# pin sits past the last such tag: git describe's form names both.
|
||||
self.assertRegex(str(_profile("duckstation")["core_version"]),
|
||||
r"^v0\.1-\d+(-\d+-g[0-9a-f]{7})?$")
|
||||
|
||||
def test_the_second_spelling_is_a_platform_name(self):
|
||||
# The lookup is case-sensitive and EmuELEC launches "Duckstation":
|
||||
# the page hides the duplicate, the profile keeps it.
|
||||
with open(REPO_ROOT / "platforms" / "emuelec.yml", encoding="utf-8") as handle:
|
||||
emuelec = yaml.safe_load(handle)
|
||||
self.assertIn("Duckstation", emuelec["cores"])
|
||||
self.assertIn("Duckstation", _profile("duckstation")["cores"])
|
||||
|
||||
|
||||
class RequiredFilesComeFirst(unittest.TestCase):
|
||||
def test_sections_in_need_order(self):
|
||||
files = [
|
||||
{"name": "data.pak", "category": "game_data", "required": True},
|
||||
{"name": "opt.bin"},
|
||||
{"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16},
|
||||
]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
heads = [line for line in page.splitlines() if line.startswith("## ")]
|
||||
self.assertEqual(heads[:4], ["## Files", "## Required", "## Optional", "## Game data"])
|
||||
self.assertLess(page.index("## Required"), page.index("req.bin"))
|
||||
self.assertLess(page.index("req.bin"), page.index("## Optional"))
|
||||
self.assertLess(page.index("## Game data"), page.index("data.pak"))
|
||||
|
||||
def test_the_table_of_contents_turns_on(self):
|
||||
page = _page("duckstation", _profile("duckstation"))
|
||||
self.assertGreater(len(re.findall(r"^## ", page, re.M)), 2)
|
||||
|
||||
def test_rows_carry_region_and_hash(self):
|
||||
files = _files(gs.EMULATOR_TABLE_MIN - 1) + [
|
||||
{"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16}]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
required = page[page.index("## Required"):page.index("## Optional")]
|
||||
self.assertIn('id="file-req-bin', required)
|
||||
self.assertIn("Region: Japan", required)
|
||||
self.assertIn(f'MD5\u00a0<code title="{"ab" * 16}">{"ab" * 6}</code>', required)
|
||||
self.assertNotIn("f000.bin", required)
|
||||
|
||||
def test_no_table_for_a_few_cards(self):
|
||||
page = _page("x", {"type": "standalone", "files": _files(2)})
|
||||
self.assertNotIn('class="rb-entries"', page)
|
||||
self.assertIn('class="rb-file-entry"', page)
|
||||
|
||||
def test_a_split_page_puts_required_on_the_first_part(self):
|
||||
count = gs.EMULATOR_CARDS_PER_PAGE * 2
|
||||
files = _files(count) + [{"name": "last.bin", "required": True}]
|
||||
parts = gs.emulator_parts("big", files)
|
||||
self.assertIn(count, parts[0][1])
|
||||
pages = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
|
||||
"files": files}, {"files": {}})
|
||||
main = pages["big"]
|
||||
self.assertLess(main.index("## Required"), main.index("## Optional"))
|
||||
self.assertLess(main.index("last.bin"), main.index("f000.bin"))
|
||||
self.assertIn("## Required", pages["big-files-1"])
|
||||
|
||||
|
||||
class TheSummaryLineIsProse(unittest.TestCase):
|
||||
def test_no_pipes_and_one_number_format(self):
|
||||
files = _files(1563, category="bios_zip") + [
|
||||
{"name": "a.pak", "category": "game_data"}, {"name": "b.pak", "category": "game_data"}]
|
||||
for f in files[:164]:
|
||||
f["required"] = True
|
||||
text = "\n".join(gs._emulator_file_summary(files))
|
||||
self.assertNotIn(" | ", text)
|
||||
self.assertNotIn("1399", text)
|
||||
self.assertIn("1,401 optional", text)
|
||||
self.assertIn("By kind: 1,563 BIOS ZIPs and 2 game data.", text)
|
||||
|
||||
def test_category_badge_in_words(self):
|
||||
badges = " ".join(gs._file_badges({"name": "a", "category": "game_data"}, True))
|
||||
self.assertIn(">game data</span>", badges)
|
||||
|
||||
|
||||
class TheIndexNamesEmulatorsAndSystems(unittest.TestCase):
|
||||
def test_columns_and_counts(self):
|
||||
# Pages another test split off would send the console elsewhere.
|
||||
siterender.reset_console_pages()
|
||||
self.addCleanup(siterender.reset_console_pages)
|
||||
saved = dict(siterender._system_page_map)
|
||||
self.addCleanup(siterender.set_system_page_map, saved)
|
||||
siterender.set_system_page_map({"sony-playstation": ("sony", "PlayStation")})
|
||||
page = gs.generate_emulators_index({
|
||||
"duckstation": {"emulator": "DuckStation", "core_classification": "official_port",
|
||||
"systems": ["sony-playstation"], "files": _files(3)},
|
||||
})
|
||||
self.assertIn("| Emulator | Kind | Systems | Files |", page)
|
||||
self.assertIn("\n## D\n", page)
|
||||
self.assertNotIn("| Engine |", page)
|
||||
self.assertNotIn(" -- ", page)
|
||||
row = next(line for line in page.splitlines() if line.startswith("| [DuckStation]"))
|
||||
self.assertIn(f"[{siterender.system_name('sony-playstation')}]", row)
|
||||
self.assertIn("(../systems/sony.md#playstation)", row)
|
||||
self.assertNotIn("sony-playstation |", row)
|
||||
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _emulator_page(name: str, profile: dict | None = None) -> str:
|
||||
return gs.generate_emulator_page(name, profile or _profile(name), EMPTY_DB, None, set())
|
||||
|
||||
|
||||
class EachArcadePageNamesItsRomset(unittest.TestCase):
|
||||
def test_the_romset_by_profile(self):
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003")), "MAME 0.78")
|
||||
# The app's version is 1.38.1, its MAME is 0.289.
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame4droid-2024")), "MAME 0.289")
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003_plus")), "MAME 0.78 (plus backports)")
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbneo")).startswith("FinalBurn Neo "))
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbalpha2012")).startswith("FB Alpha "))
|
||||
self.assertEqual(gs.emulator_romset(_profile("duckstation")), "")
|
||||
# HBMAME publishes no MAME release number.
|
||||
self.assertEqual(gs.emulator_romset(_profile("hbmame")), "")
|
||||
|
||||
def test_a_row_and_a_line_under_the_intro(self):
|
||||
page = _emulator_page("mame2003")
|
||||
self.assertIn(f"| Romset | [MAME 0.78]({gs.ROMSET_FAQ}) |", page)
|
||||
self.assertNotIn("| MAME version |", _emulator_page("mame2016"))
|
||||
line = page.index("Its BIOS and game sets must come from the MAME 0.78 romset.")
|
||||
self.assertLess(line, page.index("About this profile"))
|
||||
self.assertIn(f"[Which version each core reads]({gs.ROMSET_FAQ})", page)
|
||||
|
||||
def test_the_faq_answer_exists(self):
|
||||
faq = (REPO_ROOT / "wiki" / "technical-faq.md").read_text(encoding="utf-8")
|
||||
self.assertIn("## Which MAME version do the arcade BIOS sets match?", faq)
|
||||
|
||||
def test_no_line_without_bios_zips(self):
|
||||
self.assertNotIn("must come from the", _emulator_page("duckstation"))
|
||||
|
||||
|
||||
class WhatTheAppShipsIsNoFileToGet(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.profile = _profile("nethersx2")
|
||||
self.page = _emulator_page("nethersx2", self.profile)
|
||||
|
||||
def test_the_badge(self):
|
||||
font = next(f for f in self.profile["files"] if f["name"] == "Roboto-Regular.ttf")
|
||||
badges = " ".join(gs._file_badges(font, False))
|
||||
self.assertIn(">comes with the app</span>", badges)
|
||||
self.assertNotIn(">required</span>", badges)
|
||||
self.assertNotIn(">missing</span>", badges)
|
||||
# Installed by one build only: the other still reads it from the folder.
|
||||
dual = " ".join(gs._file_badges({"name": "a", "bundled": True, "mode": "standalone",
|
||||
"required": True}, False))
|
||||
self.assertIn(">required</span>", dual)
|
||||
self.assertIn("installed with the standalone emulator", dual)
|
||||
|
||||
def test_counts_leave_them_out(self):
|
||||
shipped = sum(1 for f in self.profile["files"] if gs.comes_with_app(f))
|
||||
self.assertGreater(shipped, 0)
|
||||
summary = self.page[self.page.index("## Files"):]
|
||||
self.assertIn(f"{shipped} more files come with the app.", summary)
|
||||
self.assertIn(": 1 required,", summary)
|
||||
# Found by content: one BIOS of any name, said as steps.
|
||||
self.assertIn("NetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", self.page)
|
||||
|
||||
def test_their_own_section_after_the_others(self):
|
||||
heads = re.findall(r"^## (.+)$", self.page, re.M)
|
||||
self.assertEqual(heads[heads.index("Files") + 1:heads.index("Files") + 3],
|
||||
["Required", "Optional"])
|
||||
self.assertIn("Comes with the app", heads)
|
||||
section = self.page[self.page.index("## Required"):self.page.index("## Optional")]
|
||||
self.assertNotIn("Roboto-Regular.ttf", section)
|
||||
|
||||
def test_the_collection_counts_go_under_the_summary(self):
|
||||
held = self.page.index("<p><small>In this collection:")
|
||||
self.assertLess(held, self.page.index("## Required"))
|
||||
self.assertNotIn("in repo", self.page[self.page.index("## Files"):held])
|
||||
|
||||
def test_the_folder_is_the_apps_own(self):
|
||||
self.assertIn("| BIOS directory | `bios/`, inside the app's own data folder |", self.page)
|
||||
|
||||
|
||||
class TheSummaryTableFitsItsContent(unittest.TestCase):
|
||||
def test_an_empty_region_column_goes(self):
|
||||
rows = gs._summary_table(_files(3, md5="ab" * 16), [0, 1, 2], lambda _i, s: s)
|
||||
self.assertNotIn("Region", rows[0])
|
||||
|
||||
def test_a_constant_required_column_goes(self):
|
||||
rows = gs._summary_table(_files(3, required=True), [0, 1, 2], lambda _i, s: s)
|
||||
self.assertEqual(rows[0], "| File |")
|
||||
|
||||
def test_hashes_are_short_with_the_whole_value_in_the_title(self):
|
||||
md5 = "0123456789abcdef" * 2
|
||||
cell = gs._hash_short({"md5": md5})
|
||||
self.assertEqual(cell, f'MD5 <code title="{md5}">{md5[:12]}</code>')
|
||||
|
||||
def test_the_split_index_holds_each_id_on_its_link(self):
|
||||
files = _files(gs.EMULATOR_CARDS_PER_PAGE + 1, category="bios_zip")
|
||||
main = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
|
||||
"files": files}, {"files": {}})["big"]
|
||||
anchor = gs.emulator_file_anchors(files)[0]
|
||||
self.assertIn(f'<a id="{anchor}" href="/retrobios/emulators/big-files-1/#{anchor}">', main)
|
||||
self.assertNotIn(f'<span id="{anchor}"></span>', main)
|
||||
|
||||
|
||||
class LongPagesFold(unittest.TestCase):
|
||||
def test_each_entry_is_listed_once_as_a_row(self):
|
||||
files = _files(gs.EMULATOR_TABLE_MIN) + [{"name": "req.bin", "required": True}]
|
||||
page = _emulator_page("x", {"type": "standalone", "files": files})
|
||||
self.assertNotIn("| File |", page)
|
||||
self.assertNotIn(f"<summary>{gs.EMULATOR_TABLE_MIN} files</summary>", page)
|
||||
self.assertEqual(page.count('<h3 id="file-'), len(files))
|
||||
required = page[page.index("## Required"):page.index("## Optional")]
|
||||
self.assertEqual(required.count('<h3 id="file-'), 1)
|
||||
self.assertIn('class="rb-entries"', required)
|
||||
|
||||
def test_a_few_cards_stay_open(self):
|
||||
page = _emulator_page("x", {"type": "standalone", "files": _files(2)})
|
||||
self.assertNotIn("<summary>2 files</summary>", page)
|
||||
|
||||
def test_the_profile_card_folds(self):
|
||||
page = _emulator_page("duckstation")
|
||||
start = page.index("<summary>About this profile</summary>")
|
||||
self.assertLess(start, page.index('<div class="rb-meta-card">'))
|
||||
self.assertLess(page.index("| Type |"), page.index("</details>", start))
|
||||
|
||||
|
||||
class DataDirectoryPagesStayOutOfSearch(unittest.TestCase):
|
||||
def test_front_matter(self):
|
||||
saved = dict(gs._DATA_MEMBERS), dict(gs._DATA_READERS)
|
||||
self.addCleanup(lambda: (gs._DATA_MEMBERS.clear(), gs._DATA_MEMBERS.update(saved[0]),
|
||||
gs._DATA_READERS.clear(), gs._DATA_READERS.update(saved[1])))
|
||||
gs._DATA_MEMBERS["dolphin-sys"] = {"a.bin": {"sha1": "a" * 40, "size": 1}}
|
||||
gs._DATA_READERS["dolphin-sys"] = [("dolphin-mmjr", "Dolphin MMJR"), ("dolphin", "Dolphin")]
|
||||
page = gs._data_tree_page("dolphin-sys", {}, [("data-dolphin-sys", ["a.bin"])], 0)
|
||||
front = yaml.safe_load(page.split("---\n")[1])
|
||||
self.assertEqual(front["search"], "exclude")
|
||||
self.assertEqual(front["parent"], {"label": "Dolphin", "link": "dolphin.md"})
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "data-dolphin-sys").is_dir(), "site not built")
|
||||
def test_the_built_page_is_not_indexed(self):
|
||||
html = (DIST / "emulators" / "data-dolphin-sys" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertNotIn("data-pagefind-body", html)
|
||||
|
||||
|
||||
class AnElementBudget(unittest.TestCase):
|
||||
def test_validate_site_reports_a_crowded_page(self):
|
||||
self.assertEqual(validate_site.MAX_PAGE_ELEMENTS, 10_000)
|
||||
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
page = Path(tmp) / "crowded" / "index.html"
|
||||
page.parent.mkdir()
|
||||
page.write_text("<html><body>" + "<i></i>" * 10_001
|
||||
+ "<script>" + "<b>" * 5 + "</script></body></html>")
|
||||
issues = validate_site.validate_site(Path(tmp))
|
||||
self.assertTrue(any("elements, over the 10,000 budget" in issue for issue in issues))
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "mame" / "index.html").is_file(), "site not built")
|
||||
def test_mame_is_under_it(self):
|
||||
page = validate_site._parse_page(DIST / "emulators" / "mame" / "index.html")
|
||||
self.assertLess(page.elements, validate_site.MAX_PAGE_ELEMENTS)
|
||||
|
||||
|
||||
def _pages(name: str, profile: dict | None = None) -> dict[str, str]:
|
||||
return gs.emulator_pages(name, profile or _profile(name), EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _front(page: str) -> dict:
|
||||
assert page.startswith("---\n"), page[:80]
|
||||
return yaml.safe_load(page[4:page.index("\n---\n", 4)])
|
||||
|
||||
|
||||
class AnAppThatReadsAnyBios(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = _pages("aethersx2")["aethersx2"]
|
||||
|
||||
def test_the_intro_says_what_to_do(self):
|
||||
intro = self.page[self.page.index("# AetherSX2"):self.page.index("About this profile")]
|
||||
self.assertIn("AetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", intro)
|
||||
# The folder itself, per system the app runs on.
|
||||
self.assertRegex(intro, r"\n1\. .+\n2\. Put it in this folder:")
|
||||
self.assertIn("`Android/data/xyz.aethersx2.android/files/bios/`", intro)
|
||||
self.assertIn("Any file name works. The app reads what the file holds.", intro)
|
||||
self.assertNotIn("It reads", intro)
|
||||
|
||||
def test_the_card_names_an_example(self):
|
||||
self.assertIn("a Sony PlayStation 2 BIOS (example: <code>ps2-0230a-20080220.bin</code>)",
|
||||
self.page)
|
||||
|
||||
def test_no_command_that_cannot_place_the_file(self):
|
||||
# Only through a setup that launches the app, on its
|
||||
# device, never as if the app on its own were found.
|
||||
install = self.page[self.page.index("Install this emulator's files"):]
|
||||
install = install[:install.index("</details>")]
|
||||
self.assertRegex(install, r"With [^:]+: the installer finds")
|
||||
self.assertEqual(set(re.findall(r'<TabItem label="([^"]+)">', install)), {"On the device"})
|
||||
nether = _pages("nethersx2")["nethersx2"]
|
||||
if "--emulator nethersx2" in nether:
|
||||
self.assertIn("With ", nether)
|
||||
|
||||
def test_a_core_keeps_its_command(self):
|
||||
# LRPS2 runs in RetroArch, whose BIOS folder the installer finds.
|
||||
page = _pages("lrps2")["lrps2"]
|
||||
self.assertIn("--emulator lrps2", page)
|
||||
self.assertIn("2. Put it in `pcsx2/bios/`, inside your BIOS folder.", page)
|
||||
self.assertIn("a Sony PlayStation 2 BIOS, any file name", page)
|
||||
|
||||
def test_only_found_by_content_with_one_bios(self):
|
||||
# DuckStation picks the BIOS of the game's region: its intro stays.
|
||||
self.assertIsNone(gs.any_name_bios(_profile("duckstation")))
|
||||
self.assertIsNone(gs.any_name_bios(_profile("melonds")))
|
||||
self.assertIsNotNone(gs.any_name_bios(_profile("pcsx2")))
|
||||
|
||||
def test_the_download_links_the_collection_card(self):
|
||||
steps = gs._any_name_steps("X", _profile("aethersx2"),
|
||||
gs.any_name_bios(_profile("aethersx2")),
|
||||
"../systems/sony.md#sha1-abc")
|
||||
self.assertIn("1. Download [`ps2-0230a-20080220.bin`](../systems/sony.md#sha1-abc).", steps)
|
||||
|
||||
|
||||
class EveryArcadeCoreNamesItsRomset(unittest.TestCase):
|
||||
def test_finalburn_and_mame_2003_plus(self):
|
||||
for name, romset in (("fbneo", f"FinalBurn Neo {_profile('fbneo')['core_version']}"),
|
||||
("mame2003_plus", "MAME 0.78 (plus backports)")):
|
||||
page = _pages(name)[name]
|
||||
line = page.index(f"Its BIOS and game sets must come from the {romset} romset.")
|
||||
self.assertLess(line, page.index("About this profile"), name)
|
||||
|
||||
|
||||
class PartsKnowWhereTheySit(unittest.TestCase):
|
||||
def test_emulator_card_parts(self):
|
||||
pages = _pages("mame")
|
||||
parts = sorted((stem for stem in pages if stem.startswith("mame-files-")),
|
||||
key=lambda stem: int(stem.rsplit("-", 1)[1]))
|
||||
self.assertGreater(len(parts), 2)
|
||||
for index, stem in enumerate(parts):
|
||||
front = _front(pages[stem])
|
||||
self.assertEqual(front["parent"], {"label": "MAME", "link": "mame.md"})
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertEqual(front["prev"]["link"],
|
||||
f"{parts[index - 1]}.md" if index else "mame.md")
|
||||
if index + 1 < len(parts):
|
||||
self.assertEqual(front["next"]["link"], f"{parts[index + 1]}.md")
|
||||
else:
|
||||
self.assertIs(front["next"], False)
|
||||
|
||||
def test_data_directory_parts(self):
|
||||
ref = "x-sys"
|
||||
members = {f"f{i:05d}": {"size": 1, "sha1": f"{i:040x}"}
|
||||
for i in range(gs.DATA_TREE_ROWS_PER_PAGE * 2 + 1)}
|
||||
gs._DATA_MEMBERS[ref] = members
|
||||
gs._DATA_READERS[ref] = [("x", "X")]
|
||||
try:
|
||||
pages = gs.data_tree_pages(ref, members)
|
||||
self.assertEqual(len(pages), 3)
|
||||
middle = _front(gs._data_tree_page(ref, {}, pages, 1))
|
||||
finally:
|
||||
del gs._DATA_MEMBERS[ref], gs._DATA_READERS[ref]
|
||||
self.assertEqual(middle["parent"], {"label": "X", "link": "x.md"})
|
||||
self.assertEqual(middle["prev"]["link"], f"{pages[0][0]}.md")
|
||||
self.assertEqual(middle["next"]["link"], f"{pages[2][0]}.md")
|
||||
self.assertEqual(middle["search"], "exclude")
|
||||
|
||||
|
||||
class ARequiredFileSaysForWhichSystem(unittest.TestCase):
|
||||
def test_melonds_dsi_files(self):
|
||||
page = _pages("melonds")["melonds"]
|
||||
row = page[page.index('id="file-dsi-nand-bin"'):]
|
||||
row = row[:row.index("</tr>")]
|
||||
self.assertIn(">required for Nintendo DSi</span>", row)
|
||||
self.assertNotIn(">required</span>", page)
|
||||
|
||||
def test_one_system_says_required(self):
|
||||
# The section heading says it: the rows under it repeat nothing.
|
||||
page = _pages("aethersx2")["aethersx2"]
|
||||
self.assertIn("## Required", page)
|
||||
self.assertNotIn("required for", page)
|
||||
|
||||
|
||||
class TheTemplateIsNoEmulator(unittest.TestCase):
|
||||
def test_off_the_index_and_the_search(self):
|
||||
profiles = {"00_example": _profile("00_example"), "melonds": _profile("melonds")}
|
||||
index = gs.generate_emulators_index(profiles)
|
||||
self.assertNotIn("00_example", index)
|
||||
self.assertIn("melonds.md", index)
|
||||
# A test profile gets no page at all (generate_site's public_profiles).
|
||||
self.assertEqual(_profile("00_example")["type"], "test")
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "aethersx2" / "index.html").is_file(), "site not built")
|
||||
class TheBuiltEmulatorPages(unittest.TestCase):
|
||||
def _html(self, path: str) -> str:
|
||||
return (DIST / path / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
def test_aethersx2_downloads_from_the_collection(self):
|
||||
page = self._html("emulators/aethersx2")
|
||||
self.assertRegex(page, r'Download <a href="[^"]*/systems/sony[^"]*#sha1-[0-9a-f]{12}"')
|
||||
# The command only where a setup launches AetherSX2 itself (KNULLI,
|
||||
# ROCKNIX), never through RetroArch's folder, which the app never reads.
|
||||
self.assertIn("With ROCKNIX or KNULLI:", page)
|
||||
self.assertNotIn("RetroArch", page.split("Install this emulator", 1)[1].split("</details>", 1)[0])
|
||||
|
||||
def test_the_index_leaves_the_template_out(self):
|
||||
# 00_example is `type: test`: no page, no index row.
|
||||
self.assertNotIn("00_example", self._html("emulators"))
|
||||
self.assertIn('data-pagefind-body', self._html("emulators/melonds"))
|
||||
self.assertFalse((DIST / "emulators" / "00_example").exists())
|
||||
|
||||
def test_mame_parts_have_a_parent_crumb(self):
|
||||
page = self._html("emulators/mame-files-3")
|
||||
crumbs = re.search(r'<nav[^>]*rb-breadcrumbs.*?</nav>', page, re.S)
|
||||
self.assertIsNotNone(crumbs)
|
||||
self.assertIn(">MAME<", crumbs.group(0))
|
||||
|
||||
|
||||
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
EMULATORS = CONTENT / "emulators"
|
||||
|
||||
|
||||
def _profiles_with_data_root() -> dict[str, dict]:
|
||||
found = {}
|
||||
for path in sorted((REPO_ROOT / "emulators").glob("*.yml")):
|
||||
profile = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if isinstance(profile, dict) and isinstance(profile.get("data_root"), dict):
|
||||
found[path.stem] = profile
|
||||
return found
|
||||
|
||||
|
||||
def _built_page(name: str) -> str:
|
||||
for suffix in (".mdx", ".md"):
|
||||
path = EMULATORS / f"{name}{suffix}"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
def _generated() -> bool:
|
||||
return EMULATORS.is_dir() and any(EMULATORS.iterdir())
|
||||
|
||||
|
||||
def _install_section(page: str) -> str:
|
||||
match = re.search(r"<summary>Install this emulator's files</summary>(.*?)</details>",
|
||||
page, re.S)
|
||||
return match[1] if match else ""
|
||||
|
||||
|
||||
def _tab_labels(text: str) -> set[str]:
|
||||
return set(re.findall(r'<TabItem label="([^"]+)">', text))
|
||||
|
||||
|
||||
def _entries(profile: dict) -> list[dict]:
|
||||
return [f for f in profile.get("files") or [] if isinstance(f, dict)]
|
||||
|
||||
|
||||
def _alternatives(root: str) -> list[str]:
|
||||
return [part.strip().rstrip("/") for part in root.split(", else ")]
|
||||
|
||||
|
||||
class FolderOnlyTable(unittest.TestCase):
|
||||
"""The entries whose joined path is not the app's folder: one table in
|
||||
the generator, each row with its reason, and it cannot rot."""
|
||||
|
||||
def test_every_row_names_a_profile_and_entries_that_exist(self) -> None:
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, reason in gs.FOLDER_ONLY:
|
||||
with self.subTest(profile=profile_name, patterns=patterns):
|
||||
self.assertIn(profile_name, roots, "no profile with data_root by that name")
|
||||
self.assertTrue(reason.strip())
|
||||
entries = _entries(roots[profile_name])
|
||||
for pattern in patterns:
|
||||
self.assertTrue(
|
||||
any(gs.folder_only_matches(pattern, f) for f in entries),
|
||||
f"{pattern!r} names no entry of {profile_name}")
|
||||
self.assertLessEqual(set(keys), set(roots[profile_name]["data_root"]))
|
||||
|
||||
def test_a_listed_entry_is_never_joined_to_the_root(self) -> None:
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
|
||||
profile = roots[profile_name]
|
||||
for entry in _entries(profile):
|
||||
if not any(gs.folder_only_matches(p, entry) for p in patterns):
|
||||
continue
|
||||
raw = {str(entry.get(k) or "") for k in ("path", "standalone_path")} - {""}
|
||||
for key in keys or tuple(profile["data_root"]):
|
||||
with self.subTest(profile=profile_name, entry=entry.get("name"), key=key):
|
||||
self.assertEqual(gs.standalone_place(profile_name, profile, entry, key), "")
|
||||
self.assertEqual(gs.standalone_place(profile_name, profile, entry), "")
|
||||
block = "\n".join(gs.data_folder_block(profile_name, profile))
|
||||
for root in _alternatives(str(profile["data_root"][key]["path"])):
|
||||
for place in raw:
|
||||
self.assertNotIn(f"{root}/{place}", block)
|
||||
|
||||
def test_built_pages_print_no_joined_path_for_a_listed_entry(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
|
||||
profile = roots[profile_name]
|
||||
pages = [p.read_text(encoding="utf-8")
|
||||
for p in EMULATORS.glob(f"{profile_name}*.md*")]
|
||||
for entry in _entries(profile):
|
||||
if not any(gs.folder_only_matches(p, entry) for p in patterns):
|
||||
continue
|
||||
for key in keys or tuple(profile["data_root"]):
|
||||
for root in _alternatives(str(profile["data_root"][key]["path"])):
|
||||
for place in {entry.get("path"), entry.get("standalone_path")} - {None, ""}:
|
||||
joined = f"{root}/{place}"
|
||||
for text in pages:
|
||||
self.assertNotIn(joined, text, profile_name)
|
||||
|
||||
|
||||
class DataFolderPrinted(unittest.TestCase):
|
||||
def test_every_profile_prints_each_of_its_folders(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
for name, profile in _profiles_with_data_root().items():
|
||||
if profile.get("type") in ("alias", "test"):
|
||||
continue
|
||||
page = _built_page(name)
|
||||
with self.subTest(profile=name):
|
||||
self.assertIn('id="data-folder"', page)
|
||||
for root in profile["data_root"].values():
|
||||
for alternative in _alternatives(str(root["path"])):
|
||||
self.assertIn(alternative, page)
|
||||
|
||||
def test_ryujinx_keys_sit_in_system(self) -> None:
|
||||
block = "\n".join(gs.data_folder_block("ryujinx", _profile("ryujinx")))
|
||||
self.assertIn("`%APPDATA%/Ryujinx/system/`", block)
|
||||
self.assertIn("`~/Library/Application Support/Ryujinx/system/`", block)
|
||||
self.assertEqual(_tab_labels(block), {"Windows", "macOS", "Linux"})
|
||||
|
||||
def test_aethersx2_step_names_the_android_folder(self) -> None:
|
||||
profile = _profile("aethersx2")
|
||||
steps = gs._any_name_steps("AetherSX2", profile, gs.any_name_bios(profile), "", "aethersx2")
|
||||
self.assertIn("2. Put it in this folder:", steps)
|
||||
self.assertIn("`Android/data/xyz.aethersx2.android/files/bios/`, on the internal storage.",
|
||||
steps)
|
||||
self.assertNotIn("app's own data folder", steps)
|
||||
|
||||
def test_one_system_is_a_line_not_a_tab(self) -> None:
|
||||
block = "\n".join(gs.data_folder_block("mame4droid", _profile("mame4droid")))
|
||||
self.assertTrue(block.startswith("On Android: `Android/data/com.seleuco.mame4droid/files/`"))
|
||||
self.assertNotIn("<Tabs", block)
|
||||
|
||||
def test_an_entry_found_by_name_or_shipped_is_not_placed(self) -> None:
|
||||
profile = {"type": "standalone", "data_root": {"linux": {"path": "~/x", "source_ref": "a"}}}
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a.bin"}), "")
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "a", "bundled": True}),
|
||||
"")
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "b/a"}), "b/a")
|
||||
dual = {**profile, "type": "standalone + libretro"}
|
||||
self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "core/a"}), "")
|
||||
self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "c/a",
|
||||
"standalone_path": "a"}), "a")
|
||||
|
||||
def test_the_metadata_card_has_a_row_per_system(self) -> None:
|
||||
rows = gs._render_metadata_rows(_profile("cxbx-reloaded"))
|
||||
self.assertIn("| Data folder (Windows) | `%APPDATA%/Cxbx-Reloaded/` |", rows)
|
||||
self.assertFalse(any("Data folder (Linux)" in row for row in rows))
|
||||
|
||||
def test_no_page_names_the_field(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
named = sorted(str(p.relative_to(CONTENT)) for p in CONTENT.rglob("*.md*")
|
||||
if "data_root" in p.read_text(encoding="utf-8")
|
||||
and not p.relative_to(CONTENT).as_posix().startswith("wiki/profiling"))
|
||||
self.assertEqual(named, [])
|
||||
|
||||
|
||||
class InstallBlockWhereItWorks(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
|
||||
cls.install = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.install)
|
||||
cls.accepted: dict[str, set[str]] = {}
|
||||
for path in sorted((REPO_ROOT / "install").glob("*.json")):
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
names = cls.install.emulator_names(manifest, manifest.get("files") or [],
|
||||
manifest.get("omitted_files") or [])
|
||||
for alias, profile in names.items():
|
||||
cls.accepted.setdefault(alias, set()).add(profile)
|
||||
|
||||
def test_every_printed_command_names_this_profile_in_a_manifest(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
for path in sorted(EMULATORS.glob("*.md*")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for name in set(re.findall(r"--emulator ([\w.+-]+)", text)):
|
||||
with self.subTest(page=path.name, name=name):
|
||||
self.assertIn(path.stem, self.accepted.get(name, set()))
|
||||
|
||||
def test_no_os_tab_outside_the_data_root(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
labels = {key: gs.TERMINALS[key][0] for key in gs.TERMINALS}
|
||||
for name, profile in _profiles_with_data_root().items():
|
||||
allowed = {labels["device"]}
|
||||
for key in profile["data_root"]:
|
||||
allowed |= {labels[t] for t in gs._DATA_ROOT_TERMINALS[key]}
|
||||
with self.subTest(profile=name):
|
||||
self.assertLessEqual(_tab_labels(_install_section(_built_page(name))), allowed)
|
||||
|
||||
def test_android_only_app_gets_no_desktop_command(self) -> None:
|
||||
self.assertEqual(gs._install_block("mame4droid", _profile("mame4droid")), [])
|
||||
command, setups, oses = gs.emulator_install_route("cxbx-reloaded", _profile("cxbx-reloaded"))
|
||||
self.assertEqual(oses, ("windows",))
|
||||
|
||||
def test_a_lone_app_is_named_with_the_setup_that_launches_it(self) -> None:
|
||||
text = "\n".join(gs._install_block("ryujinx", _profile("ryujinx")))
|
||||
self.assertRegex(text, r"With [^:]*EmuDeck[^:]*: the installer finds")
|
||||
self.assertNotIn("Android (Termux)", text)
|
||||
self.assertNotIn("RetroArch", text)
|
||||
|
||||
def test_a_dual_app_says_what_the_command_fills(self) -> None:
|
||||
text = "\n".join(gs._install_block("duckstation", _profile("duckstation")))
|
||||
self.assertIn("For DuckStation inside RetroArch, Batocera or another setup.", text)
|
||||
|
||||
def test_no_files_no_command(self) -> None:
|
||||
self.assertEqual(gs._install_block("beetle_wswan", _profile("beetle_wswan")), [])
|
||||
|
||||
def test_tabs_follow_the_keys_given(self) -> None:
|
||||
tabs = "\n".join(gs.install_commands("--emulator x", oses=("linux", "steamdeck")))
|
||||
self.assertEqual(_tab_labels(tabs), {"Linux", "Steam Deck"})
|
||||
self.assertIn('syncKey="os"', tabs)
|
||||
blocks = "\n".join(gs.install_commands("--emulator x"))
|
||||
self.assertIn("Windows (PowerShell):", blocks)
|
||||
|
||||
|
||||
class SmallFixes(unittest.TestCase):
|
||||
def test_structured_blocks_closed_below_the_files(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
page = _built_page("cemu")
|
||||
self.assertLess(page.index("## Files"), page.index("<summary>Firmware titles</summary>"))
|
||||
self.assertNotRegex(page, r"<details[^>]*open>\s*<summary>Firmware titles")
|
||||
|
||||
def test_region_choice_summary(self) -> None:
|
||||
lines = gs._emulator_file_summary(_profile("duckstation")["files"])
|
||||
self.assertIn("One of 3 is required, for your games' region.", lines[0])
|
||||
self.assertNotIn("3 required", lines[0])
|
||||
|
||||
def test_no_emulator_part_says_where_it_sits(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
parts = sorted(EMULATORS.glob("*-files-*.md*"))
|
||||
if not parts:
|
||||
self.skipTest("no split emulator page")
|
||||
said = [p.name for p in parts if "\nPart of [" in p.read_text(encoding="utf-8")]
|
||||
self.assertEqual(said, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -366,7 +366,7 @@ class AVariantShowsWhoPinsIt(unittest.TestCase):
|
||||
platforms={"c" * 40: {"batocera", "retrobat"}}, emulators={"a" * 40: {"mame"}})
|
||||
page = generate_site.generate_system_page(
|
||||
"Entex", {"Adventure Vision": [primary, variant]}, consumers)
|
||||
# Another revision of a set sits open beside it (round 5, 6.3).
|
||||
# Another revision of a set sits open beside it.
|
||||
block = page[page.index('id="sha1-' + "c" * 12 + '"'):]
|
||||
self.assertIn("<dt>Collection path</dt><dd><code>bios/Entex/Adventure Vision/"
|
||||
".variants/advision.zip.1c086064</code>", block)
|
||||
|
||||
@@ -203,11 +203,11 @@ class DeepLinksCarryALabel(unittest.TestCase):
|
||||
|
||||
A link right after the question it answers ("Have a file? [Check it]")
|
||||
says it too: there a label read as a broken crumb beside the Reference
|
||||
tab (round 4)."""
|
||||
tab."""
|
||||
|
||||
# Answers a start page sends its reader to on purpose: the arcade
|
||||
# version table opens on one plain sentence, and "Reference:" before
|
||||
# its link read as a broken crumb (round 4, C4 and C9).
|
||||
# its link read as a broken crumb.
|
||||
ANSWERS = ("/retrobios/wiki/technical-faq/#which-mame-version-do-the-arcade-bios-sets-match",)
|
||||
|
||||
def test_start_and_setup_pages(self):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""What the round 4 packages left between them once merged.
|
||||
"""What separate fixes left between them once merged.
|
||||
|
||||
A data directory page printed its readers as a Python list, every board
|
||||
page's breadcrumb read "Arcade / Arcade", the fba2012 section was headed
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Navigation (round 2, package 3).
|
||||
"""Navigation and search.
|
||||
|
||||
The 404 page's sidebar read "0 1 2 3 4". A sidebar group label was a link
|
||||
to its first page ("Handhelds and TV boxes" opened AmberELEC), and a crumb
|
||||
@@ -7,8 +7,18 @@ Previous and next ran from Sony to Sord and from one tab into another,
|
||||
and disagreed with the page's own "Next" list. The Reference tab was a flat
|
||||
list of fifteen. These tests read the generator and, where it is built,
|
||||
the site.
|
||||
"""
|
||||
|
||||
"playstation bios" found five setups' lists for the console before its own
|
||||
page: the pages a setup's list moves a console to weighed 1.5, three times a
|
||||
reference page. The file names a system page holds were the text of a hidden
|
||||
span, so the search showed them as the excerpt. Start here looped: First
|
||||
install's "Next steps" pointed back to Get your files, Is it legal? to Common
|
||||
questions, and both pages had the same page as previous and next. A part
|
||||
page had no crumb to the page it belongs to and nothing marked in the
|
||||
sidebar. "psx" or "dc" found nothing, though every platform files the
|
||||
console under that name. These tests read the generator and, where they
|
||||
are built, the pages and the site.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -19,20 +29,24 @@ import tempfile
|
||||
import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
import ast
|
||||
from html import unescape
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import sitestarlight # noqa: E402
|
||||
from common import list_registered_platforms, load_platform_config, load_platform_registry # noqa: E402
|
||||
from common import ( # noqa: E402
|
||||
list_registered_platforms,
|
||||
load_platform_config,
|
||||
load_platform_registry,
|
||||
)
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
ROUTE = REPO_ROOT / "website" / "src" / "routeData.ts"
|
||||
TAB_ROOTS = {"/retrobios/", "/retrobios/platforms/", "/retrobios/explore/",
|
||||
"/retrobios/wiki/", "/retrobios/contributing/"}
|
||||
|
||||
|
||||
def _nav() -> list:
|
||||
platforms = REPO_ROOT / "platforms"
|
||||
registry = load_platform_registry(str(platforms))
|
||||
@@ -47,24 +61,18 @@ def _nav() -> list:
|
||||
"mame": {"emulator": "MAME", "systems": ["arcade"]},
|
||||
}
|
||||
return generate_site.generate_nav(coverages, manufacturers, profiles, registry)
|
||||
|
||||
|
||||
def _targets(value: object) -> set[str]:
|
||||
if isinstance(value, str):
|
||||
return {value}
|
||||
if isinstance(value, list):
|
||||
return set().union(*(_targets(v) for v in value)) if value else set()
|
||||
return set().union(*(_targets(v) for v in value.values()))
|
||||
|
||||
|
||||
def _present(nav: list) -> set[str]:
|
||||
return {t for t in _targets(nav) if not t.startswith("http")}
|
||||
|
||||
|
||||
# The setups index writes one `## <label>` per device group, so its anchors
|
||||
# follow the labels in platforms/_guides.yml instead of a copy of them.
|
||||
from siteguides import DEVICE_GROUPS # noqa: E402
|
||||
|
||||
|
||||
def _index_anchors(markdown: str) -> set[str]:
|
||||
"""The heading ids a generated page carries once written."""
|
||||
_title, title_id, body = sitestarlight.split_title(markdown)
|
||||
@@ -315,7 +323,7 @@ class PlatformShareCards(unittest.TestCase):
|
||||
def test_other_pages_keep_their_section_card(self):
|
||||
self.assertEqual(sitestarlight.og_image("platforms/index.md", "setup"),
|
||||
f"{sitestarlight.SITE_URL}assets/og/setup.png")
|
||||
# A manufacturer page has its own card since round 3 (6.6). One
|
||||
# A manufacturer page has its own card. One
|
||||
# without a rendered card keeps its section's.
|
||||
self.assertEqual(sitestarlight.og_image("systems/sony.md", "explore"),
|
||||
f"{sitestarlight.SITE_URL}assets/og/systems/sony.png")
|
||||
@@ -374,7 +382,7 @@ def _read(path: Path) -> _Page:
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
class BuiltSite(unittest.TestCase):
|
||||
class BuiltNavigation(unittest.TestCase):
|
||||
def test_the_404_sidebar_names_pages(self):
|
||||
page = _read(DIST / "404.html")
|
||||
self.assertTrue(page.sidebar)
|
||||
@@ -411,5 +419,224 @@ class BuiltSite(unittest.TestCase):
|
||||
self.assertEqual(data.get("/emulators/mednafen_psx/"), "/emulators/beetle_psx/")
|
||||
|
||||
|
||||
SEARCH_GATE = REPO_ROOT / "website" / "tools" / "search_gate.py"
|
||||
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
|
||||
CONTENT_CONFIG = REPO_ROOT / "website" / "src" / "content.config.ts"
|
||||
START_HERE = [
|
||||
"/retrobios/", "/retrobios/wiki/what-are-bios-files/", "/retrobios/which-pack/",
|
||||
"/retrobios/wiki/first-install/", "/retrobios/wiki/troubleshooting/",
|
||||
"/retrobios/wiki/faq/", "/retrobios/wiki/legal/", "/retrobios/wiki/glossary/",
|
||||
"/retrobios/platforms/",
|
||||
]
|
||||
|
||||
|
||||
def _front(text: str) -> str:
|
||||
end = text.find("\n---\n", 4)
|
||||
return text[4:end] if text.startswith("---\n") and end != -1 else ""
|
||||
|
||||
|
||||
def _moved_platform_pages() -> list[Path]:
|
||||
platforms = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||
folder = DOCS / "platforms"
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
# A guide page sits beside the setups (platforms/linux-handhelds) without
|
||||
# being a list moved off one of them.
|
||||
from siteguides import HANDHELD_GUIDE # noqa: PLC0415
|
||||
|
||||
guides = {Path(HANDHELD_GUIDE.path).stem} if HANDHELD_GUIDE else set()
|
||||
return [path for path in sorted(folder.glob("*.md*"))
|
||||
if path.stem not in platforms and path.stem != "index" and path.stem not in guides]
|
||||
|
||||
|
||||
class MetaFields(unittest.TestCase):
|
||||
def test_file_names_are_an_attribute_not_text(self) -> None:
|
||||
body = ('<h3 class="rb-file-title"><code>scph5501.bin</code></h3>'
|
||||
'<h3 class="rb-file-title"><code>a,b.bin</code></h3>')
|
||||
meta = sitestarlight.file_names_meta(body)
|
||||
# Each name as its parts, each part once.
|
||||
self.assertIn('data-pagefind-meta="files:a b bin scph5501"', meta)
|
||||
self.assertRegex(meta, r"<span [^>]*hidden></span>")
|
||||
|
||||
def test_a_field_escapes_its_value(self) -> None:
|
||||
meta = sitestarlight.search_field("aliases", ['a"b', "c&d"])
|
||||
self.assertIn('data-pagefind-meta="aliases:a"b c&d"', meta)
|
||||
|
||||
def test_an_empty_field_writes_nothing(self) -> None:
|
||||
self.assertEqual(sitestarlight.search_field("aliases", []), "")
|
||||
self.assertEqual(sitestarlight.file_names_meta("<p>no card</p>"), "")
|
||||
|
||||
def test_system_pages_carry_the_platforms_names(self) -> None:
|
||||
page = DOCS / "systems" / "sony-playstation.md"
|
||||
if not page.is_file():
|
||||
self.skipTest("site pages not generated")
|
||||
text = page.read_text(encoding="utf-8")
|
||||
match = re.search(r'data-pagefind-meta="aliases:([^"]*)"', text)
|
||||
self.assertIsNotNone(match, "no aliases field on the PlayStation page")
|
||||
self.assertIn("psx", unescape(match[1]).split())
|
||||
# The Dreamcast section's page: its own, or Sega's when it fits there.
|
||||
own = DOCS / "systems" / "sega-dreamcast.md"
|
||||
dreamcast = (own if own.is_file() else DOCS / "systems" / "sega.md").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertRegex(dreamcast, r'data-pagefind-meta="aliases:[^"]*\bdc\b')
|
||||
|
||||
|
||||
class SystemPartsSitUnderTheirMaker(unittest.TestCase):
|
||||
"""The crumbs say where a system part sits, read from its parent: field.
|
||||
The "Part of" line under them said it again."""
|
||||
|
||||
def test_every_part_names_its_maker_as_parent(self) -> None:
|
||||
pages = sorted((DOCS / "systems").glob("*.md"))
|
||||
if not pages:
|
||||
self.skipTest("site pages not generated")
|
||||
makers = {page.stem for page in pages}
|
||||
orphans = []
|
||||
for page in pages:
|
||||
text = page.read_text(encoding="utf-8")
|
||||
front = text.split("\n---\n", 1)[0]
|
||||
parent = re.search(r"^parent:\n label: .+\n link: /retrobios/systems/([a-z0-9-]+)/$",
|
||||
front, re.M)
|
||||
if parent is None:
|
||||
continue
|
||||
if parent[1] not in makers:
|
||||
orphans.append(page.name)
|
||||
self.assertNotIn("\nPart of [", text, page.name)
|
||||
self.assertEqual(orphans, [])
|
||||
|
||||
|
||||
class MovedPlatformPages(unittest.TestCase):
|
||||
def test_a_setups_list_for_one_console_is_a_reference(self) -> None:
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
block = source[source.index("for stem, page in moved.items():"):]
|
||||
block = block[: block.index("PAGE_DATA[f\"platforms/{name}.md\"]")]
|
||||
self.assertIn('SEARCH_DEMOTED.add(f"platforms/{stem}.md")', block)
|
||||
self.assertIn('level="reference"', block)
|
||||
self.assertIn('search="exclude"', block)
|
||||
self.assertIn("parent=", block)
|
||||
|
||||
def test_generated_pages_weigh_as_references_and_name_their_setup(self) -> None:
|
||||
pages = _moved_platform_pages()
|
||||
if not pages:
|
||||
self.skipTest("site pages not generated")
|
||||
for path in pages:
|
||||
front = _front(path.read_text(encoding="utf-8"))
|
||||
with self.subTest(page=path.name):
|
||||
self.assertIn("\nlevel: reference\n", f"\n{front}\n")
|
||||
self.assertIn("\nsearchWeight: 0.5\n", f"\n{front}\n")
|
||||
# The title alone was indexed, and it outranked the console.
|
||||
self.assertIn("\npagefind: false\n", f"\n{front}\n")
|
||||
self.assertRegex(front, r"parent:\n label: .+\n link: /retrobios/platforms/[a-z0-9_-]+/\n")
|
||||
|
||||
def test_parent_link_resolves_against_the_page(self) -> None:
|
||||
_target, text = sitestarlight.finalize_page(
|
||||
"platforms/retroarch-sony-playstation.md", "# RetroArch: Sony PlayStation\n\nBody.\n",
|
||||
level="reference", weight=0.5, browser_title="x", description="d",
|
||||
structured={}, terms={}, parent={"label": "RetroArch", "link": "retroarch.md"},
|
||||
)
|
||||
self.assertIn("parent:\n label: RetroArch\n link: /retrobios/platforms/retroarch/\n", text)
|
||||
|
||||
def test_the_schema_accepts_parent(self) -> None:
|
||||
self.assertRegex(CONTENT_CONFIG.read_text(encoding="utf-8"),
|
||||
r"parent: z\.object\(\{ label: z\.string\(\), link: z\.string\(\) \}\)")
|
||||
|
||||
|
||||
class ArcadeGroup(unittest.TestCase):
|
||||
def test_arcade_is_not_a_console_or_computer(self) -> None:
|
||||
nav = generate_site._system_nav({"Sony": {}, "Arcade": {}, "Sega": {}})
|
||||
groups = {next(iter(entry)): entry[next(iter(entry))] for entry in nav if isinstance(entry, dict)}
|
||||
self.assertEqual(groups["Arcade"], [{"Arcade": "systems/arcade.md"}])
|
||||
self.assertNotIn({"Arcade": "systems/arcade.md"}, groups["Consoles and computers"])
|
||||
|
||||
|
||||
class RouteMiddleware(unittest.TestCase):
|
||||
def test_next_steps_link_is_kept_only_forward(self) -> None:
|
||||
source = ROUTE.read_text(encoding="utf-8")
|
||||
self.assertIn("target <= position", source)
|
||||
|
||||
def test_parent_is_a_crumb_and_marked(self) -> None:
|
||||
source = ROUTE.read_text(encoding="utf-8")
|
||||
self.assertIn("route.entry.data.parent", source)
|
||||
self.assertIn("markCurrent(route.sidebar", source)
|
||||
|
||||
|
||||
def _gate_queries() -> dict:
|
||||
"""The search gate's QUERIES, read from its source: the tool is run
|
||||
with node beside it, not imported as a package."""
|
||||
tree = ast.parse(SEARCH_GATE.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "QUERIES":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("no QUERIES in search_gate.py")
|
||||
|
||||
|
||||
class SearchGateQueries(unittest.TestCase):
|
||||
def test_the_audits_queries_are_gated(self) -> None:
|
||||
for query in ("playstation bios", "dreamcast bios", "neo geo bios", "bios not found",
|
||||
"offline install", "r36s", "wonderswan color", "dc bios missing",
|
||||
"which mame version", "psx bios"):
|
||||
self.assertIn(query, _gate_queries())
|
||||
|
||||
def test_prefixes_match_a_consoles_own_page(self) -> None:
|
||||
pages, _rank = _gate_queries()["playstation bios"]
|
||||
self.assertTrue(any("systems/sony-playstation/".startswith(p) for p in pages))
|
||||
|
||||
|
||||
def _html(url: str) -> str:
|
||||
rel = url[len("/retrobios/"):]
|
||||
path = DIST / rel / "index.html"
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
|
||||
def _pagination(text: str) -> tuple[str | None, str | None]:
|
||||
start = text.find('<div class="pagination-links')
|
||||
if start == -1:
|
||||
return None, None
|
||||
links = text[start:text.find("</footer>", start)]
|
||||
prev = re.search(r'<a href="([^"]+)"[^>]*rel="prev"', links)
|
||||
nxt = re.search(r'<a href="([^"]+)"[^>]*rel="next"', links)
|
||||
return (prev[1] if prev else None), (nxt[1] if nxt else None)
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
class BuiltSearchAndCrumbs(unittest.TestCase):
|
||||
def test_next_from_home_reads_start_here_once(self) -> None:
|
||||
seen, url = [], "/retrobios/"
|
||||
while url and url not in seen and len(seen) < 20:
|
||||
seen.append(url)
|
||||
url = _pagination(_html(url))[1]
|
||||
self.assertEqual(seen[: len(START_HERE)], START_HERE)
|
||||
self.assertEqual(len(seen), len(set(seen)))
|
||||
|
||||
def test_no_page_has_the_same_previous_and_next(self) -> None:
|
||||
same = []
|
||||
for path in DIST.rglob("index.html"):
|
||||
prev, nxt = _pagination(path.read_text(encoding="utf-8"))
|
||||
if prev and prev == nxt:
|
||||
same.append(str(path.relative_to(DIST)))
|
||||
self.assertEqual(same, [])
|
||||
|
||||
def test_search_fields_hold_no_text(self) -> None:
|
||||
texts = []
|
||||
for path in (DIST / "systems").rglob("index.html"):
|
||||
# The search fields are attributes on empty spans. The title
|
||||
# meta reads the visible h1.
|
||||
for match in re.finditer(r'<(\w+)[^>]*data-pagefind-meta="(?:files|aliases|devices|consoles|terms):'
|
||||
r'[^>]*>([^<]*)</\1>',
|
||||
path.read_text(encoding="utf-8")):
|
||||
if match[2].strip():
|
||||
texts.append(str(path.relative_to(DIST)))
|
||||
self.assertEqual(texts, [])
|
||||
|
||||
def test_a_setups_console_page_names_its_setup(self) -> None:
|
||||
text = _html("/retrobios/platforms/retroarch-sony-playstation/")
|
||||
if not text:
|
||||
self.skipTest("no RetroArch PlayStation page")
|
||||
crumbs = re.search(r'<nav[^>]*class="[^"]*rb-breadcrumbs[^"]*"[^>]*>(.*?)</nav>', text, re.S)
|
||||
self.assertIsNotNone(crumbs)
|
||||
self.assertIn('href="/retrobios/platforms/retroarch/"', crumbs[1])
|
||||
self.assertRegex(text, r'<a href="/retrobios/platforms/retroarch/"[^>]*aria-current="page"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Round 2, package 2: the site's components and runtime.
|
||||
|
||||
Source checks always run. Checks on the built site read website/dist and
|
||||
skip when no build is there. The stacking rule runs the plugin under node
|
||||
and skips without node or the site's node_modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
WEBSITE = REPO_ROOT / "website"
|
||||
DIST = WEBSITE / "dist"
|
||||
COMPONENTS = WEBSITE / "src" / "components"
|
||||
SITE_TS = WEBSITE / "src" / "scripts" / "site.ts"
|
||||
CONFIG = WEBSITE / "astro.config.mjs"
|
||||
PLUGIN = WEBSITE / "src" / "plugins" / "rehype-stack-tables.mjs"
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _built(page: str) -> str:
|
||||
path = DIST / page / "index.html"
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest(f"no built site at {path}")
|
||||
return _read(path)
|
||||
|
||||
|
||||
def _tables(html: str) -> list[str]:
|
||||
return re.findall(r"<table\b.*?</table>", html, re.S)
|
||||
|
||||
|
||||
def _headers(table: str) -> list[str]:
|
||||
head = re.search(r"<thead>(.*?)</thead>", table, re.S)
|
||||
cells = re.findall(r"<th\b[^>]*>(.*?)</th>", head.group(1), re.S) if head else []
|
||||
return [re.sub(r"<[^>]+>", "", cell).strip() for cell in cells]
|
||||
|
||||
|
||||
class TablesStackWhenBuilt(unittest.TestCase):
|
||||
"""The setup tables of Get your files were 591, 503 and 511 px wide in a
|
||||
358 px phone column: site.ts stacked them only once they neared the
|
||||
screen, so the ZIP column first painted off-screen."""
|
||||
|
||||
def test_the_plugin_is_registered_and_the_runtime_rule_is_gone(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("import stackTables from './src/plugins/rehype-stack-tables.mjs'", config)
|
||||
self.assertIn("hastPlugins: [stackTables(), ", config)
|
||||
self.assertNotIn("stackActionTable", _read(SITE_TS))
|
||||
|
||||
def test_setup_tables_carry_the_layout_in_the_html(self):
|
||||
html = _built("which-pack")
|
||||
setups = [t for t in _tables(html) if _headers(t) == ["Setup", "Runs on", "BIOS folder", "ZIP"]]
|
||||
self.assertGreaterEqual(len(setups), 3)
|
||||
for table in setups:
|
||||
with self.subTest(table=table[:80]):
|
||||
self.assertIn('data-rb-stack="true"', table.split(">", 1)[0])
|
||||
cells = re.findall(r"<td\b[^>]*>", table)
|
||||
self.assertTrue(cells)
|
||||
for cell in cells:
|
||||
self.assertRegex(cell, r'data-label="(Setup|Runs on|BIOS folder|ZIP)"')
|
||||
|
||||
def test_two_column_tables_are_label_and_value(self):
|
||||
html = _built("platforms/retroarch")
|
||||
pairs = [t for t in _tables(html) if len(_headers(t)) == 2]
|
||||
self.assertTrue(pairs)
|
||||
for table in pairs:
|
||||
self.assertIn('data-rb-stack="kv"', table.split(">", 1)[0])
|
||||
|
||||
def test_the_rule_reads_the_table(self):
|
||||
node = shutil.which("node")
|
||||
if not node or not (WEBSITE / "node_modules").is_dir():
|
||||
self.skipTest("node or the site's node_modules is missing")
|
||||
cases = {
|
||||
# A setup added after the release: no ZIP link, the header still names one.
|
||||
"zip-header": {"labels": ["Setup", "Runs on", "BIOS folder", "ZIP"], "rows": 5, "action": False},
|
||||
"action": {"labels": ["A", "B", "C"], "rows": 40, "action": True},
|
||||
"pair": {"labels": ["Where", "BIOS folder"], "rows": 3, "action": False},
|
||||
"short-start": {"labels": ["System", "Runs", "BIOS folder"], "rows": 8, "action": False, "level": "start"},
|
||||
"long-setup": {"labels": ["System", "Files", "Status"], "rows": 97, "action": False, "level": "setup"},
|
||||
"reference": {"labels": ["A", "B", "C"], "rows": 5, "action": False, "level": "reference"},
|
||||
# Four columns do not fit a phone column, on any page (round 3).
|
||||
"wide": {"labels": ["File", "Region", "Required", "Hash"], "rows": 105, "action": False, "level": "reference"},
|
||||
# A hash never breaks: three columns with one overflow a phone (NetherSX2).
|
||||
"hashes": {"labels": ["File", "Required", "Hash"], "rows": 40, "action": False, "level": "reference"},
|
||||
}
|
||||
script = (
|
||||
f"import {{ stackKind }} from {json.dumps(PLUGIN.as_uri())};"
|
||||
f"const cases = {json.dumps(cases)};"
|
||||
"console.log(JSON.stringify(Object.fromEntries(Object.entries(cases).map(([k, v]) => [k, stackKind(v)]))));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[node, "--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, check=True, cwd=WEBSITE,
|
||||
)
|
||||
self.assertEqual(json.loads(result.stdout), {
|
||||
"zip-header": "true", "action": "true", "pair": "kv",
|
||||
"short-start": "true", "long-setup": None, "reference": None, "wide": "true",
|
||||
"hashes": "true",
|
||||
})
|
||||
|
||||
|
||||
class ScrollingTablesHaveTheirOwnName(unittest.TestCase):
|
||||
"""Every scrolling table was the region "Data table, scrollable table"
|
||||
(axe landmark-unique on which-pack, retroarch, gaps)."""
|
||||
|
||||
def test_no_bare_fallback_label(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertNotIn("'Data table'", source)
|
||||
self.assertIn("`${tableName(table)}, scrollable table`", source)
|
||||
self.assertIn("`${heading}, table ${count}`", source)
|
||||
|
||||
|
||||
class CommandBlocksHaveNoWindowDots(unittest.TestCase):
|
||||
def test_the_dots_are_off_in_the_theme(self):
|
||||
self.assertIn("terminalTitlebarDotsOpacity: '0'", _read(CONFIG))
|
||||
css = DIST / "_astro"
|
||||
if not css.is_dir():
|
||||
self.skipTest("no built site")
|
||||
values = {m for f in css.glob("ec.*.css") for m in re.findall(r"--ec-frm-trmTtbDotsOpa:([^;]+)", _read(f))}
|
||||
self.assertEqual(values, {"0"})
|
||||
|
||||
|
||||
class RedirectsComeFromTheGenerator(unittest.TestCase):
|
||||
def test_config_reads_the_file_and_builds_without_it(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("./src/generated/redirects.json", config)
|
||||
self.assertIn("if (error.code === 'ENOENT') return {};", config)
|
||||
self.assertIn("redirects: readRedirects(),", config)
|
||||
# Astro does not put the base before the new address.
|
||||
self.assertIn("BASE + to.replace(/^\\//, '')", config)
|
||||
|
||||
|
||||
class SearchAnswersAHashAndShowsNoFileDump(unittest.TestCase):
|
||||
"""Searching the scph5501 MD5 opened on Data model, results carried a
|
||||
second "#_top" title, and a system page showed its 120 file names."""
|
||||
|
||||
def test_runtime(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("Look up this hash", source)
|
||||
self.assertIn("files/?hash=${value}", source)
|
||||
self.assertIn("endsWith('#_top')", source)
|
||||
self.assertIn("UNSHOWN_FIELDS.has(tag.dataset.pagefindUiMeta ?? '')", source)
|
||||
# Pagefind's display rules beat the hidden attribute without this.
|
||||
self.assertIn("#starlight__search [hidden] { display: none !important; }", _read(COMPONENTS / "Header.astro"))
|
||||
|
||||
|
||||
class SectionTabsInTheHeader(unittest.TestCase):
|
||||
def test_registered_and_built(self):
|
||||
self.assertIn("Header: './src/components/Header.astro'", _read(CONFIG))
|
||||
html = _built("which-pack")
|
||||
nav = re.search(r'<nav class="rb-tabs[^"]*"[^>]*>(.*?)</nav>', html, re.S)
|
||||
self.assertIsNotNone(nav)
|
||||
labels = [re.sub(r"<[^>]+>", "", a).strip() for a in re.findall(r"<a\b[^>]*>.*?</a>", nav.group(1), re.S)]
|
||||
self.assertEqual(labels, ["Start here", "Your setup", "Explore", "Reference", "Contribute"])
|
||||
self.assertEqual(nav.group(1).count('aria-current="true"'), 1)
|
||||
self.assertRegex(nav.group(1), r'aria-current="true"[^>]*>\s*Start here')
|
||||
|
||||
|
||||
def _title_block(html: str) -> str:
|
||||
"""What PageTitle draws: from the main column to the h1."""
|
||||
end = html.index('id="_top"')
|
||||
return html[html.rfind("<main", 0, end):end]
|
||||
|
||||
|
||||
class LevelBadgeOnlyWhereItAddsSomething(unittest.TestCase):
|
||||
def test_badge_follows_the_crumb(self):
|
||||
# A tab root: the header tab already says it (round 3, 2.1).
|
||||
self.assertNotIn('class="rb-level ', _title_block(_built("platforms")))
|
||||
# Under the "Start here" or "Your setup" crumb the badge repeated it.
|
||||
for page in ("which-pack", "platforms/batocera"):
|
||||
with self.subTest(page=page):
|
||||
block = _title_block(_built(page))
|
||||
self.assertIn('aria-label="Breadcrumb"', block)
|
||||
self.assertNotIn('class="rb-level ', block)
|
||||
|
||||
|
||||
class TitleLogo(unittest.TestCase):
|
||||
def test_front_matter_field(self):
|
||||
self.assertIn("logo: z.string().optional()", _read(WEBSITE / "src" / "content.config.ts"))
|
||||
title = _read(COMPONENTS / "PageTitle.astro")
|
||||
self.assertIn('<img class="rb-title-mark" src={logoSrc} alt="" width={MARK} height={MARK} />', title)
|
||||
|
||||
|
||||
class FooterSaysWhoAndWhen(unittest.TestCase):
|
||||
def test_release_and_maintainer(self):
|
||||
tag = json.loads(_read(REPO_ROOT / "release.json"))["tag"]
|
||||
html = _built("which-pack")
|
||||
footer = html[html.index('class="rb-footer'):]
|
||||
self.assertIn("https://github.com/Abdess/retrobios/issues/new", footer)
|
||||
self.assertIn(f"/releases/tag/{tag}", footer)
|
||||
match = re.fullmatch(r"v(\d{4})\.(\d{2})\.(\d{2})", tag)
|
||||
if match:
|
||||
self.assertIn(f'<time datetime="{match[1]}-{match[2]}-{match[3]}"', footer)
|
||||
self.assertRegex(re.sub(r"<[^>]+>", "", footer), r"Abdess on GitHub\. Report a problem\.")
|
||||
|
||||
|
||||
class FindAFileGivesAVerdict(unittest.TestCase):
|
||||
def test_lookup_renderer(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("'Held in the collection at '", source)
|
||||
self.assertIn("['Setup', 'Name as declared', 'Destination', 'Hash matched', 'Required']", source)
|
||||
self.assertIn("api/v1/platforms/${item.owner}.json", source)
|
||||
self.assertIn("api/v1/systems/${words.slice(0, size).join('-')}.json", source)
|
||||
style = _read(COMPONENTS / "MarkdownContent.astro")
|
||||
self.assertIn("max-width: 40rem;", style)
|
||||
self.assertIn("width: 100%;", style)
|
||||
|
||||
|
||||
class Picker(unittest.TestCase):
|
||||
def test_choices(self):
|
||||
source = _read(COMPONENTS / "FilePicker.astro")
|
||||
self.assertIn("box.checked = preset ? preset.includes(choice.id) : false;", source)
|
||||
self.assertIn(".sort((a, b) => a.title.localeCompare(b.title, 'en'))", source)
|
||||
self.assertIn('placeholder="Type a console"', source)
|
||||
self.assertIn("filter.focus()", source)
|
||||
self.assertIn("n.toLocaleString('en')", source)
|
||||
self.assertNotRegex(source, r"`\$\{files\.length\} files")
|
||||
self.assertIn("<figcaption>Terminal</figcaption>", source)
|
||||
self.assertIn("flex-direction: column;", source)
|
||||
# The size gap is the release against the current list, from the
|
||||
# release record reduced to the registry's release_pack setups.
|
||||
self.assertIn("import release from '../generated/release-advertised.json';", source)
|
||||
self.assertIn("It was built from the list of that day.", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Round 2 theme: the stylesheet fixes of package 1, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: a red focus ring
|
||||
and hover, Starlight's 35 px h2, tinted disclosure blocks, striped asides,
|
||||
the search result file dump, lifting shadows, a sidebar without hierarchy,
|
||||
two indigos, permalinks hidden on touch screens, OS tabs cut at 320 px, a
|
||||
column that moves on pages without a TOC.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CSS_PATH = ROOT / "website" / "src" / "styles" / "retrobios.css"
|
||||
|
||||
# Page backgrounds Starlight 0.42.6 draws under our tokens, read with
|
||||
# getComputedStyle on a built page (light: white, dark: gray-7 hsl(224 10% 10%)).
|
||||
PAGE_BG = {"light": "#ffffff", "dark": "#17181c"}
|
||||
# Text Starlight draws on a filled button (--sl-color-black in each theme).
|
||||
ON_FILL = {"light": "#ffffff", "dark": "#17181c"}
|
||||
|
||||
|
||||
def _strip_comments(css: str) -> str:
|
||||
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
|
||||
|
||||
def _blocks(css: str) -> list[tuple[str, str]]:
|
||||
"""Innermost (selector, body) pairs, media queries flattened."""
|
||||
return [(sel.strip(), body) for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)]
|
||||
|
||||
|
||||
def _theme_tokens(css: str, selector: str) -> dict[str, str]:
|
||||
for sel, body in _blocks(css):
|
||||
if sel == selector:
|
||||
return dict(re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", body))
|
||||
raise AssertionError(f"no block {selector}")
|
||||
|
||||
|
||||
def _rules(css: str, selector_part: str) -> list[str]:
|
||||
return [body for sel, body in _blocks(css) if selector_part in sel]
|
||||
|
||||
|
||||
def _luminance(hex_colour: str) -> float:
|
||||
value = hex_colour.lstrip("#")
|
||||
channels = [int(value[i : i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||
linear = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
|
||||
|
||||
def _ratio(a: str, b: str) -> float:
|
||||
la, lb = sorted((_luminance(a), _luminance(b)), reverse=True)
|
||||
return (la + 0.05) / (lb + 0.05)
|
||||
|
||||
|
||||
class ThemeStylesheet(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
dark = _theme_tokens(cls.css, ":root")
|
||||
light = {**dark, **_theme_tokens(cls.css, ":root[data-theme='light']")}
|
||||
cls.tokens = {"dark": dark, "light": light}
|
||||
|
||||
def resolve(self, theme: str, token: str) -> str:
|
||||
value = self.tokens[theme][token].strip()
|
||||
while value.startswith("var("):
|
||||
value = self.tokens[theme][value[4:-1].strip()].strip()
|
||||
return value
|
||||
|
||||
# 1.1
|
||||
def test_focus_ring_is_the_link_colour_not_red(self):
|
||||
focus = _rules(self.css, ":focus-visible")
|
||||
self.assertTrue(focus)
|
||||
for body in focus:
|
||||
self.assertIn("2px solid var(--rb-link)", body)
|
||||
self.assertNotIn("accent", body.replace("--rb-link", ""))
|
||||
ring = next(b for s, b in _blocks(self.css) if s.startswith(":where(a, button"))
|
||||
self.assertIn("outline-offset: 2px", ring)
|
||||
self.assertNotIn("#b42318", self.css)
|
||||
self.assertNotIn("--rb-accent", self.css)
|
||||
|
||||
def test_button_hover_stays_in_its_own_colour(self):
|
||||
self.assertNotRegex(self.css, r"\.rb-button:is\(:hover,\s*:focus\)")
|
||||
primary = _rules(self.css, ".rb-button-primary:hover")
|
||||
self.assertTrue(primary)
|
||||
self.assertIn("var(--rb-link-strong)", primary[0])
|
||||
self.assertEqual(self.resolve("light", "--rb-link-strong"), "#1d2f73")
|
||||
self.assertEqual(self.resolve("dark", "--rb-link-strong"), "#c9d2ff")
|
||||
outline = [b for s, b in _blocks(self.css) if s.endswith(".rb-button:hover")]
|
||||
self.assertIn("var(--sl-color-accent-low)", outline[0])
|
||||
|
||||
# 1.3
|
||||
def test_heading_scale(self):
|
||||
root = self.tokens["dark"]
|
||||
self.assertEqual(root["--sl-text-h1"], "2.25rem")
|
||||
self.assertEqual(root["--sl-text-h2"], "1.5rem")
|
||||
self.assertEqual(root["--sl-text-h3"], "1.2rem")
|
||||
self.assertEqual(root["--sl-text-h4"], "1rem")
|
||||
self.assertRegex(self.css, r"@media \(max-width: 50rem\) \{\s*:root \{ --sl-text-h1: 1\.875rem; \}")
|
||||
self.assertRegex(self.css, r"level-h2, h2\) \{ margin-top: 2\.75rem; \}")
|
||||
self.assertRegex(self.css, r"level-h3, h3:not\(\.rb-file-title\)\) \{ margin-top: 1\.75rem; \}")
|
||||
|
||||
# 1.2
|
||||
def test_asides_are_neutral_boxes(self):
|
||||
neutral = _rules(self.css, ".starlight-aside:is(.starlight-aside--note, .starlight-aside--tip)")
|
||||
joined = "".join(neutral)
|
||||
self.assertIn("--sl-color-asides-border: var(--rb-border)", joined)
|
||||
self.assertIn("background: var(--rb-surface)", joined)
|
||||
self.assertIn("display: none", joined)
|
||||
box = _rules(self.css, ".sl-markdown-content .starlight-aside")[0]
|
||||
self.assertIn("border: 1px solid", box)
|
||||
|
||||
# 1.4
|
||||
def test_disclosure_blocks_are_neutral(self):
|
||||
self.assertNotIn("rb-details-tip", self.css)
|
||||
self.assertNotIn("--sl-color-blue", self.css)
|
||||
base = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content details.rb-details")
|
||||
self.assertIn("background: transparent", base)
|
||||
self.assertIn("var(--rb-border)", base)
|
||||
self.assertIn("0.95rem", _rules(self.css, "details.rb-details > summary")[0])
|
||||
|
||||
# 1.5
|
||||
def test_search_hides_the_file_list(self):
|
||||
self.assertIn("display: none", _rules(self.css, "site-search .pagefind-ui__result-tags")[0])
|
||||
|
||||
# 1.6
|
||||
def test_no_lifting_shadow_on_pagination_or_menu(self):
|
||||
self.assertIn("box-shadow: none", _rules(self.css, ".pagination-links a")[0])
|
||||
self.assertIn("font-size: 1.05rem", _rules(self.css, ".pagination-links .link-title")[0])
|
||||
menu = next(b for s, b in _blocks(self.css) if s == ".sl-menu-button")
|
||||
self.assertIn("box-shadow: none", menu)
|
||||
self.assertIn("background: transparent", menu)
|
||||
self.assertIn("1px solid var(--rb-border)", menu)
|
||||
|
||||
# 1.7
|
||||
def test_sidebar_hierarchy(self):
|
||||
label = _rules(self.css, ".top-level summary .large")[0]
|
||||
self.assertIn("var(--sl-text-xs)", label)
|
||||
self.assertIn("var(--rb-text-secondary)", label)
|
||||
current = _rules(self.css, "a[aria-current='page']")[0]
|
||||
self.assertIn("background: var(--sl-color-accent-low)", current)
|
||||
self.assertIn("color: var(--sl-color-accent-high)", current)
|
||||
self.assertNotIn("border", current)
|
||||
|
||||
# 1.8
|
||||
def test_one_indigo(self):
|
||||
for theme in ("light", "dark"):
|
||||
with self.subTest(theme=theme):
|
||||
self.assertEqual(self.resolve(theme, "--sl-color-accent"), self.resolve(theme, "--rb-link"))
|
||||
self.assertEqual(self.resolve(theme, "--sl-color-text-accent"), self.resolve(theme, "--rb-link"))
|
||||
|
||||
# 1.9
|
||||
def test_permalinks_visible_without_hover(self):
|
||||
self.assertRegex(self.css, r"@media \(hover: none\) \{\s*\.rb-permalink \{ opacity: 1; \}")
|
||||
permalink = next(b for s, b in _blocks(self.css) if s == ".rb-permalink")
|
||||
self.assertIn("min-height: 24px", permalink)
|
||||
|
||||
# 1.10, then round 4 item 1.1: the row wraps, every tab on screen
|
||||
def test_os_tabs_stay_reachable(self):
|
||||
tablist = _rules(self.css, "starlight-tabs [role='tablist']")[0]
|
||||
self.assertIn("flex-wrap: wrap", tablist)
|
||||
self.assertIn("overflow-x: visible", tablist)
|
||||
self.assertIn("white-space: nowrap", _rules(self.css, "starlight-tabs [role='tab']")[0])
|
||||
|
||||
# 1.11
|
||||
def test_page_without_toc_keeps_the_column(self):
|
||||
self.assertIn("--__toc-width: initial", _rules(self.css, ":root[data-has-sidebar]:not([data-has-toc])")[0])
|
||||
# Round 5: one pane rule for every page with a sidebar, "On this page"
|
||||
# or not, placed from the grid width.
|
||||
pane = _rules(self.css, ":root[data-has-sidebar] .main-pane")[0]
|
||||
self.assertIn("--sl-content-margin-inline: max(0rem, 100% - var(--rb-grid-width)) auto", pane)
|
||||
|
||||
# 1.12, 1.14
|
||||
def test_card_pages(self):
|
||||
cards = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content :is(.rb-sys-file, .rb-file-entry)")
|
||||
self.assertIn("content-visibility: auto", cards)
|
||||
self.assertIn("contain-intrinsic-size: auto 320px", cards)
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)", _rules(self.css, "body:has(.rb-sys-file, .rb-file-entry)")[0])
|
||||
self.assertEqual(_theme_tokens(self.css, ":root")["--rb-grid-width"], "54rem")
|
||||
facts = _rules(self.css, "dl.rb-facts")[0]
|
||||
self.assertIn("grid-template-columns: 11rem minmax(0, 1fr)", facts)
|
||||
self.assertRegex(self.css, r"@media \(max-width: 30rem\) \{\s*\.sl-markdown-content dl\.rb-facts \{ grid-template-columns: minmax\(0, 1fr\)")
|
||||
|
||||
# 1.13
|
||||
def test_long_values_wrap_between_words(self):
|
||||
table = _rules(self.css, ".rb-meta-card table")[0]
|
||||
self.assertIn("table-layout: fixed", table)
|
||||
self.assertIn("display: table", table)
|
||||
self.assertNotRegex(self.css, r"\.rb-meta-card td \{ overflow-wrap: anywhere")
|
||||
self.assertIn("overflow-wrap: anywhere", _rules(self.css, ".rb-meta-card td code")[0])
|
||||
self.assertIn("width: 40%", "".join(_rules(self.css, ".rb-meta-card td:first-child")))
|
||||
upstream = _rules(self.css, "#by-upstream-emulator")[0]
|
||||
self.assertIn("max-width: 22rem", upstream)
|
||||
|
||||
# 1.15
|
||||
def test_two_column_tables_stack_label_over_value(self):
|
||||
self.assertIn("content: none", _rules(self.css, "table[data-rb-stack='kv'] td::before")[0])
|
||||
self.assertTrue(_rules(self.css, "table[data-rb-stack='kv'] td:first-child"))
|
||||
|
||||
def test_contrast_meets_wcag_aa_in_both_themes(self):
|
||||
for theme in ("light", "dark"):
|
||||
bg = PAGE_BG[theme]
|
||||
pairs = [
|
||||
("link", self.resolve(theme, "--rb-link"), bg, 4.5),
|
||||
("focus ring", self.resolve(theme, "--rb-link"), bg, 3.0),
|
||||
("primary button", ON_FILL[theme], self.resolve(theme, "--rb-link"), 4.5),
|
||||
("primary hover", ON_FILL[theme], self.resolve(theme, "--rb-link-strong"), 4.5),
|
||||
("outline hover", self.resolve(theme, "--rb-link"), self.resolve(theme, "--sl-color-accent-low"), 4.5),
|
||||
("sidebar current", self.resolve(theme, "--sl-color-accent-high"), self.resolve(theme, "--sl-color-accent-low"), 4.5),
|
||||
("secondary text", self.resolve(theme, "--rb-text-secondary"), bg, 4.5),
|
||||
("link on surface", self.resolve(theme, "--rb-link"), self.resolve(theme, "--rb-surface"), 4.5),
|
||||
]
|
||||
for label, fg, back, need in pairs:
|
||||
with self.subTest(theme=theme, pair=label):
|
||||
self.assertGreaterEqual(_ratio(fg, back), need, f"{fg} on {back}")
|
||||
|
||||
|
||||
|
||||
|
||||
class DeviceTilesAndHeaderLogo(unittest.TestCase):
|
||||
"""The device tiles had no style once merged: every line underlined and one
|
||||
tall block per setup on a phone. The header logo was sized from the whole
|
||||
header, tab row included, and grew to 84 px."""
|
||||
|
||||
def test_a_tile_is_one_compact_link(self):
|
||||
css = CSS_PATH.read_text(encoding="utf-8")
|
||||
self.assertRegex(css, r"a\.rb-tile \{[^}]*grid-template-columns: 40px 1fr")
|
||||
self.assertRegex(css, r"a\.rb-tile \{[^}]*text-decoration: none")
|
||||
|
||||
def test_the_header_logo_follows_the_top_bar(self):
|
||||
css = CSS_PATH.read_text(encoding="utf-8")
|
||||
self.assertIn("header .site-title img { height: calc(4rem - 2 * var(--sl-nav-pad-y))", css)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Emulator pages say what is needed first, in names (round 2, package 7).
|
||||
|
||||
DuckStation's page opened on "It reads 105 files, 3 of them required" and
|
||||
read as "get all three", its three BIOS were cards 12 to 14 under one
|
||||
heading, its header said "Official ports" and "duckstation, Duckstation",
|
||||
and every system was an id ("sony-playstation").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import siterender # noqa: E402
|
||||
|
||||
EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
with open(REPO_ROOT / "emulators" / f"{name}.yml", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle)
|
||||
|
||||
|
||||
def _page(name: str, profile: dict) -> str:
|
||||
return gs.generate_emulator_page(name, profile, EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _files(count: int, **fields: object) -> list[dict]:
|
||||
return [{"name": f"f{i:03d}.bin", **fields} for i in range(count)]
|
||||
|
||||
|
||||
class TheIntroSaysWhatIsNeeded(unittest.TestCase):
|
||||
def test_one_bios_per_region(self):
|
||||
profile = _profile("duckstation")
|
||||
intro = gs._emulator_intro("DuckStation", profile)
|
||||
self.assertIn(
|
||||
"It picks the BIOS for your game's region: scph5500.bin (Japan), "
|
||||
"scph5501.bin (North America and Latin America) or scph5502.bin "
|
||||
"(Europe and Oceania).", intro)
|
||||
others = len(profile["files"]) - 3
|
||||
self.assertIn(f"The {others} other files are optional.", intro)
|
||||
self.assertNotIn("3 of them required", intro)
|
||||
self.assertIn("DuckStation emulates Sony PlayStation.", intro)
|
||||
|
||||
def test_required_files_without_regions_keep_the_count(self):
|
||||
intro = gs._emulator_intro("X", {"files": [
|
||||
{"name": "a.bin", "required": True}, {"name": "b.bin"}]})
|
||||
self.assertIn("It reads 2 files, 1 of them required", intro)
|
||||
|
||||
def test_several_machines_say_who_needs_a_required_file(self):
|
||||
intro = gs._emulator_intro("X", {
|
||||
"systems": ["sony-playstation", "sega-saturn"],
|
||||
"files": [{"name": "a.bin", "required": True}]})
|
||||
self.assertIn("A required file is needed only by the machines that use it.", intro)
|
||||
one = gs._emulator_intro("X", {
|
||||
"systems": ["sony-playstation"], "files": [{"name": "a.bin", "required": True}]})
|
||||
self.assertNotIn("machines that use it", one)
|
||||
|
||||
|
||||
class TheHeaderReadsAsWords(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = _page("duckstation", _profile("duckstation"))
|
||||
|
||||
def test_singular_classification(self):
|
||||
self.assertIn("| Classification | Official port |", self.page)
|
||||
|
||||
def test_cores_once_per_spelling(self):
|
||||
# duckstation-sa is ROCKNIX's name for the standalone build.
|
||||
self.assertIn("| Cores | duckstation, duckstation-sa |", self.page)
|
||||
|
||||
def test_systems_by_name(self):
|
||||
row = next(line for line in self.page.splitlines() if line.startswith("| Systems |"))
|
||||
self.assertIn("Sony PlayStation", row)
|
||||
self.assertNotIn("[sony-playstation]", row)
|
||||
|
||||
def test_region_badge_is_labelled(self):
|
||||
self.assertIn(">Region: Japan</span>", self.page)
|
||||
self.assertNotIn('rb-badge-muted">japan</span>', self.page)
|
||||
|
||||
def test_profile_version_names_the_pinned_build(self):
|
||||
# DuckStation tags builds v0.1-<commits since tag 0.1>, and the
|
||||
# pin sits past the last such tag: git describe's form names both.
|
||||
self.assertRegex(str(_profile("duckstation")["core_version"]),
|
||||
r"^v0\.1-\d+(-\d+-g[0-9a-f]{7})?$")
|
||||
|
||||
def test_the_second_spelling_is_a_platform_name(self):
|
||||
# The lookup is case-sensitive and EmuELEC launches "Duckstation":
|
||||
# the page hides the duplicate, the profile keeps it.
|
||||
with open(REPO_ROOT / "platforms" / "emuelec.yml", encoding="utf-8") as handle:
|
||||
emuelec = yaml.safe_load(handle)
|
||||
self.assertIn("Duckstation", emuelec["cores"])
|
||||
self.assertIn("Duckstation", _profile("duckstation")["cores"])
|
||||
|
||||
|
||||
class RequiredFilesComeFirst(unittest.TestCase):
|
||||
def test_sections_in_need_order(self):
|
||||
files = [
|
||||
{"name": "data.pak", "category": "game_data", "required": True},
|
||||
{"name": "opt.bin"},
|
||||
{"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16},
|
||||
]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
heads = [line for line in page.splitlines() if line.startswith("## ")]
|
||||
self.assertEqual(heads[:4], ["## Files", "## Required", "## Optional", "## Game data"])
|
||||
self.assertLess(page.index("## Required"), page.index("req.bin"))
|
||||
self.assertLess(page.index("req.bin"), page.index("## Optional"))
|
||||
self.assertLess(page.index("## Game data"), page.index("data.pak"))
|
||||
|
||||
def test_the_table_of_contents_turns_on(self):
|
||||
page = _page("duckstation", _profile("duckstation"))
|
||||
self.assertGreater(len(re.findall(r"^## ", page, re.M)), 2)
|
||||
|
||||
def test_rows_carry_region_and_hash(self):
|
||||
files = _files(gs.EMULATOR_TABLE_MIN - 1) + [
|
||||
{"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16}]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
required = page[page.index("## Required"):page.index("## Optional")]
|
||||
self.assertIn('id="file-req-bin', required)
|
||||
self.assertIn("Region: Japan", required)
|
||||
self.assertIn(f'MD5\u00a0<code title="{"ab" * 16}">{"ab" * 6}</code>', required)
|
||||
self.assertNotIn("f000.bin", required)
|
||||
|
||||
def test_no_table_for_a_few_cards(self):
|
||||
page = _page("x", {"type": "standalone", "files": _files(2)})
|
||||
self.assertNotIn('class="rb-entries"', page)
|
||||
self.assertIn('class="rb-file-entry"', page)
|
||||
|
||||
def test_a_split_page_puts_required_on_the_first_part(self):
|
||||
count = gs.EMULATOR_CARDS_PER_PAGE * 2
|
||||
files = _files(count) + [{"name": "last.bin", "required": True}]
|
||||
parts = gs.emulator_parts("big", files)
|
||||
self.assertIn(count, parts[0][1])
|
||||
pages = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
|
||||
"files": files}, {"files": {}})
|
||||
main = pages["big"]
|
||||
self.assertLess(main.index("## Required"), main.index("## Optional"))
|
||||
self.assertLess(main.index("last.bin"), main.index("f000.bin"))
|
||||
self.assertIn("## Required", pages["big-files-1"])
|
||||
|
||||
|
||||
class TheSummaryLineIsProse(unittest.TestCase):
|
||||
def test_no_pipes_and_one_number_format(self):
|
||||
files = _files(1563, category="bios_zip") + [
|
||||
{"name": "a.pak", "category": "game_data"}, {"name": "b.pak", "category": "game_data"}]
|
||||
for f in files[:164]:
|
||||
f["required"] = True
|
||||
text = "\n".join(gs._emulator_file_summary(files))
|
||||
self.assertNotIn(" | ", text)
|
||||
self.assertNotIn("1399", text)
|
||||
self.assertIn("1,401 optional", text)
|
||||
self.assertIn("By kind: 1,563 BIOS ZIPs and 2 game data.", text)
|
||||
|
||||
def test_category_badge_in_words(self):
|
||||
badges = " ".join(gs._file_badges({"name": "a", "category": "game_data"}, True))
|
||||
self.assertIn(">game data</span>", badges)
|
||||
|
||||
|
||||
class TheIndexNamesEmulatorsAndSystems(unittest.TestCase):
|
||||
def test_columns_and_counts(self):
|
||||
# Pages another test split off would send the console elsewhere.
|
||||
siterender.reset_console_pages()
|
||||
self.addCleanup(siterender.reset_console_pages)
|
||||
saved = dict(siterender._system_page_map)
|
||||
self.addCleanup(siterender.set_system_page_map, saved)
|
||||
siterender.set_system_page_map({"sony-playstation": ("sony", "PlayStation")})
|
||||
page = gs.generate_emulators_index({
|
||||
"duckstation": {"emulator": "DuckStation", "core_classification": "official_port",
|
||||
"systems": ["sony-playstation"], "files": _files(3)},
|
||||
})
|
||||
self.assertIn("| Emulator | Kind | Systems | Files |", page)
|
||||
self.assertIn("\n## D\n", page)
|
||||
self.assertNotIn("| Engine |", page)
|
||||
self.assertNotIn(" -- ", page)
|
||||
row = next(line for line in page.splitlines() if line.startswith("| [DuckStation]"))
|
||||
self.assertIn(f"[{siterender.system_name('sony-playstation')}]", row)
|
||||
self.assertIn("(../systems/sony.md#playstation)", row)
|
||||
self.assertNotIn("sony-playstation |", row)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,330 +0,0 @@
|
||||
"""Entry pages and guides (round 2, package 4).
|
||||
|
||||
The home page said the command "writes only in your BIOS folder" while
|
||||
install.py writes the entries a manifest files under `root: save` into the
|
||||
frontend's save folder. It ended on a tinted "Next" box, a raw ISO stamp and
|
||||
a setup count the diagrams gave one lower. Get your files offered the same
|
||||
choice three times, a Batocera page sent a beginner to "a terminal on the
|
||||
device" with no way to open one, and command blocks had no Windows form or
|
||||
no title.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import generate_readme # noqa: E402
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import siteguides # noqa: E402
|
||||
import sitemarkup # noqa: E402
|
||||
from common import load_platform_config, load_platform_registry # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
|
||||
GUIDES_YAML = (REPO_ROOT / "platforms" / "_guides.yml").read_text(encoding="utf-8")
|
||||
UNTITLED_FENCE = re.compile(r"^\s*```(?:sh|powershell)\s*$", re.M)
|
||||
# A path a reader types: a network share, a home or root path, a drive letter.
|
||||
ABSOLUTE_PATH = re.compile(
|
||||
r"(?<![\w/@:}>])(?:\\\\[^\s,)]+|smb://[^\s,)]+|~/[^\s,)]*|/[A-Za-z][^\s,)]*|[A-Z]:\\[^\s,)]*)"
|
||||
)
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
sitepages.require()
|
||||
return sitepages.page(name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _body(text: str) -> str:
|
||||
"""A page without its front matter."""
|
||||
return text.split("\n---\n", 1)[1] if text.startswith("---\n") else text
|
||||
|
||||
|
||||
def _guide_part(text: str) -> str:
|
||||
match = re.search(r"^## Reference\b", text, re.M)
|
||||
return text[: match.start()] if match else text
|
||||
|
||||
|
||||
class TrustItemsSayWhereItWrites(unittest.TestCase):
|
||||
def test_no_only_in_the_bios_folder_while_a_manifest_writes_saves(self):
|
||||
saves = [
|
||||
path.name for path in sorted((REPO_ROOT / "install").glob("*.json"))
|
||||
if any(entry.get("root") == "save"
|
||||
for entry in json.loads(path.read_text(encoding="utf-8")).get("files", []))
|
||||
]
|
||||
self.assertTrue(saves, "no manifest files an entry under root: save")
|
||||
text = " ".join(siteguides.TRUST_ITEMS).lower()
|
||||
self.assertNotIn("only in your bios folder", text)
|
||||
self.assertIn("save folder", text)
|
||||
self.assertIn("--restore", text)
|
||||
|
||||
def test_home_shows_the_same_items(self):
|
||||
home = _page("index.md")
|
||||
for item in siteguides.TRUST_ITEMS:
|
||||
self.assertIn(f"- {item}", home)
|
||||
self.assertNotIn("only in your BIOS folder", home)
|
||||
|
||||
|
||||
class NextStepsIsAPlainList(unittest.TestCase):
|
||||
def test_the_helpers(self):
|
||||
block = "\n".join(generate_site._next_block(["[A](a.md)", "[B](b.md)"]))
|
||||
self.assertEqual(block, "## Next steps\n\n- [A](a.md)\n- [B](b.md)\n")
|
||||
self.assertIn("## Next steps", generate_site.reference_next())
|
||||
self.assertNotIn(":::", generate_site.reference_next())
|
||||
|
||||
def test_entry_pages(self):
|
||||
for name in ("which-pack.md", "contributing.md", "platforms/batocera.md",
|
||||
"platforms/retroarch.md"):
|
||||
with self.subTest(page=name):
|
||||
text = _page(name)
|
||||
self.assertNotIn(":::tip[Next]", text)
|
||||
self.assertIn("## Next steps", text)
|
||||
|
||||
def test_home_has_no_next_box_and_no_stamp(self):
|
||||
home = _page("index.md")
|
||||
self.assertNotIn("Next steps", home)
|
||||
self.assertNotIn(":::tip", home)
|
||||
self.assertNotIn("Generated on", home)
|
||||
self.assertNotRegex(home, r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
|
||||
|
||||
|
||||
class DatesInWords(unittest.TestCase):
|
||||
def test_data_as_of(self):
|
||||
moment = datetime(2026, 10, 22, 22, 43, 8, tzinfo=timezone.utc)
|
||||
self.assertEqual(
|
||||
generate_site.data_as_of(moment),
|
||||
'<time datetime="2026-10-22T22:43:08Z">Data as of 22 October 2026</time>',
|
||||
)
|
||||
|
||||
def test_get_your_files_carries_no_date(self):
|
||||
self.assertNotIn("Generated on", _page("which-pack.md"))
|
||||
|
||||
|
||||
class OneSetupCount(unittest.TestCase):
|
||||
def test_every_entry_page_states_the_registry_count(self):
|
||||
count = len(REGISTRY)
|
||||
self.assertEqual(siteguides.setup_count(), count)
|
||||
self.assertIn(f"and {count - 3} more.", _page("index.md"))
|
||||
self.assertIn(f'<span class="rb-stat-value">{count}</span>', _page("explore.md"))
|
||||
|
||||
|
||||
class SetupTiles(unittest.TestCase):
|
||||
def test_one_tile_per_setup_on_home_and_get_your_files(self):
|
||||
for name in ("index.md", "which-pack.md"):
|
||||
text = _page(name)
|
||||
with self.subTest(page=name):
|
||||
extra = sum(len(tiles) for tiles in siteguides.GROUP_TILES.values())
|
||||
self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY) + extra)
|
||||
for key in REGISTRY:
|
||||
self.assertIn(f'href="/retrobios/platforms/{key}/"', text)
|
||||
|
||||
def test_get_your_files_offers_the_choice_once(self):
|
||||
text = _page("which-pack.md")
|
||||
self.assertEqual(text.count('class="rb-card"'), 0)
|
||||
self.assertEqual(text.count("<FilePicker"), 1)
|
||||
|
||||
def test_a_setup_without_a_logo_shows_its_initials(self):
|
||||
self.assertEqual(sitemarkup.initials("MyMinUI"), "MM")
|
||||
tile = "".join(sitemarkup.platform_tiles([("MyMinUI", "x.md", "", "Handhelds")]))
|
||||
self.assertIn(">MM</text>", tile)
|
||||
tile = "".join(sitemarkup.platform_tiles([("RomM", "x.md", "logo.png", "A server")]))
|
||||
self.assertIn('<img class="rb-card-logo" src="logo.png"', tile)
|
||||
|
||||
def test_a_one_setup_group_is_not_titled_with_its_own_name(self):
|
||||
home = _page("index.md")
|
||||
display = {k: load_platform_config(k, str(REPO_ROOT / "platforms")).get("platform", k)
|
||||
for k in REGISTRY}
|
||||
for group, (label, _description) in siteguides.DEVICE_GROUPS.items():
|
||||
members = [k for k in REGISTRY if REGISTRY[k].get("device_group") == group]
|
||||
if len(members) == 1:
|
||||
with self.subTest(group=group):
|
||||
self.assertNotIn(f"**{label}**", home)
|
||||
self.assertIn(f">{display[members[0]]}</strong>", home)
|
||||
|
||||
def test_hardware_is_named_and_cited(self):
|
||||
self.assertIn("Raspberry Pi", siteguides.GUIDES["batocera"].summary)
|
||||
self.assertIn("Raspberry Pi", siteguides.DEVICE_GROUPS["gaming-os"][0])
|
||||
self.assertIn("Anbernic", siteguides.DEVICE_GROUPS["handheld"][0])
|
||||
lines = GUIDES_YAML.splitlines()
|
||||
for number, line in enumerate(lines):
|
||||
if re.match(r" (runs_on|terminal):", line):
|
||||
with self.subTest(line=line.strip()):
|
||||
self.assertTrue(lines[number - 1].lstrip().startswith("#"),
|
||||
"no source cited on the line above")
|
||||
|
||||
|
||||
class GetYourFilesOrder(unittest.TestCase):
|
||||
def test_choice_then_tables_then_picker_then_command(self):
|
||||
text = _page("which-pack.md")
|
||||
order = [text.index(mark) for mark in (
|
||||
"## Pick your setup", "| Setup | Runs on | BIOS folder | ZIP |", "<FilePicker",
|
||||
"## Not in the list? One command finds it", "It asks **",
|
||||
"<summary>Is it safe?</summary>",
|
||||
)]
|
||||
self.assertEqual(order, sorted(order))
|
||||
|
||||
def test_a_setup_after_the_release_says_no_zip_yet(self):
|
||||
text = _page("which-pack.md")
|
||||
self.assertNotIn("Not in v", text)
|
||||
record = release_record.load_advertised()
|
||||
pending = [k for k in REGISTRY if REGISTRY[k].get("release_pack")
|
||||
and not release_record.pack_for(k, record)]
|
||||
if record.get("tag") and pending:
|
||||
self.assertIn("No ZIP yet. [Use the command]", text)
|
||||
self.assertIn("Setups added after the last release have no ZIP until the next one.",
|
||||
text)
|
||||
else:
|
||||
self.assertNotIn("No ZIP yet.", text)
|
||||
if any(not entry.get("release_pack") for entry in REGISTRY.values()):
|
||||
self.assertIn("No ZIP. [Use the command]", text)
|
||||
|
||||
def test_zip_advice_answers_the_usual_mistakes(self):
|
||||
text = _page("which-pack.md")
|
||||
self.assertIn("Download every part", text)
|
||||
self.assertIn("not in a new folder", text)
|
||||
self.assertIn("`neogeo.zip`", text)
|
||||
|
||||
|
||||
class GuideRoutes(unittest.TestCase):
|
||||
def test_a_device_system_opens_on_the_computer_route(self):
|
||||
text = _page("platforms/batocera.md")
|
||||
self.assertLess(text.index("## From your computer"), text.index("## On the device"))
|
||||
self.assertIn("{ #install }", "\n".join(generate_site._guide_install(
|
||||
"batocera", siteguides.GUIDES["batocera"], "## On the device { #install }")))
|
||||
self.assertIn("Unzip them all into the network folder above", text)
|
||||
|
||||
def test_a_computer_app_keeps_the_command_first(self):
|
||||
text = _page("platforms/retroarch.md")
|
||||
self.assertLess(text.index("## Install"), text.index("## Download"))
|
||||
|
||||
def test_each_device_tab_says_how_to_open_its_terminal(self):
|
||||
shared = siteguides.TERMINALS["device"][1]
|
||||
for name, guide in siteguides.GUIDES.items():
|
||||
if "device" not in guide.run:
|
||||
continue
|
||||
with self.subTest(platform=name):
|
||||
self.assertTrue(guide.terminal, "no terminal text of its own")
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
self.assertIn(guide.terminal.split(".")[0], text)
|
||||
self.assertNotIn(shared, text)
|
||||
|
||||
def test_retroarch_names_the_menu_before_the_folders(self):
|
||||
text = _page("platforms/retroarch.md")
|
||||
self.assertLess(text.index("Settings > Directory > System/BIOS"),
|
||||
text.index("| Where | BIOS folder |"))
|
||||
|
||||
def test_emudeck_names_the_card_and_ends_the_job(self):
|
||||
text = _guide_part(_page("platforms/emudeck.md"))
|
||||
self.assertIn("installed on the SD card", text)
|
||||
self.assertIn("Return to Gaming Mode", text)
|
||||
self.assertNotIn("it is on the card", text)
|
||||
|
||||
|
||||
class PackManualReadsThePageFolders(unittest.TestCase):
|
||||
def test_every_path_in_a_pack_manual_is_a_folder_of_its_page(self):
|
||||
for name, guide in siteguides.GUIDES.items():
|
||||
if not guide.pack_manual:
|
||||
continue
|
||||
known = " ".join(path.replace("`", "") for _w, path in siteguides.folder_list(name))
|
||||
known += " " + str(REGISTRY[name].get("extract_to") or "")
|
||||
for path in ABSOLUTE_PATH.findall(siteguides.pack_manual(name)):
|
||||
with self.subTest(platform=name, path=path):
|
||||
self.assertIn(path.rstrip(".:"), known)
|
||||
|
||||
def test_the_retroarch_manual_lists_the_detected_systems(self):
|
||||
manual = siteguides.pack_manual("retroarch")
|
||||
self.assertNotIn("{folders}", manual)
|
||||
self.assertIn("/run/muos/storage/bios", manual)
|
||||
self.assertIn("~/.var/app/org.libretro.RetroArch/config/retroarch/system/", manual)
|
||||
|
||||
|
||||
class DownloadNamesTheRelease(unittest.TestCase):
|
||||
def test_tag_date_checksums_and_signature(self):
|
||||
record = release_record.load_record()
|
||||
if not release_record.pack_for("batocera", record):
|
||||
self.skipTest("release.json has no Batocera pack")
|
||||
tag = record["tag"]
|
||||
lines = generate_site.release_line("batocera", record)
|
||||
self.assertTrue(lines[0].startswith(f"Pack {tag}, built "))
|
||||
self.assertIn(f"/releases/download/{tag}/SHA256SUMS.txt)", lines[0])
|
||||
self.assertIn(f"/releases/download/{tag}/SHA256SUMS.txt.sig)", lines[0])
|
||||
self.assertEqual(generate_site._release_date("v2026.10.10"), "10 October 2026")
|
||||
|
||||
def test_a_newer_platform_than_the_pack_is_named(self):
|
||||
record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
|
||||
"assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
|
||||
version = load_platform_config("retrobat", str(REPO_ROOT / "platforms"))["version"]
|
||||
lines = generate_site.release_line("retrobat", record)
|
||||
self.assertIn(f"built for RetroBat 1.0. The command installs the files for "
|
||||
f"RetroBat {version}.", lines[-1])
|
||||
|
||||
|
||||
class CommandBlocks(unittest.TestCase):
|
||||
def test_every_command_on_an_entry_page_has_a_title(self):
|
||||
names = ["index.md", "which-pack.md", "contributing.md"] + [
|
||||
f"platforms/{key}.md" for key in REGISTRY]
|
||||
for name in names:
|
||||
with self.subTest(page=name):
|
||||
self.assertIsNone(UNTITLED_FENCE.search(_guide_part(_page(name))))
|
||||
|
||||
def test_shell_lines_have_a_powershell_form(self):
|
||||
text = _page("which-pack.md")
|
||||
groups = re.findall(r"<Tabs.*?</Tabs>|<details.*?</details>", text, re.S)
|
||||
commands = [group for group in groups if "/i | sh" in group]
|
||||
self.assertGreaterEqual(len(commands), 3)
|
||||
for group in commands:
|
||||
self.assertIn("/i.ps1", group)
|
||||
block = "\n".join(generate_site._console_install_block(
|
||||
"PlayStation", [("batocera", "psx"), ("recalbox", "psx")]))
|
||||
# Every setup's options in both forms: the list of every command,
|
||||
# and the one command under the OS tabs.
|
||||
sh_args = re.findall(r"/i \| sh -s -- ([^<\n]+)", block)
|
||||
ps_args = re.findall(r"/i\.ps1\)\)\) ([^<\n]+)", block)
|
||||
self.assertEqual(sorted(set(sh_args)), sorted(set(ps_args)))
|
||||
self.assertEqual(len(set(sh_args)), 2)
|
||||
self.assertIn('```powershell title="PowerShell"', block)
|
||||
for key, guide in siteguides.GUIDES.items():
|
||||
if "windows" in guide.run:
|
||||
with self.subTest(platform=key):
|
||||
self.assertIn("/i.ps1", _guide_part(_page(f"platforms/{key}.md")))
|
||||
|
||||
|
||||
class ContributingOpensOnTheAction(unittest.TestCase):
|
||||
def test_both_generators_open_on_the_upload(self):
|
||||
for text in (generate_readme.generate_contributing(), generate_site.generate_contributing()):
|
||||
body = text.split("## Add a BIOS file", 1)[1]
|
||||
self.assertTrue(body.lstrip().startswith("Have a file the collection lacks?"))
|
||||
self.assertLess(body.index(generate_readme.UPLOAD_URL), body.index("## What happens next"))
|
||||
self.assertLess(body.index("gaps"), body.index("## What happens next"))
|
||||
self.assertLess(body.index("## What happens next"), body.index("CI hashes"))
|
||||
|
||||
def test_description_is_a_sentence(self):
|
||||
text = generate_site._page_description("", Path("contributing.md"), "Contributing")
|
||||
self.assertTrue(text.endswith("."))
|
||||
fallback = generate_site._page_description(
|
||||
"One file needs no clone and no local setup at all here:\n\n"
|
||||
"A plain sentence that describes the page well enough.",
|
||||
Path("other.md"), "Other")
|
||||
self.assertEqual(fallback, "A plain sentence that describes the page well enough.")
|
||||
|
||||
|
||||
class HomeLinksTheOtherTasks(unittest.TestCase):
|
||||
def test_troubleshooting_file_check_and_legal(self):
|
||||
home = _body(_page("index.md"))
|
||||
for target in ("/retrobios/wiki/troubleshooting/", "/retrobios/files/",
|
||||
"/retrobios/wiki/legal/"):
|
||||
self.assertIn(f"]({target})", home)
|
||||
self.assertLess(home.index("/retrobios/wiki/troubleshooting/"), home.index("## Do I need it?"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,394 +0,0 @@
|
||||
"""Reference pages (round 2, package 8).
|
||||
|
||||
A reference page cites a hash, a source line or a count, and each citation
|
||||
has to hold when an expert checks it: the reader of a pinned dump is the
|
||||
profile entry accepting its hash, a source line is a pinned link, an entry
|
||||
names the file that serves it, and the exports carry what the page shows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import siterender # noqa: E402
|
||||
from common import ( # noqa: E402
|
||||
list_registered_platforms,
|
||||
load_emulator_profiles,
|
||||
load_platform_config,
|
||||
)
|
||||
from exporter.baseline import HASH_FIELDS, _hash_values # noqa: E402
|
||||
from exporter.candidates import EntryCategories # noqa: E402
|
||||
from sitesources import launcher_ref_markdown, list_source # noqa: E402
|
||||
|
||||
PLATFORMS = str(REPO_ROOT / "platforms")
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _accepted(profile_entry: dict) -> set[str]:
|
||||
values = {v for f in HASH_FIELDS for v in _hash_values(profile_entry, f)}
|
||||
for member in profile_entry.get("contents") or []:
|
||||
if isinstance(member, dict):
|
||||
values |= {v for f in HASH_FIELDS for v in _hash_values(member, f)}
|
||||
return values
|
||||
|
||||
|
||||
class ReaderMatchedByHash(unittest.TestCase):
|
||||
"""Batocera's scph1001.bin (md5 dc2b9bf8, SCPH-1001 v2.0) cited
|
||||
DuckStation's bios.cpp:36, the line of the v2.2 dump: the reader was
|
||||
matched by name."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
|
||||
|
||||
def test_batocera_scph1001_cites_the_v20_line(self):
|
||||
config = load_platform_config("batocera", PLATFORMS)
|
||||
cats = EntryCategories(config, self.profiles)
|
||||
entry = next(e for e in config["systems"]["sony-playstation"]["files"]
|
||||
if e["name"] == "scph1001.bin")
|
||||
found = cats.explain(entry, entry.get("destination") or entry["name"],
|
||||
"sony-playstation")
|
||||
duck = next(r for r in found["read_by"] if r["emulator"] == "duckstation")
|
||||
self.assertEqual(duck["entries"], [
|
||||
{"name": "scph1001_v20.bin", "source_ref": "src/core/bios.cpp:42"}])
|
||||
|
||||
def test_every_cited_entry_accepts_the_md5_or_says_by_name(self):
|
||||
by_name = 0
|
||||
for platform in list_registered_platforms(PLATFORMS, include_archived=True):
|
||||
config = load_platform_config(platform, PLATFORMS)
|
||||
cats = EntryCategories(config, self.profiles)
|
||||
for sys_id, system in (config.get("systems") or {}).items():
|
||||
for entry in system.get("files") or []:
|
||||
md5s = set(_hash_values(entry, "md5")) | set(_hash_values(entry, "alt_md5"))
|
||||
if not md5s:
|
||||
continue
|
||||
# The same dump, named by whichever hash both sides state.
|
||||
declared = md5s | {v for f in HASH_FIELDS for v in _hash_values(entry, f)}
|
||||
found = cats.explain(entry, entry.get("destination") or entry["name"], sys_id)
|
||||
for reader in found.get("read_by", []):
|
||||
if reader["emulator"] == "launcher":
|
||||
continue
|
||||
files = self.profiles[reader["emulator"]].get("files") or []
|
||||
for cited in reader["entries"]:
|
||||
if cited.get("by") == "name":
|
||||
by_name += 1
|
||||
continue
|
||||
accepted = set().union(*(
|
||||
_accepted(f) for f in files
|
||||
if isinstance(f, dict) and f.get("name") == cited["name"]))
|
||||
with self.subTest(platform=platform, entry=entry["name"],
|
||||
emulator=reader["emulator"]):
|
||||
self.assertTrue(any(
|
||||
a == m or (29 <= len(m) < 32 and a.startswith(m))
|
||||
for m in declared for a in accepted))
|
||||
self.assertGreater(by_name, 0)
|
||||
|
||||
def test_a_contradicting_homonym_is_not_cited(self):
|
||||
profiles = {"core": {"type": "libretro", "systems": ["s"], "files": [
|
||||
{"name": "bios.bin", "md5": "1" * 32, "source_ref": "a.c:1"},
|
||||
{"name": "bios_v2.bin", "md5": "2" * 32, "source_ref": "a.c:2"},
|
||||
]}}
|
||||
config = {"cores": ["core"], "systems": {"s": {"files": []}}}
|
||||
cats = EntryCategories(config, profiles)
|
||||
found = cats.explain({"name": "bios.bin", "md5": "2" * 32}, "bios.bin", "s")
|
||||
self.assertEqual(found["read_by"][0]["entries"],
|
||||
[{"name": "bios_v2.bin", "source_ref": "a.c:2"}])
|
||||
found = cats.explain({"name": "bios.bin", "md5": "3" * 32}, "bios.bin", "s")
|
||||
self.assertEqual(found["read_by"][0]["entries"], [])
|
||||
|
||||
|
||||
class EvidenceLinksItsSource(unittest.TestCase):
|
||||
"""Platform pages printed refs as code with the role word in front:
|
||||
`source frontend/libretro.c:3864-3867`."""
|
||||
|
||||
def test_pinned_link_and_by_name(self):
|
||||
evidence = {"read_by": [{
|
||||
"emulator": "pcsx_rearmed", "proof": "name", "runs_system": True,
|
||||
"entries": [{"name": "scph1001.bin", "by": "name",
|
||||
"source_ref": "source frontend/libretro.c:3864-3867 (listed_bios[6])"}],
|
||||
}]}
|
||||
text = generate_site._category_evidence_md(evidence)
|
||||
self.assertRegex(text, r"\[`frontend/libretro\.c:3864-3867`\]\(https://github\.com/"
|
||||
r"libretro/pcsx_rearmed/blob/[0-9a-f]{40}/frontend/libretro\.c"
|
||||
r"#L3864-L3867\)")
|
||||
self.assertIn("(by name)", text)
|
||||
self.assertNotIn("`source ", text)
|
||||
|
||||
def test_launcher_record_named_with_its_lines(self):
|
||||
evidence = {"read_by": [{"emulator": "launcher", "proof": "launcher",
|
||||
"entries": [], "runs_system": True}]}
|
||||
config = load_platform_config("batocera", PLATFORMS)
|
||||
launched = generate_site._launcher_refs(config)["scph1001.bin"]
|
||||
text = generate_site._category_evidence_md(evidence, launched)
|
||||
self.assertNotIn("(by launcher)", text)
|
||||
self.assertIn("https://github.com/batocera-linux/batocera.linux/blob/batocera-43.1/"
|
||||
"package/batocera/core/batocera-configgen/", text)
|
||||
self.assertIn("duckstationGenerator.py:115-136", text)
|
||||
|
||||
def test_every_platform_links_its_launchers(self):
|
||||
"""Only Batocera and RetroBat were linked: Recalbox, ROCKNIX, EmuDeck,
|
||||
RetroPie and RetroDECK read their launchers in the list's repository
|
||||
too, and their citations came out as plain code."""
|
||||
checked = 0
|
||||
for path in sorted(Path(PLATFORMS).glob("*.yml")):
|
||||
if not path.stem[0].isalpha():
|
||||
continue
|
||||
config = load_platform_config(path.stem, PLATFORMS)
|
||||
if not config.get("launchers"):
|
||||
continue
|
||||
with self.subTest(platform=path.stem):
|
||||
rendered = [text for texts in generate_site._launcher_refs(config).values()
|
||||
for text in texts]
|
||||
self.assertTrue(rendered)
|
||||
self.assertFalse([t for t in rendered if " at `" in t])
|
||||
checked += 1
|
||||
self.assertGreaterEqual(checked, 7)
|
||||
|
||||
def test_a_launcher_in_another_repository_is_not_linked(self):
|
||||
source = list_source("https://gitlab.com/recalbox/recalbox/-/raw/10.1.1/x/es_bios.xml")
|
||||
self.assertEqual(launcher_ref_markdown(None, "a/b.py:3"), "`b.py:3`")
|
||||
self.assertIn("gitlab.com/recalbox/recalbox/-/blob/10.1.1/a/b.py#L3",
|
||||
launcher_ref_markdown(source, "a/b.py:3"))
|
||||
|
||||
|
||||
class EachEntryNamesItsFile(unittest.TestCase):
|
||||
def test_served_held_and_missing(self):
|
||||
path, digest = next(
|
||||
(p, d) for p, d in generate_site._collection_sha1_by_path().items()
|
||||
if p.startswith("bios/") and p.count("/") >= 3)
|
||||
served = generate_site._served_line({"status": "ok", "path": path})
|
||||
self.assertRegex(served, rf"\]\(\.\./systems/[^)]+\.md#sha1-{digest[:12]}\)")
|
||||
self.assertTrue(served.startswith("Served by: "))
|
||||
self.assertIn(f"`{digest[:8]}`", served)
|
||||
# The row already shows the declared sha1: repeating it said nothing.
|
||||
quiet = generate_site._served_line({"status": "ok", "path": path}, f"aaaa, {digest.upper()}")
|
||||
self.assertNotIn(digest[:8], quiet.split(")", 1)[1])
|
||||
self.assertIn(f"`{digest[:8]}`", generate_site._served_line(
|
||||
{"status": "ok", "path": path}, "a" * 40))
|
||||
self.assertEqual(generate_site._served_line({"status": "missing"}), "Not held")
|
||||
self.assertTrue(generate_site._served_line(
|
||||
{"status": "untested", "path": path}).startswith("Held, not the declared hash"))
|
||||
# Existence mode reports no path: nothing is claimed, held or not.
|
||||
self.assertEqual(generate_site._served_line({"status": "ok"}), "")
|
||||
|
||||
def test_a_long_system_list_is_cut_between_rows_by_elements(self):
|
||||
rows = [[f'<details class="rb-file"><summary>f{n}.bin</summary>x<br>y</details>']
|
||||
for n in range(4)]
|
||||
# Three elements a row: two rows fit in a budget of seven.
|
||||
runs = generate_site._blocks_in_runs(rows, 7, generate_site._element_weight)
|
||||
self.assertEqual([len(run) for run in runs], [2, 2])
|
||||
self.assertEqual(sum(runs, []), sum(rows, []))
|
||||
|
||||
|
||||
class AlsoNamedIsNotCited(unittest.TestCase):
|
||||
def test_other_system_cores_are_named_only(self):
|
||||
evidence = {"read_by": [
|
||||
{"emulator": "duckstation", "proof": "name", "runs_system": True,
|
||||
"entries": [{"name": "scph1001_v20.bin", "source_ref": "src/core/bios.cpp:42"}]},
|
||||
{"emulator": "mame", "proof": "name", "runs_system": False,
|
||||
"entries": [{"name": "x.zip", "source_ref": "src/mame/x.cpp:1", "by": "name"}]},
|
||||
]}
|
||||
text = generate_site._category_evidence_md(evidence)
|
||||
also = text.split(", also named by ", 1)[1]
|
||||
self.assertNotIn("x.cpp", also)
|
||||
self.assertIn("mame", also)
|
||||
|
||||
|
||||
class PlatformReferenceInWords(unittest.TestCase):
|
||||
def _page(self, mode: str, hash_type: str, systems: dict | None = None,
|
||||
registry: dict | None = None) -> str:
|
||||
details = [{"name": "f.bin", "status": "ok", "system": s} for s in (systems or {})]
|
||||
cov = {"platform": "P", "details": details, "total": 1, "verified": 1,
|
||||
"present": 1, "missing": 0, "untested": 0, "mode": mode,
|
||||
"config": {"hash_type": hash_type, "systems": systems or {},
|
||||
"source": "https://github.com/o/r/tree/" + "a" * 40}}
|
||||
return generate_site.generate_platform_page("p", cov, registry or {})
|
||||
|
||||
def test_hash_type_only_when_it_differs(self):
|
||||
self.assertNotIn("Hash type", self._page("md5", "md5"))
|
||||
self.assertNotIn("Hashes in the list", self._page("md5", "md5"))
|
||||
page = self._page("existence", "sha1")
|
||||
self.assertIn("| Hashes in the list | SHA-1 |", page)
|
||||
self.assertIn(">name only</span>", page)
|
||||
self.assertIn(">MD5</span>", self._page("md5", "md5"))
|
||||
self.assertIn("[r repository at commit aaaaaaaaaaaa](https://github.com/o/r/tree/"
|
||||
+ "a" * 40 + ")", page)
|
||||
|
||||
def test_every_check_label_reads_the_hash_rule(self):
|
||||
"""RomM's badge and table rows said MD5 beside a sentence saying it
|
||||
accepts the size and any one hash."""
|
||||
details = [{"name": "f.bin", "status": "ok", "system": "sony-playstation"}]
|
||||
cov = {"platform": "RomM", "details": details, "total": 1, "verified": 1,
|
||||
"present": 1, "missing": 0, "untested": 0, "mode": "md5",
|
||||
"core_present": 0, "core_missing": 0, "core_unsourceable": 0,
|
||||
"config": {"hash_type": "md5", "hash_rule": "size_and_any_hash",
|
||||
"systems": {"sony-playstation": {"files": [{"name": "f.bin"}]}},
|
||||
"source": "https://github.com/o/r/tree/" + "a" * 40}}
|
||||
page = generate_site.generate_platform_page("romm", cov, {})
|
||||
self.assertIn(">size, then any hash</span>", page)
|
||||
self.assertNotIn(">MD5</span>", page)
|
||||
table = "\n".join(generate_site.setup_table({"romm": cov}, {}))
|
||||
self.assertIn("| size, then any hash |", table)
|
||||
|
||||
def test_systems_by_name_with_the_id_in_small_text(self):
|
||||
systems = {"sony-playstation": {"files": [{"name": "f.bin"}]}}
|
||||
page = self._page("md5", "md5", systems)
|
||||
name = siterender.system_name("sony-playstation")
|
||||
self.assertIn(f"| [{name}](#sony-playstation) <small data-pagefind-ignore>sony-playstation</small> |", page)
|
||||
heading = next(line for line in page.splitlines() if line.startswith("### ")
|
||||
and "sony-playstation" in line)
|
||||
self.assertIn(name, heading)
|
||||
self.assertTrue(heading.endswith("<small>sony-playstation</small> { #sony-playstation }"))
|
||||
|
||||
def test_logo_in_front_matter_not_floated(self):
|
||||
page = self._page("md5", "md5", registry={"p": {"logo": "https://x.test/l.png"}})
|
||||
self.assertTrue(page.startswith('---\nlogo: "https://x.test/l.png"\n---\n'))
|
||||
self.assertNotIn('align="right"', page)
|
||||
|
||||
|
||||
class GapsPageNumbers(unittest.TestCase):
|
||||
def test_badge_only_for_missing_required_firmware(self):
|
||||
self.assertNotIn("rb-badge", generate_site._ratio_cell(99, 100))
|
||||
self.assertIn('class="rb-bar"', generate_site._ratio_cell(99, 100))
|
||||
self.assertIn("rb-badge-danger", generate_site._ratio_cell(99, 100, 1))
|
||||
cov = {"details": [
|
||||
{"name": "a", "system": "s", "status": "missing", "required": True, "category": "bios"},
|
||||
{"name": "b", "system": "s", "status": "missing", "required": True,
|
||||
"category": "game_data"},
|
||||
{"name": "c", "system": "s", "status": "missing", "required": False},
|
||||
]}
|
||||
self.assertEqual(generate_site._missing_required_firmware(cov), 1)
|
||||
|
||||
def test_headings_and_header(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
body = source[source.index("def generate_gap_analysis("):]
|
||||
body = body[:body.index("\ndef ", 10)]
|
||||
self.assertIn('"# What the packs do not hold"', body)
|
||||
self.assertIn("On its BIOS list (destinations)", body)
|
||||
self.assertIn('"### Files to source"', body)
|
||||
self.assertNotIn("### Files to source (", body)
|
||||
|
||||
|
||||
class UpstreamsGrouped(unittest.TestCase):
|
||||
def test_mame_trees_are_one_project(self):
|
||||
keys = {generate_site.upstream_project(u)[0] for u in (
|
||||
"https://github.com/mamedev/mame",
|
||||
"https://github.com/mamedev/mame/tree/mame0289",
|
||||
"https://github.com/mamedev/mame/tree/mame0135u4",
|
||||
)}
|
||||
self.assertEqual(keys, {"github.com/mamedev/mame"})
|
||||
self.assertEqual(generate_site.upstream_project(
|
||||
"https://github.com/mamedev/mame/tree/mame0289")[1], "mame0289")
|
||||
|
||||
def test_nested_groups_notes_and_archives(self):
|
||||
project = generate_site.upstream_project
|
||||
self.assertEqual(
|
||||
project("https://gitlab.com/recalbox/packages/recalbox-cores/libretro-exel-hence"),
|
||||
("gitlab.com/recalbox/packages/recalbox-cores/libretro-exel-hence", ""))
|
||||
self.assertEqual(project("https://gitlab.com/jgemu/geolith/-/tree/v1"),
|
||||
("gitlab.com/jgemu/geolith", "v1"))
|
||||
self.assertEqual(
|
||||
project("https://git.9front.org/plan9front/plan9front (sys/src/games/nes/)"),
|
||||
("git.9front.org/plan9front/plan9front", "sys/src/games/nes/"))
|
||||
key, _ = project("https://archive.softwareheritage.org/browse/revision/"
|
||||
"dc20a615275f66bee20a4fd851ef0231daca4f14/")
|
||||
self.assertEqual(generate_site._upstream_label(key),
|
||||
"Software Heritage revision dc20a615275f")
|
||||
|
||||
def test_overview_and_a_page_per_platform(self):
|
||||
profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
|
||||
coverages = {}
|
||||
for name in ("batocera", "retroarch"):
|
||||
config = load_platform_config(name, PLATFORMS)
|
||||
coverages[name] = {"platform": config.get("platform", name), "config": config}
|
||||
moved: dict[str, str] = {}
|
||||
page = generate_site.generate_cross_reference(coverages, profiles, moved)
|
||||
self.assertEqual(set(moved), {"cross-reference-batocera", "cross-reference-retroarch"})
|
||||
self.assertNotIn("<details", page)
|
||||
upstream = page[page.index("## By upstream emulator"):]
|
||||
self.assertEqual(len(re.findall(r"\| \[mamedev/<wbr>mame\]\(", upstream)), 1)
|
||||
header = next(line for line in upstream.splitlines() if line.startswith("| Cores"))
|
||||
self.assertTrue(header.startswith("| Cores | Upstream |"))
|
||||
|
||||
|
||||
class ExportsComplete(unittest.TestCase):
|
||||
def test_gap_rows_once_with_their_outcome(self):
|
||||
path = next(iter(generate_site._collection_sha1_by_path()))
|
||||
detail = {"name": "x.bin", "system": "s", "status": "untested", "path": path,
|
||||
"category": "bios"}
|
||||
coverages = {"p": {"platform": "P", "details": [
|
||||
{"name": "h.xml", "system": "s", "status": "missing"},
|
||||
{"name": "h.xml", "system": "s", "status": "missing"}, detail]}}
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
rows = generate_site._gap_export_rows(coverages, {}, db)
|
||||
self.assertEqual([r["name"] for r in rows], ["h.xml", "x.bin"])
|
||||
held = rows[1]
|
||||
self.assertEqual(held["collection_path"], path)
|
||||
self.assertEqual(held["resolved_sha1"], generate_site._collection_sha1_by_path()[path])
|
||||
self.assertEqual(held["category"], "bios")
|
||||
|
||||
def test_platform_rows_carry_status_and_file(self):
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
sha1, entry = next(iter(db["files"].items()))
|
||||
coverages = {"p": {"platform": "P", "config": {"systems": {"s": {"files": [
|
||||
{"name": "a.bin"}, {"name": "b.bin"}]}}}, "details": [
|
||||
{"name": "a.bin", "system": "s", "status": "ok", "path": entry["path"],
|
||||
"category": "bios"},
|
||||
{"name": "b.bin", "system": "s", "status": "missing", "category": "game_data"}]}}
|
||||
_items, rows = generate_site._platform_export_rows(coverages, db)
|
||||
self.assertEqual((rows[0]["status"], rows[0]["resolved_sha1"]), ("ok", sha1))
|
||||
self.assertEqual(rows[0]["collection_path"], entry["path"])
|
||||
self.assertEqual((rows[1]["status"], rows[1]["category"], rows[1]["collection_path"]),
|
||||
("missing", "game_data", ""))
|
||||
|
||||
def test_commit_and_wanted_list(self):
|
||||
commit = generate_site._source_commit()
|
||||
self.assertRegex(commit, r"^[0-9a-f]{40}$")
|
||||
schema = json.loads((REPO_ROOT / "schemas" / "site-api-envelope.schema.json")
|
||||
.read_text(encoding="utf-8"))
|
||||
self.assertIn("commit", schema["properties"])
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
rows = generate_site._provenance_wanted_rows(db)
|
||||
self.assertTrue(rows)
|
||||
self.assertTrue(all(r["catalog"] and r["name"] for r in rows))
|
||||
text = generate_site._csv_document(list(generate_site.WANTED_FIELDS), rows)
|
||||
self.assertEqual(next(csv.reader(io.StringIO(text))), list(generate_site.WANTED_FIELDS))
|
||||
|
||||
|
||||
class DatesAndNames(unittest.TestCase):
|
||||
def test_snapshot_date_in_words(self):
|
||||
self.assertEqual(generate_site._date_in_words("2026-08-07T10:00:00Z"),
|
||||
'<time datetime="2026-08-07">7 August 2026</time>')
|
||||
|
||||
def test_data_page_title(self):
|
||||
self.assertIn("\n---\n\n# Data and API\n", generate_site.generate_data_page([]))
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "platforms" / "batocera" / "index.html").is_file(),
|
||||
"site not built")
|
||||
class BuiltBatoceraPage(unittest.TestCase):
|
||||
def test_served_by_and_pinned_links(self):
|
||||
main = (DIST / "platforms" / "batocera" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertTrue("batocera-systems at tag batocera-43.1" in main)
|
||||
# PlayStation's list is long enough to sit on a page of its own.
|
||||
pages = [main] + [p.read_text(encoding="utf-8") for p in
|
||||
(DIST / "platforms").glob("batocera-sony-playstation*/index.html")]
|
||||
html = "".join(pages)
|
||||
for text in ("Served by:", "scph1001_v20.bin", "https://github.com/stenzek/duckstation/blob/"):
|
||||
with self.subTest(text=text):
|
||||
self.assertTrue(text in html)
|
||||
self.assertFalse("(by launcher)" in html)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,327 +0,0 @@
|
||||
"""System pages and file cards (round 2, package 6).
|
||||
|
||||
The Sony page opened on LibPS.exe, its card titles read "scph5501.bin
|
||||
permalink" in the site search, the two setup lines of a card were defined
|
||||
only in a code comment, and the systems index named no console.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import siterender # noqa: E402
|
||||
from cross_reference import FileConsumers # noqa: E402
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _file(name: str, char: str, path: str = "") -> dict:
|
||||
return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 524288,
|
||||
"path": path or f"bios/Sony/PlayStation/{name}"}
|
||||
|
||||
|
||||
def _consumers(readers: dict[str, list[dict]], platforms: dict[str, set[str]] | None = None
|
||||
) -> types.SimpleNamespace:
|
||||
"""Consumers where each sha1 is read by the given profile entries, one
|
||||
emulator per entry."""
|
||||
files_of: dict[str, list] = {}
|
||||
entries: dict[str, dict[str, int]] = {}
|
||||
emulators: dict[str, set[str]] = {}
|
||||
for sha1, read_by in readers.items():
|
||||
for number, entry in enumerate(read_by):
|
||||
emulator = f"emu{number}-{sha1[:4]}"
|
||||
files_of[emulator] = [entry]
|
||||
entries.setdefault(sha1, {})[emulator] = 0
|
||||
emulators.setdefault(sha1, set()).add(emulator)
|
||||
return types.SimpleNamespace(
|
||||
platforms=platforms or {}, emulators=emulators, emulator_entries=entries,
|
||||
emulator_files=files_of, installed={}, platform_systems={},
|
||||
platform_destinations={},
|
||||
)
|
||||
|
||||
|
||||
class TheConsoleBiosComesFirst(unittest.TestCase):
|
||||
"""Cards were sorted by byte order: capitals first, so LibPS.exe and
|
||||
PSX_DTLH2000.bin opened the PlayStation section and scph5501.bin was
|
||||
28th."""
|
||||
|
||||
def setUp(self):
|
||||
self.files = [
|
||||
_file("LibPS.exe", "1"), _file("PSX_DTLH2000.bin", "2"),
|
||||
_file("dtlh1100.bin", "3"), _file("scph5501.bin", "4"),
|
||||
_file("scph5500.bin", "5"), _file("scph1001.bin", "6"),
|
||||
_file("openbios.bin", "7"),
|
||||
]
|
||||
self.consumers = _consumers({
|
||||
"4" * 40: [{"name": "scph5501.bin", "required": True, "priority": 2,
|
||||
"region": ["north-america"]}],
|
||||
"5" * 40: [{"name": "scph5500.bin", "required": True, "priority": 5,
|
||||
"region": ["japan"]}],
|
||||
"6" * 40: [{"name": "scph1001.bin", "required": False, "priority": 1}],
|
||||
"2" * 40: [{"name": "PSX_DTLH2000.bin", "required": False}],
|
||||
}, platforms={"6" * 40: {"a", "b"}, "3" * 40: {"a", "b", "c"}})
|
||||
|
||||
def names(self) -> list[str]:
|
||||
cards = gs._console_cards("Sony", self.files, self.consumers, set())
|
||||
return [re.search(r"<code>(.*?)</code>", card[2])[1] for _v, card, _s in cards]
|
||||
|
||||
def test_required_then_priority_then_setups_then_name(self):
|
||||
self.assertEqual(self.names(), [
|
||||
"scph5501.bin", "scph5500.bin", # required, by priority
|
||||
"scph1001.bin", # optional with a priority
|
||||
"dtlh1100.bin", # listed by three setups
|
||||
"LibPS.exe", "openbios.bin", "PSX_DTLH2000.bin", # by name, any case
|
||||
])
|
||||
|
||||
def test_a_console_cut_in_parts_goes_by_name(self):
|
||||
cards = gs._console_cards("Sony", self.files, self.consumers, set(), by_name=True)
|
||||
names = [re.search(r"<code>(.*?)</code>", card[2])[1] for _v, card, _s in cards]
|
||||
self.assertEqual(names, sorted(names, key=str.casefold))
|
||||
|
||||
def required_only(self) -> types.SimpleNamespace:
|
||||
"""The BIOS readers alone: an emulator reading only an optional
|
||||
file runs without the BIOS, and two of four would say so."""
|
||||
return _consumers({
|
||||
"4" * 40: [{"name": "scph5501.bin", "required": True, "priority": 2,
|
||||
"region": ["north-america"]}],
|
||||
"5" * 40: [{"name": "scph5500.bin", "required": True, "priority": 5,
|
||||
"region": ["japan"]}],
|
||||
})
|
||||
|
||||
def test_the_section_opens_on_the_bios_by_region(self):
|
||||
lines = gs._start_with(self.files, self.required_only())
|
||||
self.assertEqual(lines[0], "**Start with** the BIOS for the region of your games:")
|
||||
self.assertIn("- **North America**: [`scph5501.bin`](#sha1-444444444444)", lines)
|
||||
self.assertIn("- **Japan**: [`scph5500.bin`](#sha1-555555555555)", lines)
|
||||
self.assertLess(lines.index("- **North America**: [`scph5501.bin`](#sha1-444444444444)"),
|
||||
lines.index("- **Japan**: [`scph5500.bin`](#sha1-555555555555)"))
|
||||
|
||||
def test_regions_needing_the_same_file_share_a_line(self):
|
||||
consumers = _consumers({"4" * 40: [
|
||||
{"required": True, "region": ["north-america", "latin-america", "brazil"]}]})
|
||||
lines = gs._start_with([_file("scph5501.bin", "4")], consumers)
|
||||
# Brazil says nothing Latin America does not.
|
||||
self.assertIn("- **North America, Latin America**: [`scph5501.bin`](#sha1-444444444444)", lines)
|
||||
|
||||
def test_a_long_list_names_the_first_and_counts_the_rest(self):
|
||||
files = [_file(f"rom{i}.bin", "abcdefgh"[i]) for i in range(8)]
|
||||
# One emulator requires all eight: files each required by another
|
||||
# emulator are other names for one slot (round 5, 6.2).
|
||||
consumers = _consumers({f["sha1"]: [{"required": True}] for f in files})
|
||||
consumers.emulator_files = {"emu": [{"required": True}] * len(files)}
|
||||
consumers.emulator_entries = {f["sha1"]: {"emu": i} for i, f in enumerate(files)}
|
||||
self.assertEqual(gs._start_with(files, consumers)[0],
|
||||
"**Start with**: [`rom0.bin`](#sha1-aaaaaaaaaaaa), [`rom1.bin`](#sha1-bbbbbbbbbbbb), "
|
||||
"[`rom2.bin`](#sha1-cccccccccccc) and 5 more files. "
|
||||
"They come first below.")
|
||||
|
||||
def test_no_required_file_no_line(self):
|
||||
self.assertEqual(gs._start_with([_file("a.bin", "a")], _consumers({})), [])
|
||||
|
||||
def test_the_page_carries_the_line_under_the_console(self):
|
||||
# Sega keeps its consoles as sections. Sony gives each a page.
|
||||
page = gs.generate_system_page("Sega", {"Saturn": self.files}, self.required_only())
|
||||
heading = page.index("## Saturn")
|
||||
self.assertLess(heading, page.index("**Start with**"))
|
||||
self.assertLess(page.index("**Start with**"), page.index('class="rb-sys-file"'))
|
||||
|
||||
|
||||
class ThePermalinkIsAnIcon(unittest.TestCase):
|
||||
"""The word "permalink" was link text inside every card title: the
|
||||
search showed "scph5501.bin permalink" and screen readers read it."""
|
||||
|
||||
def test_no_word_and_out_of_the_search(self):
|
||||
lines = gs._system_file_card(_file("scph5501.bin", "4"), FileConsumers({"files": {}}))
|
||||
title = lines[2]
|
||||
link = re.search(r'<a class="rb-permalink"[^>]*>(.*?)</a>', title)
|
||||
self.assertNotIn("permalink", re.sub(r"<[^>]+>", "", title))
|
||||
self.assertIn('aria-label="Permanent link to scph5501.bin"', link[0])
|
||||
self.assertIn("data-pagefind-ignore", link[0])
|
||||
self.assertIn('<use href="#rb-link-icon"/>', link[1])
|
||||
|
||||
def test_the_page_draws_the_icon_once(self):
|
||||
page = gs.generate_system_page(
|
||||
"Sega", {"Saturn": [_file("a.bin", "a"), _file("b.bin", "b")]},
|
||||
FileConsumers({"files": {}}))
|
||||
self.assertEqual(page.count('<symbol id="rb-link-icon"'), 1)
|
||||
self.assertEqual(page.count('href="#rb-link-icon"'), 2)
|
||||
|
||||
|
||||
class TheCardLinesSayWhatTheyMean(unittest.TestCase):
|
||||
""""Platforms:" and "Also installed by:" listed different setups, as
|
||||
lower-case ids, and only a code comment told them apart."""
|
||||
|
||||
def setUp(self):
|
||||
sha1 = "4" * 40
|
||||
self.consumers = types.SimpleNamespace(
|
||||
platforms={sha1: {"batocera"}}, emulators={},
|
||||
platform_systems={sha1: {"batocera": {"sony-playstation"}}},
|
||||
platform_destinations={sha1: {"batocera": {"scph5501.bin"}}},
|
||||
installed={sha1: {"retrobat": {"bios/scph5501.bin"}}},
|
||||
)
|
||||
self.lines = gs._system_file_card(_file("scph5501.bin", "4"), self.consumers)
|
||||
|
||||
def test_labels_and_display_names(self):
|
||||
text = "\n".join(self.lines)
|
||||
# Grouped by where the setups put the file (round 4, 6.9).
|
||||
self.assertIn("<dt>On the setup's list</dt><dd><code>scph5501.bin</code>: <a href=\"../"
|
||||
"platforms/batocera.md#sony-playstation\">Batocera</a></dd>", text)
|
||||
self.assertIn("<dt>Packed for its emulators by</dt><dd><code>bios/scph5501.bin</code>: "
|
||||
"<a href=\"../platforms/retrobat.md\">RetroBat</a></dd>", text)
|
||||
self.assertNotIn("Platforms:", text)
|
||||
self.assertNotIn("Also installed by", text)
|
||||
|
||||
def test_the_facts_are_out_of_the_search(self):
|
||||
# The setup list made every card's excerpt a run of names and
|
||||
# paths (round 4, V6): the card's title is what the search reads.
|
||||
# The facts shown open are one list, the others one fold, both
|
||||
# out of the search.
|
||||
shown = self.lines[:next(i for i, line in enumerate(self.lines) if line.startswith("<details"))]
|
||||
lists = [line for line in shown if line.startswith("<dl")]
|
||||
self.assertEqual(lists, ['<dl class="rb-facts" data-pagefind-ignore>'])
|
||||
folds = [line for line in self.lines if line.startswith("<details")]
|
||||
self.assertEqual(len(folds), 1)
|
||||
self.assertEqual(folds, ["<details data-pagefind-ignore>"])
|
||||
|
||||
def test_the_page_defines_both_lines(self):
|
||||
page = gs.generate_system_page("Sega", {"Saturn": [_file("scph5501.bin", "4")]},
|
||||
self.consumers)
|
||||
key = page.index("**On the setup's list** names")
|
||||
self.assertLess(key, page.index('class="rb-sys-file"'))
|
||||
self.assertIn("**Packed for its emulators by** names", page)
|
||||
|
||||
def test_a_file_nothing_reads_says_so(self):
|
||||
title = gs._system_file_card(_file("GameIndex.yaml", "9"), FileConsumers({"files": {}}))[2]
|
||||
self.assertIn(">No reader</span>", title)
|
||||
self.assertNotIn("No reader", self.lines[2])
|
||||
|
||||
|
||||
class FileFactsAreAGrid(unittest.TestCase):
|
||||
"""Each card was two bullet lists of code chips."""
|
||||
|
||||
def test_one_value_cell_per_label_and_plain_hashes(self):
|
||||
entry = {**_file("scph5501.bin", "4"), "crc32": "8d8cb7e4",
|
||||
"provenance": {"redump": {"name": "ps-30a.bin"},
|
||||
"tosec": {"name": "SCPH-5501 (1996).bin"}}}
|
||||
lines = gs._system_file_card(entry, FileConsumers({"files": {}}))
|
||||
text = "\n".join(lines)
|
||||
# One value cell after each label, in the open list and in the fold.
|
||||
facts = re.findall(r"<dt>[^<]*</dt><dd[^>]*>.*?</dd>", text)
|
||||
self.assertTrue(facts)
|
||||
self.assertEqual(len(facts), text.count("<dt>"))
|
||||
self.assertEqual(text.count("<dt>"), len(re.findall(r"<dd[ >]", text)))
|
||||
# The hash is the cell's text, styled by the cell: no element around it.
|
||||
self.assertIn('<dt>SHA1</dt><dd class="rb-hash">' + "4" * 40 + "</dd>", text)
|
||||
self.assertNotIn("<code class=\"rb-hash\">", text)
|
||||
self.assertNotIn('<span class="rb-hash">', text)
|
||||
catalogued = next(fact for fact in facts if fact.startswith("<dt>Catalogued"))
|
||||
self.assertIn("<br>", catalogued)
|
||||
self.assertNotRegex(text, r"(?m)^- ")
|
||||
|
||||
def test_collection_path_keeps_its_chip(self):
|
||||
text = "\n".join(gs._system_file_card(_file("a.bin", "a"), FileConsumers({"files": {}})))
|
||||
self.assertIn("<dt>Collection path</dt><dd><code>bios/Sony/PlayStation/a.bin</code>", text)
|
||||
|
||||
|
||||
class ConsoleHeadingsStartWithTheName(unittest.TestCase):
|
||||
"""The icon and a space came before the name: "Section titled
|
||||
“ PlayStation”" on five of seven Sony headings. Round 3 removed the
|
||||
icon altogether."""
|
||||
|
||||
def test_no_icon_even_when_one_exists(self):
|
||||
icon = siterender._icon_name("Sony", "PlayStation")
|
||||
with mock.patch.dict(siterender._icon_available, {icon: True}):
|
||||
self.assertEqual(gs._console_heading("Sony", "PlayStation"),
|
||||
"## PlayStation { #playstation }")
|
||||
|
||||
def test_no_icon_no_image(self):
|
||||
icon = siterender._icon_name("Nobody", "Nothing")
|
||||
with mock.patch.dict(siterender._icon_available, {icon: False}):
|
||||
self.assertEqual(gs._console_heading("Nobody", "Nothing"), "## Nothing { #nothing }")
|
||||
|
||||
|
||||
class TheSystemsIndexListsConsoles(unittest.TestCase):
|
||||
"""The index listed 74 manufacturers and no console: PlayStation had to
|
||||
be known to be under Sony."""
|
||||
|
||||
def test_one_row_per_console_by_its_name(self):
|
||||
gs.reset_console_pages()
|
||||
self.addCleanup(gs.reset_console_pages)
|
||||
groups = {
|
||||
"Sony": {"PlayStation": [_file("a.bin", "a")], "PlayStation 2": [_file("b.bin", "b")]},
|
||||
"Other": {"ScummVM": [_file("c.bin", "c")]},
|
||||
"Game Engines": {"TheXTech": [_file("d.png", "d")]},
|
||||
}
|
||||
page = gs.generate_systems_index(groups)
|
||||
self.assertIn("| [Sony PlayStation](sony.md#playstation) | 1 | [Sony](sony.md) |", page)
|
||||
self.assertIn("| [Sony PlayStation 2](sony.md#playstation-2) | 1 | [Sony](sony.md) |",
|
||||
page)
|
||||
# A program's folder under Other is named alone, in its own table.
|
||||
consoles = page.split("## Emulator folders")[0]
|
||||
self.assertNotIn("ScummVM", consoles)
|
||||
self.assertIn("| [ScummVM](../emulators/scummvm.md) | [1 file](other.md#scummvm) |",
|
||||
page.split("## Emulator folders")[1])
|
||||
self.assertNotIn("TheXTech", consoles)
|
||||
self.assertIn("| [TheXTech](game-engines.md#thextech)", page)
|
||||
|
||||
def test_rows_go_by_name_not_by_maker(self):
|
||||
groups = {"Sega": {"Saturn": [_file("a.bin", "a")]},
|
||||
"Atari": {"Lynx": [_file("b.bin", "b")]}}
|
||||
page = gs.generate_systems_index(groups)
|
||||
self.assertLess(page.index("Atari Lynx"), page.index("Sega Saturn"))
|
||||
|
||||
|
||||
class FindAFileSaysWhatAHashIs(unittest.TestCase):
|
||||
|
||||
def test_the_sentence_above_the_box(self):
|
||||
page = gs.generate_files_page()
|
||||
intro = page.split('<form class="rb-file-lookup"')[0]
|
||||
self.assertIn("A hash is a fingerprint of a file's contents", intro)
|
||||
self.assertIn("(../wiki/glossary.md)", intro)
|
||||
self.assertNotIn("**permalink**", page)
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "systems" / "sony" / "index.html").is_file(), "site not built")
|
||||
class TheBuiltSonyPage(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.text = (DIST / "systems" / "sony" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
def test_no_card_title_says_permalink(self):
|
||||
# Every Sony console has a page of its own: the cards are there.
|
||||
own = (DIST / "systems" / "sony-playstation" / "index.html").read_text(encoding="utf-8")
|
||||
titles = re.findall(r'<h3[^>]*class="rb-file-title".*?</h3>', own, re.S)
|
||||
self.assertTrue(titles)
|
||||
for title in titles:
|
||||
self.assertNotIn("permalink", re.sub(r"<[^>]+>", "", title))
|
||||
|
||||
def test_playstation_opens_on_a_bios_it_needs(self):
|
||||
section = self.text.split('id="playstation"', 1)[1].split("<h2", 1)[0]
|
||||
# The opening line follows the profiles: "Start with" when most
|
||||
# emulators require the BIOS, "No file needed with ... Only ...
|
||||
# require" when most run without it. Either way it names a BIOS and
|
||||
# links it, before the first card.
|
||||
opening = section.split('class="rb-sys-file"', 1)[0]
|
||||
self.assertRegex(opening, r'<a href="[^"]+"><code[^>]*>scph\d+\.bin</code></a>')
|
||||
# PlayStation has a page of its own, and its files open there.
|
||||
own = (DIST / "systems" / "sony-playstation" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertIn('href="/retrobios/systems/sony-playstation/"', section)
|
||||
first = re.search(r'class="rb-file-title"[^>]*><code[^>]*>(.*?)</code>', own)[1]
|
||||
self.assertNotIn(first, {"LibPS.exe", "PSX_DTLH2000.bin"})
|
||||
self.assertRegex(first, r"^scph\d+")
|
||||
|
||||
def test_headings_have_no_leading_space(self):
|
||||
self.assertNotRegex(self.text, r'<h2 id="playstation[^"]*"><img[^>]*>\s')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,388 +0,0 @@
|
||||
"""Wiki copy and the diagrams module (round 2, package 5).
|
||||
|
||||
Each class names the finding it holds: a safety line the code contradicted,
|
||||
install commands with no Windows form, a run's end described wrongly, pages
|
||||
with two names, an FAQ answer the setups' own checks contradict, a setup
|
||||
count given two ways, unreadable diagram text, and example hashes that
|
||||
answered a hash search in place of the file card.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import sitediagrams # noqa: E402
|
||||
from siteguides import INSTALL_SH # noqa: E402
|
||||
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
WIKI = REPO_ROOT / "wiki"
|
||||
FENCED = re.compile(r"^```.*?^```", re.S | re.M)
|
||||
|
||||
|
||||
def _front(text: str) -> dict:
|
||||
match = re.match(r"---\n(.*?)\n---\n", text, re.S)
|
||||
return yaml.safe_load(match.group(1)) if match else {}
|
||||
|
||||
|
||||
def _h1(text: str) -> str:
|
||||
match = re.search(r"^# (.+?)(?:\s*\{[^}]*\})?\s*$", FENCED.sub("", text), re.M)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def _section(text: str, heading: str) -> str:
|
||||
start = re.search(rf"(?m)^#+ {re.escape(heading)}(?:\s*\{{[^}}]*\}})?\n", text)
|
||||
if start is None:
|
||||
raise AssertionError(f"no section {heading!r}")
|
||||
rest = text[start.end():]
|
||||
end = re.search(r"(?m)^#{1,2} ", FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest))
|
||||
return rest[: end.start()] if end else rest
|
||||
|
||||
|
||||
def _wiki(name: str) -> str:
|
||||
return (WIKI / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _built(name: str) -> str:
|
||||
sitepages.require()
|
||||
return sitepages.page(name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
START_PAGES = ("what-are-bios-files.md", "first-install.md", "troubleshooting.md",
|
||||
"faq.md", "glossary.md", "legal.md")
|
||||
|
||||
|
||||
class SafetyListMatchesTheInstaller(unittest.TestCase):
|
||||
"""C1: "It writes only in your BIOS folder", while install.py writes the
|
||||
entries marked root: save into the setup's save folder."""
|
||||
|
||||
def test_the_faq_names_the_save_folder_while_a_manifest_writes_there(self):
|
||||
saved = [
|
||||
path.name for path in sorted((REPO_ROOT / "install").glob("*.json"))
|
||||
if any(entry.get("root") == "save"
|
||||
for entry in json.loads(path.read_text(encoding="utf-8")).get("files", []))
|
||||
]
|
||||
section = _section(_wiki("faq.md"), "Is the command safe?")
|
||||
if saved:
|
||||
self.assertNotIn("only in your BIOS folder", section)
|
||||
self.assertIn("save folder", section)
|
||||
self.assertIn("retrobios-backup", section)
|
||||
self.assertIn("--restore", section)
|
||||
|
||||
|
||||
class InstallCommandsHaveEveryForm(unittest.TestCase):
|
||||
"""N7, B3, C6: troubleshooting and the FAQ gave only `curl ... | sh`.
|
||||
Every install command on a Start here page is marked for the system tabs,
|
||||
and the built page shows the PowerShell form beside it."""
|
||||
|
||||
def test_every_install_command_on_a_start_page_is_marked(self):
|
||||
for name in START_PAGES:
|
||||
text = _wiki(name)
|
||||
with self.subTest(page=name):
|
||||
for match in re.finditer(r"(?m)^```(?:sh|bash)\n(.*?)\n```", text, re.S):
|
||||
if "install.sh" not in match.group(1) or "|" not in match.group(1):
|
||||
continue
|
||||
before = text[: match.start()].rstrip("\n").rsplit("\n", 1)[-1]
|
||||
self.assertRegex(before, r"<!--\s*os-tabs\b", match.group(1))
|
||||
|
||||
def test_the_built_pages_carry_the_windows_form(self):
|
||||
for name in ("wiki/troubleshooting.md", "wiki/faq.md", "wiki/first-install.md"):
|
||||
text = _built(name)
|
||||
with self.subTest(page=name):
|
||||
self.assertIn('<TabItem label="Windows">', text)
|
||||
self.assertIn("&([scriptblock]::Create((irm", text)
|
||||
self.assertNotIn("os-tabs", text)
|
||||
self.assertIn('```powershell title="PowerShell"', text)
|
||||
self.assertIn('```sh title="Terminal"', text)
|
||||
|
||||
def test_a_windows_path_replaces_the_unix_one(self):
|
||||
page = (
|
||||
"<!-- os-tabs windows: --platform retroarch --dest E:\\x -->\n```sh\n"
|
||||
f"curl -fsSL {INSTALL_SH} \\\n | sh -s -- --platform retroarch --dest /a/b\n```\n"
|
||||
)
|
||||
text = sitediagrams.expand_commands(page)
|
||||
windows, rest = text.split('<TabItem label="macOS">', 1)
|
||||
self.assertIn("--dest E:\\x", windows)
|
||||
self.assertNotIn("/a/b", windows)
|
||||
self.assertIn("| sh -s -- --platform retroarch --dest /a/b", rest)
|
||||
|
||||
def test_a_marked_block_without_an_install_command_fails_the_build(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sitediagrams.expand_commands("<!-- os-tabs -->\n```sh\nls -l\n```\n")
|
||||
|
||||
|
||||
class FirstInstallEnds(unittest.TestCase):
|
||||
"""C7, B15: the page said `Done.` may list failures, and sent a wrong
|
||||
folder to a section with no answer for it."""
|
||||
|
||||
def test_the_wrong_folder_answer_is_on_the_screen(self):
|
||||
# The one-liner opens the full screen: its first screen has the
|
||||
# answer, a row the page names as the interface spells it.
|
||||
import install
|
||||
|
||||
section = _section(_wiki("first-install.md"), "2. Check the setup it found")
|
||||
state = install.TuiState()
|
||||
row = install.tui_rows(state)[-1].label
|
||||
self.assertIn(f"**{row}**", section)
|
||||
self.assertIn("<your setup>", section)
|
||||
self.assertNotIn("Batocera", section)
|
||||
|
||||
def test_the_diagram_follows_the_steps(self):
|
||||
text = _wiki("first-install.md")
|
||||
self.assertLess(text.index("## 5. Read the result"), text.index("<!-- image: install-flow -->"))
|
||||
|
||||
|
||||
class OneNamePerPage(unittest.TestCase):
|
||||
"""N11, C4, V22: "Common questions" in the menu opened "FAQ"."""
|
||||
|
||||
def test_no_wiki_page_names_itself_twice(self):
|
||||
for path in sorted(WIKI.glob("*.md")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
with self.subTest(page=path.name):
|
||||
self.assertNotIn("nav_title", _front(text))
|
||||
|
||||
def test_links_to_a_renamed_page_use_its_heading(self):
|
||||
names = {path.name: _h1(path.read_text(encoding="utf-8")) for path in WIKI.glob("*.md")}
|
||||
stale = {"faq.md": "FAQ", "glossary.md": "Glossary", "legal.md": "The project's legal position"}
|
||||
for path in sorted(WIKI.glob("*.md")):
|
||||
for text, target in re.findall(r"\[([^\]]+)\]\(([a-z-]+\.md)\)", path.read_text(encoding="utf-8")):
|
||||
if target in stale and target in names:
|
||||
with self.subTest(page=path.name, link=text):
|
||||
self.assertNotEqual(text, stale[target])
|
||||
|
||||
|
||||
class ArcadeFilesDependOnTheSetup(unittest.TestCase):
|
||||
"""C11, C02: "Does a BIOS work on every setup? Yes." Platforms check an
|
||||
MD5 and refuse another revision, and neogeo.zip differs by setup."""
|
||||
|
||||
def test_the_answer_is_not_a_plain_yes(self):
|
||||
section = _section(_wiki("faq.md"), "Does a BIOS work on every setup?").strip()
|
||||
self.assertFalse(section.startswith("Yes"))
|
||||
self.assertIn("neogeo.zip", section)
|
||||
|
||||
def test_the_setups_do_install_different_neogeo_zips(self):
|
||||
digests = set()
|
||||
for path in (REPO_ROOT / "install").glob("*.json"):
|
||||
for entry in json.loads(path.read_text(encoding="utf-8")).get("files", []):
|
||||
if str(entry.get("dest", "")).lower().endswith("/neogeo.zip") or entry.get("dest") == "neogeo.zip":
|
||||
digests.add(entry.get("sha1"))
|
||||
self.assertGreater(len(digests), 1)
|
||||
|
||||
def test_zip_or_unzip_is_answered(self):
|
||||
section = _section(_wiki("faq.md"), "Zip or unzip?")
|
||||
self.assertIn("neogeo.zip", section)
|
||||
|
||||
|
||||
class VerificationModesSplit(unittest.TestCase):
|
||||
"""C12, N24, F5: the page opened on "Before a game starts", ran 37
|
||||
entries of contents, and cited checker code as plain text."""
|
||||
|
||||
def test_opening(self):
|
||||
text = _wiki("verification-modes.md")
|
||||
self.assertNotIn("Before a game starts", text)
|
||||
self.assertIn("Most setups can show which BIOS files are missing.", text)
|
||||
|
||||
def test_the_project_reports_have_their_own_page(self):
|
||||
modes = _wiki("verification-modes.md")
|
||||
reports = _wiki("reading-reports.md")
|
||||
for heading in ("## Hash mismatch / UNTESTED", "## verify.py reports errors", "### Reading the output"):
|
||||
with self.subTest(heading=heading):
|
||||
self.assertNotIn(heading, modes)
|
||||
self.assertIn(heading, reports)
|
||||
self.assertEqual(_front(reports).get("level"), "reference")
|
||||
|
||||
def test_checker_links_are_pinned_to_the_revision_the_platform_names(self):
|
||||
named = " ".join(
|
||||
f"{data.get('version', '')} {data.get('source', '')}"
|
||||
for data in (yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
for p in (REPO_ROOT / "platforms").glob("*.yml"))
|
||||
if isinstance(data, dict)
|
||||
)
|
||||
links = re.findall(r"\((https://(?:github\.com|gitlab\.com)/[^)\s]+)\)", _wiki("verification-modes.md"))
|
||||
pinned = [url for url in links if re.search(r"/-?/?blob/", url)]
|
||||
self.assertGreaterEqual(len(pinned), 10)
|
||||
for url in pinned:
|
||||
revision = re.search(r"/blob/([^/]+)/", url).group(1)
|
||||
with self.subTest(url=url):
|
||||
self.assertNotIn(revision, ("main", "master", "HEAD"))
|
||||
self.assertIn(revision, named)
|
||||
|
||||
|
||||
class SetupCount(unittest.TestCase):
|
||||
"""C2: the home page said 15 more, the diagram 14 more, from two counts."""
|
||||
|
||||
def test_one_count(self):
|
||||
from common import list_registered_platforms
|
||||
|
||||
self.assertEqual(len(sitediagrams.setups()),
|
||||
len(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True)))
|
||||
|
||||
def test_every_rendered_count_agrees(self):
|
||||
"""Each "RetroArch, Batocera, EmuDeck and N more" names three setups."""
|
||||
found = set()
|
||||
for path in [*sitepages.pages()]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
found.update(int(n) for n in re.findall(r"(?:RetroArch|EmuDeck|Batocera) and (\d+) more", text))
|
||||
found.update(int(n) for n in re.findall(
|
||||
r"and (\d+) more", sitediagrams.og_card_svg("home", "<svg></svg>")))
|
||||
self.assertTrue(found)
|
||||
self.assertEqual(found, {len(sitediagrams.setups()) - 3})
|
||||
|
||||
|
||||
class Vocabulary(unittest.TestCase):
|
||||
"""C5: one thing called device, setup, frontend, platform and game menu."""
|
||||
|
||||
def test_the_glossary_defines_the_words_the_reference_uses(self):
|
||||
terms = {term for term in re.findall(r"^\*\*([^*]+)\*\*", _wiki("glossary.md"), re.M)}
|
||||
terms |= set(re.findall(r"\*\*([^*]+)\*\*", _wiki("glossary.md")))
|
||||
for word in ("Pack", "Platform", "MD5", "SHA-1", "Setup"):
|
||||
with self.subTest(word=word):
|
||||
self.assertIn(word, terms)
|
||||
|
||||
def test_start_pages_and_the_first_diagram_say_setup(self):
|
||||
for name in START_PAGES[:5]:
|
||||
text = FENCED.sub("", _wiki(name)).lower()
|
||||
with self.subTest(page=name):
|
||||
self.assertNotRegex(text, r"\bfrontends?\b")
|
||||
self.assertNotIn("game menu", text)
|
||||
for figure in ("bios-chain", "install-flow"):
|
||||
with self.subTest(figure=figure):
|
||||
self.assertNotIn("rontend", sitediagrams.figure_html(figure))
|
||||
|
||||
|
||||
class NavigationPlaces(unittest.TestCase):
|
||||
"""N14, N15, N16, T5: installer and legal in Explore, build internals in
|
||||
Reference, and a legacy Getting started page in no tab."""
|
||||
|
||||
def test_front_matter(self):
|
||||
expected = {
|
||||
"installer.md": "setup", "legal.md": "start",
|
||||
"architecture.md": "contribute", "tools.md": "contribute",
|
||||
"advanced-usage.md": "contribute", "reference-terms.md": "contribute",
|
||||
}
|
||||
for name, level in expected.items():
|
||||
with self.subTest(page=name):
|
||||
self.assertEqual(_front(_wiki(name))["level"], level)
|
||||
self.assertEqual(_front(_wiki("legal.md"))["order"], _front(_wiki("faq.md"))["order"] + 5)
|
||||
self.assertEqual(_front(_wiki("verification-modes.md"))["group"], "Checking files")
|
||||
self.assertEqual(_front(_wiki("community-tools.md"))["group"], "Data")
|
||||
self.assertFalse((WIKI / "getting-started.md").exists())
|
||||
|
||||
def test_no_next_aside_is_left(self):
|
||||
for path in sorted(WIKI.glob("*.md")):
|
||||
with self.subTest(page=path.name):
|
||||
self.assertNotIn(":::tip[Next]", path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TroubleshootingLines(unittest.TestCase):
|
||||
"""C22: "Details: When something goes wrong", a sudo line that is the
|
||||
installer's to give, and an unclear Linux line."""
|
||||
|
||||
def test_lines(self):
|
||||
text = _wiki("troubleshooting.md")
|
||||
self.assertNotIn("Details: [When something goes wrong]", text)
|
||||
self.assertIn("Other messages: [When something goes wrong]", text)
|
||||
self.assertIn("Run the command once.", text)
|
||||
for name in ("troubleshooting.md", "first-install.md"):
|
||||
with self.subTest(page=name):
|
||||
self.assertNotIn("sudo", _wiki(name))
|
||||
self.assertRegex(" ".join(_wiki(name).split()), r"[Dd]o what the message says")
|
||||
|
||||
def test_the_installer_names_what_to_run(self):
|
||||
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
advice = source[source.index("def _write_advice"):source.index("def _plural")]
|
||||
self.assertIn("hint.command(plat, path, elevated=True)", advice)
|
||||
|
||||
|
||||
class DiagramText(unittest.TestCase):
|
||||
"""V17: 11 px text in fixed-height boxes, paths broken mid-token."""
|
||||
|
||||
def test_text_reads_at_14_px_in_the_column(self):
|
||||
self.assertGreaterEqual(sitediagrams.TEXT_SIZE * sitediagrams.COLUMN / sitediagrams.WIDE, 14)
|
||||
|
||||
def test_no_box_holds_more_than_three_lines(self):
|
||||
wide = sitediagrams.WIDE - 2
|
||||
layouts = { # the box width of each wide layout: flow, grid of two, columns
|
||||
"bios-chain": (sitediagrams._bios_chain_boxes(), (wide - 26 * 3) / 4),
|
||||
"install-flow": (sitediagrams._install_boxes(), (wide - 18) / 2),
|
||||
"verification-modes": (sitediagrams._mode_boxes(), (wide - 16 * 2) / 3),
|
||||
}
|
||||
for name, (boxes, box_width) in layouts.items():
|
||||
for width in (sitediagrams.NARROW - 2, box_width):
|
||||
for box in boxes:
|
||||
with self.subTest(figure=name, box=box.title, width=width):
|
||||
self.assertLessEqual(len(sitediagrams.box_lines(box, width)[1]),
|
||||
sitediagrams.MAX_BOX_LINES)
|
||||
|
||||
def test_a_path_is_mono_on_its_own_line(self):
|
||||
html = sitediagrams.figure_html("bios-chain")
|
||||
self.assertRegex(html, r'<text [^>]*style="font-family: var\(--sl-font-mono[^>]*>/userdata/bios/</text>')
|
||||
|
||||
def test_boxes_of_a_row_share_one_height(self):
|
||||
for layout in (sitediagrams.flow, sitediagrams.columns):
|
||||
for vertical, total in ((False, sitediagrams.WIDE), (True, sitediagrams.NARROW)):
|
||||
_width, _height, body = layout(sitediagrams._bios_chain_boxes(), vertical, total)
|
||||
heights = {float(h) for h in re.findall(r'<rect [^>]*height="([\d.]+)"[^>]*class="rb-d-box',
|
||||
"".join(body))}
|
||||
with self.subTest(layout=layout.__name__, vertical=vertical):
|
||||
self.assertEqual(len(heights), 1)
|
||||
|
||||
|
||||
class ExampleHashesStayOutOfSearch(unittest.TestCase):
|
||||
"""F4: the scph5501 MD5 typed in search found four documentation pages
|
||||
and not the file card."""
|
||||
|
||||
def test_wiki_hashes_are_excluded(self):
|
||||
text = _built("wiki/verification-modes.md")
|
||||
for match in re.finditer(r"\b[0-9a-f]{32}\b", text):
|
||||
line_start = text.rfind("\n", 0, match.start())
|
||||
context = text[line_start: match.end() + 10]
|
||||
with self.subTest(hash=match.group(0)):
|
||||
self.assertIn("data-pagefind-ignore", context)
|
||||
|
||||
def test_a_fenced_hash_is_wrapped(self):
|
||||
out = sitediagrams.hide_hashes("```sh\nx 490f666e1afb15b7362b406ed1cea246\n```\n")
|
||||
self.assertTrue(out.startswith("<div data-pagefind-ignore>\n\n```sh"))
|
||||
|
||||
def test_only_wiki_pages_are_touched(self):
|
||||
page = "Hash `490f666e1afb15b7362b406ed1cea246`.\n"
|
||||
self.assertEqual(sitediagrams.prepare_page(page, Path("systems/sony.md")), page)
|
||||
self.assertIn("data-pagefind-ignore", sitediagrams.prepare_page(page, Path("wiki/x.md")))
|
||||
|
||||
|
||||
class InstallerOptionsAtAGlance(unittest.TestCase):
|
||||
"""C23: a 9,000-word page with no table at the top. The table is read
|
||||
from the parser, so it lists what --help lists."""
|
||||
|
||||
def test_every_option_of_the_parser_has_a_row(self):
|
||||
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
declared = set(re.findall(r'parser\.add_argument\(\s*"(--[a-z-]+)"', source))
|
||||
listed = {usage.split()[0].rstrip(",") for usage, _ in sitediagrams.installer_options()}
|
||||
self.assertEqual(declared | {"--help"}, listed)
|
||||
|
||||
def test_the_built_page_opens_on_a_link_per_option(self):
|
||||
text = _built("wiki/installer.md")
|
||||
glance = text.index("## Options at a glance")
|
||||
self.assertLess(glance, text.index("## One line"))
|
||||
listed = text[glance:text.index("## One line")]
|
||||
for usage, _ in sitediagrams.installer_options():
|
||||
flag = usage.split()[0].rstrip(",")
|
||||
with self.subTest(option=flag):
|
||||
self.assertIn(f"[`{flag}`](#option-{flag.lstrip('-')})", listed)
|
||||
self.assertNotIn("<!-- installer-options", text)
|
||||
# The markers of scripts/generate_installer_doc.py stay in the source.
|
||||
self.assertNotIn("generated:", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,426 +0,0 @@
|
||||
"""Round 3, package 2: components and runtime.
|
||||
|
||||
Source checks always run. Checks on the built site read website/dist and
|
||||
skip when no build is there. The hash functions and the table rule run
|
||||
under node and skip without it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import unittest
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
WEBSITE = REPO_ROOT / "website"
|
||||
DIST = WEBSITE / "dist"
|
||||
COMPONENTS = WEBSITE / "src" / "components"
|
||||
SITE_TS = WEBSITE / "src" / "scripts" / "site.ts"
|
||||
CONFIG = WEBSITE / "astro.config.mjs"
|
||||
PLUGIN = WEBSITE / "src" / "plugins" / "rehype-stack-tables.mjs"
|
||||
UNBROKEN = WEBSITE / "src" / "plugins" / "ec-unbroken-tokens.mjs"
|
||||
LOGOS = REPO_ROOT / "docs_assets" / "logos"
|
||||
|
||||
# Shown at 40 px in a tile and beside a setup's title: twice that for a
|
||||
# sharp mark on a dense screen.
|
||||
LOGO_PIXELS = 80
|
||||
TAB_ROOTS = ("", "platforms", "explore", "wiki", "contributing")
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _built(page: str) -> str:
|
||||
path = DIST / page / "index.html"
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest(f"no built site at {path}")
|
||||
return _read(path)
|
||||
|
||||
|
||||
def _title_block(html: str) -> str:
|
||||
"""What PageTitle draws: from the main column to the end of the h1."""
|
||||
end = html.index("</h1>", html.index('id="_top"'))
|
||||
return html[html.rfind("<main", 0, end):end]
|
||||
|
||||
|
||||
def _tables(html: str) -> list[str]:
|
||||
return re.findall(r"<table\b.*?</table>", html, re.S)
|
||||
|
||||
|
||||
def _headers(table: str) -> list[str]:
|
||||
head = re.search(r"<thead>(.*?)</thead>", table, re.S)
|
||||
cells = re.findall(r"<th\b[^>]*>(.*?)</th>", head.group(1), re.S) if head else []
|
||||
return [re.sub(r"<[^>]+>", "", cell).strip() for cell in cells]
|
||||
|
||||
|
||||
def _node_module(script: str) -> str:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
raise unittest.SkipTest("node is missing")
|
||||
result = subprocess.run(
|
||||
[node, "--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, check=True, cwd=WEBSITE,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _webp_size(data: bytes) -> tuple[int, int]:
|
||||
"""Width and height from a WebP header (VP8, VP8L or VP8X)."""
|
||||
if data[:4] != b"RIFF" or data[8:12] != b"WEBP":
|
||||
raise ValueError("not a WebP file")
|
||||
chunk = data[12:16]
|
||||
if chunk == b"VP8 ":
|
||||
width, height = struct.unpack("<HH", data[26:30])
|
||||
return width & 0x3FFF, height & 0x3FFF
|
||||
if chunk == b"VP8L":
|
||||
bits = int.from_bytes(data[21:25], "little")
|
||||
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||
if chunk == b"VP8X":
|
||||
return int.from_bytes(data[24:27], "little") + 1, int.from_bytes(data[27:30], "little") + 1
|
||||
raise ValueError(f"unknown WebP chunk {chunk!r}")
|
||||
|
||||
|
||||
class TitleLogoOnAPlate(unittest.TestCase):
|
||||
"""The RetroArch and Batocera marks are black: beside the title they
|
||||
vanished on the dark theme, and the image had no width (Lighthouse
|
||||
unsized-images, V3, T-F3)."""
|
||||
|
||||
def test_the_component_draws_the_tile_square(self):
|
||||
title = _read(COMPONENTS / "PageTitle.astro")
|
||||
self.assertIn('width={MARK} height={MARK}', title)
|
||||
self.assertIn("const MARK = 40;", title)
|
||||
style = title[title.index(".rb-title-mark {"):]
|
||||
style = style[:style.index("}")]
|
||||
for rule in ("width: 40px;", "height: 40px;", "padding: 3px;", "border: 1px solid var(--rb-border);",
|
||||
"border-radius: 6px;", "background: #fff;", "object-fit: contain;"):
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule, style)
|
||||
|
||||
def test_every_setup_page_has_a_sized_mark(self):
|
||||
index = json.loads(_read(WEBSITE / "public" / "data" / "picker" / "index.json"))
|
||||
for entry in index:
|
||||
with self.subTest(setup=entry["platform"]):
|
||||
block = _title_block(_built(f"platforms/{entry['platform']}"))
|
||||
self.assertIn('class="rb-title-mark', block)
|
||||
for image in re.findall(r"<img\b[^>]*>", block):
|
||||
self.assertIn('width="40"', image)
|
||||
self.assertIn('height="40"', image)
|
||||
|
||||
def test_a_setup_with_no_logo_gets_its_initials(self):
|
||||
block = _title_block(_built("platforms/myminui"))
|
||||
self.assertNotIn("<img", block)
|
||||
self.assertRegex(block, r"<svg class=\"rb-title-mark[^>]*>.*?>MM</text>")
|
||||
|
||||
|
||||
class NoLevelBadgeOnATabRoot(unittest.TestCase):
|
||||
def test_tab_roots(self):
|
||||
for page in TAB_ROOTS:
|
||||
with self.subTest(page=page or "home"):
|
||||
self.assertNotIn('class="rb-level ', _title_block(_built(page)))
|
||||
|
||||
|
||||
class FooterLicenceMatchesTheLicenceFile(unittest.TestCase):
|
||||
"""The footer said MIT covers the tooling. LICENSE names the metadata too."""
|
||||
|
||||
def test_footer(self):
|
||||
self.assertIn("metadata", _read(REPO_ROOT / "LICENSE").split("MIT License")[0])
|
||||
html = _built("data")
|
||||
footer = re.sub(r"<[^>]+>", "", html[html.index('class="rb-footer'):])
|
||||
self.assertIn("MIT for the tooling and metadata.", footer)
|
||||
|
||||
|
||||
class WideTablesStackAndFiltersAreBuilt(unittest.TestCase):
|
||||
"""Level 2 and 3 tables overflowed a phone (emulator summary, gaps,
|
||||
verification modes, emulators index, data: V5, J8). The filter box,
|
||||
added after paint, pushed the table down: CLS 0.066 on
|
||||
gaps-files-to-source (T-F10)."""
|
||||
|
||||
def test_every_wide_table_stacks(self):
|
||||
for page in ("emulators/duckstation", "gaps", "gaps-files-to-source", "emulators", "data",
|
||||
"wiki/verification-modes"):
|
||||
html = (DIST / page / "index.html")
|
||||
if not html.is_file():
|
||||
continue
|
||||
for table in _tables(_read(html)):
|
||||
if len(_headers(table)) < 4:
|
||||
continue
|
||||
with self.subTest(page=page, header=_headers(table)):
|
||||
self.assertIn('data-rb-stack="true"', table.split(">", 1)[0])
|
||||
|
||||
def test_long_tables_get_their_labels_at_runtime(self):
|
||||
"""Labels on every cell of a long stacked table were 122 KB of
|
||||
emulators/mame and took it over the 600,000-byte page budget."""
|
||||
self.assertIn("if (kind === 'kv' || rows.length >= RECORD_ROWS) return;", _read(PLUGIN))
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("const labelCells = (table: HTMLTableElement) => {", source)
|
||||
self.assertIn("labelCells(table);", source)
|
||||
html = _built("emulators/mame")
|
||||
self.assertLess(len(html.encode()), 600_000)
|
||||
|
||||
def test_the_filter_box_is_in_the_html(self):
|
||||
html = _built("gaps-files-to-source")
|
||||
boxes = re.findall(r'<div class="rb-table-filter">(.*?)</div>\s*<table', html, re.S)
|
||||
self.assertTrue(boxes)
|
||||
for box in boxes:
|
||||
self.assertRegex(box, r'<label for="rb-table-filter-\d+">Filter [^<]+</label>')
|
||||
self.assertRegex(box, r'<input id="rb-table-filter-\d+" type="search"')
|
||||
self.assertRegex(box, r'aria-live="polite">[\d,]+ rows</span>')
|
||||
source = _read(SITE_TS)
|
||||
self.assertNotIn("controls.className = 'rb-table-filter'", source)
|
||||
self.assertIn("controls?.matches('.rb-table-filter')", source)
|
||||
|
||||
def test_every_long_table_has_its_box(self):
|
||||
html = _built("systems")
|
||||
for table in _tables(html):
|
||||
rows = len(re.findall(r"<tr\b", table.split("<tbody>", 1)[-1]))
|
||||
if rows < 20:
|
||||
continue
|
||||
start = html.index(table)
|
||||
self.assertIn('class="rb-table-filter"', html[max(0, start - 600):start])
|
||||
|
||||
def test_the_placeholder_names_the_columns(self):
|
||||
"""Every filter offered "Name, platform, system, hash...", over the
|
||||
console list too, which has no hash (V small)."""
|
||||
self.assertNotIn("Name, platform, system, hash", _read(SITE_TS))
|
||||
cases = [["File", "Region", "Required", "Hash"], ["Console", "Files"], ["System", "MD5", "Size"]]
|
||||
out = _node_module(
|
||||
f"import {{ filterPlaceholder }} from {json.dumps(PLUGIN.as_uri())};"
|
||||
f"console.log(JSON.stringify({json.dumps(cases)}.map(filterPlaceholder)));"
|
||||
)
|
||||
self.assertEqual(json.loads(out), ["File, region, required, hash...", "Console, files...", "System, MD5, size..."])
|
||||
html = _built("systems")
|
||||
self.assertRegex(html, r'placeholder="Console[^"]*\.\.\."')
|
||||
self.assertNotRegex(html, r'placeholder="[^"]*hash[^"]*"')
|
||||
|
||||
|
||||
class HashLookupGivesOneVerdict(unittest.TestCase):
|
||||
"""The lookup read "24 matches", "Setups that list it (12)" over 17 rows,
|
||||
and emulators by id. ?md5= showed the empty form (N8, J7)."""
|
||||
|
||||
def test_parameters(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("const HASH_PARAMS = ['hash', 'md5', 'crc32', 'sha256'];", source)
|
||||
self.assertIn("HASH_PARAMS.map((key) => params.get(key))", source)
|
||||
|
||||
def test_counts_say_what_they_count(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertNotIn("'match' : 'matches'", source)
|
||||
self.assertIn("count(platforms.length, 'declaration', 'declarations')} by ${count(setups.size, 'setup', 'setups')}", source)
|
||||
self.assertIn("count(listers.size, 'setup lists', 'setups list')", source)
|
||||
self.assertIn("'file in the collection', 'files in the collection'", source)
|
||||
|
||||
def test_emulators_by_name(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("json(`api/v1/emulators/${owner}.json`)", source)
|
||||
self.assertIn("profile.emulator || profile.display_name || owner", source)
|
||||
profile = json.loads(_read(DIST / "api" / "v1" / "emulators" / "duckstation.json")) \
|
||||
if (DIST / "api" / "v1" / "emulators" / "duckstation.json").is_file() else None
|
||||
if profile is None:
|
||||
self.skipTest("no built site")
|
||||
self.assertEqual(profile["items"][0]["profile"]["emulator"], "DuckStation")
|
||||
|
||||
def test_a_console_page_card_reads_its_manufacturer_file(self):
|
||||
"""Every collection match on a console page (systems/sony-playstation/)
|
||||
asked for api/v1/systems/sony-playstation.json, which does not exist:
|
||||
the verdict lost the file's path."""
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("api/v1/systems/${words.slice(0, size).join('-')}.json", source)
|
||||
api = DIST / "api" / "v1" / "systems"
|
||||
if not api.is_dir():
|
||||
self.skipTest("no built site")
|
||||
self.assertFalse((api / "sony-playstation.json").exists())
|
||||
self.assertTrue((api / "sony.json").is_file())
|
||||
|
||||
def test_no_match_leads_somewhere(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("link('contributing/', 'add it')", source)
|
||||
anchor = re.search(r"const WANTED = 'provenance/#([^']+)';", source)
|
||||
self.assertIsNotNone(anchor)
|
||||
self.assertIn(f'id="{anchor[1]}"', _built("provenance"))
|
||||
self.assertNotIn("No file the site knows has this hash.", source)
|
||||
|
||||
|
||||
class HashAFileHere(unittest.TestCase):
|
||||
"""Find a file asked for a hash only: hashing meant another tool (J7)."""
|
||||
|
||||
def test_the_chooser(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("input.type = 'file';", source)
|
||||
self.assertIn("It is hashed on this device. Nothing is uploaded.", source)
|
||||
self.assertIn("crypto.subtle.digest(name, buffer)", source)
|
||||
self.assertIn("['SHA-1', 'SHA-256']", source)
|
||||
self.assertIn("lookUp(status, result, [found.sha1, found.md5, found.crc32, found.sha256])", source)
|
||||
|
||||
def test_md5_and_crc32_match_python(self):
|
||||
source = _read(SITE_TS)
|
||||
block = source[source.index("// Hashing a file on this device"):source.index("type FileHashes")]
|
||||
sizes = [0, 1, 55, 56, 63, 64, 65, 119, 120, 128, 1000, 70_001]
|
||||
out = _node_module(block + (
|
||||
f"console.log(JSON.stringify({json.dumps(sizes)}.map((n) => {{"
|
||||
"const bytes = new Uint8Array(n);"
|
||||
"for (let i = 0; i < n; i += 1) bytes[i] = (i * 31 + 7) & 0xff;"
|
||||
"return [md5(bytes), crc32(bytes)]; })));"
|
||||
))
|
||||
for size, (md5, crc) in zip(sizes, json.loads(out)):
|
||||
data = bytes((i * 31 + 7) & 0xFF for i in range(size))
|
||||
with self.subTest(size=size):
|
||||
self.assertEqual(md5, hashlib.md5(data).hexdigest())
|
||||
self.assertEqual(crc, f"{zlib.crc32(data):08x}")
|
||||
|
||||
|
||||
class SearchFoldsPagesUnderAHash(unittest.TestCase):
|
||||
"""Under "Look up this hash" came 14 unrelated pages: Magnavox for
|
||||
E490.WAV, Philips for cdi490a.zip (N8)."""
|
||||
|
||||
def test_fold(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("toggle.textContent = 'Pages that mention these characters';", source)
|
||||
self.assertIn("toggle.setAttribute('aria-expanded', 'false');", source)
|
||||
header = _read(COMPONENTS / "Header.astro")
|
||||
self.assertIn(
|
||||
".pagefind-ui__drawer[data-rb-hash]:not([data-rb-pages-shown]) .pagefind-ui__results-area { display: none; }",
|
||||
header,
|
||||
)
|
||||
|
||||
def test_search_fields_are_weighted_and_never_shown(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 10, devices: 5, consoles: 100, exact: 20, terms: 30 },", config)
|
||||
self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'exact', 'terms']);", _read(SITE_TS))
|
||||
|
||||
|
||||
class OsTabsNeverScrollThePage(unittest.TestCase):
|
||||
"""The tabs wrap since round 4 (retrobios.css): the script picks the
|
||||
visitor's tab and never scrolls the page."""
|
||||
|
||||
def test_no_page_scroll(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertNotIn("scrollIntoView", source)
|
||||
self.assertIn("osTabs();", source)
|
||||
|
||||
|
||||
class CommandsNeverBreakInsideAWord(unittest.TestCase):
|
||||
"""`--` / `platform retroarch` at 1440 px, `--` / `system` and
|
||||
`nintendo-` / `64dd` on a phone: a browser breaks a line after a hyphen,
|
||||
whatever overflow-wrap says."""
|
||||
|
||||
def test_the_words_are_wrapped_and_the_text_kept(self):
|
||||
# Shiki cuts `--platform` into two tokens: the span has to hold both.
|
||||
line = {"type": "element", "tagName": "div", "properties": {"className": ["code"]}, "children": [
|
||||
{"type": "element", "tagName": "span", "properties": {"style": "a"}, "children": [
|
||||
{"type": "text", "value": "sh -s "}]},
|
||||
{"type": "element", "tagName": "span", "properties": {"style": "b"}, "children": [
|
||||
{"type": "text", "value": "--"}]},
|
||||
{"type": "element", "tagName": "span", "properties": {"style": "c"}, "children": [
|
||||
{"type": "text", "value": "platform retroarch --system nintendo-64dd"}]},
|
||||
]}
|
||||
out = json.loads(_node_module(
|
||||
f"import {{ unbreakWords }} from {json.dumps(UNBROKEN.as_uri())};"
|
||||
f"console.log(JSON.stringify(unbreakWords({json.dumps(line)})));"
|
||||
))
|
||||
|
||||
def text(node: dict) -> str:
|
||||
return node.get("value", "") + "".join(text(child) for child in node.get("children", []))
|
||||
|
||||
self.assertEqual("".join(text(n) for n in out), "sh -s --platform retroarch --system nintendo-64dd")
|
||||
words = [text(n) for n in out if n.get("properties", {}).get("className") == ["rb-nb"]]
|
||||
self.assertEqual(words, ["-s", "--platform", "--system", "nintendo-64dd"])
|
||||
# The colours of each token survive the cut.
|
||||
platform = next(n for n in out if text(n) == "--platform")
|
||||
self.assertEqual([c["properties"]["style"] for c in platform["children"]], ["b", "c"])
|
||||
|
||||
def test_registered(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("import unbrokenTokens from './src/plugins/ec-unbroken-tokens.mjs';", config)
|
||||
self.assertIn("plugins: [unbrokenTokens()],", config)
|
||||
self.assertIn("baseStyles: `.${CLASS} { white-space: nowrap; }`", _read(UNBROKEN))
|
||||
picker = _read(COMPONENTS / "FilePicker.astro")
|
||||
self.assertIn("showCommand(cmd, `${INSTALL} --platform", picker)
|
||||
self.assertIn(".rb-picker .rb-pick-cmd .rb-nb { white-space: nowrap; }", picker)
|
||||
self.assertIn("navigator.clipboard?.writeText(cmd.textContent ?? '')", picker)
|
||||
|
||||
def test_copy_gives_the_rendered_text(self):
|
||||
"""The copy button's text is the block's text: the spans add nothing.
|
||||
Expressive Code leaves a leading comment line out of the copy."""
|
||||
import html as html_lib
|
||||
|
||||
page = _built("platforms/retroarch")
|
||||
figures = re.findall(r"<figure\b.*?</figure>", page, re.S)
|
||||
self.assertTrue(figures)
|
||||
self.assertIn('<span class="rb-nb">', page)
|
||||
for figure in figures:
|
||||
copied = re.search(r'data-code="([^"]*)"', figure)
|
||||
if not copied:
|
||||
continue
|
||||
lines = re.findall(r'<div class="ec-line">(.*?)</div></div>', figure, re.S)
|
||||
shown = "\n".join(html_lib.unescape(re.sub(r"<[^>]+>", "", line)) for line in lines)
|
||||
code = html_lib.unescape(copied[1]).replace("\x7f", "\n")
|
||||
with self.subTest(code=code[:60]):
|
||||
self.assertTrue(shown.endswith(code), (shown, code))
|
||||
|
||||
|
||||
class LogosAtTheirDisplaySize(unittest.TestCase):
|
||||
"""136 KiB of the home page was 96 px PNG logos shown at 40 px (T-F7)."""
|
||||
|
||||
def test_raster_logos_are_small_webp(self):
|
||||
manifest = json.loads(_read(LOGOS / "sources.json"))["images"]
|
||||
self.assertTrue(manifest)
|
||||
for entry in manifest:
|
||||
path = LOGOS / entry["file"]
|
||||
with self.subTest(file=entry["file"]):
|
||||
self.assertTrue(path.is_file())
|
||||
self.assertIn(path.suffix, (".webp", ".svg"))
|
||||
if path.suffix == ".webp":
|
||||
self.assertEqual(_webp_size(path.read_bytes()), (LOGO_PIXELS, LOGO_PIXELS))
|
||||
self.assertEqual(sorted(p.name for p in LOGOS.glob("*.png")), [])
|
||||
script = _read(REPO_ROOT / "scripts" / "fetch_site_images.py")
|
||||
self.assertIn(f"LOGO_SIZE = {LOGO_PIXELS}", script)
|
||||
self.assertIn('square_image(data, LOGO_SIZE, "WEBP")', script)
|
||||
|
||||
def test_pages_point_at_the_copies(self):
|
||||
html = _built("platforms/emudeck")
|
||||
self.assertIn('src="/retrobios/assets/logos/emudeck.webp"', html)
|
||||
|
||||
|
||||
class SearchConsoleOwnership(unittest.TestCase):
|
||||
"""Search Console checks the tag on every recrawl: without it the
|
||||
property goes unverified and the sitemap stops being read."""
|
||||
|
||||
def test_the_head_carries_the_verification_tag(self):
|
||||
self.assertIn("{ tag: 'meta', attrs: { name: 'google-site-verification', "
|
||||
"content: '8SueST3hNHl8_vM-OJJP0rz-ZMlI2DALLpChrwZoGMs' } }",
|
||||
CONFIG.read_text(encoding="utf-8"))
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
def test_the_home_page_serves_it(self):
|
||||
self.assertIn('<meta name="google-site-verification" '
|
||||
'content="8SueST3hNHl8_vM-OJJP0rz-ZMlI2DALLpChrwZoGMs"',
|
||||
(DIST / "index.html").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
|
||||
class EverySonyConsoleHasItsPage(unittest.TestCase):
|
||||
"""A section shares its page's search fields: "playstation 3 bios"
|
||||
found the PlayStation page before the Sony page holding the PS3."""
|
||||
|
||||
def test_sony_is_split_by_console(self):
|
||||
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
self.assertIn('CONSOLE_PAGES = frozenset({"Sony"})', source)
|
||||
self.assertIn("manufacturer in CONSOLE_PAGES", source)
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
def test_each_console_page_is_built(self):
|
||||
for slug in ("sony-playstation", "sony-playstation-2", "sony-playstation-3",
|
||||
"sony-playstation-4", "sony-playstation-portable", "sony-playstation-vita",
|
||||
"sony-pocketstation"):
|
||||
with self.subTest(page=slug):
|
||||
self.assertTrue((DIST / "systems" / slug / "index.html").is_file())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Round 3 theme: the stylesheet fixes of package 1, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: commands allowed
|
||||
to break inside any word on a phone, a sidebar whose top links outsize its
|
||||
group labels, badge links cut to a few pixels by the
|
||||
next row, 12 px hashes, Get your files tables each sizing their own columns.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tests.test_site_r2_css import CSS_PATH, PAGE_BG, _blocks, _ratio, _rules, _strip_comments, _theme_tokens
|
||||
|
||||
# The Get your files tables: no class reaches them, the Markdown table is
|
||||
# matched by the cells the generator labels.
|
||||
PACKS = "table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])"
|
||||
|
||||
|
||||
class ThemeStylesheetRound3(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
dark = _theme_tokens(cls.css, ":root")
|
||||
light = {**dark, **_theme_tokens(cls.css, ":root[data-theme='light']")}
|
||||
cls.tokens = {"dark": dark, "light": light}
|
||||
|
||||
# 1.1
|
||||
def test_commands_never_break_inside_a_word(self):
|
||||
blocks = _rules(self.css, ".expressive-code pre > code")
|
||||
self.assertTrue(blocks)
|
||||
for body in blocks:
|
||||
self.assertIn("white-space: pre-wrap", body, "commands keep wrapping")
|
||||
self.assertNotIn("overflow-wrap: anywhere", body)
|
||||
for selector in (".rb-facts code", ".rb-hash code", ".rb-meta-card td code"):
|
||||
with self.subTest(selector=selector):
|
||||
self.assertIn("overflow-wrap: anywhere", _rules(self.css, selector)[0])
|
||||
|
||||
# 1.2
|
||||
def test_sidebar_labels_above_links(self):
|
||||
label = _rules(self.css, ".top-level summary .large")[0]
|
||||
# 13 px grey semibold, no uppercase: the house style refuses small caps.
|
||||
for declaration in ("font-size: var(--sl-text-xs)", "font-weight: 600", "color: var(--rb-text-secondary)"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, label)
|
||||
links = next(b for s, b in _blocks(self.css) if s == "#starlight__sidebar .top-level a")
|
||||
self.assertIn("font-size: var(--sl-text-sm)", links)
|
||||
self.assertIn("font-weight: 400", links)
|
||||
|
||||
# 1.4
|
||||
def test_badge_links_keep_a_24px_target(self):
|
||||
dd = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content .rb-facts dd")
|
||||
self.assertIn("line-height: 24px", dd)
|
||||
badge = _rules(self.css, ".rb-facts :is(.rb-badge:has(> a), a.rb-badge)")[0]
|
||||
for declaration in ("display: inline-flex", "align-items: center", "min-height: 24px", "margin-block: 2px"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, badge)
|
||||
|
||||
# 1.5
|
||||
def test_hashes_are_13px_at_every_width(self):
|
||||
hashes = [b for s, b in _blocks(self.css) if ".rb-hash" in s and "font-size" in b]
|
||||
self.assertEqual(len(hashes), 1, "a second size for .rb-hash, on phones")
|
||||
self.assertIn("font-size: 0.8125rem", hashes[0])
|
||||
short = _rules(self.css, "td code[title]")[0]
|
||||
self.assertIn("white-space: nowrap", short)
|
||||
self.assertIn("underline dotted", short)
|
||||
self.assertIn("min-width: 12rem", _rules(self.css, "td.rb-reason")[0])
|
||||
|
||||
# 1.6
|
||||
def test_pack_tables_share_their_columns(self):
|
||||
base = next(b for s, b in _blocks(self.css) if PACKS in s and "table-layout" in b)
|
||||
self.assertIn("table-layout: fixed", base)
|
||||
self.assertIn("width: 100%", base)
|
||||
widths = {s.rsplit("th:nth-child(", 1)[1].split(")")[0]: b for s, b in _blocks(self.css)
|
||||
if PACKS in s and "th:nth-child(" in s}
|
||||
self.assertEqual({k: v.strip() for k, v in widths.items()},
|
||||
{"1": "width: 18%;", "2": "width: 20%;", "3": "width: 40%;", "4": "width: 22%;"})
|
||||
parts = next(b for s, b in _blocks(self.css) if PACKS in s and s.endswith(":is(a, .rb-button)"))
|
||||
self.assertIn("white-space: nowrap", parts)
|
||||
|
||||
def test_secondary_text_on_the_sidebar_meets_aa(self):
|
||||
for theme in ("light", "dark"):
|
||||
with self.subTest(theme=theme):
|
||||
fg = self.tokens[theme]["--rb-text-secondary"].strip()
|
||||
self.assertGreaterEqual(_ratio(fg, PAGE_BG[theme]), 4.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,197 +0,0 @@
|
||||
"""Emulator pages say what to get, and fit (round 3, package 7).
|
||||
|
||||
NetherSX2 counted three interface fonts its app ships among "4 required"
|
||||
files and opened its file list on the project's counts. AetherSX2's summary
|
||||
held an empty Region column that pushed Required off a phone. No MAME or
|
||||
FinalBurn page said which romset its sets must match. DuckStation's page was
|
||||
59 phone screens of optional cards, and MAME's index put 13,300 elements on
|
||||
one page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import validate_site # noqa: E402
|
||||
|
||||
EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _page(name: str, profile: dict | None = None) -> str:
|
||||
return gs.generate_emulator_page(name, profile or _profile(name), EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _files(count: int, **fields: object) -> list[dict]:
|
||||
return [{"name": f"f{i:03d}.bin", **fields} for i in range(count)]
|
||||
|
||||
|
||||
class EachArcadePageNamesItsRomset(unittest.TestCase):
|
||||
def test_the_romset_by_profile(self):
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003")), "MAME 0.78")
|
||||
# The app's version is 1.38.1, its MAME is 0.289.
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame4droid-2024")), "MAME 0.289")
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003_plus")), "MAME 0.78 (plus backports)")
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbneo")).startswith("FinalBurn Neo "))
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbalpha2012")).startswith("FB Alpha "))
|
||||
self.assertEqual(gs.emulator_romset(_profile("duckstation")), "")
|
||||
# HBMAME publishes no MAME release number.
|
||||
self.assertEqual(gs.emulator_romset(_profile("hbmame")), "")
|
||||
|
||||
def test_a_row_and_a_line_under_the_intro(self):
|
||||
page = _page("mame2003")
|
||||
self.assertIn(f"| Romset | [MAME 0.78]({gs.ROMSET_FAQ}) |", page)
|
||||
self.assertNotIn("| MAME version |", _page("mame2016"))
|
||||
line = page.index("Its BIOS and game sets must come from the MAME 0.78 romset.")
|
||||
self.assertLess(line, page.index("About this profile"))
|
||||
self.assertIn(f"[Which version each core reads]({gs.ROMSET_FAQ})", page)
|
||||
|
||||
def test_the_faq_answer_exists(self):
|
||||
faq = (REPO_ROOT / "wiki" / "technical-faq.md").read_text(encoding="utf-8")
|
||||
self.assertIn("## Which MAME version do the arcade BIOS sets match?", faq)
|
||||
|
||||
def test_no_line_without_bios_zips(self):
|
||||
self.assertNotIn("must come from the", _page("duckstation"))
|
||||
|
||||
|
||||
class WhatTheAppShipsIsNoFileToGet(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.profile = _profile("nethersx2")
|
||||
self.page = _page("nethersx2", self.profile)
|
||||
|
||||
def test_the_badge(self):
|
||||
font = next(f for f in self.profile["files"] if f["name"] == "Roboto-Regular.ttf")
|
||||
badges = " ".join(gs._file_badges(font, False))
|
||||
self.assertIn(">comes with the app</span>", badges)
|
||||
self.assertNotIn(">required</span>", badges)
|
||||
self.assertNotIn(">missing</span>", badges)
|
||||
# Installed by one build only: the other still reads it from the folder.
|
||||
dual = " ".join(gs._file_badges({"name": "a", "bundled": True, "mode": "standalone",
|
||||
"required": True}, False))
|
||||
self.assertIn(">required</span>", dual)
|
||||
self.assertIn("installed with the standalone emulator", dual)
|
||||
|
||||
def test_counts_leave_them_out(self):
|
||||
shipped = sum(1 for f in self.profile["files"] if gs.comes_with_app(f))
|
||||
self.assertGreater(shipped, 0)
|
||||
summary = self.page[self.page.index("## Files"):]
|
||||
self.assertIn(f"{shipped} more files come with the app.", summary)
|
||||
self.assertIn(": 1 required,", summary)
|
||||
# Found by content: one BIOS of any name, said as steps (round 4).
|
||||
self.assertIn("NetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", self.page)
|
||||
|
||||
def test_their_own_section_after_the_others(self):
|
||||
heads = re.findall(r"^## (.+)$", self.page, re.M)
|
||||
self.assertEqual(heads[heads.index("Files") + 1:heads.index("Files") + 3],
|
||||
["Required", "Optional"])
|
||||
self.assertIn("Comes with the app", heads)
|
||||
section = self.page[self.page.index("## Required"):self.page.index("## Optional")]
|
||||
self.assertNotIn("Roboto-Regular.ttf", section)
|
||||
|
||||
def test_the_collection_counts_go_under_the_summary(self):
|
||||
held = self.page.index("<p><small>In this collection:")
|
||||
self.assertLess(held, self.page.index("## Required"))
|
||||
self.assertNotIn("in repo", self.page[self.page.index("## Files"):held])
|
||||
|
||||
def test_the_folder_is_the_apps_own(self):
|
||||
self.assertIn("| BIOS directory | `bios/`, inside the app's own data folder |", self.page)
|
||||
|
||||
|
||||
class TheSummaryTableFitsItsContent(unittest.TestCase):
|
||||
def test_an_empty_region_column_goes(self):
|
||||
rows = gs._summary_table(_files(3, md5="ab" * 16), [0, 1, 2], lambda _i, s: s)
|
||||
self.assertNotIn("Region", rows[0])
|
||||
|
||||
def test_a_constant_required_column_goes(self):
|
||||
rows = gs._summary_table(_files(3, required=True), [0, 1, 2], lambda _i, s: s)
|
||||
self.assertEqual(rows[0], "| File |")
|
||||
|
||||
def test_hashes_are_short_with_the_whole_value_in_the_title(self):
|
||||
md5 = "0123456789abcdef" * 2
|
||||
cell = gs._hash_short({"md5": md5})
|
||||
self.assertEqual(cell, f'MD5 <code title="{md5}">{md5[:12]}</code>')
|
||||
|
||||
def test_the_split_index_holds_each_id_on_its_link(self):
|
||||
files = _files(gs.EMULATOR_CARDS_PER_PAGE + 1, category="bios_zip")
|
||||
main = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
|
||||
"files": files}, {"files": {}})["big"]
|
||||
anchor = gs.emulator_file_anchors(files)[0]
|
||||
self.assertIn(f'<a id="{anchor}" href="/retrobios/emulators/big-files-1/#{anchor}">', main)
|
||||
self.assertNotIn(f'<span id="{anchor}"></span>', main)
|
||||
|
||||
|
||||
class LongPagesFold(unittest.TestCase):
|
||||
def test_each_entry_is_listed_once_as_a_row(self):
|
||||
files = _files(gs.EMULATOR_TABLE_MIN) + [{"name": "req.bin", "required": True}]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
self.assertNotIn("| File |", page)
|
||||
self.assertNotIn(f"<summary>{gs.EMULATOR_TABLE_MIN} files</summary>", page)
|
||||
self.assertEqual(page.count('<h3 id="file-'), len(files))
|
||||
required = page[page.index("## Required"):page.index("## Optional")]
|
||||
self.assertEqual(required.count('<h3 id="file-'), 1)
|
||||
self.assertIn('class="rb-entries"', required)
|
||||
|
||||
def test_a_few_cards_stay_open(self):
|
||||
page = _page("x", {"type": "standalone", "files": _files(2)})
|
||||
self.assertNotIn("<summary>2 files</summary>", page)
|
||||
|
||||
def test_the_profile_card_folds(self):
|
||||
page = _page("duckstation")
|
||||
start = page.index("<summary>About this profile</summary>")
|
||||
self.assertLess(start, page.index('<div class="rb-meta-card">'))
|
||||
self.assertLess(page.index("| Type |"), page.index("</details>", start))
|
||||
|
||||
|
||||
class DataDirectoryPagesStayOutOfSearch(unittest.TestCase):
|
||||
def test_front_matter(self):
|
||||
saved = dict(gs._DATA_MEMBERS), dict(gs._DATA_READERS)
|
||||
self.addCleanup(lambda: (gs._DATA_MEMBERS.clear(), gs._DATA_MEMBERS.update(saved[0]),
|
||||
gs._DATA_READERS.clear(), gs._DATA_READERS.update(saved[1])))
|
||||
gs._DATA_MEMBERS["dolphin-sys"] = {"a.bin": {"sha1": "a" * 40, "size": 1}}
|
||||
gs._DATA_READERS["dolphin-sys"] = [("dolphin-mmjr", "Dolphin MMJR"), ("dolphin", "Dolphin")]
|
||||
page = gs._data_tree_page("dolphin-sys", {}, [("data-dolphin-sys", ["a.bin"])], 0)
|
||||
front = yaml.safe_load(page.split("---\n")[1])
|
||||
self.assertEqual(front["search"], "exclude")
|
||||
self.assertEqual(front["parent"], {"label": "Dolphin", "link": "dolphin.md"})
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "data-dolphin-sys").is_dir(), "site not built")
|
||||
def test_the_built_page_is_not_indexed(self):
|
||||
html = (DIST / "emulators" / "data-dolphin-sys" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertNotIn("data-pagefind-body", html)
|
||||
|
||||
|
||||
class AnElementBudget(unittest.TestCase):
|
||||
def test_validate_site_reports_a_crowded_page(self):
|
||||
self.assertEqual(validate_site.MAX_PAGE_ELEMENTS, 10_000)
|
||||
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
page = Path(tmp) / "crowded" / "index.html"
|
||||
page.parent.mkdir()
|
||||
page.write_text("<html><body>" + "<i></i>" * 10_001
|
||||
+ "<script>" + "<b>" * 5 + "</script></body></html>")
|
||||
issues = validate_site.validate_site(Path(tmp))
|
||||
self.assertTrue(any("elements, over the 10,000 budget" in issue for issue in issues))
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "mame" / "index.html").is_file(), "site not built")
|
||||
def test_mame_is_under_it(self):
|
||||
page = validate_site._parse_page(DIST / "emulators" / "mame" / "index.html")
|
||||
self.assertLess(page.elements, validate_site.MAX_PAGE_ELEMENTS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
"""Entry pages and guides (round 3, package 4).
|
||||
|
||||
A handheld owner found no tile for Android, ArkOS, muOS, Onion or spruceOS:
|
||||
the systems sat in a closed note with a path seen from inside the device.
|
||||
The setup version a pack fits was named only under Reference, the setup page
|
||||
offered the full pack only, Get your files listed every setup twice, the Your
|
||||
setup index ended on a table in project words, and the home page said a
|
||||
Dreamcast needs its BIOS while Flycast boots without one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import siteguides # noqa: E402
|
||||
import sitemarkup # noqa: E402
|
||||
from common import load_platform_config, load_platform_registry # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
|
||||
GUIDE = siteguides.HANDHELD_GUIDE
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
sitepages.require()
|
||||
return sitepages.page(name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _guide_part(text: str) -> str:
|
||||
"""The guide part of a setup page, glossary marks taken off."""
|
||||
match = re.search(r"^## Reference\b", text, re.M)
|
||||
text = text[: match.start()] if match else text
|
||||
return re.sub(r"</?abbr[^>]*>", "", text)
|
||||
|
||||
|
||||
def _display(key: str) -> str:
|
||||
return load_platform_config(key, str(REPO_ROOT / "platforms")).get("platform", key)
|
||||
|
||||
|
||||
class HandheldWayIn(unittest.TestCase):
|
||||
def test_android_and_linux_tiles_on_home_and_get_your_files(self):
|
||||
tiles = siteguides.GROUP_TILES.get("handheld", ())
|
||||
self.assertEqual([tile.name for tile in tiles],
|
||||
["Android", "Linux handhelds", "iPhone and iPad"])
|
||||
for name in ("index.md", "which-pack.md"):
|
||||
text = _page(name)
|
||||
with self.subTest(page=name):
|
||||
self.assertIn('href="/retrobios/platforms/retroarch/#where-the-files-go"', text)
|
||||
self.assertIn('href="/retrobios/platforms/linux-handhelds/"', text)
|
||||
self.assertIn(">Retroid, AYN Odin and other Android handhelds</span>", text)
|
||||
|
||||
def test_the_closed_note_is_gone(self):
|
||||
self.assertNotIn("Other handhelds that run RetroArch", _page("which-pack.md"))
|
||||
|
||||
def test_one_row_per_detected_system(self):
|
||||
detected = [name for name, _folder in siteguides.detected_systems(REGISTRY, GUIDE.uses)]
|
||||
self.assertEqual(sorted((s.name for s in GUIDE.systems), key=str.casefold), detected)
|
||||
text = _page(GUIDE.path)
|
||||
for system in GUIDE.systems:
|
||||
with self.subTest(system=system.name):
|
||||
self.assertIn(f"| {system.name} |", text)
|
||||
|
||||
def test_every_folder_and_device_list_is_cited(self):
|
||||
for system in GUIDE.systems:
|
||||
with self.subTest(system=system.name):
|
||||
if system.folder:
|
||||
self.assertTrue(system.folder_source.startswith("https://"))
|
||||
if system.devices:
|
||||
self.assertTrue(system.devices_source.startswith("https://"))
|
||||
for name in ("ArkOS", "muOS", "Onion", "spruceOS"):
|
||||
system = next(s for s in GUIDE.systems if s.name == name)
|
||||
with self.subTest(system=name):
|
||||
self.assertTrue(system.folder and system.devices)
|
||||
|
||||
def test_a_value_without_its_page_is_refused(self):
|
||||
with self.assertRaises(siteguides.GuideError):
|
||||
siteguides._handheld_system({"name": "Onion", "folder": "`BIOS`"}, "x")
|
||||
with self.assertRaises(siteguides.GuideError):
|
||||
siteguides._handheld_system(
|
||||
{"name": "Onion", "devices": ["Miyoo Mini"], "devices_source": "a blog"}, "x")
|
||||
|
||||
def test_the_guide_rejects_a_missing_or_unknown_system(self):
|
||||
data = {"page": "x", "title": "X", "summary": "S", "uses": "retroarch",
|
||||
"systems": [{"name": "Onion"}]}
|
||||
with self.assertRaises(siteguides.GuideError):
|
||||
siteguides._handheld_guide(data, REGISTRY, siteguides.GUIDES, "x")
|
||||
|
||||
def test_devices_are_a_search_field_with_no_text(self):
|
||||
text = _page(GUIDE.path)
|
||||
match = re.search(r'<span data-pagefind-meta="devices:([^"]*)"></span>', text)
|
||||
self.assertIsNotNone(match)
|
||||
self.assertIn("R36S", match[1])
|
||||
self.assertIn("Miyoo Mini", match[1])
|
||||
self.assertNotIn(",", match[1])
|
||||
# The names typed most often are also on the page itself.
|
||||
self.assertIn("R36S", text.split(match[0], 1)[1])
|
||||
|
||||
def test_the_guide_is_in_the_handheld_group_of_the_navigation(self):
|
||||
coverages = {key: {"platform": _display(key)} for key in REGISTRY}
|
||||
nav = generate_site.generate_nav(coverages, {}, {}, REGISTRY)
|
||||
setup = next(tab["Your setup"] for tab in nav if "Your setup" in tab)
|
||||
groups = {next(iter(item)): next(iter(item.values()))
|
||||
for item in setup if isinstance(item, dict)}
|
||||
label = siteguides.DEVICE_GROUPS["handheld"][0]
|
||||
self.assertIn({GUIDE.title: GUIDE.path}, groups[label])
|
||||
|
||||
def test_the_guide_fills_the_card_and_never_its_root(self):
|
||||
text = _page(GUIDE.path)
|
||||
self.assertIn("--platform retroarch --dest E:\\BIOS", text)
|
||||
self.assertNotRegex(text, r"--dest E:\\\s")
|
||||
|
||||
|
||||
class PackNamesItsVersion(unittest.TestCase):
|
||||
def test_the_line_under_the_release(self):
|
||||
record = release_record.load_record()
|
||||
if not release_record.pack_for("batocera", record):
|
||||
self.skipTest("release.json has no Batocera pack")
|
||||
block = generate_site._pack_buttons("batocera")
|
||||
version = generate_site._pack_version(release_record.pack_name_for("batocera", record))
|
||||
self.assertIn(f"Made for Batocera {version}. "
|
||||
+ generate_site.OTHER_VERSION, block)
|
||||
|
||||
def test_a_version_gap_is_said_once(self):
|
||||
record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
|
||||
"assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
|
||||
released = generate_site.release_line("retrobat", record)
|
||||
line = generate_site.version_line("retrobat", record, named=len(released) > 1)
|
||||
self.assertEqual(line, generate_site.OTHER_VERSION)
|
||||
|
||||
def test_the_v_prefix_is_dropped(self):
|
||||
self.assertEqual(generate_site.setup_version("retroarch", {}),
|
||||
f"RetroArch {load_platform_config('retroarch', 'platforms')['version'].lstrip('v')}")
|
||||
|
||||
def test_every_setup_page_names_a_version(self):
|
||||
"""A setup with a release ZIP names the version it was made for. One
|
||||
without says there is no ZIP: the command installs current files."""
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
text = _guide_part(_page(f"platforms/{key}.md"))
|
||||
if generate_site.has_release_pack(key):
|
||||
self.assertRegex(text, r"(?:[Mm]ade for|This pack was built for) \S")
|
||||
else:
|
||||
self.assertIn(generate_site.no_zip_line(_display(key)), text)
|
||||
|
||||
|
||||
class SetupPagesOfferPartOfThePack(unittest.TestCase):
|
||||
def test_pick_them_and_press_c(self):
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
text = _guide_part(_page(f"platforms/{key}.md"))
|
||||
self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text)
|
||||
# The screen's own row: the line menu's c is not what the
|
||||
# one-liner opens.
|
||||
self.assertIn("or the command's **Choose systems**.", text)
|
||||
# Never "choose **Choose systems**" (tui_audit_17 P3-9).
|
||||
self.assertNotIn("choose **Choose systems**", text)
|
||||
|
||||
def test_the_anchor_exists(self):
|
||||
self.assertIn("## Only some consoles?", _page("which-pack.md"))
|
||||
|
||||
def test_a_file_in_place_but_missing_is_answered(self):
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
self.assertIn("Reported missing anyway?",
|
||||
_guide_part(_page(f"platforms/{key}.md")))
|
||||
|
||||
|
||||
class SafetyListIsLinked(unittest.TestCase):
|
||||
def test_setup_pages_link_the_home_list(self):
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
text = _guide_part(_page(f"platforms/{key}.md"))
|
||||
self.assertIn("[Is it safe?](/retrobios/#is-it-safe)", text)
|
||||
self.assertNotIn("<summary>Is it safe?</summary>", text)
|
||||
self.assertIn("## Is it safe? { #is-it-safe }", _page("index.md"))
|
||||
|
||||
|
||||
class OfflineLine(unittest.TestCase):
|
||||
def test_under_from_your_computer(self):
|
||||
text = _page("platforms/batocera.md")
|
||||
start = text.index("## From your computer")
|
||||
line = text.index("No internet on the device?")
|
||||
self.assertLess(start, line)
|
||||
self.assertLess(line, text.index("## On the device"))
|
||||
self.assertIn("/retrobios/wiki/installer/#without-a-network-offline-install", text)
|
||||
self.assertIn("{ #without-a-network-offline-install }",
|
||||
(REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class GetYourFilesListsEachSetupOnce(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.text = _page("which-pack.md")
|
||||
|
||||
def test_the_tables_are_in_one_closed_block(self):
|
||||
block = re.search(r'<details class="rb-details[^"]*">\s*<summary>Every folder and ZIP '
|
||||
r'in one table</summary>(.*?)</details>', self.text, re.S)
|
||||
self.assertIsNotNone(block)
|
||||
outside = self.text.replace(block[0], "")
|
||||
self.assertNotIn("| Setup | Runs on | BIOS folder | ZIP |", outside)
|
||||
for key in REGISTRY:
|
||||
with self.subTest(platform=key):
|
||||
self.assertEqual(block[1].count(f"| [{_display(key)}](/retrobios/platforms/{key}/) |"), 1)
|
||||
|
||||
def test_single_setup_groups_share_one_table(self):
|
||||
self.assertIn("**Other setups**", self.text)
|
||||
self.assertNotIn("**MiSTer FPGA**", self.text)
|
||||
self.assertNotIn("**Self-hosted game library**", self.text)
|
||||
|
||||
def test_no_unzip_into_without_a_zip(self):
|
||||
record = release_record.load_record()
|
||||
for line in self.text.splitlines():
|
||||
if "Unzip into" in line:
|
||||
key = re.search(r"/retrobios/platforms/([a-z0-9]+)/", line)[1]
|
||||
with self.subTest(platform=key):
|
||||
self.assertTrue(release_record.pack_for(key, record))
|
||||
|
||||
def test_parts_are_buttons_with_the_size_after(self):
|
||||
"""A row links the setup's get/ page, which lists every part, with the
|
||||
download size after the button."""
|
||||
rows = [line for line in self.text.splitlines() if ">ZIP</a>" in line]
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
self.assertRegex(row, r'<a class="rb-button" href="/retrobios/get/[a-z0-9]+/">ZIP</a>')
|
||||
self.assertRegex(row, r"</a> \(\d[\d.]* [KMG]B\) \|$")
|
||||
|
||||
def test_the_zip_note(self):
|
||||
text = re.sub(r"</?abbr[^>]*>", "", self.text)
|
||||
for line in ("A big ZIP comes in parts, like `Pack.part1of2.zip`. Download every part.",
|
||||
"Leave the ZIPs inside, like `neogeo.zip`, as they are.",
|
||||
"Their setup's page says where to unzip them."):
|
||||
self.assertIn(line, text)
|
||||
self.assertNotIn("**Unzip into** means", self.text)
|
||||
|
||||
|
||||
class YourSetupIndexInPlainWords(unittest.TestCase):
|
||||
def test_the_index_links_the_table(self):
|
||||
text = _page("platforms/index.md")
|
||||
self.assertNotIn("| Platform |", text)
|
||||
self.assertNotIn("at a glance", text.split("---", 2)[-1].lower().replace(
|
||||
"every setup at a glance", ""))
|
||||
self.assertIn("/retrobios/wiki/verification-modes/#every-setup-at-a-glance", text)
|
||||
|
||||
def test_the_table_on_verification_modes(self):
|
||||
text = _page("wiki/verification-modes.md")
|
||||
self.assertIn("| Setup | Files on its own list | Files in the ZIP | How it checks |", text)
|
||||
table = text.split("## Every setup at a glance", 1)[1].split("\n## ", 1)[0]
|
||||
self.assertNotIn("existence", table)
|
||||
self.assertIn("| name only |", table)
|
||||
self.assertIn("No list of its own", table)
|
||||
self.assertLess(text.index("## Every setup at a glance"), text.index("## Next steps"))
|
||||
|
||||
def test_insertion_keeps_next_steps_last(self):
|
||||
page = "# T\n\nBody.\n\n## Next steps\n\n- [A](a.md)\n"
|
||||
out = generate_site.with_setup_table(page, {}, {})
|
||||
self.assertTrue(out.rstrip().endswith("- [A](a.md)"))
|
||||
self.assertIn("## Every setup at a glance", out)
|
||||
|
||||
|
||||
class HomeCopy(unittest.TestCase):
|
||||
def test_dreamcast_and_the_check_link(self):
|
||||
home = re.sub(r"</?abbr[^>]*>", "", _page("index.md"))
|
||||
self.assertIn("- **Yes** for PlayStation 1 and 2, Saturn and Neo Geo.", home)
|
||||
self.assertIn("- **Better with it** for Dreamcast: some games need it.", home)
|
||||
# A question and the link that answers it: no level label, which
|
||||
# read as a broken crumb beside the Reference tab (round 4).
|
||||
self.assertNotIn("Reference:", home.split("\n---\n", 1)[-1])
|
||||
self.assertIn("- Have a file? [Check it]", home)
|
||||
self.assertIn("BIOS missing, even with the file there?", home)
|
||||
|
||||
def test_flycast_boots_without_the_dreamcast_bios(self):
|
||||
import yaml
|
||||
|
||||
profile = yaml.safe_load((REPO_ROOT / "emulators" / "flycast.yml").read_text(encoding="utf-8"))
|
||||
boot = next(f for f in profile["files"] if f.get("name") == "dc_boot.bin")
|
||||
self.assertFalse(boot.get("required"))
|
||||
self.assertTrue(boot.get("hle_fallback"))
|
||||
|
||||
|
||||
class TileLogosLoadWhenSeen(unittest.TestCase):
|
||||
def test_the_helper(self):
|
||||
tiles = [(f"S{i}", "x.md", f"{i}.png", "here") for i in range(4)]
|
||||
html = "".join(sitemarkup.platform_tiles(tiles, eager=2))
|
||||
logos = re.findall(r"<img [^>]*>", html)
|
||||
self.assertEqual(['loading="lazy"' in logo for logo in logos], [False, False, True, True])
|
||||
self.assertNotIn("loading=", "".join(sitemarkup.platform_tiles(tiles[:1], eager=1)))
|
||||
|
||||
def test_only_the_first_row_is_eager_on_entry_pages(self):
|
||||
for name in ("index.md", "which-pack.md"):
|
||||
logos = re.findall(r'<img class="rb-card-logo"[^>]*>', _page(name))
|
||||
with self.subTest(page=name):
|
||||
self.assertGreater(len(logos), sitemarkup.FIRST_ROW_TILES)
|
||||
eager = [logo for logo in logos if 'loading="lazy"' not in logo]
|
||||
self.assertEqual(eager, logos[: sitemarkup.FIRST_ROW_TILES])
|
||||
|
||||
|
||||
class DownloadLine(unittest.TestCase):
|
||||
def test_checksums_and_signature_read_as_two(self):
|
||||
record = release_record.load_record()
|
||||
if not release_record.pack_for("batocera", record):
|
||||
self.skipTest("release.json has no Batocera pack")
|
||||
line = generate_site.release_line("batocera", record)[0]
|
||||
self.assertRegex(line, r"\[SHA256SUMS\.txt\]\([^)]+\) and its \[signature\]\([^)]+\)\.$")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,252 +0,0 @@
|
||||
"""Navigation and search (round 3, package 3).
|
||||
|
||||
"playstation bios" found five setups' lists for the console before its own
|
||||
page: the pages a setup's list moves a console to weighed 1.5, three times a
|
||||
reference page. The file names a system page holds were the text of a hidden
|
||||
span, so the search showed them as the excerpt. Start here looped: First
|
||||
install's "Next steps" pointed back to Get your files, Is it legal? to Common
|
||||
questions, and both pages had the same page as previous and next. A part
|
||||
page had no crumb to the page it belongs to and nothing marked in the
|
||||
sidebar. "psx" or "dc" found nothing, though every platform files the
|
||||
console under that name. These tests read the generator and, where they
|
||||
are built, the pages and the site.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import sitestarlight # noqa: E402
|
||||
from common import list_registered_platforms # noqa: E402
|
||||
|
||||
SEARCH_GATE = REPO_ROOT / "website" / "tools" / "search_gate.py"
|
||||
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
ROUTE = REPO_ROOT / "website" / "src" / "routeData.ts"
|
||||
CONTENT_CONFIG = REPO_ROOT / "website" / "src" / "content.config.ts"
|
||||
START_HERE = [
|
||||
"/retrobios/", "/retrobios/wiki/what-are-bios-files/", "/retrobios/which-pack/",
|
||||
"/retrobios/wiki/first-install/", "/retrobios/wiki/troubleshooting/",
|
||||
"/retrobios/wiki/faq/", "/retrobios/wiki/legal/", "/retrobios/wiki/glossary/",
|
||||
"/retrobios/platforms/",
|
||||
]
|
||||
|
||||
|
||||
def _front(text: str) -> str:
|
||||
end = text.find("\n---\n", 4)
|
||||
return text[4:end] if text.startswith("---\n") and end != -1 else ""
|
||||
|
||||
|
||||
def _moved_platform_pages() -> list[Path]:
|
||||
platforms = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||
folder = DOCS / "platforms"
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
# A guide page sits beside the setups (platforms/linux-handhelds) without
|
||||
# being a list moved off one of them.
|
||||
from siteguides import HANDHELD_GUIDE # noqa: PLC0415
|
||||
|
||||
guides = {Path(HANDHELD_GUIDE.path).stem} if HANDHELD_GUIDE else set()
|
||||
return [path for path in sorted(folder.glob("*.md*"))
|
||||
if path.stem not in platforms and path.stem != "index" and path.stem not in guides]
|
||||
|
||||
|
||||
class MetaFields(unittest.TestCase):
|
||||
def test_file_names_are_an_attribute_not_text(self) -> None:
|
||||
body = ('<h3 class="rb-file-title"><code>scph5501.bin</code></h3>'
|
||||
'<h3 class="rb-file-title"><code>a,b.bin</code></h3>')
|
||||
meta = sitestarlight.file_names_meta(body)
|
||||
# Each name as its parts, each part once (round 5, 3.2).
|
||||
self.assertIn('data-pagefind-meta="files:a b bin scph5501"', meta)
|
||||
self.assertRegex(meta, r"<span [^>]*hidden></span>")
|
||||
|
||||
def test_a_field_escapes_its_value(self) -> None:
|
||||
meta = sitestarlight.search_field("aliases", ['a"b', "c&d"])
|
||||
self.assertIn('data-pagefind-meta="aliases:a"b c&d"', meta)
|
||||
|
||||
def test_an_empty_field_writes_nothing(self) -> None:
|
||||
self.assertEqual(sitestarlight.search_field("aliases", []), "")
|
||||
self.assertEqual(sitestarlight.file_names_meta("<p>no card</p>"), "")
|
||||
|
||||
def test_system_pages_carry_the_platforms_names(self) -> None:
|
||||
page = DOCS / "systems" / "sony-playstation.md"
|
||||
if not page.is_file():
|
||||
self.skipTest("site pages not generated")
|
||||
text = page.read_text(encoding="utf-8")
|
||||
match = re.search(r'data-pagefind-meta="aliases:([^"]*)"', text)
|
||||
self.assertIsNotNone(match, "no aliases field on the PlayStation page")
|
||||
self.assertIn("psx", unescape(match[1]).split())
|
||||
# The Dreamcast section's page: its own, or Sega's when it fits there.
|
||||
own = DOCS / "systems" / "sega-dreamcast.md"
|
||||
dreamcast = (own if own.is_file() else DOCS / "systems" / "sega.md").read_text(
|
||||
encoding="utf-8")
|
||||
self.assertRegex(dreamcast, r'data-pagefind-meta="aliases:[^"]*\bdc\b')
|
||||
|
||||
|
||||
class SystemPartsSitUnderTheirMaker(unittest.TestCase):
|
||||
"""The crumbs say where a system part sits, read from its parent: field.
|
||||
The "Part of" line under them said it again (round 5, 6.9)."""
|
||||
|
||||
def test_every_part_names_its_maker_as_parent(self) -> None:
|
||||
pages = sorted((DOCS / "systems").glob("*.md"))
|
||||
if not pages:
|
||||
self.skipTest("site pages not generated")
|
||||
makers = {page.stem for page in pages}
|
||||
orphans = []
|
||||
for page in pages:
|
||||
text = page.read_text(encoding="utf-8")
|
||||
front = text.split("\n---\n", 1)[0]
|
||||
parent = re.search(r"^parent:\n label: .+\n link: /retrobios/systems/([a-z0-9-]+)/$",
|
||||
front, re.M)
|
||||
if parent is None:
|
||||
continue
|
||||
if parent[1] not in makers:
|
||||
orphans.append(page.name)
|
||||
self.assertNotIn("\nPart of [", text, page.name)
|
||||
self.assertEqual(orphans, [])
|
||||
|
||||
|
||||
class MovedPlatformPages(unittest.TestCase):
|
||||
def test_a_setups_list_for_one_console_is_a_reference(self) -> None:
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
block = source[source.index("for stem, page in moved.items():"):]
|
||||
block = block[: block.index("PAGE_DATA[f\"platforms/{name}.md\"]")]
|
||||
self.assertIn('SEARCH_DEMOTED.add(f"platforms/{stem}.md")', block)
|
||||
self.assertIn('level="reference"', block)
|
||||
self.assertIn('search="exclude"', block)
|
||||
self.assertIn("parent=", block)
|
||||
|
||||
def test_generated_pages_weigh_as_references_and_name_their_setup(self) -> None:
|
||||
pages = _moved_platform_pages()
|
||||
if not pages:
|
||||
self.skipTest("site pages not generated")
|
||||
for path in pages:
|
||||
front = _front(path.read_text(encoding="utf-8"))
|
||||
with self.subTest(page=path.name):
|
||||
self.assertIn("\nlevel: reference\n", f"\n{front}\n")
|
||||
self.assertIn("\nsearchWeight: 0.5\n", f"\n{front}\n")
|
||||
# The title alone was indexed, and it outranked the console.
|
||||
self.assertIn("\npagefind: false\n", f"\n{front}\n")
|
||||
self.assertRegex(front, r"parent:\n label: .+\n link: /retrobios/platforms/[a-z0-9_-]+/\n")
|
||||
|
||||
def test_parent_link_resolves_against_the_page(self) -> None:
|
||||
_target, text = sitestarlight.finalize_page(
|
||||
"platforms/retroarch-sony-playstation.md", "# RetroArch: Sony PlayStation\n\nBody.\n",
|
||||
level="reference", weight=0.5, browser_title="x", description="d",
|
||||
structured={}, terms={}, parent={"label": "RetroArch", "link": "retroarch.md"},
|
||||
)
|
||||
self.assertIn("parent:\n label: RetroArch\n link: /retrobios/platforms/retroarch/\n", text)
|
||||
|
||||
def test_the_schema_accepts_parent(self) -> None:
|
||||
self.assertRegex(CONTENT_CONFIG.read_text(encoding="utf-8"),
|
||||
r"parent: z\.object\(\{ label: z\.string\(\), link: z\.string\(\) \}\)")
|
||||
|
||||
|
||||
class ArcadeGroup(unittest.TestCase):
|
||||
def test_arcade_is_not_a_console_or_computer(self) -> None:
|
||||
nav = generate_site._system_nav({"Sony": {}, "Arcade": {}, "Sega": {}})
|
||||
groups = {next(iter(entry)): entry[next(iter(entry))] for entry in nav if isinstance(entry, dict)}
|
||||
self.assertEqual(groups["Arcade"], [{"Arcade": "systems/arcade.md"}])
|
||||
self.assertNotIn({"Arcade": "systems/arcade.md"}, groups["Consoles and computers"])
|
||||
|
||||
|
||||
class RouteMiddleware(unittest.TestCase):
|
||||
def test_next_steps_link_is_kept_only_forward(self) -> None:
|
||||
source = ROUTE.read_text(encoding="utf-8")
|
||||
self.assertIn("target <= position", source)
|
||||
|
||||
def test_parent_is_a_crumb_and_marked(self) -> None:
|
||||
source = ROUTE.read_text(encoding="utf-8")
|
||||
self.assertIn("route.entry.data.parent", source)
|
||||
self.assertIn("markCurrent(route.sidebar", source)
|
||||
|
||||
|
||||
def _gate_queries() -> dict:
|
||||
"""The search gate's QUERIES, read from its source: the tool is run
|
||||
with node beside it, not imported as a package."""
|
||||
tree = ast.parse(SEARCH_GATE.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "QUERIES":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("no QUERIES in search_gate.py")
|
||||
|
||||
|
||||
class SearchGateQueries(unittest.TestCase):
|
||||
def test_the_audits_queries_are_gated(self) -> None:
|
||||
for query in ("playstation bios", "dreamcast bios", "neo geo bios", "bios not found",
|
||||
"offline install", "r36s", "wonderswan color", "dc bios missing",
|
||||
"which mame version", "psx bios"):
|
||||
self.assertIn(query, _gate_queries())
|
||||
|
||||
def test_prefixes_match_a_consoles_own_page(self) -> None:
|
||||
pages, _rank = _gate_queries()["playstation bios"]
|
||||
self.assertTrue(any("systems/sony-playstation/".startswith(p) for p in pages))
|
||||
|
||||
|
||||
def _html(url: str) -> str:
|
||||
rel = url[len("/retrobios/"):]
|
||||
path = DIST / rel / "index.html"
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
|
||||
def _pagination(text: str) -> tuple[str | None, str | None]:
|
||||
start = text.find('<div class="pagination-links')
|
||||
if start == -1:
|
||||
return None, None
|
||||
links = text[start:text.find("</footer>", start)]
|
||||
prev = re.search(r'<a href="([^"]+)"[^>]*rel="prev"', links)
|
||||
nxt = re.search(r'<a href="([^"]+)"[^>]*rel="next"', links)
|
||||
return (prev[1] if prev else None), (nxt[1] if nxt else None)
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
|
||||
class BuiltSite(unittest.TestCase):
|
||||
def test_next_from_home_reads_start_here_once(self) -> None:
|
||||
seen, url = [], "/retrobios/"
|
||||
while url and url not in seen and len(seen) < 20:
|
||||
seen.append(url)
|
||||
url = _pagination(_html(url))[1]
|
||||
self.assertEqual(seen[: len(START_HERE)], START_HERE)
|
||||
self.assertEqual(len(seen), len(set(seen)))
|
||||
|
||||
def test_no_page_has_the_same_previous_and_next(self) -> None:
|
||||
same = []
|
||||
for path in DIST.rglob("index.html"):
|
||||
prev, nxt = _pagination(path.read_text(encoding="utf-8"))
|
||||
if prev and prev == nxt:
|
||||
same.append(str(path.relative_to(DIST)))
|
||||
self.assertEqual(same, [])
|
||||
|
||||
def test_search_fields_hold_no_text(self) -> None:
|
||||
texts = []
|
||||
for path in (DIST / "systems").rglob("index.html"):
|
||||
# The search fields are attributes on empty spans. The title
|
||||
# meta reads the visible h1 since round 4 item 2.4.
|
||||
for match in re.finditer(r'<(\w+)[^>]*data-pagefind-meta="(?:files|aliases|devices|consoles|terms):'
|
||||
r'[^>]*>([^<]*)</\1>',
|
||||
path.read_text(encoding="utf-8")):
|
||||
if match[2].strip():
|
||||
texts.append(str(path.relative_to(DIST)))
|
||||
self.assertEqual(texts, [])
|
||||
|
||||
def test_a_setups_console_page_names_its_setup(self) -> None:
|
||||
text = _html("/retrobios/platforms/retroarch-sony-playstation/")
|
||||
if not text:
|
||||
self.skipTest("no RetroArch PlayStation page")
|
||||
crumbs = re.search(r'<nav[^>]*class="[^"]*rb-breadcrumbs[^"]*"[^>]*>(.*?)</nav>', text, re.S)
|
||||
self.assertIsNotNone(crumbs)
|
||||
self.assertIn('href="/retrobios/platforms/retroarch/"', crumbs[1])
|
||||
self.assertRegex(text, r'<a href="/retrobios/platforms/retroarch/"[^>]*aria-current="page"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,505 +0,0 @@
|
||||
"""Reference pages and data (round 3, package 8).
|
||||
|
||||
The CSV and SQLite dropped zipped_file, so a check built from them judged
|
||||
106 good Batocera files wrong. The manifest a pack installs was nowhere on
|
||||
the site. Contracts failed their own schema, 716 documents lacked `commit`,
|
||||
no endpoint answered a system id or a buildbot core name, lookup URLs
|
||||
resolved to 404s, the data's licence went unstated, platform overviews
|
||||
named emulators as unlinked ids, gaps tables overflowed with full hashes
|
||||
and a source link went into a repository that does not hold the file.
|
||||
Tests that read the built site skip when it has not been generated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import check_platform_folder # noqa: E402
|
||||
import generate_site # noqa: E402
|
||||
import sitesources # noqa: E402
|
||||
|
||||
PUBLIC = REPO_ROOT / "website" / "public"
|
||||
API = PUBLIC / "api" / "v1"
|
||||
SITE = "https://abdess.github.io/retrobios/"
|
||||
TMP = REPO_ROOT / "tmp"
|
||||
|
||||
|
||||
def _built(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
raise unittest.SkipTest(f"{path.relative_to(REPO_ROOT)} not generated")
|
||||
return path
|
||||
|
||||
|
||||
def _verify_status(entry: dict, path: Path, mode: str) -> str:
|
||||
"""What verify.py says of one entry, the platform's native logic."""
|
||||
from verify import verify_entry_existence, verify_entry_md5, verify_entry_sha1
|
||||
|
||||
local = str(path) if path.is_file() else None
|
||||
if mode == "existence":
|
||||
return verify_entry_existence(entry, local)["status"]
|
||||
if mode == "sha1":
|
||||
return verify_entry_sha1(entry, local)["status"]
|
||||
return verify_entry_md5(entry, local)["status"]
|
||||
|
||||
|
||||
def _as_snippet(status: str) -> str:
|
||||
return {"ok": "ok", "untested": "wrong", "missing": "missing"}[status]
|
||||
|
||||
|
||||
class TheExportsKeepWhatACheckNeeds(unittest.TestCase):
|
||||
"""8.1: zipped_file and the Recalbox flags reach the CSV and SQLite."""
|
||||
|
||||
COVERAGE = {"batocera": {
|
||||
"platform": "Batocera", "details": [],
|
||||
"config": {"platform": "Batocera", "verification_mode": "md5",
|
||||
"install": {"detect": []}, "logo": "x.svg", "_key": "batocera",
|
||||
"systems": {"bbc": {"files": [
|
||||
{"name": "bbcb.zip", "destination": "bbcb.zip",
|
||||
"md5": "521a197d", "zipped_file": "basic1.rom"},
|
||||
{"name": "n.rom", "destination": "n.rom", "md5": "ab",
|
||||
"hash_match_mandatory": True, "mandatory_declared": False,
|
||||
"shared_group": "group-x"},
|
||||
]}}},
|
||||
}}
|
||||
|
||||
def test_rows_carry_the_member_and_the_flags(self):
|
||||
_items, rows = generate_site._platform_export_rows(self.COVERAGE)
|
||||
zipped, flagged = rows
|
||||
self.assertEqual(zipped["zipped_file"], "basic1.rom")
|
||||
self.assertIs(flagged["hash_match_mandatory"], True)
|
||||
self.assertIs(flagged["mandatory_declared"], False)
|
||||
self.assertEqual(flagged["shared_group"], "group-x")
|
||||
self.assertEqual(flagged["zipped_file"], "")
|
||||
|
||||
def test_the_csv_header_and_the_table_name_them(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn('"collection_path", "resolved_sha1", *ENTRY_EXPORT_FIELDS,', source)
|
||||
for field in generate_site.ENTRY_EXPORT_FIELDS:
|
||||
self.assertIn(f":{field}", source)
|
||||
for field in ("zipped_file", "hash_match_mandatory", "mandatory_declared", "curated"):
|
||||
self.assertIn(field, generate_site.ENTRY_EXPORT_FIELDS)
|
||||
|
||||
def test_the_built_csv_has_the_column(self):
|
||||
text = _built(PUBLIC / "downloads" / "platform-files.csv").read_text(encoding="utf-8")
|
||||
rows = list(csv.DictReader(io.StringIO(text)))
|
||||
batocera = [row for row in rows if row["platform_id"] == "batocera"]
|
||||
self.assertGreater(sum(1 for row in batocera if row["zipped_file"]), 300)
|
||||
with sqlite3.connect(_built(PUBLIC / "downloads" / "retrobios.sqlite")) as db:
|
||||
count = db.execute("SELECT count(*) FROM platform_files "
|
||||
"WHERE platform_id = 'batocera' AND zipped_file != ''").fetchone()[0]
|
||||
license_row = db.execute("SELECT value FROM metadata WHERE key = 'license'").fetchone()
|
||||
self.assertGreater(count, 300)
|
||||
self.assertEqual(license_row[0], generate_site.DATA_LICENSE)
|
||||
|
||||
|
||||
class AFolderCheckAgreesWithTheVerifier(unittest.TestCase):
|
||||
"""8.1 and 8.3: the published example gives verify.py's verdicts."""
|
||||
|
||||
def _folder(self, tmp: Path) -> tuple[dict, Path]:
|
||||
member = b"basic rom bytes"
|
||||
root = tmp / "bios"
|
||||
root.mkdir()
|
||||
with zipfile.ZipFile(root / "bbcb.zip", "w") as archive:
|
||||
archive.writestr("BASIC1.ROM", member)
|
||||
(root / "plain.bin").write_bytes(b"plain")
|
||||
(root / "wrong.bin").write_bytes(b"not it")
|
||||
(root / "nohash.bin").write_bytes(b"x")
|
||||
md5 = lambda data: hashlib.md5(data).hexdigest() # noqa: E731
|
||||
contract = {"verification_mode": "md5", "systems": {"s": {"files": [
|
||||
{"name": "bbcb.zip", "destination": "bbcb.zip", "md5": md5(member),
|
||||
"zipped_file": "basic1.rom"},
|
||||
{"name": "bbcb.zip", "destination": "bbcb.zip", "md5": "0" * 32,
|
||||
"zipped_file": "basic1.rom"},
|
||||
{"name": "plain.bin", "destination": "plain.bin", "md5": md5(b"plain")[:29]},
|
||||
{"name": "wrong.bin", "destination": "wrong.bin", "md5": md5(b"plain")},
|
||||
{"name": "nohash.bin", "destination": "nohash.bin"},
|
||||
{"name": "gone.bin", "destination": "gone.bin", "md5": "1" * 32},
|
||||
]}}}
|
||||
return contract, root
|
||||
|
||||
def test_each_rule_on_a_sample_folder(self):
|
||||
with tempfile.TemporaryDirectory(dir=TMP) as tmp:
|
||||
contract, root = self._folder(Path(tmp))
|
||||
platforms = Path(tmp) / "platforms"
|
||||
platforms.mkdir()
|
||||
(platforms / "sample.json").write_text(
|
||||
json.dumps({"items": [{"contract": contract}]}), encoding="utf-8")
|
||||
found = check_platform_folder.check("sample", root, platforms.as_uri() + "/")
|
||||
expected = [
|
||||
(entry["destination"], _as_snippet(_verify_status(entry, root / entry["destination"], "md5")))
|
||||
for entry in contract["systems"]["s"]["files"]
|
||||
]
|
||||
self.assertEqual(found, expected)
|
||||
self.assertEqual([v for _d, v in found], ["ok", "wrong", "ok", "wrong", "ok", "missing"])
|
||||
|
||||
def test_batocera_from_the_csv_matches_the_verifier(self):
|
||||
"""A check driven by platform-files.csv, with zipped_file, gives the
|
||||
verifier's verdict on every Batocera entry of a folder filled from
|
||||
the collection."""
|
||||
text = _built(PUBLIC / "downloads" / "platform-files.csv").read_text(encoding="utf-8")
|
||||
rows = [row for row in csv.DictReader(io.StringIO(text)) if row["platform_id"] == "batocera"]
|
||||
with tempfile.TemporaryDirectory(dir=TMP) as tmp:
|
||||
root = Path(tmp)
|
||||
for row in rows:
|
||||
source = REPO_ROOT / row["collection_path"] if row["collection_path"] else None
|
||||
target = root / row["destination"]
|
||||
if source and source.is_file() and not target.exists():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.link(source, target)
|
||||
except OSError:
|
||||
shutil.copyfile(source, target)
|
||||
held = [row for row in rows if (root / row["destination"]).is_file()]
|
||||
if len(held) < 100:
|
||||
raise unittest.SkipTest("the collection files are not in this checkout")
|
||||
# The verifier reads the platform's own entries, the check the CSV.
|
||||
contract = json.loads(_built(API / "platforms" / "batocera.json").read_text())[
|
||||
"items"][0]["contract"]
|
||||
declared = [entry for _sys, system in sorted(contract["systems"].items())
|
||||
for entry in system.get("files") or []]
|
||||
self.assertEqual([e["name"] for e in declared], [row["name"] for row in rows])
|
||||
mismatched = []
|
||||
zipped_ok = 0
|
||||
for row, platform_entry in zip(rows, declared):
|
||||
entry = {key: value for key, value in row.items() if value}
|
||||
path = root / row["destination"]
|
||||
ours = "ok" if check_platform_folder.accepts(entry, path, "md5") else (
|
||||
"wrong" if path.is_file() else "missing")
|
||||
theirs = _as_snippet(_verify_status(platform_entry, path, "md5"))
|
||||
if ours != theirs:
|
||||
mismatched.append((row["destination"], row.get("zipped_file"), ours, theirs))
|
||||
zipped_ok += ours == "ok" and bool(row["zipped_file"])
|
||||
self.assertEqual(mismatched, [])
|
||||
self.assertGreater(zipped_ok, 100)
|
||||
|
||||
def test_the_data_page_shows_the_script_itself(self):
|
||||
script = (REPO_ROOT / "scripts" / "check_platform_folder.py").read_text(encoding="utf-8")
|
||||
page = generate_site.generate_data_page([])
|
||||
self.assertIn("```python\n" + script.rstrip("\n") + "\n```", page)
|
||||
self.assertLess(len(script.splitlines()), 70)
|
||||
self.assertIn("## Check a folder for one platform", page)
|
||||
|
||||
|
||||
class TheContractsValidate(unittest.TestCase):
|
||||
"""8.4: registry keys leave the contract, every $id is its served URL."""
|
||||
|
||||
def test_every_contract_validates_against_the_platform_schema(self):
|
||||
try:
|
||||
from jsonschema import Draft202012Validator # noqa: PLC0415
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema is a tooling dependency")
|
||||
|
||||
from common import list_registered_platforms, load_platform_config # noqa: PLC0415
|
||||
|
||||
schema = json.loads((REPO_ROOT / "schemas" / "platform.schema.json").read_text())
|
||||
validator = Draft202012Validator(schema)
|
||||
platforms = str(REPO_ROOT / "platforms")
|
||||
for name in list_registered_platforms(platforms, include_archived=True):
|
||||
config = load_platform_config(name, platforms)
|
||||
contract, registry = generate_site._split_contract(config)
|
||||
with self.subTest(platform=name):
|
||||
errors = [error.message for error in validator.iter_errors(contract)]
|
||||
self.assertEqual(errors, [])
|
||||
self.assertIn("install", registry)
|
||||
self.assertNotIn("_key", registry)
|
||||
self.assertEqual(set(contract) | set(registry) | {"_key"}, set(config) | {"_key"})
|
||||
|
||||
def test_every_schema_id_is_the_address_it_is_served_at(self):
|
||||
for path in sorted((REPO_ROOT / "schemas").glob("*.schema.json")):
|
||||
schema = json.loads(path.read_text())
|
||||
with self.subTest(schema=path.name):
|
||||
self.assertEqual(schema.get("$id"), f"{SITE}api/v1/schemas/{path.name}")
|
||||
|
||||
def test_every_schema_is_published(self):
|
||||
published = {p.name for p in _built(API / "schemas").glob("*.json")}
|
||||
self.assertEqual(published, {p.name for p in (REPO_ROOT / "schemas").glob("*.schema.json")})
|
||||
|
||||
def test_the_built_api_validates(self):
|
||||
try:
|
||||
from jsonschema import Draft202012Validator # noqa: PLC0415
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema is a tooling dependency")
|
||||
|
||||
schemas = {p.name: json.loads(p.read_text()) for p in (REPO_ROOT / "schemas").glob("*.json")}
|
||||
envelope = Draft202012Validator(schemas["site-api-envelope.schema.json"])
|
||||
platform = Draft202012Validator(schemas["platform.schema.json"])
|
||||
document = json.loads(_built(API / "platforms.json").read_text())
|
||||
self.assertEqual(list(envelope.iter_errors(document)), [])
|
||||
for item in document["items"]:
|
||||
with self.subTest(platform=item["id"]):
|
||||
self.assertEqual([e.message for e in platform.iter_errors(item["contract"])], [])
|
||||
for name in ("systems.json", "cores.json", "index.json", "catalog.json"):
|
||||
with self.subTest(document=name):
|
||||
self.assertEqual(
|
||||
[e.message for e in envelope.iter_errors(json.loads((API / name).read_text()))], [])
|
||||
|
||||
|
||||
class EveryEnvelopeCarriesTheCommit(unittest.TestCase):
|
||||
"""8.5: emulators/ and systems/ lacked `commit` (716 documents)."""
|
||||
|
||||
def test_the_writers_pass_it(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn('str(db.get("generated_at") or _timestamp()), _source_commit(),\n )\n'
|
||||
" clash", source)
|
||||
block = source[source.index("public_profiles = {"):source.index("pages = emulator_pages(")]
|
||||
self.assertIn("write_single_item(", block)
|
||||
self.assertIn("_source_commit()", block)
|
||||
|
||||
def test_every_built_envelope_has_it(self):
|
||||
_built(API / "catalog.json")
|
||||
if not generate_site._source_commit():
|
||||
raise unittest.SkipTest("not a git checkout")
|
||||
lacking = [
|
||||
path.relative_to(API).as_posix() for path in sorted(API.rglob("*.json"))
|
||||
if path.parent.name not in ("schemas", "manifests", "targets", "exports")
|
||||
and path.name not in ("database.json", "stats.json", "release.json")
|
||||
and "commit" not in json.loads(path.read_text())
|
||||
]
|
||||
self.assertEqual(lacking, [])
|
||||
|
||||
|
||||
class ManifestsArePublished(unittest.TestCase):
|
||||
"""8.2: the manifest a pack installs, byte for byte, in the catalog."""
|
||||
|
||||
def test_each_manifest_is_the_repository_file(self):
|
||||
catalog = json.loads(_built(API / "catalog.json").read_text())
|
||||
listed = {item["url"]: item for item in catalog["items"]}
|
||||
for source in sorted((REPO_ROOT / "install").glob("*.json")):
|
||||
with self.subTest(manifest=source.name):
|
||||
item = listed[f"manifests/{source.name}"]
|
||||
self.assertEqual(item["sha256"], hashlib.sha256(source.read_bytes()).hexdigest())
|
||||
self.assertEqual(item["schema"], "schemas/install-manifest.schema.json")
|
||||
self.assertIn("release.json", listed)
|
||||
self.assertEqual(catalog["license"], generate_site.DATA_LICENSE)
|
||||
|
||||
def test_the_data_page_explains_the_fields_from_the_schema(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
schema = json.loads((REPO_ROOT / "schemas" / "install-manifest.schema.json").read_text())
|
||||
for field in ("dest", "repo_path", "release_asset", "cores"):
|
||||
description = schema["$defs"]["file"]["properties"][field]["description"]
|
||||
self.assertIn(f"| `{field}` | {generate_site._escape_tags(description)} |", page)
|
||||
self.assertIn("## Platform manifests", page)
|
||||
self.assertIn("**Licence:**", page)
|
||||
self.assertIn("## Hosting, rate limits and caching", page)
|
||||
# The endpoints first, the DAT below them.
|
||||
self.assertLess(page.index("## Stable JSON endpoints"), page.index("The DAT is Logiqx XML"))
|
||||
|
||||
|
||||
class AnIdLeadsToItsData(unittest.TestCase):
|
||||
"""8.6: a system by its id, a core by the name the buildbot gives it."""
|
||||
|
||||
def test_a_buildbot_name_designates_its_profile(self):
|
||||
profiles = {
|
||||
"beetle_psx": {"type": "libretro", "cores": ["mednafen_psx", "mednafen_psx_hw"]},
|
||||
"pcsx2": {"type": "standalone"},
|
||||
"lrps2": {"type": "libretro", "cores": ["pcsx2"]},
|
||||
"mednafen_psx_alias": {"type": "alias", "alias_of": "beetle_psx"},
|
||||
"probe": {"type": "test", "cores": ["probe_core"]},
|
||||
}
|
||||
items = generate_site.core_index_items(profiles)
|
||||
by_name: dict[str, list[tuple[str, bool]]] = {}
|
||||
for item in items:
|
||||
by_name.setdefault(item["name"], []).append((item["profile"], item["preferred"]))
|
||||
self.assertEqual(by_name["mednafen_psx"], [("beetle_psx", True)])
|
||||
self.assertEqual(by_name["pcsx2"], [("pcsx2", True), ("lrps2", False)])
|
||||
self.assertEqual(by_name["mednafen_psx_alias"], [("beetle_psx", True)])
|
||||
self.assertNotIn("probe_core", by_name)
|
||||
|
||||
def test_the_built_index_resolves_ids_to_sections(self):
|
||||
items = {item["id"]: item for item in
|
||||
json.loads(_built(API / "systems.json").read_text())["items"]}
|
||||
color = items["wonderswan-color"]
|
||||
self.assertEqual(color["name"], "Bandai WonderSwan Color")
|
||||
self.assertTrue(color["url"].startswith("/retrobios/systems/bandai/"), color["url"])
|
||||
self.assertTrue(items["sony-playstation"]["sha1"])
|
||||
cores = json.loads((API / "cores.json").read_text())["items"]
|
||||
self.assertIn({"name": "mednafen_psx", "profile": "beetle_psx", "preferred": True}, cores)
|
||||
|
||||
|
||||
class LookupUrlsResolve(unittest.TestCase):
|
||||
"""8.7: a record's url, joined to the shard holding it, is a page."""
|
||||
|
||||
def test_site_absolute(self):
|
||||
db = {"files": {"ab" * 20: {"path": "bios/Sony/PlayStation/scph5501.bin"}}}
|
||||
[item] = generate_site.file_location_items(db)
|
||||
self.assertTrue(item["url"].startswith("/retrobios/systems/"), item["url"])
|
||||
shard = SITE + "api/v1/lookup/ab.json"
|
||||
self.assertTrue(urllib.parse.urljoin(shard, item["url"]).startswith(SITE + "systems/"))
|
||||
|
||||
def test_each_record_names_its_system_as_the_pages_do(self):
|
||||
"""Find a file opens on "a BIOS file for Sony PlayStation": the name
|
||||
comes from system_name, never built in the browser."""
|
||||
from siterender import system_name # noqa: PLC0415
|
||||
|
||||
db = {"files": {"ab" * 20: {"path": "bios/Sony/PlayStation/scph5501.bin",
|
||||
"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246"}}}
|
||||
coverages = {"batocera": {"platform": "Batocera", "config": {"systems": {
|
||||
"sony-playstation": {"files": [{"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246"}]}}}}}
|
||||
profiles = {"duckstation": {"systems": ["sony-playstation"], "files": [
|
||||
{"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246"}]}}
|
||||
records = generate_site.file_lookup_records(
|
||||
db, coverages, profiles, generate_site.file_location_items(db))
|
||||
expected = system_name("sony-playstation")
|
||||
by_layer = {r["layer"]: r for r in records if r["algorithm"] == "md5"}
|
||||
for layer in ("collection", "platform", "emulator"):
|
||||
with self.subTest(layer=layer):
|
||||
self.assertEqual(by_layer[layer]["system_name"], expected)
|
||||
|
||||
def test_built_shards_carry_the_name(self):
|
||||
shard = json.loads(_built(API / "lookup" / "05.json").read_text())
|
||||
record = next(r for r in shard["items"] if r["value"].startswith("0555c6fae890"))
|
||||
self.assertEqual(record["system_name"], "Sony PlayStation")
|
||||
|
||||
def test_built_records_land_on_built_pages(self):
|
||||
shard = json.loads(_built(API / "lookup" / "05.json").read_text())
|
||||
dist = REPO_ROOT / "website" / "dist"
|
||||
for record in shard["items"][:200]:
|
||||
with self.subTest(url=record["url"]):
|
||||
self.assertTrue(record["url"].startswith("/retrobios/"))
|
||||
if dist.is_dir():
|
||||
page = urllib.parse.unquote(record["url"].split("#")[0].removeprefix("/retrobios/"))
|
||||
self.assertTrue((dist / page / "index.html").is_file(), page)
|
||||
|
||||
|
||||
class PlatformPagesLinkTheirSystemsAndEmulators(unittest.TestCase):
|
||||
"""8.9: the overview named emulators as ids, none of 1,463 linked."""
|
||||
|
||||
def test_an_emulator_is_named_and_linked(self):
|
||||
link = generate_site._emulator_named_link("beetle_psx", "../")
|
||||
self.assertRegex(link, r"^\[[^\]]+\]\(\.\./emulators/beetle_psx\.md\)$")
|
||||
self.assertNotEqual(link, "[beetle_psx](../emulators/beetle_psx.md)")
|
||||
|
||||
def test_the_overview_column_uses_it(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
block = source[source.index("### Systems overview"):source.index("_render_platform_systems(by_system")]
|
||||
self.assertIn("_emulator_named_link(e, \"../\")", block)
|
||||
self.assertNotIn('", ".join(sys_emus[:3])', block)
|
||||
|
||||
def test_a_moved_list_opens_on_its_console_and_names_its_parent(self):
|
||||
# Past PLATFORM_SECTION_BUDGET, so the list leaves for a page of its own.
|
||||
files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(2000)]
|
||||
moved: dict[str, str] = {}
|
||||
generate_site._render_platform_systems(
|
||||
{"sony-playstation": files}, {}, {}, ("retroarch", "RetroArch"), moved, {})
|
||||
self.assertTrue(moved)
|
||||
page = next(iter(moved.values()))
|
||||
self.assertTrue(page.startswith('---\nparent:\n label: "RetroArch"\n'
|
||||
' link: "/platforms/retroarch/"\n---\n'), page[:120])
|
||||
self.assertRegex(page, r"RetroArch's list for \[Sony PlayStation\]\(\.\./systems/")
|
||||
|
||||
def test_part_pages_of_gaps_and_cross_reference_name_their_parent(self):
|
||||
moved: dict[str, str] = {}
|
||||
generate_site._list_pages("gaps-x", "T", "intro", [], [["| a |"]], moved,
|
||||
parent=("What the packs do not hold", "/gaps/"))
|
||||
self.assertIn('parent:\n label: "What the packs do not hold"\n link: "/gaps/"', moved["gaps-x"])
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn('_parent_front_matter(("Cores by platform", "/cross-reference/"))', source)
|
||||
self.assertIn('parent=("Dump provenance", "/provenance/")', source)
|
||||
|
||||
|
||||
class TablesFitTheirColumn(unittest.TestCase):
|
||||
"""8.12 and 8.13: a hash in a cell is 12 characters, the rest in its title."""
|
||||
|
||||
def test_the_downloads_table(self):
|
||||
item = {"title": "SQLite snapshot", "url": "downloads/retrobios.sqlite",
|
||||
"media_type": "application/vnd.sqlite3", "bytes": 10, "sha256": "a" * 64}
|
||||
page = generate_site.generate_data_page([item])
|
||||
row = next(line for line in page.splitlines() if "SQLite snapshot" in line and "|" in line)
|
||||
self.assertIn(f'<code title="{"a" * 64}">{"a" * 12}</code>', row)
|
||||
self.assertNotIn("a" * 13, row.replace(f'title="{"a" * 64}"', ""))
|
||||
|
||||
def test_the_gaps_tables(self):
|
||||
problem = {"platform": "Batocera", "name": "x.bin", "status": "untested", "required": True,
|
||||
"reason": "expected md5 differs", "declared": {"md5": ["b" * 32]}, "path": ""}
|
||||
table = "\n".join(generate_site._render_problem_files([problem], db={"files": {}}))
|
||||
row = next(line for line in table.splitlines() if "x.bin" in line)
|
||||
self.assertIn(f'<code title="{"b" * 32}">{"b" * 12}</code>', row)
|
||||
self.assertIn('<span class="rb-reason">expected md5 differs</span>', row)
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn('expected = f"{algorithm} {short_hash(value)}"', source)
|
||||
self.assertIn("{_reason_cell(u['reason'])}", source)
|
||||
|
||||
|
||||
class SourceLinksGoWhereTheFileIs(unittest.TestCase):
|
||||
"""8.14: trident cited a Panda3DS path and linked it into 3dsTrident."""
|
||||
|
||||
PROFILE = {
|
||||
"emulator": "trident", "type": "libretro",
|
||||
"source": "https://github.com/DanAlexMorton/3dsTrident",
|
||||
"upstream": "https://github.com/wheremyfoodat/Panda3DS",
|
||||
"source_commit": "b" * 40,
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
sitesources._tree_paths.cache_clear()
|
||||
|
||||
def _render(self, tree: frozenset[str]) -> str:
|
||||
original = sitesources._tree_paths
|
||||
sitesources._tree_paths = lambda repo, pin: tree # type: ignore[assignment]
|
||||
try:
|
||||
return sitesources._source_ref_markdown(
|
||||
self.PROFILE, "src/emulator.cpp:234; src/core/loader/ncch.cpp:151-164")
|
||||
finally:
|
||||
sitesources._tree_paths = original
|
||||
|
||||
def test_a_path_the_tree_lacks_is_not_linked(self):
|
||||
text = self._render(frozenset({"src/emulator.cpp"}))
|
||||
self.assertIn("[`src/emulator.cpp:234`](https://github.com/DanAlexMorton/3dsTrident/", text)
|
||||
self.assertIn("`src/core/loader/ncch.cpp:151-164`", text)
|
||||
self.assertNotIn("3dsTrident/blob/" + "b" * 40 + "/src/core/loader", text)
|
||||
|
||||
def test_a_path_no_tree_confirmed_is_not_linked(self):
|
||||
"""Round 4: without a tree the declared repository took the path,
|
||||
and the deploy, which has no tree cache, linked 30 paths to a 404."""
|
||||
text = self._render(frozenset())
|
||||
self.assertNotIn("](https://", text)
|
||||
self.assertIn("`src/core/loader/ncch.cpp:151-164`", text)
|
||||
|
||||
def test_every_rendered_link_is_in_its_cached_tree(self):
|
||||
"""Offline: each link the pages render points at a path the cached
|
||||
tree of its repository holds."""
|
||||
if not sitesources.TREE_CACHE.is_dir():
|
||||
raise unittest.SkipTest("no cached tree listings")
|
||||
from common import load_emulator_profiles # noqa: PLC0415
|
||||
|
||||
link = re.compile(r"\(https://github\.com/([^/]+/[^/]+)/blob/([0-9a-f]{40})/([^#)]+)")
|
||||
checked, wrong = 0, []
|
||||
profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"), skip_aliases=False)
|
||||
for key, profile in sorted(profiles.items()):
|
||||
for entry in profile.get("files") or []:
|
||||
if not isinstance(entry, dict) or not entry.get("source_ref"):
|
||||
continue
|
||||
for slug, pin, path in link.findall(
|
||||
sitesources._source_ref_markdown(profile, entry["source_ref"])):
|
||||
import upstream # noqa: PLC0415
|
||||
|
||||
repo = upstream.parse_repo(f"https://github.com/{slug}")
|
||||
held = sitesources.tree_holds(repo, pin, urllib.parse.unquote(path))
|
||||
if held is None:
|
||||
continue
|
||||
checked += 1
|
||||
if not held:
|
||||
wrong.append((key, slug, path))
|
||||
if not checked:
|
||||
raise unittest.SkipTest("no rendered link has a cached tree")
|
||||
self.assertEqual(wrong, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,316 +0,0 @@
|
||||
"""System pages and file cards (round 3, package 6).
|
||||
|
||||
The DS section opened on five DSi files, the Game Boy Advance line printed
|
||||
BizHawk's file name where eight setups write another, the Neo Geo section
|
||||
opened on a file one emulator reads, no arcade card said which release its
|
||||
set matches, console headings carried an icon on 15 % of them, arcade boards
|
||||
sat under "Consoles and computers", and every system page shared one card.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import make_og_cards # noqa: E402
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
TMP = REPO_ROOT / "tmp"
|
||||
|
||||
|
||||
def _file(name: str, char: str, path: str) -> dict:
|
||||
return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 4096, "path": path}
|
||||
|
||||
|
||||
def _consumers(
|
||||
readers: dict[str, list[tuple[str, dict]]],
|
||||
db_files: list[dict] = (),
|
||||
destinations: dict[str, dict[str, set[str]]] | None = None,
|
||||
) -> types.SimpleNamespace:
|
||||
"""Consumers where each sha1 is read by the given (emulator, entry)."""
|
||||
files_of: dict[str, list] = {}
|
||||
entries: dict[str, dict[str, int]] = {}
|
||||
emulators: dict[str, set[str]] = {}
|
||||
for sha1, read_by in readers.items():
|
||||
for emulator, entry in read_by:
|
||||
files_of.setdefault(emulator, []).append(entry)
|
||||
entries.setdefault(sha1, {})[emulator] = len(files_of[emulator]) - 1
|
||||
emulators.setdefault(sha1, set()).add(emulator)
|
||||
return types.SimpleNamespace(
|
||||
platforms={}, emulators=emulators, emulator_entries=entries,
|
||||
emulator_files=files_of, installed={}, platform_systems={},
|
||||
platform_destinations=destinations or {},
|
||||
_db={"files": {f["sha1"]: f for f in db_files}},
|
||||
)
|
||||
|
||||
|
||||
DS = ("Nintendo", "Nintendo DS")
|
||||
|
||||
|
||||
class TheSectionStartsWithItsOwnConsole(unittest.TestCase):
|
||||
"""A file filed with the DS but read for the DSi is the DSi's."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
folder = "bios/Nintendo/Nintendo DS"
|
||||
self.files = [_file("NDS_Bios7.bin", "7", f"{folder}/NDS_Bios7.bin"),
|
||||
_file("NDS_Bios9.bin", "9", f"{folder}/NDS_Bios9.bin"),
|
||||
_file("dsi_bios7.bin", "d", f"{folder}/dsi_bios7.bin")]
|
||||
hle = {"required": False, "hle_fallback": True, "system": "nintendo-ds"}
|
||||
self.consumers = _consumers({
|
||||
"7" * 40: [("emu-a", {**hle, "name": "bios7.bin"}),
|
||||
("emu-b", {**hle, "name": "bios7.bin"})],
|
||||
"9" * 40: [("emu-a", {**hle, "name": "bios9.bin"})],
|
||||
"d" * 40: [("emu-a", {"name": "dsi_bios7.bin", "required": True,
|
||||
"system": "nintendo-dsi"})],
|
||||
})
|
||||
|
||||
def test_no_dsi_file_on_the_ds_line(self) -> None:
|
||||
text = "\n".join(gs._start_with(self.files, self.consumers, section=DS))
|
||||
self.assertNotIn("dsi", text)
|
||||
|
||||
def test_a_console_its_emulators_run_without_says_so(self) -> None:
|
||||
self.assertEqual(gs._start_with(self.files, self.consumers, section=DS), [
|
||||
"**No file needed** with emu-a or emu-b. For full accuracy: "
|
||||
"[`bios7.bin`](#sha1-777777777777), [`bios9.bin`](#sha1-999999999999).", ""])
|
||||
|
||||
def test_the_dsi_section_names_its_file(self) -> None:
|
||||
lines = gs._start_with(self.files, self.consumers, section=("Nintendo", "Nintendo DSi"))
|
||||
self.assertEqual(lines, ["**Start with**: [`dsi_bios7.bin`](#sha1-dddddddddddd).", ""])
|
||||
|
||||
def test_more_than_three_emulators_are_counted(self) -> None:
|
||||
hle = {"required": False, "hle_fallback": True, "system": "nintendo-ds"}
|
||||
consumers = _consumers({"7" * 40: [(f"emu-{n}", hle) for n in "abcd"]})
|
||||
self.assertEqual(gs._start_with(self.files[:1], consumers, section=DS)[0],
|
||||
"**No file needed**: none of its 4 emulators requires one. "
|
||||
"For full accuracy: [`NDS_Bios7.bin`](#sha1-777777777777).")
|
||||
|
||||
def test_a_shipped_file_is_no_start(self) -> None:
|
||||
consumers = _consumers({"7" * 40: [("emu-a", {"required": True, "bundled": True,
|
||||
"system": "nintendo-ds"})]})
|
||||
self.assertEqual(gs._start_with(self.files[:1], consumers, section=DS), [])
|
||||
|
||||
|
||||
class TheNameSetupsWrite(unittest.TestCase):
|
||||
def test_most_setups_win_over_the_collection_name(self) -> None:
|
||||
f = _file("GBA_bios.rom", "a", "bios/Nintendo/Game Boy Advance/GBA_bios.rom")
|
||||
consumers = _consumers(
|
||||
{"a" * 40: [("emu-a", {"name": "GBA_bios.rom", "required": True,
|
||||
"system": "nintendo-gba"})]},
|
||||
destinations={"a" * 40: {"p1": {"gba_bios.bin"}, "p2": {"bios/gba_bios.bin"},
|
||||
"p3": {"GBA_bios.rom"}}})
|
||||
self.assertEqual(gs._start_with([f], consumers, section=("Nintendo", "Game Boy Advance")),
|
||||
["**Start with**: [`gba_bios.bin`](#sha1-aaaaaaaaaaaa).", ""])
|
||||
|
||||
def test_no_setup_then_the_emulators_name(self) -> None:
|
||||
f = _file("x.rom", "b", "bios/Nintendo/Game Boy Advance/x.rom")
|
||||
consumers = _consumers({"b" * 40: [("emu-a", {"name": "gba_bios.bin", "required": True})]})
|
||||
self.assertEqual(gs._setup_name(f, consumers, [("emu-a", {"name": "gba_bios.bin"})]),
|
||||
"gba_bios.bin")
|
||||
|
||||
|
||||
class ArcadeSetsAreNamedWhereverFiled(unittest.TestCase):
|
||||
"""The Neo Geo BIOS is under Arcade: the Neo Geo section named
|
||||
aes.zip, read by one emulator, and never neogeo.zip."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.aes = _file("aes.zip", "1", "bios/SNK/Neo Geo/aes.zip")
|
||||
neogeo = _file("neogeo.zip", "2", "bios/Arcade/Arcade/neogeo.zip")
|
||||
mega_cd = _file("bios_CD_U.bin", "3", "bios/Sega/Mega CD/bios_CD_U.bin")
|
||||
shader = _file("tfx.glsl", "4", "bios/Other/emu/tfx.glsl")
|
||||
mvs = {"required": True, "system": "snk-neogeo-mvs"}
|
||||
self.consumers = _consumers({
|
||||
"1" * 40: [("geolith", {"name": "aes.zip", "required": True,
|
||||
"system": "snk-neogeo-aes"})],
|
||||
"2" * 40: [(f"mame{n}", {**mvs, "name": "neogeo.zip"}) for n in range(3)],
|
||||
"3" * 40: [("bizhawk", {"name": "bios_CD_U.bin", "required": True,
|
||||
"system": "snk-neogeo-mvs"})],
|
||||
"4" * 40: [("emu", {"name": "tfx.glsl", "required": True,
|
||||
"system": "snk-neogeo-mvs"})],
|
||||
}, db_files=[self.aes, neogeo, mega_cd, shader])
|
||||
|
||||
def test_the_file_most_emulators_read_leads_and_the_arcade_set_is_linked(self) -> None:
|
||||
lines = gs._start_with([self.aes], self.consumers, section=("SNK", "Neo Geo"))
|
||||
link = f"[`neogeo.zip`](/retrobios/files/?sha1={'2' * 40})"
|
||||
self.assertEqual(lines[0], f"**Start with** {link}. Keep it zipped. "
|
||||
"mame0, mame1 and mame2 read it.")
|
||||
self.assertIn("Geolith reads [`aes.zip`](#sha1-111111111111) instead.", lines)
|
||||
|
||||
def test_another_consoles_file_or_an_emulators_folder_is_left_out(self) -> None:
|
||||
text = "\n".join(gs._start_with([self.aes], self.consumers, section=("SNK", "Neo Geo")))
|
||||
self.assertNotIn("bios_CD_U.bin", text)
|
||||
self.assertNotIn("tfx.glsl", text)
|
||||
|
||||
def test_the_card_read_by_most_emulators_comes_first(self) -> None:
|
||||
one = _file("aes.zip", "1", "bios/SNK/Neo Geo/aes.zip")
|
||||
many = _file("sp-s3.sp1", "5", "bios/SNK/Neo Geo/sp-s3.sp1")
|
||||
consumers = _consumers({
|
||||
"1" * 40: [("geolith", {"required": True})],
|
||||
"5" * 40: [(f"emu{n}", {"required": True}) for n in range(3)],
|
||||
})
|
||||
cards = gs._console_cards("SNK", [one, many], consumers, set())
|
||||
self.assertIn("sp-s3.sp1", cards[0][1][2])
|
||||
|
||||
def test_a_long_list_names_the_first_files(self) -> None:
|
||||
files = [_file(f"rom{i}.bin", "abcdefgh"[i], f"bios/SNK/Neo Geo/rom{i}.bin")
|
||||
for i in range(8)]
|
||||
consumers = _consumers({f["sha1"]: [("emu", {"required": True})] for f in files})
|
||||
self.assertEqual(gs._start_with(files, consumers, first_below=False)[0],
|
||||
"**Start with**: [`rom0.bin`](#sha1-aaaaaaaaaaaa), [`rom1.bin`](#sha1-bbbbbbbbbbbb), "
|
||||
"[`rom2.bin`](#sha1-cccccccccccc) and 5 more files.")
|
||||
|
||||
|
||||
class CardsSayWhichReleaseTheSetMatches(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
TMP.mkdir(exist_ok=True)
|
||||
self.path = TMP / "r3_systems_set.zip"
|
||||
with zipfile.ZipFile(self.path, "w") as archive:
|
||||
archive.writestr("sp-s2.sp1", b"bios")
|
||||
crc = f"{zipfile.ZipFile(self.path).infolist()[0].CRC:08x}"
|
||||
self.releases = {"set.zip": [
|
||||
((("sp-s2.sp1", crc),), "mame", [(0, "0.190"), (1, "0.195"), (4, "0.220")]),
|
||||
((("sp-s2.sp1", crc),), "fbneo", [(2, "1.0.0.03"), (7, "1.0.0.03")]),
|
||||
((("other.bin", "00000000"),), "mame", [(9, "0.289")]),
|
||||
]}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
def card(self) -> str:
|
||||
f = {"name": "set.zip", "sha1": "e" * 40, "md5": "e" * 32, "size": 4,
|
||||
"path": str(self.path)}
|
||||
with mock.patch.object(gs, "_romset_releases", return_value=self.releases):
|
||||
return "\n".join(gs._system_file_card(f, _consumers({})))
|
||||
|
||||
def test_the_matching_releases_as_runs(self) -> None:
|
||||
self.assertIn("<dt>Romset</dt><dd>Same contents as MAME 0.190 to 0.195, 0.220 and "
|
||||
"FBNeo 1.0.0.03</dd>", self.card())
|
||||
|
||||
def test_no_match_counts_what_is_missing(self) -> None:
|
||||
self.releases["set.zip"] = self.releases["set.zip"][2:]
|
||||
self.assertIn("<dd>No match: missing 1 ROM of MAME 0.289</dd>", self.card())
|
||||
|
||||
def test_no_recipe_no_line(self) -> None:
|
||||
self.releases = {}
|
||||
self.assertNotIn("Romset", self.card())
|
||||
|
||||
def test_the_snapshots_cover_mame_and_fbneo(self) -> None:
|
||||
coverage = gs._romset_coverage()
|
||||
self.assertRegex(coverage, r"^MAME 0\.\d+ to 0\.\d+ or FBNeo [\d.]+$")
|
||||
|
||||
def test_a_page_of_sets_links_the_version_answer(self) -> None:
|
||||
f = {"name": "set.zip", "sha1": "e" * 40, "path": "bios/SNK/Neo Geo/set.zip"}
|
||||
with mock.patch.object(gs, "_romset_releases", return_value=self.releases):
|
||||
page = gs.generate_system_page("SNK", {"Neo Geo": [f]}, _consumers({}))
|
||||
self.assertIn("MAME and FBNeo sets must match the emulator's version. [Which version "
|
||||
f"each core reads]({gs.ROMSET_FAQ}).", page)
|
||||
anchor = gs.ROMSET_FAQ.split("#")[1]
|
||||
faq = (REPO_ROOT / "wiki" / "technical-faq.md").read_text(encoding="utf-8")
|
||||
self.assertIn("## Which MAME version do the arcade BIOS sets match?", faq)
|
||||
self.assertEqual(anchor, "which-mame-version-do-the-arcade-bios-sets-match")
|
||||
|
||||
|
||||
class ArcadeIsItsOwnGroup(unittest.TestCase):
|
||||
def test_arcade_rows_under_arcade(self) -> None:
|
||||
f = {"name": "a.bin", "sha1": "a" * 40, "path": "bios/x/a.bin"}
|
||||
index = gs.generate_systems_index({"Arcade": {"MAME": [f]}, "Sony": {"PlayStation": [f]}})
|
||||
arcade = index.index("## Arcade")
|
||||
self.assertLess(index.index("## Consoles and computers"), arcade)
|
||||
self.assertGreater(index.index("[MAME]"), arcade)
|
||||
self.assertLess(index.index("[Sony PlayStation]"), arcade)
|
||||
|
||||
|
||||
class EachManufacturerHasItsCard(unittest.TestCase):
|
||||
def test_a_tagline_holds_three_lines_and_counts_the_rest(self) -> None:
|
||||
names = [f"Console number {n}" for n in range(40)]
|
||||
text = make_og_cards.consoles_tagline(names)
|
||||
self.assertRegex(text, r" and \d+ more$")
|
||||
lines = make_og_cards.wrap(text, make_og_cards.TAGLINE_WIDTH, make_og_cards.TAGLINE_SIZE)
|
||||
self.assertLessEqual(len(lines), make_og_cards.TAGLINE_LINES)
|
||||
self.assertEqual(make_og_cards.consoles_tagline(["Saturn", "Dreamcast"]),
|
||||
"BIOS files for Dreamcast and Saturn")
|
||||
|
||||
def test_every_manufacturer_page_has_a_committed_card(self) -> None:
|
||||
cards = make_og_cards.system_cards()
|
||||
missing = [key for key in cards
|
||||
if not (REPO_ROOT / "docs_assets" / "og" / f"{key}.png").is_file()]
|
||||
self.assertEqual(missing, [], "run python scripts/make_og_cards.py --only systems")
|
||||
# A card left behind by a page that went: oric.png outlived its page.
|
||||
committed = (REPO_ROOT / "docs_assets" / "og" / "systems").glob("*.png")
|
||||
stale = sorted(f"systems/{path.stem}" for path in committed
|
||||
if f"systems/{path.stem}" not in cards)
|
||||
self.assertEqual(stale, [])
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "systems" / "nintendo" / "index.html").is_file(), "site not built")
|
||||
class TheBuiltPages(unittest.TestCase):
|
||||
def test_no_console_heading_carries_an_icon(self) -> None:
|
||||
with_icon = [path.relative_to(DIST).as_posix() for path in DIST.glob("systems/**/index.html")
|
||||
if re.search(r"<h2[^>]*>(?:(?!</h2>).)*<img", path.read_text(encoding="utf-8"), re.S)]
|
||||
self.assertEqual(with_icon, [])
|
||||
|
||||
def test_the_ds_section_names_no_dsi_file(self) -> None:
|
||||
page = (DIST / "systems" / "nintendo" / "index.html").read_text(encoding="utf-8")
|
||||
start = page.index('id="nintendo-ds"')
|
||||
section = page[start:page.index('id="nintendo-dsi"')]
|
||||
# The section may have moved to a page of its own: no card follows.
|
||||
opening = section.split('class="rb-sys-file"')[0]
|
||||
self.assertRegex(opening, r"No file needed|Start with")
|
||||
self.assertNotRegex(opening.casefold(), r"dsi_|bios7i|bios9i")
|
||||
|
||||
def test_the_gba_line_prints_the_setups_name(self) -> None:
|
||||
page = (DIST / "systems" / "nintendo" / "index.html").read_text(encoding="utf-8")
|
||||
section = page[page.index('id="game-boy-advance"'):page.index('id="game-boy-color"')]
|
||||
self.assertRegex(section[:section.index("class=\"rb-sys-file\"")], r"<code[^>]*>gba_bios.bin</code>")
|
||||
|
||||
def test_the_neo_geo_section_opens_on_the_neo_geo_bios(self) -> None:
|
||||
page = (DIST / "systems" / "snk" / "index.html").read_text(encoding="utf-8")
|
||||
section = page[page.index('id="neo-geo"'):]
|
||||
opening = section[:section.index('class="rb-sys-file"')]
|
||||
self.assertRegex(opening, r"<code[^>]*>neogeo.zip</code>")
|
||||
self.assertIn("Which version each core reads", page)
|
||||
|
||||
def test_the_nintendo_summary_holds_its_budget(self) -> None:
|
||||
size = (DIST / "systems" / "nintendo" / "index.html").stat().st_size
|
||||
self.assertLess(size, 540_000)
|
||||
|
||||
|
||||
class InstallBoxesCountTowardTheSummary(unittest.TestCase):
|
||||
"""Ten install boxes were 295 KB of the 583 KB Nintendo page: the
|
||||
weights that keep a section on its maker's summary counted cards only,
|
||||
and the page passed its 600 KB budget once the DS files were refiled."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
import siterender
|
||||
|
||||
siterender.reset_console_pages()
|
||||
self.addCleanup(siterender.reset_console_pages)
|
||||
self.consoles = {
|
||||
name: [_file(f"{name}{i}.bin", "0123456789"[index * 3 + i],
|
||||
f"bios/Maker/{name}/{name}{i}.bin") for i in range(3)]
|
||||
for index, name in enumerate(("Boxed", "Heavy", "Light"))
|
||||
}
|
||||
|
||||
def pages(self, box: int) -> dict:
|
||||
def install(console: str, _declared: list) -> list[str]:
|
||||
return ["x" * box] if console == "Boxed" else []
|
||||
|
||||
with mock.patch.object(gs, "_console_install_block", side_effect=install), \
|
||||
mock.patch.object(gs, "SYSTEM_SUMMARY_HTML", 50_000):
|
||||
return gs.system_pages("Maker", self.consoles, _consumers({}), budget=30_000)
|
||||
|
||||
def test_a_section_with_a_heavy_install_box_moves(self) -> None:
|
||||
pages = self.pages(20_000)
|
||||
self.assertIn("maker-boxed", pages)
|
||||
self.assertNotIn("maker-light", pages)
|
||||
|
||||
def test_a_light_install_box_stays(self) -> None:
|
||||
self.assertEqual(list(self.pages(10)), ["maker"])
|
||||
@@ -1,316 +0,0 @@
|
||||
"""Wiki copy (round 3, package 5).
|
||||
|
||||
Each class names the finding it holds: a legal page that opened on case law,
|
||||
no arcade answer where players look, a Dreamcast "yes" the Dreamcast
|
||||
emulator contradicts, an offline install no one could find, setups with no
|
||||
page of their own, link labels that named another page, an exit status that
|
||||
could not tell missing files from a failure, and FAQ answers the community
|
||||
asks for (region and language, keys and firmware). Every claim the copy
|
||||
makes about an emulator or the installer is held against the repository.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
WIKI = REPO_ROOT / "wiki"
|
||||
FENCED = re.compile(r"^```.*?^```", re.S | re.M)
|
||||
|
||||
|
||||
def _wiki(name: str) -> str:
|
||||
return (WIKI / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _section(text: str, heading: str) -> str:
|
||||
start = re.search(rf"(?m)^#+ {re.escape(heading)}(?:\s*\{{[^}}]*\}})?\n", text)
|
||||
if start is None:
|
||||
raise AssertionError(f"no section {heading!r}")
|
||||
rest = text[start.end():]
|
||||
end = re.search(r"(?m)^#{1,2} ", FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest))
|
||||
return rest[: end.start()] if end else rest
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _headings(text: str) -> list[str]:
|
||||
return re.findall(r"(?m)^#{2,3} (.+?)\s*$", FENCED.sub("", text))
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _entry(profile: dict, name: str) -> dict:
|
||||
for entry in profile.get("files", []):
|
||||
if entry.get("name") == name:
|
||||
return entry
|
||||
raise AssertionError(f"{name} not in the profile")
|
||||
|
||||
|
||||
class LegalOpensInPlainWords(unittest.TestCase):
|
||||
"""C5: Is it legal? is a Start here page and opened on case law."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("legal.md")
|
||||
self.opening = self.text.split("## The reasoning in detail", 1)[0]
|
||||
|
||||
def test_the_answer_comes_before_the_reasoning(self):
|
||||
self.assertIn("## The reasoning in detail", self.text)
|
||||
self.assertIn("It depends on your country. Nothing here is legal advice.", self.opening)
|
||||
for case in ("Connectix", "Accolade", "DMCA", "CFR", "Chamberlain", "Yuzu"):
|
||||
with self.subTest(case=case):
|
||||
self.assertNotIn(case, self.opening)
|
||||
self.assertIn(case, self.text)
|
||||
|
||||
def test_the_plain_lines_hold_against_the_detail(self):
|
||||
opening = _flat(self.opening)
|
||||
self.assertIn("encryption keys (Switch, 3DS, Wii U)", opening)
|
||||
self.assertIn("weakest part of the project's position", self.text)
|
||||
self.assertIn("issues/new", opening)
|
||||
self.assertIn("path or hash", opening)
|
||||
self.assertIn("identifying the file by path or hash", _flat(self.text))
|
||||
|
||||
def test_the_detail_sits_under_the_reasoning_heading(self):
|
||||
detail = self.text.split("## The reasoning in detail", 1)[1]
|
||||
detail = detail.split("## Asking for a file to be removed", 1)[0]
|
||||
self.assertEqual(re.findall(r"(?m)^## ", detail), [])
|
||||
self.assertIn("### Emulation and BIOS redistribution", detail)
|
||||
|
||||
def test_a_contact_section_names_only_a_channel_that_exists(self):
|
||||
section = _section(self.text, "Contact")
|
||||
self.assertIn("https://github.com/Abdess/retrobios/issues", section)
|
||||
self.assertNotIn("@", section)
|
||||
|
||||
|
||||
class ArcadeAnswerWherePlayersLook(unittest.TestCase):
|
||||
"""C3, N3: the romset answer sat in Reference only."""
|
||||
|
||||
def test_troubleshooting_answers_after_step_three(self):
|
||||
text = _wiki("troubleshooting.md")
|
||||
headings = _headings(text)
|
||||
self.assertIn("An arcade game does not start", headings)
|
||||
self.assertGreater(headings.index("An arcade game does not start"),
|
||||
headings.index("3. Check the folder and the names"))
|
||||
section = _flat(_section(text, "An arcade game does not start"))
|
||||
for words in ("neogeo.zip", "same emulator version", "MAME version", "Details:",
|
||||
"technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match",
|
||||
"Run the command once."):
|
||||
with self.subTest(words=words):
|
||||
self.assertIn(words, section)
|
||||
|
||||
def test_the_faq_links_the_technical_answer(self):
|
||||
section = _section(_wiki("faq.md"), "My arcade game fails (which MAME version?)")
|
||||
anchor = "technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match"
|
||||
self.assertIn(anchor, section)
|
||||
self.assertIn("## Which MAME version do the arcade BIOS sets match?", _wiki("technical-faq.md"))
|
||||
self.assertLessEqual(len(re.findall(r"[.!?](?:\s|$)", section)), 2)
|
||||
|
||||
def test_emulator_pages_name_the_version_the_copy_promises(self):
|
||||
for name in ("mame2003", "mame2000", "fbneo"):
|
||||
with self.subTest(profile=name):
|
||||
profile = _profile(name)
|
||||
self.assertTrue(profile.get("core_version") or profile.get("version"))
|
||||
|
||||
def test_the_check_step_holds_the_searched_words(self):
|
||||
self.assertIn("## 1. Check my BIOS folder { #checking-what-you-installed }",
|
||||
_wiki("troubleshooting.md"))
|
||||
|
||||
|
||||
class BiosFineWhatNext(unittest.TestCase):
|
||||
"""Community need 11: the page assumed the BIOS was the cause."""
|
||||
|
||||
def setUp(self):
|
||||
self.section = _flat(_section(_wiki("troubleshooting.md"),
|
||||
"The BIOS is fine, the game still fails"))
|
||||
|
||||
def test_the_three_causes(self):
|
||||
for words in ("Another emulator?", "A BIOS setting?", ".cue", ".m3u"):
|
||||
with self.subTest(words=words):
|
||||
self.assertIn(words, self.section)
|
||||
|
||||
def test_the_named_setting_is_the_core_option(self):
|
||||
# libretro/pcsx_rearmed frontend/libretro_core_options.h: key
|
||||
# pcsx_rearmed_bios, label "BIOS Selection", values Auto and HLE.
|
||||
self.assertIn("**BIOS Selection** to **Auto**", self.section)
|
||||
notes = _profile("pcsx_rearmed").get("notes", "")
|
||||
self.assertIn('pcsx_rearmed_bios = "HLE"', notes)
|
||||
|
||||
|
||||
class DreamcastIsBetterWithIt(unittest.TestCase):
|
||||
"""C7: "Yes for Dreamcast" while Flycast runs without dc_boot.bin."""
|
||||
|
||||
def test_flycast_falls_back(self):
|
||||
entry = _entry(_profile("flycast"), "dc_boot.bin")
|
||||
self.assertFalse(entry.get("required"))
|
||||
self.assertTrue(entry.get("hle_fallback"))
|
||||
|
||||
def test_no_page_says_yes_for_dreamcast(self):
|
||||
for name in ("faq.md", "what-are-bios-files.md"):
|
||||
text = _wiki(name)
|
||||
with self.subTest(page=name):
|
||||
yes = next(line for line in text.splitlines() if line.startswith("- **Yes"))
|
||||
self.assertNotIn("Dreamcast", yes)
|
||||
self.assertRegex(_flat(text), r"\*\*Better with it:?\*\*:? (for )?Dreamcast")
|
||||
|
||||
|
||||
class OfflineInstallIsFindable(unittest.TestCase):
|
||||
"""N7: no page linked the offline install, and search did not find it."""
|
||||
|
||||
ANCHOR = "without-a-network-offline-install"
|
||||
|
||||
def test_the_installer_heading_says_offline(self):
|
||||
text = _wiki("installer.md")
|
||||
self.assertIn(f"## Without a network (offline install) {{ #{self.ANCHOR} }}", text)
|
||||
# Links written before the rename still land.
|
||||
self.assertIn('<span id="without-a-network"></span>', text)
|
||||
self.assertNotIn("](#without-a-network)", text)
|
||||
|
||||
def test_the_faq_answers_next_to_the_sd_card(self):
|
||||
text = _wiki("faq.md")
|
||||
headings = _headings(text)
|
||||
self.assertEqual(headings.index("No internet on the device?"),
|
||||
headings.index("Fill an SD card from a computer?") + 1)
|
||||
section = _section(text, "No internet on the device?")
|
||||
self.assertIn("--from-pack", section)
|
||||
self.assertIn(f"installer.md#{self.ANCHOR}", section)
|
||||
|
||||
def test_from_pack_is_an_installer_option(self):
|
||||
self.assertIn('"--from-pack"', (REPO_ROOT / "install.py").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class SetupsWithoutAPage(unittest.TestCase):
|
||||
"""N9, J13: ES-DE, LaunchBox and iOS found nothing."""
|
||||
|
||||
def test_the_answer(self):
|
||||
section = _flat(_section(_wiki("faq.md"), "My setup is not in the list"))
|
||||
for words in ("--dest", "ES-DE", "LaunchBox", "iPhone"):
|
||||
with self.subTest(words=words):
|
||||
self.assertIn(words, section)
|
||||
# No folder is written: the copy names none it cannot cite. Link
|
||||
# targets are pages of this site, not folders.
|
||||
self.assertNotRegex(re.sub(r"\]\([^)]*\)", "]", section), r"[~/\\][A-Za-z]")
|
||||
|
||||
def test_the_installer_fills_the_emulators_a_launcher_starts(self):
|
||||
installer = _flat(_wiki("installer.md"))
|
||||
self.assertIn("they reference emulators rather than owning a BIOS directory", installer)
|
||||
self.assertIn("the installer fills their own BIOS folders", installer)
|
||||
|
||||
|
||||
class LinkLabelsMatchPages(unittest.TestCase):
|
||||
"""N5: "Your setup's page" landed on a page titled Get your files."""
|
||||
|
||||
def test_links_to_get_your_files_carry_its_name(self):
|
||||
for name in ("first-install.md", "troubleshooting.md", "faq.md"):
|
||||
with self.subTest(page=name):
|
||||
for label in re.findall(r"\[([^\]]+)\]\(\.\./which-pack\.md\)", _wiki(name)):
|
||||
self.assertEqual(label, "Get your files")
|
||||
|
||||
|
||||
class ExitStatusAndPinnedInstaller(unittest.TestCase):
|
||||
"""D10, J12: --check exits 1 for missing files and for failures, and a
|
||||
packager had no fixed installer to point at."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("installer.md")
|
||||
self.source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_the_check_event_tells_missing_from_failure(self):
|
||||
section = _flat(_section(self.text, "Exit status"))
|
||||
self.assertIn("read the `check` event", section)
|
||||
self.assertIn("`missing` and `wrong_hash` count what is left", section)
|
||||
fields = re.search(r'"check": \(([^)]*)\)', self.source).group(1)
|
||||
for field in ("missing", "wrong_hash"):
|
||||
with self.subTest(field=field):
|
||||
self.assertIn(f'"{field}"', fields)
|
||||
|
||||
def test_a_packager_pins_installer_and_lists_to_one_commit(self):
|
||||
# No release carries the installer yet: the section pins a commit.
|
||||
section = _section(self.text, "One line")
|
||||
self.assertIn("raw.githubusercontent.com/Abdess/retrobios/$commit", section)
|
||||
self.assertIn("$base/install.py", section)
|
||||
self.assertIn("RETROBIOS_REF=$commit", section)
|
||||
self.assertIn('os.environ.get("RETROBIOS_REF"', self.source)
|
||||
|
||||
|
||||
class SmallCopyFixes(unittest.TestCase):
|
||||
"""C P3: the SD card example and the end line of a run."""
|
||||
|
||||
def test_the_sd_card_example_names_its_setup(self):
|
||||
section = _flat(_section(_wiki("faq.md"), "Fill an SD card from a computer?"))
|
||||
self.assertIn("For RetroArch on a Switch, with the card in drive `E:`, run:", section)
|
||||
guides = (REPO_ROOT / "platforms" / "_guides.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("where: Nintendo Switch", guides)
|
||||
self.assertIn("`retroarch/cores/system/` on the SD card", guides)
|
||||
self.assertIn("--dest E:\\retroarch\\cores\\system", section)
|
||||
|
||||
def test_the_end_is_the_shown_one(self):
|
||||
# The one-liner opens the full screen: the page names the titles
|
||||
# its result screen shows, as tui_outcome writes them.
|
||||
import install
|
||||
|
||||
section = _section(_wiki("first-install.md"), "5. Read the result")
|
||||
state = install.TuiState()
|
||||
state.stack, state.code = ["result"], 0
|
||||
for summary in ({"installed": 3, "up_to_date": 0, "failed": []},
|
||||
{"installed": 2, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]},
|
||||
{"installed": 0, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]}):
|
||||
state.summary = summary
|
||||
title, _lines = install.tui_outcome(state)
|
||||
with self.subTest(title=title):
|
||||
self.assertIn(f"**{title}**", section)
|
||||
|
||||
|
||||
class CommunityAnswers(unittest.TestCase):
|
||||
"""Community needs 6, 10 and 15: region and language, keys versus
|
||||
firmware versus BIOS, zip or unzip."""
|
||||
|
||||
def setUp(self):
|
||||
self.faq = _wiki("faq.md")
|
||||
|
||||
def test_language_comes_from_the_game(self):
|
||||
section = _flat(_section(self.faq, "Does the BIOS set the game's language?"))
|
||||
self.assertIn("comes from the game", section)
|
||||
self.assertIn("Flycast's **Language** option", section)
|
||||
|
||||
def test_keys_firmware_bios(self):
|
||||
section = _flat(_section(self.faq, "Keys, firmware or BIOS?"))
|
||||
for name, profile in (("scph5501.bin", "duckstation"), ("PS3UPDAT.PUP", "rpcs3"),
|
||||
("prod.keys", "eden")):
|
||||
with self.subTest(name=name):
|
||||
self.assertIn(f"`{name}`", section)
|
||||
_entry(_profile(profile), name)
|
||||
self.assertIn("Firmware (system NCAs) must be installed through Eden's UI",
|
||||
_profile("eden")["notes"])
|
||||
|
||||
def test_zip_or_unzip_stays(self):
|
||||
self.assertIn("neogeo.zip", _section(self.faq, "Zip or unzip?"))
|
||||
|
||||
|
||||
class StartPagesStayInBudget(unittest.TestCase):
|
||||
"""The built pages: the new answers fit the level 0 ceilings."""
|
||||
|
||||
def test_ceilings(self):
|
||||
from tests import test_site_copy
|
||||
|
||||
sitepages.require()
|
||||
for name in ("wiki/faq.md", "wiki/troubleshooting.md", "wiki/first-install.md",
|
||||
"wiki/what-are-bios-files.md"):
|
||||
with self.subTest(page=name):
|
||||
self.assertLessEqual(test_site_copy._load(name).words(),
|
||||
test_site_copy.START_PAGES[name])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,302 +0,0 @@
|
||||
"""Round 4, package 2: components and runtime.
|
||||
|
||||
Source checks always run. Checks on the built site read website/dist and
|
||||
skip when no build is there. The rules written in TypeScript or in a build
|
||||
plugin run under node and skip without it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
WEBSITE = REPO_ROOT / "website"
|
||||
DIST = WEBSITE / "dist"
|
||||
COMPONENTS = WEBSITE / "src" / "components"
|
||||
SITE_TS = WEBSITE / "src" / "scripts" / "site.ts"
|
||||
CONFIG = WEBSITE / "astro.config.mjs"
|
||||
SHORT_CODE = WEBSITE / "src" / "plugins" / "rehype-short-code.mjs"
|
||||
LOGOS = REPO_ROOT / "docs_assets" / "logos"
|
||||
|
||||
# The four hashes of one dump, bios/Sony/PlayStation/scph5501.bin.
|
||||
SCPH5501 = {
|
||||
"md5": "490f666e1afb15b7362b406ed1cea246",
|
||||
"sha1": "0555c6fae8906f3f09baf5988f00e55f88e9f30b",
|
||||
"crc32": "8d8cb7e4",
|
||||
"sha256": "11052b6499e466bbf0a709b1f9cb6834a9418e66680387912451e971cf8a1fef",
|
||||
}
|
||||
ANDROID_UA = ("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36")
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _built(page: str) -> str:
|
||||
path = DIST / page / "index.html"
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest(f"no built site at {path}")
|
||||
return _read(path)
|
||||
|
||||
|
||||
def _node(script: str) -> str:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
raise unittest.SkipTest("node is missing")
|
||||
result = subprocess.run(
|
||||
[node, "--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, check=True, cwd=WEBSITE,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _short_unmarked(html: str) -> list[str]:
|
||||
"""Inline code of 30 characters or fewer without rb-short, outside pre."""
|
||||
body = re.sub(r"<pre\b.*?</pre>", "", html, flags=re.S)
|
||||
found = []
|
||||
for attributes, text in re.findall(r"<code(\s[^>]*)?>([^<]*)</code>", body):
|
||||
plain = re.sub(r"&(?:#\d+|#x[0-9a-f]+|[a-z]+);", "_", text, flags=re.I)
|
||||
if 0 < len(plain) <= 30 and "rb-short" not in (attributes or ""):
|
||||
found.append(text)
|
||||
return found
|
||||
|
||||
|
||||
class OneVerdictWhateverHash(unittest.TestCase):
|
||||
"""2.1: the SHA1 of scph5501.bin found 4 setups, its MD5 12, its SHA256
|
||||
none (V1, J4). A setup's list writes one hash of a file."""
|
||||
|
||||
def test_the_other_hashes_are_read(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("const heldHashes = (file: Held) =>", source)
|
||||
self.assertIn("await lookupValues(others)", source)
|
||||
self.assertIn("item.sha1 ?? files.find((file) => file && heldHashes(file).includes(item.value))?.sha1", source)
|
||||
self.assertIn("(fileOf(declared) ?? item.sha1) === item.sha1", source)
|
||||
|
||||
def test_the_verdict_names_the_other_name(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("'another name' : 'other names'}: `", source)
|
||||
self.assertNotIn("under the ${otherNames.length === 1 ? 'name' : 'names'}", source)
|
||||
|
||||
def test_entries_match_on_any_hash_of_the_file(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("const HASH_FIELDS = ['sha1', 'md5', 'crc32', 'sha256'];", source)
|
||||
self.assertIn("declaredEntry(item, valuesOf(item))", source)
|
||||
self.assertIn("readerEntry(reader.get(item.owner ?? ''), item, valuesOf(item))", source)
|
||||
|
||||
def test_what_the_renderer_reads_is_built(self):
|
||||
"""Each hash finds the collection record with its SHA1, and the
|
||||
manufacturer file gives the other three, so every paste reads the
|
||||
same four shards."""
|
||||
api = DIST / "api" / "v1"
|
||||
if not (api / "lookup").is_dir():
|
||||
self.skipTest("no built site")
|
||||
for value in SCPH5501.values():
|
||||
with self.subTest(value=value):
|
||||
shard = json.loads(_read(api / "lookup" / f"{value[:2]}.json"))["items"]
|
||||
held = [i for i in shard if i["value"] == value and i["layer"] == "collection"]
|
||||
self.assertEqual([i["sha1"] for i in held], [SCPH5501["sha1"]])
|
||||
self.assertIn("/systems/sony-playstation/", held[0]["url"])
|
||||
consoles = json.loads(_read(api / "systems" / "sony.json"))["items"][0]["consoles"]
|
||||
card = next(f for files in consoles.values() for f in files if f["sha1"] == SCPH5501["sha1"])
|
||||
self.assertEqual({k: card[k] for k in SCPH5501}, SCPH5501)
|
||||
|
||||
|
||||
class TheVisitorsTab(unittest.TestCase):
|
||||
"""2.2: on an Android phone the tabs opened on Windows (V4, T-F1)."""
|
||||
|
||||
def _rule(self) -> str:
|
||||
source = _read(SITE_TS)
|
||||
return source[source.index("const OS_TAB_RULES"):source.index("// End of the OS tab rule.")]
|
||||
|
||||
def test_labels_from_the_user_agent(self):
|
||||
cases = [
|
||||
[ANDROID_UA, ""],
|
||||
["Mozilla/5.0 (X11; Linux x86_64) Valve Steam GameOverlay/1729", "Linux"],
|
||||
["Mozilla/5.0 (X11; Linux x86_64; SteamOS) Firefox/130.0", ""],
|
||||
["Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) Safari/605.1.15", ""],
|
||||
["Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0", "Windows"],
|
||||
["Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0", "Linux"],
|
||||
["Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Safari/604.1", ""],
|
||||
["Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) Chrome/130.0", "Chrome OS"],
|
||||
]
|
||||
out = _node(self._rule() + f"console.log(JSON.stringify({json.dumps(cases)}.map(([ua, p]) => osTabLabel(ua, p))));")
|
||||
self.assertEqual(json.loads(out), [
|
||||
"Android (Termux)", "Steam Deck", "Steam Deck", "macOS", "Windows", "Linux", "", "",
|
||||
])
|
||||
|
||||
def test_labels_are_tab_labels(self):
|
||||
guides = _read(REPO_ROOT / "platforms" / "_guides.yml")
|
||||
for label in re.findall(r"\[/[^/]+/i, '([^']+)'\]", self._rule()):
|
||||
with self.subTest(label=label):
|
||||
self.assertIn(f"label: {label}\n", guides)
|
||||
|
||||
def test_a_saved_choice_wins_and_the_guess_is_not_saved(self):
|
||||
source = _read(SITE_TS)
|
||||
block = source[source.index("const osTabs = () =>"):source.index("// The sidebar lists")]
|
||||
self.assertIn("savedTab(OS_SYNC_KEY)", block)
|
||||
self.assertIn("set.switchTab(set.tabs[index], index, false)", block)
|
||||
self.assertNotIn("localStorage.setItem", block)
|
||||
# The one choice the script saves is the setup picked in an install
|
||||
# box, under its own key: never the OS guess.
|
||||
self.assertEqual(re.findall(r"localStorage\.setItem\((\w+)", source), ["SETUP_KEY"])
|
||||
self.assertNotIn("keepTabInView", source)
|
||||
|
||||
|
||||
class TheSidebarShowsTheCurrentPage(unittest.TestCase):
|
||||
"""2.3: the current link sat at y 1364 in a 900 px window (V5)."""
|
||||
|
||||
def test_the_pane_scrolls_never_the_page(self):
|
||||
source = _read(SITE_TS)
|
||||
block = source[source.index("const showCurrentPage = () =>"):source.index("// A glossary term")]
|
||||
self.assertIn("pane.scrollTop +=", block)
|
||||
self.assertIn("pane.clientHeight / 3", block)
|
||||
self.assertIn("addEventListener('toggle'", block)
|
||||
self.assertNotIn("scrollIntoView", source)
|
||||
self.assertIn("showCurrentPage();", source)
|
||||
|
||||
|
||||
class ChromeOutOfTheIndex(unittest.TestCase):
|
||||
"""2.4: excerpts opened on the title and closed on the licence line, and
|
||||
"bios" matched every page through the footer (N2, V6)."""
|
||||
|
||||
def test_components(self):
|
||||
title = _read(COMPONENTS / "PageTitle.astro")
|
||||
self.assertIn('<nav class="rb-breadcrumbs" aria-label="Breadcrumb" data-pagefind-ignore>', title)
|
||||
self.assertEqual(title.count('data-pagefind-ignore data-pagefind-meta="title"'), 2)
|
||||
footer = _read(COMPONENTS / "Footer.astro")
|
||||
self.assertIn('<div class="rb-footer-root" data-pagefind-ignore>\n<Default />', footer)
|
||||
|
||||
def test_built_page(self):
|
||||
html = _built("systems/sony-playstation")
|
||||
self.assertRegex(html, r'<nav class="rb-breadcrumbs[^"]*"[^>]*data-pagefind-ignore')
|
||||
self.assertRegex(html, r'<h1 id="_top"[^>]*data-pagefind-ignore[^>]*data-pagefind-meta="title"')
|
||||
root = html.index('class="rb-footer-root')
|
||||
self.assertIn("data-pagefind-ignore", html[html.rfind("<div", 0, root):root + 80])
|
||||
self.assertGreater(html.index("MIT for the tooling"), root)
|
||||
|
||||
|
||||
class ShortCodeMarked(unittest.TestCase):
|
||||
"""2.5: `--restore` split into `--` and `restore` on the home page at
|
||||
1440 px, and 51 flags split in the installer's options table (V3)."""
|
||||
|
||||
def test_the_rule(self):
|
||||
out = _node(
|
||||
f"import {{ isShort, markRawCode, SHORT_CODE }} from {json.dumps(SHORT_CODE.as_uri())};"
|
||||
"console.log(JSON.stringify([SHORT_CODE, isShort('--restore', false), isShort('--restore', true),"
|
||||
"isShort('x'.repeat(31), false), markRawCode('<td><code>bios/a.bin</code> <code class=\"rb-hash\">ab</code></td>'),"
|
||||
"markRawCode('<pre><code>a</code></pre>')]));"
|
||||
)
|
||||
self.assertEqual(json.loads(out), [
|
||||
30, True, False, False,
|
||||
'<td><code class="rb-short">bios/a.bin</code> <code class="rb-hash">ab</code></td>',
|
||||
"<pre><code>a</code></pre>",
|
||||
])
|
||||
|
||||
def test_registered(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("hastPlugins: [stackTables(), shortCode()],", config)
|
||||
|
||||
def test_every_short_code_is_marked(self):
|
||||
for page in ("", "wiki/installer", "which-pack", "emulators/duckstation", "systems/sony", "wiki/faq"):
|
||||
with self.subTest(page=page or "home"):
|
||||
self.assertEqual(_short_unmarked(_built(page)), [])
|
||||
self.assertIn('class="rb-short"', _built(""))
|
||||
|
||||
def test_the_options_table(self):
|
||||
self.assertRegex(_built("wiki/installer"), r'<table[^>]*\sclass="(?:[^"]*\s)?rb-options[\s"]')
|
||||
|
||||
|
||||
class SearchFields(unittest.TestCase):
|
||||
"""2.6: `consoles` and `terms` weigh as a title (N3, T-F2, D4)."""
|
||||
|
||||
def test_declared_and_never_shown(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 10, devices: 5, consoles: 100, exact: 20, terms: 30 },", config)
|
||||
self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'exact', 'terms']);", _read(SITE_TS))
|
||||
|
||||
|
||||
class SearchLoadsOnFirstUse(unittest.TestCase):
|
||||
"""2.7: ui-core.js (25.8 KiB) loaded on idle on every page (T-F8)."""
|
||||
|
||||
def test_the_override(self):
|
||||
self.assertIn("Search: './src/components/Search.astro',", _read(CONFIG))
|
||||
search = _read(COMPONENTS / "Search.astro")
|
||||
self.assertNotIn("requestIdleCallback", search)
|
||||
self.assertNotIn("DOMContentLoaded", search)
|
||||
self.assertIn("for (const type of ['pointerenter', 'pointerdown', 'focus'])", search)
|
||||
self.assertIn("loadSearch()?.then(", search)
|
||||
self.assertNotIn("TODO", search)
|
||||
|
||||
|
||||
class LogosAtTheirWeight(unittest.TestCase):
|
||||
"""2.7: two logos were Inkscape files, one with a 441 x 334 PNG inside."""
|
||||
|
||||
def test_no_editor_markup_and_small_rasters(self):
|
||||
import base64
|
||||
import struct
|
||||
|
||||
for path in sorted(LOGOS.glob("*.svg")):
|
||||
text = _read(path)
|
||||
with self.subTest(logo=path.name):
|
||||
self.assertNotRegex(text, r"inkscape:|sodipodi:|<metadata")
|
||||
self.assertNotRegex(text, r"data:image/(?!png)")
|
||||
for data in re.findall(r"data:image/png;base64,([A-Za-z0-9+/=]+)", text):
|
||||
# Width and height of a PNG: the IHDR chunk, bytes 16 to 24.
|
||||
width, height = struct.unpack(">II", base64.b64decode(data)[16:24])
|
||||
self.assertLessEqual(max(width, height), 80)
|
||||
|
||||
def test_the_fetcher_writes_them_so(self):
|
||||
script = _read(REPO_ROOT / "scripts" / "fetch_site_images.py")
|
||||
self.assertIn('minify_svg(shrink_embedded(safe_svg(data, url)))', script)
|
||||
|
||||
|
||||
class CopyButtonInTheHeader(unittest.TestCase):
|
||||
"""2.10: on touch the copy button covered the end of the first line."""
|
||||
|
||||
def test_rule(self):
|
||||
source = _read(COMPONENTS / "MarkdownContent.astro")
|
||||
self.assertIn("@media (pointer: coarse)", source)
|
||||
self.assertIn(".expressive-code .frame.has-title .copy { inset-block-start:", source)
|
||||
|
||||
|
||||
class AChecksumLineIsAHash(unittest.TestCase):
|
||||
"""The lookup and the search took a bare hash only: the line md5sum,
|
||||
shasum --tag, certutil or Get-FileHash prints was refused whole."""
|
||||
|
||||
def test_the_hash_is_read_out_of_the_line(self):
|
||||
source = _read(SITE_TS)
|
||||
code = (source[source.index("const HASH_LENGTHS"):source.index("\n", source.index("const HASH_LENGTHS"))]
|
||||
+ source[source.index("const normalizeHash"):source.index("// Tables")])
|
||||
md5, sha1 = "490f666e1afb15b7362b406ed1cea246", "b05def971d8ec59f346f2d9ac21fb742e3eb6917"
|
||||
spaced = " ".join(sha1[i:i + 2] for i in range(0, 40, 2))
|
||||
cases = [
|
||||
f"{md5} scph5501.bin",
|
||||
f"SHA1 (scph5501.bin) = {sha1}",
|
||||
f"SHA1 hash of scph5501.bin:\r\n{spaced}\r\nCertUtil: -hashfile command completed successfully.",
|
||||
f"Algorithm Hash Path\n--------- ---- ----\nSHA1 {sha1.upper()} C:\\bios\\scph5501.bin",
|
||||
f" 0x{md5.upper()} ",
|
||||
"8d8cb7e4",
|
||||
"deadbeef.bin",
|
||||
f"{md5} {sha1}",
|
||||
]
|
||||
out = _node(code + f"console.log(JSON.stringify({json.dumps(cases)}.map((c) => "
|
||||
"[normalizeHash(c), isHash(normalizeHash(c))])));")
|
||||
self.assertEqual(json.loads(out), [
|
||||
[md5, True], [sha1, True], [sha1, True], [sha1, True], [md5, True],
|
||||
["8d8cb7e4", True], ["deadbeef.bin", False], [md5 + sha1, False],
|
||||
])
|
||||
|
||||
def test_the_form_takes_the_line(self):
|
||||
source = _read(REPO_ROOT / "scripts" / "generate_site.py")
|
||||
start = source.index('<input id="rb-hash"')
|
||||
self.assertNotIn("pattern=", source[start:source.index("required>", start)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Round 4 theme: the stylesheet fixes of package 1, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: OS tabs on one
|
||||
hidden scrolling row, stacked table cells 16 px apart with labels drawn over
|
||||
the next row, short flags split across lines, lone card links 19 px high, a
|
||||
card label column too narrow for its longest label.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tests.test_site_r2_css import CSS_PATH, _blocks, _rules, _strip_comments
|
||||
|
||||
STACK = ".sl-markdown-content table[data-rb-stack] td"
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
bodies = [b for s, b in _blocks(css) if s == selector]
|
||||
if not bodies:
|
||||
raise AssertionError(f"no block {selector}")
|
||||
return "".join(bodies)
|
||||
|
||||
|
||||
class ThemeStylesheetRound4(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.raw = CSS_PATH.read_text(encoding="utf-8")
|
||||
cls.css = _strip_comments(cls.raw)
|
||||
|
||||
# 1.1
|
||||
def test_os_tabs_wrap_and_never_hide(self):
|
||||
tablist = _block(self.css, "starlight-tabs [role='tablist']")
|
||||
for declaration in ("flex-wrap: wrap", "overflow-x: visible", "row-gap: 0.25rem"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, tablist)
|
||||
self.assertNotIn("scrollbar-width: none", self.css)
|
||||
self.assertNotIn("::-webkit-scrollbar", self.css)
|
||||
|
||||
# 1.2
|
||||
def test_stacked_cells_close_up_and_labels_push_rows(self):
|
||||
cell = _block(self.css, STACK)
|
||||
# Round 5 tightened the values: 7.1rem column, 1.35 lines (test_site_r5_css).
|
||||
for declaration in ("margin-top: 0", "display: flow-root", "line-height: 1.35",
|
||||
"padding: 0 0.6rem 0 7.1rem"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, cell)
|
||||
label = _block(self.css, STACK + "::before")
|
||||
self.assertIn("position: static", label)
|
||||
self.assertIn("float: left", label)
|
||||
self.assertNotIn("position: absolute", label)
|
||||
# The label sits in the cell's left padding.
|
||||
self.assertIn("margin: 0 0.6rem 0 -7.1rem", label)
|
||||
|
||||
# 1.3
|
||||
def test_short_inline_code_never_splits(self):
|
||||
self.assertIn("white-space: pre", _block(self.css, ".sl-markdown-content :not(pre) > code.rb-short"))
|
||||
self.assertIn("white-space: break-spaces", _block(self.css, ".sl-markdown-content :not(pre) > code"))
|
||||
self.assertIn("white-space: nowrap", _block(self.css, ".sl-markdown-content table.rb-options td:first-child"))
|
||||
# A stacked value column is narrow: short code there moves down whole
|
||||
# and splits only when wider than the column, never pushing the page.
|
||||
stacked = _block(self.css, STACK + " code.rb-short")
|
||||
for declaration in ("display: inline-block", "max-width: 100%", "overflow-wrap: anywhere"):
|
||||
with self.subTest(declaration=declaration):
|
||||
self.assertIn(declaration, stacked)
|
||||
# Commands in code blocks keep wrapping on a phone.
|
||||
self.assertIn("white-space: pre-wrap", _block(self.css, ".sl-markdown-content .expressive-code pre > code"))
|
||||
|
||||
# 1.4
|
||||
def test_lone_card_links_are_24px_targets(self):
|
||||
dd = _block(self.css, ".sl-markdown-content .rb-facts dd")
|
||||
self.assertIn("min-height: 24px", dd)
|
||||
self.assertIn("line-height: 24px", dd)
|
||||
link = _block(self.css, ".sl-markdown-content .rb-facts dd > a")
|
||||
self.assertIn("display: inline-block", link)
|
||||
self.assertIn("min-height: 24px", link)
|
||||
|
||||
# 1.5
|
||||
def test_card_labels_fit_and_lookup_labels_match(self):
|
||||
facts = _rules(self.css, "dl.rb-facts")[0]
|
||||
self.assertIn("grid-template-columns: 11rem minmax(0, 1fr)", facts)
|
||||
self.assertIn("font-weight: 600", _block(self.css, ".sl-markdown-content .rb-file-lookup label"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,186 +0,0 @@
|
||||
"""Emulator pages, round 4 (package 7).
|
||||
|
||||
AetherSX2's page offered a command that answered "No supported platform
|
||||
detected" and a file to get by one name, while the app reads any PS2 BIOS
|
||||
by its contents. FinalBurn Neo and MAME 2003-Plus named no romset under
|
||||
their intro. MAME's card pages and the data directory pages did not say
|
||||
where they sit. melonDS marked its DSi files "required" above its DS files.
|
||||
The `00_example` template was listed as an emulator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
|
||||
EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _pages(name: str, profile: dict | None = None) -> dict[str, str]:
|
||||
return gs.emulator_pages(name, profile or _profile(name), EMPTY_DB, None, set())
|
||||
|
||||
|
||||
def _front(page: str) -> dict:
|
||||
assert page.startswith("---\n"), page[:80]
|
||||
return yaml.safe_load(page[4:page.index("\n---\n", 4)])
|
||||
|
||||
|
||||
class AnAppThatReadsAnyBios(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = _pages("aethersx2")["aethersx2"]
|
||||
|
||||
def test_the_intro_says_what_to_do(self):
|
||||
intro = self.page[self.page.index("# AetherSX2"):self.page.index("About this profile")]
|
||||
self.assertIn("AetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", intro)
|
||||
# Round 5 (7.1): the folder itself, per system the app runs on.
|
||||
self.assertRegex(intro, r"\n1\. .+\n2\. Put it in this folder:")
|
||||
self.assertIn("`Android/data/xyz.aethersx2.android/files/bios/`", intro)
|
||||
self.assertIn("Any file name works. The app reads what the file holds.", intro)
|
||||
self.assertNotIn("It reads", intro)
|
||||
|
||||
def test_the_card_names_an_example(self):
|
||||
self.assertIn("a Sony PlayStation 2 BIOS (example: <code>ps2-0230a-20080220.bin</code>)",
|
||||
self.page)
|
||||
|
||||
def test_no_command_that_cannot_place_the_file(self):
|
||||
# Round 5 (7.2): only through a setup that launches the app, on its
|
||||
# device, never as if the app on its own were found.
|
||||
install = self.page[self.page.index("Install this emulator's files"):]
|
||||
install = install[:install.index("</details>")]
|
||||
self.assertRegex(install, r"With [^:]+: the installer finds")
|
||||
self.assertEqual(set(re.findall(r'<TabItem label="([^"]+)">', install)), {"On the device"})
|
||||
nether = _pages("nethersx2")["nethersx2"]
|
||||
if "--emulator nethersx2" in nether:
|
||||
self.assertIn("With ", nether)
|
||||
|
||||
def test_a_core_keeps_its_command(self):
|
||||
# LRPS2 runs in RetroArch, whose BIOS folder the installer finds.
|
||||
page = _pages("lrps2")["lrps2"]
|
||||
self.assertIn("--emulator lrps2", page)
|
||||
self.assertIn("2. Put it in `pcsx2/bios/`, inside your BIOS folder.", page)
|
||||
self.assertIn("a Sony PlayStation 2 BIOS, any file name", page)
|
||||
|
||||
def test_only_found_by_content_with_one_bios(self):
|
||||
# DuckStation picks the BIOS of the game's region: its intro stays.
|
||||
self.assertIsNone(gs.any_name_bios(_profile("duckstation")))
|
||||
self.assertIsNone(gs.any_name_bios(_profile("melonds")))
|
||||
self.assertIsNotNone(gs.any_name_bios(_profile("pcsx2")))
|
||||
|
||||
def test_the_download_links_the_collection_card(self):
|
||||
steps = gs._any_name_steps("X", _profile("aethersx2"),
|
||||
gs.any_name_bios(_profile("aethersx2")),
|
||||
"../systems/sony.md#sha1-abc")
|
||||
self.assertIn("1. Download [`ps2-0230a-20080220.bin`](../systems/sony.md#sha1-abc).", steps)
|
||||
|
||||
|
||||
class EveryArcadeCoreNamesItsRomset(unittest.TestCase):
|
||||
def test_finalburn_and_mame_2003_plus(self):
|
||||
for name, romset in (("fbneo", f"FinalBurn Neo {_profile('fbneo')['core_version']}"),
|
||||
("mame2003_plus", "MAME 0.78 (plus backports)")):
|
||||
page = _pages(name)[name]
|
||||
line = page.index(f"Its BIOS and game sets must come from the {romset} romset.")
|
||||
self.assertLess(line, page.index("About this profile"), name)
|
||||
|
||||
|
||||
class PartsKnowWhereTheySit(unittest.TestCase):
|
||||
def test_emulator_card_parts(self):
|
||||
pages = _pages("mame")
|
||||
parts = sorted((stem for stem in pages if stem.startswith("mame-files-")),
|
||||
key=lambda stem: int(stem.rsplit("-", 1)[1]))
|
||||
self.assertGreater(len(parts), 2)
|
||||
for index, stem in enumerate(parts):
|
||||
front = _front(pages[stem])
|
||||
self.assertEqual(front["parent"], {"label": "MAME", "link": "mame.md"})
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertEqual(front["prev"]["link"],
|
||||
f"{parts[index - 1]}.md" if index else "mame.md")
|
||||
if index + 1 < len(parts):
|
||||
self.assertEqual(front["next"]["link"], f"{parts[index + 1]}.md")
|
||||
else:
|
||||
self.assertIs(front["next"], False)
|
||||
|
||||
def test_data_directory_parts(self):
|
||||
ref = "x-sys"
|
||||
members = {f"f{i:05d}": {"size": 1, "sha1": f"{i:040x}"}
|
||||
for i in range(gs.DATA_TREE_ROWS_PER_PAGE * 2 + 1)}
|
||||
gs._DATA_MEMBERS[ref] = members
|
||||
gs._DATA_READERS[ref] = [("x", "X")]
|
||||
try:
|
||||
pages = gs.data_tree_pages(ref, members)
|
||||
self.assertEqual(len(pages), 3)
|
||||
middle = _front(gs._data_tree_page(ref, {}, pages, 1))
|
||||
finally:
|
||||
del gs._DATA_MEMBERS[ref], gs._DATA_READERS[ref]
|
||||
self.assertEqual(middle["parent"], {"label": "X", "link": "x.md"})
|
||||
self.assertEqual(middle["prev"]["link"], f"{pages[0][0]}.md")
|
||||
self.assertEqual(middle["next"]["link"], f"{pages[2][0]}.md")
|
||||
self.assertEqual(middle["search"], "exclude")
|
||||
|
||||
|
||||
class ARequiredFileSaysForWhichSystem(unittest.TestCase):
|
||||
def test_melonds_dsi_files(self):
|
||||
page = _pages("melonds")["melonds"]
|
||||
row = page[page.index('id="file-dsi-nand-bin"'):]
|
||||
row = row[:row.index("</tr>")]
|
||||
self.assertIn(">required for Nintendo DSi</span>", row)
|
||||
self.assertNotIn(">required</span>", page)
|
||||
|
||||
def test_one_system_says_required(self):
|
||||
# The section heading says it: the rows under it repeat nothing.
|
||||
page = _pages("aethersx2")["aethersx2"]
|
||||
self.assertIn("## Required", page)
|
||||
self.assertNotIn("required for", page)
|
||||
|
||||
|
||||
class TheTemplateIsNoEmulator(unittest.TestCase):
|
||||
def test_off_the_index_and_the_search(self):
|
||||
profiles = {"00_example": _profile("00_example"), "melonds": _profile("melonds")}
|
||||
index = gs.generate_emulators_index(profiles)
|
||||
self.assertNotIn("00_example", index)
|
||||
self.assertIn("melonds.md", index)
|
||||
# A test profile gets no page at all (generate_site's public_profiles).
|
||||
self.assertEqual(_profile("00_example")["type"], "test")
|
||||
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "aethersx2" / "index.html").is_file(), "site not built")
|
||||
class TheBuiltPages(unittest.TestCase):
|
||||
def _html(self, path: str) -> str:
|
||||
return (DIST / path / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
def test_aethersx2_downloads_from_the_collection(self):
|
||||
page = self._html("emulators/aethersx2")
|
||||
self.assertRegex(page, r'Download <a href="[^"]*/systems/sony[^"]*#sha1-[0-9a-f]{12}"')
|
||||
# The command only where a setup launches AetherSX2 itself (KNULLI,
|
||||
# ROCKNIX), never through RetroArch's folder, which the app never reads.
|
||||
self.assertIn("With ROCKNIX or KNULLI:", page)
|
||||
self.assertNotIn("RetroArch", page.split("Install this emulator", 1)[1].split("</details>", 1)[0])
|
||||
|
||||
def test_the_index_leaves_the_template_out(self):
|
||||
# 00_example is `type: test`: no page, no index row.
|
||||
self.assertNotIn("00_example", self._html("emulators"))
|
||||
self.assertIn('data-pagefind-body', self._html("emulators/melonds"))
|
||||
self.assertFalse((DIST / "emulators" / "00_example").exists())
|
||||
|
||||
def test_mame_parts_have_a_parent_crumb(self):
|
||||
page = self._html("emulators/mame-files-3")
|
||||
crumbs = re.search(r'<nav[^>]*rb-breadcrumbs.*?</nav>', page, re.S)
|
||||
self.assertIsNotNone(crumbs)
|
||||
self.assertIn(">MAME<", crumbs.group(0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,222 +0,0 @@
|
||||
"""Entry pages and guides (round 4, package 4).
|
||||
|
||||
Six handheld setups have no ZIP, and three of them offered SSH as root as
|
||||
the only route while their page already named a network folder. Six rows
|
||||
of the Linux handhelds table said "The command finds it on the device" for
|
||||
devices with no terminal, while the installer held each folder with its
|
||||
source. The offline line sat on six setup pages, the home page carried a
|
||||
stray "Reference:" label, the Your setup index left out the handheld tiles,
|
||||
and an iPhone owner found one FAQ line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import siteguides # noqa: E402
|
||||
import sitemarkup # noqa: E402
|
||||
from common import load_platform_registry # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
|
||||
HANDHELDS = siteguides.HANDHELD_GUIDE
|
||||
OFFLINE_ANCHOR = "#without-a-network-offline-install"
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
sitepages.require()
|
||||
return re.sub(r"</?abbr[^>]*>", "", sitepages.page(name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _guide_part(text: str) -> str:
|
||||
match = re.search(r"^## Reference\b", text, re.M)
|
||||
return text[: match.start()] if match else text
|
||||
|
||||
|
||||
def _with_zip(name: str) -> bool:
|
||||
pack = release_record.pack_for(name, release_record.load_advertised())
|
||||
return bool(pack and pack.get("assets"))
|
||||
|
||||
|
||||
SETUPS = [name for name in siteguides.GUIDES if name in REGISTRY]
|
||||
|
||||
|
||||
class NoZipNoUnzipText(unittest.TestCase):
|
||||
def test_a_setup_without_a_zip_never_says_unzip_or_download_pack(self):
|
||||
for name in SETUPS:
|
||||
if _with_zip(name):
|
||||
continue
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
with self.subTest(setup=name):
|
||||
self.assertNotIn("Unzip the pack into", text)
|
||||
self.assertNotIn("pack holds its own", text)
|
||||
self.assertNotRegex(text, r"Download \S+ Pack")
|
||||
self.assertNotIn("/releases/download/", text)
|
||||
|
||||
def test_the_minui_family_makes_each_folder(self):
|
||||
for name in ("minui", "myminui", "nextui"):
|
||||
with self.subTest(setup=name):
|
||||
self.assertIn("The command makes each console's folder for you.",
|
||||
_page(f"platforms/{name}.md"))
|
||||
|
||||
def test_knulli_is_filled_over_the_network_with_the_samba_switch_cited(self):
|
||||
text = _guide_part(_page("platforms/knulli.md"))
|
||||
self.assertIn("## From your computer { #from-your-computer }", text)
|
||||
self.assertIn("--platform knulli --dest \\\\KNULLI\\share\\bios\n", text)
|
||||
self.assertRegex(text, r"turn on \*\*SAMBA\*\* in \*\*System Settings > Services\*\*\s*"
|
||||
r"\(\[KNULLI docs\]\(https://github\.com/knulli-cfw/knulli\.org/blob/"
|
||||
r"[0-9a-f]{40}/docs/play/add-games/network-transfer\.md")
|
||||
# The computer route comes before the SSH one.
|
||||
self.assertLess(text.index("## From your computer"), text.index("## On the device"))
|
||||
|
||||
def test_the_elec_systems_say_they_need_the_network(self):
|
||||
for name, share in (("amberelec", "\\\\AmberELEC\\roms"), ("emuelec", "\\\\EMUELEC\\roms")):
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
with self.subTest(setup=name):
|
||||
self.assertIn("Needs the device on your network.", text)
|
||||
self.assertIn(f"--platform {name} --dest {share}\n", text)
|
||||
|
||||
def test_a_mac_and_a_linux_desktop_reach_the_share_too(self):
|
||||
"""The share route was a PowerShell tab alone: a Mac user with Recalbox
|
||||
on Wi-Fi fell back to SSH or to taking the card out."""
|
||||
args, how = generate_site._share_tabs("recalbox", "\\\\RECALBOX\\share\\bios")
|
||||
self.assertEqual(args["macos"], "--platform recalbox --dest /Volumes/share/bios")
|
||||
self.assertIn("`smb://recalbox/share`", how["macos"])
|
||||
self.assertEqual(args["linux"], "--platform recalbox --dest /path/to/share/bios")
|
||||
self.assertIn("`smb://recalbox/share`", how["linux"])
|
||||
lines = generate_site._setup_routes("recalbox", "Recalbox")
|
||||
routes = "\n".join(lines[:lines.index("## On the device { #install }")])
|
||||
for label in ("Windows", "macOS", "Linux"):
|
||||
self.assertIn(f'label="{label}"', routes)
|
||||
# The setup page sends a Mac or Linux reader to those tabs.
|
||||
lines = generate_site._setup_routes("recalbox", "Recalbox", page_link=True)
|
||||
self.assertIn("[Mac or Linux](../get/recalbox.md#from-your-computer)", lines)
|
||||
|
||||
def test_the_network_route_needs_a_share_and_no_zip(self):
|
||||
for name in SETUPS:
|
||||
share = generate_site._network_share(name, siteguides.GUIDES[name])
|
||||
with self.subTest(setup=name):
|
||||
if _with_zip(name) or siteguides.GUIDES[name].run != ("device",):
|
||||
self.assertEqual(share, "")
|
||||
if share:
|
||||
self.assertTrue(share.startswith("\\\\"))
|
||||
self.assertFalse(share.endswith("\\"))
|
||||
|
||||
|
||||
class EveryHandheldRowSaysWhere(unittest.TestCase):
|
||||
def test_no_row_sends_the_reader_to_a_command(self):
|
||||
text = _page(HANDHELDS.path)
|
||||
self.assertNotIn("The command finds it on the device", text)
|
||||
rows = [line for line in text.splitlines() if line.startswith("| ")][1:]
|
||||
self.assertEqual(len(rows), len(HANDHELDS.systems))
|
||||
for row in rows:
|
||||
folder = row.rstrip(" |").rsplit(" | ", 1)[-1]
|
||||
with self.subTest(row=row[:40]):
|
||||
self.assertTrue("([source](https://" in folder
|
||||
or folder == generate_site.RETROARCH_FOLDER_SETTING, folder)
|
||||
|
||||
def test_the_installer_folders_are_written_with_their_source(self):
|
||||
systems = {s.name: s for s in HANDHELDS.systems}
|
||||
for name in ("Allium", "JELOS", "KOLOS", "MiyooCFW"):
|
||||
with self.subTest(system=name):
|
||||
self.assertTrue(systems[name].folder)
|
||||
self.assertRegex(systems[name].folder_source, r"/[0-9a-f]{40}/")
|
||||
|
||||
def test_a_folder_only_linux_reads_says_what_to_do(self):
|
||||
text = _page(HANDHELDS.path)
|
||||
for system in HANDHELDS.systems:
|
||||
row = next(line for line in text.splitlines() if line.startswith(f"| {system.name} |"))
|
||||
with self.subTest(system=system.name):
|
||||
self.assertEqual(generate_site.LINUX_ONLY_HINT in row, system.linux_only)
|
||||
self.assertTrue(any(s.linux_only for s in HANDHELDS.systems if s.name == "ArchR"))
|
||||
self.assertNotIn("Windows and macOS cannot open", text)
|
||||
|
||||
def test_linux_only_needs_a_folder(self):
|
||||
with self.assertRaises(siteguides.GuideError):
|
||||
siteguides._handheld_system({"name": "X", "linux_only": True}, "test")
|
||||
|
||||
def test_download_is_one_jump_under_the_lead(self):
|
||||
text = _page(HANDHELDS.path)
|
||||
self.assertIn("[Download](#download)", text)
|
||||
self.assertLess(text.index("[Download](#download)"), text.index("## Where the files go"))
|
||||
|
||||
|
||||
class OfflineLineWithEveryZip(unittest.TestCase):
|
||||
def test_every_setup_with_a_zip_links_the_offline_install(self):
|
||||
for name in SETUPS:
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
with self.subTest(setup=name):
|
||||
self.assertEqual(OFFLINE_ANCHOR in text, _with_zip(name))
|
||||
|
||||
def test_the_handheld_guide_links_it(self):
|
||||
self.assertEqual(OFFLINE_ANCHOR in _page(HANDHELDS.path), _with_zip(HANDHELDS.uses))
|
||||
|
||||
|
||||
class EntryCopy(unittest.TestCase):
|
||||
def test_no_reference_label_on_home_explore_and_the_setup_index(self):
|
||||
for name in ("index.md", "explore.md", "platforms/index.md"):
|
||||
if not sitepages.page(name).is_file():
|
||||
continue
|
||||
body = _page(name).split("\n---\n", 1)[-1]
|
||||
with self.subTest(page=name):
|
||||
self.assertNotIn("Reference: [", body)
|
||||
self.assertIn("- Have a file? [Check it](/retrobios/files/)", _page("index.md"))
|
||||
|
||||
def test_the_explore_cards_have_bare_links(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
self.assertNotIn('"Reference: [How each platform checks files]', source)
|
||||
self.assertNotIn('"Reference: [Data and API]', source)
|
||||
|
||||
def test_the_setup_index_lists_the_handheld_tiles(self):
|
||||
text = _page("platforms/index.md")
|
||||
for tile in siteguides.GROUP_TILES["handheld"]:
|
||||
target = tile.link.removesuffix(".md").replace(".md#", "/#")
|
||||
with self.subTest(tile=tile.name):
|
||||
self.assertIn(f"**[{tile.name}](/retrobios/{target}", text.replace("/)", ")"))
|
||||
self.assertIn(tile.text, text)
|
||||
|
||||
|
||||
class IphoneAndIpad(unittest.TestCase):
|
||||
def test_a_tile_in_the_handheld_group(self):
|
||||
tile = next(t for t in siteguides.GROUP_TILES["handheld"] if t.name == "iPhone and iPad")
|
||||
self.assertEqual(tile.link, "which-pack.md#iphone-and-ipad")
|
||||
for name in ("index.md", "which-pack.md"):
|
||||
with self.subTest(page=name):
|
||||
self.assertIn('href="/retrobios/which-pack/#iphone-and-ipad"', _page(name))
|
||||
self.assertEqual(sitemarkup.initials("iPhone and iPad"), "iP")
|
||||
|
||||
def test_each_app_step_is_cited_and_the_picker_replaces_the_zip(self):
|
||||
text = _page("which-pack.md")
|
||||
section = text.split("## iPhone and iPad { #iphone-and-ipad }", 1)[1].split("\n## ", 1)[0]
|
||||
self.assertIn('data-pagefind-meta="devices:iPhone iPad iOS Delta Provenance"', section)
|
||||
self.assertIn("/retrobios/which-pack/?platform=retroarch#only-some-consoles", section)
|
||||
items = [line for line in section.splitlines() if line.startswith("- ")]
|
||||
self.assertEqual([re.match(r"- \*\*(\w+)\*\*", i)[1] for i in items],
|
||||
["RetroArch", "Delta", "Provenance"])
|
||||
for item in items:
|
||||
with self.subTest(item=item[:20]):
|
||||
self.assertRegex(item, r"\(\[(docs|code)\]\(https://")
|
||||
self.assertNotIn("/get/retroarch/", section)
|
||||
|
||||
|
||||
class WhatItAsks(unittest.TestCase):
|
||||
def test_the_page_says_the_screen_not_a_line_menu(self):
|
||||
# The one-liner opens the full screen: a "What it asks" block of the
|
||||
# line menu showed keys a beginner never sees (owner's note).
|
||||
text = _page("which-pack.md")
|
||||
self.assertNotIn('title="What it asks"', text)
|
||||
for line in generate_site.first_screen_words():
|
||||
self.assertIn(line, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,367 +0,0 @@
|
||||
"""Reference pages and data (round 4, package 8).
|
||||
|
||||
The lookup answered a SHA1 without the setups that list the same file by
|
||||
MD5, the published folder check failed 134 EmuDeck entries a folder EmuDeck
|
||||
accepts, the manifest table printed `<ref>` behind a dead link and showed
|
||||
17 of 48 fields, 28 system ids held files and no page, and source links went
|
||||
to 404s on the deploy, which has no tree cache. Tests that read the built
|
||||
site skip when it has not been generated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import check_platform_folder # noqa: E402
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import sitesources # noqa: E402
|
||||
import upstream # noqa: E402
|
||||
|
||||
PUBLIC = REPO_ROOT / "website" / "public"
|
||||
API = PUBLIC / "api" / "v1"
|
||||
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
TMP = REPO_ROOT / "tmp"
|
||||
|
||||
|
||||
def _built(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
raise unittest.SkipTest(f"{path.relative_to(REPO_ROOT)} not generated")
|
||||
return path
|
||||
|
||||
|
||||
def _page(stem: str) -> str:
|
||||
for suffix in (".md", ".mdx"):
|
||||
path = DOCS / f"{stem}{suffix}"
|
||||
if path.is_file():
|
||||
return path.read_text(encoding="utf-8")
|
||||
raise unittest.SkipTest(f"{stem} not generated")
|
||||
|
||||
|
||||
class LookupRecordsUnderEveryHash(unittest.TestCase):
|
||||
"""8.1: a platform listing a file by MD5 is found by its SHA1 too."""
|
||||
|
||||
SHA1 = "a" * 40
|
||||
FILE = {"name": "bios.bin", "path": "bios/Sony/PlayStation/bios.bin", "sha1": SHA1,
|
||||
"md5": "b" * 32, "crc32": "c" * 8, "sha256": "d" * 64}
|
||||
|
||||
def records(self) -> list[dict]:
|
||||
db = {"files": {self.SHA1: self.FILE}, "indexes": {
|
||||
"by_md5": {"b" * 32: self.SHA1}, "by_crc32": {"c" * 8: self.SHA1},
|
||||
"by_sha256": {"d" * 64: self.SHA1}}}
|
||||
coverages = {"setup": {"config": {"systems": {"sony-playstation": {"files": [
|
||||
{"name": "bios.bin", "md5": "b" * 32}]}}}}}
|
||||
profiles = {"core": {"type": "libretro", "systems": ["sony-playstation"],
|
||||
"files": [{"name": "bios.bin", "crc32": "c" * 8}]}}
|
||||
with mock.patch.object(generate_site, "_console_system_names", return_value={}), \
|
||||
mock.patch.object(generate_site, "_lookup_system_name", return_value=None), \
|
||||
mock.patch.object(generate_site, "emulator_card_page", side_effect=lambda k, a: k):
|
||||
return generate_site.file_lookup_records(
|
||||
db, coverages, profiles, [{"sha1": self.SHA1, "url": "/x"}])
|
||||
|
||||
def test_each_hash_names_the_setup_and_the_emulator(self):
|
||||
records = self.records()
|
||||
for value in (self.SHA1, "b" * 32, "c" * 8, "d" * 64):
|
||||
owners = {(r["layer"], r.get("owner")) for r in records if r["value"] == value}
|
||||
self.assertIn(("platform", "setup"), owners, value)
|
||||
self.assertIn(("emulator", "core"), owners, value)
|
||||
|
||||
def test_records_carry_the_sha1_and_say_what_was_declared(self):
|
||||
for record in self.records():
|
||||
if record["layer"] in ("platform", "emulator"):
|
||||
self.assertEqual(record["sha1"], self.SHA1)
|
||||
copy = next(r for r in self.records()
|
||||
if r["layer"] == "platform" and r["value"] == self.SHA1)
|
||||
self.assertEqual(copy["declared"], "md5")
|
||||
|
||||
def test_scph5501_gives_one_answer_for_its_four_hashes(self):
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
sha1 = "0555c6fae8906f3f09baf5988f00e55f88e9f30b"
|
||||
entry = db["files"][sha1]
|
||||
_built(API / "lookup")
|
||||
answers = []
|
||||
for algorithm in ("sha1", "md5", "crc32", "sha256"):
|
||||
value = str(entry[algorithm]).lower()
|
||||
shard = json.loads((API / "lookup" / f"{value[:2]}.json").read_text())
|
||||
found = [r for r in shard["items"] if r["value"] == value]
|
||||
answers.append(tuple(sorted({(r["layer"], r.get("owner", "")) for r in found})))
|
||||
self.assertEqual(len(set(answers)), 1, answers)
|
||||
setups = {owner for layer, owner in answers[0] if layer == "platform"}
|
||||
emulators = {owner for layer, owner in answers[0] if layer == "emulator"}
|
||||
self.assertGreaterEqual(len(setups), 10)
|
||||
self.assertGreaterEqual(len(emulators), 8)
|
||||
|
||||
|
||||
class TheFolderCheckReadsEmuDecksWhitelist(unittest.TestCase):
|
||||
"""8.2: an entry with no destination names no file. EmuDeck's check
|
||||
passes the system when any top-level file of the folder has its md5."""
|
||||
|
||||
def check(self, files: dict[str, bytes]) -> list[tuple[str, str]]:
|
||||
good = b"ps1 bios bytes"
|
||||
contract = {"verification_mode": "md5", "systems": {"sony-playstation": {"files": [
|
||||
{"name": f"sony-playstation:{hashlib.md5(good).hexdigest()}", "destination": "",
|
||||
"md5": hashlib.md5(good).hexdigest()},
|
||||
{"name": "sony-playstation:" + "0" * 32, "destination": "", "md5": "0" * 32},
|
||||
]}}}
|
||||
with tempfile.TemporaryDirectory(dir=TMP) as tmp:
|
||||
root = Path(tmp) / "bios"
|
||||
(root / "sub").mkdir(parents=True)
|
||||
for name, data in files.items():
|
||||
(root / name).write_bytes(data)
|
||||
api = Path(tmp) / "api"
|
||||
api.mkdir()
|
||||
(api / "emudeck.json").write_text(json.dumps({"items": [{"contract": contract}]}))
|
||||
found = check_platform_folder.check("emudeck", root, api.as_uri() + "/")
|
||||
# The verifier's verdict on the matching entry, file by file.
|
||||
from verify import verify_entry_md5 # noqa: PLC0415
|
||||
|
||||
verdicts = {name: verify_entry_md5(contract["systems"]["sony-playstation"]["files"][0],
|
||||
str(root / name))["status"]
|
||||
for name in files if (root / name).is_file()}
|
||||
return found, verdicts
|
||||
|
||||
def test_any_name_passes(self):
|
||||
found, verdicts = self.check({"my-ps1-dump.bin": b"ps1 bios bytes"})
|
||||
self.assertEqual(found, [("sony-playstation: any file", "ok")])
|
||||
self.assertEqual(verdicts, {"my-ps1-dump.bin": "ok"})
|
||||
|
||||
def test_a_folder_without_it_is_missing(self):
|
||||
found, _verdicts = self.check({"other.bin": b"x", "sub/deep.bin": b"ps1 bios bytes"})
|
||||
self.assertEqual(found, [("sony-playstation: any file", "missing")])
|
||||
|
||||
def test_the_data_page_states_the_rule(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
self.assertIn("An entry with no `destination` (EmuDeck) names no file", page)
|
||||
self.assertLess(len((REPO_ROOT / "scripts" / "check_platform_folder.py")
|
||||
.read_text().splitlines()), 70)
|
||||
|
||||
|
||||
class ManifestFieldsAreComplete(unittest.TestCase):
|
||||
"""8.3 and 8.4."""
|
||||
|
||||
SCHEMA = json.loads((REPO_ROOT / "schemas" / "install-manifest.schema.json").read_text())
|
||||
|
||||
def test_a_url_template_is_code(self):
|
||||
rows = generate_site._schema_field_rows(self.SCHEMA["$defs"]["file"], ("repo_path",))
|
||||
self.assertIn("`https://raw.githubusercontent.com/Abdess/retrobios/<ref>/<repo_path>`",
|
||||
rows[0])
|
||||
rows = generate_site._schema_field_rows(
|
||||
{"properties": {"a": {"description": "From https://x.test/<asset>, then."}}}, ("a",))
|
||||
self.assertEqual(rows, ["| `a` | From `https://x.test/<asset>`, then. |"])
|
||||
|
||||
def test_every_field_is_described_and_shown(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
for schema in (self.SCHEMA, self.SCHEMA["$defs"]["file"]):
|
||||
for key, spec in schema["properties"].items():
|
||||
self.assertTrue(spec.get("description"), key)
|
||||
self.assertIn(f"| `{key}` |", page)
|
||||
|
||||
def test_no_link_carries_an_escaped_bracket(self):
|
||||
html = _built(DIST / "data" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertNotRegex(html, r'href="[^"]*&lt;')
|
||||
self.assertNotRegex(html, r'href="[^"]*<')
|
||||
|
||||
|
||||
class EverySystemIdWithFilesHasAPage(unittest.TestCase):
|
||||
"""8.5: tyrian, scummvm, dos and 25 more had files and a null url."""
|
||||
|
||||
def test_built_index(self):
|
||||
items = json.loads(_built(API / "systems.json").read_text())["items"]
|
||||
orphans = [item["id"] for item in items if item["sha1"] and not item["url"]]
|
||||
self.assertEqual(orphans, [])
|
||||
|
||||
def test_first_file_then_emulator(self):
|
||||
files = {"f" * 40: {"path": "bios/DOS/DOS/a.bin"}}
|
||||
with mock.patch("siterender._card_pages", {}):
|
||||
place = generate_site._first_file_place(
|
||||
"dos", ["f" * 40], files, {}, lambda path, sha1: "dos")
|
||||
self.assertEqual(place, ("DOS", "/retrobios/systems/dos/#sha1-ffffffffffff"))
|
||||
place = generate_site._first_file_place(
|
||||
"tyrian", [], files, {"opentyrian": {"systems": ["tyrian"]}}, None)
|
||||
self.assertEqual(place, ("", "/retrobios/emulators/opentyrian/"))
|
||||
|
||||
|
||||
# A source permalink of each forge family the pages write.
|
||||
_PERMALINK = re.compile(
|
||||
r"\]\(https://(?P<host>[^/]+)/(?P<slug>[^/]+/[^/]+)/"
|
||||
r"(?:blob/|-/blob/|src/commit/)(?P<pin>[0-9a-f]{40})/(?P<path>[^#)]+)")
|
||||
|
||||
|
||||
class SourceLinksOnlyWhereTheFileIs(unittest.TestCase):
|
||||
"""8.6: the deploy reads the committed record, never a tree cache."""
|
||||
|
||||
def test_an_address_in_a_binary_reads_as_one(self):
|
||||
profile = {"source": "https://github.com/o/r", "source_commit": "e" * 40}
|
||||
text = sitesources._source_ref_markdown(
|
||||
profile, "Nesoid 2.5.0 libnes.so 0x455a4-0x45694 (setter), 0x8094-0x80ac")
|
||||
self.assertEqual(text, "offset `0x455a4-0x45694` in `Nesoid 2.5.0 libnes.so`, "
|
||||
"offset `0x8094-0x80ac` in `Nesoid 2.5.0 libnes.so`")
|
||||
|
||||
def test_only_a_recorded_path_links(self):
|
||||
profile = {"source": "https://github.com/o/r", "source_commit": "e" * 40}
|
||||
repo = upstream.parse_repo("https://github.com/o/r")
|
||||
record = {sitesources.tree_key(repo, "e" * 40): frozenset({"src/a.c"})}
|
||||
with mock.patch.object(sitesources, "_verified", return_value=record):
|
||||
sitesources._tree_paths.cache_clear()
|
||||
try:
|
||||
text = sitesources._source_ref_markdown(profile, "src/a.c:5, src/b.c:7")
|
||||
finally:
|
||||
sitesources._tree_paths.cache_clear()
|
||||
self.assertIn("[`src/a.c:5`](https://github.com/o/r/blob/" + "e" * 40 + "/src/a.c#L5)", text)
|
||||
self.assertIn("`src/b.c:7`", text)
|
||||
self.assertNotIn("src/b.c#", text)
|
||||
|
||||
def test_the_writer_keeps_held_paths_only(self):
|
||||
profile = {"source": "https://github.com/o/r", "source_commit": "e" * 40,
|
||||
"notes": "see src/c.c:3", "files": [{"source_ref": "src/a.c:5, gone.c:1"}]}
|
||||
with mock.patch.object(sitesources, "_cached_tree",
|
||||
return_value=frozenset({"src/a.c", "src/c.c", "x.c"})):
|
||||
held, unread = sitesources.build_source_paths({"p": profile}, [TMP])
|
||||
self.assertEqual(held, {"github.com/o/r@" + "e" * 40: ["src/a.c", "src/c.c"]})
|
||||
self.assertEqual(unread, [])
|
||||
|
||||
def test_a_tree_no_directory_answers_for_stays_unread(self):
|
||||
# TeknoParrotUI answers 404: listed folder by folder, every listing
|
||||
# failed and the tree read as holding nothing, so a fetch without its
|
||||
# cached tree dropped its four recorded paths.
|
||||
profile = {"source": "https://github.com/o/r", "source_commit": "e" * 40,
|
||||
"files": [{"source_ref": "src/a.c:5"}]}
|
||||
with mock.patch.object(sitesources, "_cached_tree", return_value=None), \
|
||||
mock.patch.object(sitesources.upstream, "list_directory",
|
||||
side_effect=upstream.UpstreamError("HTTP 404")):
|
||||
held, unread = sitesources.build_source_paths({"p": profile}, [TMP])
|
||||
self.assertEqual(held, {})
|
||||
self.assertEqual(unread, ["github.com/o/r@" + "e" * 40])
|
||||
|
||||
def test_every_rendered_link_is_recorded(self):
|
||||
record = json.loads(sitesources.SOURCE_PATHS.read_text(encoding="utf-8"))
|
||||
pages = sorted(_built(DOCS / "emulators").rglob("*.md*")) + sorted(DOCS.glob("gaps*.md*"))
|
||||
import measurement_records # noqa: PLC0415
|
||||
|
||||
# A Tested block links a record line and its data file in this
|
||||
# repository. The records are append-only, so a file a permalink
|
||||
# names is still in the tree.
|
||||
records = set(measurement_records.tree_files(REPO_ROOT))
|
||||
checked, stray = 0, []
|
||||
for page in pages:
|
||||
for found in _PERMALINK.finditer(page.read_text(encoding="utf-8")):
|
||||
key = f"{found['host']}/{found['slug']}@{found['pin']}"
|
||||
path = urllib.parse.unquote(found["path"])
|
||||
if "/Abdess/retrobios/" in found[0]:
|
||||
if path.startswith(f"{measurement_records.RECORDS_DIR}/") and path not in records:
|
||||
stray.append((page.name, key, path))
|
||||
continue
|
||||
checked += 1
|
||||
if path not in record.get(key, ()):
|
||||
stray.append((page.name, key, path))
|
||||
self.assertGreater(checked, 1000)
|
||||
self.assertEqual(stray[:20], [])
|
||||
|
||||
def test_the_record_is_read_by_the_deploy(self):
|
||||
"""The record is committed where the build reads it, sorted."""
|
||||
record = json.loads(sitesources.SOURCE_PATHS.read_text(encoding="utf-8"))
|
||||
self.assertGreater(len(record), 300)
|
||||
for key, paths in record.items():
|
||||
self.assertRegex(key, r"^[a-z0-9.-]+/.+@[0-9a-f]{40}$")
|
||||
self.assertEqual(paths, sorted(set(paths)), key)
|
||||
|
||||
|
||||
class TheReferencePagesAnswerTheirWords(unittest.TestCase):
|
||||
"""8.7, 8.8, 8.10 and 8.13."""
|
||||
|
||||
def test_find_or_check_a_file(self):
|
||||
page = generate_site.generate_files_page()
|
||||
self.assertTrue(page.startswith(
|
||||
"---\nterms: check my dump, md5 check, verify bios file, check hash, "
|
||||
"is my bios correct, is my bios right\n---\n"))
|
||||
self.assertIn("\n# Find or check a file\n\nCheck a BIOS dump: paste its hash "
|
||||
"or choose the file.\n", page)
|
||||
|
||||
def test_the_data_page(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
self.assertTrue(page.startswith(
|
||||
"---\nterms: manifest, schema, json schema, license, check a folder, "
|
||||
"check bios folder script, rate limit, api\n---\n"))
|
||||
self.assertIn("## Hosting, rate limits and caching", page)
|
||||
self.assertIn("HTTP 429", page)
|
||||
self.assertIn("crypto.subtle.digest(\"SHA-1\"", page)
|
||||
self.assertIn("api/v1/lookup/", page)
|
||||
self.assertIn("`preferred: true`", page)
|
||||
self.assertIn("`database.json`, `stats.json`, `release.json` and the manifests are not "
|
||||
"envelopes", page)
|
||||
self.assertIn("- MinUI, MyMinUI and NextUI have no list of their own "
|
||||
"(`native_list: none`). An empty list does not mean nothing is needed.", page)
|
||||
|
||||
def test_overview_ids_and_counts_are_not_indexed(self):
|
||||
html = _built(DIST / "platforms" / "batocera" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertRegex(html, r"<small data-pagefind-ignore(=\"true\")?>sony-playstation</small>")
|
||||
self.assertRegex(html, r"<span data-pagefind-ignore(=\"true\")?>\d+/\d+</span>")
|
||||
|
||||
|
||||
class TheReleaseRecordHasAVersion(unittest.TestCase):
|
||||
"""8.9."""
|
||||
|
||||
def test_the_writer_and_the_committed_record(self):
|
||||
self.assertEqual(release_record.SCHEMA_VERSION, 1)
|
||||
source = (REPO_ROOT / "scripts" / "release_record.py").read_text(encoding="utf-8")
|
||||
self.assertIn('{"schema_version": SCHEMA_VERSION, "tag": tag, "packs": packs}', source)
|
||||
schema = json.loads((REPO_ROOT / "schemas" / "release.schema.json").read_text())
|
||||
self.assertIn("schema_version", schema["required"])
|
||||
record = json.loads((REPO_ROOT / "release.json").read_text())
|
||||
self.assertEqual(record["schema_version"], 1)
|
||||
|
||||
|
||||
class NoEmptyCoreTable(unittest.TestCase):
|
||||
"""8.11: MiSTer and BizHawk declare cores: [] and had a page of nothing."""
|
||||
|
||||
def test_own_list_platforms_get_a_sentence(self):
|
||||
coverages = {"misterfpga": {"platform": "MiSTer FPGA", "config": {"cores": [], "systems": {}}}}
|
||||
moved: dict[str, str] = {}
|
||||
page = generate_site.generate_cross_reference(coverages, {}, moved)
|
||||
self.assertEqual(moved, {})
|
||||
self.assertIn(generate_site.OWN_LIST_ONLY, page)
|
||||
|
||||
def test_built_pages(self):
|
||||
_built(DOCS / "cross-reference.md")
|
||||
for name in ("misterfpga", "bizhawk"):
|
||||
self.assertFalse((DOCS / f"cross-reference-{name}.md").exists())
|
||||
self.assertIn(generate_site.OWN_LIST_ONLY, _page(f"platforms/{name}"))
|
||||
redirects = json.loads((REPO_ROOT / "website" / "src" / "generated" /
|
||||
"redirects.json").read_text())
|
||||
self.assertEqual(redirects.get("/cross-reference-misterfpga/"), "/platforms/misterfpga/")
|
||||
|
||||
|
||||
class FilesToSourceOnAPhone(unittest.TestCase):
|
||||
"""8.12: 601 rows on one page, no way to give a file."""
|
||||
|
||||
def test_parts_of_a_hundred_rows_each_with_an_upload(self):
|
||||
_built(DOCS / "gaps-files-to-source.md")
|
||||
parts = sorted(DOCS.glob("gaps-files-to-source*.md"))
|
||||
self.assertGreater(len(parts), 1)
|
||||
for part in parts:
|
||||
rows = [line for line in part.read_text(encoding="utf-8").splitlines()
|
||||
if line.startswith("| `")]
|
||||
self.assertLessEqual(len(rows), generate_site.FILES_TO_SOURCE_PART, part.name)
|
||||
for row in rows:
|
||||
self.assertIn(f"[Have it? Upload]({generate_site.UPLOAD_URL}", row)
|
||||
|
||||
def test_the_upload_lands_in_the_console_folder(self):
|
||||
with mock.patch("siterender._own_section", return_value=("Sony", "PlayStation")):
|
||||
self.assertEqual(generate_site._upload_folder({"systems": ["sony-playstation"]}),
|
||||
"Sony/PlayStation")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,260 +0,0 @@
|
||||
"""Search (round 4, package 3).
|
||||
|
||||
The search gate listed the queries that missed and no workflow ran it: its
|
||||
browser runner could not even start from the repository root. Console and
|
||||
error words had no field to reach their page ("wonderswan color", "bios not
|
||||
found"), parts of a split page carried the console page's weight and came
|
||||
first for "which mame version", and 707 pages gave search engines one of
|
||||
five template sentences of level 3 words as their description. These tests
|
||||
read the generator, the gate, the workflow and, where it is built, the site.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
import sitestarlight # noqa: E402
|
||||
import validate_site # noqa: E402
|
||||
|
||||
TOOLS = REPO_ROOT / "website" / "tools"
|
||||
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "deploy-site.yml"
|
||||
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _front(text: str) -> dict:
|
||||
return yaml.safe_load(text.split("---\n")[1])
|
||||
|
||||
|
||||
def _gate_queries() -> dict:
|
||||
tree = ast.parse((TOOLS / "search_gate.py").read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "QUERIES":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("no QUERIES in search_gate.py")
|
||||
|
||||
|
||||
def _finalize(rel: str, body: str, authored: dict) -> dict:
|
||||
_target, text = sitestarlight.finalize_page(
|
||||
rel, body, level="", weight=None, browser_title="T", description="d",
|
||||
structured={}, terms={}, authored=authored)
|
||||
return {"front": _front(text), "text": text}
|
||||
|
||||
|
||||
class TheGateRunsAndBlocks(unittest.TestCase):
|
||||
def test_the_deploy_runs_the_gate_before_the_upload(self) -> None:
|
||||
steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["build"]["steps"]
|
||||
runs = [str(step.get("run", "")) for step in steps]
|
||||
uses = [str(step.get("uses", "")) for step in steps]
|
||||
gate = next(i for i, run in enumerate(runs)
|
||||
if "website/tools/search_gate.py --site-dir website/dist" in run)
|
||||
build = next(i for i, run in enumerate(runs) if "npm run build --prefix website" in run)
|
||||
upload = next(i for i, use in enumerate(uses) if "upload-pages-artifact" in use)
|
||||
self.assertLess(build, gate)
|
||||
self.assertLess(gate, upload)
|
||||
# The driver is pinned to a version and kept out of the lockfile.
|
||||
self.assertRegex(runs[gate], r"npm install --no-save --prefix website playwright-core@\d+\.\d+\.\d+")
|
||||
|
||||
def test_no_third_party_action_and_every_action_pinned(self) -> None:
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
for repo, ref in re.findall(r"uses:\s*([\w.-]+/[\w.-]+)@(\S+)", text):
|
||||
with self.subTest(action=repo):
|
||||
self.assertTrue(repo.startswith("actions/"))
|
||||
self.assertRegex(ref, r"^[0-9a-f]{40}$")
|
||||
|
||||
def test_the_runner_resolves_from_website_and_says_what_is_missing(self) -> None:
|
||||
source = (TOOLS / "measure.mjs").read_text(encoding="utf-8")
|
||||
self.assertIn("await import('playwright-core')", source)
|
||||
self.assertIn("npm install --no-save --prefix website playwright-core", source)
|
||||
self.assertNotRegex(source, r"^import \{ chromium \}", "a static import fails before any message")
|
||||
|
||||
def test_the_gate_serves_a_build_dir_and_fails_on_a_miss(self) -> None:
|
||||
source = (TOOLS / "search_gate.py").read_text(encoding="utf-8")
|
||||
self.assertIn("--site-dir", source)
|
||||
self.assertIn("serve.mjs", source)
|
||||
self.assertIn("return 0 if answered == len(QUERIES)", source)
|
||||
|
||||
def test_the_plans_queries_are_gated(self) -> None:
|
||||
queries = _gate_queries()
|
||||
expected = {
|
||||
"playstation bios": ("systems/sony-playstation/", 1),
|
||||
"please insert playstation cd-rom": ("wiki/troubleshooting/", 3),
|
||||
"bios not found": ("wiki/troubleshooting/", 3),
|
||||
"check my dump": ("files/", 1),
|
||||
"md5 check": ("files/", 1),
|
||||
"manifest": ("data/", 3),
|
||||
"events": ("wiki/installer/", 3),
|
||||
"exit code": ("wiki/installer/", 3),
|
||||
"rate limit": ("data/", 3),
|
||||
"ios": ("wiki/faq/", 3),
|
||||
"dc bios missing": ("emulators/flycast/", 3),
|
||||
"neogeo.zip": ("systems/snk/", 1),
|
||||
"scph5501": ("systems/sony-playstation/", 3),
|
||||
}
|
||||
for query, (page, rank) in expected.items():
|
||||
with self.subTest(query=query):
|
||||
pages, gated = queries[query]
|
||||
self.assertTrue(any(page.startswith(prefix) for prefix in pages))
|
||||
self.assertLessEqual(gated, rank)
|
||||
|
||||
def test_the_home_has_a_weight_budget(self) -> None:
|
||||
source = (TOOLS / "search_gate.py").read_text(encoding="utf-8")
|
||||
self.assertIn("HOME_BUDGET_KB = 80", source)
|
||||
|
||||
|
||||
class FieldsFromFrontMatter(unittest.TestCase):
|
||||
def test_consoles_and_terms_become_search_fields(self) -> None:
|
||||
text = _finalize("systems/snk.md", "# SNK\n\nBody.\n", {
|
||||
"consoles": ["Neo Geo", "Neo Geo Pocket Color"],
|
||||
"terms": "bios not found, bios missing",
|
||||
})["text"]
|
||||
self.assertIn('data-pagefind-meta="consoles:Neo Geo Neo Geo Pocket Color"', text)
|
||||
self.assertIn('data-pagefind-meta="terms:bios not found bios missing Neo Geo BIOS '
|
||||
'Neo Geo Pocket Color BIOS"', text)
|
||||
# A field value is an attribute: none of it reaches an excerpt.
|
||||
self.assertNotRegex(text, r">[^<]*bios missing[^<]*<")
|
||||
|
||||
def test_no_search_word_is_typed_in_the_module(self) -> None:
|
||||
source = (REPO_ROOT / "scripts" / "sitestarlight.py").read_text(encoding="utf-8")
|
||||
for words in ("bios not found", "mame version", "WonderSwan"):
|
||||
self.assertNotIn(words, source)
|
||||
|
||||
def test_a_page_without_fields_gets_none(self) -> None:
|
||||
text = _finalize("wiki/legal.md", "# Legal\n\nBody.\n", {})["text"]
|
||||
self.assertNotIn("consoles:", text)
|
||||
self.assertNotIn("terms:", text)
|
||||
|
||||
|
||||
class FindOrCheckAFile(unittest.TestCase):
|
||||
""""check my dump" and "md5 check" found emulator pages first."""
|
||||
|
||||
def test_the_page_weighs_two(self) -> None:
|
||||
rel = Path("files/index.md")
|
||||
self.assertEqual(gs.search_weight(rel, gs.page_level(rel)), 2)
|
||||
|
||||
def test_its_terms_become_a_field(self) -> None:
|
||||
text = _finalize("files/index.md", "# Find or check a file\n\nBody.\n",
|
||||
{"terms": "check dump, md5 check"})["text"]
|
||||
self.assertIn('data-pagefind-meta="terms:check dump md5 check"', text)
|
||||
|
||||
def test_the_sidebar_names_it_as_its_title(self) -> None:
|
||||
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
self.assertIn('{"Find or check a file": "files/index.md"}', source)
|
||||
self.assertNotIn('{"Find a file": "files/index.md"}', source)
|
||||
|
||||
|
||||
class PartPages(unittest.TestCase):
|
||||
def test_part_weight(self) -> None:
|
||||
self.assertEqual(gs.SEARCH_WEIGHT["part"], 0.5)
|
||||
self.assertEqual(gs.search_weight(Path("systems/arcade-mame-5.md"), "part"), 0.5)
|
||||
|
||||
def test_a_part_keeps_its_own_weight_and_neighbours(self) -> None:
|
||||
"""The MAME card parts write prev, next and searchWeight: the build
|
||||
kept only parent and wrote prev: false, next: false."""
|
||||
page = (
|
||||
"---\n"
|
||||
"parent:\n label: MAME\n link: mame.md\n"
|
||||
"prev:\n label: MAME files, part 2 of 8\n link: mame-files-2.md\n"
|
||||
"next:\n label: MAME files, part 4 of 8\n link: mame-files-4.md\n"
|
||||
f"searchWeight: {gs.search_weight(Path('emulators/mame-files-3.md'), 'part')}\n"
|
||||
"---\n\n# MAME files, part 3 of 8\n\nPart of [MAME](mame.md). "
|
||||
"This part holds 196 entries, `a.zip` to `b.zip`.\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
path = Path(tmp) / "emulators" / "mame-files-3.md"
|
||||
path.parent.mkdir()
|
||||
path.write_text(page, encoding="utf-8")
|
||||
gs.PART_PAGES.add("emulators/mame-files-3.md")
|
||||
self.addCleanup(gs.PART_PAGES.discard, "emulators/mame-files-3.md")
|
||||
gs.decorate_markdown_pages(Path(tmp))
|
||||
front = _front(path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertEqual(front["prev"], {"label": "MAME files, part 2 of 8",
|
||||
"link": "/retrobios/emulators/mame-files-2/"})
|
||||
self.assertEqual(front["next"]["link"], "/retrobios/emulators/mame-files-4/")
|
||||
self.assertEqual(front["parent"]["link"], "/retrobios/emulators/mame/")
|
||||
self.assertIn("This part holds 196 entries, a.zip to b.zip", front["description"])
|
||||
|
||||
def test_built_mame_parts_state_their_neighbours(self) -> None:
|
||||
part = DOCS / "emulators" / "mame-files-3.md"
|
||||
if not part.is_file():
|
||||
self.skipTest("site pages not generated")
|
||||
front = _front(part.read_text(encoding="utf-8"))
|
||||
if "searchWeight" not in front and front.get("prev") is False:
|
||||
self.skipTest("the emulator pages package is not merged")
|
||||
self.assertEqual(front["searchWeight"], 0.5)
|
||||
self.assertTrue(str(front["prev"]["link"]).startswith("/retrobios/emulators/"))
|
||||
|
||||
|
||||
class Descriptions(unittest.TestCase):
|
||||
def test_titles_say_bios_files(self) -> None:
|
||||
saved = dict(gs.PAGE_DATA)
|
||||
self.addCleanup(lambda: (gs.PAGE_DATA.clear(), gs.PAGE_DATA.update(saved)))
|
||||
gs.PAGE_DATA["emulators/duckstation.md"] = {"hasPart": [{"name": "scph5501.bin"}]}
|
||||
self.assertEqual(gs._browser_title(Path("emulators/duckstation.md"), "DuckStation"),
|
||||
"DuckStation BIOS files")
|
||||
self.assertEqual(gs._browser_title(Path("systems/sega.md"), "Sega"), "Sega BIOS files")
|
||||
self.assertEqual(gs._browser_title(Path("systems/sega-dreamcast.md"), "Sega: Dreamcast"),
|
||||
"Dreamcast BIOS files (Sega)")
|
||||
|
||||
def test_an_emulator_page_is_described_by_its_intro(self) -> None:
|
||||
saved = dict(gs.PAGE_DATA)
|
||||
self.addCleanup(lambda: (gs.PAGE_DATA.clear(), gs.PAGE_DATA.update(saved)))
|
||||
gs.PAGE_DATA["emulators/duckstation.md"] = {"hasPart": [{"name": "scph5501.bin"}]}
|
||||
body = ("# DuckStation\n\nDuckStation emulates Sony PlayStation. It picks the BIOS for "
|
||||
"your game's region.\n\n<details>\n\nThe installer finds your platform.\n")
|
||||
text = gs._page_description(body, Path("emulators/duckstation.md"), "DuckStation")
|
||||
self.assertTrue(text.startswith("DuckStation emulates Sony PlayStation."))
|
||||
self.assertNotIn("source-pinned", text)
|
||||
|
||||
def test_a_console_page_is_described_by_its_start_line(self) -> None:
|
||||
body = "# Sega: Dreamcast\n\nPart of Sega.\n\n**Start with**: `dc_boot.bin`, `dc_flash.bin`.\n"
|
||||
text = gs._page_description(body, Path("systems/sega-dreamcast.md"), "Sega: Dreamcast")
|
||||
self.assertEqual(text, "Dreamcast BIOS files (Sega). Start with dc_boot.bin, dc_flash.bin.")
|
||||
|
||||
def test_the_faq_is_described_by_its_questions(self) -> None:
|
||||
body = ("# Common questions\n\n## Do I need BIOS files?\n\nYes.\n\n## Which one?\n\n"
|
||||
"Run the command with --dry-run.\n\n## Zip or not?\n\nZip.\n")
|
||||
text = gs._page_description(body, Path("wiki/faq.md"), "Common questions")
|
||||
self.assertTrue(text.startswith("Common questions: Do I need BIOS files?"))
|
||||
|
||||
def test_a_written_description_wins(self) -> None:
|
||||
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
|
||||
self.assertIn('authored.get("description") or _page_description(', source)
|
||||
|
||||
def test_the_check_counts_templates(self) -> None:
|
||||
pages = {}
|
||||
for n in range(12):
|
||||
page = validate_site.Page(Path(f"/s/p{n}/index.html"))
|
||||
page.h1 = f"Core {n}"
|
||||
page.title = f"Core {n} BIOS files - RetroBIOS"
|
||||
page.descriptions = [f"Core {n}: emulator firmware profile with paths."]
|
||||
pages[page.path] = page
|
||||
issues = validate_site._check_description_templates(pages, Path("/s"))
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertIn("12 pages share", issues[0])
|
||||
for page in list(pages.values())[:2]:
|
||||
page.descriptions = [f"{page.h1} emulates a console of its own {page.h1[-1]}x."]
|
||||
self.assertEqual(validate_site._check_description_templates(pages, Path("/s")), [])
|
||||
|
||||
def test_built_descriptions_are_not_templates(self) -> None:
|
||||
if not (DIST / "index.html").is_file():
|
||||
self.skipTest("site not built")
|
||||
pages = {path.resolve(): validate_site._parse_page(path) for path in DIST.rglob("*.html")}
|
||||
content = {path: page for path, page in pages.items() if not page.redirect}
|
||||
self.assertEqual(validate_site._check_description_templates(content, DIST.resolve()), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,334 +0,0 @@
|
||||
"""System pages and file cards (round 4, package 6).
|
||||
|
||||
The Game Boy, NES and SNES sections said "Start with" a file one core of
|
||||
sixteen requires, under a home page saying they need none. The romset line
|
||||
said no MAME release matched sets MAME loads. The Neo Geo opened on files
|
||||
ares alone reads, and RetroArch had no install line. The Arcade page was
|
||||
sorted by storage folder: NAOMI, PGM and ZN-1 had no section, and their
|
||||
names were plain on every emulator page. Cards named emulators by id, part
|
||||
pages had no parent and no name, and four paragraphs came before the first
|
||||
console.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
from sitestarlight import toc_slug # noqa: E402
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
|
||||
|
||||
def _unlinked(text: str) -> str:
|
||||
"""A start line's wording: each file it names links to its card."""
|
||||
return re.sub(r"\[(`[^`]*`)\]\([^)]*\)", r"\1", text)
|
||||
|
||||
|
||||
def _file(name: str, char: str, path: str = "") -> dict:
|
||||
return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 4096,
|
||||
"path": path or f"bios/Nintendo/Game Boy/{name}"}
|
||||
|
||||
|
||||
def _consumers(readers: dict[str, list[tuple[str, dict]]],
|
||||
systems: dict[str, dict[str, set[str]]] | None = None) -> types.SimpleNamespace:
|
||||
"""Consumers where each sha1 is read by the given (emulator, entry)."""
|
||||
files_of: dict[str, list] = {}
|
||||
entries: dict[str, dict[str, int]] = {}
|
||||
emulators: dict[str, set[str]] = {}
|
||||
for sha1, read_by in readers.items():
|
||||
for emulator, entry in read_by:
|
||||
files_of.setdefault(emulator, []).append(entry)
|
||||
entries.setdefault(sha1, {})[emulator] = len(files_of[emulator]) - 1
|
||||
emulators.setdefault(sha1, set()).add(emulator)
|
||||
return types.SimpleNamespace(
|
||||
platforms={}, emulators=emulators, emulator_entries=entries,
|
||||
emulator_files=files_of, installed={}, platform_systems=systems or {},
|
||||
platform_destinations={}, _db={"files": {}},
|
||||
)
|
||||
|
||||
|
||||
def _page(path: Path) -> str:
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest(f"{path.relative_to(REPO_ROOT)} not built")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _opening(html: str, anchor: str) -> str:
|
||||
"""The text of the first paragraph under a heading."""
|
||||
start = html.index("<p>", html.index(f'id="{anchor}"'))
|
||||
return re.sub(r"<[^>]+>", "", html[start:html.index("</p>", start)]).strip()
|
||||
|
||||
|
||||
class AFileFewRequireIsNoStartFile(unittest.TestCase):
|
||||
"""6.1: one requiring emulator decided the line for the console."""
|
||||
|
||||
def test_one_of_four_says_no_file_needed_and_names_it(self) -> None:
|
||||
boot = _file("dmg_boot.bin", "1")
|
||||
optional = {"required": False, "system": "nintendo-gb"}
|
||||
consumers = _consumers({"1" * 40: [
|
||||
("emux_gb", {"name": "dmg_boot.bin", "required": True}),
|
||||
("gambatte", optional), ("mgba", optional), ("sameboy", optional)]})
|
||||
line = _unlinked(gs._start_with([boot], consumers)[0])
|
||||
self.assertTrue(line.startswith("**No file needed** with "), line)
|
||||
self.assertRegex(line, r"Only \S.* requires `dmg_boot.bin`\.$")
|
||||
|
||||
def test_most_requiring_keeps_the_start_line(self) -> None:
|
||||
bios = _file("scph5501.bin", "2")
|
||||
required = {"name": "scph5501.bin", "required": True}
|
||||
consumers = _consumers({"2" * 40: [
|
||||
("a", required), ("b", required), ("c", {"required": False, "hle_fallback": True})]})
|
||||
self.assertEqual(_unlinked(gs._start_with([bios], consumers)[0]), "**Start with**: `scph5501.bin`.")
|
||||
|
||||
def test_an_entry_read_for_a_system_with_no_section_is_dropped(self) -> None:
|
||||
sample = _file("ym2608_adpcm_rom.bin", "3", "bios/Nintendo/NES/ym2608_adpcm_rom.bin")
|
||||
consumers = _consumers({"3" * 40: [
|
||||
("fbneo", {"name": "ym2608_adpcm_rom.bin", "required": True,
|
||||
"system": "no-such-system-anywhere"})]})
|
||||
self.assertEqual(gs._start_with([sample], consumers, section=("Nintendo", "NES")), [])
|
||||
|
||||
def test_the_home_usually_not_consoles_open_on_no_file_needed(self) -> None:
|
||||
home = _page(DIST / "index.html")
|
||||
match = re.search(r"<strong>Usually not</strong> for ([^<.]+)", home)
|
||||
self.assertIsNotNone(match, "the home page no longer says Usually not")
|
||||
names = re.split(r", | and ", match[1])
|
||||
sections = {"NES": ("nintendo", "nes"), "Super Nintendo": ("nintendo", "snes"),
|
||||
"Mega Drive": ("sega", "mega-drive"), "Game Boy": ("nintendo", "game-boy"),
|
||||
"Game Boy Advance": ("nintendo", "game-boy-advance")}
|
||||
for name in names:
|
||||
self.assertIn(name, sections, f"map {name!r} to its section")
|
||||
page, anchor = sections[name]
|
||||
html = _page(DIST / "systems" / page / "index.html")
|
||||
self.assertTrue(_opening(html, anchor).startswith("No file needed"), name)
|
||||
|
||||
|
||||
class TheRomsetLineMatchesByCrc(unittest.TestCase):
|
||||
"""6.2: a set with an extra BIOS revision read "No match"."""
|
||||
|
||||
def test_the_main_neogeo_set_names_every_mame_release(self) -> None:
|
||||
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
|
||||
main = next((f for sha1, f in db["files"].items() if sha1.startswith("447e2375")), None)
|
||||
if main is None or not (REPO_ROOT / main["path"]).is_file():
|
||||
self.skipTest("the main neogeo.zip is not in this checkout")
|
||||
line = gs._romset_line(main)
|
||||
self.assertIn("Holds every ROM of MAME 0.190 to 0.289", line)
|
||||
self.assertNotIn("No match", line)
|
||||
|
||||
|
||||
class TheNeoGeoOpensOnItsSet(unittest.TestCase):
|
||||
"""6.3: the set 26 emulators read leads, RetroArch gets its command."""
|
||||
|
||||
def test_the_lead_names_the_exceptions(self) -> None:
|
||||
neogeo = _file("neogeo.zip", "4", "bios/SNK/Neo Geo/neogeo.zip")
|
||||
epo = _file("neo-epo.sp1", "5", "bios/SNK/Neo Geo/neo-epo.sp1")
|
||||
mvs = {"name": "neogeo.zip", "required": True, "system": "snk-neogeo-mvs"}
|
||||
consumers = _consumers({
|
||||
"4" * 40: [(f"mame{n}", mvs) for n in range(4)],
|
||||
"5" * 40: [("ares", {"name": "neo-epo.sp1", "required": True,
|
||||
"system": "snk-neogeo"})],
|
||||
})
|
||||
lines = [_unlinked(line) for line in gs._start_with([neogeo, epo], consumers, section=("SNK", "Neo Geo"))]
|
||||
self.assertTrue(lines[0].startswith("**Start with** `neogeo.zip`. Keep it zipped. "))
|
||||
self.assertIn("ares reads `neo-epo.sp1` instead.", lines)
|
||||
cards = gs._console_cards("SNK", [epo, neogeo], consumers, set(),
|
||||
start=gs._start_order([neogeo, epo], consumers,
|
||||
("SNK", "Neo Geo")))
|
||||
self.assertIn("neogeo.zip", cards[0][1][2])
|
||||
|
||||
def test_a_setup_filing_the_set_elsewhere_gets_its_command(self) -> None:
|
||||
consumers = _consumers({}, systems={"a" * 40: {"retroarch": {"arcade"}}})
|
||||
pairs = gs._install_pairs([("batocera", "snk-neogeo")], "a" * 40, consumers)
|
||||
self.assertIn(("retroarch", "arcade"), pairs)
|
||||
block = "\n".join(gs._console_install_block("Neo Geo", pairs))
|
||||
self.assertIn("<li>RetroArch: <code", block)
|
||||
self.assertIn("--platform retroarch --system arcade</code>", block)
|
||||
|
||||
def test_the_built_section(self) -> None:
|
||||
html = _page(DIST / "systems" / "snk" / "index.html")
|
||||
section = html[html.index('id="neo-geo"'):]
|
||||
self.assertTrue(_opening(html, "neo-geo").startswith("Start with neogeo.zip"))
|
||||
self.assertIn("--platform retroarch --system arcade", re.sub(r"<[^>]+>", "", section))
|
||||
|
||||
|
||||
BOARDS = ("Sega NAOMI", "Sammy Atomiswave", "Capcom ZN-1", "IGS PolyGame Master",
|
||||
"Nintendo PlayChoice-10", "SNK Hyper Neo Geo 64")
|
||||
|
||||
|
||||
class EachArcadeBoardHasASection(unittest.TestCase):
|
||||
"""6.4: the Arcade page was cut by storage folder."""
|
||||
|
||||
def test_each_board_heads_its_page(self) -> None:
|
||||
# The page's title names the board, no h2 says it again (round 5, 6.8).
|
||||
for name in BOARDS:
|
||||
slug = toc_slug(name)
|
||||
html = _page(DIST / "systems" / f"arcade-{slug}" / "index.html")
|
||||
self.assertRegex(html, rf"<h1[^>]*>Arcade: {re.escape(name)}</h1>", name)
|
||||
self.assertNotRegex(html, rf"<h2[^>]*>{re.escape(name)}<", name)
|
||||
|
||||
def test_every_board_on_an_emulator_systems_row_is_a_link(self) -> None:
|
||||
if not (DIST / "emulators").is_dir():
|
||||
self.skipTest("site not built")
|
||||
plain = []
|
||||
for page in sorted((DIST / "emulators").glob("*/index.html")):
|
||||
row = re.search(r"<td>Systems</td>\s*<td>(.*?)</td>", page.read_text(encoding="utf-8"),
|
||||
re.S)
|
||||
if not row:
|
||||
continue
|
||||
text = re.sub(r"<a [^>]*>.*?</a>", "", row[1])
|
||||
plain += [(page.parent.name, name) for name in BOARDS if name in text]
|
||||
self.assertEqual(plain, [])
|
||||
|
||||
def test_the_arcade_page_lists_the_boards_first(self) -> None:
|
||||
md = _page(CONTENT / "systems" / "arcade.md")
|
||||
headings = re.findall(r"^## (.+?) \{", md, re.M)
|
||||
self.assertIn("Sega NAOMI", headings)
|
||||
self.assertLess(headings.index("Sega NAOMI"), headings.index(gs.OTHER_ARCADE_LABEL))
|
||||
|
||||
|
||||
class CardsNameEmulatorsByName(unittest.TestCase):
|
||||
"""6.6: 19,499 card links read the profile id."""
|
||||
|
||||
def test_the_display_name_is_the_link_text(self) -> None:
|
||||
sha1 = "6" * 40
|
||||
consumers = _consumers({sha1: [("melonds_ds", {"name": "bios7.bin"})]})
|
||||
link = gs._emulator_links(sha1, consumers)[0]
|
||||
self.assertTrue(link.startswith("[melonDS DS]("), link)
|
||||
|
||||
def test_no_built_card_link_reads_an_id(self) -> None:
|
||||
if not (DIST / "systems").is_dir():
|
||||
self.skipTest("site not built")
|
||||
profiles = gs._profiles_on_disk()
|
||||
bad = set()
|
||||
for page in (DIST / "systems").glob("*/index.html"):
|
||||
for emulator, text in re.findall(
|
||||
r'<a href="/retrobios/emulators/([^/"#]+)/[^"]*">([^<]+)</a>',
|
||||
page.read_text(encoding="utf-8")):
|
||||
display = str((profiles.get(emulator) or {}).get("emulator") or emulator)
|
||||
if text == emulator and display != emulator:
|
||||
bad.add(emulator)
|
||||
self.assertEqual(sorted(bad), [])
|
||||
|
||||
|
||||
class PartPagesKnowWhereTheyAre(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
# system_pages records where consoles went, for every later page.
|
||||
gs.reset_console_pages()
|
||||
|
||||
"""6.7: 128 system pages had no parent, parts were "part 3 of 5"."""
|
||||
|
||||
def test_every_part_has_a_parent_and_a_name(self) -> None:
|
||||
if not (CONTENT / "systems").is_dir():
|
||||
self.skipTest("pages not generated")
|
||||
orphans, numbered = [], []
|
||||
for page in sorted((CONTENT / "systems").glob("*.md*")):
|
||||
text = page.read_text(encoding="utf-8")
|
||||
# A part is titled "<maker>: <console>" or "<name> files a to b".
|
||||
title = re.search(r"^title: (.*)$", text, re.M)
|
||||
part = title and (": " in title[1] or re.search(r" files .+ to ", title[1]))
|
||||
if part and "\nparent:" not in text.split("\n---\n", 1)[0]:
|
||||
orphans.append(page.name)
|
||||
if re.search(r"^title: .*part \d+ of \d+", text, re.M | re.I):
|
||||
numbered.append(page.name)
|
||||
self.assertEqual(orphans, [])
|
||||
self.assertEqual(numbered, [])
|
||||
|
||||
def test_parts_are_even_and_linked_to_their_neighbours(self) -> None:
|
||||
files = [_file(f"rom{i:02}.bin", "0123456789"[i], f"bios/Sony/PlayStation 2/rom{i:02}.bin")
|
||||
for i in range(10)]
|
||||
consumers = _consumers({})
|
||||
with gs.markup.using("mkdocs"):
|
||||
one = sum(len(line) + 1 for line in gs._system_file_card(files[0], consumers))
|
||||
pages = gs.system_pages("Sony", {"PlayStation 2": files}, consumers, budget=one * 7)
|
||||
parts = [stem for stem in pages if stem != "sony"]
|
||||
self.assertEqual(len(parts), 2)
|
||||
counts = [pages[stem][0].count('class="rb-sys-file"') for stem in parts]
|
||||
self.assertEqual(counts, [5, 5])
|
||||
first = pages[parts[0]][0]
|
||||
self.assertIn("parent:\n label: \"Sony\"\n link: \"/systems/sony/\"", first)
|
||||
self.assertIn(f"next:\n label: \"PlayStation 2 files rom05.bin to rom09.bin\"\n"
|
||||
f" link: \"/systems/{parts[1]}/\"", first)
|
||||
self.assertIn("# PlayStation 2 files rom00.bin to rom04.bin", first)
|
||||
self.assertIn(f"searchWeight: {gs._part_weight()}", first)
|
||||
|
||||
|
||||
def _consoles(page: str) -> list[str]:
|
||||
"""The consoles: front matter of a generated page."""
|
||||
front = page.split("\n---\n", 1)[0] if page.startswith("---\n") else ""
|
||||
block = re.search(r"^consoles:\n((?: - .*\n?)+)", front + "\n", re.M)
|
||||
return [json.loads(line[4:]) for line in block[1].splitlines()] if block else []
|
||||
|
||||
|
||||
class ConsolesFieldNamesMachinesOnce(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
# system_pages records where consoles went, for every later page.
|
||||
gs.reset_console_pages()
|
||||
|
||||
"""The consoles search field: only machine sections, and a console
|
||||
with a page of its own is that page's, or "dolphin" found Other and
|
||||
"playstation bios" found Sony before the PlayStation page."""
|
||||
|
||||
def test_no_emulator_folder_is_a_console(self) -> None:
|
||||
for maker in ("Other", "Game Engines"):
|
||||
page = gs.system_pages(maker, {"dolphin": [_file("a.bin", "a")]}, _consumers({}))
|
||||
self.assertEqual(_consoles(page[gs._slug(maker)][0]), [], maker)
|
||||
|
||||
def test_a_console_with_its_own_page_is_left_to_it(self) -> None:
|
||||
big = [_file(f"r{i:02}.bin", "0123456789"[i], f"bios/Sega/Saturn/r{i:02}.bin")
|
||||
for i in range(10)]
|
||||
small = [_file("pico.bin", "p", "bios/Sega/Pico/pico.bin")]
|
||||
consumers = _consumers({})
|
||||
with gs.markup.using("mkdocs"):
|
||||
one = sum(len(line) + 1 for line in gs._system_file_card(big[0], consumers))
|
||||
# Sega splits by size. Every Sony console has a page whatever its size.
|
||||
pages = gs.system_pages("Sega", {"Saturn": big, "Pico": small},
|
||||
consumers, budget=one * 12)
|
||||
self.assertIn("sega-saturn", pages)
|
||||
self.assertEqual(_consoles(pages["sega"][0]), ["Pico"])
|
||||
self.assertEqual(_consoles(pages["sega-saturn"][0]), ["Saturn"])
|
||||
self.addCleanup(gs.reset_console_pages)
|
||||
sony = gs.system_pages("Sony", {"PlayStation": big, "PocketStation": small},
|
||||
consumers, budget=one * 12)
|
||||
self.assertEqual(_consoles(sony["sony"][0]), [])
|
||||
self.assertEqual(_consoles(sony["sony-pocketstation"][0]), ["PocketStation"])
|
||||
|
||||
|
||||
class OneLineBeforeTheFirstConsole(unittest.TestCase):
|
||||
"""6.8: four paragraphs, word for word on every maker's page."""
|
||||
|
||||
def test_the_intro_is_one_line_and_the_legend_closed(self) -> None:
|
||||
page = gs.generate_system_page("SNK", {"Neo Geo": [_file("a.bin", "a")]}, _consumers({}))
|
||||
body = page.split("# SNK\n", 1)[1]
|
||||
before = body[:body.index("## Neo Geo")]
|
||||
self.assertTrue(before.lstrip().startswith(
|
||||
"Every BIOS file for SNK machines, one card per file.\n"))
|
||||
legend = before[before.index("<section data-pagefind-ignore>"):]
|
||||
self.assertIn("<summary>How to read a card</summary>", legend)
|
||||
self.assertNotIn(" open>", legend)
|
||||
self.assertNotIn("JSON", before)
|
||||
self.assertIn("as JSON](../api/v1/systems/snk.json)", page.split("## Next steps")[1])
|
||||
|
||||
|
||||
class SetupsGroupedByPlace(unittest.TestCase):
|
||||
"""6.9: each setup repeated the name it gives the file."""
|
||||
|
||||
def test_one_line_per_place(self) -> None:
|
||||
places = [("Batocera", "b.md", ["scph5501.bin"]), ("KNULLI", "k.md", ["scph5501.bin"]),
|
||||
("RetroDECK", "r.md", ["bios/scph5501.bin"]), ("EmuDeck", "e.md", [])]
|
||||
self.assertEqual(gs._places_lines(places), [
|
||||
"<code>scph5501.bin</code>: [Batocera](b.md), [KNULLI](k.md)",
|
||||
"<code>bios/scph5501.bin</code>: [RetroDECK](r.md)",
|
||||
"[EmuDeck](e.md)",
|
||||
])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,356 +0,0 @@
|
||||
"""Wiki and release docs (round 4, package 5).
|
||||
|
||||
Each class names the finding it holds: an error message a reader types
|
||||
into search and no page that says it, an arcade answer written for
|
||||
maintainers, a subfolder setting that hides every file, FAQ labels that
|
||||
read as broken crumbs, no legal route to a BIOS, a packager who cannot pin
|
||||
the installer, a backslash in a PowerShell command, an events table with no
|
||||
sample, the installer options printed twice, and a glossary "Pack" that
|
||||
ignored parts. Every claim the copy makes is held against the repository.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import sitediagrams # noqa: E402
|
||||
from check_release import ( # noqa: E402
|
||||
INSTALLER,
|
||||
SUMS,
|
||||
Asset,
|
||||
compare_release,
|
||||
installer_files,
|
||||
)
|
||||
|
||||
WIKI = REPO_ROOT / "wiki"
|
||||
FENCED = re.compile(r"^```.*?^```", re.S | re.M)
|
||||
ARCADE_ANCHOR = "technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match"
|
||||
|
||||
|
||||
def _wiki(name: str) -> str:
|
||||
return (WIKI / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _front(text: str) -> dict:
|
||||
return yaml.safe_load(text.split("---", 2)[1])
|
||||
|
||||
|
||||
def _section(text: str, heading: str) -> str:
|
||||
start = re.search(rf"(?m)^#+ {re.escape(heading)}(?:\s*\{{[^}}]*\}})?\n", text)
|
||||
if start is None:
|
||||
raise AssertionError(f"no section {heading!r}")
|
||||
rest = text[start.end():]
|
||||
blank = FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest)
|
||||
end = re.search(r"(?m)^#{1,2} ", blank)
|
||||
return rest[: end.start()] if end else rest
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TroubleshootingSaysTheWordsOnScreen(unittest.TestCase):
|
||||
"""C6, N3: "bios not found" ranked the page 4th, and the most quoted
|
||||
on-screen message found nothing."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("troubleshooting.md")
|
||||
|
||||
def test_step_one_opens_on_the_messages(self):
|
||||
step = _flat(_section(self.text, "1. Check my BIOS folder"))
|
||||
self.assertIn("**BIOS not found**, **BIOS missing** or **firmware missing**?", step)
|
||||
self.assertLess(step.index("BIOS not found"), step.index("```"))
|
||||
|
||||
def test_each_message_is_cited(self):
|
||||
for source in ("bsx.cpp:1232", "fds.c:312", "libretro.cpp:866", "msg_hash_us.h:32805"):
|
||||
with self.subTest(source=source):
|
||||
self.assertIn(source, self.text)
|
||||
|
||||
def test_the_disc_message(self):
|
||||
section = _flat(_section(self.text, "The BIOS is fine, the game still fails"))
|
||||
self.assertIn('"Please insert PlayStation CD-ROM"?** The BIOS works.', section)
|
||||
self.assertIn("open its `.cue` or `.m3u` file, not a `.bin`", section)
|
||||
|
||||
def test_terms_for_search(self):
|
||||
terms = _front(self.text)["terms"]
|
||||
for word in ("bios not found", "bios missing", "firmware missing", "bios error"):
|
||||
with self.subTest(word=word):
|
||||
self.assertIn(word, terms)
|
||||
|
||||
|
||||
class SubfolderTrap(unittest.TestCase):
|
||||
"""C8: a BIOS setting pointed at system/dc, while Flycast adds dc/ itself."""
|
||||
|
||||
def test_step_three_names_it(self):
|
||||
step = _flat(_section(_wiki("troubleshooting.md"), "3. Check the folder and the names"))
|
||||
self.assertIn("subfolder, like `system/dc`?", step)
|
||||
self.assertIn("like `dc/` for Flycast", step)
|
||||
|
||||
def test_flycast_reads_dc(self):
|
||||
flycast = _profile("flycast")
|
||||
boot = next(e for e in flycast["files"] if e["name"] == "dc_boot.bin")
|
||||
self.assertEqual(boot["path"], "dc/dc_boot.bin")
|
||||
self.assertIn('appends "dc/" to the system directory', flycast["notes"])
|
||||
|
||||
|
||||
def _arcade_profiles() -> dict[str, dict]:
|
||||
"""The MAME and FinalBurn libretro cores whose name does not give the version."""
|
||||
found = {}
|
||||
for path in sorted((REPO_ROOT / "emulators").glob("*.yml")):
|
||||
if not re.match(r"(mame|fbneo|fbalpha)", path.stem):
|
||||
continue
|
||||
profile = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if "libretro" not in str(profile.get("type", "")):
|
||||
continue
|
||||
# Recalbox's MAME 0.258 and 0.278 carry the answer in their name.
|
||||
if _version(profile) in _retroarch_name(profile):
|
||||
continue
|
||||
found[path.stem] = profile
|
||||
return found
|
||||
|
||||
|
||||
def _retroarch_name(profile: dict) -> str:
|
||||
""""Arcade (MAME 2003)" gives "MAME 2003", "Arcade (FinalBurn Neo) (Neo-Geo)"
|
||||
gives "FinalBurn Neo (Neo-Geo)"."""
|
||||
inner = profile["display_name"].split("(", 1)[1]
|
||||
return inner.replace(")", "", 1).strip()
|
||||
|
||||
|
||||
def _version(profile: dict) -> str:
|
||||
return str(profile.get("mame_version") or profile["core_version"]).split()[0].lstrip("v")
|
||||
|
||||
|
||||
class ArcadeVersionTable(unittest.TestCase):
|
||||
"""C4, N3: the answer named profile ids and left out FinalBurn Neo."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("technical-faq.md")
|
||||
section = _section(self.text, "Which MAME version do the arcade BIOS sets match?")
|
||||
self.section = section
|
||||
self.rows = {}
|
||||
for line in section.splitlines():
|
||||
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
if (line.startswith("|") and len(cells) == 2 and not set(cells[0]) <= {"-"}
|
||||
and cells[1] != "Sets from"):
|
||||
for name in cells[0].split(", "):
|
||||
self.rows[name] = cells[1]
|
||||
|
||||
def test_the_section_opens_on_the_answer(self):
|
||||
self.assertTrue(self.section.lstrip().startswith(
|
||||
"Your game sets and BIOS sets must come from the same version as your arcade\nemulator."))
|
||||
self.assertIn("| Emulator, as RetroArch names it | Sets from |", self.section)
|
||||
self.assertLess(self.section.index("| Emulator"), self.section.index("### How this is known"))
|
||||
|
||||
def test_every_core_has_its_row_and_version(self):
|
||||
profiles = _arcade_profiles()
|
||||
self.assertGreaterEqual(len(profiles), 20)
|
||||
for key, profile in profiles.items():
|
||||
name = _retroarch_name(profile)
|
||||
with self.subTest(profile=key, name=name):
|
||||
self.assertIn(name, self.rows)
|
||||
self.assertIn(_version(profile), self.rows[name])
|
||||
|
||||
def test_no_row_without_a_core(self):
|
||||
names = {_retroarch_name(p) for p in _arcade_profiles().values()}
|
||||
self.assertEqual(set(self.rows) - names, set())
|
||||
|
||||
def test_terms_and_links(self):
|
||||
terms = _front(self.text)["terms"]
|
||||
for word in ("mame version", "romset version", "which mame"):
|
||||
with self.subTest(word=word):
|
||||
self.assertIn(word, terms)
|
||||
arcade = _section(_wiki("troubleshooting.md"), "An arcade game does not start")
|
||||
self.assertIn(ARCADE_ANCHOR, arcade)
|
||||
self.assertNotIn("emulators/index.md", arcade)
|
||||
faq = _wiki("faq.md")
|
||||
self.assertIn("## My arcade game fails (which MAME version?) "
|
||||
"{ #my-arcade-game-does-not-start }", faq)
|
||||
|
||||
|
||||
class FaqLabels(unittest.TestCase):
|
||||
"""C9, N10: "Reference:" read as a broken crumb, "E::" as a typo, iOS unfound."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("faq.md")
|
||||
|
||||
def test_no_reference_label(self):
|
||||
self.assertNotRegex(self.text, r"(?m)^Reference:")
|
||||
arcade = _section(self.text, "My arcade game fails (which MAME version?)")
|
||||
self.assertIn(f"Game sets must match the emulator's version:\n[the list]({ARCADE_ANCHOR}).",
|
||||
arcade)
|
||||
|
||||
def test_the_card_sentence(self):
|
||||
self.assertIn("For RetroArch on a Switch,\nwith the card in drive `E:`, run:", self.text)
|
||||
|
||||
def test_ios(self):
|
||||
self.assertIn("iPhone, iPad (iOS)", self.text)
|
||||
|
||||
|
||||
class CopyYourOwnBios(unittest.TestCase):
|
||||
"""C-other, need 12: no page answered the legal route."""
|
||||
|
||||
def test_each_console_has_a_guide(self):
|
||||
section = _section(_wiki("faq.md"), "Can I copy my own console's BIOS?")
|
||||
links = re.findall(r"\[([^\]]+)\]\((https://[^)]+)\)", section)
|
||||
self.assertEqual(dict(links), {
|
||||
"PCSX2": "https://pcsx2.net/docs/setup/bios/",
|
||||
"melonDS": "https://melonds.kuribo64.net/faq.php",
|
||||
})
|
||||
|
||||
|
||||
def _sums_with(names: list[str]) -> dict[str, str]:
|
||||
return {name: f"{i:064x}" for i, name in enumerate(names, 1)}
|
||||
|
||||
|
||||
class PinnedInstaller(unittest.TestCase):
|
||||
"""J9, D5: release tags are deleted and the release carried no installer."""
|
||||
|
||||
def test_the_list_holds_the_installer_and_every_file_list(self):
|
||||
names = installer_files(REPO_ROOT / "install")
|
||||
self.assertEqual(names[:3], list(INSTALLER))
|
||||
self.assertIn("retroarch.json", names)
|
||||
self.assertEqual(len(names), 3 + len(list((REPO_ROOT / "install").glob("*.json"))))
|
||||
|
||||
def test_a_release_without_the_installer_is_refused(self):
|
||||
record = {"tag": "v1", "packs": {"P.zip": {"download_size": 5, "assets": ["P.zip"]}}}
|
||||
files = ["install.py", "install.sh", "install.ps1", "retroarch.json"]
|
||||
sums = _sums_with(["P.zip", *files])
|
||||
assets = {name: Asset(5, digest) for name, digest in sums.items()}
|
||||
assets[SUMS] = Asset(1, None)
|
||||
self.assertEqual(compare_release(record, sums, assets, "v1", installer=files), [])
|
||||
|
||||
del sums["install.py"]
|
||||
del assets["install.py"]
|
||||
kinds = {(f.kind, f.name) for f in compare_release(record, sums, assets, "v1",
|
||||
installer=files)}
|
||||
self.assertEqual(kinds, {("no-installer", "install.py")})
|
||||
|
||||
def test_listed_but_not_uploaded_is_missing(self):
|
||||
record = {"tag": "v1", "packs": {}}
|
||||
sums = _sums_with(list(INSTALLER))
|
||||
assets = {name: Asset(5, digest) for name, digest in sums.items() if name != "install.sh"}
|
||||
assets[SUMS] = Asset(1, None)
|
||||
kinds = {(f.kind, f.name) for f in compare_release(record, sums, assets, "v1",
|
||||
installer=list(INSTALLER))}
|
||||
self.assertEqual(kinds, {("missing", "install.sh")})
|
||||
|
||||
def test_the_release_steps_ship_them(self):
|
||||
text = _wiki("release-process.md")
|
||||
self.assertIn("cp install.py install.sh install.ps1 install/*.json dist/", text)
|
||||
sums = re.search(r"sha256sum [^>]+> SHA256SUMS.txt", text, re.S).group(0)
|
||||
upload = re.search(r"for f in dist/SHA256SUMS.txt.*?; do", text, re.S).group(0)
|
||||
for name in (*INSTALLER, "*.json"):
|
||||
with self.subTest(name=name):
|
||||
self.assertIn(name, sums)
|
||||
for name in INSTALLER:
|
||||
with self.subTest(upload=name):
|
||||
self.assertIn(f"dist/{name}", upload)
|
||||
self.assertIn("install/*.json", upload)
|
||||
|
||||
def test_the_installer_page_pins_one_commit(self):
|
||||
# v2026.10.10 carries no install.py: until a release does, the
|
||||
# section pins a commit, never a release address.
|
||||
text = _wiki("installer.md")
|
||||
section = _flat(_section(text, "For packagers"))
|
||||
for words in ("raw.githubusercontent.com/Abdess/retrobios/$commit",
|
||||
'"$base/install/retroarch.json"', "--manifest retroarch.json",
|
||||
"sha256sum --check", "RETROBIOS_REF=$commit"):
|
||||
with self.subTest(words=words):
|
||||
self.assertIn(words, section)
|
||||
self.assertNotIn("releases/download/", section)
|
||||
self.assertNotIn("releases/latest/download/", section)
|
||||
terms = _front(text)["terms"]
|
||||
for word in ("manifest", "events", "exit code", "schema"):
|
||||
with self.subTest(term=word):
|
||||
self.assertIn(word, terms)
|
||||
|
||||
|
||||
class NoBackslashInProseCode(unittest.TestCase):
|
||||
"""D9: `irm \\| iex` showed its backslash outside a table."""
|
||||
|
||||
def test_wiki(self):
|
||||
for path in sorted(WIKI.glob("*.md")):
|
||||
text = FENCED.sub("", path.read_text(encoding="utf-8"))
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
if line.lstrip().startswith("|"):
|
||||
continue
|
||||
with self.subTest(page=path.name, line=number):
|
||||
self.assertNotRegex(line, r"`[^`]*\\\|[^`]*`")
|
||||
|
||||
|
||||
def _install_module():
|
||||
spec = importlib.util.spec_from_file_location("install_r4wiki", REPO_ROOT / "install.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class EventsTranscript(unittest.TestCase):
|
||||
"""D10: the events table had no sample of what a run writes."""
|
||||
|
||||
def test_five_lines_that_follow_the_schema(self):
|
||||
section = _section(_wiki("installer.md"), "Machine-readable events")
|
||||
block = re.search(r"```json\n(.*?)\n```", section, re.S)
|
||||
self.assertIsNotNone(block)
|
||||
lines = block.group(1).splitlines()
|
||||
self.assertEqual(len(lines), 5)
|
||||
keys = _install_module().EVENT_KEYS
|
||||
events = [json.loads(line) for line in lines]
|
||||
for event in events:
|
||||
with self.subTest(event=event["event"]):
|
||||
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})
|
||||
|
||||
|
||||
class OneOptionsTable(unittest.TestCase):
|
||||
"""T-F9: the options printed twice, the offline install 57,750 px down."""
|
||||
|
||||
def setUp(self):
|
||||
self.text = _wiki("installer.md")
|
||||
|
||||
def test_each_option_links_to_its_row(self):
|
||||
# The glance is written from the parser when the page is built.
|
||||
glance = _section(sitediagrams.expand_glance(self.text), "Options at a glance")
|
||||
table = _section(self.text, "Options")
|
||||
self.assertNotIn("installer-options", glance)
|
||||
self.assertNotIn("|", glance)
|
||||
for usage, _ in sitediagrams.installer_options():
|
||||
flag = usage.split()[0].rstrip(",")
|
||||
anchor = f"option-{flag.lstrip('-')}"
|
||||
with self.subTest(option=flag):
|
||||
self.assertIn(f"[`{flag}`](#{anchor})", glance)
|
||||
self.assertRegex(table, rf'(?m)^\| <span id="{anchor}"></span>`{flag}[ `]')
|
||||
|
||||
def test_the_offline_install_comes_early(self):
|
||||
headings = re.findall(r"(?m)^## (.+?)(?:\s*\{[^}]*\})?$", FENCED.sub("", self.text))
|
||||
self.assertLess(headings.index("Without a network (offline install)"),
|
||||
headings.index("The full-screen interface"))
|
||||
|
||||
|
||||
class GlossaryPack(unittest.TestCase):
|
||||
"""C-other: packs come in parts and leave out files the emulator ships."""
|
||||
|
||||
def test_definition(self):
|
||||
text = _flat(_wiki("glossary.md"))
|
||||
self.assertIn("**Pack** : The ZIP, sometimes in parts, with one setup's BIOS files.", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,227 +0,0 @@
|
||||
"""Round 5, package 2: components and runtime.
|
||||
|
||||
Source checks always run. Checks on the built site read website/dist and
|
||||
skip when no build is there. The rules written in a build plugin run under
|
||||
node and skip without it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from common import list_release_pack_platforms # noqa: E402
|
||||
|
||||
WEBSITE = REPO_ROOT / "website"
|
||||
DIST = WEBSITE / "dist"
|
||||
COMPONENTS = WEBSITE / "src" / "components"
|
||||
SITE_TS = WEBSITE / "src" / "scripts" / "site.ts"
|
||||
PLUGINS = WEBSITE / "src" / "plugins"
|
||||
SHORT_CODE = PLUGINS / "rehype-short-code.mjs"
|
||||
STACK_TABLES = PLUGINS / "rehype-stack-tables.mjs"
|
||||
ENTITY = re.compile(r"&(?:#\d+|#x[0-9a-f]+|[a-z]+);", re.I)
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _built(page: str) -> str:
|
||||
path = DIST / page / "index.html"
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest(f"no built site at {path}")
|
||||
return _read(path)
|
||||
|
||||
|
||||
def _node(script: str) -> str:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
raise unittest.SkipTest("node is missing")
|
||||
result = subprocess.run(
|
||||
[node, "--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, check=True, cwd=WEBSITE,
|
||||
env={"NODE_DISABLE_COMPILE_CACHE": "1", "PATH": str(Path(node).parent)},
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _text(page: str) -> str:
|
||||
"""A built page's visible words: tags dropped, entities read."""
|
||||
return html.unescape(re.sub(r"<[^>]+>", "", page))
|
||||
|
||||
|
||||
def _picker_packs() -> set[str]:
|
||||
"""The setups the built picker offers a release ZIP line for."""
|
||||
page = _built("which-pack")
|
||||
match = re.search(r'data-release="([^"]*)"', page)
|
||||
if not match:
|
||||
raise AssertionError("which-pack has no picker release data")
|
||||
return set(json.loads(html.unescape(match[1]))["packs"])
|
||||
|
||||
|
||||
class PickerFollowsTheRegistry(unittest.TestCase):
|
||||
"""2.1: the picker said "The ZIP of release v2026.10.10 holds 5,804
|
||||
files" for ROCKNIX while its setup page and get/ page said "No ZIP for
|
||||
ROCKNIX" (N2, J5). The picker read the raw release.json."""
|
||||
|
||||
def test_source(self):
|
||||
source = _read(COMPONENTS / "FilePicker.astro")
|
||||
self.assertIn("import release from '../generated/release-advertised.json';", source)
|
||||
self.assertNotIn("release.json'", source)
|
||||
|
||||
def test_picker_offers_only_registry_packs(self):
|
||||
offered = _picker_packs()
|
||||
self.assertTrue(offered)
|
||||
released = set(list_release_pack_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||
self.assertLessEqual(offered, released)
|
||||
|
||||
def test_picker_setup_and_get_pages_agree(self):
|
||||
offered = _picker_packs()
|
||||
setups = json.loads(_read(DIST / "data" / "picker" / "index.json"))
|
||||
self.assertTrue(setups)
|
||||
for entry in setups:
|
||||
name = entry["platform"]
|
||||
with self.subTest(platform=name):
|
||||
get_page = _text(_built(f"get/{name}"))
|
||||
setup_page = _text(_built(f"platforms/{name}"))
|
||||
no_zip = f"No ZIP for {entry['name']}."
|
||||
self.assertEqual(name in offered, no_zip not in get_page)
|
||||
self.assertEqual(name in offered, no_zip not in setup_page)
|
||||
|
||||
|
||||
class PathsBreakAtSeparators(unittest.TestCase):
|
||||
"""2.2: `~/.var/app/org.libretro.RetroArch/config/r` then
|
||||
`etroarch/system/` on platforms/retroarch/ at 390 px (V8)."""
|
||||
|
||||
def test_the_rule(self):
|
||||
uri = json.dumps(SHORT_CODE.as_uri())
|
||||
out = _node(
|
||||
f"import {{ markRawCode, withBreaks, breaksAtSeparators }} from {uri};"
|
||||
"console.log(JSON.stringify(["
|
||||
"markRawCode('<td><code>~/.config/retroarch/system/aaaaaaaaaaa/</code> <code>bios/a.bin</code></td>'),"
|
||||
"markRawCode('<td><code class=\"rb-path\">C:\\\\RetroArch-Win64\\\\system\\\\aaaaaaaaaaaaa</code></td>'),"
|
||||
"withBreaks('a/b/'), breaksAtSeparators('x'.repeat(31), false),"
|
||||
"breaksAtSeparators('a/' + 'x'.repeat(30), true), breaksAtSeparators('a/b', false)]));"
|
||||
)
|
||||
self.assertEqual(json.loads(out), [
|
||||
'<td><code>~/<wbr>.config/<wbr>retroarch/<wbr>system/<wbr>aaaaaaaaaaa/</code>'
|
||||
' <code class="rb-short">bios/a.bin</code></td>',
|
||||
'<td><code class="rb-path">C:\\<wbr>RetroArch-Win64\\<wbr>system\\<wbr>aaaaaaaaaaaaa</code></td>',
|
||||
"a/<wbr>b/", False, False, False,
|
||||
])
|
||||
|
||||
def test_built_page(self):
|
||||
page = _built("platforms/retroarch")
|
||||
self.assertIn("~/<wbr>.var/<wbr>app/<wbr>org.libretro.RetroArch/<wbr>config/<wbr>retroarch/<wbr>system/", page)
|
||||
|
||||
def test_the_page_budget(self):
|
||||
"""Each `wbr` is an element: bluemsx held 3,097 of them on 9,369
|
||||
elements against a budget of 10,000. The build spends BREAK_BUDGET
|
||||
per page, the runtime writes the others."""
|
||||
uri = json.dumps(SHORT_CODE.as_uri())
|
||||
out = _node(
|
||||
f"import {{ markRawCode, BREAK_BUDGET }} from {uri};"
|
||||
"const room = { left: 3 };"
|
||||
"const path = '<code>aaaaaaaaaa/bbbbbbbbbb/cccccccccc/d</code>';"
|
||||
"console.log(JSON.stringify([BREAK_BUDGET, markRawCode(path, room), markRawCode(path, room), room.left]));"
|
||||
)
|
||||
budget, first, second, left = json.loads(out)
|
||||
self.assertEqual(first, "<code>aaaaaaaaaa/<wbr>bbbbbbbbbb/<wbr>cccccccccc/<wbr>d</code>")
|
||||
self.assertEqual(second, "<code>aaaaaaaaaa/bbbbbbbbbb/cccccccccc/d</code>")
|
||||
self.assertEqual(left, 0)
|
||||
if not DIST.is_dir():
|
||||
self.skipTest("no built site")
|
||||
# Link text the generator breaks itself (a forge address) is not code.
|
||||
code = re.compile(r"<code(?:\s[^>]*)?>(?:[^<]|<wbr>)*</code>")
|
||||
over = [str(path.relative_to(DIST)) for path in DIST.rglob("index.html")
|
||||
if sum(found.count("<wbr>") for found in code.findall(_read(path))) > budget]
|
||||
self.assertEqual(over, [])
|
||||
|
||||
def test_the_runtime_writes_the_rest(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("const LONG_CODE = 30;", source)
|
||||
self.assertIn("const SEPARATORS = /([/\\\\])(?!$)/g;", source)
|
||||
self.assertIn("if (unbrokenPath(code)) observer.observe(code);", source)
|
||||
self.assertIn("\nbreakPaths();\n", source)
|
||||
plugin = _read(SHORT_CODE)
|
||||
self.assertIn("export const SHORT_CODE = 30;", plugin)
|
||||
self.assertIn("const SEPARATORS = /([/\\\\])(?!$)/g;", plugin)
|
||||
|
||||
def test_every_long_path_breaks_at_run_time(self):
|
||||
"""Inline code over 30 characters holding a separator: the build
|
||||
broke it, or the runtime rule picks it (all text, no `wbr`)."""
|
||||
if not DIST.is_dir():
|
||||
self.skipTest("no built site")
|
||||
unbroken = []
|
||||
for path in sorted(DIST.rglob("index.html")):
|
||||
page = _read(path)
|
||||
if 'http-equiv="refresh"' in page or "sl-markdown-content" not in page:
|
||||
continue
|
||||
body = re.sub(r"<pre\b.*?</pre>", "", page[page.index("sl-markdown-content"):], flags=re.S)
|
||||
for text in re.findall(r"<code(?:\s[^>]*)?>((?:[^<]|<(?!/code>))*)</code>", body):
|
||||
plain = ENTITY.sub("_", re.sub(r"<wbr>", "", text))
|
||||
if len(plain) > 30 and re.search(r"[/\\]", plain[:-1]) \
|
||||
and "<wbr>" not in text and "<" in text:
|
||||
unbroken.append(f"{path.relative_to(DIST)}: {text}")
|
||||
self.assertEqual(unbroken, [])
|
||||
|
||||
def test_crumb_labels_in_a_span(self):
|
||||
source = _read(COMPONENTS / "PageTitle.astro")
|
||||
self.assertIn("<a href={crumb.href}><span>{crumb.label}</span></a> : <span>{crumb.label}</span>", source)
|
||||
crumbs = _built("platforms/retroarch")
|
||||
crumbs = crumbs[crumbs.index('class="rb-breadcrumbs'):]
|
||||
crumbs = crumbs[:crumbs.index("</ol>")]
|
||||
items = re.findall(r"<li\b[^>]*>(.*?)</li>", crumbs)
|
||||
self.assertTrue(items)
|
||||
for item in items:
|
||||
self.assertRegex(item, r"^(?:<a\b[^>]*>)?<span\b[^>]*>[^<]+</span>")
|
||||
|
||||
|
||||
class ShortStackedLabels(unittest.TestCase):
|
||||
"""2.3: "Name as declared" and "On its BIOS list (destinations)" wrap
|
||||
to two lines in the stacked label column (V9)."""
|
||||
|
||||
def test_the_rule(self):
|
||||
uri = json.dumps(STACK_TABLES.as_uri())
|
||||
out = _node(
|
||||
f"import {{ stackedLabel }} from {uri};"
|
||||
"console.log(JSON.stringify([stackedLabel('Declared as', 'Name as declared'),"
|
||||
"stackedLabel(undefined, 'Setup'), stackedLabel(' ', 'Setup')]));"
|
||||
)
|
||||
self.assertEqual(json.loads(out), ["Declared as", "Setup", "Setup"])
|
||||
|
||||
def test_plugin_and_runtime_read_the_title(self):
|
||||
plugin = _read(STACK_TABLES)
|
||||
self.assertIn("stackedLabel(cell.properties?.title, labels[column])", plugin)
|
||||
self.assertIn("ctx.setProperty(cell, 'dataLabel', stacked[column])", plugin)
|
||||
runtime = _read(SITE_TS)
|
||||
self.assertIn("cell.getAttribute('title')?.trim() || (cell.textContent?.trim() ?? '')", runtime)
|
||||
self.assertIn("[...table.querySelectorAll('thead th')].map(stackedLabel)", runtime)
|
||||
self.assertIn("'Name as declared': 'Declared as'", runtime)
|
||||
self.assertIn("cell.dataset.label = stackedLabel(head.cells[column]);", runtime)
|
||||
|
||||
|
||||
class LookupShowsTheDeclaredHash(unittest.TestCase):
|
||||
"""2.4: with the CRC32 pasted, Batocera's row read "Hash matched: CRC32",
|
||||
though Batocera declares an MD5 (J9, N10)."""
|
||||
|
||||
def test_source(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("declared?: string;", source)
|
||||
self.assertIn("const declaredHash = (item: Item) => (item.declared || item.algorithm).toUpperCase();", source)
|
||||
table = source[source.index("const setupTable"):source.index("const distinct")]
|
||||
self.assertIn("declaredHash(item),", table)
|
||||
self.assertNotIn("item.algorithm", table)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Round 5 theme: the stylesheet fixes of package 1, read from its source.
|
||||
|
||||
Each test fails on the stylesheet as it was before the fix: detection tables
|
||||
whose last column sat past the page at 1440 px, a header search box, title
|
||||
and "On this page" that moved 12 px between a guide and a console page,
|
||||
stacked rows 136 px tall for four short cells, a line 4 px under a button,
|
||||
a heading touching its first line, the browser's own choice list, and a
|
||||
crumb separator free to end a line alone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from tests.test_site_r2_css import CSS_PATH, _blocks, _strip_comments, _theme_tokens
|
||||
|
||||
STACK = ".sl-markdown-content table[data-rb-stack] td"
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
bodies = [b for s, b in _blocks(css) if s == selector]
|
||||
if not bodies:
|
||||
raise AssertionError(f"no block {selector}")
|
||||
return "".join(bodies)
|
||||
|
||||
|
||||
def _declarations(body: str) -> dict[str, str]:
|
||||
return {k.strip(): v.strip() for k, v in re.findall(r"([\w-]+)\s*:\s*([^;]+);", body)}
|
||||
|
||||
|
||||
class ThemeStylesheetRound5(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
# 1.1
|
||||
def test_long_code_in_a_cell_breaks_to_fit_its_column(self):
|
||||
self.assertIn("overflow-wrap: anywhere",
|
||||
_block(self.css, ".sl-markdown-content table td code:not(.rb-short)"))
|
||||
last = _block(self.css, ".sl-markdown-content table:not([data-rb-stack='kv']) "
|
||||
"td:last-child:not(:first-child):has(code:not(.rb-short))")
|
||||
self.assertIn("min-width: 9rem", last)
|
||||
# A flag or a short name in a cell still never splits.
|
||||
self.assertIn("white-space: pre", _block(self.css, ".sl-markdown-content :not(pre) > code.rb-short"))
|
||||
|
||||
# 1.2
|
||||
def test_one_grid_for_every_page(self):
|
||||
self.assertEqual(_theme_tokens(self.css, ":root").get("--rb-grid-width"), "54rem")
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)",
|
||||
_block(self.css, "body:has(.rb-sys-file, .rb-file-entry)"))
|
||||
# The header places its search box from the grid, not the page's column.
|
||||
self.assertIn("--sl-content-width: var(--rb-grid-width)",
|
||||
_block(self.css, ":root[data-has-sidebar] header.header"))
|
||||
toc = _block(self.css, ":root[data-has-sidebar] .right-sidebar-container")
|
||||
pane = _block(self.css, ":root[data-has-sidebar] .main-pane")
|
||||
for body in (toc, pane):
|
||||
self.assertIn("var(--rb-grid-width)", body)
|
||||
self.assertNotIn("var(--sl-content-width)", body)
|
||||
# Every column starts on the grid's edge, the narrow one included.
|
||||
self.assertIn("--sl-content-margin-inline: max(0rem, 100% - var(--rb-grid-width)) auto", pane)
|
||||
# The pane rule covers pages with and without "On this page" alike.
|
||||
self.assertFalse([s for s, _ in _blocks(self.css) if s.endswith(":not([data-has-toc]) .main-pane")])
|
||||
|
||||
# 1.3
|
||||
def test_stacked_rows_are_short(self):
|
||||
cell = _declarations(_block(self.css, STACK))
|
||||
self.assertEqual(cell["font-size"], "0.875rem")
|
||||
self.assertEqual(cell["line-height"], "1.35")
|
||||
label = _declarations(_block(self.css, STACK + "::before"))
|
||||
self.assertEqual(label["width"], "max-content")
|
||||
self.assertEqual(label["min-width"], "6.5rem")
|
||||
self.assertEqual(label["max-width"], "9rem")
|
||||
self.assertEqual(label["font-size"], "0.8125rem")
|
||||
# Values start in the column of the shortest label: the label's
|
||||
# negative margin is the cell's left padding.
|
||||
indent = cell["padding"].split()[3]
|
||||
self.assertEqual(label["margin"].split()[3], f"-{indent}")
|
||||
self.assertEqual(indent, "7.1rem")
|
||||
self.assertIn("padding: 0.3rem 0", _block(self.css, ".sl-markdown-content table[data-rb-stack] tr"))
|
||||
# A two-column list keeps body text.
|
||||
self.assertIn("font-size: 1rem", _block(self.css, ".sl-markdown-content table[data-rb-stack='kv'] td"))
|
||||
|
||||
# 1.5
|
||||
def test_spacing_after_a_button_and_a_search_marker(self):
|
||||
self.assertIn("margin-top: 1rem",
|
||||
_block(self.css, ".sl-markdown-content :is(.rb-button, p:has(> .rb-button)) + p"))
|
||||
self.assertIn("margin-top: var(--sl-content-gap-y)",
|
||||
_block(self.css, ".sl-markdown-content span[data-pagefind-meta]:empty + :is(p, ul, ol, table, div)"))
|
||||
|
||||
def test_choice_list_matches_the_filter_box(self):
|
||||
select = _declarations(_block(self.css, ".sl-markdown-content :is(.rb-file-lookup, .rb-picker) select,\n"
|
||||
".sl-markdown-content select"))
|
||||
box = _declarations(_block(self.css, ".rb-table-filter input"))
|
||||
for prop in ("border", "border-radius", "min-height"):
|
||||
with self.subTest(prop=prop):
|
||||
self.assertEqual(select[prop], box[prop])
|
||||
self.assertEqual(select["appearance"], "none")
|
||||
self.assertIn("var(--rb-select-arrow)", select["background"])
|
||||
dark = _theme_tokens(self.css, ":root")["--rb-select-arrow"]
|
||||
light = _theme_tokens(self.css, ":root[data-theme='light']")["--rb-select-arrow"]
|
||||
# The arrow is drawn in each theme's secondary text colour.
|
||||
for arrow, tokens in ((dark, _theme_tokens(self.css, ":root")),
|
||||
(light, _theme_tokens(self.css, ":root[data-theme='light']"))):
|
||||
colour = tokens["--rb-text-secondary"].lstrip("#")
|
||||
self.assertIn(f"%23{colour}", arrow)
|
||||
|
||||
def test_crumb_separator_stays_with_its_label(self):
|
||||
self.assertIn("white-space: nowrap", _block(self.css, ".rb-breadcrumbs li"))
|
||||
self.assertIn("white-space: normal", _block(self.css, ".rb-breadcrumbs li > :is(a, span)"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,268 +0,0 @@
|
||||
"""Emulator pages (round 5, package 7).
|
||||
|
||||
61 profiles cite the folder their app reads its data from (data_root) and
|
||||
no page printed it: AetherSX2's step said "inside the app's own data
|
||||
folder", ten pages named the field itself, and every install block offered
|
||||
Linux, Windows and Android commands, for MAME4droid an Android-only app.
|
||||
215 pages printed `--emulator X` for a name no published manifest takes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site as gs # noqa: E402
|
||||
|
||||
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
|
||||
EMULATORS = CONTENT / "emulators"
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _profiles_with_data_root() -> dict[str, dict]:
|
||||
found = {}
|
||||
for path in sorted((REPO_ROOT / "emulators").glob("*.yml")):
|
||||
profile = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if isinstance(profile, dict) and isinstance(profile.get("data_root"), dict):
|
||||
found[path.stem] = profile
|
||||
return found
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
for suffix in (".mdx", ".md"):
|
||||
path = EMULATORS / f"{name}{suffix}"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
def _generated() -> bool:
|
||||
return EMULATORS.is_dir() and any(EMULATORS.iterdir())
|
||||
|
||||
|
||||
def _install_section(page: str) -> str:
|
||||
match = re.search(r"<summary>Install this emulator's files</summary>(.*?)</details>",
|
||||
page, re.S)
|
||||
return match[1] if match else ""
|
||||
|
||||
|
||||
def _tab_labels(text: str) -> set[str]:
|
||||
return set(re.findall(r'<TabItem label="([^"]+)">', text))
|
||||
|
||||
|
||||
def _entries(profile: dict) -> list[dict]:
|
||||
return [f for f in profile.get("files") or [] if isinstance(f, dict)]
|
||||
|
||||
|
||||
def _alternatives(root: str) -> list[str]:
|
||||
return [part.strip().rstrip("/") for part in root.split(", else ")]
|
||||
|
||||
|
||||
class FolderOnlyTable(unittest.TestCase):
|
||||
"""The entries whose joined path is not the app's folder: one table in
|
||||
the generator, each row with its reason, and it cannot rot."""
|
||||
|
||||
def test_every_row_names_a_profile_and_entries_that_exist(self) -> None:
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, reason in gs.FOLDER_ONLY:
|
||||
with self.subTest(profile=profile_name, patterns=patterns):
|
||||
self.assertIn(profile_name, roots, "no profile with data_root by that name")
|
||||
self.assertTrue(reason.strip())
|
||||
entries = _entries(roots[profile_name])
|
||||
for pattern in patterns:
|
||||
self.assertTrue(
|
||||
any(gs.folder_only_matches(pattern, f) for f in entries),
|
||||
f"{pattern!r} names no entry of {profile_name}")
|
||||
self.assertLessEqual(set(keys), set(roots[profile_name]["data_root"]))
|
||||
|
||||
def test_a_listed_entry_is_never_joined_to_the_root(self) -> None:
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
|
||||
profile = roots[profile_name]
|
||||
for entry in _entries(profile):
|
||||
if not any(gs.folder_only_matches(p, entry) for p in patterns):
|
||||
continue
|
||||
raw = {str(entry.get(k) or "") for k in ("path", "standalone_path")} - {""}
|
||||
for key in keys or tuple(profile["data_root"]):
|
||||
with self.subTest(profile=profile_name, entry=entry.get("name"), key=key):
|
||||
self.assertEqual(gs.standalone_place(profile_name, profile, entry, key), "")
|
||||
self.assertEqual(gs.standalone_place(profile_name, profile, entry), "")
|
||||
block = "\n".join(gs.data_folder_block(profile_name, profile))
|
||||
for root in _alternatives(str(profile["data_root"][key]["path"])):
|
||||
for place in raw:
|
||||
self.assertNotIn(f"{root}/{place}", block)
|
||||
|
||||
def test_built_pages_print_no_joined_path_for_a_listed_entry(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
roots = _profiles_with_data_root()
|
||||
for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
|
||||
profile = roots[profile_name]
|
||||
pages = [p.read_text(encoding="utf-8")
|
||||
for p in EMULATORS.glob(f"{profile_name}*.md*")]
|
||||
for entry in _entries(profile):
|
||||
if not any(gs.folder_only_matches(p, entry) for p in patterns):
|
||||
continue
|
||||
for key in keys or tuple(profile["data_root"]):
|
||||
for root in _alternatives(str(profile["data_root"][key]["path"])):
|
||||
for place in {entry.get("path"), entry.get("standalone_path")} - {None, ""}:
|
||||
joined = f"{root}/{place}"
|
||||
for text in pages:
|
||||
self.assertNotIn(joined, text, profile_name)
|
||||
|
||||
|
||||
class DataFolderPrinted(unittest.TestCase):
|
||||
def test_every_profile_prints_each_of_its_folders(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
for name, profile in _profiles_with_data_root().items():
|
||||
if profile.get("type") in ("alias", "test"):
|
||||
continue
|
||||
page = _page(name)
|
||||
with self.subTest(profile=name):
|
||||
self.assertIn('id="data-folder"', page)
|
||||
for root in profile["data_root"].values():
|
||||
for alternative in _alternatives(str(root["path"])):
|
||||
self.assertIn(alternative, page)
|
||||
|
||||
def test_ryujinx_keys_sit_in_system(self) -> None:
|
||||
block = "\n".join(gs.data_folder_block("ryujinx", _profile("ryujinx")))
|
||||
self.assertIn("`%APPDATA%/Ryujinx/system/`", block)
|
||||
self.assertIn("`~/Library/Application Support/Ryujinx/system/`", block)
|
||||
self.assertEqual(_tab_labels(block), {"Windows", "macOS", "Linux"})
|
||||
|
||||
def test_aethersx2_step_names_the_android_folder(self) -> None:
|
||||
profile = _profile("aethersx2")
|
||||
steps = gs._any_name_steps("AetherSX2", profile, gs.any_name_bios(profile), "", "aethersx2")
|
||||
self.assertIn("2. Put it in this folder:", steps)
|
||||
self.assertIn("`Android/data/xyz.aethersx2.android/files/bios/`, on the internal storage.",
|
||||
steps)
|
||||
self.assertNotIn("app's own data folder", steps)
|
||||
|
||||
def test_one_system_is_a_line_not_a_tab(self) -> None:
|
||||
block = "\n".join(gs.data_folder_block("mame4droid", _profile("mame4droid")))
|
||||
self.assertTrue(block.startswith("On Android: `Android/data/com.seleuco.mame4droid/files/`"))
|
||||
self.assertNotIn("<Tabs", block)
|
||||
|
||||
def test_an_entry_found_by_name_or_shipped_is_not_placed(self) -> None:
|
||||
profile = {"type": "standalone", "data_root": {"linux": {"path": "~/x", "source_ref": "a"}}}
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a.bin"}), "")
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "a", "bundled": True}),
|
||||
"")
|
||||
self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "b/a"}), "b/a")
|
||||
dual = {**profile, "type": "standalone + libretro"}
|
||||
self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "core/a"}), "")
|
||||
self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "c/a",
|
||||
"standalone_path": "a"}), "a")
|
||||
|
||||
def test_the_metadata_card_has_a_row_per_system(self) -> None:
|
||||
rows = gs._render_metadata_rows(_profile("cxbx-reloaded"))
|
||||
self.assertIn("| Data folder (Windows) | `%APPDATA%/Cxbx-Reloaded/` |", rows)
|
||||
self.assertFalse(any("Data folder (Linux)" in row for row in rows))
|
||||
|
||||
def test_no_page_names_the_field(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
named = sorted(str(p.relative_to(CONTENT)) for p in CONTENT.rglob("*.md*")
|
||||
if "data_root" in p.read_text(encoding="utf-8")
|
||||
and not p.relative_to(CONTENT).as_posix().startswith("wiki/profiling"))
|
||||
self.assertEqual(named, [])
|
||||
|
||||
|
||||
class InstallBlockWhereItWorks(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
|
||||
cls.install = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.install)
|
||||
cls.accepted: dict[str, set[str]] = {}
|
||||
for path in sorted((REPO_ROOT / "install").glob("*.json")):
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
names = cls.install.emulator_names(manifest, manifest.get("files") or [],
|
||||
manifest.get("omitted_files") or [])
|
||||
for alias, profile in names.items():
|
||||
cls.accepted.setdefault(alias, set()).add(profile)
|
||||
|
||||
def test_every_printed_command_names_this_profile_in_a_manifest(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
for path in sorted(EMULATORS.glob("*.md*")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for name in set(re.findall(r"--emulator ([\w.+-]+)", text)):
|
||||
with self.subTest(page=path.name, name=name):
|
||||
self.assertIn(path.stem, self.accepted.get(name, set()))
|
||||
|
||||
def test_no_os_tab_outside_the_data_root(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
labels = {key: gs.TERMINALS[key][0] for key in gs.TERMINALS}
|
||||
for name, profile in _profiles_with_data_root().items():
|
||||
allowed = {labels["device"]}
|
||||
for key in profile["data_root"]:
|
||||
allowed |= {labels[t] for t in gs._DATA_ROOT_TERMINALS[key]}
|
||||
with self.subTest(profile=name):
|
||||
self.assertLessEqual(_tab_labels(_install_section(_page(name))), allowed)
|
||||
|
||||
def test_android_only_app_gets_no_desktop_command(self) -> None:
|
||||
self.assertEqual(gs._install_block("mame4droid", _profile("mame4droid")), [])
|
||||
command, setups, oses = gs.emulator_install_route("cxbx-reloaded", _profile("cxbx-reloaded"))
|
||||
self.assertEqual(oses, ("windows",))
|
||||
|
||||
def test_a_lone_app_is_named_with_the_setup_that_launches_it(self) -> None:
|
||||
text = "\n".join(gs._install_block("ryujinx", _profile("ryujinx")))
|
||||
self.assertRegex(text, r"With [^:]*EmuDeck[^:]*: the installer finds")
|
||||
self.assertNotIn("Android (Termux)", text)
|
||||
self.assertNotIn("RetroArch", text)
|
||||
|
||||
def test_a_dual_app_says_what_the_command_fills(self) -> None:
|
||||
text = "\n".join(gs._install_block("duckstation", _profile("duckstation")))
|
||||
self.assertIn("For DuckStation inside RetroArch, Batocera or another setup.", text)
|
||||
|
||||
def test_no_files_no_command(self) -> None:
|
||||
self.assertEqual(gs._install_block("beetle_wswan", _profile("beetle_wswan")), [])
|
||||
|
||||
def test_tabs_follow_the_keys_given(self) -> None:
|
||||
tabs = "\n".join(gs.install_commands("--emulator x", oses=("linux", "steamdeck")))
|
||||
self.assertEqual(_tab_labels(tabs), {"Linux", "Steam Deck"})
|
||||
self.assertIn('syncKey="os"', tabs)
|
||||
blocks = "\n".join(gs.install_commands("--emulator x"))
|
||||
self.assertIn("Windows (PowerShell):", blocks)
|
||||
|
||||
|
||||
class SmallFixes(unittest.TestCase):
|
||||
def test_structured_blocks_closed_below_the_files(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
page = _page("cemu")
|
||||
self.assertLess(page.index("## Files"), page.index("<summary>Firmware titles</summary>"))
|
||||
self.assertNotRegex(page, r"<details[^>]*open>\s*<summary>Firmware titles")
|
||||
|
||||
def test_region_choice_summary(self) -> None:
|
||||
lines = gs._emulator_file_summary(_profile("duckstation")["files"])
|
||||
self.assertIn("One of 3 is required, for your games' region.", lines[0])
|
||||
self.assertNotIn("3 required", lines[0])
|
||||
|
||||
def test_no_emulator_part_says_where_it_sits(self) -> None:
|
||||
if not _generated():
|
||||
self.skipTest("site pages not generated")
|
||||
parts = sorted(EMULATORS.glob("*-files-*.md*"))
|
||||
if not parts:
|
||||
self.skipTest("no split emulator page")
|
||||
said = [p.name for p in parts if "\nPart of [" in p.read_text(encoding="utf-8")]
|
||||
self.assertEqual(said, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,300 +0,0 @@
|
||||
"""Entry pages, get/ and guides (round 5, package 4).
|
||||
|
||||
The get/ page of a setup with no ZIP printed one command for three
|
||||
systems, with no --dest, so it failed on the computer most readers open
|
||||
it on. "All good when you see 0 wrong hash" was also what an empty folder
|
||||
prints. Setups with no ZIP lost every route that works without a network.
|
||||
The get/ pages were titled "Download KNULLI", /get/ answered 404, and two
|
||||
Linux handheld rows sent the reader to a menu.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
import siteguides # noqa: E402
|
||||
from common import load_platform_config, load_platform_registry # noqa: E402
|
||||
from tests import sitepages # noqa: E402
|
||||
|
||||
REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
|
||||
SETUPS = [name for name in siteguides.GUIDES if name in REGISTRY]
|
||||
COMMAND = re.compile(r"^(?:curl -fsSL |&\(\[scriptblock\]|irm ).*$", re.M)
|
||||
OFFLINE_ANCHOR = "#without-a-network-offline-install"
|
||||
CARD_FAQ = "/retrobios/wiki/faq/#fill-an-sd-card-from-a-computer"
|
||||
|
||||
|
||||
def _page(name: str) -> str:
|
||||
sitepages.require()
|
||||
return re.sub(r"</?abbr[^>]*>", "", sitepages.page(name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _guide_part(text: str) -> str:
|
||||
match = re.search(r"^## Reference\b", text, re.M)
|
||||
return text[: match.start()] if match else text
|
||||
|
||||
|
||||
def _with_zip(name: str) -> bool:
|
||||
pack = release_record.pack_for(name, release_record.load_advertised())
|
||||
return bool(pack and pack.get("assets"))
|
||||
|
||||
|
||||
def _display(name: str) -> str:
|
||||
return load_platform_config(name, str(REPO_ROOT / "platforms")).get("platform", name)
|
||||
|
||||
|
||||
def _tab_labels(text: str) -> set[str]:
|
||||
return set(re.findall(r'<TabItem label="([^"]+)"', text))
|
||||
|
||||
|
||||
class GetPagesGiveTheSetupsOwnRoutes(unittest.TestCase):
|
||||
def test_every_command_on_a_get_page_is_on_its_setup_page(self):
|
||||
for name in SETUPS:
|
||||
get = set(COMMAND.findall(_page(f"get/{name}.md")))
|
||||
page = _page(f"platforms/{name}.md")
|
||||
setup = set(COMMAND.findall(page))
|
||||
with self.subTest(setup=name):
|
||||
# The Mac and Linux lines of the network folder live on the
|
||||
# get/ page alone, which the setup page links for them.
|
||||
if f"(/retrobios/get/{name}/#from-your-computer)" in page:
|
||||
get = {c for c in get if "--dest /Volumes/" not in c
|
||||
and "--dest /path/to/" not in c}
|
||||
self.assertTrue(get <= setup, sorted(get - setup))
|
||||
if not _with_zip(name):
|
||||
self.assertTrue(get, "a setup with no ZIP gets its command")
|
||||
|
||||
def test_a_card_setup_names_the_card(self):
|
||||
for name in SETUPS:
|
||||
if not siteguides.GUIDES[name].on_card:
|
||||
continue
|
||||
with self.subTest(setup=name):
|
||||
for command in COMMAND.findall(_page(f"get/{name}.md")):
|
||||
self.assertIn("--dest ", command)
|
||||
|
||||
def test_no_tab_for_a_system_the_setup_does_not_run_from(self):
|
||||
labels = {key: entry[0] for key, entry in siteguides.TERMINALS.items()}
|
||||
for name in SETUPS:
|
||||
if _with_zip(name):
|
||||
continue
|
||||
guide = siteguides.GUIDES[name]
|
||||
allowed = {labels[key] for key in guide.run}
|
||||
allowed |= {labels[key] for key in guide.card_run or ("windows", "macos", "linux")}
|
||||
if generate_site._network_share(name, guide):
|
||||
# The network folder is reached from any computer.
|
||||
allowed |= {labels[key] for key in ("windows", "macos", "linux")}
|
||||
with self.subTest(setup=name):
|
||||
self.assertLessEqual(_tab_labels(_page(f"get/{name}.md")), allowed)
|
||||
self.assertNotIn("Android (Termux", _page(f"get/{name}.md"))
|
||||
|
||||
def test_retrodeck_runs_from_linux_and_the_deck(self):
|
||||
text = _page("get/retrodeck.md")
|
||||
install = text.split("## Install", 1)[1].split("No network", 1)[0]
|
||||
self.assertEqual(_tab_labels(install), {"Steam Deck", "Linux"})
|
||||
|
||||
def test_one_helper_writes_both_pages(self):
|
||||
source = Path(generate_site.__file__).read_text(encoding="utf-8")
|
||||
get_page = source.split("def generate_get_page", 1)[1].split("\ndef ", 1)[0]
|
||||
guide = source.split("def _platform_guide", 1)[1].split("\ndef ", 1)[0]
|
||||
self.assertNotIn("install_commands(", get_page)
|
||||
self.assertIn("_setup_routes(", get_page)
|
||||
self.assertIn("_setup_routes(", guide)
|
||||
|
||||
|
||||
class CheckNeedsTheFiles(unittest.TestCase):
|
||||
def test_the_pass_line_is_what_the_installer_prints_only_when_complete(self):
|
||||
installer = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
printed = re.search(r"`([^`]+)`", generate_site.check_pass_line())[1]
|
||||
self.assertEqual(printed, generate_site._installer().EVERYTHING_IN_PLACE)
|
||||
self.assertIn('print(f"\\n {EVERYTHING_IN_PLACE}")', installer)
|
||||
self.assertNotIn("wrong hash", generate_site.check_pass_line())
|
||||
|
||||
def test_no_setup_page_passes_an_empty_folder(self):
|
||||
for name in SETUPS:
|
||||
text = _page(f"platforms/{name}.md")
|
||||
with self.subTest(setup=name):
|
||||
self.assertNotIn("All good when you see `0 wrong hash`", text)
|
||||
self.assertIn(generate_site.check_pass_line(), text)
|
||||
|
||||
|
||||
class ARouteWithoutANetwork(unittest.TestCase):
|
||||
def test_every_setup_page_has_the_offline_line_or_the_card_route(self):
|
||||
for name in SETUPS:
|
||||
guide = siteguides.GUIDES[name]
|
||||
text = _guide_part(_page(f"platforms/{name}.md"))
|
||||
with self.subTest(setup=name):
|
||||
if _with_zip(name):
|
||||
self.assertIn(OFFLINE_ANCHOR, text)
|
||||
elif guide.on_card:
|
||||
self.assertIn("Put the SD card in a computer.", text)
|
||||
else:
|
||||
self.assertIn("No network", text)
|
||||
self.assertNotIn(OFFLINE_ANCHOR, text)
|
||||
|
||||
def test_every_device_setup_with_no_zip_links_the_card_answer(self):
|
||||
for name in SETUPS:
|
||||
guide = siteguides.GUIDES[name]
|
||||
if _with_zip(name) or guide.run != ("device",):
|
||||
continue
|
||||
extract_to = REGISTRY[name]["extract_to"]
|
||||
for page in (f"platforms/{name}.md", f"get/{name}.md"):
|
||||
text = _page(page)
|
||||
with self.subTest(page=page):
|
||||
self.assertIn("## From your computer { #from-your-computer }", text)
|
||||
self.assertIn(CARD_FAQ, text)
|
||||
self.assertIn(f"the folder the device calls `{extract_to}`", text)
|
||||
self.assertIn(f"--platform {name} --dest /path/to/", text)
|
||||
|
||||
def test_rocknix_has_a_computer_route(self):
|
||||
text = _guide_part(_page("platforms/rocknix.md"))
|
||||
self.assertLess(text.index("## From your computer"), text.index("## On the device"))
|
||||
|
||||
def test_retropie_card_is_read_from_linux_only(self):
|
||||
self.assertEqual(siteguides.GUIDES["retropie"].card_run, ("linux",))
|
||||
text = _page("platforms/retropie.md")
|
||||
card = text.split("No network?", 1)[1].split("## On the device", 1)[0]
|
||||
self.assertEqual(_tab_labels(card), {"Linux"})
|
||||
|
||||
def test_no_zip_is_promised_for_an_unmarked_setup(self):
|
||||
for name in SETUPS:
|
||||
if REGISTRY[name].get("release_pack") is True:
|
||||
continue
|
||||
with self.subTest(setup=name):
|
||||
for page in (f"platforms/{name}.md", f"get/{name}.md"):
|
||||
text = _guide_part(_page(page))
|
||||
self.assertNotIn("/releases/download/", text)
|
||||
self.assertNotIn(OFFLINE_ANCHOR, text)
|
||||
|
||||
|
||||
class GetPagesSayWhatTheyHold(unittest.TestCase):
|
||||
def test_title_description_and_parent(self):
|
||||
for name in SETUPS:
|
||||
display = _display(name)
|
||||
text = sitepages.page(f"get/{name}.md").read_text(encoding="utf-8")
|
||||
kind = "pack" if REGISTRY[name].get("release_pack") is True else "files"
|
||||
title = (f"BIOS {kind} for {display}"
|
||||
if (REPO_ROOT / "emulators" / f"{name}.yml").is_file()
|
||||
else f"{display} BIOS {kind}")
|
||||
with self.subTest(setup=name):
|
||||
self.assertEqual(generate_site.get_page_title(name, display), title)
|
||||
self.assertIn(f"title: {title}\n", text)
|
||||
self.assertNotIn("Download ", text.split("\n---\n", 1)[0].split("title:", 1)[1]
|
||||
.split("\n", 1)[0])
|
||||
self.assertIn(f"link: /platforms/{name}/", text)
|
||||
if kind == "files":
|
||||
self.assertIn(f"The BIOS files for {display}: one command installs and "
|
||||
"checks them.", text)
|
||||
|
||||
def test_a_setup_with_no_zip_links_its_get_page(self):
|
||||
for name in SETUPS:
|
||||
if REGISTRY[name].get("release_pack") is True:
|
||||
continue
|
||||
with self.subTest(setup=name):
|
||||
title = generate_site.get_page_title(name, _display(name))
|
||||
self.assertIn(f"Link to share: [{title}](/retrobios/get/{name}/)",
|
||||
_page(f"platforms/{name}.md"))
|
||||
|
||||
def test_a_relative_folder_is_named_as_the_setups(self):
|
||||
text = _page("get/retroarch.md")
|
||||
self.assertIn("Unzip them all into RetroArch's `system` folder. "
|
||||
"[Where is it?](/retrobios/platforms/retroarch/#where-the-files-go)", text)
|
||||
self.assertIn("In RetroArch, open **Settings > Directory > System/BIOS**", text)
|
||||
self.assertIn("into RetroBat's `bios` folder", _page("get/retrobat.md"))
|
||||
self.assertNotIn("into `system/`", text)
|
||||
|
||||
|
||||
class HandheldRowsSayWhere(unittest.TestCase):
|
||||
def test_retrooz_names_its_folder_with_a_pinned_source(self):
|
||||
system = next(s for s in siteguides.HANDHELD_GUIDE.systems if s.name == "RetroOZ")
|
||||
self.assertIn("`/roms/bios`", system.folder)
|
||||
self.assertRegex(system.folder_source, r"/wiki/Home/[0-9a-f]{40}$")
|
||||
row = next(line for line in _page("platforms/linux-handhelds.md").splitlines()
|
||||
if line.startswith("| RetroOZ |"))
|
||||
self.assertNotIn(generate_site.RETROARCH_FOLDER_SETTING, row)
|
||||
|
||||
|
||||
class GetIndex(unittest.TestCase):
|
||||
def test_every_setup_is_linked_under_its_group(self):
|
||||
text = _page("get/index.md")
|
||||
for name in SETUPS:
|
||||
with self.subTest(setup=name):
|
||||
self.assertIn(f"(/retrobios/get/{name}/)", text)
|
||||
for label, _description in siteguides.DEVICE_GROUPS.values():
|
||||
self.assertIn(f"## {label}", text)
|
||||
|
||||
|
||||
class HomeAndWhichPack(unittest.TestCase):
|
||||
def test_the_home_step_does_not_promise_a_zip(self):
|
||||
text = _page("index.md")
|
||||
self.assertIn("**Run one command.** Some setups have a ZIP.", text)
|
||||
self.assertNotIn("or download a ZIP file", text)
|
||||
|
||||
def test_which_pack_answers_ios(self):
|
||||
text = _page("which-pack.md")
|
||||
meta = re.search(r'data-pagefind-meta="terms:([^"]+)"', text)
|
||||
self.assertIsNotNone(meta)
|
||||
for word in ("ios", "iphone", "ipad", "delta", "provenance"):
|
||||
self.assertIn(word, meta[1].split())
|
||||
|
||||
|
||||
class TheSiteSaysWhatTheInstallerShows(unittest.TestCase):
|
||||
"""which-pack showed a line menu (Enter, i, c, q) the one-liner never
|
||||
opens, typed by hand, its keys out of date. The site, the README and
|
||||
the wiki now take the screen's words and the line menu from install.py."""
|
||||
|
||||
MENU_LINES = ("Enter) install everything", "i) only what the installed cores read",
|
||||
"c) choose systems, cores and regions")
|
||||
|
||||
def test_which_pack_describes_the_screen_in_the_installers_words(self):
|
||||
installer = generate_site._installer()
|
||||
text = _page("which-pack.md")
|
||||
for line in generate_site.first_screen_words():
|
||||
self.assertIn(line, text)
|
||||
self.assertIn(f"**{installer.TUI_INSTALL_ROW}**", text)
|
||||
self.assertIn(f"**{installer.TUI_SYSTEMS_ROW}**", text)
|
||||
for line in self.MENU_LINES:
|
||||
self.assertNotIn(line, text)
|
||||
self.assertNotIn("press **c**", text)
|
||||
|
||||
def test_the_plan_rows_are_the_words_the_pages_use(self):
|
||||
installer = generate_site._installer()
|
||||
state = installer.TuiState(os_type="linux", home=Path("/home/me"))
|
||||
state.stack = ["plan"]
|
||||
state.check = {"files": 2, "present": 0, "missing": 2, "wrong_hash": 0, "need": 2,
|
||||
"bytes_needed": 10}
|
||||
state.systems = [{"system": name, "missing": 1, "wrong_hash": 0, "present": 0,
|
||||
"files": 1, "bytes_needed": 5} for name in ("a", "b")]
|
||||
state.manifest = {"files": [{"dest": "a.bin", "systems": ["a"]},
|
||||
{"dest": "b.bin", "systems": ["b"]}]}
|
||||
state.ticked = {"a", "b"}
|
||||
state.checked_flags = installer.tui_flags(state)
|
||||
labels = [row.label for row in installer.tui_rows(state)]
|
||||
self.assertEqual(labels[0], installer.TUI_INSTALL_ROW)
|
||||
self.assertIn(installer.TUI_SYSTEMS_ROW, labels)
|
||||
|
||||
def test_the_readme_and_the_wiki_take_the_same_words(self):
|
||||
installer = generate_site._installer()
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
self.assertIn(f"fewer under **{installer.TUI_SYSTEMS_ROW}**", readme)
|
||||
self.assertNotIn("offers to install everything or to choose systems", readme)
|
||||
source = (REPO_ROOT / "scripts" / "generate_readme.py").read_text(encoding="utf-8")
|
||||
self.assertIn("_installer_words().TUI_SYSTEMS_ROW", source)
|
||||
import generate_installer_doc # noqa: PLC0415
|
||||
|
||||
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
|
||||
self.assertIn(generate_installer_doc.line_menu(), page)
|
||||
menu = page.split("<!-- generated: line-menu -->", 1)[1]
|
||||
outside = page.replace(menu.split("<!-- /generated: line-menu -->", 1)[0], "")
|
||||
for line in self.MENU_LINES:
|
||||
self.assertNotIn(line, outside)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,266 +0,0 @@
|
||||
"""Reference pages and data (round 5, package 8).
|
||||
|
||||
The API said Lakka and RetroPie had a release pack while their pages said
|
||||
none, 50 nested manifest fields and the nine omission codes had no
|
||||
description, the lookup named 9 emulators where the card named 11, four
|
||||
served schemas and the target lists appeared nowhere on the Data page, the
|
||||
folder check exited 0 on a folder missing every file, and a system id did
|
||||
not lead to its JSON document. Tests reading the generated site skip when
|
||||
it has not been generated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
import release_record # noqa: E402
|
||||
from common import list_release_pack_platforms # noqa: E402
|
||||
|
||||
PUBLIC = REPO_ROOT / "website" / "public"
|
||||
API = PUBLIC / "api" / "v1"
|
||||
GENERATED = REPO_ROOT / "website" / "src" / "generated"
|
||||
SCHEMA = json.loads((REPO_ROOT / "schemas" / "install-manifest.schema.json").read_text())
|
||||
|
||||
|
||||
def _built(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
raise unittest.SkipTest(f"{path.relative_to(REPO_ROOT)} not generated")
|
||||
return path
|
||||
|
||||
|
||||
def _released() -> set[str]:
|
||||
return set(list_release_pack_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||
|
||||
|
||||
class OneAnswerOnReleasePacks(unittest.TestCase):
|
||||
"""8.1: the API, the picker's data and the pages read the registry."""
|
||||
|
||||
def test_a_child_does_not_inherit_its_parents_pack(self):
|
||||
_contract, registry = generate_site._split_contract(
|
||||
{"_key": "lakka", "inherits": "retroarch", "release_pack": True})
|
||||
self.assertIs(registry["release_pack"], "lakka" in _released())
|
||||
_contract, registry = generate_site._split_contract({"_key": "minui"})
|
||||
self.assertNotIn("release_pack", registry)
|
||||
_contract, registry = generate_site._split_contract({"_key": "retroarch"})
|
||||
self.assertIs(registry["release_pack"], "retroarch" in _released())
|
||||
|
||||
def test_api_release_pack_set_equals_the_registry(self):
|
||||
found = set()
|
||||
for path in _built(API / "platforms").glob("*.json"):
|
||||
item = json.loads(path.read_text())["items"][0]
|
||||
if item["registry"].get("release_pack"):
|
||||
found.add(item["id"])
|
||||
self.assertEqual(found, _released())
|
||||
|
||||
def test_the_picker_file_is_the_advertised_record(self):
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir()
|
||||
else None) as folder:
|
||||
generate_site.write_release_advertised(Path(folder))
|
||||
written = json.loads((Path(folder) / "release-advertised.json").read_text())
|
||||
self.assertEqual(written, release_record.load_advertised())
|
||||
served = {p for pack in written.get("packs", {}).values() for p in pack["platforms"]}
|
||||
self.assertLessEqual(served, _released())
|
||||
|
||||
def test_the_built_picker_file(self):
|
||||
written = json.loads(_built(GENERATED / "release-advertised.json").read_text())
|
||||
self.assertEqual(written, release_record.load_advertised())
|
||||
|
||||
|
||||
def _properties(node: object, path: str = ""):
|
||||
"""(path, spec) of every property at any depth, $defs included."""
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
for key, spec in (node.get("properties") or {}).items():
|
||||
yield f"{path}{key}", spec
|
||||
yield from _properties(spec, f"{path}{key}.")
|
||||
for key in ("items", "additionalProperties"):
|
||||
yield from _properties(node.get(key), path)
|
||||
for name, spec in (node.get("$defs") or {}).items():
|
||||
yield from _properties(spec, f"#{name}.")
|
||||
for key in ("oneOf", "anyOf", "allOf"):
|
||||
for spec in node.get(key) or []:
|
||||
yield from _properties(spec, path)
|
||||
|
||||
|
||||
class EveryManifestFieldIsDescribed(unittest.TestCase):
|
||||
"""8.2: every property at any depth, and each omission code."""
|
||||
|
||||
def test_every_property_has_a_description(self):
|
||||
missing = [path for path, spec in _properties(SCHEMA)
|
||||
if isinstance(spec, dict) and not spec.get("description")]
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_each_reason_code_has_a_line(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
for path, spec in _properties(SCHEMA):
|
||||
if path.endswith(".reason"):
|
||||
self.assertEqual(len(spec["enumDescriptions"]), len(spec["enum"]), path)
|
||||
for code in spec["enum"]:
|
||||
self.assertRegex(page, rf"\n- `{code}`: \S")
|
||||
|
||||
def test_nested_tables_are_on_the_page(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
for label in ("Each entry of `omitted_files`:", "Each entry of `data_directories`:",
|
||||
"Each value of `data_directories[].files`:", "Fields of `save_directory`:",
|
||||
"Each entry of `files[].alternatives`:",
|
||||
"Each entry of `files[].slot_choices`:",
|
||||
"Each entry of `files[].declared_choices`:",
|
||||
"Each entry of `files[].absent_claims`:"):
|
||||
self.assertIn(label, page)
|
||||
for key in ("below_platform_folder", "strip_components", "by_core"):
|
||||
self.assertIn(f"`{key}`", page)
|
||||
|
||||
|
||||
class FakeConsumers:
|
||||
def __init__(self, sha1: str) -> None:
|
||||
self.emulators = {sha1: {"byname"}}
|
||||
self.emulator_entries = {sha1: {"byname": 0}}
|
||||
self.emulator_files = {"byname": [{"name": "bios.bin"}]}
|
||||
|
||||
|
||||
class LookupCountsEmulatorsLikeTheCard(unittest.TestCase):
|
||||
"""8.3: an emulator reading the file by name is in the verdict."""
|
||||
|
||||
SHA1 = "a" * 40
|
||||
|
||||
def records(self) -> list[dict]:
|
||||
db = {"files": {self.SHA1: {"name": "bios.bin", "path": "bios/Sony/PlayStation/bios.bin",
|
||||
"sha1": self.SHA1, "md5": "b" * 32}},
|
||||
"indexes": {"by_md5": {"b" * 32: self.SHA1}}}
|
||||
coverages = {"setup": {"config": {"systems": {"sony-playstation": {"files": [
|
||||
{"name": "bios.bin", "md5": "b" * 32}]}}}}}
|
||||
with mock.patch.object(generate_site, "_console_system_names", return_value={}), \
|
||||
mock.patch.object(generate_site, "_lookup_system_name", return_value=None), \
|
||||
mock.patch.object(generate_site, "emulator_card_page", side_effect=lambda k, a: k):
|
||||
return generate_site.file_lookup_records(
|
||||
db, coverages, {}, [{"sha1": self.SHA1, "url": "/x"}], None,
|
||||
FakeConsumers(self.SHA1))
|
||||
|
||||
def test_by_name_under_every_hash(self):
|
||||
records = self.records()
|
||||
for value in (self.SHA1, "b" * 32):
|
||||
named = [r for r in records if r["value"] == value and r.get("owner") == "byname"]
|
||||
self.assertEqual(len(named), 1, value)
|
||||
self.assertEqual(named[0]["declared"], "name")
|
||||
|
||||
def test_every_setup_and_emulator_record_says_what_was_declared(self):
|
||||
for record in self.records():
|
||||
if record["layer"] in ("platform", "emulator"):
|
||||
self.assertIn("declared", record)
|
||||
own = next(r for r in self.records()
|
||||
if r["layer"] == "platform" and r["value"] == "b" * 32)
|
||||
self.assertEqual(own["declared"], "md5")
|
||||
|
||||
def test_scph5501_names_the_cards_eleven(self):
|
||||
sha1 = "0555c6fae8906f3f09baf5988f00e55f88e9f30b"
|
||||
shard = json.loads(_built(API / "lookup" / f"{sha1[:2]}.json").read_text())
|
||||
owners = {r["owner"] for r in shard["items"]
|
||||
if r["value"] == sha1 and r["layer"] == "emulator"}
|
||||
self.assertGreaterEqual(len(owners), 11)
|
||||
self.assertLessEqual({"pcsx_rearmed", "pcsx1"}, owners)
|
||||
|
||||
|
||||
class TheDataPageListsWhatIsServed(unittest.TestCase):
|
||||
"""8.4 and 8.6."""
|
||||
|
||||
def test_every_schema_is_listed(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
for path in (REPO_ROOT / "schemas").glob("*.schema.json"):
|
||||
self.assertIn(f"- [`{path.name}`](api/v1/schemas/{path.name}): ", page)
|
||||
|
||||
def test_target_lists_are_published_and_catalogued(self):
|
||||
catalog = json.loads(_built(API / "catalog.json").read_text())
|
||||
listed = {item["url"] for item in catalog["items"]}
|
||||
for source in (REPO_ROOT / "install" / "targets").glob("*.json"):
|
||||
self.assertIn(f"targets/{source.name}", listed)
|
||||
self.assertEqual((API / "targets" / source.name).read_bytes(), source.read_bytes())
|
||||
|
||||
def test_declared_only_and_search_words(self):
|
||||
page = generate_site.generate_data_page([])
|
||||
self.assertIn("With `--declared-only`, or from the `declared` event, "
|
||||
"`install.py --check` answers it too.", page)
|
||||
terms = page.split("\n", 2)[1]
|
||||
for word in ("json schema", "license", "check bios folder script"):
|
||||
self.assertIn(word, terms)
|
||||
files_terms = generate_site.generate_files_page().split("---\n", 2)[1]
|
||||
for word in ("is my bios correct", "is my bios right"):
|
||||
self.assertIn(word, files_terms)
|
||||
|
||||
|
||||
def _respond(routes: dict):
|
||||
def urlopen(url: str, *args, **kwargs):
|
||||
for suffix, body in routes.items():
|
||||
if url.endswith(suffix):
|
||||
if body is None:
|
||||
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
|
||||
return io.BytesIO(json.dumps(body).encode())
|
||||
raise AssertionError(url)
|
||||
return urlopen
|
||||
|
||||
|
||||
class TheFolderCheckExitCode(unittest.TestCase):
|
||||
"""8.5: exit 1 on a file wrong or missing, a word for an unknown platform."""
|
||||
|
||||
SCRIPT = str(REPO_ROOT / "scripts" / "check_platform_folder.py")
|
||||
CONTRACT = {"items": [{"contract": {"verification_mode": "existence", "systems": {
|
||||
"x": {"files": [{"name": "a.bin", "destination": "a.bin"}]}}}}]}
|
||||
|
||||
def run_script(self, platform: str, folder: Path, routes: dict) -> tuple[object, str]:
|
||||
err = io.StringIO()
|
||||
with mock.patch("urllib.request.urlopen", _respond(routes)), \
|
||||
mock.patch.object(sys, "argv", ["check", platform, str(folder)]), \
|
||||
mock.patch("sys.stdout", io.StringIO()), mock.patch("sys.stderr", err), \
|
||||
self.assertRaises(SystemExit) as raised:
|
||||
runpy.run_path(self.SCRIPT, run_name="__main__")
|
||||
return raised.exception.code, err.getvalue()
|
||||
|
||||
def test_missing_exits_one_and_complete_exits_zero(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
code, _err = self.run_script("p", Path(folder), {"/p.json": self.CONTRACT})
|
||||
self.assertEqual(code, 1)
|
||||
(Path(folder) / "a.bin").write_bytes(b"x")
|
||||
code, _err = self.run_script("p", Path(folder), {"/p.json": self.CONTRACT})
|
||||
self.assertEqual(code, 0)
|
||||
|
||||
def test_an_unknown_platform_names_the_known_ones(self):
|
||||
catalog = {"items": [{"url": "manifests/batocera.json"}, {"url": "stats.json"}]}
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
code, _err = self.run_script(
|
||||
"nosuch", Path(folder), {"/nosuch.json": None, "/catalog.json": catalog})
|
||||
self.assertEqual(code, "unknown platform: nosuch. Known: batocera")
|
||||
|
||||
def test_the_page_shows_the_script(self):
|
||||
script = Path(self.SCRIPT).read_text(encoding="utf-8")
|
||||
self.assertIn("```python\n" + script.rstrip("\n") + "\n```",
|
||||
generate_site.generate_data_page([]))
|
||||
|
||||
|
||||
class SystemItemsCarryTheirApi(unittest.TestCase):
|
||||
"""8.7."""
|
||||
|
||||
def test_each_api_address_is_served(self):
|
||||
items = json.loads(_built(API / "systems.json").read_text())["items"]
|
||||
playstation = next(i for i in items if i["id"] == "sony-playstation")
|
||||
self.assertEqual(playstation["api"], "/retrobios/api/v1/systems/sony.json")
|
||||
for item in items:
|
||||
if item["url"] is not None:
|
||||
self.assertIsNotNone(item["api"], item["id"])
|
||||
if item["api"]:
|
||||
self.assertTrue((PUBLIC / item["api"].removeprefix("/retrobios/")).is_file(),
|
||||
item["api"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user