tests/Get-DFRoleWinners.Tests.ps1

BeforeAll {
    . "$PSScriptRoot/TestSupport.ps1"
    foreach ($f in Get-DFTestModuleFile) { . $f }

    $rolesFile = Join-Path $TestDrive 'roles.json'
    @'
{ "prompt": { "kind": "single", "exclusive": true, "hook": "Initialize-DFRolePrompt" },
  "pager": { "kind": "single", "exclusive": false, "hook": "Initialize-DFRolePager" },
  "grep": { "kind": "category" } }
'@
 | Set-Content $rolesFile
    $script:RoleDb = Get-DFRoleDb -Path $rolesFile

    function New-RoleTool([string]$Name, [hashtable]$Roles) {
        ConvertTo-DFToolRecord ([pscustomobject]@{ name = $Name; executable = "$Name.exe"; roles = [pscustomobject]$Roles })
    }
}

Describe 'Get-DFRoleWinners' {
    BeforeEach {
        Set-DFTestConfig $null
        $script:Installed = @('omp.exe', 'star.exe', 'less.exe', 'bat.exe', 'rg.exe')
        Mock Test-DFToolAvailable { $Executable -in $script:Installed }
        $omp  = New-RoleTool 'omp'  @{ prompt = [pscustomobject]@{ priority = 20 } }
        $star = New-RoleTool 'star' @{ prompt = [pscustomobject]@{ priority = 10 } }
        $less = New-RoleTool 'less' @{ pager  = [pscustomobject]@{ priority = 10 } }
        $bat  = New-RoleTool 'bat'  @{ pager  = [pscustomobject]@{ priority = 10 } }
        $rg   = New-RoleTool 'rg'   @{ grep   = [pscustomobject]@{} }
        $script:Db = @{ omp = $omp; star = $star; less = $less; bat = $bat; rg = $rg }
        $script:All = @($omp, $star, $less, $bat, $rg)
    }
    AfterEach { Set-DFTestConfig $null }

    It 'picks the highest priority candidate when Defaults is absent' {
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb
        $w['prompt'].Winner | Should -Be 'omp'
        $w['prompt'].Reason | Should -Be 'priority'
        $w['prompt'].Candidates | Should -Be @('omp', 'star')
    }

    It 'breaks a priority tie on tool name' {
        (Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb)['pager'].Winner | Should -Be 'bat'
    }

    It 'lets Defaults override priority' {
        Set-DFTestConfig @{ Defaults = @{ prompt = 'star' } }
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb
        $w['prompt'].Winner | Should -Be 'star'
        $w['prompt'].Reason | Should -Be 'Defaults'
    }

    It 'warns and falls back to priority when Defaults names a non-member' {
        Set-DFTestConfig @{ Defaults = @{ prompt = 'less' } }
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb -WarningVariable warn -WarningAction SilentlyContinue
        $w['prompt'].Winner | Should -Be 'omp'
        "$warn" | Should -Match "Defaults\['prompt'\].*less"
    }

    It 'warns when Defaults names a role that does not exist' {
        Set-DFTestConfig @{ Defaults = @{ nosuchrole = 'omp' } }
        $null = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb -WarningVariable warn -WarningAction SilentlyContinue
        "$warn" | Should -Match 'nosuchrole'
    }

    It 'falls back to another requested tool, without a warning, when the Defaults tool is requested but not installed' {
        # The end-of-load missing-tools notice reports it ("using omp instead").
        $script:Installed = @('omp.exe')
        Set-DFTestConfig @{ Defaults = @{ prompt = 'star' } }
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb -WarningVariable warn
        $warn | Should -BeNullOrEmpty
        $w['prompt'].Winner | Should -Be 'omp'
        $w['prompt'].Reason | Should -Be 'fallback'
        $w['prompt'].Preferred | Should -Be 'star'
    }

    It 'warns when Defaults names a tool that is not requested, and uses priority' {
        Set-DFTestConfig @{ Defaults = @{ prompt = 'starship' } }
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb -WarningVariable warn -WarningAction SilentlyContinue
        $w['prompt'].Winner | Should -Be 'omp'
        "$warn" | Should -Match "Defaults\['prompt'\].*starship.*not requested"
    }

    It 'never mentions the old global $DFConfig in its messages' {
        Set-DFTestConfig @{ Defaults = @{ nosuchrole = 'omp'; prompt = 'less' } }
        $null = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb -WarningVariable warn -WarningAction SilentlyContinue
        "$warn" | Should -Not -Match '\$DFConfig'
    }

    It 'treats an empty Defaults value as absent, without a warning' {
        Set-DFTestConfig @{ Defaults = @{ pager = '' } }
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb -WarningVariable warn
        $warn | Should -BeNullOrEmpty
        $w['pager'].Reason | Should -Be 'priority'
    }

    It 'treats a whitespace-only Defaults value as absent, without a warning' {
        Set-DFTestConfig @{ Defaults = @{ pager = ' ' } }
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb -WarningVariable warn
        $warn | Should -BeNullOrEmpty
        $w['pager'].Reason | Should -Be 'priority'
    }

    It 'reports a Defaults winner with the tool''s own spelling' {
        Set-DFTestConfig @{ Defaults = @{ prompt = 'STAR' } }
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb
        $w['prompt'].Winner | Should -BeExactly 'star'
        $w['prompt'].Reason | Should -Be 'Defaults'
    }

    It 'warns that a category role has no winner when Defaults names one' {
        Set-DFTestConfig @{ Defaults = @{ grep = 'rg' } }
        $null = Get-DFRoleWinners -ToolDb $script:Db -Tools $script:All -RoleDb $script:RoleDb -WarningVariable warn -WarningAction SilentlyContinue
        "$warn" | Should -Match "'grep' is a category"
    }

    It 'lists candidates by priority in Ranked, winner first' {
        $script:Installed += 'aaa.exe'
        $low = New-RoleTool 'aaa' @{ prompt = [pscustomobject]@{ priority = 1 } }
        $w = Get-DFRoleWinners -ToolDb ($script:Db + @{ aaa = $low }) -Tools ($script:All + $low) -RoleDb $script:RoleDb
        $w['prompt'].Ranked | Should -Be @('omp', 'star', 'aaa')
        $w['prompt'].Candidates | Should -Be @('aaa', 'omp', 'star')
    }

    It 'considers only the tools being registered (subset registration)' {
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools @($script:Db.star) -RoleDb $script:RoleDb
        $w['prompt'].Winner | Should -Be 'star'
        $w['prompt'].Reason | Should -Be 'sole'
    }

    It 'leaves out roles with no candidate and category roles' {
        $w = Get-DFRoleWinners -ToolDb $script:Db -Tools @($script:Db.rg) -RoleDb $script:RoleDb
        $w.Count | Should -Be 0
    }

    It 'warns about a tool that declares a role missing from the role definitions, and ignores it' {
        $odd = New-RoleTool 'odd' @{ nosuch = [pscustomobject]@{} }
        $w = Get-DFRoleWinners -ToolDb @{ odd = $odd } -Tools @($odd) -RoleDb $script:RoleDb -WarningVariable warn -WarningAction SilentlyContinue
        "$warn" | Should -Match "odd declares unknown role 'nosuch'"
        $w.Count | Should -Be 0
    }

    It 'does not warn about unknown roles when no role definitions loaded at all' {
        $odd = New-RoleTool 'odd' @{ nosuch = [pscustomobject]@{} }
        $null = Get-DFRoleWinners -ToolDb @{ odd = $odd } -Tools @($odd) -RoleDb @{} -WarningVariable warn
        $warn | Should -BeNullOrEmpty
    }
}

Describe 'Write-DFRoleNotice' {
    BeforeEach {
        $script:SavedState = $Env:XDG_STATE_HOME
        $Env:XDG_STATE_HOME = Join-Path $TestDrive "state-$([guid]::NewGuid())"
        $script:StateFile = Join-Path $Env:XDG_STATE_HOME 'dotforge' 'role-state.json'
        $script:Unresolved = @{ prompt = [pscustomobject]@{ Role = 'prompt'; Winner = 'omp'; Reason = 'priority'; Candidates = [string[]]@('omp', 'star') } }
    }
    AfterEach { $Env:XDG_STATE_HOME = $script:SavedState }

    It 'warns once per candidate set, naming the winner and the override' {
        Write-DFRoleNotice -RoleWinners $script:Unresolved -RoleDb $script:RoleDb -WarningVariable w1 -WarningAction SilentlyContinue
        "$w1" | Should -Match "using omp"
        "$w1" | Should -Match "Defaults = @\{ 'prompt' = 'star' \}"
        Write-DFRoleNotice -RoleWinners $script:Unresolved -RoleDb $script:RoleDb -WarningVariable w2
        $w2 | Should -BeNullOrEmpty
    }

    It 'suggests the next candidate by priority, not alphabetically' {
        $ranked = @{ prompt = [pscustomobject]@{ Role = 'prompt'; Winner = 'omp'; Reason = 'priority'
                                                 Candidates = [string[]]@('aaa', 'omp', 'star'); Ranked = [string[]]@('omp', 'star', 'aaa') } }
        Write-DFRoleNotice -RoleWinners $ranked -RoleDb $script:RoleDb -WarningVariable w -WarningAction SilentlyContinue
        "$w" | Should -Match "Defaults = @\{ 'prompt' = 'star' \}"
    }

    It 'warns again when the candidate set changes' {
        Write-DFRoleNotice -RoleWinners $script:Unresolved -RoleDb $script:RoleDb -WarningAction SilentlyContinue
        $script:Unresolved.prompt.Candidates = [string[]]@('new', 'omp', 'star')
        Write-DFRoleNotice -RoleWinners $script:Unresolved -RoleDb $script:RoleDb -WarningVariable w -WarningAction SilentlyContinue
        $w | Should -Not -BeNullOrEmpty
    }

    It 'stays quiet for non-exclusive roles and for Defaults or sole winners' {
        $quiet = @{
            pager  = [pscustomobject]@{ Role = 'pager'; Winner = 'bat'; Reason = 'priority'; Candidates = [string[]]@('bat', 'less') }
            prompt = [pscustomobject]@{ Role = 'prompt'; Winner = 'star'; Reason = 'Defaults'; Candidates = [string[]]@('omp', 'star') }
        }
        Write-DFRoleNotice -RoleWinners $quiet -RoleDb $script:RoleDb -WarningVariable w
        $w | Should -BeNullOrEmpty
        Test-Path $script:StateFile | Should -BeFalse
    }

    It 'emits no error, only the warning, when the state file cannot be read' {
        New-Item -ItemType Directory -Force (Split-Path $script:StateFile) | Out-Null
        '{}' | Set-Content $script:StateFile
        Mock Get-Content { Write-Error 'The process cannot access the file because it is being used by another process.' }
        $out = Write-DFRoleNotice -RoleWinners $script:Unresolved -RoleDb $script:RoleDb -WarningVariable w -WarningAction SilentlyContinue 2>&1
        @($out | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) | Should -BeNullOrEmpty
        "$w" | Should -Match 'using omp'
    }

    It 'treats a corrupt state file as empty and rewrites it as valid JSON' {
        New-Item -ItemType Directory -Force (Split-Path $script:StateFile) | Out-Null
        '{ not json' | Set-Content $script:StateFile
        { Write-DFRoleNotice -RoleWinners $script:Unresolved -RoleDb $script:RoleDb -WarningAction SilentlyContinue } | Should -Not -Throw
        (Get-Content $script:StateFile -Raw | ConvertFrom-Json).prompt | Should -Be 'omp,star'
    }
}