From 44c63901b7ad7b05bc7f826547b02d624e50a19d Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Mon, 1 Dec 2025 23:03:10 +0100 Subject: [PATCH 1/2] feat: Add public IConditionEvaluator interface for standalone condition evaluation Implements #24 - Add IConditionEvaluator interface in TriasDev.Templify.Conditionals namespace - Add ConditionEvaluator implementation wrapping internal ConditionalEvaluator - Support Dictionary, JSON string, and IEvaluationContext inputs - Include sync and async methods (EvaluateAsync wraps sync for API compatibility) - Add CreateContext methods for batch evaluation scenarios - Add null parameter validation with ArgumentNullException - Add 26 unit tests covering all methods and edge cases --- .../ConditionEvaluatorTests.cs | 314 ++++++++++++++++++ .../Conditionals/ConditionEvaluator.cs | 121 +++++++ .../Conditionals/IConditionEvaluator.cs | 138 ++++++++ 3 files changed, 573 insertions(+) create mode 100644 TriasDev.Templify.Tests/ConditionEvaluatorTests.cs create mode 100644 TriasDev.Templify/Conditionals/ConditionEvaluator.cs create mode 100644 TriasDev.Templify/Conditionals/IConditionEvaluator.cs diff --git a/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs b/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs new file mode 100644 index 0000000..ccee2e4 --- /dev/null +++ b/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs @@ -0,0 +1,314 @@ +// Copyright (c) 2025 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using TriasDev.Templify.Conditionals; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests; + +/// +/// Tests for the public class. +/// +public class ConditionEvaluatorTests +{ + private readonly ConditionEvaluator _evaluator = new(); + + #region Evaluate with Dictionary + + [Fact] + public void Evaluate_WithTrueBoolean_ReturnsTrue() + { + Dictionary data = new() { ["IsActive"] = true }; + + bool result = _evaluator.Evaluate("IsActive", data); + + Assert.True(result); + } + + [Fact] + public void Evaluate_WithFalseBoolean_ReturnsFalse() + { + Dictionary data = new() { ["IsActive"] = false }; + + bool result = _evaluator.Evaluate("IsActive", data); + + Assert.False(result); + } + + [Fact] + public void Evaluate_WithMissingVariable_ReturnsFalse() + { + Dictionary data = new(); + + bool result = _evaluator.Evaluate("MissingVar", data); + + Assert.False(result); + } + + [Fact] + public void Evaluate_WithComparison_ReturnsCorrectResult() + { + Dictionary data = new() { ["Count"] = 5 }; + + Assert.True(_evaluator.Evaluate("Count > 3", data)); + Assert.True(_evaluator.Evaluate("Count = 5", data)); + Assert.False(_evaluator.Evaluate("Count < 3", data)); + } + + [Fact] + public void Evaluate_WithStringComparison_ReturnsCorrectResult() + { + Dictionary data = new() { ["Status"] = "Active" }; + + bool result = _evaluator.Evaluate("Status = \"Active\"", data); + + Assert.True(result); + } + + [Fact] + public void Evaluate_WithLogicalOperators_ReturnsCorrectResult() + { + Dictionary data = new() + { + ["IsEnabled"] = true, + ["HasAccess"] = true + }; + + Assert.True(_evaluator.Evaluate("IsEnabled and HasAccess", data)); + Assert.True(_evaluator.Evaluate("IsEnabled or HasAccess", data)); + } + + [Fact] + public void Evaluate_WithNegation_ReturnsCorrectResult() + { + Dictionary data = new() { ["IsDisabled"] = false }; + + bool result = _evaluator.Evaluate("not IsDisabled", data); + + Assert.True(result); + } + + [Fact] + public void Evaluate_WithNestedPath_ReturnsCorrectResult() + { + Dictionary data = new() + { + ["Customer"] = new Dictionary + { + ["Name"] = "John", + ["IsActive"] = true + } + }; + + Assert.True(_evaluator.Evaluate("Customer.IsActive", data)); + Assert.True(_evaluator.Evaluate("Customer.Name = \"John\"", data)); + } + + #endregion + + #region Evaluate with JSON + + [Fact] + public void Evaluate_WithJsonData_ReturnsCorrectResult() + { + string json = """{"IsActive": true, "Count": 5}"""; + + Assert.True(_evaluator.Evaluate("IsActive", json)); + Assert.True(_evaluator.Evaluate("Count > 3", json)); + } + + [Fact] + public void Evaluate_WithComplexJsonData_ReturnsCorrectResult() + { + string json = """ + { + "Customer": { + "Name": "John", + "IsActive": true + }, + "Status": "Active" + } + """; + + Assert.True(_evaluator.Evaluate("Customer.IsActive", json)); + Assert.True(_evaluator.Evaluate("Status = \"Active\"", json)); + } + + [Fact] + public void Evaluate_WithInvalidJson_ThrowsJsonException() + { + string invalidJson = "{ invalid json }"; + + Assert.Throws(() => _evaluator.Evaluate("IsActive", invalidJson)); + } + + [Fact] + public void Evaluate_WithJsonArray_ThrowsJsonException() + { + string jsonArray = "[1, 2, 3]"; + + Assert.Throws(() => _evaluator.Evaluate("IsActive", jsonArray)); + } + + #endregion + + #region EvaluateAsync + + [Fact] + public async Task EvaluateAsync_WithDictionary_ReturnsCorrectResult() + { + Dictionary data = new() { ["IsActive"] = true }; + + bool result = await _evaluator.EvaluateAsync("IsActive", data); + + Assert.True(result); + } + + [Fact] + public async Task EvaluateAsync_WithJson_ReturnsCorrectResult() + { + string json = """{"IsActive": true}"""; + + bool result = await _evaluator.EvaluateAsync("IsActive", json); + + Assert.True(result); + } + + [Fact] + public async Task EvaluateAsync_WithContext_ReturnsCorrectResult() + { + Dictionary data = new() { ["IsActive"] = true }; + IEvaluationContext context = _evaluator.CreateContext(data); + + bool result = await _evaluator.EvaluateAsync("IsActive", context); + + Assert.True(result); + } + + #endregion + + #region CreateContext + + [Fact] + public void CreateContext_WithDictionary_ReturnsValidContext() + { + Dictionary data = new() + { + ["Name"] = "Test", + ["Count"] = 42 + }; + + IEvaluationContext context = _evaluator.CreateContext(data); + + Assert.NotNull(context); + Assert.True(context.TryResolveVariable("Name", out object? name)); + Assert.Equal("Test", name); + Assert.True(context.TryResolveVariable("Count", out object? count)); + Assert.Equal(42, count); + } + + [Fact] + public void CreateContext_WithJson_ReturnsValidContext() + { + string json = """{"Name": "Test", "Count": 42}"""; + + IEvaluationContext context = _evaluator.CreateContext(json); + + Assert.NotNull(context); + Assert.True(context.TryResolveVariable("Name", out object? name)); + Assert.Equal("Test", name); + } + + [Fact] + public void CreateContext_AllowsBatchEvaluation() + { + Dictionary data = new() + { + ["IsActive"] = true, + ["Count"] = 5, + ["Status"] = "Active" + }; + + IEvaluationContext context = _evaluator.CreateContext(data); + + Assert.True(_evaluator.Evaluate("IsActive", context)); + Assert.True(_evaluator.Evaluate("Count > 3", context)); + Assert.True(_evaluator.Evaluate("Status = \"Active\"", context)); + } + + #endregion + + #region Evaluate with Context + + [Fact] + public void Evaluate_WithContext_ReturnsCorrectResult() + { + Dictionary data = new() + { + ["IsEnabled"] = true, + ["Count"] = 10 + }; + IEvaluationContext context = _evaluator.CreateContext(data); + + Assert.True(_evaluator.Evaluate("IsEnabled", context)); + Assert.True(_evaluator.Evaluate("Count > 5", context)); + Assert.True(_evaluator.Evaluate("IsEnabled and Count > 5", context)); + } + + #endregion + + #region Interface Implementation + + [Fact] + public void ConditionEvaluator_ImplementsIConditionEvaluator() + { + IConditionEvaluator evaluator = new ConditionEvaluator(); + + Assert.NotNull(evaluator); + } + + #endregion + + #region Null Parameter Validation + + [Fact] + public void Evaluate_WithNullExpression_ThrowsArgumentNullException() + { + Dictionary data = new() { ["Key"] = "Value" }; + + Assert.Throws(() => _evaluator.Evaluate(null!, data)); + } + + [Fact] + public void Evaluate_WithNullDictionary_ThrowsArgumentNullException() + { + Assert.Throws(() => _evaluator.Evaluate("IsActive", (Dictionary)null!)); + } + + [Fact] + public void Evaluate_WithNullJsonData_ThrowsArgumentNullException() + { + Assert.Throws(() => _evaluator.Evaluate("IsActive", (string)null!)); + } + + [Fact] + public void Evaluate_WithNullContext_ThrowsArgumentNullException() + { + Assert.Throws(() => _evaluator.Evaluate("IsActive", (IEvaluationContext)null!)); + } + + [Fact] + public void CreateContext_WithNullDictionary_ThrowsArgumentNullException() + { + Assert.Throws(() => _evaluator.CreateContext((Dictionary)null!)); + } + + [Fact] + public void CreateContext_WithNullJsonData_ThrowsArgumentNullException() + { + Assert.Throws(() => _evaluator.CreateContext((string)null!)); + } + + #endregion +} diff --git a/TriasDev.Templify/Conditionals/ConditionEvaluator.cs b/TriasDev.Templify/Conditionals/ConditionEvaluator.cs new file mode 100644 index 0000000..27dbc97 --- /dev/null +++ b/TriasDev.Templify/Conditionals/ConditionEvaluator.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2025 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Core; +using TriasDev.Templify.Utilities; + +namespace TriasDev.Templify.Conditionals; + +/// +/// Provides standalone condition evaluation functionality. +/// +/// +/// +/// This class exposes the condition evaluation engine used by Templify templates +/// for use in standalone scenarios without Word document processing. +/// +/// +/// Thread Safety: This class is thread-safe. The underlying evaluator has no mutable +/// instance state (only immutable constants), so multiple threads can call Evaluate +/// concurrently without synchronization. +/// +/// +/// +/// Basic usage: +/// +/// var evaluator = new ConditionEvaluator(); +/// var data = new Dictionary<string, object> +/// { +/// ["IsActive"] = true, +/// ["Count"] = 5, +/// ["Status"] = "Active" +/// }; +/// +/// bool result1 = evaluator.Evaluate("IsActive", data); // true +/// bool result2 = evaluator.Evaluate("Count > 3", data); // true +/// bool result3 = evaluator.Evaluate("Status = \"Active\"", data); // true +/// +/// +/// +/// Using JSON data: +/// +/// var evaluator = new ConditionEvaluator(); +/// string json = """{"IsActive": true, "Count": 5}"""; +/// +/// bool result = evaluator.Evaluate("IsActive and Count > 0", json); +/// +/// +/// +/// Batch evaluation with context: +/// +/// var evaluator = new ConditionEvaluator(); +/// var context = evaluator.CreateContext(data); +/// +/// // Evaluate multiple expressions efficiently +/// bool r1 = evaluator.Evaluate("Condition1", context); +/// bool r2 = evaluator.Evaluate("Condition2", context); +/// bool r3 = evaluator.Evaluate("Condition3", context); +/// +/// +public sealed class ConditionEvaluator : IConditionEvaluator +{ + private readonly ConditionalEvaluator _evaluator = new(); + + /// + public bool Evaluate(string expression, Dictionary data) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(data); + return _evaluator.Evaluate(expression, data); + } + + /// + public bool Evaluate(string expression, string jsonData) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(jsonData); + Dictionary data = JsonDataParser.ParseJsonToDataDictionary(jsonData); + return _evaluator.Evaluate(expression, data); + } + + /// + public bool Evaluate(string expression, IEvaluationContext context) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(context); + return _evaluator.Evaluate(expression, context); + } + + /// + public Task EvaluateAsync(string expression, Dictionary data) + { + return Task.FromResult(Evaluate(expression, data)); + } + + /// + public Task EvaluateAsync(string expression, string jsonData) + { + return Task.FromResult(Evaluate(expression, jsonData)); + } + + /// + public Task EvaluateAsync(string expression, IEvaluationContext context) + { + return Task.FromResult(Evaluate(expression, context)); + } + + /// + public IEvaluationContext CreateContext(Dictionary data) + { + ArgumentNullException.ThrowIfNull(data); + return new GlobalEvaluationContext(data); + } + + /// + public IEvaluationContext CreateContext(string jsonData) + { + ArgumentNullException.ThrowIfNull(jsonData); + Dictionary data = JsonDataParser.ParseJsonToDataDictionary(jsonData); + return new GlobalEvaluationContext(data); + } +} diff --git a/TriasDev.Templify/Conditionals/IConditionEvaluator.cs b/TriasDev.Templify/Conditionals/IConditionEvaluator.cs new file mode 100644 index 0000000..f6fa3dc --- /dev/null +++ b/TriasDev.Templify/Conditionals/IConditionEvaluator.cs @@ -0,0 +1,138 @@ +// Copyright (c) 2025 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Conditionals; + +/// +/// Provides standalone condition evaluation functionality. +/// +/// +/// +/// This interface exposes the condition evaluation engine used by Templify templates +/// for use in standalone scenarios without Word document processing. +/// +/// +/// Supported operators: =, !=, >, <, >=, <=, and, or, not +/// +/// +/// Examples: +/// +/// var evaluator = new ConditionEvaluator(); +/// +/// // Simple variable check +/// evaluator.Evaluate("IsActive", data); // true if IsActive is truthy +/// +/// // Comparison +/// evaluator.Evaluate("Status = \"Active\"", data); +/// +/// // Complex expression +/// evaluator.Evaluate("Count > 0 and IsEnabled", data); +/// +/// +/// +public interface IConditionEvaluator +{ + #region Evaluate (Synchronous) + + /// + /// Evaluates a conditional expression against the provided data. + /// + /// + /// The expression to evaluate. Supports: + /// + /// Simple variables: "IsActive" + /// Nested paths: "Customer.Address.City" + /// Comparisons: "Status = \"Active\"", "Count > 0" + /// Logical operators: "IsEnabled and HasAccess", "A or B" + /// Negation: "not IsDisabled" + /// + /// + /// The data dictionary containing variables to resolve. + /// True if the condition is met; otherwise, false. + /// Thrown when or is null. + bool Evaluate(string expression, Dictionary data); + + /// + /// Evaluates a conditional expression against JSON data. + /// + /// The expression to evaluate. + /// A JSON string representing the data object. + /// True if the condition is met; otherwise, false. + /// Thrown when or is null. + /// Thrown when JSON is invalid or root is not an object. + bool Evaluate(string expression, string jsonData); + + /// + /// Evaluates a conditional expression using a pre-created evaluation context. + /// + /// The expression to evaluate. + /// The evaluation context containing variable data. + /// True if the condition is met; otherwise, false. + /// + /// Use this overload with a context from + /// for optimal performance when evaluating multiple expressions against the same data. + /// + /// Thrown when or is null. + bool Evaluate(string expression, IEvaluationContext context); + + #endregion + + #region EvaluateAsync (Asynchronous) + + /// + /// Asynchronously evaluates a conditional expression against the provided data. + /// + /// The expression to evaluate. + /// The data dictionary containing variables to resolve. + /// A task that resolves to true if the condition is met; otherwise, false. + /// Thrown when or is null. + Task EvaluateAsync(string expression, Dictionary data); + + /// + /// Asynchronously evaluates a conditional expression against JSON data. + /// + /// The expression to evaluate. + /// A JSON string representing the data object. + /// A task that resolves to true if the condition is met; otherwise, false. + /// Thrown when or is null. + /// Thrown when JSON is invalid or root is not an object. + Task EvaluateAsync(string expression, string jsonData); + + /// + /// Asynchronously evaluates a conditional expression using a pre-created evaluation context. + /// + /// The expression to evaluate. + /// The evaluation context containing variable data. + /// A task that resolves to true if the condition is met; otherwise, false. + /// Thrown when or is null. + Task EvaluateAsync(string expression, IEvaluationContext context); + + #endregion + + #region CreateContext + + /// + /// Creates an evaluation context from a data dictionary for batch evaluations. + /// + /// The data dictionary containing variables to resolve. + /// An evaluation context that can be reused for multiple evaluations. + /// + /// Use this method when evaluating multiple expressions against the same data + /// to avoid repeated context creation overhead. + /// + /// Thrown when is null. + IEvaluationContext CreateContext(Dictionary data); + + /// + /// Creates an evaluation context from JSON data for batch evaluations. + /// + /// A JSON string representing the data object. + /// An evaluation context that can be reused for multiple evaluations. + /// Thrown when is null. + /// Thrown when JSON is invalid or root is not an object. + IEvaluationContext CreateContext(string jsonData); + + #endregion +} From 278722f67508cef0d6469b59a9826d1e1cb37be0 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Mon, 1 Dec 2025 23:22:21 +0100 Subject: [PATCH 2/2] refactor: Address PR review feedback - Add CancellationToken parameter to all EvaluateAsync methods - Add cancellationToken.ThrowIfCancellationRequested() in implementation - Add null validation tests for EvaluateAsync methods - Add cancellation token test for EvaluateAsync --- .../ConditionEvaluatorTests.cs | 35 +++++++++++++++++++ .../Conditionals/ConditionEvaluator.cs | 9 +++-- .../Conditionals/IConditionEvaluator.cs | 12 +++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs b/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs index ccee2e4..9e0a574 100644 --- a/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs +++ b/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs @@ -310,5 +310,40 @@ public void CreateContext_WithNullJsonData_ThrowsArgumentNullException() Assert.Throws(() => _evaluator.CreateContext((string)null!)); } + [Fact] + public async Task EvaluateAsync_WithNullExpression_ThrowsArgumentNullException() + { + Dictionary data = new() { ["Key"] = "Value" }; + + await Assert.ThrowsAsync(() => _evaluator.EvaluateAsync(null!, data)); + } + + [Fact] + public async Task EvaluateAsync_WithNullDictionary_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync(() => _evaluator.EvaluateAsync("IsActive", (Dictionary)null!)); + } + + [Fact] + public async Task EvaluateAsync_WithNullJsonData_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync(() => _evaluator.EvaluateAsync("IsActive", (string)null!)); + } + + [Fact] + public async Task EvaluateAsync_WithNullContext_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync(() => _evaluator.EvaluateAsync("IsActive", (IEvaluationContext)null!)); + } + + [Fact] + public async Task EvaluateAsync_WithCancelledToken_ThrowsOperationCanceledException() + { + Dictionary data = new() { ["IsActive"] = true }; + CancellationToken cancelledToken = new(canceled: true); + + await Assert.ThrowsAsync(() => _evaluator.EvaluateAsync("IsActive", data, cancelledToken)); + } + #endregion } diff --git a/TriasDev.Templify/Conditionals/ConditionEvaluator.cs b/TriasDev.Templify/Conditionals/ConditionEvaluator.cs index 27dbc97..5f251a0 100644 --- a/TriasDev.Templify/Conditionals/ConditionEvaluator.cs +++ b/TriasDev.Templify/Conditionals/ConditionEvaluator.cs @@ -87,20 +87,23 @@ public bool Evaluate(string expression, IEvaluationContext context) } /// - public Task EvaluateAsync(string expression, Dictionary data) + public Task EvaluateAsync(string expression, Dictionary data, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(Evaluate(expression, data)); } /// - public Task EvaluateAsync(string expression, string jsonData) + public Task EvaluateAsync(string expression, string jsonData, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(Evaluate(expression, jsonData)); } /// - public Task EvaluateAsync(string expression, IEvaluationContext context) + public Task EvaluateAsync(string expression, IEvaluationContext context, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(Evaluate(expression, context)); } diff --git a/TriasDev.Templify/Conditionals/IConditionEvaluator.cs b/TriasDev.Templify/Conditionals/IConditionEvaluator.cs index f6fa3dc..696971e 100644 --- a/TriasDev.Templify/Conditionals/IConditionEvaluator.cs +++ b/TriasDev.Templify/Conditionals/IConditionEvaluator.cs @@ -86,28 +86,34 @@ public interface IConditionEvaluator /// /// The expression to evaluate. /// The data dictionary containing variables to resolve. + /// A token to cancel the operation. /// A task that resolves to true if the condition is met; otherwise, false. /// Thrown when or is null. - Task EvaluateAsync(string expression, Dictionary data); + /// Thrown when the operation is canceled. + Task EvaluateAsync(string expression, Dictionary data, CancellationToken cancellationToken = default); /// /// Asynchronously evaluates a conditional expression against JSON data. /// /// The expression to evaluate. /// A JSON string representing the data object. + /// A token to cancel the operation. /// A task that resolves to true if the condition is met; otherwise, false. /// Thrown when or is null. /// Thrown when JSON is invalid or root is not an object. - Task EvaluateAsync(string expression, string jsonData); + /// Thrown when the operation is canceled. + Task EvaluateAsync(string expression, string jsonData, CancellationToken cancellationToken = default); /// /// Asynchronously evaluates a conditional expression using a pre-created evaluation context. /// /// The expression to evaluate. /// The evaluation context containing variable data. + /// A token to cancel the operation. /// A task that resolves to true if the condition is met; otherwise, false. /// Thrown when or is null. - Task EvaluateAsync(string expression, IEvaluationContext context); + /// Thrown when the operation is canceled. + Task EvaluateAsync(string expression, IEvaluationContext context, CancellationToken cancellationToken = default); #endregion