File: Templating\ConditionalBlockProcessorTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Cli.Templating;
 
namespace Aspire.Cli.Tests.Templating;
 
public class ConditionalBlockProcessorTests
{
    // ──────────────────────────────────────────────────────────────────────
    // Basic include / exclude
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void Include_KeepsContent_RemovesMarkers()
    {
        var input = """
            before
            // {{#feature}}
            included content
            // {{/feature}}
            after
            """;
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "feature", include: true);
 
        Assert.Contains("included content", result);
        Assert.DoesNotContain("{{#feature}}", result);
        Assert.DoesNotContain("{{/feature}}", result);
        Assert.Contains("before", result);
        Assert.Contains("after", result);
    }
 
    [Fact]
    public void Exclude_RemovesContentAndMarkers()
    {
        var input = """
            before
            // {{#feature}}
            excluded content
            // {{/feature}}
            after
            """;
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "feature", include: false);
 
        Assert.DoesNotContain("excluded content", result);
        Assert.DoesNotContain("{{#feature}}", result);
        Assert.DoesNotContain("{{/feature}}", result);
        Assert.Contains("before", result);
        Assert.Contains("after", result);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // Comment style variations
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void Handles_DoubleSlash_CommentMarkers()
    {
        var input = "line1\n// {{#test}}\nkept\n// {{/test}}\nline2\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("line1\nkept\nline2\n", result);
    }
 
    [Fact]
    public void Handles_Hash_CommentMarkers()
    {
        var input = "line1\n# {{#test}}\nkept\n# {{/test}}\nline2\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("line1\nkept\nline2\n", result);
    }
 
    [Fact]
    public void Handles_Bare_Markers_WithoutCommentPrefix()
    {
        var input = "line1\n{{#test}}\nkept\n{{/test}}\nline2\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("line1\nkept\nline2\n", result);
    }
 
    [Fact]
    public void Handles_IndentedMarkers()
    {
        var input = "line1\n    // {{#test}}\n    kept\n    // {{/test}}\nline2\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("line1\n    kept\nline2\n", result);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // Multiple blocks of same type
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void Include_MultipleBlocks_AllKept()
    {
        var input = "a\n// {{#x}}\nb\n// {{/x}}\nc\n// {{#x}}\nd\n// {{/x}}\ne\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "x", include: true);
 
        Assert.Equal("a\nb\nc\nd\ne\n", result);
    }
 
    [Fact]
    public void Exclude_MultipleBlocks_AllRemoved()
    {
        var input = "a\n// {{#x}}\nb\n// {{/x}}\nc\n// {{#x}}\nd\n// {{/x}}\ne\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "x", include: false);
 
        Assert.Equal("a\nc\ne\n", result);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // Edge cases: position in file
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void Block_AtStartOfFile()
    {
        // Exercises LastIndexOf returning -1 for start-of-file boundary.
        var input = "// {{#test}}\nfirst\n// {{/test}}\nafter\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("first\nafter\n", result);
    }
 
    [Fact]
    public void Block_AtEndOfFile_NoTrailingNewline()
    {
        // Exercises IndexOf returning content.Length for end-of-file boundary.
        var input = "before\n// {{#test}}\nlast\n// {{/test}}";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("before\nlast\n", result);
    }
 
    [Fact]
    public void Block_IsEntireFile_Exclude()
    {
        var input = "// {{#test}}\nall content\n// {{/test}}\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: false);
 
        Assert.Equal("", result);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // Edge cases: content variations
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void EmptyBlock_ProducesSameResultForIncludeAndExclude()
    {
        var input = "before\n// {{#test}}\n// {{/test}}\nafter\n";
 
        var includeResult = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
        var excludeResult = ConditionalBlockProcessor.ProcessBlock(input, "test", include: false);
 
        Assert.Equal("before\nafter\n", includeResult);
        Assert.Equal(includeResult, excludeResult);
    }
 
    [Fact]
    public void MultilineBlockContent_Include()
    {
        var input = "before\n// {{#test}}\nline1\nline2\nline3\n// {{/test}}\nafter\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("before\nline1\nline2\nline3\nafter\n", result);
    }
 
    [Fact]
    public void ContentWithCurlyBraces_NotConfusedWithMarkers()
    {
        var input = "before\n// {{#test}}\nvar x = { key: \"value\" };\n// {{/test}}\nafter\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("before\nvar x = { key: \"value\" };\nafter\n", result);
    }
 
    [Fact]
    public void ContentWithTemplateTokens_NotConfusedWithMarkers()
    {
        var input = "before\n// {{#test}}\nname: {{projectName}}\n// {{/test}}\nafter\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("before\nname: {{projectName}}\nafter\n", result);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // No markers / no matching markers
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void NoMarkers_ReturnsUnchanged()
    {
        var input = "just some content\nwith no markers\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal(input, result);
    }
 
    [Fact]
    public void DifferentBlockName_IgnoresNonMatchingMarkers()
    {
        var input = "before\n// {{#other}}\nkept\n// {{/other}}\nafter\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: false);
 
        Assert.Equal(input, result);
    }
 
    [Fact]
    public void MissingEndMarker_ThrowsInvalidOperationException()
    {
        var input = "before\n// {{#test}}\ncontent\nafter\n";
 
        Assert.Throws<InvalidOperationException>(
            () => ConditionalBlockProcessor.ProcessBlock(input, "test", include: false));
    }
 
    [Fact]
    public void MissingStartMarker_LeavesContentUnchanged()
    {
        var input = "before\ncontent\n// {{/test}}\nafter\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: false);
 
        Assert.Equal(input, result);
    }
 
    [Fact]
    public void EmptyContent_ReturnsEmpty()
    {
        var result = ConditionalBlockProcessor.ProcessBlock("", "test", include: true);
 
        Assert.Equal("", result);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // Process() with multiple conditions
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void Process_MultipleConditions_IncludesAndExcludesCorrectly()
    {
        var input = """
            start
            // {{#redis}}
            redis stuff
            // {{/redis}}
            middle
            // {{#no-redis}}
            no redis stuff
            // {{/no-redis}}
            end
            """;
 
        var conditions = new Dictionary<string, bool>
        {
            ["redis"] = true,
            ["no-redis"] = false,
        };
 
        var result = ConditionalBlockProcessor.Process(input, conditions);
 
        Assert.Contains("redis stuff", result);
        Assert.DoesNotContain("no redis stuff", result);
        Assert.Contains("start", result);
        Assert.Contains("middle", result);
        Assert.Contains("end", result);
    }
 
    [Fact]
    public void Process_InverseConditions_IncludesAndExcludesCorrectly()
    {
        var input = """
            start
            // {{#redis}}
            redis stuff
            // {{/redis}}
            middle
            // {{#no-redis}}
            no redis stuff
            // {{/no-redis}}
            end
            """;
 
        var conditions = new Dictionary<string, bool>
        {
            ["redis"] = false,
            ["no-redis"] = true,
        };
 
        var result = ConditionalBlockProcessor.Process(input, conditions);
 
        // "no redis stuff" contains the substring "redis stuff", so assert on the full line.
        Assert.DoesNotContain("// {{#redis}}", result);
        Assert.Contains("no redis stuff", result);
        Assert.Contains("start", result);
        Assert.Contains("middle", result);
        Assert.Contains("end", result);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // Adjacent blocks
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void AdjacentBlocks_ExcludeBoth()
    {
        var input = "before\n// {{#a}}\nalpha\n// {{/a}}\n// {{#b}}\nbeta\n// {{/b}}\nafter\n";
 
        var conditions = new Dictionary<string, bool>
        {
            ["a"] = false,
            ["b"] = false,
        };
 
        var result = ConditionalBlockProcessor.Process(input, conditions);
 
        Assert.DoesNotContain("alpha", result);
        Assert.DoesNotContain("beta", result);
        Assert.Equal("before\nafter\n", result);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // Realistic template scenarios
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void PythonFile_WithRedisAndNoRedisBlocks()
    {
        var input = """
            import os
            # {{#redis}}
            import redis
            # {{/redis}}
 
            # {{#redis}}
            def get_client():
                return redis.from_url(os.environ.get("CACHE_URI"))
            # {{/redis}}
 
            # {{#no-redis}}
            def get_forecast():
                return generate_fresh()
            # {{/no-redis}}
            """;
 
        var conditions = new Dictionary<string, bool>
        {
            ["redis"] = true,
            ["no-redis"] = false,
        };
 
        var result = ConditionalBlockProcessor.Process(input, conditions);
 
        Assert.Contains("import redis", result);
        Assert.Contains("def get_client():", result);
        Assert.DoesNotContain("def get_forecast():", result);
        Assert.DoesNotContain("{{#", result);
        Assert.DoesNotContain("{{/", result);
    }
 
    [Fact]
    public void PyprojectToml_DependenciesBlock()
    {
        var input = """
            dependencies = [
                "fastapi[standard]>=0.119.0",
                "opentelemetry-distro>=0.59b0",
            # {{#redis}}
                "opentelemetry-instrumentation-redis>=0.59b0",
                "redis>=6.4.0",
            # {{/redis}}
            ]
            """;
 
        var resultWith = ConditionalBlockProcessor.ProcessBlock(input, "redis", include: true);
        Assert.Contains("redis>=6.4.0", resultWith);
        Assert.DoesNotContain("{{#redis}}", resultWith);
        // Verify the closing bracket is still there
        Assert.Contains("]", resultWith);
 
        var resultWithout = ConditionalBlockProcessor.ProcessBlock(input, "redis", include: false);
        Assert.DoesNotContain("redis", resultWithout);
        Assert.Contains("fastapi", resultWithout);
        Assert.Contains("]", resultWithout);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // Whitespace / formatting preservation
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void PreservesIndentation_InKeptContent()
    {
        var input = "    // {{#test}}\n        indented content\n    // {{/test}}\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: true);
 
        Assert.Equal("        indented content\n", result);
    }
 
    [Fact]
    public void SurroundingBlankLines_PreservedWhenExcluding()
    {
        var input = "before\n\n// {{#test}}\ncontent\n// {{/test}}\n\nafter\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "test", include: false);
 
        // The processor removes the block and its markers but preserves surrounding blank lines.
        Assert.Equal("before\n\n\nafter\n", result);
    }
 
    // ──────────────────────────────────────────────────────────────────────
    // Block name edge cases
    // ──────────────────────────────────────────────────────────────────────
 
    [Fact]
    public void BlockNameWithHyphen_WorksCorrectly()
    {
        var input = "before\n// {{#no-redis}}\ncontent\n// {{/no-redis}}\nafter\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "no-redis", include: true);
 
        Assert.Equal("before\ncontent\nafter\n", result);
    }
 
    [Fact]
    public void SimilarBlockNames_DoNotInterfere()
    {
        var input = "// {{#redis}}\nredis content\n// {{/redis}}\n// {{#redis-cluster}}\ncluster content\n// {{/redis-cluster}}\n";
 
        var result = ConditionalBlockProcessor.ProcessBlock(input, "redis", include: false);
 
        Assert.DoesNotContain("redis content", result);
        Assert.Contains("cluster content", result);
        Assert.Contains("{{#redis-cluster}}", result);
    }
}