build / build (push) Successful in 1m28s
- 修复办理页「查看流程图」双 hash URL:改 router.resolve + toAppHashUrl 单 hash
- 修复管理端办件抽屉「查看大图」域名尾点(origin + BASE_URL("./") 拼接)
- 新增 flowError 口径层与办理页内联失败条,审批失败提示可读、不泄漏堆栈
- 审批节点抽屉新增「预览候选人」真入口 + AssigneePreviewModal(只读,不写回定义)
- 线级停发/恢复失败改为单条错误面,避免同错双 toast
- 审批抽屉核心区/高级设置分层;插入菜单常用/高级分组
- 新增 FlowAssignEmbed 选人 iframe 父侧宿主(origin/source/信封/context 四校验)
- 修复 /diagram、/assign-preview 面包屑误显示「流程编辑器」
232 lines
9.2 KiB
TypeScript
232 lines
9.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
|
|
import ViewUIPlus from "view-ui-plus";
|
|
import { nextTick, reactive } from "vue";
|
|
|
|
const { flowApiMock } = vi.hoisted(() => ({
|
|
flowApiMock: {
|
|
adminTeacherTree: vi.fn(),
|
|
deptMembers: vi.fn(),
|
|
mapLookupUser: vi.fn(),
|
|
applyPreview: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/api/module/flow", () => ({ flowApi: flowApiMock }));
|
|
|
|
import ApprovalDrawer from "@/views/flow/admin/def-editor/components/ApprovalDrawer.vue";
|
|
import AssigneePreviewModal from "@/views/flow/admin/def-editor/components/AssigneePreviewModal.vue";
|
|
import { EDITOR_CTX, createApproval, type ApprovalNode, type EditorItem } from "@/views/flow/admin/def-editor/model";
|
|
|
|
/**
|
|
* 审批节点抽屉的组件测试:验收 P2-1 的两条硬指标——
|
|
* ① 面板里有可发现的「预览候选人」入口,点开是真接口解析结果;
|
|
* ② 核心/高级分层只挪展示,不改字段与数据语义。
|
|
* 注:iView Drawer/Modal 内容 teleport 到 body,一律按真实 DOM 断言。
|
|
*/
|
|
|
|
enableAutoUnmount(afterEach);
|
|
|
|
function ok<T>(value: T) {
|
|
return { send: async () => value };
|
|
}
|
|
|
|
function makeNode(over: Partial<ApprovalNode> = {}): ApprovalNode {
|
|
return { ...createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" }), ...over } as ApprovalNode;
|
|
}
|
|
|
|
function baseCtx(over: Record<string, unknown> = {}) {
|
|
const model = reactive({
|
|
code: "c",
|
|
name: "n",
|
|
list: [] as EditorItem[],
|
|
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
|
|
});
|
|
return {
|
|
model,
|
|
depts: [
|
|
{ deptKey: "41", deptName: "校长室", count: 2 },
|
|
{ deptKey: "42", deptName: "空科室", count: 0 },
|
|
],
|
|
userNames: { 101: "陈星", 102: "刘文娟" },
|
|
routers: [],
|
|
availableVars: [],
|
|
issueOf: () => [],
|
|
openNode: () => undefined,
|
|
removeNode: () => undefined,
|
|
insertItem: () => undefined,
|
|
addBranch: () => undefined,
|
|
removeBranch: () => undefined,
|
|
exitOutlets: () => undefined,
|
|
flowGuid: "g-line-1",
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function mountDrawer(node: ApprovalNode, ctxOver: Record<string, unknown> = {}): VueWrapper {
|
|
return mount(ApprovalDrawer, {
|
|
props: { modelValue: true, node },
|
|
global: {
|
|
plugins: [ViewUIPlus],
|
|
provide: { [EDITOR_CTX as unknown as symbol]: baseCtx(ctxOver) },
|
|
},
|
|
attachTo: document.body,
|
|
});
|
|
}
|
|
|
|
const bodyText = () => document.body.textContent || "";
|
|
const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
|
|
const hiddenByStyle = (el: HTMLElement | null) => (el?.getAttribute("style") || "").includes("display: none");
|
|
|
|
async function clickEl(el: HTMLElement | null) {
|
|
expect(el, "目标元素不存在").toBeTruthy();
|
|
el!.click();
|
|
await nextTick();
|
|
await flushPromises();
|
|
}
|
|
|
|
function buttonNamed(text: string) {
|
|
return (Array.from(document.querySelectorAll("button")) as HTMLButtonElement[])
|
|
.find((b) => (b.textContent || "").includes(text)) || null;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
document.body.innerHTML = "";
|
|
flowApiMock.adminTeacherTree.mockImplementation(() => ok([]));
|
|
flowApiMock.deptMembers.mockImplementation((deptId: string) =>
|
|
ok(deptId === "41"
|
|
? [
|
|
{ userId: 101, userName: "陈星", status: 0 },
|
|
{ userId: 102, userName: "刘文娟", status: 0 },
|
|
{ userId: 103, userName: "测试教师10", status: 1 },
|
|
]
|
|
: []),
|
|
);
|
|
flowApiMock.mapLookupUser.mockImplementation((id: number) => ok({ id, name: `用户${id}`, status: 1 }));
|
|
flowApiMock.applyPreview.mockImplementation(() => ok({ defVersion: 3, nodes: [] }));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("审批节点抽屉 · 核心/高级分层", () => {
|
|
it("核心区直接可见:节点名称、审批人、预览入口;高级项默认收起", async () => {
|
|
mountDrawer(makeNode());
|
|
await flushPromises();
|
|
const text = bodyText();
|
|
expect(text).toContain("节点名称");
|
|
expect(text).toContain("审批人类型");
|
|
expect(text).toContain("科室映射");
|
|
expect(text).toContain("预览候选人");
|
|
expect(text).toContain("高级设置");
|
|
// 收起只是隐藏,不删除能力
|
|
expect(hiddenByStyle(q(".adv-body"))).toBe(true);
|
|
});
|
|
|
|
it("高级能力一个都没少:展开后会签/发起时指派/时限/多出口都在原位置", async () => {
|
|
mountDrawer(makeNode());
|
|
await flushPromises();
|
|
await clickEl(q(".adv-head"));
|
|
const adv = q(".adv-body");
|
|
expect(hiddenByStyle(adv)).toBe(false);
|
|
const text = adv?.textContent || "";
|
|
expect(text).toContain("多人审批方式");
|
|
expect(text).toContain("发起时指派");
|
|
expect(text).toContain("办理时限(小时)");
|
|
expect(text).toContain("出口路由");
|
|
});
|
|
|
|
it("已配置高级项的节点:打开抽屉自动展开,收起态也标出配了哪几项", async () => {
|
|
mountDrawer(makeNode({ countersign: "ALL", deadlineHours: 24 }));
|
|
await flushPromises();
|
|
expect(hiddenByStyle(q(".adv-body"))).toBe(false);
|
|
|
|
await clickEl(q(".adv-head"));
|
|
expect(q(".adv-badge")?.textContent).toContain("会签");
|
|
expect(q(".adv-badge")?.textContent).toContain("办理时限");
|
|
});
|
|
|
|
it("分层不改数据:折叠/展开来回,节点字段值不变", async () => {
|
|
const node = makeNode({ countersign: "ALL", deadlineHours: 8, outlets: ["同意", "退回"] });
|
|
const snapshot = JSON.stringify(node);
|
|
mountDrawer(node);
|
|
await flushPromises();
|
|
await clickEl(q(".adv-head"));
|
|
await clickEl(q(".adv-head"));
|
|
expect(JSON.stringify(node)).toEqual(snapshot);
|
|
});
|
|
});
|
|
|
|
describe("审批节点抽屉 · 预览候选人入口", () => {
|
|
async function openPreview() {
|
|
await clickEl(buttonNamed("预览候选人"));
|
|
}
|
|
|
|
it("点入口即按当前节点配置走真实接口(科室成员 + 发起预览),不是静态示例", async () => {
|
|
mountDrawer(makeNode());
|
|
await flushPromises();
|
|
await openPreview();
|
|
expect(flowApiMock.deptMembers).toHaveBeenCalledWith("41");
|
|
expect(flowApiMock.applyPreview).toHaveBeenCalledWith("g-line-1");
|
|
const text = bodyText();
|
|
expect(text).toContain("校长室");
|
|
expect(text).toContain("陈星");
|
|
expect(text).toContain("刘文娟");
|
|
expect(text).toContain("该成员已停用,不计入候选");
|
|
expect(text).toContain("预览只读");
|
|
});
|
|
|
|
it("预览读的是抽屉里的未保存改动(换科室后立刻按新科室解析)", async () => {
|
|
mountDrawer(makeNode({ assignee: { type: "DEPT_KEY", deptKey: "42" } }));
|
|
await flushPromises();
|
|
await openPreview();
|
|
expect(flowApiMock.deptMembers).toHaveBeenCalledWith("42");
|
|
const text = bodyText();
|
|
expect(text).toContain("0 名启用办理人");
|
|
expect(text).toContain("发布会被拦截");
|
|
});
|
|
|
|
it("指定用户节点按 UserBase 检索解析,非启用账号如实标注", async () => {
|
|
flowApiMock.mapLookupUser.mockImplementation((id: number) =>
|
|
ok(id === 101 ? { id: 101, name: "陈星", status: 1 } : { id, name: `用户${id}`, status: 0 }),
|
|
);
|
|
mountDrawer(makeNode({ assignee: { type: "USERS", userIds: [101, 102] } }));
|
|
await flushPromises();
|
|
await openPreview();
|
|
expect(flowApiMock.mapLookupUser).toHaveBeenCalledWith(101);
|
|
expect(flowApiMock.mapLookupUser).toHaveBeenCalledWith(102);
|
|
const text = bodyText();
|
|
expect(text).toContain("陈星");
|
|
expect(text).toContain("非启用");
|
|
});
|
|
|
|
it("流程线尚未保存时说明没有可比对的已发布版本,不假装有一版", async () => {
|
|
mountDrawer(makeNode(), { flowGuid: "" });
|
|
await flushPromises();
|
|
await openPreview();
|
|
expect(flowApiMock.applyPreview).not.toHaveBeenCalled();
|
|
expect(bodyText()).toContain("尚未保存");
|
|
});
|
|
|
|
it("预览只读:打开再关闭不写回节点、不产生任何接口写入", async () => {
|
|
const node = makeNode();
|
|
const before = JSON.stringify(node);
|
|
const wrapper = mountDrawer(node);
|
|
await flushPromises();
|
|
await openPreview();
|
|
await clickEl(buttonNamed("关闭"));
|
|
expect(JSON.stringify(node)).toEqual(before);
|
|
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
|
|
// 预览的数据源只有读接口:科室成员 / 用户检索 / 发起预览
|
|
expect(flowApiMock.deptMembers).toHaveBeenCalled();
|
|
expect(flowApiMock.applyPreview).toHaveBeenCalled();
|
|
});
|
|
|
|
it("预览弹窗组件确实挂在抽屉内(入口不是死链)", async () => {
|
|
const wrapper = mountDrawer(makeNode());
|
|
await flushPromises();
|
|
expect(wrapper.findComponent(AssigneePreviewModal).exists()).toBe(true);
|
|
});
|
|
});
|