diff --git a/TriasDev.Templify.Tests/Odt/OdtValidationTests.cs b/TriasDev.Templify.Tests/Odt/OdtValidationTests.cs index 858eb11..11c18ff 100644 --- a/TriasDev.Templify.Tests/Odt/OdtValidationTests.cs +++ b/TriasDev.Templify.Tests/Odt/OdtValidationTests.cs @@ -140,7 +140,46 @@ public void TableRowLoop_UnmatchedInsideCell_IsStillReported() ValidationResult result = Validate(template); - Assert.Contains(result.Errors, e => e.Message.Contains("'{{#if A}}'", StringComparison.Ordinal)); + // Reported once, with the row-level message that processing fails with (as for Word templates). + ValidationError error = Assert.Single(result.Errors); + Assert.Equal(ValidationErrorType.UnmatchedConditionalStart, error.Type); + Assert.Equal("Table row conditional start marker '{{#if A}}' has no matching '{{/if}}'.", error.Message); + } + + [Fact] + public void UnmatchedListItemMarkers_AreReportedOnce_WithTheMessageProcessingFailsWith() + { + OdtDocumentBuilder template = new OdtDocumentBuilder() + .AddXml("{{#if A}}b") + .AddXml("{{#foreach Items}}b"); + byte[] bytes = template.ToBytes(); + + ValidationResult result = new OdtTemplateProcessor().ValidateTemplate(new MemoryStream(bytes)); + ProcessingResult processing = new OdtTemplateProcessor().ProcessTemplate( + bytes, + new Dictionary { ["A"] = true, ["Items"] = new List { 1 } }, + out _); + + Assert.Equal( + new[] + { + "UnmatchedConditionalStart: List item conditional start marker '{{#if A}}' has no matching '{{/if}}'.", + "UnmatchedLoopStart: List item loop start marker '{{#foreach Items}}' has no matching '{{/foreach}}'.", + }, + result.Errors.Select(e => $"{e.Type}: {e.Message}").Order(StringComparer.Ordinal)); + Assert.False(processing.IsSuccess); + Assert.Contains(processing.ErrorMessage!, result.Errors.Select(e => "Processing failed: " + e.Message)); + } + + [Fact] + public void SameUnmatchedMarker_InTwoParagraphs_IsReportedOnce() + { + ValidationResult result = Validate(new OdtDocumentBuilder() + .AddTable(new[] { "{{#if A}} x" }, new[] { "y" }) + .AddParagraph("{{#if A}}")); + + ValidationError error = Assert.Single(result.Errors); + Assert.Equal("Conditional start marker '{{#if A}}' has no matching '{{/if}}'.", error.Message); } [Fact] diff --git a/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs b/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs index eeeb5d0..4e2fb80 100644 --- a/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs +++ b/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs @@ -270,6 +270,9 @@ private sealed class SyntaxWalker { private readonly HashSet _allPlaceholders; private readonly List _errors; + /// The marker quoted in an unmatched-marker message, e.g. '{{#if A}}'. + private static readonly Regex _quotedMarker = new Regex(@"start marker '(\{\{#[^']*\}\})'", RegexOptions.CultureInvariant); + private readonly HashSet _reported = new HashSet(StringComparer.Ordinal); public SyntaxWalker(HashSet allPlaceholders, List errors) @@ -339,7 +342,12 @@ private IReadOnlyList Detect(Func> detect) } catch (TemplateSyntaxException ex) { - if (_reported.Add(ex.Message)) + // A marker that fails at row or list-item level is found again inside its cell or item, with the + // cell-level wording ("Conditional start marker '{{#if A}}' ..."); report each marker once, with + // the message of the outermost level, which is the one processing fails with. + Match marker = _quotedMarker.Match(ex.Message); + string key = marker.Success ? $"{ex.ErrorType}:{marker.Groups[1].Value}" : ex.Message; + if (_reported.Add(key)) { _errors.Add(ValidationError.Create(ex.ErrorType, ex.Message)); }