Files
virtualjaguar-libretro/scripts/test_check_site_cheats.py
Joe MattielloandClaude Opus 5.5 caca12ad71 feat(cheats): CI-generated community cheat packs on the project site (#1071, part 1) (#1073)
* docs(cheats): community cheat store design and plan (#1071)

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(cheats): map printf_notify to app toasts

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(cheat-import): BigPEmu API map; int_full settings omitted; printf_notify ignored

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(cheat-import): refuse side effects in message args; scope Unsupported.call to API gaps

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(cheat-import): committed cartridge identity table (hashes only)

scan_cartridges.py walks a ROM directory (read-only) and writes a sorted
FNV1a64 -> CRC32 table with size, title and name; no ROM bytes or paths.
load_cartridges rejects duplicate fnv/crc, bad hex and format != 1. The
generated cartridges.json (152 rows) lets CI map community script hashes
to the CRC32 the app binds packs to without any ROMs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(cheat-import): community cheat pack generator (#1071)

build_packs.py converts every pinned community C cheat source with convert.py
and writes the site/cheats tree: per-cartridge .cht packs (one per CRC32, sources
merged, identical cheats deduped, same-name cheats suffixed), manifest.json,
the unsupported.json worklist with per-call API usage, index.html and README.
Deterministic, bounded fetch of one codeload tarball per source, --source-dir for
local checkouts, --summary and --bump for the bump workflow. sources.json pins
jguffer/BigPEmu_Scripts at 2421150.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* data(cheats): generate community packs from jguffer/BigPEmu_Scripts@2421150

23 cartridge packs (72 cheats, 9 partial) plus the unsupported.json worklist
(55 entries), manifest and index, generated with build_packs.py from the pinned
codeload tarball; byte-identical to a local checkout run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(cheat-import): worklist unmatched literals beside a hit; bound fetch errors and member count

- identity row for table-missing literals in a file that also has a hit
- http.client.HTTPException during fetch is a FetchError (exit 2)
- tar expansion cap counts a header block per member and caps member count
- Collector uses the api_map given to build()

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* data(cheats): regenerate for generator fix (unmatched-literal identity rows)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(site): publish community cheat packs under /cheats/

build_site.py copies the committed site/cheats tree verbatim; check_site.py
gains check_cheats (manifest format/sha256/bytes/CRC guard, unlisted .cht,
unsupported.json, index.html links, 256 KiB manifest and 1 MiB tree limits)
with unit tests. pages.yml watches site/cheats and tools/cheat-import. The
converter README now documents the author's permission, the credit, the
sources -> cartridges -> packs workflow and how to add a source; the
community_test harness and its fixtures are removed (the committed packs and
the regeneration diff are the conversion contract).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci(cheats): regen-diff job, concurrency, weekly pin-bump PR

cheat-import.yml: add site/cheats/** to the path filters, a per-ref
concurrency group, drop the community_test.py fetch step (the files are
gone), and add a packs job that regenerates twice, diffs the runs and the
committed tree, flags unmapped BigPEmu calls, then runs the site-check tests
and build_site + check_site.

cheat-packs-bump.yml: weekly (Mon 06:23 UTC) poll of each pinned github
source; on movement, bump the pin, regenerate, validate, force-push the fixed
branch cheats/pin-bump and open or refresh a develop PR (validation failures
head the body instead of being dropped), then dispatch cheat-import.yml on the
branch because GITHUB_TOKEN PRs do not fire pull_request workflows.

test_committed_sources no longer pins the commit/date value: every bump PR
would otherwise fail the unit tests; only the shape is asserted.

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci(cheats): gate bump workflow to the upstream repo, drop bot trailer

Only libretro/virtualjaguar-libretro opens bump PRs (the Provenance-Emu
fork runs crons too), with a 30 minute job timeout. The bot-authored bump
commit no longer carries a Claude co-author trailer.

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci(cheats): pin pycparser, force LF, add job timeouts

Pin pycparser==3.11 (newest release that installs on the Python 3.10 CI leg;
2.22, 2.23, 3.0, 3.11 and 3.12 all regenerate byte-identical packs). Add a
root .gitattributes forcing LF for the generator and generated tree so pack
bytes cannot drift by checkout. Give both jobs a 20 minute timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(cheats): dedupe pack header title, refuse control-character paths

The pack's first line printed the title twice when the cartridge name began
with the title. Print the name alone in that case. Fetch and local-dir reads
now fail closed on any path containing a control character, since names can
reach ::warning:: annotations and PR bodies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* data(cheats): regenerate packs with deduplicated header title

Line 1 of each pack no longer repeats the title when the cartridge name
already starts with it. Regenerated from the pinned source commit; two runs
and a --source-dir run are byte-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci(cheats): even indentation on a bump-workflow continuation line

editorconfig-checker wants multiples of 2 in workflow files.

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-23 02:45:05 -04:00

122 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Unit tests for check_site.check_cheats (stdlib only)."""
import hashlib
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import check_site # noqa: E402
REPO = Path(__file__).resolve().parent.parent
CHT = ('cheats = 1\n\nvj_rom_crc32 = "0509C85E"\n'
'cheat0_desc = "x"\ncheat0_enable = false\n').encode()
def build(root):
"""A minimal valid <root>/cheats tree."""
c = root / "cheats"
(c / "packs").mkdir(parents=True)
(c / "packs" / "0509C85E.cht").write_bytes(CHT)
manifest = {"format": 1, "packs": [{
"file": "packs/0509C85E.cht", "crc32": "0509C85E",
"sha256": hashlib.sha256(CHT).hexdigest(), "bytes": len(CHT),
"messages": ["hello"]}]}
(c / "manifest.json").write_text(json.dumps(manifest))
(c / "unsupported.json").write_text("{}")
(c / "index.html").write_text(
'<a href="packs/0509C85E.cht">p</a><a href="https://x.test/">x</a>')
return c
class CheckCheats(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.tmp, True)
self.c = build(self.tmp)
def problems(self):
return check_site.check_cheats(self.tmp)
def rewrite_manifest(self, fn):
p = self.c / "manifest.json"
m = json.loads(p.read_text())
fn(m)
p.write_text(json.dumps(m))
def test_absent_is_skipped(self):
self.assertEqual(check_site.check_cheats(self.tmp / "nope"), [])
def test_valid(self):
self.assertEqual(self.problems(), [])
def test_corrupt_pack_names_file(self):
f = self.c / "packs" / "0509C85E.cht"
b = bytearray(f.read_bytes())
b[-3] ^= 1
f.write_bytes(bytes(b))
out = self.problems()
self.assertTrue(any("0509C85E.cht" in p and "sha256" in p for p in out), out)
def test_wrong_size(self):
self.rewrite_manifest(lambda m: m["packs"][0].update(bytes=1))
self.assertTrue(any("bytes" in p for p in self.problems()))
def test_crc_mismatch(self):
self.rewrite_manifest(lambda m: m["packs"][0].update(crc32="DEADBEEF"))
self.assertTrue(any("vj_rom_crc32" in p for p in self.problems()))
def test_missing_pack(self):
(self.c / "packs" / "0509C85E.cht").unlink()
self.assertTrue(any("missing" in p for p in self.problems()))
def test_unlisted_cht(self):
(self.c / "packs" / "AAAAAAAA.cht").write_bytes(CHT)
self.assertTrue(any("AAAAAAAA.cht" in p for p in self.problems()))
def test_bad_format(self):
self.rewrite_manifest(lambda m: m.update(format=2))
self.assertTrue(any("format" in p for p in self.problems()))
def test_manifest_not_json(self):
(self.c / "manifest.json").write_text("{")
self.assertTrue(any("does not parse" in p for p in self.problems()))
def test_unsupported_not_json(self):
(self.c / "unsupported.json").write_text("{")
self.assertTrue(any("unsupported.json" in p for p in self.problems()))
def test_broken_index_link(self):
(self.c / "index.html").write_text('<a href="packs/NOPE.cht">p</a>')
self.assertTrue(any("NOPE.cht" in p for p in self.problems()))
def test_messages_must_be_strings(self):
self.rewrite_manifest(lambda m: m["packs"][0].update(messages=[1]))
self.assertTrue(any("messages" in p for p in self.problems()))
def test_messages_optional(self):
self.rewrite_manifest(lambda m: m["packs"][0].pop("messages"))
self.assertEqual(self.problems(), [])
def test_manifest_too_big(self):
self.rewrite_manifest(lambda m: m.update(pad="x" * (256 * 1024)))
self.assertTrue(any("manifest.json is" in p for p in self.problems()))
def test_tree_too_big(self):
(self.c / "packs" / "pad.bin").write_bytes(b"\0" * (1024 * 1024))
self.assertTrue(any("tree is" in p for p in self.problems()))
def test_committed_tree_is_valid(self):
stage = self.tmp / "real"
stage.mkdir()
shutil.copytree(REPO / "site" / "cheats", stage / "cheats")
(stage / "app.html").write_text("") # index.html links ../app.html
self.assertEqual(check_site.check_cheats(stage), [])
if __name__ == "__main__":
unittest.main()