Files
flow-center-front/tests/assign-preview.test.ts
T
chenxing bed57846f4
build / build (push) Successful in 1m31s
feat(flow): 落地 FL2.0 原型——部门选人抽屉 / 授权管理页 / 退回重提入口(基线 origin/main)
在 c93313c 之上增量落地原型与前端规范,保留既有流程图入口/失败提示/指派预览/线级停发:

- 审批/抄送抽屉按原型重构(422→1026 / 239→742 行):科室树+成员多选、SYSTEM/CUSTOM 分支、
  CUSTOM 分类导航自动定位、跨节点导航重置、保存后重开回显
- 新增授权管理页 /admin/usage-grants(UsageGrants.vue,417 行):校域清单、按流程线筛选、
  授予/撤销后回读,失败不宣称成功
- 路由与 API:新增 usage-grant(schoolList/summary/grant/revoke)、department(groups/options/resolve/members)、
  instance/resubmit;TaskHandle 增加「退回待修改」入口(仅 status=50 显示)
- 错误处理沿用 flowError 统一出口,不透出后端原文
- 组件/契约测试:flow-error、flow2-contract、department-roundtrip、task-result

不含构建产物(dist-verify)与依赖目录
2026-10-30 10:15:47 +08:00

164 lines
7.0 KiB
TypeScript

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 () => {
// 与 alova 层一致:业务信封 code≠0 的拒绝才带 bizCode,安全呈现层据此判定可信原文
throw Object.assign(new Error("流程线已停发,不可发起新办件"), { bizCode: 2 });
},
});
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("必选")));
});