/** * 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(A1 的账)。 * * 这几条断言守的是剧本本身的可用性:项 id 对不对得上、判据会不会开局即真、 * 声明的台账与工具是不是活动真有的、每一项有没有进度条的格子。 */ import { describe, expect, it } from 'vitest'; import { evaluateCondition } from '@sfeel'; import { ALTERNATIVE_PHASES, ALTERNATIVE_STEPS, CMMN_XML } from '../src/case-model.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 ALTERNATIVE_STEPS) { expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item); } }); it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => { for (const id of ITEM_IDS) { expect(ALTERNATIVE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id); } }); it('清单里挂的就是这份剧本(不是另抄一份)', () => { expect(manifest.assets.businessModel.steps).toBe(ALTERNATIVE_STEPS); }); }); describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => { /** 开案那一刻的两种局面:流内(上游载荷)与单飞(一段需求)—— 台账一行没有 */ const FRESH_FLOW: Record = { rfqRef: 'RFQ MD14.1', records: [{ pn: 'BBPY00201209601Y00', type_tag: 'C' }], customer_name: 'Itelma', }; const FRESH_SOLO: Record = { subject: '车规运放选型需求', body: '需要一颗车规级双通道运放……', }; it('**开局一步都不该被自动收掉** —— 流内与单飞两种开局都要 false', () => { for (const fresh of [FRESH_FLOW, FRESH_SOLO]) { for (const step of ALTERNATIVE_STEPS) { if (step.doneWhen === undefined) continue; expect( safeEval(step.doneWhen, fresh), `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`, ).toBe(false); } } }); /* ── ① 归一清单 ─────────────────────────────────────────────── */ it('整份落表且两边对上 → 归一收得掉', () => { expect( safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 2 }), ).toBe(true); }); it('**少拆多写都收不掉** —— 自报条数与基座行数对不上,错在第 2 拍就现形', () => { expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 2, quote_alternative_list_rows: 3 })).toBe(false); expect(safeEval(doneOf('S1_normalize'), { alt_line_total: 0, quote_alternative_list_rows: 0 })).toBe(false); }); /* ── ② 交 FAE 填回:人环,引擎不许替人按 ─────────────────────── */ it('**FAE 那一步不给 doneWhen** —— 引擎不许替人交卷', () => { expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill')?.doneWhen).toBeUndefined(); }); it('填回这一步是一张执行单:工作单一颗一页、卡上零输入、答复处理记 filled_total', () => { const fill = ALTERNATIVE_STEPS.find((s) => s.item === 'S2_fill'); expect(fill, 'FAE 填回这一步不见了').toBeDefined(); expect(fill?.view).toBe('alt-worksheet'); /* * **一张执行单,多页待执行报告**(2026-08-02 用户定,替代此前的逐颗按序派单): * 一颗待选型芯片一页,右上角翻页;定案三格是每页必填(requiredWhen orig_type=C), * 提交闸看着。卡上零输入;人交回后只清点 filled_total、finish。 */ expect(fill?.guide).toContain('一颗待选型芯片一页'); expect(fill?.guide).toContain('只发一次'); expect(fill?.guide).not.toContain('"fields"'); expect(fill?.guide).not.toContain('sel_stage'); expect(fill?.guide).toContain('filled_total'); for (const code of ['P2P', 'FUNC', 'OTHER']) expect(fill?.guide).toContain(code); }); it('归一那一步不再排队列 —— 单卡多页后 sel_stage 已退役', () => { expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.guide).not.toContain('sel_stage'); }); /* ── ③ 判稳定标:收步判据与验收判据同源 ─────────────────────── */ it('C 类清零且去向定了 → 判稳收得掉', () => { expect( safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0, route_decided_total: 1 }), ).toBe(true); }); it('**还有一颗 C 没处置 → 判稳收不掉** —— 收步比验收松,松出来的那一截就是返工', () => { expect( safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 1, route_decided_total: 1 }), ).toBe(false); }); it('**没记过去向数 → 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => { expect(safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0 })).toBe(false); }); it('全转 A(need_sourcing = false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => { expect( safeEval(doneOf('S3_reclass'), { quote_alternative_list_type_tag_c: 0, route_decided_total: 1, need_sourcing: false, }), ).toBe(true); }); it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => { expect(ALTERNATIVE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined(); }); /* * `records` 是上游整包落地行 —— 不声明 reads 会被整表段的截断刀盲砍 * (按字符截,砍掉的恰是 C 类行;A1 认客户那一步栽过同款)。 */ it('归一那一步声明了 reads: records —— 上游载荷置前不截断', () => { expect(ALTERNATIVE_STEPS.find((s) => s.item === 'S1_normalize')?.reads).toContain('records'); }); }); 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 ALTERNATIVE_STEPS) { for (const address of step.writes) { expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address); } } }); it('每一步要调的工具都在清单的工具声明里', () => { for (const step of ALTERNATIVE_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 ALTERNATIVE_STEPS) { expect(step.view, `${step.item} 没定画面`).toBeDefined(); expect(names.has(step.view ?? ''), `${step.item} 的 view「${step.view}」不在模版名单里`).toBe(true); } }); it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => { for (const step of ALTERNATIVE_STEPS) { expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe(''); expect(step.guide.trim().length, `${step.item} 的 guide 太短,等于没写`).toBeGreaterThan(20); } }); }); function doneOf(item: string): string { const found = ALTERNATIVE_STEPS.find((s) => s.item === item)?.doneWhen; if (found === undefined) throw new Error(`${item} 没有 doneWhen`); return found; } /** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */ function safeEval(expression: string, scope: Record): boolean { try { return evaluateCondition(expression, scope); } catch { return false; } } describe('阶段刻度:每一项都有格可待(A1 漏 S3_confirm 的账)', () => { const inPhase = new Set(ALTERNATIVE_PHASES.flatMap((p) => p.items)); it('**除放行外每一项恰好属于一个阶段** —— 漏一项,进度条就会在它办着的时候跳过去', () => { const planned = ALTERNATIVE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release'); expect(planned.filter((id) => !inPhase.has(id))).toEqual([]); }); it('一项不进两个阶段 —— 进了两个,「这一格过没过」就有两种答案', () => { const all = ALTERNATIVE_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(ALTERNATIVE_STEPS.map((s) => s.item)); expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]); }); });