Public/New-QAFrameworkExecutionPlan.ps1

function New-QAFrameworkExecutionPlan {
    <#
    .SYNOPSIS
        Turns selected QAFramework tests into an ordered plan of executable work items.
    .DESCRIPTION
        Assigns every test to the phases the legacy TaskSchedulerAndRunner used, expands the
        TargetDMA attribute into concrete agents and produces one work item per execution that
        has to be started through Start-QAOpsBridgeExecution.

        Phase order:

        PreRun - PreRunFixture tests, one at a time, honouring preRunFilter.
        DiagnosticsBefore - DiagnosticTestFixture with run type Before or BeforeAndAfter.
        Main - Standard fixtures, and failover fixtures that have a
                                     BeforeSwitch method, that may run concurrently.
        NonConcurrent - tests with CanRunConcurrently(false), cluster exclusive.
        FailoverDirectBeforeSwitch - failover tests with RunDirectBeforeSwitch.
        FailoverDirectAfterSwitch - failover tests with RunDirectAfterSwitch.
        FailoverAfterSwitch - failover tests with an AfterSwitch method.
        FailoverAfterSwitchBack - failover tests with an AfterSwitchBack method.
        DiagnosticsAfter - DiagnosticTestFixture with run type After or BeforeAndAfter.

        A single failover test legitimately produces several work items because the legacy
        runner executes the same script once per failover state.
    .PARAMETER Test
        The selected schema v1 test metadata, typically Select-QAFrameworkTest .Selected.
    .PARAMETER Topology
        A cluster topology from Get-QAFrameworkClusterTopology. Without it every work item is
        scheduled on any available agent.
    .PARAMETER Configuration
        A run configuration from Get-QAFrameworkRunConfiguration. Supplies preRunFilter,
        includeNonConcurrent, disableFailoverRun and agentCapacity.
    .EXAMPLE
        $plan = New-QAFrameworkExecutionPlan -Test $selection.Selected -Topology $topology -Configuration $config
        $plan.WorkItems | Group-Object Phase | Select-Object Name, Count
    .OUTPUTS
        A plan object with WorkItems, Phases and Skipped collections.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [AllowEmptyCollection()]
        [object[]]$Test,

        [Parameter()]
        [object]$Topology,

        [Parameter()]
        [object]$Configuration
    )

    begin {
        $all = [System.Collections.Generic.List[object]]::new()
    }

    process {
        foreach ($item in $Test) {
            if ($null -ne $item) { [void]$all.Add($item) }
        }
    }

    end {
        $workItems = [System.Collections.Generic.List[object]]::new()
        $skipped = [System.Collections.Generic.List[object]]::new()

        $preRunFilter = 'all'
        $includeNonConcurrent = $true
        $disableFailoverRun = $false
        $agentCapacity = 3

        if ($Configuration) {
            if (-not [string]::IsNullOrWhiteSpace([string]$Configuration.preRunFilter)) { $preRunFilter = [string]$Configuration.preRunFilter }
            if ($null -ne $Configuration.includeNonConcurrent) { $includeNonConcurrent = [bool]$Configuration.includeNonConcurrent }
            if ($null -ne $Configuration.disableFailoverRun) { $disableFailoverRun = [bool]$Configuration.disableFailoverRun }
            if ($Configuration.agentCapacity -gt 0) { $agentCapacity = [int]$Configuration.agentCapacity }
        }

        $hasFailover = [bool]($Topology -and $Topology.HasFailover)
        $runFailoverPhases = $hasFailover -and -not $disableFailoverRun

        $addWorkItem = {
            param([object]$Metadata, [string]$Phase)

            $isFailoverFixture = [string]::Equals([string]$Metadata.fixture, 'Failover', [StringComparison]::OrdinalIgnoreCase)
            $isFailoverPhase = $Phase -like 'Failover*'
            $resolved = Resolve-QAFrameworkTargetAgent -TargetDma $Metadata.targetDma -Topology $Topology -PreferFailoverAgents:($isFailoverFixture -and $isFailoverPhase)

            if (-not $resolved.IsAnyAgent -and $resolved.Agents.Count -eq 0) {
                $skipped.Add([pscustomobject]@{
                        Name   = $Metadata.name
                        Phase  = $Phase
                        Reason = "TargetDMA '$($Metadata.targetDma)' could not be resolved. $($resolved.Reason)".Trim()
                        Test   = $Metadata
                    })
                return
            }

            $weight = 1
            if ($Metadata.weight -gt 0) { $weight = [int]$Metadata.weight }
            if ($weight -gt $agentCapacity) { $weight = $agentCapacity }

            $targets = [object[]]@($resolved.Agents)
            if ($resolved.IsAnyAgent) { $targets = [object[]]@($null) }

            $pairIds = @{}

            # A failover phase runs once per failover pair, on the agent that is active at that
            # moment. The plan pins the primary agent; the run updates it after every switch.
            if ($isFailoverPhase -and $Topology) {
                $pairs = [object[]]@($Topology.FailoverPairs)
                if ($pairs.Count -gt 0) {
                    $targets = [object[]]@($pairs | ForEach-Object { $_.Primary })
                    foreach ($pair in $pairs) { $pairIds[$pair.Primary.BridgeId] = $pair.PairId }
                }
            }

            foreach ($target in $targets) {
                $sequenceNumber = $workItems.Count + 1

                $workItems.Add([pscustomobject]@{
                        Id             = ('{0}#{1:D4}' -f $Metadata.name, $sequenceNumber)
                        Name           = [string]$Metadata.name
                        Phase          = $Phase
                        Weight         = $weight
                        TargetBridgeId = if ($target) { $target.BridgeId } else { $null }
                        TargetDmaId    = if ($target) { $target.DmaId } else { $null }
                        FailoverPairId = if ($target -and $pairIds.ContainsKey($target.BridgeId)) { $pairIds[$target.BridgeId] } else { $null }
                        Test           = $Metadata
                        State          = 'Pending'
                        ExecutionId    = $null
                        Outcome        = $null
                        Message        = $null
                        StartedAt      = $null
                        CompletedAt    = $null
                        Attempt        = 0
                    })
            }
        }

        foreach ($metadata in $all) {
            $fixture = [string]$metadata.fixture
            if ([string]::IsNullOrWhiteSpace($fixture)) { $fixture = 'Standard' }

            $failover = $metadata.failover
            $canRunConcurrently = $true
            if ($null -ne $metadata.canRunConcurrently) { $canRunConcurrently = [bool]$metadata.canRunConcurrently }

            switch -Regex ($fixture) {
                '^(?i)PreRun$' {
                    if (Test-QAFrameworkPreRunFilter -Name $metadata.name -Filter $preRunFilter) {
                        & $addWorkItem $metadata 'PreRun'
                    }
                    else {
                        $skipped.Add([pscustomobject]@{
                                Name   = $metadata.name
                                Phase  = 'PreRun'
                                Reason = "The pre-run filter '$preRunFilter' does not match this test."
                                Test   = $metadata
                            })
                    }
                    break
                }

                '^(?i)Diagnostic$' {
                    $runType = [string]$metadata.diagnosticRunType
                    if ([string]::IsNullOrWhiteSpace($runType)) { $runType = 'BeforeAndAfter' }

                    if ($runType -in @('Before', 'BeforeAndAfter')) { & $addWorkItem $metadata 'DiagnosticsBefore' }
                    if ($runType -in @('After', 'BeforeAndAfter')) { & $addWorkItem $metadata 'DiagnosticsAfter' }
                    break
                }

                '^(?i)Failover$' {
                    $runsOnNonFailover = [bool]($failover -and $failover.runOnNonFailoverSystems)

                    # The BeforeSwitch part runs in the regular pool while the cluster is still
                    # in its BeforeSwitch state, exactly as the legacy runner does.
                    if (-not $failover -or $failover.hasBeforeSwitch -or $runsOnNonFailover) {
                        $phase = if ($canRunConcurrently) { 'Main' } else { 'NonConcurrent' }
                        if ($phase -ne 'NonConcurrent' -or $includeNonConcurrent) { & $addWorkItem $metadata $phase }
                    }

                    if ($runFailoverPhases -and $failover) {
                        if ($failover.runDirectBeforeSwitch) { & $addWorkItem $metadata 'FailoverDirectBeforeSwitch' }
                        if ($failover.runDirectAfterSwitch) { & $addWorkItem $metadata 'FailoverDirectAfterSwitch' }
                        if ($failover.hasAfterSwitch) { & $addWorkItem $metadata 'FailoverAfterSwitch' }
                        if ($failover.hasAfterSwitchBack) { & $addWorkItem $metadata 'FailoverAfterSwitchBack' }
                    }
                    break
                }

                default {
                    if ($canRunConcurrently) {
                        & $addWorkItem $metadata 'Main'
                    }
                    elseif ($includeNonConcurrent) {
                        & $addWorkItem $metadata 'NonConcurrent'
                    }
                    else {
                        $skipped.Add([pscustomobject]@{
                                Name   = $metadata.name
                                Phase  = 'NonConcurrent'
                                Reason = 'Tests that cannot run concurrently are excluded from this run.'
                                Test   = $metadata
                            })
                    }
                    break
                }
            }
        }

        $order = @(
            'PreRun'
            'DiagnosticsBefore'
            'Main'
            'NonConcurrent'
            'FailoverDirectBeforeSwitch'
            'FailoverDirectAfterSwitch'
            'FailoverAfterSwitch'
            'FailoverAfterSwitchBack'
            'DiagnosticsAfter'
        )

        $used = [System.Collections.Generic.List[string]]::new()
        foreach ($phase in $order) {
            if ($workItems | Where-Object { $_.Phase -eq $phase }) { $used.Add($phase) }
        }

        return [pscustomobject]@{
            Phases    = [string[]]$used
            WorkItems = [object[]]$workItems
            Skipped   = [object[]]$skipped
        }
    }
}