$ErrorActionPreference = 'Stop' function Assert-Contains { param([string]$Content, [string]$Expected, [string]$Message) if (-not $Content.Contains($Expected)) { throw $Message } } function Assert-NotContains { param([string]$Content, [string]$Unexpected, [string]$Message) if ($Content.Contains($Unexpected)) { throw $Message } } function Assert-Matches { param([string]$Content, [string]$Pattern, [string]$Message) if ($Content -notmatch $Pattern) { throw $Message } } $root = Split-Path -Parent $PSScriptRoot $profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8 $mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding utf8 $api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding utf8 $paths = @( 'pages/tree/t01-tree-overview.vue', 'pages/tree/t03-member-profile.vue', 'pages/tree/t04-add-relative.vue', 'pages/tree/t05-edit-member.vue', 'pages/tree/t06-edit-relationship.vue', 'pages/tree/t07-member-directory.vue', 'pages/tree/t08-member-states.vue' ) $pages = @{} foreach ($path in $paths) { $content = Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding utf8 if ($content -match ')') { throw "$path must not retain ModulePage" } if ($content -match "@/utils/api\.js|\bappApi\b") { throw "$path must not connect the API layer in navigation task 6" } if ($content -match '\buni\.(?:navigateTo|navigateBack|redirectTo|reLaunch|switchTab)\b') { throw "$path must use the navigation gateway exclusively" } if ($content.Contains('/pages/')) { throw "$path must not own route literals" } $pages[$path] = $content } foreach ($path in @('pages/tree/t01-tree-overview.vue', 'pages/tree/t03-member-profile.vue', 'pages/tree/t07-member-directory.vue', 'pages/tree/t08-member-states.vue')) { Assert-Contains $pages[$path] 'ModulePageBackground' "$path must use the tree module background" } $t01 = $pages['pages/tree/t01-tree-overview.vue'] Assert-NotContains $t01 'consumeNavigationResult(' 'T01 must not pre-register a mutation consumer before real writes exist' foreach ($mapping in @( 'openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")', 'openPage("T07", { genealogyId: genealogyId.value }, "T01")', 'openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")', 'openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")' )) { Assert-Contains $t01 $mapping "T01 missing gateway target: $mapping" } Assert-Matches $t01 '(?s)openPage\(\s*"T04",\s*\{ genealogyId: genealogyId\.value, mode: "first" \},\s*"T01",?\s*\)' 'T01 empty state must open first-member mode through T04 route contract' $t03 = $pages['pages/tree/t03-member-profile.vue'] foreach ($required in @( 'member-state--detail', 'member-state--restricted', 'member-state--error', 'adaptive.adaptive-tree-panel', 'adaptive.adaptive-tree-field', '', 'const initializeMemberTrail = async (initialPersonId) =>', 'memberTrail.splice(0, memberTrail.length, normalizedPersonId);', 'trailIndex.value = 0;', 'const openRelative = async (nextPersonId) =>', 'memberTrail.splice(trailIndex.value + 1);', 'memberTrail.push(normalizedPersonId);', 'trailIndex.value = memberTrail.length - 1;', 'const popMemberTrail = async () =>', 'memberTrail.splice(targetIndex, 1);', 'internalTrail: trailIndex.value > 0', '"pop-internal-trail": popMemberTrail', 'consumeNavigationResult("T03")', 'result?.operation === "member-open-requested"', 'onBackPress((event) => handleBackPress(event, requestBack));', 'openPage("T05", { genealogyId: genealogyId.value, personId: personId.value }, "T03")', 'openPage("T08", { genealogyId: genealogyId.value, personId: personId.value }, "T03")' )) { Assert-Contains $t03 $required "Missing T03 single-instance trail contract: $required" } Assert-Matches $t03 '(?s)const openRelative = async \(nextPersonId\) => \{.*?const loaded = await loadMember\(normalizedPersonId\);\s*if \(!loaded\) return false;.*?memberTrail\.push\(normalizedPersonId\)' 'T03 must append a relative only after a successful read' Assert-Matches $t03 '(?s)const initializeMemberTrail = async \(initialPersonId\) => \{\s*memberTrail\.splice\(0, memberTrail\.length\);\s*trailIndex\.value = -1;.*?if \(!loaded\) return false;.*?memberTrail\.splice\(0, memberTrail\.length, normalizedPersonId\)' 'T03 initial failure must leave the trail empty' Assert-Contains $t03 ':data-current-person-id="personId"' 'T03 runtime smoke needs a semantic current-person marker' Assert-Contains $t03 ':data-trail-length="memberTrail.length"' 'T03 runtime smoke must observe trail initialization without internal access' Assert-Contains $t03 ':data-trail-index="trailIndex"' 'T03 runtime smoke must observe the current trail position' Assert-Contains $t03 ':data-person-id="relative.id"' 'T03 relatives need stable lexical identity in the rendered list' Assert-NotContains $t03 'genealogyContext' 'T03 must not fall back to mutable global genealogy context' Assert-Matches $t03 '(?s)const restricted\s*=\s*\["privacy",\s*"forbidden"\]\.includes\(fixture\.status\).*?memberState\.value\s*=\s*restricted\s*\?\s*"restricted"\s*:\s*"detail"' 'T03 must derive restricted presentation from the scoped member status' Assert-Contains $t03 'const canPreviewEdit = computed(() => memberState.value === "detail");' 'T03 may expose only a clearly local preview entry for unrestricted detail state' Assert-NotContains $t03 'canEdit' 'T03 must not treat a fixture field as a backend edit capability' $specializedForms = @{ 't04-add-relative.vue' = @('add-state--form', 'add-state--preview', 'add-relative-form', 'relationOptions') 't05-edit-member.vue' = @('edit-state--form', 'edit-state--preview', 'edit-member-form', 'findTreeMemberPresentationFixture') 't06-edit-relationship.vue' = @('relationship-state--form', 'relationship-state--preview', 'relationship-form', 'relationshipOptions') } foreach ($entry in $specializedForms.GetEnumerator()) { $content = $pages["pages/tree/$($entry.Key)"] if ($content -match ')|import\s+TreeMemberForm') { throw "$($entry.Key) must own its business workflow instead of TreeMemberForm" } foreach ($required in $entry.Value) { Assert-Contains -Content $content -Expected $required -Message ("Missing specialized form contract in {0}: {1}" -f $entry.Key, $required) } foreach ($required in @( 'createDiscardConfirmation', 'handleBackPress', 'runBackGuard', 'submitting: isSubmitting.value', '"block-submitting"', 'onBackPress((event) => handleBackPress(event, requestBack));', 'discardConfirmation.dispose();' )) { Assert-Contains -Content $content -Expected $required -Message ("{0} missing shared guarded local-preview contract: {1}" -f $entry.Key, $required) } Assert-NotContains -Content $content -Unexpected 'finishPage(' -Message ("{0} must not emit a server-success result before a real write succeeds" -f $entry.Key) foreach ($operation in @('relative-created', 'member-updated', 'relationship-updated')) { Assert-NotContains -Content $content -Unexpected $operation -Message ("{0} must not retain a pre-API mutation operation" -f $entry.Key) } Assert-Matches -Content $content -Pattern '\u5c1a\u672a\u63d0\u4ea4\u670d\u52a1\u5668' -Message ("{0} must disclose that its preview is not submitted" -f $entry.Key) } Assert-Contains -Content ($pages['pages/tree/t04-add-relative.vue']) -Expected 'returnTo("T01", { genealogyId: genealogyId.value })' -Message 'T04 local preview must return to T01 without a mutation result' Assert-Contains -Content ($pages['pages/tree/t05-edit-member.vue']) -Expected 'return goBack();' -Message 'T05 must preserve the existing single T03 route identity when leaving a relative preview' Assert-NotContains -Content ($pages['pages/tree/t05-edit-member.vue']) -Unexpected 'returnTo("T03"' -Message 'T05 must not replace the host T03 initial personId with its current in-page member' Assert-Contains -Content ($pages['pages/tree/t06-edit-relationship.vue']) -Expected 'returnTo("T01", { genealogyId: genealogyId.value })' -Message 'T06 local preview must return to T01 without a mutation result' foreach ($submission in @( @{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Function = 'submitAdd'; State = 'addState.value' }, @{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Function = 'saveMember'; State = 'editState.value' }, @{ Key = 'T06'; Content = $pages['pages/tree/t06-edit-relationship.vue']; Function = 'saveRelationship'; State = 'relationshipState.value' } )) { $body = [regex]::Match( $submission.Content, "(?s)const $($submission.Function) = \(\) => \{.*?`n\};" ).Value if (-not $body) { throw "$($submission.Key) submission function is not statically auditable" } if (-not $body.Contains('"preview"')) { throw "$($submission.Key) valid submission must stop at local preview" } if ($body -match '\b(?:returnTo|goBack|finishPage)\s*\(') { throw "$($submission.Key) local validation must not navigate automatically" } } if (([regex]::Matches($pages['pages/tree/t05-edit-member.vue'], 'baseline\.value = formSnapshot\.value;')).Count -ne 1) { throw 'T05 local preview must not clear the dirty baseline before a server write exists' } $t07 = $pages['pages/tree/t07-member-directory.vue'] foreach ($required in @( 'directory-state--list', 'directory-state--empty', 'directory-state--error', 'adaptive.adaptive-genealogy-list-card', 'directory-card__status', 'listTreeMemberPresentationFixtures', 'const isRestrictedMember =', 'memberMeta(item)', 'memberStatus(item)', 'openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")' )) { Assert-Contains $t07 $required "Missing T07 contract: $required" } foreach ($privateTemplateRead in @('{{ item.generationName }}', '{{ item.branch }}', '{{ item.note }}')) { Assert-NotContains $t07 $privateTemplateRead "T07 must not render a restricted member private field directly: $privateTemplateRead" } foreach ($required in @( 'const hasValidContext = computed(() => Boolean(genealogyId.value));', '!hasValidContext.value ? "error"', 'v-if="hasValidContext"' )) { Assert-Contains $t07 $required "T07 must fail closed without genealogy context: $required" } $t08 = $pages['pages/tree/t08-member-states.vue'] foreach ($required in @( 'member-status--privacy', 'member-status--deceased', 'member-status--forbidden', 'adaptive.adaptive-tree-panel', 'memberIdentityCopy', '姓名、世代与家族关系可见', 'goRoot("G01")', 'goBack()' )) { Assert-Contains $t08 $required "Missing T08 contract: $required" } Assert-NotContains $t08 '{{ member.branch }}' 'T08 must not assume a restricted member branch is visible' Assert-NotContains $t08 'genealogyContext' 'T08 must not fall back to mutable global genealogy context' foreach ($required in @( 'const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));', 'member.value = hasValidContext.value' )) { Assert-Contains $t08 $required "T08 must fail closed without its complete route identity: $required" } $t06 = $pages['pages/tree/t06-edit-relationship.vue'] foreach ($required in @( 'const hasValidContext = computed(', 'Boolean(genealogyId.value && memberById.value.has(personId.value))', 'relationshipForm.sourceId = hasValidContext.value ? personId.value : "";', 'relationshipState.value = !hasValidContext.value || query.state === "error"' )) { Assert-Contains $t06 $required "T06 must fail closed without a valid routed member: $required" } Assert-NotContains $t06 ': memberOptions[0].id' 'T06 must never replace a missing routed person with the first fixture member' $treeOverview = $pages['pages/tree/t01-tree-overview.vue'] foreach ($page in @( @{ Key = 'T01'; Content = $treeOverview }, @{ Key = 'T03'; Content = $t03 }, @{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue'] }, @{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue'] }, @{ Key = 'T06'; Content = $t06 }, @{ Key = 'T07'; Content = $t07 }, @{ Key = 'T08'; Content = $t08 } )) { Assert-Contains $page.Content '@/data/mock.js' "$($page.Key) must consume the shared tree-member fixture owner" if ($page.Content -match 'import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*["'']@/data/mock\.js["'']') { throw "$($page.Key) must consume snapshots instead of the mutable treeMembers store" } } foreach ($localOwner in @( @{ Key = 'T01'; Content = $treeOverview; Pattern = 'const\s+members\s*=\s*ref\(\[\s*\{' }, @{ Key = 'T03'; Content = $t03; Pattern = 'const\s+memberFixtures\s*=' }, @{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Pattern = 'const\s+memberFixtures\s*=' }, @{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Pattern = 'const\s+memberFixtures\s*=' }, @{ Key = 'T06'; Content = $t06; Pattern = 'const\s+memberOptions\s*=\s*\[' }, @{ Key = 'T07'; Content = $t07; Pattern = 'const\s+members\s*=\s*\[' }, @{ Key = 'T08'; Content = $t08; Pattern = 'const\s+memberFixtures\s*=' } )) { if ($localOwner.Content -match $localOwner.Pattern) { throw "$($localOwner.Key) retains a competing local tree-member fixture owner" } } foreach ($ownerContract in @( 'export const treeMembers = [', 'export const listTreeMemberFixtures = (genealogyId) =>', 'export const findTreeMemberFixture = (genealogyId, personId) =>', 'relatives: member.relatives.map((relative) => ({ ...relative }))' )) { Assert-Contains $mock $ownerContract "Shared tree-member owner missing: $ownerContract" } Assert-Matches $api '(?s)import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*''@/data/mock\.js''' 'utils/api.js must remain the only mutable tree-member store consumer' foreach ($sourceRoot in @('pages', 'components', 'utils')) { Get-ChildItem -LiteralPath (Join-Path $root $sourceRoot) -Recurse -File | Where-Object { $_.Extension -in @('.js', '.vue') -and $_.FullName -ne (Join-Path $root 'utils/api.js') } | ForEach-Object { $source = Get-Content -LiteralPath $_.FullName -Raw -Encoding utf8 if ($source -match 'import\s*\{[^}]*\btreeMembers\b[^}]*\}\s*from\s*["'']@/data/mock\.js["'']') { throw "$($_.FullName) bypasses the tree-member snapshot selectors" } } } foreach ($entry in @( @{ Key = 'T01'; Content = $treeOverview; Expected = 'listTreeMemberFixtures(genealogyId.value)' }, @{ Key = 'T03'; Content = $t03; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId)' }, @{ Key = 'T04'; Content = $pages['pages/tree/t04-add-relative.vue']; Expected = 'findTreeMemberFixture(genealogyId.value, personId.value)' }, @{ Key = 'T05'; Content = $pages['pages/tree/t05-edit-member.vue']; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, id)' }, @{ Key = 'T06'; Content = $t06; Expected = 'listTreeMemberFixtures(genealogyId.value)' }, @{ Key = 'T07'; Content = $t07; Expected = 'listTreeMemberPresentationFixtures(genealogyId.value)' }, @{ Key = 'T08'; Content = $t08; Expected = 'findTreeMemberPresentationFixture(genealogyId.value, personId.value)' } )) { Assert-Contains $entry.Content $entry.Expected "$($entry.Key) must scope fixture reads by genealogy and member identity" } Assert-Contains $pages['pages/tree/t04-add-relative.vue'] 'const hasValidContext = computed(' 'T04 must own an explicit route-context gate' Assert-Contains $pages['pages/tree/t04-add-relative.vue'] 'isFirstMember.value ? !personId.value : Boolean(currentMember.value)' 'T04 must only allow a missing personId in first-member mode' Assert-Contains $pages['pages/tree/t05-edit-member.vue'] 'if (!member) return false;' 'T05 must reject unknown members instead of synthesizing an editable identity' Assert-Matches $pages['pages/tree/t05-edit-member.vue'] '(?s)\["privacy",\s*"forbidden"\]\.includes\(originalMember\.value\.status\).*?"no-permission"' 'T05 deep links must not edit a restricted member without a backend capability' foreach ($asset in @('t01-state-panel.png', 't07-search-input-frame.png', 'list-slip-frame.png')) { Assert-Contains $profiles $asset "Adaptive tree profile missing: $asset" } Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS NAVIGATION TRAIL LOCAL-PREVIEW'