$ErrorActionPreference = 'Stop' function Assert-Contains { param([string]$Content, [string]$Expected, [string]$Message) if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message } } function Assert-NotContains { param([string]$Content, [string]$Unexpected, [string]$Message) if ($Content -match [regex]::Escape($Unexpected)) { throw $Message } } function Assert-Matches { param([string]$Content, [string]$Pattern, [string]$Message) if ($Content -notmatch $Pattern) { throw $Message } } function Assert-Count { param([string]$Content, [string]$Expected, [int]$Count, [string]$Message) $actual = ([regex]::Matches($Content, [regex]::Escape($Expected))).Count if ($actual -ne $Count) { throw "$Message,期望 $Count,实际 $actual" } } $root = Split-Path -Parent $PSScriptRoot $a01 = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a01-entry.vue') -Raw -Encoding utf8 $a04 = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a04-register.vue') -Raw -Encoding utf8 $a05 = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a05-reset-password.vue') -Raw -Encoding utf8 $a06 = Get-Content -LiteralPath (Join-Path $root 'pages/auth/a06-auth-status.vue') -Raw -Encoding utf8 $g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8 $g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8 $g05 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding utf8 $g06 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g06-search-genealogies.vue') -Raw -Encoding utf8 $g08 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding utf8 $g09 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding utf8 $g10 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g10-application-review.vue') -Raw -Encoding utf8 $g11 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g11-genealogy-settings.vue') -Raw -Encoding utf8 $g12 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g12-generation-poems.vue') -Raw -Encoding utf8 $t01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8 $t03 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding utf8 $t04 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t04-add-relative.vue') -Raw -Encoding utf8 $t05 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t05-edit-member.vue') -Raw -Encoding utf8 $t06 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t06-edit-relationship.vue') -Raw -Encoding utf8 $t07 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t07-member-directory.vue') -Raw -Encoding utf8 $t08 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t08-member-states.vue') -Raw -Encoding utf8 $f01 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f01-family-feed.vue') -Raw -Encoding utf8 $f02 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f02-publish-feed.vue') -Raw -Encoding utf8 $f03 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f03-feed-detail.vue') -Raw -Encoding utf8 $f04 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f04-article-list.vue') -Raw -Encoding utf8 $f05 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f05-article-detail.vue') -Raw -Encoding utf8 $f06 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding utf8 $f07 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f07-album-list.vue') -Raw -Encoding utf8 $f08 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f08-album-detail.vue') -Raw -Encoding utf8 $f09 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f09-media-upload.vue') -Raw -Encoding utf8 $f10 = Get-Content -LiteralPath (Join-Path $root 'pages/family/f10-video-list.vue') -Raw -Encoding utf8 $r01 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r01-people-list.vue') -Raw -Encoding utf8 $r02 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r02-person-detail.vue') -Raw -Encoding utf8 $r03 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r03-gift-list.vue') -Raw -Encoding utf8 $r04 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r04-gift-editor.vue') -Raw -Encoding utf8 $r05 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r05-ritual-list.vue') -Raw -Encoding utf8 $r06 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r06-ritual-detail.vue') -Raw -Encoding utf8 $r07 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r07-ritual-editor.vue') -Raw -Encoding utf8 $r08 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r08-growth-journal.vue') -Raw -Encoding utf8 $r09 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r09-life-events.vue') -Raw -Encoding utf8 $r10 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r10-memo-list.vue') -Raw -Encoding utf8 $r11 = Get-Content -LiteralPath (Join-Path $root 'pages/records/r11-merit-records.vue') -Raw -Encoding utf8 $n01 = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n01-message-center.vue') -Raw -Encoding utf8 $n02 = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n02-message-detail.vue') -Raw -Encoding utf8 $m01 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m01-profile-home.vue') -Raw -Encoding utf8 $m02 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m02-edit-profile.vue') -Raw -Encoding utf8 $m03 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m03-security-settings.vue') -Raw -Encoding utf8 $m04 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m04-change-password.vue') -Raw -Encoding utf8 $m05 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m05-change-phone.vue') -Raw -Encoding utf8 $m06 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m06-help-center.vue') -Raw -Encoding utf8 $m07 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m07-feedback.vue') -Raw -Encoding utf8 $m08 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m08-promotion.vue') -Raw -Encoding utf8 $m09 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m09-vip-orders.vue') -Raw -Encoding utf8 $m10 = Get-Content -LiteralPath (Join-Path $root 'pages/profile/m10-about-settings.vue') -Raw -Encoding utf8 $mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding utf8 $navigation = Get-Content -LiteralPath (Join-Path $root 'utils/navigation.js') -Raw -Encoding utf8 $routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding utf8 foreach ($page in @( @{ Key = 'A01'; Content = $a01 }, @{ Key = 'A04'; Content = $a04 }, @{ Key = 'A05'; Content = $a05 }, @{ Key = 'A06'; Content = $a06 } )) { foreach ($forbidden in @( 'uni.navigateTo(', 'uni.navigateBack(', 'uni.redirectTo(', 'uni.reLaunch(', 'getCurrentPages(', '' -Message "$($page.Key) 必须接管 Android 返回入口" Assert-Contains -Content $page.Content -Expected 'handleBackPress(event, requestBack)' -Message "$($page.Key) 必须通过同步适配器处理 Android 返回" } foreach ($page in @( @{ Key = 'A04'; Content = $a04 }, @{ Key = 'A05'; Content = $a05 }, @{ Key = 'A06'; Content = $a06 } )) { Assert-Contains -Content $page.Content -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message "$($page.Key) 必须把同步布尔结果原样返回平台" } Assert-Contains -Content $a01 -Expected 'openPage("A05", {}, "A01")' -Message 'A01 忘记密码必须声明 A05 与 A01 来源' Assert-Contains -Content $a01 -Expected 'openPage("A04", {}, "A01")' -Message 'A01 注册入口必须声明 A04 与 A01 来源' Assert-Matches -Content $a01 -Pattern '(?s)const requestBack = \(\) =>\s*runBackGuard\(\{\s*transientOpen: tacVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*"close-transient": closeTac,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return true;\s*\},\s*\}\);' -Message 'A01 返回守卫必须先关闭真实 TAC,并允许显式取消认证请求' Assert-Matches -Content $a01 -Pattern '(?s)onBackPress\(\(event\) => \{\s*if \(!tacVisible\.value && \(submitting\.value \|\| sendingCode\.value\)\) \{\s*cancelPendingRequest\(\);\s*return false;\s*\}\s*if \(!tacVisible\.value\) return false;\s*return handleBackPress\(event, requestBack\);\s*\}\);' -Message 'A01 Android 返回必须先取消认证请求,再把根页退出交还平台' Assert-Contains -Content $a04 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'A04 必须使用项目对话框确认放弃表单' Assert-Contains -Content $a04 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'A04 必须消费唯一放弃确认控制器' Assert-Contains -Content $a04 -Expected 'const isDirty = computed(() =>' -Message 'A04 必须计算未保存表单状态' Assert-Matches -Content $a04 -Pattern '(?s)const requestBack = \(\) =>\s*runBackGuard\(\{\s*transientOpen: tacVisible\.value \|\| discardVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*dirty: isDirty\.value,\s*"close-transient": tacVisible\.value \? closeTac : cancelDiscard,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return requestBack\(\);\s*\},\s*"confirm-discard": requestDiscardConfirmation,\s*\}\);' -Message 'A04 requestBack 必须优先关闭 TAC;请求中返回先中止网络,再恢复脏表单守卫' Assert-Contains -Content $a04 -Expected 'discardConfirmation.dispose();' -Message 'A04 卸载时必须通过唯一控制器释放等待者' Assert-Count -Content $a04 -Expected '@click="requestBack"' -Count 2 -Message 'A04 页头和已有账号入口必须共用 requestBack' Assert-Count -Content $a04 -Expected '' -Message 'A05 必须计算未保存表单状态' Assert-Matches -Content $a05 -Pattern '(?s)const requestBack = \(\) => \{\s*if \(successVisible\.value\) return leaveResetSuccess\(\);\s*return runBackGuard\(\{\s*transientOpen: tacVisible\.value \|\| discardVisible\.value,\s*submitting: submitting\.value \|\| sendingCode\.value,\s*dirty: isDirty\.value,\s*"close-transient": tacVisible\.value \? closeTac : cancelDiscard,\s*"block-submitting": \(\) => \{\s*cancelPendingRequest\(\);\s*return requestBack\(\);\s*\},\s*"confirm-discard": requestDiscardConfirmation,\s*\}\);\s*\};' -Message 'A05 requestBack 必须先处理成功终态;请求中返回先中止网络,再恢复放弃守卫' Assert-Contains -Content $a05 -Expected 'discardConfirmation.dispose();' -Message 'A05 卸载时必须通过唯一控制器释放等待者' Assert-Count -Content $a05 -Expected '@click="requestBack"' -Count 2 -Message 'A05 页头和想起密码入口必须共用 requestBack' Assert-Count -Content $a05 -Expected ' returnTo("A01", {});' -Message 'A05 远端成功终态必须无结果返回 A01' Assert-Contains -Content $a05 -Expected 'if (successVisible.value) return leaveResetSuccess();' -Message 'A05 Android 返回必须从成功终态退出,不能重新暴露已提交表单' Assert-Contains -Content $a05 -Expected '@confirm="leaveResetSuccess"' -Message 'A05 成功对话框 CTA 必须复用唯一成功终态出口' Assert-Count -Content $a05 -Expected 'returnTo("A01", {})' -Count 1 -Message 'A05 无结果成功终点只能定义一次' Assert-NotContains -Content $a05 -Unexpected 'finishPage(' -Message 'A05 重设成功不应发出跨页业务结果' Assert-NotContains -Content $a05 -Unexpected 'password-reset' -Message 'A05 不得伪造 password-reset 导航结果' foreach ($form in @( @{ Key = 'A04'; Content = $a04; NonMaskDialogCount = 1 }, @{ Key = 'A05'; Content = $a05; NonMaskDialogCount = 2 } )) { Assert-Matches -Content $form.Content -Pattern '(?s)const discardConfirmation = createDiscardConfirmation\(\(visible\) => \{\s*discardVisible\.value = visible;\s*\}\);' -Message "$($form.Key) 必须把对话框可见状态交给唯一控制器" foreach ($binding in @( 'const requestDiscardConfirmation = discardConfirmation.request;', 'const confirmDiscard = discardConfirmation.confirm;', 'const cancelDiscard = discardConfirmation.cancel;' )) { Assert-Contains -Content $form.Content -Expected $binding -Message "$($form.Key) 缺少唯一控制器绑定:$binding" } foreach ($duplicateOwner in @('let discardPromise', 'resolveDiscardConfirmation', 'settleDiscardConfirmation')) { Assert-NotContains -Content $form.Content -Unexpected $duplicateOwner -Message "$($form.Key) 不得保留页面级放弃确认 owner:$duplicateOwner" } Assert-Count -Content $form.Content -Expected ':close-on-mask="false"' -Count $form.NonMaskDialogCount -Message "$($form.Key) 的确认与成功终态框不得被遮罩点击隐式关闭" Assert-Count -Content $form.Content -Expected '@confirm="confirmDiscard"' -Count 1 -Message "$($form.Key) 放弃确认只能有一个确认入口" Assert-Count -Content $form.Content -Expected '@cancel="cancelDiscard"' -Count 1 -Message "$($form.Key) 放弃确认只能有一个取消入口" } Assert-Contains -Content $a06 -Expected 'const resolveStatus = (options = {}) =>' -Message 'A06 必须直接消费 onLoad 页面参数' Assert-Contains -Content $a06 -Expected 'options.status || "risk"' -Message 'A06 必须从 onLoad 参数解析状态并安全回退' Assert-NotContains -Content $a06 -Unexpected 'URLSearchParams' -Message 'A06 不得保留浏览器地址解析旁路' Assert-NotContains -Content $a06 -Unexpected 'onShow(' -Message 'A06 不得在 onShow 重读已丢失的路由参数' Assert-Matches -Content $a06 -Pattern '(?s)const requestBack = \(\) => \{\s*if \(recoveryVisible\.value\) \{\s*return runBackGuard\(\{\s*transientOpen: true,\s*"close-transient": closeRecovery,\s*\}\);\s*\}\s*return goRoot\("A01"\);\s*\};' -Message 'A06 必须先通过守卫关闭恢复浮层,否则回到唯一认证根页' Assert-Count -Content $a06 -Expected '@click="requestBack"' -Count 2 -Message 'A06 页头和返回登录入口必须共用 requestBack' foreach ($page in @( @{ Key = 'G01'; Content = $g01 }, @{ Key = 'G03'; Content = $g03 }, @{ Key = 'G05'; Content = $g05 }, @{ Key = 'G06'; Content = $g06 }, @{ Key = 'G08'; Content = $g08 }, @{ Key = 'G09'; Content = $g09 }, @{ Key = 'G10'; Content = $g10 }, @{ Key = 'G11'; Content = $g11 }, @{ Key = 'G12'; Content = $g12 } )) { foreach ($forbidden in @( 'uni.navigateTo(', 'uni.navigateBack(', 'uni.redirectTo(', 'uni.reLaunch(', 'getCurrentPages(', '\s*openPage\("G05", \{ genealogyId: String\(genealogy\.id\) \}, "G01"\)\.then\(\s*\(opened\) => \{\s*if \(opened\) \{\s*selectedGenealogyId\.value = String\(genealogy\.id\);\s*genealogyContext\.setCurrentGenealogyId\(genealogy\.id\);\s*\}\s*return opened;\s*\},\s*\);' -Message 'G01 只有导航成功后才能同步页面与持久 context,避免并发点击串谱' Assert-Matches -Content $g01 -Pattern '(?s)const openShortcut = \(key\) => \{(?:(?!setCurrentGenealogyId).)*const actions =' -Message 'G01 快捷入口不得在导航结果确定前改写当前家谱 context' Assert-Contains -Content $g01 -Expected 'openPage("G03", {}, "G01")' -Message 'G01 创建入口必须进入 G03' Assert-Contains -Content $g01 -Expected 'openPage("G06", {}, "G01")' -Message 'G01 搜索入口必须进入 G06' Assert-Contains -Content $g01 -Expected 'openPage("G06", { mode: "invite" }, "G01")' -Message 'G01 邀请码入口必须进入 G06 邀请模式' Assert-Contains -Content $g01 -Expected 'openPage("N01", notificationParams, "G01")' -Message 'G01 通知入口必须声明 N01 与当前家谱上下文' foreach ($mapping in @( 'tree: () => openPage("T01", { genealogyId }, "G01")', 'members: () => openPage("G05", { genealogyId }, "G01")', 'poem: () => openPage("G12", { genealogyId }, "G01")', 'applications: () => openPage("G10", { genealogyId }, "G01")' )) { Assert-Contains -Content $g01 -Expected $mapping -Message "G01 快捷入口缺少规范映射:$mapping" } Assert-Contains -Content $g01 -Expected 'openPage("G09", { status: "pending" }, "G01")' -Message 'G01 审核中记录必须进入 G09' Assert-Contains -Content $g01 -Expected 'openPage(' -Message 'G01 必须通过导航网关进入申请流程' Assert-Matches -Content $g01 -Pattern '(?s)return openPage\(\s*"G08",\s*\{\s*genealogyId: String\(record\.genealogyId\),\s*source: "search",\s*\},\s*"G01",\s*\);' -Message 'G01 被拒绝或已退出记录必须携带字符串家谱 ID 进入 G08' Assert-Matches -Content $g01 -Pattern '(?s)const createGenealogy = \(\) => \{\s*closeAddDialog\(\);\s*return openPage\("G03", \{\}, "G01"\);\s*\};' -Message 'G01 添加面板创建入口必须先关闭面板再跳转' Assert-Matches -Content $g01 -Pattern '(?s)const requestBack = \(\) =>\s*runBackGuard\(\{\s*transientOpen: switcherVisible\.value \|\| addDialogVisible\.value,\s*"close-transient": closeActiveOverlay,\s*\}\);' -Message 'G01 返回守卫必须只关闭当前浮层' Assert-Matches -Content $g01 -Pattern '(?s)onBackPress\(\(event\) => \{\s*if \(!switcherVisible\.value && !addDialogVisible\.value\) return false;\s*return handleBackPress\(event, requestBack\);\s*\}\);' -Message 'G01 无浮层时必须把根页 Android 返回交还平台' Assert-Contains -Content $g03 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'G03 脏表单必须使用项目对话框' Assert-Contains -Content $g03 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'G03 必须消费唯一放弃确认控制器' Assert-Contains -Content $g03 -Expected 'import { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation.js";' -Message 'G03 必须只消费导航网关和返回守卫' Assert-Contains -Content $g03 -Expected '@back="requestBack"' -Message 'G03 页头必须复用统一返回守卫' Assert-Contains -Content $g03 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G03 Android 返回必须使用同步适配器' Assert-Contains -Content $g03 -Expected 'const isDirty = computed(() =>' -Message 'G03 必须识别未保存表单' Assert-Contains -Content $g03 -Expected 'internalTrail: isAncestorStep.value' -Message 'G03 首代步骤必须作为同页内部轨迹返回' Assert-Contains -Content $g03 -Expected 'submitting: isSubmitting.value' -Message 'G03 提交期间必须阻止离开' Assert-Contains -Content $g03 -Expected 'transientOpen: duplicateReminderVisible.value || discardVisible.value' -Message 'G03 返回必须先处理重复提醒或放弃确认层' Assert-Contains -Content $g03 -Expected 'currentStep.value = "ancestor";' -Message 'G03 创建预览成功后必须在同页进入首代步骤' Assert-Matches -Content $g03 -Pattern '(?s)const confirmCreate = \(\) => \{\s*if \(isSubmitting\.value\) return;\s*closeDuplicateReminder\(\);' -Message 'G03 重复确认必须在创建定时器产生前被原子阻止' Assert-Contains -Content $g03 -Expected 'const createSnapshot = Object.freeze({ ...createForm });' -Message 'G03 创建校验通过后必须快照本次提交值' Assert-Contains -Content $g03 -Expected 'const ancestorSnapshot = Object.freeze({ ...ancestorForm });' -Message 'G03 首代校验通过后必须快照本次提交值' Assert-Contains -Content $g03 -Expected 'if (submitTimer !== timer) return;' -Message 'G03 定时器回调必须校验自己仍是当前提交' Assert-Contains -Content $g03 -Expected 'openPage("G06", {}, "G03")' -Message 'G03 搜索已有家谱必须通过网关' Assert-Contains -Content $g03 -Expected 'const enterOverview = () =>' -Message 'G03 必须定义唯一成功终态出口' Assert-Contains -Content $g03 -Expected 'returnTo("G05", { genealogyId: genealogyId.value });' -Message 'G03 本地成功终态必须无结果进入 G05' Assert-Count -Content $g03 -Expected 'returnTo("G05", { genealogyId: genealogyId.value })' -Count 1 -Message 'G03 无结果成功终点只能定义一次' Assert-Contains -Content $g03 -Expected 'discardConfirmation.dispose();' -Message 'G03 卸载时必须释放放弃确认等待者' foreach ($forbidden in @('query?.step', 'step=ancestor', 'finishPage(', 'genealogy-created', 'application-created')) { Assert-NotContains -Content $g03 -Unexpected $forbidden -Message "G03 本地预览不得保留旧流程或伪造后端成功:$forbidden" } Assert-NotContains -Content $g03 -Unexpected 'onLoad(' -Message 'G03 没有路由业务参数,不得保留无语义的 onLoad 兼容壳' Assert-Contains -Content $g05 -Expected 'appApi.getOverview(genealogyId.value' -Message 'G05 必须消费真实家谱概览 owner' Assert-Matches -Content $g05 -Pattern '(?s)import \{\s*goBack,\s*goRoot,\s*handleBackPress,\s*openPage,\s*returnTo,\s*\} from "@/utils/navigation\.js";' -Message 'G05 必须只消费导航网关与 Android 返回适配器' Assert-Contains -Content $g05 -Expected '@back="requestBack"' -Message 'G05 页头必须使用统一返回入口' Assert-Contains -Content $g05 -Expected 'const requestBack = () => goBack();' -Message 'G05 返回必须保留真实栈并支持深链父页回退' Assert-Contains -Content $g05 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G05 Android 返回必须使用同步适配器' Assert-Contains -Content $g05 -Expected 'const accessRole = ref("guest");' -Message 'G05 未验证访问默认必须是最低权限' Assert-Contains -Content $g05 -Expected 'accessRole.value = result.accessRole;' -Message 'G05 显示角色必须来自概览窄映射' Assert-NotContains -Content $g05 -Unexpected 'findGenealogyFixture' -Message 'G05 不得继续读取本地家谱夹具' Assert-Contains -Content $g05 -Expected 'overviewState.value = "no-permission";' -Message 'G05 未知家谱必须明确拒绝访问' Assert-Contains -Content $g05 -Expected 'returnTo("G01", {})' -Message 'G05 状态页显式按钮必须返回家谱根页' foreach ($mapping in @( 'openPage("T01", { genealogyId: genealogyId.value }, "G05")', 'goRoot("F01", { genealogyId: genealogyId.value })', 'openPage("G10", { genealogyId: genealogyId.value }, "G05")', 'openPage("G11", { genealogyId: genealogyId.value }, "G05")', 'openPage("G12", { genealogyId: genealogyId.value }, "G05")' )) { Assert-Contains -Content $g05 -Expected $mapping -Message "G05 缺少规范入口:$mapping" } Assert-Matches -Content $g05 -Pattern '(?s)openPage\(\s*"G08",\s*\{ genealogyId: genealogyId\.value, source: "search" \},\s*"G05",\s*\)' -Message 'G05 公开申请必须通过 G08 规范入口' Assert-Contains -Content $g05 -Expected 'openPage("G09", { status: "pending" }, "G05")' -Message 'G05 审核中关系必须进入 G09 而不是重复申请' foreach ($forbidden in @('query.mode', 'query.role', 'query.genealogyName', 'toFirstPerson', 'finishPage(', 'genealogy-created')) { Assert-NotContains -Content $g05 -Unexpected $forbidden -Message "G05 不得信任查询权限或保留重复流程:$forbidden" } Assert-Matches -Content $routes -Pattern '(?s)G08:\s*defineRoute\(\{.*?allowedSources:\s*\["G01", "G05", "G06", "G09"\]' -Message 'G08 必须允许 G01 的被拒绝与已退出申请进入' Assert-Contains -Content $g06 -Expected 'import { goRoot, openPage } from "@/utils/navigation.js";' -Message 'G06 必须只消费导航网关' Assert-Matches -Content $g06 -Pattern '(?s)openPage\(\s*"G05",\s*\{ genealogyId: String\(item\.id\) \},\s*"G06",\s*\)' -Message 'G06 公开预览必须只携带字符串家谱 ID 进入 G05' Assert-Matches -Content $g06 -Pattern '(?s)openPage\(\s*"G08",\s*\{ genealogyId: String\(item\.id\), source: "search" \},\s*"G06",\s*\)' -Message 'G06 搜索申请必须进入 G08' Assert-Contains -Content $g06 -Expected 'openPage("G09", { status: "pending" }, "G06")' -Message 'G06 审核中结果必须进入 G09' Assert-Matches -Content $g06 -Pattern '(?s)goRoot\("G01", \{ genealogyId: String\(item\.id\) \}\)' -Message 'G06 已加入或自建家谱必须回到 G01 根语义' Assert-Matches -Content $g06 -Pattern '(?s)openPage\(\s*"G08",\s*\{\s*genealogyId: String\(inviteTarget\.value\.id\),\s*source: "invite",\s*\},\s*"G06",\s*\)' -Message 'G06 邀请码预览必须进入 G08,不能本地直接入谱' foreach ($forbidden in @('genealogyName:', 'previous:', 'mode: "public"', 'finishPage(', 'application-created')) { Assert-NotContains -Content $g06 -Unexpected $forbidden -Message "G06 不得传播未声明业务状态或伪成功:$forbidden" } Assert-Contains -Content $g08 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'G08 脏表单必须使用项目对话框' Assert-Matches -Content $g08 -Pattern '(?s)import \{\s*findGenealogyFixture,\s*getGenealogyFixtureAccess,\s*\} from "@/data/mock\.js";' -Message 'G08 名称与申请资格必须来自共享夹具 owner' Assert-Contains -Content $g08 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'G08 必须消费唯一放弃确认控制器' foreach ($navigationSymbol in @('goBack', 'goRoot', 'handleBackPress', 'openPage', 'returnTo', 'runBackGuard')) { Assert-Contains -Content $g08 -Expected $navigationSymbol -Message "G08 缺少统一导航语义:$navigationSymbol" } Assert-Contains -Content $g08 -Expected '' -Message 'G08 页头必须复用统一返回守卫' Assert-Contains -Content $g08 -Expected 'const genealogyId = ref("");' -Message 'G08 必须持有词法家谱 ID' Assert-Contains -Content $g08 -Expected 'genealogyId.value = String(query.genealogyId || "");' -Message 'G08 路由家谱 ID 必须保持字符串' Assert-Contains -Content $g08 -Expected 'const fixture = findGenealogyFixture(genealogyId.value);' -Message 'G08 必须从共享 owner 读取家谱名称' Assert-Contains -Content $g08 -Expected 'const access = getGenealogyFixtureAccess(genealogyId.value);' -Message 'G08 必须从共享 owner 读取申请资格' Assert-Contains -Content $g08 -Expected 'if (!fixture || !access.canApply)' -Message 'G08 未知、已加入、所有者与审核中关系必须失败关闭' Assert-Contains -Content $g08 -Expected 'const isDirty = computed(() =>' -Message 'G08 必须识别未保存表单' Assert-Contains -Content $g08 -Expected 'submitting: isSubmitting.value' -Message 'G08 提交期间必须阻止离开' Assert-Contains -Content $g08 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G08 Android 返回必须使用同步适配器' Assert-Contains -Content $g08 -Expected 'discardConfirmation.dispose();' -Message 'G08 卸载必须释放确认等待者' Assert-Matches -Content $g08 -Pattern '(?s)onUnload\(\(\) => \{\s*const timer = submitTimer;\s*submitTimer = null;\s*if \(timer\) clearTimeout\(timer\);\s*discardConfirmation\.dispose\(\);\s*\}\);' -Message 'G08 卸载必须先作废再清理本地预览定时器与确认等待者' Assert-Contains -Content $g08 -Expected 'const submitSnapshot = Object.freeze({ ...form });' -Message 'G08 本地校验必须快照本次提交值' Assert-Contains -Content $g08 -Expected 'if (submitTimer !== timer) return;' -Message 'G08 定时器回调必须校验自己仍是当前提交' Assert-Contains -Content $g08 -Expected 'returnTo("G09", {})' -Message 'G08 搜索申请本地成功只能无结果进入 G09' Assert-Contains -Content $g08 -Expected 'returnTo("G01", {})' -Message 'G08 邀请码本地成功不得选中新家谱' Assert-Contains -Content $g08 -Expected '本地流程预览' -Message 'G08 必须明确本地成功尚未提交服务器' foreach ($forbidden in @('query.previous', 'query.genealogyName', 'previousRelation', 'decodeURIComponent', 'genealogyPreview', 'finishPage(', 'application-created', '已直接加入', '无需等待审核')) { Assert-NotContains -Content $g08 -Unexpected $forbidden -Message "G08 不得消费旧查询字段或伪造后端成功:$forbidden" } Assert-Contains -Content $g09 -Expected 'import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";' -Message 'G09 必须只消费导航网关与浮层返回守卫' Assert-Contains -Content $g09 -Expected '' -Message 'G09 页头必须复用浮层返回守卫' Assert-Contains -Content $g09 -Expected ':close-on-mask="false"' -Message 'G09 撤回确认不得由遮罩隐式关闭' Assert-Count -Content $g09 -Expected '@cancel="cancelWithdraw"' -Count 1 -Message 'G09 撤回取消只能有一个入口' Assert-NotContains -Content $g09 -Unexpected '@close="cancelWithdraw"' -Message 'G09 不得由 cancel 与 close 重复处理同一次关闭' Assert-Contains -Content $g09 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G09 Android 返回必须先关闭撤回确认层' Assert-Contains -Content $g09 -Expected 'LOCAL_WITHDRAWN' -Message 'G09 本地撤回必须使用非领域预览状态' Assert-Contains -Content $g09 -Expected '尚未提交服务器' -Message 'G09 本地撤回必须明确不改变服务器申请' Assert-NotContains -Content $g09 -Unexpected 'target.status = "WITHDRAWN"' -Message 'G09 不得把本地预览写成真实撤回状态' Assert-Matches -Content $g09 -Pattern '(?s)openPage\(\s*"G05",\s*\{ genealogyId: String\(item\.genealogyId\) \},\s*"G09",\s*\)' -Message 'G09 已通过申请必须进入可信家谱总览' Assert-Matches -Content $g09 -Pattern '(?s)openPage\(\s*"G08",\s*\{ genealogyId: String\(item\.genealogyId\), source: "search" \},\s*"G09",\s*\)' -Message 'G09 重新申请必须只携带注册字段进入 G08' Assert-Contains -Content $g09 -Expected 'openPage("G06", {}, "G09")' -Message 'G09 搜索入口必须进入 G06' foreach ($forbidden in @('previous:', 'finishPage(', 'application-created')) { Assert-NotContains -Content $g09 -Unexpected $forbidden -Message "G09 本地状态不得传播旧字段或结果:$forbidden" } Assert-Contains -Content $g10 -Expected 'import { getGenealogyFixtureAccess } from "@/data/mock.js";' -Message 'G10 审核权限必须来自共享夹具解析器' Assert-Contains -Content $g10 -Expected 'import { handleBackPress, runBackGuard } from "@/utils/navigation.js";' -Message 'G10 必须使用统一浮层返回守卫' Assert-Contains -Content $g10 -Expected 'genealogyId.value = String(query.genealogyId || "");' -Message 'G10 家谱 ID 必须保持词法字符串' Assert-Contains -Content $g10 -Expected 'getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"' -Message 'G10 非 owner 必须失败关闭' Assert-Contains -Content $g10 -Expected '@back="requestBack"' -Message 'G10 页头必须复用返回守卫' Assert-Contains -Content $g10 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G10 Android 返回必须先关闭审核或说明弹层' Assert-Contains -Content $g10 -Expected ':close-on-mask="false"' -Message 'G10 审核弹层不得由遮罩隐式关闭' Assert-Count -Content $g10 -Expected '@cancel="closeDialog"' -Count 1 -Message 'G10 弹层取消只能有一个入口' Assert-NotContains -Content $g10 -Unexpected '@close="closeDialog"' -Message 'G10 不得重复处理 cancel 与 close' Assert-Contains -Content $g10 -Expected '尚未提交服务器' -Message 'G10 本地审核必须明确未提交服务器' foreach ($forbidden in @('query.genealogyId?.value', 'genealogyContext', 'finishPage(', 'application-reviewed')) { Assert-NotContains -Content $g10 -Unexpected $forbidden -Message "G10 不得保留兼容读取、上下文旁路或本地结果:$forbidden" } Assert-Contains -Content $g11 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'G11 脏表单必须使用项目对话框' Assert-Matches -Content $g11 -Pattern '(?s)import \{\s*findGenealogyFixture,\s*getGenealogyFixtureAccess,\s*\} from "@/data/mock\.js";' -Message 'G11 数据和显示角色必须来自共享夹具 owner' Assert-Contains -Content $g11 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'G11 必须消费唯一放弃确认控制器' Assert-Contains -Content $g11 -Expected 'import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";' -Message 'G11 必须使用统一导航守卫' Assert-Contains -Content $g11 -Expected 'genealogyId.value = String(query.genealogyId || "");' -Message 'G11 家谱 ID 必须保持词法字符串' Assert-Contains -Content $g11 -Expected 'getGenealogyFixtureAccess(genealogyId.value).accessRole !== "owner"' -Message 'G11 非 owner 必须失败关闭' Assert-Contains -Content $g11 -Expected 'const isDirty = computed(' -Message 'G11 必须识别未保存表单' Assert-Contains -Content $g11 -Expected '@back="requestBack"' -Message 'G11 页头必须复用返回守卫' Assert-Contains -Content $g11 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G11 Android 返回必须使用同步适配器' Assert-Contains -Content $g11 -Expected 'discardConfirmation.dispose();' -Message 'G11 卸载必须释放确认等待者' Assert-Contains -Content $g11 -Expected '本地预览已更新,尚未提交服务器' -Message 'G11 本地保存必须明确未提交服务器' Assert-Contains -Content $g11 -Expected 'genealogyDraft.name.trim().length > 20' -Message 'G11 名称运行时上限必须与输入控件 20 字约束一致' Assert-Contains -Content $g11 -Expected '@click="handleResultAction"' -Message 'G11 错误与无权限状态必须通过受控动作处理' foreach ($forbidden in @('settingsFixtures', '@click="settingsState = ''form''"', 'finishPage(', 'genealogy-settings-updated')) { Assert-NotContains -Content $g11 -Unexpected $forbidden -Message "G11 不得绕过权限或伪造后端保存:$forbidden" } Assert-Contains -Content $g12 -Expected 'import AppDialog from "@/components/AppDialog.vue";' -Message 'G12 编辑取消必须使用项目对话框' Assert-Matches -Content $g12 -Pattern '(?s)import \{\s*findGenealogyFixture,\s*getGenealogyFixtureAccess,\s*\} from "@/data/mock\.js";' -Message 'G12 数据与维护权限必须来自共享夹具解析器' Assert-Contains -Content $g12 -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message 'G12 必须消费唯一放弃确认控制器' Assert-Contains -Content $g12 -Expected 'import { handleBackPress, runBackGuard } from "@/utils/navigation.js";' -Message 'G12 必须使用统一返回守卫' Assert-Contains -Content $g12 -Expected 'genealogyId.value = String(query.genealogyId || "");' -Message 'G12 家谱 ID 必须保持词法字符串' Assert-Contains -Content $g12 -Expected 'const canManage = ref(false);' -Message 'G12 必须显式持有维护权限' Assert-Contains -Content $g12 -Expected 'const editorOrigin = ref("list");' -Message 'G12 必须记录编辑器来自列表还是空状态' Assert-Contains -Content $g12 -Expected 'const editorSnapshot = ref(null);' -Message 'G12 必须保存进入编辑器前的草稿快照' Assert-Contains -Content $g12 -Expected 'const isDirty = computed(() =>' -Message 'G12 必须识别编辑器脏状态' Assert-Contains -Content $g12 -Expected 'const requestLeaveEditor = async () =>' -Message 'G12 取消必须经过统一脏表单确认' Assert-Contains -Content $g12 -Expected 'restoreEditorSnapshot();' -Message 'G12 放弃编辑必须恢复进入前快照' Assert-Contains -Content $g12 -Expected '@back="requestBack"' -Message 'G12 页头必须复用返回守卫' Assert-Contains -Content $g12 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'G12 Android 返回必须使用同步适配器' Assert-Contains -Content $g12 -Expected 'discardConfirmation.dispose();' -Message 'G12 卸载必须释放确认等待者' foreach ($forbidden in @('@click="poemState = ''list''"', 'finishPage(', 'generation-poems-updated')) { Assert-NotContains -Content $g12 -Unexpected $forbidden -Message "G12 不得静默丢弃编辑或伪造后端保存:$forbidden" } foreach ($entry in @( @{ Key = 'T01'; Content = $t01 }, @{ Key = 'T03'; Content = $t03 }, @{ Key = 'T04'; Content = $t04 }, @{ Key = 'T05'; Content = $t05 }, @{ Key = 'T06'; Content = $t06 }, @{ Key = 'T07'; Content = $t07 }, @{ Key = 'T08'; Content = $t08 } )) { foreach ($forbidden in @('uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', '/pages/')) { Assert-NotContains -Content $entry.Content -Unexpected $forbidden -Message "$($entry.Key) 必须只使用导航网关:$forbidden" } } foreach ($mapping in @( 'openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")', 'openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")', 'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")', 'openPage("T07", { genealogyId: genealogyId.value }, "T01")' )) { Assert-Contains -Content $t01 -Expected $mapping -Message "T01 缺少规范入口:$mapping" } Assert-Contains -Content $t03 -Expected 'consumeNavigationResult("T03")' -Message 'T03 必须消费 single 页面激活请求' Assert-Contains -Content $t03 -Expected 'internalTrail: trailIndex.value > 0' -Message 'T03 返回必须优先消费页内成员轨迹' Assert-Contains -Content $t03 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'T03 Android 返回必须复用页内轨迹守卫' Assert-Contains -Content $t07 -Expected 'openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")' -Message 'T07 必须进入唯一 T03 实例' Assert-Contains -Content $t08 -Expected 'goRoot("G01")' -Message 'T08 权限失效动作必须进入 G01 根语义' Assert-Contains -Content $t08 -Expected 'goBack()' -Message 'T08 普通动作必须按真实栈返回' foreach ($form in @( @{ Key = 'T04'; Content = $t04; Return = 'returnTo("T01", { genealogyId: genealogyId.value })' }, @{ Key = 'T05'; Content = $t05; Return = 'return goBack();' }, @{ Key = 'T06'; Content = $t06; Return = 'returnTo("T01", { genealogyId: genealogyId.value })' } )) { Assert-Contains -Content $form.Content -Expected '尚未提交服务器' -Message "$($form.Key) 必须明确本地预览没有写入服务器" Assert-Contains -Content $form.Content -Expected $form.Return -Message "$($form.Key) 本地预览必须无结果返回规范目标" foreach ($forbidden in @('finishPage(', 'relative-created', 'member-updated', 'relationship-updated')) { Assert-NotContains -Content $form.Content -Unexpected $forbidden -Message "$($form.Key) 不得在真实写入前伪造结果:$forbidden" } } Assert-Matches -Content $routes -Pattern '(?s)T01:\s*defineRoute\(\{(?:(?!resultOperations).)*?\}\),\s*T03:' -Message 'T01 当前不得预留 mutation 结果能力' Assert-Matches -Content $routes -Pattern '(?s)T03:\s*defineRoute\(\{.*?resultOperations:\s*\["member-open-requested"\]' -Message 'T03 当前只能登记 single 激活结果' foreach ($entry in @( @{ Key = 'F01'; Content = $f01 }, @{ Key = 'F02'; Content = $f02 }, @{ Key = 'F03'; Content = $f03 }, @{ Key = 'F04'; Content = $f04 }, @{ Key = 'F05'; Content = $f05 }, @{ Key = 'F06'; Content = $f06 }, @{ Key = 'F07'; Content = $f07 }, @{ Key = 'F08'; Content = $f08 }, @{ Key = 'F09'; Content = $f09 }, @{ Key = 'F10'; Content = $f10 } )) { foreach ($forbidden in @('uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', '/pages/', 'finishPage(')) { Assert-NotContains -Content $entry.Content -Unexpected $forbidden -Message "$($entry.Key) 必须只使用无伪结果的导航网关:$forbidden" } } Assert-Contains -Content $f01 -Expected 'goRoot("F01", { genealogyId: resolvedGenealogyId })' -Message 'F01 从全局解析家谱后必须先规范化根页 URL' Assert-Contains -Content $f01 -Expected 'listFamilyFeedFixtures(resolvedGenealogyId)' -Message 'F01 必须读取共享家族动态 owner' foreach ($mapping in @( 'openPage("F02", { genealogyId: genealogyId.value }, "F01")', 'genealogyId: genealogyId.value, feedId: String(item.id)', 'return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");' )) { Assert-Contains -Content $f01 -Expected $mapping -Message "F01 缺少保留家谱身份的规范入口:$mapping" } foreach ($form in @( @{ Key = 'F02'; Content = $f02 }, @{ Key = 'F03'; Content = $f03 }, @{ Key = 'F06'; Content = $f06 }, @{ Key = 'F09'; Content = $f09 } )) { Assert-Contains -Content $form.Content -Expected 'import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";' -Message "$($form.Key) 必须消费唯一放弃确认 owner" Assert-Contains -Content $form.Content -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message "$($form.Key) Android 返回必须复用页面守卫" Assert-Contains -Content $form.Content -Expected 'runBackGuard({' -Message "$($form.Key) 页头与 Android 返回必须共享守卫上下文" Assert-Contains -Content $form.Content -Expected 'submitting:' -Message "$($form.Key) 校验中必须阻止离开" Assert-Contains -Content $form.Content -Expected 'discardConfirmation.dispose();' -Message "$($form.Key) 卸载必须释放确认等待者" } Assert-Contains -Content $f02 -Expected '尚未提交服务器' -Message 'F02 只能生成明确未提交的本地预览' Assert-Contains -Content $f02 -Expected 'returnTo("F01", { genealogyId: genealogyId.value })' -Message 'F02 必须无结果返回同一家谱 F01' Assert-Contains -Content $f03 -Expected 'findFamilyFeedFixture(genealogyId.value, feedId.value)' -Message 'F03 必须按复合身份精确查询动态' Assert-Contains -Content $f03 -Expected '草稿已保留' -Message 'F03 评论校验后必须保留草稿并声明未提交' foreach ($forbidden in @('feedComments.value.push', 'commentDraft.value = ""', '评论已发送')) { Assert-NotContains -Content $f03 -Unexpected $forbidden -Message "F03 不得把评论草稿伪装成已发送:$forbidden" } $f01CanonicalIndex = $f01.IndexOf('if (!hasRouteIdentity)') $f01ActivationIndex = $f01.IndexOf('hasValidContext.value = isAccessible;') if ($f01CanonicalIndex -lt 0 -or $f01ActivationIndex -le $f01CanonicalIndex) { throw 'F01 必须在缺参根 URL 规范化分支之后才开放发布与业务快捷入口' } Assert-Contains -Content $f04 -Expected 'listFamilyArticleFixtures(genealogyId.value)' -Message 'F04 必须读取同一家谱的共享谱文 owner' Assert-Contains -Content $f04 -Expected ':action="hasValidContext ? ''新建'' : ''''"' -Message 'F04 无效家谱状态不得暴露新建入口' if ($f04 -notmatch '(?s)const createArticle = \(\) =>\s*hasValidContext\.value\s*\?\s*openPage\(') { throw 'F04 新建处理器必须在调用导航网关前再次校验有效家谱上下文' } Assert-Contains -Content $f05 -Expected 'findFamilyArticleFixture(genealogyId.value, articleId.value)' -Message 'F05 必须按复合身份精确查询谱文' Assert-Contains -Content $f05 -Expected 'disabled label="收藏暂未开放"' -Message 'F05 无线上端点时必须禁用收藏二态' Assert-NotContains -Content $f05 -Unexpected 'toggleFavorite' -Message 'F05 不得保留本地收藏伪持久化' Assert-Contains -Content $f06 -Expected '(editorMode.value === "create" && !articleId.value)' -Message 'F06 create 必须禁止 articleId' Assert-Contains -Content $f06 -Expected '(editorMode.value === "edit" && Boolean(article))' -Message 'F06 edit 必须要求精确文章' Assert-Contains -Content $f06 -Expected '当前尚未提交服务器' -Message 'F06 必须明确本地预览没有保存文章' Assert-Contains -Content $f07 -Expected 'listFamilyAlbumFixtures(genealogyId.value)' -Message 'F07 必须读取同一家谱的共享相册 owner' Assert-Contains -Content $f07 -Expected 'localAlbumPreview.value = Object.freeze({ name });' -Message 'F07 草稿只能进入独立本地预览' Assert-NotContains -Content $f07 -Unexpected 'albums.value.unshift' -Message 'F07 不得把未提交草稿插入正式相册列表' Assert-Contains -Content $f07 -Expected 'onBackPress((event) => handleBackPress(event, requestBack));' -Message 'F07 弹层与草稿必须接管 Android 返回' Assert-Contains -Content $f08 -Expected 'findFamilyAlbumFixture(genealogyId.value, albumId.value)' -Message 'F08 必须按复合身份精确查询相册' Assert-Contains -Content $f08 -Expected 'transientOpen: previewVisible.value' -Message 'F08 返回必须优先关闭照片预览' Assert-Contains -Content $f08 -Expected 'openPage(' -Message 'F08 添加照片必须走导航网关' Assert-Contains -Content $f09 -Expected 'findFamilyAlbumFixture(genealogyId.value, albumId.value)' -Message 'F09 必须按复合身份锁定上传目标' Assert-Contains -Content $f09 -Expected '照片尚未上传' -Message 'F09 本地预览必须明确未上传' foreach ($forbidden in @('uploading', 'uploaded', 'success', 'retryFailed', '/genealogy/app/files/upload', 'media-uploaded')) { Assert-NotContains -Content $f09 -Unexpected $forbidden -Message "F09 不得保留假上传状态或陈旧接口:$forbidden" } Assert-Contains -Content $f10 -Expected 'returnTo("F01", { genealogyId: genealogyId.value })' -Message 'F10 必须保留家谱身份返回 F01' foreach ($operation in @('feed-created', 'article-created', 'article-updated', 'media-uploaded')) { Assert-NotContains -Content $routes -Unexpected $operation -Message "F 系列真实写入前不得预注册结果:$operation" } foreach ($page in @($r01,$r02,$r03,$r04,$r05,$r06,$r07,$r08,$r09,$r10,$r11)) { foreach ($forbidden in @('uni.navigateTo(', 'uni.navigateBack(', 'uni.redirectTo(', 'uni.reLaunch(', '/pages/records/')) { Assert-NotContains -Content $page -Unexpected $forbidden -Message "R 系列必须只通过导航网关消费活动路由:$forbidden" } } foreach ($contract in @( @{ Content = $r01; RouteKey = 'R02' }, @{ Content = $r02; RouteKey = 'R08' }, @{ Content = $r02; RouteKey = 'R09' }, @{ Content = $r03; RouteKey = 'R04' }, @{ Content = $r05; RouteKey = 'R06' }, @{ Content = $r05; RouteKey = 'R07' }, @{ Content = $r06; RouteKey = 'R07' }, @{ Content = $t03; RouteKey = 'R08' }, @{ Content = $t03; RouteKey = 'R09' } )) { Assert-Matches -Content $contract.Content -Pattern "openPage\(\s*`"$($contract.RouteKey)`"" -Message "R 系列缺少注册表流程:$($contract.RouteKey)" } foreach ($operation in @('person-created','person-updated','person-deleted','gift-created','gift-updated','gift-deleted','ritual-created','ritual-updated','ritual-deleted')) { Assert-NotContains -Content $routes -Unexpected $operation -Message "R 系列真实写入前不得预注册结果:$operation" } foreach ($page in @( @{ Key = 'N01'; Content = $n01 }, @{ Key = 'N02'; Content = $n02 }, @{ Key = 'M01'; Content = $m01 }, @{ Key = 'M02'; Content = $m02 }, @{ Key = 'M03'; Content = $m03 }, @{ Key = 'M04'; Content = $m04 }, @{ Key = 'M05'; Content = $m05 }, @{ Key = 'M06'; Content = $m06 }, @{ Key = 'M07'; Content = $m07 }, @{ Key = 'M08'; Content = $m08 }, @{ Key = 'M09'; Content = $m09 }, @{ Key = 'M10'; Content = $m10 } )) { foreach ($forbidden in @( 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', 'getCurrentPages(', '\s*Object\.freeze\(\{ routeKey, params: Object\.freeze\(params\) \}\);' -Message '通知目标定义及参数列表必须不可变' Assert-Matches -Content $routes -Pattern '(?s)export const NOTICE_TARGETS = Object\.freeze\(\{\s*GENEALOGY_REVIEW: defineNoticeTarget\("G10", \["genealogyId"\]\),\s*GENEALOGY_HOME: defineNoticeTarget\("G01", \["genealogyId"\]\),\s*\}\);' -Message '通知目标白名单只能把审核映射到 G10、家谱首页映射到 G01' foreach ($token in @( 'export const openNoticeTarget = (targetType, params, sourceKey = "N02") =>', 'assertScalarString("通知目标类型", targetType);', 'assertScalarString("通知来源", sourceKey);', 'if (!hasOwn(NOTICE_TARGETS, targetType))', 'if (!["N01", "N02"].includes(sourceKey))', 'const normalizedParams = snapshotDataRecord("通知目标参数", params);', 'const expectedParams = new Set(target.params);', 'if (!expectedParams.has(name))', 'assertScalarString(name, normalizedParams[name]);', 'if (!hasOwn(normalizedParams, name))', '? goRoot(target.routeKey, normalizedParams)', ': openPage(target.routeKey, normalizedParams, sourceKey);' )) { Assert-Contains -Content $navigation -Expected $token -Message "通知导航网关缺少安全边界:$token" } $noticeWhitelistIndex = $navigation.IndexOf('if (!hasOwn(NOTICE_TARGETS, targetType))') $noticeLookupIndex = $navigation.IndexOf('const target = NOTICE_TARGETS[targetType];') if ($noticeWhitelistIndex -lt 0 -or $noticeLookupIndex -le $noticeWhitelistIndex) { throw '通知导航网关必须先验证白名单,再读取目标定义' } foreach ($token in @( "targetType: 'GENEALOGY_REVIEW'", "targetType: 'GENEALOGY_HOME'", "targetParams: { genealogyId: '1001' }", 'export const listNotificationFixtures', 'export const findNotificationFixture' )) { Assert-Contains -Content $mock -Expected $token -Message "通知夹具 owner 缺少安全目标字段:$token" } foreach ($forbidden in @('detailId', 'targetUrl', 'returnUrl', "target: '/pages/", 'const noticeDetails')) { Assert-NotContains -Content $mock -Unexpected $forbidden -Message "通知夹具不得保留重复身份、页面 owner 或可执行路径:$forbidden" Assert-NotContains -Content $n01 -Unexpected $forbidden -Message "N01 不得保留重复身份、页面 owner 或可执行路径:$forbidden" Assert-NotContains -Content $n02 -Unexpected $forbidden -Message "N02 不得保留重复身份、页面 owner 或可执行路径:$forbidden" } Assert-Contains -Content $n01 -Expected 'import { listNotificationFixtures } from "@/data/mock.js";' -Message 'N01 必须读取共享通知夹具 owner' Assert-Matches -Content $n01 -Pattern '(?s)openPage\(\s*"N02",\s*\{ id: String\(item\.id\) \},\s*"N01",?\s*\)' -Message 'N01 必须只携带词法消息 ID 并声明 N01 来源进入 N02' Assert-Matches -Content $n01 -Pattern '(?s)genealogyId\.value\s*\?\s*openPage\(\s*"G10",\s*\{ genealogyId: genealogyId\.value \},\s*"N01",?\s*\)' -Message 'N01 审核入口必须缺少家谱 ID 时失败关闭,并通过 G10 注册路由' Assert-Contains -Content $n02 -Expected 'findNotificationFixture(noticeId.value)' -Message 'N02 必须按共享 owner 的词法消息 ID 精确查询' Assert-Contains -Content $n02 -Expected 'noticeDetail.value.unread = false;' -Message 'N02 已读动作必须更新共享 owner 的 unread 字段' Assert-NotContains -Content $n02 -Unexpected 'noticeDetail.read' -Message 'N02 不得创建与 unread 冲突的 read 字段' Assert-Contains -Content $n02 -Expected 'getGenealogyFixtureAccess' -Message 'N02 打开业务目标前必须校验家谱访问权限' Assert-Matches -Content $n02 -Pattern '(?s)return await navigateNoticeTarget\(\s*noticeDetail\.value\.targetType,\s*noticeDetail\.value\.targetParams,\s*\);' -Message 'N02 只能把声明式目标类型和参数交给安全通知网关' if ($n02 -match 'noticeDetail\.value\.target(?![A-Za-z0-9_$])') { throw 'N02 不得消费可执行 target 字段' } Assert-Contains -Content $n02 -Expected 'const backToMessages = () => returnTo("N01", {});' -Message 'N02 页头必须通过返回网关回到 N01' Assert-Contains -Content $n02 -Expected 'onBackPress((event) => handleBackPress(event, backToMessages));' -Message 'N02 Android 返回必须复用唯一 N01 返回动作' foreach ($mapping in @( 'openPage("M02", {}, "M01")', 'openPage("N01", {}, "M01")', 'routeKey: "M03"', 'routeKey: "M06"', 'routeKey: "M08"', 'routeKey: "M09"', 'routeKey: "M10"', 'openPage(item.routeKey, {}, "M01")' )) { Assert-Contains -Content $m01 -Expected $mapping -Message "M01 缺少注册表入口:$mapping" } foreach ($mapping in @('routeKey: "M04"', 'routeKey: "M05"', 'openPage(item.routeKey, {}, "M03")')) { Assert-Contains -Content $m03 -Expected $mapping -Message "M03 缺少注册表入口:$mapping" } Assert-Contains -Content $m06 -Expected 'openPage("M07", {}, "M06")' -Message 'M06 联系支持必须进入注册的 M07 流程' foreach ($form in @( @{ Key = 'M02'; Content = $m02 }, @{ Key = 'M04'; Content = $m04 }, @{ Key = 'M05'; Content = $m05 }, @{ Key = 'M07'; Content = $m07 } )) { foreach ($token in @( 'custom-back', '@back="requestBack"', 'createDiscardConfirmation', 'const isDirty = computed(', 'runBackGuard({', 'dirty: isDirty.value', 'submitting:', 'onBackPress((event) => handleBackPress(event, requestBack));', 'discardConfirmation.dispose();', ':close-on-mask="false"' )) { Assert-Contains -Content $form.Content -Expected $token -Message "$($form.Key) 缺少脏表单返回守卫:$token" } } foreach ($page in @( @{ Key = 'M08'; Content = $m08 }, @{ Key = 'M09'; Content = $m09 }, @{ Key = 'M10'; Content = $m10 } )) { foreach ($token in @( 'custom-back', '@back="requestBack"', 'runBackGuard({', 'transientOpen:', 'onBackPress((event) => handleBackPress(event, requestBack));', ':close-on-mask="false"' )) { Assert-Contains -Content $page.Content -Expected $token -Message "$($page.Key) 缺少浮层优先返回守卫:$token" } } Assert-Matches -Content $m10 -Pattern '(?s)const confirmLogout = \(\) => \{\s*session\.clear\(\);\s*logoutVisible\.value = false;\s*return goRoot\("A01"\);\s*\};' -Message 'M10 退出必须依次清理唯一会话、关闭浮层并进入 A01 根语义' Write-Output 'NAVIGATION-FLOW-CONTRACT PASS AUTH G01-G12 T01-T08 F01-F10 R01-R11 N01-N02 M01-M10'