/** * 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(与 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): Record => ({ ...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)); 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 = /]*>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('