diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 5cac5272..0db97ec4 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -11,7 +11,8 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate ($node.Name -like "*-MultiProject*" -or $node.Name -cin @( "ConvertTo-SketchCanonicalJson", "Test-RetryableUiaError", - "Test-UpdateBannerClickTarget" + "Test-UpdateBannerClickTarget", + "Get-DynamicProjectCardSnapshotDecision" )) }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } } @@ -1907,7 +1908,42 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate Assert-MultiCase (Test-MultiProjectObservedSurface "project" $true ([ordered]@{ loopBars = @(); toolbars = @(); foreignWorkspaceFragmentCount = 0 }) $workspaceOwner 4242) "project-only marker shape rejected" - return "Two ordinary project rows, one exact project-card projection" + $expectedDynamic = @( + [ordered]@{ automationId = "canvas-card-1983941480823304696"; name = "UIA loop A" }, + [ordered]@{ automationId = "canvas-card-1983940381311676485"; name = "UIA loop B" } + ) + $dynamic = @( + [ordered]@{ automationId = $expectedDynamic[0].automationId; name = $expectedDynamic[0].name + processId = 4242; bounds = @(496,236,936,408) }, + [ordered]@{ automationId = $expectedDynamic[1].automationId; name = $expectedDynamic[1].name + processId = 4242; bounds = @(976,236,1416,408) } + ) + $graphBounds = @(440,68,2400,1300) + Assert-MultiCase ((Get-DynamicProjectCardSnapshotDecision "project" $dynamic $expectedDynamic 4242 $graphBounds).ready) ` + "exact DPI192 project-card snapshot was rejected" + $empty = Copy-MultiProjectValue $dynamic + $empty[1].bounds = @(0,0,0,0) + $emptyDecision = Get-DynamicProjectCardSnapshotDecision "project" $empty $expectedDynamic 4242 $graphBounds + Assert-MultiCase (-not $emptyDecision.ready -and $emptyDecision.failure -ceq "bounds-empty" -and + $emptyDecision.cards[1].automationId -ceq $expectedDynamic[1].automationId) ` + "identity-present DPI192 empty bounds did not fail exactly" + foreach ($mutation in @("missing", "foreign", "duplicate", "order", "wrong-surface", "outside-viewport", "overlap")) { + $candidate = Copy-MultiProjectValue $dynamic + $surface = "project" + switch ($mutation) { + "missing" { $candidate = @($candidate[0]) } + "foreign" { $candidate[1].processId = 7777 } + "duplicate" { $candidate[1].automationId = $candidate[0].automationId } + "order" { $candidate = @($candidate[1], $candidate[0]) } + "wrong-surface" { $surface = "overview" } + "outside-viewport" { $candidate[1].bounds = @(2600,236,3040,408) } + "overlap" { $candidate[1].bounds = @(700,236,1140,408) } + } + Assert-MultiCase (-not (Get-DynamicProjectCardSnapshotDecision ` + $surface $candidate $expectedDynamic 4242 $graphBounds).ready) ` + "dynamic project-card snapshot accepted $mutation" + } + return "Exact DPI192 frozen card snapshot; identity-present empty bounds and missing/foreign/duplicate/order/surface/viewport/overlap negatives" } foreach ($mutation in @("extra-card", "extra-project", "duplicate-card", "duplicate-project", "foreign-card-pid", "foreign-project-pid", "foreign-card-count", "foreign-project-count")) { @@ -4211,6 +4247,13 @@ Start-Sleep -Seconds 60 "projectBounds", "sidebarBounds", "source-derived, not a UIA caption")) { if (-not $uiaLiveGateSource.Contains($required)) { throw "RED: multi-project live evidence lacks $required" } } + if (-not $uiaLiveGateSource.Contains("function Get-DynamicProjectCardSnapshotDecision") -or + -not $uiaLiveGateSource.Contains('Get-DynamicProjectCardSnapshotDecision "project"') -or + -not $uiaLiveGateSource.Contains("function Get-DynamicProjectCardCapture") -or + -not $uiaLiveGateSource.Contains("UIA_DYNAMIC_PROJECT_CARD_EVIDENCE=") -or + -not $uiaLiveGateSource.Contains('"fit-canvas"')) { + throw "RED: dynamic project cards are not frozen after bounded Fit Canvas normalization and reacquisition" + } if (-not $uiaLiveGateSource.Contains('$matches = Test-MultiProjectObservedSurface $surface $matches $workspaceProjection $selectedOwner $multiProcess.Id') -or $uiaLiveGateSource.Contains('if ($null -ne $loopBar) { $matches = $matches -and')) { throw "RED: workspace observation still accepts a project-only projection without mandatory owned loop-bar and toolbar proof" diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index f8685ca0..42ee3c81 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -2570,6 +2570,117 @@ function Wait-ForGraphChildren( return [pscustomobject]@{ Graph = $liveGraph; Items = $observed; Children = $children } } +function Get-DynamicProjectCardSnapshotDecision( + [string] $surface, + [object[]] $cards, + [object[]] $expected, + [int] $processId, + [double[]] $graphBounds +) { + $reject = { + param([string] $failure) + return [ordered]@{ ready = $false; failure = $failure; cards = @($cards) } + } + if ($surface -cne "project") { return & $reject "wrong-surface" } + if ($processId -le 0) { return & $reject "invalid-process" } + if ($cards -isnot [array] -or $expected -isnot [array] -or + $cards.Count -ne $expected.Count -or $expected.Count -ne 2) { + return & $reject "card-count" + } + if ($graphBounds -isnot [array] -or $graphBounds.Count -ne 4) { + return & $reject "graph-bounds" + } + foreach ($coordinate in $graphBounds) { + if (($coordinate -isnot [int] -and $coordinate -isnot [long] -and + $coordinate -isnot [double] -and $coordinate -isnot [single] -and + $coordinate -isnot [decimal]) -or [double]::IsNaN([double]$coordinate) -or + [double]::IsInfinity([double]$coordinate)) { + return & $reject "graph-bounds" + } + } + if ($graphBounds[2] -le $graphBounds[0] -or $graphBounds[3] -le $graphBounds[1]) { + return & $reject "graph-bounds" + } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + for ($index = 0; $index -lt $cards.Count; $index++) { + $card = $cards[$index] + $target = $expected[$index] + if ($card -isnot [Collections.IDictionary] -or $card.Count -ne 4 -or + $target -isnot [Collections.IDictionary] -or $target.Count -ne 2) { + return & $reject "card-shape" + } + if (@($card.Keys | Where-Object { + @("automationId", "name", "processId", "bounds") -cnotcontains $_ + }).Count -ne 0 -or + @($target.Keys | Where-Object { + @("automationId", "name") -cnotcontains $_ + }).Count -ne 0) { + return & $reject "card-shape" + } + if ($card.automationId -isnot [string] -or -not $seen.Add($card.automationId)) { + return & $reject "duplicate-card" + } + if ($card.automationId -cne $target.automationId -or $card.name -cne $target.name) { + return & $reject "card-order" + } + if (($card.processId -isnot [int] -and $card.processId -isnot [long]) -or + $card.processId -ne $processId) { + return & $reject "foreign-card" + } + if ($card.bounds -isnot [array] -or $card.bounds.Count -ne 4) { + return & $reject "bounds-shape" + } + foreach ($coordinate in $card.bounds) { + if (($coordinate -isnot [int] -and $coordinate -isnot [long] -and + $coordinate -isnot [double] -and $coordinate -isnot [single] -and + $coordinate -isnot [decimal]) -or [double]::IsNaN([double]$coordinate) -or + [double]::IsInfinity([double]$coordinate)) { + return & $reject "bounds-shape" + } + } + if ($card.bounds[2] -le $card.bounds[0] -or $card.bounds[3] -le $card.bounds[1]) { + return & $reject "bounds-empty" + } + if ([Math]::Min($card.bounds[2], $graphBounds[2]) -le + [Math]::Max($card.bounds[0], $graphBounds[0]) -or + [Math]::Min($card.bounds[3], $graphBounds[3]) -le + [Math]::Max($card.bounds[1], $graphBounds[1])) { + return & $reject "outside-viewport" + } + } + $first = $cards[0].bounds + $second = $cards[1].bounds + if ([Math]::Min($first[2], $second[2]) -gt [Math]::Max($first[0], $second[0]) -and + [Math]::Min($first[3], $second[3]) -gt [Math]::Max($first[1], $second[1])) { + return & $reject "overlap" + } + return [ordered]@{ ready = $true; failure = $null; cards = @($cards) } +} + +function Get-DynamicProjectCardCapture( + [System.Windows.Automation.AutomationElement] $element +) { + $request = New-Object System.Windows.Automation.CacheRequest + $request.TreeScope = [System.Windows.Automation.TreeScope]::Element + $request.Add([System.Windows.Automation.AutomationElement]::ProcessIdProperty) + $request.Add([System.Windows.Automation.AutomationElement]::AutomationIdProperty) + $request.Add([System.Windows.Automation.AutomationElement]::NameProperty) + $request.Add([System.Windows.Automation.AutomationElement]::BoundingRectangleProperty) + $cached = $element.GetUpdatedCache($request) + $bounds = $cached.Cached.BoundingRectangle + return [ordered]@{ + automationId = [string]$cached.Cached.AutomationId + name = [string]$cached.Cached.Name + processId = [int]$cached.Cached.ProcessId + bounds = @( + [double]$bounds.Left, + [double]$bounds.Top, + [double]$bounds.Right, + [double]$bounds.Bottom + ) + } +} + # A modal teardown (e.g. dismissing the update-offer dialog via SendCommand) # rebuilds the shell's fragment tree, and a $root captured before that teardown # can become a permanently dead reference - not a transient blip that @@ -6507,28 +6618,101 @@ try { ForEach-Object { $_.Current.AutomationId } | Where-Object { $_ }) $null = Assert-FragmentLinks $loops $rawWalker $loopChildIds "RawView Loops" $null = Assert-FragmentLinks $loops $controlWalker $loopChildIds "ControlView Loops" - $projectCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { - $_.Current.AutomationId -match '^canvas-card-' -and - $_.Current.Name -in @("UIA loop A", "UIA loop B") + # Canvas cards are not virtualized sidebar rows and intentionally do not expose + # ScrollItem. Normalize the camera through the source-owned Fit Canvas action, + # then reacquire the graph and freeze one exact two-card publication before + # reading geometry. Holding the pre-sidebar graph/card handles here samples an + # arbitrary later provider state, which is the DPI192 empty-bounds cutoff this + # phase is meant to prevent. + $dynamicProjectNodeIds = @( + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ) + $dynamicProjectExpected = @( + [ordered]@{ + automationId = Get-MultiProjectAutomationId "project-card" $fixtureProjectPath $dynamicProjectNodeIds[0] + name = "UIA loop A" + }, + [ordered]@{ + automationId = Get-MultiProjectAutomationId "project-card" $fixtureProjectPath $dynamicProjectNodeIds[1] + name = "UIA loop B" + } + ) + $fitProjectCanvas = Find-FragmentByIdWithRetry $root "fit-canvas" $rawWalker + Require (($null -ne $fitProjectCanvas) -and + ($fitProjectCanvas.Current.ProcessId -eq $process.Id) -and + ($fitProjectCanvas.Current.Name -ceq "Fit canvas")) ` + "dynamic project card normalization omitted the exact owned Fit Canvas action" + $fitProjectCanvas.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern + ).Invoke() + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + $dynamicProjectDecision = $null + $dynamicProjectChildren = @() + $dynamicProjectElements = @() + $graph = $null + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $graph = Find-FragmentById $root "graph" $rawWalker + if ($null -ne $graph) { + $dynamicProjectChildren = @(Get-DirectChildren $graph $rawWalker) + $dynamicProjectElements = @($dynamicProjectChildren | Where-Object { + ($_.Current.AutomationId -cin @($dynamicProjectExpected | ForEach-Object { $_.automationId })) -or + ($_.Current.Name -cin @($dynamicProjectExpected | ForEach-Object { $_.name })) + }) + $dynamicProjectCaptures = @($dynamicProjectElements | ForEach-Object { + Get-DynamicProjectCardCapture $_ + }) + $graphBounds = $graph.Current.BoundingRectangle + $dynamicProjectDecision = Get-DynamicProjectCardSnapshotDecision "project" ` + $dynamicProjectCaptures $dynamicProjectExpected $process.Id @( + [double]$graphBounds.Left, + [double]$graphBounds.Top, + [double]$graphBounds.Right, + [double]$graphBounds.Bottom + ) + if ($dynamicProjectDecision.ready) { break } + } + Start-Sleep -Milliseconds 100 + $root = Wait-ForRootReconnect $process @($rawWalker, $controlWalker) + } + Require (($null -ne $dynamicProjectDecision) -and $dynamicProjectDecision.ready) ` + "dynamic project cards did not converge after bounded Fit Canvas normalization/reacquisition: $($dynamicProjectDecision.failure)" + $projectCards = @($dynamicProjectExpected | ForEach-Object { + $expectedId = $_.automationId + @($dynamicProjectElements | Where-Object { + $_.Current.AutomationId -ceq $expectedId + })[0] }) - Require (($projectCards.Count -eq 2) -and - ((@($projectCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) "Graph did not expose synchronized project cards" + $projectCardIds = @($dynamicProjectDecision.cards | ForEach-Object { $_.automationId }) + Write-Host ("UIA_DYNAMIC_PROJECT_CARD_EVIDENCE=" + ([ordered]@{ + surface = "project" + normalizedBy = "fit-canvas" + attempts = $attempt + 1 + processId = $process.Id + graphBounds = @( + [double]$graphBounds.Left, + [double]$graphBounds.Top, + [double]$graphBounds.Right, + [double]$graphBounds.Bottom + ) + cards = $dynamicProjectDecision.cards + exactOrder = $projectCardIds + nonOverlapping = $true + viewportIntersectionRequired = $true + } | ConvertTo-Json -Compress -Depth 5)) foreach ($card in $projectCards) { - Require (($card.Current.BoundingRectangle.Width -gt 0) -and - ($card.Current.BoundingRectangle.Height -gt 0)) "dynamic project card has empty bounds" $null = $card.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) $null = $card.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) } - $projectCardIds = @($projectCards | ForEach-Object { $_.Current.AutomationId }) - $attentionAction = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $attentionAction = @($dynamicProjectChildren | Where-Object { $_.Current.AutomationId -match '^attention-action-' -and $_.Current.Name -eq "Reply" }) | Select-Object -First 1 Require ($null -ne $attentionAction) "NEEDS YOU card omitted its reason-specific Reply action" Require (($attentionAction.Current.BoundingRectangle.Width -gt 0) -and ($attentionAction.Current.BoundingRectangle.Height -gt 0)) "Reply attention action had empty bounds" $null = $attentionAction.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) - $reclaimOffer = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.Name -eq "Reclaim" }) | Select-Object -First 1 - $keepOffer = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.Name -eq "Keep" }) | Select-Object -First 1 + $reclaimOffer = @($dynamicProjectChildren | Where-Object { $_.Current.Name -eq "Reclaim" }) | Select-Object -First 1 + $keepOffer = @($dynamicProjectChildren | Where-Object { $_.Current.Name -eq "Keep" }) | Select-Object -First 1 Require ($null -ne $reclaimOffer) "resolved card with a reclaimable worktree omitted its Reclaim descendant" Require ($null -ne $keepOffer) "resolved card with a reclaimable worktree omitted its Keep descendant" Require (($reclaimOffer.Current.BoundingRectangle.Width -gt 0) -and @@ -6537,7 +6721,7 @@ try { ($keepOffer.Current.BoundingRectangle.Height -gt 0)) "Keep descendant had empty bounds" $null = $reclaimOffer.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) $null = $keepOffer.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) - $railReviews = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $railReviews = @($dynamicProjectChildren | Where-Object { $_.Current.AutomationId -match '^canvas-attention-review-' }) Require ($railReviews.Count -eq 1) "attention rail omitted its single Review button"