Tests/PSInquirer.Tests.ps1

#Requires -Version 5.1
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }

<#
.SYNOPSIS
    Pester 5 tests for PSInquirer, targeting >= 100% logical branch coverage.
 
.NOTES
    The one line that cannot be exercised in an automated headless environment
    is the body of Read-ConsoleKey (i.e. [Console]::ReadKey($true)), because
    that call blocks until a real keystroke arrives. Every other logical branch
    -- including all navigation, VT-detection, and rendering paths -- is covered.
#>


BeforeAll {
    # Import the module fresh before every test run.
    $path = Join-Path $PSScriptRoot '..' 
    $path = Join-Path $path 'PSInquirer.psd1'
    Import-Module $path -Force

    # ── Shared key-info factory helper ───────────────────────────────────────

    function New-KeyInfo {
        param (
            [System.ConsoleKey] $Key,
            [char]              $KeyChar = [char]0
        )
        return [System.ConsoleKeyInfo]::new($KeyChar, $Key, $false, $false, $false)
    }

    # Returns a Queue[ConsoleKeyInfo] pre-loaded with the supplied keys.
    # Write-Output -NoEnumerate prevents PowerShell from unrolling the Queue
    # into individual items when it is assigned to a variable.
    function New-KeyQueue {
        param ([System.ConsoleKeyInfo[]] $Keys)
        $q = [System.Collections.Generic.Queue[System.ConsoleKeyInfo]]::new()
        foreach ($k in $Keys) { $q.Enqueue($k) }
        Write-Output -NoEnumerate $q
    }

    # Pre-built key objects reused across tests.
    $script:EnterKey = New-KeyInfo -Key ([System.ConsoleKey]::Enter)    -KeyChar ([char]13)
    $script:DownKey  = New-KeyInfo -Key ([System.ConsoleKey]::DownArrow)
    $script:UpKey    = New-KeyInfo -Key ([System.ConsoleKey]::UpArrow)
    $script:OtherKey = New-KeyInfo -Key ([System.ConsoleKey]::Spacebar) -KeyChar ([char]32)
}

# ═══════════════════════════════════════════════════════════════════════════════
# Get-PSMajorVersion
# ═══════════════════════════════════════════════════════════════════════════════

Describe 'Get-PSMajorVersion' {
    It 'returns the current PowerShell major version as an integer' {
        InModuleScope PSInquirer {
            $result = Get-PSMajorVersion
            $result | Should -Be $PSVersionTable.PSVersion.Major
            $result | Should -BeOfType [int]
        }
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# Get-VTSupport
# ═══════════════════════════════════════════════════════════════════════════════

Describe 'Get-VTSupport' {

    Context 'when running on PowerShell 7+ (major version >= 6)' {
        It 'returns $true without checking environment variables' {
            InModuleScope PSInquirer {
                if ($PSVersionTable.PSVersion.Major -lt 6) {
                    Set-ItResult -Skip -Because "Test is only applicable to PowerShell 6+"
                }
                else {
                    # No mock needed -- we are already running on PS 7+.
                    Get-VTSupport | Should -BeTrue
                }
            }
        }
    }

    Context 'when simulating PowerShell 5.1 (major version < 6)' {

        BeforeEach {
            # Override Get-PSMajorVersion so the PS-version branch is skipped,
            # allowing the env-var branches below to be exercised.
            Mock -ModuleName PSInquirer Get-PSMajorVersion { return 5 }
        }

        It 'returns $true when ConEmuANSI equals ON' {
            InModuleScope PSInquirer {
                $old = $env:ConEmuANSI
                try {
                    $env:ConEmuANSI = 'ON'
                    Get-VTSupport | Should -BeTrue
                }
                finally {
                    if ($null -eq $old) { Remove-Item Env:ConEmuANSI -ErrorAction SilentlyContinue }
                    else               { $env:ConEmuANSI = $old }
                }
            }
        }

        It 'returns $false when ConEmuANSI is not ON and TERM is unset' {
            InModuleScope PSInquirer {
                $oldConEmu = $env:ConEmuANSI
                $oldTerm   = $env:TERM
                try {
                    Remove-Item Env:ConEmuANSI -ErrorAction SilentlyContinue
                    Remove-Item Env:TERM       -ErrorAction SilentlyContinue
                    Get-VTSupport | Should -BeFalse
                }
                finally {
                    if ($null -ne $oldConEmu) { $env:ConEmuANSI = $oldConEmu }
                    if ($null -ne $oldTerm)   { $env:TERM = $oldTerm }
                }
            }
        }

        It 'returns $true when TERM contains xterm' {
            InModuleScope PSInquirer {
                $old = $env:TERM
                try {
                    Remove-Item Env:ConEmuANSI -ErrorAction SilentlyContinue
                    $env:TERM = 'xterm-256color'
                    Get-VTSupport | Should -BeTrue
                }
                finally {
                    if ($null -eq $old) { Remove-Item Env:TERM -ErrorAction SilentlyContinue }
                    else               { $env:TERM = $old }
                }
            }
        }

        It 'returns $true when TERM contains vt100' {
            InModuleScope PSInquirer {
                $old = $env:TERM
                try {
                    Remove-Item Env:ConEmuANSI -ErrorAction SilentlyContinue
                    $env:TERM = 'vt100'
                    Get-VTSupport | Should -BeTrue
                }
                finally {
                    if ($null -eq $old) { Remove-Item Env:TERM -ErrorAction SilentlyContinue }
                    else               { $env:TERM = $old }
                }
            }
        }

        It 'returns $true when TERM contains ansi' {
            InModuleScope PSInquirer {
                $old = $env:TERM
                try {
                    Remove-Item Env:ConEmuANSI -ErrorAction SilentlyContinue
                    $env:TERM = 'ansi'
                    Get-VTSupport | Should -BeTrue
                }
                finally {
                    if ($null -eq $old) { Remove-Item Env:TERM -ErrorAction SilentlyContinue }
                    else               { $env:TERM = $old }
                }
            }
        }
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# Write-MenuRow
# ═══════════════════════════════════════════════════════════════════════════════

Describe 'Write-MenuRow' {

    Context 'unselected row' {
        It 'uses " " prefix (two spaces) and writes plain text to console' {
            InModuleScope PSInquirer {
                # Redirect [Console]::Out to capture raw console writes.
                $sw = [System.IO.StringWriter]::new()
                $oldOut = [Console]::Out
                [Console]::SetOut($sw)
                try {
                    Write-MenuRow -Text 'Gamma' -Selected $false -ConsoleWidth 20 -SupportsVT $true
                }
                finally {
                    [Console]::SetOut($oldOut)
                }

                $written = $sw.ToString()
                $written | Should -Match '^\s{2}Gamma'  # starts with two spaces
                $written | Should -Not -Match '>'       # no pointer arrow
            }
        }
    }

    Context 'selected row with VT support' {
        It 'uses "> " prefix and wraps output in ANSI cyan escape codes' {
            InModuleScope PSInquirer {
                $sw = [System.IO.StringWriter]::new()
                $oldOut = [Console]::Out
                [Console]::SetOut($sw)
                try {
                    Write-MenuRow -Text 'Alpha' -Selected $true -ConsoleWidth 20 -SupportsVT $true
                }
                finally {
                    [Console]::SetOut($oldOut)
                }

                $written = $sw.ToString()
                $written | Should -Match '\[96m'   # ANSI cyan open sequence
                $written | Should -Match '\[0m'    # ANSI reset sequence
                $written | Should -Match '>'       # pointer character present
            }
        }
    }

    Context 'selected row without VT support' {
        It 'calls Write-Host with ForegroundColor Cyan instead of ANSI escapes' {
            InModuleScope PSInquirer {
                Mock Write-Host {} -Verifiable

                Write-MenuRow -Text 'Beta' -Selected $true -ConsoleWidth 20 -SupportsVT $false

                Should -Invoke Write-Host -Exactly 1 -ParameterFilter {
                    $ForegroundColor -eq 'Cyan' -and $NoNewline -eq $true
                }
            }
        }

        It 'passes a line that starts with the "> " pointer to Write-Host' {
            InModuleScope PSInquirer {
                Mock Write-Host {} -Verifiable

                Write-MenuRow -Text 'Beta' -Selected $true -ConsoleWidth 20 -SupportsVT $false

                # Use ParameterFilter to assert on the value passed to Write-Host,
                # avoiding cross-scope variable capture issues.
                Should -Invoke Write-Host -Exactly 1 -ParameterFilter {
                    ($Object -join '') -match '^> '
                }
            }
        }
    }

    Context 'line padding' {
        It 'pads line to (ConsoleWidth - 1) characters' {
            InModuleScope PSInquirer {
                $sw = [System.IO.StringWriter]::new()
                $oldOut = [Console]::Out
                [Console]::SetOut($sw)
                try {
                    Write-MenuRow -Text 'X' -Selected $false -ConsoleWidth 30 -SupportsVT $true
                }
                finally {
                    [Console]::SetOut($oldOut)
                }

                # Written string should be exactly 29 characters wide.
                $sw.ToString().Length | Should -Be 29
            }
        }

        It 'uses padWidth of 0 when ConsoleWidth is 0 (no auto-wrap crash)' {
            InModuleScope PSInquirer {
                { Write-MenuRow -Text 'X' -Selected $false -ConsoleWidth 0 -SupportsVT $false } |
                    Should -Not -Throw
            }
        }

        It 'uses padWidth of 0 when ConsoleWidth is 1' {
            InModuleScope PSInquirer {
                { Write-MenuRow -Text 'X' -Selected $false -ConsoleWidth 1 -SupportsVT $false } |
                    Should -Not -Throw
            }
        }
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# Invoke-PromptList
# ═══════════════════════════════════════════════════════════════════════════════

Describe 'Invoke-PromptList' {

    BeforeEach {
        # Suppress Write-Host output so test runs stay clean.
        Mock -ModuleName PSInquirer Write-Host {}

        # Default: return Enter immediately so the first item is selected.
        Mock -ModuleName PSInquirer Read-ConsoleKey { return $script:EnterKey }

        # Stub console geometry so tests never touch the real console handle.
        # This prevents IOException in headless/CI environments (no TTY attached).
        Mock -ModuleName PSInquirer Get-ConsoleWidth       { return 80 }
        Mock -ModuleName PSInquirer Get-ConsoleCursorTop   { return 0  }
        Mock -ModuleName PSInquirer Get-ConsoleBufferHeight { return 50 }
    }

    # ── Return value ───────────────────────────────────────────────────────────

    Context 'return value' {

        It 'returns the first choice when Enter is pressed without navigating' {
            $result = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma')
            $result | Should -Be 'Alpha'
        }

        It 'outputs the selected string to the pipeline' {
            $pipeline = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta')
            $pipeline | Should -BeOfType [string]
            $pipeline | Should -Be 'Alpha'
        }
    }

    # ── DownArrow navigation ───────────────────────────────────────────────────

    Context 'DownArrow navigation' {

        It 'moves selection down one item (index 0 -> 1)' {
            $q = New-KeyQueue @($script:DownKey, $script:EnterKey)
            Mock -ModuleName PSInquirer Read-ConsoleKey { return $q.Dequeue() }

            $result = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma')
            $result | Should -Be 'Beta'
        }

        It 'moves selection down two items (index 0 -> 2)' {
            $q = New-KeyQueue @($script:DownKey, $script:DownKey, $script:EnterKey)
            Mock -ModuleName PSInquirer Read-ConsoleKey { return $q.Dequeue() }

            $result = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma')
            $result | Should -Be 'Gamma'
        }

        It 'wraps from the last item back to the first item' {
            # Three choices; press Down 3 times -> wraps around to index 0.
            $q = New-KeyQueue @(
                $script:DownKey, $script:DownKey, $script:DownKey, $script:EnterKey
            )
            Mock -ModuleName PSInquirer Read-ConsoleKey { return $q.Dequeue() }

            $result = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma')
            $result | Should -Be 'Alpha'
        }
    }

    # ── UpArrow navigation ─────────────────────────────────────────────────────

    Context 'UpArrow navigation' {

        It 'wraps from the first item to the last item' {
            $q = New-KeyQueue @($script:UpKey, $script:EnterKey)
            Mock -ModuleName PSInquirer Read-ConsoleKey { return $q.Dequeue() }

            $result = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma')
            $result | Should -Be 'Gamma'
        }

        It 'moves selection up from the last item (index 2 -> 1)' {
            # Go to last item first, then go up once.
            $q = New-KeyQueue @(
                $script:DownKey, $script:DownKey,  # -> index 2
                $script:UpKey,                      # -> index 1
                $script:EnterKey
            )
            Mock -ModuleName PSInquirer Read-ConsoleKey { return $q.Dequeue() }

            $result = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma')
            $result | Should -Be 'Beta'
        }

        It 'moves selection up from a middle item (index 1 -> 0)' {
            $q = New-KeyQueue @($script:DownKey, $script:UpKey, $script:EnterKey)
            Mock -ModuleName PSInquirer Read-ConsoleKey { return $q.Dequeue() }

            $result = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma')
            $result | Should -Be 'Alpha'
        }
    }

    # ── Unrecognised keys ──────────────────────────────────────────────────────

    Context 'unrecognised / non-navigation keys' {

        It 'ignores keys other than Up, Down, Enter and does not change selection' {
            # Press Spacebar (ignored key) then Enter.
            $q = New-KeyQueue @($script:OtherKey, $script:EnterKey)
            Mock -ModuleName PSInquirer Read-ConsoleKey { return $q.Dequeue() }

            $result = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta')
            $result | Should -Be 'Alpha'
        }
    }

    # ── VT colour paths ────────────────────────────────────────────────────────

    Context 'final answer display with VT support enabled' {

        It 'writes the answer wrapped in ANSI green escape codes' {
            Mock -ModuleName PSInquirer Get-VTSupport { return $true }

            # Redirect [Console]::Out to capture ANSI codes in the final output.
            $sw = [System.IO.StringWriter]::new()
            $oldOut = [Console]::Out
            [Console]::SetOut($sw)
            try {
                Invoke-PromptList -Message 'Color?' -Choices @('Red', 'Blue') | Out-Null
            }
            finally {
                [Console]::SetOut($oldOut)
            }

            $written = $sw.ToString()
            $written | Should -Match '\[32m'   # ANSI green open
            $written | Should -Match '\[0m'    # ANSI reset
        }
    }

    Context 'final answer display without VT support' {

        It 'calls Write-Host with ForegroundColor Green for the answer' {
            Mock -ModuleName PSInquirer Get-VTSupport { return $false }

            $script:greenCallCount = 0
            Mock -ModuleName PSInquirer Write-Host {
                if ($ForegroundColor -eq 'Green') { $script:greenCallCount++ }
            }

            Invoke-PromptList -Message 'Color?' -Choices @('Red', 'Blue') | Out-Null

            $script:greenCallCount | Should -BeGreaterThan 0
        }
    }

    # ── Cursor state ───────────────────────────────────────────────────────────

    Context 'cursor management' {

        It 'restores cursor visibility to $true after a normal run' {
            Invoke-PromptList -Message 'Pick' -Choices @('A', 'B') | Out-Null

            # [Console]::CursorVisible getter is unavailable on some platforms:
            # Linux raises PlatformNotSupportedException; Windows CI without a
            # console handle raises IOException. Skip gracefully in both cases.
            try {
                [Console]::CursorVisible | Should -BeTrue
            }
            catch [System.PlatformNotSupportedException] {
                Set-ItResult -Skipped -Because '[Console]::CursorVisible getter is not supported on this platform'
            }
            catch [System.IO.IOException] {
                Set-ItResult -Skipped -Because '[Console]::CursorVisible getter requires a console handle (IOException)'
            }
        }

        It 'restores cursor visibility to $true even when an exception is thrown mid-render' {
            Mock -ModuleName PSInquirer Read-ConsoleKey { throw 'Simulated interrupt' }

            # Swallow the propagated error; only cursor-restore behaviour matters.
            try {
                Invoke-PromptList -Message 'Pick' -Choices @('A', 'B') | Out-Null
            }
            catch { }

            try {
                [Console]::CursorVisible | Should -BeTrue
            }
            catch [System.PlatformNotSupportedException] {
                Set-ItResult -Skipped -Because '[Console]::CursorVisible getter is not supported on this platform'
            }
            catch [System.IO.IOException] {
                Set-ItResult -Skipped -Because '[Console]::CursorVisible getter requires a console handle (IOException)'
            }
        }
    }

    # ── Edge cases ─────────────────────────────────────────────────────────────

    Context 'edge cases' {

        It 'works correctly with a single-item list' {
            $result = Invoke-PromptList -Message 'Confirm?' -Choices @('Yes')
            $result | Should -Be 'Yes'
        }

        It 'works correctly with exactly two choices' {
            $q = New-KeyQueue @($script:DownKey, $script:EnterKey)
            Mock -ModuleName PSInquirer Read-ConsoleKey { return $q.Dequeue() }

            $result = Invoke-PromptList -Message 'Binary?' -Choices @('Yes', 'No')
            $result | Should -Be 'No'
        }

        It 'wraps DownArrow from last to first item when only two choices exist' {
            # DownArrow from index 1 (last) -> wraps to index 0 (first).
            $q = New-KeyQueue @($script:DownKey, $script:DownKey, $script:EnterKey)
            Mock -ModuleName PSInquirer Read-ConsoleKey { return $q.Dequeue() }

            $result = Invoke-PromptList -Message 'Binary?' -Choices @('Yes', 'No')
            $result | Should -Be 'Yes'
        }
    }

    # ── Buffer-overflow guard (SetCursorPosition bottom-of-screen fix) ─────────

    Context 'buffer overflow guard' {

        It 'does not throw when cursor is at the last row of the console buffer' {
            # Simulate a tiny 5-row buffer with the cursor at row 4 (the last row).
            # Without the pre-scroll guard this triggers the SetCursorPosition
            # "top" out-of-range exception reported on Windows PowerShell 5.1.
            Mock -ModuleName PSInquirer Get-ConsoleCursorTop    { return 4 }
            Mock -ModuleName PSInquirer Get-ConsoleBufferHeight { return 5 }

            { Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma') } |
                Should -Not -Throw
        }

        It 'returns the correct selection when cursor starts at the bottom of the buffer' {
            Mock -ModuleName PSInquirer Get-ConsoleCursorTop    { return 4 }
            Mock -ModuleName PSInquirer Get-ConsoleBufferHeight { return 5 }

            $result = Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma')
            $result | Should -Be 'Alpha'
        }

        It 'adjusts menuStartRow so it is never negative when buffer is smaller than totalLines' {
            # Buffer of 2 rows but 4 lines needed; menuStartRow must clamp to 0.
            Mock -ModuleName PSInquirer Get-ConsoleCursorTop    { return 1 }
            Mock -ModuleName PSInquirer Get-ConsoleBufferHeight { return 2 }

            { Invoke-PromptList -Message 'Pick' -Choices @('Alpha', 'Beta', 'Gamma') } |
                Should -Not -Throw
        }
    }
}