Tests/PSJinja.Tests.ps1

#Requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }

BeforeAll {
    $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..' 
    $modulePath = Join-Path -Path $modulePath -ChildPath 'PSJinja.psm1'
    Import-Module (Resolve-Path $modulePath).Path -Force
}

AfterAll {
    Remove-Module PSJinja -ErrorAction SilentlyContinue
}

Describe 'PSJinja Module' {

    # -------------------------------------------------------------------------
    Describe 'Module Manifest' {
        It 'has a valid PSJinja.psd1 manifest' {
            $psd1 = Join-Path $PSScriptRoot '..' 
            $psd1 = Join-Path  $psd1 'PSJinja.psd1'
            Test-ModuleManifest -Path (Resolve-Path $psd1).Path | Should -Not -BeNullOrEmpty
        }

        It 'exports only Invoke-Jinja' {
            $commands = Get-Command -Module PSJinja
            $commands | Should -HaveCount 1
            $commands[0].Name | Should -Be 'Invoke-Jinja'
        }
    }

    # -------------------------------------------------------------------------
    Describe 'Invoke-Jinja — Parameter Validation' {

        It 'accepts an empty string and returns it unchanged' {
            Invoke-Jinja -Template '' | Should -Be ''
        }

        It 'accepts a null Data parameter without error' {
            Invoke-Jinja -Template 'Hello' -Data $null | Should -Be 'Hello'
        }

        It 'renders a plain text template with no tags' {
            Invoke-Jinja -Template 'No tags here.' | Should -Be 'No tags here.'
        }

        It 'accepts Data as a PSCustomObject' {
            $obj = [PSCustomObject]@{ Name = 'World' }
            Invoke-Jinja -Template 'Hello, {{ Name }}!' -Data $obj | Should -Be 'Hello, World!'
        }

        It 'accepts template from pipeline' {
            'Hello, {{ X }}!' | Invoke-Jinja -Data @{ X = 'pipe' } | Should -Be 'Hello, pipe!'
        }

        It 'returns an empty string for a null Template value' {
            # [string] coerces $null to ''
            Invoke-Jinja -Template ([string]$null) | Should -Be ''
        }
    }

    # -------------------------------------------------------------------------
    Describe 'Parsing — Variables' {

        Context 'Simple substitution' {
            It 'replaces a single variable' {
                Invoke-Jinja -Template '{{ Name }}' -Data @{ Name = 'Alice' } |
                    Should -Be 'Alice'
            }

            It 'replaces multiple variables' {
                Invoke-Jinja -Template '{{ A }} and {{ B }}' -Data @{ A = 'foo'; B = 'bar' } |
                    Should -Be 'foo and bar'
            }

            It 'returns empty string for an undefined variable' {
                Invoke-Jinja -Template '{{ Missing }}' -Data @{} | Should -Be ''
            }

            It 'returns empty string for a null variable value' {
                Invoke-Jinja -Template '{{ X }}' -Data @{ X = $null } | Should -Be ''
            }

            It 'renders an integer variable' {
                Invoke-Jinja -Template 'Count: {{ N }}' -Data @{ N = 42 } |
                    Should -Be 'Count: 42'
            }
        }

        Context 'Dot notation' {
            It 'accesses a nested hashtable key' {
                $data = @{ User = @{ Name = 'Bob' } }
                Invoke-Jinja -Template '{{ User.Name }}' -Data $data | Should -Be 'Bob'
            }

            It 'accesses a PSCustomObject property' {
                $data = @{ User = [PSCustomObject]@{ Name = 'Carol' } }
                Invoke-Jinja -Template '{{ User.Name }}' -Data $data | Should -Be 'Carol'
            }

            It 'returns empty string for a missing nested key' {
                $data = @{ User = @{ Name = 'Dave' } }
                Invoke-Jinja -Template '{{ User.Age }}' -Data $data | Should -Be ''
            }

            It 'returns empty string when a parent key is missing' {
                Invoke-Jinja -Template '{{ Missing.Prop }}' -Data @{} | Should -Be ''
            }

            It 'returns empty for deeply nested missing path' {
                $data = @{ A = @{} }
                Invoke-Jinja -Template '{{ A.B.C }}' -Data $data | Should -Be ''
            }
        }

        Context 'Array index notation' {
            It 'accesses an array element by index' {
                $data = @{ Items = @('x', 'y', 'z') }
                Invoke-Jinja -Template '{{ Items[1] }}' -Data $data | Should -Be 'y'
            }

            It 'returns empty string for an out-of-range index' {
                $data = @{ Items = @('x') }
                Invoke-Jinja -Template '{{ Items[5] }}' -Data $data | Should -Be ''
            }
        }
    }

    # -------------------------------------------------------------------------
    Describe 'Parsing — Comments' {

        It 'removes a comment entirely' {
            Invoke-Jinja -Template 'A{# this is a comment #}B' | Should -Be 'AB'
        }

        It 'removes a comment leaving surrounding text intact' {
            Invoke-Jinja -Template 'Hello {# greeting #} World' | Should -Be 'Hello World'
        }

        It 'removes multiple comments' {
            Invoke-Jinja -Template '{# c1 #}X{# c2 #}Y{# c3 #}' | Should -Be 'XY'
        }
    }

    # -------------------------------------------------------------------------
    Describe 'Parsing — Filters' {

        It 'applies the upper filter' {
            Invoke-Jinja -Template '{{ Name | upper }}' -Data @{ Name = 'alice' } |
                Should -Be 'ALICE'
        }

        It 'applies the lower filter' {
            Invoke-Jinja -Template '{{ Name | lower }}' -Data @{ Name = 'ALICE' } |
                Should -Be 'alice'
        }

        It 'applies the capitalize filter' {
            Invoke-Jinja -Template '{{ Name | capitalize }}' -Data @{ Name = 'hELLO' } |
                Should -Be 'Hello'
        }

        It 'applies the title filter' {
            Invoke-Jinja -Template '{{ Title | title }}' -Data @{ Title = 'hello world' } |
                Should -Be 'Hello World'
        }

        It 'applies the trim filter' {
            Invoke-Jinja -Template '{{ Name | trim }}' -Data @{ Name = ' hi ' } |
                Should -Be 'hi'
        }

        It 'applies the length filter' {
            Invoke-Jinja -Template '{{ Items | length }}' -Data @{ Items = @(1,2,3) } |
                Should -Be '3'
        }

        It 'applies the default filter when value is null' {
            Invoke-Jinja -Template '{{ Missing | default("n/a") }}' -Data @{} |
                Should -Be 'n/a'
        }

        It 'does not apply default filter when value exists' {
            Invoke-Jinja -Template '{{ Name | default("n/a") }}' -Data @{ Name = 'Alice' } |
                Should -Be 'Alice'
        }

        It 'applies the join filter with separator' {
            Invoke-Jinja -Template '{{ Items | join(", ") }}' -Data @{ Items = @('a','b','c') } |
                Should -Be 'a, b, c'
        }

        It 'applies the first filter' {
            Invoke-Jinja -Template '{{ Items | first }}' -Data @{ Items = @('x','y','z') } |
                Should -Be 'x'
        }

        It 'applies the last filter' {
            Invoke-Jinja -Template '{{ Items | last }}' -Data @{ Items = @('x','y','z') } |
                Should -Be 'z'
        }

        It 'applies the sort filter' {
            Invoke-Jinja -Template '{{ Items | sort | join(", ") }}' -Data @{ Items = @('c','a','b') } |
                Should -Be 'a, b, c'
        }

        It 'applies the reverse filter' {
            Invoke-Jinja -Template '{{ Items | reverse | join(", ") }}' -Data @{ Items = @('a','b','c') } |
                Should -Be 'c, b, a'
        }

        It 'applies the int filter' {
            Invoke-Jinja -Template '{{ Val | int }}' -Data @{ Val = '42' } |
                Should -Be '42'
        }

        It 'applies the float filter' {
            Invoke-Jinja -Template '{{ Val | float }}' -Data @{ Val = '3.14' } |
                Should -BeExactly '3.14'
        }

        It 'applies the string filter' {
            Invoke-Jinja -Template '{{ Val | string }}' -Data @{ Val = 123 } |
                Should -Be '123'
        }

        It 'chains multiple filters' {
            Invoke-Jinja -Template '{{ Name | lower | trim }}' -Data @{ Name = ' HELLO ' } |
                Should -Be 'hello'
        }

        It 'applies upper filter to null returning empty string' {
            Invoke-Jinja -Template '{{ Missing | upper }}' -Data @{} | Should -Be ''
        }

        It 'applies length filter to null returning 0' {
            Invoke-Jinja -Template '{{ Missing | length }}' -Data @{} | Should -Be '0'
        }

        It 'warns and passes through unknown filter' {
            $result = Invoke-Jinja -Template '{{ Name | unknownfilter }}' -Data @{ Name = 'hi' } -WarningVariable wv
            $result | Should -Be 'hi'
        }
    }

    # -------------------------------------------------------------------------
    Describe 'Control Flow — If / Elif / Else' {

        Context 'Simple if' {
            It 'renders the body when condition is true' {
                Invoke-Jinja -Template '{% if flag %}yes{% endif %}' -Data @{ flag = $true } |
                    Should -Be 'yes'
            }

            It 'renders nothing when condition is false' {
                Invoke-Jinja -Template '{% if flag %}yes{% endif %}' -Data @{ flag = $false } |
                    Should -Be ''
            }

            It 'evaluates truthy non-null value' {
                Invoke-Jinja -Template '{% if Name %}set{% endif %}' -Data @{ Name = 'Alice' } |
                    Should -Be 'set'
            }

            It 'evaluates falsy null value' {
                Invoke-Jinja -Template '{% if Name %}set{% endif %}' -Data @{ Name = $null } |
                    Should -Be ''
            }

            It 'evaluates a missing variable as falsy' {
                Invoke-Jinja -Template '{% if Missing %}yes{% endif %}' -Data @{} |
                    Should -Be ''
            }
        }

        Context 'If / else' {
            It 'renders if branch when true' {
                Invoke-Jinja -Template '{% if flag %}YES{% else %}NO{% endif %}' -Data @{ flag = $true } |
                    Should -Be 'YES'
            }

            It 'renders else branch when false' {
                Invoke-Jinja -Template '{% if flag %}YES{% else %}NO{% endif %}' -Data @{ flag = $false } |
                    Should -Be 'NO'
            }
        }

        Context 'If / elif / else' {
            BeforeAll {
                $tmpl = '{% if score -ge 90 %}A{% elif score -ge 80 %}B{% elif score -ge 70 %}C{% else %}F{% endif %}'
            }

            It 'renders first branch (score=95)' {
                Invoke-Jinja -Template $tmpl -Data @{ score = 95 } | Should -Be 'A'
            }

            It 'renders second branch (score=85)' {
                Invoke-Jinja -Template $tmpl -Data @{ score = 85 } | Should -Be 'B'
            }

            It 'renders third branch (score=75)' {
                Invoke-Jinja -Template $tmpl -Data @{ score = 75 } | Should -Be 'C'
            }

            It 'renders else branch (score=50)' {
                Invoke-Jinja -Template $tmpl -Data @{ score = 50 } | Should -Be 'F'
            }
        }

        Context 'Comparison operators' {
            It 'evaluates -eq' {
                Invoke-Jinja -Template '{% if X -eq 5 %}yes{% endif %}' -Data @{ X = 5 } | Should -Be 'yes'
            }

            It 'evaluates -ne' {
                Invoke-Jinja -Template '{% if X -ne 5 %}yes{% endif %}' -Data @{ X = 3 } | Should -Be 'yes'
            }

            It 'evaluates -gt' {
                Invoke-Jinja -Template '{% if X -gt 3 %}yes{% endif %}' -Data @{ X = 5 } | Should -Be 'yes'
            }

            It 'evaluates -ge' {
                Invoke-Jinja -Template '{% if X -ge 5 %}yes{% endif %}' -Data @{ X = 5 } | Should -Be 'yes'
            }

            It 'evaluates -lt' {
                Invoke-Jinja -Template '{% if X -lt 10 %}yes{% endif %}' -Data @{ X = 5 } | Should -Be 'yes'
            }

            It 'evaluates -le' {
                Invoke-Jinja -Template '{% if X -le 5 %}yes{% endif %}' -Data @{ X = 5 } | Should -Be 'yes'
            }

            It 'evaluates -like' {
                Invoke-Jinja -Template '{% if Name -like "Al*" %}yes{% endif %}' -Data @{ Name = 'Alice' } |
                    Should -Be 'yes'
            }

            It 'evaluates -notlike' {
                Invoke-Jinja -Template '{% if Name -notlike "Z*" %}yes{% endif %}' -Data @{ Name = 'Alice' } |
                    Should -Be 'yes'
            }

            It 'evaluates -match' {
                Invoke-Jinja -Template '{% if Name -match "^Al" %}yes{% endif %}' -Data @{ Name = 'Alice' } |
                    Should -Be 'yes'
            }

            It 'evaluates -notmatch' {
                Invoke-Jinja -Template '{% if Name -notmatch "^Z" %}yes{% endif %}' -Data @{ Name = 'Alice' } |
                    Should -Be 'yes'
            }

            It 'evaluates -in' {
                Invoke-Jinja -Template '{% if X -in Items %}yes{% endif %}' -Data @{ X = 2; Items = @(1,2,3) } |
                    Should -Be 'yes'
            }

            It 'evaluates -notin' {
                Invoke-Jinja -Template '{% if X -notin Items %}yes{% endif %}' -Data @{ X = 5; Items = @(1,2,3) } |
                    Should -Be 'yes'
            }

            It 'evaluates -contains' {
                Invoke-Jinja -Template '{% if Items -contains 2 %}yes{% endif %}' -Data @{ Items = @(1,2,3) } |
                    Should -Be 'yes'
            }

            It 'evaluates -notcontains' {
                Invoke-Jinja -Template '{% if Items -notcontains 5 %}yes{% endif %}' -Data @{ Items = @(1,2,3) } |
                    Should -Be 'yes'
            }
        }

        Context 'not operator' {
            It 'negates a true value' {
                Invoke-Jinja -Template '{% if not flag %}yes{% endif %}' -Data @{ flag = $true } |
                    Should -Be ''
            }

            It 'negates a false value' {
                Invoke-Jinja -Template '{% if not flag %}yes{% endif %}' -Data @{ flag = $false } |
                    Should -Be 'yes'
            }

            It 'negates a comparison' {
                Invoke-Jinja -Template '{% if not X -eq 5 %}yes{% endif %}' -Data @{ X = 3 } |
                    Should -Be 'yes'
            }
        }

        Context 'Literal comparison values' {
            It 'compares to a string literal' {
                Invoke-Jinja -Template '{% if Name -eq "Alice" %}match{% endif %}' -Data @{ Name = 'Alice' } |
                    Should -Be 'match'
            }

            It 'compares to an integer literal' {
                Invoke-Jinja -Template '{% if N -eq 7 %}match{% endif %}' -Data @{ N = 7 } |
                    Should -Be 'match'
            }

            It 'compares to true literal' {
                Invoke-Jinja -Template '{% if Flag -eq true %}yes{% endif %}' -Data @{ Flag = $true } |
                    Should -Be 'yes'
            }
        }

        Context 'Nested if' {
            It 'evaluates nested if correctly' {
                $tmpl = '{% if A %}{% if B %}both{% else %}A only{% endif %}{% endif %}'
                Invoke-Jinja -Template $tmpl -Data @{ A = $true; B = $true } | Should -Be 'both'
                Invoke-Jinja -Template $tmpl -Data @{ A = $true; B = $false } | Should -Be 'A only'
                Invoke-Jinja -Template $tmpl -Data @{ A = $false; B = $true } | Should -Be ''
            }
        }
    }

    # -------------------------------------------------------------------------
    Describe 'Control Flow — For Loops' {

        Context 'Basic iteration' {
            It 'iterates over a simple array' {
                $result = Invoke-Jinja -Template '{% for i in Items %}{{ i }}{% endfor %}' -Data @{ Items = @('a','b','c') }
                $result | Should -Be 'abc'
            }

            It 'produces no output for an empty array' {
                $result = Invoke-Jinja -Template '{% for i in Items %}{{ i }}{% endfor %}' -Data @{ Items = @() }
                $result | Should -Be ''
            }

            It 'produces no output for a null list' {
                $result = Invoke-Jinja -Template '{% for i in Missing %}{{ i }}{% endfor %}' -Data @{}
                $result | Should -Be ''
            }

            It 'iterates over a single-element array' {
                $result = Invoke-Jinja -Template '{% for i in Items %}{{ i }}{% endfor %}' -Data @{ Items = @('only') }
                $result | Should -Be 'only'
            }
        }

        Context 'Loop variable' {
            It 'exposes loop.index (1-based)' {
                $result = Invoke-Jinja -Template '{% for i in Items %}{{ loop.index }}{% endfor %}' -Data @{ Items = @('a','b','c') }
                $result | Should -Be '123'
            }

            It 'exposes loop.index0 (0-based)' {
                $result = Invoke-Jinja -Template '{% for i in Items %}{{ loop.index0 }}{% endfor %}' -Data @{ Items = @('a','b') }
                $result | Should -Be '01'
            }

            It 'exposes loop.first' {
                $result = Invoke-Jinja -Template '{% for i in Items %}{% if loop.first %}F{% endif %}{% endfor %}' -Data @{ Items = @('a','b','c') }
                $result | Should -Be 'F'
            }

            It 'exposes loop.last' {
                $result = Invoke-Jinja -Template '{% for i in Items %}{% if loop.last %}L{% endif %}{% endfor %}' -Data @{ Items = @('a','b','c') }
                $result | Should -Be 'L'
            }

            It 'exposes loop.length' {
                $result = Invoke-Jinja -Template '{% for i in Items %}{{ loop.length }}{% endfor %}' -Data @{ Items = @('a','b','c') }
                $result | Should -Be '333'
            }
        }

        Context 'Nested loops' {
            It 'renders a nested for loop correctly' {
                $tmpl   = '{% for row in Rows %}{% for cell in row %}{{ cell }}{% endfor %}|{% endfor %}'
                $data   = @{ Rows = @(@('1','2'), @('3','4')) }
                $result = Invoke-Jinja -Template $tmpl -Data $data
                $result | Should -Be '12|34|'
            }

            It 'accesses outer context inside inner loop' {
                $tmpl   = '{% for item in Items %}{{ Prefix }}{{ item }} {% endfor %}'
                $result = Invoke-Jinja -Template $tmpl -Data @{ Items = @('a','b'); Prefix = '>' }
                $result | Should -Be '>a >b '
            }
        }

        Context 'For with conditional inside' {
            It 'applies an if condition inside a for loop' {
                $tmpl   = '{% for n in Numbers %}{% if n -gt 2 %}{{ n }}{% endif %}{% endfor %}'
                $result = Invoke-Jinja -Template $tmpl -Data @{ Numbers = @(1,2,3,4,5) }
                $result | Should -Be '345'
            }
        }

        Context 'For loop over object properties' {
            It 'iterates over an array of hashtables' {
                $tmpl  = '{% for user in Users %}{{ user.Name }}{% endfor %}'
                $data  = @{ Users = @(@{ Name = 'Alice' }, @{ Name = 'Bob' }) }
                $result = Invoke-Jinja -Template $tmpl -Data $data
                $result | Should -Be 'AliceBob'
            }
        }
    }

    # -------------------------------------------------------------------------
    Describe 'Control Flow — Set' {

        It 'sets a variable and renders it' {
            $tmpl = '{% set greeting = "Hello" %}{{ greeting }}'
            Invoke-Jinja -Template $tmpl -Data @{} | Should -Be 'Hello'
        }

        It 'overwrites an existing variable' {
            $tmpl = '{% set X = "new" %}{{ X }}'
            Invoke-Jinja -Template $tmpl -Data @{ X = 'old' } | Should -Be 'new'
        }

        It 'sets an integer value' {
            $tmpl = '{% set N = 42 %}{{ N }}'
            Invoke-Jinja -Template $tmpl -Data @{} | Should -Be '42'
        }
    }

    # -------------------------------------------------------------------------
    Describe 'Edge Cases & Graceful Error Handling' {

        It 'handles a template with only whitespace' {
            Invoke-Jinja -Template ' ' | Should -Be ' '
        }

        It 'handles a template with only tags and no text' {
            Invoke-Jinja -Template '{{ A }}{{ B }}' -Data @{ A = '1'; B = '2' } | Should -Be '12'
        }

        It 'treats unclosed {{ as plain text (no match)' {
            # The regex won't match unclosed delimiters; they pass through as TEXT
            Invoke-Jinja -Template 'Hello {{ World' | Should -Be 'Hello {{ World'
        }

        It 'treats unclosed {% as plain text' {
            Invoke-Jinja -Template '{% if flag' | Should -Be '{% if flag'
        }

        It 'handles whitespace-control dashes in block tags' {
            # {%- and -%} should work the same as {% and %} for parsing
            Invoke-Jinja -Template '{%- if flag -%}yes{%- endif -%}' -Data @{ flag = $true } |
                Should -Be 'yes'
        }

        It 'handles a Data object that is not a hashtable or PSCustomObject' {
            # Falls back to empty context; no variable substitution
            Invoke-Jinja -Template '{{ X }}' -Data 'not-an-object' | Should -Be ''
        }

        It 'handles unknown block tags gracefully (warning + skip)' {
            $result = Invoke-Jinja -Template '{% unknowntag foo %}hello' -Data @{} -WarningVariable wv
            $result | Should -Be 'hello'
        }

        It 'handles deeply nested if/for combination' {
            $tmpl = '{% for row in M %}{% for v in row %}{% if v -gt 0 %}+{% else %}-{% endif %}{% endfor %}|{% endfor %}'
            $data = @{ M = @(@(1,-1),@(-1,1)) }
            Invoke-Jinja -Template $tmpl -Data $data | Should -Be '+-|-+|'
        }

        It 'renders Booleans correctly inside conditions' {
            Invoke-Jinja -Template '{% if IsAdmin -eq $true %}admin{% endif %}' -Data @{ IsAdmin = $true } |
                Should -Be 'admin'
        }

        It 'handles invalid for-loop syntax gracefully' {
            # Warning is expected; body is skipped
            $result = Invoke-Jinja -Template '{% for badloop %}{{ x }}{% endfor %}' -Data @{} -WarningVariable wv
            $result | Should -Be ''
        }

        It 'handles a parenthesised condition' {
            Invoke-Jinja -Template '{% if (Flag) %}yes{% endif %}' -Data @{ Flag = $true } |
                Should -Be 'yes'
        }
    }
}