Files
JX-BPOS/apps/api/src/modules/process/__tests__/save-package.test.ts
T
ChaiHaoandClaude Opus 5 c9ac058bdf 三面拆分⑯:流程包详情改三栏 · 字段只声明 · 版本可删可改名
**字段只声明,不做任何要求**(用户定):编排层不再碰字段 ——
  · 激活闸只剩两面:结构闭合(dangling-node / multi-default-edge)与活动装载
    (unknown-activity);字段类的检查(同名不同型、容忍分支被必填依赖)全部退役。
  · 节点属性卡的「数据接口」从「推来源」改成**纯声明**:照活动契约列出吃什么、吐什么,
    不标必填、不推来源、不提补全 —— 缺了怎么补是**活动内实现**的事,不在这一层现形。
  · 字段类型支持数组:统一写成 `T[]`(如 `any[]`),出口口的字段也带上类型
    (原来只有字段名)。实测 A2 的 benchmark 口显示「rows any[] · needSourcing boolean」。

**详情页改三栏**:左窄轨版本(132px) | 中流程图 | 右节点属性。
  · 版本条一版一行(v几 · 状态 · 点/边计数),动作只在**看着的那一版**上展开 ——
    窄栏放不下每行一排钮,也不该让人误点别版。
  · 删掉「激活 · 双判据」整卡:没选节点时,属性栏就地列出还差哪几条(点一条定位过去);
    两面都齐时盖一枚小检验章,不再占一整张卡。
  · 删掉节点拣选行 —— 直接在图上点节点。
  · 属性卡用包壳绝对定位:行高由中栏的图定,属性长了在卡内滚,不把图拉长
    (实测卡高 467、明细内容 1039、可滚,图框不受影响)。窄轨里属性小卡改单列竖排。
  · 顺带校正:上一轮那条「图框下沿对齐左列底栏顶线」的 20px 留白,
    三栏改版后左栏已无底栏,没有对齐对象了 —— 改回四边等距(18px)。

**版本可删可改名**(新):
  · 删:生效版不可删(先停用,ActiveVersionError → 409)、在途钉版不可删
    (那些单要按它跑完,PinnedVersionError → 409);删掉正看的那版会落到剩下的最新版,
    一版不剩就回货架。
  · 改名:名字是**这条流的身份**,一次改及于所有版本 —— 版本列表上只显示 v1/v2…,
    若只改一版的名,列表上什么也看不见,人会以为没生效。
  · 5 条单测守两道闸与改名口径;实测走查:改名落库 → 改回;生效版删 409、停用版删 200。

process CONTRACT.md 与产品设计.md 同步。五闸绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 16:14:54 +08:00

144 lines
5.9 KiB
TypeScript

/**
* 保存 —— **编辑即其身**(产品决策 2026-07-28 第四轮):
* 写回当前版本号不变;新版本只出自显式新建(asNew)或新流程首版。
* 两道底线:在途钉版不可变;生效版写回必须闭合(生效的必须始终可运行)。
*/
import { describe, expect, it } from 'vitest';
import { processPackageSchema } from '@contracts';
import type { ProcessPackage } from '@contracts';
import { NotClosedError, PinnedVersionError, StaleVersionError } from '../contract.ts';
import { createProcessService } from '../internal/usecase/process-service.ts';
import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts';
const basePkg = (version: number, state: 'active' | 'retired'): ProcessPackage =>
processPackageSchema.parse({
id: 'p1',
name: '包',
version,
state,
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [
{ id: 'S', kind: 'start', label: '起' },
{ id: 'E', kind: 'end', label: '终' },
],
edges: [{ id: 'E1', from: 'S', to: 'E', mapping: [], reopen: false }],
variables: [],
});
function harness(seed: ProcessPackage[], pinned = 0) {
const shelf = [...seed];
const updated: Array<{ id: string; version: number }> = [];
const inserted: ProcessPackage[] = [];
const deps: ProcessServiceDeps = {
store: {
savePackage: async (pkg) => {
inserted.push(pkg);
shelf.push(pkg);
},
updatePackageDefinition: async (id, version, pkg) => {
const at = shelf.findIndex((p) => p.id === id && p.version === version);
if (at < 0) throw new Error('版本不存在');
shelf[at] = pkg;
updated.push({ id, version });
},
countPinnedInFlight: async () => pinned,
setPackageState: async () => undefined,
getPackage: async (id, version) =>
version === undefined
? [...shelf].sort((a, b) => b.version - a.version).find((p) => p.id === id)
: shelf.find((p) => p.id === id && p.version === version),
listPackages: async () => shelf,
insertWorkflow: async () => undefined,
getWorkflow: async () => undefined,
listWorkflows: async () => [],
updateWorkflow: async () => {
throw new Error('测试:不该走到 updateWorkflow');
},
listInstances: async () => [],
insertInstance: async () => undefined,
updateInstance: async () => undefined,
findInstanceByTask: async () => undefined,
deletePackage: async () => undefined,
renameFlow: async () => undefined,
nodeOwners: async () => ({}),
setNodeOwner: async () => undefined,
},
activities: { faces: async () => new Map() },
org: {
positions: async () => new Set<string>(['PM']),
candidates: async () => [],
workGroupBinding: async () => undefined,
isActive: async () => true,
},
tasks: { start: async () => ({ id: 't' }) },
approvals: { raise: async () => ({ id: 'a' }) },
sfeel: { condition: () => true, expression: () => undefined },
schemas: { validate: () => ({ ok: true, errors: [] }) },
events: { publish: async () => undefined },
receipts: { notify: async () => undefined },
clock: { now: () => new Date('2026-07-28T00:00:00.000Z') },
ids: { next: () => 'x' },
};
return { service: createProcessService(deps), shelf, updated, inserted };
}
describe('savePackage —— 编辑即其身', () => {
it('已存在的版本就地写回:号不变、状态不变、不长新版', async () => {
const h = harness([basePkg(4, 'retired')]);
const edited = { ...basePkg(4, 'retired'), name: '改名了' };
const saved = await h.service.savePackage(edited);
expect(saved.version).toBe(4);
expect(saved.state).toBe('retired');
expect(h.updated).toEqual([{ id: 'p1', version: 4 }]);
expect(h.inserted).toHaveLength(0);
expect(h.shelf).toHaveLength(1);
});
it('在途钉版不可变 —— 有未完结工作流即拒,一字不落库', async () => {
const h = harness([basePkg(4, 'retired')], 2);
await expect(h.service.savePackage(basePkg(4, 'retired'))).rejects.toBeInstanceOf(PinnedVersionError);
expect(h.updated).toHaveLength(0);
expect(h.inserted).toHaveLength(0);
});
it('写回生效版须闭合 —— 生效的必须始终可运行', async () => {
const h = harness([basePkg(4, 'active')]);
// 把唯一一条边砍掉:起点无出边、终点无入边 —— 不闭合
const broken = { ...basePkg(4, 'active'), edges: [] };
await expect(h.service.savePackage(broken)).rejects.toBeInstanceOf(NotClosedError);
expect(h.updated).toHaveLength(0);
// 闭合的改动照常写回,生效状态保持
const ok = { ...basePkg(4, 'active'), name: '热改' };
const saved = await h.service.savePackage(ok);
expect(saved.state).toBe('active');
expect(h.updated).toEqual([{ id: 'p1', version: 4 }]);
});
it('asNew 显式新建:铸下一个版本号,不生效 —— 新版本只出自这条路', async () => {
const h = harness([basePkg(4, 'active')]);
const saved = await h.service.savePackage(basePkg(4, 'active'), { asNew: true });
expect(saved.version).toBe(5);
expect(saved.state).toBe('retired');
expect(h.inserted).toHaveLength(1);
expect(h.updated).toHaveLength(0);
});
it('陈旧编辑拒之门外:流程存在而版本号已不存在 —— 不静默复活旧内容', async () => {
const h = harness([basePkg(4, 'active')]);
// 停在已清理旧版(v1)上的标签页发来保存
await expect(h.service.savePackage(basePkg(1, 'retired'))).rejects.toBeInstanceOf(StaleVersionError);
expect(h.inserted).toHaveLength(0);
expect(h.updated).toHaveLength(0);
});
it('新流程首版:版本不存在即插入 v1,不生效', async () => {
const h = harness([]);
const saved = await h.service.savePackage(basePkg(1, 'active'));
expect(saved.version).toBe(1);
expect(saved.state).toBe('retired');
expect(h.inserted).toHaveLength(1);
});
});