Files
jiapuapp/tests/navigation-routes-contract.ps1
T
2026-07-29 18:16:08 +08:00

299 lines
15 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
$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, 53, "路由注册表必须恰好包含 53 条活动路由");
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 参数");
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(
Array.from(ROUTES.T03.resultOperations),
["member-open-requested", "member-updated"],
"T03 当前必须保留成员打开与编辑完成后的返回结果",
);
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")