- 修复办理页「查看流程图」双 hash URL:改 router.resolve + toAppHashUrl 单 hash
- 修复管理端办件抽屉「查看大图」域名尾点(origin + BASE_URL("./") 拼接)
- 新增 flowError 口径层与办理页内联失败条,审批失败提示可读、不泄漏堆栈
- 审批节点抽屉新增「预览候选人」真入口 + AssigneePreviewModal(只读,不写回定义)
- 线级停发/恢复失败改为单条错误面,避免同错双 toast
- 审批抽屉核心区/高级设置分层;插入菜单常用/高级分组
- 新增 FlowAssignEmbed 选人 iframe 父侧宿主(origin/source/信封/context 四校验)
- 修复 /diagram、/assign-preview 面包屑误显示「流程编辑器」
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { toAppHashUrl } from "../src/utils/appUrl";
|
||||
|
||||
/** 整页跳转地址单测:只锁「最终只有一个 hash」这条线上缺陷口径,不替代浏览器实测 */
|
||||
|
||||
test("办理页入口:resolve 出的 #/diagram 不再追加到当前 hash 之后", () => {
|
||||
const out = toAppHashUrl(
|
||||
"#/diagram?instanceId=107&mode=user",
|
||||
"https://base.yhxt.com.cn/flow-center/front/index.html#/task/339",
|
||||
);
|
||||
assert.equal(out, "https://base.yhxt.com.cn/flow-center/front/index.html#/diagram?instanceId=107&mode=user");
|
||||
assert.equal(out.split("#").length - 1, 1);
|
||||
});
|
||||
|
||||
test("参数原样保留:instanceId 与 mode 不因去 hash 而丢失", () => {
|
||||
const out = toAppHashUrl("#/diagram?instanceId=107&mode=dept", "https://h/flow-center/front/index.html#/task/339");
|
||||
assert.match(out, /instanceId=107/);
|
||||
assert.match(out, /mode=dept/);
|
||||
});
|
||||
|
||||
test("无 hash 的入口地址:直接接上目标 hash", () => {
|
||||
assert.equal(
|
||||
toAppHashUrl("#/diagram?instanceId=1", "https://h/flow-center/front/index.html"),
|
||||
"https://h/flow-center/front/index.html#/diagram?instanceId=1",
|
||||
);
|
||||
});
|
||||
|
||||
test("真实 query 保留在 hash 之前(部署带 ?v= 缓存位时不被吞)", () => {
|
||||
assert.equal(
|
||||
toAppHashUrl("#/diagram?instanceId=1", "https://h/index.html?v=9#/task/339"),
|
||||
"https://h/index.html?v=9#/diagram?instanceId=1",
|
||||
);
|
||||
});
|
||||
|
||||
test("不带前导 # 的 resolve 结果同样归一,且不出现双 hash", () => {
|
||||
const out = toAppHashUrl("/diagram?instanceId=1", "https://h/index.html#/task/339");
|
||||
assert.equal(out, "https://h/index.html#/diagram?instanceId=1");
|
||||
});
|
||||
|
||||
test("查看大图:不再用 origin+BASE_URL 拼出 host 尾点地址", () => {
|
||||
// 旧写法 origin + "./" + "#/diagram?…" 会得到 https://base.yhxt.com.cn./#/diagram?…
|
||||
const legacy = `${"https://base.yhxt.com.cn"}${"./"}#/diagram?instanceId=1`;
|
||||
assert.match(legacy, /com\.cn\.\//);
|
||||
const fixed = toAppHashUrl("#/diagram?instanceId=1", "https://base.yhxt.com.cn/flow-center/front/index.html#/admin/instances");
|
||||
assert.ok(!/\.cn\.\//.test(fixed), fixed);
|
||||
assert.equal(fixed, "https://base.yhxt.com.cn/flow-center/front/index.html#/diagram?instanceId=1");
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveNodePreview } from "../src/views/flow/admin/def-editor/assignPreview";
|
||||
import type { PreviewApi, PreviewDept, PreviewNodeConfig } from "../src/views/flow/admin/def-editor/assignPreview";
|
||||
|
||||
/**
|
||||
* 编辑器指派预览解析单测:断言的是「用什么接口、给什么结论、失败怎么讲」。
|
||||
* 数据用桩,不代表真实科室数据与线上服务已验证。
|
||||
*/
|
||||
|
||||
const depts: PreviewDept[] = [
|
||||
{ deptKey: "41", deptName: "校长室", count: 2 },
|
||||
{ deptKey: "42", deptName: "空科室", count: 0 },
|
||||
];
|
||||
|
||||
function cfg(over: Partial<PreviewNodeConfig> = {}): PreviewNodeConfig {
|
||||
return {
|
||||
uid: "u3",
|
||||
name: "科室审核",
|
||||
type: "DEPT_KEY",
|
||||
deptKey: "41",
|
||||
userIds: [],
|
||||
countersign: "ANY",
|
||||
assignPolicy: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function stubApi(over: Partial<PreviewApi> = {}): PreviewApi & { calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
deptMembers: async (deptId) => {
|
||||
calls.push(`deptMembers:${deptId}`);
|
||||
if (deptId === "41") {
|
||||
return [
|
||||
{ userId: 101, userName: "陈星", status: 0 },
|
||||
{ userId: 102, userName: "刘文娟", status: 0 },
|
||||
{ userId: 103, userName: "测试教师10", status: 1 },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
lookupUser: async (userId) => {
|
||||
calls.push(`lookupUser:${userId}`);
|
||||
if (userId === 101) return { id: 101, name: "陈星", status: 1 };
|
||||
if (userId === 999) return null;
|
||||
return { id: userId, name: `用户${userId}`, status: 0 };
|
||||
},
|
||||
applyPreview: async (flowGuid) => {
|
||||
calls.push(`applyPreview:${flowGuid}`);
|
||||
return { flowGuid, defVersion: 3, nodes: [] };
|
||||
},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test("科室映射节点:读既有科室成员接口,启用成员进候选、停用成员标注不计入", async () => {
|
||||
const api = stubApi();
|
||||
const out = await resolveNodePreview(cfg(), depts, api);
|
||||
assert.equal(out.ok, true);
|
||||
assert.ok(out.headline.includes("校长室"));
|
||||
assert.ok(out.headline.includes("2 名启用办理人"));
|
||||
assert.deepEqual(api.calls.filter((c) => c.startsWith("deptMembers")), ["deptMembers:41"]);
|
||||
assert.equal(out.rows.length, 3);
|
||||
assert.deepEqual(out.rows.map((r) => r.userName), ["陈星", "刘文娟", "测试教师10"]);
|
||||
assert.equal(out.rows[2].warn, "该成员已停用,不计入候选");
|
||||
assert.ok(out.notes.some((n) => n.includes("预览只读")));
|
||||
});
|
||||
|
||||
test("未选科室=明确的不可预览原因,而不是空列表里什么都不说", async () => {
|
||||
const out = await resolveNodePreview(cfg({ deptKey: "" }), depts, stubApi());
|
||||
assert.equal(out.ok, false);
|
||||
assert.equal(out.blocked, "尚未选择科室映射,请先在「科室映射」里选一个科室。");
|
||||
});
|
||||
|
||||
test("科室已被删除:点名到映射键并说明发布会被拦截", async () => {
|
||||
const out = await resolveNodePreview(cfg({ deptKey: "99" }), depts, stubApi());
|
||||
assert.equal(out.ok, false);
|
||||
assert.ok(out.blocked?.includes("「99」"));
|
||||
assert.ok(out.blocked?.includes("发布会被拦截"));
|
||||
});
|
||||
|
||||
test("科室 0 名启用成员:给出补人路径,不误报成功", async () => {
|
||||
const out = await resolveNodePreview(cfg({ deptKey: "42" }), depts, stubApi());
|
||||
assert.equal(out.ok, false);
|
||||
assert.ok(out.blocked?.includes("0 名启用办理人"));
|
||||
assert.ok(out.blocked?.includes("办理人映射"));
|
||||
});
|
||||
|
||||
test("指定用户节点:逐个走 UserBase 检索,禁用账号标注服务端会拒绝", async () => {
|
||||
const api = stubApi();
|
||||
const out = await resolveNodePreview(
|
||||
cfg({ type: "USERS", deptKey: "", userIds: [101, 102, 999] }),
|
||||
depts,
|
||||
api,
|
||||
);
|
||||
assert.equal(out.ok, true);
|
||||
assert.deepEqual(out.rows.map((r) => r.userName), ["陈星", "用户102", "#999"]);
|
||||
assert.ok(out.rows[1].warn?.includes("非启用"));
|
||||
assert.ok(out.rows[2].warn?.includes("UserBase 查不到"));
|
||||
});
|
||||
|
||||
test("指定用户检索失败(服务不可达):原因可读,堆栈类原文不外泄", async () => {
|
||||
const api = stubApi({
|
||||
lookupUser: async () => {
|
||||
throw new Error("java.lang.RuntimeException: connect timed out at com.yunhai.UserClient");
|
||||
},
|
||||
});
|
||||
const out = await resolveNodePreview(cfg({ type: "USERS", deptKey: "", userIds: [101] }), depts, api);
|
||||
assert.equal(out.ok, false);
|
||||
assert.ok(out.blocked?.includes("发布会被拦截"));
|
||||
assert.ok(!out.rows[0].warn?.includes("java."));
|
||||
assert.ok(out.rows[0].warn?.includes("服务未能处理本次请求"));
|
||||
});
|
||||
|
||||
test("新建未保存线:已发布版本对账说明「尚未保存」,不假装有一版可比", async () => {
|
||||
const out = await resolveNodePreview(cfg(), depts, stubApi(), "");
|
||||
assert.equal(out.published?.state, "unavailable");
|
||||
assert.ok(out.published?.text.includes("尚未保存"));
|
||||
});
|
||||
|
||||
test("草稿节点不在已发布预览中:区分「未开启指派」与「尚未发布」", async () => {
|
||||
const out = await resolveNodePreview(cfg(), depts, stubApi(), "g1");
|
||||
assert.equal(out.published?.state, "missing");
|
||||
assert.ok(out.published?.text.includes("v3"));
|
||||
assert.ok(out.published?.text.includes("未开启发起时指派"));
|
||||
});
|
||||
|
||||
test("已发布版本有本节点:候选人按服务端解析结果展示(含提单人剔除后的集)", async () => {
|
||||
const api = stubApi({
|
||||
applyPreview: async () => ({
|
||||
defVersion: 4,
|
||||
nodes: [{ nodeId: "u3", nodeName: "科室审核", candidates: [{ userId: 102, userName: "刘文娟" }] }],
|
||||
}),
|
||||
});
|
||||
const out = await resolveNodePreview(cfg(), depts, api, "g1");
|
||||
assert.equal(out.published?.state, "ok");
|
||||
assert.equal(out.published?.defVersion, 4);
|
||||
assert.deepEqual(out.published?.rows.map((r) => r.userName), ["刘文娟"]);
|
||||
});
|
||||
|
||||
test("已发布预览被服务端拒绝(如流程线已停发):把拒绝原因原样讲清,不静默", async () => {
|
||||
const api = stubApi({
|
||||
applyPreview: async () => {
|
||||
throw new Error("流程线已停发,不可发起新办件");
|
||||
},
|
||||
});
|
||||
const out = await resolveNodePreview(cfg(), depts, api, "g1");
|
||||
assert.equal(out.published?.state, "unavailable");
|
||||
assert.ok(out.published?.text.includes("流程线已停发,不可发起新办件"));
|
||||
});
|
||||
|
||||
test("会签/发起时指派口径进说明文案(分层后信息不丢)", async () => {
|
||||
const out = await resolveNodePreview(
|
||||
cfg({ countersign: "ALL", assignPolicy: { mode: "SINGLE", required: true } }),
|
||||
depts,
|
||||
stubApi(),
|
||||
);
|
||||
assert.ok(out.notes.some((n) => n.includes("会签(全部同意才通过)")));
|
||||
assert.ok(out.notes.some((n) => n.includes("单选 1 人") && n.includes("必选")));
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
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 } from "vue";
|
||||
|
||||
import FlowAssignEmbed from "@/components/FlowAssignEmbed.vue";
|
||||
|
||||
/**
|
||||
* FL 侧选人 iframe 宿主的协议测试(自动化测试宿主,不是产品入口)。
|
||||
* 覆盖:地址构造(单 hash)、四道校验、监听器生命周期、取消保留旧选择、确认后 B 替换 A。
|
||||
*/
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
const PROTO = "flow";
|
||||
const VERSION = 1;
|
||||
const APP_ORIGIN = window.location.origin;
|
||||
|
||||
function baseData(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
proto: PROTO,
|
||||
v: VERSION,
|
||||
type: "assign-confirmed",
|
||||
contextId: "",
|
||||
flowGuid: "g-embed",
|
||||
defVersion: 3,
|
||||
assigns: [{ nodeId: "u2", userIds: [101] }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** view-ui-plus Modal 默认把内容 teleport 到 body,组件子树里查不到——按真实 DOM 取 */
|
||||
function frameEl(): HTMLIFrameElement | null {
|
||||
return document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
}
|
||||
|
||||
/** 父页收到消息的唯一入口:直接派发 MessageEvent,绕开真实 iframe 加载 */
|
||||
function post(wrapper: VueWrapper, data: unknown, opts: { origin?: string; source?: unknown } = {}) {
|
||||
const frame = frameEl() as HTMLIFrameElement;
|
||||
const ev = new MessageEvent("message", {
|
||||
origin: opts.origin ?? APP_ORIGIN,
|
||||
source: (opts.source ?? frame.contentWindow) as Window | null,
|
||||
data,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
return nextTick();
|
||||
}
|
||||
|
||||
function mountHost(props: Record<string, unknown> = {}) {
|
||||
return mount(FlowAssignEmbed, {
|
||||
props: { flowGuid: "g-embed", ...props },
|
||||
global: { plugins: [ViewUIPlus] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
/** 开框后 jsdom 仍不会真正加载子文档:给 iframe 挂一个显式窗口桩,父侧按它核 event.source */
|
||||
async function openHost(wrapper: VueWrapper) {
|
||||
(wrapper.vm as any).open();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
const frame = frameEl() as HTMLIFrameElement;
|
||||
Object.defineProperty(frame, "contentWindow", { value: { __frame: true }, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 地址与会话", () => {
|
||||
it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const src = (frameEl() as HTMLIFrameElement).getAttribute("src") || "";
|
||||
expect(src.split("#").length - 1).toBe(1);
|
||||
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
|
||||
expect(src).toContain("flowGuid=g-embed");
|
||||
expect(src).toContain("contextId=");
|
||||
expect((wrapper.vm as any).contextId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("缺少 flowGuid 时不开框、不监听", async () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const wrapper = mountHost({ flowGuid: "" });
|
||||
(wrapper.vm as any).open();
|
||||
await nextTick();
|
||||
expect(frameEl()).toBeNull();
|
||||
expect(addSpy.mock.calls.filter((c) => c[0] === "message").length).toBe(0);
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 消息校验", () => {
|
||||
it("协议相符的确认消息:回传最终选择与版本锚", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
const events = wrapper.emitted("confirmed");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0][0] as any).assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
|
||||
expect((events[0][0] as any).defVersion).toBe(3);
|
||||
expect((events[0][0] as any).flowGuid).toBe("g-embed");
|
||||
});
|
||||
|
||||
it("异源、异窗口、异 contextId、畸形信封一律丢弃,不影响宿主", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
const fakeOtherWindow = { __other: true };
|
||||
|
||||
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example.com" });
|
||||
await post(wrapper, baseData({ contextId: cid }), { source: fakeOtherWindow });
|
||||
await post(wrapper, baseData({ contextId: "stale-from-prev-session" }));
|
||||
await post(wrapper, { proto: "fc", v: VERSION, type: "assign-confirmed", contextId: cid });
|
||||
await post(wrapper, { proto: PROTO, v: 99, type: "assign-confirmed", contextId: cid });
|
||||
await post(wrapper, { proto: PROTO, v: VERSION, type: "not-ours", contextId: cid });
|
||||
await post(wrapper, "plain-string-from-page-script");
|
||||
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
expect(wrapper.emitted("cancelled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("确认消息缺 assigns 时形状校验拦下(不落成空选择)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid, assigns: undefined }));
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 生命周期与选择语义", () => {
|
||||
it("取消:只通知宿主,不产生确认,旧选择不被覆盖", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid, type: "assign-cancelled" }));
|
||||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("确认后同一会话的迟到消息不再触发(监听器已摘)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
await post(wrapper, baseData({ contextId: cid, assigns: [{ nodeId: "u2", userIds: [777] }] }));
|
||||
const events = wrapper.emitted("confirmed");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0][0] as any).assigns[0].userIds).toEqual([101]);
|
||||
});
|
||||
|
||||
it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
(wrapper.vm as any).close();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("重开换新 contextId:旧会话残消息不影响新一轮,确认以最近一次为准(B 替换 A)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid1 = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [101] }] }));
|
||||
expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]);
|
||||
|
||||
// 宿主把 A 记在内存里,重开选 B
|
||||
await openHost(wrapper);
|
||||
const cid2 = (wrapper.vm as any).contextId;
|
||||
expect(cid2).not.toEqual(cid1);
|
||||
|
||||
// 上一轮残帧补发一条:按 context 失效
|
||||
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [999] }] }));
|
||||
expect(wrapper.emitted("confirmed")).toHaveLength(1);
|
||||
|
||||
await post(wrapper, baseData({ contextId: cid2, assigns: [{ nodeId: "u2", userIds: [102, 103] }] }));
|
||||
const events = wrapper.emitted("confirmed");
|
||||
expect(events).toHaveLength(2);
|
||||
expect((events[1][0] as any).assigns[0].userIds).toEqual([102, 103]);
|
||||
});
|
||||
|
||||
it("卸载后监听器摘掉,后续消息不再回传", async () => {
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
const frame = frameEl() as HTMLIFrameElement;
|
||||
wrapper.unmount();
|
||||
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
|
||||
window.dispatchEvent(new MessageEvent("message", {
|
||||
origin: APP_ORIGIN,
|
||||
source: frame.contentWindow as Window,
|
||||
data: baseData({ contextId: cid }),
|
||||
}));
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,7 @@ describe("Defs 流程线停发/恢复交互", () => {
|
||||
|
||||
expect(wrapper.text()).toContain("处理中…");
|
||||
expect(lineLink(wrapper, "正常线", "停发流程线")!.classes()).toContain("disabled");
|
||||
expect(flowApiMock.defOfflineLine).toHaveBeenCalledWith("g-normal");
|
||||
expect(flowApiMock.defOfflineLine).toHaveBeenCalledWith("g-normal", { silentBizToast: true });
|
||||
|
||||
gate.resolve(2);
|
||||
await pending;
|
||||
@@ -265,7 +265,7 @@ describe("Defs 流程线停发/恢复交互", () => {
|
||||
await pending;
|
||||
await flushPromises();
|
||||
|
||||
expect(flowApiMock.defEnableLine).toHaveBeenCalledWith("g-off");
|
||||
expect(flowApiMock.defEnableLine).toHaveBeenCalledWith("g-off", { silentBizToast: true });
|
||||
expect(flowApiMock.defOfflineLine).not.toHaveBeenCalled();
|
||||
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已恢复(1 个版本"));
|
||||
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { toContentJson, fromContentJson } from "../src/views/flow/admin/def-editor/convert";
|
||||
import { createApproval, createCondition, createNotify, createParallel } from "../src/views/flow/admin/def-editor/model";
|
||||
import type { EditorItem, EditorModel } from "../src/views/flow/admin/def-editor/model";
|
||||
|
||||
/**
|
||||
* 流程定义序列化回归闸门(任务书 §6.8「检查流程定义序列化」)。
|
||||
* 本轮只动编辑器面板的**展示分层**,这里钉住:结构语义不因这些改动漂移。
|
||||
* 对比口径按语义集合比(节点字段集 + 边三元组集),不比数组顺序——空泳道会产生纯顺序差。
|
||||
*/
|
||||
|
||||
type Json = ReturnType<typeof toContentJson>;
|
||||
|
||||
function model(items: EditorItem[]): EditorModel {
|
||||
return {
|
||||
code: "rt-demo",
|
||||
name: "往返回归",
|
||||
list: items,
|
||||
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
|
||||
};
|
||||
}
|
||||
|
||||
const clone = (o: unknown) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function fullItems(): EditorItem[] {
|
||||
const approval = createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" });
|
||||
approval.countersign = "ALL";
|
||||
approval.deadlineHours = 8;
|
||||
approval.assignPolicy = { mode: "SINGLE", required: true };
|
||||
|
||||
const outletNode = createApproval("多出口", { type: "USERS", userIds: [101, 102] });
|
||||
outletNode.outlets = ["同意", "退回", "加签"];
|
||||
outletNode.branches = [[createApproval("分支一")], [], [createNotify("分支三抄送")]];
|
||||
|
||||
const cond = createCondition();
|
||||
cond.judgeMode = "EXPRESSION";
|
||||
cond.outlets = ["大额", "小额"];
|
||||
cond.conditions = ["amount > 1000", ""];
|
||||
cond.branches = [[createApproval("大额复核")], [createApproval("小额直过")]];
|
||||
|
||||
const par = createParallel();
|
||||
par.branches = [[createNotify("并行抄送A")], [createApproval("并行复核B", { type: "DEPT_KEY", deptKey: "42" })]];
|
||||
|
||||
return [approval, cond, par, outletNode];
|
||||
}
|
||||
|
||||
/** 已知缺陷用:非首位并行容器重解析后 split/join 会换成新 id(见最后一条用例) */
|
||||
function parallelDerivedIds(json: Json): string[] {
|
||||
return json.nodes.filter((n) => n.type === "PARALLEL_SPLIT" || n.type === "PARALLEL_JOIN").map((n) => n.id);
|
||||
}
|
||||
|
||||
function roundTrip(times: number, json: Json): Json {
|
||||
let cur = json;
|
||||
for (let i = 0; i < times; i++) {
|
||||
cur = toContentJson({ ...model([]), ...fromContentJson(clone(cur)) } as EditorModel);
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
test("结构往返两轮:每个节点的配置语义逐项一致(并行派生 id 另用例专门记录)", () => {
|
||||
const first = toContentJson(model(fullItems()));
|
||||
const second = roundTrip(1, first);
|
||||
const third = roundTrip(2, first);
|
||||
|
||||
const byId = (json: Json) => {
|
||||
const m = new Map<string, string>();
|
||||
json.nodes.forEach((n) => m.set(n.id, JSON.stringify(n)));
|
||||
return m;
|
||||
};
|
||||
const a = byId(first);
|
||||
const b = byId(second);
|
||||
const c = byId(third);
|
||||
|
||||
// 配置类节点(审批/抄送/条件)的 id 与内容必须逐字不变
|
||||
["u1", "u2", "u3", "u4", "u5c", "u8", "u9", "start", "end"].forEach((id) => {
|
||||
assert.equal(b.get(id), a.get(id), `节点 ${id} 内容在第二轮发生变化`);
|
||||
assert.equal(c.get(id), a.get(id), `节点 ${id} 内容在第三轮发生变化`);
|
||||
});
|
||||
assert.deepEqual(second.settings, first.settings);
|
||||
assert.deepEqual(third.settings, first.settings);
|
||||
|
||||
// 并行分支内的两名办理人同样不变
|
||||
const branchIds = ["u13", "u14"];
|
||||
branchIds.forEach((id) => assert.equal(b.get(id), a.get(id), `并行分支节点 ${id} 变化`));
|
||||
|
||||
// 出口边(含条件表达式)在两轮都存在
|
||||
const edgeSet = (json: Json) => new Set(json.edges.map((e) => `${e.from}->${e.to}${e.name ? `(${e.name})` : ""}${e.condition ? `[${e.condition}]` : ""}`));
|
||||
const ea = edgeSet(first);
|
||||
const eb = edgeSet(second);
|
||||
["u2->u3(同意)", "u2->u4(加签)", "u2->end(退回)", "u5c->u8(大额)[amount > 1000]", "u5c->u9(小额)"].forEach((e) => {
|
||||
assert.ok(ea.has(e), `第一轮缺少边 ${e}`);
|
||||
assert.ok(eb.has(e), `第二轮缺少边 ${e}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("关键语义字段全部活过往返,不被展示层分层影响", () => {
|
||||
const first = toContentJson(model(fullItems()));
|
||||
const parsed = fromContentJson(clone(first));
|
||||
const second = toContentJson({ ...model([]), ...parsed } as EditorModel);
|
||||
|
||||
// 会签 ALL 是后端缺省值,生成 JSON 时按设计省略(model.ts 注释);口径以解析回编辑态为准
|
||||
const approvalItem = parsed.list.find((it) => it.kind === "approval" && it.assignee?.type === "DEPT_KEY");
|
||||
assert.ok(approvalItem && approvalItem.kind === "approval");
|
||||
assert.equal(approvalItem.countersign, "ALL", "会签语义在解析端丢失");
|
||||
assert.equal(approvalItem.deadlineHours, 8, "办理时限丢失");
|
||||
assert.deepEqual(approvalItem.assignPolicy, { mode: "SINGLE", required: true }, "发起时指派策略丢失");
|
||||
|
||||
assert.ok(second.nodes.some((n) => n.type === "NOTIFY" && n.receivers?.type === "USERS"), "抄送对象丢失");
|
||||
assert.ok(second.nodes.some((n) => n.judgeMode === "EXPRESSION"), "条件判断方式丢失");
|
||||
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_SPLIT"), "并行拆分丢失");
|
||||
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_JOIN" && n.waitFor === "ALL"), "并行汇聚语义丢失");
|
||||
assert.ok(second.nodes.some((n) => n.id === "start") && second.nodes.some((n) => n.id === "end"), "端点丢失");
|
||||
assert.ok(second.edges.filter((e) => e.name).length >= 3, "多出口/条件分支的出口名丢失");
|
||||
});
|
||||
|
||||
test("设置与端点名不被丢:rejectPolicy/urge/start-end name 原样回写", () => {
|
||||
const m = model([createApproval("单节点")]);
|
||||
m.startName = "提交申请";
|
||||
m.endName = "办结";
|
||||
const json = toContentJson(m);
|
||||
assert.equal(json.settings.rejectPolicy, "END_REJECTED");
|
||||
assert.equal(json.settings.urge.intervalHours, 24);
|
||||
assert.equal((json.nodes.find((n) => n.type === "START") || {}).name, "提交申请");
|
||||
assert.equal((json.nodes.find((n) => n.type === "END") || {}).name, "办结");
|
||||
});
|
||||
|
||||
test("已知缺陷(本轮不修,修它等于改序列化契约):并行容器重解析后 split/join 换成新 id", () => {
|
||||
const first = toContentJson(model([createApproval("a"), createParallel()]));
|
||||
const before = parallelDerivedIds(first);
|
||||
const rt = roundTrip(1, first);
|
||||
const after = parallelDerivedIds(rt);
|
||||
|
||||
assert.equal(after.length, before.length, "并行拆/合节点数量发生变化");
|
||||
assert.notDeepEqual(after, before, "并行 id 改名行为已变化——若已被修好,请把本用例改成断言 id 稳定并同步终审");
|
||||
|
||||
// 改名只涉及 id 字面,汇聚语义不变
|
||||
const join = rt.nodes.find((n) => n.type === "PARALLEL_JOIN");
|
||||
assert.equal(rt.nodes.filter((n) => n.type === "PARALLEL_SPLIT").length, 1);
|
||||
assert.equal(join?.waitFor, "ALL", "并行汇聚 waitFor 语义丢失");
|
||||
// 拆分/汇聚与分支节点的连接关系仍然闭合(每个分支节点都连到某条 split/join)
|
||||
const anchors = new Set(after);
|
||||
const linked = rt.edges.filter((e) => anchors.has(e.from) || anchors.has(e.to));
|
||||
assert.ok(linked.length >= 4, `并行连线丢失,只剩 ${linked.length} 条`);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { describeFlowError, flowFailTip, FLOW_GENERIC_FALLBACK, FLOW_NETWORK_FALLBACK } from "../src/utils/flowError";
|
||||
|
||||
/** 审批失败文案单测:口径=具体业务话原样透出,通用/异常形态换兜底话术且不外泄内部信息 */
|
||||
|
||||
test("后端给出通用 code=6 占位文案:换成不误导的兜底话术,不猜原因", () => {
|
||||
assert.equal(describeFlowError("内部服务错误"), FLOW_GENERIC_FALLBACK);
|
||||
assert.equal(describeFlowError("内部服务错误!"), FLOW_GENERIC_FALLBACK);
|
||||
assert.equal(describeFlowError("业务错误"), FLOW_GENERIC_FALLBACK);
|
||||
assert.equal(describeFlowError(""), FLOW_GENERIC_FALLBACK);
|
||||
assert.ok(!FLOW_GENERIC_FALLBACK.includes("成功"));
|
||||
});
|
||||
|
||||
test("契约内的具体业务 msg 原样透出,不改写不误导", () => {
|
||||
assert.equal(describeFlowError("流程线不存在:8d07f049"), "流程线不存在:8d07f049");
|
||||
assert.equal(describeFlowError("该任务已被他人办理"), "该任务已被他人办理");
|
||||
assert.equal(
|
||||
describeFlowError("该待办缺少流程推进记录,需管理员回填后办理"),
|
||||
"该待办缺少流程推进记录,需管理员回填后办理",
|
||||
);
|
||||
});
|
||||
|
||||
test("异常/SQL/令牌形态不回显:堆栈与凭据不进页面", () => {
|
||||
const leaks = [
|
||||
"java.lang.IllegalStateException: D09 task has no proven activation; audited backfill required",
|
||||
"at com.yunhai.flowcenter.service.FlowTaskService.handle(FlowTaskService.java:100)",
|
||||
"Caused by: SQLException error code [1064]",
|
||||
"refresh token=Bearer abcdef123456",
|
||||
"<html><body>500 Internal Server Error</body></html>",
|
||||
];
|
||||
for (const raw of leaks) {
|
||||
const out = describeFlowError(raw);
|
||||
assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
|
||||
assert.ok(!/java\.|Exception|SQL|Bearer|<html>/i.test(out));
|
||||
}
|
||||
});
|
||||
|
||||
test("网络/登录态失败:按网络口径提示,不与业务失败混说", () => {
|
||||
assert.equal(describeFlowError("网络错误"), FLOW_NETWORK_FALLBACK);
|
||||
assert.equal(describeFlowError("Failed to fetch"), FLOW_NETWORK_FALLBACK);
|
||||
assert.equal(describeFlowError("timeout of 20000ms exceeded"), FLOW_NETWORK_FALLBACK);
|
||||
});
|
||||
|
||||
test("Error 对象可直接传入(取 message)", () => {
|
||||
assert.equal(describeFlowError(new Error("流程线不存在:g1")), "流程线不存在:g1");
|
||||
assert.equal(describeFlowError(new Error("内部服务错误")), FLOW_GENERIC_FALLBACK);
|
||||
});
|
||||
|
||||
test("页面内联提示带动作名,明确「未生效」而不是成功", () => {
|
||||
const tip = flowFailTip("同意", new Error("内部服务错误"));
|
||||
assert.ok(tip.startsWith("同意未完成:"));
|
||||
assert.ok(tip.includes(FLOW_GENERIC_FALLBACK));
|
||||
assert.ok(!tip.includes("已同意"));
|
||||
});
|
||||
@@ -142,6 +142,16 @@ test("静态接线:Defs.vue 使用线级接口,不混入 listAll 或重复
|
||||
|
||||
test("静态接线:API 客户端使用既有 admin 端点与 flowGuid 请求体", () => {
|
||||
const api = readFileSync(resolve(process.cwd(), "src/api/module/flow.ts"), "utf8");
|
||||
assert.match(api, /`\$\{ROOT\}\/admin\/def\/offlineLine`, \{ flowGuid \}/);
|
||||
assert.match(api, /`\$\{ROOT\}\/admin\/def\/enableLine`, \{ flowGuid \}/);
|
||||
assert.match(api, /`\$\{ROOT\}\/admin\/def\/offlineLine`, \{ flowGuid \}, config/);
|
||||
assert.match(api, /`\$\{ROOT\}\/admin\/def\/enableLine`, \{ flowGuid \}, config/);
|
||||
});
|
||||
|
||||
test("静态接线:线级启停失败只有一条错误面(调用方带 silentBizToast,全局 toast 认这个旗标)", () => {
|
||||
const defs = readFileSync(resolve(process.cwd(), "src/views/flow/admin/Defs.vue"), "utf8");
|
||||
const core = readFileSync(resolve(process.cwd(), "src/utils/alova/alova-core.ts"), "utf8");
|
||||
assert.match(defs, /defOfflineLine\(guid, \{ silentBizToast: true \}\)/);
|
||||
assert.match(defs, /defEnableLine\(guid, \{ silentBizToast: true \}\)/);
|
||||
assert.match(core, /silentBizToast/);
|
||||
assert.match(core, /if \(!isSilentBizToast\(method\)\) onBizError/);
|
||||
assert.match(core, /if \(!isSilentBizToast\(method\)\) onHttpError/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user