mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-06 01:53:56 -06:00
chore: hold contributor pages to schema and code
This commit is contained in:
1 parent
c92b8eb7da
commit
09fd7af35b
3 files changed
+332
-5
No files matched your search
@@ -860,5 +860,332 @@ class RetroArchWindowsSystemFolder(unittest.TestCase):
|
||||
self.assertEqual(folders["macOS"], "`~/Documents/RetroArch/system/`")
|
||||
|
||||
|
||||
# The contributor and reference pages state fields, scripts, options, paths,
|
||||
# platform lists and examples that the code and the data already hold. Each
|
||||
# check below reads both sides, so a page falls out of step the day the code
|
||||
# moves rather than the day a reader trips on it.
|
||||
|
||||
CONTRIBUTOR_PAGES = (
|
||||
"profiling.md", "adding-a-platform.md", "adding-a-scraper.md", "architecture.md",
|
||||
"verification-modes.md", "reference-terms.md", "glossary.md", "legal.md", "index.md",
|
||||
"what-are-bios-files.md", "community-tools.md",
|
||||
)
|
||||
REPO_DIRS = (
|
||||
"scripts/", "platforms/", "emulators/", "schemas/", "tests/", "wiki/", "install/",
|
||||
"provenance/", "recipes/", "exports/", "measurements/", "website/", "automation/",
|
||||
"ports/", "docs_assets/", "docs/",
|
||||
)
|
||||
PLACEHOLDER = re.compile(r"[<>*{}]|myplatform|\bNAME\b|\.\.\.")
|
||||
|
||||
|
||||
def _schema(name: str) -> dict:
|
||||
return json.loads((ROOT / "schemas" / f"{name}.schema.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _schema_keys(schema: object, keys: set[str]) -> set[str]:
|
||||
if isinstance(schema, dict):
|
||||
keys.update(schema.get("properties", {}))
|
||||
for value in schema.values():
|
||||
_schema_keys(value, keys)
|
||||
elif isinstance(schema, list):
|
||||
for value in schema:
|
||||
_schema_keys(value, keys)
|
||||
return keys
|
||||
|
||||
|
||||
def _registry() -> dict:
|
||||
import yaml
|
||||
|
||||
return yaml.safe_load((ROOT / "platforms" / "_registry.yml").read_text(encoding="utf-8"))["platforms"]
|
||||
|
||||
|
||||
def _table_fields(section: str) -> set[str]:
|
||||
fields = set()
|
||||
for line in section.splitlines():
|
||||
cell = re.match(r"^\| ((?:`[a-z_0-9]+`(?:, )?)+) \|", line)
|
||||
if cell:
|
||||
fields |= set(re.findall(r"`([a-z_0-9]+)`", cell.group(1)))
|
||||
return fields
|
||||
|
||||
|
||||
def _code_spans(text: str) -> list[str]:
|
||||
"""Inline code and every line of fenced code."""
|
||||
spans = re.findall(r"`([^`\n]+)`", FENCED.sub("", text))
|
||||
for block in FENCED.findall(text):
|
||||
spans.extend(block.splitlines()[1:-1])
|
||||
return spans
|
||||
|
||||
|
||||
def missing_repo_paths(text: str) -> list[str]:
|
||||
missing = []
|
||||
for span in _code_spans(text):
|
||||
for token in re.findall(r"[\w./-]+", span):
|
||||
if PLACEHOLDER.search(span) and "myplatform" in token:
|
||||
continue
|
||||
path = re.sub(r":[\d,-]+$", "", token).rstrip(".,")
|
||||
if path.startswith(REPO_DIRS) and not PLACEHOLDER.search(path):
|
||||
if not (ROOT / path).exists():
|
||||
missing.append(path)
|
||||
elif re.fullmatch(r"test_\w+\.py", path) and not (ROOT / "tests" / path).exists():
|
||||
missing.append(path)
|
||||
return sorted(set(missing))
|
||||
|
||||
|
||||
def _script_source(line: str) -> tuple[str, str] | None:
|
||||
"""The command a line runs and the source its options must come from."""
|
||||
match = re.search(r"python3? (scripts/[\w/]+\.py)", line)
|
||||
if match:
|
||||
return match.group(1), (ROOT / match.group(1)).read_text(encoding="utf-8") \
|
||||
if (ROOT / match.group(1)).is_file() else ""
|
||||
match = re.search(r"python3? -m (scripts\.scraper\.[\w.]+)", line)
|
||||
if match:
|
||||
module = match.group(1)
|
||||
path = ROOT / (module.replace(".", "/") + ".py")
|
||||
source = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
if "scraper_cli" in source or "myplatform" in module:
|
||||
source += (ROOT / "scripts/scraper/base_scraper.py").read_text(encoding="utf-8")
|
||||
return module, source
|
||||
return None
|
||||
|
||||
|
||||
def unknown_commands(text: str) -> list[str]:
|
||||
wrong = []
|
||||
for span in _code_spans(text):
|
||||
for command in re.split(r"\||&&|;", span):
|
||||
found = _script_source(command)
|
||||
if found is None:
|
||||
continue
|
||||
name, source = found
|
||||
if not source:
|
||||
wrong.append(name)
|
||||
continue
|
||||
for option in re.findall(r"(?<![\w-])(--[a-z][a-z0-9-]*)", command):
|
||||
if f'"{option}"' not in source:
|
||||
wrong.append(f"{name} {option}")
|
||||
return sorted(set(wrong))
|
||||
|
||||
|
||||
def fields_of_colon_tokens(text: str) -> set[str]:
|
||||
"""`key:` and `key: value` spans: the page naming a YAML key."""
|
||||
return {
|
||||
m.group(1) for span in re.findall(r"`([^`\n]+)`", FENCED.sub("", text))
|
||||
if (m := re.fullmatch(r"([a-z][a-z0-9_]*):(?:\s.*)?", span))
|
||||
}
|
||||
|
||||
|
||||
class PagesNameWhatExists(unittest.TestCase):
|
||||
"""The pages named a target scraper PLATFORM_NAME the discovery does
|
||||
not use, a docs/ folder the site left, exporter methods that no longer
|
||||
exist and profile fields in the wrong table. Every path, script and
|
||||
option a page names is looked up."""
|
||||
|
||||
def test_every_repository_path_exists(self):
|
||||
for name in CONTRIBUTOR_PAGES:
|
||||
with self.subTest(page=name):
|
||||
self.assertEqual(missing_repo_paths(_page(name)), [])
|
||||
|
||||
def test_every_command_and_option_exists(self):
|
||||
for name in CONTRIBUTOR_PAGES:
|
||||
with self.subTest(page=name):
|
||||
self.assertEqual(unknown_commands(_page(name)), [])
|
||||
|
||||
def test_the_checks_catch_the_old_text(self):
|
||||
self.assertEqual(missing_repo_paths("`scripts/scraper/targets/foo_targets.py`"),
|
||||
["scripts/scraper/targets/foo_targets.py"])
|
||||
self.assertEqual(unknown_commands("```bash\npython scripts/verify.py --no-such-flag\n```"),
|
||||
["scripts/verify.py --no-such-flag"])
|
||||
|
||||
def test_every_yaml_key_a_page_names_is_a_schema_key(self):
|
||||
keys = set()
|
||||
for name in ("emulator", "platform", "registry"):
|
||||
_schema_keys(_schema(name), keys)
|
||||
keys |= {key for entry in _registry().values() for key in entry}
|
||||
for name in ("profiling.md", "reference-terms.md", "verification-modes.md"):
|
||||
with self.subTest(page=name):
|
||||
self.assertEqual(sorted(fields_of_colon_tokens(_page(name)) - keys), [])
|
||||
self.assertEqual(fields_of_colon_tokens("`bios_md5: abc`"), {"bios_md5"})
|
||||
|
||||
|
||||
class ProfileFieldTables(unittest.TestCase):
|
||||
"""Seven profile-level fields (pin_frozen, data_root, version_source and
|
||||
four more) sat in the file entry table, where a reader wrote them on a
|
||||
file and the schema refused it."""
|
||||
|
||||
def test_each_table_holds_its_level_of_the_schema(self):
|
||||
schema = _schema("emulator")
|
||||
top = set(schema["properties"])
|
||||
entry = set(schema["properties"]["files"]["items"]["properties"])
|
||||
page = _page("profiling.md")
|
||||
profile_section = page.split("### Profile fields")[1].split("\n### ")[0]
|
||||
described = _table_fields(profile_section)
|
||||
described |= set(re.findall(r"`([a-z_0-9]+)`", profile_section.split("Descriptive fields")[1]))
|
||||
self.assertEqual(sorted(described ^ top), [])
|
||||
file_section = page.split("### File entry fields")[1].split("\n## ")[0]
|
||||
self.assertEqual(sorted(_table_fields(file_section) ^ entry), [])
|
||||
self.assertIn("pin_frozen", _table_fields("| `pin_frozen` | why |") - entry)
|
||||
|
||||
|
||||
class PlatformListsFollowTheRegistry(unittest.TestCase):
|
||||
"""The mode lists and the list of platforms naming their cores were
|
||||
written by hand: KNULLI, AmberELEC, EmuELEC and the MinUI family were
|
||||
missing from the core list."""
|
||||
|
||||
def _names(self) -> dict[str, str]:
|
||||
import yaml
|
||||
|
||||
names = {}
|
||||
for key, entry in _registry().items():
|
||||
config = yaml.safe_load((ROOT / "platforms" / entry["config"]).read_text(encoding="utf-8"))
|
||||
names[str(config.get("platform") or key)] = key
|
||||
return names
|
||||
|
||||
def _modes(self) -> dict[str, str]:
|
||||
import common
|
||||
|
||||
return {key: common.load_platform_config(key, str(ROOT / "platforms")).get("verification_mode")
|
||||
for key in _registry()}
|
||||
|
||||
def _split(self, text: str) -> set[str]:
|
||||
names = self._names()
|
||||
listed = {n.strip() for n in re.split(r",|\band\b", text) if n.strip()}
|
||||
unknown = listed - set(names)
|
||||
self.assertEqual(unknown, set(), "not in platforms/_registry.yml")
|
||||
return {names[n] for n in listed}
|
||||
|
||||
def test_verification_mode_sections(self):
|
||||
page = _page("verification-modes.md")
|
||||
modes = self._modes()
|
||||
seen = set()
|
||||
for mode, heading in (("existence", "Existence mode"), ("md5", "MD5 mode"), ("sha1", "SHA1 mode")):
|
||||
line = re.search(r"\*\*Platforms\*\*: (.+)", _section(page, heading)).group(1)
|
||||
keys = self._split(line)
|
||||
self.assertEqual({k for k in keys if modes[k] != mode}, set(), mode)
|
||||
seen |= keys
|
||||
self.assertEqual(seen, set(_registry()))
|
||||
|
||||
def test_adding_a_platform_mode_table(self):
|
||||
section = _section(_page("adding-a-platform.md"), "Step 4: Add verification logic")
|
||||
modes = self._modes()
|
||||
seen = set()
|
||||
for row in _table_rows(section):
|
||||
keys = self._split(row[2])
|
||||
self.assertEqual({k for k in keys if modes[k] != row[0].strip("`")}, set(), row[0])
|
||||
seen |= keys
|
||||
self.assertEqual(seen, set(_registry()))
|
||||
|
||||
def test_the_explicit_core_lists(self):
|
||||
import yaml
|
||||
|
||||
section = _section(_page("adding-a-platform.md"), "Required fields")
|
||||
bullet = next(p for p in section.split("\n- ") if p.startswith("**Explicit list**"))
|
||||
used_by = re.search(r"Used by ([^.]+)\.", bullet.replace("\n", " ")).group(1)
|
||||
listing = set()
|
||||
for key, entry in _registry().items():
|
||||
config = yaml.safe_load((ROOT / "platforms" / entry["config"]).read_text(encoding="utf-8"))
|
||||
cores = entry.get("cores", config.get("cores"))
|
||||
if isinstance(cores, list) and cores:
|
||||
listing.add(key)
|
||||
self.assertEqual(self._split(used_by), listing)
|
||||
|
||||
def test_the_check_refuses_an_unknown_platform(self):
|
||||
with self.assertRaises(AssertionError):
|
||||
self._split("RetroArch, Lakka, Batocera Plus")
|
||||
|
||||
|
||||
class ListsFollowTheCode(unittest.TestCase):
|
||||
"""The detection table named three of the eight methods the registry
|
||||
uses, the pipeline list stopped at eleven of its steps and the target
|
||||
scraper table at four of seven scrapers."""
|
||||
|
||||
def test_install_detection_methods(self):
|
||||
used = {rule["method"] for entry in _registry().values()
|
||||
for rule in (entry.get("install") or {}).get("detect") or []}
|
||||
section = _section(_page("adding-a-platform.md"), "Step 7: Add install detection (optional)")
|
||||
self.assertEqual({row[0].strip("`") for row in _table_rows(section)}, used)
|
||||
|
||||
def test_pipeline_steps(self):
|
||||
from pipeline import STEP_LABELS
|
||||
|
||||
section = _section(_page("adding-a-platform.md"), "Step 8: Validate the full pipeline")
|
||||
listed = re.findall(r"^- (\d\w*) ", section, re.M)
|
||||
self.assertEqual(listed, [label.split(" ", 1)[0] for label in STEP_LABELS.values()])
|
||||
|
||||
def test_target_scrapers(self):
|
||||
modules = {p.stem.removesuffix("_scraper") for p in (ROOT / "scripts/scraper/targets").glob("*_targets_scraper.py")}
|
||||
section = _section(_page("adding-a-scraper.md"), "Existing target scrapers")
|
||||
self.assertEqual({row[0].strip("`") for row in _table_rows(section)}, modules)
|
||||
for path in (ROOT / "scripts/scraper/targets").glob("*_targets_scraper.py"):
|
||||
name = re.search(r'^PLATFORM_NAME = "(\w+)"', path.read_text(encoding="utf-8"), re.M).group(1)
|
||||
self.assertIn(name, _registry())
|
||||
for page in ("adding-a-platform.md", "adding-a-scraper.md"):
|
||||
self.assertNotIn('PLATFORM_NAME = "myplatform_targets"', _page(page))
|
||||
|
||||
def test_exporter_contract(self):
|
||||
from exporter.base_exporter import BaseExporter
|
||||
|
||||
section = _page("adding-a-platform.md").split("## Step 5: Create an exporter")[1].split("### Round-trip")[0]
|
||||
for method in set(re.findall(r"def (\w+)\(", section)) | set(re.findall(r"`(\w+)\(\)`", section)):
|
||||
self.assertTrue(hasattr(BaseExporter, method), method)
|
||||
self.assertTrue(BaseExporter.__abstractmethods__ <= set(re.findall(r"def (\w+)\(", section)))
|
||||
|
||||
|
||||
class ExamplesComeFromTheData(unittest.TestCase):
|
||||
"""The Dolphin example carried a source_ref, an Adler-32 and a region
|
||||
the profile never had, the launcher paragraph said 22 names per region
|
||||
for 22 in all, and two pages cited Beetle PSX as a required HLE file
|
||||
after the profile made it optional."""
|
||||
|
||||
def _profile(self, name: str) -> dict:
|
||||
import yaml
|
||||
|
||||
return yaml.safe_load((ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
def _yaml_blocks(self, page: str) -> list[str]:
|
||||
return [b.split("\n", 1)[1].rsplit("```", 1)[0] for b in FENCED.findall(_page(page)) if b.startswith("```yaml")]
|
||||
|
||||
def _subset(self, shown: dict, actual: dict) -> None:
|
||||
for key, value in shown.items():
|
||||
self.assertEqual(value, actual.get(key), key)
|
||||
|
||||
def test_profile_excerpts(self):
|
||||
import yaml
|
||||
|
||||
blocks = self._yaml_blocks("profiling.md")
|
||||
duck = yaml.safe_load(blocks[0])[0]
|
||||
entry = next(f for f in self._profile("duckstation")["files"] if f["name"] == duck["name"])
|
||||
self._subset(duck, entry)
|
||||
dolphin = yaml.safe_load(next(b for b in blocks if b.startswith("emulator: Dolphin")))
|
||||
profile = self._profile("dolphin")
|
||||
self._subset({k: v for k, v in dolphin.items() if k != "files"}, profile)
|
||||
for shown in dolphin["files"]:
|
||||
actual = next(f for f in profile["files"] if f.get("path") == shown["path"])
|
||||
self._subset(shown, actual)
|
||||
|
||||
def test_launcher_excerpt(self):
|
||||
import yaml
|
||||
|
||||
shown = yaml.safe_load(next(b for b in self._yaml_blocks("adding-a-platform.md")
|
||||
if b.startswith("launchers:")))["launchers"][0]
|
||||
config = yaml.safe_load((ROOT / "platforms/batocera.yml").read_text(encoding="utf-8"))
|
||||
record = next(r for r in config["launchers"]
|
||||
if r["emulator"] == shown["emulator"] and r.get("slot") == shown["slot"])
|
||||
self._subset({k: v for k, v in shown.items() if k != "source_ref"}, record)
|
||||
self.assertTrue(record["source_ref"].endswith(shown["source_ref"].split("...")[-1]))
|
||||
|
||||
def test_required_hle_examples(self):
|
||||
for page in ("profiling.md", "reference-terms.md"):
|
||||
text = _page(page).replace("\n", " ")
|
||||
match = re.search(r"among them (\w+)'s +`([^`]+)`", text)
|
||||
entry = next(f for f in self._profile(match.group(1).lower())["files"]
|
||||
if f["name"] == match.group(2))
|
||||
self.assertTrue(entry.get("required") and entry.get("hle_fallback"), page)
|
||||
|
||||
def test_reference_terms_source_ref(self):
|
||||
text = _section(_page("reference-terms.md"), "Terms of the reference pages").replace("\n", " ")
|
||||
cited = re.search(r"lists `scph5501.bin` at\s+`([^`]+)`", text).group(1)
|
||||
entry = next(f for f in self._profile("duckstation")["files"] if f["name"] == "scph5501.bin")
|
||||
self.assertEqual(cited, entry["source_ref"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -554,7 +554,7 @@ Read the platform's source code to understand its exact verification behavior be
|
||||
|
||||
## Step 5: Create an exporter
|
||||
|
||||
An exporter writes the platform's own BIOS file back, corrected by what the
|
||||
An exporter writes the platform's own BIOS file back, updated with what the
|
||||
emulator code shows. Every registered platform has one, unless its registry
|
||||
entry says `native_list: none`: `tests/test_exporters.py` fails otherwise.
|
||||
Exporters live in `scripts/exporter/` and are found the same way as scrapers
|
||||
|
||||
@@ -31,7 +31,7 @@ platforms/ one YAML config per platform (scraped from upstream)
|
||||
targets/ hardware target configs + _overrides.yml
|
||||
provenance/ dump-catalog snapshots (redump, no-intro, tosec)
|
||||
recipes/ romset recipes read from MAME and FBNeo DATs
|
||||
exports/ each platform's own file, corrected, with a report of the changes
|
||||
exports/ each platform's own file, updated, with a report of the changes
|
||||
measurements/ records of real runs, one JSONL file per emulator
|
||||
scripts/ all tooling (Python and pyyaml, jsonschema for schema checks)
|
||||
scraper/ upstream scrapers (libretro, batocera, recalbox...)
|
||||
@@ -207,9 +207,9 @@ step with every emulator release is a large amount of work, and the files are
|
||||
written to make it lighter.
|
||||
|
||||
An entry a platform declares is never removed from its file on the strength of
|
||||
a missing match. When this was measured, an entry the generated data did not
|
||||
pair was still read in 1189 of 2776 cases, by an alias, a hash, a directory
|
||||
entry or a core outside the resolved set. A removal needs positive proof:
|
||||
a missing match. When this was measured, entries the generated data did not
|
||||
pair were often still read, by an alias, a hash, a directory entry or a core
|
||||
outside the resolved set. A removal needs positive proof:
|
||||
every core of the system profiled, each with a reference showing the closed
|
||||
set of names it opens. Until then the entry is listed as a removal candidate
|
||||
with the cores examined (see [export_native.py](tools.md#export_nativepy)).
|
||||
|
||||
Reference in new issue
Block a user