From d14e8b87be319335d2c31f4390d5e3df6999bbfa Mon Sep 17 00:00:00 2001 From: BiosSystem <63607038+BiosSystem@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:21:26 +0300 Subject: [PATCH] Cover GUI helpers that work on live WPF controls Third GUI test slice, and the pattern for the rest. ConvertTo-NormalizedCheckboxState, Set-TriStatePresetCheckBoxState, and Test-ComboBoxContainsMatch take real CheckBox and ComboBox objects, so each scenario builds the control, calls the function, and checks the resulting state. WPF construction needs an STA thread and the unit job runs under pwsh (MTA), so every scenario runs in its own STA runspace, which also loads the WPF assemblies away from the test host. Verified under both STA and MTA parents. Covers the subtle cases: a checkbox that was indeterminate before its click is normalized to checked, and the tri-state preset box goes unchecked / checked / indeterminate for none / all / partial selections. Wired into test.yml. --- .github/workflows/test.yml | 9 ++ Tests/Unit/Test-GuiControlState.ps1 | 142 ++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 Tests/Unit/Test-GuiControlState.ps1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2fbc339..64e2d38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -203,6 +203,15 @@ jobs: exit 1 } + - name: Run GUI control state tests + shell: pwsh + run: | + $result = Invoke-Pester -Path "Tests/Unit/Test-GuiControlState.ps1" -Output Detailed -PassThru + if ($result.FailedCount -gt 0) { + Write-Error "$($result.FailedCount) test(s) failed in Test-GuiControlState.ps1" + exit 1 + } + - name: Run Store search suggestion tests shell: pwsh run: | diff --git a/Tests/Unit/Test-GuiControlState.ps1 b/Tests/Unit/Test-GuiControlState.ps1 new file mode 100644 index 0000000..f6ff7d1 --- /dev/null +++ b/Tests/Unit/Test-GuiControlState.ps1 @@ -0,0 +1,142 @@ +#Requires -Modules Pester +<# +.SYNOPSIS + Unit tests for GUI helpers that operate on live WPF controls. +.DESCRIPTION + ConvertTo-NormalizedCheckboxState, Set-TriStatePresetCheckBoxState, and + Test-ComboBoxContainsMatch take real CheckBox and ComboBox objects, so they + cannot be exercised with a plain data stand-in. Each scenario builds the + control, calls the function, and returns the resulting state. + + WPF construction needs a single-threaded apartment; the unit suite runs + under pwsh, which is multi-threaded, so every scenario runs in its own STA + runspace. The runspace shares this process, so the returned hashtable holds + real values, not serialized copies. +#> + +BeforeAll { + $script:repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') | Select-Object -ExpandProperty Path + + function Invoke-InStaRunspace { + param( + [Parameter(Mandatory)][scriptblock]$Script, + [object[]]$ArgumentList = @() + ) + $runspace = [runspacefactory]::CreateRunspace() + $runspace.ApartmentState = 'STA' + $runspace.ThreadOptions = 'ReuseThread' + $runspace.Open() + try { + $shell = [powershell]::Create() + $shell.Runspace = $runspace + [void]$shell.AddScript($Script.ToString()) + foreach ($argument in $ArgumentList) { [void]$shell.AddArgument($argument) } + $output = $shell.Invoke() + if ($shell.HadErrors) { + throw (($shell.Streams.Error | ForEach-Object { $_.ToString() }) -join '; ') + } + $shell.Dispose() + return $output[-1] + } + finally { + $runspace.Dispose() + } + } + + # Runs against MainWindow-AppSelection.ps1 loaded inside the STA runspace. + $script:appSelectionScenario = { + param($repoRoot, $case) + Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase -ErrorAction Stop + . (Join-Path $repoRoot 'Scripts\GUI\MainWindow-AppSelection.ps1') + + switch ($case) { + 'normalize-indeterminate' { + $cb = New-Object System.Windows.Controls.CheckBox + $cb.IsThreeState = $true + $cb | Add-Member -NotePropertyName WasIndeterminateBeforeClick -NotePropertyValue $true -Force + $cb.IsChecked = $false + $result = ConvertTo-NormalizedCheckboxState -CheckBox $cb + return @{ Result = $result; IsChecked = $cb.IsChecked; StillFlagged = $cb.WasIndeterminateBeforeClick } + } + 'normalize-checked' { + $cb = New-Object System.Windows.Controls.CheckBox + $cb.IsChecked = $true + return @{ Result = (ConvertTo-NormalizedCheckboxState -CheckBox $cb) } + } + 'normalize-unchecked' { + $cb = New-Object System.Windows.Controls.CheckBox + $cb.IsChecked = $false + return @{ Result = (ConvertTo-NormalizedCheckboxState -CheckBox $cb) } + } + default { + # tri-state: case is "Total,Selected" + $parts = $case -split ',' + $cb = New-Object System.Windows.Controls.CheckBox + $cb.IsThreeState = $true + Set-TriStatePresetCheckBoxState -CheckBox $cb -Total ([int]$parts[0]) -Selected ([int]$parts[1]) + return @{ IsChecked = $cb.IsChecked; IsNull = ($null -eq $cb.IsChecked); IsEnabled = $cb.IsEnabled } + } + } + } + + $script:comboScenario = { + param($repoRoot, $searchText) + Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase -ErrorAction Stop + . (Join-Path $repoRoot 'Scripts\GUI\MainWindow-TweaksBuilder.ps1') + + $combo = New-Object System.Windows.Controls.ComboBox + $item = New-Object System.Windows.Controls.ComboBoxItem + $item.Content = 'Show search box' + [void]$combo.Items.Add($item) + [void]$combo.Items.Add('Hide taskbar') + return @{ Match = (Test-ComboBoxContainsMatch -ComboBox $combo -SearchText $searchText) } + } +} + +Describe 'ConvertTo-NormalizedCheckboxState' { + It 'treats a checkbox that was indeterminate before the click as checked' { + $r = Invoke-InStaRunspace -Script $script:appSelectionScenario -ArgumentList @($script:repoRoot, 'normalize-indeterminate') + $r.Result | Should -BeTrue + $r.IsChecked | Should -BeTrue + $r.StillFlagged | Should -BeFalse -Because 'the one-shot flag is cleared' + } + + It 'reports a checked box as checked and an unchecked box as unchecked' { + (Invoke-InStaRunspace -Script $script:appSelectionScenario -ArgumentList @($script:repoRoot, 'normalize-checked')).Result | Should -BeTrue + (Invoke-InStaRunspace -Script $script:appSelectionScenario -ArgumentList @($script:repoRoot, 'normalize-unchecked')).Result | Should -BeFalse + } +} + +Describe 'Set-TriStatePresetCheckBoxState' { + It 'disables and unchecks the box when nothing is available' { + $r = Invoke-InStaRunspace -Script $script:appSelectionScenario -ArgumentList @($script:repoRoot, '0,0') + $r.IsEnabled | Should -BeFalse + $r.IsChecked | Should -BeFalse + } + + It 'unchecks when none selected, checks when all selected' { + $none = Invoke-InStaRunspace -Script $script:appSelectionScenario -ArgumentList @($script:repoRoot, '3,0') + $none.IsEnabled | Should -BeTrue + $none.IsChecked | Should -BeFalse + + $all = Invoke-InStaRunspace -Script $script:appSelectionScenario -ArgumentList @($script:repoRoot, '3,3') + $all.IsChecked | Should -BeTrue + } + + It 'goes indeterminate on a partial selection' { + $r = Invoke-InStaRunspace -Script $script:appSelectionScenario -ArgumentList @($script:repoRoot, '3,1') + $r.IsNull | Should -BeTrue + $r.IsEnabled | Should -BeTrue + } +} + +Describe 'Test-ComboBoxContainsMatch' { + It 'finds a match in a ComboBoxItem and in a plain string item' { + (Invoke-InStaRunspace -Script $script:comboScenario -ArgumentList @($script:repoRoot, 'search')).Match | Should -BeTrue + (Invoke-InStaRunspace -Script $script:comboScenario -ArgumentList @($script:repoRoot, 'taskbar')).Match | Should -BeTrue + } + + It 'returns false when no item contains the search text' { + (Invoke-InStaRunspace -Script $script:comboScenario -ArgumentList @($script:repoRoot, 'nonexistent')).Match | Should -BeFalse + } +}