/** * 步骤剧本 —— **这段错了不报错,只是活动开始转圈**。 * * A1 反复空转的根因是没有它:S1 一次把八字段写齐之后,S3「补齐八字段」已经无活可干, * 而每一拍喂给模型的是整本说明书,它只能把同样三行再写一遍 —— * 被「写了等于没写」拒回,下一拍再来一遍,连拒到熔断。 * * 剧本把这件事挪进引擎:`doneWhen` 一真,引擎自己收掉那一步,**不问模型**。 * 所以这几条断言守的是剧本本身的可用性 —— 项 id 对不对得上、判据会不会开局即真、 * 声明的台账与工具是不是活动真有的。写错任何一条,后果都是运行时才发现的空转。 */ import { describe, expect, it } from 'vitest'; import { evaluateCondition } from '@sfeel'; import { INTAKE_STEPS } from '../src/case-model.ts'; import { CMMN_XML } from '../src/case-model.ts'; import { manifest } from '../src/manifest.ts'; /** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */ const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map( (m) => m[1] ?? '', ); describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => { it('每一条剧本都指向一个真实存在的计划项', () => { for (const step of INTAKE_STEPS) { expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item); } }); it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => { for (const id of ITEM_IDS) { expect(INTAKE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id); } }); it('清单里挂的就是这份剧本(不是另抄一份)', () => { expect(manifest.assets.businessModel.steps).toBe(INTAKE_STEPS); }); }); describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => { /** 开案那一刻的局面:台账一行没有,汇总列全无值 */ const FRESH: Record = { customerEmail: 'buyer@x.com', subject: '【询价】RFQ-2026-0001', body: 'LM358DR 12K/年', }; it('**开局一步都不该被自动收掉** —— 这是最容易写错的地方', () => { for (const step of INTAKE_STEPS) { if (step.doneWhen === undefined) continue; const ripe = safeEval(step.doneWhen, FRESH); expect(ripe, `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`).toBe(false); } }); /* ── ① 定位客户信息 ─────────────────────────────────────────────── */ it('**老客户**认出来了 → S1 收得掉(不必问人)', () => { expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 0 })).toBe(true); }); /* * 这一条是拿一次实测换来的:剧本白纸黑字写着「别拿邮箱域名凑一个客户编码,也别自己编」, * 模型照样从 purchasing@sz-machinery.com 编出 `CUST-SZ-MACH` 直接把档建了 —— * 人根本没被问过一句,而客户编码是主数据的主键,编错一次后面每一单都跟着错。 * **说明书管不住,收步条件管**:新客户没问过人,这一步就收不掉。 */ it('**新客户没问过人 → S1 收不掉**(说明书管不住的,让收步条件管)', () => { expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1 })).toBe(false); }); it('新客户问过人了 → S1 收得掉', () => { expect( safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1, customer_asked_total: 1 }), ).toBe(true); }); /* * 这一条守的是那个老坑:**不能写 `customer_code != null`** —— * S-FEEL 里未绑定的名字 `!= null` 求值为 true,这一步会在还没认人之前就被收掉。 * 判据必须取同步写下的计数列。 */ it('还没认到客户 → S1 收不掉,归一也进不了场', () => { expect(safeEval(doneOf('S1_customer'), FRESH)).toBe(false); expect(safeEval(doneOf('S2_normalize'), FRESH)).toBe(false); }); /* ── ② 归一客户 RFQ 输入 ────────────────────────────────────────── */ const normalized = (over: Record) => ({ ...FRESH, customer_total: 1, line_total: 3, // 拆出来几行就报几条 —— 收步与验收同源(见下面那条「多写一行」) quote_intake_record_rows: 3, missing_key_total: 0, missing_minor_total: 0, asked_total: 0, ...over, }); it('拆出行来了 → S2 收得掉(拆料是 AI 的活,办完交给下一步请人)', () => { expect(safeEval(doneOf('S2_normalize'), normalized({}))).toBe(true); }); /* * **缺不缺字段不再是 S2 的收步条件** —— 缺口是卡上要说清的事,不是拦住拆料的理由。 * 从前把「缺了就得问过人」写进这里,连带出下面那条真正的病: * 不缺就等于不必经过人。现在分开:S2 只管拆,S3_confirm 管「经过人」。 */ it('缺必填 / 缺选填都不拦 S2 —— 那是 S3 卡上的事', () => { expect(safeEval(doneOf('S2_normalize'), normalized({ missing_key_total: 2 }))).toBe(true); expect(safeEval(doneOf('S2_normalize'), normalized({ missing_minor_total: 2 }))).toBe(true); }); it('一条都没归一出来 → S2 收不掉', () => { expect(safeEval(doneOf('S2_normalize'), normalized({ line_total: 0 }))).toBe(false); }); /* * **多写一行 → S2 就收不掉**(2026-08-01 加)。 * * 实测:客户明说本轮跳过的那颗料被写进了有效清单 —— 数报得全对(9 有效 / 1 跳过), * 行却有 10 条。从前 S2 只看 `line_total > 0`,照收;错要到第 10 拍提交放行时 * 才被同一条等式拦住,那时模型只会去改数(改了 skipped_total 又不对),连拒五拍熔断。 * **同一个错,在第 2 拍发现比在第 10 拍便宜得多**:那时行刚写下,多的是哪一行一目了然。 */ it('**多写一行(如把客户说跳过的那条也写了)→ S2 收不掉**,错在第 2 拍就现形', () => { expect(safeEval(doneOf('S2_normalize'), normalized({ quote_intake_record_rows: 4 }))).toBe(false); }); /* ── ③ 受理需求交人审定:阶段2 那张执行页,一单必经一次 ─────────── */ /* * **这一条是用户 2026-08-01 当场逼出来的**:「我都没确认需求 它就让我验收了」。 * * 病根在旧口径:「经过人」是靠 `missing_minor_total = 0 or answered_total > 0` * 表达的 —— 只有缺东西的时候才叫人。字段齐全的一封询价进来,这个条件在拆完那一刻 * 就为真,引擎自己把归一那步收了,人一眼没看过受理需求, * 下一次被叫到已经是终局的验收放行。**确认不是缺口的补丁,是一道恒在的门。** * * 所以这一步跟放行同一个做法:**人工激活 + 不给 doneWhen**。 * 给它写 doneWhen 就是给引擎一把替人签字的钥匙; * 而「人看过、改过、认了」按定义也不是台账里任何一个数能自己变出来的。 */ it('**受理需求那一步不给 doneWhen** —— 引擎不许替人认下这份稿', () => { expect(INTAKE_STEPS.find((s) => s.item === 'S3_confirm')?.doneWhen).toBeUndefined(); }); it('审定这一步在剧本里,且摆的是阶段2 那份目标件', () => { const confirm = INTAKE_STEPS.find((s) => s.item === 'S3_confirm'); expect(confirm, '受理需求交人审定这一步不见了').toBeDefined(); expect(confirm?.view).toBe('intake-overview'); // 卡上那张表绑台账整份铺开 —— 人改的是九行,不是缺的那一两行 expect(confirm?.guide).toContain('"ledger":"quote_intake.record"'); expect(confirm?.guide).toContain('confirmed_total'); }); /* * **收步判据与验收判据同源**(2026-08-01 改)。 * * 从前 S5 只看自报的 `untyped_total = 0`、S6 只看「写过 alt_request_total 这个数」—— * 数写对了就收步,而验收比的是**基座数出来的行**(`type_table_rows = record_rows`、 * `alt_request_rows = type_c_total`)。两处比的不是同一件事,松的那一截就是返工: * 收了步却过不了验收,模型得回头补写一张已经收掉的表。 */ it('打完标且两张表行数对上 → S5 收得掉', () => { const typed = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 3, quote_intake_record_rows: 3 }; expect(safeEval(doneOf('S5_classify'), typed)).toBe(true); }); it('**数归零了但表没写全 → S5 收不掉** —— 那正是从前「收了步却过不了验收」的口子', () => { const short = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 1, quote_intake_record_rows: 3 }; expect(safeEval(doneOf('S5_classify'), short)).toBe(false); }); /* * **S6 不再开单**(2026-08-02 用户当场定:「替代评估请求 / 询价请求这两项数据 * 不在这里产出;随令牌流向下一个节点的只有工作成果验收内的内容」)。 * 收步判据随之改成「两枚旗立过了」——它仍取**计数**而非布尔: * 一单全 A 类时两枚旗都是 false,「写了 false」与「压根没写」分不清。 */ it('去向定了 → S6 收得掉', () => { expect(safeEval(doneOf('S6_route'), { untyped_total: 0, route_decided_total: 1 })).toBe(true); }); it('**没记过那个数 → S6 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => { expect(safeEval(doneOf('S6_route'), { untyped_total: 0 })).toBe(false); }); it('全 A 类(两枚旗都是 false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => { const allA = { untyped_total: 0, route_decided_total: 1, need_alternative: false, need_sourcing: false }; expect(safeEval(doneOf('S6_route'), allA)).toBe(true); }); it('**S6 不写请求单** —— 那两张表已经不归 A1 产出', () => { const writes = INTAKE_STEPS.find((x) => x.item === 'S6_route')?.writes ?? []; expect(writes).toEqual(['quote_intake.verdict']); }); /* * 放行触达世界 —— 它必须由人按一下(CMMN 里标了 manualActivationRule)。 * 给它写 doneWhen 就是给引擎一把自动放行的钥匙,那是铁律③ 的红线。 */ it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => { expect(INTAKE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined(); }); }); 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 INTAKE_STEPS) { for (const address of step.writes) { expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address); } } }); it('每一步要调的工具都在清单的工具声明里', () => { for (const step of INTAKE_STEPS) { for (const tool of step.tools) { expect(declaredTools, `${step.item} 要调 ${tool},而活动没声明这件工具`).toContain(tool); } } }); it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => { for (const step of INTAKE_STEPS) { expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe(''); expect(step.guide.trim().length, `${step.item} 的 guide 太短,等于没写`).toBeGreaterThan(20); } }); }); function doneOf(item: string): string { const found = INTAKE_STEPS.find((s) => s.item === item)?.doneWhen; if (found === undefined) throw new Error(`${item} 没有 doneWhen`); return found; } /** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */ function safeEval(expression: string, scope: Record): boolean { try { return evaluateCondition(expression, scope); } catch { return false; } }