Viscalyx.Assert.psm1

#Region '.\prefix.ps1' -1

$script:dscResourceCommonModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules/DscResource.Common'
Import-Module -Name $script:dscResourceCommonModulePath

$script:viscalyxCommonModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules/Viscalyx.Common'
Import-Module -Name $script:viscalyxCommonModulePath

$script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US'
#EndRegion '.\prefix.ps1' 8
#Region '.\Public\Assert-BlockString.ps1' -1

<#
    .SYNOPSIS
        Asserts that a string, here-string, or array of strings matches the expected
        value.
 
    .DESCRIPTION
        The `Assert-BlockString` command compares a string, here-string, or array of
        strings against an expected string, here-string, or array of strings. If
        they are not identical, it throws an error that includes the hex output.
        The comparison is case-sensitive. It is commonly used in unit testing
        scenarios to verify the correctness of string outputs at the byte level.
 
    .PARAMETER Actual
        The actual string, here-string, or array of strings to be compared with
        the expected value. This parameter accepts pipeline input.
 
    .PARAMETER Expected
        The expected string, here-string, or array of strings that the actual value
        should match.
 
    .PARAMETER Because
        An optional reason or explanation for the assertion.
 
    .PARAMETER Highlight
        An optional ANSI color code to highlight the difference between the expected
        and actual strings. The default value is '31m' (red text).
 
    .PARAMETER NoHexOutput
        Specifies whether to omit the hex columns and output only the character groups.
 
    .EXAMPLE
        PS> Assert-BlockString -Actual 'hello', 'world' -Expected 'Hello', 'World'
 
        This example asserts that the array of strings 'hello' and 'world' matches
        the expected array of strings 'Hello' and 'World'. If the assertion fails,
        an error is thrown.
 
    .EXAMPLE
        PS> 'hello', 'world' | Assert-BlockString -Expected 'Hello', 'World'
 
        This example demonstrates the usage of pipeline input. A string array containing
        'hello' and 'world' are piped to `Assert-BlockString` and compared with the
        expected string array containing 'Hello' and 'World'. If the assertion fails,
        an error is thrown.
 
    .NOTES
        TODO: Is it possible to rename the command to `Should-BeBlockString`? Pester
        handles commands with the `Should` verb; however, it is unclear if this issue
        can be resolved here. See:
        https://github.com/pester/Pester/commit/c8bc9679bed19c8fbc4229caa01dd083f2d03d4f#diff-b7592dd925696de2521c9b12b966d65519d502045462f002c343caa7c0986936
        and
        https://github.com/pester/Pester/commit/c8bc9679bed19c8fbc4229caa01dd083f2d03d4f#diff-460f64eafc16facefbed201eb00fb151c75eadf7cc58a504a01527015fb1c7cdR17
#>

function Assert-BlockString
{
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseSyntacticallyCorrectExamples', '', Justification = 'Because the examples are syntactically correct. The rule does not seem to understand that there is pipeline input.')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseProcessBlockForPipelineCommand', '')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('AvoidThrowOutsideOfTry', '')]
    [CmdletBinding()]
    [Alias('Should-BeBlockString')]
    [OutputType()]
    param
    (
        [Parameter(Position = 1, Mandatory = $true, ValueFromPipeline = $true)]
        [System.Object]
        $Actual,

        [Parameter(Position = 0, Mandatory = $true)]
        [System.Object]
        $Expected,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Because,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Highlight = '31m',

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $NoHexOutput
    )

    $hasPipelineInput = $MyInvocation.ExpectingInput

    if ($hasPipelineInput)
    {
        $Actual = @($local:Input)
    }

    # Verify if $Actual is a string or string array
    $isActualStringType = $Actual -is [System.String] -or ($Actual -is [System.Array] -and ($Actual.Count -eq 0 -or ($Actual.Count -gt 0 -and $Actual[0] -is [System.String])))

    $isExpectedStringType = $Expected -is [System.String] -or ($Expected -is [System.Array] -and ($Expected.Count -eq 0 -or ($Expected.Count -gt 0 -and $Expected[0] -is [System.String])))

    $stringsAreEqual = $isActualStringType -and $isExpectedStringType -and (-join $Actual) -ceq (-join $Expected)

    if (-not $stringsAreEqual)
    {
        if (-not $isActualStringType)
        {
            $message = $script:localizedData.Assert_BlockString_ActualInvalid
        }
        elseif (-not $isExpectedStringType)
        {
            $message = $script:localizedData.Assert_BlockString_ExpectedInvalid
        }
        else
        {
            $message = $script:localizedData.Assert_BlockString_StringsNotEqual

            if ($Because)
            {
                $message += " {0} $Because" -f $script:localizedData.Assert_BlockString_StringsNotEqual_Because
            }

            $message += "{0}`r`n " -f $script:localizedData.Assert_BlockString_Difference

            $message += Out-Difference -Reference $Expected -Difference $Actual -ReferenceLabel 'Expected:' -DifferenceLabel 'But was:' -HighlightStart:$Highlight -NoHexOutput:$NoHexOutput.IsPresent |
                ForEach-Object -Process { "`e[0m$_`r`n" }
        }

        throw [Pester.Factory]::CreateShouldErrorRecord($Message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
    }
}
#EndRegion '.\Public\Assert-BlockString.ps1' 129
#Region '.\Public\Assert-ObjectMethod.ps1' -1

<#
    .SYNOPSIS
        Asserts that an object contains a specified method.
 
    .DESCRIPTION
        The `Assert-ObjectMethod` command verifies that an object contains a
        specified method. This is commonly used in unit testing scenarios to
        verify object structure and ensure that required methods are available
        on objects before attempting to invoke them.
 
    .PARAMETER Method
        The name of the method to assert exists on the object.
 
    .PARAMETER Actual
        The object to be inspected for the method. This parameter accepts
        pipeline input.
 
    .PARAMETER Because
        An optional reason or explanation for the assertion.
 
    .INPUTS
        System.Object
            Accepts any object via the pipeline for method inspection.
 
    .OUTPUTS
        None
            This command does not return any output on success.
 
    .EXAMPLE
        PS> Assert-ObjectMethod -Actual $myObject -Method 'ToString'
 
        This example asserts that the object in `$myObject` has a method named
        'ToString'. If the method does not exist, an error is thrown.
 
    .EXAMPLE
        PS> $myObject | Assert-ObjectMethod -Method 'GetHashCode'
 
        This example demonstrates pipeline usage. The object `$myObject` is piped
        to `Assert-ObjectMethod` and checked for a method named 'GetHashCode'.
 
    .EXAMPLE
        PS> Assert-ObjectMethod -Method 'Connect' -Actual $connection -Because 'the connection object should support Connect method'
 
        This example asserts that `$connection` has a method named 'Connect',
        providing a reason for the assertion.
 
    .EXAMPLE
        PS> $collection | Assert-ObjectMethod -Method 'Add'
 
        This example verifies that a collection object has an 'Add' method,
        which is useful when you need to ensure you can add items to a collection.
#>

function Assert-ObjectMethod
{
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseSyntacticallyCorrectExamples', '', Justification = 'Because the examples are syntactically correct. The rule does not seem to understand that there is pipeline input.')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseProcessBlockForPipelineCommand', '')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('AvoidThrowOutsideOfTry', '')]
    [CmdletBinding()]
    [Alias('Should-HaveMethod')]
    [OutputType()]
    param
    (
        [Parameter(Position = 0, Mandatory = $true)]
        [System.String]
        $Method,

        [Parameter(Position = 1, Mandatory = $true, ValueFromPipeline = $true)]
        [System.Object]
        $Actual,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Because
    )

    $hasPipelineInput = $MyInvocation.ExpectingInput

    if ($hasPipelineInput)
    {
        $Actual = @($local:Input)
    }

    # If multiple objects were passed via pipeline, iterate through each one
    if ($hasPipelineInput -and $Actual -is [System.Array] -and $Actual.Count -gt 0)
    {
        foreach ($currentObject in $Actual)
        {
            # Check if the current object is null
            if ($null -eq $currentObject)
            {
                $message = $script:localizedData.Assert_ObjectMethod_ActualIsNull

                if ($Because)
                {
                    $message += " {0} $Because" -f $script:localizedData.Assert_ObjectMethod_Because
                }

                throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
            }

            # Check if the method exists on the current object
            $hasMethod = $false
            try
            {
                # Use Get-Member to check for method existence
                $member = $currentObject | Get-Member -Name $Method -MemberType Method, ScriptMethod -ErrorAction SilentlyContinue
                if ($null -ne $member)
                {
                    $hasMethod = $true
                }
                else
                {
                    # For dynamic objects and PSCustomObject, also check PSObject.Methods
                    $methodMember = $currentObject.PSObject.Methods[$Method]
                    if ($null -ne $methodMember)
                    {
                        $hasMethod = $true
                    }
                    else
                    {
                        # Final check: try to get the method directly for edge cases
                        try
                        {
                            $methodInfo = $currentObject.GetType().GetMethod($Method)
                            if ($null -ne $methodInfo)
                            {
                                $hasMethod = $true
                            }
                        }
                        catch
                        {
                            # If reflection fails, the method doesn't exist
                            $hasMethod = $false
                        }
                    }
                }
            }
            catch
            {
                $hasMethod = $false
            }

            if (-not $hasMethod)
            {
                $message = $script:localizedData.Assert_ObjectMethod_MethodNotFound -f $Method

                if ($Because)
                {
                    $message += " {0} $Because" -f $script:localizedData.Assert_ObjectMethod_Because
                }

                throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
            }
        }
    }
    else
    {
        # Single object case (direct parameter or not from pipeline)
        # Check if the actual value is null
        if ($null -eq $Actual)
        {
            $message = $script:localizedData.Assert_ObjectMethod_ActualIsNull

            if ($Because)
            {
                $message += " {0} $Because" -f $script:localizedData.Assert_ObjectMethod_Because
            }

            throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
        }

        # Check if the method exists on the object
        $hasMethod = $false
        try
        {
            # Use Get-Member to check for method existence
            $member = $Actual | Get-Member -Name $Method -MemberType Method, ScriptMethod -ErrorAction SilentlyContinue
            if ($null -ne $member)
            {
                $hasMethod = $true
            }
            else
            {
                # For dynamic objects and PSCustomObject, also check PSObject.Methods
                $methodMember = $Actual.PSObject.Methods[$Method]
                if ($null -ne $methodMember)
                {
                    $hasMethod = $true
                }
                else
                {
                    # Final check: try to get the method directly for edge cases
                    try
                    {
                        $methodInfo = $Actual.GetType().GetMethod($Method)
                        if ($null -ne $methodInfo)
                        {
                            $hasMethod = $true
                        }
                    }
                    catch
                    {
                        # If reflection fails, the method doesn't exist
                        $hasMethod = $false
                    }
                }
            }
        }
        catch
        {
            $hasMethod = $false
        }

        if (-not $hasMethod)
        {
            $message = $script:localizedData.Assert_ObjectMethod_MethodNotFound -f $Method

            if ($Because)
            {
                $message += " {0} $Because" -f $script:localizedData.Assert_ObjectMethod_Because
            }

            throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
        }
    }
}
#EndRegion '.\Public\Assert-ObjectMethod.ps1' 228
#Region '.\Public\Assert-ObjectProperty.ps1' -1

<#
    .SYNOPSIS
        Asserts that an object contains a specified property and optionally that
        the property has a specified value.
 
    .DESCRIPTION
        The `Assert-ObjectProperty` command verifies that an object contains a
        specified property. It can optionally also verify that the property has
        a specific value. This is commonly used in unit testing scenarios to
        verify object structure and property values.
 
    .PARAMETER Property
        The name of the property to assert exists on the object.
 
    .PARAMETER Actual
        The object to be inspected for the property. This parameter accepts
        pipeline input.
 
    .PARAMETER Value
        The expected value of the property. If specified, the assertion will
        check both property existence and value equality.
 
    .PARAMETER Because
        An optional reason or explanation for the assertion.
 
    .INPUTS
        System.Object
            Accepts any object via the pipeline for property inspection.
 
    .OUTPUTS
        None
            This command does not return any output on success.
 
    .EXAMPLE
        PS> Assert-ObjectProperty -Actual $myObject -Property 'Enabled'
 
        This example asserts that the object in `$myObject` has a property named
        'Enabled'. If the property does not exist, an error is thrown.
 
    .EXAMPLE
        PS> Assert-ObjectProperty -Actual $myObject -Property 'Enabled' -Value $true
 
        This example asserts that the object in `$myObject` has a property named
        'Enabled' with the value `$true`. If the property does not exist or the
        value does not match, an error is thrown.
 
    .EXAMPLE
        PS> $myObject | Assert-ObjectProperty -Property 'Status' -Value 'Running'
 
        This example demonstrates pipeline usage. The object `$myObject` is piped
        to `Assert-ObjectProperty` and checked for a property named 'Status' with
        the value 'Running'.
 
    .EXAMPLE
        PS> Assert-ObjectProperty -Property 'Count' -Actual $collection -Value 5 -Because 'the collection should contain exactly 5 items'
 
        This example asserts that `$collection` has a property named 'Count' with
        the value 5, providing a reason for the assertion.
#>

function Assert-ObjectProperty
{
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseSyntacticallyCorrectExamples', '', Justification = 'Because the examples are syntactically correct. The rule does not seem to understand that there is pipeline input.')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseProcessBlockForPipelineCommand', '')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('AvoidThrowOutsideOfTry', '')]
    [CmdletBinding(DefaultParameterSetName = 'AssertProperty')]
    [Alias('Should-HaveProperty')]
    [OutputType()]
    param
    (
        [Parameter(ParameterSetName = 'AssertProperty', Position = 0, Mandatory = $true)]
        [Parameter(ParameterSetName = 'AssertValue', Position = 0, Mandatory = $true)]
        [System.String]
        $Property,

        [Parameter(ParameterSetName = 'AssertProperty', Position = 1, Mandatory = $true, ValueFromPipeline = $true)]
        [Parameter(ParameterSetName = 'AssertValue', Position = 2, Mandatory = $true, ValueFromPipeline = $true)]
        [System.Object]
        $Actual,

        [Parameter(ParameterSetName = 'AssertValue', Position = 1, Mandatory = $true)]
        [AllowNull()]
        [System.Object]
        $Value,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Because
    )

    $hasPipelineInput = $MyInvocation.ExpectingInput

    if ($hasPipelineInput)
    {
        $Actual = @($local:Input)
    }

    # If multiple objects were passed via pipeline, iterate through each one
    if ($hasPipelineInput -and $Actual -is [System.Array] -and $Actual.Count -gt 0)
    {
        foreach ($currentObject in $Actual)
        {
            # Check if the current object is null
            if ($null -eq $currentObject)
            {
                $message = $script:localizedData.Assert_ObjectProperty_ActualIsNull

                if ($Because)
                {
                    $message += " {0} $Because" -f $script:localizedData.Assert_ObjectProperty_Because
                }

                throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
            }

            # Check if the property exists on the current object
            $hasProperty = $false
            try
            {
                # For hashtables, check if the key exists
                if ($currentObject -is [System.Collections.IDictionary])
                {
                    $hasProperty = $currentObject.ContainsKey($Property)
                }
                # For PSCustomObject and other objects, try PSObject.Properties first
                elseif ($null -ne $currentObject.PSObject.Properties[$Property])
                {
                    $hasProperty = $true
                }
                # Use Get-Member as fallback for .NET objects
                else
                {
                    $member = $currentObject | Get-Member -Name $Property -MemberType Property, NoteProperty, ScriptProperty -ErrorAction SilentlyContinue
                    if ($null -ne $member)
                    {
                        $hasProperty = $true
                    }
                    else
                    {
                        # Final check with direct property access for edge cases
                        try
                        {
                            $null = $currentObject.$Property
                            # If we got here without exception, the property exists
                            # But we need to make sure it's not just returning $null for non-existent properties
                            $actualMember = $currentObject.PSObject.Members | Where-Object { $_.Name -eq $Property }
                            $hasProperty = $null -ne $actualMember
                        }
                        catch
                        {
                            $hasProperty = $false
                        }
                    }
                }
            }
            catch
            {
                $hasProperty = $false
            }

            if (-not $hasProperty)
            {
                $message = $script:localizedData.Assert_ObjectProperty_PropertyNotFound -f $Property

                if ($Because)
                {
                    $message += " {0} $Because" -f $script:localizedData.Assert_ObjectProperty_Because
                }

                throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
            }

            # If we're in the AssertValue parameter set, also check the value
            if ($PSCmdlet.ParameterSetName -eq 'AssertValue')
            {
                $actualValue = $currentObject.$Property

                # Use more sophisticated comparison that handles arrays and null values correctly
                $valuesAreEqual = $false
                if ($null -eq $actualValue -and $null -eq $Value)
                {
                    $valuesAreEqual = $true
                }
                elseif ($actualValue -is [System.Array] -and $Value -is [System.Array])
                {
                    # Use StructuralEqualityComparer for element-wise array comparison (supports value-type arrays)
                    $valuesAreEqual = [System.Collections.StructuralComparisons]::StructuralEqualityComparer.Equals($actualValue, $Value)
                }
                else
                {
                    # Use PowerShell's built-in comparison for other types
                    $valuesAreEqual = $actualValue -eq $Value
                }

                if (-not $valuesAreEqual)
                {
                    $message = $script:localizedData.Assert_ObjectProperty_ValueMismatch -f $Property, $Value, $actualValue

                    if ($Because)
                    {
                        $message += " {0} $Because" -f $script:localizedData.Assert_ObjectProperty_Because
                    }

                    throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
                }
            }
        }
    }
    else
    {
        # Single object case (not an array or empty array)
        # Check if the actual value is null
        if ($null -eq $Actual)
        {
            $message = $script:localizedData.Assert_ObjectProperty_ActualIsNull

            if ($Because)
            {
                $message += " {0} $Because" -f $script:localizedData.Assert_ObjectProperty_Because
            }

            throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
        }

        # Check if the property exists on the object
        $hasProperty = $false
        try
        {
            # For hashtables, check if the key exists
            if ($Actual -is [System.Collections.IDictionary])
            {
                $hasProperty = $Actual.ContainsKey($Property)
            }
            # For PSCustomObject and other objects, try PSObject.Properties first
            elseif ($null -ne $Actual.PSObject.Properties[$Property])
            {
                $hasProperty = $true
            }
            # Use Get-Member as fallback for .NET objects
            else
            {
                $member = $Actual | Get-Member -Name $Property -MemberType Property, NoteProperty, ScriptProperty -ErrorAction SilentlyContinue
                if ($null -ne $member)
                {
                    $hasProperty = $true
                }
                else
                {
                    # Final check with direct property access for edge cases
                    try
                    {
                        $null = $Actual.$Property
                        # If we got here without exception, the property exists
                        # But we need to make sure it's not just returning $null for non-existent properties
                        $actualMember = $Actual.PSObject.Members | Where-Object { $_.Name -eq $Property }
                        $hasProperty = $null -ne $actualMember
                    }
                    catch
                    {
                        $hasProperty = $false
                    }
                }
            }
        }
        catch
        {
            $hasProperty = $false
        }

        if (-not $hasProperty)
        {
            $message = $script:localizedData.Assert_ObjectProperty_PropertyNotFound -f $Property

            if ($Because)
            {
                $message += " {0} $Because" -f $script:localizedData.Assert_ObjectProperty_Because
            }

            throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
        }

        # If we're in the AssertValue parameter set, also check the value
        if ($PSCmdlet.ParameterSetName -eq 'AssertValue')
        {
            $actualValue = $Actual.$Property

            # Use more sophisticated comparison that handles arrays and null values correctly
            $valuesAreEqual = $false
            if ($null -eq $actualValue -and $null -eq $Value)
            {
                $valuesAreEqual = $true
            }
            elseif ($actualValue -is [System.Array] -and $Value -is [System.Array])
            {
                # Use SequenceEqual for efficient structural array comparison
                $valuesAreEqual = [System.Linq.Enumerable]::SequenceEqual([System.Collections.Generic.IEnumerable[object]]$actualValue, [System.Collections.Generic.IEnumerable[object]]$Value)
            }
            else
            {
                # Use PowerShell's built-in comparison for other types
                $valuesAreEqual = $actualValue -eq $Value
            }

            if (-not $valuesAreEqual)
            {
                $message = $script:localizedData.Assert_ObjectProperty_ValueMismatch -f $Property, $Value, $actualValue

                if ($Because)
                {
                    $message += " {0} $Because" -f $script:localizedData.Assert_ObjectProperty_Because
                }

                throw [Pester.Factory]::CreateShouldErrorRecord($message, $MyInvocation.ScriptName, $MyInvocation.ScriptLineNumber, $MyInvocation.Line.TrimEnd([System.Environment]::NewLine), $true)
            }
        }
    }
}
#EndRegion '.\Public\Assert-ObjectProperty.ps1' 318