Archived
A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了
活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 —— 契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、 入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、 三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。 基座(共享面,改一处影响所有活动,故每处都带回归): · 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」, 于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断; · 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒; · 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面; · 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具; · 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松); · 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。 A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、 卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。 契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml; api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了, 本次予以恢复而非提交删除。 门禁:typecheck / lint / depcruise / test(1366)全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 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('<casePlanModel');
|
||||
expect(model.narrative.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
/*
|
||||
* **MVP 简化口径的四项**:归一(AI)→ 交 FAE 填回(一张执行单)→ 判稳定标(AI)→ 放行。
|
||||
* SOP 里初选核险 / 圈选替代 / 对比评估的实质工作在 FAE 系统外,不建成项。
|
||||
*/
|
||||
it('计划项照状态机一步一项:归一 + 填回 + 判稳 + 放行,不多建一件', () => {
|
||||
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('</humanTask>')), `${id} 少了 manualActivationRule`).toContain(
|
||||
'<manualActivationRule',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('不用阶段 / 里程碑 / 裁量项 —— 它们换不来业务上说得出的好处', () => {
|
||||
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
|
||||
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
|
||||
}
|
||||
});
|
||||
|
||||
it('常来回的项标了可重复 —— 归一会随剔行改数重入', () => {
|
||||
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<string, { type?: string }> }).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(/<script|\son[a-z]+=|https?:\/\//i);
|
||||
expect(template.html, template.name).toContain('<style>');
|
||||
}
|
||||
});
|
||||
|
||||
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(/<style>[\s\S]*?<\/style>/, '');
|
||||
expect(markup).not.toContain('case:routeKey');
|
||||
expect(markup).not.toContain('jc-dmn');
|
||||
});
|
||||
|
||||
it('编辑面 = FAE 的定案列 + 行的钥匙 pn —— 保存按格里的主键认行,少了它两行会对到一行上(走查逮过)', () => {
|
||||
const fae = ['alt_pn', 'alt_brand', 'alt_grade', 'alt_diff', 'alt_risk', 'alt_verify', 'alt_note'];
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'alt-worksheet')?.html ?? '';
|
||||
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
|
||||
expect(cols.has('pn'), '少了行的钥匙 pn').toBe(true);
|
||||
for (const col of fae) expect(cols.has(col), `少了定案列 ${col}`).toBe(true);
|
||||
// 现标不在工作单编辑面(推翻判定是放行时的职权,走验收卡的 editableAtRelease 表)
|
||||
expect(cols.has('type_tag')).toBe(false);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
// 还有 B 类 → 去寻源;全 A → 直通定价 —— 同一个数,两口恰好互斥
|
||||
const [toSourcing, toPricing] = groups[0]?.ports ?? [];
|
||||
expect(safeEval(toSourcing?.when ?? '', { quote_alternative_list_type_tag_b: 2 })).toBe(true);
|
||||
expect(safeEval(toPricing?.when ?? '', { quote_alternative_list_type_tag_b: 2 })).toBe(false);
|
||||
expect(safeEval(toSourcing?.when ?? '', { quote_alternative_list_type_tag_b: 0 })).toBe(false);
|
||||
expect(safeEval(toPricing?.when ?? '', { quote_alternative_list_type_tag_b: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('宿主拒推含脚本的模版 —— 这道门在基座,不靠模版作者自觉', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
||||
});
|
||||
});
|
||||
|
||||
const CONTRACT = {
|
||||
activityId: 'quote-alternative',
|
||||
version: '1.0.0',
|
||||
data: { rfqRef: 'RFQ MD14.1', records: [] },
|
||||
message: undefined,
|
||||
outputPorts: { mandatory: ['roster'], selective: [] },
|
||||
prohibitions: [],
|
||||
discretion: [],
|
||||
} as const;
|
||||
|
||||
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
|
||||
it('默认导出是一个有名字的活动定义', () => {
|
||||
expect(activity.name).toBe('quote-alternative');
|
||||
});
|
||||
|
||||
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
|
||||
});
|
||||
});
|
||||
|
||||
/** 计划项 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(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
||||
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
|
||||
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
|
||||
}
|
||||
return { manualActivation, repetition };
|
||||
}
|
||||
|
||||
/** 抠出某枚哨兵的 ifPart 条件(XML 实体还原)—— 断言放行门认的是「办完了没有」 */
|
||||
function sentryIf(xml: string, sentryId: string): string {
|
||||
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
|
||||
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
|
||||
return raw.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&');
|
||||
}
|
||||
|
||||
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */
|
||||
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
|
||||
try {
|
||||
return evaluateCondition(expression, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
|
||||
*
|
||||
* 这几条断言守的是剧本本身的可用性:项 id 对不对得上、判据会不会开局即真、
|
||||
* 声明的台账与工具是不是活动真有的、每一项有没有进度条的格子。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { ALTERNATIVE_PHASES, ALTERNATIVE_STEPS, CMMN_XML } from '../src/case-model.ts';
|
||||
import { manifest } from '../src/manifest.ts';
|
||||
|
||||
/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */
|
||||
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
|
||||
(m) => m[1] ?? '',
|
||||
);
|
||||
|
||||
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
|
||||
it('每一条剧本都指向一个真实存在的计划项', () => {
|
||||
for (const step of ALTERNATIVE_STEPS) {
|
||||
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
|
||||
}
|
||||
});
|
||||
|
||||
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
|
||||
for (const id of ITEM_IDS) {
|
||||
expect(ALTERNATIVE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
|
||||
expect(manifest.assets.businessModel.steps).toBe(ALTERNATIVE_STEPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
|
||||
/** 开案那一刻的两种局面:流内(上游载荷)与单飞(一段需求)—— 台账一行没有 */
|
||||
const FRESH_FLOW: Record<string, unknown> = {
|
||||
rfqRef: 'RFQ MD14.1',
|
||||
records: [{ pn: 'BBPY00201209601Y00', type_tag: 'C' }],
|
||||
customer_name: 'Itelma',
|
||||
};
|
||||
const FRESH_SOLO: Record<string, unknown> = {
|
||||
subject: '车规运放选型需求',
|
||||
body: '需要一颗车规级双通道运放……',
|
||||
};
|
||||
|
||||
it('**开局一步都不该被自动收掉** —— 流内与单飞两种开局都要 false', () => {
|
||||
for (const fresh of [FRESH_FLOW, FRESH_SOLO]) {
|
||||
for (const step of ALTERNATIVE_STEPS) {
|
||||
if (step.doneWhen === undefined) continue;
|
||||
expect(
|
||||
safeEval(step.doneWhen, fresh),
|
||||
`${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── ① 归一清单 ─────────────────────────────────────────────── */
|
||||
|
||||
it('整份落表且两边对上 → 归一收得掉', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 2 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('**少拆多写都收不掉** —— 自报条数与基座行数对不上,错在第 2 拍就现形', () => {
|
||||
expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 3 })).toBe(false);
|
||||
expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 0, quote_alternative_list_rows: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
/* ── ② 交 FAE 填回:人环,引擎不许替人按 ─────────────────────── */
|
||||
|
||||
it('**FAE 那一步不给 doneWhen** —— 引擎不许替人交卷', () => {
|
||||
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill')?.doneWhen).toBeUndefined();
|
||||
});
|
||||
|
||||
it('填回这一步是一张执行单:工作单一颗一页、卡上零输入、答复处理记 filled_total', () => {
|
||||
const fill = ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill');
|
||||
expect(fill, 'FAE 填回这一步不见了').toBeDefined();
|
||||
expect(fill?.view).toBe('alt-worksheet');
|
||||
/*
|
||||
* **一张执行单,多页待执行报告**(2026-08-02 用户定,替代此前的逐颗按序派单):
|
||||
* 一颗待选型芯片一页,右上角翻页;定案三格是每页必填(requiredWhen orig_type=C),
|
||||
* 提交闸看着。卡上零输入;人交回后只清点 filled_total、finish。
|
||||
*/
|
||||
expect(fill?.guide).toContain('一颗待选型芯片一页');
|
||||
expect(fill?.guide).toContain('只发一次');
|
||||
expect(fill?.guide).not.toContain('"fields"');
|
||||
expect(fill?.guide).not.toContain('sel_stage');
|
||||
expect(fill?.guide).toContain('filled_total');
|
||||
for (const code of ['P2P', 'FUNC', 'OTHER']) expect(fill?.guide).toContain(code);
|
||||
});
|
||||
|
||||
it('归一那一步不再排队列 —— 单卡多页后 sel_stage 已退役', () => {
|
||||
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.guide).not.toContain('sel_stage');
|
||||
});
|
||||
|
||||
/* ── ③ 判稳定标:收步判据与验收判据同源 ─────────────────────── */
|
||||
|
||||
it('C 类清零且去向定了 → 判稳收得掉', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0, route_decided_total: 1 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('**还有一颗 C 没处置 → 判稳收不掉** —— 收步比验收松,松出来的那一截就是返工', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 1, route_decided_total: 1 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('**没记过去向数 → 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => {
|
||||
expect(safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('全转 A(need_sourcing = false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S3_reclass'), {
|
||||
quote_alternative_list_type_tag_c: 0,
|
||||
route_decided_total: 1,
|
||||
need_sourcing: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => {
|
||||
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
|
||||
});
|
||||
|
||||
/*
|
||||
* `records` 是上游整包落地行 —— 不声明 reads 会被整表段的截断刀盲砍
|
||||
* (按字符截,砍掉的恰是 C 类行;A1 认客户那一步栽过同款)。
|
||||
*/
|
||||
it('归一那一步声明了 reads: records —— 上游载荷置前不截断', () => {
|
||||
expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.reads).toContain('records');
|
||||
});
|
||||
});
|
||||
|
||||
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 ALTERNATIVE_STEPS) {
|
||||
for (const address of step.writes) {
|
||||
expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('每一步要调的工具都在清单的工具声明里', () => {
|
||||
for (const step of ALTERNATIVE_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 ALTERNATIVE_STEPS) {
|
||||
expect(step.view, `${step.item} 没定画面`).toBeDefined();
|
||||
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => {
|
||||
for (const step of ALTERNATIVE_STEPS) {
|
||||
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
|
||||
expect(step.guide.trim().length, `${step.item} 的 guide 太短,等于没写`).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function doneOf(item: string): string {
|
||||
const found = ALTERNATIVE_STEPS.find((s) => s.item === item)?.doneWhen;
|
||||
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
|
||||
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
|
||||
try {
|
||||
return evaluateCondition(expression, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('阶段刻度:每一项都有格可待(A1 漏 S3_confirm 的账)', () => {
|
||||
const inPhase = new Set(ALTERNATIVE_PHASES.flatMap((p) => p.items));
|
||||
|
||||
it('**除放行外每一项恰好属于一个阶段** —— 漏一项,进度条就会在它办着的时候跳过去', () => {
|
||||
const planned = ALTERNATIVE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
|
||||
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it('一项不进两个阶段 —— 进了两个,「这一格过没过」就有两种答案', () => {
|
||||
const all = ALTERNATIVE_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(ALTERNATIVE_STEPS.map((s) => s.item));
|
||||
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user