Files
jiapuapp/tests/navigation-flow-contract.ps1
T
2026-07-27 06:50:23 +08:00

676 lines
57 KiB
PowerShell
Raw Blame History

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