完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+13 -16
View File
@@ -220,23 +220,25 @@ $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("sms")',
'const passwordVisible = ref(false)',
'const LOGIN_METHOD_STORAGE_KEY = "a01:last-login-method"',
'uni.getStorageSync(LOGIN_METHOD_STORAGE_KEY)',
'uni.setStorageSync(LOGIN_METHOD_STORAGE_KEY, method)',
'login-tab--unavailable',
'aria-disabled="true"',
'const switchLoginMethod = (method) =>',
'const togglePasswordVisibility = () =>',
'/pages/auth/a04-register',
'/pages/auth/a05-reset-password',
'class="feedback-toast"',
'class="verification-layer"',
'class="login-submit"',
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
'<TacVerification',
'AUTH_TAC_SCENE.SMS_LOGIN',
'normalizeTacSuccess',
'appApi.loginWithSms',
'PASSWORD_TAC_BLOCKED_MESSAGE',
'login-submit',
'class="agreement-error"',
'const agreementError = ref(false)'
)) {
Assert-Contains -Content $entry -Expected $contract -Message "A01 is missing state or interaction contract: $contract"
}
Assert-NotContains -Content $entry -Unexpected 'a01:last-login-method' -Message '密码登录后端未闭环前不得恢复为首屏或记忆入口。'
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $entry -Unexpected $nativeUi -Message "A01 must not use native UniApp feedback: $nativeUi"
@@ -251,11 +253,6 @@ Assert-NotContains -Content $entry -Unexpected 'a01-icon-eye-closed-v1.png' -Mes
Assert-NotContains -Content $entry -Unexpected 'a01-icon-verification-v1.png' -Message 'A01 SMS field must use a message-code icon instead of a success-state checkmark.'
Assert-NotContains -Content $entry -Unexpected 'a01-icon-sms-code-v1.svg' -Message 'A01 SMS field must use the custom generated icon instead of the temporary third-party SVG.'
Assert-NotContains -Content $entry -Unexpected '@media (max-height:' -Message 'A01 must scroll naturally on short screens instead of compressing the selected design with height breakpoints.'
Assert-NotContains -Content $entry -Unexpected 'feedback-toast__skin' -Message 'A01 feedback Toast must use the dedicated nine-slice border asset instead of an absolutely stretched image.'
$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 '@include adaptive.adaptive-feedback-toast;' -Message 'A01 feedback Toast must consume the approved shared nine-slice profile.'
Assert-Contains -Content $entry -Expected 'import AppToast from "@/components/AppToast.vue";' -Message 'A01 feedback must consume the shared live-region Toast owner.'
Assert-NotContains -Content $entry -Unexpected 'class="feedback-toast"' -Message 'A01 must not duplicate the shared Toast implementation.'
Write-Output 'A01-LOGIN-MERGE-CONTRACT PASS'
+16 -25
View File
@@ -118,10 +118,10 @@ const run = async () => {
assert(metrics.paperTop >= metrics.headerBottom - 1, `A01 paper overlaps the header at ${size.width}x${size.height}`)
assert(metrics.contentTop >= metrics.paperTop, `A01 content escapes paper flow at ${size.width}x${size.height}`)
if (size.width >= 360 && size.height >= 640) {
assert(metrics.rootScrollHeight <= metrics.innerHeight + 1, `A01 password state must fit at ${size.width}x${size.height}`)
assert(metrics.agreementBottom <= metrics.innerHeight + 1, `A01 password agreement must remain visible at ${size.width}x${size.height}`)
assert(metrics.rootScrollHeight <= metrics.innerHeight + 1, `A01 SMS state must fit at ${size.width}x${size.height}`)
assert(metrics.agreementBottom <= metrics.innerHeight + 1, `A01 SMS agreement must remain visible at ${size.width}x${size.height}`)
} else {
assert(metrics.agreementBottom <= metrics.documentScrollHeight + 1, `A01 password agreement must remain reachable at ${size.width}x${size.height}`)
assert(metrics.agreementBottom <= metrics.documentScrollHeight + 1, `A01 SMS agreement must remain reachable at ${size.width}x${size.height}`)
}
const buttonAssets = await valueOf(send, `Array.from(document.querySelectorAll('.button-skin img')).map((image) => ({
@@ -151,12 +151,13 @@ const run = async () => {
assert(smsMetrics.agreementBottom <= smsMetrics.documentScrollHeight + 1, `A01 SMS agreement must remain reachable at ${size.width}x${size.height}`)
}
await valueOf(send, "document.querySelectorAll('.login-tab')[0].click()")
await waitFor(send, "document.querySelectorAll('.login-tab')[0].classList.contains('active')", `A01 password tab did not reactivate at ${size.width}x${size.height}`)
await waitFor(send, "document.querySelector('.app-toast__copy')?.textContent === '密码登录的服务端安全验证尚未开放,请先使用验证码登录'", `A01 password unavailable reason was not announced at ${size.width}x${size.height}`)
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.login-tab')[0].classList.contains('active')"), false, `A01 exposed the unsafe password form at ${size.width}x${size.height}`)
}
await valueOf(send, "document.querySelector('.login-submit').click()")
await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A01 invalid phone Toast did not render')
const toastBorderImage = await valueOf(send, "getComputedStyle(document.querySelector('.feedback-toast')).borderImageSource")
await waitFor(send, "Boolean(document.querySelector('.app-toast'))", 'A01 invalid phone Toast did not render')
const toastBorderImage = await valueOf(send, "getComputedStyle(document.querySelector('.app-toast')).borderImageSource")
assert(toastBorderImage.includes('a01-scroll-toast-v3.png'), `A01 Toast did not render the v3 nine-slice asset: ${toastBorderImage}`)
await valueOf(send, "document.querySelectorAll('.login-tab')[1].click()")
@@ -165,37 +166,27 @@ const run = async () => {
const smsIconSource = await valueOf(send, "document.querySelector('.input-icon--sms img')?.getAttribute('src')")
assert(smsIconSource?.includes('a01-icon-sms-code-v2.png'), `A01 SMS state did not use the approved message icon: ${smsIconSource}`)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.password-toggle'))"), false, 'A01 SMS state retained the password eye')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.forgot-password'))"), false, 'A01 SMS state retained forgot password')
await valueOf(send, "document.querySelectorAll('.login-tab')[0].click()")
await waitFor(send, "document.querySelectorAll('.login-tab')[0].classList.contains('active')", 'A01 password tab did not activate')
assert(await valueOf(send, "Boolean(document.querySelector('.password-toggle'))"), 'A01 password state is missing the eye control')
assert(await valueOf(send, "Boolean(document.querySelector('.forgot-password'))"), 'A01 password state is missing forgot password')
assert(await valueOf(send, "Boolean(document.querySelector('.forgot-password'))"), 'A01 SMS state must retain the password-recovery entry')
await valueOf(send, `(() => {
const inputs = document.querySelectorAll('.auth-input input')
inputs[0].value = '13800138000'
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
inputs[1].value = 'demo-password'
inputs[1].value = '1234'
inputs[1].dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.login-submit').click()
})()`)
await waitFor(send, "Boolean(document.querySelector('.agreement-error'))", 'A01 did not show inline agreement validation')
await valueOf(send, "document.querySelector('.agreement-row').click()")
await valueOf(send, "document.querySelector('.agreement-toggle').click()")
await waitFor(send, "!document.querySelector('.agreement-error')", 'A01 agreement error did not clear after selection')
await valueOf(send, "document.querySelector('.login-submit').click()")
await waitFor(send, "Boolean(document.querySelector('.verification-layer'))", 'A01 did not open the custom verification layer after local validation')
await waitFor(send, "document.querySelector('.verification-dialog__skin img')?.naturalWidth === 1860", 'A01 verification dialog v3 asset did not finish loading')
const dialogAsset = await valueOf(send, `(() => {
const image = document.querySelector('.verification-dialog__skin img')
return image ? { src: image.currentSrc || image.src, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight } : null
})()`)
assert(dialogAsset?.src.includes('a01-scroll-dialog-v3.png'), `A01 verification dialog did not load the v3 asset: ${dialogAsset?.src}`)
assert.deepStrictEqual(
[dialogAsset.naturalWidth, dialogAsset.naturalHeight],
[1860, 1560],
'A01 verification dialog loaded unexpected natural dimensions'
await waitFor(
send,
"document.querySelector('.app-toast__copy')?.textContent === '当前为本地预览模式,真实认证服务未启用'",
'A01 local preview did not reject a fake SMS login'
)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A01 retained the obsolete fake verification layer')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.tac-layer--visible'))"), false, 'A01 opened TAC without a server-bindable password-login ticket')
assert.deepStrictEqual(exceptions, [], `A01 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A01-RESPONSIVE-RUNTIME-SMOKE PASS\n')
-2
View File
@@ -2,12 +2,10 @@ $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 52) { throw "Expected 52 active routes after the approved page-state merges and A06 archive, found $($pages.pages.Count)." }
Write-Output 'A03-ROUTE-REMOVAL-CONTRACT PASS'
+12 -12
View File
@@ -1,4 +1,4 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
@@ -18,12 +18,10 @@ function Assert-NotContains {
$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
$globalStyles = Get-Content -LiteralPath (Join-Path $root 'styles/global.scss') -Raw -Encoding utf8
$registerCopy = ConvertFrom-Utf8Base64 '5rOo5YaM6LSm5Y+3'
$loginCopy = ConvertFrom-Utf8Base64 '55m75b2V'
$sliderPendingCopy = ConvertFrom-Utf8Base64 '5ruR5Yqo6aqM6K+B5b6F5o6l5Y+j5o6l5YWl'
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'
@@ -32,21 +30,24 @@ foreach ($required in @(
'import AuthPageShell from "@/components/AuthPageShell.vue";',
'a01-vnext-divider-v1.png',
'a01-scroll-primary-v3.png',
'adaptive.adaptive-feedback-toast',
'import AppToast from "@/components/AppToast.vue";',
'a02-agreement-unchecked.png',
'a02-agreement-checked.png',
'v-model.trim="phone"',
'v-model.trim="verificationCode"',
'v-model="password"',
'v-model="confirmPassword"',
'const agreed = ref(false)',
'const agreementError = ref(false)',
'const fieldErrors = ref({',
'const toggleAgreement = () =>',
'const submitRegister = () =>',
'class="feedback-toast"',
$sliderPendingCopy,
'const prepareLogin = () => uni.redirectTo({ url: "/pages/auth/a01-entry" });',
'if (!/^1\d{10}$/.test(phone.value))',
'const submitRegister = async () =>',
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
'<TacVerification',
'AUTH_TAC_SCENE.REGISTER',
'appApi.registerWithPassword',
'if (!/^\d{4}$/.test(verificationCode.value))',
'if (!isAuthPhone(phone.value))',
'validatePassword(password.value)',
'PASSWORD_POLICY_MESSAGE',
'if (!confirmPassword.value)',
@@ -56,7 +57,6 @@ foreach ($required in @(
}
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 ($forbidden in @('class="page-canvas"', 'class="page-backdrop"', 'mode="scaleToFill"', '1665rpx', 'a01-red-hall-ink-backdrop-v1.png')) {
Assert-NotContains -Content $register -Unexpected $forbidden -Message "A04 retains rejected page coordinates: $forbidden"
@@ -80,8 +80,8 @@ Assert-NotContains -Content $register -Unexpected '/pages/auth/a02-login' -Messa
foreach ($required in @(
'register-divider',
'mode="aspectFit"',
'adaptive.adaptive-feedback-toast;',
'top: calc(var(--status-bar-height, 0px) + 24rpx);'
'role="alert"',
'aria-describedby'
)) {
Assert-Contains -Content $register -Expected $required -Message "Missing A-04 visual balance contract: $required"
}
+11 -7
View File
@@ -107,23 +107,27 @@ const run = async () => {
}
await valueOf(send, "document.querySelector('.register-submit').click()")
await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'A04 empty submit did not show three field errors')
await waitFor(send, "document.querySelectorAll('.field-error').length === 4", 'A04 empty submit did not show four field errors')
assert(await valueOf(send, "Boolean(document.querySelector('.agreement-error'))"), 'A04 empty submit did not show agreement error')
await valueOf(send, `(() => {
const inputs = document.querySelectorAll('.auth-input input')
const values = ['13800138000', 'demo-password', 'demo-password']
const values = ['13800138000', '1234', 'demo-password', 'demo-password']
inputs.forEach((input, index) => {
input.value = values[index]
input.dispatchEvent(new Event('input', { bubbles: true }))
})
document.querySelector('.agreement-row').click()
document.querySelector('.agreement-toggle').click()
document.querySelector('.register-submit').click()
})()`)
await waitFor(send, "Boolean(document.querySelector('.feedback-toast'))", 'A04 valid submit did not show slider integration feedback')
assert.strictEqual(await valueOf(send, "document.querySelector('.feedback-toast__copy')?.textContent"), '滑动验证待接口接入', 'A04 valid submit showed unexpected feedback')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A04 must not retain the non-final custom verification placeholder')
const toastBorderImage = await valueOf(send, "getComputedStyle(document.querySelector('.feedback-toast')).borderImageSource")
await waitFor(
send,
"document.querySelector('.app-toast__copy')?.textContent === '当前为本地预览模式,真实认证服务未启用'",
'A04 local preview did not fail closed instead of faking registration success'
)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A04 retained the obsolete fake verification layer')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.tac-layer--visible'))"), false, 'A04 opened TAC during registration submission instead of the SMS request phase')
const toastBorderImage = await valueOf(send, "getComputedStyle(document.querySelector('.app-toast')).borderImageSource")
assert(toastBorderImage.includes('a01-scroll-toast-v3.png'), `A04 Toast did not render the v3 nine-slice asset: ${toastBorderImage}`)
await navigate(send, a04Url, '.login-entry__link')
+16 -18
View File
@@ -1,4 +1,4 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
function ConvertFrom-Utf8Base64 {
param([string]$Value)
@@ -18,13 +18,11 @@ function Assert-NotContains {
$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'
$sliderPendingCopy = ConvertFrom-Utf8Base64 '5ruR5Yqo6aqM6K+B5b6F5o6l5Y+j5o6l5YWl'
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'
@@ -33,28 +31,29 @@ foreach ($required in @(
'import AuthPageShell from "@/components/AuthPageShell.vue";',
'a01-vnext-divider-v1.png',
'a01-scroll-primary-v3.png',
'adaptive.adaptive-feedback-toast',
'a01-scroll-dialog-v3.png',
'import AppToast from "@/components/AppToast.vue";',
'chevron-right.png',
'v-model.trim="phone"',
'v-model.trim="verificationCode"',
'v-model="password"',
'v-model="confirmPassword"',
'const prepareGetCode = () =>',
'const submitReset = () =>',
'const prepareGetCode = async () =>',
'const submitReset = async () =>',
'const fieldErrors = ref({',
'const successVisible = ref(false)',
'class="feedback-toast"',
'class="success-layer"',
'const prepareLogin = () => uni.redirectTo({ url: "/pages/auth/a01-entry" });',
'if (!/^1\d{10}$/.test(phone.value))',
'if (!/^\d{6}$/.test(verificationCode.value))',
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
'title="密码已重设"',
'if (!isAuthPhone(phone.value))',
'if (!/^\d{4}$/.test(verificationCode.value))',
'<TacVerification',
'AUTH_TAC_SCENE.FORGOT_PASSWORD',
'await appApi.resetPassword',
'validatePassword(password.value)',
'PASSWORD_POLICY_MESSAGE',
'if (password.value !== confirmPassword.value)',
'adaptive.adaptive-feedback-toast;',
'top: calc(var(--status-bar-height, 0px) + 24rpx);',
'max-height: calc(var(--app-viewport-height) - 40px);'
'role="alert"',
'aria-describedby',
':visible="successVisible"'
)) {
Assert-Contains -Content $page -Expected $required -Message "Missing A-05 reset-password contract: $required"
}
@@ -62,8 +61,7 @@ Assert-Contains -Content $page -Expected $resetCopy -Message 'A-05 must visibly
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 A01 return path'
Assert-Contains -Content $page -Expected $sliderPendingCopy -Message 'A-05 must defer the real slider to the interface stage'
Assert-NotContains -Content $catalog -Unexpected 'a05:' -Message 'A-05 must not remain in the temporary page catalog'
Assert-NotContains -Content $page -Unexpected '滑动验证待接口接入' -Message 'A-05 must not retain the retired TAC placeholder'
foreach ($forbidden in @('class="page-canvas"', 'class="page-backdrop"', 'mode="scaleToFill"', '1665rpx', 'a01-red-hall-ink-backdrop-v1.png')) {
Assert-NotContains -Content $page -Unexpected $forbidden -Message "A05 retains rejected page coordinates: $forbidden"
}
@@ -84,7 +82,7 @@ foreach ($obsoleteVisual in @(
}
$primarySkinCount = ([regex]::Matches($page, 'a01-scroll-primary-v3\.png')).Count
if ($primarySkinCount -ne 2) { throw "A-05 must use the approved primary skin exactly twice, found $primarySkinCount" }
if ($primarySkinCount -ne 1) { throw "A-05 page-local submit action must use the approved primary skin exactly once, found $primarySkinCount" }
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
Assert-NotContains -Content $page -Unexpected $nativeUi -Message "A-05 must not use native UniApp feedback: $nativeUi"
}
+23 -11
View File
@@ -120,31 +120,43 @@ const run = async () => {
await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'A05 invalid phone did not show inline error')
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')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A05 must not retain the old verification panel')
assert.strictEqual(await valueOf(send, "document.querySelector('.auth-input input').value"), '13800138000', 'A05 pending slider feedback cleared the form')
assert.strictEqual(await valueOf(send, "document.querySelector('.get-code').textContent.trim()"), '获取验证码', 'A05 must not fake a requested-code state before the real slider and SMS interface')
await waitFor(
send,
"document.querySelector('.app-toast__copy')?.textContent === '当前为本地预览模式,真实认证服务未启用'",
'A05 local preview did not fail closed before requesting TAC'
)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.verification-layer'))"), false, 'A05 retained the obsolete fake verification layer')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.tac-layer--visible'))"), false, 'A05 opened TAC without a remote requirement response')
assert.strictEqual(await valueOf(send, "document.querySelector('.auth-input input').value"), '13800138000', 'A05 unavailable remote verification cleared the form')
assert.strictEqual(await valueOf(send, "document.querySelector('.get-code').textContent.trim()"), '获取验证码', 'A05 faked a requested-code state without TAC and the SMS service')
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 setInputs(send, ['13800138000', '123456', 'new-password', 'different-password'])
await setInputs(send, ['13800138000', '1234', 'new-password', 'different-password'])
await sleep(100)
await valueOf(send, "document.querySelector('.reset-submit').click()")
await sleep(100)
const mismatchState = await valueOf(send, `({
values: Array.from(document.querySelectorAll('.auth-input input')).map((item) => item.value),
errors: Array.from(document.querySelectorAll('.field-error')).map((item) => item.textContent),
success: Boolean(document.querySelector('.success-layer'))
success: document.querySelector('.app-dialog__title')?.textContent === '密码已重设'
})`)
assert(mismatchState.errors.some((message) => message.includes('不一致')), `A05 mismatched passwords did not show inline error: ${JSON.stringify(mismatchState)}`)
await setInputs(send, ['13800138000', '123456', 'new-password', 'new-password'])
await setInputs(send, ['13800138000', '1234', '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')
await valueOf(send, "document.querySelector('.success-action').click()")
await waitFor(send, "Boolean(document.querySelector('.login-tab')) && (location.hash === '#/' || location.href.includes('/pages/auth/a01-entry'))", 'A05 success action did not return to A01')
await waitFor(
send,
"document.querySelector('.app-toast__copy')?.textContent === '当前为本地预览模式,真实认证服务未启用'",
'A05 local preview did not reject a fake password reset'
)
assert.strictEqual(await valueOf(send, "document.querySelector('.app-dialog__title')?.textContent === '密码已重设'"), false, 'A05 showed reset success without a successful remote response')
assert.deepStrictEqual(
await valueOf(send, "Array.from(document.querySelectorAll('.auth-input input')).map((item) => item.value)"),
['13800138000', '1234', 'new-password', 'new-password'],
'A05 cleared the form after a rejected remote reset'
)
assert.deepStrictEqual(exceptions, [], `A05 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('A05-RESET-PASSWORD-RUNTIME-SMOKE PASS\n')
+1 -5
View File
@@ -13,7 +13,6 @@ function Assert-NotContains {
$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
$runtime = Get-Content -LiteralPath (Join-Path $root 'tests/a06-auth-status-runtime-smoke.js') -Raw -Encoding utf8
if ($pages.pages.path -contains 'pages/auth/a06-auth-status') { throw 'A-06 is archived and must not remain declared in pages.json' }
@@ -22,7 +21,6 @@ if (-not ($pages.pages.path -contains 'pages/auth/a01-entry')) { throw 'A-06 mus
Assert-Contains -Content $runtime -Expected 'A06-AUTH-STATUS-RUNTIME-SMOKE SKIP archived route' -Message 'A-06 runtime smoke must explicitly skip while the route is archived'
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(',
@@ -34,11 +32,9 @@ foreach ($required in @(
'reasonLabel:',
'impactLabel:',
'recoveryLabel:',
'const resolveStatus = () =>',
'const goLogin = () => uni.redirectTo({ url: "/pages/auth/a01-entry" });',
'const resolveStatus = (options = {}) =>',
'const openRecovery = () =>',
'const closeRecovery = () =>',
'const goBack = () =>',
'<AuthPageShell',
'import AuthPageShell from "@/components/AuthPageShell.vue";',
'a01-vnext-divider-v1.png',
@@ -1,16 +1,28 @@
$ErrorActionPreference = 'Stop'
$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 = @()
$remoteBusinessOwners = @(
'pages/auth/a01-entry.vue',
'pages/auth/a04-register.vue',
'pages/auth/a05-reset-password.vue',
'pages/profile/m07-feedback.vue'
)
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" }
$usesRemoteBusiness = $source -match '@/utils/api\.js|\bappApi\b'
if ($usesRemoteBusiness -and $relativePath -notin $remoteBusinessOwners) {
throw "$relativePath must remain local-design only until its own tested interface batch"
}
if (-not $usesRemoteBusiness -and $relativePath -in $remoteBusinessOwners) {
throw "$relativePath lost its owned authentication interface"
}
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$relativePath must use project feedback components" }
}
@@ -21,26 +33,26 @@ if ($moduleConsumers.Count -gt 0) {
$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/f05-article-detail.vue' = @('articleParagraphs', 'disabled label=', '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/records/r03-gift-list.vue' = @('relativeRecords', 'openRelative', 'createRelativePreview', 'relative-state--empty')
'pages/records/r04-gift-editor.vue' = @('relativeForm', 'validateRelative', 'localRelativePreview', 'relativeId')
'pages/records/r05-ritual-list.vue' = @('ceremonies', 'openCeremony', 'createCeremonyPreview', 'ceremony-state--empty')
'pages/records/r06-ritual-detail.vue' = @('ceremonyDetail', 'invitees', 'editCeremony', 'ceremony-state--expired')
'pages/records/r07-ritual-editor.vue' = @('ceremonyForm', 'validateCeremony', 'localCeremonyPreview', 'ceremonyId')
'pages/records/r08-growth-journal.vue' = @('growthRecords', 'recordGrowth', 'localGrowthPreview', 'timeline-state--empty')
'pages/records/r09-life-events.vue' = @('人生事件接口尚未开放', 'serviceState', 'requestBack')
'pages/records/r10-memo-list.vue' = @('memos', 'createMemoPreview', 'localMemoPreview', 'memo-state--empty')
'pages/records/r11-merit-records.vue' = @('meritRecords', 'createMeritPreview', 'localMeritPreview', 'totalContribution')
'pages/notification/n02-message-detail.vue' = @('findNotificationFixture', 'noticeDetail.unread', '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/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--limited', 'currentUser.phone', 'checkSecurity')
'pages/profile/m04-change-password.vue' = @('passwordForm', 'validatePassword', 'togglePassword', 'savePassword')
'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'codeCountdown', 'savePhone')
'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'validatePhone', 'phone-state--saving', '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/m08-promotion.vue' = @('inviteState', 'openInviteExplanation', 'explanationVisible', 'share-state--unavailable')
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orderState', 'order-state--unavailable', 'openServiceNotice')
'pages/profile/m10-about-settings.vue' = @('agreementItems', 'openAgreement', 'confirmLogout', 'appVersion')
}
@@ -10,8 +10,6 @@ $profile = Get-Content -LiteralPath $profilePath -Raw -Encoding UTF8
foreach ($token in @(
'@mixin adaptive-auth-dialog',
'@mixin adaptive-scroll-button($type)',
'@mixin adaptive-module-content',
'@mixin adaptive-module-field',
'@mixin adaptive-feedback-toast',
'@mixin adaptive-genealogy-current-slip',
'@mixin adaptive-genealogy-list-card',
@@ -38,7 +36,6 @@ foreach ($token in @(
'a01-scroll-dialog-v3.png',
'border-image-slice: 260 240 360 240 fill;',
'border-image-slice: 56 300 fill;',
'border-image-slice: 105 150;',
'border-image-slice: 70 100 fill;'
)) {
if (-not $profile.Contains($token)) {
@@ -7,9 +7,6 @@ function Read-Utf8([string]$path) {
$expected = @{
'components/AppToast.vue' = @('adaptive.adaptive-feedback-toast')
'pages/auth/a01-entry.vue' = @('adaptive.adaptive-feedback-toast')
'pages/auth/a04-register.vue' = @('adaptive.adaptive-feedback-toast')
'pages/auth/a05-reset-password.vue' = @('adaptive.adaptive-feedback-toast')
'pages/genealogy/g01-my-genealogies.vue' = @(
'adaptive.adaptive-genealogy-current-slip',
'adaptive.adaptive-genealogy-list-card',
@@ -0,0 +1,129 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$attributePattern = '(?:"[^"]*"|''[^'']*''|[^>"'']+)*'
function Read-ProjectFile {
param([string]$Path)
return Get-Content -LiteralPath (Join-Path $root $Path) -Raw -Encoding UTF8
}
function Require-Match {
param([string]$Content, [string]$Pattern, [string]$Label)
if ($Content -notmatch $Pattern) { throw "$Label 未满足" }
}
function Require-ButtonClass {
param([string]$Content, [string]$Class, [string]$Label)
$button = [regex]::Matches($Content, "(?s)<button\b$script:attributePattern>") |
Where-Object { $_.Value -match "class=`"[^`"]*\b$([regex]::Escape($Class))\b" } |
Select-Object -First 1
if ($null -eq $button) { throw "$Label 必须由原生 button 承载:$Class" }
$view = [regex]::Matches($Content, "(?s)<view\b$script:attributePattern>") |
Where-Object { $_.Value -match "class=`"[^`"]*\b$([regex]::Escape($Class))\b" -and $_.Value.Contains('@click') } |
Select-Object -First 1
if ($null -ne $view) {
throw "$Label 不得继续用 view 模拟按钮:$Class"
}
}
function Get-ButtonByClass {
param([string]$Content, [string]$Class)
return [regex]::Matches($Content, "(?s)<button\b$script:attributePattern>") |
Where-Object { $_.Value -match "class=`"[^`"]*\b$([regex]::Escape($Class))\b" } |
Select-Object -First 1
}
function Require-FieldRelation {
param([string]$Content, [string]$Prefix, [string]$IdSuffix, [string]$ErrorKey, [string]$Label)
$inputId = "$Prefix-$IdSuffix"
$errorId = "$inputId-error"
Require-Match $Content "(?s)<label\b[^>]*for=`"$([regex]::Escape($inputId))`"[^>]*>" "$Label label/for"
$input = [regex]::Matches($Content, "(?s)<input\b$script:attributePattern>") |
Where-Object { $_.Value.Contains("id=`"$inputId`"") } |
Select-Object -First 1
if ($null -eq $input) { throw "$Label 缺少对应 input$inputId" }
foreach ($pattern in @(
":aria-invalid=`"Boolean\(fieldErrors\.$([regex]::Escape($ErrorKey))\)`"",
":aria-describedby=`"fieldErrors\.$([regex]::Escape($ErrorKey)) \? '$([regex]::Escape($errorId))' : undefined`""
)) {
if ($input.Value -notmatch $pattern) { throw "$Label 输入错误关联缺失:$inputId" }
}
Require-Match $Content "(?s)<text\b(?=[^>]*id=`"$([regex]::Escape($errorId))`")(?=[^>]*role=`"alert`")[^>]*>" "$Label 错误节点"
}
$a01 = Read-ProjectFile 'pages/auth/a01-entry.vue'
$a04 = Read-ProjectFile 'pages/auth/a04-register.vue'
$a05 = Read-ProjectFile 'pages/auth/a05-reset-password.vue'
$globalStyles = Read-ProjectFile 'styles/global.scss'
foreach ($page in @($a01, $a04, $a05)) {
Require-Match $page 'import\s+AppToast\s+from\s+["'']@/components/AppToast\.vue["'']' '认证状态播报 import'
Require-Match $page '(?s)<AppToast\b(?=[^>]*:visible="feedbackVisible")(?=[^>]*:message="feedbackMessage")[^>]*/>' '认证状态播报实例'
if ($page.Contains('class="feedback-toast"')) { throw '认证页不得重复实现缺少稳定 live region 的 Toast' }
}
Require-Match $a01 '<view\s+class="login-tabs"\s+role="tablist"' 'A01 登录方式容器'
$a01Buttons = [regex]::Matches($a01, "(?s)<button\b$attributePattern>")
$tabButtons = @($a01Buttons | Where-Object { $_.Value.Contains('role="tab"') })
if ($tabButtons.Count -ne 2) { throw 'A01 两个登录方式必须都是原生 tab 按钮' }
if (@($tabButtons | Where-Object { $_.Value.Contains(':aria-selected=') }).Count -ne 2) { throw 'A01 两个 tab 都必须声明选中状态' }
foreach ($class in @(
'login-tab', 'password-toggle', 'get-code', 'forgot-password', 'login-submit',
'wechat-login', 'register-link', 'agreement-toggle', 'agreement-link'
)) { Require-ButtonClass $a01 $class 'A01' }
if (@($a01Buttons | Where-Object { $_.Value -match 'class="[^"]*\bagreement-link\b' }).Count -ne 2) { throw 'A01 两份协议必须各自可聚焦' }
foreach ($name in @('手机号', '登录密码', '短信验证码')) {
$namedInput = [regex]::Matches($a01, "(?s)<input\b$attributePattern>") |
Where-Object { $_.Value.Contains("aria-label=`"$name`"") } |
Select-Object -First 1
if ($null -eq $namedInput) { throw "A01 输入缺少名称:$name" }
}
$passwordToggle = Get-ButtonByClass $a01 'password-toggle'
if ($passwordToggle.Value -notmatch ':aria-label="passwordVisible \? ''隐藏密码'' : ''显示密码''"' -or -not $passwordToggle.Value.Contains(':aria-pressed="passwordVisible"')) { throw 'A01 密码显隐状态未关联' }
$a01GetCode = Get-ButtonByClass $a01 'get-code'
if (-not $a01GetCode.Value.Contains(':disabled="sendingCode || submitting || cooldownSeconds > 0"')) { throw 'A01 短信按钮禁用态未关联' }
$a01Submit = Get-ButtonByClass $a01 'login-submit'
if (-not $a01Submit.Value.Contains(':disabled="submitting || sendingCode || tacVisible"') -or -not $a01Submit.Value.Contains(':aria-busy="submitting"')) { throw 'A01 提交忙碌态未关联' }
$a01Agreement = Get-ButtonByClass $a01 'agreement-toggle'
if (-not $a01Agreement.Value.Contains('role="checkbox"') -or -not $a01Agreement.Value.Contains(':aria-checked="agreed"')) { throw 'A01 协议复选语义未关联' }
Require-Match $a01 '(?s)<text\b[^>]*class="agreement-error"[^>]*role="alert"' 'A01 协议错误播报'
foreach ($class in @('back-button', 'get-code', 'register-submit', 'agreement-toggle', 'agreement-link', 'login-entry__link')) {
Require-ButtonClass $a04 $class 'A04'
}
$a04Buttons = [regex]::Matches($a04, "(?s)<button\b$attributePattern>")
if (@($a04Buttons | Where-Object { $_.Value -match 'class="[^"]*\bagreement-link\b' }).Count -ne 2) { throw 'A04 两份协议必须各自可聚焦' }
foreach ($class in @('back-button', 'get-code', 'reset-submit', 'login-entry__link')) {
Require-ButtonClass $a05 $class 'A05'
}
foreach ($entry in @(
@{ Key = 'A04'; Content = $a04; Prefix = 'a04'; SubmitClass = 'register-submit' },
@{ Key = 'A05'; Content = $a05; Prefix = 'a05'; SubmitClass = 'reset-submit' }
)) {
Require-FieldRelation $entry.Content $entry.Prefix 'phone' 'phone' "$($entry.Key) 手机号"
Require-FieldRelation $entry.Content $entry.Prefix 'verification-code' 'verificationCode' "$($entry.Key) 验证码"
Require-FieldRelation $entry.Content $entry.Prefix 'password' 'password' "$($entry.Key) 密码"
Require-FieldRelation $entry.Content $entry.Prefix 'confirm-password' 'confirmPassword' "$($entry.Key) 确认密码"
$getCode = Get-ButtonByClass $entry.Content 'get-code'
if (-not $getCode.Value.Contains(':disabled="sendingCode || submitting || cooldownSeconds > 0"')) { throw "$($entry.Key) 短信按钮禁用态未关联" }
$submit = Get-ButtonByClass $entry.Content $entry.SubmitClass
if (-not $submit.Value.Contains(':disabled="submitting || sendingCode || tacVisible"') -or -not $submit.Value.Contains(':aria-busy="submitting"')) { throw "$($entry.Key) 主提交忙碌态未关联" }
}
$a04Agreement = Get-ButtonByClass $a04 'agreement-toggle'
if (-not $a04Agreement.Value.Contains('role="checkbox"') -or -not $a04Agreement.Value.Contains(':aria-checked="agreed"')) { throw 'A04 协议复选语义未关联' }
Require-Match $a05 '(?s)<AppDialog\b(?=[^>]*:visible="successVisible")(?=[^>]*title="密码已重设")[^>]*>' 'A05 成功终态对话框'
if ($a05.Contains('class="success-layer"')) { throw 'A05 成功终态必须复用唯一 AppDialog owner' }
foreach ($token in @(
'.auth-plain-button {', 'margin: 0;', 'padding: 0;', 'border: 0;',
'background: transparent;', 'line-height: normal;', '.auth-plain-button::after {',
'.auth-plain-button:focus-visible {', '.auth-page.auth-page .auth-plain-button {',
'min-width: 48px;', 'min-height: 48px;'
)) {
if (-not $globalStyles.Contains($token)) { throw "认证原生按钮重置 owner 缺少:$token" }
}
Write-Output 'AUTH-ACCESSIBILITY-STATIC-CONTRACT PASS'
@@ -0,0 +1,28 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$evidencePath = Join-Path $root 'tests/manual/tac-android-accessibility-evidence.json'
if (-not (Test-Path -LiteralPath $evidencePath)) {
throw @'
ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED
- 当前只能证明认证页与 TAC 外壳具备静态语义、键盘焦点和 48px 操作目标,不能证明天爱拖动挑战可由 TalkBack、外接键盘或低精度操作完成。
- 后端/provider 尚未提供与同一租户、客户端、场景、手机号和 challengeId 原子绑定的非拖动等价验证路径。
- 尚无 MuMu Android 上的 TalkBack、外接键盘、返回键、动态播报、焦点恢复和 48dp 触控目标三方实测证据。
- 关闭条件:先落地安全等价的非拖动验证方式,再由三位评审在 MuMu Android 对同一候选包完成实测,并提交 tests/manual/tac-android-accessibility-evidence.json。
'@
}
$evidence = Get-Content -LiteralPath $evidencePath -Raw -Encoding UTF8 | ConvertFrom-Json
if ($evidence.schemaVersion -ne 1) { throw 'Android 无障碍证据 schemaVersion 必须为 1' }
if ($evidence.platform -ne 'MuMu Android') { throw 'Android 无障碍证据必须来自 MuMu Android' }
if ([string]$evidence.artifactSha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Android 无障碍证据必须绑定候选包 SHA256' }
if ([string]$evidence.accessibleChallenge.type -match '^(?i:slider|drag)$') { throw '拖动挑战不能作为无障碍等价路径' }
if ($evidence.accessibleChallenge.serverBound -ne $true) { throw '无障碍等价挑战必须由服务端原子绑定并消费' }
foreach ($check in @('talkBackPass', 'externalKeyboardPass', 'androidBackPass', 'liveRegionPass', 'focusRestorePass', 'touchTarget48dpPass')) {
if ($evidence.checks.PSObject.Properties[$check].Value -ne $true) { throw "Android 无障碍证据未通过:$check" }
}
$reviewers = @($evidence.reviewers | Sort-Object -Unique)
if ($reviewers.Count -ne 3) { throw 'Android 无障碍证据必须由三位不同评审者共同签署' }
Write-Output 'ANDROID-AUTH-ACCESSIBILITY-RELEASE PASS'
+161
View File
@@ -0,0 +1,161 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const savedTokens = [];
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = {
mode: "remote",
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const hasRemoteConfig = () => true;
const resolveRuntimeMode = () => "remote";
const AUTH_TAC_SCENE = Object.freeze({
SMS_LOGIN: "APP_SMS_LOGIN",
REGISTER: "APP_REGISTER",
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
});
const assertSmsCode = (value) => {
if (typeof value !== "string" || !/^\\d{4}$/.test(value)) throw new Error("请输入 4 位短信验证码");
return value;
};
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const session = {
getToken: () => "",
saveToken: (token) => globalThis.__savedTokens.push(token),
};
`;
globalThis.__savedTokens = savedTokens;
const requests = [];
let nextResponse = null;
let holdResponse = false;
globalThis.uni = {
request(options) {
requests.push(options);
const task = {
aborted: false,
abort() {
this.aborted = true;
options.fail({ errMsg: "request:fail abort" });
},
};
options.__task = task;
if (!holdResponse) queueMicrotask(() => options.success(nextResponse));
return task;
},
};
const { appApi, createRequestController } = await import(
toDataModuleUrl(`${prelude}\n${moduleBody}`)
);
const respond = (response) => {
nextResponse = response;
};
const sendSms = () => appApi.sendSmsCode({
sceneCode: "APP_REGISTER",
phone: "13800138000",
validToken: "ticket-1",
});
for (const invalid of [
{ statusCode: 200, data: null },
{ statusCode: 200, data: "<html>ok</html>" },
{ statusCode: 200, data: {} },
{ statusCode: 200, data: { code: "200", data: null } },
{ statusCode: 200, data: { code: null, data: null } },
{ statusCode: 200, data: { code: false, data: null } },
{ statusCode: 204, data: { code: 200, msg: "成功", data: null } },
]) {
respond(invalid);
await assert.rejects(sendSms(), /认证服务|响应|200/);
}
respond({ statusCode: 500, data: null });
await assert.rejects(sendSms(), /500/);
respond({ statusCode: 200, data: { code: 500, msg: "业务拒绝", data: null } });
await assert.rejects(sendSms(), /业务拒绝/);
respond({ statusCode: 200, data: { code: 200, msg: "操作成功", data: null } });
assert.strictEqual(await sendSms(), null, "合法 RVoid 必须精确解析为 null");
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(await sendSms(), null, "RVoid 未声明 data 必填,省略 data 仍必须解析为 null");
assert.strictEqual(requests.at(-1).header.clientid, "client-1");
respond({ statusCode: 200, data: null });
await assert.rejects(
appApi.resetPassword({
phone: "13800138000",
passwordHash: "a".repeat(32),
smsCode: "1234",
}),
/认证服务|响应/,
);
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(
await appApi.resetPassword({
phone: "13800138000",
passwordHash: "a".repeat(32),
smsCode: "1234",
}),
null,
"找回密码的 RVoid 省略 data 时仍必须解析为 null",
);
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-1" } },
});
const login = await appApi.loginWithSms({ phone: "13800138000", smsCode: "1234" });
assert.strictEqual(login.access_token, "token-1");
assert.deepStrictEqual(savedTokens, ["token-1"]);
holdResponse = true;
const requestController = createRequestController();
const cancelled = appApi.sendSmsCode(
{
sceneCode: "APP_REGISTER",
phone: "13800138000",
validToken: "ticket-1",
},
{ requestController },
);
const activeRequest = requests.at(-1);
const activeTask = activeRequest.__task;
assert.strictEqual(activeRequest.timeout, 15000, "认证请求必须限制弱网等待时间");
requestController.abort();
await assert.rejects(cancelled, (error) => error.code === "REQUEST_CANCELLED");
assert.strictEqual(activeTask.aborted, true, "页面离开时必须真正中止 RequestTask");
delete globalThis.uni;
delete globalThis.__savedTokens;
process.stdout.write("AUTH-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+122
View File
@@ -0,0 +1,122 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Read-ProjectFile {
param([string]$Path)
$fullPath = Join-Path $root $Path
if (-not (Test-Path -LiteralPath $fullPath)) { throw "缺少 TAC 集成文件:$Path" }
return Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8
}
function Require-Text {
param([string]$Content, [string]$Text, [string]$Label)
if (-not $Content.Contains($Text)) { throw "$Label 缺少:$Text" }
}
function Reject-Text {
param([string]$Content, [string]$Text, [string]$Label)
if ($Content.Contains($Text)) { throw "$Label 仍保留:$Text" }
}
$owner = Read-ProjectFile 'utils/auth-verification.js'
$adapter = Read-ProjectFile 'static/tac/js/jiapu-tac-adapter.js'
$component = Read-ProjectFile 'components/TacVerification.vue'
$api = Read-ProjectFile 'utils/api.js'
$a01 = Read-ProjectFile 'pages/auth/a01-entry.vue'
$a04 = Read-ProjectFile 'pages/auth/a04-register.vue'
$a05 = Read-ProjectFile 'pages/auth/a05-reset-password.vue'
$vendorAssets = [ordered]@{
'static/tac/css/tac.css' = '181694518971a9f991d551b6a6e6dab2bf750f940bfc1673a158213f92eedbe0'
'static/tac/js/tac.min.js' = '505f73c051908d7b805db458990790be3e91f792c4001cec0ea9377d7d302b55'
'static/tac/images/icon.png' = '53e37ffc5bb81c46e6306b7d61d2eaa3de57e47ca6cdb8d5210022ae815c21c2'
'static/tac/images/dun.jpeg' = 'd9178a8c4cca36e3df6c3acd7e895ce9d34dd60ef3f1cf4a70c94d4324ed96e7'
}
foreach ($entry in $vendorAssets.GetEnumerator()) {
$absolutePath = Join-Path $root $entry.Key
if (-not (Test-Path -LiteralPath $absolutePath)) { throw "缺少用户提供的 TAC 资产:$($entry.Key)" }
$actualHash = (Get-FileHash -LiteralPath $absolutePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $entry.Value) { throw "用户提供的 TAC 供应商资产发生漂移:$($entry.Key)" }
}
foreach ($token in @('lang="renderjs"', './static/tac/css/tac.css', './static/tac/js/tac.min.js', './static/tac/js/jiapu-tac-adapter.js', 'window.TAC', 'window.CaptchaConfig', 'window.JiapuTacAdapter', 'xhr.status >= 200 && xhr.status < 300', '$ownerInstance.callMethod', 'activeXhr', 'xhr.timeout = 15000', 'xhr.ontimeout', 'xhr.abort()', 'config.doSendRequest = (options) => this.sendStrictRequest(options)', '@media (max-width: 340px)')) {
Require-Text -Content $component -Text $token -Label 'TacVerification'
}
Reject-Text -Content $component -Text 'config.doSendRequest = this.sendStrictRequest' -Label '失去 renderjs 实例上下文的传输函数'
$staleGuard = 'if (generation !== this.generation || !this.context || this.context.visible !== true) return;'
if ([regex]::Matches($component, [regex]::Escape($staleGuard)).Count -lt 2) {
throw 'TacVerification 必须在资源加载成功与失败两条分支都拒绝过期代次'
}
Require-Text -Content $adapter -Text 'payload: { track:' -Label 'TAC payload.track 适配器'
foreach ($unsafe in @('code === 200 && response.data', 'passed !== false', "validToken: 'mock", 'mock-valid-token')) {
Reject-Text -Content ($owner + $adapter + $component + $api) -Text $unsafe -Label 'TAC 安全合同'
}
foreach ($method in @('getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword')) {
Require-Text -Content $api -Text "async $method" -Label '认证 API'
}
$authApiMatch = [regex]::Match($api, '(?s)async getCaptchaRequirement.*?(?=\s+async getProfile)')
if (-not $authApiMatch.Success) { throw '无法定位唯一认证 API 区段' }
Reject-Text -Content $authApiMatch.Value -Text "return { success: true }" -Label '认证短信 mock'
Reject-Text -Content $authApiMatch.Value -Text "mock-session-token" -Label '认证会话 mock'
Require-Text -Content $api -Text 'sceneCode, phone, validToken' -Label '短信票据请求'
foreach ($token in @('const requestAuth =', 'strictEnvelope: true', 'expectedStatus: 200', "hasOwnProperty.call(data, 'data')")) {
Require-Text -Content $api -Text $token -Label '认证严格响应 owner'
}
foreach ($token in @('const REQUEST_TIMEOUT_MS = 15000', 'export const createRequestController', "error?.code === 'REQUEST_CANCELLED'", 'task?.abort?.()')) {
Require-Text -Content $api -Text $token -Label '认证请求生命周期 owner'
}
foreach ($page in @($a01, $a04, $a05)) {
Require-Text -Content $page -Text '<TacVerification' -Label '认证页面'
Require-Text -Content $page -Text 'normalizeTacSuccess' -Label '认证页面'
Reject-Text -Content $page -Text '滑动验证待接口接入' -Label '认证页面占位'
Reject-Text -Content $page -Text '行为验证接口待接入' -Label '认证页面占位'
Reject-Text -Content $page -Text 'verification-layer' -Label '旧假验证浮层'
Require-Text -Content $page -Text 'isAuthPhone' -Label '认证手机号唯一校验器'
Reject-Text -Content $page -Text '/^1\d{10}$/' -Label '认证页面重复手机号规则'
Require-Text -Content $page -Text 'submitting: submitting.value || sendingCode.value' -Label '认证异步返回守卫'
Require-Text -Content $page -Text '"block-submitting"' -Label '认证异步返回守卫'
Require-Text -Content $page -Text 'let pageActive = true' -Label '认证页面卸载代次'
Require-Text -Content $page -Text 'pageActive = false' -Label '认证页面卸载代次'
Require-Text -Content $page -Text 'createRequestController' -Label '认证页面可取消请求'
Require-Text -Content $page -Text 'isRequestCancelled' -Label '认证页面取消静默处理'
Require-Text -Content $page -Text 'authRequestController.abort()' -Label '认证页面离页中止请求'
if ([regex]::Matches($page, [regex]::Escape('{ requestController: authRequestController }')).Count -lt 3) {
throw '认证页面的策略、短信与最终提交必须都绑定页面请求控制器'
}
Require-Text -Content $page -Text 'const requestedPhone = phone.value' -Label '认证手机号请求快照'
Require-Text -Content $page -Text 'subject: requestedPhone' -Label '认证手机号请求快照'
Require-Text -Content $page -Text ':disabled="sendingCode || cooldownSeconds > 0 || submitting"' -Label '短信流程手机号锁定'
if ([regex]::Matches($page, [regex]::Escape('if (!pageActive) return;')).Count -lt 3) {
throw '认证页面必须在策略、短信与最终提交的异步回流前拒绝卸载后的旧结果'
}
}
foreach ($pageContract in @(
@{ Content = $a01; Loading = '登录中…' },
@{ Content = $a04; Loading = '注册中…' },
@{ Content = $a05; Loading = '提交中…' }
)) {
Require-Text -Content $pageContract.Content -Text '请求中…' -Label '认证短信加载文案'
Require-Text -Content $pageContract.Content -Text $pageContract.Loading -Label '认证提交加载文案'
}
foreach ($token in @('const blockBusyAction = () =>', 'if (blockBusyAction()) return;', 'const prepareForgotPassword = () => {', 'const prepareRegister = () => {')) {
Require-Text -Content $a01 -Text $token -Label 'A01 忙碌动作门禁'
}
foreach ($token in @('AUTH_TAC_SCENE.SMS_LOGIN', 'appApi.loginWithSms', '/^\d{4}$/', 'PASSWORD_TAC_BLOCKED_MESSAGE')) {
Require-Text -Content $a01 -Text $token -Label 'A01'
}
Reject-Text -Content $a01 -Text '/^\d{6}$/' -Label 'A01 六位短信码'
foreach ($token in @('AUTH_TAC_SCENE.REGISTER', 'v-model.trim="verificationCode"', 'appApi.registerWithPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'goRoot("G01")')) {
Require-Text -Content $a04 -Text $token -Label 'A04'
}
foreach ($token in @('AUTH_TAC_SCENE.FORGOT_PASSWORD', 'appApi.resetPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'await appApi.resetPassword')) {
Require-Text -Content $a05 -Text $token -Label 'A05'
}
Reject-Text -Content $a05 -Text '/^\d{6}$/' -Label 'A05 六位短信码'
Write-Output 'AUTH-TAC-INTEGRATION-CONTRACT PASS'
+128
View File
@@ -0,0 +1,128 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$json = Get-Content -LiteralPath (Join-Path $root 'APP.openapi.json') -Raw -Encoding UTF8 | ConvertFrom-Json
$yaml = Get-Content -LiteralPath (Join-Path $root 'APP.openapi.yaml') -Raw -Encoding UTF8
$blockers = [System.Collections.Generic.List[string]]::new()
function Require-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $json.paths.PSObject.Properties[$Path]
if ($null -eq $pathProperty -or $null -eq $pathProperty.Value.PSObject.Properties[$Method]) {
throw "认证源合同缺少操作:$($Method.ToUpperInvariant()) $Path"
}
if ($yaml -notmatch [regex]::Escape(" $Path`:") -or $yaml -notmatch "(?m)^ $Method`:\s*$") {
throw "YAML 认证源合同缺少操作:$($Method.ToUpperInvariant()) $Path"
}
return $pathProperty.Value.PSObject.Properties[$Method].Value
}
function Require-Schema {
param([string]$Name)
$property = $json.components.schemas.PSObject.Properties[$Name]
if ($null -eq $property) { throw "认证源合同缺少 schema$Name" }
if ($yaml -notmatch "(?m)^ $([regex]::Escape($Name)):\s*$") { throw "YAML 认证源合同缺少 schema$Name" }
return $property.Value
}
function Assert-ExactSet {
param([object[]]$Actual, [object[]]$Expected, [string]$Label)
$actualSet = @($Actual | ForEach-Object { [string]$_ } | Sort-Object -Unique)
$expectedSet = @($Expected | ForEach-Object { [string]$_ } | Sort-Object -Unique)
if (($actualSet -join ',') -ne ($expectedSet -join ',')) {
throw "$Label 漂移:actual=[$($actualSet -join ',')] expected=[$($expectedSet -join ',')]"
}
}
$operations = @(
@('/captcha/require', 'get'),
@('/captcha/challenge', 'post'),
@('/captcha/verify', 'post'),
@('/genealogy/app/auth/sms/code', 'post'),
@('/genealogy/app/auth/login', 'post'),
@('/genealogy/app/auth/login/sms', 'post'),
@('/genealogy/app/auth/register', 'post'),
@('/genealogy/app/auth/password/reset', 'put')
)
foreach ($entry in $operations) { [void](Require-Operation -Path $entry[0] -Method $entry[1]) }
$expectedVerificationScenes = @('APP_SMS_LOGIN', 'APP_REGISTER', 'APP_FORGOT_PASSWORD', 'APP_PHONE_CHANGE', 'APP_ACCOUNT_DEACTIVATE')
foreach ($schemaName in @('VerificationChallengeBody', 'VerificationCheckBody')) {
$schema = Require-Schema -Name $schemaName
Assert-ExactSet -Actual @($schema.properties.sceneCode.enum) -Expected $expectedVerificationScenes -Label "$schemaName.sceneCode"
}
$check = Require-Schema -Name 'VerificationCheckBody'
Assert-ExactSet -Actual @($check.properties.payload.oneOf.'$ref') -Expected @('#/components/schemas/TianaiVerificationPayload', '#/components/schemas/SystemImageVerificationPayload') -Label 'VerificationCheckBody.payload.oneOf'
$requiredCheckFields = @('tenantId', 'clientId', 'sceneCode', 'subject', 'challengeId', 'providerCode', 'captchaType', 'payload')
$missingCheckFields = @($requiredCheckFields | Where-Object { $_ -notin @($check.required) })
if ($missingCheckFields.Count -gt 0) {
$blockers.Add("VerificationCheckBody 未强制字段:$($missingCheckFields -join '、')")
}
if ($check.additionalProperties -ne $false) {
$blockers.Add('VerificationCheckBody 未设置 additionalProperties=false,服务端校验边界仍可接受未声明字段。')
}
if (@($check.oneOf).Count -lt 2 -or $check.discriminator.propertyName -ne 'providerCode') {
$blockers.Add('VerificationCheckBody 未用 providerCode 判别至少两个 oneOf 分支,providerCode、captchaType 与 payload 形态无法被原子约束。')
}
$tianaiPayload = Require-Schema -Name 'TianaiVerificationPayload'
Assert-ExactSet -Actual @($tianaiPayload.required) -Expected @('track') -Label 'TianaiVerificationPayload.required'
if ($tianaiPayload.properties.track.'$ref' -ne '#/components/schemas/TianaiCaptchaTrack') { throw '天爱校验载荷必须唯一包装为 payload.track' }
if ($tianaiPayload.additionalProperties -ne $false) {
$blockers.Add('TianaiVerificationPayload 未设置 additionalProperties=false,历史直传字段仍可能绕过 payload.track 约束。')
}
$systemImagePayload = Require-Schema -Name 'SystemImageVerificationPayload'
if ($systemImagePayload.additionalProperties -ne $false) {
$blockers.Add('SystemImageVerificationPayload 未设置 additionalProperties=false,系统图形验证码载荷边界未闭合。')
}
$track = Require-Schema -Name 'TianaiCaptchaTrack'
Assert-ExactSet -Actual @($track.required) -Expected @('bgImageWidth', 'bgImageHeight', 'startTime', 'stopTime', 'trackList') -Label 'TianaiCaptchaTrack.required'
if ($track.properties.trackList.minItems -ne 1) { throw '天爱行为轨迹不得为空' }
$smsCode = Require-Schema -Name 'SmsCodeBody'
Assert-ExactSet -Actual @($smsCode.required) -Expected @('clientId', 'grantType', 'tenantId', 'sceneCode', 'phone', 'validToken') -Label 'SmsCodeBody.required'
if ($smsCode.additionalProperties -ne $false) { throw 'SmsCodeBody 必须拒绝历史供应商字段' }
$expectedPublicSmsScenes = @('APP_SMS_LOGIN', 'APP_REGISTER', 'APP_FORGOT_PASSWORD', 'APP_ACCOUNT_DEACTIVATE')
$actualPublicSmsScenes = @($smsCode.properties.sceneCode.enum | ForEach-Object { [string]$_ } | Sort-Object -Unique)
$expectedPublicSmsScenes = @($expectedPublicSmsScenes | Sort-Object -Unique)
if (($actualPublicSmsScenes -join ',') -ne ($expectedPublicSmsScenes -join ',')) {
$blockers.Add('公共 SmsCodeBody.sceneCode 必须删除 APP_PHONE_CHANGE;换绑发码只能由需要 SaToken 的专用 /auth/phone/sms/code operation 持有。')
}
$smsSecretProperty = $json.components.schemas.PSObject.Properties['SmsCodeSecret']
if ($null -eq $smsSecretProperty) {
$blockers.Add('缺少全认证场景共用的 SmsCodeSecret;当前四位码必须原子升级为严格六位 ASCII 数字,不能保留 4/6 双接受。')
} else {
$smsSecret = $smsSecretProperty.Value
if ($smsSecret.type -ne 'string' -or $smsSecret.writeOnly -ne $true -or
[int]$smsSecret.minLength -ne 6 -or [int]$smsSecret.maxLength -ne 6 -or
[string]$smsSecret.pattern -ne '^[0-9]{6}$' -or $smsSecret.example) {
$blockers.Add('SmsCodeSecret 必须是无示例、保留前导零的 writeOnly 六位 ASCII 数字字符串。')
}
}
foreach ($schemaName in @('SmsLoginBody', 'PasswordRegisterBody', 'PasswordResetBody')) {
$schema = Require-Schema -Name $schemaName
$actualRef = [string]$schema.properties.smsCode.'$ref'
if ($actualRef -ne '#/components/schemas/SmsCodeSecret') {
$blockers.Add("$schemaName.smsCode 必须引用唯一 SmsCodeSecret,禁止继续内联四位码或接受双长度。")
}
}
$passwordLogin = Require-Schema -Name 'PasswordLoginBody'
$passwordFields = @($passwordLogin.properties.PSObject.Properties.Name)
$passwordRequired = @($passwordLogin.required)
if ('validToken' -notin $passwordFields -or 'validToken' -notin $passwordRequired) {
$blockers.Add('PasswordLoginBody 未定义并强制消费 validToken,密码登录无法形成服务端 TAC 闭环,客户端先滑后登录仍可被绕过。')
}
if ($blockers.Count -gt 0) {
$details = $blockers | ForEach-Object { "- $_" }
throw (@(
'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
$details
'- 关闭条件:后端同步更新同版本 JSON/YAML;校验体按 providerCode 严格区分供应商并拒绝缺字段/多余字段;密码登录原子消费绑定租户、客户端、场景、手机号的一次性 TAC 票据;全活动短信码原子迁移为六位;APP_PHONE_CHANGE 改由专用受保护发码 operation;全部部署到 HTTPS 环境并通过反向用例。'
) -join [Environment]::NewLine)
}
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT PASS'
+132
View File
@@ -0,0 +1,132 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const source = fs.readFileSync(
path.join(__dirname, "../utils/auth-verification.js"),
"utf8",
);
const {
AUTH_TAC_SCENE,
PASSWORD_TAC_BLOCKED_MESSAGE,
isAuthPhone,
assertSmsCode,
normalizeCaptchaRequirement,
createTacRenderContext,
normalizeTacSuccess,
} = await import(toDataModuleUrl(source));
assert.deepStrictEqual(
{ ...AUTH_TAC_SCENE },
{
SMS_LOGIN: "APP_SMS_LOGIN",
REGISTER: "APP_REGISTER",
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
},
"认证 TAC 场景必须由受保护 OpenAPI 的唯一枚举拥有",
);
assert.match(PASSWORD_TAC_BLOCKED_MESSAGE, /服务端|安全验证|验证码登录/);
assert.strictEqual(isAuthPhone("13800138000"), true);
for (const invalid of ["", "12800138000", "1380013800", "138001380000", 13800138000, null]) {
assert.strictEqual(isAuthPhone(invalid), false, `非法认证手机号被放行:${invalid}`);
}
assert.strictEqual(assertSmsCode("1234"), "1234");
for (const invalid of ["", "123", "12345", "12a4", 1234, null]) {
assert.throws(() => assertSmsCode(invalid), /4 位短信验证码/);
}
const requirement = normalizeCaptchaRequirement(
{
required: true,
providerCode: "TIANAI",
captchaType: "SLIDER",
sceneCode: AUTH_TAC_SCENE.REGISTER,
ttlSeconds: 300,
},
AUTH_TAC_SCENE.REGISTER,
);
assert.deepStrictEqual(requirement, {
required: true,
providerCode: "TIANAI",
captchaType: "SLIDER",
sceneCode: AUTH_TAC_SCENE.REGISTER,
ttlSeconds: 300,
});
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, required: false }, AUTH_TAC_SCENE.REGISTER),
/未要求行为验证|票据/,
);
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, sceneCode: AUTH_TAC_SCENE.SMS_LOGIN }, AUTH_TAC_SCENE.REGISTER),
/场景/,
);
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, providerCode: "OTHER" }, AUTH_TAC_SCENE.REGISTER),
/TIANAI/,
);
assert.throws(
() => normalizeCaptchaRequirement({ ...requirement, captchaType: "math" }, AUTH_TAC_SCENE.REGISTER),
/验证码类型/,
);
const context = createTacRenderContext({
requestId: "register-1",
baseUrl: "https://backend-api.ddxcjp.cn/",
clientId: "client-1",
tenantId: "000000",
sceneCode: AUTH_TAC_SCENE.REGISTER,
subject: "13800138000",
requirement,
});
assert.deepStrictEqual(context, {
requestId: "register-1",
baseUrl: "https://backend-api.ddxcjp.cn",
challengeUrl: "https://backend-api.ddxcjp.cn/captcha/challenge",
verifyUrl: "https://backend-api.ddxcjp.cn/captcha/verify",
clientId: "client-1",
tenantId: "000000",
sceneCode: AUTH_TAC_SCENE.REGISTER,
subject: "13800138000",
providerCode: "TIANAI",
captchaType: "SLIDER",
});
assert.throws(
() => createTacRenderContext({ ...context, baseUrl: "http://backend-api.ddxcjp.cn", requirement }),
/HTTPS/,
);
assert.throws(
() => createTacRenderContext({ ...context, subject: "1380013800", requirement }),
/手机号/,
);
assert.deepStrictEqual(
normalizeTacSuccess(
{ requestId: "register-1", validToken: "ticket-1", expireSeconds: 300 },
"register-1",
),
{ requestId: "register-1", validToken: "ticket-1", expireSeconds: 300 },
);
assert.throws(
() => normalizeTacSuccess({ requestId: "stale", validToken: "ticket-1" }, "register-1"),
/已过期|不匹配/,
);
assert.throws(
() => normalizeTacSuccess({ requestId: "register-1", validToken: "" }, "register-1"),
/票据/,
);
process.stdout.write("AUTH-VERIFICATION-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+57 -8
View File
@@ -1,4 +1,4 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$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')
@@ -23,7 +23,7 @@ foreach ($method in @('getCurrentGenealogyId', 'setCurrentGenealogyId', 'clearCu
}
$api = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'utils/api.js')
foreach ($method in @('unwrapResponse', 'sendSmsCode', 'loginWithPassword', 'loginWithSms')) {
foreach ($method in @('unwrapResponse', 'getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword')) {
if ($api -notmatch [regex]::Escape($method)) {
throw "Missing auth API method: $method"
}
@@ -53,11 +53,46 @@ 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', 'submitAncestor', 'genealogyId')) {
foreach ($token in @('currentStep.value = "ancestor"', 'submitAncestor', 'genealogyId')) {
if ($createFlow -notmatch [regex]::Escape($token)) {
throw "Missing G03 first-person flow: $token"
}
}
if ($config -notmatch "mode:\s*'mock'" -or $config -notmatch "baseUrl:\s*'https://backend-api\.ddxcjp\.cn'") {
throw 'Runtime config must retain mock isolation while owning the new HTTPS backend base URL'
}
if ($config -match '182\.61\.18\.23|http://backend-api\.ddxcjp\.cn|https://backend-api\.ddxcjp\.cn/') {
throw 'Runtime config retains an obsolete, insecure or trailing-slash backend URL'
}
if (-not $api.Contains('loginResult?.access_token') -or $api -match 'loginResult\?\.(token|accessToken|tokenValue)') {
throw 'Session adapter must consume only the current AppLoginVo.access_token contract'
}
if ($api -match 'mock-session-token|mockResult') { throw '认证链路不得生成本地伪会话或伪票据' }
if (([regex]::Matches($api, "return saveLogin\(result\)")).Count -ne 3) {
throw '密码登录、短信登录与注册必须共用唯一 AppLoginVo 会话适配器'
}
if ($api -notmatch 'import \{ hasRemoteConfig, resolveRuntimeMode, runtimeConfig \}' -or ([regex]::Matches($api, 'requireRemoteAuth\(\)')).Count -ne 6) {
throw '六个认证传输必须通过共享运行模式解析器失败关闭'
}
if ($api -match 'if \(isMockMode\(\)\)') {
throw 'Auth transports must not treat every non-mock mode as remote'
}
foreach ($token in @(
'async auditApplication(genealogyId, applicationId, { status, auditRemark = '''' })',
'data: { status, auditRemark }',
'payload.relationDesc',
'payload.applyReason'
)) {
if (-not $api.Contains($token)) { throw "G-series API adapter contract missing: $token" }
}
if ($api.Contains('data: { approved }') -or $api.Contains('payload.message')) {
throw 'G-series API adapter retains the deleted approved/message payload contract'
}
if ($api -notmatch '!/\^\[12\]\$/\.test\(status\)' -or $api -notmatch 'Array\.from\(auditRemark\)\.length > 500') {
throw 'G-series audit adapter does not enforce the OpenAPI status pattern or remark length'
}
if ($createFlow -match 'step=ancestor|query\?\.step') { throw 'G03 first-person flow must not restore the retired route step contract' }
if ($createFlow -match 'createPerson') { throw 'G03 must not retain the removed createPerson entrypoint' }
foreach ($route in @('pages/family/f04-article-list', 'pages/family/f02-publish-feed')) {
if ($pages -notmatch [regex]::Escape($route)) {
@@ -74,8 +109,18 @@ foreach ($pageFile in @('pages/genealogy/g05-genealogy-overview.vue', 'pages/tre
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') {
throw "Missing genealogy context in $pageFile"
} elseif ($pageFile -eq 'pages/tree/t01-tree-overview.vue') {
if ($page -notmatch 'genealogyContext') {
throw 'T01 must retain the selected genealogy fallback until its domain-data phase'
}
} else {
if ($page -notmatch 'const\s+genealogyId\s*=\s*ref\(["'']["'']\)' -or
$page -notmatch 'const\s+personId\s*=\s*ref\(["'']["'']\)' -or
$page -notmatch 'query\.genealogyId' -or
$page -notmatch 'query\.personId' -or
$page -match 'genealogyContext') {
throw 'T03 must use its validated genealogyId/personId route identity without mutable global fallback'
}
}
}
@@ -93,9 +138,12 @@ foreach ($pageFile in @('pages/genealogy/g10-application-review.vue', 'pages/not
}
$applications = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/genealogy/g10-application-review.vue')
if ($applications -notmatch 'genealogyContext') {
throw 'Missing genealogy context in applications page'
foreach ($token in @('const genealogyId = ref("")', 'genealogyId.value = String(query.genealogyId || "")', 'getGenealogyFixtureAccess(genealogyId.value)')) {
if ($applications -notmatch [regex]::Escape($token)) {
throw "Applications page must use its explicit fail-closed route context: $token"
}
}
if ($applications -match 'genealogyContext') { throw 'Applications page must not bypass its explicit route context through global genealogy context' }
$notifications = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/notification/n01-message-center.vue')
foreach ($method in @('openNotice', 'markAllRead', 'notice-state--list')) {
@@ -104,11 +152,12 @@ foreach ($method in @('openNotice', 'markAllRead', 'notice-state--list')) {
if ($notifications -match "@/utils/api\.js|\bappApi\b") { throw 'Notification design page must not connect the API layer' }
$family = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/family/f01-family-feed.vue')
foreach ($token in @('genealogyContext', 'openSection', 'pages/family/f04-article-list', 'pages/family/f07-album-list', 'pages/records/r05-ritual-list', 'pages/records/r10-memo-list', 'pages/family/f02-publish-feed')) {
foreach ($token in @('genealogyContext', 'openSection', 'articles: "F04"', 'albums: "F07"', 'rituals: "R05"', 'memos: "R10"', 'openPage("F02"')) {
if ($family -notmatch [regex]::Escape($token)) {
throw "Missing family content flow: $token"
}
}
if ($family -match '/pages/') { throw 'F01 family content flow must use the unique navigation registry' }
$profile = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/profile/m01-profile-home.vue')
foreach ($token in @('profile-state--ready', 'menuItems', 'toProfile')) {
+498 -9
View File
@@ -38,11 +38,21 @@ if ($designDocument -match '阶段 0 正在执行') {
if ($mappingDocument -match '当前阶段:阶段 0——迁移现行页面与业务事实') {
throw '接口与页面映射总表仍把阶段 0 迁移写成当前任务'
}
if ($mappingDocument -notmatch '阶段 0 已完成;导航栈与 T01 专项设计已完成三人终审,业务代码尚未实施') {
throw '接口与页面映射总表缺少阶段 0 完成、书面设计收口及业务代码未实施的准确边界'
if ($mappingDocument -notmatch '家谱工作区、G03 原子创建、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑三人审查及 OpenAPI 红灯已完成' -or
$mappingDocument -notmatch 'T01、TAC、工作区、G03、帮助、profile、通知、logout、password 与 phone-change 后端接口门禁当前红灯') {
throw '接口与页面映射总表没有登记导航、TAC 客户端、后端红灯与 MuMu 边界'
}
if ($overviewDocument -notmatch '下一步从导航失败测试开始') {
throw '项目总览没有写明当前精确实施入口'
if ($designDocument -notmatch '导航任务 1—10 的静态实施和零债务门禁已经完成' -or
$designDocument -notmatch '当前 SFC 分段词法扫描的迁移债务已经清零' -or
$designDocument -notmatch 'MIGRATION-DEBT=0') {
throw '治理设计没有同步导航静态零债务结果'
}
if ($mappingDocument -notmatch '实际 `64/64` 个 Vue 文件') {
throw '接口与页面映射总表没有同步退役通用页面后的响应式覆盖实数'
}
if ($overviewDocument -notmatch 'G01/G05、G03、M06、M01/M02/M03 个人资料读写、N01/N02/M01/G01 通知域、M10 退出域、M04 密码凭证域与 M05 换绑域已完成三人接口审查和失败门禁' -or
$overviewDocument -notmatch '继续审查下一个不依赖现有红灯的业务域') {
throw '项目总览没有写明领域上下文与 M07 完成后的当前精确实施入口'
}
if ($overviewDocument -notmatch 'API-T01-001') {
throw '项目总览没有登记 T01 后端接口门禁'
@@ -53,14 +63,32 @@ if ($overviewDocument -match '当前 MuMu[^\r\n]*在线') {
if ($overviewDocument -notmatch '阶段 0 验收时的 MuMu 证据') {
throw '项目总览缺少带时间边界的 MuMu 验收证据'
}
$visualDocument = $documentContents['docs/视觉资产与构建基线.md']
if ($visualDocument -notmatch '`static/tac/` 当前共 5 个文件' -or
$visualDocument -notmatch '4 个后端提供的供应商文件' -or
$visualDocument -notmatch '`static/tac/js/jiapu-tac-adapter.js`' -or
$visualDocument -notmatch 'tests/auth-tac-integration-contract.ps1') {
throw '视觉资产基线没有登记 TAC 供应商资产、项目适配器及唯一校验入口'
}
if ($mappingDocument -notmatch 'A01、A04、A05 已接入同一个 `TacVerification`' -or
$mappingDocument -notmatch '不能把本地滑动成功冒充服务端验证' -or
$mappingDocument -notmatch '密码登录入口保持不可用') {
throw '接口页面映射没有准确登记 A01/A04/A05 的 TAC 客户端状态与密码登录硬关闭'
}
if ($mappingDocument -notmatch '任务 5 实施前' -or $mappingDocument -notmatch '这只是历史视觉基线' -or $mappingDocument -notmatch '任务 4—6 的当前代码仍须.*MuMu 流程矩阵') {
throw '接口页面映射把历史 MuMu 证据越界成了当前代码验收'
}
# 导航设计已经三人终审,文档合同必须保护其所有权、根语义、公开方法和阶段边界,不能只检查“有一份计划”。
foreach ($owner in @('utils/navigation-routes.js', 'utils/navigation.js')) {
if ($designDocument -notmatch [regex]::Escape($owner) -or $planDocument -notmatch [regex]::Escape($owner)) {
throw "导航设计或实施计划缺少唯一所有者:$owner"
}
if (-not (Test-Path -LiteralPath (Join-Path $root $owner) -PathType Leaf)) {
throw "导航唯一所有者尚未真实落地:$owner"
}
}
foreach ($semanticMethod in @('openPage', 'replaceStep', 'goBack', 'returnTo', 'finishPage', 'goRoot')) {
foreach ($semanticMethod in @('openPage', 'goBack', 'returnTo', 'finishPage', 'goRoot')) {
if ($designDocument -notmatch "(?m)^$([regex]::Escape($semanticMethod))\(") {
throw "导航设计缺少公开语义方法:$semanticMethod"
}
@@ -86,14 +114,475 @@ if ($planDocument -notmatch '导航阶段允许只迁移 T01 的现有导航调
if ($planDocument -notmatch 'T03_CONTEXT_CONFLICT') {
throw '实施计划缺少 T03 跨家谱上下文冲突规则'
}
if ($planDocument -notmatch 'T03_STACK_CONFLICT') {
throw '实施计划缺少 T03 历史重复实例冲突规则'
}
if ($planDocument -notmatch 'returnTo\(routeKey, targetParams = \{\}\)' -or $planDocument -notmatch 'finishPage\(routeKey, targetParams, result\)') {
throw '实施计划没有锁定普通返回与唯一完成结果入口'
}
if ($planDocument -match 'returnTo\([^\)\r\n]*,\s*result\s*\)') {
throw '实施计划重新引入了 returnTo 携带流程结果的旧入口'
}
if ($planDocument -notmatch 'own enumerable data properties' -or $planDocument -notmatch 'WeakMap') {
throw '实施计划缺少严格数据属性快照或页面实例弱身份令牌合同'
}
$navigationFoundation = [regex]::Match(
$planDocument,
'(?s)### 任务 1:建立路由语义注册表.*?(?=### 任务 3:迁移共享页头、底栏并退役通用母版)'
).Value
if ([string]::IsNullOrWhiteSpace($navigationFoundation) -or $navigationFoundation -match '- \[ \]') {
throw '实施计划没有把已经验证的导航任务 1/2 精确标为完成'
}
if ($planDocument -notmatch 'M04 密码凭证任务 33、M05 手机号换绑任务 34 和 G03 原子创建任务 35 已完成三人审查及 OpenAPI 红灯' -or
$planDocument -notmatch '等待后端期间转向 G/F/R 下一域') {
throw '实施计划没有登记领域上下文与 M07 完成后的独立实施入口'
}
$taskThree = [regex]::Match(
$planDocument,
'(?s)### 任务 3:迁移共享页头、底栏并退役通用母版.*?(?=### 任务 4:迁移认证导航)'
).Value
if ([string]::IsNullOrWhiteSpace($taskThree) -or $taskThree -match '- \[ \] \*\*步骤 [123]') {
throw '实施计划没有把任务 3 的静态实施步骤精确标为完成'
}
if ($taskThree -notmatch 'MuMu 复核待执行' -or $taskThree -notmatch '39') {
throw '实施计划没有明确任务 3 的 MuMu 边界或剩余迁移债务'
}
$taskFour = [regex]::Match(
$planDocument,
'(?s)### 任务 4:迁移认证导航.*?(?=### 任务 5:迁移 G 系列导航)'
).Value
if ([string]::IsNullOrWhiteSpace($taskFour) -or $taskFour -match '- \[ \] \*\*步骤 [123]') {
throw '实施计划没有把任务 4 的静态实施步骤精确标为完成'
}
if ($taskFour -notmatch '- \[ \] \*\*步骤 4:在 MuMu 复核认证流程' -or $taskFour -notmatch '35') {
throw '实施计划没有明确任务 4 的 MuMu 边界或剩余迁移债务'
}
if ($taskFour -notmatch 'tests/navigation-flow-contract.ps1' -or $taskFour -match 'navigation-auth-flow-contract') {
throw '认证导航没有消费跨批次唯一流程合同'
}
$taskFive = [regex]::Match(
$planDocument,
'(?s)### 任务 5:迁移 G 系列导航.*?(?=### 任务 6:迁移 T 系列并实现 T03 单实例轨迹)'
).Value
if ([string]::IsNullOrWhiteSpace($taskFive) -or $taskFive -match '- \[ \] \*\*步骤 [1234]') {
throw '实施计划没有把任务 5 的静态实施步骤精确标为完成'
}
if ($taskFive -notmatch '- \[ \] \*\*步骤 5:完成 G 系列 MuMu 矩阵' -or $taskFive -notmatch '29 个文件') {
throw '实施计划没有明确任务 5 的 MuMu 边界或剩余迁移债务'
}
if ($taskFive -match 'finishPage\("G05"' -or $taskFive -match 'finishPage\("G09"') {
throw '实施计划重新引入了 G 系列本地预览的伪后端完成结果'
}
if ($taskFive -notmatch '未知值失败关闭' -or $taskFive -notmatch '每批 50 行渲染') {
throw '实施计划没有登记 G11/G12 的最终静态合同'
}
$taskSix = [regex]::Match(
$planDocument,
'(?s)### 任务 6:迁移 T 系列并实现 T03 单实例轨迹.*?(?=### 任务 7:迁移 F 系列导航)'
).Value
if ([string]::IsNullOrWhiteSpace($taskSix) -or $taskSix -match '- \[ \] \*\*步骤 [123]') {
throw '实施计划没有把任务 6 的静态实施步骤精确标为完成'
}
if ($taskSix -notmatch '- \[ \] \*\*步骤 4:完成 T 系列 MuMu 矩阵' -or $taskSix -notmatch '22 个页面/表单组件文件') {
throw '实施计划没有明确任务 6 的 MuMu 边界或剩余迁移债务'
}
if ($taskSix -match 'finishPage\(' -or $taskSix -match 'relative-created|member-updated|relationship-updated') {
throw '实施计划在 T 系列本地预览阶段伪造了服务端写成功结果'
}
foreach ($taskSixContract in @(
'本地预览',
'尚未提交服务器',
'`goBack()`',
'宿主页路由身份',
'页内活动成员',
'listTreeMemberFixtures(genealogyId)',
'findTreeMemberFixture(genealogyId, personId)',
'深拷贝快照',
'跨谱隔离',
'失败关闭',
'tests/tree-member-fixture-runtime-smoke.js',
'PowerShell `122/122`',
'Node 语法 `35/35`',
'纯 Node `7/7`'
)) {
if ($taskSix -notmatch [regex]::Escape($taskSixContract)) {
throw "实施计划缺少任务 6 最终合同:$taskSixContract"
}
}
$taskSeven = [regex]::Match(
$planDocument,
'(?s)### 任务 7:迁移 F 系列导航.*?(?=### 任务 8:迁移 R 系列导航)'
).Value
if ($planDocument -match 'assert\.deepEqual\(consumeNavigationResult\("F04"\),\s*\{\s*operation:\s*"article-created"') {
throw '实施计划仍保留已经废止的 F04 伪写结果示例'
}
if ([string]::IsNullOrWhiteSpace($taskSeven) -or $taskSeven -match '- \[ \] \*\*步骤 [12345]') {
throw '实施计划没有把任务 7 的静态实施步骤精确标为完成'
}
if ($taskSeven -notmatch '- \[ \] \*\*步骤 6:完成 F 系列 MuMu 矩阵' -or $taskSeven -notmatch '13 个页面/表单组件文件') {
throw '实施计划没有明确任务 7 的 MuMu 边界或剩余迁移债务'
}
foreach ($taskSevenContract in @(
'F01—F10',
'跨谱',
'WRITE_UNAVAILABLE',
'feed-created/article-created/article-updated/media-uploaded',
'PowerShell `122/122`',
'Node 语法 `36/36`',
'纯 Node `8/8`',
'MIGRATION-DEBT=13'
)) {
if ($taskSeven -notmatch [regex]::Escape($taskSevenContract)) {
throw "实施计划缺少任务 7 最终合同:$taskSevenContract"
}
}
$taskEight = [regex]::Match(
$planDocument,
'(?s)### 任务 8:迁移 R 系列导航.*?(?=### 任务 9)'
).Value
foreach ($taskEightContract in @(
'R03/R04 归属 `relative-records`',
'`giftId` 全量退役为 `relativeId`',
'`ritualId` 全量退役为 `ceremonyId`',
'R09 必须保持硬关闭',
'resultOperations',
'本地预览,尚未提交服务器',
'只读复合身份 owner',
'MuMu'
)) {
if ($taskEight -notmatch [regex]::Escape($taskEightContract)) {
throw "实施计划缺少任务 8 三方审查结论:$taskEightContract"
}
}
if ([string]::IsNullOrWhiteSpace($taskEight) -or $taskEight -match '- \[ \] \*\*步骤 [123456]') {
throw '实施计划没有把任务 8 的静态实施步骤精确标为完成'
}
if ($taskEight -notmatch '- \[ \] \*\*步骤 7:完成 R 系列 MuMu 矩阵' -or $taskEight -notmatch 'MIGRATION-DEBT=6') {
throw '实施计划没有明确任务 8 的 MuMu 边界或剩余迁移债务'
}
foreach ($taskEightVerification in @('PowerShell `122/122`', 'Node 语法 `37/37`', '纯 Node `9/9`')) {
if ($taskEight -notmatch [regex]::Escape($taskEightVerification)) {
throw "实施计划缺少任务 8 最新验证:$taskEightVerification"
}
}
$taskNine = [regex]::Match(
$planDocument,
'(?s)### 任务 9:迁移 N/M 系列与安全通知目标.*?(?=### 任务 10:关闭导航阶段)'
).Value
if ([string]::IsNullOrWhiteSpace($taskNine) -or $taskNine -match '- \[ \] \*\*步骤 [1234]') {
throw '实施计划没有把任务 9 静态实施步骤精确标为完成'
}
if ($taskNine -notmatch '- \[ \] \*\*步骤 5:完成 N/M 系列 MuMu 矩阵' -or $taskNine -notmatch 'MIGRATION-DEBT=1') {
throw '实施计划没有明确任务 9 的 MuMu 边界或最后一项导航债务'
}
$taskTen = [regex]::Match(
$planDocument,
'(?s)### 任务 10:关闭导航阶段.*?(?=---\s*## 第二阶段:T01 大规模世系树)'
).Value
if ([string]::IsNullOrWhiteSpace($taskTen) -or $taskTen -match '- \[ \] \*\*步骤 [12]') {
throw '实施计划没有把任务 10 的静态门禁步骤精确标为完成'
}
if ($taskTen -notmatch '- \[ \] \*\*步骤 3:执行 NAV-MUMU-01 至 NAV-MUMU-08' -or
$taskTen -notmatch 'MIGRATION-DEBT=0' -or
$taskTen -notmatch 'TreeMemberForm\.vue') {
throw '实施计划没有记录导航零债务、孤儿删除或 MuMu 待验边界'
}
$taskEleven = [regex]::Match(
$planDocument,
'(?s)### 任务 11:向后端提交规范图合同并建立接口门禁.*?(?=### 任务 12:实现新图的规范化与严格校验)'
).Value
if ([string]::IsNullOrWhiteSpace($taskEleven) -or
$taskEleven -match '- \[ \] \*\*步骤 [12]' -or
$taskEleven -notmatch '- \[ \] \*\*步骤 [34]') {
throw '实施计划没有精确登记 T01 门禁已落地、后端双导出仍待提供'
}
foreach ($taskElevenFact in @('LINEAGE-OPENAPI-CONTRACT BLOCKED', 'JSON/YAML 各缺四条操作和三个固定根模型', '平铺 query parameters 无法由 OpenAPI 静态证明')) {
if ($taskEleven -notmatch [regex]::Escape($taskElevenFact)) {
throw "任务 11 缺少门禁分层事实:$taskElevenFact"
}
}
if ($mappingDocument -notmatch '### 2\.12 R 系列线上接口与静态迁移边界') {
throw '接口与页面映射总表缺少 R 系列三方接口审查账本'
}
$taskEighteen = [regex]::Match(
$planDocument,
'(?s)### 任务 18:接入新接口、mock 和跨页变更版本.*?(?=### 任务 19:完成 T01 MuMu、性能与三人终审)'
).Value
foreach ($taskEighteenContract in @(
'pages/tree/t05-edit-member.vue',
'appApi.updateLineagePerson(genealogyId, memberId, payload, treeVersion)',
'T04/T05/T06 完成回流',
'genealogyId/personId/memberId',
'personId=A',
'memberId=B',
'hostPersonId',
'editedMemberId',
'禁止 `memberId || personId` 双读',
'parentParamMap` 仍只负责无历史时构造父页',
'同步收紧 `tests/navigation-flow-contract.ps1`'
)) {
if ($taskEighteen -notmatch [regex]::Escape($taskEighteenContract)) {
throw "任务 18 缺少 T05 双身份或原子合同迁移约束:$taskEighteenContract"
}
}
foreach ($retiredOwner in @('components/ModulePage.vue', 'data/page-catalog.js')) {
if ($combined -match [regex]::Escape($retiredOwner)) {
throw "当前权威文档不得把退役通用页面重新登记为 owner:$retiredOwner"
}
}
if ($combined -notmatch 'tests/retired-module-page-contract.ps1') {
throw '当前权威文档缺少通用母版退役防回归合同'
}
$navigationSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/navigation.js')
$actualNavigationExports = @(
[regex]::Matches($navigationSource, '(?m)^export const ([A-Za-z][A-Za-z0-9]*)') |
ForEach-Object { $_.Groups[1].Value } |
Sort-Object
)
$expectedNavigationExports = @(
'buildRouteUrl',
'consumeNavigationResult',
'finishPage',
'goBack',
'goRoot',
'handleBackPress',
'openNoticeTarget',
'openPage',
'resolveBackAction',
'returnTo',
'runBackGuard'
) | Sort-Object
if (($actualNavigationExports -join ',') -ne ($expectedNavigationExports -join ',')) {
throw "导航网关公开导出面漂移:$($actualNavigationExports -join ', ')"
}
if ($navigationSource -match '(?m)^export\s+(const|function)\s+replaceStep\b' -or $designDocument -match '(?m)^replaceStep\(') {
throw '当前没有替换边,不得恢复公开 replaceStep 入口'
}
if ($mappingDocument -match '将在阶段 1.*确定' -or $mappingDocument -match '阶段 1 依赖') {
throw '接口映射总表仍把已收口导航或未来业务错误绑定为阶段 1 待定项'
}
# A04 已有精确 OpenAPI 证据:注册成功必须建立会话并进入 G01,不能退回并行的“注册后再登录”终点。
if ($mappingDocument -notmatch '成功建立登录态并进入 G01' -or $mappingDocument -notmatch '注册成功响应为 `LoginResult`') {
throw 'A04 文档没有同时锁定 LoginResult 与建立登录态后进入 G01'
if ($overviewDocument -notmatch '当前物理库存为 `140` 个 PowerShell 合同、`47` 个 Node 文件' -or
$overviewDocument -notmatch 'PowerShell `126/140` 通过' -or
$overviewDocument -notmatch 'Node 语法 `47/47` 通过' -or
$overviewDocument -notmatch '纯 Node 冒烟 `19/19` 通过' -or
$overviewDocument -notmatch 'Vue 脚本模块语法 `64/64` 通过') {
throw '项目总览没有登记当前测试库存与最新全量验证证据'
}
foreach ($completedDomainFact in @(
'appApi.submitFeedback',
'/genealogy/app/feedback',
'WRITE_UNAVAILABLE',
'feedbackContent',
'撤权',
'禁止静默',
'uncertain'
)) {
if ($overviewDocument -notmatch [regex]::Escape($completedDomainFact) -and
$mappingDocument -notmatch [regex]::Escape($completedDomainFact) -and
$planDocument -notmatch [regex]::Escape($completedDomainFact)) {
throw "当前文档没有登记领域上下文或 M07 完成事实:$completedDomainFact"
}
}
foreach ($workspaceGateFact in @(
'API-GENEALOGY-WORKSPACE-001',
'genealogy-workspace-openapi-contract.ps1',
'RListAppGenealogyVo',
'RAppGenealogyVo',
'canView',
'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED'
)) {
if ($overviewDocument -notmatch [regex]::Escape($workspaceGateFact) -and
$mappingDocument -notmatch [regex]::Escape($workspaceGateFact) -and
$planDocument -notmatch [regex]::Escape($workspaceGateFact)) {
throw "当前文档没有登记家谱工作区门禁事实:$workspaceGateFact"
}
}
foreach ($helpGateFact in @(
'API-M06-001',
'help-center-openapi-contract.ps1',
'RListHelpArticleVo',
'HelpArticleVo',
'list-only',
'HELP-CENTER-OPENAPI-CONTRACT BLOCKED'
)) {
if ($overviewDocument -notmatch [regex]::Escape($helpGateFact) -and
$mappingDocument -notmatch [regex]::Escape($helpGateFact) -and
$planDocument -notmatch [regex]::Escape($helpGateFact)) {
throw "当前文档没有登记 M06 帮助门禁事实:$helpGateFact"
}
}
foreach ($profileGateFact in @(
'API-PROFILE-READ-001',
'profile-openapi-contract.ps1',
'RAppProfileVo',
'AppProfileVo',
'maskedPhone',
'PROFILE-OPENAPI-CONTRACT BLOCKED'
)) {
if ($overviewDocument -notmatch [regex]::Escape($profileGateFact) -and
$mappingDocument -notmatch [regex]::Escape($profileGateFact) -and
$planDocument -notmatch [regex]::Escape($profileGateFact)) {
throw "当前文档没有登记个人资料读取门禁事实:$profileGateFact"
}
}
foreach ($profileUpdateGateFact in @(
'API-PROFILE-UPDATE-001',
'profile-update-openapi-contract.ps1',
'AppProfileMergeUpdateBody',
'profileVersion',
'If-Match',
'PROFILE_VERSION_CHANGED',
'PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED'
)) {
if ($overviewDocument -notmatch [regex]::Escape($profileUpdateGateFact) -and
$mappingDocument -notmatch [regex]::Escape($profileUpdateGateFact) -and
$planDocument -notmatch [regex]::Escape($profileUpdateGateFact)) {
throw "当前文档没有登记个人资料写入门禁事实:$profileUpdateGateFact"
}
}
foreach ($notificationGateFact in @(
'API-NOTIFICATION-READ-001',
'API-NOTIFICATION-STATE-001',
'notification-read-openapi-contract.ps1',
'notification-read-state-openapi-contract.ps1',
'RListNotificationVo',
'RNotificationUnreadCount',
'generationordinal',
'NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED',
'NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED'
)) {
if ($overviewDocument -notmatch [regex]::Escape($notificationGateFact) -and
$mappingDocument -notmatch [regex]::Escape($notificationGateFact) -and
$planDocument -notmatch [regex]::Escape($notificationGateFact)) {
throw "当前文档没有登记通知读取或已读写入门禁事实:$notificationGateFact"
}
}
foreach ($logoutGateFact in @(
'API-LOGOUT-001',
'logout-openapi-contract.ps1',
'RLogoutRejected',
'TOKEN_CLIENT_MISMATCH',
'LOGOUT-OPENAPI-CONTRACT BLOCKED',
'logoutCoordinator'
)) {
if ($overviewDocument -notmatch [regex]::Escape($logoutGateFact) -and
$mappingDocument -notmatch [regex]::Escape($logoutGateFact) -and
$planDocument -notmatch [regex]::Escape($logoutGateFact)) {
throw "当前文档没有登记 M10 服务端退出门禁事实:$logoutGateFact"
}
}
foreach ($passwordChangeGateFact in @(
'API-PASSWORD-001',
'API-PASSWORD-005',
'password-change-openapi-contract.ps1',
'CurrentPasswordSecret',
'NewPasswordSecret',
'RPasswordChangeRejected',
'CREDENTIAL_VERSION_CONFLICT',
'credentialChangeInFlight',
'PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED'
)) {
if ($overviewDocument -notmatch [regex]::Escape($passwordChangeGateFact) -and
$mappingDocument -notmatch [regex]::Escape($passwordChangeGateFact) -and
$planDocument -notmatch [regex]::Escape($passwordChangeGateFact)) {
throw "当前文档没有登记 M04 密码凭证门禁事实:$passwordChangeGateFact"
}
}
foreach ($phoneChangeGateFact in @(
'API-PHONE-001',
'API-PHONE-005',
'phone-change-openapi-contract.ps1',
'PhoneChangeSmsCodeBody',
'NewBoundPhone',
'SmsCodeSecret',
'RPhoneChangeRejected',
'STEP_UP_UNAVAILABLE',
'PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED'
)) {
if ($overviewDocument -notmatch [regex]::Escape($phoneChangeGateFact) -and
$mappingDocument -notmatch [regex]::Escape($phoneChangeGateFact) -and
$planDocument -notmatch [regex]::Escape($phoneChangeGateFact)) {
throw "当前文档没有登记 M05 手机号换绑门禁事实:$phoneChangeGateFact"
}
}
foreach ($g03BootstrapGateFact in @(
'API-G03-001',
'API-G03-005',
'g03-bootstrap-openapi-contract.ps1',
'AppGenealogyBootstrapBody',
'GenealogyBootstrapOperationKey',
'GenealogyAccessPreset',
'GenealogyRegionCode',
'FAILED_NO_COMMIT',
'x-bootstrap-root-editable-fields',
'x-state-transitions',
'x-domain-effects=NONE',
'任意精度',
'G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED',
'g03-bootstrap-client-release-gate.ps1',
'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED',
'openapi-yaml-json-parity-runtime-smoke.js',
'/genealogy/app/region/search',
'GET 必须纯读',
'fatal/quarantined',
'家谱工作区读取门禁',
'If-Match、版本/CAS',
'receipt→`/mine` cache→context→G05'
)) {
if ($overviewDocument -notmatch [regex]::Escape($g03BootstrapGateFact) -and
$designDocument -notmatch [regex]::Escape($g03BootstrapGateFact) -and
$mappingDocument -notmatch [regex]::Escape($g03BootstrapGateFact) -and
$planDocument -notmatch [regex]::Escape($g03BootstrapGateFact)) {
throw "当前文档没有登记 G03 原子创建门禁事实:$g03BootstrapGateFact"
}
}
foreach ($liveOpenApiFact in @('https://backend-api.ddxcjp.cn/', '3.1.0', '722', '858', '507', '/captcha/challenge', 'validToken')) {
if ($overviewDocument -notmatch [regex]::Escape($liveOpenApiFact) -or $mappingDocument -notmatch [regex]::Escape($liveOpenApiFact)) {
throw "项目总览或接口映射缺少新线上 OpenAPI 事实:$liveOpenApiFact"
}
}
if ($mappingDocument -notmatch '密码登录体尚无票据字段' -or $mappingDocument -notmatch '没有任何 `/genealogy/app/v2/') {
throw '接口映射没有锁定 TAC 密码登录或 T01 v2 的线上硬缺口'
}
foreach ($lineageGateFact in @('LINEAGE-OPENAPI-CONTRACT BLOCKED', 'LineageGraphWindow/LineageOverview/LineageLocator')) {
if ($overviewDocument -notmatch [regex]::Escape($lineageGateFact) -and $mappingDocument -notmatch [regex]::Escape($lineageGateFact) -and $planDocument -notmatch [regex]::Escape($lineageGateFact)) {
throw "当前文档没有登记 T01 门禁事实:$lineageGateFact"
}
}
foreach ($authGateFact in @(
'AUTH-TAC-OPENAPI-CONTRACT BLOCKED',
'ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED',
'API-AUTH-TAC-001',
'API-AUTH-TAC-002',
'API-AUTH-TAC-003',
'API-AUTH-TAC-004',
'runtimeConfig.mode',
'mock',
'15 秒',
'APP_SMS_LOGIN',
'APP_REGISTER',
'APP_FORGOT_PASSWORD'
)) {
if ($overviewDocument -notmatch [regex]::Escape($authGateFact) -and $mappingDocument -notmatch [regex]::Escape($authGateFact) -and $planDocument -notmatch [regex]::Escape($authGateFact)) {
throw "当前文档没有登记认证门禁事实:$authGateFact"
}
}
if ($combined -notmatch '同一验证中心' -or
$combined -notmatch '不得.*无障碍.*绕过' -or
$combined -notmatch '非交互风控' -or
$combined -notmatch '已安全绑定设备' -or
$combined -notmatch '人工.*兜底' -or
$combined -notmatch 'POC') {
throw '当前文档没有锁定 TAC 可访问替代路径的统一票据、安全边界和待验证状态'
}
# A04 线上响应已经迁移,注册成功仍必须建立会话并进入 G01,不能保留旧令牌字段或“注册后再登录”终点。
if ($mappingDocument -notmatch '成功建立登录态并进入 G01' -or $mappingDocument -notmatch 'RAppLoginVo' -or $mappingDocument -notmatch 'AppLoginVo' -or $mappingDocument -notmatch 'access_token') {
throw 'A04 文档没有同时锁定线上登录响应与建立登录态后进入 G01'
}
if ($combined -match 'token/accessToken/tokenValue') { throw '当前权威文档仍把旧登录令牌字段写成目标合同' }
if ($designDocument -notmatch '正式终点不是“注册后再登录”' -or $planDocument -notmatch '不能先回 A01 再让用户重复登录') {
throw '设计或计划没有明确排除注册后再次登录的并行终点'
}
+1 -1
View File
@@ -134,7 +134,7 @@ const stressApplications = async (send, size) => {
const stressMedia = async (send, size) => {
await setSize(send, size)
await open(send, '/pages/family/f09-media-upload', '', '.media-upload-state--initial')
await open(send, '/pages/family/f09-media-upload', '?genealogyId=1001&albumId=201', '.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, `(() => {
@@ -0,0 +1,52 @@
const fs = require("node:fs");
const path = require("node:path");
const assert = require("node:assert/strict");
const root = path.resolve(__dirname, "..");
const modulePath = path.join(root, "utils", "discard-confirmation.js");
const loadModule = async () => {
const source = fs.readFileSync(modulePath, "utf8");
return import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);
};
const run = async () => {
const { createDiscardConfirmation } = await loadModule();
const visibility = [];
const controller = createDiscardConfirmation((visible) => {
visibility.push(visible);
});
const first = controller.request();
const repeated = controller.request();
assert.strictEqual(repeated, first, "重复请求必须复用同一个等待 Promise");
assert.deepEqual(visibility, [true], "重复请求不得重复打开确认框");
controller.cancel();
assert.equal(await first, false, "取消必须让等待者得到 false");
assert.deepEqual(visibility, [true, false], "取消必须关闭确认框");
const confirmed = controller.request();
controller.confirm();
assert.equal(await confirmed, true, "确认必须让等待者得到 true");
const disposed = controller.request();
controller.dispose();
assert.equal(await disposed, false, "页面卸载必须释放等待者并返回 false");
const afterDispose = controller.request();
assert.notStrictEqual(afterDispose, disposed, "释放后必须能建立新的确认周期");
controller.cancel();
assert.equal(await afterDispose, false);
assert.throws(
() => createDiscardConfirmation(null),
/setVisible 必须是函数/,
"必须拒绝无法同步可见状态的消费者",
);
console.log("DISCARD-CONFIRMATION-RUNTIME-SMOKE PASS");
};
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+7 -27
View File
@@ -66,8 +66,8 @@
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-order, .media-photo-current, .media-photo-status",
"reason": "依附照片缩略图的顺序与状态角标"
"selector": ".media-photo-order, .media-photo-current",
"reason": "依附照片缩略图的顺序与当前预览角标"
},
{
"file": "pages/family/f09-media-upload.vue",
@@ -84,31 +84,6 @@
"selector": ".member-sheet__skin",
"reason": "固定底部详情抽屉内部的装饰框层"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".feedback-toast",
"reason": "登录页面跨内容轻提示"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".verification-layer",
"reason": "用户触发的验证码弹窗遮罩"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".feedback-toast",
"reason": "注册页面跨内容轻提示"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".feedback-toast",
"reason": "重置密码页面跨内容轻提示"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".success-layer",
"reason": "用户触发的重置成功弹窗遮罩"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".recovery-layer",
@@ -149,6 +124,11 @@
"selector": ".app-dialog-layer",
"reason": "全屏弹窗遮罩层"
},
{
"file": "components/TacVerification.vue",
"selector": ".tac-layer",
"reason": "三个认证流程共用的真实滑动验证全屏遮罩"
},
{
"file": "components/AppToast.vue",
"selector": ".app-toast",
+7 -7
View File
@@ -13,23 +13,23 @@ function Assert-Contains([string]$source, [string]$expected, [string]$page) {
$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'
'genealogyId', 'feedId', 'comment-state--validating', 'comment-state--preview',
'findFamilyFeedFixture', 'returnTo("F01", { genealogyId: genealogyId.value })'
)
'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'
'listFamilyArticleFixtures', 'openPage(', 'genealogyId: genealogyId.value'
)
'pages/family/f05-article-detail.vue' = @(
'articleParagraphs', 'toggleFavorite', 'article-state--expired', 'backToArticles',
'articleParagraphs', 'disabled label=', 'article-state--expired', 'backToArticles',
'articleId', 'article-state--privacy', 'article-state--error',
'/pages/family/f04-article-list', '/pages/family/f06-article-editor?mode=edit&articleId='
'findFamilyArticleFixture', 'returnTo("F04", { genealogyId: genealogyId.value })'
)
'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='
'listFamilyAlbumFixtures', 'localAlbumPreview', 'openPage('
)
}
@@ -39,7 +39,7 @@ foreach ($entry in $contracts.GetEnumerator()) {
foreach ($required in @('ModulePageBackground', 'PageHeader', 'AppButton', 'AppLoading')) {
Assert-Contains $source $required $entry.Key
}
foreach ($forbidden in @('import ModulePage from', 'uni.showToast', 'uni.showModal')) {
foreach ($forbidden in @('import ModulePage from', 'uni.showToast', 'uni.showModal', 'uni.navigateTo', 'uni.redirectTo', 'uni.reLaunch', '/pages/', 'finishPage(')) {
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains forbidden implementation: $forbidden" }
}
if ($source -match '<ModulePage(?:\s|/|>)') { throw "$($entry.Key) retains forbidden ModulePage owner" }
+25 -10
View File
@@ -59,35 +59,50 @@ const run = async () => {
if (!(await valueOf(send, "document.querySelector('.app-toast')?.textContent.includes('请先写下动态内容')"))) {
throw new Error("F02 empty submit showed unexpected feedback");
}
await setInput(send, ".publish-form textarea", "这是一条本地动态预览。");
await click(send, ".publish-form .app-button");
await waitFor(send, "Boolean(document.querySelector('.publish-state--preview'))", "F02 did not enter honest local preview");
if (!(await valueOf(send, "document.querySelector('.publish-result')?.textContent.includes('尚未提交服务器')"))) {
throw new Error("F02 preview did not disclose that content was not published");
}
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}`);
await open(send, "/pages/family/f04-article-list?genealogyId=1001", ".article-card");
if ((await valueOf(send, "document.querySelectorAll('.article-card').length")) !== 3) throw new Error(`F04 did not render the scoped article owner 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}`);
await open(send, "/pages/family/f07-album-list?genealogyId=1001", ".album-card");
if ((await valueOf(send, "document.querySelectorAll('.album-card').length")) !== 3) throw new Error(`F07 did not render the scoped album owner 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 open(send, "/pages/family/f04-article-list?genealogyId=1001", ".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 waitFor(send, "location.hash.includes('/pages/family/f05-article-detail?genealogyId=1001&articleId=101')", "F04 card did not open composite-identity F05");
await open(send, "/pages/family/f03-feed-detail?feedId=1", ".feed-comment-form textarea");
await open(send, "/pages/family/f03-feed-detail?genealogyId=1001&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 waitFor(send, "Boolean(document.querySelector('.comment-state--preview'))", "F03 did not enter comment preview");
if ((await valueOf(send, "document.querySelectorAll('.feed-comment-card').length")) !== before) throw new Error("F03 appended an unsubmitted comment");
if ((await valueOf(send, "document.querySelector('.feed-comment-form textarea').value")) !== "愿家人岁岁平安,常聚常新。") throw new Error("F03 cleared the unsubmitted comment draft");
await open(send, "/pages/family/f07-album-list", ".album-list > .app-button");
await open(send, "/pages/family/f07-album-list?genealogyId=1001", ".album-list > .app-button");
const albumCount = await valueOf(send, "document.querySelectorAll('.album-card').length");
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");
await waitFor(send, "document.querySelector('.album-local-preview')?.textContent.includes('清明祭祖影像')", "F07 local album preview did not render");
if ((await valueOf(send, "document.querySelectorAll('.album-card').length")) !== albumCount) throw new Error("F07 inserted an unsubmitted album into the official list");
await open(send, "/pages/family/f03-feed-detail?genealogyId=1002&feedId=1", ".feed-state--expired");
await open(send, "/pages/family/f05-article-detail?genealogyId=1002&articleId=101", ".article-state--expired");
await open(send, "/pages/family/f08-album-detail?genealogyId=1002&albumId=201", ".album-state--expired");
await open(send, "/pages/family/f09-media-upload?genealogyId=1002&albumId=201", ".media-upload-state--invalid");
process.stdout.write("F-BUSINESS-FLOW-RUNTIME-SMOKE PASS\n");
} finally {
+1 -13
View File
@@ -6,7 +6,6 @@ function Assert-Match([string]$Content, [string]$Pattern, [string]$Message) { if
$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 @('24rpx','23rpx','22rpx')) {
Assert-Match $f01 "font-size:\s*$expected" "F01 readability token missing: $expected"
@@ -14,15 +13,4 @@ foreach ($expected in @('24rpx','23rpx','22rpx')) {
foreach ($expected in @('24rpx','23rpx')) {
Assert-Match $f02 "font-size:\s*$expected" "F02 readability token missing: $expected"
}
foreach ($expected in @(
'(?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-Match $module $expected "ModulePage readability contract missing: $expected"
}
Write-Output 'F series all-states visual contract passed.'
Write-Output 'F01-F02 readability visual contract passed.'
+3 -2
View File
@@ -1,9 +1,10 @@
$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')) {
foreach ($route in @('people: "R01"','gifts: "R03"','merits: "R11"','videos: "F10"')) {
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' }
if (-not $page.Contains('return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");')) { throw 'F01 child routes must preserve genealogy context through the navigation gateway' }
if ($page.Contains('/pages/')) { throw 'F01 must not retain route literals outside the unique registry' }
Write-Output 'F01-PRODUCT-ENTRY-CONTRACT PASS'
+10 -23
View File
@@ -1,4 +1,4 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
@@ -12,32 +12,19 @@ function Assert-Contains {
if (-not $Content.Contains($Expected)) { throw $Message }
}
function Decode-Utf8Base64([string]$Value) {
[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
$catalog = Read-Utf8 'data/page-catalog.js'
$module = Read-Utf8 'components/ModulePage.vue'
$page = Read-Utf8 'pages/family/f05-article-detail.vue'
foreach ($expected in @(
'states: {',
"eyebrow: '$(Decode-Utf8Base64 '6LCx5paH5bey5aSx5pWI')'",
"title: '$(Decode-Utf8Base64 '6L+Z56+H6LCx5paH5bey5peg5rOV5p+l55yL')'",
"copy: '$(Decode-Utf8Base64 '5YaF5a655Y+v6IO95bey6KKr5L2c6ICF5Yig6Zmk5oiW5Y+W5raI5YWs5byA77yM6K+36L+U5Zue6LCx5paH5YiX6KGo5p+l55yL5YW25LuW5YaF5a6544CC')'",
"action: '$(Decode-Utf8Base64 '6L+U5Zue6LCx5paH5YiX6KGo')'",
"actionMode: 'back'"
)) {
Assert-Contains $catalog $expected "F05 expired-state catalog contract missing: $expected"
}
foreach ($expected in @(
'page.value.states?.[moduleState.value]',
'"article-state--expired": articleState.value === "expired"',
'expired: { title: "这篇谱文已无法查看"',
'当前家谱中不存在这篇谱文,页面不会回退到其他文章。',
'findFamilyArticleFixture(genealogyId.value, articleId.value)',
':label="stateCopy.action"',
'@click="handleStateAction"',
'const handleStateAction = () => {',
'stateCopy.value.actionMode === "back"',
'uni.navigateBack()'
'const backToArticles = () =>',
'return backToArticles();'
)) {
Assert-Contains $module $expected "ModulePage state override contract missing: $expected"
Assert-Contains $page $expected "F05 real expired-state owner missing: $expected"
}
Write-Output 'F05-EXPIRED-STATE-CONTRACT PASS'
+10 -10
View File
@@ -13,7 +13,6 @@ function Assert-Contains {
}
$page = Read-Utf8 'pages/family/f06-article-editor.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="article-editor-page"',
@@ -31,13 +30,16 @@ foreach ($expected in @(
'"draft"',
'"loading"',
'"validation"',
'"saving"',
'"error"',
'"success"',
'if (editorState.value === "saving") return;',
'editorState.value = "saving";',
'? "error" : "success"',
':disabled="editorState === ''saving''"',
'"preview"',
'const isSubmitting = ref(false);',
'if (isSubmitting.value || !hasValidContext.value) return;',
'isSubmitting.value = true;',
'editorState.value = submitSnapshot ? "preview" : "error";',
':disabled="isSubmitting"',
'findFamilyArticleFixture',
'createDiscardConfirmation',
'runBackGuard',
'onUnload(() => {',
'ModulePageBackground',
'AppLoading',
@@ -48,7 +50,7 @@ foreach ($expected in @(
Assert-Contains $page $expected "F06 dedicated editor contract missing: $expected"
}
foreach ($forbidden in @('<template><ModulePage', 'import ModulePage from', 'page-id="f06"', '@/utils/api.js', 'uni.showToast', 'uni.showModal', 'class="editor-panel__skin"')) {
foreach ($forbidden in @('<template><ModulePage', 'import ModulePage from', 'page-id="f06"', '@/utils/api.js', 'uni.showToast', 'uni.showModal', 'class="editor-panel__skin"', '"saving"', '"success"', 'finishPage(')) {
if ($page.Contains($forbidden)) { throw "F06 retains forbidden dependency: $forbidden" }
}
@@ -56,6 +58,4 @@ if ($page -match 'border-image-slice\s*:') { throw 'F06 must consume border-imag
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'
+13 -2
View File
@@ -1,8 +1,19 @@
$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}')) {
foreach ($token in @(
'genealogyId.value = String(query.genealogyId || "");',
'articleId.value = String(query.articleId || "");',
'editorMode.value = String(query.mode || "");',
'(editorMode.value === "create" && !articleId.value)',
'(editorMode.value === "edit" && Boolean(article))',
'findFamilyArticleFixture(genealogyId.value, articleId.value)',
'returnTo("F05", {',
'returnTo("F04", { genealogyId: genealogyId.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' }
foreach ($forbidden in @('simulateSaveFailure', 'form.title.trim() === "保存失败"', 'uni.redirectTo', '/pages/')) {
if ($page.Contains($forbidden)) { throw "F06 must not retain a context or navigation bypass: $forbidden" }
}
Write-Output 'F06-EDITOR-CONTEXT-CONTRACT PASS'
+7 -13
View File
@@ -13,7 +13,6 @@ function Assert-Contains {
}
$page = Read-Utf8 'pages/family/f08-album-detail.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="album-detail-page"',
@@ -23,23 +22,22 @@ foreach ($expected in @(
'class="album-preview"',
'class="album-upload-action"',
'const albumState = ref("normal");',
'const photos = [',
'const photos = ref([]);',
'findFamilyAlbumFixture(genealogyId.value, albumId.value)',
'const openPreview = (index) =>',
'const closePreview = () =>',
'const toUpload = () =>',
'/pages/family/f09-media-upload?albumId=${albumId.value}',
'"F09"',
'genealogyId: genealogyId.value, albumId: albumId.value',
'query.state === "empty"',
'query.state === "preview"',
'query.state === "expired"',
'class="album-empty-state"',
'class="album-expired-state"',
'class="album-preview__position"',
'f08-reunion-hero.png',
'f08-family-portrait.png',
'f08-reunion-table.png',
'f08-ancestral-home.png',
'f08-ancestral-portrait.png',
':alt="photo.alt"',
'transientOpen: previewVisible.value',
'onBackPress((event) => handleBackPress(event, requestBack));',
'ModulePageBackground',
'PageHeader',
'AppButton'
@@ -47,7 +45,7 @@ foreach ($expected in @(
Assert-Contains $page $expected "F08 contract missing: $expected"
}
foreach ($forbidden in @('<ModulePage page-id="f08"', 'import ModulePage from', 'uni.showToast', 'uni.showModal')) {
foreach ($forbidden in @('<ModulePage page-id="f08"', 'import ModulePage from', 'uni.showToast', 'uni.showModal', 'uni.navigateTo', 'uni.redirectTo', '/pages/')) {
if ($page.Contains($forbidden)) { throw "F08 retains forbidden dependency: $forbidden" }
}
@@ -61,8 +59,4 @@ foreach ($requiredPosition in @(
}
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'
}
Write-Output 'F08-ALBUM-DETAIL-CONTRACT PASS'
+7 -5
View File
@@ -61,7 +61,7 @@ const run = async () => {
await send('Page.enable')
await send('Runtime.enable')
await navigate('/pages/family/f08-album-detail', '.album-state--normal')
await navigate('/pages/family/f08-album-detail?genealogyId=1001&albumId=201', '.album-state--normal')
const photoCount = await valueOf(send, "document.querySelectorAll('.album-photo-tile').length")
assert.strictEqual(photoCount, 5, 'F08 normal state must render five photo tiles')
@@ -73,19 +73,21 @@ const run = async () => {
await send('Runtime.evaluate', { expression: "document.querySelector('.album-upload-action')?.click()" })
await waitFor(
send,
"location.href.includes('/pages/family/f09-media-upload?albumId=reunion')",
"location.href.includes('/pages/family/f09-media-upload?genealogyId=1001&albumId=201')",
'F08 upload action did not carry the current album into F09'
)
await navigate('/pages/family/f08-album-detail?state=empty', '.album-state--empty')
await navigate('/pages/family/f08-album-detail?genealogyId=1001&albumId=201&state=empty', '.album-state--empty')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.album-photo-tile').length"), 0, 'F08 empty state must not render photos')
await navigate('/pages/family/f08-album-detail?state=preview', '.album-state--preview')
await navigate('/pages/family/f08-album-detail?genealogyId=1001&albumId=201&state=preview', '.album-state--preview')
await waitFor(send, "Boolean(document.querySelector('.album-preview'))", 'F08 direct preview state did not render')
await navigate('/pages/family/f08-album-detail?state=expired', '.album-state--expired')
await navigate('/pages/family/f08-album-detail?genealogyId=1001&albumId=201&state=expired', '.album-state--expired')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.album-photo-tile').length"), 0, 'F08 expired state must not render photos')
await navigate('/pages/family/f08-album-detail?genealogyId=1002&albumId=201', '.album-state--expired')
process.stdout.write('F08-ALBUM-DETAIL-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
+5 -2
View File
@@ -2,10 +2,13 @@ $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}')) {
foreach ($token in @('const genealogyId = ref', 'const albumId = ref', 'findFamilyAlbumFixture(genealogyId.value, albumId.value)', 'toUpload', 'openPage(', 'genealogyId: genealogyId.value, 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}')) {
foreach ($token in @('const genealogyId = ref', 'const albumId = ref', 'findFamilyAlbumFixture(genealogyId.value, albumId.value)', 'returnTo("F08", {', 'genealogyId: genealogyId.value', 'albumId: albumId.value')) {
if (-not $upload.Contains($token)) { throw "F09 album context missing: $token" }
}
foreach ($page in @($detail, $upload)) {
if ($page.Contains('query.albumId || "reunion"') -or $page.Contains('/pages/')) { throw 'F08/F09 must not default the album identity or retain route literals' }
}
Write-Output 'F08-F09-ALBUM-CONTEXT-CONTRACT PASS'
+19 -14
View File
@@ -13,7 +13,6 @@ function Assert-Contains {
}
$page = Read-Utf8 'pages/family/f09-media-upload.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="media-upload-page"',
@@ -34,16 +33,18 @@ foreach ($expected in @(
'const removePhoto = (index) =>',
'const addMockPhoto = () =>',
'const updateActiveNote = (event) =>',
'const startUpload = () =>',
'const retryFailed = () =>',
'const generatePreview = () =>',
'const returnToAlbum = () =>',
'["permission", "selected", "uploading", "error", "success"].includes(query.state)',
'["permission", "selected", "preview"].includes(query.state)',
'findFamilyAlbumFixture(genealogyId.value, albumId.value)',
'const isDirty = computed(() =>',
'createDiscardConfirmation',
'runBackGuard',
'onBackPress((event) => handleBackPress(event, requestBack));',
'class="media-preview-title"',
'class="media-permission-action"',
'class="media-photo-status media-photo-status--error"',
'class="media-success-title"',
'class="media-progress-copy"',
'class="media-preview-title"',
'class="media-primary-action"',
'class="media-retry-action"',
'f08-reunion-hero.png',
'f08-family-portrait.png',
'f08-reunion-table.png',
@@ -60,14 +61,22 @@ foreach ($forbidden in @(
'<ModulePage page-id="f09"',
'import ModulePage from',
'uni.chooseMedia',
'uni.showModal'
'uni.showModal',
'uni.redirectTo',
'/pages/',
'uploading',
'uploaded',
'success',
'retryFailed',
'/genealogy/app/files/upload',
'finishPage('
)) {
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-order,\s*\.media-photo-current\s*\{[^}]*position:\s*absolute;',
'(?s)\.media-photo-remove\s*\{[^}]*position:\s*absolute;'
)) {
if ($page -notmatch $requiredPosition) { throw "F09 required thumbnail overlay boundary missing: $requiredPosition" }
@@ -75,8 +84,4 @@ foreach ($requiredPosition in @(
if ([regex]::Matches($page, 'position\s*:').Count -ne 3) { throw 'F09 must keep only thumbnail badges and their local photo boundary positioned' }
if ([regex]::Matches($page, '<textarea(?=[^>]*\sauto-height(?:\s|>))').Count -ne 2) { throw 'F09 batch and per-photo descriptions must both grow with content' }
if ($catalog -match '(?m)^\s*f09\s*:') {
throw 'F09 obsolete page-catalog owner must be removed'
}
Write-Output 'F09-MEDIA-UPLOAD-CONTRACT PASS'
+1 -1
View File
@@ -50,7 +50,7 @@ const run = async () => {
screenWidth: 412,
screenHeight: 915,
})
const url = `${origin}/?f09LayoutAudit=1#/pages/family/f09-media-upload?state=selected`
const url = `${origin}/?f09LayoutAudit=1#/pages/family/f09-media-upload?genealogyId=1001&albumId=201&state=selected`
await send('Page.navigate', { url })
await waitFor("Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 selected state missing')
const metrics = await valueOf(`(() => {
+7 -10
View File
@@ -61,7 +61,7 @@ const run = async () => {
await send('Page.enable')
await send('Runtime.enable')
await navigate('/pages/family/f09-media-upload', '.media-upload-state--initial')
await navigate('/pages/family/f09-media-upload?genealogyId=1001&albumId=201', '.media-upload-state--initial')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-tile').length"), 0)
await send('Runtime.evaluate', { expression: "document.querySelector('.media-primary-action')?.click()" })
@@ -94,19 +94,16 @@ const run = async () => {
textarea.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.media-primary-action')?.click()
})()` })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--uploading'))", 'F09 did not enter uploading state')
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--preview'))", 'F09 did not enter honest local preview')
await waitFor(send, "document.querySelector('.media-preview-card')?.textContent.includes('照片尚未上传') === true", 'F09 preview did not disclose that no upload occurred')
await navigate('/pages/family/f09-media-upload?state=permission', '.media-upload-state--permission')
await navigate('/pages/family/f09-media-upload?genealogyId=1001&albumId=201&state=permission', '.media-upload-state--permission')
await send('Runtime.evaluate', { expression: "document.querySelector('.media-permission-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 permission action did not return to selected state')
await navigate('/pages/family/f09-media-upload?state=error', '.media-upload-state--error')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-status--error').length"), 2)
await send('Runtime.evaluate', { expression: "document.querySelector('.media-retry-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--uploading'))", 'F09 retry did not enter uploading state')
await navigate('/pages/family/f09-media-upload?state=success', '.media-upload-state--success')
await waitFor(send, "document.querySelector('.media-success-title')?.textContent.includes('4 张照片已上传') === true", 'F09 success copy missing')
await navigate('/pages/family/f09-media-upload?genealogyId=1001&albumId=201&state=preview', '.media-upload-state--preview')
assert.strictEqual(await valueOf(send, "document.body.textContent.includes('已上传')"), false, 'F09 must never claim an upload succeeded')
await navigate('/pages/family/f09-media-upload?genealogyId=1002&albumId=201', '.media-upload-state--invalid')
process.stdout.write('F09-MEDIA-UPLOAD-RUNTIME-SMOKE PASS\n')
} finally {
+4 -7
View File
@@ -9,14 +9,15 @@ function Assert-Contains([string]$Content, [string]$Expected, [string]$Message)
}
$page = Read-Utf8 'pages/family/f10-video-list.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="video-status-page"',
'class="video-status-card"',
'class="video-return-action"',
'const returnToFamily = () =>',
'/pages/family/f01-family-feed',
'genealogyId.value = String(query.genealogyId || "");',
'returnTo("F01", { genealogyId: genealogyId.value })',
'getGenealogyFixtureAccess',
'ModulePageBackground',
'PageHeader',
'AppButton',
@@ -26,7 +27,7 @@ foreach ($expected in @(
Assert-Contains $page $expected "F10 contract missing: $expected"
}
foreach ($forbidden in @('<ModulePage page-id="f10"', 'import ModulePage from', 'class="video-status-card__skin"', 'module-content-frame.png')) {
foreach ($forbidden in @('<ModulePage page-id="f10"', 'import ModulePage from', 'class="video-status-card__skin"', 'module-content-frame.png', 'uni.reLaunch', '/pages/')) {
if ($page.Contains($forbidden)) { throw "F10 retains forbidden dependency: $forbidden" }
}
@@ -41,8 +42,4 @@ foreach ($selector in @('.video-status-lead text')) {
}
}
if ($catalog -match '(?m)^\s*f10\s*:') {
throw 'F10 obsolete page-catalog owner must be removed'
}
Write-Output 'F10-VIDEO-STATUS-CONTRACT PASS'
+2 -1
View File
@@ -47,7 +47,7 @@ const waitFor = async (send, expression, message) => {
const run = async () => {
const { socket, send } = await connect()
const url = `${origin}/?f10RuntimeAudit=${Date.now()}#/pages/family/f10-video-list`
const url = `${origin}/?f10RuntimeAudit=${Date.now()}#/pages/family/f10-video-list?genealogyId=1001`
try {
await send('Page.enable')
@@ -63,6 +63,7 @@ const run = async () => {
await send('Runtime.evaluate', { expression: "document.querySelector('.video-return-action')?.click()" })
await waitFor(send, "location.hash.startsWith('#/pages/family/f01-family-feed')", 'F10 did not return to F01')
await waitFor(send, "location.hash.includes('genealogyId=1001')", 'F10 dropped the genealogy identity on return')
await waitFor(send, "Boolean(document.querySelector('.feed-state--list'))", 'F01 did not render after F10 return')
process.stdout.write('F10-VIDEO-STATUS-RUNTIME-SMOKE PASS\n')
+146
View File
@@ -0,0 +1,146 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const loadMockModule = async () => {
const source = fs.readFileSync(path.join(__dirname, "../data/mock.js"), "utf8");
const contractSource = fs.readFileSync(
path.join(__dirname, "../utils/genealogy-contracts.js"),
"utf8",
);
const contractUrl = `data:text/javascript;base64,${Buffer.from(contractSource).toString("base64")}`;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source.replace("../utils/genealogy-contracts.js", contractUrl)).toString("base64")}`;
return import(moduleUrl);
};
const assertScopedOwner = ({ list, find, ids, nestedField }) => {
assert.deepStrictEqual(list("1001").map((item) => item.id), ids);
assert.deepStrictEqual(list("1002"), [], "另一家谱不得读取 1001 的内容夹具");
assert.strictEqual(find("1002", ids[0]), null, "已知 ID 换谱后必须失败关闭");
assert.strictEqual(find("1001", "unknown"), null, "未知实体不得回退首条记录");
assert.strictEqual(find("", ids[0]), null, "缺少 genealogyId 必须失败关闭");
assert.strictEqual(find("1001", ""), null, "缺少实体 ID 必须失败关闭");
const listed = list("1001");
const original = find("1001", ids[0]);
listed[0].title = "被列表调用方修改";
assert.notStrictEqual(find("1001", ids[0]).title, "被列表调用方修改");
if (nestedField) {
const mutateNested = (item, value) => {
if (item[nestedField][0] && typeof item[nestedField][0] === "object") {
item[nestedField][0].__contractMutation = value;
} else {
item[nestedField][0] = value;
}
};
mutateNested(listed[0], "被列表调用方修改");
assert(!JSON.stringify(find("1001", ids[0])[nestedField]).includes("被列表调用方修改"));
mutateNested(original, "被详情调用方修改");
assert(!JSON.stringify(find("1001", ids[0])[nestedField]).includes("被详情调用方修改"));
}
};
const run = async () => {
const fixtures = await loadMockModule();
const {
listFamilyFeedFixtures,
findFamilyFeedFixture,
listFamilyArticleFixtures,
findFamilyArticleFixture,
listFamilyAlbumFixtures,
findFamilyAlbumFixture,
} = fixtures;
for (const selector of [
listFamilyFeedFixtures,
findFamilyFeedFixture,
listFamilyArticleFixtures,
findFamilyArticleFixture,
listFamilyAlbumFixtures,
findFamilyAlbumFixture,
]) {
assert.strictEqual(typeof selector, "function", "F 系列只读夹具必须由共享查询函数拥有");
}
assertScopedOwner({
list: listFamilyFeedFixtures,
find: findFamilyFeedFixture,
ids: ["1", "2"],
nestedField: "comments",
});
assertScopedOwner({
list: listFamilyArticleFixtures,
find: findFamilyArticleFixture,
ids: ["101", "102", "103"],
nestedField: "paragraphs",
});
assertScopedOwner({
list: listFamilyAlbumFixtures,
find: findFamilyAlbumFixture,
ids: ["201", "202", "203"],
nestedField: "photos",
});
for (const [list, entityName] of [
[listFamilyFeedFixtures, "feed"],
[listFamilyArticleFixtures, "article"],
[listFamilyAlbumFixtures, "album"],
]) {
for (const item of list("1001")) {
assert.strictEqual(typeof item.id, "string", `${entityName}.id 必须保持词法字符串`);
assert.strictEqual(item.genealogyId, "1001", `${entityName} 必须显式持有家谱身份`);
}
}
const firstFeed = findFamilyFeedFixture("1001", "1");
firstFeed.comments[0].content = "被详情调用方修改";
assert.notStrictEqual(
findFamilyFeedFixture("1001", "1").comments[0].content,
"被详情调用方修改",
);
globalThis.__familyFixtureOwner = fixtures;
const apiSource = fs
.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8")
.replace(
/^import \{[\s\S]*?\} from '@\/data\/mock\.js'\r?\n/,
"const { currentUser, genealogies, publicGenealogies, treeMembers, notifications, joinApplications, listFamilyFeedFixtures, listFamilyArticleFixtures, listFamilyAlbumFixtures, listCeremonyFixtures, listGrowthRecordFixtures } = globalThis.__familyFixtureOwner;\n",
)
.replace(
/^import \{ hasRemoteConfig[^\n]+\r?\n/m,
"const hasRemoteConfig = () => false; const resolveRuntimeMode = () => 'mock'; const runtimeConfig = { baseUrl: '', clientId: 'test', tenantId: 'test' };\n",
)
.replace(
/^import \{ AUTH_TAC_SCENE, assertSmsCode \}[^\n]+\r?\n/m,
"const AUTH_TAC_SCENE = {}; const assertSmsCode = (value) => value;\n",
)
.replace(
/^import \{ GENEALOGY_ACCESS_PRESET \}[^\n]+\r?\n/m,
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' };\n",
)
.replace(
/^import \{ session \}[^\n]+\r?\n/m,
"const session = { getToken: () => '', saveToken: () => {} };\n",
);
const apiUrl = `data:text/javascript;base64,${Buffer.from(apiSource).toString("base64")}`;
const { appApi } = await import(apiUrl);
assert.deepStrictEqual((await appApi.getFeeds("1001")).map((item) => item.id), ["1", "2"]);
assert.deepStrictEqual((await appApi.getArticles("1001")).map((item) => item.id), ["101", "102", "103"]);
assert.deepStrictEqual((await appApi.getAlbums("1001")).map((item) => item.id), ["201", "202", "203"]);
assert.deepStrictEqual(await appApi.getFeeds("1002"), []);
await assert.rejects(
appApi.createFeed("1001", { feedContent: "不得写入正式夹具" }),
(error) => error?.code === "WRITE_UNAVAILABLE" && /不会保存/.test(error.message),
"mock createFeed 必须明确失败,不能污染只读内容 owner",
);
assert.deepStrictEqual(listFamilyFeedFixtures("1001").map((item) => item.id), ["1", "2"]);
process.stdout.write("FAMILY-FIXTURE-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+202
View File
@@ -0,0 +1,202 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {};
const genealogies = [];
const publicGenealogies = [];
const treeMembers = [];
const notifications = [];
const joinApplications = [];
const listFamilyFeedFixtures = () => [];
const listFamilyArticleFixtures = () => [];
const listFamilyAlbumFixtures = () => [];
const listCeremonyFixtures = () => [];
const listGrowthRecordFixtures = () => [];
const listNotificationFixtures = () => [];
const runtimeConfig = {
mode: "remote",
baseUrl: "https://backend-api.ddxcjp.cn",
clientId: "client-1",
tenantId: "000000",
};
const hasRemoteConfig = () => globalThis.__runtimeMode === "remote";
const resolveRuntimeMode = () => {
if (globalThis.__runtimeMode === "mock") return "mock";
if (globalThis.__runtimeMode === "remote") return "remote";
throw new Error("运行模式配置无效:只允许 mock 或配置完整的 remote");
};
const AUTH_TAC_SCENE = Object.freeze({
SMS_LOGIN: "APP_SMS_LOGIN",
REGISTER: "APP_REGISTER",
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
});
const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const session = {
getToken: () => "session-token",
saveToken: () => {},
};
`;
globalThis.__runtimeMode = "remote";
const requests = [];
let nextResponse = null;
let nextFailure = null;
let holdResponse = false;
globalThis.uni = {
request(options) {
requests.push(options);
const task = {
aborted: false,
abort() {
this.aborted = true;
options.fail({ errMsg: "request:fail abort" });
},
};
options.__task = task;
if (!holdResponse) {
queueMicrotask(() => {
if (nextFailure) options.fail(nextFailure);
else options.success(nextResponse);
});
}
return task;
},
};
const { appApi, createRequestController, isRequestCancelled } = await import(
toDataModuleUrl(`${prelude}\n${moduleBody}`),
);
const submit = (payload = {
feedbackType: "功能问题",
feedbackContent: "上传照片时出现异常",
contactInfo: "user@example.com",
}) => appApi.submitFeedback(payload);
for (const invalid of [
null,
[],
{},
{ feedbackContent: " " },
{ feedbackContent: 123 },
{ feedbackContent: "内容", feedbackType: 1 },
{ feedbackContent: "内容", contactInfo: false },
{ feedbackContent: "内容", type: "旧字段" },
Object.assign(Object.create({ feedbackType: "继承字段" }), { feedbackContent: "内容" }),
]) {
await assert.rejects(submit(invalid), /反馈|字段|对象|字符串/);
}
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { accepted: true } } };
const contentOnlyResult = await submit({ feedbackContent: " 仅反馈内容 " });
assert.deepStrictEqual(contentOnlyResult, { accepted: true });
assert.deepStrictEqual(requests.at(-1).data, { feedbackContent: "仅反馈内容" });
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { feedbackId: 1 } } };
const result = await submit({ feedbackContent: " 有效反馈 ", feedbackType: " 使用建议 ", contactInfo: " " });
assert.deepStrictEqual(result, { feedbackId: 1 });
assert.deepStrictEqual(requests.at(-1).data, {
feedbackContent: "有效反馈",
feedbackType: "使用建议",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/feedback");
assert.strictEqual(requests.at(-1).method, "POST");
assert.strictEqual(requests.at(-1).timeout, 15000);
assert.strictEqual(requests.at(-1).header.clientid, "client-1");
assert.strictEqual(requests.at(-1).header.Authorization, "Bearer session-token");
nextResponse = { statusCode: 200, data: { code: 500, msg: "业务拒绝", data: null } };
await assert.rejects(submit(), (error) =>
error.code === "BUSINESS_ERROR" && error.businessCode === 500,
);
nextResponse = { statusCode: 401, data: { code: 401, msg: "登录失效", data: null } };
await assert.rejects(submit(), (error) =>
error.code === "HTTP_ERROR" && error.httpStatus === 401,
);
for (const statusCode of [201, 204]) {
nextResponse = { statusCode, data: { code: 200, msg: "成功", data: null } };
await assert.rejects(submit(), (error) =>
error.code === "HTTP_ERROR" && error.httpStatus === statusCode,
);
}
for (const invalidResponse of [
{ statusCode: 200, data: null },
{ statusCode: 200, data: {} },
{ statusCode: 200, data: { code: "200", data: null } },
{ statusCode: 200, data: { code: null, data: null } },
{ statusCode: 200, data: { code: false, data: null } },
]) {
nextResponse = invalidResponse;
await assert.rejects(submit(), (error) => error.code === "RESPONSE_INVALID");
}
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功" } };
assert.deepStrictEqual(
await submit({ feedbackContent: "响应无需 data" }),
{ code: 200, msg: "成功" },
"反馈响应未声明 data 必填,不得把合法成功误报为未知",
);
const requestCountBeforeUnsafeOptions = requests.length;
await assert.rejects(
appApi.submitFeedback(
{ feedbackContent: "不得关闭认证头" },
{ authenticated: false },
),
/请求选项|字段/,
);
assert.strictEqual(requests.length, requestCountBeforeUnsafeOptions);
nextFailure = { errMsg: "request:fail timeout" };
await assert.rejects(submit(), (error) => error.code === "REQUEST_TIMEOUT");
nextFailure = null;
holdResponse = true;
const controller = createRequestController();
const cancelled = appApi.submitFeedback(
{ feedbackContent: "离页取消" },
{ requestController: controller },
);
const activeTask = requests.at(-1).__task;
controller.abort();
await assert.rejects(cancelled, (error) => isRequestCancelled(error));
assert.strictEqual(activeTask.aborted, true);
holdResponse = false;
globalThis.__runtimeMode = "mock";
const requestCount = requests.length;
await assert.rejects(
appApi.submitFeedback({ feedbackContent: "本地不得伪提交" }),
(error) => error.code === "WRITE_UNAVAILABLE",
);
assert.strictEqual(requests.length, requestCount, "mock 模式不得发送或伪造反馈请求");
globalThis.__runtimeMode = "invalid";
await assert.rejects(
appApi.submitFeedback({ feedbackContent: "非法环境不得降级成 mock" }),
/运行模式配置无效/,
);
assert.strictEqual(requests.length, requestCount, "非法环境不得发送请求或伪装成本地预览");
delete globalThis.uni;
delete globalThis.__runtimeMode;
process.stdout.write("FEEDBACK-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+49
View File
@@ -0,0 +1,49 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$json = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$path = '/genealogy/app/feedback'
$pathProperty = $json.paths.PSObject.Properties[$path]
if (-not $pathProperty) { throw 'Feedback POST path is missing' }
$operation = $pathProperty.Value.post
if (-not $operation) { throw 'Feedback POST operation is missing' }
if ($operation.requestBody.required -ne $true) { throw 'Feedback request body must be required' }
if ($operation.requestBody.content.'application/json'.schema.'$ref' -ne '#/components/schemas/FeedbackBody') {
throw 'Feedback operation must consume FeedbackBody'
}
$hasSaToken = $false
foreach ($securityRequirement in @($operation.security)) {
if ($securityRequirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { throw 'Feedback operation must require SaToken' }
$body = $json.components.schemas.FeedbackBody
$fields = @($body.properties.PSObject.Properties.Name | Sort-Object)
if (($fields -join ',') -ne 'contactInfo,feedbackContent,feedbackType') {
throw "FeedbackBody fields drifted: $($fields -join ',')"
}
$required = @($body.required | Sort-Object)
if (($required -join ',') -ne 'feedbackContent') {
throw "FeedbackBody required fields drifted: $($required -join ',')"
}
foreach ($field in $fields) {
if ($body.properties.$field.type -ne 'string') {
throw "FeedbackBody.$field must be a string"
}
}
foreach ($yamlFact in @(
' /genealogy/app/feedback:',
"`$ref: '#/components/schemas/FeedbackBody'",
' FeedbackBody:',
' - feedbackContent',
' feedbackType:',
' feedbackContent:',
' contactInfo:'
)) {
if (-not $yaml.Contains($yamlFact)) { throw "Feedback YAML fact is missing: $yamlFact" }
}
Write-Output 'FEEDBACK-OPENAPI-CONTRACT PASS'
+307
View File
@@ -0,0 +1,307 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$jsonPath = Join-Path $root 'APP.openapi.json'
$yamlPath = Join-Path $root 'APP.openapi.yaml'
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath $yamlPath
$helper = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/generation-poem.js')
function Get-YamlBlock {
param(
[string]$Header,
[string]$NextHeaderPattern
)
$lines = @($yaml -split "`r?`n")
$start = [Array]::IndexOf($lines, $Header)
if ($start -lt 0) { throw "YAML block missing: $Header" }
$end = $lines.Count
for ($index = $start + 1; $index -lt $lines.Count; $index += 1) {
if ($lines[$index] -match $NextHeaderPattern) {
$end = $index
break
}
}
return ($lines[$start..($end - 1)] -join "`n")
}
function Get-YamlNestedBlock {
param(
[string]$Content,
[string]$Header,
[string]$NextHeaderPattern
)
$pattern = '(?ms)^' + [regex]::Escape($Header) + "`n(?<body>.*?)(?=$NextHeaderPattern|\z)"
$match = [regex]::Match($Content, $pattern)
if (-not $match.Success) { throw "YAML nested block missing: $Header" }
return $match.Value
}
foreach ($path in @(
'/genealogy/app/genealogies',
'/genealogy/app/genealogies/{genealogyId}',
'/genealogy/app/genealogies/{genealogyId}/join-applies',
'/genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit',
'/genealogy/app/genealogies/{genealogyId}/generation-poems',
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview',
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save',
'/genealogy/app/genealogies/{genealogyId}/generation-poems/management'
)) {
if (-not $document.paths.PSObject.Properties[$path]) { throw "G-series OpenAPI path missing: $path" }
if (-not $yaml.Contains(" $path`:")) { throw "G-series YAML path missing: $path" }
}
# 创建、设置与访问预设从本轮起只由 g03-bootstrap-openapi-contract.ps1 管理.
# 旧测试不得继续锁定 GenealogyCreateBody 或 visibility/joinMode,否则会把后端的新单一合同误判为回归.
$joinApply = $document.components.schemas.GenealogyJoinApplyBody
foreach ($field in @('applicantName', 'phone', 'relationDesc', 'applyReason', 'inviterUserId')) {
if (-not $joinApply.properties.PSObject.Properties[$field]) {
throw "GenealogyJoinApplyBody field missing: $field"
}
}
$audit = $document.components.schemas.GenealogyJoinAuditBody
if ('status' -notin @($audit.required)) { throw 'GenealogyJoinAuditBody.status must remain required' }
foreach ($field in @('status', 'auditRemark')) {
if (-not $audit.properties.PSObject.Properties[$field]) {
throw "GenealogyJoinAuditBody field missing: $field"
}
}
$batch = $document.components.schemas.GenerationPoemBatchBody
$batchProperties = @($batch.properties.PSObject.Properties.Name | Sort-Object)
if (($batchProperties -join ',') -ne 'disableMissing,poemText') {
throw "GenerationPoemBatchBody fields drifted: $($batchProperties -join ',')"
}
$batchRequired = @($batch.required | Sort-Object)
if (($batchRequired -join ',') -ne 'poemText') {
throw "GenerationPoemBatchBody required fields drifted: $($batchRequired -join ',')"
}
if ($batch.properties.poemText.type -ne 'string') { throw 'GenerationPoemBatchBody.poemText must remain a string' }
if ($batch.properties.disableMissing.type -ne 'boolean') { throw 'GenerationPoemBatchBody.disableMissing must remain a boolean' }
if ($batch.properties.poemText.maxLength -ne 26000) { throw 'GenerationPoemBatchBody.poemText maxLength must be 26000' }
$poemDescription = [string]$batch.properties.poemText.description
foreach ($fact in @(
'\u5355\u4e2a\u5b57\u8f88\u6700\u591a50\u4e2a\u5b57\u7b26',
'\u4e00\u6b21\u6700\u591a500\u4e2a\u4e16\u4ee3',
'\u7a7a\u683c\u3001\u9017\u53f7\u3001\u5206\u53f7\u3001\u987f\u53f7\u3001\u659c\u6760\u6216\u7ad6\u7ebf'
)) {
if ($poemDescription -notmatch $fact) { throw "GenerationPoemBatchBody description missing: $fact" }
}
if ([string]$batch.properties.disableMissing.description -notmatch '\u4e0d\u4f1a\u5220\u9664\u5386\u53f2\u8bb0\u5f55') {
throw 'GenerationPoemBatchBody.disableMissing no longer guarantees history retention'
}
foreach ($path in @(
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview',
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save'
)) {
$bodyRef = $document.paths.$path.post.requestBody.content.'application/json'.schema.'$ref'
if ($bodyRef -ne '#/components/schemas/GenerationPoemBatchBody') {
throw "Batch endpoint request body drifted: $path -> $bodyRef"
}
}
$poemView = $document.components.schemas.GenerationPoemView
$poemViewFields = @{
poemId = @{ Type = 'integer'; Format = 'int64' }
generationNo = @{ Type = 'integer'; Format = 'int64' }
generationText = @{ Type = 'string'; Format = $null }
status = @{ Type = 'string'; Format = $null }
}
foreach ($field in $poemViewFields.Keys) {
$property = $poemView.properties.PSObject.Properties[$field].Value
if (-not $property) { throw "GenerationPoemView field missing: $field" }
if ($property.type -ne $poemViewFields[$field].Type -or $property.format -ne $poemViewFields[$field].Format) {
throw "GenerationPoemView.$field type/format drifted"
}
}
if ([string]$poemView.properties.status.description -notmatch '0\u6b63\u5e38\uff0c1\u505c\u7528') {
throw 'GenerationPoemView status 0/1 contract drifted'
}
$normalList = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get
if ([string]$normalList.description -notmatch '\u53ef\u67e5\u770b\u5bb6\u8c31' -or [string]$normalList.description -notmatch '\u4ec5\u8fd4\u56de\u6b63\u5e38\u72b6\u6001') {
throw 'Normal generation-poem list capability or active-only projection drifted'
}
$managementList = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems/management'.get
if ([string]$managementList.description -notmatch '\u5185\u5bb9\u7f16\u8f91\u8005' -or [string]$managementList.description -notmatch '\u6b63\u5e38\u548c\u505c\u7528') {
throw 'Management generation-poem list capability or status projection drifted'
}
foreach ($operation in @($normalList, $managementList)) {
if ($operation.responses.'200'.'$ref' -ne '#/components/responses/GenerationPoemListResult') {
throw 'Generation-poem list operation no longer returns GenerationPoemListResult'
}
}
if ($document.components.responses.GenerationPoemListResult.content.'application/json'.schema.'$ref' -ne '#/components/schemas/RGenerationPoemList') {
throw 'GenerationPoemListResult wrapper drifted'
}
$poemListData = $document.components.schemas.RGenerationPoemList.properties.data
if ($poemListData.type -ne 'array' -or $poemListData.items.'$ref' -ne '#/components/schemas/GenerationPoemView') {
throw 'RGenerationPoemList.data no longer exposes GenerationPoemView items'
}
$previewPath = '/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview'
if ($document.paths.$previewPath.post.responses.'200'.'$ref' -ne '#/components/responses/GenerationPoemBatchPreviewResult') {
throw 'Generation-poem preview response no longer uses GenerationPoemBatchPreviewResult'
}
if ($document.components.responses.GenerationPoemBatchPreviewResult.content.'application/json'.schema.'$ref' -ne '#/components/schemas/RGenerationPoemBatchPreview') {
throw 'GenerationPoemBatchPreviewResult wrapper drifted'
}
if ($document.components.schemas.RGenerationPoemBatchPreview.properties.data.'$ref' -ne '#/components/schemas/GenerationPoemBatchPreviewView') {
throw 'RGenerationPoemBatchPreview.data no longer exposes the preview view'
}
$previewView = $document.components.schemas.GenerationPoemBatchPreviewView
if ($previewView.properties.items.type -ne 'array' -or $previewView.properties.items.items.'$ref' -ne '#/components/schemas/GenerationPoemBatchItemView') {
throw 'GenerationPoemBatchPreviewView.items contract drifted'
}
$previewItem = $document.components.schemas.GenerationPoemBatchItemView
$previewItemExpected = @{
poemId = @{ Type = 'integer'; Format = 'int64' }
generationNo = @{ Type = 'integer'; Format = 'int64' }
oldGenerationText = @{ Type = 'string'; Format = $null }
newGenerationText = @{ Type = 'string'; Format = $null }
oldStatus = @{ Type = 'string'; Format = $null }
newStatus = @{ Type = 'string'; Format = $null }
action = @{ Type = 'string'; Format = $null }
warning = @{ Type = 'string'; Format = $null }
}
$previewItemFields = @($previewItem.properties.PSObject.Properties.Name | Sort-Object)
if (($previewItemFields -join ',') -ne (@($previewItemExpected.Keys | Sort-Object) -join ',')) {
throw "GenerationPoemBatchItemView fields drifted: $($previewItemFields -join ',')"
}
foreach ($field in $previewItemExpected.Keys) {
$property = $previewItem.properties.PSObject.Properties[$field].Value
if ($property.type -ne $previewItemExpected[$field].Type -or $property.format -ne $previewItemExpected[$field].Format) {
throw "GenerationPoemBatchItemView.$field type/format drifted"
}
}
if ([string]$previewItem.properties.action.description -notmatch 'create.*update.*keep.*disable') {
throw 'GenerationPoemBatchItemView.action dictionary drifted'
}
$yamlBatch = Get-YamlBlock ' GenerationPoemBatchBody:' '^ [A-Za-z0-9_]+:$'
if ($yamlBatch -notmatch '(?m)^ required:\n - poemText\n properties:$') {
throw 'YAML GenerationPoemBatchBody required fields drifted'
}
$yamlBatchFields = @([regex]::Matches($yamlBatch, '(?m)^ (?<field>[A-Za-z][A-Za-z0-9]*):$') | ForEach-Object { $_.Groups['field'].Value } | Sort-Object)
if (($yamlBatchFields -join ',') -ne 'disableMissing,poemText') {
throw "YAML GenerationPoemBatchBody fields drifted: $($yamlBatchFields -join ',')"
}
$yamlPoemText = Get-YamlNestedBlock $yamlBatch ' poemText:' '^ [A-Za-z0-9_]+:$'
foreach ($pattern in @('(?m)^ type: string$', '(?m)^ maxLength: 26000$', '\u5355\u4e2a\u5b57\u8f88\u6700\u591a50\u4e2a\u5b57\u7b26', '\u4e00\u6b21\u6700\u591a500\u4e2a\u4e16\u4ee3')) {
if ($yamlPoemText -notmatch $pattern) { throw "YAML GenerationPoemBatchBody.poemText drifted: $pattern" }
}
$yamlDisableMissing = Get-YamlNestedBlock $yamlBatch ' disableMissing:' '^ [A-Za-z0-9_]+:$'
foreach ($pattern in @('(?m)^ type: boolean$', '\u4e0d\u4f1a\u5220\u9664\u5386\u53f2\u8bb0\u5f55')) {
if ($yamlDisableMissing -notmatch $pattern) { throw "YAML GenerationPoemBatchBody.disableMissing drifted: $pattern" }
}
$yamlPoemView = Get-YamlBlock ' GenerationPoemView:' '^ [A-Za-z0-9_]+:$'
$yamlPoemViewExpected = @{
poemId = @{ Type = 'integer'; Format = 'int64' }
generationNo = @{ Type = 'integer'; Format = 'int64' }
generationText = @{ Type = 'string'; Format = $null }
status = @{ Type = 'string'; Format = $null }
}
foreach ($field in $yamlPoemViewExpected.Keys) {
$fieldBlock = Get-YamlNestedBlock $yamlPoemView " $field`:" '^ [A-Za-z0-9_]+:$'
if ($fieldBlock -notmatch "(?m)^ type: $($yamlPoemViewExpected[$field].Type)$") {
throw "YAML GenerationPoemView.$field type drifted"
}
$format = $yamlPoemViewExpected[$field].Format
if (($format -and $fieldBlock -notmatch "(?m)^ format: $format$") -or (-not $format -and $fieldBlock -match '(?m)^ format:')) {
throw "YAML GenerationPoemView.$field format drifted"
}
}
$yamlStatus = Get-YamlNestedBlock $yamlPoemView ' status:' '^ [A-Za-z0-9_]+:$'
if ($yamlStatus -notmatch 'description:.*0\u6b63\u5e38\uff0c1\u505c\u7528' -or
$yamlStatus -notmatch '(?m)^ type: string$') {
throw 'YAML GenerationPoemView.status contract drifted'
}
$yamlPreviewPath = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview:' '^ /.*:$'
if ($yamlPreviewPath -notmatch [regex]::Escape("`$ref: '#/components/responses/GenerationPoemBatchPreviewResult'")) {
throw 'YAML generation-poem preview response drifted'
}
$yamlPreviewResponse = Get-YamlBlock ' GenerationPoemBatchPreviewResult:' '^ [A-Za-z0-9_]+:$'
if ($yamlPreviewResponse -notmatch [regex]::Escape("`$ref: '#/components/schemas/RGenerationPoemBatchPreview'")) {
throw 'YAML GenerationPoemBatchPreviewResult wrapper drifted'
}
$yamlPreviewWrapper = Get-YamlBlock ' RGenerationPoemBatchPreview:' '^ [A-Za-z0-9_]+:$'
if ($yamlPreviewWrapper -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchPreviewView'")) {
throw 'YAML RGenerationPoemBatchPreview.data drifted'
}
$yamlPreviewView = Get-YamlBlock ' GenerationPoemBatchPreviewView:' '^ [A-Za-z0-9_]+:$'
$yamlPreviewItems = Get-YamlNestedBlock $yamlPreviewView ' items:' '^ [A-Za-z0-9_]+:$'
if ($yamlPreviewItems -notmatch '(?m)^ type: array$' -or $yamlPreviewItems -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchItemView'")) {
throw 'YAML GenerationPoemBatchPreviewView.items drifted'
}
$yamlPreviewItem = Get-YamlBlock ' GenerationPoemBatchItemView:' '^ [A-Za-z0-9_]+:$'
$yamlPreviewItemFields = @([regex]::Matches($yamlPreviewItem, '(?m)^ (?<field>[A-Za-z][A-Za-z0-9]*):$') | ForEach-Object { $_.Groups['field'].Value } | Sort-Object)
if (($yamlPreviewItemFields -join ',') -ne (@($previewItemExpected.Keys | Sort-Object) -join ',')) {
throw "YAML GenerationPoemBatchItemView fields drifted: $($yamlPreviewItemFields -join ',')"
}
foreach ($field in $previewItemExpected.Keys) {
$fieldBlock = Get-YamlNestedBlock $yamlPreviewItem " $field`:" '^ [A-Za-z0-9_]+:$'
if ($fieldBlock -notmatch "(?m)^ type: $($previewItemExpected[$field].Type)$") {
throw "YAML GenerationPoemBatchItemView.$field type drifted"
}
$format = $previewItemExpected[$field].Format
if (($format -and $fieldBlock -notmatch "(?m)^ format: $format$") -or (-not $format -and $fieldBlock -match '(?m)^ format:')) {
throw "YAML GenerationPoemBatchItemView.$field format drifted"
}
}
$yamlPreviewAction = Get-YamlNestedBlock $yamlPreviewItem ' action:' '^ [A-Za-z0-9_]+:$'
if ($yamlPreviewAction -notmatch 'create.*update.*keep.*disable') {
throw 'YAML GenerationPoemBatchItemView.action dictionary drifted'
}
$yamlNormalList = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems:' '^ /.*:$'
$yamlNormalGet = Get-YamlNestedBlock $yamlNormalList ' get:' '^ [a-z]+:$'
if ($yamlNormalGet -notmatch '\u53ef\u67e5\u770b\u5bb6\u8c31' -or $yamlNormalGet -notmatch '\u4ec5\u8fd4\u56de\u6b63\u5e38\u72b6\u6001' -or
$yamlNormalGet -notmatch [regex]::Escape("`$ref: '#/components/responses/GenerationPoemListResult'")) {
throw 'YAML normal generation-poem capability or projection drifted'
}
$yamlManagementList = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems/management:' '^ /.*:$'
$yamlManagementGet = Get-YamlNestedBlock $yamlManagementList ' get:' '^ [a-z]+:$'
if ($yamlManagementGet -notmatch '\u5185\u5bb9\u7f16\u8f91\u8005' -or $yamlManagementGet -notmatch '\u6b63\u5e38\u548c\u505c\u7528' -or
$yamlManagementGet -notmatch [regex]::Escape("`$ref: '#/components/responses/GenerationPoemListResult'")) {
throw 'YAML management generation-poem capability or projection drifted'
}
$yamlPoemListResponse = Get-YamlBlock ' GenerationPoemListResult:' '^ [A-Za-z0-9_]+:$'
if ($yamlPoemListResponse -notmatch [regex]::Escape("`$ref: '#/components/schemas/RGenerationPoemList'")) {
throw 'YAML GenerationPoemListResult wrapper drifted'
}
$yamlPoemListWrapper = Get-YamlBlock ' RGenerationPoemList:' '^ [A-Za-z0-9_]+:$'
$yamlPoemListData = Get-YamlNestedBlock $yamlPoemListWrapper ' data:' '^ [A-Za-z0-9_]+:$'
if ($yamlPoemListData -notmatch '(?m)^ type: array$' -or
$yamlPoemListData -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemView'")) {
throw 'YAML RGenerationPoemList.data drifted'
}
$yamlPreviewPost = Get-YamlNestedBlock $yamlPreviewPath ' post:' '^ [a-z]+:$'
if ($yamlPreviewPost -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchBody'")) {
throw 'YAML generation-poem preview request body drifted'
}
$yamlSavePath = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save:' '^ /.*:$'
$yamlSavePost = Get-YamlNestedBlock $yamlSavePath ' post:' '^ [a-z]+:$'
if ($yamlSavePost -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchBody'")) {
throw 'YAML generation-poem save request body drifted'
}
foreach ($constant in @(
'MAX_GENERATION_COUNT = 500',
'MAX_GENERATION_TEXT_LENGTH = 50',
'MAX_GENERATION_POEM_INPUT_LENGTH = 26000',
"ACTIVE: '0'",
"DISABLED: '1'"
)) {
if (-not $helper.Contains($constant)) { throw "Generation poem helper is not synchronized with OpenAPI: $constant" }
}
foreach ($schemaName in @('GenealogyJoinApplyBody', 'GenealogyJoinAuditBody', 'GenerationPoemBatchBody', 'GenerationPoemView')) {
if (-not $yaml.Contains(" $schemaName`:")) { throw "G-series YAML schema missing: $schemaName" }
}
Write-Output 'G-SERIES-OPENAPI-CONTRACT PASS'
-2
View File
@@ -38,8 +38,6 @@ foreach ($required in @(
'empty-create-note',
'@include adaptive.adaptive-genealogy-state-panel;',
'const createGenealogy = () =>',
'/pages/genealogy/g03-create-genealogy',
'/pages/genealogy/g06-search-genealogies',
'const hasGenealogies = computed('
)) {
if ($g01 -notmatch [regex]::Escape($required)) { throw "Missing G01 empty-state contract: $required" }
+10 -1
View File
@@ -2,10 +2,19 @@ $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}')) {
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding UTF8
$context = Get-Content -LiteralPath (Join-Path $root 'utils/genealogy-context.js') -Raw -Encoding UTF8
foreach ($token in @('genealogyContext', 'setCurrentGenealogyId', 'reconcileCurrentGenealogyId', 'invalidateCurrentGenealogyId', 'isCurrentGenealogyInvalidated', 'reconcilePageGenealogyContext', 'getGenealogyFixtureAccess(item.id).accessRole === "owner"', 'getGenealogyFixtureAccess(item.id).accessRole === "member"', 'String(query?.genealogyId || "")', 'contextInvalidated', 'state-panel--context', ') || null,', '<button', ':aria-pressed=')) {
if (-not $page.Contains($token)) { throw "G01 context contract missing: $token" }
}
foreach ($token in @('CURRENT_GENEALOGY_INVALIDATED_KEY', 'normalizeGenealogyId', 'getCurrentGenealogyId: readCurrentGenealogyId', 'invalidateCurrentGenealogyId:', 'isCurrentGenealogyInvalidated:', 'new Set(normalizedIds).size !== normalizedIds.length', 'normalizedIds.includes(normalizedPreferredId)', 'normalizedIds.includes(storedId)', 'if (normalizedPreferredId)', 'if (storedId)')) {
if (-not $context.Contains($token)) { throw "Current genealogy owner contract missing: $token" }
}
if ($page.Contains('Number(query?.genealogyId)')) { throw 'G01 must not coerce a genealogy ID to Number' }
if ($page -match 'currentGenealogy\s*=\s*computed\([\s\S]*?\|\|\s*availableGenealogies\.value\[0\]') { throw 'G01 must not silently fall back to the first genealogy after permission loss' }
if ($page -notmatch '(?s)const retryLoad = \(\) => \{.*?reconcilePageGenealogyContext\(\)') { throw 'G01 context failure retry must rerun reconciliation instead of exposing a dead button' }
foreach ($membership in @("membership: 'created'", "membership: 'joined'")) {
if (-not $mock.Contains($membership)) { throw "Genealogy mock ownership missing: $membership" }
}
if (-not $api.Contains('id: String(Date.now())')) { throw 'Mock genealogy creation must produce a lexical string ID' }
Write-Output 'G01-GENEALOGY-CONTEXT-CONTRACT PASS'
+5 -1
View File
@@ -7,7 +7,11 @@ function Assert-Contains([string]$Content, [string]$Pattern, [string]$Message) {
Assert-Contains $page 'v-for="item in visibleShortcuts"' 'G01 must render the membership-filtered shortcut list'
Assert-Contains $page '{{ currentRoleLabel }}' 'G01 must render the membership-derived role label'
Assert-Contains $page 'currentGenealogy.value?.membership === "created"' 'G01 must derive owner state from membership'
Assert-Contains $page 'getGenealogyFixtureAccess(currentGenealogy.value?.id).accessRole' 'G01 must consume the shared fixture access resolver'
Assert-Contains $page 'item.key !== "applications"' 'G01 must remove application review for joined members'
$selectedBinding = ':selected="item.id === currentGenealogy.id"'
if (([regex]::Matches($page, [regex]::Escape($selectedBinding))).Count -ne 2) {
throw 'G01 created and joined groups must render the same current-genealogy state'
}
Write-Output 'G01-ROLE-SHORTCUT-CONTRACT PASS'
+17
View File
@@ -244,6 +244,7 @@ const run = async () => {
await valueOf(send, "document.querySelectorAll('.switcher-item')[1].click()")
await waitFor(send, "!document.querySelector('.genealogy-switcher')", 'Selecting the second genealogy did not close the switcher')
assert((await valueOf(send, "document.querySelector('.current-slip')?.textContent"))?.includes('山东'), 'Selecting the second genealogy did not update the current genealogy')
assert((await valueOf(send, "document.querySelector('.genealogy-card--current')?.textContent"))?.includes('汤氏宗谱'), 'Joined genealogy card did not expose the current visual state')
assert(
(await valueOf(send, "document.querySelector('.current-meta')?.textContent"))?.includes('成员'),
'Joined genealogy did not display the member role'
@@ -271,6 +272,22 @@ const run = async () => {
await openSwitcher(send)
await clearClones(send)
await valueOf(send, "document.querySelector('.genealogy-switcher__close').click()")
await valueOf(send, `(() => {
const cards = document.querySelectorAll('.genealogy-card')
if (cards.length < 2) return false
cards[0].click()
cards[1].click()
return true
})()`)
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=1001')", 'Rapid genealogy clicks did not keep the first accepted navigation target')
await valueOf(send, "document.querySelector('.header-back')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies')", 'G05 did not return to the existing G01 instance')
assert((await valueOf(send, "document.querySelector('.current-slip')?.textContent"))?.includes('河南'), 'Rejected second navigation overwrote the visible current genealogy')
assert((await valueOf(send, "document.querySelector('.current-meta')?.textContent"))?.includes('管理员'), 'Rejected second navigation overwrote the persisted genealogy context')
await openSwitcher(send)
await clearClones(send)
const final = await getMetrics(send)
assert(final.viewport.width === 412 && final.viewport.height === 915, 'Final viewport is not 412x915')
assert(final.itemCount === 2 && final.cloneCount === 0, 'Final state contains temporary items')
-3
View File
@@ -167,9 +167,6 @@ if ($page -notmatch '(?s)class="genealogy-switcher__close"[^>]*aria-label="\u517
if ($page -match 'class="dialog-close"[^>]*@click="closeSwitcher"') {
throw 'G-01 switcher still exposes the obsolete bottom close copy.'
}
if ($page -notmatch '(?s)onBackPress\(\(\)\s*=>\s*\{\s*if\s*\(switcherVisible\.value\)\s*\{\s*closeSwitcher\(\);\s*return\s*true;\s*\}\s*if\s*\(addDialogVisible\.value\)') {
throw 'G-01 switcher does not consume Android back before the add dialog and page navigation.'
}
if ($page -notmatch 'import\s*\{[^}]*onBackPress[^}]*\}\s*from\s*"@dcloudio/uni-app"') {
throw 'G-01 add sheet does not import onBackPress.'
}
+154
View File
@@ -0,0 +1,154 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$issues = New-Object System.Collections.Generic.List[string]
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Read-RequiredFile {
param([string]$RelativePath)
$path = Join-Path $root $RelativePath
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
Add-Issue "missing required client owner: $RelativePath"
return ''
}
return Get-Content -Raw -Encoding UTF8 -LiteralPath $path
}
function Assert-Contains {
param([string]$Content, [string]$Label, [string]$Expected)
if (-not $Content.Contains($Expected)) {
Add-Issue "$Label missing production ownership marker: $Expected"
}
}
function Assert-DoesNotContain {
param([string]$Content, [string]$Label, [string]$Forbidden)
if ($Content.Contains($Forbidden)) {
Add-Issue "$Label retains retired preview/legacy owner: $Forbidden"
}
}
$page = Read-RequiredFile 'pages/genealogy/g03-create-genealogy.vue'
$api = Read-RequiredFile 'utils/api.js'
$coordinator = Read-RequiredFile 'utils/genealogy-bootstrap.js'
$accessContract = Read-RequiredFile 'utils/genealogy-contracts.js'
$mock = Read-RequiredFile 'data/mock.js'
$staticContract = Read-RequiredFile 'tests/g03-create-flow-contract.ps1'
$legacyRuntimeSmoke = Read-RequiredFile 'tests/g03-create-flow-runtime-smoke.js'
$runtimePath = Join-Path $root 'tests/g03-bootstrap-runtime-smoke.js'
# Keep only durable public ownership checks here. Internal helper names, cache strategy,
# debounce timers, and test titles are intentionally left to executable behavior tests.
Assert-Contains $page 'G03 page' '@/utils/genealogy-bootstrap.js'
Assert-Contains $page 'G03 page' 'createGenealogyBootstrapCoordinator'
Assert-Contains $api 'utils/api.js' '/genealogy/app/genealogies'
Assert-Contains $api 'utils/api.js' '/genealogy/app/genealogy-bootstrap-operations/'
Assert-Contains $api 'utils/api.js' '/genealogy/app/region/search'
Assert-Contains $api 'utils/api.js' 'Idempotency-Key'
Assert-Contains $coordinator 'utils/genealogy-bootstrap.js' 'createGenealogyBootstrapCoordinator'
foreach ($entry in @(
[pscustomobject]@{ Content = $page; Label = 'G03 page' },
[pscustomobject]@{ Content = $mock; Label = 'data/mock.js' },
[pscustomobject]@{ Content = $staticContract; Label = 'G03 static flow contract' },
[pscustomobject]@{ Content = $legacyRuntimeSmoke; Label = 'G03 legacy runtime smoke' }
)) {
foreach ($retiredOwner in @(
'createLocalGenealogyPreview',
'updateLocalGenealogyPreview',
'updateLocalGenealogyPreviewAncestor',
'removeLocalGenealogyPreview',
'local-created-'
)) {
Assert-DoesNotContain $entry.Content $entry.Label $retiredOwner
}
}
foreach ($retiredApiOwner in @('async createGenealogy(', 'genealogies.unshift(created)')) {
Assert-DoesNotContain $api 'utils/api.js' $retiredApiOwner
}
foreach ($retiredAccessOwner in @(
'visibility:',
'joinMode:',
'fromApiGenealogyAccess',
'toApiGenealogyAccess'
)) {
Assert-DoesNotContain $accessContract 'utils/genealogy-contracts.js' $retiredAccessOwner
}
# The release gate executes the dependency-injected state machine suite. The suite must
# deep-compare the exact marker, use request/storage/cache/context/navigation spies, and
# publish a machine-readable case ledger only after all assertions pass.
$expectedCases = @(
'ACCOUNT_EPOCH_ISOLATION',
'CONTEXT_FAILURE_LOCAL_RETRY',
'FAILED_CLEAR',
'FATAL_EXPLICIT_ABANDON_CLEAR',
'KEY_REUSED_QUARANTINE',
'KNOWN_NO_COMMIT',
'LOCAL_ZERO_DISPATCH',
'MARKER_EXACT',
'NAVIGATION_FAILURE_LOCAL_RETRY',
'NO_MOCK_MUTATION',
'NO_NEW_KEY',
'NO_PII',
'PENDING_RETRY_AFTER',
'POST_408_UNKNOWN',
'POST_CANCEL_UNKNOWN',
'POST_UNEXPECTED_STATUS_UNKNOWN',
'POST_UNKNOWN',
'PRECLAIM_400',
'PRECLAIM_401',
'PRECLAIM_403',
'PROCESS_RECOVERY_STATUS_ONLY',
'REQUEST_BUILD_ZERO_DISPATCH',
'RETRY_429',
'STATUS_400_CLEAR',
'STATUS_401_CLEAR_SESSION',
'STATUS_404_KEEP',
'STATUS_429_KEEP',
'STATUS_500_KEEP',
'STATUS_CANCEL_KEEP',
'STATUS_MALFORMED_KEEP',
'STATUS_NETWORK_KEEP',
'STATUS_UNEXPECTED_STATUS_KEEP',
'SUCCEEDED_ORDER'
) | Sort-Object
if (-not (Test-Path -LiteralPath $runtimePath -PathType Leaf)) {
Add-Issue 'missing executable state-machine suite: tests/g03-bootstrap-runtime-smoke.js'
} else {
$runtimeOutput = @(& node $runtimePath 2>&1)
$runtimeExit = $LASTEXITCODE
if ($runtimeExit -ne 0) {
Add-Issue "G03 runtime state-machine suite failed: $($runtimeOutput -join ' | ')"
} else {
if ('G03-BOOTSTRAP-RUNTIME PASS' -notin $runtimeOutput) {
Add-Issue 'G03 runtime state-machine suite did not emit its PASS marker'
}
$coverageLine = @($runtimeOutput | Where-Object { $_ -like 'G03-BOOTSTRAP-RUNTIME-COVERAGE *' })
if ($coverageLine.Count -ne 1) {
Add-Issue 'G03 runtime state-machine suite must emit exactly one coverage ledger'
} else {
$actualCases = @(($coverageLine[0] -replace '^G03-BOOTSTRAP-RUNTIME-COVERAGE\s+', '').Split(',') | Where-Object { $_ } | Sort-Object)
if (($actualCases -join ',') -ne ($expectedCases -join ',')) {
Add-Issue "G03 runtime coverage ledger drifted: $($actualCases -join ',')"
}
}
}
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Keep the honest local preview until backend, workspace, client, and MuMu release gates are green.')
$lines.Add('- Migrate the coordinator, strict transport, marker/status recovery, context, fixtures, focused tests, and docs as one owner change.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'G03-BOOTSTRAP-CLIENT-RELEASE PASS'
+988
View File
@@ -0,0 +1,988 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$jsonPath = Join-Path $root 'APP.openapi.json'
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
$issues = New-Object System.Collections.Generic.List[string]
$createPath = '/genealogy/app/genealogies'
$statusPath = '/genealogy/app/genealogy-bootstrap-operations/{operationKey}'
$settingsPath = '/genealogy/app/genealogies/{genealogyId}'
$regionSearchPath = '/genealogy/app/region/search'
$personPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons'
$personDetailPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}'
$personParentsPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/parents'
$minePath = '/genealogy/app/genealogies/mine'
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Test-IsJsonArray {
param([object]$Value)
return $null -ne $Value -and $Value.GetType().IsArray
}
function Test-IsJsonBoolean {
param([object]$Value, [bool]$Expected)
return $Value -is [System.Boolean] -and $Value -eq $Expected
}
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
$parityOutput = @(& node $parityScript 2>&1)
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Get-ComposedPropertyDefinitions {
param(
[object]$Schema,
[string]$Field,
[hashtable]$Seen = @{}
)
if (-not $Schema) { return @() }
$definitions = New-Object System.Collections.Generic.List[object]
$direct = if ($Schema.properties) { $Schema.properties.PSObject.Properties[$Field] } else { $null }
if ($direct) { $definitions.Add($direct.Value) }
if ($Schema.'$ref') {
$name = ([string]$Schema.'$ref').Split('/')[-1]
if (-not $Seen.ContainsKey($name)) {
$Seen[$name] = $true
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON missing composed schema owner: $name"
} else {
foreach ($definition in @(Get-ComposedPropertyDefinitions $owner.Value $Field $Seen)) {
$definitions.Add($definition)
}
}
}
}
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
$branches = $Schema.PSObject.Properties[$keyword]
if (-not $branches) { continue }
foreach ($branch in @($branches.Value)) {
foreach ($definition in @(Get-ComposedPropertyDefinitions $branch $Field $Seen)) {
$definitions.Add($definition)
}
}
}
return @($definitions)
}
function Get-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
$operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null }
if (-not $operation) {
Add-Issue "JSON missing $($Method.ToUpperInvariant()) $Path"
return $null
}
return $operation.Value
}
function Get-EffectiveParameters {
param([string]$Path, [object]$Operation)
if (-not $Operation) { return @() }
$pathProperty = $document.paths.PSObject.Properties[$Path]
$pathParameters = if ($pathProperty) { @($pathProperty.Value.parameters) } else { @() }
$parametersByIdentity = [ordered]@{}
# OpenAPI lets an operation override a path-item parameter with the same in/name identity.
# Reject duplicates inside each scope before applying the legal operation-level override.
$scopes = @(
[pscustomobject]@{ Name = 'path-item'; Parameters = @($pathParameters) },
[pscustomobject]@{ Name = 'operation'; Parameters = @($Operation.parameters) }
)
foreach ($scope in $scopes) {
$scopeIdentities = @{}
foreach ($parameter in @($scope.Parameters)) {
if (-not $parameter) { continue }
$resolved = $parameter
if ($parameter.'$ref') {
$name = ([string]$parameter.'$ref').Split('/')[-1]
$owner = $document.components.parameters.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON missing parameter owner: $name"
continue
}
$resolved = $owner.Value
}
$identity = (([string]$resolved.in) + ':' + ([string]$resolved.name)).ToLowerInvariant()
if ($identity -eq ':') {
Add-Issue "JSON $Path contains a parameter without in/name"
continue
}
if ($scopeIdentities.ContainsKey($identity)) {
Add-Issue "JSON $Path contains duplicate $($scope.Name) parameter: $identity"
continue
}
$scopeIdentities[$identity] = $true
$parametersByIdentity[$identity] = $resolved
}
}
return @($parametersByIdentity.Values)
}
function Get-Parameter {
param([object]$Operation, [string]$Name, [string]$In, [string]$Path = '')
if (-not $Operation) { return $null }
$parameters = if ($Path) { Get-EffectiveParameters $Path $Operation } else { @($Operation.parameters) }
$matches = @($parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In })
if ($matches.Count -ne 1) {
Add-Issue "JSON operation must define exactly one $In parameter named $Name"
return $null
}
return $matches[0]
}
function Assert-SaTokenOnly {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
$requirements = if ($Operation.PSObject.Properties['security']) {
@($Operation.security)
} else {
@($document.security)
}
$valid = $requirements.Count -eq 1 -and
$requirements[0].PSObject.Properties.Count -eq 1 -and
$requirements[0].PSObject.Properties.Name -contains 'SaToken'
if (-not $valid) { Add-Issue "JSON $Label must require SaToken without an anonymous alternative" }
}
function Assert-RequiredClientId {
param([object]$Operation, [string]$Label, [string]$Path)
$header = Get-Parameter $Operation 'clientid' 'header' $Path
if (-not $header) { return }
if (-not (Test-IsJsonBoolean $header.required $true) -or
$header.schema.type -ne 'string' -or [int]$header.schema.minLength -lt 1) {
Add-Issue "JSON $Label clientid must be a required non-empty string header"
}
}
function Assert-RequestBodyRef {
param([object]$Operation, [string]$Label, [string]$ExpectedRef)
if (-not $Operation) { return }
$media = $Operation.requestBody.content.PSObject.Properties['application/json']
if (-not (Test-IsJsonBoolean $Operation.requestBody.required $true) -or -not $media) {
Add-Issue "JSON $Label must require an application/json body"
} elseif ([string]$media.Value.schema.'$ref' -ne $ExpectedRef) {
Add-Issue "JSON $Label request body must use $ExpectedRef"
}
}
function Assert-ExactObject {
param(
[object]$Schema,
[string]$Label,
[string[]]$Fields,
[string[]]$Required,
[int]$MinProperties = -1,
[int]$MaxProperties = -1
)
if (-not $Schema) { return }
$actualFields = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
$expectedFields = @($Fields | Sort-Object)
$actualRequired = @($Schema.required | Sort-Object)
$expectedRequired = @($Required | Sort-Object)
$requiredProperty = $Schema.PSObject.Properties['required']
$requiredShapeValid = if ($expectedRequired.Count -gt 0) {
Test-IsJsonArray $Schema.required
} elseif ($requiredProperty) {
Test-IsJsonArray $Schema.required
} else { $true }
if ($Schema.type -ne 'object' -or -not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
-not $requiredShapeValid -or
($actualFields -join ',') -ne ($expectedFields -join ',') -or
($actualRequired -join ',') -ne ($expectedRequired -join ',')) {
Add-Issue "JSON $Label must be a closed object; fields=$($expectedFields -join ','); required=$($expectedRequired -join ',')"
}
if ($MinProperties -ge 0 -and [int]$Schema.minProperties -ne $MinProperties) {
Add-Issue "JSON $Label minProperties must be $MinProperties"
}
if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) {
Add-Issue "JSON $Label maxProperties must be $MaxProperties"
}
}
function Assert-PropertyRef {
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
if ($actual -ne $ExpectedRef) {
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
}
}
function Assert-ErrorSchema {
param([string]$Name, [int]$HttpStatus, [string[]]$BusinessCodes)
$schema = Get-Schema $Name
if (-not $schema) { return }
Assert-ExactObject $schema $Name @('code', 'businessCode') @('code', 'businessCode')
$actualCodes = @($schema.properties.businessCode.enum | Sort-Object)
$expectedCodes = @($BusinessCodes | Sort-Object)
if ($schema.properties.code.type -ne 'integer' -or
-not (Test-IsJsonArray $schema.properties.code.enum) -or
@($schema.properties.code.enum).Count -ne 1 -or
@($schema.properties.code.enum)[0] -ne $HttpStatus -or
$schema.properties.businessCode.type -ne 'string' -or
-not (Test-IsJsonArray $schema.properties.businessCode.enum) -or
($actualCodes -join ',') -ne ($expectedCodes -join ',')) {
Add-Issue "JSON $Name business-code contract drifted: $($actualCodes -join ',')"
}
}
function Assert-StringField {
param(
[object]$Schema,
[string]$SchemaName,
[string]$Field,
[int]$MinLength,
[int]$MaxLength,
[string]$Format = ''
)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) {
Add-Issue "JSON $SchemaName missing field: $Field"
return
}
$value = $property.Value
if ($value.type -ne 'string' -or [int]$value.minLength -ne $MinLength -or [int]$value.maxLength -ne $MaxLength) {
Add-Issue "JSON $SchemaName.$Field must be a string with length $MinLength..$MaxLength"
}
if ($Format -and $value.format -ne $Format) {
Add-Issue "JSON $SchemaName.$Field format must be $Format"
}
}
function Get-Response {
param([object]$Operation, [string]$Label, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON $Label missing $Status response"
return $null
}
$response = $property.Value
if ($response.'$ref') {
$name = ([string]$response.'$ref').Split('/')[-1]
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON missing response owner: $name"
return $null
}
$response = $owner.Value
}
return $response
}
function Get-JsonResponseRef {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return '' }
$media = $Response.content.PSObject.Properties['application/json']
if (-not $media) {
Add-Issue "JSON $Label $Status must use application/json"
return ''
}
return [string]$media.Value.schema.'$ref'
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
return
}
$header = $property.Value
if ($header.'$ref') {
$name = ([string]$header.'$ref').Split('/')[-1]
$owner = $document.components.headers.PSObject.Properties[$name]
if ($owner) { $header = $owner.Value }
}
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
Add-Issue "JSON $Label $Status Cache-Control must specify private, no-store"
}
}
function Assert-RetryAfter {
param([object]$Response, [string]$Label, [string]$Status)
$header = if ($Response -and $Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $header) {
Add-Issue "JSON $Label $Status must document Retry-After"
return
}
$value = $header.Value
if ($value.'$ref') {
$name = ([string]$value.'$ref').Split('/')[-1]
$owner = $document.components.headers.PSObject.Properties[$name]
if ($owner) { $value = $owner.Value }
}
if ($value.schema.type -notin @('integer', 'string')) {
Add-Issue "JSON $Label $Status Retry-After must have an integer or string schema"
}
}
function Assert-ExactResponseSet {
param([object]$Operation, [string]$Label, [string[]]$Expected)
if (-not $Operation) { return }
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
$expectedSorted = @($Expected | Sort-Object)
if (($actual -join ',') -ne ($expectedSorted -join ',')) {
Add-Issue "JSON $Label response statuses must be exactly $($expectedSorted -join ','); actual: $($actual -join ',')"
}
}
function Assert-AllDeclaredResponsesPrivateJson {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
foreach ($responseProperty in $Operation.responses.PSObject.Properties) {
$status = [string]$responseProperty.Name
$response = Get-Response $Operation $Label $status
[void](Get-JsonResponseRef $response $Label $status)
Assert-PrivateNoStore $response $Label $status
}
}
$createOperation = Get-Operation $createPath 'post'
$statusOperation = Get-Operation $statusPath 'get'
$settingsOperation = Get-Operation $settingsPath 'put'
$regionOperation = Get-Operation $regionSearchPath 'get'
$personOperation = Get-Operation $personPath 'post'
$personUpdateOperation = Get-Operation $personDetailPath 'put'
$personDeleteOperation = Get-Operation $personDetailPath 'delete'
$personParentsOperation = Get-Operation $personParentsPath 'post'
$mineOperation = Get-Operation $minePath 'get'
$overviewOperation = Get-Operation $overviewPath 'get'
if ($document.paths.PSObject.Properties['/genealogy/region/search']) {
Add-Issue 'JSON legacy /genealogy/region/search must be removed; APP consumers use the authenticated /genealogy/app/region/search owner'
}
foreach ($entry in @(
[pscustomobject]@{ Operation = $createOperation; Label = "POST $createPath"; Path = $createPath },
[pscustomobject]@{ Operation = $statusOperation; Label = "GET $statusPath"; Path = $statusPath },
[pscustomobject]@{ Operation = $settingsOperation; Label = "PUT $settingsPath"; Path = $settingsPath },
[pscustomobject]@{ Operation = $regionOperation; Label = "GET $regionSearchPath"; Path = $regionSearchPath },
[pscustomobject]@{ Operation = $personOperation; Label = "POST $personPath"; Path = $personPath },
[pscustomobject]@{ Operation = $personUpdateOperation; Label = "PUT $personDetailPath"; Path = $personDetailPath },
[pscustomobject]@{ Operation = $personDeleteOperation; Label = "DELETE $personDetailPath"; Path = $personDetailPath },
[pscustomobject]@{ Operation = $personParentsOperation; Label = "POST $personParentsPath"; Path = $personParentsPath }
)) {
Assert-SaTokenOnly $entry.Operation $entry.Label
Assert-RequiredClientId $entry.Operation $entry.Label $entry.Path
}
Assert-RequestBodyRef $createOperation "POST $createPath" '#/components/schemas/AppGenealogyBootstrapBody'
Assert-RequestBodyRef $settingsOperation "PUT $settingsPath" '#/components/schemas/AppGenealogySettingsUpdateBody'
$idempotencyHeader = Get-Parameter $createOperation 'Idempotency-Key' 'header' $createPath
if ($idempotencyHeader) {
if (-not (Test-IsJsonBoolean $idempotencyHeader.required $true) -or
[string]$idempotencyHeader.schema.'$ref' -ne '#/components/schemas/GenealogyBootstrapOperationKey') {
Add-Issue 'JSON create Idempotency-Key must be required and reference GenealogyBootstrapOperationKey'
}
}
$statusKey = Get-Parameter $statusOperation 'operationKey' 'path' $statusPath
if ($statusKey) {
if (-not (Test-IsJsonBoolean $statusKey.required $true) -or
[string]$statusKey.schema.'$ref' -ne '#/components/schemas/GenealogyBootstrapOperationKey') {
Add-Issue 'JSON bootstrap status operationKey must be required and reference GenealogyBootstrapOperationKey'
}
}
if ($createOperation) {
$semantics = [string]$createOperation.description
foreach ($pattern in @(
'(?i)before acceptUntil.*short transaction.*claim PENDING.*canonical digest'
'(?i)unique.*account.*tenant.*client.*path.*key'
'(?i)fencing lease.*stale worker.*cannot commit'
'(?i)business transaction.*quota.*genealogy.*OWNER.*unique generation-one root.*READY.*SUCCEEDED receipt'
'(?i)failure.*rolls back.*all.*FAILED_NO_COMMIT'
'(?i)watchdog.*resolveBy.*compare-and-set.*FAILED_NO_COMMIT'
'(?i)resolveBy.*no later than.*2 minutes'
'(?i)expired absent key.*reject.*late POST'
'(?i)same key.*same canonical.*same receipt'
'(?i)same key.*different.*digest.*IDEMPOTENCY_KEY_REUSED'
'(?i)200.*committed'
'(?i)transaction.*revalidates.*region.*selectable'
'(?i)duplicate name.*advisory.*not unique'
'(?i)400.*401.*403.*before claim.*no operation record'
'(?i)create limit.*FAILED_NO_COMMIT'
'(?i)unprocessable.*FAILED_NO_COMMIT'
'(?i)429.*same key.*retry'
'(?i)500.*outcome unknown.*status'
)) {
if ($semantics -notmatch $pattern) {
Add-Issue "JSON POST $createPath description is missing atomic/idempotent semantics: $pattern"
}
}
}
if ($statusOperation) {
if ($statusOperation.PSObject.Properties['requestBody']) {
Add-Issue "JSON GET $statusPath must not define a request body"
}
$actualParameters = @(Get-EffectiveParameters $statusPath $statusOperation | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
$expectedParameters = @('header:clientid', 'path:operationKey') | Sort-Object
if (($actualParameters -join ',') -ne ($expectedParameters -join ',')) {
Add-Issue "JSON GET $statusPath parameters must be exactly clientid plus operationKey; actual: $($actualParameters -join ',')"
}
$semantics = [string]$statusOperation.description
foreach ($pattern in @(
'(?i)current account.*tenant.*client'
'(?i)PENDING.*SUCCEEDED.*FAILED_NO_COMMIT'
'(?i)SUCCEEDED.*same receipt'
'(?i)read-only.*no side effect'
'(?i)PENDING.*must.*terminal.*claimedAt.*2 minutes'
'(?i)404.*before.*acceptUntil.*unknown.*must not.*new key'
'(?i)after.*acceptUntil.*absent.*computed.*FAILED_NO_COMMIT'
'(?i)expired.*POST.*permanently rejected'
'(?i)no request payload.*personal data'
'(?i)does not consume.*quota'
'(?i)SUCCEEDED.*retained.*entity lifecycle'
'(?i)FAILED_NO_COMMIT.*retained.*30 days'
'(?i)cross-account.*tenant.*client.*404.*non-disclosing'
)) {
if ($semantics -notmatch $pattern) {
Add-Issue "JSON GET $statusPath description is missing crash-recovery semantics: $pattern"
}
}
}
if ($personOperation) {
$personSemantics = [string]$personOperation.description
foreach ($pattern in @(
'(?i)READY.*ordinary person.*not.*bootstrap.*root'
'(?i)database.*bootstrap-root marker.*authoritative'
'(?i)generation.*1.*cannot.*create.*replace.*bootstrap root'
)) {
if ($personSemantics -notmatch $pattern) {
Add-Issue "JSON POST $personPath is missing root-protection semantics: $pattern"
}
}
foreach ($status in @('409', '422')) {
$response = Get-Response $personOperation "POST $personPath" $status
$responseRef = Get-JsonResponseRef $response "POST $personPath" $status
$expectedRef = if ($status -eq '409') {
'#/components/schemas/RLineageRootConflict'
} else {
'#/components/schemas/RLineageRootUnprocessable'
}
if ($responseRef -ne $expectedRef) {
Add-Issue "JSON POST $personPath $status must use $expectedRef; actual: $responseRef"
}
}
}
foreach ($mutation in @(
[pscustomobject]@{
Operation = $personUpdateOperation
Label = "PUT $personDetailPath"
Patterns = @(
'(?i)bootstrap root.*profile fields.*may.*edit'
'(?i)root identity.*generation.*parentage.*immutable'
'(?i)database.*bootstrap-root marker.*authoritative'
)
},
[pscustomobject]@{
Operation = $personDeleteOperation
Label = "DELETE $personDetailPath"
Patterns = @(
'(?i)bootstrap root.*cannot.*delete'
'(?i)database.*bootstrap-root marker.*authoritative'
)
},
[pscustomobject]@{
Operation = $personParentsOperation
Label = "POST $personParentsPath"
Patterns = @(
'(?i)bootstrap root.*cannot.*add.*reassign.*parent'
'(?i)database.*bootstrap-root marker.*authoritative'
)
}
)) {
if (-not $mutation.Operation) { continue }
$semantics = [string]$mutation.Operation.description
foreach ($pattern in $mutation.Patterns) {
if ($semantics -notmatch $pattern) {
Add-Issue "JSON $($mutation.Label) is missing bootstrap-root guard: $pattern"
}
}
foreach ($status in @('409', '422')) {
$response = Get-Response $mutation.Operation $mutation.Label $status
$actualRef = Get-JsonResponseRef $response $mutation.Label $status
$expectedRef = if ($status -eq '409') {
'#/components/schemas/RLineageRootConflict'
} else {
'#/components/schemas/RLineageRootUnprocessable'
}
if ($actualRef -ne $expectedRef) {
Add-Issue "JSON $($mutation.Label) $status must use $expectedRef; actual: $actualRef"
}
}
}
if ($personUpdateOperation) {
$rootEditableFields = $personUpdateOperation.'x-bootstrap-root-editable-fields'
$actualRootEditableFields = @($rootEditableFields | Sort-Object)
$expectedRootEditableFields = @('biography', 'birthDate', 'name', 'sex') | Sort-Object
if (-not (Test-IsJsonArray $rootEditableFields) -or
($actualRootEditableFields -join ',') -ne ($expectedRootEditableFields -join ',') -or
[string]$personUpdateOperation.'x-bootstrap-root-noneditable-policy' -ne 'REJECT_422_BOOTSTRAP_ROOT_IMMUTABLE') {
Add-Issue 'JSON root PUT must machine-bind the exact name/sex/birthDate/biography editable allowlist and reject every other root field with typed 422'
}
}
$bootstrapBody = Get-Schema 'AppGenealogyBootstrapBody'
$rootPersonBody = Get-Schema 'AppGenealogyRootPersonBody'
$operationKey = Get-Schema 'GenealogyBootstrapOperationKey'
$regionCode = Get-Schema 'GenealogyRegionCode'
$accessPreset = Get-Schema 'GenealogyAccessPreset'
$settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody'
$bootstrapResult = Get-Schema 'GenealogyBootstrapResult'
$operationStatus = Get-Schema 'GenealogyBootstrapOperationStatus'
$pendingStatus = Get-Schema 'GenealogyBootstrapPendingStatus'
$succeededStatus = Get-Schema 'GenealogyBootstrapSucceededStatus'
$failedStatus = Get-Schema 'GenealogyBootstrapFailedStatus'
$resultEnvelope = Get-Schema 'RAppGenealogyBootstrapResult'
$statusEnvelope = Get-Schema 'RAppGenealogyBootstrapOperationStatus'
$regionItem = Get-Schema 'RegionSelectVo'
$regionEnvelope = Get-Schema 'RListRegionSelectVo'
Assert-ExactObject $bootstrapBody 'AppGenealogyBootstrapBody' @(
'genealogyName', 'surname', 'ancestralHall', 'regionCode', 'accessPreset', 'rootPerson'
) @('genealogyName', 'surname', 'regionCode', 'accessPreset', 'rootPerson')
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'rootPerson' '#/components/schemas/AppGenealogyRootPersonBody'
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'regionCode' '#/components/schemas/GenealogyRegionCode'
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'genealogyName' 1 24
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'surname' 1 4
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'ancestralHall' 1 12
Assert-ExactObject $rootPersonBody 'AppGenealogyRootPersonBody' @(
'name', 'sex', 'birthDate', 'biography'
) @('name', 'sex')
Assert-StringField $rootPersonBody 'AppGenealogyRootPersonBody' 'name' 1 20
Assert-StringField $rootPersonBody 'AppGenealogyRootPersonBody' 'birthDate' 10 10 'date'
Assert-StringField $rootPersonBody 'AppGenealogyRootPersonBody' 'biography' 1 200
if ($rootPersonBody) {
$sexValues = @($rootPersonBody.properties.sex.enum | Sort-Object)
$expectedSex = @('FEMALE', 'MALE', 'UNKNOWN') | Sort-Object
if ($rootPersonBody.properties.sex.type -ne 'string' -or
-not (Test-IsJsonArray $rootPersonBody.properties.sex.enum) -or
($sexValues -join ',') -ne ($expectedSex -join ',')) {
Add-Issue 'JSON AppGenealogyRootPersonBody.sex must be MALE/FEMALE/UNKNOWN'
}
$description = [string]$rootPersonBody.description
foreach ($pattern in @('(?i)server.*generation.*1', '(?i)no.*generationName', '(?i)Unicode code point.*NFC.*boundary whitespace')) {
if ($description -notmatch $pattern) { Add-Issue "JSON root-person description is missing invariant: $pattern" }
}
}
if ($operationKey) {
if ($operationKey.type -ne 'string' -or [int]$operationKey.minLength -ne 40 -or
[int]$operationKey.maxLength -ne 61 -or
[string]$operationKey.pattern -ne '^gcb\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$') {
Add-Issue 'JSON GenealogyBootstrapOperationKey must encode epoch milliseconds plus 128-bit-or-stronger CSPRNG material'
}
$keyDescription = [string]$operationKey.description
foreach ($pattern in @(
'(?i)issuedAt'
'(?i)128-bit.*CSPRNG'
'(?i)acceptUntil\s*=\s*issuedAt\s*\+\s*10 minutes'
'(?i)server time.*future.*5 minutes.*OPERATION_KEY_INVALID'
'(?i)existing operation.*replay.*after acceptUntil'
)) {
if ($keyDescription -notmatch $pattern) { Add-Issue "JSON operation-key description is missing invariant: $pattern" }
}
if ([int]$operationKey.'x-accept-window-seconds' -ne 600 -or
[int]$operationKey.'x-max-future-skew-seconds' -ne 300 -or
[string]$operationKey.'x-issued-at-source' -ne 'KEY_EPOCH_MILLISECONDS') {
Add-Issue 'JSON GenealogyBootstrapOperationKey must machine-bind issuedAt, the 600-second accept window, and 300-second future skew'
}
}
if ($regionCode) {
if ($regionCode.type -ne 'string' -or [int]$regionCode.minLength -ne 1 -or
[int]$regionCode.maxLength -ne 32 -or
[string]$regionCode.pattern -ne '^[A-Za-z0-9][A-Za-z0-9._~-]{0,31}$') {
Add-Issue 'JSON GenealogyRegionCode must be a bounded lexical identifier, not a JavaScript number'
}
}
if ($accessPreset) {
$values = @($accessPreset.enum | Sort-Object)
$expected = @('MEMBER_ONLY', 'PUBLIC_APPLY') | Sort-Object
if ($accessPreset.type -ne 'string' -or -not (Test-IsJsonArray $accessPreset.enum) -or
($values -join ',') -ne ($expected -join ',')) {
Add-Issue 'JSON GenealogyAccessPreset must contain only MEMBER_ONLY/PUBLIC_APPLY'
}
}
Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @(
'genealogyName', 'intro', 'accessPreset'
) @() 1 3
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
foreach ($legacySchema in @('GenealogyCreateBody', 'AppGenealogyCreateBody', 'GenealogyUpdateBody', 'AppGenealogyUpdateBody')) {
if ($document.components.schemas.PSObject.Properties[$legacySchema]) {
Add-Issue "JSON legacy schema must be removed in the same migration: $legacySchema"
}
}
$appGenealogy = $document.components.schemas.PSObject.Properties['AppGenealogyVo']
if (-not $appGenealogy) {
Add-Issue 'JSON missing AppGenealogyVo for the shared read contract'
} else {
$view = $appGenealogy.Value
Assert-PropertyRef $view 'AppGenealogyVo' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
if ('accessPreset' -notin @($view.required)) {
Add-Issue 'JSON AppGenealogyVo.accessPreset must be required for every APP read projection'
}
foreach ($legacyField in @('visibility', 'joinMode')) {
if ($view.properties.PSObject.Properties[$legacyField]) {
Add-Issue "JSON AppGenealogyVo must remove legacy access field: $legacyField"
}
}
}
# accessPreset owns the entire APP genealogy wire, including every AppGenealogy* model.
foreach ($schemaProperty in $document.components.schemas.PSObject.Properties) {
if ($schemaProperty.Name -notmatch '^AppGenealogy') { continue }
foreach ($legacyField in @('visibility', 'joinMode')) {
if (@(Get-ComposedPropertyDefinitions $schemaProperty.Value $legacyField @{}).Count -gt 0) {
Add-Issue "JSON $($schemaProperty.Name) retains legacy APP access field: $legacyField"
}
}
foreach ($presetDefinition in @(Get-ComposedPropertyDefinitions $schemaProperty.Value 'accessPreset' @{})) {
if ([string]$presetDefinition.'$ref' -ne '#/components/schemas/GenealogyAccessPreset') {
Add-Issue "JSON $($schemaProperty.Name).accessPreset must reference GenealogyAccessPreset"
}
}
}
Assert-ExactObject $bootstrapResult 'GenealogyBootstrapResult' @(
'genealogyId', 'rootPersonId', 'setupState', 'roleType', 'canView'
) @('genealogyId', 'rootPersonId', 'setupState', 'roleType', 'canView')
if ($bootstrapResult) {
foreach ($idField in @('genealogyId', 'rootPersonId')) {
$id = $bootstrapResult.properties.$idField
if ($id.type -ne 'string' -or [int]$id.minLength -ne 1 -or [int]$id.maxLength -ne 128 -or
[string]$id.pattern -ne '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$') {
Add-Issue "JSON GenealogyBootstrapResult.$idField must be a 1..128 URL-safe lexical string"
}
}
if ($bootstrapResult.properties.setupState.type -ne 'string' -or
-not (Test-IsJsonArray $bootstrapResult.properties.setupState.enum) -or
@($bootstrapResult.properties.setupState.enum).Count -ne 1 -or
($bootstrapResult.properties.setupState.enum -join ',') -ne 'READY') {
Add-Issue 'JSON GenealogyBootstrapResult.setupState must be the single value READY'
}
if ($bootstrapResult.properties.roleType.type -ne 'string' -or
-not (Test-IsJsonArray $bootstrapResult.properties.roleType.enum) -or
@($bootstrapResult.properties.roleType.enum).Count -ne 1 -or
($bootstrapResult.properties.roleType.enum -join ',') -ne 'OWNER') {
Add-Issue 'JSON GenealogyBootstrapResult.roleType must be the single value OWNER'
}
if ($bootstrapResult.properties.canView.type -ne 'boolean' -or
-not (Test-IsJsonArray $bootstrapResult.properties.canView.enum) -or
@($bootstrapResult.properties.canView.enum).Count -ne 1 -or
-not (Test-IsJsonBoolean @($bootstrapResult.properties.canView.enum)[0] $true)) {
Add-Issue 'JSON GenealogyBootstrapResult.canView must be the single boolean value true'
}
}
if ($operationStatus) {
$actualBranches = @($operationStatus.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
$expectedBranches = @(
'#/components/schemas/GenealogyBootstrapFailedStatus'
'#/components/schemas/GenealogyBootstrapPendingStatus'
'#/components/schemas/GenealogyBootstrapSucceededStatus'
) | Sort-Object
$mapping = $operationStatus.discriminator.mapping
$mappingPairs = if ($mapping) {
@($mapping.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" } | Sort-Object)
} else { @() }
$expectedMappingPairs = @(
'FAILED_NO_COMMIT=#/components/schemas/GenealogyBootstrapFailedStatus'
'PENDING=#/components/schemas/GenealogyBootstrapPendingStatus'
'SUCCEEDED=#/components/schemas/GenealogyBootstrapSucceededStatus'
) | Sort-Object
if (-not (Test-IsJsonArray $operationStatus.oneOf) -or
($actualBranches -join ',') -ne ($expectedBranches -join ',') -or
$operationStatus.discriminator.propertyName -ne 'status' -or
($mappingPairs -join ',') -ne ($expectedMappingPairs -join ',')) {
Add-Issue 'JSON GenealogyBootstrapOperationStatus must use three status-discriminated oneOf branches'
}
$transitions = $operationStatus.'x-state-transitions'
$transitionNames = if ($transitions) { @($transitions.PSObject.Properties.Name | Sort-Object) } else { @() }
$expectedTransitionNames = @('ABSENT', 'FAILED_NO_COMMIT', 'PENDING', 'SUCCEEDED') | Sort-Object
$absentRaw = if ($transitions) { $transitions.ABSENT } else { $null }
$pendingRaw = if ($transitions) { $transitions.PENDING } else { $null }
$succeededRaw = if ($transitions) { $transitions.SUCCEEDED } else { $null }
$failedRaw = if ($transitions) { $transitions.FAILED_NO_COMMIT } else { $null }
$absentTargets = @($absentRaw)
$pendingTargets = @($pendingRaw | Sort-Object)
$succeededTargets = @($succeededRaw)
$failedTargets = @($failedRaw)
if (($transitionNames -join ',') -ne ($expectedTransitionNames -join ',') -or
-not (Test-IsJsonArray $absentRaw) -or -not (Test-IsJsonArray $pendingRaw) -or
-not (Test-IsJsonArray $succeededRaw) -or -not (Test-IsJsonArray $failedRaw) -or
($absentTargets -join ',') -ne 'PENDING' -or
($pendingTargets -join ',') -ne 'FAILED_NO_COMMIT,SUCCEEDED' -or
$succeededTargets.Count -ne 0 -or $failedTargets.Count -ne 0 -or
-not (Test-IsJsonBoolean $operationStatus.'x-terminal-immutable' $true)) {
Add-Issue 'JSON bootstrap state machine must allow only ABSENT->PENDING->SUCCEEDED/FAILED_NO_COMMIT and make both terminal states immutable'
}
}
Assert-ExactObject $pendingStatus 'GenealogyBootstrapPendingStatus' @(
'status', 'resolveBy', 'retryAfterSeconds'
) @('status', 'resolveBy', 'retryAfterSeconds')
if ($pendingStatus) {
if ($pendingStatus.properties.status.type -ne 'string' -or
-not (Test-IsJsonArray $pendingStatus.properties.status.enum) -or
@($pendingStatus.properties.status.enum).Count -ne 1 -or
($pendingStatus.properties.status.enum -join ',') -ne 'PENDING' -or
$pendingStatus.properties.resolveBy.type -ne 'string' -or
$pendingStatus.properties.resolveBy.format -ne 'date-time' -or
$pendingStatus.properties.retryAfterSeconds.type -ne 'integer' -or
[int]$pendingStatus.properties.retryAfterSeconds.minimum -ne 1 -or
[int]$pendingStatus.properties.retryAfterSeconds.maximum -ne 30) {
Add-Issue 'JSON GenealogyBootstrapPendingStatus must require PENDING, RFC3339 resolveBy, and retryAfterSeconds=1..30'
}
if ([string]$pendingStatus.description -notmatch '(?i)resolveBy\s*<=\s*claimedAt\s*\+\s*2 minutes') {
Add-Issue 'JSON GenealogyBootstrapPendingStatus must bind resolveBy to a maximum two-minute claim SLA'
}
if ([int]$pendingStatus.'x-resolve-sla-seconds' -ne 120 -or
[string]$pendingStatus.'x-resolve-from' -ne 'CLAIMED_AT') {
Add-Issue 'JSON GenealogyBootstrapPendingStatus must machine-bind resolveBy to claimedAt plus at most 120 seconds'
}
}
Assert-ExactObject $succeededStatus 'GenealogyBootstrapSucceededStatus' @('status', 'result') @('status', 'result')
if ($succeededStatus) {
if ($succeededStatus.properties.status.type -ne 'string' -or
-not (Test-IsJsonArray $succeededStatus.properties.status.enum) -or
@($succeededStatus.properties.status.enum).Count -ne 1 -or
($succeededStatus.properties.status.enum -join ',') -ne 'SUCCEEDED' -or
[string]$succeededStatus.properties.result.'$ref' -ne '#/components/schemas/GenealogyBootstrapResult') {
Add-Issue 'JSON GenealogyBootstrapSucceededStatus must require SUCCEEDED plus the strict receipt'
}
}
Assert-ExactObject $failedStatus 'GenealogyBootstrapFailedStatus' @('status') @('status')
if ($failedStatus -and ($failedStatus.properties.status.type -ne 'string' -or
-not (Test-IsJsonArray $failedStatus.properties.status.enum) -or
@($failedStatus.properties.status.enum).Count -ne 1 -or
($failedStatus.properties.status.enum -join ',') -ne 'FAILED_NO_COMMIT')) {
Add-Issue 'JSON GenealogyBootstrapFailedStatus must contain only FAILED_NO_COMMIT'
}
if ($failedStatus -and ([string]$failedStatus.'x-domain-effects' -ne 'NONE' -or
-not (Test-IsJsonBoolean $failedStatus.'x-quota-consumed' $false))) {
Add-Issue 'JSON FAILED_NO_COMMIT must machine-guarantee zero domain effects and zero quota consumption'
}
foreach ($envelopeEntry in @(
[pscustomobject]@{ Schema = $resultEnvelope; Name = 'RAppGenealogyBootstrapResult'; DataRef = '#/components/schemas/GenealogyBootstrapResult' },
[pscustomobject]@{ Schema = $statusEnvelope; Name = 'RAppGenealogyBootstrapOperationStatus'; DataRef = '#/components/schemas/GenealogyBootstrapOperationStatus' }
)) {
$schema = $envelopeEntry.Schema
if (-not $schema) { continue }
Assert-ExactObject $schema $envelopeEntry.Name @('code', 'data') @('code', 'data')
if ($schema.properties.code.type -ne 'integer' -or
-not (Test-IsJsonArray $schema.properties.code.enum) -or
@($schema.properties.code.enum).Count -ne 1 -or
@($schema.properties.code.enum)[0] -ne 200 -or
[string]$schema.properties.data.'$ref' -ne $envelopeEntry.DataRef) {
Add-Issue "JSON $($envelopeEntry.Name) must expose fixed code=200 and typed data only"
}
}
Assert-ErrorSchema 'RGenealogyBootstrapBadRequest' 400 @('OPERATION_KEY_INVALID', 'REQUEST_MALFORMED')
Assert-ErrorSchema 'RGenealogyBootstrapStatusBadRequest' 400 @('OPERATION_KEY_INVALID')
Assert-ErrorSchema 'RGenealogyBootstrapUnauthorized' 401 @('AUTH_REQUIRED')
Assert-ErrorSchema 'RGenealogyBootstrapForbidden' 403 @('GENEALOGY_CREATE_FORBIDDEN')
Assert-ErrorSchema 'RGenealogyBootstrapConflict' 409 @(
'GENEALOGY_CREATE_LIMIT_REACHED', 'IDEMPOTENCY_KEY_REUSED', 'OPERATION_KEY_EXPIRED'
)
Assert-ErrorSchema 'RGenealogyBootstrapUnprocessable' 422 @('ACCESS_PRESET_INVALID', 'REGION_NOT_SELECTABLE', 'ROOT_PERSON_INVALID')
Assert-ErrorSchema 'RGenealogyBootstrapRateLimited' 429 @('RATE_LIMITED')
Assert-ErrorSchema 'RGenealogyBootstrapOutcomeUnknown' 500 @('BOOTSTRAP_OUTCOME_UNKNOWN')
Assert-ErrorSchema 'RGenealogyBootstrapStatusUnavailable' 500 @('BOOTSTRAP_STATUS_UNAVAILABLE')
Assert-ErrorSchema 'RLineageRootConflict' 409 @('GENEALOGY_NOT_READY')
Assert-ErrorSchema 'RLineageRootUnprocessable' 422 @('BOOTSTRAP_ROOT_IMMUTABLE')
$operationNotFound = Get-Schema 'RGenealogyBootstrapOperationNotFound'
Assert-ExactObject $operationNotFound 'RGenealogyBootstrapOperationNotFound' @(
'code', 'businessCode', 'acceptUntil'
) @('code', 'businessCode', 'acceptUntil')
if ($operationNotFound) {
if ($operationNotFound.properties.code.type -ne 'integer' -or
-not (Test-IsJsonArray $operationNotFound.properties.code.enum) -or
@($operationNotFound.properties.code.enum).Count -ne 1 -or
@($operationNotFound.properties.code.enum)[0] -ne 404 -or
$operationNotFound.properties.businessCode.type -ne 'string' -or
-not (Test-IsJsonArray $operationNotFound.properties.businessCode.enum) -or
@($operationNotFound.properties.businessCode.enum).Count -ne 1 -or
($operationNotFound.properties.businessCode.enum -join ',') -ne 'BOOTSTRAP_OPERATION_NOT_AVAILABLE' -or
$operationNotFound.properties.acceptUntil.type -ne 'string' -or
$operationNotFound.properties.acceptUntil.format -ne 'date-time') {
Add-Issue 'JSON RGenealogyBootstrapOperationNotFound must expose the non-secret server acceptUntil boundary'
}
}
if ($regionOperation) {
$keyword = Get-Parameter $regionOperation 'keyword' 'query' $regionSearchPath
if ($keyword -and (-not (Test-IsJsonBoolean $keyword.required $true) -or $keyword.schema.type -ne 'string' -or
[int]$keyword.schema.minLength -ne 1 -or [int]$keyword.schema.maxLength -lt 1 -or
[int]$keyword.schema.maxLength -gt 50)) {
Add-Issue 'JSON region search keyword must be a bounded required string'
}
$region200 = Get-Response $regionOperation "GET $regionSearchPath" '200'
if ((Get-JsonResponseRef $region200 "GET $regionSearchPath" '200') -ne '#/components/schemas/RListRegionSelectVo') {
Add-Issue 'JSON region search 200 must return RListRegionSelectVo'
}
Assert-AllDeclaredResponsesPrivateJson $regionOperation "GET $regionSearchPath"
}
if ($mineOperation) {
$mine200 = Get-Response $mineOperation "GET $minePath" '200'
if ((Get-JsonResponseRef $mine200 "GET $minePath" '200') -ne '#/components/schemas/RListAppGenealogyVo') {
Add-Issue 'JSON mine read must return RListAppGenealogyVo so accessPreset reaches the actual list consumer'
}
Assert-AllDeclaredResponsesPrivateJson $mineOperation "GET $minePath"
}
if ($overviewOperation) {
$overview200 = Get-Response $overviewOperation "GET $overviewPath" '200'
if ((Get-JsonResponseRef $overview200 "GET $overviewPath" '200') -ne '#/components/schemas/RAppGenealogyVo') {
Add-Issue 'JSON overview read must return RAppGenealogyVo so accessPreset reaches the actual detail consumer'
}
Assert-AllDeclaredResponsesPrivateJson $overviewOperation "GET $overviewPath"
}
$listGenealogyEnvelope = $document.components.schemas.PSObject.Properties['RListAppGenealogyVo']
$objectGenealogyEnvelope = $document.components.schemas.PSObject.Properties['RAppGenealogyVo']
if (-not $listGenealogyEnvelope) {
Add-Issue 'JSON missing RListAppGenealogyVo for the actual mine read'
} elseif (
[string]$listGenealogyEnvelope.Value.properties.data.items.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
Add-Issue 'JSON RListAppGenealogyVo.data items must reference AppGenealogyVo'
}
if (-not $objectGenealogyEnvelope) {
Add-Issue 'JSON missing RAppGenealogyVo for the actual overview read'
} elseif (
[string]$objectGenealogyEnvelope.Value.properties.data.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
Add-Issue 'JSON RAppGenealogyVo.data must reference AppGenealogyVo'
}
if ($regionItem) {
foreach ($required in @('regionCode', 'label', 'selectable')) {
if ($required -notin @($regionItem.required)) { Add-Issue "JSON RegionSelectVo.required missing: $required" }
}
if ([string]$regionItem.properties.regionCode.'$ref' -ne '#/components/schemas/GenealogyRegionCode') {
Add-Issue 'JSON RegionSelectVo.regionCode must use GenealogyRegionCode'
}
if ($regionItem.properties.label.type -ne 'string' -or [int]$regionItem.properties.label.minLength -lt 1) {
Add-Issue 'JSON RegionSelectVo.label must be a non-empty string'
}
if ($regionItem.properties.selectable.type -ne 'boolean') {
Add-Issue 'JSON RegionSelectVo.selectable must be boolean and must not be inferred from leaf'
}
}
if ($regionEnvelope) {
foreach ($required in @('code', 'data')) {
if ($required -notin @($regionEnvelope.required)) { Add-Issue "JSON RListRegionSelectVo.required missing: $required" }
}
if ($regionEnvelope.properties.code.type -ne 'integer' -or
$regionEnvelope.properties.data.type -ne 'array' -or
[string]$regionEnvelope.properties.data.items.'$ref' -ne '#/components/schemas/RegionSelectVo') {
Add-Issue 'JSON RListRegionSelectVo must expose integer code and RegionSelectVo[] data'
}
}
$createResponses = @{}
Assert-ExactResponseSet $createOperation "POST $createPath" @('200', '400', '401', '403', '409', '422', '429', '500')
foreach ($status in @('200', '400', '401', '403', '409', '422', '429', '500')) {
$createResponses[$status] = Get-Response $createOperation "POST $createPath" $status
[void](Get-JsonResponseRef $createResponses[$status] "POST $createPath" $status)
Assert-PrivateNoStore $createResponses[$status] "POST $createPath" $status
}
if ((Get-JsonResponseRef $createResponses['200'] "POST $createPath" '200') -ne '#/components/schemas/RAppGenealogyBootstrapResult') {
Add-Issue 'JSON create 200 must return RAppGenealogyBootstrapResult'
}
$createErrorRefs = @{
'400' = '#/components/schemas/RGenealogyBootstrapBadRequest'
'401' = '#/components/schemas/RGenealogyBootstrapUnauthorized'
'403' = '#/components/schemas/RGenealogyBootstrapForbidden'
'409' = '#/components/schemas/RGenealogyBootstrapConflict'
'422' = '#/components/schemas/RGenealogyBootstrapUnprocessable'
'429' = '#/components/schemas/RGenealogyBootstrapRateLimited'
'500' = '#/components/schemas/RGenealogyBootstrapOutcomeUnknown'
}
foreach ($status in $createErrorRefs.Keys) {
$actualRef = Get-JsonResponseRef $createResponses[$status] "POST $createPath" $status
if ($actualRef -ne $createErrorRefs[$status]) {
Add-Issue "JSON create $status must return $($createErrorRefs[$status]); actual: $actualRef"
}
}
Assert-RetryAfter $createResponses['429'] "POST $createPath" '429'
$statusResponses = @{}
Assert-ExactResponseSet $statusOperation "GET $statusPath" @('200', '400', '401', '404', '429', '500')
foreach ($status in @('200', '400', '401', '404', '429', '500')) {
$statusResponses[$status] = Get-Response $statusOperation "GET $statusPath" $status
[void](Get-JsonResponseRef $statusResponses[$status] "GET $statusPath" $status)
Assert-PrivateNoStore $statusResponses[$status] "GET $statusPath" $status
}
if ((Get-JsonResponseRef $statusResponses['200'] "GET $statusPath" '200') -ne '#/components/schemas/RAppGenealogyBootstrapOperationStatus') {
Add-Issue 'JSON bootstrap status 200 must return RAppGenealogyBootstrapOperationStatus'
}
$statusErrorRefs = @{
'400' = '#/components/schemas/RGenealogyBootstrapStatusBadRequest'
'401' = '#/components/schemas/RGenealogyBootstrapUnauthorized'
'404' = '#/components/schemas/RGenealogyBootstrapOperationNotFound'
'429' = '#/components/schemas/RGenealogyBootstrapRateLimited'
'500' = '#/components/schemas/RGenealogyBootstrapStatusUnavailable'
}
foreach ($status in $statusErrorRefs.Keys) {
$actualRef = Get-JsonResponseRef $statusResponses[$status] "GET $statusPath" $status
if ($actualRef -ne $statusErrorRefs[$status]) {
Add-Issue "JSON bootstrap status $status must return $($statusErrorRefs[$status]); actual: $actualRef"
}
}
Assert-RetryAfter $statusResponses['404'] "GET $statusPath" '404'
Assert-RetryAfter $statusResponses['429'] "GET $statusPath" '429'
Assert-AllDeclaredResponsesPrivateJson $settingsOperation "PUT $settingsPath"
Assert-AllDeclaredResponsesPrivateJson $personOperation "POST $personPath"
Assert-AllDeclaredResponsesPrivateJson $personUpdateOperation "PUT $personDetailPath"
Assert-AllDeclaredResponsesPrivateJson $personDeleteOperation "DELETE $personDetailPath"
Assert-AllDeclaredResponsesPrivateJson $personParentsOperation "POST $personParentsPath"
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Replace the existing create body with one closed atomic bootstrap body; the first in-page step performs no network write.')
$lines.Add('- One transaction must create the genealogy, OWNER membership, unique generation-one root, READY state, and idempotency receipt, or roll everything back.')
$lines.Add('- Use one GenealogyAccessPreset across APP read/create/update and remove visibility/joinMode in the same backend version; do not keep parallel mappings.')
$lines.Add('- Persist only operationKey/sessionEpoch/startedAt on device; use the authenticated status operation after process death instead of storing ancestor PII or guessing from /mine.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'G03-BOOTSTRAP-OPENAPI-CONTRACT PASS'
+13 -5
View File
@@ -27,16 +27,12 @@ if (Test-Path -LiteralPath $g04) { throw 'G04 page file must be deleted; first-a
foreach ($required in @(
'const currentStep = ref("create");',
'query?.step === "ancestor"',
'onLoad((query) =>',
'step=ancestor&genealogyId=',
'const createState = ref("form");',
'const ancestorState = ref("form");',
'const fieldErrors = reactive({',
'const duplicateReminderVisible = ref(false);',
'class="duplicate-reminder-layer"',
'if (isSubmitting.value) return;',
'/pages/genealogy/g05-genealogy-overview?genealogyId=',
'adaptive.adaptive-genealogy-state-panel',
'a01-scroll-primary-v3.png',
'create-flow-panel',
@@ -44,10 +40,22 @@ foreach ($required in @(
'import PageHeader from "@/components/PageHeader.vue";',
'<PageHeader',
'custom-back',
'@back="goBack"'
'@back="requestBack"'
)) {
Assert-Contains -Content $g03 -Expected $required -Message "Missing G03 flow contract: $required"
}
foreach ($required in @(
'GENEALOGY_ACCESS_PRESET',
'createLocalGenealogyPreview',
'updateLocalGenealogyPreview',
'updateLocalGenealogyPreviewAncestor'
'removeLocalGenealogyPreview'
)) {
Assert-Contains -Content $g03 -Expected $required -Message "G03 missing shared genealogy contract: $required"
}
foreach ($forbidden in @('GENEALOGY_VISIBILITY', 'createForm.visibility', 'SEARCHABLE')) {
if ($g03.Contains($forbidden)) { throw "G03 must not retain the old or parallel access contract: $forbidden" }
}
Assert-Contains -Content $profiles -Expected 'g01-empty-panel-frame.png' -Message 'Adaptive genealogy panel profile must own the G03 panel asset'
foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', '/pages/tree/t01-tree-overview?genealogyId=', 'class="flow-header"', 'flow-header__back', 'root-header-cinnabar.jpg')) {
+39 -16
View File
@@ -79,6 +79,33 @@ const openDefaultG03 = async (send) => {
await waitFor(send, "Boolean(document.querySelector('.create-flow-panel .flow-rule'))", 'Default G03 creation step did not render')
}
const advanceToAncestor = async (send, name = '自动验证汤氏家谱') => {
await setInput(send, 0, '汤')
await setInput(send, 1, name)
await setInput(send, 2, '敦本堂')
await setInput(send, 3, '河南·洛阳')
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await waitFor(send, "Boolean(document.querySelector('.duplicate-reminder-layer'))", 'G03 did not show the duplicate reminder')
await valueOf(send, `(() => {
window.__g03SubmitTimerCount = 0
window.__g03OriginalSetTimeout = window.setTimeout
window.setTimeout = (callback, delay, ...args) => {
if (delay === 320) window.__g03SubmitTimerCount += 1
return window.__g03OriginalSetTimeout(callback, delay, ...args)
}
return true
})()`)
await valueOf(send, "(() => { const action = document.querySelector('.duplicate-reminder__confirm'); action.click(); action.click(); return true })()")
await setInput(send, 1, '失败')
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 did not advance to the same-page ancestor step')
const timerCount = await valueOf(send, 'window.__g03SubmitTimerCount')
await valueOf(send, 'window.setTimeout = window.__g03OriginalSetTimeout; delete window.__g03OriginalSetTimeout')
if (timerCount !== 1) throw new Error(`G03 duplicate confirmation created ${timerCount} submit timers`)
if (await valueOf(send, "location.hash.includes('step=ancestor')")) {
throw new Error('G03 leaked its internal ancestor step into the route')
}
}
const run = async () => {
const { socket, send, exceptions } = await connect()
@@ -99,36 +126,32 @@ const run = async () => {
await waitFor(send, "document.querySelector('.flow-primary-action__copy')?.textContent.includes('正在创建')", 'G03 create step did not expose its submitting state')
await waitFor(send, "Boolean(document.querySelector('.flow-error'))", 'G03 simulated create failure did not render')
// 步骤二:首代人物继续覆盖必填校验、提交中与保存失败,且保留同一路由两步流程语义
await openG03(send, '?step=ancestor&genealogyId=runtime-failure')
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 ancestor failure step did not render')
// 步骤二:只允许从同页第一步进入首代人物,重复确认不能产生第二个提交定时器
await openDefaultG03(send)
await advanceToAncestor(send, '首代失败验证家谱')
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'G03 ancestor step did not validate the required name')
await setInput(send, 0, '失败')
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await setInput(send, 0, '提交后改名')
await waitFor(send, "document.querySelector('.flow-primary-action__copy')?.textContent.includes('正在保存')", 'G03 ancestor step did not expose its submitting state')
await waitFor(send, "Boolean(document.querySelector('.flow-error'))", 'G03 simulated ancestor failure did not render')
// 步骤三:完整成功路径仍需从立谱、重复提醒、首代保存一路进入家谱总览。
await openDefaultG03(send)
await setInput(send, 0, '汤')
await setInput(send, 1, '自动验证汤氏家谱')
await setInput(send, 3, '河南·洛阳')
await sleep(100)
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await waitFor(send, "Boolean(document.querySelector('.duplicate-reminder-layer'))", 'G03 did not show the existing-genealogy reminder before creation')
await valueOf(send, "document.querySelector('.duplicate-reminder__confirm').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 advanceToAncestor(send)
await setInput(send, 0, '汤始祖')
await sleep(100)
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
await setInput(send, 0, '失败')
await waitFor(send, "Boolean(document.querySelector('.flow-success-layer'))", 'Saving the first ancestor did not show the custom success result')
await valueOf(send, "document.querySelector('.flow-success-dialog__action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=')", 'Saving the first ancestor did not open G05')
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=local-created-')", 'Saving the first ancestor did not open a unique local G05 preview')
await waitFor(send, "Boolean(document.querySelector('.overview-public'))", 'G03 local preview did not render in G05')
const previewText = await valueOf(send, "document.querySelector('.overview-public')?.textContent")
for (const expected of ['自动验证汤氏家谱', '汤氏', '敦本堂', '河南·洛阳', '汤始祖', '仅成员可见']) {
if (!previewText.includes(expected)) throw new Error(`G03 local preview lost submitted field: ${expected}`)
}
await openDefaultG03(send)
if (await valueOf(send, "Boolean(document.querySelector('.intro-field'))")) {
+1 -1
View File
@@ -9,7 +9,7 @@ function Assert-Match {
}
if ($g03 -match '<text>返回</text>') { throw 'G03 header must use only the approved image back arrow' }
Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+:title="isAncestorStep \? ''\u5F55\u5165\u9996\u4EE3\u4EBA\u7269'' : ''\u521B\u5EFA\u5BB6\u8C31''"\s+custom-back\s+@back="goBack"\s*/>' -Message 'G03 must use PageHeader with its dynamic title and custom back handler'
Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+:title="isAncestorStep \? ''\u5F55\u5165\u9996\u4EE3\u4EBA\u7269'' : ''\u521B\u5EFA\u5BB6\u8C31''"\s+custom-back\s+@back="requestBack"\s*/>' -Message 'G03 must use PageHeader with its dynamic title and guarded back handler'
if ($g03 -match 'flow-header|flow-header__back|flow-header__title') { throw 'G03 must not retain a private header implementation' }
Assert-Match -Content $g03 -Pattern '(?s)\.flow-note,\s*\.flow-rule__note\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 guidance copy must use the approved readable size'
Assert-Match -Content $g03 -Pattern '(?s)\.flow-rule__option\s*\{[^}]*font-size:\s*23rpx;' -Message 'G03 visibility options must use the approved readable size'
+2 -2
View File
@@ -9,10 +9,10 @@ Assert-Match '(?s)\.overview-state\s*\{[^}]*@include\s+adaptive\.adaptive-geneal
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*\{[^}]*@include\s+adaptive\.adaptive-g05-overview-surface;' 'G05 ready surface must consume the shared adaptive overview profile'
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 'class="overview-summary"' 'G05 member mode must fill the illustrated lower cells with real read-only membership information'
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)</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)</view>\s*<view class="overview-public__notice">.*?publicDescription.*?</view>\s*<view\s+v-if="viewMode === ''public'' && publicActionLabel"\s+class="overview-public__action"' 'G05 public description must occupy the dedicated bottom note surface'
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'
+13 -1
View File
@@ -1,8 +1,20 @@
$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')) {
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding UTF8
foreach ($token in @('findGenealogyFixture', 'getGenealogyFixtureAccess', 'getGenealogyAccessPresetLabel', 'access.canView', 'ancestorName', 'updatedAt')) {
if (-not $page.Contains($token)) { throw "G05 data ownership contract missing: $token" }
}
if ($page -notmatch '(?s)const access = getGenealogyFixtureAccess\(genealogyId\.value\);\s*if \(!access\.canView\) \{\s*overviewState\.value = "no-permission";\s*return;\s*\}') {
throw 'G05 must fail closed before exposing a fixture that the current role cannot view'
}
foreach ($token in @('export const publicGenealogies', 'export const createLocalGenealogyPreview', 'export const updateLocalGenealogyPreviewAncestor', 'export const findGenealogyFixture', 'export const getGenealogyFixtureAccess', 'fixture?.localPreview', "memberFixture?.membership === 'created'", "memberFixture?.membership === 'joined'")) {
if (-not $mock.Contains($token)) { throw "Shared genealogy fixture ownership missing: $token" }
}
foreach ($forbidden in @('overviewFixtures', 'query.genealogyName', 'query.role', 'query.mode')) {
if ($page.Contains($forbidden)) { throw "G05 retains duplicate or untrusted data owner: $forbidden" }
}
if ($page.Contains('getGenealogyVisibilityLabel') -or $page.Contains('genealogy.visibility')) { throw 'G05 retains the deleted visibility-only contract' }
if ($mock -match "(?s)const localCreatedGenealogy = \{.*?membership:\s*'created'") { throw 'Local-created URL fixture must never grant owner membership' }
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'
+3 -13
View File
@@ -15,33 +15,23 @@ function Assert-NoCssSurface {
}
$root = Split-Path -Parent $PSScriptRoot
$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
$g05 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding utf8
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
$runtimeSmoke = Get-Content -LiteralPath (Join-Path $root 'tests/g05-overview-runtime-smoke.js') -Raw -Encoding utf8
$asset = Join-Path $root 'static/assets/modules/genealogy/opaque/g05-overview-surface.png'
Assert-Contains $g01 'g05-genealogy-overview?genealogyId=' 'G01 must open G05 with the genealogyId query contract'
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")',
"query.genealogyId",
'const viewMode = ref("member")',
'const accessRole = ref("owner")',
'const overviewFixture = {',
'const accessRole = ref("guest")',
'findGenealogyFixture',
'overview-ready',
'overview-public',
'overview-state--empty',
'overview-state--error',
'overview-state--no-permission',
'adaptive.adaptive-g05-overview-surface',
'/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=',
'/pages/tree/t01-tree-overview?genealogyId=',
'/pages/genealogy/g10-application-review?genealogyId=',
'/pages/genealogy/g11-genealogy-settings?genealogyId=',
'/pages/genealogy/g12-generation-poems?genealogyId=',
'/pages/family/f01-family-feed'
'adaptive.adaptive-g05-overview-surface'
)) {
Assert-Contains $g05 $required "Missing G05 overview contract: $required"
}
+27 -9
View File
@@ -79,27 +79,45 @@ const run = async () => {
await open(socket, send, '?state=empty', '.overview-state--empty')
await open(socket, send, '?state=error&genealogyId=1001', '.overview-state--error')
await open(socket, send, '?state=no-permission&genealogyId=1001', '.overview-state--no-permission')
await open(socket, send, '?mode=public&genealogyId=2001', '.overview-public')
await open(socket, send, '?genealogyId=2001', '.overview-public')
if ((await valueOf(send, "document.querySelectorAll('.overview-public__details > uni-view').length")) !== 5) throw new Error('G05 public preview is missing identity details')
if (await valueOf(send, "Boolean(document.querySelector('.overview-actions'))")) throw new Error('G05 public preview exposed member actions')
await valueOf(send, "document.querySelector('.overview-public__action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?source=search&genealogyId=2001')", 'G05 public apply action did not open G08 search source')
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?genealogyId=2001&source=search&sourceKey=G05')", 'G05 public apply action did not open G08 search source')
await open(socket, send, '?role=member&genealogyId=1001', '.overview-ready')
await open(socket, send, '?genealogyId=2003', '.overview-public')
if (!(await valueOf(send, "document.querySelector('.overview-public__action')?.textContent.includes('查看申请进度')"))) throw new Error('G05 pending preview exposed a duplicate application action')
await valueOf(send, "document.querySelector('.overview-public__action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g09-my-applications?status=pending&sourceKey=G05')", 'G05 pending preview did not open G09')
await open(socket, send, '?genealogyId=1002', '.overview-ready')
if ((await valueOf(send, "document.querySelectorAll('.overview-action').length")) !== 2) throw new Error('G05 member overview exposed owner actions')
if (await valueOf(send, "document.querySelector('.header-action')?.textContent.trim().length > 0")) throw new Error('G05 member overview exposed management action')
if ((await valueOf(send, "document.querySelectorAll('.overview-summary').length")) !== 2) throw new Error('G05 member overview left illustrated cells empty')
if (await valueOf(send, "Boolean(document.querySelector('.header-action'))")) throw new Error('G05 member overview exposed a duplicate header management entry')
await open(socket, send, '?genealogyId=1001', '.overview-ready')
if ((await valueOf(send, "document.querySelectorAll('.overview-action').length")) !== 4) throw new Error('G05 owner overview is missing management actions')
await valueOf(send, "document.querySelector('.overview-action--ancestor')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=1001')", 'G05 ancestor action did not open the G03 ancestor step')
await open(socket, send, '?role=member&mode=public&genealogyName=伪造谱名&genealogyId=1001', '.overview-ready')
if ((await valueOf(send, "document.querySelectorAll('.overview-action').length")) !== 4) throw new Error('G05 owner overview did not fill its four canonical action cells')
if (await valueOf(send, "Boolean(document.querySelector('.overview-action--ancestor'))")) throw new Error('G05 retained the duplicate first-ancestor entry')
if (!await valueOf(send, "Boolean(document.querySelector('.overview-action--settings'))")) throw new Error('G05 owner overview is missing its canonical settings entry')
if (await valueOf(send, "Boolean(document.querySelector('.header-action'))")) throw new Error('G05 owner overview retained the duplicate header management entry')
if ((await valueOf(send, "document.querySelector('.overview-hero__title')?.textContent.trim()")) !== '汤氏家谱') throw new Error('G05 accepted an untrusted genealogyName override')
await open(socket, send, '?genealogyId=local-created-genealogy', '.overview-public')
if ((await valueOf(send, "document.querySelector('.overview-public__eyebrow')?.textContent.trim()")) !== '本地流程预览') throw new Error('G05 local creation did not enter preview mode')
if (await valueOf(send, "Boolean(document.querySelector('.overview-actions, .overview-public__action'))")) throw new Error('G05 local preview exposed a member, management or join action')
if (await valueOf(send, "Boolean(document.querySelector('.header-action'))")) throw new Error('G05 local preview exposed a header management entry')
await open(socket, send, '?genealogyId=unknown', '.overview-state--no-permission')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
await open(socket, send, '?mode=public&genealogyId=2001', '.overview-public')
await open(socket, send, '?genealogyId=2001', '.overview-public')
const width = await valueOf(send, 'document.documentElement.scrollWidth')
if (width > size.width + 1) throw new Error(`G05 has horizontal overflow at ${size.width}x${size.height}`)
}
await open(socket, send, '?genealogyId=1001', '.overview-ready')
await valueOf(send, "document.querySelector('.header-back')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies')", 'G05 deep-link back did not fall back to G01')
if (exceptions.length) throw new Error(`G05 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('G05-OVERVIEW-RUNTIME-SMOKE PASS\n')
} finally {
+5 -4
View File
@@ -2,10 +2,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 @('genealogyName:', 'previous:', 'encodeURIComponent(item.name)')) {
if ($search.Contains($token)) { throw "G06 must not propagate retired context: $token" }
}
foreach ($token in @('previousRelation', 'query.genealogyName', 'previousNotice', 'decodeURIComponent')) {
if (-not $join.Contains($token)) { throw "G08 context consumption missing: $token" }
foreach ($token in @('previousRelation', 'query.genealogyName', 'query.previous', 'decodeURIComponent', 'genealogyPreview')) {
if ($join.Contains($token)) { throw "G08 must not consume retired context: $token" }
}
if (-not $join.Contains('findGenealogyFixture')) { throw 'G08 must read display identity from the shared genealogy fixture owner' }
Write-Output 'G06-G08-CONTEXT-CONTRACT PASS'
+27 -15
View File
@@ -17,6 +17,7 @@ $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
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding utf8
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
$g07 = Join-Path $root 'pages/genealogy/g07-search-result.vue'
$inputSkinPath = Join-Path $root 'static/assets/modules/genealogy/opaque/g06-search-input-wide.png'
@@ -30,20 +31,13 @@ foreach ($required in @(
'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"',
'parentName:',
'branchName:',
'manager:',
'certification:',
'/pages/genealogy/g08-join-application?source=search&genealogyId=',
'/pages/genealogy/g08-join-application?source=invite&genealogyId=',
'/pages/genealogy/g05-genealogy-overview?mode=public&genealogyId=',
'getGenealogyFixtureAccess,',
'isGenealogySearchVisible,',
'publicGenealogies,',
'const resultActionLabel = (item) =>',
'const resultRelationLabel = (item) =>',
'getGenealogyFixtureAccess(String(item.id)).canApply',
'isGenealogySearchVisible(String(item.id))',
'search-page__header',
'adaptive.adaptive-g06-search-field',
'adaptive.adaptive-g06-search-action',
@@ -53,10 +47,28 @@ foreach ($required in @(
'search-initial',
'search-results',
'search-empty',
'search-action'
'search-action',
'const invalidateSearch = () =>',
'if (searchTimer !== timer || mode.value !== "search") return;',
'@input="resetInvite"',
'if (inviteState.value !== "valid") return false;'
)) {
Assert-Contains -Content $g06 -Expected $required -Message "Missing G06 flow contract: $required"
}
foreach ($required in @(
"relation: 'available'",
"relation: 'joined'",
"relation: 'pending'",
"relation: 'rejected'",
"relation: 'removed'",
"relation: 'owned'",
'parentName:',
'branchName:',
'manager:',
'certification:'
)) {
Assert-Contains -Content $mock -Expected $required -Message "Missing shared G06 fixture contract: $required"
}
foreach ($assetName in @('g06-search-input-wide.png', 'g06-search-button.png')) {
Assert-Contains $profiles $assetName "Adaptive G06 profile must own: $assetName"
}
+24 -2
View File
@@ -75,6 +75,14 @@ const run = async () => {
await openG06(send)
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.genealogy-card'))"), false, 'G06 initial state revealed results')
await setInput(send, '.search-input input', '汤')
await valueOf(send, "document.querySelector('.search-action').click()")
await valueOf(send, "document.querySelectorAll('.mode-tab')[1].click()")
await sleep(350)
await valueOf(send, "document.querySelectorAll('.mode-tab')[0].click()")
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.search-initial'))"), true, 'G06 stale search timer repopulated a reset mode')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.search-results .result-card').length"), 0, 'G06 stale search results survived a mode round trip')
await setInput(send, '.search-input input', '汤')
await valueOf(send, "document.querySelector('.search-action').click()")
await waitFor(send, "document.querySelectorAll('.search-results .result-card').length === 6", 'G06 did not render all six relation states')
@@ -83,7 +91,14 @@ const run = async () => {
assert(firstCardText.includes(detail), `G06 result card is missing ${detail}`)
}
await valueOf(send, "document.querySelector('.result-card').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?mode=public&genealogyId=')", 'G06 available card did not open G05 public preview')
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=2001&sourceKey=G06')", 'G06 available card did not open G05 public preview')
await openG06(send)
await setInput(send, '.search-input input', '汤')
await valueOf(send, "document.querySelector('.search-action').click()")
await waitFor(send, "document.querySelectorAll('.search-results .result-card').length === 6", 'G06 relation results did not reload')
await valueOf(send, "document.querySelectorAll('.search-results .result-card__action')[1].click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies?genealogyId=1002')", 'G06 joined result did not resolve to the canonical member genealogy')
await openG06(send)
await setInput(send, '.search-input input', '不存在的家谱')
@@ -106,8 +121,15 @@ const run = async () => {
await setInput(send, '.invite-input input', 'JP2026')
await valueOf(send, "document.querySelector('.search-action').click()")
await waitFor(send, "Boolean(document.querySelector('.invite-result .result-card'))", 'G06 valid invite target did not render')
assert((await valueOf(send, "document.querySelector('.invite-result')?.textContent"))?.includes('不会变更成员身份'), 'G06 local invite preview claimed a durable membership change')
await setInput(send, '.invite-input input', 'BAD-CODE')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.invite-initial'))"), true, 'G06 edited invite code retained a stale validated target')
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.invite-result'))"), false, 'G06 stale invite target remained actionable after editing')
await setInput(send, '.invite-input input', 'JP2026')
await valueOf(send, "document.querySelector('.search-action').click()")
await waitFor(send, "Boolean(document.querySelector('.invite-result .result-card'))", 'G06 valid invite target did not revalidate after editing')
await valueOf(send, "document.querySelector('.invite-result .result-card__action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?source=invite&genealogyId=')", 'G06 invite action did not open G08 invite source')
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?genealogyId=2001&source=invite&sourceKey=G06')", 'G06 invite action did not open G08 invite source')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
+4 -11
View File
@@ -19,7 +19,6 @@ $g08 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-appli
$g09 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding utf8
$g10 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g10-application-review.vue') -Raw -Encoding utf8
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
foreach ($page in @($g08, $g09, $g10)) {
if ($page -match '<ModulePage') { throw 'G08-G10 must not retain the generic ModulePage shell' }
@@ -35,13 +34,11 @@ foreach ($required in @(
'adaptive.adaptive-genealogy-state-panel',
'adaptive.adaptive-genealogy-form-field',
'a01-scroll-primary-v3.png',
'const genealogyPreview',
'findGenealogyFixture',
'query.genealogyId',
'const source = ref("search")',
'const sourceContract = computed(() =>',
'const fieldErrors = reactive({',
'/pages/genealogy/g09-my-applications',
'/pages/genealogy/g01-my-genealogies?genealogyId='
'const fieldErrors = reactive({'
)) { Assert-Contains $g08 $required "Missing G08 join contract: $required" }
foreach ($assetName in @('g01-empty-panel-frame.png', 'g-form-field-frame.png')) {
Assert-Contains $profiles $assetName "Adaptive genealogy profile must own: $assetName"
@@ -62,12 +59,11 @@ foreach ($required in @(
'"PENDING"',
'"APPROVED"',
'"REJECTED"',
'"WITHDRAWN"',
'"LOCAL_WITHDRAWN"',
'const applicationSamples',
'const actionFor = (item) =>',
'const withdrawTarget = ref(null)',
'<AppDialog',
'/pages/genealogy/g08-join-application?source=search&previous=rejected&genealogyId='
'<AppDialog'
)) { Assert-Contains $g09 $required "Missing G09 application contract: $required" }
Assert-Contains $profiles 'list-slip-frame.png' 'Adaptive genealogy list profile must own the G09 card asset'
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
@@ -96,9 +92,6 @@ foreach ($className in @('join-panel', 'join-action', 'application-card', 'revie
foreach ($page in @($g08, $g09, $g10)) { Assert-NoCssSurface $page $className }
}
foreach ($entry in @('g07:', 'g08:', 'g09:')) {
if ($catalog -match [regex]::Escape($entry)) { throw "Page catalog must remove migrated generic entry: $entry" }
}
$statusAsset = Join-Path $root 'static/assets/modules/genealogy/transparent/list-slip-frame.png'
if (-not (Test-Path -LiteralPath $statusAsset)) { throw 'G09-G10 cards and application empty states require list-slip-frame.png' }
@@ -66,18 +66,26 @@ const run = async () => {
try {
await send('Page.enable')
await send('Runtime.enable')
await open(send, '/pages/genealogy/g08-join-application', '?genealogyId=1002', '.join-state--form')
await open(send, '/pages/genealogy/g08-join-application', '?genealogyId=2001', '.join-state--form')
await waitFor(send, "document.querySelectorAll('.join-field input').length >= 2", 'G08 real input controls did not render')
const prepared = await fillJoinForm(send, '汤明远', '某某某堂侄')
if (!prepared) throw new Error('G08 form controls could not be prepared')
await valueOf(send, "document.querySelector('.join-action')?.click()")
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 real form submission did not reach the same-page success state')
if (!(await valueOf(send, "document.body.textContent.includes('申请尚未提交服务器')"))) throw new Error('G08 local search preview claimed a server submission')
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g09-my-applications')", 'G08 local search preview did not continue to G09 without a result')
for (const genealogyId of ['1001', '1002', '2003', 'unknown']) {
await open(send, '/pages/genealogy/g08-join-application', `?role=guest&state=success&source=search&genealogyId=${genealogyId}`, '.join-state--ineligible')
if (await valueOf(send, "Boolean(document.querySelector('.join-form'))")) throw new Error(`G08 exposed an application form for ineligible genealogy ${genealogyId}`)
}
// 步骤一:失败输入必须经过真实提交态进入错误结果,并能返回原表单重试。
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=1002', '.join-state--form')
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
if (!await fillJoinForm(send, '失败', '某某某堂侄')) throw new Error('G08 failure controls could not be prepared')
await valueOf(send, "document.querySelector('.join-action').click()")
await waitFor(send, "document.querySelector('.join-action')?.textContent.includes('正在提交')", 'G08 did not expose its submitting state')
await waitFor(send, "document.querySelector('.join-action')?.textContent.includes('正在校验')", 'G08 did not expose its submitting state')
await waitFor(send, "Boolean(document.querySelector('.join-state--error'))", 'G08 simulated submission failure did not render')
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
await waitFor(send, "Boolean(document.querySelector('.join-state--form'))", 'G08 failure retry did not restore the form')
@@ -90,9 +98,9 @@ const run = async () => {
await sleep(100)
await valueOf(send, "document.querySelector('.join-action').click()")
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 invite source did not reach success')
if (!(await valueOf(send, "document.body.textContent.includes('无需等待审核')"))) throw new Error('G08 invite source did not preserve direct-join semantics')
if (!(await valueOf(send, "document.body.textContent.includes('不会选中或加入这部家谱')"))) throw new Error('G08 invite preview claimed a durable membership change')
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies?genealogyId=2001')", 'G08 invite success did not return to selected G01 genealogy')
await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies') && !location.href.includes('genealogyId=2001')", 'G08 invite preview selected a genealogy without backend success')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
@@ -111,9 +119,10 @@ const run = async () => {
await valueOf(send, "document.querySelector('.application-card__action').click()")
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 waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('本地撤回预览')", 'G09 withdrawal did not use a local-only status')
if (!(await valueOf(send, "document.querySelector('.application-card')?.textContent.includes('尚未提交服务器')"))) throw new Error('G09 local withdrawal claimed a durable server result')
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 waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?genealogyId=2004&source=search&sourceKey=G09')", 'G09 rejected action did not reopen G08 with the canonical contract')
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')
await valueOf(send, "document.querySelector('.review-actions .app-button:nth-child(2)').click()")
@@ -121,6 +130,7 @@ const run = async () => {
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('已通过')", 'G10 approval did not update the card')
await waitFor(send, "Boolean(document.querySelector('.review-feedback'))", 'G10 approval did not show custom feedback')
if (!(await valueOf(send, "document.querySelector('.review-feedback')?.textContent.includes('尚未提交服务器')"))) throw new Error('G10 local approval claimed a durable server result')
await valueOf(send, "document.querySelector('.header-action').click()")
await waitFor(send, "document.querySelector('.app-dialog__title')?.textContent.includes('审核说明')", 'G10 help did not use the custom dialog')
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button').click()")
@@ -144,7 +154,9 @@ const run = async () => {
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('已拒绝')", 'G10 rejection did not update the card')
await waitFor(send, "Boolean(document.querySelector('.review-feedback'))", 'G10 rejection did not show custom feedback')
await open(send, '/pages/genealogy/g10-application-review', '?state=empty&genealogyId=1001', '.review-state--empty')
await open(send, '/pages/genealogy/g10-application-review', '?state=no-permission&genealogyId=1001', '.review-state--no-permission')
await open(send, '/pages/genealogy/g10-application-review', '?role=owner&state=list&genealogyId=1002', '.review-state--no-permission')
if (await valueOf(send, "Boolean(document.querySelector('.application-card'))")) throw new Error('G10 trusted a forged owner role for a non-owner genealogy')
await open(send, '/pages/genealogy/g10-application-review', '?state=list&genealogyId=2001', '.review-state--no-permission')
if (exceptions.length) throw new Error(`G08-G10 raised browser exceptions: ${exceptions.join('; ')}`)
process.stdout.write('G08-G10-APPLICATION-FLOW-RUNTIME-SMOKE PASS\n')
} finally {
+2 -1
View File
@@ -2,9 +2,10 @@ $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')) {
foreach ($token in @('query.status', 'LOCAL_WITHDRAWN:', 'target.status = "LOCAL_WITHDRAWN"')) {
if (-not $mine.Contains($token)) { throw "G09 business state missing: $token" }
}
if ($mine.Contains('previous=withdrawn') -or $mine.Contains('previous:')) { throw 'G09 must not export application history as a route parameter' }
foreach ($token in @(
'rejectionReason',
'rejectionError',
+1 -1
View File
@@ -12,7 +12,7 @@ foreach ($required in @(
'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 adaptive asset surface: $required" } }
if ($page -notmatch '(?s)<textarea[^>]*auto-height') { throw 'G11 access note must grow from its text data' }
if ($page -notmatch '(?s)<textarea[^>]*auto-height') { throw 'G11 genealogy intro must grow from its text data' }
$settingsPanelStyle = [regex]::Match($style, '(?s)\.settings-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
if ($settingsPanelStyle -match '\bmin-height\s*:') {
throw 'G11 settings panel height must be driven by its current content'
+6 -7
View File
@@ -18,7 +18,6 @@ $root = Split-Path -Parent $PSScriptRoot
$g11 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g11-genealogy-settings.vue') -Raw -Encoding utf8
$g12 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g12-generation-poems.vue') -Raw -Encoding utf8
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
$catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Raw -Encoding utf8
foreach ($page in @($g11, $g12)) {
if ($page -match '<ModulePage') { throw 'G11-G12 must not retain the generic ModulePage shell' }
@@ -39,9 +38,12 @@ foreach ($required in @(
'a01-scroll-primary-v3.png',
'const genealogyDraft',
'query.genealogyId',
'"MEMBER_ONLY"',
'"PUBLIC_APPLY"'
'GENEALOGY_ACCESS_PRESET,',
'GENEALOGY_ACCESS_PRESET_OPTIONS,'
)) { Assert-Contains $g11 $required "Missing G11 settings contract: $required" }
foreach ($forbidden in @('value: "MEMBER_ONLY"', 'value: "PUBLIC_APPLY"', 'accessNote')) {
if ($g11.Contains($forbidden)) { throw "G11 retains a duplicate or unsupported settings contract: $forbidden" }
}
foreach ($required in @(
'const poemState = ref("loading")',
@@ -56,7 +58,7 @@ foreach ($required in @(
'a01-scroll-primary-v3.png',
'a01-scroll-secondary-v3.png',
'const poemRows',
'stopMissingOldGeneration',
'disableMissing',
'query.genealogyId'
)) { Assert-Contains $g12 $required "Missing G12 generation-poem contract: $required" }
foreach ($assetName in @('g01-empty-panel-frame.png', 'g-form-field-frame.png')) {
@@ -73,7 +75,4 @@ foreach ($className in @('settings-panel', 'settings-action', 'poem-row', 'poem-
foreach ($page in @($g11, $g12)) { Assert-NoCssSurface $page $className }
}
foreach ($entry in @('g11:', 'g12:')) {
if ($catalog -match [regex]::Escape($entry)) { throw "Page catalog must remove migrated generic entry: $entry" }
}
Write-Output 'G11-G12-SETTINGS-POEMS-CONTRACT PASS'
+64 -4
View File
@@ -90,22 +90,82 @@ const run = async () => {
await valueOf(send, "document.querySelector('.settings-action').click()")
await waitFor(send, "Boolean(document.querySelector('.settings-state--success'))", 'G11 valid save did not enter success state')
await waitFor(send, "Boolean(document.querySelector('.settings-feedback'))", 'G11 valid save did not show feedback')
if (!(await valueOf(send, "document.body.textContent.includes('尚未提交服务器')"))) throw new Error('G11 local settings preview claimed a server save')
await open(socket, send, '/pages/genealogy/g11-genealogy-settings', '?state=success&genealogyId=1001', '.settings-state--success')
await open(socket, send, '/pages/genealogy/g11-genealogy-settings', '?role=owner&state=form&genealogyId=1002', '.settings-state--no-permission')
if (await valueOf(send, "Boolean(document.querySelector('.settings-form'))")) throw new Error('G11 trusted a forged owner role for a non-owner genealogy')
// 步骤二:G12 依次验证空内容、模拟保存失败和真实保存成功,三类结果不能只靠查询参数摆出。
// 步骤二:G12 依次验证空内容、非法字符和本地保存成功,结果不能只靠查询参数摆出。
await open(socket, send, '/pages/genealogy/g12-generation-poems', '?genealogyId=1001', '.poem-state--list')
await open(socket, send, '/pages/genealogy/g12-generation-poems', '?genealogyId=1002', '.poem-state--list')
if (await valueOf(send, "Boolean(document.querySelector('.header-action, .poem-list > .poem-action'))")) throw new Error('G12 member view exposed an owner maintenance entry')
await open(socket, send, '/pages/genealogy/g12-generation-poems', '?role=owner&state=edit&genealogyId=1002', '.poem-state--no-permission')
await open(socket, send, '/pages/genealogy/g12-generation-poems', '?state=list&genealogyId=2001', '.poem-state--no-permission')
await open(socket, send, '/pages/genealogy/g12-generation-poems', '?state=empty&genealogyId=1001', '.poem-state--empty')
await open(socket, send, '/pages/genealogy/g12-generation-poems', '?state=edit&genealogyId=1001', '.poem-state--edit')
await setFieldValue(send, '.poem-field textarea', '临时草稿')
await valueOf(send, "document.querySelector('.poem-editor__actions .poem-action:first-child').click()")
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", 'G12 dirty cancel did not open the shared discard confirmation')
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:first-child').click()")
await waitFor(send, "Boolean(document.querySelector('.poem-state--edit'))", 'G12 continuing edit did not keep the editor open')
await valueOf(send, "document.querySelector('.poem-editor__actions .poem-action:first-child').click()")
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", 'G12 second dirty cancel did not reopen confirmation')
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
await waitFor(send, "Boolean(document.querySelector('.poem-state--list'))", 'G12 confirmed discard did not restore the editor origin')
await open(socket, send, '/pages/genealogy/g12-generation-poems', '?state=edit&genealogyId=1001', '.poem-state--edit')
await setFieldValue(send, '.poem-field textarea', '')
await valueOf(send, "document.querySelector('.poem-editor__actions .poem-action:last-child').click()")
await waitFor(send, "document.querySelector('.poem-field-error')?.textContent.includes('请录入字辈内容')", 'G12 empty poem did not show validation')
await setFieldValue(send, '.poem-field textarea', '失败')
await setFieldValue(send, '.poem-field textarea', '启'.repeat(501))
await valueOf(send, "document.querySelector('.poem-editor__actions .poem-action:last-child').click()")
await waitFor(send, "document.querySelector('.poem-field-error')?.textContent.includes('保存失败')", 'G12 simulated save failure did not render')
await setFieldValue(send, '.poem-field textarea', '启宪敦本继世传芳')
await waitFor(send, "document.querySelector('.poem-field-error')?.textContent.includes('最多录入 500 个世代')", 'G12 generation limit did not render')
// 步骤三:走完整的保留、停用和取消恢复接线,不能只证明纯函数会合并。
await setFieldValue(send, '.poem-field textarea', '启 宗 敦')
await valueOf(send, "document.querySelector('.poem-editor__actions .poem-action:last-child').click()")
await waitFor(send, "document.querySelectorAll('.poem-row').length === 4", 'G12 disableMissing=false did not preserve the uncovered tail')
if (await valueOf(send, "document.querySelector('.poem-row:last-child')?.classList.contains('poem-row--disabled')")) throw new Error('G12 disableMissing=false unexpectedly disabled the tail')
if (!(await valueOf(send, "document.querySelector('.poem-row:last-child .poem-row__character')?.textContent.includes('本')"))) throw new Error('G12 disableMissing=false did not preserve the tail text')
await valueOf(send, "document.querySelector('.header-action').click()")
await waitFor(send, "Boolean(document.querySelector('.poem-state--edit'))", 'G12 maintenance action did not reopen the editor')
await valueOf(send, "document.querySelector('.poem-policy__option').click()")
await valueOf(send, "document.querySelector('.poem-editor__actions .poem-action:last-child').click()")
await waitFor(send, "document.querySelector('.poem-row:last-child')?.classList.contains('poem-row--disabled')", 'G12 disableMissing=true did not disable the uncovered tail')
if (!(await valueOf(send, "document.querySelector('.poem-row:last-child .poem-row__character')?.textContent.includes('本')"))) throw new Error('G12 disabled tail lost its historical text')
if ((await valueOf(send, "document.querySelectorAll('.poem-row--current').length")) !== 1) throw new Error('G12 did not keep exactly one active current generation')
if (await valueOf(send, "document.querySelector('.poem-row--current')?.classList.contains('poem-row--disabled')")) throw new Error('G12 marked a disabled generation as current')
await valueOf(send, "document.querySelector('.header-action').click()")
await waitFor(send, "Boolean(document.querySelector('.poem-state--edit'))", 'G12 did not reopen after disabling the tail')
await setFieldValue(send, '.poem-field textarea', '临时草稿')
await valueOf(send, "document.querySelector('.poem-policy__option').click()")
await valueOf(send, "document.querySelector('.poem-editor__actions .poem-action:first-child').click()")
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", 'G12 policy change did not trigger discard confirmation')
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
await waitFor(send, "Boolean(document.querySelector('.poem-state--list'))", 'G12 confirmed policy discard did not leave the editor')
await valueOf(send, "document.querySelector('.header-action').click()")
await waitFor(send, "Boolean(document.querySelector('.poem-state--edit'))", 'G12 did not reopen after policy discard')
if (!(await valueOf(send, "document.querySelector('.poem-policy__option')?.textContent.includes('停用并保留记录')"))) throw new Error('G12 discard did not restore disableMissing=true')
if ((await valueOf(send, "document.querySelector('.poem-field textarea')?.value")) !== '启 宗 敦') throw new Error('G12 discard did not restore the poem draft snapshot')
// 步骤四:500×50 个补充平面字符接近双重上限,验证 textarea、解析、保存与分批渲染。
const maximumPoem = Array.from({ length: 500 }, () => '𠀀'.repeat(50)).join(' ')
if (Array.from(maximumPoem).length !== 25499 || maximumPoem.length !== 50499) throw new Error('G12 maximum Unicode fixture is malformed')
await setFieldValue(send, '.poem-field textarea', maximumPoem)
await valueOf(send, "document.querySelector('.poem-editor__actions .poem-action:last-child').click()")
await waitFor(send, "Boolean(document.querySelector('.poem-state--list'))", 'G12 valid save did not return to the list')
await waitFor(send, "Boolean(document.querySelector('.poem-feedback'))", 'G12 valid save did not show feedback')
if (!(await valueOf(send, "document.querySelector('.poem-feedback')?.textContent.includes('本地字辈预览已更新')"))) throw new Error('G12 local save feedback is not explicit')
if ((await valueOf(send, "document.querySelectorAll('.poem-row').length")) !== 50) throw new Error('G12 did not render the 500-generation list in 50-row batches')
if (!(await valueOf(send, "document.querySelector('.poem-load-more')?.textContent.includes('剩余 450 代')"))) throw new Error('G12 first batch did not expose the remaining generation count')
for (let batch = 1; batch < 10; batch += 1) {
await valueOf(send, "document.querySelector('.poem-load-more')?.click()")
await sleep(30)
}
await waitFor(send, "document.querySelectorAll('.poem-row').length === 500 && !document.querySelector('.poem-load-more')", 'G12 did not make the final generation reachable')
if (!(await valueOf(send, "[...document.querySelectorAll('.poem-row__number')].at(-1)?.textContent.includes('第 500 世')"))) throw new Error('G12 final generation is not reachable')
if (!(await valueOf(send, "document.documentElement.scrollWidth <= innerWidth && [...document.querySelectorAll('.poem-row__character')].every((item) => item.scrollWidth <= item.clientWidth)"))) throw new Error('G12 legal supplementary 50-character generation text overflowed its row')
process.stdout.write('G11-G12-SETTINGS-POEMS-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
+9 -1
View File
@@ -1,7 +1,15 @@
$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')) {
foreach ($token in @('findGenealogyFixture', 'getGenealogyFixtureAccess', 'isGenealogyAccessPreset', 'GENEALOGY_ACCESS_PRESET', 'GENEALOGY_ACCESS_PRESET_OPTIONS', 'genealogyDraft.accessPreset', 'genealogyDraft.name.trim().length > 20', 'originalDraft', 'isDirty')) {
if (-not $page.Contains($token)) { throw "G11 settings data contract missing: $token" }
}
if ($page.Contains('settingsFixtures')) { throw 'G11 must not retain a private genealogy settings fixture owner' }
if ($page.Contains('GENEALOGY_VISIBILITY') -or $page.Contains('genealogyDraft.visibility')) { throw 'G11 retains the deleted visibility-only contract' }
if ($page -notmatch '(?s)if \(!isGenealogyAccessPreset\(fixture\.accessPreset\)\) \{\s*settingsState\.value = "error";\s*return;\s*\}') {
throw 'G11 must fail closed before loading an unknown access preset'
}
if ($page -match '(?s)accessPreset:\s*Object\.values\(GENEALOGY_ACCESS_PRESET\).*?:\s*GENEALOGY_ACCESS_PRESET\.MEMBER_ONLY') {
throw 'G11 must not silently downgrade an unknown access preset to MEMBER_ONLY'
}
Write-Output 'G11-SETTINGS-DATA-CONTRACT PASS'
@@ -1,8 +1,31 @@
$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')) {
foreach ($token in @(
'MAX_GENERATION_COUNT',
'MAX_GENERATION_TEXT_LENGTH',
'MAX_GENERATION_POEM_INPUT_LENGTH',
'validateGenerationPoemText',
'findFirstGenerationGap',
'mergeGenerationPoemRows',
':maxlength="MAX_GENERATION_POEM_INPUT_LENGTH * 2"',
'disableMissing.value',
'const disableMissing = ref(false)',
'const POEM_RENDER_BATCH_SIZE = 50',
'const visiblePoemRows = computed',
'const loadMorePoems = () =>',
'v-for="item in visiblePoemRows"',
'GENERATION_POEM_STATUS.DISABLED',
'const activeRows = nextRows.filter',
'const firstGap = lastActiveGeneration === null',
'if (firstGap !== null)',
'overflow-wrap: anywhere',
'word-break: break-word'
)) {
if (-not $page.Contains($token)) { throw "G12 capacity contract missing: $token" }
}
foreach ($forbidden in @('stopMissingOldGeneration', 'MAX_GENERATION_POEM_LENGTH', 'query.startGeneration', 'query.currentGeneration', 'maxlength="50"', ':maxlength="MAX_GENERATION_POEM_INPUT_LENGTH"', ':maxlength="-1"')) {
if ($page.Contains($forbidden)) { throw "G12 retains an unsafe generation contract: $forbidden" }
}
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'
+129
View File
@@ -0,0 +1,129 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const source = fs.readFileSync(
path.join(__dirname, "../utils/genealogy-context.js"),
"utf8",
);
const storage = new Map();
const removals = [];
globalThis.uni = {
getStorageSync: (key) => storage.get(key),
setStorageSync: (key, value) => storage.set(key, value),
removeStorageSync: (key) => {
removals.push(key);
storage.delete(key);
},
};
const { genealogyContext } = await import(toDataModuleUrl(source));
assert.throws(
() => genealogyContext.setCurrentGenealogyId(1001),
/字符串/,
"当前家谱 ID 不得从可能已经失真的 JavaScript number 转换",
);
for (const invalidId of ["", " 1001", "1001 "]) {
assert.throws(
() => genealogyContext.setCurrentGenealogyId(invalidId),
/家谱 ID/,
"空值或带边界空白的 ID 必须失败关闭",
);
}
genealogyContext.setCurrentGenealogyId("9223372036854775807");
assert.strictEqual(
storage.get("jiapu_current_genealogy_id"),
"9223372036854775807",
"词法 ID 必须原样保存,不能转成 Number",
);
storage.set("jiapu_current_genealogy_id", { id: "1001" });
assert.strictEqual(
genealogyContext.getCurrentGenealogyId(),
"",
"旧对象或损坏值不得泄漏给页面",
);
assert.strictEqual(storage.has("jiapu_current_genealogy_id"), false);
assert.strictEqual(
genealogyContext.reconcileCurrentGenealogyId(["1001", "1002"], "1002"),
"1002",
"显式且仍可用的路由目标优先",
);
assert.strictEqual(
genealogyContext.reconcileCurrentGenealogyId(["1001", "1002"]),
"1002",
"没有显式目标时保留仍可用的已存选择",
);
assert.strictEqual(
genealogyContext.reconcileCurrentGenealogyId(["1001"]),
"",
"权限或列表变化移除旧选择后必须清空,不能静默切换家谱",
);
assert.strictEqual(storage.has("jiapu_current_genealogy_id"), false);
assert.strictEqual(
genealogyContext.reconcileCurrentGenealogyId(["1001", "1002"]),
"",
"撤权标记必须跨页面重载继续失败关闭",
);
assert.strictEqual(storage.get("jiapu_current_genealogy_invalidated"), "1");
genealogyContext.setCurrentGenealogyId("1001");
assert.strictEqual(storage.has("jiapu_current_genealogy_invalidated"), false);
genealogyContext.invalidateCurrentGenealogyId();
assert.strictEqual(genealogyContext.getCurrentGenealogyId(), "");
assert.strictEqual(genealogyContext.isCurrentGenealogyInvalidated(), true);
assert.strictEqual(
genealogyContext.reconcileCurrentGenealogyId(["1001"]),
"",
"领域列表异常写入的失效标记也必须阻止重载自动切谱",
);
genealogyContext.clearCurrentGenealogyId();
assert.strictEqual(
genealogyContext.reconcileCurrentGenealogyId(["1001", "1002"]),
"1001",
"只有没有历史撤权标记的首次进入才允许建立首个上下文",
);
assert.strictEqual(
genealogyContext.reconcileCurrentGenealogyId(["1001", "1002"], "missing"),
"",
"显式但不可用的目标必须失败关闭,不能回退到其他家谱",
);
assert.strictEqual(
genealogyContext.reconcileCurrentGenealogyId(["1001", "1002"]),
"",
"显式无权目标产生的撤权标记也必须跨重载保留",
);
assert.strictEqual(
genealogyContext.reconcileCurrentGenealogyId([], "missing"),
"",
"没有可用家谱时必须清空旧上下文",
);
assert.strictEqual(storage.has("jiapu_current_genealogy_id"), false);
assert.throws(
() => genealogyContext.reconcileCurrentGenealogyId(["1001", "1001"]),
/重复/,
"重复实体必须在领域边界被拒绝,不能静默去重",
);
assert.throws(
() => genealogyContext.reconcileCurrentGenealogyId([1001]),
/字符串/,
"可用列表也必须保持词法 ID",
);
assert.ok(removals.length >= 4, "损坏值、撤权与空列表都应清理持久上下文");
process.stdout.write("GENEALOGY-CONTEXT-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+187
View File
@@ -0,0 +1,187 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const run = async () => {
const source = fs.readFileSync(
path.join(__dirname, "../data/mock.js"),
"utf8",
);
const contractSource = fs.readFileSync(
path.join(__dirname, "../utils/genealogy-contracts.js"),
"utf8",
);
const contractUrl = `data:text/javascript;base64,${Buffer.from(contractSource).toString("base64")}`;
const {
GENEALOGY_ACCESS_PRESET,
isGenealogyAccessPreset,
toApiGenealogyAccess,
fromApiGenealogyAccess,
} = await import(contractUrl);
assert.strictEqual(isGenealogyAccessPreset(GENEALOGY_ACCESS_PRESET.MEMBER_ONLY), true);
assert.strictEqual(isGenealogyAccessPreset(GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY), true);
assert.strictEqual(isGenealogyAccessPreset("UNKNOWN"), false);
assert.strictEqual(isGenealogyAccessPreset(null), false);
assert.deepStrictEqual(toApiGenealogyAccess(GENEALOGY_ACCESS_PRESET.MEMBER_ONLY), {
visibility: "2",
joinMode: "0",
});
assert.deepStrictEqual(toApiGenealogyAccess(GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY), {
visibility: "1",
joinMode: "1",
});
assert.strictEqual(fromApiGenealogyAccess({ visibility: "2", joinMode: "0" }), "MEMBER_ONLY");
assert.strictEqual(fromApiGenealogyAccess({ visibility: "1", joinMode: "1" }), "PUBLIC_APPLY");
assert.strictEqual(fromApiGenealogyAccess({ visibility: "1", joinMode: "2" }), null);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source.replace("../utils/genealogy-contracts.js", contractUrl)).toString("base64")}`;
const {
genealogies,
publicGenealogies,
createLocalGenealogyPreview,
updateLocalGenealogyPreview,
updateLocalGenealogyPreviewAncestor,
removeLocalGenealogyPreview,
findGenealogyFixture,
getGenealogyFixtureAccess,
isGenealogySearchVisible,
} = await import(moduleUrl);
assert(genealogies.every((item) => typeof item.id === "string"));
assert(publicGenealogies.every((item) => typeof item.id === "string"));
assert(
publicGenealogies.every((item) => !Object.hasOwn(item, "membership")),
"公开搜索投影不得携带成员权限字段",
);
for (const publicItem of publicGenealogies.filter((item) =>
["joined", "owned"].includes(item.relation),
)) {
const memberItem = genealogies.find((item) => item.id === publicItem.id);
assert(memberItem, `${publicItem.relation} 搜索结果必须对应真实成员家谱`);
assert.strictEqual(publicItem.name, memberItem.name, "同一 ID 的家谱名称必须一致");
assert.strictEqual(
getGenealogyFixtureAccess(publicItem.id).accessRole,
publicItem.relation === "owned" ? "owner" : "member",
);
}
assert.strictEqual(findGenealogyFixture("2003")?.name, "汤氏济宁宗谱");
assert.strictEqual(findGenealogyFixture("2001")?.accessPreset, "PUBLIC_APPLY");
for (const id of ["2003", "2004", "2005"]) {
assert.strictEqual(
findGenealogyFixture(id)?.accessPreset,
"PUBLIC_APPLY",
`${id} 只有公开可申请家谱才能暴露关系动作`,
);
}
for (const [id, relation, canApply] of [
["1001", "owned", false],
["1002", "joined", false],
["2001", "available", true],
["2003", "pending", false],
["2004", "rejected", true],
["2005", "removed", true],
["local-created-genealogy", "preview", false],
["unknown", "unknown", false],
]) {
const access = getGenealogyFixtureAccess(id);
assert.strictEqual(access.relation, relation, `${id} 关系必须来自共享夹具 owner`);
assert.strictEqual(access.canApply, canApply, `${id} 申请资格必须失败关闭`);
}
assert.strictEqual(getGenealogyFixtureAccess("local-created-genealogy").viewMode, "preview");
assert.strictEqual(getGenealogyFixtureAccess("unknown").accessRole, "guest");
const publicFixture = findGenealogyFixture("2001");
const originalAccessPreset = publicFixture.accessPreset;
publicFixture.accessPreset = "MEMBER_ONLY";
assert.strictEqual(
getGenealogyFixtureAccess("2001").canView,
false,
"陌生账号不得直接打开仅成员可见家谱的总览",
);
assert.strictEqual(
getGenealogyFixtureAccess("2001").canApply,
false,
"仅成员可见家谱即使关系标签陈旧也必须失败关闭",
);
assert.strictEqual(
isGenealogySearchVisible("2001"),
false,
"陌生账号不得搜索到仅成员可见的家谱",
);
publicFixture.accessPreset = originalAccessPreset;
assert.strictEqual(getGenealogyFixtureAccess("2001").canView, true);
assert.strictEqual(isGenealogySearchVisible("2001"), true);
assert.strictEqual(getGenealogyFixtureAccess("1001").canView, true, "所有者可查看自己的私密家谱");
assert.strictEqual(getGenealogyFixtureAccess("local-created-genealogy").canView, true, "本轮本地预览必须可回看");
assert.strictEqual(getGenealogyFixtureAccess("unknown").canView, false);
assert.strictEqual(isGenealogySearchVisible("1001"), true, "所有者仍可定位自己的私密家谱");
const firstPreviewId = createLocalGenealogyPreview({
surname: "欧阳",
name: "欧阳氏测试谱",
hall: "敦本堂",
location: "江西·吉安",
accessPreset: "PUBLIC_APPLY",
});
const secondPreviewId = createLocalGenealogyPreview({
surname: "汤",
name: "第二部测试谱",
hall: "",
location: "河南·洛阳",
accessPreset: "MEMBER_ONLY",
});
assert.notStrictEqual(firstPreviewId, secondPreviewId, "重复创建不得复用本地预览 ID");
assert.strictEqual(typeof firstPreviewId, "string");
assert.deepStrictEqual(
{
surname: findGenealogyFixture(firstPreviewId)?.surname,
name: findGenealogyFixture(firstPreviewId)?.name,
hall: findGenealogyFixture(firstPreviewId)?.hall,
location: findGenealogyFixture(firstPreviewId)?.location,
accessPreset: findGenealogyFixture(firstPreviewId)?.accessPreset,
},
{
surname: "欧阳",
name: "欧阳氏测试谱",
hall: "敦本堂",
location: "江西·吉安",
accessPreset: "PUBLIC_APPLY",
},
);
updateLocalGenealogyPreviewAncestor(firstPreviewId, { personName: "欧阳修" });
assert.strictEqual(findGenealogyFixture(firstPreviewId)?.ancestorName, "欧阳修");
assert.strictEqual(
updateLocalGenealogyPreview(firstPreviewId, {
surname: "欧阳",
name: "欧阳氏修订谱",
hall: "敦本堂",
location: "江西·庐陵",
accessPreset: "MEMBER_ONLY",
}),
firstPreviewId,
);
assert.strictEqual(findGenealogyFixture(firstPreviewId)?.name, "欧阳氏修订谱");
assert.strictEqual(findGenealogyFixture(firstPreviewId)?.ancestorName, "欧阳修");
assert.deepStrictEqual(
{
viewMode: getGenealogyFixtureAccess(firstPreviewId).viewMode,
accessRole: getGenealogyFixtureAccess(firstPreviewId).accessRole,
relation: getGenealogyFixtureAccess(firstPreviewId).relation,
canView: getGenealogyFixtureAccess(firstPreviewId).canView,
canApply: getGenealogyFixtureAccess(firstPreviewId).canApply,
},
{ viewMode: "preview", accessRole: "guest", relation: "preview", canView: true, canApply: false },
);
assert.strictEqual(removeLocalGenealogyPreview(secondPreviewId), true);
assert.strictEqual(findGenealogyFixture(secondPreviewId), null);
process.stdout.write("GENEALOGY-FIXTURE-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
@@ -0,0 +1,275 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$jsonPath = Join-Path $root 'APP.openapi.json'
$yamlPath = Join-Path $root 'APP.openapi.yaml'
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath $yamlPath
$issues = New-Object System.Collections.Generic.List[string]
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-JsonOperation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) {
Add-Issue "JSON missing path: $Path"
return $null
}
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
if (-not $operationProperty) {
Add-Issue "JSON missing operation: $($Method.ToUpper()) $Path"
return $null
}
return $operationProperty.Value
}
function Get-JsonResponseSchemaRef {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return '' }
$responseProperty = $Operation.responses.PSObject.Properties['200']
if (-not $responseProperty) {
Add-Issue "JSON $Label missing 200 response"
return ''
}
$response = $responseProperty.Value
if ($response.'$ref') {
$responseName = ([string]$response.'$ref').Split('/')[-1]
$responseOwner = $document.components.responses.PSObject.Properties[$responseName]
if (-not $responseOwner) {
Add-Issue "JSON $Label references missing response owner: $responseName"
return ''
}
$response = $responseOwner.Value
}
$media = @($response.content.PSObject.Properties)
if ($media.Count -eq 0) {
Add-Issue "JSON $Label 200 response has no content schema"
return ''
}
return [string]$media[0].Value.schema.'$ref'
}
function Get-JsonSchema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Assert-JsonRequired {
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
if (-not $Schema) { return }
$required = @($Schema.required)
foreach ($field in $Fields) {
if ($field -notin $required) {
Add-Issue "JSON $SchemaName.required missing: $field"
}
}
}
function Assert-JsonProperty {
param(
[object]$Schema,
[string]$SchemaName,
[string]$Field,
[string]$Type,
[switch]$NonEmpty
)
if (-not $Schema) { return $null }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) {
Add-Issue "JSON $SchemaName missing property: $Field"
return $null
}
if ($property.Value.type -ne $Type) {
Add-Issue "JSON $SchemaName.$Field must be $Type"
}
if ($NonEmpty -and [int]$property.Value.minLength -lt 1) {
Add-Issue "JSON $SchemaName.$Field must declare minLength >= 1"
}
return $property.Value
}
function Get-YamlBlock {
param([string]$Header, [string]$NextHeaderPattern)
$lines = @($yaml -split "`r?`n")
$start = [Array]::IndexOf($lines, $Header)
if ($start -lt 0) { return '' }
$end = $lines.Count
for ($index = $start + 1; $index -lt $lines.Count; $index += 1) {
if ($lines[$index] -match $NextHeaderPattern) {
$end = $index
break
}
}
return ($lines[$start..($end - 1)] -join "`n")
}
function Get-YamlRequiredFields {
param([string]$Block)
$match = [regex]::Match(
$Block,
'(?ms)^ required:\s*\n(?<rows>(?: - [^\n]+\n?)+)'
)
if (-not $match.Success) { return @() }
return @([regex]::Matches($match.Groups['rows'].Value, '(?m)^ - (?<name>[^\s]+)\s*$') |
ForEach-Object { $_.Groups['name'].Value })
}
function Get-YamlFieldBlock {
param([string]$SchemaBlock, [string]$Field)
$pattern = '(?ms)^ ' + [regex]::Escape($Field) + ':\s*\n(?<body>.*?)(?=^ [A-Za-z0-9_]+:\s*$|\z)'
$match = [regex]::Match($SchemaBlock, $pattern)
if (-not $match.Success) { return '' }
return $match.Value
}
function Assert-YamlRequired {
param([string]$Block, [string]$SchemaName, [string[]]$Fields)
if (-not $Block) { return }
$required = @(Get-YamlRequiredFields $Block)
foreach ($field in $Fields) {
if ($field -notin $required) {
Add-Issue "YAML $SchemaName.required missing: $field"
}
}
}
function Assert-YamlField {
param(
[string]$Block,
[string]$SchemaName,
[string]$Field,
[string]$Type,
[switch]$NonEmpty
)
if (-not $Block) { return '' }
$fieldBlock = Get-YamlFieldBlock $Block $Field
if (-not $fieldBlock) {
Add-Issue "YAML $SchemaName missing property: $Field"
return ''
}
if ($fieldBlock -notmatch "(?m)^ type: $([regex]::Escape($Type))\s*$") {
Add-Issue "YAML $SchemaName.$Field must be $Type"
}
if ($NonEmpty -and $fieldBlock -notmatch '(?m)^ minLength: [1-9][0-9]*\s*$') {
Add-Issue "YAML $SchemaName.$Field must declare minLength >= 1"
}
return $fieldBlock
}
$minePath = '/genealogy/app/genealogies/mine'
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
$mine = Get-JsonOperation $minePath 'get'
$overview = Get-JsonOperation $overviewPath 'get'
$mineResponseRef = Get-JsonResponseSchemaRef $mine 'GET /mine'
if ($mineResponseRef -ne '#/components/schemas/RListAppGenealogyVo') {
Add-Issue "JSON GET /mine must return RListAppGenealogyVo; actual: $mineResponseRef"
}
$overviewResponseRef = Get-JsonResponseSchemaRef $overview 'GET /overview'
if ($overviewResponseRef -ne '#/components/schemas/RAppGenealogyVo') {
Add-Issue "JSON GET /overview must return RAppGenealogyVo; actual: $overviewResponseRef"
}
$listEnvelope = Get-JsonSchema 'RListAppGenealogyVo'
$objectEnvelope = Get-JsonSchema 'RAppGenealogyVo'
$genealogy = Get-JsonSchema 'AppGenealogyVo'
Assert-JsonRequired $listEnvelope 'RListAppGenealogyVo' @('code', 'data')
Assert-JsonRequired $objectEnvelope 'RAppGenealogyVo' @('code', 'data')
Assert-JsonProperty $listEnvelope 'RListAppGenealogyVo' 'code' 'integer' | Out-Null
Assert-JsonProperty $objectEnvelope 'RAppGenealogyVo' 'code' 'integer' | Out-Null
if ($listEnvelope) {
$listData = $listEnvelope.properties.PSObject.Properties['data'].Value
if (-not $listData -or $listData.type -ne 'array' -or $listData.items.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
Add-Issue 'JSON RListAppGenealogyVo.data must be AppGenealogyVo[]'
}
}
if ($objectEnvelope) {
$objectData = $objectEnvelope.properties.PSObject.Properties['data'].Value
if (-not $objectData -or $objectData.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
Add-Issue 'JSON RAppGenealogyVo.data must reference AppGenealogyVo'
}
}
$consumedFields = @('genealogyId', 'genealogyName', 'canView', 'canManage', 'canEditContent', 'roleType')
Assert-JsonRequired $genealogy 'AppGenealogyVo' $consumedFields
Assert-JsonProperty $genealogy 'AppGenealogyVo' 'genealogyId' 'string' -NonEmpty | Out-Null
Assert-JsonProperty $genealogy 'AppGenealogyVo' 'genealogyName' 'string' -NonEmpty | Out-Null
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
Assert-JsonProperty $genealogy 'AppGenealogyVo' $field 'boolean' | Out-Null
}
$roleType = Assert-JsonProperty $genealogy 'AppGenealogyVo' 'roleType' 'string'
if ($roleType) {
$roleValues = @($roleType.enum | Where-Object { $_ -is [string] -and $_.Trim() }) | Select-Object -Unique
if ($roleValues.Count -lt 2) {
Add-Issue 'JSON AppGenealogyVo.roleType must declare at least two non-empty enum values'
}
}
$yamlMine = Get-YamlBlock " $minePath`:" '^ /.*:$'
$yamlOverview = Get-YamlBlock " $overviewPath`:" '^ /.*:$'
if (-not $yamlMine) {
Add-Issue "YAML missing path: $minePath"
} elseif ($yamlMine -notmatch [regex]::Escape('#/components/schemas/RListAppGenealogyVo')) {
Add-Issue 'YAML GET /mine must return RListAppGenealogyVo'
}
if (-not $yamlOverview) {
Add-Issue "YAML missing path: $overviewPath"
} elseif ($yamlOverview -notmatch [regex]::Escape('#/components/schemas/RAppGenealogyVo')) {
Add-Issue 'YAML GET /overview must return RAppGenealogyVo'
}
$yamlListEnvelope = Get-YamlBlock ' RListAppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
$yamlObjectEnvelope = Get-YamlBlock ' RAppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
$yamlGenealogy = Get-YamlBlock ' AppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
foreach ($schema in @(
@{ Name = 'RListAppGenealogyVo'; Block = $yamlListEnvelope },
@{ Name = 'RAppGenealogyVo'; Block = $yamlObjectEnvelope },
@{ Name = 'AppGenealogyVo'; Block = $yamlGenealogy }
)) {
if (-not $schema.Block) { Add-Issue "YAML missing schema owner: $($schema.Name)" }
}
Assert-YamlRequired $yamlListEnvelope 'RListAppGenealogyVo' @('code', 'data')
Assert-YamlRequired $yamlObjectEnvelope 'RAppGenealogyVo' @('code', 'data')
Assert-YamlRequired $yamlGenealogy 'AppGenealogyVo' $consumedFields
Assert-YamlField $yamlListEnvelope 'RListAppGenealogyVo' 'code' 'integer' | Out-Null
Assert-YamlField $yamlObjectEnvelope 'RAppGenealogyVo' 'code' 'integer' | Out-Null
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'genealogyId' 'string' -NonEmpty | Out-Null
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'genealogyName' 'string' -NonEmpty | Out-Null
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' $field 'boolean' | Out-Null
}
$yamlRoleType = Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'roleType' 'string'
if ($yamlRoleType) {
$enumMatch = [regex]::Match(
$yamlRoleType,
'(?ms)^ enum:\s*\n(?<rows>(?: - [^\n]+\n?)+)'
)
$yamlRoleValues = if ($enumMatch.Success) {
@([regex]::Matches($enumMatch.Groups['rows'].Value, '(?m)^ - (?<value>\S.*?)\s*$') |
ForEach-Object { $_.Groups['value'].Value } | Select-Object -Unique)
} else { @() }
if ($yamlRoleValues.Count -lt 2) {
Add-Issue 'YAML AppGenealogyVo.roleType must declare at least two enum values'
}
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Release behavior still requires authenticated tests for current-account filtering, revocation, cross-account access, 401/403/404/5xx, malformed JSON, and cancellation.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS'
+254
View File
@@ -0,0 +1,254 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const run = async () => {
const source = fs.readFileSync(
path.join(__dirname, "../utils/generation-poem.js"),
"utf8",
);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const {
MAX_GENERATION_NO,
MAX_GENERATION_COUNT,
MAX_GENERATION_TEXT_LENGTH,
MAX_GENERATION_POEM_INPUT_LENGTH,
GENERATION_POEM_STATUS,
normalizeGenerationNumber,
validateGenerationPoemText,
findFirstGenerationGap,
mergeGenerationPoemRows,
} = await import(moduleUrl);
assert.strictEqual(MAX_GENERATION_COUNT, 500);
assert.strictEqual(MAX_GENERATION_TEXT_LENGTH, 50);
assert.strictEqual(MAX_GENERATION_POEM_INPUT_LENGTH, 26000);
assert.deepStrictEqual(GENERATION_POEM_STATUS, {
ACTIVE: "0",
DISABLED: "1",
});
assert.strictEqual(normalizeGenerationNumber(undefined, 12), 12);
assert.strictEqual(normalizeGenerationNumber("300", 12), 300);
for (const invalid of [
"0",
"1.5",
"1e2",
"Infinity",
Infinity,
NaN,
MAX_GENERATION_NO + 1,
]) {
assert.strictEqual(
normalizeGenerationNumber(invalid, 12),
null,
`非法代次未拒绝:${invalid}`,
);
}
assert.deepStrictEqual(validateGenerationPoemText("启宗敦本").generations, [
"启",
"宗",
"敦",
"本",
]);
assert.deepStrictEqual(
validateGenerationPoemText("克勤 克俭,承先;启后、敦本/睦族|传芳").generations,
["克勤", "克俭", "承先", "启后", "敦本", "睦族", "传芳"],
"OpenAPI 声明的分隔符必须统一解析多字字辈",
);
assert.deepStrictEqual(
validateGenerationPoemText("德承家亦\n忠厚传芳").generations,
["德承家亦", "忠厚传芳"],
);
assert.strictEqual(
validateGenerationPoemText("𠀀").valid,
true,
"扩展区字符必须按一个 code point 计数",
);
assert.strictEqual(validateGenerationPoemText("").valid, false);
assert.strictEqual(validateGenerationPoemText("启".repeat(501)).valid, false);
assert.strictEqual(
validateGenerationPoemText(`${"启".repeat(51)}`).valid,
false,
);
assert.strictEqual(
validateGenerationPoemText("启".repeat(MAX_GENERATION_POEM_INPUT_LENGTH + 1)).valid,
false,
);
assert.throws(
() =>
mergeGenerationPoemRows({
existingRows: [],
generationTexts: ["启", "宗"],
startGeneration: MAX_GENERATION_NO,
currentGeneration: MAX_GENERATION_NO,
disableMissing: false,
}),
/超出支持范围/,
);
assert.throws(
() =>
mergeGenerationPoemRows({
existingRows: [],
generationTexts: Array.from({ length: 501 }, () => "启"),
startGeneration: 1,
currentGeneration: 1,
disableMissing: false,
}),
/最多 500 个世代/,
);
assert.throws(
() =>
mergeGenerationPoemRows({
existingRows: [],
generationTexts: ["启"],
startGeneration: 1,
currentGeneration: 1,
disableMissing: "false",
}),
/disableMissing/,
);
const contiguous = [
{ generationNo: 12, generationText: "启", status: "0" },
{ generationNo: 13, generationText: "宗", status: "0" },
{ generationNo: 14, generationText: "敦", status: "0" },
];
assert.strictEqual(findFirstGenerationGap(contiguous, 15), null);
assert.strictEqual(
findFirstGenerationGap([contiguous[0], contiguous[2]], 15),
13,
"历史缺口必须定位到首个缺失世代",
);
assert.strictEqual(
findFirstGenerationGap([{ generationNo: 5, status: "0" }], 6, 1),
1,
"完整序列必须从第一世检查前缀缺口",
);
assert.strictEqual(
findFirstGenerationGap(
[
{ generationNo: 1, status: "0" },
{ generationNo: 2, status: "0" },
{ generationNo: 5, status: "0" },
],
6,
1,
),
3,
"保留后续 ACTIVE 记录时不能留下中间缺口",
);
assert.strictEqual(
findFirstGenerationGap(
[
{ generationNo: 1, status: "0" },
{ generationNo: 2, status: "1" },
{ generationNo: 3, status: "0" },
],
4,
1,
),
2,
"DISABLED 记录不能填补 ACTIVE 序列缺口",
);
assert.throws(
() =>
mergeGenerationPoemRows({
existingRows: [
{ generationNo: 1, generationText: "启", status: "UNKNOWN" },
],
generationTexts: ["宗"],
startGeneration: 2,
currentGeneration: 2,
disableMissing: false,
}),
/GenerationPoemView/,
);
assert.throws(
() =>
mergeGenerationPoemRows({
existingRows: [
{ generationNo: 1, generationText: "启", status: "0" },
{ generationNo: 1, generationText: "宗", status: "1" },
],
generationTexts: ["敦"],
startGeneration: 2,
currentGeneration: 2,
disableMissing: false,
}),
/重复的世代记录/,
"同一世代存在多条记录时必须失败关闭,不能静默 last-wins",
);
const preserved = mergeGenerationPoemRows({
existingRows: [
...contiguous,
{ poemId: "9007199254740993", generationNo: 15, generationText: "本", status: "0" },
{ generationNo: 16, generationText: "继", status: "0" },
],
generationTexts: ["克勤"],
startGeneration: 15,
currentGeneration: 15,
disableMissing: false,
});
assert.deepStrictEqual(
preserved.map(({ generationNo, generationText, status }) => ({
generationNo,
generationText,
status,
})),
[
{ generationNo: 12, generationText: "启", status: "0" },
{ generationNo: 13, generationText: "宗", status: "0" },
{ generationNo: 14, generationText: "敦", status: "0" },
{ generationNo: 15, generationText: "克勤", status: "0" },
{ generationNo: 16, generationText: "继", status: "0" },
],
);
assert.strictEqual(
preserved.find((row) => row.generationNo === 15)?.poemId,
"9007199254740993",
"覆盖文字时必须保留词法 int64 记录身份",
);
const disabled = mergeGenerationPoemRows({
existingRows: preserved,
generationTexts: ["昭穆"],
startGeneration: 15,
currentGeneration: 15,
disableMissing: true,
});
assert.strictEqual(
disabled.find((row) => row.generationNo === 12)?.generationText,
"启",
);
assert.strictEqual(
disabled.find((row) => row.generationNo === 16)?.status,
"1",
);
assert.strictEqual(
disabled.find((row) => row.generationNo === 16)?.generationText,
"继",
);
const maximumBatch = mergeGenerationPoemRows({
existingRows: [],
generationTexts: Array.from({ length: MAX_GENERATION_COUNT }, () => "启"),
startGeneration: 1,
currentGeneration: 500,
disableMissing: false,
});
assert.strictEqual(maximumBatch.length, 500);
assert.strictEqual(maximumBatch[499].generationNo, 500);
assert.strictEqual(maximumBatch[499].current, true);
process.stdout.write("GENERATION-POEM-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
@@ -60,11 +60,6 @@ foreach ($entry in $moduleAssets.GetEnumerator()) {
if (-not $asset) { throw "Missing module background asset: $($entry.Value)" }
}
$modulePage = Read-Utf8 (Join-Path $componentsRoot 'ModulePage.vue')
foreach ($token in @('<ModulePageBackground', '<AppButton', '<AppToast')) {
if ($modulePage -notmatch [regex]::Escape($token)) { throw "ModulePage does not consume $token." }
}
$g01 = Read-Utf8 (Join-Path $pagesRoot 'genealogy/g01-my-genealogies.vue')
if ($g01 -notmatch '(?s)\.current-meta\s*\{[^}]*color:\s*#62584c;[^}]*font-weight:\s*500;') { throw 'G01 current genealogy metadata is not using the approved readable style.' }
+123
View File
@@ -0,0 +1,123 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$issues = New-Object System.Collections.Generic.List[string]
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Assert-Required {
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
if (-not $Schema) { return }
foreach ($field in $Fields) {
if ($field -notin @($Schema.required)) {
Add-Issue "JSON $SchemaName.required missing: $field"
}
}
}
function Assert-NonEmptyString {
param([object]$Schema, [string]$SchemaName, [string]$Field)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) {
Add-Issue "JSON $SchemaName missing property: $Field"
return
}
if ($property.Value.type -ne 'string') {
Add-Issue "JSON $SchemaName.$Field must be string"
}
if ([int]$property.Value.minLength -lt 1) {
Add-Issue "JSON $SchemaName.$Field must declare minLength >= 1"
}
}
$path = '/genealogy/app/help-articles'
$pathProperty = $document.paths.PSObject.Properties[$path]
$operation = if ($pathProperty) { $pathProperty.Value.get } else { $null }
if (-not $operation) {
Add-Issue "JSON missing GET $path"
} else {
$hasSaToken = $false
foreach ($securityRequirement in @($operation.security)) {
if ($securityRequirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { Add-Issue "JSON GET $path must require SaToken" }
$response = $operation.responses.PSObject.Properties['200'].Value
if ($response.'$ref') {
$responseName = ([string]$response.'$ref').Split('/')[-1]
$response = $document.components.responses.PSObject.Properties[$responseName].Value
}
$media = @($response.content.PSObject.Properties)
$responseRef = if ($media.Count -gt 0) { [string]$media[0].Value.schema.'$ref' } else { '' }
if ($responseRef -ne '#/components/schemas/RListHelpArticleVo') {
Add-Issue "JSON GET $path must return RListHelpArticleVo; actual: $responseRef"
}
}
$envelope = Get-Schema 'RListHelpArticleVo'
$article = Get-Schema 'HelpArticleVo'
Assert-Required $envelope 'RListHelpArticleVo' @('code', 'data')
Assert-Required $article 'HelpArticleVo' @('helpCategory', 'helpTitle', 'helpContent')
if ($envelope) {
if ($envelope.properties.code.type -ne 'integer') {
Add-Issue 'JSON RListHelpArticleVo.code must be integer'
}
$data = $envelope.properties.data
if ($data.type -ne 'array' -or $data.items.'$ref' -ne '#/components/schemas/HelpArticleVo') {
Add-Issue 'JSON RListHelpArticleVo.data must be HelpArticleVo[]'
}
}
foreach ($field in @('helpCategory', 'helpTitle', 'helpContent')) {
Assert-NonEmptyString $article 'HelpArticleVo' $field
}
if ($article) {
$contentDescription = [string]$article.properties.helpContent.description
if ($contentDescription -notmatch '(?i)plain[ -]?text') {
Add-Issue 'JSON HelpArticleVo.helpContent must declare plain-text semantics'
}
}
foreach ($yamlFact in @(
' /genealogy/app/help-articles:',
'#/components/schemas/RListHelpArticleVo',
' RListHelpArticleVo:',
' HelpArticleVo:',
' - code',
' - data',
' - helpCategory',
' - helpTitle',
' - helpContent'
)) {
if (-not $yaml.Contains($yamlFact)) {
Add-Issue "YAML fact is missing: $yamlFact"
}
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('HELP-CENTER-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- M06 will use the complete list response as its only remote owner; the detail endpoint and helpId are not consumed.')
$lines.Add('- Authenticated release tests must still cover published-only ordering, 401, malformed data, empty data, 5xx, timeout, and cancellation.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'HELP-CENTER-OPENAPI-CONTRACT PASS'
+118 -60
View File
@@ -3,7 +3,6 @@
$root = Split-Path -Parent $PSScriptRoot
$pagesPath = Join-Path $root 'pages.json'
$mappingPath = Join-Path $root 'docs/接口与页面映射总表.md'
$planPath = Join-Path $root 'docs/家谱项目全量治理实施计划.md'
if (-not (Test-Path -LiteralPath $mappingPath -PathType Leaf)) {
throw '缺少页面与接口关系的唯一总表:docs/接口与页面映射总表.md'
@@ -13,8 +12,6 @@ $pagesDocument = Get-Content -Raw -Encoding UTF8 -LiteralPath $pagesPath | Conve
$registeredRoutes = @($pagesDocument.pages | ForEach-Object { $_.path })
$mappingLines = @(Get-Content -Encoding UTF8 -LiteralPath $mappingPath)
$mappingText = $mappingLines -join "`n"
$planLines = @(Get-Content -Encoding UTF8 -LiteralPath $planPath)
$planText = $planLines -join "`n"
# 第一步:只解析以正式页面编号开头的表格行,避免把正文中的示例路由误算成页面映射。
$routeRows = @(
@@ -58,6 +55,16 @@ if ($duplicateRoutes.Count -gt 0) {
throw "页面映射存在重复路由:$($duplicateRoutes.Name -join ', ')"
}
# 页面编号必须由实际文件名语义得出,不能把正确路径和另一个页面编号错误拼接。
foreach ($row in $routeRows) {
if ($row.Route -notmatch '/(?<routeCode>[a-z]\d{2})-[^/]+$') {
throw "页面映射路由缺少文件名语义编号:$($row.Route)"
}
if ($Matches.routeCode.ToUpperInvariant() -ne $row.Code) {
throw "页面映射编号与路由不一致:编号=$($row.Code),路由=$($row.Route)"
}
}
# 第四步:总表顺序和路由集合必须与 pages.json 精确一致,新增或删除页面时必须同轮更新唯一总表。
$mappedRoutes = @($routeRows | ForEach-Object Route)
$missingRoutes = @($registeredRoutes | Where-Object { $_ -notin $mappedRoutes })
@@ -72,11 +79,67 @@ for ($index = 0; $index -lt $registeredRoutes.Count; $index += 1) {
}
}
# 第五步:只允许 A04 和 T01 使用已经形成证据的专项结论;其余页面不能提前冒充完成 OpenAPI 审查。
# 第五步:A04、G 系列第一轮、F 系列当前写边界和 T01 允许登记已有专项证据;
# 其余页面不能提前冒充完成 OpenAPI 审查。F 系列每页必须同时写出已核对事实和仍未启用的边界。
$authSeriesReviewPatterns = @{
A01 = '`APP_SMS_LOGIN` 客户端链已落地.*`API-AUTH-TAC-001` 硬关闭.*见 2\.10'
A04 = '`APP_REGISTER`、4 位码、`RAppLoginVo → AppLoginVo\.access_token` 客户端链已落地.*真实 challenge.*红灯.*见 2\.10'
A05 = '`APP_FORGOT_PASSWORD` 客户端链已落地.*真实后端、Android 可访问替代与 MuMu 仍红灯.*见 2\.10'
}
$gSeriesWorkspacePatterns = @{
G01 = 'Task26 工作区红灯已建立.*`/mine`.*尚未接远端.*Task29 unread-count 红灯.*M01.*见 2\.13/5\.6'
G03 = 'Task35 已建立 atomic bootstrap、无 PII operation-status、统一 accessPreset、APP 可信地区与词法 ID 的后端/客户端双红灯.*见 2\.8/5\.12'
G05 = 'Task26 选定 `/overview` 为唯一 owner.*对象级授权和错误语义未关闭.*见 2\.8/5\.3'
}
$gSeriesFirstReviewCodes = @('G06', 'G08', 'G09', 'G10', 'G11', 'G12')
$fSeriesReviewPatterns = @{
F01 = '任务 7 已核对.*线上有动态列表/写入能力.*当前静态批次只读'
F02 = '线上写请求要求 `feedContent`.*minLength=1.*`feedType/mediaOssIds/sortOrder/status`.*`WRITE_UNAVAILABLE`'
F03 = '线上评论写请求要求 `commentContent`.*minLength=0/maxLength=1000.*不宣称发送成功'
F04 = '任务 7 已核对线上列表及写接口.*唯一只读 owner'
F05 = '线上未证明收藏合同.*`articleTitle/articleContent`.*minLength=1'
F06 = '线上 POST/PUT 已存在.*`categoryId/coverOssId` 为 int64.*真实调用未'
F07 = '线上创建要求 `albumName`.*不伪增列表或计数'
F08 = '任务 7 已核对.*复合身份.*跨谱'
F09 = '旧 `/files/upload` 已下线.*resumable init/chunk/complete.*complete 返回.*`ossId` 为 string.*照片创建要求 int64.*后端消除类型冲突前不得转换或提交'
F10 = '任务 7 未发现.*保持关闭'
}
$rSeriesReviewPatterns = @{
R01 = '任务 8 已核对.*lineage/persons/page.*词法字符串'
R02 = '任务 8 已核对.*AppLineagePersonBody.*`name` 必填.*真实写入未启用'
R03 = '任务 8 已核对.*relative-records.*不是.*ceremony gifts.*方向语义'
R04 = '任务 8 已核对.*`relativeId`.*`relativeName` 必填.*本地预览'
R05 = '任务 8 已核对.*ceremonies.*`ceremonyTitle/ceremonyType` 必填.*无正式枚举'
R06 = '任务 8 已核对.*invitations.*成员选项.*参与者'
R07 = '任务 8 已核对.*`ceremonyId`.*真实写入未启用'
R08 = '任务 8 已核对.*growth-records.*`lineagePersonId`.*`recordType` 无枚举'
R09 = '任务 8 已核对.*没有独立人生事件接口.*硬关闭'
R10 = '任务 8 已核对.*memos.*`memoTitle` 必填.*`completed` 无枚举'
R11 = '任务 8 已核对.*merit-records.*`donorName/meritTitle` 必填.*无枚举'
}
$nmSeriesReviewPatterns = @{
N01 = 'Task29 读取红灯已建立.*完整活动集合.*纯文本.*计数同域.*见 2\.13/5\.6.*Task30 前不得本地伪写'
N02 = 'Task29 选定 list-owned snapshot.*删除目标 CTA.*Task30.*私有字符串 ID.*见 2\.13/5\.6/5\.7'
M01 = 'Task28 profile GET 红灯.*身份失败不锁菜单.*见 2\.13/5\.5.*Task29 unread-count 红灯.*fixture 伪数.*见 2\.13/5\.6'
M02 = 'Task28 读取红灯仍是前置.*Task31 已建立 PUT merge.*If-Match.*typed response.*409 红灯.*头像不混入.*见 2\.13/5\.5/5\.8'
M03 = 'Task28 已建立 profile GET 红灯.*普通读取失败不得阻断密码入口.*设备状态仍无合同.*见 2\.13/5\.5'
M04 = 'Task33 已建立 raw writeOnly、ALL 会话撤销、typed 错误与 session marker 红灯.*四条密码 wire 必须同批迁移.*见 2\.13/5\.10'
M05 = 'Task34 已建立专用 SaToken 发码、全活动六位码、ALL 会话撤销、outbox 与 credential marker 红灯.*依赖 M04 raw wire.*见 2\.13/5\.11'
M06 = 'Task27 选定列表唯一 owner 并建立红灯.*专用 required、纯文本、仅发布、顺序和认证语义待后端关闭.*见 2\.13/5\.4'
M07 = 'Task25 已接 `POST /genealogy/app/feedback` 严格客户端.*remote 实测与 MuMu 待认证门禁关闭.*见 2\.13'
M08 = '线上无邀请码签发/校验/直入合同.*删除码值、复制和海报伪能力.*见 2\.13'
M09 = '套餐/订单端点存在但支付闭环未定义.*删除查询参数演示订单.*见 2\.13'
M10 = 'Task9 已完成本机清理基线.*Task32 已建立当前凭证族、幂等 200、logoutCoordinator、required RVoid 与部署复用红灯.*见 2\.13/5\.9'
}
$prematureRows = @(
$routeRows | Where-Object {
if ($_.Code -eq 'A04') { return $_.Line -notmatch '已核对注册成功响应为 `LoginResult`' -or $_.Line -notmatch '成功建立登录态并进入 G01' }
if ($authSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $authSeriesReviewPatterns[$_.Code] }
if ($_.Code -eq 'T01') { return $_.Line -notmatch '专项已核对:现有递归 `LineagePersonTreeView` 不满足' }
if ($gSeriesWorkspacePatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $gSeriesWorkspacePatterns[$_.Code] }
if ($_.Code -in $gSeriesFirstReviewCodes) { return $_.Line -notmatch '第一轮已核对' -or $_.Line -notmatch '见 2\.8' }
if ($fSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $fSeriesReviewPatterns[$_.Code] }
if ($rSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $rSeriesReviewPatterns[$_.Code] }
if ($nmSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $nmSeriesReviewPatterns[$_.Code] }
return $_.Line -notmatch '待对应业务阶段 OpenAPI 审查'
}
)
@@ -98,10 +161,32 @@ foreach ($heading in @(
# 第六步:锁定旧资料中仍然有效的关键业务事实,避免未来把表格保留却悄悄清空真正的产品合同。
foreach ($requiredFact in @(
'832',
'A01 在本地校验通过后、登录请求前触发',
'A05 在请求发送短信验证码前触发',
'A04 当前临时在完整注册表单提交后触发',
'15—64 Unicode code point',
'无组成规则',
'SmsCodeSecret',
'恰好 6 位 ASCII 数字',
'/genealogy/app/auth/phone/sms/code',
'PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED',
'API-PHONE-001',
'STEP_UP_UNAVAILABLE',
'AppGenealogyBootstrapBody',
'GenealogyBootstrapOperationKey',
'GenealogyAccessPreset',
'GenealogyRegionCode',
'FAILED_NO_COMMIT',
'G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED',
'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED',
'/genealogy/app/region/search',
'GET 必须纯读',
'receipt→mine cache→context→G05',
'API-G03-001',
'API-G03-005',
'A01、A04、A05 已接入同一个 `TacVerification`',
'`APP_SMS_LOGIN`',
'`APP_REGISTER`',
'`APP_FORGOT_PASSWORD`',
'密码登录入口保持不可用',
'不能把本地滑动成功冒充服务端验证',
'聚焦密码框',
'审核中记录只展示进度和“撤回申请”',
'被拒绝记录展示原因和“修改后重新提交”',
@@ -128,7 +213,8 @@ foreach ($requiredFact in @(
'/genealogy/app/v2/',
'API-T01-001',
'单个原生页面实例',
'注册成功响应为 `LoginResult`',
'注册成功响应为 `RAppLoginVo`',
'`AppLoginVo.access_token`',
'aria-describedby',
'邀请码直接加入且不生成审核记录',
'appApi',
@@ -151,58 +237,30 @@ foreach ($requiredFact in @(
}
}
# 第七步:在可执行注册表落地前解析实施计划中的 52 行路由表,防止计划与页面总表各自正确却互相漂移。
$plannedRouteRows = @(
foreach ($line in $planLines) {
if ($line -match '^\|\s*(?<code>[AGTFRNM]\d{2})\s*\|\s*(?<kind>认证根页|根页|普通页|流程页|单实例页)\s*\|') {
$columns = @($line.Trim('|').Split('|') | ForEach-Object { $_.Trim() })
[pscustomobject]@{
Code = $Matches.code
Kind = $Matches.kind
Columns = $columns
Line = $line
}
}
}
)
if ($plannedRouteRows.Count -ne 52) {
throw "实施计划路由表不是 52 行:实际=$($plannedRouteRows.Count)"
}
$duplicatePlannedCodes = @($plannedRouteRows | Group-Object Code | Where-Object Count -gt 1)
if ($duplicatePlannedCodes.Count -gt 0) {
throw "实施计划路由表存在重复编号:$($duplicatePlannedCodes.Name -join ', ')"
}
$mappedCodes = @($routeRows | ForEach-Object Code)
$plannedCodes = @($plannedRouteRows | ForEach-Object Code)
$missingPlannedCodes = @($mappedCodes | Where-Object { $_ -notin $plannedCodes })
$stalePlannedCodes = @($plannedCodes | Where-Object { $_ -notin $mappedCodes })
if ($missingPlannedCodes.Count -gt 0 -or $stalePlannedCodes.Count -gt 0) {
throw "实施计划路由表与页面映射编号不相等:缺失=[$($missingPlannedCodes -join ', ')];陈旧=[$($stalePlannedCodes -join ', ')]"
}
$plannedRootCodes = @($plannedRouteRows | Where-Object { $_.Kind -in @('认证根页', '根页') } | ForEach-Object Code)
if (($plannedRootCodes -join ',') -ne 'A01,G01,F01,M01') {
throw "实施计划四个根语义错误:$($plannedRootCodes -join ',')"
}
$plannedBusinessRoots = @($plannedRouteRows | Where-Object Kind -eq '根页' | ForEach-Object Code)
if (($plannedBusinessRoots -join ',') -ne 'G01,F01,M01') {
throw "实施计划业务根页必须仅为 G01/F01/M01$($plannedBusinessRoots -join ',')"
}
if ($planText -notmatch 'T03: defineRoute\([^\r\n]*parentParamMap: \{ selectedId: "personId" \}') {
throw '实施计划缺少 T03 直接进入时 personId 到 selectedId 的父页参数映射'
}
if ($planText -notmatch 'openPage\("N02", \{ id \}, "N01"\)') {
throw '实施计划没有把 N01 打开 N02 的真实来源固定为 N01'
}
$plannedTableText = ($plannedRouteRows | ForEach-Object Line) -join "`n"
foreach ($forbiddenPlannedParam in @('genealogyId,step', 'genealogyName')) {
if ($plannedTableText.Contains($forbiddenPlannedParam)) {
throw "实施计划路由表仍含已删除参数:$forbiddenPlannedParam"
}
}
if ($mappingText -match '汤正华堂侄') {
throw '当前产品文档不得使用具体姓名示例,应改为“某某某堂侄”等通用占位表达'
}
$localRouteBoundary = [regex]::Match(
$mappingText,
'(?s)### 2\.6 当前本地数据与路由参数边界.*?(?=### 2\.7)'
).Value
$localRouteTable = [regex]::Match($localRouteBoundary, '(?s)\| 页面 \|.*?\n\n').Value
foreach ($retiredRParameter in @('`count`', '`saveResult`', '`giftId`', '`ritualId`', '`personName`')) {
if ($localRouteTable.Contains($retiredRParameter)) {
throw "R 系列本地路由边界仍登记已退役参数:$retiredRParameter"
}
}
foreach ($currentRContract in @(
'| R01 | `genealogyId`、`state` |',
'| R04 | `genealogyId`、`mode`、`relativeId`、`state` |',
'| R07 | `genealogyId`、`mode`、`ceremonyId`、`state` |',
'| R08、R09 | `genealogyId`、`personId`、`state` |',
'| R10、R11 | `genealogyId`、`state` |'
)) {
if (-not $localRouteTable.Contains($currentRContract)) {
throw "R 系列本地路由边界缺少当前复合身份合同:$currentRContract"
}
}
Write-Output "INTERFACE-PAGE-MAPPING-CONTRACT PASS ROUTES=$($routeRows.Count)"
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$issues = New-Object System.Collections.Generic.List[string]
$logoutPath = '/genealogy/app/auth/logout'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Get-Response {
param([object]$Operation, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON DELETE $logoutPath missing $Status response"
return $null
}
$response = $property.Value
if ($response.'$ref') {
$name = ([string]$response.'$ref').Split('/')[-1]
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON missing response owner: $name"
return $null
}
$response = $owner.Value
}
return $response
}
function Get-ResponseSchemaRef {
param([object]$Operation, [string]$Status)
$response = Get-Response $Operation $Status
if (-not $response) { return '' }
$media = $response.content.PSObject.Properties['application/json']
if (-not $media) {
Add-Issue "JSON DELETE $logoutPath $Status must use application/json"
return ''
}
return [string]$media.Value.schema.'$ref'
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON DELETE $logoutPath $Status must document Cache-Control: private, no-store"
return
}
$header = $property.Value
if ($header.'$ref') {
$name = ([string]$header.'$ref').Split('/')[-1]
$owner = $document.components.headers.PSObject.Properties[$name]
if ($owner) { $header = $owner.Value }
}
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
Add-Issue "JSON DELETE $logoutPath $Status Cache-Control must specify private, no-store"
}
}
$pathProperty = $document.paths.PSObject.Properties[$logoutPath]
$operation = if ($pathProperty) { $pathProperty.Value.delete } else { $null }
if (-not $operation) { Add-Issue "JSON missing DELETE $logoutPath" }
if ($operation) {
$hasSaToken = $false
foreach ($requirement in @($operation.security)) {
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { Add-Issue "JSON DELETE $logoutPath must require SaToken" }
$clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or
$clientHeaders[0].schema.type -ne 'string' -or [int]$clientHeaders[0].schema.minLength -lt 1) {
Add-Issue "JSON DELETE $logoutPath must require one non-empty string clientid header"
}
if ($operation.requestBody) { Add-Issue "JSON DELETE $logoutPath must not accept a request body" }
$semantics = [string]$operation.description
foreach ($semanticPattern in @(
'(?i)presented (access token|credential family)',
'(?i)other device sessions remain valid',
'(?i)repeated.*(idempotent|no additional side effects)',
'(?i)active.*revoked.*expired.*same 200',
'(?i)successful revocation.*token.*rejected'
)) {
if ($semantics -notmatch $semanticPattern) {
Add-Issue "JSON DELETE $logoutPath description is missing scope/idempotency semantics: $semanticPattern"
}
}
foreach ($status in @('200', '400', '401', '429', '500')) {
if (-not $operation.responses.PSObject.Properties[$status]) {
Add-Issue "JSON DELETE $logoutPath missing documented response: $status"
}
}
}
$successResponse = Get-Response $operation '200'
$terminalResponse = Get-Response $operation '401'
$successRef = Get-ResponseSchemaRef $operation '200'
$terminalRef = Get-ResponseSchemaRef $operation '401'
if ($successRef -ne '#/components/schemas/RVoid') {
Add-Issue "JSON DELETE $logoutPath 200 must return RVoid; actual: $successRef"
}
if ($terminalRef -ne '#/components/schemas/RLogoutRejected') {
Add-Issue "JSON DELETE $logoutPath 401 must return RLogoutRejected; actual: $terminalRef"
}
Assert-PrivateNoStore $successResponse '200'
Assert-PrivateNoStore $terminalResponse '401'
$void = Get-Schema 'RVoid'
$terminal = Get-Schema 'RLogoutRejected'
if ($void) {
if ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer') {
Add-Issue 'JSON RVoid must require integer code'
}
}
if ($terminal) {
foreach ($field in @('code', 'businessCode')) {
if ($field -notin @($terminal.required)) {
Add-Issue "JSON RLogoutRejected.required missing: $field"
}
}
$codes = @($terminal.properties.businessCode.enum | Sort-Object)
if ($terminal.properties.code.type -ne 'integer' -or
$terminal.properties.businessCode.type -ne 'string' -or
($codes -join ',') -ne 'TOKEN_CLIENT_MISMATCH,TOKEN_INVALID') {
Add-Issue 'JSON RLogoutRejected must expose only TOKEN_CLIENT_MISMATCH/TOKEN_INVALID rejection codes'
}
}
foreach ($yamlFact in @(
' /genealogy/app/auth/logout:',
' delete:',
' name: clientid',
'#/components/schemas/RVoid',
'#/components/schemas/RLogoutRejected',
' RVoid:',
' RLogoutRejected:',
' - TOKEN_CLIENT_MISMATCH',
' - TOKEN_INVALID',
' Cache-Control:'
)) {
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('LOGOUT-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Logout revokes only the presented current-device credential family; other device sessions remain valid.')
$lines.Add('- Runtime proof must show that a token cannot access a protected endpoint after 200, while a second-device token still can.')
$lines.Add('- The client starts DELETE with an in-memory token snapshot, immediately clears local session once, never restores it, and never persists a retry token.')
$lines.Add('- Active, already-revoked, and expired credentials issued for this client all converge to the same 200 RVoid; 401 is only TOKEN_INVALID/TOKEN_CLIENT_MISMATCH and is not success.')
$lines.Add('- Generic 401, network, timeout, malformed responses, and 5xx mean remote revocation is unconfirmed; only a valid 200 response confirms server-side termination.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'LOGOUT-OPENAPI-CONTRACT PASS'
+15 -5
View File
@@ -14,11 +14,15 @@ $required = @(
'a01-icon-lock-v1.png',
'notice.png',
'AppButton',
'/pages/profile/m02-edit-profile',
'/pages/notification/n01-message-center',
'/pages/profile/m03-security-settings',
'/pages/profile/m06-help-center',
'/pages/profile/m10-about-settings'
'import { openPage } from "@/utils/navigation.js";',
'routeKey: "M03"',
'routeKey: "M06"',
'routeKey: "M10"',
'routeKey: "M08"',
'routeKey: "M09"',
'openPage("M02", {}, "M01")',
'openPage("N01", {}, "M01")',
'openPage(item.routeKey, {}, "M01")'
)
foreach ($token in $required) {
@@ -27,6 +31,12 @@ foreach ($token in $required) {
}
}
foreach ($forbiddenNavigation in @('uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', 'getCurrentPages(', '<navigator', '/pages/')) {
if ($source.Contains($forbiddenNavigation)) {
throw "M01 must use the registered navigation gateway: $forbiddenNavigation"
}
}
foreach ($forbidden in @('m01-profile-notice-card.png', 'm01-profile-menu-frame.png', 'g03-create-flow-panel.png', 'application-status-card.png', 'g06-search-input-wide.png')) {
if ($source.Contains($forbidden)) {
throw "M01 must not reuse opaque business asset: $forbidden"
+6 -2
View File
@@ -1,8 +1,12 @@
$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" }
foreach ($routeKey in @('routeKey: "M08"', 'routeKey: "M09"')) {
if (-not $page.Contains($routeKey)) { throw "M01 service entry missing: $routeKey" }
}
if (-not $page.Contains('openPage(item.routeKey, {}, "M01")')) { throw 'M01 service entries must use the registered gateway with M01 as source' }
foreach ($forbidden in @('/pages/profile/', 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch')) {
if ($page.Contains($forbidden)) { throw "M01 service entry bypasses the navigation gateway: $forbidden" }
}
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'
+163
View File
@@ -0,0 +1,163 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const loadPageState = async (submitFeedback) => {
const source = fs.readFileSync(
path.join(__dirname, "../pages/profile/m07-feedback.vue"),
"utf8",
);
const scriptMatch = source.match(/<script setup>([\s\S]*?)<\/script>/);
if (!scriptMatch) throw new Error("M07 script setup is missing");
const script = scriptMatch[1].replace(/^import[\s\S]*?;\s*$/gm, "");
globalThis.__m07SubmitFeedback = submitFeedback;
const prelude = `
const computed = (getter) => ({ get value() { return getter(); } });
const reactive = (value) => value;
const ref = (value) => ({ value });
const nextTick = (callback) => Promise.resolve().then(callback);
let __watchCallback = null;
const watch = (_source, callback) => { __watchCallback = callback; };
const onBackPress = () => {};
const onUnload = () => {};
const appApi = { submitFeedback: (...args) => globalThis.__m07SubmitFeedback(...args) };
const createRequestController = () => ({ abort() {}, bind() {}, release() {} });
const isRequestCancelled = (error) => error?.code === "REQUEST_CANCELLED";
const createDiscardConfirmation = () => ({
request: () => Promise.resolve(true),
confirm() {},
cancel() {},
dispose() {},
});
const handleBackPress = () => true;
const runBackGuard = () => true;
`;
const exports = `
export const __state = {
feedbackForm,
feedbackState,
feedbackResult,
feedbackResultTone,
formSnapshot,
baseline,
isDirty,
submitDisabled,
submitFeedback,
triggerWatch: () => __watchCallback(formSnapshot.value),
};
`;
const module = await import(
toDataModuleUrl(`${prelude}\n${script}\n${exports}\n// ${Math.random()}`),
);
return module.__state;
};
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
const run = async () => {
let calls = 0;
let pending = deferred();
const successState = await loadPageState(() => {
calls += 1;
return pending.promise;
});
successState.feedbackForm.feedbackContent = "第一份反馈";
let request = successState.submitFeedback();
assert.strictEqual(successState.feedbackState.value, "submitting");
pending.resolve({ accepted: true });
await request;
assert.strictEqual(successState.feedbackState.value, "success");
assert.strictEqual(successState.submitDisabled.value, true);
assert.strictEqual(successState.isDirty.value, false);
await successState.submitFeedback();
assert.strictEqual(calls, 1, "成功快照不得原样重复提交");
successState.feedbackForm.feedbackContent = "编辑后的新反馈";
successState.triggerWatch();
assert.strictEqual(successState.feedbackState.value, "ready");
assert.strictEqual(successState.submitDisabled.value, false);
assert.strictEqual(successState.isDirty.value, true);
pending = deferred();
request = successState.submitFeedback();
successState.feedbackForm.feedbackContent = "请求期间迟到输入";
successState.triggerWatch();
pending.resolve({ accepted: true });
await request;
assert.strictEqual(successState.feedbackState.value, "ready");
assert.match(successState.feedbackResult.value, /上一份反馈已提交.*当前修改尚未提交/);
assert.strictEqual(successState.isDirty.value, true);
let uncertainCalls = 0;
const uncertainState = await loadPageState(async () => {
uncertainCalls += 1;
const error = new Error("timeout");
error.code = "REQUEST_TIMEOUT";
throw error;
});
uncertainState.feedbackForm.feedbackContent = "结果未知反馈";
await uncertainState.submitFeedback();
assert.strictEqual(uncertainState.feedbackState.value, "uncertain");
assert.strictEqual(uncertainState.submitDisabled.value, true);
await uncertainState.submitFeedback();
assert.strictEqual(uncertainCalls, 1, "未知结果快照不得原样重复提交");
uncertainState.feedbackForm.feedbackContent = "修改后的另一份反馈";
uncertainState.triggerWatch();
assert.strictEqual(uncertainState.feedbackState.value, "ready");
assert.strictEqual(uncertainState.submitDisabled.value, false);
const unexpectedSuccessState = await loadPageState(async () => {
const error = new Error("unexpected success status");
error.code = "HTTP_ERROR";
error.httpStatus = 202;
throw error;
});
unexpectedSuccessState.feedbackForm.feedbackContent = "服务返回意外成功状态";
await unexpectedSuccessState.submitFeedback();
assert.strictEqual(
unexpectedSuccessState.feedbackState.value,
"uncertain",
"非幂等 POST 收到意外 2xx 时不能开放原样重试",
);
assert.strictEqual(unexpectedSuccessState.submitDisabled.value, true);
const lateUncertainRequest = deferred();
const lateUncertainState = await loadPageState(() => lateUncertainRequest.promise);
lateUncertainState.feedbackForm.feedbackContent = "正在提交的反馈";
request = lateUncertainState.submitFeedback();
lateUncertainState.feedbackForm.feedbackContent = "请求期间修改的新反馈";
lateUncertainState.triggerWatch();
const timeoutError = new Error("timeout");
timeoutError.code = "REQUEST_TIMEOUT";
lateUncertainRequest.reject(timeoutError);
await request;
assert.strictEqual(
lateUncertainState.feedbackState.value,
"ready",
"旧快照结果未知时,请求期间的新输入必须保持可提交状态",
);
assert.match(lateUncertainState.feedbackResult.value, /结果仍待确认.*当前修改尚未提交/);
assert.strictEqual(lateUncertainState.submitDisabled.value, false);
delete globalThis.__m07SubmitFeedback;
process.stdout.write("M07-FEEDBACK-STATE-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
delete globalThis.__m07SubmitFeedback;
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+44
View File
@@ -0,0 +1,44 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/profile/m07-feedback.vue')
foreach ($required in @(
'@/utils/api.js',
'appApi.submitFeedback(',
'createRequestController',
'isRequestCancelled',
':aria-pressed=',
'role="group"',
'feedback-result--error',
'feedback-result--success',
'feedback-result--uncertain',
'feedbackState.value = "success"',
'feedbackState.value = "error"',
'feedbackState.value = "uncertain"',
'lastUncertainSnapshot',
'isFeedbackResultUncertain',
'formSnapshot.value === submittedSnapshot',
'submittedWithPendingEditsMessage',
':focus="feedbackContentFocused"',
'nextTick(',
"feedbackResultTone === 'error' ? 'assertive' : 'polite'",
'const submittedSnapshot = formSnapshot.value',
'baseline.value = submittedSnapshot',
'requestController.abort()',
'pageActive = false',
':disabled="feedbackState === ''submitting''"'
)) {
if (-not $page.Contains($required)) { throw "M07 contract is missing: $required" }
}
foreach ($retired in @('setTimeout(', 'errors.feedbackType')) {
if ($page.Contains($retired)) { throw "M07 retired path remains: $retired" }
}
if ($page -notmatch '(?s)const submitFeedback = async \(\) =>.*?feedbackState\.value = "submitting";.*?await appApi\.submitFeedback\(') {
throw 'M07 must use one locked async submit path and the API owner'
}
if ($page -notmatch '(?s)onUnload\(\(\) => \{.*?pageActive = false;.*?requestController\.abort\(\);') {
throw 'M07 unload must deactivate the page before aborting the request'
}
Write-Output 'M07-FEEDBACK-SUBMIT-CONTRACT PASS'
+1 -1
View File
@@ -30,7 +30,7 @@ Assert-Match -Content $pages.F01 -Pattern '<AppLoading\s+v-if="feedState === ''l
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\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.T03 -Pattern '(?s)const\s+memberState\s*=\s*ref\(["'']loading["'']\).*if\s*\(query\.state\s*===\s*["'']loading["'']\)\s*return;' -Message 'T03 must preserve its initial loading presentation state without redundant reassignment'
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'
@@ -24,10 +24,4 @@ foreach ($target in $targets) {
}
}
$modulePage = Read-Utf8 'components/ModulePage.vue'
if ($modulePage.Contains('class="page-intro"')) { throw 'ModulePage must remove repeated numbered intro blocks' }
foreach ($required in @('module-content-frame.png', 'module-field-frame.png', '<AppLoading')) {
if (-not $modulePage.Contains($required)) { throw "ModulePage baseline promotion missing: $required" }
}
Write-Output 'MODULE-BASELINE-PROMOTION-CONTRACT PASS'
@@ -1,21 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$component = [System.IO.File]::ReadAllText((Join-Path $root 'components/ModulePage.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'class="form-row"',
'class="settings-row"',
'grid-template-columns: auto minmax(0, 1fr)',
'grid-template-columns: minmax(0, 1fr) auto'
)) {
if (-not $component.Contains($expected)) { throw "ModulePage document-flow contract missing: $expected" }
}
foreach ($selector in @('.form-row > text','.form-row input','.settings-row > text')) {
$escaped = [regex]::Escape($selector)
if ($component -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "ModulePage normal content selector uses positioning: $selector"
}
}
Write-Output 'MODULE-PAGE-DOCUMENT-FLOW-CONTRACT PASS'
+13 -13
View File
@@ -1,13 +1,13 @@
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/family/f03-feed-detail?genealogyId=1001&feedId=1', '.feed-detail-page'], ['/pages/family/f04-article-list?genealogyId=1001', '.article-list-page'],
['/pages/family/f05-article-detail?genealogyId=1001&articleId=101', '.article-detail-page'], ['/pages/family/f07-album-list?genealogyId=1001', '.album-list-page'],
['/pages/records/r03-gift-list?genealogyId=1001', '.gift-page'], ['/pages/records/r04-gift-editor?genealogyId=1001&mode=create', '.gift-editor-page'],
['/pages/records/r05-ritual-list?genealogyId=1001', '.ritual-page'], ['/pages/records/r06-ritual-detail?genealogyId=1001&ceremonyId=501', '.ritual-detail-page'],
['/pages/records/r07-ritual-editor?genealogyId=1001&mode=create', '.ritual-editor-page'], ['/pages/records/r08-growth-journal?genealogyId=1001&personId=101', '.timeline-page'],
['/pages/records/r09-life-events?genealogyId=1001&personId=101', '.service-page'], ['/pages/records/r10-memo-list?genealogyId=1001', '.memo-page'],
['/pages/records/r11-merit-records?genealogyId=1001', '.merit-page'], ['/pages/notification/n02-message-detail?id=review-1', '.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'],
@@ -41,14 +41,14 @@ 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', '', '.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/family/f04-article-list', '?genealogyId=1001', '.article-list-page')
await open(send, '/pages/records/r04-gift-editor', '?genealogyId=1001&mode=create', '.gift-editor-page')
await open(send, '/pages/family/f03-feed-detail', '?genealogyId=1001&feedId=1', '.feed-detail-page')
await open(send, '/pages/records/r08-growth-journal', '?genealogyId=1001&personId=101', '.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/family/f04-article-list', '?genealogyId=1001&state=empty', '.article-list-state--empty')
await open(send, '/pages/records/r04-gift-editor', '?genealogyId=1001&mode=edit&relativeId=missing', '.relative-editor-state--invalid')
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 })
-68
View File
@@ -1,68 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$componentPath = Join-Path $root 'components/ModulePage.vue'
if (-not (Test-Path -LiteralPath $componentPath)) { throw 'Missing shared module page component' }
$component = Get-Content -LiteralPath $componentPath -Raw -Encoding utf8
foreach ($anchor in @(
'const moduleState = ref(',
'["loading", "empty", "success", "error"].includes(query.state)',
'module-state--${moduleState}',
'empty: {',
'success: {',
'error: {',
'module-content-frame.png',
'module-field-frame.png',
'<AppLoading',
'<AppButton',
'<AppToast',
'<ModulePageBackground',
':style="moduleAssetStyle"',
'@use "../styles/adaptive-frame-profiles.scss" as adaptive;',
'@include adaptive.adaptive-module-field;',
'@include adaptive.adaptive-module-content;',
'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" }
}
foreach ($forbidden in @(
'class="page-intro"',
'g03-create-flow-panel.png',
'g06-search-input-wide.png',
'application-status-card.png',
'background: var(--module-field-asset) center / 100% 100% no-repeat;',
'background: var(--module-content-asset) center / 100% 100% no-repeat;',
'height: 100%;'
)) {
if ($component -match [regex]::Escape($forbidden)) { throw "ModulePage must not keep obsolete shared surface: $forbidden" }
}
foreach ($className in @('module-panel', 'form-row', 'list-card', 'detail-card', 'timeline-row', 'settings-row', 'status-card')) {
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"
}
}
}
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% auto no-repeat;',
'(?s)\.module-lead\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;[^}]*justify-content:\s*center;',
'(?s)\.form-row,\s*\.settings-row\s*\{[^}]*@include\s+adaptive\.adaptive-module-field;',
'(?s)\.list-card,\s*\.detail-card,\s*\.timeline-row,\s*\.status-card\s*\{[^}]*@include\s+adaptive\.adaptive-module-content;'
)) {
if ($component -notmatch $rule) { throw "ModulePage document-flow background rule missing: $rule" }
}
Write-Output 'MODULE-PAGE-VISUAL-CONTRACT PASS'
+10 -10
View File
@@ -8,17 +8,17 @@ const sizes = [
{ width: 412, height: 915 }
]
const routes = [
['/pages/family/f01-family-feed', '.family-page'],
['/pages/family/f02-publish-feed', '.publish-page'],
['/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', '.feed-detail-page'],
['/pages/records/r08-growth-journal', '.timeline-page'],
['/pages/family/f01-family-feed?genealogyId=1001', '.family-page'],
['/pages/family/f02-publish-feed?genealogyId=1001', '.publish-page'],
['/pages/family/f04-article-list?genealogyId=1001', '.article-list-page'],
['/pages/family/f06-article-editor?genealogyId=1001&mode=create', '.article-editor-page'],
['/pages/family/f08-album-detail?genealogyId=1001&albumId=201', '.album-detail-page'],
['/pages/family/f09-media-upload?genealogyId=1001&albumId=201&state=selected', '.media-upload-page'],
['/pages/family/f03-feed-detail?genealogyId=1001&feedId=1', '.feed-detail-page'],
['/pages/records/r08-growth-journal?genealogyId=1001&personId=101', '.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/family/f10-video-list?genealogyId=1001', '.video-status-page'],
['/pages/records/r02-person-detail?genealogyId=1001&mode=view&personId=101', '.person-detail-page'],
['/pages/notification/n01-message-center', '.notice-page'],
['/pages/profile/m01-profile-home', '.profile-page']
]
+25 -2
View File
@@ -2,10 +2,33 @@ $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')) {
foreach ($token in @('listNotificationFixtures', 'unreadCount', 'openNotice', ':action="unreadCount > 0')) {
if (-not $list.Contains($token)) { throw "N01-N02 flow missing: $token" }
}
foreach ($token in @('noticeDetail', 'openNoticeTarget', 'markAsRead')) {
if ($list -notmatch '(?s)openPage\(\s*"N02",\s*\{ id: String\(item\.id\) \},\s*"N01",?\s*\)') {
throw 'N01 must open N02 through the registered gateway with only lexical id and N01 source'
}
foreach ($token in @('findNotificationFixture', 'noticeDetail.unread', 'markAsRead', 'getGenealogyFixtureAccess', 'openNoticeTarget as navigateNoticeTarget', 'noticeDetail.value.targetType', 'noticeDetail.value.targetParams')) {
if (-not $detail.Contains($token)) { throw "N02 business detail missing: $token" }
}
if ($detail -notmatch '(?s)const selectedNotice = findNotificationFixture\(noticeId\.value\);.*?if \(.*?!selectedNotice.*?\) \{.*?noticeState\.value = "expired";.*?return;') {
throw 'N02 unknown notice identity must fail closed'
}
if ($detail.Contains('noticeDetail.read') -or -not $detail.Contains('noticeDetail.value.unread = false;')) {
throw 'N02 must consume and update the notification owner unread field'
}
if (-not $detail.Contains('const backToMessages = () => returnTo("N01", {});')) {
throw 'N02 must return to N01 through the registered return gateway'
}
if (-not $detail.Contains('onBackPress((event) => handleBackPress(event, backToMessages));')) {
throw 'N02 Android back must reuse the guarded N01 return'
}
foreach ($source in @($list, $detail)) {
foreach ($forbidden in @('detailId', 'const noticeDetails', 'target: "/pages/', 'targetUrl', 'returnUrl', 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', '/pages/')) {
if ($source.Contains($forbidden)) { throw "N01-N02 retains a duplicate owner or navigation bypass: $forbidden" }
}
}
if ($detail -match 'noticeDetail\.value\.target(?![A-Za-z0-9_$])') {
throw 'N02 must not consume an executable notification target field'
}
Write-Output 'N01-N02-MESSAGE-FLOW-CONTRACT PASS'
+3 -3
View File
@@ -2,7 +2,6 @@ $ErrorActionPreference = 'Stop'
$activeFiles = @(
'pages/genealogy/g01-my-genealogies.vue',
'pages/genealogy/g03-create-genealogy.vue',
'pages/genealogy/g06-search-genealogies.vue'
)
@@ -19,11 +18,12 @@ foreach ($file in $activeFiles) {
}
$g01 = Get-Content 'pages/genealogy/g01-my-genealogies.vue' -Raw -Encoding UTF8
$g03 = Get-Content 'pages/genealogy/g03-create-genealogy.vue' -Raw -Encoding UTF8
$g06 = Get-Content 'pages/genealogy/g06-search-genealogies.vue' -Raw -Encoding UTF8
if ($g01 -notmatch 'query\?\.state') { throw 'G01 must normalize presentation state from onLoad query' }
if ($g03 -notmatch 'query\?\.step\s*===\s*"ancestor"') { throw 'G03 must whitelist the ancestor step from onLoad query' }
if ($g06 -notmatch 'query\?\.mode\s*===\s*"invite"') { throw 'G06 must whitelist invite mode from onLoad query' }
$g03 = Get-Content 'pages/genealogy/g03-create-genealogy.vue' -Raw -Encoding UTF8
if ($g03 -match 'onLoad\s*\(|query\?\.(?:step|genealogyId)|step=ancestor') { throw 'G03 has no route parameters; its ancestor transition must remain internal' }
Write-Output 'NATIVE-ROUTE-QUERY-CONTRACT PASS'
+678
View File
@@ -0,0 +1,678 @@
$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 }
}
function Assert-Matches {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
function Assert-Count {
param([string]$Content, [string]$Expected, [int]$Count, [string]$Message)
$actual = ([regex]::Matches($Content, [regex]::Escape($Expected))).Count
if ($actual -ne $Count) { throw "$Message,期望 $Count,实际 $actual" }
}
$root = Split-Path -Parent $PSScriptRoot
$a01 = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a01-entry.vue') -Raw -Encoding utf8
$a04 = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a04-register.vue') -Raw -Encoding utf8
$a05 = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a05-reset-password.vue') -Raw -Encoding utf8
$a06 = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a06-auth-status.vue') -Raw -Encoding utf8
$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
$g05 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding utf8
$g06 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g06-search-genealogies.vue') -Raw -Encoding utf8
$g08 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding utf8
$g09 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding utf8
$g10 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g10-application-review.vue') -Raw -Encoding utf8
$g11 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g11-genealogy-settings.vue') -Raw -Encoding utf8
$g12 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g12-generation-poems.vue') -Raw -Encoding utf8
$t01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
$t03 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding utf8
$t04 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding utf8
$t05 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding utf8
$t06 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding utf8
$t07 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t07-member-directory.vue') -Raw -Encoding utf8
$t08 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t08-member-states.vue') -Raw -Encoding utf8
$f01 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f01-family-feed.vue') -Raw -Encoding utf8
$f02 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f02-publish-feed.vue') -Raw -Encoding utf8
$f03 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f03-feed-detail.vue') -Raw -Encoding utf8
$f04 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f04-article-list.vue') -Raw -Encoding utf8
$f05 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f05-article-detail.vue') -Raw -Encoding utf8
$f06 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding utf8
$f07 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f07-album-list.vue') -Raw -Encoding utf8
$f08 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f08-album-detail.vue') -Raw -Encoding utf8
$f09 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f09-media-upload.vue') -Raw -Encoding utf8
$f10 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f10-video-list.vue') -Raw -Encoding utf8
$r01 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r01-people-list.vue') -Raw -Encoding utf8
$r02 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r02-person-detail.vue') -Raw -Encoding utf8
$r03 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r03-gift-list.vue') -Raw -Encoding utf8
$r04 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r04-gift-editor.vue') -Raw -Encoding utf8
$r05 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r05-ritual-list.vue') -Raw -Encoding utf8
$r06 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r06-ritual-detail.vue') -Raw -Encoding utf8
$r07 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r07-ritual-editor.vue') -Raw -Encoding utf8
$r08 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r08-growth-journal.vue') -Raw -Encoding utf8
$r09 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r09-life-events.vue') -Raw -Encoding utf8
$r10 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r10-memo-list.vue') -Raw -Encoding utf8
$r11 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r11-merit-records.vue') -Raw -Encoding utf8
$n01 = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n01-message-center.vue') -Raw -Encoding utf8
$n02 = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n02-message-detail.vue') -Raw -Encoding utf8
$m01 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m01-profile-home.vue') -Raw -Encoding utf8
$m02 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m02-edit-profile.vue') -Raw -Encoding utf8
$m03 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m03-security-settings.vue') -Raw -Encoding utf8
$m04 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m04-change-password.vue') -Raw -Encoding utf8
$m05 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m05-change-phone.vue') -Raw -Encoding utf8
$m06 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m06-help-center.vue') -Raw -Encoding utf8
$m07 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m07-feedback.vue') -Raw -Encoding utf8
$m08 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m08-promotion.vue') -Raw -Encoding utf8
$m09 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m09-vip-orders.vue') -Raw -Encoding utf8
$m10 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m10-about-settings.vue') -Raw -Encoding utf8
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding utf8
$navigation = Get-Content -LiteralPath (Join-Path $root 'utils/navigation.js') -Raw -Encoding utf8
$routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding utf8
foreach ($page in @(
@{ Key = 'A01'; Content = $a01 },
@{ Key = 'A04'; Content = $a04 },
@{ Key = 'A05'; Content = $a05 },
@{ Key = 'A06'; Content = $a06 }
)) {
foreach ($forbidden in @(
'uni.navigateTo(',
'uni.navigateBack(',
'uni.redirectTo(',
'uni.reLaunch(',
'getCurrentPages(',
'<navigator',
'/pages/'
)) {
Assert-NotContains -Content $page.Content -Unexpected $forbidden -Message "$($page.Key) 不得绕过导航网关:$forbidden"
}
Assert-Matches -Content $page.Content -Pattern 'onBackPress\(\(event\)\s*=>' -Message "$($page.Key) 必须接管 Android 返回入口"
Assert-Contains -Content $page.Content -Expected 'handleBackPress(event, requestBack)' -Message "$($page.Key) 必须通过同步适配器处理 Android 返回"
}
foreach ($page in @(
@{ Key = 'A04'; Content = $a04 },
@{ Key = 'A05'; Content = $a05 },
@{ Key = 'A06'; Content = $a06 }
)) {
Assert-Contains -Content $page.Content -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message "$($page.Key) 必须把同步布尔结果原样返回平台"
}
Assert-Contains -Content $a01 -Expected 'openPage("A05", {}, "A01")' -Message 'A01 忘记密码必须声明 A05 与 A01 来源'
Assert-Contains -Content $a01 -Expected 'openPage("A04", {}, "A01")' -Message 'A01 注册入口必须声明 A04 与 A01 来源'
Assert-Matches -Content $a01 -Pattern '(?s)const requestBack = \(\) =>\s*runBackGuard\(\{\s*transientOpen: tacVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*"close-transient": closeTac,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return true;\s*\},\s*\}\);' -Message 'A01 返回守卫必须先关闭真实 TAC,并允许显式取消认证请求'
Assert-Matches -Content $a01 -Pattern '(?s)onBackPress\(\(event\) => \{\s*if \(!tacVisible\.value && \(submitting\.value \|\| sendingCode\.value\)\) \{\s*cancelPendingRequest\(\);\s*return false;\s*\}\s*if \(!tacVisible\.value\) return false;\s*return handleBackPress\(event, requestBack\);\s*\}\);' -Message 'A01 Android 返回必须先取消认证请求,再把根页退出交还平台'
Assert-Contains -Content $a04 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'A04 必须使用项目对话框确认放弃表单'
Assert-Contains -Content $a04 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'A04 必须消费唯一放弃确认控制器'
Assert-Contains -Content $a04 -Expected 'const isDirty = computed(() =>' -Message 'A04 必须计算未保存表单状态'
Assert-Matches -Content $a04 -Pattern '(?s)const requestBack = \(\) =>\s*runBackGuard\(\{\s*transientOpen: tacVisible\.value \|\| discardVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*dirty: isDirty\.value,\s*"close-transient": tacVisible\.value \? closeTac : cancelDiscard,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return requestBack\(\);\s*\},\s*"confirm-discard": requestDiscardConfirmation,\s*\}\);' -Message 'A04 requestBack 必须优先关闭 TAC;请求中返回先中止网络,再恢复脏表单守卫'
Assert-Contains -Content $a04 -Expected 'discardConfirmation.dispose();' -Message 'A04 卸载时必须通过唯一控制器释放等待者'
Assert-Count -Content $a04 -Expected '@click="requestBack"' -Count 2 -Message 'A04 页头和已有账号入口必须共用 requestBack'
Assert-Count -Content $a04 -Expected '<AppDialog' -Count 1 -Message 'A04 只允许一个放弃确认框'
Assert-Contains -Content $a05 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'A05 必须使用项目对话框确认放弃表单'
Assert-Contains -Content $a05 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'A05 必须消费唯一放弃确认控制器'
Assert-Contains -Content $a05 -Expected 'const isDirty = computed(() =>' -Message 'A05 必须计算未保存表单状态'
Assert-Matches -Content $a05 -Pattern '(?s)const requestBack = \(\) => \{\s*if \(successVisible\.value\) return leaveResetSuccess\(\);\s*return runBackGuard\(\{\s*transientOpen: tacVisible\.value \|\| discardVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*dirty: isDirty\.value,\s*"close-transient": tacVisible\.value \? closeTac : cancelDiscard,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return requestBack\(\);\s*\},\s*"confirm-discard": requestDiscardConfirmation,\s*\}\);\s*\};' -Message 'A05 requestBack 必须先处理成功终态;请求中返回先中止网络,再恢复放弃守卫'
Assert-Contains -Content $a05 -Expected 'discardConfirmation.dispose();' -Message 'A05 卸载时必须通过唯一控制器释放等待者'
Assert-Count -Content $a05 -Expected '@click="requestBack"' -Count 2 -Message 'A05 页头和想起密码入口必须共用 requestBack'
Assert-Count -Content $a05 -Expected '<AppDialog' -Count 2 -Message 'A05 必须分别复用一个放弃确认框和一个成功终态框'
Assert-Contains -Content $a05 -Expected 'const leaveResetSuccess = () => returnTo("A01", {});' -Message 'A05 远端成功终态必须无结果返回 A01'
Assert-Contains -Content $a05 -Expected 'if (successVisible.value) return leaveResetSuccess();' -Message 'A05 Android 返回必须从成功终态退出,不能重新暴露已提交表单'
Assert-Contains -Content $a05 -Expected '@confirm="leaveResetSuccess"' -Message 'A05 成功对话框 CTA 必须复用唯一成功终态出口'
Assert-Count -Content $a05 -Expected 'returnTo("A01", {})' -Count 1 -Message 'A05 无结果成功终点只能定义一次'
Assert-NotContains -Content $a05 -Unexpected 'finishPage(' -Message 'A05 重设成功不应发出跨页业务结果'
Assert-NotContains -Content $a05 -Unexpected 'password-reset' -Message 'A05 不得伪造 password-reset 导航结果'
foreach ($form in @(
@{ Key = 'A04'; Content = $a04; NonMaskDialogCount = 1 },
@{ Key = 'A05'; Content = $a05; NonMaskDialogCount = 2 }
)) {
Assert-Matches -Content $form.Content -Pattern '(?s)const discardConfirmation = createDiscardConfirmation\(\(visible\) => \{\s*discardVisible\.value = visible;\s*\}\);' -Message "$($form.Key) 必须把对话框可见状态交给唯一控制器"
foreach ($binding in @(
'const requestDiscardConfirmation = discardConfirmation.request;',
'const confirmDiscard = discardConfirmation.confirm;',
'const cancelDiscard = discardConfirmation.cancel;'
)) {
Assert-Contains -Content $form.Content -Expected $binding -Message "$($form.Key) 缺少唯一控制器绑定:$binding"
}
foreach ($duplicateOwner in @('let discardPromise', 'resolveDiscardConfirmation', 'settleDiscardConfirmation')) {
Assert-NotContains -Content $form.Content -Unexpected $duplicateOwner -Message "$($form.Key) 不得保留页面级放弃确认 owner$duplicateOwner"
}
Assert-Count -Content $form.Content -Expected ':close-on-mask="false"' -Count $form.NonMaskDialogCount -Message "$($form.Key) 的确认与成功终态框不得被遮罩点击隐式关闭"
Assert-Count -Content $form.Content -Expected '@confirm="confirmDiscard"' -Count 1 -Message "$($form.Key) 放弃确认只能有一个确认入口"
Assert-Count -Content $form.Content -Expected '@cancel="cancelDiscard"' -Count 1 -Message "$($form.Key) 放弃确认只能有一个取消入口"
}
Assert-Contains -Content $a06 -Expected 'const resolveStatus = (options = {}) =>' -Message 'A06 必须直接消费 onLoad 页面参数'
Assert-Contains -Content $a06 -Expected 'options.status || "risk"' -Message 'A06 必须从 onLoad 参数解析状态并安全回退'
Assert-NotContains -Content $a06 -Unexpected 'URLSearchParams' -Message 'A06 不得保留浏览器地址解析旁路'
Assert-NotContains -Content $a06 -Unexpected 'onShow(' -Message 'A06 不得在 onShow 重读已丢失的路由参数'
Assert-Matches -Content $a06 -Pattern '(?s)const requestBack = \(\) => \{\s*if \(recoveryVisible\.value\) \{\s*return runBackGuard\(\{\s*transientOpen: true,\s*"close-transient": closeRecovery,\s*\}\);\s*\}\s*return goRoot\("A01"\);\s*\};' -Message 'A06 必须先通过守卫关闭恢复浮层,否则回到唯一认证根页'
Assert-Count -Content $a06 -Expected '@click="requestBack"' -Count 2 -Message 'A06 页头和返回登录入口必须共用 requestBack'
foreach ($page in @(
@{ Key = 'G01'; Content = $g01 },
@{ Key = 'G03'; Content = $g03 },
@{ Key = 'G05'; Content = $g05 },
@{ Key = 'G06'; Content = $g06 },
@{ Key = 'G08'; Content = $g08 },
@{ Key = 'G09'; Content = $g09 },
@{ Key = 'G10'; Content = $g10 },
@{ Key = 'G11'; Content = $g11 },
@{ Key = 'G12'; Content = $g12 }
)) {
foreach ($forbidden in @(
'uni.navigateTo(',
'uni.navigateBack(',
'uni.redirectTo(',
'uni.reLaunch(',
'getCurrentPages(',
'<navigator',
'/pages/'
)) {
Assert-NotContains -Content $page.Content -Unexpected $forbidden -Message "$($page.Key) 不得绕过导航网关:$forbidden"
}
}
Assert-Contains -Content $g01 -Expected 'import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";' -Message 'G01 必须只消费导航网关与根页返回适配器'
Assert-NotContains -Content $g01 -Unexpected 'Number(query?.genealogyId)' -Message 'G01 不得把可能为 int64 的家谱 ID 转成 Number'
Assert-Contains -Content $g01 -Expected 'requestedGenealogyId.value = String(query?.genealogyId || "");' -Message 'G01 路由家谱 ID 必须保持词法字符串'
Assert-Matches -Content $g01 -Pattern '(?s)const openGenealogy = \(genealogy\) =>\s*openPage\("G05", \{ genealogyId: String\(genealogy\.id\) \}, "G01"\)\.then\(\s*\(opened\) => \{\s*if \(opened\) \{\s*selectedGenealogyId\.value = String\(genealogy\.id\);\s*genealogyContext\.setCurrentGenealogyId\(genealogy\.id\);\s*\}\s*return opened;\s*\},\s*\);' -Message 'G01 只有导航成功后才能同步页面与持久 context,避免并发点击串谱'
Assert-Matches -Content $g01 -Pattern '(?s)const openShortcut = \(key\) => \{(?:(?!setCurrentGenealogyId).)*const actions =' -Message 'G01 快捷入口不得在导航结果确定前改写当前家谱 context'
Assert-Contains -Content $g01 -Expected 'openPage("G03", {}, "G01")' -Message 'G01 创建入口必须进入 G03'
Assert-Contains -Content $g01 -Expected 'openPage("G06", {}, "G01")' -Message 'G01 搜索入口必须进入 G06'
Assert-Contains -Content $g01 -Expected 'openPage("G06", { mode: "invite" }, "G01")' -Message 'G01 邀请码入口必须进入 G06 邀请模式'
Assert-Contains -Content $g01 -Expected 'openPage("N01", notificationParams, "G01")' -Message 'G01 通知入口必须声明 N01 与当前家谱上下文'
foreach ($mapping in @(
'tree: () => openPage("T01", { genealogyId }, "G01")',
'members: () => openPage("G05", { genealogyId }, "G01")',
'poem: () => openPage("G12", { genealogyId }, "G01")',
'applications: () => openPage("G10", { genealogyId }, "G01")'
)) {
Assert-Contains -Content $g01 -Expected $mapping -Message "G01 快捷入口缺少规范映射:$mapping"
}
Assert-Contains -Content $g01 -Expected 'openPage("G09", { status: "pending" }, "G01")' -Message 'G01 审核中记录必须进入 G09'
Assert-Contains -Content $g01 -Expected 'openPage(' -Message 'G01 必须通过导航网关进入申请流程'
Assert-Matches -Content $g01 -Pattern '(?s)return openPage\(\s*"G08",\s*\{\s*genealogyId: String\(record\.genealogyId\),\s*source: "search",\s*\},\s*"G01",\s*\);' -Message 'G01 被拒绝或已退出记录必须携带字符串家谱 ID 进入 G08'
Assert-Matches -Content $g01 -Pattern '(?s)const createGenealogy = \(\) => \{\s*closeAddDialog\(\);\s*return openPage\("G03", \{\}, "G01"\);\s*\};' -Message 'G01 添加面板创建入口必须先关闭面板再跳转'
Assert-Matches -Content $g01 -Pattern '(?s)const requestBack = \(\) =>\s*runBackGuard\(\{\s*transientOpen: switcherVisible\.value \|\| addDialogVisible\.value,\s*"close-transient": closeActiveOverlay,\s*\}\);' -Message 'G01 返回守卫必须只关闭当前浮层'
Assert-Matches -Content $g01 -Pattern '(?s)onBackPress\(\(event\) => \{\s*if \(!switcherVisible\.value && !addDialogVisible\.value\) return false;\s*return handleBackPress\(event, requestBack\);\s*\}\);' -Message 'G01 无浮层时必须把根页 Android 返回交还平台'
Assert-Contains -Content $g03 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'G03 脏表单必须使用项目对话框'
Assert-Contains -Content $g03 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'G03 必须消费唯一放弃确认控制器'
Assert-Contains -Content $g03 -Expected 'import { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation.js";' -Message 'G03 必须只消费导航网关和返回守卫'
Assert-Contains -Content $g03 -Expected '@back="requestBack"' -Message 'G03 页头必须复用统一返回守卫'
Assert-Contains -Content $g03 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G03 Android 返回必须使用同步适配器'
Assert-Contains -Content $g03 -Expected 'const isDirty = computed(() =>' -Message 'G03 必须识别未保存表单'
Assert-Contains -Content $g03 -Expected 'internalTrail: isAncestorStep.value' -Message 'G03 首代步骤必须作为同页内部轨迹返回'
Assert-Contains -Content $g03 -Expected 'submitting: isSubmitting.value' -Message 'G03 提交期间必须阻止离开'
Assert-Contains -Content $g03 -Expected 'transientOpen: duplicateReminderVisible.value || discardVisible.value' -Message 'G03 返回必须先处理重复提醒或放弃确认层'
Assert-Contains -Content $g03 -Expected 'currentStep.value = "ancestor";' -Message 'G03 创建预览成功后必须在同页进入首代步骤'
Assert-Matches -Content $g03 -Pattern '(?s)const confirmCreate = \(\) => \{\s*if \(isSubmitting\.value\) return;\s*closeDuplicateReminder\(\);' -Message 'G03 重复确认必须在创建定时器产生前被原子阻止'
Assert-Contains -Content $g03 -Expected 'const createSnapshot = Object.freeze({ ...createForm });' -Message 'G03 创建校验通过后必须快照本次提交值'
Assert-Contains -Content $g03 -Expected 'const ancestorSnapshot = Object.freeze({ ...ancestorForm });' -Message 'G03 首代校验通过后必须快照本次提交值'
Assert-Contains -Content $g03 -Expected 'if (submitTimer !== timer) return;' -Message 'G03 定时器回调必须校验自己仍是当前提交'
Assert-Contains -Content $g03 -Expected 'openPage("G06", {}, "G03")' -Message 'G03 搜索已有家谱必须通过网关'
Assert-Contains -Content $g03 -Expected 'const enterOverview = () =>' -Message 'G03 必须定义唯一成功终态出口'
Assert-Contains -Content $g03 -Expected 'returnTo("G05", { genealogyId: genealogyId.value });' -Message 'G03 本地成功终态必须无结果进入 G05'
Assert-Count -Content $g03 -Expected 'returnTo("G05", { genealogyId: genealogyId.value })' -Count 1 -Message 'G03 无结果成功终点只能定义一次'
Assert-Contains -Content $g03 -Expected 'discardConfirmation.dispose();' -Message 'G03 卸载时必须释放放弃确认等待者'
foreach ($forbidden in @('query?.step', 'step=ancestor', 'finishPage(', 'genealogy-created', 'application-created')) {
Assert-NotContains -Content $g03 -Unexpected $forbidden -Message "G03 本地预览不得保留旧流程或伪造后端成功:$forbidden"
}
Assert-NotContains -Content $g03 -Unexpected 'onLoad(' -Message 'G03 没有路由业务参数,不得保留无语义的 onLoad 兼容壳'
Assert-Contains -Content $g05 -Expected 'import { findGenealogyFixture, getGenealogyFixtureAccess } from "@/data/mock.js";' -Message 'G05 必须消费唯一家谱夹具与访问判定所有者'
Assert-Matches -Content $g05 -Pattern '(?s)import \{\s*goBack,\s*goRoot,\s*handleBackPress,\s*openPage,\s*returnTo,\s*\} from "@/utils/navigation\.js";' -Message 'G05 必须只消费导航网关与 Android 返回适配器'
Assert-Contains -Content $g05 -Expected '@back="requestBack"' -Message 'G05 页头必须使用统一返回入口'
Assert-Contains -Content $g05 -Expected 'const requestBack = () => goBack();' -Message 'G05 返回必须保留真实栈并支持深链父页回退'
Assert-Contains -Content $g05 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G05 Android 返回必须使用同步适配器'
Assert-Contains -Content $g05 -Expected 'const accessRole = ref("guest");' -Message 'G05 未验证访问默认必须是最低权限'
Assert-Contains -Content $g05 -Expected 'const fixture = findGenealogyFixture(genealogyId.value);' -Message 'G05 身份和内容必须来自共享家谱夹具'
Assert-Contains -Content $g05 -Expected 'const access = getGenealogyFixtureAccess(genealogyId.value);' -Message 'G05 显示角色必须来自共享夹具解析器'
Assert-Contains -Content $g05 -Expected 'publicRelation.value = access.relation;' -Message 'G05 公开操作必须消费共享关系合同'
Assert-NotContains -Content $g05 -Unexpected 'fixture.membership' -Message 'G05 不得用可变展示夹具直接判权'
Assert-Contains -Content $g05 -Expected 'viewMode === "preview" ? "本地流程预览" : "公开家谱"' -Message 'G05 本地创建 ID 必须进入不含成员权限的预览态'
Assert-Contains -Content $g05 -Expected "v-if=`"viewMode === 'public' && publicActionLabel`"" -Message 'G05 只有具备关系动作的公开家谱可显示入口,本地预览不得申请自己'
Assert-Contains -Content $g05 -Expected 'overviewState.value = "no-permission";' -Message 'G05 未知家谱必须明确拒绝访问'
Assert-Contains -Content $g05 -Expected 'returnTo("G01", {})' -Message 'G05 状态页显式按钮必须返回家谱根页'
foreach ($mapping in @(
'openPage("T01", { genealogyId: genealogyId.value }, "G05")',
'goRoot("F01", { genealogyId: genealogyId.value })',
'openPage("G10", { genealogyId: genealogyId.value }, "G05")',
'openPage("G11", { genealogyId: genealogyId.value }, "G05")',
'openPage("G12", { genealogyId: genealogyId.value }, "G05")'
)) {
Assert-Contains -Content $g05 -Expected $mapping -Message "G05 缺少规范入口:$mapping"
}
Assert-Matches -Content $g05 -Pattern '(?s)openPage\(\s*"G08",\s*\{ genealogyId: genealogyId\.value, source: "search" \},\s*"G05",\s*\)' -Message 'G05 公开申请必须通过 G08 规范入口'
Assert-Contains -Content $g05 -Expected 'openPage("G09", { status: "pending" }, "G05")' -Message 'G05 审核中关系必须进入 G09 而不是重复申请'
foreach ($forbidden in @('query.mode', 'query.role', 'query.genealogyName', 'toFirstPerson', 'finishPage(', 'genealogy-created')) {
Assert-NotContains -Content $g05 -Unexpected $forbidden -Message "G05 不得信任查询权限或保留重复流程:$forbidden"
}
Assert-Matches -Content $routes -Pattern '(?s)G08:\s*defineRoute\(\{.*?allowedSources:\s*\["G01", "G05", "G06", "G09"\]' -Message 'G08 必须允许 G01 的被拒绝与已退出申请进入'
Assert-Contains -Content $g06 -Expected 'import { goRoot, openPage } from "@/utils/navigation.js";' -Message 'G06 必须只消费导航网关'
Assert-Matches -Content $g06 -Pattern '(?s)openPage\(\s*"G05",\s*\{ genealogyId: String\(item\.id\) \},\s*"G06",\s*\)' -Message 'G06 公开预览必须只携带字符串家谱 ID 进入 G05'
Assert-Matches -Content $g06 -Pattern '(?s)openPage\(\s*"G08",\s*\{ genealogyId: String\(item\.id\), source: "search" \},\s*"G06",\s*\)' -Message 'G06 搜索申请必须进入 G08'
Assert-Contains -Content $g06 -Expected 'openPage("G09", { status: "pending" }, "G06")' -Message 'G06 审核中结果必须进入 G09'
Assert-Matches -Content $g06 -Pattern '(?s)goRoot\("G01", \{ genealogyId: String\(item\.id\) \}\)' -Message 'G06 已加入或自建家谱必须回到 G01 根语义'
Assert-Matches -Content $g06 -Pattern '(?s)openPage\(\s*"G08",\s*\{\s*genealogyId: String\(inviteTarget\.value\.id\),\s*source: "invite",\s*\},\s*"G06",\s*\)' -Message 'G06 邀请码预览必须进入 G08,不能本地直接入谱'
foreach ($forbidden in @('genealogyName:', 'previous:', 'mode: "public"', 'finishPage(', 'application-created')) {
Assert-NotContains -Content $g06 -Unexpected $forbidden -Message "G06 不得传播未声明业务状态或伪成功:$forbidden"
}
Assert-Contains -Content $g08 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'G08 脏表单必须使用项目对话框'
Assert-Matches -Content $g08 -Pattern '(?s)import \{\s*findGenealogyFixture,\s*getGenealogyFixtureAccess,\s*\} from "@/data/mock\.js";' -Message 'G08 名称与申请资格必须来自共享夹具 owner'
Assert-Contains -Content $g08 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'G08 必须消费唯一放弃确认控制器'
foreach ($navigationSymbol in @('goBack', 'goRoot', 'handleBackPress', 'openPage', 'returnTo', 'runBackGuard')) {
Assert-Contains -Content $g08 -Expected $navigationSymbol -Message "G08 缺少统一导航语义:$navigationSymbol"
}
Assert-Contains -Content $g08 -Expected '<PageHeader :title="sourceContract.headerTitle" custom-back @back="requestBack" />' -Message 'G08 页头必须复用统一返回守卫'
Assert-Contains -Content $g08 -Expected 'const genealogyId = ref("");' -Message 'G08 必须持有词法家谱 ID'
Assert-Contains -Content $g08 -Expected 'genealogyId.value = String(query.genealogyId || "");' -Message 'G08 路由家谱 ID 必须保持字符串'
Assert-Contains -Content $g08 -Expected 'const fixture = findGenealogyFixture(genealogyId.value);' -Message 'G08 必须从共享 owner 读取家谱名称'
Assert-Contains -Content $g08 -Expected 'const access = getGenealogyFixtureAccess(genealogyId.value);' -Message 'G08 必须从共享 owner 读取申请资格'
Assert-Contains -Content $g08 -Expected 'if (!fixture || !access.canApply)' -Message 'G08 未知、已加入、所有者与审核中关系必须失败关闭'
Assert-Contains -Content $g08 -Expected 'const isDirty = computed(() =>' -Message 'G08 必须识别未保存表单'
Assert-Contains -Content $g08 -Expected 'submitting: isSubmitting.value' -Message 'G08 提交期间必须阻止离开'
Assert-Contains -Content $g08 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G08 Android 返回必须使用同步适配器'
Assert-Contains -Content $g08 -Expected 'discardConfirmation.dispose();' -Message 'G08 卸载必须释放确认等待者'
Assert-Matches -Content $g08 -Pattern '(?s)onUnload\(\(\) => \{\s*const timer = submitTimer;\s*submitTimer = null;\s*if \(timer\) clearTimeout\(timer\);\s*discardConfirmation\.dispose\(\);\s*\}\);' -Message 'G08 卸载必须先作废再清理本地预览定时器与确认等待者'
Assert-Contains -Content $g08 -Expected 'const submitSnapshot = Object.freeze({ ...form });' -Message 'G08 本地校验必须快照本次提交值'
Assert-Contains -Content $g08 -Expected 'if (submitTimer !== timer) return;' -Message 'G08 定时器回调必须校验自己仍是当前提交'
Assert-Contains -Content $g08 -Expected 'returnTo("G09", {})' -Message 'G08 搜索申请本地成功只能无结果进入 G09'
Assert-Contains -Content $g08 -Expected 'returnTo("G01", {})' -Message 'G08 邀请码本地成功不得选中新家谱'
Assert-Contains -Content $g08 -Expected '本地流程预览' -Message 'G08 必须明确本地成功尚未提交服务器'
foreach ($forbidden in @('query.previous', 'query.genealogyName', 'previousRelation', 'decodeURIComponent', 'genealogyPreview', 'finishPage(', 'application-created', '已直接加入', '无需等待审核')) {
Assert-NotContains -Content $g08 -Unexpected $forbidden -Message "G08 不得消费旧查询字段或伪造后端成功:$forbidden"
}
Assert-Contains -Content $g09 -Expected 'import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";' -Message 'G09 必须只消费导航网关与浮层返回守卫'
Assert-Contains -Content $g09 -Expected '<PageHeader title="我的申请" custom-back @back="requestBack" />' -Message 'G09 页头必须复用浮层返回守卫'
Assert-Contains -Content $g09 -Expected ':close-on-mask="false"' -Message 'G09 撤回确认不得由遮罩隐式关闭'
Assert-Count -Content $g09 -Expected '@cancel="cancelWithdraw"' -Count 1 -Message 'G09 撤回取消只能有一个入口'
Assert-NotContains -Content $g09 -Unexpected '@close="cancelWithdraw"' -Message 'G09 不得由 cancel 与 close 重复处理同一次关闭'
Assert-Contains -Content $g09 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G09 Android 返回必须先关闭撤回确认层'
Assert-Contains -Content $g09 -Expected 'LOCAL_WITHDRAWN' -Message 'G09 本地撤回必须使用非领域预览状态'
Assert-Contains -Content $g09 -Expected '尚未提交服务器' -Message 'G09 本地撤回必须明确不改变服务器申请'
Assert-NotContains -Content $g09 -Unexpected 'target.status = "WITHDRAWN"' -Message 'G09 不得把本地预览写成真实撤回状态'
Assert-Matches -Content $g09 -Pattern '(?s)openPage\(\s*"G05",\s*\{ genealogyId: String\(item\.genealogyId\) \},\s*"G09",\s*\)' -Message 'G09 已通过申请必须进入可信家谱总览'
Assert-Matches -Content $g09 -Pattern '(?s)openPage\(\s*"G08",\s*\{ genealogyId: String\(item\.genealogyId\), source: "search" \},\s*"G09",\s*\)' -Message 'G09 重新申请必须只携带注册字段进入 G08'
Assert-Contains -Content $g09 -Expected 'openPage("G06", {}, "G09")' -Message 'G09 搜索入口必须进入 G06'
foreach ($forbidden in @('previous:', 'finishPage(', 'application-created')) {
Assert-NotContains -Content $g09 -Unexpected $forbidden -Message "G09 本地状态不得传播旧字段或结果:$forbidden"
}
Assert-Contains -Content $g10 -Expected 'import { getGenealogyFixtureAccess } from "@/data/mock.js";' -Message 'G10 审核权限必须来自共享夹具解析器'
Assert-Contains -Content $g10 -Expected 'import { handleBackPress, runBackGuard } from "@/utils/navigation.js";' -Message 'G10 必须使用统一浮层返回守卫'
Assert-Contains -Content $g10 -Expected 'genealogyId.value = String(query.genealogyId || "");' -Message 'G10 家谱 ID 必须保持词法字符串'
Assert-Contains -Content $g10 -Expected 'getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"' -Message 'G10 非 owner 必须失败关闭'
Assert-Contains -Content $g10 -Expected '@back="requestBack"' -Message 'G10 页头必须复用返回守卫'
Assert-Contains -Content $g10 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G10 Android 返回必须先关闭审核或说明弹层'
Assert-Contains -Content $g10 -Expected ':close-on-mask="false"' -Message 'G10 审核弹层不得由遮罩隐式关闭'
Assert-Count -Content $g10 -Expected '@cancel="closeDialog"' -Count 1 -Message 'G10 弹层取消只能有一个入口'
Assert-NotContains -Content $g10 -Unexpected '@close="closeDialog"' -Message 'G10 不得重复处理 cancel 与 close'
Assert-Contains -Content $g10 -Expected '尚未提交服务器' -Message 'G10 本地审核必须明确未提交服务器'
foreach ($forbidden in @('query.genealogyId?.value', 'genealogyContext', 'finishPage(', 'application-reviewed')) {
Assert-NotContains -Content $g10 -Unexpected $forbidden -Message "G10 不得保留兼容读取、上下文旁路或本地结果:$forbidden"
}
Assert-Contains -Content $g11 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'G11 脏表单必须使用项目对话框'
Assert-Matches -Content $g11 -Pattern '(?s)import \{\s*findGenealogyFixture,\s*getGenealogyFixtureAccess,\s*\} from "@/data/mock\.js";' -Message 'G11 数据和显示角色必须来自共享夹具 owner'
Assert-Contains -Content $g11 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'G11 必须消费唯一放弃确认控制器'
Assert-Contains -Content $g11 -Expected 'import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";' -Message 'G11 必须使用统一导航守卫'
Assert-Contains -Content $g11 -Expected 'genealogyId.value = String(query.genealogyId || "");' -Message 'G11 家谱 ID 必须保持词法字符串'
Assert-Contains -Content $g11 -Expected 'getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"' -Message 'G11 非 owner 必须失败关闭'
Assert-Contains -Content $g11 -Expected 'const isDirty = computed(' -Message 'G11 必须识别未保存表单'
Assert-Contains -Content $g11 -Expected '@back="requestBack"' -Message 'G11 页头必须复用返回守卫'
Assert-Contains -Content $g11 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G11 Android 返回必须使用同步适配器'
Assert-Contains -Content $g11 -Expected 'discardConfirmation.dispose();' -Message 'G11 卸载必须释放确认等待者'
Assert-Contains -Content $g11 -Expected '本地预览已更新,尚未提交服务器' -Message 'G11 本地保存必须明确未提交服务器'
Assert-Contains -Content $g11 -Expected 'genealogyDraft.name.trim().length > 20' -Message 'G11 名称运行时上限必须与输入控件 20 字约束一致'
Assert-Contains -Content $g11 -Expected '@click="handleResultAction"' -Message 'G11 错误与无权限状态必须通过受控动作处理'
foreach ($forbidden in @('settingsFixtures', '@click="settingsState = ''form''"', 'finishPage(', 'genealogy-settings-updated')) {
Assert-NotContains -Content $g11 -Unexpected $forbidden -Message "G11 不得绕过权限或伪造后端保存:$forbidden"
}
Assert-Contains -Content $g12 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'G12 编辑取消必须使用项目对话框'
Assert-Matches -Content $g12 -Pattern '(?s)import \{\s*findGenealogyFixture,\s*getGenealogyFixtureAccess,\s*\} from "@/data/mock\.js";' -Message 'G12 数据与维护权限必须来自共享夹具解析器'
Assert-Contains -Content $g12 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'G12 必须消费唯一放弃确认控制器'
Assert-Contains -Content $g12 -Expected 'import { handleBackPress, runBackGuard } from "@/utils/navigation.js";' -Message 'G12 必须使用统一返回守卫'
Assert-Contains -Content $g12 -Expected 'genealogyId.value = String(query.genealogyId || "");' -Message 'G12 家谱 ID 必须保持词法字符串'
Assert-Contains -Content $g12 -Expected 'const canManage = ref(false);' -Message 'G12 必须显式持有维护权限'
Assert-Contains -Content $g12 -Expected 'const editorOrigin = ref("list");' -Message 'G12 必须记录编辑器来自列表还是空状态'
Assert-Contains -Content $g12 -Expected 'const editorSnapshot = ref(null);' -Message 'G12 必须保存进入编辑器前的草稿快照'
Assert-Contains -Content $g12 -Expected 'const isDirty = computed(() =>' -Message 'G12 必须识别编辑器脏状态'
Assert-Contains -Content $g12 -Expected 'const requestLeaveEditor = async () =>' -Message 'G12 取消必须经过统一脏表单确认'
Assert-Contains -Content $g12 -Expected 'restoreEditorSnapshot();' -Message 'G12 放弃编辑必须恢复进入前快照'
Assert-Contains -Content $g12 -Expected '@back="requestBack"' -Message 'G12 页头必须复用返回守卫'
Assert-Contains -Content $g12 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G12 Android 返回必须使用同步适配器'
Assert-Contains -Content $g12 -Expected 'discardConfirmation.dispose();' -Message 'G12 卸载必须释放确认等待者'
foreach ($forbidden in @('@click="poemState = ''list''"', 'finishPage(', 'generation-poems-updated')) {
Assert-NotContains -Content $g12 -Unexpected $forbidden -Message "G12 不得静默丢弃编辑或伪造后端保存:$forbidden"
}
foreach ($entry in @(
@{ Key = 'T01'; Content = $t01 },
@{ Key = 'T03'; Content = $t03 },
@{ Key = 'T04'; Content = $t04 },
@{ Key = 'T05'; Content = $t05 },
@{ Key = 'T06'; Content = $t06 },
@{ Key = 'T07'; Content = $t07 },
@{ Key = 'T08'; Content = $t08 }
)) {
foreach ($forbidden in @('uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', '/pages/')) {
Assert-NotContains -Content $entry.Content -Unexpected $forbidden -Message "$($entry.Key) 必须只使用导航网关:$forbidden"
}
}
foreach ($mapping in @(
'openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T07", { genealogyId: genealogyId.value }, "T01")'
)) {
Assert-Contains -Content $t01 -Expected $mapping -Message "T01 缺少规范入口:$mapping"
}
Assert-Contains -Content $t03 -Expected 'consumeNavigationResult("T03")' -Message 'T03 必须消费 single 页面激活请求'
Assert-Contains -Content $t03 -Expected 'internalTrail: trailIndex.value > 0' -Message 'T03 返回必须优先消费页内成员轨迹'
Assert-Contains -Content $t03 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'T03 Android 返回必须复用页内轨迹守卫'
Assert-Contains -Content $t07 -Expected 'openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")' -Message 'T07 必须进入唯一 T03 实例'
Assert-Contains -Content $t08 -Expected 'goRoot("G01")' -Message 'T08 权限失效动作必须进入 G01 根语义'
Assert-Contains -Content $t08 -Expected 'goBack()' -Message 'T08 普通动作必须按真实栈返回'
foreach ($form in @(
@{ Key = 'T04'; Content = $t04; Return = 'returnTo("T01", { genealogyId: genealogyId.value })' },
@{ Key = 'T05'; Content = $t05; Return = 'return goBack();' },
@{ Key = 'T06'; Content = $t06; Return = 'returnTo("T01", { genealogyId: genealogyId.value })' }
)) {
Assert-Contains -Content $form.Content -Expected '尚未提交服务器' -Message "$($form.Key) 必须明确本地预览没有写入服务器"
Assert-Contains -Content $form.Content -Expected $form.Return -Message "$($form.Key) 本地预览必须无结果返回规范目标"
foreach ($forbidden in @('finishPage(', 'relative-created', 'member-updated', 'relationship-updated')) {
Assert-NotContains -Content $form.Content -Unexpected $forbidden -Message "$($form.Key) 不得在真实写入前伪造结果:$forbidden"
}
}
Assert-Matches -Content $routes -Pattern '(?s)T01:\s*defineRoute\(\{(?:(?!resultOperations).)*?\}\),\s*T03:' -Message 'T01 当前不得预留 mutation 结果能力'
Assert-Matches -Content $routes -Pattern '(?s)T03:\s*defineRoute\(\{.*?resultOperations:\s*\["member-open-requested"\]' -Message 'T03 当前只能登记 single 激活结果'
foreach ($entry in @(
@{ Key = 'F01'; Content = $f01 },
@{ Key = 'F02'; Content = $f02 },
@{ Key = 'F03'; Content = $f03 },
@{ Key = 'F04'; Content = $f04 },
@{ Key = 'F05'; Content = $f05 },
@{ Key = 'F06'; Content = $f06 },
@{ Key = 'F07'; Content = $f07 },
@{ Key = 'F08'; Content = $f08 },
@{ Key = 'F09'; Content = $f09 },
@{ Key = 'F10'; Content = $f10 }
)) {
foreach ($forbidden in @('uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', '/pages/', 'finishPage(')) {
Assert-NotContains -Content $entry.Content -Unexpected $forbidden -Message "$($entry.Key) 必须只使用无伪结果的导航网关:$forbidden"
}
}
Assert-Contains -Content $f01 -Expected 'goRoot("F01", { genealogyId: resolvedGenealogyId })' -Message 'F01 从全局解析家谱后必须先规范化根页 URL'
Assert-Contains -Content $f01 -Expected 'listFamilyFeedFixtures(resolvedGenealogyId)' -Message 'F01 必须读取共享家族动态 owner'
foreach ($mapping in @(
'openPage("F02", { genealogyId: genealogyId.value }, "F01")',
'genealogyId: genealogyId.value, feedId: String(item.id)',
'return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");'
)) {
Assert-Contains -Content $f01 -Expected $mapping -Message "F01 缺少保留家谱身份的规范入口:$mapping"
}
foreach ($form in @(
@{ Key = 'F02'; Content = $f02 },
@{ Key = 'F03'; Content = $f03 },
@{ Key = 'F06'; Content = $f06 },
@{ Key = 'F09'; Content = $f09 }
)) {
Assert-Contains -Content $form.Content -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message "$($form.Key) 必须消费唯一放弃确认 owner"
Assert-Contains -Content $form.Content -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message "$($form.Key) Android 返回必须复用页面守卫"
Assert-Contains -Content $form.Content -Expected 'runBackGuard({' -Message "$($form.Key) 页头与 Android 返回必须共享守卫上下文"
Assert-Contains -Content $form.Content -Expected 'submitting:' -Message "$($form.Key) 校验中必须阻止离开"
Assert-Contains -Content $form.Content -Expected 'discardConfirmation.dispose();' -Message "$($form.Key) 卸载必须释放确认等待者"
}
Assert-Contains -Content $f02 -Expected '尚未提交服务器' -Message 'F02 只能生成明确未提交的本地预览'
Assert-Contains -Content $f02 -Expected 'returnTo("F01", { genealogyId: genealogyId.value })' -Message 'F02 必须无结果返回同一家谱 F01'
Assert-Contains -Content $f03 -Expected 'findFamilyFeedFixture(genealogyId.value, feedId.value)' -Message 'F03 必须按复合身份精确查询动态'
Assert-Contains -Content $f03 -Expected '草稿已保留' -Message 'F03 评论校验后必须保留草稿并声明未提交'
foreach ($forbidden in @('feedComments.value.push', 'commentDraft.value = ""', '评论已发送')) {
Assert-NotContains -Content $f03 -Unexpected $forbidden -Message "F03 不得把评论草稿伪装成已发送:$forbidden"
}
$f01CanonicalIndex = $f01.IndexOf('if (!hasRouteIdentity)')
$f01ActivationIndex = $f01.IndexOf('hasValidContext.value = isAccessible;')
if ($f01CanonicalIndex -lt 0 -or $f01ActivationIndex -le $f01CanonicalIndex) {
throw 'F01 必须在缺参根 URL 规范化分支之后才开放发布与业务快捷入口'
}
Assert-Contains -Content $f04 -Expected 'listFamilyArticleFixtures(genealogyId.value)' -Message 'F04 必须读取同一家谱的共享谱文 owner'
Assert-Contains -Content $f04 -Expected ':action="hasValidContext ? ''新建'' : ''''"' -Message 'F04 无效家谱状态不得暴露新建入口'
if ($f04 -notmatch '(?s)const createArticle = \(\) =>\s*hasValidContext\.value\s*\?\s*openPage\(') {
throw 'F04 新建处理器必须在调用导航网关前再次校验有效家谱上下文'
}
Assert-Contains -Content $f05 -Expected 'findFamilyArticleFixture(genealogyId.value, articleId.value)' -Message 'F05 必须按复合身份精确查询谱文'
Assert-Contains -Content $f05 -Expected 'disabled label="收藏暂未开放"' -Message 'F05 无线上端点时必须禁用收藏二态'
Assert-NotContains -Content $f05 -Unexpected 'toggleFavorite' -Message 'F05 不得保留本地收藏伪持久化'
Assert-Contains -Content $f06 -Expected '(editorMode.value === "create" && !articleId.value)' -Message 'F06 create 必须禁止 articleId'
Assert-Contains -Content $f06 -Expected '(editorMode.value === "edit" && Boolean(article))' -Message 'F06 edit 必须要求精确文章'
Assert-Contains -Content $f06 -Expected '当前尚未提交服务器' -Message 'F06 必须明确本地预览没有保存文章'
Assert-Contains -Content $f07 -Expected 'listFamilyAlbumFixtures(genealogyId.value)' -Message 'F07 必须读取同一家谱的共享相册 owner'
Assert-Contains -Content $f07 -Expected 'localAlbumPreview.value = Object.freeze({ name });' -Message 'F07 草稿只能进入独立本地预览'
Assert-NotContains -Content $f07 -Unexpected 'albums.value.unshift' -Message 'F07 不得把未提交草稿插入正式相册列表'
Assert-Contains -Content $f07 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'F07 弹层与草稿必须接管 Android 返回'
Assert-Contains -Content $f08 -Expected 'findFamilyAlbumFixture(genealogyId.value, albumId.value)' -Message 'F08 必须按复合身份精确查询相册'
Assert-Contains -Content $f08 -Expected 'transientOpen: previewVisible.value' -Message 'F08 返回必须优先关闭照片预览'
Assert-Contains -Content $f08 -Expected 'openPage(' -Message 'F08 添加照片必须走导航网关'
Assert-Contains -Content $f09 -Expected 'findFamilyAlbumFixture(genealogyId.value, albumId.value)' -Message 'F09 必须按复合身份锁定上传目标'
Assert-Contains -Content $f09 -Expected '照片尚未上传' -Message 'F09 本地预览必须明确未上传'
foreach ($forbidden in @('uploading', 'uploaded', 'success', 'retryFailed', '/genealogy/app/files/upload', 'media-uploaded')) {
Assert-NotContains -Content $f09 -Unexpected $forbidden -Message "F09 不得保留假上传状态或陈旧接口:$forbidden"
}
Assert-Contains -Content $f10 -Expected 'returnTo("F01", { genealogyId: genealogyId.value })' -Message 'F10 必须保留家谱身份返回 F01'
foreach ($operation in @('feed-created', 'article-created', 'article-updated', 'media-uploaded')) {
Assert-NotContains -Content $routes -Unexpected $operation -Message "F 系列真实写入前不得预注册结果:$operation"
}
foreach ($page in @($r01,$r02,$r03,$r04,$r05,$r06,$r07,$r08,$r09,$r10,$r11)) {
foreach ($forbidden in @('uni.navigateTo(', 'uni.navigateBack(', 'uni.redirectTo(', 'uni.reLaunch(', '/pages/records/')) {
Assert-NotContains -Content $page -Unexpected $forbidden -Message "R 系列必须只通过导航网关消费活动路由:$forbidden"
}
}
foreach ($contract in @(
@{ Content = $r01; RouteKey = 'R02' },
@{ Content = $r02; RouteKey = 'R08' },
@{ Content = $r02; RouteKey = 'R09' },
@{ Content = $r03; RouteKey = 'R04' },
@{ Content = $r05; RouteKey = 'R06' },
@{ Content = $r05; RouteKey = 'R07' },
@{ Content = $r06; RouteKey = 'R07' },
@{ Content = $t03; RouteKey = 'R08' },
@{ Content = $t03; RouteKey = 'R09' }
)) {
Assert-Matches -Content $contract.Content -Pattern "openPage\(\s*`"$($contract.RouteKey)`"" -Message "R 系列缺少注册表流程:$($contract.RouteKey)"
}
foreach ($operation in @('person-created','person-updated','person-deleted','gift-created','gift-updated','gift-deleted','ritual-created','ritual-updated','ritual-deleted')) {
Assert-NotContains -Content $routes -Unexpected $operation -Message "R 系列真实写入前不得预注册结果:$operation"
}
foreach ($page in @(
@{ Key = 'N01'; Content = $n01 },
@{ Key = 'N02'; Content = $n02 },
@{ Key = 'M01'; Content = $m01 },
@{ Key = 'M02'; Content = $m02 },
@{ Key = 'M03'; Content = $m03 },
@{ Key = 'M04'; Content = $m04 },
@{ Key = 'M05'; Content = $m05 },
@{ Key = 'M06'; Content = $m06 },
@{ Key = 'M07'; Content = $m07 },
@{ Key = 'M08'; Content = $m08 },
@{ Key = 'M09'; Content = $m09 },
@{ Key = 'M10'; Content = $m10 }
)) {
foreach ($forbidden in @(
'uni.navigateTo',
'uni.navigateBack',
'uni.redirectTo',
'uni.reLaunch',
'getCurrentPages(',
'<navigator',
'/pages/',
'finishPage('
)) {
Assert-NotContains -Content $page.Content -Unexpected $forbidden -Message "$($page.Key) 必须只消费唯一导航网关且不得伪造流程结果:$forbidden"
}
}
foreach ($contract in @(
@{ Key = 'N01'; ParamToken = 'optionalParams: ["genealogyId"]'; SourceToken = 'allowedSources: ["G01", "M01"]' },
@{ Key = 'N02'; ParamToken = 'requiredParams: ["id"]'; SourceToken = 'allowedSources: ["N01"]' },
@{ Key = 'M02'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M01"]' },
@{ Key = 'M03'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
@{ Key = 'M04'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M03"]' },
@{ Key = 'M05'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M03"]' },
@{ Key = 'M06'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
@{ Key = 'M07'; ParamToken = 'kind: "flow"'; SourceToken = 'allowedSources: ["M06"]' },
@{ Key = 'M08'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
@{ Key = 'M09'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' },
@{ Key = 'M10'; ParamToken = 'kind: "page"'; SourceToken = 'allowedSources: ["M01"]' }
)) {
$routePattern = '(?s)' +
[regex]::Escape("$($contract.Key): defineRoute({") +
'(?:(?!\}\),).)*' +
[regex]::Escape($contract.ParamToken) +
'(?:(?!\}\),).)*' +
[regex]::Escape($contract.SourceToken)
Assert-Matches -Content $routes -Pattern $routePattern -Message "$($contract.Key) 路由参数、页面语义或唯一来源漂移"
}
Assert-Matches -Content $routes -Pattern '(?s)M01:\s*defineRoute\(\{\s*path:\s*"/pages/profile/m01-profile-home",\s*kind:\s*"root",\s*parent:\s*null,?\s*\}\)' -Message 'M01 必须保留唯一 profile 根语义'
Assert-Matches -Content $routes -Pattern '(?s)const defineNoticeTarget = \(routeKey, params\) =>\s*Object\.freeze\(\{ routeKey, params: Object\.freeze\(params\) \}\);' -Message '通知目标定义及参数列表必须不可变'
Assert-Matches -Content $routes -Pattern '(?s)export const NOTICE_TARGETS = Object\.freeze\(\{\s*GENEALOGY_REVIEW: defineNoticeTarget\("G10", \["genealogyId"\]\),\s*GENEALOGY_HOME: defineNoticeTarget\("G01", \["genealogyId"\]\),\s*\}\);' -Message '通知目标白名单只能把审核映射到 G10、家谱首页映射到 G01'
foreach ($token in @(
'export const openNoticeTarget = (targetType, params, sourceKey = "N02") =>',
'assertScalarString("通知目标类型", targetType);',
'assertScalarString("通知来源", sourceKey);',
'if (!hasOwn(NOTICE_TARGETS, targetType))',
'if (!["N01", "N02"].includes(sourceKey))',
'const normalizedParams = snapshotDataRecord("通知目标参数", params);',
'const expectedParams = new Set(target.params);',
'if (!expectedParams.has(name))',
'assertScalarString(name, normalizedParams[name]);',
'if (!hasOwn(normalizedParams, name))',
'? goRoot(target.routeKey, normalizedParams)',
': openPage(target.routeKey, normalizedParams, sourceKey);'
)) {
Assert-Contains -Content $navigation -Expected $token -Message "通知导航网关缺少安全边界:$token"
}
$noticeWhitelistIndex = $navigation.IndexOf('if (!hasOwn(NOTICE_TARGETS, targetType))')
$noticeLookupIndex = $navigation.IndexOf('const target = NOTICE_TARGETS[targetType];')
if ($noticeWhitelistIndex -lt 0 -or $noticeLookupIndex -le $noticeWhitelistIndex) {
throw '通知导航网关必须先验证白名单,再读取目标定义'
}
foreach ($token in @(
"targetType: 'GENEALOGY_REVIEW'",
"targetType: 'GENEALOGY_HOME'",
"targetParams: { genealogyId: '1001' }",
'export const listNotificationFixtures',
'export const findNotificationFixture'
)) {
Assert-Contains -Content $mock -Expected $token -Message "通知夹具 owner 缺少安全目标字段:$token"
}
foreach ($forbidden in @('detailId', 'targetUrl', 'returnUrl', "target: '/pages/", 'const noticeDetails')) {
Assert-NotContains -Content $mock -Unexpected $forbidden -Message "通知夹具不得保留重复身份、页面 owner 或可执行路径:$forbidden"
Assert-NotContains -Content $n01 -Unexpected $forbidden -Message "N01 不得保留重复身份、页面 owner 或可执行路径:$forbidden"
Assert-NotContains -Content $n02 -Unexpected $forbidden -Message "N02 不得保留重复身份、页面 owner 或可执行路径:$forbidden"
}
Assert-Contains -Content $n01 -Expected 'import { listNotificationFixtures } from "@/data/mock.js";' -Message 'N01 必须读取共享通知夹具 owner'
Assert-Matches -Content $n01 -Pattern '(?s)openPage\(\s*"N02",\s*\{ id: String\(item\.id\) \},\s*"N01",?\s*\)' -Message 'N01 必须只携带词法消息 ID 并声明 N01 来源进入 N02'
Assert-Matches -Content $n01 -Pattern '(?s)genealogyId\.value\s*\?\s*openPage\(\s*"G10",\s*\{ genealogyId: genealogyId\.value \},\s*"N01",?\s*\)' -Message 'N01 审核入口必须缺少家谱 ID 时失败关闭,并通过 G10 注册路由'
Assert-Contains -Content $n02 -Expected 'findNotificationFixture(noticeId.value)' -Message 'N02 必须按共享 owner 的词法消息 ID 精确查询'
Assert-Contains -Content $n02 -Expected 'noticeDetail.value.unread = false;' -Message 'N02 已读动作必须更新共享 owner 的 unread 字段'
Assert-NotContains -Content $n02 -Unexpected 'noticeDetail.read' -Message 'N02 不得创建与 unread 冲突的 read 字段'
Assert-Contains -Content $n02 -Expected 'getGenealogyFixtureAccess' -Message 'N02 打开业务目标前必须校验家谱访问权限'
Assert-Matches -Content $n02 -Pattern '(?s)return await navigateNoticeTarget\(\s*noticeDetail\.value\.targetType,\s*noticeDetail\.value\.targetParams,\s*\);' -Message 'N02 只能把声明式目标类型和参数交给安全通知网关'
if ($n02 -match 'noticeDetail\.value\.target(?![A-Za-z0-9_$])') {
throw 'N02 不得消费可执行 target 字段'
}
Assert-Contains -Content $n02 -Expected 'const backToMessages = () => returnTo("N01", {});' -Message 'N02 页头必须通过返回网关回到 N01'
Assert-Contains -Content $n02 -Expected 'onBackPress((event) => handleBackPress(event, backToMessages));' -Message 'N02 Android 返回必须复用唯一 N01 返回动作'
foreach ($mapping in @(
'openPage("M02", {}, "M01")',
'openPage("N01", {}, "M01")',
'routeKey: "M03"',
'routeKey: "M06"',
'routeKey: "M08"',
'routeKey: "M09"',
'routeKey: "M10"',
'openPage(item.routeKey, {}, "M01")'
)) {
Assert-Contains -Content $m01 -Expected $mapping -Message "M01 缺少注册表入口:$mapping"
}
foreach ($mapping in @('routeKey: "M04"', 'routeKey: "M05"', 'openPage(item.routeKey, {}, "M03")')) {
Assert-Contains -Content $m03 -Expected $mapping -Message "M03 缺少注册表入口:$mapping"
}
Assert-Contains -Content $m06 -Expected 'openPage("M07", {}, "M06")' -Message 'M06 联系支持必须进入注册的 M07 流程'
foreach ($form in @(
@{ Key = 'M02'; Content = $m02 },
@{ Key = 'M04'; Content = $m04 },
@{ Key = 'M05'; Content = $m05 },
@{ Key = 'M07'; Content = $m07 }
)) {
foreach ($token in @(
'custom-back',
'@back="requestBack"',
'createDiscardConfirmation',
'const isDirty = computed(',
'runBackGuard({',
'dirty: isDirty.value',
'submitting:',
'onBackPress((event) => handleBackPress(event, requestBack));',
'discardConfirmation.dispose();',
':close-on-mask="false"'
)) {
Assert-Contains -Content $form.Content -Expected $token -Message "$($form.Key) 缺少脏表单返回守卫:$token"
}
}
foreach ($page in @(
@{ Key = 'M08'; Content = $m08 },
@{ Key = 'M09'; Content = $m09 },
@{ Key = 'M10'; Content = $m10 }
)) {
foreach ($token in @(
'custom-back',
'@back="requestBack"',
'runBackGuard({',
'transientOpen:',
'onBackPress((event) => handleBackPress(event, requestBack));',
':close-on-mask="false"'
)) {
Assert-Contains -Content $page.Content -Expected $token -Message "$($page.Key) 缺少浮层优先返回守卫:$token"
}
}
Assert-Matches -Content $m10 -Pattern '(?s)const confirmLogout = \(\) => \{\s*session\.clear\(\);\s*logoutVisible\.value = false;\s*return goRoot\("A01"\);\s*\};' -Message 'M10 退出必须依次清理唯一会话、关闭浮层并进入 A01 根语义'
Write-Output 'NAVIGATION-FLOW-CONTRACT PASS AUTH G01-G12 T01-T08 F01-F10 R01-R11 N01-N02 M01-M10'
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$pagesPath = Join-Path $root 'pages.json'
$routesPath = Join-Path $root 'utils/navigation-routes.js'
if (-not (Test-Path -LiteralPath $routesPath -PathType Leaf)) {
throw '缺少路由语义注册表:utils/navigation-routes.js'
}
<# NODE-CONTRACT-BEGIN
"use strict";
const assert = require("assert");
const fs = require("fs");
(async () => {
const [pagesPath, routesPath] = process.argv.slice(2);
const pages = JSON.parse(fs.readFileSync(pagesPath, "utf8"));
const routesSource = fs.readFileSync(routesPath, "utf8");
const moduleUrl = `data:text/javascript;base64,${Buffer.from(routesSource).toString("base64")}`;
const { NOTICE_TARGETS, ROUTES, ROOT_ROUTE_KEYS, getRoute, getRouteKeyByPath } = await import(moduleUrl);
const registeredPaths = pages.pages.map(({ path }) => `/${path}`);
const expectedRouteKeys = registeredPaths.map((path) => {
const filename = path.split("/").pop();
const match = /^([a-z]\d{2})-/i.exec(filename);
assert(match, `页面文件名缺少语义编号:${path}`);
return match[1].toUpperCase();
});
const routeKeys = Object.keys(ROUTES);
const routePaths = routeKeys.map((routeKey) => ROUTES[routeKey].path);
assert.strictEqual(routeKeys.length, 52, "路由注册表必须恰好包含 52 条活动路由");
assert.deepStrictEqual(routeKeys, expectedRouteKeys, "路由键集合和顺序必须来自 pages.json 的页面编号");
assert.deepStrictEqual(routePaths, registeredPaths, "路由路径集合和顺序必须与 pages.json 精确一致");
assert(Object.isFrozen(ROUTES), "ROUTES 必须冻结");
assert(Object.isFrozen(ROOT_ROUTE_KEYS), "ROOT_ROUTE_KEYS 必须冻结");
assert(Object.isFrozen(NOTICE_TARGETS), "NOTICE_TARGETS 必须冻结");
assert.deepStrictEqual(Object.keys(NOTICE_TARGETS), ["GENEALOGY_REVIEW", "GENEALOGY_HOME"], "通知目标类型必须是封闭白名单");
assert.deepStrictEqual(
Object.fromEntries(Object.entries(NOTICE_TARGETS).map(([key, value]) => [key, { routeKey: value.routeKey, params: Array.from(value.params) }])),
{
GENEALOGY_REVIEW: { routeKey: "G10", params: ["genealogyId"] },
GENEALOGY_HOME: { routeKey: "G01", params: ["genealogyId"] },
},
"通知只能映射到登记业务语义及精确参数",
);
for (const target of Object.values(NOTICE_TARGETS)) {
assert(Object.isFrozen(target), "通知目标定义必须冻结");
assert(Object.isFrozen(target.params), "通知目标参数表必须冻结");
}
const routeFields = [
"path", "kind", "parent", "parentParamMap", "requiredParams", "optionalParams",
"allowedSources", "resultOperations",
];
const routeKinds = new Set(["auth-root", "root", "page", "flow", "single"]);
const forbiddenParams = new Set([
"previous", "returnUrl", "fallbackUrl", "targetUrl", "state", "count",
"saveResult", "step", "genealogyName", "sourceKey", "personName",
"giftId", "ritualId",
]);
const discoveredRoots = routeKeys.filter((routeKey) => ROUTES[routeKey].parent === null);
assert.strictEqual(discoveredRoots.length, 4, "活动路由必须保持四个互不伪造历史的根语义");
assert.deepStrictEqual(Array.from(ROOT_ROUTE_KEYS), ["A01", "G01", "F01", "M01"], "根安全边界必须固定");
assert.deepStrictEqual(Array.from(ROOT_ROUTE_KEYS), discoveredRoots, "ROOT_ROUTE_KEYS 必须精确列出 parent=null 的路由");
assert.strictEqual(ROUTES.A01.kind, "auth-root", "A01 必须是认证根语义");
assert.deepStrictEqual(Array.from(ROUTES.G03.optionalParams), [], "G03 不得保留未消费的 genealogyId 或 step 参数");
for (const routeKey of ["G01", "G05", "G09", "T01"]) {
assert.deepStrictEqual(Array.from(ROUTES[routeKey].resultOperations), [], `${routeKey} 不得预留没有真实 API 生产者的结果能力`);
}
assert.deepStrictEqual(
Array.from(ROUTES.T03.resultOperations),
["member-open-requested"],
"T03 当前只能保留 single 页面激活实际使用的页内打开结果",
);
const expectedFamilyParams = {
F01: { required: [], optional: ["genealogyId"] },
F02: { required: ["genealogyId"], optional: [] },
F03: { required: ["genealogyId", "feedId"], optional: [] },
F04: { required: ["genealogyId"], optional: [] },
F05: { required: ["genealogyId", "articleId"], optional: [] },
F06: { required: ["genealogyId", "mode"], optional: ["articleId"] },
F07: { required: ["genealogyId"], optional: [] },
F08: { required: ["genealogyId", "albumId"], optional: [] },
F09: { required: ["genealogyId", "albumId"], optional: [] },
F10: { required: ["genealogyId"], optional: [] },
};
for (const [routeKey, expected] of Object.entries(expectedFamilyParams)) {
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].requiredParams),
expected.required,
`${routeKey} 必填参数必须精确表达家谱复合身份`,
);
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].optionalParams),
expected.optional,
`${routeKey} 可选参数不得保留身份旁路`,
);
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].resultOperations),
[],
`${routeKey} 在真实写接口落地前不得预留伪成功结果`,
);
}
assert.deepStrictEqual(
Array.from(ROUTES.F08.allowedSources),
["F07"],
"F09 无结果返回 F08,不得伪装成新的 F08 打开来源",
);
const expectedRecordParams = {
R01: { required: ["genealogyId"], optional: [] },
R02: { required: ["genealogyId", "mode"], optional: ["personId"] },
R03: { required: ["genealogyId"], optional: [] },
R04: { required: ["genealogyId", "mode"], optional: ["relativeId"] },
R05: { required: ["genealogyId"], optional: [] },
R06: { required: ["genealogyId", "ceremonyId"], optional: [] },
R07: { required: ["genealogyId", "mode"], optional: ["ceremonyId"] },
R08: { required: ["genealogyId", "personId"], optional: [] },
R09: { required: ["genealogyId", "personId"], optional: [] },
R10: { required: ["genealogyId"], optional: [] },
R11: { required: ["genealogyId"], optional: [] },
};
for (const [routeKey, expected] of Object.entries(expectedRecordParams)) {
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].requiredParams),
expected.required,
`${routeKey} 必填参数必须精确表达 R 系列复合身份`,
);
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].optionalParams),
expected.optional,
`${routeKey} 可选参数不得保留旧 ID 或状态旁路`,
);
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].resultOperations),
[],
`${routeKey} 在真实写接口落地前不得预留伪成功结果`,
);
}
for (const routeKey of ["N01", "N02", "M01", "M02", "M03", "M04", "M05", "M06", "M07", "M08", "M09", "M10"]) {
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].resultOperations),
[],
`${routeKey} 在真实写接口落地前不得预留伪成功结果`,
);
}
assert(ROUTES.G09.allowedSources.includes("G05"), "G05 审核中公开预览必须能进入 G09 申请进度");
for (const routeKey of ["G01", "F01", "M01"]) {
assert.strictEqual(ROUTES[routeKey].kind, "root", `${routeKey} 必须是业务根语义`);
}
assert.deepStrictEqual(routeKeys.filter((routeKey) => ROUTES[routeKey].kind === "single"), ["T03"], "T03 必须是唯一单实例页面");
for (const [index, routeKey] of routeKeys.entries()) {
const route = ROUTES[routeKey];
assert(Object.isFrozen(route), `${routeKey} 路由项必须冻结`);
assert.deepStrictEqual(Object.keys(route).sort(), [...routeFields].sort(), `${routeKey} 路由字段集合不精确`);
assert(routeKinds.has(route.kind), `${routeKey} 页面类型非法`);
assert.strictEqual(route.path, registeredPaths[index], `${routeKey} 路径与 pages.json 不一致`);
for (const field of ["requiredParams", "optionalParams", "allowedSources", "resultOperations"]) {
const values = route[field];
assert(Array.isArray(values), `${routeKey}.${field} 必须是数组`);
assert(Object.isFrozen(values), `${routeKey}.${field} 必须冻结`);
assert.strictEqual(new Set(values).size, values.length, `${routeKey}.${field} 不得重复`);
assert(values.every((value) => typeof value === "string" && value.length > 0), `${routeKey}.${field} 只能包含非空字符串`);
}
assert(Object.isFrozen(route.parentParamMap), `${routeKey}.parentParamMap 必须冻结`);
assert(route.parentParamMap && Object.getPrototypeOf(route.parentParamMap) === Object.prototype, `${routeKey}.parentParamMap 必须是普通对象`);
const businessParams = [...route.requiredParams, ...route.optionalParams];
assert.strictEqual(new Set(businessParams).size, businessParams.length, `${routeKey} 必填与可选参数不得重复`);
for (const name of businessParams) {
assert(/^[A-Za-z][A-Za-z0-9]*$/.test(name), `${routeKey} 参数名非法:${name}`);
assert(!forbiddenParams.has(name), `${routeKey} 不得声明旧参数 ${name}`);
}
for (const sourceKey of route.allowedSources) {
assert(getRoute(sourceKey), `${routeKey} 引用了不存在的来源 ${sourceKey}`);
assert.notStrictEqual(sourceKey, routeKey, `${routeKey} 不得把自身声明为来源`);
}
for (const operation of route.resultOperations) {
assert(/^[a-z]+(?:-[a-z]+)*$/.test(operation), `${routeKey} 结果操作必须使用小写 kebab-case${operation}`);
}
if (route.parent === null) {
assert(["auth-root", "root"].includes(route.kind), `${routeKey} 根语义类型非法`);
assert.strictEqual(route.allowedSources.length, 0, `${routeKey} 根语义不得伪造允许来源`);
} else {
assert(typeof route.parent === "string" && getRoute(route.parent), `${routeKey} 引用了不存在的父页`);
assert(routeKinds.has(route.kind) && !["auth-root", "root"].includes(route.kind), `${routeKey} 非根页面类型非法`);
assert(routeKeys.indexOf(route.parent) < index, `${routeKey} 父页必须先于子页注册`);
assert(route.allowedSources.length > 0, `${routeKey} 非根页面必须声明至少一个允许来源`);
}
const mappedChildParams = [];
for (const [parentParam, childParam] of Object.entries(route.parentParamMap)) {
const parent = getRoute(route.parent);
assert(parent, `${routeKey} 声明父参数映射但没有父页`);
assert([...parent.requiredParams, ...parent.optionalParams].includes(parentParam), `${routeKey} 的父参数 ${parentParam} 未在父页声明`);
assert(businessParams.includes(childParam), `${routeKey} 的子参数 ${childParam} 未在本页声明`);
mappedChildParams.push(childParam);
}
assert.strictEqual(new Set(mappedChildParams).size, mappedChildParams.length, `${routeKey} 同一子参数不得映射到多个父参数`);
}
// 每条父链必须无环并最终到达已登记根页;这比维护第二张父页表更能约束结构安全。
for (const routeKey of routeKeys) {
const visited = new Set();
let cursor = routeKey;
while (cursor !== null) {
assert(!visited.has(cursor), `${routeKey} 父链存在环:${cursor}`);
visited.add(cursor);
const route = getRoute(cursor);
assert(route, `${routeKey} 父链包含未知路由:${cursor}`);
cursor = route.parent;
}
const terminal = Array.from(visited).pop();
assert(ROOT_ROUTE_KEYS.includes(terminal), `${routeKey} 父链未落到登记根页`);
}
// allowedSources 是进入权限边。所有页面都必须能从四根之一沿合法入口到达,
// 防止若干非根页面组成自洽但与真实产品入口隔离的来源环。
const sourceReachable = new Set(ROOT_ROUTE_KEYS);
let sourceReachabilityChanged = true;
while (sourceReachabilityChanged) {
sourceReachabilityChanged = false;
for (const routeKey of routeKeys) {
if (sourceReachable.has(routeKey)) continue;
if (ROUTES[routeKey].allowedSources.some((sourceKey) => sourceReachable.has(sourceKey))) {
sourceReachable.add(routeKey);
sourceReachabilityChanged = true;
}
}
}
assert.deepStrictEqual(Array.from(sourceReachable).sort(), routeKeys.slice().sort(), "存在无法从任何根语义合法进入的页面");
assert.strictEqual(getRoute(routeKeys[0]), ROUTES[routeKeys[0]], "getRoute 必须返回注册表中的同一冻结对象");
assert.strictEqual(getRoute("UNKNOWN"), null, "getRoute 对未知键必须返回 null");
for (const inheritedKey of ["constructor", "toString", "valueOf", "__proto__"]) {
assert.strictEqual(getRoute(inheritedKey), null, `getRoute 不得把原型链键 ${inheritedKey} 当成活动路由`);
}
for (const invalidKey of [null, 42, [routeKeys[0]], new String(routeKeys[0]), { toString: () => routeKeys[0] }]) {
assert.strictEqual(getRoute(invalidKey), null, "getRoute 只接受原始字符串路由键");
}
const samplePath = routePaths[Math.floor(routePaths.length / 2)];
const sampleKey = routeKeys[Math.floor(routeKeys.length / 2)];
assert.strictEqual(getRouteKeyByPath(samplePath.slice(1)), sampleKey, "路径查询必须接受无前导斜杠路径");
assert.strictEqual(getRouteKeyByPath(samplePath), sampleKey, "路径查询必须接受有前导斜杠路径");
assert.strictEqual(getRouteKeyByPath("/pages/unknown"), null, "未知路径必须返回 null");
for (const invalidPath of [null, 42, [samplePath], { toString: () => samplePath }]) {
assert.strictEqual(getRouteKeyByPath(invalidPath), null, "路径查询只接受原始字符串路径");
}
console.log(`NAVIGATION-ROUTES-CONTRACT PASS ROUTES=${routeKeys.length}`);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
NODE-CONTRACT-END #>
# 注册表是导航语义的唯一数据所有者。本合同真实导入 ESM,并以 pages.json 交叉和
# 通用图不变量验收边界,避免用一张必须同步维护的同构影子表复制合同。
$contractPath = Join-Path $root 'tests/navigation-routes-contract.ps1'
$contractFile = Get-Content -Raw -Encoding UTF8 -LiteralPath $contractPath
$startMarker = '<# NODE-CONTRACT-BEGIN'
$endMarker = 'NODE-CONTRACT-END #>'
$startIndex = $contractFile.IndexOf($startMarker)
$endIndex = $contractFile.IndexOf($endMarker)
if ($startIndex -lt 0 -or $endIndex -le $startIndex) {
throw '无法读取内嵌的路由注册表运行合同'
}
$startIndex += $startMarker.Length
$nodeContract = $contractFile.Substring(
$startIndex,
$endIndex - $startIndex
).Trim()
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$output = $nodeContract | & node - $pagesPath $routesPath 2>&1
$nodeExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorActionPreference
if ($nodeExitCode -ne 0) {
throw ($output -join "`n")
}
Write-Output ($output -join "`n")
+293
View File
@@ -0,0 +1,293 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
function Remove-JavaScriptComments([string]$Content) {
# 注释不能伪造迁移计数;字符串必须保留,才能继续捕获方括号与 Reflect 取值。
$builder = [System.Text.StringBuilder]::new($Content.Length)
$state = 'code'
$quote = [char]0
$stringReturnState = 'code'
$commentReturnState = 'code'
$templateReturnStates = [System.Collections.Generic.Stack[string]]::new()
$templateExpressionDepths = [System.Collections.Generic.Stack[int]]::new()
for ($index = 0; $index -lt $Content.Length; $index += 1) {
$character = $Content[$index]
$next = if ($index + 1 -lt $Content.Length) { $Content[$index + 1] } else { [char]0 }
if ($state -eq 'template-string') {
[void]$builder.Append($character)
if ($character -eq [char]92 -and $index + 1 -lt $Content.Length) {
$index += 1
[void]$builder.Append($Content[$index])
} elseif ($character -eq [char]96) {
$state = $templateReturnStates.Pop()
} elseif ($character -eq [char]36 -and $next -eq [char]123) {
[void]$builder.Append($next)
$index += 1
$templateExpressionDepths.Push(1)
$state = 'template-code'
}
continue
}
if ($state -in @('code', 'template-code')) {
if ($state -eq 'template-code' -and $character -eq [char]123) {
$depth = $templateExpressionDepths.Pop() + 1
$templateExpressionDepths.Push($depth)
[void]$builder.Append($character)
} elseif ($state -eq 'template-code' -and $character -eq [char]125) {
$depth = $templateExpressionDepths.Pop() - 1
[void]$builder.Append($character)
if ($depth -eq 0) {
$state = 'template-string'
} else {
$templateExpressionDepths.Push($depth)
}
} elseif ($state -eq 'code' -and $character -eq [char]60 -and $index + 3 -lt $Content.Length -and $Content.Substring($index, 4) -eq '<!--') {
$state = 'html-comment'
[void]$builder.Append(' ')
$index += 3
} elseif ($character -eq [char]96) {
$templateReturnStates.Push($state)
$state = 'template-string'
[void]$builder.Append($character)
} elseif ($character -in @([char]39, [char]34)) {
$stringReturnState = $state
$state = 'string'
$quote = $character
[void]$builder.Append($character)
} elseif ($character -eq [char]47 -and $next -eq [char]47) {
$state = 'line-comment'
$commentReturnState = if ($templateExpressionDepths.Count -gt 0) { 'template-code' } else { 'code' }
[void]$builder.Append(' ')
$index += 1
} elseif ($character -eq [char]47 -and $next -eq [char]42) {
$state = 'block-comment'
$commentReturnState = if ($templateExpressionDepths.Count -gt 0) { 'template-code' } else { 'code' }
[void]$builder.Append(' ')
$index += 1
} else {
[void]$builder.Append($character)
}
continue
}
if ($state -eq 'string') {
[void]$builder.Append($character)
if ($character -eq [char]92 -and $index + 1 -lt $Content.Length) {
$index += 1
[void]$builder.Append($Content[$index])
} elseif ($character -eq $quote) {
$state = $stringReturnState
}
continue
}
if ($state -eq 'line-comment') {
if ($character -in @([char]10, [char]13)) {
$state = $commentReturnState
[void]$builder.Append($character)
} else {
[void]$builder.Append(' ')
}
continue
}
if ($state -eq 'html-comment') {
if ($character -eq [char]45 -and $index + 2 -lt $Content.Length -and $Content.Substring($index, 3) -eq '-->') {
$state = 'code'
[void]$builder.Append(' ')
$index += 2
} elseif ($character -in @([char]10, [char]13)) {
[void]$builder.Append($character)
} else {
[void]$builder.Append(' ')
}
continue
}
if ($character -eq [char]42 -and $next -eq [char]47) {
$state = $commentReturnState
[void]$builder.Append(' ')
$index += 1
} elseif ($character -in @([char]10, [char]13)) {
[void]$builder.Append($character)
} else {
[void]$builder.Append(' ')
}
}
return $builder.ToString()
}
function Remove-HtmlComments([string]$Content) {
return [regex]::Replace(
$Content,
'(?s)<!--.*?-->',
{ param($match) $match.Value -replace '[^\r\n]', ' ' }
)
}
function Get-ScannableSource([string]$Content, [string]$Extension) {
if ($Extension -ne '.vue') { return Remove-JavaScriptComments $Content }
# SFC 必须分段处理:先遮蔽顶层 script/style,再把完整 markup(含嵌套 template
# 按 HTML 处理;template 中的 https:// 绝不是 JS 注释。
$builder = [System.Text.StringBuilder]::new()
$codeBlockPattern = '(?is)<(?<Tag>script|style)\b[^>]*>(?<Body>.*?)</\k<Tag>>'
$blocks = [regex]::Matches(
$Content,
$codeBlockPattern
)
$markup = [regex]::Replace(
$Content,
$codeBlockPattern,
{ param($match) $match.Value -replace '[^\r\n]', ' ' }
)
[void]$builder.AppendLine((Remove-HtmlComments $markup))
foreach ($block in $blocks) {
[void]$builder.AppendLine((Remove-JavaScriptComments $block.Groups['Body'].Value))
}
return $builder.ToString()
}
$navigationMethodPattern = 'navigateTo|navigateBack|redirectTo|reLaunch|switchTab'
$navigationReferencePattern = '\b(?:' + $navigationMethodPattern + ')\b'
$switchTabReferencePattern = '\bswitchTab\b'
$stackReferencePattern = '\bgetCurrentPages\b'
$declarativeNavigationPattern = '<\s*navigator\b'
$dynamicUniAccessPattern = '(?:\buni\b|\(\s*uni\s*\))\s*(?:\?\.)?\s*\[|\bReflect\.(?:get|apply)\s*\(\s*uni\b'
$escapedUniValuePattern = '\buni\b(?!\s*(?:\?\.|\.|\[|-))'
foreach ($sample in @(
'uni.navigateTo',
'(uni).navigateBack',
'uni?.["redirectTo"]',
"const {`n reLaunch: replace`n} = uni",
'const u = uni; u.switchTab',
'Reflect.get(uni, "navigateTo")'
)) {
if ((Remove-JavaScriptComments $sample) -notmatch $navigationReferencePattern) {
throw "导航源扫描正则漏检受保护语法:$sample"
}
}
foreach ($sample in @(
'// uni.navigateTo()',
'/* Reflect.get(uni, "navigateBack") */',
'<!-- uni.redirectTo({}) -->',
'const label = `${ /* uni.reLaunch() */ safe }`'
)) {
if ((Remove-JavaScriptComments $sample) -match $navigationReferencePattern) {
throw "导航源扫描不得让注释伪造迁移计数:$sample"
}
}
foreach ($sample in @('<navigator url="/pages/demo">', 'uni?.[method]', '(uni)[method]', 'Reflect.get(uni, method)')) {
$sanitized = Remove-JavaScriptComments $sample
if ($sanitized -notmatch $declarativeNavigationPattern -and $sanitized -notmatch $dynamicUniAccessPattern) {
throw "导航源扫描漏检声明式或动态 Uni 导航入口:$sample"
}
}
foreach ($sample in @('const u = uni', 'consume(uni)', 'const u = globalThis.uni')) {
if ((Remove-JavaScriptComments $sample) -notmatch $escapedUniValuePattern) {
throw "导航源扫描漏检 Uni 对象逃逸:$sample"
}
}
$sfcUrlSample = '<template><text>https://example.test/a</text><button @click="uni.navigateTo({ url: target })"></button></template>'
if ((Get-ScannableSource $sfcUrlSample '.vue') -notmatch '\bnavigateTo\b') {
throw '导航源扫描把 template 的 https:// 误判为注释并漏掉后续真实调用'
}
$nestedTemplateSample = '<template><template v-if="ready"><text>安全内容</text></template><button @click="uni.redirectTo({ url: target })"></button></template>'
if ((Get-ScannableSource $nestedTemplateSample '.vue') -notmatch '\bredirectTo\b') {
throw '导航源扫描在嵌套 template 结束标签处提前停止'
}
$templateLiteralUrlSample = 'const url = `https://example.test/a`; uni.reLaunch({ url })'
if ((Remove-JavaScriptComments $templateLiteralUrlSample) -notmatch '\breLaunch\b') {
throw '导航源扫描把模板字符串中的 https:// 误判为行注释'
}
# 这是任务 3—9 的临时、精确迁移债务账本:每迁移一个文件就必须同轮删除
# 对应行并收紧计数;任务 10 必须把该列表清空,而不是保留长期白名单。
$expectedMigrationDebt = @()
$excludedDirectories = @('.agents', '.git', '.hbuilderx', 'design-pipeline', 'docs', 'node_modules', 'static', 'tests', 'unpackage')
$productionFiles = @(
Get-ChildItem -LiteralPath $root -File |
Where-Object { $_.Extension -in @('.vue', '.js') }
foreach ($directory in Get-ChildItem -LiteralPath $root -Directory | Where-Object { $_.Name -notin $excludedDirectories }) {
Get-ChildItem -LiteralPath $directory.FullName -Recurse -File |
Where-Object { $_.Extension -in @('.vue', '.js') }
}
)
$sourceFiles = @($productionFiles | Where-Object {
$relativePath = $_.FullName.Substring($root.Length + 1).Replace('\', '/')
$relativePath -match '^(components|pages)/'
})
foreach ($file in $productionFiles) {
$content = Get-ScannableSource ([System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)) $file.Extension
$relativePath = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
if ($content -match $declarativeNavigationPattern) {
throw "声明式 navigator 绕过了唯一导航网关:$relativePath"
}
if ($content -match $dynamicUniAccessPattern) {
throw "动态 Uni 属性或反射调用绕过了可审计导航合同:$relativePath"
}
if ($relativePath -ne 'utils/navigation.js' -and $content -match $escapedUniValuePattern) {
throw "Uni 对象被作为值传递或保存,无法静态审计导航调用:$relativePath"
}
}
$actualMigrationDebt = @()
$switchTabOwners = @()
foreach ($file in $sourceFiles) {
$content = Get-ScannableSource ([System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)) $file.Extension
$relativePath = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
$navigateToCount = [regex]::Matches($content, '\bnavigateTo\b').Count
$navigateBackCount = [regex]::Matches($content, '\bnavigateBack\b').Count
$redirectToCount = [regex]::Matches($content, '\bredirectTo\b').Count
$reLaunchCount = [regex]::Matches($content, '\breLaunch\b').Count
$navigationCount = $navigateToCount + $navigateBackCount + $redirectToCount + $reLaunchCount
$stackCount = [regex]::Matches($content, $stackReferencePattern).Count
$routeLiteralCount = [regex]::Matches($content, '/pages/').Count
if ($content -match $switchTabReferencePattern) { $switchTabOwners += $relativePath }
if (($navigationCount + $stackCount + $routeLiteralCount) -gt 0) {
$actualMigrationDebt += "$relativePath|$navigateToCount|$navigateBackCount|$redirectToCount|$reLaunchCount|$stackCount|$routeLiteralCount"
}
}
$actualMigrationDebt = @($actualMigrationDebt | Sort-Object)
if ($switchTabOwners.Count -gt 0) {
throw "项目没有原生 tabBar,不得调用 uni.switchTab$($switchTabOwners -join ', ')"
}
if (($actualMigrationDebt -join "`n") -ne ($expectedMigrationDebt -join "`n")) {
$unexpected = @($actualMigrationDebt | Where-Object { $_ -notin $expectedMigrationDebt })
$missing = @($expectedMigrationDebt | Where-Object { $_ -notin $actualMigrationDebt })
throw "导航迁移债务漂移;新增/计数变化:$($unexpected -join ', ');已消失但未收紧:$($missing -join ', ')"
}
foreach ($file in $productionFiles | Where-Object {
$_.FullName.Substring($root.Length + 1).Replace('\', '/') -notmatch '^(components|pages)/'
}) {
$content = Get-ScannableSource ([System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)) $file.Extension
$relativePath = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
$hasNavigationRuntime = $content -match "$navigationReferencePattern|$stackReferencePattern"
$hasRouteLiteral = $content.Contains('/pages/')
if ($content -match $switchTabReferencePattern) {
throw "项目没有原生 tabBarutils 也不得调用 uni.switchTab$relativePath"
}
if ($hasNavigationRuntime -and $relativePath -ne 'utils/navigation.js') {
throw "导航网关外的 utils 文件调用 Uni 导航或页面栈:$relativePath"
}
if ($hasRouteLiteral -and $relativePath -ne 'utils/navigation-routes.js') {
throw "路由注册表外的 utils 文件保存业务页面路径:$relativePath"
}
}
$fallbackOwners = @(
$productionFiles | Where-Object {
(Get-ScannableSource ([System.IO.File]::ReadAllText($_.FullName, [System.Text.Encoding]::UTF8)) $_.Extension) -match 'fallbackUrl|fallback-url'
} | ForEach-Object { $_.FullName.Substring($root.Length + 1).Replace('\', '/') }
)
if ($fallbackOwners.Count -gt 0) {
throw "旧 fallbackUrl 合同必须全局删除:$($fallbackOwners -join ', ')"
}
Write-Output "NAVIGATION-SOURCE-SCAN-CONTRACT PASS MIGRATION-DEBT=$($actualMigrationDebt.Count)"
+45 -13
View File
@@ -2,15 +2,15 @@ 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/notification/n02-message-detail?id=review-1', '.notice-state--ready'],
['/pages/profile/m02-edit-profile', '.profile-state--ready'],
['/pages/profile/m03-security-settings', '.device-state--safe'],
['/pages/profile/m03-security-settings', '.device-state--limited'],
['/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/m08-promotion', '.share-state--unavailable'],
['/pages/profile/m09-vip-orders', '.order-state--unavailable'],
['/pages/profile/m10-about-settings', '.about-page'],
]
@@ -62,15 +62,18 @@ const run = async () => {
}
}
await open('/pages/notification/n02-message-detail', '.notice-state--ready')
await open('/pages/notification/n02-message-detail?id=review-1', '.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/notification/n02-message-detail?id=missing', '.notice-state--expired')
assert.strictEqual(await valueOf(send, `Boolean(document.querySelector('.action-stack'))`), false, 'N02 unknown notice exposed business actions')
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 open('/pages/profile/m03-security-settings', '.device-state--limited')
await valueOf(send, `document.querySelector('.security-row').click()`)
await waitFor(send, `location.hash.includes('/pages/profile/m04-change-password')`, 'M03 password navigation failed')
@@ -88,19 +91,48 @@ const run = async () => {
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')
assert.strictEqual(await valueOf(send, `document.querySelectorAll('.field-error').length`), 1, 'M07 must only require feedbackContent')
await valueOf(send, `(() => {
document.querySelector('.type-chip').click()
const content = document.querySelector('textarea')
content.value = 'runtime feedback'
content.dispatchEvent(new Event('input', { bubbles: true }))
const contact = document.querySelector('.contact-row input')
contact.value = 'runtime@example.com'
contact.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.page-content > .app-button').click()
return true
})()`)
await waitFor(send, `document.querySelector('.feedback-result--error')?.textContent.includes('本地预览模式')`, 'M07 must fail closed instead of faking feedback submission in mock mode')
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/m08-promotion', '.share-state--unavailable')
assert.strictEqual(await valueOf(send, `Boolean(document.querySelector('.copy-action, .invite-code'))`), false, 'M08 exposed an invitation code action')
assert.strictEqual(await valueOf(send, `document.body.innerText.includes('TN20260720')`), false, 'M08 exposed the retired fake invitation code')
await valueOf(send, `document.querySelector('.page-content > .app-button').click()`)
await waitFor(send, `document.querySelector('.app-dialog__title')?.textContent.includes('功能尚未接入')`, 'M08 unavailable explanation 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/m09-vip-orders', '.order-state--unavailable')
assert.strictEqual(await valueOf(send, `Boolean(document.querySelector('.order-card'))`), false, 'M09 exposed an order card')
assert.strictEqual(await valueOf(send, `document.body.innerText.includes('O20260720001') || document.body.innerText.includes('¥0.00') || document.body.innerText.includes('演示记录')`), false, 'M09 exposed retired fake order data')
await valueOf(send, `document.querySelector('.page-content > .app-button').click()`)
await waitFor(send, `document.querySelector('.app-dialog__title')?.textContent.includes('付费服务尚未开放')`, 'M09 unavailable 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')
await valueOf(send, `document.querySelector('.app-dialog__actions .app-button').click()`)
await waitFor(send, `!document.querySelector('.app-dialog')`, 'M10 agreement dialog did not close')
await valueOf(send, `(() => {
uni.setStorageSync('jiapu_token', 'runtime-token')
uni.setStorageSync('jiapu_current_genealogy_id', '1001')
document.querySelector('.page-content > .app-button').click()
return true
})()`)
await waitFor(send, `document.querySelector('.app-dialog__title')?.textContent.includes('确认退出登录')`, 'M10 logout confirmation missing')
await valueOf(send, `document.querySelector('.app-dialog__actions .app-button:last-child').click()`)
await waitFor(send, `location.hash === '#/pages/auth/a01-entry' && Boolean(document.querySelector('.login-page'))`, 'M10 logout did not goRoot A01')
assert.strictEqual(await valueOf(send, `uni.getStorageSync('jiapu_token')`), '', 'M10 logout did not clear the session token')
assert.strictEqual(await valueOf(send, `uni.getStorageSync('jiapu_current_genealogy_id')`), '', 'M10 logout did not clear the genealogy context')
process.stdout.write('NM-BUSINESS-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
+144 -8
View File
@@ -1,29 +1,165 @@
$ErrorActionPreference = 'Stop'
$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/notification/n01-message-center.vue' = @('listNotificationFixtures', 'notices', 'markAllRead', 'openNotice', 'openPage(', '"N02",', '"G10",')
'pages/notification/n02-message-detail.vue' = @('findNotificationFixture', 'noticeDetail', 'markAsRead', 'openNoticeTarget', 'targetType', 'targetParams', 'notice-state--expired', 'notice-state--ready')
'pages/profile/m01-profile-home.vue' = @('menuItems', 'routeKey', 'openPage(', '"M02"', '"N01"')
'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/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--limited', 'checkSecurity', 'routeKey: "M04"', 'routeKey: "M05"')
'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/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'savePhone', 'phone-state--saving')
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport', 'openPage("M07", {}, "M06")')
'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/m08-promotion.vue' = @('inviteState', 'openInviteExplanation', 'share-state--unavailable', 'explanationVisible')
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orderState', 'order-state--unavailable', 'openServiceNotice')
'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')) {
foreach ($forbidden in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', 'getCurrentPages(', '/pages/')) {
if ($source.Contains($forbidden)) { throw "$($entry.Key) contains forbidden token: $forbidden" }
}
if ($entry.Key -ne 'pages/profile/m07-feedback.vue' -and $source.Contains('@/utils/api.js')) {
throw "$($entry.Key) must not consume the API before its independent interface batch"
}
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" }
}
$sources = @{}
foreach ($path in $contracts.Keys) {
$sources[$path] = Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding UTF8
}
$n01 = $sources['pages/notification/n01-message-center.vue']
$n02 = $sources['pages/notification/n02-message-detail.vue']
$m02 = $sources['pages/profile/m02-edit-profile.vue']
$m03 = $sources['pages/profile/m03-security-settings.vue']
$m08 = $sources['pages/profile/m08-promotion.vue']
$m10 = $sources['pages/profile/m10-about-settings.vue']
if ($n01 -notmatch '(?s)openPage\(\s*"N02",\s*\{ id: String\(item\.id\) \},\s*"N01",?\s*\)') {
throw 'N01 must open N02 with only its registered lexical notice identity'
}
if ($n01 -notmatch '(?s)genealogyId\.value\s*\?\s*openPage\(\s*"G10",\s*\{ genealogyId: genealogyId\.value \},\s*"N01",?\s*\)') {
throw 'N01 review entry must fail closed without genealogyId and use the gateway'
}
foreach ($source in @($n01, $n02)) {
foreach ($forbiddenTarget in @('detailId', 'target: "/pages/', 'targetUrl', 'returnUrl', 'const noticeDetails')) {
if ($source.Contains($forbiddenTarget)) { throw "Notification pages retain a duplicate identity, owner, or raw target: $forbiddenTarget" }
}
}
if ($n02 -match 'noticeDetail\.value\.target(?![A-Za-z0-9_$])') {
throw 'N02 retains an executable target field'
}
if ($n02.Contains('noticeDetail.read') -or -not $n02.Contains('noticeDetail.unread')) {
throw 'N02 must consume the notification owner unread field instead of creating a duplicate read field'
}
if ($n02 -notmatch '(?s)const selectedNotice = findNotificationFixture\(noticeId\.value\);.*?if \(.*?!selectedNotice.*?\) \{.*?noticeState\.value = "expired";.*?return;') {
throw 'N02 unknown notice identity must fail closed instead of reusing a default notice'
}
foreach ($required in @(
'getGenealogyFixtureAccess',
'targetError',
'navigateNoticeTarget(',
'noticeDetail.value.targetType',
'noticeDetail.value.targetParams'
)) {
if (-not $n02.Contains($required)) { throw "N02 safe target flow missing: $required" }
}
foreach ($formPath in @(
'pages/profile/m02-edit-profile.vue',
'pages/profile/m04-change-password.vue',
'pages/profile/m05-change-phone.vue',
'pages/profile/m07-feedback.vue'
)) {
$source = $sources[$formPath]
foreach ($required in @(
'custom-back',
'@back="requestBack"',
'createDiscardConfirmation',
'const isDirty = computed(',
'runBackGuard({',
'dirty: isDirty.value',
'submitting:',
'onBackPress((event) => handleBackPress(event, requestBack));',
'discardConfirmation.dispose();',
':close-on-mask="false"'
)) {
if (-not $source.Contains($required)) { throw "$formPath back-guard contract missing: $required" }
}
}
foreach ($previewContract in @(
@{ Path = 'pages/profile/m02-edit-profile.vue'; Retired = @('个人资料已保存') },
@{ Path = 'pages/profile/m04-change-password.vue'; Retired = @('密码已修改', 'passwordForm.current = ""', 'passwordForm.next = ""', 'passwordForm.confirm = ""') },
@{ Path = 'pages/profile/m05-change-phone.vue'; Retired = @('演示验证码已发送', '绑定手机号已更新', 'phoneForm.currentPhone =') }
)) {
$source = $sources[$previewContract.Path]
if (-not $source.Contains('尚未提交服务器')) {
throw "$($previewContract.Path) must identify local validation as unsaved"
}
foreach ($retired in $previewContract.Retired) {
if ($source.Contains($retired)) { throw "$($previewContract.Path) retains fake server success: $retired" }
}
}
foreach ($dialogPath in @(
'pages/profile/m08-promotion.vue',
'pages/profile/m09-vip-orders.vue',
'pages/profile/m10-about-settings.vue'
)) {
$source = $sources[$dialogPath]
foreach ($required in @(
'custom-back',
'@back="requestBack"',
'runBackGuard({',
'transientOpen:',
'onBackPress((event) => handleBackPress(event, requestBack));',
':close-on-mask="false"'
)) {
if (-not $source.Contains($required)) { throw "$dialogPath transient back-guard contract missing: $required" }
}
}
foreach ($retiredInviteCopy in @('仍需管理员审核才能加入', '家人提交加入申请', '管理员核实并审核', 'inviteCode', 'setClipboardData', 'posterVisible', 'generatePoster')) {
if ($m08.Contains($retiredInviteCopy)) { throw "M08 retains retired invitation review semantics: $retiredInviteCopy" }
}
foreach ($directInviteCopy in @('邀请码校验成功后直接加入', '不会生成入谱审核记录')) {
if (-not $m08.Contains($directInviteCopy)) { throw "M08 direct-join contract missing: $directInviteCopy" }
}
foreach ($unavailableInviteCopy in @('当前没有可用邀请码', '尚未接入邀请码校验')) {
if (-not $m08.Contains($unavailableInviteCopy)) { throw "M08 unavailable boundary missing: $unavailableInviteCopy" }
}
if (-not $m03.Contains('currentUser.phone') -or $m03.Contains('139****6421')) {
throw 'M03 must consume the unique current-user phone instead of a second hard-coded identity'
}
if (-not $m02.Contains('currentUser.name') -or $m02.Contains('nickName: "汤文远"')) {
throw 'M02 must initialize editable identity from the unique current-user owner'
}
$m09 = $sources['pages/profile/m09-vip-orders.vue']
foreach ($forbiddenOrderPreview in @('query.state', '演示订单', '¥0.00', 'order-state--ready')) {
if ($m09.Contains($forbiddenOrderPreview)) { throw "M09 must not fabricate an order before API integration: $forbiddenOrderPreview" }
}
if ($m10 -notmatch 'import \{ session \} from "@/utils/session\.js";') {
throw 'M10 must consume the unique session owner'
}
if ($m10 -notmatch '(?s)const confirmLogout = \(\) => \{\s*session\.clear\(\);\s*logoutVisible\.value = false;\s*return goRoot\("A01"\);\s*\};') {
throw 'M10 logout order must be session.clear -> close dialog -> goRoot(A01)'
}
if (([regex]::Matches($m10, 'session\.clear\(\)')).Count -ne 1) {
throw 'M10 cancellation or back paths must never clear the session'
}
if ($m10 -notmatch 'import manifest from "@/manifest\.json";' -or $m10.Contains('const appVersion = "1.0.0"')) {
throw 'M10 must display the version from manifest.json instead of duplicating release metadata'
}
Write-Output 'NM-PAGE-BUSINESS-CONTRACT PASS'
@@ -0,0 +1,53 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const run = async () => {
const source = fs.readFileSync(path.join(__dirname, "../data/mock.js"), "utf8");
const contractSource = fs.readFileSync(
path.join(__dirname, "../utils/genealogy-contracts.js"),
"utf8",
);
const contractUrl = `data:text/javascript;base64,${Buffer.from(contractSource).toString("base64")}`;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source.replace("../utils/genealogy-contracts.js", contractUrl)).toString("base64")}`;
const {
findNotificationFixture,
listNotificationFixtures,
notifications,
} = await import(moduleUrl);
assert.deepStrictEqual(
notifications.map((notice) => notice.id),
["review-1", "approved"],
"notification owner must use one lexical identity for list and detail",
);
for (const notice of notifications) {
assert.strictEqual(typeof notice.id, "string");
assert.strictEqual(typeof notice.unread, "boolean");
assert.strictEqual(Object.prototype.hasOwnProperty.call(notice, "read"), false);
assert.strictEqual(typeof notice.targetType, "string");
assert.strictEqual(typeof notice.targetParams?.genealogyId, "string");
assert.strictEqual(Object.prototype.hasOwnProperty.call(notice, "target"), false);
assert.strictEqual(Object.prototype.hasOwnProperty.call(notice, "detailId"), false);
}
assert.strictEqual(findNotificationFixture("missing"), null);
assert.strictEqual(findNotificationFixture("__proto__"), null);
const listSnapshot = listNotificationFixtures();
listSnapshot[0].title = "changed";
listSnapshot[0].targetParams.genealogyId = "changed";
assert.notStrictEqual(findNotificationFixture("review-1").title, "changed");
assert.notStrictEqual(
findNotificationFixture("review-1").targetParams.genealogyId,
"changed",
);
process.stdout.write("NOTIFICATION-FIXTURE-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
@@ -0,0 +1,205 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$issues = New-Object System.Collections.Generic.List[string]
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-Operation {
param([string]$Path)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty -or -not $pathProperty.Value.get) {
Add-Issue "JSON missing GET $Path"
return $null
}
return $pathProperty.Value.get
}
function Get-ResponseRef {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return '' }
$response = $Operation.responses.PSObject.Properties['200'].Value
if (-not $response) {
Add-Issue "JSON $Label missing 200 response"
return ''
}
if ($response.'$ref') {
$responseName = ([string]$response.'$ref').Split('/')[-1]
$response = $document.components.responses.PSObject.Properties[$responseName].Value
}
$media = @($response.content.PSObject.Properties)
if ($media.Count -eq 0) {
Add-Issue "JSON $Label missing response content"
return ''
}
return [string]$media[0].Value.schema.'$ref'
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Assert-SecurityAndClient {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
$hasSaToken = $false
foreach ($requirement in @($Operation.security)) {
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { Add-Issue "JSON $Label must require SaToken" }
$client = @($Operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
if ($client.Count -ne 1 -or $client[0].required -ne $true -or $client[0].schema.type -ne 'string') {
Add-Issue "JSON $Label must require one string clientid header"
}
}
function Assert-Required {
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
if (-not $Schema) { return }
foreach ($field in $Fields) {
if ($field -notin @($Schema.required)) {
Add-Issue "JSON $SchemaName.required missing: $field"
}
}
}
$listPath = '/genealogy/app/notifications'
$countPath = '/genealogy/app/notifications/unread-count'
$listOperation = Get-Operation $listPath
$countOperation = Get-Operation $countPath
Assert-SecurityAndClient $listOperation "GET $listPath"
Assert-SecurityAndClient $countOperation "GET $countPath"
$listRef = Get-ResponseRef $listOperation "GET $listPath"
if ($listRef -ne '#/components/schemas/RListNotificationVo') {
Add-Issue "JSON GET $listPath must return RListNotificationVo; actual: $listRef"
}
$countRef = Get-ResponseRef $countOperation "GET $countPath"
if ($countRef -ne '#/components/schemas/RNotificationUnreadCount') {
Add-Issue "JSON GET $countPath must return RNotificationUnreadCount; actual: $countRef"
}
$listEnvelope = Get-Schema 'RListNotificationVo'
$notice = Get-Schema 'NotificationVo'
$countEnvelope = Get-Schema 'RNotificationUnreadCount'
Assert-Required $listEnvelope 'RListNotificationVo' @('code', 'data')
Assert-Required $notice 'NotificationVo' @('noticeTitle', 'noticeContent', 'publishTime', 'readStatus')
Assert-Required $countEnvelope 'RNotificationUnreadCount' @('code', 'data')
$listSemantics = ([string]$listOperation.description) + ' ' + ([string]$listEnvelope.properties.data.description)
if ($listSemantics -notmatch '(?i)newest[ -]?first' -or
$listSemantics -notmatch '(?i)complete active notification set' -or
$listSemantics -notmatch '(?i)at most 200') {
Add-Issue 'JSON notification list must document newest-first, complete active-set, and at-most-200 semantics'
}
$countSemantics = ([string]$countOperation.description) + ' ' + ([string]$countEnvelope.properties.data.description)
if ($countSemantics -notmatch '(?i)same active notification set' -or
$countSemantics -notmatch '(?i)readStatus=UNREAD') {
Add-Issue 'JSON unread count must document the same active-set and readStatus=UNREAD scope'
}
if ($listEnvelope) {
if ($listEnvelope.properties.code.type -ne 'integer') {
Add-Issue 'JSON RListNotificationVo.code must be integer'
}
$data = $listEnvelope.properties.data
if ($data.type -ne 'array' -or $data.items.'$ref' -ne '#/components/schemas/NotificationVo') {
Add-Issue 'JSON RListNotificationVo.data must be NotificationVo[]'
}
if ([int]$data.maxItems -lt 1 -or [int]$data.maxItems -gt 200) {
Add-Issue 'JSON RListNotificationVo.data must declare maxItems between 1 and 200'
}
}
if ($notice) {
foreach ($field in @('noticeTitle', 'noticeContent')) {
if ($notice.properties.$field.type -ne 'string') {
Add-Issue "JSON NotificationVo.$field must be string"
}
if ([int]$notice.properties.$field.minLength -lt 1) {
Add-Issue "JSON NotificationVo.$field must declare minLength >= 1"
}
}
if ([int]$notice.properties.noticeTitle.maxLength -lt 1 -or [int]$notice.properties.noticeTitle.maxLength -gt 50) {
Add-Issue 'JSON NotificationVo.noticeTitle must declare maxLength between 1 and 50'
}
if ([int]$notice.properties.noticeContent.maxLength -lt 1 -or [int]$notice.properties.noticeContent.maxLength -gt 1000) {
Add-Issue 'JSON NotificationVo.noticeContent must declare maxLength between 1 and 1000'
}
$contentDescription = [string]$notice.properties.noticeContent.description
if ($contentDescription -notmatch '(?i)plain[ -]?text' -or $contentDescription -notmatch '(?i)complete|untruncated') {
Add-Issue 'JSON NotificationVo.noticeContent must declare complete, untruncated plain-text semantics'
}
$publishTime = $notice.properties.publishTime
if ($publishTime.type -ne 'string' -or $publishTime.format -ne 'date-time') {
Add-Issue 'JSON NotificationVo.publishTime must be date-time string'
}
$timePattern = [string]$publishTime.pattern
try {
$timeRegex = [regex]::new($timePattern)
if (-not $timePattern -or
-not $timeRegex.IsMatch('2026-07-22T21:00:00Z') -or
-not $timeRegex.IsMatch('2026-07-22T21:00:00.123+08:00') -or
$timeRegex.IsMatch('2026-07-22T21:00:00')) {
Add-Issue 'JSON NotificationVo.publishTime pattern must require an RFC3339 timezone suffix'
}
} catch {
Add-Issue 'JSON NotificationVo.publishTime pattern must be a valid regular expression'
}
$readStatus = $notice.properties.readStatus
$readValues = @($readStatus.enum | Sort-Object)
if ($readStatus.type -ne 'string' -or ($readValues -join ',') -ne 'READ,UNREAD') {
Add-Issue 'JSON NotificationVo.readStatus must be enum READ/UNREAD'
}
}
if ($countEnvelope) {
if ($countEnvelope.properties.code.type -ne 'integer') {
Add-Issue 'JSON RNotificationUnreadCount.code must be integer'
}
$countData = $countEnvelope.properties.data
if ($countData.type -ne 'integer' -or $countData.format -ne 'int32' -or
[int64]$countData.minimum -ne 0 -or [int64]$countData.maximum -ne 200) {
Add-Issue 'JSON RNotificationUnreadCount.data must be int32 in range 0..200'
}
}
foreach ($yamlFact in @(
' /genealogy/app/notifications:',
' /genealogy/app/notifications/unread-count:',
'#/components/schemas/RListNotificationVo',
'#/components/schemas/RNotificationUnreadCount',
' NotificationVo:',
' RListNotificationVo:',
' RNotificationUnreadCount:',
' - noticeTitle',
' - noticeContent',
' - publishTime',
' - readStatus'
)) {
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- The first adapter drops every server ID, senderPhone, and biz target field; N02 reads only the current immutable in-memory snapshot.')
$lines.Add('- The service must document newest-first ordering, the active retention/window policy, and count/list scope consistency.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'NOTIFICATION-READ-OPENAPI-CONTRACT PASS'
@@ -0,0 +1,134 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$issues = New-Object System.Collections.Generic.List[string]
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-Post {
param([string]$Path)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty -or -not $pathProperty.Value.post) {
Add-Issue "JSON missing POST $Path"
return $null
}
return $pathProperty.Value.post
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Assert-SecurityAndClient {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
$hasSaToken = $false
foreach ($requirement in @($Operation.security)) {
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { Add-Issue "JSON $Label must require SaToken" }
$client = @($Operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
if ($client.Count -ne 1 -or $client[0].required -ne $true -or $client[0].schema.type -ne 'string') {
Add-Issue "JSON $Label must require one string clientid header"
}
}
function Get-ResponseRef {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return '' }
$response = $Operation.responses.PSObject.Properties['200'].Value
if (-not $response) {
Add-Issue "JSON $Label missing 200 response"
return ''
}
if ($response.'$ref') {
$responseName = ([string]$response.'$ref').Split('/')[-1]
$response = $document.components.responses.PSObject.Properties[$responseName].Value
}
$media = @($response.content.PSObject.Properties)
if ($media.Count -eq 0) {
Add-Issue "JSON $Label missing response content"
return ''
}
return [string]$media[0].Value.schema.'$ref'
}
$singlePath = '/genealogy/app/notifications/{notificationId}/read'
$allPath = '/genealogy/app/notifications/read-all'
$notificationIdPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
$single = Get-Post $singlePath
$all = Get-Post $allPath
Assert-SecurityAndClient $single "POST $singlePath"
Assert-SecurityAndClient $all "POST $allPath"
if ((Get-ResponseRef $single "POST $singlePath") -ne '#/components/schemas/RVoid') {
Add-Issue "JSON POST $singlePath must return RVoid"
}
if ((Get-ResponseRef $all "POST $allPath") -ne '#/components/schemas/RVoid') {
Add-Issue "JSON POST $allPath must return RVoid"
}
if ($single) {
$ids = @($single.parameters | Where-Object { $_.name -eq 'notificationId' -and $_.in -eq 'path' })
if ($ids.Count -ne 1 -or $ids[0].required -ne $true -or $ids[0].schema.type -ne 'string' -or
$ids[0].schema.minLength -ne 1 -or $ids[0].schema.maxLength -ne 128 -or
$ids[0].schema.pattern -ne $notificationIdPattern) {
Add-Issue 'JSON notificationId path must be a required 1..128 URL-safe opaque string'
}
if ($single.requestBody) { Add-Issue 'JSON mark-read operation must not accept a request body' }
}
if ($all -and $all.requestBody) { Add-Issue 'JSON read-all operation must not accept a request body' }
$void = Get-Schema 'RVoid'
if ($void) {
if ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer') {
Add-Issue 'JSON RVoid must require integer code'
}
}
$notice = Get-Schema 'NotificationVo'
if ($notice) {
if ('notificationId' -notin @($notice.required)) {
Add-Issue 'JSON NotificationVo.required missing: notificationId'
}
$id = $notice.properties.notificationId
if ($id.type -ne 'string' -or [int]$id.minLength -ne 1 -or [int]$id.maxLength -ne 128 -or
$id.pattern -ne $notificationIdPattern) {
Add-Issue 'JSON NotificationVo.notificationId must be the same 1..128 URL-safe opaque string'
}
}
foreach ($yamlFact in @(
' /genealogy/app/notifications/{notificationId}/read:',
' /genealogy/app/notifications/read-all:',
'#/components/schemas/RVoid',
' NotificationVo:',
' RVoid:',
' - notificationId',
' - code'
)) {
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Both operations must be idempotent for the current account; repeated calls return success without duplicate side effects.')
$lines.Add('- The backend must define cross-account 403/404 behavior, read-all cutoff semantics, concurrent new-message behavior, and count/list refresh rules.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'NOTIFICATION-READ-STATE-OPENAPI-CONTRACT PASS'
@@ -0,0 +1,519 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const LOSSLESS_NUMBER = Symbol("openapi-parity-number");
// JSON/YAML 的 number 语义不等于 JavaScript 的 IEEE-754 Number。统一归一化为
// “有效数字 e 十进制指数”,可让 1、1.0、10e-1 等价,同时无损区分任意大整数。
const normalizeNumber = (source) => {
const match = /^(-?)(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(source);
if (!match) throw new Error(`Invalid JSON/YAML number: ${source}`);
const fraction = match[3] || "";
let digits = `${match[2]}${fraction}`.replace(/^0+/, "");
if (!digits) return "0e0";
let exponent = BigInt(match[4] || "0") - BigInt(fraction.length);
const trailingZeros = digits.match(/0+$/)?.[0].length || 0;
if (trailingZeros) {
digits = digits.slice(0, -trailingZeros);
exponent += BigInt(trailingZeros);
}
return `${match[1] ? "-" : ""}${digits}e${exponent}`;
};
const createLosslessNumber = (source) =>
Object.freeze({ [LOSSLESS_NUMBER]: normalizeNumber(source) });
const getLosslessNumber = (value) =>
value && typeof value === "object" ? value[LOSSLESS_NUMBER] : undefined;
const losslessNumberToPlainString = (canonical) => {
const match = /^(-?)(\d+)e(-?\d+)$/.exec(canonical);
if (!match) throw new Error(`Invalid canonical number: ${canonical}`);
const exponentValue = BigInt(match[3]);
if (exponentValue > 10000n || exponentValue < -10000n) {
throw new Error(`YAML numeric mapping key exponent is too large: ${canonical}`);
}
const exponent = Number(exponentValue);
const sign = match[1];
const digits = match[2];
if (exponent >= 0) return `${sign}${digits}${"0".repeat(exponent)}`;
const point = digits.length + exponent;
return point > 0
? `${sign}${digits.slice(0, point)}.${digits.slice(point)}`
: `${sign}0.${"0".repeat(-point)}${digits}`;
};
// 后端会同时交付 JSON 与 YAML。这个解析器只实现当前 OpenAPI 导出实际使用的
// YAML 1.2 子集,但不依赖固定缩进、键是否加引号或字段顺序;遇到锚点、tag、
// tab 缩进等未声明语法会立即失败,不能把无法理解的 YAML 当成“已经一致”。
const parseYamlScalar = (source) => {
const value = source.trim();
if (value === "[]") return [];
if (value === "{}") return {};
if (value === "null" || value === "~") return null;
if (value === "true") return true;
if (value === "false") return false;
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) {
return createLosslessNumber(value);
}
if (value.startsWith('"')) {
try {
return JSON.parse(value);
} catch (error) {
throw new Error(`Invalid double-quoted YAML scalar: ${value}`);
}
}
if (value.startsWith("'")) {
if (!value.endsWith("'")) throw new Error(`Invalid single-quoted YAML scalar: ${value}`);
return value.slice(1, -1).replace(/''/g, "'");
}
if (/^[!&*]/.test(value)) {
throw new Error(`Unsupported YAML tag/anchor/alias: ${value}`);
}
if (findMappingColon(value) >= 0) {
throw new Error(`Invalid YAML plain scalar containing a mapping separator: ${value}`);
}
return value;
};
const stripInlineComment = (source) => {
let singleQuoted = false;
let doubleQuoted = false;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (doubleQuoted && escaped) {
escaped = false;
continue;
}
if (doubleQuoted && character === "\\") {
escaped = true;
continue;
}
if (!doubleQuoted && character === "'") {
if (singleQuoted && source[index + 1] === "'") {
index += 1;
continue;
}
singleQuoted = !singleQuoted;
continue;
}
if (!singleQuoted && character === '"') {
doubleQuoted = !doubleQuoted;
continue;
}
if (!singleQuoted && !doubleQuoted && character === "#" && (index === 0 || /\s/.test(source[index - 1]))) {
return source.slice(0, index).trimEnd();
}
}
return source.trimEnd();
};
const findMappingColon = (source) => {
let singleQuoted = false;
let doubleQuoted = false;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (doubleQuoted && escaped) {
escaped = false;
continue;
}
if (doubleQuoted && character === "\\") {
escaped = true;
continue;
}
if (!doubleQuoted && character === "'") {
if (singleQuoted && source[index + 1] === "'") {
index += 1;
continue;
}
singleQuoted = !singleQuoted;
continue;
}
if (!singleQuoted && character === '"') {
doubleQuoted = !doubleQuoted;
continue;
}
if (
!singleQuoted &&
!doubleQuoted &&
character === ":" &&
(index === source.length - 1 || /\s/.test(source[index + 1]))
) {
return index;
}
}
return -1;
};
const parseYamlKey = (source) => {
const key = parseYamlScalar(source);
const numericKey = getLosslessNumber(key);
if (numericKey !== undefined) return losslessNumberToPlainString(numericKey);
if (typeof key !== "string" && typeof key !== "number") {
throw new Error(`YAML mapping key must be scalar: ${source}`);
}
return String(key);
};
const foldBlockLines = (lines) => {
let result = "";
for (let index = 0; index < lines.length; index += 1) {
const current = lines[index];
if (index === 0) {
result = current;
continue;
}
const previous = lines[index - 1];
result += previous === "" || current === "" ? `\n${current}` : ` ${current}`;
}
return result;
};
const applyBlockChomping = (value, indicator) => {
if (indicator.endsWith("-")) return value.replace(/\n+$/, "");
if (indicator.endsWith("+")) return value;
return `${value.replace(/\n+$/, "")}\n`;
};
const parseOpenApiYaml = (source) => {
const rawLines = source.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n");
const lines = rawLines.map((raw, index) => {
if (/^\t+/.test(raw) || /^ +\t/.test(raw)) {
throw new Error(`YAML tab indentation is not supported at line ${index + 1}`);
}
const indent = raw.match(/^ */)[0].length;
return { raw, indent, content: stripInlineComment(raw.slice(indent)), line: index + 1 };
});
const nextSignificant = (start) => {
let index = start;
while (index < lines.length && lines[index].content.trim() === "") index += 1;
return index;
};
const parseBlockScalar = (lineIndex, parentIndent, indicator) => {
let end = lineIndex + 1;
while (end < lines.length) {
const line = lines[end];
if (line.raw.trim() !== "" && line.indent <= parentIndent) break;
end += 1;
}
const block = lines.slice(lineIndex + 1, end);
const nonBlank = block.filter((line) => line.raw.trim() !== "");
const contentIndent = nonBlank.length
? Math.min(...nonBlank.map((line) => line.indent))
: parentIndent + 1;
if (contentIndent <= parentIndent) {
throw new Error(`Invalid YAML block indentation at line ${lines[lineIndex].line}`);
}
const values = block.map((line) => (line.raw.trim() === "" ? "" : line.raw.slice(contentIndent)));
const rawValue = indicator.startsWith(">") ? foldBlockLines(values) : values.join("\n");
return { value: applyBlockChomping(rawValue, indicator), next: end };
};
const parsePair = (content, lineIndex, entryIndent) => {
const colon = findMappingColon(content);
if (colon < 0) throw new Error(`Missing YAML mapping colon at line ${lines[lineIndex].line}`);
const key = parseYamlKey(content.slice(0, colon).trim());
const rawValue = content.slice(colon + 1).trim();
if (/^[>|][+-]?$/.test(rawValue)) {
const parsed = parseBlockScalar(lineIndex, entryIndent, rawValue);
return { key, value: parsed.value, next: parsed.next };
}
if (rawValue !== "") {
return { key, value: parseYamlScalar(rawValue), next: lineIndex + 1 };
}
const childIndex = nextSignificant(lineIndex + 1);
if (childIndex < lines.length && lines[childIndex].indent > entryIndent) {
const parsed = parseBlock(childIndex, lines[childIndex].indent);
return { key, value: parsed.value, next: parsed.next };
}
return { key, value: null, next: lineIndex + 1 };
};
const parseMap = (start, indent) => {
const result = {};
let index = start;
while (index < lines.length) {
index = nextSignificant(index);
if (index >= lines.length || lines[index].indent < indent) break;
if (lines[index].indent > indent) {
throw new Error(`Unexpected YAML indentation at line ${lines[index].line}`);
}
const content = lines[index].content.trim();
if (content.startsWith("-")) break;
const parsed = parsePair(content, index, indent);
if (Object.prototype.hasOwnProperty.call(result, parsed.key)) {
throw new Error(`Duplicate YAML key '${parsed.key}' at line ${lines[index].line}`);
}
result[parsed.key] = parsed.value;
index = parsed.next;
}
return { value: result, next: index };
};
const parseSequence = (start, indent) => {
const result = [];
let index = start;
while (index < lines.length) {
index = nextSignificant(index);
if (index >= lines.length || lines[index].indent < indent) break;
if (lines[index].indent > indent) {
throw new Error(`Unexpected YAML sequence indentation at line ${lines[index].line}`);
}
const content = lines[index].content.trim();
if (!content.startsWith("-") || (content.length > 1 && !/\s/.test(content[1]))) break;
const item = content.slice(1).trim();
if (item === "") {
const childIndex = nextSignificant(index + 1);
if (childIndex >= lines.length || lines[childIndex].indent <= indent) {
result.push(null);
index += 1;
} else {
const parsed = parseBlock(childIndex, lines[childIndex].indent);
result.push(parsed.value);
index = parsed.next;
}
continue;
}
if (findMappingColon(item) < 0) {
result.push(parseYamlScalar(item));
index += 1;
continue;
}
const object = {};
const first = parsePair(item, index, indent + 2);
object[first.key] = first.value;
index = first.next;
const continuation = nextSignificant(index);
if (continuation < lines.length && lines[continuation].indent === indent + 2 && !lines[continuation].content.trim().startsWith("-")) {
const parsed = parseMap(continuation, indent + 2);
for (const [key, value] of Object.entries(parsed.value)) {
if (Object.prototype.hasOwnProperty.call(object, key)) {
throw new Error(`Duplicate YAML sequence-object key '${key}' at line ${lines[continuation].line}`);
}
object[key] = value;
}
index = parsed.next;
}
result.push(object);
}
return { value: result, next: index };
};
function parseBlock(start, indent) {
const index = nextSignificant(start);
if (index >= lines.length) return { value: null, next: index };
if (lines[index].indent !== indent) {
throw new Error(`Expected YAML indentation ${indent} at line ${lines[index].line}`);
}
return lines[index].content.trim().startsWith("-")
? parseSequence(index, indent)
: parseMap(index, indent);
}
const start = nextSignificant(0);
const parsed = parseBlock(start, lines[start]?.indent || 0);
if (nextSignificant(parsed.next) < lines.length) {
throw new Error(`Unparsed YAML content begins at line ${lines[nextSignificant(parsed.next)].line}`);
}
return parsed.value;
};
// Node 没有内置的 lossless JSON 解析器。这个小型递归下降解析器只实现 RFC 8259,
// 但把 number token 直接交给同一个任意精度归一化 owner,避免 JSON.parse 先行舍入。
const parseJsonLossless = (source) => {
let index = 0;
const fail = (message) => {
throw new Error(`${message} at JSON offset ${index}`);
};
const skipWhitespace = () => {
while (index < source.length && /[\u0020\u000a\u000d\u0009]/.test(source[index])) index += 1;
};
const parseString = () => {
const start = index;
index += 1;
while (index < source.length) {
const character = source[index];
if (character === '"') {
index += 1;
try {
return JSON.parse(source.slice(start, index));
} catch (error) {
fail("Invalid JSON string");
}
}
if (character === "\\") {
index += 2;
} else {
index += 1;
}
}
fail("Unterminated JSON string");
};
const parseNumber = () => {
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(source.slice(index));
if (!match) fail("Invalid JSON number");
index += match[0].length;
return createLosslessNumber(match[0]);
};
const parseLiteral = (literal, value) => {
if (source.slice(index, index + literal.length) !== literal) fail(`Invalid JSON literal`);
index += literal.length;
return value;
};
const parseArray = () => {
const result = [];
index += 1;
skipWhitespace();
if (source[index] === "]") {
index += 1;
return result;
}
while (index < source.length) {
result.push(parseValue());
skipWhitespace();
if (source[index] === "]") {
index += 1;
return result;
}
if (source[index] !== ",") fail("Expected ',' or ']' in JSON array");
index += 1;
skipWhitespace();
}
fail("Unterminated JSON array");
};
const parseObject = () => {
const result = Object.create(null);
index += 1;
skipWhitespace();
if (source[index] === "}") {
index += 1;
return result;
}
while (index < source.length) {
if (source[index] !== '"') fail("JSON object key must be a string");
const key = parseString();
if (Object.prototype.hasOwnProperty.call(result, key)) fail(`Duplicate JSON key '${key}'`);
skipWhitespace();
if (source[index] !== ":") fail("Expected ':' after JSON object key");
index += 1;
result[key] = parseValue();
skipWhitespace();
if (source[index] === "}") {
index += 1;
return result;
}
if (source[index] !== ",") fail("Expected ',' or '}' in JSON object");
index += 1;
skipWhitespace();
}
fail("Unterminated JSON object");
};
function parseValue() {
skipWhitespace();
const character = source[index];
if (character === "{") return parseObject();
if (character === "[") return parseArray();
if (character === '"') return parseString();
if (character === "t") return parseLiteral("true", true);
if (character === "f") return parseLiteral("false", false);
if (character === "n") return parseLiteral("null", null);
if (character === "-" || /\d/.test(character || "")) return parseNumber();
fail("Unexpected JSON token");
}
const value = parseValue();
skipWhitespace();
if (index !== source.length) fail("Unexpected trailing JSON content");
return value;
};
const firstDifference = (left, right, trail = "$") => {
const leftNumber = getLosslessNumber(left);
const rightNumber = getLosslessNumber(right);
if (leftNumber !== undefined || rightNumber !== undefined) {
return leftNumber === rightNumber
? null
: `${trail}: number ${leftNumber ?? "<non-number>"} != ${rightNumber ?? "<non-number>"}`;
}
if (Object.is(left, right)) return null;
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right)) return `${trail}: type mismatch`;
if (left.length !== right.length) return `${trail}: array length ${left.length} != ${right.length}`;
for (let index = 0; index < left.length; index += 1) {
const difference = firstDifference(left[index], right[index], `${trail}[${index}]`);
if (difference) return difference;
}
return null;
}
if (left && right && typeof left === "object" && typeof right === "object") {
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
if (leftKeys.length !== rightKeys.length || leftKeys.some((key, index) => key !== rightKeys[index])) {
return `${trail}: keys ${leftKeys.join(",")} != ${rightKeys.join(",")}`;
}
for (const key of leftKeys) {
const difference = firstDifference(left[key], right[key], `${trail}.${key}`);
if (difference) return difference;
}
return null;
}
return `${trail}: ${JSON.stringify(left)} != ${JSON.stringify(right)}`;
};
// 先用与 OpenAPI 导出相同的关键 YAML 形状验证解析器本身,防止 parity 检查因
// quoted key、非二空格缩进、对象数组或 block scalar 变化而静默误判。
const fixture = parseOpenApiYaml(`
openapi: 3.1.0
paths:
"/items/{id}":
get:
security:
- SaToken: []
responses:
200:
description: >-
first line
second line
headers: {}
components:
schemas:
Sample:
type: object
required:
- id
properties:
id:
type: string # lexical identity
`);
assert.strictEqual(fixture.openapi, "3.1.0");
assert.deepStrictEqual(fixture.paths["/items/{id}"].get.security, [{ SaToken: [] }]);
assert.strictEqual(fixture.paths["/items/{id}"].get.responses["200"].description, "first line second line");
assert.deepStrictEqual(fixture.components.schemas.Sample.required, ["id"]);
assert.throws(() => parseOpenApiYaml("openapi:3.0.1\n"), /mapping colon/);
assert.throws(() => parseOpenApiYaml("description: a: b\n"), /plain scalar/);
// JavaScript Number 会把这两个相邻的大整数舍入为同一个值;parity 合同必须先证明
// 自己能够发现这类双导出漂移,才能检查包含 int64 示例的真实 OpenAPI。
const unsafeIntegerDifference = firstDifference(
parseOpenApiYaml("id: 2060000000000000001\n"),
parseJsonLossless('{"id":2060000000000000000}'),
);
assert.match(unsafeIntegerDifference || "", /^\$\.id:/);
assert.strictEqual(
firstDifference(
parseOpenApiYaml("value: 1.00e2\n"),
parseJsonLossless('{"value":100}'),
),
null,
);
const yamlDocument = parseOpenApiYaml(fs.readFileSync(path.join(root, "APP.openapi.yaml"), "utf8"));
const jsonDocument = parseJsonLossless(fs.readFileSync(path.join(root, "APP.openapi.json"), "utf8"));
const difference = firstDifference(yamlDocument, jsonDocument);
if (difference) throw new Error(`Protected OpenAPI JSON/YAML semantic drift: ${difference}`);
console.log("OPENAPI-YAML-JSON-PARITY PASS");
+259
View File
@@ -0,0 +1,259 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$issues = New-Object System.Collections.Generic.List[string]
$passwordPath = '/genealogy/app/auth/password'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Get-Response {
param([object]$Operation, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON PUT $passwordPath missing $Status response"
return $null
}
$response = $property.Value
if ($response.'$ref') {
$name = ([string]$response.'$ref').Split('/')[-1]
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON missing response owner: $name"
return $null
}
$response = $owner.Value
}
return $response
}
function Get-JsonSchemaRef {
param([object]$Response, [string]$Status)
if (-not $Response) { return '' }
$media = $Response.content.PSObject.Properties['application/json']
if (-not $media) {
Add-Issue "JSON PUT $passwordPath $Status must use application/json"
return ''
}
return [string]$media.Value.schema.'$ref'
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON PUT $passwordPath $Status must document Cache-Control: private, no-store"
return
}
$header = $property.Value
if ($header.'$ref') {
$name = ([string]$header.'$ref').Split('/')[-1]
$owner = $document.components.headers.PSObject.Properties[$name]
if ($owner) { $header = $owner.Value }
}
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
Add-Issue "JSON PUT $passwordPath $Status Cache-Control must specify private, no-store"
}
}
function Assert-SecretSchema {
param(
[object]$Schema,
[string]$Name,
[int]$Minimum,
[int]$Maximum
)
if (-not $Schema) { return }
if ($Schema.type -ne 'string' -or $Schema.format -ne 'password' -or $Schema.writeOnly -ne $true -or
[int]$Schema.minLength -ne $Minimum -or [int]$Schema.maxLength -ne $Maximum) {
Add-Issue "JSON $Name must be a writeOnly password string of $Minimum..$Maximum Unicode code points"
}
if ($Schema.pattern -or $Schema.example -or ([string]$Schema.description) -match '(?i)MD5|hex|字母.*数字|数字.*字母') {
Add-Issue "JSON $Name must not retain a static digest, composition rule, pattern, or password example"
}
if (([string]$Schema.description) -notmatch '(?i)Unicode code point' -or
([string]$Schema.description) -notmatch '(?i)NFC' -or
([string]$Schema.description) -notmatch '(?i)(space|空格)') {
Add-Issue "JSON $Name must define Unicode code-point length, NFC normalization, and space handling"
}
}
function Assert-FieldRef {
param([string]$SchemaName, [string]$Field, [string]$ExpectedRef)
$schema = Get-Schema $SchemaName
if (-not $schema) { return }
$property = $schema.properties.PSObject.Properties[$Field]
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
if ($actual -ne $ExpectedRef) {
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
}
}
$pathProperty = $document.paths.PSObject.Properties[$passwordPath]
$operation = if ($pathProperty) { $pathProperty.Value.put } else { $null }
if (-not $operation) { Add-Issue "JSON missing PUT $passwordPath" }
if ($operation) {
$hasSaToken = $false
foreach ($requirement in @($operation.security)) {
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { Add-Issue "JSON PUT $passwordPath must require SaToken" }
$clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or
$clientHeaders[0].schema.type -ne 'string' -or [int]$clientHeaders[0].schema.minLength -lt 1) {
Add-Issue "JSON PUT $passwordPath must require one non-empty string clientid header"
}
$requestMedia = $operation.requestBody.content.PSObject.Properties['application/json']
if ($operation.requestBody.required -ne $true -or -not $requestMedia) {
Add-Issue "JSON PUT $passwordPath must require an application/json body"
} elseif ($requestMedia.Value.schema.'$ref' -ne '#/components/schemas/PasswordChangeBody') {
Add-Issue 'JSON password change request must use PasswordChangeBody'
}
$semantics = [string]$operation.description
foreach ($semanticPattern in @(
'(?i)current password.*re-authentication',
'(?i)atomic.*password.*credential epoch',
'(?i)all.*access.*refresh.*sessions.*including.*current',
'(?i)200.*sessions.*invalidated',
'(?i)new password.*different.*current password',
'(?i)(common|breached) password.*blocklist',
'(?i)rate limit'
)) {
if ($semantics -notmatch $semanticPattern) {
Add-Issue "JSON PUT $passwordPath description is missing security/session semantics: $semanticPattern"
}
}
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
if (-not $operation.responses.PSObject.Properties[$status]) {
Add-Issue "JSON PUT $passwordPath missing documented response: $status"
}
}
}
$changeBody = Get-Schema 'PasswordChangeBody'
$currentSecret = Get-Schema 'CurrentPasswordSecret'
$newSecret = Get-Schema 'NewPasswordSecret'
Assert-SecretSchema $currentSecret 'CurrentPasswordSecret' 1 64
Assert-SecretSchema $newSecret 'NewPasswordSecret' 15 64
if ($changeBody) {
$properties = @($changeBody.properties.PSObject.Properties.Name | Sort-Object)
$required = @($changeBody.required | Sort-Object)
if ($changeBody.type -ne 'object' -or $changeBody.additionalProperties -ne $false -or
($properties -join ',') -ne 'newPassword,oldPassword' -or
($required -join ',') -ne 'newPassword,oldPassword') {
Add-Issue 'JSON PasswordChangeBody must be a closed object requiring only oldPassword/newPassword'
}
}
# 密码传输是跨登录、注册、找回和登录态改密的单一合同。禁止只让 M04 改成明文,
# 其余入口继续接受可重放摘要;新合同落地时必须一次删除全部 MD5 wire fallback。
Assert-FieldRef 'PasswordLoginBody' 'password' '#/components/schemas/CurrentPasswordSecret'
Assert-FieldRef 'PasswordRegisterBody' 'password' '#/components/schemas/NewPasswordSecret'
Assert-FieldRef 'PasswordResetBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
Assert-FieldRef 'PasswordChangeBody' 'oldPassword' '#/components/schemas/CurrentPasswordSecret'
Assert-FieldRef 'PasswordChangeBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
$responses = @{}
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
$responses[$status] = Get-Response $operation $status
[void](Get-JsonSchemaRef $responses[$status] $status)
Assert-PrivateNoStore $responses[$status] $status
}
if ((Get-JsonSchemaRef $responses['200'] '200') -ne '#/components/schemas/RVoid') {
Add-Issue 'JSON PUT password 200 must return RVoid after all sessions are invalidated'
}
foreach ($status in @('409', '422')) {
if ((Get-JsonSchemaRef $responses[$status] $status) -ne '#/components/schemas/RPasswordChangeRejected') {
Add-Issue "JSON PUT password $status must return RPasswordChangeRejected"
}
}
$retryAfterProperty = if ($responses['429'] -and $responses['429'].headers) {
$responses['429'].headers.PSObject.Properties['Retry-After']
} else { $null }
if (-not $retryAfterProperty) {
Add-Issue 'JSON PUT password 429 must document Retry-After'
}
$void = Get-Schema 'RVoid'
$rejected = Get-Schema 'RPasswordChangeRejected'
if ($void -and ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer')) {
Add-Issue 'JSON RVoid must require integer code'
}
if ($rejected) {
foreach ($field in @('code', 'businessCode')) {
if ($field -notin @($rejected.required)) { Add-Issue "JSON RPasswordChangeRejected.required missing: $field" }
}
$codes = @($rejected.properties.businessCode.enum | Sort-Object)
$expectedCodes = @(
'CREDENTIAL_VERSION_CONFLICT',
'CURRENT_PASSWORD_INCORRECT',
'NEW_PASSWORD_SAME_AS_CURRENT',
'PASSWORD_POLICY_VIOLATION'
) | Sort-Object
if ($rejected.properties.code.type -ne 'integer' -or
$rejected.properties.businessCode.type -ne 'string' -or
($codes -join ',') -ne ($expectedCodes -join ',')) {
Add-Issue 'JSON RPasswordChangeRejected must expose the four stable conflict/validation business codes'
}
}
foreach ($yamlFact in @(
' /genealogy/app/auth/password:',
' name: clientid',
'#/components/schemas/PasswordChangeBody',
'#/components/schemas/CurrentPasswordSecret',
'#/components/schemas/NewPasswordSecret',
'#/components/schemas/RPasswordChangeRejected',
' CurrentPasswordSecret:',
' NewPasswordSecret:',
' writeOnly: true',
' minLength: 15',
' maxLength: 64',
' RPasswordChangeRejected:',
' - CREDENTIAL_VERSION_CONFLICT',
' - CURRENT_PASSWORD_INCORRECT',
' - NEW_PASSWORD_SAME_AS_CURRENT',
' - PASSWORD_POLICY_VIOLATION',
' Cache-Control:',
' Retry-After:'
)) {
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Remove static MD5 from login/register/reset/change in one contract migration; accept raw writeOnly passwords only over authenticated HTTPS and store a salted adaptive server-side hash.')
$lines.Add('- The target new-password policy is 15..64 Unicode code points, NFC, spaces allowed, no composition rule, plus server-side common/breached-password blocklist and rate limiting.')
$lines.Add('- A strict 200 means the password is durable and every pre-existing access/refresh session, including the caller, is invalidated; the client clears locally and returns to A01.')
$lines.Add('- Network, timeout, malformed response, or 5xx after dispatch is outcome-unknown: clear secrets/session, return to A01, and never retry automatically or claim success.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'PASSWORD-CHANGE-OPENAPI-CONTRACT PASS'
+350
View File
@@ -0,0 +1,350 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$issues = New-Object System.Collections.Generic.List[string]
$phonePath = '/genealogy/app/auth/phone'
$sendPath = '/genealogy/app/auth/phone/sms/code'
$publicSendPath = '/genealogy/app/auth/sms/code'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Get-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
$operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null }
if (-not $operation) {
Add-Issue "JSON missing $($Method.ToUpperInvariant()) $Path"
return $null
}
return $operation.Value
}
function Assert-SaTokenOnly {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
$requirements = @($Operation.security)
$valid = $requirements.Count -eq 1 -and
$requirements[0].PSObject.Properties.Count -eq 1 -and
$requirements[0].PSObject.Properties.Name -contains 'SaToken'
if (-not $valid) { Add-Issue "JSON $Label must require SaToken without an anonymous alternative" }
}
function Assert-ClientIdHeader {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
$headers = @($Operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
if ($headers.Count -ne 1 -or $headers[0].required -ne $true -or
$headers[0].schema.type -ne 'string' -or [int]$headers[0].schema.minLength -lt 1) {
Add-Issue "JSON $Label must require one non-empty string clientid header"
}
}
function Assert-RequestBody {
param([object]$Operation, [string]$Label, [string]$ExpectedRef)
if (-not $Operation) { return }
$media = $Operation.requestBody.content.PSObject.Properties['application/json']
if ($Operation.requestBody.required -ne $true -or -not $media) {
Add-Issue "JSON $Label must require an application/json body"
} elseif ([string]$media.Value.schema.'$ref' -ne $ExpectedRef) {
Add-Issue "JSON $Label request body must use $ExpectedRef"
}
}
function Get-Response {
param([object]$Operation, [string]$Label, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON $Label missing $Status response"
return $null
}
$response = $property.Value
if ($response.'$ref') {
$name = ([string]$response.'$ref').Split('/')[-1]
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON missing response owner: $name"
return $null
}
$response = $owner.Value
}
return $response
}
function Get-JsonSchemaRef {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return '' }
$media = $Response.content.PSObject.Properties['application/json']
if (-not $media) {
Add-Issue "JSON $Label $Status must use application/json"
return ''
}
return [string]$media.Value.schema.'$ref'
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
return
}
$header = $property.Value
if ($header.'$ref') {
$name = ([string]$header.'$ref').Split('/')[-1]
$owner = $document.components.headers.PSObject.Properties[$name]
if ($owner) { $header = $owner.Value }
}
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
Add-Issue "JSON $Label $Status Cache-Control must specify private, no-store"
}
}
function Assert-FieldRef {
param([string]$SchemaName, [string]$Field, [string]$ExpectedRef)
$schema = Get-Schema $SchemaName
if (-not $schema) { return }
$property = $schema.properties.PSObject.Properties[$Field]
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
if ($actual -ne $ExpectedRef) {
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
}
}
$phoneOperation = Get-Operation $phonePath 'put'
$sendOperation = Get-Operation $sendPath 'post'
$publicSendOperation = Get-Operation $publicSendPath 'post'
Assert-SaTokenOnly $phoneOperation "PUT $phonePath"
Assert-SaTokenOnly $sendOperation "POST $sendPath"
Assert-ClientIdHeader $phoneOperation "PUT $phonePath"
Assert-ClientIdHeader $sendOperation "POST $sendPath"
Assert-RequestBody $phoneOperation "PUT $phonePath" '#/components/schemas/PhoneChangeBody'
Assert-RequestBody $sendOperation "POST $sendPath" '#/components/schemas/PhoneChangeSmsCodeBody'
if ($publicSendOperation) {
$publicBodyRef = [string]$publicSendOperation.requestBody.content.'application/json'.schema.'$ref'
if ($publicBodyRef -ne '#/components/schemas/SmsCodeBody') {
Add-Issue "JSON POST $publicSendPath must keep SmsCodeBody as the public-scene owner"
}
}
$phoneBody = Get-Schema 'PhoneChangeBody'
$sendBody = Get-Schema 'PhoneChangeSmsCodeBody'
$newPhone = Get-Schema 'NewBoundPhone'
$smsSecret = Get-Schema 'SmsCodeSecret'
if ($phoneBody) {
$properties = @($phoneBody.properties.PSObject.Properties.Name | Sort-Object)
$required = @($phoneBody.required | Sort-Object)
if ($phoneBody.type -ne 'object' -or $phoneBody.additionalProperties -ne $false -or
($properties -join ',') -ne 'currentPassword,phone,smsCode' -or
($required -join ',') -ne 'currentPassword,phone,smsCode') {
Add-Issue 'JSON PhoneChangeBody must be a closed object requiring only currentPassword/phone/smsCode'
}
}
if ($sendBody) {
$properties = @($sendBody.properties.PSObject.Properties.Name | Sort-Object)
$required = @($sendBody.required | Sort-Object)
if ($sendBody.type -ne 'object' -or $sendBody.additionalProperties -ne $false -or
($properties -join ',') -ne 'phone,validToken' -or
($required -join ',') -ne 'phone,validToken') {
Add-Issue 'JSON PhoneChangeSmsCodeBody must be a closed object requiring only phone/validToken; scene, client and tenant come from the protected operation context'
}
if (-not $sendBody.properties.validToken -or $sendBody.properties.validToken.type -ne 'string' -or
$sendBody.properties.validToken.writeOnly -ne $true -or [int]$sendBody.properties.validToken.minLength -lt 1) {
Add-Issue 'JSON PhoneChangeSmsCodeBody.validToken must be a non-empty writeOnly string'
}
}
if ($newPhone) {
if ($newPhone.type -ne 'string' -or $newPhone.writeOnly -ne $true -or
[int]$newPhone.minLength -ne 11 -or [int]$newPhone.maxLength -ne 11 -or
[string]$newPhone.pattern -ne '^1[3-9][0-9]{9}$' -or $newPhone.example) {
Add-Issue 'JSON NewBoundPhone must be a writeOnly canonical 11-digit mainland mobile number without an example'
}
}
if ($smsSecret) {
if ($smsSecret.type -ne 'string' -or $smsSecret.writeOnly -ne $true -or
[int]$smsSecret.minLength -ne 6 -or [int]$smsSecret.maxLength -ne 6 -or
[string]$smsSecret.pattern -ne '^[0-9]{6}$' -or $smsSecret.example) {
Add-Issue 'JSON SmsCodeSecret must be exactly six ASCII decimal digits, preserve leading zero, be writeOnly, and expose no example'
}
$smsDescription = [string]$smsSecret.description
foreach ($pattern in @('(?i)CSPRNG', '(?i)leading zero', '(?i)single-use', '(?i)5 minutes', '(?i)5 failed')) {
if ($smsDescription -notmatch $pattern) {
Add-Issue "JSON SmsCodeSecret description is missing OTP security semantics: $pattern"
}
}
}
Assert-FieldRef 'PhoneChangeBody' 'currentPassword' '#/components/schemas/CurrentPasswordSecret'
Assert-FieldRef 'PhoneChangeBody' 'phone' '#/components/schemas/NewBoundPhone'
Assert-FieldRef 'PhoneChangeSmsCodeBody' 'phone' '#/components/schemas/NewBoundPhone'
# 短信码是跨登录、注册、找回、换绑和注销的单一 wire 合同。升级为六位时必须一次.
# 删除所有四位入口,不能让同一服务端生成器长期同时接受 4/6 位密码等价物..
$otpSchemaNames = @(
'SmsLoginBody'
'PasswordRegisterBody'
'PasswordResetBody'
'PhoneChangeBody'
'AccountDeactivateBody'
)
foreach ($schemaName in $otpSchemaNames) {
Assert-FieldRef $schemaName 'smsCode' '#/components/schemas/SmsCodeSecret'
}
$publicSmsBody = Get-Schema 'SmsCodeBody'
if ($publicSmsBody) {
$scenes = @($publicSmsBody.properties.sceneCode.enum | Sort-Object)
$expectedScenes = @('APP_ACCOUNT_DEACTIVATE', 'APP_FORGOT_PASSWORD', 'APP_REGISTER', 'APP_SMS_LOGIN') | Sort-Object
if (($scenes -join ',') -ne ($expectedScenes -join ',')) {
Add-Issue 'JSON public SmsCodeBody.sceneCode must exclude APP_PHONE_CHANGE and retain only the four public/shared scenes'
}
}
if ($sendOperation) {
$semantics = [string]$sendOperation.description
foreach ($pattern in @(
'(?i)scene.*APP_PHONE_CHANGE.*server'
'(?i)validToken.*tenant.*client.*phone'
'(?i)account.*session.*credentialEpoch'
'(?i)one active.*generation'
'(?i)resend.*invalidates.*previous'
'(?i)60 seconds'
'(?i)failed attempt.*not reset'
'(?i)rate limit'
)) {
if ($semantics -notmatch $pattern) {
Add-Issue "JSON POST $sendPath description is missing protected send semantics: $pattern"
}
}
}
if ($phoneOperation) {
$semantics = [string]$phoneOperation.description
foreach ($pattern in @(
'(?i)current password.*re-authentication'
'(?i)single transaction.*phone.*sms.*credentialEpoch'
'(?i)unique.*phone'
'(?i)all.*access.*refresh.*including.*current'
'(?i)old phone.*outbox'
'(?i)200.*sessions.*invalidated'
'(?i)no password.*STEP_UP_UNAVAILABLE'
)) {
if ($semantics -notmatch $pattern) {
Add-Issue "JSON PUT $phonePath description is missing identity/session semantics: $pattern"
}
}
}
$operations = @(
[pscustomobject]@{ Operation = $sendOperation; Label = "POST $sendPath" },
[pscustomobject]@{ Operation = $phoneOperation; Label = "PUT $phonePath" }
)
foreach ($entry in $operations) {
if (-not $entry.Operation) { continue }
$responses = @{}
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
$responses[$status] = Get-Response $entry.Operation $entry.Label $status
[void](Get-JsonSchemaRef $responses[$status] $entry.Label $status)
Assert-PrivateNoStore $responses[$status] $entry.Label $status
}
if ((Get-JsonSchemaRef $responses['200'] $entry.Label '200') -ne '#/components/schemas/RVoid') {
Add-Issue "JSON $($entry.Label) 200 must return RVoid"
}
foreach ($status in @('409', '422', '429')) {
if ((Get-JsonSchemaRef $responses[$status] $entry.Label $status) -ne '#/components/schemas/RPhoneChangeRejected') {
Add-Issue "JSON $($entry.Label) $status must return RPhoneChangeRejected"
}
}
$retryAfter = if ($responses['429'] -and $responses['429'].headers) {
$responses['429'].headers.PSObject.Properties['Retry-After']
} else { $null }
if (-not $retryAfter) { Add-Issue "JSON $($entry.Label) 429 must document Retry-After" }
}
$void = Get-Schema 'RVoid'
$rejected = Get-Schema 'RPhoneChangeRejected'
if ($void -and ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer')) {
Add-Issue 'JSON RVoid must require integer code'
}
if ($rejected) {
foreach ($field in @('code', 'businessCode')) {
if ($field -notin @($rejected.required)) { Add-Issue "JSON RPhoneChangeRejected.required missing: $field" }
}
$codes = @($rejected.properties.businessCode.enum | Sort-Object)
$expectedCodes = @(
'CREDENTIAL_VERSION_CONFLICT'
'CURRENT_PASSWORD_INCORRECT'
'NEW_PHONE_SAME_AS_CURRENT'
'PHONE_ALREADY_BOUND'
'SEND_RATE_LIMITED'
'SMS_CODE_ATTEMPTS_EXCEEDED'
'SMS_CODE_EXPIRED'
'SMS_CODE_INVALID'
'STEP_UP_UNAVAILABLE'
'VERIFICATION_REQUIRED'
) | Sort-Object
if ($rejected.properties.code.type -ne 'integer' -or
$rejected.properties.businessCode.type -ne 'string' -or
($codes -join ',') -ne ($expectedCodes -join ',')) {
Add-Issue 'JSON RPhoneChangeRejected must expose the ten stable send/change business codes'
}
}
foreach ($yamlFact in @(
' /genealogy/app/auth/phone/sms/code:'
' /genealogy/app/auth/phone:'
'#/components/schemas/PhoneChangeSmsCodeBody'
'#/components/schemas/PhoneChangeBody'
'#/components/schemas/CurrentPasswordSecret'
'#/components/schemas/NewBoundPhone'
'#/components/schemas/SmsCodeSecret'
'#/components/schemas/RPhoneChangeRejected'
' PhoneChangeSmsCodeBody:'
' NewBoundPhone:'
' SmsCodeSecret:'
' pattern: ^[0-9]{6}$'
' RPhoneChangeRejected:'
' - STEP_UP_UNAVAILABLE'
' - VERIFICATION_REQUIRED'
' Cache-Control:'
' Retry-After:'
)) {
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Split APP_PHONE_CHANGE sending into the authenticated POST /genealogy/app/auth/phone/sms/code operation; keep one shared backend OTP service, and remove this scene from the public sender.')
$lines.Add('- Upgrade every active SMS-code consumer atomically from four digits to one six-ASCII-digit SmsCodeSecret; no 4/6 compatibility window is allowed.')
$lines.Add('- PUT requires the current raw password plus the new-phone OTP; a strict 200 means the phone, OTP consumption, credential epoch, all-session revocation, and old-phone notification outbox are durable.')
$lines.Add('- Timeout, malformed response, 5xx, or process death after final PUT dispatch is outcome-unknown: clear the same-epoch local session, return to A01, and never retry automatically.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'PHONE-CHANGE-OPENAPI-CONTRACT PASS'
+141
View File
@@ -0,0 +1,141 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
$issues = New-Object System.Collections.Generic.List[string]
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Assert-Required {
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
if (-not $Schema) { return }
foreach ($field in $Fields) {
if ($field -notin @($Schema.required)) {
Add-Issue "JSON $SchemaName.required missing: $field"
}
}
}
$path = '/genealogy/app/auth/profile'
$pathProperty = $document.paths.PSObject.Properties[$path]
$operation = if ($pathProperty) { $pathProperty.Value.get } else { $null }
if (-not $operation) {
Add-Issue "JSON missing GET $path"
} else {
$hasSaToken = $false
foreach ($securityRequirement in @($operation.security)) {
if ($securityRequirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
}
if (-not $hasSaToken) { Add-Issue "JSON GET $path must require SaToken" }
$response = $operation.responses.PSObject.Properties['200'].Value
if ($response.'$ref') {
$responseName = ([string]$response.'$ref').Split('/')[-1]
$response = $document.components.responses.PSObject.Properties[$responseName].Value
}
$media = @($response.content.PSObject.Properties)
$responseRef = if ($media.Count -gt 0) { [string]$media[0].Value.schema.'$ref' } else { '' }
if ($responseRef -ne '#/components/schemas/RAppProfileVo') {
Add-Issue "JSON GET $path must return RAppProfileVo; actual: $responseRef"
}
}
$envelope = Get-Schema 'RAppProfileVo'
$profile = Get-Schema 'AppProfileVo'
Assert-Required $envelope 'RAppProfileVo' @('code', 'data')
Assert-Required $profile 'AppProfileVo' @('phone')
if ($envelope) {
if ($envelope.properties.code.type -ne 'integer') {
Add-Issue 'JSON RAppProfileVo.code must be integer'
}
if ($envelope.properties.data.'$ref' -ne '#/components/schemas/AppProfileVo') {
Add-Issue 'JSON RAppProfileVo.data must reference AppProfileVo'
}
}
if ($profile) {
foreach ($field in @('phone', 'nickName', 'realName', 'email')) {
$property = $profile.properties.PSObject.Properties[$field]
if (-not $property) {
Add-Issue "JSON AppProfileVo missing property: $field"
} elseif ($property.Value.type -ne 'string') {
Add-Issue "JSON AppProfileVo.$field must be string"
}
}
$phonePattern = [string]$profile.properties.phone.pattern
try {
$phoneRegex = [regex]::new($phonePattern)
if (-not $phonePattern -or
-not $phoneRegex.IsMatch('13800138000') -or
$phoneRegex.IsMatch('138****8000') -or
$phoneRegex.IsMatch('+8613800138000')) {
Add-Issue 'JSON AppProfileVo.phone must declare the canonical mainland mobile pattern'
}
} catch {
Add-Issue 'JSON AppProfileVo.phone pattern must be a valid regular expression'
}
foreach ($field in @('nickName', 'realName')) {
if ([int]$profile.properties.$field.minLength -lt 1) {
Add-Issue "JSON AppProfileVo.$field must declare minLength >= 1 when present"
}
if ([int]$profile.properties.$field.maxLength -ne 30) {
Add-Issue "JSON AppProfileVo.$field must declare maxLength 30"
}
}
if ([int]$profile.properties.email.minLength -lt 1) {
Add-Issue 'JSON AppProfileVo.email must declare minLength >= 1 when present'
}
if ([int]$profile.properties.email.maxLength -ne 100) {
Add-Issue 'JSON AppProfileVo.email must declare maxLength 100'
}
if ($profile.properties.email.format -ne 'email') {
Add-Issue 'JSON AppProfileVo.email must declare email format'
}
}
foreach ($yamlFact in @(
' /genealogy/app/auth/profile:',
'#/components/schemas/RAppProfileVo',
' RAppProfileVo:',
' AppProfileVo:',
' - code',
' - data',
' - phone',
' nickName:',
' realName:',
' email:'
)) {
if (-not $yaml.Contains($yamlFact)) {
Add-Issue "YAML fact is missing: $yamlFact"
}
}
if ($issues.Count -gt 0) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('PROFILE-OPENAPI-CONTRACT BLOCKED')
foreach ($issue in $issues) { $lines.Add("- $issue") }
$lines.Add('- Only phone is required on the wire. Missing optional profile fields mean unset; present empty or null values are invalid.')
$lines.Add('- The first client adapter immediately replaces raw phone with maskedPhone and projects nickName, realName, and email; numeric userId/avatar are not consumed.')
$lines.Add('- Authenticated release tests must still cover masked display, account switching, 401, malformed data, 5xx, timeout, and cancellation.')
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
throw ($lines -join [Environment]::NewLine)
}
Write-Output 'PROFILE-OPENAPI-CONTRACT PASS'

Some files were not shown because too many files have changed in this diff Show More