Archived
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2): - 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进 S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视 - 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo, 计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标) - 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源, 正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填 - 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键; 标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段 - 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数 - 阶段刻度对齐文档 7 步;起单样例预填满 基座侧: - 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY 广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻 - 多行 write 整批先验后写(dryRun),被拒 = 一行都没落 - 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors) - 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行) - 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端 - 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸 - 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组; JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖 - 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01 契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo, objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。 1700 测 / typecheck / lint / depcruise / api:check 绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
462 lines
22 KiB
TypeScript
462 lines
22 KiB
TypeScript
/**
|
|
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(与 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 收得掉;缺一头都收不掉', () => {
|
|
const full = {
|
|
crm_coverage_plan_rows: 3,
|
|
crm_coverage_plan_missing: 0,
|
|
crm_coverage_plan_distinct_customer_code: 2,
|
|
crm_coverage_candidate_rows: 2,
|
|
crm_coverage_receipt_rows: 3,
|
|
};
|
|
expect(safeEval(doneOf('S3_plan'), full)).toBe(true);
|
|
expect(safeEval(doneOf('S3_plan'), { ...full, crm_coverage_plan_rows: 0 })).toBe(false);
|
|
expect(safeEval(doneOf('S3_plan'), { ...full, crm_coverage_plan_missing: 2 })).toBe(false);
|
|
});
|
|
|
|
it('**回执骨架比计划少一行 → S3 收不掉、S4 哨兵不开**(2026-08-24 柴浩实测:计划 7 回执 6,人填完就能交卷)', () => {
|
|
expect(
|
|
safeEval(doneOf('S3_plan'), {
|
|
crm_coverage_plan_rows: 7,
|
|
crm_coverage_plan_missing: 0,
|
|
crm_coverage_plan_distinct_customer_code: 6,
|
|
crm_coverage_candidate_rows: 6,
|
|
crm_coverage_receipt_rows: 6,
|
|
}),
|
|
).toBe(false);
|
|
const sentry = /<ifPart id="if_pa"><condition>([^<]+)<\/condition>/.exec(CMMN_XML)?.[1] ?? '';
|
|
expect(sentry).toContain('crm_coverage_receipt_rows = crm_coverage_plan_rows');
|
|
const s4 = COVERAGE_STEPS.find((x) => x.item === 'S4_plan_approval')?.guide ?? '';
|
|
expect(s4).toContain('加了一条计划就同拍给它加一行');
|
|
const rel = COVERAGE_STEPS.find((x) => x.item === 'T_release')?.guide ?? '';
|
|
expect(rel).toContain('不许删计划来凑数');
|
|
});
|
|
|
|
it('**计划漏了一家候选客户 → S3 收不掉**(2026-08-24 柴浩实测:人加的那家被从头无视到尾)', () => {
|
|
// 人在覆盖范围确认卡上加了一家(candidate 2 行),计划只盖住 AI 识别的那一家
|
|
expect(
|
|
safeEval(doneOf('S3_plan'), {
|
|
crm_coverage_plan_rows: 3,
|
|
crm_coverage_plan_missing: 0,
|
|
crm_coverage_plan_distinct_customer_code: 1,
|
|
crm_coverage_candidate_rows: 2,
|
|
}),
|
|
).toBe(false);
|
|
// S4 的入场哨兵是同一句闭合 —— 计划没盖全,审批卡就不该发出来
|
|
const sentry = /<ifPart id="if_pd"><condition>([^<]+)<\/condition>/.exec(CMMN_XML)?.[1] ?? '';
|
|
expect(sentry).toContain('crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows');
|
|
});
|
|
|
|
it('覆盖闭合的两边都是基座数:distinct 声明在 plan.customer_code 上,放行 L2 也钉同一句', () => {
|
|
const plan = manifest.declarations.ledger.find((l) => l.object === 'plan');
|
|
expect(plan?.fields.find((f) => f.name === 'customer_code')?.distinct).toBe(true);
|
|
const criterion = manifest.contract.acceptanceCriteria.find((c) => c.key === 'plan-covers-scope');
|
|
expect(criterion?.level).toBe('L2');
|
|
expect(criterion?.predicate).toBe('crm_coverage_plan_distinct_customer_code = crm_coverage_candidate_rows');
|
|
});
|
|
|
|
it('台账更新明细齐 + 主数据必填齐 + 无重复位置 → S6 收得掉', () => {
|
|
expect(
|
|
safeEval(doneOf('S6_ledger_update'), {
|
|
crm_coverage_ledger_update_rows: 4,
|
|
crm_coverage_ledger_update_missing: 0,
|
|
crm_coverage_ledger_update_distinct_update_key: 4,
|
|
customer_profile_missing: 0,
|
|
customer_contact_missing: 0,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('**重做后旧明细没撤(同一处位置两条)→ S6 收不掉**(2026-08-24 用户:「重做后老的变更记录应该删除」)', () => {
|
|
expect(
|
|
safeEval(doneOf('S6_ledger_update'), {
|
|
crm_coverage_ledger_update_rows: 5,
|
|
crm_coverage_ledger_update_missing: 0,
|
|
crm_coverage_ledger_update_distinct_update_key: 4,
|
|
customer_profile_missing: 0,
|
|
customer_contact_missing: 0,
|
|
}),
|
|
).toBe(false);
|
|
const decl = manifest.declarations.ledger.find((l) => l.object === 'ledger_update');
|
|
const col = decl?.fields.find((f) => f.name === 'update_key');
|
|
expect(col?.distinct).toBe(true);
|
|
expect(col?.required).toBe(true);
|
|
expect(manifest.contract.acceptanceCriteria.some((c) => c.key === 'no-duplicate-update' && c.level === 'L2')).toBe(true);
|
|
// 依据一列写人话:不带联系人编码这类机器键;重做分支写明过期行要撤
|
|
const s6 = COVERAGE_STEPS.find((x) => x.item === 'S6_ledger_update')?.guide ?? '';
|
|
expect(s6).toContain('不写联系人编码');
|
|
expect(s6).toContain('update_key');
|
|
const rel = COVERAGE_STEPS.find((x) => x.item === 'T_release')?.guide ?? '';
|
|
expect(rel).toContain('过期的旧记录要撤掉');
|
|
});
|
|
|
|
it('四份件标题旁不再摆单号(2026-08-24 用户定);验收判据与「AI 已完成」文案不夹字段名', () => {
|
|
for (const t of manifest.assets.viewTemplates) {
|
|
expect(t.html).not.toMatch(/<h3>[^<]*<span data-slot="round_ref">/);
|
|
}
|
|
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? '';
|
|
const prose = html.replace(/<style>[\s\S]*?<\/style>/, '').replace(/data-[a-z-]+="[^"]*"/g, '');
|
|
expect(prose).not.toMatch(/coverage_score|decision_chain/);
|
|
});
|
|
|
|
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('驳回意见先认归属:S4 剧本写死「照基座比对的观察认、不许改到没点名的行、认不出唯一就重发卡问」', () => {
|
|
// 2026-08-24 柴浩实测:意见点名「测试111」,模型把改动落到了 Itelma 那一行
|
|
const guide = COVERAGE_STEPS.find((x) => x.item === 'S4_plan_approval')?.guide ?? '';
|
|
expect(guide).toContain('先认归属');
|
|
expect(guide).toContain('意见按字面点名对上了');
|
|
expect(guide).toContain('不是把改动挪到别家');
|
|
expect(guide).toContain('不许猜');
|
|
});
|
|
|
|
it('姓名是必填:客户档案与联系人档案都不许无名落库(2026-08-24 两条空联系人 + 无名新客户档案)', () => {
|
|
const profile = manifest.declarations.ledger.find((l) => l.domain === 'customer' && l.object === 'profile');
|
|
const contact = manifest.declarations.ledger.find((l) => l.domain === 'customer' && l.object === 'contact');
|
|
expect(profile?.fields.find((f) => f.name === 'name')?.required).toBe(true);
|
|
expect(contact?.fields.find((f) => f.name === 'name')?.required).toBe(true);
|
|
// 行业/区域是建议档:AI 推得出就填,推不出橘胶囊留给人(确认屏上现在改得了)
|
|
expect(profile?.fields.find((f) => f.name === 'industry')?.expect).toBe(true);
|
|
expect(profile?.fields.find((f) => f.name === 'region')?.expect).toBe(true);
|
|
// 剧本写死:没有真实姓名的人不建联系人行;碰过的行 name 照现值带上
|
|
const guide = COVERAGE_STEPS.find((x) => x.item === 'S6_writeback')?.guide ?? '';
|
|
expect(guide).toContain('没有真实姓名的人不许建联系人行');
|
|
expect(guide).toContain('name');
|
|
expect(guide).toContain('照候选行抄');
|
|
expect(guide).toContain('不许编造一个查无出处的行业或地区');
|
|
});
|
|
|
|
it('计划只指向真人:占位句式已退役,查无联系人走数据录入卡(2026-08-24 用户裁定)', () => {
|
|
// 实测:模型把占位句式泛化到缺口节点,给 Itelma 编了两个顺延引用码 + 占位人名
|
|
const guide = COVERAGE_STEPS.find((x) => x.item === 'S3_plan')?.guide ?? '';
|
|
expect(guide).toContain('真实存在的编码');
|
|
expect(guide).toContain('数据录入卡');
|
|
expect(guide).toContain('"kind":"entry"');
|
|
// 占位引用码的句式一个不留 —— 给了可照抄的样板,模型就会把它用到不该用的地方
|
|
expect(guide).not.toContain('-00');
|
|
expect(guide).not.toContain('待识别');
|
|
const s6 = COVERAGE_STEPS.find((x) => x.item === 'S6_writeback')?.guide ?? '';
|
|
expect(s6).not.toContain('-00');
|
|
expect(manifest.contract.prohibitions.some((x) => x.key === 'no-phantom-contact')).toBe(true);
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* **进度条的刻度要与业务流程的步数对得上**(2026-08-24 用户:「E04 应该是有 7 步、
|
|
* 给人确认是 4 步,这里要是 x/7 才对」)。从前粗分三段 → 屏上 x/4,
|
|
* 而文档开篇写的是「整体流程(7 步)」:同一件事两个数,人只能挑一个信。
|
|
*/
|
|
describe('阶段刻度 = 文档那 7 步', () => {
|
|
it('声明 6 段 —— 加上基座那一格「放行收口」,人看到的是 x/7', () => {
|
|
expect(COVERAGE_PHASES).toHaveLength(6);
|
|
});
|
|
|
|
it('一段一步:除「更新关系台账」外每段恰好一个计划项', () => {
|
|
const multi = COVERAGE_PHASES.filter((p) => p.items.length > 1).map((p) => p.name);
|
|
expect(multi).toEqual(['更新关系台账']); // 写回主数据 + 明细留痕,业务上是同一步⑥
|
|
});
|
|
|
|
it('四张卡对应的那四步各占一格 —— 「给人确认是 4 步」说的就是它们', () => {
|
|
const names = COVERAGE_PHASES.map((p) => p.name);
|
|
// 人确认的三格在声明里,第四格(放行收口)由基座加
|
|
expect(names).toContain('覆盖范围确认');
|
|
expect(names).toContain('经营计划审批');
|
|
expect(names).toContain('执行经营动作与回执');
|
|
});
|
|
});
|