/** * A3 寻源询价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。 * 案例活动没有 `run`,自测不跑回合;测的是清单本身与三处对齐 * (判据谓词 / 出口载荷 / 剧本声明)。 */ import { describe, expect, it } from 'vitest'; import { activityManifestSchema } from '@contracts'; import { evaluateCondition } from '@sfeel'; import { createMockHost } from '@sdk'; import { manifest } from '../src/manifest.ts'; import activity from '../src/index.ts'; describe('清单本身合规', () => { it('过 @contracts 的 schema', () => { expect(() => activityManifestSchema.parse(manifest)).not.toThrow(); }); it('监听时限已配 —— 无悬空监听(AM 4.4)', () => { 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(); } }); /* * MVP 口径:A3 **没有判定表**。上一版的 STRATEGY_DMN 声明着三个入参而台账没有承载、 * 剧本零调用。取价规则(A 取历史价 / B 问货源价)写死在剧本。 */ it('没有声明无求值器的判定表 —— 取价规则写死在剧本', () => { expect(manifest.assets.businessModel.dmn.tables).toEqual([]); const normalize = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_normalize'); expect(normalize?.guide).toContain('HIST'); }); /* * **底价是必填** —— 这是 A3 的门面判据:件上的缺价红带、执行卡的提交闸、 * 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。 */ it('底价标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => { const quoteList = manifest.declarations.ledger.find((d) => d.object === 'quote_list'); expect(quoteList?.fields.find((f) => f.name === 'base_price')?.required).toBe(true); }); }); describe('案例模型 —— 双表示同版本', () => { const model = manifest.assets.businessModel; const cmmn = parseCmmnLite(model.cmmnXml); it('CMMN 与说明书同在,不允许分叉', () => { expect(model.cmmnXml).toContain(' { expect(items(model.cmmnXml)).toEqual(['S1_normalize', 'S2_fill', 'S3_compile', 'T_release']); }); it('两处人的门都标了人工激活:PRM 补价 · 清单放行', () => { for (const id of ['S2_fill', 'T_release']) { const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`)); expect(block.slice(0, block.indexOf('')), `${id} 少了 manualActivationRule`).toContain( ' { for (const tag of [' { expect(cmmn.repetition).toContain('S1_normalize'); }); it('放行门哨兵带 ifPart:每行有价 + 价源守恒 + 定盘记过,全读基座数', () => { const condition = sentryIf(model.cmmnXml, 's_ready'); expect(condition).toContain('quote_sourcing_quote_list_missing = 0'); expect(condition).toContain('quote_sourcing_quote_list_price_basis_hist + quote_sourcing_quote_list_price_basis_sourced'); const done = { route_decided_total: 1, quote_sourcing_quote_list_rows: 3, quote_sourcing_quote_list_missing: 0, quote_sourcing_quote_list_price_basis_hist: 1, quote_sourcing_quote_list_price_basis_sourced: 2, }; expect(safeEval(condition, done)).toBe(true); // 还有一行缺底价 → 门不开 expect(safeEval(condition, { ...done, quote_sourcing_quote_list_missing: 1 })).toBe(false); // 价源没记齐(守恒不成立)→ 门不开 expect(safeEval(condition, { ...done, quote_sourcing_quote_list_price_basis_sourced: 1 })).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 quoteList = manifest.declarations.ledger.find((d) => d.object === 'quote_list'); const names = new Set((quoteList?.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('出口载荷的标量字段都有台账列承载', () => { 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 ?? ''); }); }); describe('报告模版 —— 活动持有,容器无脚本执行权', () => { it('两个局面各一张:询价工作单(干活)与目标报价清单(收口),同一局面在办与归档同一张', () => { expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['sourcing-worksheet', 'sourcing-roster']); }); /* * 执行阶段的件必须是**询价工作台**,不是验收单(A2 同款口径,2026-08-02)。 * 工作单四要件:逐颗询价档案(题面含取价对象与客户约束)、按场景圈渠道的规则、 * 价格验证的尺、填回工作表 —— 素材出处 MVP 示例工作流 §1.7,只作文字参考。 */ it('询价工作单是干活的件:档案卡 + 场景圈渠道 + 验价的尺 + 时限', () => { const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-worksheet')?.html ?? ''; expect(sheet).toContain('jc-selcard'); expect(sheet).toContain('data-slot="remark"'); expect(sheet).toContain('取价对象'); for (const scene of ['BOM 早期', 'BOM 后期', '现货', '样品']) expect(sheet).toContain(scene); expect(sheet).toContain('无库存转单商不可选'); expect(sheet).toContain('历史最低价降两成'); }); it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => { for (const template of manifest.assets.viewTemplates) { expect(template.html, template.name).not.toMatch(/'); } }); /* * A3 只有一个出口(E7 无条件边),**没有去向要判** —— 件上不摆判定表, * 也不许残留 `case:route*` 的空壳(没有选择组它永远是空的,一屏灰行像判定没跑)。 */ it('单出口活动:没有选择组,件上也不摆去向判定', () => { expect(manifest.contract.output.selective).toEqual([]); const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? ''; expect(roster).not.toContain('case:routeKey'); }); it('缺价红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => { const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? ''; expect(roster).toContain('quote_sourcing_quote_list_missing'); expect(roster).toContain('quote_sourcing_quote_list_missing_keys'); }); it('编辑面 = PRM 的价格五列 + 行的钥匙 pn —— 保存按格里的主键认行(A2 走查逮过同款)', () => { const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? ''; const cols = [...roster.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]); expect(new Set(cols)).toEqual(new Set(['pn', 'supplier', 'base_price', 'moq', 'source_lead_time', 'note'])); }); it('价源分布读的是基座按值现数的两段 —— 守恒等式的可视形', () => { const roster = manifest.assets.viewTemplates.find((t) => t.name === 'sourcing-roster')?.html ?? ''; expect(roster).toContain('quote_sourcing_quote_list_price_basis_hist'); expect(roster).toContain('quote_sourcing_quote_list_price_basis_sourced'); }); it('宿主拒推含脚本的模版', async () => { const host = createMockHost({ contract: CONTRACT }); await expect(host.base.interaction.pushHtml('
')).rejects.toThrow(/无脚本执行权/); }); }); const CONTRACT = { activityId: 'quote-sourcing', version: '1.0.0', data: { rfqRef: 'RFQ MD14.1', rows: [] }, message: undefined, outputPorts: { mandatory: ['quotation'], selective: [] }, prohibitions: [], discretion: [], } as const; describe('代码入口 —— 案例活动没有 run,不是残包', () => { it('默认导出是一个有名字的活动定义', () => { expect(activity.name).toBe('quote-sourcing'); }); it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => { const host = createMockHost({ contract: CONTRACT }); await expect(host.run(activity)).rejects.toThrow(/没有 run/); }); }); 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; } }