using MrGameEng.Formulas; using Xunit; namespace MrGameEng.Core.Tests; public class FormulaTests { private static IFormulaContext Vars(Dictionary 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(() => Formula.Compile(expr)); } [Fact] public void Evaluate_UnknownVariableWithoutContext_Throws() { var formula = Formula.Compile("x + 1"); Assert.Throws(() => formula.Evaluate()); } // --- Group functions (regex aggregation over matching variables) --- private sealed class GroupContext(Dictionary values) : IFormulaContext { public float Resolve(string name) => values[name]; public IEnumerable ResolveMatching(Func matches) { foreach (var (name, value) in values) { if (matches(name)) { yield return value; } } } } [Fact] public void Evaluate_GroupFunctions_AggregateMatchingVariables() { var ctx = new GroupContext( new() { ["leaf_a"] = 2f, ["leaf_b"] = 4f, ["leaf_c"] = 6f, ["root_a"] = 100f, } ); Assert.Equal(12f, Formula.Compile("gsum('leaf_.*')").Evaluate(ctx), 4); // 2+4+6 Assert.Equal(3f, Formula.Compile("gcount('leaf_.*')").Evaluate(ctx), 4); Assert.Equal(4f, Formula.Compile("gavg('leaf_.*')").Evaluate(ctx), 4); Assert.Equal(2f, Formula.Compile("gmin('leaf_.*')").Evaluate(ctx), 4); Assert.Equal(6f, Formula.Compile("gmax('leaf_.*')").Evaluate(ctx), 4); } [Fact] public void Evaluate_GroupFunction_ComposesWithArithmetic() { var ctx = new GroupContext(new() { ["g1"] = 3f, ["g2"] = 5f }); Assert.Equal(16f, Formula.Compile("gsum('g.*') * 2").Evaluate(ctx), 4); // (3+5)*2 } [Fact] public void Evaluate_GroupFunction_NoMatches_IsZero() { var ctx = new GroupContext(new() { ["x"] = 1f }); Assert.Equal(0f, Formula.Compile("gsum('none_.*')").Evaluate(ctx), 4); Assert.Equal(0f, Formula.Compile("gavg('none_.*')").Evaluate(ctx), 4); } [Theory] [InlineData("gsum(leaf)")] // pattern must be a quoted string [InlineData("gsum('[')")] // invalid regex [InlineData("gsum('a' 'b')")] // extra token public void Compile_BadGroupCall_Throws(string expr) { Assert.Throws(() => Formula.Compile(expr)); } }