From f950a4419d4c46ba105b48366f422dab169f7f05 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 03:14:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(loop-engine):=20=E9=85=8D=E6=96=B9?= =?UTF-8?q?=E8=A1=A8=E8=BE=BE=E5=BC=8F=E6=B1=82=E5=80=BC=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=85=AB=E7=A7=8D=E5=BD=A2=E7=8A=B6,S-FEEL=20?= =?UTF-8?q?=E4=BB=8D=E7=94=B1=E5=A4=96=E9=9D=A2=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 地址四种、取不到一律空数组(首拍很多表本来就没行,抛了等于开局交人); 比值一律按字符串(台账里数字与文本混着存);谓词算不出来走 else,不拖垮整拍。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../loop-engine/__tests__/auto-expr.test.ts | 92 +++++++++++++++++++ .../loop-engine/internal/domain/auto-expr.ts | 78 ++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts create mode 100644 apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts new file mode 100644 index 0000000..e7786d4 --- /dev/null +++ b/apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts @@ -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>): 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(''); + }); +}); diff --git a/apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts b/apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts new file mode 100644 index 0000000..26fcc4d --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts @@ -0,0 +1,78 @@ +/** + * 配方表达式求值 —— **纯函数,零 IO**(编码规范:domain 层不碰端口)。 + * + * S-FEEL 不在这里实现:`condition` 由调用方注入(与哨兵、`doneWhen` 同一个求值器)。 + * **不发明第二套求值** 是 ADR-014 的一条硬约束。 + */ +import type { AutoExpr } from '@contracts'; + +export type Row = Readonly>; + +export type ExprCtx = { + /** 任务视图(正式 + 本单草稿叠加)+ 基座读数 + 起单入参,外加本拍的 `result:*` */ + readonly scope: Readonly>; + /** 配方级 `let` 算好的值 —— 与 scope 同一命名空间,它优先 */ + readonly lets: Readonly>; + readonly row?: Row | undefined; + readonly outer?: Row | undefined; + /** 同一行里**已经算出来**的字段 —— 「先 lookup 出依据,再按依据判 found/absent」靠它 */ + readonly computed?: Row | undefined; + readonly condition: (expr: string, scope: Readonly>) => boolean; +}; + +/** + * 地址取行 —— 四种地址(spec §5.1),取不到一律空数组。 + * + * **取不到不抛**:首拍很多表本来就一行都还没有,抛了等于每一单开局就交人。 + */ +export function rowsAt(scope: Readonly>, 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); +}