Archived
fix(w05): S1 配方次序 —— 案卷投影排到主数据/参数骨架之后
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -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<Record<string, unknown>>, 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<Record<string, readonly unknown[]>>, declared: readonly Declared[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
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<Record<string, readonly unknown[]>>, declared: readonly Declared[]): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
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<string, unknown>, 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<string, unknown>;
|
||||
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<string, string> = {};
|
||||
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<Record<string, readonly unknown[]>>, declared: readonly Declared[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
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<string, unknown>;
|
||||
return String(one[field.name] ?? '').trim() === value;
|
||||
}).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 抄自 `engines.ts` 的 `distinctReadings`(逐字一致) */
|
||||
function distinctReadings(mine: Readonly<Record<string, readonly unknown[]>>, declared: readonly Declared[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
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<string>();
|
||||
for (const row of rows) {
|
||||
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
|
||||
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<string, unknown>) {
|
||||
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||
const cards: Array<Record<string, unknown>> = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
const view = (): Record<string, unknown> => {
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
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<string, unknown> = {};
|
||||
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<string, string>; patch: Record<string, unknown>; 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<string, string> }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
return Promise.resolve({ dropped: drafts.delete(id) });
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
issue: (i: Record<string, unknown>) => {
|
||||
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<Record<string, unknown>>) => {
|
||||
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<string, unknown> {
|
||||
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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user