/** * 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(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 = { rfqRef: 'RFQ MD14.1', rows: [{ pn: 'BBPY00201209601Y00', type_tag: 'B', base_price: 0.058 }], }; const FRESH_INTAKE: Record = { rfqRef: 'RFQ-2026-0555', records: [{ pn: 'PESD2ETH-XR', type_tag: 'B' }], }; const FRESH_SOLO: Record = { 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): Record => ({ 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): Record => ({ 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): boolean { try { return evaluateCondition(expression, scope); } catch { return false; } }