60%
This commit is contained in:
@@ -158,10 +158,8 @@ foreach ($asset in @(
|
||||
'brand-seal.png',
|
||||
'a01-vnext-divider-v1.png',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'a01-scroll-secondary-v3.png',
|
||||
'a01-scroll-toast-v3.png',
|
||||
'a01-scroll-dialog-v3.png',
|
||||
'auth-wechat.png',
|
||||
'a01-icon-phone-v1.png',
|
||||
'a01-icon-lock-v1.png',
|
||||
'a01-icon-eye-open-v1.png',
|
||||
@@ -189,8 +187,8 @@ foreach ($obsoleteVisualAsset in @(
|
||||
}
|
||||
|
||||
$buttonSkinTags = [regex]::Matches($entry, '<image\s+class="button-skin"[^>]+>')
|
||||
if ($buttonSkinTags.Count -ne 2) {
|
||||
throw "A01 must have exactly two visible login button skins, found $($buttonSkinTags.Count)."
|
||||
if ($buttonSkinTags.Count -ne 1) {
|
||||
throw "A01 must have exactly one supported login button skin, found $($buttonSkinTags.Count)."
|
||||
}
|
||||
foreach ($buttonSkinTag in $buttonSkinTags) {
|
||||
Assert-Contains -Content $buttonSkinTag.Value -Expected 'mode="aspectFit"' -Message 'A01 button skins must use uniform aspectFit rendering.'
|
||||
@@ -207,7 +205,6 @@ $requiredCopy = @(
|
||||
'6aqM6K+B56CB',
|
||||
'5b+Y6K6w5a+G56CB',
|
||||
'6I635Y+W6aqM6K+B56CB',
|
||||
'5b6u5L+h55m75b2V',
|
||||
'6L+Y5rKh5pyJ6LSm5Y+377yf',
|
||||
'5rOo5YaM6LSm5Y+3',
|
||||
'44CK55So5oi35Y2P6K6u44CL',
|
||||
|
||||
@@ -16,15 +16,21 @@ const pageScript = match[1].replace(
|
||||
"",
|
||||
);
|
||||
|
||||
const createHarnessFactory = (goRootResult = true, goRootError = null) => new Function(
|
||||
const createHarnessFactory = (
|
||||
goRootResult = true,
|
||||
goRootError = null,
|
||||
initialSessionToken = "",
|
||||
) => new Function(
|
||||
"goRootResult",
|
||||
"goRootError",
|
||||
"initialSessionToken",
|
||||
`
|
||||
"use strict";
|
||||
const calls = [];
|
||||
const ref = (value) => ({ value });
|
||||
const onBackPress = () => {};
|
||||
const onShow = (callback) => callback();
|
||||
const showCallbacks = [];
|
||||
const onShow = (callback) => showCallbacks.push(callback);
|
||||
const onUnload = () => {};
|
||||
const AuthPageShell = {};
|
||||
const AppToast = {};
|
||||
@@ -96,6 +102,9 @@ const createHarnessFactory = (goRootResult = true, goRootError = null) => new Fu
|
||||
tenantId: "000000",
|
||||
};
|
||||
const calcMD5 = (value) => "md5:" + value;
|
||||
const session = {
|
||||
getToken: () => initialSessionToken,
|
||||
};
|
||||
const goRoot = async (pageId) => {
|
||||
calls.push({ type: "go-root", pageId });
|
||||
if (goRootError) throw goRootError;
|
||||
@@ -124,17 +133,21 @@ const createHarnessFactory = (goRootResult = true, goRootError = null) => new Fu
|
||||
submitLogin,
|
||||
completeTac,
|
||||
closeTac,
|
||||
async triggerShow() {
|
||||
for (const callback of showCallbacks) await callback();
|
||||
},
|
||||
setPendingTacAction(value) {
|
||||
pendingTacAction = value;
|
||||
},
|
||||
};
|
||||
`,
|
||||
)(goRootResult, goRootError);
|
||||
)(goRootResult, goRootError, initialSessionToken);
|
||||
|
||||
const createHarness = (options = {}) =>
|
||||
createHarnessFactory(
|
||||
options.goRootResult ?? true,
|
||||
options.goRootError ?? null,
|
||||
options.initialSessionToken ?? "",
|
||||
);
|
||||
|
||||
const countCalls = (harness, type) =>
|
||||
@@ -159,6 +172,16 @@ const completePasswordTac = (harness) =>
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
const restored = createHarness({ initialSessionToken: "persisted-token" });
|
||||
await restored.triggerShow();
|
||||
assert.deepStrictEqual(
|
||||
restored.calls.map((call) => call.type),
|
||||
["go-root"],
|
||||
"冷启动时已有会话必须直接进入家谱根页",
|
||||
);
|
||||
assert.strictEqual(restored.calls[0].pageId, "G01");
|
||||
assert.strictEqual(restored.authenticationCommitted.value, true);
|
||||
|
||||
const success = createHarness();
|
||||
await preparePassword(success);
|
||||
assert.strictEqual(countCalls(success, "password-login"), 0);
|
||||
|
||||
@@ -34,6 +34,9 @@ foreach ($required in @(
|
||||
'a02-agreement-unchecked.png',
|
||||
'a02-agreement-checked.png',
|
||||
'v-model.trim="phone"',
|
||||
'v-model.trim="nickName"',
|
||||
'for="a04-nickname"',
|
||||
'class="required-mark"',
|
||||
'v-model.trim="verificationCode"',
|
||||
'v-model="password"',
|
||||
'v-model="confirmPassword"',
|
||||
|
||||
@@ -112,7 +112,7 @@ const run = async () => {
|
||||
|
||||
await valueOf(send, `(() => {
|
||||
const inputs = document.querySelectorAll('.auth-input input')
|
||||
const values = ['13800138000', '1234', 'demo-password', 'demo-password']
|
||||
const values = ['13800138000', '联调昵称', '1234', 'demo-password', 'demo-password']
|
||||
inputs.forEach((input, index) => {
|
||||
input.value = values[index]
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
@@ -1,77 +1,36 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$activePaths = @($pages.pages | ForEach-Object { "$($_.path).vue" })
|
||||
$moduleConsumers = @()
|
||||
$remoteBusinessOwners = @(
|
||||
'pages/auth/a01-entry.vue',
|
||||
'pages/auth/a04-register.vue',
|
||||
'pages/auth/a05-reset-password.vue',
|
||||
'pages/genealogy/g01-my-genealogies.vue',
|
||||
'pages/genealogy/g05-genealogy-overview.vue',
|
||||
'pages/tree/t01-tree-overview.vue',
|
||||
'pages/tree/t03-member-profile.vue',
|
||||
'pages/tree/t04-add-relative.vue',
|
||||
'pages/tree/t05-edit-member.vue',
|
||||
'pages/tree/t06-edit-relationship.vue',
|
||||
'pages/profile/m04-change-password.vue',
|
||||
'pages/profile/m07-feedback.vue',
|
||||
'pages/profile/m10-about-settings.vue'
|
||||
)
|
||||
|
||||
foreach ($relativePath in $activePaths) {
|
||||
$fullPath = Join-Path $root $relativePath
|
||||
if (-not (Test-Path -LiteralPath $fullPath)) { throw "Missing active page: $relativePath" }
|
||||
$source = Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8
|
||||
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') { $moduleConsumers += $relativePath }
|
||||
$usesRemoteBusiness = $source -match '@/utils/api\.js|\bappApi\b'
|
||||
if ($usesRemoteBusiness -and $relativePath -notin $remoteBusinessOwners) {
|
||||
throw "$relativePath must remain local-design only until its own tested interface batch"
|
||||
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') {
|
||||
throw "Active page must own its business content instead of ModulePage: $relativePath"
|
||||
}
|
||||
if (-not $usesRemoteBusiness -and $relativePath -in $remoteBusinessOwners) {
|
||||
throw "$relativePath lost its owned remote business interface"
|
||||
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') {
|
||||
throw "$relativePath must use project feedback components"
|
||||
}
|
||||
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$relativePath must use project feedback components" }
|
||||
}
|
||||
|
||||
if ($moduleConsumers.Count -gt 0) {
|
||||
throw "Active pages must own business content instead of ModulePage: $($moduleConsumers -join ', ')"
|
||||
$recordContracts = [ordered]@{
|
||||
'pages/records/r03-gift-list.vue' = @('appApi.getRelativeRecords', 'appApi')
|
||||
'pages/records/r04-gift-editor.vue' = @('appApi.createRelativeRecord', 'pickAndUploadImage')
|
||||
'pages/records/r05-ritual-list.vue' = @('appApi.getCeremonies', 'appApi')
|
||||
'pages/records/r06-ritual-detail.vue' = @('appApi.getCeremonyDetail', 'appApi')
|
||||
'pages/records/r07-ritual-editor.vue' = @('appApi.createCeremony', 'appApi')
|
||||
'pages/records/r08-growth-journal.vue' = @('appApi.getGrowthRecords', 'appApi.createGrowthRecord')
|
||||
'pages/records/r10-memo-list.vue' = @('appApi.getMemos', 'appApi.createMemo')
|
||||
'pages/records/r11-merit-records.vue' = @('appApi.getMeritRecords', 'appApi.createMeritRecord')
|
||||
}
|
||||
|
||||
$contracts = [ordered]@{
|
||||
'pages/family/f03-feed-detail.vue' = @('feedComments', 'commentDraft', 'submitComment', 'feed-state--expired')
|
||||
'pages/family/f04-article-list.vue' = @('articleCategories', 'filteredArticles', 'openArticle', 'createArticle')
|
||||
'pages/family/f05-article-detail.vue' = @('articleParagraphs', 'disabled label=', 'article-state--expired', 'backToArticles')
|
||||
'pages/family/f07-album-list.vue' = @('albums', 'openAlbum', 'createAlbum', 'album-state--empty')
|
||||
'pages/records/r03-gift-list.vue' = @('relativeRecords', 'openRelative', 'createRelativePreview', 'relative-state--empty')
|
||||
'pages/records/r04-gift-editor.vue' = @('relativeForm', 'validateRelative', 'localRelativePreview', 'relativeId')
|
||||
'pages/records/r05-ritual-list.vue' = @('ceremonies', 'openCeremony', 'createCeremonyPreview', 'ceremony-state--empty')
|
||||
'pages/records/r06-ritual-detail.vue' = @('ceremonyDetail', 'invitees', 'editCeremony', 'ceremony-state--expired')
|
||||
'pages/records/r07-ritual-editor.vue' = @('ceremonyForm', 'validateCeremony', 'localCeremonyPreview', 'ceremonyId')
|
||||
'pages/records/r08-growth-journal.vue' = @('growthRecords', 'recordGrowth', 'localGrowthPreview', 'timeline-state--empty')
|
||||
'pages/records/r09-life-events.vue' = @('人生事件接口尚未开放', 'serviceState', 'requestBack')
|
||||
'pages/records/r10-memo-list.vue' = @('memos', 'createMemoPreview', 'localMemoPreview', 'memo-state--empty')
|
||||
'pages/records/r11-merit-records.vue' = @('meritRecords', 'createMeritPreview', 'localMeritPreview', 'totalContribution')
|
||||
'pages/notification/n02-message-detail.vue' = @('findNotificationFixture', 'noticeDetail.unread', 'markAsRead', 'openNoticeTarget', 'notice-state--expired')
|
||||
'pages/profile/m02-edit-profile.vue' = @('profileForm', 'chooseAvatar', 'validateProfile', 'saveProfile')
|
||||
'pages/profile/m03-security-settings.vue' = @('securityItems', 'openSecurityItem', 'device-state--limited', 'currentUser.phone', 'checkSecurity')
|
||||
'pages/profile/m04-change-password.vue' = @('passwordForm', 'validatePassword', 'togglePassword', 'savePassword')
|
||||
'pages/profile/m05-change-phone.vue' = @('phoneForm', 'sendCode', 'validatePhone', 'phone-state--saving', 'savePhone')
|
||||
'pages/profile/m06-help-center.vue' = @('helpCategories', 'filteredQuestions', 'toggleQuestion', 'contactSupport')
|
||||
'pages/profile/m07-feedback.vue' = @('feedbackForm', 'feedbackTypes', 'validateFeedback', 'submitFeedback')
|
||||
'pages/profile/m08-promotion.vue' = @('inviteState', 'openInviteExplanation', 'explanationVisible', 'share-state--unavailable')
|
||||
'pages/profile/m09-vip-orders.vue' = @('serviceBenefits', 'orderState', 'order-state--unavailable', 'openServiceNotice')
|
||||
'pages/profile/m10-about-settings.vue' = @('agreementItems', 'openAgreement', 'confirmLogout', 'appVersion')
|
||||
}
|
||||
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
foreach ($entry in $recordContracts.GetEnumerator()) {
|
||||
$source = Get-Content -LiteralPath (Join-Path $root $entry.Key) -Raw -Encoding UTF8
|
||||
foreach ($token in $entry.Value) {
|
||||
if (-not $source.Contains($token)) { throw "$($entry.Key) missing page-owned contract: $token" }
|
||||
}
|
||||
foreach ($shared in @('ModulePageBackground', 'PageHeader')) {
|
||||
if (-not $source.Contains($shared)) { throw "$($entry.Key) must consume shared visual primitive: $shared" }
|
||||
if (-not $source.Contains($token)) { throw "$($entry.Key) missing active interface owner: $token" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const origin = process.argv[2] || "http://localhost:5173";
|
||||
const cdpPort = process.env.CDP_PORT || "9222";
|
||||
const genealogyId = process.env.GENEALOGY_ID || "2080557121112465409";
|
||||
const feedId = process.env.FEED_ID || "2080572776100487169";
|
||||
const articleId = process.env.ARTICLE_ID || "2080573188207632386";
|
||||
const albumId = process.env.ALBUM_ID || "2080573946172891138";
|
||||
const captureDirectory = path.join(__dirname, "..", "tmp", "all-page-audit");
|
||||
const routeStart = Number(process.env.PAGE_AUDIT_START || 0);
|
||||
const routeEnd = Number(process.env.PAGE_AUDIT_END || 0);
|
||||
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const pageExpectations = {
|
||||
"pages/auth/a01-entry": { title: "", noReload: true, skipTitle: true },
|
||||
"pages/auth/a04-register": { title: "注册账号" },
|
||||
"pages/auth/a05-reset-password": { title: "重设密码" },
|
||||
"pages/genealogy/g01-my-genealogies": { title: "我的家谱", capture: "01-genealogies.png" },
|
||||
"pages/genealogy/g03-create-genealogy": { title: "创建家谱", capture: "02-create-genealogy.png" },
|
||||
"pages/genealogy/g05-genealogy-overview": { title: "家谱总览", query: `genealogyId=${genealogyId}` },
|
||||
"pages/genealogy/g06-search-genealogies": { title: "搜索家谱" },
|
||||
"pages/genealogy/g08-join-application": { title: "申请加入", query: `genealogyId=${genealogyId}` },
|
||||
"pages/genealogy/g09-my-applications": { title: "我的申请" },
|
||||
"pages/genealogy/g10-application-review": { title: "申请审核", query: `genealogyId=${genealogyId}` },
|
||||
"pages/genealogy/g11-genealogy-settings": { title: "家谱设置", query: `genealogyId=${genealogyId}` },
|
||||
"pages/genealogy/g12-generation-poems": { title: "字辈诗", query: `genealogyId=${genealogyId}` },
|
||||
"pages/tree/t01-tree-overview": { title: "世系树", query: `genealogyId=${genealogyId}`, capture: "03-tree.png" },
|
||||
"pages/tree/t03-member-profile": { title: "成员档案", query: `genealogyId=${genealogyId}&state=error` },
|
||||
"pages/tree/t04-add-relative": { title: "录入首位成员", query: `genealogyId=${genealogyId}&mode=first` },
|
||||
"pages/tree/t05-edit-member": { title: "编辑成员", query: `genealogyId=${genealogyId}&state=error` },
|
||||
"pages/tree/t06-edit-relationship": { title: "调整排行", query: `genealogyId=${genealogyId}&mode=rank&state=error` },
|
||||
"pages/tree/t07-member-directory": { title: "成员目录", query: `genealogyId=${genealogyId}` },
|
||||
"pages/tree/t08-member-states": { title: "成员状态", query: `genealogyId=${genealogyId}&state=error` },
|
||||
"pages/family/f01-family-feed": { title: "家族动态", query: `genealogyId=${genealogyId}`, capture: "04-feed.png" },
|
||||
"pages/family/f02-publish-feed": { title: "发布动态", query: `genealogyId=${genealogyId}` },
|
||||
"pages/family/f03-feed-detail": { title: "动态详情", query: `genealogyId=${genealogyId}&feedId=${feedId}` },
|
||||
"pages/family/f04-article-list": { title: "谱文", query: `genealogyId=${genealogyId}` },
|
||||
"pages/family/f05-article-detail": { title: "谱文详情", query: `genealogyId=${genealogyId}&articleId=${articleId}` },
|
||||
"pages/family/f06-article-editor": { title: "新建谱文", query: `genealogyId=${genealogyId}` },
|
||||
"pages/family/f07-album-list": { title: "家族相册", query: `genealogyId=${genealogyId}` },
|
||||
"pages/family/f08-album-detail": { title: "相册详情", query: `genealogyId=${genealogyId}&albumId=${albumId}` },
|
||||
"pages/family/f09-media-upload": { title: "添加照片", query: `genealogyId=${genealogyId}&albumId=${albumId}` },
|
||||
"pages/family/f10-video-list": { title: "家族视频", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r01-people-list": { title: "人物录", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r02-person-detail": { title: "人物详情", query: `genealogyId=${genealogyId}&state=error` },
|
||||
"pages/records/r03-gift-list": { title: "亲友往来", query: `genealogyId=${genealogyId}`, capture: "05-gifts.png" },
|
||||
"pages/records/r04-gift-editor": { title: "新建往来记录", query: `genealogyId=${genealogyId}&mode=create` },
|
||||
"pages/records/r05-ritual-list": { title: "礼仪活动", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r06-ritual-detail": { title: "礼仪详情", query: `genealogyId=${genealogyId}&ceremonyId=0` },
|
||||
"pages/records/r07-ritual-editor": { title: "新建礼仪活动", query: `genealogyId=${genealogyId}&mode=create` },
|
||||
"pages/records/r08-growth-journal": { title: "成长记录", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r09-life-events": { title: "人生事件", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r10-memo-list": { title: "家族备忘", query: `genealogyId=${genealogyId}` },
|
||||
"pages/records/r11-merit-records": { title: "功德记录", query: `genealogyId=${genealogyId}` },
|
||||
"pages/notification/n01-message-center": { title: "消息中心", capture: "06-messages.png" },
|
||||
"pages/notification/n02-message-detail": { title: "消息详情" },
|
||||
"pages/profile/m01-profile-home": { title: "我的", capture: "07-profile.png" },
|
||||
"pages/profile/m02-edit-profile": { title: "编辑资料" },
|
||||
"pages/profile/m03-security-settings": { title: "账号与安全" },
|
||||
"pages/profile/m04-change-password": { title: "修改密码" },
|
||||
"pages/profile/m05-change-phone": { title: "换绑手机号" },
|
||||
"pages/profile/m06-help-center": { title: "帮助中心" },
|
||||
"pages/profile/m07-feedback": { title: "意见反馈" },
|
||||
"pages/profile/m08-promotion": { title: "推广中心" },
|
||||
"pages/profile/m09-vip-orders": { title: "VIP 与订单" },
|
||||
"pages/profile/m10-about-settings": { title: "关于家谱" },
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
const targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
|
||||
const page = targets.find((candidate) => candidate.type === "page" && candidate.url.startsWith(`${origin}/`));
|
||||
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`);
|
||||
const socket = new WebSocket(page.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;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
return { socket, send };
|
||||
};
|
||||
|
||||
const evaluate = async (send, expression) => (await send("Runtime.evaluate", {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})).result?.value;
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (await evaluate(send, expression)) return;
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const open = async (send, route, noReload) => {
|
||||
const url = `${origin}/#/${route}${pageExpectations[route].query ? `?${pageExpectations[route].query}` : ""}`;
|
||||
await send("Page.navigate", { url });
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `navigation failed: ${route}`);
|
||||
if (!noReload) {
|
||||
const previousTimeOrigin = await evaluate(send, "performance.timeOrigin");
|
||||
await send("Page.reload");
|
||||
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `reload failed: ${route}`);
|
||||
}
|
||||
await waitFor(send, "document.body && document.body.innerText.length > 0", `page did not render: ${route}`);
|
||||
await wait(700);
|
||||
};
|
||||
|
||||
const capture = async (send, filename) => {
|
||||
fs.mkdirSync(captureDirectory, { recursive: true });
|
||||
const image = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
|
||||
fs.writeFileSync(path.join(captureDirectory, filename), Buffer.from(image.data, "base64"));
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const allRoutes = require("../pages.json").pages.map((page) => page.path);
|
||||
assert.deepStrictEqual(allRoutes.sort(), Object.keys(pageExpectations).sort(), "route coverage drifted from pages.json");
|
||||
const routes = routeEnd > routeStart ? allRoutes.slice(routeStart, routeEnd) : allRoutes;
|
||||
const { socket, send } = await connect();
|
||||
const results = [];
|
||||
try {
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
for (const route of routes) {
|
||||
const expectation = pageExpectations[route];
|
||||
try {
|
||||
await open(send, route, expectation.noReload);
|
||||
const state = await evaluate(send, `(() => {
|
||||
const text = document.body.innerText || "";
|
||||
return {
|
||||
titlePresent: text.includes(${JSON.stringify(expectation.title)}),
|
||||
loading: Boolean(document.querySelector('.app-loading, .uni-loading, .loading-spinner')),
|
||||
textLength: text.length,
|
||||
textStart: text.slice(0, 120),
|
||||
hash: location.hash,
|
||||
};
|
||||
})()`);
|
||||
if (!expectation.skipTitle) assert.strictEqual(state.titlePresent, true, `${route} title missing: ${state.textStart}`);
|
||||
assert.strictEqual(state.loading, false, `${route} remained in a loading state`);
|
||||
if (expectation.capture) await capture(send, expectation.capture);
|
||||
results.push({ route, state: "PASS" });
|
||||
} catch (error) {
|
||||
results.push({ route, state: "FAIL", reason: error.message });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
for (const result of results) {
|
||||
process.stdout.write(`${result.state} ${result.route}${result.reason ? ` — ${result.reason}` : ""}\n`);
|
||||
}
|
||||
const failures = results.filter((result) => result.state === "FAIL");
|
||||
if (failures.length) throw new Error(`${failures.length}/${results.length} routed pages failed to reach a stable expected state`);
|
||||
process.stdout.write(`ALL-PAGE-ROUTE-RUNTIME-SMOKE PASS (${results.length} pages)\n`);
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -70,7 +70,7 @@ if ($tabButtons.Count -ne 2) { throw 'A01 两个登录方式必须都是原生 t
|
||||
if (@($tabButtons | Where-Object { $_.Value.Contains(':aria-selected=') }).Count -ne 2) { throw 'A01 两个 tab 都必须声明选中状态' }
|
||||
foreach ($class in @(
|
||||
'login-tab', 'password-toggle', 'get-code', 'forgot-password', 'login-submit',
|
||||
'wechat-login', 'register-link', 'agreement-toggle', 'agreement-link'
|
||||
'register-link', 'agreement-toggle', 'agreement-link'
|
||||
)) { Require-ButtonClass $a01 $class 'A01' }
|
||||
if (@($a01Buttons | Where-Object { $_.Value -match 'class="[^"]*\bagreement-link\b' }).Count -ne 2) { throw 'A01 两份协议必须各自可聚焦' }
|
||||
foreach ($name in @('手机号', '登录密码', '短信验证码')) {
|
||||
|
||||
@@ -11,6 +11,8 @@ const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||
const savedTokens = [];
|
||||
const clearedSessions = [];
|
||||
const relaunchedRoutes = [];
|
||||
const prelude = `
|
||||
const currentUser = {};
|
||||
const genealogies = [];
|
||||
@@ -45,14 +47,21 @@ const run = async () => {
|
||||
const session = {
|
||||
getToken: () => "",
|
||||
saveToken: (token) => globalThis.__savedTokens.push(token),
|
||||
clear: () => globalThis.__clearedSessions.push("cleared"),
|
||||
};
|
||||
`;
|
||||
|
||||
globalThis.__savedTokens = savedTokens;
|
||||
globalThis.__clearedSessions = clearedSessions;
|
||||
globalThis.__relaunchedRoutes = relaunchedRoutes;
|
||||
const requests = [];
|
||||
let nextResponse = null;
|
||||
let holdResponse = false;
|
||||
globalThis.uni = {
|
||||
reLaunch(options) {
|
||||
globalThis.__relaunchedRoutes.push(options.url);
|
||||
options.complete?.();
|
||||
},
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
const task = {
|
||||
@@ -104,6 +113,16 @@ const run = async () => {
|
||||
respond({ statusCode: 200, data: { code: 200 } });
|
||||
assert.strictEqual(await sendSms(), null, "RVoid 未声明 data 必填,省略 data 仍必须解析为 null");
|
||||
assert.strictEqual(requests.at(-1).header.clientid, "client-1");
|
||||
assert.strictEqual(requests.at(-1).header.tenantId, "000000");
|
||||
|
||||
// 已鉴权读取收到业务 401 时,只清理失效的本地会话;请求仍向调用方失败返回。
|
||||
respond({ statusCode: 200, data: { code: 401, msg: "认证失败", data: null } });
|
||||
await assert.rejects(
|
||||
appApi.getProfile(),
|
||||
(error) => error.code === "BUSINESS_ERROR" && error.businessCode === 401,
|
||||
);
|
||||
assert.deepStrictEqual(clearedSessions, ["cleared"]);
|
||||
assert.deepStrictEqual(relaunchedRoutes, ["/pages/auth/a01-entry"]);
|
||||
|
||||
respond({ statusCode: 200, data: null });
|
||||
await assert.rejects(
|
||||
@@ -156,6 +175,51 @@ const run = async () => {
|
||||
});
|
||||
assert.deepStrictEqual(savedTokens, ["token-1", "token-password"]);
|
||||
|
||||
// 登录接口不携带既有会话,错误凭据不能借由 401 清理其他页面的本地会话。
|
||||
respond({ statusCode: 200, data: { code: 401, msg: "密码错误", data: null } });
|
||||
await assert.rejects(
|
||||
appApi.loginWithPassword({
|
||||
phone: "13800138000",
|
||||
passwordHash: "a".repeat(32),
|
||||
}),
|
||||
(error) => error.code === "BUSINESS_ERROR" && error.businessCode === 401,
|
||||
);
|
||||
assert.deepStrictEqual(clearedSessions, ["cleared"]);
|
||||
assert.deepStrictEqual(relaunchedRoutes, ["/pages/auth/a01-entry"]);
|
||||
|
||||
respond({
|
||||
statusCode: 200,
|
||||
data: { code: 200, msg: "操作成功", data: { access_token: "token-register" } },
|
||||
});
|
||||
const registration = await appApi.registerWithPassword({
|
||||
phone: "13800138000",
|
||||
nickName: " 联调昵称 ",
|
||||
passwordHash: "a".repeat(32),
|
||||
smsCode: "1234",
|
||||
});
|
||||
assert.strictEqual(registration.access_token, "token-register");
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
clientId: "client-1",
|
||||
tenantId: "000000",
|
||||
grantType: "password",
|
||||
phone: "13800138000",
|
||||
password: "a".repeat(32),
|
||||
smsCode: "1234",
|
||||
nickName: "联调昵称",
|
||||
});
|
||||
|
||||
respond({
|
||||
statusCode: 200,
|
||||
data: { code: 200, msg: "操作成功", data: { access_token: "token-register-empty" } },
|
||||
});
|
||||
await appApi.registerWithPassword({
|
||||
phone: "13800138000",
|
||||
nickName: " ",
|
||||
passwordHash: "a".repeat(32),
|
||||
smsCode: "1234",
|
||||
});
|
||||
assert.strictEqual(Object.hasOwn(requests.at(-1).data, "nickName"), false);
|
||||
|
||||
holdResponse = true;
|
||||
const requestController = createRequestController();
|
||||
const cancelled = appApi.sendSmsCode(
|
||||
@@ -175,6 +239,8 @@ const run = async () => {
|
||||
|
||||
delete globalThis.uni;
|
||||
delete globalThis.__savedTokens;
|
||||
delete globalThis.__clearedSessions;
|
||||
delete globalThis.__relaunchedRoutes;
|
||||
process.stdout.write("AUTH-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ foreach ($token in @('lang="renderjs"', './static/tac/css/tac.css', './static/ta
|
||||
Require-Text -Content $component -Text $token -Label 'TacVerification'
|
||||
}
|
||||
Reject-Text -Content $component -Text 'config.doSendRequest = this.sendStrictRequest' -Label '失去 renderjs 实例上下文的传输函数'
|
||||
$staleGuard = 'if (generation !== this.generation || !this.context || this.context.visible !== true) return;'
|
||||
$staleGuard = 'if (generation !== this.generation || !this.requestContext || this.requestContext.visible !== true) return;'
|
||||
if ([regex]::Matches($component, [regex]::Escape($staleGuard)).Count -lt 2) {
|
||||
throw 'TacVerification 必须在资源加载成功与失败两条分支都拒绝过期代次'
|
||||
}
|
||||
|
||||
@@ -34,8 +34,10 @@ foreach ($page in $pages.pages) {
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'manifest.json') | ConvertFrom-Json
|
||||
if ($manifest.vueVersion -eq '3' -and -not (Test-Path -LiteralPath (Join-Path $root 'index.html'))) {
|
||||
$manifestPath = Join-Path $root 'manifest.json'
|
||||
$manifestVueVersion = & node.exe -e 'process.stdout.write(String(require(process.argv[1]).vueVersion||String()))' $manifestPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'manifest.json 不是有效 JSON' }
|
||||
if ($manifestVueVersion -eq '3' -and -not (Test-Path -LiteralPath (Join-Path $root 'index.html'))) {
|
||||
$errors.Add('Vue 3 project is missing index.html.')
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ $ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Read-Utf8([string]$relativePath) {
|
||||
return Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding UTF8
|
||||
Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding UTF8
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$source, [string]$expected, [string]$page) {
|
||||
@@ -12,39 +12,29 @@ function Assert-Contains([string]$source, [string]$expected, [string]$page) {
|
||||
|
||||
$contracts = [ordered]@{
|
||||
'pages/family/f03-feed-detail.vue' = @(
|
||||
'feedComments', 'commentDraft', 'submitComment', 'feed-state--expired',
|
||||
'genealogyId', 'feedId', 'comment-state--validating', 'comment-state--preview',
|
||||
'findFamilyFeedFixture', 'returnTo("F01", { genealogyId: genealogyId.value })'
|
||||
'feedState', 'appApi.getFeedDetail', 'appApi.getFeedComments', 'appApi.createFeedComment',
|
||||
'feedTypeLabel(feed.type)', 'returnTo("F01", { genealogyId: genealogyId.value })'
|
||||
)
|
||||
'pages/family/f04-article-list.vue' = @(
|
||||
'articleCategories', 'filteredArticles', 'openArticle', 'createArticle',
|
||||
'article-list-state--loading', 'article-list-state--empty', 'article-list-state--error',
|
||||
'listFamilyArticleFixtures', 'openPage(', 'genealogyId: genealogyId.value'
|
||||
'listState', 'appApi.getArticles', 'openArticle', 'createArticle',
|
||||
'openPage("F05"', 'openPage("F06"'
|
||||
)
|
||||
'pages/family/f05-article-detail.vue' = @(
|
||||
'articleParagraphs', 'disabled label=', 'article-state--expired', 'backToArticles',
|
||||
'articleId', 'article-state--privacy', 'article-state--error',
|
||||
'findFamilyArticleFixture', 'returnTo("F04", { genealogyId: genealogyId.value })'
|
||||
'articleState', 'appApi.getArticleDetail', 'article-state--${articleState}',
|
||||
'returnTo("F04", { genealogyId: genealogyId.value })'
|
||||
)
|
||||
'pages/family/f07-album-list.vue' = @(
|
||||
'albums', 'openAlbum', 'createAlbum', 'album-state--empty',
|
||||
'album-list-state--loading', 'album-list-state--error', 'albumNameDraft',
|
||||
'listFamilyAlbumFixtures', 'localAlbumPreview', 'openPage('
|
||||
'listState', 'appApi.getAlbums', 'appApi.createAlbum', 'openAlbum',
|
||||
'pickAndUploadImage', 'openPage("F08"'
|
||||
)
|
||||
}
|
||||
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
$source = Read-Utf8 $entry.Key
|
||||
foreach ($anchor in $entry.Value) { Assert-Contains $source $anchor $entry.Key }
|
||||
foreach ($required in @('ModulePageBackground', 'PageHeader', 'AppButton', 'AppLoading')) {
|
||||
Assert-Contains $source $required $entry.Key
|
||||
foreach ($forbidden in @('listFamilyArticleFixtures', 'findFamilyArticleFixture', 'listFamilyAlbumFixtures', 'localAlbumPreview', 'feed-detail-contract-note', 'data/mock')) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains retired F business-flow implementation: $forbidden" }
|
||||
}
|
||||
foreach ($forbidden in @('import ModulePage from', 'uni.showToast', 'uni.showModal', 'uni.navigateTo', 'uni.redirectTo', 'uni.reLaunch', '/pages/', 'finishPage(')) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains forbidden implementation: $forbidden" }
|
||||
}
|
||||
if ($source -match '<ModulePage(?:\s|/|>)') { throw "$($entry.Key) retains forbidden ModulePage owner" }
|
||||
if ($source -match '(?im)(?<![-\w])position\s*:') { throw "$($entry.Key) ordinary content must not use position" }
|
||||
if ($source -match '(?im)overflow\s*:\s*hidden') { throw "$($entry.Key) must not clip data-driven content" }
|
||||
}
|
||||
|
||||
Write-Output 'F-BUSINESS-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -82,13 +82,10 @@ const run = async () => {
|
||||
await click(send, ".article-card");
|
||||
await waitFor(send, "location.hash.includes('/pages/family/f05-article-detail?genealogyId=1001&articleId=101')", "F04 card did not open composite-identity F05");
|
||||
|
||||
await open(send, "/pages/family/f03-feed-detail?genealogyId=1001&feedId=1", ".feed-comment-form textarea");
|
||||
const before = await valueOf(send, "document.querySelectorAll('.feed-comment-card').length");
|
||||
await setInput(send, ".feed-comment-form textarea", "愿家人岁岁平安,常聚常新。");
|
||||
await click(send, ".feed-comment-form .app-button");
|
||||
await waitFor(send, "Boolean(document.querySelector('.comment-state--preview'))", "F03 did not enter comment preview");
|
||||
if ((await valueOf(send, "document.querySelectorAll('.feed-comment-card').length")) !== before) throw new Error("F03 appended an unsubmitted comment");
|
||||
if ((await valueOf(send, "document.querySelector('.feed-comment-form textarea').value")) !== "愿家人岁岁平安,常聚常新。") throw new Error("F03 cleared the unsubmitted comment draft");
|
||||
await open(send, "/pages/family/f03-feed-detail?genealogyId=1001&feedId=1", ".feed-detail-contract-note");
|
||||
if (!(await valueOf(send, "document.querySelector('.feed-detail-contract-note')?.textContent.includes('不读取宽接口')"))) {
|
||||
throw new Error("F03 did not disclose its contract-safe unavailable state");
|
||||
}
|
||||
|
||||
await open(send, "/pages/family/f07-album-list?genealogyId=1001", ".album-list > .app-button");
|
||||
const albumCount = await valueOf(send, "document.querySelectorAll('.album-card').length");
|
||||
|
||||
@@ -1,61 +1,45 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-Utf8([string]$Path) {
|
||||
[System.IO.File]::ReadAllText(
|
||||
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
}
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding UTF8
|
||||
|
||||
function Assert-Contains {
|
||||
param([string]$Content, [string]$Expected, [string]$Message)
|
||||
if (-not $Content.Contains($Expected)) { throw $Message }
|
||||
function Assert-Contains([string]$expected, [string]$message) {
|
||||
if (-not $page.Contains($expected)) { throw $message }
|
||||
}
|
||||
|
||||
$page = Read-Utf8 'pages/family/f06-article-editor.vue'
|
||||
|
||||
foreach ($expected in @(
|
||||
'class="article-editor-page"',
|
||||
'article-editor-state--${editorState}',
|
||||
'<textarea',
|
||||
'v-model="form.title"',
|
||||
'v-model="form.category"',
|
||||
'v-model="form.content"',
|
||||
'auto-height',
|
||||
'fieldErrors.title',
|
||||
'fieldErrors.category',
|
||||
'fieldErrors.content',
|
||||
'const editorState = ref("form");',
|
||||
'const allowedStates = new Set([',
|
||||
'"draft"',
|
||||
'"loading"',
|
||||
'"validation"',
|
||||
'"error"',
|
||||
'"preview"',
|
||||
'const isSubmitting = ref(false);',
|
||||
'if (isSubmitting.value || !hasValidContext.value) return;',
|
||||
'isSubmitting.value = true;',
|
||||
'editorState.value = submitSnapshot ? "preview" : "error";',
|
||||
':disabled="isSubmitting"',
|
||||
'findFamilyArticleFixture',
|
||||
'appApi.createArticle(',
|
||||
'v-model="form.articleTitle"',
|
||||
'v-model="form.articleSummary"',
|
||||
'v-model="form.articleContent"',
|
||||
'v-model="form.authorName"',
|
||||
'v-model="form.sortOrder"',
|
||||
'pickAndUploadImage',
|
||||
'toConsumerOssId',
|
||||
'coverOssId: coverOssId.value',
|
||||
'class="required-mark"',
|
||||
'editor-control--unavailable',
|
||||
'createDiscardConfirmation',
|
||||
'runBackGuard',
|
||||
'onUnload(() => {',
|
||||
'onUnmounted(() =>',
|
||||
'ModulePageBackground',
|
||||
'AppLoading',
|
||||
'AppDialog',
|
||||
'AppButton',
|
||||
'@include adaptive.adaptive-family-panel;',
|
||||
'@include adaptive.adaptive-family-field;'
|
||||
)) {
|
||||
Assert-Contains $page $expected "F06 dedicated editor contract missing: $expected"
|
||||
Assert-Contains $expected "F06 article editor contract missing: $expected"
|
||||
}
|
||||
|
||||
foreach ($forbidden in @('<template><ModulePage', 'import ModulePage from', 'page-id="f06"', '@/utils/api.js', 'uni.showToast', 'uni.showModal', 'class="editor-panel__skin"', '"saving"', '"success"', 'finishPage(')) {
|
||||
if ($page.Contains($forbidden)) { throw "F06 retains forbidden dependency: $forbidden" }
|
||||
if ($page -match 'v-model="form\.(categoryId|status)"') {
|
||||
throw 'F06 must not ask users to enter category or status codes'
|
||||
}
|
||||
if ($page -match 'placeholder="[^"]*(分类 ID|状态码|例如:0)') {
|
||||
throw 'F06 must not expose raw category or status code inputs'
|
||||
}
|
||||
|
||||
if ($page -match 'border-image-slice\s*:') { throw 'F06 must consume border-image geometry from the adaptive profile owner' }
|
||||
|
||||
if ($page -match 'position\s*:') { throw 'F06 must keep editor content in document flow' }
|
||||
|
||||
Write-Output 'F06-ARTICLE-EDITOR-CONTRACT PASS'
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f06-article-editor.vue') -Raw -Encoding UTF8
|
||||
|
||||
foreach ($token in @(
|
||||
'genealogyId.value = String(query.genealogyId || "");',
|
||||
'articleId.value = String(query.articleId || "");',
|
||||
'editorMode.value = String(query.mode || "");',
|
||||
'(editorMode.value === "create" && !articleId.value)',
|
||||
'(editorMode.value === "edit" && Boolean(article))',
|
||||
'findFamilyArticleFixture(genealogyId.value, articleId.value)',
|
||||
'returnTo("F05", {',
|
||||
'returnTo("F04", { genealogyId: genealogyId.value })'
|
||||
'genealogyId.value = String(query?.genealogyId || "");',
|
||||
'query?.mode !== "create"',
|
||||
'const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));',
|
||||
'returnTo("F04", { genealogyId: genealogyId.value })',
|
||||
'appApi.createArticle(',
|
||||
'createRequestController',
|
||||
'isRequestCancelled'
|
||||
)) {
|
||||
if (-not $page.Contains($token)) { throw "F06 editor context missing: $token" }
|
||||
}
|
||||
foreach ($forbidden in @('simulateSaveFailure', 'form.title.trim() === "保存失败"', 'uni.redirectTo', '/pages/')) {
|
||||
if ($page.Contains($forbidden)) { throw "F06 must not retain a context or navigation bypass: $forbidden" }
|
||||
foreach ($forbidden in @('findFamilyArticleFixture', 'form.category', 'form.status', 'uni.redirectTo', '/pages/')) {
|
||||
if ($page.Contains($forbidden)) { throw "F06 must not retain a context, code-input, or navigation bypass: $forbidden" }
|
||||
}
|
||||
Write-Output 'F06-EDITOR-CONTEXT-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"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_TAC_SCENE = 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 = [];
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: {} } }));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
|
||||
await appApi.createFeed("1001", {
|
||||
feedType: " photo ",
|
||||
feedContent: " family memory ",
|
||||
mediaOssIds: "2060000000000000001,2060000000000000002",
|
||||
sortOrder: "0",
|
||||
status: " 0 ",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
feedType: "photo",
|
||||
feedContent: "family memory",
|
||||
mediaOssIds: "2060000000000000001,2060000000000000002",
|
||||
sortOrder: 0,
|
||||
status: "0",
|
||||
});
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/feeds");
|
||||
|
||||
await appApi.createFeed("1001", { feedContent: "only required" });
|
||||
assert.deepStrictEqual(requests.at(-1).data, { feedContent: "only required" });
|
||||
await assert.rejects(
|
||||
appApi.createFeed("1001", { feedContent: "bad media", mediaOssIds: "1, 2" }),
|
||||
/mediaOssIds/,
|
||||
);
|
||||
|
||||
await appApi.createArticle("1001", {
|
||||
categoryId: "900040001",
|
||||
articleTitle: " title ",
|
||||
articleSummary: " summary ",
|
||||
coverOssId: 900001,
|
||||
articleContent: " body ",
|
||||
authorName: " author ",
|
||||
sortOrder: "1",
|
||||
status: " 0 ",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
categoryId: 900040001,
|
||||
articleTitle: "title",
|
||||
articleSummary: "summary",
|
||||
coverOssId: 900001,
|
||||
articleContent: "body",
|
||||
authorName: "author",
|
||||
sortOrder: 1,
|
||||
status: "0",
|
||||
});
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/articles");
|
||||
|
||||
await appApi.createAlbum("1001", {
|
||||
albumName: " old photos ",
|
||||
albumDesc: " family album ",
|
||||
coverOssId: 900001,
|
||||
sortOrder: "1",
|
||||
status: " 0 ",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
albumName: "old photos",
|
||||
albumDesc: "family album",
|
||||
coverOssId: 900001,
|
||||
sortOrder: 1,
|
||||
status: "0",
|
||||
});
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/1001/albums");
|
||||
await assert.rejects(
|
||||
appApi.createAlbum("1001", { albumName: "bad id", coverOssId: "9007199254740992" }),
|
||||
/coverOssId/,
|
||||
);
|
||||
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("FAMILY-CREATE-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
function Read-Utf8([string]$relativePath) {
|
||||
Get-Content -Raw -Encoding UTF8 (Join-Path $root $relativePath)
|
||||
}
|
||||
function Assert-Contains([string]$text, [string]$token, [string]$message) {
|
||||
if (-not $text.Contains($token)) { throw $message }
|
||||
}
|
||||
|
||||
$f02 = Read-Utf8 'pages/family/f02-publish-feed.vue'
|
||||
foreach ($token in @('form.feedType', 'mediaReceipts', 'mediaOssIds', 'form.sortOrder', 'pickAndUploadImage', 'class="required-mark"')) {
|
||||
Assert-Contains $f02 $token "F02 is missing full feed field owner: $token"
|
||||
}
|
||||
|
||||
$f06 = Read-Utf8 'pages/family/f06-article-editor.vue'
|
||||
foreach ($token in @('form.articleSummary', 'coverOssId', 'form.authorName', 'form.sortOrder', 'pickAndUploadImage', 'toConsumerOssId', 'class="required-mark"')) {
|
||||
Assert-Contains $f06 $token "F06 is missing full article field owner: $token"
|
||||
}
|
||||
if ($f06 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F06 must not expose a raw cover OSS ID input' }
|
||||
|
||||
$f07 = Read-Utf8 'pages/family/f07-album-list.vue'
|
||||
foreach ($token in @('form.albumDesc', 'coverOssId', 'form.sortOrder', 'pickAndUploadImage', 'toConsumerOssId', 'class="required-mark"')) {
|
||||
Assert-Contains $f07 $token "F07 is missing full album field owner: $token"
|
||||
}
|
||||
if ($f07 -match 'v-model="(?:form\.)?coverOssId"') { throw 'F07 must not expose a raw cover OSS ID input' }
|
||||
|
||||
foreach ($page in @($f02, $f06, $f07)) {
|
||||
if ($page -match 'v-model="form\.(status|categoryId)"') { throw 'Family create forms must not ask users to enter status or category codes' }
|
||||
if ($page -match 'placeholder="[^"]*(分类 ID|状态码|例如:0)') { throw 'Family create forms must not expose raw code placeholders' }
|
||||
}
|
||||
|
||||
$fullWidthOptionalLabel = [string]([char]0xff08) + [char]0x9009 + [char]0x586b + [char]0xff09
|
||||
$asciiOptionalLabel = '(' + [char]0x9009 + [char]0x586b + ')'
|
||||
foreach ($page in @($f02, $f06, $f07)) {
|
||||
if ($page.Contains($fullWidthOptionalLabel) -or $page.Contains($asciiOptionalLabel)) { throw 'Family create forms must not label optional fields as optional' }
|
||||
}
|
||||
|
||||
Write-Output 'FAMILY-CREATE-PAGES-CONTRACT PASS'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
"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_TAC_SCENE = 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 response;
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success(response));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
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 { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
const basePayload = {
|
||||
uploadId: "upload-1",
|
||||
fileName: "avatar.png",
|
||||
fileMd5: "a".repeat(32),
|
||||
totalSize: 1024,
|
||||
totalChunks: 1,
|
||||
chunkSize: 1024,
|
||||
contentType: "image/png",
|
||||
};
|
||||
const completePayload = {
|
||||
uploadId: basePayload.uploadId,
|
||||
fileName: basePayload.fileName,
|
||||
fileMd5: basePayload.fileMd5,
|
||||
totalSize: basePayload.totalSize,
|
||||
totalChunks: basePayload.totalChunks,
|
||||
};
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: { uploadId: "upload-1", instant: true, ossId: 900001 } } };
|
||||
assert.deepStrictEqual(await appApi.initializeResumableUpload(basePayload), {
|
||||
uploadId: "upload-1",
|
||||
instant: true,
|
||||
ossId: "900001",
|
||||
url: "",
|
||||
fileName: "",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, basePayload);
|
||||
assert.strictEqual(requests.at(-1).url, "https://backend-api.ddxcjp.cn/genealogy/app/files/resumable/init");
|
||||
|
||||
await appApi.uploadResumableChunk({
|
||||
uploadId: "upload-1",
|
||||
chunkIndex: 0,
|
||||
chunkMd5: "b".repeat(32),
|
||||
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" } }]);
|
||||
|
||||
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), {
|
||||
ossId: "900001",
|
||||
url: "https://oss.example/avatar.png",
|
||||
thumbnailUrl: "",
|
||||
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/,
|
||||
);
|
||||
await assert.rejects(
|
||||
appApi.uploadResumableChunk({ uploadId: "upload-1", chunkIndex: -1, chunkMd5: "b".repeat(32), filePath: "/x" }),
|
||||
/chunkIndex/,
|
||||
);
|
||||
|
||||
delete globalThis.plus;
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("FILE-UPLOAD-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('sexOptions', 'mode="selector"', 'mode="date"', 'ancestorForm.sex', 'auto-height')) {
|
||||
if (-not $page.Contains($token)) { throw "G03 ancestor semantic field missing: $token" }
|
||||
foreach ($token in @('sexOptions', 'mode="selector"', 'mode="date"', 'ancestorForm', 'createLocalGenealogyPreview')) {
|
||||
if ($page.Contains($token)) { throw "G03 must not retain uncommitted first-ancestor flow: $token" }
|
||||
}
|
||||
if (-not $page.Contains('class="create-card__note"')) { throw 'G03 must retain a create-result guidance note' }
|
||||
Write-Output 'G03-ANCESTOR-SEMANTIC-FIELDS-CONTRACT PASS'
|
||||
|
||||
@@ -13,48 +13,49 @@ function Read-RequiredFile {
|
||||
return Get-Content -Raw -Encoding UTF8 -LiteralPath $path
|
||||
}
|
||||
|
||||
function Assert-Contains {
|
||||
param([string]$Content, [string]$Label, [string]$Expected)
|
||||
if (-not $Content.Contains($Expected)) {
|
||||
$script:issues.Add("$Label missing: $Expected")
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-DoesNotContain {
|
||||
param([string]$Content, [string]$Label, [string]$Forbidden)
|
||||
if ($Content.Contains($Forbidden)) {
|
||||
$script:issues.Add("$Label must not claim unsupported remote ownership: $Forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
$page = Read-RequiredFile 'pages/genealogy/g03-create-genealogy.vue'
|
||||
$api = Read-RequiredFile 'utils/api.js'
|
||||
$flowContract = Read-RequiredFile 'tests/g03-create-flow-contract.ps1'
|
||||
$flowRuntime = Read-RequiredFile 'tests/g03-create-flow-runtime-smoke.js'
|
||||
|
||||
# Apifox only declares two independent writes. There is no recoverable atomic
|
||||
# bootstrap or result-query operation, so G03 remains an explicit local preview.
|
||||
Assert-Contains $page 'G03 page' 'flow-success-dialog__copy'
|
||||
Assert-Contains $page 'G03 page' 'createLocalGenealogyPreview'
|
||||
Assert-Contains $page 'G03 page' 'updateLocalGenealogyPreviewAncestor'
|
||||
Assert-Contains $page 'G03 page' 'removeLocalGenealogyPreview'
|
||||
Assert-Contains $flowContract 'G03 flow contract' 'createLocalGenealogyPreview'
|
||||
Assert-Contains $flowRuntime 'G03 runtime smoke' 'local-created-'
|
||||
|
||||
foreach ($forbidden in @(
|
||||
'createGenealogyBootstrapCoordinator',
|
||||
'genealogy-bootstrap-operations',
|
||||
'Idempotency-Key',
|
||||
'/genealogy/app/region/search',
|
||||
'requestStrict(',
|
||||
'appApi.'
|
||||
foreach ($required in @(
|
||||
'appApi.createGenealogy',
|
||||
'finishPage("G01", {}, {',
|
||||
'entityId: created.id',
|
||||
'requestController: createController'
|
||||
)) {
|
||||
Assert-DoesNotContain $page 'G03 page' $forbidden
|
||||
if (-not $page.Contains($required)) { $issues.Add("G03 real create page missing: $required") }
|
||||
}
|
||||
foreach ($required in @(
|
||||
"url: '/genealogy/app/genealogies'",
|
||||
'normalizeCreatedGenealogy',
|
||||
'requestStrict({'
|
||||
)) {
|
||||
if (-not $api.Contains($required)) { $issues.Add("G03 real create API missing: $required") }
|
||||
}
|
||||
foreach ($required in @(
|
||||
'G03-CREATE-FLOW-CONTRACT PASS',
|
||||
'G03-CREATE-FLOW-RUNTIME-SMOKE PASS'
|
||||
)) {
|
||||
$source = if ($required -like '*RUNTIME*') { $flowRuntime } else { $flowContract }
|
||||
if (-not $source.Contains($required)) { $issues.Add("G03 verification missing: $required") }
|
||||
}
|
||||
foreach ($forbidden in @(
|
||||
'createLocalGenealogyPreview',
|
||||
'updateLocalGenealogyPreview',
|
||||
'removeLocalGenealogyPreview',
|
||||
'local-created-',
|
||||
'setTimeout(',
|
||||
'genealogies.unshift(created)'
|
||||
)) {
|
||||
if ($page.Contains($forbidden) -or $api.Contains($forbidden)) {
|
||||
$issues.Add("G03 must not retain a local preview path: $forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
Write-Output 'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED'
|
||||
foreach ($issue in $issues) { Write-Output "- $issue" }
|
||||
Write-Output '- Keep the two-step visual preview local until Apifox supplies a recoverable create/result contract with a stable lexical genealogyId.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"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 () => {
|
||||
// 该测试只替身网络层:验证真实创建路径发出的 wire,不产生远端写入。
|
||||
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_TAC_SCENE = 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 = [];
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: { genealogyId: "1001" } } }));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
await appApi.createGenealogy({
|
||||
genealogyName: "王氏家谱",
|
||||
surname: "王",
|
||||
regionCode: "110101",
|
||||
});
|
||||
|
||||
const request = requests.at(-1);
|
||||
assert.strictEqual(request.method, "POST");
|
||||
assert.strictEqual(request.url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies");
|
||||
assert.deepStrictEqual(request.header, {
|
||||
clientid: "client-1",
|
||||
tenantId: "000000",
|
||||
Authorization: "Bearer session-1",
|
||||
});
|
||||
assert.deepStrictEqual(request.data, {
|
||||
genealogyName: "王氏家谱",
|
||||
surname: "王",
|
||||
regionCode: "110101",
|
||||
});
|
||||
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("G03-CREATE-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,84 +1,76 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-Contains {
|
||||
param([string]$Content, [string]$Expected, [string]$Message)
|
||||
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
$pattern = "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:"
|
||||
if ($Content -match $pattern) { throw "G03 must not construct .$ClassName with CSS $property" }
|
||||
}
|
||||
}
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding utf8 | ConvertFrom-Json
|
||||
$paths = @($pages.pages.path)
|
||||
$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
|
||||
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
|
||||
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding utf8
|
||||
$g04 = Join-Path $root 'pages/genealogy/g04-first-ancestor.vue'
|
||||
$panelPath = Join-Path $root 'static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png'
|
||||
|
||||
if (-not ($paths -contains 'pages/genealogy/g03-create-genealogy')) { throw 'G03 route missing' }
|
||||
if ($paths -contains 'pages/genealogy/g04-first-ancestor') { throw 'G04 route must be removed; first-ancestor is a G03 state' }
|
||||
if (Test-Path -LiteralPath $g04) { throw 'G04 page file must be deleted; first-ancestor is a G03 state' }
|
||||
if ($paths -contains 'pages/genealogy/g04-first-ancestor') { throw 'G04 route must remain removed' }
|
||||
if (Test-Path -LiteralPath $g04) { throw 'G04 page file must remain removed' }
|
||||
|
||||
foreach ($required in @(
|
||||
'const currentStep = ref("create");',
|
||||
'const createState = ref("form");',
|
||||
'const ancestorState = ref("form");',
|
||||
'const fieldErrors = reactive({',
|
||||
'const duplicateReminderVisible = ref(false);',
|
||||
'class="duplicate-reminder-layer"',
|
||||
'if (isSubmitting.value) return;',
|
||||
'adaptive.adaptive-genealogy-state-panel',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'create-flow-panel',
|
||||
'flow-primary-action',
|
||||
'import PageHeader from "@/components/PageHeader.vue";',
|
||||
'<PageHeader',
|
||||
'custom-back',
|
||||
'@back="requestBack"'
|
||||
'v-model="form.surname"',
|
||||
'v-model="form.genealogyName"',
|
||||
'getRegionChildren',
|
||||
'selectedRegion',
|
||||
'regionPickerOpen',
|
||||
'picker-view',
|
||||
'picker-view-column',
|
||||
'handleRegionPickerChange',
|
||||
'regionPickerIndicatorStyle',
|
||||
'region-sheet__picker-view',
|
||||
'confirmRegionSelection',
|
||||
'regionPickerColumns',
|
||||
'v-model="form.originPlace"',
|
||||
'v-model="form.addressDetail"',
|
||||
'v-model="form.intro"',
|
||||
'pickAndUploadImage',
|
||||
'toConsumerOssId',
|
||||
'coverOssId.value',
|
||||
'GENEALOGY_ACCESS_PRESET_OPTIONS',
|
||||
'toApiGenealogyAccess',
|
||||
'appApi.createGenealogy',
|
||||
'finishPage("G01", {}, {',
|
||||
'operation: "genealogy-created"',
|
||||
'createRequestController()',
|
||||
'createController.abort()'
|
||||
)) {
|
||||
Assert-Contains -Content $g03 -Expected $required -Message "Missing G03 flow contract: $required"
|
||||
if (-not $g03.Contains($required)) { throw "G03 real-create contract missing: $required" }
|
||||
}
|
||||
foreach ($required in @(
|
||||
'GENEALOGY_ACCESS_PRESET',
|
||||
if ($g03 -match 'v-model="(?:form\.)?regionCode"') { throw 'G03 must not expose a raw region code input' }
|
||||
if ($g03 -match 'v-model="(?:form\.)?coverOssId"') { throw 'G03 must not expose a raw cover OSS ID input' }
|
||||
if ($g03.Contains('AppDialog')) { throw 'G03 region selector must not use the generic dialog card wall' }
|
||||
if ($g03.Contains('继续选择')) { throw 'G03 region selector must not repeat a continuation label on every option' }
|
||||
|
||||
foreach ($forbidden in @(
|
||||
'createLocalGenealogyPreview',
|
||||
'updateLocalGenealogyPreview',
|
||||
'updateLocalGenealogyPreviewAncestor'
|
||||
'removeLocalGenealogyPreview'
|
||||
'removeLocalGenealogyPreview',
|
||||
'local-created-',
|
||||
'create-flow-panel',
|
||||
'flow-success-dialog',
|
||||
'录入首代人物',
|
||||
'Date.now()',
|
||||
'goRegionParent',
|
||||
'region-picker__back',
|
||||
'region-picker-sheet'
|
||||
)) {
|
||||
Assert-Contains -Content $g03 -Expected $required -Message "G03 missing shared genealogy contract: $required"
|
||||
}
|
||||
foreach ($forbidden in @('GENEALOGY_VISIBILITY', 'createForm.visibility', 'SEARCHABLE')) {
|
||||
if ($g03.Contains($forbidden)) { throw "G03 must not retain the old or parallel access contract: $forbidden" }
|
||||
}
|
||||
Assert-Contains -Content $profiles -Expected 'g01-empty-panel-frame.png' -Message 'Adaptive genealogy panel profile must own the G03 panel asset'
|
||||
|
||||
foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet', '/pages/tree/t01-tree-overview?genealogyId=', 'class="flow-header"', 'flow-header__back', 'root-header-cinnabar.jpg')) {
|
||||
if ($g03 -match [regex]::Escape($forbidden)) { throw "G03 retains forbidden implementation: $forbidden" }
|
||||
if ($g03.Contains($forbidden)) { throw "G03 must not retain speculative flow code: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($className in @('create-flow-panel', 'flow-primary-action')) {
|
||||
Assert-NoCssSurface -Content $g03 -ClassName $className
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $panelPath)) { throw 'G03 requires the accepted genealogy paper panel bitmap' }
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$panel = [System.Drawing.Bitmap]::FromFile($panelPath)
|
||||
try {
|
||||
$corners = @(
|
||||
$panel.GetPixel(0, 0).A,
|
||||
$panel.GetPixel(($panel.Width - 1), 0).A,
|
||||
$panel.GetPixel(0, ($panel.Height - 1)).A,
|
||||
$panel.GetPixel(($panel.Width - 1), ($panel.Height - 1)).A
|
||||
)
|
||||
if (($corners | Where-Object { $_ -ne 0 }).Count -ne 0) { throw 'G03 paper panel outer corners must remain transparent' }
|
||||
} finally {
|
||||
$panel.Dispose()
|
||||
foreach ($required in @(
|
||||
"url: '/genealogy/app/genealogies'",
|
||||
"method: 'POST'",
|
||||
'normalizeGenealogyCreatePayload',
|
||||
'normalizeCreatedGenealogy',
|
||||
'coverOssId',
|
||||
'REMOTE_WRITE_REQUIRED'
|
||||
)) {
|
||||
if (-not $api.Contains($required)) { throw "G03 API contract missing: $required" }
|
||||
}
|
||||
if ($api.Contains('genealogies.unshift(created)')) { throw 'G03 API must not create a local preview record' }
|
||||
|
||||
Write-Output 'G03-CREATE-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -1,179 +1,97 @@
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
|
||||
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
|
||||
const pages = await (await fetch("http://127.0.0.1:9222/json/list")).json();
|
||||
const page = pages.find((item) => item.type === "page" && item.url.startsWith("http://localhost:5173"));
|
||||
if (!page) throw new Error("Chrome debugging has no localhost:5173 page");
|
||||
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl)
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true })
|
||||
socket.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
const exceptions = []
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text)
|
||||
const request = pending.get(message.id)
|
||||
if (!request) return
|
||||
pending.delete(message.id)
|
||||
if (message.error) request.reject(new Error(message.error.message))
|
||||
else request.resolve(message.result)
|
||||
})
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const exceptions = [];
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.method === "Runtime.exceptionThrown") exceptions.push(message.params.exceptionDetails.text);
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error) request.reject(new Error(message.error.message));
|
||||
else request.resolve(message.result);
|
||||
});
|
||||
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1
|
||||
pending.set(id, { resolve, reject })
|
||||
socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
|
||||
return { socket, send, exceptions }
|
||||
}
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
return { socket, send, exceptions };
|
||||
};
|
||||
|
||||
const valueOf = async (send, expression) => {
|
||||
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
|
||||
return result.result?.value
|
||||
}
|
||||
const result = await send("Runtime.evaluate", { expression, returnByValue: true });
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
||||
return result.result?.value;
|
||||
};
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (await valueOf(send, expression)) return
|
||||
await sleep(100)
|
||||
if (await valueOf(send, expression)) return;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const setInput = async (send, index, value) => {
|
||||
await waitFor(
|
||||
send,
|
||||
`Boolean(document.querySelectorAll('.flow-fields input')[${index}])`,
|
||||
`G03 input ${index} did not render`
|
||||
)
|
||||
const expression = `(() => {
|
||||
const input = document.querySelectorAll('.flow-fields input')[${index}]
|
||||
if (!input) return false
|
||||
input.value = ${JSON.stringify(value)}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
})()`
|
||||
if (!await valueOf(send, expression)) throw new Error(`Could not fill G03 input ${index}`)
|
||||
}
|
||||
const origin = process.argv[2] || "http://localhost:5173";
|
||||
|
||||
const origin = process.argv[2] || 'http://localhost:5173'
|
||||
const g03Path = '/pages/genealogy/g03-create-genealogy'
|
||||
let navigationId = 0
|
||||
|
||||
const openG03 = async (send, query = '') => {
|
||||
navigationId += 1
|
||||
const url = `${origin}/?g03Audit=${navigationId}#${g03Path}${query}`
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `G03 navigation failed: ${query || 'default'}`)
|
||||
}
|
||||
|
||||
const openDefaultG03 = async (send) => {
|
||||
await openG03(send)
|
||||
await waitFor(send, "Boolean(document.querySelector('.create-flow-panel .flow-rule'))", 'Default G03 creation step did not render')
|
||||
}
|
||||
|
||||
const advanceToAncestor = async (send, name = '自动验证汤氏家谱') => {
|
||||
await setInput(send, 0, '汤')
|
||||
await setInput(send, 1, name)
|
||||
await setInput(send, 2, '敦本堂')
|
||||
await setInput(send, 3, '河南·洛阳')
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.duplicate-reminder-layer'))", 'G03 did not show the duplicate reminder')
|
||||
await valueOf(send, `(() => {
|
||||
window.__g03SubmitTimerCount = 0
|
||||
window.__g03OriginalSetTimeout = window.setTimeout
|
||||
window.setTimeout = (callback, delay, ...args) => {
|
||||
if (delay === 320) window.__g03SubmitTimerCount += 1
|
||||
return window.__g03OriginalSetTimeout(callback, delay, ...args)
|
||||
}
|
||||
return true
|
||||
})()`)
|
||||
await valueOf(send, "(() => { const action = document.querySelector('.duplicate-reminder__confirm'); action.click(); action.click(); return true })()")
|
||||
await setInput(send, 1, '失败')
|
||||
await waitFor(send, "Boolean(document.querySelector('.intro-field'))", 'G03 did not advance to the same-page ancestor step')
|
||||
const timerCount = await valueOf(send, 'window.__g03SubmitTimerCount')
|
||||
await valueOf(send, 'window.setTimeout = window.__g03OriginalSetTimeout; delete window.__g03OriginalSetTimeout')
|
||||
if (timerCount !== 1) throw new Error(`G03 duplicate confirmation created ${timerCount} submit timers`)
|
||||
if (await valueOf(send, "location.hash.includes('step=ancestor')")) {
|
||||
throw new Error('G03 leaked its internal ancestor step into the route')
|
||||
}
|
||||
}
|
||||
const openG03 = async (send, suffix = "") => {
|
||||
const url = `${origin}/?g03CreateAudit=${Date.now()}#${"/pages/genealogy/g03-create-genealogy"}${suffix}`;
|
||||
await send("Page.navigate", { url });
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, "G03 navigation failed");
|
||||
await waitFor(send, "Boolean(document.querySelector('.create-card'))", "G03 create form did not render");
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send, exceptions } = await connect()
|
||||
|
||||
const { socket, send, exceptions } = await connect();
|
||||
try {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
await openG03(send);
|
||||
|
||||
// 步骤一:空表单、创建失败和短暂提交态都由真实控件触发,避免旧截图助手伪装成功能测试。
|
||||
await openDefaultG03(send)
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await waitFor(send, "document.querySelectorAll('.field-error').length === 3", 'G03 create step did not report all required fields')
|
||||
await setInput(send, 0, '汤')
|
||||
await setInput(send, 1, '失败')
|
||||
await setInput(send, 3, '河南·洛阳')
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.duplicate-reminder-layer'))", 'G03 failure case did not show the duplicate reminder')
|
||||
await valueOf(send, "document.querySelector('.duplicate-reminder__confirm').click()")
|
||||
await waitFor(send, "document.querySelector('.flow-primary-action__copy')?.textContent.includes('正在创建')", 'G03 create step did not expose its submitting state')
|
||||
await waitFor(send, "Boolean(document.querySelector('.flow-error'))", 'G03 simulated create failure did not render')
|
||||
|
||||
// 步骤二:只允许从同页第一步进入首代人物,重复确认不能产生第二个提交定时器。
|
||||
await openDefaultG03(send)
|
||||
await advanceToAncestor(send, '首代失败验证家谱')
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.field-error'))", 'G03 ancestor step did not validate the required name')
|
||||
await setInput(send, 0, '失败')
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await setInput(send, 0, '提交后改名')
|
||||
await waitFor(send, "document.querySelector('.flow-primary-action__copy')?.textContent.includes('正在保存')", 'G03 ancestor step did not expose its submitting state')
|
||||
await waitFor(send, "Boolean(document.querySelector('.flow-error'))", 'G03 simulated ancestor failure did not render')
|
||||
|
||||
// 步骤三:完整成功路径仍需从立谱、重复提醒、首代保存一路进入家谱总览。
|
||||
await openDefaultG03(send)
|
||||
await advanceToAncestor(send)
|
||||
await setInput(send, 0, '汤始祖')
|
||||
await sleep(100)
|
||||
await valueOf(send, "document.querySelector('.flow-primary-action')?.click()")
|
||||
await setInput(send, 0, '失败')
|
||||
await waitFor(send, "Boolean(document.querySelector('.flow-success-layer'))", 'Saving the first ancestor did not show the custom success result')
|
||||
await valueOf(send, "document.querySelector('.flow-success-dialog__action').click()")
|
||||
await waitFor(send, "location.href.includes('/pages/genealogy/g05-genealogy-overview?genealogyId=local-created-')", 'Saving the first ancestor did not open a unique local G05 preview')
|
||||
await waitFor(send, "Boolean(document.querySelector('.overview-public'))", 'G03 local preview did not render in G05')
|
||||
const previewText = await valueOf(send, "document.querySelector('.overview-public')?.textContent")
|
||||
for (const expected of ['自动验证汤氏家谱', '汤氏', '敦本堂', '河南·洛阳', '汤始祖', '仅成员可见']) {
|
||||
if (!previewText.includes(expected)) throw new Error(`G03 local preview lost submitted field: ${expected}`)
|
||||
}
|
||||
|
||||
await openDefaultG03(send)
|
||||
if (await valueOf(send, "Boolean(document.querySelector('.intro-field'))")) {
|
||||
throw new Error('Default G03 must not render the ancestor step')
|
||||
const text = await valueOf(send, "document.querySelector('.create-card')?.textContent || ''");
|
||||
for (const required of ["立谱信息", "所在地区", "确认创建家谱"]) {
|
||||
if (!text.includes(required)) throw new Error(`G03 create form missing: ${required}`);
|
||||
}
|
||||
const inputCount = await valueOf(send, "document.querySelectorAll('.create-card input').length");
|
||||
if (inputCount !== 5) throw new Error(`G03 expected five text inputs plus region and upload selectors, got ${inputCount}`);
|
||||
const regionSelector = await valueOf(send, "Boolean(document.querySelector('.field-row--selector'))");
|
||||
if (!regionSelector) throw new Error("G03 region selector did not render");
|
||||
const uploadControl = await valueOf(send, "Boolean(document.querySelector('.upload-button'))");
|
||||
if (!uploadControl) throw new Error("G03 cover upload control did not render");
|
||||
const introControl = await valueOf(send, "Boolean(document.querySelector('.create-card textarea'))");
|
||||
if (!introControl) throw new Error("G03 optional intro field did not render");
|
||||
const fakeControl = await valueOf(send, "Boolean(document.querySelector('.flow-success-dialog'))");
|
||||
if (fakeControl) throw new Error("G03 must not expose the retired local bootstrap flow");
|
||||
|
||||
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
|
||||
await openDefaultG03(send)
|
||||
const width = await valueOf(send, 'document.documentElement.scrollWidth')
|
||||
if (width > size.width + 1) throw new Error(`G03 has horizontal overflow at ${size.width}x${size.height}`)
|
||||
await send("Emulation.setDeviceMetricsOverride", { ...size, deviceScaleFactor: 1, mobile: true });
|
||||
await openG03(send, `&width=${size.width}`);
|
||||
const scrollWidth = await valueOf(send, "document.documentElement.scrollWidth");
|
||||
if (scrollWidth > size.width + 1) throw new Error(`G03 create form has horizontal overflow at ${size.width}x${size.height}`);
|
||||
}
|
||||
if (exceptions.length) throw new Error(`G03 raised browser exceptions: ${exceptions.join('; ')}`)
|
||||
|
||||
process.stdout.write('G03-CREATE-FLOW-RUNTIME-SMOKE PASS\n')
|
||||
if (exceptions.length) throw new Error(`G03 raised browser exceptions: ${exceptions.join("; ")}`);
|
||||
process.stdout.write("G03-CREATE-FLOW-RUNTIME-SMOKE PASS\n");
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
try { await send("Emulation.clearDeviceMetricsOverride"); } catch (_) {}
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
|
||||
$style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 1 -or $positions[0].Groups[1].Value.Trim() -ne 'fixed') {
|
||||
throw "G03 must retain only its combined fixed dialog layer rule; found $($positions.Count) position declarations"
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'@include adaptive.adaptive-genealogy-state-panel;',
|
||||
'background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G03 is missing real asset background: $required" } }
|
||||
foreach ($forbidden in @('flow-header', 'root-header-cinnabar.jpg')) {
|
||||
if ($page -match [regex]::Escape($forbidden)) { throw "G03 must not retain private header token: $forbidden" }
|
||||
'.create-card',
|
||||
'.field-row',
|
||||
'.region-sheet',
|
||||
'.access-rule__option--active',
|
||||
'.create-card .app-button'
|
||||
)) {
|
||||
if ($page -notmatch [regex]::Escape($required)) { throw "G03 create form missing: $required" }
|
||||
}
|
||||
foreach ($forbidden in @('flow-header', 'root-header-cinnabar.jpg', 'duplicate-reminder', 'flow-success-dialog')) {
|
||||
if ($page -match [regex]::Escape($forbidden)) { throw "G03 must not retain retired flow token: $forbidden" }
|
||||
}
|
||||
Write-Output 'G03-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -8,16 +8,12 @@ function Assert-Match {
|
||||
if ($Content -notmatch $Pattern) { throw $Message }
|
||||
}
|
||||
|
||||
if ($g03 -match '<text>返回</text>') { throw 'G03 header must use only the approved image back arrow' }
|
||||
Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+:title="isAncestorStep \? ''\u5F55\u5165\u9996\u4EE3\u4EBA\u7269'' : ''\u521B\u5EFA\u5BB6\u8C31''"\s+custom-back\s+@back="requestBack"\s*/>' -Message 'G03 must use PageHeader with its dynamic title and guarded back handler'
|
||||
if ($g03 -match '<text>返回</text>') { throw 'G03 header must use the shared PageHeader back control' }
|
||||
Assert-Match -Content $g03 -Pattern '(?s)<PageHeader\s+title="[^"]+"\s+custom-back\s+@back="backToGenealogies"\s*/>' -Message 'G03 must use PageHeader with its guarded back handler'
|
||||
if ($g03 -match 'flow-header|flow-header__back|flow-header__title') { throw 'G03 must not retain a private header implementation' }
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-note,\s*\.flow-rule__note\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 guidance copy must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-rule__option\s*\{[^}]*font-size:\s*23rpx;' -Message 'G03 visibility options must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;' -Message 'G03 field errors must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.flow-error\s*\{[^}]*font-size:\s*25rpx;[^}]*line-height:\s*36rpx;' -Message 'G03 submit errors must use the approved readable size'
|
||||
|
||||
foreach ($asset in @('a01-scroll-dialog-v3.png', 'a01-scroll-primary-v3.png', 'a01-scroll-secondary-v3.png')) {
|
||||
if ($g03 -notmatch [regex]::Escape($asset)) { throw "G03 must retain approved shared asset: $asset" }
|
||||
}
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.create-card__note\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 guidance copy must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.access-rule__option\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 visibility options must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.field-error,\s*\.submit-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*1\.5;' -Message 'G03 validation errors must use the approved readable size'
|
||||
Assert-Match -Content $g03 -Pattern '(?s)\.create-card\s*\.app-button\s*\{[^}]*margin-top:\s*32rpx;' -Message 'G03 primary action spacing must remain stable'
|
||||
|
||||
Write-Output 'G03-VISUAL-STATES-CONTRACT PASS'
|
||||
|
||||
@@ -2,11 +2,13 @@ $ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$search = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g06-search-genealogies.vue') -Raw -Encoding UTF8
|
||||
$join = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding UTF8
|
||||
foreach ($token in @('genealogyName:', 'previous:', 'encodeURIComponent(item.name)')) {
|
||||
if ($search.Contains($token)) { throw "G06 must not propagate retired context: $token" }
|
||||
foreach ($required in @('genealogyId: item.id', 'genealogyName: item.name')) {
|
||||
if (-not $search.Contains($required)) { throw "G06 must pass application context: $required" }
|
||||
}
|
||||
foreach ($token in @('previousRelation', 'query.genealogyName', 'query.previous', 'decodeURIComponent', 'genealogyPreview')) {
|
||||
if ($join.Contains($token)) { throw "G08 must not consume retired context: $token" }
|
||||
foreach ($required in @('query?.genealogyId', 'query?.genealogyName', 'appApi.applyToJoin')) {
|
||||
if (-not $join.Contains($required)) { throw "G08 must consume real application context: $required" }
|
||||
}
|
||||
foreach ($retired in @('previousRelation', 'query.previous', 'decodeURIComponent', 'findGenealogyFixture', 'genealogyPreview')) {
|
||||
if ($join.Contains($retired)) { throw "G08 must not consume retired context: $retired" }
|
||||
}
|
||||
if (-not $join.Contains('findGenealogyFixture')) { throw 'G08 must read display identity from the shared genealogy fixture owner' }
|
||||
Write-Output 'G06-G08-CONTEXT-CONTRACT PASS'
|
||||
|
||||
@@ -5,10 +5,14 @@ $style = [regex]::Match($page, '(?s)<style[^>]*>(.*?)</style>').Groups[1].Value
|
||||
$positions = [regex]::Matches([regex]::Replace($style, '(?s)/\*.*?\*/', ''), '(?m)\bposition\s*:\s*([^;]+);')
|
||||
if ($positions.Count -ne 0) { throw "G08 join flow must use document flow; found $($positions.Count) position declarations" }
|
||||
foreach ($required in @(
|
||||
'<GenealogyPageBackground />',
|
||||
'@include adaptive.adaptive-genealogy-state-panel;',
|
||||
'@include adaptive.adaptive-genealogy-form-field;',
|
||||
'background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G08 is missing real asset background: $required" } }
|
||||
if ($page -match 'class="join-panel__skin"') { throw 'G08 must not retain the decorative panel image layer' }
|
||||
if ($page -notmatch '(?s)<textarea[^>]*auto-height') { throw 'G08 application message must grow from its text data' }
|
||||
'appApi.applyToJoin',
|
||||
'createRequestController',
|
||||
'auto-height'
|
||||
)) { if ($page -notmatch [regex]::Escape($required)) { throw "G08 is missing: $required" } }
|
||||
foreach ($retired in @('findGenealogyFixture', 'join-panel__skin', 'uni.showToast', 'uni.showModal')) {
|
||||
if ($page -match [regex]::Escape($retired)) { throw "G08 must not retain retired local-preview owner: $retired" }
|
||||
}
|
||||
Write-Output 'G08-DOCUMENT-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -5,94 +5,33 @@ function Assert-Contains {
|
||||
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-NoCssSurface {
|
||||
param([string]$Content, [string]$ClassName)
|
||||
foreach ($property in @('border', 'border-radius')) {
|
||||
if ($Content -match "(?s)\\.$ClassName\\s*\\{[^}]*\\b$property\\s*:") {
|
||||
throw "Application flow must not construct .$ClassName with CSS $property"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$g08 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g08-join-application.vue') -Raw -Encoding utf8
|
||||
$g09 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding utf8
|
||||
$g10 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g10-application-review.vue') -Raw -Encoding utf8
|
||||
$profiles = Get-Content -LiteralPath (Join-Path $root 'styles/adaptive-frame-profiles.scss') -Raw -Encoding utf8
|
||||
|
||||
foreach ($page in @($g08, $g09, $g10)) {
|
||||
if ($page -match '<ModulePage') { throw 'G08-G10 must not retain the generic ModulePage shell' }
|
||||
Assert-Contains $page '<GenealogyPageBackground />' 'G08-G10 must use the accepted shared genealogy background'
|
||||
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'G08-G10 design phase must not connect the API layer' }
|
||||
Assert-Contains $page '<GenealogyPageBackground />' 'G08-G10 must use the shared genealogy background'
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($page -match [regex]::Escape($nativeUi)) { throw "G08-G10 must not use native UniApp UI: $nativeUi" }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const joinState = ref("form")',
|
||||
'join-state--form',
|
||||
'join-state--success',
|
||||
'join-state--error',
|
||||
'adaptive.adaptive-genealogy-state-panel',
|
||||
'adaptive.adaptive-genealogy-form-field',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'findGenealogyFixture',
|
||||
'query.genealogyId',
|
||||
'const source = ref("search")',
|
||||
'const sourceContract = computed(() =>',
|
||||
'const fieldErrors = reactive({'
|
||||
)) { Assert-Contains $g08 $required "Missing G08 join contract: $required" }
|
||||
foreach ($assetName in @('g01-empty-panel-frame.png', 'g-form-field-frame.png')) {
|
||||
Assert-Contains $profiles $assetName "Adaptive genealogy profile must own: $assetName"
|
||||
foreach ($required in @('appApi.applyToJoin', 'createRequestController', 'applicantName', 'phone', 'relationDesc', 'applyReason', 'inviterUserId')) {
|
||||
Assert-Contains $g08 $required "G08 must submit the APP join-application contract field: $required"
|
||||
}
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($g08 -match [regex]::Escape($nativeUi)) { throw "G08 must not use native UniApp UI: $nativeUi" }
|
||||
}
|
||||
if ($g08 -notmatch '(?s)\.join-field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;') {
|
||||
throw 'G08 inline validation must remain readable over the illustrated panel'
|
||||
foreach ($retired in @('findGenealogyFixture', 'joinSamples', 'LOCAL_WITHDRAWN', 'applicationSamples')) {
|
||||
if ($g08 -match [regex]::Escape($retired)) { throw "G08 must not retain local application fixture state: $retired" }
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'const applicationState = ref("loading")',
|
||||
'application-state--list',
|
||||
'application-state--empty',
|
||||
'application-state--error',
|
||||
'adaptive.adaptive-genealogy-list-card',
|
||||
'"PENDING"',
|
||||
'"APPROVED"',
|
||||
'"REJECTED"',
|
||||
'"LOCAL_WITHDRAWN"',
|
||||
'const applicationSamples',
|
||||
'const actionFor = (item) =>',
|
||||
'const withdrawTarget = ref(null)',
|
||||
'<AppDialog'
|
||||
)) { Assert-Contains $g09 $required "Missing G09 application contract: $required" }
|
||||
Assert-Contains $profiles 'list-slip-frame.png' 'Adaptive genealogy list profile must own the G09 card asset'
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($g09 -match [regex]::Escape($nativeUi)) { throw "G09 must not use native UniApp UI: $nativeUi" }
|
||||
foreach ($required in @('appApi.getMyJoinApplications', 'createRequestController', 'AppLoading', 'applications.value.length')) {
|
||||
Assert-Contains $g09 $required "G09 must read real application records: $required"
|
||||
}
|
||||
if ($g09 -match 'applicationSamples|findGenealogyFixture') { throw 'G09 must not display local application fixtures' }
|
||||
|
||||
foreach ($required in @(
|
||||
'const reviewState = ref("loading")',
|
||||
'review-state--list',
|
||||
'review-state--empty',
|
||||
'review-state--error',
|
||||
'adaptive.adaptive-genealogy-list-card',
|
||||
'a01-scroll-primary-v3.png',
|
||||
'a01-scroll-secondary-v3.png',
|
||||
'const reviewSamples',
|
||||
'const confirmation = ref(null)',
|
||||
'const helpVisible = ref(false)',
|
||||
'<AppDialog',
|
||||
'review-state--no-permission'
|
||||
)) { Assert-Contains $g10 $required "Missing G10 review contract: $required" }
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($g10 -match [regex]::Escape($nativeUi)) { throw "G10 must not use native UniApp UI: $nativeUi" }
|
||||
foreach ($required in @('appApi.getPendingApplications', 'createRequestController', 'AppLoading', 'applications.value.length')) {
|
||||
Assert-Contains $g10 $required "G10 must read real pending applications: $required"
|
||||
}
|
||||
|
||||
foreach ($className in @('join-panel', 'join-action', 'application-card', 'review-action')) {
|
||||
foreach ($page in @($g08, $g09, $g10)) { Assert-NoCssSurface $page $className }
|
||||
}
|
||||
|
||||
$statusAsset = Join-Path $root 'static/assets/modules/genealogy/transparent/list-slip-frame.png'
|
||||
if (-not (Test-Path -LiteralPath $statusAsset)) { throw 'G09-G10 cards and application empty states require list-slip-frame.png' }
|
||||
if ($g10 -match 'reviewSamples|findGenealogyFixture|auditApplication') { throw 'G10 must not invent audit semantics or local review fixtures' }
|
||||
|
||||
Write-Output 'G08-G10-APPLICATION-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
const origin = process.argv[2] || 'http://localhost:5173'
|
||||
const debugPort = process.env.CDP_PORT || '9222'
|
||||
const genealogyId = process.env.G10_GENEALOGY_ID || ''
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const pages = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()
|
||||
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
|
||||
if (!page) throw new Error(`Chrome debugging has no ${origin} application page`)
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl)
|
||||
@@ -34,133 +36,56 @@ const valueOf = async (send, expression) => {
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
|
||||
return result.result?.value
|
||||
}
|
||||
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (await valueOf(send, expression)) return
|
||||
await sleep(100)
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
let auditId = 0
|
||||
|
||||
const open = async (send, route, query, selector) => {
|
||||
auditId += 1
|
||||
const url = `${origin}/?g0810Audit=${auditId}#${route}${query}`
|
||||
const url = `${origin}/#${route}${query}`
|
||||
await send('Page.navigate', { url })
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
|
||||
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render`)
|
||||
}
|
||||
|
||||
const fillJoinForm = (send, realName, relation) => valueOf(send, `(() => {
|
||||
const inputs = Array.from(document.querySelectorAll('.join-field input'))
|
||||
if (inputs.length < 2) return false
|
||||
const values = [${JSON.stringify(realName)}, ${JSON.stringify(relation)}]
|
||||
inputs.forEach((input, index) => {
|
||||
input.value = values[index]
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
return true
|
||||
})()`)
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send, exceptions } = await connect()
|
||||
try {
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?genealogyId=2001', '.join-state--form')
|
||||
await waitFor(send, "document.querySelectorAll('.join-field input').length >= 2", 'G08 real input controls did not render')
|
||||
const prepared = await fillJoinForm(send, '汤明远', '某某某堂侄')
|
||||
if (!prepared) throw new Error('G08 form controls could not be prepared')
|
||||
await valueOf(send, "document.querySelector('.join-action')?.click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 real form submission did not reach the same-page success state')
|
||||
if (!(await valueOf(send, "document.body.textContent.includes('申请尚未提交服务器')"))) throw new Error('G08 local search preview claimed a server submission')
|
||||
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
|
||||
await waitFor(send, "location.href.includes('/pages/genealogy/g09-my-applications')", 'G08 local search preview did not continue to G09 without a result')
|
||||
|
||||
for (const genealogyId of ['1001', '1002', '2003', 'unknown']) {
|
||||
await open(send, '/pages/genealogy/g08-join-application', `?role=guest&state=success&source=search&genealogyId=${genealogyId}`, '.join-state--ineligible')
|
||||
if (await valueOf(send, "Boolean(document.querySelector('.join-form'))")) throw new Error(`G08 exposed an application form for ineligible genealogy ${genealogyId}`)
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?genealogyId=2001&genealogyName=%E6%B5%8B%E8%AF%95%E5%AE%B6%E8%B0%B1', '.join-form')
|
||||
if ((await valueOf(send, "document.querySelectorAll('.form-field input, .form-field textarea').length")) !== 5) {
|
||||
throw new Error('G08 did not render every APP join-application field')
|
||||
}
|
||||
if (await valueOf(send, "document.querySelector('.form-field .required-mark') !== null")) {
|
||||
throw new Error('G08 marked an optional APP field as required')
|
||||
}
|
||||
|
||||
// 步骤一:失败输入必须经过真实提交态进入错误结果,并能返回原表单重试。
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
|
||||
if (!await fillJoinForm(send, '失败', '某某某堂侄')) throw new Error('G08 failure controls could not be prepared')
|
||||
await valueOf(send, "document.querySelector('.join-action').click()")
|
||||
await waitFor(send, "document.querySelector('.join-action')?.textContent.includes('正在校验')", 'G08 did not expose its submitting state')
|
||||
await waitFor(send, "Boolean(document.querySelector('.join-state--error'))", 'G08 simulated submission failure did not render')
|
||||
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.join-state--form'))", 'G08 failure retry did not restore the form')
|
||||
await open(send, '/pages/genealogy/g09-my-applications', '', '.applications-page')
|
||||
await waitFor(send, "!document.querySelector('.app-loading')", 'G09 application read did not settle')
|
||||
if (!await valueOf(send, "Boolean(document.querySelector('.state-card, .application-list'))")) {
|
||||
throw new Error('G09 did not render a settled real-read state')
|
||||
}
|
||||
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?source=invite&genealogyId=2001', '.join-state--form')
|
||||
await valueOf(send, "document.querySelector('.join-action').click()")
|
||||
await waitFor(send, "document.querySelectorAll('.join-field-error').length === 2", 'G08 invite source did not show two inline required errors')
|
||||
const invitePrepared = await fillJoinForm(send, '汤明远', '某某某堂侄')
|
||||
if (!invitePrepared) throw new Error('G08 invite controls could not be prepared')
|
||||
await sleep(100)
|
||||
await valueOf(send, "document.querySelector('.join-action').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.join-state--success'))", 'G08 invite source did not reach success')
|
||||
if (!(await valueOf(send, "document.body.textContent.includes('不会选中或加入这部家谱')"))) throw new Error('G08 invite preview claimed a durable membership change')
|
||||
await valueOf(send, "document.querySelector('.join-result .join-action').click()")
|
||||
await waitFor(send, "location.href.includes('/pages/genealogy/g01-my-genealogies') && !location.href.includes('genealogyId=2001')", 'G08 invite preview selected a genealogy without backend success')
|
||||
|
||||
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
|
||||
await open(send, '/pages/genealogy/g08-join-application', '?source=search&genealogyId=2001', '.join-state--form')
|
||||
const width = await valueOf(send, 'document.documentElement.scrollWidth')
|
||||
if (width > size.width + 1) {
|
||||
const offenders = await valueOf(send, `Array.from(document.querySelectorAll('*')).map((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
return { tag: node.tagName, className: String(node.className || ''), left: rect.left, right: rect.right, width: rect.width }
|
||||
}).filter((item) => item.left < -1 || item.right > innerWidth + 1).slice(0, 12)`)
|
||||
throw new Error(`G08 has horizontal overflow at ${size.width}x${size.height}: ${JSON.stringify(offenders)}`)
|
||||
if (genealogyId) {
|
||||
await open(send, '/pages/genealogy/g10-application-review', `?genealogyId=${encodeURIComponent(genealogyId)}`, '.review-page')
|
||||
await waitFor(send, "!document.querySelector('.app-loading')", 'G10 pending-application read did not settle')
|
||||
if (!await valueOf(send, "Boolean(document.querySelector('.state-card, .application-list'))")) {
|
||||
throw new Error('G10 did not render a settled real-read state')
|
||||
}
|
||||
} else {
|
||||
await open(send, '/pages/genealogy/g10-application-review', '', '.state-card')
|
||||
if (!await valueOf(send, "document.body.textContent.includes('审核入口无效')")) {
|
||||
throw new Error('G10 invalid-context state did not render')
|
||||
}
|
||||
}
|
||||
await open(send, '/pages/genealogy/g09-my-applications', '', '.application-state--list')
|
||||
if ((await valueOf(send, "document.querySelectorAll('.application-card__action').length")) !== 3) throw new Error('G09 list did not render the three status-specific actions')
|
||||
await valueOf(send, "document.querySelector('.application-card__action').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", 'G09 pending action did not open custom withdrawal confirmation')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
|
||||
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('本地撤回预览')", 'G09 withdrawal did not use a local-only status')
|
||||
if (!(await valueOf(send, "document.querySelector('.application-card')?.textContent.includes('尚未提交服务器')"))) throw new Error('G09 local withdrawal claimed a durable server result')
|
||||
await valueOf(send, "document.querySelector('.application-card__status--rejected')?.closest('.application-card')?.querySelector('.application-card__action')?.click()")
|
||||
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?genealogyId=2004&source=search&sourceKey=G09')", 'G09 rejected action did not reopen G08 with the canonical contract')
|
||||
await open(send, '/pages/genealogy/g09-my-applications', '?state=empty', '.application-state--empty')
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?genealogyId=1001', '.review-state--list')
|
||||
await valueOf(send, "document.querySelector('.review-actions .app-button:nth-child(2)').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", 'G10 approve action did not open custom confirmation')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
|
||||
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('已通过')", 'G10 approval did not update the card')
|
||||
await waitFor(send, "Boolean(document.querySelector('.review-feedback'))", 'G10 approval did not show custom feedback')
|
||||
if (!(await valueOf(send, "document.querySelector('.review-feedback')?.textContent.includes('尚未提交服务器')"))) throw new Error('G10 local approval claimed a durable server result')
|
||||
await valueOf(send, "document.querySelector('.header-action').click()")
|
||||
await waitFor(send, "document.querySelector('.app-dialog__title')?.textContent.includes('审核说明')", 'G10 help did not use the custom dialog')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button').click()")
|
||||
|
||||
// 步骤二:拒绝必须先聚焦关联错误,再在填写原因后完成,不能沿用旧截图助手的空原因假路径。
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?genealogyId=1001', '.review-state--list')
|
||||
await valueOf(send, "document.querySelector('.review-actions .app-button:first-child').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('.rejection-field textarea'))", 'G10 reject action did not open its reason field')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
|
||||
await waitFor(send, "Boolean(document.querySelector('#g10-rejection-error'))", 'G10 empty rejection reason did not show the associated error')
|
||||
await waitFor(send, "document.activeElement === document.querySelector('.rejection-field textarea') || Boolean(document.activeElement?.closest('#g10-rejection-reason'))", 'G10 rejection validation did not focus the reason field')
|
||||
const reasonPrepared = await valueOf(send, `(() => {
|
||||
const textarea = document.querySelector('.rejection-field textarea')
|
||||
if (!textarea) return false
|
||||
textarea.value = '请补充可核验的亲属关系材料'
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
return true
|
||||
})()`)
|
||||
if (!reasonPrepared) throw new Error('G10 rejection reason could not be prepared')
|
||||
await valueOf(send, "document.querySelector('.app-dialog__actions .app-button:last-child').click()")
|
||||
await waitFor(send, "document.querySelector('.application-card__status')?.textContent.includes('已拒绝')", 'G10 rejection did not update the card')
|
||||
await waitFor(send, "Boolean(document.querySelector('.review-feedback'))", 'G10 rejection did not show custom feedback')
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?state=empty&genealogyId=1001', '.review-state--empty')
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?role=owner&state=list&genealogyId=1002', '.review-state--no-permission')
|
||||
if (await valueOf(send, "Boolean(document.querySelector('.application-card'))")) throw new Error('G10 trusted a forged owner role for a non-owner genealogy')
|
||||
await open(send, '/pages/genealogy/g10-application-review', '?state=list&genealogyId=2001', '.review-state--no-permission')
|
||||
if (exceptions.length) throw new Error(`G08-G10 raised browser exceptions: ${exceptions.join('; ')}`)
|
||||
process.stdout.write('G08-G10-APPLICATION-FLOW-RUNTIME-SMOKE PASS\n')
|
||||
} finally {
|
||||
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,25 @@ foreach ($forbidden in @('relationType: relationType.value', 'sex: addForm.gende
|
||||
if ($t04.Contains($forbidden)) { throw "T04 must not guess unsupported wire field: $forbidden" }
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
'v-model="addForm.aliasName"',
|
||||
'v-model="addForm.generationName"',
|
||||
'v-model="addForm.birthLunar"',
|
||||
'v-model="addForm.birthPlace"',
|
||||
'v-model="addForm.deathLunar"',
|
||||
'v-model="addForm.deathPlace"',
|
||||
'v-model="addForm.burialPlace"',
|
||||
'v-model="addForm.biography"',
|
||||
'v-model="addForm.remark"',
|
||||
'aliasName: addForm.aliasName',
|
||||
'generationName: addForm.generationName',
|
||||
'biography: addForm.biography',
|
||||
'remark: addForm.remark',
|
||||
'...(isFirstMember.value ? { generation: 1 } : {})'
|
||||
)) {
|
||||
if (-not $t04.Contains($required)) { throw "T04 safe optional field missing: $required" }
|
||||
}
|
||||
|
||||
foreach ($required in @('appApi.createRelatedPerson(', 'appApi.createPerson(', 'appApi.updatePerson(', 'failedAction.value = "save"')) {
|
||||
$source = if ($required -eq 'appApi.updatePerson(' -or $required -eq 'failedAction.value = "save"') { $t05 } else { $t04 }
|
||||
if (-not $source.Contains($required)) { throw "Lineage write page missing: $required" }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const run = async () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, "../utils/md5.js"), "utf8");
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
const { calcMD5Bytes } = await import(moduleUrl);
|
||||
for (const value of [Buffer.from([]), Buffer.from([0, 1, 127, 128, 255]), Buffer.from("家谱图片", "utf8")]) {
|
||||
const expected = crypto.createHash("md5").update(value).digest("hex");
|
||||
assert.strictEqual(calcMD5Bytes(value), expected);
|
||||
assert.strictEqual(calcMD5Bytes(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)), expected);
|
||||
}
|
||||
assert.throws(() => calcMD5Bytes("not-bytes"), /ArrayBuffer/);
|
||||
process.stdout.write("MD5-BYTES-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -428,10 +428,10 @@ foreach ($entry in @(
|
||||
}
|
||||
|
||||
Assert-Contains -Content $f01 -Expected 'goRoot("F01", { genealogyId: resolvedGenealogyId })' -Message 'F01 从全局解析家谱后必须先规范化根页 URL'
|
||||
Assert-Contains -Content $f01 -Expected 'listFamilyFeedFixtures(resolvedGenealogyId)' -Message 'F01 必须读取共享家族动态 owner'
|
||||
Assert-Contains -Content $f01 -Expected '动态列表待后端字段合同' -Message 'F01 必须明确动态列表读取仍被字段合同阻塞'
|
||||
Assert-NotContains -Content $f01 -Unexpected 'listFamilyFeedFixtures' -Message 'F01 不得把本地动态夹具伪装成服务端列表'
|
||||
foreach ($mapping in @(
|
||||
'openPage("F02", { genealogyId: genealogyId.value }, "F01")',
|
||||
'genealogyId: genealogyId.value, feedId: String(item.id)',
|
||||
'return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");'
|
||||
)) {
|
||||
Assert-Contains -Content $f01 -Expected $mapping -Message "F01 缺少保留家谱身份的规范入口:$mapping"
|
||||
@@ -439,7 +439,6 @@ foreach ($mapping in @(
|
||||
|
||||
foreach ($form in @(
|
||||
@{ Key = 'F02'; Content = $f02 },
|
||||
@{ Key = 'F03'; Content = $f03 },
|
||||
@{ Key = 'F06'; Content = $f06 },
|
||||
@{ Key = 'F09'; Content = $f09 }
|
||||
)) {
|
||||
@@ -452,17 +451,13 @@ foreach ($form in @(
|
||||
|
||||
Assert-Contains -Content $f02 -Expected '尚未提交服务器' -Message 'F02 只能生成明确未提交的本地预览'
|
||||
Assert-Contains -Content $f02 -Expected 'returnTo("F01", { genealogyId: genealogyId.value })' -Message 'F02 必须无结果返回同一家谱 F01'
|
||||
Assert-Contains -Content $f03 -Expected 'findFamilyFeedFixture(genealogyId.value, feedId.value)' -Message 'F03 必须按复合身份精确查询动态'
|
||||
Assert-Contains -Content $f03 -Expected '草稿已保留' -Message 'F03 评论校验后必须保留草稿并声明未提交'
|
||||
foreach ($forbidden in @('feedComments.value.push', 'commentDraft.value = ""', '评论已发送')) {
|
||||
Assert-NotContains -Content $f03 -Unexpected $forbidden -Message "F03 不得把评论草稿伪装成已发送:$forbidden"
|
||||
Assert-Contains -Content $f03 -Expected 'feed-detail-contract-note' -Message 'F03 必须明确详情与评论读取仍被合同阻塞'
|
||||
Assert-Contains -Content $f03 -Expected 'returnTo("F01", { genealogyId: genealogyId.value })' -Message 'F03 必须安全返回同一家谱 F01'
|
||||
foreach ($forbidden in @('appApi.', 'findFamilyFeedFixture', 'feedComments', 'commentDraft', 'submitComment')) {
|
||||
Assert-NotContains -Content $f03 -Unexpected $forbidden -Message "F03 后端门禁未关闭前不得保留宽接口或本地评论路径:$forbidden"
|
||||
}
|
||||
|
||||
$f01CanonicalIndex = $f01.IndexOf('if (!hasRouteIdentity)')
|
||||
$f01ActivationIndex = $f01.IndexOf('hasValidContext.value = isAccessible;')
|
||||
if ($f01CanonicalIndex -lt 0 -or $f01ActivationIndex -le $f01CanonicalIndex) {
|
||||
throw 'F01 必须在缺参根 URL 规范化分支之后才开放发布与业务快捷入口'
|
||||
}
|
||||
Assert-Contains -Content $f01 -Expected 'feedState.value = hasValidContext.value ? "unavailable" : "error";' -Message 'F01 必须在解析家谱上下文后进入明确的不可用或错误状态'
|
||||
|
||||
Assert-Contains -Content $f04 -Expected 'listFamilyArticleFixtures(genealogyId.value)' -Message 'F04 必须读取同一家谱的共享谱文 owner'
|
||||
Assert-Contains -Content $f04 -Expected ':action="hasValidContext ? ''新建'' : ''''"' -Message 'F04 无效家谱状态不得暴露新建入口'
|
||||
|
||||
@@ -253,6 +253,34 @@ process.exitCode = 1;
|
||||
`${routeKey} 生产合同不得保留未落地写接口的成功结果`,
|
||||
);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
productionRoutes.G01.resultOperations,
|
||||
["genealogy-created"],
|
||||
"G01 必须只接受 G03 已落地的真实创建结果",
|
||||
);
|
||||
{
|
||||
const targetPage = createPage(productionRoutes, "G01");
|
||||
const productionHarness = await createHarness({
|
||||
routes: productionRoutes,
|
||||
stack: [targetPage, createPage(productionRoutes, "G03")],
|
||||
});
|
||||
const result = {
|
||||
operation: "genealogy-created",
|
||||
entityId: "genealogy-created-by-api",
|
||||
refresh: true,
|
||||
};
|
||||
assert.strictEqual(
|
||||
await productionHarness.navigation.finishPage("G01", {}, result),
|
||||
true,
|
||||
"G03 真实创建成功后必须能回流 G01",
|
||||
);
|
||||
assert.strictEqual(productionHarness.calls[0].method, "navigateBack");
|
||||
assert.deepStrictEqual(
|
||||
productionHarness.navigation.consumeNavigationResult("G01"),
|
||||
result,
|
||||
"G01 必须收到真实创建完成结果",
|
||||
);
|
||||
}
|
||||
{
|
||||
const rejectionCases = [
|
||||
{
|
||||
|
||||
@@ -68,7 +68,12 @@ const fs = require("fs");
|
||||
assert.deepStrictEqual(Array.from(ROOT_ROUTE_KEYS), discoveredRoots, "ROOT_ROUTE_KEYS 必须精确列出 parent=null 的路由");
|
||||
assert.strictEqual(ROUTES.A01.kind, "auth-root", "A01 必须是认证根语义");
|
||||
assert.deepStrictEqual(Array.from(ROUTES.G03.optionalParams), [], "G03 不得保留未消费的 genealogyId 或 step 参数");
|
||||
for (const routeKey of ["G01", "G05", "G09", "T01"]) {
|
||||
assert.deepStrictEqual(
|
||||
Array.from(ROUTES.G01.resultOperations),
|
||||
["genealogy-created"],
|
||||
"G01 必须接受 G03 真实创建完成结果",
|
||||
);
|
||||
for (const routeKey of ["G05", "G09", "T01"]) {
|
||||
assert.deepStrictEqual(Array.from(ROUTES[routeKey].resultOperations), [], `${routeKey} 不得预留没有真实 API 生产者的结果能力`);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"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_TAC_SCENE = 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 response;
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success(response));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
const profile = {
|
||||
userId: 101,
|
||||
tenantId: "000000",
|
||||
userNo: "U-101",
|
||||
phone: "13800138000",
|
||||
nickName: "家谱用户",
|
||||
realName: "王小明",
|
||||
avatar: 900001,
|
||||
sex: "男",
|
||||
birthday: "1990-01-02T00:00:00",
|
||||
email: "ming@example.com",
|
||||
registerSource: "APP",
|
||||
status: "0",
|
||||
};
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: profile } };
|
||||
assert.deepStrictEqual(await appApi.getProfile(), profile);
|
||||
const profileRequest = requests.at(-1);
|
||||
assert.strictEqual(profileRequest.url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/profile");
|
||||
assert.strictEqual(profileRequest.method, "GET");
|
||||
assert.strictEqual(profileRequest.timeout, 15000);
|
||||
assert.deepStrictEqual(profileRequest.header, { clientid: "client-1", tenantId: "000000", Authorization: "Bearer session-1" });
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: profile } };
|
||||
await appApi.updateProfile({
|
||||
nickName: " 新昵称 ",
|
||||
realName: "王小明",
|
||||
avatar: 900001,
|
||||
sex: "女",
|
||||
birthday: "1991-02-03T00:00:00+08:00",
|
||||
email: "new@example.com",
|
||||
});
|
||||
assert.deepStrictEqual(requests.at(-1).data, {
|
||||
nickName: "新昵称",
|
||||
realName: "王小明",
|
||||
avatar: 900001,
|
||||
sex: "女",
|
||||
birthday: "1991-02-03T00:00:00+08:00",
|
||||
email: "new@example.com",
|
||||
});
|
||||
await assert.rejects(appApi.updateProfile({}), /至少需要一个/);
|
||||
await assert.rejects(appApi.updateProfile({ avatar: Number.MAX_SAFE_INTEGER + 1 }), /安全整数/);
|
||||
await assert.rejects(appApi.updateProfile({ birthday: "1991-02-03" }), /ISO/);
|
||||
await assert.rejects(appApi.updateProfile({ email: "bad-email" }), /email/);
|
||||
|
||||
response = { statusCode: 200, data: { code: 200, data: { ...profile, avatar: 0 } } };
|
||||
assert.strictEqual((await appApi.getProfile()).avatar, null, "未设置头像的 0 必须归一为 null");
|
||||
response = { statusCode: 200, data: { code: 200, data: { ...profile, avatar: -1 } } };
|
||||
assert.strictEqual((await appApi.getProfile()).avatar, null, "未设置头像的负值哨兵必须归一为 null");
|
||||
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("PROFILE-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-ProjectFile {
|
||||
param([string]$RelativePath)
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root $RelativePath)
|
||||
}
|
||||
|
||||
function Require-Text {
|
||||
param([string]$Content, [string]$Text, [string]$Label)
|
||||
if (-not $Content.Contains($Text)) { throw "$Label missing: $Text" }
|
||||
}
|
||||
|
||||
function Forbid-Text {
|
||||
param([string]$Content, [string]$Text, [string]$Label)
|
||||
if ($Content.Contains($Text)) { throw "$Label must not retain: $Text" }
|
||||
}
|
||||
|
||||
$m01 = Read-ProjectFile 'pages/profile/m01-profile-home.vue'
|
||||
$m02 = Read-ProjectFile 'pages/profile/m02-edit-profile.vue'
|
||||
|
||||
foreach ($stale in @('fixture', 'currentUser', 'state-card__copy')) {
|
||||
Forbid-Text $m01 $stale 'M01'
|
||||
Forbid-Text $m02 $stale 'M02'
|
||||
}
|
||||
|
||||
foreach ($required in @('appApi.getProfile', 'onShow(loadProfile)', 'profile.nickName', 'profile.realName', 'profile.phone', 'profile.sex', 'profile.birthday', 'profile.email')) {
|
||||
Require-Text $m01 $required 'M01'
|
||||
}
|
||||
foreach ($required in @('appApi.getProfile', 'appApi.updateProfile', 'pickAndUploadImage', 'form.nickName', 'form.realName', 'form.sex', 'form.birthday', 'form.email', 'avatarId', 'buildUpdatePayload')) {
|
||||
Require-Text $m02 $required 'M02'
|
||||
}
|
||||
foreach ($forbidden in @('avatarOssId', 'coverOssId', 'fileId')) {
|
||||
Forbid-Text $m02 $forbidden 'M02'
|
||||
}
|
||||
|
||||
Write-Output 'PROFILE-PAGES-CONTRACT PASS'
|
||||
@@ -1,140 +1,54 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
function Read-Page([string]$path) { Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding UTF8 }
|
||||
|
||||
$contracts = [ordered]@{
|
||||
'pages/records/r03-gift-list.vue' = @('listRelativeRecordFixtures','relativeRecords','openRelative','createRelativePreview','relative-state--empty','openPage(','"R04",','relativeId')
|
||||
'pages/records/r04-gift-editor.vue' = @('findRelativeRecordFixture','relativeForm','validateRelative','localRelativePreview','relativeId','relativeName','eventName','giftAmount','requestBack')
|
||||
'pages/records/r05-ritual-list.vue' = @('listCeremonyFixtures','ceremonies','openCeremony','createCeremonyPreview','ceremony-state--empty','openPage(','"R06",','"R07",')
|
||||
'pages/records/r06-ritual-detail.vue' = @('findCeremonyFixture','listTreeMemberPresentationFixtures','ceremonyDetail','invitees','inviteeUserId','memberOptions','editCeremony','ceremonyId','openPage(','"R07",','ceremony-state--expired')
|
||||
'pages/records/r07-ritual-editor.vue' = @('findCeremonyFixture','ceremonyForm','validateCeremony','localCeremonyPreview','ceremonyId','ceremonyType','ceremonyTitle','requestBack')
|
||||
'pages/records/r08-growth-journal.vue' = @('listGrowthRecordFixtures','findTreeMemberPresentationFixture','growthRecords','localGrowthPreview','recordGrowth','requestBack','timeline-state--empty')
|
||||
'pages/records/r09-life-events.vue' = @('findTreeMemberPresentationFixture','人生事件接口尚未开放','serviceState','requestBack')
|
||||
'pages/records/r10-memo-list.vue' = @('listMemoFixtures','memos','createMemoPreview','localMemoPreview','memoTitle','requestBack','memo-state--empty')
|
||||
'pages/records/r11-merit-records.vue' = @('listMeritRecordFixtures','meritRecords','createMeritPreview','localMeritPreview','donorName','meritTitle','totalContribution','requestBack')
|
||||
'pages/records/r03-gift-list.vue' = @('appApi.getRelativeRecords', 'relativeId', 'openPage("R04"', 'record-list')
|
||||
'pages/records/r04-gift-editor.vue' = @('appApi.createRelativeRecord', 'relativeName', 'mediaOssIds', 'pickAndUploadImage')
|
||||
'pages/records/r05-ritual-list.vue' = @('appApi.getCeremonies', 'ceremonyId', 'openPage("R06"', 'openPage("R07"')
|
||||
'pages/records/r06-ritual-detail.vue' = @('appApi.getCeremonyDetail', 'ceremonyId')
|
||||
'pages/records/r07-ritual-editor.vue' = @('appApi.createCeremony', 'ceremonyType', 'ceremonyTitle')
|
||||
'pages/records/r08-growth-journal.vue' = @('appApi.getGrowthRecords', 'appApi.createGrowthRecord', 'recordId', 'recordTitle', 'view.value = "list"')
|
||||
'pages/records/r09-life-events.vue' = @('hasValidContext', 'returnToRecords')
|
||||
'pages/records/r10-memo-list.vue' = @('appApi.getMemos', 'appApi.createMemo', 'memoId', 'memoTitle', 'view.value = "list"')
|
||||
'pages/records/r11-merit-records.vue' = @('appApi.getMeritRecords', 'appApi.createMeritRecord', 'meritId', 'meritTitle', 'totalAmount')
|
||||
}
|
||||
|
||||
foreach ($entry in $contracts.GetEnumerator()) {
|
||||
$source = Read-Page $entry.Key
|
||||
foreach ($token in $entry.Value) {
|
||||
if (-not $source.Contains($token)) { throw "$($entry.Key) missing R contract: $token" }
|
||||
if (-not $source.Contains($token)) { throw "$($entry.Key) missing current record contract: $token" }
|
||||
}
|
||||
foreach ($shared in @('ModulePageBackground','PageHeader','AppButton')) {
|
||||
foreach ($shared in @('ModulePageBackground', 'PageHeader', 'AppButton')) {
|
||||
if (-not $source.Contains($shared)) { throw "$($entry.Key) missing shared primitive: $shared" }
|
||||
}
|
||||
foreach ($forbidden in @(
|
||||
'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch',
|
||||
'/pages/records/', 'saveResult', 'finishPage(', 'Date.now()', '.unshift(',
|
||||
'giftId', 'ritualId', 'personName=', '已保存', '已删除'
|
||||
)) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains forbidden fake-write or old-route contract: $forbidden" }
|
||||
foreach ($forbidden in @('Fixture', 'localGrowthPreview', 'localMemoPreview', 'localMeritPreview', 'Date.now()', 'uni.navigateTo', 'uni.navigateBack', 'uni.redirectTo', 'uni.reLaunch')) {
|
||||
if ($source.Contains($forbidden)) { throw "$($entry.Key) retains retired local or direct-navigation contract: $forbidden" }
|
||||
}
|
||||
if ($source -match '<ModulePage(?:\s|/|>)|import\s+ModulePage\s+from') { throw "$($entry.Key) must own its business page" }
|
||||
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$($entry.Key) must use project feedback primitives" }
|
||||
if ($source -match '(?im)(?<![-\w])position\s*:|overflow\s*:\s*hidden') { throw "$($entry.Key) contains forbidden document-flow declaration" }
|
||||
}
|
||||
|
||||
foreach ($pageAndTarget in @(
|
||||
@{ Path = 'pages/records/r04-gift-editor.vue'; Target = 'R03' },
|
||||
@{ Path = 'pages/records/r06-ritual-detail.vue'; Target = 'R05' },
|
||||
@{ Path = 'pages/records/r07-ritual-editor.vue'; Target = 'R05' }
|
||||
)) {
|
||||
$source = Read-Page $pageAndTarget.Path
|
||||
$safeReturnPattern = '(?s)genealogyId\.value\s*\?\s*returnTo\("{0}".*?:\s*goBack\(\)' -f $pageAndTarget.Target
|
||||
if ($source -notmatch $safeReturnPattern) {
|
||||
throw "$($pageAndTarget.Path) invalid-entry CTA must fall back to goBack without genealogyId"
|
||||
}
|
||||
}
|
||||
|
||||
$r03 = Read-Page 'pages/records/r03-gift-list.vue'
|
||||
$r04 = Read-Page 'pages/records/r04-gift-editor.vue'
|
||||
$r06 = Read-Page 'pages/records/r06-ritual-detail.vue'
|
||||
foreach ($forbiddenCurrency in @('¥', '¥')) {
|
||||
if ($r03.Contains($forbiddenCurrency) -or $r04.Contains($forbiddenCurrency)) {
|
||||
throw "R03/R04 must not invent a currency symbol absent from the backend contract: $forbiddenCurrency"
|
||||
}
|
||||
foreach ($optionalRelativeField in @('relationName', 'eventName', 'eventTime', 'giftAmount', 'recordContent', 'mediaOssIds', 'sortOrder')) {
|
||||
if ($r04 -match "if\s*\(!form\.$optionalRelativeField") { throw "R04 must not make optional backend field required: $optionalRelativeField" }
|
||||
}
|
||||
foreach ($optionalRelativeField in @('relationName', 'eventName', 'eventTime')) {
|
||||
if ($r04 -match "relativeErrors\.$optionalRelativeField\s*=\s*relativeForm\.$optionalRelativeField\.trim\(\)") {
|
||||
throw "R04 must not make optional backend field required: $optionalRelativeField"
|
||||
}
|
||||
}
|
||||
foreach ($invalidAmountRule in @('amount >= 0', 'String(Number(relativeForm.giftAmount))')) {
|
||||
if ($r04.Contains($invalidAmountRule)) {
|
||||
throw "R04 must preserve optional numeric amount semantics without inventing a boundary or string body: $invalidAmountRule"
|
||||
}
|
||||
}
|
||||
foreach ($inventedInvitationField in @('inviteStatusLabel', 'userId:', 'name:', 'relation:')) {
|
||||
if ($r06.Contains($inventedInvitationField)) {
|
||||
throw "R06 must join inviteeUserId to scoped member options and must not retain invented invitation fields: $inventedInvitationField"
|
||||
}
|
||||
}
|
||||
foreach ($previewGuard in @(
|
||||
@{ Path = 'pages/records/r04-gift-editor.vue'; Pattern = 'editorState\.value\s*===\s*"preview"' },
|
||||
@{ Path = 'pages/records/r07-ritual-editor.vue'; Pattern = 'editorState\.value\s*===\s*"preview"' },
|
||||
@{ Path = 'pages/records/r08-growth-journal.vue'; Pattern = 'dirty:\s*Boolean\(localGrowthPreview\.value\)' },
|
||||
@{ Path = 'pages/records/r10-memo-list.vue'; Pattern = 'dirty:\s*Boolean\(localMemoPreview\.value\)' },
|
||||
@{ Path = 'pages/records/r11-merit-records.vue'; Pattern = 'dirty:\s*Boolean\(localMeritPreview\.value\)' }
|
||||
)) {
|
||||
$source = Read-Page $previewGuard.Path
|
||||
if ($source -notmatch $previewGuard.Pattern) {
|
||||
throw "$($previewGuard.Path) must guard page back while a local preview remains unsaved"
|
||||
}
|
||||
}
|
||||
foreach ($dialogPage in @(
|
||||
'pages/records/r04-gift-editor.vue',
|
||||
'pages/records/r07-ritual-editor.vue',
|
||||
'pages/records/r08-growth-journal.vue',
|
||||
'pages/records/r10-memo-list.vue',
|
||||
'pages/records/r11-merit-records.vue'
|
||||
)) {
|
||||
$source = Read-Page $dialogPage
|
||||
$dialogs = [regex]::Matches($source, '(?s)<AppDialog\b.*?>')
|
||||
if ($dialogs.Count -eq 0 -or ($dialogs | Where-Object { $_.Value -notmatch ':close-on-mask="false"' })) {
|
||||
throw "$dialogPage dialogs must ignore mask taps"
|
||||
}
|
||||
}
|
||||
$r11 = Read-Page 'pages/records/r11-merit-records.vue'
|
||||
foreach ($amountContract in @(
|
||||
'type="digit"',
|
||||
'Number.isFinite(Number(amountInput))',
|
||||
'amount: amountInput ? Number(amountInput) : null',
|
||||
'金额数值(单位待确认)',
|
||||
'localMeritPreview.meritType'
|
||||
)) {
|
||||
if (-not $r11.Contains($amountContract)) {
|
||||
throw "R11 optional numeric amount or preview visibility contract missing: $amountContract"
|
||||
}
|
||||
|
||||
foreach ($page in @('pages/records/r08-growth-journal.vue', 'pages/records/r10-memo-list.vue', 'pages/records/r11-merit-records.vue')) {
|
||||
$source = Read-Page $page
|
||||
if ($source -notmatch 'onShow\(') { throw "$page must reread the service when returning to its list" }
|
||||
if ($source -notmatch 'await\s+load') { throw "$page must read back after a successful write" }
|
||||
}
|
||||
|
||||
$r10 = Read-Page 'pages/records/r10-memo-list.vue'
|
||||
foreach ($forbidden in @('toggleMemo', 'memo.done =', '点击标记完成', '点击恢复待办')) {
|
||||
if ($r10.Contains($forbidden)) { throw "R10 must not fake completed-state mutation: $forbidden" }
|
||||
}
|
||||
$r09 = Read-Page 'pages/records/r09-life-events.vue'
|
||||
foreach ($forbidden in @('lifeEvents', 'createLifeEvent', 'saveLifeEvent', 'growth-records', 'LIFE_EVENT')) {
|
||||
if ($r09.Contains($forbidden)) { throw "R09 must remain hard closed without a backend contract: $forbidden" }
|
||||
foreach ($forbidden in @('toggleMemo', 'memo.done =')) {
|
||||
if ($r10.Contains($forbidden)) { throw "R10 must not invent a completed-state mutation: $forbidden" }
|
||||
}
|
||||
|
||||
$runtimeSources = @(
|
||||
'tests/r-business-flow-runtime-smoke.js',
|
||||
'tests/r02-person-detail-runtime-smoke.js',
|
||||
'tests/r02-background-runtime-smoke.js',
|
||||
'tests/module-page-runtime-smoke.js',
|
||||
'tests/module-series-responsive-runtime-smoke.js'
|
||||
) | ForEach-Object { Read-Page $_ }
|
||||
$runtimeText = $runtimeSources -join "`n"
|
||||
foreach ($retired in @('giftId', 'ritualId', 'count=50', 'r02-person-detail?personId=1', 'r08-growth-journal",".timeline-card')) {
|
||||
if ($runtimeText.Contains($retired)) { throw "R runtime smoke retains retired route or fake-volume contract: $retired" }
|
||||
}
|
||||
foreach ($requiredRuntimeFact in @(
|
||||
'genealogyId=1001&mode=view&relativeId=301&sourceKey=R03',
|
||||
'R08 local preview must not enter the official timeline',
|
||||
'R11 local preview must not change the official count',
|
||||
'personId=103',
|
||||
'preview back guard missing'
|
||||
)) {
|
||||
if (-not $runtimeText.Contains($requiredRuntimeFact)) { throw "R runtime smoke misses current contract: $requiredRuntimeFact" }
|
||||
$r09 = Read-Page 'pages/records/r09-life-events.vue'
|
||||
foreach ($forbidden in @('lifeEvents', 'createLifeEvent', 'saveLifeEvent', 'growth-records', 'LIFE_EVENT')) {
|
||||
if ($r09.Contains($forbidden)) { throw "R09 must remain closed without a backend contract: $forbidden" }
|
||||
}
|
||||
|
||||
Write-Output 'R-BUSINESS-FLOW-CONTRACT PASS'
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const origin = process.argv[2] || "http://localhost:5173";
|
||||
const cdpPort = process.env.CDP_PORT || "9222";
|
||||
const genealogyId = process.env.GENEALOGY_ID || "2080557121112465409";
|
||||
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const connect = async () => {
|
||||
const pages = await (await fetch("http://127.0.0.1:9222/json/list")).json();
|
||||
const page = pages.find(
|
||||
(candidate) => candidate.type === "page" && candidate.url.startsWith(`${origin}/`),
|
||||
);
|
||||
const pages = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
|
||||
const page = pages.find((candidate) => candidate.type === "page" && candidate.url.startsWith(`${origin}/`));
|
||||
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`);
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
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) => {
|
||||
@@ -22,145 +20,68 @@ const connect = async () => {
|
||||
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);
|
||||
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result);
|
||||
});
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
id += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
return { socket, send };
|
||||
};
|
||||
|
||||
const valueOf = async (send, expression) =>
|
||||
(await send("Runtime.evaluate", { expression, returnByValue: true })).result?.value;
|
||||
const valueOf = async (send, expression) => (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result?.value;
|
||||
const waitFor = async (send, expression, message) => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
if (await valueOf(send, expression)) return;
|
||||
await sleep(100);
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
let auditId = 0;
|
||||
const open = async (send, route, selector) => {
|
||||
auditId += 1;
|
||||
const url = `${origin}/?rBusiness=${auditId}#${route}`;
|
||||
const open = async (send, route) => {
|
||||
const url = `${origin}/#${route}`;
|
||||
await send("Page.navigate", { url });
|
||||
await waitFor(send, `location.href===${JSON.stringify(url)}`, `navigation failed: ${route}`);
|
||||
await waitFor(
|
||||
send,
|
||||
`Boolean(document.querySelector(${JSON.stringify(selector)}))`,
|
||||
`missing ${selector}: ${route}`,
|
||||
);
|
||||
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `navigation failed: ${route}`);
|
||||
const previousTimeOrigin = await valueOf(send, "performance.timeOrigin");
|
||||
await send("Page.reload");
|
||||
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(previousTimeOrigin)}`, `reload failed: ${route}`);
|
||||
await waitFor(send, "document.body.innerText.length > 0", `page did not render: ${route}`);
|
||||
};
|
||||
const click = (send, selector) =>
|
||||
valueOf(send, `document.querySelector(${JSON.stringify(selector)})?.click()`);
|
||||
const inputAt = (send, selector, index, value) =>
|
||||
valueOf(
|
||||
send,
|
||||
`(() => { const input=document.querySelectorAll(${JSON.stringify(selector)})[${index}]; input.value=${JSON.stringify(value)}; input.dispatchEvent(new Event('input',{bubbles:true})); return input.value; })()`,
|
||||
);
|
||||
|
||||
const routes = [
|
||||
["/pages/records/r03-gift-list?genealogyId=1001", ".record-card"],
|
||||
["/pages/records/r04-gift-editor?genealogyId=1001&mode=create", ".form-card"],
|
||||
["/pages/records/r05-ritual-list?genealogyId=1001", ".record-card"],
|
||||
["/pages/records/r06-ritual-detail?genealogyId=1001&ceremonyId=501", ".detail-card"],
|
||||
["/pages/records/r07-ritual-editor?genealogyId=1001&mode=create", ".form-card"],
|
||||
["/pages/records/r08-growth-journal?genealogyId=1001&personId=101", ".timeline-card"],
|
||||
["/pages/records/r09-life-events?genealogyId=1001&personId=101", ".service-state--unavailable"],
|
||||
["/pages/records/r10-memo-list?genealogyId=1001", ".memo-card"],
|
||||
["/pages/records/r11-merit-records?genealogyId=1001", ".merit-card"],
|
||||
];
|
||||
|
||||
const run = async () => {
|
||||
const { socket, send } = await connect();
|
||||
try {
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
for (const size of [
|
||||
{ width: 320, height: 568 },
|
||||
{ width: 412, height: 915 },
|
||||
]) {
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
...size,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
screenWidth: size.width,
|
||||
screenHeight: size.height,
|
||||
});
|
||||
for (const [route, selector] of routes) {
|
||||
await open(send, route, selector);
|
||||
const documentWidth = await valueOf(send, "document.documentElement.scrollWidth");
|
||||
assert(documentWidth <= size.width + 1, `${route} overflows at ${size.width}`);
|
||||
}
|
||||
const readbacks = [
|
||||
["/pages/records/r03-gift-list?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u4eb2\u53cb"],
|
||||
["/pages/records/r05-ritual-list?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u793c\u4eea\u6d3b\u52a8"],
|
||||
["/pages/records/r08-growth-journal?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u6210\u957f\u8bb0\u5f55"],
|
||||
["/pages/records/r10-memo-list?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u5907\u5fd8"],
|
||||
["/pages/records/r11-merit-records?genealogyId=" + genealogyId, "\u63a5\u53e3\u8054\u8c03\u9a8c\u8bc1\u529f\u5fb7"],
|
||||
];
|
||||
for (const [route, text] of readbacks) {
|
||||
await open(send, route);
|
||||
await waitFor(send, `document.body.innerText.includes(${JSON.stringify(text)})`, `service readback missing: ${route}`);
|
||||
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.state-card .app-loading'))"), false, `page stuck loading: ${route}`);
|
||||
}
|
||||
|
||||
await open(send, "/pages/records/r08-growth-journal?genealogyId=1002&personId=101", ".timeline-state--invalid");
|
||||
await open(send, "/pages/records/r09-life-events", ".service-state--invalid");
|
||||
await open(send, "/pages/records/r08-growth-journal?genealogyId=" + genealogyId);
|
||||
await valueOf(send, "document.querySelector('.header-action')?.click()");
|
||||
await waitFor(send, "Boolean(document.querySelector('.form-card'))", "R08 create form did not open");
|
||||
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.required-mark').length"), 1, "R08 required marker drifted");
|
||||
|
||||
await open(send, "/pages/records/r03-gift-list?genealogyId=1001", ".record-card");
|
||||
await click(send, ".record-card");
|
||||
await waitFor(
|
||||
send,
|
||||
"location.hash.includes('genealogyId=1001&mode=view&relativeId=301&sourceKey=R03')",
|
||||
"R03 did not open the scoped R04 record",
|
||||
);
|
||||
await open(send, "/pages/records/r10-memo-list?genealogyId=" + genealogyId);
|
||||
await valueOf(send, "document.querySelector('.header-action')?.click()");
|
||||
await waitFor(send, "Boolean(document.querySelector('.form-card'))", "R10 create form did not open");
|
||||
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.required-mark').length"), 1, "R10 required marker drifted");
|
||||
|
||||
await open(send, "/pages/records/r05-ritual-list?genealogyId=1001", ".record-card");
|
||||
await click(send, ".record-card");
|
||||
await waitFor(
|
||||
send,
|
||||
"location.hash.includes('genealogyId=1001&ceremonyId=501&sourceKey=R05')",
|
||||
"R05 did not open the scoped R06 ceremony",
|
||||
);
|
||||
|
||||
await open(send, "/pages/records/r08-growth-journal?genealogyId=1001&personId=101", ".timeline-card");
|
||||
const growthCount = await valueOf(send, "document.querySelectorAll('.timeline-card').length");
|
||||
await click(send, ".header-action");
|
||||
await waitFor(send, "Boolean(document.querySelector('.app-dialog-layer'))", "R08 editor missing");
|
||||
await inputAt(send, ".dialog-form input", 0, "第一次远行");
|
||||
await click(send, ".app-dialog__actions .app-button:last-child");
|
||||
await waitFor(
|
||||
send,
|
||||
"document.querySelector('.preview-card')?.innerText.includes('第一次远行')",
|
||||
"R08 local preview missing",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await valueOf(send, "document.querySelectorAll('.timeline-card').length"),
|
||||
growthCount,
|
||||
"R08 local preview must not enter the official timeline",
|
||||
);
|
||||
|
||||
await open(send, "/pages/records/r11-merit-records?genealogyId=1001", ".merit-card");
|
||||
const meritCount = await valueOf(send, "document.querySelectorAll('.merit-card').length");
|
||||
await click(send, ".header-action");
|
||||
await inputAt(send, ".dialog-form input", 0, "整理旧谱");
|
||||
await inputAt(send, ".dialog-form input", 1, "汤文清");
|
||||
await click(send, ".app-dialog__actions .app-button:last-child");
|
||||
await waitFor(
|
||||
send,
|
||||
"document.querySelector('.preview-card')?.innerText.includes('整理旧谱')",
|
||||
"R11 local preview missing",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await valueOf(send, "document.querySelectorAll('.merit-card').length"),
|
||||
meritCount,
|
||||
"R11 local preview must not change the official count",
|
||||
);
|
||||
await open(send, "/pages/records/r11-merit-records?genealogyId=" + genealogyId);
|
||||
await valueOf(send, "document.querySelector('.header-action')?.click()");
|
||||
await waitFor(send, "Boolean(document.querySelector('.form-card'))", "R11 create form did not open");
|
||||
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.required-mark').length"), 2, "R11 required marker drifted");
|
||||
process.stdout.write("R-BUSINESS-FLOW-RUNTIME-SMOKE PASS\n");
|
||||
} finally {
|
||||
try {
|
||||
await send("Emulation.clearDeviceMetricsOverride");
|
||||
} catch (_) {}
|
||||
socket.close();
|
||||
}
|
||||
} finally { socket.close(); }
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1); });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"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_TAC_SCENE = 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 = [];
|
||||
globalThis.uni = { request(options) { requests.push(options); queueMicrotask(() => options.success({ statusCode: 200, data: { code: 200, data: [{ regionCode: "11", label: "Beijing", parentCode: "0", leaf: false, regionLevel: 1 }] } })); 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/app/region/children");
|
||||
assert.strictEqual(requests.at(-1).method, "GET");
|
||||
assert.deepStrictEqual(requests.at(-1).data, { parentCode: "0" });
|
||||
await assert.rejects(appApi.getRegionChildren(" "), /地区父级/);
|
||||
delete globalThis.uni;
|
||||
process.stdout.write("REGION-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1); });
|
||||
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/resumable-image-upload.js"), "utf8");
|
||||
const source = rawSource
|
||||
.replace('import { appApi } from "@/utils/api.js";', "const appApi = globalThis.__appApi;")
|
||||
.replace('import { calcMD5Bytes } from "@/utils/md5.js";', "const calcMD5Bytes = () => \"a\".repeat(32);");
|
||||
globalThis.__appApi = {
|
||||
initializeResumableUpload: async () => ({
|
||||
instant: true,
|
||||
ossId: "2060000000000000001",
|
||||
url: "https://files.example/image.jpg",
|
||||
fileName: "image.jpg",
|
||||
}),
|
||||
};
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
const { pickAndUploadImage, toConsumerOssId } = await import(moduleUrl);
|
||||
|
||||
assert.strictEqual(toConsumerOssId("900001"), 900001);
|
||||
assert.throws(() => toConsumerOssId("0"), (error) => error.code === "OSS_ID_INVALID");
|
||||
assert.throws(() => toConsumerOssId("9007199254740992"), (error) => error.code === "OSS_ID_UNSAFE");
|
||||
await assert.rejects(pickAndUploadImage(), (error) => error.code === "IMAGE_PICK_UNAVAILABLE");
|
||||
|
||||
globalThis.plus = {
|
||||
gallery: {
|
||||
pick(success) {
|
||||
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 } });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.deepStrictEqual(await pickAndUploadImage(), {
|
||||
ossId: "2060000000000000001",
|
||||
url: "https://files.example/image.jpg",
|
||||
thumbnailUrl: "",
|
||||
fileName: "image.jpg",
|
||||
});
|
||||
delete globalThis.plus;
|
||||
delete globalThis.__appApi;
|
||||
process.stdout.write("RESUMABLE-IMAGE-UPLOAD-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,12 +8,12 @@ function Assert-Match([string]$Pattern, [string]$Message) {
|
||||
}
|
||||
|
||||
Assert-Match 'class="directory-context"' 'T07 must show the current genealogy context.'
|
||||
Assert-Match '\u6c64\u6c0f\u5bb6\u8c31' 'T07 baseline must identify the current genealogy in the H5 mock state.'
|
||||
Assert-Match '\u5f53\u524d\u5bb6\u8c31' 'T07 must not render a stale mock genealogy name.'
|
||||
Assert-Match '''directory-state--loading'': directoryState === ''loading''' 'T07 loading state must have an explicit root class.'
|
||||
Assert-Match 'v-if="directoryState === ''list'' \|\| directoryState === ''empty''"' 'T07 search must remain available for list and empty states only.'
|
||||
Assert-Match 'class="directory-search__action"[^>]*role="button"[^>]*aria-label="\u67e5\u627e\u6210\u5458"' 'T07 search action must expose button semantics and an accessible label.'
|
||||
Assert-Match '<AppButton\s+v-if="directoryState === ''error''"[^>]+type="secondary"[^>]+:label="hasValidContext \? ''\u91cd\u65b0\u67e5\u770b'' : ''\u8fd4\u56de\u4e0a\u4e00\u9875''"[^>]+@click="retryDirectory"' 'T07 error state must provide context-safe recovery.'
|
||||
Assert-Match '(?s)const retryDirectory = \(\) => \{\s*if \(!hasValidContext\.value\) return goBack\(\);\s*directoryState\.value = "list";\s*\}' 'T07 retry action must only restore a valid directory context.'
|
||||
Assert-Match '(?s)const retryDirectory = \(\) => \{\s*if \(!hasValidContext\.value\) return goBack\(\);\s*pageNum\.value = 1;\s*return loadMembers\(\);\s*\}' 'T07 retry action must reload a valid directory context.'
|
||||
Assert-Match '(?s)\.directory-page\s*\{[^}]*overflow-y:\s*auto;' 'T07 must allow natural vertical scrolling.'
|
||||
if ($page -match '(?s)\.directory-page\s*\{[^}]*overflow-x:\s*hidden;') { throw 'T07 must not hide horizontal layout failures at the page root.' }
|
||||
Assert-Match '(?s)\.directory-search\s*\{[^}]*min-height:\s*44px;' 'T07 search field must preserve a 44 CSS px minimum touch height at every viewport.'
|
||||
|
||||
@@ -187,7 +187,7 @@ const run = async () => {
|
||||
sceneCode: "APP_REGISTER",
|
||||
subject: "13800138000",
|
||||
};
|
||||
instance.context = context;
|
||||
instance.requestContext = context;
|
||||
instance.generation += 1;
|
||||
instance.createTac();
|
||||
assert.strictEqual(latestTac.initialized, true);
|
||||
@@ -214,7 +214,7 @@ const run = async () => {
|
||||
|
||||
const staleSuccess = firstSuccess;
|
||||
instance.generation += 1;
|
||||
instance.context = { ...context, requestId: "request-2" };
|
||||
instance.requestContext = { ...context, requestId: "request-2" };
|
||||
staleSuccess({ data: { validToken: "stale", expireSeconds: 300 } }, null, latestTac);
|
||||
assert.strictEqual(ownerCalls.length, 1, "换轮后的陈旧回调不得污染新页面状态");
|
||||
|
||||
@@ -228,7 +228,7 @@ const run = async () => {
|
||||
"关闭按钮重复回调只能提交一次取消",
|
||||
);
|
||||
|
||||
instance.context = { ...context, requestId: "request-3" };
|
||||
instance.requestContext = { ...context, requestId: "request-3" };
|
||||
await instance.onContextChange({ visible: false });
|
||||
assert.strictEqual(instance.tac, null, "隐藏或卸载验证层必须销毁 SDK 实例");
|
||||
assert.strictEqual(host.innerHTML, "", "隐藏或卸载验证层必须清空宿主节点");
|
||||
|
||||
Reference in New Issue
Block a user