"""Tests for launcher — no GUI/network dependency (download is mocked).""" import hashlib import json import os import shutil import sys import tempfile import unittest import zipfile from pathlib import Path from unittest.mock import patch sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import launcher from services.update_service import UpdateInfo def _make_release_zip(dst_zip, version, exe_body): """Build a flat release zip (CMBot.exe + version.txt) and return its sha256.""" tmp = Path(tempfile.mkdtemp()) try: (tmp / "CMBot.exe").write_text(exe_body, encoding="ascii") (tmp / "version.txt").write_text(version, encoding="ascii") with zipfile.ZipFile(str(dst_zip), "w", zipfile.ZIP_DEFLATED) as z: z.write(str(tmp / "CMBot.exe"), "CMBot.exe") z.write(str(tmp / "version.txt"), "version.txt") finally: shutil.rmtree(str(tmp), ignore_errors=True) return hashlib.sha256(dst_zip.read_bytes()).hexdigest() class _LauncherBase(unittest.TestCase): def setUp(self): self.tmp = Path(tempfile.mkdtemp()) self.install = self.tmp / "install" self.app = self.install / "app" (self.app / "config").mkdir(parents=True) (self.app / "CMBot.exe").write_text("OLD-1.0.0", encoding="ascii") (self.app / "version.txt").write_text("1.0.0", encoding="ascii") # factory defaults shipped inside app\config (self.app / "config" / "app_config.json").write_text( json.dumps({"update_source": "http://x/manifest.json", "last_template": "正方形"}), encoding="utf-8") (self.app / "config" / "templates.json").write_text("{}", encoding="utf-8") # data root -> a temp dir via the env override self.data = self.tmp / "data" self._prev_env = os.environ.get("CMBOT_DATA_DIR") os.environ["CMBOT_DATA_DIR"] = str(self.data) def tearDown(self): if self._prev_env is None: os.environ.pop("CMBOT_DATA_DIR", None) else: os.environ["CMBOT_DATA_DIR"] = self._prev_env shutil.rmtree(str(self.tmp), ignore_errors=True) class TestHelpers(_LauncherBase): def test_seed_defaults_copies_when_missing(self): launcher.seed_defaults(self.app, self.data) self.assertTrue((self.data / "config" / "app_config.json").exists()) self.assertTrue((self.data / "config" / "templates.json").exists()) def test_seed_defaults_does_not_overwrite(self): (self.data / "config").mkdir(parents=True) (self.data / "config" / "app_config.json").write_text( '{"update_source":"USER"}', encoding="utf-8") launcher.seed_defaults(self.app, self.data) kept = json.loads((self.data / "config" / "app_config.json").read_text(encoding="utf-8")) self.assertEqual(kept["update_source"], "USER") def test_is_writable(self): self.assertTrue(launcher.is_writable(self.install)) def test_find_package_root_flat(self): d = self.tmp / "flat" d.mkdir() (d / "CMBot.exe").write_text("x", encoding="ascii") self.assertEqual(launcher._find_package_root(d), d) def test_find_package_root_wrapped(self): d = self.tmp / "wrap" (d / "CMBot-1.1.0").mkdir(parents=True) (d / "CMBot-1.1.0" / "CMBot.exe").write_text("x", encoding="ascii") self.assertEqual(launcher._find_package_root(d), d / "CMBot-1.1.0") def test_find_package_root_missing(self): d = self.tmp / "empty" d.mkdir() self.assertIsNone(launcher._find_package_root(d)) def test_read_version_tolerates_bom(self): (self.app / "version.txt").write_text("1.0.0", encoding="utf-8-sig") self.assertEqual(launcher._read_version(self.app), "1.0.0") class TestRun(_LauncherBase): def _info(self, version, zip_path): sha = hashlib.sha256(zip_path.read_bytes()).hexdigest() return UpdateInfo(version=version, source="http://x/{}.zip".format(version), sha256=sha, size=zip_path.stat().st_size) def test_update_applied_and_swapped(self): new_zip = self.tmp / "new.zip" _make_release_zip(new_zip, "1.1.0", "NEW-1.1.0") info = self._info("1.1.0", new_zip) def fake_download(url, dest, user="", password="", timeout=120): shutil.copy(str(new_zip), str(dest)) with patch("launcher.check_for_update", return_value=info), \ patch("launcher.download", side_effect=fake_download): rc = launcher.run(self.install, no_launch=True) self.assertEqual(rc, 0) self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.1.0") self.assertEqual((self.app / "CMBot.exe").read_text(encoding="ascii"), "NEW-1.1.0") self.assertEqual(((self.install / "app.old") / "version.txt").read_text(encoding="ascii"), "1.0.0") self.assertFalse((self.install / "staging" / "1.1.0.zip").exists()) def test_no_update_keeps_local(self): with patch("launcher.check_for_update", return_value=None): rc = launcher.run(self.install, no_launch=True) self.assertEqual(rc, 0) self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0") self.assertFalse((self.install / "app.old").exists()) def test_sha256_mismatch_degrades(self): new_zip = self.tmp / "new.zip" _make_release_zip(new_zip, "1.1.0", "NEW") info = self._info("1.1.0", new_zip) info.sha256 = "deadbeef" # force mismatch def fake_download(url, dest, user="", password="", timeout=120): shutil.copy(str(new_zip), str(dest)) with patch("launcher.check_for_update", return_value=info), \ patch("launcher.download", side_effect=fake_download): rc = launcher.run(self.install, no_launch=True) self.assertEqual(rc, 0) self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0") self.assertFalse((self.install / "app.old").exists()) def test_seeds_config_into_data_root(self): with patch("launcher.check_for_update", return_value=None): launcher.run(self.install, no_launch=True) self.assertTrue((self.data / "config" / "app_config.json").exists()) if __name__ == "__main__": unittest.main()