UNPKG

@player-ui/player

Version:

768 lines (641 loc) 24.2 kB
import { describe, it, expect, vitest, test, beforeEach } from "vitest"; import type { DataModelWithParser } from "../../data"; import { LocalModel, withParser } from "../../data"; import type { BindingLike } from "../../binding"; import { BindingParser } from "../../binding"; import type { ExpressionType } from ".."; import { ExpressionEvaluator } from ".."; import { Logger } from "../../logger"; describe("evaluator", () => { let evaluator: ExpressionEvaluator; let model: DataModelWithParser; let parseBinding: BindingParser["parse"]; beforeEach(() => { const localModel = new LocalModel(); const bindingParser = new BindingParser({ get: localModel.get, set: localModel.set, evaluate: (exp: ExpressionType) => { return evaluator.evaluate(exp); }, }); parseBinding = bindingParser.parse; model = withParser(localModel, bindingParser.parse); evaluator = new ExpressionEvaluator({ model }); }); test("resolveOptions hook", () => { model = withParser(new LocalModel({ foo: 2 }), parseBinding); evaluator = new ExpressionEvaluator({ model }); const testFn = vitest.fn(); evaluator.hooks.resolveOptions.tap("test", (hookOptions) => { testFn.mockImplementation((value: any) => hookOptions.model.set(value)); return { ...hookOptions, model: { ...hookOptions.model, set: testFn } }; }); evaluator.evaluate("{{foo}} = 3"); expect(model.get("foo")).toStrictEqual(3); expect(testFn).toBeCalled(); }); test("member expression", () => { evaluator.setExpressionVariable("foo", { bar: "baz" }); expect(evaluator.evaluate('foo["bar"]')).toStrictEqual("baz"); }); test("custom variables (identifier)", () => { evaluator.setExpressionVariable("foo", 3); evaluator.setExpressionVariable("bar", "baz"); evaluator.setExpressionVariable("bang", [1, 2, 3]); expect(evaluator.evaluate("foo")).toStrictEqual(3); expect(evaluator.evaluate("foo + 1")).toStrictEqual(4); expect(evaluator.evaluate("bar")).toStrictEqual("baz"); expect(evaluator.evaluate("bang")).toStrictEqual([1, 2, 3]); }); test("literals", () => { expect(evaluator.evaluate("2")).toStrictEqual(2); expect(evaluator.evaluate("true")).toStrictEqual(true); expect(evaluator.evaluate("false")).toStrictEqual(false); expect(evaluator.evaluate("undefined")).toBe(undefined); expect(evaluator.evaluate(undefined)).toBe(undefined); expect(evaluator.evaluate(null)).toBe(null); expect(evaluator.evaluate("null")).toBe(null); expect(evaluator.evaluate(2)).toStrictEqual(2); expect(evaluator.evaluate(true)).toStrictEqual(true); expect(evaluator.evaluate(false)).toStrictEqual(false); }); test("Objects", () => { expect( evaluator.evaluate('[{"foo": "value"}, {"bar": "value"}]'), ).toStrictEqual([ { foo: "value", }, { bar: "value" }, ]); expect(evaluator.evaluate('{"foo": "value"}')).toStrictEqual({ foo: "value", }); expect(evaluator.evaluate('{"foo": 1 + 2}')).toStrictEqual({ foo: 3, }); expect(evaluator.evaluate('{"foo": 1 > 2}')).toStrictEqual({ foo: false, }); expect(evaluator.evaluate('{"foo": 1 + 2, "bar": 4}')).toStrictEqual({ foo: 3, bar: 4, }); expect( evaluator.evaluate('{"foo": 2, "bar": { "baz": "foo" }}'), ).toStrictEqual({ foo: 2, bar: { baz: "foo", }, }); expect(evaluator.evaluate({ value: "1 + 2" })).toStrictEqual(3); }); test("functions", () => { model = withParser(new LocalModel({ test: 2 }), parseBinding); evaluator.addExpressionFunction("publish", (_context, key, value: any) => { model.set([[key as BindingLike, value]]); }); evaluator.evaluate('publish("test", {"key": "value"})'); expect(model.get("test")).toStrictEqual({ key: "value" }); }); test("eval in context", () => { model = withParser(new LocalModel({ test: 2 }), parseBinding); evaluator.addExpressionFunction("exec", (_context, value: string) => { _context.evaluate(value); }); evaluator.evaluate('exec("{{foo}} = true")', { model }); expect(model.get("foo")).toBe(true); }); test("binary operators", () => { expect(evaluator.evaluate("1 == 1")).toStrictEqual(true); expect(evaluator.evaluate("1 == 2")).toStrictEqual(false); expect(evaluator.evaluate("1 === 1")).toStrictEqual(true); expect(evaluator.evaluate("1 === 2")).toStrictEqual(false); expect(evaluator.evaluate("1 != 1")).toStrictEqual(false); expect(evaluator.evaluate("1 != 2")).toStrictEqual(true); expect(evaluator.evaluate("1 !== 1")).toStrictEqual(false); expect(evaluator.evaluate("1 !== 2")).toStrictEqual(true); expect(evaluator.evaluate("1 < 2")).toStrictEqual(true); expect(evaluator.evaluate("1 < 1")).toStrictEqual(false); expect(evaluator.evaluate("1 < 0")).toStrictEqual(false); expect(evaluator.evaluate("2 > 1")).toStrictEqual(true); expect(evaluator.evaluate("1 > 1")).toStrictEqual(false); expect(evaluator.evaluate("0 > 1")).toStrictEqual(false); expect(evaluator.evaluate("1 <= 2")).toStrictEqual(true); expect(evaluator.evaluate("1 <= 1")).toStrictEqual(true); expect(evaluator.evaluate("1 <= 0")).toStrictEqual(false); expect(evaluator.evaluate("2 >= 1")).toStrictEqual(true); expect(evaluator.evaluate("1 >= 1")).toStrictEqual(true); expect(evaluator.evaluate("0 >= 1")).toStrictEqual(false); expect(evaluator.evaluate("1 + 2")).toStrictEqual(3); expect(evaluator.evaluate("5 - 3")).toStrictEqual(2); expect(evaluator.evaluate("2 * 3")).toStrictEqual(6); expect(evaluator.evaluate("6 / 3")).toStrictEqual(2); expect(evaluator.evaluate("8 % 3")).toStrictEqual(2); }); test("add binary operators", () => { evaluator.addBinaryOperator("==", () => 5); expect(evaluator.evaluate("1 == 2")).toStrictEqual(5); }); test("add unary operators", () => { evaluator.addUnaryOperator("!", () => 5); expect(evaluator.evaluate("!1")).toStrictEqual(5); }); test("logical operators", () => { expect(evaluator.evaluate("true && false")).toStrictEqual(false); expect(evaluator.evaluate("true && true")).toStrictEqual(true); expect(evaluator.evaluate("1 && 2")).toStrictEqual(2); expect(evaluator.evaluate("1 && 0")).toStrictEqual(0); }); test("unary operators", () => { expect(evaluator.evaluate("!false")).toStrictEqual(true); expect(evaluator.evaluate("!true")).toStrictEqual(false); expect(evaluator.evaluate("+1")).toStrictEqual(1); expect(evaluator.evaluate("-1")).toStrictEqual(-1); expect(evaluator.evaluate("-(-1)")).toStrictEqual(1); }); test("model ref", () => { model = withParser(new LocalModel({ foo: { bar: "baz" } }), parseBinding); expect( evaluator.evaluate("{{foo.bar}}", { model, }), ).toStrictEqual("baz"); }); test("model ref and Object", () => { model = withParser( new LocalModel({ bar: { hello: "world" } }), parseBinding, ); expect( evaluator.evaluate('{ "foo": {{bar}} }', { model, }), ).toStrictEqual({ foo: { hello: "world" } }); }); test("ternary operator (conditional)", () => { expect(evaluator.evaluate("true ? true : false")).toStrictEqual(true); expect(evaluator.evaluate("false ? true : false")).toStrictEqual(false); }); test("array", () => { expect(evaluator.evaluate("[1,2,3]")).toStrictEqual([1, 2, 3]); }); test("assignment", () => { model = withParser(new LocalModel({ foo: 2 }), parseBinding); evaluator.evaluate("{{foo}} = 3", { model, }); expect(model.get("foo")).toStrictEqual(3); }); test("local assignment and re-assignment", () => { evaluator.evaluate("foo = 5"); expect(evaluator.evaluate("foo")).toStrictEqual(5); evaluator.evaluate("foo = 7"); expect(evaluator.evaluate("foo")).toStrictEqual(7); }); test("object assignment", () => { model = withParser(new LocalModel({ foo: 2 }), parseBinding); evaluator.evaluate('{{foo}} = {"foo": 2}', { model, }); expect(model.get("foo")).toStrictEqual({ foo: 2 }); }); describe("modification", () => { describe("on model", () => { beforeEach(() => { model = withParser(new LocalModel({ foo: 1 }), parseBinding); }); test("simple add", () => { evaluator.evaluate("{{foo}} += 3", { model }); expect(model.get("foo")).toStrictEqual(4); }); test("simple substract", () => { evaluator.evaluate("{{foo}} -= 3", { model }); expect(model.get("foo")).toStrictEqual(-2); }); }); describe("on expression variable", () => { beforeEach(() => { evaluator.setExpressionVariable("foo", 1); }); test("simple add", () => { evaluator.evaluate("foo += 3"); expect(evaluator.getExpressionVariable("foo")).toStrictEqual(4); }); test("simple subtract", () => { evaluator.evaluate("foo -= 3"); expect(evaluator.getExpressionVariable("foo")).toStrictEqual(-2); }); }); }); describe("Call Expressions", () => { beforeEach(() => { model = withParser(new LocalModel({ foo: 1 }), parseBinding); }); test("get model", () => { expect( evaluator.evaluate('getDataVal("foo")', { model, }), ).toStrictEqual(1); }); test("set model", () => { evaluator.evaluate('setDataVal("foo", 2)', { model, }); expect(model.get("foo")).toStrictEqual(2); }); test("conditional", () => { expect( evaluator.evaluate("conditional(true, true, false)"), ).toStrictEqual(true); expect( evaluator.evaluate("conditional(false, true, false)"), ).toStrictEqual(false); }); }); describe("not supported", () => { test("this ref", () => { expect(() => evaluator.evaluate("this")).toThrow(); }); test("compound expression", () => { expect(() => evaluator.evaluate("foo bar")).toThrow(); }); }); describe("error handling", () => { test("skips throwing error when handler is provided, but not when throwErrors is true", () => { const errorHandler = vitest.fn(); evaluator.hooks.onError.tap("test", (e) => { errorHandler(e); return true; }); evaluator.evaluate("foo()"); expect(errorHandler).toBeCalledTimes(1); expect(() => evaluator.evaluate("foo()", { throwErrors: true, model }), ).toThrowError(); }); }); describe("complex", () => { beforeEach(() => { model = withParser( new LocalModel({ foo: { bar: true }, baz: { other: false } }), parseBinding, ); }); it("can set a model to an object", () => { evaluator.evaluate("{{foo.nested}} = false || {{baz}}", { model }); expect(model.get("foo.nested.other")).toStrictEqual(false); }); }); describe("shortcuts binary ops", () => { const aFunc = vitest.fn(); const bFunc = vitest.fn(); beforeEach(() => { aFunc.mockReset(); bFunc.mockReset(); evaluator.addExpressionFunction("a", aFunc); evaluator.addExpressionFunction("b", bFunc); }); it("shortcuts || on true", () => { aFunc.mockReturnValue(true); evaluator.evaluate("a() || b()"); expect(aFunc).toBeCalledTimes(1); expect(bFunc).not.toBeCalled(); }); it("works for full ||", () => { aFunc.mockReturnValue(0); evaluator.evaluate("a() || b()"); expect(aFunc).toBeCalledTimes(1); expect(bFunc).toBeCalledTimes(1); }); it("shortcuts && on false", () => { aFunc.mockReturnValue(0); evaluator.evaluate("a() && b()"); expect(aFunc).toBeCalledTimes(1); expect(bFunc).not.toBeCalled(); }); it("works for full &&", () => { aFunc.mockReturnValue(true); evaluator.evaluate("a() && b()"); expect(aFunc).toBeCalledTimes(1); expect(bFunc).toBeCalledTimes(1); }); }); test("throws errors for unknown expressions", () => { expect(() => evaluator.evaluate("foo()")).toThrowError( "Error evaluating expression: foo()", ); }); test("enables hooks to change expression", () => { evaluator.hooks.beforeEvaluate.tap("test", (expression) => { return `'foo' == 'bar'`; }); expect(evaluator.evaluate("bar()")).toStrictEqual(false); }); test("ignores props other than value on expression", () => { expect( evaluator.evaluate({ _comment: "hello world", value: true, } as any), ).toStrictEqual(true); }); }); describe("async evaluator", () => { let evaluator: ExpressionEvaluator; let model: any; beforeEach(() => { const bindingParser = new BindingParser(); model = withParser( new LocalModel({}), bindingParser.parse.bind(bindingParser), ); evaluator = new ExpressionEvaluator({ model }); }); test("conditional with sync values works normally", () => { expect(evaluator.evaluate("true ? 'yes' : 'no'")).toBe("yes"); expect(evaluator.evaluate("false ? 'yes' : 'no'")).toBe("no"); }); test("conditional with async test condition returns Promise and resolves correctly", async () => { // Add an async function that returns false evaluator.addExpressionFunction("asyncFalse", async () => { return Promise.resolve(false); }); // Add an async function that returns true evaluator.addExpressionFunction("asyncTrue", async () => { return Promise.resolve(true); }); // Test with async false condition - should return Promise const falseResult = evaluator.evaluateAsync( "await(asyncFalse()) ? 'yes' : 'no'", ); expect(falseResult).toBeInstanceOf(Promise); expect(await falseResult).toBe("no"); // Test with async true condition - should return Promise const trueResult = evaluator.evaluateAsync( "await(asyncTrue()) ? 'yes' : 'no'", ); expect(trueResult).toBeInstanceOf(Promise); expect(await trueResult).toBe("yes"); }); test("conditional with async test condition and async branches", async () => { evaluator.addExpressionFunction("asyncTrue", async () => { return Promise.resolve(true); }); evaluator.addExpressionFunction("asyncValue", async (ctx, val) => { return Promise.resolve(val); }); const result = evaluator.evaluate( "asyncTrue() ? asyncValue('truthy') : asyncValue('falsy')", ); expect(result).toBeInstanceOf(Promise); expect(await result).toBe("truthy"); }); test("Async functions are only called once", async () => { const mockHandler = vitest.fn().mockReturnValue(Promise.resolve(true)); evaluator.addExpressionFunction("asyncTest", mockHandler); const result = evaluator.evaluateAsync("await(asyncTest())"); expect(result).toBeInstanceOf(Promise); expect(await result).toBe(true); expect(mockHandler).toBeCalledTimes(1); }); test("logical operators with async values", async () => { evaluator.addExpressionFunction("asyncTrue", async () => { return Promise.resolve(true); }); evaluator.addExpressionFunction("asyncFalse", async () => { return Promise.resolve(false); }); // Test && with async left side (false) const andFalseResult = evaluator.evaluateAsync( "await(asyncFalse()) && true", ); expect(andFalseResult).toBeInstanceOf(Promise); expect(await andFalseResult).toBe(false); // Test && with async left side (true) const andTrueResult = evaluator.evaluateAsync( "await(asyncTrue()) && 'right-side'", ); expect(andTrueResult).toBeInstanceOf(Promise); expect(await andTrueResult).toBe("right-side"); // Test || with async left side (true) const orTrueResult = evaluator.evaluateAsync( "await(asyncTrue()) || 'should-not-evaluate'", ); expect(orTrueResult).toBeInstanceOf(Promise); expect(await orTrueResult).toBe(true); // Test || with async left side (false) const orFalseResult = evaluator.evaluateAsync( "await(asyncFalse()) || 'right-side'", ); expect(orFalseResult).toBeInstanceOf(Promise); expect(await orFalseResult).toBe("right-side"); }); test("nested conditional with async test", async () => { evaluator.addExpressionFunction("asyncTrue", async () => { return Promise.resolve(true); }); evaluator.addExpressionFunction("asyncFalse", async () => { return Promise.resolve(false); }); const result = evaluator.evaluateAsync( "await(asyncTrue()) ? (await(asyncFalse()) ? 'inner-true' : 'inner-false') : 'outer-false'", ); expect(result).toBeInstanceOf(Promise); expect(await result).toBe("inner-false"); }); test("array of expressions", async () => { evaluator.addExpressionFunction("asyncTrue", async () => { return Promise.resolve(true); }); evaluator.addExpressionFunction("asyncFalse", async () => { return Promise.resolve(false); }); const result = evaluator.evaluateAsync([ "await(asyncTrue()) == true", "await(asyncFalse()) == true", ]); expect(result).toBeInstanceOf(Promise); expect(await result).toBe(false); }); test("Complex async expression with conditionals and logical operators", async () => { evaluator.addExpressionFunction( "delayedValue", async (ctx, value, delay = 1) => { return new Promise((resolve) => { setTimeout(() => resolve(value), Number(delay)); }); }, ); const result = evaluator.evaluateAsync( "await(delayedValue(false, 1)) && await(delayedValue(true, 1)) ? await(delayedValue('should-not-reach', 1)) : await(delayedValue('correct', 1))", ); expect(result).toBeInstanceOf(Promise); expect(await result).toBe("correct"); }); test("Unary not operator with async values", async () => { evaluator.addExpressionFunction("asyncTrue", async () => { return Promise.resolve(true); }); evaluator.addExpressionFunction("asyncFalse", async () => { return Promise.resolve(false); }); // Test !async const notTrueResult = evaluator.evaluateAsync("!await(asyncTrue())"); expect(notTrueResult).toBeInstanceOf(Promise); expect(await notTrueResult).toBe(false); const notFalseResult = evaluator.evaluateAsync("!await(asyncFalse())"); expect(notFalseResult).toBeInstanceOf(Promise); expect(await notFalseResult).toBe(true); }); test("Binary comparison operators with async values", async () => { evaluator.addExpressionFunction("asyncValue", async (ctx, val) => { return Promise.resolve(val); }); // Test equality const eqResult = evaluator.evaluateAsync("await(asyncValue(5)) == 5"); expect(eqResult).toBeInstanceOf(Promise); expect(await eqResult).toBe(true); // Test strict equality const strictEqResult = evaluator.evaluateAsync( "await(asyncValue(5)) === 5", ); expect(strictEqResult).toBeInstanceOf(Promise); expect(await strictEqResult).toBe(true); // Test greater than const gtResult = evaluator.evaluateAsync("await(asyncValue(10)) > 5"); expect(gtResult).toBeInstanceOf(Promise); expect(await gtResult).toBe(true); // Test less than const ltResult = evaluator.evaluateAsync("await(asyncValue(3)) < 5"); expect(ltResult).toBeInstanceOf(Promise); expect(await ltResult).toBe(true); }); test("Array expressions with mixed sync/async values", async () => { evaluator.addExpressionFunction("asyncValue", async (ctx, val) => { return Promise.resolve(val); }); const result = evaluator.evaluateAsync( "[1, await(asyncValue(2)), 3, await(asyncValue(4))]", ); expect(result).toBeInstanceOf(Promise); expect(await result).toEqual([1, 2, 3, 4]); }); test("Object expressions with async keys/values", async () => { evaluator.addExpressionFunction("asyncValue", async (ctx, val) => { return Promise.resolve(val); }); const result = evaluator.evaluateAsync( '{"sync": 1, "async": await(asyncValue(2))}', ); expect(result).toBeInstanceOf(Promise); expect(await result).toEqual({ sync: 1, async: 2 }); }); test("Complex nested async expressions", async () => { evaluator.addExpressionFunction("asyncValue", async (ctx, val) => { return Promise.resolve(val); }); evaluator.addExpressionFunction("asyncTrue", async () => { return Promise.resolve(true); }); // Complex expression combining multiple async features const result = evaluator.evaluateAsync( "await(asyncTrue()) && await(asyncValue(5)) > 3 ? [await(asyncValue(1)), await(asyncValue(2))] : []", ); expect(result).toBeInstanceOf(Promise); expect(await result).toEqual([1, 2]); }); test("Mixed Promise and non-Promise in complex expression", async () => { evaluator.addExpressionFunction("asyncValue", async (ctx, val) => { return Promise.resolve(val); }); // This should handle the case where some parts are async and others are sync const result = evaluator.evaluateAsync( "true && await(asyncValue(false)) ? 'should-not-reach' : 'correct'", ); expect(result).toBeInstanceOf(Promise); expect(await result).toBe("correct"); }); test("conditional function with evaluateAsync vs evaluate", async () => { // Test with sync evaluate model.set([["local.test", "bla"]]); const syncResult = evaluator.evaluate( "conditional(false,{{local.test}}='TRUE',{{local.test}}='FALSE')", { model }, ); expect(syncResult).toBe("FALSE"); expect(model.get("local.test")).toBe("FALSE"); // Reset model model.set([["local.test", "bla"]]); // Test with async evaluateAsync false let asyncResult = await evaluator.evaluateAsync( "conditional(false,{{local.test}}='TRUE',{{local.test}}='FALSE')", { model }, ); expect(asyncResult).toBe("FALSE"); expect(model.get("local.test")).toBe("FALSE"); // Test with async evaluateAsync true asyncResult = await evaluator.evaluateAsync( "conditional(true,{{local.test}}='TRUE',{{local.test}}='FALSE')", { model }, ); expect(asyncResult).toBe("TRUE"); expect(model.get("local.test")).toBe("TRUE"); }); }); describe("Async usage in sync evaluation", () => { let evaluator: ExpressionEvaluator; let model: any; beforeEach(() => { const bindingParser = new BindingParser(); model = withParser( new LocalModel({}), bindingParser.parse.bind(bindingParser), ); evaluator = new ExpressionEvaluator({ model }); }); test("Expect await usage to throw an error", () => { // Add an async function that returns false evaluator.addExpressionFunction("asyncFalse", async () => { return Promise.resolve(false); }); expect(() => evaluator.evaluate("await(asyncFalse())"), ).toThrowErrorMatchingInlineSnapshot( `[NestedError: Error evaluating expression: await(asyncFalse())]`, ); }); }); describe("Undefined behavior warnings", () => { let evaluator: ExpressionEvaluator; let model: any; let mockLogger: Logger; beforeEach(() => { mockLogger = { trace: vitest.fn(), debug: vitest.fn(), info: vitest.fn(), warn: vitest.fn(), error: vitest.fn(), }; const bindingParser = new BindingParser(); model = withParser( new LocalModel({}), bindingParser.parse.bind(bindingParser), ); evaluator = new ExpressionEvaluator({ model, logger: mockLogger }); }); test("Model assignment", () => { // Add an async function that returns false evaluator.addExpressionFunction("asyncFalse", async () => { return Promise.resolve(false); }); evaluator.evaluate("{{some.path}} = asyncFalse()"); expect(mockLogger.warn).toHaveBeenCalledWith( "Unawaited promise written to mode, this behavior is undefined and may change in future releases", ); }); });