tools/Build-PublicUnitTests.ps1

<#
.SYNOPSIS
Generate Pester 3 unit test files for every public GitEasy command.
 
.DESCRIPTION
Writes one Tests\Unit\<CommandName>.Tests.ps1 file per public command. Each test file uses Pester 3 syntax (Should Be / Should Match / Should Not BeNullOrEmpty) and asserts the command's contract: it is exported, declares the expected parameters with the right shapes (Mandatory / SwitchParameter / ValidateSet), and ships full CBH (Synopsis + at least one Example).
 
The generated files are deterministic — re-running this script regenerates them in place. Hand edits will be overwritten.
 
.PARAMETER ProjectRoot
Absolute path to the GitEasy source repository. Defaults to C:\Sysadmin\Scripts\GitEasy.
 
.EXAMPLE
.\tools\Build-PublicUnitTests.ps1
#>


[CmdletBinding()]
param(
    [string]$ProjectRoot = 'C:\Sysadmin\Scripts\GitEasy'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$publicRoot = Join-Path $ProjectRoot 'Public'
$unitRoot   = Join-Path $ProjectRoot 'Tests\Unit'

if (-not (Test-Path -LiteralPath $publicRoot -PathType Container)) {
    throw "Missing Public folder: $publicRoot"
}

if (-not (Test-Path -LiteralPath $unitRoot -PathType Container)) {
    New-Item -Path $unitRoot -ItemType Directory -Force | Out-Null
}

Remove-Module GitEasy -Force -ErrorAction SilentlyContinue
Import-Module (Join-Path $ProjectRoot 'GitEasy.psd1') -Force

$publicFiles = @(Get-ChildItem -LiteralPath $publicRoot -Filter '*.ps1' -File | Sort-Object Name)

$generated = 0

foreach ($file in $publicFiles) {
    $cmdName = $file.BaseName
    $cmd = Get-Command -Module GitEasy -Name $cmdName -ErrorAction SilentlyContinue
    if (-not $cmd) {
        Write-Warning "Skipping $cmdName - not exported by GitEasy."
        continue
    }

    # Inspect the AST to find the function's declared param block (drops the
    # auto-added common parameters that Get-Command surfaces).
    $tokens = $null; $errors = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors)
    $fnAst = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) | Select-Object -First 1

    $declaredParams = @()
    if ($fnAst -and $fnAst.Body.ParamBlock -and $fnAst.Body.ParamBlock.Parameters) {
        foreach ($p in $fnAst.Body.ParamBlock.Parameters) {
            $name = $p.Name.VariablePath.UserPath
            $type = if ($p.StaticType) { $p.StaticType.Name } else { '' }
            $isMandatory = $false
            $validateSet = @()
            foreach ($attr in $p.Attributes) {
                if ($attr -is [System.Management.Automation.Language.AttributeAst]) {
                    if ($attr.TypeName.Name -eq 'Parameter') {
                        foreach ($na in $attr.NamedArguments) {
                            if ($na.ArgumentName -eq 'Mandatory') { $isMandatory = $true }
                        }
                    }
                    if ($attr.TypeName.Name -eq 'ValidateSet') {
                        foreach ($pa in $attr.PositionalArguments) {
                            if ($pa -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
                                $validateSet += $pa.Value
                            }
                        }
                    }
                }
            }
            $declaredParams += [PSCustomObject]@{
                Name        = $name
                Type        = $type
                Mandatory   = $isMandatory
                IsSwitch    = ($type -eq 'SwitchParameter')
                ValidateSet = $validateSet
            }
        }
    }

    $supportsShouldProcess = $false
    if ($fnAst -and $fnAst.Body.ParamBlock -and $fnAst.Body.ParamBlock.Attributes) {
        foreach ($attr in $fnAst.Body.ParamBlock.Attributes) {
            if ($attr -is [System.Management.Automation.Language.AttributeAst] -and $attr.TypeName.Name -eq 'CmdletBinding') {
                foreach ($na in $attr.NamedArguments) {
                    if ($na.ArgumentName -eq 'SupportsShouldProcess') { $supportsShouldProcess = $true }
                }
            }
        }
    }

    $lines = New-Object System.Collections.Generic.List[string]
    $lines.Add('# Generated by tools\Build-PublicUnitTests.ps1. Re-run to regenerate.')
    $lines.Add('')
    $lines.Add('$ProjectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)')
    $lines.Add('$ModulePath = Join-Path $ProjectRoot ''GitEasy.psd1''')
    $lines.Add('')
    $lines.Add("Describe '$cmdName (unit contract)' {")
    $lines.Add('')
    $lines.Add(' BeforeAll {')
    $lines.Add(' Remove-Module GitEasy -Force -ErrorAction SilentlyContinue')
    $lines.Add(' Import-Module $ModulePath -Force')
    $lines.Add(' }')
    $lines.Add('')
    $lines.Add(" It 'is exported by the GitEasy module' {")
    $lines.Add(" @(Get-Command -Module GitEasy -Name '$cmdName').Count | Should Be 1")
    $lines.Add(' }')
    $lines.Add('')
    $lines.Add(" It 'has CmdletBinding' {")
    $lines.Add(' $cmd = Get-Command ''' + $cmdName + '''')
    $lines.Add(' $cmd.CmdletBinding | Should Be $true')
    $lines.Add(' }')
    $lines.Add('')

    if ($supportsShouldProcess) {
        $lines.Add(" It 'supports ShouldProcess (-WhatIf and -Confirm available)' {")
        $lines.Add(' $cmd = Get-Command ''' + $cmdName + '''')
        $lines.Add(' $cmd.Parameters.ContainsKey(''WhatIf'') | Should Be $true')
        $lines.Add(' $cmd.Parameters.ContainsKey(''Confirm'') | Should Be $true')
        $lines.Add(' }')
        $lines.Add('')
    }

    foreach ($p in $declaredParams) {
        $lines.Add(" It 'declares the -$($p.Name) parameter' {")
        $lines.Add(' $cmd = Get-Command ''' + $cmdName + '''')
        $lines.Add(' $cmd.Parameters.ContainsKey(''' + $p.Name + ''') | Should Be $true')
        $lines.Add(' }')
        $lines.Add('')

        if ($p.IsSwitch) {
            $lines.Add(" It '-$($p.Name) is a switch parameter' {")
            $lines.Add(' $cmd = Get-Command ''' + $cmdName + '''')
            $lines.Add(' $cmd.Parameters[''' + $p.Name + '''].ParameterType.Name | Should Be ''SwitchParameter''')
            $lines.Add(' }')
            $lines.Add('')
        }

        if ($p.Mandatory) {
            $lines.Add(" It '-$($p.Name) is mandatory' {")
            $lines.Add(' $cmd = Get-Command ''' + $cmdName + '''')
            $lines.Add(' $attrs = $cmd.Parameters[''' + $p.Name + '''].Attributes')
            $lines.Add(' $paramAttr = $attrs | Where-Object { $_.GetType().Name -eq ''ParameterAttribute'' } | Select-Object -First 1')
            $lines.Add(' $paramAttr.Mandatory | Should Be $true')
            $lines.Add(' }')
            $lines.Add('')
        }

        if ($p.ValidateSet.Count -gt 0) {
            $lines.Add(" It '-$($p.Name) accepts only the documented values' {")
            $lines.Add(' $cmd = Get-Command ''' + $cmdName + '''')
            $lines.Add(' $set = ($cmd.Parameters[''' + $p.Name + '''].Attributes | Where-Object { $_.GetType().Name -eq ''ValidateSetAttribute'' } | Select-Object -First 1).ValidValues')
            foreach ($v in $p.ValidateSet) {
                $lines.Add(' $set -contains ''' + $v + ''' | Should Be $true')
            }
            $lines.Add(' }')
            $lines.Add('')
        }
    }

    $lines.Add(" It 'has a non-empty .SYNOPSIS' {")
    $lines.Add(' $help = Get-Help ''' + $cmdName + ''' -ErrorAction SilentlyContinue')
    $lines.Add(' $help.Synopsis | Should Not BeNullOrEmpty')
    $lines.Add(' }')
    $lines.Add('')
    $lines.Add(" It 'has at least one .EXAMPLE in CBH' {")
    $lines.Add(' $help = Get-Help ''' + $cmdName + ''' -Full -ErrorAction SilentlyContinue')
    $lines.Add(' @($help.examples.example).Count -gt 0 | Should Be $true')
    $lines.Add(' }')
    $lines.Add('}')

    $body = ($lines.ToArray() -join "`r`n") + "`r`n"
    $outPath = Join-Path $unitRoot ($cmdName + '.Tests.ps1')
    [System.IO.File]::WriteAllText($outPath, $body, [System.Text.UTF8Encoding]::new($false))
    $generated++
}

Write-Host ''
Write-Host "Generated $generated public unit-test files in $unitRoot" -ForegroundColor Green