tests/Roles.Registration.Tests.ps1

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

Describe 'Register-DFTool role activation' {
    BeforeEach { Reset-DFTestSession;
        $script:DFToolDb = $null
        $script:DFToolAvailability = @{}
        $global:DFRoleEnvState = $null
        foreach ($v in 'CONFIG', 'CACHE', 'STATE', 'DATA') {
            Set-Variable -Scope Script -Name "Saved$v" -Value ([Environment]::GetEnvironmentVariable("XDG_$($v)_HOME"))
            [Environment]::SetEnvironmentVariable("XDG_$($v)_HOME", (Join-Path $TestDrive $v.ToLower()))
        }
        # TestDrive lives for the whole Describe; a fresh state folder per test keeps
        # one test's "already warned" record from silencing the next test's warning.
        $Env:XDG_STATE_HOME = Join-Path $TestDrive "state-$([guid]::NewGuid())"
        $script:SavedPager = $Env:DF_T_PAGER
        Remove-Item Env:DF_T_PAGER -ErrorAction Ignore
        Set-DFTestConfig $null
        $script:Tools = Join-Path $TestDrive "tools-$([guid]::NewGuid())"
        New-Item -ItemType Directory $script:Tools | Out-Null

        $rolesFile = Join-Path $TestDrive 'roles.json'
        @'
{ "tprompt": { "kind": "single", "exclusive": true, "hook": "Initialize-DFRoleTprompt" },
  "tpager": { "kind": "single", "hook": "Initialize-DFRoleTpager", "reserved": { "env": ["DF_T_PAGER"] } },
  "tother": { "kind": "single", "hook": "Initialize-DFRoleTother" },
  "tlisting":{ "kind": "single", "hook": "Initialize-DFRoleTlisting", "reserved": { "aliases": ["tls"] } } }
'@
 | Set-Content $rolesFile
        $script:DFRoleDb = Get-DFRoleDb -Path $rolesFile

        function script:Write-Tool([string]$Name, [string]$Json, [string]$Sidecar) {
            $Json | Set-Content (Join-Path $script:Tools "$Name.json")
            if ($Sidecar) { $Sidecar | Set-Content (Join-Path $script:Tools "$Name.ps1") }
        }
        Write-Tool 'alpha' '{ "name": "alpha", "executable": "alpha.exe",
            "roles": { "tprompt": { "priority": 20 }, "tother": { "priority": 0 } },
            "env": { "DF_T_ALPHA_ENV": "1" } }'
 @'
$global:RoleCalls += 'alpha:body'
function Initialize-DFRoleTprompt { param($Tool, $Role) $global:RoleCalls += "alpha:$Role" }
function Initialize-DFRoleTother { param($Tool, $Role) $global:RoleCalls += "alpha:$Role" }
'@

        Write-Tool 'beta' '{ "name": "beta", "executable": "beta.exe",
            "roles": { "tprompt": { "priority": 10 }, "tother": { "priority": 50 } } }'
 @'
$global:RoleCalls += 'beta:body'
function Initialize-DFRoleTprompt { param($Tool, $Role) $global:RoleCalls += "beta:$Role" }
function Initialize-DFRoleTother { param($Tool, $Role) $global:RoleCalls += "beta:$Role" }
'@

        Write-Tool 'dfrolepager' '{ "name": "dfrolepager", "executable": "dfrolepager.exe",
            "roles": { "tpager": { "env": { "DF_T_PAGER": "dfrolepager --paging" } },
                       "tlisting": { "aliases": { "tls": { "command": "dfrolepager", "args": ["--list"] } } } } }'

        Write-Tool 'dfrolepagerb' '{ "name": "dfrolepagerb", "executable": "dfrolepagerb.exe",
            "roles": { "tlisting": { "priority": -1, "aliases": { "tls": { "command": "dfrolepagerb", "args": [] } } } } }'


        $global:RoleCalls = @()
        Mock Test-DFToolAvailable { $true }
        Mock Write-DFConflictNotice { }
    }
    AfterEach {
        foreach ($v in 'CONFIG', 'CACHE', 'STATE', 'DATA') {
            [Environment]::SetEnvironmentVariable("XDG_$($v)_HOME", (Get-Variable -Scope Script -Name "Saved$v").Value)
        }
        if ($null -eq $script:SavedPager) { Remove-Item Env:DF_T_PAGER -ErrorAction Ignore } else { $Env:DF_T_PAGER = $script:SavedPager }
        Remove-Item Env:DF_T_ALPHA_ENV -ErrorAction Ignore
        Set-DFTestConfig $null; Remove-Variable RoleCalls -Scope Global -ErrorAction Ignore
        Remove-DFTestGlobal -Function tls
        $script:DFRoleDb = $null
    }

    It 'calls only the winner''s hook, and runs every tool''s body' {
        Register-DFTool -Name alpha, beta -ToolsPath $script:Tools -WarningAction SilentlyContinue
        $global:RoleCalls | Should -Contain 'alpha:body'
        $global:RoleCalls | Should -Contain 'beta:body'
        $global:RoleCalls | Should -Contain 'alpha:tprompt'
        $global:RoleCalls | Should -Not -Contain 'beta:tprompt'
    }

    It 'activates a tool in a role it wins even when it lost another' {
        Register-DFTool -Name alpha, beta -ToolsPath $script:Tools -WarningAction SilentlyContinue
        $global:RoleCalls | Should -Contain 'beta:tother'
        $global:RoleCalls | Should -Not -Contain 'alpha:tother'
    }

    It 'follows Defaults for the hook' {
        Set-DFTestConfig @{ Defaults = @{ tprompt = 'beta' } }
        Register-DFTool -Name alpha, beta -ToolsPath $script:Tools
        $global:RoleCalls | Should -Contain 'beta:tprompt'
        $global:RoleCalls | Should -Not -Contain 'alpha:tprompt'
    }

    It 'still applies a loser''s non-role configuration' {
        Set-DFTestConfig @{ Defaults = @{ tprompt = 'beta' } }
        Register-DFTool -Name alpha, beta -ToolsPath $script:Tools
        $Env:DF_T_ALPHA_ENV | Should -Be '1'
    }

    It 'applies the winner''s declarative env and aliases, never a loser''s' {
        Register-DFTool -Name dfrolepager, dfrolepagerb -ToolsPath $script:Tools
        $Env:DF_T_PAGER | Should -Be 'dfrolepager --paging'
        (Get-Command tls).ScriptBlock | Should -Not -BeNullOrEmpty
        function global:dfrolepager { $global:RoleCalls += "dfrolepager:$args" }
        function global:dfrolepagerb { $global:RoleCalls += 'dfrolepagerb' }
        try { tls } finally { Remove-DFTestGlobal -Function dfrolepager, dfrolepagerb }
        $global:RoleCalls | Should -Contain 'dfrolepager:--list'
        $global:RoleCalls | Should -Not -Contain 'dfrolepagerb'
    }

    It 'warns once for an unresolved exclusive role' {
        Register-DFTool -Name alpha, beta -ToolsPath $script:Tools -WarningVariable w1 -WarningAction SilentlyContinue
        "$w1" | Should -Match 'tprompt role; using alpha'
        Register-DFTool -Name alpha, beta -ToolsPath $script:Tools -WarningVariable w2 -WarningAction SilentlyContinue
        "$w2" | Should -Not -Match 'tprompt role'
    }

    It 'warns, and continues with later tools, when a won role has no hook and no role-block content' {
        Write-Tool 'nohook' '{ "name": "nohook", "executable": "nohook.exe", "roles": { "tother": { "priority": 99 } } }'
        Register-DFTool -Name nohook, dfrolepager -ToolsPath $script:Tools -WarningVariable w -WarningAction SilentlyContinue
        "$w" | Should -Match 'nohook.*tother.*Initialize-DFRoleTother'
        $Env:DF_T_PAGER | Should -Be 'dfrolepager --paging'
    }

    It 'treats an empty env or aliases block as no content, so a missing hook still warns' {
        Write-Tool 'emptyblock' '{ "name": "emptyblock", "executable": "emptyblock.exe", "roles": { "tother": { "priority": 99, "env": {}, "aliases": {} } } }'
        Register-DFTool -Name emptyblock -ToolsPath $script:Tools -WarningVariable w -WarningAction SilentlyContinue
        "$w" | Should -Match 'emptyblock.*tother.*Initialize-DFRoleTother'
    }

    It 'warns, and continues, when a hook throws' {
        Write-Tool 'boom' '{ "name": "boom", "executable": "boom.exe", "roles": { "tother": { "priority": 99 } } }' @'
function Initialize-DFRoleTother { param($Tool, $Role) throw 'kaboom' }
'@

        Register-DFTool -Name boom, dfrolepager -ToolsPath $script:Tools -WarningVariable w -WarningAction SilentlyContinue
        "$w" | Should -Match 'boom.*tother.*kaboom'
        $Env:DF_T_PAGER | Should -Be 'dfrolepager --paging'
    }

    It 'ignores a same-named global function that does not come from the tool''s own sidecar' {
        function global:Initialize-DFRoleTother { $global:RoleCalls += 'stray' }
        try {
            Write-Tool 'nohook' '{ "name": "nohook", "executable": "nohook.exe", "roles": { "tother": { "priority": 99 } } }'
            Register-DFTool -Name nohook -ToolsPath $script:Tools -WarningAction SilentlyContinue
            $global:RoleCalls | Should -Not -Contain 'stray'
        } finally { Remove-DFTestGlobal -Function 'Initialize-DFRoleTother' }
    }

    It 'expands ${DF_TOOL_EXE} in a won role''s env to the resolved path' {
        Write-Tool 'exetool' '{ "name": "exetool", "executable": "exetool.exe", "roles": { "tpager": { "priority": 99, "env": { "DF_T_PAGER": "${DF_TOOL_EXE}" } } } }'
        Mock Resolve-DFToolExecutable { 'C:\Tools\exetool.exe' }
        Register-DFTool -Name exetool -ToolsPath $script:Tools
        $Env:DF_T_PAGER | Should -Be 'C:/Tools/exetool.exe'
    }

    It 're-running registration calls the winner''s hook again' {
        Register-DFTool -Name alpha -ToolsPath $script:Tools
        Register-DFTool -Name alpha -ToolsPath $script:Tools
        @($global:RoleCalls | Where-Object { $_ -eq 'alpha:tprompt' }).Count | Should -Be 2
    }

    Context 'legacy "role" string (role v1 records)' {
        BeforeEach {
            Write-Tool 'legacytool' '{ "name": "legacytool", "executable": "legacytool.exe", "role": "tlisting", "after": ["dfrolepager"],
                "aliases": { "tls": { "command": "legacytool", "args": ["--legacy"] }, "lonly": { "command": "legacytool", "args": ["--only"] } } }'

            function global:legacytool { $global:RoleCalls += "legacytool:$args" }
            function global:dfrolepager { $global:RoleCalls += "dfrolepager:$args" }
        }
        AfterEach { Remove-DFTestGlobal -Function legacytool, dfrolepager, lonly }

        It 'keeps a legacy winner''s top-level aliases, with no missing-hook warning' {
            Set-DFTestConfig @{ Defaults = @{ tlisting = 'legacytool' } }
            Register-DFTool -Name legacytool, dfrolepager -ToolsPath $script:Tools -WarningVariable w -WarningAction SilentlyContinue
            "$w" | Should -Not -Match 'Initialize-DFRole'
            tls
            $global:RoleCalls | Should -Contain 'legacytool:--legacy'
            $global:RoleCalls | Should -Not -Contain 'dfrolepager:--list'
        }

        It 'suppresses a legacy loser''s reserved top-level aliases, and keeps its others' {
            Set-DFTestConfig @{ Defaults = @{ tlisting = 'dfrolepager' } }
            Register-DFTool -Name legacytool, dfrolepager -ToolsPath $script:Tools -WarningAction SilentlyContinue
            tls
            $global:RoleCalls | Should -Contain 'dfrolepager:--list'
            $global:RoleCalls | Should -Not -Contain 'legacytool:--legacy'
            Test-Path function:global:lonly | Should -BeTrue
        }

        It 'loads a legacy free-form role name quietly' {
            Write-Tool 'oddlegacy' '{ "name": "oddlegacy", "executable": "oddlegacy.exe", "role": "my-own-group" }'
            Register-DFTool -Name oddlegacy -ToolsPath $script:Tools -WarningVariable w -WarningAction SilentlyContinue
            "$w" | Should -Not -Match 'unknown role'
        }
    }
}

Describe 'Get-DFRoleWinners opt-in memberships' {
    BeforeEach { Reset-DFTestSession;
        Set-DFTestConfig $null
        Mock Test-DFToolAvailable { $true }
        $script:RoleDb = @{ 'tab-completion' = [pscustomobject]@{ kind = 'single' } }
        $script:ToolDb = @{
            standard = [pscustomobject]@{ name = 'standard'; executable = 'standard.exe'; type = 'exe'; roles = [pscustomobject]@{ 'tab-completion' = [pscustomobject]@{ priority = 10; optIn = $false } } }
            optional = [pscustomobject]@{ name = 'optional'; executable = 'optional.exe'; type = 'exe'; roles = [pscustomobject]@{ 'tab-completion' = [pscustomobject]@{ priority = 20; optIn = $true } } }
        }
    }
    AfterEach { Set-DFTestConfig $null }

    It 'skips an opt-in candidate unless Defaults names it' {
        $winner = Get-DFRoleWinners -ToolDb $script:ToolDb -Tools $script:ToolDb.Values -RoleDb $script:RoleDb
        $winner['tab-completion'].Winner | Should -Be 'standard'
        $winner['tab-completion'].Candidates | Should -Be @('standard')
    }

    It 'allows Defaults to opt an optional candidate in over a higher-priority standard candidate' {
        Set-DFTestConfig @{ Defaults = @{ 'tab-completion' = 'optional' } }
        $winner = Get-DFRoleWinners -ToolDb $script:ToolDb -Tools $script:ToolDb.Values -RoleDb $script:RoleDb
        $winner['tab-completion'].Winner | Should -Be 'optional'
        $winner['tab-completion'].Reason | Should -Be 'Defaults'
    }

    It 'leaves a sole opt-in candidate without a winner' {
        $winner = Get-DFRoleWinners -ToolDb @{ optional = $script:ToolDb.optional } -Tools @($script:ToolDb.optional) -RoleDb $script:RoleDb
        $winner.ContainsKey('tab-completion') | Should -BeFalse
    }
}