Files
totk-pc-recomp/tests/installer_test.py
T
Valiantsin DzerakhandClaude Opus 5.5 a76224f8a6 Fail the install when romfs/ is gone or unreadable after cleanup
An install whose romfs/ went empty and unreadable after the extraction had verified it (a folder Windows was still
deleting) ended with result ok, and the game stopped at its first file read. Cleanup now checks that romfs.bin and
index.json are still there, match the verified size and hash record, and that romfs.bin opens; otherwise it fails with
cleanup.romfs_unreadable, which tells the player to restart Windows and run the installer on the same folder (resume
extracts the game data again). Reported in #107.

tests/installer_test.py CleanupTest: an emptied, removed or resized romfs passed cleanup before, fails now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:42:03 +03:00

325 lines
15 KiB
Python

"""installer/ bookkeeping with fake steps: fresh install, resume after a failure or cancel, transient steps, update
with a newer release, totk.ini and the event stream. No game files, no tools; stdlib only.
Run: python tests/installer_test.py
"""
import io
import json
import re
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from installer import ini, runner, state, steps # noqa: E402
from installer.events import Reporter # noqa: E402
from installer.layout import Layout # noqa: E402
class Fake(runner.Step):
"""A step whose output is <dir>/<id>.out holding its key; dir is the temp folder for a transient step."""
def __init__(self, id, transient=False, always=False, previous=None):
self.id, self.transient, self.always, self.previous = id, transient, always, previous
def out(self, ctx):
return (ctx.temp if self.transient else ctx.dest) / f'{self.id}.out'
def key(self, ctx):
upstream = ctx.keys[self.previous] if self.previous else ''
return f'{self.id}:{ctx.options.inputs.get(self.id, "v1")}:{upstream}'
def current(self, ctx, record):
p = self.out(ctx)
return p.is_file() and p.read_text() == ctx.keys[self.id]
def verify(self, ctx, record):
ctx.options.verified.append(self.id)
return ctx.options.verify_ok.get(self.id, True)
def run(self, ctx):
o = ctx.options
o.ran.append(self.id)
if self.id in ctx.forced:
o.forced.append(self.id)
if self.id == 'check':
ctx.verify_kept(self.id)
failure = o.fail.pop(self.id, None)
if failure == 'error':
raise runner.InstallError('fake.failed', step=self.id)
if failure == 'cancel':
raise KeyboardInterrupt
if self.id == 'cleanup':
for p in ctx.temp.glob('*.out'):
p.unlink()
return {}
p = self.out(ctx)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(ctx.keys[self.id])
return {'note': self.id}
def fake_steps():
return [Fake('check', always=True), Fake('extract'), Fake('lift', transient=True, previous='extract'),
Fake('compile', previous='lift'), Fake('prepare', previous='compile'), Fake('cleanup', always=True)]
class Bed:
"""A dest and temp folder plus one run of the fake steps per call."""
def __init__(self, base):
self.dest, self.temp = base / 'dest', base / 'temp'
self.temp.mkdir()
def run(self, release='1.0', inputs=None, fail=None, verify_ok=None, verify=False):
layout = Layout(root=Path('.'), dev=True, release=release, python=Path('python'), tools=Path('tools'),
recomp=Path('recomp'), llvm=Path('llvm'),
deps=Path('deps'), runtime=Path('rt'), runtime_lib=Path('lib'), ninja=Path('ninja'),
hactool=Path('hactool'), xlate=Path('xlate'))
options = SimpleNamespace(inputs=inputs or {}, fail=dict(fail or {}), verify_ok=verify_ok or {}, ran=[],
verified=[], forced=[])
out = io.StringIO()
ctx = runner.Context(layout, self.dest, self.temp, Reporter(out=out), options, state=state.load(self.dest),
verify=verify)
mode = ctx.mode
result = runner.run_steps(fake_steps(), ctx)
events = [json.loads(line) for line in out.getvalue().splitlines()]
return SimpleNamespace(result=result, ran=options.ran, verified=options.verified, forced=options.forced,
events=events, mode=mode,
state=state.load(self.dest))
@staticmethod
def skipped(r):
return {e['step']: e['reason'] for e in r.events if e['type'] == 'step' and e['state'] == 'skipped'}
ALL = ['check', 'extract', 'lift', 'compile', 'prepare', 'cleanup']
class RunnerTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.bed = Bed(Path(self.tmp.name))
def tearDown(self):
self.tmp.cleanup()
def test_fresh_install_runs_everything_and_records_it(self):
r = self.bed.run()
self.assertEqual((r.result, r.mode, r.ran), ('ok', 'install', ALL))
self.assertEqual(set(r.state['steps']), {'extract', 'lift', 'compile', 'prepare'})
self.assertEqual(r.state['steps']['compile']['note'], 'compile')
self.assertEqual(r.state['release'], '1.0')
started = [e['step'] for e in r.events if e['type'] == 'step' and e['state'] == 'started']
done = [e['step'] for e in r.events if e['type'] == 'step' and e['state'] == 'done']
self.assertEqual((started, done), (ALL, ALL))
def test_rerun_skips_finished_steps_and_unneeded_transient(self):
self.bed.run()
r = self.bed.run()
self.assertEqual((r.result, r.mode, r.ran), ('ok', 'resume', ['check', 'cleanup']))
self.assertEqual(self.bed.skipped(r), {'extract': 'current', 'lift': 'not_needed', 'compile': 'current',
'prepare': 'current'})
self.assertEqual(r.verified, []) # same release: no full checks
def test_resume_after_failure(self):
r = self.bed.run(fail={'compile': 'error'})
self.assertEqual((r.result, r.ran), ('failed', ['check', 'extract', 'lift', 'compile']))
error = [e for e in r.events if e['type'] == 'error'][0]
self.assertEqual((error['id'], error['step'], error['params']), ('fake.failed', 'compile', {'step': 'compile'}))
self.assertEqual(set(r.state['steps']), {'extract', 'lift'})
r = self.bed.run()
# lift's output is still in the temp folder (no cleanup ran), so only compile and later run.
self.assertEqual((r.result, r.mode, r.ran), ('ok', 'resume', ['check', 'compile', 'prepare', 'cleanup']))
def test_resume_after_cancel_with_transient_output_gone(self):
r = self.bed.run(fail={'prepare': 'cancel'})
self.assertEqual(r.result, 'cancelled')
self.assertNotIn('prepare', r.state['steps'])
for p in self.bed.temp.glob('*.out'):
p.unlink()
r = self.bed.run()
self.assertEqual(r.ran, ['check', 'prepare', 'cleanup'])
self.assertEqual(self.bed.skipped(r)['lift'], 'not_needed')
def test_failed_check_writes_nothing(self):
r = self.bed.run(fail={'check': 'error'})
self.assertEqual(r.result, 'failed')
self.assertFalse(self.bed.dest.exists())
def test_removed_output_runs_again(self):
self.bed.run()
(self.bed.dest / 'compile.out').unlink()
r = self.bed.run()
self.assertEqual(r.ran, ['check', 'lift', 'compile', 'cleanup'])
def test_update_with_new_lifter_keeps_extraction(self):
self.bed.run(release='1.0')
r = self.bed.run(release='1.1', inputs={'lift': 'v2'})
self.assertEqual((r.result, r.mode), ('ok', 'update'))
self.assertEqual(r.ran, ['check', 'lift', 'compile', 'prepare', 'cleanup'])
self.assertEqual(r.verified, ['extract']) # kept from another release: full check
self.assertEqual(r.state['steps']['extract']['release'], '1.0')
self.assertEqual(r.state['steps']['compile']['release'], '1.1')
self.assertEqual(r.state['release'], '1.1')
def test_update_that_changes_nothing_skips_the_rebuild(self):
self.bed.run(release='1.0')
r = self.bed.run(release='1.1')
self.assertEqual(r.ran, ['check', 'cleanup'])
self.assertEqual(r.verified, ['extract', 'compile', 'prepare'])
def test_update_with_changed_kept_files_extracts_again(self):
self.bed.run(release='1.0')
r = self.bed.run(release='1.1', verify_ok={'extract': False})
self.assertEqual(r.ran, ['check', 'extract', 'cleanup'])
self.assertEqual(r.forced, ['extract']) # the step knows not to trust what it left behind
warnings = [e for e in r.events if e['type'] == 'warning']
self.assertEqual([(w['id'], w['params']) for w in warnings], [('state.output_changed', {'step': 'extract'})])
def test_verify_option_checks_same_release(self):
self.bed.run()
r = self.bed.run(verify=True)
self.assertEqual(r.verified, ['extract', 'compile', 'prepare'])
def test_state_errors(self):
self.bed.dest.mkdir()
(self.bed.dest / state.NAME).write_text('{not json')
with self.assertRaises(state.StateError) as e:
state.load(self.bed.dest)
self.assertEqual(e.exception.id, 'state.unreadable')
(self.bed.dest / state.NAME).write_text(json.dumps({'format': 99, 'steps': {}}))
with self.assertRaises(state.StateError) as e:
state.load(self.bed.dest)
self.assertEqual(e.exception.id, 'state.newer_format')
class IniTest(unittest.TestCase):
EXAMPLE = '[display]\n# vk_present = mailbox\n\n[language]\n# locale = en-US\n# lang = en-US\n# x = 1\n'
def test_example_line_is_activated(self):
out = ini.set_language(self.EXAMPLE, 'ru')
self.assertIn('\nlang = ru\n', out)
self.assertNotIn('# lang', out)
self.assertIn('# locale = en-US', out)
def test_active_line_is_rewritten_and_the_rest_kept(self):
text = '[display]\nvk_present = fifo\n[language]\n LANG = ja \nfps60 = 0\n'
out = ini.set_language(text, 'de')
self.assertEqual(out, '[display]\nvk_present = fifo\n[language]\n lang = de\nfps60 = 0\n')
self.assertEqual(ini.get_language(out), 'de')
def test_missing_section_is_added(self):
self.assertEqual(ini.set_language('fps60 = 0\n', 'fr'), 'fps60 = 0\n\n[language]\nlang = fr\n')
self.assertEqual(ini.set_language('[language]\nlocale = de-DE\n', 'fr'),
'[language]\nlang = fr\nlocale = de-DE\n')
def test_old_chinese_codes_are_written_as_the_canonical_ones(self):
self.assertEqual(ini.canonical_language('zh-CN'), 'zh-Hans')
self.assertEqual(ini.canonical_language('zh-TW'), 'zh-Hant')
self.assertEqual(ini.canonical_language('ja'), 'ja')
self.assertNotIn('zh-CN', ini.LANGUAGES)
self.assertNotIn('zh-TW', ini.LANGUAGES)
out = ini.set_language(self.EXAMPLE, 'zh-CN')
self.assertEqual(ini.get_language(out), 'zh-Hans')
self.assertEqual(ini.get_language(ini.set_language(out, 'zh-TW')), 'zh-Hant')
def test_write_keeps_user_lines(self):
with tempfile.TemporaryDirectory() as d:
d = Path(d)
example = d / 'totk.ini.example'
example.write_text(self.EXAMPLE)
path = ini.write(d / '', 'en-GB', example)
self.assertEqual(ini.get_language(path.read_text()), 'en-GB')
path.write_text(path.read_text().replace('# vk_present = mailbox', 'vk_present = fifo'))
ini.write(d, 'ja', example)
text = path.read_text()
self.assertIn('vk_present = fifo', text)
self.assertEqual(ini.get_language(text), 'ja')
class CleanupTest(unittest.TestCase):
"""The real Cleanup step on a finished install whose romfs/ is intact, emptied or gone (#107)."""
def setUp(self):
self.steps = steps
self.tmp = tempfile.TemporaryDirectory()
base = Path(self.tmp.name)
self.dest, temp = base / 'dest', base / 'temp'
(self.dest / 'romfs').mkdir(parents=True)
temp.mkdir()
image = b'romfs image'
(self.dest / 'romfs' / 'romfs.bin').write_bytes(image)
digest = steps.extract.sha256(self.dest / 'romfs' / 'romfs.bin')
(self.dest / 'romfs' / 'index.json').write_text(json.dumps({'romfs_size': len(image), 'romfs_sha256': digest}))
self.expected = {'romfs': {'size': len(image), 'sha256': digest}}
self.ctx = SimpleNamespace(reporter=Reporter(out=io.StringIO(), log_path=base / 'x.log'), dest=self.dest,
temp=temp, lang='en-US', state={},
layout=SimpleNamespace(ini_example=ROOT / 'totk.ini.example'))
self.saved = steps.expected_update
steps.expected_update = lambda ctx: self.expected
def tearDown(self):
self.steps.expected_update = self.saved
self.ctx.reporter.close()
self.tmp.cleanup()
def run_cleanup(self):
try:
self.steps.Cleanup().run(self.ctx)
return 'ok'
except self.steps.InstallError as e:
return e.id
def test_intact_romfs_passes(self):
self.assertEqual(self.run_cleanup(), 'ok')
def test_emptied_romfs_fails(self):
for p in (self.dest / 'romfs').iterdir():
p.unlink()
self.assertEqual(self.run_cleanup(), 'cleanup.romfs_unreadable')
def test_missing_romfs_fails(self):
self.steps.remove(self.dest / 'romfs')
self.assertEqual(self.run_cleanup(), 'cleanup.romfs_unreadable')
def test_romfs_of_another_size_fails(self):
(self.dest / 'romfs' / 'romfs.bin').write_bytes(b'short')
self.assertEqual(self.run_cleanup(), 'cleanup.romfs_unreadable')
class EventsTest(unittest.TestCase):
def test_lines_are_json_and_the_log_is_english(self):
with tempfile.TemporaryDirectory() as d:
out = io.StringIO()
rep = Reporter(out=out, log_path=Path(d) / 'x.log')
rep.step('check', 'started')
rep.progress('extract', 'copy', 0, 10, 'bytes')
rep.progress('extract', 'copy', 5, 10, 'bytes') # throttled
rep.progress('extract', 'copy', 10, 10, 'bytes') # last one always goes out
rep.error('check.disk_space', {'path': 'D:\\x', 'need_gb': 40, 'free_gb': 3}, step='check')
rep.close()
events = [json.loads(line) for line in out.getvalue().splitlines()]
self.assertEqual([e['type'] for e in events], ['step', 'progress', 'progress', 'error'])
self.assertEqual([e.get('done') for e in events[1:3]], [0, 10])
self.assertTrue(all('t' in e for e in events))
log = (Path(d) / 'x.log').read_text(encoding='utf-8')
self.assertIn('40 GB needed, 3 GB free', log)
self.assertIn('[check.disk_space]', log)
def test_catalog_has_every_id_the_code_uses(self):
catalog = json.loads((ROOT / 'installer' / 'text' / 'en.json').read_text(encoding='utf-8'))
used = set()
for path in (ROOT / 'installer').glob('*.py'):
used |= set(re.findall(r"InstallError\('([a-z_.]+)'", path.read_text()))
used |= set(re.findall(r"warning\('([a-z_.]+)'", path.read_text()))
used |= {f'phase.{s}.{p}' for s, p in re.findall(r"progress\('(\w+)', '(\w+)'[,)]", path.read_text())}
self.assertEqual(sorted(i for i in used if i not in catalog), [])
if __name__ == '__main__':
unittest.main()