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