feat(diagnostic): 完成阶段 0C 视觉与 IME 坐标探测
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user