Files

173 lines
9.3 KiB
Python

"""Read-only launch readiness and Windows wrapper regression checks; no FTL needed."""
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import launch
@unittest.skipUnless(os.name == "nt", "The live launcher is Windows only")
class LaunchSetupTests(unittest.TestCase):
def setUp(self):
# Native Windows lookup must be able to read these fixtures too; some
# sandboxes grant that access in the checkout but not the user's Temp.
scratch = Path(__file__).resolve().parents[1] / "local_game_data/test_scratch"
scratch.mkdir(parents=True, exist_ok=True)
self.temporary = tempfile.TemporaryDirectory(dir=scratch)
self.root = Path(self.temporary.name) / "FTL checkout with spaces"
self.root.mkdir()
self.lab = self.root / "separate lab"
self.lab.mkdir()
self.source = self.root / "owned original"
self.source.mkdir()
(self.source / "ftl.dat").write_bytes(b"synthetic archive placeholder")
(self.lab / "FTLGame.exe").write_bytes(b"synthetic executable placeholder")
(self.lab / "Hyperspace.dll").write_bytes(b"synthetic loader placeholder")
self.marker = {"source_game": str(self.source), "activated": True, "save_prefix": "ftlvr"}
self.write_marker()
names = ("GetShiftState", "GetCtrlState", "ForceAutofireFlag", "OnTextInput",
"OnTextEvent", "TextInputOnRender", "TextInputStart", "CommandGuiRenderStatic",
"CommandGuiRenderPause", "TabbedWindowOnRender", "ChoiceBoxOnRender",
"MouseControlOnRender", "StarMapOnRender", "MenuScreenOnRender", "OptionsScreenOnRender", "CombatControlRenderTarget")
self.hooks = self.lab / "hooks.json"
self.hooks.write_text(json.dumps({"rvas": dict.fromkeys(names, 1),"offsets":{"gui_game_over":0x1e88,"focus_window_open":4},
"sha1": hashlib.sha1((self.lab / "FTLGame.exe").read_bytes()).hexdigest()}))
self.godot = self.root / "godot.exe"
self.godot.write_bytes(b"synthetic renderer placeholder")
self.local = self.root / "local_game_data"
self.local.mkdir()
self.manifest = self.local / "manifest.json"
self.manifest.write_text(json.dumps({"ships": ["test"], "fonts": 1, "ui_assets": 1}))
self.config = {"lab": str(self.lab), "hooks": str(self.hooks), "godot": str(self.godot)}
self.project_patch = patch.object(launch, "PROJECT", self.root)
self.project_patch.start()
self.packages_patch = patch.object(launch.importlib.util, "find_spec", return_value=object())
self.packages_patch.start()
def tearDown(self):
self.packages_patch.stop()
self.project_patch.stop()
self.temporary.cleanup()
def write_marker(self):
(self.lab / "ftlvr-lab.json").write_text(json.dumps(self.marker))
def test_shared_preflight_accepts_complete_desktop_setup(self):
self.assertEqual(launch.preflight(self.config, desktop=True)["mode"], "desktop")
def test_old_hooks_require_modal_hud_capture_update(self):
value = json.loads(self.hooks.read_text())
for name in ("MenuScreenOnRender", "OptionsScreenOnRender", "CombatControlRenderTarget"):
with self.subTest(name=name):
incomplete = dict(value, rvas={key: address for key, address in value["rvas"].items() if key != name})
self.hooks.write_text(json.dumps(incomplete))
with self.assertRaisesRegex(ValueError, "Regenerate lab hooks.json.*live native HUD capture"):
launch.preflight(self.config, desktop=True)
def test_missing_configuration_explains_setup(self):
with self.assertRaisesRegex(ValueError, "Run SETUP.cmd"):
launch.load_config(self.local / "launcher.json")
def test_config_accepts_utf8_bom(self):
path = self.local / "launcher.json"
path.write_text(json.dumps(self.config), encoding="utf-8-sig")
self.assertEqual(launch.load_config(path), self.config)
def test_blank_or_relative_configuration_is_rejected(self):
for path, message in (("", "nonempty"), ("godot.exe", "absolute")):
with self.subTest(path=path), self.assertRaisesRegex(ValueError, message):
launch.preflight(dict(self.config, godot=path), desktop=True)
def test_game_and_renderer_need_correct_path_types(self):
with self.assertRaisesRegex(ValueError, "Missing godot"):
launch.preflight(dict(self.config, godot=str(self.root)), desktop=True)
def test_missing_or_partial_assets_prevent_game_launch(self):
self.manifest.unlink()
with self.assertRaisesRegex(ValueError, "Local game assets are missing"):
launch.preflight(self.config, desktop=True)
self.manifest.write_text(json.dumps({"ships": ["test"], "fonts": 0, "ui_assets": 1}))
with self.assertRaisesRegex(ValueError, "extraction is incomplete"):
launch.preflight(self.config, desktop=True)
def test_qa_save_prefix_is_rejected_by_normal_launcher(self):
self.marker["save_prefix"] = "testvr"
self.write_marker()
with self.assertRaisesRegex(ValueError, "ftlvr save prefix"):
launch.preflight(self.config, desktop=True)
def test_click_wrappers_report_unconfigured_checkout_without_hanging(self):
checkout = self.root / "unconfigured clone"
checkout.mkdir()
(checkout / "tools").mkdir()
actual_project = Path(__file__).resolve().parents[1]
helper = actual_project / "tools/show_launcher_error.cmd"
shutil.copyfile(helper, checkout / "tools/show_launcher_error.cmd")
for name in ("RUN-VR.cmd", "RUN-DESKTOP.cmd", "CHECK-SETUP.cmd"):
shutil.copyfile(actual_project / name, checkout / name)
environment = dict(os.environ, FTLVR_NO_PAUSE="1")
result = subprocess.run([os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", name],
cwd=checkout, env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1, name)
self.assertIn("SETUP.cmd", result.stdout, name)
self.assertIn("Launch stopped", result.stdout, name)
def test_setup_rejects_microsoft_store_alias(self):
checkout = self.root / "store alias clone"
checkout.mkdir()
(checkout / "tools").mkdir()
actual_project = Path(__file__).resolve().parents[1]
for name in ("SETUP.cmd", "tools/show_launcher_error.cmd"):
shutil.copyfile(actual_project / name, checkout / name)
alias = checkout / "fake LocalAppData/Microsoft/WindowsApps"
alias.mkdir(parents=True)
(alias / "python.exe").touch()
# System32 can contain a real py.exe on CI runners. Keep only the
# lookup utility, not that directory, on this synthetic PATH.
lookup = checkout / "isolated lookup tools"
lookup.mkdir()
where = lookup / "where.exe"
shutil.copyfile(Path(os.environ["SystemRoot"]) / "System32/where.exe", where)
environment = dict(os.environ, FTLVR_NO_PAUSE="1", LOCALAPPDATA=str(checkout / "fake LocalAppData"),
PATH=str(lookup) + os.pathsep + str(alias))
environment.pop("FTLVR_PYTHON", None)
absent_launcher = subprocess.run([str(where), "py"], cwd=checkout, env=environment,
capture_output=True, text=True, timeout=10)
self.assertEqual(absent_launcher.returncode, 1, "Synthetic PATH must not expose the host's py launcher")
store_alias = subprocess.run([str(where), "python"], cwd=checkout, env=environment,
capture_output=True, text=True, timeout=10)
self.assertEqual(store_alias.returncode, 0)
self.assertEqual(store_alias.stdout.strip().casefold(), str(alias / "python.exe").casefold())
result = subprocess.run([os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", "SETUP.cmd --dry-run"],
cwd=checkout, env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1)
self.assertIn("Microsoft Store alias is not a Python installation", result.stdout)
self.assertFalse((checkout / ".venv").exists())
def test_setup_accepts_explicit_python_and_forwards_dry_run(self):
checkout = self.root / "explicit Python clone"
checkout.mkdir()
(checkout / "tools").mkdir()
actual_project = Path(__file__).resolve().parents[1]
for name in ("SETUP.cmd", "tools/setup_environment.py", "tools/show_launcher_error.cmd"):
shutil.copyfile(actual_project / name, checkout / name)
environment = dict(os.environ, FTLVR_NO_PAUSE="1", FTLVR_PYTHON=sys.executable)
result = subprocess.run([os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", "SETUP.cmd --dry-run"],
cwd=checkout, env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Dry run complete", result.stdout)
self.assertFalse((checkout / ".venv").exists())
self.assertFalse((checkout / "local_game_data").exists())
if __name__ == "__main__":
unittest.main()