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(value: T) { return { send: async () => value }; } function makeNode(over: Partial = {}): ApprovalNode { return { ...createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" }), ...over } as ApprovalNode; } function baseCtx(over: Record = {}) { 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 = {}): 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); }); });