feat(input): 完成阶段3键盘路由与焦点重绘收口
This commit is contained in:
+155
-9
@@ -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
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user