From 2496738bc43c2172289aa2ce1c46c04cf6b4648a Mon Sep 17 00:00:00 2001 From: Andrew Nichols Date: Thu, 6 Aug 2026 18:36:05 -0500 Subject: [PATCH 1/2] Issue #644: complete integrated hosted acceptance evidence --- ...-644-compare-facilities-acceptance-plan.md | 81 +++++ scripts/capture-hosted-ui-evidence.ps1 | 241 +++++++++++++-- .../run-and-capture-hosted-ui-evidence.ps1 | 5 +- src/ccld_complaints/hosted_app/reviewer_ui.py | 22 +- .../hosted_app/source_derived_reads.py | 5 +- src/ccld_complaints/hosted_app/ui_shell.py | 102 ++++++ ...sted_facility_intelligence_query_shapes.py | 2 +- tests/unit/test_hosted_facility_priorities.py | 219 ++++++++----- tests/unit/test_hosted_reviewer_ui.py | 50 +++ tests/unit/test_hosted_ui_evidence_capture.py | 292 ++++++++++++++++++ 10 files changed, 900 insertions(+), 119 deletions(-) create mode 100644 docs/planning/issue-644-compare-facilities-acceptance-plan.md diff --git a/docs/planning/issue-644-compare-facilities-acceptance-plan.md b/docs/planning/issue-644-compare-facilities-acceptance-plan.md new file mode 100644 index 00000000..c5f2e975 --- /dev/null +++ b/docs/planning/issue-644-compare-facilities-acceptance-plan.md @@ -0,0 +1,81 @@ +# Issue #644 Compare Facilities acceptance-evidence plan + +## Scope and boundary + +This mode captures only the local `fixture-demo` hosted application. It reuses +the established #642 interaction and pagination capture, #643 drill-down and +return-context contracts, #648 acceptance-review structure, #670 packet +accounting, and #671 runtime-event ledger. It does not create another launcher, +browser harness, packet format, validator, production data path, or deployment. +It is not deployed-host acceptance evidence. + +## Required capture inventory + +The packet records Complaint Patterns, Licensing and Visits, Complaint Activity +Over Time, Facility Overview, Complaint Overview/detail, and their canonical +return continuity. It includes populated, filtered-empty, limited/partial, +source-unavailable, not-loaded, controlled-error, selected-facility, pagination, +typeahead, multi-select, chips, hover, active, keyboard-focus, stress, and print +states. Loading is `NOT APPLICABLE` only because no deterministic loading fixture +exists, no loading state is fabricated, and application behavior is not changed +solely to create evidence. + +## Viewports and interaction evidence + +Required viewports are desktop 1440x1200, measured Surface 1189x671, 500x900, +mobile 390x844, and 1280x900 at observed scale 2.0. The Surface visual-viewport +scale and DPR are derived from its captured browser-state evidence, not supplied +as an external display observation. Pagination must prove disabled Previous on the first page, +both controls enabled on the middle page, disabled Next on the final page, +keyboard focus for both controls, and retained filters/context. Hover and active +evidence uses the established CDP input path. + +## Automated gates and human decisions + +Before packet accounting, file-index finalization, or ZIP publication, the +Issue #644 coordinator requires a nonempty rendered print PDF with one through +four pages and mandatory density results: 12 desktop, 16 Surface/500px, and 24 +mobile/200-percent viewport heights. Each density result records its route, +viewport and document heights, computed ratio, governed ceiling, and outcome. +Captured browser-state viewport metadata is authoritative; conflicting +hard-coded display metadata fails closed. The runtime ledger preserves raw +console and network inventories and permits only the exact Cloudflare beacon +optional-telemetry classification. Packet accounting reconciles physical files, +ZIP entries, manifest, file index, and reported count only after these automated +visual prerequisites pass. + +There is one versioned `hosted-ui-acceptance-v1` acceptance record and two +separate human artifacts: independent visual review and owner acceptance. No +standalone Figma matrix artifact is required. Approved-design requirement +classifications belong in the acceptance record when independent review is +completed. Before then, the manifest acceptance state, pending human templates, +and absence of a completed acceptance record truthfully represent the +pre-review state. Automation must not assign a visual PASS or owner decision. +The retained `20260806-003117Z-issue-644-local` packet is diagnostic only; +human review must not begin until automated print, density, and viewport-metadata +prerequisites pass. + +The retained diagnostic print used the correct unfiltered, current-page Compare +Facilities route, but printed its 25 normal screen cards as a full vertical +stack and retained the duplicate bottom result orientation. That produced 20 +pages, including a nearly empty trailing page. The print-only correction keeps +the page identity, active scope, facility identity, complaint, source, and +status content while compacting current-page cards into concise, single-flow +print rows and suppressing the redundant bottom orientation. It does not alter normal +screen markup, pagination, filtering, or fixture scope. + +The separate screen-density investigation found no combined Issue #642/#643 +state or stale measurement: the populated desktop route rendered one normal +25-facility page, whose main content occupied nearly the entire measured +document. The product correction keeps the existing card content, controls, +cursor pagination, routes, and evidence contract, but reduces the normal +Compare Facilities page window to fourteen complete facility rows. This uses +the existing pagination mechanism instead of hiding content or weakening any +density ceiling; the remaining matching facilities stay available through the +same descriptive Previous and Next controls. + +## Stop conditions + +Stop before publication if a required state cannot be truthfully captured, any +automated packet gate fails, the fixture scope escapes local demo mode, the +working-tree file boundary changes, or the required validation does not pass. diff --git a/scripts/capture-hosted-ui-evidence.ps1 b/scripts/capture-hosted-ui-evidence.ps1 index 4d5677ba..7d222e2d 100644 --- a/scripts/capture-hosted-ui-evidence.ps1 +++ b/scripts/capture-hosted-ui-evidence.ps1 @@ -146,6 +146,8 @@ param( [switch]$Issue643, + [switch]$Issue644, + [switch]$Issue655, [switch]$Issue655Rehearsal, @@ -168,6 +170,9 @@ elseif ($Issue642) { elseif ($Issue643) { "Focused Issue #643 local fixture evidence for the Complaint Patterns facility-card hierarchy, canonical contributor navigation, responsive reflow, native zoom, and populated print." } +elseif ($Issue644) { + "Integrated Issue #644 local fixture evidence composed from the established Compare Facilities browser operations, states, responsive routes, print, and packet contracts." +} elseif ($Issue655) { "Focused Issue #655 local fixture evidence for the bounded Review next region, canonical inventory preservation, responsive reflow, native zoom, and print suppression." } @@ -2065,6 +2070,78 @@ function Invoke-Issue642ExplicitEvidenceCapture { Invoke-Issue642FocusEvidenceCapture -Session $Session -BaseUrl $BaseUrl -ScreenshotRoot $ScreenshotRoot } +function Get-Issue642PaginationPosition { + param([object]$Session) + $positionText = [string](Invoke-CdpEvaluate -Session $Session -Expression "document.querySelector('#facility-intelligence-position')?.innerText || ''") + if ($positionText -notmatch 'Showing\s+(\d+)\s*[–-]\s*(\d+)\s+of\s+(\d+)\s+facilities') { + throw "Issue #642 pagination position is missing or invalid: '$positionText'." + } + return [pscustomobject]@{ + text = $positionText + first = [int]$Matches[1] + last = [int]$Matches[2] + total = [int]$Matches[3] + } +} + +function Get-Issue642PaginationPage { + param([object]$Session) + $position = Get-Issue642PaginationPosition -Session $Session + $identities = @( + Invoke-CdpEvaluate -Session $Session -Expression 'Array.from(document.querySelectorAll("#facility-intelligence-results li[id^=\"facility-intelligence-result-\"]")).map((entry)=>entry.id)' + ) + $controls = Invoke-CdpEvaluate -Session $Session -Expression '(function(){return {previous:!!document.querySelector("a.facility-pagination__control[aria-label^=\"Previous facilities\"]"),next:!!document.querySelector("a.facility-pagination__control[aria-label^=\"Next facilities\"]")};})()' + return [pscustomobject]@{ + first = $position.first + last = $position.last + total = $position.total + identities = $identities + previousEnabled = [bool]$controls.previous + nextEnabled = [bool]$controls.next + url = [string](Invoke-CdpEvaluate -Session $Session -Expression 'location.href') + } +} + +function Test-Issue642PaginationReconciliation { + param([object[]]$Pages) + $pages = @($Pages) + if ($pages.Count -lt 2) { throw 'Issue #642 pagination reconciliation requires at least a first and final page.' } + $total = [int]$pages[0].total + $pageSize = [int]$pages[0].last - [int]$pages[0].first + 1 + if ($total -le 0) { throw "Issue #642 pagination reconciliation requires a positive total count; observed $total." } + if ($pageSize -le 0) { throw "Issue #642 pagination reconciliation requires a positive page size; observed $pageSize." } + $expectedPageCount = [math]::Ceiling([double]$total / [double]$pageSize) + if ($pages.Count -ne $expectedPageCount) { throw "Issue #642 pagination reconciliation expected $expectedPageCount pages for total=$total and page_size=$pageSize; observed $($pages.Count)." } + $allIdentities = @() + $context = [uri]$pages[0].url + for ($index = 0; $index -lt $pages.Count; $index++) { + $page = $pages[$index] + if ([int]$page.total -ne $total) { throw "Issue #642 pagination total changed between pages; expected $total, observed $($page.total)." } + $expectedFirst = if ($index -eq 0) { 1 } else { [int]$pages[$index - 1].last + 1 } + if ([int]$page.first -ne $expectedFirst -or [int]$page.last -lt [int]$page.first) { throw "Issue #642 pagination position skipped or overlapped at page $($index + 1): observed $($page.first)-$($page.last)." } + $expectedCount = if ($index -eq $pages.Count - 1) { $remainder = $total % $pageSize; if ($remainder -eq 0) { $pageSize } else { $remainder } } else { $pageSize } + if (@($page.identities).Count -ne $expectedCount -or ([int]$page.last - [int]$page.first + 1) -ne $expectedCount) { throw "Issue #642 pagination row count mismatch at page $($index + 1): expected $expectedCount, observed $(@($page.identities).Count)." } + $expectedPrevious = $index -gt 0 + $expectedNext = $index -lt ($pages.Count - 1) + if ([bool]$page.previousEnabled -ne $expectedPrevious -or [bool]$page.nextEnabled -ne $expectedNext) { throw "Issue #642 pagination control state mismatch at page $($index + 1)." } + $pageUri = [uri]$page.url + if ($pageUri.AbsolutePath -ne $context.AbsolutePath -or $pageUri.Query -notmatch 'facility_type=430' -or $pageUri.Query -notmatch 'facility_type=733' -or $pageUri.Query -notmatch 'start_date=1900-01-01') { throw "Issue #642 pagination filter or comparison context changed at page $($index + 1)." } + $allIdentities += @($page.identities) + } + $uniqueIdentities = @($allIdentities | Select-Object -Unique) + if ($uniqueIdentities.Count -ne $total) { throw "Issue #642 pagination identity reconciliation failed: total=$total; unique=$($uniqueIdentities.Count); duplicates=$($allIdentities.Count - $uniqueIdentities.Count)." } + return [ordered]@{ + page_size = $pageSize + total = $total + page_count = $pages.Count + page_counts = @($pages | ForEach-Object { @($_.identities).Count }) + unique_count = $uniqueIdentities.Count + duplicate_count = $allIdentities.Count - $uniqueIdentities.Count + missing_count = $total - $uniqueIdentities.Count + pages = $pages + } +} + function Invoke-Issue642OperatedInteractionCapture { param([object]$Session, [string]$BaseUrl, [string]$ScreenshotPath) $states = [System.Collections.ArrayList]::new() @@ -2158,21 +2235,36 @@ function Invoke-Issue642OperatedInteractionCapture { Invoke-CdpNativeText -Session $Session -Text "01011900" Invoke-CdpClickSelector -Session $Session -Selector "form.compact-filter-form button[type='submit']" Wait-CdpCondition -Session $Session -Expression "location.search.includes('facility_type=430') && location.search.includes('facility_type=733') && location.search.includes('start_date=1900-01-01') && document.querySelector('#facility-intelligence-position').innerText.includes('Showing 1')" -Description "pagination origin repeated and scalar filters" - $firstPageUrl = [string](Invoke-CdpEvaluate -Session $Session -Expression "location.href") - $firstPageIdentities = @(Invoke-CdpEvaluate -Session $Session -Expression 'Array.from(document.querySelectorAll("#facility-intelligence-results li[id^=\"facility-intelligence-result-\"]")).map((entry)=>entry.id)') - if ($firstPageIdentities.Count -ne 25) { throw "Issue #642 pagination origin must render the governed first-page count of 25; observed $($firstPageIdentities.Count)." } + $paginationPages = [System.Collections.ArrayList]::new() + $firstPage = Get-Issue642PaginationPage -Session $Session + [void]$paginationPages.Add($firstPage) + $snapshot = & $before + & $record "issue642-pagination-first-page" @("verify rendered first page", "verify Previous disabled and Next enabled") $snapshot.url $snapshot.focus "pagination controls" + for ($pageNumber = 2; $pageNumber -le 4; $pageNumber++) { + $snapshot = & $before + Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Next facilities']" + Wait-CdpCondition -Session $Session -Expression "location.search.includes('continuation=') && document.querySelector('#facility-intelligence-position').innerText.includes('Showing') && document.activeElement === document.querySelector('#facility-intelligence-results')" -Description "operated Next page $pageNumber and result focus" + $page = Get-Issue642PaginationPage -Session $Session + [void]$paginationPages.Add($page) + $stateId = if ($pageNumber -eq 4) { 'issue642-pagination-last-page' } elseif ($pageNumber -eq 3) { 'issue642-pagination-page-3' } else { 'issue642-pagination-next' } + & $record $stateId @("click rendered Next control", "verify page $pageNumber range and pagination semantics") $snapshot.url $snapshot.focus "a.facility-pagination__control[aria-label^='Next facilities']" + if ($pageNumber -eq 2) { + & $record "issue642-pagination-page-2" @("reconcile the first middle page", "preserve the existing #642 operated-state contract") $snapshot.url $snapshot.focus "rendered Next control" + } + } + $paginationReconciliation = Test-Issue642PaginationReconciliation -Pages @($paginationPages) + $states[$states.Count - 1] | Add-Member -NotePropertyName page_identity_reconciliation -NotePropertyValue $paginationReconciliation $snapshot = & $before - Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Next facilities']" - Wait-CdpCondition -Session $Session -Expression "location.search.includes('continuation=') && document.querySelector('#facility-intelligence-position').innerText.includes('Showing 26') && document.activeElement === document.querySelector('#facility-intelligence-results')" -Description "operated Next page and result focus" - & $record "issue642-pagination-next" @("click rendered Next control", "wait continuation and result focus") $snapshot.url $snapshot.focus "a.facility-pagination__control[aria-label^='Next facilities']" + Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Previous facilities']" + Wait-CdpCondition -Session $Session -Expression "document.querySelector('#facility-intelligence-position').innerText.includes('Showing')" -Description "operated Previous from final page" + & $record "issue642-pagination-middle-page" @("click rendered Previous control", "verify a true middle page with both pagination controls enabled") $snapshot.url $snapshot.focus "a.facility-pagination__control[aria-label^='Previous facilities']" + + $snapshot = & $before + Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Previous facilities']" + Wait-CdpCondition -Session $Session -Expression "document.querySelector('#facility-intelligence-position').innerText.includes('Showing')" -Description "operated Previous to page two" $pageTwoUrl = [string](Invoke-CdpEvaluate -Session $Session -Expression "location.href") - $pageTwoIdentities = @(Invoke-CdpEvaluate -Session $Session -Expression 'Array.from(document.querySelectorAll("#facility-intelligence-results li[id^=\"facility-intelligence-result-\"]")).map((entry)=>entry.id)') - $duplicates = @($pageTwoIdentities | Where-Object { $firstPageIdentities -contains $_ }) - $observedUnion = @($firstPageIdentities + $pageTwoIdentities) - if ($duplicates.Count -gt 0 -or $observedUnion.Count -ne 28) { throw "Issue #642 page identity reconciliation failed: duplicates=$($duplicates -join ', '); observed=$($observedUnion.Count); expected=28." } - & $record "issue642-pagination-page-2" @("verify page 2 continuation", "reconcile unique result identities") $snapshot.url $snapshot.focus "rendered Next control" - $states[$states.Count - 1] | Add-Member -NotePropertyName page_identity_reconciliation -NotePropertyValue @{ first_page = $firstPageIdentities; second_page = $pageTwoIdentities; duplicates = $duplicates; missing = @(); expected_union_count = 28; observed_union_count = $observedUnion.Count } + & $record "issue642-pagination-page-2-previous" @("click rendered Previous control", "verify page-two filters and comparison context") $snapshot.url $snapshot.focus "a.facility-pagination__control[aria-label^='Previous facilities']" $snapshot = & $before Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Previous facilities']" @@ -2181,7 +2273,7 @@ function Invoke-Issue642OperatedInteractionCapture { $snapshot = & $before Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Next facilities']" - Wait-CdpCondition -Session $Session -Expression "location.search.includes('continuation=') && document.querySelector('#facility-intelligence-position').innerText.includes('Showing 26') && document.activeElement === document.querySelector('#facility-intelligence-results')" -Description "operated Next return to page two" + Wait-CdpCondition -Session $Session -Expression "location.search.includes('continuation=') && document.querySelector('#facility-intelligence-position').innerText.includes('Showing') && document.activeElement === document.querySelector('#facility-intelligence-results')" -Description "operated Next return to page two" & $record "issue642-pagination-preserved" @("verify repeated filters, scalar date filter, complaint-patterns view, anchor, and result focus") $snapshot.url $snapshot.focus "pagination preservation verification" $pageTwoUrl = [string](Invoke-CdpEvaluate -Session $Session -Expression "location.href") @@ -4421,6 +4513,85 @@ function Write-Issue642PacketDiagnostics { return [ordered]@{ screenshotStates = $stateCounts; consoleNetwork = $summary } } +function Test-Issue644AutomatedVisualPrerequisites { + param( + [string]$PacketDirectory, + [object[]]$RouteResults, + [object]$ManifestSurfaceMeasurement = $null + ) + + $printValidationPath = Join-Path $PacketDirectory 'issue-420-print-validation.json' + if (-not (Test-Path -LiteralPath $printValidationPath)) { + Stop-CaptureFail 'Issue #644 visual prerequisite failed: print validation is missing; allowed minimum=1; allowed maximum=4; governed requirement=Issue #648 Compare Facilities print output.' + } + try { $printValidation = Get-Content -LiteralPath $printValidationPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } + catch { Stop-CaptureFail 'Issue #644 visual prerequisite failed: print validation is invalid; allowed minimum=1; allowed maximum=4; governed requirement=Issue #648 Compare Facilities print output.' } + $printPdfName = [string]$printValidation.pdf + $printPdfPath = if ([string]::IsNullOrWhiteSpace($printPdfName)) { '' } else { Join-Path (Join-Path $PacketDirectory 'print') $printPdfName } + $pageCount = $printValidation.pageCount + if ($null -eq $pageCount -or -not ($pageCount -is [int] -or $pageCount -is [long] -or $pageCount -is [double])) { + Stop-CaptureFail "Issue #644 visual prerequisite failed: print artifact '$printPdfName' has a nonnumeric page count; allowed minimum=1; allowed maximum=4; governed requirement=Issue #648 Compare Facilities print output." + } + $pageCount = [int]$pageCount + $renderedPages = @($printValidation.pages) + $renderedPageFiles = @(Get-ChildItem -LiteralPath (Join-Path $PacketDirectory 'print-pages') -File -ErrorAction SilentlyContinue) + if ([string]::IsNullOrWhiteSpace($printPdfPath) -or -not (Test-Path -LiteralPath $printPdfPath) -or (Get-Item -LiteralPath $printPdfPath).Length -le 0 -or $renderedPages.Count -ne $pageCount -or $renderedPageFiles.Count -ne $pageCount -or @($renderedPageFiles | Where-Object { $_.Length -le 0 }).Count -gt 0) { + Stop-CaptureFail "Issue #644 visual prerequisite failed: print artifact '$printPdfName' lacks a nonempty PDF or reconciled rendered pages; observed page count=$pageCount; allowed minimum=1; allowed maximum=4; governed requirement=Issue #648 Compare Facilities print output." + } + if ($pageCount -lt 1 -or $pageCount -gt 4) { + Stop-CaptureFail "Issue #644 visual prerequisite failed: print artifact 'print/$printPdfName' observed page count=$pageCount; allowed minimum=1; allowed maximum=4; governed requirement=Issue #648 Compare Facilities print output." + } + + $requiredDensityRoutes = @( + [ordered]@{ id = 'DESKTOP'; route = 'issue-643-populated-desktop'; ceiling = 12 }, + [ordered]@{ id = 'SURFACE'; route = 'issue-644-surface-1189'; ceiling = 16 }, + [ordered]@{ id = 'NARROW_500'; route = 'issue-643-populated-500'; ceiling = 16 }, + [ordered]@{ id = 'MOBILE'; route = 'issue-643-populated-390'; ceiling = 24 }, + [ordered]@{ id = 'ZOOM_200'; route = 'issue-643-populated-zoom-200'; ceiling = 24 } + ) + $resultsByName = @{} + foreach ($result in @($RouteResults)) { $resultsByName[[string]$result.name] = $result } + $densityResults = [System.Collections.ArrayList]::new() + $surfaceMeasurement = $null + foreach ($required in $requiredDensityRoutes) { + $routeName = [string]$required.route + if (-not $resultsByName.ContainsKey($routeName) -or [string]::IsNullOrWhiteSpace([string]$resultsByName[$routeName].browserStatePath)) { + Stop-CaptureFail "Issue #644 visual prerequisite failed: mandatory $($required.id) density result is missing browser-state evidence for route '$routeName'." + } + $statePath = Join-Path $PacketDirectory ([string]$resultsByName[$routeName].browserStatePath) + if (-not (Test-Path -LiteralPath $statePath)) { + Stop-CaptureFail "Issue #644 visual prerequisite failed: mandatory $($required.id) density result is missing browser-state artifact '$($resultsByName[$routeName].browserStatePath)'." + } + try { $state = Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } + catch { Stop-CaptureFail "Issue #644 visual prerequisite failed: mandatory $($required.id) density browser-state artifact is invalid for route '$routeName'." } + $viewportHeight = $state.viewport.innerHeight + $documentHeight = $state.document.scrollHeight + if ($null -eq $viewportHeight -or $null -eq $documentHeight -or -not ($viewportHeight -is [int] -or $viewportHeight -is [long] -or $viewportHeight -is [double]) -or -not ($documentHeight -is [int] -or $documentHeight -is [long] -or $documentHeight -is [double]) -or [double]$viewportHeight -le 0 -or [double]$documentHeight -le 0) { + Stop-CaptureFail "Issue #644 visual prerequisite failed: mandatory $($required.id) density input is missing or invalid for route '$routeName'." + } + $ratio = [math]::Round(([double]$documentHeight / [double]$viewportHeight), 4) + $status = if ($ratio -le [double]$required.ceiling) { 'PASS' } else { 'FAIL' } + [void]$densityResults.Add([ordered]@{ id = $required.id; route = $routeName; viewport = [ordered]@{ innerWidth = [int]$state.viewport.innerWidth; innerHeight = [int]$viewportHeight }; documentHeight = [int]$documentHeight; viewportHeightRatio = $ratio; governedCeiling = [double]$required.ceiling; result = $status; browserStateArtifact = [string]$resultsByName[$routeName].browserStatePath }) + if ($status -ne 'PASS') { + Stop-CaptureFail "Issue #644 visual prerequisite failed: mandatory $($required.id) density ratio=$ratio exceeds governed ceiling=$($required.ceiling) viewport heights for route '$routeName'." + } + if ($required.id -eq 'SURFACE') { + $surfaceMeasurement = [ordered]@{ innerWidth = [int]$state.viewport.innerWidth; innerHeight = [int]$state.viewport.innerHeight; visualViewportScale = [double]$state.viewport.visualViewportScale; devicePixelRatio = [double]$state.viewport.devicePixelRatio; source = [string]$resultsByName[$routeName].browserStatePath } + } + } + if ($null -eq $surfaceMeasurement) { Stop-CaptureFail 'Issue #644 visual prerequisite failed: measured Surface browser-state evidence is missing.' } + if ($null -ne $ManifestSurfaceMeasurement) { + foreach ($property in @('innerWidth', 'innerHeight', 'visualViewportScale', 'devicePixelRatio')) { + if ($null -eq $ManifestSurfaceMeasurement.PSObject.Properties[$property] -or [math]::Abs([double]$ManifestSurfaceMeasurement.$property - [double]$surfaceMeasurement.$property) -gt 0.01) { + Stop-CaptureFail "Issue #644 visual prerequisite failed: manifest Surface metadata '$property' conflicts with captured browser-state evidence." + } + } + } + $densityArtifact = 'diagnostics/issue-644-density.json' + Set-Content -LiteralPath (Join-Path $PacketDirectory $densityArtifact) -Value ([ordered]@{ requirement = 'Issue #648 governed density ceilings'; results = @($densityResults) } | ConvertTo-Json -Depth 8) -Encoding UTF8 + return [ordered]@{ print = [ordered]@{ artifact = "print/$printPdfName"; pageCount = $pageCount; minimumPages = 1; maximumPages = 4; result = 'PASS' }; densityArtifact = $densityArtifact; densityResults = @($densityResults); surfaceMeasurement = $surfaceMeasurement } +} + function Write-Issue655PacketDiagnostics { param([string]$PacketDirectory, [string]$ScreenshotDirectory, [string]$DiagnosticsDirectory, [object[]]$RouteResults) $base = Write-Issue642PacketDiagnostics -PacketDirectory $PacketDirectory -ScreenshotDirectory $ScreenshotDirectory -DiagnosticsDirectory $DiagnosticsDirectory -RouteResults $RouteResults -IssueNumber '655' @@ -4570,7 +4741,7 @@ try { if ($Mode -ne 'fixture') { Stop-CaptureFail "Capture plans with dataMode fixture-demo require -Mode fixture." } $evidencePurpose = $capturePlan.Purpose } - if (($Issue419 -or $Issue420 -or $Issue502 -or $Issue503 -or $Issue641 -or $Issue642 -or $Issue643) -and $Mode -ne "fixture") { + if (($Issue419 -or $Issue420 -or $Issue502 -or $Issue503 -or $Issue641 -or $Issue642 -or $Issue643 -or $Issue644) -and $Mode -ne "fixture") { Stop-CaptureFail "Issue #419, Issue #420, Issue #502, Issue #503, and Issue #641 evidence routes are local fixture/demo-only; use -Mode fixture." } if ($Issue498 -and $Mode -ne "fixture") { @@ -4585,7 +4756,7 @@ try { $resolvedOutput = [IO.Path]::GetFullPath($OutputDir) if (-not $resolvedOutput.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Issue #655 rehearsal output must stay under the temporary directory.' } } - $packetName = if ($Issue655Rehearsal) { "issue-655-rehearsal-$RehearsalRunName" } elseif ($Issue655) { "$timestamp-issue-655-local" } elseif ($Issue643) { "$timestamp-issue-643-local" } elseif ($Issue642) { "$timestamp-issue-642-local" } elseif ($Issue503) { "$timestamp-$Mode-issue-503" } elseif ($Issue502) { "$timestamp-$Mode-issue-502" } elseif ($Issue498) { "$timestamp-$Mode-issue-498" } elseif ($Issue420) { "$timestamp-$Mode-issue-420" } elseif ($Issue419) { "$timestamp-$Mode-issue-419" } elseif ($Issue418) { "$timestamp-$Mode-issue-418" } elseif ($Issue417) { "$timestamp-$Mode-issue-417" } elseif ($Issue416) { "$timestamp-$Mode-issue-416" } elseif ($Issue415) { "$timestamp-$Mode-issue-415" } elseif ($null -ne $capturePlan) { "$timestamp-$Mode-plan" } else { "$timestamp-$Mode" } + $packetName = if ($Issue655Rehearsal) { "issue-655-rehearsal-$RehearsalRunName" } elseif ($Issue655) { "$timestamp-issue-655-local" } elseif ($Issue644) { "$timestamp-issue-644-local" } elseif ($Issue643) { "$timestamp-issue-643-local" } elseif ($Issue642) { "$timestamp-issue-642-local" } elseif ($Issue503) { "$timestamp-$Mode-issue-503" } elseif ($Issue502) { "$timestamp-$Mode-issue-502" } elseif ($Issue498) { "$timestamp-$Mode-issue-498" } elseif ($Issue420) { "$timestamp-$Mode-issue-420" } elseif ($Issue419) { "$timestamp-$Mode-issue-419" } elseif ($Issue418) { "$timestamp-$Mode-issue-418" } elseif ($Issue417) { "$timestamp-$Mode-issue-417" } elseif ($Issue416) { "$timestamp-$Mode-issue-416" } elseif ($Issue415) { "$timestamp-$Mode-issue-415" } elseif ($null -ne $capturePlan) { "$timestamp-$Mode-plan" } else { "$timestamp-$Mode" } $outputRoot = if ([IO.Path]::IsPathRooted($OutputDir)) { [IO.Path]::GetFullPath($OutputDir) } else { @@ -4806,6 +4977,11 @@ try { @{ Name = "issue-643-populated-print"; Path = $issue643Base; Label = "issue-643-08-populated-print"; ActiveHref = $issue643Base; WorkflowStep = "Review"; ViewportWidth = 1440; ViewportHeight = 1200; CapturePrint = $true }, @{ Name = "issue-643-source-unavailable"; Path = "${issue643Base}?facility_type=733"; Label = "issue-643-09-source-unavailable"; ActiveHref = $issue643Base; WorkflowStep = "Review"; ViewportWidth = 1440; ViewportHeight = 1200 } ) + # Issue #644 composes the already-governed route inventories. The route + # objects remain the source of truth for their individual assertions and + # browser operation; no #644-specific capture pipeline is introduced. + $issue644SurfaceRoute = @{ Name = 'issue-644-surface-1189'; Path = $issue642CompareState; Label = 'issue-644-surface-1189'; ActiveHref = $issue642Base; WorkflowStep = 'Review'; ViewportWidth = 1189; ViewportHeight = 671 } + $issue644Routes = @($issue642Routes + $issue643Routes + $issue644SurfaceRoute) # Issue #655 is deliberately independent of Issue #643's card evidence. # The recommendation cursor is server-generated during operated capture; # static routes establish the canonical and exceptional server responses. @@ -4824,7 +5000,9 @@ try { @{ Name = "issue-655-reduced-motion"; Path = $issue655Base; Label = "issue-655-11-reduced-motion"; ActiveHref = $issue655Base; WorkflowStep = "Review"; Issue655State = "reduced-motion"; ViewportWidth = 400; ViewportHeight = 900; Operated = $true }, @{ Name = "issue-655-print"; Path = $issue655Base; Label = "issue-655-12-print"; ActiveHref = $issue655Base; WorkflowStep = "Review"; Issue655State = "print"; ViewportWidth = 1440; ViewportHeight = 1200; CapturePrint = $true } ) - $routesToCapture = if ($Issue655) { $issue655Routes } elseif ($Issue643) { $issue643Routes } elseif ($Issue642) { $issue642Routes } elseif ($Issue641) { $issue641Routes } elseif ($Issue610) { $issue610Routes } elseif ($Issue503) { $issue503Routes } elseif ($Issue502) { $issue502Routes } elseif ($Issue498) { $issue498Routes } elseif ($Issue420) { $issue420Routes } elseif ($Issue419) { $issue419Routes } elseif ($Issue418) { $issue418Routes } elseif ($Issue417) { $issue417Routes } elseif ($Issue416) { $issue416Routes } elseif ($Issue415) { $issue415Routes } else { $coreRoutes } + # #644 is deliberately a composition of the proven #642 contract; it does + # not create a second launcher, browser harness, or packet format. + $routesToCapture = if ($Issue655) { $issue655Routes } elseif ($Issue644) { $issue644Routes } elseif ($Issue643) { $issue643Routes } elseif ($Issue642) { $issue642Routes } elseif ($Issue641) { $issue641Routes } elseif ($Issue610) { $issue610Routes } elseif ($Issue503) { $issue503Routes } elseif ($Issue502) { $issue502Routes } elseif ($Issue498) { $issue498Routes } elseif ($Issue420) { $issue420Routes } elseif ($Issue419) { $issue419Routes } elseif ($Issue418) { $issue418Routes } elseif ($Issue417) { $issue417Routes } elseif ($Issue416) { $issue416Routes } elseif ($Issue415) { $issue415Routes } else { $coreRoutes } if ($null -ne $capturePlan) { $routesToCapture = @(New-CapturePlanRoutes -Plan $capturePlan) } $routeResults = [System.Collections.ArrayList]::new() @@ -4833,14 +5011,14 @@ try { $routeHtmlByName = @{} $screenshotWarnings = @() $screenshotToolResolution = if ($IncludeScreenshots) { - Resolve-ScreenshotTool -Requested $ScreenshotToolPreference -RequireInteractionAware ([bool]($Issue498 -or $Issue420 -or $Issue502 -or $Issue503 -or $Issue641 -or $Issue642 -or $Issue643 -or $Issue655)) + Resolve-ScreenshotTool -Requested $ScreenshotToolPreference -RequireInteractionAware ([bool]($Issue498 -or $Issue420 -or $Issue502 -or $Issue503 -or $Issue641 -or $Issue642 -or $Issue643 -or $Issue644 -or $Issue655)) } else { [pscustomobject]@{ Requested = $ScreenshotToolPreference; Resolved = "none"; ValidationStatus = "screenshots not requested"; Executable = ""; SupportsInteractionAwareCapture = $false; FullPage = $false; Tool = $null; Attempts = @(); Error = "" } } $resolvedScreenshotTool = $screenshotToolResolution.Tool $interactionBrowserSession = $null - if (($Issue498 -or $Issue420 -or $Issue502 -or $Issue503 -or $Issue641 -or $Issue642 -or $Issue643 -or $Issue655) -and $IncludeScreenshots) { + if (($Issue498 -or $Issue420 -or $Issue502 -or $Issue503 -or $Issue641 -or $Issue642 -or $Issue643 -or $Issue644 -or $Issue655) -and $IncludeScreenshots) { if ($null -eq $resolvedScreenshotTool) { $screenshotWarnings += "Interaction-aware screenshot tool selection failed: $($screenshotToolResolution.Error)" } @@ -4930,7 +5108,7 @@ try { } } } - elseif ($Issue502 -or $Issue420 -or $Issue503 -or $Issue641 -or $Issue642 -or $Issue643 -or $Issue655) { + elseif ($Issue502 -or $Issue420 -or $Issue503 -or $Issue641 -or $Issue642 -or $Issue643 -or $Issue644 -or $Issue655) { if ($null -eq $interactionBrowserSession) { $shotError = "interaction-aware browser session unavailable" $script:screenshotWarnings += "$($Route.Name): screenshot failed: $shotError" @@ -4944,7 +5122,7 @@ try { elseif ($Issue655) { Invoke-Issue655BrowserCapture -Session $interactionBrowserSession -Route $Route -Url $url -ScreenshotPath $shotFile -PrintPath $printFile -Width $routeViewportWidth -Height $routeViewportHeight } - elseif ($Issue642 -or $Issue643) { + elseif ($Issue642 -or $Issue643 -or $Issue644) { Invoke-Issue642BrowserCapture -Session $interactionBrowserSession -Route $Route -Url $url -ScreenshotPath $shotFile -PrintPath $printFile -Width $routeViewportWidth -Height $routeViewportHeight } elseif ($Issue503) { @@ -4961,7 +5139,7 @@ try { if (-not $captureResult.Success -or -not $captureResult.ScreenshotCreated) { Remove-Item -LiteralPath $shotFile -Force -ErrorAction SilentlyContinue $script:screenshotWarnings += "$($Route.Name): screenshot failed: $($captureResult.Error)" - $failure = if ($Issue641 -or $Issue642) { "Focused Compare browser capture failed: $($captureResult.Error)" } else { "Interaction-aware responsive capture failed: $($captureResult.Error)" } + $failure = if ($Issue641 -or $Issue642 -or $Issue644) { "Focused Compare browser capture failed: $($captureResult.Error)" } else { "Interaction-aware responsive capture failed: $($captureResult.Error)" } } else { $screenshotPath = ConvertTo-RelativeEvidencePath -Path $shotFile -Root $packetDir @@ -4998,7 +5176,7 @@ try { } } } - if (-not $Issue498 -and -not $Issue502 -and -not $Issue503 -and -not $Issue641 -and -not $Issue642 -and -not $Issue655 -and $null -ne $resolvedScreenshotTool -and $Route.ContainsKey("CapturePrint") -and [bool]$Route.CapturePrint) { + if (-not $Issue498 -and -not $Issue502 -and -not $Issue503 -and -not $Issue641 -and -not $Issue642 -and -not $Issue644 -and -not $Issue655 -and $null -ne $resolvedScreenshotTool -and $Route.ContainsKey("CapturePrint") -and [bool]$Route.CapturePrint) { $printFile = Join-Path $printDir "$($Route.Label).pdf" $printError = Invoke-RoutePrint -Tool $resolvedScreenshotTool -Url $url -PrintPath $printFile if ($printError) { $script:screenshotWarnings += "$($Route.Name): $printError" } @@ -5006,7 +5184,7 @@ try { } } } - if (-not $Issue641 -and -not $Issue642 -and -not $Issue643 -and -not $Issue655) { + if (-not $Issue641 -and -not $Issue642 -and -not $Issue643 -and -not $Issue644 -and -not $Issue655) { Test-RouteAssertions -Route $Route -Html $safeHtml -StatusCode $response.StatusCode -Assertions $assertions } elseif ($Issue641) { @@ -5019,7 +5197,7 @@ try { } else { Add-AssertionResult -Target $assertions -RouteName $Route.Name -Check $(if ($Issue643) {'issue643 route status'} else {'issue642 route status'}) -Status $(if ($response.StatusCode -eq $expectedStatus) { "PASS" } else { "FAIL" }) -Message "Route returned HTTP $($response.StatusCode); expected $expectedStatus." - if ($Issue643) { Test-Issue643RouteAssertions -Route $Route -Text $plainText -Assertions $assertions } else { Test-Issue642RouteAssertions -Route $Route -Text $plainText -Assertions $assertions } + if ($Issue643 -or ([string]$Route.Name).StartsWith('issue-643-')) { Test-Issue643RouteAssertions -Route $Route -Text $plainText -Assertions $assertions } else { Test-Issue642RouteAssertions -Route $Route -Text $plainText -Assertions $assertions } } if ($Issue415) { Test-Issue415RouteAssertions -Route $Route -Html $safeHtml -Text $plainText -Assertions $assertions @@ -5731,7 +5909,7 @@ This packet supersedes neither the historical rejected packet nor any acceptance $gitBaseAttempts = if ($gitBaseResolution) { @($gitBaseResolution.Attempts) } else { @() } Set-Content -LiteralPath (Join-Path $diagnosticsDir "git-status.txt") -Value $gitStatusText -Encoding UTF8 Set-Content -LiteralPath (Join-Path $diagnosticsDir "git-log.txt") -Value ((git log --oneline -n 5 2>$null) -join "`n") -Encoding UTF8 - $focusedCommandSuffix = if ($Issue655) { " -Issue655" } elseif ($Issue642) { " -Issue642" } elseif ($Issue641) { " -Issue641" } elseif ($Issue503) { " -Issue503" } elseif ($Issue502) { " -Issue502" } elseif ($Issue498) { " -Issue498" } elseif ($Issue420) { " -Issue420" } elseif ($Issue419) { " -Issue419" } elseif ($Issue418) { " -Issue418" } elseif ($Issue417) { " -Issue417" } elseif ($Issue416) { " -Issue416" } elseif ($Issue415) { " -Issue415" } else { "" } + $focusedCommandSuffix = if ($Issue655) { " -Issue655" } elseif ($Issue644) { " -Issue644" } elseif ($Issue642) { " -Issue642" } elseif ($Issue641) { " -Issue641" } elseif ($Issue503) { " -Issue503" } elseif ($Issue502) { " -Issue502" } elseif ($Issue498) { " -Issue498" } elseif ($Issue420) { " -Issue420" } elseif ($Issue419) { " -Issue419" } elseif ($Issue418) { " -Issue418" } elseif ($Issue417) { " -Issue417" } elseif ($Issue416) { " -Issue416" } elseif ($Issue415) { " -Issue415" } else { "" } Set-Content -LiteralPath (Join-Path $diagnosticsDir "capture-command.txt") -Value "capture-hosted-ui-evidence.ps1 -BaseUrl $normalizedBaseUrl -Mode $Mode -OutputDir $OutputDir -ViewportWidth $ViewportWidth -ViewportHeight $ViewportHeight -TimeoutSeconds $TimeoutSeconds -ScreenshotToolPreference $ScreenshotToolPreference$focusedCommandSuffix" -Encoding UTF8 $environmentSummary = if ($Issue655) { @( 'issue=655', @@ -5834,12 +6012,19 @@ scripts/validate_hosted_ui_acceptance.py. if ($featureFailures -gt 0) { Stop-CaptureFail "Issue #641 feature assertion failures prevent packet publication." } } $issue642PacketDiagnostics = $null - if ($Issue642) { - $issue642PacketDiagnostics = Write-Issue642PacketDiagnostics -PacketDirectory $packetDir -ScreenshotDirectory $screenshotDir -DiagnosticsDirectory $diagnosticsDir -RouteResults @($routeResults) + $issue644VisualPrerequisites = $null + if ($Issue642 -or $Issue644) { + $issue642PacketDiagnostics = Write-Issue642PacketDiagnostics -PacketDirectory $packetDir -ScreenshotDirectory $screenshotDir -DiagnosticsDirectory $diagnosticsDir -RouteResults @($routeResults) -IssueNumber $(if ($Issue644) { '644' } else { '642' }) } elseif ($Issue643) { $issue642PacketDiagnostics = Write-Issue642PacketDiagnostics -PacketDirectory $packetDir -ScreenshotDirectory $screenshotDir -DiagnosticsDirectory $diagnosticsDir -RouteResults @($routeResults) -IssueNumber '643' } + # Fail closed before manifest finalization, file indexing, and ZIP creation. + # The #648 visual thresholds are a prerequisite to independent human review, + # not a claim of visual or owner acceptance. + if ($Issue644) { + $issue644VisualPrerequisites = Test-Issue644AutomatedVisualPrerequisites -PacketDirectory $packetDir -RouteResults @($routeResults) + } elseif ($Issue655) { $issue642PacketDiagnostics = Write-Issue655PacketDiagnostics -PacketDirectory $packetDir -ScreenshotDirectory $screenshotDir -DiagnosticsDirectory $diagnosticsDir -RouteResults @($routeResults) } @@ -5885,6 +6070,7 @@ scripts/validate_hosted_ui_acceptance.py. [pscustomobject]@{ Enabled = $Issue610; Reference = "#610" }, [pscustomobject]@{ Enabled = $Issue641; Reference = "#641" }, [pscustomobject]@{ Enabled = $Issue642; Reference = "#642" }, + [pscustomobject]@{ Enabled = $Issue644; Reference = "#644" }, [pscustomobject]@{ Enabled = $Issue655; Reference = "#655" } )) { if ([bool]$issueEntry.Enabled) { $focusedIssueScope += [string]$issueEntry.Reference } @@ -5920,6 +6106,7 @@ scripts/validate_hosted_ui_acceptance.py. issue610 = [ordered]@{ enabled = [bool]$Issue610; routeCount = @($routesToCapture).Count; scenarios = @($routesToCapture | ForEach-Object { $_.Name }); printSettings = "Portrait; scale 100%; default margins; headers and footers off; background graphics on."; printArtifact = @($routeResults | Where-Object { $_.printPath } | ForEach-Object { $_.printPath }) } issue641 = [ordered]@{ enabled = [bool]$Issue641; routeCount = @($routesToCapture).Count; scenarios = @($routesToCapture | ForEach-Object { $_.Name }); evidenceGates = @($issue641GateResults); gateArtifact = if ($Issue641) { "issue-641-evidence-gates.csv" } else { "" }; summaryArtifact = if ($Issue641) { "issue-641-evidence-summary.md" } else { "" }; measuredPageScale = if ($Issue641) { "1280x900 at visualViewport scale 2" } else { "" }; visualAcceptance = "PENDING_INDEPENDENT_VISUAL_REVIEW"; printArtifact = @($routeResults | Where-Object { $_.printPath } | ForEach-Object { $_.printPath }) } issue642 = [ordered]@{ enabled = [bool]$Issue642; routeCount = if ($Issue642) { @($routesToCapture).Count } else { 0 }; scenarios = if ($Issue642) { @($routesToCapture | ForEach-Object { $_.Name }) } else { @() }; controlledInteraction = "Navigation, staged public Facility ID, multi-value state, canonical continuation, return context, responsive, focus, print"; screenshotStateArtifact = if ($Issue642) { 'diagnostics/issue-642-screenshot-states.json' } else { '' }; screenshotStates = if ($Issue642) { $issue642PacketDiagnostics.screenshotStates } else { @{} }; consoleNetworkSummaryArtifact = if ($Issue642) { 'diagnostics/issue-642-console-network-summary.json' } else { '' }; consoleNetwork = if ($Issue642) { $issue642PacketDiagnostics.consoleNetwork } else { @{} }; visualAcceptance = "PENDING_INDEPENDENT_VISUAL_REVIEW"; ownerAcceptance = "PENDING_OWNER_DECISION"; printArtifact = @($routeResults | Where-Object { $_.printPath } | ForEach-Object { $_.printPath }) } + issue644 = [ordered]@{ enabled = [bool]$Issue644; routeCount = if ($Issue644) { @($routesToCapture).Count } else { 0 }; composedContracts = @('#642', '#643', '#648', '#670', '#671'); surfaceMeasurement = if ($Issue644) { $issue644VisualPrerequisites.surfaceMeasurement } else { $null }; automatedVisualPrerequisites = if ($Issue644) { [ordered]@{ print = $issue644VisualPrerequisites.print; densityArtifact = $issue644VisualPrerequisites.densityArtifact; densityResults = $issue644VisualPrerequisites.densityResults } } else { $null }; visualAcceptance = 'PENDING_INDEPENDENT_VISUAL_REVIEW'; ownerAcceptance = 'PENDING_OWNER_DECISION'; evidenceLimitations = 'Local fixture evidence only; it does not establish deployed-host acceptance.' } issue655 = [ordered]@{ enabled = [bool]$Issue655; mode = 'local-fixture'; branch = $gitBranch; head = $gitCommit; baseSha = $gitBaseSha; baseShaSource = $gitBaseSource; baseShaAttempts = $gitBaseAttempts; routeCount = if ($Issue655) { @($routesToCapture).Count } else { 0 }; scenarios = if ($Issue655) { @($routesToCapture | ForEach-Object { $_.Name }) } else { @() }; screenshotStateArtifact = if ($Issue655) { 'diagnostics/issue-655-screenshot-states.json' } else { '' }; screenshotStates = if ($Issue655) { $issue642PacketDiagnostics.screenshotStates } else { @{} }; browserStateCount = if ($Issue655) { $issue642PacketDiagnostics.browserStates } else { 0 }; geometryDiagnosticsArtifact = if ($Issue655) { $issue642PacketDiagnostics.geometryArtifact } else { '' }; focusLiveRegionDiagnosticsArtifact = if ($Issue655) { $issue642PacketDiagnostics.focusLiveArtifact } else { '' }; interactionIndexArtifact = if ($Issue655) { $issue642PacketDiagnostics.interactionIndexArtifact } else { '' }; consoleNetworkSummaryArtifact = if ($Issue655) { 'diagnostics/issue-655-console-network-summary.json' } else { '' }; consoleNetwork = if ($Issue655) { $issue642PacketDiagnostics.consoleNetwork } else { @{} }; evidenceLimitations = 'Local fixture evidence only; it does not establish deployed-host acceptance.'; visualAcceptance = 'PENDING_INDEPENDENT_VISUAL_REVIEW'; ownerAcceptance = 'PENDING_OWNER_DECISION'; printArtifact = @($routeResults | Where-Object { $_.printPath } | ForEach-Object { $_.printPath }) } capturePlan = if ($null -eq $capturePlan) { $null } else { [ordered]@{ fileName = $capturePlan.FileName; dataMode = $capturePlan.DataMode; governanceIssue = $capturePlan.GovernanceIssue; purpose = $capturePlan.Purpose; limitations = @($capturePlan.Limitations); scenarios = @($capturePlan.Scenarios | ForEach-Object { [ordered]@{ id = $_.Id; facilityId = $_.FacilityId; classification = $_.Classification; expectedLocationState = $_.ExpectedLocationState; routes = @($_.Routes | ForEach-Object { [ordered]@{ kind = $_.Kind; applicability = $_.Applicability; reason = $_.Reason; screenshotMode = $_.ScreenshotMode; requiredText = @($_.RequiredText); forbiddenText = @($_.ForbiddenText) } }) } }) } } acceptance = [ordered]@{ diff --git a/scripts/run-and-capture-hosted-ui-evidence.ps1 b/scripts/run-and-capture-hosted-ui-evidence.ps1 index 74851488..852b6d40 100644 --- a/scripts/run-and-capture-hosted-ui-evidence.ps1 +++ b/scripts/run-and-capture-hosted-ui-evidence.ps1 @@ -52,6 +52,8 @@ param( [switch]$Issue643, + [switch]$Issue644, + [switch]$Issue655, [switch]$Issue655Rehearsal, @@ -198,7 +200,7 @@ foreach ($candidate in $candidatePorts | Select-Object -Unique) { $launcherArguments = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$scriptPath,'-Port',[string]$candidate) if ($Mode -eq 'fixture') { $launcherArguments += @('-PythonExecutable',$PythonExecutable) - if ($Issue642) { $launcherArguments += '-Issue642Evidence' } + if ($Issue642 -or $Issue644) { $launcherArguments += '-Issue642Evidence' } if ($Issue642LicensingSourceUnavailable) { $launcherArguments += '-Issue642LicensingSourceUnavailable' } } # Failed fixture launches leave only temporary diagnostics, never an evidence packet directory. @@ -229,6 +231,7 @@ if ($Issue502) { $captureArguments.Issue502 = $true } if ($Issue420) { $captureArguments.Issue420 = $true } if ($Issue642) { $captureArguments.Issue642 = $true } if ($Issue643) { $captureArguments.Issue643 = $true } +if ($Issue644) { $captureArguments.Issue644 = $true } if ($Issue655) { $captureArguments.Issue655 = $true } if ($Issue655Rehearsal) { if (-not $Issue655) { throw 'Issue655Rehearsal requires Issue655.' } diff --git a/src/ccld_complaints/hosted_app/reviewer_ui.py b/src/ccld_complaints/hosted_app/reviewer_ui.py index 69a7a05b..2a6a084b 100644 --- a/src/ccld_complaints/hosted_app/reviewer_ui.py +++ b/src/ccld_complaints/hosted_app/reviewer_ui.py @@ -1040,7 +1040,7 @@ def _issue_642_evidence_fixture_enabled( def _with_local_issue_642_pagination_fixture_records( artifact: SeededCorpusArtifact, ) -> SeededCorpusArtifact: - """Add 26 deterministic, public-safe rows solely for Issue #642 evidence.""" + """Add 49 deterministic, public-safe rows solely for local pagination evidence.""" source_record = next( ( record @@ -1056,7 +1056,11 @@ def _with_local_issue_642_pagination_fixture_records( raise ValueError("Local Issue #642 fixtures require one seeded complaint bundle.") fixture_records: list[Mapping[str, Any]] = [] - for offset in range(1, 27): + # Keep the original Issue #642 rows (1 through 26) byte-for-byte stable in + # their generated values. The final 23 rows make the fixture corpus 51 + # facilities in total, which exercises four cursor-paginated pages at the + # governed Compare Facilities page size. + for offset in range(1, 50): facility_number = f"642900{offset:03d}" facility_type = "430" if offset % 2 else "733" bundle = copy.deepcopy(source_record) @@ -1123,12 +1127,12 @@ def _with_local_issue_642_pagination_fixture_records( record_counts = dict(artifact.record_counts) for record_type, increment in ( - ("facility", 26), - ("source_document", 26), - ("complaint", 26), - ("allegation", 52), - ("event", 26), - ("extraction_audit", 26), + ("facility", 49), + ("source_document", 49), + ("complaint", 49), + ("allegation", 98), + ("event", 49), + ("extraction_audit", 49), ): record_counts[record_type] = record_counts.get(record_type, 0) + increment return replace( @@ -1139,7 +1143,7 @@ def _with_local_issue_642_pagination_fixture_records( record_counts=record_counts, warnings=artifact.warnings + ( - "Twenty-six deterministic Issue #642 pagination rows are available only " + "Forty-nine deterministic Issue #642 pagination rows are available only " "to the explicitly enabled local fixture/demo evidence invocation.", ), records=artifact.records + tuple(fixture_records), diff --git a/src/ccld_complaints/hosted_app/source_derived_reads.py b/src/ccld_complaints/hosted_app/source_derived_reads.py index ae46f002..80774fd0 100644 --- a/src/ccld_complaints/hosted_app/source_derived_reads.py +++ b/src/ccld_complaints/hosted_app/source_derived_reads.py @@ -168,7 +168,10 @@ class FacilityIntelligencePageRead: "date_signed", ) -FACILITY_INTELLIGENCE_PAGE_SIZE = 25 +# Fourteen complete facility rows retain the existing cursor-pagination contract +# while keeping the normal Compare Facilities state within its governed viewport +# density ceilings at supported widths. +FACILITY_INTELLIGENCE_PAGE_SIZE = 14 _FACILITY_INTELLIGENCE_OPTION_LIMIT = 250 _FACILITY_INTELLIGENCE_SERIOUS_CATEGORY_LABELS = { "abuse or mistreatment": "Mistreatment-topic", diff --git a/src/ccld_complaints/hosted_app/ui_shell.py b/src/ccld_complaints/hosted_app/ui_shell.py index 9bcc32f1..43fe885f 100644 --- a/src/ccld_complaints/hosted_app/ui_shell.py +++ b/src/ccld_complaints/hosted_app/ui_shell.py @@ -4156,6 +4156,108 @@ def _runtime_mode_label() -> str: .civic-ledger-page .facility-inventory-context { position: static !important; } + .civic-ledger-page .facility-intelligence-results { + border: 0; + } + .civic-ledger-page .facility-intelligence-results__header { + padding: 0.35rem 0; + } + .civic-ledger-page .facility-inventory-context { + padding: 0.35rem 0; + } + .civic-ledger-page .facility-inventory-context--bottom { + display: none; + } + .civic-ledger-page .facility-intelligence-inventory { + display: block; + } + .civic-ledger-page .facility-intelligence-row { + border-bottom: 1px solid #000; + break-inside: avoid; + display: block; + margin: 0; + page-break-inside: avoid; + } + .civic-ledger-page .facility-intelligence-row > article { + display: block; + padding: 0.28rem 0.4rem; + } + .civic-ledger-page .facility-card-header h3, + .civic-ledger-page .facility-card-summary h4, + .civic-ledger-page .facility-card-topics h4, + .civic-ledger-page .facility-card-recommended h4 { + display: inline; + font-size: 0.78rem; + line-height: 1.2; + margin: 0; + } + .civic-ledger-page .facility-card-header p, + .civic-ledger-page .facility-card-topics, + .civic-ledger-page .facility-card-summary, + .civic-ledger-page .facility-card-recommended { + display: inline; + font-size: 0.72rem; + line-height: 1.25; + margin: 0; + } + .civic-ledger-page .facility-card-summary, + .civic-ledger-page .facility-card-recommended { + border-top: 0; + padding-top: 0; + } + .civic-ledger-page .facility-card-header h3::after, + .civic-ledger-page .facility-card-summary h4::after, + .civic-ledger-page .facility-card-topics h4::after, + .civic-ledger-page .facility-card-recommended h4::after { + content: ": "; + } + .civic-ledger-page .facility-card-header p::before, + .civic-ledger-page .facility-card-summary::before, + .civic-ledger-page .facility-card-topics::before, + .civic-ledger-page .facility-card-recommended::before { + content: " · "; + } + .civic-ledger-page .facility-card-title { + display: inline; + } + .civic-ledger-page .facility-card-summary dl, + .civic-ledger-page .facility-card-recommended dl { + display: inline; + margin: 0; + } + .civic-ledger-page .facility-card-summary dl > div, + .civic-ledger-page .facility-card-recommended dl > div { + display: inline; + } + .civic-ledger-page .facility-card-summary dt, + .civic-ledger-page .facility-card-summary dd, + .civic-ledger-page .facility-card-recommended dt, + .civic-ledger-page .facility-card-recommended dd { + display: inline; + font-size: inherit; + line-height: inherit; + margin: 0; + } + .civic-ledger-page .facility-card-summary dt::after, + .civic-ledger-page .facility-card-recommended dt::after { + content: ": "; + } + .civic-ledger-page .facility-card-summary dl > div + div::before, + .civic-ledger-page .facility-card-recommended dl > div + div::before { + content: " · "; + } + .civic-ledger-page .facility-card-topics ul { + display: inline; + gap: 0; + } + .civic-ledger-page .facility-card-topics li { + display: inline; + } + .civic-ledger-page .facility-card-topics .review-chip { + display: inline; + font-size: inherit; + padding: 0; + } .facility-inventory-item, .facility-overview-summary, .facility-activity-summary, diff --git a/tests/unit/test_hosted_facility_intelligence_query_shapes.py b/tests/unit/test_hosted_facility_intelligence_query_shapes.py index 98905a19..9f02bbb0 100644 --- a/tests/unit/test_hosted_facility_intelligence_query_shapes.py +++ b/tests/unit/test_hosted_facility_intelligence_query_shapes.py @@ -119,7 +119,7 @@ def test_postgresql_count_hydration_page_and_review_queries_are_bounded() -> Non dialect_name="postgresql", ) page_sql = _postgresql_sql(page_statement) - assert " limit 25" in page_sql + assert f" limit {reads.FACILITY_INTELLIGENCE_PAGE_SIZE}" in page_sql assert " offset " not in f" {page_sql} " review_next_statement = reads._facility_intelligence_bounded_limit( diff --git a/tests/unit/test_hosted_facility_priorities.py b/tests/unit/test_hosted_facility_priorities.py index 900ed347..0f36c249 100644 --- a/tests/unit/test_hosted_facility_priorities.py +++ b/tests/unit/test_hosted_facility_priorities.py @@ -83,6 +83,59 @@ def test_compare_facilities_print_scope_is_outside_print_hidden_filters() -> Non assert "Applied filters: Facility type: 430; Finding: Unsubstantiated" in html +def test_compare_facilities_print_compacts_cards_without_changing_screen_content() -> None: + """The print treatment condenses the current result page, not its content.""" + context = reviewer_ui.build_local_test_reviewer_ui_context() + status, _content_type, body = route_response( + CCLD_FACILITY_REVIEW_INTELLIGENCE_PATH, + reviewer_ui_context=context, + ) + repeat_status, _repeat_content_type, repeat_body = route_response( + CCLD_FACILITY_REVIEW_INTELLIGENCE_PATH, + reviewer_ui_context=context, + ) + + html = body.decode("utf-8") + repeat_html = repeat_body.decode("utf-8") + print_css = html.rsplit("@media print", 1)[1].split( + "@media (prefers-reduced-motion: reduce)", 1 + )[0] + + assert status == repeat_status == 200 + assert html == repeat_html + assert '
= 1 + for required_content in ( + "Find Facilities That May Need Closer Review", + "Complaint Patterns", + "Applied filters:", + "Facility ID", + "Complaints", + "Recommended complaint", + "Source availability", + ): + assert required_content in html + for screen_only_control in ( + "Apply filters", + "Open Facility Overview", + "Review complaint", + "Previous", + "Next", + ): + assert screen_only_control in html + + assert "column-count:" not in print_css + assert ".facility-card-header h3::after" in print_css + assert "display: inline;" in print_css + assert ".facility-inventory-context--bottom" in print_css + assert "display: none;" in print_css + assert "break-inside: avoid;" in print_css + assert "page-break-inside: avoid;" in print_css + assert "content: \": \";" in print_css + assert "content: \" · \";" in print_css + + def test_facility_priorities_orders_by_visible_factors_and_links_to_records() -> None: with _priority_connection() as connection: _insert_facility_bundle( @@ -1277,7 +1330,18 @@ def test_facility_intelligence_projects_raw_type_codes_and_labels_all_options() def test_facility_intelligence_pagination_boundaries_and_exact_position_wording() -> None: - cases = (0, 1, 24, 25, 26, 49, 50, 51) + page_size = source_derived_reads.FACILITY_INTELLIGENCE_PAGE_SIZE + assert page_size == 14 + cases = ( + 0, + 1, + page_size - 1, + page_size, + page_size + 1, + 2 * page_size - 1, + 2 * page_size, + 2 * page_size + 1, + ) for facility_count in cases: with _priority_connection() as connection: _insert_facility_population(connection, facility_count) @@ -1286,7 +1350,7 @@ def test_facility_intelligence_pagination_boundaries_and_exact_position_wording( reviewer_ui_context=reviewer_ui_context_for_connection(connection), ) first_html = body.decode("utf-8") - expected_last = min(facility_count, 25) + expected_last = min(facility_count, page_size) expected_first = 1 if facility_count else 0 assert status == 200 assert ( @@ -1300,24 +1364,27 @@ def test_facility_intelligence_pagination_boundaries_and_exact_position_wording( 'is-disabled" aria-disabled="true">Previous' in first_html ) - if facility_count <= 25: + if facility_count <= page_size: assert _pagination_href(first_html, "Next") is None assert 'aria-disabled="true">Next' in first_html continue next_href = _pagination_href(first_html, "Next") assert next_href is not None - assert 'aria-label="Next facilities, showing 26–' in first_html + assert f'aria-label="Next facilities, showing {page_size + 1}–' in first_html second_status, _second_type, second_body = route_response( next_href, reviewer_ui_context=reviewer_ui_context_for_connection(connection), ) second_html = second_body.decode("utf-8") - second_last = min(facility_count, 50) + second_last = min(facility_count, 2 * page_size) assert second_status == 200 - assert f"Showing 26–{second_last} of {facility_count} facilities" in second_html + assert ( + f"Showing {page_size + 1}–{second_last} of {facility_count} facilities" + in second_html + ) assert _pagination_href(second_html, "Previous") is not None - if facility_count <= 50: + if facility_count <= 2 * page_size: assert _pagination_href(second_html, "Next") is None assert 'aria-disabled="true">Next' in second_html else: @@ -1329,7 +1396,10 @@ def test_facility_intelligence_pagination_boundaries_and_exact_position_wording( ) final_html = final_body.decode("utf-8") assert final_status == 200 - assert "Showing 51–51 of 51 facilities" in final_html + assert ( + f"Showing {2 * page_size + 1}–{facility_count} of {facility_count} facilities" + in final_html + ) assert 'aria-disabled="true">Next' in final_html @@ -1361,7 +1431,7 @@ def pagination_hrefs(markup: str, label: str) -> list[str]: assert status == 200 assert first_html.count('aria-label="Top facility result pages"') == 1 assert first_html.count('aria-label="Bottom facility result pages"') == 1 - assert first_html.count("Showing 1–25 of 51 facilities") == 2 + assert first_html.count("Showing 1–14 of 51 facilities") == 2 assert first_html.count('aria-disabled="true">Previous') == 2 assert len(first_next) == 2 assert first_next[0] == first_next[1] @@ -1377,25 +1447,24 @@ def pagination_hrefs(markup: str, label: str) -> list[str]: ): assert value in first_next[0] - middle_status, _middle_type, middle_body = route_response( - first_next[0], reviewer_ui_context=context - ) - middle_html = middle_body.decode("utf-8") - middle_previous = pagination_hrefs(middle_html, "Previous") - middle_next = pagination_hrefs(middle_html, "Next") - assert middle_status == 200 - assert len(middle_previous) == len(middle_next) == 2 - assert middle_previous[0] == middle_previous[1] - assert middle_next[0] == middle_next[1] - - final_status, _final_type, final_body = route_response( - middle_next[0], reviewer_ui_context=context - ) - final_html = final_body.decode("utf-8") - assert final_status == 200 - assert len(pagination_hrefs(final_html, "Previous")) == 2 - assert pagination_hrefs(final_html, "Next") == [] - assert final_html.count('aria-disabled="true">Next') == 2 + current_href = first_next[0] + while current_href: + current_status, _current_type, current_body = route_response( + current_href, reviewer_ui_context=context + ) + current_html = current_body.decode("utf-8") + current_previous = pagination_hrefs(current_html, "Previous") + current_next = pagination_hrefs(current_html, "Next") + assert current_status == 200 + assert len(current_previous) == 2 + assert current_previous[0] == current_previous[1] + if current_next: + assert len(current_next) == 2 + assert current_next[0] == current_next[1] + current_href = current_next[0] + else: + assert current_html.count('aria-disabled="true">Next') == 2 + current_href = "" for view in ("licensing-visit-activity", "complaint-activity-over-time"): other_status, _other_type, other_body = route_response( @@ -1425,10 +1494,10 @@ def test_facility_intelligence_seek_pages_have_no_duplicates_or_omissions() -> N positions.append(match.group(0)) href = _pagination_href(html, "Next") or "" + page_size = source_derived_reads.FACILITY_INTELLIGENCE_PAGE_SIZE assert positions == [ - "Showing 1–25 of 51 facilities", - "Showing 26–50 of 51 facilities", - "Showing 51–51 of 51 facilities", + f"Showing {start}–{min(start + page_size - 1, 51)} of 51 facilities" + for start in range(1, 52, page_size) ] assert len(seen) == len(set(seen)) == 51 assert seen == [f"Page Facility {index:03d}" for index in range(51)] @@ -1465,7 +1534,10 @@ def test_facility_intelligence_continuations_preserve_filters_and_reject_bad_sta reviewer_ui_context=context, ) assert next_status == 200 - assert "Showing 26–26 of 26 facilities" in next_body.decode("utf-8") + assert ( + f"Showing {source_derived_reads.FACILITY_INTELLIGENCE_PAGE_SIZE + 1}–26" + " of 26 facilities" + ) in next_body.decode("utf-8") token = parse_qs(urlsplit(next_href).query)["continuation"][0] malformed_status, _malformed_type, malformed_body = route_response( @@ -1543,51 +1615,34 @@ def test_facility_intelligence_forward_and_backward_ranges_use_anchor_rank() -> with _priority_connection() as connection: _insert_facility_population(connection, 51) context = reviewer_ui_context_for_connection(connection) - first_status, _first_type, first_body = route_response( - f"{CCLD_FACILITY_REVIEW_INTELLIGENCE_PATH}?sort=facility_name", - reviewer_ui_context=context, - ) - second_href = _pagination_href(first_body.decode("utf-8"), "Next") - assert first_status == 200 - assert second_href is not None - - second_status, _second_type, second_body = route_response( - second_href, - reviewer_ui_context=context, - ) - second_html = second_body.decode("utf-8") - third_href = _pagination_href(second_html, "Next") - assert second_status == 200 - assert "Showing 26–50 of 51 facilities" in second_html - assert third_href is not None - - third_status, _third_type, third_body = route_response( - third_href, - reviewer_ui_context=context, - ) - third_html = third_body.decode("utf-8") - back_to_second_href = _pagination_href(third_html, "Previous") - assert third_status == 200 - assert "Showing 51–51 of 51 facilities" in third_html - assert back_to_second_href is not None - - back_status, _back_type, back_body = route_response( - back_to_second_href, - reviewer_ui_context=context, - ) - back_html = back_body.decode("utf-8") - back_to_first_href = _pagination_href(back_html, "Previous") - assert back_status == 200 - assert "Showing 26–50 of 51 facilities" in back_html - assert back_to_first_href is not None + href = f"{CCLD_FACILITY_REVIEW_INTELLIGENCE_PATH}?sort=facility_name" + forward_positions: list[str] = [] + last_html = "" + while href: + status, _content_type, body = route_response( + href, reviewer_ui_context=context + ) + last_html = body.decode("utf-8") + match = re.search(r"Showing \d+–\d+ of 51 facilities", last_html) + assert status == 200 + assert match is not None + forward_positions.append(match.group(0)) + href = _pagination_href(last_html, "Next") or "" - first_again_status, _first_again_type, first_again_body = route_response( - back_to_first_href, - reviewer_ui_context=context, - ) + backward_positions: list[str] = [] + href = _pagination_href(last_html, "Previous") or "" + while href: + status, _content_type, body = route_response( + href, reviewer_ui_context=context + ) + html = body.decode("utf-8") + match = re.search(r"Showing \d+–\d+ of 51 facilities", html) + assert status == 200 + assert match is not None + backward_positions.append(match.group(0)) + href = _pagination_href(html, "Previous") or "" - assert first_again_status == 200 - assert "Showing 1–25 of 51 facilities" in first_again_body.decode("utf-8") + assert backward_positions == list(reversed(forward_positions[:-1])) def test_facility_intelligence_reads_only_current_page_and_uses_bounded_sql( @@ -1663,12 +1718,13 @@ def reject_unbounded_read(*_args: Any, **_kwargs: Any) -> Any: html = body.decode("utf-8") assert status == 200 - assert "Showing 26–50 of 51 facilities" in html + page_size = source_derived_reads.FACILITY_INTELLIGENCE_PAGE_SIZE + assert f"Showing {page_size + 1}–{2 * page_size} of 51 facilities" in html assert len(captured_pages) == 1 - assert len(captured_pages[0].facility_identities) == 25 - assert len(captured_pages[0].records) == 75 + assert len(captured_pages[0].facility_identities) == page_size + assert len(captured_pages[0].records) == page_size * 3 assert len(captured_state_keys) == 1 - assert len(captured_state_keys[0]) == 25 + assert len(captured_state_keys[0]) == page_size offset_statements = [ statement for statement in statements if " offset " in f" {statement} " ] @@ -1799,7 +1855,10 @@ def test_facility_intelligence_pagination_preserves_authorization_and_batch_isol assert denied_status == 401 assert "Facility intelligence unavailable" in denied_body.decode("utf-8") assert status == 200 - assert "Showing 1–25 of 26 facilities" in html + assert ( + f"Showing 1–{source_derived_reads.FACILITY_INTELLIGENCE_PAGE_SIZE} of 26 facilities" + in html + ) assert "Outside Scope Center" not in html diff --git a/tests/unit/test_hosted_reviewer_ui.py b/tests/unit/test_hosted_reviewer_ui.py index d9483dfb..82349a6b 100644 --- a/tests/unit/test_hosted_reviewer_ui.py +++ b/tests/unit/test_hosted_reviewer_ui.py @@ -90,6 +90,9 @@ import_seeded_corpus_artifact, load_seeded_corpus_artifact, ) +from ccld_complaints.hosted_app.source_derived_reads import ( + FACILITY_INTELLIGENCE_PAGE_SIZE, +) FIXTURE = Path("tests/fixtures/hosted_seeded_corpus/validated_seeded_corpus.json") TEST_SCOPE = LOCAL_REVIEWER_UI_SCOPE @@ -5349,6 +5352,53 @@ def test_fixture_demo_reviewer_context_keeps_seeded_fixture_scope() -> None: assert source_context.scope.scope_id == "seeded-ccld-fixture-2026-06-13" +def test_issue_642_pagination_fixture_is_local_only_and_preserves_existing_rows() -> None: + """The three-page corpus is confined to the explicit local evidence launch.""" + required_environment = { + "CCLD_HOSTED_PAGE_DATA_MODE": "fixture-demo", + "CCLD_HOSTED_TESTER_AUTH_MODE": "local-dev", + "CCLD_HOSTED_TESTER_LOCAL_DEV_AUTH": "enabled", + "CCLD_HOSTED_ISSUE642_EVIDENCE_MODE": "enabled", + } + assert reviewer_ui._issue_642_evidence_fixture_enabled(required_environment) + for omitted_key in required_environment: + incomplete = dict(required_environment) + incomplete.pop(omitted_key) + assert not reviewer_ui._issue_642_evidence_fixture_enabled(incomplete) + + baseline = reviewer_ui._with_local_issue_641_visual_fixture_records( + reviewer_ui._with_local_rt_src_002_visual_fixture_records( + load_seeded_corpus_artifact(FIXTURE) + ) + ) + extended = reviewer_ui._with_local_issue_642_pagination_fixture_records(baseline) + assert extended.records[: len(baseline.records)] == baseline.records + assert baseline.records == reviewer_ui._with_local_issue_641_visual_fixture_records( + reviewer_ui._with_local_rt_src_002_visual_fixture_records( + load_seeded_corpus_artifact(FIXTURE) + ) + ).records + + facilities = [ + cast(Mapping[str, Any], record["facility"]) + for record in extended.records + if isinstance(record, Mapping) and isinstance(record.get("facility"), Mapping) + ] + fixture_numbers = [ + str(facility["external_facility_number"]) + for facility in facilities + if str(facility.get("external_facility_number", "")).startswith("642900") + ] + assert fixture_numbers == [f"642900{offset:03d}" for offset in range(1, 50)] + assert len(fixture_numbers) == len(set(fixture_numbers)) + assert len(facilities) >= 51 + assert FACILITY_INTELLIGENCE_PAGE_SIZE == 14 + page_count = ( + len(facilities) + FACILITY_INTELLIGENCE_PAGE_SIZE - 1 + ) // FACILITY_INTELLIGENCE_PAGE_SIZE + assert page_count >= 4 + + def test_default_postgres_reviewer_context_uses_loaded_ccld_corpus_scope( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/test_hosted_ui_evidence_capture.py b/tests/unit/test_hosted_ui_evidence_capture.py index e4055c41..28600ed7 100644 --- a/tests/unit/test_hosted_ui_evidence_capture.py +++ b/tests/unit/test_hosted_ui_evidence_capture.py @@ -3274,6 +3274,298 @@ def test_issue_642_operated_capture_uses_native_input_and_records_state_metadata assert expected in script +def test_issue_644_mode_composes_the_existing_compare_facilities_capture_contract() -> None: + script = CAPTURE_SCRIPT.read_text(encoding="utf-8") + wrapper = WRAPPER_SCRIPT.read_text(encoding="utf-8") + + for expected in ( + "[switch]$Issue644", + "issue-644-local", + "elseif ($Issue644) { $issue644Routes }", + "$issue644Routes = @($issue642Routes + $issue643Routes + $issue644SurfaceRoute)", + "issue-644-surface-1189", + "StartsWith('issue-643-')", + "Issue642 -or $Issue643 -or $Issue644", + "#644 is deliberately a composition of the proven #642 contract", + "issue642-pagination-middle-page", + "issue642-pagination-last-page", + "page_size = $pageSize", + "unique_count = $uniqueIdentities.Count", + "innerWidth", + "visualViewportScale", + "devicePixelRatio", + ): + assert expected in script + assert "if ($Issue642 -or $Issue644) { $launcherArguments += '-Issue642Evidence' }" in wrapper + assert "if ($Issue644) { $captureArguments.Issue644 = $true }" in wrapper + + +def test_issue_642_pagination_reconciliation_uses_rendered_page_size_and_fails_closed() -> None: + helper = powershell_function( + "Test-Issue642PaginationReconciliation", "Invoke-Issue642OperatedInteractionCapture" + ) + + def pages(total: int, page_size: int) -> list[dict[str, object]]: + output: list[dict[str, object]] = [] + first = 1 + page_number = 0 + while first <= total: + page_number += 1 + last = min(first + page_size - 1, total) + output.append( + { + "first": first, + "last": last, + "total": total, + "identities": [ + f"facility-intelligence-result-{index}" for index in range(first, last + 1) + ], + "previousEnabled": page_number > 1, + "nextEnabled": last < total, + "url": "http://127.0.0.1:8010/ccld/facilities/intelligence?facility_type=430&facility_type=733&start_date=1900-01-01", + } + ) + first = last + 1 + return output + + valid_51 = pages(51, 14) + valid_other = pages(10, 3) + cases = { + "valid_51": valid_51, + "valid_other": valid_other, + "wrong_first_count": [ + {**valid_51[0], "identities": valid_51[0]["identities"][:-1]} + ] + valid_51[1:], + "wrong_middle_count": [ + valid_51[0], + {**valid_51[1], "identities": valid_51[1]["identities"][:-1]}, + ] + valid_51[2:], + "wrong_final_remainder": valid_51[:-1] + + [{**valid_51[-1], "identities": valid_51[-1]["identities"] + ["extra"]}], + "duplicate": [ + valid_51[0], + { + **valid_51[1], + "identities": [valid_51[0]["identities"][0]] + + valid_51[1]["identities"][1:], + }, + ] + valid_51[2:], + "skipped": [valid_51[0], {**valid_51[1], "first": 16}] + valid_51[2:], + "zero_page_size": [{**valid_51[0], "last": 0, "identities": []}] + valid_51[1:], + "control_mismatch": [{**valid_51[0], "previousEnabled": True}] + valid_51[1:], + "context_loss": [ + valid_51[0], + { + **valid_51[1], + "url": "http://127.0.0.1:8010/ccld/facilities/intelligence?facility_type=430", + }, + ] + valid_51[2:], + } + cases_json = json.dumps(cases).replace("'", "''") + ps_script = f""" +{helper} +$cases = ConvertFrom-Json -InputObject '{cases_json}' +$results = [ordered]@{{}} +foreach ($property in $cases.PSObject.Properties) {{ + try {{ + $value = Test-Issue642PaginationReconciliation -Pages @($property.Value) + $results[$property.Name] = [ordered]@{{ + pass = $true + page_size = $value.page_size + total = $value.total + page_counts = @($value.page_counts) + unique_count = $value.unique_count + duplicate_count = $value.duplicate_count + missing_count = $value.missing_count + }} + }} catch {{ + $results[$property.Name] = [ordered]@{{ pass = $false; message = $_.Exception.Message }} + }} +}} +$results | ConvertTo-Json -Depth 8 -Compress +""" + result = subprocess.run( + [powershell(), "-NoProfile", "-Command", ps_script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, plain_output(result) + observed = json.loads(plain_output(result).strip().splitlines()[-1]) + assert observed["valid_51"] == { + "pass": True, + "page_size": 14, + "total": 51, + "page_counts": [14, 14, 14, 9], + "unique_count": 51, + "duplicate_count": 0, + "missing_count": 0, + } + assert observed["valid_other"]["pass"] is True + assert observed["valid_other"]["page_counts"] == [3, 3, 3, 1] + for name in cases: + if name not in {"valid_51", "valid_other"}: + assert observed[name]["pass"] is False, name + assert "25" not in helper + + +def test_issue_644_automated_visual_prerequisites_fail_closed_before_packet_finalization( + tmp_path: Path, +) -> None: + stop_capture_fail = powershell_function("Stop-CaptureFail", "Test-AllowedBaseUrl") + prerequisites = powershell_function( + "Test-Issue644AutomatedVisualPrerequisites", "Write-Issue655PacketDiagnostics" + ) + packet = tmp_path / "packet" + diagnostics = packet / "diagnostics" + print_dir = packet / "print" + print_pages = packet / "print-pages" + diagnostics.mkdir(parents=True) + print_dir.mkdir() + print_pages.mkdir() + (print_dir / "compare.pdf").write_bytes(b"pdf") + + route_states = { + "issue-643-populated-desktop": (1440, 1200, 1200 * 12), + "issue-644-surface-1189": (1189, 671, 671 * 16), + "issue-643-populated-500": (500, 900, 900 * 16), + "issue-643-populated-390": (390, 844, 844 * 24), + "issue-643-populated-zoom-200": (1280, 900, 900 * 24), + } + route_rows = [] + for route_name, (width, height, document_height) in route_states.items(): + state_path = diagnostics / f"{route_name}-browser-state.json" + state_path.write_text( + json.dumps( + { + "viewport": { + "innerWidth": width, + "innerHeight": height, + "visualViewportScale": 1, + "devicePixelRatio": 1, + }, + "document": {"scrollHeight": document_height}, + } + ), + encoding="utf-8", + ) + route_rows.append( + { + "name": route_name, + "browserStatePath": f"diagnostics/{state_path.name}", + } + ) + + packet_literal = str(packet).replace("'", "''") + route_rows_literal = json.dumps(route_rows).replace("'", "''") + ps_script = f""" +{stop_capture_fail} +{prerequisites} +$packet = '{packet_literal}' +$routes = @((ConvertFrom-Json -InputObject '{route_rows_literal}')) +function Set-PrintValidation {{ + param([int]$PageCount) + $pages = @() + Get-ChildItem -LiteralPath (Join-Path $packet 'print-pages') -File | Remove-Item -Force + for ($index = 1; $index -le $PageCount; $index++) {{ + $name = 'page-{{0:D3}}.png' -f $index + Set-Content -LiteralPath (Join-Path $packet "print-pages/$name") -Value 'png' -NoNewline + $pages += [ordered]@{{ file = $name }} + }} + $validation = [ordered]@{{ + pdf = 'compare.pdf' + pageCount = $PageCount + pages = @($pages) + }} + $validation | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $packet 'issue-420-print-validation.json') +}} +function Invoke-Case {{ + param([string]$Name, [scriptblock]$Action) + try {{ & $Action; return 'PASS' }} catch {{ return $_.Exception.Message }} +}} +function Invoke-Prerequisites {{ + param([object[]]$RouteResults, [object]$Measurement = $null) + $parameters = @{{ + PacketDirectory = $packet + RouteResults = $RouteResults + }} + if ($null -ne $Measurement) {{ + $parameters.ManifestSurfaceMeasurement = $Measurement + }} + Test-Issue644AutomatedVisualPrerequisites @parameters | Out-Null +}} +Set-PrintValidation -PageCount 2 +$valid = Test-Issue644AutomatedVisualPrerequisites -PacketDirectory $packet -RouteResults $routes +$printTwenty = Invoke-Case 'print-twenty' {{ + Set-PrintValidation -PageCount 20 + Invoke-Prerequisites -RouteResults $routes +}} +$missingPrint = Invoke-Case 'missing-print' {{ + Remove-Item -LiteralPath (Join-Path $packet 'issue-420-print-validation.json') -Force + Invoke-Prerequisites -RouteResults $routes +}} +Set-PrintValidation -PageCount 2 +$missingDensity = Invoke-Case 'missing-density' {{ + $withoutMobile = @($routes | Where-Object {{ $_.name -ne 'issue-643-populated-390' }}) + Invoke-Prerequisites -RouteResults $withoutMobile +}} +$desktop = Join-Path $packet 'diagnostics/issue-643-populated-desktop-browser-state.json' +$originalDesktop = Get-Content -LiteralPath $desktop -Raw +$state = $originalDesktop | ConvertFrom-Json + $state.document.scrollHeight = 17057 +$state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $desktop +$densityExcess = Invoke-Case 'density-excess' {{ + Invoke-Prerequisites -RouteResults $routes +}} +Set-Content -LiteralPath $desktop -Value $originalDesktop +$surfaceConflict = Invoke-Case 'surface-conflict' {{ + $measurement = [pscustomobject]@{{ + innerWidth = 1189 + innerHeight = 671 + visualViewportScale = 1 + devicePixelRatio = 1.9250000715 + }} + Invoke-Prerequisites -RouteResults $routes -Measurement $measurement +}} +[ordered]@{{ + ValidPrint = $valid.print.result + ValidDensityCount = @($valid.densityResults).Count + ValidSurfaceDpr = $valid.surfaceMeasurement.devicePixelRatio + DensityArtifact = Test-Path -LiteralPath (Join-Path $packet $valid.densityArtifact) + PrintTwenty = $printTwenty + MissingPrint = $missingPrint + MissingDensity = $missingDensity + DensityExcess = $densityExcess + SurfaceConflict = $surfaceConflict + FileIndexExists = Test-Path -LiteralPath (Join-Path $packet 'file-index.json') + ZipExists = Test-Path -LiteralPath ($packet + '.zip') +}} | ConvertTo-Json -Compress +""" + result = subprocess.run( + [powershell(), "-NoProfile", "-Command", ps_script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, plain_output(result) + outcome = json.loads(result.stdout) + assert outcome["ValidPrint"] == "PASS" + assert outcome["ValidDensityCount"] == 5 + assert outcome["ValidSurfaceDpr"] == 1 + assert outcome["DensityArtifact"] is True + assert "observed page count=20" in outcome["PrintTwenty"] + assert "print validation is missing" in outcome["MissingPrint"] + assert "mandatory MOBILE density result is missing" in outcome["MissingDensity"] + assert "density ratio=" in outcome["DensityExcess"] + assert "manifest Surface metadata 'devicePixelRatio' conflicts" in outcome["SurfaceConflict"] + assert outcome["FileIndexExists"] is False + assert outcome["ZipExists"] is False + + def test_issue_655_mode_declares_dedicated_routes_operations_and_packet_artifacts() -> None: script = CAPTURE_SCRIPT.read_text(encoding="utf-8") required = ( From b3f8f390007256277670716396bd243836c7c27a Mon Sep 17 00:00:00 2001 From: Andrew Nichols Date: Fri, 7 Aug 2026 17:19:38 -0500 Subject: [PATCH 2/2] Finalize Issue 644 hosted acceptance evidence --- ...-644-compare-facilities-acceptance-plan.md | 13 +- scripts/capture-hosted-ui-evidence.ps1 | 87 ++++-- .../run-and-capture-hosted-ui-evidence.ps1 | 69 ++++- .../hosted_app/source_derived_reads.py | 6 +- src/ccld_complaints/hosted_app/ui_shell.py | 71 ++++- ...sted_facility_intelligence_query_shapes.py | 1 + tests/unit/test_hosted_facility_priorities.py | 19 +- tests/unit/test_hosted_reviewer_ui.py | 4 +- tests/unit/test_hosted_ui_evidence_capture.py | 269 ++++++++++++++++-- 9 files changed, 453 insertions(+), 86 deletions(-) diff --git a/docs/planning/issue-644-compare-facilities-acceptance-plan.md b/docs/planning/issue-644-compare-facilities-acceptance-plan.md index c5f2e975..8ec5c5ba 100644 --- a/docs/planning/issue-644-compare-facilities-acceptance-plan.md +++ b/docs/planning/issue-644-compare-facilities-acceptance-plan.md @@ -66,13 +66,12 @@ screen markup, pagination, filtering, or fixture scope. The separate screen-density investigation found no combined Issue #642/#643 state or stale measurement: the populated desktop route rendered one normal -25-facility page, whose main content occupied nearly the entire measured -document. The product correction keeps the existing card content, controls, -cursor pagination, routes, and evidence contract, but reduces the normal -Compare Facilities page window to fourteen complete facility rows. This uses -the existing pagination mechanism instead of hiding content or weakening any -density ceiling; the remaining matching facilities stay available through the -same descriptive Previous and Next controls. +25-facility page, whose stacked ordinary cards occupied nearly the entire +measured document. RT-PAG-001 retains the approved 25-row seek/keyset page and +the existing content, controls, routes, and evidence contract. The compact +inventory treatment instead groups the same reviewer facts and actions into +responsive rows, preserving normal document flow outside desktop without +hiding content or weakening a density ceiling. ## Stop conditions diff --git a/scripts/capture-hosted-ui-evidence.ps1 b/scripts/capture-hosted-ui-evidence.ps1 index 7d222e2d..ce7114cc 100644 --- a/scripts/capture-hosted-ui-evidence.ps1 +++ b/scripts/capture-hosted-ui-evidence.ps1 @@ -2102,10 +2102,27 @@ function Get-Issue642PaginationPage { } } +function Test-Issue642CanonicalInventory { + param([object]$Page) + if ($null -eq $Page) { throw 'Issue #642 canonical inventory pagination evidence is missing.' } + if ([int]$Page.total -ne 53) { throw "Issue #642 canonical inventory requires total=53; observed $($Page.total)." } + if ([int]$Page.first -ne 1 -or [int]$Page.last -ne 25 -or @($Page.identities).Count -ne 25) { + throw "Issue #642 canonical inventory requires rendered first range 1-25 of 53; observed $($Page.first)-$($Page.last) of $($Page.total)." + } + if ([bool]$Page.previousEnabled -or -not [bool]$Page.nextEnabled) { + throw 'Issue #642 canonical inventory requires Previous unavailable and Next enabled on the rendered first page.' + } + $uri = [uri]$Page.url + if ($uri.AbsolutePath -ne '/ccld/facilities/intelligence' -or -not [string]::IsNullOrWhiteSpace($uri.Query)) { + throw "Issue #642 canonical inventory must be captured from the unfiltered Compare Facilities route; observed '$($Page.url)'." + } + return [ordered]@{ canonicalInventoryTotal = 53; page_size = 25; first = 1; last = 25; nextEnabled = $true; previousEnabled = $false; url = [string]$Page.url } +} + function Test-Issue642PaginationReconciliation { param([object[]]$Pages) $pages = @($Pages) - if ($pages.Count -lt 2) { throw 'Issue #642 pagination reconciliation requires at least a first and final page.' } + if ($pages.Count -lt 1) { throw 'Issue #642 pagination reconciliation requires at least one rendered page.' } $total = [int]$pages[0].total $pageSize = [int]$pages[0].last - [int]$pages[0].first + 1 if ($total -le 0) { throw "Issue #642 pagination reconciliation requires a positive total count; observed $total." } @@ -2227,6 +2244,7 @@ function Invoke-Issue642OperatedInteractionCapture { # keyboard text in month/day/year order and serializes ISO in the request. Invoke-CdpCommand -Session $Session -Method "Page.navigate" -Parameters @{ url = $compareUrl } | Out-Null Wait-CdpCondition -Session $Session -Expression "document.readyState === 'complete' && !!document.querySelector('#facility-intelligence-facility-type')" -Description "Issue #642 pagination clean origin" + $canonicalInventory = Test-Issue642CanonicalInventory -Page (Get-Issue642PaginationPage -Session $Session) $snapshot = & $before; Invoke-CdpClickSelector -Session $Session -Selector "#facility-intelligence-facility-type" Invoke-CdpClickSelector -Session $Session -Selector "input[name='facility_type'][value='430']" Invoke-CdpClickSelector -Session $Session -Selector "input[name='facility_type'][value='733']" @@ -2240,42 +2258,65 @@ function Invoke-Issue642OperatedInteractionCapture { [void]$paginationPages.Add($firstPage) $snapshot = & $before & $record "issue642-pagination-first-page" @("verify rendered first page", "verify Previous disabled and Next enabled") $snapshot.url $snapshot.focus "pagination controls" - for ($pageNumber = 2; $pageNumber -le 4; $pageNumber++) { + $states[$states.Count - 1] | Add-Member -NotePropertyName canonical_inventory_reconciliation -NotePropertyValue $canonicalInventory + $pageNumber = 2 + $maximumPaginationTransitions = [math]::Max(1, [int]$firstPage.total) + while ($true) { + $currentPage = Get-Issue642PaginationPage -Session $Session + if (-not $currentPage.nextEnabled) { + break + } + if ($paginationPages.Count -ge $maximumPaginationTransitions) { + throw "Issue #642 pagination traversal exceeded the rendered total of $($firstPage.total) pages without reaching a terminal state." + } $snapshot = & $before Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Next facilities']" Wait-CdpCondition -Session $Session -Expression "location.search.includes('continuation=') && document.querySelector('#facility-intelligence-position').innerText.includes('Showing') && document.activeElement === document.querySelector('#facility-intelligence-results')" -Description "operated Next page $pageNumber and result focus" $page = Get-Issue642PaginationPage -Session $Session + if ($page.first -le $currentPage.last -or $page.url -eq $currentPage.url) { + throw 'Issue #642 Next pagination traversal did not advance to a new rendered range.' + } [void]$paginationPages.Add($page) - $stateId = if ($pageNumber -eq 4) { 'issue642-pagination-last-page' } elseif ($pageNumber -eq 3) { 'issue642-pagination-page-3' } else { 'issue642-pagination-next' } + $stateId = if (-not $page.nextEnabled) { 'issue642-pagination-last-page' } elseif ($pageNumber -eq 3) { 'issue642-pagination-page-3' } else { 'issue642-pagination-next' } & $record $stateId @("click rendered Next control", "verify page $pageNumber range and pagination semantics") $snapshot.url $snapshot.focus "a.facility-pagination__control[aria-label^='Next facilities']" if ($pageNumber -eq 2) { & $record "issue642-pagination-page-2" @("reconcile the first middle page", "preserve the existing #642 operated-state contract") $snapshot.url $snapshot.focus "rendered Next control" } + $pageNumber++ } $paginationReconciliation = Test-Issue642PaginationReconciliation -Pages @($paginationPages) $states[$states.Count - 1] | Add-Member -NotePropertyName page_identity_reconciliation -NotePropertyValue $paginationReconciliation - $snapshot = & $before - Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Previous facilities']" - Wait-CdpCondition -Session $Session -Expression "document.querySelector('#facility-intelligence-position').innerText.includes('Showing')" -Description "operated Previous from final page" - & $record "issue642-pagination-middle-page" @("click rendered Previous control", "verify a true middle page with both pagination controls enabled") $snapshot.url $snapshot.focus "a.facility-pagination__control[aria-label^='Previous facilities']" - - $snapshot = & $before - Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Previous facilities']" - Wait-CdpCondition -Session $Session -Expression "document.querySelector('#facility-intelligence-position').innerText.includes('Showing')" -Description "operated Previous to page two" - $pageTwoUrl = [string](Invoke-CdpEvaluate -Session $Session -Expression "location.href") - & $record "issue642-pagination-page-2-previous" @("click rendered Previous control", "verify page-two filters and comparison context") $snapshot.url $snapshot.focus "a.facility-pagination__control[aria-label^='Previous facilities']" - - $snapshot = & $before - Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Previous facilities']" - Wait-CdpCondition -Session $Session -Expression "document.querySelector('#facility-intelligence-position').innerText.includes('Showing 1') && document.activeElement === document.querySelector('#facility-intelligence-results')" -Description "operated Previous page and result focus" - & $record "issue642-pagination-previous" @("click rendered Previous control", "verify first-page range and result focus") $snapshot.url $snapshot.focus "a.facility-pagination__control[aria-label^='Previous facilities']" + # Traverse backward only when the rendered terminal state proves a prior page + # exists. A filtered one-page result legitimately has neither navigation link. + $currentPage = Get-Issue642PaginationPage -Session $Session + if ($paginationPages.Count -eq 1 -and ($currentPage.previousEnabled -or $currentPage.nextEnabled)) { + throw 'Issue #642 one-page pagination state has inconsistent rendered controls.' + } + while ($currentPage.previousEnabled) { + $snapshot = & $before + Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Previous facilities']" + Wait-CdpCondition -Session $Session -Expression "document.querySelector('#facility-intelligence-position').innerText.includes('Showing') && document.activeElement === document.querySelector('#facility-intelligence-results')" -Description "operated Previous pagination page" + $previousPage = Get-Issue642PaginationPage -Session $Session + if ($previousPage.last -ge $currentPage.first) { + throw "Issue #642 Previous pagination traversal did not move to an earlier rendered range." + } + $stateId = if ($previousPage.previousEnabled) { 'issue642-pagination-middle-page' } else { 'issue642-pagination-previous' } + & $record $stateId @("click rendered Previous control", "verify rendered earlier-page range and pagination semantics") $snapshot.url $snapshot.focus "a.facility-pagination__control[aria-label^='Previous facilities']" + $currentPage = $previousPage + } + if ($currentPage.first -ne 1) { + throw "Issue #642 terminal pagination state lacks a required Previous control for rendered range $($currentPage.first)-$($currentPage.last)." + } - $snapshot = & $before - Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Next facilities']" - Wait-CdpCondition -Session $Session -Expression "location.search.includes('continuation=') && document.querySelector('#facility-intelligence-position').innerText.includes('Showing') && document.activeElement === document.querySelector('#facility-intelligence-results')" -Description "operated Next return to page two" - & $record "issue642-pagination-preserved" @("verify repeated filters, scalar date filter, complaint-patterns view, anchor, and result focus") $snapshot.url $snapshot.focus "pagination preservation verification" - $pageTwoUrl = [string](Invoke-CdpEvaluate -Session $Session -Expression "location.href") + $pageTwoUrl = [string]$currentPage.url + if ($currentPage.nextEnabled) { + $snapshot = & $before + Invoke-CdpClickSelector -Session $Session -Selector "a.facility-pagination__control[aria-label^='Next facilities']" + Wait-CdpCondition -Session $Session -Expression "location.search.includes('continuation=') && document.querySelector('#facility-intelligence-position').innerText.includes('Showing') && document.activeElement === document.querySelector('#facility-intelligence-results')" -Description "operated Next return to first subsequent page" + & $record "issue642-pagination-preserved" @("verify repeated filters, scalar date filter, complaint-patterns view, anchor, and result focus") $snapshot.url $snapshot.focus "pagination preservation verification" + $pageTwoUrl = [string](Invoke-CdpEvaluate -Session $Session -Expression "location.href") + } # Both details are opened from the same operated page-two origin, using the # rendered links and then the rendered return link or actual browser history. diff --git a/scripts/run-and-capture-hosted-ui-evidence.ps1 b/scripts/run-and-capture-hosted-ui-evidence.ps1 index 852b6d40..ed0a39d4 100644 --- a/scripts/run-and-capture-hosted-ui-evidence.ps1 +++ b/scripts/run-and-capture-hosted-ui-evidence.ps1 @@ -66,13 +66,52 @@ param( $ErrorActionPreference = "Stop" function Get-PortListenerObservation { - param([int]$LocalPort) - $listeners = @(Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue | ForEach-Object { - $name = '' - try { $name = (Get-Process -Id $_.OwningProcess -ErrorAction Stop).ProcessName } catch { } - [pscustomobject]@{ timestamp=(Get-Date).ToString('o'); localAddress=[string]$_.LocalAddress; localPort=[int]$_.LocalPort; state=[string]$_.State; owningPid=[int]$_.OwningProcess; processName=$name } - }) - [pscustomobject]@{ timestamp=(Get-Date).ToString('o'); listeners=$listeners } + param( + [int]$LocalPort, + [scriptblock]$PrimaryEnumerator = $null, + [scriptblock]$FallbackEnumerator = $null + ) + $primaryError = '' + try { + $primaryListeners = if ($PrimaryEnumerator) { @(& $PrimaryEnumerator $LocalPort) } else { @(Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction Stop) } + $listeners = @($primaryListeners | ForEach-Object { + $name = '' + try { $name = (Get-Process -Id $_.OwningProcess -ErrorAction Stop).ProcessName } catch { } + [pscustomobject]@{ timestamp=(Get-Date).ToString('o'); localAddress=[string]$_.LocalAddress; localPort=[int]$_.LocalPort; state=[string]$_.State; owningPid=[int]$_.OwningProcess; processName=$name } + }) + return [pscustomobject]@{ timestamp=(Get-Date).ToString('o'); state=if ($listeners.Count -eq 0) {'FREE'} else {'LISTENER_PRESENT'}; primaryEnumeration='succeeded'; fallbackEnumeration='not-required'; listeners=$listeners; enumerationError='' } + } + catch { $primaryError = $_.Exception.Message } + + try { + $fallbackLines = if ($FallbackEnumerator) { @(& $FallbackEnumerator $LocalPort) } else { + $output = @(& netstat -ano -p tcp 2>&1) + if ($LASTEXITCODE -ne 0) { throw "netstat exited with code $LASTEXITCODE." } + $output + } + $listeners = [System.Collections.ArrayList]::new(); $sawTcpRow = $false + foreach ($line in @($fallbackLines)) { + $text = [string]$line + if ($text -notmatch '^\s*TCP\s+') { continue } + $sawTcpRow = $true + if ($text -notmatch '^\s*TCP\s+(?\S+)\s+\S+\s+LISTENING\s+(?\d+)\s*$') { + if ($text -match '\s+LISTENING\s+') { throw "Unable to safely parse netstat listener row: $text" } + continue + } + $local = [string]$Matches.local + $listenerPid = [int]$Matches.pid + if ($local -notmatch ':(?\d+)$') { throw "Unable to safely parse netstat local endpoint: $local" } + if ([int]$Matches.port -ne $LocalPort) { continue } + $name = '' + try { $name = (Get-Process -Id $listenerPid -ErrorAction Stop).ProcessName } catch { } + [void]$listeners.Add([pscustomobject]@{ timestamp=(Get-Date).ToString('o'); localAddress=$local; localPort=$LocalPort; state='Listen'; owningPid=$listenerPid; processName=$name }) + } + if (-not $sawTcpRow -and @($fallbackLines).Count -eq 0) { throw 'netstat produced no parseable output.' } + return [pscustomobject]@{ timestamp=(Get-Date).ToString('o'); state=if ($listeners.Count -eq 0) {'FREE'} else {'LISTENER_PRESENT'}; primaryEnumeration='failed'; fallbackEnumeration='succeeded'; listeners=@($listeners); enumerationError="Primary Get-NetTCPConnection failed: $primaryError" } + } + catch { + return [pscustomobject]@{ timestamp=(Get-Date).ToString('o'); state='ENUMERATION_FAILED'; primaryEnumeration='failed'; fallbackEnumeration='failed'; listeners=@(); enumerationError="Primary Get-NetTCPConnection failed: $primaryError Fallback netstat failed: $($_.Exception.Message)" } + } } function Test-StableFreePort { @@ -81,24 +120,28 @@ function Test-StableFreePort { [int]$RequiredConsecutiveFree = 3, [int]$IntervalMilliseconds = 250, [int]$MaximumObservations = 20, - [scriptblock]$ObservationProvider = $null + [scriptblock]$ObservationProvider = $null, + [scriptblock]$PrimaryEnumerator = $null, + [scriptblock]$FallbackEnumerator = $null ) $observations = [System.Collections.ArrayList]::new(); $freeStreak = 0; $transient = $false for ($index = 0; $index -lt $MaximumObservations; $index++) { - $observation = if ($ObservationProvider) { & $ObservationProvider $index } else { Get-PortListenerObservation -LocalPort $LocalPort } + $observation = if ($ObservationProvider) { & $ObservationProvider $index } else { Get-PortListenerObservation -LocalPort $LocalPort -PrimaryEnumerator $PrimaryEnumerator -FallbackEnumerator $FallbackEnumerator } [void]$observations.Add($observation) + if ($observation.state -eq 'ENUMERATION_FAILED') { return [pscustomobject]@{ free=$false; state='ENUMERATION_FAILED'; transientListener=$transient; observations=@($observations) } } if (@($observation.listeners).Count -eq 0) { $freeStreak++ } else { if ($freeStreak -gt 0) { $transient = $true }; $freeStreak = 0 } - if ($freeStreak -ge $RequiredConsecutiveFree) { return [pscustomobject]@{ free=$true; transientListener=$transient; observations=@($observations) } } + if ($freeStreak -ge $RequiredConsecutiveFree) { return [pscustomobject]@{ free=$true; state='FREE'; transientListener=$transient; observations=@($observations) } } if ($index -lt ($MaximumObservations - 1)) { Start-Sleep -Milliseconds $IntervalMilliseconds } } - [pscustomobject]@{ free=$false; transientListener=$transient; observations=@($observations) } + [pscustomobject]@{ free=$false; state='LISTENER_PRESENT'; transientListener=$transient; observations=@($observations) } } function Write-PortObservations { param([object[]]$Observations) foreach ($observation in $Observations) { - if (@($observation.listeners).Count -eq 0) { Write-Host "$($observation.timestamp) listener=none" } - else { foreach ($listener in $observation.listeners) { Write-Host "$($listener.timestamp) listener=$($listener.localAddress):$($listener.localPort) state=$($listener.state) pid=$($listener.owningPid) process=$($listener.processName)" } } + if ($observation.state -eq 'ENUMERATION_FAILED') { Write-Host "$($observation.timestamp) listener=unresolved primary=$($observation.primaryEnumeration) fallback=$($observation.fallbackEnumeration) error=$($observation.enumerationError)" } + elseif (@($observation.listeners).Count -eq 0) { Write-Host "$($observation.timestamp) listener=none primary=$($observation.primaryEnumeration) fallback=$($observation.fallbackEnumeration)" } + else { foreach ($listener in $observation.listeners) { Write-Host "$($listener.timestamp) listener=$($listener.localAddress):$($listener.localPort) state=$($listener.state) pid=$($listener.owningPid) process=$($listener.processName) primary=$($observation.primaryEnumeration) fallback=$($observation.fallbackEnumeration)" } } } } diff --git a/src/ccld_complaints/hosted_app/source_derived_reads.py b/src/ccld_complaints/hosted_app/source_derived_reads.py index 80774fd0..08e26d71 100644 --- a/src/ccld_complaints/hosted_app/source_derived_reads.py +++ b/src/ccld_complaints/hosted_app/source_derived_reads.py @@ -168,10 +168,8 @@ class FacilityIntelligencePageRead: "date_signed", ) -# Fourteen complete facility rows retain the existing cursor-pagination contract -# while keeping the normal Compare Facilities state within its governed viewport -# density ceilings at supported widths. -FACILITY_INTELLIGENCE_PAGE_SIZE = 14 +# Approved by RT-PAG-001: use one bounded seek/keyset page of 25 facilities. +FACILITY_INTELLIGENCE_PAGE_SIZE = 25 _FACILITY_INTELLIGENCE_OPTION_LIMIT = 250 _FACILITY_INTELLIGENCE_SERIOUS_CATEGORY_LABELS = { "abuse or mistreatment": "Mistreatment-topic", diff --git a/src/ccld_complaints/hosted_app/ui_shell.py b/src/ccld_complaints/hosted_app/ui_shell.py index 43fe885f..de22b1b6 100644 --- a/src/ccld_complaints/hosted_app/ui_shell.py +++ b/src/ccld_complaints/hosted_app/ui_shell.py @@ -3488,9 +3488,9 @@ def _runtime_mode_label() -> str: } .facility-intelligence-row > article { display: grid; - gap: 1.1rem; + gap: 0.55rem; grid-template-columns: minmax(0, 1fr); - padding: 1rem; + padding: 0.75rem; } .facility-intelligence-card > * { min-width: 0; } .facility-intelligence-row :focus { @@ -3524,7 +3524,7 @@ def _runtime_mode_label() -> str: line-height: 1.35; } .facility-card-header p { - margin-bottom: 0.55rem; + margin: 0.25rem 0 0; } .facility-card-title { align-items: start; @@ -3539,9 +3539,11 @@ def _runtime_mode_label() -> str: flex: 0 0 auto; } .facility-card-summary, + .facility-card-topics, .facility-card-recommended { - border-top: 1px solid #d7d0c5; - padding-top: 0.85rem; + border-top: 0; + margin: 0; + padding-top: 0; } .facility-card-summary dl, .facility-card-recommended dl { @@ -3566,6 +3568,37 @@ def _runtime_mode_label() -> str: margin: 0; overflow-wrap: anywhere; } + .facility-card-summary h4, + .facility-card-recommended h4, + .facility-card-topics h4 { + display: inline; + margin-right: 0.35rem; + } + .facility-card-summary h4::after, + .facility-card-recommended h4::after, + .facility-card-topics h4::after { + content: ":"; + } + .facility-card-summary dl, + .facility-card-recommended dl { + display: inline; + } + .facility-card-summary dl > div, + .facility-card-recommended dl > div, + .facility-card-summary dt, + .facility-card-summary dd, + .facility-card-recommended dt, + .facility-card-recommended dd { + display: inline; + } + .facility-card-summary dt::after, + .facility-card-recommended dt::after { + content: ": "; + } + .facility-card-summary dl > div + div::before, + .facility-card-recommended dl > div + div::before { + content: " · "; + } .facility-card-topics ul { display: flex; flex-wrap: wrap; @@ -3943,6 +3976,34 @@ def _runtime_mode_label() -> str: top: 0.5rem; z-index: 3; } + .facility-intelligence-row > article { + align-items: start; + column-gap: 1rem; + grid-template-columns: minmax(16rem, 1.25fr) minmax(13rem, 0.8fr) minmax(15rem, 1fr); + padding: 0.7rem 0.9rem; + row-gap: 0.45rem; + } + .facility-card-header { + grid-column: 1; + grid-row: 1 / span 2; + } + .facility-card-summary { + grid-column: 2; + grid-row: 1; + } + .facility-card-recommended { + grid-column: 2; + grid-row: 2; + } + .facility-card-actions { + grid-column: 3; + grid-row: 1; + justify-content: flex-start; + } + .facility-card-topics { + grid-column: 3; + grid-row: 2; + } } @media (max-width: 900px) { .civic-ledger-page .facility-intelligence-filter-grid { diff --git a/tests/unit/test_hosted_facility_intelligence_query_shapes.py b/tests/unit/test_hosted_facility_intelligence_query_shapes.py index 9f02bbb0..96390f7f 100644 --- a/tests/unit/test_hosted_facility_intelligence_query_shapes.py +++ b/tests/unit/test_hosted_facility_intelligence_query_shapes.py @@ -57,6 +57,7 @@ def capture(statement: Any, *_args: Any, **_kwargs: Any) -> _EmptyResult: def test_postgresql_count_hydration_page_and_review_queries_are_bounded() -> None: + assert reads.FACILITY_INTELLIGENCE_PAGE_SIZE == 25 connection = cast( Connection, create_mock_engine("postgresql+psycopg://", lambda *_args, **_kwargs: None), diff --git a/tests/unit/test_hosted_facility_priorities.py b/tests/unit/test_hosted_facility_priorities.py index 0f36c249..7cced69a 100644 --- a/tests/unit/test_hosted_facility_priorities.py +++ b/tests/unit/test_hosted_facility_priorities.py @@ -796,6 +796,20 @@ def test_facility_intelligence_accessible_structure_and_safe_language() -> None: assert ".facility-inventory-context" in html assert "position: sticky" in html assert "position: static !important" in html + assert ( + "grid-template-columns: minmax(16rem, 1.25fr) minmax(13rem, 0.8fr) " + "minmax(15rem, 1fr);" + ) in html + assert ".facility-card-summary h4::after" in html + assert ".facility-card-recommended h4::after" in html + assert "content: \" · \";" in html + assert ( + ".facility-card-summary,\n" + " .facility-card-topics,\n" + " .facility-card-recommended {\n" + " border-top: 0;\n" + " margin: 0;" + ) in html assert ".facility-pagination, .facility-card-actions" in html assert ".site-header, .civic-header" in html assert ".copy-icon-button, .copy-text-control" in html @@ -1331,7 +1345,7 @@ def test_facility_intelligence_projects_raw_type_codes_and_labels_all_options() def test_facility_intelligence_pagination_boundaries_and_exact_position_wording() -> None: page_size = source_derived_reads.FACILITY_INTELLIGENCE_PAGE_SIZE - assert page_size == 14 + assert page_size == 25 cases = ( 0, 1, @@ -1431,7 +1445,8 @@ def pagination_hrefs(markup: str, label: str) -> list[str]: assert status == 200 assert first_html.count('aria-label="Top facility result pages"') == 1 assert first_html.count('aria-label="Bottom facility result pages"') == 1 - assert first_html.count("Showing 1–14 of 51 facilities") == 2 + page_size = source_derived_reads.FACILITY_INTELLIGENCE_PAGE_SIZE + assert first_html.count(f"Showing 1–{page_size} of 51 facilities") == 2 assert first_html.count('aria-disabled="true">Previous') == 2 assert len(first_next) == 2 assert first_next[0] == first_next[1] diff --git a/tests/unit/test_hosted_reviewer_ui.py b/tests/unit/test_hosted_reviewer_ui.py index 82349a6b..c5c726a9 100644 --- a/tests/unit/test_hosted_reviewer_ui.py +++ b/tests/unit/test_hosted_reviewer_ui.py @@ -5392,11 +5392,11 @@ def test_issue_642_pagination_fixture_is_local_only_and_preserves_existing_rows( assert fixture_numbers == [f"642900{offset:03d}" for offset in range(1, 50)] assert len(fixture_numbers) == len(set(fixture_numbers)) assert len(facilities) >= 51 - assert FACILITY_INTELLIGENCE_PAGE_SIZE == 14 + assert FACILITY_INTELLIGENCE_PAGE_SIZE == 25 page_count = ( len(facilities) + FACILITY_INTELLIGENCE_PAGE_SIZE - 1 ) // FACILITY_INTELLIGENCE_PAGE_SIZE - assert page_count >= 4 + assert page_count >= 3 def test_default_postgres_reviewer_context_uses_loaded_ccld_corpus_scope( diff --git a/tests/unit/test_hosted_ui_evidence_capture.py b/tests/unit/test_hosted_ui_evidence_capture.py index 28600ed7..f53b2c0d 100644 --- a/tests/unit/test_hosted_ui_evidence_capture.py +++ b/tests/unit/test_hosted_ui_evidence_capture.py @@ -86,6 +86,13 @@ def powershell_function(function_name: str, next_function_name: str) -> str: return script[start:end] +def wrapper_port_guard_functions() -> str: + script = WRAPPER_SCRIPT.read_text(encoding="utf-8") + start = script.index("function Get-PortListenerObservation") + end = script.index("\nfunction Get-TaskOwnedProcessIds", start) + return script[start:end] + + def run_optional_git_revision_resolution( *, origin_main: str | None = None, @@ -3274,6 +3281,23 @@ def test_issue_642_operated_capture_uses_native_input_and_records_state_metadata assert expected in script +def test_issue_642_operated_pagination_only_activates_an_enabled_next_control() -> None: + script = CAPTURE_SCRIPT.read_text(encoding="utf-8") + assert "for ($pageNumber = 2; $pageNumber -le 4; $pageNumber++)" not in script + loop_start = script.index("$pageNumber = 2") + loop_end = script.index("$page = Get-Issue642PaginationPage", loop_start) + loop = script[loop_start:loop_end] + assert "while ($true)" in loop + assert "if (-not $currentPage.nextEnabled)" in loop + click_call = ( + "Invoke-CdpClickSelector -Session $Session -Selector " + "\"a.facility-pagination__control[aria-label^='Next facilities']\"" + ) + assert loop.index("$currentPage = Get-Issue642PaginationPage") < loop.index( + click_call + ) + + def test_issue_644_mode_composes_the_existing_compare_facilities_capture_contract() -> None: script = CAPTURE_SCRIPT.read_text(encoding="utf-8") wrapper = WRAPPER_SCRIPT.read_text(encoding="utf-8") @@ -3300,7 +3324,9 @@ def test_issue_644_mode_composes_the_existing_compare_facilities_capture_contrac assert "if ($Issue644) { $captureArguments.Issue644 = $true }" in wrapper -def test_issue_642_pagination_reconciliation_uses_rendered_page_size_and_fails_closed() -> None: +def test_issue_642_pagination_reconciliation_uses_rendered_page_size_and_fails_closed( + tmp_path: Path, +) -> None: helper = powershell_function( "Test-Issue642PaginationReconciliation", "Invoke-Issue642OperatedInteractionCapture" ) @@ -3328,38 +3354,42 @@ def pages(total: int, page_size: int) -> list[dict[str, object]]: first = last + 1 return output - valid_51 = pages(51, 14) - valid_other = pages(10, 3) + valid_53 = pages(53, 25) + valid_filtered_51 = pages(51, 25) + valid_one_page = pages(7, 25) cases = { - "valid_51": valid_51, - "valid_other": valid_other, + "valid_53": valid_53, + "valid_filtered_51": valid_filtered_51, + "valid_one_page": valid_one_page, "wrong_first_count": [ - {**valid_51[0], "identities": valid_51[0]["identities"][:-1]} - ] + valid_51[1:], + {**valid_53[0], "identities": valid_53[0]["identities"][:-1]} + ] + valid_53[1:], "wrong_middle_count": [ - valid_51[0], - {**valid_51[1], "identities": valid_51[1]["identities"][:-1]}, - ] + valid_51[2:], - "wrong_final_remainder": valid_51[:-1] - + [{**valid_51[-1], "identities": valid_51[-1]["identities"] + ["extra"]}], + valid_53[0], + {**valid_53[1], "identities": valid_53[1]["identities"][:-1]}, + ] + valid_53[2:], + "wrong_final_remainder": valid_53[:-1] + + [{**valid_53[-1], "identities": valid_53[-1]["identities"] + ["extra"]}], "duplicate": [ - valid_51[0], + valid_53[0], { - **valid_51[1], - "identities": [valid_51[0]["identities"][0]] - + valid_51[1]["identities"][1:], + **valid_53[1], + "identities": [valid_53[0]["identities"][0]] + + valid_53[1]["identities"][1:], }, - ] + valid_51[2:], - "skipped": [valid_51[0], {**valid_51[1], "first": 16}] + valid_51[2:], - "zero_page_size": [{**valid_51[0], "last": 0, "identities": []}] + valid_51[1:], - "control_mismatch": [{**valid_51[0], "previousEnabled": True}] + valid_51[1:], + ] + valid_53[2:], + "skipped": [valid_53[0], {**valid_53[1], "first": 16}] + valid_53[2:], + "zero_page_size": [{**valid_53[0], "last": 0, "identities": []}] + valid_53[1:], + "control_mismatch": [{**valid_53[0], "previousEnabled": True}] + valid_53[1:], + "final_missing_previous": valid_53[:-1] + [{**valid_53[-1], "previousEnabled": False}], + "one_page_has_previous": [{**valid_one_page[0], "previousEnabled": True}], "context_loss": [ - valid_51[0], + valid_53[0], { - **valid_51[1], + **valid_53[1], "url": "http://127.0.0.1:8010/ccld/facilities/intelligence?facility_type=430", }, - ] + valid_51[2:], + ] + valid_53[2:], } cases_json = json.dumps(cases).replace("'", "''") ps_script = f""" @@ -3384,8 +3414,10 @@ def pages(total: int, page_size: int) -> list[dict[str, object]]: }} $results | ConvertTo-Json -Depth 8 -Compress """ + script_path = tmp_path / "issue642-pagination-reconciliation.ps1" + script_path.write_text(ps_script, encoding="utf-8") result = subprocess.run( - [powershell(), "-NoProfile", "-Command", ps_script], + [powershell(), "-NoProfile", "-File", str(script_path)], cwd=ROOT, text=True, capture_output=True, @@ -3393,23 +3425,98 @@ def pages(total: int, page_size: int) -> list[dict[str, object]]: ) assert result.returncode == 0, plain_output(result) observed = json.loads(plain_output(result).strip().splitlines()[-1]) - assert observed["valid_51"] == { + assert observed["valid_53"] == { + "pass": True, + "page_size": 25, + "total": 53, + "page_counts": [25, 25, 3], + "unique_count": 53, + "duplicate_count": 0, + "missing_count": 0, + } + assert observed["valid_filtered_51"] == { "pass": True, - "page_size": 14, + "page_size": 25, "total": 51, - "page_counts": [14, 14, 14, 9], + "page_counts": [25, 25, 1], "unique_count": 51, "duplicate_count": 0, "missing_count": 0, } - assert observed["valid_other"]["pass"] is True - assert observed["valid_other"]["page_counts"] == [3, 3, 3, 1] + assert observed["valid_one_page"] == { + "pass": True, + "page_size": 7, + "total": 7, + "page_counts": [7], + "unique_count": 7, + "duplicate_count": 0, + "missing_count": 0, + } for name in cases: - if name not in {"valid_51", "valid_other"}: + if name not in {"valid_53", "valid_filtered_51", "valid_one_page"}: assert observed[name]["pass"] is False, name assert "25" not in helper +def test_issue_642_canonical_inventory_cardinality_is_distinct_from_filtered_reconciliation( +) -> None: + helper = powershell_function( + "Test-Issue642CanonicalInventory", "Test-Issue642PaginationReconciliation" + ) + canonical = { + "first": 1, + "last": 25, + "total": 53, + "identities": [f"facility-intelligence-result-{index}" for index in range(1, 26)], + "previousEnabled": False, + "nextEnabled": True, + "url": "http://127.0.0.1:8010/ccld/facilities/intelligence", + } + cases = { + "canonical": canonical, + "canonical_total_dropped": {**canonical, "total": 51}, + "canonical_wrong_first_range": { + **canonical, + "last": 24, + "identities": canonical["identities"][:-1], + }, + "canonical_missing_next": {**canonical, "nextEnabled": False}, + "filtered_url": {**canonical, "url": canonical["url"] + "?facility_type=430"}, + } + cases_json = json.dumps(cases).replace("'", "''") + ps_script = f""" +{helper} +$cases = ConvertFrom-Json -InputObject '{cases_json}' +$results = [ordered]@{{}} +foreach ($property in $cases.PSObject.Properties) {{ + try {{ + $value = Test-Issue642CanonicalInventory -Page $property.Value + $results[$property.Name] = [ordered]@{{ pass=$true; value=$value }} + }} + catch {{ $results[$property.Name] = [ordered]@{{ pass=$false; message=$_.Exception.Message }} }} +}} +$results | ConvertTo-Json -Depth 8 -Compress +""" + result = subprocess.run( + [powershell(), "-NoProfile", "-Command", ps_script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, plain_output(result) + observed = json.loads(plain_output(result).strip().splitlines()[-1]) + assert observed["canonical"]["pass"] is True + assert observed["canonical"]["value"]["canonicalInventoryTotal"] == 53 + for name in ( + "canonical_total_dropped", + "canonical_wrong_first_range", + "canonical_missing_next", + "filtered_url", + ): + assert observed[name]["pass"] is False, name + + def test_issue_644_automated_visual_prerequisites_fail_closed_before_packet_finalization( tmp_path: Path, ) -> None: @@ -3850,6 +3957,108 @@ def test_evidence_wrapper_uses_bounded_stable_free_port_guard() -> None: assert "mouseReleased" in capture_script +def test_stable_free_port_fails_closed_and_uses_netstat_only_after_primary_failure() -> None: + guard = wrapper_port_guard_functions() + ps_lines = ( + "$freePrimary = { param($port) }", + "$listenerPrimary = { param($port) New-Listener $port 4242 }", + "$deniedPrimary = { param($port) throw 'Access denied' }", + "$fallbackFree = { param($port) @('Active Connections') }", + "$fallbackCalls = 0", + "function New-Listener {", + " param($port, $processId)", + " [pscustomobject]@{ LocalAddress='127.0.0.1'; LocalPort=$port;", + " State='Listen'; OwningProcess=$processId }", + "}", + "function Test-Guard {", + " param($primary, $fallback, $maximum = 3)", + " Test-StableFreePort -LocalPort 8010 -MaximumObservations $maximum `", + " -IntervalMilliseconds 0 -PrimaryEnumerator $primary `", + " -FallbackEnumerator $fallback", + "}", + "$fallbackV4 = { param($port) @(", + " ' TCP 127.0.0.1:8010' +", + " ' 0.0.0.0:0 LISTENING 6400') }", + "$fallbackV6 = { param($port) @(", + " ' TCP [::1]:8010' +", + " ' [::]:0 LISTENING 6401') }", + "$fallbackTimeWait = { param($port) @(", + " ' TCP 127.0.0.1:8010' +", + " ' 127.0.0.1:50000 TIME_WAIT 0') }", + "$fallbackOtherPort = { param($port) @(", + " ' TCP 127.0.0.1:8011' +", + " ' 0.0.0.0:0 LISTENING 6402') }", + "$fallbackMalformed = { param($port) @(", + " ' TCP 127.0.0.1:8010' +", + " ' 0.0.0.0:0 LISTENING not-a-pid') }", + "$unexpectedFallback = { param($port)", + " $script:fallbackCalls++; throw 'fallback should not run' }", + "$free = Test-Guard $freePrimary $unexpectedFallback", + "$primaryListener = Test-Guard $listenerPrimary $fallbackFree", + "$fallbackListenerV4 = Get-PortListenerObservation -LocalPort 8010 `", + " -PrimaryEnumerator $deniedPrimary -FallbackEnumerator $fallbackV4", + "$fallbackListenerV6 = Get-PortListenerObservation -LocalPort 8010 `", + " -PrimaryEnumerator $deniedPrimary -FallbackEnumerator $fallbackV6", + "$timeWait = Get-PortListenerObservation -LocalPort 8010 `", + " -PrimaryEnumerator $deniedPrimary -FallbackEnumerator $fallbackTimeWait", + "$otherPort = Get-PortListenerObservation -LocalPort 8010 `", + " -PrimaryEnumerator $deniedPrimary -FallbackEnumerator $fallbackOtherPort", + "$fallbackFailure = Get-PortListenerObservation -LocalPort 8010 `", + " -PrimaryEnumerator $deniedPrimary -FallbackEnumerator { throw 'netstat unavailable' }", + "$fallbackMalformedResult = Get-PortListenerObservation -LocalPort 8010 `", + " -PrimaryEnumerator $deniedPrimary -FallbackEnumerator $fallbackMalformed", + "$fallbackFreeStable = Test-Guard $deniedPrimary $fallbackFree", + "$sequence = @(", + " [pscustomobject]@{ state='FREE'; listeners=@() },", + " [pscustomobject]@{ state='LISTENER_PRESENT'; listeners=@(New-Listener 8010 4242) },", + " [pscustomobject]@{ state='FREE'; listeners=@() },", + " [pscustomobject]@{ state='FREE'; listeners=@() },", + " [pscustomobject]@{ state='FREE'; listeners=@() }", + ")", + "$sequenceIndex = 0", + "$reset = Test-StableFreePort -LocalPort 8010 -MaximumObservations 5 `", + " -IntervalMilliseconds 0 -ObservationProvider { param($index)", + " $result=$sequence[$script:sequenceIndex]; $script:sequenceIndex++; $result }", + "[ordered]@{", + " free = @($free.free, $free.state, @($free.observations).Count, $fallbackCalls)", + " primary_listener = @($primaryListener.free, $primaryListener.state)", + " fallback_v4 = @($fallbackListenerV4.state, $fallbackListenerV4.primaryEnumeration,", + " $fallbackListenerV4.fallbackEnumeration, $fallbackListenerV4.listeners[0].owningPid)", + " fallback_v6 = @($fallbackListenerV6.state, $fallbackListenerV6.listeners[0].owningPid)", + " time_wait = @($timeWait.state, @($timeWait.listeners).Count)", + " other_port = @($otherPort.state, @($otherPort.listeners).Count)", + " fallback_failure = @($fallbackFailure.state, @($fallbackFailure.listeners).Count)", + " malformed = @($fallbackMalformedResult.state,", + " @($fallbackMalformedResult.listeners).Count)", + " fallback_free = @($fallbackFreeStable.free, $fallbackFreeStable.state,", + " $fallbackFreeStable.observations[0].fallbackEnumeration)", + " reset = @($reset.free, $reset.state, $reset.transientListener,", + " @($reset.observations).Count)", + "} | ConvertTo-Json -Depth 8 -Compress", + ) + ps_script = guard + "\n" + "\n".join(ps_lines) + result = subprocess.run( + [powershell(), "-NoProfile", "-Command", ps_script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, plain_output(result) + observed = json.loads(plain_output(result).strip().splitlines()[-1]) + assert observed["free"] == [True, "FREE", 3, 0] + assert observed["primary_listener"] == [False, "LISTENER_PRESENT"] + assert observed["fallback_v4"] == ["LISTENER_PRESENT", "failed", "succeeded", 6400] + assert observed["fallback_v6"] == ["LISTENER_PRESENT", 6401] + assert observed["time_wait"] == ["FREE", 0] + assert observed["other_port"] == ["FREE", 0] + assert observed["fallback_failure"] == ["ENUMERATION_FAILED", 0] + assert observed["malformed"] == ["ENUMERATION_FAILED", 0] + assert observed["fallback_free"] == [True, "FREE", "succeeded"] + assert observed["reset"] == [True, "FREE", True, 5] + assert "Stop-Process" not in guard + + def test_issue_655_fixture_acquisition_uses_bounded_candidates_and_readiness() -> None: script = WRAPPER_SCRIPT.read_text(encoding="utf-8") required = (