tests/lib/framework.ps1

# 数据驱动测试框架:用例注册、依赖选择、构建去重与缓存键。
$ErrorActionPreference = 'Stop'

$script:RepoRoot = Get-RepoRoot
$script:TestCases = [System.Collections.Generic.List[hashtable]]::new()

# 编译器核心输入:影响「把任意脚本编成 exe」的源文件。用例引用它做增量选择;
# 刻意逐文件枚举而非用 `src/` 前缀,好让未列入的新文件触发「零匹配 -> 全量」安全网。
$script:CoreCompileDeps = @(
    'ps12exe.ps1', 'ps12exe.psm1', 'ps12exe.psd1',
    'src/CodeDomCompiler.ps1', 'src/CoreCompiler.ps1', 'src/TinySharpCompiler.ps1', 'src/BuildFrame.ps1',
    'src/InitCompileThings.ps1', 'src/ConstProgramCheck.ps1', 'src/ReadScriptFile.ps1', 'src/AstAnalyze.ps1',
    'src/ExeSinker.ps1', 'src/GolfModeHeader.ps1', 'src/predicate.ps1', 'src/PSObjectToString.ps1',
    'src/WriteI18n.ps1', 'src/LocaleLoader.ps1',
    'src/programFrames/', 'src/RuntimePwsh2.0/', 'src/bin/'
)

function Get-TestRepoRoot { return $script:RepoRoot }

function Add-Test {
    param([Parameter(Mandatory)][hashtable]$Descriptor)
    if (-not $Descriptor.Name) { throw 'Add-Test: Name is required' }
    $d = @{
        Name       = [string]$Descriptor.Name
        Group      = if ($Descriptor.Group) { [string]$Descriptor.Group } else { 'misc' }
        Deps       = @($Descriptor.Deps | Where-Object { $_ })
        Build      = $Descriptor.Build
        Builds     = @($Descriptor.Builds | Where-Object { $_ })
        Run        = $Descriptor.Run
        Serial     = [bool]$Descriptor.Serial
        Timeout    = if ($Descriptor.Timeout) { [int]$Descriptor.Timeout } else { 600 }
    }
    if ($d.Build) { $d.Builds = @($d.Build) + $d.Builds }
    [void]$script:TestCases.Add($d)
}

function Get-AllTestCases {
    param([string]$CasesDir)
    if (-not $CasesDir) { $CasesDir = Join-Path $script:RepoRoot 'tests/cases' }
    if (-not (Test-Path -LiteralPath $CasesDir)) { throw "找不到用例目录:$CasesDir" }
    # 幂等:重复调用时先清空,避免用例被叠加注册。
    $script:TestCases = [System.Collections.Generic.List[hashtable]]::new()
    Get-ChildItem -LiteralPath $CasesDir -Filter '*.ps1' -File | Sort-Object Name | ForEach-Object { . $_.FullName }
    return $script:TestCases
}

# 归一化某个用例的构建清单,返回 @( @{ Name; Spec } )。
function Get-CaseBuildSpecs {
    param([hashtable]$Case)
    $result = @()
    $i = 0
    foreach ($b in @($Case.Builds)) {
        if (-not $b) { continue }
        $name = if ($b.Name) { [string]$b.Name } else { "build$i" }
        $result += , @{ Name = $name; Spec = $b }
        $i++
    }
    return $result
}

function Get-ArgKeyText {
    param($Value)
    if ($null -eq $Value) { return '$null' }
    if ($Value -is [hashtable]) {
        $pairs = $Value.Keys | Sort-Object | ForEach-Object { "$_=$(Get-ArgKeyText $Value[$_])" }
        return '{' + ($pairs -join ',') + '}'
    }
    if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) {
        return '[' + ((@($Value) | ForEach-Object { Get-ArgKeyText $_ }) -join ',') + ']'
    }
    return [string]$Value
}

function Get-BuildKey {
    param([string]$Fingerprint, [hashtable]$Spec)
    $inputHash = ''
    if ($null -ne $Spec.InputText) { $inputHash = Get-StringHash ([string]$Spec.InputText) }
    elseif ($Spec.InputFile) {
        $p = [string]$Spec.InputFile
        if (-not [System.IO.Path]::IsPathRooted($p)) { $p = Join-Path $script:RepoRoot $p }
        if (Test-Path -LiteralPath $p) { $inputHash = Get-StringHash ([System.IO.File]::ReadAllText($p)) }
        else { $inputHash = "missing:$p" }
    }
    $inputId = if ($Spec.InputFile) { Get-NormalizedRelPath -Path ([string]$Spec.InputFile) -Base $script:RepoRoot } else { 'inline' }
    $paramsText = ''
    if ($Spec.Params) {
        $paramsText = (($Spec.Params.Keys | Sort-Object) | ForEach-Object { "$_=$(Get-ArgKeyText $Spec.Params[$_])" }) -join ';'
    }
    $envText = ''
    if ($Spec.Env) {
        $envText = (($Spec.Env.Keys | Sort-Object) | ForEach-Object { "$_=$($Spec.Env[$_])" }) -join ';'
    }
    $out = if ($Spec.Output) { [string]$Spec.Output } else { 'out.exe' }
    $compiler = if ($Spec.Compiler) { [string]$Spec.Compiler } else { 'ps12exe' }
    $raw = "fp=$Fingerprint|compiler=$compiler|inputId=$inputId|input=$inputHash|params=$paramsText|env=$envText|out=$out"
    return (Get-StringHash $raw).Substring(0, 24)
}

# 一个构建的缓存键只依赖它真正用到的编译组件。Core 目标走 CoreCompiler;
# 其余(含 TinySharp 常量壳、Framework2.0/4.0)都走 CodeDom/TinySharp;ps2exe 兼容层再加 shim。
function Get-BuildFingerprintComponents {
    param([hashtable]$Spec)
    $names = [System.Collections.Generic.List[string]]::new()
    $names.Add('common')
    $target = ''
    if ($Spec.Params -and $Spec.Params['Build'] -and $Spec.Params['Build']['Target']) {
        $target = [string]$Spec.Params['Build']['Target']
    }
    if ($Spec.Compiler -eq 'ps2exe') {
        $names.Add('codeDom'); $names.Add('tinySharp'); $names.Add('ps2exe')
    }
    elseif ($target -eq 'Core') {
        $names.Add('core')
    }
    else {
        $names.Add('codeDom'); $names.Add('tinySharp')
    }
    return @($names)
}

# 全量用例的分片归属:按「构建数」贪心均衡到各片。基于全部用例(而非本次选中集)计算,
# 因此同一用例的分片是稳定的,分片缓存可跨 run 复用;纯测试用例按权重 1 参与均衡。
function Get-ShardAssignment {
    param([array]$Cases, [int]$ShardCount)
    if ($ShardCount -le 1) {
        $single = @{}
        foreach ($c in $Cases) { $single[$c.Name] = 0 }
        return $single
    }
    $load = New-Object 'int[]' $ShardCount
    $map = @{}
    $ordered = @($Cases | Sort-Object @{ Expression = { @(Get-CaseBuildSpecs -Case $_).Count }; Descending = $true }, @{ Expression = { $_.Name } })
    foreach ($c in $ordered) {
        $weight = @(Get-CaseBuildSpecs -Case $c).Count
        if ($weight -lt 1) { $weight = 1 }
        $target = 0
        for ($i = 1; $i -lt $ShardCount; $i++) { if ($load[$i] -lt $load[$target]) { $target = $i } }
        $map[$c.Name] = $target
        $load[$target] += $weight
    }
    return $map
}

function Select-ShardCases {
    param([array]$Cases, [int]$ShardCount, [int]$ShardIndex, [hashtable]$Assignment)
    if ($ShardCount -le 1) { return @($Cases) }
    if (-not $Assignment) { $Assignment = Get-ShardAssignment -Cases $Cases -ShardCount $ShardCount }
    return @($Cases | Where-Object { $Assignment[$_.Name] -eq $ShardIndex })
}

function Test-DepMatchesPath {
    param([string]$RelPath, [string]$Dep)
    $Dep = $Dep -replace '\\', '/'
    if ($Dep.EndsWith('/')) { return $RelPath.StartsWith($Dep, [System.StringComparison]::OrdinalIgnoreCase) }
    if ($Dep -like '*`**') { return $RelPath -like $Dep }
    return $RelPath -eq $Dep
}

# 测试基础设施变更(tests/ 或 workflow)时全量;否则按 Deps 选择。
function Get-AffectedCases {
    param([array]$Cases, [string[]]$ChangedPaths, [string]$RepoRoot)
    $changed = @($ChangedPaths | Where-Object { $_ } | ForEach-Object {
        ($_.Trim() -replace '\\', '/').TrimStart('./')
    })
    if ($changed.Count -eq 0) { return @($Cases) }

    $infraChanged = $changed | Where-Object { $_ -like 'tests/*' -or $_ -like '.github/workflows/*' }
    if ($infraChanged) { return @($Cases) }

    $selected = @()
    $matched = @()
    foreach ($c in $Cases) {
        if (@($c.Deps).Count -eq 0) { $selected += , $c; continue }
        $hit = $false
        foreach ($path in $changed) {
            foreach ($dep in $c.Deps) {
                if (Test-DepMatchesPath -RelPath $path -Dep $dep) { $hit = $true; break }
            }
            if ($hit) { break }
        }
        if ($hit) { $selected += , $c; $matched += , $c }
    }

    if ($matched.Count -eq 0) {
        $productRoots = @('ps12exe.ps1', 'ps12exe.psm1', 'ps12exe.psd1', 'exe21sp.ps1', 'src/')
        $productChanged = $changed | Where-Object {
            $p = $_
            @($productRoots | Where-Object { $p -eq $_ -or $p.StartsWith($_, [System.StringComparison]::OrdinalIgnoreCase) }).Count -gt 0
        }
        # 改了产品源码却没有用例声明依赖它,属于用例映射缺口,保守全量。
        if ($productChanged) { return @($Cases) }
    }
    return @($selected)
}

function Get-GitChangedPaths {
    param([string]$RepoRoot)
    Push-Location -LiteralPath $RepoRoot
    try {
        # 本地优先看未提交改动(含未跟踪文件);干净时才看最后一次提交。
        $paths = @(git diff --name-only HEAD 2>$null)
        $paths += @(git ls-files --others --exclude-standard 2>$null)
        $paths = @($paths | Where-Object { $_ })
        if (-not $paths) { $paths = @(git diff --name-only 'HEAD~1' 2>$null) }
        return @($paths | Where-Object { $_ })
    }
    finally { Pop-Location }
}