/** * A1 询价受理的包内自测 —— **只准 import @sdk / @contracts / @sfeel** * (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在 * `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道, * depcruise 双向隔离在守着。 * * 案例活动没有 `run`,所以自测不跑回合(那是 Loop 的事)。这里测的是**清单本身**: * ① 清单合规 + 判据等级不谎报; * ② 案例模型双表示同在、触达世界的项标了人工激活; * ③ 两张判定表逐行求值(能算的不问 LLM); * ④ **声明与契约对得上** —— 这一段最容易在改动中悄悄错位: * 必交付物的台账地址、验收谓词读的列、出口载荷要的标量,三处必须都有台账列承载。 */ import { describe, expect, it } from 'vitest'; import { activityManifestSchema } from '@contracts'; import { evaluateUnaryTest } from '@sfeel'; import { createMockHost } from '@sdk'; import { F01_DMN, INTAKE_DMN, P03_DMN, 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(); } }); }); describe('案例模型(AM 03/12.2)—— 双表示同版本', () => { const model = manifest.assets.businessModel; const cmmn = parseCmmnLite(model.cmmnXml); it('CMMN 与说明书同在,不允许分叉', () => { expect(model.cmmnXml).toContain(' { /* * 只剩放行这一件。定位客户与归一 RFQ 两步**发卡但不落地**: * 补全卡与执行卡都是「问一句」,回来的值仍旧只进草稿层, * 真正触达世界的只有放行那一下(铁律③)。 */ for (const id of ['T_release']) { expect(cmmn.manualActivation, `${id} 应为人工激活`).toContain(id); } }); /* * **图上有几步就是几项**。上一版加了两个阶段、两个里程碑、一个裁量项、 * 一个用户事件监听器 —— 12 个计划项装 6 步的活,业务一分没多,卡壳的花样多了一堆: * 每一条「行动被拒」都是结构上的,没有一条是业务判断上的(实测)。 * 阶段 / 里程碑 / 裁量项是 CMMN 有的东西,但「有」不等于「该用」。 */ /* * 2026-07-31 用户重定前两态: * ① `S1_customer` 定位客户信息 —— 按发件邮箱认人,认不出发补全卡请人建档; * ② `S2_normalize` 归一客户 RFQ 输入 —— 解析正文逐条落表。 * 旧的 S1_register / S2_ask_back / S3_normalize 三项合并进这两态。 * * 2026-08-01 补回 `S3_confirm`(**人的门,不是 AI 的步**): * 追问从前是「归一里缺字段时的动作」,于是不缺字段就不叫人 —— 一封齐整的询价 * 从起单一路自动跑到终局验收,人一眼没看过受理需求(用户当场指出)。 * 阶段2 那张执行页(`A1-询价受理需求.html`,页脚是【修改】【确认受理记录】) * 从来就是一张人签的页,所以它得是**一项**:人工激活、无 doneWhen、一单必经一次。 */ it('计划项照状态机一步一项:两态 + 人审定 + 后续三步 + 放行,不多建一件', () => { expect(items(model.cmmnXml)).toEqual([ 'S1_customer', 'S2_normalize', 'S3_confirm', 'S4_history', 'S5_classify', 'S6_route', 'T_release', ]); }); /* * **触达世界的项必须人工激活** —— 引擎不许自己按下它们。 * 受理需求那张卡交到人手上、放行那一签发出去,都是外面能看见的动作。 */ it('两处人的门都标了人工激活:受理需求审定 · 受理放行', () => { for (const id of ['S3_confirm', '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 [' { for (const id of ['S1_customer', 'S2_normalize']) { expect(cmmn.repetition, `${id} 应可重复`).toContain(id); } }); it('末尾一张验收单 —— 完成只出自放行通过', () => { expect(model.cmmnXml).toContain('id="T_release"'); }); it('能算的交 DMN,不交 Agent —— 打标是 decisionTask', () => { expect(model.cmmnXml).toContain('decisionRef="p03-type"'); expect(INTAKE_DMN.tables.some((t) => t.id === 'p03-type')).toBe(true); }); /* * 作业指导**一处一份**:逐步的做法在剧本(`steps[].guide`),整单的事在说明书。 * 从前两处各写一份六步,实测分叉过 —— 说明书里写着 S1_register / S2_ask_back / * S3_normalize 三个案例模型里根本不存在的计划项,模型照它报 itemId,一报一个被拒。 * 所以这里改测三件事:每一项都有剧本、剧本说得出做法、说明书**不许再点名计划项**。 */ 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('说明书不点名计划项 —— 点了就会与案例模型分叉(踩过)', () => { // 认的是**计划项 id 的形状**(S数字_字母 / T_字母),不是「S1 → S2」那种路线简写 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); } }); }); /** 极小取值:只为断言用,不重实现引擎的解析器(那一份在 loop-engine 里有自己的测试) */ /** 计划项 id 按出现次序 —— 断言「图上有几步就是几项」 */ 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 }; } /** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */ function decideBy(table: typeof P03_DMN, scope: Record) { 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; } describe('受理字段完整度 F-01 —— 三要素齐才放行', () => { it('缺 PN/EAU/交期任一 → 退回补全', () => { expect(decideBy(F01_DMN, { missing_key_total: 2, missing_minor_total: 0 })).toMatchObject({ outputs: ['缺PN/数量/交期', '退回补全'], rule: 'f01-2', }); }); it('只缺次要字段 → 受理并追问(不卡住)', () => { expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 3 })).toMatchObject({ outputs: ['缺次要字段', '受理并追问'], rule: 'f01-3', }); }); it('八字段齐 → 往下走', () => { expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 0 })).toMatchObject({ rule: 'f01-1' }); }); }); describe('三分类规则 P-03 —— 能算的不问 LLM', () => { const decide = (scope: Record) => decideBy(P03_DMN, scope); it('历史价 ≤90 天且行情稳 → A · 直接快答', () => { expect(decide({ history_days: 30, market_stable: true, lifecycle: '在产' })).toMatchObject({ outputs: ['A', '直接快答'], rule: 'p03-a', }); }); it('无历史 / 过期 / 波动 → B · 重估后报', () => { expect(decide({ history_days: 9999, market_stable: false, lifecycle: '未知' })?.outputs[0]).toBe('B'); }); it('EOL / 缺货 / 要求替代 → C · 转案处理(**优先于 A**:新历史价也不该让 EOL 直通)', () => { expect(decide({ history_days: 10, market_stable: true, lifecycle: 'EOL' })).toMatchObject({ rule: 'p03-c' }); }); it('同输入必同结论 —— 零方差、零 token', () => { const scope = { history_days: 30, market_stable: true, lifecycle: '在产' }; expect(decide(scope)).toEqual(decide(scope)); }); }); /** * 声明与契约的对齐 —— 三处引用同一批台账列,错位了不会报错,只会在放行那一刻 * 「判据取不到值 / 出口取不到字段」,而两者都长得像业务问题。所以在这儿钉死。 */ 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); } }); /* * 除了台账列,谓词还能读一类**基座数出来的读数**:`域_对象_rows` = 本单在那张表上 * 写下了几行。它不是列(活动写不了它),正因如此才拦得住「两边都由活动自报」的假对账。 * 这里照样要核到底:对象没声明过,`quote_intake_recrod_rows` 这种手滑仍要当场炸。 */ const rowReadings = new Set([ ...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`), /* * `域_对象_missing` = 本单那张表缺必填的有几行(按声明的 `fields[].required` 现数)。 * 与 `_rows` 同类:活动写不了它,所以它才拦得住「两边都由活动自报」的假对账 —— * 「必填齐」那条判据从前读活动自报的 `missing_key_total`,模型漏算一次就永远绿不了。 */ ...manifest.declarations.ledger .filter((d) => d.fields.some((f) => f.required === true)) .map((d) => `${d.domain}_${d.object}_missing`), /* * `域_对象_<列>_<值>` = 本单那张表按值现数的条数(取值域由 `fields[].tally` 声明)。 * 与 `_rows` / `_missing` 同类:活动写不了它 —— 所以人在放行页改了类标, * 分布条、去向谓词、派单对账**全都跟着重算**。手工修改本来就是为了纠正 AI 的判断。 */ ...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) ?? []) { // S-FEEL 的保留字不是台账名 —— 漏掉它,`a = 0 or b > 0` 会被当成引用了一列叫 `or` 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('出口载荷的标量字段都有台账列承载(§7.5;踩过:两枚去向旗没列 → G1 全流死路)', () => { 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); } } }); }); describe('报告模版 —— 活动持有,容器无脚本执行权(§9.3)', () => { it('四个局面各一张,不在一张里堆条件分支', () => { expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual([ 'intake-overview', 'intake-classified', 'intake-release', ]); }); it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => { for (const template of manifest.assets.viewTemplates) { expect(template.html, template.name).not.toMatch(/'); } }); /* * 放行那张报告要摆出**去向是怎么算出来的** —— 人签字前得看见这一单要往哪走, * 更要看见凭哪一条走。 * * 从前这里断言的是两枚去向旗(`need_alternative` / `need_sourcing`)—— * 那是「活动自己写下一个布尔,再照它发口」的旧口径:旗立错了没人看得出来。 * 现在去向由契约里三个出口各自的 `when` 按序求值(首个命中即出), * 基座把命中结果注入 `case:route*`;报告摆的是**同一次判定**,不是这屏又算一遍。 * 所以这里改断言那条链:判定表三行、命中注入、以及判据读的那三个数。 */ it('放行那张报告摆出分支判定与算出来的去向 —— 不是人点出来的,也不是报告自己算的', () => { const release = manifest.assets.viewTemplates.find((t) => t.name === 'intake-release')?.html ?? ''; // 判定表三行俱在,壳上按命中项上色 expect(release).toContain('data-class-of="case:routeKey"'); for (const row of ['jc-dmn-r r1', 'jc-dmn-r r2', 'jc-dmn-r r3']) expect(release).toContain(row); /* * 去向那句话由基座填,报告不复述。 * **只摆人话的那一份**(2026-08-02 用户定:报告里不该出现系统内的英文名)—— * 口名 `to-alternative` 与判据表达式 `…_type_tag_c > 0` 都是给排障看的, * 摆在人的成果件上等于噪音;人要知道的是「转到哪个环节、为什么」。 */ expect(release).toContain('data-slot="case:routeTo"'); expect(release).not.toContain('case:routePort'); expect(release).not.toContain('case:routeWhy'); // 判据读的是三类条数:摆出来的数与算出去向用的是同三列 // 三类条数读**基座按值现数**的那一份 —— 人改了类标,分布条与去向才跟着变 for (const col of ['quote_intake_record_type_tag_a', 'quote_intake_record_type_tag_b', 'quote_intake_record_type_tag_c']) expect(release).toContain(col); }); /* * **契约里每个出口都得写 `when`** —— 少一口,`routePort` 就整组算不出来, * 放行卡当场退回「人选一口」,而报告上那张判定表也就没有命中行: * 一屏三条规则都不高亮,看着像判定没跑,实则是这组分支有一段没有判据。 */ it('选择组里每一口都写了判据 —— 去向才算得出来', () => { const groups = manifest.contract.output.selective; expect(groups.length).toBeGreaterThan(0); for (const group of groups) { expect(group.ports.length).toBeGreaterThanOrEqual(2); for (const port of group.ports) expect(port.when, `${group.group}/${port.name}`).toBeTruthy(); } }); it('宿主拒推含脚本的模版 —— 这道门在基座,不靠模版作者自觉', async () => { const host = createMockHost({ contract: CONTRACT }); await expect(host.base.interaction.pushHtml('
')).rejects.toThrow(/无脚本执行权/); }); }); const CONTRACT = { activityId: 'quote-intake', version: '1.0.0', data: { customerEmail: 'purchasing@sz-machinery.com', subject: '【询价】RFQ-2026-0001', body: 'LM358DR 12K/年' }, message: undefined, outputPorts: { mandatory: ['intake'], selective: [] }, prohibitions: [], discretion: [], } as const; describe('代码入口 —— 案例活动没有 run,不是残包', () => { it('默认导出是一个有名字的活动定义', () => { expect(activity.name).toBe('quote-intake'); }); it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => { const host = createMockHost({ contract: CONTRACT }); await expect(host.run(activity)).rejects.toThrow(/没有 run/); }); });