294 lines
15 KiB
PowerShell
294 lines
15 KiB
PowerShell
$ErrorActionPreference = 'Stop'
|
||
|
||
$root = Split-Path -Parent $PSScriptRoot
|
||
$pagesPath = Join-Path $root 'pages.json'
|
||
$routesPath = Join-Path $root 'utils/navigation-routes.js'
|
||
|
||
if (-not (Test-Path -LiteralPath $routesPath -PathType Leaf)) {
|
||
throw '缺少路由语义注册表:utils/navigation-routes.js'
|
||
}
|
||
|
||
<# NODE-CONTRACT-BEGIN
|
||
"use strict";
|
||
|
||
const assert = require("assert");
|
||
const fs = require("fs");
|
||
|
||
(async () => {
|
||
const [pagesPath, routesPath] = process.argv.slice(2);
|
||
const pages = JSON.parse(fs.readFileSync(pagesPath, "utf8"));
|
||
const routesSource = fs.readFileSync(routesPath, "utf8");
|
||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(routesSource).toString("base64")}`;
|
||
const { NOTICE_TARGETS, ROUTES, ROOT_ROUTE_KEYS, getRoute, getRouteKeyByPath } = await import(moduleUrl);
|
||
|
||
const registeredPaths = pages.pages.map(({ path }) => `/${path}`);
|
||
const expectedRouteKeys = registeredPaths.map((path) => {
|
||
const filename = path.split("/").pop();
|
||
const match = /^([a-z]\d{2})-/i.exec(filename);
|
||
assert(match, `页面文件名缺少语义编号:${path}`);
|
||
return match[1].toUpperCase();
|
||
});
|
||
const routeKeys = Object.keys(ROUTES);
|
||
const routePaths = routeKeys.map((routeKey) => ROUTES[routeKey].path);
|
||
|
||
assert.strictEqual(routeKeys.length, 52, "路由注册表必须恰好包含 52 条活动路由");
|
||
assert.deepStrictEqual(routeKeys, expectedRouteKeys, "路由键集合和顺序必须来自 pages.json 的页面编号");
|
||
assert.deepStrictEqual(routePaths, registeredPaths, "路由路径集合和顺序必须与 pages.json 精确一致");
|
||
assert(Object.isFrozen(ROUTES), "ROUTES 必须冻结");
|
||
assert(Object.isFrozen(ROOT_ROUTE_KEYS), "ROOT_ROUTE_KEYS 必须冻结");
|
||
assert(Object.isFrozen(NOTICE_TARGETS), "NOTICE_TARGETS 必须冻结");
|
||
assert.deepStrictEqual(Object.keys(NOTICE_TARGETS), ["GENEALOGY_REVIEW", "GENEALOGY_HOME"], "通知目标类型必须是封闭白名单");
|
||
assert.deepStrictEqual(
|
||
Object.fromEntries(Object.entries(NOTICE_TARGETS).map(([key, value]) => [key, { routeKey: value.routeKey, params: Array.from(value.params) }])),
|
||
{
|
||
GENEALOGY_REVIEW: { routeKey: "G10", params: ["genealogyId"] },
|
||
GENEALOGY_HOME: { routeKey: "G01", params: ["genealogyId"] },
|
||
},
|
||
"通知只能映射到登记业务语义及精确参数",
|
||
);
|
||
for (const target of Object.values(NOTICE_TARGETS)) {
|
||
assert(Object.isFrozen(target), "通知目标定义必须冻结");
|
||
assert(Object.isFrozen(target.params), "通知目标参数表必须冻结");
|
||
}
|
||
|
||
const routeFields = [
|
||
"path", "kind", "parent", "parentParamMap", "requiredParams", "optionalParams",
|
||
"allowedSources", "resultOperations",
|
||
];
|
||
const routeKinds = new Set(["auth-root", "root", "page", "flow", "single"]);
|
||
const forbiddenParams = new Set([
|
||
"previous", "returnUrl", "fallbackUrl", "targetUrl", "state", "count",
|
||
"saveResult", "step", "genealogyName", "sourceKey", "personName",
|
||
"giftId", "ritualId",
|
||
]);
|
||
const discoveredRoots = routeKeys.filter((routeKey) => ROUTES[routeKey].parent === null);
|
||
|
||
assert.strictEqual(discoveredRoots.length, 4, "活动路由必须保持四个互不伪造历史的根语义");
|
||
assert.deepStrictEqual(Array.from(ROOT_ROUTE_KEYS), ["A01", "G01", "F01", "M01"], "根安全边界必须固定");
|
||
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[routeKey].resultOperations), [], `${routeKey} 不得预留没有真实 API 生产者的结果能力`);
|
||
}
|
||
assert.deepStrictEqual(
|
||
Array.from(ROUTES.T03.resultOperations),
|
||
["member-open-requested"],
|
||
"T03 当前只能保留 single 页面激活实际使用的页内打开结果",
|
||
);
|
||
const expectedFamilyParams = {
|
||
F01: { required: [], optional: ["genealogyId"] },
|
||
F02: { required: ["genealogyId"], optional: [] },
|
||
F03: { required: ["genealogyId", "feedId"], optional: [] },
|
||
F04: { required: ["genealogyId"], optional: [] },
|
||
F05: { required: ["genealogyId", "articleId"], optional: [] },
|
||
F06: { required: ["genealogyId", "mode"], optional: ["articleId"] },
|
||
F07: { required: ["genealogyId"], optional: [] },
|
||
F08: { required: ["genealogyId", "albumId"], optional: [] },
|
||
F09: { required: ["genealogyId", "albumId"], optional: [] },
|
||
F10: { required: ["genealogyId"], optional: [] },
|
||
};
|
||
for (const [routeKey, expected] of Object.entries(expectedFamilyParams)) {
|
||
assert.deepStrictEqual(
|
||
Array.from(ROUTES[routeKey].requiredParams),
|
||
expected.required,
|
||
`${routeKey} 必填参数必须精确表达家谱复合身份`,
|
||
);
|
||
assert.deepStrictEqual(
|
||
Array.from(ROUTES[routeKey].optionalParams),
|
||
expected.optional,
|
||
`${routeKey} 可选参数不得保留身份旁路`,
|
||
);
|
||
assert.deepStrictEqual(
|
||
Array.from(ROUTES[routeKey].resultOperations),
|
||
[],
|
||
`${routeKey} 在真实写接口落地前不得预留伪成功结果`,
|
||
);
|
||
}
|
||
assert.deepStrictEqual(
|
||
Array.from(ROUTES.F08.allowedSources),
|
||
["F07"],
|
||
"F09 无结果返回 F08,不得伪装成新的 F08 打开来源",
|
||
);
|
||
const expectedRecordParams = {
|
||
R01: { required: ["genealogyId"], optional: [] },
|
||
R02: { required: ["genealogyId", "mode"], optional: ["personId"] },
|
||
R03: { required: ["genealogyId"], optional: [] },
|
||
R04: { required: ["genealogyId", "mode"], optional: ["relativeId"] },
|
||
R05: { required: ["genealogyId"], optional: [] },
|
||
R06: { required: ["genealogyId", "ceremonyId"], optional: [] },
|
||
R07: { required: ["genealogyId", "mode"], optional: ["ceremonyId"] },
|
||
R08: { required: ["genealogyId", "personId"], optional: [] },
|
||
R09: { required: ["genealogyId", "personId"], optional: [] },
|
||
R10: { required: ["genealogyId"], optional: [] },
|
||
R11: { required: ["genealogyId"], optional: [] },
|
||
};
|
||
for (const [routeKey, expected] of Object.entries(expectedRecordParams)) {
|
||
assert.deepStrictEqual(
|
||
Array.from(ROUTES[routeKey].requiredParams),
|
||
expected.required,
|
||
`${routeKey} 必填参数必须精确表达 R 系列复合身份`,
|
||
);
|
||
assert.deepStrictEqual(
|
||
Array.from(ROUTES[routeKey].optionalParams),
|
||
expected.optional,
|
||
`${routeKey} 可选参数不得保留旧 ID 或状态旁路`,
|
||
);
|
||
assert.deepStrictEqual(
|
||
Array.from(ROUTES[routeKey].resultOperations),
|
||
[],
|
||
`${routeKey} 在真实写接口落地前不得预留伪成功结果`,
|
||
);
|
||
}
|
||
for (const routeKey of ["N01", "N02", "M01", "M02", "M03", "M04", "M05", "M06", "M07", "M08", "M09", "M10"]) {
|
||
assert.deepStrictEqual(
|
||
Array.from(ROUTES[routeKey].resultOperations),
|
||
[],
|
||
`${routeKey} 在真实写接口落地前不得预留伪成功结果`,
|
||
);
|
||
}
|
||
assert(ROUTES.G09.allowedSources.includes("G05"), "G05 审核中公开预览必须能进入 G09 申请进度");
|
||
for (const routeKey of ["G01", "F01", "M01"]) {
|
||
assert.strictEqual(ROUTES[routeKey].kind, "root", `${routeKey} 必须是业务根语义`);
|
||
}
|
||
assert.deepStrictEqual(routeKeys.filter((routeKey) => ROUTES[routeKey].kind === "single"), ["T03"], "T03 必须是唯一单实例页面");
|
||
|
||
for (const [index, routeKey] of routeKeys.entries()) {
|
||
const route = ROUTES[routeKey];
|
||
assert(Object.isFrozen(route), `${routeKey} 路由项必须冻结`);
|
||
assert.deepStrictEqual(Object.keys(route).sort(), [...routeFields].sort(), `${routeKey} 路由字段集合不精确`);
|
||
assert(routeKinds.has(route.kind), `${routeKey} 页面类型非法`);
|
||
assert.strictEqual(route.path, registeredPaths[index], `${routeKey} 路径与 pages.json 不一致`);
|
||
|
||
for (const field of ["requiredParams", "optionalParams", "allowedSources", "resultOperations"]) {
|
||
const values = route[field];
|
||
assert(Array.isArray(values), `${routeKey}.${field} 必须是数组`);
|
||
assert(Object.isFrozen(values), `${routeKey}.${field} 必须冻结`);
|
||
assert.strictEqual(new Set(values).size, values.length, `${routeKey}.${field} 不得重复`);
|
||
assert(values.every((value) => typeof value === "string" && value.length > 0), `${routeKey}.${field} 只能包含非空字符串`);
|
||
}
|
||
|
||
assert(Object.isFrozen(route.parentParamMap), `${routeKey}.parentParamMap 必须冻结`);
|
||
assert(route.parentParamMap && Object.getPrototypeOf(route.parentParamMap) === Object.prototype, `${routeKey}.parentParamMap 必须是普通对象`);
|
||
|
||
const businessParams = [...route.requiredParams, ...route.optionalParams];
|
||
assert.strictEqual(new Set(businessParams).size, businessParams.length, `${routeKey} 必填与可选参数不得重复`);
|
||
for (const name of businessParams) {
|
||
assert(/^[A-Za-z][A-Za-z0-9]*$/.test(name), `${routeKey} 参数名非法:${name}`);
|
||
assert(!forbiddenParams.has(name), `${routeKey} 不得声明旧参数 ${name}`);
|
||
}
|
||
|
||
for (const sourceKey of route.allowedSources) {
|
||
assert(getRoute(sourceKey), `${routeKey} 引用了不存在的来源 ${sourceKey}`);
|
||
assert.notStrictEqual(sourceKey, routeKey, `${routeKey} 不得把自身声明为来源`);
|
||
}
|
||
|
||
for (const operation of route.resultOperations) {
|
||
assert(/^[a-z]+(?:-[a-z]+)*$/.test(operation), `${routeKey} 结果操作必须使用小写 kebab-case:${operation}`);
|
||
}
|
||
|
||
if (route.parent === null) {
|
||
assert(["auth-root", "root"].includes(route.kind), `${routeKey} 根语义类型非法`);
|
||
assert.strictEqual(route.allowedSources.length, 0, `${routeKey} 根语义不得伪造允许来源`);
|
||
} else {
|
||
assert(typeof route.parent === "string" && getRoute(route.parent), `${routeKey} 引用了不存在的父页`);
|
||
assert(routeKinds.has(route.kind) && !["auth-root", "root"].includes(route.kind), `${routeKey} 非根页面类型非法`);
|
||
assert(routeKeys.indexOf(route.parent) < index, `${routeKey} 父页必须先于子页注册`);
|
||
assert(route.allowedSources.length > 0, `${routeKey} 非根页面必须声明至少一个允许来源`);
|
||
}
|
||
|
||
const mappedChildParams = [];
|
||
for (const [parentParam, childParam] of Object.entries(route.parentParamMap)) {
|
||
const parent = getRoute(route.parent);
|
||
assert(parent, `${routeKey} 声明父参数映射但没有父页`);
|
||
assert([...parent.requiredParams, ...parent.optionalParams].includes(parentParam), `${routeKey} 的父参数 ${parentParam} 未在父页声明`);
|
||
assert(businessParams.includes(childParam), `${routeKey} 的子参数 ${childParam} 未在本页声明`);
|
||
mappedChildParams.push(childParam);
|
||
}
|
||
assert.strictEqual(new Set(mappedChildParams).size, mappedChildParams.length, `${routeKey} 同一子参数不得映射到多个父参数`);
|
||
}
|
||
|
||
// 每条父链必须无环并最终到达已登记根页;这比维护第二张父页表更能约束结构安全。
|
||
for (const routeKey of routeKeys) {
|
||
const visited = new Set();
|
||
let cursor = routeKey;
|
||
while (cursor !== null) {
|
||
assert(!visited.has(cursor), `${routeKey} 父链存在环:${cursor}`);
|
||
visited.add(cursor);
|
||
const route = getRoute(cursor);
|
||
assert(route, `${routeKey} 父链包含未知路由:${cursor}`);
|
||
cursor = route.parent;
|
||
}
|
||
const terminal = Array.from(visited).pop();
|
||
assert(ROOT_ROUTE_KEYS.includes(terminal), `${routeKey} 父链未落到登记根页`);
|
||
}
|
||
|
||
// allowedSources 是进入权限边。所有页面都必须能从四根之一沿合法入口到达,
|
||
// 防止若干非根页面组成自洽但与真实产品入口隔离的来源环。
|
||
const sourceReachable = new Set(ROOT_ROUTE_KEYS);
|
||
let sourceReachabilityChanged = true;
|
||
while (sourceReachabilityChanged) {
|
||
sourceReachabilityChanged = false;
|
||
for (const routeKey of routeKeys) {
|
||
if (sourceReachable.has(routeKey)) continue;
|
||
if (ROUTES[routeKey].allowedSources.some((sourceKey) => sourceReachable.has(sourceKey))) {
|
||
sourceReachable.add(routeKey);
|
||
sourceReachabilityChanged = true;
|
||
}
|
||
}
|
||
}
|
||
assert.deepStrictEqual(Array.from(sourceReachable).sort(), routeKeys.slice().sort(), "存在无法从任何根语义合法进入的页面");
|
||
|
||
assert.strictEqual(getRoute(routeKeys[0]), ROUTES[routeKeys[0]], "getRoute 必须返回注册表中的同一冻结对象");
|
||
assert.strictEqual(getRoute("UNKNOWN"), null, "getRoute 对未知键必须返回 null");
|
||
for (const inheritedKey of ["constructor", "toString", "valueOf", "__proto__"]) {
|
||
assert.strictEqual(getRoute(inheritedKey), null, `getRoute 不得把原型链键 ${inheritedKey} 当成活动路由`);
|
||
}
|
||
for (const invalidKey of [null, 42, [routeKeys[0]], new String(routeKeys[0]), { toString: () => routeKeys[0] }]) {
|
||
assert.strictEqual(getRoute(invalidKey), null, "getRoute 只接受原始字符串路由键");
|
||
}
|
||
|
||
const samplePath = routePaths[Math.floor(routePaths.length / 2)];
|
||
const sampleKey = routeKeys[Math.floor(routeKeys.length / 2)];
|
||
assert.strictEqual(getRouteKeyByPath(samplePath.slice(1)), sampleKey, "路径查询必须接受无前导斜杠路径");
|
||
assert.strictEqual(getRouteKeyByPath(samplePath), sampleKey, "路径查询必须接受有前导斜杠路径");
|
||
assert.strictEqual(getRouteKeyByPath("/pages/unknown"), null, "未知路径必须返回 null");
|
||
for (const invalidPath of [null, 42, [samplePath], { toString: () => samplePath }]) {
|
||
assert.strictEqual(getRouteKeyByPath(invalidPath), null, "路径查询只接受原始字符串路径");
|
||
}
|
||
|
||
console.log(`NAVIGATION-ROUTES-CONTRACT PASS ROUTES=${routeKeys.length}`);
|
||
})().catch((error) => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|
||
NODE-CONTRACT-END #>
|
||
|
||
# 注册表是导航语义的唯一数据所有者。本合同真实导入 ESM,并以 pages.json 交叉和
|
||
# 通用图不变量验收边界,避免用一张必须同步维护的同构影子表复制合同。
|
||
$contractPath = Join-Path $root 'tests/navigation-routes-contract.ps1'
|
||
$contractFile = Get-Content -Raw -Encoding UTF8 -LiteralPath $contractPath
|
||
$startMarker = '<# NODE-CONTRACT-BEGIN'
|
||
$endMarker = 'NODE-CONTRACT-END #>'
|
||
$startIndex = $contractFile.IndexOf($startMarker)
|
||
$endIndex = $contractFile.IndexOf($endMarker)
|
||
if ($startIndex -lt 0 -or $endIndex -le $startIndex) {
|
||
throw '无法读取内嵌的路由注册表运行合同'
|
||
}
|
||
$startIndex += $startMarker.Length
|
||
$nodeContract = $contractFile.Substring(
|
||
$startIndex,
|
||
$endIndex - $startIndex
|
||
).Trim()
|
||
|
||
$previousErrorActionPreference = $ErrorActionPreference
|
||
$ErrorActionPreference = 'Continue'
|
||
$output = $nodeContract | & node - $pagesPath $routesPath 2>&1
|
||
$nodeExitCode = $LASTEXITCODE
|
||
$ErrorActionPreference = $previousErrorActionPreference
|
||
if ($nodeExitCode -ne 0) {
|
||
throw ($output -join "`n")
|
||
}
|
||
|
||
Write-Output ($output -join "`n")
|