/** * A2 替代选型的包内自测 —— **只准 import @sdk / @contracts / @sfeel** * (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在 * `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道。 * * 案例活动没有 `run`,所以自测不跑回合。这里测的是**清单本身**: * ① 清单合规 + 判据等级不谎报; * ② 案例模型双表示同在、触达世界的项标了人工激活; * ③ **声明与契约对得上** —— 必交付物的台账地址、验收谓词读的列、 * 出口载荷要的标量、出口 when 读的读数,处处必须有承载; * ④ 报告模版无脚本、去向判定链齐全。 */ 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 口径:A2 **没有判定表**。上一版的 GRADE_DMN 声明着三个入参而台账没有承载、 * 剧本零调用 —— 一张没有求值器的判定表不是资产,是错觉。判稳规则写死在剧本里。 */ it('没有声明无求值器的判定表 —— 判稳规则写死在剧本(带 90 天那把尺)', () => { expect(manifest.assets.businessModel.dmn.tables).toEqual([]); const reclass = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_reclass'); expect(reclass?.guide).toContain('90'); expect(reclass?.guide).toContain('marketStable'); }); }); 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_reclass', 'T_release']); }); it('两处人的门都标了人工激活:FAE 填回 · 清单放行', () => { 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('末尾一张验收单 —— 完成只出自放行通过', () => { expect(model.cmmnXml).toContain('id="T_release"'); }); /* * **放行门的哨兵必须有 ifPart,且认「办完了没有」、读基座数**(A1 s_routed 的账: * 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 false 就推开了)。 */ it('放行门哨兵带 ifPart:C 类清零 + 定标守恒 + 去向定过,全读基座数', () => { const condition = sentryIf(model.cmmnXml, 's_routed'); expect(condition).toContain('quote_alternative_list_type_tag_c = 0'); expect(condition).toContain('quote_alternative_list_type_tag_a + quote_alternative_list_type_tag_b'); expect(condition).toContain('route_decided_total'); // FAE 漏填一行替代料(那一行仍是 C)→ 门不开;全转完 → 门开 const done = { route_decided_total: 1, quote_alternative_list_rows: 2, quote_alternative_list_type_tag_a: 1, quote_alternative_list_type_tag_b: 1, quote_alternative_list_type_tag_c: 0 }; expect(safeEval(condition, done)).toBe(true); expect(safeEval(condition, { ...done, quote_alternative_list_type_tag_c: 1, quote_alternative_list_type_tag_b: 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('说明书不点名计划项 —— 点了就会与案例模型分叉(A1 踩过)', () => { 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 list = manifest.declarations.ledger.find((d) => d.object === 'list'); const names = new Set((list?.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, '')}`), ), ), /* * **工具调用读数**(2026-08-03 起):基座按留痕数「这一单调了几次某工具」, * 声明过的工具一律先铺 0。有了它,「转 A 有据」这类判据才两边都不是活动自报的。 */ ...manifest.declarations.tools.map((t) => `tool_${t.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`), ]); 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('出口选择组每一口的 when 读的名字也都有承载 —— 静态审不查它,这里自己查', () => { for (const group of manifest.contract.output.selective) { for (const port of group.ports) { for (const identifier of (port.when ?? '').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), `${port.name} 的 when 引用了 ${identifier}`).toBe(true); } } } }); it('出口载荷的标量字段都有台账列承载(踩过:去向旗没列 → E5 从来不成立)', () => { 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); } } }); /* * 裁量判据的 label 与剧本里 judge 的 criterion 必须**逐字一致** —— * judge 按整句对名,差一个字那一条永远算没裁过,提交被拒。 */ 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('报告模版 —— 活动持有,容器无脚本执行权', () => { /* * **选型作业与收口验收各有各的件**(2026-08-03 用户:「验收工单怎么又变成 * 选型了?」)—— One Pager 归选型执行单(S1/S2),整合清单归收口(S3/放行/归档, * 「清单是所有选型完成后收口才整合的东西」)。「1:1」落在每份件与它的办结快照 * 之间(finalHtml),不是全程只许一张。 */ it('两件各归各位:选型 One Pager 归作业,整合清单归收口', () => { expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['alt-worksheet', 'alt-roster']); const viewOf = (item: string) => manifest.assets.businessModel.steps?.find((s) => s.item === item)?.view; for (const item of ['S1_normalize', 'S2_fill']) expect(viewOf(item), item).toBe('alt-worksheet'); for (const item of ['S3_reclass', 'T_release']) expect(viewOf(item), item).toBe('alt-roster'); }); it('验收报告重列表与落边:汇总带 + 受理记录大表 + 落边图(命中的边亮)', () => { const roster = manifest.assets.viewTemplates.find((t) => t.name === 'alt-roster')?.html ?? ''; expect(roster).toContain('statrow'); expect(roster).toContain('data-class-of="case:routeKey"'); // 落边图:两条边(A3 寻源 / A4 定价)+ 命中流光 for (const mark of ['class="redge e1"', 'class="redge e2"', 'class="rpulse p1"', 'class="rpulse p2"']) { expect(roster).toContain(mark); } for (const t of ['A3 · 渠道寻源', 'A4 · 成本定价', '还有 B 类?']) expect(roster).toContain(t); for (const col of ['orig_type', 'type_tag', 'alt_grade', 'basis']) expect(roster).toContain(`data-slot="${col}"`); // 收口件只读:就地改走验收卡的 editableAtRelease 表,这里一个输入格都不给 expect(roster).not.toContain('data-edit='); expect(roster).not.toContain('data-col='); }); /* * 对决区(2026-08-02 用户定):左侧被替换目标的静态 SVG 卡,右侧替换卡可编辑, * 替代等级三枚选择钮直接选(正确性全由人控);渲染态就是结构, * 编辑只点亮输入;02 选型指引铺开(不写类型,口径贴在选择钮上)。 */ it('执行工单重单颗选型:档案头 + 蓝图带 + 题面/定案双栏 + 等级三选 + 方法脚注,每颗一页', () => { const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? ''; expect(sheet).toContain('class="blueprint"'); expect(sheet).toContain('data-page'); expect(sheet).toContain('class="alt-page"'); expect(sheet).toContain('class="given"'); expect(sheet).toContain('class="decide"'); // 等级 = 三枚 radio 直接选,值域即台账取值域 for (const v of ['value="P2P"', 'value="FUNC"', 'value="OTHER"']) expect(sheet).toContain(v); expect((sheet.match(/name="alt_grade_pick"/g) ?? []).length).toBe(3); expect(sheet).toContain('class="m-five"'); expect(sheet).toContain('class="m-funnel"'); expect(sheet).toContain('data-slot="remark"'); expect(sheet).toContain('同封装不等于同引脚'); }); /* * 空占位三级(2026-08-02 用户定,全局口径):必填红 / 建议填橘 / 选填灰, * 与声明 required / expect / 其余一一对应。 */ it('空占位三种:必填(红)· 选填(灰)· 建议(橘);待选择归必填', () => { const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? ''; for (const word of ['"必填"', '"建议填"', '"选填"']) expect(sheet).toContain(word); // 等级空位归「必填」—— 不再有单独的「待选定」文案 expect(sheet).not.toContain('待选定'); // 声明侧对应:定案三格 requiredWhen(NOW 行必填),三格 expect(建议填) const cols = new Map(manifest.declarations.ledger.find((l) => l.object === 'list')?.fields.map((f) => [f.name, f]) ?? []); for (const c of ['alt_pn', 'alt_brand', 'alt_grade']) expect(cols.get(c)?.requiredWhen?.value, c).toBe('C'); for (const c of ['alt_diff', 'alt_risk', 'alt_verify']) expect(cols.get(c)?.expect, c).toBe(true); expect(cols.get('alt_note')?.expect).toBeUndefined(); }); it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => { for (const template of manifest.assets.viewTemplates) { expect(template.html, template.name).not.toMatch(/'); } }); it('One Pager 不摆去向与清单 —— 那是收口整合的东西(2026-08-02 用户定),去向在验收卡上', () => { // 断正文 —— PRD 共享样式表里带着 jc-dmn 的规则,不算摆了判定表 const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? ''; const markup = sheet.replace(/