test(compat): pin independent Pong gameplay checks

This commit is contained in:
0xrsydn 2026-10-06 22:39:40 +07:00
commit 9bea613674
10 changed files with 348 additions and 0 deletions

View file

@ -0,0 +1,36 @@
{
"repository": "ZeroDayArcade/Pong-Homebrew-GBA",
"revision": "c784b6036a4f188c50932b411e98126bfcbd07d6",
"license": "MIT",
"rom": "Pong-Homebrew-GBA.gba",
"files": {
"LICENSE": {
"bytes": 1074,
"sha256": "8e466e1d75cd5e8c1deb5397de6275429d0046f62d5c59129e2e6872559b9b67"
},
"README.md": {
"bytes": 6838,
"sha256": "6ea1beefe845bacf9fa443a1dad3c952ad43fa02596c080617f68088c11ab66c"
},
"Makefile": {
"bytes": 6229,
"sha256": "f1185b69995f8a9e6be21457eea0ea5ca8dcfc5f132e77a2e49069e81c572d33"
},
"source/main.c": {
"bytes": 12987,
"sha256": "8f757309fa142b3d3a4b369a48c20a967958182c22a643456d790549a46bfce4"
},
"source/graphics.h": {
"bytes": 5305,
"sha256": "0ee9e111ea484e8e4062693e5e35c32117981968ee32eafc5f3bab1d3925bfea"
},
"source/characters.h": {
"bytes": 11247,
"sha256": "216a7457764b180eeeba239526646e0e658c8a5283b7025cc8e4da4ed62a9c1d"
},
"Pong-Homebrew-GBA.gba": {
"bytes": 8884,
"sha256": "3c850319ddef6d237756dd64b85ddf3b75bf7c096feb2408ed1b2dd0a05a0158"
}
}
}

View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Prepare hash-pinned public Pong gameplay checks outside tracked paths.
Only original adapter logic is included here. Downloaded source, assets, ROM,
and license stay in the requested local directory. A successful preparation
is not a compatibility verdict. Run the generated suite separately.
"""
import argparse
import json
from pathlib import Path
import sys
from prepare_gba_tests import MAX_FILE_BYTES, destination, fetch, verify
LOCK = Path(__file__).with_name("homebrew-pong.lock.json")
def manifest(rom):
"""Sparse assertions derived from the pinned source, not emulator screenshots."""
white, blue, green = 0xffffff, 0x0000ff, 0x00ff00
def pixel(x, y, color):
return {"kind": "pixel", "x": x, "y": y, "equals": color}
def inputs(*pairs):
return [{"vblank": frame, "buttons": buttons} for frame, buttons in pairs]
def case(name, end, events, checks):
return {"name": f"pong-{name}", "rom": rom, "step_limit": 3_000_000,
"completion": {"vblanks": end}, "inputs": events, "checks": checks}
start = inputs((30, 8), (32, 0)) # Start, release
move = start + inputs((160, 64), (170, 0)) # Up, release after initial pause
menu = inputs((30, 128), (32, 0), (40, 1), (42, 0)) # Down, A
return {"version": 2, "cases": [
case("menu", 30, [], [pixel(81, 64, white), pixel(74, 88, white), pixel(74, 100, 0)]),
case("settings", 50, menu, [pixel(81, 64, 0), pixel(84, 64, white),
pixel(74, 88, 0), pixel(74, 100, white)]),
case("back", 70, menu + inputs((60, 8), (62, 0)),
[pixel(81, 64, white), pixel(74, 100, white)]),
case("start", 40, start, [pixel(2, 70, blue), pixel(232, 70, blue),
pixel(118, 77, green), pixel(120, 2, white)]),
case("ball-motion", 160, start, [pixel(118, 77, 0), pixel(135, 95, green)]),
case("up", 175, move, [pixel(2, 50, blue), pixel(2, 90, 0)]),
case("release", 185, move, [pixel(2, 50, blue), pixel(2, 90, 0)]),
case("down", 205, move + inputs((190, 128), (200, 0)),
[pixel(2, 50, 0), pixel(2, 70, blue)]),
]}
def prepare(output, source=None, download=fetch):
output = destination(output)
lock = json.loads(LOCK.read_text(encoding="utf-8"))
base = f"https://raw.githubusercontent.com/{lock['repository']}/{lock['revision']}/"
files = {}
for name, expected in lock["files"].items():
if source is None:
data = download(base + name)
else:
path = Path(source) / name
if not path.is_file() or path.stat().st_size > MAX_FILE_BYTES:
raise ValueError(f"source must be a bounded regular file: {name}")
with path.open("rb") as stream:
data = stream.read(MAX_FILE_BYTES + 1)
verify(name, data, expected)
files[name] = data
# Verify the full pinned set before creating output. Never reuse an existing
# directory or overwrite files. Disk errors can leave partial output.
output.mkdir()
for name, data in files.items():
path = output / name
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("xb") as stream:
stream.write(data)
for name, value in [("suite.json", manifest(lock["rom"])), ("source-lock.json", lock)]:
with (output / name).open("x", encoding="utf-8") as stream:
json.dump(value, stream, indent=2)
stream.write("\n")
return output / "suite.json"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output", type=Path, help="new directory outside the repository or under roms/")
parser.add_argument("--source-dir", type=Path, help="offline upstream files matching every pinned hash")
args = parser.parse_args()
try:
path = prepare(args.output, args.source_dir)
except (OSError, ValueError) as error:
print(f"cannot prepare homebrew Pong: {error}", file=sys.stderr)
return 1
print(f"Prepared pinned Pong suite: {path}")
print("Run with gameboy-rust --test-suite PATH. Preparation does not mean the tests passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,114 @@
"""Offline adapter tests. All fixture bytes are original synthetic data."""
import hashlib
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import prepare_homebrew_pong as adapter
from prepare_gba_tests import ROOT, MAX_FILE_BYTES
class PongPreparation(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
self.output = self.base / "prepared"
self.source = self.base / "source"
self.source.mkdir()
real = json.loads(adapter.LOCK.read_text())
self.files = {name: f"original fixture: {name}\n".encode() for name in real["files"]}
self.lock = dict(real, files={name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
for name, data in self.files.items()})
self.lock_path = self.base / "lock.json"
self.lock_path.write_text(json.dumps(self.lock))
self.addCleanup(patch.stopall)
patch.object(adapter, "LOCK", self.lock_path).start()
for name, data in self.files.items():
path = self.source / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def test_offline_preparation_retains_license_and_is_repeatable(self):
first = adapter.prepare(self.output, self.source)
second = adapter.prepare(self.base / "second", self.source)
self.assertEqual(first.read_bytes(), second.read_bytes())
self.assertEqual(json.loads(first.read_text()), adapter.manifest(self.lock["rom"]))
self.assertEqual(json.loads((self.output / "source-lock.json").read_text()), self.lock)
for name, data in self.files.items():
self.assertEqual((self.output / name).read_bytes(), data)
with self.assertRaisesRegex(ValueError, "already exists"):
adapter.prepare(self.output, self.source)
self.assertEqual((self.output / "LICENSE").read_bytes(), self.files["LICENSE"])
def test_download_uses_only_pinned_revision_urls(self):
base = f"https://raw.githubusercontent.com/{self.lock['repository']}/{self.lock['revision']}/"
calls = []
def download(url):
self.assertTrue(url.startswith(base))
calls.append(url.removeprefix(base))
return self.files[calls[-1]]
adapter.prepare(self.output, download=download)
self.assertEqual(calls, list(self.files))
def test_bad_hash_size_missing_and_oversized_files_leave_no_output(self):
rom = self.source / self.lock["rom"]
original = rom.read_bytes()
for replacement in [bytes(len(original)), original + b"x", b"x" * (MAX_FILE_BYTES + 1)]:
rom.write_bytes(replacement)
with self.assertRaises(ValueError):
adapter.prepare(self.output, self.source)
self.assertFalse(self.output.exists())
rom.unlink()
with self.assertRaises(ValueError):
adapter.prepare(self.output, self.source)
self.assertFalse(self.output.exists())
def test_network_failure_leaves_no_output(self):
def fail(_):
raise OSError("offline")
with self.assertRaises(OSError):
adapter.prepare(self.output, download=fail)
self.assertFalse(self.output.exists())
def test_tracked_and_symlinked_destinations_are_rejected_before_fetch(self):
def fail(_):
self.fail("must not fetch for unsafe destination")
with self.assertRaises(ValueError):
adapter.prepare(ROOT / "tracked-pong-test", download=fail)
link = self.base / "linked"
link.symlink_to(ROOT, target_is_directory=True)
with self.assertRaises(ValueError):
adapter.prepare(link / "tracked-pong-test", download=fail)
with self.assertRaises(ValueError):
adapter.prepare(self.base / "missing" / "out", download=fail)
def test_manifest_has_bounded_distinct_scenarios_and_complete_button_snapshots(self):
manifest = adapter.manifest("test.gba")
self.assertEqual(manifest["version"], 2)
cases = manifest["cases"]
self.assertEqual(len({case["name"] for case in cases}), len(cases))
self.assertLessEqual(sum(case["step_limit"] for case in cases), 100000000)
for case in cases:
self.assertEqual(case["rom"], "test.gba")
frames = [event["vblank"] for event in case["inputs"]]
self.assertEqual(frames, sorted(set(frames)))
self.assertTrue(all(frame < case["completion"]["vblanks"] for frame in frames))
for event in case["inputs"]:
self.assertLessEqual(event["buttons"], 1023)
if case["inputs"]:
self.assertEqual(case["inputs"][-1]["buttons"], 0)
self.assertTrue(case["checks"])
for check in case["checks"]:
self.assertEqual(check["kind"], "pixel")
self.assertLess(check["x"], 240)
self.assertLess(check["y"], 160)
self.assertLessEqual(check["equals"], 0xffffff)
if __name__ == "__main__":
unittest.main()