/** * 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; 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> }).rows?.[0]?.['target_price']).toBeLessThan( (overLimit?.input as { rows?: Array> }).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(' { 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 [' { 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 }).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(/'); } }); 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(''); }); it('宿主拒推含脚本的模版', async () => { const host = createMockHost({ contract: CONTRACT }); await expect(host.base.interaction.pushHtml('
')).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, ): { 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(//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(`([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('')))?.[1] ?? ''; return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&'); } function safeEval(expression: string, scope: Record): boolean { try { return evaluateCondition(expression, scope); } catch { return false; } }