mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
bc5ec35998
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
253 lines
12 KiB
Python
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()
|