This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts
T
xcsandClaude Fable 5 0923fbfe4d feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params /
summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts`
(与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema);
迁移 0035_activity_ops;《API文档-活动模块》按契约回写。
设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*`
整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。
2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:15 +08:00

211 lines
12 KiB
TypeScript

/**
* 启动参数表:从清单 `input.data` 派生 + 服务端校验。
* 夹具照 W05 / E04 的清单形状手写(apps 不得 import activities —— depcruise 在守)。
*/
import { describe, expect, it } from 'vitest';
import { controlOf, fieldSpecsOf, launchParamOf, launchParamsOf, validateLaunchParams } from '../activity-ops/launch-params.ts';
/** W05 的四参 + 选填文件,按 2026-08-27 的清单写法 */
const W05_INPUT = {
type: 'object',
required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
properties: {
customerCode: {
type: 'string',
title: '客户',
description: '台账里有就直接选;新客户自己填',
examples: ['CU-0142'],
'x-control': 'combo',
'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' },
},
customerName: { type: 'string', title: '客户名称', examples: ['深圳恒星电源有限公司'] },
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea', examples: ['工业电源项目在用 LM2596S-5.0'] },
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' } },
candidateInternalPns: { type: 'array', title: '候选内部料号', items: { type: 'string' }, examples: [['XL2596S-5.0', 'LM2596S-ADJ']] },
datasheets: { type: 'array', format: 'file', title: 'datasheet', items: { type: 'object' } },
},
};
/** E04:两级下拉 + 数组(元素形状不声明)+ 选填说明 */
const E04_INPUT = {
type: 'object',
required: ['trigger'],
properties: {
trigger: { type: 'string', enum: ['monthly', 'event'], enumNames: ['月度例行', '事件驱动'], title: '触发方式', default: 'monthly' },
eventType: { type: 'string', enum: ['new_customer', 'major_deal'], title: '事件类型', showWhen: { field: 'trigger', equals: 'event' } },
customers: { type: 'array', title: '本轮涉及的客户', examples: [['CU-0142']] },
note: { type: 'string', title: '本轮说明' },
budget: { type: 'integer', title: '预算' },
},
};
const noResolve = async () => undefined;
describe('fieldSpecsOf / controlOf', () => {
it('保属性序;必填从 required[] 认;x-control / x-options / description 都读到', () => {
const specs = fieldSpecsOf(W05_INPUT);
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets']);
expect(specs[0]).toMatchObject({ required: true, control: 'combo', description: '台账里有就直接选;新客户自己填', optionsSource: { ledger: 'customer.profile', value: 'customer_code', label: 'name' } });
// label 列没写就用 value 列当标签
expect(specs[3]?.optionsSource).toEqual({ ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' });
expect(specs[1]?.required).toBe(false);
});
it('控件推断:文件不上表单、字符串数组默认 list、enum 是 select、其余 text;数组标了 combo/select 就摆成单选', () => {
const specs = fieldSpecsOf(W05_INPUT);
expect(specs.map((s) => controlOf(s))).toEqual(['combo', 'text', 'textarea', 'combo', 'list', undefined]);
const e04 = fieldSpecsOf(E04_INPUT);
expect(e04.map((s) => controlOf(s))).toEqual(['select', 'select', 'list', 'text', 'text']);
// 数组字段标 combo → 摆成单选(提交时后端包成数组);标 list 或不标 → list
expect(controlOf({ ...specs[4]!, control: 'combo' })).toBe('combo');
expect(controlOf({ ...specs[4]!, control: 'list' })).toBe('list');
expect(controlOf({ ...specs[4]!, itemType: 'object' })).toBeUndefined();
expect(controlOf({ ...specs[1]!, type: 'object' })).toBeUndefined();
});
});
describe('x-order', () => {
it('声明了次序的按数字排,没声明的殿后保持原序(jsonb 不保键序,书写顺序靠不住)', () => {
const specs = fieldSpecsOf({
properties: {
customerCode: { type: 'string', 'x-order': 1 },
customerName: { type: 'string', 'x-order': 2 },
originalInternalPn: { type: 'string', 'x-order': 4 },
requestDescription: { type: 'string', 'x-order': 3 },
zz: { type: 'string' },
aa: { type: 'string' },
},
});
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'zz', 'aa']);
});
});
describe('launchParamsOf', () => {
it('W05:五参(datasheets 省略);combo 候选来自 resolver;placeholder 取 examples[0]', async () => {
const asked: string[] = [];
const params = await launchParamsOf(W05_INPUT, async (src) => {
asked.push(src.ledger);
return src.ledger === 'customer.profile' ? [{ value: 'CU-0142', label: 'Itelma' }] : undefined;
});
expect(params.map((p) => p.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']);
expect(asked).toEqual(['customer.profile', 'parts.profile']);
expect(params[0]).toEqual({
key: 'customerCode',
label: '客户',
type: 'combo',
required: true,
desc: '台账里有就直接选;新客户自己填',
placeholder: 'CU-0142',
options: [{ value: 'CU-0142', label: 'Itelma' }],
});
// 读不到候选的 combo 仍是 combo(自由填),只是没有 options
expect(params[3]).toEqual({ key: 'originalInternalPn', label: '原内部料号', type: 'combo', required: true });
expect(params[4]).toMatchObject({ type: 'list', placeholder: 'XL2596S-5.0', required: true });
expect(params[2]).toMatchObject({ type: 'textarea', placeholder: '工业电源项目在用 LM2596S-5.0' });
});
it('E04:enum → select 带人读名与 default;没 enumNames 拿值当标签;数组元素形状不声明也当 list', async () => {
const params = await launchParamsOf(E04_INPUT, noResolve);
expect(params[0]).toEqual({
key: 'trigger',
label: '触发方式',
type: 'select',
required: true,
options: [{ value: 'monthly', label: '月度例行' }, { value: 'event', label: '事件驱动' }],
default: 'monthly',
});
expect(params[1]?.options).toEqual([{ value: 'new_customer', label: 'new_customer' }, { value: 'major_deal', label: 'major_deal' }]);
expect(params[2]).toMatchObject({ type: 'list', placeholder: 'CU-0142' });
});
it('x-control:select 却一个候选都没有 → 降成 text(只能选却无可选等于锁死)', () => {
const spec = fieldSpecsOf({ properties: { k: { type: 'string', 'x-control': 'select' } } })[0]!;
expect(launchParamOf(spec, undefined)).toEqual({ key: 'k', label: 'k', type: 'text' });
expect(launchParamOf(spec, [])?.type).toBe('text');
});
});
describe('数组字段摆成单选(2026-08-27 用户定:候选料号与原料号同款下拉,只选一颗)', () => {
const INPUT = {
required: ['candidateInternalPns'],
properties: {
candidateInternalPns: {
type: 'array',
title: '候选内部料号',
items: { type: 'string' },
examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
default: ['LM2596S-ADJ'],
'x-control': 'combo',
'x-options': { ledger: 'parts.profile', value: 'internal_pn' },
},
tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], 'x-control': 'select' },
},
};
it('数组 + x-control:combo → combo 控件,占位与预填取第一颗,候选照读', async () => {
const params = await launchParamsOf(INPUT, async () => [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }]);
expect(params[0]).toEqual({
key: 'candidateInternalPns',
label: '候选内部料号',
type: 'combo',
required: true,
placeholder: 'XL2596S-5.0',
options: [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }],
default: 'LM2596S-ADJ',
});
expect(params[1]).toMatchObject({ type: 'select', options: [{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }] });
});
it('提交单个字符串包成一元数组;传数组也照收;空的按必填拦;select 逐项对候选', async () => {
const specs = fieldSpecsOf(INPUT);
const params = await launchParamsOf(INPUT, noResolve);
expect(validateLaunchParams(params, { candidateInternalPns: 'XL2596S-5.0' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0'] } });
expect(validateLaunchParams(params, { candidateInternalPns: ['XL2596S-5.0', ' LM2596S-ADJ '] }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] } });
const empty = validateLaunchParams(params, { candidateInternalPns: ' ' }, specs);
expect(empty).toEqual({ ok: false, issues: [{ key: 'candidateInternalPns', message: '必填' }] });
const off = validateLaunchParams(params, { candidateInternalPns: 'x', tags: ['a', 'z'] }, specs);
expect(off).toEqual({ ok: false, issues: [{ key: 'tags', message: '不在候选里(单选只能选候选)' }] });
expect(validateLaunchParams(params, { candidateInternalPns: 'x', tags: 'b' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['x'], tags: ['b'] } });
});
});
describe('validateLaunchParams', () => {
it('必填空 / 未知键 / 类型不符 / select 不在候选 各点名一格;combo 候选之外合法', async () => {
const specs = fieldSpecsOf(W05_INPUT);
const params = await launchParamsOf(W05_INPUT, async () => [{ value: 'CU-0142', label: 'Itelma' }]);
const bad = validateLaunchParams(params, { customerCode: 'CU-NEW', requestDescription: '', candidateInternalPns: [' '], oops: 'x' }, specs);
expect(bad.ok).toBe(false);
if (!bad.ok) {
expect(bad.issues.map((i) => i.key).sort()).toEqual(['candidateInternalPns', 'oops', 'originalInternalPn', 'requestDescription']);
}
const typed = validateLaunchParams(params, { customerCode: ['CU-1'], requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: 'PN-1' }, specs);
expect(typed.ok).toBe(false);
// 标量格传数组是错;list 格传单个字符串**照收**(包成一元数组)
if (!typed.ok) expect(typed.issues).toEqual([{ key: 'customerCode', message: '要传字符串' }]);
const wrapped = validateLaunchParams(params, { customerCode: 'CU-1', requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: ' PN-1 ' }, specs);
expect(wrapped).toMatchObject({ ok: true, data: { candidateInternalPns: ['PN-1'] } });
const good = validateLaunchParams(
params,
{ customerCode: 'CU-NEW', customerName: '', requestDescription: ' 客户要求验证替代料 ', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', '', ' LM2596S-ADJ '] },
specs,
);
expect(good).toEqual({
ok: true,
// 可选空串不落入参;list 逐项去空白、丢空项;combo 候选之外原样收
data: { customerCode: 'CU-NEW', requestDescription: '客户要求验证替代料', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] },
});
});
it('select 只认候选;整数按清单类型转回数字', async () => {
const specs = fieldSpecsOf(E04_INPUT);
const params = await launchParamsOf(E04_INPUT, noResolve);
const bad = validateLaunchParams(params, { trigger: 'weekly' }, specs);
expect(bad.ok).toBe(false);
if (!bad.ok) expect(bad.issues[0]).toMatchObject({ key: 'trigger', message: expect.stringContaining('候选') });
expect(validateLaunchParams(params, { trigger: 'event', budget: '12' }, specs)).toEqual({ ok: true, data: { trigger: 'event', budget: 12 } });
const nan = validateLaunchParams(params, { trigger: 'event', budget: '1.5' }, specs);
expect(nan.ok).toBe(false);
});
});