feat(diagnostic): 完成阶段 0B 原生键盘隔离消费探测
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$Path
|
[string]$Path
|
||||||
)
|
)
|
||||||
@@ -39,7 +39,183 @@ $textLeaks = @($records | Where-Object {
|
|||||||
$_.messageCategory -eq 'text' -and $_.wParamHex -ne $null
|
$_.messageCategory -eq 'text' -and $_.wParamHex -ne $null
|
||||||
})
|
})
|
||||||
$lost = @($records | Where-Object recordType -eq 'records_lost')
|
$lost = @($records | Where-Object recordType -eq 'records_lost')
|
||||||
|
$lostTotal = 0
|
||||||
|
foreach ($lostRecord in $lost) {
|
||||||
|
if ($null -ne $lostRecord.lostCount) {
|
||||||
|
$lostTotal += [int]$lostRecord.lostCount
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$lostTotal++
|
||||||
|
}
|
||||||
|
}
|
||||||
$attach = $records | Where-Object recordType -eq 'window_attach' | Select-Object -First 1
|
$attach = $records | Where-Object recordType -eq 'window_attach' | Select-Object -First 1
|
||||||
|
$closeObserved = @($records | Where-Object messageName -eq 'WM_CLOSE').Count -gt 0
|
||||||
|
$sessionEndObserved = @($records | Where-Object recordType -eq 'session_end').Count -gt 0
|
||||||
|
$experimentConfigs = @($records | Where-Object recordType -eq 'experiment_config')
|
||||||
|
if ($experimentConfigs.Count -gt 1) {
|
||||||
|
throw "More than one experiment_config record was found: $($experimentConfigs.Count)"
|
||||||
|
}
|
||||||
|
$experimentConfig = $experimentConfigs | Select-Object -First 1
|
||||||
|
$experimentTargetEnters = @()
|
||||||
|
$experimentTargetLeaves = @()
|
||||||
|
$experimentEnters = @()
|
||||||
|
$experimentLeaves = @()
|
||||||
|
$matchingEasyX = @()
|
||||||
|
$easyxVisibility = $null
|
||||||
|
$experimentValidity = 'not_configured'
|
||||||
|
$imePositionAttempts = @()
|
||||||
|
$imePositionSuccesses = @()
|
||||||
|
$imePositionResultRecords = @()
|
||||||
|
$imePositionOrphans = @()
|
||||||
|
$runtimeFailOpenEnters = @()
|
||||||
|
$runtimeFailOpenLeaves = @()
|
||||||
|
$runtimeFailOpenReason = $null
|
||||||
|
if ($experimentConfig) {
|
||||||
|
$schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique)
|
||||||
|
$experimentTargetEnters = @($records | Where-Object {
|
||||||
|
$_.recordType -eq 'wndproc_enter' -and
|
||||||
|
$_.experimentId -eq $experimentConfig.experimentId -and
|
||||||
|
$_.ruleId -eq $experimentConfig.ruleId
|
||||||
|
})
|
||||||
|
$targetCallIds = @($experimentTargetEnters | Select-Object -ExpandProperty wndProcCallId)
|
||||||
|
$experimentTargetLeaves = @($records | Where-Object {
|
||||||
|
$_.recordType -eq 'wndproc_leave' -and
|
||||||
|
$targetCallIds -contains $_.wndProcCallId
|
||||||
|
})
|
||||||
|
$runtimeFailOpenEnters = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true)
|
||||||
|
$runtimeFailOpenCallIds = @($runtimeFailOpenEnters | Select-Object -ExpandProperty wndProcCallId)
|
||||||
|
$runtimeFailOpenLeaves = @($experimentTargetLeaves | Where-Object {
|
||||||
|
$runtimeFailOpenCallIds -contains $_.wndProcCallId -and $_.failOpenApplied -eq $true
|
||||||
|
})
|
||||||
|
$runtimeFailOpenRecords = @($runtimeFailOpenEnters) + @($runtimeFailOpenLeaves)
|
||||||
|
$runtimeFailOpenReasons = @($runtimeFailOpenRecords |
|
||||||
|
Select-Object -ExpandProperty failOpenReason -Unique)
|
||||||
|
if ($runtimeFailOpenReasons.Count -eq 1) {
|
||||||
|
$runtimeFailOpenReason = [string]$runtimeFailOpenReasons[0]
|
||||||
|
}
|
||||||
|
$experimentEnters = @($experimentTargetEnters | Where-Object ruleMatched -eq $true)
|
||||||
|
$matchedCallIds = @($experimentEnters | Select-Object -ExpandProperty wndProcCallId)
|
||||||
|
$experimentLeaves = @($records | Where-Object {
|
||||||
|
$_.recordType -eq 'wndproc_leave' -and
|
||||||
|
$matchedCallIds -contains $_.wndProcCallId
|
||||||
|
})
|
||||||
|
$easyXMessageType = if ([string]$experimentConfig.targetMessage -match 'UP$') {
|
||||||
|
'WM_KEYUP'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'WM_KEYDOWN'
|
||||||
|
}
|
||||||
|
$targetScanSpecified = $null -ne $experimentConfig.targetScanCode
|
||||||
|
$matchingEasyX = @($records | Where-Object {
|
||||||
|
$_.recordType -eq 'easyx_key' -and
|
||||||
|
$_.easyxMessageType -eq $easyXMessageType -and
|
||||||
|
[int]$_.easyxVkCode -eq [int]$experimentConfig.targetVirtualKey -and
|
||||||
|
(-not $targetScanSpecified -or
|
||||||
|
[int]$_.easyxScanCode -eq [int]$experimentConfig.targetScanCode)
|
||||||
|
})
|
||||||
|
$imePositionResultRecords = @($records | Where-Object recordType -eq 'ime_position')
|
||||||
|
# 兼容本修复前把定位结果直接挂在 wndproc_enter 上的 schema v2 日志。
|
||||||
|
$legacyImePositionAttempts = @($records | Where-Object {
|
||||||
|
$_.recordType -eq 'wndproc_enter' -and $_.imePositionAttempted -eq $true
|
||||||
|
})
|
||||||
|
$imePositionAttempts = @($imePositionResultRecords) + @($legacyImePositionAttempts)
|
||||||
|
$imePositionSuccesses = @($imePositionAttempts | Where-Object {
|
||||||
|
$_.imePositionContextAvailable -eq $true -and
|
||||||
|
$_.compositionPositionSucceeded -eq $true -and
|
||||||
|
$_.candidatePositionSucceeded -eq $true
|
||||||
|
})
|
||||||
|
$sequenceLookup = @{}
|
||||||
|
foreach ($entry in $records) { $sequenceLookup[[string]$entry.sequence] = $true }
|
||||||
|
$imePositionOrphans = @($imePositionResultRecords | Where-Object {
|
||||||
|
$null -eq $_.parentSequence -or
|
||||||
|
-not $sequenceLookup.ContainsKey([string]$_.parentSequence) -or
|
||||||
|
[uint64]$_.parentSequence -ge [uint64]$_.sequence
|
||||||
|
})
|
||||||
|
$easyxVisibility = if ($matchingEasyX.Count -eq 0) {
|
||||||
|
'absent'
|
||||||
|
}
|
||||||
|
elseif ($matchingEasyX.Count -eq 1) {
|
||||||
|
'present_once'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'present_multiple'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($schemaVersions.Count -ne 1 -or [int]$schemaVersions[0] -ne 2) {
|
||||||
|
$experimentValidity = 'invalid_schema'
|
||||||
|
}
|
||||||
|
elseif (-not $sequenceStrict) {
|
||||||
|
$experimentValidity = 'inconclusive_sequence'
|
||||||
|
}
|
||||||
|
elseif ($textLeaks.Count -gt 0) {
|
||||||
|
$experimentValidity = 'inconclusive_text_leak'
|
||||||
|
}
|
||||||
|
elseif ($lostTotal -gt 0) {
|
||||||
|
$experimentValidity = 'inconclusive_capture_loss'
|
||||||
|
}
|
||||||
|
elseif ($imePositionOrphans.Count -gt 0) {
|
||||||
|
$experimentValidity = 'inconclusive_ime_position_link'
|
||||||
|
}
|
||||||
|
elseif (-not $closeObserved) {
|
||||||
|
$experimentValidity = 'inconclusive_close_missing'
|
||||||
|
}
|
||||||
|
elseif (-not $sessionEndObserved) {
|
||||||
|
$experimentValidity = 'inconclusive_session_end_missing'
|
||||||
|
}
|
||||||
|
elseif (-not $experimentConfig.configurationValid) {
|
||||||
|
$experimentValidity = 'invalid_configuration'
|
||||||
|
}
|
||||||
|
elseif (-not $experimentConfig.ruleEligible) {
|
||||||
|
$experimentValidity = 'ineligible_fail_open'
|
||||||
|
}
|
||||||
|
elseif ([string]$experimentConfig.inputSource -eq 'Manual' -and
|
||||||
|
(-not $experimentConfig.imePositionConfigured -or $imePositionSuccesses.Count -eq 0)) {
|
||||||
|
$experimentValidity = 'inconclusive_ime_position'
|
||||||
|
}
|
||||||
|
elseif ($experimentTargetEnters.Count -ne [int]$experimentConfig.maxMatches) {
|
||||||
|
$experimentValidity = 'unexpected_target_count'
|
||||||
|
}
|
||||||
|
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
|
||||||
|
$experimentEnters.Count -eq 0 -and
|
||||||
|
$runtimeFailOpenEnters.Count -eq [int]$experimentConfig.maxMatches -and
|
||||||
|
$runtimeFailOpenLeaves.Count -eq $runtimeFailOpenEnters.Count -and
|
||||||
|
$runtimeFailOpenReasons.Count -eq 1 -and
|
||||||
|
-not [string]::IsNullOrWhiteSpace($runtimeFailOpenReason) -and
|
||||||
|
@($runtimeFailOpenLeaves | Where-Object {
|
||||||
|
$_.actualAction -ne 'ObserveAndForward' -or $_.forwarded -ne $true
|
||||||
|
}).Count -eq 0) {
|
||||||
|
$experimentValidity = 'runtime_fail_open'
|
||||||
|
}
|
||||||
|
elseif ($experimentEnters.Count -ne [int]$experimentConfig.maxMatches) {
|
||||||
|
$experimentValidity = 'unexpected_match_count'
|
||||||
|
}
|
||||||
|
elseif ($experimentLeaves.Count -ne $experimentEnters.Count) {
|
||||||
|
$experimentValidity = 'missing_leave_record'
|
||||||
|
}
|
||||||
|
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
|
||||||
|
@($experimentLeaves | Where-Object actualAction -ne 'ObserveAndConsume').Count -gt 0) {
|
||||||
|
$experimentValidity = 'consume_action_mismatch'
|
||||||
|
}
|
||||||
|
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
|
||||||
|
@($experimentLeaves | Where-Object forwarded -eq $true).Count -gt 0) {
|
||||||
|
$experimentValidity = 'consume_was_forwarded'
|
||||||
|
}
|
||||||
|
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
|
||||||
|
@($experimentLeaves | Where-Object forwardResultHex -ne '0x0000000000000000').Count -gt 0) {
|
||||||
|
$experimentValidity = 'consume_result_nonzero'
|
||||||
|
}
|
||||||
|
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and
|
||||||
|
@($experimentLeaves | Where-Object actualAction -ne 'ObserveAndForward').Count -gt 0) {
|
||||||
|
$experimentValidity = 'forward_action_mismatch'
|
||||||
|
}
|
||||||
|
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and
|
||||||
|
@($experimentLeaves | Where-Object forwarded -ne $true).Count -gt 0) {
|
||||||
|
$experimentValidity = 'forward_was_not_forwarded'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$experimentValidity = 'valid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$correlationCounts = [ordered]@{}
|
$correlationCounts = [ordered]@{}
|
||||||
foreach ($entry in $echoSummaries) {
|
foreach ($entry in $echoSummaries) {
|
||||||
@@ -51,8 +227,10 @@ foreach ($entry in $echoSummaries) {
|
|||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
file = $file.FullName
|
file = $file.FullName
|
||||||
recordCount = $records.Count
|
recordCount = $records.Count
|
||||||
|
schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique)
|
||||||
sequenceStrict = $sequenceStrict
|
sequenceStrict = $sequenceStrict
|
||||||
recordsLost = $lost.Count
|
recordsLost = $lostTotal
|
||||||
|
lossRecordCount = $lost.Count
|
||||||
textRawWParamLeaks = $textLeaks.Count
|
textRawWParamLeaks = $textLeaks.Count
|
||||||
isWindowUnicode = $attach.isWindowUnicode
|
isWindowUnicode = $attach.isWindowUnicode
|
||||||
ansiCodePage = $attach.ansiCodePage
|
ansiCodePage = $attach.ansiCodePage
|
||||||
@@ -72,6 +250,34 @@ foreach ($entry in $echoSummaries) {
|
|||||||
successfulScalarCounts = @($successfulResults | Select-Object -ExpandProperty scalarCount)
|
successfulScalarCounts = @($successfulResults | Select-Object -ExpandProperty scalarCount)
|
||||||
digestPresentCount = @($successfulResults | Where-Object sessionDigest -ne $null).Count
|
digestPresentCount = @($successfulResults | Where-Object sessionDigest -ne $null).Count
|
||||||
correlationCounts = [pscustomobject]$correlationCounts
|
correlationCounts = [pscustomobject]$correlationCounts
|
||||||
closeObserved = @($records | Where-Object messageName -eq 'WM_CLOSE').Count -gt 0
|
closeObserved = $closeObserved
|
||||||
sessionEndObserved = @($records | Where-Object recordType -eq 'session_end').Count -gt 0
|
sessionEndObserved = $sessionEndObserved
|
||||||
|
experimentConfigured = $null -ne $experimentConfig
|
||||||
|
experimentId = if ($experimentConfig) { $experimentConfig.experimentId } else { $null }
|
||||||
|
ruleId = if ($experimentConfig) { $experimentConfig.ruleId } else { $null }
|
||||||
|
ruleEligible = if ($experimentConfig) { [bool]$experimentConfig.ruleEligible } else { $null }
|
||||||
|
experimentFailOpenReason = if ($experimentConfig) { $experimentConfig.failOpenReason } else { $null }
|
||||||
|
runtimeFailOpenReason = $runtimeFailOpenReason
|
||||||
|
requestedAction = if ($experimentConfig) { $experimentConfig.requestedAction } else { $null }
|
||||||
|
targetMessage = if ($experimentConfig) { $experimentConfig.targetMessage } else { $null }
|
||||||
|
targetVirtualKey = if ($experimentConfig) { $experimentConfig.targetVirtualKey } else { $null }
|
||||||
|
targetScanCode = if ($experimentConfig) { $experimentConfig.targetScanCode } else { $null }
|
||||||
|
baselineId = if ($experimentConfig) { $experimentConfig.baselineId } else { $null }
|
||||||
|
targetObservationCount = $experimentTargetEnters.Count
|
||||||
|
targetLeaveCount = $experimentTargetLeaves.Count
|
||||||
|
failOpenObservationCount = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true).Count
|
||||||
|
failOpenForwardedCount = @($experimentTargetLeaves | Where-Object {
|
||||||
|
$_.failOpenApplied -eq $true -and $_.forwarded -eq $true
|
||||||
|
}).Count
|
||||||
|
ruleMatchCount = $experimentEnters.Count
|
||||||
|
consumedMatchCount = @($experimentEnters | Where-Object actualAction -eq 'ObserveAndConsume').Count
|
||||||
|
forwardedMatchCount = @($experimentLeaves | Where-Object forwarded -eq $true).Count
|
||||||
|
matchingEasyXKeyCount = $matchingEasyX.Count
|
||||||
|
easyxVisibility = $easyxVisibility
|
||||||
|
imePositionAttemptCount = $imePositionAttempts.Count
|
||||||
|
imePositionSuccessCount = $imePositionSuccesses.Count
|
||||||
|
imePositionReady = $imePositionSuccesses.Count -gt 0
|
||||||
|
imePositionResultRecordCount = $imePositionResultRecords.Count
|
||||||
|
imePositionOrphanCount = $imePositionOrphans.Count
|
||||||
|
experimentValidity = $experimentValidity
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet('MicrosoftPinyin', 'Sogou')]
|
||||||
|
[string]$ImeName,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet('ObserveAndForward', 'ObserveAndConsume')]
|
||||||
|
[string]$Action,
|
||||||
|
[ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')]
|
||||||
|
[string]$Message = 'WM_KEYDOWN',
|
||||||
|
[ValidateRange(1, 255)]
|
||||||
|
[int]$VirtualKey = 229,
|
||||||
|
[ValidateRange(-1, 255)]
|
||||||
|
[int]$ScanCode = 1,
|
||||||
|
[string]$OperatorKeyLabel = 'Escape',
|
||||||
|
[ValidateSet('IdleOnly', 'ComposingOnly')]
|
||||||
|
[string]$ImeCondition = 'ComposingOnly',
|
||||||
|
[ValidateSet('none', 'ime_condition_not_met')]
|
||||||
|
[string]$ExpectedFailOpenReason = 'none',
|
||||||
|
[string]$BaselineSet,
|
||||||
|
[switch]$ConfirmImeIdentity
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$runner = Join-Path $PSScriptRoot 'run-input-consume-probe.ps1'
|
||||||
|
$arguments = @{
|
||||||
|
Action = $Action
|
||||||
|
Message = $Message
|
||||||
|
VirtualKey = $VirtualKey
|
||||||
|
ScanCode = $ScanCode
|
||||||
|
OperatorKeyLabel = $OperatorKeyLabel
|
||||||
|
InputSource = 'Manual'
|
||||||
|
ImeCondition = $ImeCondition
|
||||||
|
ImeName = $ImeName
|
||||||
|
ConfirmImeIdentity = [bool]$ConfirmImeIdentity
|
||||||
|
ExpectedFailOpenReason = $ExpectedFailOpenReason
|
||||||
|
}
|
||||||
|
if ($BaselineSet) {
|
||||||
|
$arguments.BaselineSet = $BaselineSet
|
||||||
|
}
|
||||||
|
|
||||||
|
& $runner @arguments
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
param(
|
||||||
|
[ValidateSet('Both', 'ObserveAndForward', 'ObserveAndConsume')]
|
||||||
|
[string]$Action = 'Both',
|
||||||
|
[ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')]
|
||||||
|
[string]$Message = 'WM_KEYDOWN',
|
||||||
|
[ValidateRange(1, 255)]
|
||||||
|
[int]$VirtualKey = 0x87,
|
||||||
|
[ValidateRange(-1, 255)]
|
||||||
|
[int]$ScanCode = -1,
|
||||||
|
[string]$OperatorKeyLabel,
|
||||||
|
[string]$BaselineSet,
|
||||||
|
[ValidateRange(1, 65535)]
|
||||||
|
[int]$ExpectedAnsiCodePage = 936,
|
||||||
|
[string]$RuleBaselineId,
|
||||||
|
[ValidateSet('none', 'baseline_id_mismatch', 'ime_condition_not_met')]
|
||||||
|
[string]$ExpectedFailOpenReason = 'none',
|
||||||
|
[ValidateSet('SendInput', 'Manual')]
|
||||||
|
[string]$InputSource = 'SendInput',
|
||||||
|
[ValidateSet('IdleOnly', 'ComposingOnly')]
|
||||||
|
[string]$ImeCondition = 'IdleOnly',
|
||||||
|
[ValidateSet('unspecified', 'MicrosoftPinyin', 'Sogou')]
|
||||||
|
[string]$ImeName = 'unspecified',
|
||||||
|
[switch]$ConfirmImeIdentity
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$project = Join-Path $root 'tests\StellarX.UiFixture.vcxproj'
|
||||||
|
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
|
||||||
|
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
|
||||||
|
$expectedBaselineId = 'sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5'
|
||||||
|
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
|
||||||
|
if ($ExpectedFailOpenReason -ne 'none' -and $Action -ne 'ObserveAndConsume') {
|
||||||
|
throw 'A fail-open negative run must request only ObserveAndConsume'
|
||||||
|
}
|
||||||
|
if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and -not $RuleBaselineId) {
|
||||||
|
throw 'A baseline mismatch run must pass -RuleBaselineId with an intentionally different value'
|
||||||
|
}
|
||||||
|
if ($InputSource -eq 'SendInput' -and $ImeCondition -ne 'IdleOnly') {
|
||||||
|
throw 'Automatic SendInput runs are limited to IdleOnly; Composition requires real manual IME input'
|
||||||
|
}
|
||||||
|
if ($InputSource -eq 'Manual' -and $Action -eq 'Both') {
|
||||||
|
throw 'Manual runs must select one action so each process contains exactly one operator exercise'
|
||||||
|
}
|
||||||
|
if ($InputSource -eq 'Manual' -and ($ImeName -eq 'unspecified' -or -not $ConfirmImeIdentity)) {
|
||||||
|
throw 'Manual runs require -ImeName and the explicit -ConfirmImeIdentity switch'
|
||||||
|
}
|
||||||
|
if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and $InputSource -ne 'SendInput') {
|
||||||
|
throw 'The baseline mismatch negative check uses the controlled SendInput path'
|
||||||
|
}
|
||||||
|
if ($ExpectedFailOpenReason -eq 'ime_condition_not_met' -and
|
||||||
|
($InputSource -ne 'Manual' -or $ImeCondition -ne 'ComposingOnly')) {
|
||||||
|
throw 'The IME condition runtime fail-open check requires Manual + ComposingOnly'
|
||||||
|
}
|
||||||
|
$effectiveScanCode = if ($ScanCode -lt 0) { 256 } else { $ScanCode }
|
||||||
|
$operatorKeyDisplay = if ([string]::IsNullOrWhiteSpace($OperatorKeyLabel)) {
|
||||||
|
"VK=$VirtualKey"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$OperatorKeyLabel.Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Sha256Text([string]$text) {
|
||||||
|
$sha = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
$bytes = [Text.Encoding]::UTF8.GetBytes($text)
|
||||||
|
return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$sha.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-BaselineSet([string]$requestedPath) {
|
||||||
|
if ($requestedPath) {
|
||||||
|
return (Resolve-Path -LiteralPath $requestedPath).Path
|
||||||
|
}
|
||||||
|
$candidates = @(Get-ChildItem -LiteralPath (Join-Path $root 'TestResults\VisualProbe') `
|
||||||
|
-Filter 'baseline-set-v1.json' -File -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending)
|
||||||
|
foreach ($candidate in $candidates) {
|
||||||
|
try {
|
||||||
|
$candidateValue = Get-Content -Raw -LiteralPath $candidate.FullName | ConvertFrom-Json
|
||||||
|
if ([string]$candidateValue.combinedBaselineId -eq $expectedBaselineId) {
|
||||||
|
return $candidate.FullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# 无效历史产物不参与当前基线选择,继续寻找下一个可解析候选。
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw 'No matching 0C baseline-set-v1.json was found; pass -BaselineSet explicitly'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-VerifiedBaseline([string]$path) {
|
||||||
|
$resolvedPath = (Resolve-Path -LiteralPath $path).Path
|
||||||
|
$sidecarPath = $resolvedPath + '.sha256'
|
||||||
|
if (-not (Test-Path -LiteralPath $sidecarPath)) { throw "Baseline sidecar missing: $sidecarPath" }
|
||||||
|
$actualBaselineHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedPath).Hash
|
||||||
|
$sidecarHash = ((Get-Content -Raw -LiteralPath $sidecarPath).Trim() -split '\s+')[0].ToUpperInvariant()
|
||||||
|
if ($actualBaselineHash -ne $sidecarHash) { throw 'Baseline-set SHA-256 does not match its sidecar' }
|
||||||
|
|
||||||
|
$baseline = Get-Content -Raw -LiteralPath $resolvedPath | ConvertFrom-Json
|
||||||
|
if ([int]$baseline.visualProbeBaselineSchemaVersion -ne 1) { throw 'Unsupported visual baseline schema' }
|
||||||
|
if ([string]$baseline.automatedStatus -ne 'pass') { throw '0C baseline automatedStatus is not pass' }
|
||||||
|
$directory = Split-Path -Parent $resolvedPath
|
||||||
|
$drawingPath = Join-Path $directory ([string]$baseline.drawingBaselineManifest)
|
||||||
|
$coordinatePath = Join-Path $directory ([string]$baseline.coordinateBaselineManifest)
|
||||||
|
if (-not (Test-Path -LiteralPath $drawingPath)) { throw "Drawing baseline missing: $drawingPath" }
|
||||||
|
if (-not (Test-Path -LiteralPath $coordinatePath)) { throw "Coordinate baseline missing: $coordinatePath" }
|
||||||
|
$drawingHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $drawingPath).Hash
|
||||||
|
$coordinateHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $coordinatePath).Hash
|
||||||
|
if ($drawingHash -ne [string]$baseline.drawingBaselineSha256) { throw 'Drawing baseline SHA-256 mismatch' }
|
||||||
|
if ($coordinateHash -ne [string]$baseline.coordinateBaselineSha256) { throw 'Coordinate baseline SHA-256 mismatch' }
|
||||||
|
$combined = 'sha256:' + (Get-Sha256Text "$drawingHash`:$coordinateHash")
|
||||||
|
if ($combined -ne [string]$baseline.combinedBaselineId) { throw 'Combined baseline ID mismatch' }
|
||||||
|
if ($combined -ne $expectedBaselineId) { throw "Unsupported 0C baseline ID: $combined" }
|
||||||
|
|
||||||
|
$coordinate = Get-Content -Raw -LiteralPath $coordinatePath | ConvertFrom-Json
|
||||||
|
$awarenessMap = @{ unaware = 0; system = 1; 'per-monitor' = 2; 'per-monitor-v2' = 2 }
|
||||||
|
$awarenessName = [string]$baseline.dpiAwareness
|
||||||
|
if (-not $awarenessMap.ContainsKey($awarenessName)) {
|
||||||
|
throw "Unsupported DPI awareness in baseline: $awarenessName"
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
path = $resolvedPath
|
||||||
|
sha256 = $actualBaselineHash
|
||||||
|
combinedBaselineId = $combined
|
||||||
|
dpiForWindow = [int]$baseline.dpiForWindow
|
||||||
|
dpiAwareness = [int]$awarenessMap[$awarenessName]
|
||||||
|
windowUnicode = [bool]$coordinate.environment.windowUnicode
|
||||||
|
drawingSha256 = $drawingHash
|
||||||
|
coordinateSha256 = $coordinateHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Windows PowerShell 5.1 能精确收口宿主注入的 PATH/Path 重复键;只影响当前 runner。
|
||||||
|
$savedPath = $env:Path
|
||||||
|
[Environment]::SetEnvironmentVariable('PATH', $null, [EnvironmentVariableTarget]::Process)
|
||||||
|
[Environment]::SetEnvironmentVariable('Path', $savedPath, [EnvironmentVariableTarget]::Process)
|
||||||
|
|
||||||
|
$baselineInfo = Read-VerifiedBaseline (Resolve-BaselineSet $BaselineSet)
|
||||||
|
& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 `
|
||||||
|
/p:StellarXInputProbe=true /v:minimal
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Input consume probe fixture build failed' }
|
||||||
|
if (-not (Test-Path -LiteralPath $fixture)) { throw "Fixture not found: $fixture" }
|
||||||
|
|
||||||
|
Add-Type @'
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class StellarXInputConsumeNative {
|
||||||
|
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; }
|
||||||
|
[StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||||
|
[StructLayout(LayoutKind.Sequential)] private struct MOUSEINPUT {
|
||||||
|
public int Dx; public int Dy; public uint MouseData; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
|
||||||
|
}
|
||||||
|
[StructLayout(LayoutKind.Sequential)] private struct KEYBDINPUT {
|
||||||
|
public ushort Vk; public ushort Scan; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
|
||||||
|
}
|
||||||
|
[StructLayout(LayoutKind.Explicit)] private struct INPUTUNION {
|
||||||
|
[FieldOffset(0)] public MOUSEINPUT Mouse;
|
||||||
|
[FieldOffset(0)] public KEYBDINPUT Keyboard;
|
||||||
|
}
|
||||||
|
[StructLayout(LayoutKind.Sequential)] private struct INPUT {
|
||||||
|
public uint Type; public INPUTUNION Data;
|
||||||
|
}
|
||||||
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] private static extern IntPtr FindWindowW(string className, string title);
|
||||||
|
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr h);
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr h);
|
||||||
|
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr h, int command);
|
||||||
|
[DllImport("user32.dll", SetLastError=true)] private static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
||||||
|
[DllImport("user32.dll", SetLastError=true)] private static extern bool GetWindowRect(IntPtr h, out RECT rect);
|
||||||
|
[DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT point);
|
||||||
|
[DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y);
|
||||||
|
[DllImport("user32.dll")] private static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo);
|
||||||
|
[DllImport("user32.dll", SetLastError=true)] private static extern bool AttachThreadInput(uint attach, uint attachTo, bool value);
|
||||||
|
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
|
||||||
|
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint message, IntPtr w, IntPtr l);
|
||||||
|
[DllImport("user32.dll", SetLastError=true)] private static extern uint GetWindowThreadProcessId(IntPtr h, out uint processId);
|
||||||
|
[DllImport("user32.dll", SetLastError=true)] private static extern uint SendInput(uint count, INPUT[] inputs, int size);
|
||||||
|
public static IntPtr FindWindowByTitle(string title) { return FindWindowW(null, title); }
|
||||||
|
public static uint GetWindowProcessId(IntPtr h) {
|
||||||
|
uint processId;
|
||||||
|
if (GetWindowThreadProcessId(h, out processId) == 0 || processId == 0) throw new Win32Exception();
|
||||||
|
return processId;
|
||||||
|
}
|
||||||
|
public static bool ActivateWindow(IntPtr h) {
|
||||||
|
uint ignored;
|
||||||
|
uint currentThread = GetCurrentThreadId();
|
||||||
|
uint targetThread = GetWindowThreadProcessId(h, out ignored);
|
||||||
|
IntPtr foreground = GetForegroundWindow();
|
||||||
|
uint foregroundThread = foreground == IntPtr.Zero ? 0 : GetWindowThreadProcessId(foreground, out ignored);
|
||||||
|
bool attachedForeground = foregroundThread != 0 && foregroundThread != currentThread &&
|
||||||
|
AttachThreadInput(currentThread, foregroundThread, true);
|
||||||
|
bool attachedTarget = targetThread != 0 && targetThread != currentThread && targetThread != foregroundThread &&
|
||||||
|
AttachThreadInput(currentThread, targetThread, true);
|
||||||
|
try {
|
||||||
|
ShowWindow(h, 9);
|
||||||
|
BringWindowToTop(h);
|
||||||
|
SetForegroundWindow(h);
|
||||||
|
return GetForegroundWindow() == h;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (attachedTarget) AttachThreadInput(currentThread, targetThread, false);
|
||||||
|
if (attachedForeground) AttachThreadInput(currentThread, foregroundThread, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static long ClickWindowForActivation(IntPtr h) {
|
||||||
|
RECT rect;
|
||||||
|
POINT original;
|
||||||
|
if (!GetWindowRect(h, out rect) || !GetCursorPos(out original)) throw new Win32Exception();
|
||||||
|
const uint noMoveNoSize = 0x0001 | 0x0002;
|
||||||
|
SetWindowPos(h, new IntPtr(-1), 0, 0, 0, 0, noMoveNoSize);
|
||||||
|
int x = rect.Left + Math.Max(16, (rect.Right - rect.Left) / 2);
|
||||||
|
int y = rect.Top + Math.Max(40, (rect.Bottom - rect.Top) / 2);
|
||||||
|
try {
|
||||||
|
if (!SetCursorPos(x, y)) throw new Win32Exception();
|
||||||
|
mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero);
|
||||||
|
mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero);
|
||||||
|
int outsideX = rect.Left > 48 ? rect.Left - 32 : rect.Right + 32;
|
||||||
|
int outsideY = rect.Top > 48 ? rect.Top - 32 : rect.Bottom + 32;
|
||||||
|
SetCursorPos(outsideX, outsideY);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
SetWindowPos(h, new IntPtr(-2), 0, 0, 0, 0, noMoveNoSize);
|
||||||
|
}
|
||||||
|
return ((long)original.X << 32) | (uint)original.Y;
|
||||||
|
}
|
||||||
|
public static void RestoreCursorPosition(long packed) {
|
||||||
|
SetCursorPos((int)(packed >> 32), (int)(packed & 0xffffffffL));
|
||||||
|
}
|
||||||
|
private static void SendKey(ushort virtualKey, bool up) {
|
||||||
|
var inputs = new INPUT[1];
|
||||||
|
inputs[0].Type = 1; inputs[0].Data.Keyboard.Vk = virtualKey;
|
||||||
|
if (up) inputs[0].Data.Keyboard.Flags = 0x0002;
|
||||||
|
if (SendInput(1, inputs, Marshal.SizeOf(typeof(INPUT))) != 1) throw new Win32Exception();
|
||||||
|
}
|
||||||
|
public static void SendKeyDown(ushort virtualKey) { SendKey(virtualKey, false); }
|
||||||
|
public static void SendKeyUp(ushort virtualKey) { SendKey(virtualKey, true); }
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
function Wait-Window([string]$title, [int]$timeoutMs = 15000) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||||||
|
$window = [StellarXInputConsumeNative]::FindWindowByTitle($title)
|
||||||
|
if ($window -ne [IntPtr]::Zero) {
|
||||||
|
$fixturePid = [StellarXInputConsumeNative]::GetWindowProcessId($window)
|
||||||
|
try {
|
||||||
|
$fixtureProcess = [Diagnostics.Process]::GetProcessById([int]$fixturePid)
|
||||||
|
return [pscustomobject]@{
|
||||||
|
window = $window
|
||||||
|
process = $fixtureProcess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# 窗口可能正处于销毁边界;继续等待同标题的有效新实例。
|
||||||
|
$fixtureProcess = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 50
|
||||||
|
}
|
||||||
|
throw "Window not found: $title"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-ProbeRun([string]$requestedAction, [string]$sessionRoot, [string]$experimentId) {
|
||||||
|
$actionSlug = if ($requestedAction -eq 'ObserveAndForward') { 'forward' } else { 'consume' }
|
||||||
|
$runDirectory = Join-Path $sessionRoot $actionSlug
|
||||||
|
$fixtureDirectory = Join-Path $runDirectory 'fixture'
|
||||||
|
New-Item -ItemType Directory -Force -Path $fixtureDirectory | Out-Null
|
||||||
|
$scanSlug = if ($ScanCode -lt 0) { 'scan-any' } else { "scan-$ScanCode" }
|
||||||
|
$ruleId = "$($Message.ToLowerInvariant())-vk$VirtualKey-$scanSlug-$($ImeCondition.ToLowerInvariant())-$actionSlug"
|
||||||
|
$windowUnicode = if ($baselineInfo.windowUnicode) { '1' } else { '0' }
|
||||||
|
$effectiveRuleBaselineId = if ($RuleBaselineId) { $RuleBaselineId } else { $baselineInfo.combinedBaselineId }
|
||||||
|
$launchParts = @(
|
||||||
|
'set "STELLARX_INPUT_PROBE=1"',
|
||||||
|
'set "STELLARX_INPUT_PROBE_0B=1"',
|
||||||
|
"set `"STELLARX_INPUT_PROBE_DIR=$runDirectory`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_EXPERIMENT_ID=$experimentId`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_RULE_ID=$ruleId`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_ACTION=$requestedAction`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_MESSAGE=$Message`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_VIRTUAL_KEY=$VirtualKey`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_SCAN_CODE=$effectiveScanCode`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_IME_CONDITION=$ImeCondition`"",
|
||||||
|
'set "STELLARX_INPUT_PROBE_0B_MAX_MATCHES=1"',
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_INPUT_SOURCE=$InputSource`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_BASELINE_ID=$effectiveRuleBaselineId`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_WINDOW_UNICODE=$windowUnicode`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_ANSI_CODE_PAGE=$ExpectedAnsiCodePage`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_FOR_WINDOW=$($baselineInfo.dpiForWindow)`"",
|
||||||
|
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_AWARENESS=$($baselineInfo.dpiAwareness)`""
|
||||||
|
)
|
||||||
|
if ($InputSource -eq 'Manual') {
|
||||||
|
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_IDENTITY_CONFIRMED=1"'
|
||||||
|
$launchParts += "set `"STELLARX_INPUT_PROBE_0B_IME_NAME=$ImeName`""
|
||||||
|
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_X=225"'
|
||||||
|
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_Y=282"'
|
||||||
|
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_LEFT=200"'
|
||||||
|
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_TOP=255"'
|
||||||
|
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_RIGHT=680"'
|
||||||
|
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_BOTTOM=284"'
|
||||||
|
}
|
||||||
|
$launchParts += ('"{0}" input-consume-probe "{1}"' -f $fixture, $fixtureDirectory)
|
||||||
|
$launchCommand = $launchParts -join ' & '
|
||||||
|
|
||||||
|
$title = 'StellarX.UI.input-consume-probe'
|
||||||
|
if ([StellarXInputConsumeNative]::FindWindowByTitle($title) -ne [IntPtr]::Zero) {
|
||||||
|
throw "A stale fixture window is already open: $title"
|
||||||
|
}
|
||||||
|
$launcherProcess = Start-Process -FilePath $env:ComSpec -ArgumentList @('/d', '/c', $launchCommand) -PassThru
|
||||||
|
$window = [IntPtr]::Zero
|
||||||
|
$fixtureProcess = $null
|
||||||
|
$savedCursorPosition = $null
|
||||||
|
$keyDownSent = $false
|
||||||
|
$keyUpSent = $false
|
||||||
|
try {
|
||||||
|
$windowState = Wait-Window $title
|
||||||
|
$window = $windowState.window
|
||||||
|
$fixtureProcess = $windowState.process
|
||||||
|
if ($InputSource -eq 'Manual') {
|
||||||
|
Write-Host "Manual 0B window ready: $ImeName / $ImeCondition / $requestedAction"
|
||||||
|
Write-Host 'Complete the printed checklist in the EasyX window, then close it.'
|
||||||
|
[void]$fixtureProcess.WaitForExit()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
$windowShell = New-Object -ComObject WScript.Shell
|
||||||
|
[void]$windowShell.AppActivate($fixtureProcess.Id)
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
|
||||||
|
[void][StellarXInputConsumeNative]::ActivateWindow($window)
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
}
|
||||||
|
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
|
||||||
|
$savedCursorPosition = [StellarXInputConsumeNative]::ClickWindowForActivation($window)
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
}
|
||||||
|
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
|
||||||
|
throw 'Fixture did not become the foreground window'
|
||||||
|
}
|
||||||
|
[StellarXInputConsumeNative]::SendKeyDown([uint16]$VirtualKey)
|
||||||
|
$keyDownSent = $true
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
[StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey)
|
||||||
|
$keyUpSent = $true
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
if (-not [StellarXInputConsumeNative]::PostMessage($window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)) {
|
||||||
|
throw 'Failed to post WM_CLOSE to the input consume fixture'
|
||||||
|
}
|
||||||
|
if (-not $fixtureProcess.WaitForExit(10000)) { throw 'Fixture did not exit after WM_CLOSE' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($keyDownSent -and -not $keyUpSent) {
|
||||||
|
try { [StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey) }
|
||||||
|
catch { Write-Verbose 'Failed to release synthetic key during cleanup' }
|
||||||
|
}
|
||||||
|
if ($fixtureProcess) { $fixtureProcess.Refresh() }
|
||||||
|
if ($fixtureProcess -and -not $fixtureProcess.HasExited -and $window -ne [IntPtr]::Zero) {
|
||||||
|
[void][StellarXInputConsumeNative]::PostMessage(
|
||||||
|
$window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
|
||||||
|
[void]$fixtureProcess.WaitForExit(3000)
|
||||||
|
$fixtureProcess.Refresh()
|
||||||
|
}
|
||||||
|
if ($fixtureProcess -and -not $fixtureProcess.HasExited) {
|
||||||
|
Stop-Process -Id $fixtureProcess.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
$launcherProcess.Refresh()
|
||||||
|
if (-not $launcherProcess.HasExited) {
|
||||||
|
[void]$launcherProcess.WaitForExit(3000)
|
||||||
|
$launcherProcess.Refresh()
|
||||||
|
}
|
||||||
|
if (-not $launcherProcess.HasExited) {
|
||||||
|
Stop-Process -Id $launcherProcess.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
if ($null -ne $savedCursorPosition) {
|
||||||
|
[StellarXInputConsumeNative]::RestoreCursorPosition([long]$savedCursorPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = & (Join-Path $PSScriptRoot 'analyze-input-probe.ps1') -Path $runDirectory
|
||||||
|
if ($ExpectedFailOpenReason -eq 'none') {
|
||||||
|
if ($summary.experimentValidity -ne 'valid') {
|
||||||
|
throw "Invalid $requestedAction experiment: $($summary.experimentValidity)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($ExpectedFailOpenReason -eq 'baseline_id_mismatch') {
|
||||||
|
$failOpenValid =
|
||||||
|
$summary.experimentValidity -eq 'ineligible_fail_open' -and
|
||||||
|
$summary.experimentFailOpenReason -eq $ExpectedFailOpenReason -and
|
||||||
|
$summary.targetObservationCount -eq 1 -and
|
||||||
|
$summary.targetLeaveCount -eq 1 -and
|
||||||
|
$summary.failOpenObservationCount -eq 1 -and
|
||||||
|
$summary.failOpenForwardedCount -eq 1 -and
|
||||||
|
$summary.ruleMatchCount -eq 0
|
||||||
|
if (-not $failOpenValid) {
|
||||||
|
throw "Invalid fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.experimentFailOpenReason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$failOpenValid =
|
||||||
|
$summary.experimentValidity -eq 'runtime_fail_open' -and
|
||||||
|
$summary.runtimeFailOpenReason -eq $ExpectedFailOpenReason -and
|
||||||
|
$summary.targetObservationCount -eq 1 -and
|
||||||
|
$summary.targetLeaveCount -eq 1 -and
|
||||||
|
$summary.failOpenObservationCount -eq 1 -and
|
||||||
|
$summary.failOpenForwardedCount -eq 1 -and
|
||||||
|
$summary.ruleMatchCount -eq 0 -and
|
||||||
|
$summary.consumedMatchCount -eq 0 -and
|
||||||
|
$summary.matchingEasyXKeyCount -eq 1
|
||||||
|
if (-not $failOpenValid) {
|
||||||
|
throw "Invalid runtime fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.runtimeFailOpenReason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$actualFailOpenReason = if ($summary.experimentFailOpenReason -and
|
||||||
|
$summary.experimentFailOpenReason -ne 'none') {
|
||||||
|
$summary.experimentFailOpenReason
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$summary.runtimeFailOpenReason
|
||||||
|
}
|
||||||
|
[pscustomobject][ordered]@{
|
||||||
|
action = $requestedAction
|
||||||
|
runDirectory = $runDirectory
|
||||||
|
file = $summary.file
|
||||||
|
fixtureProcessId = if ($fixtureProcess) { $fixtureProcess.Id } else { $null }
|
||||||
|
sequenceStrict = $summary.sequenceStrict
|
||||||
|
recordsLost = $summary.recordsLost
|
||||||
|
ruleMatchCount = $summary.ruleMatchCount
|
||||||
|
consumedMatchCount = $summary.consumedMatchCount
|
||||||
|
forwardedMatchCount = $summary.forwardedMatchCount
|
||||||
|
matchingEasyXKeyCount = $summary.matchingEasyXKeyCount
|
||||||
|
easyxVisibility = $summary.easyxVisibility
|
||||||
|
imePositionAttemptCount = $summary.imePositionAttemptCount
|
||||||
|
imePositionSuccessCount = $summary.imePositionSuccessCount
|
||||||
|
imePositionReady = $summary.imePositionReady
|
||||||
|
imePositionResultRecordCount = $summary.imePositionResultRecordCount
|
||||||
|
imePositionOrphanCount = $summary.imePositionOrphanCount
|
||||||
|
closeObserved = $summary.closeObserved
|
||||||
|
sessionEndObserved = $summary.sessionEndObserved
|
||||||
|
expectedFailOpenReason = $ExpectedFailOpenReason
|
||||||
|
actualFailOpenReason = $actualFailOpenReason
|
||||||
|
experimentFailOpenReason = $summary.experimentFailOpenReason
|
||||||
|
runtimeFailOpenReason = $summary.runtimeFailOpenReason
|
||||||
|
targetObservationCount = $summary.targetObservationCount
|
||||||
|
targetLeaveCount = $summary.targetLeaveCount
|
||||||
|
failOpenObservationCount = $summary.failOpenObservationCount
|
||||||
|
failOpenForwardedCount = $summary.failOpenForwardedCount
|
||||||
|
experimentValidity = $summary.experimentValidity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
|
||||||
|
$comparisonId = [Guid]::NewGuid().ToString()
|
||||||
|
$sessionRoot = Join-Path $root "TestResults\InputProbe\0B\$stamp-$comparisonId"
|
||||||
|
New-Item -ItemType Directory -Force -Path $sessionRoot | Out-Null
|
||||||
|
$actions = if ($Action -eq 'Both') {
|
||||||
|
@('ObserveAndForward', 'ObserveAndConsume')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
@($Action)
|
||||||
|
}
|
||||||
|
$results = @()
|
||||||
|
if ($InputSource -eq 'Manual') {
|
||||||
|
Write-Host "Manual IME consume probe: $ImeName"
|
||||||
|
$scanDescription = if ($ScanCode -lt 0) { 'any scan code' } else { "scanCode=$ScanCode" }
|
||||||
|
Write-Host " Rule: $Message VK=$VirtualKey, $scanDescription, $ImeCondition, $Action"
|
||||||
|
Write-Host ' 1. Confirm the selected input method really matches -ImeName, then click the EasyX window.'
|
||||||
|
if ($ImeCondition -eq 'ComposingOnly') {
|
||||||
|
Write-Host ' 2. Type only ceshi until the composition/candidate UI is visible.'
|
||||||
|
Write-Host " 3. Press $operatorKeyDisplay exactly once while composition is active; release it normally."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " 2. Before starting composition, press $operatorKeyDisplay exactly once; release it normally."
|
||||||
|
Write-Host ' 3. Then type only ceshi to confirm ordinary candidate behavior still works.'
|
||||||
|
}
|
||||||
|
Write-Host ' 4. Check candidate, commit/cancel behavior and position against the accepted 0C baseline.'
|
||||||
|
Write-Host ' 5. Close the EasyX window. The runner will validate privacy-safe structural evidence.'
|
||||||
|
}
|
||||||
|
foreach ($requestedAction in $actions) {
|
||||||
|
Write-Host "Running 0B $requestedAction $Message VK=$VirtualKey scan=$effectiveScanCode"
|
||||||
|
$results += Invoke-ProbeRun $requestedAction $sessionRoot $comparisonId
|
||||||
|
}
|
||||||
|
|
||||||
|
$forward = $results | Where-Object action -eq 'ObserveAndForward' | Select-Object -First 1
|
||||||
|
$consume = $results | Where-Object action -eq 'ObserveAndConsume' | Select-Object -First 1
|
||||||
|
$allRunsComplete = @($results | Where-Object {
|
||||||
|
-not $_.sequenceStrict -or
|
||||||
|
$_.recordsLost -gt 0 -or
|
||||||
|
-not $_.closeObserved -or
|
||||||
|
-not $_.sessionEndObserved
|
||||||
|
}).Count -eq 0
|
||||||
|
$comparisonStatus = if (-not $allRunsComplete) {
|
||||||
|
'inconclusive'
|
||||||
|
}
|
||||||
|
elseif ($ExpectedFailOpenReason -ne 'none' -and @($results | Where-Object {
|
||||||
|
$_.actualFailOpenReason -ne $ExpectedFailOpenReason -or
|
||||||
|
$_.failOpenObservationCount -ne 1 -or
|
||||||
|
$_.failOpenForwardedCount -ne 1
|
||||||
|
}).Count -gt 0) {
|
||||||
|
'inconclusive'
|
||||||
|
}
|
||||||
|
elseif ($Action -eq 'Both' -and (-not $forward -or -not $consume)) {
|
||||||
|
'inconclusive'
|
||||||
|
}
|
||||||
|
elseif ($Action -eq 'Both' -and $forward.matchingEasyXKeyCount -lt 1) {
|
||||||
|
'inconclusive'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'pass'
|
||||||
|
}
|
||||||
|
$observedRelation = if (-not $consume) {
|
||||||
|
'not_measured'
|
||||||
|
}
|
||||||
|
elseif ($consume.experimentValidity -eq 'runtime_fail_open') {
|
||||||
|
'easyx_visible_after_fail_open_forward'
|
||||||
|
}
|
||||||
|
elseif ($consume.easyxVisibility -eq 'absent') {
|
||||||
|
'easyx_suppressed_by_wndproc_consume'
|
||||||
|
}
|
||||||
|
elseif ($consume.easyxVisibility -eq 'present_once' -or $consume.easyxVisibility -eq 'present_multiple') {
|
||||||
|
'easyx_still_visible_after_wndproc_consume'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'inconclusive'
|
||||||
|
}
|
||||||
|
|
||||||
|
$comparison = [pscustomobject][ordered]@{
|
||||||
|
inputConsumeProbeSchemaVersion = 1
|
||||||
|
comparisonId = $comparisonId
|
||||||
|
createdAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||||
|
targetMessage = $Message
|
||||||
|
targetVirtualKey = $VirtualKey
|
||||||
|
targetScanCode = if ($ScanCode -lt 0) { $null } else { $ScanCode }
|
||||||
|
inputSource = $InputSource
|
||||||
|
imeCondition = $ImeCondition
|
||||||
|
imeName = if ($InputSource -eq 'Manual') { $ImeName } else { $null }
|
||||||
|
imeIdentityConfirmed = $InputSource -eq 'Manual' -and $ConfirmImeIdentity
|
||||||
|
expectedFailOpenReason = $ExpectedFailOpenReason
|
||||||
|
baselineSet = $baselineInfo.path
|
||||||
|
baselineSetSha256 = $baselineInfo.sha256
|
||||||
|
combinedBaselineId = $baselineInfo.combinedBaselineId
|
||||||
|
drawingBaselineSha256 = $baselineInfo.drawingSha256
|
||||||
|
coordinateBaselineSha256 = $baselineInfo.coordinateSha256
|
||||||
|
automatedStatus = $comparisonStatus
|
||||||
|
observedRelation = $observedRelation
|
||||||
|
finalVerdict = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') {
|
||||||
|
'MustForward'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'Inconclusive'
|
||||||
|
}
|
||||||
|
finalVerdictReason = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') {
|
||||||
|
'The candidate UI was visible, but the HWND exposed no reliable composing state; the consume request fail-opened to Forward.'
|
||||||
|
}
|
||||||
|
elseif ($InputSource -eq 'Manual') {
|
||||||
|
'Structural capture only; operator review of IME behavior and 0C position evidence is still required.'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'Core EX_KEY relation only; the required message and IME matrix is not complete.'
|
||||||
|
}
|
||||||
|
runs = @($results)
|
||||||
|
}
|
||||||
|
$comparisonPath = Join-Path $sessionRoot 'comparison-v1.json'
|
||||||
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
[IO.File]::WriteAllText($comparisonPath,
|
||||||
|
($comparison | ConvertTo-Json -Depth 20) + [Environment]::NewLine, $utf8Bom)
|
||||||
|
$comparisonHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $comparisonPath).Hash
|
||||||
|
$comparison | Format-List
|
||||||
|
Write-Host "Comparison: $comparisonPath"
|
||||||
|
Write-Host "Comparison SHA-256: $comparisonHash"
|
||||||
|
if ($comparisonStatus -ne 'pass') { throw '0B automatic comparison is inconclusive' }
|
||||||
+815
-58
File diff suppressed because it is too large
Load Diff
+18
-2
@@ -710,6 +710,19 @@ LRESULT CALLBACK Window::WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l)
|
|||||||
return DefWindowProc(h, m, w, l);
|
return DefWindowProc(h, m, w, l);
|
||||||
}
|
}
|
||||||
const auto probeCall = StellarX::Internal::inputProbeWndProcEnter(h, m, w, l);
|
const auto probeCall = StellarX::Internal::inputProbeWndProcEnter(h, m, w, l);
|
||||||
|
if (probeCall.observeAndConsume)
|
||||||
|
{
|
||||||
|
// 阶段 0B 的唯一消费出口:仅诊断构建、有效单规则和匹配基线下可达。
|
||||||
|
// 默认构建的 token 恒为 false,不改变生产窗口过程行为。
|
||||||
|
return StellarX::Internal::inputProbeWndProcLeave(probeCall, 0, false);
|
||||||
|
}
|
||||||
|
if (m == WM_CLOSE)
|
||||||
|
{
|
||||||
|
// EasyX 的旧窗口过程可能在 WM_CLOSE 内直接终止进程,不保证返回到 leave、
|
||||||
|
// WM_NCDESTROY 或宿主循环。这里是诊断会话最后一个可证明到达的耐久边界。
|
||||||
|
// 默认构建中 inputProbeDetach 为空操作,不改变生产关闭路径。
|
||||||
|
StellarX::Internal::inputProbeDetach(h);
|
||||||
|
}
|
||||||
|
|
||||||
// 关键点①:禁止系统擦背景,避免和我们自己的清屏/双缓冲打架造成闪烁
|
// 关键点①:禁止系统擦背景,避免和我们自己的清屏/双缓冲打架造成闪烁
|
||||||
if (m == WM_ERASEBKGND)
|
if (m == WM_ERASEBKGND)
|
||||||
@@ -922,7 +935,8 @@ int Window::runEventLoop()
|
|||||||
{
|
{
|
||||||
if (!hasLiveNativeWindow())
|
if (!hasLiveNativeWindow())
|
||||||
{
|
{
|
||||||
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd);
|
// 宿主循环已经确定退出;在 Window/EasyX 进一步改写句柄状态前结束探测会话。
|
||||||
|
StellarX::Internal::inputProbeDetach(hWnd);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,7 +1061,9 @@ int Window::runEventLoop()
|
|||||||
StellarX::Internal::inputProbeHostBatchEnd(hWnd, probeBatch, batch.processed, batch.stopped);
|
StellarX::Internal::inputProbeHostBatchEnd(hWnd, probeBatch, batch.processed, batch.stopped);
|
||||||
if (batch.stopped || !hasLiveNativeWindow())
|
if (batch.stopped || !hasLiveNativeWindow())
|
||||||
{
|
{
|
||||||
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd);
|
// batch.stopped(典型为 WM_CLOSE)是宿主线程最后一个可靠安全点。
|
||||||
|
// detach 会落下 window_detach/session_end;默认构建中该调用为空操作。
|
||||||
|
StellarX::Internal::inputProbeDetach(hWnd);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ namespace StellarX::Internal
|
|||||||
UINT message = 0;
|
UINT message = 0;
|
||||||
bool depthCounted = false;
|
bool depthCounted = false;
|
||||||
bool recorded = false;
|
bool recorded = false;
|
||||||
|
bool experimentTagged = false;
|
||||||
|
bool ruleMatched = false;
|
||||||
|
bool observeAndConsume = false;
|
||||||
|
bool failOpenApplied = false;
|
||||||
|
std::uint8_t failOpenReason = 0;
|
||||||
|
std::uint32_t matchOrdinal = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct InputProbeBatchToken
|
struct InputProbeBatchToken
|
||||||
|
|||||||
+35
-9
@@ -35,7 +35,10 @@ int main(int argc, char** argv)
|
|||||||
// fixture 每次只运行一个确定性场景,并用唯一标题让驱动脚本精确绑定 HWND。
|
// fixture 每次只运行一个确定性场景,并用唯一标题让驱动脚本精确绑定 HWND。
|
||||||
const std::string scenario = argc > 1 ? argv[1] : "geometry";
|
const std::string scenario = argc > 1 ? argv[1] : "geometry";
|
||||||
const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults";
|
const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults";
|
||||||
Window window(480, 320, 1, Background, "StellarX.UI." + scenario);
|
const bool consumeProbeScenario = scenario == "input-consume-probe";
|
||||||
|
const int windowWidth = consumeProbeScenario ? 720 : 480;
|
||||||
|
const int windowHeight = consumeProbeScenario ? 420 : 320;
|
||||||
|
Window window(windowWidth, windowHeight, 1, Background, "StellarX.UI." + scenario);
|
||||||
|
|
||||||
if (scenario == "ownership")
|
if (scenario == "ownership")
|
||||||
{
|
{
|
||||||
@@ -89,16 +92,25 @@ int main(int argc, char** argv)
|
|||||||
button->setTooltipText("stationary tip");
|
button->setTooltipText("stationary tip");
|
||||||
window.addControl(std::move(button));
|
window.addControl(std::move(button));
|
||||||
}
|
}
|
||||||
else if (scenario == "input-probe")
|
else if (scenario == "input-probe" || scenario == "input-consume-probe")
|
||||||
{
|
{
|
||||||
// 该场景只提供一个可聚焦的 EasyX 顶层窗口,不让控件消费或改写键盘/IME 消息。
|
// 该场景只提供一个可聚焦的 EasyX 顶层窗口,不让控件消费或改写键盘/IME 消息。
|
||||||
const std::array<std::string, 5> instructions{
|
const bool consumeProbe = scenario == "input-consume-probe";
|
||||||
"StellarX input probe - manual IME run",
|
const std::array<std::string, 5> instructions = consumeProbe
|
||||||
"1. Click this window, then switch to the requested IME.",
|
? std::array<std::string, 5>{
|
||||||
"2. Type only the agreed non-sensitive test strings.",
|
"StellarX stage 0B isolated consume probe",
|
||||||
"3. Cover commit, candidate select, cancel and language switch.",
|
"This window is driven by the bounded diagnostic runner.",
|
||||||
"4. Close the window after the checklist is complete."
|
"Only one message, key and action rule is active.",
|
||||||
};
|
"No control receives business keyboard routing.",
|
||||||
|
"The runner will close this window after capture."
|
||||||
|
}
|
||||||
|
: std::array<std::string, 5>{
|
||||||
|
"StellarX input probe - manual IME run",
|
||||||
|
"1. Click this window, then switch to the requested IME.",
|
||||||
|
"2. Type only the agreed non-sensitive test strings.",
|
||||||
|
"3. Cover commit, candidate select, cancel and language switch.",
|
||||||
|
"4. Close the window after the checklist is complete."
|
||||||
|
};
|
||||||
int y = 30;
|
int y = 30;
|
||||||
for (const std::string& line : instructions)
|
for (const std::string& line : instructions)
|
||||||
{
|
{
|
||||||
@@ -107,6 +119,20 @@ int main(int argc, char** argv)
|
|||||||
window.addControl(std::move(label));
|
window.addControl(std::move(label));
|
||||||
y += 42;
|
y += 42;
|
||||||
}
|
}
|
||||||
|
if (consumeProbe)
|
||||||
|
{
|
||||||
|
// 与 runner 传给 IMM32 的固定客户区坐标完全一致:行区 (200,255)-(680,284),
|
||||||
|
// caret bottom 为 (225,282)。仅用于人工确认候选框没有落回桌面边缘。
|
||||||
|
auto anchorLabel = std::make_unique<Label>(20, 260, "IME anchor:", WHITE, Background);
|
||||||
|
anchorLabel->setTextdisap(true);
|
||||||
|
window.addControl(std::move(anchorLabel));
|
||||||
|
auto lineRegion = std::make_unique<Canvas>(200, 255, 480, 29);
|
||||||
|
lineRegion->setCanvasBkColor(RGB(45, 70, 95));
|
||||||
|
window.addControl(std::move(lineRegion));
|
||||||
|
auto caret = std::make_unique<Canvas>(224, 258, 2, 24);
|
||||||
|
caret->setCanvasBkColor(HoverColor);
|
||||||
|
window.addControl(std::move(caret));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (scenario == "native-close")
|
else if (scenario == "native-close")
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user