src/Equivalence/Assert-Equivalent.ps1

function Test-Same ($Expected, $Actual) {
    [object]::ReferenceEquals($Expected, $Actual)
}

function Test-CollectionSize ($Expected, $Actual) {
    return $Expected.Length -eq $Actual.Length
}

function Get-ValueNotEquivalentMessage ($Expected, $Actual, $Property) { 
    $Expected = Format-Custom -Value $Expected 
    $Actual = Format-Custom -Value $Actual
    $propertyInfo = if ($Property) { " property $Property with value" }
    "Expected$propertyInfo '$Expected' to be equivalent to the actual value, but got '$Actual'."
}


function Get-CollectionSizeNotTheSameMessage ($Actual, $Expected, $Property) {
    $expectedLength = $Expected.Length
    $actualLength = $Actual.Length
    $Expected = Format-Collection -Value $Expected
    $Actual = Format-Collection -Value $Actual
    
    $propertyMessage = $null
    if ($property) {
        $propertyMessage = " in property $Property with values"
    }
    "Expected collection$propertyMessage '$Expected' with length '$expectedLength' to be the same size as the actual collection, but got '$Actual' with length '$actualLength'."
}

function Compare-CollectionEquivalent ($Expected, $Actual, $Property) {
    if (-not (Test-Collection -Value $Expected)) 
    {
        throw [ArgumentException]"Expected must be a collection."
    }

    if (-not (Test-Collection -Value $Actual)) 
    { 
        $expectedFormatted = Format-Collection -Value $Expected 
        $expectedLength = $expected.Length
        $actualFormatted = Format-Custom -Value $actual
        return "Expected collection '$expectedFormatted' with length '$expectedLength', but got '$actualFormatted'."
    }

    if (-not (Test-CollectionSize -Expected $Expected -Actual $Actual)) {
        return Get-CollectionSizeNotTheSameMessage -Expected $Expected -Actual $Actual -Property $Property
    }

    $eEnd = $Expected.Length
    $aEnd = $Actual.Length
    $taken = @()
    $notFound = @()
    for ($e=0; $e -lt $eEnd; $e++) { 
        $currentExpected = $Expected[$e]
        $found = $false
        for ($a=0; $a -lt $aEnd; $a++) { 
            $currentActual = $Actual[$a]
            if ((-not (Compare-Equivalent -Expected $currentExpected -Actual $currentActual -Path $Property)) -and $taken -notcontains $a) 
            {
                $taken += $a
                $found = $true
            }
        }
        if (-not $found) 
        {
            $notFound += $currentExpected
        }
    }
    $Expected = Format-Custom -Value $Expected
    $Actual = Format-Custom -Value $Actual
    $notFoundFormatted = Format-Custom -Value ( $notFound | % { Format-Custom -Value $_ } )
    
    if ($notFound) {
        $propertyMessage = if ($Property) {" in property $Property which is"}
        return "Expected collection$propertyMessage '$Expected' to be equivalent to '$Actual' but some values were missing: '$notFoundFormatted'."
    }
}

function Compare-ValueEquivalent ($Actual, $Expected, $Property) { 
    $Expected = $($Expected)
    if (-not (Test-Value -Value $Expected)) 
    {
        throw [ArgumentException]"Expected must be a Value."
    }

     #fix that string 'false' becomes $true boolean
    if ($Actual -is [Bool] -and $Expected -is [string] -and "$Expected" -eq 'False') 
    {
        $Expected = $false
        if ($Expected -ne $Actual)
        {
            Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Property
        }
        return
    }

    if ($Expected -is [Bool] -and $Actual -is [string] -and "$Actual" -eq 'False') 
    {
        $Actual = $false
        if ($Expected -ne $Actual)
        {
            Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Property
        }
        return
    }

    #fix that scriptblocks are compared by reference
    if (Test-ScriptBlock -Value $Expected) 
    {
        #forcing scriptblock to serialize to string and then comparing that
        if ("$Expected" -ne $Actual)
        {
            Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Path
        }
        return
    }

    if ($Expected -ne $Actual)
    {
        Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Property
    }
}

function Compare-HashtableEquivalent ($Actual, $Expected, $Property) { 
    if (-not (Test-Hashtable -Value $Expected)) 
    {
        throw [ArgumentException]"Expected must be a hashtable."
    }

    if (-not (Test-Hashtable -Value $Actual)) 
    { 
        $expectedFormatted = Format-Custom -Value $Expected
        $actualFormatted = Format-Custom -Value $Actual 
        return "Expected hashtable '$expectedFormatted', but got '$actualFormatted'."    
    }
    
    $actualKeys = $Actual.Keys
    $expectedKeys = $Expected.Keys

    $result = @()
    foreach ($k in $expectedKeys)
    {
        $actualHasKey = $actualKeys -contains $k
        if (-not $actualHasKey)
        {
            $result += "Expected has key '$k' that the other object does not have."
            continue
        }

        $expectedValue = $Expected[$k]
        $actualValue = $Actual[$k]

        $result += Compare-Equivalent -Expected $expectedValue -Actual $actualValue -Path "$Property.$k"
    }

    $keysNotInExpected =  $actualKeys | where {$expectedKeys -notcontains $_ }
    foreach ($k in $keysNotInExpected)
    {
        $result += "Expected is missing key '$k' that the other object has."
    }    

    if ($result)
    {
        $expectedFormatted = Format-Custom -Value $Expected
        $actualFormatted = Format-Custom -Value $Actual 
        "Expected hashtable '$expectedFormatted', but got '$actualFormatted'.`n$($result -join "`n")"
    }
}

function Compare-DictionaryEquivalent ($Actual, $Expected, $Property) { 
    if (-not (Test-Dictionary -Value $Expected)) 
    {
        throw [ArgumentException]"Expected must be a dictionary."
    }

    if (-not (Test-Dictionary -Value $Actual)) 
    { 
        $expectedFormatted = Format-Custom -Value $Expected
        $actualFormatted = Format-Custom -Value $Actual 
        return "Expected dictionary '$expectedFormatted', but got '$actualFormatted'."    
    }
    
    $actualKeys = $Actual.Keys
    $expectedKeys = $Expected.Keys

    $result = @()
    foreach ($k in $expectedKeys)
    {
        $actualHasKey = $actualKeys -contains $k
        if (-not $actualHasKey)
        {
            $result += "Expected has key '$k' that the other object does not have."
            continue
        }

        $expectedValue = $Expected[$k]
        $actualValue = $Actual[$k]

        $result += Compare-Equivalent -Expected $expectedValue -Actual $actualValue -Path "$Property.$k"
    }

    $keysNotInExpected =  $actualKeys | where {$expectedKeys -notcontains $_ }
    foreach ($k in $keysNotInExpected)
    {
        $result += "Expected is missing key '$k' that the other object has."
    }    

    if ($result)
    {
        $expectedFormatted = Format-Custom -Value $Expected
        $actualFormatted = Format-Custom -Value $Actual 
        "Expected dictionary '$expectedFormatted', but got '$actualFormatted'.`n$($result -join "`n")"
    }
}

function Compare-ObjectEquivalent ($Actual, $Expected, $Property) {

    if (-not (Test-Object -Value $Expected))
    {
        throw [ArgumentException]"Expected must be an object."
    }

    if (-not (Test-Object -Value $Actual)) {
        $expectedFormatted = Format-Custom -Value $Expected
        $actualFormatted = Format-Custom -Value $Actual
        return "Expected object '$expectedFormatted', but got '$actualFormatted'."
    }

    $actualProperties = $Actual.PsObject.Properties
    $expectedProperties = $Expected.PsObject.Properties

    foreach ($p in $expectedProperties)
    {
        $propertyName = $p.Name
        $actualProperty = $actualProperties | Where { $_.Name -eq $propertyName}
        if (-not $actualProperty)
        {
            "Expected has property '$PropertyName' that the other object does not have."
            continue
        }
    
        Compare-Equivalent -Expected $p.Value -Actual $actualProperty.Value -Path "$Property.$propertyName"
    }

    #check if there are any extra actual object props
    $expectedPropertyNames = $expectedProperties | select -ExpandProperty Name

    $propertiesNotInExpected =  $actualProperties | where {$expectedPropertyNames -notcontains $_.name }
        
    foreach ($p in $propertiesNotInExpected)
    {
        "Expected is missing property '$($p.Name)' that the other object has."
    }    
}

function Compare-Equivalent ($Actual, $Expected, $Path) { 

    #start by null checks to avoid implementing null handling
    #logic in the functions that follow
    if ($null -eq $Expected)
    {
        if ($Expected -ne $Actual)
        {
           Get-ValueNotEquivalentMessage -Expected $Expected -Actual $Actual -Property $Path
        }
        return
    }

    #test value types, strings, and single item arrays with values in them as values
    #expand the single item array to get to the value in it
    if (Test-Value -Value $Expected) 
    {
        Compare-ValueEquivalent -Actual $Actual -Expected $Expected -Property $Path
        return
    }

    #are the same instance
    if (Test-Same -Expected $Expected -Actual $Actual)
    { 
        return
    }
    
    if (Test-Hashtable -Value $Expected)
    {
        Compare-HashtableEquivalent -Expected $Expected -Actual $Actual -Property $Path
        return 
    }

    # dictionaries? (they are IEnumerable so they must go before collections)
    if (Test-Dictionary -Value $Expected)
    {
        Compare-DictionaryEquivalent -Expected $Expected -Actual $Actual -Property $Path
        return
    }

    #compare collection
    if (Test-Collection -Value $Expected) { 
        Compare-CollectionEquivalent -Expected $Expected -Actual $Actual -Property $Path
        return
    }

    Compare-ObjectEquivalent -Expected $Expected -Actual $Actual -Property $Path
}

function Assert-Equivalent($Actual, $Expected) {
    $Option = $null
    $areDifferent = Compare-Equivalent -Actual $Actual -Expected $Expected | Out-String
    if ($areDifferent)
    {
        $message = Get-AssertionMessage -Actual $actual -Expected $Expected -Option $Option -Pretty -Message "Expected and actual are not equivalent!`nExpected:`n<expected>`n`nActual:`n<actual>`n`nSummary:`n$areDifferent`n<options>"
        throw [Assertions.AssertionException]$message
    }
}