Modules/businessdev.ALbuild.Apps/Private/Get-BcAlTestProcedure.ps1

function Get-BcAlTestProcedure {
    <#
    .SYNOPSIS
        Enumerates the [Test] procedures in AL sources and returns their statement lines.
    .DESCRIPTION
        Shared AL scan behind Get-BcTestQuality and Test-BcTestAssertion, so both see the same set of
        tests and the same bodies and can only differ in the verdict they draw. For every procedure that
        carries a [Test] attribute it returns the declaring codeunit, the procedure name, its location
        and the executable statement lines of its body (comments stripped, block keywords and the
        surrounding begin/end removed).
 
        The body runs from the first 'begin' after the procedure header to its matching 'end', so nested
        begin/end blocks stay inside the test and the next procedure is not swallowed.
    .PARAMETER WorkspaceRoot
        AL source root to scan; tooling folders (.alpackages, output, .git, node_modules, ...) are skipped.
    .PARAMETER Path
        A single .al file to scan instead of a whole workspace.
    .OUTPUTS
        PSCustomObject per test: Codeunit, TestName, FilePath, Line, Statements[].
    #>

    [CmdletBinding(DefaultParameterSetName = 'Workspace')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Workspace')] [ValidateNotNullOrEmpty()] [string] $WorkspaceRoot,
        [Parameter(Mandatory, ParameterSetName = 'File')] [ValidateNotNullOrEmpty()] [string] $Path
    )

    $files = if ($PSCmdlet.ParameterSetName -eq 'File') { , (Get-Item -LiteralPath $Path) }
    else {
        $root = (Resolve-Path -LiteralPath $WorkspaceRoot).Path
        Get-ChildItem -LiteralPath $root -Filter '*.al' -File -Recurse -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName.Substring($root.Length) -notmatch '[\\/](\.alpackages|\.altemplates|\.snapshots|\.output|output|\.git|node_modules)[\\/]' }
    }

    $result = [System.Collections.Generic.List[object]]::new()
    foreach ($file in $files) {
        $lines = @(Get-Content -LiteralPath $file.FullName -ErrorAction SilentlyContinue)
        if ($lines.Count -eq 0) { continue }

        $cuName = ''
        $m = [regex]::Match(($lines -join "`n"), '(?im)^\s*codeunit\s+\d+\s+("(?<q>[^"]+)"|(?<b>[A-Za-z0-9_]+))')
        if ($m.Success) { $cuName = if ($m.Groups['q'].Success) { $m.Groups['q'].Value } else { $m.Groups['b'].Value } }

        $isTestAttr = $false
        for ($i = 0; $i -lt $lines.Count; $i++) {
            $t = $lines[$i].Trim()
            if ($t -match '^\[Test\b') { $isTestAttr = $true; continue }
            $pm = [regex]::Match($t, '(?i)^(local\s+|internal\s+)*procedure\s+(?<n>[A-Za-z0-9_]+)')
            # Any other attribute ([HandlerFunctions], [TransactionModel], ...) keeps the pending [Test].
            if (-not $pm.Success) { if ($t -ne '' -and $t -notmatch '^\[') { $isTestAttr = $false }; continue }
            if (-not $isTestAttr) { continue }
            $isTestAttr = $false

            $statements = [System.Collections.Generic.List[string]]::new()
            $depth = 0; $started = $false
            for ($j = $i + 1; $j -lt $lines.Count; $j++) {
                $code = ($lines[$j] -replace '//.*$', '').Trim()
                if ($code -eq '') { continue }
                $lower = $code.ToLowerInvariant()
                $opens = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])begin(?![A-Za-z0-9_])')).Count
                $closes = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])end(?![A-Za-z0-9_])')).Count
                if (-not $started) { if ($opens -gt 0) { $started = $true; $depth = 0 } else { continue } }
                $isKeyword = $lower -match '^(begin|end|end;|else|do|then|var)$'
                if (-not $isKeyword -and $lower -notmatch '^[{}();]+$' -and $lower -notmatch '^(begin|end)\b') {
                    [void]$statements.Add($code)
                }
                $depth += $opens - $closes
                if ($depth -le 0) { break }
            }

            $result.Add([PSCustomObject]@{
                    Codeunit   = $cuName
                    TestName   = $pm.Groups['n'].Value
                    FilePath   = $file.FullName
                    Line       = $i + 1
                    Statements = @($statements)
                })
        }
    }

    return @($result)
}