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 Fable 5 11375eaf93 feat(w05): 比对基准合一卡 + prune:'live' —— 类型与字段跟手,只显当前那一种
2026-08-31 用户第二轮:「芯片类型列表还是有问题…和核心参数字段不能合并成一个卡片吗…
两个芯片都选一样的就应该只显示一种,现在只会跟着变多不会跟着变少」+
「卡片下面那个容器黑色和页面背景不搭」。

① 比对基准卡(类型 + 字段合一,只读、跟手):不再列 mine: 草稿镜像(镜像里积着改选前的
  旧类型 —— 撤行只在确认那一拍跑,F3 不许在人编辑时静默删行),按案卷锚
  (sub_eval.case.chip_type_id,配方每拍现算)嵌套查字典:卡上改选、按保存,quiet 拍
  project 照跑,这张卡当场换脸 —— 两颗料同类型时恰好只有那一种;没人选时空态指路。
  新建类型收进 details 抽屉(零脚本),两张编辑表原样在抽屉里(种子行/加行机制不动)。

② 契约新增 prune:'live'(quiet 拍也撤):只给**人不会在件上加行**的表用 —— F3 防的是
  「人刚加的行被撤」,人加不了行的表没有这个风险,而「改了类型,旧类型的参数行还摆在
  屏上」是另一种撒谎。W05 参数骨架 ⑤ 改 'live':改选类型,参数行跟手换。
  static-audit 的 prune×humanWrites 审计盖住 'live';规范 §4.6 纪律 5 补这条反面特例;
  两档都跳过已落地的行并出声(skipLanded)。

③ 板底改浅色蓝图:深色容器与纸面色阶断裂(用户点名),换强调色极浅晕染 + 淡格线,
  走线与信号点用强调色 —— 电路语汇留着,整页只剩 hero 一处深色。

测:契约 'live' 解析 + runner quiet-live 对齐(变异验证)+ 基准卡锚(变异验证)+
steps ⑤ 钉 'live';108 包内 + 全仓四门绿;2.8.1 装库 SQL 核过;:8787 已跑新构建
(watcher 又断,按应急程序手起 node dist/main.js)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 14:47:25 +08:00

474 lines
28 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', 'project', 'project', 'card', 'say']);
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
'sub_eval.part', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter', 'sub_eval.case',
]);
});
/*
* 2026-08-30,Ruling H35:`parts.chip_type`/`parts.type_param` 各自把「选中类型的那一行」
* 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行,
* 好让它们能安全回到下面 ⑦ 卡的 `gateLedgers` 上(闸不会再拿「零行」误判)。
*
* **带 `prune`,已落地的行由基座 `skipLanded` 跳过**(2026-08-31 改回)。曾特意不带
* `prune`(2026-08-30 e2e 案例 2:已落地的旧类型行撤不掉,配方整单炸)—— 代价是
* **没落地的**陈旧行也不清:人把候选从 LDO 改回 DC-DC,LDO 的字典/字段行积在屏上
* (2026-08-31 用户实测点名「下面还是显示两个类型」)。现在 `draftDrop` 对配方这一路
* 跳过已落地的行(loop-engine/port.ts),prune 回来:没落地的陈旧选型照撤,
* 已落地的原地留下 —— 案例 2 不再炸,用户那条路清得干净。
*/
it('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(带 prune,已落地由基座跳过)', () => {
const chipType = acts[1];
expect(chipType?.kind === 'project' && chipType.into).toBe('parts.chip_type');
expect(chipType?.kind === 'project' && chipType.prune).toBe(true);
expect(chipType?.kind === 'project' && chipType.from).toMatchObject({
each: 'mine:sub_eval.part',
then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } },
});
const typeParam = acts[2];
expect(typeParam?.kind === 'project' && typeParam.into).toBe('parts.type_param');
expect(typeParam?.kind === 'project' && typeParam.prune).toBe(true);
expect(typeParam?.kind === 'project' && typeParam.from).toMatchObject({
each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
});
});
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' });
});
/*
* **2026-08-30 修 Critical 2**:两个启动参数改成必填之后(`manifest.ts` 的
* `contract.input.required`),`sub_eval_part_rows < 2` 只剩一条能触发它的路径 ——
* 两颗料号填成了同一个(`internal_pn` 撞成同一把主键,两行覆盖成一行)。旧文案
* 「原料号与候选料号还没填全 —— 在下面把两格补上」指向一条不存在的路(块① 没有
* 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。
*/
it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => {
const kase = acts[5];
const fields = kase?.kind === 'project' ? kase.fields : undefined;
const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined;
expect(gateReason?.when).toBe('sub_eval_part_rows < 2');
expect(gateReason?.then).not.toContain('还没填全');
expect(gateReason?.then).not.toContain('把两格补上');
expect(gateReason?.then).toContain('终止');
});
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
const params = acts[4];
/* 'live':人加不了行的表,quiet 拍也撤 —— 改类型后旧参数行跟手消失(2026-08-31 用户点名「只会变多不会变少」) */
expect(params?.kind === 'project' && params.prune).toBe('live');
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[6];
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');
});
/*
* 2026-08-30,Ruling H35(推翻 2026-08-29 评审 Critical 那条旧结论,不是又改主意):
* 从前 `parts.chip_type` 永久不列进这张卡的闸,理由是「它没有编辑手段,`mine:` 天然
* 为空,列了就永久卡死」——那条理由**在 ②③ 两笔自投影加上之后不再成立**。
* 而不列进闸的代价是真实存在的:「数据工单确认即入库」按 `gateLedgers` 整批并入正式表,
* `parts.chip_type` 不在名单里,人新建的类型字典行就只留在草稿层、从不落地,
* 引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 —— 一条悬空引用
* (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。
*/
it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => {
const card = acts[6];
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[6]?.kind === 'card' ? (acts[6].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('六笔:分析 → 关系(先摆一遍满足 refersTo)→ 明细 → 关系(重算)→ 验证记录 → 交代;analyze 恰好一笔', () => {
expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'project', 'say']);
expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1);
/* 两次 project 到 substitution.relation 是同一个引用(RELATION_PROJECTION),不是抄了两份 */
expect(acts[1]).toBe(acts[3]);
expect(acts[1]?.kind === 'project' && acts[1].into).toBe('substitution.relation');
expect(acts[2]?.kind === 'project' && acts[2].into).toBe('substitution.difference_item');
});
it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => {
const items = acts[2];
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[2]?.kind === 'project' ? acts[2].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[2]?.kind === 'project' ? acts[2].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 出参、不读自报列', () => {
// acts[1] 与 acts[3] 是同一个 RELATION_PROJECTION 引用,查哪个都一样
const rel = acts[1]?.kind === 'project' ? acts[1].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[4];
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', 'project', 'card', 'say']);
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.difference_item');
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('substitution.relation');
expect(s3[2]?.kind === 'project' && s3[2].into).toBe('validation.record');
});
/*
* **这是 2026-08-30 task-12「结论纹丝不动」缺陷的回归钉**:S3 从前直接从
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 起步,人改的 `difference_status`
* 没有任何一笔把它转译成 `judgement_class`,两笔投影读到的 tally 纹丝不动。
* `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。
*/
it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => {
const recompute = s3[0];
expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' });
/* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有
`prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */
expect('prune' in (recompute ?? {})).toBe(false);
const fields = recompute?.kind === 'project' ? recompute.fields : undefined;
/* 只有两把主键(定位这一行)+ 两个派生列(judgement_class/risk_level)—— 一个都不多写:
difference_status / impact_statement / evidence_note / unknown_disposition(人可以就地改的那几列)
一个都不在这份 fields 里,靠 draftWrite 的稀疏 patch 语义保证不被这一笔碰到。 */
expect(Object.keys(fields ?? {}).sort()).toEqual(['difference_item_id', 'judgement_class', 'risk_level', 'substitution_relation_id']);
});
/*
* **判定分类 / 风险等级的 when 链只许有一处真源**:S3 的重算笔与 S2 差异明细创建笔的
* `judgement_class`/`risk_level` 必须是**同一个对象引用**(都来自 `...JUDGEMENT_DERIVED_FIELDS`
* 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。
*/
it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => {
const s3Fields = s3[0]?.kind === 'project' ? s3[0].fields : undefined;
const s2Fields = s2[2]?.kind === 'project' ? s2[2].fields : undefined;
expect(s2Fields?.['judgement_class']).toBeDefined();
expect(s3Fields?.['judgement_class']).toBe(s2Fields?.['judgement_class']);
expect(s3Fields?.['risk_level']).toBe(s2Fields?.['risk_level']);
});
/**
* **S3 的关系笔只重算判定,收口四格一个字不碰**(2026-08-31 用户:「差异评估结论审核这个
* 界面的收口结论下面这 4 个字段也需要可以编辑」)。
*
* 此前 S2 与 S3 共用同一份 `RELATION_PROJECTION`(「各写一份早晚分叉」)。件上把四格打开
* 之后那条口径不能再照搬:那一笔每跑一次就按规则重写这四列,而人在件上按保存会触发
* quiet 拍、quiet 恰恰跑 `project` —— 人刚写下的话当场被冲掉,等于格子白开。
*
* 现在 S3 换成 `RELATION_RECOMPUTE`:只列主键 + `recommendation_level`。`patch` 是稀疏的、
* DB 侧是 jsonb 浅合并,没列的列原样留着 —— **这条断言的要害就是「没列」**,列了就等于没改。
* 而 `recommendation_level` 必须仍在(判据核心:件② 判据表 / 件③ 落账结论 / 出口谓词都读它,
* 「人改四态 → 等级跟着变」这条链不能断),且与 S2 那份是**同一个 when 链引用**(不许分叉)。
*/
it('**S3 的关系笔只重算判定** —— 收口四格(理由/摘要/风险/验证要求)一列都不许出现,否则人改完一保存就被冲掉', () => {
const relation = s3[1];
expect(relation?.kind).toBe('project');
const fieldsOf = (act: (typeof s3)[number] | undefined): Readonly<Record<string, unknown>> =>
act?.kind === 'project' ? act.fields : {};
const fields = fieldsOf(relation);
for (const col of ['recommendation_reason', 'difference_summary', 'risk_level', 'verification_requirement']) {
expect(Object.keys(fields), col).not.toContain(col);
}
/* 判定仍要重算,且与 S2 那份共用同一条 when 链 —— 两处各写一份早晚分叉 */
const s2Relation = fieldsOf(s2[3]);
expect(fields['recommendation_level']).toBeDefined();
expect(fields['recommendation_level']).toBe(s2Relation['recommendation_level']);
/* 主键带回:同 PK 覆盖,不是新开一行 */
expect(fields['substitution_relation_id']).toBeDefined();
/* 验证记录那一笔仍与 S2 同一份(它不涉及人可改的格子) */
expect(s3[2]).toBe(s2[4]);
});
/*
* **这一条读的是真 `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[3];
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();
});
});