This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/activities/part-substitution-assessment/__tests__/steps.test.ts
T
xcsandClaude Opus 5 eb80876554 走查逼出的六条:发卡前清单要有行 · 在库要拿得出依据 · 数据工单只建缺的那颗 · 录入卡自带 basis
ACT-WORK-05 差异评估的浏览器全流程走查(六轮六条路径,全部 released),
逐条治了实跑里现形的缺陷。活动包 1.2.2 → 1.2.6:

- H(1.2.3):模型压拍把执行工单发了、清单一行没写,人对着空表 ——
  「先写清单再发卡」只在剧本里,压过拍就只有说明书还在;规矩搬进说明书。
- I(1.2.4,结构治):原 PN 根本不在库,模型两颗都判「在库」,硬阻塞整条路被跳过。
  剧本已写到头 → pn_check 加 found_pn_ref(refersTo + requiredWhen):
  判在库就必须填一个基座查得到的料号,查无此值当场拒、填不出则收不了步。
  这是「机检只读基座数」第六次现形,前五次都是计数,这次是存在性。
- J(1.2.5):数据工单派给了在库的那颗,缺的那颗没人建 ——
  剧本把「建哪几颗」和「要不要问人」写在同一串分岔里,从没写过「found 的不建」;拆成两问。
- K(1.2.6):数据录入卡的参考信息栏摆着引擎内部家具(九个计划项全名)——
  entryCard.basis 契约里可选,活动没声明,基座就兜了内部事实;卡骨架补三行人话。

包内自测 56 → 61 条;门禁 typecheck / lint / depcruise / test(2071)全绿
(api:check 的差异全在 org/* 与 api/*,是工作树里先前那批鉴权改动,本轮未添)。

docs/ACT-WORK-05-端到端测试记录.md:六轮走查的逐轮结论与 A–K 十一条发现总表。
docs/debt.md:四条平台欠账(disable 无路可走 · 前一项不收后一项的件推不出来 ·
拒回数不出缺的是工具调用 · 零行台账的提交闸无牙)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 04:29:40 +08:00

263 lines
15 KiB
TypeScript

/**
* 步骤剧本与呈现面 —— 这段错了不报错,只是活动开始转圈 / 界面开始撒谎(与 E04 / I01 同一套惯例)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { ASSESSMENT_PHASES, ASSESSMENT_STEPS, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
import { manifest, D } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map((m) => m[1] ?? '');
function doneOf(item: string): string {
const found = ASSESSMENT_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;
}
}
/** 开局局面:入参 + 所有基座读数归零 */
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
...input,
demand_request_rows: 0,
demand_request_missing: 0,
[`${D}_verdict_rows`]: 0,
[`${D}_pn_check_rows`]: 0,
[`${D}_pn_check_missing`]: 0,
[`${D}_pn_check_exists_status_absent`]: 0,
[`${D}_pn_check_exists_status_abandoned`]: 0,
[`${D}_input_gap_rows`]: 0,
[`${D}_input_gap_missing`]: 0,
input_confirmed_total: 0,
review_approved_total: 0,
landing_prepared_total: 0,
substitution_relation_rows: 0,
substitution_relation_missing: 0,
substitution_relation_analysis_status_fae_confirmed: 0,
substitution_difference_item_rows: 0,
substitution_difference_item_missing: 0,
substitution_difference_item_distinct_substitution_relation_id: 0,
substitution_difference_item_difference_status_match: 0,
substitution_difference_item_difference_status_mismatch: 0,
substitution_difference_item_difference_status_unknown: 0,
substitution_difference_item_difference_status_conditional: 0,
tool_part_substitution_assessment_compare_parameters_calls: 0,
tool_part_substitution_assessment_recommend_calls: 0,
});
describe('剧本与案例模型对得上', () => {
it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => {
for (const step of ASSESSMENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
for (const id of ITEM_IDS) expect(ASSESSMENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
});
it('清单里挂的就是这份剧本与这份阶段', () => {
expect(manifest.assets.businessModel.steps).toBe(ASSESSMENT_STEPS);
expect(manifest.assets.businessModel.phases).toBe(ASSESSMENT_PHASES);
});
});
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 / reads(台账那几个)全落在声明过的对象上', () => {
for (const step of ASSESSMENT_STEPS) {
for (const address of step.writes) expect(declaredAddresses.has(address), `${step.item} 要写 ${address}`).toBe(true);
for (const address of step.reads.filter((r) => r.includes('.') && !r.startsWith('case:'))) {
expect(declaredAddresses.has(address), `${step.item} 要读 ${address}`).toBe(true);
}
}
});
it('steps[].tools 全部是声明过的工具;只有 S3 调工具', () => {
for (const step of ASSESSMENT_STEPS) {
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
if (step.item !== 'S3_assess') expect(step.tools, step.item).toEqual([]);
}
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.tools).toHaveLength(2);
});
it('customer_approval_status 在任何剧本里都不许写(只读边界)', () => {
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(/customer_approval_status\s*=/);
});
});
describe('doneWhen —— 开局不许真,干完必须真', () => {
it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => {
for (const sample of manifest.samples) {
for (const step of ASSESSMENT_STEPS) {
if (step.doneWhen === undefined) continue;
expect(safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>)), `样例「${sample.name}」下 ${step.item} 开局就为真`).toBe(false);
}
}
});
it('S0:两颗候选 + 原 PN = 3 行核对齐、需求与结论都写了 → 收得掉;少写一颗 → 收不掉', () => {
const base = { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'], demand_request_rows: 1, demand_request_missing: 0, [`${D}_verdict_rows`]: 1, [`${D}_pn_check_missing`]: 0 };
expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 3 })).toBe(true);
expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 2 })).toBe(false);
});
it('S2:未放弃的候选各一条关系且每条都有明细 → 收得掉;放弃一颗后关系数随之减一', () => {
const two = { candidateInternalPns: ['A', 'B'], [`${D}_pn_check_exists_status_abandoned`]: 0, substitution_difference_item_rows: 14 };
expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 2 })).toBe(true);
expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(false);
expect(safeEval(doneOf('S2_normalize'), { ...two, [`${D}_pn_check_exists_status_abandoned`]: 1, substitution_relation_rows: 1, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(true);
});
it('S3:四态之和 = 明细行数、必填齐、两枚工具都调过 → 收得掉;工具没调过 → 收不掉', () => {
const ok = {
substitution_difference_item_missing: 0,
substitution_relation_missing: 0,
substitution_difference_item_rows: 7,
substitution_difference_item_difference_status_match: 4,
substitution_difference_item_difference_status_mismatch: 1,
substitution_difference_item_difference_status_unknown: 1,
substitution_difference_item_difference_status_conditional: 1,
tool_part_substitution_assessment_compare_parameters_calls: 1,
tool_part_substitution_assessment_recommend_calls: 1,
};
expect(safeEval(doneOf('S3_assess'), ok)).toBe(true);
expect(safeEval(doneOf('S3_assess'), { ...ok, tool_part_substitution_assessment_recommend_calls: 0 })).toBe(false);
expect(safeEval(doneOf('S3_assess'), { ...ok, substitution_difference_item_difference_status_match: 3 })).toBe(false);
});
it('S5:旗 + 全部关系 FAE 确认 + P-01 回写齐 → 收得掉;缺一头都收不掉', () => {
const ok = { landing_prepared_total: 1, substitution_relation_analysis_status_fae_confirmed: 2, substitution_relation_rows: 2, substitution_relation_missing: 0, demand_request_missing: 0 };
expect(safeEval(doneOf('S5_prepare_landing'), ok)).toBe(true);
expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, substitution_relation_analysis_status_fae_confirmed: 1 })).toBe(false);
expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, demand_request_missing: 1 })).toBe(false);
});
});
describe('说明书与剧本的骨架', () => {
it('五张卡的骨架都住在说明书里(压拍之后只有说明书还在)', () => {
expect(NARRATIVE).toContain('"kind":"entry","itemId":"S0_bind"');
expect(NARRATIVE).toContain('"kind":"decide","itemId":"S0d_p08_master"');
expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)');
expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)');
expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认');
expect(NARRATIVE).toContain('"kind":"decide","itemId":"S4_review"');
});
it('剧本里不引反例原话;S3 固定三拍且写明「工具后同拍作废」', () => {
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
expect(s3).toContain('第一拍');
expect(s3).toContain('第二拍');
expect(s3).toContain('第三拍');
expect(s3).toContain('工具调用后同拍其余动作作废');
});
it('S3 第三拍写盘前先自检两枚工具的调用次数(2026-08-27 实测:漏调 recommend 会让 doneWhen 恒假、模型死循环重写同样的行)', () => {
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
const thirdPass = s3.slice(s3.indexOf('第三拍'));
expect(thirdPass).toContain('tool_part_substitution_assessment_recommend_calls');
expect(thirdPass).toContain('tool_part_substitution_assessment_compare_parameters_calls');
expect(thirdPass).toMatch(/自检|先.*找这两个数|必须\s*>\s*0/);
// 第二拍那一段要点出「推荐等级只能由工具算,不调它收不了步」,别让模型自己拍推荐等级
const secondPass = s3.slice(s3.indexOf('第二拍'), s3.indexOf('第三拍'));
expect(secondPass).toContain('推荐等级只能由它算');
expect(secondPass).toMatch(/收不了步|收不掉/);
});
it('S0 剧本写明都在库时数据工单两项本就不会进「当下可为」;S0d_p08_master 剧本按候选总数判断该不该问人(=1 必建、≥2 才问、原 PN 硬阻塞)', () => {
const s0 = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
expect(s0).toContain('当下可为');
expect(s0).toContain('不要对计划项用');
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(s0d).toContain('硬阻塞');
expect(s0d).toContain('候选总数 = 1');
});
it('S0d_p08_master 剧本按候选总数(不是「不在库的颗数」)判断要不要问人 —— 候选总数 ≥2 时逐颗不在库的都要问(2026-08-27 实测逼出的措辞修正)', () => {
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(s0d).toContain('候选总数');
expect(s0d).toContain('不在库');
expect(s0d).toMatch(/候选总数\s*≥\s*2/);
// 反面提示:「不在库的只有一颗」不该被读成跳过决策卡的理由
expect(s0d).toContain('不是跳过决策卡的理由');
});
it('s_bound 哨兵真的检查了「有料号不在库」这件事(都在库时门不该开;退回旧写法会红)', () => {
expect(CMMN_XML).toContain('sub_eval_pn_check_exists_status_absent &gt; 0');
const sBoundBlock = /<sentry id="s_bound">[\s\S]*?<\/sentry>/.exec(CMMN_XML)?.[0] ?? '';
expect(sBoundBlock).toContain('caseFileItemRef="sub_eval_pn_check_exists_status_absent"');
expect(sBoundBlock).toContain('sub_eval_pn_check_exists_status_absent &gt; 0');
});
it('说明书与全部剧本里不会教模型对计划项调用 disable(允许出现在「不要 disable」这类禁止句里)', () => {
// 只逮「disable + 计划项 id」这种祈使形(教模型真的去调用它);
// 「不要…用 `disable`」「Disabled 只能从 Enabled 来」这类禁止句/状态名不含「disable + id」,不会被误伤。
const disableCallPattern = /disable\s+(S\d[a-zA-Z_]*|T_release)/;
expect(NARRATIVE).not.toMatch(disableCallPattern);
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(disableCallPattern);
});
it('S0d 拆成两项各自绑自己的 view(P-10 那份件不会永远推不出来),两项都没有 doneWhen', () => {
const p08 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master');
const p10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document');
expect(p08?.view).toBe('p08-master-create');
expect(p10?.view).toBe('p10-document-create');
expect(p08?.doneWhen).toBeUndefined();
expect(p10?.doneWhen).toBeUndefined();
});
it('候选放弃到只剩最后一颗时不许再放弃(至少留一颗,防止全放弃死锁)', () => {
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(s0d).toContain('至少留一颗候选');
});
it('P-08 交回之后先 finish 再发 P-10 卡,顺序写死进两段 guide(2026-08-27 实测:漏收步会让工作区画面停在 P-08 那两张表,P-10 卡发出后人却看不见资料表、提交闸拦死)', () => {
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(s0d).toContain('finish S0d_p08_master');
expect(s0d).toMatch(/顺序不能反/);
expect(s0d).toContain('p08-master-create');
const s0d10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document')?.guide ?? '';
expect(s0d10).toContain('finish S0d_p08_master');
expect(s0d10).toMatch(/若它还开着|先.*finish/);
});
it('「发卡前清单必须先有行」写在说明书里,不只在剧本里(2026-08-27 实测:模型把 P-10 收步与 S1 发卡压成一拍,那一拍 S1 剧本没摊开,卡发出去时 input_gap 一行都没有,人对着空表无从下手)', () => {
expect(NARRATIVE).toContain('sub_eval.input_gap` 必须先有行');
expect(NARRATIVE).toMatch(/压拍/);
expect(NARRATIVE).toMatch(/顺序不能反/);
});
it('存在性核对的剧本教的是「填得出依据才算在库」,不是「你看着办」(结构治,配 found_pn_ref 那道墙)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
expect(g).toContain('found_pn_ref');
expect(g).toMatch(/查无此值当场拒/);
expect(g).toMatch(/名单里没有就是没有/);
expect(g).toMatch(/把这一行改成 absent/);
});
it('数据工单的名单只认 absent,「建哪几颗」与「要不要问人」分开写(2026-08-27 实测:原 PN 缺库那单,模型把在库的候选派成了数据工单,缺的那颗没人建)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(g).toMatch(/问题一 · 建哪几颗/);
expect(g).toMatch(/问题二 · 建之前要不要先问人/);
expect(g).toMatch(/exists_status = "found" 的一行都不建/);
});
it('数据录入卡自带 basis,不让基座兜上内部家具(2026-08-27 实测:没声明 basis 的那张卡,参考信息栏摆的是「这一步 / 当下可为 / 目标缺口」加九个计划项全名)', () => {
const i = NARRATIVE.indexOf('"kind":"entry"');
expect(i).toBeGreaterThan(-1);
const block = NARRATIVE.slice(i, i + 900);
expect(block).toContain('"basis"');
expect(block).toContain('触发原因');
});
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
expect(ASSESSMENT_PHASES).toHaveLength(5);
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
const all = ASSESSMENT_PHASES.flatMap((p) => p.items);
expect(new Set(all).size).toBe(all.length);
expect([...all].sort()).toEqual([...ITEM_IDS.filter((i) => i !== 'T_release')].sort());
});
});