完成70%

This commit is contained in:
2026-07-24 07:56:22 +08:00
parent bb6431b319
commit c7f278fe79
92 changed files with 4741 additions and 14440 deletions
@@ -11,7 +11,13 @@ $remoteBusinessOwners = @(
'pages/genealogy/g01-my-genealogies.vue',
'pages/genealogy/g05-genealogy-overview.vue',
'pages/tree/t01-tree-overview.vue',
'pages/profile/m07-feedback.vue'
'pages/tree/t03-member-profile.vue',
'pages/tree/t04-add-relative.vue',
'pages/tree/t05-edit-member.vue',
'pages/tree/t06-edit-relationship.vue',
'pages/profile/m04-change-password.vue',
'pages/profile/m07-feedback.vue',
'pages/profile/m10-about-settings.vue'
)
foreach ($relativePath in $activePaths) {
+6 -1
View File
@@ -107,7 +107,12 @@ foreach ($entry in @(
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) 短信按钮禁用态未关联" }
if ($entry.Key -eq 'A04') {
$requiredDisabled = ':disabled="sendingCode || submitting || cooldownSeconds > 0 || registrationCommitted"'
} else {
$requiredDisabled = ':disabled="sendingCode || submitting || cooldownSeconds > 0"'
}
if (-not $getCode.Value.Contains($requiredDisabled)) { 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) 主提交忙碌态未关联" }
}
+2 -2
View File
@@ -32,8 +32,8 @@ foreach ($relativePath in @(
if ($page.Contains('cooldownSeconds.value -= 1')) {
throw "$relativePath still uses a decrementing cooldown that freezes in background"
}
if ($page -match '(?s)v-model\.trim="phone".{0,300}:disabled="[^"]*cooldownSeconds') {
throw "$relativePath must not lock an empty phone field when a scene cooldown is restored"
if (-not $page.Contains('cooldownSeconds > 0 && phone.length > 0')) {
throw "$relativePath must preserve an editable empty phone field when a scene cooldown is restored"
}
}
+8 -3
View File
@@ -40,7 +40,7 @@ foreach ($entry in $vendorAssets.GetEnumerator()) {
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)')) {
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)', 'this.tac = new window.TAC(config);')) {
Require-Text -Content $component -Text $token -Label 'TacVerification'
}
Reject-Text -Content $component -Text 'config.doSendRequest = this.sendStrictRequest' -Label '失去 renderjs 实例上下文的传输函数'
@@ -52,6 +52,9 @@ Require-Text -Content $adapter -Text 'payload: { track:' -Label 'TAC payload.tra
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 ($customVisual in @('tac-panel', 'tac-heading', 'tac-tool', 'logoUrl:', 'i18n:', 'new window.TAC(config, {')) {
Reject-Text -Content $component -Text $customVisual -Label 'TAC 供应商原生呈现'
}
foreach ($method in @('getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword')) {
Require-Text -Content $api -Text "async $method" -Label '认证 API'
@@ -89,9 +92,11 @@ foreach ($page in @($a01, $a04, $a05)) {
Require-Text -Content $page -Text 'const requestedPhone = phone.value' -Label '认证手机号请求快照'
Require-Text -Content $page -Text 'subject: requestedPhone' -Label '认证手机号请求快照'
$phoneLock = if ($page -eq $a01) {
':disabled="sendingCode || cooldownSeconds > 0 || submitting || tacVisible"'
':disabled="sendingCode || submitting || tacVisible || authenticationCommitted || (cooldownSeconds > 0 && phone.length > 0)"'
} elseif ($page -eq $a04) {
':disabled="sendingCode || submitting || tacVisible || registrationCommitted || (cooldownSeconds > 0 && phone.length > 0)"'
} else {
':disabled="sendingCode || cooldownSeconds > 0 || submitting"'
':disabled="sendingCode || submitting || (cooldownSeconds > 0 && phone.length > 0)"'
}
Require-Text -Content $page -Text $phoneLock -Label '短信流程手机号锁定'
if ([regex]::Matches($page, [regex]::Escape('if (!pageActive) return;')).Count -lt 3) {
+37 -113
View File
@@ -1,128 +1,52 @@
$ErrorActionPreference = 'Stop'
$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()
$apiPath = Join-Path $root 'utils/api.js'
$a01Path = Join-Path $root 'pages/auth/a01-entry.vue'
$runtimePath = Join-Path $PSScriptRoot 'auth-api-runtime-smoke.js'
$issues = New-Object System.Collections.Generic.List[string]
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 ',')]"
foreach ($path in @($apiPath, $a01Path, $runtimePath)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
$issues.Add("missing authentication owner: $path")
}
}
$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 数字字符串。')
if ($issues.Count -eq 0) {
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath $apiPath
$a01 = Get-Content -Raw -Encoding UTF8 -LiteralPath $a01Path
$passwordOwner = [regex]::Match($api, '(?s)async loginWithPassword\(\{ phone, passwordHash \}.*?(?=\s+async loginWithSms)')
if (-not $passwordOwner.Success) {
$issues.Add('missing bounded password login owner')
} else {
foreach ($required in @("url: '/genealogy/app/auth/login'", "grantType: 'password'", 'password: assertPasswordHash(passwordHash)')) {
if (-not $passwordOwner.Value.Contains($required)) { $issues.Add("password login owner missing: $required") }
}
if ($passwordOwner.Value.Contains('validToken')) {
$issues.Add('password login must not upload validToken')
}
}
foreach ($required in @('const preparePasswordLogin = async () =>', 'appApi.loginWithPassword', 'TAC')) {
if (-not $a01.Contains($required)) { $issues.Add("A01 missing password TAC precondition: $required") }
}
$smsOwner = [regex]::Match($api, '(?s)async sendSmsCode\(\{ sceneCode, phone, validToken \}.*?(?=\s+async loginWithPassword)')
if (-not $smsOwner.Success -or -not $smsOwner.Value.Contains('validToken: assertValidToken(validToken)')) {
$issues.Add('SMS owner must remain the sole validToken consumer')
}
}
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,禁止继续内联四位码或接受双长度。")
if ($issues.Count -eq 0) {
$runtimeOutput = @(& node $runtimePath 2>&1)
if ($LASTEXITCODE -ne 0 -or 'AUTH-API-RUNTIME-SMOKE PASS' -notin $runtimeOutput) {
$issues.Add("password and SMS wire runtime smoke failed: $($runtimeOutput -join ' | ')")
}
}
$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)
if ($issues.Count -gt 0) {
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
Write-Output '- Password login is gated by native TAC on the client and must not send validToken. SMS operations consume their own validToken only.'
exit 1
}
Write-Output 'AUTH-TAC-OPENAPI-CONTRACT PASS'
+3 -3
View File
@@ -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', 'getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword')) {
foreach ($method in @('unwrapResponse', 'getCaptchaRequirement', 'sendSmsCode', 'loginWithPassword', 'loginWithSms', 'registerWithPassword', 'resetPassword', 'changePassword', 'logout')) {
if ($api -notmatch [regex]::Escape($method)) {
throw "Missing auth API method: $method"
}
@@ -71,8 +71,8 @@ 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 -notmatch 'import \{ hasRemoteConfig, resolveRuntimeMode, runtimeConfig \}' -or ([regex]::Matches($api, 'requireRemoteAuth\(\)')).Count -ne 7) {
throw '个认证传输必须通过共享运行模式解析器失败关闭'
}
if ($api -match 'if \(isMockMode\(\)\)') {
throw 'Auth transports must not treat every non-mock mode as remote'
+3 -2
View File
@@ -7,7 +7,8 @@ $currentDocuments = @(
'docs/家谱项目全量治理实施计划.md',
'docs/视觉资产与构建基线.md',
'docs/接口与页面映射总表.md',
'docs/今晚全量联调与明早测试执行计划.md'
'docs/今晚全量联调与明早测试执行计划.md',
'docs/产品参考页面功能映射表.md'
)
$combined = ''
@@ -574,7 +575,7 @@ foreach ($g03BootstrapGateFact in @(
'g03-bootstrap-client-release-gate.ps1',
'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED',
'openapi-yaml-json-parity-runtime-smoke.js',
'/genealogy/app/region/search',
'/genealogy/region/search',
'GET 必须纯读',
'fatal/quarantined',
'家谱工作区读取门禁',
@@ -449,6 +449,12 @@
"risk": "fixed-content-height",
"reason": "固定尺寸属于已审核的导航、操作触点或结构化节点视觉边界,不作为正文容量上限"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-node__avatar",
"risk": "fixed-content-height",
"reason": "固定尺寸只约束人物头像图标,不承载成员姓名或关系正文"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-page",
@@ -502,5 +508,23 @@
"selector": ".app-dialog",
"risk": "clipping-overflow",
"reason": "弹窗外框只约束共享九宫格边界,正文容量由内部滚动区域完整承载"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".get-code",
"risk": "single-line-truncation",
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".get-code",
"risk": "single-line-truncation",
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".get-code",
"risk": "single-line-truncation",
"reason": "验证码按钮只显示固定短文案或秒数,单行约束保护输入行触点"
}
]
@@ -74,16 +74,6 @@
"selector": ".media-photo-remove",
"reason": "依附照片缩略图右上角的删除操作"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-sheet",
"reason": "用户选择节点后显示的固定底部详情抽屉"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-sheet__skin",
"reason": "固定底部详情抽屉内部的装饰框层"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".recovery-layer",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -117,8 +117,8 @@ const run = async () => {
"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",
/^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m,
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' }; const fromApiGenealogyAccess = () => GENEALOGY_ACCESS_PRESET.MEMBER_ONLY;\n",
)
.replace(
/^import \{ session \}[^\n]+\r?\n/m,
+25 -118
View File
@@ -3,16 +3,11 @@ $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"
$script:issues.Add("missing required G03 file: $RelativePath")
return ''
}
return Get-Content -Raw -Encoding UTF8 -LiteralPath $path
@@ -21,134 +16,46 @@ function Read-RequiredFile {
function Assert-Contains {
param([string]$Content, [string]$Label, [string]$Expected)
if (-not $Content.Contains($Expected)) {
Add-Issue "$Label missing production ownership marker: $Expected"
$script:issues.Add("$Label missing: $Expected")
}
}
function Assert-DoesNotContain {
param([string]$Content, [string]$Label, [string]$Forbidden)
if ($Content.Contains($Forbidden)) {
Add-Issue "$Label retains retired preview/legacy owner: $Forbidden"
$script:issues.Add("$Label must not claim unsupported remote ownership: $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'
$flowContract = Read-RequiredFile 'tests/g03-create-flow-contract.ps1'
$flowRuntime = Read-RequiredFile 'tests/g03-create-flow-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'
# Apifox only declares two independent writes. There is no recoverable atomic
# bootstrap or result-query operation, so G03 remains an explicit local preview.
Assert-Contains $page 'G03 page' 'flow-success-dialog__copy'
Assert-Contains $page 'G03 page' 'createLocalGenealogyPreview'
Assert-Contains $page 'G03 page' 'updateLocalGenealogyPreviewAncestor'
Assert-Contains $page 'G03 page' 'removeLocalGenealogyPreview'
Assert-Contains $flowContract 'G03 flow contract' 'createLocalGenealogyPreview'
Assert-Contains $flowRuntime 'G03 runtime smoke' 'local-created-'
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 ($forbidden in @(
'createGenealogyBootstrapCoordinator',
'genealogy-bootstrap-operations',
'Idempotency-Key',
'/genealogy/app/region/search',
'requestStrict(',
'appApi.'
)) {
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 ',')"
}
}
}
Assert-DoesNotContain $page 'G03 page' $forbidden
}
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 BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
Write-Output '- Keep the two-step visual preview local until Apifox supplies a recoverable create/result contract with a stable lexical genealogyId.'
exit 1
}
Write-Output 'G03-BOOTSTRAP-CLIENT-RELEASE PASS'
File diff suppressed because it is too large Load Diff
+33 -105
View File
@@ -1,123 +1,51 @@
$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')
$snapshotPath = Join-Path $root 'APP.openapi.json'
$pagePath = Join-Path $root 'pages/profile/m06-help-center.vue'
$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"
if (-not (Test-Path -LiteralPath $snapshotPath -PathType Leaf)) {
$issues.Add('missing protected OpenAPI snapshot')
} else {
$snapshot = Get-Content -Raw -Encoding UTF8 -LiteralPath $snapshotPath | ConvertFrom-Json
$path = '/genealogy/app/help-articles'
$pathProperty = $snapshot.paths.PSObject.Properties[$path]
$operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties['get'] } else { $null }
if (-not $operation) {
$issues.Add("protected snapshot does not declare GET $path")
} else {
$response = $operation.Value.responses.PSObject.Properties['200']
$responseValue = if ($response) { $response.Value } else { $null }
if ($responseValue -and $responseValue.'$ref') {
$responseName = ([string]$responseValue.'$ref').Split('/')[-1]
$responseValue = $snapshot.components.responses.PSObject.Properties[$responseName].Value
}
$media = if ($responseValue -and $responseValue.content) { $responseValue.content.PSObject.Properties['application/json'] } else { $null }
$responseRef = if ($media) { [string]$media.Value.schema.'$ref' } else { '' }
if ($responseRef -eq '#/components/schemas/RListHelpArticleVo') {
$issues.Add('protected snapshot asserts RListHelpArticleVo, while current Apifox only declares a generic ListResult item projection')
}
}
}
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"
if (-not (Test-Path -LiteralPath $pagePath -PathType Leaf)) {
$issues.Add('missing M06 page')
} else {
$hasSaToken = $false
foreach ($securityRequirement in @($operation.security)) {
if ($securityRequirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath $pagePath
foreach ($required in @('help-source-note', 'openPage("M07", {}, "M06")')) {
if (-not $page.Contains($required)) { $issues.Add("M06 page missing local/help fallback: $required") }
}
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"
foreach ($forbidden in @('appApi.getHelp', '/help-articles/', 'helpId')) {
if ($page.Contains($forbidden)) { $issues.Add("M06 page must not invent an independent detail owner: $forbidden") }
}
}
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 BLOCKED'
foreach ($issue in $issues) { Write-Output "- $issue" }
Write-Output '- The list is the only declared remote candidate. Do not add a detail endpoint or consume the protected snapshot schema until Apifox and a real authenticated response agree.'
exit 1
}
Write-Output 'HELP-CENTER-OPENAPI-CONTRACT PASS'
+1 -1
View File
@@ -183,7 +183,7 @@ foreach ($requiredFact in @(
'FAILED_NO_COMMIT',
'G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED',
'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED',
'/genealogy/app/region/search',
'/genealogy/region/search',
'GET 必须纯读',
'receipt→mine cache→context→G05',
'API-G03-001',
+32
View File
@@ -0,0 +1,32 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -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
foreach ($required in @(
'const normalizeLineageWritePayload = (payload) =>',
"const lineageRelationPath = Object.freeze({",
"FATHER: 'parents'",
"SPOUSE: 'spouses'",
"SIBLING: 'siblings'",
"SON: 'children'",
'async createRelatedPerson(genealogyId, personId, relationType, payload, requestOptions = {})',
'async updatePerson(genealogyId, personId, payload, requestOptions = {})',
'method: ''PUT''',
'requestStrict({'
)) {
if (-not $api.Contains($required)) { throw "Lineage Apifox write contract missing: $required" }
}
foreach ($forbidden in @('relationType: relationType.value', 'sex: addForm.gender', 'sortOrder:')) {
if ($t04.Contains($forbidden)) { throw "T04 must not guess unsupported wire field: $forbidden" }
}
foreach ($required in @('appApi.createRelatedPerson(', 'appApi.createPerson(', 'appApi.updatePerson(', 'failedAction.value = "save"')) {
$source = if ($required -eq 'appApi.updatePerson(' -or $required -eq 'failedAction.value = "save"') { $t05 } else { $t04 }
if (-not $source.Contains($required)) { throw "Lineage write page missing: $required" }
}
Write-Output 'LINEAGE-WRITE-APIFOX-CONTRACT PASS'
+23 -163
View File
@@ -1,177 +1,37 @@
$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')
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/profile/m10-about-settings.vue')
$issues = New-Object System.Collections.Generic.List[string]
$logoutPath = '/genealogy/app/auth/logout'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
function Require-Text {
param([string]$Source, [string]$Text, [string]$Description)
if (-not $Source.Contains($Text)) { $script:issues.Add($Description) }
}
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
Require-Text $api 'async logout(requestOptions = {}) {' 'api owner must expose logout'
Require-Text $api "url: '/genealogy/app/auth/logout'" 'logout must use the declared APP logout owner'
Require-Text $api "method: 'DELETE'" 'logout must use DELETE'
Require-Text $api 'requireData: false' 'logout must accept the declared VoidResult envelope'
Require-Text $api 'requestController: requestOptions.requestController ?? null' 'logout must accept page cancellation ownership'
Require-Text $page 'appApi.logout({ requestController: logoutRequestController });' 'M10 must call the shared logout owner'
Require-Text $page 'session.clear();' 'M10 must clear the local session after every request outcome'
Require-Text $page 'return goRoot("A01");' 'M10 must return to A01 after local session clear'
Require-Text $page 'logoutRequestController.abort()' 'M10 must cancel an in-flight request when unloading'
$logoutOwner = [regex]::Match($api, "async logout\(requestOptions = \{\}\) \{[\s\S]*?\n \},\n async ").Value
if (-not $logoutOwner) {
$issues.Add('logout owner boundary is not identifiable')
} elseif ($logoutOwner -match '(?m)^\s*data:') {
$issues.Add('logout must not add a request body')
}
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 ($page -match 'session\.clear\(\);[\s\S]{0,80}appApi\.logout') {
$issues.Add('M10 must not clear local session before starting the remote request')
}
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)
throw ("LOGOUT-OPENAPI-CONTRACT FAIL`n- " + ($issues -join "`n- "))
}
Write-Output 'LOGOUT-OPENAPI-CONTRACT PASS'
+16 -10
View File
@@ -116,7 +116,7 @@ Assert-Matches -Content $a01 -Pattern '(?s)onBackPress\(\(event\) => \{\s*if \(!
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-Matches -Content $a04 -Pattern '(?s)const requestBack = \(\) =>\s*registrationCommitted\.value\s*\? enterAuthenticatedRoot\(\)\s*:\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 只允许一个放弃确认框'
@@ -377,12 +377,14 @@ foreach ($entry in @(
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("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id), mode: "rank" }, "T01")',
'openPage("T07", { genealogyId: genealogyId.value }, "T01")'
)) {
Assert-Contains -Content $t01 -Expected $mapping -Message "T01 缺少规范入口:$mapping"
}
foreach ($required in @('routeKey: "T04"', 'params.relationType = action.relationType;', 'return openPage(action.routeKey, params, "T01");')) {
Assert-Contains -Content $t01 -Expected $required -Message "T01 人物操作面板缺少 T04 关系入口:$required"
}
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 返回必须复用页内轨迹守卫'
@@ -391,16 +393,20 @@ Assert-Contains -Content $t08 -Expected 'goRoot("G01")' -Message 'T08 权限失
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 })' }
@{ Key = 'T04'; Content = $t04; Return = 'returnTo("T01", { genealogyId: genealogyId.value })'; Write = 'appApi.createRelatedPerson(' },
@{ Key = 'T05'; Content = $t05; Return = 'return goBack();'; Write = 'appApi.updatePerson(' }
)) {
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-Contains -Content $form.Content -Expected 'appApi.getPerson(' -Message "$($form.Key) 必须从真实后端读取当前成员"
Assert-Contains -Content $form.Content -Expected 'createRequestController' -Message "$($form.Key) 成员读取必须可取消"
Assert-Contains -Content $form.Content -Expected $form.Write -Message "$($form.Key) 必须使用已核对的远端写入 owner"
Assert-Contains -Content $form.Content -Expected $form.Return -Message "$($form.Key) 写入成功后必须保留规范返回目标"
foreach ($forbidden in @('finishPage(', 'relative-created', 'member-updated', 'relationship-updated', 'setTimeout(')) {
Assert-NotContains -Content $form.Content -Unexpected $forbidden -Message "$($form.Key) 不得在真实写入前伪造结果:$forbidden"
}
}
foreach ($required in @('appApi.getPerson(', 'createRequestController', '"unavailable"', 'goBack()')) {
Assert-Contains -Content $t06 -Expected $required -Message "T06 原子排行合同未收紧时必须显式关闭提交:$required"
}
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 激活结果'
@@ -669,6 +675,6 @@ foreach ($page in @(
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 根语义'
Assert-Matches -Content $m10 -Pattern '(?s)const confirmLogout = async \(\) => \{.*?await appApi\.logout\(\{ requestController: logoutRequestController \}\);.*?finally \{\s*session\.clear\(\);\s*logoutVisible\.value = false;.*?\}\s*return goRoot\("A01"\);\s*\};' -Message 'M10 退出必须先尝试唯一远端 owner,再在所有结果下清理本机会话并进入 A01 根语义'
Write-Output 'NAVIGATION-FLOW-CONTRACT PASS AUTH G01-G12 T01-T08 F01-F10 R01-R11 N01-N02 M01-M10'
+75
View File
@@ -0,0 +1,75 @@
{
"version": 1,
"baselineInventory": {
"powershell": 150,
"node": 54
},
"defaultTimeoutSeconds": 120,
"h5ChromeRuntimeTests": [
"tests/a01-responsive-runtime-smoke.js",
"tests/a04-registration-runtime-smoke.js",
"tests/a05-reset-password-runtime-smoke.js",
"tests/data-driven-layout-runtime-smoke.js",
"tests/f08-album-detail-runtime-smoke.js",
"tests/f09-media-upload-layout-smoke.js",
"tests/f09-media-upload-runtime-smoke.js",
"tests/f10-video-status-runtime-smoke.js",
"tests/f-business-flow-runtime-smoke.js",
"tests/g01-empty-state-runtime-smoke.js",
"tests/g01-switch-dialog-runtime-smoke.js",
"tests/g03-create-flow-runtime-smoke.js",
"tests/g05-overview-runtime-smoke.js",
"tests/g06-search-flow-runtime-smoke.js",
"tests/g08-g10-application-flow-runtime-smoke.js",
"tests/g11-g12-settings-poems-runtime-smoke.js",
"tests/module-page-runtime-smoke.js",
"tests/module-series-responsive-runtime-smoke.js",
"tests/nm-business-runtime-smoke.js",
"tests/r02-background-runtime-smoke.js",
"tests/r02-person-detail-runtime-smoke.js",
"tests/r-business-flow-runtime-smoke.js",
"tests/root-pages-runtime-smoke.js",
"tests/t01-tree-state-runtime-smoke.js",
"tests/t03-t08-business-specialization-runtime-smoke.js",
"tests/t03-t08-member-flow-runtime-smoke.js",
"tests/t07-module-baseline-runtime-smoke.js"
],
"t0": [
"tests/compile-audit.ps1",
"tests/t01-person-action-panel-contract.ps1",
"tests/t04-relative-remote-close-contract.ps1",
"tests/t05-member-remote-close-contract.ps1",
"tests/t06-rank-remote-close-contract.ps1",
"tests/auth-android-accessibility-release-gate.ps1",
"tests/auth-tac-openapi-contract.ps1",
"tests/g03-bootstrap-client-release-gate.ps1",
"tests/g03-bootstrap-openapi-contract.ps1",
"tests/g11-settings-openapi-contract.ps1",
"tests/g12-generation-poem-openapi-contract.ps1",
"tests/genealogy-workspace-openapi-contract.ps1",
"tests/help-center-openapi-contract.ps1",
"tests/invite-ticket-openapi-contract.ps1",
"tests/join-application-openapi-contract.ps1",
"tests/lineage-openapi-contract.ps1",
"tests/notification-read-openapi-contract.ps1",
"tests/notification-read-state-openapi-contract.ps1",
"tests/phone-change-openapi-contract.ps1",
"tests/profile-openapi-contract.ps1",
"tests/profile-update-openapi-contract.ps1",
"tests/t03-member-remote-contract.ps1"
],
"expectedBlocked": [
{ "script": "tests/auth-android-accessibility-release-gate.ps1", "marker": "ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED" },
{ "script": "tests/g11-settings-openapi-contract.ps1", "marker": "G11-SETTINGS-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/g12-generation-poem-openapi-contract.ps1", "marker": "G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/genealogy-workspace-openapi-contract.ps1", "marker": "GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/invite-ticket-openapi-contract.ps1", "marker": "INVITE-TICKET-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/join-application-openapi-contract.ps1", "marker": "JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/lineage-openapi-contract.ps1", "marker": "LINEAGE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/notification-read-openapi-contract.ps1", "marker": "NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/notification-read-state-openapi-contract.ps1", "marker": "NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/phone-change-openapi-contract.ps1", "marker": "PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/profile-openapi-contract.ps1", "marker": "PROFILE-OPENAPI-CONTRACT BLOCKED" },
{ "script": "tests/profile-update-openapi-contract.ps1", "marker": "PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED" }
]
}
+182
View File
@@ -0,0 +1,182 @@
param(
[ValidateSet('T0', 'ALL')]
[string]$Tier = 'T0',
[string]$OutputDir = ''
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$manifestPath = Join-Path $PSScriptRoot 'night-run.manifest.json'
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $OutputDir) {
$OutputDir = Join-Path ([System.IO.Path]::GetTempPath()) ('jiapuapp-night-run-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
}
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$inventory = @(
Get-ChildItem -LiteralPath $PSScriptRoot -File -Recurse |
Where-Object { $_.Extension -in '.ps1', '.js' -and $_.Name -ne 'night-run.ps1' } |
ForEach-Object { $_.FullName.Substring($root.Length + 1).Replace('\', '/') } |
Sort-Object
)
$powerShellCount = @($inventory | Where-Object { $_.EndsWith('.ps1') }).Count
$nodeCount = @($inventory | Where-Object { $_.EndsWith('.js') }).Count
$newExecutableTests = $inventory.Count - $manifest.baselineInventory.powershell - $manifest.baselineInventory.node
if ($newExecutableTests -lt 0) {
throw "夜跑 inventory 少于冻结基线:actual=$($inventory.Count) baseline=$($manifest.baselineInventory.powershell + $manifest.baselineInventory.node)"
}
$expectedBlocked = @{}
foreach ($entry in @($manifest.expectedBlocked)) {
$expectedBlocked[$entry.script] = [string]$entry.marker
}
$h5ChromeRuntimeTests = @{}
foreach ($script in @($manifest.h5ChromeRuntimeTests)) {
$h5ChromeRuntimeTests[[string]$script] = $true
}
if ($Tier -eq 'T0') {
$selected = @($manifest.t0 | Sort-Object -Unique)
} else {
$selected = $inventory
}
$unknown = @($selected | Where-Object { $_ -notin $inventory })
if ($unknown.Count -gt 0) {
throw "夜跑清单引用不存在的测试:$($unknown -join '、')"
}
function Test-H5ChromeRuntime {
try {
$pages = Invoke-RestMethod -Uri 'http://127.0.0.1:9222/json/list' -TimeoutSec 2
return @($pages | Where-Object {
$_.type -eq 'page' -and $_.url -like 'http://localhost:5173*'
}).Count -gt 0
} catch {
return $false
}
}
$needsH5ChromeRuntime = @($selected | Where-Object { $h5ChromeRuntimeTests.ContainsKey($_) }).Count -gt 0
$h5ChromeRuntimeReady = -not $needsH5ChromeRuntime -or (Test-H5ChromeRuntime)
function Test-ExactLine {
param([string]$Text, [string]$Marker)
return [regex]::IsMatch($Text, "(?m)^$([regex]::Escape($Marker))`r?$")
}
function Invoke-ManagedTest {
param([string]$RelativePath)
$startedAt = Get-Date
if ($h5ChromeRuntimeTests.ContainsKey($RelativePath) -and -not $h5ChromeRuntimeReady) {
return [pscustomobject]@{
script = $RelativePath
startedAt = $startedAt.ToString('o')
finishedAt = (Get-Date).ToString('o')
durationMs = 0
exitCode = -2
checkResult = 'INFRA_ERROR'
expectedBlockedMarker = $null
infraReason = 'H5_CHROME_RUNTIME_UNAVAILABLE'
outputPath = $null
errorPath = $null
}
}
$absolutePath = Join-Path $root $RelativePath.Replace('/', '\')
$outputPath = Join-Path $OutputDir (($RelativePath -replace '[\\/]', '__') + '.stdout.txt')
$errorPath = Join-Path $OutputDir (($RelativePath -replace '[\\/]', '__') + '.stderr.txt')
$fileName = if ($RelativePath.EndsWith('.ps1')) { 'powershell.exe' } else { 'node.exe' }
$arguments = if ($RelativePath.EndsWith('.ps1')) {
@('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $absolutePath)
} else {
@($absolutePath)
}
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $fileName
$startInfo.Arguments = (($arguments | ForEach-Object {
'"' + ([string]$_).Replace('"', '\"') + '"'
}) -join ' ')
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "无法启动夜跑子进程:$RelativePath"
}
$standardOutputTask = $process.StandardOutput.ReadToEndAsync()
$standardErrorTask = $process.StandardError.ReadToEndAsync()
$timedOut = -not $process.WaitForExit([int]$manifest.defaultTimeoutSeconds * 1000)
if ($timedOut) {
$process.Kill()
$process.WaitForExit()
}
$exitCode = if ($timedOut) { -1 } else { [int]$process.ExitCode }
$standardOutput = $standardOutputTask.GetAwaiter().GetResult()
$standardError = $standardErrorTask.GetAwaiter().GetResult()
[System.IO.File]::WriteAllText($outputPath, $standardOutput, [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($errorPath, $standardError, [System.Text.UTF8Encoding]::new($false))
$text = ($standardOutput + "`n" + $standardError).Trim()
$marker = $expectedBlocked[$RelativePath]
$result = if ($timedOut) {
'INFRA_ERROR'
} elseif ($marker) {
$passMarker = $marker -replace ' BLOCKED$', ' PASS'
if ($exitCode -eq 0 -and (Test-ExactLine $text $passMarker)) { 'PASS' }
elseif ($exitCode -ne 0 -and (Test-ExactLine $text $marker)) { 'EXPECTED_BLOCKED' }
else { 'FAIL' }
} elseif ($exitCode -eq 0) {
'PASS'
} else {
'FAIL'
}
return [pscustomobject]@{
script = $RelativePath
startedAt = $startedAt.ToString('o')
finishedAt = (Get-Date).ToString('o')
durationMs = [int]((Get-Date) - $startedAt).TotalMilliseconds
exitCode = $exitCode
checkResult = $result
expectedBlockedMarker = $marker
outputPath = $outputPath
errorPath = $errorPath
}
}
$records = @()
foreach ($test in $selected) {
$record = Invoke-ManagedTest $test
$records += $record
$checkpoint = [pscustomobject]@{
baselineInventoryTests = [int]$manifest.baselineInventory.powershell + [int]$manifest.baselineInventory.node
newExecutableTests = $newExecutableTests
inventoryTests = $inventory.Count
selectedTests = $selected.Count
records = $records
}
$checkpoint | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $OutputDir 'checkpoint.json') -Encoding UTF8
Write-Output ("{0} {1} exit={2}" -f $record.checkResult, $record.script, $record.exitCode)
}
$summary = [pscustomobject]@{
baselineInventoryTests = [int]$manifest.baselineInventory.powershell + [int]$manifest.baselineInventory.node
newExecutableTests = $newExecutableTests
inventoryTests = $inventory.Count
scheduledTests = $selected.Count
executedTests = $records.Count
passTests = @($records | Where-Object checkResult -eq 'PASS').Count
failTests = @($records | Where-Object checkResult -eq 'FAIL').Count
expectedBlockedTests = @($records | Where-Object checkResult -eq 'EXPECTED_BLOCKED').Count
infraErrorTests = @($records | Where-Object checkResult -eq 'INFRA_ERROR').Count
timedOutTests = @($records | Where-Object { $_.exitCode -eq -1 }).Count
notRunScheduledTests = 0
notRunTests = $inventory.Count - $selected.Count
outputDir = $OutputDir
}
$summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $OutputDir 'summary.json') -Encoding UTF8
Write-Output ('NIGHT-RUN-SUMMARY ' + ($summary | ConvertTo-Json -Compress))
if ($summary.infraErrorTests -gt 0) { exit 4 }
if ($summary.failTests -gt 0) { exit 2 }
exit 0
+25 -5
View File
@@ -9,7 +9,7 @@ $contracts = [ordered]@{
'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', 'savePhone', 'phone-state--saving')
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport', 'openPage("M07", {}, "M06")')
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport', 'help-source-note', 'openPage("M07", {}, "M06")')
'pages/profile/m07-feedback.vue' = @('feedbackForm', 'feedbackTypes', 'validateFeedback', 'submitFeedback', 'feedback-state--submitting')
'pages/profile/m08-promotion.vue' = @('inviteState', 'openInviteExplanation', 'share-state--unavailable', 'explanationVisible')
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orderState', 'order-state--unavailable', 'openServiceNotice')
@@ -22,7 +22,7 @@ foreach ($entry in $contracts.GetEnumerator()) {
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')) {
if ($entry.Key -notin @('pages/profile/m04-change-password.vue', 'pages/profile/m07-feedback.vue', 'pages/profile/m10-about-settings.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) {
@@ -39,8 +39,10 @@ $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']
$m04 = $sources['pages/profile/m04-change-password.vue']
$m08 = $sources['pages/profile/m08-promotion.vue']
$m10 = $sources['pages/profile/m10-about-settings.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'
@@ -97,7 +99,6 @@ foreach ($formPath in @(
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]
@@ -109,6 +110,25 @@ foreach ($previewContract in @(
}
}
foreach ($required in @(
'appApi.changePassword',
'calcMD5(passwordForm.current)',
'calcMD5(passwordForm.next)',
'createRequestController',
'passwordRequestController.abort()',
'isRequestCancelled(error)',
'确认修改密码'
)) {
if (-not $m04.Contains($required)) { throw "M04 declared password change contract missing: $required" }
}
foreach ($forbidden in @('校验新密码(不提交)', '本地校验通过,尚未提交服务器')) {
if ($m04.Contains($forbidden)) { throw "M04 must not retain local-only password change copy: $forbidden" }
}
foreach ($required in @('appApi.logout', 'logoutRequestController', 'session.clear();', 'return goRoot("A01");')) {
if (-not $m10.Contains($required)) { throw "M10 declared logout contract missing: $required" }
}
foreach ($dialogPath in @(
'pages/profile/m08-promotion.vue',
'pages/profile/m09-vip-orders.vue',
@@ -152,8 +172,8 @@ foreach ($forbiddenOrderPreview in @('query.state', '演示订单', '¥0.00', 'o
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 ($m10 -notmatch '(?s)const confirmLogout = async \(\) => \{.*?await appApi\.logout\(\{ requestController: logoutRequestController \}\);.*?finally \{\s*session\.clear\(\);\s*logoutVisible\.value = false;.*?\}\s*return goRoot\("A01"\);\s*\};') {
throw 'M10 logout must attempt the declared remote owner, then clear the local session and enter A01 in every result'
}
if (([regex]::Matches($m10, 'session\.clear\(\)')).Count -ne 1) {
throw 'M10 cancellation or back paths must never clear the session'
+23 -245
View File
@@ -1,259 +1,37 @@
$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')
$api = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
$page = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/profile/m04-change-password.vue')
$issues = New-Object System.Collections.Generic.List[string]
$passwordPath = '/genealogy/app/auth/password'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
function Require-Text {
param([string]$Source, [string]$Text, [string]$Description)
if (-not $Source.Contains($Text)) { $script:issues.Add($Description) }
}
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
Require-Text $api 'async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) {' 'api owner must expose password change'
Require-Text $api "url: '/genealogy/app/auth/password'" 'password change must use the declared APP password owner'
Require-Text $api "method: 'PUT'" 'password change must use PUT'
Require-Text $api 'oldPassword: assertPasswordHash(oldPasswordHash)' 'old password must be the declared 32-character digest'
Require-Text $api 'newPassword: assertPasswordHash(newPasswordHash)' 'new password must be the declared 32-character digest'
Require-Text $api 'requireData: false' 'password change must accept the declared VoidResult envelope'
Require-Text $api 'requestController: requestOptions.requestController ?? null' 'password change must accept page cancellation ownership'
Require-Text $page 'appApi.changePassword({' 'M04 must call the shared password owner'
Require-Text $page 'oldPasswordHash: calcMD5(passwordForm.current)' 'M04 must hash the current password before transport'
Require-Text $page 'newPasswordHash: calcMD5(passwordForm.next)' 'M04 must hash the new password before transport'
Require-Text $page 'passwordRequestController.abort()' 'M04 must cancel an in-flight request when unloading'
Require-Text $page 'isRequestCancelled(error)' 'M04 must not report a cancelled request as a password-change failure'
if ($api -match "url: '/genealogy/app/auth/password'[\s\S]{0,260}passwordForm\.") {
$issues.Add('password API owner must not depend on page form state')
}
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 ($page -notmatch 'oldPasswordHash:\s*calcMD5\(passwordForm\.current\)[\s\S]{0,160}newPasswordHash:\s*calcMD5\(passwordForm\.next\)') {
$issues.Add('M04 must pass only the MD5 digests to the API owner')
}
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)
throw ("PASSWORD-CHANGE-OPENAPI-CONTRACT FAIL`n- " + ($issues -join "`n- "))
}
Write-Output 'PASSWORD-CHANGE-OPENAPI-CONTRACT PASS'
+1
View File
@@ -79,6 +79,7 @@
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".generation-band", "property": "fixed-height", "reason": "Fixed-height generation lane in the scrollable tree canvas." },
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".member-node", "property": "fixed-height", "reason": "Fixed-ratio interactive tree node." },
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".member-node--selected", "property": "fixed-height", "reason": "Selected state preserves the fixed-ratio tree node." },
{ "path": "pages/tree/t01-tree-overview.vue", "selector": ".member-node__avatar", "property": "fixed-height", "reason": "Fixed-ratio member avatar icon." },
{ "path": "pages/tree/t03-member-profile.vue", "selector": ".member-heading__seal", "property": "fixed-height", "reason": "Fixed-ratio member seal." }
]
}
+5 -6
View File
@@ -7,11 +7,11 @@ foreach ($required in @(
'<AppButton',
'type="secondary"',
'@click="toMember"',
'@click="toAddRelative"',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
't01-member-drawer.png',
'openMemberPanel(member)',
'member-action-profile',
'v-for="connector in lineageConnectors"',
'lineage-pan-cue',
'const treeScrollLeft = ref(90)',
@@ -35,7 +35,7 @@ foreach ($rule in @(
'(?s)\.node-relation\s*\{[^}]*font-size:\s*21rpx;',
'(?s)\.node-years\s*\{[^}]*font-size:\s*20rpx;',
'(?s)\.tree-state-card__copy\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.sheet-meta\s*\{[^}]*font-size:\s*22rpx;',
'(?s)\.member-action-profile__copy text:nth-child\(2\)\s*\{[^}]*font-size:\s*21rpx;',
'(?s)\.tree-stage--lineage\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*190rpx minmax\(0, 1fr\);',
'(?s)\.generation-rail\s*\{[^}]*display:\s*grid;[^}]*width:\s*190rpx;',
'(?s)\.tree-scroll--lineage\s*\{[^}]*grid-area:\s*1 / 2;[^}]*min-width:\s*0;',
@@ -45,9 +45,8 @@ foreach ($rule in @(
'(?s)\.generation-band image,\s*\.generation-band__copy\s*\{[^}]*grid-area:\s*1 / 1;',
'(?s)\.member-node\s*\{[^}]*display:\s*grid;',
'(?s)\.member-node__skin,\s*\.member-node__copy\s*\{[^}]*grid-area:\s*1 / 1;',
'(?s)\.member-sheet\s*\{[^}]*min-height:\s*240rpx;',
'(?s)\.sheet-actions\s*\{[^}]*justify-content:\s*center;[^}]*margin-top:\s*auto;',
'(?s)\.sheet-action\s*\{[^}]*width:\s*250rpx;[^}]*min-height:\s*88rpx;'
'(?s)\.member-action-profile\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;',
'(?s)\.member-action-profile__avatar\s*\{[^}]*width:\s*82rpx;[^}]*aspect-ratio:\s*1;[^}]*flex:\s*0 0 82rpx;'
)) {
if ($page -notmatch $rule) { throw "T01 readable visual rule missing: $rule" }
}
@@ -0,0 +1,49 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
$routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8
foreach ($required in @(
'class="member-node__avatar"',
'const memberActionPanelVisible = ref(false)',
'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>',
'<AppDialog',
'member-action-grid',
'unavailableActionVisible',
'BIND_INVITE'
)) {
if (-not $page.Contains($required)) {
throw "T01 member action panel missing: $required"
}
}
foreach ($actionKey in @(
'VIEW_PROFILE',
'ADD_FATHER',
'ADD_MOTHER',
'ADD_SPOUSE',
'ADD_SIBLING',
'ADJUST_RANK',
'ADD_SON',
'ADD_DAUGHTER',
'BIND_INVITE',
'EDIT_PROFILE'
)) {
if (-not $page.Contains(('key: "' + $actionKey + '"'))) {
throw "T01 member action panel missing action: $actionKey"
}
}
foreach ($required in @(
'optionalParams: ["personId", "mode", "relationType"]',
'allowedSources: ["T01", "T03"]',
'optionalParams: ["mode"]'
)) {
if (-not $routes.Contains($required)) {
throw "T01 member action route contract missing: $required"
}
}
Write-Output 'T01-PERSON-ACTION-PANEL-CONTRACT PASS'
@@ -1,9 +1,8 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/tree/t01-tree-overview.vue'), [System.Text.Encoding]::UTF8)
foreach ($selector in @('.tree-state-card','.member-sheet__copy','.sheet-actions')) {
foreach ($selector in @('.tree-state-card','.member-action-profile','.member-action-profile__copy')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "T01 normal sheet/state content uses positioning: $selector" }
}
if (-not $page.Contains('margin-top: auto')) { throw 'T01 fixed member sheet actions must use flex flow' }
Write-Output 'T01-SHEET-STATE-DOCUMENT-FLOW-CONTRACT PASS'
+4 -3
View File
@@ -60,9 +60,10 @@ foreach ($required in @(
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
't01-member-drawer.png',
'class="member-action-profile"',
'const openMemberPanel = (member) =>',
'openPage("T03"',
'openPage("T04"',
'routeKey: "T04"',
'openPage("T06"',
'openPage("T07"',
'query.genealogyId',
@@ -101,7 +102,7 @@ foreach ($forbidden in @(
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must not keep opaque surface asset: $forbidden" }
}
foreach ($className in @('tree-canvas', 'member-node', 'member-sheet', 'tree-state-card')) {
foreach ($className in @('tree-canvas', 'member-node', 'member-action-profile', 'tree-state-card')) {
Assert-NoCssSurface $page $className
}
+1 -1
View File
@@ -144,7 +144,7 @@ const run = async () => {
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node')
await valueOf(send, "document.querySelector('.member-node')?.click()")
await valueOf(send, "document.querySelector('.member-sheet .app-button')?.click()")
await valueOf(send, "document.querySelector('.member-action-profile')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'T01 selected member did not open T03')
process.stdout.write('T01-TREE-STATE-RUNTIME-SMOKE PASS\n')
} finally {
+15 -25
View File
@@ -1,40 +1,30 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$profile = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding utf8
$forms = [ordered]@{
'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
$pages = @{
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
}
foreach ($rule in @(
'(?s)\.member-heading > view:last-child text:last-child\s*\{[^}]*font-size:\s*23rpx;',
'(?s)\.member-section-title\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.member-info-row text\s*\{[^}]*font-size:\s*23rpx;',
'(?s)\.member-relatives\s*\{[^}]*font-size:\s*23rpx;',
'(?s)\.member-error > text:nth-child\(2\)\s*\{[^}]*font-size:\s*24rpx;'
'(?s)\.member-info-row text\s*\{[^}]*font-size:\s*23rpx;'
)) {
if ($profile -notmatch $rule) { throw "T03 readable visual rule missing: $rule" }
if ($pages.T03 -notmatch $rule) { throw "T03 readable visual rule missing: $rule" }
}
foreach ($entry in $forms.GetEnumerator()) {
foreach ($token in @(
'@include adaptive.adaptive-tree-panel;',
'@include adaptive.adaptive-tree-field;',
'grid-template-columns: auto minmax(0, 1fr)'
)) {
if (-not $entry.Value.Contains($token)) { throw "$($entry.Key) active form layout token missing: $token" }
foreach ($key in @('T04', 'T05', 'T06')) {
$content = $pages[$key]
foreach ($token in @('@include adaptive.adaptive-tree-panel;', '@include adaptive.adaptive-tree-field;', 'grid-template-columns: auto minmax(0, 1fr)')) {
if (-not $content.Contains($token)) { throw "$key active form layout token missing: $token" }
}
foreach ($rule in @(
'(?s)\.form-copy,\s*\.form-note\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.form-field[^{]*text:first-child\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.form-field[^{]*(?:input|text:last-child)[^{]*\{[^}]*font-size:\s*24rpx;'
)) {
if ($entry.Value -notmatch $rule) { throw "$($entry.Key) readable visual rule missing: $rule" }
}
if ($entry.Value -match '(?m)\bposition\s*:') {
throw "$($entry.Key) must keep active form content in document flow"
if ($content -match '(?m)^\s*\.rank-form\s*\{[^}]*\bposition\s*:|(?m)^\s*\.add-relative-form\s*\{[^}]*\bposition\s*:|(?m)^\s*\.edit-member-form\s*\{[^}]*\bposition\s*:') {
throw "$key must keep active content in document flow"
}
}
if ($pages.T06 -notmatch '(?s)\.form-copy\s*\{[^}]*font-size:\s*24rpx;') { throw 'T06 rank copy must remain readable' }
Write-Output 'T03-T06-ALL-STATES-VISUAL-CONTRACT PASS'
@@ -1,128 +1,35 @@
$ErrorActionPreference = 'Stop'
function Read-Page([string]$RelativePath) {
return [System.IO.File]::ReadAllText(
(Join-Path (Split-Path -Parent $PSScriptRoot) $RelativePath),
[System.Text.Encoding]::UTF8
)
$root = Split-Path -Parent $PSScriptRoot
$pages = @{
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
}
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) {
if (-not $Content.Contains($Expected)) { throw $Message }
}
$t03 = Read-Page 'pages/tree/t03-member-profile.vue'
$t04 = Read-Page 'pages/tree/t04-add-relative.vue'
$t05 = Read-Page 'pages/tree/t05-edit-member.vue'
$t06 = Read-Page 'pages/tree/t06-edit-relationship.vue'
$t08 = Read-Page 'pages/tree/t08-member-states.vue'
Assert-Contains $t03 '{{ memberContextDescription }}' 'T03 must render its state-specific context description'
if ($t03 -notmatch '(?s)const\s+memberContextDescription\s*=\s*computed\(\(\)\s*=>\s*\(\{\s*loading:\s*"\u6B63\u5728\u8BFB\u53D6\u6210\u5458\u8D44\u6599",\s*detail:\s*"\u6210\u5458\u8EAB\u4EFD\u4E0E\u4EB2\u5C5E\u5173\u7CFB",\s*restricted:\s*"\u9690\u79C1\u6210\u5458\u4EC5\u5C55\u793A\u57FA\u7840\u8EAB\u4EFD",\s*error:\s*"\u8BF7\u91CD\u65B0\u9009\u62E9\u6210\u5458"') {
throw 'T03 must keep distinct loading, detail, restricted, and error context copy'
}
foreach ($entry in @(
@{ Name = 'T04'; Content = $t04 },
@{ Name = 'T05'; Content = $t05 },
@{ Name = 'T06'; Content = $t06 }
)) {
if ($entry.Content -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
throw "$($entry.Name) must own its business workflow instead of rendering TreeMemberForm"
foreach ($entry in $pages.GetEnumerator()) {
if ($entry.Value -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
throw "$($entry.Key) must own its workflow"
}
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($entry.Content.Contains($nativeUi)) { throw "$($entry.Name) must not use $nativeUi" }
if ($entry.Value.Contains($nativeUi)) { throw "$($entry.Key) must not use $nativeUi" }
}
}
foreach ($required in @(
'const addState = ref("form")',
'const addForm = reactive({',
'const relationOptions = [',
'const genderOptions = [',
'const isFirstMember = computed(',
'query.personId',
'query.mode === "first"',
'fieldErrors.name',
'fieldErrors.relation',
'add-state--form',
'add-state--preview',
'add-state--error',
'class="add-relative-form"',
'<picker',
'<AppDialog'
)) { Assert-Contains $t04 $required "T04 missing independent add-relative contract: $required" }
$t04Style = [regex]::Match($t04, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
$t04PanelStyle = [regex]::Match($t04Style, '(?ms)^\s*\.add-relative-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
if ($t04PanelStyle -match '\bmin-height\s*:') {
throw 'T04 add-relative panel height must be driven by its current content'
foreach ($key in @('T04', 'T05', 'T06')) {
$content = $pages[$key]
foreach ($required in @('appApi.getPerson(', 'createRequestController', 'isRequestCancelled')) {
if (-not $content.Contains($required)) { throw "$key missing remote read owner: $required" }
}
foreach ($forbidden in @('@/data/mock.js', 'setTimeout(', 'preview')) {
if ($content.Contains($forbidden)) { throw "$key retains forbidden local preview token: $forbidden" }
}
}
foreach ($required in @(
'const editState = ref("form")',
'const editForm = reactive({',
'findTreeMemberPresentationFixture(genealogyId.value, id)',
'const isDirty = computed(',
'const discardDialogVisible = ref(false)',
'query.personId',
'edit-state--form',
'edit-state--preview',
'edit-state--error',
'edit-state--no-permission',
'class="edit-member-form"',
'<AppDialog',
'onBackPress('
)) { Assert-Contains $t05 $required "T05 missing independent member-edit contract: $required" }
$t05Style = [regex]::Match($t05, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
$t05PanelStyle = [regex]::Match($t05Style, '(?ms)^\s*\.edit-member-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
if ($t05PanelStyle -match '\bmin-height\s*:') {
throw 'T05 edit-member panel height must be driven by its current content'
if (-not $pages.T06.Contains('"unavailable"')) { throw 'T06 must expose a closed service state' }
foreach ($required in @('appApi.getPerson(', 'memberContextDescription', 'openPage("T08"')) {
if (-not $pages.T03.Contains($required)) { throw "T03 missing member detail contract: $required" }
}
foreach ($required in @(
'const relationshipState = ref("form")',
'const relationshipForm = reactive({',
'const memberOptions = computed(',
'const relationshipOptions = [',
'const relationshipPreview = computed(',
'const validateRelationship = () =>',
'query.personId',
'relationship-state--form',
'relationship-state--preview',
'relationship-state--conflict',
'relationship-state--error',
'class="relationship-form"',
'<picker',
'<AppDialog'
)) { Assert-Contains $t06 $required "T06 missing independent relationship-edit contract: $required" }
$t06Style = [regex]::Match($t06, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
$t06PanelStyle = [regex]::Match($t06Style, '(?ms)^\s*\.relationship-panel\s*\{(?<body>.*?)\}').Groups['body'].Value
if ($t06PanelStyle -match '\bmin-height\s*:') {
throw 'T06 relationship panel height must be driven by its current content'
}
foreach ($required in @(
'const genealogyId = ref("")',
'const personId = ref("")',
'const statusState = ref("loading")',
'findTreeMemberPresentationFixture(genealogyId.value, personId.value)',
'query.personId',
'query.state',
'member-status--privacy',
'member-status--deceased',
'member-status--forbidden',
'member-status--error',
'class="member-status-context"'
)) { Assert-Contains $t08 $required "T08 missing member-driven status contract: $required" }
if ($t08 -match 'class="status-tabs"|const\s+tabs\s*=|@click="statusState\s*=') {
throw 'T08 must not let the user switch among demonstration states'
}
foreach ($required in @(
'findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId)',
'query.personId',
'const openMemberState = () =>',
'openPage("T08", { genealogyId: genealogyId.value, personId: personId.value }, "T03")'
)) { Assert-Contains $t03 $required "T03 missing semantic member-state entry: $required" }
Write-Output 'T03-T08-BUSINESS-SPECIALIZATION-CONTRACT PASS'
Write-Output 'T03-T08-BUSINESS-SPECIALIZATION-CONTRACT PASS REMOTE-CLOSED'
+43 -265
View File
@@ -1,285 +1,63 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
function Assert-NotContains {
param([string]$Content, [string]$Unexpected, [string]$Message)
if ($Content.Contains($Unexpected)) { throw $Message }
}
function Assert-Matches {
param([string]$Content, [string]$Pattern, [string]$Message)
if ($Content -notmatch $Pattern) { throw $Message }
}
$root = Split-Path -Parent $PSScriptRoot
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding utf8
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding utf8
$paths = @(
'pages/tree/t01-tree-overview.vue',
'pages/tree/t03-member-profile.vue',
'pages/tree/t04-add-relative.vue',
'pages/tree/t05-edit-member.vue',
'pages/tree/t06-edit-relationship.vue',
'pages/tree/t07-member-directory.vue',
'pages/tree/t08-member-states.vue'
)
$pages = @{}
foreach ($path in $paths) {
$content = Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding utf8
if ($content -match '<ModulePage(?:\s|>)') { throw "$path must not retain ModulePage" }
if ($content -match "@/utils/api\.js|\bappApi\b") { throw "$path must not connect the API layer in navigation task 6" }
if ($content -match '\buni\.(?:navigateTo|navigateBack|redirectTo|reLaunch|switchTab)\b') {
throw "$path must use the navigation gateway exclusively"
$pages = @{
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
}
foreach ($entry in $pages.GetEnumerator()) {
if ($entry.Value -match '\buni\.(?:navigateTo|navigateBack|redirectTo|reLaunch|switchTab)\b') {
throw "$($entry.Key) must use the navigation gateway exclusively"
}
if ($entry.Value.Contains('/pages/')) {
throw "$($entry.Key) must not own route literals"
}
if ($content.Contains('/pages/')) { throw "$path must not own route literals" }
$pages[$path] = $content
}
foreach ($path in @('pages/tree/t01-tree-overview.vue', 'pages/tree/t03-member-profile.vue', 'pages/tree/t07-member-directory.vue', 'pages/tree/t08-member-states.vue')) {
Assert-Contains $pages[$path] 'ModulePageBackground' "$path must use the tree module background"
}
$t01 = $pages['pages/tree/t01-tree-overview.vue']
Assert-NotContains $t01 'consumeNavigationResult(' 'T01 must not pre-register a mutation consumer before real writes exist'
foreach ($mapping in @(
'openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T07", { genealogyId: genealogyId.value }, "T01")',
'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")',
'openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")'
)) {
Assert-Contains $t01 $mapping "T01 missing gateway target: $mapping"
}
Assert-Matches $t01 '(?s)openPage\(\s*"T04",\s*\{ genealogyId: genealogyId\.value, mode: "first" \},\s*"T01",?\s*\)' 'T01 empty state must open first-member mode through T04 route contract'
$t03 = $pages['pages/tree/t03-member-profile.vue']
foreach ($required in @(
'member-state--detail',
'member-state--restricted',
'member-state--error',
'adaptive.adaptive-tree-panel',
'adaptive.adaptive-tree-field',
'<PageHeader title=',
'custom-back',
'@back="requestBack"',
'appApi.getTree',
'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>',
'openPage("T03"',
'routeKey: "T04"',
'routeKey: "T05"',
'routeKey: "T06"',
'return openPage(action.routeKey, params, "T01")'
)) { Assert-Contains $pages.T01 $required "T01 missing member flow contract: $required" }
foreach ($required in @(
'appApi.getPerson(',
'const memberTrail = reactive([]);',
'const trailIndex = ref(-1);',
'const loadMember = async (nextPersonId) =>',
'const initializeMemberTrail = async (initialPersonId) =>',
'memberTrail.splice(0, memberTrail.length, normalizedPersonId);',
'trailIndex.value = 0;',
'const openRelative = async (nextPersonId) =>',
'memberTrail.splice(trailIndex.value + 1);',
'memberTrail.push(normalizedPersonId);',
'trailIndex.value = memberTrail.length - 1;',
'const popMemberTrail = async () =>',
'memberTrail.splice(targetIndex, 1);',
'internalTrail: trailIndex.value > 0',
'"pop-internal-trail": popMemberTrail',
'consumeNavigationResult("T03")',
'result?.operation === "member-open-requested"',
'onBackPress((event) => handleBackPress(event, requestBack));',
'openPage("T05", { genealogyId: genealogyId.value, personId: personId.value }, "T03")',
'openPage("T08", { genealogyId: genealogyId.value, personId: personId.value }, "T03")'
)) {
Assert-Contains $t03 $required "Missing T03 single-instance trail contract: $required"
}
Assert-Matches $t03 '(?s)const openRelative = async \(nextPersonId\) => \{.*?const loaded = await loadMember\(normalizedPersonId\);\s*if \(!loaded\) return false;.*?memberTrail\.push\(normalizedPersonId\)' 'T03 must append a relative only after a successful read'
Assert-Matches $t03 '(?s)const initializeMemberTrail = async \(initialPersonId\) => \{\s*memberTrail\.splice\(0, memberTrail\.length\);\s*trailIndex\.value = -1;.*?if \(!loaded\) return false;.*?memberTrail\.splice\(0, memberTrail\.length, normalizedPersonId\)' 'T03 initial failure must leave the trail empty'
Assert-Contains $t03 ':data-current-person-id="personId"' 'T03 runtime smoke needs a semantic current-person marker'
Assert-Contains $t03 ':data-trail-length="memberTrail.length"' 'T03 runtime smoke must observe trail initialization without internal access'
Assert-Contains $t03 ':data-trail-index="trailIndex"' 'T03 runtime smoke must observe the current trail position'
Assert-Contains $t03 ':data-person-id="relative.id"' 'T03 relatives need stable lexical identity in the rendered list'
Assert-NotContains $t03 'genealogyContext' 'T03 must not fall back to mutable global genealogy context'
Assert-Matches $t03 '(?s)const restricted\s*=\s*\["privacy",\s*"forbidden"\]\.includes\(fixture\.status\).*?memberState\.value\s*=\s*restricted\s*\?\s*"restricted"\s*:\s*"detail"' 'T03 must derive restricted presentation from the scoped member status'
Assert-Contains $t03 'const canPreviewEdit = computed(() => memberState.value === "detail");' 'T03 may expose only a clearly local preview entry for unrestricted detail state'
Assert-NotContains $t03 'canEdit' 'T03 must not treat a fixture field as a backend edit capability'
'onUnload(() =>',
'memberRequestController.abort()'
)) { Assert-Contains $pages.T03 $required "T03 missing remote member trail contract: $required" }
$specializedForms = @{
't04-add-relative.vue' = @('add-state--form', 'add-state--preview', 'add-relative-form', 'relationOptions')
't05-edit-member.vue' = @('edit-state--form', 'edit-state--preview', 'edit-member-form', 'findTreeMemberPresentationFixture')
't06-edit-relationship.vue' = @('relationship-state--form', 'relationship-state--preview', 'relationship-form', 'relationshipOptions')
}
foreach ($entry in $specializedForms.GetEnumerator()) {
$content = $pages["pages/tree/$($entry.Key)"]
if ($content -match '<TreeMemberForm(?:\s|/|>)|import\s+TreeMemberForm') {
throw "$($entry.Key) must own its business workflow instead of TreeMemberForm"
foreach ($key in @('T04', 'T05', 'T06')) {
$content = $pages[$key]
foreach ($required in @('appApi.getPerson(', 'createRequestController', 'isRequestCancelled', 'onUnload(() =>')) {
Assert-Contains $content $required "$key missing remote closed-flow contract: $required"
}
foreach ($required in $entry.Value) {
Assert-Contains -Content $content -Expected $required -Message ("Missing specialized form contract in {0}: {1}" -f $entry.Key, $required)
}
foreach ($required in @(
'createDiscardConfirmation',
'handleBackPress',
'runBackGuard',
'submitting: isSubmitting.value',
'"block-submitting"',
'onBackPress((event) => handleBackPress(event, requestBack));',
'discardConfirmation.dispose();'
)) {
Assert-Contains -Content $content -Expected $required -Message ("{0} missing shared guarded local-preview contract: {1}" -f $entry.Key, $required)
}
Assert-NotContains -Content $content -Unexpected 'finishPage(' -Message ("{0} must not emit a server-success result before a real write succeeds" -f $entry.Key)
foreach ($operation in @('relative-created', 'member-updated', 'relationship-updated')) {
Assert-NotContains -Content $content -Unexpected $operation -Message ("{0} must not retain a pre-API mutation operation" -f $entry.Key)
}
Assert-Matches -Content $content -Pattern '\u5c1a\u672a\u63d0\u4ea4\u670d\u52a1\u5668' -Message ("{0} must disclose that its preview is not submitted" -f $entry.Key)
}
Assert-Contains -Content ($pages['pages/tree/t04-add-relative.vue']) -Expected 'returnTo("T01", { genealogyId: genealogyId.value })' -Message 'T04 local preview must return to T01 without a mutation result'
Assert-Contains -Content ($pages['pages/tree/t05-edit-member.vue']) -Expected 'return goBack();' -Message 'T05 must preserve the existing single T03 route identity when leaving a relative preview'
Assert-NotContains -Content ($pages['pages/tree/t05-edit-member.vue']) -Unexpected 'returnTo("T03"' -Message 'T05 must not replace the host T03 initial personId with its current in-page member'
Assert-Contains -Content ($pages['pages/tree/t06-edit-relationship.vue']) -Expected 'returnTo("T01", { genealogyId: genealogyId.value })' -Message 'T06 local preview must return to T01 without a mutation result'
foreach ($submission in @(
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Function = 'submitAdd'; State = 'addState.value' },
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Function = 'saveMember'; State = 'editState.value' },
@{ Key = 'T06'; Content = $pages['pages/tree/t06-edit-relationship.vue']; Function = 'saveRelationship'; State = 'relationshipState.value' }
)) {
$body = [regex]::Match(
$submission.Content,
"(?s)const $($submission.Function) = \(\) => \{.*?`n\};"
).Value
if (-not $body) { throw "$($submission.Key) submission function is not statically auditable" }
if (-not $body.Contains('"preview"')) { throw "$($submission.Key) valid submission must stop at local preview" }
if ($body -match '\b(?:returnTo|goBack|finishPage)\s*\(') {
throw "$($submission.Key) local validation must not navigate automatically"
}
}
if (([regex]::Matches($pages['pages/tree/t05-edit-member.vue'], 'baseline\.value = formSnapshot\.value;')).Count -ne 1) {
throw 'T05 local preview must not clear the dirty baseline before a server write exists'
}
$t07 = $pages['pages/tree/t07-member-directory.vue']
foreach ($required in @(
'directory-state--list',
'directory-state--empty',
'directory-state--error',
'adaptive.adaptive-genealogy-list-card',
'directory-card__status',
'listTreeMemberPresentationFixtures',
'const isRestrictedMember =',
'memberMeta(item)',
'memberStatus(item)',
'openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")'
)) {
Assert-Contains $t07 $required "Missing T07 contract: $required"
}
foreach ($privateTemplateRead in @('{{ item.generationName }}', '{{ item.branch }}', '{{ item.note }}')) {
Assert-NotContains $t07 $privateTemplateRead "T07 must not render a restricted member private field directly: $privateTemplateRead"
}
foreach ($required in @(
'const hasValidContext = computed(() => Boolean(genealogyId.value));',
'!hasValidContext.value ? "error"',
'v-if="hasValidContext"'
)) {
Assert-Contains $t07 $required "T07 must fail closed without genealogy context: $required"
}
$t08 = $pages['pages/tree/t08-member-states.vue']
foreach ($required in @(
'member-status--privacy',
'member-status--deceased',
'member-status--forbidden',
'adaptive.adaptive-tree-panel',
'memberIdentityCopy',
'姓名、世代与家族关系可见',
'goRoot("G01")',
'goBack()'
)) {
Assert-Contains $t08 $required "Missing T08 contract: $required"
}
Assert-NotContains $t08 '{{ member.branch }}' 'T08 must not assume a restricted member branch is visible'
Assert-NotContains $t08 'genealogyContext' 'T08 must not fall back to mutable global genealogy context'
foreach ($required in @(
'const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));',
'member.value = hasValidContext.value'
)) {
Assert-Contains $t08 $required "T08 must fail closed without its complete route identity: $required"
}
$t06 = $pages['pages/tree/t06-edit-relationship.vue']
foreach ($required in @(
'const hasValidContext = computed(',
'Boolean(genealogyId.value && memberById.value.has(personId.value))',
'relationshipForm.sourceId = hasValidContext.value ? personId.value : "";',
'relationshipState.value = !hasValidContext.value || query.state === "error"'
)) {
Assert-Contains $t06 $required "T06 must fail closed without a valid routed member: $required"
}
Assert-NotContains $t06 ': memberOptions[0].id' 'T06 must never replace a missing routed person with the first fixture member'
$treeOverview = $pages['pages/tree/t01-tree-overview.vue']
foreach ($page in @(
@{ Key = 'T01'; Content = $treeOverview },
@{ Key = 'T03'; Content = $t03 },
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue'] },
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue'] },
@{ Key = 'T06'; Content = $t06 },
@{ Key = 'T07'; Content = $t07 },
@{ Key = 'T08'; Content = $t08 }
)) {
Assert-Contains $page.Content '@/data/mock.js' "$($page.Key) must consume the shared tree-member fixture owner"
if ($page.Content -match 'import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*["'']@/data/mock\.js["'']') {
throw "$($page.Key) must consume snapshots instead of the mutable treeMembers store"
}
}
foreach ($localOwner in @(
@{ Key = 'T01'; Content = $treeOverview; Pattern = 'const\s+members\s*=\s*ref\(\[\s*\{' },
@{ Key = 'T03'; Content = $t03; Pattern = 'const\s+memberFixtures\s*=' },
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Pattern = 'const\s+memberFixtures\s*=' },
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Pattern = 'const\s+memberFixtures\s*=' },
@{ Key = 'T06'; Content = $t06; Pattern = 'const\s+memberOptions\s*=\s*\[' },
@{ Key = 'T07'; Content = $t07; Pattern = 'const\s+members\s*=\s*\[' },
@{ Key = 'T08'; Content = $t08; Pattern = 'const\s+memberFixtures\s*=' }
)) {
if ($localOwner.Content -match $localOwner.Pattern) {
throw "$($localOwner.Key) retains a competing local tree-member fixture owner"
foreach ($forbidden in @('@/data/mock.js', 'setTimeout(')) {
if ($content.Contains($forbidden)) { throw "$key retains local preview owner: $forbidden" }
}
}
foreach ($ownerContract in @(
'export const treeMembers = [',
'export const listTreeMemberFixtures = (genealogyId) =>',
'export const findTreeMemberFixture = (genealogyId, personId) =>',
'relatives: member.relatives.map((relative) => ({ ...relative }))'
)) {
Assert-Contains $mock $ownerContract "Shared tree-member owner missing: $ownerContract"
}
Assert-Matches $api '(?s)import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*''@/data/mock\.js''' 'utils/api.js must remain the only mutable tree-member store consumer'
foreach ($sourceRoot in @('pages', 'components', 'utils')) {
Get-ChildItem -LiteralPath (Join-Path $root $sourceRoot) -Recurse -File |
Where-Object { $_.Extension -in @('.js', '.vue') -and $_.FullName -ne (Join-Path $root 'utils/api.js') } |
ForEach-Object {
$source = Get-Content -LiteralPath $_.FullName -Raw -Encoding utf8
if ($source -match 'import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*["'']@/data/mock\.js["'']') {
throw "$($_.FullName) bypasses the tree-member snapshot selectors"
}
}
}
Assert-Contains $pages.T04 'relationType.value' 'T04 must accept the validated relation intent from T01'
$routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8
Assert-Contains $routes 'allowedSources: ["T01", "T03"]' 'T05 route source contract drifted'
Assert-Contains $pages.T06 'query.mode !== "rank"' 'T06 must reject non-rank entry modes'
foreach ($entry in @(
@{ Key = 'T01'; Content = $treeOverview; Expected = 'listTreeMemberFixtures(genealogyId.value)' },
@{ Key = 'T03'; Content = $t03; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId)' },
@{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Expected = 'findTreeMemberFixture(genealogyId.value, personId.value)' },
@{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, id)' },
@{ Key = 'T06'; Content = $t06; Expected = 'listTreeMemberFixtures(genealogyId.value)' },
@{ Key = 'T07'; Content = $t07; Expected = 'listTreeMemberPresentationFixtures(genealogyId.value)' },
@{ Key = 'T08'; Content = $t08; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, personId.value)' }
)) {
Assert-Contains $entry.Content $entry.Expected "$($entry.Key) must scope fixture reads by genealogy and member identity"
}
Assert-Contains $pages['pages/tree/t04-add-relative.vue'] 'const hasValidContext = computed(' 'T04 must own an explicit route-context gate'
Assert-Contains $pages['pages/tree/t04-add-relative.vue'] 'isFirstMember.value ? !personId.value : Boolean(currentMember.value)' 'T04 must only allow a missing personId in first-member mode'
Assert-Contains $pages['pages/tree/t05-edit-member.vue'] 'if (!member) return false;' 'T05 must reject unknown members instead of synthesizing an editable identity'
Assert-Matches $pages['pages/tree/t05-edit-member.vue'] '(?s)\["privacy",\s*"forbidden"\]\.includes\(originalMember\.value\.status\).*?"no-permission"' 'T05 deep links must not edit a restricted member without a backend capability'
foreach ($asset in @('t01-state-panel.png', 't07-search-input-frame.png', 'list-slip-frame.png')) {
Assert-Contains $profiles $asset "Adaptive tree profile missing: $asset"
}
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS NAVIGATION TRAIL LOCAL-PREVIEW'
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS REMOTE-WRITE'
@@ -0,0 +1,35 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding UTF8
foreach ($required in @(
'appApi.getPerson(',
'createRequestController',
'isRequestCancelled',
'addRequestController.abort()',
'let loadSequence = 0',
'relationType.value',
'const relationIntents = Object.freeze({',
'appApi.createPerson(',
'appApi.createRelatedPerson(',
'await returnTo("T01", { genealogyId: genealogyId.value })'
)) {
if (-not $page.Contains($required)) {
throw "T04 remote close contract missing: $required"
}
}
foreach ($forbidden in @(
'@/data/mock.js',
'findTreeMemberFixture',
'setTimeout(',
'addState.value = "preview"',
'addState.value = "unavailable"'
)) {
if ($page.Contains($forbidden)) {
throw "T04 must not retain local preview owner: $forbidden"
}
}
Write-Output 'T04_RELATIVE_REMOTE_WRITE_CONTRACT PASS'
@@ -0,0 +1,33 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding UTF8
foreach ($required in @(
'appApi.getPerson(',
'createRequestController',
'isRequestCancelled',
'editRequestController.abort()',
'let loadSequence = 0',
'appApi.updatePerson(',
'failedAction.value = "save"'
)) {
if (-not $page.Contains($required)) {
throw "T05 remote close contract missing: $required"
}
}
foreach ($forbidden in @(
'@/data/mock.js',
'findTreeMemberPresentationFixture',
'setTimeout(',
'editState.value = "preview"',
'"no-permission"',
'editState.value = "unavailable"'
)) {
if ($page.Contains($forbidden)) {
throw "T05 must not retain local preview owner: $forbidden"
}
}
Write-Output 'T05_MEMBER_REMOTE_WRITE_CONTRACT PASS'
+33
View File
@@ -0,0 +1,33 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding UTF8
foreach ($required in @(
'class="rank-page"',
'appApi.getPerson(',
'createRequestController',
'isRequestCancelled',
'rankRequestController.abort()',
'let loadSequence = 0',
'query.mode !== "rank"',
'rankState.value = "unavailable"'
)) {
if (-not $page.Contains($required)) {
throw "T06 rank remote close contract missing: $required"
}
}
foreach ($forbidden in @(
'@/data/mock.js',
'listTreeMemberFixtures',
'setTimeout(',
'relationshipOptions',
'relationshipState'
)) {
if ($page.Contains($forbidden)) {
throw "T06 must not retain relationship local-preview owner: $forbidden"
}
}
Write-Output 'T06-RANK-REMOTE-CLOSE-CONTRACT PASS'
+2 -54
View File
@@ -90,50 +90,10 @@ class FakeTac {
const host = { innerHTML: "rendered" };
const loadedStyle = { dataset: { jiapuState: "loaded" }, sheet: {} };
const backgroundControl = {
focusCount: 0,
focus() {
this.focusCount += 1;
document.activeElement = this;
},
};
const refreshControl = {
offsetParent: {},
focus() {
document.activeElement = this;
},
};
const closeControl = {
offsetParent: {},
focus() {
document.activeElement = this;
},
};
const dialog = {
offsetParent: {},
listener: null,
focus() {
document.activeElement = this;
},
querySelector(selector) {
return selector === ".tac-tool--refresh" ? refreshControl : null;
},
querySelectorAll() {
return [refreshControl, closeControl];
},
addEventListener(name, listener) {
if (name === "keydown") this.listener = listener;
},
removeEventListener(name, listener) {
if (name === "keydown" && this.listener === listener) this.listener = null;
},
};
const document = {
activeElement: backgroundControl,
head: { appendChild() {} },
querySelector(selector) {
if (selector === "#jiapu-tac-host") return host;
if (selector === "#jiapu-tac-dialog") return dialog;
if (selector.startsWith("link[data-jiapu-tac=")) return loadedStyle;
return null;
},
@@ -228,23 +188,12 @@ const run = async () => {
subject: "13800138000",
};
instance.context = context;
instance.previousFocus = backgroundControl;
instance.generation += 1;
instance.createTac();
assert.strictEqual(latestTac.initialized, true);
assert.strictEqual(typeof latestTac.config.doSendRequest, "function");
assert.strictEqual(document.activeElement, refreshControl, "打开 TAC 后必须把焦点移入对话框");
document.activeElement = closeControl;
let tabPrevented = false;
dialog.listener({
key: "Tab",
shiftKey: false,
preventDefault() {
tabPrevented = true;
},
});
assert.strictEqual(tabPrevented, true, "TAC 末项 Tab 必须被焦点环截获");
assert.strictEqual(document.activeElement, refreshControl);
latestTac.config.options.btnRefreshFun(null, latestTac);
assert.strictEqual(latestTac.reloadCount, 1, "必须保留供应商原生刷新操作");
const firstSuccess = latestTac.config.options.validSuccess;
firstSuccess(
@@ -283,7 +232,6 @@ const run = async () => {
await instance.onContextChange({ visible: false });
assert.strictEqual(instance.tac, null, "隐藏或卸载验证层必须销毁 SDK 实例");
assert.strictEqual(host.innerHTML, "", "隐藏或卸载验证层必须清空宿主节点");
assert.strictEqual(backgroundControl.focusCount, 1, "关闭 TAC 后必须恢复进入前焦点");
console.log("TAC-RENDERJS-RUNTIME-SMOKE PASS");
};
+28 -21
View File
@@ -1,32 +1,39 @@
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$component = Get-Content -LiteralPath (Join-Path $root 'components/TacVerification.vue') -Raw -Encoding UTF8
foreach ($token in @(
'v-show="visible"', 'id="jiapu-tac-dialog"', 'role="dialog"', 'aria-modal="true"',
'tabindex="-1"', 'aria-labelledby="jiapu-tac-title"',
'aria-describedby="jiapu-tac-description"', '@keydown.esc.stop.prevent="requestCancel"',
'class="tac-tool tac-tool--refresh"', 'aria-label="刷新安全验证"',
'class="tac-tool tac-tool--close"', 'aria-label="关闭安全验证"',
'const previousFocus = document.activeElement', 'activateFocusTrap()',
'deactivateFocusTrap(', 'event.key !== "Tab"', 'previousFocus?.focus?.()',
':deep(.slider-bottom .close-btn)', ':deep(.slider-bottom .refresh-btn)',
'min-width: 48px;', 'min-height: 48px;'
'v-show="visible"',
'id="jiapu-tac-host"',
':prop="renderContext"',
':change:prop="tacRenderer.onContextChange"',
'btnCloseFun:',
'btnRefreshFun:',
'this.tac = new window.TAC(config);'
)) {
if (-not $component.Contains($token)) { throw "TAC 外壳无障碍预检缺少:$token" }
if (-not $component.Contains($token)) { throw "Missing native TAC shell token: $token" }
}
$logicalScript = [regex]::Match($component, '(?s)<script>(?<Body>.*?)</script>').Groups['Body'].Value
foreach ($browserOnly in @('document.', 'window.', 'querySelector(')) {
if ($logicalScript.Contains($browserOnly)) {
throw "TAC 逻辑层不得访问浏览器专属对象:$browserOnly"
}
foreach ($forbidden in @(
'tac-panel',
'tac-heading',
'tac-tool',
'slider-bottom .close-btn',
'slider-bottom .refresh-btn',
'logoUrl:',
'i18n:',
'new window.TAC(config, {'
)) {
if ($component.Contains($forbidden)) { throw "TAC overrides vendor rendering: $forbidden" }
}
$toolButtons = [regex]::Matches($component, '(?s)<button\b[^>]*class="[^"]*tac-tool\b')
if ($toolButtons.Count -lt 2) { throw 'TAC 外壳至少要有刷新与关闭两个原生操作' }
if ([regex]::Matches($component, 'class="tac-tool tac-tool--refresh"').Count -ne 1) { throw 'TAC 刷新操作必须有唯一 owner' }
if ([regex]::Matches($component, 'class="tac-tool tac-tool--close"').Count -ne 1) { throw 'TAC 关闭操作必须有唯一 owner' }
$styleStart = $component.IndexOf('<style scoped>')
if ($styleStart -lt 0) { throw 'Missing minimal TAC mount style' }
$style = $component.Substring($styleStart)
$visualTokens = @('background:', 'border:', 'border-radius:', 'box-shadow:', 'color:', 'font-', 'padding:', 'opacity:', 'transition:')
foreach ($forbidden in $visualTokens) {
if ($style.Contains($forbidden)) { throw "TAC mount adds visual styling: $forbidden" }
}
Write-Output 'TAC-SHELL-ACCESSIBILITY-CONTRACT PASS'
Write-Output 'TAC-VENDOR-NATIVE-SHELL-CONTRACT PASS'
+2 -2
View File
@@ -140,8 +140,8 @@ const run = async () => {
"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",
/^import \{ GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess \}[^\n]+\r?\n/m,
"const GENEALOGY_ACCESS_PRESET = { MEMBER_ONLY: 'MEMBER_ONLY' }; const fromApiGenealogyAccess = () => GENEALOGY_ACCESS_PRESET.MEMBER_ONLY;\n",
)
.replace(
/^import \{ session \}[^\n]+\r?\n/m,