feat: enable full AI automation and safe startup updates
This commit is contained in:
1 parent
2ddec53954
commit
7b1a0755e2
62 files changed
+3123
-240
No files matched your search
@@ -0,0 +1,127 @@
|
||||
"""Versioned Excel task template and bounded, non-executing import validation."""
|
||||
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
HEADERS = {
|
||||
"任务名称": "name",
|
||||
"任务目标": "goal",
|
||||
"预期结果": "expected",
|
||||
"目标应用": "app",
|
||||
"前置条件": "preconditions",
|
||||
"输入参数(JSON)": "parameters",
|
||||
"标签": "tags",
|
||||
}
|
||||
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
||||
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
||||
|
||||
|
||||
class TaskCase(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
goal: str = Field(min_length=1, max_length=10000)
|
||||
expected: str = Field(default="", max_length=10000)
|
||||
app: str = Field(default="", max_length=200)
|
||||
preconditions: str = Field(default="", max_length=10000)
|
||||
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
||||
tags: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
def template() -> bytes:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
assert ws is not None
|
||||
ws.title = "任务"
|
||||
ws.append(list(HEADERS))
|
||||
ws.freeze_panes = "A2"
|
||||
ws.auto_filter.ref = "A1:G1"
|
||||
for cell in ws[1]:
|
||||
cell.font = Font(bold=True, color="FFFFFF")
|
||||
cell.fill = PatternFill("solid", fgColor="245C78")
|
||||
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = 32
|
||||
help_sheet = wb.create_sheet("填写说明")
|
||||
for line in [
|
||||
"模板版本:1",
|
||||
"每行一个独立任务;任务名称、任务目标必填。",
|
||||
"预期结果决定要验证的业务条件。未填写时只报告任务完成情况。",
|
||||
"前置条件描述实际用例起点;不会统一重启或清空应用数据。",
|
||||
'输入参数使用 JSON 对象,例如 {"关键词":"天气"};目标可引用 {{关键词}}。',
|
||||
"目标应用可填名称或包名;空白时由任务决定。",
|
||||
"不要填写 Excel 公式、宏或外部链接。导入仅预览,点击开始后执行。",
|
||||
]:
|
||||
help_sheet.append([line])
|
||||
help_sheet.column_dimensions["A"].width = 105
|
||||
for row in help_sheet:
|
||||
row[0].alignment = Alignment(wrap_text=True)
|
||||
stream = BytesIO()
|
||||
wb.save(stream)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def parse_workbook(data: bytes) -> dict:
|
||||
if len(data) > MAX_BYTES:
|
||||
raise ValueError("文件过大,请拆分批次后导入")
|
||||
try:
|
||||
with zipfile.ZipFile(BytesIO(data)) as archive:
|
||||
if (
|
||||
len(archive.infolist()) > 2000
|
||||
or sum(i.file_size for i in archive.infolist()) > MAX_BYTES * 10
|
||||
):
|
||||
raise ValueError("Excel 解压体积超过限制")
|
||||
if any(
|
||||
"vbaproject" in i.filename.lower() or "externallinks" in i.filename.lower()
|
||||
for i in archive.infolist()
|
||||
):
|
||||
raise ValueError("不支持宏或外部链接")
|
||||
wb = load_workbook(BytesIO(data), read_only=True, data_only=False, keep_links=False)
|
||||
except (zipfile.BadZipFile, KeyError, OSError) as exc:
|
||||
raise ValueError("请上传有效的 .xlsx 模板文件") from exc
|
||||
try:
|
||||
if "任务" not in wb.sheetnames:
|
||||
raise ValueError("缺少“任务”工作表,请使用下载的模板")
|
||||
rows = iter(wb["任务"].iter_rows())
|
||||
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
||||
if headers != list(HEADERS):
|
||||
raise ValueError("列名或顺序与模板不一致,请重新下载模板")
|
||||
tasks, errors = [], []
|
||||
for number, row in enumerate(rows, 2):
|
||||
if number > MAX_ROWS + 1:
|
||||
raise ValueError(f"单批最多 {MAX_ROWS} 行任务")
|
||||
if not any(cell.value is not None for cell in row):
|
||||
continue
|
||||
try:
|
||||
if any(cell.data_type == "f" for cell in row):
|
||||
raise ValueError("不允许公式,请粘贴为纯文本")
|
||||
values = [str(cell.value or "").strip() for cell in row[: len(HEADERS)]]
|
||||
item = dict(zip(HEADERS.values(), values))
|
||||
item["parameters"] = json.loads(item.get("parameters") or "{}")
|
||||
case = TaskCase.model_validate(item)
|
||||
tasks.append({"row": number, **case.model_dump()})
|
||||
except (ValueError, TypeError) as exc:
|
||||
errors.append({"row": number, "message": str(exc)[:500]})
|
||||
if not tasks and not errors:
|
||||
errors.append({"row": 2, "message": "没有任务,请填写模板后重新导入"})
|
||||
return {"version": 1, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def case_goal(case: dict) -> str:
|
||||
goal = case["goal"]
|
||||
for key, value in case.get("parameters", {}).items():
|
||||
goal = goal.replace("{{" + key + "}}", str(value))
|
||||
return (
|
||||
f"任务名称:{case['name']}\n任务目标:{goal}\n目标应用:{case.get('app') or '按目标判断'}\n"
|
||||
f"前置条件:{case.get('preconditions') or '根据实际用例判断合适起点'}\n"
|
||||
"先观察手机并恢复到本用例需要的起始位置,记录准备过程。不要固定重启或清空数据。"
|
||||
"如果无法建立前置条件,明确报告前置条件未满足,不继续正文操作。\n"
|
||||
f"预期结果:{case.get('expected') or '仅确认任务完成情况,不宣称测试通过'}"
|
||||
)
|
||||
Reference in new issue
Block a user