This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
ChaiHaoandClaude Opus 5 ade0fa5a94 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>
2026-08-03 12:28:31 +08:00

576 lines
27 KiB
TypeScript

/**
* A7 情报归档的包内自测 —— **只准 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 { A01_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.length).toBeGreaterThan(0);
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();
}
});
/*
* **宽进严出**:起单一个必填都不设 —— 上游(A6 的三口)发的是
* `{rfqRef, customerCode, quoteNo, outcome, rows}`,而上一版必填前三个,
* 「补一单历史」那条单飞路直接被门口拦掉。
*/
it('起单宽进:input.required 是空的,两种来路的键都声明着', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
const properties = manifest.contract.input.data['properties'] as Record<string, unknown>;
for (const key of ['rfqRef', 'customerCode', 'quoteNo', 'outcome', 'rows', 'subject', 'body']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
});
/*
* **输赢归因是必填** —— 这是 A7 的门面判据:件上的缺归因红带、执行卡的提交闸、
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
*/
it('输赢归因标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
expect(intel?.fields.find((f) => f.name === 'cause_tag')?.required).toBe(true);
});
/*
* **竞对价是行级必填**:表级 required 会让交期 / 认证败的行永远缺一格,
* 把闸拦成死锁 —— 而那几行本来就没有竞对报价这回事。
*/
it('竞对价是行级必填:只有归因落「输在价格」的那一行才算缺', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const competitor = intel?.fields.find((f) => f.name === 'competitor_price');
expect(competitor?.required).toBeUndefined();
expect(competitor?.requiredWhen).toEqual({ column: 'cause_tag', value: 'PRICE' });
});
it('归因 / 竞对价 / 依据三列写死了「你一格都不写」', () => {
const collect = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_collect')?.guide ?? '';
expect(collect).toContain('cause_tag / competitor_price / evidence');
expect(collect).toMatch(/系统不替人归因/);
});
it('结果、归因、败价幅度、入库口径四列都给了取值域 —— 条数由基座现数,不由活动自报', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const tally = (name: string): readonly string[] =>
intel?.fields.find((f) => f.name === name)?.tally ?? [];
expect(tally('deal_result')).toEqual(['WON', 'LOST']);
expect(tally('cause_tag')).toEqual(['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']);
expect(tally('price_gap_band')).toEqual(['SLIGHT', 'MODERATE', 'STRUCTURAL']);
expect(tally('share_scope')).toEqual(['PENDING', 'FULL', 'MASKED', 'INTERNAL']);
});
it('没有一列滥标 expect —— `_gaps` 不是拿来常亮的', () => {
const flagged = manifest.declarations.ledger.flatMap((d) => d.fields.filter((f) => f.expect === true));
expect(flagged.length).toBe(0);
});
/*
* **三张共享域表已退役**:落地率单案例算不出来(让 AI 编一个数是界面撒谎)、
* 客户画像 A1 已经持有那张主数据、历史价格情报与情报卡是同一份事实。
*/
it('共享域三张表已退役 —— 只剩情报卡与归档结论两张', () => {
const addresses = manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`);
expect(addresses).toEqual(['quote_archive.intel', 'quote_archive.verdict']);
});
/*
* 情报要**下次查得到**:料号 / 客户 / 时间 / 我方价 / 成交价 / 竞对价 / 输赢原因
* 七样缺一样,这条情报到下一单就哑了。
*/
it('情报卡是「下次查得到的形状」:料号 · 客户 · 时间 · 两个价 · 竞对价 · 归因', () => {
const intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const names = new Set((intel?.fields ?? []).map((f) => f.name));
for (const col of ['pn', 'customer_code', 'quoted_at', 'quoted_price', 'final_price', 'competitor_price', 'cause_tag']) {
expect(names.has(col), `情报卡少了 ${col}`).toBe(true);
}
expect(intel?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['rfq_ref', 'pn']);
});
it('三条常备案例形状真不同:流内失单 / 成交归档 / 单飞补历史', () => {
expect(manifest.samples.length).toBe(3);
const [lost, won, solo] = manifest.samples;
expect(lost?.input['outcome']).toBe('lost');
expect(won?.input['outcome']).toBe('won');
expect(solo?.input['rows']).toBeUndefined();
expect(String(solo?.input['body'] ?? '').length).toBeGreaterThan(40);
for (const sample of manifest.samples) expect(sample.expect?.ports).toEqual(['archived']);
});
});
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 示例工作流 §1.6 的 S1 结果登记 / S2 画像更新 / S3 落地率统计,一步一项 */
it('计划项照 SOP 一步一项:归一 + 逐条归因 + 入库口径拍板 + 放行', () => {
expect(items(model.cmmnXml)).toEqual(['S1_collect', 'S2_attribute', 'T_scope', 'T_release']);
});
it('三处人的门都标了人工激活:逐条归因 · 入库口径拍板 · 归档放行(归一是查表,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_attribute', 'T_scope', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
/*
* **归因齐了才叫人拍口径** —— 这个活动的要害:哨兵读的是人自己填的那一格
* (基座按 required 现数的缺项 + 只有人交回才产生的 attributed_total),
* 而不是模型判出来的结论列。A6 两版栽的就是这个:
* **「该不该叫人」不能由被叫的那一方说了算**。
*/
it('入库口径拍板的哨兵读**人自己填的那一格**,不读模型判出来的结论', () => {
const condition = sentryIf(model.cmmnXml, 's_attributed');
expect(condition).toContain('quote_archive_intel_missing = 0');
expect(condition).toContain('attributed_total');
expect(condition).toContain('quote_archive_intel_share_scope_pending > 0');
// 归因落齐、口径还待定 → 叫人
expect(
safeEval(condition, {
attributed_total: 2,
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(true);
// 还有行没归因 → 先别叫(问他口径之前,先让他把归因填完)
expect(
safeEval(condition, {
attributed_total: 2,
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 1,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(false);
// 人还没交回执行单 → 门不开
expect(
safeEval(condition, {
quote_archive_intel_rows: 2,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 2,
}),
).toBe(false);
});
/*
* 入场哨兵也归「机检只读基座数」管(A1 第四次违反就藏在哨兵里)——
* 读活动自报的条数,模型不记那个账,门就永不开,而它一步也没做错。
*/
it('交人归因的入场哨兵读基座行数,不读自报的归档条数', () => {
const condition = sentryIf(model.cmmnXml, 's_collected');
expect(condition).toContain('quote_archive_intel_rows');
expect(condition).not.toContain('archived_total');
expect(safeEval(condition, { intel_total: 2, quote_archive_intel_rows: 2, collected_total: 2 })).toBe(true);
// 行落了但归一那一轮还没走完(collected_total 没写过)→ 门不开
expect(safeEval(condition, { intel_total: 2, quote_archive_intel_rows: 2 })).toBe(false);
});
it('放行门哨兵带 ifPart:每条有归因 + 口径守恒 + 待定清零 + 定盘记过,全读基座数', () => {
const condition = sentryIf(model.cmmnXml, 's_ready');
expect(condition).toContain('quote_archive_intel_missing = 0');
expect(condition).toContain('quote_archive_intel_share_scope_pending = 0');
const done = {
scope_decided_total: 1,
quote_archive_intel_rows: 3,
quote_archive_intel_missing: 0,
quote_archive_intel_share_scope_pending: 0,
quote_archive_intel_share_scope_full: 1,
quote_archive_intel_share_scope_masked: 2,
quote_archive_intel_share_scope_internal: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 还有一行缺归因 → 门不开
expect(safeEval(condition, { ...done, quote_archive_intel_missing: 1 })).toBe(false);
// 还有条目压在待定上 → 门不开(人还没拍板)
expect(
safeEval(condition, { ...done, quote_archive_intel_share_scope_pending: 1, quote_archive_intel_share_scope_masked: 1 }),
).toBe(false);
// 口径没拍过(scope_decided_total 没写过)→ 门不开
expect(safeEval(condition, { ...done, scope_decided_total: undefined })).toBe(false);
});
it('每个计划项都有剧本', () => {
const ids = new Set((model.steps ?? []).map((s) => s.item));
for (const id of items(model.cmmnXml)) expect(ids.has(id), `${id} 没有剧本`).toBe(true);
});
/* 说明书只讲整单的事;逐步做法归剧本 —— 两处各写一份会分叉出不存在的项名(A1 的账) */
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
const narrative = model.narrative;
for (const mark of ['§0 开案', '§Z 结案', '一路上要说话']) {
expect(narrative, `说明书少了「${mark}」`).toContain(mark);
}
expect(narrative, '说明书不该逐步教做法(那是剧本的事)').not.toContain('doneWhen');
});
/*
* 断点那几拍(依赖断点 / 目标缺口)剧本的 step guide 不摊开,模型手上只剩说明书 ——
* 那张决策卡的三处写死必须在说明书里也有一份,否则它会自编一张(A6 实测)。
*/
it('说明书写死了那一张决策卡的 itemId / ask / 三个 value —— 断点那几拍只剩它', () => {
const narrative = model.narrative;
expect(narrative).toContain('"itemId":"T_scope"');
expect(narrative).toContain('按哪一档进库?');
for (const value of ['full', 'masked', 'internal']) {
expect(narrative, `说明书少了选项 ${value}`).toContain(value);
}
});
});
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 intel = manifest.declarations.ledger.find((d) => d.object === 'intel');
const names = new Set((intel?.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 || f.requiredWhen !== undefined))
.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);
}
});
/*
* **A7 是终点**:流程包 E14(`A7 → end_arch`)是无条件边 —— 没有去向要选,
* 所以一口必交付、选择组为空。摆一个空的选择组会让基座退回「人选一口」,
* 而根本没有第二口可选。
*/
it('只有一口 archived,且没有选择组 —— 终点不是岔路口', () => {
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['archived']);
expect(manifest.contract.output.selective).toEqual([]);
});
it('出口载荷的标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
const ports = [
...manifest.contract.output.mandatory,
...manifest.contract.output.selective.flatMap((g) => g.ports),
];
for (const port of ports) {
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);
}
}
});
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)。
* A-01 的出参就是台账 `price_gap_band` 的取值:模型算完照抄,不必再翻译一道。
*/
describe('败价幅度 A-01 —— 价差 → 下一单怎么用这条情报', () => {
const table = A01_DMN.tables.find((t) => t.id === 'a01-band');
it('三个点以内 → 擦肩而过', () => {
expect(decideBy(table, { gap: 0.02 })).toMatchObject({ outputs: ['SLIGHT'] });
});
it('三到十个点 → 够得着(FIRST 命中,第二行就是这一档)', () => {
expect(decideBy(table, { gap: 0.08 })).toMatchObject({ rule: 'a01-2', outputs: ['MODERATE'] });
});
it('超过一成 → 结构性价差,下一单别再报同一价位', () => {
expect(decideBy(table, { gap: 0.204 })).toMatchObject({ outputs: ['STRUCTURAL'] });
});
it('三个档位码与台账取值域逐字一致', () => {
const tally = manifest.declarations.ledger
.find((d) => d.object === 'intel')
?.fields.find((f) => f.name === 'price_gap_band')?.tally;
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
});
});
describe('入库口径建议 A-02 —— 归因 → 建议哪一档(建议归尺,结论归人)', () => {
const table = A01_DMN.tables.find((t) => t.id === 'a02-scope');
it('输在价格 → 建议脱敏:竞对的价是他给客户的,不是给我们的', () => {
expect(decideBy(table, { cause_tag: 'PRICE' })).toMatchObject({ outputs: ['masked'] });
});
it('不涉第三方报价的败因 → 建议全量入库', () => {
for (const tag of ['LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']) {
expect(decideBy(table, { cause_tag: tag }), tag).toMatchObject({ outputs: ['full'] });
}
});
it('出参就是决策卡上那三个 value —— 建议标得上,人才点得准', () => {
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_scope')?.guide ?? '';
for (const value of new Set(table?.rules.map((r) => String(r.then[0])))) {
expect(guide, `拍板剧本少了 ${value}`).toContain(`"${value}"`);
}
});
});
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
const attribute = manifest.assets.businessModel.steps?.find((s) => s.item === 'S2_attribute')?.guide ?? '';
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
it('六个归因码剧本与工作单都写着(大写,一个字母不许差)', () => {
for (const code of ['PRICE', 'LEADTIME', 'CERT', 'SPEC', 'RELATION', 'OTHER']) {
expect(attribute, `剧本少了 ${code}`).toContain(code);
expect(sheet, `工作单少了 ${code}`).toContain(code);
}
});
it('三个败价幅度码剧本里写死', () => {
for (const code of ['SLIGHT', 'MODERATE', 'STRUCTURAL']) {
expect(attribute, `剧本少了 ${code}`).toContain(code);
}
});
it('败价幅度的三档门槛剧本与工作单说的是同一组数', () => {
for (const threshold of ['0.03', '0.10']) expect(attribute, `剧本少了 ${threshold}`).toContain(threshold);
for (const band of ['≤ 3%', '3% ~ 10%', '10%']) expect(sheet, `工作单少了 ${band}`).toContain(band);
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('两个局面各一张:情报归档工作单(干活)与情报归档清单(收口)', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual(['archive-worksheet', 'archive-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('archive-roster');
});
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('工作单是干活的件:逐颗档案 + 两把尺 + 填回工作表', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
expect(sheet).toContain('jc-selcard');
expect(sheet).toContain('输赢归因六档');
expect(sheet).toContain('败价幅度 A-01');
expect(sheet).toContain('data-edit="quote_archive.intel"');
});
it('缺归因红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
for (const name of ['archive-worksheet', 'archive-roster']) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain('quote_archive_intel_missing');
expect(html, name).toContain('quote_archive_intel_missing_keys');
}
});
/*
* 编辑面精确等于人在那个局面的职权面 + **行的钥匙 pn**:
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
*
* 两张件的编辑面**故意不同**:干活那张收归因四列,收口那张只让改归因与依据
* (= `editableAtRelease`)—— 不同工作各有各的件。
*/
it('工作单的编辑面 = 归因 + 竞对价 + 竞对名 + 依据 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(cols)).toEqual(new Set(['pn', 'cause_tag', 'competitor_price', 'competitor_name', 'evidence']));
});
it('收口件的编辑面 = editableAtRelease 那两列 + 行的钥匙 pn', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
const editable = manifest.contract.requiredDeliverables[0]?.editableAtRelease ?? [];
expect(cols).toEqual(new Set(['pn', ...editable]));
});
it('口径分布读的是基座按值现数的四段 —— 守恒等式的可视形', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
for (const code of ['pending', 'full', 'masked', 'internal']) {
expect(roster).toContain(`quote_archive_intel_share_scope_${code}`);
}
});
/*
* 一口活动的落边图**不冒充判过**:A7 只有一口且无条件(E14),
* 所以它不投 `case:routeKey`(那是「选出来的」才有的东西),
* 摆的是「这一签之后谁用得上」—— 右边三个用处各挂本单条数。
*/
it('收口件摆落边图:一口无条件,不投 routeKey 冒充选择', () => {
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'archive-roster')?.html ?? '';
expect(roster).toContain('归档闭环');
expect(roster).toContain('quote_archive_intel_cause_tag_price');
expect(roster, '一口活动不该摆去向选择').not.toContain('case:routeKey');
});
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'archive-worksheet')?.html ?? '';
expect(sheet).toContain('<i data-class-of="deal_result"></i>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'quote-archive',
version: '1.0.0',
data: { rfqRef: 'RFQ-2026-0777', rows: [] },
message: undefined,
outputPorts: { mandatory: ['archived'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('quote-archive');
});
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 A01_DMN)['tables'][number] | undefined,
scope: Record<string, unknown>,
): { 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(/<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 };
}
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('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}