Tests/DocTreeGenerator.Tests.ps1

Import-Module "$PSScriptRoot\..\DocTreeGenerator.psd1" -force

InModuleScope DocTreeGenerator {

    $2space = ' ' * 2
    $4space = ' ' * 4
    $8space = ' ' * 8

    $stdProperties  = @(
        $8space
        "$($8space)Required? false"
        "$($8space)Position? 3"
        "$($8space)Default value"
        "$($8space)Accept pipeline input? false"
        "$($8space)Accept wildcard characters? false"
    )
    $stdDescription = @(
        "$($8space)para one, line one."
        "$($8space)para one, line two."
        $8space
        "$($8space)para two."
    )


    function Stringify([string[]]$text, [switch]$stripHtml)
    {
         $result = $text -join '' -replace "`r" -replace "`n"
         if ($stripHtml) { $result -replace '<.*?>' }
         else { $result }
    }

    function StripLineBreaks([string]$text)
    {
        $text -replace "`r`n"
    }

    function SplitToArray([string]$text)
    {
        $text -split "`r`n"
    }

    function GenerateText([string]$paramName, [string[]]$description)
    {
        $text = ," -$paramName"
        if ($description) {
            $description | % { $text += $_ }
        }
        $stdProperties | % { $text += $_ }
        $text
    }

    function GetArrayIndex([string[]]$list, [string]$target)
    {
        $matchIndex = -1
        for ($i = 0; $i -lt $list.Count; $i++) {
            if ($list[$i] -match $target) {
                $matchIndex = $i
                break
            }
        }
        $matchIndex
    }

    Describe 'Body/links' {

        Mock Write-Host
        Mock Get-CmdletDocLinks
        Mock Get-Template { "any" }
        Init-Variables

        BeforeEach {
            $stdSections  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                'RELATED LINKS' = ''
            }
            $stdSectionOrder = @(
                'NAME'
                'SYNOPSIS'
                'SYNTAX'
                'DESCRIPTION'
                'PARAMETERS'
                'INPUTS'
                'OUTPUTS'
                'EXAMPLES'
                'RELATED LINKS'
            )
        }

        It 'Reports no links if no links present' {
            $result = Stringify (ConvertTo-Body $stdSections $stdSectionOrder 'any') -stripHtml
            $result | Should Match 'RELATED LINKS-none-'
        }

        It 'Reports one link if link present' {
            $stdSections['RELATED LINKS'] = 'foo'
            $result = Stringify (ConvertTo-Body $stdSections $stdSectionOrder 'any')
            $result | Should Match ('RELATED LINKS.*' + (Get-HtmlListItem 'foo'))
        }

        It 'Reports multiple links if links present' {
            $stdSections['RELATED LINKS'] = 'foo','bar'
            $result = Stringify (ConvertTo-Body $stdSections $stdSectionOrder 'any') 
            $result | Should Match ('RELATED LINKS.*' + (Get-HtmlListItem 'foo') + (Get-HtmlListItem 'bar'))
        }

        It 'Reports description present when present' {
            $stdSections.DESCRIPTION = 'my description here'
            $result = Stringify (ConvertTo-Body $stdSections $stdSectionOrder 'any') 
            $result | Should Match ("DESCRIPTION.*" + (Stringify (Get-HtmlDiv $stdSections.DESCRIPTION)))
        }

        It 'Reports description missing when missing' {
            $source = 'mySource'
            $stdSections.Remove('DESCRIPTION')
            $stdSections.NAME[0] = $source
            $stdSectionOrder = $stdSectionOrder | ? { $_ -ne 'DESCRIPTION' }
            $result = Stringify (ConvertTo-Body $stdSections $stdSectionOrder 'any') 
            $result | Should Match "DESCRIPTION.*missing description.*$source"
        }
    }

    Describe 'Body/examples' {

        Mock Write-Host
        Mock Get-CmdletDocLinks
        Mock Get-Template { "any" }
        Init-Variables

        $cmd = 'Get-Foobar'
        $cmdStyle = $CSS_PS_CMD
        $docStyle = $CSS_PS_DOC_SECTION 
        $exampleHeader = '<h2>EXAMPLES</h2>'

        $stdExample1 = ' -------------------------- EXAMPLE 1 --------------------------',
            '',
            " $cmd",
            '',
            '',
            ' This gets the foobar for the current shebang.'

        $stdExample2 = ' -------------------------- EXAMPLE 2 --------------------------',
            '',
            ' Get-SomethingElse',
            '',
            '',
            ' This gets the foobar for the current shebang.'

        $multiLineToSingleLine = ' -------------------------- EXAMPLE 1 --------------------------',
            '',
            ' Get-Something1 |',
            ' Get-Something2',
            '',
            '',
            ' This gets the foobar for the current shebang.'

        # Add a crucial leading space (at least one, less than four, in front of second line)
        $multilineToMultiLine = ' -------------------------- EXAMPLE 1 --------------------------',
            '',
            ' Get-Something1 |',
            ' Get-Something2',
            '',
            '',
            ' This gets the foobar for the current shebang.'

        $multilineWithPrompts = ' -------------------------- EXAMPLE 1 --------------------------',
            '',
            ' Get-Something1 | Got-Something',
            ' PS> Get-Something2',
            '',
            '',
            ' This gets the foobar for the current shebang.'

        It 'Adds example header in front of first example when just one example' {
            $examples  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                EXAMPLES = $stdExample1
            }
            $expected = @"
<div class=['"]$docStyle['"]>
\s*$exampleHeader
\s*<div>
\s*<br/>
\s*\S+\s*----+ EXAMPLE 1 ----+
"@

            $result = Stringify (ConvertTo-Body $examples EXAMPLES 'any')

            $result | Should Match (StripLineBreaks $expected)
        }

        It 'Adds example header in front of first example with multiple examples' {
            $examples  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                EXAMPLES = $stdExample1,'','',$stdExample2
            }
            $expected = @"
<div class=['"]$docStyle['"]>
\s*$exampleHeader
\s*<div>
\s*<br/>
\s*\S+\s*----+ EXAMPLE 1 ----+
"@

            $result = Stringify (ConvertTo-Body $examples EXAMPLES 'any')

            $result | Should Match (StripLineBreaks $expected)
        }

        It 'Wraps command in span element' {
            $examples  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                EXAMPLES = $stdExample1
            }
            $expected = @"
<span class=['"]$cmdStyle['"]>$cmd</span>
"@

            $result = Stringify (ConvertTo-Body $examples EXAMPLES 'any')

            $result | Should Match (StripLineBreaks $expected)
        }

        It 'Adds 2 breaks after the example header before the command' {
            $examples  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                EXAMPLES = $stdExample1
            }
            $expected = @"
----+ EXAMPLE 1 ----+\S+
\s*<br/>
\s*<br/>
\s*.*$cmd
"@

            $result = Stringify (ConvertTo-Body $examples EXAMPLES 'any')

            $result | Should Match (StripLineBreaks $expected)
        }

        It 'Adds 2 breaks after the command before the description' {
            $examples  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                EXAMPLES = $stdExample1
            }
            $expected = @"
$cmd\S+
\s*<br/>
\s*<br/>
\s*This gets the foobar for the current shebang.
"@

            $result = Stringify (ConvertTo-Body $examples EXAMPLES 'any')

            $result | Should Match (StripLineBreaks $expected)
        }

        It 'treats multiple standard lines as a single paragraph (like a browser would)' {
            $examples  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                EXAMPLES = $multiLineToSingleLine
            }
            $expected = @"
<span class=['"]$cmdStyle['"]>\s*Get-Something1 \|\s*Get-Something2\s*</span>
"@

            $result = Stringify (ConvertTo-Body $examples EXAMPLES 'any')

            $result | Should Match (StripLineBreaks $expected)
        }

        It 'Wraps multi-line command in span element' {
            $examples  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                EXAMPLES = $multiLineToMultiLine
            }
            $expected = @"
<span class=['"]$cmdStyle['"]>\s*Get-Something1 \|\s*<br/>\s*Get-Something2\s*</span>
"@

            $result = Stringify (ConvertTo-Body $examples EXAMPLES 'any')

            $result | Should Match (StripLineBreaks $expected)
        }

        It 'treats multiple lines with ps prompt as multiple paragraphs' {
            $examples  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                EXAMPLES = $multilineWithPrompts
            }
            $expected = @"
<span class=['"]$cmdStyle['"]>\s*Get-Something1 \| Got-Something<br/>\s*PS&gt; Get-Something2\s*</span>
"@

            $result = Stringify (ConvertTo-Body $examples EXAMPLES 'any')

            $result | Should Match (StripLineBreaks $expected)
        }

    }

    Describe 'Body/parameters' {

        Mock Write-Host
        Mock Get-CmdletDocLinks
        Mock Get-Template { "any" }
        Init-Variables

        BeforeEach {
            $stdSections  = @{
                NAME = @('any')
                DESCRIPTION = 'any'
                PARAMETERS = ' -Param1 <PSObject[]>',
                    ' List of project items ',
                    ' used by the project`',
                    ' in the course of its work.',
                    ' ',
                    ' Required? false',
                    ' Position? 1',
                    ' Default value $auditItems',
                    ' Accept pipeline input? false',
                    ' Accept wildcard characters? false',
                    ' ',
                    ' -TestFilter <String>',
                    ' This parameter is not used here.',
                    ' ',
                    ' Required? false',
                    ' Position? 2',
                    ' Default value ',
                    ' Accept pipeline input? false',
                    ' Accept wildcard characters? false',
                    ' ',
                    ' <CommonParameters>',
                    ' This cmdlet supports the common parameters: Verbose, Debug,',
                    ' ErrorAction, ErrorVariable, WarningAction, WarningVariable,',
                    ' OutBuffer, PipelineVariable, and OutVariable. For more information, see ',
                    ' about_CommonParameters (http://go.microsoft.com/fwlink/?LinkID=113216).',
                    ' '
            }
            $stdSectionOrder = @(
                'NAME'
                'SYNOPSIS'
                'SYNTAX'
                'DESCRIPTION'
                'PARAMETERS'
                'INPUTS'
                'OUTPUTS'
                'EXAMPLES'
                'RELATED LINKS'
            )
        }
        $expected = (@('<h2>PARAMETERS</h2>',
            '<div>',
            '<br/>',
            ' -<strong>Param1 &lt;PSObject[]&gt;</strong>',
            '<br/>',
            ' List of project items ',
            'used by the project`',
            'in the course of its work.',
            '<br/>',
            '<br/>',
            '<pre> Required? false</pre>',
            '<pre> Position? 1</pre>',
            '<pre> Default value $auditItems</pre>',
            '<pre> Accept pipeline input? false</pre>',
            '<pre> Accept wildcard characters? false</pre>',
            '<br/>',
            ' -<strong>TestFilter &lt;String&gt;</strong>',
            '<br/>',
            ' This parameter is not used here.',
            '<br/>',
            '<br/>',
            '<pre> Required? false</pre>',
            '<pre> Position? 2</pre>',
            '<pre> Default value </pre>',
            '<pre> Accept pipeline input? false</pre>',
            '<pre> Accept wildcard characters? false</pre>',
            '<br/>',
            ' &lt;CommonParameters&gt;',
            '<pre> This cmdlet supports the common parameters: Verbose, Debug,</pre>',
            '<pre> ErrorAction, ErrorVariable, WarningAction, WarningVariable,</pre>'
            '<pre> OutBuffer, PipelineVariable, and OutVariable. For more information, see </pre>',
            '<pre> about_CommonParameters (http://go.microsoft.com/fwlink/?LinkID=113216).</pre>',
            '<br/></div>'
        ) -join '') -replace '[$[+*?()\\.]','\$&'

        It 'correctly renders entire parameter section' {
            $result = Stringify (ConvertTo-Body $stdSections $stdSectionOrder 'any')

            $result | Should Match $expected
        }

        It 'Separates parameter name from properties with blank line' {
            $paramName = 'SomeParam'
            $stdSections.Parameters = GenerateText $paramName

            $result = ConvertTo-Body $stdSections $stdSectionOrder 'any'

            $paramResult = ($result | ? { $_ -match 'PARAMETERS' }) -split "`n"
            $i = GetArrayIndex $paramResult $paramName
            $paramResult[$i+1] | Should Match '<br/>'
            $paramResult[$i+2] | Should Match '<br/>'
            $paramResult[$i+3] | Should Match 'Required'
        }

        It 'Does not separate parameter name from description with blank line but includes a line break' {
            $paramName = 'SomeParam'
            $stdSections.Parameters = GenerateText $paramName $stdDescription

            $result = ConvertTo-Body $stdSections $stdSectionOrder 'any'

            $paramResult = ($result | ? { $_ -match 'PARAMETERS' }) -split "`n"
            $i = GetArrayIndex $paramResult $paramName
            $paramResult[$i+1] | Should Match '<br/>'
            $paramResult[$i+2] | Should Match ($stdDescription[0] -replace $8space)
        }

        It 'Separates description from properties with blank line' {
            $paramName = 'SomeParam'
            $stdSections.Parameters = GenerateText $paramName $stdDescription

            $result = ConvertTo-Body $stdSections $stdSectionOrder 'any'

            $paramResult = ($result | ? { $_ -match 'PARAMETERS' }) -split "`n"
            $descCount = $stdDescription.Count
            $i = GetArrayIndex $paramResult ($stdDescription[$descCount-1] -replace $8space)
            $paramResult[$i+1] | Should Match '<br/>'
            $paramResult[$i+2] | Should Match '<br/>'
            $paramResult[$i+3] | Should Match 'Required'
        }
    }

    Describe 'Indenting and line breaks' {

        It 'omits break for single words' {
            $text = 'one', 'two', 'three'
            (ApplyLineBreaks $text) -join ' ' |
            Should Be 'one two three'
        }

        It 'omits break for multiple words' {
            $text = 'one word', 'two words', 'three words'
            (ApplyLineBreaks $text) -join ' ' |
            Should Be 'one word two words three words'
        }

        It 'omits break for normal text' {
            $text = 'one word.', 'two words?', 'three words,', 'done'
            (ApplyLineBreaks $text) -join ' ' |
            Should Be 'one word. two words? three words, done'
        }

        It 'omits break for text with less than 4 header characters' {
            $text = 'one word.', 'it -- the green one --', 'is true'
            (ApplyLineBreaks $text) -join ' ' |
            Should Be 'one word. it -- the green one -- is true'
        }

        It 'includes auto-break after header-like text' {
            $text = 'title1 ----', 'title2 ====', 'title3 ####'
            (ApplyLineBreaks $text) -join '' |
            Should Be '<br/><strong>title1 ----</strong><br/><strong>title2 ====</strong><br/><strong>title3 ####</strong><br/>'
        }

        It 'includes auto-break after header-like text plus extra whitespace at the end' {
            $text = 'title1---- ', "title2 ====`t", 'plain text'
            (ApplyLineBreaks $text) -join '' |
            Should Be "<br/><strong>title1---- </strong><br/><strong>title2 ====`t</strong><br/>plain text"
        }

        It 'includes auto-break before and after header-like text' {
            $text = '---- title1 ----', '====title2===='
            (ApplyLineBreaks $text) -join '' |
            Should Be '<br/><strong>---- title1 ----</strong><br/><strong>====title2====</strong><br/>'
        }

        It 'includes auto-break before header-like text' {
            $text = '---- title1', '==== title2'
            (ApplyLineBreaks $text) -join '' |
            Should Be '<br/><strong>---- title1</strong><br/><strong>==== title2</strong><br/>'
        }

        It 'includes auto-break before header-like text plus extra whitespace at the start' {
            $text = ' ---- title1', ' ====== title2', 'plain text'
            (ApplyLineBreaks $text) -join '' |
            Should Be '<br/><strong> ---- title1</strong><br/><strong> ====== title2</strong><br/>plain text'
        }

        It 'uses preformat block for single indented line' {
            $text = ($8space + 'one'), ($4space + 'two')
            (ApplyLineBreaks $text) -join '' |
            Should Be '<pre> one</pre> two'
        }

        It 'uses preformat block for multiple indented lines' {
            $text = ($8space + 'one'), ($8space + 'two')
            (ApplyLineBreaks $text) -join '' |
            Should Be '<pre> one</pre><pre> two</pre>'
        }

        It 'includes auto-break for list-like text with no preamble' {
            $text = '* item1', '- item2', '+ item3'
            (ApplyLineBreaks $text) -join '' |
            Should Be '<br/>* item1<br/>- item2<br/>+ item3'
        }

        It 'includes auto-break for list-like text with immediate preamble' {
            $text = 'my list:','* item1', '- item2', '+ item3'
            (ApplyLineBreaks $text) -join '' |
            Should Be 'my list:<br/>* item1<br/>- item2<br/>+ item3'
        }

        It 'includes auto-break for list-like text with whitespace after preamble' {
            $text = 'my list:','','','* item1', '- item2', '+ item3'
            (ApplyLineBreaks $text) -join '' |
            Should Be 'my list:<br/><br/>* item1<br/>- item2<br/>+ item3'
        }

        It 'includes auto-break for list-like text plus extra whitespace at start' {
            $text = ' * item1', ' - item2', ' + item3'
            (ApplyLineBreaks $text) -join '' |
            Should Be '<br/> * item1<br/> - item2<br/> + item3'
        }

        It 'no line breaks with standard 4-space indent produced by Get-Help' {
            $text = ($4space+'line one'), ($4space+'line two'), ($4space+'line three')
            (ApplyLineBreaks $text) -join '' |
            Should Be ' line one line two line three'
        }

        $testCases = @(
            @{ spaces = ' '; description = '1' }
            @{ spaces = ' '; description = '2' }
            @{ spaces = ' '; description = '3' }
        )
        It 'includes auto-break for <description> leading spaces beyond standard 4-space indent' -testcases $testCases {
            param ($spaces)
            $text = ($4space+'line one'), ($4space+$spaces+'line two'), ($4space+'line three')
            (ApplyLineBreaks $text) -join '' |
            Should Be "${4space}line one<br/>${4space}${spaces}line two${4space}line three"
        }

        It 'includes auto-break for ps prompt on second line' {
            $text = ' cmdlet1', ' PS&gt; cmdlet2'
            (ApplyLineBreaks $text) -join '' |
            Should Be "${4space}cmdlet1<br/>${4space}PS&gt; cmdlet2"
        }

        It 'applies double-space for a blank line' {
            $text = 'one','','two'
            (ApplyLineBreaks $text) -join '' |
            Should Be 'one<br/><br/>two'
        }
        It 'applies just one double-space for multiple blank lines' {
            $text = 'one','','','','two'
            (ApplyLineBreaks $text) -join '' |
            Should Be 'one<br/><br/>two'
        }
        It 'applies just one double-space for list item followed by blank line' {
            $text = '* item1', '* item2', '','next para...'
            (ApplyLineBreaks $text) -join '' |
            Should Be '<br/>* item1<br/>* item2<br/><br/>next para...'
        }
        It 'omits break following a pre-formatted item' {
            $text = ($8space + 'one'), '------ header ----'
            (ApplyLineBreaks $text) -join '' |
            Should Be '<pre> one</pre><strong>------ header ----</strong><br/>'
        }

    }

    Describe 'Template available' {
        Mock Get-Content

        It 'Uses default if parameter not supplied' {
            $script:TemplateName = $null
            Get-Template 'default'
            Assert-MockCalled Get-Content 1 { $Path -eq 'default' } -Scope It
        }

        It 'Uses supplied value when default supplied' {
            $script:TemplateName = 'foo'
            Get-Template 'default'
            Assert-MockCalled Get-Content 1 { $Path -eq 'foo' } -Scope It
        }

        It 'Uses supplied value when default not supplied' {
            $script:TemplateName = 'foo'
            Get-Template
            Assert-MockCalled Get-Content 1 { $Path -eq 'foo' } -Scope It
        }
    }

    Describe 'Template not available' {

        It 'Reports error if default template not found' {
            $script:TemplateName = $null
            { Get-Template 'non-existent-file' } | Should Throw 'Cannot find path'
        }

        It 'Reports error if supplied template not found' {
            $script:TemplateName = 'non-existent-file'
            { Get-Template } | Should Throw 'Cannot find path'
        }
    }

    Describe 'Links' {
        Mock Write-Host
        Mock Get-Template { "any" }

        $stdTestCases = @(
            @{ template = '{0}'; description = 'no extra spaces'}
            @{ template = ' {0}'; description = 'extra spaces at start of line'}
            @{ template = '{0} '; description = 'extra spaces at end of line'}
        )

        It 'Generates link for standard cmdlet with <description>' -TestCases $stdTestCases {
            param ($template, $description)
            $url = 'http://any.com'
            $cmdlet = 'Get-ChildItem'
            $inputText = $template -f $cmdlet
            Mock Get-CmdletDocLinks { return @{ $cmdlet = $url } }
            Init-Variables

            Add-Links 'any' $inputText | Should Be "<li><a href='$url'>$cmdlet</a></li>"
        }

        It 'Generates link for "about" topic with <description>' -TestCases $stdTestCases {
            param ($template, $description)
            $url = 'http://any.com'
            $aboutTopic = 'about_Aliases'
            $inputText = $template -f $aboutTopic
            Mock Get-CmdletDocLinks { return @{ $aboutTopic = $url } }
            Init-Variables

            Add-Links 'any' $inputText | Should Be "<li><a href='$url'>$aboutTopic</a></li>"
        }

        It 'Generates link for custom function in same module with <description>' -TestCases $stdTestCases {
            param ($template, $description)
            $currModule = 'myModule'
            $cmd = 'New-Frobdingnab'
            $inputText = $template -f $cmd
            Mock Get-CmdletDocLinks { return @{ } }
            Mock Get-Command `
                -MockWith { return @{
                    ModuleName = $currModule
                    Module = @{ Path = 'gparent\parent\self'} # any -- not used
                } } `
                -ParameterFilter { $Name -eq $cmd }
            Init-Variables

            Add-Links $currModule $inputText | Should Be "<li><a href='$cmd.html'>$cmd</a></li>"
        }

        It 'Generates link for custom function in different module with <description>' -TestCases $stdTestCases {
            param ($template, $description)
            $someModule = 'someModule'
            $namespace = 'someNS'
            $cmd = 'New-Frobdingnab'
            $inputText = $template -f $cmd
            Mock Get-CmdletDocLinks { return @{ } }
            Mock Get-Command `
                -MockWith { return @{
                    ModuleName = $someModule
                    Module = @{ Path = "$namespace\$someModule\$someModule.psm1"}
                } } `
                -ParameterFilter { $Name -eq $cmd }
            Init-Variables

            Add-Links 'currentModule' $inputText |
                Should Be "<li><a href='../../$namespace/$someModule/$cmd.html'>$cmd</a></li>"
        }

        It 'Generates no link for unidentified custom function in different module' -TestCases $stdTestCases {
            param ($template, $description)
            $someModule = 'someModule'
            $namespace = 'someNS'
            $cmd = 'New-Frobdingnab'
            $inputText = $template -f $cmd
            Mock Get-CmdletDocLinks { return @{ } }
            Mock Get-Command `
                -MockWith { return $null } `
                -ParameterFilter { $Name -eq $cmd }
            Init-Variables

            Add-Links 'currentModule' $inputText |
                Should Be "<li>$cmd</li>"
        }

        It 'Generates no link for plain text with <description>' -TestCases $stdTestCases {
            param ($template, $description)
            $plainText = 'not a cmdlet'
            $inputText = $template -f $plainText
            Mock Get-CmdletDocLinks { return @{ } }
            Init-Variables

            Add-Links 'any' $inputText | Should Be "<li>$plainText</li>"
        }

        $testCases = @(
            @{ inputText = 'not a cmdlet'; description = 'multiple words'}
            @{ inputText = 'one_word'; description = 'single word'}
            @{ inputText = '!#$@'; description = 'stray characters'}
        )
        It 'Generates no link for plain text with <description>' -TestCases $testCases {
            param ($inputText, $description)
            Mock Get-CmdletDocLinks { return @{ } }
            Init-Variables

            Add-Links 'any' $inputText | Should Be "<li>$inputText</li>"
        }


        $testCases = @(
            @{ template = '[{0}]({1})'; description = 'no extra spaces'}
            @{ template = '[ {0} ]({1})'; description = 'extra spaces in label'}
            @{ template = '[{0}]( {1} )'; description = 'extra spaces in url'}
            @{ template = '[{0}] ({1})'; description = 'extra spaces between label and url'}
            @{ template = '[{0}]({1}) '; description = 'extra spaces at end of line'}
            @{ template = ' [{0}]({1})'; description = 'extra spaces at start of line'}
        )
        It 'Generates link for explicit link with label with <description>' -TestCases $testCases {
            param ($template, $description)
            $url = 'http://any.com'
            $label = 'explicit label'
            $inputText = $template -f $label, $url
            Mock Get-CmdletDocLinks { return @{ } }
            Init-Variables

            Add-Links 'any' $inputText | Should Be "<li><a href='$url'>$label</a></li>"
        }

        It 'Generates link for explicit link without label with <description>' -TestCases $stdTestCases {
            param ($template, $description)
            $url = 'http://any.com'
            $inputText = $template -f $url
            Mock Get-CmdletDocLinks { return @{ } }
            Init-Variables

            Add-Links 'any' $inputText | Should Be "<li><a href='$url'>$url</a></li>"
        }
    }

    Describe 'Parameters' {

        It 'Emboldens parameter name by itself' {
            $paramName = 'SomeParam'
            $text = " -$paramName"
            CorrectParamIndents $text | Should Be " -<strong>$paramName</strong>"
        }

        It 'Emboldens parameter name with properties' {
            $paramName = 'SomeParam'
            (CorrectParamIndents (GenerateText $paramName)) -join '' |
                Should Match "^ -<strong>$paramName</strong>"
        }

        It 'Emboldens parameter name with description and properties' {
            $paramName = 'SomeParam'
            (CorrectParamIndents (GenerateText $paramName $stdDescription)) -join '' |
                Should Match "^ -<strong>$paramName</strong>"
        }

        It 'Separates parameter name from properties with blank line' {
            $paramName = 'SomeParam'
            $result = CorrectParamIndents (GenerateText $paramName)
            $result.Count | Should Be ($stdProperties.Count + 1)
            $result[0] | Should Match $paramName
            $result[1] | Should Match '^\s*$'
            $result[2] | Should Match 'Required'
        }

        It 'Does not separate parameter name from description with blank line' {
            $paramName = 'SomeParam'
            $result = CorrectParamIndents (GenerateText $paramName $stdDescription)
            $result.Count | Should Be ($stdProperties.Count + 1 + $stdDescription.Count)
            $result[0] | Should Match $paramName
            $result[1] | Should Match ($stdDescription[0] -replace $8space)
        }

        It 'Separates description from properties with blank line' {
            $descCount = $stdDescription.Count
            $paramName = 'SomeParam'
            $result = CorrectParamIndents (GenerateText $paramName $stdDescription)
            $result[$descCount] | Should Be ($stdDescription[$descCount-1] -replace $8space)
            $result[$descCount+1] | Should BeNullOrEmpty
            $result[$descCount+2] | Should Match 'Required'
        }

        It 'Removes leading spaces on description except for the first line' {
            $descCount = $stdDescription.Count
            $paramName = 'SomeParam'
            $result = CorrectParamIndents (GenerateText $paramName $stdDescription)
            $result[1] | Should Be ($stdDescription[0] -replace $8space, "${4space}${2space}")
            2..$descCount | % { $result[$_] | Should be ($stdDescription[$_-1] -replace $8space) }
        }

        It 'Retains leading spaces on properties when no description' {
            $paramName = 'SomeParam'
            (CorrectParamIndents (GenerateText $paramName)) -join '' |
                Should Match "$($8space)Required.*$($8space)Position.*$($8space)Default.*($8space)Accept pipeline.*($8space)Accept wildcard"
        }

        It 'Retains leading spaces on properties when description present' {
            $descCount = $stdDescription.Count
            $paramName = 'SomeParam'
            $result = CorrectParamIndents (GenerateText $paramName $stdDescription)
            1..($stdProperties.Length-1) | % {
                $result[$descCount+1+$_] | Should Be $stdProperties[$_]
            }
        }
    }

    Describe 'Overview' {
        Mock Write-Host
        Mock Get-CmdletDocLinks
        Mock Get-Template { 'any' }
        Mock Test-Path { $true }
        Mock Handle-MissingValue { 'missing value' }
        Init-Variables

        $testCases = @(
            @{ content='foo';  description='non-html' }
            @{ content='<html>';  description='mal-formed html 1' }
            @{ content='<html></html';  description='mal-formed html 2' }
            @{ content='<html><body></html>';  description='mal-formed html 3' }
        )
        It 'Reports <description> as invalid content' -TestCases $testCases {
            param ($content, $msg)
            $filename = 'anyfile.html'
            Mock Get-Content { $content }
            Get-Overview $moduleRoot $filename
            Assert-MockCalled Handle-MissingValue 1 -ParameterFilter { $message -match "cannot convert.*$content" }  -Scope It
            Assert-MockCalled Handle-MissingValue 1 -ParameterFilter { $message -match "missing summary.*$filename" }  -Scope It
        }

        $testCases = @(
            @{ content='<p></p>';  msg='Overview.*must be a complete, valid HTML file'; description='no root html element' }
            @{ content='<html></html>';  msg='Overview.*must contain.*body'; description='empty html' }
            @{ content='<html><head></head></html>';  msg='Overview.*must contain.*body'; description='html with no body' }
            @{ content='<html><body></body></html>'; msg='body.*must be non-empty'; description='empty body' }
            @{ content='<html><body>raw text</body></html>'; msg='body.*must be in a child element'; description='raw body' }
        )
        It 'Reports <description> as invalid content' -TestCases $testCases {
            param ($content, $msg)
            $filename = 'anyfile.html'
            Mock Get-Content { $content }
            Get-Overview $moduleRoot $filename
            Assert-MockCalled Handle-MissingValue 1 -ParameterFilter { $message -match $msg }  -Scope It
            Assert-MockCalled Handle-MissingValue 1 -ParameterFilter { $message -match "missing summary.*$filename" }  -Scope It
        }

        $testCases = @(
            @{ content='<p></p>';  description='empty element' }
            @{ content='<p>overview text</p>';  description='nominal paragraph' }
        )
        It 'Returns <description> as body content' -TestCases $testCases {
            param ($content)
            $filename = 'anyfile.html'
            Mock Get-Content { "<html><body>$content</body></html>" }
            $result = Get-Overview $moduleRoot $filename
            $result | Should Match $content
        }

        It 'Handles nominal paragraph, multi-line' {
            $content = @'
<p>
Module Description
</p>
'@

            $frame = @"
<html>
<body>
$content
</body>
</html>
"@

            $filename = 'anyfile.html'
            Mock Get-Content { $frame }
            $result = Get-Overview $moduleRoot $filename
            Stringify $result | Should Match (Stringify $content)
        }

        It 'Handles standard attributes on html element' {
            $content = @'
<p>Module Description</p>
'@

            $frame = @"
<html xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.w3.org/1999/xhtml http://www.w3.org/2002/08/xhtml/xhtml1-transitional.xsd" xml:lang="en" lang="en">
<body>
$content
</body>
</html>
"@

            $filename = 'anyfile.html'
            Mock Get-Content { $frame }
            $result = Get-Overview $moduleRoot $filename
            Stringify $result | Should Match (Stringify $content)
        }

        It 'Handles standard doctype' {
            $content = @'
<p>Module Description</p>
'@

            $frame =
@"
<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<body>
$content
</body>
</html>
"@

            $filename = 'anyfile.html'
            Mock Get-Content { $frame }
            $result = Get-Overview $moduleRoot $filename
            Stringify $result | Should Match (Stringify $content)
        }

    }

    Describe 'Main' {
        Mock Write-Host
        Mock Write-Warning
        Mock Get-CmdletDocLinks
        Mock Handle-MissingValue
        Mock Generate-HomePage
        Mock Generate-ContentsPage

        It 'Imports each module in a namespace before building' {
            Mock Import-AllModules -MockWith { $script:sequence += 'import'; return 'm1' } -ParameterFilter { $namespace -eq 'ns1' }
            Mock Remove-AllModules { $script:sequence += 'remove' }
            Mock Process-Module { $script:sequence += 'process' }
            Mock GlobExpandNamespaceArgument { $nsArgument }
            $script:sequence = @()
            Convert-HelpToHtmlTree -Namespaces 'ns1'
            Assert-MockCalled Import-AllModules 1 -Scope It
            $sequence[0] | Should Be 'import'
            $sequence[1] | Should Be 'process'
        }

        It 'Removes each module in a namespace after building' {
            Mock Import-AllModules -MockWith { $script:sequence += 'import'; return 'm1' } -ParameterFilter { $namespace -eq 'ns1' }
            Mock Remove-AllModules { $script:sequence += 'remove' }
            Mock Process-Module { $script:sequence += 'process' }
            Mock GlobExpandNamespaceArgument { $nsArgument }
            $script:sequence = @()
            Convert-HelpToHtmlTree -Namespaces 'ns1'
            Assert-MockCalled Remove-AllModules 1 -Scope It
            $sequence[1] | Should Be 'process'
            $sequence[2] | Should Be 'remove'
        }

        It 'Processes each module in a single namespace' {
            $moduleNames = 'm1','m2'
            Mock Import-AllModules -MockWith { $moduleNames } -ParameterFilter { $namespace -eq 'ns1' }
            Mock Remove-AllModules { $script:sequence += 'remove' }
            Mock Process-Module { $script:sequence += 'process' }
            Mock GlobExpandNamespaceArgument { $nsArgument }
            $script:sequence = @()
            Convert-HelpToHtmlTree -Namespaces 'ns1'
            foreach ($name in $moduleNames) {
                Assert-MockCalled Process-Module 1 { $moduleName -eq $name } -Scope It
            }
        }

        It 'Processes each module in multiple namespaces' {
            $ns1Modules = @('ns1-m1', 'ns1-m2')
            $ns2Modules = @('ns2-m1', 'ns2-m2', 'ns2-m3')
            $namespaces = @{
                'nspace1' = $ns1Modules
                'nspace2' = $ns2Modules
                }
            Mock Import-AllModules -MockWith { $ns1Modules } -ParameterFilter { $namespace -eq 'nspace1' }
            Mock Import-AllModules -MockWith { $ns2Modules } -ParameterFilter { $namespace -eq 'nspace2' }
            Mock Remove-AllModules { $script:sequence += 'remove' }
            Mock Process-Module { $script:sequence += 'process' }
            Mock GlobExpandNamespaceArgument { $nsArgument }
            $script:sequence = @()
            Convert-HelpToHtmlTree -Namespaces 'nspace1','nspace2'
            foreach ($ns in $namespaces.keys) {
                foreach ($name in $namespaces[$ns]) {
                    #Assert-MockCalled Process-Module 1 { $moduleName -eq $name } -Scope It
                }
            }
        }

        It 'WARNS about no namespaces when supplied argument does not resolve to path' {
            Mock Import-AllModules -ParameterFilter { $namespace -eq 'unknownNamespace' }
            Mock Remove-AllModules
            Mock Process-Module
            Mock GlobExpandNamespaceArgument { return @() }
            Convert-HelpToHtmlTree -Namespaces 'unknownNamespace'
            Assert-MockCalled Handle-MissingValue 1 { $message -eq 'No namespaces found' } -scope It
        }

        It 'Does NOT warn about no namespaces when supplied argument resolves to path' {
            Mock Import-AllModules -MockWith { 'm1' } -ParameterFilter { $namespace -eq 'ns1' }
            Mock Remove-AllModules
            Mock GlobExpandNamespaceArgument { $nsArgument }
            # emulate real Process-Module with respect to generating warning
            Mock Process-Module { $script:moduleCount++ }
            Convert-HelpToHtmlTree -Namespaces 'ns1'
            Assert-MockCalled Handle-MissingValue 0 -Scope It
        }

        It 'WARNS about no modules when single namespace dir has none' {
            Mock Import-AllModules -MockWith { @() } -ParameterFilter { $namespace -eq 'ns1' }
            Mock Remove-AllModules
            Mock GlobExpandNamespaceArgument { $nsArgument }
            Mock Process-Module { $script:moduleCount++ }
            Convert-HelpToHtmlTree -Namespaces 'ns1'
            Assert-MockCalled Handle-MissingValue 1 { $message -match 'No modules found.*ns1' } -scope It
        }

        It 'WARNS about no modules when one of several namespace dirs has none' {
            Mock Import-AllModules { @() } -ParameterFilter { $namespace -eq 'ns2' }
            Mock Import-AllModules { 'm1','m2' } -ParameterFilter { $namespace -in 'ns1','ns3' }
            Mock Remove-AllModules
            Mock GlobExpandNamespaceArgument { $nsArgument }
            Mock Process-Module { $script:moduleCount++ }
            Convert-HelpToHtmlTree -Namespaces 'ns1','ns2','ns3'
            Assert-MockCalled Handle-MissingValue 1 { $message -match 'No modules found.*ns2' } -scope It
        }

        It 'WARNS about no modules when multiple namespace dirs have none' {
            Mock Import-AllModules { @() } -ParameterFilter { $namespace -in 'ns2','ns4' }
            Mock Import-AllModules { 'm1','m2' } -ParameterFilter { $namespace -in 'ns1','ns3' }
            Mock Remove-AllModules
            Mock GlobExpandNamespaceArgument { $nsArgument }
            Mock Process-Module { $script:moduleCount++ }
            Convert-HelpToHtmlTree -Namespaces 'ns1','ns2','ns3','ns4'
            Assert-MockCalled Handle-MissingValue 1 { $message -match 'No modules found.*ns2.*ns4' } -scope It
        }

        It 'Does NOT warn about no modules when modules present' {
            Mock Import-AllModules -MockWith { 'm1' } -ParameterFilter { $namespace -eq 'ns1' }
            Mock Remove-AllModules
            Mock GlobExpandNamespaceArgument { $nsArgument }
            Mock Process-Module { $script:moduleCount++ }
            Convert-HelpToHtmlTree -Namespaces 'ns1'
            Assert-MockCalled Handle-MissingValue 0  -scope It
        }

        It 'Returns multiple modules that loaded without error' {
            Mock Get-ChildItem { @(
                (New-Object PSObject -Property @{ name = 'badModule'; PsIsContainer = $true }),
                (New-Object PSObject -Property @{ name = 'm1'; PsIsContainer = $true }),
                (New-Object PSObject -Property @{ name = 'm2'; PsIsContainer = $true })
            ) }
            Mock Import-ModuleUnlessDocGeneratorItself { throw "bad module" } -ParameterFilter { $moduleName -eq 'badModule' }
            Mock Import-ModuleUnlessDocGeneratorItself 
            Mock Remove-AllModules
            Mock GlobExpandNamespaceArgument { $nsArgument }
            Mock Process-Module
            Convert-HelpToHtmlTree -Namespaces 'ns5'
            Assert-MockCalled Handle-MissingValue 1 { $message -match 'bad module' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules -contains 'm1' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules -contains 'm2' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules -notcontains 'badModule' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules.Count -eq 2 } -scope It
        }

        It 'Returns single module out of multiple modules that loaded without error' {
            Mock Get-ChildItem { @(
                (New-Object PSObject -Property @{ name = 'badM1'; PsIsContainer = $true }),
                (New-Object PSObject -Property @{ name = 'badM2'; PsIsContainer = $true }),
                (New-Object PSObject -Property @{ name = 'm1'; PsIsContainer = $true })
            ) }
            Mock Import-ModuleUnlessDocGeneratorItself { throw "bad module" } -ParameterFilter { $moduleName -match 'bad' }
            Mock Import-ModuleUnlessDocGeneratorItself 
            Mock Remove-AllModules
            Mock GlobExpandNamespaceArgument { $nsArgument }
            Mock Process-Module
            Convert-HelpToHtmlTree -Namespaces 'ns5'
            Assert-MockCalled Handle-MissingValue 1 { $message -match 'bad module' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules -contains 'm1' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules -notcontains 'badM1' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules -notcontains 'badM2' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules.Count -eq 1 } -scope It
        }

        It 'Returns sole module that loaded without error' {
            Mock Get-ChildItem { @(
                (New-Object PSObject -Property @{ name = 'm1'; PsIsContainer = $true })
            ) }
            Mock Import-ModuleUnlessDocGeneratorItself { throw "bad module" } -ParameterFilter { $moduleName -match 'bad' }
            Mock Import-ModuleUnlessDocGeneratorItself 
            Mock Remove-AllModules
            Mock GlobExpandNamespaceArgument { $nsArgument }
            Mock Process-Module
            Convert-HelpToHtmlTree -Namespaces 'ns5'
            Assert-MockCalled Handle-MissingValue 0 { $message -match 'bad module' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules -contains 'm1' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules.Count -eq 1 } -scope It
        }

        It 'Returns no modules when sole module has error' {
            Mock Get-ChildItem { @(
                (New-Object PSObject -Property @{ name = 'badModule'; PsIsContainer = $true })
            ) }
            Mock Import-ModuleUnlessDocGeneratorItself { throw "bad module" } -ParameterFilter { $moduleName -match 'bad' }
            Mock Import-ModuleUnlessDocGeneratorItself 
            Mock Remove-AllModules
            Mock GlobExpandNamespaceArgument { $nsArgument }
            Mock Process-Module
            Convert-HelpToHtmlTree -Namespaces 'ns5'
            Assert-MockCalled Handle-MissingValue 1 { $message -match 'bad module' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules -notcontains 'badModule' } -scope It
            Assert-MockCalled Remove-AllModules 1 { $nsModules.Count -eq 0 } -scope It
        }

    }

}