using MeterVault.Core.Analysis.Virtual; namespace MeterVault.Core.Tests.Analysis; /// /// The formula grammar is user input evaluated on every read, so the parser is the safety boundary (D-26): it /// accepts only numbers, m<id>, unary minus, + - * / and parentheses, reports every rejection /// with a position instead of throwing, and cannot be driven into a stack overflow. Ported from the old /// ExpressionEvaluatorTests; the one deliberate change is that an unknown identifier is now an error, not 0. /// public sealed class FormulaParserTests { private static double Evaluate(string text) => Formula.Parse(text).Evaluate(id => id switch { 1 => 411, 2 => 416, _ => throw new KeyNotFoundException($"m{id}") }); [Theory] [InlineData("1 + 2 * 3", 7)] [InlineData("(1 + 2) * 3", 9)] [InlineData("-5", -5)] [InlineData("--5", 5)] [InlineData("+5", 5)] [InlineData("2 * -3", -6)] [InlineData("-(1 + 2) * 3", -9)] [InlineData("10 / 4", 2.5)] [InlineData("2 - 3 - 4", -5)] // left-associative [InlineData("8 / 4 / 2", 1)] // left-associative [InlineData("0.5 * 4", 2)] [InlineData(".5 + 5.", 5.5)] [InlineData("m1 - m2", -5)] // Netz Einsparung Okt 2022: Haus 411 − Netz 416 [InlineData("m1 + m2", 827)] [InlineData("m1+m2", 827)] [InlineData(" ( m1 ) - m2 ", -5)] public void Evaluates_arithmetic_over_numbers_and_meter_references(string text, double expected) { Assert.Equal(expected, Evaluate(text), 9); } [Fact] public void Numbers_are_invariant_decimals_whatever_the_current_culture() { var previous = Thread.CurrentThread.CurrentCulture; try { Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE"); Assert.Equal(0.25, Formula.Parse("0.25").Evaluate(_ => 0), 12); Assert.Equal(FormulaErrorKind.UnexpectedCharacter, FormulaParser.Parse("0,25").Error!.Kind); } finally { Thread.CurrentThread.CurrentCulture = previous; } } [Theory] [InlineData("1 +", FormulaErrorKind.UnexpectedEnd, 3, null)] [InlineData("(1 + 2", FormulaErrorKind.MissingClosingParenthesis, 0, "(")] [InlineData("m1 + (m2 * (3 - 1)", FormulaErrorKind.MissingClosingParenthesis, 5, "(")] [InlineData("1 2", FormulaErrorKind.UnexpectedToken, 2, "2")] [InlineData("m1 m2", FormulaErrorKind.UnexpectedToken, 3, "m2")] [InlineData(")", FormulaErrorKind.UnexpectedToken, 0, ")")] [InlineData("(m1))", FormulaErrorKind.UnexpectedToken, 4, ")")] [InlineData("m1 * * m2", FormulaErrorKind.UnexpectedToken, 5, "*")] [InlineData("1 % 2", FormulaErrorKind.UnexpectedCharacter, 2, "%")] [InlineData("1.2.3", FormulaErrorKind.InvalidNumber, 0, "1.2.3")] [InlineData(".", FormulaErrorKind.InvalidNumber, 0, ".")] [InlineData("1e3", FormulaErrorKind.UnexpectedToken, 1, "e3")] [InlineData("2m1", FormulaErrorKind.UnexpectedToken, 1, "m1")] [InlineData("m99999999999", FormulaErrorKind.MeterIdOutOfRange, 0, "m99999999999")] public void Rejects_malformed_formulas_with_the_position_and_token(string text, FormulaErrorKind kind, int position, string? token) { var result = FormulaParser.Parse(text); Assert.False(result.Success); Assert.Equal(new FormulaError(kind, position, token), result.Error); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void Empty_text_is_an_error_not_an_exception(string? text) { Assert.Equal(FormulaErrorKind.Empty, FormulaParser.Parse(text).Error!.Kind); } [Theory] [InlineData("unknown + 1", "unknown", 0)] // the old evaluator read this as 1 [InlineData("m1 - n2", "n2", 5)] [InlineData("M1 + m2", "M1", 0)] [InlineData("m1a", "m1a", 0)] [InlineData("m_1", "m_1", 0)] [InlineData("m", "m", 0)] [InlineData("sum(m1)", "sum", 0)] [InlineData("m1 + Hausverbrauch", "Hausverbrauch", 5)] public void Only_m_digits_identifiers_are_meter_references_and_anything_else_is_named(string text, string identifier, int position) { Assert.Equal(new FormulaError(FormulaErrorKind.UnknownIdentifier, position, identifier), FormulaParser.Parse(text).Error); } [Fact] public void Nesting_64_parentheses_deep_is_accepted_and_65_is_rejected_at_the_65th() { static string Nested(int depth) => new string('(', depth) + "m1" + new string(')', depth); Assert.True(FormulaParser.Parse(Nested(FormulaParser.MaxDepth)).Success); var tooDeep = FormulaParser.Parse(Nested(FormulaParser.MaxDepth + 1)); Assert.Equal(new FormulaError(FormulaErrorKind.TooDeep, 64, "("), tooDeep.Error); } [Fact] public void Two_thousand_characters_are_accepted_and_2001_rejected() { var chain = "m1" + string.Concat(Enumerable.Repeat(" + m1", 399)); // 1,997 characters var exactly = chain + " "; Assert.Equal(FormulaParser.MaxLength, exactly.Length); var accepted = FormulaParser.Parse(exactly); Assert.True(accepted.Success); Assert.Equal(400 * 411, accepted.Formula.Evaluate(_ => 411), 6); Assert.Equal(FormulaErrorKind.TooLong, FormulaParser.Parse(exactly + " ").Error!.Kind); } [Fact] public void A_hundred_thousand_open_parentheses_are_rejected_without_overflowing_the_stack() { Assert.Equal(FormulaErrorKind.TooLong, FormulaParser.Parse(new string('(', 100_000)).Error!.Kind); Assert.Equal(FormulaErrorKind.TooDeep, FormulaParser.Parse(new string('(', 1_999)).Error!.Kind); } [Fact] public void Long_operator_chains_and_sign_runs_parse_and_evaluate_without_recursion() { var signs = FormulaParser.Parse(new string('-', 1_999) + "1"); Assert.True(signs.Success); Assert.Equal(-1, signs.Formula.Evaluate(_ => 0)); var product = FormulaParser.Parse("1" + string.Concat(Enumerable.Repeat(" * 1", 499))); Assert.True(product.Success); Assert.Equal(1, product.Formula.Evaluate(_ => 0)); Assert.Equal(product.Formula, Formula.Parse(product.Formula.ToString())); } [Fact] public void Scanning_finds_meter_tokens_even_in_text_that_does_not_parse() { Assert.Equal([2, 5, 12], FormulaParser.ScanMeterIds("m12 + (m5 * m2 +")); Assert.Empty(FormulaParser.ScanMeterIds("1.5 + x2")); Assert.Empty(FormulaParser.ScanMeterIds(null)); } [Fact] public void Rewriting_ids_in_text_keeps_the_users_spacing_and_even_a_syntax_error() { var map = new Dictionary { [1] = 41, [2] = 42 }; Assert.Equal("m41 -( m42 )", FormulaParser.RewriteMeterIds("m1 -( m2 )", id => map[id])); Assert.Equal("m41 + ", FormulaParser.RewriteMeterIds("m1 + ", id => map[id])); Assert.Equal("0.1m99 + xm1", FormulaParser.RewriteMeterIds("0.1m2 + xm1", _ => 99)); // "xm1" names no meter } }