mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
feat: publish manifests, id indexes and licence
The CSV and SQLite carry zipped_file and the Recalbox flags, contracts leave registry keys in a registry field and validate, every envelope carries commit, lookup URLs are site-absolute, systems.json and cores.json answer an id, the Data page shows the manifest fields and a folder check, and platform overviews link emulators by name.
This commit is contained in:
1 parent
c3b45182ca
commit
2b64eaab9b
7 files changed
+907
-63
No files matched your search
+426
-53
@@ -106,6 +106,9 @@ STARLIGHT_CONTENT = "src/content/docs"
|
||||
SITE_NAME = "RetroBIOS"
|
||||
REPO_URL = "https://github.com/Abdess/retrobios"
|
||||
RELEASE_URL = f"{REPO_URL}/releases/latest"
|
||||
# What LICENSE grants over the published data: its MIT terms name the
|
||||
# metadata. The BIOS files themselves are under NOTICE, never this.
|
||||
DATA_LICENSE = "MIT (LICENSE: scripts, tooling, schemas, documentation and metadata)"
|
||||
SITE_URL = "https://abdess.github.io/retrobios/"
|
||||
GENERATED_DIRS = ["platforms", "systems", "emulators", "wiki", "api", "downloads"]
|
||||
# Generated directories of files served as they are, not pages.
|
||||
@@ -806,6 +809,36 @@ def _sha1_by_path(db: dict | None) -> dict[str, str]:
|
||||
return {str(e.get("path", "")): sha1 for sha1, e in ((db or {}).get("files") or {}).items()}
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _contract_keys() -> frozenset[str]:
|
||||
"""The top-level keys platform.schema.json allows in a platform's list."""
|
||||
schema = json.loads((_REPO_ROOT / "schemas" / "platform.schema.json").read_text(encoding="utf-8"))
|
||||
return frozenset(schema.get("properties") or {})
|
||||
|
||||
|
||||
def _split_contract(config: dict) -> tuple[dict, dict]:
|
||||
"""The list, valid against platform.schema.json, and the registry entry.
|
||||
|
||||
load_platform_config merges the registry into the list: `install`,
|
||||
`logo`, `scraper` and `_key` made all 18 contracts fail the published
|
||||
schema. They stay published, beside the contract.
|
||||
"""
|
||||
allowed = _contract_keys()
|
||||
contract = {key: value for key, value in config.items() if key in allowed}
|
||||
registry = {key: value for key, value in config.items()
|
||||
if key not in allowed and key != "_key"}
|
||||
return contract, registry
|
||||
|
||||
|
||||
# Per-entry fields a folder check needs beyond the hashes: an md5 is a ZIP
|
||||
# member's when zipped_file is set, and a CSV without it judged 106 good
|
||||
# Batocera files wrong.
|
||||
ENTRY_EXPORT_FIELDS = (
|
||||
"zipped_file", "hash_match_mandatory", "mandatory_declared", "curated",
|
||||
"shared_group", "mirror_of", "zip_digest",
|
||||
)
|
||||
|
||||
|
||||
def _platform_export_rows(
|
||||
coverages: dict, db: dict | None = None,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
@@ -816,6 +849,7 @@ def _platform_export_rows(
|
||||
for key, coverage in sorted(coverages.items()):
|
||||
config = coverage["config"]
|
||||
outcome = _verify_outcomes(coverage, sha1_by_path)
|
||||
contract, registry = _split_contract(config)
|
||||
items.append({
|
||||
"id": key,
|
||||
"name": coverage["platform"],
|
||||
@@ -826,7 +860,8 @@ def _platform_export_rows(
|
||||
"core_present", "core_missing", "core_unsourceable",
|
||||
)
|
||||
},
|
||||
"contract": config,
|
||||
"contract": contract,
|
||||
"registry": registry,
|
||||
})
|
||||
for system_id, system in sorted(config.get("systems", {}).items()):
|
||||
for entry in system.get("files", []) or []:
|
||||
@@ -846,11 +881,20 @@ def _platform_export_rows(
|
||||
"sha256": _json_text(entry.get("sha256")),
|
||||
"md5": _json_text(entry.get("md5")),
|
||||
"crc32": _json_text(entry.get("crc32")),
|
||||
**{field: _export_flag(entry.get(field)) for field in ENTRY_EXPORT_FIELDS},
|
||||
**outcome(system_id, entry.get("name", "")),
|
||||
})
|
||||
return items, file_rows
|
||||
|
||||
|
||||
def _export_flag(value: object) -> object:
|
||||
"""A per-entry field as CSV and SQLite hold it: booleans stay booleans,
|
||||
an absent field is empty."""
|
||||
if value is None:
|
||||
return ""
|
||||
return value if isinstance(value, bool) else str(value)
|
||||
|
||||
|
||||
def _emulator_export_items(profiles: dict) -> list[dict]:
|
||||
return [
|
||||
{"id": key, "profile": profile}
|
||||
@@ -1089,7 +1133,9 @@ def _write_sqlite_export(
|
||||
destination TEXT NOT NULL, required INTEGER NOT NULL,
|
||||
region TEXT, variant_group TEXT, size INTEGER,
|
||||
sha1 TEXT, sha256 TEXT, md5 TEXT, crc32 TEXT,
|
||||
status TEXT, category TEXT, collection_path TEXT, resolved_sha1 TEXT
|
||||
status TEXT, category TEXT, collection_path TEXT, resolved_sha1 TEXT,
|
||||
zipped_file TEXT, hash_match_mandatory INTEGER, mandatory_declared INTEGER,
|
||||
curated INTEGER, shared_group TEXT, mirror_of TEXT, zip_digest TEXT
|
||||
);
|
||||
CREATE TABLE emulators (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT,
|
||||
@@ -1134,6 +1180,7 @@ def _write_sqlite_export(
|
||||
**({"commit": commit} if commit else {}),
|
||||
"source": REPO_URL,
|
||||
"scope": "metadata only, no BIOS or firmware payload bytes",
|
||||
"license": DATA_LICENSE,
|
||||
}
|
||||
connection.executemany(
|
||||
"INSERT INTO metadata(key, value) VALUES (?, ?)",
|
||||
@@ -1168,7 +1215,8 @@ def _write_sqlite_export(
|
||||
"INSERT INTO platform_files VALUES "
|
||||
"(:platform_id, :system, :name, :destination, :required, :region, "
|
||||
":variant_group, :size, :sha1, :sha256, :md5, :crc32, :status, :category, "
|
||||
":collection_path, :resolved_sha1)",
|
||||
":collection_path, :resolved_sha1, :zipped_file, :hash_match_mandatory, "
|
||||
":mandatory_declared, :curated, :shared_group, :mirror_of, :zip_digest)",
|
||||
platform_files,
|
||||
)
|
||||
for item in emulator_items:
|
||||
@@ -1359,13 +1407,21 @@ def generate_data_exports(
|
||||
json.dumps(db, ensure_ascii=False, indent=2) + "\n",
|
||||
)
|
||||
|
||||
schema_names = (
|
||||
"database.schema.json", "emulator.schema.json", "platform.schema.json",
|
||||
"site-api-envelope.schema.json", "stats.schema.json",
|
||||
)
|
||||
for schema_name in schema_names:
|
||||
shutil.copy2(Path("schemas") / schema_name, schemas_dest / schema_name)
|
||||
_record(schemas_dest / schema_name)
|
||||
# Every schema the published files are written against, the manifests'
|
||||
# too: an integrator had the platform's list and not what the pack writes.
|
||||
for schema_path in sorted((_REPO_ROOT / "schemas").glob("*.schema.json")):
|
||||
shutil.copy2(schema_path, schemas_dest / schema_path.name)
|
||||
_record(schemas_dest / schema_path.name)
|
||||
|
||||
manifest_assets = _publish_manifests(api)
|
||||
for filename, (kind, items) in {
|
||||
"systems.json": ("system-index", system_index_items(db, coverages, profiles)),
|
||||
"cores.json": ("core-index", core_index_items(profiles)),
|
||||
}.items():
|
||||
write_if_changed(str(api / filename), json.dumps({
|
||||
"schema_version": 1, "generated_at": generated_at, **stamp,
|
||||
"kind": kind, "count": len(items), "items": items,
|
||||
}, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
file_rows = []
|
||||
for sha1, entry in sorted(db.get("files", {}).items()):
|
||||
@@ -1400,7 +1456,7 @@ def generate_data_exports(
|
||||
"platform_id", "platform", "system", "name", "destination",
|
||||
"required", "region", "variant_group", "size", "sha1",
|
||||
"sha256", "md5", "crc32", "status", "category",
|
||||
"collection_path", "resolved_sha1",
|
||||
"collection_path", "resolved_sha1", *ENTRY_EXPORT_FIELDS,
|
||||
],
|
||||
platform_files,
|
||||
),
|
||||
@@ -1452,6 +1508,11 @@ def generate_data_exports(
|
||||
(api / "gaps.json", "Verification and coverage gaps", "application/json", "schemas/site-api-envelope.schema.json"),
|
||||
(api / "file-locations.json", "Where each file's card is, by SHA1", "application/json", "schemas/site-api-envelope.schema.json"),
|
||||
(api / "stats.json", "Project statistics", "application/json", "schemas/stats.schema.json"),
|
||||
(api / "systems.json", "System ids, their names and sections", "application/json",
|
||||
"schemas/site-api-envelope.schema.json"),
|
||||
(api / "cores.json", "Core names and the profile each one is", "application/json",
|
||||
"schemas/site-api-envelope.schema.json"),
|
||||
*manifest_assets,
|
||||
(downloads / "files.csv", "File hashes CSV", "text/csv", None),
|
||||
(downloads / "retrobios.dat", "Collection DAT (Logiqx XML, for clrmamepro and RomVault)",
|
||||
"application/xml", None),
|
||||
@@ -1488,6 +1549,7 @@ def generate_data_exports(
|
||||
"generated_at": generated_at,
|
||||
**stamp,
|
||||
"kind": "catalog",
|
||||
"license": DATA_LICENSE,
|
||||
"count": len(published),
|
||||
"items": published,
|
||||
}
|
||||
@@ -1495,10 +1557,176 @@ def generate_data_exports(
|
||||
str(api / "catalog.json"),
|
||||
json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
)
|
||||
# api/v1/ itself is a folder GitHub Pages answers with the 404 page: the
|
||||
# entry point is a document of its own, naming where to start.
|
||||
entry_points = [
|
||||
{"title": "Catalog", "url": "catalog.json",
|
||||
"description": "Every published file with its size, SHA256 and schema."},
|
||||
{"title": "System ids", "url": "systems.json",
|
||||
"description": "Each system id with its name and the section holding its files."},
|
||||
{"title": "Core names", "url": "cores.json",
|
||||
"description": "Each core name and alias with the profile it designates."},
|
||||
{"title": "Schemas", "url": "schemas/site-api-envelope.schema.json",
|
||||
"description": "The envelope every JSON document of api/v1 shares."},
|
||||
]
|
||||
write_if_changed(str(api / "index.json"), json.dumps({
|
||||
"schema_version": 1, "generated_at": generated_at, **stamp,
|
||||
"kind": "index", "license": DATA_LICENSE,
|
||||
"count": len(entry_points), "items": entry_points,
|
||||
}, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
|
||||
return catalog_items
|
||||
|
||||
|
||||
def _publish_manifests(api: Path) -> list[tuple]:
|
||||
"""The install manifests and the release record, beside the API.
|
||||
|
||||
The platform's list says what a setup checks. The manifest says what its
|
||||
pack installs: Batocera's list holds 353 files, its pack 14,508. The
|
||||
only copy was install/<platform>.json on the repository's main branch,
|
||||
which no page named. Copied byte for byte: the SHA256 the catalog gives
|
||||
is the repository file's.
|
||||
"""
|
||||
folder = api / "manifests"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
assets: list[tuple] = []
|
||||
for source in sorted((_REPO_ROOT / "install").glob("*.json")):
|
||||
target = folder / source.name
|
||||
shutil.copy2(source, target)
|
||||
_record(target)
|
||||
assets.append((target, f"Install manifest: {source.stem}", "application/json",
|
||||
"schemas/install-manifest.schema.json"))
|
||||
release = _REPO_ROOT / "release.json"
|
||||
if release.is_file():
|
||||
shutil.copy2(release, api / "release.json")
|
||||
_record(api / "release.json")
|
||||
assets.append((api / "release.json", "Latest release: packs, sizes and assets",
|
||||
"application/json", "schemas/release.schema.json"))
|
||||
return assets
|
||||
|
||||
|
||||
def _site_path(markdown_target: str) -> str:
|
||||
"""A page link as generators write it (systems/sony.md#psx) as a
|
||||
site-absolute path (/retrobios/systems/sony/#psx), which resolves the same
|
||||
from any document that holds it."""
|
||||
page, _, anchor = markdown_target.partition("#")
|
||||
base = urllib.parse.urlsplit(SITE_URL).path
|
||||
page = page.removesuffix(".md")
|
||||
page = "" if page == "index" else page.removesuffix("/index") + "/"
|
||||
return f"{base}{page}" + (f"#{anchor}" if anchor else "")
|
||||
|
||||
|
||||
def system_index_items(db: dict, coverages: dict, profiles: dict) -> list[dict]:
|
||||
"""Each system id the platforms and profiles write, reachable by that id.
|
||||
|
||||
api/v1/systems/ is keyed by manufacturer and its consoles by display
|
||||
name: a frontend keying by system id (sony-playstation) had no way in.
|
||||
The url is the section system_section gives, the one every page links.
|
||||
"""
|
||||
from siterender import system_ids, system_section
|
||||
|
||||
sha1_by_path = _sha1_by_path(db)
|
||||
files = db.get("files") or {}
|
||||
by_md5 = (db.get("indexes") or {}).get("by_md5") or {}
|
||||
held: dict[str, set[str]] = {}
|
||||
for coverage in coverages.values():
|
||||
for detail in coverage.get("details") or []:
|
||||
path = str(detail.get("path") or "")
|
||||
sha1 = sha1_by_path.get(path) if detail.get("status") != "missing" else None
|
||||
if sha1:
|
||||
held.setdefault(str(detail.get("system", "")), set()).add(sha1)
|
||||
for profile in profiles.values():
|
||||
if profile.get("type") in ("alias", "test"):
|
||||
continue
|
||||
own = [str(s) for s in profile.get("systems") or []]
|
||||
for entry in profile.get("files") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
ids = ([str(entry["system"])] if entry.get("system")
|
||||
else [str(s) for s in entry.get("systems") or []] or
|
||||
(own if len(own) == 1 else []))
|
||||
found = {v for v in _hash_values(entry.get("sha1")) if v in files}
|
||||
for md5 in _hash_values(entry.get("md5")):
|
||||
match = by_md5.get(md5)
|
||||
found.update([match] if isinstance(match, str) else match or [])
|
||||
for sys_id in ids:
|
||||
held.setdefault(sys_id, set()).update(found)
|
||||
manufacturers = {_slug(mfr): mfr for mfr in _group_by_manufacturer(db)}
|
||||
items = []
|
||||
for sys_id in sorted(system_ids(profiles, [c["config"] for c in coverages.values()])):
|
||||
section = system_section(sys_id)
|
||||
try:
|
||||
name = system_name(sys_id)
|
||||
except KeyError:
|
||||
name = sys_id
|
||||
items.append({
|
||||
"id": sys_id,
|
||||
"name": name,
|
||||
"manufacturer": manufacturers.get(section[0], "") if section else "",
|
||||
"url": _site_path(console_href(*section)) if section else None,
|
||||
"sha1": sorted(held.get(sys_id, set()) & files.keys()),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def core_index_items(profiles: dict) -> list[dict]:
|
||||
"""Every name a core goes by, with the profile it designates.
|
||||
|
||||
A frontend knows mednafen_psx_libretro, and api/v1/emulators/ had only
|
||||
beetle_psx.json: the reverse index resolve_platform_cores reads, aliases
|
||||
included, said once. A name two profiles claim (pcsx2) lists both, the
|
||||
one a platform list designates first.
|
||||
"""
|
||||
from common import preferred_profile, upstream_profile_index
|
||||
|
||||
published = {name for name, p in profiles.items() if p.get("type") not in ("alias", "test")}
|
||||
index = upstream_profile_index(profiles)
|
||||
for name, profile in profiles.items():
|
||||
target = str(profile.get("alias_of") or "")
|
||||
if profile.get("type") == "alias" and target in published:
|
||||
index.setdefault(name, set()).add(target)
|
||||
items = []
|
||||
for name in sorted(index):
|
||||
claimants = sorted(index[name] & published)
|
||||
preferred = preferred_profile({name: set(claimants)}, name)
|
||||
for profile in sorted(claimants, key=lambda p: (p != preferred, p)):
|
||||
items.append({"name": name, "profile": profile, "preferred": profile == preferred})
|
||||
return items
|
||||
|
||||
|
||||
def _is_manifest(item: dict) -> bool:
|
||||
return item["url"].startswith("api/v1/manifests/")
|
||||
|
||||
|
||||
def _schema_field_rows(schema: dict, keys: tuple[str, ...]) -> list[str]:
|
||||
"""One table row per field, its description read from the schema, so the
|
||||
page and the published contract say the same thing."""
|
||||
props = schema.get("properties") or {}
|
||||
return [
|
||||
f"| `{key}` | {_escape_tags(str(props[key]['description']))} |"
|
||||
for key in keys if (props.get(key) or {}).get("description")
|
||||
]
|
||||
|
||||
|
||||
# The manifest fields an integrator reads first, in the order they matter.
|
||||
MANIFEST_TOP_FIELDS = ("platform", "version", "base_destination", "files",
|
||||
"omitted_files", "pack_files", "core_names", "system_cores")
|
||||
MANIFEST_FILE_FIELDS = ("dest", "sha1", "size", "repo_path", "release_asset", "cores",
|
||||
"systems", "category", "alternatives")
|
||||
|
||||
|
||||
def _short_sha256(item: dict, catalog: str) -> str:
|
||||
"""A digest cell that fits its column: 12 characters linking the catalog
|
||||
that holds it, the whole value in the title. 64 characters pushed the
|
||||
table to 932 px in a 720 px column."""
|
||||
value = str(item["sha256"])
|
||||
return f'<a href="{catalog}"><code title="{value}">{value[:12]}</code></a>'
|
||||
|
||||
|
||||
def generate_data_page(exports: list[dict]) -> str:
|
||||
manifest_schema = json.loads(
|
||||
(_REPO_ROOT / "schemas" / "install-manifest.schema.json").read_text(encoding="utf-8"))
|
||||
snippet = (_REPO_ROOT / "scripts" / "check_platform_folder.py").read_text(encoding="utf-8")
|
||||
manifests = [item for item in exports if _is_manifest(item)]
|
||||
lines = [
|
||||
"# Data and API",
|
||||
"",
|
||||
@@ -1511,20 +1739,21 @@ def generate_data_page(exports: list[dict]) -> str:
|
||||
markup.button("Collection DAT", "downloads/retrobios.dat"),
|
||||
]),
|
||||
"",
|
||||
"The DAT is Logiqx XML, the format clrmamepro and RomVault read: one game "
|
||||
"per `bios/<Manufacturer>/<Console>/` folder of the collection, named "
|
||||
"`Manufacturer\\Console` so the tools look in that subfolder, each file a "
|
||||
"rom named by its path below that folder. It audits a copy of the "
|
||||
"collection's `bios/` tree, or rebuilds that layout from your own dumps. A "
|
||||
"platform pack has its own layout and is checked with `install.py --check`.",
|
||||
f"**Licence:** the metadata is offered under the MIT terms of [LICENSE]({REPO_URL}/blob/main/LICENSE), "
|
||||
"which cover the scripts, tooling, schemas, documentation and metadata. "
|
||||
f"They never cover a BIOS or firmware file ([NOTICE]({REPO_URL}/blob/main/NOTICE)). "
|
||||
"`catalog.json` and the SQLite `metadata` table carry the same `license`.",
|
||||
"",
|
||||
"## Stable JSON endpoints",
|
||||
"",
|
||||
"Start at [`api/v1/index.json`](api/v1/index.json): it names the catalog, "
|
||||
"the two id indexes and the envelope schema.",
|
||||
"",
|
||||
"| Dataset | Endpoint | Schema | Size |",
|
||||
"|---------|----------|--------|-----:|",
|
||||
]
|
||||
for item in exports:
|
||||
if item["media_type"] != "application/json":
|
||||
if item["media_type"] != "application/json" or _is_manifest(item):
|
||||
continue
|
||||
schema = (
|
||||
f"[JSON Schema](api/v1/{item['schema']})"
|
||||
@@ -1536,17 +1765,28 @@ def generate_data_page(exports: list[dict]) -> str:
|
||||
)
|
||||
lines.extend([
|
||||
"",
|
||||
"Every JSON document carries `schema_version`, and `commit`, the revision "
|
||||
"Every envelope carries `schema_version`, `kind` and `commit`, the revision "
|
||||
"of this repository the data was read at. Breaking changes use a new "
|
||||
"URL prefix (`/api/v2/`), and fields may only be added compatibly within v1.",
|
||||
"URL prefix (`/api/v2/`), and fields may only be added compatibly within v1. "
|
||||
"Each schema's `$id` is the address it is served at.",
|
||||
"",
|
||||
"One emulator profile without the whole list: "
|
||||
"`api/v1/emulators/<id>.json`, the envelope of `emulators.json` holding "
|
||||
"that one item, its `profile` valid against "
|
||||
"[`emulator.schema.json`](api/v1/schemas/emulator.schema.json). Each "
|
||||
"emulator page links its own. `api/v1/platforms/<id>.json` holds one "
|
||||
"platform of `platforms.json`, and `api/v1/systems/<slug>.json` the files "
|
||||
"of one system page by console. Each platform and system page links its own.",
|
||||
"platform of `platforms.json`, its `contract` valid against "
|
||||
"[`platform.schema.json`](api/v1/schemas/platform.schema.json) and its "
|
||||
"`registry` the install and scraper settings beside the list. "
|
||||
"`api/v1/systems/<slug>.json` holds the files of one system page by console. "
|
||||
"Each platform and system page links its own.",
|
||||
"",
|
||||
"By id: [`systems.json`](api/v1/systems.json) gives each system id a platform "
|
||||
"or a profile writes (`sony-playstation`) with its name, its manufacturer, the "
|
||||
"`url` of the section holding its files and their SHA1. "
|
||||
"[`cores.json`](api/v1/cores.json) gives each core name the buildbot or a "
|
||||
"platform uses (`mednafen_psx`) the profile it designates (`beetle_psx`), "
|
||||
"whose document is `api/v1/emulators/<profile>.json`.",
|
||||
"",
|
||||
"One hash without the whole list: `api/v1/lookup/<xx>.json`, where `<xx>` "
|
||||
"is the first two characters of the hash in lowercase, holds every SHA1, "
|
||||
@@ -1555,6 +1795,65 @@ def generate_data_page(exports: list[dict]) -> str:
|
||||
"profile), the file `name` and the `url` of the page that shows it. "
|
||||
"[Find a file](files/index.md) reads it.",
|
||||
"",
|
||||
"Two kinds of `url`:",
|
||||
"",
|
||||
"- In `catalog.json` and `index.json`, a `url` is relative to the document "
|
||||
"holding it, as RFC 3986 resolves it.",
|
||||
"- In the lookup shards, `file-locations.json` and `systems.json`, a `url` "
|
||||
f"is site-absolute (`/retrobios/systems/sony/#...`). Prefix "
|
||||
f"`{SITE_URL.removesuffix(urllib.parse.urlsplit(SITE_URL).path)}` for a full address.",
|
||||
"",
|
||||
"## Platform manifests",
|
||||
"",
|
||||
"A platform's list is what the setup checks. Its manifest is what the pack "
|
||||
"installs: the list's files plus every file the setup's cores load. "
|
||||
"`api/v1/manifests/<platform>.json` is the file `install.py` reads, byte "
|
||||
"for byte, valid against "
|
||||
"[`install-manifest.schema.json`](api/v1/schemas/install-manifest.schema.json). "
|
||||
"[`api/v1/release.json`](api/v1/release.json) records the packs of the latest "
|
||||
"release and the files each one holds.",
|
||||
"",
|
||||
"Manifests: " + ", ".join(
|
||||
f"[{item['url'].rsplit('/', 1)[-1].removesuffix('.json')}]({item['url']})"
|
||||
for item in manifests) + "." if manifests else "",
|
||||
"",
|
||||
"| Field | Meaning |",
|
||||
"|-------|---------|",
|
||||
*_schema_field_rows(manifest_schema, MANIFEST_TOP_FIELDS),
|
||||
"",
|
||||
"Each entry of `files`:",
|
||||
"",
|
||||
"| Field | Meaning |",
|
||||
"|-------|---------|",
|
||||
*_schema_field_rows(manifest_schema["$defs"]["file"], MANIFEST_FILE_FIELDS),
|
||||
"",
|
||||
"## Check a folder for one platform",
|
||||
"",
|
||||
"The job most tools come for: is this BIOS folder what the platform checks? "
|
||||
"Read `api/v1/platforms/<id>.json` and apply the platform's own rules:",
|
||||
"",
|
||||
"- `verification_mode: existence`: a file at its `destination` passes.",
|
||||
"- An entry with no hash in the mode's field is checked on presence too.",
|
||||
"- `zipped_file` set: the md5 is that of this member inside the ZIP, never "
|
||||
"the ZIP's. Member names compare casefolded. A ZIP declared once per member "
|
||||
"appears once per member: Batocera lists 692 entries for 353 destinations.",
|
||||
"- An md5 shorter than 32 characters (Batocera) compares by its prefix. "
|
||||
"Recalbox writes several accepted md5 in one field, comma separated.",
|
||||
"- `zip_digest: composite` (Recalbox): the md5 of a ZIP is that of its members "
|
||||
"read in name order, never the archive's.",
|
||||
"- `hash_rule: size_and_any_hash` (RomM) wants the size and any one hash. "
|
||||
"The script below does not apply it.",
|
||||
"",
|
||||
"```python",
|
||||
snippet.rstrip("\n"),
|
||||
"```",
|
||||
"",
|
||||
"The script is [`scripts/check_platform_folder.py`]"
|
||||
f"({REPO_URL}/blob/main/scripts/check_platform_folder.py). A test runs it "
|
||||
"against the verifier on a sample folder, so the two give the same answer. "
|
||||
"`install.py --check` answers another question: whether the folder holds "
|
||||
"what the pack installs, the manifest above.",
|
||||
"",
|
||||
"## Bulk downloads",
|
||||
"",
|
||||
"| Export | Format | Size | SHA256 |",
|
||||
@@ -1565,9 +1864,19 @@ def generate_data_page(exports: list[dict]) -> str:
|
||||
continue
|
||||
lines.append(
|
||||
f"| [{item['title']}]({item['url']}) | `{item['media_type']}` | "
|
||||
f"{_fmt_size(item['bytes'])} | `{item['sha256']}` |"
|
||||
f"{_fmt_size(item['bytes'])} | {_short_sha256(item, 'api/v1/catalog.json')} |"
|
||||
)
|
||||
lines.extend([
|
||||
"",
|
||||
"Each SHA256 is cut to 12 characters. Point at it for the whole value, or "
|
||||
"read it in [`catalog.json`](api/v1/catalog.json).",
|
||||
"",
|
||||
"The DAT is Logiqx XML, the format clrmamepro and RomVault read: one game "
|
||||
"per `bios/<Manufacturer>/<Console>/` folder of the collection, named "
|
||||
"`Manufacturer\\Console` so the tools look in that subfolder, each file a "
|
||||
"rom named by its path below that folder. It audits a copy of the "
|
||||
"collection's `bios/` tree, or rebuilds that layout from your own dumps. A "
|
||||
"platform pack has its own layout and is checked with `install.py --check`.",
|
||||
"",
|
||||
"The SQLite file contains indexed tables for content hashes, provenance "
|
||||
"catalog matches, platform declarations, emulator profiles and current "
|
||||
@@ -1601,6 +1910,25 @@ def generate_data_page(exports: list[dict]) -> str:
|
||||
"`provenance-wanted.csv` is the Wanted list of [Dump provenance]"
|
||||
"(provenance.md): each dump a catalog describes and the collection lacks.",
|
||||
"",
|
||||
"`platform-files.csv` and the `platform_files` table also carry the entry "
|
||||
"fields a check needs:",
|
||||
"",
|
||||
"- `zipped_file` set: the md5 is the md5 of that member inside the ZIP.",
|
||||
"- `zip_digest` is `composite` where the md5 of a ZIP covers its members.",
|
||||
"- `hash_match_mandatory` and `mandatory_declared` are Recalbox's flags.",
|
||||
"- `curated`, `shared_group` and `mirror_of` mark an entry the platform's "
|
||||
"own file does not carry.",
|
||||
"",
|
||||
"## Hosting and limits",
|
||||
"",
|
||||
"- Static files on GitHub Pages, read with a plain GET. No key, no account.",
|
||||
"- Any origin may read them (`Access-Control-Allow-Origin: *`), so a "
|
||||
"browser tool can call them directly.",
|
||||
"- Responses are cached for 10 minutes (`Cache-Control: max-age=600`). "
|
||||
"Send the `ETag` back in `If-None-Match` to skip an unchanged download.",
|
||||
"- The base is the project's address, "
|
||||
f"`{SITE_URL}api/v1/`. Compare `commit` to know whether two downloads differ.",
|
||||
"",
|
||||
"## Semantics and limits",
|
||||
"",
|
||||
"Treat four questions separately: content identity (hashes), presence in "
|
||||
@@ -2365,19 +2693,30 @@ def _blocks_in_runs(blocks: list[list[str]], budget: int) -> list[list[str]]:
|
||||
return runs
|
||||
|
||||
|
||||
def _parent_front_matter(parent: tuple[str, str] | None) -> list[str]:
|
||||
"""The `parent:` front matter of a part page: the page it belongs to,
|
||||
by label and site path, which the breadcrumb and sidebar read."""
|
||||
if parent is None:
|
||||
return []
|
||||
label, link = parent
|
||||
return ["parent:", f" label: {json.dumps(label)}", f" link: {json.dumps(link)}"]
|
||||
|
||||
|
||||
def _list_pages(
|
||||
stem: str, title: str, intro: str, head: list[str], blocks: list[list[str]],
|
||||
moved: dict[str, str], budget: int = LIST_PART_BUDGET,
|
||||
parent: tuple[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Pages of their own for a long list: ``head`` (a table header, say)
|
||||
opens each part, ``blocks`` are cut between parts. Returns where the list
|
||||
went, as the page it left says it."""
|
||||
went, as the page it left says it. ``parent`` (label, site path) is the
|
||||
page the parts belong to, for the breadcrumb."""
|
||||
runs = _blocks_in_runs(blocks, budget)
|
||||
stems = [stem + (f"-{n}" if n > 1 else "") for n in range(1, len(runs) + 1)]
|
||||
for n, (here, run) in enumerate(zip(stems, runs), 1):
|
||||
part = f" (part {n} of {len(runs)})" if len(runs) > 1 else ""
|
||||
moved[here] = "\n".join([
|
||||
"---", "level: reference", "---", "",
|
||||
"---", "level: reference", *_parent_front_matter(parent), "---", "",
|
||||
f"# {title}{part}", "", intro, "",
|
||||
*([", ".join(f"[part {i}]({other}.md)" if other != here else f"part {i}"
|
||||
for i, other in enumerate(stems, 1)), ""] if len(runs) > 1 else []),
|
||||
@@ -2404,6 +2743,11 @@ def _system_heading_link(sys_id: str, prefix: str = "") -> str:
|
||||
writes. A system with no section of its own is named, never sent to the
|
||||
page a storage folder chose.
|
||||
"""
|
||||
return f"{_system_named_link(sys_id, prefix)} <small>{_escape_tags(sys_id)}</small>"
|
||||
|
||||
|
||||
def _system_named_link(sys_id: str, prefix: str = "") -> str:
|
||||
"""A system's name, linked to the section system_section gives."""
|
||||
from siterender import system_section
|
||||
|
||||
name = _escape_tags(system_name(sys_id))
|
||||
@@ -2411,7 +2755,17 @@ def _system_heading_link(sys_id: str, prefix: str = "") -> str:
|
||||
if section is not None:
|
||||
slug, console = section
|
||||
name = f"[{name}]({console_href(slug, console, prefix)})"
|
||||
return f"{name} <small>{_escape_tags(sys_id)}</small>"
|
||||
return name
|
||||
|
||||
|
||||
def _emulator_named_link(key: str, prefix: str = "") -> str:
|
||||
"""An emulator by the name its page carries, linked to that page. A
|
||||
profile with no page (an alias, a test) is named only."""
|
||||
profile = load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False).get(key, {})
|
||||
label = _escape_tags(str(profile.get("emulator") or key))
|
||||
if profile.get("type") in ("alias", "test"):
|
||||
return label
|
||||
return f"[{label}]({prefix}emulators/{key}.md)"
|
||||
|
||||
|
||||
def _served_line(f: dict) -> str:
|
||||
@@ -2493,7 +2847,7 @@ def _render_platform_systems(
|
||||
anchor = sys_id.replace(" ", "-")
|
||||
body = [f"{ok_count}/{total} files verified"]
|
||||
if sys_emus:
|
||||
emu_links = ", ".join(_emulator_link(e, "../") for e in sorted(sys_emus))
|
||||
emu_links = ", ".join(_emulator_named_link(e, "../") for e in sorted(sys_emus))
|
||||
body[-1] += "\\"
|
||||
body.append(f"Emulators: {emu_links}")
|
||||
body.append("")
|
||||
@@ -2620,8 +2974,12 @@ def _render_platform_systems(
|
||||
for n in range(1, len(runs) + 1)]
|
||||
for n, (stem, run) in enumerate(zip(stems, runs), 1):
|
||||
part = f" (part {n} of {len(runs)})" if len(runs) > 1 else ""
|
||||
# A console query landed here with no way to the console's
|
||||
# own page: the list says whose it is first, linked.
|
||||
moved[stem] = "\n".join([
|
||||
"---", *_parent_front_matter((display, f"/platforms/{key}/")), "---", "",
|
||||
f"# {display}: {system_name(sys_id)}{part}", "",
|
||||
f"{display}'s list for {_system_named_link(sys_id, '../')}. "
|
||||
f"Part of the [{display} page]({key}.md#{anchor}), whose other systems "
|
||||
f"are listed there. Each file below is as {display} declares it, with "
|
||||
"its status in the pack.",
|
||||
@@ -3121,10 +3479,12 @@ def generate_platform_page(
|
||||
f'{non_ok} issue{"s" if non_ok > 1 else ""}</span>'
|
||||
)
|
||||
sys_emus = _system_emulators(sys_id, emulator_files, by_system_declared.get(sys_id))
|
||||
emu_str = ", ".join(sys_emus[:3])
|
||||
if len(sys_emus) > 3:
|
||||
emu_str += f" +{len(sys_emus) - 3}"
|
||||
anchor = sys_id.replace(" ", "-")
|
||||
# Named and linked as the detail blocks below do: the column held
|
||||
# profile ids, none of 1,463 linked.
|
||||
emu_str = ", ".join(_emulator_named_link(e, "../") for e in sorted(sys_emus)[:3])
|
||||
if len(sys_emus) > 3:
|
||||
emu_str += f" [+{len(sys_emus) - 3}](#{anchor})"
|
||||
# The name a reader filters on, the id the list writes beside it.
|
||||
lines.append(
|
||||
f"| [{_escape_tags(system_name(sys_id))}](#{anchor}) <small>{sys_id}</small> "
|
||||
@@ -3257,7 +3617,9 @@ def file_location_items(
|
||||
A card lives on its manufacturer's page, and a file refiled between
|
||||
manufacturers (mt32_control.rom, Commodore to Roland) moved its link
|
||||
with it. files/?sha1= reads this list and goes to the card wherever it
|
||||
is now: the address cited depends on the content only.
|
||||
is now: the address cited depends on the content only. The url is
|
||||
site-absolute (/retrobios/systems/...): written from the site root, it
|
||||
resolved against the document holding it to a page that does not exist.
|
||||
"""
|
||||
items = []
|
||||
for sha1, entry in sorted(db.get("files", {}).items()):
|
||||
@@ -3265,7 +3627,7 @@ def file_location_items(
|
||||
if len(parts) < 3 or parts[0] != "bios":
|
||||
continue
|
||||
page = card_page(entry.get("path", ""), sha1)
|
||||
items.append({"sha1": sha1, "url": f"systems/{page}/#sha1-{sha1[:12]}"})
|
||||
items.append({"sha1": sha1, "url": _site_path(f"systems/{page}.md#sha1-{sha1[:12]}")})
|
||||
# A data directory's files are listed on the page of an emulator reading
|
||||
# the tree: 2,534 hashes the packs ship were found nowhere on the site.
|
||||
owners = _data_member_owners(profiles or {})
|
||||
@@ -3281,7 +3643,8 @@ def file_location_items(
|
||||
if sha1 and sha1 not in known:
|
||||
known.add(sha1)
|
||||
page = page_of.get(member, owners[ref])
|
||||
items.append({"sha1": sha1, "url": f"emulators/{page}/#sha1-{sha1[:12]}"})
|
||||
items.append({"sha1": sha1,
|
||||
"url": _site_path(f"emulators/{page}.md#sha1-{sha1[:12]}")})
|
||||
return sorted(items, key=lambda item: item["sha1"])
|
||||
|
||||
|
||||
@@ -3325,7 +3688,7 @@ def file_lookup_records(
|
||||
})
|
||||
for key, coverage in sorted(coverages.items()):
|
||||
for system_id, system in sorted((coverage["config"].get("systems") or {}).items()):
|
||||
url = f"platforms/{key}/#{system_id.replace(' ', '-')}"
|
||||
url = _site_path(f"platforms/{key}.md#{system_id.replace(' ', '-')}")
|
||||
for entry in system.get("files") or []:
|
||||
for algorithm in HASH_ALGORITHMS:
|
||||
for value in _hash_values(entry.get(algorithm)):
|
||||
@@ -3338,7 +3701,7 @@ def file_lookup_records(
|
||||
continue
|
||||
entries = profile.get("files") or []
|
||||
for entry, anchor in zip(entries, emulator_file_anchors(entries)):
|
||||
url = f"emulators/{emulator_card_page(key, anchor)}/#{anchor}"
|
||||
url = _site_path(f"emulators/{emulator_card_page(key, anchor)}.md#{anchor}")
|
||||
members = [(str(entry.get("name", "")), entry)] + [
|
||||
(str(member["name"]), member) for member in entry.get("contents") or []
|
||||
if isinstance(member, dict) and member.get("name")
|
||||
@@ -3745,7 +4108,7 @@ def generate_provenance_page(
|
||||
f"provenance-{_slugify_anchor(source)}", f"Wanted from {label}",
|
||||
f"Dumps {label} catalogues and the collection lacks, by DAT. Part of "
|
||||
f"[Dump provenance](provenance.md#{_slugify_anchor(label)}).",
|
||||
[], blocks, moved,
|
||||
[], blocks, moved, parent=("Dump provenance", "/provenance/"),
|
||||
)
|
||||
lines.extend([
|
||||
f"{_count(len(data['missing']), 'dump')} in {_count(len(by_dat), 'DAT')}: "
|
||||
@@ -6092,12 +6455,27 @@ def _declared_hashes(config: dict, detail: dict) -> dict[str, list[str]]:
|
||||
return {}
|
||||
|
||||
|
||||
def short_hash(value: str) -> str:
|
||||
"""A hash in a table cell: its first 12 characters, the whole value in
|
||||
the title. Full MD5 and SHA1 values pushed the gaps tables past the
|
||||
column on every screen."""
|
||||
value = str(value)
|
||||
if len(value) <= 12:
|
||||
return _code(value)
|
||||
return f'<code title="{html.escape(value)}">{html.escape(value[:12])}</code>'
|
||||
|
||||
|
||||
def _hash_cell(hashes: dict[str, list[str]]) -> str:
|
||||
return "<br>".join(
|
||||
f"{algo} " + ", ".join(_code(v) for v in values) for algo, values in hashes.items()
|
||||
f"{algo} " + ", ".join(short_hash(v) for v in values) for algo, values in hashes.items()
|
||||
)
|
||||
|
||||
|
||||
def _reason_cell(text: str) -> str:
|
||||
"""A reason in a gaps table, in the class that keeps its column readable."""
|
||||
return f'<span class="rb-reason">{text}</span>' if text else "-"
|
||||
|
||||
|
||||
def _copy_cell(path: str, algos: list[str], db: dict | None) -> str:
|
||||
"""The file a path holds, with its hashes in the algorithms declared."""
|
||||
if not path:
|
||||
@@ -6208,7 +6586,7 @@ def _render_problem_files(
|
||||
plats = ", ".join(sorted(set(f["platforms"])))
|
||||
declared = f["declared"]
|
||||
lines.append(
|
||||
f"| {_code(f['name'])} | {plats} | {f['reason']} "
|
||||
f"| {_code(f['name'])} | {plats} | {_reason_cell(f['reason'])} "
|
||||
f"| {_hash_cell(declared) or '-'} "
|
||||
f"| {_copy_cell(f['path'], list(declared) or ['md5'], db)} |"
|
||||
)
|
||||
@@ -6230,7 +6608,7 @@ def _render_problem_files(
|
||||
plats = ", ".join(sorted(set(f["platforms"])))
|
||||
held = _held_accepted(f["discrepancy"], db)
|
||||
cell = f"[{_code(held)}]({_fetch_url(held)})" if held else "-"
|
||||
lines.append(f"| `{fname}` | {plats} | {f['discrepancy']} | {cell} |")
|
||||
lines.append(f"| `{fname}` | {plats} | {_reason_cell(f['discrepancy'])} | {cell} |")
|
||||
lines.append("")
|
||||
|
||||
if settled:
|
||||
@@ -6752,6 +7130,7 @@ def generate_gap_analysis(
|
||||
"the collection does not hold. Part of "
|
||||
"[What the packs do not hold](gaps.md#files-to-source).",
|
||||
head, [[row] for row in rows], moved,
|
||||
parent=("What the packs do not hold", "/gaps/"),
|
||||
)
|
||||
lines.extend([f"Listed on {where}.", ""])
|
||||
if bundled_rows:
|
||||
@@ -6953,7 +7332,8 @@ def _render_xref_by_platform(
|
||||
)
|
||||
if moved is not None:
|
||||
moved[stem] = "\n".join([
|
||||
"---", "level: reference", "---", "",
|
||||
"---", "level: reference",
|
||||
*_parent_front_matter(("Cores by platform", "/cross-reference/")), "---", "",
|
||||
f"# Cores on {display}",
|
||||
"",
|
||||
f"{_count(count, 'core')}{_core_scope_note(config, unique)} that "
|
||||
@@ -7869,7 +8249,7 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
write_single_item(
|
||||
public / "api" / "v1" / "systems" / f"{slug}.json", "systems",
|
||||
system_export_item(slug, mfr, consoles),
|
||||
str(db.get("generated_at") or _timestamp()),
|
||||
str(db.get("generated_at") or _timestamp()), _source_commit(),
|
||||
)
|
||||
clash = split_system_stems() & {_slug(m) for m in manufacturers}
|
||||
if clash:
|
||||
@@ -7936,18 +8316,11 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
# The envelope and item of emulators.json, one item long: the
|
||||
# profile validates against the published schema, which an "id"
|
||||
# beside its fields broke on all 530, and schema_version is there.
|
||||
write_if_changed(
|
||||
str(public / "api" / "v1" / "emulators" / f"{name}.json"),
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": str(db.get("generated_at") or _timestamp()),
|
||||
"kind": "emulators",
|
||||
"count": 1,
|
||||
"items": [{"id": name, "profile": profile}],
|
||||
},
|
||||
indent=2, sort_keys=True, ensure_ascii=False, default=str,
|
||||
) + "\n",
|
||||
# The commit too, as every other envelope: 625 files lacked it.
|
||||
write_single_item(
|
||||
public / "api" / "v1" / "emulators" / f"{name}.json", "emulators",
|
||||
{"id": name, "profile": profile},
|
||||
str(db.get("generated_at") or _timestamp()), _source_commit(),
|
||||
)
|
||||
pages = emulator_pages(
|
||||
name, profile, db, consumers, suppl_names, data_registry,
|
||||
|
||||
@@ -685,14 +685,14 @@ class AHashFindsEveryPageThatNamesIt(unittest.TestCase):
|
||||
records = generate_site.file_lookup_records(db, coverages, profiles, locations, members)
|
||||
found = {(r["layer"], r.get("owner", ""), r["algorithm"], r["value"]): r for r in records}
|
||||
self.assertEqual(found[("collection", "", "md5", md5)]["url"],
|
||||
"systems/sony/#sha1-" + "ab" * 6)
|
||||
"/retrobios/systems/sony/#sha1-" + "ab" * 6)
|
||||
self.assertIn(("collection", "", "crc32", "8d8cb7e4"), found)
|
||||
self.assertEqual(found[("data-directory", "", "sha1", "cd" * 20)]["name"], "font.bin")
|
||||
self.assertEqual(found[("platform", "batocera", "md5", md5)]["url"],
|
||||
"platforms/batocera/#sony-playstation")
|
||||
"/retrobios/platforms/batocera/#sony-playstation")
|
||||
self.assertIn(("platform", "batocera", "md5", "0123456789abcdef0123456789abcdef"), found)
|
||||
self.assertEqual(found[("emulator", "duckstation", "md5", md5)]["url"],
|
||||
"emulators/duckstation/#file-scph5501-bin-490f666e")
|
||||
"/retrobios/emulators/duckstation/#file-scph5501-bin-490f666e")
|
||||
self.assertEqual(found[("emulator", "duckstation", "crc32", "11223344")]["name"], "m.rom")
|
||||
self.assertNotIn(("emulator", "an-alias", "md5", md5), found)
|
||||
|
||||
@@ -724,7 +724,7 @@ class AFileHasAnAddressByContent(unittest.TestCase):
|
||||
}}
|
||||
self.assertEqual(
|
||||
generate_site.file_location_items(db),
|
||||
[{"sha1": "ab" * 20, "url": "systems/roland/#sha1-" + "ab" * 6}],
|
||||
[{"sha1": "ab" * 20, "url": "/retrobios/systems/roland/#sha1-" + "ab" * 6}],
|
||||
)
|
||||
self.assertIn('id="rb-file-lookup-status"', generate_site.generate_files_page())
|
||||
script = (Path(__file__).resolve().parents[1] / "website" / "src" / "scripts"
|
||||
@@ -865,7 +865,8 @@ class ADataDirectoryFileCanBeLookedUp(unittest.TestCase):
|
||||
members = data_directory_members(Path(tmp))
|
||||
items = file_location_items(
|
||||
{"files": {}}, {"dolphin": {"data_directories": [{"ref": "dolphin-sys"}]}}, members)
|
||||
self.assertEqual(items, [{"sha1": "c" * 40, "url": "emulators/dolphin/#sha1-cccccccccccc"}])
|
||||
self.assertEqual(items, [{"sha1": "c" * 40,
|
||||
"url": "/retrobios/emulators/dolphin/#sha1-cccccccccccc"}])
|
||||
|
||||
|
||||
class ACardSaysWhyAFileIsLeftOut(unittest.TestCase):
|
||||
|
||||
@@ -69,8 +69,9 @@ class AnUntestedRowNamesBothHashes(unittest.TestCase):
|
||||
db = {"files": {"f" * 40: {"path": path, "sha256": held}}}
|
||||
table = "\n".join(gs._render_problem_files([problem], db=db))
|
||||
row = next(line for line in table.splitlines() if "dsifirmware" in line)
|
||||
self.assertIn(f"sha256 `{declared}`", row)
|
||||
self.assertIn(f"sha256 `{held}`", row)
|
||||
# Cut to 12 characters in the cell, the whole value in its title.
|
||||
self.assertIn(f'sha256 <code title="{declared}">{declared[:12]}</code>', row)
|
||||
self.assertIn(f'sha256 <code title="{held}">{held[:12]}</code>', row)
|
||||
self.assertIn(f"`{path}`", row)
|
||||
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ class TheRules(unittest.TestCase):
|
||||
{"files": {}}, {"dolphin": {"data_directories": [{"ref": "dolphin-sys"}]}},
|
||||
{"dolphin-sys": members})
|
||||
last = f"{generate_site.DATA_TREE_ROWS_PER_PAGE:040x}"
|
||||
self.assertIn({"sha1": last, "url": f"emulators/data-dolphin-sys-2/#sha1-{last[:12]}"}, items)
|
||||
self.assertIn({"sha1": last, "url": f"/retrobios/emulators/data-dolphin-sys-2/#sha1-{last[:12]}"}, items)
|
||||
|
||||
def test_no_float_rule_for_right_aligned_images(self):
|
||||
css = (REPO_ROOT / "website" / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
"""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):
|
||||
from jsonschema import Draft202012Validator # noqa: PLC0415
|
||||
|
||||
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):
|
||||
from jsonschema import Draft202012Validator # noqa: PLC0415
|
||||
|
||||
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")
|
||||
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 and limits", 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_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(1200)]
|
||||
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)
|
||||
|
||||
|
||||
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] | None) -> 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_without_a_cached_tree_the_declarations_decide(self):
|
||||
text = self._render(None)
|
||||
self.assertIn("3dsTrident/blob/" + "b" * 40 + "/src/core/loader/ncch.cpp", 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()
|
||||
@@ -224,7 +224,7 @@ class AnAliasTreeIsListedOnItsParentsPage(unittest.TestCase):
|
||||
members = {"freeintv-overlays": {"a.png": {"sha1": "02" * 20}}}
|
||||
items = generate_site.file_location_items({"files": {}}, self.PROFILES, members)
|
||||
self.assertEqual(items, [{"sha1": "02" * 20,
|
||||
"url": "emulators/freeintv/#sha1-020202020202"}])
|
||||
"url": "/retrobios/emulators/freeintv/#sha1-020202020202"}])
|
||||
|
||||
def test_the_parent_page_lists_the_tree(self):
|
||||
import generate_site
|
||||
|
||||
@@ -54,7 +54,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection` |
|
||||
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
|
||||
Reference in new issue
Block a user