Core/New-TestEnvironmentCheck.ps1

function New-TestEnvironmentCheck {
    <#
    .SYNOPSIS
        Builds one verification check from what the seed data expects and what the directory holds
    .DESCRIPTION
        Every provider's Test-<Provider>Environment reduces to a list of these. A check compares
        two sets of identifiers, or two counts, or a list of value mismatches, and records what was
        expected, what was found, what is missing, what is there that should not be, and whether
        it passed. Shaping the result in one place is what lets Test-TestEnvironment return the
        same object for every provider, and lets one renderer print all of them.

        Three kinds of check:

        - Identity. -Expected and -Found are identifiers - logins, names, distinguished names,
          "group <- member" pairs. Missing is what was expected and not found; Unexpected is what
          was found and not expected. -IgnoreCase compares them case-insensitively, for the
          identifiers a directory folds itself: a UPN, a SAM account name, an Okta login. Names are
          compared exactly, by codepoint, because a decomposed and a precomposed name are the same
          string to -eq and different strings on the wire. -MissingOnly leaves Unexpected empty and
          out of the verdict, for memberships, where a dynamic rule or an automember rule adds
          members the data never lists.

        - Value. -Compared says how many values were compared and -Mismatch lists the ones that
          differed, already described. Used for names, where the caller has to say which object
          carried which wrong value.

        - Count. -FoundCount, with or without -ExpectedCount. Without an expectation the check is
          observational: it records what is there and takes no part in the verdict, which is how
          object types whose expected number depends on the tenant's licence are reported.
    .PARAMETER Name
        What is being checked, as the console line and the result object name it
    .PARAMETER Expected
        The identifiers the seed data says should exist
    .PARAMETER Found
        The identifiers the directory holds
    .PARAMETER IgnoreCase
        Compare identifiers case-insensitively
    .PARAMETER MissingOnly
        Report only what is missing; identifiers found beyond the expected set are not a failure
    .PARAMETER Compared
        How many values were compared for a value check
    .PARAMETER Mismatch
        The values that differed, one description each
    .PARAMETER FoundCount
        How many objects the directory holds, for a count check
    .PARAMETER ExpectedCount
        How many objects the seed data says there should be; omit to record the count without judging it
    .OUTPUTS
        PSCustomObject typed TestEnvironmentCheck, with Name, Kind, Expected, Found, Missing,
        Unexpected and Passed. Passed is $null for an observational count.
    .EXAMPLE
        PS> New-TestEnvironmentCheck -Name 'Users' -Expected $upns -Found $directory.userPrincipalName -IgnoreCase

        Compares the seeded UPNs with the directory's and names the ones missing on either side.
    .EXAMPLE
        PS> New-TestEnvironmentCheck -Name 'Conditional Access policies' -FoundCount $policies.Count

        Records how many policies are there without judging it.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds a result object in memory and changes nothing outside it.')]
    [CmdletBinding(DefaultParameterSetName = 'Identity')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [Parameter(ParameterSetName = 'Identity')]
        [AllowEmptyCollection()]
        [AllowNull()]
        [string[]]$Expected,

        [Parameter(ParameterSetName = 'Identity')]
        [AllowEmptyCollection()]
        [AllowNull()]
        [string[]]$Found,

        [Parameter(ParameterSetName = 'Identity')]
        [switch]$IgnoreCase,

        [Parameter(ParameterSetName = 'Identity')]
        [switch]$MissingOnly,

        [Parameter(Mandatory = $true, ParameterSetName = 'Value')]
        [ValidateRange(0, [int]::MaxValue)]
        [int]$Compared,

        [Parameter(ParameterSetName = 'Value')]
        [AllowEmptyCollection()]
        [AllowNull()]
        [string[]]$Mismatch,

        [Parameter(Mandatory = $true, ParameterSetName = 'Count')]
        [ValidateRange(0, [int]::MaxValue)]
        [int]$FoundCount,

        [Parameter(ParameterSetName = 'Count')]
        [ValidateRange(0, [int]::MaxValue)]
        [int]$ExpectedCount
    )

    $result = [ordered]@{
        PSTypeName = 'TestEnvironmentCheck'
        Name       = $Name
        Kind       = $PSCmdlet.ParameterSetName
        Expected   = 0
        Found      = 0
        Missing    = @()
        Unexpected = @()
        Passed     = $null
    }

    switch ($PSCmdlet.ParameterSetName) {
        'Identity' {
            $comparer = if ($IgnoreCase) { [System.StringComparer]::OrdinalIgnoreCase } else { [System.StringComparer]::Ordinal }
            $expectedSet = New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList $comparer
            $foundSet = New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList $comparer
            foreach ($item in @($Expected)) { if ($null -ne $item -and $item -ne '') { $null = $expectedSet.Add($item) } }
            foreach ($item in @($Found)) { if ($null -ne $item -and $item -ne '') { $null = $foundSet.Add($item) } }

            # Sorted by ordinal, so two runs list the same names in the same order and a diff of two
            # results is about the directory rather than enumeration order or the host's culture.
            $missing = [string[]]@($expectedSet | Where-Object { -not $foundSet.Contains($_) })
            [Array]::Sort($missing, [System.StringComparer]::Ordinal)
            $unexpected = [string[]]@()
            if (-not $MissingOnly) {
                $unexpected = [string[]]@($foundSet | Where-Object { -not $expectedSet.Contains($_) })
                [Array]::Sort($unexpected, [System.StringComparer]::Ordinal)
            }

            $result.Expected = $expectedSet.Count
            $result.Found = $foundSet.Count
            $result.Missing = $missing
            $result.Unexpected = $unexpected
            $result.Passed = ($missing.Count -eq 0 -and $unexpected.Count -eq 0)
        }
        'Value' {
            $mismatches = @($Mismatch | Where-Object { $null -ne $_ -and $_ -ne '' })
            $result.Expected = $Compared
            $result.Found = [Math]::Max(0, $Compared - $mismatches.Count)
            $result.Missing = $mismatches
            $result.Passed = ($mismatches.Count -eq 0)
        }
        'Count' {
            $result.Found = $FoundCount
            if ($PSBoundParameters.ContainsKey('ExpectedCount')) {
                $result.Expected = $ExpectedCount
                $result.Passed = ($FoundCount -eq $ExpectedCount)
            }
            else {
                $result.Expected = $null
            }
        }
    }

    return [PSCustomObject]$result
}