feat(input): 完成阶段3键盘路由与焦点重绘收口

This commit is contained in:
Codex
2026-10-10 00:05:23 +08:00
parent 69c26a35a2
commit 29df994f09
30 changed files with 2227 additions and 80 deletions
+155 -9
View File
@@ -1,14 +1,27 @@
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
param(
[int]$Repeat = 1,
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow')]
[string[]]$OnlyScenario = @()
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow', 'keyboard-routing')]
[string[]]$OnlyScenario = @(),
[string]$FixturePath = '',
[string]$ResultRoot = '',
[ValidateSet('Legacy', 'Routed')]
[string]$InputPipelineExpectation = 'Routed'
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
$resultRoot = Join-Path $root 'TestResults\ui'
$fixture = if ($FixturePath) {
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FixturePath)
} else {
Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
}
$resultRoot = if ($ResultRoot) {
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ResultRoot)
} else {
Join-Path $root 'TestResults\ui'
}
if (-not (Test-Path -LiteralPath $fixture -PathType Leaf)) { throw "UI fixture not found: $fixture" }
New-Item -ItemType Directory -Force -Path $resultRoot | Out-Null
Add-Type -AssemblyName System.Drawing
@@ -182,6 +195,20 @@ function Post-KeyWave([IntPtr]$h, [int]$pairCount = 1000) {
}
}
function Post-KeyPair([IntPtr]$h, [int]$virtualKey, [int]$scanCode) {
# Post the same Win32 down/up shape captured from a real keyboard. This keeps
# the test local to the fixture HWND while traversing its actual WndProc.
$down = [IntPtr]([long](1 -bor ($scanCode -shl 16)))
$up = [IntPtr]([long](0xC0000001L -bor ($scanCode -shl 16)))
if (-not [StellarXUiNative]::PostMessage($h, 0x0100, [IntPtr]$virtualKey, $down)) {
throw "Failed to post key-down for VK=$virtualKey"
}
if (-not [StellarXUiNative]::PostMessage($h, 0x0101, [IntPtr]$virtualKey, $up)) {
throw "Failed to post key-up for VK=$virtualKey"
}
Start-Sleep -Milliseconds 120
}
function Capture-Client([IntPtr]$h, [string]$path) {
# 只保存客户区,排除窗口边框;返回 Bitmap 供调用方继续读取关键像素。
$rect = [StellarXUiNative]::GetClientRectangle($h)
@@ -264,6 +291,17 @@ function Assert-RegionsDiffer([Drawing.Bitmap]$before, [Drawing.Bitmap]$after,
throw $message
}
function Assert-RegionsEqual([Drawing.Bitmap]$before, [Drawing.Bitmap]$after,
[int]$left, [int]$top, [int]$right, [int]$bottom, [string]$message) {
for ($y = $top; $y -lt $bottom; $y++) {
for ($x = $left; $x -lt $right; $x++) {
if ($before.GetPixel($x, $y).ToArgb() -ne $after.GetPixel($x, $y).ToArgb()) {
throw "$message first difference=($x,$y)"
}
}
}
}
function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
[string]$capturePath, [int]$timeoutMs = 15000) {
# 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。
@@ -283,9 +321,10 @@ function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
$savedCursor = New-Object StellarXUiNative+POINT
# UI 测试会抢占前台并移动鼠标,外层 finally 必须恢复用户原来的光标位置。
[void][StellarXUiNative]::GetCursorPos([ref]$savedCursor)
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow')
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow', 'keyboard-routing')
$scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios }
$sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID")
$scenarioTimings = [Collections.Generic.List[object]]::new()
try {
# 场景按固定顺序串行执行,避免多个 EasyX 图形上下文同时争用桌面。
@@ -296,6 +335,7 @@ try {
$resultFile = Join-Path $runDir "$scenario.json"
$stdoutFile = Join-Path $runDir 'fixture.stdout.log'
$stderrFile = Join-Path $runDir 'fixture.stderr.log'
$scenarioStopwatch = [Diagnostics.Stopwatch]::StartNew()
$process = Start-Process -FilePath $fixture -ArgumentList @($scenario, $runDir) -PassThru `
-RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
$h = [IntPtr]::Zero
@@ -310,9 +350,29 @@ try {
'tab' { Click-Mouse $h 230 35; Click-Mouse $h 395 238; $result = Wait-Result $resultFile; if ($result.active -ne 1 -or $result.firstSelected -or -not $result.secondSelected -or $result.firstPageVisible -or -not $result.secondPageVisible) { throw 'tab uniqueness result failed' } }
'table' { Click-Mouse $h 395 268; $result = Wait-Result $resultFile; if ($result.width -le 40 -or $result.coverageRight -le $result.coverageLeft -or $result.coverageBottom -le $result.coverageTop) { throw 'table minimum width or coverage failed' } }
'focus' {
# 先把鼠标移到固定空白处,排除进入场景时的随机 hover 像素。
Move-Mouse $h 780 500
$initialFocus = Capture-Client $h (Join-Path $runDir 'focus-textbox.png')
try {
Assert-RegionContainsRgb $initialFocus 20 78 182 118 32 96 192 'TextBox focus frame was not rendered'
# TextBox 后一站是 Table 当前页唯一可用的 next Button。焦点框
# 必须在同一键盘批次出现;再 Tab 离开后必须同一批次消失。
Post-KeyPair $h 0x09 0x0F
$tableFocused = Capture-Client $h (Join-Path $runDir 'focus-table-on.png')
try {
Assert-RegionsDiffer $initialFocus $tableFocused 200 20 340 145 `
'Table pagination focus frame did not repaint immediately'
}
finally { $tableFocused.Dispose() }
Post-KeyPair $h 0x09 0x0F
$tableBlurred = Capture-Client $h (Join-Path $runDir 'focus-table-off.png')
try {
Assert-RegionsEqual $initialFocus $tableBlurred 200 20 340 145 `
'Table pagination focus frame remained after focus moved away'
}
finally { $tableBlurred.Dispose() }
Click-Mouse $h 80 40
Move-Mouse $h 470 310
$buttonFocus = Capture-Client $h (Join-Path $runDir 'focus-button.png')
@@ -321,6 +381,45 @@ try {
Assert-RegionsDiffer $initialFocus $buttonFocus 20 20 140 60 'Button focus visual did not change after pointer focus'
}
finally { $buttonFocus.Dispose() }
# Canvas 背景和 Window 背景都是明确的焦点清除面。
Click-Mouse $h 560 57
Move-Mouse $h 780 500
$canvasFocused = Capture-Client $h (Join-Path $runDir 'focus-canvas-button.png')
try {
Assert-RegionsDiffer $initialFocus $canvasFocused 518 38 632 76 `
'Canvas child did not receive pointer focus'
}
finally { $canvasFocused.Dispose() }
Click-Mouse $h 730 120
Move-Mouse $h 780 500
$canvasCleared = Capture-Client $h (Join-Path $runDir 'focus-canvas-cleared.png')
try {
Assert-RegionsEqual $initialFocus $canvasCleared 518 38 632 76 `
'Canvas background click did not clear focus visual'
}
finally { $canvasCleared.Dispose() }
Click-Mouse $h 80 40
Move-Mouse $h 780 500
Click-Mouse $h 780 500
$windowCleared = Capture-Client $h (Join-Path $runDir 'focus-window-cleared.png')
try {
Assert-RegionsEqual $initialFocus $windowCleared 18 18 142 62 `
'Window background click did not clear focus visual'
}
finally { $windowCleared.Dispose() }
# 同父 overlay 回归:下层失焦和上层获焦同时发生后,交叠处
# 必须仍是上层 Canvas 背景色,而不是被下层重绘覆盖。
Click-Mouse $h 540 225
Click-Mouse $h 680 295
$overlayFocus = Capture-Client $h (Join-Path $runDir 'focus-overlay-restored.png')
try {
Assert-Rgb ($overlayFocus.GetPixel(610, 245)) 200 160 120 `
'Upper Canvas background was covered by lower focus repaint'
}
finally { $overlayFocus.Dispose() }
}
finally { $initialFocus.Dispose() }
Click-Mouse $h 405 278
@@ -365,10 +464,29 @@ try {
$memoryAfterSecondWave = $process.PrivateMemorySize64
Click-Mouse $h 100 61
$result = Wait-Result $resultFile
if ($result.legacyKeyCount -ne $expectedKeyEvents) { throw "legacy EX_KEY count mismatch: $($result.legacyKeyCount)" }
if ($result.legacyKeyDownCount -ne ($expectedKeyEvents / 2) -or
$result.legacyKeyUpCount -ne ($expectedKeyEvents / 2)) {
throw "legacy EX_KEY action mismatch: down=$($result.legacyKeyDownCount) up=$($result.legacyKeyUpCount)"
if ($InputPipelineExpectation -eq 'Legacy') {
if ($result.legacyKeyCount -ne $expectedKeyEvents) { throw "legacy EX_KEY count mismatch: $($result.legacyKeyCount)" }
if ($result.legacyKeyDownCount -ne ($expectedKeyEvents / 2) -or
$result.legacyKeyUpCount -ne ($expectedKeyEvents / 2)) {
throw "legacy EX_KEY action mismatch: down=$($result.legacyKeyDownCount) up=$($result.legacyKeyUpCount)"
}
}
else {
if ($result.legacyKeyCount -ne 0 -or $result.legacyKeyDownCount -ne 0 -or
$result.legacyKeyUpCount -ne 0) {
throw "legacy EX_KEY business path was not disabled: total=$($result.legacyKeyCount)"
}
if ($result.routedKeyCount -ne $expectedKeyEvents -or
$result.routedKeyDownCount -ne ($expectedKeyEvents / 2) -or
$result.routedKeyUpCount -ne ($expectedKeyEvents / 2)) {
throw "routed key delivery mismatch: total=$($result.routedKeyCount) down=$($result.routedKeyDownCount) up=$($result.routedKeyUpCount)"
}
if ($result.easyXKeyboardMessagesDrained -ne $expectedKeyEvents) {
throw "EasyX EX_KEY drain mismatch: $($result.easyXKeyboardMessagesDrained)"
}
if ($result.legacyEasyXKeyboardBusinessDeliveries -ne 0) {
throw "legacy EX_KEY business delivery count was not zero: $($result.legacyEasyXKeyboardBusinessDeliveries)"
}
}
if ($result.keyEvents -ne $expectedKeyEvents) { throw "normalized key count mismatch: $($result.keyEvents)" }
if ($result.pushed -ne $result.popped -or $result.currentDepth -ne 0) { throw 'native input queue did not drain completely' }
@@ -381,11 +499,24 @@ try {
if ($memoryGrowth -gt 16MB) { throw "input shadow memory grew after warmup: $memoryGrowth bytes" }
@{
expectedKeyEvents = $expectedKeyEvents
inputPipelineExpectation = $InputPipelineExpectation
memoryAfterWarmup = $memoryAfterWarmup
memoryAfterSecondWave = $memoryAfterSecondWave
memoryGrowth = $memoryGrowth
} | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $runDir 'input-shadow-memory.json')
}
'keyboard-routing' {
# Space confirms on KeyUp, Tab is the framework default,
# and Enter confirms on first KeyDown.
Post-KeyPair $h 0x20 0x39
Post-KeyPair $h 0x09 0x0F
Post-KeyPair $h 0x0D 0x1C
$result = Wait-Result $resultFile
if ($result.firstClicks -ne 1 -or $result.secondClicks -ne 1 -or
-not $result.secondFocused) {
throw 'production keyboard route did not activate and traverse exactly once'
}
}
}
$bitmap = Capture-Client $h (Join-Path $runDir 'screen.png')
if ($scenario -eq 'ownership' -or $scenario -eq 'geometry') {
@@ -417,6 +548,13 @@ try {
Assert-Rgb ($bitmap.GetPixel(240, 150)) 30 50 70 'dialog pixels remained after both dialogs closed'
}
$bitmap.Dispose()
$scenarioStopwatch.Stop()
$scenarioTimings.Add([pscustomobject]@{
iteration = $iteration
scenario = $scenario
passed = $true
durationMs = $scenarioStopwatch.ElapsedMilliseconds
})
}
catch {
# 失败时保留驱动错误、fixture 输出和客户区截图,便于定位像素或状态问题。
@@ -448,6 +586,14 @@ try {
Assert-NativeCloseExitsProcess $fixture `
(Join-Path $sessionRoot "run-$iteration-modal-native-close") 'modal-close'
}
@{
fixture = $fixture
fixtureSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $fixture).Hash
inputPipelineExpectation = $InputPipelineExpectation
repeat = $Repeat
scenarios = @($scenarioTimings)
} | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 (Join-Path $sessionRoot 'run-summary.json')
}
finally {
[void][StellarXUiNative]::SetCursorPos($savedCursor.X, $savedCursor.Y)
+27 -8
View File
@@ -10,10 +10,6 @@
# 统一测试入口:负责构建指定矩阵,并把所有诊断结果集中到 TestResults。
$ErrorActionPreference = 'Stop'
# 暂时清空再恢复 PATH,避免 MSBuild/VSTest 被其他工具链中的同名程序劫持。
$savedPath = $env:Path
[Environment]::SetEnvironmentVariable('PATH', $null, [EnvironmentVariableTarget]::Process)
[Environment]::SetEnvironmentVariable('Path', $savedPath, [EnvironmentVariableTarget]::Process)
$root = Split-Path -Parent $PSScriptRoot
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
$vstest = if ($env:STELLARX_VSTEST) { $env:STELLARX_VSTEST } else { 'D:\vs2022\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe' }
@@ -23,12 +19,34 @@ New-Item -ItemType Directory -Force -Path $results | Out-Null
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
if (-not (Test-Path -LiteralPath $vstest)) { throw "VSTest not found: $vstest" }
$pathKeyCount = @(
[Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process).Keys |
Where-Object { [string]$_ -ieq 'Path' }
).Count
$lastMsBuildExitCode = 0
function Invoke-StellarXMSBuild([string[]]$arguments) {
if ($pathKeyCount -gt 1) {
# PowerShell 7 宿主可能同时注入 PATH/Path。MSBuild 本身能启动,但在创建
# CL.exe 时会因重复环境键抛 MSB6001。通过 cmd 只删除大写 PATH,保留
# 同值的 Path;该规整仅作用于本次构建子进程,不改调用者环境。
$quotedArguments = @($arguments | ForEach-Object { '"' + $_ + '"' })
$buildCommand = 'set "PATH=" & "' + $msbuild + '" ' + ($quotedArguments -join ' ')
& $env:ComSpec /d /c $buildCommand
}
else {
& $msbuild @arguments
}
$script:lastMsBuildExitCode = $LASTEXITCODE
}
function Build-Project([string]$project, [string]$config, [string]$arch, [bool]$asan = $false) {
# ASan 使用 Rebuild,确保运行库和插桩设置不会沿用上一种构建配置。
$asanValue = if ($asan) { 'true' } else { 'false' }
$target = if ($asan) { 'Rebuild' } else { 'Build' }
& $msbuild $project /m /t:$target /p:Configuration=$config /p:Platform=$arch /p:EnableAsan=$asanValue /v:minimal
if ($LASTEXITCODE -ne 0) { throw "Build failed: $project ($config|$arch)" }
Invoke-StellarXMSBuild @($project, '/m', "/t:$target", "/p:Configuration=$config",
"/p:Platform=$arch", "/p:EnableAsan=$asanValue", '/v:minimal')
if ($lastMsBuildExitCode -ne 0) { throw "Build failed: $project ($config|$arch)" }
}
function Run-Unit([string]$config, [string]$arch, [bool]$asan = $false) {
@@ -61,8 +79,9 @@ function Build-KeyScenarios {
# KEY1-KEY6 是应用现有主路径的编译/链接冒烟,不在这里模拟鼠标交互。
$project = Join-Path $root 'imGui-easyX.vcxproj'
foreach ($key in 1..6) {
& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:KeyScenario=$key /p:EnableAsan=false /v:minimal
if ($LASTEXITCODE -ne 0) { throw "KEY$key build failed" }
Invoke-StellarXMSBuild @($project, '/m', '/t:Build', '/p:Configuration=Debug',
'/p:Platform=x64', "/p:KeyScenario=$key", '/p:EnableAsan=false', '/v:minimal')
if ($lastMsBuildExitCode -ne 0) { throw "KEY$key build failed" }
}
}