"""编排测试:阶段推进、断点续跑、失败归属与回滚(全部用 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()