Files
psxrecomp/tools/tests/test_cli_rebuild_host.py
2026-10-11 23:30:47 -07:00

171 lines
6.9 KiB
Python

"""psxrecomp_cli.py rebuild: host portability and configure-flag precedence.
No cmake, compiler or game runs: configure/build are replaced by recorders, so
these cases pin what the CLI asks CMake for, and that the pre-build mtime clamp
works on hosts without lutimes (Windows CPython).
"""
import os
from pathlib import Path
import sys
import tempfile
import time
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 ClampFutureMtimesTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.future = time.time() + 3600
def tearDown(self):
self.tmp.cleanup()
def touch_future(self, rel):
path = self.root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('x')
os.utime(path, (self.future, self.future))
return path
def test_clamps_without_lutimes(self):
# Windows CPython: os.utime is absent from supports_follow_symlinks and
# follow_symlinks=False raises NotImplementedError, not OSError.
log = self.touch_future('build-release.log')
with mock.patch.object(cli, '_UTIME_NOFOLLOW', False):
self.assertEqual(cli.clamp_future_mtimes(self.root), 1)
self.assertLessEqual(log.stat().st_mtime, time.time())
def test_skips_build_dir(self):
self.touch_future('build-release/out.o')
src = self.touch_future('src/a.c')
self.assertEqual(
cli.clamp_future_mtimes(self.root, skip=self.root / 'build-release'), 1)
self.assertLessEqual(src.stat().st_mtime, time.time())
def test_never_retimes_symlink_target_without_lutimes(self):
outside = tempfile.TemporaryDirectory()
self.addCleanup(outside.cleanup)
target = Path(outside.name) / 'target.txt'
target.write_text('x')
os.utime(target, (self.future, self.future))
try:
os.symlink(target, self.root / 'link.txt')
except (OSError, NotImplementedError):
self.skipTest('symlinks unavailable on this host')
with mock.patch.object(cli, '_UTIME_NOFOLLOW', False):
self.assertEqual(cli.clamp_future_mtimes(self.root), 0)
self.assertGreater(target.stat().st_mtime, time.time())
class RebuildConfigureFlagsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
(self.root / 'game.toml').write_text('[game]\nid = "TEST-00001"\n')
self.configures = []
def tearDown(self):
self.tmp.cleanup()
def rebuild(self, extra, pgo=False):
args = cli.build_parser().parse_args(
['rebuild', '--config', str(self.root / 'game.toml'),
'--project-root', str(self.root), '--build-dir', 'build-x',
*(['--force-pgo', '--disc', str(self.root / 'game.toml')] if pgo
else ['--no-pgo']),
*[f'--cmake-extra={e}' for e in extra]])
exe = self.root / 'build-x' / 'psx-runtime.exe'
def configure(root, build_dir, pgo, extra, progress):
self.configures.append((pgo, list(extra)))
with mock.patch.object(cli, 'activate_embedded_toolchain', return_value=True), \
mock.patch.object(cli, '_cmake_configure', side_effect=configure), \
mock.patch.object(cli, '_cmake_build'), \
mock.patch.object(cli, 'run_pgo_train'), \
mock.patch.object(cli, '_resolve_runtime_exe', return_value=(exe, '')), \
mock.patch.object(cli, 'stage_overlay_toolchain_for_product'):
self.assertEqual(cli.cmd_rebuild(args, mock.Mock()), cli.EXIT_OK)
@staticmethod
def debug_tools(extra):
values = [e.split('=', 1)[1] for e in extra if e.startswith('-DPSX_DEBUG_TOOLS=')]
return values[-1] # CMake applies the last -D for a name
def test_shipping_build_defaults_debug_tools_off(self):
self.rebuild([])
self.assertEqual([self.debug_tools(e) for _, e in self.configures], ['OFF'])
def test_cmake_extra_overrides_debug_tools(self):
self.rebuild(['-DPSX_DEBUG_TOOLS=ON'])
self.assertEqual([self.debug_tools(e) for _, e in self.configures], ['ON'])
def test_pgo_instrument_stage_keeps_debug_tools_on(self):
# PGO training drives the debug server; a user OFF must not break it,
# while the optimized stage still honours the user's choice.
self.rebuild(['-DPSX_DEBUG_TOOLS=OFF'], pgo=True)
self.assertEqual([(p, self.debug_tools(e)) for p, e in self.configures],
[('generate', 'ON'), ('use', 'OFF')])
class CmakeBuildToolTest(unittest.TestCase):
"""Build must run the cmake that configured the tree, never a PATH cmake.
On Windows a PATH ``cmake`` is often MSYS2's, which rewrites the Windows
build directory as ``/f/.../F:/...`` and fails.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.build = Path(self.tmp.name) / 'build-release'
self.build.mkdir()
self.configured = Path(self.tmp.name) / 'toolchain' / 'bin' / 'cmake.exe'
self.configured.parent.mkdir(parents=True)
self.configured.write_text('')
def tearDown(self):
self.tmp.cleanup()
def build_cmd(self):
calls = []
def run(cmd, **_):
calls.append(cmd)
return mock.Mock(returncode=0, stdout='', stderr='')
with mock.patch.object(cli.subprocess, 'run', side_effect=run):
cli._cmake_build(self.build, 'psx-runtime', mock.Mock())
return calls[0]
def test_uses_cmake_command_from_cache(self):
(self.build / 'CMakeCache.txt').write_text(
'CMAKE_GENERATOR:INTERNAL=Ninja\n'
f'CMAKE_COMMAND:INTERNAL={self.configured}\n')
with mock.patch.object(cli, '_which_tool', return_value=Path('/msys/usr/bin/cmake')):
self.assertEqual(self.build_cmd()[0], str(self.configured))
def test_falls_back_to_path_without_cache(self):
with mock.patch.object(cli, '_which_tool', return_value=Path('/usr/bin/cmake')):
self.assertEqual(self.build_cmd()[0], str(Path('/usr/bin/cmake')))
def test_stale_cache_entry_falls_back_to_path(self):
(self.build / 'CMakeCache.txt').write_text(
f'CMAKE_COMMAND:INTERNAL={self.configured.parent / "gone.exe"}\n')
with mock.patch.object(cli, '_which_tool', return_value=Path('/usr/bin/cmake')):
self.assertEqual(self.build_cmd()[0], str(Path('/usr/bin/cmake')))
def test_no_cmake_anywhere_is_an_error(self):
with mock.patch.object(cli, '_which_tool', return_value=None):
with self.assertRaises(RuntimeError):
cli._cmake_build(self.build, 'psx-runtime', mock.Mock())
if __name__ == '__main__':
unittest.main()