From defa04827027d63f8d0cdef50c59ce54b2a6db37 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 14:45:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(w05):=20S1=20=E9=85=8D=E6=96=B9=E6=AC=A1?= =?UTF-8?q?=E5=BA=8F=20=E2=80=94=E2=80=94=20=E6=A1=88=E5=8D=B7=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=E6=8E=92=E5=88=B0=E4=B8=BB=E6=95=B0=E6=8D=AE/?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E9=AA=A8=E6=9E=B6=E4=B9=8B=E5=90=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gate_reason 最深一层要读 parts_profile_missing + parts_parameter_missing,这两个数由主数据/ 参数骨架两笔撑出来。案卷投影若排在它们之前,读到的是这一拍刷新之前的旧数:candidate 的类型刚被 判定、参数骨架行即将铺出但还不存在时,parts_parameter_missing 读到 0,片刻之后变成 1。收步与 发卡的把关本身没坏(card.when 排在铺骨架之后能读到真实缺口,卡照样正确发出),但案卷里存的话、 连同卡面 basis 里「还差什么」那一格,会是一句空话。 六笔重排为 part → profile → parameter → case → card → say;新增引擎级回归测试 (w05-gate-reason-order.test.ts,手写镜像 S1 配方六笔与生产读数层的四个纯函数,因 activities/apps 两侧互相不许 import 而不能直接复用真配方或真引擎的读数逻辑),变异验证过 (旧次序下测试确实变红、且红得精确:Received 恰是评审报告里点名的那个空字符串)。 顺带补一句注释:parts.parameter 的 param_unit 有意直接取字典而不回填现值 —— 同一类型下两颗料 必须按同一个单位比,字典值才是比对基准,S2 判定时用的单位同源,不是漏写了 lookup。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/steps.test.ts | 14 +- .../src/case-model.ts | 133 +++--- .../__tests__/w05-gate-reason-order.test.ts | 434 ++++++++++++++++++ 3 files changed, 522 insertions(+), 59 deletions(-) create mode 100644 apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index eeccad7..8e0fdbe 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -119,10 +119,18 @@ describe('S1 配方', () => { const acts = recipe?.do ?? []; const kinds = acts.map((a) => a.kind); - it('六笔,次序是:两颗料 → 案卷 → 主数据骨架 → 参数骨架 → 卡 → 交代', () => { + /* + * 次序是承重的,不是随手写的(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', 'sub_eval.case', 'parts.profile', 'parts.parameter', + 'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case', ]); }); @@ -140,7 +148,7 @@ describe('S1 配方', () => { }); it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => { - const params = acts[3]; + const params = acts[2]; expect(params?.kind === 'project' && params.prune).toBe(true); const from = params?.kind === 'project' ? params.from : undefined; /* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */ diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index afc4708..8c4ff68 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -156,7 +156,83 @@ export const ASSESSMENT_STEPS = [ chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' }, }, }, - /* ② 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤) */ + /* + * ② 主数据骨架:**两颗料都铺、值回填现值**。 + * 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。 + * **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。 + * 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。 + */ + { + kind: 'project' as const, + into: 'parts.profile', + prune: true, + from: { each: 'mine:sub_eval.part' }, + fields: { + internal_pn: { col: 'internal_pn' }, + chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'chip_type_id', else: '' }, + brand_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'brand_name', else: '' }, + lifecycle_status: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'lifecycle_status', else: '' }, + specification_description: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'specification_description', else: '' }, + manufacturer_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'manufacturer_name', else: '' }, + datasheet_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_ref', else: '' }, + datasheet_version: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_version', else: '' }, + }, + }, + /* + * ③ 参数骨架 —— 三件事一笔办完: + * · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**; + * 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**; + * · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报; + * · **「在库但当年参数没录全」于是自动被拦住** —— 那一格回填出来是空的,红带点名、闸按不下。 + * + * **`param_unit` 是有意不回填现值、直接取字典**(2026-08-29 评审 Minor 记录): + * 同一类型下两颗料必须按**同一个单位**比,`parts.type_param.param_unit` 才是这次比对的基准 —— + * 若某颗料自己记的单位与字典不同,以字典覆盖是**故意的**,不是漏写了 `lookup`; + * S2 喂给 AI 判定时用的 `unit` 同样取自这张字典,两处必须同源,换成回填现值反而会让 + * 「展示的单位」与「判定时用的单位」分叉。 + */ + { + kind: 'project' as const, + into: 'parts.parameter', + prune: true, + from: { + each: 'mine:sub_eval.part', + then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, + }, + fields: { + internal_pn: { outer: 'internal_pn' }, + param_name: { col: 'param_name' }, + param_unit: { col: 'param_unit' }, + param_value: { + lookup: 'parts.parameter', + by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, + take: 'param_value', + else: '', + }, + param_note: { + lookup: 'parts.parameter', + by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, + take: 'param_note', + else: '', + }, + }, + }, + /* + * ④ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤)。 + * + * **排在 ②③ 之后 —— 这是承重的次序,不是随手写的**(2026-08-29 评审 Important 修正): + * `gate_reason` 的 when 链最深一层要读 `parts_profile_missing + parts_parameter_missing`, + * 而这两个数是 ②③ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ②③ **之前** + * (原来的次序),它读到的是这一拍**刷新之前**的旧数:当某颗料的 `chip_type_id` + * 刚在这一拍被 ① 设上、②③ 即将把它的主数据与参数骨架铺出来时,那一刻 ③ 即将新建的 + * 参数骨架行还不存在,`parts_parameter_missing` 读到 0(不存在的行不算「缺」), + * 片刻之后 ③ 一跑就变成 1。收步与发卡的把关本身没坏(⑤ 排在 ②③④ 之后,`card.when` + * 用的是刷新之后的真实读数,卡照样正确发出),但案卷里存的这句话、连同卡面 + * `basis` 里「还差什么」那一格,会是一句空话 —— 人最需要一句解释的时候,读到的是空的。 + * **每一笔之前刷读数**(auto-runner 的硬规矩)保证的是「下一笔看得见上一笔的结果」, + * 保证不了「这一笔算的话用了后面几笔才有的数」—— 读别人数据的那一笔,必须排在被读的 + * 那几笔之后。 + */ { kind: 'project' as const, into: 'sub_eval.case', @@ -187,61 +263,6 @@ export const ASSESSMENT_STEPS = [ }, }, }, - /* - * ③ 主数据骨架:**两颗料都铺、值回填现值**。 - * 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。 - * **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。 - * 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。 - */ - { - kind: 'project' as const, - into: 'parts.profile', - prune: true, - from: { each: 'mine:sub_eval.part' }, - fields: { - internal_pn: { col: 'internal_pn' }, - chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'chip_type_id', else: '' }, - brand_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'brand_name', else: '' }, - lifecycle_status: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'lifecycle_status', else: '' }, - specification_description: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'specification_description', else: '' }, - manufacturer_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'manufacturer_name', else: '' }, - datasheet_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_ref', else: '' }, - datasheet_version: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_version', else: '' }, - }, - }, - /* - * ④ 参数骨架 —— 三件事一笔办完: - * · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**; - * 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**; - * · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报; - * · **「在库但当年参数没录全」于是自动被拦住** —— 那一格回填出来是空的,红带点名、闸按不下。 - */ - { - kind: 'project' as const, - into: 'parts.parameter', - prune: true, - from: { - each: 'mine:sub_eval.part', - then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, - }, - fields: { - internal_pn: { outer: 'internal_pn' }, - param_name: { col: 'param_name' }, - param_unit: { col: 'param_unit' }, - param_value: { - lookup: 'parts.parameter', - by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, - take: 'param_value', - else: '', - }, - param_note: { - lookup: 'parts.parameter', - by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, - take: 'param_note', - else: '', - }, - }, - }, /* * ⑤ 缺什么就派一张数据工单。 * **条件只用基座读数**:`gate_reason` 为空串时那一列压根不写、于是未绑定, diff --git a/apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts b/apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts new file mode 100644 index 0000000..5762acb --- /dev/null +++ b/apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts @@ -0,0 +1,434 @@ +/** + * **S1 配方的动作次序是承重的**(2026-08-29,评审用活探针复现,详情见 + * `activities/part-substitution-assessment/src/case-model.ts` `S1_inputs.auto` 里第 ④ 笔 + * 头上的注释,以及 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-4-report.md`)。 + * + * 案卷投影(`sub_eval.case`)写的 `gate_reason` 是**摆给人看的那句话**,它最深一层的 `when` + * 要读 `parts_profile_missing + parts_parameter_missing`(主数据 / 参数骨架两笔铺出来的基座读数)。 + * 若案卷这一笔排在铺骨架那两笔**之前**,它读到的是这一拍**刷新之前**的旧数 —— + * 具体地:candidate 这颗料的类型刚在本拍第 ① 笔被判定,主数据/参数骨架**即将**在后面几笔 + * 铺出来,而此刻它们还不存在,`parts_parameter_missing` 读到 0(不存在的行不算「缺」)。 + * 收步与发卡的把关本身没坏(`card.when` 排在铺骨架之后,读到的是刷新之后的真实读数, + * 卡照样正确发出),但案卷里存的这句话、连同卡面 `basis` 里「还差什么」那一格,会是空的 —— + * 人最需要一句解释的时候,读到的是空的。 + * + * **为什么不直接 import `case-model.ts` 的真配方**:`activities/*` 与 `apps/*` 互相不许 + * import(`.dependency-cruiser.cjs` 的 `activities-isolated` / `apps-not-into-activities`, + * ADR-004)——测试文件不例外。下面手写的六笔**逐字镜像** `S1_inputs.auto` 当前的地址 / 字段 / + * `when` 链,证明的是"这个次序在真实引擎语义下会不会重现这个 bug",不是"这份文件今天长这样"。 + * + * **为什么不用 `auto-runner.test.ts` 现成的 `harness()`**:那个替身的 `writable()` 硬编码只有 + * 两张表、且没有 `required`/`tally`/`distinct` 元数据。`_missing`/`_distinct_` 这类基座读数 + * 根本不是 `auto-runner.ts` 自己算的(它只管 project/analyze/card/submit/say 五个动词)—— + * 是**上一层**(`apps/api/src/app/wiring/engines.ts` 的 `taskData.view()`)在 `mine:` 之上叠出来的。 + * 下面把 `rowReadings`/`missingReadings`/`tallyReadings`/`distinctReadings` 四个纯函数原样抄一份 + * (逐行核对过与生产版一致,各自头上标了抄自哪个函数),连同真实的 `@sfeel` 求值器,拼出一个 + * 「引擎 + 读数层」的最小复现环境 —— 这正是评审所说"把未经修改的引擎与配方直接执行"的写法。 + */ +import { describe, expect, it } from 'vitest'; +import type { AutoRecipe } from '@contracts'; +import { taskIdSchema, staffIdSchema } from '@contracts'; +import { evaluateCondition } from '@sfeel'; +import { createAutoRunner } from '../internal/usecase/auto-runner.ts'; + +const TASK = taskIdSchema.parse('33333333-3333-4333-8333-333333333333'); +const OWNER = staffIdSchema.parse('44444444-4444-4444-8444-444444444444'); + +type Declared = { + domain: string; + object: string; + caseKey?: string; + fields: ReadonlyArray<{ + name: string; + primaryKey: boolean; + required?: boolean; + expect?: boolean; + tally?: readonly string[]; + distinct?: boolean; + requiredWhen?: { column: string; value: string }; + }>; +}; + +/* + * **四张台账的字段元数据 —— 逐列核对过与 `manifest.ts` 一致**(镜像,非 import,理由见头注)。 + * `writable()` 那个更窄的形状(auto-runner 拿它取主键、校验 `into` 地址)不够算读数: + * `required`/`tally`/`distinct` 这些标记只在这份「声明」里,读数层要靠它们才算得出来。 + */ +const DECLARED: readonly Declared[] = [ + { + domain: 'sub_eval', + object: 'part', + caseKey: 'eval_ref', + fields: [ + { name: 'eval_ref', primaryKey: true }, + { name: 'internal_pn', primaryKey: true }, + { name: 'role', primaryKey: false, required: true, tally: ['original', 'candidate'] }, + { name: 'found_pn_ref', primaryKey: false, requiredWhen: { column: 'exists_status', value: 'found' } }, + { name: 'exists_status', primaryKey: false, required: true, tally: ['found', 'absent'] }, + { name: 'chip_type_id', primaryKey: false, required: true, distinct: true }, + ], + }, + { + domain: 'sub_eval', + object: 'case', + caseKey: 'eval_ref', + fields: [ + { name: 'eval_ref', primaryKey: true }, + { name: 'chip_type_id', primaryKey: false }, + { name: 'gate_reason', primaryKey: false }, + { name: 'summary', primaryKey: false }, + ], + }, + { + domain: 'parts', + object: 'profile', + fields: [ + { name: 'internal_pn', primaryKey: true }, + { name: 'chip_type_id', primaryKey: false, required: true }, + { name: 'brand_name', primaryKey: false, required: true }, + { name: 'lifecycle_status', primaryKey: false, required: true, tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] }, + { name: 'specification_description', primaryKey: false, expect: true }, + { name: 'manufacturer_name', primaryKey: false, expect: true }, + { name: 'datasheet_ref', primaryKey: false, expect: true }, + { name: 'datasheet_version', primaryKey: false, expect: true }, + ], + }, + { + domain: 'parts', + object: 'parameter', + fields: [ + { name: 'internal_pn', primaryKey: true }, + { name: 'param_name', primaryKey: true }, + { name: 'param_value', primaryKey: false, required: true }, + { name: 'param_unit', primaryKey: false, expect: true }, + { name: 'param_note', primaryKey: false, expect: true }, + ], + }, +]; + +/** 抄自 `apps/api/src/app/wiring/engines.ts` 的 `rowLabel`(逐字一致)—— `missingReadings` 拒回点名要用 */ +function rowLabel(id: Readonly>, caseKey: string | undefined): string { + const parts = Object.entries(id) + .filter(([k, v]) => k !== caseKey && v !== undefined && v !== null && String(v) !== '') + .map(([, v]) => String(v)); + return parts.length > 0 ? parts.join('/') : Object.values(id).map(String).join('/'); +} + +/** 抄自 `engines.ts` 的 `rowReadings`(逐字一致,略去与本用例无关的入参默认值注释) */ +function rowReadings(mine: Readonly>, declared: readonly Declared[]): Record { + const out: Record = {}; + for (const { domain, object } of declared) out[`${domain}_${object}_rows`] = 0; + for (const [key, list] of Object.entries(mine)) { + if (!key.startsWith('mine:')) continue; + out[`${key.slice('mine:'.length).replace(/\./g, '_')}_rows`] = list.length; + } + return out; +} + +/** 抄自 `engines.ts` 的 `missingReadings`(逐字一致) */ +function missingReadings(mine: Readonly>, declared: readonly Declared[]): Record { + const out: Record = {}; + for (const { domain, object, caseKey, fields } of declared) { + const rows = mine[`mine:${domain}.${object}`] ?? []; + const keys = fields.filter((f) => f.primaryKey).map((f) => f.name); + const blank = (one: Record, c: string): boolean => one[c] === undefined || one[c] === null || String(one[c]).trim() === ''; + for (const [mark, suffix] of [ + ['required', 'missing'], + ['expect', 'gaps'], + ] as const) { + const watched = fields.filter((f) => f[mark] === true || (mark === 'required' && f.requiredWhen !== undefined)); + if (watched.length === 0) continue; + const bad: string[] = []; + for (const row of rows) { + const one = (typeof row === 'object' && row !== null ? row : {}) as Record; + const due = watched.filter((f) => f.requiredWhen === undefined || String(one[f.requiredWhen.column] ?? '') === f.requiredWhen.value); + const gaps = due.filter((f) => blank(one, f.name)); + if (gaps.length === 0) continue; + const id: Record = {}; + for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]); + bad.push(`${rowLabel(id, caseKey)} 缺${gaps.map((f) => f.name).join('、')}`); + } + out[`${domain}_${object}_${suffix}`] = bad.length; + if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';'); + } + } + return out; +} + +/** 抄自 `engines.ts` 的 `tallyReadings`(逐字一致) */ +function tallyReadings(mine: Readonly>, declared: readonly Declared[]): Record { + const out: Record = {}; + for (const { domain, object, fields } of declared) { + for (const field of fields) { + if (field.tally === undefined || field.tally.length === 0) continue; + const rows = mine[`mine:${domain}.${object}`] ?? []; + const slug = (v: string): string => v.toLowerCase().replace(/[^a-z0-9_]/g, ''); + for (const value of field.tally) { + const key = `${domain}_${object}_${field.name}_${slug(value)}`; + if (key.endsWith('_')) continue; + out[key] = rows.filter((row) => { + const one = (typeof row === 'object' && row !== null ? row : {}) as Record; + return String(one[field.name] ?? '').trim() === value; + }).length; + } + } + } + return out; +} + +/** 抄自 `engines.ts` 的 `distinctReadings`(逐字一致) */ +function distinctReadings(mine: Readonly>, declared: readonly Declared[]): Record { + const out: Record = {}; + for (const { domain, object, fields } of declared) { + for (const field of fields) { + if (field.distinct !== true) continue; + const rows = mine[`mine:${domain}.${object}`] ?? []; + const seen = new Set(); + for (const row of rows) { + const one = (typeof row === 'object' && row !== null ? row : {}) as Record; + const value = String(one[field.name] ?? '').trim(); + if (value !== '') seen.add(value); + } + out[`${domain}_${object}_distinct_${field.name}`] = seen.size; + } + } + return out; +} + +/** + * 假端口 —— 手写的依赖注入替身(不是 mock 框架),`view()` 按生产 `taskData.view()` 同一份配方 + * 拼 `enriched` 作用域:`{...seed, ...读数四件套, ...mine}`(生产多算的 `rowKeyList` / + * `toolCallReadings` / `decisionReadings` 本用例的 `when` 链用不到,略去)。 + */ +function harness(seed: Record) { + const drafts = new Map; patch: Record }>(); + const cards: Array> = []; + const notes: string[] = []; + + const view = (): Record => { + const mine: Record = {}; + for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch }); + /* 草稿字段展平到顶层(与生产 `taskData.view()` 同一条:`{in:'gate_reason'}` 靠它读到值) */ + const flattened: Record = {}; + for (const d of drafts.values()) { + for (const [field, value] of Object.entries({ ...d.key, ...d.patch })) { + if (value !== null && value !== undefined) flattened[field] = value; + } + } + return { + ...seed, + ...rowReadings(mine, DECLARED), + ...missingReadings(mine, DECLARED), + ...tallyReadings(mine, DECLARED), + ...distinctReadings(mine, DECLARED), + ...mine, + ...flattened, + }; + }; + + const deps = { + data: { + view: () => Promise.resolve(view()), + writable: () => + Promise.resolve( + DECLARED.map((d) => ({ + domain: d.domain, + object: d.object, + label: `${d.domain}.${d.object}`, + keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name), + ...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }), + fields: d.fields.map((f) => ({ name: f.name, label: f.name })), + })), + ), + draftWrite: (i: { domain: string; object: string; key: Record; patch: Record; skipUnchanged?: boolean; skipExisting?: boolean }) => { + const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; + const before = drafts.get(id); + const changed = before === undefined || Object.entries(i.patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v)); + if (i.skipUnchanged === true && !changed) return Promise.resolve({ changed }); + drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: { ...(before?.patch ?? {}), ...i.patch } }); + return Promise.resolve({ changed }); + }, + draftDrop: (i: { domain: string; object: string; key: Record }) => { + const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; + return Promise.resolve({ dropped: drafts.delete(id) }); + }, + }, + cards: { + issue: (i: Record) => { + cards.push(i); + return Promise.resolve({ id: 'card-1' }); + }, + decide: () => Promise.reject(new Error('本用例不该发决策卡')), + entry: () => Promise.reject(new Error('本用例不该发录入卡')), + ask: () => Promise.reject(new Error('本用例不该发补全卡')), + unanswered: () => Promise.resolve([]), + }, + memory: { + append: (m: { title: string }) => { + notes.push(m.title); + return Promise.resolve(); + }, + }, + analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, + sfeel: { + /* 真实 S-FEEL 求值器(与生产 `sfeel.condition` 同一条:异常按不成立处置) */ + condition: (expr: string, scope: Readonly>) => { + try { + return evaluateCondition(expr, scope); + } catch { + return false; + } + }, + expression: () => undefined, + unaryTest: () => false, + }, + }; + const ctx = { taskId: TASK, stepId: 'S1_inputs', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false }; + // 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 与 auto-runner.test.ts 同款既有做法 + return { runner: createAutoRunner(deps as never), drafts, cards, notes, ctx }; +} + +/** 起单入参 + 台账种子:LM2596S-5.0 原本就在库且齐全,XL2596S-5.0 已归同一类型但一条参数都没录过 */ +function seedScenario(): Record { + return { + originalInternalPn: 'LM2596S-5.0', + candidateInternalPn: 'XL2596S-5.0', + 'case:openedAt': '2026-08-29T00:00:00.000Z', + 'parts.type_param': [{ type_id: 1, param_name: '输入电压范围', param_desc: '决定能否直接替换而不改外围', constraint_type: 'hard', param_unit: 'V' }], + 'parts.profile': [ + { internal_pn: 'LM2596S-5.0', chip_type_id: 1, brand_name: 'TI', lifecycle_status: 'active', specification_description: '同步降压', manufacturer_name: 'TI', datasheet_ref: 'https://example.com/lm2596.pdf', datasheet_version: 'Rev-J' }, + /* candidate 的类型已经归好(chip_type_id=1)—— 与原厂同类型,才走得到 gate_reason 最深那层判据 */ + { internal_pn: 'XL2596S-5.0', chip_type_id: 1, brand_name: 'XLSEMI', lifecycle_status: 'active', specification_description: '同步降压', manufacturer_name: 'XLSEMI', datasheet_ref: 'https://example.com/xl2596.pdf', datasheet_version: 'Rev-1' }, + ], + /* candidate 一条参数都没有 —— ③ 铺出骨架空行之前,mine:parts.parameter 里压根没有它的行 */ + 'parts.parameter': [{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5V~40V', param_unit: 'V', param_note: '' }], + }; +} + +/* + * **六笔,逐字镜像 `S1_inputs.auto` 当前的地址 / 字段 / when 链**(2026-08-29 修复后的次序: + * 两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代)。 + */ +const ACT_PART = { + kind: 'project', + into: 'sub_eval.part', + from: { + rows: [ + { pn: { in: 'originalInternalPn' }, role: 'original' }, + { pn: { in: 'candidateInternalPn' }, role: 'candidate' }, + ], + }, + fields: { + eval_ref: { in: 'evalRef' }, + internal_pn: { col: 'pn' }, + role: { col: 'role' }, + found_pn_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'internal_pn', else: '' }, + exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' }, + chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' }, + }, +} as const; + +const ACT_PROFILE = { + kind: 'project', + into: 'parts.profile', + prune: true, + from: { each: 'mine:sub_eval.part' }, + fields: { + internal_pn: { col: 'internal_pn' }, + chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'chip_type_id', else: '' }, + brand_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'brand_name', else: '' }, + lifecycle_status: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'lifecycle_status', else: '' }, + specification_description: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'specification_description', else: '' }, + manufacturer_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'manufacturer_name', else: '' }, + datasheet_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_ref', else: '' }, + datasheet_version: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_version', else: '' }, + }, +} as const; + +const ACT_PARAMETER = { + kind: 'project', + into: 'parts.parameter', + prune: true, + from: { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } }, + fields: { + internal_pn: { outer: 'internal_pn' }, + param_name: { col: 'param_name' }, + param_unit: { col: 'param_unit' }, + param_value: { lookup: 'parts.parameter', by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' }, + param_note: { lookup: 'parts.parameter', by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' }, + }, +} as const; + +const ACT_CASE = { + kind: 'project', + into: 'sub_eval.case', + from: { once: true }, + fields: { + eval_ref: { in: 'evalRef' }, + chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' }, + gate_reason: { + when: 'sub_eval_part_rows < 2', + then: '原料号与候选料号还没填全 —— 在下面把两格补上。', + else: { + when: 'sub_eval_part_exists_status_absent > 0', + then: '有料号库里查不到 —— 在下面把它的主数据建起来。', + else: { + when: 'sub_eval_part_missing > 0', + then: '有料号还没归芯片类型 —— 在下面给它选一个;没有合适的就新建一个,并把它的核心参数字段一并定下来。', + else: { + when: 'sub_eval_part_distinct_chip_type_id > 1', + then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。', + else: { + when: 'parts_profile_missing + parts_parameter_missing > 0', + then: '还有必填格空着 —— 红带点名的那几格填上就能提交。', + else: '', + }, + }, + }, + }, + }, + }, +} as const; + +const ACT_CARD = { + kind: 'card', + when: 'sub_eval_part_exists_status_found < 2 or sub_eval_part_missing > 0 or sub_eval_part_distinct_chip_type_id > 1 or parts_profile_missing > 0 or parts_parameter_missing > 0', + as: 'execution', + variant: 'data', + title: '数据工单 · 差异评估的输入还差几格', + basis: [ + { k: '原料号', v: { in: 'originalInternalPn' } }, + { k: '候选料号', v: { in: 'candidateInternalPn' } }, + { k: '还差什么', v: { in: 'gate_reason' } }, + ], +} as const; + +const ACT_SAY = { kind: 'say', text: '已核对两颗料的在库状态与参数齐备情况。' } as const; + +/** 修复后的正确次序(与 `case-model.ts` 当前的 `S1_inputs.auto.do` 一致) */ +const FIXED_ORDER: AutoRecipe = { + let: { evalRef: 'W05-20260829000000' }, + do: [ACT_PART, ACT_PROFILE, ACT_PARAMETER, ACT_CASE, ACT_CARD, ACT_SAY], +}; + +describe('S1 配方次序:gate_reason 必须读到这一拍刷新之后的缺口读数', () => { + it('新次序(part→profile→parameter→case→card→say):gate_reason 读到真实缺口,不是空串', async () => { + const { runner, drafts, cards, ctx } = harness(seedScenario()); + const out = await runner.run(FIXED_ORDER, ctx); + + // 卡确实发了出去(与旧次序下的行为一致 —— 收步与发卡的把关本身没坏) + expect(out.issuedCardId).toBe('card-1'); + expect(cards).toHaveLength(1); + + // 案卷里存的 gate_reason:必须非空、且点到真实缺口(不是「一切正常」的假象) + const caseRow = [...drafts.values()].find((d) => d.object === 'case'); + expect(caseRow?.patch['gate_reason']).toBe('还有必填格空着 —— 红带点名的那几格填上就能提交。'); + + // 卡面 basis 里「还差什么」那一格 —— 评审报告里点名的那个空字符串,这里必须不是空的 + const card = cards[0]; + const basis = (card?.['card'] as { basis?: Array<{ k: string; v: string }> } | undefined)?.basis ?? []; + const gapLine = basis.find((b) => b.k === '还差什么'); + expect(gapLine?.v).toBe('还有必填格空着 —— 红带点名的那几格填上就能提交。'); + expect(gapLine?.v).not.toBe(''); + }); +});