feat(diagnostic): 完成阶段 0B 原生键盘隔离消费探测

This commit is contained in:
Codex
2026-10-07 04:06:59 +08:00
parent e514a7dbb9
commit 0db94a7d41
7 changed files with 1701 additions and 73 deletions
+210 -4
View File
@@ -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
+576
View File
@@ -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' }
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -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
View File
@@ -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")
{ {