Archived
feat(w05): S2 关系表拆两笔写、S1 自投影类型字典、e2e 跨案例隔离 —— 真跑逼出来的三处
① S2:substitution.relation 拆成两笔同主键覆盖写,中间夹 difference_item —— difference_item.substitution_relation_id 声明了 refersTo(要求父行已存在), 而 relation 的等级/风险/摘要要读 difference_item 的 tally,两张表互相要对方先写。 ② S1:总把选中的那一行 chip_type / type_param 原样投一遍(幂等), 于是这一单的草稿层永不为空 —— parts.chip_type 得以放回 gateLedgers, 新建的类型随「数据工单确认即入库」一起落进正式表,不再留悬空引用。 (此前把它删出名单只顾了闸、没顾落地:引用它的表落了地而它自己没落。) ③ e2e:案例 0 之后做跨案例隔离(withDb + isolationCleanup,不论成败都跑、 失败只告警不拖垮整套)—— LAND_MASTER=1 之下案例 0 为满足 distinct=1 必须把 共享的原 PN 转挂新类型,那一改会落进主数据,案例 1 共用同一颗料就看到假的类型不一致。 断言 expectRevised 改名 expectFinalValue:「点名驳回归属」那条对 W05 不适用 (四步 guide 全空、唯一的 agent 调用发生在意见之前,没有轮次会读到那条观察)。 包版本 2.1.5。pnpm gates 退出码 0。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -146,13 +146,41 @@ describe('S1 配方', () => {
|
||||
* 变成空字符串)见 `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']);
|
||||
it('八笔,次序是:两颗料 → 类型字典自投 → 字段清单自投 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => {
|
||||
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'project', 'project', 'card', 'say']);
|
||||
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
|
||||
'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
|
||||
'sub_eval.part', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter', 'sub_eval.case',
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-30,Ruling H35:`parts.chip_type`/`parts.type_param` 各自把「选中类型的那一行」
|
||||
* 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行,
|
||||
* 好让它们能安全回到下面 ⑦ 卡的 `gateLedgers` 上(闸不会再拿「零行」误判)。
|
||||
*
|
||||
* **不带 `prune`**(2026-08-30 第二处修正,e2e 案例 2 撞出来的):这两张表在 ⑦ 的
|
||||
* `gateLedgers` 上,一旦人交回数据工单就随「数据工单确认即入库」落进正式表——已落地的行
|
||||
* `draftDrop`(`prune` 的撤行机制)撤不掉,配方原样抛错。字典类的表允许历史选型留在
|
||||
* 草稿层(不算错),不像 `parts.profile`/`parts.parameter` 那几笔(它们仍然 `prune:true`,
|
||||
* 见下面「主数据骨架」那笔)必须清掉不再相关的陈旧行。
|
||||
*/
|
||||
it('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(不带 prune)', () => {
|
||||
const chipType = acts[1];
|
||||
expect(chipType?.kind === 'project' && chipType.into).toBe('parts.chip_type');
|
||||
expect(chipType?.kind === 'project' && chipType.prune).toBeUndefined();
|
||||
expect(chipType?.kind === 'project' && chipType.from).toMatchObject({
|
||||
each: 'mine:sub_eval.part',
|
||||
then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } },
|
||||
});
|
||||
const typeParam = acts[2];
|
||||
expect(typeParam?.kind === 'project' && typeParam.into).toBe('parts.type_param');
|
||||
expect(typeParam?.kind === 'project' && typeParam.prune).toBeUndefined();
|
||||
expect(typeParam?.kind === 'project' && typeParam.from).toMatchObject({
|
||||
each: 'mine:sub_eval.part',
|
||||
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
|
||||
});
|
||||
});
|
||||
|
||||
it('案号由 let 铸,只从本单开案时刻取材', () => {
|
||||
expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] });
|
||||
});
|
||||
@@ -167,7 +195,7 @@ describe('S1 配方', () => {
|
||||
});
|
||||
|
||||
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
|
||||
const params = acts[2];
|
||||
const params = acts[4];
|
||||
expect(params?.kind === 'project' && params.prune).toBe(true);
|
||||
const from = params?.kind === 'project' ? params.from : undefined;
|
||||
/* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
|
||||
@@ -177,7 +205,7 @@ describe('S1 配方', () => {
|
||||
});
|
||||
|
||||
it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
|
||||
const card = acts[4];
|
||||
const card = acts[6];
|
||||
const when = card?.kind === 'card' ? (card.when ?? '') : '';
|
||||
/*
|
||||
* S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
|
||||
@@ -189,26 +217,25 @@ describe('S1 配方', () => {
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-29 评审 Critical(Task 7 走查):`gateLedgers` 一度带着 `parts.chip_type`,
|
||||
* 而 `input-supplement` 这张件没有任何 `data-edit="parts.chip_type"`(选类型走
|
||||
* `data-options` 下拉现读,不写这张表)—— `mine:parts.chip_type` 永远是空数组,
|
||||
* `desk.ts` 的 `emptyGate` 会永远判「这一单还一行都没有」,提交钮永久按不下去。
|
||||
* **`parts.chip_type` 永久不列进这张卡的闸**(不是等新建类型的编辑表补上就该还原 ——
|
||||
* 见 `case-model.ts` 该处注释:主路是从下拉挑一个已有类型,那时这张表天然零行,
|
||||
* `emptyGate`「这张卡就是来建它的」这条语义对一张只是有时候才写的表根本不成立)。
|
||||
* 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 不在——它没有编辑手段,列了就永久卡死)、且有 basis(基座拒发 basis 为空的工单)', () => {
|
||||
const card = acts[4];
|
||||
it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => {
|
||||
const card = acts[6];
|
||||
expect(card?.kind === 'card' && card.as).toBe('execution');
|
||||
expect(card?.kind === 'card' && card.variant).toBe('data');
|
||||
expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.type_param', 'parts.profile', 'parts.parameter']);
|
||||
expect((card?.kind === 'card' ? card.gateLedgers ?? [] : [])).not.toContain('parts.chip_type');
|
||||
expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']);
|
||||
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
|
||||
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('发卡条件与 doneWhen 是同一件事的正反面', () => {
|
||||
const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : '';
|
||||
const when = acts[6]?.kind === 'card' ? (acts[6].when ?? '') : '';
|
||||
for (const reading of [
|
||||
'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
|
||||
'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing',
|
||||
@@ -223,13 +250,17 @@ describe('S2 配方 —— AI 只出场一次', () => {
|
||||
const recipe = stepOf('S2_assess')?.auto;
|
||||
const acts = recipe?.do ?? [];
|
||||
|
||||
it('五笔:分析 → 明细 → 关系 → 验证记录 → 交代;analyze 恰好一笔', () => {
|
||||
expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'say']);
|
||||
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[1];
|
||||
const items = acts[2];
|
||||
expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' });
|
||||
/*
|
||||
* **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)——
|
||||
@@ -247,7 +278,7 @@ describe('S2 配方 —— AI 只出场一次', () => {
|
||||
});
|
||||
|
||||
it('**值不取 AI 出参,一律 lookup 台账**', () => {
|
||||
const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined;
|
||||
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' });
|
||||
}
|
||||
@@ -256,7 +287,7 @@ describe('S2 配方 —— AI 只出场一次', () => {
|
||||
});
|
||||
|
||||
it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => {
|
||||
const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined;
|
||||
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');
|
||||
@@ -265,7 +296,8 @@ describe('S2 配方 —— AI 只出场一次', () => {
|
||||
});
|
||||
|
||||
it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => {
|
||||
const rel = acts[2]?.kind === 'project' ? acts[2].fields : undefined;
|
||||
// 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');
|
||||
@@ -274,7 +306,7 @@ describe('S2 配方 —— AI 只出场一次', () => {
|
||||
});
|
||||
|
||||
it('P-06 一单固定一条,状态 not_started —— 本活动只写「还得去验什么」,不虚构实测结果', () => {
|
||||
const val = acts[3];
|
||||
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;
|
||||
@@ -320,8 +352,10 @@ describe('S3 / S4 配方', () => {
|
||||
});
|
||||
|
||||
it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => {
|
||||
expect(s3[0]).toBe(s2[2]);
|
||||
expect(s3[1]).toBe(s2[3]);
|
||||
// S2 的 do 数组里 RELATION_PROJECTION 出现两次(acts[1]/acts[3],同一个引用)——
|
||||
// 与 S3 比对随便挑一个都行,这里挑 acts[3](紧跟在差异明细之后、值是真的那一次)
|
||||
expect(s3[0]).toBe(s2[3]);
|
||||
expect(s3[1]).toBe(s2[4]);
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user