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,616 @@
|
||||
/**
|
||||
* A6 报价议价的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
|
||||
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
|
||||
* (判据谓词 / 出口载荷与 `when` / 剧本声明 / 两把尺的档位)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import { evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
||||
import { createMockHost } from '@sdk';
|
||||
import { N01_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();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **宽进严出**:起单一个必填都不设 —— 上游(A4 的 draft 口)发的是
|
||||
* `{rfqRef, customerCode, items}`,而上一版必填这三个,单飞那条路直接被门口拦掉。
|
||||
*/
|
||||
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', 'items', 'subject', 'body']) {
|
||||
expect(properties[key], `入参少了 ${key}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **客户表态是必填** —— 这是 A6 的门面判据:件上的缺表态红带、执行卡的提交闸、
|
||||
* 放行门 L1 三处同源(都数基座的 `_missing`)。声明丢了 required,三处一起失明。
|
||||
*/
|
||||
it('客户表态标了 required —— 红带 / 提交闸 / 放行门三处同源的那个数才存在', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
expect(line?.fields.find((f) => f.name === 'reply_type')?.required).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **客户还价用 `requiredWhen` 钉在还价那一行** —— 表级 required 会让接受 / 拒绝的行
|
||||
* 永远缺一格,把执行卡的闸拦成死锁(A2 逐颗派单踩过同一坑)。
|
||||
*/
|
||||
it('客户还价是行级必填:只有还价那一行才算缺', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
const counter = line?.fields.find((f) => f.name === 'counter_price');
|
||||
expect(counter?.requiredWhen).toEqual({ column: 'reply_type', value: 'COUNTER' });
|
||||
expect(counter?.required, '整表必填会把接受/拒绝的行也拦住').toBeUndefined();
|
||||
});
|
||||
|
||||
/*
|
||||
* **客户那一侧的三列 AI 一格都不写**(结构治,不是措辞治):
|
||||
* 一条要模型分辨「这行能不能替客户填」的规矩会走岔;「这几列不写」没有分支可走岔。
|
||||
*/
|
||||
it('客户表态 / 还价 / 原话三列写死了「你一格都不写」', () => {
|
||||
const guide = manifest.assets.businessModel.steps?.find((s) => s.item === 'S1_compose')?.guide ?? '';
|
||||
expect(guide).toMatch(/reply_type[^\n]*counter_price[^\n]*reply_note|这三列你一格都不写/);
|
||||
expect(guide).toContain('一格都不写');
|
||||
});
|
||||
|
||||
it('表态、定案、让步处置三列都给了取值域 —— 条数由基座现数,不由活动自报', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
expect(line?.fields.find((f) => f.name === 'reply_type')?.tally).toEqual(['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']);
|
||||
expect(line?.fields.find((f) => f.name === 'deal_status')?.tally).toEqual(['WON', 'HAGGLE', 'REVISE', 'LOST']);
|
||||
expect(line?.fields.find((f) => f.name === 'concession')?.tally).toEqual(['ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']);
|
||||
});
|
||||
|
||||
/*
|
||||
* 我方写的注不标 `expect` —— 标了 `_gaps` 恒大于 0,读它的判据永远绿不了(A1 的账)。
|
||||
* 客户还价那一格由 `requiredWhen` 管,不再重复标 expect。
|
||||
*/
|
||||
it('没有一列滥标 expect —— `_gaps` 不是拿来常亮的', () => {
|
||||
const line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
expect((line?.fields ?? []).filter((f) => f.expect === true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('三条常备案例形状真不同:流内一轮成交 / 客户压价 / 单飞超时', () => {
|
||||
expect(manifest.samples.length).toBe(3);
|
||||
const [flowIn, haggled, solo] = manifest.samples;
|
||||
expect((flowIn?.input as { items?: unknown[] }).items?.length).toBeGreaterThan(0);
|
||||
expect(flowIn?.expect?.ports).toEqual(['won']);
|
||||
expect(haggled?.expect?.ports).toEqual(['revise']);
|
||||
expect((solo?.input as { items?: unknown[] }).items).toBeUndefined();
|
||||
expect(solo?.expect?.ports).toEqual(['lost']);
|
||||
});
|
||||
});
|
||||
|
||||
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.4 的 S1 发报留档 / S2 跟进议价 / S3 定案交接,一步一项 */
|
||||
it('计划项照 SOP 一步一项:排版 + 发出落回音 + 逐条定案 + 异议拍板 + 放行', () => {
|
||||
expect(items(model.cmmnXml)).toEqual(['S1_compose', 'S2_send', 'S3_settle', 'T_haggle', 'T_release']);
|
||||
});
|
||||
|
||||
it('三处人的门都标了人工激活:发出并等回音 · 异议拍板 · 结果放行(逐条定案是查表,不标)', () => {
|
||||
expect(cmmn.manualActivation).toEqual(['S2_send', 'T_haggle', 'T_release']);
|
||||
});
|
||||
|
||||
it('不用阶段 / 里程碑 / 裁量项', () => {
|
||||
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
|
||||
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **还价才叫人** —— 这个活动的要害:哨兵读基座按值现数的还价条数,
|
||||
* 而不是活动自报的 `haggle_total`;且要等回音落齐(客户直接接受或拒绝的单
|
||||
* 一个人都不必额外惊动)。
|
||||
*/
|
||||
it('异议拍板的哨兵读**客户自己填的那一格**,不读模型判出来的定案', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_haggle');
|
||||
expect(condition).toContain('quote_negotiate_line_reply_type_counter > 0');
|
||||
expect(condition).toContain('replied_total');
|
||||
/*
|
||||
* **「该不该叫人」不能由被叫的那一方说了算**(2026-08-03 两轮实测):
|
||||
* 哨兵读 deal_status 时,模型在定案那一步直接写 REVISE、HAGGLE 一条不留,
|
||||
* 这道门整个不开 —— 单子 released 而决策卡 0 张,让步梯度成了摆设。
|
||||
*/
|
||||
expect(condition, '哨兵不该读模型判出来的定案').not.toContain('deal_status');
|
||||
// 客户还过价且回音落过 → 叫人
|
||||
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1, replied_total: 2 })).toBe(true);
|
||||
// 没人还价 → 不叫人(这一步整个不入场)
|
||||
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 0, replied_total: 2 })).toBe(false);
|
||||
// 回音还没落 → 先别叫
|
||||
expect(safeEval(condition, { quote_negotiate_line_reply_type_counter: 1 })).toBe(false);
|
||||
// **模型自己把定案写成 REVISE 也绕不过去** —— 客户那一格还在那儿
|
||||
expect(
|
||||
safeEval(condition, {
|
||||
quote_negotiate_line_reply_type_counter: 1,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 1,
|
||||
replied_total: 2,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 入场哨兵也归「机检只读基座数」管(A1 第四次违反就藏在哨兵里)——
|
||||
* 读活动自报的条数,模型不记那个账,门就永不开,而它一步也没做错。
|
||||
*/
|
||||
it('交人发出的入场哨兵读基座行数,不读自报的报价条数', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_composed');
|
||||
expect(condition).toContain('quote_negotiate_line_rows');
|
||||
expect(condition).not.toContain('quote_total');
|
||||
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2, composed_total: 2 })).toBe(true);
|
||||
// 行落了但排版那一轮还没走完(composed_total 没写过)→ 门不开
|
||||
expect(safeEval(condition, { line_total: 2, quote_negotiate_line_rows: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('逐条定案的入场哨兵认「回音齐了」:人交回 + 基座数的缺项为 0', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_replied');
|
||||
expect(condition).toContain('quote_negotiate_line_missing = 0');
|
||||
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 0 })).toBe(true);
|
||||
expect(safeEval(condition, { replied_total: 2, quote_negotiate_line_rows: 2, quote_negotiate_line_missing: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('放行门哨兵带 ifPart:每条有表态 + 定案守恒 + 待拍板清零 + 定盘记过,全读基座数', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_ready');
|
||||
expect(condition).toContain('quote_negotiate_line_missing = 0');
|
||||
expect(condition).toContain('quote_negotiate_line_deal_status_haggle = 0');
|
||||
const done = {
|
||||
route_decided_total: 1,
|
||||
quote_negotiate_line_rows: 3,
|
||||
quote_negotiate_line_missing: 0,
|
||||
quote_negotiate_line_deal_status_won: 2,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 0,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
};
|
||||
expect(safeEval(condition, done)).toBe(true);
|
||||
// 还有一行缺客户表态 → 门不开
|
||||
expect(safeEval(condition, { ...done, quote_negotiate_line_missing: 1 })).toBe(false);
|
||||
// 定案没记齐(守恒不成立)→ 门不开
|
||||
expect(safeEval(condition, { ...done, quote_negotiate_line_deal_status_lost: 0 })).toBe(false);
|
||||
/*
|
||||
* **还悬着的还价放不了行**(与 A4 的「拦截档必须清零」同一形状):
|
||||
* HAGGLE 是过渡态,拍板那一下必须把它换成 WON / REVISE / LOST。
|
||||
*/
|
||||
expect(
|
||||
safeEval(condition, {
|
||||
...done,
|
||||
quote_negotiate_line_deal_status_won: 1,
|
||||
quote_negotiate_line_deal_status_haggle: 1,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
// 退回重批是结论,不是悬着 —— 照样放得了行
|
||||
expect(
|
||||
safeEval(condition, {
|
||||
...done,
|
||||
quote_negotiate_line_deal_status_won: 1,
|
||||
quote_negotiate_line_deal_status_revise: 1,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **决策卡那三处写死的东西也进说明书**(2026-08-03 实测逼出来的):
|
||||
* 当下可为为空那几拍(依赖断点)引擎只说「请调人」,而拍板那一步的 guide
|
||||
* 根本没摊开 —— 模型手上只剩计划项的中文名与自己的想象:
|
||||
* 一轮把中文名当 itemId(连拒两拍),一轮换了问法于是同一个岔路口发出去两张卡。
|
||||
* **说明书每一拍都在提示词里**,写死在这儿才是每一拍都看得见。
|
||||
*/
|
||||
it('说明书写死了那一张决策卡的 itemId / ask / 三个 value —— 断点那几拍只剩它', () => {
|
||||
expect(model.narrative).toContain('"itemId":"T_haggle"');
|
||||
expect(model.narrative).toContain('按哪条路办');
|
||||
for (const value of ['accept-counter', 'revise', 'hold']) {
|
||||
expect(model.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 line = manifest.declarations.ledger.find((d) => d.object === 'line');
|
||||
const names = new Set((line?.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);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **三口的去向是算出来的**:每一口都得写 `when`,少一口基座就退回「人选一口」——
|
||||
* 而选择组恰选一口是这个活动的全部业务分支(E11/E12/E13 读的就是它)。
|
||||
*/
|
||||
it('三个出口对齐流程包 E11/E12/E13,且每一口都带 when', () => {
|
||||
const group = manifest.contract.output.selective[0];
|
||||
expect(group?.group).toBe('negotiation');
|
||||
expect(group?.ports.map((p) => p.name)).toEqual(['revise', 'won', 'lost']);
|
||||
for (const port of group?.ports ?? []) {
|
||||
expect(port.when, `${port.name} 少了 when —— 少一口整组就退回人选`).toBeDefined();
|
||||
expect(port.when).toContain('quote_negotiate_line_deal_status_');
|
||||
expect(port.when, `${port.name} 的 when 不该读过渡态 HAGGLE`).not.toContain('_haggle');
|
||||
}
|
||||
});
|
||||
|
||||
it('去向按序求值:还价优先退回重批,其次成交转履约,都没成转归档', () => {
|
||||
const ports = manifest.contract.output.selective[0]?.ports ?? [];
|
||||
const pick = (scope: Record<string, unknown>): string | undefined =>
|
||||
ports.find((p) => safeEval(p.when ?? '', scope))?.name;
|
||||
expect(pick({ quote_negotiate_line_deal_status_revise: 1, quote_negotiate_line_deal_status_won: 1, quote_negotiate_line_deal_status_lost: 0 })).toBe('revise');
|
||||
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 2, quote_negotiate_line_deal_status_lost: 1 })).toBe('won');
|
||||
expect(pick({ quote_negotiate_line_deal_status_revise: 0, quote_negotiate_line_deal_status_won: 0, quote_negotiate_line_deal_status_lost: 2 })).toBe('lost');
|
||||
});
|
||||
|
||||
/* 下游 A7 的入参要 rfqRef / customerCode / quoteNo —— 不成交那一口必须带齐 */
|
||||
it('不成交那一口带齐 A7 要的三个键', () => {
|
||||
const lost = manifest.contract.output.selective[0]?.ports.find((p) => p.name === 'lost');
|
||||
const required = (lost?.payload as { required?: string[] }).required ?? [];
|
||||
for (const key of ['rfqRef', 'customerCode', 'quoteNo']) expect(required, `lost 口少了 ${key}`).toContain(key);
|
||||
});
|
||||
|
||||
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)。
|
||||
* 出参就是台账 `deal_status` / `concession` 的取值:模型算完照抄,不必再翻译一道。
|
||||
*/
|
||||
describe('议价结果 N-01 —— 客户表态 → 定案', () => {
|
||||
const table = N01_DMN.tables.find((t) => t.id === 'n01-outcome');
|
||||
|
||||
it('客户接受 → WON 成交', () => {
|
||||
expect(decideBy(table, { reply_type: 'ACCEPT' })).toMatchObject({ outputs: ['WON'] });
|
||||
});
|
||||
|
||||
it('客户还价 → HAGGLE 压在还价上,要人拍板', () => {
|
||||
expect(decideBy(table, { reply_type: 'COUNTER' })).toMatchObject({ rule: 'n01-2', outputs: ['HAGGLE'] });
|
||||
});
|
||||
|
||||
it('客户拒绝与超时无回复都 → LOST(两条路一个结果,缘由记在备注)', () => {
|
||||
expect(decideBy(table, { reply_type: 'REJECT' })).toMatchObject({ outputs: ['LOST'] });
|
||||
expect(decideBy(table, { reply_type: 'NOREPLY' })).toMatchObject({ outputs: ['LOST'] });
|
||||
});
|
||||
|
||||
it('**认不出的表态按不成交处置** —— 兜底不许猜成接受', () => {
|
||||
expect(decideBy(table, { reply_type: '客户说再看看' })).toMatchObject({ rule: 'n01-default', outputs: ['LOST'] });
|
||||
});
|
||||
|
||||
/*
|
||||
* 这把尺只出三个码:**REVISE 不在其中** —— 「退回重批」是人在决策卡上拍的,
|
||||
* 判定表算不出来。所以是子集关系,不是等号;而多出来的那一个恰好只有一个,
|
||||
* 就是拍板那条路(测这一条,免得哪天有人往 tally 里塞一个没人写得出的码)。
|
||||
*/
|
||||
it('尺出的定案码都在台账取值域里,只有「退回重批」由人拍板补上', () => {
|
||||
const tally = manifest.declarations.ledger
|
||||
.find((d) => d.object === 'line')
|
||||
?.fields.find((f) => f.name === 'deal_status')?.tally ?? [];
|
||||
const fromTable = new Set(table?.rules.map((r) => String(r.then[0])));
|
||||
for (const code of fromTable) expect(tally, `尺出了台账没有的码 ${code}`).toContain(code);
|
||||
expect([...tally].filter((v) => !fromTable.has(v))).toEqual(['REVISE']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('让步梯度 N-02 —— 还价幅度 → 让不让、谁来让', () => {
|
||||
const table = N01_DMN.tables.find((t) => t.id === 'n02-concession');
|
||||
|
||||
it('三个点以内 → 接了拿单', () => {
|
||||
expect(decideBy(table, { gap: 0.02 })).toMatchObject({ outputs: ['ACCEPT_COUNTER', true] });
|
||||
});
|
||||
|
||||
it('三到十个点 → 授权内回旋(FIRST 命中,第二行就是这一档)', () => {
|
||||
expect(decideBy(table, { gap: 0.08 })).toMatchObject({ rule: 'n02-2', outputs: ['NEGOTIATE', true] });
|
||||
});
|
||||
|
||||
it('超过一成 → 超出授权,退回成本定价重批', () => {
|
||||
expect(decideBy(table, { gap: 0.204 })).toMatchObject({ outputs: ['ESCALATE', false] });
|
||||
});
|
||||
|
||||
it('三个处置码与台账取值域逐字一致', () => {
|
||||
const tally = manifest.declarations.ledger
|
||||
.find((d) => d.object === 'line')
|
||||
?.fields.find((f) => f.name === 'concession')?.tally;
|
||||
expect(new Set(table?.rules.map((r) => String(r.then[0])))).toEqual(new Set(tally));
|
||||
});
|
||||
});
|
||||
|
||||
describe('两把尺的档位三处同一份 —— 判定表 / 剧本 / 工作单', () => {
|
||||
const settle = manifest.assets.businessModel.steps?.find((s) => s.item === 'S3_settle')?.guide ?? '';
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
|
||||
it('四个表态码剧本与工作单都写着(大写,一个字母不许差)', () => {
|
||||
for (const code of ['ACCEPT', 'COUNTER', 'REJECT', 'NOREPLY']) {
|
||||
expect(settle, `剧本少了 ${code}`).toContain(code);
|
||||
expect(sheet, `工作单少了 ${code}`).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('三个定案码与三个让步处置码剧本里写死', () => {
|
||||
for (const code of ['WON', 'HAGGLE', 'REVISE', 'LOST', 'ACCEPT_COUNTER', 'NEGOTIATE', 'ESCALATE']) {
|
||||
expect(settle, `剧本少了 ${code}`).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('让步梯度的三档门槛剧本与工作单说的是同一组数', () => {
|
||||
for (const threshold of ['0.03', '0.10']) expect(settle, `剧本少了 ${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(['negotiate-worksheet', 'negotiate-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('negotiate-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 === 'negotiate-worksheet')?.html ?? '';
|
||||
expect(sheet).toContain('jc-selcard');
|
||||
expect(sheet).toContain('议价结果 N-01');
|
||||
expect(sheet).toContain('让步梯度 N-02');
|
||||
expect(sheet).toContain('data-edit="quote_negotiate.line"');
|
||||
});
|
||||
|
||||
it('缺表态红带与提交闸同源 —— 件上摆的是基座的 `_missing` 与主键名单', () => {
|
||||
for (const name of ['negotiate-worksheet', 'negotiate-roster']) {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
||||
expect(html, name).toContain('quote_negotiate_line_missing');
|
||||
expect(html, name).toContain('quote_negotiate_line_missing_keys');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 编辑面精确等于人在那个局面的职权面 + **行的钥匙 pn**:
|
||||
* 主键格不挂 `data-col`,服务端按「全表一致列」补键,两行会对到同一行上
|
||||
* (A2 浏览器走查逮过,e2e 走 API 自带主键测不出来)。
|
||||
*
|
||||
* 两张件的编辑面**故意不同**:干活那张收客户的话,收口那张只让改成交价与原话
|
||||
* (= `editableAtRelease`)—— 不同工作各有各的件。
|
||||
*/
|
||||
it('工作单的编辑面 = 客户表态 + 客户还价 + 客户原话 + 行的钥匙 pn', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
const cols = [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(new Set(cols)).toEqual(new Set(['pn', 'reply_type', 'counter_price', 'reply_note']));
|
||||
});
|
||||
|
||||
it('收口件的编辑面 = editableAtRelease 那两列 + 行的钥匙 pn', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-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 === 'negotiate-roster')?.html ?? '';
|
||||
for (const code of ['won', 'haggle', 'revise', 'lost']) {
|
||||
expect(roster).toContain(`quote_negotiate_line_deal_status_${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
/* 三口活动:收口件摆去向判定,由基座在放行那一刻求值后投影(件不算不判) */
|
||||
it('收口件摆落边图,去向由 case:route* 投影', () => {
|
||||
const roster = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-roster')?.html ?? '';
|
||||
expect(roster).toContain('data-class-of="case:routeKey"');
|
||||
expect(roster).toContain('case:routeTo');
|
||||
expect(roster).toContain('case:routeWhy');
|
||||
});
|
||||
|
||||
it('模版只做取值 —— data-class-of 不以 data-slot 为前提(纯色块曾整条静默失色)', () => {
|
||||
const sheet = manifest.assets.viewTemplates.find((t) => t.name === 'negotiate-worksheet')?.html ?? '';
|
||||
expect(sheet).toContain('<i data-class-of="deal_status"></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-negotiate',
|
||||
version: '1.0.0',
|
||||
data: { rfqRef: 'RFQ MD14.1', items: [] },
|
||||
message: undefined,
|
||||
outputPorts: { mandatory: [], selective: ['revise', 'won', 'lost'] },
|
||||
prohibitions: [],
|
||||
discretion: [],
|
||||
} as const;
|
||||
|
||||
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
|
||||
it('默认导出是一个有名字的活动定义', () => {
|
||||
expect(activity.name).toBe('quote-negotiate');
|
||||
});
|
||||
|
||||
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 N01_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('>', '>').replaceAll('<', '<').replaceAll('&', '&');
|
||||
}
|
||||
|
||||
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
|
||||
try {
|
||||
return evaluateCondition(expression, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { CMMN_XML, NEGOTIATE_PHASES, NEGOTIATE_STEPS } from '../src/case-model.ts';
|
||||
import { manifest } from '../src/manifest.ts';
|
||||
|
||||
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
|
||||
(m) => m[1] ?? '',
|
||||
);
|
||||
|
||||
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
|
||||
it('每一条剧本都指向一个真实存在的计划项', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
|
||||
}
|
||||
});
|
||||
|
||||
it('图上每一步都有剧本', () => {
|
||||
for (const id of ITEM_IDS) {
|
||||
expect(NEGOTIATE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
|
||||
expect(manifest.assets.businessModel.steps).toBe(NEGOTIATE_STEPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
|
||||
/** 开案那一刻的两种局面:流内转来 / 单飞 —— 台账一行没有 */
|
||||
const FRESH_FLOW: Record<string, unknown> = {
|
||||
rfqRef: 'RFQ MD14.1',
|
||||
customerCode: 'CU-0142',
|
||||
items: [{ pn: 'BBPY00201209601Y00', quoted_price: 0.085 }],
|
||||
};
|
||||
const FRESH_SOLO: Record<string, unknown> = { subject: 'MD14.2 报价函发客户', body: 'XCJ-7788T 8K 批准价 14.80……' };
|
||||
|
||||
it('**开局一步都不该被自动收掉** —— 两种开局都要 false', () => {
|
||||
for (const fresh of [FRESH_FLOW, FRESH_SOLO]) {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
if (step.doneWhen === undefined) continue;
|
||||
expect(safeEval(step.doneWhen, fresh), `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 排版那一步 ─────────────────────────────────────────────── */
|
||||
|
||||
const composed = (over: Record<string, unknown>): Record<string, unknown> => ({
|
||||
line_total: 2,
|
||||
quote_negotiate_line_rows: 2,
|
||||
composed_total: 2,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('整份落表、行数对上、排版那一轮走完 → 收得掉', () => {
|
||||
expect(safeEval(doneOf('S1_compose'), composed({}))).toBe(true);
|
||||
});
|
||||
|
||||
it('**第一拍写完行就收步是不许的** —— composed_total 没写过,未绑定的名字比大小为假', () => {
|
||||
const scope = composed({});
|
||||
delete scope['composed_total'];
|
||||
expect(safeEval(doneOf('S1_compose'), scope)).toBe(false);
|
||||
});
|
||||
|
||||
it('行数两边对不上 → 收不掉(漏落一行当场现形,不等到第 10 拍)', () => {
|
||||
expect(safeEval(doneOf('S1_compose'), composed({ quote_negotiate_line_rows: 1 }))).toBe(false);
|
||||
});
|
||||
|
||||
/* ── 三处不给 doneWhen ──────────────────────────────────────── */
|
||||
|
||||
it('**发出等回音、异议拍板、放行三项都不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
|
||||
for (const item of ['S2_send', 'T_haggle', 'T_release']) {
|
||||
expect(NEGOTIATE_STEPS.find((s) => s.item === item)?.doneWhen, `${item} 不该有 doneWhen`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 逐条定案:判完了才算办完(收步判据与验收 L2 同源)────────────── */
|
||||
|
||||
const settled = (over: Record<string, unknown>): Record<string, unknown> => ({
|
||||
quote_negotiate_line_rows: 3,
|
||||
quote_negotiate_line_deal_status_won: 2,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 0,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
quote_negotiate_line_reply_type_counter: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('四档定案的条数加起来等于行数 → 逐条定案收得掉', () => {
|
||||
expect(safeEval(doneOf('S3_settle'), settled({}))).toBe(true);
|
||||
});
|
||||
|
||||
it('**有待拍板条目也照样收得掉** —— 判出来了就是判完了,还价的由下一步叫人拍板', () => {
|
||||
expect(
|
||||
safeEval(
|
||||
doneOf('S3_settle'),
|
||||
settled({
|
||||
quote_negotiate_line_deal_status_lost: 0,
|
||||
quote_negotiate_line_deal_status_haggle: 1,
|
||||
quote_negotiate_line_reply_type_counter: 1,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **模型自己写 REVISE 绕过拍板 → 这一步收不掉**(2026-08-03 实测逼出来的):
|
||||
* 一轮里模型在定案那一步直接把还价的行判成「退回重批」,HAGGLE 一条没有,
|
||||
* 「还价才叫人」那道门整个不开,单子一路 released 而没有人拍过板 —— 决策卡 0 张。
|
||||
* 收步判据补上「待拍板条数 = 客户还价条数」,两边都是基座现数,模型改不动。
|
||||
*/
|
||||
it('客户还了价却没落成待拍板(自己写了 REVISE)→ 收不掉,拍板绕不过去', () => {
|
||||
expect(
|
||||
safeEval(
|
||||
doneOf('S3_settle'),
|
||||
settled({
|
||||
quote_negotiate_line_deal_status_won: 1,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 1,
|
||||
quote_negotiate_line_deal_status_lost: 1,
|
||||
quote_negotiate_line_reply_type_counter: 1,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('还有行没判 → 收不掉(松出来的那一截就是放行门前的返工)', () => {
|
||||
expect(safeEval(doneOf('S3_settle'), settled({ quote_negotiate_line_deal_status_lost: 0 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('**一行都还没写时 0+0+0+0 = 0 不算判完** —— 全零相等那一坑,靠 rows > 0 挡住', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S3_settle'), {
|
||||
quote_negotiate_line_reply_type_counter: 0,
|
||||
quote_negotiate_line_rows: 0,
|
||||
quote_negotiate_line_deal_status_won: 0,
|
||||
quote_negotiate_line_deal_status_haggle: 0,
|
||||
quote_negotiate_line_deal_status_revise: 0,
|
||||
quote_negotiate_line_deal_status_lost: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('归一那一步声明了 reads: items —— 上游载荷置前不截断', () => {
|
||||
expect(NEGOTIATE_STEPS.find((s) => s.item === 'S1_compose')?.reads ?? []).toContain('items');
|
||||
});
|
||||
|
||||
/*
|
||||
* **一单同一张卡只发一次**,且缺信息不再走补全卡(2026-08-02 全局退役)——
|
||||
* 剧本里不许残留「还缺就再发一张卡」这条已经不存在的路。
|
||||
*/
|
||||
it('剧本不教补全卡,也不留「还缺就再发一张」的死路', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
expect(step.guide, `${step.item} 的 guide 提到了补全卡`).not.toMatch(/补全卡|"kind":"ask"/);
|
||||
expect(step.guide, `${step.item} 的 guide 教了再发一张卡`).not.toMatch(/再发一张卡|重新发一张卡/);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **一单只发一张卡**:人点完之后这一步会被重新念一遍,而 guide 的头一段说什么,
|
||||
* 模型就先做什么 —— 头一段若是「发一张决策卡」,它就再发一张(A4 实测连发四张,
|
||||
* 调人预算烧光 exhausted)。所以拍板那一步的 guide 必须**先分岔再发卡**。
|
||||
*/
|
||||
it('拍板那一步先问「人点过没有」,再谈发卡 —— 分岔要排在发卡骨架前面', () => {
|
||||
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
|
||||
const branch = guide.indexOf('找到了');
|
||||
const skeleton = guide.indexOf('【发一张决策卡');
|
||||
expect(branch, 'T_haggle 的 guide 没有「人点过没有」这一岔').toBeGreaterThanOrEqual(0);
|
||||
expect(skeleton, 'T_haggle 的 guide 没有发卡骨架').toBeGreaterThan(branch);
|
||||
expect(guide).toMatch(/不许再发|别再发第二张卡/);
|
||||
});
|
||||
|
||||
/*
|
||||
* **拍板那三条路都得动 deal_status** —— 这是 2026-08-03 逼出来的结构账:
|
||||
* 上一版 revise 那条写着「定案不动」,人点完了 haggle 还是那个数,
|
||||
* 入场哨兵照旧成立,模型读同一段做法就再发一张卡(实测同一张连发六次)。
|
||||
* 「办完了」得在数上看得见,措辞治不了。
|
||||
*/
|
||||
it('拍板三条路各把 deal_status 改成一个结论码 —— 没有一条留在 HAGGLE', () => {
|
||||
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
|
||||
for (const code of ['WON', 'REVISE', 'LOST']) {
|
||||
expect(guide, `拍板剧本少了 ${code} 这条路`).toContain(`deal_status 改 **${code}**`);
|
||||
}
|
||||
expect(guide, '拍板剧本还留着「定案不动」这条自我循环的路').not.toContain('定案不动');
|
||||
expect(guide).toContain('quote_negotiate_line_deal_status_haggle');
|
||||
});
|
||||
|
||||
/*
|
||||
* start 墙已拆(2026-08-02):动它就算启动。剧本里再教 start 只是白烧 token,
|
||||
* 而且学不稳的礼仪就是死法。
|
||||
*/
|
||||
it('剧本不教 start 礼仪 —— 那道墙已经拆了(说「不必单发 start」可以,教它先按一下不行)', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
expect(step.guide, `${step.item} 的 guide 还在教 start`).not.toMatch(/先\s*start|务必\s*start|要先发\s*start/);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **等回音不落 `listen`**(见 case-model 头注):`listen` 在引擎里恒挂起,
|
||||
* 要外部事件才唤得醒,而这一单里回音的落单人本来就是 BD。
|
||||
* 剧本里若冒出一句「listen 等回音」,模型照做就是把整单挂到时限到期。
|
||||
*/
|
||||
it('定案那一步写死「这一拍到此为止」—— 收掉的步上再挂行动一律被拒', () => {
|
||||
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'S3_settle')?.guide ?? '';
|
||||
expect(guide, 'S3_settle 没写「这一拍到此为止」').toContain('这一拍到此为止');
|
||||
expect(guide, 'S3_settle 没说清决策卡属于下一步').toContain('T_haggle');
|
||||
expect(guide, 'S3_settle 没写死「REVISE 这一步不写」').toMatch(/REVISE[^\n]*这一步一格都不写/);
|
||||
});
|
||||
|
||||
/*
|
||||
* 【目标缺口】那一段摆的是计划项的**中文名**;当下可为为空(依赖断点)那几拍,
|
||||
* 模型手上只剩中文名,于是把它当 itemId 写了进去 —— 「计划项不存在:异议拍板」
|
||||
* 连拒两拍(2026-08-03 实测)。发卡骨架旁边必须点名说清写英文 id。
|
||||
*/
|
||||
it('发卡骨架写死了 itemId 是英文项号,并点名中文名不是 id', () => {
|
||||
const guide = NEGOTIATE_STEPS.find((s) => s.item === 'T_haggle')?.guide ?? '';
|
||||
expect(guide).toContain('"itemId":"T_haggle"');
|
||||
expect(guide, '没告诉模型中文名不能当 itemId').toMatch(/别写中文名/);
|
||||
for (const value of ['accept-counter', 'revise', 'hold']) {
|
||||
expect(guide, `发卡骨架少了选项 ${value}`).toContain(value);
|
||||
}
|
||||
});
|
||||
|
||||
it('剧本不教 listen 等回音 —— 那是一条挂到天荒地老的路', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
expect(step.guide, `${step.item} 的 guide 教了 listen`).not.toMatch(/"kind":"listen"|立监听|发一个\s*listen/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 NEGOTIATE_STEPS) {
|
||||
for (const address of step.writes) {
|
||||
expect(declaredLedger, `${step.item} 要写 ${address}`).toContain(address);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('每一步要调的工具都在清单的工具声明里', () => {
|
||||
for (const step of NEGOTIATE_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 NEGOTIATE_STEPS) {
|
||||
expect(step.view, `${step.item} 没定画面`).toBeDefined();
|
||||
expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('每一步都说得出它交出什么', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
|
||||
expect(step.guide.trim().length, `${step.item} 的 guide 太短`).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
/* 卡面是纯文本:星号记号不会变粗体,只会原样摆在人眼前(规范 §5.5-10) */
|
||||
it('发卡骨架里不写星号记号 —— 卡面是纯文本', () => {
|
||||
for (const step of NEGOTIATE_STEPS) {
|
||||
for (const line of step.guide.split('\n')) {
|
||||
if (!/"steps"|"standard"|"ask"|"label"|"consequence"/.test(line)) continue;
|
||||
expect(line.includes('**'), `${step.item} 的卡面文案带了星号:${line.slice(0, 60)}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('阶段刻度:每一项都有格可待', () => {
|
||||
const inPhase = new Set(NEGOTIATE_PHASES.flatMap((p) => p.items));
|
||||
|
||||
it('**除放行外每一项恰好属于一个阶段**', () => {
|
||||
const planned = NEGOTIATE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
|
||||
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it('一项不进两个阶段', () => {
|
||||
const all = NEGOTIATE_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(NEGOTIATE_STEPS.map((s) => s.item));
|
||||
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function doneOf(item: string): string {
|
||||
const found = NEGOTIATE_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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user