Archived
feat(loop-engine): 配方执行器 —— project / prune / say,每一笔之前刷读数
三条硬规矩:每一笔之前刷读数(后一笔要数前一笔刚写下的行,不刷新就恒为 0); 值没变的写静默跳过(配方不是模型,原地打转不必被看见); 出错交人不重试不退回模型(配方抛错是包的 bug,退回模型就是让它去凑)。 主键算不出来的那一行先不写 —— 空主键写下去是一行永远对不上的垃圾。 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,149 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AutoRecipe } from '@contracts';
|
||||
import { taskIdSchema, staffIdSchema } from '@contracts';
|
||||
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');
|
||||
|
||||
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
|
||||
function harness(seed: Record<string, unknown> = {}) {
|
||||
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: 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 });
|
||||
return { ...seed, ...mine };
|
||||
};
|
||||
const deps = {
|
||||
data: {
|
||||
view: () => Promise.resolve(view()),
|
||||
writable: () =>
|
||||
Promise.resolve([
|
||||
{ domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] },
|
||||
{ domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] },
|
||||
]),
|
||||
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
const before = JSON.stringify(drafts.get(id)?.patch ?? null);
|
||||
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch });
|
||||
return Promise.resolve({ changed: before !== JSON.stringify(i.patch) });
|
||||
},
|
||||
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: () => Promise.resolve({ id: 'card-1' }),
|
||||
decide: () => Promise.resolve({ id: 'card-2' }),
|
||||
entry: () => Promise.resolve({ id: 'card-3' }),
|
||||
ask: () => Promise.resolve({ id: 'card-4' }),
|
||||
unanswered: () => Promise.resolve([]),
|
||||
},
|
||||
memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } },
|
||||
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
||||
sfeel: {
|
||||
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
||||
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, notes, 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' },
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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:这一笔没投影出来的行,从草稿层撤掉', 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' } } }] }, 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']);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user