Compare commits

..
4 Commits
Author SHA1 Message Date
feifei.xu f188055228 清理无用代码、合并工具链、修复注释与日志问题
- 删除破损文件: main.py/main.ui/main_ui.py
- 删除 runtime_hook.py(归宿主维护),清除 tools/ 目录
- 合并 export_runtime/pack_zip/verify_companions 到 build_pyd.py
- 修复注释: docstring 参数与实际签名不一致、过时引用、拼写错误
- 修复日志: 消除静默吞异常、删冗余 log+raise、补缺失日志
- 精简 .gitignore
2026-07-21 18:14:34 +08:00
feifei.xu 91bb61444e feat: 增加伴生包加载器、manifest配置,Excel处理优化 2026-07-21 16:26:16 +08:00
feifei.xu 45e4f1ecf2 更新优化 2026-07-10 13:35:29 +08:00
feifei.xu 4c5fc718c1 上传初版 2026-07-10 13:35:16 +08:00
29 changed files with 2305 additions and 941 deletions
+8 -106
View File
@@ -28,8 +28,6 @@ share/python-wheels/
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
@@ -52,82 +50,6 @@ coverage.xml
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
@@ -137,42 +59,22 @@ ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
# Ruff
.ruff_cache/
# UV
.pdm.toml
.pdm-python
.pdm-build/
__pypackages__/
# PyPI configuration file
.pypirc
*.xlsx
*.md
/plugins
+441
View File
@@ -0,0 +1,441 @@
"""通过 Nuitka 把 mil 包及其纯 Python 伴生包编译为 .pyd,并完成导出、打包、部署、验证全流程。
策略说明:
- **mil 业务包**mil/ 整个包:core + ui)→ 编 pyd
- **Qt 桥接代码**mil/ui/*_ui.py)→ 保留为源码;
- **纯 Python 伴生包**COMPANIONS 列表)→ 逐个编 pyd,产物随 mil 一起分发;
- **Qt .ui 资源文件** → 运行时由 mil 包内的 uic.loadUiType 直接读源码路径;
- **PySide6** → --nofollow-import-to,运行时走宿主 pip 安装的二进制。
前置条件:
- openpyxl 3.x 的 C 扩展依赖 et_xmlfile 必须升到 >= 2.0(纯 Python 实现),
否则 Nuitka --module 模式会丢弃 _xmlfile.pyd 导致运行期 ImportError。
pip install --upgrade --force-reinstall "et_xmlfile>=2.0"
pip install --upgrade --force-reinstall openpyxl
用法(在工程根 F:\MyProject\mil_sdk 下):
python build_pyd.py # 编译 + 导出 + 打包 + 部署(默认全流程)
python build_pyd.py --clean # 清理 build/ 后走全流程
python build_pyd.py --no-progress # 关闭进度条(CI 友好)
python build_pyd.py --only mil # 只编指定包(逗号分隔)
python build_pyd.py --no-deploy # 不复制 zip 到网络盘
python build_pyd.py --verify # 单独验证伴生包加载链路
python build_pyd.py --verify --fallback # 验证时允许回退到 site-packages
全流程四步:
1. Nuitka 编译 .pyd → build/
2. export 聚合 → plugins/
3. pack 打包 → dist/<tool>-<ver>-<tag>.zip
4. 复制 zip → Y:/SE/xufeifei/plugins/--no-deploy 跳过)
产物布局:
build/
mil.cp311-win_amd64.pyd ← 业务包
plugins/ ← 中间产物
dist/
mil-<version>-cp311-win_amd64.zip ← 最终交付物
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent
BUILD_DIR = ROOT / "build"
PLUGINS_DIR = ROOT / "plugins"
DIST_DIR = ROOT / "dist"
REMOTE_DEPLOY_DIR = Path("Y:/SE/xufeifei/plugins")
# ============================================================
# 共享工具函数
# ============================================================
def _python_tag() -> str:
"""返回 Python ABI tag,如 cp311-win_amd64。"""
impl = "cp" + str(sys.version_info.major) + str(sys.version_info.minor)
plat = "win_amd64" if platform.system() == "Windows" else platform.machine()
return f"{impl}-{plat}"
def _read_plugin_meta() -> tuple[str, str, str]:
"""从 mil/__init__.py 源码提取插件名/版本/描述(绕过 PySide6 import)。"""
src = (ROOT / "mil" / "__init__.py").read_text(encoding="utf-8")
name = re.search(r'NMAE\s*=\s*"([^"]+)"', src).group(1)
major = re.search(r"__MAJOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
minor = re.search(r"__MINOR_VER:\s*int\s*=\s*(\d+)", src).group(1)
micro = re.search(r"__MICRO_VER:\s*int\s*=\s*(\d+)", src).group(1)
desc_match = re.search(r'DESCRIPITION\s*=\s*"""([\s\S]*?)"""', src)
desc = desc_match.group(1).strip() if desc_match else ""
return name, f"{major}.{minor}.{micro}", desc
# ============================================================
# 伴生包清单(从 manifest.py 加载,绕过 mil/__init__.py 的 PySide6 依赖)
# ============================================================
_spec = importlib.util.spec_from_file_location(
"mil_manifest", ROOT / "mil" / "core" / "manifest.py"
)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
COMPANIONS: list[str] = _mod.COMPANION_PACKAGES_PYD
# ============================================================
# Nuitka 编译
# ============================================================
def _resolve_entry(pkg: str) -> Path:
"""定位包的入口 __init__.py。
业务包(mil)从工程根找;第三方包从当前 Python 的 site-packages 找。
"""
local = ROOT / pkg / "__init__.py"
if local.is_file():
return local
try:
spec = importlib.util.find_spec(pkg)
if spec is not None and spec.origin:
origin = Path(spec.origin)
if origin.is_file():
return origin
except (ImportError, ValueError):
pass
raise FileNotFoundError(f"找不到包 {pkg} 的入口 __init__.py")
def build_one(pkg: str, no_progress: bool) -> Path:
"""对单个包跑 Nuitka --module,产出 <pkg>.<python-tag>.pyd。
关键约束:--module 模式下要编""必须传"包目录路径"(末尾带 /),
不能传 __init__.py。Nuitka 看到目录就会自动按包模式处理。
"""
entry = _resolve_entry(pkg)
pkg_dir = entry.parent
pkg_name = pkg_dir.name
is_local = pkg_dir.parent == ROOT
module_arg = str(pkg_dir) + "/"
cmd: list[str] = [
sys.executable, "-m", "nuitka",
"--module",
module_arg,
f"--include-package={pkg_name}",
f"--output-dir={BUILD_DIR}",
]
if not is_local:
cmd += [
"--nofollow-import-to=xml",
"--nofollow-import-to=xml.etree",
"--nofollow-import-to=xml.etree.ElementTree",
"--nofollow-import-to=zipfile",
"--nofollow-import-to=tempfile",
"--nofollow-import-to=datetime",
"--nofollow-import-to=decimal",
"--nofollow-import-to=re",
"--nofollow-import-to=json",
"--nofollow-import-to=argparse",
"--nofollow-import-to=logging",
]
if is_local:
for pkg in _mod.COMPANION_PACKAGES_INLINE:
cmd.append(f"--include-package={pkg}")
for mod in _mod.COMPANION_STDLIB_INLINE:
cmd.append(f"--include-module={mod}")
for ext_pkgs in _mod.COMPANION_C_EXTENSIONS.values():
for ext in ext_pkgs:
cmd.append(f"--nofollow-import-to={ext}")
cmd.append("--nofollow-import-to=pyexpat")
cmd += ["--no-deployment-flag=frame-useless-set-trace"]
if no_progress:
cmd.append("--no-progress")
subprocess.run(cmd, cwd=str(pkg_dir.parent), check=True)
candidates = list(BUILD_DIR.glob(f"{pkg_name}.*.pyd"))
if not candidates:
candidates = list((BUILD_DIR / pkg_name).glob(f"{pkg_name}.*.pyd"))
if not candidates:
raise FileNotFoundError(
f"Nuitka 未产出预期的 {pkg_name}.*.pyd(已扫遍 build/"
)
return candidates[0]
# ============================================================
# 导出 plugins/ 分发包(原 tools/export_runtime.py
# ============================================================
def _copy_pyd_files() -> list[str]:
"""把 build/ 下的 .pyd 拷到 plugins/,返回已拷贝的包名列表。"""
copied: list[str] = []
for pkg in _mod.COMPANION_PACKAGES + ["mil"]:
matches = list(BUILD_DIR.glob(f"{pkg}.*.pyd"))
if not matches:
matches = list((BUILD_DIR / pkg).glob(f"{pkg}.*.pyd"))
if not matches:
print(f"[export] WARN: build/ 下未找到 {pkg}.*.pyd,跳过")
continue
dst = PLUGINS_DIR / matches[0].name
shutil.copy2(matches[0], dst)
copied.append(pkg)
print(f"[export] {pkg}: {matches[0].name} -> {dst}")
return copied
def _copy_c_extensions() -> list[str]:
"""从 Python 安装目录拷贝 C 扩展 .pyd 到 plugins/。"""
copied: list[str] = []
py_root = Path(sys.base_prefix)
search_dirs = [py_root, py_root / "DLLs"]
for stdlib_pkg, ext_names in _mod.COMPANION_C_EXTENSIONS.items():
for ext_name in ext_names:
candidates: list[Path] = []
for d in search_dirs:
candidates = list(d.glob(f"{ext_name}.*.pyd"))
if candidates:
break
candidates = list(d.glob(f"{ext_name}*.pyd"))
if candidates:
break
if not candidates:
print(f"[export] WARN: 找不到 C 扩展 {ext_name}.pyd"
f"(已查 {', '.join(str(d) for d in search_dirs)}")
continue
dst = PLUGINS_DIR / candidates[0].name
shutil.copy2(candidates[0], dst)
copied.append(ext_name)
print(f"[export] C 扩展 {ext_name}: {candidates[0]} -> {dst}")
return copied
def _write_manifest(copied_packages: list[str], stdlib_packages: list[str],
c_extensions: list[str]) -> None:
"""生成 plugins/manifest.json,供宿主 runtime hook 读取并配置 sys.path。"""
name, version, desc = _read_plugin_meta()
manifest = {
"python_tag": _python_tag(),
"python_version": platform.python_version(),
"packages": copied_packages,
"stdlib": stdlib_packages,
"c_extensions": c_extensions,
"plugin_name": name,
"plugin_version": version,
"plugin_description": desc,
"tool_name": "mil",
}
dst = PLUGINS_DIR / "manifest.json"
dst.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"[export] manifest -> {dst}")
def _export_runtime() -> None:
"""导出插件分发包到 plugins/ 目录。"""
if not BUILD_DIR.exists():
print("[export] ERROR: build/ 不存在,请先运行 python build_pyd.py", file=sys.stderr)
raise SystemExit(1)
if PLUGINS_DIR.exists():
shutil.rmtree(PLUGINS_DIR)
PLUGINS_DIR.mkdir(parents=True)
copied_pyd = _copy_pyd_files()
copied_cext = _copy_c_extensions()
_write_manifest(copied_pyd, [], copied_cext)
print(f"\n[export] 完成!plugins/ 目录已就绪:{PLUGINS_DIR}")
print(f"[export] .pyd 文件: {len(copied_pyd)} 个(含已编入 mil.pyd 的 openpyxl/et_xmlfile")
print(f"[export] C 扩展: {len(copied_cext)} 个(运行时从插件目录加载)")
print(f"[export] Python 版本: {platform.python_version()}")
print(f"[export] 下一步: 把 plugins/ 目录放到宿主 exe 旁边")
# ============================================================
# 打包 zip 分发包(原 tools/pack_zip.py
# ============================================================
def _pack_zip() -> None:
"""把 plugins/ 目录打包成带版本号的 zip 分发包。"""
if not PLUGINS_DIR.exists() or not any(PLUGINS_DIR.iterdir()):
print("[pack] ERROR: plugins/ 不存在或为空,请先执行导出步骤", file=sys.stderr)
raise SystemExit(1)
name, version, _ = _read_plugin_meta()
manifest = json.loads((PLUGINS_DIR / "manifest.json").read_text(encoding="utf-8"))
tool_name = manifest["tool_name"]
zip_name = f"{tool_name}-{version}-{_python_tag()}.zip"
DIST_DIR.mkdir(parents=True, exist_ok=True)
zip_path = DIST_DIR / zip_name
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for f in PLUGINS_DIR.rglob("*"):
if f.is_file():
arcname = f.relative_to(PLUGINS_DIR).as_posix()
zf.write(f, arcname)
file_count = len(zipfile.ZipFile(zip_path).namelist())
print(f"[pack] 打包完成: {zip_path}")
print(f"[pack] 插件: {name} v{version}")
print(f"[pack] 文件数: {file_count}")
print(f"[pack] 下一步: 把 zip 复制到远程 {REMOTE_DEPLOY_DIR}")
# ============================================================
# 部署
# ============================================================
def _deploy_zip() -> None:
"""把 dist/ 下最新生成的 zip 复制到网络盘远程分发目录。"""
zips = sorted(DIST_DIR.glob("*.zip"), key=lambda f: f.stat().st_mtime, reverse=True)
if not zips:
print("[build_pyd] WARN: dist/ 下无 zip 文件,跳过部署", file=sys.stderr)
return
src = zips[0]
if not REMOTE_DEPLOY_DIR.exists():
print(f"[build_pyd] WARN: 远程目录不可达: {REMOTE_DEPLOY_DIR},跳过部署",
file=sys.stderr)
return
dst = REMOTE_DEPLOY_DIR / src.name
shutil.copy2(src, dst)
print(f"[build_pyd] 步骤 4/4: 已部署 {src.name} -> {dst}")
# ============================================================
# 验证伴生包加载链路(原 tools/verify_companions.py
# ============================================================
def verify_companions(fallback: bool = False) -> int:
"""验证 build/ 下伴生包 .pyd 的 openpyxl 读写闭环是否可用。
直接 import mil.core.companion_loader,不经过 mil/__init__.py
避免在无 PySide6 的开发机上因 UI 桥接而炸。
"""
spec = importlib.util.spec_from_file_location(
"mil_companion_loader", ROOT / "mil" / "core" / "companion_loader.py"
)
loader_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(loader_mod)
load_companions = loader_mod.load_companions
packages = list(_mod.COMPANION_PACKAGES)
loaded = load_companions(packages)
print(f"[verify] 加载器返回: {loaded}")
if not loaded and not fallback:
print(
"[verify] FAIL: 没有挂载到任何伴生包(是否忘了 python build_pyd.py?)\n"
" 调试期可加 --fallback 走 site-packages 自测加载器逻辑。",
file=sys.stderr,
)
return 1
if not loaded and fallback:
import importlib as _il
for pkg in packages:
_il.import_module(pkg)
loaded.append(pkg)
print(f"[verify] fallback 后挂载: {loaded}")
import openpyxl # noqa: WPS433
print(f"[verify] openpyxl 来自: {openpyxl.__file__}")
with tempfile.TemporaryDirectory() as td:
xlsx_path = Path(td) / "smoke.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws["A1"] = "time"
ws["B1"] = "sig1"
ws["A2"] = 0.0
ws["B2"] = 1
ws["A3"] = 1.0
ws["B3"] = 2
wb.save(xlsx_path)
wb2 = openpyxl.load_workbook(xlsx_path)
ws2 = wb2.active
assert ws2["A1"].value == "time", ws2["A1"].value
assert ws2["B1"].value == "sig1", ws2["B1"].value
assert float(ws2["A3"].value) == 1.0, ws2["A3"].value
assert int(ws2["B3"].value) == 2, ws2["B3"].value
print("[verify] PASS: openpyxl 写读闭环 ✓")
return 0
# ============================================================
# 主入口
# ============================================================
def main() -> None:
"""编译 mil 包及其伴生依赖为 .pyd,导出为 plugins/ 目录,打包为 zip 并部署至网络盘。
通过 --verify 可跳过编译,仅验证已编译产物的 openpyxl 读写闭环。
"""
parser = argparse.ArgumentParser(description="编译 mil + 伴生包为 .pyd,并打包成 zip")
parser.add_argument("--clean", action="store_true", help="清理 build/ 与上次产物")
parser.add_argument("--no-progress", action="store_true", help="关闭进度条(CI 友好)")
parser.add_argument("--no-deploy", action="store_true", help="不复制 zip 到网络盘")
parser.add_argument("--only", type=str, default="",
help="只编指定包(逗号分隔),如 --only mil,openpyxl;默认全编")
parser.add_argument("--verify", action="store_true", help="验证伴生包 .pyd 加载链路(不编译)")
parser.add_argument("--fallback", action="store_true",
help="验证时,未发现产物则回退到 site-packages(开发自测用)")
args = parser.parse_args()
if args.verify:
sys.exit(verify_companions(args.fallback))
if args.clean and BUILD_DIR.exists():
shutil.rmtree(BUILD_DIR)
targets = ["mil"]
if args.only:
targets = [p.strip() for p in args.only.split(",") if p.strip()]
else:
targets += COMPANIONS
print("[build_pyd] 步骤 1/4: Nuitka 编译 .pyd")
for pkg in targets:
print(f"[build_pyd] 正在编译: {pkg}")
out = build_one(pkg, args.no_progress)
print(f"[build_pyd] {pkg} -> {out}")
try:
print("[build_pyd] 步骤 2/4: 导出 plugins/ 分发包")
_export_runtime()
print("[build_pyd] 步骤 3/4: 打包 zip")
_pack_zip()
except SystemExit as e:
if e.code != 0:
print(f"[build_pyd] 打包流程中止(退出码 {e.code}", file=sys.stderr)
sys.exit(e.code)
if not args.no_deploy:
_deploy_zip()
if __name__ == "__main__":
main()
-25
View File
@@ -1,25 +0,0 @@
"""main.py - MIL SDK 测试示例"""
from pathlib import Path
from src.core import (
setup_logging,
read_excel_data,
read_excel_case,
create_excel_case,
update_case_excel,
)
def main() -> None:
"""MIL SDK 示例程序主函数"""
setup_logging()
excel_path = Path(__file__).parent
data_dict = read_excel_data("new_data.xlsx", return_object=False)
old_data_dict = read_excel_data("old_data.xlsx", return_object=False)
update_case_excel("new_case.xlsx", old_data_dict, data_dict)
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
{
"DataPath": "",
"FilePath": "",
"AddTimeEn": true,
"GeratePath": true,
"CurrProject": "ABC",
"ItemConfigs": {
"吉利E211": {
"DataPath": "C:/Users/xff1atk/Downloads/模型检查单.xlsx",
"FilePath": "C:/Users/xff1atk/Downloads/Geely_E22H_Signallist.xlsx"
},
"ABC": {
"DataPath": "C:/Users/xff1atk/Downloads/Geely_E22H_Signallist.xlsx",
"FilePath": "C:/Users/xff1atk/Downloads/合肥模型团队-MIL测试模版V0.3.xlsx"
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import re
from .ui.mil_tool import FrameMILTool
from .core.companion_loader import load_companions
from .core.manifest import COMPANION_PACKAGES
# 工具名称
NMAE = "MIL Tool"
# 工具版本
__MAJOR_VER: int = 0 # 主版本号
__MINOR_VER: int = 0 # 次版本号
__MICRO_VER: int = 6 # 修订版本号
VERSION:str = f"{__MAJOR_VER}.{__MINOR_VER}.{__MICRO_VER}"
# 工具描述
DESCRIPITION = """
主要用于生成Simulink Test Case。
"""
SVG = """
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 512 512"><path fill="#1de9b6" d="M495.855 367.604c-28.98-73.177-53.037-148.231-80.725-221.893c-12.23-31.362-24.198-62.986-40.868-92.33c-6.62-10.535-12.956-22.119-23.814-28.86c-2.739-1.74-5.828-2.479-8.88-2.42c-5.084.096-10.14 2.37-14.025 5.65c-14.571 11.543-23.209 28.334-32.896 43.793c-17.114 28.698-35.156 58.748-63.773 77.396c-13.44 9.485-31.039 10.514-43.995 20.686c-17.72 13.36-29.767 32.31-44.298 48.737c-3.31 3.956-8.436 5.53-12.916 7.67C86.315 243.71 42.945 261.35 0 279.916c36.367 28.132 75.115 53.157 112.208 80.321c10.172-2.018 20.383-6.196 30.877-4.339c16.63 5.207 26.377 21.15 34.006 35.721c15.5 31.765 26.7 65.307 39.253 98.283c20.988-1.493 39.878-12.31 56.104-25.025c31.24-25.307 55.034-58.001 81.23-88.192c12.552-13.199 25.347-28.92 44.197-32.593c19.05-4.601 39.454 2.22 53.581 15.338c19.818 17.719 37.376 38.203 60.544 51.765c-3.108-15.338-10.575-29.101-16.145-43.592M171.844 316.04c-18.284 10.817-37.699 19.717-56.71 29.162c-29.828-19.98-58.889-41.15-88.192-61.856c39.515-17.315 79.635-33.4 119.675-49.545c18.97 14.813 38.547 28.88 57.517 43.693c-8.92 14.248-19.657 27.487-32.29 38.546m40.665-49.646c-18.85-14.167-37.94-27.951-56.508-42.482c11.907-16.226 24.945-31.906 40.565-44.701c10.333-6.62 22.966-8.456 33.703-14.43c25.469-12.714 42.845-36.044 59.434-58.324c-21.271 55.216-43.975 110.654-77.194 159.937"/></svg>
"""
# 伴生包清单从 manifest 导入(唯一事实源),此处保留引用以兼容旧调用方
# 加新伴生包:改 mil/core/manifest.py 的 COMPANION_PACKAGES,不要改这里
def create_plugin(workspace, parent=None):
# 先挂伴生包,再构造 UI——mil 包内任何 import openpyxl 都将命中 build/ 下的 pyd
load_companions(COMPANION_PACKAGES)
return FrameMILTool(workspace, parent)
def create_plugin_svg(file:str):
with open(file,'w', encoding='utf-8') as f:
f.write(SVG.strip())
f.close()
def read_plugin_name():
return NMAE
def read_plugin_version():
return VERSION
def read_plugin_description():
return re.sub(r'^[\r\n]+',"",DESCRIPITION)
__all__ = [
"create_plugin",
"create_plugin_svg",
"read_plugin_name",
"read_plugin_version",
"read_plugin_description",
]
@@ -3,14 +3,12 @@
提供 MIL 仿真数据读取功能
使用示例:
from src.core import setup_logging, read_excel_data
from mil.core import read_excel_data
setup_logging()
result = read_excel_data("simulation.xlsx")
"""
import logging
from .base import DataLog, SignalData, ExcelDataResult
from .base import DataLog, SignalData, ExcelDataResult,Config
from .mil_read_data_excel import read_excel_data, ExcelReaderConfig
from .mil_read_case_excel import read_excel_case
from .mil_create_data_excel import create_excel_case
@@ -23,7 +21,9 @@ from .exceptions import (
CaseDataError,
ExcelWriteError,
)
from .logging_config import setup_logging, get_logger
__all__ = [
"DataLog",
@@ -39,6 +39,5 @@ __all__ = [
"ExcelFormatError",
"CaseDataError",
"ExcelWriteError",
"setup_logging",
"get_logger",
"Config"
]
+174
View File
@@ -0,0 +1,174 @@
"""MIL SDK 核心数据模型"""
import os
import json
import logging
from dataclasses import dataclass, field, fields
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class Config():
"""MIL SDK 全局配置
承载测试工程相关的全局开关与路径信息,是 UI 层与 core 层之间的
配置传输载体。序列化使用 JSON 文件持久化。
Attributes:
path: 配置文件路径(JSON 文件)
DataPath: 仿真数据目录
FilePath: 当前打开的 Excel 文件路径
AddTimeEn: 用例步骤时间是否按累加方式记录
GeratePath: 是否为生成的用例另存新文件
CurrProject: 当前工程名称
ItemConfigs: 各模块/用例项的细粒度配置
"""
path:str
DataPath:str = ""
FilePath:str = ""
AddTimeEn:bool = True
GeratePath:bool = True
CurrProject:str = ""
# dict 是可变类型,必须用 default_factory 显式实例化,避免多个 Config 共享同一对象
ItemConfigs: dict = field(default_factory=dict)
def check_path(self):
return os.path.exists(self.path)
def to_dict(self):
"""将 Config 实例序列化为 dict,便于写入 JSON 文件。"""
return {
"DataPath":self.DataPath,
"FilePath":self.FilePath,
"AddTimeEn":self.AddTimeEn,
"GeratePath":self.GeratePath,
"CurrProject":self.CurrProject,
"ItemConfigs":self.ItemConfigs,
}
def load_config(self) -> "Config":
"""从 self.path 指定的 JSON 文件读取配置并填充到当前实例的各个字段。
解析规则:
- JSON 中存在的字段会被回写到 Config 的对应字段;
- JSON 中缺失的字段保持当前 Config 实例的默认值;
- JSON 中多余字段被忽略(向前兼容:增加字段不会破坏老配置)。
Returns:
self:填充后的 Config 实例,便于链式调用。
Raises:
FileNotFoundError: 配置文件不存在。
json.JSONDecodeError: 文件内容不是合法 JSON。
"""
try:
# 以 UTF-8 读取 JSON,避免 Windows 默认编码带来的乱码问题
with open(self.path, 'r', encoding='utf-8') as f:
raw = json.load(f)
except FileNotFoundError:
# 显式记录并重抛,遵循"错误必须显式处理,禁止静默失败"
logger.error(f"配置文件{self.path}未找到")
raise
if not isinstance(raw, dict):
# 文件不合法(非 dict 根节点)→ 用一个空 dict 填充,保持实例可用
raw = {}
# 用反射取出本类已声明的字段名白名单,避免 JSON 脏字段污染
allowed = {f.name for f in fields(self.__class__)}
for key, value in raw.items():
if key in allowed:
setattr(self, key, value)
return self
def save_config(self):
"""将当前配置以 JSON 格式写入 self.path 指定的文件。
Raises:
Exception: 写入失败时记录日志并原样抛出异常。
"""
try:
with open(self.path,'w',encoding='utf-8') as f:
json.dump(self.to_dict(), f, indent=4, ensure_ascii=False)
except Exception as e:
logger.error(f"配置文件{self.path}写入失败: {e}")
raise
@dataclass
class DataLog:
"""仿真数据日志记录
Attributes:
time: 时间戳(秒)
value: 信号值(字符串形式,写入 Excel 时按内容推断 int/str 类型)
"""
time: float = 0.0
value: str = ""
@dataclass
class SignalData:
"""信号数据封装
描述 Excel 中某一列信号的完整信息:
- 所在列号(column
- 列上方的属性行(attributes,例如 Signal Name / BlockPath 等)
- 时间-值采样序列(datalog)
Attributes:
column: 信号在 Excel 工作表中的列索引(1-based)
attributes: 列上方各属性行(行号 -> 文本)的字典映射
datalog: 时间戳-数值采样点列表
"""
column: int = 0
attributes: dict[str, str] = field(default_factory=dict)
datalog: list[DataLog] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""将 SignalData 转为 dict,便于跨层传输与持久化。
Returns:
包含 column、attributes、datalog 字段的字典。
"""
return {
"column": self.column,
"attributes": self.attributes,
"datalog": self.datalog
}
@dataclass
class ExcelDataResult:
"""Excel 数据读取结果封装
提供对 Excel 数据的类型安全访问,隐藏内部实现细节
Attributes:
sheet_name: 工作表名称
source_row: Source: Input 所在行号
signals: 信号名称到信号数据的映射
"""
sheet_name: str = "Scenario1"
source_row: int = 0
signals: dict[str, SignalData] = field(default_factory=dict)
def get_signal(self, name: str) -> SignalData | None:
"""获取指定信号的数据
Args:
name: 信号名称
Returns:
信号数据对象,如果不存在返回 None
"""
return self.signals.get(name)
def get_signal_names(self) -> list[str]:
"""获取所有信号名称
Returns:
信号名称列表
"""
return list(self.signals.keys())
+104
View File
@@ -0,0 +1,104 @@
"""伴生包加载器:把纯 Python 伴生包从产物目录挂到 sys.modules。
设计目标:宿主 exe 一次打包后,mil 的伴生依赖(openpyxl / et_xmlfile / ...
可通过"覆盖 build/<pkg>/ 下的 .pyd 或 mil_runtime/<pkg>/ 下的源码"增量更新,
宿主不需要重打。
加载顺序(命中即停):
1. sys.modules 已存在(不动)
2. mil_runtime/<pkg>/ 下的纯 Python 源码(数据化部署)
3. build/<pkg>/ 下的预编译 .pyd(本轮主路径)
4. 都不命中 → 跳过并 warning,由调用方决定是否兜底
约定:
- mil 包的父目录下存在两个候选目录:mil_runtime/(源码)和 build/.pyd)。
- 解析顺序可通过环境变量 MIL_RUNTIME_DIR / MIL_BUILD_DIR 覆盖。
"""
from __future__ import annotations
import importlib.util
import logging
import os
import sys
from pathlib import Path
from types import ModuleType
logger = logging.getLogger(__name__)
DEFAULT_RUNTIME_DIRNAME = "mil_runtime"
DEFAULT_BUILD_DIRNAME = "build"
def _mil_root() -> Path:
"""获取 mil 包的父目录(即 mil_runtime/ 与 build/ 所在的工程根)。
不通过 `import mil` 解析位置,避免触发 mil 包完整初始化
(宿主侧必装 PySide6,开发侧不一定有)。
"""
# 本文件位于 <root>/mil/core/companion_loader.py,往上两级就是 <root>
return Path(__file__).resolve().parent.parent.parent
def _load_from_source(runtime_dir: Path, pkg: str) -> ModuleType | None:
"""尝试从 mil_runtime/<pkg>/ 加载纯 Python 源码包。"""
pkg_dir = runtime_dir / pkg
if not (pkg_dir / "__init__.py").is_file():
return None
spec = importlib.util.spec_from_file_location(
pkg,
pkg_dir / "__init__.py",
submodule_search_locations=[str(pkg_dir)],
)
if spec is None or spec.loader is None:
logger.warning(f"无法为 {pkg} 构造 specruntime 源码),跳过")
return None
module = importlib.util.module_from_spec(spec)
sys.modules[pkg] = module
spec.loader.exec_module(module)
return module
def _load_from_pyd(build_dir: Path, pkg: str) -> ModuleType | None:
"""尝试从 build/<pkg>/<pkg>.<python-tag>.pyd 加载预编译包。"""
candidates = list(build_dir.glob(f"{pkg}.*.pyd"))
if not candidates:
return None
pyd_path = candidates[0]
spec = importlib.util.spec_from_file_location(pkg, pyd_path)
if spec is None or spec.loader is None:
logger.warning(f"无法为 {pkg} 构造 specpyd={pyd_path}),跳过")
return None
module = importlib.util.module_from_spec(spec)
sys.modules[pkg] = module
spec.loader.exec_module(module)
return module
def load_companions(packages: list[str]) -> list[str]:
"""按上述顺序把每个伴生包挂到 sys.modules,返回成功挂载的包名列表。"""
root = _mil_root()
runtime_dir = Path(os.environ.get("MIL_RUNTIME_DIR", root / DEFAULT_RUNTIME_DIRNAME))
build_dir = Path(os.environ.get("MIL_BUILD_DIR", root / DEFAULT_BUILD_DIRNAME))
loaded: list[str] = []
for pkg in packages:
if pkg in sys.modules:
loaded.append(pkg)
continue
mod = (
_load_from_source(runtime_dir, pkg)
or _load_from_pyd(build_dir, pkg)
)
if mod is not None:
loaded.append(pkg)
logger.info(f"伴生包已挂载: {pkg}")
else:
logger.warning(
f"伴生包未找到: {pkg}runtime={runtime_dir} / build={build_dir} 均无)"
)
return loaded
+33
View File
@@ -0,0 +1,33 @@
"""MIL SDK 自定义异常模块
约定:
- 所有 SDK 主动抛出的异常均继承自 MILSDKError,便于上层统一捕获;
- 异常命名体现"出错阶段"(读取 / 格式 / 用例数据 / 写入),
调用方只需按需捕获细分异常,或在外层兜底捕获 MILSDKError。
"""
class MILSDKError(Exception):
"""MIL SDK 所有自定义异常的根类,UI 层可针对此类型统一兜底。"""
pass
class ExcelReadError(MILSDKError):
"""Excel 读取阶段错误:文件不存在、权限不足、被占用、底层解析失败等。"""
pass
class ExcelFormatError(MILSDKError):
"""Excel 内容格式错误:缺少约定的工作表、缺少 'Source: Input' 标记、
模板版本号缺失等结构性异常。"""
pass
class CaseDataError(MILSDKError):
"""用例数据语义错误:信号名未在数据字典中找到、步骤时间非数值等。"""
pass
class ExcelWriteError(MILSDKError):
"""Excel 写入阶段错误:磁盘权限、文件被占用、保存失败等。"""
pass
+41
View File
@@ -0,0 +1,41 @@
"""插件运行期依赖清单。
这是唯一的事实源:
- build_pyd.py 读 COMPANION_PACKAGES_PYD 决定要编哪些伴生包;
- build_pyd.py 编译 mil 时用 --include-package 把 COMPANION_PACKAGES_INLINE 编进 mil.pyd
- build_pyd.py 导出步骤读 COMPANION_C_EXTENSIONS 决定要拷哪些 C 扩展。
加新伴生包时只动这里,其它脚本自动跟随。
"""
from __future__ import annotations
# 需 Nuitka 编译成独立 .pyd 的伴生包(业务代码需保护)
COMPANION_PACKAGES_PYD: list[str] = []
# 编译 mil 时用 --include-package 编进 mil.pyd 的纯 Python 包
# 这些包的 .py 代码编进 mil.pyd,运行时内部 hard-import,不查 sys.path
COMPANION_PACKAGES_INLINE: list[str] = [
"openpyxl",
"et_xmlfile",
]
# 编译 mil 时用 --include-module 编进 mil.pyd 的 stdlib 子模块
# Nuitka 默认不编译 stdlib--nofollow-stdlib),所以必须显式 include。
# 这些是 openpyxl/et_xmlfile 间接依赖的纯 Python stdlib,编进 mil.pyd 后
# 运行时 hard-import,不查 sys.path。只有 C 扩展(_elementtree)留给运行时。
COMPANION_STDLIB_INLINE: list[str] = [
"xml.etree",
"xml.etree.ElementTree",
"xml.etree.ElementPath",
"xml.etree.ElementInclude",
]
# 兼容旧接口:所有伴生包
COMPANION_PACKAGES: list[str] = COMPANION_PACKAGES_PYD + COMPANION_PACKAGES_INLINE
# 插件依赖的 C 扩展模块(Nuitka --module 编不进去,运行时从插件目录加载)
# key = 所属 stdlib 包名, value = C 扩展模块名列表
COMPANION_C_EXTENSIONS: dict[str, list[str]] = {
"xml": ["_elementtree"], # xml.etree.ElementTree 内部 from _elementtree import *
}
@@ -24,18 +24,23 @@ def create_excel_case(
data_dict: dict,
sheets_dict: dict,
) -> None:
"""生成测试用例 Excel 文件
"""根据读入的数据字典与用例模板,批量生成测试用例 Excel 文件
每个用例以"用例名.xlsx"的形式输出到 excel_path 目录下文件格式
read_excel_data 读入的模板保持一致
Args:
excel_path: 输出目录路径
data_dict: 数据字典
sheets_dict: 工作表字典
excel_path: 输出目录路径
data_dict: read_excel_data 返回的字典需要包含 wb / sheet / source_row
以及每个信号的 column / datalog
sheets_dict: read_excel_case 返回的用例字典
Raises:
CaseDataError: 数据异常
ExcelWriteError: 文件写入错误
CaseDataError: 传入的数据字典类型不符合 Workbook/Worksheet
ExcelWriteError: 保存失败路径非法权限不足等
"""
logger.info(f"开始生成测试用例,输出目录: {excel_path}")
# 取出原始工作簿的引用;后续每个用例都基于原 wb 做 deepcopy,避免相互污染
wb = data_dict.get('wb')
sheet = data_dict.get('sheet')
source_row = data_dict.get('source_row')
@@ -44,11 +49,13 @@ def create_excel_case(
logger.error("数据类型错误,Workbook 或 Worksheet 类型不匹配")
raise CaseDataError("数据异常,Workbook 或 Worksheet 类型错误")
# 拆出"信号 -> 数据"部分,剥离 Workbook 元数据,便于对每个用例独立处理
data_dict_copy = {k: v for k, v in data_dict.items()
if k not in ('wb', 'sheet', 'source_row')}
datas_dict = _analysis_case(data_dict_copy, sheets_dict)
case_count = 0
# 对每个用例:拷贝原工作簿 → 写入信号值 → 另存为独立文件
for name in datas_dict.keys():
case_count += 1
logger.debug(f"正在生成用例: {name}")
@@ -58,7 +65,8 @@ def create_excel_case(
generate_path = f"{excel_path}/{name}.xlsx"
_write_excel_data(sheet, datas_dict[name], source_row)
wb.save(generate_path)
# 注意必须保存副本,不能回写源 wb(修复后的关键点)
new_wb.save(generate_path)
logger.info(f"已生成用例: {name},路径: {generate_path}")
@@ -66,16 +74,17 @@ def create_excel_case(
def _write_excel_data(sheet: Worksheet, data_dict: dict, source_row: int) -> None:
"""写入 Excel 数据
"""将每个信号的 datalog 序列写回到对应单元格,并校正末尾时间戳。
Args:
sheet: Worksheet 对象
data_dict: 数据字典
source_row: Source: Input 所在行号
sheet: 目标 Worksheet 对象
data_dict: 当前用例的"信号 -> {column, datalog}"字典
source_row: "Source: Input" 所在行号数据起始行
"""
for name in data_dict.keys():
column = data_dict[name]["column"]
datalog = data_dict[name]["datalog"]
# 逐点写入:根据值类型设置 data_type,便于后续读取时类型还原
for index, data in enumerate(datalog):
try:
value = int(data.value)
@@ -85,10 +94,12 @@ def _write_excel_data(sheet: Worksheet, data_dict: dict, source_row: int) -> Non
sheet.cell(row=source_row + index, column=column).data_type = "str"
finally:
sheet.cell(row=source_row + index, column=column).value = value
# 第 2 列(首个信号列)额外把 time 写进 A 列,保持和原模板一致
if column == 2:
sheet.cell(row=source_row + index, column=1).value = data.time
sheet.cell(row=source_row + index, column=1).data_type = "float"
# 校正尾部时间戳:当最后一行的时间小于信号最后采样的 time 时,沿用信号末尾时间
column = 2
while sheet.cell(1, column).value != "time":
column += 1
@@ -102,14 +113,18 @@ def _write_excel_data(sheet: Worksheet, data_dict: dict, source_row: int) -> Non
def _analysis_case(data_dict: dict, sheets_dict: dict) -> CaseResultDict:
"""分析用例字典,将用例中的信号名替换为信号值
"""遍历每个用例模板,把"信号名"展开成"信号值"序列,得到每个用例的最终数据字典。
处理逻辑
- enable=True 的用例跳过不生成
- 缺失 enable 字段也跳过避免模板脏数据导致运行期错误
Args:
data_dict: 数据字典
sheets_dict: 工作表字典
data_dict: 去除 wb/sheet/source_row 后的数据字典
sheets_dict: 用例字典read_excel_case 的返回
Returns:
处理后的用例字典
用例名 -> 用例专属数据字典 的映射
"""
datas_dict: CaseResultDict = {}
for sheet_name in sheets_dict.keys():
@@ -120,8 +135,10 @@ def _analysis_case(data_dict: dict, sheets_dict: dict) -> CaseResultDict:
if sheet_dict[case]["enable"]:
continue
except KeyError:
logger.warning(f"用例 '{case}' 缺少 enable 字段,跳过生成")
continue
# 拷贝数据并重置 datalog,然后按步骤逐条填充
datas_dict[case] = copy.deepcopy(data_dict)
_init_data_log(datas_dict[case])
_analysis_step(sheet_dict[case]["step"], datas_dict[case])
@@ -129,34 +146,40 @@ def _analysis_case(data_dict: dict, sheets_dict: dict) -> CaseResultDict:
def _analysis_step(step_dict: dict, data_dict: dict) -> None:
"""分析步骤字典,将步骤中的信号名替换为信号值
"""步骤字典依次处理每一步:有 action 走 action;无 action 走"信号保持"
Args:
step_dict: 步骤字典
data_dict: 数据字典
step_dict: 步骤字典 {"step0": {...}, "step1": {...}, ...}
data_dict: 当前用例的数据字典in-place 修改
"""
for step_key in step_dict.keys():
if "action" in step_dict[step_key].keys():
# 该步骤显式定义了信号赋值
_analysis_data(step_dict[step_key]["action"], data_dict, step_dict[step_key]["time"])
else:
# 无 action:对每个信号按"前一帧值保持"在时间轴上插一个数据点
for name in data_dict.keys():
datalog = data_dict[name]["datalog"]
datalog.append(DataLog(step_dict[step_key]["time"], datalog[-1].value))
def _analysis_data(action_dict: dict, data_dict: dict, time: float) -> None:
"""分析数据字典,将数据字典中的信号名替换为信号值
"""对单步 action 展开:为每个被赋值的信号追加或修改 datalog 中的当前时间点。
注意data_log_len 用于本步结束后将"未参与动作的其他信号"补齐到同等长度
保持所有信号在同一时间轴上对齐
Args:
action_dict: 操作字典
data_dict: 数据字典
time: 时间戳
action_dict: 操作字典信号名 -> 字符串值
data_dict: 当前用例的数据字典in-place 修改
time: 本步骤的时间戳
Raises:
CaseDataError: 信号不存在
CaseDataError: action 中存在 data_dict 找不到的信号
"""
data_log_len: int | None = None
for name in action_dict.keys():
# 大小写不敏感匹配:避免 Excel 中信号名大小写差异导致的 KeyError
matched_key: str | None = None
for key in data_dict.keys():
if name.lower() == key.strip().lower():
@@ -167,11 +190,14 @@ def _analysis_data(action_dict: dict, data_dict: dict, time: float) -> None:
else:
datalog = data_dict[matched_key]["datalog"]
if datalog[-1].time == time:
# 同一时间戳已存在 → 修改末点值;多步合并到同一时刻
datalog[-1].value = action_dict[name]
data_log_len = len(datalog)
else:
# 追加新的采样点
datalog.append(DataLog(time, action_dict[name]))
data_log_len = len(datalog)
# 对未参与动作的信号补点(保持上一帧值),保证时间轴对齐
for name in data_dict.keys():
datalog = data_dict[name]["datalog"]
if len(datalog) != data_log_len:
@@ -179,10 +205,10 @@ def _analysis_data(action_dict: dict, data_dict: dict, time: float) -> None:
def _init_data_log(data_dict: dict) -> None:
"""初始化数据日志
"""把每个信号的 datalog 重置为只保留首点,作为各用例的初始状态。
Args:
data_dict: 数据字典
data_dict: 数据字典in-place 修改
"""
for name in data_dict.keys():
data_dict[name]["datalog"] = data_dict[name]["datalog"][:1]
@@ -28,7 +28,13 @@ CaseDict = dict[str, Any]
class CaseColumns:
"""用例 Excel 列索引常量"""
"""用例 Excel 各列含义的常量定义。
TITLE: 用例标题用于分组与启停判断
STATUS: 用例状态"完成测试"
ACTION: 操作描述 "signal1=value1; signal2=value2"
NAME: 当前步骤的名称
TIME: 当前步骤的时间戳浮点
"""
TITLE = 1
STATUS = 2
ACTION = 3
@@ -36,6 +42,7 @@ class CaseColumns:
TIME = 5
# STATUS 列等于此值表示该用例已被勾选为"已完成测试"
STATUS_COMPLETE = "完成测试"
@@ -44,63 +51,80 @@ def read_excel_case(
data_dict: dict,
addTimeEn: bool,
) -> CaseDict:
"""读取 Excel 用例模板
"""读取 Excel 用例模板并解析为内存中的结构化用例字典。
Args:
excel_path: Excel 模板文件路径
data_dict: 信号数据字典
addTimeEn: 是否累加时间
excel_path: Excel 模板文件路径
data_dict: 数据字典来自 read_excel_data用于校验 action 中的信号名
addTimeEn: True 表示按"累加"方式记录每一步的时间戳
False 表示直接使用步骤中填写的时间
Returns:
用例字典
用例字典
{
sheet_title: {
case_name: {
"enable": bool,
"step": {
"step0": {"name": ..., "time": ..., "action": {...} 可选},
...
}
}
}
}
Raises:
ExcelReadError: 文件读取失败
ExcelFormatError: 格式错误
CaseDataError: 用例数据错误
ExcelReadError: 文件读取失败不存在 / 权限问题
ExcelFormatError: 缺少 Atech-Hefei / 缺少模板版本号
CaseDataError: 标题缺失 / 时间非数值 / action 信号未在 data_dict 中找到
"""
logger.info(f"开始读取用例模板: {excel_path}")
# 用只读 + 取值模式打开,避免触发公式重算、降低内存占用
try:
wb = load_workbook(excel_path, read_only=True, data_only=True)
logger.debug(f"Excel 文件加载成功,Sheet 数量: {len(wb.sheetnames)}")
except FileNotFoundError:
raise ExcelReadError(f"Excel文件 {excel_path} 不存在")
except Exception as e:
logger.error(f"读取 Excel 文件失败: {e}")
raise ExcelReadError(f"无法读取Excel文件 {excel_path}: {str(e)}")
# 版本表 Atech-Hefei 必须存在;解析后将其从工作簿移除,避免进入后续遍历
try:
version = _get_template_version(wb["Atech-Hefei"])
if version is None:
logger.error("缺少模板版本号")
raise ExcelFormatError("Excel格式错误,缺少模板版本号")
logger.info(f"用例模板版本号: {version}")
del wb["Atech-Hefei"]
except KeyError:
logger.error(f"缺少 'Atech-Hefei'")
raise ExcelFormatError(f"Excel文件 {excel_path} 格式错误,缺少 Atech-Hefei 表")
result_dict: CaseDict = {}
sheet_count = 0
# 按 sheet 维度组织用例:每个 sheet 内连续的行通过 "标题" 列切换归属
for sheet in wb.worksheets:
sheet_count += 1
logger.debug(f"正在读取用例模板表: {sheet.title}")
result_dict[sheet.title] = {}
# 当前行所属标题 / 上一次见到过的标题,用于检测标题变化、新用例的开始
new_head = None
old_head = None
step_id = 0
row = 2
# 累加模式下的当前累计时间
old_time = 0.0
while True:
# 自上而下扫描 TITLE 列;遇到 None 才视为结束
if sheet.cell(row=row, column=CaseColumns.TITLE).value is not None:
new_head = sheet.cell(row=row, column=CaseColumns.TITLE).value
if new_head is None:
# 标题列出现连续空,说明用例段落已结束(不能一开始就为空)
raise CaseDataError(
f"Excel文件 {excel_path} 格式错误,{sheet.title}{row} 行第 {CaseColumns.TITLE} 列必须有标题名"
)
# 标题切换:意味着进入新用例,重置步骤序号与累加时间
if new_head != old_head:
step_id = 0
old_time = 0.0
@@ -109,6 +133,7 @@ def read_excel_case(
result_dict[sheet.title][new_head] = {}
result_dict[sheet.title][new_head]["step"] = {}
# 是否启用(仅看该用例首行的 STATUS 即可)
if sheet.cell(row=row, column=CaseColumns.STATUS).value == STATUS_COMPLETE:
result_dict[sheet.title][new_head]["enable"] = True
else:
@@ -116,8 +141,10 @@ def read_excel_case(
step_name = sheet.cell(row, CaseColumns.NAME).value
step_time = sheet.cell(row, CaseColumns.TIME).value
# 时间为空 → 当前用例段落的所有步骤处理完毕
if step_time is None:
break
# 时间列必须是浮点数字,否则视为脏数据
try:
step_time = float(step_time)
except (ValueError, TypeError):
@@ -129,9 +156,11 @@ def read_excel_case(
result_dict[sheet.title][new_head]["step"][f"step{step_id}"]["name"] = step_name
if addTimeEn:
# 累加模式下:用 old_time 累加,写出"绝对时间"
old_time += step_time
result_dict[sheet.title][new_head]["step"][f"step{step_id}"]["time"] = old_time
# 解析 action 字符串(信号名=信号值;...),缺省视为无动作
strings = sheet.cell(row, CaseColumns.ACTION).value
if strings is not None:
result_dict[sheet.title][new_head]["step"][f"step{step_id}"]["action"] = \
@@ -144,16 +173,17 @@ def read_excel_case(
def _get_template_version(sheet: Worksheet) -> str | None:
"""Excel 模板版本号
"""'Atech-Hefei' 表中 TITLE 列自第 2 行起的全部版本号文本。
Args:
sheet: Excel 工作表
sheet: Excel 工作表对象
Returns:
模板版本号如果未找到返回 None
模板版本号字符串若整列均为空返回 None
"""
row = 2
version = None
# 持续向下读取直到遇到空单元格;保留最后一个非空值作为版本号
while sheet.cell(row=row, column=CaseColumns.TITLE).value is not None:
version = sheet.cell(row=row, column=CaseColumns.TITLE).value
row += 1
@@ -168,23 +198,30 @@ def _analysis_action(
data_dict: dict,
excel_path: str
) -> dict[str, str]:
"""解析操作字符串
""""signal1=value1; signal2=value2" 形式的操作字符串解析为字典。
解析规则
- 兼容中文分号 "" 与换行符
- 容忍空格
- "=" 的片段直接跳过
- 信号名按"忽略大小写 + 去空格" data_dict 中匹配匹配失败抛 CaseDataError
Args:
sheet: Excel 工作表
row: 行号
column: 列号
strings: 操作字符串
data_dict: 数据字典
excel_path: Excel 文件路径
sheet: Excel 工作表用于错误信息中显示 sheet
row: 当前所在行号
column: 当前所在列号
strings: 原始操作字符串
data_dict: 来自 read_excel_data 的信号字典
excel_path: 仅用于错误信息中上下文
Returns:
操作字典
信号名 -> 信号值的字典
Raises:
CaseDataError: 信号不存在或格式错误
CaseDataError: 信号 data_dict 中找不到
"""
action_dict: dict[str, str] = {}
# 中文分号替换为半角;同时剔除换行避免空片段
strings = strings.replace("", ";").replace("\n", "")
for item in strings.split(";"):
@@ -192,13 +229,16 @@ def _analysis_action(
if not item:
continue
# 容忍信号与值之间的空格,统一去掉
item = item.replace(" ", "")
if "=" not in item:
# 没有 "=" 视为非法片段,直接跳过(保持容错)
continue
signal_name, signal_value = item.split("=", 1)
signal_name = signal_name.strip()
# 大小写不敏感地在 data_dict 中查找原始 key(保留原拼写用于回写)
matched_key: str | None = None
for key in data_dict.keys():
if signal_name.lower() == key.strip().lower():
@@ -32,20 +32,14 @@ class ExcelReaderConfig:
source_header: 数据源标记行文本默认 "Source: Input"
time_column: 时间列索引默认 1A
header_row: 信号名称所在行号默认 1
type_row: 信号类型所在行号默认 3
data_start_row_offset: 相对于 source_row 的数据起始行偏移量默认 1
"""
sheet_name: str = "Scenario1"
source_header: str = "Source: Input"
time_column: int = 1
header_row: int = 1
type_row: int = 3
interp_row: int = 6
data_start_row_offset: int = 1
output_header: str = "Source: Output"
block_path_row: int = 4
def read_excel_data(
excel_path: str,
@@ -75,56 +69,57 @@ def read_excel_data(
ExcelReadError: 文件不存在或读取失败
ExcelFormatError: Excel 格式不符合预期
"""
# 未显式传入配置时,退回到默认约定值
config = config or ExcelReaderConfig()
logger.info(f"开始读取文件: {excel_path}")
# 1) 加载底层 Excel 文件:openpyxl 自身抛出的两类异常分别映射到自定义异常类
try:
wb = load_workbook(excel_path)
logger.info(f"文件加载成功,Sheet 数量: {len(wb.sheetnames)}")
except FileNotFoundError:
logger.error(f"文件不存在: {excel_path}")
raise ExcelReadError(f"Excel文件 {excel_path} 不存在")
except Exception as e:
logger.error(f"读取文件失败: {e}")
raise ExcelReadError(f"读取Excel文件 {excel_path} 失败: {e}")
# 2) 取出约定名称的工作表,KeyError 表示工作表缺失,视为格式错误
try:
sheet = wb[config.sheet_name]
except KeyError:
logger.error(f"缺少 '{config.sheet_name}'")
raise ExcelFormatError(f"Excel文件 {excel_path} 缺少 '{config.sheet_name}'")
# 3) 自第 1 行起纵向扫描 "Source: Input" 标记行
column = 2
source_row = 1
while sheet.cell(row=source_row, column=column).value != config.source_header:
source_row += 1
# 扫描到表格末尾仍未命中,认定为格式错误
if source_row >= sheet.max_row:
logger.error(f"缺少 {config.source_header} 标记")
raise ExcelFormatError(f"Excel格式错误,缺少 {config.source_header}")
logger.debug(f"找到 {config.source_header} 标记,行号: {source_row}")
# 4) 沿 source_row 横向遍历每一列,识别并解析出每个有效信号
signals: SignalDict = {}
column = 2
current_header = None
while True:
# 当列所在单元格的标记不再是 source_header 时,说明信号区域结束
value = sheet.cell(source_row, column).value
if value is not None:
current_header = value
if current_header is None:
logger.error(f"缺少 {config.source_header} 标记")
raise ExcelFormatError(f"Excel格式错误,缺少 {config.source_header}")
if current_header == config.source_header:
if value != config.source_header:
break
# 取 header_row 行的列名作为信号名
name = sheet.cell(config.header_row, column).value
if name is None:
break
# 时间列与若干固定属性名跳过
if name == "time":
column += 1
continue
if name in ("Parameter:", "Value", "BlockPath"):
column += 1
continue
# 收集列上方属性 + 数据序列,写入 SignalData
attributes = __get_signal_attributes(sheet, column, source_row)
if attributes is None:
break
@@ -134,13 +129,12 @@ def read_excel_data(
datalog=__get_data_log(sheet, source_row + config.data_start_row_offset, column, config.time_column)
)
logger.debug(f"解析信号: {name}, 数据点: {len(signals[name].datalog)}")
else:
break
column += 1
signal_count = len(signals)
logger.info(f"解析完成,共 {signal_count} 个信号")
# 新接口:返回强类型 ExcelDataResult(推荐)
if return_object:
return ExcelDataResult(
sheet_name=config.sheet_name,
@@ -148,6 +142,7 @@ def read_excel_data(
signals=signals
)
# 旧接口:保持返回 dict 以便调用方继续拿到 Workbook / Worksheet 引用
data: DataDict = {
"wb": wb,
"sheet": sheet,
@@ -164,15 +159,15 @@ def __get_signal_attributes(
column: int,
max_row: int
) -> dict[int, str]:
"""从指定位置读取信号属性字典
"""读取指定列在 [1, max_row) 范围内各属性行的内容。
Args:
sheet: Worksheet 对象
column: 列号
max_row: 最大行号
max_row: Source: Input 标记行不包含即属性区域的上界
Returns:
信号属性字典键为行号值为单元格
信号属性字典键为行号int值为单元格文本str
"""
attributes: dict[int, str] = {}
for row in range(1, max_row):
@@ -185,19 +180,20 @@ def __get_data_log(
column: int,
time_column: int = 1
) -> list[DataLog]:
"""从指定位置读取时间-值数据对列表
"""从指定起始行向下读取 (time, value) 序列,遇到空时间戳即终止。
Args:
sheet: Worksheet 对象
row: 起始行号
column: 数据列号
time_column: 时间列索引默认 1
time_column: 时间列索引默认 1A
Returns:
DataLog 对象列表直到遇到空时间戳为止
"""
data_log: list[DataLog] = []
while True:
# 同时读取时间列与数据列,时间列空即视为数据终止
time = sheet.cell(row=row, column=time_column).value
value = sheet.cell(row=row, column=column).value
if time is None:
@@ -16,18 +16,25 @@ logger = logging.getLogger(__name__)
def update_case_excel(filename: str, old_data: dict, new_data: dict) -> None:
"""新 Excel 文件中的信号列
"""将旧 Excel 同步到新 Excel 的信号列集合(增 / 删)。
流程
1. 拆出 wb / sheet / source_row 等元数据
2. diff要新增的信号要删除的信号
3. 先删后插保持列号稳定
4. 回写文件
Args:
filename: Excel 文件路径
old_data: 旧数据字典
new_data: 新数据字典
filename: 目标 Excel 文件路径
old_data: 旧数据字典 wb / sheet / source_row 及各信号
new_data: 新数据字典结构同上用于 diff 比较
Raises:
CaseDataError: 数据类型错误
CaseDataError: 数据字典中 Workbook/Worksheet 类型不匹配
"""
logger.info(f"开始更新 Excel 文件 {filename}")
# 注意:pop 会从字典中移除键,调用前请确认数据不再被复用
old_wb = old_data.pop('wb')
old_sheet = old_data.pop('sheet')
old_source_row = old_data.pop('source_row')
@@ -37,28 +44,34 @@ def update_case_excel(filename: str, old_data: dict, new_data: dict) -> None:
new_source_row = new_data.pop('source_row')
if not isinstance(old_wb, Workbook) or not isinstance(old_sheet, Worksheet):
logger.error("数据类型错误,Workbook 或 Worksheet 类型不匹配")
raise CaseDataError("数据异常,Workbook 或 Worksheet 类型错误")
# 计算"新增"与"删除"两组信号
add_input = _get_add_input(old_data, new_data)
del_input = _get_del_input(old_data, new_data)
datalog_len = _get_datalog_len(old_data)
# 先删除旧列(逆序删除是为了让被删列的索引不会影响后续操作)
for name, info in reversed(del_input.items()):
old_sheet.delete_cols(info['column'])
logger.info(f"删除第{info['column']}\t信号名: {name}")
# 再插入新增列:先 insert_cols 占位,再填充属性行与数据点
for name, info in add_input.items():
old_sheet.insert_cols(info['column'])
for index in range(1,old_source_row):
if index == old_source_row -1 :
old_sheet.cell(index, info['column']).value = info['attributes'][new_source_row - 1]
# 1) 属性行(header 行以下至 source_row - 1
for index in range(1, old_source_row):
if index == old_source_row - 1:
# source_header 行:从 attributes 字典里取出 source_header 文本
# 注:attributes 的行号空间与 old_data 保持一致,应使用 old_source_row
old_sheet.cell(index, info['column']).value = info['attributes'][old_source_row - 1]
old_sheet.cell(index, info['column']).data_type = "str"
else:
old_sheet.cell(index, info['column']).value = info['attributes'][index]
old_sheet.cell(index, info['column']).data_type = "str"
# 2) 数据行:根据值类型设置 data_type
for index in range(old_source_row + 1, datalog_len + old_source_row + 1):
try:
value = int(info['datalog'][0].value)
@@ -75,14 +88,14 @@ def update_case_excel(filename: str, old_data: dict, new_data: dict) -> None:
def _get_add_input(old_data: dict, new_data: dict) -> dict[str, SignalData]:
"""获取新增输入数据
"""计算"新增"信号集合:存在于 new_data 但不存在于 old_data 的信号。
Args:
old_data: 旧数据字典
new_data: 新数据字典
old_data: 旧数据字典
new_data: 新数据字典
Returns:
新增信号字典
新增信号字典
"""
add_input: dict[str, SignalData] = {}
@@ -93,14 +106,14 @@ def _get_add_input(old_data: dict, new_data: dict) -> dict[str, SignalData]:
def _get_del_input(old_data: dict, new_data: dict) -> dict[str, SignalData]:
"""获取删除输入数据
"""计算"删除"信号集合:存在于 old_data 但不存在于 new_data 的信号。
Args:
old_data: 旧数据字典
new_data: 新数据字典
old_data: 旧数据字典
new_data: 新数据字典
Returns:
删除信号字典
删除信号字典
"""
del_input: dict[str, SignalData] = {}
@@ -111,14 +124,18 @@ def _get_del_input(old_data: dict, new_data: dict) -> dict[str, SignalData]:
def _get_datalog_len(old_data: dict) -> int:
"""获取数据日志长度
"""获取数据中第一个信号的 datalog 长度,作为新增列填充行数的基准。
假设 old_data 中各信号 datalog 长度一致来自同一时间轴
Args:
old_data: 数据字典
old_data: 数据字典
Returns:
数据日志长度
数据日志长度行数空字典返回 0
"""
for name in old_data.keys():
return len(old_data[name]['datalog'])
if not old_data:
return 0
# 取首项的 datalog 长度即可,无需遍历整张字典
first_name = next(iter(old_data))
return len(old_data[first_name]['datalog'])
+124
View File
@@ -0,0 +1,124 @@
import os
import logging
from PySide6.QtGui import *
from PySide6.QtCore import *
from PySide6.QtWidgets import *
logger = logging.getLogger(__name__)
from .mil_project_ui import Ui_FrameMILProject
from mil.core import Config
class MILProject(QFrame, Ui_FrameMILProject):
update_item_name_signal = Signal()
update_item_data_path_signal = Signal(str,str)
update_item_file_path_signal = Signal(str,str)
def __init__(self, config, name = None,parent = None):
super().__init__(parent)
self.setupUi(self)
self.initUI(config,name)
def initUI(self,config:Config, name:str):
self.lineEditDataPath.setReadOnly(True)
self.lineEditFilePath.setReadOnly(True)
self.name = name
self.config = config
self.ItemConfigs = config.ItemConfigs
if self.name is not None and self.name in self.ItemConfigs.keys():
self.lineEditName.setText(name)
self.lineEditDataPath.setText(self.ItemConfigs[self.name]["DataPath"])
self.lineEditFilePath.setText(self.ItemConfigs[self.name]["FilePath"])
else:
self.pushButtonDataPath.setEnabled(False)
self.pushButtonFilePath.setEnabled(False)
self.lineEditName.setPlaceholderText("必填项!!!")
self.lineEditDataPath.setPlaceholderText("点击【选择数据文件】按钮,选择路径!")
self.lineEditFilePath.setPlaceholderText("点击【选择测试用例】按钮,选择路径!")
self.lineEditName.editingFinished.connect(self.on_name_editing_finished_event)
self.lineEditDataPath.editingFinished.connect(self.on_datapath_editing_finished_event)
self.lineEditFilePath.editingFinished.connect(self.on_filepath_editing_finished_event)
self.pushButtonDataPath.clicked.connect(self.on_data_path_event)
self.pushButtonFilePath.clicked.connect(self.on_file_path_event)
def on_name_editing_finished_event(self):
name = self.lineEditName.text()
if name.replace(" ", "") == "":
self.lineEditName.setText(self.name)
logger.warning("项目名称是必填项!!!")
return
if name == self.name:
return
if self.name is None:
self.name = name
self.ItemConfigs[name] = dict()
self.ItemConfigs[name]["DataPath"] = ""
self.ItemConfigs[name]["FilePath"] = ""
else:
self.ItemConfigs[name] = self.ItemConfigs.pop(self.name)
if self.name == self.config.CurrProject:
self.config.CurrProject = name
self.name = name
self.config.save_config()
self.update_item_name_signal.emit()
self.pushButtonDataPath.setEnabled(True)
self.pushButtonFilePath.setEnabled(True)
def on_datapath_editing_finished_event(self):
name = self.lineEditName.text()
data_path = self.lineEditDataPath.text()
if data_path.replace(" ","") == "":
logger.warning("数据文件路径无效!")
return
self.ItemConfigs[name]["DataPath"] = data_path
self.config.save_config()
self.update_item_data_path_signal.emit(name,data_path)
def on_filepath_editing_finished_event(self):
name = self.lineEditName.text()
file_path = self.lineEditFilePath.text()
if file_path.replace(" ","") == "":
logger.warning("测试用例路径无效!")
return
self.ItemConfigs[name]["FilePath"] = file_path
self.config.save_config()
self.update_item_file_path_signal.emit(name,file_path)
def on_data_path_event(self):
dataPath = self.lineEditDataPath.text()
if os.path.exists(dataPath):
path,type = QFileDialog.getOpenFileName(self, "选择文件", dataPath,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)")
if not os.path.exists(path):
logger.debug(f"数据文件选择已取消: {dataPath}")
return
self.lineEditDataPath.setText(path)
self.on_datapath_editing_finished_event()
def on_file_path_event(self):
filePath = self.lineEditFilePath.text()
if os.path.exists(filePath):
path,type = QFileDialog.getOpenFileName(self, "选择文件", filePath,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", "./","Excel工作簿(*.xlsx)")
if not os.path.exists(path):
logger.debug(f"测试用例文件选择已取消: {filePath}")
return
self.lineEditFilePath.setText(path)
self.on_filepath_editing_finished_event()
+113
View File
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>FrameMILProject</class>
<widget class="QFrame" name="FrameMILProject">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>740</width>
<height>95</height>
</rect>
</property>
<property name="windowTitle">
<string>Frame</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="0" column="0">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>模型项目:</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>数据文件:</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>测试用例:</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="0" column="1">
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLineEdit" name="lineEditName">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLineEdit" name="lineEditDataPath">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLineEdit" name="lineEditFilePath">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</item>
<item row="0" column="2">
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QPushButton" name="pushButtonDataPath">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>选择数据文件</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="pushButtonFilePath">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>选择测试用例</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'mil_project.ui'
##
## Created by: Qt User Interface Compiler version 6.11.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QLabel,
QLineEdit, QPushButton, QSizePolicy, QWidget)
class Ui_FrameMILProject(object):
def setupUi(self, FrameMILProject):
if not FrameMILProject.objectName():
FrameMILProject.setObjectName(u"FrameMILProject")
FrameMILProject.resize(740, 95)
self.gridLayout_4 = QGridLayout(FrameMILProject)
self.gridLayout_4.setObjectName(u"gridLayout_4")
self.gridLayout = QGridLayout()
self.gridLayout.setObjectName(u"gridLayout")
self.label = QLabel(FrameMILProject)
self.label.setObjectName(u"label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.label_2 = QLabel(FrameMILProject)
self.label_2.setObjectName(u"label_2")
self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1)
self.label_3 = QLabel(FrameMILProject)
self.label_3.setObjectName(u"label_3")
self.gridLayout.addWidget(self.label_3, 2, 0, 1, 1)
self.gridLayout_4.addLayout(self.gridLayout, 0, 0, 1, 1)
self.gridLayout_2 = QGridLayout()
self.gridLayout_2.setObjectName(u"gridLayout_2")
self.lineEditName = QLineEdit(FrameMILProject)
self.lineEditName.setObjectName(u"lineEditName")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.lineEditName.sizePolicy().hasHeightForWidth())
self.lineEditName.setSizePolicy(sizePolicy)
self.gridLayout_2.addWidget(self.lineEditName, 0, 0, 1, 1)
self.lineEditDataPath = QLineEdit(FrameMILProject)
self.lineEditDataPath.setObjectName(u"lineEditDataPath")
sizePolicy.setHeightForWidth(self.lineEditDataPath.sizePolicy().hasHeightForWidth())
self.lineEditDataPath.setSizePolicy(sizePolicy)
self.gridLayout_2.addWidget(self.lineEditDataPath, 1, 0, 1, 1)
self.lineEditFilePath = QLineEdit(FrameMILProject)
self.lineEditFilePath.setObjectName(u"lineEditFilePath")
sizePolicy.setHeightForWidth(self.lineEditFilePath.sizePolicy().hasHeightForWidth())
self.lineEditFilePath.setSizePolicy(sizePolicy)
self.gridLayout_2.addWidget(self.lineEditFilePath, 2, 0, 1, 1)
self.gridLayout_4.addLayout(self.gridLayout_2, 0, 1, 1, 1)
self.gridLayout_3 = QGridLayout()
self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pushButtonDataPath = QPushButton(FrameMILProject)
self.pushButtonDataPath.setObjectName(u"pushButtonDataPath")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.pushButtonDataPath.sizePolicy().hasHeightForWidth())
self.pushButtonDataPath.setSizePolicy(sizePolicy1)
self.gridLayout_3.addWidget(self.pushButtonDataPath, 0, 0, 1, 1)
self.pushButtonFilePath = QPushButton(FrameMILProject)
self.pushButtonFilePath.setObjectName(u"pushButtonFilePath")
sizePolicy1.setHeightForWidth(self.pushButtonFilePath.sizePolicy().hasHeightForWidth())
self.pushButtonFilePath.setSizePolicy(sizePolicy1)
self.gridLayout_3.addWidget(self.pushButtonFilePath, 1, 0, 1, 1)
self.gridLayout_4.addLayout(self.gridLayout_3, 0, 2, 1, 1)
self.retranslateUi(FrameMILProject)
QMetaObject.connectSlotsByName(FrameMILProject)
# setupUi
def retranslateUi(self, FrameMILProject):
FrameMILProject.setWindowTitle(QCoreApplication.translate("FrameMILProject", u"Frame", None))
self.label.setText(QCoreApplication.translate("FrameMILProject", u"\u6a21\u578b\u9879\u76ee\uff1a", None))
self.label_2.setText(QCoreApplication.translate("FrameMILProject", u"\u6570\u636e\u6587\u4ef6\uff1a", None))
self.label_3.setText(QCoreApplication.translate("FrameMILProject", u"\u6d4b\u8bd5\u7528\u4f8b\uff1a", None))
self.lineEditName.setText("")
self.pushButtonDataPath.setText(QCoreApplication.translate("FrameMILProject", u"\u9009\u62e9\u6570\u636e\u6587\u4ef6", None))
self.pushButtonFilePath.setText(QCoreApplication.translate("FrameMILProject", u"\u9009\u62e9\u6d4b\u8bd5\u7528\u4f8b", None))
# retranslateUi
+194
View File
@@ -0,0 +1,194 @@
import os
import logging
from pathlib import Path
from .mil_tool_ui import Ui_FrameMILTool
from PySide6.QtGui import *
from PySide6.QtCore import *
from PySide6.QtWidgets import *
from .mil_project import MILProject
from mil.core import Config
logger = logging.getLogger(__name__)
CONF = "mil.json"
class FrameMILTool(QFrame,Ui_FrameMILTool):
def __init__(self,workspace:Path, parent=None):
super().__init__(parent)
self.setupUi(self)
self.initUI(workspace)
def initUI(self, workspace:Path):
self.workspace = workspace
if not self.workspace.exists():
os.mkdir(self.workspace)
self.conf = self.workspace.joinpath(CONF)
self.config = Config(self.conf)
if self.config.check_path():
self.config.load_config()
self.checkBox.setChecked(self.config.AddTimeEn)
if self.config.GeratePath:
self.radioButtonData.setChecked(True)
else:
self.radioButtonFile.setChecked(True)
else:
self.config.save_config()
self.update_config()
for key in self.config.ItemConfigs.keys():
self.add_pro_event(key)
self.menu = QMenu(self.listWidget)
self.new_pro_acction = QAction("新建项目", self)
self.new_pro_acction.triggered.connect(self.on_new_pro_event)
self.del_pro_acction = QAction("删除项目", self)
self.del_pro_acction.triggered.connect(self.on_del_pro_event)
self.menu.addAction(self.new_pro_acction)
self.menu.addAction(self.del_pro_acction)
self.DataPath.setReadOnly(True)
self.FilePath.setReadOnly(True)
self.DataButton.clicked.connect(self.on_load_data_path)
self.FileButton.clicked.connect(self.on_load_file_path)
self.checkBox.toggled.connect(self.on_select_add_time_en)
self.radioButtonData.toggled.connect(self.on_select_gerate_path)
self.comboBox.currentTextChanged.connect(self.on_text_changed_event)
self.listWidget.customContextMenuRequested.connect(self.on_ContextMenuRequested)
def update_config(self):
self.comboBox.clear()
if self.config.ItemConfigs.keys().__len__() == 0:
return
self.lock_event = True
for key in self.config.ItemConfigs.keys():
self.comboBox.addItem(key)
self.lock_event = False
if self.config.CurrProject == "":
self.config.CurrProject = self.comboBox.currentText()
self.config.save_config()
self.DataPath.setText(self.config.ItemConfigs[self.comboBox.currentText()]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.comboBox.currentText()]["FilePath"])
else:
self.comboBox.setCurrentText(self.config.CurrProject)
self.DataPath.setText(self.config.ItemConfigs[self.config.CurrProject]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.config.CurrProject]["FilePath"])
def on_update_data_path_event(self,name,data_path):
if name == self.comboBox.currentText():
self.DataPath.setText(data_path)
def on_update_file_path_event(self,name,file_path):
if name == self.comboBox.currentText():
self.FilePath.setText(file_path)
def on_select_gerate_path(self,checked):
self.config.GeratePath = checked
self.config.save_config()
def on_select_add_time_en(self,checked):
self.config.AddTimeEn = checked
self.config.save_config()
def on_ContextMenuRequested(self,point:QPoint):
self.menu.exec_(QCursor.pos())
def on_text_changed_event(self,text:str):
if text == "" or self.lock_event:
return
self.config.CurrProject = text
self.config.save_config()
self.DataPath.setText(self.config.ItemConfigs[self.config.CurrProject]["DataPath"])
self.FilePath.setText(self.config.ItemConfigs[self.config.CurrProject]["FilePath"])
def on_new_pro_event(self):
item = QListWidgetItem()
mil_project = MILProject(self.config)
mil_project.update_item_name_signal.connect(self.update_config)
mil_project.update_item_data_path_signal.connect(self.on_update_data_path_event)
mil_project.update_item_file_path_signal.connect(self.on_update_file_path_event)
item.setSizeHint(QSize(mil_project.sizeHint().width(), 150))
self.listWidget.addItem(item)
self.listWidget.setCurrentItem(item)
self.listWidget.setItemWidget(item, mil_project)
def add_pro_event(self, name):
item = QListWidgetItem()
mil_project = MILProject(self.config, name=name)
mil_project.update_item_name_signal.connect(self.update_config)
mil_project.update_item_data_path_signal.connect(self.on_update_data_path_event)
mil_project.update_item_file_path_signal.connect(self.on_update_file_path_event)
item.setSizeHint(QSize(mil_project.sizeHint().width(), 150))
self.listWidget.addItem(item)
self.listWidget.setCurrentItem(item)
self.listWidget.setItemWidget(item, mil_project)
def on_del_pro_event(self):
mil_project = self.listWidget.itemWidget(self.listWidget.currentItem())
if not isinstance(mil_project, MILProject):
logger.error("on_del_pro_event: 当前项不是 MILProject 实例")
return
if self.config.CurrProject == mil_project.name:
self.config.CurrProject = ""
self.listWidget.takeItem(self.listWidget.currentRow())
self.config.ItemConfigs.pop(mil_project.name)
self.config.save_config()
self.update_config()
def search_mil_pro_item(self, name):
for mil_project in self.listWidget.findChildren(MILProject):
if not isinstance(mil_project,MILProject):
return
if mil_project.name == name:
return mil_project
return None
def on_load_data_path(self):
data_path = self.DataPath.text()
if os.path.exists(data_path):
path,type = QFileDialog.getOpenFileName(self, "选择文件", data_path,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)")
self.DataPath.setText(path)
if not path:
return
self.config.ItemConfigs[self.config.CurrProject]["DataPath"] = path
mil_project = self.search_mil_pro_item(self.config.CurrProject)
mil_project.lineEditDataPath.setText(path)
self.config.save_config()
def on_load_file_path(self):
file_path = self.FilePath.text()
if os.path.exists(file_path):
path,type = QFileDialog.getOpenFileName(self, "选择文件", file_path,"Excel工作簿(*.xlsx)")
else:
path,type = QFileDialog.getOpenFileName(self, "选择文件", os.getcwd(),"Excel工作簿(*.xlsx)")
self.FilePath.setText(path)
if not path:
return
self.config.ItemConfigs[self.config.CurrProject]["FilePath"] = path
mil_project = self.search_mil_pro_item(self.config.CurrProject)
mil_project.lineEditFilePath.setText(path)
self.config.save_config()
+371
View File
@@ -0,0 +1,371 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>FrameMILTool</class>
<widget class="QFrame" name="FrameMILTool">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>706</width>
<height>308</height>
</rect>
</property>
<property name="windowTitle">
<string>Frame</string>
</property>
<layout class="QGridLayout" name="gridLayout_8">
<item row="0" column="0">
<widget class="QTabWidget" name="tabWidget">
<property name="tabPosition">
<enum>QTabWidget::TabPosition::North</enum>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="widget">
<attribute name="title">
<string>功能面板</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_12">
<item row="0" column="0">
<widget class="QFrame" name="frame_3">
<property name="frameShape">
<enum>QFrame::Shape::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>项目</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="comboBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="styleSheet">
<string notr="true">color: rgb(255, 255, 255);</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="2">
<widget class="QLineEdit" name="FilePath">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="styleSheet">
<string notr="true">color: rgb(255, 255, 255);</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="DataPath">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="styleSheet">
<string notr="true">color: rgb(255, 255, 255);</string>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QPushButton" name="FileButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item row="0" column="4">
<widget class="QCheckBox" name="checkBox">
<property name="text">
<string>累计时间</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QRadioButton" name="radioButtonFile">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="1" column="4">
<widget class="QPushButton" name="pushButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>生成</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label_3">
<property name="text">
<string>测试用例 </string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_2">
<property name="text">
<string>数据文件 </string>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QPushButton" name="DataButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QRadioButton" name="radioButtonData">
<property name="text">
<string/>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item row="1" column="0">
<widget class="QFrame" name="frame">
<property name="frameShape">
<enum>QFrame::Shape::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
</property>
<layout class="QGridLayout" name="gridLayout_6">
<item row="0" column="0">
<layout class="QGridLayout" name="gridLayout_5">
<item row="0" column="0">
<widget class="QLabel" name="label_4">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>名称</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="lineEdit"/>
</item>
<item row="0" column="2">
<widget class="QLabel" name="label_7">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>名称</string>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QLineEdit" name="lineEdit_4"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_5">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>类型</string>
</property>
</widget>
</item>
<item row="1" column="1" rowspan="2">
<widget class="QLineEdit" name="lineEdit_2"/>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_8">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>类型</string>
</property>
</widget>
</item>
<item row="1" column="3" rowspan="2">
<widget class="QLineEdit" name="lineEdit_5"/>
</item>
<item row="2" column="0" rowspan="2">
<widget class="QLabel" name="label_6">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>值定义</string>
</property>
</widget>
</item>
<item row="2" column="2" rowspan="2">
<widget class="QLabel" name="label_9">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>值定义</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="lineEdit_3"/>
</item>
<item row="3" column="3">
<widget class="QLineEdit" name="lineEdit_6"/>
</item>
</layout>
</item>
<item row="1" column="0">
<layout class="QGridLayout" name="gridLayout_10">
<item row="0" column="0">
<widget class="QPushButton" name="pushButton_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>加载测试用例</string>
</property>
</widget>
</item>
<item row="0" column="4">
<widget class="QPushButton" name="pushButton_4">
<property name="text">
<string>删除</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QPushButton" name="pushButton_5">
<property name="text">
<string>查找</string>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QPushButton" name="pushButton_3">
<property name="text">
<string>替换</string>
</property>
</widget>
</item>
<item row="0" column="5">
<widget class="QPushButton" name="UpdateButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>更新</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item row="2" column="0">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>项目列表</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_11">
<item row="0" column="0">
<widget class="QListWidget" name="listWidget">
<property name="contextMenuPolicy">
<enum>Qt::ContextMenuPolicy::CustomContextMenu</enum>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
+317
View File
@@ -0,0 +1,317 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'mil_tool.ui'
##
## Created by: Qt User Interface Compiler version 6.11.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QFrame,
QGridLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QPushButton, QRadioButton, QSizePolicy,
QSpacerItem, QTabWidget, QWidget)
class Ui_FrameMILTool(object):
def setupUi(self, FrameMILTool):
if not FrameMILTool.objectName():
FrameMILTool.setObjectName(u"FrameMILTool")
FrameMILTool.resize(706, 308)
self.gridLayout_8 = QGridLayout(FrameMILTool)
self.gridLayout_8.setObjectName(u"gridLayout_8")
self.tabWidget = QTabWidget(FrameMILTool)
self.tabWidget.setObjectName(u"tabWidget")
self.tabWidget.setTabPosition(QTabWidget.TabPosition.North)
self.widget = QWidget()
self.widget.setObjectName(u"widget")
self.gridLayout_12 = QGridLayout(self.widget)
self.gridLayout_12.setObjectName(u"gridLayout_12")
self.frame_3 = QFrame(self.widget)
self.frame_3.setObjectName(u"frame_3")
self.frame_3.setFrameShape(QFrame.Shape.StyledPanel)
self.frame_3.setFrameShadow(QFrame.Shadow.Raised)
self.gridLayout_3 = QGridLayout(self.frame_3)
self.gridLayout_3.setObjectName(u"gridLayout_3")
self.gridLayout_2 = QGridLayout()
self.gridLayout_2.setObjectName(u"gridLayout_2")
self.label = QLabel(self.frame_3)
self.label.setObjectName(u"label")
self.gridLayout_2.addWidget(self.label, 0, 0, 1, 1)
self.comboBox = QComboBox(self.frame_3)
self.comboBox.setObjectName(u"comboBox")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.comboBox.sizePolicy().hasHeightForWidth())
self.comboBox.setSizePolicy(sizePolicy)
self.comboBox.setStyleSheet(u"color: rgb(255, 255, 255);")
self.gridLayout_2.addWidget(self.comboBox, 0, 1, 1, 1)
self.gridLayout_3.addLayout(self.gridLayout_2, 0, 0, 1, 1)
self.gridLayout = QGridLayout()
self.gridLayout.setObjectName(u"gridLayout")
self.FilePath = QLineEdit(self.frame_3)
self.FilePath.setObjectName(u"FilePath")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.FilePath.sizePolicy().hasHeightForWidth())
self.FilePath.setSizePolicy(sizePolicy1)
self.FilePath.setStyleSheet(u"color: rgb(255, 255, 255);")
self.gridLayout.addWidget(self.FilePath, 1, 2, 1, 1)
self.DataPath = QLineEdit(self.frame_3)
self.DataPath.setObjectName(u"DataPath")
sizePolicy1.setHeightForWidth(self.DataPath.sizePolicy().hasHeightForWidth())
self.DataPath.setSizePolicy(sizePolicy1)
self.DataPath.setStyleSheet(u"color: rgb(255, 255, 255);")
self.gridLayout.addWidget(self.DataPath, 0, 2, 1, 1)
self.FileButton = QPushButton(self.frame_3)
self.FileButton.setObjectName(u"FileButton")
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
sizePolicy2.setHorizontalStretch(0)
sizePolicy2.setVerticalStretch(0)
sizePolicy2.setHeightForWidth(self.FileButton.sizePolicy().hasHeightForWidth())
self.FileButton.setSizePolicy(sizePolicy2)
self.gridLayout.addWidget(self.FileButton, 1, 3, 1, 1)
self.checkBox = QCheckBox(self.frame_3)
self.checkBox.setObjectName(u"checkBox")
self.gridLayout.addWidget(self.checkBox, 0, 4, 1, 1)
self.radioButtonFile = QRadioButton(self.frame_3)
self.radioButtonFile.setObjectName(u"radioButtonFile")
self.gridLayout.addWidget(self.radioButtonFile, 1, 0, 1, 1)
self.pushButton = QPushButton(self.frame_3)
self.pushButton.setObjectName(u"pushButton")
sizePolicy2.setHeightForWidth(self.pushButton.sizePolicy().hasHeightForWidth())
self.pushButton.setSizePolicy(sizePolicy2)
self.gridLayout.addWidget(self.pushButton, 1, 4, 1, 1)
self.label_3 = QLabel(self.frame_3)
self.label_3.setObjectName(u"label_3")
self.gridLayout.addWidget(self.label_3, 1, 1, 1, 1)
self.label_2 = QLabel(self.frame_3)
self.label_2.setObjectName(u"label_2")
self.gridLayout.addWidget(self.label_2, 0, 1, 1, 1)
self.DataButton = QPushButton(self.frame_3)
self.DataButton.setObjectName(u"DataButton")
sizePolicy2.setHeightForWidth(self.DataButton.sizePolicy().hasHeightForWidth())
self.DataButton.setSizePolicy(sizePolicy2)
self.gridLayout.addWidget(self.DataButton, 0, 3, 1, 1)
self.radioButtonData = QRadioButton(self.frame_3)
self.radioButtonData.setObjectName(u"radioButtonData")
self.radioButtonData.setChecked(True)
self.gridLayout.addWidget(self.radioButtonData, 0, 0, 1, 1)
self.gridLayout_3.addLayout(self.gridLayout, 1, 0, 1, 1)
self.gridLayout_12.addWidget(self.frame_3, 0, 0, 1, 1)
self.frame = QFrame(self.widget)
self.frame.setObjectName(u"frame")
self.frame.setFrameShape(QFrame.Shape.StyledPanel)
self.frame.setFrameShadow(QFrame.Shadow.Raised)
self.gridLayout_6 = QGridLayout(self.frame)
self.gridLayout_6.setObjectName(u"gridLayout_6")
self.gridLayout_5 = QGridLayout()
self.gridLayout_5.setObjectName(u"gridLayout_5")
self.label_4 = QLabel(self.frame)
self.label_4.setObjectName(u"label_4")
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
sizePolicy3.setHorizontalStretch(0)
sizePolicy3.setVerticalStretch(0)
sizePolicy3.setHeightForWidth(self.label_4.sizePolicy().hasHeightForWidth())
self.label_4.setSizePolicy(sizePolicy3)
self.gridLayout_5.addWidget(self.label_4, 0, 0, 1, 1)
self.lineEdit = QLineEdit(self.frame)
self.lineEdit.setObjectName(u"lineEdit")
self.gridLayout_5.addWidget(self.lineEdit, 0, 1, 1, 1)
self.label_7 = QLabel(self.frame)
self.label_7.setObjectName(u"label_7")
sizePolicy3.setHeightForWidth(self.label_7.sizePolicy().hasHeightForWidth())
self.label_7.setSizePolicy(sizePolicy3)
self.gridLayout_5.addWidget(self.label_7, 0, 2, 1, 1)
self.lineEdit_4 = QLineEdit(self.frame)
self.lineEdit_4.setObjectName(u"lineEdit_4")
self.gridLayout_5.addWidget(self.lineEdit_4, 0, 3, 1, 1)
self.label_5 = QLabel(self.frame)
self.label_5.setObjectName(u"label_5")
sizePolicy3.setHeightForWidth(self.label_5.sizePolicy().hasHeightForWidth())
self.label_5.setSizePolicy(sizePolicy3)
self.gridLayout_5.addWidget(self.label_5, 1, 0, 1, 1)
self.lineEdit_2 = QLineEdit(self.frame)
self.lineEdit_2.setObjectName(u"lineEdit_2")
self.gridLayout_5.addWidget(self.lineEdit_2, 1, 1, 2, 1)
self.label_8 = QLabel(self.frame)
self.label_8.setObjectName(u"label_8")
sizePolicy3.setHeightForWidth(self.label_8.sizePolicy().hasHeightForWidth())
self.label_8.setSizePolicy(sizePolicy3)
self.gridLayout_5.addWidget(self.label_8, 1, 2, 1, 1)
self.lineEdit_5 = QLineEdit(self.frame)
self.lineEdit_5.setObjectName(u"lineEdit_5")
self.gridLayout_5.addWidget(self.lineEdit_5, 1, 3, 2, 1)
self.label_6 = QLabel(self.frame)
self.label_6.setObjectName(u"label_6")
sizePolicy3.setHeightForWidth(self.label_6.sizePolicy().hasHeightForWidth())
self.label_6.setSizePolicy(sizePolicy3)
self.gridLayout_5.addWidget(self.label_6, 2, 0, 2, 1)
self.label_9 = QLabel(self.frame)
self.label_9.setObjectName(u"label_9")
sizePolicy3.setHeightForWidth(self.label_9.sizePolicy().hasHeightForWidth())
self.label_9.setSizePolicy(sizePolicy3)
self.gridLayout_5.addWidget(self.label_9, 2, 2, 2, 1)
self.lineEdit_3 = QLineEdit(self.frame)
self.lineEdit_3.setObjectName(u"lineEdit_3")
self.gridLayout_5.addWidget(self.lineEdit_3, 3, 1, 1, 1)
self.lineEdit_6 = QLineEdit(self.frame)
self.lineEdit_6.setObjectName(u"lineEdit_6")
self.gridLayout_5.addWidget(self.lineEdit_6, 3, 3, 1, 1)
self.gridLayout_6.addLayout(self.gridLayout_5, 0, 0, 1, 1)
self.gridLayout_10 = QGridLayout()
self.gridLayout_10.setObjectName(u"gridLayout_10")
self.pushButton_2 = QPushButton(self.frame)
self.pushButton_2.setObjectName(u"pushButton_2")
sizePolicy.setHeightForWidth(self.pushButton_2.sizePolicy().hasHeightForWidth())
self.pushButton_2.setSizePolicy(sizePolicy)
self.gridLayout_10.addWidget(self.pushButton_2, 0, 0, 1, 1)
self.pushButton_4 = QPushButton(self.frame)
self.pushButton_4.setObjectName(u"pushButton_4")
self.gridLayout_10.addWidget(self.pushButton_4, 0, 4, 1, 1)
self.pushButton_5 = QPushButton(self.frame)
self.pushButton_5.setObjectName(u"pushButton_5")
self.gridLayout_10.addWidget(self.pushButton_5, 0, 2, 1, 1)
self.pushButton_3 = QPushButton(self.frame)
self.pushButton_3.setObjectName(u"pushButton_3")
self.gridLayout_10.addWidget(self.pushButton_3, 0, 3, 1, 1)
self.UpdateButton = QPushButton(self.frame)
self.UpdateButton.setObjectName(u"UpdateButton")
sizePolicy2.setHeightForWidth(self.UpdateButton.sizePolicy().hasHeightForWidth())
self.UpdateButton.setSizePolicy(sizePolicy2)
self.gridLayout_10.addWidget(self.UpdateButton, 0, 5, 1, 1)
self.gridLayout_6.addLayout(self.gridLayout_10, 1, 0, 1, 1)
self.gridLayout_12.addWidget(self.frame, 1, 0, 1, 1)
self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.gridLayout_12.addItem(self.verticalSpacer, 2, 0, 1, 1)
self.tabWidget.addTab(self.widget, "")
self.tab_2 = QWidget()
self.tab_2.setObjectName(u"tab_2")
self.gridLayout_11 = QGridLayout(self.tab_2)
self.gridLayout_11.setObjectName(u"gridLayout_11")
self.listWidget = QListWidget(self.tab_2)
self.listWidget.setObjectName(u"listWidget")
self.listWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.gridLayout_11.addWidget(self.listWidget, 0, 0, 1, 1)
self.tabWidget.addTab(self.tab_2, "")
self.gridLayout_8.addWidget(self.tabWidget, 0, 0, 1, 1)
self.retranslateUi(FrameMILTool)
self.tabWidget.setCurrentIndex(0)
QMetaObject.connectSlotsByName(FrameMILTool)
# setupUi
def retranslateUi(self, FrameMILTool):
FrameMILTool.setWindowTitle(QCoreApplication.translate("FrameMILTool", u"Frame", None))
self.label.setText(QCoreApplication.translate("FrameMILTool", u"\u9879\u76ee", None))
self.FileButton.setText(QCoreApplication.translate("FrameMILTool", u"...", None))
self.checkBox.setText(QCoreApplication.translate("FrameMILTool", u"\u7d2f\u8ba1\u65f6\u95f4", None))
self.radioButtonFile.setText("")
self.pushButton.setText(QCoreApplication.translate("FrameMILTool", u"\u751f\u6210", None))
self.label_3.setText(QCoreApplication.translate("FrameMILTool", u"\u6d4b\u8bd5\u7528\u4f8b ", None))
self.label_2.setText(QCoreApplication.translate("FrameMILTool", u"\u6570\u636e\u6587\u4ef6 ", None))
self.DataButton.setText(QCoreApplication.translate("FrameMILTool", u"...", None))
self.radioButtonData.setText("")
self.label_4.setText(QCoreApplication.translate("FrameMILTool", u"\u540d\u79f0", None))
self.label_7.setText(QCoreApplication.translate("FrameMILTool", u"\u540d\u79f0", None))
self.label_5.setText(QCoreApplication.translate("FrameMILTool", u"\u7c7b\u578b", None))
self.label_8.setText(QCoreApplication.translate("FrameMILTool", u"\u7c7b\u578b", None))
self.label_6.setText(QCoreApplication.translate("FrameMILTool", u"\u503c\u5b9a\u4e49", None))
self.label_9.setText(QCoreApplication.translate("FrameMILTool", u"\u503c\u5b9a\u4e49", None))
self.pushButton_2.setText(QCoreApplication.translate("FrameMILTool", u"\u52a0\u8f7d\u6d4b\u8bd5\u7528\u4f8b", None))
self.pushButton_4.setText(QCoreApplication.translate("FrameMILTool", u"\u5220\u9664", None))
self.pushButton_5.setText(QCoreApplication.translate("FrameMILTool", u"\u67e5\u627e", None))
self.pushButton_3.setText(QCoreApplication.translate("FrameMILTool", u"\u66ff\u6362", None))
self.UpdateButton.setText(QCoreApplication.translate("FrameMILTool", u"\u66f4\u65b0", None))
self.tabWidget.setTabText(self.tabWidget.indexOf(self.widget), QCoreApplication.translate("FrameMILTool", u"\u529f\u80fd\u9762\u677f", None))
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_2), QCoreApplication.translate("FrameMILTool", u"\u9879\u76ee\u5217\u8868", None))
# retranslateUi
+1
View File
@@ -5,3 +5,4 @@ openpyxl>=3.0.0
# 开发依赖
pytest>=7.0.0
nuitka
-92
View File
@@ -1,92 +0,0 @@
"""MIL SDK 核心数据模型"""
from dataclasses import dataclass, field
from typing import Any
@dataclass
class DataLog:
"""仿真数据日志记录
Attributes:
time: 时间戳(秒)
value: 信号值(可以是任意类型)
"""
time: float = 0.0
value: str = ""
@dataclass
class SignalData:
"""信号数据封装
Attributes:
signal_type: 信号类型
column: 列索引
datalog: 数据日志列表
"""
# signal_type: str | None = None
column: int = 0
attributes: dict[str, str] = field(default_factory=dict)
datalog: list[DataLog] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""转换为字典格式
Returns:
包含 signal_type、column、datalog 的字典
"""
return {
"column": self.column,
"attributes": self.attributes,
"datalog": self.datalog
}
@dataclass
class ExcelDataResult:
"""Excel 数据读取结果封装
提供对 Excel 数据的类型安全访问,隐藏内部实现细节
Attributes:
sheet_name: 工作表名称
source_row: Source: Input 所在行号
signals: 信号名称到信号数据的映射
"""
sheet_name: str = "Scenario1"
source_row: int = 0
signals: dict[str, SignalData] = field(default_factory=dict)
def get_signal(self, name: str) -> SignalData | None:
"""获取指定信号的数据
Args:
name: 信号名称
Returns:
信号数据对象,如果不存在返回 None
"""
return self.signals.get(name)
def get_signal_names(self) -> list[str]:
"""获取所有信号名称
Returns:
信号名称列表
"""
return list(self.signals.keys())
def to_dict(self) -> dict[str, Any]:
"""转换为字典格式(兼容旧 API
Returns:
包含所有信号的字典,保留原有的数据结构
"""
result = {
"sheet_name": self.sheet_name,
"source_row": self.source_row
}
for name, signal in self.signals.items():
result[name] = signal.to_dict()
return result
-26
View File
@@ -1,26 +0,0 @@
"""MIL SDK 自定义异常模块"""
class MILSDKError(Exception):
"""MIL SDK 基础异常类"""
pass
class ExcelReadError(MILSDKError):
"""Excel 文件读取错误(文件不存在、权限问题等)"""
pass
class ExcelFormatError(MILSDKError):
"""Excel 格式错误(缺少 Sheet、格式不匹配等)"""
pass
class CaseDataError(MILSDKError):
"""用例数据错误(信号不存在、类型错误等)"""
pass
class ExcelWriteError(MILSDKError):
"""Excel 文件写入错误(权限问题、保存失败等)"""
pass
-61
View File
@@ -1,61 +0,0 @@
"""日志配置模块"""
import logging
import sys
from pathlib import Path
from typing import Literal
def setup_logging(
level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO",
log_file: str | Path | None = "logs/mil_sdk.log",
console_output: bool = True,
) -> logging.Logger:
"""配置日志系统
Args:
level: 日志级别,默认 INFO
log_file: 日志文件路径,默认 logs/mil_sdk.log。设为 None 则不写入文件
console_output: 是否输出到控制台,默认 True
Returns:
根日志记录器
"""
logger = logging.getLogger()
logger.setLevel(level)
if logger.hasHandlers():
logger.handlers.clear()
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
if log_file is not None:
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
if console_output:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(level)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger
def get_logger(name: str) -> logging.Logger:
"""获取指定名称的日志记录器
Args:
name: 日志记录器名称,通常使用 __name__
Returns:
日志记录器实例
"""
return logging.getLogger(name)
View File
-47
View File
@@ -1,47 +0,0 @@
import pytest
from pathlib import Path
from openpyxl import Workbook
@pytest.fixture
def sample_excel_path() -> Path:
"""返回根目录下的 sample.xlsx 路径"""
path = Path(__file__).parent.parent / "sample.xlsx"
if not path.exists():
pytest.skip(f"测试文件 {path} 不存在")
return path
@pytest.fixture
def invalid_excel_path(tmp_path: Path) -> Path:
"""创建缺少 Scenario1 表的无效 Excel 文件"""
wb = Workbook()
wb.save(tmp_path / "invalid.xlsx")
return tmp_path / "invalid.xlsx"
@pytest.fixture
def sample_data_dict() -> dict:
"""返回示例数据字典"""
return {
"Scenario1": {
"headers": ["Column1", "Column2", "Column3"],
"rows": [
["Value1", "Value2", "Value3"],
["Value4", "Value5", "Value6"]
]
}
}
@pytest.fixture
def sample_sheets_dict() -> dict:
"""返回示例工作表字典"""
return {
"Scenario1": {
"A1": "Header1",
"B1": "Header2",
"A2": "Data1",
"B2": "Data2"
}
}
-20
View File
@@ -1,20 +0,0 @@
import pytest
from src.core.base import DataLog
def test_datalog_defaults():
log = DataLog()
assert log.time == 0.0
assert log.value == ""
def test_datalog_with_values():
log = DataLog(time=1.5, value="test")
assert log.time == 1.5
assert log.value == "test"
def test_datalog_equality():
log1 = DataLog(time=1.0, value="a")
log2 = DataLog(time=1.0, value="a")
assert log1 == log2
-292
View File
@@ -1,292 +0,0 @@
import pytest
from pathlib import Path
from openpyxl import Workbook
from src.core.mil_read_case_excel import (
read_excel_case,
_get_template_version,
_analysis_action,
)
from src.core.mil_create_data_excel import (
create_excel_case,
_init_data_log,
_analysis_case,
_analysis_step,
)
from src.core.exceptions import ExcelReadError, ExcelFormatError, CaseDataError
from src.core.base import DataLog
@pytest.fixture
def sample_data_dict():
"""返回示例数据字典"""
return {
"signal1": {
"type": "Type1",
"column": 2,
"datalog": [DataLog(0.0, "initial"), DataLog(1.0, "value1")]
},
"signal2": {
"type": "Type2",
"column": 3,
"datalog": [DataLog(0.0, "init2"), DataLog(1.0, "value2")]
}
}
@pytest.fixture
def sample_sheets_dict():
"""返回示例工作表字典"""
return {
"TestSheet": {
"TestCase1": {
"enable": False,
"step": {
"step0": {
"name": "Step1",
"time": 1.0,
"action": {"signal1": "new_value1"}
},
"step1": {
"name": "Step2",
"time": 2.0,
"action": {"signal2": "new_value2"}
}
}
},
"TestCase2": {
"enable": True,
"step": {}
}
}
}
def test_get_template_version():
"""验证获取模板版本号"""
wb = Workbook()
sheet = wb.active
sheet.cell(2, 1, "v1.0.0")
sheet.cell(3, 1, None)
version = _get_template_version(sheet)
assert version == "v1.0.0"
def test_get_template_version_empty():
"""验证空工作表的版本号"""
wb = Workbook()
sheet = wb.active
version = _get_template_version(sheet)
assert version is None
def test_analysis_action_valid(sample_data_dict):
"""验证解析有效操作字符串"""
wb = Workbook()
sheet = wb.create_sheet("TestSheet")
sheet.cell(1, 1, "Test")
action = _analysis_action(sheet, 1, 3, "signal1=newvalue", sample_data_dict, "test.xlsx")
assert "signal1" in action
assert action["signal1"] == "newvalue"
def test_analysis_action_multiple(sample_data_dict):
"""验证解析多个操作"""
wb = Workbook()
sheet = wb.create_sheet("TestSheet")
sheet.cell(1, 1, "Test")
action = _analysis_action(sheet, 1, 3, "signal1=v1; signal2=v2", sample_data_dict, "test.xlsx")
assert "signal1" in action
assert "signal2" in action
assert action["signal1"] == "v1"
assert action["signal2"] == "v2"
def test_analysis_action_invalid_signal(sample_data_dict):
"""验证解析无效信号时抛出异常"""
wb = Workbook()
sheet = wb.create_sheet("TestSheet")
sheet.cell(1, 1, "Test")
with pytest.raises(CaseDataError, match="信号 .* 不存在"):
_analysis_action(sheet, 1, 3, "invalid_signal=value", sample_data_dict, "test.xlsx")
def test_init_data_log(sample_data_dict):
"""验证初始化数据日志"""
data_copy = {k: v.copy() for k, v in sample_data_dict.items()}
for name in data_copy:
data_copy[name]["datalog"] = data_copy[name]["datalog"].copy()
_init_data_log(data_copy)
for name in data_copy:
assert len(data_copy[name]["datalog"]) == 1
assert data_copy[name]["datalog"][0].time == 0.0
def test_analysis_step_with_action(sample_data_dict):
"""验证分析带操作的步骤"""
step_dict = {
"step0": {
"name": "TestStep",
"time": 1.5,
"action": {"signal1": "newvalue"}
}
}
data_copy = {k: v.copy() for k, v in sample_data_dict.items()}
for name in data_copy:
data_copy[name]["datalog"] = data_copy[name]["datalog"].copy()
_analysis_step(step_dict, data_copy)
assert len(data_copy["signal1"]["datalog"]) == 3
assert data_copy["signal1"]["datalog"][-1].time == 1.5
assert data_copy["signal1"]["datalog"][-1].value == "newvalue"
def test_analysis_step_without_action(sample_data_dict):
"""验证分析不带操作的步骤"""
step_dict = {
"step0": {
"name": "TestStep",
"time": 1.0
}
}
data_copy = {k: v.copy() for k, v in sample_data_dict.items()}
for name in data_copy:
data_copy[name]["datalog"] = data_copy[name]["datalog"].copy()
initial_length = len(data_copy["signal1"]["datalog"])
_analysis_step(step_dict, data_copy)
assert len(data_copy["signal1"]["datalog"]) == initial_length + 1
def test_analysis_case(sample_data_dict, sample_sheets_dict):
"""验证用例分析"""
result = _analysis_case(sample_data_dict, sample_sheets_dict)
assert "TestCase1" in result
assert "TestCase2" not in result
def test_analysis_case_skips_enabled_cases(sample_data_dict):
"""验证跳过早启用的用例"""
sheets_dict = {
"TestSheet": {
"EnabledCase": {
"enable": True,
"step": {}
}
}
}
result = _analysis_case(sample_data_dict, sheets_dict)
assert "EnabledCase" not in result
def test_read_excel_case_file_not_found():
"""验证文件不存在时抛出异常"""
with pytest.raises(ExcelReadError, match="不存在"):
read_excel_case("nonexistent.xlsx", {}, False)
def test_read_excel_case_missing_atech_sheet(tmp_path: Path):
"""验证缺少 Atech-Hefei 表时抛出异常"""
wb = Workbook()
wb.create_sheet("OtherSheet")
invalid_path = tmp_path / "invalid.xlsx"
wb.save(invalid_path)
with pytest.raises(ExcelFormatError, match="缺少 Atech-Hefei 表"):
read_excel_case(invalid_path, {}, False)
def test_read_excel_case_missing_version(tmp_path: Path, sample_data_dict):
"""验证缺少版本号时抛出异常"""
wb = Workbook()
wb.create_sheet("Atech-Hefei")
wb.create_sheet("TestSheet")
invalid_path = tmp_path / "no_version.xlsx"
wb.save(invalid_path)
with pytest.raises(ExcelFormatError, match="缺少模板版本号"):
read_excel_case(invalid_path, sample_data_dict, False)
def test_read_excel_case_success(tmp_path: Path, sample_data_dict):
"""验证成功读取用例模板"""
wb = Workbook()
wb.remove(wb.active)
version_sheet = wb.create_sheet("Atech-Hefei")
version_sheet.cell(2, 1, "v1.0.0")
test_sheet = wb.create_sheet("TestSheet")
test_sheet.cell(2, 1, "TestCase1")
test_sheet.cell(2, 2, "未完成")
test_sheet.cell(2, 3, None)
test_sheet.cell(2, 4, "Step1")
test_sheet.cell(2, 5, 1.0)
test_sheet.cell(2, 6, None)
test_sheet.cell(3, 1, "TestCase1")
test_sheet.cell(3, 2, "未完成")
test_sheet.cell(3, 3, None)
test_sheet.cell(3, 4, "Step2")
test_sheet.cell(3, 5, 2.0)
test_sheet.cell(3, 6, None)
valid_path = tmp_path / "valid.xlsx"
wb.save(valid_path)
result = read_excel_case(valid_path, sample_data_dict, False)
assert "TestSheet" in result
assert "TestCase1" in result["TestSheet"]
def test_read_excel_case_missing_title(tmp_path: Path, sample_data_dict):
"""验证缺少标题时抛出异常"""
wb = Workbook()
version_sheet = wb.create_sheet("Atech-Hefei")
version_sheet.cell(2, 1, "v1.0.0")
test_sheet = wb.create_sheet("TestSheet")
test_sheet.cell(2, 1, None)
invalid_path = tmp_path / "missing_title.xlsx"
wb.save(invalid_path)
with pytest.raises(CaseDataError, match="必须有标题名"):
read_excel_case(invalid_path, sample_data_dict, False)
def test_read_excel_case_invalid_time(tmp_path: Path, sample_data_dict):
"""验证时间格式错误时抛出异常"""
wb = Workbook()
wb.remove(wb.active)
version_sheet = wb.create_sheet("Atech-Hefei")
version_sheet.cell(2, 1, "v1.0.0")
test_sheet = wb.create_sheet("TestSheet")
test_sheet.cell(2, 1, "TestCase1")
test_sheet.cell(2, 2, "未完成")
test_sheet.cell(2, 4, "Step1")
test_sheet.cell(2, 5, "invalid_time")
test_sheet.cell(2, 6, None)
invalid_path = tmp_path / "invalid_time.xlsx"
wb.save(invalid_path)
with pytest.raises(CaseDataError, match="时间必须是数字"):
read_excel_case(invalid_path, sample_data_dict, False)
-150
View File
@@ -1,150 +0,0 @@
import pytest
from pathlib import Path
from openpyxl import Workbook
from src.core.mil_read_data_excel import read_excel_data, ExcelReaderConfig
from src.core.exceptions import ExcelReadError, ExcelFormatError
def test_read_excel_data_returns_required_keys(sample_excel_path: Path):
"""验证返回结果包含必需键"""
result = read_excel_data(sample_excel_path)
assert "wb" in result
assert "sheet" in result
assert "source_row" in result
def test_read_excel_data_contains_signals(sample_excel_path: Path):
"""验证能解析出信号数据"""
result = read_excel_data(sample_excel_path)
signal_keys = [k for k in result.keys() if k not in ("wb", "sheet", "source_row")]
assert len(signal_keys) > 0, "应至少包含一个信号"
for name in signal_keys:
assert "type" in result[name]
assert "column" in result[name]
assert "datalog" in result[name]
def test_read_excel_file_not_found():
"""文件不存在时抛出 ExcelReadError"""
with pytest.raises(ExcelReadError, match="不存在"):
read_excel_data("nonexistent.xlsx")
def test_read_excel_missing_sheet(tmp_path: Path):
"""缺少 Scenario1 表时抛出 ExcelFormatError"""
wb = Workbook()
wb.create_sheet("WrongSheet")
invalid_path = tmp_path / "invalid.xlsx"
wb.save(invalid_path)
with pytest.raises(ExcelFormatError, match="缺少"):
read_excel_data(invalid_path)
def test_read_excel_missing_source_header(tmp_path: Path):
"""缺少 Source: Input 标记时抛出 ExcelFormatError"""
wb = Workbook()
sheet = wb.active
sheet.title = ExcelReaderConfig().sheet_name
sheet.cell(1, 1, "time")
sheet.cell(1, 2, "signal1")
invalid_path = tmp_path / "missing_header.xlsx"
wb.save(invalid_path)
with pytest.raises(ExcelFormatError, match="缺少"):
read_excel_data(invalid_path)
def test_datalog_is_list(sample_excel_path: Path):
"""验证 datalog 是 DataLog 对象列表"""
result = read_excel_data(sample_excel_path)
signal_keys = [k for k in result.keys() if k not in ("wb", "sheet", "source_row")]
assert len(signal_keys) > 0
first_signal = result[signal_keys[0]]
assert len(first_signal["datalog"]) > 0
assert hasattr(first_signal["datalog"][0], "time")
assert hasattr(first_signal["datalog"][0], "value")
def test_read_excel_data_return_object(sample_excel_path: Path):
"""验证 return_object=True 时返回 ExcelDataResult 对象"""
from src.core.base import ExcelDataResult, SignalData
result = read_excel_data(sample_excel_path, return_object=True)
assert isinstance(result, ExcelDataResult)
assert result.sheet_name == "Scenario1"
assert result.source_row > 0
assert len(result.signals) > 0
def test_read_excel_data_signal_data_access(sample_excel_path: Path):
"""验证 ExcelDataResult 的信号访问方法"""
from src.core.base import SignalData
result = read_excel_data(sample_excel_path, return_object=True)
signal_names = result.get_signal_names()
assert len(signal_names) > 0
first_signal_name = signal_names[0]
signal = result.get_signal(first_signal_name)
assert isinstance(signal, SignalData)
assert signal.datalog is not None
def test_excel_reader_config_defaults():
"""验证 ExcelReaderConfig 默认值"""
config = ExcelReaderConfig()
assert config.sheet_name == "Scenario1"
assert config.source_header == "Source: Input"
assert config.time_column == 1
assert config.header_row == 1
assert config.type_row == 3
assert config.data_start_row_offset == 1
def test_excel_reader_config_custom():
"""验证 ExcelReaderConfig 自定义值"""
config = ExcelReaderConfig(
sheet_name="CustomSheet",
source_header="CustomHeader",
time_column=2,
header_row=2,
type_row=4,
data_start_row_offset=2
)
assert config.sheet_name == "CustomSheet"
assert config.source_header == "CustomHeader"
assert config.time_column == 2
assert config.header_row == 2
assert config.type_row == 4
assert config.data_start_row_offset == 2
def test_read_excel_with_custom_config(tmp_path: Path):
"""验证使用自定义配置读取 Excel"""
wb = Workbook()
sheet = wb.active
sheet.title = "CustomSheet"
sheet.cell(1, 1, "time")
sheet.cell(1, 2, "signal1")
sheet.cell(2, 1, "CustomHeader")
sheet.cell(2, 2, "CustomHeader")
sheet.cell(3, 1, "Type1")
sheet.cell(4, 1, 0.0)
sheet.cell(4, 2, "value1")
sheet.cell(5, 1, 1.0)
sheet.cell(5, 2, "value2")
custom_path = tmp_path / "custom.xlsx"
wb.save(custom_path)
config = ExcelReaderConfig(
sheet_name="CustomSheet",
source_header="CustomHeader"
)
result = read_excel_data(custom_path, return_object=True, config=config)
assert result.sheet_name == "CustomSheet"
assert "signal1" in result.signals