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>` 这个**开放索引签名**上 —— 拼写保护是回来了,但整个 * 文件里按方括号取字段值的 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(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', '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', ]); }); /* * **数据齐了也叫人一次**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个 * 验收工单…把芯片卡片上的编辑按钮去掉,只展示不能编辑」)。两张卡是互斥的两条路,不是先后: * 引擎的 `carded` 幂等保证一步只发一张 —— 数据工单发过之后,验收工单这一笔永远轮不到 * (补完数据不会再被叫第二次)。 */ it('两张卡:缺东西派数据工单(读闸)· 都齐了派只读验收工单(读上一笔刚写下的粘旗)', () => { const cards = acts.filter((a) => a.kind === 'card'); expect(cards.map((c) => (c.kind === 'card' ? c.variant : undefined))).toEqual(['data', 'accept']); /* 验收工单不重算一遍闸 —— 读的是案卷刚写下的旗,同一件事只许有一处判定 */ expect(cards[1]?.kind === 'card' && cards[1].when).toBe('sub_eval_case_order_kind_accept > 0'); /* 两张都是执行工单(验收工单是它的变体,不是第三种 mode);零输入,填报/核对都在件上 */ for (const c of cards) expect(c.kind === 'card' && c.as).toBe('execution'); /* 闸同一份:件只读也要在确认那一刻再数一遍「数据是齐的」 */ for (const c of cards) expect(c.kind === 'card' && c.gateLedgers).toEqual(['parts.profile', 'parts.parameter']); }); /* * 旗**粘着**:第一档读的是上一拍写下的自己(tally 读数)。不粘着的后果实打实 —— * 人在数据工单上把最后一格填上、还没点确认,闸当场变 clear,件的题头会当场改名叫 * 「验收工单」、卡角的笔也没了,而他手里那张卡还开着。 */ it('order_kind 粘着:已经是 data 就一直是 data,否则按此刻的闸决定(与发卡条件同一句)', () => { const caseAct = acts.find((a) => a.kind === 'project' && a.into === 'sub_eval.case'); const kind = caseAct?.kind === 'project' ? caseAct.fields['order_kind'] : undefined; const gate = acts[4]?.kind === 'card' ? acts[4].when : undefined; expect(kind).toEqual({ when: 'sub_eval_case_order_kind_data > 0', then: 'data', /* 第二档与数据工单那张卡读的是同一句闸(一处真源) */ else: { when: gate, then: 'data', else: 'accept' }, }); }); /* * 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 不再炸,用户那条路清得干净。 */ /* * SKIP(2026-08-31 用户定「只选不建」):②③ 两笔自投影连同 gateLedgers 上那两张表 * 一并退役 —— 类型只许从字典里选,选中的行本来就在正式表里,镜像与落地都失去了对象。 * 这条钉的机制(prune + skipLanded)仍活在 auto-runner 测试里,不随本活动退役。 */ it.skip('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(带 prune,已落地由基座跳过)', () => { expect(true).toBe(true); }); 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[3]; 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[2]; /* '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[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'); }); /* * 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[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.profile', 'parts.parameter']); /* * 类型不一致不许确认(2026-08-31 用户定)。钉 parts.profile 本表的 distinct —— * 提交闸从草稿现读零滞后;钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦。 */ expect(card?.kind === 'card' && card.gateWhen).toBe('parts_profile_distinct_chip_type_id <= 1'); expect(card?.kind === 'card' && typeof card.gateWhenSay === 'string' && card.gateWhenSay.includes('不是同一个芯片类型')).toBe(true); expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0); expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3); }); it('红条开关 gate_state 与发卡条件同一句判定(一处真源)—— gate_reason 清空不落笔,开关列两个方向都真写', () => { const kase = acts[3]; const fields = kase?.kind === 'project' ? kase.fields : undefined; const gs = fields?.['gate_state'] as { when?: string; then?: string; else?: string } | undefined; const card = acts[4]; expect(gs?.then).toBe('open'); expect(gs?.else).toBe('clear'); expect(card?.kind === 'card' && card.when).toBe(gs?.when); }); 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('六笔:分析 → 关系(先摆一遍满足 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', 'analyze', 'project', 'project', 'project', 'project', 'say', 'card']); for (const at of [0, 1, 3, 4]) expect(s3[at]?.kind === 'project' && s3[at].into).toBe('substitution.difference_item'); expect(s3[5]?.kind === 'project' && s3[5].into).toBe('substitution.relation'); expect(s3[6]?.kind === 'project' && s3[6].into).toBe('validation.record'); }); /* * **人打回时,意见要交回给 AI 重判**(2026-09-01 用户定:「重做后是否没有将重做原因带回去 * 重新让 ai 重跑这一步?」)—— 在此之前 S3 那一轮只把派生列重算一遍,人写的那句话除了留痕 * 之外**无人读**:AI 判过的四态一个字不会变,除非人自己在件上逐格改。 * * 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的那句话(没打回就是空串)—— * 常路(第一次送审)这两笔跳过,整个活动仍然只调一次模型。 */ it('复核轮四笔都钉在「人打回了才跑」上 —— 常路一笔都不跑,整单仍只调一次模型', () => { /* `when` 不是每一种笔都有的键 —— 按「有没有这个键」取,不做属性访问(与本文件既有的 `'prune' in …` 同一写法) */ const whenOf = (at: number): unknown => { const act = s3[at]; return act !== undefined && 'when' in act ? act.when : undefined; }; for (const at of [0, 1, 2, 3]) expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('case_redo_note != ""'); /* 后面那几笔(重算 / 关系 / 验证)照旧每一轮都跑 —— 人就地改了四态也要跟着重算 */ for (const at of [4, 5, 6]) expect(whenOf(at), `第 ${at} 笔`).toBeUndefined(); }); /* * **谁可以变,由确定的一侧圈**(2026-09-01 用户实测:「我打回让修改工作温度的结论,给我改了 * 静态电流的结论」)—— 第一版把整表交给 AI 重判、整表写回,没有任何东西限制它只动被点名的 * 那一行;它改了没人问过的那条,`evidence_note` 里还写着「维持原判」。 * 现在:①按字面点名圈出 `named`;③入参**只有** `named` 的行;④写回也只写这几行。 */ it('圈范围:点名的标 named,其余 kept;一条都没点到时整表算被点名', () => { const mark = s3[0]; const widen = s3[1]; const markFields: Readonly> = mark?.kind === 'project' ? mark.fields : {}; expect(markFields['redo_scope']).toEqual({ when: 'contains(case_redo_note, parameter_name)', then: 'named', else: 'kept' }); /* 兜底那一笔读的是上一笔刚写下的 tally 读数 —— 不重新算一遍谁被点到 */ expect(widen?.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named = 0'); expect(widen?.kind === 'project' ? widen.fields['redo_scope'] : undefined).toBe('named'); }); it('重判与写回都只认 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着', () => { const redo = s3[2]; const land = s3[3]; const named = { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } }; expect(redo?.kind === 'analyze' ? redo.input.from : undefined).toEqual(named); expect(land?.kind === 'project' ? land.from : undefined).toEqual(named); /* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */ const rules = s2[0]?.kind === 'analyze' ? s2[0].instruction : ''; expect(redo?.kind === 'analyze' && redo.instruction.startsWith(rules)).toBe(true); /* * **复核轮审核人说了算**(2026-09-01 用户第二次实测:「重做还是没有修改掉内容」)—— * 他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 搬出第一轮那条 * 「硬约束不因措辞变成 match」把他顶了回去。那条规矩管的是**它自己判**的时候别被措辞带偏; * 审核人是这一步的责任人(要签他的字,他在件上本来就能亲手改任何一条四态),不归它管。 * 指令里必须**明写这条覆盖**,否则前面那份规矩排在前面、模型照旧照它办。 */ const tail = redo?.kind === 'analyze' ? redo.instruction.slice(rules.length) : ''; expect(tail).toContain('这一轮不归它管'); expect(tail).toContain('他点名要什么结论,就落什么'); expect(tail).toContain('不要顶回去'); /* 落的同时要把「按谁的意见、数据面是什么」记进依据 —— 下一个人得能分清人定的与数据判的 */ expect(tail).toContain('数据面'); /* 入参:上一轮结论 + 他写的意见 —— 少了任何一样,重判要么无据可依,要么读不到意见 */ const fields: Readonly> = redo?.kind === 'analyze' ? redo.input.fields : {}; for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) { expect(Object.keys(fields)).toContain(key); } expect(fields['reviewer_note']).toEqual({ in: 'case_redo_note' }); /* 模型漏答某一条:兜回这一行上一轮的值 —— 漏答不该变成「把它清空」 */ const landFields: Readonly> = land?.kind === 'project' ? land.fields : {}; for (const col of ['difference_status', 'impact_statement', 'evidence_note', 'unknown_disposition']) { expect(landFields[col]).toMatchObject({ lookup: 'result:items', else: { col } }); } /* 派生列与两处创建笔共用同一份 when 链 */ const s2Fields: Readonly> = s2[2]?.kind === 'project' ? s2[2].fields : {}; expect(landFields['judgement_class']).toBe(s2Fields['judgement_class']); }); /* * **这是 2026-08-30 task-12「结论纹丝不动」缺陷的回归钉**:S3 从前直接从 * `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 起步,人改的 `difference_status` * 没有任何一笔把它转译成 `judgement_class`,两笔投影读到的 tally 纹丝不动。 * `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。 */ it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => { const recompute = s3[4]; 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[4]?.kind === 'project' ? s3[4].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[5]; expect(relation?.kind).toBe('project'); const fieldsOf = (act: (typeof s3)[number] | undefined): Readonly> => 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[6]).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); }); /* * **交代必须排在卡之前**(2026-09-01 用户:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来, * 不然都不知道发生了什么」)—— 发了卡这一拍就收了,排在卡后面的 `say` 要等**下一拍**才跑, * 那时人早看完卡了。实测:原来那句「差异结论已送审,请核对」出现在人答完之后。 * 两句互斥:被打回那一轮说「按你的意见改了哪一条、改成什么」(摆的是件上那一行自己的 * `evidence_note`,不另编一句);第一轮说「结论已备好,请核对」。 */ it('打回那一轮的交代排在卡之前,摆的是件上那一行的判断依据', () => { const kinds = s3.map((a) => a.kind); /* **排在卡之前**:发了卡这一拍就收了,排在后面的 say 要等下一拍 —— 那时人早看完卡了 */ expect(kinds.lastIndexOf('say')).toBeLessThan(kinds.indexOf('card')); /* 只在被打回那一轮说 —— 第一轮那句与卡自己的 ask 是同一件事,退役了 */ const redoSay = s3[7]; expect(redoSay?.kind === 'say' && redoSay.when).toBe('case_redo_note != ""'); const text = redoSay?.kind === 'say' ? redoSay.text : undefined; const parts = JSON.stringify(text); expect(parts).toContain('按你的意见复核了'); /* 摆的是件上那一行自己的话与名字 —— 对话区与件上说的是同一件事,就该是同一句话 */ expect(parts).toContain('evidence_note'); expect(parts).toContain('parameter_name'); expect(parts).toContain('substitution_difference_item_redo_scope_named'); }); it('S3 的卡:审核面孔、单一选项、挂本步的件', () => { const card = s3[8]; 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(); }); });