Archived
feat(loop-engine): 配方表达式求值 —— 八种形状,S-FEEL 仍由外面注入
地址四种、取不到一律空数组(首拍很多表本来就没行,抛了等于开局交人); 比值一律按字符串(台账里数字与文本混着存);谓词算不出来走 else,不拖垮整拍。 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,92 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { evalExpr, evalText, rowsAt } from '../internal/domain/auto-expr.ts';
|
||||||
|
|
||||||
|
/** 假的 S-FEEL:只认这份测试用得到的两句,够验证「条件走的是注入进来的求值器」 */
|
||||||
|
const condition = (expr: string, scope: Readonly<Record<string, unknown>>): boolean => {
|
||||||
|
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
|
||||||
|
if (expr === 'n > 0') return Number(scope['n'] ?? 0) > 0;
|
||||||
|
throw new Error(`没教过这句:${expr}`);
|
||||||
|
};
|
||||||
|
const base = { scope: {}, lets: {}, condition };
|
||||||
|
|
||||||
|
describe('rowsAt:地址取行', () => {
|
||||||
|
const scope = {
|
||||||
|
'parts.profile': [{ internal_pn: 'A', chip_type_id: 1 }],
|
||||||
|
'mine:sub_eval.part': [{ internal_pn: 'B' }],
|
||||||
|
pns: ['X', 'Y'],
|
||||||
|
'result:items': [{ param_name: 'p' }],
|
||||||
|
};
|
||||||
|
it('域.对象 取任务视图整表', () => {
|
||||||
|
expect(rowsAt(scope, 'parts.profile')).toEqual([{ internal_pn: 'A', chip_type_id: 1 }]);
|
||||||
|
});
|
||||||
|
it('mine: 只取本单草稿', () => {
|
||||||
|
expect(rowsAt(scope, 'mine:sub_eval.part')).toEqual([{ internal_pn: 'B' }]);
|
||||||
|
});
|
||||||
|
it('in: 取入参数组;字符串元素归一成 { value }', () => {
|
||||||
|
expect(rowsAt(scope, 'in:pns')).toEqual([{ value: 'X' }, { value: 'Y' }]);
|
||||||
|
});
|
||||||
|
it('result: 取本拍 AI 出参', () => {
|
||||||
|
expect(rowsAt(scope, 'result:items')).toEqual([{ param_name: 'p' }]);
|
||||||
|
});
|
||||||
|
it('取不到就是空数组,不抛 —— 首拍很多表本来就还没有行', () => {
|
||||||
|
expect(rowsAt(scope, 'nope.nope')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evalExpr:八种形状', () => {
|
||||||
|
it('常量原样', () => {
|
||||||
|
expect(evalExpr('W05', base)).toBe('W05');
|
||||||
|
expect(evalExpr(7, base)).toBe(7);
|
||||||
|
expect(evalExpr(true, base)).toBe(true);
|
||||||
|
});
|
||||||
|
it('{in} 先看 let,再看作用域', () => {
|
||||||
|
const ctx = { ...base, scope: { pn: 'FROM-SCOPE', other: 'S' }, lets: { pn: 'FROM-LET' } };
|
||||||
|
expect(evalExpr({ in: 'pn' }, ctx)).toBe('FROM-LET');
|
||||||
|
expect(evalExpr({ in: 'other' }, ctx)).toBe('S');
|
||||||
|
});
|
||||||
|
it('{col} 取本行,{outer} 取外层那一行', () => {
|
||||||
|
const ctx = { ...base, row: { a: 1 }, outer: { b: 2 } };
|
||||||
|
expect(evalExpr({ col: 'a' }, ctx)).toBe(1);
|
||||||
|
expect(evalExpr({ outer: 'b' }, ctx)).toBe(2);
|
||||||
|
});
|
||||||
|
it('取不到的列是空串,不是 undefined —— 空串写进草稿层等于「这一列没写」', () => {
|
||||||
|
expect(evalExpr({ col: 'nope' }, { ...base, row: {} })).toBe('');
|
||||||
|
});
|
||||||
|
it('{cat} 按字符串拼', () => {
|
||||||
|
expect(evalExpr({ cat: ['W05-', { in: 'd' }] }, { ...base, lets: { d: 20260829 } })).toBe('W05-20260829');
|
||||||
|
});
|
||||||
|
it('{fmt as digits} 只留数字', () => {
|
||||||
|
expect(evalExpr({ fmt: { in: 't' }, as: 'digits' }, { ...base, lets: { t: '2026-08-29T21:51:03Z' } })).toBe('20260829215103');
|
||||||
|
});
|
||||||
|
it('{lookup} 查另一张表的一列', () => {
|
||||||
|
const ctx = { ...base, scope: { 'parts.profile': [{ internal_pn: 'A', chip_type_id: 3 }] }, row: { pn: 'A' } };
|
||||||
|
expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe(3);
|
||||||
|
});
|
||||||
|
it('{lookup} 查不到给 else,缺省空串', () => {
|
||||||
|
const ctx = { ...base, scope: { 'parts.profile': [] }, row: { pn: 'A' } };
|
||||||
|
expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe('');
|
||||||
|
expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'x', else: 'absent' }, ctx)).toBe('absent');
|
||||||
|
});
|
||||||
|
it('{lookup} 比值按字符串 —— 台账里数字与文本混着存', () => {
|
||||||
|
const ctx = { ...base, scope: { 'a.b': [{ k: 1, v: 'hit' }] }, row: { k: '1' } };
|
||||||
|
expect(evalExpr({ lookup: 'a.b', by: { k: { col: 'k' } }, take: 'v' }, ctx)).toBe('hit');
|
||||||
|
});
|
||||||
|
it('{when} 走注入的 S-FEEL,已算出的同行字段进它的作用域', () => {
|
||||||
|
const ctx = { ...base, computed: { found_ref: 'LM2596S-5.0' } };
|
||||||
|
expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, ctx)).toBe('found');
|
||||||
|
expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, { ...base, computed: { found_ref: '' } })).toBe('absent');
|
||||||
|
});
|
||||||
|
it('{when} 谓词算不出来时走 else,不把整拍拖垮', () => {
|
||||||
|
expect(evalExpr({ when: '这句没教过', then: 'a', else: 'b' }, base)).toBe('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evalText:一律出字符串', () => {
|
||||||
|
it('数字与布尔照实转', () => {
|
||||||
|
expect(evalText(3, base)).toBe('3');
|
||||||
|
expect(evalText(true, base)).toBe('true');
|
||||||
|
});
|
||||||
|
it('空与 undefined 都是空串', () => {
|
||||||
|
expect(evalText({ col: 'nope' }, { ...base, row: {} })).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 配方表达式求值 —— **纯函数,零 IO**(编码规范:domain 层不碰端口)。
|
||||||
|
*
|
||||||
|
* S-FEEL 不在这里实现:`condition` 由调用方注入(与哨兵、`doneWhen` 同一个求值器)。
|
||||||
|
* **不发明第二套求值** 是 ADR-014 的一条硬约束。
|
||||||
|
*/
|
||||||
|
import type { AutoExpr } from '@contracts';
|
||||||
|
|
||||||
|
export type Row = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
|
export type ExprCtx = {
|
||||||
|
/** 任务视图(正式 + 本单草稿叠加)+ 基座读数 + 起单入参,外加本拍的 `result:*` */
|
||||||
|
readonly scope: Readonly<Record<string, unknown>>;
|
||||||
|
/** 配方级 `let` 算好的值 —— 与 scope 同一命名空间,它优先 */
|
||||||
|
readonly lets: Readonly<Record<string, unknown>>;
|
||||||
|
readonly row?: Row | undefined;
|
||||||
|
readonly outer?: Row | undefined;
|
||||||
|
/** 同一行里**已经算出来**的字段 —— 「先 lookup 出依据,再按依据判 found/absent」靠它 */
|
||||||
|
readonly computed?: Row | undefined;
|
||||||
|
readonly condition: (expr: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地址取行 —— 四种地址(spec §5.1),取不到一律空数组。
|
||||||
|
*
|
||||||
|
* **取不到不抛**:首拍很多表本来就一行都还没有,抛了等于每一单开局就交人。
|
||||||
|
*/
|
||||||
|
export function rowsAt(scope: Readonly<Record<string, unknown>>, address: string): readonly Row[] {
|
||||||
|
const raw = address.startsWith('in:') ? scope[address.slice('in:'.length)] : scope[address];
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((item) =>
|
||||||
|
typeof item === 'object' && item !== null ? (item as Row) : ({ value: item } satisfies Row),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个值参与比较时的归一:台账里数字与文本混着存,一律按字符串比 */
|
||||||
|
function asKey(value: unknown): string {
|
||||||
|
return value === null || value === undefined ? '' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown {
|
||||||
|
if (typeof expr === 'string' || typeof expr === 'number' || typeof expr === 'boolean') return expr;
|
||||||
|
|
||||||
|
if ('in' in expr) {
|
||||||
|
const fromLet = ctx.lets[expr.in];
|
||||||
|
return fromLet !== undefined ? fromLet : (ctx.scope[expr.in] ?? '');
|
||||||
|
}
|
||||||
|
if ('col' in expr) return ctx.row?.[expr.col] ?? '';
|
||||||
|
if ('outer' in expr) return ctx.outer?.[expr.outer] ?? '';
|
||||||
|
if ('cat' in expr) return expr.cat.map((part) => evalText(part, ctx)).join('');
|
||||||
|
if ('fmt' in expr) return evalText(expr.fmt, ctx).replace(/\D/g, '');
|
||||||
|
|
||||||
|
if ('lookup' in expr) {
|
||||||
|
const pairs = Object.entries(expr.by).map(([column, valueExpr]) => [column, asKey(evalExpr(valueExpr, ctx))] as const);
|
||||||
|
const hit = rowsAt(ctx.scope, expr.lookup).find((row) => pairs.every(([column, want]) => asKey(row[column]) === want));
|
||||||
|
if (hit === undefined) return expr.else === undefined ? '' : evalExpr(expr.else, ctx);
|
||||||
|
return hit[expr.take] ?? (expr.else === undefined ? '' : evalExpr(expr.else, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 条件走注入的 S-FEEL。谓词写坏了**不该拖垮整拍** —— 与 `doneWhen` 同一条处置:
|
||||||
|
* 当作「没算出来」走 else。真写错了,静态审在装包时就该拦下(Task 9)。
|
||||||
|
*/
|
||||||
|
const scope = { ...ctx.scope, ...ctx.lets, ...(ctx.outer ?? {}), ...(ctx.row ?? {}), ...(ctx.computed ?? {}) };
|
||||||
|
let hit = false;
|
||||||
|
try {
|
||||||
|
hit = ctx.condition(expr.when, scope);
|
||||||
|
} catch {
|
||||||
|
hit = false;
|
||||||
|
}
|
||||||
|
return evalExpr(hit ? expr.then : expr.else, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 出字符串的那一路 —— 拼接、标题、留痕都要它 */
|
||||||
|
export function evalText(expr: AutoExpr, ctx: ExprCtx): string {
|
||||||
|
const value = evalExpr(expr, ctx);
|
||||||
|
return value === null || value === undefined ? '' : String(value);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user