build / build (push) Successful in 1m33s
配套 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 可跑)
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
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();
|
||
});
|
||
});
|