资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。
**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算
**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」
**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。
验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* E04 关系覆盖与关键人经营的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
|
||||
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
|
||||
* (判据谓词 / 台账声明的三级占位 / 决策卡循环的骨架 / 报告模版的读写面)。
|
||||
*/
|
||||
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('没有悬空监听(E04 本轮没有静态监听声明)', () => {
|
||||
expect(manifest.declarations.listeners).toEqual([]);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **人环预算必须严格大于「最后一次拍板」时的实际调人次数**(2026-08-20 实测栽过):
|
||||
* 基座按 `used >= cap` 判耗尽,若 cap 恰等于完成放行所需的调人数,「裁决:pass」
|
||||
* 那一次回复本身就把 used 顶到 cap,harnessCheck 在 land() 之前就判 exhausted ——
|
||||
* 验收卡显示全部达标、人也点了通过,任务却终态 exhausted,草稿从没合并进正式版。
|
||||
* 基线四次(S2/S4通过/S5/T_release)+ 两轮决策卡循环(每轮 +2)= 8,留够余量。
|
||||
*/
|
||||
it('人环预算留够余量:基线四次 + 两轮决策卡循环仍在 cap 之内', () => {
|
||||
const baseline = 4;
|
||||
const twoRejectRounds = baseline + 4;
|
||||
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(twoRejectRounds);
|
||||
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(twoRejectRounds);
|
||||
});
|
||||
|
||||
it('起单只必填 trigger,eventType/customers/note 都可选 —— 两级下拉+客户数组的形状', () => {
|
||||
expect(manifest.contract.input.data['required']).toEqual(['trigger']);
|
||||
const properties = manifest.contract.input.data['properties'] as Record<
|
||||
string,
|
||||
{ enum?: unknown[]; showWhen?: { field?: string; equals?: string } }
|
||||
>;
|
||||
for (const key of ['trigger', 'eventType', 'customers', 'note']) {
|
||||
expect(properties[key], `入参少了 ${key}`).toBeDefined();
|
||||
}
|
||||
expect(properties['trigger']?.enum).toEqual(['monthly', 'event']);
|
||||
expect(properties['eventType']?.enum).toEqual(['new_customer', 'key_person_change', 'major_deal', 't04_project']);
|
||||
// eventType 只在 trigger:'event' 时该露面 —— 测试台靠这个字段做级联下拉
|
||||
expect(properties['eventType']?.showWhen).toEqual({ field: 'trigger', equals: 'event' });
|
||||
});
|
||||
|
||||
it('五条常备样例形状真不同:月度例行 1 条 + 事件驱动四种事件类型各 1 条', () => {
|
||||
expect(manifest.samples.length).toBe(5);
|
||||
const [majorDeal, monthly, keyPerson, newCustomer, t04] = manifest.samples;
|
||||
type Input = { trigger?: string; eventType?: string; customers?: unknown[]; note?: string };
|
||||
expect((monthly?.input as Input).trigger).toBe('monthly');
|
||||
expect((monthly?.input as Input).customers).toBeUndefined();
|
||||
expect((majorDeal?.input as Input).eventType).toBe('major_deal');
|
||||
expect(typeof (majorDeal?.input as Input).customers?.[0]).toBe('string');
|
||||
expect((keyPerson?.input as Input).eventType).toBe('key_person_change');
|
||||
expect((keyPerson?.input as Input).customers).not.toEqual((majorDeal?.input as Input).customers);
|
||||
// 新客户样例:customers 里是对象(新建 L-02 档案的字段),不是客户编码字符串
|
||||
expect((newCustomer?.input as Input).eventType).toBe('new_customer');
|
||||
const newCustomerItem = (newCustomer?.input as Input).customers?.[0] as { name?: string } | undefined;
|
||||
expect(typeof newCustomerItem).toBe('object');
|
||||
expect(newCustomerItem?.name).toBeDefined();
|
||||
// 攻坚项目(T04)立项:四种事件类型里最后一种,点名已有客户(字符串形状)
|
||||
expect((t04?.input as Input).eventType).toBe('t04_project');
|
||||
expect((t04?.input as Input).customers).toEqual(['CU-0142']);
|
||||
// 四种事件类型两两不同,凑齐 enum 的全部取值
|
||||
const eventTypes = new Set([majorDeal, keyPerson, newCustomer, t04].map((s) => (s?.input as Input).eventType));
|
||||
expect(eventTypes).toEqual(new Set(['major_deal', 'key_person_change', 'new_customer', 't04_project']));
|
||||
for (const s of manifest.samples) expect(s.expect.ports).toEqual(['coverage-map']);
|
||||
});
|
||||
|
||||
/*
|
||||
* **落到这个活动上就该看见一份填好的入参**(2026-08-23 用户:「询价受理起单时左边
|
||||
* 都有预填好的活动输入,E04 也要」)。测试台自动铺的是 `samples[0]`,所以第一条
|
||||
* 必须四个字段全有值 —— 从前第一条是月度例行、只有 trigger 一个键,表单看着像没预填。
|
||||
* 每一条也都至少要有背景说明:整屏只有一个下拉有值的样例,人看不出这活动吃什么。
|
||||
*/
|
||||
it('样例 0 是满载的那条(四个字段全有值)—— 测试台落地即预填', () => {
|
||||
const first = manifest.samples[0]?.input as Record<string, unknown>;
|
||||
for (const key of ['trigger', 'eventType', 'customers', 'note']) {
|
||||
expect(first[key], `样例 0 缺 ${key}`).toBeDefined();
|
||||
}
|
||||
expect((first['customers'] as unknown[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('每条样例都带本轮说明,且样例名 ≤14 字(超了测试台只显示「样例 N」)', () => {
|
||||
for (const s of manifest.samples) {
|
||||
expect((s.input as { note?: string }).note, `样例「${s.name}」没有 note`).toBeTruthy();
|
||||
expect(s.name.length, `样例名「${s.name}」超过 14 字`).toBeLessThanOrEqual(14);
|
||||
}
|
||||
});
|
||||
|
||||
it('主数据追加列全部显式标 kind:master —— 与平台声明一致,不然静态审会拒', () => {
|
||||
for (const object of ['profile', 'contact']) {
|
||||
const decl = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === object);
|
||||
expect(decl?.kind, `customer.${object}`).toBe('master');
|
||||
}
|
||||
});
|
||||
|
||||
it('influence / priority / completion_status 三个 tally 列的取值域全是 ASCII', () => {
|
||||
for (const d of manifest.declarations.ledger) {
|
||||
for (const f of d.fields) {
|
||||
for (const value of f.tally ?? []) {
|
||||
expect(/^[a-z0-9_]+$/.test(value), `${d.domain}.${d.object}.${f.name} 的取值 ${value} 不是纯 ASCII —— data-class-of 会把它清空成空 class`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('customer.profile 新增 cust_type:L-02 文档明列的字段,expect 而非 required,不带 tally', () => {
|
||||
const profile = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'profile');
|
||||
const custType = profile?.fields.find((f) => f.name === 'cust_type');
|
||||
expect(custType, 'customer.profile 缺 cust_type').toBeDefined();
|
||||
expect(custType?.expect).toBe(true);
|
||||
expect(custType?.required).toBeUndefined();
|
||||
expect(custType?.tally).toBeUndefined();
|
||||
});
|
||||
|
||||
it('crm_coverage.verdict 新增 event_type 列(人读标签,月度例行留空)', () => {
|
||||
const verdict = manifest.declarations.ledger.find((d) => d.object === 'verdict');
|
||||
expect(verdict?.fields.find((f) => f.name === 'event_type')).toBeDefined();
|
||||
});
|
||||
|
||||
it('我方追加的批注列(change_log)不标 expect —— 标了会让 _gaps 永远大于零', () => {
|
||||
const contact = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'contact');
|
||||
expect(contact?.fields.find((f) => f.name === 'change_log')?.expect).toBeUndefined();
|
||||
});
|
||||
|
||||
it('候选客户清单的客户编码/客户名称都标 required —— 业务说明明文两列必填', () => {
|
||||
const candidate = manifest.declarations.ledger.find((d) => d.object === 'candidate');
|
||||
expect(candidate?.fields.find((f) => f.name === 'customer_code')?.required).toBe(true);
|
||||
expect(candidate?.fields.find((f) => f.name === 'customer_name')?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('receipt 的完成状态/结果摘要/关键人变动三列必填,客户反馈选填', () => {
|
||||
const receipt = manifest.declarations.ledger.find((d) => d.object === 'receipt');
|
||||
for (const col of ['completion_status', 'result_summary', 'key_person_change']) {
|
||||
expect(receipt?.fields.find((f) => f.name === col)?.required, col).toBe(true);
|
||||
}
|
||||
expect(receipt?.fields.find((f) => f.name === 'customer_feedback')?.required).toBeUndefined();
|
||||
});
|
||||
|
||||
it('两个工具都是纯工具(pure),批量入参 customers/contacts 优先于单条', () => {
|
||||
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
|
||||
expect(declared.has('crm-relationship-coverage.recompute-score')).toBe(true);
|
||||
expect(declared.has('crm-relationship-coverage.rank-key-persons')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('案例模型 —— 双表示同版本', () => {
|
||||
const model = manifest.assets.businessModel;
|
||||
const cmmn = parseCmmnLite(model.cmmnXml);
|
||||
|
||||
it('CMMN 与说明书同在,不允许分叉', () => {
|
||||
expect(model.cmmnXml).toContain('<casePlanModel');
|
||||
expect(model.narrative.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
/*
|
||||
* 收口拆两项(2.0.4 结构治):写回主数据(S6_writeback)与留更新明细(S6_ledger_update)
|
||||
* 各一项 —— 从前一项三拍,模型照样把四张表挤进一串,串尾最费字数的明细表被截成
|
||||
* 空数组挨拒(两轮实测同一处);剧本按项摊开,拆开之后串想长也长不起来。
|
||||
*/
|
||||
it('八个计划项:识别候选 / 人核对 / 制定计划 / 审批 / 执行回执 / 写回主数据 / 留更新明细 / 放行', () => {
|
||||
expect(items(model.cmmnXml)).toEqual([
|
||||
'S1_identify_candidates',
|
||||
'S2_scope_confirm',
|
||||
'S3_plan',
|
||||
'S4_plan_approval',
|
||||
'S5_execute_receipt',
|
||||
'S6_writeback',
|
||||
'S6_ledger_update',
|
||||
'T_release',
|
||||
]);
|
||||
});
|
||||
|
||||
it('四处人的门都标了人工激活:核对范围 / 审批 / 执行回执 / 放行(识别/制定计划/写台账是查表或AI起草,不标)', () => {
|
||||
expect(cmmn.manualActivation).toEqual(['S2_scope_confirm', 'S4_plan_approval', 'S5_execute_receipt', 'T_release']);
|
||||
});
|
||||
|
||||
it('不用阶段 / 里程碑 / 裁量项', () => {
|
||||
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
|
||||
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
|
||||
}
|
||||
});
|
||||
|
||||
it('人工激活的四项在剧本里都没给 doneWhen —— 给了就是给引擎替人签字的钥匙', () => {
|
||||
const manual = new Set(cmmn.manualActivation);
|
||||
for (const step of model.steps ?? []) {
|
||||
if (manual.has(step.item)) expect(step.doneWhen, `${step.item} 不该有 doneWhen`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('AI 起草的三项都给了 doneWhen,且不是「未绑定名字恒真」那种坑', () => {
|
||||
const manual = new Set(cmmn.manualActivation);
|
||||
for (const step of model.steps ?? []) {
|
||||
if (manual.has(step.item)) continue;
|
||||
expect(step.doneWhen, `${step.item} 缺 doneWhen`).toBeDefined();
|
||||
expect(step.doneWhen, `${step.item} 的 doneWhen 不许用 != null`).not.toContain('!= null');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **放行门的入场哨兵认「办完了没有」,四处全读基座数**(2026-08-02 那类「只有 onPart
|
||||
* 没有 ifPart 等于没有门」的坑,与「写台账要给行 rows 为空」的哨兵松绑同一类)。
|
||||
*/
|
||||
it('放行门哨兵带 ifPart,四个条件全是基座读数或本步显式记账的旗', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_ledger_updated');
|
||||
expect(condition).toContain('ledger_update_confirmed_total');
|
||||
expect(condition).toContain('crm_coverage_ledger_update_rows');
|
||||
expect(condition).toContain('crm_coverage_ledger_update_missing');
|
||||
expect(condition).toContain('customer_profile_missing');
|
||||
expect(condition).toContain('customer_contact_missing');
|
||||
const done = {
|
||||
ledger_update_confirmed_total: 1,
|
||||
crm_coverage_ledger_update_rows: 4,
|
||||
crm_coverage_ledger_update_missing: 0,
|
||||
customer_profile_missing: 0,
|
||||
customer_contact_missing: 0,
|
||||
};
|
||||
expect(safeEval(condition, done)).toBe(true);
|
||||
// 写完了但主数据还缺必填 → 门不开
|
||||
expect(safeEval(condition, { ...done, customer_profile_missing: 1 })).toBe(false);
|
||||
// 写过一笔但还没显式记账(旗还没置一)→ 门不开(踩过「只有 onPart 没有 ifPart」的坑)
|
||||
expect(safeEval(condition, { ...done, ledger_update_confirmed_total: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('S3 制定计划的入场哨兵认「人核对过了」,不认「AI 起草过」', () => {
|
||||
const condition = sentryIf(model.cmmnXml, 's_scope_confirmed');
|
||||
expect(condition).toContain('scope_confirmed_total');
|
||||
expect(safeEval(condition, { scope_confirmed_total: 1 })).toBe(true);
|
||||
expect(safeEval(condition, {})).toBe(false);
|
||||
});
|
||||
|
||||
it('每个计划项都有剧本,guide 有实质内容', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **决策卡循环三处写死的东西也进说明书**(照 A6 T_haggle 立的规矩):
|
||||
* itemId / 三个 value / allowCustom 断点那几拍模型手上只剩这份说明书。
|
||||
*/
|
||||
it('说明书写死了决策卡的 itemId / 三个 value / allowCustom', () => {
|
||||
expect(model.narrative).toContain('S4_plan_approval');
|
||||
for (const value of ['approve', 'reject', 'custom:']) {
|
||||
expect(model.narrative, `说明书少了 value ${value}`).toContain(value);
|
||||
}
|
||||
expect(model.narrative).toContain('allowCustom');
|
||||
});
|
||||
|
||||
/*
|
||||
* **重发判断走在「问理由」之前**(2026-08-20 实测逼出来的修复):固定按钮点出来的
|
||||
* 驳回永远没有 note,再问一遍是把人指向一扇打不开的门。S4 的 guide 必须写死
|
||||
* 「不许再发卡追问理由」,而不是 A6 T_haggle 那种「找不到答复才发卡」的单发模式
|
||||
* (E04 的决策卡允许多轮循环,与 A6 恰好相反,两处不能弄混)。
|
||||
*/
|
||||
it('S4 的 guide 写死「reject 不许追问理由」,不是无脑重问', () => {
|
||||
const s4 = (model.steps ?? []).find((s) => s.item === 'S4_plan_approval');
|
||||
expect(s4?.guide).toContain('不许再发卡追问理由');
|
||||
});
|
||||
|
||||
/*
|
||||
* **新客户建档的数据录入卡骨架也写死进说明书**(与 S4 决策卡同一条纪律):
|
||||
* "断点"那几拍模型手上只保证摊开 NARRATIVE,S1 自己的 guide 不一定摊开。
|
||||
* 照抄 A1 quote-intake 的 S1_customer 机制——`customer_code` 从不给 AI 编,
|
||||
* 必须由人在卡上现场给。
|
||||
*/
|
||||
it('说明书写死了新客户录入卡的骨架:entry / customer.profile / customer_code 现场给', () => {
|
||||
expect(model.narrative).toContain('"kind":"entry"');
|
||||
expect(model.narrative).toContain('"ledger":"customer.profile"');
|
||||
expect(model.narrative).toContain('customer_code');
|
||||
expect(model.narrative, '不许自己编客户编码').toContain('不能自己编一个');
|
||||
});
|
||||
|
||||
it('S1 的 guide 写死「人确认后不许再 write 一遍 customer.profile/.contact」', () => {
|
||||
const s1 = (model.steps ?? []).find((s) => s.item === 'S1_identify_candidates');
|
||||
expect(s1?.guide).toContain('不许你再');
|
||||
expect(s1?.guide).toContain('customer.profile');
|
||||
expect(s1?.guide).toContain('customer.contact');
|
||||
});
|
||||
});
|
||||
|
||||
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 全是空数组 —— 验收工单只读展示,不可修改', () => {
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
expect(d.editableAtRelease, d.key).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
if (identifier === 'plan_approved_total' || identifier === 'scope_confirmed_total') continue; // 一次性记账旗,直接是 caseFileItem
|
||||
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) ?? [];
|
||||
const grounded = names.some((n) => rowReadings.has(n) || n.endsWith('_total'));
|
||||
expect(grounded, `${c.key} 一个基座读数/记账旗都没读`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('单一必发口 coverage-map,无选择组(E04 全程线性,无分支)', () => {
|
||||
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['coverage-map']);
|
||||
expect(manifest.contract.output.selective).toEqual([]);
|
||||
});
|
||||
|
||||
it('coverage-map 出口标量字段都有台账列承载', () => {
|
||||
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
const port = manifest.contract.output.mandatory[0];
|
||||
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('L4 判据裁量语句整句出现在放行剧本里 —— judge 才对得上名', () => {
|
||||
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level !== 'L4') continue;
|
||||
expect(release?.guide, `T_release 的 guide 要整句抄 ${c.key}`).toContain(c.label);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
|
||||
it('四个局面各一张:候选清单 / 经营计划 / 执行回执 / 台账更新明细', () => {
|
||||
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual([
|
||||
'coverage-scope',
|
||||
'coverage-plan',
|
||||
'coverage-receipt',
|
||||
'coverage-ledger-update',
|
||||
]);
|
||||
});
|
||||
|
||||
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('coverage-ledger-update');
|
||||
});
|
||||
|
||||
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('候选清单可编辑:data-edit 指向 crm_coverage.candidate,主键客户编码挂了 data-col', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-scope')?.html ?? '';
|
||||
expect(html).toContain('data-edit="crm_coverage.candidate"');
|
||||
expect(html).toContain('data-col="customer_code"');
|
||||
});
|
||||
|
||||
it('经营计划只读:没有任何 data-edit / data-col —— 人只审不改', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-plan')?.html ?? '';
|
||||
expect(html).not.toContain('data-edit=');
|
||||
expect(html).not.toContain('data-col=');
|
||||
});
|
||||
|
||||
it('台账更新明细只读:没有任何 data-edit / data-col —— 归档件不给改', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? '';
|
||||
expect(html).not.toContain('data-edit=');
|
||||
expect(html).not.toContain('data-col=');
|
||||
});
|
||||
|
||||
it('执行回执混排:主键联系人编码挂 data-col,四个可填列各自挂了 data-col', () => {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-receipt')?.html ?? '';
|
||||
expect(html).toContain('data-edit="crm_coverage.receipt"');
|
||||
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
|
||||
expect(cols).toEqual(
|
||||
new Set(['contact_code', 'completion_status', 'result_summary', 'customer_feedback', 'key_person_change']),
|
||||
);
|
||||
});
|
||||
|
||||
it('每个可编辑格子的 data-col 都在对应台账对象的声明里 —— 编辑面不能超出声明的字段', () => {
|
||||
const editableViews: Array<[string, string]> = [
|
||||
['coverage-scope', 'candidate'],
|
||||
['coverage-receipt', 'receipt'],
|
||||
];
|
||||
for (const [view, object] of editableViews) {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === view)?.html ?? '';
|
||||
const declared = new Set(manifest.declarations.ledger.find((d) => d.object === object)?.fields.map((f) => f.name));
|
||||
for (const col of [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1])) {
|
||||
expect(declared.has(col ?? ''), `${view} 的 data-col="${col}" 不在 crm_coverage.${object} 的声明里`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('缺必填红带与提交闸同源 —— 可编辑件上摆的是基座的 `_missing` 与主键名单', () => {
|
||||
for (const [name, object] of [
|
||||
['coverage-scope', 'candidate'],
|
||||
['coverage-receipt', 'receipt'],
|
||||
] as const) {
|
||||
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
|
||||
expect(html, name).toContain(`crm_coverage_${object}_missing`);
|
||||
expect(html, name).toContain(`crm_coverage_${object}_missing_keys`);
|
||||
}
|
||||
});
|
||||
|
||||
it('三个状态徽(候选状态/优先级/完成状态)只用 data-class-of 投影,不写 data-slot 重复暴露原值', () => {
|
||||
const scope = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-scope')?.html ?? '';
|
||||
expect(scope).toContain('<span class="jc-tag" data-class-of="status"></span>');
|
||||
const plan = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-plan')?.html ?? '';
|
||||
expect(plan).toContain('<span class="jc-tag" data-class-of="priority"></span>');
|
||||
const receipt = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-receipt')?.html ?? '';
|
||||
expect(receipt).toContain('<span class="jc-tag" data-class-of="completion_status"></span>');
|
||||
});
|
||||
|
||||
it('宿主拒推含脚本的模版', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
||||
});
|
||||
});
|
||||
|
||||
const CONTRACT = {
|
||||
activityId: 'crm-relationship-coverage',
|
||||
version: '1.0.0',
|
||||
data: {},
|
||||
message: undefined,
|
||||
outputPorts: { mandatory: ['coverage-map'], selective: [] },
|
||||
prohibitions: [],
|
||||
discretion: [],
|
||||
} as const;
|
||||
|
||||
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
|
||||
it('默认导出是一个有名字的活动定义', () => {
|
||||
expect(activity.name).toBe('crm-relationship-coverage');
|
||||
});
|
||||
|
||||
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
|
||||
const host = createMockHost({ contract: CONTRACT });
|
||||
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
|
||||
});
|
||||
});
|
||||
|
||||
function items(xml: string): string[] {
|
||||
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 有的 humanTask 是自闭合标签(`<humanTask .../>`,如没有人工激活的 S1)——
|
||||
* `[\s\S]*?<\/humanTask>` 这种非贪婪匹配会跳过自闭合标签、错吞下一个 humanTask
|
||||
* 的内容(把 S2 的 manualActivationRule 记到 S1 头上)。两种收尾分别匹配。
|
||||
*/
|
||||
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,317 @@
|
||||
/**
|
||||
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(与 quote-* 各包同一套惯例)。
|
||||
*
|
||||
* E04 的实测账本(2026-08-21/22)每一条都对应这里的一类断言:
|
||||
* · S1 只写一行就 finish → doneWhen 的「该有几行」半句 + count(customers) 语义;
|
||||
* · S5 抢跑写旗 → 人工激活项不给 doneWhen(activity.test.ts 钉)+ 收口列写全;
|
||||
* · 收口漏 influence → 新客户路放行门永远打不开(「碰过的行三列都带上」);
|
||||
* · 剧本 writes/tools 指向没声明的地址 → 运行时被「台账地址未声明」拒到熔断。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { CMMN_XML, COVERAGE_PHASES, COVERAGE_STEPS } from '../src/case-model.ts';
|
||||
import { COVERAGE_LEDGER_UPDATE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_SCOPE } from '../src/views/report.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 COVERAGE_STEPS) {
|
||||
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
|
||||
}
|
||||
});
|
||||
|
||||
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
|
||||
for (const id of ITEM_IDS) {
|
||||
expect(COVERAGE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('清单里挂的就是这份剧本与这份阶段(不是另抄一份)', () => {
|
||||
expect(manifest.assets.businessModel.steps).toBe(COVERAGE_STEPS);
|
||||
expect(manifest.assets.businessModel.phases).toBe(COVERAGE_PHASES);
|
||||
});
|
||||
|
||||
it('除放行外每个计划项恰属一个阶段 —— 进度条不许漏格,也不许一项占两格', () => {
|
||||
const inPhases = COVERAGE_PHASES.flatMap((p) => p.items);
|
||||
const expected = ITEM_IDS.filter((id) => id !== 'T_release');
|
||||
expect([...inPhases].sort()).toEqual([...expected].sort());
|
||||
expect(new Set(inPhases).size).toBe(inPhases.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('剧本声明的读写面 ⊆ 清单声明 —— 越出去的地址运行时才炸', () => {
|
||||
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
|
||||
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
|
||||
|
||||
it('steps[].writes 全部落在声明过的台账对象上', () => {
|
||||
for (const step of COVERAGE_STEPS) {
|
||||
for (const address of step.writes) {
|
||||
expect(declaredAddresses.has(address), `${step.item} 要写 ${address},清单没声明`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('steps[].tools 全部是声明过的工具', () => {
|
||||
for (const step of COVERAGE_STEPS) {
|
||||
for (const tool of step.tools) {
|
||||
expect(declaredTools.has(tool), `${step.item} 要调 ${tool},清单没声明`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneWhen 是「成果已存在」的判据 —— 开局不许真,干完必须真', () => {
|
||||
/** 四条常备样例各自的开案局面:台账一行没有,基座读数按声明铺 0 */
|
||||
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
|
||||
...input,
|
||||
crm_coverage_candidate_rows: 0,
|
||||
crm_coverage_candidate_missing: 0,
|
||||
crm_coverage_plan_rows: 0,
|
||||
crm_coverage_plan_missing: 0,
|
||||
crm_coverage_receipt_rows: 0,
|
||||
crm_coverage_receipt_missing: 0,
|
||||
crm_coverage_ledger_update_rows: 0,
|
||||
crm_coverage_ledger_update_missing: 0,
|
||||
customer_profile_missing: 0,
|
||||
customer_contact_missing: 0,
|
||||
});
|
||||
|
||||
it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => {
|
||||
for (const sample of manifest.samples) {
|
||||
for (const step of COVERAGE_STEPS) {
|
||||
if (step.doneWhen === undefined) continue;
|
||||
const ripe = safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>));
|
||||
expect(ripe, `样例「${sample.name}」下 ${step.item} 的 doneWhen 开局就为真`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **count(customers) 的三种形状钉死**(2026-08-22 注释与实现相悖那一课):
|
||||
* sfeel 的 count() 对未绑定名 flatten 出 [undefined],长度 1 —— 不是 0。
|
||||
* 月度例行(键不存在)靠 rows >= 1 与 rows > 0 同义而行为恰好正确;
|
||||
* 显式空数组才走 count = 0 那半句。这里用真 @sfeel 求值,注释再想撒谎测试拦着。
|
||||
*/
|
||||
it('月度例行(customers 键不存在):写下 1 行且必填齐 → S1 收得掉', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S1_identify_candidates'), {
|
||||
trigger: 'monthly',
|
||||
crm_coverage_candidate_rows: 1,
|
||||
crm_coverage_candidate_missing: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('点名两家客户只写了一行 → S1 收不掉(漏写的那家不会自己回来)', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S1_identify_candidates'), {
|
||||
trigger: 'event',
|
||||
customers: ['CU-0142', 'CU-0087'],
|
||||
crm_coverage_candidate_rows: 1,
|
||||
crm_coverage_candidate_missing: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('点名两家客户写齐两行 → S1 收得掉', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S1_identify_candidates'), {
|
||||
trigger: 'event',
|
||||
customers: ['CU-0142', 'CU-0087'],
|
||||
crm_coverage_candidate_rows: 2,
|
||||
crm_coverage_candidate_missing: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('显式传空数组(customers: []):count = 0 那半句真的兜住', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S1_identify_candidates'), {
|
||||
trigger: 'monthly',
|
||||
customers: [],
|
||||
crm_coverage_candidate_rows: 1,
|
||||
crm_coverage_candidate_missing: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('行齐了但缺必填 → S1 收不掉', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S1_identify_candidates'), {
|
||||
trigger: 'monthly',
|
||||
crm_coverage_candidate_rows: 2,
|
||||
crm_coverage_candidate_missing: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('计划有行且必填齐 → S3 收得掉;缺一头都收不掉', () => {
|
||||
expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 3, crm_coverage_plan_missing: 0 })).toBe(true);
|
||||
expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 0, crm_coverage_plan_missing: 0 })).toBe(false);
|
||||
expect(safeEval(doneOf('S3_plan'), { crm_coverage_plan_rows: 3, crm_coverage_plan_missing: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('台账更新明细齐 + 主数据必填齐 → S6 收得掉', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S6_ledger_update'), {
|
||||
crm_coverage_ledger_update_rows: 4,
|
||||
crm_coverage_ledger_update_missing: 0,
|
||||
customer_profile_missing: 0,
|
||||
customer_contact_missing: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('**收口写了明细却漏了联系人必填(如 influence)→ S6 收不掉** —— 新客户路的死锁口', () => {
|
||||
expect(
|
||||
safeEval(doneOf('S6_ledger_update'), {
|
||||
crm_coverage_ledger_update_rows: 4,
|
||||
crm_coverage_ledger_update_missing: 0,
|
||||
customer_profile_missing: 0,
|
||||
customer_contact_missing: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('实测账本对应的剧本口径 —— 改口要连测试一起改', () => {
|
||||
const writeback = COVERAGE_STEPS.find((s) => s.item === 'S6_writeback');
|
||||
const ledgerUpdate = COVERAGE_STEPS.find((s) => s.item === 'S6_ledger_update');
|
||||
|
||||
it('写回主数据那一项含 candidate(状态置 covered)与联系人三列的硬要求', () => {
|
||||
expect(writeback?.writes).toContain('crm_coverage.candidate');
|
||||
expect(writeback?.guide).toContain('covered');
|
||||
for (const column of ['influence', 'relationship', 'key_person']) {
|
||||
expect(writeback?.guide, `S6_writeback guide 没点名 ${column}`).toContain(column);
|
||||
}
|
||||
expect(writeback?.guide).toContain('新建档的联系人');
|
||||
});
|
||||
|
||||
/*
|
||||
* **放行那一步要接得住「被打回」**(2026-08-23,柴浩实测:点重做写了意见,模型照
|
||||
* T_release 的剧本「裁判据 → submit」原样又递回来 —— 剧本里压根没有「被打回了怎么办」
|
||||
* 这一支)。基座那道闸只拦得住原样重提,拦不出「该改哪一张表」——那是活动自己的知识。
|
||||
*/
|
||||
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
|
||||
const release = COVERAGE_STEPS.find((s) => s.item === 'T_release');
|
||||
expect(release?.guide).toContain('验收被打回重做');
|
||||
for (const address of ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.verdict']) {
|
||||
expect(release?.writes, `T_release 改不了 ${address}`).toContain(address);
|
||||
}
|
||||
// 改的是值不是数 —— 新开行会把收步判据弄假
|
||||
expect(release?.guide).toContain('覆盖写');
|
||||
});
|
||||
|
||||
/*
|
||||
* 收口拆两项的口径钉死(2.0.4 结构治):留明细那一项**只**写明细与结论 ——
|
||||
* 写面一旦混进别的表,「一串挤四张、串尾截成空数组」那口锅迟早再背一次。
|
||||
*/
|
||||
it('留更新明细那一项只写 ledger_update + verdict 两张表', () => {
|
||||
expect(ledgerUpdate?.writes).toEqual(['crm_coverage.ledger_update', 'crm_coverage.verdict']);
|
||||
expect(ledgerUpdate?.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('verdict 的触发列叫 trigger_label —— 与入参 trigger 同名会让首帧摆英文码', () => {
|
||||
const verdict = manifest.declarations.ledger.find((d) => d.object === 'verdict');
|
||||
expect(verdict?.fields.some((f) => f.name === 'trigger_label')).toBe(true);
|
||||
expect(verdict?.fields.some((f) => f.name === 'trigger')).toBe(false);
|
||||
});
|
||||
|
||||
it('plan 没有 status 列 —— 「过没过审」由 plan_approved_total 一处说了算', () => {
|
||||
const plan = manifest.declarations.ledger.find((d) => d.domain === 'crm_coverage' && d.object === 'plan');
|
||||
expect(plan?.fields.some((f) => f.name === 'status')).toBe(false);
|
||||
});
|
||||
|
||||
it('建档卡要写的六列都在声明里 —— 授权面是台账声明,说明书不许开空头支票', () => {
|
||||
const profile = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'profile');
|
||||
const contact = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'contact');
|
||||
for (const column of ['name', 'industry', 'region']) {
|
||||
expect(profile?.fields.some((f) => f.name === column), `customer.profile 缺 ${column}`).toBe(true);
|
||||
}
|
||||
for (const column of ['name', 'title', 'email']) {
|
||||
expect(contact?.fields.some((f) => f.name === column), `customer.contact 缺 ${column}`).toBe(true);
|
||||
}
|
||||
// 邮箱照平台口径标脱敏 —— 不标的话治理面那一层裁剪不到它
|
||||
expect(contact?.fields.find((f) => f.name === 'email')?.masked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('四份工单模版照目标件 §6.2 补齐的动态位', () => {
|
||||
const ALL = [COVERAGE_SCOPE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_LEDGER_UPDATE];
|
||||
|
||||
it('卡头:收件人 + 关联任务;side:步骤行;页脚:单号 —— 四份都有', () => {
|
||||
for (const view of ALL) {
|
||||
expect(view).toContain('data-slot="case:owner"');
|
||||
expect(view).toContain('data-slot="case:taskId"');
|
||||
expect(view).toContain('jc-rp-step');
|
||||
// 页脚右侧「工单类型:… · <单号>」—— round_ref 是唯一真数据源
|
||||
expect(view).toContain('data-slot="round_ref"');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **枚举列上下两态说同一个字**(2026-08-23 用户:「BT 层级下拉框显示不全,
|
||||
* 都看不到具体选了哪个」):存的是 ASCII(台账与 recompute-score 的 btLevel 入参认它),
|
||||
* 那么**只读那一份必须走 data-class-of 徽标**,由样式表出人读名 ——
|
||||
* data-slot 直显会把 bt-02 摆到人脸上,与编辑态下拉里的「BT-02 · 技术」对不上。
|
||||
* 覆盖深度当初修过这个坑,BT 层级漏了;列宽也得放得下选项文字,否则下拉被挤扁。
|
||||
*/
|
||||
it('BT 层级与覆盖深度:只读态走徽标(不 data-slot 直显 ASCII),且人读名在样式表里', () => {
|
||||
for (const column of ['bt_level', 'depth']) {
|
||||
expect(COVERAGE_SCOPE).toContain(`data-class-of="${column}"`);
|
||||
expect(COVERAGE_SCOPE, `${column} 不该 data-slot 直显机器码`).not.toContain(`data-slot="${column}"`);
|
||||
}
|
||||
for (const label of ['BT-01 · 采购/供应链', 'BT-02 · 技术', 'BT-03 · 高层']) {
|
||||
expect(COVERAGE_SCOPE, `徽标字典缺「${label}」`).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it('BT 层级列放得下选项文字 —— 80px 那会儿下拉被挤扁,选了什么看不见', () => {
|
||||
const head = /<th[^>]*>BT 层级<\/th>/.exec(COVERAGE_SCOPE)?.[0] ?? '';
|
||||
const width = Number(/min-width:(\d+)px/.exec(head)?.[1] ?? 0);
|
||||
expect(width, `BT 层级列宽 ${width}px,装不下「BT-01 · 采购/供应链」`).toBeGreaterThanOrEqual(140);
|
||||
});
|
||||
|
||||
it('执行工单1 的编辑闸照目标件措辞:「AI 已识别 N 个客户」,N 是基座读数', () => {
|
||||
expect(COVERAGE_SCOPE).toContain('AI 已识别');
|
||||
expect(COVERAGE_SCOPE).toContain('data-slot="crm_coverage_candidate_rows"');
|
||||
// 触发口径并进【做什么】,不再单开一段(照目标件的 note 结构)
|
||||
expect(COVERAGE_SCOPE).not.toContain('【本轮触发】');
|
||||
expect(COVERAGE_SCOPE).toContain('data-slot="trigger_label"');
|
||||
});
|
||||
|
||||
it('经营计划表摆得出时间点 —— 验收判据③指向的东西必须在卡面上', () => {
|
||||
expect(COVERAGE_PLAN).toContain('data-slot="target_date"');
|
||||
});
|
||||
|
||||
it('执行回执:对象列长「客户 · 关键人」;结果摘要/客户反馈照目标件是 textarea', () => {
|
||||
expect(COVERAGE_RECEIPT).toContain('data-slot="customer_name"');
|
||||
expect(COVERAGE_RECEIPT).toContain('<textarea class="jc-cell" data-col="result_summary"');
|
||||
expect(COVERAGE_RECEIPT).toContain('<textarea class="jc-cell" data-col="customer_feedback"');
|
||||
});
|
||||
|
||||
it('台账更新确认摆得出依据与编码 —— 三条 L4 判据不再让人判他看不见的东西', () => {
|
||||
expect(COVERAGE_LEDGER_UPDATE).toContain('data-slot="evidence_ref"');
|
||||
expect(COVERAGE_LEDGER_UPDATE).toContain('data-slot="customer_code"');
|
||||
});
|
||||
});
|
||||
|
||||
function doneOf(item: string): string {
|
||||
const found = COVERAGE_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