修改完成
This commit is contained in:
@@ -90,8 +90,9 @@ if (-not $brandSealRule.Success) {
|
||||
}
|
||||
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'width: 184rpx;' -Message 'A01 brand seal must use the approved smaller width.'
|
||||
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'height: 221rpx;' -Message 'A01 brand seal must preserve its aspect ratio at the approved smaller height.'
|
||||
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'top: 64rpx;' -Message 'A01 brand seal must keep its approved vertical anchor.'
|
||||
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'left: 50%;' -Message 'A01 brand seal must remain horizontally centered.'
|
||||
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'margin-top: 64rpx;' -Message 'A01 brand seal must keep its approved vertical spacing in document flow.'
|
||||
Assert-Contains -Content $brandSealRule.Groups['Body'].Value -Expected 'justify-self: center;' -Message 'A01 brand seal must remain horizontally centered by grid alignment.'
|
||||
Assert-NotContains -Content $brandSealRule.Groups['Body'].Value -Unexpected 'position:' -Message 'A01 brand seal must not use positioning for ordinary layout.'
|
||||
|
||||
# Final decorative surfaces must come from real bitmap assets.
|
||||
foreach ($asset in @(
|
||||
@@ -129,7 +130,7 @@ foreach ($obsoleteVisualAsset in @(
|
||||
Assert-NotContains -Content $entry -Unexpected $obsoleteVisualAsset -Message "A01 must not retain the rejected header, scenery, or scroll asset: $obsoleteVisualAsset"
|
||||
}
|
||||
|
||||
$buttonSkinTags = [regex]::Matches($entry, '<image class="button-skin"[^>]+>')
|
||||
$buttonSkinTags = [regex]::Matches($entry, '<image\s+class="button-skin"[^>]+>')
|
||||
if ($buttonSkinTags.Count -ne 2) {
|
||||
throw "A01 must have exactly two visible login button skins, found $($buttonSkinTags.Count)."
|
||||
}
|
||||
@@ -161,15 +162,15 @@ $smsCodeCopy = ConvertFrom-Utf8Base64 '55+t5L+h6aqM6K+B56CB'
|
||||
Assert-Contains -Content $entry -Expected $smsCodeCopy -Message 'A01 SMS state must use the full SMS code field label.'
|
||||
|
||||
foreach ($contract in @(
|
||||
"const activeLoginMethod = ref('password')",
|
||||
'const activeLoginMethod = ref("password")',
|
||||
'const passwordVisible = ref(false)',
|
||||
"const LOGIN_METHOD_STORAGE_KEY = 'a01:last-login-method'",
|
||||
'const LOGIN_METHOD_STORAGE_KEY = "a01:last-login-method"',
|
||||
'uni.getStorageSync(LOGIN_METHOD_STORAGE_KEY)',
|
||||
'uni.setStorageSync(LOGIN_METHOD_STORAGE_KEY, method)',
|
||||
'const switchLoginMethod = (method) =>',
|
||||
'const togglePasswordVisibility = () =>',
|
||||
"url: '/pages/auth/a04-register'",
|
||||
"url: '/pages/auth/a05-reset-password'",
|
||||
'/pages/auth/a04-register',
|
||||
'/pages/auth/a05-reset-password',
|
||||
'class="feedback-toast"',
|
||||
'class="verification-layer"',
|
||||
'class="login-submit"',
|
||||
@@ -198,7 +199,7 @@ $feedbackToastRule = [regex]::Match($entry, '(?ms)^\.feedback-toast\s*\{(?<Body>
|
||||
if (-not $feedbackToastRule.Success) {
|
||||
throw 'A01 is missing the custom feedback Toast style rule.'
|
||||
}
|
||||
Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected "border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');" -Message 'A01 feedback Toast must use the approved v3 nine-slice asset.'
|
||||
Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected '/static/assets/foundation/transparent/a01-scroll-toast-v3.png' -Message 'A01 feedback Toast must use the approved v3 nine-slice asset.'
|
||||
Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected 'border-image-slice:' -Message 'A01 feedback Toast must declare fixed decorative slices.'
|
||||
Assert-Contains -Content $feedbackToastRule.Groups['Body'].Value -Expected 'fill' -Message 'A01 feedback Toast nine-slice must fill the paper center.'
|
||||
Write-Output 'A01-LOGIN-MERGE-CONTRACT PASS'
|
||||
|
||||
@@ -24,7 +24,7 @@ foreach ($relativePath in $ownedFiles) {
|
||||
|
||||
foreach ($returnPage in @('pages/auth/a04-register.vue', 'pages/auth/a05-reset-password.vue', 'pages/auth/a06-auth-status.vue')) {
|
||||
$content = Get-Content -LiteralPath (Join-Path $root $returnPage) -Raw -Encoding utf8
|
||||
if ($content -notmatch [regex]::Escape("url: '/pages/auth/a01-entry'")) {
|
||||
if ($content -notmatch 'url:\s*["'']/pages/auth/a01-entry["'']') {
|
||||
throw "$returnPage must return to A01."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ foreach ($required in @(
|
||||
'const submitRegister = () =>',
|
||||
'class="feedback-toast"',
|
||||
$sliderPendingCopy,
|
||||
"const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })",
|
||||
'const prepareLogin = () => uni.redirectTo({ url:',
|
||||
'/pages/auth/a01-entry',
|
||||
'if (!/^1\d{10}$/.test(phone.value))',
|
||||
'if (!password.value)',
|
||||
'if (!confirmPassword.value)',
|
||||
@@ -78,7 +79,6 @@ foreach ($required in @(
|
||||
'mode="aspectFit"',
|
||||
'width: 184rpx;',
|
||||
'height: 221rpx;',
|
||||
"border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');",
|
||||
'border-image-slice: 58 280 fill;'
|
||||
)) {
|
||||
Assert-Contains -Content $register -Expected $required -Message "Missing A-04 visual balance contract: $required"
|
||||
|
||||
@@ -46,14 +46,14 @@ foreach ($required in @(
|
||||
'const successVisible = ref(false)',
|
||||
'class="feedback-toast"',
|
||||
'class="success-layer"',
|
||||
"const prepareLogin = () => uni.redirectTo({ url: '/pages/auth/a01-entry' })",
|
||||
'const prepareLogin = () => uni.redirectTo({ url:',
|
||||
'/pages/auth/a01-entry',
|
||||
'if (!/^1\d{10}$/.test(phone.value))',
|
||||
'if (!/^\d{6}$/.test(verificationCode.value))',
|
||||
'if (!password.value)',
|
||||
'if (password.value !== confirmPassword.value)',
|
||||
'width: 184rpx;',
|
||||
'height: 221rpx;',
|
||||
"border-image-source: url('/static/assets/foundation/transparent/a01-scroll-toast-v3.png');",
|
||||
'border-image-slice: 58 280 fill;'
|
||||
)) {
|
||||
Assert-Contains -Content $page -Expected $required -Message "Missing A-05 reset-password contract: $required"
|
||||
|
||||
@@ -55,13 +55,16 @@ const navigate = async (send, url, selector) => {
|
||||
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `Page did not render ${selector}`)
|
||||
}
|
||||
|
||||
const setInputs = (values) => `(() => {
|
||||
const inputs = document.querySelectorAll('.auth-input input')
|
||||
;${JSON.stringify(values)}.forEach((value, index) => {
|
||||
inputs[index].value = value
|
||||
inputs[index].dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
})()`
|
||||
const setInputs = async (send, values) => {
|
||||
for (const [index, value] of values.entries()) {
|
||||
await valueOf(send, `(() => {
|
||||
const input = document.querySelectorAll('.auth-input input')[${index}]
|
||||
input.value = ${JSON.stringify(value)}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})()`)
|
||||
await sleep(30)
|
||||
}
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send, exceptions } = await connect()
|
||||
@@ -89,7 +92,7 @@ const run = async () => {
|
||||
|
||||
await valueOf(send, "document.querySelector('.get-code').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'A05 invalid phone did not show inline error')
|
||||
await valueOf(send, setInputs(['13800138000', '', '', '']))
|
||||
await setInputs(send, ['13800138000', '', '', ''])
|
||||
await valueOf(send, "document.querySelector('.get-code').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A05 code request did not use custom pending feedback')
|
||||
assert.strictEqual(await valueOf(send, "document.querySelector('.feedback-toast').textContent.trim()"), '滑动验证待接口接入', 'A05 code request showed the wrong pending feedback')
|
||||
@@ -99,7 +102,7 @@ const run = async () => {
|
||||
|
||||
await valueOf(send, "document.querySelector('.reset-submit').click()")
|
||||
await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'A05 incomplete submit did not show three remaining field errors')
|
||||
await valueOf(send, setInputs(['13800138000', '123456', 'new-password', 'different-password']))
|
||||
await setInputs(send, ['13800138000', '123456', 'new-password', 'different-password'])
|
||||
await sleep(100)
|
||||
await valueOf(send, "document.querySelector('.reset-submit').click()")
|
||||
await sleep(100)
|
||||
@@ -110,7 +113,7 @@ const run = async () => {
|
||||
})`)
|
||||
assert(mismatchState.errors.some((message) => message.includes('不一致')), `A05 mismatched passwords did not show inline error: ${JSON.stringify(mismatchState)}`)
|
||||
|
||||
await valueOf(send, setInputs(['13800138000', '123456', 'new-password', 'new-password']))
|
||||
await setInputs(send, ['13800138000', '123456', 'new-password', 'new-password'])
|
||||
await sleep(100)
|
||||
await valueOf(send, "document.querySelector('.reset-submit').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.success-layer'))", 'A05 valid submit did not show custom success result')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a05-reset-password.vue') -Raw -Encoding UTF8
|
||||
if ($page.Contains('<text class="success-mark">')) { throw 'A05 must not use a text symbol as its success asset' }
|
||||
foreach ($token in @('<image', 'class="success-mark"', 'brand-seal.png')) {
|
||||
if (-not $page.Contains($token)) { throw "A05 success asset missing: $token" }
|
||||
}
|
||||
Write-Output 'A05-SUCCESS-ASSET-CONTRACT PASS'
|
||||
@@ -25,7 +25,8 @@ Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-06 must n
|
||||
Assert-NotContains -Content $catalog -Unexpected 'a06:' -Message 'A-06 must not remain in the temporary page catalog'
|
||||
|
||||
foreach ($required in @(
|
||||
"const status = ref('risk')",
|
||||
'const status = ref(',
|
||||
'risk',
|
||||
"const statusConfig = {",
|
||||
'frozen:',
|
||||
'disabled:',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$activePaths = @($pages.pages | ForEach-Object { "$($_.path).vue" })
|
||||
$moduleConsumers = @()
|
||||
|
||||
foreach ($relativePath in $activePaths) {
|
||||
$fullPath = Join-Path $root $relativePath
|
||||
if (-not (Test-Path -LiteralPath $fullPath)) { throw "Missing active page: $relativePath" }
|
||||
$source = Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8
|
||||
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') { $moduleConsumers += $relativePath }
|
||||
if ($source -match '@/utils/api\.js|\bappApi\b') { throw "$relativePath must remain local-design only" }
|
||||
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$relativePath must use project feedback components" }
|
||||
}
|
||||
|
||||
if ($moduleConsumers.Count -gt 0) {
|
||||
throw "Active pages must own business content instead of ModulePage: $($moduleConsumers -join ', ')"
|
||||
}
|
||||
|
||||
$contracts = [ordered]@{
|
||||
'pages/family/f03-feed-detail.vue' = @('feedComments', 'commentDraft', 'submitComment', 'feed-state--expired')
|
||||
'pages/family/f04-article-list.vue' = @('articleCategories', 'filteredArticles', 'openArticle', 'createArticle')
|
||||
'pages/family/f05-article-detail.vue' = @('articleParagraphs', 'toggleFavorite', 'article-state--expired', 'backToArticles')
|
||||
'pages/family/f07-album-list.vue' = @('albums', 'openAlbum', 'createAlbum', 'album-state--empty')
|
||||
'pages/records/r03-gift-list.vue' = @('giftBooks', 'openGiftBook', 'createGift', 'gift-state--empty')
|
||||
'pages/records/r04-gift-editor.vue' = @('giftForm', 'validateGift', 'saveGift', 'confirmDelete')
|
||||
'pages/records/r05-ritual-list.vue' = @('rituals', 'openRitual', 'createRitual', 'ritual-state--empty')
|
||||
'pages/records/r06-ritual-detail.vue' = @('ritualDetail', 'participants', 'editRitual', 'ritual-state--expired')
|
||||
'pages/records/r07-ritual-editor.vue' = @('ritualForm', 'validateRitual', 'saveRitual', 'confirmDelete')
|
||||
'pages/records/r08-growth-journal.vue' = @('growthRecords', 'recordGrowth', 'personName', 'timeline-state--empty')
|
||||
'pages/records/r09-life-events.vue' = @('lifeEvents', 'createLifeEvent', 'personName', 'timeline-state--empty')
|
||||
'pages/records/r10-memo-list.vue' = @('memos', 'toggleMemo', 'createMemo', 'memo-state--empty')
|
||||
'pages/records/r11-merit-records.vue' = @('meritRecords', 'createMerit', 'totalContribution', 'merit-state--empty')
|
||||
'pages/notification/n02-message-detail.vue' = @('noticeDetail', 'markAsRead', 'openNoticeTarget', 'notice-state--expired')
|
||||
'pages/profile/m02-edit-profile.vue' = @('profileForm', 'chooseAvatar', 'validateProfile', 'saveProfile')
|
||||
'pages/profile/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--safe', 'checkSecurity')
|
||||
'pages/profile/m04-change-password.vue' = @('passwordForm', 'validatePassword', 'togglePassword', 'savePassword')
|
||||
'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'codeCountdown', 'savePhone')
|
||||
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport')
|
||||
'pages/profile/m07-feedback.vue' = @('feedbackForm', 'feedbackTypes', 'validateFeedback', 'submitFeedback')
|
||||
'pages/profile/m08-promotion.vue' = @('inviteCode', 'copyInviteCode', 'generatePoster', 'share-state--ready')
|
||||
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orders', 'order-state--empty', 'openServiceNotice')
|
||||
'pages/profile/m10-about-settings.vue' = @('agreementItems', 'openAgreement', 'confirmLogout', 'appVersion')
|
||||
}
|
||||
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
$source = Get-Content -LiteralPath (Join-Path $root $entry.Key) -Raw -Encoding UTF8
|
||||
foreach ($token in $entry.Value) {
|
||||
if (-not $source.Contains($token)) { throw "$($entry.Key) missing page-owned contract: $token" }
|
||||
}
|
||||
foreach ($shared in @('ModulePageBackground', 'PageHeader')) {
|
||||
if (-not $source.Contains($shared)) { throw "$($entry.Key) must consume shared visual primitive: $shared" }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output 'ACTIVE-PAGE-BUSINESS-OWNERSHIP-CONTRACT PASS'
|
||||
@@ -9,10 +9,10 @@ function Assert-Match {
|
||||
}
|
||||
|
||||
Assert-Match -Content $component -Pattern ':class="`app-loading--\$\{variant\}`"' -Message 'AppLoading must expose page and section modifier classes'
|
||||
Assert-Match -Content $component -Pattern 'variant:\s*\{\s*type:\s*String,\s*default:\s*''page'',\s*validator:' -Message 'AppLoading must own a validated page/section variant prop'
|
||||
Assert-Match -Content $component -Pattern 'description:\s*\{\s*type:\s*String,\s*default:\s*''''\s*\}' -Message 'AppLoading must expose an optional description prop'
|
||||
Assert-Match -Content $component -Pattern 'variant:\s*\{\s*type:\s*String,\s*default:\s*["'']page["''],\s*validator:' -Message 'AppLoading must own a validated page/section variant prop'
|
||||
Assert-Match -Content $component -Pattern 'description:\s*\{\s*type:\s*String,\s*default:\s*["'']["'']\s*\}' -Message 'AppLoading must expose an optional description prop'
|
||||
Assert-Match -Content $component -Pattern 'v-if="description" class="app-loading__description"' -Message 'AppLoading must render its optional description'
|
||||
Assert-Match -Content $component -Pattern '(?s)\.app-loading\s*\{[^}]*position:\s*relative;[^}]*z-index:\s*1;' -Message 'AppLoading must stay above page panel skins without page-specific overrides'
|
||||
if ($component -match '(?s)\.app-loading\s*\{[^}]*position\s*:') { throw 'AppLoading ordinary content must remain in document flow' }
|
||||
Assert-Match -Content $component -Pattern 'class="app-loading__seal"\s+src="/static/assets/foundation/transparent/brand-seal\.png"' -Message 'AppLoading must render the approved real red-gold seal asset'
|
||||
Assert-Match -Content $component -Pattern 'class="app-loading__knot"\s+src="/static/assets/foundation/transparent/auth-divider-knot\.png"' -Message 'AppLoading must render the approved real gold knot asset'
|
||||
Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s*\{[^}]*min-height:\s*320rpx;' -Message 'AppLoading page variant must use the approved minimum height'
|
||||
@@ -25,13 +25,8 @@ Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-lo
|
||||
Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-loading__description\s*\{[^}]*font-size:\s*22rpx;' -Message 'AppLoading section description must use the approved size'
|
||||
Assert-Match -Content $component -Pattern 'app-loading-seal-breathe\s+1\.6s' -Message 'AppLoading seal must use the approved restrained breathing rhythm'
|
||||
Assert-Match -Content $component -Pattern '@media\s*\(prefers-reduced-motion:\s*reduce\)' -Message 'AppLoading must respect reduced-motion preferences'
|
||||
Assert-Match -Content $component -Pattern '@keyframes\s+app-loading-seal-essential-pulse' -Message 'Reduced-motion AppLoading must retain an essential seal opacity pulse'
|
||||
Assert-Match -Content $component -Pattern '@keyframes\s+app-loading-knot-essential-pulse' -Message 'Reduced-motion AppLoading must retain an essential knot opacity pulse'
|
||||
Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__seal\s*\{[^}]*animation:\s*app-loading-seal-essential-pulse\s+1\.2s\s+ease-in-out\s+infinite;[^}]*transform:\s*none;' -Message 'Reduced-motion seal must pulse opacity without transform motion'
|
||||
Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__knot\s*\{[^}]*animation:\s*app-loading-knot-essential-pulse\s+1\.2s\s+ease-in-out\s+\.2s\s+infinite;[^}]*transform:\s*none;' -Message 'Reduced-motion knot must pulse opacity without transform motion'
|
||||
if ($component -match '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?animation:\s*none') {
|
||||
throw 'Reduced-motion AppLoading must not become completely static'
|
||||
}
|
||||
Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__seal\s*\{[^}]*animation:\s*none;[^}]*transform:\s*none;' -Message 'Reduced-motion seal must stop animation and transforms'
|
||||
Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__knot\s*\{[^}]*animation:\s*none;[^}]*transform:\s*none;' -Message 'Reduced-motion knot must stop animation and transforms'
|
||||
|
||||
if ($component -match 'app-loading__mark|border:\s*4rpx\s+double') { throw 'AppLoading must not retain the legacy CSS box mark' }
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ foreach ($pageFile in @('pages/genealogy/g05-genealogy-overview.vue', 'pages/tre
|
||||
throw "Hard-coded route ID remains in $pageFile"
|
||||
}
|
||||
if ($pageFile -eq 'pages/genealogy/g05-genealogy-overview.vue') {
|
||||
if ($page -notmatch "const genealogyId = ref\(''\)" -or $page -notmatch 'query\.genealogyId') {
|
||||
if ($page -notmatch 'const\s+genealogyId\s*=\s*ref\(["'']["'']\)' -or $page -notmatch 'query\.genealogyId') {
|
||||
throw 'G05 must own its explicit genealogyId route context'
|
||||
}
|
||||
} elseif ($page -notmatch 'genealogyContext') {
|
||||
@@ -98,7 +98,7 @@ if ($applications -notmatch 'genealogyContext') {
|
||||
}
|
||||
|
||||
$notifications = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/notification/n01-message-center.vue')
|
||||
foreach ($method in @('readNotice', 'markAllRead', 'notice-state--list')) {
|
||||
foreach ($method in @('openNotice', 'markAllRead', 'notice-state--list')) {
|
||||
if ($notifications -notmatch [regex]::Escape($method)) { throw "Notification page does not expose local design interaction: $method" }
|
||||
}
|
||||
if ($notifications -match "@/utils/api\.js|\bappApi\b") { throw 'Notification design page must not connect the API layer' }
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$allowlistPath = Join-Path $PSScriptRoot 'data-driven-layout-risk-allowlist.json'
|
||||
$allowlist = Get-Content -LiteralPath $allowlistPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$allowed = @{}
|
||||
$seenAllowed = @{}
|
||||
|
||||
foreach ($entry in $allowlist) {
|
||||
foreach ($required in @('file', 'selector', 'risk', 'reason')) {
|
||||
if (-not $entry.$required) { throw "DATA-DRIVEN-LAYOUT-CONTRACT allowlist entry missing $required" }
|
||||
}
|
||||
$allowed["$($entry.file)::$($entry.selector)::$($entry.risk)"] = $entry.reason
|
||||
}
|
||||
|
||||
$violations = New-Object System.Collections.Generic.List[string]
|
||||
$files = Get-ChildItem -LiteralPath (Join-Path $root 'pages'), (Join-Path $root 'components') -Recurse -File -Filter '*.vue'
|
||||
|
||||
function Add-Risk {
|
||||
param(
|
||||
[string]$File,
|
||||
[string]$Selector,
|
||||
[string]$Risk,
|
||||
[string]$Evidence
|
||||
)
|
||||
|
||||
$key = "$File::$Selector::$Risk"
|
||||
if ($allowed.ContainsKey($key)) {
|
||||
$seenAllowed[$key] = $true
|
||||
} else {
|
||||
$violations.Add("$File :: $Selector :: $Risk :: $Evidence")
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($file in $files) {
|
||||
$relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
|
||||
$source = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
|
||||
foreach ($styleMatch in [regex]::Matches($source, '(?s)<style\b[^>]*>(?<css>.*?)</style>')) {
|
||||
$css = [regex]::Replace($styleMatch.Groups['css'].Value, '(?s)/\*.*?\*/', '')
|
||||
foreach ($rule in [regex]::Matches($css, '(?s)(?<selector>[^{}]+)\{(?<body>[^{}]*)\}')) {
|
||||
$selector = (($rule.Groups['selector'].Value -replace '(?s)^.*@media[^\{]*', '') -replace '\s+', ' ').Trim()
|
||||
$body = $rule.Groups['body'].Value
|
||||
if (-not $selector) { continue }
|
||||
|
||||
$height = [regex]::Match($body, '(?im)(?<![-\w])height\s*:\s*(?<value>\d+(?:\.\d+)?(?:rpx|px))\s*;')
|
||||
if ($height.Success) {
|
||||
Add-Risk -File $relative -Selector $selector -Risk 'fixed-content-height' -Evidence "height:$($height.Groups['value'].Value)"
|
||||
}
|
||||
|
||||
$overflow = [regex]::Match($body, '(?im)(?<![-\w])overflow(?:-[xy])?\s*:\s*hidden\s*;')
|
||||
if ($overflow.Success) {
|
||||
Add-Risk -File $relative -Selector $selector -Risk 'clipping-overflow' -Evidence (($overflow.Value -replace '\s+', ' ').Trim())
|
||||
}
|
||||
|
||||
$singleLine = [regex]::Match($body, '(?im)(white-space\s*:\s*nowrap|text-overflow\s*:\s*ellipsis|-webkit-line-clamp\s*:\s*\d+)\s*;')
|
||||
if ($singleLine.Success) {
|
||||
Add-Risk -File $relative -Selector $selector -Risk 'single-line-truncation' -Evidence (($singleLine.Value -replace '\s+', ' ').Trim())
|
||||
}
|
||||
|
||||
$gridRows = [regex]::Match($body, '(?im)grid-template-rows\s*:\s*(?<value>[^;{}]+)\s*;')
|
||||
if ($gridRows.Success -and $gridRows.Groups['value'].Value -match '(\d+(?:\.\d+)?%|\d+(?:\.\d+)?(?:rpx|px))') {
|
||||
Add-Risk -File $relative -Selector $selector -Risk 'fixed-grid-track' -Evidence "grid-template-rows:$((($gridRows.Groups['value'].Value -replace '\s+', ' ').Trim()))"
|
||||
}
|
||||
|
||||
$repeat = [regex]::Match($body, '(?im)grid-template-(?:columns|rows)\s*:\s*repeat\(\s*(?<count>\d+)\s*,')
|
||||
if ($repeat.Success -and [int]$repeat.Groups['count'].Value -ge 12) {
|
||||
Add-Risk -File $relative -Selector $selector -Risk 'fixed-capacity-canvas' -Evidence (($repeat.Value -replace '\s+', ' ').Trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stale = @($allowed.Keys | Where-Object { -not $seenAllowed.ContainsKey($_) } | Sort-Object)
|
||||
if ($stale.Count -gt 0) {
|
||||
$stale | ForEach-Object { Write-Output "STALE ALLOWLIST :: $_" }
|
||||
throw "DATA-DRIVEN-LAYOUT-CONTRACT found $($stale.Count) stale allowlist entries."
|
||||
}
|
||||
|
||||
if ($violations.Count -gt 0) {
|
||||
$violations | Sort-Object | ForEach-Object { Write-Output $_ }
|
||||
throw "DATA-DRIVEN-LAYOUT-CONTRACT found $($violations.Count) non-allowlisted layout capacity risks."
|
||||
}
|
||||
|
||||
Write-Output 'DATA-DRIVEN-LAYOUT-CONTRACT PASS'
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,188 @@
|
||||
const assert = require('assert')
|
||||
|
||||
const origin = process.argv[2] || 'http://localhost:5173'
|
||||
const sizes = [
|
||||
{ width: 320, height: 568 },
|
||||
{ width: 360, height: 640 },
|
||||
{ width: 360, height: 800 },
|
||||
{ width: 412, height: 915 }
|
||||
]
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const projectPages = pages.filter((page) => page.type === 'page' && page.url.startsWith(origin))
|
||||
assert.strictEqual(projectPages.length, 1, `Expected one project page, found ${projectPages.length}`)
|
||||
const socket = new WebSocket(projectPages[0].webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true })
|
||||
socket.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
const request = pending.get(message.id)
|
||||
if (!request) return
|
||||
pending.delete(message.id)
|
||||
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
|
||||
})
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1
|
||||
pending.set(id, { resolve, reject })
|
||||
socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
return { socket, send }
|
||||
}
|
||||
|
||||
const valueOf = async (send, expression) => {
|
||||
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
|
||||
return result.result?.value
|
||||
}
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (await valueOf(send, expression)) return
|
||||
await sleep(80)
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
let auditId = 0
|
||||
const open = async (send, route, query, selector) => {
|
||||
auditId += 1
|
||||
const url = `${origin}/?dataLayoutAudit=${auditId}#${route}${query}`
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Navigation failed: ${route}${query}`)
|
||||
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `State did not render: ${selector}`)
|
||||
}
|
||||
|
||||
const setSize = (send, size) => send('Emulation.setDeviceMetricsOverride', {
|
||||
...size,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
screenWidth: size.width,
|
||||
screenHeight: size.height
|
||||
})
|
||||
|
||||
const assertViewport = async (send, label, expectedCount, selector, allowVisualClipping = false) => {
|
||||
const metrics = await valueOf(send, `(() => {
|
||||
const items = Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
|
||||
const last = items.at(-1)
|
||||
last?.scrollIntoView({ block: 'end' })
|
||||
const lastRect = last?.getBoundingClientRect()
|
||||
return {
|
||||
count: items.length,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: innerWidth,
|
||||
viewportHeight: innerHeight,
|
||||
lastBottom: lastRect?.bottom || 0,
|
||||
clipped: items.some((item) => item.scrollWidth > item.clientWidth + 1 || item.scrollHeight > item.clientHeight + 1)
|
||||
}
|
||||
})()`)
|
||||
assert.strictEqual(metrics.count, expectedCount, `${label}: data count changed`)
|
||||
assert(metrics.documentWidth <= metrics.viewportWidth + 1, `${label}: horizontal overflow ${JSON.stringify(metrics)}`)
|
||||
assert(metrics.lastBottom <= metrics.viewportHeight + 1, `${label}: last item is not reachable ${JSON.stringify(metrics)}`)
|
||||
if (!allowVisualClipping) assert.strictEqual(metrics.clipped, false, `${label}: a data item clips its content`)
|
||||
}
|
||||
|
||||
const stressDirectory = async (send, size) => {
|
||||
await setSize(send, size)
|
||||
await open(send, '/pages/tree/t07-member-directory', '?genealogyId=1001', '.directory-state--list')
|
||||
const prepared = await valueOf(send, `(() => {
|
||||
const first = document.querySelector('.directory-card')
|
||||
const parent = first?.parentElement
|
||||
if (!first || !parent) return false
|
||||
const originals = Array.from(parent.querySelectorAll('.directory-card'))
|
||||
for (let index = originals.length; index < 50; index += 1) {
|
||||
const clone = originals[index % originals.length].cloneNode(true)
|
||||
clone.querySelector('.directory-card__name').textContent = '汤氏超长成员姓名用于三倍文案压力验证' + (index + 1)
|
||||
clone.querySelector('.directory-card__meta').textContent = '第十世 · 超长字辈名称 · 超长支系与地区说明用于验证数据自然换行'
|
||||
parent.appendChild(clone)
|
||||
}
|
||||
parent.querySelectorAll('.directory-card__name, .directory-card__meta').forEach((node) => {
|
||||
node.style.fontSize = (parseFloat(getComputedStyle(node).fontSize) * 1.3) + 'px'
|
||||
})
|
||||
return true
|
||||
})()`)
|
||||
assert.strictEqual(prepared, true, 'T07 directory stress data could not be prepared')
|
||||
await assertViewport(send, `T07 ${size.width}x${size.height}`, 50, '.directory-card')
|
||||
}
|
||||
|
||||
const stressApplications = async (send, size) => {
|
||||
await setSize(send, size)
|
||||
await open(send, '/pages/genealogy/g09-my-applications', '', '.application-state--list')
|
||||
assert(await valueOf(send, `(() => {
|
||||
const first = document.querySelector('.application-card')
|
||||
const parent = first?.parentElement
|
||||
if (!first || !parent) return false
|
||||
const originals = Array.from(parent.querySelectorAll('.application-card'))
|
||||
for (let index = originals.length; index < 50; index += 1) {
|
||||
const clone = originals[index % originals.length].cloneNode(true)
|
||||
clone.querySelector('.application-card__name').textContent = '超长家谱名称与地区支系压力验证' + (index + 1)
|
||||
clone.querySelector('.application-card__relation').textContent = '祖居河南南阳并迁居多地的三倍关系说明,验证卡片由数据自然撑高'
|
||||
parent.appendChild(clone)
|
||||
}
|
||||
parent.querySelectorAll('.application-card__name, .application-card__relation, .application-card__hint').forEach((node) => {
|
||||
node.style.fontSize = (parseFloat(getComputedStyle(node).fontSize) * 1.3) + 'px'
|
||||
})
|
||||
return true
|
||||
})()`), 'G09 application stress data could not be prepared')
|
||||
await assertViewport(send, `G09 ${size.width}x${size.height}`, 50, '.application-card')
|
||||
}
|
||||
|
||||
const stressMedia = async (send, size) => {
|
||||
await setSize(send, size)
|
||||
await open(send, '/pages/family/f09-media-upload', '', '.media-upload-state--initial')
|
||||
await valueOf(send, "document.querySelector('.media-primary-action')?.click()")
|
||||
await waitFor(send, "document.querySelectorAll('.media-photo-tile').length === 4", 'F09 selected media did not render')
|
||||
assert(await valueOf(send, `(() => {
|
||||
const first = document.querySelector('.media-photo-tile')
|
||||
const parent = first?.parentElement
|
||||
if (!first || !parent) return false
|
||||
const originals = Array.from(parent.querySelectorAll('.media-photo-tile'))
|
||||
for (let index = originals.length; index < 30; index += 1) parent.appendChild(originals[index % originals.length].cloneNode(true))
|
||||
return true
|
||||
})()`), 'F09 media stress data could not be prepared')
|
||||
await assertViewport(send, `F09 ${size.width}x${size.height}`, 30, '.media-photo-tile', true)
|
||||
}
|
||||
|
||||
const stressAutoHeightForm = async (send) => {
|
||||
await setSize(send, { width: 320, height: 568 })
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
|
||||
const metrics = await valueOf(send, `(() => {
|
||||
const textarea = document.querySelector('.join-field textarea')
|
||||
const before = textarea.getBoundingClientRect().height
|
||||
textarea.value = '这是用于验证表单由数据驱动自然增高的长说明。'.repeat(8)
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return { before, after: textarea.getBoundingClientRect().height, documentWidth: document.documentElement.scrollWidth }
|
||||
})()`)
|
||||
await sleep(100)
|
||||
const after = await valueOf(send, "document.querySelector('.join-field textarea').getBoundingClientRect().height")
|
||||
assert(after > metrics.before * 1.5, `G08 auto-height textarea did not grow: ${JSON.stringify({ ...metrics, after })}`)
|
||||
assert(metrics.documentWidth <= 321, 'G08 long form value caused horizontal overflow')
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send } = await connect()
|
||||
try {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
for (const size of sizes) await stressDirectory(send, size)
|
||||
for (const size of [sizes[0], sizes[3]]) {
|
||||
await stressApplications(send, size)
|
||||
await stressMedia(send, size)
|
||||
}
|
||||
await stressAutoHeightForm(send)
|
||||
process.stdout.write('DATA-DRIVEN-LAYOUT-RUNTIME-SMOKE PASS\n')
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -4,11 +4,6 @@
|
||||
"selector": ".app-dialog-layer",
|
||||
"reason": "全屏弹窗遮罩"
|
||||
},
|
||||
{
|
||||
"file": "components/AppDialog.vue",
|
||||
"selector": ".app-dialog__skin",
|
||||
"reason": "弹窗内部装饰皮肤"
|
||||
},
|
||||
{
|
||||
"file": "components/AppToast.vue",
|
||||
"selector": ".app-toast",
|
||||
@@ -34,31 +29,6 @@
|
||||
"selector": ".header-hall",
|
||||
"reason": "固定顶部栏内部纯装饰祠堂层"
|
||||
},
|
||||
{
|
||||
"file": "components/PageHeader.vue",
|
||||
"selector": ".notice-dot",
|
||||
"reason": "依附通知按钮的局部未读角标"
|
||||
},
|
||||
{
|
||||
"file": "pages/records/r02-person-detail.vue",
|
||||
"selector": ".person-card-skin",
|
||||
"reason": "依附卡片容器且不承担排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "components/AppButton.vue",
|
||||
"selector": ".app-button__skin",
|
||||
"reason": "依附按钮容器且不承担文字排版的卷轴皮肤"
|
||||
},
|
||||
{
|
||||
"file": "components/GenealogyCard.vue",
|
||||
"selector": ".row-frame",
|
||||
"reason": "依附家谱卡片容器的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "components/GenealogyCard.vue",
|
||||
"selector": ".surname-seal-frame",
|
||||
"reason": "依附姓氏印章容器的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "components/GenealogyPageBackground.vue",
|
||||
"selector": ".genealogy-page-background",
|
||||
@@ -80,74 +50,9 @@
|
||||
"reason": "独立模块背景层内部的底部背景画面"
|
||||
},
|
||||
{
|
||||
"file": "components/ModulePage.vue",
|
||||
"selector": ".module-lead image",
|
||||
"reason": "依附模块导语容器且不承担文字排版的分隔装饰"
|
||||
},
|
||||
{
|
||||
"file": "components/ModulePage.vue",
|
||||
"selector": ".form-row image, .detail-card image, .settings-row image, .list-card > image, .timeline-row > image",
|
||||
"reason": "依附各内容容器且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "components/ModulePage.vue",
|
||||
"selector": ".status-card__skin",
|
||||
"reason": "依附状态卡容器且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "components/tree/TreeMemberForm.vue",
|
||||
"selector": ".member-form-panel__skin",
|
||||
"reason": "依附成员表单容器且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "components/tree/TreeMemberForm.vue",
|
||||
"selector": ".member-field image",
|
||||
"reason": "依附成员字段容器且不承担表单排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "components/tree/TreeMemberForm.vue",
|
||||
"selector": ".member-form-action image",
|
||||
"reason": "依附成员表单按钮且不承担文字排版的卷轴皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/records/r01-people-list.vue",
|
||||
"selector": ".people-search > image",
|
||||
"reason": "依附人物搜索容器且不承担输入排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/records/r01-people-list.vue",
|
||||
"selector": ".person-card__skin",
|
||||
"reason": "依附人物卡片且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/records/r01-people-list.vue",
|
||||
"selector": ".people-result-empty > image, .people-state-card > image",
|
||||
"reason": "依附人物状态卡且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f01-family-feed.vue",
|
||||
"selector": ".feed-shortcuts__skin",
|
||||
"reason": "依附快捷入口容器且不承担文字排版的卷轴皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f01-family-feed.vue",
|
||||
"selector": ".feed-card__skin, .feed-state-card > image, .feed-action image",
|
||||
"reason": "依附动态卡片、状态卡和按钮且不承担正文排版的装饰皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f02-publish-feed.vue",
|
||||
"selector": ".publish-panel__skin",
|
||||
"reason": "依附发布面板且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f02-publish-feed.vue",
|
||||
"selector": ".publish-field image",
|
||||
"reason": "依附发布文本域且不承担输入排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f06-article-editor.vue",
|
||||
"selector": ".editor-control > image",
|
||||
"reason": "依附文章编辑控件且不承担输入排版的装饰框层"
|
||||
"file": "pages/family/f08-album-detail.vue",
|
||||
"selector": ".album-photo-tile",
|
||||
"reason": "仅作为照片裁切层与底部说明叠层的精确局部边界"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f08-album-detail.vue",
|
||||
@@ -164,6 +69,11 @@
|
||||
"selector": ".album-preview",
|
||||
"reason": "用户触发的全屏照片预览层"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f09-media-upload.vue",
|
||||
"selector": ".media-photo-tile",
|
||||
"reason": "仅作为照片缩略图角标与删除操作的精确局部边界"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f09-media-upload.vue",
|
||||
"selector": ".media-photo-order, .media-photo-current, .media-photo-status",
|
||||
@@ -174,66 +84,6 @@
|
||||
"selector": ".media-photo-remove",
|
||||
"reason": "依附照片缩略图右上角的删除操作"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f10-video-list.vue",
|
||||
"selector": ".video-status-lead image",
|
||||
"reason": "依附视频状态导语的分隔装饰层"
|
||||
},
|
||||
{
|
||||
"file": "pages/family/f10-video-list.vue",
|
||||
"selector": ".video-status-card__skin",
|
||||
"reason": "依附视频状态卡且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/notification/n01-message-center.vue",
|
||||
"selector": ".notice-card__skin",
|
||||
"reason": "依附消息卡且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/notification/n01-message-center.vue",
|
||||
"selector": ".notice-state-card__skin",
|
||||
"reason": "依附消息状态卡且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/profile/m01-profile-home.vue",
|
||||
"selector": ".profile-hero__frame",
|
||||
"reason": "依附个人摘要容器的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/profile/m01-profile-home.vue",
|
||||
"selector": ".profile-hero__hall",
|
||||
"reason": "个人摘要容器内的纯装饰祠堂层"
|
||||
},
|
||||
{
|
||||
"file": "pages/profile/m01-profile-home.vue",
|
||||
"selector": ".profile-hero__cloud",
|
||||
"reason": "个人摘要容器内的纯装饰云纹层"
|
||||
},
|
||||
{
|
||||
"file": "pages/profile/m01-profile-home.vue",
|
||||
"selector": ".profile-scroll-notice__skin",
|
||||
"reason": "依附提醒入口且不承担文字排版的卷轴皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/profile/m01-profile-home.vue",
|
||||
"selector": ".profile-error__skin",
|
||||
"reason": "依附个人资料错误卡且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/tree/t01-tree-overview.vue",
|
||||
"selector": ".lineage-connector",
|
||||
"reason": "依附世系画布且不承担节点排版的关系线绘制层"
|
||||
},
|
||||
{
|
||||
"file": "pages/tree/t01-tree-overview.vue",
|
||||
"selector": ".tree-state-card__skin",
|
||||
"reason": "依附世系状态卡且不承担正文排版的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/tree/t01-tree-overview.vue",
|
||||
"selector": ".tree-state-card__action image",
|
||||
"reason": "依附世系状态按钮且不承担文字排版的卷轴皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/tree/t01-tree-overview.vue",
|
||||
"selector": ".member-sheet",
|
||||
@@ -244,150 +94,50 @@
|
||||
"selector": ".member-sheet__skin",
|
||||
"reason": "固定底部详情抽屉内部的装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a01-entry.vue",
|
||||
"selector": ".brand-seal",
|
||||
"reason": "依附登录品牌标题的印章装饰"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a01-entry.vue",
|
||||
"selector": ".button-skin",
|
||||
"reason": "依附登录按钮且不承担文字排版的卷轴皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a01-entry.vue",
|
||||
"selector": ".feedback-toast",
|
||||
"reason": "登录页面跨内容轻提示"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a01-entry.vue",
|
||||
"selector": ".login-tab.active::after",
|
||||
"reason": "依附激活登录标签的局部下划线指示"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a01-entry.vue",
|
||||
"selector": ".login-tab:first-child::before",
|
||||
"reason": "登录标签组内部的局部分隔装饰"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a01-entry.vue",
|
||||
"selector": ".page-backdrop",
|
||||
"reason": "不参与登录内容排版的页面背景层"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a01-entry.vue",
|
||||
"selector": ".verification-layer",
|
||||
"reason": "用户触发的验证码弹窗遮罩"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a01-entry.vue",
|
||||
"selector": ".verification-dialog__skin",
|
||||
"reason": "验证码弹窗内部装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a04-register.vue",
|
||||
"selector": ".back-button",
|
||||
"reason": "认证全屏页左上角返回操作"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a04-register.vue",
|
||||
"selector": ".brand-seal",
|
||||
"reason": "依附注册品牌标题的印章装饰"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a04-register.vue",
|
||||
"selector": ".feedback-toast",
|
||||
"reason": "注册页面跨内容轻提示"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a04-register.vue",
|
||||
"selector": ".page-backdrop",
|
||||
"reason": "不参与注册内容排版的页面背景层"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a04-register.vue",
|
||||
"selector": ".register-submit__skin",
|
||||
"reason": "依附注册按钮且不承担文字排版的卷轴皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a05-reset-password.vue",
|
||||
"selector": ".back-button",
|
||||
"reason": "认证全屏页左上角返回操作"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a05-reset-password.vue",
|
||||
"selector": ".brand-seal",
|
||||
"reason": "依附重置密码品牌标题的印章装饰"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a05-reset-password.vue",
|
||||
"selector": ".feedback-toast",
|
||||
"reason": "重置密码页面跨内容轻提示"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a05-reset-password.vue",
|
||||
"selector": ".page-backdrop",
|
||||
"reason": "不参与重置密码内容排版的页面背景层"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a05-reset-password.vue",
|
||||
"selector": ".reset-submit__skin, .success-action__skin",
|
||||
"reason": "依附重置与成功操作按钮的卷轴皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a05-reset-password.vue",
|
||||
"selector": ".success-layer",
|
||||
"reason": "用户触发的重置成功弹窗遮罩"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a05-reset-password.vue",
|
||||
"selector": ".success-dialog__skin",
|
||||
"reason": "重置成功弹窗内部装饰框层"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a06-auth-status.vue",
|
||||
"selector": ".back-button",
|
||||
"reason": "认证全屏页左上角返回操作"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a06-auth-status.vue",
|
||||
"selector": ".brand-seal",
|
||||
"reason": "依附认证状态品牌标题的印章装饰"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a06-auth-status.vue",
|
||||
"selector": ".page-backdrop",
|
||||
"reason": "不参与认证状态内容排版的页面背景层"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a06-auth-status.vue",
|
||||
"selector": ".status-primary__skin, .recovery-action__skin",
|
||||
"reason": "依附认证状态操作按钮的卷轴皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a06-auth-status.vue",
|
||||
"selector": ".recovery-layer",
|
||||
"reason": "用户触发的账号恢复弹窗遮罩"
|
||||
},
|
||||
{
|
||||
"file": "pages/auth/a06-auth-status.vue",
|
||||
"selector": ".recovery-dialog__skin",
|
||||
"reason": "账号恢复弹窗内部装饰框层"
|
||||
"file": "pages/genealogy/g01-my-genealogies.vue",
|
||||
"selector": ".add-dialog-layer",
|
||||
"reason": "用户触发的固定底部添加家谱弹层"
|
||||
},
|
||||
{
|
||||
"file": "pages/genealogy/g03-create-genealogy.vue",
|
||||
"selector": ".create-flow-panel__skin",
|
||||
"reason": "依附创建家谱流程面板的装饰框层"
|
||||
"file": "pages/genealogy/g01-my-genealogies.vue",
|
||||
"selector": ".genealogy-switcher-layer",
|
||||
"reason": "用户触发的固定家谱切换弹窗遮罩"
|
||||
},
|
||||
{
|
||||
"file": "pages/genealogy/g03-create-genealogy.vue",
|
||||
"selector": ".flow-header__skin",
|
||||
"reason": "依附创建流程标题区的装饰皮肤"
|
||||
},
|
||||
{
|
||||
"file": "pages/genealogy/g03-create-genealogy.vue",
|
||||
"selector": ".flow-primary-action__skin",
|
||||
"reason": "依附创建流程主操作的卷轴皮肤"
|
||||
"file": "pages/genealogy/g10-application-review.vue",
|
||||
"selector": ".review-feedback",
|
||||
"reason": "审核操作后跨内容显示的固定轻提示"
|
||||
},
|
||||
{
|
||||
"file": "pages/genealogy/g03-create-genealogy.vue",
|
||||
@@ -395,13 +145,13 @@
|
||||
"reason": "用户触发的重复提醒与成功弹窗遮罩"
|
||||
},
|
||||
{
|
||||
"file": "pages/genealogy/g03-create-genealogy.vue",
|
||||
"selector": ".duplicate-reminder__skin, .flow-success-dialog__skin",
|
||||
"reason": "创建流程弹窗内部装饰框层"
|
||||
"file": "pages/genealogy/g11-genealogy-settings.vue",
|
||||
"selector": ".settings-feedback",
|
||||
"reason": "保存设置后跨内容显示的固定轻提示"
|
||||
},
|
||||
{
|
||||
"file": "pages/genealogy/g03-create-genealogy.vue",
|
||||
"selector": ".duplicate-reminder__search image, .duplicate-reminder__confirm image, .flow-success-dialog__action image",
|
||||
"reason": "创建流程弹窗操作按钮的卷轴皮肤"
|
||||
"file": "pages/genealogy/g12-generation-poems.vue",
|
||||
"selector": ".poem-feedback",
|
||||
"reason": "保存字辈后跨内容显示的固定轻提示"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ $root = Split-Path $PSScriptRoot -Parent
|
||||
$allowlistPath = Join-Path $PSScriptRoot 'document-flow-position-allowlist.json'
|
||||
$allowlist = Get-Content -LiteralPath $allowlistPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$allowed = @{}
|
||||
$seenAllowed = @{}
|
||||
foreach ($entry in $allowlist) {
|
||||
$allowed["$($entry.file)::$($entry.selector)"] = $entry.reason
|
||||
}
|
||||
@@ -15,20 +16,31 @@ foreach ($file in $files) {
|
||||
foreach ($styleMatch in [regex]::Matches($source, '(?s)<style\b[^>]*>(?<css>.*?)</style>')) {
|
||||
$css = $styleMatch.Groups['css'].Value
|
||||
foreach ($rule in [regex]::Matches($css, '(?s)(?<selector>[^{}]+)\{(?<body>[^{}]*)\}')) {
|
||||
$body = $rule.Groups['body'].Value
|
||||
if ($body -notmatch '(?m)position\s*:\s*(absolute|fixed|sticky)\s*;') { continue }
|
||||
$body = [regex]::Replace($rule.Groups['body'].Value, '(?s)/\*.*?\*/', '')
|
||||
$positionMatches = [regex]::Matches($body, '(?im)(?<![-\w])position\s*:\s*(?<value>[^;{}]+?)\s*;')
|
||||
if ($positionMatches.Count -eq 0) { continue }
|
||||
$selector = (($rule.Groups['selector'].Value -replace '(?s)^.*@media[^\{]*', '') -replace '\s+', ' ').Trim()
|
||||
$key = "$relative::$selector"
|
||||
if (-not $allowed.ContainsKey($key)) {
|
||||
$position = [regex]::Match($body, '(?m)position\s*:\s*(absolute|fixed|sticky)\s*;').Groups[1].Value
|
||||
$violations.Add("$relative :: $selector :: position:$position")
|
||||
if ($allowed.ContainsKey($key)) {
|
||||
$seenAllowed[$key] = $true
|
||||
} else {
|
||||
foreach ($positionMatch in $positionMatches) {
|
||||
$position = ($positionMatch.Groups['value'].Value -replace '\s+', ' ').Trim()
|
||||
$violations.Add("$relative :: $selector :: position:$position")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$staleAllowlistEntries = @($allowed.Keys | Where-Object { -not $seenAllowed.ContainsKey($_) } | Sort-Object)
|
||||
if ($staleAllowlistEntries.Count -gt 0) {
|
||||
$staleAllowlistEntries | ForEach-Object { Write-Output "STALE ALLOWLIST :: $_" }
|
||||
throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($staleAllowlistEntries.Count) stale allowlist entries."
|
||||
}
|
||||
|
||||
if ($violations.Count -gt 0) {
|
||||
$violations | Sort-Object | ForEach-Object { Write-Output $_ }
|
||||
throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($violations.Count) non-overlay positioning rules."
|
||||
throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($violations.Count) non-allowlisted position declarations."
|
||||
}
|
||||
Write-Output 'DOCUMENT-FLOW-POSITION-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Read-Utf8([string]$relativePath) {
|
||||
return Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding UTF8
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$source, [string]$expected, [string]$page) {
|
||||
if (-not $source.Contains($expected)) { throw "$page missing F business-flow anchor: $expected" }
|
||||
}
|
||||
|
||||
$contracts = [ordered]@{
|
||||
'pages/family/f03-feed-detail.vue' = @(
|
||||
'feedComments', 'commentDraft', 'submitComment', 'feed-state--expired',
|
||||
'feedId', 'comment-state--saving', 'comment-state--error',
|
||||
'/pages/family/f01-family-feed'
|
||||
)
|
||||
'pages/family/f04-article-list.vue' = @(
|
||||
'articleCategories', 'filteredArticles', 'openArticle', 'createArticle',
|
||||
'article-list-state--loading', 'article-list-state--empty', 'article-list-state--error',
|
||||
'/pages/family/f05-article-detail?articleId=', '/pages/family/f06-article-editor?mode=create'
|
||||
)
|
||||
'pages/family/f05-article-detail.vue' = @(
|
||||
'articleParagraphs', 'toggleFavorite', 'article-state--expired', 'backToArticles',
|
||||
'articleId', 'article-state--privacy', 'article-state--error',
|
||||
'/pages/family/f04-article-list', '/pages/family/f06-article-editor?mode=edit&articleId='
|
||||
)
|
||||
'pages/family/f07-album-list.vue' = @(
|
||||
'albums', 'openAlbum', 'createAlbum', 'album-state--empty',
|
||||
'album-list-state--loading', 'album-list-state--error', 'albumNameDraft',
|
||||
'/pages/family/f08-album-detail?albumId='
|
||||
)
|
||||
}
|
||||
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
$source = Read-Utf8 $entry.Key
|
||||
foreach ($anchor in $entry.Value) { Assert-Contains $source $anchor $entry.Key }
|
||||
foreach ($required in @('ModulePageBackground', 'PageHeader', 'AppButton', 'AppLoading')) {
|
||||
Assert-Contains $source $required $entry.Key
|
||||
}
|
||||
foreach ($forbidden in @('import ModulePage from', 'uni.showToast', 'uni.showModal')) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains forbidden implementation: $forbidden" }
|
||||
}
|
||||
if ($source -match '<ModulePage(?:\s|/|>)') { throw "$($entry.Key) retains forbidden ModulePage owner" }
|
||||
if ($source -match '(?im)(?<![-\w])position\s*:') { throw "$($entry.Key) ordinary content must not use position" }
|
||||
if ($source -match '(?im)overflow\s*:\s*hidden') { throw "$($entry.Key) must not clip data-driven content" }
|
||||
}
|
||||
|
||||
Write-Output 'F-BUSINESS-FLOW-CONTRACT PASS'
|
||||
@@ -0,0 +1,90 @@
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const origin = process.argv[2] || "http://localhost:5173";
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch("http://127.0.0.1:9222/json/list")).json();
|
||||
const page = pages.find((item) => item.type === "page" && item.url.startsWith(`${origin}/`));
|
||||
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`);
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result);
|
||||
});
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
return { socket, send };
|
||||
};
|
||||
|
||||
const valueOf = async (send, expression) => (await send("Runtime.evaluate", { expression, returnByValue: true })).result?.value;
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
if (await valueOf(send, expression)) return;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
let auditId = 0;
|
||||
const open = async (send, route, selector) => {
|
||||
auditId += 1;
|
||||
const url = `${origin}/?fBusiness=${auditId}#${route}`;
|
||||
await send("Page.navigate", { url });
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `navigation failed: ${route}`);
|
||||
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `missing ${selector}: ${route}`);
|
||||
};
|
||||
const click = (send, selector) => valueOf(send, `document.querySelector(${JSON.stringify(selector)}).click()`);
|
||||
const setInput = (send, selector, value) => valueOf(send, `(() => { const input = document.querySelector(${JSON.stringify(selector)}); input.value = ${JSON.stringify(value)}; input.dispatchEvent(new Event('input', { bubbles: true })); return input.value; })()`);
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send } = await connect();
|
||||
try {
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
for (const size of [{ width: 320, height: 568 }, { width: 412, height: 915 }]) {
|
||||
await send("Emulation.setDeviceMetricsOverride", { ...size, deviceScaleFactor: 1, mobile: true, screenWidth: size.width, screenHeight: size.height });
|
||||
await open(send, "/pages/family/f04-article-list?count=50", ".article-card");
|
||||
if ((await valueOf(send, "document.querySelectorAll('.article-card').length")) !== 50) throw new Error(`F04 did not render 50 articles at ${size.width}`);
|
||||
if ((await valueOf(send, "document.documentElement.scrollWidth")) > size.width + 1) throw new Error(`F04 horizontal overflow at ${size.width}`);
|
||||
await valueOf(send, "document.querySelector('.article-card:last-of-type').scrollIntoView()");
|
||||
|
||||
await open(send, "/pages/family/f07-album-list?count=30", ".album-card");
|
||||
if ((await valueOf(send, "document.querySelectorAll('.album-card').length")) !== 30) throw new Error(`F07 did not render 30 albums at ${size.width}`);
|
||||
if ((await valueOf(send, "document.documentElement.scrollWidth")) > size.width + 1) throw new Error(`F07 horizontal overflow at ${size.width}`);
|
||||
}
|
||||
|
||||
await open(send, "/pages/family/f04-article-list", ".article-card");
|
||||
await click(send, ".article-card");
|
||||
await waitFor(send, "location.hash.includes('/pages/family/f05-article-detail?articleId=101')", "F04 card did not open ID-driven F05");
|
||||
|
||||
await open(send, "/pages/family/f03-feed-detail?feedId=1", ".feed-comment-form textarea");
|
||||
const before = await valueOf(send, "document.querySelectorAll('.feed-comment-card').length");
|
||||
await setInput(send, ".feed-comment-form textarea", "愿家人岁岁平安,常聚常新。");
|
||||
await click(send, ".feed-comment-form .app-button");
|
||||
await waitFor(send, `document.querySelectorAll('.feed-comment-card').length === ${before + 1}`, "F03 comment was not appended");
|
||||
|
||||
await open(send, "/pages/family/f07-album-list", ".album-list > .app-button");
|
||||
await click(send, ".album-list > .app-button");
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", "F07 create dialog did not open");
|
||||
await setInput(send, ".album-dialog-field input", "清明祭祖影像");
|
||||
await click(send, ".app-dialog__actions .app-button:last-child");
|
||||
await waitFor(send, "document.querySelector('.album-card .album-card__copy').innerText.includes('清明祭祖影像')", "F07 created album was not added");
|
||||
|
||||
process.stdout.write("F-BUSINESS-FLOW-RUNTIME-SMOKE PASS\n");
|
||||
} finally {
|
||||
try { await send("Emulation.clearDeviceMetricsOverride"); } catch (_) {}
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1); });
|
||||
@@ -2,26 +2,27 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-Utf8([string]$Path) { [System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8) }
|
||||
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) { if (-not $Content.Contains($Expected)) { throw $Message } }
|
||||
function Assert-Match([string]$Content, [string]$Pattern, [string]$Message) { if ($Content -notmatch $Pattern) { throw $Message } }
|
||||
|
||||
$f01 = Read-Utf8 'pages/family/f01-family-feed.vue'
|
||||
$f02 = Read-Utf8 'pages/family/f02-publish-feed.vue'
|
||||
$module = Read-Utf8 'components/ModulePage.vue'
|
||||
|
||||
foreach ($expected in @('font-size:24rpx','font-size:23rpx','font-size:22rpx')) {
|
||||
Assert-Contains $f01 $expected "F01 readability token missing: $expected"
|
||||
foreach ($expected in @('24rpx','23rpx','22rpx')) {
|
||||
Assert-Match $f01 "font-size:\s*$expected" "F01 readability token missing: $expected"
|
||||
}
|
||||
foreach ($expected in @('font-size:24rpx','font-size:23rpx')) {
|
||||
Assert-Contains $f02 $expected "F02 readability token missing: $expected"
|
||||
foreach ($expected in @('24rpx','23rpx')) {
|
||||
Assert-Match $f02 "font-size:\s*$expected" "F02 readability token missing: $expected"
|
||||
}
|
||||
foreach ($expected in @(
|
||||
'.module-lead { position: relative; height: 56rpx; margin: 0 12rpx 18rpx; color: $ink-muted; font-size: 23rpx;',
|
||||
'.form-row > text { position: absolute; top: 28rpx; left: 26rpx; z-index: 1; color: $ink; font-size: 24rpx;',
|
||||
'.form-row input { position: absolute; top: 0; right: 22rpx; bottom: 0; left: 178rpx; z-index: 1; height: 84rpx; color: $ink; font-size: 23rpx;',
|
||||
'.list-card__copy text:nth-child(2) { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx;',
|
||||
'.detail-card text:last-child { margin-top: 10rpx; color: $ink; font-size: 24rpx;',
|
||||
'.status-card__note { display: block; margin-top: 17rpx; color: $ink-muted; font-size: 24rpx;'
|
||||
'(?s)\.module-lead\s*\{[^}]*height:\s*56rpx;[^}]*font-size:\s*23rpx;',
|
||||
'(?s)\.form-row > text\s*\{[^}]*font-size:\s*24rpx;',
|
||||
'(?s)\.form-row input\s*\{[^}]*height:\s*56rpx;[^}]*font-size:\s*23rpx;',
|
||||
'(?s)\.list-card__copy text:nth-child\(2\)\s*\{[^}]*font-size:\s*23rpx;',
|
||||
'(?s)\.detail-card text:last-child\s*\{[^}]*font-size:\s*24rpx;',
|
||||
'(?s)\.status-card__note\s*\{[^}]*font-size:\s*24rpx;'
|
||||
)) {
|
||||
Assert-Contains $module $expected "ModulePage readability contract missing: $expected"
|
||||
Assert-Match $module $expected "ModulePage readability contract missing: $expected"
|
||||
}
|
||||
|
||||
Write-Output 'F series all-states visual contract passed.'
|
||||
|
||||
@@ -2,14 +2,16 @@ $ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f01-family-feed.vue'), [System.Text.Encoding]::UTF8)
|
||||
|
||||
foreach ($expected in @('box-sizing: border-box','pointer-events: none')) {
|
||||
foreach ($expected in @(
|
||||
'box-sizing: border-box',
|
||||
'width: 514rpx',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/modules/family/transparent/f01-family-letter-card.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / 100% 100% no-repeat'
|
||||
)) {
|
||||
if (-not $page.Contains($expected)) { throw "F01 document-flow contract missing: $expected" }
|
||||
}
|
||||
foreach ($selector in @('.feed-card__copy','.feed-state-card > view')) {
|
||||
$escaped = [regex]::Escape($selector)
|
||||
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
|
||||
throw "F01 normal content selector uses positioning: $selector"
|
||||
}
|
||||
}
|
||||
if ($page -match '<image\s+(?:[^>]*\s)?class="(?:feed-shortcuts__skin|feed-card__skin)"|feed-state-card\s*>\s*<image|feed-action[^>]*>\s*<image') { throw 'F01 decorative skins must be container backgrounds' }
|
||||
if ($page -match 'position\s*:') { throw 'F01 must keep ordinary content in document flow' }
|
||||
|
||||
Write-Output 'F01-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -13,7 +13,8 @@ Assert-Match 'class="feed-card__meta"' 'F01 must expose category and time as sec
|
||||
Assert-Match 'class="feed-card__summary"' 'F01 must expose feed summary copy.'
|
||||
Assert-Match 'class="feed-card__author"' 'F01 must expose the author as tertiary information.'
|
||||
Assert-Match '(?s)\.feed-shortcut\s*\{[^}]*min-height:\s*44px;' 'F01 shortcuts must preserve a 44 CSS px touch height.'
|
||||
Assert-Match 'class="feed-shortcuts__skin"' 'F01 shortcuts must share one visible navigation skin.'
|
||||
Assert-Match '(?s)\.feed-shortcuts\s*\{[^}]*grid-template-columns:\s*repeat\(4,minmax\(0,1fr\)\);' 'F01 shortcuts must use one four-column equal-width navigation strip.'
|
||||
Assert-Match 'a01-scroll-secondary-v3\.png' 'F01 shortcuts must share one visible navigation skin.'
|
||||
Assert-Match '(?s)\.feed-shortcuts\s*\{[^}]*grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\);' 'F01 shortcuts must use one four-column equal-width navigation strip.'
|
||||
if ($page -match 'position\s*:') { throw 'F01 must keep ordinary content in document flow.' }
|
||||
|
||||
Write-Output 'F01-MODULE-BASELINE-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f01-family-feed.vue') -Raw -Encoding UTF8
|
||||
foreach ($route in @('/pages/records/r01-people-list','/pages/records/r03-gift-list','/pages/records/r11-merit-records','/pages/family/f10-video-list')) {
|
||||
if (-not $page.Contains($route)) { throw "F01 product entry missing: $route" }
|
||||
}
|
||||
if ($page -match '(?s)\.family-page\s*\{[^}]*overflow:\s*hidden') { throw 'F01 must not clip long page content' }
|
||||
if (-not $page.Contains('genealogyId=${genealogyId.value}')) { throw 'F01 child routes must preserve genealogy context' }
|
||||
Write-Output 'F01-PRODUCT-ENTRY-CONTRACT PASS'
|
||||
@@ -0,0 +1,15 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f02-publish-feed.vue'), [System.Text.Encoding]::UTF8)
|
||||
|
||||
foreach ($expected in @(
|
||||
'background: url("/static/assets/modules/family/transparent/module-content-frame.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat',
|
||||
'min-height: min(640px, calc((100vw - 16px) * 1.48))'
|
||||
)) {
|
||||
if (-not $page.Contains($expected)) { throw "F02 document-flow contract missing: $expected" }
|
||||
}
|
||||
if ($page -match '<image\s+(?:[^>]*\s)?class="publish-panel__skin"|publish-field[^>]*>\s*<image') { throw 'F02 decorative frames must be container backgrounds' }
|
||||
if ($page -match 'position\s*:') { throw 'F02 must keep panel, form, result, and field in document flow' }
|
||||
|
||||
Write-Output 'F02-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -53,6 +53,8 @@ foreach ($forbidden in @('<template><ModulePage', 'import ModulePage from', 'pag
|
||||
if ($page.Contains($forbidden)) { throw "F06 retains forbidden dependency: $forbidden" }
|
||||
}
|
||||
|
||||
if ($page -match 'position\s*:') { throw 'F06 must keep editor content in document flow' }
|
||||
|
||||
if ($catalog -match '(?m)^\s*f06\s*:') { throw 'F06 obsolete page-catalog owner must be removed' }
|
||||
|
||||
Write-Output 'F06-ARTICLE-EDITOR-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('articleId', 'editorMode', 'simulateSaveFailure', 'query.articleId', '/pages/family/f05-article-detail?articleId=${articleId.value}')) {
|
||||
if (-not $page.Contains($token)) { throw "F06 editor context missing: $token" }
|
||||
}
|
||||
if ($page.Contains('form.title.trim() === "保存失败"')) { throw 'F06 must not use visible title as a failure control channel' }
|
||||
Write-Output 'F06-EDITOR-CONTEXT-CONTRACT PASS'
|
||||
@@ -26,14 +26,14 @@ foreach ($expected in @(
|
||||
'const photos = [',
|
||||
'const openPreview = (index) =>',
|
||||
'const closePreview = () =>',
|
||||
'const showUploadNotice = () =>',
|
||||
'const toUpload = () =>',
|
||||
'/pages/family/f09-media-upload?albumId=${albumId.value}',
|
||||
'query.state === "empty"',
|
||||
'query.state === "preview"',
|
||||
'query.state === "expired"',
|
||||
'class="album-empty-state"',
|
||||
'class="album-expired-state"',
|
||||
'class="album-preview__position"',
|
||||
':message="uploadNoticeMessage"',
|
||||
'f08-reunion-hero.png',
|
||||
'f08-family-portrait.png',
|
||||
'f08-reunion-table.png',
|
||||
@@ -42,8 +42,7 @@ foreach ($expected in @(
|
||||
':alt="photo.alt"',
|
||||
'ModulePageBackground',
|
||||
'PageHeader',
|
||||
'AppButton',
|
||||
'AppToast'
|
||||
'AppButton'
|
||||
)) {
|
||||
Assert-Contains $page $expected "F08 contract missing: $expected"
|
||||
}
|
||||
@@ -52,6 +51,16 @@ foreach ($forbidden in @('<ModulePage page-id="f08"', 'import ModulePage from',
|
||||
if ($page.Contains($forbidden)) { throw "F08 retains forbidden dependency: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($requiredPosition in @(
|
||||
'(?s)\.album-photo-tile\s*\{[^}]*position:\s*relative;',
|
||||
'(?s)\.album-hero-photo,\s*\.album-photo-tile__image\s*\{[^}]*position:\s*absolute;',
|
||||
'(?s)\.album-photo-tile__caption\s*\{[^}]*position:\s*absolute;',
|
||||
'(?s)\.album-preview\s*\{[^}]*position:\s*fixed;'
|
||||
)) {
|
||||
if ($page -notmatch $requiredPosition) { throw "F08 required local overlay boundary missing: $requiredPosition" }
|
||||
}
|
||||
if ([regex]::Matches($page, 'position\s*:').Count -ne 4) { throw 'F08 must keep only its photo-tile overlays and full-screen preview positioned' }
|
||||
|
||||
if ($catalog -match '(?m)^\s*f08\s*:') {
|
||||
throw 'F08 obsolete page-catalog owner must be removed'
|
||||
}
|
||||
|
||||
@@ -71,7 +71,11 @@ const run = async () => {
|
||||
await waitFor(send, "!document.querySelector('.album-preview')", 'F08 preview did not close')
|
||||
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.album-upload-action')?.click()" })
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-toast'))", 'F08 upload notice did not render')
|
||||
await waitFor(
|
||||
send,
|
||||
"location.href.includes('/pages/family/f09-media-upload?albumId=reunion')",
|
||||
'F08 upload action did not carry the current album into F09'
|
||||
)
|
||||
|
||||
await navigate('/pages/family/f08-album-detail?state=empty', '.album-state--empty')
|
||||
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.album-photo-tile').length"), 0, 'F08 empty state must not render photos')
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$detail = Get-Content -LiteralPath (Join-Path $root 'pages/family/f08-album-detail.vue') -Raw -Encoding UTF8
|
||||
$upload = Get-Content -LiteralPath (Join-Path $root 'pages/family/f09-media-upload.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('const albumId = ref', 'toUpload', '/pages/family/f09-media-upload?albumId=${albumId.value}')) {
|
||||
if (-not $detail.Contains($token)) { throw "F08 album context missing: $token" }
|
||||
}
|
||||
foreach ($token in @('const albumId = ref', 'query.albumId', '/pages/family/f08-album-detail?albumId=${albumId.value}')) {
|
||||
if (-not $upload.Contains($token)) { throw "F09 album context missing: $token" }
|
||||
}
|
||||
Write-Output 'F08-F09-ALBUM-CONTEXT-CONTRACT PASS'
|
||||
@@ -63,6 +63,15 @@ foreach ($forbidden in @(
|
||||
if ($page.Contains($forbidden)) { throw "F09 retains forbidden dependency: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($requiredPosition in @(
|
||||
'(?s)\.media-photo-tile\s*\{[^}]*position:\s*relative;',
|
||||
'(?s)\.media-photo-order,\s*\.media-photo-current,\s*\.media-photo-status\s*\{[^}]*position:\s*absolute;',
|
||||
'(?s)\.media-photo-remove\s*\{[^}]*position:\s*absolute;'
|
||||
)) {
|
||||
if ($page -notmatch $requiredPosition) { throw "F09 required thumbnail overlay boundary missing: $requiredPosition" }
|
||||
}
|
||||
if ([regex]::Matches($page, 'position\s*:').Count -ne 3) { throw 'F09 must keep only thumbnail badges and their local photo boundary positioned' }
|
||||
|
||||
if ($catalog -match '(?m)^\s*f09\s*:') {
|
||||
throw 'F09 obsolete page-catalog owner must be removed'
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ foreach ($forbidden in @('<ModulePage page-id="f10"', 'import ModulePage from'))
|
||||
if ($page.Contains($forbidden)) { throw "F10 retains forbidden dependency: $forbidden" }
|
||||
}
|
||||
|
||||
if ($page -match '(?m)^\s*position\s*:\s*relative\s*;') {
|
||||
throw 'F10 ordinary page content must not use position: relative'
|
||||
}
|
||||
|
||||
foreach ($selector in @('.video-status-lead text', '.video-status-card__body')) {
|
||||
$escapedSelector = [regex]::Escape($selector)
|
||||
if ($page -notmatch "(?s)$escapedSelector\s*\{[^}]*z-index\s*:\s*1\s*;") {
|
||||
throw "F10 grid content must render above its decorative skin: $selector"
|
||||
}
|
||||
}
|
||||
|
||||
if ($catalog -match '(?m)^\s*f10\s*:') {
|
||||
throw 'F10 obsolete page-catalog owner must be removed'
|
||||
}
|
||||
|
||||
@@ -3,16 +3,10 @@ $root = Split-Path $PSScriptRoot -Parent
|
||||
$publish = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f02-publish-feed.vue'), [System.Text.Encoding]::UTF8)
|
||||
$editor = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f06-article-editor.vue'), [System.Text.Encoding]::UTF8)
|
||||
|
||||
foreach ($selector in @('.publish-form, .publish-result','.publish-field textarea')) {
|
||||
$escaped = [regex]::Escape($selector)
|
||||
if ($publish -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "F02 normal content selector uses positioning: $selector" }
|
||||
}
|
||||
foreach ($selector in @('.editor-control input, .editor-control textarea')) {
|
||||
$escaped = [regex]::Escape($selector)
|
||||
if ($editor -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "F06 normal content selector uses positioning: $selector" }
|
||||
}
|
||||
foreach ($content in @($publish,$editor)) {
|
||||
if (-not $content.Contains('pointer-events: none')) { throw 'Family editor decorative skins must not intercept form controls' }
|
||||
}
|
||||
if ($publish -match 'position\s*:') { throw 'F02 must keep its editor in document flow' }
|
||||
if ($editor -match 'position\s*:') { throw 'F06 must keep its editor in document flow' }
|
||||
if (-not $publish.Contains('background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat')) { throw 'F02 field frame must be a container background' }
|
||||
if (-not $editor.Contains('background: url("/static/assets/modules/family/transparent/module-field-frame.png") center / 100% 100% no-repeat')) { throw 'F06 field frame must be a container background' }
|
||||
if ($editor -match '<view[^>]+class="editor-control[^>]*>\s*<image') { throw 'F06 control must not retain a positioned decorative image' }
|
||||
|
||||
Write-Output 'FAMILY-EDITORS-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -24,16 +24,17 @@ foreach ($entry in $pages.GetEnumerator()) {
|
||||
Assert-Match -Content $entry.Value -Pattern 'import AppLoading from [''"]@/components/AppLoading\.vue[''"]' -Message "$($entry.Key) must import AppLoading"
|
||||
}
|
||||
|
||||
Assert-Match -Content $pages.G05 -Pattern '(?s)<view\s+v-else-if="overviewState === ''loading''"\s+class="overview-state overview-state--loading">.*?<AppLoading[^>]+description="[^"]+"\s*/>.*?</view>' -Message 'G05 must render AppLoading inside its transparent framed loading state'
|
||||
Assert-Match -Content $pages.G05 -Pattern 'v-else-if="overviewState === ''loading''"\s+class="overview-state overview-state--loading"' -Message 'G05 must retain its transparent framed loading branch'
|
||||
Assert-Match -Content $pages.G05 -Pattern '(?s)<AppLoading\s+[^>]*description="[^"]+"[^>]*/>' -Message 'G05 must render AppLoading with a loading description'
|
||||
Assert-Match -Content $pages.G06 -Pattern '<AppLoading\s+v-if="searchState === ''loading''"\s+variant="section"[^>]+description="[^"]+"\s*/>' -Message 'G06 search results must render the section AppLoading variant'
|
||||
Assert-Match -Content $pages.G06 -Pattern 'requestedState === ''loading''' -Message 'G06 must expose its section loading state for visual review'
|
||||
Assert-Match -Content $pages.G06 -Pattern 'requestedState\s*===\s*["'']loading["'']' -Message 'G06 must expose its section loading state for visual review'
|
||||
Assert-Match -Content $pages.G09 -Pattern '<AppLoading\s+v-else-if="applicationState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'G09 must render a page AppLoading branch'
|
||||
Assert-Match -Content $pages.G10 -Pattern '<AppLoading\s+v-else-if="reviewState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'G10 must render a page AppLoading branch'
|
||||
Assert-Match -Content $pages.G11 -Pattern '<AppLoading\s+v-if="settingsState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'G11 must render a page AppLoading branch'
|
||||
Assert-Match -Content $pages.G12 -Pattern '<AppLoading\s+v-if="poemState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'G12 must render a page AppLoading branch'
|
||||
Assert-Match -Content $pages.G10 -Pattern 'if \(query\.state === ''loading''\) \{ reviewState\.value = ''loading''; return \}' -Message 'G10 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.G11 -Pattern 'query\.state === ''loading'' \? ''loading''' -Message 'G11 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.G12 -Pattern 'query\.state === ''loading'' \? ''loading''' -Message 'G12 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.G10 -Pattern 'query\.state\s*===\s*["'']loading["'']' -Message 'G10 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.G11 -Pattern 'query\.state\s*===\s*["'']loading["'']\s*\?\s*["'']loading["'']' -Message 'G11 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.G12 -Pattern 'query\.state\s*===\s*["'']loading["'']\s*\?\s*["'']loading["'']' -Message 'G12 must preserve its loading presentation state'
|
||||
|
||||
if ($pages.G06 -match 'class="search-status search-loading"') { throw 'G06 must not retain its duplicated text-only search loader' }
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/genealogy/g01-my-genealogies.vue'), [System.Text.Encoding]::UTF8)
|
||||
|
||||
foreach ($expected in @(
|
||||
'background: url("/static/assets/modules/genealogy/transparent/current-slip-frame.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / contain no-repeat',
|
||||
'grid-template-columns: minmax(0, 1fr) 96rpx'
|
||||
)) {
|
||||
if (-not $page.Contains($expected)) { throw "G01 document-flow contract missing: $expected" }
|
||||
}
|
||||
foreach ($layer in @('.add-dialog-layer', '.genealogy-switcher-layer')) {
|
||||
$escaped = [regex]::Escape($layer)
|
||||
if ($page -notmatch "(?s)$escaped\s*\{[^}]*position:\s*fixed;") { throw "G01 required dialog layer is not fixed: $layer" }
|
||||
}
|
||||
if ([regex]::Matches($page, 'position\s*:').Count -ne 2) { throw 'G01 must keep only its two user-triggered dialog layers positioned' }
|
||||
if ($page -match 'class="(?:current-frame|current-seal-frame|application-record__skin|error-panel__frame|empty-panel__frame|empty-seal__skin|empty-search-action__skin|empty-invite-action__skin|state-retry__skin)"') {
|
||||
throw 'G01 decorative skins must be container backgrounds'
|
||||
}
|
||||
|
||||
Write-Output 'G01-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -7,7 +7,7 @@ function Assert-Match {
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'background', 'border-radius')) {
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
$pattern = "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:"
|
||||
if ($Content -match $pattern) {
|
||||
throw "G01 empty state must not construct .$ClassName with CSS $property"
|
||||
@@ -35,12 +35,11 @@ foreach ($required in @(
|
||||
'empty-search-action',
|
||||
'empty-invite-action',
|
||||
'empty-create-note',
|
||||
'empty-panel__frame',
|
||||
'g01-empty-panel-frame.png',
|
||||
'const createGenealogy = () =>',
|
||||
'/pages/genealogy/g03-create-genealogy',
|
||||
'/pages/genealogy/g06-search-genealogies',
|
||||
'const hasGenealogies = computed(() => !forceEmptyState.value && list.value.length > 0);'
|
||||
'const hasGenealogies = computed('
|
||||
)) {
|
||||
if ($g01 -notmatch [regex]::Escape($required)) { throw "Missing G01 empty-state contract: $required" }
|
||||
}
|
||||
@@ -60,8 +59,7 @@ foreach ($required in @(
|
||||
Assert-Match -Content $g01 -Pattern '(?s)<view class="genealogy-fixed-zone">.*class="current-slip".*class="shortcut-grid".*class="section-divider".*</view>\s*<scroll-view[^>]*class="genealogy-list-scroll"[^>]*>.*class="genealogy-lower".*</scroll-view>' -Message 'G01 fixed and scrolling regions are not separated correctly'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.genealogy-index--split\s*\{[^}]*height:\s*100vh;[^}]*padding-bottom:\s*calc\(112rpx\s*\+\s*env\(safe-area-inset-bottom\)\)' -Message 'G01 split layout must reserve the fixed tabbar and safe area'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.genealogy-list-scroll\s*\{[^}]*height:\s*0;[^}]*flex:\s*1;' -Message 'G01 list scroll region must consume the remaining computed height'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)<view v-else class="genealogy-empty-state">.*?<view class="empty-panel">\s*<image\s+class="empty-panel__frame"\s+src="/static/assets/modules/genealogy/transparent/g01-empty-panel-frame\.png"\s+mode="scaleToFill"\s*/>' -Message 'G01 empty state must render the exact extracted transparent frame without an opaque panel skin'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel__frame\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*width:\s*100%;[^}]*height:\s*100%;[^}]*pointer-events:\s*none;' -Message 'G01 empty state transparent frame does not cover the approved panel slot'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel\s*\{[^}]*background:\s*url\("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame\.png"\)\s*center\s*/\s*100%\s+100%\s+no-repeat;' -Message 'G01 empty state must render the exact extracted transparent frame as its container background'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.genealogy-empty-state\s*\{[^}]*min-height:\s*1120rpx;' -Message 'G01 empty state does not reserve the approved visibly fuller height'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel\s*\{[^}]*height:\s*1120rpx;' -Message 'G01 empty panel does not use the approved visibly fuller height'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel__content\s*\{[^}]*justify-content:\s*center;[^}]*padding:\s*50rpx 28rpx 46rpx;' -Message 'G01 empty panel content is not centered with the approved responsive padding'
|
||||
|
||||
@@ -101,7 +101,7 @@ const run = async () => {
|
||||
if ((await valueOf(send, "document.querySelectorAll('.add-dialog .app-button').length")) !== 3) {
|
||||
throw new Error('G01 add dialog must keep search, invite, and create visible together')
|
||||
}
|
||||
await valueOf(send, "document.querySelector('.dialog-close').click()")
|
||||
await valueOf(send, "document.querySelector('.add-dialog__close').click()")
|
||||
await waitFor(send, "!document.querySelector('.add-dialog-layer')", 'G01 add dialog did not close')
|
||||
|
||||
await valueOf(send, "document.querySelector('.current-slip').click()")
|
||||
@@ -192,7 +192,7 @@ const run = async () => {
|
||||
if (scrollTopBeforeDialog <= 0) throw new Error('G01 list must be scrolled before testing position preservation')
|
||||
await valueOf(send, "document.querySelector('.create-action').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.add-dialog-layer'))", 'G01 add dialog did not open over the independent list')
|
||||
await valueOf(send, "document.querySelector('.dialog-close').click()")
|
||||
await valueOf(send, "document.querySelector('.add-dialog__close').click()")
|
||||
await waitFor(send, "!document.querySelector('.add-dialog-layer')", 'G01 add dialog did not close over the independent list')
|
||||
const scrollTopAfterDialog = await valueOf(send, readListScrollTop)
|
||||
if (Math.abs(scrollTopAfterDialog - scrollTopBeforeDialog) > 1) {
|
||||
|
||||
@@ -10,9 +10,11 @@ function Assert-Match {
|
||||
|
||||
$errorBlock = [regex]::Match($g01, '(?s)<view v-else-if="hasError" class="state-panel state-panel--error">.*?<template v-else-if="hasGenealogies">').Value
|
||||
if (-not $errorBlock) { throw 'G01 error state block missing' }
|
||||
foreach ($required in @('error-panel__frame', 'g01-empty-panel-frame.png', 'error-panel__seal', 'brand-seal.png', 'error-panel__divider', 'section-divider.png', 'state-retry', 'a01-scroll-primary-v3.png', '@click="retryLoad"')) {
|
||||
foreach ($required in @('error-panel__seal', 'brand-seal.png', 'error-panel__divider', 'section-divider.png', 'state-retry', '@click="retryLoad"')) {
|
||||
if ($errorBlock -notmatch [regex]::Escape($required)) { throw "G01 error state missing approved contract: $required" }
|
||||
}
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s*\{[^}]*background:\s*url\("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame\.png"\)\s*center\s*/\s*100%\s+100%\s+no-repeat;' -Message 'G01 error state must use the transparent frame as its container background'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.state-retry\s*\{[^}]*background:\s*url\("/static/assets/foundation/transparent/a01-scroll-primary-v3\.png"\)\s*center\s*/\s*contain\s+no-repeat;' -Message 'G01 retry must use the approved scroll skin as a container background'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s*\{[^}]*height:\s*1120rpx;' -Message 'G01 error state must use the approved full-height frame slot'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.error-panel__seal\s*\{[^}]*width:\s*132rpx;[^}]*height:\s*136rpx;' -Message 'G01 error state seal must use the approved prominent size'
|
||||
Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s+\.state-title\s*\{[^}]*font-size:\s*42rpx;' -Message 'G01 error title must use the approved hierarchy'
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding UTF8
|
||||
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding UTF8
|
||||
foreach ($token in @('genealogyContext', 'setCurrentGenealogyId', 'getCurrentGenealogyId', 'item.membership === "created"', 'item.membership === "joined"', 'genealogyId=${currentGenealogy.value.id}')) {
|
||||
if (-not $page.Contains($token)) { throw "G01 context contract missing: $token" }
|
||||
}
|
||||
foreach ($membership in @("membership: 'created'", "membership: 'joined'")) {
|
||||
if (-not $mock.Contains($membership)) { throw "Genealogy mock ownership missing: $membership" }
|
||||
}
|
||||
Write-Output 'G01-GENEALOGY-CONTEXT-CONTRACT PASS'
|
||||
@@ -2,7 +2,7 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const origin = 'http://localhost:5173'
|
||||
const outputDirectory = path.resolve('docs/design/screens/runtime/2026-07-19/g01-approval')
|
||||
const outputDirectory = path.join(require('os').tmpdir(), 'jiapuapp-runtime', 'g01-approval')
|
||||
const sizes = [
|
||||
{ width: 320, height: 568 },
|
||||
{ width: 360, height: 640 },
|
||||
@@ -36,8 +36,16 @@ const connect = async () => {
|
||||
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1
|
||||
pending.set(id, { resolve, reject })
|
||||
socket.send(JSON.stringify({ id, method, params }))
|
||||
const requestId = id
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(requestId)
|
||||
reject(new Error(`CDP request timed out: ${method}`))
|
||||
}, 8000)
|
||||
pending.set(requestId, {
|
||||
resolve: (value) => { clearTimeout(timer); resolve(value) },
|
||||
reject: (error) => { clearTimeout(timer); reject(error) }
|
||||
})
|
||||
socket.send(JSON.stringify({ id: requestId, method, params }))
|
||||
})
|
||||
|
||||
return { projectPageCount: projectPages.length, socket, send }
|
||||
@@ -69,6 +77,13 @@ const setSize = async (send, width, height) => {
|
||||
await sleep(150)
|
||||
}
|
||||
|
||||
let navigationId = 0
|
||||
const openG01 = async (send) => {
|
||||
navigationId += 1
|
||||
await send('Page.navigate', { url: `${origin}/?g01SwitchAudit=${navigationId}#/pages/genealogy/g01-my-genealogies` })
|
||||
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'G01 did not render after navigation')
|
||||
}
|
||||
|
||||
const openSwitcher = async (send) => {
|
||||
if (!await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))")) {
|
||||
const clicked = await valueOf(send, `(() => {
|
||||
@@ -134,9 +149,10 @@ const getMetrics = (send) => valueOf(send, `(() => {
|
||||
})()`)
|
||||
|
||||
const capture = async (send, filename) => {
|
||||
await send('Page.bringToFront')
|
||||
const screenshot = await send('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
fromSurface: true,
|
||||
fromSurface: false,
|
||||
captureBeyondViewport: false
|
||||
})
|
||||
fs.mkdirSync(outputDirectory, { recursive: true })
|
||||
@@ -165,13 +181,13 @@ const run = async () => {
|
||||
await send('Runtime.enable')
|
||||
await send('Network.enable')
|
||||
await setSize(send, 412, 915)
|
||||
await send('Page.reload')
|
||||
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'G01 did not render after reload')
|
||||
await openG01(send)
|
||||
await openSwitcher(send)
|
||||
|
||||
const sizeResults = []
|
||||
for (const size of sizes) {
|
||||
await setSize(send, size.width, size.height)
|
||||
await openG01(send)
|
||||
await clearClones(send)
|
||||
await openSwitcher(send)
|
||||
const metrics = await getMetrics(send)
|
||||
@@ -189,6 +205,8 @@ const run = async () => {
|
||||
}
|
||||
|
||||
await setSize(send, 412, 915)
|
||||
await openG01(send)
|
||||
await openSwitcher(send)
|
||||
assert(await addClonesToTotal(send, 6), 'Could not prepare six-item stress state')
|
||||
await sleep(100)
|
||||
const sixItems = await getMetrics(send)
|
||||
@@ -214,6 +232,21 @@ const run = async () => {
|
||||
const twelveItemsBottom = await getMetrics(send)
|
||||
assert(twelveItemsBottom.listScrollTop > 0, 'Twelve-item list did not scroll to the bottom')
|
||||
|
||||
assert(await addClonesToTotal(send, 50), 'Could not prepare fifty-item stress state')
|
||||
await sleep(100)
|
||||
const fiftyItemsTop = await getMetrics(send)
|
||||
assert(fiftyItemsTop.itemCount === 50, 'Fifty-item state did not contain fifty items')
|
||||
assert(Math.abs(fiftyItemsTop.dialog.height - expectedMaximumHeight) < 3, 'Fifty-item dialog escaped its safe maximum height')
|
||||
assert(fiftyItemsTop.listScrollHeight > fiftyItemsTop.listClientHeight * 3, 'Fifty-item list does not own its long-data scrolling')
|
||||
await valueOf(send, `(() => {
|
||||
const list = document.querySelector('.genealogy-switcher__list')
|
||||
list.scrollTop = list.scrollHeight
|
||||
return true
|
||||
})()`)
|
||||
await sleep(50)
|
||||
const fiftyItemsBottom = await getMetrics(send)
|
||||
assert(fiftyItemsBottom.listScrollTop > 0, 'Fifty-item list did not reach its bottom data')
|
||||
|
||||
await clearClones(send)
|
||||
await valueOf(send, "document.querySelector('.genealogy-switcher__content').click()")
|
||||
assert(await valueOf(send, "Boolean(document.querySelector('.genealogy-switcher'))"), 'Inner click closed the switcher')
|
||||
@@ -243,7 +276,7 @@ const run = async () => {
|
||||
assert(runtimeErrors.length === 0, `Runtime exceptions: ${runtimeErrors.join(' | ')}`)
|
||||
assert(resourceErrors.length === 0, `Resource errors: ${resourceErrors.join(' | ')}`)
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ projectPageCount, sizeResults, sixItems, twelveItemsTop, twelveItemsBottom, final, runtimeErrors, resourceErrors }, null, 2)}\n`)
|
||||
process.stdout.write(`${JSON.stringify({ projectPageCount, sizeResults, sixItems, twelveItemsTop, twelveItemsBottom, fiftyItemsTop, fiftyItemsBottom, final, runtimeErrors, resourceErrors }, null, 2)}\n`)
|
||||
process.stdout.write('PASS G-01 switch dialog runtime smoke\n')
|
||||
} finally {
|
||||
socket.close()
|
||||
|
||||
@@ -49,7 +49,7 @@ if ($header -notmatch 'notice\.png') { throw 'Header does not use the approved n
|
||||
if ($header -notmatch 'unreadCount\s*>\s*0') { throw 'Header badge is not conditional on unread count.' }
|
||||
if ($header -notmatch 'class="header-hall"') { throw 'Header does not isolate the hall line-art as a low-contrast background layer.' }
|
||||
if ($header -match 'background-image: url') { throw 'Header still applies hall line-art directly to the red header surface.' }
|
||||
if ($header -notmatch 'opacity:\s*\.29') { throw 'Header hall line-art is not visible enough for the approved eave layer.' }
|
||||
if ($header -notmatch 'opacity:\s*0?\.29') { throw 'Header hall line-art is not visible enough for the approved eave layer.' }
|
||||
if ($header -notmatch 'root-header-cinnabar\.jpg') { throw 'Header does not use the approved cinnabar texture asset.' }
|
||||
if ($header -notmatch 'height:\s*calc\(124rpx\s*\+\s*var\(--status-bar-height,\s*0px\)\)') { throw 'Header does not preserve the approved visual height above the system status bar.' }
|
||||
if ($header -notmatch 'padding-top:\s*var\(--status-bar-height,\s*0px\)') { throw 'Header does not reserve the real system status-bar height.' }
|
||||
@@ -75,7 +75,7 @@ $searchCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('5pCc5
|
||||
$inviteCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('6YKA6K+356CB5Yqg5YWl'))
|
||||
$continueCreateCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('57un57ut5Yib5bu65a626LCx'))
|
||||
$legacyCreateCopy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('56Gu6K6k5rKh5pyJ546w5pyJ5a626LCx77yM57un57ut5Yib5bu6'))
|
||||
$addMarkup = [regex]::Match($page, '(?s)<view v-if="addDialogVisible".*?<view v-if="switcherVisible"').Value
|
||||
$addMarkup = [regex]::Match($page, '(?s)<view\s+[^>]*v-if="addDialogVisible".*?<view\s+[^>]*v-if="switcherVisible"').Value
|
||||
if (-not $addMarkup) { throw 'G-01 add sheet markup could not be isolated.' }
|
||||
foreach ($token in @(
|
||||
'class="add-dialog__body"',
|
||||
@@ -118,14 +118,14 @@ if ($page -notmatch '(?s)\.add-dialog__content\s*\{[^}]*min-height:\s*780rpx;[^}
|
||||
if ($page -notmatch '(?s)\.add-dialog__body\s*\{[^}]*margin:\s*auto\s+0;') {
|
||||
throw 'G-01 add sheet body does not center safely with collapsible auto margins.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__heading\s*\{[^}]*position:\s*relative;[^}]*padding-right:\s*96rpx;') {
|
||||
throw 'G-01 add sheet heading does not own the close-control positioning context.'
|
||||
if ($page -notmatch '(?s)\.add-dialog__heading\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+96rpx;') {
|
||||
throw 'G-01 add sheet heading does not reserve a document-flow close-control column.'
|
||||
}
|
||||
if ($page -match '(?s)\.add-dialog__heading\s*\{[^}]*top\s*:') {
|
||||
throw 'G-01 add sheet heading must use real flow spacing instead of a visual offset.'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.add-dialog__close\s*\{[^}]*position:\s*absolute;[^}]*top:\s*0;[^}]*right:\s*-22rpx;') {
|
||||
throw 'G-01 add sheet close control does not follow the centered heading.'
|
||||
if ($page -notmatch '(?s)\.add-dialog__close\s*\{[^}]*grid-column:\s*2;[^}]*grid-row:\s*1\s*/\s*span\s+2;[^}]*margin-right:\s*-22rpx;') {
|
||||
throw 'G-01 add sheet close control does not follow the heading grid.'
|
||||
}
|
||||
if ($page -notmatch [regex]::Escape('/static/assets/modules/genealogy/transparent/g01-add-sheet-background-v3.png')) {
|
||||
throw 'G-01 add sheet does not use the approved complete background asset.'
|
||||
@@ -245,7 +245,7 @@ if ($page -notmatch '(?s)\.current-meta-icon\s*\{[^}]*width:\s*36rpx;[^}]*height
|
||||
if ($page -notmatch '(?s)@media screen and \(max-width:\s*340px\)[^{]*\{.*?\.current-meta-icon\s*\{[^}]*width:\s*32rpx;[^}]*height:\s*32rpx;') { throw 'G-01 compact viewport metadata icons do not preserve the approved 32rpx size.' }
|
||||
if ($card -notmatch '(?s)\.card-meta-icon\s*\{[^}]*width:\s*46rpx;[^}]*height:\s*46rpx;[^}]*flex:\s*0 0 auto;[^}]*opacity:\s*1;[^}]*filter:\s*saturate\(1\.35\) brightness\(0\.82\) contrast\(1\.15\);') { throw 'G-01 list location and member icons do not use the approved balanced size and contrast.' }
|
||||
if ($card -notmatch '(?s)\.card-meta-item\s*\{[^}]*margin-right:\s*0;') { throw 'G-01 list metadata groups still retain the superseded trailing margin.' }
|
||||
if ($card -notmatch '(?s)\.card-meta-item\s*\+\s*\.card-meta-item\s*\{[^}]*margin-left:\s*18rpx;') { throw 'G-01 list metadata groups do not keep the approved 18rpx separation.' }
|
||||
if ($card -notmatch '(?s)\.card-metas\s*\{[^}]*flex-wrap:\s*wrap;[^}]*gap:\s*4rpx 18rpx;') { throw 'G-01 list metadata groups must preserve the approved separation while allowing data-driven wrapping.' }
|
||||
if ($card -notmatch '(?s)\.genealogy-card\s*\{[^}]*min-height:\s*178rpx;') { throw 'G-01 list cards do not reserve the approved height for the update-date row.' }
|
||||
if ($card -notmatch '(?s)\.card-detail-row\s*\{[^}]*flex-wrap:\s*wrap;') { throw 'G-01 list detail row does not allow the update date to occupy its own row.' }
|
||||
if ($card -notmatch '(?s)<view class="card-detail-row">\s*<view class="card-updated">.*?</view>\s*<view class="card-metas">') { throw 'G-01 update date must precede metadata in the approved visual and DOM order.' }
|
||||
@@ -255,7 +255,7 @@ if ($card -notmatch '(?s)@media screen and \(max-width:\s*340px\)[^{]*\{.*?\.gen
|
||||
if ($card -notmatch '(?s)\.card-meta\s*\{[^}]*font-size:\s*26rpx') { throw 'G-01 list metadata must retain its original 26rpx size.' }
|
||||
if ($card -notmatch '(?s)\.card-updated\s*\{[^}]*font-size:\s*24rpx') { throw 'G-01 list update date must retain its original 24rpx size.' }
|
||||
if ($card -notmatch '(?s)\.card-role\s*\{[^}]*font-size:\s*26rpx') { throw 'G-01 list role must retain its original 26rpx size.' }
|
||||
if ($page -notmatch '(?s)\.create-action\s*>\s*text\s*\{.*?white-space:\s*nowrap') { throw 'G-01 create action text can wrap onto multiple lines.' }
|
||||
if ($page -match '(?s)\.create-action\s*>\s*text\s*\{[^}]*white-space:\s*nowrap') { throw 'G-01 create action text must not use a fixed single-line capacity.' }
|
||||
if ($page -notmatch '(?s)\.application-record\s*\+\s*\.application-record\s*\{[^}]*margin-top:\s*12rpx') { throw 'G-01 adjacent application cards must keep the approved 12rpx separation.' }
|
||||
if ($page -notmatch '(?s)\.application-record__copy\s*\{[^}]*color:\s*#62584c;[^}]*font-size:\s*28rpx;[^}]*font-weight:\s*500;[^}]*line-height:\s*1\.4;') { throw 'G-01 application descriptions do not use the approved readable style.' }
|
||||
if ($page -notmatch '(?s)\.application-record__status\s*\{[^}]*color:\s*#7f4f16;[^}]*font-size:\s*27rpx;[^}]*font-weight:\s*600;') { throw 'G-01 application statuses do not use the approved readable base style.' }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('sexOptions', 'mode="selector"', 'mode="date"', 'ancestorForm.sex', 'auto-height')) {
|
||||
if (-not $page.Contains($token)) { throw "G03 ancestor semantic field missing: $token" }
|
||||
}
|
||||
Write-Output 'G03-ANCESTOR-SEMANTIC-FIELDS-CONTRACT PASS'
|
||||
@@ -7,7 +7,7 @@ function Assert-Contains {
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'background', 'border-radius')) {
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
$pattern = "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:"
|
||||
if ($Content -match $pattern) { throw "G03 must not construct .$ClassName with CSS $property" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 1 -or $positions[0].Groups[1].Value.Trim() -ne 'fixed') {
|
||||
throw "G03 must retain only its combined fixed dialog layer rule; found $($positions.Count) position declarations"
|
||||
}
|
||||
foreach ($required in @(
|
||||
'background: url("/static/assets/foundation/opaque/root-header-cinnabar.jpg")',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")',
|
||||
'background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G03 is missing real asset background: $required" } }
|
||||
Write-Output 'G03-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -5,15 +5,14 @@ $g05 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-
|
||||
function Assert-Match { param([string]$Pattern,[string]$Message); if ($g05 -notmatch $Pattern) { throw $Message } }
|
||||
|
||||
Assert-Match 'overview-surface--state' 'G05 system states must own an independent surface'
|
||||
Assert-Match 'class="overview-state__frame".*g01-empty-panel-frame\.png' 'G05 system states must reuse the transparent gold frame'
|
||||
Assert-Match 'class="overview-state__seal".*brand-seal\.png' 'G05 non-loading states must use the approved red-gold seal'
|
||||
Assert-Match '(?s)\.overview-surface--state\s+\.overview-surface__skin\s*\{[^}]*display:\s*none;' 'G05 system states must hide the legacy function-grid skin'
|
||||
Assert-Match '(?s)\.overview-state\s*\{[^}]*background:\s*url\("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame\.png"\)' 'G05 system states must reuse the transparent gold frame as its real background'
|
||||
Assert-Match '(?s)class="overview-state__seal".*?brand-seal\.png' 'G05 non-loading states must use the approved red-gold seal'
|
||||
Assert-Match '(?s)\.overview-surface\s*\{[^}]*background:\s*url\("/static/assets/modules/genealogy/opaque/g05-overview-surface\.png"\)' 'G05 ready surface must use the approved overview asset as its real background'
|
||||
Assert-Match '(?s)\.overview-state__seal\s*\{[^}]*width:\s*132rpx;[^}]*height:\s*136rpx;' 'G05 state seal must use the approved size'
|
||||
Assert-Match 'overview-action-lock' 'G05 member mode must explain owner-only actions instead of leaving blank cells'
|
||||
Assert-Match '(?s)\.overview-actions--member\s*\{[^}]*grid-template-rows:\s*1fr 1fr;' 'G05 member actions must retain the full two-row surface'
|
||||
Assert-Match '(?s)\.overview-actions\s*\{[^}]*min-height:\s*386rpx;' 'G05 actions must retain a minimum two-row surface while allowing data growth'
|
||||
Assert-Match '(?s)\.overview-public__details > view:nth-child\(5\)\s*\{[^}]*grid-column:\s*1 / -1;' 'G05 public fifth identity detail must span the full row'
|
||||
Assert-Match '(?s)\.overview-public__details > view:nth-child\(5\)\s*\{[^}]*position:\s*absolute;[^}]*top:\s*250rpx;' 'G05 public parent genealogy must occupy the dedicated identity row'
|
||||
Assert-Match '(?s)</view>\s*<view class="overview-public__notice">.*publicDescription.*</view>\s*<view class="overview-public__action"' 'G05 public description must occupy the dedicated bottom note surface'
|
||||
Assert-Match '(?s)\.overview-public__notice\s*\{[^}]*position:\s*absolute;[^}]*top:\s*78%;' 'G05 public description must stay inside the approved bottom note region'
|
||||
Assert-Match '(?s)\.overview-public__action\s*\{[^}]*min-height:\s*82rpx;' 'G05 public action must retain its visual minimum while allowing the public content to grow'
|
||||
|
||||
Write-Output 'G05-ALL-STATES-VISUAL-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('overviewFixtures', 'overviewFixtures[genealogyId.value]', 'query.genealogyName', 'ancestorName', 'updatedAt')) {
|
||||
if (-not $page.Contains($token)) { throw "G05 data ownership contract missing: $token" }
|
||||
}
|
||||
if ($page.Contains('class="overview-action-lock"')) { throw 'G05 ordinary members must not see inert management lock cards' }
|
||||
Write-Output 'G05-DATA-OWNERSHIP-CONTRACT PASS'
|
||||
@@ -0,0 +1,40 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$pagePath = Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue'
|
||||
$page = Get-Content -LiteralPath $pagePath -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$styleWithoutComments = [regex]::Replace($style, '(?s)/\*.*?\*/', '')
|
||||
$positions = [regex]::Matches($styleWithoutComments, '(?m)\bposition\s*:\s*([^;]+);')
|
||||
|
||||
if ($positions.Count -ne 0) {
|
||||
$values = ($positions | ForEach-Object { $_.Groups[1].Value.Trim() }) -join ', '
|
||||
throw "G05 ordinary overview layout must use document flow; found $($positions.Count) position declarations: $values"
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'background: url("/static/assets/modules/genealogy/opaque/g05-overview-surface.png")',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) {
|
||||
if ($page -notmatch [regex]::Escape($required)) {
|
||||
throw "G05 document-flow migration is missing asset-backed surface: $required"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($obsoleteClass in @('overview-surface__skin', 'overview-state__frame')) {
|
||||
if ($page -match "class=`"$obsoleteClass`"") {
|
||||
throw "G05 must not retain decorative image layer .$obsoleteClass"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($selector in @('overview-ready', 'overview-public')) {
|
||||
if ($style -match "(?s)\.$selector\s*\{[^}]*(?:height\s*:\s*100%|grid-template-rows\s*:[^;}]+)") {
|
||||
throw "G05 .$selector must let overview content define row heights"
|
||||
}
|
||||
}
|
||||
if ($style -match '(?s)\.overview-public__details\s*\{[^}]*grid-template-rows\s*:') {
|
||||
throw 'G05 public details must grow from their data instead of fixed rows'
|
||||
}
|
||||
|
||||
Write-Output 'G05-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -7,7 +7,7 @@ function Assert-Contains {
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'background', 'border-radius')) {
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
|
||||
throw "G05 must not construct .$ClassName with CSS $property"
|
||||
}
|
||||
@@ -24,10 +24,10 @@ Assert-Contains $g01 'g05-genealogy-overview?genealogyId=' 'G01 must open G05 wi
|
||||
if ($g01 -match 'g05-genealogy-overview\?id=') { throw 'G01 must not retain the obsolete G05 id query key' }
|
||||
|
||||
foreach ($required in @(
|
||||
"const overviewState = ref('loading')",
|
||||
'const overviewState = ref("loading")',
|
||||
"query.genealogyId",
|
||||
"const viewMode = ref('member')",
|
||||
"const accessRole = ref('owner')",
|
||||
'const viewMode = ref("member")',
|
||||
'const accessRole = ref("owner")',
|
||||
'const overviewFixture = {',
|
||||
'overview-ready',
|
||||
'overview-public',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$pagePath = Join-Path $root 'pages/genealogy/g06-search-genealogies.vue'
|
||||
$page = Get-Content -LiteralPath $pagePath -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$styleWithoutComments = [regex]::Replace($style, '(?s)/\*.*?\*/', '')
|
||||
$positions = [regex]::Matches($styleWithoutComments, '(?m)\bposition\s*:\s*([^;]+);')
|
||||
|
||||
if ($positions.Count -ne 0) {
|
||||
$values = ($positions | ForEach-Object { $_.Groups[1].Value.Trim() }) -join ', '
|
||||
throw "G06 search and invitation layouts must use document flow; found $($positions.Count) position declarations: $values"
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'background: url("/static/assets/modules/genealogy/opaque/g06-search-title-strip.png")',
|
||||
'background: url("/static/assets/modules/genealogy/opaque/g06-search-input-wide.png")',
|
||||
'background: url("/static/assets/modules/genealogy/opaque/g06-search-button.png")',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) {
|
||||
if ($page -notmatch [regex]::Escape($required)) {
|
||||
throw "G06 document-flow migration is missing asset-backed surface: $required"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($obsoleteClass in @('search-title-strip__skin', 'search-field__skin', 'search-action__skin', 'result-card__skin')) {
|
||||
if ($page -match "class=`"$obsoleteClass`"") {
|
||||
throw "G06 must not retain decorative image layer .$obsoleteClass"
|
||||
}
|
||||
}
|
||||
|
||||
if ($style -match '(?s)\.result-card\s*\{[^}]*overflow\s*:\s*hidden') {
|
||||
throw 'G06 result cards must grow with search-result data instead of clipping content'
|
||||
}
|
||||
|
||||
Write-Output 'G06-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -0,0 +1,11 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$search = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g06-search-genealogies.vue') -Raw -Encoding UTF8
|
||||
$join = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('genealogyName=${encodeURIComponent(item.name)}', 'previous=${item.relation}')) {
|
||||
if (-not $search.Contains($token)) { throw "G06 context propagation missing: $token" }
|
||||
}
|
||||
foreach ($token in @('previousRelation', 'query.genealogyName', 'previousNotice', 'decodeURIComponent')) {
|
||||
if (-not $join.Contains($token)) { throw "G08 context consumption missing: $token" }
|
||||
}
|
||||
Write-Output 'G06-G08-CONTEXT-CONTRACT PASS'
|
||||
@@ -3,7 +3,7 @@ $ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$screenshots = Join-Path $root 'docs/design/screens/runtime/2026-07-14'
|
||||
$screenshots = Join-Path $root 'docs/design/screens/handoff/2026-07-14'
|
||||
$files = [ordered]@{
|
||||
initial = Join-Path $screenshots 'G06-selected-strip-final-initial-recheck-360x800.png'
|
||||
results = Join-Path $screenshots 'G06-selected-strip-final-results-360x800.png'
|
||||
|
||||
@@ -7,7 +7,7 @@ function Assert-Contains {
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'background', 'border-radius')) {
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
$pattern = "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:"
|
||||
if ($Content -match $pattern) { throw "G06 must not construct .$ClassName with CSS $property" }
|
||||
}
|
||||
@@ -27,16 +27,16 @@ if ($paths -contains 'pages/genealogy/g07-search-result') { throw 'G07 route mus
|
||||
if (Test-Path -LiteralPath $g07) { throw 'G07 page file must be deleted; search results are a G06 state' }
|
||||
|
||||
foreach ($required in @(
|
||||
"const mode = ref('search')",
|
||||
"const searchState = ref('initial')",
|
||||
"const inviteState = ref('initial')",
|
||||
'const mode = ref("search")',
|
||||
'const searchState = ref("initial")',
|
||||
'const inviteState = ref("initial")',
|
||||
'const resultFixtures = [',
|
||||
"relation: 'available'",
|
||||
"relation: 'joined'",
|
||||
"relation: 'pending'",
|
||||
"relation: 'rejected'",
|
||||
"relation: 'removed'",
|
||||
"relation: 'owned'",
|
||||
'relation: "available"',
|
||||
'relation: "joined"',
|
||||
'relation: "pending"',
|
||||
'relation: "rejected"',
|
||||
'relation: "removed"',
|
||||
'relation: "owned"',
|
||||
'parentName:',
|
||||
'branchName:',
|
||||
'manager:',
|
||||
@@ -52,8 +52,6 @@ foreach ($required in @(
|
||||
'section-divider.png',
|
||||
'root-header-hall.png',
|
||||
'search-controls',
|
||||
'search-field__skin',
|
||||
'search-action__skin',
|
||||
'search-initial',
|
||||
'search-results',
|
||||
'search-empty',
|
||||
@@ -79,7 +77,7 @@ if ($g06 -notmatch '(?s)\.search-controls\s*\{[^}]*display:\s*flex[^}]*align-ite
|
||||
throw 'G06 search input and action must form one compact control row'
|
||||
}
|
||||
Assert-Contains -Content $g06 -Expected 'class="status-retry"' -Message 'G06 error retry action is missing'
|
||||
Assert-Contains -Content $g06 -Expected 'src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"' -Message 'G06 error retry must use the approved A-series primary scroll asset'
|
||||
Assert-Contains -Content $g06 -Expected 'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")' -Message 'G06 error retry must use the approved A-series primary scroll asset as its real background'
|
||||
if ($g06 -notmatch '(?s)\.status-retry\s*\{[^}]*width:\s*300rpx;[^}]*height:\s*76rpx;') {
|
||||
throw 'G06 error retry must use the approved compact action size'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 0) { throw "G08 join flow must use document flow; found $($positions.Count) position declarations" }
|
||||
foreach ($required in @(
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G08 is missing real asset background: $required" } }
|
||||
if ($page -match 'class="join-panel__skin"') { throw 'G08 must not retain the decorative panel image layer' }
|
||||
if ($page -notmatch '(?s)<textarea[^>]*auto-height') { throw 'G08 application message must grow from its text data' }
|
||||
Write-Output 'G08-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -7,7 +7,7 @@ function Assert-Contains {
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'background', 'border-radius')) {
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
|
||||
throw "Application flow must not construct .$ClassName with CSS $property"
|
||||
}
|
||||
@@ -27,7 +27,7 @@ foreach ($page in @($g08, $g09, $g10)) {
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
"const joinState = ref('form')",
|
||||
'const joinState = ref("form")',
|
||||
'join-state--form',
|
||||
'join-state--success',
|
||||
'join-state--error',
|
||||
@@ -36,7 +36,7 @@ foreach ($required in @(
|
||||
'a01-scroll-primary-v3.png',
|
||||
'const genealogyPreview',
|
||||
'query.genealogyId',
|
||||
"const source = ref('search')",
|
||||
'const source = ref("search")',
|
||||
'const sourceContract = computed(() =>',
|
||||
'const fieldErrors = reactive({',
|
||||
'/pages/genealogy/g09-my-applications',
|
||||
@@ -50,15 +50,15 @@ if ($g08 -notmatch '(?s)\.join-field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
"const applicationState = ref('loading')",
|
||||
'const applicationState = ref("loading")',
|
||||
'application-state--list',
|
||||
'application-state--empty',
|
||||
'application-state--error',
|
||||
'list-slip-frame.png',
|
||||
"'PENDING'",
|
||||
"'APPROVED'",
|
||||
"'REJECTED'",
|
||||
"'WITHDRAWN'",
|
||||
'"PENDING"',
|
||||
'"APPROVED"',
|
||||
'"REJECTED"',
|
||||
'"WITHDRAWN"',
|
||||
'const applicationSamples',
|
||||
'const actionFor = (item) =>',
|
||||
'const withdrawTarget = ref(null)',
|
||||
@@ -70,7 +70,7 @@ foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'un
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
"const reviewState = ref('loading')",
|
||||
'const reviewState = ref("loading")',
|
||||
'review-state--list',
|
||||
'review-state--empty',
|
||||
'review-state--error',
|
||||
|
||||
@@ -93,7 +93,13 @@ const run = async () => {
|
||||
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
|
||||
const width = await valueOf(send, 'document.documentElement.scrollWidth')
|
||||
if (width > size.width + 1) throw new Error(`G08 has horizontal overflow at ${size.width}x${size.height}`)
|
||||
if (width > size.width + 1) {
|
||||
const offenders = await valueOf(send, `Array.from(document.querySelectorAll('*')).map((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
return { tag: node.tagName, className: String(node.className || ''), left: rect.left, right: rect.right, width: rect.width }
|
||||
}).filter((item) => item.left < -1 || item.right > innerWidth + 1).slice(0, 12)`)
|
||||
throw new Error(`G08 has horizontal overflow at ${size.width}x${size.height}: ${JSON.stringify(offenders)}`)
|
||||
}
|
||||
}
|
||||
await open(send, '/pages/genealogy/g09-my-applications', '', '.application-state--list')
|
||||
if ((await valueOf(send, "document.querySelectorAll('.application-card__action').length")) !== 3) throw new Error('G09 list did not render the three status-specific actions')
|
||||
@@ -101,7 +107,7 @@ const run = async () => {
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", 'G09 pending action did not open custom withdrawal confirmation')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
|
||||
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('已撤回')", 'G09 withdrawal did not update the local status')
|
||||
await valueOf(send, "document.querySelectorAll('.application-card__action')[1].click()")
|
||||
await valueOf(send, "document.querySelector('.application-card__status--rejected')?.closest('.application-card')?.querySelector('.application-card__action')?.click()")
|
||||
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?source=search&previous=rejected&genealogyId=')", 'G09 rejected action did not reopen G08 search source')
|
||||
await open(send, '/pages/genealogy/g09-my-applications', '?state=empty', '.application-state--empty')
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?genealogyId=1001', '.review-state--list')
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 0) { throw "G09 application list must use document flow; found $($positions.Count) position declarations" }
|
||||
foreach ($required in @(
|
||||
'background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G09 is missing real asset background: $required" } }
|
||||
if ($page -match 'class="application-card__skin"') { throw 'G09 must not retain decorative card image layers' }
|
||||
if ($page -notmatch '(?s)\.application-card__status\s*\{[^}]*grid-row:\s*2;[^}]*grid-column:\s*2;' -or $page -notmatch '(?s)\.application-card__action\s*\{[^}]*grid-row:\s*3;[^}]*grid-column:\s*2;') {
|
||||
throw 'G09 status and action must occupy separate data-driven rows'
|
||||
}
|
||||
Write-Output 'G09-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -0,0 +1,11 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$mine = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding UTF8
|
||||
$review = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g10-application-review.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('query.status', 'WITHDRAWN:', 'previous=withdrawn')) {
|
||||
if (-not $mine.Contains($token)) { throw "G09 business state missing: $token" }
|
||||
}
|
||||
foreach ($token in @('rejectionReason', 'rejectionError', 'class="rejection-field"', 'current.item.rejectionReason')) {
|
||||
if (-not $review.Contains($token)) { throw "G10 rejection contract missing: $token" }
|
||||
}
|
||||
Write-Output 'G09-G10-BUSINESS-STATE-CONTRACT PASS'
|
||||
@@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/genealogy/g10-application-review.vue'), [System.Text.Encoding]::UTF8)
|
||||
|
||||
foreach ($expected in @(
|
||||
'background: url("/static/assets/modules/genealogy/transparent/list-slip-frame.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/section-divider.png") bottom center / 100% 14rpx no-repeat',
|
||||
'grid-template-columns: auto minmax(0, 1fr) auto',
|
||||
'padding: 44rpx 52rpx 29rpx 64rpx',
|
||||
'grid-area: 1 / 1',
|
||||
'z-index: 1',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-toast-v3.png") center / 100% 100% no-repeat'
|
||||
)) {
|
||||
if (-not $page.Contains($expected)) { throw "G10 document-flow contract missing: $expected" }
|
||||
}
|
||||
if ($page -notmatch '(?s)\.review-feedback\s*\{[^}]*position:\s*fixed;') { throw 'G10 feedback must remain fixed' }
|
||||
if ([regex]::Matches($page, 'position\s*:').Count -ne 1) { throw 'G10 must keep only its fixed feedback positioned' }
|
||||
if ($page -match 'class="(?:application-card__skin|review-state-card__skin)"|review-intro[^>]*>.*?section-divider\.png|review-feedback[^>]*>\s*<image') { throw 'G10 decorative frames must be container backgrounds' }
|
||||
|
||||
Write-Output 'G10-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -0,0 +1,16 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g11-genealogy-settings.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 1 -or $positions[0].Groups[1].Value.Trim() -ne 'fixed') {
|
||||
throw "G11 must retain only its fixed feedback layer; found $($positions.Count) position declarations"
|
||||
}
|
||||
foreach ($required in @(
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G11 is missing real asset background: $required" } }
|
||||
if ($page -notmatch '(?s)<textarea[^>]*auto-height') { throw 'G11 access note must grow from its text data' }
|
||||
Write-Output 'G11-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -4,7 +4,7 @@ $g11 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g11-genealogy-
|
||||
$g12 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g12-generation-poems.vue') -Raw -Encoding utf8
|
||||
|
||||
foreach ($rule in @(
|
||||
'(?s)\.settings-form__copy, \.settings-result__copy\s*\{[^}]*font-size:\s*24rpx;',
|
||||
'(?s)\.settings-form__copy,\s*\.settings-result__copy\s*\{[^}]*font-size:\s*24rpx;',
|
||||
'(?s)\.settings-field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;',
|
||||
'(?s)\.visibility-block__hint\s*\{[^}]*font-size:\s*22rpx;',
|
||||
'(?s)\.visibility-option text\s*\{[^}]*font-size:\s*24rpx;'
|
||||
|
||||
@@ -7,7 +7,7 @@ function Assert-Contains {
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'background', 'border-radius')) {
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
|
||||
throw "G11-G12 must not construct .$ClassName with CSS $property"
|
||||
}
|
||||
@@ -27,7 +27,7 @@ foreach ($page in @($g11, $g12)) {
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
"const settingsState = ref('loading')",
|
||||
'const settingsState = ref("loading")',
|
||||
'settings-state--form',
|
||||
'settings-state--success',
|
||||
'settings-state--error',
|
||||
@@ -38,12 +38,12 @@ foreach ($required in @(
|
||||
'a01-scroll-primary-v3.png',
|
||||
'const genealogyDraft',
|
||||
'query.genealogyId',
|
||||
"'MEMBER_ONLY'",
|
||||
"'PUBLIC_APPLY'"
|
||||
'"MEMBER_ONLY"',
|
||||
'"PUBLIC_APPLY"'
|
||||
)) { Assert-Contains $g11 $required "Missing G11 settings contract: $required" }
|
||||
|
||||
foreach ($required in @(
|
||||
"const poemState = ref('loading')",
|
||||
'const poemState = ref("loading")',
|
||||
'poem-state--list',
|
||||
'poem-state--empty',
|
||||
'poem-state--edit',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g11-genealogy-settings.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('settingsFixtures', 'settingsFixtures[genealogyId.value]', 'genealogyDraft.name.trim().length > 30', 'originalDraft', 'isDirty')) {
|
||||
if (-not $page.Contains($token)) { throw "G11 settings data contract missing: $token" }
|
||||
}
|
||||
Write-Output 'G11-SETTINGS-DATA-CONTRACT PASS'
|
||||
@@ -0,0 +1,15 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g12-generation-poems.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 1 -or $positions[0].Groups[1].Value.Trim() -ne 'fixed') {
|
||||
throw "G12 must retain only its fixed feedback layer; found $($positions.Count) position declarations"
|
||||
}
|
||||
foreach ($required in @(
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png")',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/g-form-field-frame.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G12 is missing real asset background: $required" } }
|
||||
if ($page -notmatch '(?s)<textarea[^>]*auto-height') { throw 'G12 generation poem field must grow from its text data' }
|
||||
Write-Output 'G12-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -0,0 +1,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g12-generation-poems.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('startGeneration', 'currentGeneration', 'characters.map(', 'maxlength="50"', 'stopMissingOldGeneration.value')) {
|
||||
if (-not $page.Contains($token)) { throw "G12 capacity contract missing: $token" }
|
||||
}
|
||||
if ($page -match '\.slice\(0,\s*4\)') { throw 'G12 must not truncate poem data to four generations' }
|
||||
Write-Output 'G12-GENERATION-POEM-CAPACITY-CONTRACT PASS'
|
||||
@@ -44,7 +44,7 @@ foreach ($encodedStale in @(
|
||||
|
||||
$approved = ([regex]::Matches($planning, '\|\s*`\[x\]`[^|]*\|\s*\d+\s*\|')).Count
|
||||
$candidate = ([regex]::Matches($planning, '\|\s*`\[~\]`[^|]*\|\s*\d+\s*\|')).Count
|
||||
if ($approved -ne 6 -or $candidate -ne 46) {
|
||||
if ($approved -ne 10 -or $candidate -ne 42) {
|
||||
throw "planning counts changed: approved=$approved candidate=$candidate"
|
||||
}
|
||||
|
||||
|
||||
@@ -32,4 +32,17 @@ foreach ($forbidden in @('m01-profile-notice-card.png', 'm01-profile-menu-frame.
|
||||
}
|
||||
}
|
||||
|
||||
if ($source -match '<image\s+(?:[^>]*\s)?class="(?:profile-hero__frame|profile-scroll-notice__skin|profile-error__skin)"') {
|
||||
throw 'M01 decorative frames must be container backgrounds instead of positioned image layers'
|
||||
}
|
||||
if ($source -match 'position\s*:') { throw 'M01 must keep content and local decoration in flex/grid document flow' }
|
||||
foreach ($token in @(
|
||||
'class="profile-hero__content"',
|
||||
'grid-area: 1 / 1',
|
||||
'background: url("/static/assets/modules/profile/transparent/m01-profile-summary-card.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png") center / 100% 100% no-repeat'
|
||||
)) {
|
||||
if (-not $source.Contains($token)) { throw "M01 missing document-flow token: $token" }
|
||||
}
|
||||
|
||||
Write-Output 'M01-MODULE-BASELINE-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m01-profile-home.vue') -Raw -Encoding UTF8
|
||||
foreach ($route in @('/pages/profile/m08-promotion','/pages/profile/m09-vip-orders')) {
|
||||
if (-not $page.Contains($route)) { throw "M01 service entry missing: $route" }
|
||||
}
|
||||
if ($page -match '(?s)\.profile-hero\s*\{[^}]*overflow:\s*hidden') { throw 'M01 profile hero must not clip expanded identity copy' }
|
||||
Write-Output 'M01-SERVICE-ENTRY-CONTRACT PASS'
|
||||
@@ -29,10 +29,10 @@ Assert-Match -Content $pages.T07 -Pattern '<AppLoading\s+v-if="directoryState ==
|
||||
Assert-Match -Content $pages.F01 -Pattern '<AppLoading\s+v-if="feedState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'F01 must render AppLoading before feed content'
|
||||
Assert-Match -Content $pages.N01 -Pattern '<AppLoading\s+v-if="noticeState === ''loading''"[^>]+description="[^"]+"\s*/>' -Message 'N01 must render AppLoading before message content'
|
||||
|
||||
Assert-Match -Content $pages.T01 -Pattern 'query\.state === ''loading''' -Message 'T01 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.T03 -Pattern 'query\.state\s*===\s*''loading''\s*\?\s*''loading''' -Message 'T03 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.T07 -Pattern 'query\.state\s*===\s*''loading''\s*\?\s*''loading''' -Message 'T07 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.F01 -Pattern 'query\.state\s*===\s*''loading''\s*\?\s*''loading''' -Message 'F01 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.N01 -Pattern 'query\.state\s*===\s*''loading''\s*\?\s*''loading''' -Message 'N01 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.T01 -Pattern 'query\.state\s*===\s*["'']loading["'']' -Message 'T01 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.T03 -Pattern '(?s)if\s*\(query\.state\s*===\s*["'']loading["'']\)\s*\{[^}]*memberState\.value\s*=\s*["'']loading["'']' -Message 'T03 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.T07 -Pattern 'query\.state\s*===\s*["'']loading["'']\s*\?\s*["'']loading["'']' -Message 'T07 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.F01 -Pattern 'query\.state\s*===\s*["'']loading["'']\s*\?\s*["'']loading["'']' -Message 'F01 must preserve its loading presentation state'
|
||||
Assert-Match -Content $pages.N01 -Pattern 'query\.state\s*===\s*["'']loading["'']\s*\?\s*["'']loading["'']' -Message 'N01 must preserve its loading presentation state'
|
||||
|
||||
Write-Output 'MODULE-APP-LOADING-CONTRACT PASS'
|
||||
|
||||
@@ -6,8 +6,7 @@ foreach ($expected in @(
|
||||
'class="form-row"',
|
||||
'class="settings-row"',
|
||||
'grid-template-columns: auto minmax(0, 1fr)',
|
||||
'grid-template-columns: minmax(0, 1fr) auto',
|
||||
'pointer-events: none'
|
||||
'grid-template-columns: minmax(0, 1fr) auto'
|
||||
)) {
|
||||
if (-not $component.Contains($expected)) { throw "ModulePage document-flow contract missing: $expected" }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
const origin = process.argv[2] || 'http://localhost:5173'
|
||||
const consumers = [
|
||||
['/pages/family/f03-feed-detail', '.feed-detail-page'], ['/pages/family/f04-article-list', '.article-list-page'],
|
||||
['/pages/family/f05-article-detail', '.article-detail-page'], ['/pages/family/f07-album-list', '.album-list-page'],
|
||||
['/pages/records/r03-gift-list', '.gift-page'], ['/pages/records/r04-gift-editor', '.gift-editor-page'],
|
||||
['/pages/records/r05-ritual-list', '.ritual-page'], ['/pages/records/r06-ritual-detail', '.ritual-detail-page'],
|
||||
['/pages/records/r07-ritual-editor', '.ritual-editor-page'], ['/pages/records/r08-growth-journal', '.timeline-page'],
|
||||
['/pages/records/r09-life-events', '.timeline-page'], ['/pages/records/r10-memo-list', '.memo-page'],
|
||||
['/pages/records/r11-merit-records', '.merit-page'], ['/pages/notification/n02-message-detail', '.notice-detail-page'],
|
||||
['/pages/profile/m02-edit-profile', '.profile-edit-page'], ['/pages/profile/m03-security-settings', '.security-page'],
|
||||
['/pages/profile/m04-change-password', '.password-page'], ['/pages/profile/m05-change-phone', '.phone-page'],
|
||||
['/pages/profile/m06-help-center', '.help-page'], ['/pages/profile/m07-feedback', '.feedback-page'],
|
||||
['/pages/profile/m08-promotion', '.promotion-page'], ['/pages/profile/m09-vip-orders', '.orders-page'],
|
||||
['/pages/profile/m10-about-settings', '.about-page']
|
||||
]
|
||||
const sizes = [{ width: 320, height: 568 }, { width: 360, height: 640 }, { width: 360, height: 800 }, { width: 412, height: 915 }]
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
|
||||
@@ -26,16 +41,28 @@ const run = async () => {
|
||||
const { socket, send } = await connect()
|
||||
try {
|
||||
await send('Page.enable'); await send('Runtime.enable')
|
||||
await open(send, '/pages/family/f04-article-list', '', '.module-page--list.module-state--ready')
|
||||
await open(send, '/pages/records/r04-gift-editor', '', '.module-page--form.module-state--ready')
|
||||
await open(send, '/pages/family/f03-feed-detail', '', '.module-page--detail.module-state--ready')
|
||||
await open(send, '/pages/records/r08-growth-journal', '', '.module-page--timeline.module-state--ready')
|
||||
await open(send, '/pages/profile/m03-security-settings', '', '.module-page--settings.module-state--ready')
|
||||
await open(send, '/pages/family/f10-video-list', '', '.module-page--status.module-state--ready')
|
||||
await open(send, '/pages/family/f04-article-list', '?state=empty', '.module-state--empty')
|
||||
await open(send, '/pages/records/r04-gift-editor', '?state=success', '.module-state--success')
|
||||
await open(send, '/pages/profile/m03-security-settings', '?state=error', '.module-state--error')
|
||||
await open(send, '/pages/family/f04-article-list', '', '.article-list-page')
|
||||
await open(send, '/pages/records/r04-gift-editor', '', '.gift-editor-page')
|
||||
await open(send, '/pages/family/f03-feed-detail', '', '.feed-detail-page')
|
||||
await open(send, '/pages/records/r08-growth-journal', '', '.timeline-page')
|
||||
await open(send, '/pages/profile/m03-security-settings', '', '.security-page')
|
||||
await open(send, '/pages/profile/m09-vip-orders', '', '.orders-page')
|
||||
await open(send, '/pages/family/f04-article-list', '?state=empty', '.article-list-state--empty')
|
||||
await open(send, '/pages/records/r04-gift-editor', '?mode=edit&giftId=missing', '.gift-editor-state--error')
|
||||
await open(send, '/pages/notification/n02-message-detail', '?state=expired', '.notice-state--expired')
|
||||
for (const size of sizes) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true, screenWidth: size.width, screenHeight: size.height })
|
||||
for (const [route, selector] of consumers) {
|
||||
await open(send, route, '', selector)
|
||||
const documentWidth = await valueOf(send, 'document.documentElement.scrollWidth')
|
||||
if (documentWidth > size.width + 1) throw new Error(`${route} overflows at ${size.width}x${size.height}`)
|
||||
if (await valueOf(send, "Boolean(document.querySelector('.module-page'))")) throw new Error(`${route} still renders the retired generic ModulePage`)
|
||||
}
|
||||
}
|
||||
process.stdout.write('MODULE-PAGE-RUNTIME-SMOKE PASS\n')
|
||||
} finally { socket.close() }
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1) })
|
||||
|
||||
@@ -6,17 +6,22 @@ if (-not (Test-Path -LiteralPath $componentPath)) { throw 'Missing shared module
|
||||
$component = Get-Content -LiteralPath $componentPath -Raw -Encoding utf8
|
||||
|
||||
foreach ($anchor in @(
|
||||
"const moduleState = ref(['loading', 'empty', 'success', 'error'].includes(query.state) ? query.state : 'ready')",
|
||||
'const moduleState = ref(',
|
||||
'["loading", "empty", "success", "error"].includes(query.state)',
|
||||
'module-state--${moduleState}',
|
||||
'empty: { eyebrow:',
|
||||
'success: { eyebrow:',
|
||||
'error: { eyebrow:',
|
||||
'empty: {',
|
||||
'success: {',
|
||||
'error: {',
|
||||
'module-content-frame.png',
|
||||
'module-field-frame.png',
|
||||
'<AppLoading',
|
||||
'<AppButton',
|
||||
'<AppToast',
|
||||
'<ModulePageBackground',
|
||||
':style="moduleAssetStyle"',
|
||||
'const moduleAssetStyle = computed(() => ({',
|
||||
'"--module-content-asset": `url(${contentAsset.value})`',
|
||||
'"--module-field-asset": `url(${fieldAsset.value})`',
|
||||
'query.state'
|
||||
)) {
|
||||
if ($component -notmatch [regex]::Escape($anchor)) { throw "ModulePage is missing high-spec anchor: $anchor" }
|
||||
@@ -32,7 +37,7 @@ foreach ($forbidden in @(
|
||||
}
|
||||
|
||||
foreach ($className in @('module-panel', 'form-row', 'list-card', 'detail-card', 'timeline-row', 'settings-row', 'status-card')) {
|
||||
foreach ($property in @('border', 'background', 'border-radius', 'box-shadow', 'background-image')) {
|
||||
foreach ($property in @('border', 'border-radius', 'box-shadow')) {
|
||||
if ($component -match "(?s)\.$className\s*\{[^}]*\b$property\s*:") {
|
||||
throw "ModulePage must not construct .$className with CSS $property"
|
||||
}
|
||||
@@ -40,5 +45,17 @@ foreach ($className in @('module-panel', 'form-row', 'list-card', 'detail-card',
|
||||
}
|
||||
|
||||
if ($component -match '::before|::after') { throw 'ModulePage must not draw visible decorations with CSS pseudo-elements' }
|
||||
if ($component -match '<image\b') { throw 'ModulePage must use approved container backgrounds instead of decorative image layers' }
|
||||
if ($component -match '(?im)(?<![-\w])position\s*:') { throw 'ModulePage ordinary content must not use position declarations' }
|
||||
|
||||
foreach ($rule in @(
|
||||
'(?s)\.module-page\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;',
|
||||
'(?s)\.module-page__header,\s*\.module-page__content,\s*\.module-page__loading\s*\{[^}]*z-index:\s*1;',
|
||||
'(?s)\.module-lead\s*\{[^}]*background:\s*url\("/static/assets/modules/genealogy/transparent/section-divider\.png"\) center / 100% 100% no-repeat;',
|
||||
'(?s)\.form-row,\s*\.settings-row\s*\{[^}]*background:\s*var\(--module-field-asset\) center / 100% 100% no-repeat;',
|
||||
'(?s)\.list-card,\s*\.detail-card,\s*\.timeline-row,\s*\.status-card\s*\{[^}]*background:\s*var\(--module-content-asset\) center / 100% 100% no-repeat;'
|
||||
)) {
|
||||
if ($component -notmatch $rule) { throw "ModulePage document-flow background rule missing: $rule" }
|
||||
}
|
||||
|
||||
Write-Output 'MODULE-PAGE-VISUAL-CONTRACT PASS'
|
||||
|
||||
@@ -10,13 +10,13 @@ const sizes = [
|
||||
const routes = [
|
||||
['/pages/family/f01-family-feed', '.family-page'],
|
||||
['/pages/family/f02-publish-feed', '.publish-page'],
|
||||
['/pages/family/f04-article-list', '.module-page--list'],
|
||||
['/pages/family/f04-article-list', '.article-list-page'],
|
||||
['/pages/family/f06-article-editor', '.article-editor-page'],
|
||||
['/pages/family/f08-album-detail', '.album-detail-page'],
|
||||
['/pages/family/f09-media-upload?state=selected', '.media-upload-page'],
|
||||
['/pages/family/f03-feed-detail', '.module-page--detail'],
|
||||
['/pages/records/r08-growth-journal', '.module-page--timeline'],
|
||||
['/pages/profile/m03-security-settings', '.module-page--settings'],
|
||||
['/pages/family/f03-feed-detail', '.feed-detail-page'],
|
||||
['/pages/records/r08-growth-journal', '.timeline-page'],
|
||||
['/pages/profile/m03-security-settings', '.security-page'],
|
||||
['/pages/family/f10-video-list', '.video-status-page'],
|
||||
['/pages/records/r02-person-detail?personId=1', '.person-detail-page'],
|
||||
['/pages/notification/n01-message-center', '.notice-page'],
|
||||
|
||||
@@ -3,7 +3,7 @@ $root = Split-Path -Parent $PSScriptRoot
|
||||
$n01 = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n01-message-center.vue') -Raw -Encoding utf8
|
||||
$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
|
||||
|
||||
foreach ($required in @('font-size:\s*21rpx','font-size:\s*23rpx','class="notice-review-action" block','<AppButton\s+block\s+:type="noticeState === ''error''')) {
|
||||
foreach ($required in @('font-size:\s*21rpx','font-size:\s*23rpx','(?s)class="notice-review-action"[^>]*\bblock\b','<AppButton\s+block\s+:type="noticeState === ''error''')) {
|
||||
if ($n01 -notmatch $required) { throw "N01 visual contract missing: $required" }
|
||||
}
|
||||
foreach ($required in @('prepareNSeriesState','n01-read-first','n01-mark-all')) {
|
||||
|
||||
@@ -29,4 +29,17 @@ if ($source.Contains('application-status-card.png')) {
|
||||
throw 'N01 must not reuse the genealogy application card'
|
||||
}
|
||||
|
||||
if ($source -match '<image\s+(?:[^>]*\s)?class="notice-(?:card|state-card)__skin"') {
|
||||
throw 'N01 decorative frames must be container backgrounds instead of positioned image layers'
|
||||
}
|
||||
if ($source -match 'position\s*:') {
|
||||
throw 'N01 must keep ordinary content in document flow'
|
||||
}
|
||||
foreach ($background in @(
|
||||
'background: url("/static/assets/modules/notification/transparent/n01-notice-card.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/modules/notification/transparent/n01-notice-card.png") top center / 100% 220rpx no-repeat'
|
||||
)) {
|
||||
if (-not $source.Contains($background)) { throw "N01 missing document-flow background: $background" }
|
||||
}
|
||||
|
||||
Write-Output 'N01-MODULE-BASELINE-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$list = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n01-message-center.vue') -Raw -Encoding UTF8
|
||||
$detail = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n02-message-detail.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('unreadCount', '/pages/notification/n02-message-detail?id=', 'openNotice', ':action="unreadCount > 0')) {
|
||||
if (-not $list.Contains($token)) { throw "N01-N02 flow missing: $token" }
|
||||
}
|
||||
foreach ($token in @('noticeDetail', 'openNoticeTarget', 'markAsRead')) {
|
||||
if (-not $detail.Contains($token)) { throw "N02 business detail missing: $token" }
|
||||
}
|
||||
Write-Output 'N01-N02-MESSAGE-FLOW-CONTRACT PASS'
|
||||
@@ -0,0 +1,110 @@
|
||||
const assert = require('assert')
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const origin = process.argv[2] || 'http://localhost:5173'
|
||||
const routes = [
|
||||
['/pages/notification/n02-message-detail', '.notice-state--ready'],
|
||||
['/pages/profile/m02-edit-profile', '.profile-state--ready'],
|
||||
['/pages/profile/m03-security-settings', '.device-state--safe'],
|
||||
['/pages/profile/m04-change-password', '.password-state--ready'],
|
||||
['/pages/profile/m05-change-phone', '.phone-state--ready'],
|
||||
['/pages/profile/m06-help-center', '.help-page'],
|
||||
['/pages/profile/m07-feedback', '.feedback-state--ready'],
|
||||
['/pages/profile/m08-promotion', '.share-state--ready'],
|
||||
['/pages/profile/m09-vip-orders', '.order-state--empty'],
|
||||
['/pages/profile/m10-about-settings', '.about-page'],
|
||||
]
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
|
||||
if (!page) throw new Error(`Chrome debugging has no ${origin} page`)
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }) })
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
socket.addEventListener('message', ({ data }) => { const message = JSON.parse(data); const request = pending.get(message.id); if (!request) return; pending.delete(message.id); message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result) })
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => { id += 1; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })) })
|
||||
return { socket, send }
|
||||
}
|
||||
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
|
||||
const waitFor = async (send, expression, message) => { for (let i = 0; i < 60; i += 1) { if (await valueOf(send, expression)) return; await sleep(100) } throw new Error(message) }
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send } = await connect()
|
||||
let audit = 0
|
||||
const open = async (route, selector) => {
|
||||
audit += 1
|
||||
const url = `${origin}/?nmAudit=${audit}#${route}`
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `navigation failed: ${route}`)
|
||||
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `selector missing: ${route} ${selector}`)
|
||||
}
|
||||
try {
|
||||
await send('Page.enable'); await send('Runtime.enable')
|
||||
for (const size of [{ width: 320, height: 568 }, { width: 412, height: 915 }]) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true, screenWidth: size.width, screenHeight: size.height })
|
||||
for (const [route, selector] of routes) {
|
||||
await open(route, selector)
|
||||
const metrics = await valueOf(send, `({ width: document.documentElement.scrollWidth, modulePage: Boolean(document.querySelector('.module-page')) })`)
|
||||
assert(metrics.width <= size.width + 1, `${route} overflows at ${size.width}x${size.height}`)
|
||||
assert.strictEqual(metrics.modulePage, false, `${route} still renders ModulePage`)
|
||||
const stress = await valueOf(send, `(() => {
|
||||
document.querySelectorAll('text').forEach((node) => {
|
||||
if (node.children.length || !node.textContent.trim()) return
|
||||
node.textContent = node.textContent.repeat(3)
|
||||
const size = Number.parseFloat(getComputedStyle(node).fontSize)
|
||||
if (size) node.style.fontSize = (size * 1.3) + 'px'
|
||||
})
|
||||
return { width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }
|
||||
})()`)
|
||||
assert(stress.width <= size.width + 1, `${route} long text overflows at ${size.width}x${size.height}`)
|
||||
assert(stress.height >= size.height, `${route} long content did not remain reachable at ${size.width}x${size.height}`)
|
||||
}
|
||||
}
|
||||
|
||||
await open('/pages/notification/n02-message-detail', '.notice-state--ready')
|
||||
await valueOf(send, `document.querySelector('.app-button').click()`)
|
||||
await waitFor(send, `document.body.innerText.includes('已读')`, 'N02 did not update read state')
|
||||
|
||||
await open('/pages/profile/m02-edit-profile', '.profile-state--ready')
|
||||
await valueOf(send, `(() => { const input=document.querySelector('input'); input.value=''; input.dispatchEvent(new Event('input',{bubbles:true})); document.querySelector('.app-button').click(); return true })()`)
|
||||
await waitFor(send, `Boolean(document.querySelector('.field-error'))`, 'M02 did not validate nickname')
|
||||
|
||||
await open('/pages/profile/m03-security-settings', '.device-state--safe')
|
||||
await valueOf(send, `document.querySelector('.security-row').click()`)
|
||||
await waitFor(send, `location.hash.includes('/pages/profile/m04-change-password')`, 'M03 password navigation failed')
|
||||
|
||||
await open('/pages/profile/m04-change-password', '.password-state--ready')
|
||||
await valueOf(send, `document.querySelector('.app-button').click()`)
|
||||
assert.strictEqual(await valueOf(send, `document.querySelectorAll('.field-error').length`), 3, 'M04 must report all empty fields')
|
||||
|
||||
await open('/pages/profile/m05-change-phone', '.phone-state--ready')
|
||||
await valueOf(send, `document.querySelector('.code-action').click()`)
|
||||
await waitFor(send, `Boolean(document.querySelector('.field-error'))`, 'M05 did not validate phone before code')
|
||||
|
||||
await open('/pages/profile/m06-help-center', '.help-page')
|
||||
await valueOf(send, `(() => { const input=document.querySelector('input'); input.value='不存在的问题'; input.dispatchEvent(new Event('input',{bubbles:true})); return true })()`)
|
||||
await waitFor(send, `Boolean(document.querySelector('.empty-card'))`, 'M06 search empty state missing')
|
||||
|
||||
await open('/pages/profile/m07-feedback', '.feedback-state--ready')
|
||||
await valueOf(send, `document.querySelector('.app-button').click()`)
|
||||
assert.strictEqual(await valueOf(send, `document.querySelectorAll('.field-error').length`), 2, 'M07 must report type and description errors')
|
||||
|
||||
await open('/pages/profile/m08-promotion', '.share-state--ready')
|
||||
await valueOf(send, `document.querySelector('.app-button').click()`)
|
||||
await waitFor(send, `Boolean(document.querySelector('.app-dialog'))`, 'M08 poster dialog missing')
|
||||
|
||||
await open('/pages/profile/m09-vip-orders', '.order-state--empty')
|
||||
await valueOf(send, `document.querySelector('.app-button').click()`)
|
||||
await waitFor(send, `Boolean(document.querySelector('.app-dialog'))`, 'M09 service dialog missing')
|
||||
|
||||
await open('/pages/profile/m10-about-settings', '.about-page')
|
||||
await valueOf(send, `document.querySelector('.settings-row').click()`)
|
||||
await waitFor(send, `Boolean(document.querySelector('.app-dialog'))`, 'M10 agreement dialog missing')
|
||||
process.stdout.write('NM-BUSINESS-RUNTIME-SMOKE PASS\n')
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1) })
|
||||
@@ -0,0 +1,29 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$contracts = [ordered]@{
|
||||
'pages/notification/n02-message-detail.vue' = @('noticeDetail', 'markAsRead', 'openNoticeTarget', 'notice-state--expired', 'notice-state--ready')
|
||||
'pages/profile/m02-edit-profile.vue' = @('profileForm', 'chooseAvatar', 'validateProfile', 'saveProfile', 'profile-state--saving')
|
||||
'pages/profile/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--safe', 'checkSecurity', '/pages/profile/m04-change-password', '/pages/profile/m05-change-phone')
|
||||
'pages/profile/m04-change-password.vue' = @('passwordForm', 'validatePassword', 'togglePassword', 'savePassword', 'password-state--saving')
|
||||
'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'codeCountdown', 'savePhone', 'phone-state--saving')
|
||||
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport', '/pages/profile/m07-feedback')
|
||||
'pages/profile/m07-feedback.vue' = @('feedbackForm', 'feedbackTypes', 'validateFeedback', 'submitFeedback', 'feedback-state--submitting')
|
||||
'pages/profile/m08-promotion.vue' = @('inviteCode', 'copyInviteCode', 'generatePoster', 'share-state--ready', 'posterVisible')
|
||||
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orders', 'order-state--empty', 'openServiceNotice', 'order-state--ready')
|
||||
'pages/profile/m10-about-settings.vue' = @('agreementItems', 'openAgreement', 'confirmLogout', 'appVersion', 'logoutVisible')
|
||||
}
|
||||
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
$source = Get-Content -LiteralPath (Join-Path $root $entry.Key) -Raw -Encoding UTF8
|
||||
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') { throw "$($entry.Key) must not consume ModulePage" }
|
||||
foreach ($forbidden in @('@/utils/api.js', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) contains forbidden token: $forbidden" }
|
||||
}
|
||||
foreach ($required in @('ModulePageBackground', 'PageHeader', 'AppButton') + $entry.Value) {
|
||||
if (-not $source.Contains($required)) { throw "$($entry.Key) missing business token: $required" }
|
||||
}
|
||||
if ($source -notmatch 'min-height:\s*100vh') { throw "$($entry.Key) must use natural page height" }
|
||||
}
|
||||
|
||||
Write-Output 'NM-PAGE-BUSINESS-CONTRACT PASS'
|
||||
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
function Read-Page([string]$path) { Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding UTF8 }
|
||||
$contracts = [ordered]@{
|
||||
'pages/records/r03-gift-list.vue' = @('giftBooks','openGiftBook','createGift','gift-state--empty','gift-state--loading','gift-state--error','/pages/records/r04-gift-editor?mode=create','/pages/records/r04-gift-editor?mode=view&giftId=')
|
||||
'pages/records/r04-gift-editor.vue' = @('giftForm','validateGift','saveGift','confirmDelete','giftId','gift-editor-state--saving','gift-editor-state--error','saveResult','/pages/records/r03-gift-list')
|
||||
'pages/records/r05-ritual-list.vue' = @('rituals','openRitual','createRitual','ritual-state--empty','ritual-state--loading','ritual-state--error','/pages/records/r06-ritual-detail?ritualId=','/pages/records/r07-ritual-editor?mode=create')
|
||||
'pages/records/r06-ritual-detail.vue' = @('ritualDetail','participants','editRitual','ritual-state--expired','ritual-state--privacy','ritual-state--error','/pages/records/r07-ritual-editor?mode=edit&ritualId=')
|
||||
'pages/records/r07-ritual-editor.vue' = @('ritualForm','validateRitual','saveRitual','confirmDelete','ritualId','ritual-editor-state--saving','ritual-editor-state--error','saveResult','/pages/records/r05-ritual-list')
|
||||
'pages/records/r08-growth-journal.vue' = @('growthRecords','recordGrowth','personName','timeline-state--empty','timeline-state--loading','timeline-state--error','growthForm','saveGrowth')
|
||||
'pages/records/r09-life-events.vue' = @('lifeEvents','createLifeEvent','personName','timeline-state--empty','timeline-state--loading','timeline-state--error','lifeEventForm','saveLifeEvent')
|
||||
'pages/records/r10-memo-list.vue' = @('memos','toggleMemo','createMemo','memo-state--empty','memo-state--loading','memo-state--error','memoForm','saveMemo')
|
||||
'pages/records/r11-merit-records.vue' = @('meritRecords','createMerit','totalContribution','merit-state--empty','merit-state--loading','merit-state--error','meritForm','saveMerit')
|
||||
}
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
$source = Read-Page $entry.Key
|
||||
foreach ($token in $entry.Value) { if (-not $source.Contains($token)) { throw "$($entry.Key) missing R business anchor: $token" } }
|
||||
foreach ($shared in @('ModulePageBackground','PageHeader','AppButton','AppLoading')) { if (-not $source.Contains($shared)) { throw "$($entry.Key) missing shared primitive: $shared" } }
|
||||
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') { throw "$($entry.Key) must own its business page" }
|
||||
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$($entry.Key) must use project feedback primitives" }
|
||||
if ($source -match '(?im)(?<![-\w])position\s*:|overflow\s*:\s*hidden') { throw "$($entry.Key) contains forbidden document-flow declaration" }
|
||||
}
|
||||
Write-Output 'R-BUSINESS-FLOW-CONTRACT PASS'
|
||||
@@ -0,0 +1,9 @@
|
||||
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));const origin=process.argv[2]||"http://localhost:5173";
|
||||
const connect=async()=>{const pages=await(await fetch("http://127.0.0.1:9222/json/list")).json();const page=pages.find(x=>x.type==="page"&&x.url.startsWith(`${origin}/`));if(!page)throw new Error("No project Chrome page");const socket=new WebSocket(page.webSocketDebuggerUrl);await new Promise((r,j)=>{socket.addEventListener("open",r,{once:true});socket.addEventListener("error",j,{once:true})});let id=0;const pending=new Map();socket.addEventListener("message",e=>{const m=JSON.parse(e.data),p=pending.get(m.id);if(!p)return;pending.delete(m.id);m.error?p.reject(new Error(m.error.message)):p.resolve(m.result)});const send=(method,params={})=>new Promise((resolve,reject)=>{id+=1;pending.set(id,{resolve,reject});socket.send(JSON.stringify({id,method,params}))});return{socket,send};};
|
||||
const val=async(s,e)=>(await s("Runtime.evaluate",{expression:e,returnByValue:true})).result?.value;const wait=async(s,e,m)=>{for(let i=0;i<60;i++){if(await val(s,e))return;await sleep(100)}throw new Error(m)};let n=0;const open=async(s,route,selector)=>{n+=1;const url=`${origin}/?rBusiness=${n}#${route}`;await s("Page.navigate",{url});await wait(s,`location.href===${JSON.stringify(url)}`,`navigation failed ${route}`);await wait(s,`Boolean(document.querySelector(${JSON.stringify(selector)}))`,`missing ${selector} ${route}`);};const click=(s,q)=>val(s,`document.querySelector(${JSON.stringify(q)}).click()`);const inputAt=(s,q,index,v)=>val(s,`(()=>{const e=document.querySelectorAll(${JSON.stringify(q)})[${index}];e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return e.value})()`);
|
||||
const routes=[["/pages/records/r03-gift-list?count=50",".record-card"],["/pages/records/r04-gift-editor?mode=create",".form-card"],["/pages/records/r05-ritual-list?count=50",".record-card"],["/pages/records/r06-ritual-detail?ritualId=501",".detail-card"],["/pages/records/r07-ritual-editor?mode=create",".form-card"],["/pages/records/r08-growth-journal",".timeline-card"],["/pages/records/r09-life-events",".timeline-card"],["/pages/records/r10-memo-list",".memo-card"],["/pages/records/r11-merit-records",".merit-card"]];
|
||||
const run=async()=>{const{socket,send}=await connect();try{await send("Page.enable");await send("Runtime.enable");for(const size of[{width:320,height:568},{width:412,height:915}]){await send("Emulation.setDeviceMetricsOverride",{...size,deviceScaleFactor:1,mobile:true,screenWidth:size.width,screenHeight:size.height});for(const[route,selector]of routes){await open(send,route,selector);if(await val(send,`document.documentElement.scrollWidth>${size.width+1}`))throw new Error(`${route} overflows at ${size.width}`);}await open(send,"/pages/records/r08-growth-journal",".timeline-card");await val(send,"(()=>{const p=document.querySelector('.page-content'),c=document.querySelector('.timeline-card');for(let i=0;i<48;i++)p.insertBefore(c.cloneNode(true),p.lastElementChild);p.querySelectorAll('.timeline-card')[49].scrollIntoView();return p.querySelectorAll('.timeline-card').length})()");if((await val(send,"document.querySelectorAll('.timeline-card').length"))!==50)throw new Error("R08 capacity stress failed");}
|
||||
await open(send,"/pages/records/r03-gift-list",".record-card");await click(send,".record-card");await wait(send,"location.hash.includes('/pages/records/r04-gift-editor?mode=view&giftId=301')","R03 did not open R04");
|
||||
await open(send,"/pages/records/r05-ritual-list",".record-card");await click(send,".record-card");await wait(send,"location.hash.includes('/pages/records/r06-ritual-detail?ritualId=501')","R05 did not open R06");
|
||||
await open(send,"/pages/records/r08-growth-journal",".header-action");await click(send,".header-action");await wait(send,"Boolean(document.querySelector('.app-dialog-layer'))","R08 dialog missing");await inputAt(send,".dialog-form input",0,"第一次远行");await inputAt(send,".dialog-form input",1,"2026 年 7 月");await click(send,".app-dialog__actions .app-button:last-child");await wait(send,"document.querySelector('.timeline-card').innerText.includes('第一次远行')","R08 save failed");
|
||||
await open(send,"/pages/records/r11-merit-records",".header-action");await click(send,".header-action");await inputAt(send,".dialog-form input",0,"整理旧谱");await inputAt(send,".dialog-form input",1,"汤文清");await click(send,".app-dialog__actions .app-button:last-child");await wait(send,"document.querySelector('.merit-summary').innerText.includes('3 次')","R11 total did not update");process.stdout.write("R-BUSINESS-FLOW-RUNTIME-SMOKE PASS\n");}finally{try{await send("Emulation.clearDeviceMetricsOverride")}catch(_){}socket.close();}};run().catch(e=>{process.stderr.write(`${e.stack||e.message}\n`);process.exit(1)});
|
||||
@@ -4,18 +4,16 @@ $page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/records/r01-people
|
||||
|
||||
foreach ($expected in @(
|
||||
'grid-template-columns: minmax(0, 1fr) 126rpx',
|
||||
'pointer-events: none'
|
||||
'class="people-state-card__copy"',
|
||||
'background: url("/static/assets/modules/records/transparent/r01-search-input-frame.png") center / 100% 100% no-repeat',
|
||||
'background: url("/static/assets/modules/records/transparent/r01-person-name-card.png") center / 100% 100% no-repeat'
|
||||
)) {
|
||||
if (-not $page.Contains($expected)) { throw "R01 document-flow contract missing: $expected" }
|
||||
}
|
||||
|
||||
foreach ($selector in @('.people-search input','.people-search__action','.person-card__copy')) {
|
||||
$escaped = [regex]::Escape($selector)
|
||||
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
|
||||
throw "R01 normal content selector uses positioning: $selector"
|
||||
}
|
||||
if ($page -match '<image\s+(?:[^>]*\s)?class="person-card__skin"|people-search\s*>\s*<image|people-result-empty\s*>\s*<image|people-state-card\s*>\s*<image') {
|
||||
throw 'R01 decorative frames must be container backgrounds instead of positioned image layers'
|
||||
}
|
||||
|
||||
if ($page -notmatch '(?s)\.person-card__copy\s*\{[^}]*position\s*:\s*relative\s*;') { throw 'R01 person-card copy must stay in normal flow above its local skin' }
|
||||
if ($page -match 'position\s*:') { throw 'R01 must not use positioning declarations' }
|
||||
|
||||
Write-Output 'R01-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -10,6 +10,7 @@ if ($page -match "import\s+ModulePage\s+from|<ModulePage(?:\s|/|>)") { throw 'R0
|
||||
if ($page -match 'application-status-card\.png') { throw 'R01 must not reuse the application status card.' }
|
||||
if ($page -match 'people-intro|brand-seal\.png') { throw 'R01 must not repeat a numbered intro block below the page header.' }
|
||||
Assert-Match 'modules/records/transparent/r01-person-name-card\.png' 'R01 must use its dedicated transparent person name card.'
|
||||
Assert-Match 'modules/records/transparent/r01-search-input-frame\.png' 'R01 must use its dedicated search frame.'
|
||||
Assert-Match 'class="people-search"' 'R01 must expose its search region.'
|
||||
Assert-Match 'people-state--empty' 'R01 must expose an empty review state.'
|
||||
Assert-Match 'people-state--error' 'R01 must expose an error review state.'
|
||||
@@ -17,5 +18,6 @@ Assert-Match 'AppButton' 'R01 must use the project button component.'
|
||||
Assert-Match 'AppToast' 'R01 must use the project Toast component.'
|
||||
Assert-Match '/pages/records/r02-person-detail' 'R01 person cards must navigate to R02.'
|
||||
Assert-Match '(?s)\.people-search__action\s*\{[^}]*min-height:\s*44px;' 'R01 search action must preserve a 44 CSS px touch height.'
|
||||
if ($page -match 'position\s*:') { throw 'R01 must keep ordinary content in document flow.' }
|
||||
|
||||
Write-Output 'R01-MODULE-BASELINE-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$list = Get-Content -LiteralPath (Join-Path $root 'pages/records/r01-people-list.vue') -Raw -Encoding UTF8
|
||||
$detail = Get-Content -LiteralPath (Join-Path $root 'pages/records/r02-person-detail.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('/pages/records/r02-person-detail?mode=create', 'showCreateNotice')) {
|
||||
if (-not $list.Contains($token)) { throw "R01 create flow missing: $token" }
|
||||
}
|
||||
foreach ($token in @('isCreateMode', 'query.mode === "create"', '/pages/records/r08-growth-journal?personId=', '/pages/records/r09-life-events?personId=', 'auto-height')) {
|
||||
if (-not $detail.Contains($token)) { throw "R02 person flow missing: $token" }
|
||||
}
|
||||
Write-Output 'R01-R02-PERSON-FLOW-CONTRACT PASS'
|
||||
@@ -38,27 +38,17 @@ const run = async () => {
|
||||
}
|
||||
await sleep(500)
|
||||
const layers = await valueOf(`Array.from(document.querySelectorAll('.person-identity-card,.person-field,.person-long-field')).map((node) => {
|
||||
const skin = node.querySelector('.person-card-skin')
|
||||
const image = skin?.querySelector('img')
|
||||
const cardRect = node.getBoundingClientRect()
|
||||
const skinRect = skin?.getBoundingClientRect()
|
||||
return {
|
||||
className: node.className,
|
||||
hasSkin: Boolean(skin),
|
||||
complete: image?.complete,
|
||||
naturalWidth: image?.naturalWidth,
|
||||
position: skin ? getComputedStyle(skin).position : '',
|
||||
widthDelta: skinRect ? Math.abs(cardRect.width - skinRect.width) : 999,
|
||||
heightDelta: skinRect ? Math.abs(cardRect.height - skinRect.height) : 999
|
||||
backgroundImage: getComputedStyle(node).backgroundImage,
|
||||
position: getComputedStyle(node).position
|
||||
}
|
||||
})`)
|
||||
assert(layers.length >= 6, 'R02 edit cards were not rendered')
|
||||
for (const layer of layers) {
|
||||
assert.strictEqual(layer.hasSkin, true, `${layer.className} decorative skin is missing`)
|
||||
assert.strictEqual(layer.complete, true, `${layer.className} decorative skin did not load`)
|
||||
assert(layer.naturalWidth > 0, `${layer.className} decorative skin has no source pixels`)
|
||||
assert.strictEqual(layer.position, 'absolute', `${layer.className} decorative skin is not a local absolute layer`)
|
||||
assert(layer.widthDelta <= 1 && layer.heightDelta <= 1, `${layer.className} decorative skin does not cover its card`)
|
||||
const expectedAsset = layer.className.includes('person-identity-card') ? 'r01-person-name-card.png' : layer.className.includes('person-field') ? 'module-field-frame.png' : 'module-content-frame.png'
|
||||
assert(layer.backgroundImage.includes(expectedAsset), `${layer.className} does not use its real background asset`)
|
||||
assert.strictEqual(layer.position, 'static', `${layer.className} must remain in document flow`)
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(layers)}\nR02-BACKGROUND-RUNTIME-SMOKE PASS\n`)
|
||||
} finally {
|
||||
|
||||
@@ -8,13 +8,23 @@ foreach ($expected in @(
|
||||
'class="person-edit-action"','class="person-save-action"','class="person-cancel-action"',
|
||||
'shortFields','longFields','person-field__label','person-long-field__label','const enterEdit = () =>','const savePerson = () =>',
|
||||
'const returnToPeople = () =>','r01-person-name-card.png','module-content-frame.png','module-field-frame.png',
|
||||
'class="person-card-skin"','pointer-events: none','ModulePageBackground','PageHeader','AppButton','AppLoading','AppToast'
|
||||
'ModulePageBackground','PageHeader','AppButton','AppLoading','AppToast'
|
||||
)) { Assert-Contains $page $expected }
|
||||
foreach ($forbidden in @('<ModulePage page-id="r02"','import ModulePage from')) { if ($page.Contains($forbidden)) { throw "R02 retains forbidden dependency: $forbidden" } }
|
||||
if ($catalog -match '(?m)^\s*r02\s*:') { throw 'R02 obsolete page-catalog owner must be removed' }
|
||||
if ($page -notmatch '(?s)\.person-card-skin\s*\{[^}]*position\s*:\s*absolute\s*;') { throw 'R02 decorative skin must use a local absolute layer' }
|
||||
foreach ($selector in @('.person-identity-card__copy','.person-field__label','.person-field input','.person-field-error','.person-long-field__label','.person-long-field textarea','.person-state-action')) {
|
||||
$escaped = [regex]::Escape($selector)
|
||||
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "R02 normal content selector uses positioning: $selector" }
|
||||
foreach ($forbidden in @('<image class="person-card-skin"','.person-card-skin')) {
|
||||
if ($page.Contains($forbidden)) { throw "R02 must use container backgrounds instead of positioned decorative skins: $forbidden" }
|
||||
}
|
||||
if ($page -match '(?im)(?<![-\w])position\s*:') {
|
||||
throw 'R02 must not contain position declarations'
|
||||
}
|
||||
foreach ($rule in @(
|
||||
'(?s)\.person-detail-page\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;',
|
||||
'(?s)\.person-detail-header,\.person-detail-loading,\.person-detail-content\s*\{[^}]*z-index:\s*1;',
|
||||
'(?s)\.person-identity-card\s*\{[^}]*background:\s*url\("/static/assets/modules/records/transparent/r01-person-name-card\.png"\) center / 100% 100% no-repeat;',
|
||||
'(?s)\.person-archive-card,\.person-long-field,\.person-state-card\s*\{[^}]*background:\s*url\("/static/assets/modules/records/transparent/module-content-frame\.png"\) center / 100% 100% no-repeat;',
|
||||
'(?s)\.person-field\s*\{[^}]*background:\s*url\("/static/assets/modules/records/transparent/module-field-frame\.png"\) center / 100% 100% no-repeat;'
|
||||
)) {
|
||||
if ($page -notmatch $rule) { throw "R02 document-flow background rule missing: $rule" }
|
||||
}
|
||||
Write-Output 'R02-PERSON-DETAIL-CONTRACT PASS'
|
||||
|
||||
@@ -37,13 +37,18 @@ const run = async () => {
|
||||
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--edit'))", 'R02 did not enter edit')
|
||||
await send('Runtime.evaluate', { expression: `(() => { const input=document.querySelector('.person-field input'); input.value=''; input.dispatchEvent(new Event('input',{bubbles:true})); document.querySelector('.person-save-action')?.click() })()` })
|
||||
await waitFor(send, "Boolean(document.querySelector('.person-field-error'))", 'R02 validation did not render')
|
||||
for (const [index, value] of ['汤文正', '家谱管理员', '18'].entries()) {
|
||||
await send('Runtime.evaluate', { expression: `(() => { const input=document.querySelectorAll('.person-field input')[${index}]; const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; setter.call(input,${JSON.stringify(value)}); input.dispatchEvent(new Event('input',{bubbles:true})) })()` })
|
||||
await waitFor(send, `document.querySelectorAll('.person-field input')[${index}]?.value === ${JSON.stringify(value)}`, `R02 edit field ${index} did not settle`)
|
||||
await sleep(150)
|
||||
}
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.person-cancel-action')?.click()" })
|
||||
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--detail'))", 'R02 did not cancel the invalid edit')
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.person-edit-action')?.click()" })
|
||||
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--edit'))", 'R02 did not re-enter edit after validation')
|
||||
await send('Runtime.evaluate', { expression: "document.querySelector('.person-save-action')?.click()" })
|
||||
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--detail'))", 'R02 did not return to detail after save')
|
||||
await sleep(200)
|
||||
const saveState = await valueOf(send, `({
|
||||
detail: Boolean(document.querySelector('.person-detail-state--detail')),
|
||||
values: Array.from(document.querySelectorAll('.person-field input')).map((input) => input.value),
|
||||
errors: Array.from(document.querySelectorAll('.person-field-error')).map((error) => error.textContent.trim())
|
||||
})`)
|
||||
if (!saveState.detail) throw new Error(`R02 did not return to detail after save: ${JSON.stringify(saveState)}`)
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-toast'))", 'R02 save feedback missing')
|
||||
await navigate('/pages/records/r02-person-detail?personId=1&state=privacy', '.person-detail-state--privacy')
|
||||
await navigate('/pages/records/r02-person-detail?personId=1&state=expired', '.person-detail-state--expired')
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Read-Utf8([string]$Path) {
|
||||
[System.IO.File]::ReadAllText((Join-Path $root $Path), [System.Text.Encoding]::UTF8)
|
||||
}
|
||||
|
||||
function Assert-Match([string]$Content, [string]$Pattern, [string]$Message) {
|
||||
if ($Content -notmatch $Pattern) { throw $Message }
|
||||
}
|
||||
|
||||
$button = Read-Utf8 'components/AppButton.vue'
|
||||
$header = Read-Utf8 'components/PageHeader.vue'
|
||||
$card = Read-Utf8 'components/GenealogyCard.vue'
|
||||
$dialog = Read-Utf8 'components/AppDialog.vue'
|
||||
$loading = Read-Utf8 'components/AppLoading.vue'
|
||||
$toast = Read-Utf8 'components/AppToast.vue'
|
||||
|
||||
if ($button -match '(?im)(?<![-\w])position\s*:') {
|
||||
throw 'AppButton must use grid overlay instead of positioning'
|
||||
}
|
||||
Assert-Match $button '(?s)\.app-button\s*\{[^}]*display:\s*inline-grid;[^}]*place-items:\s*center;' 'AppButton root must own the shared grid overlay'
|
||||
Assert-Match $button '(?s)\.app-button--block\s*\{[^}]*display:\s*grid;' 'AppButton block variant must preserve the grid overlay'
|
||||
Assert-Match $button '(?s)\.app-button__skin,\s*\.app-button__label\s*\{[^}]*grid-area:\s*1 / 1;' 'AppButton skin and label must share one grid cell'
|
||||
|
||||
Assert-Match $header '(?s)\.page-header--root \.header-side,\s*\.page-header--root \.header-title\s*\{[^}]*z-index:\s*2;' 'PageHeader root content must remain above decorations as flex items'
|
||||
if ($header -match '(?s)\.page-header--root \.header-side,\s*\.page-header--root \.header-title\s*\{[^}]*position\s*:') {
|
||||
throw 'PageHeader root content must not use positioning'
|
||||
}
|
||||
Assert-Match $header '(?s)\.header-icon-button\s*\{[^}]*display:\s*grid;[^}]*place-items:\s*center;' 'PageHeader icon button must use a grid overlay'
|
||||
if ($header -match '(?s)\.header-icon-button\s*\{[^}]*position\s*:') {
|
||||
throw 'PageHeader icon button must not be a positioning context'
|
||||
}
|
||||
Assert-Match $header '(?s)\.header-logo,\s*\.header-notice-icon,\s*\.notice-dot\s*\{[^}]*grid-area:\s*1 / 1;' 'PageHeader icon and notice dot must share one grid cell'
|
||||
if ($header -match '(?s)\.notice-dot\s*\{[^}]*position\s*:') {
|
||||
throw 'PageHeader notice dot must use grid alignment instead of positioning'
|
||||
}
|
||||
|
||||
if ($card -match '(?im)(?<![-\w])position\s*:') {
|
||||
throw 'GenealogyCard must use grid instead of positioning'
|
||||
}
|
||||
Assert-Match $card '(?s)\.genealogy-card\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*72rpx minmax\(0, 1fr\);[^}]*column-gap:\s*22rpx;' 'GenealogyCard must own the two-column document-flow grid'
|
||||
Assert-Match $card '(?s)\.row-frame\s*\{[^}]*grid-area:\s*1 / 1 / 2 / -1;[^}]*z-index:\s*1;[^}]*width:\s*calc\(100% \+ var\(--card-padding-x\) \+ var\(--card-padding-x\)\);[^}]*height:\s*calc\(100% \+ var\(--card-padding-y\) \+ var\(--card-padding-y\)\);[^}]*margin:\s*calc\(-1 \* var\(--card-padding-y\)\) calc\(-1 \* var\(--card-padding-x\)\);' 'GenealogyCard row frame must span the padded card grid above the fixed page background'
|
||||
Assert-Match $card '(?s)\.surname-seal\s*\{[^}]*display:\s*grid;[^}]*grid-row:\s*1;[^}]*z-index:\s*2;[^}]*place-items:\s*center;' 'GenealogyCard surname seal must share the frame row and stay above it'
|
||||
Assert-Match $card '(?s)\.card-main\s*\{[^}]*grid-row:\s*1;[^}]*z-index:\s*2;' 'GenealogyCard content must share the frame row and stay above it'
|
||||
Assert-Match $card '(?s)\.surname-seal-frame,\s*\.surname-seal-copy\s*\{[^}]*grid-area:\s*1 / 1;' 'GenealogyCard seal frame and copy must share one grid cell'
|
||||
if ($card -match '(?s)\.card-name\s*\{[^}]*(?:overflow:\s*hidden|text-overflow:\s*ellipsis|white-space:\s*nowrap)') {
|
||||
throw 'GenealogyCard name must wrap instead of clipping data-driven content'
|
||||
}
|
||||
foreach ($selector in @('card-meta', 'card-updated', 'card-role')) {
|
||||
if ($card -match "(?s)\.$selector\s*\{[^}]*white-space:\s*nowrap") {
|
||||
throw "GenealogyCard .$selector must wrap instead of fixing text capacity"
|
||||
}
|
||||
}
|
||||
|
||||
if ($dialog -match '(?s)\.app-dialog\s*\{[^}]*position\s*:|(?s)\.app-dialog__content\s*\{[^}]*position\s*:') {
|
||||
throw 'AppDialog internal content must not use positioning'
|
||||
}
|
||||
Assert-Match $dialog '(?s)\.app-dialog\s*\{[^}]*background:\s*url\("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3\.png"\)' 'AppDialog must render its approved dialog asset as the dialog background'
|
||||
if ($loading -match '(?im)(?<![-\w])position\s*:') { throw 'AppLoading must stay in its parent document flow' }
|
||||
if ($toast -match '(?s)\.app-toast__copy\s*\{[^}]*position\s*:') { throw 'AppToast copy must not create an unnecessary positioning context' }
|
||||
|
||||
Write-Output 'SHARED-COMPONENT-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -0,0 +1,36 @@
|
||||
$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)<button[^>]+class="header-icon-button"[^>]+aria-label='
|
||||
)) {
|
||||
if ($header -notmatch $pattern) { throw "PageHeader accessibility contract missing: $pattern" }
|
||||
}
|
||||
if (-not $header.Contains('fallbackUrl')) { throw 'PageHeader must expose a deep-link fallback route' }
|
||||
|
||||
$dialog = Read-Utf8 'components/AppDialog.vue'
|
||||
foreach ($token in @('role="dialog"', 'aria-modal="true"', 'tabindex="-1"', '@keydown.esc.stop="cancel"', 'max-height: calc(100vh - 80rpx)', '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" }
|
||||
}
|
||||
|
||||
Write-Output 'SHARED-INTERACTION-ACCESSIBILITY-CONTRACT PASS'
|
||||
@@ -12,7 +12,7 @@ foreach ($required in @(
|
||||
't01-member-node-selected.png',
|
||||
't01-state-panel.png',
|
||||
't01-member-drawer.png',
|
||||
'lineage-connector--root',
|
||||
'v-for="connector in lineageConnectors"',
|
||||
'lineage-pan-cue',
|
||||
'const initialScrollLeft = ref(90)'
|
||||
)) {
|
||||
@@ -34,10 +34,16 @@ foreach ($rule in @(
|
||||
'(?s)\.node-years\s*\{[^}]*font-size:\s*20rpx;',
|
||||
'(?s)\.tree-state-card__copy\s*\{[^}]*font-size:\s*24rpx;',
|
||||
'(?s)\.sheet-meta\s*\{[^}]*font-size:\s*22rpx;',
|
||||
'(?s)\.generation-rail\s*\{[^}]*width:\s*190rpx;',
|
||||
'(?s)\.tree-scroll--lineage\s*\{[^}]*width:\s*calc\(100% - 190rpx\);[^}]*margin-left:\s*190rpx;',
|
||||
'(?s)\.tree-stage--lineage\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*190rpx minmax\(0, 1fr\);',
|
||||
'(?s)\.generation-rail\s*\{[^}]*display:\s*grid;[^}]*width:\s*190rpx;',
|
||||
'(?s)\.tree-scroll--lineage\s*\{[^}]*min-width:\s*0;',
|
||||
'const treeMetricsStyle = computed',
|
||||
'(?s)\.generation-band\s*\{[^}]*display:\s*grid;',
|
||||
'(?s)\.generation-band image,\s*\.generation-band__copy\s*\{[^}]*grid-area:\s*1 / 1;',
|
||||
'(?s)\.member-node\s*\{[^}]*display:\s*grid;',
|
||||
'(?s)\.member-node__skin,\s*\.member-node__copy\s*\{[^}]*grid-area:\s*1 / 1;',
|
||||
'(?s)\.member-sheet\s*\{[^}]*height:\s*240rpx;',
|
||||
'(?s)\.sheet-actions\s*\{[^}]*right:\s*0;[^}]*bottom:\s*24rpx;[^}]*left:\s*0;[^}]*justify-content:\s*center;',
|
||||
'(?s)\.sheet-actions\s*\{[^}]*justify-content:\s*center;[^}]*margin-top:\s*auto;',
|
||||
'(?s)\.sheet-action\s*\{[^}]*width:\s*250rpx;[^}]*min-height:\s*88rpx;'
|
||||
)) {
|
||||
if ($page -notmatch $rule) { throw "T01 readable visual rule missing: $rule" }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('const layoutMembers = computed', 'childrenByParent', 'layoutMembers.value', 'GENERATION_GAP', 'MEMBER_GAP')) {
|
||||
if (-not $page.Contains($token)) { throw "T01 relation layout contract missing: $token" }
|
||||
}
|
||||
if ($page -match 'maxX\s*=.*members\.value.*\.x|maxY\s*=.*members\.value.*\.y') { throw 'T01 canvas must not depend on API pixel coordinates' }
|
||||
Write-Output 'T01-RELATION-LAYOUT-CONTRACT PASS'
|
||||
@@ -14,6 +14,14 @@ function Assert-NoCssSurface {
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-NoPosition {
|
||||
param([string]$Content, [string]$Selector)
|
||||
$escaped = [regex]::Escape($Selector)
|
||||
if ($Content -match "(?s)$escaped\s*\{[^}]*\bposition\s*:") {
|
||||
throw "T01 ordinary layout selector must stay in document flow: $Selector"
|
||||
}
|
||||
}
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$pagePath = Join-Path $root 'pages/tree/t01-tree-overview.vue'
|
||||
$t02Path = Join-Path $root 'pages/tree/t02-tree-states.vue'
|
||||
@@ -34,13 +42,16 @@ foreach ($required in @(
|
||||
'tree-state--error',
|
||||
'tree-canvas--state',
|
||||
'lineage-connector',
|
||||
'const generationRows = [',
|
||||
'const generationRows = computed(',
|
||||
'v-for="row in generationRows"',
|
||||
':style="generationRowStyle(row)"',
|
||||
':style="generationBandStyle(row)"',
|
||||
'class="tree-stage"',
|
||||
'tree-stage--lineage',
|
||||
'class="generation-rail"',
|
||||
'tree-scroll--lineage',
|
||||
'const generationRowStyle = (row)',
|
||||
':style="nodeGridStyle(member)"',
|
||||
'const nodeGridStyle = (member)',
|
||||
'class="member-node__copy"',
|
||||
't01-member-node-standard.png',
|
||||
't01-member-node-selected.png',
|
||||
't01-state-panel.png',
|
||||
@@ -67,4 +78,41 @@ foreach ($className in @('tree-canvas', 'member-node', 'member-sheet', 'tree-sta
|
||||
Assert-NoCssSurface $page $className
|
||||
}
|
||||
|
||||
foreach ($selector in @('.generation-rail', '.generation-band', '.member-node')) {
|
||||
Assert-NoPosition $page $selector
|
||||
}
|
||||
|
||||
if ($page -match '(?s)\.node-name,\s*\.node-relation,\s*\.node-years\s*\{[^}]*\bposition\s*:') {
|
||||
throw 'T01 member node text must stay in document flow'
|
||||
}
|
||||
if ($page -notmatch '(?s)\.tree-page\s*\{[^}]*display:\s*grid;[^}]*grid-template-rows:\s*auto auto minmax\(0, 1fr\);') {
|
||||
throw 'T01 page content must own a grid stacking context above the fixed module background'
|
||||
}
|
||||
|
||||
foreach ($obsolete in @('const nodeStyle = (member)', 'const generationRowStyle = (row)', 'left: `${member.x}rpx`', 'top: `${member.y}rpx`')) {
|
||||
if ($page -match [regex]::Escape($obsolete)) { throw "T01 obsolete positioned layout remains: $obsolete" }
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const treeMetrics = computed(',
|
||||
':style="treeMetricsStyle"',
|
||||
':style="generationBandStyle(row)"',
|
||||
'overflow-y: auto;'
|
||||
)) { Assert-Contains $page $required "T01 data-driven tree boundary missing: $required" }
|
||||
if ($page -match '<scroll-view\s+class="tree-stage"') {
|
||||
throw 'T01 outer vertical tree owner must preserve the stage grid instead of wrapping its grid children'
|
||||
}
|
||||
|
||||
foreach ($fixedCapacity in @(
|
||||
'grid-template-columns: repeat(180, 5rpx)',
|
||||
'grid-template-rows: repeat(180, 5rpx)',
|
||||
'width: 900rpx',
|
||||
'height: 900rpx',
|
||||
'grid-template-rows: 47rpx 58rpx 157rpx 58rpx 242rpx 58rpx 1fr'
|
||||
)) {
|
||||
if ($page -match [regex]::Escape($fixedCapacity)) {
|
||||
throw "T01 must derive tree capacity from member data: $fixedCapacity"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output 'T01-TREE-STATE-CONTRACT PASS'
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
const assert = require('assert')
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
const origin = process.argv[2] || 'http://localhost:5173'
|
||||
const sizes = [
|
||||
{ width: 320, height: 568 },
|
||||
{ width: 360, height: 640 },
|
||||
{ width: 360, height: 800 },
|
||||
{ width: 412, height: 915 }
|
||||
]
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
@@ -27,7 +34,13 @@ const connect = async () => {
|
||||
return { socket, send }
|
||||
}
|
||||
|
||||
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
|
||||
const valueOf = async (send, expression) => {
|
||||
const evaluation = await send('Runtime.evaluate', { expression, returnByValue: true })
|
||||
if (evaluation.exceptionDetails) {
|
||||
throw new Error(evaluation.exceptionDetails.exception?.description || evaluation.exceptionDetails.text)
|
||||
}
|
||||
return evaluation.result?.value
|
||||
}
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (await valueOf(send, expression)) return
|
||||
@@ -39,7 +52,9 @@ const open = async (send, query, selector) => {
|
||||
const url = `${origin}/#/pages/tree/t01-tree-overview${query}`
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `T01 navigation failed: ${query}`)
|
||||
const previousTimeOrigin = await valueOf(send, 'performance.timeOrigin')
|
||||
await send('Page.reload')
|
||||
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `T01 reload did not create a fresh document: ${query}`)
|
||||
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T01 state did not render: ${selector}`)
|
||||
}
|
||||
|
||||
@@ -51,12 +66,89 @@ const run = async () => {
|
||||
await open(send, '?state=landscape&genealogyId=1001', '.tree-state--landscape')
|
||||
await open(send, '?state=empty&genealogyId=1001', '.tree-state--empty')
|
||||
await open(send, '?state=error&genealogyId=1001', '.tree-state--error')
|
||||
for (const size of sizes) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true, screenWidth: size.width, screenHeight: size.height })
|
||||
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node')
|
||||
const metrics = await valueOf(send, `(() => ({
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
stageDisplay: getComputedStyle(document.querySelector('.tree-stage')).display,
|
||||
railPosition: getComputedStyle(document.querySelector('.generation-rail')).position,
|
||||
nodePosition: getComputedStyle(document.querySelector('.member-node')).position,
|
||||
nodeCount: document.querySelectorAll('.member-node').length,
|
||||
firstNodeRect: (() => {
|
||||
const rect = document.querySelector('.member-node').getBoundingClientRect()
|
||||
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
||||
})(),
|
||||
canvasRect: (() => {
|
||||
const rect = document.querySelector('.tree-canvas').getBoundingClientRect()
|
||||
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
||||
})()
|
||||
}))()`)
|
||||
assert(metrics.documentWidth <= size.width + 1, `T01 document overflows at ${size.width}x${size.height}`)
|
||||
assert.strictEqual(metrics.stageDisplay, 'grid', `T01 stage is not a grid at ${size.width}x${size.height}`)
|
||||
assert.strictEqual(metrics.railPosition, 'static', `T01 generation rail is positioned at ${size.width}x${size.height}`)
|
||||
assert.strictEqual(metrics.nodePosition, 'static', `T01 member node is positioned at ${size.width}x${size.height}`)
|
||||
assert.strictEqual(metrics.nodeCount, 6, `T01 member nodes are incomplete at ${size.width}x${size.height}`)
|
||||
assert(metrics.firstNodeRect.width > 80 && metrics.firstNodeRect.height > 30, `T01 member node collapsed at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
|
||||
assert(metrics.firstNodeRect.top >= metrics.canvasRect.top && metrics.firstNodeRect.top < metrics.canvasRect.top + metrics.canvasRect.height, `T01 member node escaped the data-driven canvas at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
|
||||
assert(metrics.firstNodeRect.left + metrics.firstNodeRect.width > 0 && metrics.firstNodeRect.left < size.width, `T01 initial member node is outside the visible horizontal tree viewport at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
|
||||
assert(metrics.firstNodeRect.top < size.height * 0.5, `T01 generation rail and canvas were stacked instead of sharing the tree-stage row at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
|
||||
}
|
||||
const stressMembers = []
|
||||
for (let generation = 1; generation <= 10; generation += 1) {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
stressMembers.push({
|
||||
id: generation * 100 + index,
|
||||
parentId: generation === 1 ? undefined : (generation - 1) * 100 + Math.floor(index / 2),
|
||||
name: `压力成员${generation}-${index + 1}`,
|
||||
relation: generation === 1 ? '始祖' : '后代',
|
||||
years: '1900—',
|
||||
generation,
|
||||
branch: `第${index + 1}支长名称`,
|
||||
x: 150 + index * 250,
|
||||
y: 135 + (generation - 1) * 220
|
||||
})
|
||||
}
|
||||
}
|
||||
const injected = await valueOf(send, `(() => {
|
||||
let instance = document.querySelector('.tree-page')?.__vueParentComponent
|
||||
while (instance && !Object.prototype.hasOwnProperty.call(instance.setupState || {}, 'members')) instance = instance.parent
|
||||
const state = instance?.setupState
|
||||
if (!state) return false
|
||||
const nextMembers = ${JSON.stringify(stressMembers)}
|
||||
if (Array.isArray(state.members)) state.members = nextMembers
|
||||
else if (state.members && 'value' in state.members) state.members.value = nextMembers
|
||||
else return false
|
||||
return true
|
||||
})()`)
|
||||
assert.strictEqual(injected, true, 'T01 stress data could not be injected into the active component')
|
||||
await waitFor(send, "document.querySelectorAll('.member-node').length === 120", 'T01 did not render 10 generations with 12 members each')
|
||||
const stressMetrics = await valueOf(send, `(() => {
|
||||
const canvas = document.querySelector('.tree-canvas')
|
||||
const rail = document.querySelector('.generation-rail')
|
||||
return {
|
||||
canvasWidth: parseFloat(getComputedStyle(canvas).width),
|
||||
canvasHeight: parseFloat(getComputedStyle(canvas).height),
|
||||
railHeight: parseFloat(getComputedStyle(rail).height),
|
||||
generationCount: document.querySelectorAll('.generation-band').length,
|
||||
connectorCount: document.querySelectorAll('.lineage-connector').length,
|
||||
documentWidth: document.documentElement.scrollWidth
|
||||
}
|
||||
})()`)
|
||||
assert(stressMetrics.canvasWidth > 1400, 'T01 canvas width did not grow from 12 members per generation')
|
||||
assert(stressMetrics.canvasHeight > 1000, 'T01 canvas height did not grow from 10 generations')
|
||||
assert(Math.abs(stressMetrics.canvasHeight - stressMetrics.railHeight) <= 1, 'T01 generation rail did not follow the data-driven canvas height')
|
||||
assert.strictEqual(stressMetrics.generationCount, 10, 'T01 generation rail did not derive all generations from data')
|
||||
assert(stressMetrics.connectorCount > 30, 'T01 relationship connectors did not derive from parent data')
|
||||
assert(stressMetrics.documentWidth <= 413, 'T01 stress tree escaped its scroll owner')
|
||||
|
||||
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node')
|
||||
await valueOf(send, "document.querySelector('.member-node')?.click()")
|
||||
await valueOf(send, "document.querySelector('.sheet-member-action')?.click()")
|
||||
await valueOf(send, "document.querySelector('.member-sheet .app-button')?.click()")
|
||||
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'T01 selected member did not open T03')
|
||||
process.stdout.write('T01-TREE-STATE-RUNTIME-SMOKE PASS\n')
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
if ([regex]::IsMatch($style, '(?m)\bposition\s*:')) { throw 'T03 member profile must use document flow throughout' }
|
||||
foreach ($required in @(
|
||||
'background: url("/static/assets/modules/tree/transparent/t01-state-panel.png")',
|
||||
'background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png")',
|
||||
'background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "T03 is missing real asset background: $required" } }
|
||||
Write-Output 'T03-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -0,0 +1,110 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-Page([string]$RelativePath) {
|
||||
return [System.IO.File]::ReadAllText(
|
||||
(Join-Path (Split-Path -Parent $PSScriptRoot) $RelativePath),
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) {
|
||||
if (-not $Content.Contains($Expected)) { throw $Message }
|
||||
}
|
||||
|
||||
$t03 = Read-Page 'pages/tree/t03-member-profile.vue'
|
||||
$t04 = Read-Page 'pages/tree/t04-add-relative.vue'
|
||||
$t05 = Read-Page 'pages/tree/t05-edit-member.vue'
|
||||
$t06 = Read-Page 'pages/tree/t06-edit-relationship.vue'
|
||||
$t08 = Read-Page 'pages/tree/t08-member-states.vue'
|
||||
|
||||
foreach ($entry in @(
|
||||
@{ Name = 'T04'; Content = $t04 },
|
||||
@{ Name = 'T05'; Content = $t05 },
|
||||
@{ Name = 'T06'; Content = $t06 }
|
||||
)) {
|
||||
if ($entry.Content -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
|
||||
throw "$($entry.Name) must own its business workflow instead of rendering TreeMemberForm"
|
||||
}
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($entry.Content.Contains($nativeUi)) { throw "$($entry.Name) must not use $nativeUi" }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const addState = ref("form")',
|
||||
'const addForm = reactive({',
|
||||
'const relationOptions = [',
|
||||
'const genderOptions = [',
|
||||
'const isFirstMember = computed(',
|
||||
'query.personId',
|
||||
'query.mode === "first"',
|
||||
'fieldErrors.name',
|
||||
'fieldErrors.relation',
|
||||
'add-state--form',
|
||||
'add-state--success',
|
||||
'add-state--error',
|
||||
'class="add-relative-form"',
|
||||
'<picker',
|
||||
'<AppDialog'
|
||||
)) { Assert-Contains $t04 $required "T04 missing independent add-relative contract: $required" }
|
||||
|
||||
foreach ($required in @(
|
||||
'const editState = ref("form")',
|
||||
'const editForm = reactive({',
|
||||
'const memberFixtures = {',
|
||||
'const isDirty = computed(',
|
||||
'const discardDialogVisible = ref(false)',
|
||||
'query.personId',
|
||||
'edit-state--form',
|
||||
'edit-state--success',
|
||||
'edit-state--error',
|
||||
'edit-state--no-permission',
|
||||
'class="edit-member-form"',
|
||||
'<AppDialog',
|
||||
'onBackPress('
|
||||
)) { Assert-Contains $t05 $required "T05 missing independent member-edit contract: $required" }
|
||||
|
||||
foreach ($required in @(
|
||||
'const relationshipState = ref("form")',
|
||||
'const relationshipForm = reactive({',
|
||||
'const memberOptions = [',
|
||||
'const relationshipOptions = [',
|
||||
'const relationshipPreview = computed(',
|
||||
'const validateRelationship = () =>',
|
||||
'query.personId',
|
||||
'relationship-state--form',
|
||||
'relationship-state--success',
|
||||
'relationship-state--conflict',
|
||||
'relationship-state--error',
|
||||
'class="relationship-form"',
|
||||
'<picker',
|
||||
'<AppDialog'
|
||||
)) { Assert-Contains $t06 $required "T06 missing independent relationship-edit contract: $required" }
|
||||
|
||||
foreach ($required in @(
|
||||
'const genealogyId = ref("")',
|
||||
'const personId = ref("")',
|
||||
'const statusState = ref("loading")',
|
||||
'const memberFixtures = {',
|
||||
'query.personId',
|
||||
'query.state',
|
||||
'member-status--privacy',
|
||||
'member-status--deceased',
|
||||
'member-status--forbidden',
|
||||
'member-status--error',
|
||||
'class="member-status-context"'
|
||||
)) { Assert-Contains $t08 $required "T08 missing member-driven status contract: $required" }
|
||||
if ($t08 -match 'class="status-tabs"|const\s+tabs\s*=|@click="statusState\s*=') {
|
||||
throw 'T08 must not let the user switch among demonstration states'
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const memberFixtures = {',
|
||||
'query.personId',
|
||||
'const openMemberState = (state) =>',
|
||||
'/pages/tree/t08-member-states?genealogyId=',
|
||||
'&personId=',
|
||||
'&state='
|
||||
)) { Assert-Contains $t03 $required "T03 missing semantic member-state entry: $required" }
|
||||
|
||||
Write-Output 'T03-T08-BUSINESS-SPECIALIZATION-CONTRACT PASS'
|
||||
@@ -0,0 +1,136 @@
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const origin = process.argv.find((argument) => /^https?:\/\//.test(argument)) || 'http://localhost:5173'
|
||||
const captureEnabled = process.argv.includes('--capture')
|
||||
const captureDirectory = path.join(os.tmpdir(), 'jiapuapp-runtime', 't-business-split')
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const projectPages = pages.filter((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
|
||||
if (projectPages.length !== 1) throw new Error(`Expected one ${origin} project tab, found ${projectPages.length}`)
|
||||
const socket = new WebSocket(projectPages[0].webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true })
|
||||
socket.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
const request = pending.get(message.id)
|
||||
if (!request) return
|
||||
pending.delete(message.id)
|
||||
message.error ? request.reject(new Error(`${request.method}: ${message.error.message}`)) : request.resolve(message.result)
|
||||
})
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1
|
||||
pending.set(id, { resolve, reject, method })
|
||||
socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
return { socket, send }
|
||||
}
|
||||
|
||||
const valueOf = async (send, expression) =>
|
||||
(await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
try {
|
||||
if (await valueOf(send, expression)) return
|
||||
} catch (error) {
|
||||
if (!String(error.message).includes('Inspected target navigated or closed')) throw error
|
||||
}
|
||||
await sleep(100)
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const open = async (send, route, query, selector) => {
|
||||
const url = `${origin}/#${route}${query}`
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
|
||||
const previousTimeOrigin = await valueOf(send, 'performance.timeOrigin')
|
||||
await send('Page.reload')
|
||||
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `${route} reload did not create a fresh document`)
|
||||
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render ${selector}`)
|
||||
const overflow = await valueOf(send, 'document.documentElement.scrollWidth > innerWidth + 1')
|
||||
if (overflow) throw new Error(`${route}${query} overflows horizontally`)
|
||||
}
|
||||
|
||||
const includesText = async (send, text) =>
|
||||
valueOf(send, `document.body.innerText.includes(${JSON.stringify(text)})`)
|
||||
|
||||
const assertText = async (send, text, message) => {
|
||||
if (!(await includesText(send, text))) throw new Error(message)
|
||||
}
|
||||
|
||||
const capture = async (send, name) => {
|
||||
if (!captureEnabled) return
|
||||
fs.mkdirSync(captureDirectory, { recursive: true })
|
||||
const result = await send('Page.captureScreenshot', { format: 'png', fromSurface: false })
|
||||
fs.writeFileSync(path.join(captureDirectory, `${name}.png`), Buffer.from(result.data, 'base64'))
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send } = await connect()
|
||||
try {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 320, height: 568, deviceScaleFactor: 1, mobile: true })
|
||||
|
||||
await open(send, '/pages/tree/t03-member-profile', '?genealogyId=1001&personId=101', '.member-state--detail')
|
||||
await assertText(send, '汤文远', 'T03 did not load person 101')
|
||||
await assertText(send, '离世纪念', 'T03 did not expose the semantic deceased-state entry')
|
||||
await valueOf(send, 'document.querySelector(".member-status-entry").click()')
|
||||
await waitFor(send, 'location.hash.includes("/pages/tree/t08-member-states?genealogyId=1001&personId=101&state=deceased")', 'T03 status entry did not open the routed member state')
|
||||
|
||||
await open(send, '/pages/tree/t03-member-profile', '?genealogyId=1001&personId=102', '.member-state--detail')
|
||||
await assertText(send, '汤正国', 'T03 did not derive content from personId 102')
|
||||
await assertText(send, '隐私资料', 'T03 did not expose person 102 privacy entry')
|
||||
await capture(send, 't03-person-102-320x568')
|
||||
|
||||
await open(send, '/pages/tree/t04-add-relative', '?genealogyId=1001&personId=101', '.add-state--form')
|
||||
await assertText(send, '为汤文远添加一位亲属', 'T04 did not derive the current member task')
|
||||
await valueOf(send, 'document.querySelector(".form-action").click()')
|
||||
await waitFor(send, 'document.querySelectorAll(".field-error").length === 3', 'T04 did not keep required-field errors near their fields')
|
||||
await capture(send, 't04-validation-320x568')
|
||||
await open(send, '/pages/tree/t04-add-relative', '?genealogyId=1001&mode=first', '.add-state--form')
|
||||
await assertText(send, '录入家谱中的第一位成员', 'T04 first-member mode did not render')
|
||||
|
||||
await open(send, '/pages/tree/t05-edit-member', '?genealogyId=1001&personId=102', '.edit-state--form')
|
||||
await assertText(send, '完善汤正国的生命记录', 'T05 did not load the selected member')
|
||||
await capture(send, 't05-person-102-320x568')
|
||||
await open(send, '/pages/tree/t05-edit-member', '?state=no-permission&genealogyId=1001&personId=103', '.edit-state--no-permission')
|
||||
await assertText(send, '当前账号不能编辑这位成员', 'T05 permission state did not render')
|
||||
|
||||
await open(send, '/pages/tree/t06-edit-relationship', '?genealogyId=1001&personId=102', '.relationship-state--form')
|
||||
await assertText(send, '汤正国', 'T06 did not preselect the routed member')
|
||||
await assertText(send, '关系影响预览', 'T06 relationship preview is missing')
|
||||
await valueOf(send, 'document.querySelector(".form-action").click()')
|
||||
await waitFor(send, 'document.querySelectorAll(".field-error").length === 2 && Boolean(document.querySelector(".relationship-state--form"))', 'T06 confused missing fields with a relationship conflict')
|
||||
await capture(send, 't06-validation-320x568')
|
||||
await open(send, '/pages/tree/t06-edit-relationship', '?state=conflict&genealogyId=1001&personId=102', '.relationship-state--conflict')
|
||||
await assertText(send, '这段关系会造成世系矛盾', 'T06 conflict result did not render')
|
||||
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=deceased&genealogyId=1001&personId=101', '.member-status--deceased')
|
||||
await assertText(send, '汤文远', 'T08 did not retain member identity')
|
||||
if (await valueOf(send, 'Boolean(document.querySelector(".status-tabs"))')) throw new Error('T08 still exposes demonstration state tabs')
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=privacy&genealogyId=1001&personId=102', '.member-status--privacy')
|
||||
await assertText(send, '汤正国', 'T08 privacy state is not person-driven')
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=forbidden&genealogyId=1001&personId=103', '.member-status--forbidden')
|
||||
await assertText(send, '汤正华', 'T08 forbidden state is not person-driven')
|
||||
await capture(send, 't08-person-103-forbidden-320x568')
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=privacy&genealogyId=1001', '.member-status--error')
|
||||
|
||||
process.stdout.write('T03-T08-BUSINESS-SPECIALIZATION-RUNTIME-SMOKE PASS\n')
|
||||
} finally {
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -31,18 +31,19 @@ foreach ($required in @('member-state--detail', 'member-state--error', 't01-stat
|
||||
Assert-Contains $t03 $required "Missing T03 contract: $required"
|
||||
}
|
||||
|
||||
$formComponentPath = Join-Path $root 'components/tree/TreeMemberForm.vue'
|
||||
if (-not (Test-Path -LiteralPath $formComponentPath)) { throw 'T04-T06 require shared TreeMemberForm component' }
|
||||
$form = Get-Content -LiteralPath $formComponentPath -Raw -Encoding utf8
|
||||
if ($form -match "@/utils/api\.js|\bappApi\b") { throw 'TreeMemberForm must not connect the API layer' }
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($form -match [regex]::Escape($nativeUi)) { throw "TreeMemberForm must use project-owned feedback instead of $nativeUi" }
|
||||
$specializedForms = @{
|
||||
't04-add-relative.vue' = @('add-state--form', 'add-state--success', 'add-relative-form', 'relationOptions')
|
||||
't05-edit-member.vue' = @('edit-state--form', 'edit-state--success', 'edit-member-form', 'memberFixtures')
|
||||
't06-edit-relationship.vue' = @('relationship-state--form', 'relationship-state--conflict', 'relationship-form', 'relationshipOptions')
|
||||
}
|
||||
foreach ($required in @('form-state--form', 'form-state--success', 'form-state--conflict', 'form-state--error', 't01-state-panel.png', 't07-search-input-frame.png', 'a01-scroll-primary-v3.png', 'a01-scroll-secondary-v3.png', 'AppDialog', 'ModulePageBackground')) {
|
||||
Assert-Contains $form $required "Missing shared form contract: $required"
|
||||
}
|
||||
foreach ($route in @('t04-add-relative.vue', 't05-edit-member.vue', 't06-edit-relationship.vue')) {
|
||||
Assert-Contains $pages["pages/tree/$route"] 'TreeMemberForm' "$route must use TreeMemberForm"
|
||||
foreach ($entry in $specializedForms.GetEnumerator()) {
|
||||
$content = $pages["pages/tree/$($entry.Key)"]
|
||||
if ($content -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
|
||||
throw "$($entry.Key) must own its business workflow instead of TreeMemberForm"
|
||||
}
|
||||
foreach ($required in $entry.Value) {
|
||||
Assert-Contains $content $required "Missing specialized form contract in $($entry.Key): $required"
|
||||
}
|
||||
}
|
||||
|
||||
$t07 = $pages['pages/tree/t07-member-directory.vue']
|
||||
|
||||
@@ -50,14 +50,14 @@ const run = async () => {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
await open(send, '/pages/tree/t03-member-profile', '?genealogyId=1001&personId=101', '.member-state--detail')
|
||||
await open(send, '/pages/tree/t04-add-relative', '?genealogyId=1001&personId=101', '.form-state--form')
|
||||
await open(send, '/pages/tree/t05-edit-member', '?state=success&genealogyId=1001&personId=101', '.form-state--success')
|
||||
await open(send, '/pages/tree/t06-edit-relationship', '?state=conflict&genealogyId=1001', '.form-state--conflict')
|
||||
await open(send, '/pages/tree/t04-add-relative', '?genealogyId=1001&personId=101', '.add-state--form')
|
||||
await open(send, '/pages/tree/t05-edit-member', '?state=success&genealogyId=1001&personId=101', '.edit-state--success')
|
||||
await open(send, '/pages/tree/t06-edit-relationship', '?state=conflict&genealogyId=1001&personId=101', '.relationship-state--conflict')
|
||||
await open(send, '/pages/tree/t07-member-directory', '?genealogyId=1001', '.directory-state--list')
|
||||
await open(send, '/pages/tree/t07-member-directory', '?state=empty&genealogyId=1001', '.directory-state--empty')
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=privacy&genealogyId=1001', '.member-status--privacy')
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=deceased&genealogyId=1001', '.member-status--deceased')
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=forbidden&genealogyId=1001', '.member-status--forbidden')
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=privacy&genealogyId=1001&personId=102', '.member-status--privacy')
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=deceased&genealogyId=1001&personId=101', '.member-status--deceased')
|
||||
await open(send, '/pages/tree/t08-member-states', '?state=forbidden&genealogyId=1001&personId=103', '.member-status--forbidden')
|
||||
process.stdout.write('T03-T08-MEMBER-FLOW-RUNTIME-SMOKE PASS\n')
|
||||
} finally { socket.close() }
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ Assert-Match '''directory-state--loading'': directoryState === ''loading''' 'T07
|
||||
Assert-Match 'v-if="directoryState === ''list'' \|\| directoryState === ''empty''"' 'T07 search must remain available for list and empty states only.'
|
||||
Assert-Match 'class="directory-search__action"[^>]*role="button"[^>]*aria-label="\u67e5\u627e\u6210\u5458"' 'T07 search action must expose button semantics and an accessible label.'
|
||||
Assert-Match '<AppButton\s+v-if="directoryState === ''error''"[^>]+type="secondary"[^>]+label="\u91cd\u65b0\u67e5\u770b"[^>]+@click="retryDirectory"' 'T07 error state must provide a custom recovery action.'
|
||||
Assert-Match 'const retryDirectory = \(\) => \{ directoryState\.value = ''list'' \}' 'T07 retry action must return to the list state.'
|
||||
Assert-Match '(?s)\.directory-page\s*\{[^}]*overflow-x:\s*hidden;[^}]*overflow-y:\s*auto;' 'T07 must allow natural vertical scrolling without horizontal overflow.'
|
||||
Assert-Match '(?s)\.directory-search\s*\{[^}]*height:\s*44px;' 'T07 search field must preserve a 44 CSS px touch height at every viewport.'
|
||||
Assert-Match '(?s)const retryDirectory = \(\) => \{\s*directoryState\.value = "list";\s*\}' 'T07 retry action must return to the list state.'
|
||||
Assert-Match '(?s)\.directory-page\s*\{[^}]*overflow-y:\s*auto;' 'T07 must allow natural vertical scrolling.'
|
||||
if ($page -match '(?s)\.directory-page\s*\{[^}]*overflow-x:\s*hidden;') { throw 'T07 must not hide horizontal layout failures at the page root.' }
|
||||
Assert-Match '(?s)\.directory-search\s*\{[^}]*min-height:\s*44px;' 'T07 search field must preserve a 44 CSS px minimum touch height at every viewport.'
|
||||
Assert-Match '(?s)\.directory-search__action\s*\{[^}]*min-width:\s*88rpx;[^}]*min-height:\s*44px;' 'T07 search action must preserve a 44 CSS px touch target.'
|
||||
Assert-Match 'modules/tree/transparent/t07-search-input-frame\.png' 'T07 search must use its dedicated transparent input frame.'
|
||||
Assert-Match 'modules/genealogy/transparent/list-slip-frame\.png' 'T07 member cards must use the approved transparent list frame.'
|
||||
|
||||
@@ -14,7 +14,7 @@ foreach ($rule in @(
|
||||
}
|
||||
|
||||
foreach ($rule in @(
|
||||
'(?s)\.status-tab text\s*\{[^}]*font-size:\s*23rpx;',
|
||||
'(?s)\.member-status-context text\s*\{[^}]*font-size:\s*23rpx;',
|
||||
'(?s)\.status-card__copy text:first-child\s*\{[^}]*font-size:\s*22rpx;',
|
||||
'(?s)\.status-card__copy text:nth-child\(2\)\s*\{[^}]*font-size:\s*32rpx;',
|
||||
'(?s)\.status-card__copy text:last-child\s*\{[^}]*font-size:\s*24rpx;',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
foreach ($relative in @('pages/tree/t07-member-directory.vue', 'pages/tree/t08-member-states.vue')) {
|
||||
$page = Get-Content -LiteralPath (Join-Path $root $relative) -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
if ([regex]::IsMatch($style, '(?m)\bposition\s*:')) { throw "$relative must use document flow throughout" }
|
||||
}
|
||||
$t07 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t07-member-directory.vue') -Raw -Encoding utf8
|
||||
if ($t07 -match '(?s)\.directory-card\s*\{[^}]*(?<![-\w])height\s*:\s*19[26]rpx') {
|
||||
throw 'T07 directory cards must grow with member data instead of using a fixed content height'
|
||||
}
|
||||
foreach ($selector in @('directory-card__name', 'directory-card__meta')) {
|
||||
if ($t07 -match "(?s)\.$selector\s*\{[^}]*(?:overflow:\s*hidden|text-overflow:\s*ellipsis|white-space:\s*nowrap)") {
|
||||
throw "T07 .$selector must wrap instead of clipping member data"
|
||||
}
|
||||
}
|
||||
Write-Output 'T07-T08-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
@@ -5,16 +5,14 @@ $component = [System.IO.File]::ReadAllText((Join-Path $root 'components/tree/Tre
|
||||
foreach ($expected in @(
|
||||
'grid-template-columns: auto minmax(0, 1fr)',
|
||||
'min-height: min(640px, calc((100vw - 16px) * 1.48))',
|
||||
'pointer-events: none'
|
||||
'background: url("/static/assets/modules/tree/transparent/t01-state-panel.png")',
|
||||
'background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png")'
|
||||
)) {
|
||||
if (-not $component.Contains($expected)) { throw "TreeMemberForm document-flow contract missing: $expected" }
|
||||
}
|
||||
|
||||
foreach ($selector in @('.member-form, .member-form-result','.member-field > text','.member-field input')) {
|
||||
$escaped = [regex]::Escape($selector)
|
||||
if ($component -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
|
||||
throw "TreeMemberForm normal content selector uses positioning: $selector"
|
||||
}
|
||||
if ($component -match '(?m)\bposition\s*:') {
|
||||
throw 'TreeMemberForm must not retain positioning in ordinary forms, fields, actions, or result content'
|
||||
}
|
||||
|
||||
Write-Output 'TREE-MEMBER-FORM-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
|
||||
Reference in New Issue
Block a user