修改未完成

This commit is contained in:
rain
2026-07-29 18:15:59 +08:00
parent 6fbcf21024
commit 8c2355a52c
59 changed files with 7995 additions and 1118 deletions
+28 -18
View File
@@ -46,7 +46,7 @@ const run = async () => {
};
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
const session = {
getToken: () => "",
getToken: () => "session-1",
saveToken: (token) => globalThis.__savedTokens.push(token),
clear: () => globalThis.__clearedSessions.push("cleared"),
};
@@ -136,6 +136,17 @@ const run = async () => {
phone: "13800138000",
}, "策略关闭时发码请求不得伪造 validToken");
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(
await appApi.changePhone({ phone: "13900139000", smsCode: "1234" }),
null,
"手机号换绑的 RVoid 响应必须解析为 null",
);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/phone");
assert.strictEqual(requests.at(-1).method, "PUT");
assert.strictEqual(requests.at(-1).header.Authorization, "Bearer session-1");
assert.deepStrictEqual(requests.at(-1).data, { phone: "13900139000", smsCode: "1234" });
// 已鉴权读取收到业务 401 时,只清理失效的本地会话;请求仍向调用方失败返回。
respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } });
await assert.rejects(
@@ -164,25 +175,12 @@ const run = async () => {
null,
"找回密码的 RVoid 省略 data 时仍必须解析为 null",
);
respond({
statusCode: 200,
data: { code: 200, msg: "操作成功", data: { access_token: "token-1" } },
});
respond({ statusCode: 200, data: { code: 200 } });
await appApi.sendLegacySmsCode({
phone: "13800138000",
validToken: "ticket-legacy",
});
assert.strictEqual(
requests.at(-1).url,
"https://backend-api.ddxcjp.cn/genealogy/app/auth/sms/code",
);
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "sms",
grantType: "password",
phone: "13800138000",
validToken: "ticket-legacy",
newPassword: "a".repeat(32),
smsCode: "1234",
});
respond({
@@ -191,6 +189,13 @@ const run = async () => {
});
const login = await appApi.loginWithSms({ phone: "13800138000", smsCode: "1234" });
assert.strictEqual(login.access_token, "token-1");
assert.deepStrictEqual(requests.at(-1).data, {
tenantId: "000000",
grantType: "sms",
phone: "13800138000",
smsCode: "1234",
});
assert.strictEqual(requests.at(-1).header.Authorization, undefined);
assert.deepStrictEqual(savedTokens, ["token-1"]);
respond({
@@ -241,7 +246,6 @@ const run = async () => {
});
assert.strictEqual(registration.access_token, "token-register");
assert.deepStrictEqual(requests.at(-1).data, {
clientId: "client-1",
tenantId: "000000",
grantType: "password",
phone: "13800138000",
@@ -262,6 +266,12 @@ const run = async () => {
});
assert.strictEqual(Object.hasOwn(requests.at(-1).data, "nickName"), false);
respond({ statusCode: 200, data: { code: 200 } });
assert.strictEqual(await appApi.deactivateAccount({ smsCode: "1234" }), null);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/account/deactivate");
assert.strictEqual(requests.at(-1).header.Authorization, "Bearer session-1");
assert.deepStrictEqual(requests.at(-1).data, { smsCode: "1234" });
holdResponse = true;
const requestController = createRequestController();
const cancelled = appApi.sendSmsCode(
+2
View File
@@ -29,6 +29,8 @@ const run = async () => {
SMS_LOGIN: "sms-login",
REGISTER: "register",
FORGOT_PASSWORD: "forgot-password",
PHONE_CHANGE: "phone-change",
ACCOUNT_DEACTIVATE: "account-deactivate",
},
"认证动作必须由受保护 OpenAPI 的唯一枚举拥有",
);
+86 -39
View File
@@ -1,57 +1,104 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$documents = @(Get-ChildItem -LiteralPath $root -File -Filter '*.openapi.json')
if ($documents.Count -ne 1) { throw 'Exactly one current OpenAPI JSON export is required at the repository root' }
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $documents[0].FullName | ConvertFrom-Json
$openApiPath = Join-Path $root 'genealogy-app-openapi.yaml'
if (-not (Test-Path -LiteralPath $openApiPath)) {
throw 'Current backend OpenAPI export genealogy-app-openapi.yaml is missing at the repository root'
}
$operations = @(
foreach ($pathProperty in $document.paths.PSObject.Properties) {
foreach ($methodProperty in $pathProperty.Value.PSObject.Properties) {
if ($methodProperty.Name -in @('get', 'post', 'put', 'delete', 'patch')) {
[PSCustomObject]@{ Path = $pathProperty.Name; Method = $methodProperty.Name }
}
}
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $openApiPath
$operationCount = [regex]::Matches($document, '(?m)^ (?:get|post|put|delete|patch):\s*$').Count
if ($operationCount -ne 137) {
throw "Current backend OpenAPI operation count drifted: $operationCount"
}
function Get-PathBlock {
param([string]$Path)
$escapedPath = [regex]::Escape($Path)
$match = [regex]::Match($document, "(?ms)^ ${escapedPath}:`r?`n(.*?)(?=^ /|\z)")
if (-not $match.Success) { throw "Missing path: $Path" }
return $match.Groups[1].Value
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
foreach ($path in @(
'/genealogy/app/auth/sms/{operationCode}/code',
'/genealogy/app/files/resumable/init',
'/genealogy/app/files/resumable/chunk',
'/genealogy/app/files/resumable/complete',
'/genealogy/app/region/children',
'/genealogy/app/region/path/{regionCode}',
'/genealogy/app/region/search',
'/genealogy/app/region/{regionCode}'
)) {
[void](Get-PathBlock $path)
}
foreach ($retiredPath in @(
'/captcha/require',
'/captcha/challenge',
'/captcha/verify',
'/auth/code',
'/genealogy/app/auth/sms/code',
'/genealogy/app/files/upload',
'/genealogy/app/files/reference',
'/genealogy/region/children',
'/genealogy/region/path/{regionCode}',
'/genealogy/region/search',
'/genealogy/region/{regionCode}'
)) {
if ($document -match "(?m)^ $([regex]::Escape($retiredPath)):") {
throw "Retired path remains in current backend OpenAPI: $retiredPath"
}
)
if ($operations.Count -ne 149) { throw "Current OpenAPI operation count drifted: $($operations.Count)" }
function Get-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) { throw "Missing path: $Path" }
$operation = $pathProperty.Value.PSObject.Properties[$Method]
if (-not $operation) { throw "Missing operation: $Method $Path" }
return $operation.Value
}
$videoList = Get-Operation -Path '/genealogy/app/genealogies/{genealogyId}/videos' -Method 'post'
if ($videoList.requestBody.content.'application/json'.schema.'$ref' -ne '#/components/schemas/VideoBody') {
throw 'Video create must consume VideoBody'
$videoPath = Get-PathBlock '/genealogy/app/genealogies/{genealogyId}/videos'
Assert-Contains $videoPath "`$ref: '#/components/requestBodies/Video'" 'Video create must consume VideoBody'
Assert-Contains $videoPath "`$ref: '#/components/responses/ListResult'" 'Video list remains a generic ListResult until the backend publishes a VideoView DTO'
$videoSchema = [regex]::Match($document, "(?ms)^ VideoBody:`r?`n(.*?)(?=^ [A-Za-z][A-Za-z0-9_-]*:|\z)").Value
Assert-Contains $videoSchema 'required: [videoTitle, videoOssId]' 'VideoBody required fields drifted'
foreach ($path in @('/genealogy/app/site/articles', '/genealogy/app/site/pages/{pageKey}')) {
$sitePath = Get-PathBlock $path
if ($sitePath -match 'VideoView|SiteArticleView|SitePageView') {
throw "Site content response DTO changed for $path; review M10 integration"
}
}
$videoBody = $document.components.schemas.VideoBody
if ((@($videoBody.required) -join ',') -ne 'videoTitle,videoOssId') {
throw 'VideoBody required fields drifted'
$phoneChangePath = Get-PathBlock '/genealogy/app/auth/phone'
Assert-Contains $phoneChangePath "`$ref: '#/components/requestBodies/PhoneChange'" 'Phone change must consume PhoneChangeBody'
$phoneChangeSchema = [regex]::Match($document, "(?ms)^ PhoneChangeBody:`r?`n(.*?)(?=^ [A-Za-z][A-Za-z0-9_-]*:|\z)").Value
Assert-Contains $phoneChangeSchema 'additionalProperties: false' 'PhoneChangeBody must reject legacy auth payload fields'
Assert-Contains $phoneChangeSchema 'required: [phone, smsCode]' 'PhoneChangeBody required fields drifted'
$apiSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/api.js')
foreach ($expected in @(
'/genealogy/app/auth/sms/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/code',
'/genealogy/app/region/children',
'/genealogy/app/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}',
'/genealogy/app/region/search',
'/genealogy/app/region/${encodeURIComponent(normalizeRegionCode(regionCode))}'
)) {
Assert-Contains $apiSource $expected "Current API adapter missing: $expected"
}
if ($videoList.responses.'200'.content.'application/json'.schema) {
throw 'Video create unexpectedly gained a response DTO; review F10 integration'
foreach ($retiredPath in @('/genealogy/app/auth/sms/code', '/genealogy/app/files/upload', '/genealogy/app/files/reference', '/genealogy/region/')) {
if ($apiSource.Contains($retiredPath)) { throw "API adapter retains retired backend path: $retiredPath" }
}
$phonePayload = [regex]::Match($apiSource, "(?ms)const normalizePhoneChangePayload = \(payload\) => \{(.*?)^\}").Value
Assert-Contains $phonePayload 'return { phone: payload.phone.trim(), smsCode: assertSmsCode(payload.smsCode) }' 'Phone change payload must only contain the documented fields'
if ($phonePayload.Contains('authPayload(')) { throw 'Phone change payload must not add clientId or tenantId to PhoneChangeBody' }
$videoPage = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'pages/family/f10-video-list.vue')
foreach ($token in @('pickAndUploadVideo', 'appApi.createVideo', 'videoTitle', 'videoOssId: receipt.value.ossId')) {
if (-not $videoPage.Contains($token)) { throw "F10 video publish contract missing: $token" }
Assert-Contains $videoPage $token "F10 video publish contract missing: $token"
}
foreach ($forbidden in @('v-model="form.videoOssId"', 'v-model="form.status"', 'v-model="form.durationSeconds"')) {
if ($videoPage.Contains($forbidden)) { throw "F10 must not expose auto or management field: $forbidden" }
}
foreach ($path in @('/genealogy/app/site/articles', '/genealogy/app/site/pages/{pageKey}')) {
$operation = Get-Operation -Path $path -Method 'get'
if ($operation.responses.'200'.content.'application/json'.schema) {
throw "Site content response DTO changed for $path; review M10 integration"
}
}
$auditDocs = @(Get-ChildItem -LiteralPath (Join-Path $root 'docs') -File -Filter '*149*.md')
if ($auditDocs.Count -lt 1) { throw 'Current 149 operation audit document is missing' }
Write-Output 'CURRENT-OPENAPI-INVENTORY-CONTRACT PASS'
+5 -4
View File
@@ -78,7 +78,7 @@ const run = async () => {
toDataModuleUrl(`${prelude}\n${moduleBody}`),
);
const submit = (payload = {
feedbackType: "功能问题",
feedbackType: "bug",
feedbackContent: "上传照片时出现异常",
contactInfo: "user@example.com",
}) => appApi.submitFeedback(payload);
@@ -90,11 +90,12 @@ const run = async () => {
{ feedbackContent: " " },
{ feedbackContent: 123 },
{ feedbackContent: "内容", feedbackType: 1 },
{ feedbackContent: "内容", feedbackType: "feature" },
{ feedbackContent: "内容", contactInfo: false },
{ feedbackContent: "内容", type: "旧字段" },
Object.assign(Object.create({ feedbackType: "继承字段" }), { feedbackContent: "内容" }),
]) {
await assert.rejects(submit(invalid), /反馈|字段|对象|字符串/);
await assert.rejects(submit(invalid), /反馈|字段|对象|字符串|feedbackType/);
}
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { accepted: true } } };
@@ -103,11 +104,11 @@ const run = async () => {
assert.deepStrictEqual(requests.at(-1).data, { feedbackContent: "仅反馈内容" });
nextResponse = { statusCode: 200, data: { code: 200, msg: "成功", data: { feedbackId: 1 } } };
const result = await submit({ feedbackContent: " 有效反馈 ", feedbackType: " 使用建议 ", contactInfo: " " });
const result = await submit({ feedbackContent: " 有效反馈 ", feedbackType: " advice ", contactInfo: " " });
assert.deepStrictEqual(result, { feedbackId: 1 });
assert.deepStrictEqual(requests.at(-1).data, {
feedbackContent: "有效反馈",
feedbackType: "使用建议",
feedbackType: "advice",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/feedback");
assert.strictEqual(requests.at(-1).method, "POST");
+46 -60
View File
@@ -43,30 +43,11 @@ const run = async () => {
},
};
const uploads = [];
const singleUploads = [];
globalThis.plus = {
uploader: {
createUpload(url, options, callback) {
const upload = {
headers: {},
files: [],
setRequestHeader(key, value) { this.headers[key] = value; },
addFile(filePath, fileOptions) { this.files.push({ filePath, fileOptions }); },
start() { queueMicrotask(() => callback({ responseText: JSON.stringify({ code: 200, data: null }) }, 200)); },
abort() {},
};
uploads.push({ url, options, upload });
return upload;
},
},
};
globalThis.uni.uploadFile = (options) => {
singleUploads.push(options);
queueMicrotask(() => options.success({
statusCode: 200,
data: JSON.stringify({ code: 200, data: { ossId: "900002", url: "https://oss.example/single.png", thumbnailUrl: "", fileName: "single.png" } }),
}));
return { abort() {} };
const task = { abort() {} };
uploads.push({ options, task });
queueMicrotask(() => options.success({ statusCode: 200, data: JSON.stringify({ code: 200, data: null }) }));
return task;
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
const basePayload = {
@@ -110,9 +91,46 @@ const run = async () => {
filePath: "/storage/emulated/0/avatar.png",
});
assert.strictEqual(uploads.length, 1);
assert.strictEqual(uploads[0].url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk?uploadId=upload-1&chunkIndex=0&chunkMd5=" + "b".repeat(32));
assert.deepStrictEqual(uploads[0].upload.headers, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
assert.deepStrictEqual(uploads[0].upload.files, [{ filePath: "/storage/emulated/0/avatar.png", fileOptions: { key: "file" } }]);
assert.strictEqual(uploads[0].options.url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk");
assert.strictEqual(uploads[0].options.filePath, "/storage/emulated/0/avatar.png");
assert.strictEqual(uploads[0].options.name, "file");
assert.strictEqual(uploads[0].options.timeout, 15000);
assert.deepStrictEqual(uploads[0].options.header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
assert.deepStrictEqual(uploads[0].options.formData, { uploadId: "upload-1", chunkIndex: "0", chunkMd5: "b".repeat(32) });
globalThis.uni.uploadFile = (options) => {
queueMicrotask(() => options.fail({ errMsg: "uploadFile:fail timeout" }));
return { abort() {} };
};
await assert.rejects(appApi.uploadResumableChunk({
uploadId: "upload-1",
chunkIndex: 0,
chunkMd5: "b".repeat(32),
filePath: "/storage/emulated/0/avatar.png",
}), (error) => error.code === "REQUEST_TIMEOUT");
const browserRequests = [];
class TestFormData {
entries = [];
append(name, value, fileName) { this.entries.push({ name, value, fileName }); }
}
globalThis.FormData = TestFormData;
globalThis.fetch = async (url, options) => {
browserRequests.push({ url, options });
return { status: 200, text: async () => JSON.stringify({ code: 200, data: null }) };
};
await appApi.uploadBrowserResumableChunk({
uploadId: "upload-1",
chunkIndex: 0,
chunkMd5: "b".repeat(32),
}, { name: "avatar.png" });
assert.strictEqual(browserRequests[0].url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/chunk");
assert.deepStrictEqual(browserRequests[0].options.body.entries, [
{ name: "uploadId", value: "upload-1", fileName: undefined },
{ name: "chunkIndex", value: "0", fileName: undefined },
{ name: "chunkMd5", value: "b".repeat(32), fileName: undefined },
{ name: "file", value: { name: "avatar.png" }, fileName: "avatar.png" },
]);
response = { statusCode: 200, data: { code: 200, data: { ossId: "900001", url: "https://oss.example/avatar.png", thumbnailUrl: "", fileName: "avatar.png" } } };
assert.deepStrictEqual(await appApi.completeResumableUpload(completePayload), {
@@ -122,39 +140,6 @@ const run = async () => {
fileName: "avatar.png",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/complete");
assert.deepStrictEqual(await appApi.uploadSingleFile({ filePath: "/storage/emulated/0/single.png" }), {
ossId: "900002",
url: "https://oss.example/single.png",
thumbnailUrl: "",
fileName: "single.png",
});
assert.strictEqual(singleUploads.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/upload");
assert.strictEqual(singleUploads.at(-1).name, "file");
assert.strictEqual(singleUploads.at(-1).filePath, "/storage/emulated/0/single.png");
assert.deepStrictEqual(singleUploads.at(-1).header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
response = { statusCode: 200, data: { code: 200, data: null } };
await appApi.createFileReference({
bizType: "family_feed",
bizName: "family feed image",
bizTable: "gen_family_feed",
bizId: "900013001",
bizField: "media_oss_ids",
ossId: "900002",
usageScene: "feed_image",
});
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/reference");
assert.deepStrictEqual(requests.at(-1).data, {
bizType: "family_feed",
bizName: "family feed image",
bizTable: "gen_family_feed",
bizId: 900013001,
bizField: "media_oss_ids",
ossId: 900002,
usageScene: "feed_image",
});
await appApi.deleteFileReference({ bizTable: "gen_family_feed", bizId: "900013001", bizField: "media_oss_ids" });
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/reference?bizTable=gen_family_feed&bizId=900013001&bizField=media_oss_ids");
await assert.rejects(
appApi.initializeResumableUpload({ ...basePayload, totalSize: 0 }),
/totalSize/,
@@ -164,8 +149,9 @@ const run = async () => {
/chunkIndex/,
);
delete globalThis.plus;
delete globalThis.uni;
delete globalThis.fetch;
delete globalThis.FormData;
process.stdout.write("FILE-UPLOAD-API-RUNTIME-SMOKE PASS\n");
};
+23 -14
View File
@@ -33,18 +33,13 @@ foreach ($source in @($t04, $t05)) {
}
foreach ($required in @(
'v-model="addForm.personNo"',
'v-model="addForm.aliasName"',
'v-model="addForm.generation"',
'v-model="addForm.generationName"',
'v-model="addForm.birthPlace"',
'v-model="addForm.deathPlace"',
'v-model="addForm.burialPlace"',
'v-model="addForm.biography"',
'v-model="addForm.remark"',
'v-model="addForm.sortOrder"',
'v-model="addForm.relationName"',
'personNo: addForm.personNo',
'sex: addForm.sex',
'avatarOssId: addForm.avatarOssId',
'personStatus: addForm.personStatus',
@@ -53,28 +48,24 @@ foreach ($required in @(
'generationName: addForm.generationName',
'biography: addForm.biography',
'remark: addForm.remark',
'? { generation: 1, relationName: addForm.relationName }',
'? { generation: 1 }',
'relationType.value === "SPOUSE"',
'v-if="isDeceased"',
'const isDeceased = computed(() => addForm.personStatus === "1")',
'const sexOptions = Object.freeze([',
'const lunarOptions = Object.freeze([',
'const personStatusOptions = Object.freeze([',
'personOptionLabels',
'appApi.getLineagePersonOptions(',
'const personOptionsRequestController = createRequestController();',
'requestController: personOptionsRequestController',
'const bindingModeOptions = Object.freeze([',
'bindingMode: addForm.bindingMode',
'addForm.bindingMode === "SPECIFIED"',
'pickAndUploadImage('
)) {
if (-not $t04.Contains($required)) { throw "T04 safe optional field missing: $required" }
}
foreach ($required in @(
'v-model="editForm.personNo"',
'originalMember.personNo ||',
'v-model="editForm.generation"',
'v-model="editForm.sortOrder"',
'v-model="editForm.relationName"',
'personNo: editForm.personNo',
'sex: editForm.sex',
'avatarOssId: editForm.avatarOssId',
'personStatus: editForm.personStatus',
@@ -89,11 +80,29 @@ foreach ($required in @(
'const bindingModeOptions = Object.freeze([',
'bindingMode: editForm.bindingMode',
'editForm.bindingMode === "SPECIFIED"',
'const isDeceased = computed(() => editForm.personStatus === "1")',
'v-if="isDeceased"',
'pickAndUploadImage('
)) {
if (-not $t05.Contains($required)) { throw "T05 LineagePersonBody field missing: $required" }
}
foreach ($forbidden in @(
'v-model="addForm.personNo"',
'personNo: addForm.personNo',
'v-model="addForm.fatherId"',
'v-model="addForm.motherId"',
'v-model="addForm.relationName"',
'v-model="editForm.personNo"',
'personNo: editForm.personNo',
'v-model="editForm.relationName"',
'relationName: editForm.relationName'
)) {
if ($t04.Contains($forbidden) -or $t05.Contains($forbidden)) {
throw "Lineage system-managed or relation-context field must not be editable: $forbidden"
}
}
foreach ($forbidden in @(
'v-model="addForm.appUserId"',
'v-model="addForm.fatherId"',
+3 -2
View File
@@ -2,9 +2,10 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/profile/m01-profile-home.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @('grid-template-columns: 42rpx minmax(0, 1fr)','pointer-events: none')) {
foreach ($expected in @('grid-area: 1 / 1','display: flex','pointer-events: none')) {
if (-not $page.Contains($expected)) { throw "M01 document-flow contract missing: $expected" }
}
if ($page -match '(?s)\.profile-hero__label\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;') { throw 'M01 profile hero label must use the hero grid flow' }
if ($page -notmatch '(?s)\.profile-hero__identity\s*\{[^}]*position\s*:\s*relative\s*;') { throw 'M01 hero identity must provide the edit-action anchor' }
if ($page -notmatch '(?s)\.profile-hero__edit\s*\{[^}]*position\s*:\s*absolute\s*;') { throw 'M01 edit action must be positioned within the hero identity block' }
Write-Output 'M01-DOCUMENT-FLOW-CONTRACT PASS'
+42 -47
View File
@@ -1,67 +1,62 @@
$ErrorActionPreference = 'Stop'
$source = Get-Content 'pages/profile/m01-profile-home.vue' -Raw -Encoding UTF8
$profiles = Get-Content 'styles/adaptive-frame-profiles.scss' -Raw -Encoding UTF8
$required = @(
foreach ($required in @(
'profile-state--ready',
'profile-state--error',
'@include adaptive.adaptive-profile-summary;',
'@include adaptive.adaptive-scroll-button(primary);',
'brand-seal.png',
'auth-divider-knot.png',
'chevron-right.png',
'root-header-hall.png',
'a01-icon-lock-v1.png',
'notice.png',
'm01-profile-archive-hero-v2.png',
'm01-profile-tree-medallion.png',
'class="profile-hero__art"',
'class="profile-hero__content"',
'class="profile-metadata"',
'class="profile-service-row"',
'grid-area: 1 / 1',
'height: 503rpx',
'height: 480rpx',
'white-space: nowrap',
'pointer-events: none',
'AppButton',
'import { openPage } from "@/utils/navigation.js";',
'AppTabbar',
'routeKey: "M03"',
'routeKey: "N01"',
'routeKey: "M06"',
'routeKey: "M10"',
'routeKey: "M08"',
'routeKey: "M09"',
'routeKey: "M10"',
'openPage("M02", {}, "M01")',
'openPage("N01", {}, "M01")',
'openPage(item.routeKey, {}, "M01")'
)
foreach ($token in $required) {
if (-not $source.Contains($token)) {
throw "M01 missing contract token: $token"
}
}
foreach ($forbiddenNavigation in @('uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch', 'getCurrentPages(', '<navigator', '/pages/')) {
if ($source.Contains($forbiddenNavigation)) {
throw "M01 must use the registered navigation gateway: $forbiddenNavigation"
}
}
foreach ($forbidden in @('m01-profile-notice-card.png', 'm01-profile-menu-frame.png', 'g03-create-flow-panel.png', 'application-status-card.png', 'g06-search-input-wide.png')) {
if ($source.Contains($forbidden)) {
throw "M01 must not reuse opaque business asset: $forbidden"
}
}
if ($source -match '<image\s+(?:[^>]*\s)?class="(?:profile-hero__frame|profile-scroll-notice__skin|profile-error__skin)"') {
throw 'M01 decorative frames must be container backgrounds instead of positioned image layers'
}
if ($source -match 'position\s*:') { throw 'M01 must keep content and local decoration in flex/grid document flow' }
foreach ($token in @(
'class="profile-hero__content"',
'grid-area: 1 / 1'
)) {
if (-not $source.Contains($token)) { throw "M01 missing document-flow token: $token" }
if (-not $source.Contains($required)) {
throw "M01 missing archive redesign contract: $required"
}
}
if ($profiles -notmatch '(?s)@mixin\s+adaptive-profile-summary\s*\{.*?m01-profile-summary-card\.png') {
throw 'The adaptive profile owner must retain the M01 summary-card artwork'
foreach ($forbidden in @(
'auth-divider-knot.png',
'root-header-hall.png',
'm01-profile-notice-card.png',
'm01-profile-menu-frame.png',
'uni.navigateTo',
'uni.navigateBack',
'uni.redirectTo',
'uni.reLaunch',
'getCurrentPages(',
'<navigator',
'/pages/'
)) {
if ($source.Contains($forbidden)) {
throw "M01 must not retain obsolete navigation or separate-decoration contract: $forbidden"
}
}
if ($profiles -notmatch '(?s)@mixin\s+adaptive-scroll-button\s*\(\$type\).*?a01-scroll-primary-v3\.png') {
throw 'The adaptive profile owner must retain the primary scroll-button artwork'
if ($source -notmatch '(?s)\.profile-hero__identity\s*\{[^}]*position\s*:\s*relative\s*;' -or
$source -notmatch '(?s)\.profile-hero__edit\s*\{[^}]*position\s*:\s*absolute\s*;') {
throw 'M01 archive redesign must anchor the edit action to the hero identity block'
}
if ($source -match '100%\s+100%\s+no-repeat|border-image-slice\s*:') {
throw 'M01 must consume adaptive frame geometry without page-local stretching or slicing'
if ($source -match '\.profile-page\s*:deep\(\.app-tabbar\)|\.profile-page\s*:deep\(\.tab-label\)') {
throw 'M01 must preserve the shared AppTabbar appearance'
}
Write-Output 'M01-MODULE-BASELINE-CONTRACT PASS'
+134
View File
@@ -0,0 +1,134 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const cdpPort = process.env.CDP_PORT || "9222";
const requestedRoute = process.env.MUMU_ROUTE || "";
const getTargets = async () => (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const latestRouteTarget = (targets, routePrefix = "pages/") => targets
.filter((candidate) => candidate.type === "page" && candidate.title.startsWith(routePrefix))
.sort((left, right) => {
const leftIndex = Number(left.title.match(/\[(\d+)\]$/)?.[1] || 0);
const rightIndex = Number(right.title.match(/\[(\d+)\]$/)?.[1] || 0);
return rightIndex - leftIndex;
})[0];
const connect = async (target) => {
const socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", reject, { once: true });
});
let id = 0;
const pending = new Map();
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result);
});
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1;
const requestId = id;
pending.set(requestId, { resolve, reject });
socket.send(JSON.stringify({ id: requestId, method, params }));
});
return { socket, send };
};
const run = async () => {
const configuredRoutes = JSON.parse(fs.readFileSync("pages.json", "utf8")).pages.map((page) => page.path);
const routes = requestedRoute ? [requestedRoute] : configuredRoutes;
assert.ok(routes.every((route) => configuredRoutes.includes(route)), "requested route is not configured in pages.json");
const failures = [];
let previousRoute = "";
for (const route of routes) {
await sleep(previousRoute === "pages/auth/a01-entry" ? 1600 : 350);
const activeTarget = latestRouteTarget(await getTargets());
if (!activeTarget) {
failures.push(`${route}: no active Uni page WebView`);
continue;
}
const active = await connect(activeTarget);
try {
void active.send("Runtime.evaluate", {
expression: `uni.reLaunch({ url: ${JSON.stringify(`/${route}`)} })`,
returnByValue: true,
}).catch(() => {});
} finally {
active.socket.close();
}
const deadline = Date.now() + 5000;
let routedTarget = null;
while (!routedTarget && Date.now() < deadline) {
routedTarget = latestRouteTarget(await getTargets(), `${route}[`);
if (!routedTarget) await sleep(100);
}
if (!routedTarget) {
await sleep(500);
const retryTarget = latestRouteTarget(await getTargets());
if (retryTarget) {
const retry = await connect(retryTarget);
try {
void retry.send("Runtime.evaluate", {
expression: `uni.reLaunch({ url: ${JSON.stringify(`/${route}`)} })`,
returnByValue: true,
}).catch(() => {});
} finally {
retry.socket.close();
}
const retryDeadline = Date.now() + 5000;
while (!routedTarget && Date.now() < retryDeadline) {
routedTarget = latestRouteTarget(await getTargets(), `${route}[`);
if (!routedTarget) await sleep(100);
}
}
}
if (!routedTarget) {
failures.push(`${route}: native route did not open`);
continue;
}
const routed = await connect(routedTarget);
const exceptions = [];
routed.socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.method === "Runtime.exceptionThrown") exceptions.push(message.params.exceptionDetails.text);
});
try {
await routed.send("Runtime.enable");
let page = null;
while (Date.now() < deadline) {
const result = await routed.send("Runtime.evaluate", {
expression: "JSON.stringify({ textLength: document.body?.innerText?.trim().length || 0, loading: [...document.querySelectorAll('.app-loading, .uni-loading, .loading-spinner')].some((element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || 1) > 0 && rect.width > 0 && rect.height > 0; }) })",
returnByValue: true,
});
page = JSON.parse(result.result?.value || "{}");
if (page.textLength > 0 && !page.loading) break;
await sleep(100);
}
if (!page?.textLength || page.loading) {
failures.push(`${route}: page did not reach a stable rendered state`);
} else if (exceptions.length) {
failures.push(`${route}: ${exceptions.join("; ")}`);
} else {
process.stdout.write(`PASS ${route}\n`);
}
} finally {
routed.socket.close();
}
previousRoute = route;
}
if (failures.length) throw new Error(`MuMu native route audit failures:\n${failures.map((failure) => `- ${failure}`).join("\n")}`);
process.stdout.write(`MUMU-NATIVE-ROUTE-RUNTIME-SMOKE PASS routes=${routes.length}\n`);
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+3 -3
View File
@@ -31,7 +31,7 @@ const fs = require("fs");
const routeKeys = Object.keys(ROUTES);
const routePaths = routeKeys.map((routeKey) => ROUTES[routeKey].path);
assert.strictEqual(routeKeys.length, 52, "路由注册表必须恰好包含 52 条活动路由");
assert.strictEqual(routeKeys.length, 53, "路由注册表必须恰好包含 53 条活动路由");
assert.deepStrictEqual(routeKeys, expectedRouteKeys, "路由键集合和顺序必须来自 pages.json 的页面编号");
assert.deepStrictEqual(routePaths, registeredPaths, "路由路径集合和顺序必须与 pages.json 精确一致");
assert(Object.isFrozen(ROUTES), "ROUTES 必须冻结");
@@ -78,8 +78,8 @@ const fs = require("fs");
}
assert.deepStrictEqual(
Array.from(ROUTES.T03.resultOperations),
["member-open-requested"],
"T03 当前只能保留 single 页面激活实际使用的页内打开结果",
["member-open-requested", "member-updated"],
"T03 当前必须保留成员打开与编辑完成后的返回结果",
);
const expectedFamilyParams = {
F01: { required: [], optional: ["genealogyId"] },
+82
View File
@@ -0,0 +1,82 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const walk = (directory) => fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory() ? walk(path.join(directory, entry.name)) : [path.join(directory, entry.name)],
);
const apiSource = fs.readFileSync("utils/api.js", "utf8");
const apiOwners = new Set([...apiSource.matchAll(/^ async ([A-Za-z0-9_]+)\(/gm)].map((match) => match[1]));
const pageSources = new Map(
walk("pages")
.filter((file) => file.endsWith(".vue"))
.map((file) => [file.replace(/\\/g, "/"), fs.readFileSync(file, "utf8")]),
);
const routedPages = new Set(
JSON.parse(fs.readFileSync("pages.json", "utf8")).pages.map((page) => `${page.path}.vue`),
);
assert.deepStrictEqual(
[...routedPages].filter((file) => !pageSources.has(file)),
[],
"every configured route must have its Vue page source",
);
const calls = [...pageSources].flatMap(([file, source]) =>
[...source.matchAll(/appApi\.([A-Za-z0-9_]+)/g)].map((match) => ({ file, method: match[1] })),
);
assert.ok(calls.length > 0, "page API ownership scan found no calls");
assert.deepStrictEqual(
calls.filter((call) => !apiOwners.has(call.method)),
[],
"every page appApi call must have one adapter owner",
);
assert.deepStrictEqual(
[...routedPages].filter((file) => !calls.some((call) => call.file === file)),
["pages/records/r09-life-events.vue"],
"only the explicitly unavailable life-events page may have no backend call",
);
const expectedCallers = {
sendSmsCode: [
"pages/auth/a01-entry.vue",
"pages/auth/a04-register.vue",
"pages/auth/a05-reset-password.vue",
"pages/profile/m05-change-phone.vue",
],
changePhone: ["pages/profile/m05-change-phone.vue"],
getRegionChildren: [
"pages/genealogy/g03-create-genealogy.vue",
"pages/genealogy/g11-genealogy-settings.vue",
],
};
for (const [method, files] of Object.entries(expectedCallers)) {
const actual = [...new Set(calls.filter((call) => call.method === method).map((call) => call.file))].sort();
assert.deepStrictEqual(actual, files, `${method} page ownership drifted`);
}
const changePhonePage = pageSources.get("pages/profile/m05-change-phone.vue");
for (const requiredToken of [
"appApi.getCaptchaRequirement",
"appApi.sendSmsCode",
"appApi.changePhone",
"AUTH_VERIFICATION_OPERATION.PHONE_CHANGE",
"<TacVerification",
]) {
assert.ok(changePhonePage.includes(requiredToken), `M05 phone-change flow is missing: ${requiredToken}`);
}
for (const source of pageSources.values()) {
for (const retiredPath of [
"/genealogy/app/auth/sms/code",
"/genealogy/app/files/upload",
"/genealogy/app/files/reference",
"/genealogy/region/",
]) {
assert.strictEqual(source.includes(retiredPath), false, `page retains retired API path: ${retiredPath}`);
}
}
process.stdout.write(`PAGE-API-OWNERSHIP-RUNTIME-SMOKE PASS calls=${calls.length}\n`);
+104
View File
@@ -0,0 +1,104 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const YAML = require("yaml");
const walk = (directory) => fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory() ? walk(path.join(directory, entry.name)) : [path.join(directory, entry.name)],
);
const document = YAML.parse(fs.readFileSync("genealogy-app-openapi.yaml", "utf8"));
assert.strictEqual(document.openapi, "3.0.3", "backend contract must be OpenAPI 3.0.3");
const operations = new Set(
Object.entries(document.paths).flatMap(([endpoint, pathItem]) =>
Object.keys(pathItem)
.filter((method) => ["get", "post", "put", "delete", "patch"].includes(method))
.map((method) => `${method.toUpperCase()} ${endpoint}`),
),
);
const apiSource = fs.readFileSync("utils/api.js", "utf8");
const appApiStart = apiSource.indexOf("export const appApi = {");
assert.ok(appApiStart >= 0, "appApi owner is missing");
const appApiSource = apiSource.slice(appApiStart);
const methodMatches = [...appApiSource.matchAll(/^ async ([A-Za-z0-9_]+)\(/gm)];
const methodSources = new Map(
methodMatches.map((match, index) => [
match[1],
appApiSource.slice(match.index, methodMatches[index + 1]?.index),
]),
);
const normalizeEndpoint = (endpoint) => endpoint
.replace(/\$\{[^}]+\}/g, "{parameter}")
.replace(/\{[^/]+\}/g, "{parameter}");
const resolveOpenApiEndpoint = (endpoint) => Object.keys(document.paths).find(
(candidate) => normalizeEndpoint(candidate) === normalizeEndpoint(endpoint),
);
const collectLiteralOperations = (source) => {
const entries = [];
const add = (method, endpoint) => {
if (endpoint.includes("${relationPath}")) {
for (const relationPath of ["parents", "spouses", "siblings", "children"]) {
entries.push({ method, endpoint: endpoint.replace("${relationPath}", relationPath) });
}
return;
}
entries.push({ method, endpoint });
};
const literal = "([`'])";
const endpoint = "(/genealogy[^`']*)";
const readPattern = new RegExp(`readRemote(?:List|Object)\\(\\s*${literal}${endpoint}\\1`, "g");
for (const match of source.matchAll(readPattern)) add("GET", match[2]);
const writePattern = new RegExp(`writeRemote(?:Void|Object)\\(\\s*${literal}${endpoint}\\1\\s*,\\s*['\"](POST|PUT|DELETE|PATCH)['\"]`, "g");
for (const match of source.matchAll(writePattern)) add(match[3], match[2]);
const directPattern = new RegExp(`url:\\s*${literal}${endpoint}\\1\\s*,\\s*method:\\s*['\"](GET|POST|PUT|DELETE|PATCH)['\"]`, "g");
for (const match of source.matchAll(directPattern)) add(match[3], match[2]);
const conditionalMethodPattern = new RegExp(`url:\\s*${literal}${endpoint}\\1\\s*,\\s*method:\\s*[^?]+\\?\\s*['\"](POST|PUT|DELETE|PATCH)['\"]\\s*:\\s*['\"](POST|PUT|DELETE|PATCH)['\"]`, "g");
for (const match of source.matchAll(conditionalMethodPattern)) {
add(match[3], match[2]);
add(match[4], match[2]);
}
return entries;
};
const pageCalls = walk("pages")
.filter((file) => file.endsWith(".vue"))
.flatMap((file) => {
const source = fs.readFileSync(file, "utf8");
return [...source.matchAll(/appApi\.([A-Za-z0-9_]+)/g)].map((match) => ({
file: file.replace(/\\/g, "/"),
method: match[1],
}));
});
const routedPages = new Set(
JSON.parse(fs.readFileSync("pages.json", "utf8")).pages.map((page) => `${page.path}.vue`),
);
const unresolvedMethods = [];
const unmappedOperations = [];
for (const method of [...new Set(pageCalls.map((call) => call.method))]) {
const source = methodSources.get(method);
if (!source) {
unresolvedMethods.push(method);
continue;
}
const references = collectLiteralOperations(source);
if (!references.length) {
unresolvedMethods.push(method);
continue;
}
for (const reference of references) {
const endpoint = resolveOpenApiEndpoint(reference.endpoint);
if (!endpoint || !operations.has(`${reference.method} ${endpoint}`)) {
unmappedOperations.push(`${method}: ${reference.method} ${reference.endpoint}`);
}
}
}
assert.deepStrictEqual(unresolvedMethods, [], "page call has no inspectable adapter endpoint owner");
assert.deepStrictEqual(unmappedOperations, [], "page adapter endpoint diverges from current backend OpenAPI");
process.stdout.write(`PAGE-OPENAPI-CONTRACT PASS routed=${routedPages.size} apiPages=${new Set(pageCalls.map((call) => call.file)).size} calls=${pageCalls.length}\n`);
+3 -3
View File
@@ -51,7 +51,7 @@ const run = async () => {
nickName: "家谱用户",
realName: "王小明",
avatar: 900001,
sex: "",
sex: "0",
birthday: "1990-01-02",
email: "ming@example.com",
registerSource: "APP",
@@ -79,7 +79,7 @@ const run = async () => {
nickName: " 新昵称 ",
realName: "王小明",
avatar: 900001,
sex: "",
sex: "1",
birthday: "1991-02-03",
email: "new@example.com",
});
@@ -87,7 +87,7 @@ const run = async () => {
nickName: "新昵称",
realName: "王小明",
avatar: 900001,
sex: "",
sex: "1",
birthday: "1991-02-03",
email: "new@example.com",
});
+4 -4
View File
@@ -21,27 +21,27 @@ const run = async () => {
globalThis.uni = { request(options) { requests.push(options); queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: responseData } })); return { abort() {} }; } };
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
assert.deepStrictEqual(await appApi.getRegionChildren(), [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }]);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/children");
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/children");
assert.strictEqual(requests.at(-1).method, "GET");
assert.deepStrictEqual(requests.at(-1).data, { parentCode: "0" });
await assert.rejects(appApi.getRegionChildren(" "), /地区父级/);
responseData = [{ regionCode: "110000", label: "Beijing" }];
assert.deepStrictEqual(await appApi.getRegionPath("110000"), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/path/110000");
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/path/110000");
await assert.rejects(appApi.getRegionPath(" "), /行政区划编码/);
assert.deepStrictEqual(
await appApi.searchRegions({ keyword: " Beijing ", level: 2, limit: 20 }),
responseData,
);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/search");
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/search");
assert.deepStrictEqual(requests.at(-1).data, { keyword: "Beijing", level: 2, limit: 20 });
await assert.rejects(appApi.searchRegions({ keyword: "" }), /行政区划搜索关键词/);
await assert.rejects(appApi.searchRegions({ keyword: "Beijing", level: 6 }), /行政区划级别/);
responseData = { regionCode: "110000", label: "Beijing" };
assert.deepStrictEqual(await appApi.getRegion("110000"), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/region/110000");
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/region/110000");
delete globalThis.uni;
process.stdout.write("REGION-API-RUNTIME-SMOKE PASS\n");
};
+5 -14
View File
@@ -31,20 +31,10 @@ const run = async () => {
success("/tmp/image.jpg");
},
},
io: {
resolveLocalFileSystemURL(_path, success) {
success({
file(done) {
done({ name: "image.jpg", size: 4, type: "image/jpeg" });
},
});
},
FileReader: class {
readAsArrayBuffer() {
this.result = new Uint8Array([1, 2, 3, 4]).buffer;
this.onloadend({ target: { result: this.result } });
}
},
};
globalThis.uni = {
getFileInfo(options) {
options.success({ size: 4, digest: "a".repeat(32) });
},
};
assert.deepStrictEqual(await pickAndUploadImage(), {
@@ -54,6 +44,7 @@ const run = async () => {
fileName: "image.jpg",
});
delete globalThis.plus;
delete globalThis.uni;
delete globalThis.__appApi;
process.stdout.write("RESUMABLE-IMAGE-UPLOAD-RUNTIME-SMOKE PASS\n");
};
+51
View File
@@ -0,0 +1,51 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const toDataModuleUrl = (source) =>
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
const run = async () => {
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
const prelude = `
const currentUser = {}; const genealogies = []; const publicGenealogies = []; const treeMembers = []; const notifications = []; const joinApplications = [];
const listFamilyFeedFixtures = () => []; const listFamilyArticleFixtures = () => []; const listFamilyAlbumFixtures = () => []; const listCeremonyFixtures = () => []; const listGrowthRecordFixtures = () => []; const listNotificationFixtures = () => [];
const runtimeConfig = { baseUrl: "https://backend-api.ddxcjp.cn", clientId: "client-1", tenantId: "000000" };
const hasRemoteConfig = () => true; const resolveRuntimeMode = () => "remote"; const AUTH_VERIFICATION_OPERATION = Object.freeze({}); const assertSmsCode = (value) => value;
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" }); const fromApiGenealogyAccess = () => null; const session = { getToken: () => "session-1", saveToken() {} };
`;
const requests = [];
let responseData = [];
globalThis.uni = {
request(options) {
requests.push(options);
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: responseData } }));
return { abort() {} };
},
};
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
responseData = [{ id: "help-1" }];
assert.deepStrictEqual(await appApi.getSiteArticles({ articleType: " help ", limit: "5" }), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/site/articles");
assert.deepStrictEqual(requests.at(-1).data, { articleType: "help", limit: 5 });
assert.strictEqual(requests.at(-1).header.Authorization, undefined);
await assert.rejects(appApi.getSiteArticles({ limit: 0 }), /数量上限/);
responseData = { pageKey: "about" };
assert.deepStrictEqual(await appApi.getSitePage("about us"), responseData);
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/site/pages/about%20us");
assert.strictEqual(requests.at(-1).header.Authorization, undefined);
await assert.rejects(appApi.getSitePage(" "), /页面标识/);
delete globalThis.uni;
process.stdout.write("SITE-CONTENT-API-RUNTIME-SMOKE PASS\n");
};
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exit(1);
});
+37 -44
View File
@@ -1,58 +1,51 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
$tree = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t02-pedigree-overview.vue') -Raw -Encoding utf8
foreach ($required in @(
'import AppButton from "@/components/AppButton.vue"',
'<AppButton',
'type="secondary"',
'@click="toMember"',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
'openMemberPanel(member)',
'member-action-profile',
'v-for="connector in lineageConnectors"',
'lineage-pan-cue',
'const treeScrollLeft = ref(90)',
':scroll-left="treeState === ''tree'' ? treeScrollLeft : 0"',
':style="treeState === ''tree'' ? treeMetricsStyle : undefined"'
'@action="toTree"',
'class="pedigree-page"',
'class="pedigree-layout"',
'class="pedigree-scroll"',
'class="pedigree-sheet"',
'class="pedigree-column pedigree-column--legend"',
'class="member-node__relation"',
'class="member-node__name"',
'class="member-node__copy"',
'class="generation-band__arrow"',
'v-for="(page, pageIndex) in pedigreePages"',
'v-for="(member, memberIndex) in page.members"',
'class="pedigree-swiper"',
':disable-touch="false"',
'scroll-y',
'const openMemberProfile = (member) =>',
'const openMemberDetail = async (member) =>',
'detailVisible',
'<AppDialog'
)) {
if ($page -notmatch [regex]::Escape($required)) { throw "T01 member action asset missing: $required" }
}
foreach ($forbidden in @(
'a01-primary-button-v2.png',
'a01-secondary-button-v2.png'
)) {
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must consume AppButton instead of legacy action asset: $forbidden" }
if ($page -notmatch [regex]::Escape($required)) {
throw "T02 pedigree visual contract missing: $required"
}
}
foreach ($rule in @(
'(?s)\.tree-toolbar__title text:last-child\s*\{[^}]*font-size:\s*22rpx;',
'(?s)\.tree-toolbar__actions\s*\{[^}]*font-size:\s*23rpx;',
'(?s)\.generation-band\s*\{[^}]*font-size:\s*22rpx;',
'(?s)\.node-relation\s*\{[^}]*font-size:\s*21rpx;',
'(?s)\.node-years\s*\{[^}]*font-size:\s*20rpx;',
'(?s)\.tree-state-card__copy\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.member-action-profile__copy text:nth-child\(2\)\s*\{[^}]*font-size:\s*21rpx;',
'(?s)\.tree-stage--lineage\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*190rpx minmax\(0, 1fr\);',
'(?s)\.generation-rail\s*\{[^}]*display:\s*grid;[^}]*width:\s*190rpx;',
'(?s)\.tree-scroll--lineage\s*\{[^}]*grid-area:\s*1 / 2;[^}]*min-width:\s*0;',
'(?s)\.lineage-pan-cue\s*\{[^}]*grid-area:\s*1 / 2;',
'const treeMetricsStyle = computed',
'(?s)\.generation-band\s*\{[^}]*display:\s*grid;',
'(?s)\.generation-band image,\s*\.generation-band__copy\s*\{[^}]*grid-area:\s*1 / 1;',
'(?s)\.member-node\s*\{[^}]*display:\s*grid;',
'(?s)\.member-node__skin,\s*\.member-node__copy\s*\{[^}]*grid-area:\s*1 / 1;',
'(?s)\.member-action-profile\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;',
'(?s)\.member-action-profile__avatar\s*\{[^}]*width:\s*82rpx;[^}]*aspect-ratio:\s*1;[^}]*flex:\s*0 0 82rpx;'
'(?s)\.pedigree-layout\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 92rpx;',
'(?s)\.pedigree-swiper\s*\{[^}]*width:\s*100%;[^}]*height:\s*100%;',
'(?s)\.pedigree-sheet\s*\{[^}]*display:\s*grid;[^}]*width:\s*100%;[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);',
'(?s)\.member-node\s*\{[^}]*grid-template-rows:\s*144rpx 274rpx minmax\(0, 1fr\);',
'(?s)\.member-node__name\s*\{[^}]*font-size:\s*34rpx;',
'(?s)\.member-node__copy\s*\{[^}]*writing-mode:\s*vertical-rl;',
'(?s)\.generation-band__copy\s*\{[^}]*writing-mode:\s*vertical-rl;'
)) {
if ($page -notmatch $rule) { throw "T01 readable visual rule missing: $rule" }
if ($page -notmatch $rule) { throw "T02 pedigree visual rule missing: $rule" }
}
if ($page -match '(?s)<view\s+class="tree-canvas".*?<view class="lineage-pan-cue">') {
throw 'T01 horizontal reading cue must stay in the visible lineage viewport, not inside the wide canvas'
if ($page -match 'scroll-x|treeScrollLeft|openMemberPanel') {
throw 'T02 must use direct swiper gestures and separate its name/detail tap targets'
}
if ($tree -notmatch 'scroll-x|treeScrollLeft|class="lineage-pan-cue"|@action="toPedigree"') {
throw 'T01 must retain the horizontal tree-diagram reader and its pedigree entry'
}
Write-Output 'T01-ALL-STATES-VISUAL-CONTRACT PASS'
+24 -18
View File
@@ -1,11 +1,12 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
$tree = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
$pedigree = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t02-pedigree-overview.vue') -Raw -Encoding UTF8
$routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8
foreach ($required in @(
'class="member-node__avatar"',
'class="member-node__surface"',
'const memberActionPanelVisible = ref(false)',
'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>',
@@ -14,35 +15,40 @@ foreach ($required in @(
'unavailableActionVisible',
'BIND_INVITE'
)) {
if (-not $page.Contains($required)) {
throw "T01 member action panel missing: $required"
if (-not $tree.Contains($required)) {
throw "T01 tree member action panel missing: $required"
}
}
foreach ($actionKey in @(
'VIEW_PROFILE',
'ADD_FATHER',
'ADD_MOTHER',
'ADD_SPOUSE',
'ADD_SIBLING',
'ADJUST_RANK',
'ADD_SON',
'ADD_DAUGHTER',
'BIND_INVITE',
'EDIT_PROFILE'
'VIEW_PROFILE', 'ADD_FATHER', 'ADD_MOTHER', 'ADD_SPOUSE', 'ADD_SIBLING',
'ADJUST_RANK', 'ADD_SON', 'ADD_DAUGHTER', 'BIND_INVITE', 'EDIT_PROFILE'
)) {
if (-not $page.Contains(('key: "' + $actionKey + '"'))) {
throw "T01 member action panel missing action: $actionKey"
if (-not $tree.Contains(('key: "' + $actionKey + '"'))) {
throw "T01 tree member action missing: $actionKey"
}
}
foreach ($required in @(
'const openMemberProfile = (member) =>',
'const openMemberDetail = async (member) =>',
'appApi.getPerson(genealogyId.value, member.id',
'detailVisible',
'class="member-node__name"',
'class="member-node__copy"'
)) {
if (-not $pedigree.Contains($required)) {
throw "T02 pedigree member interaction missing: $required"
}
}
foreach ($required in @(
'optionalParams: ["personId", "mode", "relationType"]',
'allowedSources: ["T01", "T03"]',
'allowedSources: ["T01", "T02", "T03"]',
'optionalParams: ["mode"]'
)) {
if (-not $routes.Contains($required)) {
throw "T01 member action route contract missing: $required"
throw "Tree route contract missing: $required"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
foreach ($token in @('const layoutMembers = computed', 'childrenByParent', 'spouseByPersonId', 'parentAnchorX', 'FAMILY_LINK_OFFSET', 'kind: "family"', 'getFamilyPartner', 'spouseRect', 'layoutMembers.value', 'GENERATION_GAP', 'MEMBER_GAP')) {
foreach ($token in @('const layoutMembers = computed', 'childrenByParent', 'spouseByPersonId', 'parentAnchorX', 'FAMILY_LINK_OFFSET', 'kind: "family"', 'layoutMembers.value', 'GENERATION_GAP', 'MEMBER_GAP')) {
if (-not $page.Contains($token)) { throw "T01 relation layout contract missing: $token" }
}
if ($page -match 'maxX\s*=.*members\.value.*\.x|maxY\s*=.*members\.value.*\.y') { throw 'T01 canvas must not depend on API pixel coordinates' }
+54 -97
View File
@@ -5,79 +5,72 @@ function Assert-Contains {
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
}
function Assert-NoCssSurface {
param([string]$Content, [string]$ClassName)
foreach ($property in @('border', 'background', 'border-radius', 'box-shadow')) {
if ($Content -match "(?s)\.$ClassName\s*\{[^}]*\b$property\s*:") {
throw "T01 must not construct .$ClassName with CSS $property"
}
}
}
function Assert-NoPosition {
param([string]$Content, [string]$Selector)
$escaped = [regex]::Escape($Selector)
if ($Content -match "(?s)$escaped\s*\{[^}]*\bposition\s*:") {
throw "T01 ordinary layout selector must stay in document flow: $Selector"
}
}
$root = Split-Path -Parent $PSScriptRoot
$pagePath = Join-Path $root 'pages/tree/t01-tree-overview.vue'
$t02Path = Join-Path $root 'pages/tree/t02-tree-states.vue'
$t02Path = Join-Path $root 'pages/tree/t02-pedigree-overview.vue'
$page = Get-Content -LiteralPath $pagePath -Raw -Encoding utf8
$pedigree = Get-Content -LiteralPath $t02Path -Raw -Encoding utf8
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8
if (Test-Path -LiteralPath $t02Path) { throw 'T02 duplicate route file must be removed' }
if ($pages -match 'pages/tree/t02-tree-states') { throw 'T02 duplicate route must be removed from pages.json' }
if (-not (Test-Path -LiteralPath $t02Path)) { throw 'T02 pedigree route file must exist' }
if ($pages -notmatch 'pages/tree/t02-pedigree-overview') { throw 'T02 pedigree route must be registered in pages.json' }
foreach ($required in @(
'appApi.getTree',
'createRequestController',
'isRequestCancelled',
'treeRequestController.abort()',
'onShow(() =>',
'requestController: treeRequestController'
'requestController: treeRequestController',
'genealogyContext.isCurrentGenealogyInvalidated()',
'genealogyContext.setCurrentGenealogyId(genealogyId.value)'
)) { Assert-Contains $page $required "Missing T01 remote data contract: $required" }
foreach ($required in @(
'@action="toPedigree"',
'const treeState = ref("loading")',
'tree-state--tree',
'tree-state--landscape',
'tree-state--empty',
'tree-state--error',
'tree-canvas--state',
'lineage-connector',
'const generationRows = computed(',
'v-for="row in generationRows"',
':style="generationBandStyle(row)"',
'class="tree-stage"',
'tree-stage--lineage',
'class="generation-rail"',
'tree-scroll--lineage',
':style="nodeGridStyle(member)"',
'const nodeGridStyle = (member)',
'class="member-node__copy"',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
'class="member-action-profile"',
'const openMemberPanel = (member) =>',
'openPage("T03"',
'routeKey: "T04"',
'openPage("T06"',
'openPage("T07"',
'query.genealogyId',
'genealogyContext.isCurrentGenealogyInvalidated()',
':id="`tree-member-${member.id}`"',
'v-for="member in layoutMembers"',
'class="member-node__portrait"',
'class="lineage-pan-cue"',
'scroll-x',
':scroll-left="treeState === ''tree'' ? treeScrollLeft : 0"',
'@scroll="handleTreeScroll"',
'const currentTreeScrollLeft = ref(90)',
'uni.createSelectorQuery()',
'nodeRect.left + nodeRect.width / 2 - (scrollRect.left + scrollRect.width / 2)',
'const treeHasDrifted = ref(false)',
':style="nodeGridStyle(member)"',
'const nodeGridStyle = (member)',
'class="generation-rail"',
'class="generation-band"',
'v-for="row in generationRows"',
':style="generationBandStyle(row)"',
'const treeMetrics = computed(',
':style="treeState === ''tree'' ? treeMetricsStyle : undefined"',
'const recenterSelectedMember = async () =>',
'class="tree-recenter"'
)) { Assert-Contains $page $required "Missing T01 contract: $required" }
'class="tree-recenter"',
'class="member-action-profile"',
'const openMemberPanel = (member) =>',
'"T03",',
'routeKey: "T04"',
'"T06",',
'openPage("T07"'
)) { Assert-Contains $page $required "Missing T01 tree contract: $required" }
foreach ($required in @(
'@action="toTree"',
'const pedigreePages = computed(() =>',
'class="pedigree-sheet"',
'class="pedigree-column pedigree-column--legend"',
'v-for="(page, pageIndex) in pedigreePages"',
'v-for="(member, memberIndex) in page.members"',
'class="pedigree-swiper"',
':disable-touch="false"',
'scroll-y',
'const handlePedigreePageChange = (event)',
'const toTree = () =>'
)) { Assert-Contains $pedigree $required "Missing T02 pedigree contract: $required" }
foreach ($fixtureToken in @('getGenealogyFixtureAccess', 'treeMembers', 'mockTree')) {
if ($page -match [regex]::Escape($fixtureToken)) {
@@ -91,56 +84,20 @@ if ($invalidatedGuardIndex -lt 0 -or $contextWriteIndex -le $invalidatedGuardInd
throw 'T01 must reject an invalidated genealogy before any route can rewrite the current context'
}
foreach ($forbidden in @(
'g03-create-flow-panel.png',
'g06-search-input-wide.png',
'application-status-card.png',
'generation-band--twelve',
'generation-band--thirteen',
'generation-band--fourteen'
)) {
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must not keep opaque surface asset: $forbidden" }
}
foreach ($className in @('tree-canvas', 'member-node', 'member-action-profile', 'tree-state-card')) {
Assert-NoCssSurface $page $className
}
foreach ($selector in @('.generation-rail', '.generation-band', '.member-node')) {
Assert-NoPosition $page $selector
}
if ($page -match '(?s)\.node-name,\s*\.node-relation,\s*\.node-years\s*\{[^}]*\bposition\s*:') {
throw 'T01 member node text must stay in document flow'
}
if ($page -notmatch '(?s)\.tree-page\s*\{[^}]*display:\s*grid;[^}]*grid-template-rows:\s*auto auto minmax\(0, 1fr\);') {
throw 'T01 page content must own a grid stacking context above the fixed module background'
}
foreach ($obsolete in @('const nodeStyle = (member)', 'const generationRowStyle = (row)', 'left: `${member.x}rpx`', 'top: `${member.y}rpx`')) {
if ($page -match [regex]::Escape($obsolete)) { throw "T01 obsolete positioned layout remains: $obsolete" }
}
foreach ($required in @(
'const treeMetrics = computed(',
':style="treeState === ''tree'' ? treeMetricsStyle : undefined"',
':style="generationBandStyle(row)"',
'overflow-y: auto;'
)) { Assert-Contains $page $required "T01 data-driven tree boundary missing: $required" }
if ($page -match '<scroll-view\s+class="tree-stage"') {
throw 'T01 outer vertical tree owner must preserve the stage grid instead of wrapping its grid children'
}
foreach ($fixedCapacity in @(
'grid-template-columns: repeat(180, 5rpx)',
'grid-template-rows: repeat(180, 5rpx)',
'width: 900rpx',
'height: 900rpx',
'grid-template-rows: 47rpx 58rpx 157rpx 58rpx 242rpx 58rpx 1fr'
)) {
if ($page -match [regex]::Escape($fixedCapacity)) {
throw "T01 must derive tree capacity from member data: $fixedCapacity"
}
if ($page -notmatch '(?s)\.tree-stage--lineage\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*144rpx minmax\(0, 1fr\);') {
throw 'T01 generation rail must stay beside the tree canvas'
}
if ($page -notmatch '(?s)\.member-node\s*\{[^}]*position:\s*relative;') {
throw 'T01 graph node must retain its positioned tree layout'
}
if ($pedigree -match 'scroll-x|treeScrollLeft|changeGeneration =') {
throw 'T02 must leave horizontal gestures to the native swiper, not an outer scroll or click pager'
}
if ($page -notmatch 'openPage\(\s*"T02"') {
throw 'T01 must expose the pedigree page from the tree header'
}
Write-Output 'T01-TREE-STATE-CONTRACT PASS'
+34 -15
View File
@@ -49,13 +49,13 @@ const waitFor = async (send, expression, message) => {
throw new Error(message)
}
const open = async (send, query, selector) => {
const url = `${origin}/#/pages/tree/t01-tree-overview${query}`
const url = `${origin}/#/pages/tree/t02-pedigree-overview${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `T01 navigation failed: ${query}`)
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `T02 navigation failed: ${query}`)
const previousTimeOrigin = await valueOf(send, 'performance.timeOrigin')
await send('Page.reload')
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `T01 reload did not create a fresh document: ${query}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T01 state did not render: ${selector}`)
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `T02 reload did not create a fresh document: ${query}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `T02 state did not render: ${selector}`)
}
const run = async () => {
@@ -88,11 +88,11 @@ const run = async () => {
assert.strictEqual(metrics.stageDisplay, 'grid', `T01 stage is not a grid at ${size.width}x${size.height}`)
assert.strictEqual(metrics.railPosition, 'static', `T01 generation rail is positioned at ${size.width}x${size.height}`)
assert.strictEqual(metrics.nodePosition, 'static', `T01 member node is positioned at ${size.width}x${size.height}`)
assert.strictEqual(metrics.nodeCount, 6, `T01 member nodes are incomplete at ${size.width}x${size.height}`)
assert(metrics.firstNodeRect.width > 80 && metrics.firstNodeRect.height > 30, `T01 member node collapsed at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert.strictEqual(metrics.nodeCount, 5, `T01 pager must render one five-member swipe page at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert(metrics.firstNodeRect.width > 30 && metrics.firstNodeRect.height > 180, `T01 pedigree column collapsed at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert(metrics.firstNodeRect.top >= metrics.canvasRect.top && metrics.firstNodeRect.top < metrics.canvasRect.top + metrics.canvasRect.height, `T01 member node escaped the data-driven canvas at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert(metrics.firstNodeRect.left + metrics.firstNodeRect.width > 0 && metrics.firstNodeRect.left < size.width, `T01 initial member node is outside the visible horizontal tree viewport at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert(metrics.firstNodeRect.top < size.height * 0.5, `T01 generation rail and canvas were stacked instead of sharing the tree-stage row at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
assert(metrics.firstNodeRect.top < size.height * 0.5, `T01 generation rail and pedigree sheet were stacked instead of sharing the tree-stage row at ${size.width}x${size.height}: ${JSON.stringify(metrics)}`)
}
const stressMembers = []
for (let generation = 1; generation <= 10; generation += 1) {
@@ -122,7 +122,7 @@ const run = async () => {
return true
})()`)
assert.strictEqual(injected, true, 'T01 stress data could not be injected into the active component')
await waitFor(send, "document.querySelectorAll('.member-node').length === 120", 'T01 did not render 10 generations with 12 members each')
await waitFor(send, "document.querySelectorAll('.member-node').length === 5", 'T01 did not render one five-member swipe page')
const stressMetrics = await valueOf(send, `(() => {
const canvas = document.querySelector('.tree-canvas')
const rail = document.querySelector('.generation-rail')
@@ -135,18 +135,37 @@ const run = async () => {
documentWidth: document.documentElement.scrollWidth
}
})()`)
assert(stressMetrics.canvasWidth > 1400, 'T01 canvas width did not grow from 12 members per generation')
assert(stressMetrics.canvasHeight > 1000, 'T01 canvas height did not grow from 10 generations')
assert(stressMetrics.canvasWidth > 250, 'T01 swipe sheet did not fill the available viewport')
assert(stressMetrics.canvasHeight > 500, 'T01 pedigree sheet height did not reserve space for vertical details')
assert(Math.abs(stressMetrics.canvasHeight - stressMetrics.railHeight) <= 1, 'T01 generation rail did not follow the data-driven canvas height')
assert.strictEqual(stressMetrics.generationCount, 10, 'T01 generation rail did not derive all generations from data')
assert(stressMetrics.connectorCount > 30, 'T01 relationship connectors did not derive from parent data')
assert(stressMetrics.documentWidth <= 413, 'T01 stress tree escaped its scroll owner')
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node')
await valueOf(send, "document.querySelector('.member-node')?.click()")
await valueOf(send, "document.querySelector('.member-action-profile')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'T01 selected member did not open T03')
process.stdout.write('T01-TREE-STATE-RUNTIME-SMOKE PASS\n')
const swiperRect = await valueOf(send, `(() => {
const rect = document.querySelector('.pedigree-swiper').getBoundingClientRect()
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
})()`)
await send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: swiperRect.left + swiperRect.width * 0.76, y: swiperRect.top + Math.min(180, swiperRect.height * 0.35), radiusX: 1, radiusY: 1, force: 1, id: 1 }]
})
await send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: swiperRect.left + swiperRect.width * 0.24, y: swiperRect.top + Math.min(180, swiperRect.height * 0.35), radiusX: 1, radiusY: 1, force: 1, id: 1 }]
})
await send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await waitFor(send, `(() => {
let instance = document.querySelector('.tree-page')?.__vueParentComponent
while (instance && !Object.prototype.hasOwnProperty.call(instance.setupState || {}, 'pedigreePageIndex')) instance = instance.parent
const value = instance?.setupState?.pedigreePageIndex
return typeof value === 'number' ? value > 0 : value?.value > 0
})()`, 'T01 did not advance to the next page after a direct left swipe')
await open(send, '?genealogyId=1001', '.tree-state--tree .member-node__name')
await valueOf(send, "document.querySelector('.member-node__name')?.click()")
await waitFor(send, "location.href.includes('/pages/tree/t03-member-profile?genealogyId=1001&personId=')", 'T02 selected member did not open T03')
process.stdout.write('T02-PEDIGREE-RUNTIME-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
+10 -2
View File
@@ -8,6 +8,7 @@ function Assert-Contains {
$root = Split-Path -Parent $PSScriptRoot
$pages = @{
T01 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding UTF8
T02 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t02-pedigree-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
@@ -29,13 +30,20 @@ foreach ($required in @(
'appApi.getTree',
'const memberActions = Object.freeze([',
'const openMemberAction = (action) =>',
'openPage("T03"',
'"T03",',
'routeKey: "T04"',
'routeKey: "T05"',
'routeKey: "T06"',
'return openPage(action.routeKey, params, "T01")'
)) { Assert-Contains $pages.T01 $required "T01 missing member flow contract: $required" }
foreach ($required in @(
'appApi.getTree',
'const pedigreePages = computed(() =>',
'const openMemberProfile = (member) =>',
'"T02",'
)) { Assert-Contains $pages.T02 $required "T02 missing pedigree member flow contract: $required" }
foreach ($required in @(
'appApi.getPerson(',
'const memberTrail = reactive([]);',
@@ -57,7 +65,7 @@ foreach ($key in @('T04', 'T05', 'T06')) {
Assert-Contains $pages.T04 'relationType.value' 'T04 must accept the validated relation intent from T01'
$routes = Get-Content -LiteralPath (Join-Path $root 'utils/navigation-routes.js') -Raw -Encoding UTF8
Assert-Contains $routes 'allowedSources: ["T01", "T03"]' 'T05 route source contract drifted'
Assert-Contains $routes 'allowedSources: ["T01", "T02", "T03"]' 'T05 route source contract drifted'
Assert-Contains $pages.T06 'query.mode !== "rank"' 'T06 must reject non-rank entry modes'
Write-Output 'T03-T08-MEMBER-FLOW-CONTRACT PASS REMOTE-WRITE'