完成40%

This commit is contained in:
rain
2026-07-14 17:36:39 +08:00
parent 1015e60ff7
commit 8c98936da5
151 changed files with 5132 additions and 351 deletions
+30 -3
View File
@@ -60,10 +60,12 @@ $smsLogin = ConvertFrom-Utf8Base64 '5omL5py66aqM6K+B56CB55m75b2V'
$forgotPassword = ConvertFrom-Utf8Base64 '5b+Y6K6w5a+G56CB'
$getCode = ConvertFrom-Utf8Base64 '6I635Y+W6aqM6K+B56CB'
$loginPending = ConvertFrom-Utf8Base64 '55m75b2V5o6l5Y+j5b6F5o6l5YWl'
$returnToEntry = ConvertFrom-Utf8Base64 '6K+35YWI6L+U5Zue55m75b2V5byV5a+856Gu6K6k5Y2P6K6u'
$forgotPasswordPending = ConvertFrom-Utf8Base64 '5b+Y6K6w5a+G56CB6aG16Z2i5YeG5aSH5Lit'
$getCodePending = ConvertFrom-Utf8Base64 '6I635Y+W6aqM6K+B56CB5Yqf6IO95b6F5o6l5YWl'
$implicitAgreement = ConvertFrom-Utf8Base64 '55m75b2V5Y2z6KGo56S65ZCM5oSP'
$agreementConfirm = ConvertFrom-Utf8Base64 '5oiR5bey6ZiF6K+75bm25ZCM5oSP'
$agreementGuard = ConvertFrom-Utf8Base64 '6K+35YWI6ZiF6K+75bm25ZCM5oSP55u45YWz5Y2P6K6u'
$legacyAgreementReminder = ConvertFrom-Utf8Base64 '6K+35YWI5Zyo55m75b2V5byV5a+86aG16Z2i5YeG5aSH5Lit6ZiF6K+75bm25ZCM5oSP5Y2P6K6u'
foreach ($requiredEntryCopy in @($wechatLogin, $registerPrompt, $registerEntry, $agreementRequired, $wechatPending, $registerPending, $agreementPending)) {
Assert-Contains -Content $entry -Expected $requiredEntryCopy -Message "Missing A-01 copy: $requiredEntryCopy"
@@ -135,7 +137,7 @@ if ($entry -match '(?s)\.auth-page\s*\{[^}]*overflow\s*:\s*hidden') {
throw 'A-01 must allow vertical scrolling on small screens'
}
foreach ($requiredLoginCopy in @($accountPasswordLogin, $smsLogin, $forgotPassword, $getCode, $loginPending, $returnToEntry, $getCodePending)) {
foreach ($requiredLoginCopy in @($accountPasswordLogin, $smsLogin, $forgotPassword, $getCode, $loginPending, $agreementConfirm, $agreementGuard, $getCodePending)) {
Assert-Contains -Content $login -Expected $requiredLoginCopy -Message "Missing A-02 copy: $requiredLoginCopy"
}
Assert-Contains -Content $login -Expected "url: '/pages/auth/a05-reset-password'" -Message 'A-02 forgot-password entry must navigate to A-05'
@@ -147,7 +149,11 @@ foreach ($requiredLoginVisualAnchor in @(
'login-panel__skin',
'a01-primary-button.png',
'login-submit__skin',
'login-submit__content'
'login-submit__content',
'agreement-row',
'agreement-icon',
'a02-agreement-unchecked.png',
'a02-agreement-checked.png'
)) {
Assert-Contains -Content $login -Expected $requiredLoginVisualAnchor -Message "Missing A-02 calibrated visual anchor: $requiredLoginVisualAnchor"
}
@@ -169,6 +175,27 @@ if ($login -match '(?s)\.login-submit\s*\{[^}]*\bborder\s*:') {
if ($login -match '(?s)\.login-submit\s*\{[^}]*\bbackground\s*:') {
throw 'A-02 login button must not draw its final surface with CSS'
}
if ($login -notmatch '<view class="agreement-row" @click="toggleAgreement">') {
throw 'A-02 must provide one directly clickable agreement row'
}
if (([regex]::Matches($login, '@click\.stop="prepareAgreement"')).Count -ne 2) {
throw 'A-02 agreement row must retain two non-toggling agreement links'
}
foreach ($requiredAgreementState in @('const agreed = ref(false)', 'const isAgreedRoute = () =>', "if (typeof location !== 'undefined') {", "new URLSearchParams(location.hash.split('?')[1] || '')", 'const pages = getCurrentPages()', 'const currentPage = pages[pages.length - 1]', "return currentPage?.options?.agreed === '1'", 'const syncAgreementFromRoute = () =>', 'agreed.value = isAgreedRoute()', 'onShow(() => {', 'syncAgreementFromRoute()', 'const toggleAgreement = () =>', 'if (agreed.value) return true')) {
Assert-Contains -Content $login -Expected $requiredAgreementState -Message "Missing A-02 agreement state: $requiredAgreementState"
}
Assert-NotContains -Content $login -Unexpected 'onLoad((options) =>' -Message 'A-02 must not leave agreement state initialized only on first load'
Assert-NotContains -Content $login -Unexpected $legacyAgreementReminder -Message 'A-02 must not retain its passive return-to-entry agreement reminder'
Assert-NotContains -Content $login -Unexpected 'agreedFromEntry' -Message 'A-02 must use its local agreed state as the submit gate'
if ($login -match '(?s)\.agreement-icon\s*\{[^}]*\bborder\s*:') {
throw 'A-02 agreement icon must not draw its final circle with CSS'
}
if ($login -match '(?s)\.agreement-icon\s*\{[^}]*\bbackground\s*:') {
throw 'A-02 agreement icon must not draw its final state with CSS'
}
if ($login -match '\.agreement-icon::(?:before|after)') {
throw 'A-02 agreement icon must not draw its final state with a pseudo element'
}
if ($login -match '(?s)\.auth-page\s*\{[^}]*overflow\s*:\s*hidden') {
throw 'A-02 must allow vertical scrolling on small screens'
+46
View File
@@ -24,4 +24,50 @@ if ($width -ne 940 -or $height -ne 1672) { throw 'A02 login-panel asset must rem
if ([Math]::Abs($ratio - 0.5622) -gt 0.001) { throw 'A02 login-panel asset must retain its approved panel ratio.' }
if (($corners | Where-Object { $_ -ne 255 }).Count -ne 0) { throw 'A02 login-panel asset must have opaque outer corners.' }
$agreementAssets = @(
'static/assets/modules/auth/transparent/a02-agreement-unchecked.png',
'static/assets/modules/auth/transparent/a02-agreement-checked.png'
)
foreach ($agreementAssetRelativePath in $agreementAssets) {
$agreementAsset = Join-Path $root $agreementAssetRelativePath
if (-not (Test-Path -LiteralPath $agreementAsset)) {
throw "Missing A02 agreement asset: $agreementAssetRelativePath"
}
$agreementBitmap = [System.Drawing.Bitmap]::FromFile($agreementAsset)
try {
if ($agreementBitmap.Width -ne 96 -or $agreementBitmap.Height -ne 96) {
throw "A02 agreement asset must be 96 x 96px: $agreementAssetRelativePath"
}
if (-not [System.Drawing.Image]::IsAlphaPixelFormat($agreementBitmap.PixelFormat)) {
throw "A02 agreement asset must retain an alpha channel: $agreementAssetRelativePath"
}
$agreementCorners = @(
$agreementBitmap.GetPixel(0, 0).A,
$agreementBitmap.GetPixel(95, 0).A,
$agreementBitmap.GetPixel(0, 95).A,
$agreementBitmap.GetPixel(95, 95).A
)
if (($agreementCorners | Where-Object { $_ -ne 0 }).Count -ne 0) {
throw "A02 agreement asset must have transparent outer corners: $agreementAssetRelativePath"
}
$hasOpaquePixel = $false
for ($y = 0; $y -lt 96 -and -not $hasOpaquePixel; $y += 1) {
for ($x = 0; $x -lt 96; $x += 1) {
if ($agreementBitmap.GetPixel($x, $y).A -eq 255) {
$hasOpaquePixel = $true
break
}
}
}
if (-not $hasOpaquePixel) {
throw "A02 agreement asset must contain an opaque icon: $agreementAssetRelativePath"
}
} finally {
$agreementBitmap.Dispose()
}
}
Write-Output 'A02-ASSET-ALPHA-AUDIT PASS'
+48
View File
@@ -0,0 +1,48 @@
$ErrorActionPreference = 'Stop'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
function Assert-Contains {
param(
[string]$Content,
[string]$Expected,
[string]$Message
)
if ($Content -notmatch [regex]::Escape($Expected)) {
throw $Message
}
}
$root = Split-Path -Parent $PSScriptRoot
$loginPath = Join-Path $root 'pages/auth/a02-login.vue'
$pagesPath = Join-Path $root 'pages.json'
$login = Get-Content -LiteralPath $loginPath -Raw -Encoding utf8
$pages = Get-Content -LiteralPath $pagesPath -Raw -Encoding utf8 | ConvertFrom-Json
$registerPrompt = ConvertFrom-Utf8Base64 '6L+Y5rKh5pyJ6LSm5Y+377yf'
$registerEntry = ConvertFrom-Utf8Base64 '5rOo5YaM6LSm5Y+3'
Assert-Contains -Content $login -Expected 'class="register-entry"' -Message 'A-02 must expose a dedicated registration entry below its agreement row'
Assert-Contains -Content $login -Expected $registerPrompt -Message 'A-02 must explain the registration path to new users'
Assert-Contains -Content $login -Expected 'class="register-link" hover-class="text-hover" @click="prepareRegister"' -Message 'A-02 registration copy must provide a focused tap target'
Assert-Contains -Content $login -Expected (">$registerEntry</view>") -Message 'A-02 registration action must be visible'
Assert-Contains -Content $login -Expected "const prepareRegister = () => uni.navigateTo({ url: '/pages/auth/a04-register' })" -Message 'A-02 registration entry must navigate to A-04'
$agreementIndex = $login.IndexOf('class="agreement-row"')
$registerIndex = $login.IndexOf('class="register-entry"')
if ($registerIndex -le $agreementIndex) {
throw 'A-02 registration entry must follow its agreement row in reading order'
}
if ($login -notmatch '(?s)\.register-link\s*\{[^}]*min-height:\s*88rpx') {
throw 'A-02 registration entry must retain a touch-friendly height'
}
if (-not ($pages.pages.path -contains 'pages/auth/a04-register')) {
throw 'A-02 registration entry target must remain declared in pages.json'
}
Write-Output 'A02-REGISTER-ENTRY-CONTRACT PASS'
+13
View File
@@ -0,0 +1,13 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
$a03File = Join-Path $root 'pages/auth/a03-mobile-verify.vue'
if ($pages.pages.path -contains 'pages/auth/a03-mobile-verify') { throw 'A-03 must not remain in pages.json' }
if (Test-Path -LiteralPath $a03File) { throw 'A-03 page file must be deleted' }
if ($catalog -match '(?m)^\s*a03\s*:') { throw 'A-03 catalog entry must be deleted' }
if ($pages.pages.Count -ne 55) { throw "Expected 55 final routes after the approved G02-to-G01, G04-to-G03 and G07-to-G06 state merges, found $($pages.pages.Count)." }
Write-Output 'A03-ROUTE-REMOVAL-CONTRACT PASS'
+70
View File
@@ -0,0 +1,70 @@
$ErrorActionPreference = 'Stop'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NotContains {
param([string]$Content, [string]$Unexpected, [string]$Message)
if ($Content -match [regex]::Escape($Unexpected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$registerPath = Join-Path $root 'pages/auth/a04-register.vue'
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
$register = Get-Content -LiteralPath $registerPath -Raw -Encoding utf8
$registerCopy = ConvertFrom-Utf8Base64 '5rOo5YaM6LSm5Y+3'
$loginCopy = ConvertFrom-Utf8Base64 '55m75b2V'
if (-not ($pages.pages.path -contains 'pages/auth/a04-register')) { throw 'A-04 route must remain declared in pages.json' }
Assert-NotContains -Content $register -Unexpected 'ModulePage' -Message 'A-04 must not remain a ModulePage shell'
foreach ($required in @(
'auth-header',
'register-panel__skin',
'a02-login-panel.png',
'a01-primary-button.png',
'a02-agreement-unchecked.png',
'a02-agreement-checked.png',
'v-model.trim="phone"',
'v-model="password"',
'v-model="confirmPassword"',
'const agreed = ref(false)',
'const toggleAgreement = () =>',
'const submitRegister = () =>',
"const prepareLogin = () => uni.navigateTo({ url: '/pages/auth/a02-login' })",
'if (!/^1\d{10}$/.test(phone.value))',
'if (!password.value)',
'if (password.value !== confirmPassword.value)'
)) {
Assert-Contains -Content $register -Expected $required -Message "Missing A-04 registration contract: $required"
}
Assert-Contains -Content $register -Expected $registerCopy -Message 'A-04 must visibly identify registration'
Assert-Contains -Content $register -Expected $loginCopy -Message 'A-04 must provide a login return path'
Assert-NotContains -Content $catalog -Unexpected 'a04:' -Message 'A-04 must not remain in the temporary page catalog'
Assert-NotContains -Content $register -Unexpected 'register-intro' -Message 'A-04 must not retain the redundant registration intro copy'
foreach ($required in @(
'auth-divider-knot.png',
'register-divider',
'width: 154rpx'
)) {
Assert-Contains -Content $register -Expected $required -Message "Missing A-04 visual balance contract: $required"
}
foreach ($forbidden in @(
'(?s)\.register-panel\s*\{[^}]*\bborder\s*:',
'(?s)\.register-panel\s*\{[^}]*\bbackground\s*:',
'(?s)\.register-submit\s*\{[^}]*\bborder\s*:',
'(?s)\.register-submit\s*\{[^}]*\bbackground\s*:',
'\.agreement-icon::(?:before|after)'
)) {
if ($register -match $forbidden) { throw "A-04 retains forbidden CSS visual construction: $forbidden" }
}
Write-Output 'A04-REGISTRATION-CONTRACT PASS'
+66
View File
@@ -0,0 +1,66 @@
$ErrorActionPreference = 'Stop'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NotContains {
param([string]$Content, [string]$Unexpected, [string]$Message)
if ($Content -match [regex]::Escape($Unexpected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$pagePath = Join-Path $root 'pages/auth/a05-reset-password.vue'
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
$page = Get-Content -LiteralPath $pagePath -Raw -Encoding utf8
$resetCopy = ConvertFrom-Utf8Base64 '6YeN6K6+5a+G56CB'
$confirmPasswordCopy = ConvertFrom-Utf8Base64 '56Gu6K6k5paw5a+G56CB'
$getCodeCopy = ConvertFrom-Utf8Base64 '6I635Y+W6aqM6K+B56CB'
$loginCopy = ConvertFrom-Utf8Base64 '6L+U5Zue55m75b2V'
if (-not ($pages.pages.path -contains 'pages/auth/a05-reset-password')) { throw 'A-05 route must remain declared in pages.json' }
Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-05 must not remain a ModulePage shell'
foreach ($required in @(
'auth-header',
'reset-panel__skin',
'a02-login-panel.png',
'a01-primary-button.png',
'auth-divider-knot.png',
'chevron-right.png',
'v-model.trim="phone"',
'v-model.trim="verificationCode"',
'v-model="password"',
'v-model="confirmPassword"',
'const prepareGetCode = () =>',
'const submitReset = () =>',
"const prepareLogin = () => uni.navigateTo({ url: '/pages/auth/a02-login' })",
'if (!/^1\d{10}$/.test(phone.value))',
'if (!/^\d{6}$/.test(verificationCode.value))',
'if (!password.value)',
'if (password.value !== confirmPassword.value)'
)) {
Assert-Contains -Content $page -Expected $required -Message "Missing A-05 reset-password contract: $required"
}
Assert-Contains -Content $page -Expected $resetCopy -Message 'A-05 must visibly identify the password-reset task'
Assert-Contains -Content $page -Expected $confirmPasswordCopy -Message 'A-05 must require password confirmation'
Assert-Contains -Content $page -Expected $getCodeCopy -Message 'A-05 must expose the verification-code action'
Assert-Contains -Content $page -Expected $loginCopy -Message 'A-05 must provide the A02 return path'
Assert-NotContains -Content $catalog -Unexpected 'a05:' -Message 'A-05 must not remain in the temporary page catalog'
foreach ($forbidden in @(
'(?s)\.reset-panel\s*\{[^}]*\bborder\s*:',
'(?s)\.reset-panel\s*\{[^}]*\bbackground\s*:',
'(?s)\.reset-submit\s*\{[^}]*\bborder\s*:',
'(?s)\.reset-submit\s*\{[^}]*\bbackground\s*:'
)) {
if ($page -match $forbidden) { throw "A-05 retains forbidden CSS visual construction: $forbidden" }
}
Write-Output 'A05-RESET-PASSWORD-CONTRACT PASS'
+60
View File
@@ -0,0 +1,60 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NotContains {
param([string]$Content, [string]$Unexpected, [string]$Message)
if ($Content -match [regex]::Escape($Unexpected)) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a06-auth-status.vue') -Raw -Encoding utf8
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
if (-not ($pages.pages.path -contains 'pages/auth/a06-auth-status')) { throw 'A-06 route must remain declared in pages.json' }
if (-not ($pages.pages.path -contains 'pages/auth/a02-login')) { throw 'A-06 must retain the A02 return target' }
if (-not ($pages.pages.path -contains 'pages/auth/a04-register')) { throw 'A-06 must retain the A04 registration target' }
Assert-NotContains -Content $page -Unexpected 'ModulePage' -Message 'A-06 must not remain a ModulePage shell'
Assert-NotContains -Content $catalog -Unexpected 'a06:' -Message 'A-06 must not remain in the temporary page catalog'
foreach ($required in @(
"const status = ref('normal')",
"const statusConfig = {",
'normal:',
'failed:',
'restricted:',
"'register-pending':",
"'wechat-cancelled':",
"'wechat-failed':",
'const resolveStatus = () =>',
'const goLogin = () =>',
'const goRegister = () =>',
'const handlePrimary = () =>',
'const goBack = () =>',
'auth-header.png',
'brand-seal.png',
'a02-login-panel.png',
'a01-primary-button.png',
'auth-title-cloud.png',
'auth-divider-knot.png',
'chevron-right.png',
'auth-login-outline.png'
)) {
Assert-Contains -Content $page -Expected $required -Message "Missing A-06 auth-status contract: $required"
}
foreach ($forbidden in @(
'(?s)\.status-panel\s*\{[^}]*\bborder\s*:',
'(?s)\.status-panel\s*\{[^}]*\bbackground\s*:',
'(?s)\.status-primary\s*\{[^}]*\bborder\s*:',
'(?s)\.status-primary\s*\{[^}]*\bbackground\s*:'
)) {
if ($page -match $forbidden) { throw "A-06 retains forbidden CSS visual construction: $forbidden" }
}
Write-Output 'A06-AUTH-STATUS-CONTRACT PASS'
+98
View File
@@ -0,0 +1,98 @@
const assert = require('assert')
const baseUrl = 'http://localhost:5173/#/pages/auth/a06-auth-status'
const text = (base64) => Buffer.from(base64, 'base64').toString('utf8')
const expected = {
normal: text('55m75b2V5pyq5a6M5oiQ'),
restricted: text('6LSm5Y+35pqC5pe25Y+X6ZmQ'),
help: text('6LSm5Y+355Sz6K+J5Yqf6IO95b6F5o6l5YWl')
}
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 page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 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)
if (message.error) request.reject(new Error(message.error.message))
else 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 })
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 30; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const navigate = async (send, url, title) => {
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Did not navigate to ${url}`)
await send('Page.reload')
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Reload did not retain ${url}`)
try {
await waitFor(send, `document.querySelector('.status-title')?.innerText === ${JSON.stringify(title)}`, `A06 did not render ${title}`)
} catch (error) {
const heading = await valueOf(send, "document.querySelector('.status-title')?.innerText")
throw new Error(`${error.message}; current heading: ${heading}`)
}
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate(send, baseUrl, expected.normal)
await valueOf(send, "document.querySelector('.status-primary')?.click()")
await waitFor(send, "location.href.includes('/pages/auth/a02-login')", 'Default A06 action did not return to A02')
await navigate(send, `${baseUrl}?status=restricted`, expected.restricted)
await valueOf(send, "document.querySelector('.status-primary')?.click()")
await waitFor(send, `document.body.innerText.includes(${JSON.stringify(expected.help)})`, 'Restricted A06 action did not show the local help notice')
await navigate(send, `${baseUrl}?status=register-pending`, text('5rOo5YaM5bCa5pyq5a6M5oiQ'))
await valueOf(send, "document.querySelector('.status-primary')?.click()")
await waitFor(send, "location.href.includes('/pages/auth/a04-register')", 'Registration-pending A06 action did not open A04')
process.stdout.write('A06-AUTH-STATUS-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+46
View File
@@ -0,0 +1,46 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$captureScriptPath = Join-Path $root 'scripts/capture-chrome-page.js'
if (-not (Test-Path -LiteralPath $captureScriptPath)) {
throw 'Missing Chrome capture script'
}
$captureScript = Get-Content -LiteralPath $captureScriptPath -Raw -Encoding utf8
if ($captureScript -notmatch "const renderSettleMilliseconds = 900") {
throw 'Chrome capture must define a 900ms render-settle delay after the target selector appears'
}
if ($captureScript -notmatch 'await sleep\(renderSettleMilliseconds\)') {
throw 'Chrome capture must wait for the render-settle delay before taking a screenshot'
}
if ($captureScript -match 'await sleep\(300\)') {
throw 'Chrome capture must not retain the unstable 300ms post-selector delay'
}
if ($captureScript -notmatch 'const waitForLoadedImages = async \(send\) =>') {
throw 'Chrome capture must define an image-load wait before a visual screenshot'
}
if ($captureScript -notmatch 'Array\.from\(document\.images\)\.every') {
throw 'Chrome capture must wait until every document image has loaded'
}
if ($captureScript -notmatch 'await waitForLoadedImages\(send\)') {
throw 'Chrome capture must wait for document images after the target selector renders'
}
if ($captureScript -notmatch "await send\('Page.reload'\)") {
throw 'Chrome capture must reload after navigation so route screenshots do not inherit a prior Vue state'
}
if ($captureScript.IndexOf("await send('Page.navigate'") -ge $captureScript.IndexOf("await send('Page.reload')")) {
throw 'Chrome capture must reload only after it has navigated to the requested URL'
}
if ($captureScript -notmatch 'const waitForFreshDocument = async \(send, documentTimeOrigin\) =>') {
throw 'Chrome capture must define a fresh-document wait after reloading'
}
if ($captureScript -notmatch 'performance\.timeOrigin') {
throw 'Chrome capture must distinguish a fresh document from a reused Vue route state'
}
if ($captureScript -notmatch 'await waitForFreshDocument\(send, documentTimeOrigin\)') {
throw 'Chrome capture must wait for a fresh document before checking the target selector'
}
Write-Output 'CAPTURE-CHROME-PAGE-CONTRACT PASS'
+12 -3
View File
@@ -1,7 +1,7 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$requiredFiles = @('utils/config.js', 'utils/session.js', 'utils/genealogy-context.js', 'utils/api.js', 'pages/genealogy/g04-first-ancestor.vue', 'pages/family/f04-article-list.vue', 'pages/family/f02-publish-feed.vue')
$requiredFiles = @('utils/config.js', 'utils/session.js', 'utils/genealogy-context.js', 'utils/api.js', 'pages/genealogy/g03-create-genealogy.vue', 'pages/family/f04-article-list.vue', 'pages/family/f02-publish-feed.vue')
foreach ($file in $requiredFiles) {
if (-not (Test-Path (Join-Path $root $file))) {
throw "Missing core-flow file: $file"
@@ -46,8 +46,17 @@ if ($api -notmatch 'getProfile') {
}
$pages = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages.json')
if ($pages -notmatch 'pages/genealogy/g04-first-ancestor') {
throw 'Missing first-person page route'
if ($pages -notmatch 'pages/genealogy/g03-create-genealogy') {
throw 'Missing G03 create-flow route'
}
if ($pages -match 'pages/genealogy/g04-first-ancestor') {
throw 'G04 first-person route must be merged into G03'
}
$createFlow = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue')
foreach ($token in @('step=ancestor', 'createPerson', 'genealogyContext')) {
if ($createFlow -notmatch [regex]::Escape($token)) {
throw "Missing G03 first-person flow: $token"
}
}
foreach ($route in @('pages/family/f04-article-list', 'pages/family/f02-publish-feed')) {
if ($pages -notmatch [regex]::Escape($route)) {
+4 -1
View File
@@ -41,7 +41,6 @@ Test-ExpectedPage -ExpectedPath 'pages/auth/a01-entry' -PageId 'A-01'
Test-ExpectedPage -ExpectedPath 'pages/auth/a02-login' -PageId 'A-02'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g01-my-genealogies' -PageId 'G-01'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g03-create-genealogy' -PageId 'G-03'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g04-first-ancestor' -PageId 'G-04'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g05-genealogy-overview' -PageId 'G-05'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g06-search-genealogies' -PageId 'G-06'
Test-ExpectedPage -ExpectedPath 'pages/genealogy/g10-application-review' -PageId 'G-10'
@@ -52,6 +51,10 @@ Test-ExpectedPage -ExpectedPath 'pages/family/f02-publish-feed' -PageId 'F-02'
Test-ExpectedPage -ExpectedPath 'pages/notification/n01-message-center' -PageId 'N-01'
Test-ExpectedPage -ExpectedPath 'pages/profile/m01-profile-home' -PageId 'M-01'
if ($registeredPaths -contains 'pages/genealogy/g04-first-ancestor') {
$errors.Add('G04 must be represented by the G03 ancestor state, not a final route')
}
# Both asset directories are required so opaque bases and transparent overlays stay separate.
Test-AssetDirectory -AssetDirectory 'static/assets/foundation/opaque'
Test-AssetDirectory -AssetDirectory 'static/assets/foundation/transparent'
+5 -5
View File
@@ -9,8 +9,8 @@ $root = Split-Path -Parent $PSScriptRoot
$pagesPath = Join-Path $root 'pages.json'
$pageHeader = ConvertFrom-Utf8Base64 '6aG16Z2i57yW5Y+377ya'
$expectedPages = @(
'pages/auth/a01-entry', 'pages/auth/a02-login', 'pages/auth/a03-mobile-verify', 'pages/auth/a04-register', 'pages/auth/a05-reset-password', 'pages/auth/a06-auth-status',
'pages/genealogy/g01-my-genealogies', 'pages/genealogy/g02-empty-genealogies', 'pages/genealogy/g03-create-genealogy', 'pages/genealogy/g04-first-ancestor', 'pages/genealogy/g05-genealogy-overview', 'pages/genealogy/g06-search-genealogies', 'pages/genealogy/g07-search-result', 'pages/genealogy/g08-join-application', 'pages/genealogy/g09-my-applications', 'pages/genealogy/g10-application-review', 'pages/genealogy/g11-genealogy-settings', 'pages/genealogy/g12-generation-poems',
'pages/auth/a01-entry', 'pages/auth/a02-login', 'pages/auth/a04-register', 'pages/auth/a05-reset-password', 'pages/auth/a06-auth-status',
'pages/genealogy/g01-my-genealogies', 'pages/genealogy/g03-create-genealogy', 'pages/genealogy/g05-genealogy-overview', 'pages/genealogy/g06-search-genealogies', 'pages/genealogy/g08-join-application', 'pages/genealogy/g09-my-applications', 'pages/genealogy/g10-application-review', 'pages/genealogy/g11-genealogy-settings', 'pages/genealogy/g12-generation-poems',
'pages/tree/t01-tree-overview', 'pages/tree/t02-tree-states', 'pages/tree/t03-member-profile', 'pages/tree/t04-add-relative', 'pages/tree/t05-edit-member', 'pages/tree/t06-edit-relationship', 'pages/tree/t07-member-directory', 'pages/tree/t08-member-states',
'pages/family/f01-family-feed', 'pages/family/f02-publish-feed', 'pages/family/f03-feed-detail', 'pages/family/f04-article-list', 'pages/family/f05-article-detail', 'pages/family/f06-article-editor', 'pages/family/f07-album-list', 'pages/family/f08-album-detail', 'pages/family/f09-media-upload', 'pages/family/f10-video-list',
'pages/records/r01-people-list', 'pages/records/r02-person-detail', 'pages/records/r03-gift-list', 'pages/records/r04-gift-editor', 'pages/records/r05-ritual-list', 'pages/records/r06-ritual-detail', 'pages/records/r07-ritual-editor', 'pages/records/r08-growth-journal', 'pages/records/r09-life-events', 'pages/records/r10-memo-list', 'pages/records/r11-merit-records',
@@ -20,11 +20,11 @@ $expectedPages = @(
$pages = Get-Content -LiteralPath $pagesPath -Raw -Encoding utf8 | ConvertFrom-Json
$actualPaths = @($pages.pages.path)
if ($actualPaths.Count -ne 59) {
throw "Expected 59 final visual routes, found $($actualPaths.Count)."
if ($actualPaths.Count -ne 55) {
throw "Expected 55 visual routes after the approved G02-to-G01, G04-to-G03 and G07-to-G06 state merges, found $($actualPaths.Count)."
}
if (Compare-Object -ReferenceObject $expectedPages -DifferenceObject $actualPaths) {
throw 'pages.json routes do not match the 59-page visual delivery contract.'
throw 'pages.json routes do not match the 55-route visual delivery contract.'
}
foreach ($page in $expectedPages) {
+61
View File
@@ -0,0 +1,61 @@
$ErrorActionPreference = 'Stop'
function Assert-Match {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
$pattern = "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:"
if ($Content -match $pattern) {
throw "G01 empty state must not construct .$ClassName with CSS $property"
}
}
}
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$paths = @($pages.pages.path)
$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
$panelPath = Join-Path $root 'static/assets/modules/genealogy/opaque/g01-empty-panel.png'
if (-not ($paths -contains 'pages/genealogy/g01-my-genealogies')) { throw 'G01 route missing' }
if ($paths -contains 'pages/genealogy/g02-empty-genealogies') { throw 'G02 route must be removed; empty state belongs to G01' }
foreach ($required in @(
'forceEmptyState',
'query.get("state") === "empty"',
'g01-empty-panel.png',
'empty-create-action',
'empty-search-action',
'const createGenealogy = () =>',
'/pages/genealogy/g03-create-genealogy',
'/pages/genealogy/g06-search-genealogies',
'const hasGenealogies = computed(() => !forceEmptyState.value && list.value.length > 0);'
)) {
if ($g01 -notmatch [regex]::Escape($required)) { throw "Missing G01 empty-state contract: $required" }
}
foreach ($className in @('empty-panel', 'empty-create-action', 'empty-search-action')) {
Assert-NoCssSurface -Content $g01 -ClassName $className
}
if (-not (Test-Path -LiteralPath $panelPath)) { throw 'G01 requires its final raster empty-panel asset' }
Add-Type -AssemblyName System.Drawing
$panel = [System.Drawing.Bitmap]::FromFile($panelPath)
try {
if ($panel.Width -ne 1122 -or $panel.Height -ne 1402) { throw 'G01 empty-panel asset must remain 1122 x 1402px' }
$corners = @(
$panel.GetPixel(0, 0).A,
$panel.GetPixel(($panel.Width - 1), 0).A,
$panel.GetPixel(0, ($panel.Height - 1)).A,
$panel.GetPixel(($panel.Width - 1), ($panel.Height - 1)).A
)
if (($corners | Where-Object { $_ -ne 255 }).Count -ne 0) { throw 'G01 empty-panel asset must have opaque outer corners' }
} finally {
$panel.Dispose()
}
Write-Output 'G01-EMPTY-STATE-CONTRACT PASS'
+95
View File
@@ -0,0 +1,95 @@
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 page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 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)
if (message.error) request.reject(new Error(message.error.message))
else 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 })
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 30; 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 origin = process.argv[2] || 'http://localhost:5173'
const g01Path = '/pages/genealogy/g01-my-genealogies'
const emptyUrl = `${origin}/#${g01Path}?state=empty`
const defaultUrl = `${origin}/#${g01Path}`
const openEmptyG01 = async (send) => {
await send('Page.navigate', { url: emptyUrl })
await waitFor(send, "location.href.includes('g01-my-genealogies?state=empty')", 'Did not navigate to G01 empty state')
await send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.genealogy-empty-state .empty-create-action'))", 'G01 empty state did not render after reload')
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await openEmptyG01(send)
await valueOf(send, "document.querySelector('.empty-create-action')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g03-create-genealogy')", 'G01 empty create action did not open G03')
await openEmptyG01(send)
await valueOf(send, "document.querySelector('.empty-search-action')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g06-search-genealogies')", 'G01 empty search action did not open G06')
await send('Page.navigate', { url: defaultUrl })
await waitFor(send, "location.href.endsWith('/pages/genealogy/g01-my-genealogies')", 'Did not navigate to default G01')
await send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.current-slip'))", 'Default G01 list did not render after reload')
if (await valueOf(send, "Boolean(document.querySelector('.genealogy-empty-state'))")) {
throw new Error('Default G01 must not render the empty state')
}
process.stdout.write('G01-EMPTY-STATE-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+66
View File
@@ -0,0 +1,66 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
$pattern = "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:"
if ($Content -match $pattern) { throw "G03 must not construct .$ClassName with CSS $property" }
}
}
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$paths = @($pages.pages.path)
$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
$g04 = Join-Path $root 'pages/genealogy/g04-first-ancestor.vue'
$panelPath = Join-Path $root 'static/assets/modules/genealogy/opaque/g03-create-flow-panel.png'
if (-not ($paths -contains 'pages/genealogy/g03-create-genealogy')) { throw 'G03 route missing' }
if ($paths -contains 'pages/genealogy/g04-first-ancestor') { throw 'G04 route must be removed; first-ancestor is a G03 state' }
if (Test-Path -LiteralPath $g04) { throw 'G04 page file must be deleted; first-ancestor is a G03 state' }
foreach ($required in @(
'const currentStep = ref("create");',
'query.get("step") === "ancestor"',
'window.addEventListener("hashchange", syncFlowFromRoute)',
'step=ancestor&genealogyId=',
'appApi.createGenealogy',
'genealogyContext.setCurrentGenealogyId',
'appApi.createPerson',
'if (isSubmitting.value) return;',
'/pages/tree/t01-tree-overview?genealogyId=',
'g03-create-flow-panel.png',
'root-header-cinnabar.jpg',
'a01-primary-button.png',
'create-flow-panel',
'flow-primary-action'
)) {
Assert-Contains -Content $g03 -Expected $required -Message "Missing G03 flow contract: $required"
}
foreach ($className in @('create-flow-panel', 'flow-primary-action')) {
Assert-NoCssSurface -Content $g03 -ClassName $className
}
if (-not (Test-Path -LiteralPath $panelPath)) { throw 'G03 requires a final opaque flow-panel bitmap' }
Add-Type -AssemblyName System.Drawing
$panel = [System.Drawing.Bitmap]::FromFile($panelPath)
try {
if ($panel.Width -ne 1122 -or $panel.Height -ne 1500) { throw 'G03 flow-panel asset must be 1122 x 1500px' }
$corners = @(
$panel.GetPixel(0, 0).A,
$panel.GetPixel(($panel.Width - 1), 0).A,
$panel.GetPixel(0, ($panel.Height - 1)).A,
$panel.GetPixel(($panel.Width - 1), ($panel.Height - 1)).A
)
if (($corners | Where-Object { $_ -ne 255 }).Count -ne 0) { throw 'G03 flow-panel outer corners must be opaque' }
} finally {
$panel.Dispose()
}
Write-Output 'G03-CREATE-FLOW-CONTRACT PASS'
+101
View File
@@ -0,0 +1,101 @@
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 page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 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)
if (message.error) request.reject(new Error(message.error.message))
else 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 })
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const setInput = async (send, index, value) => {
const expression = `(() => {
const input = document.querySelectorAll('.flow-fields input')[${index}]
if (!input) return false
input.value = ${JSON.stringify(value)}
input.dispatchEvent(new Event('input', { bubbles: true }))
return true
})()`
if (!await valueOf(send, expression)) throw new Error(`Could not fill G03 input ${index}`)
}
const origin = process.argv[2] || 'http://localhost:5173'
const g03Path = '/pages/genealogy/g03-create-genealogy'
const defaultUrl = `${origin}/#${g03Path}`
const openDefaultG03 = async (send) => {
await send('Page.navigate', { url: defaultUrl })
await waitFor(send, "Boolean(document.querySelector('.create-flow-panel .flow-rule'))", 'Default G03 creation step did not render')
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await openDefaultG03(send)
await setInput(send, 0, '汤')
await setInput(send, 1, '自动验证汤氏家谱')
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await waitFor(send, "location.href.includes('g03-create-genealogy?step=ancestor&genealogyId=')", 'Creating a genealogy did not open G03 ancestor step')
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not render')
await send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor step did not survive reload')
await setInput(send, 0, '汤始祖')
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t01-tree-overview?genealogyId=')", 'Saving the first ancestor did not open T01')
await openDefaultG03(send)
if (await valueOf(send, "Boolean(document.querySelector('.intro-field'))")) {
throw new Error('Default G03 must not render the ancestor step')
}
process.stdout.write('G03-CREATE-FLOW-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+88
View File
@@ -0,0 +1,88 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
$pattern = "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:"
if ($Content -match $pattern) { throw "G06 must not construct .$ClassName with CSS $property" }
}
}
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$paths = @($pages.pages.path)
$g06 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g06-search-genealogies.vue') -Raw -Encoding utf8
$g07 = Join-Path $root 'pages/genealogy/g07-search-result.vue'
$titleStripPath = Join-Path $root 'static/assets/modules/genealogy/opaque/g06-search-title-strip.png'
$inputSkinPath = Join-Path $root 'static/assets/modules/genealogy/opaque/g06-search-input-wide.png'
$buttonSkinPath = Join-Path $root 'static/assets/modules/genealogy/opaque/g06-search-button.png'
if (-not ($paths -contains 'pages/genealogy/g06-search-genealogies')) { throw 'G06 route missing' }
if ($paths -contains 'pages/genealogy/g07-search-result') { throw 'G07 route must be removed; search results are a G06 state' }
if (Test-Path -LiteralPath $g07) { throw 'G07 page file must be deleted; search results are a G06 state' }
foreach ($required in @(
"const searchState = ref('initial')",
"searchState.value = 'loading'",
"searchState.value = 'initial'",
"searchState.value = results.value.length ? 'results' : 'empty'",
'/pages/genealogy/g08-join-application?genealogyId=',
'search-page__header',
'g06-search-input-wide.png',
'g06-search-button.png',
'g06-search-title-strip.png',
'search-title-strip',
'section-divider.png',
'root-header-hall.png',
'search-controls',
'search-field__skin',
'search-action__skin',
'search-initial',
'search-results',
'search-empty',
'search-action'
)) {
Assert-Contains -Content $g06 -Expected $required -Message "Missing G06 flow contract: $required"
}
if ($g06 -match [regex]::Escape('appApi.applyToJoin')) { throw 'G06 must navigate to G08 instead of submitting an application directly' }
if ($g06 -match [regex]::Escape('g06-search-panel-v2.png')) { throw 'G06 must not embed the search controls in a large panel image' }
if ($g06 -match [regex]::Escape('search-hero')) { throw 'G06 must replace the floating hero with the selected title-strip hierarchy' }
if ($g06 -match [regex]::Escape('g06-search-input.png')) { throw 'G06 must use the wide selected-design input skin instead of the tall legacy skin' }
foreach ($className in @('search-title-strip', 'search-field', 'search-action')) {
Assert-NoCssSurface -Content $g06 -ClassName $className
}
if ($g06 -notmatch '(?s)\.search-controls\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center[^}]*gap:\s*20rpx') {
throw 'G06 search input and action must form one compact control row'
}
Add-Type -AssemblyName System.Drawing
foreach ($asset in @(
@{ Path = $titleStripPath; Width = 1122; Height = 220; Label = 'search-title-strip' },
@{ Path = $inputSkinPath; Width = 1120; Height = 248; Label = 'search-input' },
@{ Path = $buttonSkinPath; Width = 300; Height = 132; Label = 'search-button' }
)) {
if (-not (Test-Path -LiteralPath $asset.Path)) { throw "G06 requires a final opaque $($asset.Label) bitmap" }
$bitmap = [System.Drawing.Bitmap]::FromFile($asset.Path)
try {
if ($bitmap.Width -ne $asset.Width -or $bitmap.Height -ne $asset.Height) { throw "G06 $($asset.Label) asset must be $($asset.Width) x $($asset.Height)px" }
$corners = @(
$bitmap.GetPixel(0, 0).A,
$bitmap.GetPixel(($bitmap.Width - 1), 0).A,
$bitmap.GetPixel(0, ($bitmap.Height - 1)).A,
$bitmap.GetPixel(($bitmap.Width - 1), ($bitmap.Height - 1)).A
)
if (($corners | Where-Object { $_ -ne 255 }).Count -ne 0) { throw "G06 $($asset.Label) outer corners must be opaque" }
} finally {
$bitmap.Dispose()
}
}
Write-Output 'G06-SEARCH-FLOW-CONTRACT PASS'
+103
View File
@@ -0,0 +1,103 @@
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 page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 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)
if (message.error) request.reject(new Error(message.error.message))
else 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 })
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 40; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(100)
}
throw new Error(message)
}
const setSearchKeyword = async (send, value) => {
const expression = `(() => {
const input = document.querySelector('.search-input input')
const action = document.querySelector('.search-action')
if (!input || !action) return false
input.value = ${JSON.stringify(value)}
input.dispatchEvent(new Event('input', { bubbles: true }))
action.click()
return true
})()`
if (!await valueOf(send, expression)) throw new Error('Could not enter and submit a G06 search')
}
const origin = process.argv[2] || 'http://localhost:5173'
const g06Url = `${origin}/#/pages/genealogy/g06-search-genealogies`
const openG06 = async (send) => {
await send('Page.navigate', { url: g06Url })
await waitFor(send, `location.href === ${JSON.stringify(g06Url)}`, 'Browser did not navigate back to G06')
await send('Page.reload')
await waitFor(send, "Boolean(document.querySelector('.search-initial'))", 'G06 initial state did not render')
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable')
await send('Runtime.enable')
await openG06(send)
if (await valueOf(send, "Boolean(document.querySelector('.genealogy-card'))")) {
throw new Error('G06 initial state must not reveal a result card')
}
await setSearchKeyword(send, '汤')
await waitFor(send, "Boolean(document.querySelector('.search-results .genealogy-card'))", 'G06 matching search did not render a result card')
await valueOf(send, "document.querySelector('.search-results .genealogy-card')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?genealogyId=')", 'Selecting a G06 search result did not open G08')
await openG06(send)
await setSearchKeyword(send, '不存在的家谱')
await waitFor(send, "Boolean(document.querySelector('.search-empty'))", 'G06 empty search state did not render')
if (await valueOf(send, "Boolean(document.querySelector('.search-empty .genealogy-card'))")) {
throw new Error('G06 empty search state must not contain a result card')
}
process.stdout.write('G06-SEARCH-FLOW-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+3 -2
View File
@@ -17,7 +17,6 @@ $requiredFiles = @(
'pages/auth/a02-login.vue',
'pages/genealogy/g01-my-genealogies.vue',
'pages/genealogy/g03-create-genealogy.vue',
'pages/genealogy/g04-first-ancestor.vue',
'pages/genealogy/g05-genealogy-overview.vue',
'pages/genealogy/g06-search-genealogies.vue',
'pages/genealogy/g10-application-review.vue',
@@ -44,7 +43,6 @@ $requiredRoutes = @(
'pages/auth/a02-login',
'pages/genealogy/g01-my-genealogies',
'pages/genealogy/g03-create-genealogy',
'pages/genealogy/g04-first-ancestor',
'pages/genealogy/g05-genealogy-overview',
'pages/genealogy/g06-search-genealogies',
'pages/genealogy/g10-application-review',
@@ -60,6 +58,9 @@ $missingRoutes = $requiredRoutes | Where-Object { $_ -notin $paths }
if ($missingRoutes) {
throw "Missing page routes: $($missingRoutes -join ', ')"
}
if ($paths -contains 'pages/genealogy/g04-first-ancestor') {
throw 'G04 route must be merged into G03'
}
$tokens = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'uni.scss')
foreach ($token in @('$brand-red:', '$ink:', '$paper:', '$gold:')) {
+36
View File
@@ -0,0 +1,36 @@
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius')) {
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") { throw "T01 must not construct .$ClassName with CSS $property" }
}
}
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
$paths = @($pages.pages.path)
$t01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
$t02 = Join-Path $root 'pages/tree/t02-tree-states.vue'
if (-not ($paths -contains 'pages/tree/t01-tree-overview')) { throw 'T01 route missing' }
if ($paths -contains 'pages/tree/t02-tree-states') { throw 'T02 route must be removed; tree states belong to T01' }
if (Test-Path -LiteralPath $t02) { throw 'T02 page file must be deleted; tree states belong to T01' }
if ($paths.Count -ne 54) { throw "Expected 54 routes after the approved T02-to-T01 state merge, found $($paths.Count)." }
foreach ($required in @("const treeState = ref('loading')", "'tree'", "'landscape'", "'empty'", "'error'", 't01-tree-canvas.png', 't01-member-node.png', 't01-member-sheet.png', 'tree-state--landscape', 'tree-state--empty', 'tree-state--error', '/pages/tree/t03-member-profile?genealogyId=', '/pages/tree/t04-add-relative?genealogyId=')) {
Assert-Contains -Content $t01 -Expected $required -Message "Missing T01 tree-state contract: $required"
}
foreach ($className in @('tree-canvas', 'member-node', 'member-sheet')) { Assert-NoCssSurface -Content $t01 -ClassName $className }
foreach ($asset in @('t01-tree-canvas.png', 't01-member-node.png', 't01-member-sheet.png')) {
if (-not (Test-Path -LiteralPath (Join-Path $root "static/assets/modules/tree/opaque/$asset"))) { throw "T01 requires final opaque asset: $asset" }
}
Write-Output 'T01-TREE-STATE-CONTRACT PASS'
+45
View File
@@ -0,0 +1,45 @@
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const origin = process.argv[2] || 'http://localhost:5173'
const t01Path = '/pages/tree/t01-tree-overview'
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.includes('localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 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 attempt = 0; attempt < 40; attempt += 1) { if (await valueOf(send, expression)) return; await sleep(100) }
throw new Error(message)
}
const open = async (send, query, selector) => {
const url = `${origin}/#${t01Path}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, 'T01 navigation failed')
await send('Page.reload')
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T01 ${query} did not render`)
}
const run = async () => {
const { socket, send } = await connect()
try {
await send('Page.enable'); await send('Runtime.enable')
await open(send, '?state=landscape', '.tree-state--landscape')
await open(send, '?state=empty', '.tree-state--empty')
await open(send, '?state=error', '.tree-state--error')
await open(send, '?genealogyId=1001', '.tree-canvas .member-node')
await valueOf(send, "document.querySelector('.member-node')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'Selecting a T01 member did not open T03')
process.stdout.write('T01-TREE-STATE-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
}
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1) })