/** 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); }); });