mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 13:33:24 -05:00
feat: add freshness tooling and override defaults
This commit is contained in:
1 parent
08ef1e8dcf
commit
8a2a9c6ffa
5 files changed
+1724
No files matched your search
@@ -5,6 +5,8 @@
|
||||
# Format:
|
||||
# platform_name:
|
||||
# targets:
|
||||
# _default: # applies to every target
|
||||
# remove_cores: [...]
|
||||
# target-name:
|
||||
# aliases: [alias1, alias2]
|
||||
# add_cores: [core_to_add]
|
||||
@@ -15,6 +17,27 @@
|
||||
# doesn't resolve. These are confirmed available on x86_64 via Config.in.
|
||||
batocera:
|
||||
targets:
|
||||
_default:
|
||||
# Amiga machine variants (A500, CD32, etc.) are selectors passed to
|
||||
# fsuae/amiberry, not emulators of their own; batocera-es-system
|
||||
# groups them under the emulator's node in es_systems.yml.
|
||||
# odcommander is OpenDingux's file manager; flatpak is a runtime
|
||||
# launcher. Neither emulates anything.
|
||||
remove_cores:
|
||||
- A500
|
||||
- "A500+"
|
||||
- A600
|
||||
- A1000
|
||||
- A1200
|
||||
- A3000
|
||||
- A4000
|
||||
- CD32
|
||||
- CDTV
|
||||
- flatpak
|
||||
- odcommander
|
||||
- sh
|
||||
- steam
|
||||
- wine-tkg
|
||||
x86_64:
|
||||
add_cores: [citron, demul, model2, xenia]
|
||||
zen3:
|
||||
@@ -37,3 +60,23 @@ retroarch:
|
||||
aliases: [ps2]
|
||||
playstation-psp:
|
||||
aliases: [psp]
|
||||
|
||||
retrodeck:
|
||||
targets:
|
||||
_default:
|
||||
# RetroDECK ships RetroArch itself as a component; its target list
|
||||
# includes the frontend name alongside the actual cores.
|
||||
remove_cores: [retroarch]
|
||||
|
||||
emudeck:
|
||||
targets:
|
||||
_default:
|
||||
# EmuDeck's EmuScripts tree carries a template.sh placeholder used
|
||||
# to scaffold new installers; it is not an emulator.
|
||||
remove_cores: [template]
|
||||
|
||||
retropie:
|
||||
targets:
|
||||
_default:
|
||||
# Homebrew game scriptmodule, not an emulator.
|
||||
remove_cores: [superflappybirds]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -276,7 +276,12 @@ def load_target_config(
|
||||
|
||||
cores = set(str(c) for c in targets[canonical].get("cores", []))
|
||||
|
||||
default_ovr = overrides.get("_default", {})
|
||||
ovr = overrides.get(canonical, {})
|
||||
for c in default_ovr.get("add_cores", []):
|
||||
cores.add(str(c))
|
||||
for c in default_ovr.get("remove_cores", []):
|
||||
cores.discard(str(c))
|
||||
for c in ovr.get("add_cores", []):
|
||||
cores.add(str(c))
|
||||
for c in ovr.get("remove_cores", []):
|
||||
@@ -1096,6 +1101,29 @@ def group_identical_platforms(
|
||||
return result
|
||||
|
||||
|
||||
def runs_standalone(
|
||||
emu_name: str, profile: dict, standalone_cores: set[str]
|
||||
) -> bool:
|
||||
"""Whether a platform lays this emulator's files out for its standalone build.
|
||||
|
||||
A platform names the emulators it launches standalone in
|
||||
``standalone_cores``, by profile key or by any name in ``cores:``. The
|
||||
name alone is not enough: Recalbox and Batocera call their standalone
|
||||
ScummVM ``scummvm``, which is also the key of the libretro core's
|
||||
profile, and that profile documents no standalone build. Laid out
|
||||
"standalone" it would lose every ``path:`` and drop its files at the
|
||||
root, so a profile whose ``type`` has no standalone build keeps its
|
||||
libretro layout whatever the platform calls it.
|
||||
"""
|
||||
if not standalone_cores:
|
||||
return False
|
||||
if "standalone" not in str(profile.get("type", "")):
|
||||
return False
|
||||
return emu_name in standalone_cores or bool(
|
||||
standalone_cores & {str(c) for c in profile.get("cores", [])}
|
||||
)
|
||||
|
||||
|
||||
def resolve_platform_cores(
|
||||
config: dict,
|
||||
profiles: dict[str, dict],
|
||||
@@ -1193,6 +1221,8 @@ MANUFACTURER_PREFIXES = (
|
||||
"interton-",
|
||||
"texas-instruments-",
|
||||
"videoton-",
|
||||
"wenquxing-",
|
||||
"aquaplus-",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Executable
+430
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refresh everything check_freshness.py reports as STALE, in parallel.
|
||||
|
||||
check_freshness.py already answers "what is out of date". This script turns
|
||||
that answer into actions: it parses the JSON report, matches each stale
|
||||
subject to the command that pulls its upstream, runs them concurrently, and
|
||||
leaves every write in the working tree for human review. Nothing is ever
|
||||
committed.
|
||||
|
||||
Mapping (stale -> command):
|
||||
platforms/<name> python -m scripts.scraper.<module>_scraper
|
||||
-o platforms/<name>.yml
|
||||
targets/<name> python -m scripts.scraper.targets.<module>
|
||||
-o platforms/targets/<name>.yml
|
||||
data/<key> python scripts/refresh_data_dirs.py --key <key>
|
||||
--force
|
||||
catalogs/mame recipes python -m scripts.scraper.romset_dat_importer
|
||||
--source mame --fetch
|
||||
catalogs/fbneo recipes python -m scripts.scraper.romset_dat_importer
|
||||
--source fbneo --fetch
|
||||
|
||||
Surfaced only (no refresh): coreinfo gaps, redump/no-intro/tosec packs,
|
||||
CI pins (install.py SHA-256, PyPI, pinned actions), profile_sync. Each needs
|
||||
a human read (new .info file, manual DAT download, PyPI version bump).
|
||||
|
||||
Usage:
|
||||
python scripts/refresh_stale.py --dry-run
|
||||
python scripts/refresh_stale.py --only platforms,targets
|
||||
python scripts/refresh_stale.py --jobs 6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
LOG_DIR = REPO_ROOT / "tmp" / "refresh_stale"
|
||||
CHECK_FRESHNESS = REPO_ROOT / "scripts" / "check_freshness.py"
|
||||
PLATFORMS_REGISTRY = REPO_ROOT / "platforms" / "_registry.yml"
|
||||
|
||||
AUTO_AREAS = ("platforms", "targets", "data", "catalogs")
|
||||
MANUAL_AREAS = ("coreinfo", "ci", "profiles")
|
||||
|
||||
JOB_TIMEOUT = 1800 # 30 minutes per refresher; scrapers rarely exceed 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Job:
|
||||
"""One refresh action derived from a stale finding."""
|
||||
|
||||
area: str
|
||||
subject: str
|
||||
command: list[str]
|
||||
log_path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobResult:
|
||||
job: Job
|
||||
returncode: int
|
||||
duration: float
|
||||
tail: str # last non-empty line of stderr or stdout
|
||||
|
||||
|
||||
def _load_platform_registry() -> dict[str, dict]:
|
||||
"""Return the platforms map from _registry.yml.
|
||||
|
||||
pyyaml is already a project dependency; the registry is small so we read
|
||||
it once at startup rather than parsing each dispatch.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with PLATFORMS_REGISTRY.open(encoding="utf-8") as fh:
|
||||
return (yaml.safe_load(fh) or {}).get("platforms") or {}
|
||||
|
||||
|
||||
def _run_check_freshness(areas: tuple[str, ...], extra: list[str]) -> list[dict]:
|
||||
"""Run check_freshness.py --json and return its findings."""
|
||||
cmd = [sys.executable, str(CHECK_FRESHNESS), "--json"]
|
||||
if areas and set(areas) != set(AUTO_AREAS + MANUAL_AREAS):
|
||||
cmd += ["--only", ",".join(areas)]
|
||||
cmd += extra
|
||||
proc = subprocess.run(
|
||||
cmd, cwd=REPO_ROOT, capture_output=True, text=True, check=False, timeout=1800
|
||||
)
|
||||
if proc.returncode not in (0, 1):
|
||||
tail = (proc.stderr or proc.stdout).strip().splitlines()[-3:]
|
||||
raise RuntimeError(
|
||||
"check_freshness.py failed:\n" + "\n".join(tail or ["(no output)"])
|
||||
)
|
||||
try:
|
||||
return json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"check_freshness.py returned unreadable JSON: {exc}") from exc
|
||||
|
||||
|
||||
def _slug(subject: str) -> str:
|
||||
"""A safe filename stem for a finding's subject."""
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "_", subject).strip("_") or "job"
|
||||
|
||||
|
||||
def plan_jobs(
|
||||
findings: list[dict], registry: dict[str, dict]
|
||||
) -> tuple[list[Job], list[dict], list[dict]]:
|
||||
"""Split findings into (refreshable jobs, surfaced-only, ignored).
|
||||
|
||||
Surfaced-only entries carry stale state that only a human can resolve
|
||||
(new .info file, manual DAT download, PyPI bump). Ignored entries are
|
||||
anything not STALE (OK, SKIPPED, UNKNOWN, ERROR); ERROR is also surfaced
|
||||
so the final report mentions it.
|
||||
"""
|
||||
jobs: list[Job] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
surfaced: list[dict] = []
|
||||
ignored: list[dict] = []
|
||||
|
||||
for finding in findings:
|
||||
status = finding.get("status")
|
||||
area = finding.get("area")
|
||||
subject = str(finding.get("subject") or "")
|
||||
|
||||
if status == "ERROR":
|
||||
surfaced.append(finding)
|
||||
continue
|
||||
if status != "STALE":
|
||||
ignored.append(finding)
|
||||
continue
|
||||
|
||||
command = _command_for(area, subject, registry)
|
||||
if command is None:
|
||||
surfaced.append(finding)
|
||||
continue
|
||||
|
||||
key = (area, " ".join(command))
|
||||
if key in seen:
|
||||
# Two findings funneling to the same command (e.g. mame recipes
|
||||
# listed twice) collapse to one job.
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
log = LOG_DIR / f"{area}__{_slug(subject)}.log"
|
||||
jobs.append(Job(area=area, subject=subject, command=command, log_path=log))
|
||||
|
||||
return jobs, surfaced, ignored
|
||||
|
||||
|
||||
def _command_for(
|
||||
area: str, subject: str, registry: dict[str, dict]
|
||||
) -> list[str] | None:
|
||||
"""Map a stale finding to its refresh command, or None if manual."""
|
||||
if area == "platforms":
|
||||
entry = registry.get(subject) or {}
|
||||
scraper = entry.get("scraper")
|
||||
if not scraper:
|
||||
return None
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
f"scripts.scraper.{scraper}_scraper",
|
||||
"-o",
|
||||
f"platforms/{subject}.yml",
|
||||
]
|
||||
|
||||
if area == "targets":
|
||||
# "<platform> cores" rows list buildbot names without a profile; no
|
||||
# scraper fixes that, a human writes `cores:` or _overrides.yml.
|
||||
if subject.endswith(" cores"):
|
||||
return None
|
||||
entry = registry.get(subject) or {}
|
||||
module = entry.get("target_scraper")
|
||||
if not module:
|
||||
return None
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
f"scripts.scraper.targets.{module}_scraper",
|
||||
"-o",
|
||||
f"platforms/targets/{subject}.yml",
|
||||
]
|
||||
|
||||
if area == "data":
|
||||
if subject == "_data_dirs.yml":
|
||||
# The registry file itself cannot be refreshed; its entries can.
|
||||
return None
|
||||
return [
|
||||
sys.executable,
|
||||
"scripts/refresh_data_dirs.py",
|
||||
"--key",
|
||||
subject,
|
||||
"--force",
|
||||
]
|
||||
|
||||
if area == "catalogs":
|
||||
if subject == "mame recipes":
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"scripts.scraper.romset_dat_importer",
|
||||
"--source",
|
||||
"mame",
|
||||
"--fetch",
|
||||
]
|
||||
if subject == "fbneo recipes":
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"scripts.scraper.romset_dat_importer",
|
||||
"--source",
|
||||
"fbneo",
|
||||
"--fetch",
|
||||
]
|
||||
# redump / no-intro / tosec need manual DAT download.
|
||||
return None
|
||||
|
||||
# coreinfo, ci, profiles: never auto-refresh.
|
||||
return None
|
||||
|
||||
|
||||
def _github_token_env() -> dict[str, str]:
|
||||
"""Hand `gh auth token` to the subprocess environment.
|
||||
|
||||
emudeck and retropie target scrapers hit the GitHub API; without a token
|
||||
the unauthenticated rate limit (60/h) is burnt within a few platforms.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
if env.get("GITHUB_TOKEN"):
|
||||
return env
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["gh", "auth", "token"], capture_output=True, text=True, timeout=10, check=False
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return env
|
||||
token = (proc.stdout or "").strip()
|
||||
if proc.returncode == 0 and token:
|
||||
env["GITHUB_TOKEN"] = token
|
||||
return env
|
||||
|
||||
|
||||
def run_job(job: Job, env: dict[str, str]) -> JobResult:
|
||||
"""Execute one refresh command and collect its output."""
|
||||
start = time.monotonic()
|
||||
job.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with job.log_path.open("w", encoding="utf-8") as log:
|
||||
log.write(f"$ {' '.join(job.command)}\n")
|
||||
log.flush()
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
job.command,
|
||||
cwd=REPO_ROOT,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
timeout=JOB_TIMEOUT,
|
||||
check=False,
|
||||
)
|
||||
returncode = proc.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
log.write(f"\nTIMEOUT after {JOB_TIMEOUT}s\n")
|
||||
returncode = 124
|
||||
duration = time.monotonic() - start
|
||||
tail = _log_tail(job.log_path)
|
||||
return JobResult(job=job, returncode=returncode, duration=duration, tail=tail)
|
||||
|
||||
|
||||
def _log_tail(path: Path) -> str:
|
||||
"""Last non-empty line of a log, truncated for the summary table."""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
for line in reversed(text.splitlines()):
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("$ "):
|
||||
return stripped[:120]
|
||||
return ""
|
||||
|
||||
|
||||
def render(
|
||||
results: list[JobResult],
|
||||
surfaced: list[dict],
|
||||
ignored: list[dict],
|
||||
) -> str:
|
||||
"""Human-readable table of what ran and what still needs a human."""
|
||||
lines: list[str] = []
|
||||
subject_w = max((len(r.job.subject) for r in results), default=10)
|
||||
subject_w = min(max(subject_w, 10), 44)
|
||||
|
||||
if results:
|
||||
lines.append("\n[refreshed]")
|
||||
lines.append(
|
||||
f" {'STATUS':8} {'AREA':10} {'SUBJECT':{subject_w}} "
|
||||
f"{'DUR':>7} LOG"
|
||||
)
|
||||
for r in sorted(results, key=lambda x: (x.job.area, x.job.subject)):
|
||||
status = "OK" if r.returncode == 0 else f"FAIL ({r.returncode})"
|
||||
rel_log = r.job.log_path.relative_to(REPO_ROOT)
|
||||
lines.append(
|
||||
f" {status:8} {r.job.area:10} {r.job.subject[:subject_w]:{subject_w}} "
|
||||
f"{r.duration:6.1f}s {rel_log}"
|
||||
)
|
||||
if r.returncode != 0 and r.tail:
|
||||
lines.append(f" -> {r.tail}")
|
||||
|
||||
if surfaced:
|
||||
lines.append("\n[manual review needed]")
|
||||
for f in surfaced:
|
||||
detail = str(f.get("detail") or "").strip()
|
||||
lines.append(
|
||||
f" {f.get('status', ''):7} {f.get('area', ''):10} "
|
||||
f"{f.get('subject', ''):28} {detail[:100]}"
|
||||
)
|
||||
|
||||
if ignored:
|
||||
counts: dict[str, int] = {}
|
||||
for f in ignored:
|
||||
counts[str(f.get("status") or "?")] = counts.get(str(f.get("status") or "?"), 0) + 1
|
||||
parts = ", ".join(f"{n} {status.lower()}" for status, n in sorted(counts.items()))
|
||||
lines.append(f"\n[ignored] {parts}")
|
||||
|
||||
ok = sum(1 for r in results if r.returncode == 0)
|
||||
fail = len(results) - ok
|
||||
lines.append(
|
||||
f"\nREFRESH: {ok} ok, {fail} failed, {len(surfaced)} to review manually"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument(
|
||||
"--only",
|
||||
help="comma-separated areas to check: " + ",".join(AUTO_AREAS),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="list what would be refreshed; do not run any command",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jobs",
|
||||
type=int,
|
||||
default=4,
|
||||
help="parallel refreshers (default: 4)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="machine-readable summary (jobs, surfaced, ignored)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--freshness-arg",
|
||||
action="append",
|
||||
default=[],
|
||||
help="extra flag to pass through to check_freshness.py (repeatable)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
all_areas = AUTO_AREAS + MANUAL_AREAS
|
||||
if args.only:
|
||||
areas = tuple(a.strip() for a in args.only.split(",") if a.strip())
|
||||
unknown = sorted(set(areas) - set(all_areas))
|
||||
if unknown:
|
||||
parser.error(f"unknown area: {', '.join(unknown)}")
|
||||
else:
|
||||
areas = all_areas
|
||||
|
||||
try:
|
||||
findings = _run_check_freshness(areas, args.freshness_arg)
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
registry = _load_platform_registry()
|
||||
jobs, surfaced, ignored = plan_jobs(findings, registry)
|
||||
|
||||
if args.dry_run:
|
||||
print(f"\n[plan] {len(jobs)} refreshable, {len(surfaced)} manual, "
|
||||
f"{len(ignored)} already clean/skipped")
|
||||
for job in sorted(jobs, key=lambda j: (j.area, j.subject)):
|
||||
print(f" {job.area:10} {job.subject:28} {' '.join(job.command)}")
|
||||
if surfaced:
|
||||
print("\n[manual review needed]")
|
||||
for f in surfaced:
|
||||
print(f" {f.get('status', ''):7} {f.get('area', ''):10} "
|
||||
f"{f.get('subject', ''):28} {str(f.get('detail') or '')[:100]}")
|
||||
return 0
|
||||
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
env = _github_token_env()
|
||||
results: list[JobResult] = []
|
||||
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
||||
futures = [pool.submit(run_job, job, env) for job in jobs]
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
|
||||
if args.json:
|
||||
payload = {
|
||||
"refreshed": [
|
||||
{
|
||||
"area": r.job.area,
|
||||
"subject": r.job.subject,
|
||||
"returncode": r.returncode,
|
||||
"duration_sec": round(r.duration, 2),
|
||||
"log": str(r.job.log_path.relative_to(REPO_ROOT)),
|
||||
"tail": r.tail,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
"surfaced": surfaced,
|
||||
"ignored_count": len(ignored),
|
||||
}
|
||||
print(json.dumps(payload, indent=2))
|
||||
else:
|
||||
print(render(results, surfaced, ignored))
|
||||
|
||||
return 0 if all(r.returncode == 0 for r in results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,218 @@
|
||||
"""check_freshness: the pure parts, no network.
|
||||
|
||||
The script answers "is the local copy the one upstream serves" for every
|
||||
transcribed layer. What can be locked without a network is how a diff is
|
||||
read, how a core name is resolved, how a pin is parsed, and how a profile
|
||||
report is folded, since each of those decides whether a row says STALE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import check_freshness as cf # noqa: E402
|
||||
from common import yaml_load # noqa: E402
|
||||
|
||||
|
||||
class PlatformDiffTests(unittest.TestCase):
|
||||
def _platform(self, version="1", files=None, cores=None):
|
||||
return {
|
||||
"version": version,
|
||||
"cores": cores or ["a", "b"],
|
||||
"systems": {
|
||||
"sys": {"files": files if files is not None else [
|
||||
{"name": "x.bin", "destination": "x.bin", "md5": "1"},
|
||||
]},
|
||||
},
|
||||
}
|
||||
|
||||
def test_identical_files_are_not_a_change(self):
|
||||
diff = cf.diff_platform(self._platform(), self._platform())
|
||||
self.assertFalse(diff.changed)
|
||||
self.assertEqual(diff.summary(), "identical")
|
||||
|
||||
def test_version_alone_is_a_change_but_not_content(self):
|
||||
diff = cf.diff_platform(self._platform("1"), self._platform("2"))
|
||||
self.assertTrue(diff.changed)
|
||||
self.assertFalse(diff.content_changed)
|
||||
self.assertEqual(diff.version, ("1", "2"))
|
||||
|
||||
def test_hash_change_on_a_destination_is_a_change_not_a_swap(self):
|
||||
old = self._platform(files=[{"name": "x.bin", "destination": "x.bin", "md5": "1"}])
|
||||
new = self._platform(files=[{"name": "x.bin", "destination": "x.bin", "md5": "2"}])
|
||||
diff = cf.diff_platform(old, new)
|
||||
self.assertEqual(diff.files_changed, ["sys/x.bin"])
|
||||
self.assertEqual(diff.files_added, [])
|
||||
self.assertEqual(diff.files_removed, [])
|
||||
|
||||
def test_systems_files_and_cores_are_reported_separately(self):
|
||||
old = self._platform()
|
||||
new = self._platform(cores=["a", "c"])
|
||||
new["systems"]["other"] = {"files": []}
|
||||
new["systems"]["sys"]["files"].append({"name": "y.bin", "destination": "y.bin"})
|
||||
new["standalone_cores"] = ["dolphin"]
|
||||
diff = cf.diff_platform(old, new)
|
||||
self.assertEqual(diff.systems_added, ["other"])
|
||||
self.assertEqual(diff.files_added, ["sys/y.bin"])
|
||||
self.assertEqual(diff.cores_added, ["c", "standalone:dolphin"])
|
||||
self.assertEqual(diff.cores_removed, ["b"])
|
||||
self.assertIn("+cores 2", diff.summary())
|
||||
|
||||
def test_target_stamp_is_not_a_change(self):
|
||||
old = {"scraped_at": "2026-01-01", "targets": {"t": {"cores": ["a"]}}}
|
||||
new = {"scraped_at": "2026-02-01", "targets": {"t": {"cores": ["a"]}}}
|
||||
self.assertFalse(cf.diff_targets(old, new).content_changed)
|
||||
new["targets"]["t"]["cores"].append("b")
|
||||
diff = cf.diff_targets(old, new)
|
||||
self.assertEqual(diff.cores_added, ["t/b"])
|
||||
|
||||
|
||||
class CoreResolutionTests(unittest.TestCase):
|
||||
PROFILES = {
|
||||
"beetle_psx": {"cores": ["mednafen_psx"], "type": "libretro"},
|
||||
"eka2l1": {"cores": ["eka2l1"], "type": "standalone"},
|
||||
"FreeIntv": {"cores": ["freeintvtsoverlay"], "type": "alias"},
|
||||
}
|
||||
|
||||
def test_index_maps_key_and_every_core_alias(self):
|
||||
index = cf.profile_name_index(self.PROFILES)
|
||||
self.assertEqual(index["mednafen_psx"], "beetle_psx")
|
||||
self.assertEqual(index["beetle_psx"], "beetle_psx")
|
||||
|
||||
def test_unresolved_honours_remove_cores_per_target(self):
|
||||
index = cf.profile_name_index(self.PROFILES)
|
||||
targets = {
|
||||
"targets": {
|
||||
"android": {"cores": ["mednafen_psx", "na", "ghost"]},
|
||||
"linux": {"cores": ["ghost"]},
|
||||
}
|
||||
}
|
||||
removed = {"android": {"na"}}
|
||||
self.assertEqual(
|
||||
cf.unresolved_target_cores(targets, index, removed),
|
||||
{"ghost": ["android", "linux"]},
|
||||
)
|
||||
|
||||
def test_coreinfo_gaps_fold_case_and_flag_standalone_profiles(self):
|
||||
names = ["FreeIntvTSOverlay", "eka2l1", "wqxemu", "mednafen_psx"]
|
||||
unprofiled, standalone = cf.coreinfo_gaps(names, self.PROFILES)
|
||||
self.assertEqual(unprofiled, ["wqxemu"])
|
||||
self.assertEqual(standalone, [("eka2l1", "eka2l1")])
|
||||
|
||||
|
||||
class PinParsingTests(unittest.TestCase):
|
||||
WORKFLOW = """
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- run: pip install pyyaml jsonschema==4.23.0 "mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14"
|
||||
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128
|
||||
"""
|
||||
|
||||
def test_pip_pins_keep_the_specifier(self):
|
||||
pins = cf.parse_pip_pins(self.WORKFLOW)
|
||||
self.assertEqual(pins["pyyaml"], "")
|
||||
self.assertEqual(pins["jsonschema"], "==4.23.0")
|
||||
self.assertEqual(pins["mkdocs-material"], ">=9.7.5,<10")
|
||||
self.assertEqual(pins["pymdown-extensions"], ">=10.14")
|
||||
|
||||
def test_action_pins_carry_sha_and_optional_tag(self):
|
||||
pins = cf.parse_action_pins(self.WORKFLOW)
|
||||
self.assertEqual(pins["actions/checkout"], ("3d3c42e5aac5ba805825da76410c181273ba90b1", "v7"))
|
||||
self.assertEqual(pins["actions/deploy-pages"][1], "")
|
||||
|
||||
def test_specifier_admits_or_refuses_the_latest(self):
|
||||
self.assertTrue(cf.specifier_allows("", "9.9"))
|
||||
self.assertTrue(cf.specifier_allows(">=9.7.5,<10", "9.7.7"))
|
||||
self.assertFalse(cf.specifier_allows(">=9.7.5,<10", "10.0.0"))
|
||||
self.assertFalse(cf.specifier_allows("==4.23.0", "4.26.0"))
|
||||
self.assertTrue(cf.specifier_allows("==4.23.0", "4.23.0"))
|
||||
self.assertTrue(cf.specifier_allows(">=10.14", "12.0.1"))
|
||||
|
||||
|
||||
class CatalogParsingTests(unittest.TestCase):
|
||||
def test_tosec_newest_release_from_category_links(self):
|
||||
html = (
|
||||
'<a href="/downloads/category/58-2024-05-17">x</a>'
|
||||
'<a href="/downloads/category/59-2025-03-13">y</a>'
|
||||
'<a href="/downloads/category/22-datfiles">z</a>'
|
||||
)
|
||||
self.assertEqual(cf.tosec_latest_pack(html), "2025-03-13")
|
||||
self.assertIsNone(cf.tosec_latest_pack("<html></html>"))
|
||||
|
||||
def test_fbneo_drift_compares_blob_shas_and_reports_untracked(self):
|
||||
listing = [
|
||||
{"type": "file", "name": "A.dat", "sha": "1"},
|
||||
{"type": "file", "name": "B.dat", "sha": "2"},
|
||||
{"type": "dir", "name": "old"},
|
||||
]
|
||||
snapshot = {"upstream": {"blobs": {"A.dat": "1", "B.dat": "9"}}}
|
||||
self.assertEqual(cf.fbneo_blob_drift(snapshot, listing), (["B.dat"], True))
|
||||
self.assertEqual(cf.fbneo_blob_drift({"upstream": {}}, listing), ([], False))
|
||||
snapshot = {"upstream": {"blobs": {"A.dat": "1", "B.dat": "2", "C.dat": "3"}}}
|
||||
self.assertEqual(cf.fbneo_blob_drift(snapshot, listing), (["C.dat"], True))
|
||||
|
||||
def test_mame_versions_read_from_dat_labels(self):
|
||||
recipes = {"dats": {"MAME mame0250": "0.250", "MAME mame0289": "0.289", "FinalBurn Neo": "x"}}
|
||||
self.assertEqual(cf.mame_versions(recipes), ["mame0250", "mame0289"])
|
||||
|
||||
|
||||
class ProfileSummaryTests(unittest.TestCase):
|
||||
def test_summary_separates_review_moved_and_unreachable(self):
|
||||
report = [
|
||||
{"name": "a", "pin": "1" * 40, "head": "1" * 40, "needs_review": 0},
|
||||
{"name": "b", "pin": "1" * 40, "head": "2" * 40, "needs_review": 0},
|
||||
{"name": "c", "pin": "1" * 40, "head": "2" * 40, "needs_review": 3},
|
||||
{"name": "d", "skipped": "host does not resolve"},
|
||||
]
|
||||
findings = cf.summarize_profile_sync(report)
|
||||
by_subject = {f.subject: f for f in findings}
|
||||
self.assertEqual(by_subject["profile_sync"].status, cf.STALE)
|
||||
self.assertEqual(by_subject["profile_sync"].local, "1 at tip, 1 anchored past the tip")
|
||||
self.assertEqual(by_subject["c"].status, cf.STALE)
|
||||
self.assertEqual(by_subject["d"].status, cf.UNKNOWN)
|
||||
self.assertNotIn("a", by_subject)
|
||||
self.assertNotIn("b", by_subject)
|
||||
|
||||
def test_a_non_list_report_is_an_error_not_a_crash(self):
|
||||
findings = cf.summarize_profile_sync({"oops": 1})
|
||||
self.assertEqual(findings[0].status, cf.ERROR)
|
||||
|
||||
|
||||
class RepositoryWiringTests(unittest.TestCase):
|
||||
"""The script derives its work from the registry: a platform whose scraper
|
||||
module does not exist would be skipped in silence."""
|
||||
|
||||
def test_every_registered_scraper_module_exists(self):
|
||||
rows = cf._scrapable_platforms(REPO_ROOT / "platforms")
|
||||
self.assertGreaterEqual(len(rows), 10)
|
||||
for _name, module, _path in rows:
|
||||
self.assertTrue(
|
||||
(REPO_ROOT / (module.replace(".", "/") + ".py")).is_file(), module
|
||||
)
|
||||
|
||||
def test_inheriting_platforms_without_a_source_are_not_scraped(self):
|
||||
names = {name for name, _m, _p in cf._scrapable_platforms(REPO_ROOT / "platforms")}
|
||||
with (REPO_ROOT / "platforms" / "lakka.yml").open(encoding="utf-8") as fh:
|
||||
lakka = yaml_load(fh)
|
||||
self.assertTrue(lakka.get("inherits"))
|
||||
self.assertNotIn("lakka", names)
|
||||
self.assertIn("retropie", names)
|
||||
|
||||
def test_render_counts_every_status_once(self):
|
||||
findings = [
|
||||
cf.Finding("ci", "a", cf.OK),
|
||||
cf.Finding("ci", "b", cf.STALE, detail="x"),
|
||||
cf.Finding("data", "c", cf.UNKNOWN),
|
||||
]
|
||||
text = cf.render(findings)
|
||||
self.assertIn("FRESHNESS: 1 stale, 1 unknown, 1 ok", text)
|
||||
self.assertIn("[ci]", text)
|
||||
self.assertIn("[data]", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user