import { describe, expect, it } from 'vitest'; import type { AutoExpr, AutoRecipe } from '@contracts'; import { taskIdSchema, staffIdSchema } from '@contracts'; import { evaluateCondition } from '@sfeel'; import { deriveSide, structuredLog } from '@modules/memory'; import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory'; import { createAutoRunner } from '../internal/usecase/auto-runner.ts'; const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111'); const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222'); type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; instruction: string }) => Promise<{ object: Record }> }; /** 合成的信封字段 —— 与 `task-memory.test.ts` 的 `entry()` 工厂同一套写法 */ const STAMP: VersionStamp = { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }; const GOVERNANCE: GovernanceMark = { masked: false, learnable: true }; /** * **把配方运行时真实留下的一笔包成完整 `MemoryEntry`,喂给 `structuredLog` 验收** * (2026-08-29,R10)。 * * 上一轮(Task 12)只测过 `actorOf` 这个纯函数;评审指出那只证明了「归属判定本身没错」, * 没证明「配方这一笔真的能走到工作日志上」—— `kind` 不在 `SKELETON` 里,或 `detail.internal` * 为真,都会在 `structuredLog` 更早的一关就把它滤掉,`actorOf` 判得再对也没用。 * 这里直接拿 `harness()` 从**真实生产代码**(`auto-runner.ts`)捕获到的 `{kind, title, detail}` * 去跑一遍 `structuredLog` 全程 —— 不是照 spec 手写一个理想形状。 */ function asLogEntry(captured: { kind: string; title: string; detail: Record | undefined }): MemoryEntry { const kind = captured.kind as EntryKind; return { id: 'e-1', taskId: TASK, seq: 1, at: '2026-08-29T00:00:00.000Z', kind, side: deriveSide(kind), title: captured.title, stamp: STAMP, governance: GOVERNANCE, ...(captured.detail === undefined ? {} : { detail: captured.detail }), }; } /** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */ function harness(seed: Record = {}, override: { analyze?: AnalyzeStub } = {}) { const drafts = new Map< string, { domain: string; object: string; key: Record; patch: Record; landed: boolean } >(); /** 「只建不改」的表里已经躺在**正式表**的键(`域.对象|键值…`)—— 生产由 `createOnlyViolation` 判 */ const formal = new Set(); const notes: string[] = []; /** 配方留下的每一笔完整留痕(kind/title/detail)—— 断言「进没进日志、归谁」走这里 */ const appended: Array<{ kind: string; title: string; detail: Record | undefined }> = []; const cards = { issued: [] as Array> }; const view = (): Record => { const mine: Record = {}; for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch }); return { ...seed, ...mine }; }; const deps = { data: { view: () => Promise.resolve(view()), writable: () => Promise.resolve([ { domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], caseKey: 'eval_ref', fields: [] }, { domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] }, ]), /** * **合并语义,不是覆盖**(2026-08-29 终审 M4):生产的 upsert 是 * `patch = 草稿.patch || excluded.patch`(`ledger-store.ts:369`)—— * 从前这个替身写成 `drafts.set(id, { patch })` 是**覆盖**, * 「重投不会擦掉人填的格子」那条结论在覆盖语义下根本不成立,替身证明不了它。 * * 同批补上生产真有、而替身从前没有的两件事: * · 任何一次真落笔都把 `landed` 打回 false —— 生产的 upsert 尾巴上写着 `landed_at = null`; * · 「只建不改」的表碰到已在正式表里的键:默认拒(抛),`skipExisting` 时跳过。 */ 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('|')}`; if (formal.has(id)) { if (i.skipExisting === true) return Promise.resolve({ changed: false, skipped: true }); return Promise.reject(new Error(`${i.domain}.${i.object} 里已经有这一行 —— 这张表只许新建库里没有的记录`)); } 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 }, landed: false, }); return Promise.resolve({ changed }); }, draftDrop: (i: { domain: string; object: string; key: Record; skipLanded?: boolean }) => { const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; /* 生产真有的落地守卫(engines.ts):已落地的行,配方带 skipLanded 就跳过,别的路硬拒 */ const row = drafts.get(id); if (row?.landed === true) { if (i.skipLanded === true) return Promise.resolve({ dropped: false, landed: true }); return Promise.reject(new Error('这一行是本单在数据工单里建好、人确认后已入库的,撤不掉')); } return Promise.resolve({ dropped: drafts.delete(id) }); }, }, cards: { issue: (i: Record) => { cards.issued.push({ ...i, kind: 'issue' }); return Promise.resolve({ id: 'card-1' }); }, decide: (i: Record) => { cards.issued.push({ ...i, kind: 'decide' }); return Promise.resolve({ id: 'card-2' }); }, entry: () => Promise.resolve({ id: 'card-3' }), ask: () => Promise.resolve({ id: 'card-4' }), unanswered: () => Promise.resolve([]), }, memory: { append: (m: { kind: string; title: string; detail?: Record }) => { notes.push(m.title); appended.push({ kind: m.kind, title: m.title, detail: m.detail }); return Promise.resolve(); }, }, analyze: override.analyze ?? { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, sfeel: { condition: (expr: string, scope: Readonly>) => { if (expr === 'never') return false; if (expr === 'always') return true; if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== ''; throw new Error(`没教过这句:${expr}`); }, expression: () => undefined, unaryTest: () => false, }, }; const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false }; // 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法 return { runner: createAutoRunner(deps as never), drafts, formal, notes, appended, cards, ctx }; } describe('配方执行器', () => { it('project once:写一行,主键与其余列分开', async () => { const { runner, drafts, ctx } = harness({ pn: 'LM2596S-5.0' }); const recipe: AutoRecipe = { let: { evalRef: 'W05-1' }, do: [ { kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: { in: 'evalRef' }, internal_pn: { in: 'pn' }, role: 'original' }, }, ], }; const out = await runner.run(recipe, ctx); expect(out.wrote).toBe(1); expect([...drafts.values()][0]).toEqual({ domain: 'sub_eval', object: 'part', key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' }, patch: { role: 'original' }, landed: false, }); }); it('主键算不出来的那一行**先不写** —— 空主键写下去就是一行永远对不上的垃圾', async () => { const { runner, drafts, ctx } = harness({}); const out = await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: { in: 'nope' }, role: 'x' } }] }, ctx, ); expect(out.wrote).toBe(0); expect(drafts.size).toBe(0); }); /* * **少写了几行要出声**(2026-08-29 终审 M1):从前主键算不出来是静默 `continue` —— * 既不计数也不留痕。表里少了行,人和判据都查不出为什么。 */ it('主键算不出来:留一句痕点名少了几行、主键列是哪几个', async () => { const { runner, notes, ctx } = harness({}); await runner.run( { do: [ { kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: '' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } }, }, ], }, ctx, ); const line = notes.find((n) => n.includes('主键算不出来')); expect(line).toBeDefined(); expect(line).toContain('1 行因主键算不出来没写'); expect(line).toContain('eval_ref、internal_pn'); }); it('when 为假就跳过这一笔', async () => { const { runner, drafts, ctx } = harness({}); await runner.run({ do: [{ kind: 'project', when: 'never', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }] }, ctx); expect(drafts.size).toBe(0); }); it('**每一笔之前刷读数**:第二笔 each 得看得见第一笔刚写下的行', async () => { const { runner, drafts, ctx } = harness({}); await runner.run( { do: [ { kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }, { kind: 'project', into: 'parts.parameter', from: { each: 'mine:sub_eval.part' }, fields: { internal_pn: { col: 'internal_pn' }, param_name: '输入电压范围', param_value: '' } }, ], }, ctx, ); const params = [...drafts.values()].filter((d) => d.object === 'parameter'); expect(params.map((p) => p.key['internal_pn']).sort()).toEqual(['A', 'B']); }); it('值没变的写是空转:**不留痕、不计数**(配方不是模型,原地打转不必被看见)', async () => { const { runner, ctx } = harness({}); const recipe: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] }; await runner.run(recipe, ctx); const second = await runner.run(recipe, ctx); expect(second.wrote).toBe(0); }); it('prune:这一笔没投影出来的行,从草稿层撤掉,**并点名撤了哪几行**(M2)', async () => { const { runner, drafts, notes, ctx } = harness({}); await runner.run({ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx); expect(drafts.size).toBe(2); const out = await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx, ); expect(out.dropped).toBe(1); expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']); /* 点名,且**去掉单号那一段** —— 人正在这一单里看,`W05-1` 是脚下的地面(2026-08-25 那条口径) */ const line = notes.find((n) => n.includes('撤掉 1 行')); expect(line).toContain('撤掉 1 行(B)'); expect(line).not.toContain('W05-1'); }); it('prune:已落地的陈旧行跳过不炸,没落地的照撤(2026-08-31 skipLanded)', async () => { const { runner, drafts, notes, ctx } = harness({}); /* 铺 A、B、C 三行;B 随「数据工单确认即入库」落了地(landed),B、C 随后都不在投影源里了 */ await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }, { pn: 'C' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx, ); for (const d of drafts.values()) if (d.key['internal_pn'] === 'B') d.landed = true; const out = await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx, ); /* 不炸(从前这里整个配方抛错停摆);C(没落地)撤掉,B(已落地)原地留下 */ expect(out.failedAt).toBeUndefined(); expect(out.dropped).toBe(1); expect([...drafts.values()].map((d) => d.key['internal_pn']).sort()).toEqual(['A', 'B']); const line = notes.find((n) => n.includes('撤掉 1 行')); expect(line).toContain('撤掉 1 行(C)'); /* 跳过也要出声:B 已入库、这一笔不清它 —— 实情说给人听(debt 那条的原话要求) */ expect(line).toContain('1 行已随数据工单入库、这一笔不清它'); }); it('say:进工作日志,归系统', async () => { const { runner, notes, ctx } = harness({ n: 2 }); await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx); expect(notes).toContain('已核对 2 颗料'); }); it('地址不在台账声明里 → 当场交人,不重试', async () => { const { runner, ctx } = harness({}); const out = await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx); expect(out.failedAt?.index).toBe(0); expect(out.failedAt?.message).toContain('nope.nope'); }); }); describe('analyze 动词', () => { it('入参由声明组装,出参落进 result: 供后一笔 each 消费', async () => { let seen: readonly unknown[] = []; const { runner, drafts, ctx } = harness( { 'parts.type_param': [ { type_id: 1, param_name: '输入电压范围', constraint_type: 'hard' }, { type_id: 1, param_name: '输出电流', constraint_type: 'soft' }, ], typeId: 1, }, { analyze: { run: (i) => { seen = i.payload; return Promise.resolve({ object: { items: [ { param_name: '输入电压范围', difference_status: 'match' }, { param_name: '输出电流', difference_status: 'unknown' }, ], }, }); }, }, }, ); await runner.run( { do: [ { kind: 'analyze', model: 'deepseek-v4-flash', into: 'items', instruction: '逐条判四态', input: { from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } }, fields: { param_name: { col: 'param_name' }, constraint_type: { col: 'constraint_type' } }, }, output: { type: 'object' }, }, { kind: 'project', into: 'parts.parameter', from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } }, fields: { internal_pn: 'XL2596S-5.0', param_name: { col: 'param_name' }, /* **行由字典决定,AI 出参只是查表** —— 它漏一条,那一格就是空 */ param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' }, }, }, ], }, ctx, ); expect(seen).toEqual([ { param_name: '输入电压范围', constraint_type: 'hard' }, { param_name: '输出电流', constraint_type: 'soft' }, ]); const values = [...drafts.values()].filter((d) => d.object === 'parameter').map((d) => d.patch['param_value']); expect(values.sort()).toEqual(['match', 'unknown']); }); it('AI 漏了一条:那一行的格子是空的 —— 行数不由它决定', async () => { const { runner, drafts, ctx } = harness( { 'parts.type_param': [{ type_id: 1, param_name: 'A' }, { type_id: 1, param_name: 'B' }], typeId: 1 }, { analyze: { run: () => Promise.resolve({ object: { items: [{ param_name: 'A', difference_status: 'match' }] } }) } }, ); await runner.run( { do: [ { kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { param_name: { col: 'param_name' } } }, output: { type: 'object' } }, { kind: 'project', into: 'parts.parameter', from: { each: 'parts.type_param' }, fields: { internal_pn: 'P', param_name: { col: 'param_name' }, param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' } } }, ], }, ctx, ); const rows = [...drafts.values()].filter((d) => d.object === 'parameter'); expect(rows).toHaveLength(2); expect(rows.find((r) => r.key['param_name'] === 'B')?.patch['param_value']).toBe(''); }); it('有卡在等人交回时不调 AI —— 只算、只写', async () => { let called = 0; const { runner, ctx } = harness( { 'parts.type_param': [{ type_id: 1, param_name: 'A' }] }, { analyze: { run: () => { called += 1; return Promise.resolve({ object: { items: [] } }); } } }, ); await runner.run( { do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { p: { col: 'param_name' } } }, output: { type: 'object' } }] }, { ...ctx, quiet: true }, ); expect(called).toBe(0); }); }); describe('card 与 submit', () => { it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => { const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' }); const out = await runner.run( { do: [ { kind: 'card', as: 'execution', variant: 'data', title: { cat: ['数据工单 · 补齐 ', { in: 'pn' }] }, guide: ['选一个芯片类型', '把参数值填齐'], standard: '必填格全部填上,红带消失', basis: [{ k: '候选料号', v: { in: 'pn' } }], gateLedgers: ['parts.profile', 'parts.parameter'], }, ], }, ctx, ); expect(out.issuedCardId).toBe('card-1'); expect(cards.issued[0]).toMatchObject({ workOrder: 'execution', itemId: 'S1', card: { mode: 'execution', title: '数据工单 · 补齐 XL2596S-5.0', variant: 'data', steps: ['选一个芯片类型', '把参数值填齐'], standard: '必填格全部填上,红带消失', basis: [{ k: '候选料号', v: 'XL2596S-5.0' }], gateLedgers: ['parts.profile', 'parts.parameter'], fields: [], }, }); }); it('这一步已经发过卡:**不再发第二张**,如实留一条观察', async () => { const { runner, notes, ctx } = harness({}); const out = await runner.run( { do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] }, { ...ctx, carded: true }, ); expect(out.issuedCardId).toBeUndefined(); expect(notes.some((n) => n.includes('已经发过'))).toBe(true); }); it('decide + review:摆成验收工单,单一选项也发得出去', async () => { const { runner, ctx } = harness({}); const out = await runner.run( { do: [ { kind: 'card', as: 'decide', variant: 'review', title: '差异评估结论审核', ask: '逐条核对过了吗?', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: '候选', v: 'XL2596S-5.0' }], }, ], }, ctx, ); expect(out.issuedCardId).toBe('card-2'); }); it('发了卡就**收拍**:排在后面的笔一律不跑', async () => { const { runner, drafts, ctx } = harness({}); await runner.run( { do: [ { kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }, { kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }, ], }, ctx, ); expect(drafts.size).toBe(0); }); it('submit:回给 turn-runner,理由现算;有卡在等人时不 submit', async () => { const { runner, ctx } = harness({ n: 3 }); const out = await runner.run({ do: [{ kind: 'submit', rationale: { cat: ['已判定 ', { in: 'n' }, ' 条参数'] } }] }, ctx); expect(out.submit).toEqual({ rationale: '已判定 3 条参数' }); const quiet = await runner.run({ do: [{ kind: 'submit', rationale: 'x' }] }, { ...ctx, quiet: true }); expect(quiet.submit).toBeUndefined(); }); }); /** * **配方留痕真的到得了工作日志**(2026-08-29,R10,spec §5.9 验收)。 * * 四类留痕(say / project / analyze / 出错兜底)各跑一遍真实的 `auto-runner.ts`, * 把捕获到的 `{kind, title, detail}` 喂给 `structuredLog` —— 断言它**出现在结果里** * (没被 `SKELETON` / `machinery()` 滤掉)且 `who` 是 `system`。 */ describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', () => { it('say 那一笔:进日志且归系统,**不标 internal**(它是人话,该进对话区)', async () => { const { runner, appended, ctx } = harness({ n: 2 }); await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx); expect(appended).toHaveLength(1); const lines = structuredLog([asLogEntry(appended[0]!)]); expect(lines).toHaveLength(1); expect(lines[0]?.who).toBe('system'); expect(appended[0]?.detail?.['internal']).toBeUndefined(); }); /** * **人正在件上填的那几拍(quiet),say 不上对话区**(2026-08-31 用户:「每次保存的时候 * 右边也会出现 ai 推进中,然后会说一句话,能不能把这个也取消掉」)。 * * 件上按保存会 `wake` 这一单(「保存即铺格」),配方跟着跑一趟 quiet —— 而 quiet 只跑 * project 与 say,于是人按几次保存,同一句交代就往对话区说几遍。对话区的纪律是 * **一轮一句,不是一拍一句**(2026-08-01),而 quiet 拍压根没有交出控制权(人正对着 * 那张卡填),算不上新一轮。 * * 压下去不是丢掉:`internal` 是两个投影共用的一个旗(终审 I5/F2)。这一条钉住的是 * **前一半**(quiet → 标 internal,而工作日志按 `detail.auto` 照旧放行); * **后一半**(标了 internal 就进不了对话区)钉在 * `apps/api/src/modules/interaction/__tests__/chat-projection.test.ts` * 「引擎内部观察(internal 标)进日志不进对话」那一条 —— `projectChat` 没出模块边界, * 跨模块深入 internal 会被 depcruise 拦,两半各钉在自己模块里。 */ it('**quiet 拍的 say:标 internal(对话区据此挡住),而工作日志照旧看得见**', async () => { const { runner, appended, ctx } = harness({ n: 2 }); await runner.run( { do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, { ...ctx, quiet: true, carded: true }, ); expect(appended).toHaveLength(1); expect(appended[0]?.detail?.['internal']).toBe(true); /* 话本身没丢:`detail.auto` 那条例外让它留在工作日志里(值有插值时也查得到) */ expect(appended[0]?.title).toBe('已核对 2 颗料'); expect(typeof appended[0]?.detail?.['auto']).toBe('string'); const lines = structuredLog([asLogEntry(appended[0]!)]); expect(lines).toHaveLength(1); expect(lines[0]?.who).toBe('system'); }); it('project 那一笔:进日志且归系统,**而且标着 internal(对话区那一侧据此挡住)**', async () => { const { runner, appended, ctx } = harness({}); await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] }, ctx, ); expect(appended).toHaveLength(1); const lines = structuredLog([asLogEntry(appended[0]!)]); expect(lines).toHaveLength(1); expect(lines[0]?.who).toBe('system'); /* * **两件事各有各的开关**(2026-08-29 终审 I5):`internal` 同时是对话区的开关 —— * 去掉它,这类机械痕会一笔一个气泡跑进对话区(见 `chat-projection.test.ts` 对应那条)。 * 进不进工作日志由 `machinery()` 对 `detail.auto` 开的例外说了算,上面那一行已经验了。 */ expect(appended[0]?.detail?.['internal']).toBe(true); expect(appended[0]?.detail?.['auto']).toBe('S1#1'); }); it('analyze 那一笔:进日志且归系统', async () => { const { runner, appended, ctx } = harness( { 'parts.type_param': [{ type_id: 1, param_name: 'A' }] }, { analyze: { run: () => Promise.resolve({ object: { items: [{ param_name: 'A', difference_status: 'match' }] } }) } }, ); await runner.run( { do: [ { kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { param_name: { col: 'param_name' } } }, output: { type: 'object' }, }, ], }, ctx, ); expect(appended).toHaveLength(1); const lines = structuredLog([asLogEntry(appended[0]!)]); expect(lines).toHaveLength(1); expect(lines[0]?.who).toBe('system'); expect(appended[0]?.detail?.['internal']).toBe(true); }); it('出错兜底那一笔:进日志且归系统,**不标 internal**(交人的话得让人看见)', async () => { const { runner, appended, ctx } = harness({}); await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx); expect(appended).toHaveLength(1); const lines = structuredLog([asLogEntry(appended[0]!)]); expect(lines).toHaveLength(1); expect(lines[0]?.who).toBe('system'); expect(appended[0]?.detail?.['internal']).toBeUndefined(); }); }); /** * **幂等在「反复进入」下也得成立**(2026-08-29 终审 I2)。 * * 配方每一拍都把同一张表重投一遍。而草稿层的 upsert 尾巴上有 `landed_at = null` * (「再写一笔就不再是已落地」)—— 数据工单确认入库之后再重投,那些行的「已落地」记号 * 就被悄悄擦掉,整单放行时会被再写一遍正式表、再抛一遍 data 事件, * 而 CLAUDE.md 对 landed 行的承诺是「整单放行不再写它、不抛事件」。 */ describe('幂等 · 重投不许把已落地的行 un-land(I2)', () => { const RECIPE: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'original' } }], }; it('值没变就压根不落笔 —— 已落地的记号原样还在', async () => { const { runner, drafts, ctx } = harness({}); await runner.run(RECIPE, ctx); // 数据工单确认入库:这一行打上「已落地」记号(生产是 `__draft.landed_at`) const row = [...drafts.values()][0]!; row.landed = true; const again = await runner.run(RECIPE, ctx); expect(again.wrote).toBe(0); expect([...drafts.values()][0]?.landed).toBe(true); }); it('值真变了才落笔,那时 un-land 是对的 —— 新的一笔要等它自己的门', async () => { const { runner, drafts, ctx } = harness({}); await runner.run(RECIPE, ctx); [...drafts.values()][0]!.landed = true; await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'candidate' } }] }, ctx, ); expect([...drafts.values()][0]?.landed).toBe(false); expect([...drafts.values()][0]?.patch['role']).toBe('candidate'); }); /* * **合并语义下才有意义的一条**(M4):人在件上填的格子不在配方的 `fields` 里, * 配方重投一遍不该把它擦掉。替身从前是覆盖语义,这条断言在那时是假绿。 */ it('人在件上填的那一格,配方重投不擦', async () => { const { runner, drafts, ctx } = harness({}); await runner.run(RECIPE, ctx); const row = [...drafts.values()][0]!; row.patch['brand'] = '人填的'; await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'candidate' } }] }, ctx, ); expect([...drafts.values()][0]?.patch).toEqual({ brand: '人填的', role: 'candidate' }); }); }); /** * **「只建不改」的表碰到已在库的键:对模型是拒回,对配方是常态**(2026-08-29 终审 I2)。 * * 那句拒回写的是「别再写它,用 drop 把骨架撤掉」—— 说给模型听的。 * 配方没有「换个做法」这回事:抛出去就是一单永久停在那儿,而人看见的是一句他办不了的话。 */ describe('createOnly + 配方:已在库的当「不用建」跳过,不抛(I2)', () => { const RECIPE: AutoRecipe = { do: [ { kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'IN_LIB' }, { pn: 'NEW' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' }, role: 'candidate' }, }, ], }; it('已在库的那一颗跳过、别的照写 —— 不交人', async () => { const { runner, drafts, formal, notes, ctx } = harness({}); formal.add('sub_eval.part|W05-1|IN_LIB'); const out = await runner.run(RECIPE, ctx); expect(out.failedAt).toBeUndefined(); expect(out.wrote).toBe(1); expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['NEW']); expect(notes.some((n) => n.includes('已在库、这一笔不用建'))).toBe(true); }); it('已在库的那一颗**不会被 prune 撤掉** —— 它是这一笔要的行,只是不用建', async () => { const { runner, drafts, formal, ctx } = harness({}); // 先铺两行草稿(此刻库里还没有 IN_LIB) await runner.run(RECIPE, ctx); expect(drafts.size).toBe(2); // 数据工单确认入库:IN_LIB 进了正式表(草稿层那一行仍在,打了 landed 记号) formal.add('sub_eval.part|W05-1|IN_LIB'); for (const d of drafts.values()) if (d.key['internal_pn'] === 'IN_LIB') d.landed = true; const out = await runner.run({ do: [{ ...RECIPE.do[0]!, prune: true } as never] }, ctx); expect(out.dropped).toBe(0); expect([...drafts.values()].map((d) => d.key['internal_pn']).sort()).toEqual(['IN_LIB', 'NEW']); expect([...drafts.values()].find((d) => d.key['internal_pn'] === 'IN_LIB')?.landed).toBe(true); }); }); /** * **`detail.changed` 只在真写了/真撤了时为真**(2026-08-29 复审 M1/M2 二审)。 * * M1/M2 让「一行没写」也出声(`localExisting`/`localUnkeyed`),而**台账其实一个字没动**—— * 重做闸(`turn-runner.raiseAcceptance` 的 `revised`)靠这个标记分清「配方真改过台账」 * 与「配方跑过一遍但什么也没落」,两者都会留痕,只有前者该解锁闸。 */ describe('project 留痕:changed 只在真写了/真撤了时为真(M1/M2 二审)', () => { it('真写了一行:changed:true', async () => { const { runner, appended, ctx } = harness({}); await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A' } }] }, ctx, ); const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part'); expect(note?.detail?.['changed']).toBe(true); }); it('全在库、这一笔不用建(skipExisting):changed:false —— 台账没动', async () => { const { runner, formal, appended, ctx } = harness({}); formal.add('sub_eval.part|W05-1|IN_LIB'); await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'IN_LIB' } }] }, ctx, ); const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part'); expect(note?.detail?.['changed']).toBe(false); }); it('主键算不出来、一行都没写:changed:false —— 台账没动', async () => { const { runner, appended, ctx } = harness({}); await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: '' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx, ); const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part'); expect(note?.detail?.['changed']).toBe(false); }); }); /** * **`carded` 只跳过这一笔,不收拍**(2026-08-29 终审 I3)。 * * 从前 carded 分支 `stop: true` → break 掉整条 `do`。同一份配方 `[project, card, project]` * 在两种局面下行为相反:人还在填(quiet)时 card 被 quiet 过滤 `continue` 掉,**第三笔照跑**; * 人交回之后(非 quiet、carded)走到 carded 分支 break,**第三笔不跑** —— * 唯一一个「人的答案已经在草稿里」的时刻,配方的后半截偏偏不执行,而且静默、没有痕。 */ describe('carded:跳这一笔,不收拍(I3)', () => { const THREE: AutoRecipe = { do: [ { kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A' } }, { kind: 'card', as: 'execution', title: '请补齐', basis: [{ k: 'a', v: 'b' }] }, { kind: 'project', into: 'parts.parameter', from: { once: true }, fields: { internal_pn: 'A', param_name: '收口', param_value: '1' } }, ], }; it('人交回之后那一拍:卡不再发,**卡后面那一笔照跑**', async () => { const { runner, drafts, cards, ctx } = harness({}); const out = await runner.run(THREE, { ...ctx, carded: true }); expect(cards.issued).toHaveLength(0); expect(out.issuedCardId).toBeUndefined(); expect([...drafts.values()].map((d) => d.object).sort()).toEqual(['parameter', 'part']); }); it('卡真发出去那一拍仍然收拍 —— 「收拍」留给发得出去的那一次', async () => { const { runner, drafts, cards, ctx } = harness({}); const out = await runner.run(THREE, ctx); expect(cards.issued).toHaveLength(1); expect(out.issuedCardId).toBe('card-1'); // 第一笔写了,第三笔没跑 expect([...drafts.values()].map((d) => d.object)).toEqual(['part']); }); it('人还在填(quiet)那一拍:card 被跳过,前后两笔都跑 —— 两种局面现在一致了', async () => { const { runner, drafts, cards, ctx } = harness({}); await runner.run(THREE, { ...ctx, quiet: true, carded: true }); expect(cards.issued).toHaveLength(0); expect([...drafts.values()].map((d) => d.object).sort()).toEqual(['parameter', 'part']); }); }); /** * **人正在件上编辑的时候不 prune**(2026-08-29 终审 F3)。 * * `prune` 的语义是「这一笔投影出来的行就是这张表的全部,多出来的撤掉」—— * 而人在件上「加一行」加出来的行,配方的数据源里当然没有。编辑中途把它悄悄撤掉, * 是最不能接受的一种静默数据丢失(2026-08-02 为人工撤行立过「撤要说出来、点名哪几行」的规矩, * 这里连说都没说)。 */ describe('prune:人在填的时候不撤行(F3)', () => { const SEED: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }], }; const PRUNE: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }], }; it('有卡在等人(quiet):只铺不撤 —— 人刚加的那一行还在', async () => { const { runner, drafts, ctx } = harness({}); await runner.run(SEED, ctx); const out = await runner.run(PRUNE, { ...ctx, quiet: true }); expect(out.dropped).toBe(0); expect([...drafts.values()].map((d) => d.key['internal_pn']).sort()).toEqual(['A', 'B']); }); it('人交回之后那一拍照旧对齐 —— 这道门只挡编辑中途,不取消 prune', async () => { const { runner, drafts, ctx } = harness({}); await runner.run(SEED, ctx); const out = await runner.run(PRUNE, ctx); expect(out.dropped).toBe(1); expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']); }); /* * `prune: 'live'`(2026-08-31):quiet 拍也撤 —— 只给**人不会在件上加行**的表用 * (行集全由配方铺,如 W05 参数骨架):F3 防的是「人刚加的行被撤」,人加不了行的表 * 没有这个风险;而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎(用户实测点名 * 「只会跟着变多,不会跟着变少」)—— 跟手比守候更诚实。 */ it("prune:'live' 在 quiet 拍也对齐 —— 人加不了行的表,改选后旧行跟手消失", async () => { const { runner, drafts, ctx } = harness({}); await runner.run(SEED, ctx); const out = await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', prune: 'live', from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, { ...ctx, quiet: true }, ); expect(out.dropped).toBe(1); expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']); }); }); /** * **回归:jsonb 打乱 `fields` 键序,`project` 的求值结果必须与原声明序一致**(2026-08-30)。 * * 背景(专查报告 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`): * `AutoAction.fields` 存进 `sys.activity_package.manifest`(`jsonb` 列)之后,PostgreSQL * 按「先短后长、同长字典序」重排对象键 —— `judgement_class` 依赖同笔内先算出来的 * `difference_status`,一旦被排到它前面,`ctx.computed.difference_status` 求值时恒为 * `undefined`,三级 `when` 链全部落空,摔进 `constraint_type` 兜底档 * (`hard_mismatch`/`soft_mismatch`)—— **与这一行真实的差异状态毫无关系**; * `risk_level` 依赖 `judgement_class`,是同一套机制的第二层级联。 * * 下面 `DIFFERENCE_ITEM_FIELDS` **逐字段照抄** * `activities/part-substitution-assessment/src/case-model.ts`(2026-08-30 一份)里 * `S2_assess` 步「project into substitution.difference_item」那一笔的真实配方 —— * `apps/**` 与 `activities/**` 互不静态 import(编码规范 §2.1),depcruise 会拦真 import, * 只能手抄;`from`/`prune` 为测试自洽而简化,不影响所测的字段求值序问题。 * 主键(`substitution_relation_id`+`difference_item_id`)与 `judgement_class`/`risk_level` * 两列的 tally 取值抄自同一活动包的 `manifest.ts` 声明。 */ describe('回归 · jsonb 打乱 fields 键序,project 求值仍要与原序一致(2026-08-30)', () => { /** 与 `case-model.ts` 里的 `judgement_class`/`risk_level` 一字不差 —— 三级 when 链依赖同笔内的 difference_status,risk_level 依赖同笔内的 judgement_class */ const DIFFERENCE_ITEM_FIELDS: Record = { substitution_relation_id: { in: 'relationId' }, difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] }, parameter_name: { col: 'param_name' }, parameter_desc: { col: 'param_desc' }, parameter_unit: { col: 'param_unit' }, constraint_type: { col: 'constraint_type' }, original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '', }, candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '', }, difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' }, 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 分成硬 / 软 —— 依赖同笔的 difference_status */ 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' }, }, }, }, /* 依赖同笔的 judgement_class */ risk_level: { when: 'judgement_class = "hard_mismatch"', then: 'high', else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' }, }, rule_version: 'RULE-V1', }; /** PostgreSQL jsonb 对对象键的重排规则:先按字节长度升序,同长按字典序 —— 下一条用例直接核对 * 这份模拟与专查报告里从库现查出来的真实顺序逐字节一致(不是「差不多像」,是同一个函数)。*/ function reorderLikeJsonb(fields: Readonly>): Record { const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0)); const out: Record = {}; for (const key of keys) out[key] = fields[key] as T; return out; } it('(加分)reorderLikeJsonb 复现的顺序与生产实测(报告从库里现查出来的那一串)逐字一致', () => { expect(Object.keys(reorderLikeJsonb(DIFFERENCE_ITEM_FIELDS))).toEqual([ 'risk_level', 'rule_version', 'evidence_note', 'original_value', 'parameter_desc', 'parameter_name', 'parameter_unit', 'candidate_value', 'constraint_type', 'judgement_class', 'impact_statement', 'difference_status', 'difference_item_id', 'unknown_disposition', 'substitution_relation_id', ]); }); /** 七行真实数据 + SQL 转录,来自专查报告「最终落库数据」那一节(6 match + 1 conditional) */ const REAL_PARAM_ROWS: Array> = [ { param_name: '使能阈值', param_desc: '欠压保护阈值', param_unit: 'V', constraint_type: 'hard' }, { param_name: '工作温度', param_desc: '结温范围', param_unit: '℃', constraint_type: 'hard' }, { param_name: '开关频率', param_desc: '振荡频率', param_unit: 'kHz', constraint_type: 'soft' }, { param_name: '输入电压范围', param_desc: '输入电压范围', param_unit: 'V', constraint_type: 'hard' }, { param_name: '输出电压', param_desc: '输出电压', param_unit: 'V', constraint_type: 'hard' }, { param_name: '输出电流', param_desc: '最大输出电流', param_unit: 'A', constraint_type: 'hard' }, { param_name: '静态电流', param_desc: '静态电流', param_unit: 'µA', constraint_type: 'soft' }, ]; /** 补三行合成数据 —— 七行真实数据全落在 match/conditional,覆盖不到 unknown 与 mismatch 两支 */ const SYNTHETIC_PARAM_ROWS: Array> = [ { param_name: 'test_unknown_param', param_desc: '(补充覆盖 unknown 分支)', param_unit: '', constraint_type: 'soft' }, { param_name: 'test_hard_mismatch_param', param_desc: '(补充覆盖 hard_mismatch 分支)', param_unit: '', constraint_type: 'hard' }, { param_name: 'test_soft_mismatch_param', param_desc: '(补充覆盖 soft_mismatch 分支)', param_unit: '', constraint_type: 'soft' }, ]; const PARAM_ROWS = [...REAL_PARAM_ROWS, ...SYNTHETIC_PARAM_ROWS]; /** 对应的 AI 出参(`result:items`)—— 七行抄自专查报告的落库数据,三行合成的覆盖剩余分支 */ const RESULT_ITEMS = [ { param_name: '使能阈值', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' }, { param_name: '工作温度', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' }, { param_name: '开关频率', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' }, { param_name: '输入电压范围', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' }, { param_name: '输出电压', difference_status: 'conditional', impact_statement: '需外围电路验证', evidence_note: '', unknown_disposition: '' }, { param_name: '输出电流', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' }, { param_name: '静态电流', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' }, { param_name: 'test_unknown_param', difference_status: 'unknown', impact_statement: '', evidence_note: '', unknown_disposition: '待实测' }, { param_name: 'test_hard_mismatch_param', difference_status: 'mismatch', impact_statement: '不满足硬约束', evidence_note: '', unknown_disposition: '' }, { param_name: 'test_soft_mismatch_param', difference_status: 'mismatch', impact_statement: '不满足软约束', evidence_note: '', unknown_disposition: '' }, ]; /* * 正确答案(不是猜的,是照 `judgement_class`/`risk_level` 的 when 链手算的): * difference_status=match → judgement_class=match → risk_level=low * difference_status=conditional → judgement_class=conditional → risk_level=medium(两条 when 都不中) * difference_status=unknown → judgement_class=unknown → risk_level=medium * difference_status=mismatch 且 constraint_type=hard → judgement_class=hard_mismatch → risk_level=high * difference_status=mismatch 且 constraint_type=soft → judgement_class=soft_mismatch → risk_level=medium */ const EXPECTED: Record = { 使能阈值: { judgement_class: 'match', risk_level: 'low' }, 工作温度: { judgement_class: 'match', risk_level: 'low' }, 开关频率: { judgement_class: 'match', risk_level: 'low' }, 输入电压范围: { judgement_class: 'match', risk_level: 'low' }, 输出电压: { judgement_class: 'conditional', risk_level: 'medium' }, 输出电流: { judgement_class: 'match', risk_level: 'low' }, 静态电流: { judgement_class: 'match', risk_level: 'low' }, test_unknown_param: { judgement_class: 'unknown', risk_level: 'medium' }, test_hard_mismatch_param: { judgement_class: 'hard_mismatch', risk_level: 'high' }, test_soft_mismatch_param: { judgement_class: 'soft_mismatch', risk_level: 'medium' }, }; /** * 专用的一次性 harness:**真 `createAutoRunner`**,`sfeel.condition` 接的是**真** * `@sfeel` 的 `evaluateCondition`(不是本文件顶部那个只认三句话的桩)—— * `judgement_class`/`risk_level` 的三级 `when` 链要真求值,不能假装。 */ function makeHarness(seed: Record) { const drafts = new Map; patch: Record }>(); const deps = { data: { view: () => Promise.resolve({ ...seed }), writable: () => Promise.resolve([ { domain: 'substitution', object: 'difference_item', label: '差异明细', keys: ['substitution_relation_id', 'difference_item_id'], fields: [], }, ]), draftWrite: (i: { domain: string; object: string; key: Record; patch: Record }) => { const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; drafts.set(id, { key: i.key, patch: i.patch }); return Promise.resolve({ changed: true }); }, draftDrop: () => Promise.resolve({ dropped: false }), }, cards: { issue: () => Promise.reject(new Error('本用例不该发卡')), decide: () => Promise.reject(new Error('本用例不该发卡')), entry: () => Promise.reject(new Error('本用例不该发卡')), ask: () => Promise.reject(new Error('本用例不该发卡')), unanswered: () => Promise.resolve([]), }, memory: { append: () => Promise.resolve() }, analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze —— 用 seed 直接铺 result:items,不经 analyze 那一笔')) }, sfeel: { condition: (expr: string, scope: Record) => evaluateCondition(expr, scope), expression: () => undefined, unaryTest: () => false, }, }; const ctx = { taskId: TASK, stepId: 'S2_assess', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false }; // 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法 return { runner: createAutoRunner(deps as never), drafts, ctx }; } const recipeOf = (fields: Record): AutoRecipe => ({ do: [{ kind: 'project', into: 'substitution.difference_item', from: { rows: PARAM_ROWS }, fields }], }); it('project 求出的每一行、每一个字段,声明序与打乱成 jsonb 重排序**逐字段完全相同**', async () => { const seed = { relationId: 'REL-1', 'result:items': RESULT_ITEMS }; const declared = makeHarness(seed); const declaredOut = await declared.runner.run(recipeOf(DIFFERENCE_ITEM_FIELDS), declared.ctx); const shuffled = makeHarness(seed); const shuffledOut = await shuffled.runner.run(recipeOf(reorderLikeJsonb(DIFFERENCE_ITEM_FIELDS)), shuffled.ctx); expect(declaredOut.failedAt).toBeUndefined(); expect(shuffledOut.failedAt).toBeUndefined(); const rowsByPn = (h: ReturnType) => new Map([...h.drafts.values()].map((d) => [String(d.patch['parameter_name']), d.patch])); const a = rowsByPn(declared); const b = rowsByPn(shuffled); expect(a.size).toBe(PARAM_ROWS.length); expect(b.size).toBe(PARAM_ROWS.length); for (const [pn, patchFromDeclaredOrder] of a) { expect(b.get(pn)).toEqual(patchFromDeclaredOrder); } }); it('且求出来的是对的:四态照 difference_status 走,不是清一色摔进 hard_mismatch(bug 复现的具体形状)', async () => { const seed = { relationId: 'REL-1', 'result:items': RESULT_ITEMS }; const shuffled = makeHarness(seed); await shuffled.runner.run(recipeOf(reorderLikeJsonb(DIFFERENCE_ITEM_FIELDS)), shuffled.ctx); const rowsByPn = new Map([...shuffled.drafts.values()].map((d) => [String(d.patch['parameter_name']), d.patch])); for (const [pn, expected] of Object.entries(EXPECTED)) { const patch = rowsByPn.get(pn); expect(patch, `${pn} 这一行没写下来`).toBeDefined(); expect(patch?.['difference_status'], `${pn} 的 difference_status`).toBe( RESULT_ITEMS.find((r) => r.param_name === pn)?.difference_status, ); expect(patch?.['judgement_class'], `${pn} 的 judgement_class`).toBe(expected.judgement_class); expect(patch?.['risk_level'], `${pn} 的 risk_level`).toBe(expected.risk_level); } }); }); /** * **回归:jsonb 打乱 `recipe.let` 键序,同样必须求出与原声明序一致的结果** * (2026-08-30 全分支终审 Important 1)。 * * H47(上面那一大段)只治了 `fields` 那一半 —— `recipe.let` 同样住在 * `sys.activity_package.manifest` 这个 jsonb 列,同样会被 PG 按「先短后长、同长字典序」 * 重排。而 `let` 的官方跨条目读法**就是** `{in:X}`(见 `packages/contracts/src/ * auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能 {in:'名'} 引用」),后一条 let * 靠它读前一条已经算出的值 —— 键序被打乱,`{in:X}` 会读到 `undefined`,与 `fields` 那次 * 事故是**同一种病灶、同一种沉默失败**(不报错、结果看着完整、只是算错)。 * * 下面 `LETS` 故意让**被依赖的那一条名字更长**(`originalStamp`,13 字)、**依赖它的那一条 * 名字更短**(`tag`,3 字)——jsonb「先短后长」重排会把 `tag` 排到 `originalStamp` **前面**, * 恰好是「后一条 let 引用前一条,键序被 jsonb 重排会颠倒」这个具体形状。 */ describe('回归 · jsonb 打乱 recipe.let 键序,求值仍要与原声明序一致(2026-08-30)', () => { /** 与上面 `reorderLikeJsonb` 同一个规则,这个 describe 块单独起一份(两处各自独立,互不依赖对方的实现细节) */ function reorderLikeJsonb(fields: Readonly>): Record { const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0)); const out: Record = {}; for (const key of keys) out[key] = fields[key] as T; return out; } /** 被依赖的那一条(13 字)排在依赖它的那一条(3 字)前面 —— 声明序是对的,jsonb 会把它排错 */ const LETS: Record = { originalStamp: { in: 'caseSeed' }, tag: { cat: [{ in: 'originalStamp' }, '-X'] }, }; it('reorderLikeJsonb 确实会把依赖的那一条排到被依赖的前面(不然这条用例什么都没测)', () => { expect(Object.keys(reorderLikeJsonb(LETS))).toEqual(['tag', 'originalStamp']); }); function harness(seed: Record) { const drafts = new Map; patch: Record }>(); const deps = { data: { view: () => Promise.resolve({ ...seed }), writable: () => Promise.resolve([{ domain: 'demo', object: 'record', label: '演示记录', keys: ['case_ref'], fields: [] }]), draftWrite: (i: { domain: string; object: string; key: Record; patch: Record }) => { const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; drafts.set(id, { key: i.key, patch: i.patch }); return Promise.resolve({ changed: true }); }, draftDrop: () => Promise.resolve({ dropped: false }), }, cards: { issue: () => Promise.reject(new Error('本用例不该发卡')), decide: () => Promise.reject(new Error('本用例不该发卡')), entry: () => Promise.reject(new Error('本用例不该发卡')), ask: () => Promise.reject(new Error('本用例不该发卡')), unanswered: () => Promise.resolve([]), }, memory: { append: () => Promise.resolve() }, analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, sfeel: { condition: (expr: string, scope: Record) => evaluateCondition(expr, scope), expression: () => undefined, unaryTest: () => false, }, }; const ctx = { taskId: TASK, stepId: 'S1_inputs', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false }; return { runner: createAutoRunner(deps as never), drafts, ctx }; } const recipeOf = (lets: Record): AutoRecipe => ({ let: lets, do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'CR-1', computed_tag: { in: 'tag' } } }], }); it('声明序与打乱成 jsonb 重排序,求出的 let 值必须完全相同、且是对的(不是空字符串兜底)', async () => { const seed = { caseSeed: 'W05-20260830' }; const declared = harness(seed); const declaredOut = await declared.runner.run(recipeOf(LETS), declared.ctx); const shuffled = harness(seed); const shuffledOut = await shuffled.runner.run(recipeOf(reorderLikeJsonb(LETS)), shuffled.ctx); expect(declaredOut.failedAt).toBeUndefined(); expect(shuffledOut.failedAt).toBeUndefined(); const declaredTag = [...declared.drafts.values()][0]?.patch['computed_tag']; const shuffledTag = [...shuffled.drafts.values()][0]?.patch['computed_tag']; /* 正确答案:originalStamp = 'W05-20260830'(读 scope),tag = 'W05-20260830' + '-X' —— 打乱前不改变这一点 */ expect(declaredTag).toBe('W05-20260830-X'); /* 求值序被治好之前,这里会是 '-X'(打乱后 `originalStamp` 还没算,`{in:'originalStamp'}` 读到 undefined→'') */ expect(shuffledTag).toBe('W05-20260830-X'); expect(shuffledTag).toBe(declaredTag); }); it('真正的环(a 依赖 b、b 依赖 a)—— 不抛出去炸掉整个回合,交回 failedAt 且话里点名是 let', async () => { const cyclic: Record = { a: { in: 'b' }, b: { in: 'a' } }; const h = harness({}); const out = await h.runner.run(recipeOf(cyclic), h.ctx); expect(out.failedAt).toBeDefined(); expect(out.failedAt?.message).toContain('循环依赖'); expect(out.failedAt?.message).toContain('let'); }); });