diff --git a/schemas/site-api-envelope.schema.json b/schemas/site-api-envelope.schema.json index 63f7b5ca..af496236 100644 --- a/schemas/site-api-envelope.schema.json +++ b/schemas/site-api-envelope.schema.json @@ -166,7 +166,7 @@ }, "registry": { "type": "object", - "description": "The platform's registry entry beside its list: install detection, logo, scraper and schedule. Not part of the list." + "description": "The platform's registry entry beside its list: install detection, logo, scraper and schedule. Not part of the list. release_pack is the platform's own entry, never its parent's: true when a release publishes its ZIP." } } } @@ -411,9 +411,10 @@ "sha1", "md5", "crc32", - "sha256" + "sha256", + "name" ], - "description": "On a copy of a platform or emulator record filed under another hash of its file: the algorithm of the value its owner wrote." + "description": "On a platform or emulator record: the algorithm of the value its owner wrote, filed under that value and under the file's other hashes. name: the emulator reads the file by its name and declares no hash that matches it." }, "owner": { "type": "string" @@ -501,6 +502,13 @@ ], "description": "Site-absolute path of the console section holding the id's files, null when no section holds them." }, + "api": { + "type": [ + "string", + "null" + ], + "description": "Site-absolute path of the JSON document holding the id's files, api/v1/systems/.json, keyed by the manufacturer page. Null when no page holds them." + }, "sha1": { "type": "array", "items": { diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 2d5a7b57..031e34a1 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -44,6 +44,7 @@ from common import ( compute_composition, count_catalog_matched, list_registered_platforms, + list_release_pack_platforms, load_platform_registry, load_database, load_emulator_profiles, @@ -964,9 +965,21 @@ def _split_contract(config: dict) -> tuple[dict, dict]: 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"} + # The merged config gives a child its parent's release_pack: Lakka and + # RetroPie said true in the API while every page said they had no ZIP. + # The registry's own entry decides, as it does for the pages. + released = config.get("_key") in _release_pack_platforms() + if released or "release_pack" in registry: + registry["release_pack"] = released return contract, registry +@functools.lru_cache(maxsize=1) +def _release_pack_platforms() -> frozenset[str]: + return frozenset(list_release_pack_platforms( + str(_REPO_ROOT / "platforms"), include_archived=True)) + + # 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. @@ -1490,6 +1503,7 @@ def generate_data_exports( profiles: dict, stats: dict, gap_report: dict | None = None, + consumers: FileConsumers | None = None, ) -> list[dict]: """Create versioned static API, CSV and SQLite metadata snapshots.""" api = docs / "api" / "v1" @@ -1517,8 +1531,8 @@ def generate_data_exports( "file-locations.json": ("file-locations", locations), } write_lookup_shards( - api, file_lookup_records(db, coverages, profiles, locations, members), generated_at, - commit, + api, file_lookup_records(db, coverages, profiles, locations, members, consumers), + generated_at, commit, ) for filename, (kind, items) in envelopes.items(): document = { @@ -1714,6 +1728,21 @@ def generate_data_exports( return catalog_items +def write_release_advertised(generated: Path) -> None: + """The release record as the pages read it, for the file picker. + + The picker imported release.json whole and offered the ZIP of six setups + the registry no longer releases, while their pages said "No ZIP". It + reads this file instead: the platforms marked release_pack only. + """ + generated.mkdir(parents=True, exist_ok=True) + write_if_changed( + str(generated / "release-advertised.json"), + json.dumps(release_record.load_advertised(), ensure_ascii=False, indent=1, + sort_keys=True) + "\n", + ) + + def _publish_manifests(api: Path) -> list[tuple]: """The install manifests and the release record, beside the API. @@ -1732,6 +1761,15 @@ def _publish_manifests(api: Path) -> list[tuple]: _record(target) assets.append((target, f"Install manifest: {source.stem}", "application/json", "schemas/install-manifest.schema.json")) + # The target lists install.py --target reads: no catalog entry, and + # api/v1/targets/ answered 404. + targets = api / "targets" + targets.mkdir(parents=True, exist_ok=True) + for source in sorted((_REPO_ROOT / "install" / "targets").glob("*.json")): + shutil.copy2(source, targets / source.name) + _record(targets / source.name) + assets.append((targets / source.name, f"Target list: {source.stem}", + "application/json", "schemas/target-manifest.schema.json")) release = _REPO_ROOT / "release.json" if release.is_file(): shutil.copy2(release, api / "release.json") @@ -1791,6 +1829,7 @@ def system_index_items(db: dict, coverages: dict, profiles: dict) -> list[dict]: for sys_id in ids: held.setdefault(sys_id, set()).update(found) manufacturers = {_slug(mfr): mfr for mfr in _group_by_manufacturer(db)} + base = urllib.parse.urlsplit(SITE_URL).path items = [] for sys_id in sorted(system_ids(profiles, [c["config"] for c in coverages.values()])): section = system_section(sys_id) @@ -1803,8 +1842,12 @@ def system_index_items(db: dict, coverages: dict, profiles: dict) -> list[dict]: url = _site_path(console_href(*section)) if section else None if url is None and sha1s: manufacturer, url = _first_file_place(sys_id, sha1s, files, profiles, card_page) + # The id's JSON document: api/v1/systems/ is keyed by the manufacturer + # page, and sony-playstation.json answered 404. + holder = section[0] if section else _slug(manufacturer) if manufacturer else "" + api = f"{base}api/v1/systems/{holder}.json" if holder else None items.append({"id": sys_id, "name": name, "manufacturer": manufacturer, - "url": url, "sha1": sha1s}) + "url": url, "api": api, "sha1": sha1s}) return items @@ -1855,6 +1898,19 @@ def _is_manifest(item: dict) -> bool: return item["url"].startswith("api/v1/manifests/") +def _is_target_list(item: dict) -> bool: + return item["url"].startswith("api/v1/targets/") + + +def _served_schemas() -> list[tuple[str, str]]: + """(file name, title) of every schema api/v1/schemas/ serves, from the folder + the export copies: the page linked 7 of 11.""" + return [ + (path.name, str(json.loads(path.read_text(encoding="utf-8")).get("title") or path.stem)) + for path in sorted((_REPO_ROOT / "schemas").glob("*.schema.json")) + ] + + # A URL template written in prose (https://...//): escaped, # the autolinker made a link of it, <ref> on screen and a 404 behind it. _URL_TEMPLATE = re.compile(r"(? list[str]: return ["| Field | Meaning |", "|-------|---------|", *_schema_field_rows(schema, rest)] +def _nested_fields(schema: dict, prefix: str = "") -> list[str]: + """A table for every object below *schema*, at any depth, and one line + per code where a field lists its codes' meanings (enumDescriptions). + + The top-level and `files` tables left 50 nested fields undescribed, all + of `omitted_files` among them: a tool could not say why a file was not + installed. + """ + lines: list[str] = [] + for key, spec in (schema.get("properties") or {}).items(): + path = f"{prefix}{key}" + codes = spec.get("enumDescriptions") + if codes: + lines.extend([f"Codes of `{path}`:", ""]) + lines.extend(f"- `{code}`: {text}" for code, text in zip(spec["enum"], codes)) + lines.append("") + for label, child, child_path in ( + ("Each entry of", spec.get("items"), f"{path}[]"), + ("Each value of", spec.get("additionalProperties"), f"{path}{{}}"), + ("Fields of", spec, path), + ): + if not isinstance(child, dict) or not child.get("properties"): + continue + lines.extend([ + f"{label} `{path}`:", "", + "| Field | Meaning |", "|-------|---------|", + *_schema_field_rows(child, tuple(child["properties"])), "", + ]) + lines.extend(_nested_fields(child, f"{child_path}.")) + return lines + + # 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") @@ -1918,10 +2006,12 @@ def generate_data_page(exports: list[dict]) -> str: (_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)] + targets = [item for item in exports if _is_target_list(item)] no_list = _platforms_without_a_list() lines = [ "---", - "terms: manifest, schema, check a folder, rate limit, api", + "terms: manifest, schema, json schema, license, check a folder, " + "check bios folder script, rate limit, api", "---", "", "# Data and API", @@ -1949,7 +2039,8 @@ def generate_data_page(exports: list[dict]) -> str: "|---------|----------|--------|-----:|", ] for item in exports: - if item["media_type"] != "application/json" or _is_manifest(item): + if (item["media_type"] != "application/json" or _is_manifest(item) + or _is_target_list(item)): continue schema = ( f"[JSON Schema](api/v1/{item['schema']})" @@ -1966,6 +2057,10 @@ def generate_data_page(exports: list[dict]) -> str: "URL prefix (`/api/v2/`), and fields may only be added compatibly within v1. " "Each schema's `$id` is the address it is served at.", "", + "Schemas:", + "", + *(f"- [`{name}`](api/v1/schemas/{name}): {title}" for name, title in _served_schemas()), + "", "`database.json`, `stats.json`, `release.json` and the manifests are not " "envelopes. Their revision is the `commit` of `catalog.json` that lists " "their SHA256.", @@ -1983,7 +2078,8 @@ def generate_data_page(exports: list[dict]) -> str: "", "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. " + "`url` of the section holding its files, the `api` document of that section " + "and the files' 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/.json`. A name two profiles " @@ -2041,6 +2137,16 @@ def generate_data_page(exports: list[dict]) -> str: f"[{item['url'].rsplit('/', 1)[-1].removesuffix('.json')}]({item['url']})" for item in manifests) + "." if manifests else "", "", + "`api/v1/targets/.json` gives each hardware target of a platform " + "the cores it runs, valid against " + "[`target-manifest.schema.json`](api/v1/schemas/target-manifest.schema.json). " + "`install.py --target` reads the same file. Like the manifests, they are " + "copied byte for byte and are not envelopes.", + "", + "Target lists: " + ", ".join( + f"[{item['url'].rsplit('/', 1)[-1].removesuffix('.json')}]({item['url']})" + for item in targets) + "." if targets else "", + "", "| Field | Meaning |", "|-------|---------|", *_schema_field_rows(manifest_schema, MANIFEST_TOP_FIELDS), @@ -2059,6 +2165,9 @@ def generate_data_page(exports: list[dict]) -> str: "Each entry of `files`:", "", *_other_fields(manifest_schema["$defs"]["file"], MANIFEST_FILE_FIELDS), + "", + *_nested_fields(manifest_schema), + *_nested_fields(manifest_schema["$defs"]["file"], "files[]."), ]), "## Check a folder for one platform", "", @@ -2088,8 +2197,12 @@ def generate_data_page(exports: list[dict]) -> str: "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.", + "It exits 1 when a file is wrong or missing. For an unknown platform it " + "prints the known ones.", + "", + "`install.py --check` answers another question by default: whether the folder " + "holds what the pack installs, the manifest above. With `--declared-only`, or " + "from the `declared` event, `install.py --check` answers it too.", "", "## Bulk downloads", "", @@ -4020,7 +4133,8 @@ def generate_files_page() -> str: """ return "\n".join([ "---", - "terms: check my dump, md5 check, verify bios file, check hash", + "terms: check my dump, md5 check, verify bios file, check hash, " + "is my bios correct, is my bios right", "---", "", "# Find or check a file", @@ -4194,6 +4308,7 @@ def file_lookup_records( profiles: dict, locations: list[dict], members: dict | None = None, + consumers: FileConsumers | None = None, ) -> list[dict]: """Every hash the site knows, one record per value, for files/?hash=. @@ -4262,11 +4377,51 @@ def file_lookup_records( **({"system_name": entry_label} if entry_label else {}), }) records += _held_under_every_hash(records, files, db.get("indexes") or {}) + for record in records: + if record["layer"] in ("platform", "emulator"): + record.setdefault("declared", record["algorithm"]) + if consumers is not None: + records += _read_by_name(records, files, consumers) unique = {json.dumps(r, sort_keys=True): r for r in records} return sorted(unique.values(), key=lambda r: ( r["value"], r["layer"], r.get("owner", ""), r["name"], r["url"])) +def _read_by_name(records: list[dict], files: dict, consumers: FileConsumers) -> list[dict]: + """The emulators the file's card names that no declared hash reached. + + The card counts every profile entry resolving to the file, by name + included: scph5501.bin named 11 emulators on its card and 9 in the + lookup, PCSX-ReARMed and PCSX1 reading it by name. Each is written under + every hash of the file, `declared` saying no hash was. + """ + reached = {(r["sha1"], r["owner"]) for r in records + if r["layer"] == "emulator" and r.get("sha1")} + added: list[dict] = [] + for sha1, emulators in sorted(consumers.emulators.items()): + entry = files.get(sha1) + if entry is None: + continue + for emulator in sorted(emulators): + if (sha1, emulator) in reached: + continue + index = consumers.emulator_entries.get(sha1, {}).get(emulator) + entries = consumers.emulator_files.get(emulator) or [] + if index is None or index >= len(entries): + continue + anchor = _emulator_card_anchor(emulator, entries, index) + url = _site_path(f"emulators/{emulator_card_page(emulator, anchor)}.md#{anchor}") + name = str(entries[index].get("name", "")) + for algorithm in HASH_ALGORITHMS: + for value in _hash_values(entry.get(algorithm)): + added.append({ + "value": value, "algorithm": algorithm, "layer": "emulator", + "owner": emulator, "name": name, "url": url, "sha1": sha1, + "declared": "name", + }) + return added + + def _collection_sha1(files: dict, indexes: dict, algorithm: str, value: str) -> str | None: """The collection file a declared value designates, None when none does.""" if algorithm == "sha1": @@ -10117,8 +10272,9 @@ def _generate(args: argparse.Namespace) -> None: print("Generating static API and bulk data exports...") exports = generate_data_exports( - public, db, coverages, profiles, stats, gap_report + public, db, coverages, profiles, stats, gap_report, consumers ) + write_release_advertised(Path(args.site_dir) / "src" / "generated") write_if_changed(str(docs / "data.md"), generate_data_page(exports) + reference_next()) PAGE_DATA["data.md"] = dataset_structured_data(exports, db) diff --git a/tests/test_site_r3_reference.py b/tests/test_site_r3_reference.py index 3d5a6dec..c363b529 100644 --- a/tests/test_site_r3_reference.py +++ b/tests/test_site_r3_reference.py @@ -267,7 +267,7 @@ class EveryEnvelopeCarriesTheCommit(unittest.TestCase): 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") + if path.parent.name not in ("schemas", "manifests", "targets") and path.name not in ("database.json", "stats.json", "release.json") and "commit" not in json.loads(path.read_text()) ] diff --git a/tests/test_site_r4_reference.py b/tests/test_site_r4_reference.py index 41b36ddf..b11a9cba 100644 --- a/tests/test_site_r4_reference.py +++ b/tests/test_site_r4_reference.py @@ -263,14 +263,16 @@ class TheReferencePagesAnswerTheirWords(unittest.TestCase): 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\n---\n")) + "---\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, check a folder, rate limit, api\n---\n")) + "---\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) diff --git a/tests/test_site_r5_reference.py b/tests/test_site_r5_reference.py new file mode 100644 index 00000000..618268bb --- /dev/null +++ b/tests/test_site_r5_reference.py @@ -0,0 +1,266 @@ +"""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()