用「WCF + 通用存储过程网关」接口替代 Selenium(D://Learning//Python//工单自动化)导入工单: 单 Sprint 12 个项目 175s 跑完(旧方案 25~35 分钟),失败可断点续跑且不重复建单。 - 分层:transport(HTTP/协议)/ api(存储过程语义)/ domain(纯规则)/ workflow(阶段编排)/ store(SQLite 台账)/ report / CLI - 幂等:台账驱动断点续跑;dry-run 与跳过行不污染进度;--rollback-on-fail 清理半成品 - 命令:plan / apply / report / status,真实写入需 --yes - tools:只读探针、存储过程源码导出(sp_helptext)、导入诊断 - docs:调研与设计文档(含联调踩到的 5 个坑)+ 6 个关键存储过程源码 实测:Sprint 2026 Sprint 10 的 12 个项目全部完成建单→导入→确认→推送 TFS, 工单 225134–225145,35 个功能模块,重跑 plan 全部 skip-pushed。69 个单测通过。
212 lines
8.5 KiB
Python
212 lines
8.5 KiB
Python
"""编排测试:阶段推进、断点续跑、失败归属与回滚(全部用 FakeApi,不联网)。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
|
|
from qtimport.config import Config
|
|
from qtimport.domain import Action, PlanRow
|
|
from qtimport.errors import Category, QtError
|
|
from qtimport.models import Feature, Project, User, WorkSheet, WorkSheetVersion
|
|
from qtimport.store import Store
|
|
from qtimport.workflow import Importer, RunOptions
|
|
|
|
|
|
class FakeLog:
|
|
def __init__(self) -> None:
|
|
self.lines: list[str] = []
|
|
|
|
def info(self, message: str, **_) -> None:
|
|
self.lines.append(message)
|
|
|
|
def warn(self, message: str, **_) -> None:
|
|
self.lines.append("!" + message)
|
|
|
|
def event(self, *_, **__) -> None:
|
|
pass
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
class FakeApi:
|
|
dry_run = False
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[str] = []
|
|
self.deleted: list[int] = []
|
|
self.version_code = "技术责任人确认版"
|
|
self.import_error: QtError | None = None
|
|
|
|
def create_worksheet(self) -> int:
|
|
self.calls.append("create")
|
|
return 501
|
|
|
|
def fill_worksheet(self, worksheet_id, project, **kwargs) -> int:
|
|
self.calls.append("fill")
|
|
return 1
|
|
|
|
def worksheet(self, worksheet_id: int) -> WorkSheet:
|
|
self.calls.append("detail")
|
|
return WorkSheet(worksheet_id=worksheet_id, status="01", version_id=88, version_code=self.version_code)
|
|
|
|
def pending_version(self, project_id: str, worksheet_id: int) -> WorkSheetVersion:
|
|
"""只读查版本,不计入 calls —— 保持断言只看写操作序列。"""
|
|
return WorkSheetVersion(
|
|
version_id=88, worksheet_id=worksheet_id,
|
|
version_code=self.version_code, version_status="N",
|
|
)
|
|
|
|
def upload_excel(self, path: str, folder: str = "WorkSheetImport") -> str:
|
|
self.calls.append("upload")
|
|
return "E:\\tmp\\x.xlsx"
|
|
|
|
def import_excel(self, worksheet_id: int, version_id: int, remote: str) -> None:
|
|
self.calls.append("import")
|
|
if self.import_error:
|
|
raise self.import_error
|
|
|
|
def verify_worksheet(self, worksheet_id, version_id, **kwargs) -> None:
|
|
self.calls.append("verify")
|
|
|
|
def features(self, worksheet_id: int) -> list[Feature]:
|
|
self.calls.append("features")
|
|
return [Feature(1, "M1", "模块")]
|
|
|
|
def push_tfs(self, feature_ids, project_name, area_path, iteration_path):
|
|
self.calls.append("push")
|
|
return []
|
|
|
|
def delete_worksheet(self, worksheet_id: int) -> None:
|
|
self.calls.append("delete")
|
|
self.deleted.append(worksheet_id)
|
|
|
|
|
|
class WorkflowTest(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.folder = tempfile.TemporaryDirectory()
|
|
self.store = Store(os.path.join(self.folder.name, "t.db"))
|
|
self.store.start_run("r1", "2026 Sprint 10", "d", dry_run=False)
|
|
self.cfg = Config(
|
|
base_url="http://example.invalid",
|
|
username="u",
|
|
password="p",
|
|
sprint="2026 Sprint 10",
|
|
start_time="2026-10-01",
|
|
end_time="2026-10-31",
|
|
tfs_project="Tools_Dev",
|
|
area_path="Tools_Dev\\DataTechnologies",
|
|
iteration_path="Tools_Dev\\2026 Sprint\\2026 Sprint 10",
|
|
workers=1,
|
|
db_path=os.path.join(self.folder.name, "t.db"),
|
|
)
|
|
self.log = FakeLog()
|
|
self.api = FakeApi()
|
|
|
|
def tearDown(self) -> None:
|
|
self.store.close()
|
|
self.folder.cleanup()
|
|
|
|
def _importer(self, **options) -> Importer:
|
|
importer = Importer(self.cfg, self.store, "r1", self.log, RunOptions(**options))
|
|
importer.api = self.api
|
|
importer.user = User("g1", "陈首林", "u")
|
|
importer.team_id = "t1"
|
|
return importer
|
|
|
|
def _row(self, action: Action = Action.CREATE, worksheet: WorkSheet | None = None) -> PlanRow:
|
|
return PlanRow(
|
|
project_name="信托 - 上海信托",
|
|
file_path="f.xlsx",
|
|
action=action,
|
|
project=Project("p1", "信托 - 上海信托"),
|
|
worksheet=worksheet,
|
|
)
|
|
|
|
def test_full_pipeline(self) -> None:
|
|
progress = self._importer()._run_project(self._row())
|
|
self.assertEqual(progress.stage, "TFS_PUSHED")
|
|
self.assertEqual(
|
|
self.api.calls, ["create", "fill", "upload", "import", "detail", "verify", "features", "push"]
|
|
)
|
|
self.assertEqual(self.store.latest_stage("p1", "2026 Sprint 10"), "TFS_PUSHED")
|
|
|
|
def test_resume_from_imported(self) -> None:
|
|
"""台账停在 EXCEL_IMPORTED,只该跑 确认 + 推送,不能重复建单。"""
|
|
self.store.upsert_job(
|
|
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
|
|
source_file="f.xlsx", action="create", stage="EXCEL_IMPORTED", worksheet_id=501, version_id=88,
|
|
)
|
|
progress = self._importer()._run_project(self._row(Action.RESUME, WorkSheet(501)))
|
|
self.assertEqual(progress.stage, "TFS_PUSHED")
|
|
self.assertEqual(self.api.calls, ["detail", "verify", "features", "push"])
|
|
|
|
def test_skip_tfs(self) -> None:
|
|
progress = self._importer(skip_tfs=True)._run_project(self._row())
|
|
self.assertNotIn("push", self.api.calls)
|
|
self.assertEqual(progress.stage, "WS_VERIFIED")
|
|
|
|
def test_import_failure_is_attributed(self) -> None:
|
|
self.api.import_error = QtError("模板不符", Category.EXCEL)
|
|
progress = self._importer()._run_project(self._row())
|
|
self.assertEqual(progress.stage, "FAILED:EXCEL_IMPORTED")
|
|
job = self.store.latest_job("p1", "2026 Sprint 10")
|
|
self.assertIn("EXCEL", job["error"])
|
|
self.assertEqual(self.api.deleted, [], "未开 --rollback-on-fail 不应删单")
|
|
|
|
def test_rollback_on_failure(self) -> None:
|
|
self.api.import_error = QtError("模板不符", Category.EXCEL)
|
|
self._importer(rollback_on_fail=True)._run_project(self._row())
|
|
self.assertEqual(self.api.deleted, [501])
|
|
|
|
def test_no_rollback_when_not_created_here(self) -> None:
|
|
self.api.import_error = QtError("模板不符", Category.EXCEL)
|
|
row = self._row(Action.RESUME, WorkSheet(501))
|
|
self.store.upsert_job(
|
|
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
|
|
source_file="f.xlsx", action="resume", stage="WS_FILLED", worksheet_id=501,
|
|
)
|
|
self._importer(rollback_on_fail=True)._run_project(row)
|
|
self.assertEqual(self.api.deleted, [], "接管别人的单不能删")
|
|
|
|
def test_resume_after_failure_does_not_recreate(self) -> None:
|
|
"""上次在导入环节失败,重跑必须复用同一张工单,不能重新建。"""
|
|
self.store.upsert_job(
|
|
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
|
|
source_file="f.xlsx", action="create", stage="FAILED:EXCEL_IMPORTED",
|
|
worksheet_id=501, version_id=88,
|
|
)
|
|
progress = self._importer()._run_project(self._row(Action.RESUME, WorkSheet(501)))
|
|
self.assertNotIn("create", self.api.calls)
|
|
self.assertNotIn("fill", self.api.calls)
|
|
self.assertEqual(self.api.calls[0], "upload", "失败点重试,先重传再导入")
|
|
self.assertEqual(progress.stage, "TFS_PUSHED")
|
|
|
|
def test_create_ignores_stale_ledger(self) -> None:
|
|
"""回滚删单后台账仍记着旧 WorkSheetId,重新规划为 create 时必须重新建单。"""
|
|
self.store.upsert_job(
|
|
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
|
|
source_file="f.xlsx", action="create", stage="FAILED:WS_FILLED", worksheet_id=999,
|
|
)
|
|
progress = self._importer()._run_project(self._row(Action.CREATE))
|
|
self.assertEqual(self.api.calls[0], "create")
|
|
self.assertNotIn(999, [progress.worksheet_id])
|
|
|
|
def test_completed_index(self) -> None:
|
|
self.assertEqual(Importer._completed_index(""), -1)
|
|
self.assertEqual(Importer._completed_index("WS_FILLED"), 1)
|
|
self.assertEqual(Importer._completed_index("FAILED:WS_FILLED"), 0)
|
|
self.assertEqual(Importer._completed_index("FAILED:WS_CREATED"), -1)
|
|
self.assertEqual(Importer._completed_index("SKIPPED"), -1)
|
|
|
|
def test_terminal_version_skips_verify(self) -> None:
|
|
self.api.version_code = "甲方确认版"
|
|
self._importer()._run_project(self._row())
|
|
self.assertNotIn("verify", self.api.calls)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|