Add formula engine: data-driven expression evaluator (Core)
CI / build-test (push) Successful in 1m19s

Keystone for the gene system. A Formula compiles a string from a def
(lexer -> recursive-descent parser -> tree of Func<IFormulaContext,float>)
once, then evaluates allocation-free against a variable context.

Supports + - * / %, comparisons, && || !, ternary, the constants
pi/tau/e, and functions abs sign floor ceil round sqrt exp log sin cos
tan min max pow clamp lerp step. Deterministic and side-effect-free so
gene-effect formulas stay pure. Covered by FormulaTests (parsing,
precedence, functions, logic/ternary, variables, errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 23:26:06 +03:00
co-authored by Claude Opus 4.8
parent 2ac074004a
commit 10898b08a0
7 changed files with 792 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
using MrGameEng.Formulas;
using Xunit;
namespace MrGameEng.Core.Tests;
public class FormulaTests
{
private static IFormulaContext Vars(Dictionary<string, float> values) =>
new DelegateFormulaContext(name =>
values.TryGetValue(name, out var v)
? v
: throw new FormulaException($"unknown '{name}'")
);
[Theory]
[InlineData("1 + 2 * 3", 7f)] // precedence
[InlineData("(1 + 2) * 3", 9f)] // parentheses
[InlineData("10 - 2 - 3", 5f)] // left associativity
[InlineData("-2 + 5", 3f)] // unary minus
[InlineData("2 * -3", -6f)] // unary minus after operator
[InlineData("7 % 3", 1f)] // modulo
[InlineData("2.5 * 4", 10f)] // decimals
public void Evaluate_Arithmetic_RespectsPrecedence(string expr, float expected)
{
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
}
[Theory]
[InlineData("min(3, 5)", 3f)]
[InlineData("max(3, 5)", 5f)]
[InlineData("clamp(12, 0, 10)", 10f)]
[InlineData("clamp(-4, 0, 10)", 0f)]
[InlineData("lerp(0, 10, 0.25)", 2.5f)]
[InlineData("pow(2, 10)", 1024f)]
[InlineData("abs(-7)", 7f)]
[InlineData("floor(3.9)", 3f)]
[InlineData("ceil(3.1)", 4f)]
[InlineData("step(5, 7)", 1f)]
[InlineData("step(5, 2)", 0f)]
public void Evaluate_Functions_ComputeExpected(string expr, float expected)
{
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
}
[Fact]
public void Evaluate_Constants_AreBuiltIn()
{
Assert.Equal(MathF.PI, Formula.Compile("pi").Evaluate(), 5);
Assert.Equal(MathF.Tau, Formula.Compile("tau").Evaluate(), 5);
Assert.Equal(MathF.E, Formula.Compile("e").Evaluate(), 5);
}
[Theory]
[InlineData("2 < 3", 1f)]
[InlineData("3 <= 3", 1f)]
[InlineData("3 > 5", 0f)]
[InlineData("4 == 4", 1f)]
[InlineData("4 != 4", 0f)]
[InlineData("1 && 0", 0f)]
[InlineData("0 || 2", 1f)]
[InlineData("!0", 1f)]
[InlineData("!5", 0f)]
public void Evaluate_LogicAndComparison_YieldBooleansAsOneOrZero(string expr, float expected)
{
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
}
[Theory]
[InlineData("3 > 2 ? 10 : 20", 10f)]
[InlineData("3 < 2 ? 10 : 20", 20f)]
[InlineData("1 ? 2 ? 3 : 4 : 5", 3f)] // nested ternary
public void Evaluate_Ternary_SelectsBranch(string expr, float expected)
{
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
}
[Fact]
public void Evaluate_Variables_ResolvedThroughContext()
{
var ctx = Vars(new() { ["light"] = 0.8f, ["optimal"] = 0.5f });
var formula = Formula.Compile("clamp(1 - abs(light - optimal), 0, 1)");
Assert.Equal(0.7f, formula.Evaluate(ctx), 4);
}
[Fact]
public void Evaluate_SameFormulaTwice_IsDeterministic()
{
var formula = Formula.Compile("sin(t) * 2 + 1");
var ctx = Vars(new() { ["t"] = 1.234f });
Assert.Equal(formula.Evaluate(ctx), formula.Evaluate(ctx), 6);
}
[Theory]
[InlineData("1 +")] // dangling operator
[InlineData("(1 + 2")] // unbalanced paren
[InlineData("1 2")] // trailing token
[InlineData("min(1)")] // wrong arity
[InlineData("nope(1)")] // unknown function
[InlineData("@")] // bad character
[InlineData("1 = 2")] // single equals
public void Compile_InvalidExpression_Throws(string expr)
{
Assert.Throws<FormulaException>(() => Formula.Compile(expr));
}
[Fact]
public void Evaluate_UnknownVariableWithoutContext_Throws()
{
var formula = Formula.Compile("x + 1");
Assert.Throws<FormulaException>(() => formula.Evaluate());
}
}