mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-12 18:31:05 +00:00
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>
325 lines
15 KiB
Python
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()
|