diff --git a/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs b/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs new file mode 100644 index 0000000..9e0a574 --- /dev/null +++ b/TriasDev.Templify.Tests/ConditionEvaluatorTests.cs @@ -0,0 +1,349 @@ +// 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!)); + } + + [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 new file mode 100644 index 0000000..5f251a0 --- /dev/null +++ b/TriasDev.Templify/Conditionals/ConditionEvaluator.cs @@ -0,0 +1,124 @@ +// 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, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Evaluate(expression, data)); + } + + /// + 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, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + 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..696971e --- /dev/null +++ b/TriasDev.Templify/Conditionals/IConditionEvaluator.cs @@ -0,0 +1,144 @@ +// 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 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 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. + /// 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. + /// Thrown when the operation is canceled. + Task EvaluateAsync(string expression, IEvaluationContext context, CancellationToken cancellationToken = default); + + #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 +}