M3(1/5): @sfeel —— S-FEEL 子集的解析与求值,前后端同源

- lexer / parser(递归下降)/ evaluate / static-check,四文件纯函数,零 IO 零外部调用
- 子集封顶(ADR-006):比较 · 逻辑 · 算术 · 区间(开闭四态)· 列表与 in ·
  封闭内建函数集(字符串 / 数字 / 日期);未知函数一律拦下
- 单元格谓词(DMN 一格):`-` 任意 / 比较 / 区间 / 逗号析取
- staticCheck 给「画图即校验」用:语法 + 未知函数 + 引用路径清单
- app 侧以它替下 passthroughEvaluator:监听内容条件真求值,求值异常按不命中处置并留痕

32 个 sfeel 测试;全仓 182 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 01:21:45 +08:00
co-authored by Claude Opus 5
parent f0444f1417
commit 146e9bbfad
13 changed files with 1048 additions and 26 deletions
+215
View File
@@ -0,0 +1,215 @@
/** S-FEEL 子集 —— 纯函数,零 mock(编码规范 §8.3;定义域 OS 03) */
import { describe, expect, it } from 'vitest';
import {
evaluate,
evaluateCondition,
evaluateUnaryTest,
parse,
SfeelEvalError,
SfeelSyntaxError,
staticCheck,
} from '../src/index.ts';
const scope = {
amount: 12000,
grade: 'A',
customer: { code: 'C001', grade: '战略客户', payment: 30 },
type: 'B',
quotedAt: '2026-07-20T00:00:00.000Z',
today: '2026-07-27T00:00:00.000Z',
tags: ['urgent', 'auto'],
};
describe('字面量与路径', () => {
it('数字 / 字符串 / 布尔 / null', () => {
expect(evaluate('1', {})).toBe(1);
expect(evaluate('1.5', {})).toBe(1.5);
expect(evaluate('"hi"', {})).toBe('hi');
expect(evaluate("'hi'", {})).toBe('hi');
expect(evaluate('true', {})).toBe(true);
expect(evaluate('null', {})).toBe(null);
});
it('多级路径', () => {
expect(evaluate('customer.grade', scope)).toBe('战略客户');
expect(evaluate('customer.missing', scope)).toBeUndefined();
expect(evaluate('nothing.deep.deeper', scope)).toBeUndefined();
});
});
describe('算术与字符串拼接', () => {
it('四则与优先级', () => {
expect(evaluate('1 + 2 * 3', {})).toBe(7);
expect(evaluate('(1 + 2) * 3', {})).toBe(9);
expect(evaluate('10 / 4', {})).toBe(2.5);
expect(evaluate('-amount + 1', scope)).toBe(-11999);
});
it('除零报错,不静默出 Infinity', () => {
expect(() => evaluate('1 / 0', {})).toThrow(SfeelEvalError);
});
it('任一侧是字符串即拼接', () => {
expect(evaluate('"Q" + 202601', {})).toBe('Q202601');
});
});
describe('比较与逻辑', () => {
it('比较运算', () => {
expect(evaluate('amount > 10000', scope)).toBe(true);
expect(evaluate('amount >= 12000', scope)).toBe(true);
expect(evaluate('grade = "A"', scope)).toBe(true);
expect(evaluate('grade != "B"', scope)).toBe(true);
});
it('字符串按字典序比较', () => {
expect(evaluate('"a" < "b"', {})).toBe(true);
});
it('and / or 短路', () => {
expect(evaluate('amount > 10000 and grade = "A"', scope)).toBe(true);
expect(evaluate('amount > 999999 and nothing.x = 1', scope)).toBe(false);
expect(evaluate('grade = "A" or nothing.x = 1', scope)).toBe(true);
});
it('not()', () => {
expect(evaluate('not(grade = "B")', scope)).toBe(true);
});
it('缺失值不为真', () => {
expect(evaluateCondition('missing', scope)).toBe(false);
expect(evaluateCondition('customer.code', scope)).toBe(true);
});
});
describe('区间、列表与 in', () => {
it('闭区间', () => {
expect(evaluate('amount in [10000..20000]', scope)).toBe(true);
expect(evaluate('amount in [12001..20000]', scope)).toBe(false);
});
it('开区间', () => {
expect(evaluate('12000 in (12000..20000]', {})).toBe(false);
expect(evaluate('12000 in [12000..20000)', {})).toBe(true);
});
it('列表', () => {
expect(evaluate('type in ["A", "B"]', scope)).toBe(true);
expect(evaluate('type in ["A", "C"]', scope)).toBe(false);
});
it('空列表', () => {
expect(evaluate('type in []', scope)).toBe(false);
});
});
describe('内建函数(封闭集合)', () => {
it('字符串', () => {
expect(evaluate('upper case("ab")', {})).toBe('AB');
expect(evaluate('lower case("AB")', {})).toBe('ab');
expect(evaluate('contains("LM358DR", "358")', {})).toBe(true);
expect(evaluate('starts with("LM358", "LM")', {})).toBe(true);
expect(evaluate('ends with("LM358", "58")', {})).toBe(true);
expect(evaluate('string length("LM358")', {})).toBe(5);
expect(evaluate('substring("LM358DR", 3, 3)', {})).toBe('358');
});
it('数字', () => {
expect(evaluate('abs(-3)', {})).toBe(3);
expect(evaluate('floor(2.7)', {})).toBe(2);
expect(evaluate('ceiling(2.1)', {})).toBe(3);
expect(evaluate('round(2.345, 2)', {})).toBe(2.35);
expect(evaluate('min(3, 1, 2)', {})).toBe(1);
expect(evaluate('max([3, 1, 2])', {})).toBe(3);
expect(evaluate('sum([1, 2, 3])', {})).toBe(6);
expect(evaluate('count(tags)', scope)).toBe(2);
});
it('日期与天数差 —— 时限与偏移计算的底盘', () => {
expect(evaluate('days and time duration(quotedAt, today)', scope)).toBe(7);
expect(evaluate('date("2026-07-27") > date("2026-07-20")', {})).toBe(true);
});
it('未知函数报错 —— 子集封顶,不可扩展', () => {
expect(() => evaluate('fetch("http://x")', {})).toThrow(/未知函数/);
});
});
describe('语法错误', () => {
it('字符串未闭合', () => {
expect(() => parse('"abc')).toThrow(SfeelSyntaxError);
});
it('括号不匹配', () => {
expect(() => parse('(1 + 2')).toThrow(SfeelSyntaxError);
});
it('尾部多余记号', () => {
expect(() => parse('1 2')).toThrow(SfeelSyntaxError);
});
it('无法识别的字符', () => {
expect(() => parse('1 # 2')).toThrow(SfeelSyntaxError);
});
});
describe('单元格谓词 —— DMN 决策表的一格', () => {
it('`-` 与空格代表任意', () => {
expect(evaluateUnaryTest('-', '任何值')).toBe(true);
expect(evaluateUnaryTest(' ', 42)).toBe(true);
});
it('等值(裸值即等值)', () => {
expect(evaluateUnaryTest('"战略客户"', '战略客户')).toBe(true);
expect(evaluateUnaryTest('"战略客户"', '普通客户')).toBe(false);
});
it('比较', () => {
expect(evaluateUnaryTest('< 10000', 9000)).toBe(true);
expect(evaluateUnaryTest('>= 10000', 10000)).toBe(true);
expect(evaluateUnaryTest('!= "A"', 'B')).toBe(true);
});
it('区间', () => {
expect(evaluateUnaryTest('[1..5]', 3)).toBe(true);
expect(evaluateUnaryTest('[1..5]', 6)).toBe(false);
expect(evaluateUnaryTest('(1..5)', 1)).toBe(false);
});
it('逗号即析取', () => {
expect(evaluateUnaryTest('"A", "B"', 'B')).toBe(true);
expect(evaluateUnaryTest('"A", "B"', 'C')).toBe(false);
expect(evaluateUnaryTest('< 0, > 100', 200)).toBe(true);
});
it('三分类规则 P-03 的行谓词', () => {
// A:历史价 ≤90 天且行情稳
const row = (days: number, stable: boolean) =>
evaluateUnaryTest('<= 90', days) && evaluateUnaryTest('true', stable);
expect(row(30, true)).toBe(true);
expect(row(120, true)).toBe(false);
expect(row(30, false)).toBe(false);
});
});
describe('staticCheck —— 画图即校验', () => {
it('语法正确即 ok,并列出引用的路径', () => {
const r = staticCheck('customer.grade = "战略客户" and amount > 10000');
expect(r.ok).toBe(true);
expect([...r.references].sort()).toEqual(['amount', 'customer.grade']);
});
it('语法错误即拦下', () => {
const r = staticCheck('customer.grade = ');
expect(r.ok).toBe(false);
expect(r.issues[0]?.kind).toBe('syntax');
});
it('未知函数即拦下 —— 无外部调用', () => {
const r = staticCheck('fetch("http://x")');
expect(r.ok).toBe(false);
expect(r.issues.some((i) => i.kind === 'unknown-function')).toBe(true);
});
it('单元格谓词也可静态检查', () => {
expect(staticCheck('<= 90', 'unary-test').ok).toBe(true);
expect(staticCheck('[1..', 'unary-test').ok).toBe(false);
});
});