diff --git a/imGui-easyX.sln b/imGui-easyX.sln index a42e348..3297a53 100644 --- a/imGui-easyX.sln +++ b/imGui-easyX.sln @@ -13,6 +13,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "StellarX.UiFixture", "tests EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EasyX.ThreadProbe", "tests\EasyX.ThreadProbe.vcxproj", "{6D338118-A536-4E4F-A4F5-3C73C5CA107A}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EasyX.VisualProbe", "tests\EasyX.VisualProbe.vcxproj", "{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -61,6 +63,14 @@ Global {6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x64.Build.0 = Release|x64 {6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.ActiveCfg = Release|Win32 {6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.Build.0 = Release|Win32 + {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x64.ActiveCfg = Debug|x64 + {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x64.Build.0 = Debug|x64 + {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x86.ActiveCfg = Debug|Win32 + {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x86.Build.0 = Debug|Win32 + {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x64.ActiveCfg = Release|x64 + {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x64.Build.0 = Release|x64 + {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x86.ActiveCfg = Release|Win32 + {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x86.Build.0 = Release|Win32 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/scripts/analyze-visual-probe.ps1 b/scripts/analyze-visual-probe.ps1 new file mode 100644 index 0000000..976de5d --- /dev/null +++ b/scripts/analyze-visual-probe.ps1 @@ -0,0 +1,410 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$SessionId, + [Parameter(Mandatory = $true)] + [string]$StartedAtUtc, + [int]$ProbeExitCode = 0 +) + +$ErrorActionPreference = 'Stop' + +function Get-FailureCategory { + param([object]$Case) + if (-not $Case.evidenceComplete) { return 'evidence_missing' } + if (-not $Case.targetChanged) { return 'surface_unreachable' } + if (-not $Case.nonTargetUnchanged) { return 'surface_cross_write' } + if (-not $Case.gdiStateRestored) { return 'gdi_state_not_restored' } + if (-not $Case.wideContentPreservedAfterEasyXFollowUp) { + return 'measurement_relation_invalid' + } + return $null +} + +function New-WideTextCases { + param([object]$Raw, [object[]]$EvidenceFiles) + $items = New-Object System.Collections.Generic.List[object] + foreach ($case in @($Raw.result.cases)) { + $caseFailure = Get-FailureCategory -Case $case + $caseEvidence = @($EvidenceFiles | Where-Object { + $_.path -like "evidence/$($case.testCaseId)_*" + }) + $phases = @('after_draw') + if ($case.batchState -eq 'inside') { $phases += @('after_flush', 'after_end') } + foreach ($text in @($case.textTests)) { + $textFailure = $caseFailure + if (-not $textFailure -and (-not $text.isValidUtf16 -or + -not $text.getTextExtentPoint32W -or -not $text.textOutW -or + [int]$text.drawTextWHeight -le 0)) { + $textFailure = 'measurement_relation_invalid' + } + foreach ($phase in $phases) { + $fingerprint = switch ($phase) { + 'after_draw' { $case.observations.afterDraw; break } + 'after_flush' { $case.observations.afterFlush; break } + 'after_end' { $case.observations.afterEnd; break } + } + $status = if ($textFailure) { 'fail' } else { 'pass' } + $items.Add([pscustomobject][ordered]@{ + testCaseId = "$($case.testCaseId)/$($text.testStringId)/$phase" + testStringId = $text.testStringId + codePoints = @($text.codePoints) + utf16Units = @($text.utf16Units) + surfaceId = $case.surfaceId + batchState = $case.batchState + observationPhase = $phase + hdc = $fingerprint.hdc + hbitmap = $fingerprint.hbitmap + hfont = $fingerprint.hfont + bitmapWidth = $fingerprint.bitmapWidth + bitmapHeight = $fingerprint.bitmapHeight + planes = $fingerprint.planes + bitsPerPixel = $fingerprint.bitsPerPixel + stride = $fingerprint.stride + dibSection = $fingerprint.dibSection + logFont = $fingerprint.logFont + gdiStateBefore = $case.before.gdiState + gdiStateAfter = $fingerprint.gdiState + apiResults = [pscustomobject][ordered]@{ + getTextExtentPoint32W = $text.getTextExtentPoint32W + textOutW = $text.textOutW + drawTextWHeight = $text.drawTextWHeight + } + logicalExtent = $text.logicalExtent + textOutInkBounds = $text.textOutInkBounds + drawTextInkBounds = $text.drawTextInkBounds + targetPixelsBefore = $case.before.pixelDigest + targetPixelsAfter = $fingerprint.pixelDigest + controlPixelsBefore = $case.nonTargetBefore.pixelDigest + controlPixelsAfter = $case.nonTargetAfterDraw.pixelDigest + easyXFollowUp = [pscustomobject][ordered]@{ + wideContentPreserved = $case.wideContentPreservedAfterEasyXFollowUp + surfaceFingerprint = $case.observations.afterEasyXFollowUp + } + automatedStatus = $status + manualStatus = 'not_reviewed' + failureCategory = $textFailure + evidenceFiles = $caseEvidence + }) + } + } + } + return $items.ToArray() +} + +function New-DpiCoordinateCases { + param([object]$Raw, [object[]]$EvidenceFiles) + $failure = $null + if ($EvidenceFiles.Count -lt @($Raw.evidenceFiles).Count) { $failure = 'evidence_missing' } + elseif (-not $Raw.result.gdiStateRestored) { $failure = 'gdi_state_not_restored' } + elseif (-not $Raw.result.roundTripsExact) { $failure = 'coordinate_transform_invalid' } + elseif (-not $Raw.result.gdiExtentSuccess -or -not $Raw.result.gdiTextSuccess) { + $failure = 'measurement_relation_invalid' + } + $status = if ($failure) { 'fail' } else { 'pass' } + $decision = if ($Raw.result.implicitScalingObserved) { + 'requires_explicit_transform_validation' + } + else { + 'direct_hwnd_relative_coordinates_supported_at_observed_dpi' + } + return @([pscustomobject][ordered]@{ + testCaseId = 'dpi-coordinate-current-environment' + surfaceId = 'main' + batchState = $Raw.result.batchState + observationPhase = 'after_end' + dpiAwareness = [pscustomobject][ordered]@{ + process = $Raw.environment.processAwareness + thread = $Raw.environment.threadAwareness + window = $Raw.environment.windowAwareness + } + windowDpi = $Raw.environment.dpiForWindow + clientRect = $Raw.environment.clientRect + windowScreenRect = $Raw.environment.windowRect + surfaceSize = [pscustomobject]@{ width = 960; height = 720 } + surfaceFingerprint = $Raw.result.surfaceFingerprintAfterDraw + surfaceBounds = $Raw.result.surfaceBounds + clientBounds = $Raw.result.clientBounds + coordinateRoundTrips = @($Raw.result.coordinateRoundTrips) + surfaceMarkerSizesAgree = $Raw.result.surfaceMarkerSizesAgree + clientMatchesSurface = $Raw.result.clientMatchesSurface + implicitScalingObserved = $Raw.result.implicitScalingObserved + coordinateDecision = $decision + automatedStatus = $status + manualStatus = 'not_reviewed' + failureCategory = $failure + evidenceFiles = $EvidenceFiles + }) +} + +function New-ImePositionCases { + param([object]$Raw, [object[]]$EvidenceFiles) + $items = New-Object System.Collections.Generic.List[object] + foreach ($anchor in @($Raw.result.anchors)) { + $events = @($Raw.result.imeEvents | Where-Object { + $_.syntheticAnchorId -eq $anchor.syntheticAnchorId -and $_.apiAttempted + }) + $successful = @($events | Where-Object { + $_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult + }) + $preconfigureEvents = @($events | Where-Object { + $_.positionTrigger -eq 'anchor_change' -or $_.stage -eq 'anchor_preconfigure' + }) + $successfulPreconfigure = @($preconfigureEvents | Where-Object { + $_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult + }) + $lifecycleEvents = @($events | Where-Object { + $_.positionTrigger -eq 'ime_lifecycle' -or + $_.stage -eq 'ime_start' -or $_.stage -eq 'ime_composition' + }) + $successfulLifecycle = @($lifecycleEvents | Where-Object { + $_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult + }) + $latest = $events | Select-Object -Last 1 + $failure = if ($successful.Count -gt 0) { $null } else { 'ime_position_invalid' } + $status = if ($failure) { 'fail' } else { 'pass' } + $anchorEvidence = @($EvidenceFiles | Where-Object { + $_.path -like "evidence/$($anchor.syntheticAnchorId)_*" -or + $_.path -like "manual-evidence/$($anchor.syntheticAnchorId)_*" + }) + $items.Add([pscustomobject][ordered]@{ + testCaseId = "ime-position/$($anchor.syntheticAnchorId)" + syntheticAnchorId = $anchor.syntheticAnchorId + anchorCoordinates = [pscustomobject][ordered]@{ + controlOrigin = $anchor.controlOrigin + controlLocalCaretTopLeft = $anchor.controlLocalCaretTopLeft + controlLocalCaretBottom = $anchor.controlLocalCaretBottom + controlLocalLineRect = $anchor.controlLocalLineRect + easyxSurfaceCaretTopLeft = $anchor.easyxSurfaceCaretTopLeft + easyxSurfaceCaretBottom = $anchor.easyxSurfaceCaretBottom + easyxSurfaceLineRect = $anchor.easyxSurfaceLineRect + targetHwndRelativePoint = $anchor.targetHwndRelativePoint + diagnosticScreenPoint = if ($latest) { $latest.diagnosticScreenPoint } else { $null } + } + compositionForm = if ($latest) { $latest.compositionForm } else { $null } + candidateForm = if ($latest) { $latest.candidateForm } else { $null } + imeId = if ($latest) { $latest.imeDescriptionUtf16 } else { @() } + imeFileName = if ($latest) { $latest.imeFileNameUtf16 } else { @() } + hkl = if ($latest) { $latest.hkl } else { $null } + apiAttemptCount = $events.Count + successfulApiAttemptCount = $successful.Count + preconfigureApiAttemptCount = $preconfigureEvents.Count + successfulPreconfigureApiAttemptCount = $successfulPreconfigure.Count + lifecycleApiAttemptCount = $lifecycleEvents.Count + successfulLifecycleApiAttemptCount = $successfulLifecycle.Count + positionDisposition = 'not_observable' + automatedStatus = $status + manualStatus = 'not_reviewed' + failureCategory = $failure + evidenceFiles = $anchorEvidence + }) + } + return $items.ToArray() +} + +$resolved = Resolve-Path -LiteralPath $Path +$item = Get-Item -LiteralPath $resolved +$directory = if ($item.PSIsContainer) { $item.FullName } else { $item.DirectoryName } +$rawPath = if ($item.PSIsContainer) { Join-Path $directory 'raw-result.json' } else { $item.FullName } +if (-not (Test-Path -LiteralPath $rawPath)) { throw "Raw result not found: $rawPath" } + +$raw = Get-Content -Raw -LiteralPath $rawPath | ConvertFrom-Json +if ([int]$raw.rawSchemaVersion -ne 1) { throw "Unsupported raw schema: $($raw.rawSchemaVersion)" } +if ($raw.mode -notin @('wide-text', 'dpi-coordinate', 'ime-position')) { + throw "Unsupported probe mode: $($raw.mode)" +} + +$evidence = New-Object System.Collections.Generic.List[object] +foreach ($entry in @($raw.evidenceFiles)) { + $relativePath = [string]$entry.path + $nativeRelative = $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar) + $fullPath = Join-Path $directory $nativeRelative + if (-not (Test-Path -LiteralPath $fullPath)) { continue } + $file = Get-Item -LiteralPath $fullPath + $evidence.Add([pscustomobject][ordered]@{ + path = $relativePath + purpose = $entry.purpose + captureBackend = $entry.captureBackend + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash + byteLength = $file.Length + crop = $null + }) +} +$rawEvidencePresent = $evidence.Count +$manualEvidenceDirectory = Join-Path $directory 'manual-evidence' +if (Test-Path -LiteralPath $manualEvidenceDirectory) { + Get-ChildItem -LiteralPath $manualEvidenceDirectory -Filter '*.png' -File | ForEach-Object { + $evidence.Add([pscustomobject][ordered]@{ + path = 'manual-evidence/' + $_.Name + purpose = 'manually cropped IME position evidence' + captureBackend = 'manual_crop' + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash + byteLength = $_.Length + crop = 'user_supplied' + }) + } +} + +$evidenceArray = $evidence.ToArray() +$testCases = if ($raw.mode -eq 'wide-text') { + New-WideTextCases -Raw $raw -EvidenceFiles $evidenceArray +} +elseif ($raw.mode -eq 'dpi-coordinate') { + New-DpiCoordinateCases -Raw $raw -EvidenceFiles $evidenceArray +} +else { + New-ImePositionCases -Raw $raw -EvidenceFiles $evidenceArray +} + +$overallManualStatus = 'not_reviewed' +$sourceManualReview = $null +$manualReviewSummary = $null +$manualReviewPath = Join-Path $directory 'manual-review-v1.json' +if (Test-Path -LiteralPath $manualReviewPath) { + $manualReview = Get-Content -Raw -LiteralPath $manualReviewPath | ConvertFrom-Json + if ([int]$manualReview.manualReviewSchemaVersion -ne 1) { + throw "Unsupported manual review schema: $($manualReview.manualReviewSchemaVersion)" + } + if ([string]$manualReview.mode -ne [string]$raw.mode) { + throw "Manual review mode does not match raw result: $($manualReview.mode)" + } + $allowedManualStatuses = @('pass', 'fail', 'inconclusive', 'not_reviewed', 'not_available') + $allowedPositionDispositions = @('as_requested', 'system_adjusted', 'not_observable') + $reviewedIds = New-Object System.Collections.Generic.List[string] + foreach ($reviewCase in @($manualReview.testCases)) { + $testCaseId = [string]$reviewCase.testCaseId + if ($reviewedIds.Contains($testCaseId)) { throw "Duplicate manual review case: $testCaseId" } + $reviewedIds.Add($testCaseId) + $matchingCases = @($testCases | Where-Object testCaseId -eq $testCaseId) + if ($matchingCases.Count -ne 1) { throw "Unknown manual review case: $testCaseId" } + $manualStatus = [string]$reviewCase.manualStatus + $positionDisposition = [string]$reviewCase.positionDisposition + if ($manualStatus -notin $allowedManualStatuses) { + throw "Unsupported manual status for ${testCaseId}: $manualStatus" + } + if ($positionDisposition -notin $allowedPositionDispositions) { + throw "Unsupported position disposition for ${testCaseId}: $positionDisposition" + } + $targetCase = $matchingCases[0] + if ($manualStatus -eq 'pass' -and $targetCase.automatedStatus -ne 'pass') { + throw "Manual pass cannot override automated failure: $testCaseId" + } + $reviewEvidence = @($reviewCase.evidenceFiles) + if ($manualStatus -eq 'pass' -and $reviewEvidence.Count -eq 0) { + throw "Manual pass requires explicit evidence files: $testCaseId" + } + foreach ($relativePath in $reviewEvidence) { + $relative = [string]$relativePath + if ($relative -notlike 'manual-evidence/*') { + throw "Manual review evidence must be under manual-evidence: $relative" + } + $matchingEvidence = @($targetCase.evidenceFiles | Where-Object path -eq $relative) + if ($matchingEvidence.Count -ne 1) { + throw "Manual review evidence is missing or belongs to another case: $relative" + } + } + if ($manualStatus -eq 'pass' -and $positionDisposition -eq 'not_observable') { + throw "Manual pass requires an observable position disposition: $testCaseId" + } + $targetCase.manualStatus = $manualStatus + $targetCase.positionDisposition = $positionDisposition + $targetCase | Add-Member -NotePropertyName manualReviewNotes ` + -NotePropertyValue ([string]$reviewCase.notes) -Force + } + if ($reviewedIds.Count -ne @($testCases).Count) { + throw 'Manual review must cover every manifest test case' + } + $caseManualStatuses = @($testCases | ForEach-Object { $_.manualStatus }) + $derivedManualStatus = if ($caseManualStatuses -contains 'fail') { + 'fail' + } + elseif ($caseManualStatuses -contains 'inconclusive') { + 'inconclusive' + } + elseif ($caseManualStatuses -contains 'not_reviewed') { + 'not_reviewed' + } + elseif ($caseManualStatuses -contains 'not_available') { + if (@($caseManualStatuses | Where-Object { $_ -ne 'not_available' }).Count -eq 0) { + 'not_available' + } + else { + 'inconclusive' + } + } + else { + 'pass' + } + if ([string]$manualReview.overallStatus -ne $derivedManualStatus) { + throw "Manual review overall status mismatch: expected $derivedManualStatus" + } + $overallManualStatus = $derivedManualStatus + $sourceManualReview = [pscustomobject][ordered]@{ + path = 'manual-review-v1.json' + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $manualReviewPath).Hash + } + $manualReviewSummary = [pscustomobject][ordered]@{ + reviewedAtUtc = $manualReview.reviewedAtUtc + reviewer = $manualReview.reviewer + expectedImeName = $manualReview.expectedImeName + actualImeName = $manualReview.actualImeName + identitySource = $manualReview.identitySource + notes = $manualReview.notes + } +} + +$failedCases = @($testCases | Where-Object automatedStatus -eq 'fail') +$allEvidencePresent = $rawEvidencePresent -eq @($raw.evidenceFiles).Count +$overallFailure = $null +if (-not $allEvidencePresent) { $overallFailure = 'evidence_missing' } +elseif ($failedCases.Count -gt 0) { $overallFailure = $failedCases[0].failureCategory } +elseif (-not $raw.completed -or $ProbeExitCode -ne 0) { $overallFailure = 'inconclusive_probe_exit' } +$overallStatus = if ($overallFailure) { 'fail' } else { 'pass' } + +$manifest = [pscustomobject][ordered]@{ + visualProbeSchemaVersion = 1 + sessionId = $SessionId + startedAtUtc = $StartedAtUtc + completedAtUtc = [DateTime]::UtcNow.ToString('o') + mode = $raw.mode + processId = $raw.environment.processId + threadId = $raw.environment.threadId + osBuild = $raw.environment.osBuild + easyxVersion = $raw.environment.easyxVersion + probeExecutableExitCode = $ProbeExitCode + dpiConfiguration = $raw.dpiConfiguration + environment = $raw.environment + automatedStatus = $overallStatus + manualStatus = $overallManualStatus + failureCategory = $overallFailure + sourceRawResult = [pscustomobject][ordered]@{ + path = 'raw-result.json' + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $rawPath).Hash + } + sourceManualReview = $sourceManualReview + manualReview = $manualReviewSummary + evidenceFiles = $evidenceArray + testCases = @($testCases) +} + +$manifestPath = Join-Path $directory 'manifest-v1.json' +$manifestJson = $manifest | ConvertTo-Json -Depth 100 +$utf8Bom = New-Object System.Text.UTF8Encoding($true) +[IO.File]::WriteAllText($manifestPath, $manifestJson + [Environment]::NewLine, $utf8Bom) +$manifestHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $manifestPath).Hash +$sidecarPath = $manifestPath + '.sha256' +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[IO.File]::WriteAllText($sidecarPath, "$manifestHash *manifest-v1.json`r`n", $utf8NoBom) + +[pscustomobject][ordered]@{ + mode = $raw.mode + manifestPath = $manifestPath + manifestSha256 = $manifestHash + automatedStatus = $overallStatus + manualStatus = $overallManualStatus + testCaseCount = @($testCases).Count + evidenceCount = $evidence.Count + dpiForWindow = $raw.environment.dpiForWindow + windowAwareness = $raw.environment.windowAwareness +} diff --git a/scripts/run-visual-probe.ps1 b/scripts/run-visual-probe.ps1 new file mode 100644 index 0000000..ae2968e --- /dev/null +++ b/scripts/run-visual-probe.ps1 @@ -0,0 +1,115 @@ +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Debug', + [ValidateSet('x64', 'Win32')] + [string]$Platform = 'x64', + [ValidateSet('All', 'wide-text', 'dpi-coordinate', 'ime-position')] + [string]$Mode = 'All', + [ValidateSet('inherit', 'unaware', 'system', 'per-monitor', 'per-monitor-v2')] + [string]$DpiAwareness = 'inherit', + [ValidateSet('unspecified', 'MicrosoftPinyin', 'Sogou')] + [string]$ImeName = 'unspecified' +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' } +$project = Join-Path $root 'tests\EasyX.VisualProbe.vcxproj' +$probe = Join-Path $root "build\$Platform\$Configuration\EasyX.VisualProbe.exe" +if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" } + +$pathKeyCount = @( + [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process).Keys | + Where-Object { [string]$_ -ieq 'Path' } +).Count +if ($pathKeyCount -gt 1) { + $buildCommand = 'set "PATH=" & "' + $msbuild + '" "' + $project + + '" /m /t:Build /p:Configuration=' + $Configuration + ' /p:Platform=' + $Platform + ' /v:minimal' + & $env:ComSpec /d /c $buildCommand +} +else { + & $msbuild $project /m /t:Build /p:Configuration=$Configuration /p:Platform=$Platform /v:minimal +} +if ($LASTEXITCODE -ne 0) { throw 'Visual probe build failed' } +if (-not (Test-Path -LiteralPath $probe)) { throw "Visual probe executable not found: $probe" } + +$sessionId = [guid]::NewGuid().ToString() +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' +$sessionRoot = Join-Path $root "TestResults\VisualProbe\$stamp-$sessionId" +New-Item -ItemType Directory -Force -Path $sessionRoot | Out-Null +$modes = if ($Mode -eq 'All') { @('wide-text', 'dpi-coordinate') } else { @($Mode) } +$summaries = New-Object System.Collections.Generic.List[object] +$probeFailed = $false + +foreach ($probeMode in $modes) { + $startedAtUtc = [DateTime]::UtcNow.ToString('o') + $runDirectory = Join-Path $sessionRoot $probeMode + New-Item -ItemType Directory -Force -Path $runDirectory | Out-Null + if ($probeMode -eq 'ime-position') { + $manualEvidence = Join-Path $runDirectory 'manual-evidence' + New-Item -ItemType Directory -Force -Path $manualEvidence | Out-Null + Write-Host "Manual IME position probe: $ImeName" + Write-Host ' 1. Select the requested IME and click the EasyX window.' + Write-Host ' 2. Press F1, type ceshi, inspect the candidate position, then press Escape.' + Write-Host ' 3. Repeat with F2 and F3. Use only the fixed non-sensitive input ceshi.' + Write-Host ' 4. At F3, commit once with Space; switch back to F1, type ceshi, and verify the old F3 position is not reused.' + Write-Host ' 5. To close 0C-3, save tightly cropped screenshots with anchor-id prefixes under:' + Write-Host " $manualEvidence" + Write-Host ' 6. Close the EasyX window when all three anchors and the post-commit F1 regression have been exercised.' + } + Write-Host "Running visual probe mode: $probeMode" + & $probe --mode $probeMode --output $runDirectory --dpi-awareness $DpiAwareness --ime-name $ImeName + $exitCode = $LASTEXITCODE + $rawPath = Join-Path $runDirectory 'raw-result.json' + if (-not (Test-Path -LiteralPath $rawPath)) { + throw "Visual probe did not produce raw-result.json (exit $exitCode): $runDirectory" + } + $summary = & (Join-Path $PSScriptRoot 'analyze-visual-probe.ps1') ` + -Path $runDirectory -SessionId $sessionId -StartedAtUtc $startedAtUtc -ProbeExitCode $exitCode + $summaries.Add($summary) + if ($exitCode -ne 0 -or $summary.automatedStatus -ne 'pass') { $probeFailed = $true } +} + +if ($summaries.Count -eq 2) { + $drawing = $summaries | Where-Object mode -eq 'wide-text' | Select-Object -First 1 + $coordinate = $summaries | Where-Object mode -eq 'dpi-coordinate' | Select-Object -First 1 + $combinedInput = "$($drawing.manifestSha256):$($coordinate.manifestSha256)" + $sha = [Security.Cryptography.SHA256]::Create() + try { + $combinedBytes = [Text.Encoding]::UTF8.GetBytes($combinedInput) + $combinedHash = ([BitConverter]::ToString($sha.ComputeHash($combinedBytes))).Replace('-', '') + } + finally { + $sha.Dispose() + } + $baseline = [pscustomobject][ordered]@{ + visualProbeBaselineSchemaVersion = 1 + sessionId = $sessionId + createdAtUtc = [DateTime]::UtcNow.ToString('o') + drawingBaselineManifest = 'wide-text/manifest-v1.json' + coordinateBaselineManifest = 'dpi-coordinate/manifest-v1.json' + drawingBaselineSha256 = $drawing.manifestSha256 + coordinateBaselineSha256 = $coordinate.manifestSha256 + combinedBaselineId = "sha256:$combinedHash" + dpiAwareness = $coordinate.windowAwareness + dpiForWindow = $coordinate.dpiForWindow + imeIdentity = $null + hkl = $null + automatedStatus = if ($probeFailed) { 'fail' } else { 'pass' } + manualStatus = 'not_reviewed' + } + $baselinePath = Join-Path $sessionRoot 'baseline-set-v1.json' + $utf8Bom = New-Object System.Text.UTF8Encoding($true) + [IO.File]::WriteAllText($baselinePath, + ($baseline | ConvertTo-Json -Depth 20) + [Environment]::NewLine, $utf8Bom) + $baselineHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $baselinePath).Hash + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [IO.File]::WriteAllText($baselinePath + '.sha256', + "$baselineHash *baseline-set-v1.json`r`n", $utf8NoBom) + Write-Host "Combined baseline: $baselinePath" + Write-Host "Combined baseline ID: $($baseline.combinedBaselineId)" +} + +$summaries | Format-Table mode, automatedStatus, manualStatus, testCaseCount, evidenceCount, dpiForWindow +Write-Host "Result directory: $sessionRoot" +if ($probeFailed) { throw "One or more visual probe modes did not pass; evidence was preserved in $sessionRoot" } diff --git a/tests/EasyX.VisualProbe.vcxproj b/tests/EasyX.VisualProbe.vcxproj new file mode 100644 index 0000000..4e4af06 --- /dev/null +++ b/tests/EasyX.VisualProbe.vcxproj @@ -0,0 +1,55 @@ + + + + DebugWin32 + ReleaseWin32 + Debugx64 + Releasex64 + + + 17.0 + Win32Proj + {B483B71A-159A-4E67-AC2D-8BC74E07E0C1} + EasyXVisualProbe + 10.0 + + + + Application + true + false + v143 + MultiByte + + + + $(ProjectDir)..\build\$(Platform)\$(Configuration)\ + $(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)\ + EasyX.VisualProbe + + + + Level4 + true + true + stdcpp17 + NotUsing + WIN32_LEAN_AND_MEAN;NOMINMAX;_WIN32_WINNT=0x0A00;%(PreprocessorDefinitions) + + + Console + true + + + + + + + + + + + + + + diff --git a/tests/visual-probe/DpiCoordinateProbe.cpp b/tests/visual-probe/DpiCoordinateProbe.cpp new file mode 100644 index 0000000..0f2b761 --- /dev/null +++ b/tests/visual-probe/DpiCoordinateProbe.cpp @@ -0,0 +1,270 @@ +#include "VisualProbeCommon.h" + +#include +#include +#include +#include +#include + +namespace VisualProbe +{ + namespace + { + constexpr COLORREF Background = RGB(238, 241, 246); + constexpr COLORREF EasyXColor = RGB(220, 45, 70); + constexpr COLORREF GdiColor = RGB(20, 150, 70); + + struct RoundTripResult + { + POINT client{}; + POINT screen{}; + POINT clientRoundTrip{}; + int errorX = 0; + int errorY = 0; + bool converted = false; + }; + + std::string normalizedRelative(const std::filesystem::path& path) + { + std::string value = path.generic_string(); + std::replace(value.begin(), value.end(), '\\', '/'); + return value; + } + + bool addSurfaceEvidence( + const Options& options, + HDC hdc, + const std::string& fileName, + const std::string& purpose, + std::vector& evidence) + { + const auto relative = std::filesystem::path("evidence") / fileName; + const bool saved = saveHdcImage(hdc, CanvasWidth, CanvasHeight, + options.outputDirectory / relative); + if (saved) evidence.push_back({ normalizedRelative(relative), purpose, "surface_bitblt" }); + return saved; + } + + bool addClientEvidence( + const Options& options, + HWND hwnd, + const std::string& fileName, + const std::string& purpose, + std::vector& evidence) + { + const auto relative = std::filesystem::path("evidence") / fileName; + Sleep(30); + std::string backend; + const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend); + if (saved) evidence.push_back({ normalizedRelative(relative), purpose, backend }); + return saved; + } + + RoundTripResult roundTrip(HWND hwnd, POINT client) + { + RoundTripResult result; + result.client = client; + result.screen = client; + if (!ClientToScreen(hwnd, &result.screen)) return result; + result.clientRoundTrip = result.screen; + if (!ScreenToClient(hwnd, &result.clientRoundTrip)) return result; + result.converted = true; + result.errorX = result.clientRoundTrip.x - result.client.x; + result.errorY = result.clientRoundTrip.y - result.client.y; + return result; + } + + int boundsWidth(const PixelBounds& bounds) + { + return bounds.present ? bounds.right - bounds.left + 1 : 0; + } + + int boundsHeight(const PixelBounds& bounds) + { + return bounds.present ? bounds.bottom - bounds.top + 1 : 0; + } + + void writeRoundTrip(std::ostream& output, const RoundTripResult& result) + { + output << "{\"converted\":"; + writeBoolean(output, result.converted); + output << ",\"client\":"; + writePointJson(output, result.client); + output << ",\"screen\":"; + writePointJson(output, result.screen); + output << ",\"clientRoundTrip\":"; + writePointJson(output, result.clientRoundTrip); + output << ",\"error\":{\"x\":" << result.errorX + << ",\"y\":" << result.errorY << "}}"; + } + } + + int runDpiCoordinateProbe(const Options& options, const DpiConfiguration& dpiConfiguration) + { + HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE); + if (!hwnd) + { + std::cerr << "initgraph failed\n"; + return 3; + } + SetWindowTextA(hwnd, "StellarX 0C DPI Coordinate Probe"); + ShowWindow(hwnd, SW_SHOWNORMAL); + SetForegroundWindow(hwnd); + Sleep(80); + const EnvironmentSnapshot environment = captureEnvironment(hwnd); + SetWorkingImage(nullptr); + HDC hdc = GetImageHDC(nullptr); + std::vector evidence; + bool evidenceComplete = true; + + BeginBatchDraw(); + setbkcolor(Background); + cleardevice(); + setlinestyle(PS_SOLID, 1); + setlinecolor(EasyXColor); + rectangle(100, 100, 240, 180); + line(100, 140, 240, 140); + line(170, 100, 170, 180); + settextcolor(EasyXColor); + setbkmode(TRANSPARENT); + settextstyle(28, 0, "Microsoft YaHei UI"); + const char* easyXText = "StellarX DPI"; + outtextxy(100, 250, easyXText); + const SIZE easyXLogicalExtent{ textwidth(easyXText), textheight(easyXText) }; + + const GdiState stateBefore = captureGdiState(hdc); + const int saved = SaveDC(hdc); + HPEN pen = CreatePen(PS_SOLID, 1, GdiColor); + HGDIOBJ previousPen = pen ? SelectObject(hdc, pen) : nullptr; + HBRUSH previousBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH))); + SetBkMode(hdc, TRANSPARENT); + SetTextColor(hdc, GdiColor); + Rectangle(hdc, 500, 100, 641, 181); + MoveToEx(hdc, 500, 140, nullptr); + LineTo(hdc, 641, 140); + MoveToEx(hdc, 570, 100, nullptr); + LineTo(hdc, 570, 181); + const std::wstring gdiText = L"StellarX DPI"; + SIZE gdiLogicalExtent{}; + const bool gdiExtentSuccess = GetTextExtentPoint32W(hdc, gdiText.data(), + static_cast(gdiText.size()), &gdiLogicalExtent) != FALSE; + const bool gdiTextSuccess = TextOutW(hdc, 500, 250, gdiText.data(), + static_cast(gdiText.size())) != FALSE; + if (previousBrush) SelectObject(hdc, previousBrush); + if (previousPen) SelectObject(hdc, previousPen); + if (saved) RestoreDC(hdc, saved); + if (pen) DeleteObject(pen); + const GdiState stateAfter = captureGdiState(hdc); + const bool gdiStateRestored = saved != 0 && sameGdiState(stateBefore, stateAfter); + + const SurfaceFingerprint afterDraw = captureSurfaceFingerprint(hdc, CanvasWidth, CanvasHeight); + const RECT easyXMarkerRegion{ 90, 90, 250, 190 }; + const RECT gdiMarkerRegion{ 490, 90, 650, 190 }; + const RECT easyXTextRegion{ 90, 240, 400, 310 }; + const RECT gdiTextRegion{ 490, 240, 800, 310 }; + const PixelBounds easyXMarkerSurface = findColorBounds(hdc, easyXMarkerRegion, EasyXColor); + const PixelBounds gdiMarkerSurface = findColorBounds(hdc, gdiMarkerRegion, GdiColor); + const PixelBounds easyXTextSurface = findChangedBounds(hdc, easyXTextRegion, Background); + const PixelBounds gdiTextSurface = findChangedBounds(hdc, gdiTextRegion, Background); + evidenceComplete &= addSurfaceEvidence(options, hdc, "dpi_after_draw_surface.png", + "DPI surface after EasyX and raw GDI drawing", evidence); + evidenceComplete &= addClientEvidence(options, hwnd, "dpi_before_flush_client.png", + "DPI client before FlushBatchDraw", evidence); + + FlushBatchDraw(); + evidenceComplete &= addClientEvidence(options, hwnd, "dpi_after_flush_client.png", + "DPI client after FlushBatchDraw", evidence); + EndBatchDraw(); + evidenceComplete &= addClientEvidence(options, hwnd, "dpi_after_end_client.png", + "DPI client after EndBatchDraw", evidence); + + PixelBounds easyXMarkerClient; + PixelBounds gdiMarkerClient; + HDC clientDc = GetDC(hwnd); + if (clientDc) + { + easyXMarkerClient = findColorBounds(clientDc, easyXMarkerRegion, EasyXColor); + gdiMarkerClient = findColorBounds(clientDc, gdiMarkerRegion, GdiColor); + ReleaseDC(hwnd, clientDc); + } + + const std::array anchorPoints{ + POINT{ 0, 0 }, POINT{ 100, 100 }, POINT{ 500, 250 }, + POINT{ CanvasWidth - 1, CanvasHeight - 1 } + }; + std::vector roundTrips; + bool roundTripsExact = true; + for (const POINT point : anchorPoints) + { + roundTrips.push_back(roundTrip(hwnd, point)); + const auto& result = roundTrips.back(); + roundTripsExact = roundTripsExact && result.converted && + result.errorX == 0 && result.errorY == 0; + } + + const bool surfaceMarkerSizesAgree = easyXMarkerSurface.present && gdiMarkerSurface.present && + std::abs(boundsWidth(easyXMarkerSurface) - boundsWidth(gdiMarkerSurface)) <= 2 && + std::abs(boundsHeight(easyXMarkerSurface) - boundsHeight(gdiMarkerSurface)) <= 2; + const bool clientMatchesSurface = easyXMarkerClient.present && gdiMarkerClient.present && + std::abs(boundsWidth(easyXMarkerClient) - boundsWidth(easyXMarkerSurface)) <= 1 && + std::abs(boundsHeight(easyXMarkerClient) - boundsHeight(easyXMarkerSurface)) <= 1 && + std::abs(boundsWidth(gdiMarkerClient) - boundsWidth(gdiMarkerSurface)) <= 1 && + std::abs(boundsHeight(gdiMarkerClient) - boundsHeight(gdiMarkerSurface)) <= 1; + const bool implicitScalingObserved = !surfaceMarkerSizesAgree || !clientMatchesSurface; + const bool completed = evidenceComplete && gdiStateRestored && gdiExtentSuccess && + gdiTextSuccess && easyXMarkerSurface.present && gdiMarkerSurface.present && + easyXTextSurface.present && gdiTextSurface.present && roundTripsExact; + + std::ostringstream result; + result << "{\"batchState\":\"inside\",\"surfaceFingerprintAfterDraw\":"; + writeSurfaceFingerprintJson(result, afterDraw); + result << ",\"gdiStateRestored\":"; + writeBoolean(result, gdiStateRestored); + result << ",\"easyXLogicalExtent\":"; + writeSizeJson(result, easyXLogicalExtent); + result << ",\"gdiLogicalExtent\":"; + writeSizeJson(result, gdiLogicalExtent); + result << ",\"gdiExtentSuccess\":"; + writeBoolean(result, gdiExtentSuccess); + result << ",\"gdiTextSuccess\":"; + writeBoolean(result, gdiTextSuccess); + result << ",\"surfaceBounds\":{\"easyXMarker\":"; + writeBoundsJson(result, easyXMarkerSurface); + result << ",\"gdiMarker\":"; + writeBoundsJson(result, gdiMarkerSurface); + result << ",\"easyXText\":"; + writeBoundsJson(result, easyXTextSurface); + result << ",\"gdiText\":"; + writeBoundsJson(result, gdiTextSurface); + result << "},\"clientBounds\":{\"easyXMarker\":"; + writeBoundsJson(result, easyXMarkerClient); + result << ",\"gdiMarker\":"; + writeBoundsJson(result, gdiMarkerClient); + result << "},\"surfaceMarkerSizesAgree\":"; + writeBoolean(result, surfaceMarkerSizesAgree); + result << ",\"clientMatchesSurface\":"; + writeBoolean(result, clientMatchesSurface); + result << ",\"implicitScalingObserved\":"; + writeBoolean(result, implicitScalingObserved); + result << ",\"roundTripsExact\":"; + writeBoolean(result, roundTripsExact); + result << ",\"coordinateRoundTrips\":["; + for (std::size_t index = 0; index < roundTrips.size(); ++index) + { + if (index) result << ','; + writeRoundTrip(result, roundTrips[index]); + } + result << "]}"; + SetWorkingImage(nullptr); + closegraph(); + + std::string error; + if (!writeRawResult(options, dpiConfiguration, environment, completed, + evidence, result.str(), error)) + { + std::cerr << error << '\n'; + return 4; + } + return completed ? 0 : 5; + } +} diff --git a/tests/visual-probe/ImePositionProbe.cpp b/tests/visual-probe/ImePositionProbe.cpp new file mode 100644 index 0000000..0fffbbd --- /dev/null +++ b/tests/visual-probe/ImePositionProbe.cpp @@ -0,0 +1,436 @@ +#include "VisualProbeCommon.h" + +#include + +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "Imm32.lib") + +namespace VisualProbe +{ + namespace + { + struct Anchor + { + const char* id; + POINT controlOrigin; + POINT localCaretTopLeft; + POINT localCaretBottom; + RECT localLineRect; + }; + + struct ImePositionEvent + { + std::uint64_t sequence = 0; + std::string stage; + std::string positionTrigger = "none"; + std::string anchorId; + DWORD threadId = 0; + std::uintptr_t hkl = 0; + std::wstring imeDescription; + std::wstring imeFileName; + bool apiAttempted = false; + bool compositionActiveAtAttempt = false; + bool contextAvailable = false; + bool compositionSucceeded = false; + bool candidateSucceeded = false; + POINT hwndRelativePoint{}; + RECT hwndRelativeLineRect{}; + POINT screenPoint{}; + }; + + struct ImePositionState + { + HWND hwnd = nullptr; + WNDPROC previousWndProc = nullptr; + std::atomic activeAnchor{ 1 }; + std::atomic compositionActive{ false }; + std::atomic closeRequested{ false }; + std::atomic nextSequence{ 0 }; + std::mutex eventsMutex; + std::vector events; + }; + + constexpr std::array Anchors{ + Anchor{ "anchor_top_left_safe", POINT{ 80, 125 }, POINT{ 30, 35 }, POINT{ 30, 61 }, RECT{ 10, 30, 250, 66 } }, + Anchor{ "anchor_center", POINT{ 350, 305 }, POINT{ 130, 55 }, POINT{ 130, 81 }, RECT{ 10, 50, 260, 86 } }, + Anchor{ "anchor_bottom_right_safe", POINT{ 650, 520 }, POINT{ 180, 45 }, POINT{ 180, 71 }, RECT{ 10, 40, 230, 76 } } + }; + + ImePositionState* activeState = nullptr; + + POINT surfaceCaret(const Anchor& anchor) noexcept + { + return POINT{ + anchor.controlOrigin.x + anchor.localCaretBottom.x, + anchor.controlOrigin.y + anchor.localCaretBottom.y + }; + } + + std::wstring imeDescription(HKL hkl) + { + const UINT length = ImmGetDescriptionW(hkl, nullptr, 0); + if (!length) return {}; + std::wstring value(length + 1, L'\0'); + const UINT written = ImmGetDescriptionW(hkl, value.data(), static_cast(value.size())); + value.resize(written); + return value; + } + + std::wstring imeFileName(HKL hkl) + { + const UINT length = ImmGetIMEFileNameW(hkl, nullptr, 0); + if (!length) return {}; + std::wstring value(length + 1, L'\0'); + const UINT written = ImmGetIMEFileNameW(hkl, value.data(), static_cast(value.size())); + value.resize(written); + return value; + } + + void appendEvent(ImePositionState& state, ImePositionEvent event) + { + event.sequence = state.nextSequence.fetch_add(1, std::memory_order_relaxed) + 1; + std::lock_guard lock(state.eventsMutex); + state.events.push_back(std::move(event)); + } + + void recordLifecycle(ImePositionState& state, const char* stage) + { + const int anchorIndex = state.activeAnchor.load(std::memory_order_acquire); + ImePositionEvent event; + event.stage = stage; + event.anchorId = Anchors[static_cast(anchorIndex)].id; + event.threadId = GetCurrentThreadId(); + const HKL hkl = GetKeyboardLayout(0); + event.hkl = reinterpret_cast(hkl); + event.imeDescription = imeDescription(hkl); + event.imeFileName = imeFileName(hkl); + appendEvent(state, std::move(event)); + } + + void configureImePosition(ImePositionState& state, const char* stage, const char* positionTrigger) + { + const int anchorIndex = state.activeAnchor.load(std::memory_order_acquire); + const Anchor& anchor = Anchors[static_cast(anchorIndex)]; + ImePositionEvent event; + event.stage = stage; + event.positionTrigger = positionTrigger; + event.anchorId = anchor.id; + event.threadId = GetCurrentThreadId(); + const HKL hkl = GetKeyboardLayout(0); + event.hkl = reinterpret_cast(hkl); + event.imeDescription = imeDescription(hkl); + event.imeFileName = imeFileName(hkl); + event.apiAttempted = true; + event.compositionActiveAtAttempt = state.compositionActive.load(std::memory_order_acquire); + event.hwndRelativePoint = surfaceCaret(anchor); + event.hwndRelativeLineRect = RECT{ + anchor.controlOrigin.x + anchor.localLineRect.left, + anchor.controlOrigin.y + anchor.localLineRect.top, + anchor.controlOrigin.x + anchor.localLineRect.right, + anchor.controlOrigin.y + anchor.localLineRect.bottom + }; + event.screenPoint = event.hwndRelativePoint; + ClientToScreen(state.hwnd, &event.screenPoint); + + HIMC context = ImmGetContext(state.hwnd); + event.contextAvailable = context != 0; + if (context) + { + COMPOSITIONFORM composition{}; + composition.dwStyle = CFS_POINT; + composition.ptCurrentPos = event.hwndRelativePoint; + event.compositionSucceeded = ImmSetCompositionWindow(context, &composition) != FALSE; + + CANDIDATEFORM candidate{}; + candidate.dwIndex = 0; + candidate.dwStyle = CFS_EXCLUDE; + candidate.ptCurrentPos = event.hwndRelativePoint; + candidate.rcArea = event.hwndRelativeLineRect; + event.candidateSucceeded = ImmSetCandidateWindow(context, &candidate) != FALSE; + ImmReleaseContext(state.hwnd, context); + } + appendEvent(state, std::move(event)); + } + + LRESULT CALLBACK ProbeWndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) + { + ImePositionState* state = activeState; + if (!state) + return DefWindowProcA(hwnd, message, wParam, lParam); + + if (message == WM_KEYDOWN && wParam >= VK_F1 && wParam <= VK_F3) + { + state->activeAnchor.store(static_cast(wParam - VK_F1), std::memory_order_release); + configureImePosition(*state, "anchor_preconfigure", "anchor_change"); + return 0; + } + else if (message == WM_IME_STARTCOMPOSITION) + { + state->compositionActive.store(true, std::memory_order_release); + configureImePosition(*state, "ime_start", "ime_lifecycle"); + } + else if (message == WM_IME_COMPOSITION) + { + configureImePosition(*state, "ime_composition", "ime_lifecycle"); + } + else if (message == WM_IME_ENDCOMPOSITION) + { + recordLifecycle(*state, "ime_end"); + state->compositionActive.store(false, std::memory_order_release); + } + else if (message == WM_INPUTLANGCHANGE) + { + recordLifecycle(*state, "input_language_change"); + } + else if (message == WM_CLOSE) + { + recordLifecycle(*state, "close_requested"); + state->closeRequested.store(true, std::memory_order_release); + return 0; + } + + return state->previousWndProc + ? CallWindowProcA(state->previousWndProc, hwnd, message, wParam, lParam) + : DefWindowProcA(hwnd, message, wParam, lParam); + } + + void drawAnchorScreen(int activeAnchorIndex) + { + SetWorkingImage(nullptr); + BeginBatchDraw(); + setbkcolor(RGB(238, 241, 246)); + cleardevice(); + setbkmode(TRANSPARENT); + settextstyle(22, 0, "Microsoft YaHei UI"); + settextcolor(RGB(30, 45, 70)); + outtextxy(30, 25, "IME position probe: F1/F2/F3 select anchor; close window when done"); + outtextxy(30, 58, "Use only fixed non-sensitive input: ceshi; inspect candidate position; Esc cancels"); + for (std::size_t index = 0; index < Anchors.size(); ++index) + { + const Anchor& anchor = Anchors[index]; + const bool selected = static_cast(index) == activeAnchorIndex; + const COLORREF color = selected ? RGB(220, 60, 45) : RGB(45, 125, 190); + const POINT caret = surfaceCaret(anchor); + setlinecolor(color); + setlinestyle(PS_SOLID, selected ? 3 : 1); + rectangle(anchor.controlOrigin.x, anchor.controlOrigin.y, + anchor.controlOrigin.x + anchor.localLineRect.right, + anchor.controlOrigin.y + anchor.localLineRect.bottom + 12); + line(caret.x, caret.y - 26, caret.x, caret.y + 4); + line(caret.x - 5, caret.y, caret.x + 5, caret.y); + settextcolor(color); + const std::string label = std::string("F") + std::to_string(index + 1) + " " + anchor.id; + outtextxy(anchor.controlOrigin.x + 8, anchor.controlOrigin.y + 5, label.c_str()); + } + EndBatchDraw(); + } + + std::string normalizedRelative(const std::filesystem::path& path) + { + std::string value = path.generic_string(); + std::replace(value.begin(), value.end(), '\\', '/'); + return value; + } + + void writeAnchor(std::ostream& output, const Anchor& anchor) + { + const POINT surfaceTopLeft{ + anchor.controlOrigin.x + anchor.localCaretTopLeft.x, + anchor.controlOrigin.y + anchor.localCaretTopLeft.y + }; + const POINT surfaceBottom = surfaceCaret(anchor); + RECT surfaceLine{ + anchor.controlOrigin.x + anchor.localLineRect.left, + anchor.controlOrigin.y + anchor.localLineRect.top, + anchor.controlOrigin.x + anchor.localLineRect.right, + anchor.controlOrigin.y + anchor.localLineRect.bottom + }; + output << "{\"syntheticAnchorId\":" << jsonString(anchor.id) + << ",\"controlOrigin\":"; + writePointJson(output, anchor.controlOrigin); + output << ",\"controlLocalCaretTopLeft\":"; + writePointJson(output, anchor.localCaretTopLeft); + output << ",\"controlLocalCaretBottom\":"; + writePointJson(output, anchor.localCaretBottom); + output << ",\"controlLocalLineRect\":"; + writeRectJson(output, anchor.localLineRect); + output << ",\"easyxSurfaceCaretTopLeft\":"; + writePointJson(output, surfaceTopLeft); + output << ",\"easyxSurfaceCaretBottom\":"; + writePointJson(output, surfaceBottom); + output << ",\"easyxSurfaceLineRect\":"; + writeRectJson(output, surfaceLine); + output << ",\"targetHwndRelativePoint\":"; + writePointJson(output, surfaceBottom); + output << '}'; + } + + void writeEvent(std::ostream& output, const ImePositionEvent& event) + { + output << "{\"sequence\":" << event.sequence + << ",\"stage\":" << jsonString(event.stage) + << ",\"positionTrigger\":" << jsonString(event.positionTrigger) + << ",\"syntheticAnchorId\":" << jsonString(event.anchorId) + << ",\"threadId\":" << event.threadId + << ",\"hkl\":" << jsonString(hexValue(event.hkl)) + << ",\"imeDescriptionUtf16\":" << utf16UnitsJson(event.imeDescription) + << ",\"imeFileNameUtf16\":" << utf16UnitsJson(event.imeFileName) + << ",\"apiAttempted\":"; + writeBoolean(output, event.apiAttempted); + output << ",\"compositionActiveAtAttempt\":"; + writeBoolean(output, event.compositionActiveAtAttempt); + output << ",\"contextAvailable\":"; + writeBoolean(output, event.contextAvailable); + output << ",\"compositionForm\":{\"style\":" << CFS_POINT + << ",\"apiResult\":"; + writeBoolean(output, event.compositionSucceeded); + output << "},\"candidateForm\":{\"style\":" << CFS_EXCLUDE + << ",\"index\":0,\"apiResult\":"; + writeBoolean(output, event.candidateSucceeded); + output << ",\"excludeRect\":"; + writeRectJson(output, event.hwndRelativeLineRect); + output << "},\"targetHwndRelativePoint\":"; + writePointJson(output, event.hwndRelativePoint); + output << ",\"diagnosticScreenPoint\":"; + writePointJson(output, event.screenPoint); + output << '}'; + } + } + + int runImePositionProbe(const Options& options, const DpiConfiguration& dpiConfiguration) + { + HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE); + if (!hwnd) + { + std::cerr << "initgraph failed\n"; + return 3; + } + SetWindowTextA(hwnd, "StellarX 0C IME Position Probe"); + ShowWindow(hwnd, SW_SHOWNORMAL); + SetForegroundWindow(hwnd); + Sleep(80); + const EnvironmentSnapshot environment = captureEnvironment(hwnd); + DWORD windowProcessId = 0; + const DWORD windowOwnerThread = GetWindowThreadProcessId(hwnd, &windowProcessId); + + ImePositionState state; + state.hwnd = hwnd; + activeState = &state; + SetLastError(ERROR_SUCCESS); + state.previousWndProc = reinterpret_cast(SetWindowLongPtrA( + hwnd, GWLP_WNDPROC, reinterpret_cast(&ProbeWndProc))); + const DWORD subclassError = state.previousWndProc ? ERROR_SUCCESS : GetLastError(); + + std::vector evidence; + bool evidenceComplete = state.previousWndProc != nullptr; + for (std::size_t index = 0; index < Anchors.size(); ++index) + { + drawAnchorScreen(static_cast(index)); + Sleep(40); + const auto relative = std::filesystem::path("evidence") / + (std::string(Anchors[index].id) + "_client.png"); + std::string backend; + const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend); + evidenceComplete = evidenceComplete && saved; + if (saved) + evidence.push_back({ normalizedRelative(relative), "synthetic anchor client evidence", backend }); + } + state.activeAnchor.store(1, std::memory_order_release); + drawAnchorScreen(1); + + int lastDrawnAnchor = 1; + const ULONGLONG loopStarted = GetTickCount64(); + while (!state.closeRequested.load(std::memory_order_acquire) && IsWindow(hwnd)) + { + if (options.autoCloseMilliseconds > 0 && + GetTickCount64() - loopStarted >= options.autoCloseMilliseconds) + { + recordLifecycle(state, "auto_close"); + state.closeRequested.store(true, std::memory_order_release); + break; + } + const int currentAnchor = state.activeAnchor.load(std::memory_order_acquire); + if (currentAnchor != lastDrawnAnchor) + { + drawAnchorScreen(currentAnchor); + lastDrawnAnchor = currentAnchor; + } + Sleep(16); + } + + if (state.previousWndProc && IsWindow(hwnd)) + SetWindowLongPtrA(hwnd, GWLP_WNDPROC, + reinterpret_cast(state.previousWndProc)); + activeState = nullptr; + if (IsWindow(hwnd)) closegraph(); + + std::vector events; + { + std::lock_guard lock(state.eventsMutex); + events = state.events; + } + std::array anchorSucceeded{}; + bool imeLifecycleObserved = false; + bool imeLifecycleSucceeded = false; + for (const auto& event : events) + { + if (event.positionTrigger == "ime_lifecycle") imeLifecycleObserved = true; + if (event.positionTrigger == "ime_lifecycle" && event.contextAvailable && + event.compositionSucceeded && event.candidateSucceeded) + { + imeLifecycleSucceeded = true; + } + if (!event.apiAttempted || !event.compositionSucceeded || !event.candidateSucceeded) + continue; + for (std::size_t index = 0; index < Anchors.size(); ++index) + { + if (event.anchorId == Anchors[index].id) anchorSucceeded[index] = true; + } + } + bool allAnchorsSucceeded = true; + for (const bool value : anchorSucceeded) allAnchorsSucceeded = allAnchorsSucceeded && value; + const bool completed = evidenceComplete && allAnchorsSucceeded && imeLifecycleSucceeded; + + std::ostringstream result; + result << "{\"expectedImeName\":" << jsonString(options.expectedImeName) + << ",\"windowOwnerThreadId\":" << windowOwnerThread + << ",\"windowProcessId\":" << windowProcessId + << ",\"subclassInstalled\":"; + writeBoolean(result, state.previousWndProc != nullptr); + result << ",\"subclassError\":" << subclassError + << ",\"allAnchorsApiSucceeded\":"; + writeBoolean(result, allAnchorsSucceeded); + result << ",\"imeLifecycleObserved\":"; + writeBoolean(result, imeLifecycleObserved); + result << ",\"imeLifecycleSucceeded\":"; + writeBoolean(result, imeLifecycleSucceeded); + result << ",\"anchors\":["; + for (std::size_t index = 0; index < Anchors.size(); ++index) + { + if (index) result << ','; + writeAnchor(result, Anchors[index]); + } + result << "],\"imeEvents\":["; + for (std::size_t index = 0; index < events.size(); ++index) + { + if (index) result << ','; + writeEvent(result, events[index]); + } + result << "]}"; + + std::string error; + if (!writeRawResult(options, dpiConfiguration, environment, completed, + evidence, result.str(), error)) + { + std::cerr << error << '\n'; + return 4; + } + return completed ? 0 : 5; + } +} diff --git a/tests/visual-probe/VisualProbeCommon.cpp b/tests/visual-probe/VisualProbeCommon.cpp new file mode 100644 index 0000000..cf5cebd --- /dev/null +++ b/tests/visual-probe/VisualProbeCommon.cpp @@ -0,0 +1,722 @@ +#include "VisualProbeCommon.h" + +#include +#include +#include +#include +#include +#include + +namespace VisualProbe +{ + namespace + { + using SetProcessDpiAwarenessContextFn = BOOL(WINAPI*)(DPI_AWARENESS_CONTEXT); + using GetDpiForWindowFn = UINT(WINAPI*)(HWND); + using GetThreadDpiAwarenessContextFn = DPI_AWARENESS_CONTEXT(WINAPI*)(); + using GetWindowDpiAwarenessContextFn = DPI_AWARENESS_CONTEXT(WINAPI*)(HWND); + using GetAwarenessFromDpiAwarenessContextFn = DPI_AWARENESS(WINAPI*)(DPI_AWARENESS_CONTEXT); + using GetProcessDpiAwarenessFn = HRESULT(WINAPI*)(HANDLE, int*); + + std::string awarenessName(int awareness) + { + switch (awareness) + { + case 0: return "unaware"; + case 1: return "system"; + case 2: return "per-monitor"; + default: return "unknown"; + } + } + + std::string contextAwarenessName(DPI_AWARENESS_CONTEXT context) noexcept + { + HMODULE user32 = GetModuleHandleW(L"user32.dll"); + const auto getAwareness = user32 ? reinterpret_cast( + GetProcAddress(user32, "GetAwarenessFromDpiAwarenessContext")) : nullptr; + if (!getAwareness || !context) + return "not_available"; + return awarenessName(static_cast(getAwareness(context))); + } + + DPI_AWARENESS_CONTEXT requestedContext(const std::string& requested) noexcept + { + if (requested == "unaware") return DPI_AWARENESS_CONTEXT_UNAWARE; + if (requested == "system") return DPI_AWARENESS_CONTEXT_SYSTEM_AWARE; + if (requested == "per-monitor") return DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE; + if (requested == "per-monitor-v2") return DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2; + return nullptr; + } + + std::uint64_t fnv1a(const std::vector& pixels) noexcept + { + std::uint64_t value = 1469598103934665603ull; + for (const std::uint32_t pixel : pixels) + { + for (unsigned int shift = 0; shift < 32; shift += 8) + { + value ^= static_cast((pixel >> shift) & 0xFFu); + value *= 1099511628211ull; + } + } + return value; + } + + std::vector readPixels( + HDC hdc, + int width, + int height, + int sourceX = 0, + int sourceY = 0) noexcept + { + std::vector pixels; + if (!hdc || width <= 0 || height <= 0) + return pixels; + BITMAPINFO info{}; + info.bmiHeader.biSize = sizeof(info.bmiHeader); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* bits = nullptr; + HDC memoryDc = CreateCompatibleDC(hdc); + if (!memoryDc) + return pixels; + HBITMAP bitmap = CreateDIBSection(hdc, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + if (!bitmap || !bits) + { + if (bitmap) DeleteObject(bitmap); + DeleteDC(memoryDc); + return pixels; + } + HGDIOBJ previous = SelectObject(memoryDc, bitmap); + const bool copied = previous && BitBlt(memoryDc, 0, 0, width, height, + hdc, sourceX, sourceY, SRCCOPY) != FALSE; + if (copied) + { + try + { + const auto* first = static_cast(bits); + pixels.assign(first, first + static_cast(width) * height); + } + catch (...) + { + pixels.clear(); + } + } + if (previous) SelectObject(memoryDc, previous); + DeleteObject(bitmap); + DeleteDC(memoryDc); + return pixels; + } + + bool xformEqual(const XFORM& left, const XFORM& right) noexcept + { + constexpr float Epsilon = 0.0001f; + return std::fabs(left.eM11 - right.eM11) < Epsilon && + std::fabs(left.eM12 - right.eM12) < Epsilon && + std::fabs(left.eM21 - right.eM21) < Epsilon && + std::fabs(left.eM22 - right.eM22) < Epsilon && + std::fabs(left.eDx - right.eDx) < Epsilon && + std::fabs(left.eDy - right.eDy) < Epsilon; + } + + std::string captureOsBuild() noexcept + { + using RtlGetVersionFn = LONG(WINAPI*)(OSVERSIONINFOW*); + HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); + const auto rtlGetVersion = ntdll ? reinterpret_cast( + GetProcAddress(ntdll, "RtlGetVersion")) : nullptr; + OSVERSIONINFOW version{}; + version.dwOSVersionInfoSize = sizeof(version); + if (!rtlGetVersion || rtlGetVersion(&version) != 0) + return "not_available"; + std::ostringstream output; + output << version.dwMajorVersion << '.' << version.dwMinorVersion + << '.' << version.dwBuildNumber; + return output.str(); + } + } + + bool parseOptions(int argc, char** argv, Options& options, std::string& error) + { + for (int index = 1; index < argc; ++index) + { + const std::string argument = argv[index]; + if ((argument == "--mode" || argument == "--output" || argument == "--dpi-awareness" || + argument == "--ime-name" || argument == "--auto-close-ms") && + index + 1 >= argc) + { + error = "Missing value for " + argument; + return false; + } + if (argument == "--mode") options.mode = argv[++index]; + else if (argument == "--output") options.outputDirectory = argv[++index]; + else if (argument == "--dpi-awareness") options.requestedDpiAwareness = argv[++index]; + else if (argument == "--ime-name") options.expectedImeName = argv[++index]; + else if (argument == "--auto-close-ms") + { + try + { + options.autoCloseMilliseconds = static_cast(std::stoul(argv[++index])); + } + catch (...) + { + error = "Invalid auto-close duration"; + return false; + } + } + else + { + error = "Unknown argument: " + argument; + return false; + } + } + if (options.mode != "wide-text" && options.mode != "dpi-coordinate" && + options.mode != "ime-position") + { + error = "Mode must be wide-text, dpi-coordinate, or ime-position"; + return false; + } + if (options.outputDirectory.empty()) + { + error = "Output directory is required"; + return false; + } + const std::vector awarenessValues{ + "inherit", "unaware", "system", "per-monitor", "per-monitor-v2" + }; + if (std::find(awarenessValues.begin(), awarenessValues.end(), options.requestedDpiAwareness) == + awarenessValues.end()) + { + error = "Unsupported DPI awareness: " + options.requestedDpiAwareness; + return false; + } + const std::vector imeNames{ "unspecified", "MicrosoftPinyin", "Sogou" }; + if (std::find(imeNames.begin(), imeNames.end(), options.expectedImeName) == imeNames.end()) + { + error = "Unsupported IME name: " + options.expectedImeName; + return false; + } + std::error_code fileError; + std::filesystem::create_directories(options.outputDirectory / "evidence", fileError); + if (fileError) + { + error = "Cannot create output directory: " + fileError.message(); + return false; + } + return true; + } + + DpiConfiguration applyRequestedDpiAwareness(const std::string& requested) noexcept + { + DpiConfiguration result; + result.requested = requested; + if (requested == "inherit") + { + result.applied = true; + return result; + } + result.attempted = true; + HMODULE user32 = GetModuleHandleW(L"user32.dll"); + const auto setContext = user32 ? reinterpret_cast( + GetProcAddress(user32, "SetProcessDpiAwarenessContext")) : nullptr; + if (!setContext) + { + result.error = ERROR_PROC_NOT_FOUND; + return result; + } + SetLastError(ERROR_SUCCESS); + result.applied = setContext(requestedContext(requested)) != FALSE; + result.error = result.applied ? ERROR_SUCCESS : GetLastError(); + return result; + } + + EnvironmentSnapshot captureEnvironment(HWND hwnd) noexcept + { + EnvironmentSnapshot snapshot; + snapshot.processId = GetCurrentProcessId(); + snapshot.threadId = GetCurrentThreadId(); + snapshot.windowUnicode = hwnd && IsWindowUnicode(hwnd) != FALSE; + snapshot.osBuild = captureOsBuild(); + const TCHAR* easyxVersion = GetEasyXVer(); + snapshot.easyxVersion = easyxVersion ? easyxVersion : "not_available"; + if (hwnd) + { + GetClientRect(hwnd, &snapshot.clientRect); + GetWindowRect(hwnd, &snapshot.windowRect); + } + + HMODULE user32 = GetModuleHandleW(L"user32.dll"); + const auto getDpi = user32 ? reinterpret_cast( + GetProcAddress(user32, "GetDpiForWindow")) : nullptr; + const auto getThreadContext = user32 ? reinterpret_cast( + GetProcAddress(user32, "GetThreadDpiAwarenessContext")) : nullptr; + const auto getWindowContext = user32 ? reinterpret_cast( + GetProcAddress(user32, "GetWindowDpiAwarenessContext")) : nullptr; + snapshot.dpiForWindow = getDpi && hwnd ? getDpi(hwnd) : 0; + snapshot.threadAwareness = contextAwarenessName(getThreadContext ? getThreadContext() : nullptr); + snapshot.windowAwareness = contextAwarenessName( + getWindowContext && hwnd ? getWindowContext(hwnd) : nullptr); + + HMODULE shcore = LoadLibraryW(L"shcore.dll"); + const auto getProcessAwareness = shcore ? reinterpret_cast( + GetProcAddress(shcore, "GetProcessDpiAwareness")) : nullptr; + int processAwareness = -1; + if (getProcessAwareness && SUCCEEDED(getProcessAwareness(nullptr, &processAwareness))) + snapshot.processAwareness = awarenessName(processAwareness); + else + snapshot.processAwareness = "not_available"; + if (shcore) FreeLibrary(shcore); + + HDC screen = GetDC(nullptr); + if (screen) + { + snapshot.deviceDpiX = GetDeviceCaps(screen, LOGPIXELSX); + snapshot.deviceDpiY = GetDeviceCaps(screen, LOGPIXELSY); + ReleaseDC(nullptr, screen); + } + return snapshot; + } + + GdiState captureGdiState(HDC hdc) noexcept + { + GdiState state; + if (!hdc) return state; + state.mapMode = GetMapMode(hdc); + GetViewportOrgEx(hdc, &state.viewportOrigin); + GetWindowOrgEx(hdc, &state.windowOrigin); + GetViewportExtEx(hdc, &state.viewportExtent); + GetWindowExtEx(hdc, &state.windowExtent); + state.graphicsMode = GetGraphicsMode(hdc); + state.worldTransformAvailable = GetWorldTransform(hdc, &state.worldTransform) != FALSE; + state.backgroundMode = GetBkMode(hdc); + state.textColor = GetTextColor(hdc); + state.backgroundColor = GetBkColor(hdc); + state.textAlign = GetTextAlign(hdc); + state.textCharacterExtra = GetTextCharacterExtra(hdc); + state.clipType = GetClipBox(hdc, &state.clipBox); + return state; + } + + bool sameGdiState(const GdiState& left, const GdiState& right) noexcept + { + return left.mapMode == right.mapMode && + left.viewportOrigin.x == right.viewportOrigin.x && + left.viewportOrigin.y == right.viewportOrigin.y && + left.windowOrigin.x == right.windowOrigin.x && + left.windowOrigin.y == right.windowOrigin.y && + left.viewportExtent.cx == right.viewportExtent.cx && + left.viewportExtent.cy == right.viewportExtent.cy && + left.windowExtent.cx == right.windowExtent.cx && + left.windowExtent.cy == right.windowExtent.cy && + left.graphicsMode == right.graphicsMode && + left.worldTransformAvailable == right.worldTransformAvailable && + (!left.worldTransformAvailable || xformEqual(left.worldTransform, right.worldTransform)) && + left.backgroundMode == right.backgroundMode && + left.textColor == right.textColor && + left.backgroundColor == right.backgroundColor && + left.textAlign == right.textAlign && + left.textCharacterExtra == right.textCharacterExtra && + left.clipType == right.clipType && + EqualRect(&left.clipBox, &right.clipBox) != FALSE; + } + + SurfaceFingerprint captureSurfaceFingerprint(HDC hdc, int width, int height) noexcept + { + SurfaceFingerprint fingerprint; + fingerprint.hdc = reinterpret_cast(hdc); + fingerprint.hbitmap = reinterpret_cast( + hdc ? GetCurrentObject(hdc, OBJ_BITMAP) : nullptr); + fingerprint.hfont = reinterpret_cast( + hdc ? GetCurrentObject(hdc, OBJ_FONT) : nullptr); + if (fingerprint.hfont) + { + fingerprint.logFontAvailable = GetObjectW( + reinterpret_cast(fingerprint.hfont), + sizeof(fingerprint.logFont), &fingerprint.logFont) == sizeof(fingerprint.logFont); + } + if (fingerprint.hbitmap) + { + BITMAP bitmap{}; + if (GetObject(reinterpret_cast(fingerprint.hbitmap), sizeof(bitmap), &bitmap) == + sizeof(bitmap)) + { + fingerprint.bitmapInfoAvailable = true; + fingerprint.bitmapWidth = bitmap.bmWidth; + fingerprint.bitmapHeight = bitmap.bmHeight; + fingerprint.planes = bitmap.bmPlanes; + fingerprint.bitsPerPixel = bitmap.bmBitsPixel; + fingerprint.stride = bitmap.bmWidthBytes; + } + DIBSECTION section{}; + fingerprint.dibSection = GetObject(reinterpret_cast(fingerprint.hbitmap), + sizeof(section), §ion) == sizeof(section); + } + const auto pixels = readPixels(hdc, width, height); + fingerprint.pixelDigest = fnv1a(pixels); + fingerprint.gdiState = captureGdiState(hdc); + return fingerprint; + } + + std::uint64_t capturePixelRegionDigest(HDC hdc, const RECT& region) noexcept + { + const int width = region.right - region.left; + const int height = region.bottom - region.top; + return fnv1a(readPixels(hdc, width, height, region.left, region.top)); + } + + PixelBounds findColorBounds(HDC hdc, const RECT& region, COLORREF color) noexcept + { + PixelBounds bounds; + for (int y = region.top; y < region.bottom; ++y) + { + for (int x = region.left; x < region.right; ++x) + { + if (GetPixel(hdc, x, y) != color) continue; + if (!bounds.present) + { + bounds.present = true; + bounds.left = bounds.right = x; + bounds.top = bounds.bottom = y; + } + else + { + bounds.left = (std::min)(bounds.left, x); + bounds.top = (std::min)(bounds.top, y); + bounds.right = (std::max)(bounds.right, x); + bounds.bottom = (std::max)(bounds.bottom, y); + } + ++bounds.pixelCount; + } + } + return bounds; + } + + PixelBounds findChangedBounds(HDC hdc, const RECT& region, COLORREF background) noexcept + { + PixelBounds bounds; + for (int y = region.top; y < region.bottom; ++y) + { + for (int x = region.left; x < region.right; ++x) + { + const COLORREF pixel = GetPixel(hdc, x, y); + if (pixel == CLR_INVALID || pixel == background) continue; + if (!bounds.present) + { + bounds.present = true; + bounds.left = bounds.right = x; + bounds.top = bounds.bottom = y; + } + else + { + bounds.left = (std::min)(bounds.left, x); + bounds.top = (std::min)(bounds.top, y); + bounds.right = (std::max)(bounds.right, x); + bounds.bottom = (std::max)(bounds.bottom, y); + } + ++bounds.pixelCount; + } + } + return bounds; + } + + bool saveHdcImage(HDC source, int width, int height, const std::filesystem::path& path) noexcept + { + if (!source || width <= 0 || height <= 0) return false; + try + { + IMAGE image(width, height); + HDC destination = GetImageHDC(&image); + if (!destination || BitBlt(destination, 0, 0, width, height, source, 0, 0, SRCCOPY) == FALSE) + return false; + saveimage(path.string().c_str(), &image); + return std::filesystem::exists(path) && std::filesystem::file_size(path) > 0; + } + catch (...) + { + return false; + } + } + + bool captureClientImage( + HWND hwnd, + const std::filesystem::path& path, + std::string* captureBackend) noexcept + { + if (!hwnd) return false; + RECT client{}; + if (!GetClientRect(hwnd, &client)) return false; + const int width = client.right - client.left; + const int height = client.bottom - client.top; + try + { + IMAGE image(width, height); + HDC destination = GetImageHDC(&image); + if (!destination) return false; + PatBlt(destination, 0, 0, width, height, BLACKNESS); + bool captured = false; + HDC clientDc = GetDC(hwnd); + if (clientDc) + { + captured = BitBlt(destination, 0, 0, width, height, + clientDc, 0, 0, SRCCOPY) != FALSE; + ReleaseDC(hwnd, clientDc); + } + if (captured) + { + if (captureBackend) *captureBackend = "client_getdc_bitblt"; + } + else + { + captured = PrintWindow(hwnd, destination, PW_CLIENTONLY) != FALSE; + if (captured && captureBackend) *captureBackend = "printwindow_fallback"; + } + if (!captured) return false; + saveimage(path.string().c_str(), &image); + return std::filesystem::exists(path) && std::filesystem::file_size(path) > 0; + } + catch (...) + { + return false; + } + } + + std::wstring makeUtf16(const std::vector& codePoints, bool& valid) + { + std::wstring text; + valid = true; + for (const std::uint32_t codePoint : codePoints) + { + if (codePoint > 0x10FFFFu || (codePoint >= 0xD800u && codePoint <= 0xDFFFu)) + { + valid = false; + continue; + } + if (codePoint <= 0xFFFFu) + text.push_back(static_cast(codePoint)); + else + { + const std::uint32_t adjusted = codePoint - 0x10000u; + text.push_back(static_cast(0xD800u + (adjusted >> 10))); + text.push_back(static_cast(0xDC00u + (adjusted & 0x3FFu))); + } + } + return text; + } + + std::string utf16UnitsJson(const std::wstring& text) + { + std::ostringstream output; + output << '['; + for (std::size_t index = 0; index < text.size(); ++index) + { + if (index) output << ','; + output << static_cast(static_cast(text[index])); + } + output << ']'; + return output.str(); + } + + std::string codePointsJson(const std::vector& codePoints) + { + std::ostringstream output; + output << '['; + for (std::size_t index = 0; index < codePoints.size(); ++index) + { + if (index) output << ','; + output << codePoints[index]; + } + output << ']'; + return output.str(); + } + + std::string jsonString(const std::string& value) + { + std::ostringstream output; + output << '"'; + for (const unsigned char character : value) + { + switch (character) + { + case '"': output << "\\\""; break; + case '\\': output << "\\\\"; break; + case '\b': output << "\\b"; break; + case '\f': output << "\\f"; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (character < 0x20u) + output << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(character) << std::dec; + else + output << static_cast(character); + break; + } + } + output << '"'; + return output.str(); + } + + std::string hexValue(std::uint64_t value) + { + std::ostringstream output; + output << "0x" << std::hex << std::uppercase << value; + return output.str(); + } + + void writeBoolean(std::ostream& output, bool value) + { + output << (value ? "true" : "false"); + } + + void writeRectJson(std::ostream& output, const RECT& rect) + { + output << "{\"left\":" << rect.left << ",\"top\":" << rect.top + << ",\"right\":" << rect.right << ",\"bottom\":" << rect.bottom << '}'; + } + + void writePointJson(std::ostream& output, const POINT& point) + { + output << "{\"x\":" << point.x << ",\"y\":" << point.y << '}'; + } + + void writeSizeJson(std::ostream& output, const SIZE& size) + { + output << "{\"cx\":" << size.cx << ",\"cy\":" << size.cy << '}'; + } + + void writeBoundsJson(std::ostream& output, const PixelBounds& bounds) + { + output << "{\"present\":"; + writeBoolean(output, bounds.present); + output << ",\"left\":" << bounds.left << ",\"top\":" << bounds.top + << ",\"right\":" << bounds.right << ",\"bottom\":" << bounds.bottom + << ",\"pixelCount\":" << bounds.pixelCount << '}'; + } + + void writeGdiStateJson(std::ostream& output, const GdiState& state) + { + output << "{\"mapMode\":" << state.mapMode << ",\"viewportOrigin\":"; + writePointJson(output, state.viewportOrigin); + output << ",\"windowOrigin\":"; + writePointJson(output, state.windowOrigin); + output << ",\"viewportExtent\":"; + writeSizeJson(output, state.viewportExtent); + output << ",\"windowExtent\":"; + writeSizeJson(output, state.windowExtent); + output << ",\"graphicsMode\":" << state.graphicsMode + << ",\"worldTransformAvailable\":"; + writeBoolean(output, state.worldTransformAvailable); + output << ",\"worldTransform\":{\"m11\":" << state.worldTransform.eM11 + << ",\"m12\":" << state.worldTransform.eM12 + << ",\"m21\":" << state.worldTransform.eM21 + << ",\"m22\":" << state.worldTransform.eM22 + << ",\"dx\":" << state.worldTransform.eDx + << ",\"dy\":" << state.worldTransform.eDy << "}" + << ",\"backgroundMode\":" << state.backgroundMode + << ",\"textColor\":" << state.textColor + << ",\"backgroundColor\":" << state.backgroundColor + << ",\"textAlign\":" << state.textAlign + << ",\"textCharacterExtra\":" << state.textCharacterExtra + << ",\"clipType\":" << state.clipType << ",\"clipBox\":"; + writeRectJson(output, state.clipBox); + output << '}'; + } + + void writeSurfaceFingerprintJson(std::ostream& output, const SurfaceFingerprint& fingerprint) + { + output << "{\"hdc\":" << jsonString(hexValue(fingerprint.hdc)) + << ",\"hbitmap\":" << jsonString(hexValue(fingerprint.hbitmap)) + << ",\"hfont\":" << jsonString(hexValue(fingerprint.hfont)) + << ",\"bitmapInfoAvailable\":"; + writeBoolean(output, fingerprint.bitmapInfoAvailable); + output << ",\"bitmapWidth\":" << fingerprint.bitmapWidth + << ",\"bitmapHeight\":" << fingerprint.bitmapHeight + << ",\"planes\":" << fingerprint.planes + << ",\"bitsPerPixel\":" << fingerprint.bitsPerPixel + << ",\"stride\":" << fingerprint.stride + << ",\"dibSection\":"; + writeBoolean(output, fingerprint.dibSection); + output << ",\"logFontAvailable\":"; + writeBoolean(output, fingerprint.logFontAvailable); + output << ",\"logFont\":{" + << "\"height\":" << fingerprint.logFont.lfHeight + << ",\"width\":" << fingerprint.logFont.lfWidth + << ",\"weight\":" << fingerprint.logFont.lfWeight + << ",\"italic\":" << static_cast(fingerprint.logFont.lfItalic) + << ",\"charset\":" << static_cast(fingerprint.logFont.lfCharSet) + << ",\"faceNameUtf16\":["; + for (std::size_t index = 0; + index < LF_FACESIZE && fingerprint.logFont.lfFaceName[index]; ++index) + { + if (index) output << ','; + output << static_cast(fingerprint.logFont.lfFaceName[index]); + } + output << "]}"; + output << ",\"pixelDigest\":" << jsonString(hexValue(fingerprint.pixelDigest)) + << ",\"gdiState\":"; + writeGdiStateJson(output, fingerprint.gdiState); + output << '}'; + } + + bool writeRawResult( + const Options& options, + const DpiConfiguration& dpiConfiguration, + const EnvironmentSnapshot& environment, + bool completed, + const std::vector& evidence, + const std::string& resultJson, + std::string& error) + { + const auto path = options.outputDirectory / "raw-result.json"; + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) + { + error = "Cannot open raw result: " + path.string(); + return false; + } + output << "{\n \"rawSchemaVersion\":1,\n \"mode\":" << jsonString(options.mode) + << ",\n \"completed\":"; + writeBoolean(output, completed); + output << ",\n \"dpiConfiguration\":{\"requested\":" + << jsonString(dpiConfiguration.requested) << ",\"attempted\":"; + writeBoolean(output, dpiConfiguration.attempted); + output << ",\"applied\":"; + writeBoolean(output, dpiConfiguration.applied); + output << ",\"error\":" << dpiConfiguration.error << "},\n \"environment\":{" + << "\"processId\":" << environment.processId + << ",\"threadId\":" << environment.threadId + << ",\"windowUnicode\":"; + writeBoolean(output, environment.windowUnicode); + output << ",\"dpiForWindow\":" << environment.dpiForWindow + << ",\"deviceDpiX\":" << environment.deviceDpiX + << ",\"deviceDpiY\":" << environment.deviceDpiY + << ",\"processAwareness\":" << jsonString(environment.processAwareness) + << ",\"threadAwareness\":" << jsonString(environment.threadAwareness) + << ",\"windowAwareness\":" << jsonString(environment.windowAwareness) + << ",\"osBuild\":" << jsonString(environment.osBuild) + << ",\"easyxVersion\":" << jsonString(environment.easyxVersion) + << ",\"clientRect\":"; + writeRectJson(output, environment.clientRect); + output << ",\"windowRect\":"; + writeRectJson(output, environment.windowRect); + output << "},\n \"evidenceFiles\":["; + for (std::size_t index = 0; index < evidence.size(); ++index) + { + if (index) output << ','; + output << "{\"path\":" << jsonString(evidence[index].relativePath) + << ",\"purpose\":" << jsonString(evidence[index].purpose) + << ",\"captureBackend\":" << jsonString(evidence[index].captureBackend) << '}'; + } + output << "],\n \"result\":" << resultJson << "\n}\n"; + if (!output.good()) + { + error = "Failed writing raw result: " + path.string(); + return false; + } + return true; + } +} diff --git a/tests/visual-probe/VisualProbeCommon.h b/tests/visual-probe/VisualProbeCommon.h new file mode 100644 index 0000000..4744cc6 --- /dev/null +++ b/tests/visual-probe/VisualProbeCommon.h @@ -0,0 +1,143 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace VisualProbe +{ + constexpr int CanvasWidth = 960; + constexpr int CanvasHeight = 720; + + struct Options + { + std::string mode; + std::filesystem::path outputDirectory; + std::string requestedDpiAwareness = "inherit"; + std::string expectedImeName = "unspecified"; + unsigned int autoCloseMilliseconds = 0; + }; + + struct DpiConfiguration + { + std::string requested = "inherit"; + bool attempted = false; + bool applied = false; + DWORD error = ERROR_SUCCESS; + }; + + struct EvidenceFile + { + std::string relativePath; + std::string purpose; + std::string captureBackend; + }; + + struct PixelBounds + { + bool present = false; + int left = 0; + int top = 0; + int right = 0; + int bottom = 0; + std::uint64_t pixelCount = 0; + }; + + struct GdiState + { + int mapMode = 0; + POINT viewportOrigin{}; + POINT windowOrigin{}; + SIZE viewportExtent{}; + SIZE windowExtent{}; + int graphicsMode = 0; + bool worldTransformAvailable = false; + XFORM worldTransform{}; + int backgroundMode = 0; + COLORREF textColor = CLR_INVALID; + COLORREF backgroundColor = CLR_INVALID; + UINT textAlign = GDI_ERROR; + int textCharacterExtra = 0; + RECT clipBox{}; + int clipType = ERROR; + }; + + struct SurfaceFingerprint + { + std::uintptr_t hdc = 0; + std::uintptr_t hbitmap = 0; + std::uintptr_t hfont = 0; + int bitmapWidth = 0; + int bitmapHeight = 0; + int planes = 0; + int bitsPerPixel = 0; + int stride = 0; + bool bitmapInfoAvailable = false; + bool dibSection = false; + bool logFontAvailable = false; + LOGFONTW logFont{}; + std::uint64_t pixelDigest = 0; + GdiState gdiState{}; + }; + + struct EnvironmentSnapshot + { + DWORD processId = 0; + DWORD threadId = 0; + bool windowUnicode = false; + UINT dpiForWindow = 0; + int deviceDpiX = 0; + int deviceDpiY = 0; + std::string processAwareness; + std::string threadAwareness; + std::string windowAwareness; + std::string osBuild; + std::string easyxVersion; + RECT clientRect{}; + RECT windowRect{}; + }; + + bool parseOptions(int argc, char** argv, Options& options, std::string& error); + DpiConfiguration applyRequestedDpiAwareness(const std::string& requested) noexcept; + EnvironmentSnapshot captureEnvironment(HWND hwnd) noexcept; + GdiState captureGdiState(HDC hdc) noexcept; + bool sameGdiState(const GdiState& left, const GdiState& right) noexcept; + SurfaceFingerprint captureSurfaceFingerprint(HDC hdc, int width, int height) noexcept; + std::uint64_t capturePixelRegionDigest(HDC hdc, const RECT& region) noexcept; + PixelBounds findColorBounds(HDC hdc, const RECT& region, COLORREF color) noexcept; + PixelBounds findChangedBounds(HDC hdc, const RECT& region, COLORREF background) noexcept; + bool saveHdcImage(HDC source, int width, int height, const std::filesystem::path& path) noexcept; + bool captureClientImage( + HWND hwnd, + const std::filesystem::path& path, + std::string* captureBackend = nullptr) noexcept; + std::wstring makeUtf16(const std::vector& codePoints, bool& valid); + std::string utf16UnitsJson(const std::wstring& text); + std::string codePointsJson(const std::vector& codePoints); + std::string jsonString(const std::string& value); + std::string hexValue(std::uint64_t value); + void writeBoolean(std::ostream& output, bool value); + void writeRectJson(std::ostream& output, const RECT& rect); + void writePointJson(std::ostream& output, const POINT& point); + void writeSizeJson(std::ostream& output, const SIZE& size); + void writeBoundsJson(std::ostream& output, const PixelBounds& bounds); + void writeGdiStateJson(std::ostream& output, const GdiState& state); + void writeSurfaceFingerprintJson(std::ostream& output, const SurfaceFingerprint& fingerprint); + bool writeRawResult( + const Options& options, + const DpiConfiguration& dpiConfiguration, + const EnvironmentSnapshot& environment, + bool completed, + const std::vector& evidence, + const std::string& resultJson, + std::string& error); + + int runWideTextProbe(const Options& options, const DpiConfiguration& dpiConfiguration); + int runDpiCoordinateProbe(const Options& options, const DpiConfiguration& dpiConfiguration); + int runImePositionProbe(const Options& options, const DpiConfiguration& dpiConfiguration); +} diff --git a/tests/visual-probe/VisualProbeMain.cpp b/tests/visual-probe/VisualProbeMain.cpp new file mode 100644 index 0000000..c99e706 --- /dev/null +++ b/tests/visual-probe/VisualProbeMain.cpp @@ -0,0 +1,29 @@ +#include "VisualProbeCommon.h" + +#include + +int main(int argc, char** argv) +{ + VisualProbe::Options options; + std::string error; + if (!VisualProbe::parseOptions(argc, argv, options, error)) + { + std::cerr << error << "\n" + << "Usage: EasyX.VisualProbe.exe --mode wide-text|dpi-coordinate|ime-position " + << "--output [--dpi-awareness inherit|unaware|system|per-monitor|per-monitor-v2] " + << "[--ime-name unspecified|MicrosoftPinyin|Sogou] [--auto-close-ms N]\n"; + return 2; + } + + const VisualProbe::DpiConfiguration dpiConfiguration = + VisualProbe::applyRequestedDpiAwareness(options.requestedDpiAwareness); + if (options.mode == "wide-text") + return VisualProbe::runWideTextProbe(options, dpiConfiguration); + if (options.mode == "dpi-coordinate") + return VisualProbe::runDpiCoordinateProbe(options, dpiConfiguration); + if (options.mode == "ime-position") + return VisualProbe::runImePositionProbe(options, dpiConfiguration); + + std::cerr << "Unsupported mode: " << options.mode << '\n'; + return 2; +} diff --git a/tests/visual-probe/WideTextProbe.cpp b/tests/visual-probe/WideTextProbe.cpp new file mode 100644 index 0000000..0e5edf5 --- /dev/null +++ b/tests/visual-probe/WideTextProbe.cpp @@ -0,0 +1,434 @@ +#include "VisualProbeCommon.h" + +#include +#include +#include +#include +#include + +namespace VisualProbe +{ + namespace + { + constexpr COLORREF MainBackground = RGB(22, 34, 56); + constexpr COLORREF WorkingBackground = RGB(64, 28, 46); + constexpr COLORREF EasyXMarker = RGB(30, 210, 235); + constexpr COLORREF WideTextColor = RGB(246, 224, 64); + + struct TextTest + { + const char* id; + std::vector codePoints; + }; + + struct TextApiResult + { + std::string id; + std::vector codePoints; + std::wstring text; + bool validUtf16 = false; + bool extentSuccess = false; + SIZE extent{}; + bool textOutSuccess = false; + int drawTextHeight = 0; + PixelBounds textOutBounds{}; + PixelBounds drawTextBounds{}; + }; + + struct WideCaseResult + { + std::string id; + std::string surfaceId; + std::string batchState; + SurfaceFingerprint before{}; + SurfaceFingerprint afterDraw{}; + SurfaceFingerprint afterFlush{}; + SurfaceFingerprint afterEnd{}; + SurfaceFingerprint afterEasyXFollowUp{}; + SurfaceFingerprint nonTargetBefore{}; + SurfaceFingerprint nonTargetAfterDraw{}; + bool targetChanged = false; + bool nonTargetUnchanged = false; + bool gdiStateRestored = false; + bool flushApplicable = false; + bool endApplicable = false; + bool explicitWorkingComposition = false; + bool wideContentPreservedAfterEasyXFollowUp = false; + bool evidenceComplete = true; + std::vector textResults; + }; + + std::vector fixedTests() + { + return { + { "ascii", { 0x0053, 0x0074, 0x0065, 0x006C, 0x006C, 0x0061, 0x0072, + 0x0058, 0x0020, 0x0030, 0x0043, 0x0020, 0x0050, 0x0072, 0x006F, 0x0062, 0x0065 } }, + { "cjk_bmp", { 0x661F, 0x57A3, 0x7ED8, 0x5236, 0x6D4B, 0x8BD5 } }, + { "supplementary", { 0x1F600 } }, + { "combining_nfd", { 0x0065, 0x0301 } }, + { "mixed", { 0x661F, 0x57A3, 0x0020, 0x0041, 0x1F600, 0x0042, + 0x0020, 0x0065, 0x0301 } } + }; + } + + void checkpoint(const Options& options, const std::string& value) noexcept + { + try + { + std::ofstream output(options.outputDirectory / "progress.log", + std::ios::binary | std::ios::app); + output << GetTickCount64() << ' ' << value << '\n'; + output.flush(); + } + catch (...) + { + } + } + + void clearSurface(IMAGE* image, COLORREF background) + { + SetWorkingImage(image); + setbkcolor(background); + cleardevice(); + } + + std::string normalizedRelative(const std::filesystem::path& path) + { + std::string value = path.generic_string(); + std::replace(value.begin(), value.end(), '\\', '/'); + return value; + } + + bool addSurfaceEvidence( + const Options& options, + HDC hdc, + const std::string& fileName, + const std::string& purpose, + std::vector& evidence) + { + const std::filesystem::path relative = std::filesystem::path("evidence") / fileName; + const bool saved = saveHdcImage(hdc, CanvasWidth, CanvasHeight, + options.outputDirectory / relative); + if (saved) + evidence.push_back({ normalizedRelative(relative), purpose, "surface_bitblt" }); + return saved; + } + + bool addClientEvidence( + const Options& options, + HWND hwnd, + const std::string& fileName, + const std::string& purpose, + std::vector& evidence) + { + const std::filesystem::path relative = std::filesystem::path("evidence") / fileName; + Sleep(30); + std::string backend; + const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend); + if (saved) + evidence.push_back({ normalizedRelative(relative), purpose, backend }); + return saved; + } + + void drawEasyXBaseline() + { + setfillcolor(EasyXMarker); + solidrectangle(20, 20, 170, 58); + settextcolor(EasyXMarker); + setbkmode(TRANSPARENT); + settextstyle(22, 0, "Microsoft YaHei UI"); + outtextxy(20, 68, "EasyX baseline"); + } + + std::vector drawWideText(HDC hdc, COLORREF background, bool& restored) + { + std::vector results; + const GdiState before = captureGdiState(hdc); + const int saved = SaveDC(hdc); + HFONT font = CreateFontW(-28, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, + DEFAULT_PITCH | FF_DONTCARE, L"Microsoft YaHei UI"); + HGDIOBJ previousFont = font ? SelectObject(hdc, font) : nullptr; + SetBkMode(hdc, TRANSPARENT); + SetTextColor(hdc, WideTextColor); + + const auto tests = fixedTests(); + for (std::size_t index = 0; index < tests.size(); ++index) + { + TextApiResult result; + result.id = tests[index].id; + result.codePoints = tests[index].codePoints; + result.text = makeUtf16(result.codePoints, result.validUtf16); + const int y = 120 + static_cast(index) * 105; + result.extentSuccess = GetTextExtentPoint32W(hdc, result.text.data(), + static_cast(result.text.size()), &result.extent) != FALSE; + result.textOutSuccess = TextOutW(hdc, 20, y, result.text.data(), + static_cast(result.text.size())) != FALSE; + RECT drawRect{ 430, y, 930, y + 70 }; + result.drawTextHeight = DrawTextW(hdc, result.text.data(), + static_cast(result.text.size()), &drawRect, + DT_LEFT | DT_TOP | DT_SINGLELINE | DT_NOPREFIX); + const RECT textOutRegion{ 15, y - 3, 410, y + 70 }; + const RECT drawTextRegion{ 425, y - 3, 940, y + 70 }; + result.textOutBounds = findChangedBounds(hdc, textOutRegion, background); + result.drawTextBounds = findChangedBounds(hdc, drawTextRegion, background); + results.push_back(std::move(result)); + } + + if (previousFont) SelectObject(hdc, previousFont); + if (saved) RestoreDC(hdc, saved); + if (font) DeleteObject(font); + const GdiState after = captureGdiState(hdc); + restored = saved != 0 && sameGdiState(before, after); + return results; + } + + WideCaseResult runCase( + const Options& options, + HWND hwnd, + IMAGE& working, + bool targetMain, + bool insideBatch, + std::vector& evidence) + { + WideCaseResult result; + result.surfaceId = targetMain ? "main" : "working"; + result.batchState = insideBatch ? "inside" : "outside"; + result.id = result.surfaceId + "_" + result.batchState; + result.flushApplicable = insideBatch; + result.endApplicable = insideBatch; + checkpoint(options, result.id + ":begin"); + + clearSurface(nullptr, MainBackground); + clearSurface(&working, WorkingBackground); + checkpoint(options, result.id + ":surfaces_cleared"); + IMAGE* targetImage = targetMain ? nullptr : &working; + SetWorkingImage(nullptr); + HDC mainDc = GetImageHDC(nullptr); + HDC workingDc = GetImageHDC(&working); + HDC targetDc = targetMain ? mainDc : workingDc; + HDC nonTargetDc = targetMain ? workingDc : mainDc; + const COLORREF targetBackground = targetMain ? MainBackground : WorkingBackground; + + result.before = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight); + result.nonTargetBefore = captureSurfaceFingerprint(nonTargetDc, CanvasWidth, CanvasHeight); + checkpoint(options, result.id + ":before_captured"); + SetWorkingImage(nullptr); + if (insideBatch) BeginBatchDraw(); + SetWorkingImage(targetImage); + drawEasyXBaseline(); + result.textResults = drawWideText(targetDc, targetBackground, result.gdiStateRestored); + checkpoint(options, result.id + ":draw_complete"); + result.afterDraw = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight); + result.nonTargetAfterDraw = captureSurfaceFingerprint(nonTargetDc, CanvasWidth, CanvasHeight); + checkpoint(options, result.id + ":after_draw_captured"); + result.targetChanged = result.before.pixelDigest != result.afterDraw.pixelDigest; + result.nonTargetUnchanged = + result.nonTargetBefore.pixelDigest == result.nonTargetAfterDraw.pixelDigest; + + result.evidenceComplete &= addSurfaceEvidence(options, targetDc, + result.id + "_after_draw_target.png", result.id + " target after draw", evidence); + result.evidenceComplete &= addSurfaceEvidence(options, nonTargetDc, + result.id + "_after_draw_non_target.png", result.id + " non-target after draw", evidence); + result.evidenceComplete &= addClientEvidence(options, hwnd, + result.id + "_after_draw_client.png", result.id + " client after draw", evidence); + checkpoint(options, result.id + ":after_draw_evidence"); + + if (insideBatch) + { + SetWorkingImage(nullptr); + FlushBatchDraw(); + result.afterFlush = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight); + result.evidenceComplete &= addClientEvidence(options, hwnd, + result.id + "_after_flush_client.png", result.id + " client after flush", evidence); + EndBatchDraw(); + checkpoint(options, result.id + ":batch_ended"); + result.afterEnd = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight); + result.evidenceComplete &= addClientEvidence(options, hwnd, + result.id + "_after_end_client.png", result.id + " client after end", evidence); + } + else + { + result.afterFlush = result.afterDraw; + result.afterEnd = result.afterDraw; + } + std::vector protectedRegionDigests; + for (std::size_t index = 0; index < result.textResults.size(); ++index) + { + const int y = 120 + static_cast(index) * 105; + const RECT protectedRegion{ 15, y - 3, 940, y + 70 }; + protectedRegionDigests.push_back( + capturePixelRegionDigest(targetDc, protectedRegion)); + } + + SetWorkingImage(targetImage); + setfillcolor(EasyXMarker); + solidrectangle(760, 20, 900, 55); + result.afterEasyXFollowUp = captureSurfaceFingerprint( + targetDc, CanvasWidth, CanvasHeight); + result.wideContentPreservedAfterEasyXFollowUp = true; + for (std::size_t index = 0; index < result.textResults.size(); ++index) + { + const int y = 120 + static_cast(index) * 105; + const RECT protectedRegion{ 15, y - 3, 940, y + 70 }; + result.wideContentPreservedAfterEasyXFollowUp = + result.wideContentPreservedAfterEasyXFollowUp && + protectedRegionDigests[index] == + capturePixelRegionDigest(targetDc, protectedRegion); + } + result.evidenceComplete &= addSurfaceEvidence(options, targetDc, + result.id + "_after_easyx_follow_up_target.png", + result.id + " target after EasyX follow-up drawing", evidence); + result.evidenceComplete &= addClientEvidence(options, hwnd, + result.id + "_after_easyx_follow_up_client.png", + result.id + " client after EasyX follow-up drawing", evidence); + checkpoint(options, result.id + ":easyx_follow_up_complete"); + + if (!targetMain) + { + SetWorkingImage(nullptr); + BeginBatchDraw(); + putimage(0, 0, &working); + result.evidenceComplete &= addClientEvidence(options, hwnd, + result.id + "_composition_before_flush.png", + result.id + " explicit putimage before flush", evidence); + FlushBatchDraw(); + result.evidenceComplete &= addClientEvidence(options, hwnd, + result.id + "_composition_after_flush.png", + result.id + " explicit putimage after flush", evidence); + EndBatchDraw(); + result.evidenceComplete &= addClientEvidence(options, hwnd, + result.id + "_composition_after_end.png", + result.id + " explicit putimage after end", evidence); + result.explicitWorkingComposition = true; + } + SetWorkingImage(nullptr); + checkpoint(options, result.id + ":end"); + return result; + } + + void writeTextResult(std::ostream& output, const TextApiResult& result) + { + output << "{\"testStringId\":" << jsonString(result.id) + << ",\"codePoints\":" << codePointsJson(result.codePoints) + << ",\"utf16Units\":" << utf16UnitsJson(result.text) + << ",\"isValidUtf16\":"; + writeBoolean(output, result.validUtf16); + output << ",\"getTextExtentPoint32W\":"; + writeBoolean(output, result.extentSuccess); + output << ",\"logicalExtent\":"; + writeSizeJson(output, result.extent); + output << ",\"textOutW\":"; + writeBoolean(output, result.textOutSuccess); + output << ",\"drawTextWHeight\":" << result.drawTextHeight + << ",\"textOutInkBounds\":"; + writeBoundsJson(output, result.textOutBounds); + output << ",\"drawTextInkBounds\":"; + writeBoundsJson(output, result.drawTextBounds); + output << '}'; + } + + void writeCaseResult(std::ostream& output, const WideCaseResult& result) + { + output << "{\"testCaseId\":" << jsonString(result.id) + << ",\"surfaceId\":" << jsonString(result.surfaceId) + << ",\"batchState\":" << jsonString(result.batchState) + << ",\"targetChanged\":"; + writeBoolean(output, result.targetChanged); + output << ",\"nonTargetUnchanged\":"; + writeBoolean(output, result.nonTargetUnchanged); + output << ",\"gdiStateRestored\":"; + writeBoolean(output, result.gdiStateRestored); + output << ",\"flushApplicable\":"; + writeBoolean(output, result.flushApplicable); + output << ",\"endApplicable\":"; + writeBoolean(output, result.endApplicable); + output << ",\"explicitWorkingComposition\":"; + writeBoolean(output, result.explicitWorkingComposition); + output << ",\"wideContentPreservedAfterEasyXFollowUp\":"; + writeBoolean(output, result.wideContentPreservedAfterEasyXFollowUp); + output << ",\"evidenceComplete\":"; + writeBoolean(output, result.evidenceComplete); + output << ",\"observations\":{\"afterDraw\":"; + writeSurfaceFingerprintJson(output, result.afterDraw); + output << ",\"afterFlush\":"; + writeSurfaceFingerprintJson(output, result.afterFlush); + output << ",\"afterEnd\":"; + writeSurfaceFingerprintJson(output, result.afterEnd); + output << ",\"afterEasyXFollowUp\":"; + writeSurfaceFingerprintJson(output, result.afterEasyXFollowUp); + output << "},\"before\":"; + writeSurfaceFingerprintJson(output, result.before); + output << ",\"nonTargetBefore\":"; + writeSurfaceFingerprintJson(output, result.nonTargetBefore); + output << ",\"nonTargetAfterDraw\":"; + writeSurfaceFingerprintJson(output, result.nonTargetAfterDraw); + output << ",\"textTests\":["; + for (std::size_t index = 0; index < result.textResults.size(); ++index) + { + if (index) output << ','; + writeTextResult(output, result.textResults[index]); + } + output << "]}"; + } + } + + int runWideTextProbe(const Options& options, const DpiConfiguration& dpiConfiguration) + { + checkpoint(options, "probe:before_initgraph"); + HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE); + if (!hwnd) + { + std::cerr << "initgraph failed\n"; + return 3; + } + checkpoint(options, "probe:after_initgraph"); + SetWindowTextA(hwnd, "StellarX 0C Wide Text Probe"); + ShowWindow(hwnd, SW_SHOWNORMAL); + SetForegroundWindow(hwnd); + Sleep(80); + const EnvironmentSnapshot environment = captureEnvironment(hwnd); + IMAGE working(CanvasWidth, CanvasHeight); + checkpoint(options, "probe:environment_and_working_ready"); + std::vector evidence; + std::vector cases; + cases.push_back(runCase(options, hwnd, working, true, false, evidence)); + cases.push_back(runCase(options, hwnd, working, true, true, evidence)); + cases.push_back(runCase(options, hwnd, working, false, false, evidence)); + cases.push_back(runCase(options, hwnd, working, false, true, evidence)); + checkpoint(options, "probe:all_cases_complete"); + + bool completed = true; + for (const auto& item : cases) + { + completed = completed && item.targetChanged && item.nonTargetUnchanged && + item.gdiStateRestored && item.wideContentPreservedAfterEasyXFollowUp && + item.evidenceComplete; + for (const auto& text : item.textResults) + { + completed = completed && text.validUtf16 && text.extentSuccess && + text.textOutSuccess && text.drawTextHeight > 0; + } + } + + std::ostringstream result; + result << "{\"caseCount\":" << cases.size() << ",\"cases\":["; + for (std::size_t index = 0; index < cases.size(); ++index) + { + if (index) result << ','; + writeCaseResult(result, cases[index]); + } + result << "]}"; + SetWorkingImage(nullptr); + closegraph(); + + std::string error; + if (!writeRawResult(options, dpiConfiguration, environment, completed, + evidence, result.str(), error)) + { + std::cerr << error << '\n'; + return 4; + } + return completed ? 0 : 5; + } +}