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 b2159022bc test(w05): 补一条读真文件的引用相等断言 —— 镜像测试证明机制,这条钉住真文件此刻是对的
复审 Important:w05-s3-review-recompute.test.ts 手写镜像、不 import case-model.ts(架构上也
不能),证明的是"少了 let 会怎样"这个机制,却没有任何东西钉住真文件此刻还带着 let: SHARED_LET
——AutoRecipe.let 是可选字段,typecheck 不报;明天有人把 S3_review.auto.let 删掉,原样重犯
这次的 Critical,镜像测试的红绿完全脱钩、不会发现。

在 steps.test.ts 里"S2 与 S3 用的是同一份投影"那条 toBe 断言旁边加同风格的一行:
expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let) ——
零新增 import,读的是真 case-model.ts。变异验证:把 S3_review.auto 的 let: SHARED_LET, 那一行
删掉(diff 核过只改了那一行),这条断言变红(undefined vs 期望的绑定对象);加回来
(diff 核过与原文件逐字节相同),变绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 17:47:36 +08:00

353 lines
19 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { planStepSchema } from '@contracts';
import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release'];
/*
* **泛型 `Extract`,不是 `PlanStep` 标注**(2026-08-29,评审 fix round 1 改判)——
* `ASSESSMENT_STEPS` 不是元组,`.find()` 的返回类型是数组**元素类型**(四步 `auto.do` 里所有动作形状的并集),
* 与查的是哪个 `item` 无关。第一版把返回类型标成契约 `PlanStep`,而 `PlanStep['auto']` 落在
* `fields: Readonly<Record<string, AutoExpr>>` 这个**开放索引签名**上 —— 拼写保护是回来了,但整个
* 文件里按方括号取字段值的 14 处(含 S1 原有 `:155`/`:156`/`:166` 三条)全部失去编译期拼写检查,
* `evidence_note` 手滑打成 `evidence_noet` 之类的错字,`tsc` 会**静默放行**,只能等 `vitest` 断言
* 落空才发现,反馈反而往后挪了一步。
*
* 现在按查的 `item` 精确 narrow:四步各自的 `item` 字段已经在 `case-model.ts` 加了 `as const`,
* `(typeof ASSESSMENT_STEPS)[number]` 因此是四种带字面量 `item` 标签的形状的并集;`stepOf('S2_assess')`
* 传的是字符串字面量,泛型 `T` 原样推成 `'S2_assess'`,`Extract<并集, {item: T}>` 精确挑出**那一步自己的**
* 形状 —— 别的步的 `project`/`analyze` 形状不再混进来,`.fields['xxx']` 落回的是那一步真实写的字面量对象
* 类型(有名字的那几个属性),不是开放索引签名。拼错字段名会报 `TS2551` 并带拼写建议。
*/
function stepOf<T extends string>(item: T) {
return ASSESSMENT_STEPS.find(
(s): s is Extract<(typeof ASSESSMENT_STEPS)[number], { item: T }> => s.item === item,
);
}
describe('四步案例模型', () => {
it('恰好四步,次序即业务次序', () => {
expect(ASSESSMENT_STEPS.map((s) => s.item)).toEqual(ITEMS);
});
it('每一步都过得了 PlanStep 契约', () => {
for (const step of ASSESSMENT_STEPS) expect(planStepSchema.safeParse(step).success).toBe(true);
});
it('**全程零 guide、零工具** —— 这是这次重写的立场', () => {
for (const step of ASSESSMENT_STEPS) {
expect(step.guide).toBe('');
expect(step.tools).toEqual([]);
expect(step.auto).toBeDefined();
}
});
it('CMMN 里四个计划项都在,且触达世界的两步标了人工激活', () => {
for (const item of ITEMS) expect(CMMN_XML).toContain(`id="${item}"`);
/* 审核与放行触达的是人 —— 完成与否不该由数据形状推出来 */
for (const item of ['S3_review', 'T_release']) {
expect(new RegExp(`id="${item}"[^>]*manualActivation="true"`).test(CMMN_XML)).toBe(true);
}
});
it('**触达世界的两步不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
expect(stepOf('S1_inputs')?.doneWhen).toBeTruthy();
expect(stepOf('S2_assess')?.doneWhen).toBeTruthy();
expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
expect(stepOf('T_release')?.doneWhen).toBeUndefined();
});
it('**判据不许编一个数不出来的数**:doneWhen 里不出现「本类型有几个字段」这类', () => {
/* `parts_type_param_rows` 数的是本单草稿写下的字段行(人新建的那几个),不是整本字典 —— 拿它当分母是错的 */
for (const step of ASSESSMENT_STEPS) {
expect(step.doneWhen ?? '').not.toContain('parts_type_param_rows');
}
});
it('S1 收步:两颗都在库、都归了类型、且是同一个类型、主数据与参数都不缺', () => {
const done = stepOf('S1_inputs')?.doneWhen ?? '';
expect(done).toContain('sub_eval_part_exists_status_found = 2');
expect(done).toContain('sub_eval_part_missing = 0');
expect(done).toContain('sub_eval_part_distinct_chip_type_id = 1');
expect(done).toContain('parts_profile_missing = 0');
expect(done).toContain('parts_parameter_missing = 0');
});
it('S2 收步:明细写了且不缺、关系一行、验证记录一行', () => {
const done = stepOf('S2_assess')?.doneWhen ?? '';
expect(done).toContain('substitution_difference_item_rows > 0');
expect(done).toContain('substitution_difference_item_missing = 0');
expect(done).toContain('substitution_relation_rows = 1');
expect(done).toContain('validation_record_rows = 1');
});
it('两张卡各带人工验收清单 —— 一条一句人眼核得动的动作', () => {
for (const item of ['S3_review', 'T_release']) {
const list = stepOf(item)?.acceptanceChecklist ?? [];
expect(list.length).toBeGreaterThanOrEqual(4);
for (const line of list) expect(line.length).toBeGreaterThan(6);
}
expect(stepOf('S3_review')?.cardVariant).toBe('review');
});
it('阶段:除放行外每一项恰好属于一个阶段', () => {
const covered = ASSESSMENT_PHASES.flatMap((p) => p.items);
expect(covered.sort()).toEqual(['S1_inputs', 'S2_assess', 'S3_review'].sort());
expect(new Set(covered).size).toBe(covered.length);
});
it('说明书只讲整单的事 —— 逐步做法归配方,而配方不是给模型看的', () => {
expect(NARRATIVE.length).toBeLessThan(2000);
/* **示例不引具体料号与参数名** —— 模型会把示例当成这一单的值(2026-08-27 复现三次) */
for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值']) {
expect(NARRATIVE).not.toContain(forbidden);
}
});
});
/*
* 恢复自 Task 2 遗留的两条 `it.skip`(原住在这份文件里,注释指名「Task 2 → Task 3 补回」)——
* 旧版断言的是旧九步剧本引旧台账地址 / 旧两枚工具,那两个前提随四步重写一起撤了,
* 这里按新的四步剧本与九张新台账重新写实(逻辑不变:读写面必须落在清单声明里、
* 工具必须落在声明过的工具里),不是新造的断言。
*/
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);
/* reads 里混着入参名(originalInternalPn 等)与 case: 前缀伪读数,只有「域.对象」形状的才对声明表 */
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 全部是声明过的工具 —— 且四步全是空数组(零工具是这次重写的立场,不是疏漏)', () => {
for (const step of ASSESSMENT_STEPS) {
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
expect(step.tools, step.item).toEqual([]);
}
});
});
describe('S1 配方', () => {
const recipe = stepOf('S1_inputs')?.auto;
const acts = recipe?.do ?? [];
const kinds = acts.map((a) => a.kind);
/*
* 次序是承重的,不是随手写的(2026-08-29 评审 Important 修正):案卷投影(`sub_eval.case`)
* 的 `gate_reason` 要读 `parts_profile_missing`/`parts_parameter_missing` —— 这两个数由
* 主数据/参数骨架两笔撑出来,案卷这一笔必须排在它们**之后**,否则读到的是这一拍刷新之前
* 的旧数。病根写在 case-model.ts ④ 那一笔头上;引擎级的复现(旧次序真的会让 gate_reason
* 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts`
* ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。
*/
it('六笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => {
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'say']);
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
]);
});
it('案号由 let 铸,只从本单开案时刻取材', () => {
expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] });
});
it('两颗料由字面两行立起来 —— 这张表是后面每一笔遍历的唯一来源', () => {
const part = acts[0];
expect(part?.kind === 'project' && 'rows' in part.from && part.from.rows).toHaveLength(2);
/* 在库与否是一次 lookup 的结果,不是一句断言:先取依据,再按依据判 */
const fields = part?.kind === 'project' ? part.fields : undefined;
expect(fields?.['found_pn_ref']).toMatchObject({ lookup: 'parts.profile', take: 'internal_pn' });
expect(fields?.['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' });
});
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
const params = acts[2];
expect(params?.kind === 'project' && params.prune).toBe(true);
const from = params?.kind === 'project' ? params.from : undefined;
/* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } });
const fields = params?.kind === 'project' ? params.fields : undefined;
expect(fields?.['param_value']).toMatchObject({ lookup: 'parts.parameter', take: 'param_value' });
});
it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
const card = acts[4];
const when = card?.kind === 'card' ? (card.when ?? '') : '';
/*
* S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
* 于是未绑定 —— 拿它当条件,恰恰在一切正常时把卡发出去。条件只用恒有值的基座读数。
*/
expect(when).not.toContain('gate_reason');
expect(when).toContain('sub_eval_part_exists_status_found < 2');
expect(when).toContain('parts_parameter_missing > 0');
});
it('卡:数据工单、四张表都在闸上、且有 basis(基座拒发 basis 为空的工单)', () => {
const card = acts[4];
expect(card?.kind === 'card' && card.as).toBe('execution');
expect(card?.kind === 'card' && card.variant).toBe('data');
expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']);
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
});
it('发卡条件与 doneWhen 是同一件事的正反面', () => {
const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : '';
for (const reading of [
'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing',
]) {
expect(when).toContain(reading);
expect(stepOf('S1_inputs')?.doneWhen).toContain(reading);
}
});
});
describe('S2 配方 —— AI 只出场一次', () => {
const recipe = stepOf('S2_assess')?.auto;
const acts = recipe?.do ?? [];
it('五笔:分析 → 明细 → 关系 → 验证记录 → 交代;analyze 恰好一笔', () => {
expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'say']);
expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1);
});
it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => {
const items = acts[1];
expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' });
/*
* **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)——
* `SomeShape | {}` 在按方括号取值时会被 TS 整体收窄成裸 `{}`(`{}` 是任何对象的父类型,
* 联合化简会把更具体的那一支吃掉),于是**全部**字段访问失去编译期保护、报
* 「不能索引类型 {}」,与 `stepOf` 标没标 `Extract` 无关(受控实验见任务报告)。
* `: undefined` 不会被这样吃掉,后续按 `?.[key]` 取值,与 S1 既有的
* `:164`/`:172`/`:175` 三处同一写法 —— 断言真跑到的值不变(条件为真时 `?.` 无副作用)。
*/
const fields = items?.kind === 'project' ? items.fields : undefined;
/* AI 的出参只是一张查表:漏一条,那一格就是空 —— required 立刻把它变成 _missing > 0 */
expect(fields?.['difference_status']).toMatchObject({ lookup: 'result:items', take: 'difference_status' });
expect(fields?.['impact_statement']).toMatchObject({ lookup: 'result:items' });
expect(fields?.['evidence_note']).toMatchObject({ lookup: 'result:items' });
});
it('**值不取 AI 出参,一律 lookup 台账**', () => {
const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined;
for (const col of ['original_value', 'candidate_value'] as const) {
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
}
/* 约束类型来自字典这一行本身,不是 AI 说的 */
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
});
it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => {
const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined;
const jc = JSON.stringify(fields?.['judgement_class']);
expect(jc).toContain('hard_mismatch');
expect(jc).toContain('soft_mismatch');
/* 顺序要紧:先认四态,最后才按约束类型分硬软 */
expect(jc.indexOf('"match"')).toBeLessThan(jc.indexOf('hard_mismatch'));
});
it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => {
const rel = acts[2]?.kind === 'project' ? acts[2].fields : undefined;
const level = JSON.stringify(rel?.['recommendation_level']);
expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0');
expect(level).toContain('not_recommended');
/* 硬约束不符必须直接落到「不推荐」,排在最前 */
expect(level.indexOf('hard_mismatch')).toBeLessThan(level.indexOf('unknown'));
});
it('P-06 一单固定一条,状态 not_started —— 本活动只写「还得去验什么」,不虚构实测结果', () => {
const val = acts[3];
expect(val?.kind === 'project' && val.into).toBe('validation.record');
expect(val?.kind === 'project' && val.from).toEqual({ once: true });
const fields = val?.kind === 'project' ? val.fields : undefined;
expect(fields?.['validation_status']).toBe('not_started');
expect(fields?.['validation_type']).toBe('parametric');
});
it('提示词:三条硬规矩都写进去了,且不引任何真实料号与参数名', () => {
const analyze = acts[0];
const prompt = analyze?.kind === 'analyze' ? analyze.instruction : '';
expect(prompt).toContain('不要复述');
expect(prompt).toContain('unknown');
expect(prompt).toContain('硬约束');
for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值', '输入电压范围']) {
expect(prompt).not.toContain(forbidden);
}
/* 出参 schema 顶层是对象,数组字段名与 into 对得上 */
const out = analyze?.kind === 'analyze' ? analyze.output : undefined;
expect(out?.['type']).toBe('object');
expect(analyze?.kind === 'analyze' && analyze.into).toBe('items');
});
it('AI 的入参**每一条都带说明** —— 参数名本身说不清「它为什么要紧」', () => {
const analyze = acts[0];
const fields = analyze?.kind === 'analyze' ? analyze.input.fields : undefined;
expect(fields?.['param_desc']).toEqual({ col: 'param_desc' });
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
for (const col of ['original_value', 'candidate_value'] as const) {
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
}
});
});
describe('S3 / S4 配方', () => {
const s3 = stepOf('S3_review')?.auto?.do ?? [];
const s4 = stepOf('T_release')?.auto?.do ?? [];
const s2 = stepOf('S2_assess')?.auto?.do ?? [];
it('S3 四笔:两笔重算在前、卡在后 —— 人答完那一刻重算才发生', () => {
expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'card', 'say']);
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.relation');
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('validation.record');
});
it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => {
expect(s3[0]).toBe(s2[2]);
expect(s3[1]).toBe(s2[3]);
});
/*
* **这一条读的是真 `case-model.ts`,不是任何镜像**(2026-08-29 复审 Important fix round 2)——
* `w05-s3-review-recompute.test.ts` 证明的是机制(S3 少了 `let`,复用的投影主键就算不出来,
* 写会被静默丢掉、或拼出一行垃圾);但那份测试手写镜像、不 import 这份真文件(架构上也不能),
* 红绿与 `case-model.ts` 此刻写的是什么**完全脱钩**——明天有人把 `S3_review.auto.let` 删掉,
* 原样重犯这次的 bug,`AutoRecipe.let` 是可选字段(typecheck 不报),镜像测试也不会变红。
* 这一条补上「读真文件」那一半:S3 与 S2 的 `auto.let` 必须是**同一个引用**,少了就在这里当场炸。
*/
it('S3 与 S2 共用同一个 `let` 引用 —— 少了它,复用的投影主键在 S3 那一拍就是未绑定的空串', () => {
expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let);
});
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
const card = s3[2];
expect(card?.kind === 'card' && card.as).toBe('decide');
expect(card?.kind === 'card' && card.variant).toBe('review');
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
expect((card?.kind === 'card' ? card.options ?? [] : [])[0]?.value).toBe('approve');
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
});
it('S4 两笔:写结论一句话 → 提交放行', () => {
expect(s4.map((a) => a.kind)).toEqual(['project', 'submit']);
expect(s4[0]?.kind === 'project' && s4[0].into).toBe('sub_eval.case');
expect(s4[1]?.kind === 'submit' && typeof s4[1].rationale !== 'undefined').toBe(true);
});
it('`submit` 所在的步声明了人工验收清单 —— 系统提交不比模型提交宽松', () => {
expect((stepOf('T_release')?.acceptanceChecklist ?? []).length).toBeGreaterThanOrEqual(4);
});
it('两步都不给 doneWhen —— 收步由人答卡决定', () => {
expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
expect(stepOf('T_release')?.doneWhen).toBeUndefined();
});
});