Files
psxrecomp/tools/tests/test_cli_generate_aot_static.py
Matthew Stanley bc5ec35998 cli: generate builds the static overlay shard an AOT profile declares
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 10:28:14 -07:00

253 lines
12 KiB
Python

"""psxrecomp_cli.py generate builds the static overlay shard a title declares.
No disc, emitter, compiler or pipeline process is used: the pipeline process is
replaced by a fake that records its command line and replays scripted output,
so these cases pin the CLI's contract -- when it runs the pipeline, with what,
and how every outcome is reported -- not the pipeline's own behaviour (that is
tools/tests/test_aot_overlay_pipeline.py).
"""
import argparse
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / 'tools')]
import psxrecomp_cli as cli # noqa: E402
class FakePipeline:
"""Stands in for subprocess.Popen of tools/aot_overlay_pipeline.py."""
def __init__(self, lines, returncode, calls):
self.lines, self.returncode, self.calls = lines, returncode, calls
def __call__(self, cmd, **kwargs):
self.calls.append((cmd, kwargs))
work = Path(cmd[cmd.index('--work-dir') + 1])
assert work.is_dir(), 'the CLI hands the pipeline an existing private work dir'
(work / 'static-compile.log').write_text('evidence\n')
process = mock.Mock()
process.stdout = iter(line + '\n' for line in self.lines)
process.wait.return_value = self.returncode
return process
class RunAotStaticTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
(self.root / 'disc').mkdir()
self.cue = self.root / 'disc' / 'game.cue'
self.cue.write_text('FILE "game.bin" BINARY\n')
self.config = self.root / 'game.toml'
self.config.write_text('[game]\nid = "TEST-00001"\n')
self.marker = self.root / 'generated' / 'GAME_000.00_dispatch.c'
self.marker.parent.mkdir()
self.marker.write_text('static void psx_cps_mark_game(void) {}\n')
self.progress = mock.Mock()
self.calls = []
def tearDown(self):
self.tmp.cleanup()
def profile(self, **fields):
(self.root / 'aot').mkdir(exist_ok=True)
(self.root / 'aot' / 'overlays.json').write_text(json.dumps(
dict(schema='psxrecomp AOT methods v1', game_id='TEST-00001', **fields)))
def run_step(self, lines=('RESULT_STATIC=built x',), returncode=0, *, working_disc=None,
game=None, matched=True, **options):
fake = FakePipeline(list(lines), returncode, self.calls)
with mock.patch.object(cli.subprocess, 'Popen', fake), \
mock.patch.object(cli, 'find_psxrecomp_game', return_value=Path('/emit/psxrecomp-game')), \
mock.patch.object(cli, 'resolve_embedded_toolchain_bin', return_value=None), \
mock.patch.object(cli, '_which_tool', side_effect=lambda name: Path(f'/bin/{name}')), \
mock.patch.object(cli, 'overlay_compiler', return_value=Path('/bin/clang')):
return cli.run_aot_static(
self.root, self.config, game=game or {}, working_disc=working_disc or self.cue,
marker=self.marker, disc_matched_known=matched, progress=self.progress, **options)
def command(self):
self.assertEqual(len(self.calls), 1)
return self.calls[0][0]
def logged(self):
return '\n'.join(str(c.args[0]) for c in self.progress.log.call_args_list)
def test_no_profile_or_no_declaration_runs_nothing(self):
self.assertEqual(self.run_step(), 'none')
self.profile(images=[])
self.assertEqual(self.run_step(), 'none')
self.assertIn('declares no static_output', self.logged())
self.assertEqual(self.calls, [])
self.progress.error.assert_not_called()
def test_declared_shard_runs_the_framework_pipeline_with_resolved_tools(self):
self.profile(static_output='generated/overlays_static.c')
self.assertEqual(self.run_step(), 'built')
cmd = self.command()
self.assertEqual(cmd[0], sys.executable)
self.assertEqual(Path(cmd[1]), cli.framework_root(self.root) / 'tools' / 'aot_overlay_pipeline.py')
self.assertEqual(cmd[2], 'static')
value = lambda flag: cmd[cmd.index(flag) + 1]
self.assertEqual(Path(value('--profile')), self.root / 'aot' / 'overlays.json')
self.assertEqual(Path(value('--game-toml')), self.config)
self.assertEqual(Path(value('--disc')), self.cue)
self.assertEqual(Path(value('--recompiler')), Path('/emit/psxrecomp-game'))
self.assertEqual(Path(value('--gcc')), Path('/bin/clang'))
self.assertEqual(Path(value('--cmake')), Path('/bin/cmake'))
self.assertGreater(int(value('--workers')), 0)
for flag in ('--cps', '--reuse', '--clear-if-not-applicable'):
self.assertIn(flag, cmd)
self.assertNotIn('--out-dir', cmd, 'the profile, not the CLI, names the destination')
self.assertEqual(self.calls[0][1]['cwd'], str(self.root))
# A successful run leaves no evidence behind.
self.assertFalse(Path(value('--work-dir')).exists())
self.progress.event.assert_called_with('aot_static', status='built')
def test_reuse_status_and_force_and_legacy_codegen(self):
self.profile(static_output='generated/overlays_static.c')
self.assertEqual(self.run_step(['Reused 3 ...', 'RESULT_STATIC=reused x']), 'reused')
self.calls.clear()
self.marker.write_text('/* legacy dispatch */\n')
self.run_step(force=True)
cmd = self.command()
self.assertNotIn('--reuse', cmd)
self.assertNotIn('--cps', cmd, 'overlay C must match the generated game C contract')
def test_disc_cue_falls_back_to_the_configured_cue(self):
self.profile(static_output='generated/overlays_static.c')
chd = self.root / 'disc' / 'game.chd'
chd.write_bytes(b'')
self.run_step(working_disc=chd, game={'disc': 'disc/game.cue'})
cmd = self.command()
self.assertEqual(Path(cmd[cmd.index('--disc') + 1]), self.cue.resolve())
self.calls.clear()
self.assertIsNone(self.run_step(working_disc=chd, game={}))
self.assertEqual(self.calls, [])
self.assertIn('cue/bin', self.progress.error.call_args.args[0])
def test_pipeline_failure_is_an_error_that_keeps_the_evidence(self):
self.profile(static_output='generated/overlays_static.c')
lines = ['Static AOT compilation failed; see x.log',
'aot_overlay_pipeline: error: Loader evidence changed: SCES 0x1']
self.assertIsNone(self.run_step(lines, 1))
message = self.progress.error.call_args.args[0]
self.assertIn('exit 1', message)
self.assertIn('Loader evidence changed', message)
work = Path(self.command()[self.command().index('--work-dir') + 1])
self.assertIn(str(work), message)
self.assertTrue((work / 'static-compile.log').is_file())
self.assertEqual(self.progress.error.call_args.kwargs['code'], cli.EXIT_ERROR)
def test_not_applicable_disc(self):
self.profile(static_output='generated/overlays_static.c')
line = ['aot_overlay_pipeline: not applicable: the AOT profile for TEST-00001 '
'describes the disc whose data track has sha256 aa, but game.cue has bb']
# A disc that matched [prepare_disc]'s own digests: the title contradicts itself.
self.assertIsNone(self.run_step(line, 3, matched=True))
self.assertIn('disagree', self.progress.error.call_args.args[0])
self.assertIn('sha256 aa', self.progress.error.call_args.args[0])
# An unverified dump: reported loudly, Generate still succeeds.
self.progress.reset_mock()
self.assertEqual(self.run_step(line, 3, matched=False), 'not_applicable')
self.progress.error.assert_not_called()
warning = [c for c in self.progress.log.call_args_list if c.kwargs.get('level') == 'warning']
self.assertEqual(len(warning), 1)
self.assertIn('sha256 aa', warning[0].args[0])
self.progress.event.assert_called_with('aot_static', status='not_applicable',
detail=mock.ANY)
def test_explicit_opt_out_is_loud(self):
self.profile(static_output='generated/overlays_static.c')
self.assertEqual(self.run_step(skip=True), 'skipped')
self.assertEqual(self.calls, [])
warning = self.progress.log.call_args_list[-1]
self.assertEqual(warning.kwargs.get('level'), 'warning')
self.assertIn('interpreter', warning.args[0])
def test_unreadable_profile_is_an_error(self):
(self.root / 'aot').mkdir()
(self.root / 'aot' / 'overlays.json').write_text('{ not json')
self.assertIsNone(self.run_step())
self.assertIn('cannot read AOT profile', self.progress.error.call_args.args[0])
class HelpersTest(unittest.TestCase):
def test_disc_matched_known_digests(self):
args = argparse.Namespace(skip_hash_check=False)
known = dict(known_md5=['x'])
self.assertTrue(cli.disc_matched_known_digests(dict(verified=True), known, args))
self.assertFalse(cli.disc_matched_known_digests(dict(verified=True), {}, args),
'no declared digests is not a known disc')
self.assertFalse(cli.disc_matched_known_digests(None, known, args))
self.assertFalse(cli.disc_matched_known_digests(dict(verified=False), known, args))
self.assertFalse(cli.disc_matched_known_digests(
dict(verified=True), known, argparse.Namespace(skip_hash_check=True)))
class GenerateWiringTest(unittest.TestCase):
"""cmd_generate runs the shard step after the game C and gates on it."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
(self.root / 'disc').mkdir()
(self.root / 'disc' / 'GAME_000.00').write_bytes(b'PS-X EXE')
self.cue = self.root / 'disc' / 'game.cue'
self.cue.write_text('FILE "game.bin" BINARY\n')
(self.root / 'game.toml').write_text(
'[game]\nid = "TEST-00001"\nexe = "disc/GAME_000.00"\ndisc = "disc/game.cue"\n'
'[prepare_disc]\nout_dir = "disc"\nboot_exe = "GAME_000.00"\n')
(self.root / 'generated').mkdir()
(self.root / 'generated' / 'GAME_000.00_dispatch.c').write_text('/* dispatch */\n')
def tearDown(self):
self.tmp.cleanup()
def generate(self, aot_result):
args = argparse.Namespace(
config=str(self.root / 'game.toml'), project_root=str(self.root), disc=str(self.cue),
skip_hash_check=False, force_prepare=False, gen_marker='', force_emitters=False,
no_toolchain_download=True, bios='', force_bios=False, force_aot_static=True,
no_aot_static=False)
progress = mock.Mock()
events = []
def emitter(cmd, **kwargs):
events.append('psxrecomp-game')
return mock.Mock(returncode=0, stdout='', stderr='')
def aot(project_root, config, **kwargs):
events.append(('aot', kwargs['marker'].name, kwargs['working_disc'], kwargs['force']))
return aot_result
replacements = dict(
activate_embedded_toolchain=lambda *a, **k: True,
verify_disc_path=lambda *a, **k: dict(verified=True),
ensure_framework=lambda *a, **k: self.root / 'psxrecomp',
ensure_emitters=lambda *a, **k: (Path('game'), Path('bios')),
find_emitters=lambda *a, **k: (Path('game'), Path('bios')),
bios_backend_present=lambda *a, **k: True,
run_aot_static=aot)
with mock.patch.multiple(cli, **replacements), \
mock.patch.object(cli.subprocess, 'run', side_effect=emitter):
code = cli.cmd_generate(args, progress)
return code, progress, events
def test_shard_step_follows_game_c_and_reports_its_status(self):
code, progress, events = self.generate('built')
self.assertEqual(code, cli.EXIT_OK)
self.assertEqual(events, ['psxrecomp-game', ('aot', 'GAME_000.00_dispatch.c', self.cue, True)])
self.assertEqual(progress.result.call_args.kwargs['aot_static'], 'built')
def test_shard_failure_fails_generate(self):
code, progress, events = self.generate(None)
self.assertEqual(code, cli.EXIT_ERROR)
progress.result.assert_not_called()
if __name__ == '__main__':
unittest.main()