Files
flow-center-front/tests/components/defs-line-offline.component.test.ts
T
chenxing c93313cdb7
build / build (push) Successful in 1m28s
feat(flow): 流程图入口/失败提示/指派预览/线级停发闭环/配置分层/选人宿主
- 修复办理页「查看流程图」双 hash URL:改 router.resolve + toAppHashUrl 单 hash
- 修复管理端办件抽屉「查看大图」域名尾点(origin + BASE_URL("./") 拼接)
- 新增 flowError 口径层与办理页内联失败条,审批失败提示可读、不泄漏堆栈
- 审批节点抽屉新增「预览候选人」真入口 + AssigneePreviewModal(只读,不写回定义)
- 线级停发/恢复失败改为单条错误面,避免同错双 toast
- 审批抽屉核心区/高级设置分层;插入菜单常用/高级分组
- 新增 FlowAssignEmbed 选人 iframe 父侧宿主(origin/source/信封/context 四校验)
- 修复 /diagram、/assign-preview 面包屑误显示「流程编辑器」
2026-10-28 03:18:54 +08:00

327 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: "<div />" } }));
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<T>(value: T) {
return { send: async () => value };
}
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((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<number>();
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", { silentBizToast: true });
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<number>();
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<number>();
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", { silentBizToast: true });
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();
});
});