Archived
用户:「每次保存的时候右边也会出现 ai 推进中,然后会说一句话,能不能把这个也取消掉」 两处,同一个根:件上按保存会 wake 这一单(「保存即铺格」),runtime 被拨成 running。 ① 右边那条办事条:`TaskChat` 的 `working` 只看 runtime === 'running'。它自己的注释 早写着「等人、等外部、已收尾都不算 —— 那时亮『工作中』就是撒谎:环没在转,是在等」—— save-wake 恰恰把「在等人」的单拨成了 running,把这条原则架空了。加 `!awaitingHuman(...)` 是**恢复**那句注释说的语义:有未办结工单 / 未回的卡就是在等人,哪怕 runtime 写着 running。 判断与工作区那一侧同一个函数。 ② 那句话:quiet 拍只跑 project 与 say,于是人按几次保存,同一句交代往对话区说几遍。 对话区的纪律是**一轮一句,不是一拍一句**(2026-08-01):只有交出控制权那一拍的交代 摆到人面前;而 quiet 拍压根没交出控制权(人正对着卡填),算不上新一轮。 改:quiet 拍的 say 标 `internal`。压下去不是丢掉 —— `internal` 是两个投影共用的一个旗 (终审 I5/F2):`chat-projection` 据此挡住对话区,`task-log.machinery()` 对 `detail.auto` 开了例外,这一笔在工作日志里照旧看得见(话有插值时值的变化也查得到)。 测:quiet 的 say 标 internal 且工作日志仍在(3 条变异各红一条 —— 退回原样 / 反过来连 非 quiet 也标 / 连 auto 一起去掉;其中「反过来标」红的是既有那条「非 quiet 不标」, 两条互为护栏)。后一半(标了 internal 就进不了对话区)由 chat-projection 既有测钉着, projectChat 没出模块边界,两半各钉在自己模块里、注释互指。 pnpm gates 退出码 0(2494 测)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
1117 lines
54 KiB
TypeScript
1117 lines
54 KiB
TypeScript
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<string, unknown> }> };
|
|
|
|
/** 合成的信封字段 —— 与 `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<string, unknown> | 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<string, unknown> = {}, override: { analyze?: AnalyzeStub } = {}) {
|
|
const drafts = new Map<
|
|
string,
|
|
{ domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown>; landed: boolean }
|
|
>();
|
|
/** 「只建不改」的表里已经躺在**正式表**的键(`域.对象|键值…`)—— 生产由 `createOnlyViolation` 判 */
|
|
const formal = new Set<string>();
|
|
const notes: string[] = [];
|
|
/** 配方留下的每一笔完整留痕(kind/title/detail)—— 断言「进没进日志、归谁」走这里 */
|
|
const appended: Array<{ kind: string; title: string; detail: Record<string, unknown> | undefined }> = [];
|
|
const cards = { issued: [] as Array<Record<string, unknown>> };
|
|
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'], 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<string, string>;
|
|
patch: Record<string, unknown>;
|
|
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<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.issued.push({ ...i, kind: 'issue' }); return Promise.resolve({ id: 'card-1' }); },
|
|
decide: (i: Record<string, unknown>) => { 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<string, unknown> }) => {
|
|
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<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, 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('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']);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **回归: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<string, AutoExpr> = {
|
|
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<T>(fields: Readonly<Record<string, T>>): Record<string, T> {
|
|
const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));
|
|
const out: Record<string, T> = {};
|
|
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<Record<string, string>> = [
|
|
{ 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<Record<string, string>> = [
|
|
{ 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<string, { judgement_class: string; risk_level: string }> = {
|
|
使能阈值: { 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<string, unknown>) {
|
|
const drafts = new Map<string, { key: Record<string, string>; patch: Record<string, unknown> }>();
|
|
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<string, string>; patch: Record<string, unknown> }) => {
|
|
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<string, unknown>) => 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<string, AutoExpr>): 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<typeof makeHarness>) =>
|
|
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<T>(fields: Readonly<Record<string, T>>): Record<string, T> {
|
|
const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));
|
|
const out: Record<string, T> = {};
|
|
for (const key of keys) out[key] = fields[key] as T;
|
|
return out;
|
|
}
|
|
|
|
/** 被依赖的那一条(13 字)排在依赖它的那一条(3 字)前面 —— 声明序是对的,jsonb 会把它排错 */
|
|
const LETS: Record<string, AutoExpr> = {
|
|
originalStamp: { in: 'caseSeed' },
|
|
tag: { cat: [{ in: 'originalStamp' }, '-X'] },
|
|
};
|
|
|
|
it('reorderLikeJsonb 确实会把依赖的那一条排到被依赖的前面(不然这条用例什么都没测)', () => {
|
|
expect(Object.keys(reorderLikeJsonb(LETS))).toEqual(['tag', 'originalStamp']);
|
|
});
|
|
|
|
function harness(seed: Record<string, unknown>) {
|
|
const drafts = new Map<string, { key: Record<string, string>; patch: Record<string, unknown> }>();
|
|
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<string, string>; patch: Record<string, unknown> }) => {
|
|
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<string, unknown>) => 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<string, AutoExpr>): 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<string, AutoExpr> = { 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');
|
|
});
|
|
});
|