Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ea53b6b
docs(conditionals): design spec for extensible operator engine (#128)
vaceslav Jul 20, 2026
182f3ba
docs(conditionals): revise spec to unify both condition engines (#128)
vaceslav Jul 20, 2026
cfc3370
docs(conditionals): unify operator precedence to standard and>or (#128)
vaceslav Jul 20, 2026
b214fb7
docs(conditionals): implementation plan for extensible operator engin…
vaceslav Jul 20, 2026
c79ff6a
feat(conditionals): add AST nodes, fixity enum, value helpers (#128)
vaceslav Jul 20, 2026
5180b3c
feat(conditionals): add condition lexer (#128)
vaceslav Jul 20, 2026
75fa10a
feat(conditionals): add operator contract, dialects, evaluator core, …
vaceslav Jul 20, 2026
c680b3f
feat(conditionals): add Pratt parser with grouping and list literals …
vaceslav Jul 20, 2026
0e873af
refactor(conditionals): route {{#if}} evaluation through unified engi…
vaceslav Jul 20, 2026
17a7d97
feat(conditionals): add 'in' membership operator (#128)
vaceslav Jul 20, 2026
90ebaab
feat(conditionals): add contains/startswith/endswith operators (#128)
vaceslav Jul 20, 2026
5ec14cb
feat(conditionals): add exists/is empty/is not empty operators (#128)
vaceslav Jul 20, 2026
91e4c14
fix(conditionals): exempt postfix exists/is empty from trailing-opera…
vaceslav Jul 20, 2026
cc0e531
refactor(conditionals): migrate inline {{(...)}} to unified engine, r…
vaceslav Jul 20, 2026
e88453e
test(conditionals): end-to-end tests for new operators (#128)
vaceslav Jul 20, 2026
284afc9
docs(conditionals): document new operators and grouping (#128)
vaceslav Jul 20, 2026
c17290c
style: apply dotnet format (#128)
vaceslav Jul 20, 2026
2de83c9
fix(conditionals): parser-based Validate + characterize inline compat…
vaceslav Jul 20, 2026
b169de3
fix(conditionals): strict variable resolution for in/string ops + inl…
vaceslav Jul 21, 2026
6f7c816
fix(conditionals): restore single-quoted string literals for inline e…
vaceslav Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions TriasDev.Templify.Tests/Engine/CompatEdgeCharacterizationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 TriasDev GmbH & Co. KG
// Licensed under the MIT License. See LICENSE file in the project root for full license information.

using TriasDev.Templify.Conditionals;
using TriasDev.Templify.Conditionals.Engine;
using TriasDev.Templify.Core;

namespace TriasDev.Templify.Tests.Engine;

/// <summary>
/// Characterization tests that pin the CURRENT (post-migration) behavior of edge cases where the new
/// expression engine intentionally differs from the legacy <c>ConditionalEvaluator</c>. These are
/// accepted, deliberate changes (F2/F3/F4); the tests exist to lock the behavior against regressions.
/// </summary>
public class CompatEdgeCharacterizationTests
{
private static bool EvalInline(string expr, Dictionary<string, object> data)
{
IReadOnlyList<ConditionToken> tokens = new ConditionLexer().Tokenize(expr);
ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens);
return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), InlineConditionDialect.Instance)
.EvaluateBool(node);
}

// F2: In the inline dialect a variable-to-variable comparison now resolves the RHS as a variable
// (rather than treating it as an opaque literal). This is an intentional change from the legacy engine.
[Fact]
public void F2_Inline_VariableToVariableEquality_ResolvesRhs_IsTrue()
{
Assert.True(EvalInline("(A = B)", new() { ["A"] = "x", ["B"] = "x" }));
}

// F3: In the inline dialect, an ordered comparison between incomparable operands yields false
// (IComparable.CompareTo throws for the mismatched types, and TryCompare maps that to false).
// Intentional change: incomparable => false rather than an error.
[Fact]
public void F3_Inline_IncomparableGreaterOrEqual_IsFalse()
{
Assert.False(EvalInline("(Amount >= \"abc\")", new() { ["Amount"] = 10 }));
}

// F4: The operator keywords are reserved. A quoted literal still compares as a string...
[Fact]
public void F4_Default_QuotedReservedWord_ComparesAsLiteral_IsTrue()
{
Assert.True(new ConditionEvaluator().Evaluate(
"Category = \"empty\"", new Dictionary<string, object> { ["Category"] = "empty" }));
}

// ...but the same word UNQUOTED is parsed as the reserved `empty` keyword, which is not a valid
// right-hand operand — so the expression fails to parse and Evaluate returns false. Intentional
// change: unquoted reserved words are no longer treated as bareword string literals.
[Fact]
public void F4_Default_UnquotedReservedWord_IsNotLiteral_IsFalse()
{
Assert.False(new ConditionEvaluator().Evaluate(
"Category = empty", new Dictionary<string, object> { ["Category"] = "empty" }));
}
}
50 changes: 50 additions & 0 deletions TriasDev.Templify.Tests/Engine/ConditionEvaluatorCoreTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2026 TriasDev GmbH & Co. KG
// Licensed under the MIT License. See LICENSE file in the project root for full license information.

using TriasDev.Templify.Conditionals.Engine;
using TriasDev.Templify.Core;

namespace TriasDev.Templify.Tests.Engine;

public class ConditionEvaluatorCoreTests
{
private static ConditionEvaluatorCore Default(Dictionary<string, object> data)
=> new(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance);

private static readonly ConditionOperatorRegistry _reg = ConditionOperatorRegistry.Shared;

[Fact]
public void Equal_MatchingStrings_IsTrue()
{
var node = new OperatorNode(_reg.FindInfix("=")!,
new ConditionNode[] { new VariableNode("Status"), new LiteralNode("Active") });
Assert.True(Default(new() { ["Status"] = "Active" }).EvaluateBool(node));
}

[Fact]
public void And_ShortCircuits_And_Combines()
{
var left = new OperatorNode(_reg.FindInfix(">")!,
new ConditionNode[] { new VariableNode("Count"), new LiteralNode(0) });
var node = new OperatorNode(_reg.FindInfix("and")!,
new ConditionNode[] { left, new VariableNode("IsOn") });
Assert.True(Default(new() { ["Count"] = 5, ["IsOn"] = true }).EvaluateBool(node));
Assert.False(Default(new() { ["Count"] = 0, ["IsOn"] = true }).EvaluateBool(node));
}

[Fact]
public void Not_NegatesOperand()
{
var node = new OperatorNode(_reg.FindPrefix("not")!,
new ConditionNode[] { new VariableNode("IsOff") });
Assert.True(Default(new() { ["IsOff"] = false }).EvaluateBool(node));
}

[Fact]
public void Greater_UsesNumericComparison()
{
var node = new OperatorNode(_reg.FindInfix(">")!,
new ConditionNode[] { new VariableNode("Count"), new LiteralNode(3) });
Assert.True(Default(new() { ["Count"] = 5 }).EvaluateBool(node));
}
}
71 changes: 71 additions & 0 deletions TriasDev.Templify.Tests/Engine/ConditionLexerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) 2026 TriasDev GmbH & Co. KG
// Licensed under the MIT License. See LICENSE file in the project root for full license information.

using TriasDev.Templify.Conditionals.Engine;

namespace TriasDev.Templify.Tests.Engine;

public class ConditionLexerTests
{
private static List<ConditionToken> Lex(string s) => new ConditionLexer().Tokenize(s).ToList();

[Fact]
public void Tokenize_Comparison_ProducesIdentifierOperatorString()
{
List<ConditionToken> t = Lex("Status = \"Active\"");
Assert.Equal(ConditionTokenType.Identifier, t[0].Type);
Assert.Equal("Status", t[0].Text);
Assert.Equal(ConditionTokenType.Operator, t[1].Type);
Assert.Equal("=", t[1].Text);
Assert.Equal(ConditionTokenType.String, t[2].Type);
Assert.Equal("Active", t[2].Text);
Assert.Equal(ConditionTokenType.End, t[3].Type);
}

[Fact]
public void Tokenize_GreaterOrEqual_MatchesLongestOperator()
{
List<ConditionToken> t = Lex("Count >= 3");
Assert.Equal(ConditionTokenType.Operator, t[1].Type);
Assert.Equal(">=", t[1].Text);
Assert.Equal(ConditionTokenType.Number, t[2].Type);
Assert.Equal(3, t[2].LiteralValue);
}

[Fact]
public void Tokenize_ListLiteral_ProducesParensAndCommas()
{
List<ConditionToken> t = Lex("Status in (\"A\", \"B\")");
Assert.Equal("in", t[1].Text);
Assert.Equal(ConditionTokenType.LParen, t[2].Type);
Assert.Equal(ConditionTokenType.String, t[3].Type);
Assert.Equal(ConditionTokenType.Comma, t[4].Type);
Assert.Equal(ConditionTokenType.RParen, t[6].Type);
}

[Fact]
public void Tokenize_WordOperators_AreLowercasedOperators()
{
List<ConditionToken> t = Lex("A AND B IS EMPTY");
Assert.Equal(ConditionTokenType.Operator, t[1].Type);
Assert.Equal("and", t[1].Text);
Assert.Equal("is", t[3].Text);
Assert.Equal("empty", t[4].Text);
}

[Fact]
public void Tokenize_CurlyQuotes_AreNormalized()
{
List<ConditionToken> t = Lex("Status = “Active”");
Assert.Equal(ConditionTokenType.String, t[2].Type);
Assert.Equal("Active", t[2].Text);
}

[Fact]
public void Tokenize_BooleanAndNull_ProduceLiteralTokens()
{
List<ConditionToken> t = Lex("Flag = true");
Assert.Equal(ConditionTokenType.Boolean, t[2].Type);
Assert.Equal(true, t[2].LiteralValue);
}
}
48 changes: 48 additions & 0 deletions TriasDev.Templify.Tests/Engine/ConditionParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) 2026 TriasDev GmbH & Co. KG
// Licensed under the MIT License. See LICENSE file in the project root for full license information.

using TriasDev.Templify.Conditionals.Engine;
using TriasDev.Templify.Core;

namespace TriasDev.Templify.Tests.Engine;

public class ConditionParserTests
{
private static bool Eval(string expr, Dictionary<string, object> data)
{
var tokens = new ConditionLexer().Tokenize(expr);
ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens);
return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node);
}

[Fact]
public void AndBindsTighterThanOr()
{
// false or (true and true) => true ; if or bound tighter, (false or true) and false path differs.
Assert.True(Eval("A or B and C", new() { ["A"] = false, ["B"] = true, ["C"] = true }));
Assert.False(Eval("A or B and C", new() { ["A"] = false, ["B"] = true, ["C"] = false }));
}

[Fact]
public void ParenthesesOverridePrecedence()
{
Assert.False(Eval("(A or B) and C", new() { ["A"] = false, ["B"] = true, ["C"] = false }));
Assert.True(Eval("(A or B) and C", new() { ["A"] = false, ["B"] = true, ["C"] = true }));
}

[Fact]
public void ComparisonBindsTighterThanNot()
{
// not Status = "Active" => not (Status = "Active")
Assert.False(Eval("not Status = \"Active\"", new() { ["Status"] = "Active" }));
Assert.True(Eval("not Status = \"Active\"", new() { ["Status"] = "Inactive" }));
}

[Fact]
public void MalformedExpression_Throws()
{
var tokens = new ConditionLexer().Tokenize("Status =");
Assert.Throws<ConditionParseException>(() =>
new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens));
}
}
29 changes: 29 additions & 0 deletions TriasDev.Templify.Tests/Engine/ConditionValueOpsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) 2026 TriasDev GmbH & Co. KG
// Licensed under the MIT License. See LICENSE file in the project root for full license information.

using TriasDev.Templify.Conditionals.Engine;

namespace TriasDev.Templify.Tests.Engine;

public class ConditionValueOpsTests
{
[Fact]
public void AreEqual_SameStrings_ReturnsTrue()
=> Assert.True(ConditionValueOps.AreEqual("Active", "Active"));

[Fact]
public void AreEqual_DifferentCaseStrings_ReturnsFalse()
=> Assert.False(ConditionValueOps.AreEqual("active", "Active"));

[Fact]
public void AreEqual_BoolAndLowercaseLiteral_ReturnsTrue()
=> Assert.True(ConditionValueOps.AreEqual(true, "true"));

[Fact]
public void AreEqual_BothNull_ReturnsTrue()
=> Assert.True(ConditionValueOps.AreEqual(null, null));

[Fact]
public void AreEqual_OneNull_ReturnsFalse()
=> Assert.False(ConditionValueOps.AreEqual(null, "x"));
}
57 changes: 57 additions & 0 deletions TriasDev.Templify.Tests/Engine/ExistenceOperatorsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) 2026 TriasDev GmbH & Co. KG
// Licensed under the MIT License. See LICENSE file in the project root for full license information.

using System.Collections;
using TriasDev.Templify.Conditionals.Engine;
using TriasDev.Templify.Core;

namespace TriasDev.Templify.Tests.Engine;

public class ExistenceOperatorsTests
{
private static bool Eval(string expr, Dictionary<string, object> data)
{
var tokens = new ConditionLexer().Tokenize(expr);
ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens);
return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node);
}

[Fact]
public void Exists_PresentVariable_IsTrue()
=> Assert.True(Eval("Notes exists", new() { ["Notes"] = "x" }));

[Fact]
public void Exists_MissingVariable_IsFalse()
=> Assert.False(Eval("Notes exists", new()));

[Fact]
public void IsEmpty_EmptyString_IsTrue()
=> Assert.True(Eval("Notes is empty", new() { ["Notes"] = " " }));

[Fact]
public void IsEmpty_EmptyCollection_IsTrue()
=> Assert.True(Eval("Items is empty", new() { ["Items"] = new List<object>() }));

[Fact]
public void IsEmpty_MissingVariable_IsTrue()
=> Assert.True(Eval("Notes is empty", new()));

[Fact]
public void IsNotEmpty_NonEmpty_IsTrue()
=> Assert.True(Eval("Notes is not empty", new() { ["Notes"] = "x" }));

[Fact]
public void PresentButNull_ExistsFalse_IsEmptyTrue()
{
var data = new Dictionary<string, object?> { ["Notes"] = null };
var ctx = new GlobalEvaluationContext(data!);
ConditionNode existsNode = Build("Notes exists");
ConditionNode emptyNode = Build("Notes is empty");
// 'Notes' resolves (key present) → exists true; value null → is empty true.
Assert.True(new ConditionEvaluatorCore(ctx, DefaultConditionDialect.Instance).EvaluateBool(existsNode));
Assert.True(new ConditionEvaluatorCore(ctx, DefaultConditionDialect.Instance).EvaluateBool(emptyNode));
}

private static ConditionNode Build(string expr)
=> new ConditionParser(ConditionOperatorRegistry.Shared).Parse(new ConditionLexer().Tokenize(expr));
}
27 changes: 27 additions & 0 deletions TriasDev.Templify.Tests/Engine/ExistenceValidationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) 2026 TriasDev GmbH & Co. KG
// Licensed under the MIT License. See LICENSE file in the project root for full license information.

using TriasDev.Templify.Conditionals;

namespace TriasDev.Templify.Tests.Engine;

public class ExistenceValidationTests
{
private static bool IsValid(string expression) => new ConditionalEvaluator().Validate(expression).IsValid;

[Fact]
public void Validate_Exists_IsValid()
=> Assert.True(IsValid("Notes exists"));

[Fact]
public void Validate_IsEmpty_IsValid()
=> Assert.True(IsValid("Notes is empty"));

[Fact]
public void Validate_IsNotEmpty_IsValid()
=> Assert.True(IsValid("Notes is not empty"));

[Fact]
public void Validate_ComparisonAndExists_IsValid()
=> Assert.True(IsValid("Status = \"Active\" and Notes exists"));
}
45 changes: 45 additions & 0 deletions TriasDev.Templify.Tests/Engine/InlineDialectTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2026 TriasDev GmbH & Co. KG
// Licensed under the MIT License. See LICENSE file in the project root for full license information.

using TriasDev.Templify.Conditionals.Engine;
using TriasDev.Templify.Core;

namespace TriasDev.Templify.Tests.Engine;

public class InlineDialectTests
{
private static bool Eval(string expr, Dictionary<string, object> data)
{
var tokens = new ConditionLexer().Tokenize(expr);
ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens);
return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), InlineConditionDialect.Instance).EvaluateBool(node);
}

[Fact]
public void And_TwoBooleans()
=> Assert.True(Eval("(var1 and var2)", new() { ["var1"] = true, ["var2"] = true }));

[Fact]
public void Or_TwoBooleans()
=> Assert.True(Eval("(var1 or var2)", new() { ["var1"] = false, ["var2"] = true }));

[Fact]
public void Not_Boolean()
=> Assert.True(Eval("(not IsActive)", new() { ["IsActive"] = false }));

[Fact]
public void Comparison_Numeric()
=> Assert.True(Eval("(Count > 0)", new() { ["Count"] = 5 }));

[Fact]
public void Nested_Grouping()
=> Assert.True(Eval("((var1 or var2) and var3)", new() { ["var1"] = false, ["var2"] = true, ["var3"] = true }));

[Fact]
public void BareStringVariable_IsFalse_InInlineDialect()
=> Assert.False(Eval("(Name)", new() { ["Name"] = "Alice" })); // Inline truthiness: only bool true is true

[Fact]
public void NewOperators_WorkInInlineDialect()
=> Assert.True(Eval("(Status in (\"A\", \"B\"))", new() { ["Status"] = "B" }));
}
Loading
Loading