diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index 6e6faf7..135f3ae 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -345,17 +345,50 @@ describe('S3 / S4 配方', () => { 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('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']); }); it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => { // S2 的 do 数组里 RELATION_PROJECTION 出现两次(acts[1]/acts[3],同一个引用)—— // 与 S3 比对随便挑一个都行,这里挑 acts[3](紧跟在差异明细之后、值是真的那一次) - expect(s3[0]).toBe(s2[3]); - expect(s3[1]).toBe(s2[4]); + expect(s3[1]).toBe(s2[3]); + expect(s3[2]).toBe(s2[4]); }); /* @@ -371,7 +404,7 @@ describe('S3 / S4 配方', () => { }); it('S3 的卡:审核面孔、单一选项、挂本步的件', () => { - const card = s3[2]; + 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); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 9bf59fb..513cc08 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -140,6 +140,70 @@ const SHARED_LET = { relationId: { cat: ['SUB-', { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }] }, } as const; +/* + * **人改了四态,判定分类 / 风险等级要跟着重算 —— 而这两列的算法此前只在 S2 出现过一次** + * (2026-08-30,治「结论纹丝不动」的要害缺陷:件上白纸黑字承诺「你改动下表里任何一条 + * 四态,这五项当场重算」——实测是假的。人在审核件上把某一行的 `difference_status` 从 + * `mismatch` 改成 `match` 并保存,草稿里那一行变成 `difference_status=match` 但 + * `judgement_class` 仍是旧的 `hard_mismatch`(**同一行自相矛盾**);`RELATION_PROJECTION`/ + * `VALIDATION_PROJECTION` 读的是 `judgement_class` 的 tally,不是 `difference_status` + * 本身,两者之间从来没有一座桥。**S3 存在的全部意义就是让人纠正 AI;纠正流不下去, + * S3 就是摆设。** + * + * **这段 when 链与 S2 差异明细那一笔共用同一份**(下面 `substitution.difference_item` + * 那笔 `project` 的 `fields` 里 `...JUDGEMENT_DERIVED_FIELDS`)—— 判定规则只许有一处真源, + * 与 `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 本身「S2 与 S3 共用同一份」同一条道理。 + */ +const JUDGEMENT_DERIVED_FIELDS = { + judgement_class: { + when: 'difference_status = "match"', then: 'match', + else: { + when: 'difference_status = "unknown"', then: 'unknown', + else: { + when: 'difference_status = "conditional"', then: 'conditional', + else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' }, + }, + }, + }, + risk_level: { + when: 'judgement_class = "hard_mismatch"', then: 'high', + else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' }, + }, +} as const; + +/* + * **S3 专用的重算笔**——逐行把 `judgement_class`/`risk_level` 按这一行**当下**的 + * `difference_status`(人可能刚改过)与 `constraint_type`(字典定的,人不改)重新算一遍。 + * + * **为什么不能像 S2 那样把差异明细整笔搬过来重投**:S2 那一笔的 `difference_status` 走 + * `lookup:'result:items'`(AI 出参,只在 `analyze` 那一拍的拍内作用域里活着);S3 这一拍 + * `result:items` 早就不在了,`{lookup:...}` 落到 `else:''`,原笔重投会把人刚改的值**抹成空** + * ——这是真实的设计张力,不是疏忽(评审已确认,见 `SHARED_LET` 定义处的头注)。 + * + * **只列这两列,一个都不多写**:`fields` 之外没列的列,`draftWrite` 的 `patch` 是稀疏的 + * (只含显式给出的列;DB 侧 `patch = patch || excluded.patch` 是 jsonb **浅合并**,没给的列 + * 原样留着,`ledger-store.ts` 的 `put`)——于是这一笔天然不会碰 `difference_status` 自己, + * 也不会碰人可以改的那几列(影响说明 / 判断依据 / UNKNOWN 去向)。**不带 `prune`**: + * `from` 是对这张表自己的无过滤 `each`,枚举出来的集合本来就是「这张表现在有的全部行」, + * `prune` 没有事情可做,而已落地的行本就不许 `prune`(H43)。 + * + * **必须排在 `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 之前**:后两笔读的 + * `substitution_difference_item_judgement_class_*` 是基座对这张表当下内容现算的 tally, + * 「一笔读的是另一笔写出来的数,它就得排在那一笔后面」(与 `sub_eval.case` 那笔 `gate_reason` + * 排在 ④⑤ 之后是同一条道理,见 S1 那笔 ⑥ 头上的大注释)。 + */ +const DIFFERENCE_ITEM_RECOMPUTE = { + kind: 'project' as const, + into: 'substitution.difference_item', + from: { each: 'mine:substitution.difference_item' }, + fields: { + /* 主键原样带回,认的是这一行自己 —— 不是新建,是同 PK 覆盖 */ + substitution_relation_id: { col: 'substitution_relation_id' }, + difference_item_id: { col: 'difference_item_id' }, + ...JUDGEMENT_DERIVED_FIELDS, + }, +} as const; + /* * **关系与验证记录的投影,S2 与 S3 共用同一份**。 * S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 —— @@ -628,21 +692,8 @@ export const ASSESSMENT_STEPS = [ impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' }, evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' }, unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' }, - /* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 */ - judgement_class: { - when: 'difference_status = "match"', then: 'match', - else: { - when: 'difference_status = "unknown"', then: 'unknown', - else: { - when: 'difference_status = "conditional"', then: 'conditional', - else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' }, - }, - }, - }, - risk_level: { - when: 'judgement_class = "hard_mismatch"', then: 'high', - else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' }, - }, + /* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */ + ...JUDGEMENT_DERIVED_FIELDS, rule_version: RULE_VERSION, }, }, @@ -689,7 +740,13 @@ export const ASSESSMENT_STEPS = [ */ let: SHARED_LET, do: [ - /* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */ + /* + * **人改了四态,先把这一行自己的判定分类 / 风险等级重算,关系与验证记录的投影才有 + * 真数据可读**(2026-08-30 修「结论纹丝不动」的要害缺陷,详见 `DIFFERENCE_ITEM_RECOMPUTE` + * 定义处的头注)。三笔次序不能乱:①→②③ 是「一笔读的是另一笔写出来的数,它就得排在 + * 那一笔后面」的又一例 —— 摘要只读,改的是四态。 + */ + DIFFERENCE_ITEM_RECOMPUTE, RELATION_PROJECTION, VALIDATION_PROJECTION, { diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 662323d..1701cb0 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -54,7 +54,7 @@ export const manifest: ActivityManifest = { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', /* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */ - packageVersion: '2.1.7', + packageVersion: '2.1.8', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', diff --git a/apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts b/apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts index 3a0e18f..98bb0d5 100644 --- a/apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts @@ -11,17 +11,39 @@ * **人在审核件上改了四态,那一行永远不会更新**,这正是 S3 存在的全部理由; * ② 验证记录的主键 `cat:['VAL-', {in:'evalRef'}]` 反而拼出非空的 `'VAL-'`,被当成一个新主键 * 写出**第二行垃圾**,破了「P-06 一单固定一条」的不变量。 + * (① ② 两条已修:S3 有了自己的 `let`,见 `S3_LET_MIRROR`/`S3_RECIPE`。) + * + * **2026-08-30 task-12 加第二条覆盖,同一份文件、同一套机制,但是另一层缺陷**(浏览器走查 + * 发现⑤,`docs/superpowers/specs/.../progress.md` H 系列 ruling 之后的那条):①②修好之后, + * `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 的主键与外键**绑得上了**,可它们算结论用的 + * `substitution_difference_item_judgement_class_*` tally 读的是**判定分类**列,不是四态列 —— + * 人在审核件上把某一行的 `difference_status` 从 `mismatch` 改成 `match` 并保存, + * **没有任何一笔把这个新值翻译成 `judgement_class`**,tally 纹丝不动,两笔投影算出同样的结论。 + * 件上白纸黑字承诺「你改动下表里任何一条四态,这五项当场重算」——这句话曾经是假的。 + * + * **这份文件原本的 `HUMAN_EDIT` 恰好把这层缺陷藏起来了**:它在同一笔 `project` 里**同时** + * 写了 `difference_status`(`{col:'status'}`)**和** `judgement_class`(`{col:'jclass'}`)—— + * 等于替被测系统把「四态 → 判定分类」的翻译**预先做完**,断言看到的「结论正确重算」其实是 + * 这笔 setup 自己写对的,不是 S3 配方推出来的。人在真实审核件上**只能改 + * `difference_status`/`impact_statement`/`evidence_note`/`unknown_disposition` 这几列** + * (`difference-review` 目标件的 `data-edit` 绑定只挂了这几个 `data-col`)——`judgement_class` + * 从来不在人能碰的字段里。现在的 `HUMAN_EDIT` 已改为只写 `difference_status` + `evidence_note` + * 两列,与真实的就地编辑同源;`S3_RECIPE` 补上 `RECOMPUTE_MIRROR`(镜像 S2/S3 新共用的 + * `DIFFERENCE_ITEM_RECOMPUTE`),让「翻译」这一步真的在被测流程里发生一遍。 * * 只有把真引擎(`createAutoRunner`)跑起来才测得出 —— - * `activities/part-substitution-assessment/__tests__/steps.test.ts` 的六条用例全是结构断言 - * (查 `kind`、查 `into`、`toBe` 引用相等),从来没有对配方求过值,漏不出这一类"名字没绑上"的洞。 + * `activities/part-substitution-assessment/__tests__/steps.test.ts` 的用例全是结构断言 + * (查 `kind`、查 `into`、`toBe` 引用相等),从来没有对配方求过值,漏不出这一类"名字没绑上" + * 或"两张表的值没对齐"的洞。 * * **手写镜像,不 import 真配方** —— 与 `w05-gate-reason-order.test.ts` 同一条理由: * `activities/*` 与 `apps/*` 互相不许 import(ADR-004,`.dependency-cruiser.cjs` 的 - * `activities-isolated` / `apps-not-into-activities`)。`REL_MIRROR`/`VAL_MIRROR`/`S3_LET_MIRROR` - * 逐字取自 `case-model.ts` 当前的 `RELATION_PROJECTION`/`VALIDATION_PROJECTION`/`SHARED_LET` - * ——在形状上代表,不是逐字同步的承诺(谁改了这三者的**形状**,该顺手核一遍这份文件还有没有 - * 代表性,欠账记在 `docs/debt.md`,与 S1 那份镜像同一条纪律)。 + * `activities-isolated` / `apps-not-into-activities`)。 + * `REL_MIRROR`/`VAL_MIRROR`/`S3_LET_MIRROR`/`JUDGEMENT_FIELDS_MIRROR`/`RECOMPUTE_MIRROR` + * 逐字取自 `case-model.ts` 当前的 + * `RELATION_PROJECTION`/`VALIDATION_PROJECTION`/`SHARED_LET`/`JUDGEMENT_DERIVED_FIELDS`/ + * `DIFFERENCE_ITEM_RECOMPUTE` ——在形状上代表,不是逐字同步的承诺(谁改了这五者的**形状**, + * 该顺手核一遍这份文件还有没有代表性,欠账记在 `docs/debt.md`,与 S1 那份镜像同一条纪律)。 * * **读数层四个函数直接 import 真身**,理由与路径别名 `@app/*` 同 `w05-gate-reason-order.test.ts` * 头注:`apps/api/src/modules/loop-engine/__tests__/` import `apps/api/src/app/wiring/engines.ts` @@ -307,8 +329,18 @@ const SETUP_S1S2: AutoRecipe = { /** * 阶段二(setup,镜像人在审核件上就地改了一格):把「静态电流」那一行的四态从 `match` - * 改成 `hard_mismatch`(审核人对着候选规格书发现待机功耗超预算,原厂的硬约束没扛住)。 - * 与真实的就地编辑同源 —— 只改这一行,不碰另一行,不用 `prune`。 + * 改成 `mismatch`(审核人对着候选规格书发现待机功耗超预算,原厂的硬约束没扛住), + * 并顺手把判断依据写清楚。 + * + * **只写人在审核件上真正碰得到的那几列**(2026-08-30 task-12 修正,见文件头注)—— + * `difference_status` + `evidence_note`。**绝不写 `judgement_class`/`risk_level`**: + * 那是 S3 配方该重算的两列,人手上的表单压根没有这两格(`difference-review` 目标件的 + * `data-edit` 绑定只挂了 `difference_status`/`impact_statement`/`evidence_note`/ + * `unknown_disposition` 四个 `data-col`)。写它们等于替被测系统预先把「四态 → 判定分类」 + * 的翻译做完,会让本用例测不出「S3 到底有没有真的重算」这件事本身 —— + * 这正是这份文件原本的写法把 task-12 那层缺陷藏起来的原因。 + * + * 与真实的就地编辑同源:只改这一行,不碰另一行,不用 `prune`。 */ const HUMAN_EDIT: AutoRecipe = { do: [ @@ -318,33 +350,63 @@ const HUMAN_EDIT: AutoRecipe = { from: { rows: [ { - item_id: `${RELATION_ID}-静态电流`, name: '静态电流', desc: '影响待机功耗', - status: 'mismatch', jclass: 'hard_mismatch', orig: '50uA', cand: '200uA', unit: 'uA', - note: '审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算', risk: 'high', + item_id: `${RELATION_ID}-静态电流`, + status: 'mismatch', + note: '审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算', }, ], }, fields: { substitution_relation_id: RELATION_ID, difference_item_id: { col: 'item_id' }, - parameter_name: { col: 'name' }, - parameter_desc: { col: 'desc' }, - constraint_type: 'hard', - original_value: { col: 'orig' }, - candidate_value: { col: 'cand' }, - parameter_unit: { col: 'unit' }, difference_status: { col: 'status' }, - judgement_class: { col: 'jclass' }, - impact_statement: '候选静态电流超出原厂 4 倍,待机功耗预算不满足', evidence_note: { col: 'note' }, - unknown_disposition: '', - risk_level: { col: 'risk' }, - rule_version: 'R-TEST', }, }, ], }; +/** + * `JUDGEMENT_DERIVED_FIELDS` 的镜像 —— 逐字取自 `case-model.ts` 当前的常量。 + * S2 的差异明细创建笔与 S3 的重算笔(`RECOMPUTE_MIRROR`,下面)共用同一份 —— + * 判定规则只许有一处真源,各写一份早晚分叉。 + */ +const JUDGEMENT_FIELDS_MIRROR = { + judgement_class: { + when: 'difference_status = "match"', then: 'match', + else: { + when: 'difference_status = "unknown"', then: 'unknown', + else: { + when: 'difference_status = "conditional"', then: 'conditional', + else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' }, + }, + }, + }, + risk_level: { + when: 'judgement_class = "hard_mismatch"', then: 'high', + else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' }, + }, +}; + +/** + * `DIFFERENCE_ITEM_RECOMPUTE` 的镜像 —— **这是本用例要钉的核心新增笔**(2026-08-30 + * task-12):S3 若没有这一笔(或它排在 `REL_MIRROR`/`VAL_MIRROR` 之后),人改的 + * `difference_status` 就没有任何桥转成 `judgement_class`,两笔投影读到的 tally 纹丝不动 —— + * 这正是「结论纹丝不动」那条要害缺陷的机制。只列主键 + 两个派生列,不碰 + * `difference_status` 自己、也不碰人可以改的叙事列(`impact_statement`/`evidence_note`/ + * `unknown_disposition`)。 + */ +const RECOMPUTE_MIRROR: AutoAction = { + kind: 'project', + into: 'substitution.difference_item', + from: { each: 'mine:substitution.difference_item' }, + fields: { + substitution_relation_id: { col: 'substitution_relation_id' }, + difference_item_id: { col: 'difference_item_id' }, + ...JUDGEMENT_FIELDS_MIRROR, + }, +}; + /** * `RELATION_PROJECTION` 的镜像 —— 逐字取自 `case-model.ts` 当前的常量(不代表逐字同步承诺, * 见文件头注)。**主键 / 外键全靠 `let` 绑定的名字**,这正是这份测试要钉住的那一段。 @@ -457,10 +519,22 @@ const S3_LET_MIRROR = { }; /** - * **S3 当前(修复后)的样子**:带上自己的 `let`。case-model.ts 现在就是这样写的 - * (`let: SHARED_LET, do: [RELATION_PROJECTION, VALIDATION_PROJECTION, card, say]`)。 + * **S3 当前(修复后)的样子**:带上自己的 `let`,并且在关系 / 验证记录投影之前先跑一遍 + * 差异明细重算笔(`RECOMPUTE_MIRROR`)。case-model.ts 现在就是这样写的 —— + * `let: SHARED_LET, do: [DIFFERENCE_ITEM_RECOMPUTE, RELATION_PROJECTION, VALIDATION_PROJECTION, card, say]` + * (2026-08-30 task-12 在原有的 `REL_MIRROR, VAL_MIRROR, ...` 前面插入了 `RECOMPUTE_MIRROR`)。 */ const S3_RECIPE: AutoRecipe = { + let: S3_LET_MIRROR, + do: [RECOMPUTE_MIRROR, REL_MIRROR, VAL_MIRROR, CARD_MIRROR, SAY_MIRROR], +}; + +/** + * **S3 修复前的样子**(2026-08-30 task-12 之前 `case-model.ts` 实际写的):没有 + * `RECOMPUTE_MIRROR` 那一笔,直接从关系 / 验证记录投影起步。留着它专门给下面「同一行 + * 自相矛盾」那条用例做对照 —— 不靠临时改文件做变异验证,这份对照本身就是常驻回归。 + */ +const S3_RECIPE_PRE_FIX: AutoRecipe = { let: S3_LET_MIRROR, do: [REL_MIRROR, VAL_MIRROR, CARD_MIRROR, SAY_MIRROR], }; @@ -495,3 +569,70 @@ describe('S3 审核卡配方:复用 S2 投影时必须自带 let,否则两笔投 expect(out.issuedCardId).toBeUndefined(); }); }); + +/* + * **2026-08-30 task-12 加的一组**:钉住「结论纹丝不动」那条要害缺陷本身 —— + * 人改了 `difference_status`,**这一行自己的** `judgement_class`/`risk_level` 有没有真的 + * 跟着重算,以及重算有没有误伤人可以改的叙事列。上一组的断言只看关系表 / 验证记录的 + * 结果,没有直接看差异明细这一行自己;而「同一行自相矛盾」正是浏览器走查实测到的现象 + * ——`difference_status=match` 与 `judgement_class=hard_mismatch` 同时摆在一行上。 + * + * 第一条用 `S3_RECIPE_PRE_FIX`(没有 `RECOMPUTE_MIRROR` 那一笔)重现修复前的样子, + * 第二条用 `S3_RECIPE`(现在的样子)证明修复生效 —— 两条对照就是常驻的变异验证, + * 不必临时改这份文件本身来复现「摘掉修复」的红。 + */ +describe('S3 审核卡配方:差异明细重算 —— 人改的四态要转成判定分类,不能只停在人写的那一列', () => { + it('修复前的形状(S3_RECIPE_PRE_FIX,没有差异明细重算笔):同一行自相矛盾 —— difference_status 变了,judgement_class 纹丝不动', async () => { + const { runner, drafts, ctx } = harness(); + await runner.run(SETUP_S1S2, ctx); + await runner.run(HUMAN_EDIT, ctx); + await runner.run(S3_RECIPE_PRE_FIX, ctx); + + const edited = [...drafts.values()].find( + (d) => + d.domain === 'substitution' && + d.object === 'difference_item' && + d.key['difference_item_id'] === `${RELATION_ID}-静态电流`, + ); + // 人改的那一列如实落了草稿 + expect(edited?.patch['difference_status']).toBe('mismatch'); + // 但没有任何一笔把它翻译成 judgement_class —— 停在 setup 阶段写下的旧值,同一行自相矛盾 + expect(edited?.patch['judgement_class']).toBe('match'); + expect(edited?.patch['risk_level']).toBe('low'); + }); + + it('修复后(S3_RECIPE):差异明细这一行自己的 judgement_class / risk_level 真的跟着 difference_status 翻了,且叙事列一个字没被覆盖', async () => { + const { runner, drafts, ctx } = harness(); + await runner.run(SETUP_S1S2, ctx); + await runner.run(HUMAN_EDIT, ctx); + await runner.run(S3_RECIPE, ctx); + + const edited = [...drafts.values()].find( + (d) => + d.domain === 'substitution' && + d.object === 'difference_item' && + d.key['difference_item_id'] === `${RELATION_ID}-静态电流`, + ); + expect(edited?.patch['difference_status']).toBe('mismatch'); + expect(edited?.patch['judgement_class']).toBe('hard_mismatch'); + expect(edited?.patch['risk_level']).toBe('high'); + + // 人在这一行写的判断依据一个字没被重算笔覆盖(它只列了两个派生列) + expect(edited?.patch['evidence_note']).toBe( + '审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算', + ); + // 人没碰过的 impact_statement 仍是 SETUP_S1S2 写的那句 —— 证明重算笔没有清空叙事列 + expect(edited?.patch['impact_statement']).toBe('无'); + + // 没被人改过的那一行(输入电压范围)保持原样 —— 重算笔不该误伤没变过的行 + const untouched = [...drafts.values()].find( + (d) => + d.domain === 'substitution' && + d.object === 'difference_item' && + d.key['difference_item_id'] === `${RELATION_ID}-输入电压范围`, + ); + expect(untouched?.patch['difference_status']).toBe('match'); + expect(untouched?.patch['judgement_class']).toBe('match'); + expect(untouched?.patch['risk_level']).toBe('low'); + }); +});