Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 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.Conditionals;

/// <summary>
/// <see cref="ConditionalEvaluator.CollectBareOperandVariables"/> finds the variables evaluated for truthiness on
/// their own.
/// </summary>
public sealed class CollectBareOperandVariablesTests
{
[Theory]
[InlineData("A", "A")]
[InlineData("not A", "A")]
[InlineData("A and B", "A,B")]
[InlineData("A or not B", "A,B")]
[InlineData("(A or B) and not (C and D)", "A,B,C,D")]
[InlineData("A and Status = \"x\"", "A")]
[InlineData("Customer.Name", "Customer.Name")]
[InlineData("Items[0].Flag or [Exists]", "Items[0].Flag,Exists")]
[InlineData("(A and B) = true", "A,B")]
[InlineData("not (A = B)", "")]
[InlineData("A exists", "")]
[InlineData("A is empty or B is not empty", "")]
[InlineData("Status = Active", "")]
[InlineData("A != B", "")]
[InlineData("A > 1", "")]
[InlineData("A in (B, C)", "")]
[InlineData("A contains B", "")]
[InlineData("A startswith B or C endswith D", "")]
[InlineData("true and not null", "")]
[InlineData("\"text\"", "")]
public void CollectsBareOperands(string expression, string expected)
{
IEnumerable<string> paths = ConditionalEvaluator
.CollectBareOperandVariables(ConditionalEvaluator.Parse(expression))
.Select(v => v.Path);

Assert.Equal(expected, string.Join(",", paths));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
// 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.Core;
using TriasDev.Templify.Tests.Helpers;

namespace TriasDev.Templify.Tests.Integration;

/// <summary>
/// <c>ValidateTemplate(template, data)</c> warns (<see cref="ValidationWarningType.MissingConditionVariable"/>) when a
/// condition evaluates a variable that is missing from the data for truthiness on its own. Every case is validated as
/// a Word and as an OpenDocument template, directly and through the <see cref="TemplateProcessor"/> facade.
/// </summary>
public sealed class ValidationMissingConditionVariableTests
{
private static Dictionary<string, object> Data() => new Dictionary<string, object>
{
["A"] = true,
["Status"] = "Active",
["Role"] = "Admin",
["Global"] = "g",
["Items"] = new List<Dictionary<string, object>>
{
new() { ["Title"] = "i1", ["Active"] = true },
new() { ["Title"] = "i2" },
},
["Empty"] = new List<object>(),
};

/// <summary>Validates the paragraphs in both formats and both entry points; returns the Word result.</summary>
private static ValidationResult Validate(Dictionary<string, object>? data, params string[] paragraphs)
{
DocumentBuilder docx = new DocumentBuilder();
OdtDocumentBuilder odt = new OdtDocumentBuilder();
foreach (string paragraph in paragraphs)
{
docx.AddParagraph(paragraph);
odt.AddParagraph(paragraph);
}

return ValidateAll(docx.ToStream().ToArray(), odt.ToBytes(), data);
}

private static ValidationResult ValidateAll(byte[] docxBytes, byte[] odtBytes, Dictionary<string, object>? data)
{
ValidationResult word = data == null
? new DocumentTemplateProcessor().ValidateTemplate(new MemoryStream(docxBytes))
: new DocumentTemplateProcessor().ValidateTemplate(new MemoryStream(docxBytes), data);
ValidationResult[] others = data == null
? new[]
{
new OdtTemplateProcessor().ValidateTemplate(new MemoryStream(odtBytes)),
new TemplateProcessor().ValidateTemplate(new MemoryStream(docxBytes)),
new TemplateProcessor().ValidateTemplate(new MemoryStream(odtBytes)),
}
: new[]
{
new OdtTemplateProcessor().ValidateTemplate(new MemoryStream(odtBytes), data),
new TemplateProcessor().ValidateTemplate(new MemoryStream(docxBytes), data),
new TemplateProcessor().ValidateTemplate(new MemoryStream(odtBytes), data),
};

foreach (ValidationResult other in others)
{
Assert.Equal(Describe(word.Warnings), Describe(other.Warnings));
Assert.Equal(word.Errors.Select(e => e.Message).Order(), other.Errors.Select(e => e.Message).Order());
Assert.Equal(word.MissingVariables, other.MissingVariables);
}

return word;
}

private static List<string> Describe(IEnumerable<ValidationWarning> warnings) =>
warnings.Select(w => $"{w.Type}: {w.Message} @ {w.Location}").ToList();

private static List<ValidationWarning> ConditionWarnings(ValidationResult result) =>
result.Warnings.Where(w => w.Type == ValidationWarningType.MissingConditionVariable).ToList();

public static TheoryData<string, string[], string> WarningCases => new TheoryData<string, string[], string>
{
{ "Missing", new[] { "{{#if Missing}}", "x", "{{/if}}" }, "{{#if Missing}}" },
{ "Missing", new[] { "{{#if not Missing}}", "x", "{{/if}}" }, "{{#if not Missing}}" },
{ "Missing", new[] { "{{#if A and Missing}}", "x", "{{/if}}" }, "{{#if A and Missing}}" },
{ "Missing", new[] { "{{#if Missing or Status = \"Active\"}}", "x", "{{/if}}" }, "{{#if Missing or Status = \"Active\"}}" },
{ "Missing", new[] { "{{#if (A or Missing) and not A}}", "x", "{{/if}}" }, "{{#if (A or Missing) and not A}}" },
{ "Missing", new[] { "{{#if A}}", "a", "{{#elseif Missing}}", "b", "{{/if}}" }, "{{#elseif Missing}}" },
{ "Missing", new[] { "Text {{#if Missing}}x{{/if}} end" }, "{{#if Missing}}" },
{ "Customer.Name", new[] { "{{#if Customer.Name}}", "x", "{{/if}}" }, "{{#if Customer.Name}}" },
};

[Theory]
[MemberData(nameof(WarningCases))]
public void BareMissingOperand_Warns(string variable, string[] paragraphs, string location)
{
ValidationResult result = Validate(Data(), paragraphs);

ValidationWarning warning = Assert.Single(ConditionWarnings(result));
Assert.Contains($"'{variable}'", warning.Message);
Assert.Contains($"Condition '{location[(location.IndexOf(' ') + 1)..^2]}'", warning.Message);
Assert.Equal(location, warning.Location);

// Warning only: no error, no missing variable, still valid.
Assert.True(result.IsValid, string.Join("; ", result.Errors.Select(e => e.Message)));
Assert.Empty(result.MissingVariables);
}

public static TheoryData<string> NoWarningConditions => new TheoryData<string>
{
"A",
"not A",
"A and Status = \"Active\"",
"Missing exists",
"not (Missing exists)",
"Missing is empty",
"Missing is not empty",
"A and Missing is empty",
"Status = Active",
"Missing = \"x\"",
"Missing != Other",
"Role in (\"Admin\", \"Owner\")",
"Missing in (\"Admin\", Other)",
"Missing contains \"x\"",
"Status startswith Missing",
"true",
"not null",
"\"text\"",
"1",
};

[Theory]
[MemberData(nameof(NoWarningConditions))]
public void NonBareOrResolvedOperand_DoesNotWarn(string condition)
{
ValidationResult result = Validate(Data(), $"{{{{#if {condition}}}}}", "x", "{{/if}}");

Assert.Empty(ConditionWarnings(result));
}

[Fact]
public void WithoutData_DoesNotWarn()
{
ValidationResult result = Validate(null, "{{#if Missing}}", "x", "{{/if}}");

Assert.Empty(result.Warnings);
Assert.True(result.IsValid);
}

[Fact]
public void InvalidCondition_IsAnErrorOnly()
{
ValidationResult result = Validate(Data(), "{{#if Missing and}}", "x", "{{/if}}");

Assert.Empty(ConditionWarnings(result));
Assert.Contains(result.Errors, e => e.Type == ValidationErrorType.InvalidConditionalExpression);
}

[Fact]
public void SameConditionTwice_WarnsOnce()
{
ValidationResult result = Validate(
Data(),
"{{#if Missing}}", "x", "{{/if}}",
"{{#if Missing}}", "y", "{{/if}}");

Assert.Single(ConditionWarnings(result));
}

[Fact]
public void SeveralMissingOperands_WarnEach()
{
ValidationResult result = Validate(Data(), "{{#if First or not Second}}", "x", "{{/if}}");

Assert.Equal(
new[] { "First", "Second" },
ConditionWarnings(result).Select(w => w.Message.Contains("'First'") ? "First" : "Second").Order());
}

[Fact]
public void InLoop_ItemPropertiesNamedVariablesMetadataAndGlobals_Resolve()
{
ValidationResult result = Validate(
Data(),
"{{#foreach item in Items}}",
"{{#if item.Active}}a{{/if}} {{#if Active}}b{{/if}} {{#if item}}c{{/if}} {{#if Title and Global}}d{{/if}}",
"{{#if @first}}e{{/if}} {{#if not @last and @index}}f{{/if}} {{#if this}}g{{/if}}",
"{{/foreach}}");

Assert.Empty(ConditionWarnings(result));
Assert.True(result.IsValid, string.Join("; ", result.Errors.Select(e => e.Message)));
}

[Fact]
public void InLoop_MissingItemPropertyOrVariable_Warns()
{
ValidationResult result = Validate(
Data(),
"{{#foreach item in Items}}",
"{{#if item.Nope}}", "a", "{{/if}}",
"{{#if Nope}}", "b", "{{/if}}",
"{{/foreach}}");

Assert.Equal(
new[] { "Condition 'Nope' uses variable 'Nope'", "Condition 'item.Nope' uses variable 'item.Nope'" },
ConditionWarnings(result).Select(w => w.Message[..w.Message.IndexOf(", which", StringComparison.Ordinal)]).Order(StringComparer.Ordinal));
}

[Fact]
public void ItemPropertyOutsideTheLoop_Warns()
{
ValidationResult result = Validate(
Data(),
"{{#foreach Items}}", "{{#if Active}}a{{/if}}", "{{/foreach}}",
"{{#if Active}}", "b", "{{/if}}");

Assert.Single(ConditionWarnings(result));
}

[Fact]
public void InEmptyLoop_IsNotChecked()
{
ValidationResult result = Validate(Data(), "{{#foreach Empty}}", "{{#if Nope}}x{{/if}}", "{{/foreach}}");

Assert.Empty(ConditionWarnings(result));
}

[Fact]
public void InlineExpression_IsNotChecked()
{
ValidationResult result = Validate(Data(), "{{(Missing and A)}} {{(not Missing):yesno}}");

Assert.Empty(ConditionWarnings(result));
}

[Fact]
public void TableRowConditionalAndHeader_Warn()
{
DocumentBuilder docx = new DocumentBuilder();
docx.AddTable(3, 1, (row, _) => row switch
{
0 => "{{#if RowFlag}}",
1 => "x",
_ => "{{/if}}"
});
docx.AddHeader("{{#if HeaderFlag}}h{{/if}}");

OdtDocumentBuilder odt = new OdtDocumentBuilder();
odt.AddTable(new[] { "{{#if RowFlag}}" }, new[] { "x" }, new[] { "{{/if}}" });
odt.AddHeaderParagraph("{{#if HeaderFlag}}h{{/if}}");

ValidationResult result = ValidateAll(docx.ToStream().ToArray(), odt.ToBytes(), Data());

Assert.Equal(2, ConditionWarnings(result).Count);
Assert.Contains(result.Warnings, w => w.Message.Contains("'RowFlag'"));
Assert.Contains(result.Warnings, w => w.Message.Contains("'HeaderFlag'"));
}
}
9 changes: 9 additions & 0 deletions TriasDev.Templify.Tests/Odt/OdtValidationParityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ public sealed class OdtValidationParityTests
{ "inline conditional", new[] { "{{Name}}{{#if Flag}} (flag {{Title}}){{/if}}" } },
{ "nested invalid iteration variable", new[] { "{{#foreach Items}}", "{{#foreach in in Tags}}", "x", "{{/foreach}}", "{{/foreach}}" } },
{ "unmatched loop with body", new[] { "{{Name}} {{Missing}}", "{{#foreach Items}}", "{{Title}} {{Nope}}" } },
{
"missing bare condition operands",
new[]
{
"{{#if Nope}}", "a", "{{#elseif not Other and A}}", "b", "{{/if}}",
"x {{#if Absent or Missing exists}}y{{/if}} {{#if Nope is empty or Status = Nope}}z{{/if}}",
"{{#foreach item in Items}}", "{{#if item.Active and not item.Nope and Title and @first}}", "c", "{{/if}}", "{{/foreach}}",
}
},
{ "nested unmatched conditional in loop", new[] { "{{#foreach Items}}", "{{#if Active}}", "{{Title}}", "{{/foreach}}" } },
};

Expand Down
4 changes: 3 additions & 1 deletion TriasDev.Templify/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,9 @@ expression string
- **`Core/TemplateValidator.cs`** (`ValidateTemplate`) walks body, headers/footers and notes: it reports unmatched
markers and invalid conditions (`ValidationError`), collects `AllPlaceholders` (condition variables are taken from the
AST), and with data reports `MissingVariables` plus warnings (`EmptyLoopCollection` when
`WarnOnEmptyLoopCollections` is on, `ReservedWordAsVariable`). Missing variables are checked by
`WarnOnEmptyLoopCollections` is on, `ReservedWordAsVariable`, and `MissingConditionVariable` for bare condition
operands missing from the data, found in the AST by `ConditionalEvaluator.CollectBareOperandVariables` and checked
in loop scope by `Core/MissingConditionVariableCheck.cs`). Missing variables are checked by
`Core/ScopedVariableValidator.cs`, which traverses containers like the walker (body loops, table row loops per table
and per loop row list, cells, nested tables, text boxes, content controls) and resolves names inside a loop against
the items of that loop and its enclosing loops (implicit properties, named iteration variables, metadata).
Expand Down
38 changes: 38 additions & 0 deletions TriasDev.Templify/Conditionals/ConditionalEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,44 @@ internal static Engine.ConditionNode Parse(string expression)
}
}

/// <summary>
/// Collects the variables of a parsed expression that are evaluated for truthiness on their own: the whole
/// expression (<c>Flag</c>) and the operands of <c>and</c>, <c>or</c> and <c>not</c>.
/// </summary>
/// <remarks>
/// Operands of comparisons (<c>Status = Active</c>, where a missing bareword falls back to a string literal),
/// of <c>exists</c> / <c>is empty</c> / <c>is not empty</c> (designed for missing values), of <c>in</c> and of
/// the string operators are not bare operands. Nested operator nodes are always descended into, so the
/// operands of a logical operator nested in a comparison (<c>(A and B) = true</c>) are still found.
/// </remarks>
internal static IEnumerable<Engine.VariableNode> CollectBareOperandVariables(Engine.ConditionNode node)
{
return Collect(node, isBooleanContext: true);

static IEnumerable<Engine.VariableNode> Collect(Engine.ConditionNode node, bool isBooleanContext)
{
switch (node)
{
case Engine.VariableNode variable when isBooleanContext:
yield return variable;
break;
case Engine.OperatorNode op:
bool isLogical = op.Operator is Engine.Operators.AndOperator
or Engine.Operators.OrOperator
or Engine.Operators.NotOperator;
foreach (Engine.ConditionNode operand in op.Operands)
{
foreach (Engine.VariableNode v in Collect(operand, isLogical))
{
yield return v;
}
}

break;
}
}
}

/// <summary>
/// Evaluates a conditional expression (backward compatibility bridge).
/// </summary>
Expand Down
Loading
Loading