import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils"; import ViewUIPlus, { Message } from "view-ui-plus"; import { nextTick } from "vue"; const { flowApiMock } = vi.hoisted(() => ({ flowApiMock: { defList: vi.fn(), defInflight: vi.fn(), appList: vi.fn(), defOfflineLine: vi.fn(), defEnableLine: vi.fn(), defPublish: vi.fn(), defOffline: vi.fn(), defEnable: vi.fn(), defUpdateMeta: vi.fn(), defVersions: vi.fn(), }, })); vi.mock("@/api/module/flow", () => ({ flowApi: flowApiMock })); vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn() }) })); vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() })); vi.mock("@/views/flow/components/SourceDrawer.vue", () => ({ default: { template: "
" } })); import MyConfirm from "@/components/MyConfirm"; import Defs from "@/views/flow/admin/Defs.vue"; /** 组件级测试:真实挂载 Defs.vue(view-ui-plus Table/操作列),只 mock HTTP 客户端、路由与确认框。 */ const confirmMock = MyConfirm as unknown as Mock; enableAutoUnmount(afterEach); function ok(value: T) { return { send: async () => value }; } function deferred() { let resolve!: (v: T) => void; let reject!: (e: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function baseRows() { return [ { id: 1, flowGuid: "g-normal", name: "正常线", code: "c1", version: 1, status: 1, lineOffline: 0, hasPublished: true, publishTime: "2026-10-20T10:00:00", }, { id: 2, flowGuid: "g-off", name: "停发线", code: "c2", version: 1, status: 1, lineOffline: 1, hasPublished: true, publishTime: "2026-10-20T10:00:00", }, { id: 3, flowGuid: "", name: "历史脏行", code: "c3", version: 1, status: 0, lineOffline: 0, hasPublished: false, publishTime: null, }, { id: 4, flowGuid: "g-version-off", name: "版本停用线", code: "c4", version: 2, status: 2, lineOffline: 0, hasPublished: false, publishTime: "2026-10-19T10:00:00", }, ]; } function offlineRows() { return baseRows().map((r) => (r.flowGuid === "g-normal" ? { ...r, lineOffline: 1 } : r)); } async function mountDefs() { flowApiMock.defList.mockImplementation(() => ok(baseRows())); flowApiMock.defInflight.mockImplementation(() => ok({ "g-normal": 2 })); flowApiMock.appList.mockImplementation(() => ok([])); const wrapper = mount(Defs, { global: { plugins: [ViewUIPlus] }, attachTo: document.body, }); await flushPromises(); return wrapper; } function rowsNamed(wrapper: VueWrapper, name: string) { return wrapper.findAll("tr.ivu-table-row").filter((r) => r.text().includes(name)); } function lineLink(wrapper: VueWrapper, name: string, label: string) { for (const row of rowsNamed(wrapper, name)) { const link = row.findAll(".operating-link").find((l) => l.text() === label); if (link) return link; } return undefined; } function lastConfirmOptions(): any { expect(confirmMock).toHaveBeenCalled(); return confirmMock.mock.calls[confirmMock.mock.calls.length - 1][0]; } let successSpy: Mock; let warningSpy: Mock; let errorSpy: Mock; let infoSpy: Mock; beforeEach(() => { vi.clearAllMocks(); successSpy = vi.spyOn(Message, "success").mockImplementation((() => {}) as any); warningSpy = vi.spyOn(Message, "warning").mockImplementation((() => {}) as any); errorSpy = vi.spyOn(Message, "error").mockImplementation((() => {}) as any); infoSpy = vi.spyOn(Message, "info").mockImplementation((() => {}) as any); }); afterEach(() => { document.body.innerHTML = ""; vi.restoreAllMocks(); }); describe("Defs 流程线状态展示(分组视图)", () => { it("线状态与版本状态独立展示,入口随线状态切换", async () => { const wrapper = await mountDefs(); expect(wrapper.text()).toContain("线状态"); const normal = rowsNamed(wrapper, "正常线")[0]?.text() || ""; expect(normal).toContain("正常"); expect(normal).toContain("已发布"); expect(normal).toContain("停发流程线"); expect(normal).not.toContain("恢复流程线"); const offline = rowsNamed(wrapper, "停发线")[0]?.text() || ""; expect(offline).toContain("已停发"); expect(offline).toContain("已发布"); expect(offline).toContain("恢复流程线"); expect(offline).not.toContain("停发流程线"); // 版本已停用但线正常:线状态不得被版本 status 带偏 const versionOff = rowsNamed(wrapper, "版本停用线")[0]?.text() || ""; expect(versionOff).toContain("正常"); expect(versionOff).toContain("停用"); expect(versionOff).toContain("停发流程线"); }); it("空 flowGuid 历史行显示 —,不出现任何线级操作入口", async () => { const wrapper = await mountDefs(); const rows = rowsNamed(wrapper, "历史脏行"); expect(rows.length).toBeGreaterThan(0); for (const row of rows) { expect(row.text()).toContain("—"); expect(row.text()).not.toContain("停发流程线"); expect(row.text()).not.toContain("恢复流程线"); } expect(lineLink(wrapper, "历史脏行", "停发流程线")).toBeUndefined(); expect(lineLink(wrapper, "历史脏行", "恢复流程线")).toBeUndefined(); }); }); describe("Defs 流程线状态展示(平铺视图)", () => { it("每个版本行展示共享线状态,线级入口与版本级停用并存", async () => { const wrapper = await mountDefs(); const radios = wrapper.findAll("input[type=radio]"); expect(radios.length).toBeGreaterThan(1); await radios[1].setValue(true); await flushPromises(); expect(wrapper.find(".fix").text()).toContain("平铺所有版本行"); expect(rowsNamed(wrapper, "正常线")[0]?.text()).toContain("正常"); expect(rowsNamed(wrapper, "停发线")[0]?.text()).toContain("已停发"); const versionOff = rowsNamed(wrapper, "版本停用线")[0]?.text() || ""; expect(versionOff).toContain("正常"); expect(versionOff).toContain("停用"); // 平铺视图长操作集进入「更多」,菜单内容仍由同一 lineActions 过滤逻辑产生 const menuItems = Array.from(document.querySelectorAll(".ivu-dropdown-item")) .map((e) => e.textContent || ""); expect(menuItems).toContain("停发流程线"); expect(menuItems).toContain("恢复流程线"); expect(menuItems).toContain("停用"); }); }); describe("Defs 流程线停发/恢复交互", () => { it("停发:确认文案含在途数,请求期间显示处理中并禁用入口,成功后刷新列表与在途", async () => { flowApiMock.defList .mockImplementationOnce(() => ok(baseRows())) .mockImplementationOnce(() => ok(offlineRows())); const wrapper = await mountDefs(); const gate = deferred(); flowApiMock.defOfflineLine.mockReturnValue({ send: () => gate.promise }); await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click"); const opts = lastConfirmOptions(); expect(opts.title).toBe("停发流程线"); expect(opts.msg).toContain("正常线"); expect(opts.info).toContain("在途办件不受影响"); expect(opts.info).toContain("当前在途 2 件"); const pending = opts.onOk(); await nextTick(); await flushPromises(); expect(wrapper.text()).toContain("处理中…"); expect(lineLink(wrapper, "正常线", "停发流程线")!.classes()).toContain("disabled"); expect(flowApiMock.defOfflineLine).toHaveBeenCalledWith("g-normal"); gate.resolve(2); await pending; await flushPromises(); expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已停发(2 个版本")); expect(flowApiMock.defList).toHaveBeenCalledTimes(2); expect(flowApiMock.defInflight).toHaveBeenCalledTimes(2); expect(wrapper.text()).toContain("已停发"); expect(lineLink(wrapper, "正常线", "恢复流程线")).toBeTruthy(); expect(wrapper.text()).not.toContain("处理中…"); }); it("在途期间重复确认/重复点击不会产生第二次请求", async () => { const wrapper = await mountDefs(); const gate = deferred(); flowApiMock.defOfflineLine.mockReturnValue({ send: () => gate.promise }); await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click"); const opts = lastConfirmOptions(); const first = opts.onOk(); await nextTick(); // 入口已置灰(点击回调不再触发);再次确认也走 busy 守卫 const disabledLink = lineLink(wrapper, "正常线", "停发流程线")!; expect(disabledLink.classes()).toContain("disabled"); await disabledLink.trigger("click"); expect(confirmMock).toHaveBeenCalledTimes(1); await opts.onOk(); expect(flowApiMock.defOfflineLine).toHaveBeenCalledTimes(1); expect(infoSpy).toHaveBeenCalledWith("该流程线操作正在处理中,请稍候"); gate.resolve(1); await first; await flushPromises(); }); it("恢复:已停发线只调 enableLine,成功后刷新并切换回停发入口", async () => { flowApiMock.defList .mockImplementationOnce(() => ok(baseRows())) .mockImplementationOnce(() => ok(baseRows())); const wrapper = await mountDefs(); const gate = deferred(); flowApiMock.defEnableLine.mockReturnValue({ send: () => gate.promise }); await lineLink(wrapper, "停发线", "恢复流程线")!.trigger("click"); const opts = lastConfirmOptions(); expect(opts.title).toBe("恢复流程线"); expect(opts.info).toContain("仅恢复流程线可用性"); const pending = opts.onOk(); await nextTick(); expect(wrapper.text()).toContain("处理中…"); expect(lineLink(wrapper, "停发线", "恢复流程线")!.classes()).toContain("disabled"); gate.resolve(1); await pending; await flushPromises(); expect(flowApiMock.defEnableLine).toHaveBeenCalledWith("g-off"); expect(flowApiMock.defOfflineLine).not.toHaveBeenCalled(); expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已恢复(1 个版本")); expect(flowApiMock.defList).toHaveBeenCalledTimes(2); }); it("接口失败/无权限/网络异常:明确报错,不刷新,不误报成功", async () => { const cases = [ { error: "无流程管理权限", expected: /无流程管理权限/ }, { error: "网络错误", expected: /网络异常/ }, ]; for (const c of cases) { vi.clearAllMocks(); const wrapper = await mountDefs(); flowApiMock.defOfflineLine.mockReturnValue({ send: () => Promise.reject(new Error(c.error)), }); await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click"); await lastConfirmOptions().onOk(); await flushPromises(); expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(c.expected)); expect(successSpy).not.toHaveBeenCalled(); expect(flowApiMock.defList).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain("正常"); wrapper.unmount(); } }); it("接口返回 0 行:提示状态未更新并要求刷新确认", async () => { const wrapper = await mountDefs(); flowApiMock.defOfflineLine.mockReturnValue(ok(0)); await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click"); await lastConfirmOptions().onOk(); await flushPromises(); expect(warningSpy).toHaveBeenCalledWith("未更新任何版本,流程线状态可能已变化,请刷新后确认"); expect(successSpy).not.toHaveBeenCalled(); expect(flowApiMock.defList).toHaveBeenCalledTimes(2); }); it("操作成功但列表刷新失败:保留成功结果并明确提示刷新失败", async () => { flowApiMock.defList .mockImplementationOnce(() => ok(baseRows())) .mockImplementationOnce(() => ({ send: async () => { throw new Error("网络异常"); } })); const wrapper = await mountDefs(); flowApiMock.defOfflineLine.mockReturnValue(ok(1)); await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click"); await lastConfirmOptions().onOk(); await flushPromises(); expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已停发(1 个版本")); expect(warningSpy).toHaveBeenCalledWith("操作已提交,但列表刷新失败,请手动刷新后确认最新状态"); expect(errorSpy).not.toHaveBeenCalled(); }); });