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-MVP/activities/quote-negotiate/__tests__/steps.test.ts
T
ChaiHaoandClaude Opus 5 ade0fa5a94 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>
2026-08-03 12:28:31 +08:00

316 lines
14 KiB
TypeScript

/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, NEGOTIATE_PHASES, NEGOTIATE_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 NEGOTIATE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本', () => {
for (const id of ITEM_IDS) {
expect(NEGOTIATE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(NEGOTIATE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的两种局面:流内转来 / 单飞 —— 台账一行没有 */
const FRESH_FLOW: Record<string, unknown> = {
rfqRef: 'RFQ MD14.1',
customerCode: 'CU-0142',
items: [{ pn: 'BBPY00201209601Y00', quoted_price: 0.085 }],
};
const FRESH_SOLO: Record<string, unknown> = { subject: 'MD14.2 报价函发客户', body: 'XCJ-7788T 8K 批准价 14.80……' };
it('**开局一步都不该被自动收掉** —— 两种开局都要 false', () => {
for (const fresh of [FRESH_FLOW, FRESH_SOLO]) {
for (const step of NEGOTIATE_STEPS) {
if (step.doneWhen === undefined) continue;
expect(safeEval(step.doneWhen, fresh), `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`).toBe(false);
}
}
});
/* ── 排版那一步 ─────────────────────────────────────────────── */
const composed = (over: Record<string, unknown>): Record<string, unknown> => ({
line_total: 2,
quote_negotiate_line_rows: 2,
composed_total: 2,
...over,
});
it('整份落表、行数对上、排版那一轮走完 → 收得掉', () => {
expect(safeEval(doneOf('S1_compose'), composed({}))).toBe(true);
});
it('**第一拍写完行就收步是不许的** —— composed_total 没写过,未绑定的名字比大小为假', () => {
const scope = composed({});
delete scope['composed_total'];
expect(safeEval(doneOf('S1_compose'), scope)).toBe(false);
});
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
expect(safeEval(doneOf('S1_compose'), composed({ quote_negotiate_line_rows: 1 }))).toBe(false);
});
/* ── 三处不给 doneWhen ──────────────────────────────────────── */
it('**发出等回音、异议拍板、放行三项都不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
for (const item of ['S2_send', 'T_haggle', 'T_release']) {
expect(NEGOTIATE_STEPS.find((s) => s.item === item)?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
}
});
/* ── 逐条定案:判完了才算办完(收步判据与验收 L2 同源)────────────── */
const settled = (over: Record<string, unknown>): Record<string, unknown> => ({
quote_negotiate_line_rows: 3,
quote_negotiate_line_deal_status_won: 2,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 0,
quote_negotiate_line_deal_status_lost: 1,
quote_negotiate_line_reply_type_counter: 0,
...over,
});
it('四档定案的条数加起来等于行数 → 逐条定案收得掉', () => {
expect(safeEval(doneOf('S3_settle'), settled({}))).toBe(true);
});
it('**有待拍板条目也照样收得掉** —— 判出来了就是判完了,还价的由下一步叫人拍板', () => {
expect(
safeEval(
doneOf('S3_settle'),
settled({
quote_negotiate_line_deal_status_lost: 0,
quote_negotiate_line_deal_status_haggle: 1,
quote_negotiate_line_reply_type_counter: 1,
}),
),
).toBe(true);
});
/*
* **模型自己写 REVISE 绕过拍板 → 这一步收不掉**(2026-08-03 实测逼出来的):
* 一轮里模型在定案那一步直接把还价的行判成「退回重批」,HAGGLE 一条没有,
* 「还价才叫人」那道门整个不开,单子一路 released 而没有人拍过板 —— 决策卡 0 张。
* 收步判据补上「待拍板条数 = 客户还价条数」,两边都是基座现数,模型改不动。
*/
it('客户还了价却没落成待拍板(自己写了 REVISE)→ 收不掉,拍板绕不过去', () => {
expect(
safeEval(
doneOf('S3_settle'),
settled({
quote_negotiate_line_deal_status_won: 1,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 1,
quote_negotiate_line_deal_status_lost: 1,
quote_negotiate_line_reply_type_counter: 1,
}),
),
).toBe(false);
});
it('还有行没判 → 收不掉(松出来的那一截就是放行门前的返工)', () => {
expect(safeEval(doneOf('S3_settle'), settled({ quote_negotiate_line_deal_status_lost: 0 }))).toBe(false);
});
it('**一行都还没写时 0+0+0+0 = 0 不算判完** —— 全零相等那一坑,靠 rows > 0 挡住', () => {
expect(
safeEval(doneOf('S3_settle'), {
quote_negotiate_line_reply_type_counter: 0,
quote_negotiate_line_rows: 0,
quote_negotiate_line_deal_status_won: 0,
quote_negotiate_line_deal_status_haggle: 0,
quote_negotiate_line_deal_status_revise: 0,
quote_negotiate_line_deal_status_lost: 0,
}),
).toBe(false);
});
it('归一那一步声明了 reads: items —— 上游载荷置前不截断', () => {
expect(NEGOTIATE_STEPS.find((s) => s.item === 'S1_compose')?.reads ?? []).toContain('items');
});
/*
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
*/
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
}
});
/*
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(A4 实测连发四张,
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
*/
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
const branch = guide.indexOf('找到了');
const skeleton = guide.indexOf('【发一张决策卡');
expect(branch, 'T_haggle 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
expect(skeleton, 'T_haggle 的 guide 没有发卡骨架').toBeGreaterThan(branch);
expect(guide).toMatch(/不许再发|别再发第二张卡/);
});
/*
* **拍板那三条路都得动 deal_status** —— 这是 2026-08-03 逼出来的结构账:
* 上一版 revise 那条写着「定案不动」,人点完了 haggle 还是那个数,
* 入场哨兵照旧成立,模型读同一段做法就再发一张卡(实测同一张连发六次)。
* 「办完了」得在数上看得见,措辞治不了。
*/
it('拍板三条路各把 deal_status 改成一个结论码 —— 没有一条留在 HAGGLE', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
for (const code of ['WON', 'REVISE', 'LOST']) {
expect(guide, `拍板剧本少了 ${code} 这条路`).toContain(`deal_status 改 **${code}**`);
}
expect(guide, '拍板剧本还留着「定案不动」这条自我循环的路').not.toContain('定案不动');
expect(guide).toContain('quote_negotiate_line_deal_status_haggle');
});
/*
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
* 而且学不稳的礼仪就是死法。
*/
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
}
});
/*
* **等回音不落 `listen`**(见 case-model 头注):`listen` 在引擎里恒挂起,
* 要外部事件才唤得醒,而这一单里回音的落单人本来就是 BD。
* 剧本里若冒出一句「listen 等回音」,模型照做就是把整单挂到时限到期。
*/
it('定案那一步写死「这一拍到此为止」—— 收掉的步上再挂行动一律被拒', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'S3_settle')?.guide ?? '';
expect(guide, 'S3_settle 没写「这一拍到此为止」').toContain('这一拍到此为止');
expect(guide, 'S3_settle 没说清决策卡属于下一步').toContain('T_haggle');
expect(guide, 'S3_settle 没写死「REVISE 这一步不写」').toMatch(/REVISE[^\n]*这一步一格都不写/);
});
/*
* 【目标缺口】那一段摆的是计划项的**中文名**;当下可为为空(依赖断点)那几拍,
* 模型手上只剩中文名,于是把它当 itemId 写了进去 —— 「计划项不存在:异议拍板」
* 连拒两拍(2026-08-03 实测)。发卡骨架旁边必须点名说清写英文 id。
*/
it('发卡骨架写死了 itemId 是英文项号,并点名中文名不是 id', () => {
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
expect(guide).toContain('"itemId":"T_haggle"');
expect(guide, '没告诉模型中文名不能当 itemId').toMatch(/别写中文名/);
for (const value of ['accept-counter', 'revise', 'hold']) {
expect(guide, `发卡骨架少了选项 ${value}`).toContain(value);
}
});
it('剧本不教 listen 等回音 —— 那是一条挂到天荒地老的路', () => {
for (const step of NEGOTIATE_STEPS) {
expect(step.guide, `${step.item} 的 guide 教了 listen`).not.toMatch(/"kind":"listen"|立监听|发一个\s*listen/);
}
});
});
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 NEGOTIATE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of NEGOTIATE_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 NEGOTIATE_STEPS) {
expect(step.view, `${step.item} 没定画面`).toBeDefined();
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
}
});
it('每一步都说得出它交出什么', () => {
for (const step of NEGOTIATE_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 NEGOTIATE_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(NEGOTIATE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段**', () => {
const planned = NEGOTIATE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段', () => {
const all = NEGOTIATE_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(NEGOTIATE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});
function doneOf(item: string): string {
const found = NEGOTIATE_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;
}
}