forked from ChaiHao/BPOS-MVP
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:
co-authored by
Claude Opus 5
parent
f0444f1417
commit
146e9bbfad
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user