初始提交: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 个单测通过。
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
"""api 层:参数构造与返回判定的单元测试(不联网)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import unittest
|
||||
import urllib.parse
|
||||
|
||||
from qtimport.api import QuickTeamApi
|
||||
from qtimport.errors import Category, QtError
|
||||
from qtimport.models import Project, TfsTeam
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple] = []
|
||||
self.table: dict = {}
|
||||
self.result: object = 0
|
||||
self.import_result: object = [{"Column1": 0}]
|
||||
self.tfs_result: object = []
|
||||
self.upload_result = "E:\\tmp\\x.xlsx"
|
||||
|
||||
def exec_table(self, connection, procedure, params, *, op="", write=False):
|
||||
self.calls.append(("exec_table", connection, procedure, params, write))
|
||||
return self.table.get(procedure, [])
|
||||
|
||||
def exec_result(self, connection, procedure, params, *, op=""):
|
||||
self.calls.append(("exec_result", connection, procedure, params))
|
||||
return self.result
|
||||
|
||||
def batch_import(self, procedure, params, file_path):
|
||||
self.calls.append(("batch_import", procedure, params, file_path))
|
||||
return self.import_result
|
||||
|
||||
def upload(self, local_path, folder, *, op="FileUpload"):
|
||||
self.calls.append(("upload", local_path, folder))
|
||||
return self.upload_result
|
||||
|
||||
def tfs(self, operation, payload=None, *, op="", write=False):
|
||||
self.calls.append(("tfs", operation, payload))
|
||||
return self.tfs_result
|
||||
|
||||
|
||||
PROJECT = Project("0f52", "信托 - 上海信托")
|
||||
|
||||
|
||||
class LoginTest(unittest.TestCase):
|
||||
def test_password_is_md5(self) -> None:
|
||||
fake = FakeTransport()
|
||||
fake.table["dbo.usp_Authentication"] = [
|
||||
{"GuidId": "g-1", "RealName": "陈首林", "UserName": "chen@goldenstand.cn"}
|
||||
]
|
||||
api = QuickTeamApi(fake)
|
||||
user = api.login("chen@goldenstand.cn", "dummy-pass")
|
||||
self.assertEqual(user.user_id, "g-1")
|
||||
self.assertEqual(user.real_name, "陈首林")
|
||||
|
||||
_, connection, procedure, params, _ = fake.calls[0]
|
||||
self.assertEqual(connection, "UserCenter")
|
||||
self.assertEqual(procedure, "dbo.usp_Authentication")
|
||||
sent = dict((name, value) for name, value, _ in params)
|
||||
self.assertEqual(sent["Password"], hashlib.md5(b"dummy-pass").hexdigest())
|
||||
|
||||
def test_empty_result_is_auth_error(self) -> None:
|
||||
api = QuickTeamApi(FakeTransport())
|
||||
with self.assertRaises(QtError) as ctx:
|
||||
api.login("a", "b")
|
||||
self.assertEqual(ctx.exception.category, Category.AUTH)
|
||||
|
||||
|
||||
class FillWorkSheetTest(unittest.TestCase):
|
||||
def _api(self) -> tuple[QuickTeamApi, FakeTransport]:
|
||||
fake = FakeTransport()
|
||||
fake.table["QuickTeam.usp_UpdateWorkSheetById"] = [{"Result": 1}]
|
||||
return QuickTeamApi(fake), fake
|
||||
|
||||
def test_payload(self) -> None:
|
||||
api, fake = self._api()
|
||||
api.fill_worksheet(
|
||||
123,
|
||||
PROJECT,
|
||||
system_name="2026 Sprint 10",
|
||||
start_time="2026-10-01",
|
||||
end_time="2026-10-31",
|
||||
evaluator="陈首林",
|
||||
technical_leader="陈首林",
|
||||
customer_leader="杜衡",
|
||||
belong_team="资管投资人事业部",
|
||||
content="需求开发测试",
|
||||
operator="陈首林",
|
||||
)
|
||||
_, connection, procedure, params, write = fake.calls[0]
|
||||
self.assertEqual(connection, "QuickTeam")
|
||||
self.assertEqual(procedure, "QuickTeam.usp_UpdateWorkSheetById")
|
||||
self.assertTrue(write, "写操作必须标记 write,避免被误重试")
|
||||
|
||||
sent = {name: (value, dbtype) for name, value, dbtype in params}
|
||||
self.assertEqual(len(sent), 17)
|
||||
self.assertEqual(sent["Operation"][0], "new")
|
||||
self.assertEqual(sent["DeleteFlag"][0], "0")
|
||||
self.assertEqual(sent["SystemName"][0], "2026 Sprint 10")
|
||||
self.assertEqual(sent["ProjectId"][0], "0f52")
|
||||
self.assertEqual(sent["CustomerLeader"][0], "杜衡")
|
||||
self.assertEqual(sent["WorkSheetId"], (123, "string")) # 类型转换在 transport 层做
|
||||
self.assertEqual(sent["Status"][0], "00", "新建工单从「等待技术责任人确认」开始")
|
||||
|
||||
def test_zero_result_but_state_applied_is_success(self) -> None:
|
||||
"""实测:UpdateWorkSheetById 的 Result 恒为 0,但数据已落库,不能因此判失败。"""
|
||||
fake = FakeTransport()
|
||||
fake.table["QuickTeam.usp_UpdateWorkSheetById"] = [{"Result": 0}]
|
||||
fake.table["QuickTeam.usp_GetWorkSheetById"] = [
|
||||
{"WorkSheetId": 1, "SystemName": "2026 Sprint 10", "Status": "00", "CurrentVersionId": 5}
|
||||
]
|
||||
api = QuickTeamApi(fake)
|
||||
self.assertEqual(
|
||||
api.fill_worksheet(
|
||||
1, PROJECT, system_name="2026 Sprint 10", start_time="a", end_time="b", evaluator="e",
|
||||
technical_leader="t", customer_leader="c", belong_team="bt", content="ct", operator="op",
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_zero_result_and_state_missing_raises(self) -> None:
|
||||
fake = FakeTransport()
|
||||
fake.table["QuickTeam.usp_UpdateWorkSheetById"] = [{"Result": 0}]
|
||||
fake.table["QuickTeam.usp_GetWorkSheetById"] = [{"WorkSheetId": 1, "SystemName": "", "Status": ""}]
|
||||
api = QuickTeamApi(fake)
|
||||
with self.assertRaises(QtError):
|
||||
api.fill_worksheet(
|
||||
1, PROJECT, system_name="2026 Sprint 10", start_time="a", end_time="b", evaluator="e",
|
||||
technical_leader="t", customer_leader="c", belong_team="bt", content="ct", operator="op",
|
||||
)
|
||||
|
||||
|
||||
class ImportExcelTest(unittest.TestCase):
|
||||
def test_success(self) -> None:
|
||||
fake = FakeTransport()
|
||||
api = QuickTeamApi(fake)
|
||||
api.import_excel(9, 260113, "E:\\tmp\\x.xlsx")
|
||||
_, procedure, params, path = fake.calls[0]
|
||||
self.assertEqual(procedure, "QuickTeam.usp_BatchImportFeatureFromExcel")
|
||||
self.assertEqual(params, [("WorkSheetId", 9, "int"), ("VersionId", 260113, "int")])
|
||||
self.assertEqual(path, "E:\\tmp\\x.xlsx")
|
||||
|
||||
def test_column1_nonzero_raises_excel(self) -> None:
|
||||
fake = FakeTransport()
|
||||
fake.import_result = [{"Column1": 3}]
|
||||
api = QuickTeamApi(fake)
|
||||
with self.assertRaises(QtError) as ctx:
|
||||
api.import_excel(9, 1, "p")
|
||||
self.assertEqual(ctx.exception.category, Category.EXCEL)
|
||||
|
||||
def test_unexpected_payload(self) -> None:
|
||||
fake = FakeTransport()
|
||||
fake.import_result = None
|
||||
api = QuickTeamApi(fake)
|
||||
with self.assertRaises(QtError):
|
||||
api.import_excel(9, 1, "p")
|
||||
|
||||
|
||||
class WorkSheetListTest(unittest.TestCase):
|
||||
"""项目工单列表:一次查全(该 SP 一行一个版本,工单本体行 VersionId 为空)。"""
|
||||
|
||||
ROWS = [
|
||||
{"WorkSheetId": 501, "WorkSheetCode": "W_501", "SystemName": "2026 Sprint 10",
|
||||
"Status": "01", "CurrentVersionId": 260220, "VersionId": "", "VersionCode": "",
|
||||
"StartTime": "/Date(1791388800000)/"},
|
||||
{"WorkSheetId": 501, "WorkSheetCode": "技术责任人确认版", "SystemName": "2026 Sprint 10",
|
||||
"Status": "01", "CurrentVersionId": 260220, "VersionId": 260220,
|
||||
"VersionCode": "技术责任人确认版"},
|
||||
{"WorkSheetId": 502, "WorkSheetCode": "客户责任人确认版", "SystemName": "2026 Sprint 9",
|
||||
"Status": "01", "CurrentVersionId": 260180, "VersionId": 260180,
|
||||
"VersionCode": "客户责任人确认版"},
|
||||
]
|
||||
|
||||
def test_dedupes_by_worksheet_and_prefers_base_row(self) -> None:
|
||||
fake = FakeTransport()
|
||||
fake.table["QuickTeam.usp_GetWorkSheetsById"] = self.ROWS
|
||||
api = QuickTeamApi(fake)
|
||||
sheets = api.work_sheets("0f52")
|
||||
self.assertEqual([s.worksheet_id for s in sheets], [501, 502])
|
||||
first = sheets[0]
|
||||
self.assertEqual(first.code, "W_501", "版本行把 WorkSheetCode 覆盖成了 VersionCode,必须取本体行")
|
||||
self.assertEqual(first.system_name, "2026 Sprint 10")
|
||||
self.assertEqual(first.status, "01")
|
||||
self.assertEqual(first.version_id, 260220)
|
||||
self.assertEqual(first.start_time, "2026-10-08")
|
||||
self.assertEqual(sheets[1].system_name, "2026 Sprint 9")
|
||||
|
||||
|
||||
class WorksheetVersionTest(unittest.TestCase):
|
||||
"""版本行来自 usp_GetWorkSheetsById(列表页那个 SP,一行一个版本)。"""
|
||||
|
||||
ROWS = [
|
||||
# union 出来的「无版本」占位行,VersionId 是空串
|
||||
{"WorkSheetId": 501, "VersionId": "", "VersionCode": "", "VersionStatus": "", "NextOperator": ""},
|
||||
{"WorkSheetId": 501, "VersionId": 260299, "VersionCode": "客户责任人确认版",
|
||||
"VersionStatus": "Y", "NextOperator": "CustomerLeader"},
|
||||
{"WorkSheetId": 501, "VersionId": 260300, "VersionCode": "技术责任人确认版",
|
||||
"VersionStatus": "N", "NextOperator": "TechnicalLeader"},
|
||||
{"WorkSheetId": 502, "VersionId": 260400, "VersionCode": "技术责任人确认版",
|
||||
"VersionStatus": "N", "NextOperator": "TechnicalLeader"},
|
||||
]
|
||||
|
||||
def _api(self) -> tuple[QuickTeamApi, FakeTransport]:
|
||||
fake = FakeTransport()
|
||||
fake.table["QuickTeam.usp_GetWorkSheetsById"] = self.ROWS
|
||||
return QuickTeamApi(fake), fake
|
||||
|
||||
def test_filters_placeholder_and_other_worksheets(self) -> None:
|
||||
api, fake = self._api()
|
||||
versions = api.worksheet_versions("0f52", 501)
|
||||
self.assertEqual([v.version_id for v in versions], [260299, 260300])
|
||||
_, connection, procedure, params, write = fake.calls[0]
|
||||
self.assertEqual(procedure, "QuickTeam.usp_GetWorkSheetsById")
|
||||
self.assertFalse(write)
|
||||
|
||||
def test_pending_version_prefers_n(self) -> None:
|
||||
"""CurrentVersionId 是 NULL,导入必须落到『待确认』那一版,否则会撞 FK。"""
|
||||
api, _ = self._api()
|
||||
pending = api.pending_version("0f52", 501)
|
||||
self.assertIsNotNone(pending)
|
||||
self.assertEqual(pending.version_id, 260300)
|
||||
self.assertEqual(pending.version_code, "技术责任人确认版")
|
||||
|
||||
def test_pending_falls_back_to_latest_when_all_confirmed(self) -> None:
|
||||
fake = FakeTransport()
|
||||
fake.table["QuickTeam.usp_GetWorkSheetsById"] = [
|
||||
{"WorkSheetId": 501, "VersionId": 1, "VersionCode": "技术责任人确认版", "VersionStatus": "Y"},
|
||||
{"WorkSheetId": 501, "VersionId": 2, "VersionCode": "甲方确认版", "VersionStatus": "Y"},
|
||||
]
|
||||
api = QuickTeamApi(fake)
|
||||
self.assertEqual(api.pending_version("0f52", 501).version_id, 2)
|
||||
|
||||
def test_no_version_returns_none(self) -> None:
|
||||
api = QuickTeamApi(FakeTransport())
|
||||
self.assertIsNone(api.pending_version("0f52", 501))
|
||||
|
||||
|
||||
class VerifyTest(unittest.TestCase):
|
||||
def test_success_zero(self) -> None:
|
||||
fake = FakeTransport()
|
||||
fake.result = 0
|
||||
api = QuickTeamApi(fake)
|
||||
api.verify_worksheet(1, 2, next_operator="CustomerLeader", next_version_code="客户责任人确认版",
|
||||
operator="陈首林", percent=20)
|
||||
_, connection, procedure, params = fake.calls[0]
|
||||
self.assertEqual(procedure, "QuickTeam.usp_VerifyWorkSheetById")
|
||||
sent = {name: value for name, value, _ in params}
|
||||
self.assertEqual(sent["NextOperator"], "CustomerLeader")
|
||||
self.assertEqual(sent["Percent"], 20)
|
||||
|
||||
def test_nonzero_raises(self) -> None:
|
||||
fake = FakeTransport()
|
||||
fake.result = 1
|
||||
api = QuickTeamApi(fake)
|
||||
with self.assertRaises(QtError):
|
||||
api.verify_worksheet(1, 2, next_operator="x", next_version_code="y", operator="o", percent=20)
|
||||
|
||||
|
||||
class TfsTest(unittest.TestCase):
|
||||
TEAMS = [
|
||||
TfsTeam("DataTechnologies", "Tools_Dev\\DataTechnologies",
|
||||
[{"Name": "2026 Sprint 10", "Path": "Tools_Dev\\2026 Sprint\\2026 Sprint 10"}]),
|
||||
TfsTeam("FundHub", "Tools_Dev\\FundHub",
|
||||
[{"Name": "2026 Sprint 06", "Path": "Tools_Dev\\FundHub\\2026 Sprint 06"}]),
|
||||
]
|
||||
|
||||
def test_resolve_exact(self) -> None:
|
||||
area, iteration = QuickTeamApi.resolve_tfs_context(self.TEAMS, "DataTechnologies", "2026 Sprint 10")
|
||||
self.assertEqual(area, "Tools_Dev\\DataTechnologies")
|
||||
self.assertEqual(iteration, "Tools_Dev\\2026 Sprint\\2026 Sprint 10")
|
||||
|
||||
def test_resolve_leading_zero(self) -> None:
|
||||
_, iteration = QuickTeamApi.resolve_tfs_context(self.TEAMS, "FundHub", "2026 Sprint 6")
|
||||
self.assertTrue(iteration.endswith("2026 Sprint 06"))
|
||||
|
||||
def test_missing_team(self) -> None:
|
||||
with self.assertRaises(QtError):
|
||||
QuickTeamApi.resolve_tfs_context(self.TEAMS, "Nope", "2026 Sprint 10")
|
||||
|
||||
def test_missing_iteration(self) -> None:
|
||||
with self.assertRaises(QtError) as ctx:
|
||||
QuickTeamApi.resolve_tfs_context(self.TEAMS, "DataTechnologies", "2026 Sprint 99")
|
||||
self.assertEqual(ctx.exception.category, Category.TFS)
|
||||
|
||||
def test_push_payload(self) -> None:
|
||||
fake = FakeTransport()
|
||||
api = QuickTeamApi(fake)
|
||||
api.push_tfs([11, 22], "Tools_Dev", "Tools_Dev\\DataTechnologies", "Tools_Dev\\2026 Sprint\\2026 Sprint 10")
|
||||
_, operation, payload = fake.calls[0]
|
||||
self.assertEqual(operation, "BatchImportWorkItemsToTfs")
|
||||
inner = json.loads(urllib.parse.unquote(payload["param"]))
|
||||
self.assertEqual(inner["SPName"], "[QuickTeam].[usp_BatchImportWorkFeaturesByFeatureIds]")
|
||||
self.assertEqual(inner["SQLParams"][0]["Value"], "11,22")
|
||||
self.assertEqual(payload["areaPath"], "Tools_Dev\\DataTechnologies")
|
||||
|
||||
def test_push_without_features(self) -> None:
|
||||
with self.assertRaises(QtError):
|
||||
QuickTeamApi(FakeTransport()).push_tfs([], "p", "a", "i")
|
||||
|
||||
def test_push_returns_status(self) -> None:
|
||||
fake = FakeTransport()
|
||||
fake.tfs_result = 0
|
||||
self.assertEqual(QuickTeamApi(fake).push_tfs([1, 2], "p", "a", "i"), 0)
|
||||
|
||||
def test_push_negative_status_is_failure(self) -> None:
|
||||
"""返回值是状态码:0 = 成功,-1 = 失败(实测:项目名不存在时返回 -1)。"""
|
||||
fake = FakeTransport()
|
||||
fake.tfs_result = -1
|
||||
with self.assertRaises(QtError) as ctx:
|
||||
QuickTeamApi(fake).push_tfs([1], "p", "a", "i")
|
||||
self.assertEqual(ctx.exception.category, Category.TFS)
|
||||
|
||||
|
||||
class DryRunTest(unittest.TestCase):
|
||||
def test_no_server_calls(self) -> None:
|
||||
fake = FakeTransport()
|
||||
api = QuickTeamApi(fake, dry_run=True)
|
||||
self.assertEqual(api.create_worksheet(), 0)
|
||||
api.fill_worksheet(
|
||||
0, PROJECT, system_name="s", start_time="a", end_time="b", evaluator="e",
|
||||
technical_leader="t", customer_leader="c", belong_team="bt", content="ct", operator="op",
|
||||
)
|
||||
api.import_excel(0, 0, "p")
|
||||
api.verify_worksheet(0, 0, next_operator="x", next_version_code="y", operator="o", percent=20)
|
||||
api.push_tfs([1], "p", "a", "i")
|
||||
self.assertEqual(fake.calls, [], "dry-run 不允许发出任何服务端调用")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""domain 层规则测试:项目匹配、动作判定、版本推进、Excel 预检。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
from qtimport.domain import Action, decide, match_project, next_transition, precheck_xlsx
|
||||
from qtimport.models import Project, WorkSheet, normalize_iteration, normalize_name
|
||||
|
||||
|
||||
def ws(worksheet_id: int = 1, status: str = "00", code: str = "W_1") -> WorkSheet:
|
||||
return WorkSheet(worksheet_id=worksheet_id, code=code, status=status)
|
||||
|
||||
|
||||
class NormalizeTest(unittest.TestCase):
|
||||
def test_space_and_dash(self) -> None:
|
||||
self.assertEqual(normalize_name("资管 - 华泰资管"), normalize_name("资管-华泰资管"))
|
||||
self.assertEqual(normalize_name("信托 - 上海信托"), normalize_name("信托-上海信托"))
|
||||
|
||||
def test_iteration_leading_zero(self) -> None:
|
||||
self.assertEqual(normalize_iteration("2026 Sprint 06"), normalize_iteration("2026 Sprint 6"))
|
||||
self.assertNotEqual(normalize_iteration("2026 Sprint 10"), normalize_iteration("2026 Sprint 1"))
|
||||
|
||||
|
||||
class MatchProjectTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.projects = [
|
||||
Project("p1", "资管 - 华泰资管"),
|
||||
Project("p2", "资管 - 华泰保险资管"),
|
||||
Project("p3", "信托 - 上海信托"),
|
||||
]
|
||||
|
||||
def test_exact_near_names_do_not_collide(self) -> None:
|
||||
"""Selenium 用 contains,会把「华泰资管」误命中「华泰保险资管」;这里必须精确。"""
|
||||
project, reason = match_project("资管 - 华泰资管", self.projects)
|
||||
self.assertEqual(project.project_id, "p1")
|
||||
self.assertEqual(reason, "")
|
||||
|
||||
def test_missing_gives_hint(self) -> None:
|
||||
project, reason = match_project("资管 - 华泰", self.projects)
|
||||
self.assertIsNone(project)
|
||||
self.assertIn("相近项目", reason)
|
||||
|
||||
def test_ambiguous(self) -> None:
|
||||
projects = [Project("a", "X - Y"), Project("b", "X-Y")]
|
||||
project, reason = match_project("X - Y", projects)
|
||||
self.assertIsNone(project)
|
||||
self.assertIn("歧义", reason)
|
||||
|
||||
|
||||
class DecideTest(unittest.TestCase):
|
||||
def test_create_when_absent(self) -> None:
|
||||
action, _, _ = decide([], ledger_stage=None, adopt=False)
|
||||
self.assertEqual(action, Action.CREATE)
|
||||
|
||||
def test_skip_when_done(self) -> None:
|
||||
action, _, _ = decide([ws(status="03")], ledger_stage=None, adopt=False)
|
||||
self.assertEqual(action, Action.SKIP_DONE)
|
||||
|
||||
def test_skip_pushed(self) -> None:
|
||||
action, _, _ = decide([ws(status="01")], ledger_stage="TFS_PUSHED", adopt=False)
|
||||
self.assertEqual(action, Action.SKIP_PUSHED)
|
||||
|
||||
def test_resume_from_ledger(self) -> None:
|
||||
action, _, sheet = decide([ws()], ledger_stage="WS_FILLED", adopt=False)
|
||||
self.assertEqual(action, Action.RESUME)
|
||||
self.assertEqual(sheet.worksheet_id, 1)
|
||||
|
||||
def test_existing_without_ledger_skips(self) -> None:
|
||||
action, reason, _ = decide([ws()], ledger_stage=None, adopt=False)
|
||||
self.assertEqual(action, Action.SKIP_EXISTS)
|
||||
self.assertIn("未接管", reason)
|
||||
|
||||
def test_adopt_takes_over(self) -> None:
|
||||
action, _, _ = decide([ws()], ledger_stage=None, adopt=True)
|
||||
self.assertEqual(action, Action.RESUME)
|
||||
|
||||
def test_writes_flag(self) -> None:
|
||||
self.assertTrue(Action.CREATE.writes)
|
||||
self.assertTrue(Action.RESUME.writes)
|
||||
for action in (Action.SKIP_DONE, Action.SKIP_EXISTS, Action.SKIP_PUSHED, Action.ERROR):
|
||||
self.assertFalse(action.writes)
|
||||
|
||||
|
||||
class VersionTest(unittest.TestCase):
|
||||
def test_first_step(self) -> None:
|
||||
self.assertEqual(next_transition("技术责任人确认版"), ("CustomerLeader", "客户责任人确认版", 20))
|
||||
|
||||
def test_draft_like_first_step(self) -> None:
|
||||
self.assertEqual(next_transition(""), ("CustomerLeader", "客户责任人确认版", 20))
|
||||
|
||||
def test_second_step(self) -> None:
|
||||
self.assertEqual(next_transition("客户责任人确认版")[1], "甲方确认版")
|
||||
|
||||
def test_terminal_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
next_transition("甲方确认版")
|
||||
|
||||
def test_unknown_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
next_transition("甲方确认版-v2")
|
||||
|
||||
|
||||
class PrecheckTest(unittest.TestCase):
|
||||
def _make_xlsx(self, path: str, strings: list[str]) -> None:
|
||||
shared = "<sst>" + "".join(f"<si><t>{text}</t></si>" for text in strings) + "</sst>"
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("xl/workbook.xml", "<workbook/>")
|
||||
archive.writestr("xl/sharedStrings.xml", shared)
|
||||
|
||||
def test_valid_template(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = os.path.join(folder, "a.xlsx")
|
||||
self._make_xlsx(path, ["工作量评估表", "服务模块", "服务内容"])
|
||||
ok, why = precheck_xlsx(path)
|
||||
self.assertTrue(ok, why)
|
||||
|
||||
def test_missing_title(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = os.path.join(folder, "b.xlsx")
|
||||
self._make_xlsx(path, ["随便一个表", "服务模块"])
|
||||
ok, why = precheck_xlsx(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("工作量评估表", why)
|
||||
|
||||
def test_not_zip(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = os.path.join(folder, "c.xlsx")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write("not a zip")
|
||||
ok, why = precheck_xlsx(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("xlsx", why)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""台账测试:dry-run 隔离、跨 run 续跑、字段合并。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from qtimport.store import Store
|
||||
|
||||
|
||||
class StoreTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.store = Store(os.path.join(self.folder.name, "t.db"))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.store.close()
|
||||
self.folder.cleanup()
|
||||
|
||||
def _job(self, run_id: str, stage: str, worksheet_id: int | None = 1) -> None:
|
||||
self.store.upsert_job(
|
||||
run_id,
|
||||
project_id="p1",
|
||||
project_name="P",
|
||||
system_name="2026 Sprint 10",
|
||||
source_file="f.xlsx",
|
||||
action="create",
|
||||
stage=stage,
|
||||
worksheet_id=worksheet_id,
|
||||
)
|
||||
|
||||
def test_latest_stage_across_runs(self) -> None:
|
||||
self.store.start_run("r1", "s", "d", dry_run=False)
|
||||
self._job("r1", "WS_CREATED")
|
||||
self.store.start_run("r2", "s", "d", dry_run=False)
|
||||
self._job("r2", "WS_FILLED")
|
||||
self.assertEqual(self.store.latest_stage("p1", "2026 Sprint 10"), "WS_FILLED")
|
||||
|
||||
def test_dry_run_does_not_pollute_ledger(self) -> None:
|
||||
self.store.start_run("real", "s", "d", dry_run=False)
|
||||
self._job("real", "WS_FILLED")
|
||||
self.store.start_run("dry", "s", "d", dry_run=True)
|
||||
self._job("dry", "TFS_PUSHED")
|
||||
self.assertEqual(
|
||||
self.store.latest_stage("p1", "2026 Sprint 10"),
|
||||
"WS_FILLED",
|
||||
"dry-run 不能把项目标成已推送",
|
||||
)
|
||||
|
||||
def test_worksheet_id_survives_partial_update(self) -> None:
|
||||
self.store.start_run("r1", "s", "d", dry_run=False)
|
||||
self._job("r1", "WS_CREATED", worksheet_id=777)
|
||||
self._job("r1", "WS_FILLED", worksheet_id=None)
|
||||
job = self.store.latest_job("p1", "2026 Sprint 10")
|
||||
self.assertEqual(job["worksheet_id"], 777)
|
||||
self.assertEqual(job["stage"], "WS_FILLED")
|
||||
|
||||
def test_skipped_row_does_not_mask_progress(self) -> None:
|
||||
"""跳过行不带进度:不能把「已推送」覆盖成「SKIPPED」,否则下次会重推 TFS。"""
|
||||
self.store.start_run("r1", "s", "d", dry_run=False)
|
||||
self._job("r1", "TFS_PUSHED")
|
||||
self.store.start_run("r2", "s", "d", dry_run=False)
|
||||
self._job("r2", "SKIPPED")
|
||||
job = self.store.latest_job("p1", "2026 Sprint 10")
|
||||
self.assertEqual(job["stage"], "TFS_PUSHED")
|
||||
|
||||
def test_events_ordered(self) -> None:
|
||||
self.store.start_run("r1", "s", "d", dry_run=False)
|
||||
self.store.add_event("r1", stage="A", action="ok", detail="1")
|
||||
self.store.add_event("r1", stage="B", action="ok", detail="2")
|
||||
self.assertEqual([e["stage"] for e in self.store.events("r1")], ["A", "B"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""传输层与错误解析的单元测试(不联网)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from qtimport.errors import Category, QtError, extract_exception_message
|
||||
from qtimport.transport import Transport
|
||||
|
||||
|
||||
WCF_ERROR_PAGE = """\ufeff<?xml version="1.0" encoding="utf-8"?>
|
||||
<html><body><div id="content">
|
||||
<p class="heading1">Request Error</p>
|
||||
<p>The server encountered an error processing the request. The exception message is
|
||||
'Could not find stored procedure 'QuickTeam.usp_Nope'.'. See server logs for more details.</p>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
class DecodeTest(unittest.TestCase):
|
||||
def test_plain_json(self) -> None:
|
||||
self.assertEqual(Transport.decode('[{"A":1}]'), [{"A": 1}])
|
||||
|
||||
def test_double_encoded(self) -> None:
|
||||
raw = '"[{\\"WorkSheetId\\": 225072}]"'
|
||||
self.assertEqual(Transport.decode(raw)[0]["WorkSheetId"], 225072)
|
||||
|
||||
def test_triple_encoded(self) -> None:
|
||||
raw = '"\\"[{\\\\\\"A\\\\\\": 1}]\\""'
|
||||
self.assertEqual(Transport.decode(raw), [{"A": 1}])
|
||||
|
||||
def test_scalar_string(self) -> None:
|
||||
self.assertEqual(Transport.decode('"0"'), 0)
|
||||
|
||||
def test_not_json(self) -> None:
|
||||
self.assertEqual(Transport.decode("<html/>"), "<html/>")
|
||||
|
||||
|
||||
class ErrorTest(unittest.TestCase):
|
||||
def test_extract_message(self) -> None:
|
||||
message = extract_exception_message(WCF_ERROR_PAGE)
|
||||
self.assertIn("Could not find stored procedure", message)
|
||||
|
||||
def test_missing_procedure_not_retryable(self) -> None:
|
||||
error = QtError.from_http(400, WCF_ERROR_PAGE)
|
||||
self.assertEqual(error.category, Category.SP_MISSING)
|
||||
self.assertFalse(error.retryable)
|
||||
|
||||
def test_param_error(self) -> None:
|
||||
body = "<p>The exception message is 'Procedure or function usp_X has too many arguments "
|
||||
body += "specified.'. See server logs</p>"
|
||||
self.assertEqual(QtError.from_http(400, body).category, Category.PARAM)
|
||||
|
||||
def test_server_error_retryable(self) -> None:
|
||||
error = QtError.from_http(503, "<p>Service Unavailable</p>")
|
||||
self.assertTrue(error.retryable)
|
||||
|
||||
def test_html_unescaped(self) -> None:
|
||||
body = "<p>The exception message is 'Parameter 'UserId' is missing.'. See server logs</p>"
|
||||
self.assertIn("UserId", extract_exception_message(body))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""编排测试:阶段推进、断点续跑、失败归属与回滚(全部用 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()
|
||||
Reference in New Issue
Block a user