Files
jiapuapp/tests/shared-interaction-accessibility-contract.ps1
T
2026-07-23 08:24:07 +08:00

116 lines
6.2 KiB
PowerShell

$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-Utf8([string]$path) { Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding UTF8 }
$button = Read-Utf8 'components/AppButton.vue'
foreach ($token in @('<button', ':disabled="disabled"', ':aria-label="label"', '@click="handleClick"')) {
if (-not $button.Contains($token)) { throw "AppButton accessibility contract missing: $token" }
}
if ($button -match '<view\s+[^>]*class="app-button"') { throw 'AppButton must use a native button root' }
$header = Read-Utf8 'components/PageHeader.vue'
foreach ($pattern in @(
'(?s)<button[^>]+class="header-back"[^>]+aria-label=',
'(?s)<button[^>]+class="header-icon-button header-notice"[^>]+aria-label=',
'(?s)<view\s+v-if="root"\s+class="header-brand"\s+aria-hidden="true"\s*>',
'(?s)v-if="root && notice"[^>]+:aria-label="unreadCount > 0',
'(?s)v-else-if="root && action"[^>]+class="header-action header-action--root"',
'(?s)v-else-if="action"\s+class="header-action"',
'(?s)<view\s+v-else\s+class="header-action-placeholder"\s+aria-hidden="true"\s*></view>'
)) {
if ($header -notmatch $pattern) { throw "PageHeader accessibility contract missing: $pattern" }
}
if ($header.Contains('fallbackUrl')) { throw 'PageHeader must not expose a duplicate deep-link fallback route' }
foreach ($token in @(
'import { goBack } from "@/utils/navigation.js";',
'customBack: { type: Boolean, default: false }',
'const emit = defineEmits(["notice", "action", "back"]);',
'@click="handleBack"',
'if (props.customBack) {',
'emit("back");',
'return goBack();'
)) {
if (-not $header.Contains($token)) { throw "PageHeader back contract missing: $token" }
}
if ($header -match 'aria-label="返回首页"|@click="\$emit\(''brand''\)"') {
throw 'PageHeader root brand without an action must be decorative and non-focusable'
}
if ($header -match ':disabled="!action"') {
throw 'PageHeader without an action must render a decorative placeholder instead of an unnamed disabled button'
}
if ($header -match 'getCurrentPages\s*\(|uni\.(navigateBack|reLaunch)\s*\(') {
throw 'PageHeader must delegate all ordinary back semantics to the navigation gateway'
}
if ($header -notmatch '(?s)const handleBack = \(\) => \{\s*if \(props\.customBack\) \{\s*emit\("back"\);\s*return;\s*\}\s*return goBack\(\);\s*\};') {
throw 'PageHeader custom back must stop after emitting; ordinary back must return the gateway Promise'
}
if ($header -notmatch '(?s)\.header-action\s*\{[^}]*display:\s*flex;[^}]*width:\s*100%;[^}]*min-height:\s*88rpx;[^}]*align-items:\s*center;[^}]*justify-content:\s*flex-end;') {
throw 'PageHeader text actions must provide an 88rpx native touch target'
}
$dialog = Read-Utf8 'components/AppDialog.vue'
foreach ($token in @('role="dialog"', 'aria-modal="true"', 'tabindex="-1"', '@keydown.esc.stop.prevent="cancel"', '@keydown.tab="trapFocus"', 'const trapFocus = (event) =>', 'querySelectorAll(', 'event.preventDefault()', 'max-height: calc(var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) - env(safe-area-inset-bottom))', 'overflow-y: auto')) {
if (-not $dialog.Contains($token)) { throw "AppDialog accessibility contract missing: $token" }
}
$toast = Read-Utf8 'components/AppToast.vue'
foreach ($token in @('v-show="visible"', 'role="status"', 'aria-live="polite"', 'aria-atomic="true"')) {
if (-not $toast.Contains($token)) { throw "AppToast accessibility contract missing: $token" }
}
$loading = Read-Utf8 'components/AppLoading.vue'
foreach ($token in @('role="status"', 'aria-live="polite"', 'aria-busy="true"', 'animation: none')) {
if (-not $loading.Contains($token)) { throw "AppLoading accessibility contract missing: $token" }
}
$tabbar = Read-Utf8 'components/AppTabbar.vue'
foreach ($token in @(
'<button',
'role="tablist"',
'role="tab"',
':aria-selected="active === item.key"',
'hover-class="tab-item--pressed"',
'aria-hidden="true"',
'import { goRoot } from "@/utils/navigation.js";',
'if (item.key === props.active) return;',
'return goRoot(item.routeKey);'
)) {
if (-not $tabbar.Contains($token)) { throw "AppTabbar interaction contract missing: $token" }
}
if ($tabbar -match '(?s)<view\s+v-for="item in items"[^>]+role="tab"') {
throw 'AppTabbar interactive tabs must use native buttons for keyboard activation'
}
if ($tabbar -notmatch '(?s)\.tab-item\s*\{[^}]*margin:\s*0;[^}]*border:\s*0;[^}]*border-radius:\s*0;[^}]*background:\s*transparent;[^}]*line-height:\s*normal;' -or $tabbar -notmatch '(?s)\.tab-item::after\s*\{[^}]*border:\s*0;') {
throw 'AppTabbar native buttons must reset platform default geometry and decoration'
}
foreach ($mapping in @(
@{ Key = 'genealogy'; RouteKey = 'G01' },
@{ Key = 'family'; RouteKey = 'F01' },
@{ Key = 'profile'; RouteKey = 'M01' }
)) {
$pattern = '(?s)\{\s*key:\s*"' + $mapping.Key + '",\s*label:\s*"[^"]+",\s*routeKey:\s*"' + $mapping.RouteKey + '",'
if ($tabbar -notmatch $pattern) {
throw "AppTabbar mapping drift: $($mapping.Key) -> $($mapping.RouteKey)"
}
}
$itemsBlock = [regex]::Match($tabbar, '(?s)const items = \[(?<Body>.*?)\];')
if (-not $itemsBlock.Success) { throw 'AppTabbar must declare its owned root item list' }
$itemCount = ([regex]::Matches($itemsBlock.Groups['Body'].Value, '\bkey:\s*"[^"]+"')).Count
$approvedItemCount = ([regex]::Matches($itemsBlock.Groups['Body'].Value, '\bkey:\s*"(?:genealogy|family|profile)"')).Count
if ($itemCount -ne 3 -or $approvedItemCount -ne 3) {
throw "AppTabbar must own exactly the three approved root items, found $itemCount total / $approvedItemCount approved"
}
if ($tabbar -match '\bpath\s*:|/pages/|uni\.reLaunch\s*\(') {
throw 'AppTabbar must consume root route keys instead of paths or direct Uni navigation'
}
if ($tabbar -notmatch '(?s)const switchRoot = \(item\) => \{\s*if \(item\.key === props\.active\) return;\s*return goRoot\(item\.routeKey\);\s*\};') {
throw 'AppTabbar active items must be no-ops and inactive items must return the gateway Promise'
}
$card = Read-Utf8 'components/GenealogyCard.vue'
foreach ($token in @('role="button"', ':aria-label="accessibleLabel"', 'hover-class="genealogy-card--pressed"', 'const accessibleLabel = computed(')) {
if (-not $card.Contains($token)) { throw "GenealogyCard interaction contract missing: $token" }
}
Write-Output 'SHARED-INTERACTION-ACCESSIBILITY-CONTRACT PASS'