Files
QPyPack/tests/test_check.py
T
QwejayHuang 448dbdbbf3 v2.8.0: Add project directory support, improve dependency resolution, and fix syntax injection
- Added direct project directory drag-and-drop support with automatic root workspace and requirements.txt detection
- Added smart entry script prioritization and interactive selection dialog for multi-script projects
- Expanded AST dependency analysis across the entire project workspace to capture sub-package imports
- Implemented local module and resource whitelisting to prevent unintended pip installations of local packages
- Expanded PyPI mappings and Auto-Hooks rules for C extensions and dynamic libraries (PyCryptodome, NaCl, pynput, etc.)
- Added automatic search path injection (`--paths` for PyInstaller and `PYTHONPATH` for Nuitka) for sub-directory entries
- Improved `requirements.txt` parser to safely handle inline comments and environment markers
- Added cross-hierarchy import detection and workspace configuration tips for standalone scripts
- Fixed SyntaxError caused by console pause code injection preceding `from __future__` statements
- Fixed TypeError crash caused by `os.walk` iteration variable shadowing the global translation function
Update QUICKSTART.md、README.MD...
2026-08-31 19:57:05 +08:00

547 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
=============================================================================
QPyPack 全功能终极一体化自检与双引擎真实打包验证系统 (UI 深度集成完整版)
=============================================================================
特性:
- 深度集成:直接实例化 MainWindow 模拟用户拖拽与点击,完整测试 UI->Engine 链路
- 前瞻兼容:不写死参数字典,直接读取 UI 界面控件生成的配置,随软件升级自动适配
- 自动自净:测试开始前与结束后自动抹除一切 build/、spec、egg-info 残留
- 全量覆盖:包含 AST 分析、I18N 多语言引擎、状态机逻辑等 8 大核心维度
=============================================================================
"""
import sys
import os
import re
import json
import time
import shutil
import zipfile
import tempfile
import subprocess
import traceback
from pathlib import Path
# 全局开启 Qt 无头模式与 Windows 字体 (屏蔽无关警告)
os.environ["QT_QPA_PLATFORM"] = "offscreen"
if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
if os.path.exists(r"C:\Windows\Fonts"):
os.environ["QT_QPA_FONTDIR"] = r"C:\Windows\Fonts"
os.environ["QT_LOGGING_RULES"] = "qt.qpa.fonts=false;qt.qpa.*=false;qt.multimedia*=false"
current_path = Path(__file__).resolve()
if current_path.parent.name == "tests":
ROOT_DIR = current_path.parent.parent
else:
ROOT_DIR = current_path.parent
PACKAGE_DIR = ROOT_DIR / "qpypack"
LOCALES_DIR = PACKAGE_DIR / "locales"
ICON_PATH = PACKAGE_DIR / "icon.ico"
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
class Colors:
CYAN = "\033[96m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
RESET = "\033[0m"
BOLD = "\033[1m"
def log_pass(msg):
print(f" {Colors.GREEN}✔ [PASS]{Colors.RESET} {msg}")
def log_warn(msg):
print(f" {Colors.YELLOW}⚠ [WARN]{Colors.RESET} {msg}")
def log_fail(msg):
print(f" {Colors.RED}✖ [FAIL]{Colors.RESET} {msg}")
def log_info(msg):
print(f" {Colors.CYAN}ℹ [INFO]{Colors.RESET} {msg}")
# =====================================================================
# 垃圾自净机制 (自动清理根目录残留)
# =====================================================================
def clean_project_junk():
"""扫描并强制清除项目根目录下的所有测试与构建残留"""
junk_patterns_dir = ["build", "dist", "*.egg-info", "__pycache__", ".pytest_cache"]
junk_patterns_file = ["*.spec", ".coverage"]
# 清理目录
for pattern in junk_patterns_dir:
for p in ROOT_DIR.glob(pattern):
if p.is_dir() and p != PACKAGE_DIR:
try:
shutil.rmtree(p, ignore_errors=True)
except Exception:
pass
for p in (ROOT_DIR / "tests").glob(pattern):
if p.is_dir():
try:
shutil.rmtree(p, ignore_errors=True)
except Exception:
pass
# 清理文件
for pattern in junk_patterns_file:
for p in ROOT_DIR.glob(pattern):
if p.is_file():
try:
p.unlink(missing_ok=True)
except Exception:
pass
for p in (ROOT_DIR / "tests").glob(pattern):
if p.is_file():
try:
p.unlink(missing_ok=True)
except Exception:
pass
# 测试前先自净一次历史垃圾
clean_project_junk()
# =====================================================================
# 1. 静态资源与配置深度检测
# =====================================================================
def test_01_static_assets():
"""测试 1: 静态资源与配置物理合法性"""
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 1/8] 静态资源与工程配置完整性检查...{Colors.RESET}")
assert ICON_PATH.exists(), f"关键图标缺失: {ICON_PATH}"
assert ICON_PATH.stat().st_size > 1024, "icon.ico 文件大小异常"
with open(ICON_PATH, "rb") as f:
header = f.read(4)
assert header == b"\x00\x00\x01\x00", "icon.ico 不是有效的 Windows ICO 格式"
log_pass(f"图标格式与二进制签名正常 (大小: {ICON_PATH.stat().st_size} bytes)")
toml_path = ROOT_DIR / "pyproject.toml"
assert toml_path.exists(), "pyproject.toml 缺失"
assert "qpypack" in toml_path.read_text(encoding="utf-8").lower()
log_pass("pyproject.toml 配置文件解析正常")
# =====================================================================
# 2. 16 种多语言语法、Key 一致性与占位符检测
# =====================================================================
def test_02_locales_and_placeholders():
"""测试 2: 16 国多语言深度校验"""
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 2/8] 16 国国际化语言包与语法占位符检测...{Colors.RESET}")
assert LOCALES_DIR.is_dir(), "locales 目录不存在"
json_files = list(LOCALES_DIR.glob("*.json"))
assert len(json_files) == 16, f"应包含 16 种语言包,实际发现 {len(json_files)} 个"
log_pass(f"成功定位到全部 {len(json_files)} 个语言包文件")
parsed = {}
for jf in json_files:
with open(jf, "r", encoding="utf-8") as f:
data = json.load(f)
assert isinstance(data, dict), f"{jf.name} 格式非合法字典"
parsed[jf.name] = data
log_pass("所有语言包 JSON 语法解析正常")
base_data = parsed.get("zh_CN.json", {})
assert base_data, "基准语言包 zh_CN.json 缺失或为空"
base_keys = set(base_data.keys())
missing_map = {}
for name, data in parsed.items():
if name == "zh_CN.json":
continue
missing = base_keys - set(data.keys())
if missing:
missing_map[name] = missing
if missing_map:
for fname, keys in missing_map.items():
log_warn(f"{fname} 缺少 {len(keys)} 个词条: {list(keys)[:3]}...")
else:
log_pass("16 国语言包与 zh_CN.json 词条保持 100% 完全对齐!")
# =====================================================================
# 3. AST 语法分析与依赖提取算法测试
# =====================================================================
def test_03_ast_engine():
"""测试 3: AST 源码静态语法分析与依赖侦测 (QPyPack 原生算法)"""
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 3/8] AST 静态分析与依赖侦测 (QPyPack 原生算法)...{Colors.RESET}")
from qpypack.main import extract_project_imports_via_ast
with tempfile.TemporaryDirectory() as tmpdir:
mock_file = Path(tmpdir) / "test_ast.py"
mock_file.write_text("import os, sys\nimport requests.auth\nfrom PyQt5.QtWidgets import QMainWindow", encoding="utf-8")
modules = extract_project_imports_via_ast(mock_file, scan_dir=False)
assert {"requests", "PyQt5", "os", "sys"}.issubset(modules), f"QPyPack 原生提取异常: {modules}"
log_pass(f"QPyPack AST 引擎成功提取模块依赖: {modules}")
# =====================================================================
# 4. 核心模块与入口健全性 (集成配置存储及多语言引擎联调)
# =====================================================================
def test_04_core_logic():
"""测试 4: 核心配置管理与翻译引擎联调测试"""
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 4/8] QPyPack 核心配置读取与多语言翻译引擎测试...{Colors.RESET}")
from qpypack.main import load_config, I18N
config = load_config(retry=True)
assert "Settings" in config, "配置生成或加载失败"
assert "Mappings" in config, "包名映射字典缺失"
# 动态切换并断言多语言系统
I18N.set_language("en_US")
assert I18N.t("Build Settings") == "Build Settings", "英文翻译错误"
I18N.set_language("zh_CN")
assert I18N.t("Build Settings") == "构建设置", "中文翻译未命中映射"
log_pass("配置 I/O 系统与 I18N 多语言引擎运行正常")
# =====================================================================
# 5. UI 控件与主窗口状态机独立交互测试
# =====================================================================
def test_05_ui_interaction():
"""测试 5: Qt UI 界面与控件状态机逻辑交互测试 (独立无打包)"""
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 5/8] Qt UI 控件与状态机模拟交互测试...{Colors.RESET}")
try:
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(["qp_test", "-platform", "offscreen"])
except ImportError:
log_warn("未安装 PySide6,跳过 GUI 交互测试")
return
from PySide6.QtCore import QEventLoop, QTimer
from qpypack.main import MainWindow
window = MainWindow()
with tempfile.TemporaryDirectory() as tmp_dir:
mock_py = Path(tmp_dir) / "mock_app.py"
mock_py.write_text("print('hello')", encoding="utf-8")
# 测试拖入脚本
window.on_script_selected(str(mock_py))
# 等待后台 AST 解析子线程完成
loop = QEventLoop()
timer = QTimer()
timer.timeout.connect(loop.quit)
timer.start(1000)
loop.exec()
timer.stop()
assert window.current_state == "ready", "脚本载入后状态机未变为 ready"
assert window.settings_panel.name_edit.text() == "mock_app", "未成功解析推导出默认程序名"
# 测试修改引擎配置
window.settings_panel.engine_combo.setCurrentIndex(1)
assert window.settings_panel.engine_combo.currentText() == "Nuitka"
log_pass("主窗口完成内存初始化,状态机推导与 UI 数据绑定响应正常")
# =====================================================================
# 6. Wheel 分发包真实打包与解压内嵌资产验证 (沙箱环境零残留)
# =====================================================================
def test_06_wheel_packaging():
"""测试 6: Wheel 包构建与资源嵌入"""
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 6/8] Wheel (.whl) 分发包构建与资源嵌入深度检查...{Colors.RESET}")
with tempfile.TemporaryDirectory(prefix="qp_whl_") as tmp_dist:
try:
build_cmd = [sys.executable, "-m", "build", "--wheel", "--outdir", tmp_dist, str(ROOT_DIR)]
result = subprocess.run(build_cmd, capture_output=True, text=True, cwd=tmp_dist)
if result.returncode != 0:
result = subprocess.run(
[sys.executable, "-m", "pip", "wheel", "--no-deps", "-w", tmp_dist, str(ROOT_DIR)],
capture_output=True, text=True, cwd=tmp_dist
)
assert result.returncode == 0, f"构建 Wheel 失败:\n{result.stderr}"
wheels = list(Path(tmp_dist).glob("*.whl"))
assert wheels, "未找到生成的 .whl 文件"
wheel_file = wheels[0]
log_pass(f"成功构建 Wheel: {wheel_file.name}")
with zipfile.ZipFile(wheel_file, "r") as zf:
names = zf.namelist()
assert any("icon.ico" in n for n in names), "icon.ico 漏打包进 Wheel!"
json_in_wheel = [n for n in names if "locales/" in n and n.endswith(".json")]
assert len(json_in_wheel) >= 16, f"语言包未全部嵌入 Wheel (仅 {len(json_in_wheel)} 个)!"
log_pass(f"icon.ico 及 16 个语言包已 100% 嵌入 Wheel 压缩包中")
finally:
clean_project_junk()
# =====================================================================
# 辅助函数: 创建微型真实测试工程 & 产物实机拉起断言
# =====================================================================
def _create_mock_project(base_dir: Path) -> Path:
proj_dir = base_dir / "mock_app"
proj_dir.mkdir(parents=True, exist_ok=True)
assets_dir = proj_dir / "assets"
assets_dir.mkdir(exist_ok=True)
with open(assets_dir / "data.json", "w", encoding="utf-8") as f:
json.dump({"engine_test": "QPYPACK_REAL_OK"}, f)
with open(proj_dir / "calc.py", "w", encoding="utf-8") as f:
f.write("def multiply(a, b): return a * b\n")
entry_file = proj_dir / "app_main.py"
with open(entry_file, "w", encoding="utf-8") as f:
f.write('''# -*- coding: utf-8 -*-
__version__ = "1.0.0"
__app_name__ = "MockTestApp"
import sys, os, json, multiprocessing
from calc import multiply
def get_res(rel):
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, rel)
return os.path.join(os.path.dirname(os.path.abspath(__file__)), rel)
if __name__ == '__main__':
multiprocessing.freeze_support()
calc_res = multiply(6, 7)
res_path = get_res(os.path.join("assets", "data.json"))
val = "FAIL"
if os.path.exists(res_path):
with open(res_path, "r", encoding="utf-8") as f:
val = json.load(f).get("engine_test", "CORRUPTED")
else:
val = f"NOT_FOUND:{res_path}"
print(f"__QP_ASSERT__|{calc_res}|{val}|__QP_ASSERT_END__")
sys.exit(0)
''')
return entry_file
def _get_size(path: Path):
if path.is_file():
return path.stat().st_size
return sum(f.stat().st_size for f in path.rglob('*') if f.is_file())
def _assert_executable_execution(exe_path: Path):
if sys.platform == "darwin" and exe_path.is_dir() and exe_path.name.endswith(".app"):
exe_path = exe_path / "Contents" / "MacOS" / exe_path.stem
assert exe_path.exists(), f"未找到生成的 EXE/二进制 文件: {exe_path}"
start_t = time.time()
proc = subprocess.run([str(exe_path)], capture_output=True, text=True, timeout=20, cwd=str(exe_path.parent))
assert proc.returncode == 0, f"程序运行报错 (Code {proc.returncode}):\n{proc.stderr}"
assert "__QP_ASSERT__|42|QPYPACK_REAL_OK|__QP_ASSERT_END__" in proc.stdout, f"输出不符,资源提取失败:\n{proc.stdout}"
size_mb = _get_size(exe_path) / (1024 * 1024)
log_pass(f"产物实机拉起验证通过!耗时: {time.time() - start_t:.2f}s | 体积: {size_mb:.2f} MB | 静态资源捆绑读取正常")
# =====================================================================
# E2E 测试核心引擎: 完整模拟 MainWindow 界面交互与打包
# =====================================================================
def run_e2e_via_mainwindow(engine_name: str, tmp_dir: Path, is_onefile: bool):
"""
通过直接实例化 MainWindow 并触发界面事件,模拟真实用户的打包全流程。
这能保证测试紧跟 UI 升级,覆盖完整的 "界面配置 -> 参数生成 -> 线程打包" 链路。
"""
from PySide6.QtCore import QEventLoop, QTimer
from qpypack.main import MainWindow
sandbox = Path(tmp_dir)
entry_file = _create_mock_project(sandbox)
# 1. 实例化主窗口 (触发所有的初始化和配置读取)
window = MainWindow()
# 2. 模拟用户拖拽源码文件到界面
window.on_script_selected(str(entry_file))
# 等待分析线程(ScriptAnalysisThread)完成
loop_wait = QEventLoop()
timer_wait = QTimer()
timer_wait.timeout.connect(loop_wait.quit)
timer_wait.start(1000) # 等待1秒确保分析完毕
loop_wait.exec()
timer_wait.stop()
assert window.current_state == "ready", "脚本载入后状态机未变为 ready"
# 3. 模拟用户配置界面面板 (SettingsPanel)
sp = window.settings_panel
sp.engine_combo.setCurrentText(engine_name)
sp.rb_onefile.setChecked(is_onefile)
sp.rb_onedir.setChecked(not is_onefile)
sp.noconsole_check.setChecked(True)
sp.enable_sign_check.setChecked(False) # 测试环境关闭签名防阻断
# 设置输出目录到沙箱中
sp.out_mode_combo.setCurrentIndex(1)
sp.out_dir_edit.setText(str(sandbox / "dist"))
# 4. 模拟用户拖入附加资源 (assets 文件夹)
window.on_main_resources_dropped([str(entry_file.parent / "assets")])
# 5. 模拟点击【开始构建】
window.start_pack()
assert window.current_state == "building", "启动打包后,主窗口未进入 building 状态"
# 6. 使用事件循环等待 PackingThread 后台线程完成
loop = QEventLoop()
def check_status():
# 轮询状态机是否退出 building
if window.current_state in ("done", "failed"):
loop.quit()
timer = QTimer()
timer.timeout.connect(check_status)
timer.start(500)
loop.exec()
timer.stop()
assert window.current_state == "done", f"【{engine_name}】打包失败!详细日志如下:\n{window.log_detailed.toPlainText()}"
# 7. 提取生成的程序名,并定位产物
app_name = sp.name_edit.text().strip() or "MockTestApp"
ext = ".exe" if sys.platform == "win32" else ""
dist_dir = sandbox / "dist"
if sys.platform == "darwin" and sp.noconsole_check.isChecked():
exe_path = dist_dir / f"{app_name}.app"
else:
if is_onefile:
exe_path = dist_dir / f"{app_name}{ext}"
else:
# 文件夹模式:产物为 dist_dir/app_name/app_name.exe
exe_path = dist_dir / app_name / f"{app_name}{ext}"
# 容错:Nuitka 在极少数情况下可能未重命名 .dist 文件夹,或者文件名带版本号
if not exe_path.exists():
fallback_dist = dist_dir / f"{app_name}.dist" / f"{app_name}{ext}"
if fallback_dist.exists():
exe_path = fallback_dist
else:
# 终极兜底:直接在目录里搜索可执行文件
possible_exes = list((dist_dir / app_name).glob(f"*{ext}"))
if possible_exes:
exe_path = possible_exes[0]
# 8. 校验实机拉起
_assert_executable_execution(exe_path)
# =====================================================================
# 7. PyInstaller E2E 测试
# =====================================================================
def test_07_pyinstaller_real_build():
"""测试 7: 深度集成 UI 的 PyInstaller 真实打包与拉起测试"""
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 7/8] 深度集成测试: UI 操作 -> PyInstaller 构建 -> 实机拉起...{Colors.RESET}")
try:
import PyInstaller
except ImportError:
log_warn("缺少 PyInstaller,跳过测试")
return
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(["qp_test", "-platform", "offscreen"])
with tempfile.TemporaryDirectory(prefix="qp_e2e_pyi_") as tmp_dir:
start_t = time.time()
# 测试 PyInstaller 的单文件模式
run_e2e_via_mainwindow("PyInstaller", Path(tmp_dir), is_onefile=True)
log_pass(f"PyInstaller E2E 完整链路测试通过,耗时: {time.time() - start_t:.2f}s")
clean_project_junk()
# =====================================================================
# 8. Nuitka E2E 测试
# =====================================================================
def test_08_nuitka_real_build():
"""测试 8: 深度集成 UI 的 Nuitka 原生编译与拉起测试"""
print(f"\n{Colors.BOLD}{Colors.CYAN}>>> [测试 8/8] 深度集成测试: UI 操作 -> Nuitka 原生编译 -> 实机拉起...{Colors.RESET}")
try:
import nuitka
except ImportError:
log_warn("缺少 Nuitka,跳过测试")
return
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(["qp_test", "-platform", "offscreen"])
with tempfile.TemporaryDirectory(prefix="qp_e2e_nuitka_") as tmp_dir:
start_t = time.time()
# 测试 Nuitka 的文件夹模式 (Standalone)
run_e2e_via_mainwindow("Nuitka", Path(tmp_dir), is_onefile=False)
log_pass(f"Nuitka E2E 完整链路测试通过,耗时: {time.time() - start_t:.2f}s")
clean_project_junk()
# =====================================================================
# 主调度入口
# =====================================================================
def main():
print(f"""
{Colors.CYAN}{Colors.BOLD}======================================================================
🚀 QPyPack 全功能终极一体化自检与双引擎真实打包验证系统 (UI深度集成版)
======================================================================{Colors.RESET}""")
tests = [
("静态资源与工程配置", test_01_static_assets),
("多语言包格式对齐与校验", test_02_locales_and_placeholders),
("QPyPack 原生 AST 分析引擎", test_03_ast_engine),
("QPyPack 核心配置与翻译引擎逻辑", test_04_core_logic),
("Qt UI 主窗口状态机独立交互测试", test_05_ui_interaction),
("Wheel 分发包沙箱构建测试", test_06_wheel_packaging),
("PyInstaller E2E 链路验证 (单文件)", test_07_pyinstaller_real_build),
("Nuitka E2E 链路验证 (文件夹)", test_08_nuitka_real_build),
]
failed = False
for title, func in tests:
try:
func()
except Exception as e:
failed = True
log_fail(f"【{title}】未通过!原因: {e}")
print(f"\n{Colors.RED}详细错误回溯:{Colors.RESET}")
traceback.print_exc()
break
# 无论成功失败,强制执行纯净清理
clean_project_junk()
print(f"\n{Colors.BOLD}======================================================================{Colors.RESET}")
if not failed:
print(f"{Colors.GREEN}{Colors.BOLD} 🎉 [CONGRATULATIONS] 全部端到端深度集成测试 100% 顺利通过!{Colors.RESET}")
print(f"{Colors.GREEN} 💡 你的所有 UI 修改、功能迭代、参数注入均完美运行,且一切残留已被销毁!{Colors.RESET}")
print(f"{Colors.BOLD}======================================================================{Colors.RESET}\n")
sys.exit(0)
else:
print(f"{Colors.RED}{Colors.BOLD} 💥 [TEST FAILED] 发现程序缺陷!测试已中断,请根据上方回溯修复代码。{Colors.RESET}")
print(f"{Colors.BOLD}======================================================================{Colors.RESET}\n")
sys.exit(1)
if __name__ == "__main__":
main()