/** * 流程包 —— 守四件事:拓扑齐、**审批点后面跟网关且两个去向都真的走**、 * 网关条件读得到判据、回边标了 reopen。 */ import { describe, expect, it } from 'vitest'; import { MVP_PACKAGE } from '../seed-mvp-flow.ts'; describe('MVP_PACKAGE', () => { it('15 语义节点 · 18 边 · 五网关一审批点双终点', () => { expect(MVP_PACKAGE.nodes).toHaveLength(15); expect(MVP_PACKAGE.edges).toHaveLength(18); expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'end')).toHaveLength(2); expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'gateway')).toHaveLength(5); expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'approval')).toHaveLength(1); }); it('分叉必有汇合:G2 的两条互斥分支并回 G2J —— 不给汇合点,A4 会被判隐式合并', () => { const g2j = MVP_PACKAGE.nodes.find((n) => n.id === 'G2J'); expect(g2j?.kind).toBe('gateway'); if (g2j?.kind !== 'gateway') return; expect(g2j.gateway).toBe('exclusive'); expect(g2j.mode).toBe('join'); expect(MVP_PACKAGE.edges.filter((e) => e.to === 'G2J')).toHaveLength(2); /* * A4 的**非回边**入边只剩一条 —— 这是发布闸能过的关键。 * A4 一共三条入边(G2J 正常来、G3 驳回、G4 有异议),后两条都是 reopen; * 闭合校验的「汇合同名字段来源不明」只看并发入边(回边是重新入场、不参与判定), * 所以 concurrent 长度 1、不触发。任一条回边漏标 reopen,发布立刻被拦。 */ expect(MVP_PACKAGE.edges.filter((e) => e.to === 'A4' && !e.reopen)).toHaveLength(1); expect(MVP_PACKAGE.edges.filter((e) => e.to === 'A4')).toHaveLength(3); }); /* * 审批点后面必然跟一个网关(BPMN 的常规)。 * * 这条曾经不成立:G3 被我做掉了,通过走 A5 的唯一出边、驳回藏在 `reject` 声明里, * 图上只有一枚牌子。补回 G3 时还有个坑 —— 若引擎不把裁决放进载荷, * G3 的条件就没东西可读,只能在边上喂一枚常量 `verdict: "pass"` 骗过它; * 那样条件恒真、驳回边永不可达,网关成了摆设。所以这里连**没有常量**一起守。 */ it('A5 后面跟着 G3,两个去向都由图表达,且判据不是喂进去的常量', () => { const a5 = MVP_PACKAGE.nodes.find((n) => n.id === 'A5'); expect(a5?.kind).toBe('approval'); if (a5?.kind !== 'approval') return; expect(a5.approver.position).toBe('PM'); // A5 的唯一出边指向网关,而不是直接指向下一个活动 const outOfA5 = MVP_PACKAGE.edges.filter((e) => e.from === 'A5'); expect(outOfA5).toHaveLength(1); expect(outOfA5[0]?.to).toBe('G3'); // 这条边不许带映射:判据由引擎在裁决时放进载荷,不是边喂的常量 expect(outOfA5[0]?.mapping).toEqual([]); const g3 = MVP_PACKAGE.nodes.find((n) => n.id === 'G3'); expect(g3?.kind).toBe('gateway'); if (g3?.kind !== 'gateway') return; expect(g3.gateway).toBe('exclusive'); expect(g3.mode).toBe('fork'); const outOfG3 = MVP_PACKAGE.edges.filter((e) => e.from === 'G3'); expect(outOfG3).toHaveLength(2); // 异或分叉的每条出边都要带条件 —— 没有无条件兜底边可用 for (const e of outOfG3) expect(e.condition, `${e.id} 缺条件`).toBeDefined(); expect(outOfG3.map((e) => `${e.to}:${e.condition ?? ''}`).sort()).toEqual([ 'A4:verdict = "reject"', 'A6:verdict = "pass"', ]); }); it('G3 的驳回出边与 A5 的 reject 声明字字对齐 —— 引擎先看图,没画边的包才退回声明', () => { const a5 = MVP_PACKAGE.nodes.find((n) => n.id === 'A5'); const edge = MVP_PACKAGE.edges.find((e) => e.id === 'e-g3-a4'); expect(a5?.kind).toBe('approval'); if (a5?.kind !== 'approval' || a5.reject.kind !== 'reopen') return; expect(edge?.to).toBe(a5.reject.nodeId); expect(edge?.reopen).toBe(true); expect(edge?.mapping).toEqual(a5.reject.mapping); }); it('两条回边都标了 reopen:审批驳回与议价有异议,都回 A4 重开', () => { expect(MVP_PACKAGE.edges.filter((e) => e.reopen).map((e) => `${e.from}→${e.to}`).sort()).toEqual([ 'G3→A4', 'G4→A4', ]); }); it('网关条件按判据写:G1/G2 读旗,G3 读裁决,G4 读 outcome', () => { const cond = (from: string, to: string) => MVP_PACKAGE.edges.find((e) => e.from === from && e.to === to)?.condition; expect(cond('G1', 'A2')).toBe('needAlternative = true'); expect(cond('G2', 'G2J')).toBe('needSourcing = false'); expect(cond('G3', 'A6')).toBe('verdict = "pass"'); expect(cond('G4', 'A7')).toBe('outcome = "lost"'); }); it('手绘图存证在包上 —— 15 语义节点 + 两枚卡点', () => { expect(MVP_PACKAGE.diagram).toBeDefined(); const nodes = (MVP_PACKAGE.diagram as { nodes: { id: string; kind: string }[] }).nodes; expect(nodes.filter((n) => n.kind === 'callout')).toHaveLength(2); expect(nodes).toHaveLength(17); // 语义节点在图上一个不少(卡点是呈现,不入语义) const drawn = new Set(nodes.map((n) => n.id)); for (const n of MVP_PACKAGE.nodes) expect(drawn.has(n.id), `图上缺 ${n.id}`).toBe(true); }); /* * 几何自查:重排的收益要能验证,不能只写在注释里。 * 手绘尺寸见 `apps/web/src/shared/gov/flow/flow-layout.ts` 的 MANUAL_SIZES。 */ it('几何:坐标吸附 10px 珊格,同带内框不重叠', () => { const nodes = (MVP_PACKAGE.diagram as { nodes: { id: string; kind: string; x: number; y: number }[] }).nodes; const size = (kind: string) => kind === 'gateway' ? { w: 40, h: 40 } : kind === 'callout' ? { w: 100, h: 30 } : kind === 'activity' ? { w: 100, h: 40 } : { w: 20, h: 20 }; for (const n of nodes) { expect(n.x % 10, `${n.id} 的 x 未吸附珊格`).toBe(0); expect(n.y % 10, `${n.id} 的 y 未吸附珊格`).toBe(0); } // 两两不重叠(轴对齐矩形相交判定) for (let i = 0; i < nodes.length; i += 1) { for (let j = i + 1; j < nodes.length; j += 1) { const a = nodes[i]!; const b = nodes[j]!; const sa = size(a.kind); const sb = size(b.kind); const overlap = a.x < b.x + sb.w && a.x + sa.w > b.x && a.y < b.y + sb.h && a.y + sa.h > b.y; expect(overlap, `${a.id} 与 ${b.id} 的框重叠`).toBe(false); } } }); });