Files
flow-center-front/tests/components/defs-line-offline.component.test.ts
T
chenxing f2841e2740
build / build (push) Successful in 1m33s
release: FL 2.0 前端候选(流程线停发/发起页指派预览/测试夹具)
配套 service 候选 500a9a2:
- flow.ts 增加 admin/def/offlineLine、enableLine(T03 线级停发/恢复)
- Defs.vue 接线级停发开关与状态展示;lineOffline.ts 收敛判定与文案
- AssignPreview.vue + assignEmbed.ts 发起时指派预览(T02 契约)
- def-editor 表单坐标绑定微调
- 新增 vitest 组件测试与 line-offline 契约测试(npm test 可跑)
2026-10-27 12:07:40 +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");
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");
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();
});
});