A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了

活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:28:31 +08:00
co-authored by Claude Opus 5
parent 85466e815f
commit ade0fa5a94
90 changed files with 17969 additions and 2316 deletions
@@ -0,0 +1,499 @@
/**
* A4 成本定价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 出口载荷 / 剧本声明 / 两把尺的档位)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
import { createMockHost } from '@sdk';
import { P01_DMN, manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('监听时限已配 —— 无悬空监听', () => {
expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A3 的 quotation 口)发的是
* `{rfqRef, rows}`,而上一版必填 `rfqRef + customerCode + records`,
* 门口拦的恰是主路。归一在活动内部做。
*/
it('起单宽进:input.required 是空的,三种来路的键都声明着', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
for (const key of ['rfqRef', 'rows', 'records', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **拟报单价是必填** —— 这是 A4 的门面判据:件上的缺价红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('拟报单价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'quoted_price')?.required).toBe(true);
});
/*
* **建议价与拟报单价分两列是结构治,不是措辞治**(2026-08-03 单飞案例逼出来的):
* 只有一列价时,剧本写「算不出的留空」,模型照样给库里查不到的料编了价 ——
* 一条要它分辨「算得出/算不出」的规矩会走岔;「这一列你不写」没有分支可走岔。
*/
it('建议价与拟报单价是两列 —— AI 写建议价,人写拟报单价', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
const suggested = draft?.fields.find((f) => f.name === 'suggested_price');
expect(suggested, '缺 suggested_price 这一列').toBeDefined();
expect(suggested?.required, '建议价不该是必填 —— 算不出就该空着').toBeUndefined();
for (const step of ['S1_price', 'S3_gate']) {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === step)?.guide ?? '';
expect(guide, `${step} 没写死「quoted_price 你一格都不写」`).toMatch(/quoted_price[^\n]*一格都不写|一格都不写[^\n]*quoted_price/);
}
});
it('门禁处置给了取值域 —— 三档条数由基座现数,不由活动自报', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'disposition')?.tally).toEqual(['PASS', 'NOTE', 'BLOCK']);
});
/*
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
* 客户目标价才是「客户该给而没给的」那一问。
*/
it('expect 只标客户该给的那一格,不标我方写的注', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
expect(draft?.fields.find((f) => f.name === 'target_price')?.expect).toBe(true);
expect(draft?.fields.find((f) => f.name === 'price_note')?.expect).toBeUndefined();
});
it('三条常备案例形状真不同:寻源转来 / 客户压价超限 / 单飞无基准', () => {
expect(manifest.samples.length).toBe(3);
const [flowIn, overLimit, solo] = manifest.samples;
expect((flowIn?.input as { rows?: unknown[] }).rows?.length).toBeGreaterThan(0);
expect((overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['target_price']).toBeLessThan(
(overLimit?.input as { rows?: Array<Record<string, number>> }).rows?.[0]?.['base_price'] ?? 0,
);
expect((solo?.input as { rows?: unknown[] }).rows).toBeUndefined();
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/* 照 MVP 示例工作流 §1.2 的 S1 取价基准 / S2 逐条定价 / S3 底价自检,一步一项 */
it('计划项照 SOP 一步一项:算建议价 + 交人定价 + 底价自检 + 超限拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_price', 'S2_review', 'S3_gate', 'T_approve', 'T_release']);
});
it('三处人的门都标了人工激活:BD 定价 · 超限拍板 · 草案放行(底价自检是算术,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_review', 'T_approve', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **超限才叫人** —— 这个活动的要害:哨兵读基座按值现数的拦截档条数,
* 而不是活动自报的 `over_total`;且要等 BD 核过价(他自己回调掉的不必再惊动第二个人)。
*/
it('超限拍板的哨兵读基座现数,且等人核过价 —— 不是每条都叫人,也不是一条都不叫', () => {
const condition = sentryIf(model.cmmnXml, 's_over');
expect(condition).toContain('quote_pricing_draft_disposition_block > 0');
expect(condition).toContain('confirmed_total');
// 有拦截档且人核过价 → 叫人
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1, confirmed_total: 2 })).toBe(true);
// 拦截档清零 → 不叫人(这一步整个不入场)
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 0, confirmed_total: 2 })).toBe(false);
// 人还没核价 → 先别叫(他自己回调掉的那几条不该再惊动一个人)
expect(safeEval(condition, { quote_pricing_draft_disposition_block: 1 })).toBe(false);
});
/*
* 入场哨兵也归「机检只读基座数」管(第四次违反就藏在哨兵里)——
* 上一版 `s_priced` 读活动自报的 `draft_total`:模型不记这个账,门就永不开。
*/
it('交人核价的入场哨兵读基座行数,不读自报的草案条数', () => {
const condition = sentryIf(model.cmmnXml, 's_priced');
expect(condition).toContain('quote_pricing_draft_rows');
expect(condition).not.toContain('draft_total');
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2, priced_total: 2 })).toBe(true);
// 行落了但算价那一轮还没走完(priced_total 没写过)→ 门不开
expect(safeEval(condition, { line_total: 2, quote_pricing_draft_rows: 2 })).toBe(false);
});
it('放行门哨兵带 ifPart:每行有价 + 处置守恒 + 拦截清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_pricing_draft_missing = 0');
expect(condition).toContain('quote_pricing_draft_disposition_block = 0');
const done = {
route_decided_total: 1,
quote_pricing_draft_rows: 3,
quote_pricing_draft_missing: 0,
quote_pricing_draft_disposition_pass: 2,
quote_pricing_draft_disposition_note: 1,
quote_pricing_draft_disposition_block: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺拟报价 → 门不开
expect(safeEval(condition, { ...done, quote_pricing_draft_missing: 1 })).toBe(false);
// 还有条目压在拦截档 → 门不开(哪怕守恒仍成立)
expect(
safeEval(condition, { ...done, quote_pricing_draft_note: 0, quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 }),
).toBe(false);
// 处置没记齐(守恒不成立)→ 门不开
expect(safeEval(condition, { ...done, quote_pricing_draft_disposition_note: 0 })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
expect(model.narrative, topic).toContain(topic);
}
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('editableAtRelease 的列都在成果表的声明里', () => {
const draft = manifest.declarations.ledger.find((d) => d.object === 'draft');
const names = new Set((draft?.fields ?? []).map((f) => f.name));
for (const d of manifest.contract.requiredDeliverables) {
for (const col of d.editableAtRelease ?? []) {
expect(names.has(col), `editableAtRelease 里的 ${col}`).toBe(true);
}
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
/*
* **机检必须至少有一边是基座数**(违反史已记四次,每次都是同一个死法:
* 那个数要模型自己重算,漏算/改小一次门就废了)。
*/
it('每条 L1/L2 都至少读一个基座读数 —— 不许两边都由活动自报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
expect(names.some((n) => rowReadings.has(n)), `${c.key} 一个基座读数都没读`).toBe(true);
}
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
for (const port of manifest.contract.output.mandatory) {
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true);
}
}
});
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
for (const skill of manifest.assets.skills) {
for (const fragment of skill.crystallized) {
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
}
}
});
it('L3 判据的整句出现在放行剧本里 —— judge 才对得上名', () => {
const l3 = manifest.contract.acceptanceCriteria.find((c) => c.level === 'L3');
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(l3).toBeDefined();
expect(release?.guide, 'T_release 的 guide 要整句抄 L3 判据名').toContain(l3?.label ?? '');
});
});
/*
* 两把尺逐行求值 —— 能算的不问 LLM(规范 §5.6)。
* 出参就是台账 `disposition` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('利润模型 P-01 —— 客户等级 × 类别 → 毛利档', () => {
const table = P01_DMN.tables.find((t) => t.id === 'p01-margin');
it('战略客户 + A 类 → 8%~10%(量换价那一档)', () => {
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'A' })).toMatchObject({ outputs: [0.08, 0.1] });
});
it('普通客户 + A 类 → 12%~15%', () => {
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'A' })).toMatchObject({ rule: 'p01-2' });
});
it('B 类不看客户等级 → 15%~20%(战略客户的 B 类也走这一档)', () => {
expect(decideBy(table, { customer_grade: '战略客户', type_tag: 'B' })).toMatchObject({ outputs: [0.15, 0.2] });
});
it('C 类替代/停产件 → 20%~50% 一事一议', () => {
expect(decideBy(table, { customer_grade: '普通客户', type_tag: 'C' })).toMatchObject({ rule: 'p01-4' });
});
it('等级与类别都认不出 → 兜底按普通客户标准件档,不是拒绝求值', () => {
expect(decideBy(table, {})).toMatchObject({ rule: 'p01-default', outputs: [0.12, 0.15] });
});
});
describe('底价门禁 P-02 —— 偏离底价线两成即止', () => {
const table = P01_DMN.tables.find((t) => t.id === 'p02-floor');
it('在底价线之上 → PASS 直接放行', () => {
expect(decideBy(table, { deviation: 0.12 })).toMatchObject({ outputs: ['PASS', true] });
expect(decideBy(table, { deviation: 0 })).toMatchObject({ outputs: ['PASS', true] });
});
it('低于底价线两成以内 → NOTE 附理由放行', () => {
expect(decideBy(table, { deviation: -0.08 })).toMatchObject({ outputs: ['NOTE', true] });
});
it('低于两成以上 → BLOCK 拦截,人拍板', () => {
expect(decideBy(table, { deviation: -0.25 })).toMatchObject({ outputs: ['BLOCK', false] });
});
it('三个处置码与台账取值域逐字一致 —— 模型照抄即可写回', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'draft')
?.fields.find((f) => f.name === 'disposition')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_price')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
it('毛利四档三处都写着同样的数', () => {
for (const band of ['8%~10%', '12%~15%', '15%~20%', '20%~50%']) {
expect(guide, `剧本少了 ${band}`).toContain(band);
expect(sheet, `工作单少了 ${band}`).toContain(band);
}
});
/* 门禁在人定完价之后才算得出来,所以三个码写死在底价自检那一步的 guide 里 */
it('三个处置码剧本里写死(大写,一个字母不许差)', () => {
const gate = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_gate')?.guide ?? '';
for (const code of ['PASS', 'NOTE', 'BLOCK']) expect(gate).toContain(code);
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:定价工作单(干活)与报价草案清单(收口)', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['pricing-worksheet', 'pricing-roster']);
});
it('剧本每一步的 view 都在模版名单里 —— 换不到画面屏上就不动', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是验收目标件 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('pricing-roster');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('定价工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('底价线');
expect(sheet).toContain('利润模型 P-01');
expect(sheet).toContain('底价门禁 P-02');
expect(sheet).toContain('data-edit="quote_pricing.draft"');
});
it('缺价红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['pricing-worksheet', 'pricing-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_pricing_draft_missing');
expect(html, name).toContain('quote_pricing_draft_missing_keys');
}
});
/*
* 编辑面精确等于 BD 的职权面(定价与让价理由)+ **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*/
it('编辑面 = 拟报单价 + 让价理由 + 行的钥匙 pn', () => {
for (const name of ['pricing-worksheet', 'pricing-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols), name).toEqual(new Set(['pn', 'quoted_price', 'price_note']));
}
});
it('门禁分布读的是基座按值现数的三段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
for (const code of ['pass', 'note', 'block']) {
expect(roster).toContain(`quote_pricing_draft_disposition_${code}`);
}
});
/*
* A4 只有一个出口(E8 无条件边),**没有去向要判** —— 件上不摆判定表,
* 也不许残留 `case:route*` 的空壳(没有选择组它永远是空的,一屏灰行像判定没跑)。
*/
it('单出口活动:没有选择组,件上也不摆去向判定', () => {
expect(manifest.contract.output.selective).toEqual([]);
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
expect(roster).not.toContain('case:routeKey');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'pricing-roster')?.html ?? '';
expect(roster).toContain('<i data-class-of="disposition"></i>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-pricing',
version: '1.0.0',
data: { rfqRef: 'RFQ MD14.1', rows: [] },
message: undefined,
outputPorts: { mandatory: ['draft'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-pricing');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
function decideBy(
table: (typeof P01_DMN)['tables'][number] | undefined,
scope: Record<string, unknown>,
): { outputs: readonly unknown[]; rule: string } | undefined {
if (table === undefined) return undefined;
for (const rule of table.rules) {
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
if (hit) return { outputs: rule.then, rule: rule.id };
}
return undefined;
}
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,248 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, PRICING_PHASES, PRICING_STEPS } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of PRICING_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(PRICING_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(PRICING_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的三种局面:寻源转来 / 受理直达 / 单飞 —— 台账一行没有 */
const FRESH_SOURCED: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', base_price: 0.058 }],
};
const FRESH_INTAKE: Record<string, unknown> = {
rfqRef: 'RFQ-2026-0555',
records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: '两颗料报个价', body: 'XCJ-7788T 8K……' };
it('**开局一步都不该被自动收掉** —— 三种开局都要 false', () => {
for (const fresh of [FRESH_SOURCED, FRESH_INTAKE, FRESH_SOLO]) {
for (const step of PRICING_STEPS) {
if (step.doneWhen === undefined) continue;
expect(
safeEval(step.doneWhen, fresh),
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`,
).toBe(false);
}
}
});
/* ── 算价那一步 ─────────────────────────────────────────────── */
const priced = (over: Record<string, unknown>): Record<string, unknown> => ({
line_total: 2,
quote_pricing_draft_rows: 2,
priced_total: 2,
...over,
});
it('整份落表、行数对上、算价那一轮走完 → 收得掉', () => {
expect(safeEval(doneOf('S1_price'), priced({}))).toBe(true);
});
it('**全是无基准的单子也收得掉** —— 一条都算不出时 priced_total 是写过的 0', () => {
expect(safeEval(doneOf('S1_price'), priced({ priced_total: 0 }))).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— priced_total 没写过,未绑定的名字比大小为假', () => {
const scope = priced({});
delete scope['priced_total'];
expect(safeEval(doneOf('S1_price'), scope)).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(safeEval(doneOf('S1_price'), priced({ quote_pricing_draft_rows: 1 }))).toBe(false);
});
/* ── 三处不给 doneWhen ──────────────────────────────────────── */
it('**交人定价、超限拍板、放行三项都不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
for (const item of ['S2_review', 'T_approve', 'T_release']) {
expect(PRICING_STEPS.find((s) => s.item === item)?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
/* ── 底价自检:判完了才算办完(收步判据与验收 L2 同源)────────────── */
const gated = (over: Record<string, unknown>): Record<string, unknown> => ({
quote_pricing_draft_rows: 3,
quote_pricing_draft_disposition_pass: 2,
quote_pricing_draft_disposition_note: 1,
quote_pricing_draft_disposition_block: 0,
...over,
});
it('三档处置的条数加起来等于行数 → 底价自检收得掉', () => {
expect(safeEval(doneOf('S3_gate'), gated({}))).toBe(true);
});
it('**有拦截档也照样收得掉** —— 判出来了就是判完了,拦截档由下一步叫人拍板', () => {
expect(
safeEval(doneOf('S3_gate'), gated({ quote_pricing_draft_disposition_note: 0, quote_pricing_draft_disposition_block: 1 })),
).toBe(true);
});
it('还有行没判 → 收不掉(松出来的那一截就是放行门前的返工)', () => {
expect(safeEval(doneOf('S3_gate'), gated({ quote_pricing_draft_disposition_note: 0 }))).toBe(false);
});
it('**一行都还没写时 0+0+0 = 0 不算判完** —— 全零相等那一坑,靠 rows > 0 挡住', () => {
expect(
safeEval(doneOf('S3_gate'), {
quote_pricing_draft_rows: 0,
quote_pricing_draft_disposition_pass: 0,
quote_pricing_draft_disposition_note: 0,
quote_pricing_draft_disposition_block: 0,
}),
).toBe(false);
});
it('归一那一步声明了 reads: rows 与 records —— 上游载荷置前不截断', () => {
const reads = PRICING_STEPS.find((s) => s.item === 'S1_price')?.reads ?? [];
expect(reads).toContain('rows');
expect(reads).toContain('records');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of PRICING_STEPS) {
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
}
});
/*
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(实测连发四张,
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
*/
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
const guide = PRICING_STEPS.find((s) => s.item === 'T_approve')?.guide ?? '';
const branch = guide.indexOf('已经有');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_approve 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_approve 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of PRICING_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
});
describe('剧本声明的东西活动真有', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of PRICING_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of PRICING_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool}`).toContain(tool);
}
}
});
it('每一步都定死了摆哪张件,且模版真在包里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of PRICING_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of PRICING_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
}
});
/* 卡面是纯文本:星号记号不会变粗体,只会原样摆在人眼前(规范 §5.5-10) */
it('发卡骨架里不写星号记号 —— 卡面是纯文本', () => {
for (const step of PRICING_STEPS) {
for (const line of step.guide.split('\n')) {
if (!/"steps"|"standard"|"ask"|"label"|"consequence"/.test(line)) continue;
expect(line.includes('**'), `${step.item} 的卡面文案带了星号:${line.slice(0, 60)}`).toBe(false);
}
}
});
});
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(PRICING_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = PRICING_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = PRICING_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项', () => {
const ids = new Set(PRICING_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
function doneOf(item: string): string {
const found = PRICING_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}