/** * 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(与 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 收得掉;缺一头都收不掉', () => { 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 = /([^<]+)<\/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 = /([^<]+)<\/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(/

[^<]*/); } const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-ledger-update')?.html ?? ''; const prose = html.replace(/