Files
QTImport/tests/test_workflow.py
T
chenshoulin 58850314cf 初始提交:QuickTeam 工单接口导入工具
用「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 个单测通过。
2026-11-05 19:14:25 +08:00

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()