修改未完成
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 的唯一枚举拥有",
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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' }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user