forked from ChaiHao/BPOS-MVP
活动: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>
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
/**
|
|
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { evaluateCondition } from '@sfeel';
|
|
import { ARCHIVE_PHASES, ARCHIVE_STEPS, CMMN_XML } 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 ARCHIVE_STEPS) {
|
|
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
|
|
}
|
|
});
|
|
|
|
it('图上每一步都有剧本', () => {
|
|
const scripted = new Set(ARCHIVE_STEPS.map((s) => s.item));
|
|
for (const id of ITEM_IDS) expect(scripted.has(id), `${id} 没有剧本`).toBe(true);
|
|
});
|
|
|
|
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
|
|
expect(manifest.assets.businessModel.steps).toBe(ARCHIVE_STEPS);
|
|
});
|
|
});
|
|
|
|
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
|
|
const doneWhen = (item: string): string => ARCHIVE_STEPS.find((s) => s.item === item)?.doneWhen ?? '';
|
|
const evalAt = (item: string, scope: Record<string, unknown>): boolean => {
|
|
try {
|
|
return evaluateCondition(doneWhen(item), scope);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/*
|
|
* **开局一步都不该被自动收掉**:S-FEEL 里未绑定的名字参与比较为假,
|
|
* 靠的就是这一条 —— 写 `!= null` 那一步会开局即收(A1 踩过)。
|
|
*/
|
|
it('**开局一步都不该被自动收掉** —— 两种开局都要 false', () => {
|
|
// 流内开局:上游载荷在,台账一行没写
|
|
expect(
|
|
evalAt('S1_collect', {
|
|
rows: [{ pn: 'PESD2ETH-XR' }],
|
|
quote_archive_intel_rows: 0,
|
|
}),
|
|
).toBe(false);
|
|
// 单飞开局:什么都没有
|
|
expect(evalAt('S1_collect', { subject: '补个归档' })).toBe(false);
|
|
});
|
|
|
|
it('整份落表、行数对上、归一那一轮走完 → 收得掉', () => {
|
|
expect(evalAt('S1_collect', { intel_total: 2, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(true);
|
|
});
|
|
|
|
it('**第一拍写完行就收步是不许的** —— collected_total 没写过,未绑定的名字比大小为假', () => {
|
|
expect(evalAt('S1_collect', { intel_total: 2, quote_archive_intel_rows: 2 })).toBe(false);
|
|
});
|
|
|
|
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
|
|
expect(evalAt('S1_collect', { intel_total: 3, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(false);
|
|
});
|
|
|
|
/*
|
|
* **三项不给 doneWhen**:交人归因(发卡触达真人)、入库口径拍板(要人拍)、
|
|
* 归档放行(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
|
|
*/
|
|
it('**逐条归因、入库口径拍板、放行三项都不给 doneWhen**', () => {
|
|
for (const item of ['S2_attribute', 'T_scope', 'T_release']) {
|
|
const step = ARCHIVE_STEPS.find((s) => s.item === item);
|
|
expect(step, `${item} 没有剧本`).toBeDefined();
|
|
expect(step?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('归一那一步声明了 reads: rows —— 上游载荷置前不截断', () => {
|
|
expect(ARCHIVE_STEPS.find((s) => s.item === 'S1_collect')?.reads ?? []).toContain('rows');
|
|
});
|
|
|
|
/*
|
|
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
|
|
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
|
|
*/
|
|
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
|
|
for (const step of ARCHIVE_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 = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
|
|
const branch = guide.indexOf('找到了');
|
|
const skeleton = guide.indexOf('【发一张决策卡');
|
|
expect(branch, 'T_scope 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
|
|
expect(skeleton, 'T_scope 的 guide 没有发卡骨架').toBeGreaterThan(branch);
|
|
expect(guide).toMatch(/不许再发|别再发第二张卡/);
|
|
});
|
|
|
|
/*
|
|
* **拍板那三条路都得动 share_scope** —— A6 2026-08-03 逼出来的结构账:
|
|
* 有一条路写着「口径不动」,人点完了待定条数还是那个数,入场哨兵照旧成立,
|
|
* 模型读同一段做法就再发一张卡(实测同一张连发六次)。
|
|
* 「办完了」得在数上看得见,措辞治不了。
|
|
*/
|
|
it('拍板三条路各把 share_scope 改成一个结论码 —— 没有一条留在 PENDING', () => {
|
|
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
|
|
for (const code of ['FULL', 'MASKED', 'INTERNAL']) {
|
|
expect(guide, `拍板剧本少了 ${code} 这条路`).toContain(`share_scope 改 **${code}**`);
|
|
}
|
|
expect(guide).toContain('quote_archive_intel_share_scope_pending');
|
|
});
|
|
|
|
/*
|
|
* **三个结论码只有拍板那一步写得出来**:归一那一步替他写了,那张卡就永远不会发,
|
|
* 这条对外边界成了摆设(A6 实测:一单从头到尾 released 而没有人拍过板)。
|
|
*/
|
|
it('归一那一步写死「口径一律 PENDING」,三个结论码一个都不许写', () => {
|
|
const collect = ARCHIVE_STEPS.find((s) => s.item === 'S1_collect')?.guide ?? '';
|
|
expect(collect).toContain('PENDING');
|
|
expect(collect, '归一剧本没说清三个结论码不许写').toMatch(/三个码这一步一个都不许写/);
|
|
const attribute = ARCHIVE_STEPS.find((s) => s.item === 'S2_attribute')?.guide ?? '';
|
|
expect(attribute, '归因那一步不该顺手写入库口径').toContain('别在这一拍写入库口径');
|
|
});
|
|
|
|
/*
|
|
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
|
|
* 而且学不稳的礼仪就是死法。
|
|
*/
|
|
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
|
|
for (const step of ARCHIVE_STEPS) {
|
|
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
|
|
}
|
|
});
|
|
|
|
/*
|
|
* 【目标缺口】那一段摆的是计划项的**中文名**;当下可为为空(依赖断点)那几拍,
|
|
* 模型手上只剩中文名,于是把它当 itemId 写了进去 —— 「计划项不存在」连拒两拍。
|
|
* 发卡骨架旁边必须点名说清写英文 id。
|
|
*/
|
|
it('发卡骨架写死了 itemId 是英文项号,并点名中文名不是 id', () => {
|
|
const guide = ARCHIVE_STEPS.find((s) => s.item === 'T_scope')?.guide ?? '';
|
|
expect(guide).toContain('"itemId":"T_scope"');
|
|
expect(guide, '没告诉模型中文名不能当 itemId').toMatch(/别写中文名/);
|
|
for (const value of ['full', 'masked', 'internal']) {
|
|
expect(guide, `发卡骨架少了选项 ${value}`).toContain(`"${value}"`);
|
|
}
|
|
});
|
|
|
|
it('剧本不教 listen 等回音 —— 那是一条挂到天荒地老的路', () => {
|
|
for (const step of ARCHIVE_STEPS) {
|
|
expect(step.guide, `${step.item} 的 guide 教了 listen`).not.toMatch(/"kind":"listen"|立监听|发一个\s*listen/);
|
|
}
|
|
});
|
|
|
|
/* 执行卡零输入(2026-08-02 用户定):填写全在工作区的件上 */
|
|
it('执行卡零输入 —— 剧本明写不许往卡里塞 blocks / fields', () => {
|
|
const guide = ARCHIVE_STEPS.find((s) => s.item === 'S2_attribute')?.guide ?? '';
|
|
expect(guide).toMatch(/别往卡里塞/);
|
|
expect(guide).toContain('执行卡零输入');
|
|
});
|
|
});
|
|
|
|
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 ARCHIVE_STEPS) {
|
|
for (const address of step.writes) {
|
|
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('每一步要调的工具都在清单的工具声明里', () => {
|
|
for (const step of ARCHIVE_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 ARCHIVE_STEPS) {
|
|
expect(step.view, `${step.item} 没定画面`).toBeDefined();
|
|
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('每一步都说得出它交出什么', () => {
|
|
for (const step of ARCHIVE_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 ARCHIVE_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(ARCHIVE_PHASES.flatMap((p) => p.items));
|
|
|
|
it('**除放行外每一项恰好属于一个阶段**', () => {
|
|
const planned = ARCHIVE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
|
|
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
|
|
});
|
|
|
|
it('一项不进两个阶段', () => {
|
|
const all = ARCHIVE_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(ARCHIVE_STEPS.map((s) => s.item));
|
|
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
|
|
});
|
|
});
|