2026-08-10 11:59:13 +08:00
|
|
|
"""Client 打包目录和发布清单测试。"""
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
2026-08-10 12:32:39 +08:00
|
|
|
import os
|
2026-08-10 11:59:13 +08:00
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
2026-08-10 12:32:39 +08:00
|
|
|
from build_tools.launcher import (
|
|
|
|
|
UpdateApplyError,
|
|
|
|
|
app_executable,
|
|
|
|
|
apply_pending_update,
|
|
|
|
|
finalize_applied_update,
|
|
|
|
|
install_root,
|
|
|
|
|
is_main_program_running,
|
|
|
|
|
)
|
2026-08-10 14:55:17 +08:00
|
|
|
from build_tools.release_manifest import (
|
|
|
|
|
MANIFEST_FILE_NAME,
|
|
|
|
|
versioned_manifest_file_name,
|
|
|
|
|
write_manifest,
|
|
|
|
|
)
|
2026-08-11 12:01:07 +08:00
|
|
|
from src.db import app_dir, data_dir
|
2026-08-10 11:59:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class LauncherPathTest(unittest.TestCase):
|
|
|
|
|
def test_main_program_is_inside_app_directory(self):
|
|
|
|
|
root = Path("D:/CMAutoBuy")
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
app_executable(root),
|
|
|
|
|
root / "app" / "CMAutoBuy.exe",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_install_root_is_launcher_parent(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
install_root("D:/CMAutoBuy/Launcher.exe"),
|
|
|
|
|
Path("D:/CMAutoBuy"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 12:32:39 +08:00
|
|
|
class LauncherUpdateTest(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.temporary_directory = tempfile.TemporaryDirectory()
|
|
|
|
|
self.root = Path(self.temporary_directory.name)
|
|
|
|
|
self.current_app = self.root / "app"
|
|
|
|
|
self.staged_app = self.root / "data" / "update" / "app.new"
|
|
|
|
|
self.current_app.mkdir(parents=True)
|
|
|
|
|
self.staged_app.mkdir(parents=True)
|
|
|
|
|
(self.current_app / "CMAutoBuy.exe").write_bytes(b"old")
|
|
|
|
|
(self.staged_app / "CMAutoBuy.exe").write_bytes(b"new")
|
|
|
|
|
self.pending_path = self.root / "data" / "update" / "pending.json"
|
|
|
|
|
self.pending_path.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"state": "ready",
|
|
|
|
|
"version": "0.2.0",
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def tearDown(self):
|
|
|
|
|
self.temporary_directory.cleanup()
|
|
|
|
|
|
|
|
|
|
def test_apply_keeps_old_app_until_health_is_confirmed(self):
|
|
|
|
|
version = apply_pending_update(self.root)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(version, "0.2.0")
|
|
|
|
|
self.assertEqual((self.root / "app" / "CMAutoBuy.exe").read_bytes(), b"new")
|
|
|
|
|
self.assertEqual((self.root / "app.old" / "CMAutoBuy.exe").read_bytes(), b"old")
|
|
|
|
|
pending = json.loads(self.pending_path.read_text(encoding="utf-8"))
|
|
|
|
|
self.assertEqual(pending["state"], "applied")
|
|
|
|
|
|
|
|
|
|
healthy_path = self.root / "data" / "update" / "healthy.json"
|
|
|
|
|
healthy_path.write_text('{"version":"0.2.0"}', encoding="utf-8")
|
|
|
|
|
finalize_applied_update(self.root)
|
|
|
|
|
self.assertFalse(self.pending_path.exists())
|
|
|
|
|
self.assertTrue((self.root / "app.old").exists())
|
|
|
|
|
|
|
|
|
|
def test_applied_without_health_rolls_back_on_next_launch(self):
|
|
|
|
|
apply_pending_update(self.root)
|
|
|
|
|
|
|
|
|
|
result = apply_pending_update(self.root)
|
|
|
|
|
|
|
|
|
|
self.assertIsNone(result)
|
|
|
|
|
self.assertEqual((self.root / "app" / "CMAutoBuy.exe").read_bytes(), b"old")
|
|
|
|
|
self.assertFalse(self.pending_path.exists())
|
|
|
|
|
self.assertTrue((self.root / "data" / "update" / "app.failed").exists())
|
|
|
|
|
|
|
|
|
|
def test_move_failure_restores_current_app(self):
|
|
|
|
|
real_replace = os.replace
|
|
|
|
|
|
|
|
|
|
def fail_for_staged_app(source, destination):
|
|
|
|
|
if Path(source).name == "app.new":
|
|
|
|
|
raise OSError("simulated lock")
|
|
|
|
|
return real_replace(source, destination)
|
|
|
|
|
|
|
|
|
|
with patch("build_tools.launcher.os.replace", side_effect=fail_for_staged_app):
|
|
|
|
|
with self.assertRaises(UpdateApplyError):
|
|
|
|
|
apply_pending_update(self.root)
|
|
|
|
|
|
|
|
|
|
self.assertEqual((self.root / "app" / "CMAutoBuy.exe").read_bytes(), b"old")
|
|
|
|
|
self.assertEqual((self.staged_app / "CMAutoBuy.exe").read_bytes(), b"new")
|
|
|
|
|
|
|
|
|
|
def test_running_pid_must_belong_to_expected_executable(self):
|
|
|
|
|
running_path = self.root / "data" / "update" / "running.json"
|
|
|
|
|
running_path.write_text('{"pid":123}', encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
is_main_program_running(
|
|
|
|
|
self.root,
|
|
|
|
|
checker=lambda pid, executable: pid == 123
|
|
|
|
|
and executable == self.root / "app" / "CMAutoBuy.exe",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
is_main_program_running(self.root, checker=lambda _pid, _path: False)
|
|
|
|
|
)
|
|
|
|
|
self.assertFalse(running_path.exists())
|
|
|
|
|
|
|
|
|
|
def test_corrupt_pending_state_stops_update_instead_of_being_ignored(self):
|
|
|
|
|
self.pending_path.write_text("not-json", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
with self.assertRaises(UpdateApplyError):
|
|
|
|
|
apply_pending_update(self.root)
|
|
|
|
|
|
|
|
|
|
self.assertEqual((self.current_app / "CMAutoBuy.exe").read_bytes(), b"old")
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 11:59:13 +08:00
|
|
|
class PackagedDataPathTest(unittest.TestCase):
|
|
|
|
|
def test_packaged_main_program_uses_root_data_directory(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory)
|
|
|
|
|
executable = root / "app" / "CMAutoBuy.exe"
|
|
|
|
|
with patch("src.db.sys.frozen", True, create=True), patch(
|
|
|
|
|
"src.db.sys.executable", str(executable)
|
|
|
|
|
):
|
|
|
|
|
self.assertEqual(data_dir(), root / "data")
|
|
|
|
|
|
2026-08-11 12:01:07 +08:00
|
|
|
def test_packaged_resource_path_uses_pyinstaller_directory(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory, patch(
|
|
|
|
|
"src.db.sys.frozen", True, create=True
|
|
|
|
|
), patch("src.db.sys._MEIPASS", directory, create=True):
|
|
|
|
|
self.assertEqual(app_dir(), Path(directory).resolve())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BundledAdbPackagingTest(unittest.TestCase):
|
|
|
|
|
def test_build_script_packages_and_checks_all_adb_files(self):
|
|
|
|
|
client_root = Path(__file__).resolve().parents[1]
|
|
|
|
|
script = (client_root / "packaging" / "build.ps1").read_text(
|
|
|
|
|
encoding="utf-8-sig"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.assertIn("--add-binary", script)
|
|
|
|
|
self.assertIn("vendor/android-platform-tools/windows", script)
|
|
|
|
|
self.assertIn("adb.exe", script)
|
|
|
|
|
self.assertIn("AdbWinApi.dll", script)
|
|
|
|
|
self.assertIn("AdbWinUsbApi.dll", script)
|
|
|
|
|
|
2026-08-10 11:59:13 +08:00
|
|
|
|
|
|
|
|
class ReleaseManifestTest(unittest.TestCase):
|
|
|
|
|
def test_manifest_name_and_hash_match_release_files(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
|
|
|
root = Path(directory)
|
2026-08-10 14:55:17 +08:00
|
|
|
update_zip = root / "CMAutoBuy_0.1.0.zip"
|
2026-08-10 15:10:03 +08:00
|
|
|
portable_zip = root / "自动采集采购工具.zip"
|
2026-08-10 11:59:13 +08:00
|
|
|
update_zip.write_bytes(b"update")
|
|
|
|
|
portable_zip.write_bytes(b"portable")
|
|
|
|
|
output = root / MANIFEST_FILE_NAME
|
|
|
|
|
|
|
|
|
|
write_manifest("0.1.0", update_zip, portable_zip, output)
|
|
|
|
|
|
2026-08-10 14:55:17 +08:00
|
|
|
self.assertEqual(output.name, "autobuy_manifest.json")
|
2026-08-10 11:59:13 +08:00
|
|
|
manifest = json.loads(output.read_text(encoding="utf-8"))
|
|
|
|
|
self.assertEqual(manifest["version"], "0.1.0")
|
|
|
|
|
self.assertEqual(manifest["update"]["size"], len(b"update"))
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
manifest["update"]["sha256"],
|
|
|
|
|
hashlib.sha256(b"update").hexdigest(),
|
|
|
|
|
)
|
2026-08-10 15:10:03 +08:00
|
|
|
self.assertEqual(manifest["update"]["file"], "CMAutoBuy_0.1.0.zip")
|
2026-08-10 11:59:13 +08:00
|
|
|
self.assertEqual(manifest["portable"]["file"], portable_zip.name)
|
|
|
|
|
|
2026-08-10 14:55:17 +08:00
|
|
|
def test_versioned_manifest_name_is_stable_and_validated(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
versioned_manifest_file_name("0.1.0"),
|
|
|
|
|
"autobuy_manifest_0.1.0.json",
|
|
|
|
|
)
|
|
|
|
|
with self.assertRaises(ValueError):
|
|
|
|
|
versioned_manifest_file_name("../latest")
|
|
|
|
|
|
2026-08-10 11:59:13 +08:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|