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 } } } |