408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { test } from "node:test";
|
|
|
|
const appRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
|
|
test("PDA 的 H5、App 和小程序统一使用 Sylvan 品牌标识", async () => {
|
|
const [config, loginPage, registerPage, aboutPage, indexHtml, fullLogo, markPng] =
|
|
await Promise.all([
|
|
readFile(path.join(appRoot, "src/config.js"), "utf8"),
|
|
readFile(path.join(appRoot, "src/pages/login.vue"), "utf8"),
|
|
readFile(path.join(appRoot, "src/pages/register.vue"), "utf8"),
|
|
readFile(path.join(appRoot, "src/pages/mine/about/index.vue"), "utf8"),
|
|
readFile(path.join(appRoot, "index.html"), "utf8"),
|
|
readFile(path.join(appRoot, "src/static/sylvan-logo.svg"), "utf8"),
|
|
readFile(path.join(appRoot, "src/static/sylvan-mark.png")),
|
|
]);
|
|
|
|
assert.match(config, /logo:\s*["']\/static\/sylvan-logo\.png["']/);
|
|
assert.match(loginPage, /globalConfig\.appInfo\.logo/);
|
|
assert.match(registerPage, /globalConfig\.appInfo\.logo/);
|
|
assert.match(aboutPage, /src=["']\/static\/sylvan-logo\.png["']/);
|
|
assert.match(indexHtml, /href=["']\/static\/favicon\.ico["']/);
|
|
assert.match(fullLogo, /viewBox="0 0 516\.1 194\.52"/);
|
|
assert.equal(markPng.subarray(1, 4).toString("ascii"), "PNG");
|
|
assert.doesNotMatch(`${config}\n${aboutPage}`, /\/static\/logo(?:200)?\.png/);
|
|
assert.doesNotMatch(fullLogo, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i);
|
|
});
|
|
|
|
const apiContracts = {
|
|
"src/api/business/inbound.js": [
|
|
["listInbound", "'/business/inbound/list'", "get"],
|
|
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
|
|
["scanInboundPallet", "'/business/inbound/scan/' + qrToken", "get"],
|
|
["getInbound", "'/business/inbound/' + inboundId", "get"],
|
|
["addInbound", "'/business/inbound'", "post"],
|
|
["updateInbound", "'/business/inbound'", "put"],
|
|
["confirmInbound", "'/business/inbound/' + inboundId + '/confirm'", "put"],
|
|
],
|
|
"src/api/business/filling.js": [
|
|
["listFilling", "'/business/filling/list'", "get"],
|
|
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
|
|
["scanFillingPallet", "'/business/filling/scan/' + qrToken", "get"],
|
|
["getFilling", "'/business/filling/' + fillingId", "get"],
|
|
["addFilling", "'/business/filling'", "post"],
|
|
],
|
|
"src/api/business/shipment.js": [
|
|
["listShipment", "'/business/shipment/list'", "get"],
|
|
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
|
|
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
|
|
["scanShipmentPallet", "'/business/shipment/scan/' + qrToken", "get"],
|
|
["getShipment", "'/business/shipment/' + shipmentId", "get"],
|
|
["addShipment", "'/business/shipment'", "post"],
|
|
["updateShipment", "'/business/shipment'", "put"],
|
|
["confirmShipment", "'/business/shipment/' + shipmentId + '/confirm'", "put"],
|
|
],
|
|
"src/api/business/usage.js": [
|
|
["listUsage", "'/business/usage/list'", "get"],
|
|
["getUsageLineOptions", "'/business/usage/line-options'", "get"],
|
|
["scanUsagePallet", "'/business/usage/scan/' + qrToken", "get"],
|
|
["getUsage", "'/business/usage/' + usageId", "get"],
|
|
["addUsage", "'/business/usage'", "post"],
|
|
],
|
|
"src/api/business/return.js": [
|
|
["listReturn", "'/business/return/list'", "get"],
|
|
["getReturnWarehouseOptions", "'/business/return/warehouse-options'", "get"],
|
|
["scanReturnPallet", "'/business/return/scan/' + qrToken", "get"],
|
|
["requestReturn", "'/business/return/request'", "post"],
|
|
["getReturn", "'/business/return/' + returnId", "get"],
|
|
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
|
|
],
|
|
};
|
|
|
|
function sourceForFunction(source, functionName) {
|
|
const match = source.match(
|
|
new RegExp(`export function ${escapeRegExp(functionName)}\\s*\\(`),
|
|
);
|
|
const start = match?.index ?? -1;
|
|
assert.notEqual(start, -1, `${functionName} 导出不存在`);
|
|
const end = source.indexOf("\n}", start);
|
|
return source.slice(start, end === -1 ? source.length : end + 2);
|
|
}
|
|
|
|
test("五个 PDA API 模块使用现有业务路径和 HTTP 方法", async (t) => {
|
|
for (const [relativePath, contracts] of Object.entries(apiContracts)) {
|
|
await t.test(relativePath, async () => {
|
|
const source = await readFile(path.join(appRoot, relativePath), "utf8");
|
|
for (const [functionName, url, method] of contracts) {
|
|
const functionSource = sourceForFunction(source, functionName);
|
|
const normalizedFunctionSource = functionSource.replaceAll("'", '"');
|
|
const normalizedUrl = url.replaceAll("'", '"');
|
|
assert.match(
|
|
normalizedFunctionSource,
|
|
new RegExp(`url:\\s*${escapeRegExp(normalizedUrl)}`),
|
|
);
|
|
assert.match(functionSource, new RegExp(`method:\\s*["']${method}["']`));
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
test("扫码值只去除首尾空白并拒绝空值", async () => {
|
|
const { normalizeScanValue } = await importSource("src/utils/palletScan.js");
|
|
assert.equal(normalizeScanValue(" QR-001\n"), "QR-001");
|
|
assert.throws(() => normalizeScanValue(" \n\t "), /二维码不能为空/);
|
|
});
|
|
|
|
test("同一扫码值只在短时窗口内被识别为重复", async () => {
|
|
const { isDuplicateScan } = await importSource("src/utils/palletScan.js");
|
|
assert.equal(isDuplicateScan("QR-001", "QR-001", 1200, 1000, 500), true);
|
|
assert.equal(isDuplicateScan("QR-001", "QR-002", 1200, 1000, 500), false);
|
|
assert.equal(isDuplicateScan("QR-001", "QR-001", 1801, 1000, 500), false);
|
|
});
|
|
|
|
test("幂等键优先使用 randomUUID,回退时使用 UUID v4 安全随机字节", async () => {
|
|
const { createIdempotencyKey } = await importSource("src/utils/palletScan.js");
|
|
const expected = "12345678-1234-4123-8123-123456789abc";
|
|
assert.equal(createIdempotencyKey({ randomUUID: () => expected }), expected);
|
|
assert.equal(
|
|
createIdempotencyKey({
|
|
getRandomValues(bytes) {
|
|
bytes.fill(0);
|
|
return bytes;
|
|
},
|
|
}),
|
|
"00000000-0000-4000-8000-000000000000",
|
|
);
|
|
});
|
|
|
|
test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async () => {
|
|
const source = await readFile(
|
|
path.join(appRoot, "src/components/business/PalletScanInput.vue"),
|
|
"utf8",
|
|
);
|
|
assert.match(source, /@confirm=["']handleSubmit["']/);
|
|
assert.match(source, /normalizeScanValue/);
|
|
assert.match(source, /isDuplicateScan/);
|
|
assert.match(source, /emit\(["']scan["']/);
|
|
assert.match(source, /emit\(["']invalid["']/);
|
|
assert.match(source, /emit\(["']duplicate["']/);
|
|
assert.match(source, /loading/);
|
|
assert.doesNotMatch(source, /uni\.scanCode|camera|摄像头/);
|
|
});
|
|
|
|
test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
|
|
const pages = JSON.parse(
|
|
await readFile(path.join(appRoot, "src/pages.json"), "utf8"),
|
|
);
|
|
const pagePaths = new Set(pages.pages.map((page) => page.path));
|
|
for (const pagePath of [
|
|
"pages/business/inbound/index",
|
|
"pages/business/filling/index",
|
|
"pages/business/shipment/index",
|
|
"pages/business/usage/index",
|
|
"pages/business/return/index",
|
|
]) {
|
|
assert.equal(pagePaths.has(pagePath), true, `${pagePath} 路由缺失`);
|
|
}
|
|
|
|
const [workSource, menuSource] = await Promise.all([
|
|
readFile(path.join(appRoot, "src/pages/work/index.vue"), "utf8"),
|
|
readFile(path.join(appRoot, "src/config/businessMenu.js"), "utf8"),
|
|
]);
|
|
assert.match(workSource, /hasPermi\(entry\.permission\)/);
|
|
assert.match(menuSource, /business:inbound:list/);
|
|
assert.match(menuSource, /business:filling:list/);
|
|
assert.match(menuSource, /business:shipment:list/);
|
|
assert.match(menuSource, /business:usage:list/);
|
|
assert.match(menuSource, /business:return:list/);
|
|
assert.match(menuSource, /pages\/business\/inbound\/index/);
|
|
assert.match(menuSource, /pages\/business\/filling\/index/);
|
|
assert.match(menuSource, /pages\/business\/shipment\/index/);
|
|
assert.match(menuSource, /pages\/business\/usage\/index/);
|
|
assert.match(menuSource, /pages\/business\/return\/index/);
|
|
});
|
|
|
|
test("首页展示真实运营驾驶舱,不保留模板入口和虚假统计", async () => {
|
|
const source = await readFile(path.join(appRoot, "src/pages/index.vue"), "utf8");
|
|
assert.match(source, /listScanExceptions/);
|
|
assert.match(source, /最近扫码异常/);
|
|
assert.match(source, /TestBatchBanner/);
|
|
assert.doesNotMatch(
|
|
source,
|
|
/快捷入口|新闻|计划|请假申请|周报|工作效率|新通知|待办任务|85%/,
|
|
);
|
|
});
|
|
|
|
test("工作台只展示真实业务入口并使用后端列表权限", async () => {
|
|
const [workSource, menuModule] = await Promise.all([
|
|
readFile(path.join(appRoot, "src/pages/work/index.vue"), "utf8"),
|
|
importSource("src/config/businessMenu.js"),
|
|
]);
|
|
|
|
assert.deepEqual(
|
|
menuModule.businessMenuEntries.map(({ title, permission, path: entryPath }) => ({
|
|
title,
|
|
permission,
|
|
path: entryPath,
|
|
})),
|
|
[
|
|
{
|
|
title: "仓库入库",
|
|
permission: "business:inbound:list",
|
|
path: "/pages/business/inbound/index",
|
|
},
|
|
{
|
|
title: "工厂装填",
|
|
permission: "business:filling:list",
|
|
path: "/pages/business/filling/index",
|
|
},
|
|
{
|
|
title: "发货出库",
|
|
permission: "business:shipment:list",
|
|
path: "/pages/business/shipment/index",
|
|
},
|
|
{
|
|
title: "客户现场使用",
|
|
permission: "business:usage:list",
|
|
path: "/pages/business/usage/index",
|
|
},
|
|
{
|
|
title: "退回仓库",
|
|
permission: "business:return:list",
|
|
path: "/pages/business/return/index",
|
|
},
|
|
],
|
|
);
|
|
assert.match(workSource, /businessMenuEntries/);
|
|
assert.match(workSource, /hasPermi\(entry\.permission\)/);
|
|
assert.match(workSource, /navigateTo\(entry\.path\)/);
|
|
assert.doesNotMatch(workSource, /AI 生产力|智能对话|图像生成|系统管理|handleBuilding|模块建设中/);
|
|
});
|
|
|
|
test("运营驾驶舱 API 对接现有扫码异常接口", async () => {
|
|
const source = await readFile(path.join(appRoot, "src/api/business/dashboard.js"), "utf8");
|
|
assert.match(source, /export function listScanExceptions\(/);
|
|
assert.match(source, /url:\s*["']\/business\/dashboard\/scan-exceptions["']/);
|
|
assert.match(source, /method:\s*["']get["']/);
|
|
});
|
|
|
|
test("APP 页面标题与后端业务菜单名称一致", async () => {
|
|
const [pages, fillingSource] = await Promise.all([
|
|
JSON.parse(await readFile(path.join(appRoot, "src/pages.json"), "utf8")),
|
|
readFile(path.join(appRoot, "src/pages/business/filling/index.vue"), "utf8"),
|
|
]);
|
|
const fillingPage = pages.pages.find((page) => page.path === "pages/business/filling/index");
|
|
assert.equal(fillingPage?.style?.navigationBarTitleText, "工厂装填");
|
|
assert.match(fillingSource, />工厂装填</);
|
|
});
|
|
|
|
test("PDA 测试模式附加批次请求头并在五个业务页持续提示", async () => {
|
|
const requestSource = await readFile(
|
|
path.join(appRoot, "src/utils/request.js"),
|
|
"utf8",
|
|
);
|
|
assert.match(requestSource, /X-Test-Batch-Id/);
|
|
assert.match(requestSource, /TEST_BATCH_UNAVAILABLE/);
|
|
assert.match(requestSource, /uni\.\$emit\(["']test-batch-unavailable["']/);
|
|
assert.match(requestSource, /reLaunch\(\{\s*url:\s*["']\/pages\/work\/index["']/);
|
|
|
|
for (const page of ["inbound", "filling", "shipment", "usage", "return"]) {
|
|
const source = await readFile(
|
|
path.join(appRoot, `src/pages/business/${page}/index.vue`),
|
|
"utf8",
|
|
);
|
|
assert.match(source, /TestBatchBanner/);
|
|
}
|
|
});
|
|
|
|
test("PDA 不再暴露入库和发货草稿物理删除", async () => {
|
|
for (const file of ["src/api/business/inbound.js", "src/api/business/shipment.js"]) {
|
|
const source = await readFile(path.join(appRoot, file), "utf8");
|
|
assert.doesNotMatch(source, /delInbound|delShipment|method:\s*["']delete["']/);
|
|
}
|
|
for (const page of ["inbound", "shipment"]) {
|
|
const source = await readFile(
|
|
path.join(appRoot, `src/pages/business/${page}/index.vue`),
|
|
"utf8",
|
|
);
|
|
assert.doesNotMatch(source, /deleteDraft|deleteLoading|business:(?:inbound|shipment):remove/);
|
|
}
|
|
});
|
|
|
|
test("登录页只预填账号,不把管理员密码写入源码", async () => {
|
|
const source = await readFile(path.join(appRoot, "src/pages/login.vue"), "utf8");
|
|
assert.match(source, /username:\s*["']admin["']/);
|
|
assert.match(source, /password:\s*["']["']/);
|
|
assert.doesNotMatch(source, /password:\s*["'][^"']+["']/);
|
|
});
|
|
|
|
test("PDA 后端地址支持 VITE_APP_BASE_API 并保留本地默认值", async () => {
|
|
const source = await readFile(path.join(appRoot, "src/config.js"), "utf8");
|
|
assert.match(source, /import\.meta\.env\.VITE_APP_BASE_API/);
|
|
assert.match(source, /VITE_APP_BASE_API\s*\|\|\s*["']http:\/\/localhost:9099["']/);
|
|
});
|
|
|
|
test("入库页面遵循 UNSTOCKED 扫码、草稿和幂等确认契约", async () => {
|
|
const source = await readFile(
|
|
path.join(appRoot, "src/pages/business/inbound/index.vue"),
|
|
"utf8",
|
|
);
|
|
assert.match(source, /PalletScanInput/);
|
|
assert.match(source, /scanInboundPallet/);
|
|
assert.match(source, /UNSTOCKED/);
|
|
assert.match(source, /warehouseId/);
|
|
assert.match(source, /palletIds/);
|
|
assert.match(source, /getInbound\(/);
|
|
assert.match(source, /updateInbound/);
|
|
assert.match(source, /confirmInbound/);
|
|
assert.match(source, /idempotencyKey/);
|
|
assert.match(source, /business:inbound:(?:query|add|edit|confirm)/);
|
|
});
|
|
|
|
test("发货页面遵循来源仓库、目标现场和整单确认契约", async () => {
|
|
const source = await readFile(
|
|
path.join(appRoot, "src/pages/business/shipment/index.vue"),
|
|
"utf8",
|
|
);
|
|
assert.match(source, /PalletScanInput/);
|
|
assert.match(source, /scanShipmentPallet/);
|
|
assert.match(source, /FILLED_IN_WAREHOUSE/);
|
|
assert.match(source, /remainingPackages/);
|
|
assert.match(source, /sourceWarehouseId/);
|
|
assert.match(source, /targetSiteId/);
|
|
assert.match(source, /palletIds/);
|
|
assert.match(source, /getShipment\(/);
|
|
assert.match(source, /updateShipment/);
|
|
assert.match(source, /confirmShipment/);
|
|
assert.match(source, /idempotencyKey/);
|
|
assert.match(source, /business:shipment:(?:query|add|edit|confirm)/);
|
|
});
|
|
|
|
test("装填页面遵循空托盘状态和 FillingCreateModel 字段契约", async () => {
|
|
const source = await readFile(
|
|
path.join(appRoot, "src/pages/business/filling/index.vue"),
|
|
"utf8",
|
|
);
|
|
assert.match(source, /PalletScanInput/);
|
|
assert.match(source, /scanFillingPallet/);
|
|
assert.match(source, /EMPTY_IN_WAREHOUSE/);
|
|
for (const field of [
|
|
"qrToken",
|
|
"warehouseId",
|
|
"strainId",
|
|
"batchNo",
|
|
"packageSpec",
|
|
"initialPackages",
|
|
"idempotencyKey",
|
|
]) {
|
|
assert.match(source, new RegExp(field));
|
|
}
|
|
assert.match(source, /addFilling/);
|
|
assert.match(source, /business:filling:(?:query|add)/);
|
|
});
|
|
|
|
test("现场使用页面遵循产线绑定、余量和使用结果契约", async () => {
|
|
const source = await readFile(
|
|
path.join(appRoot, "src/pages/business/usage/index.vue"),
|
|
"utf8",
|
|
);
|
|
assert.match(source, /PalletScanInput/);
|
|
assert.match(source, /scanUsagePallet/);
|
|
assert.match(source, /AT_SITE_UNUSED/);
|
|
assert.match(source, /AT_SITE_PARTIAL/);
|
|
assert.match(source, /currentLineId/);
|
|
assert.match(source, /getUsageLineOptions/);
|
|
assert.match(source, /usedPackages/);
|
|
assert.match(source, /remainingPackages/);
|
|
assert.match(source, /flowState/);
|
|
assert.match(source, /addUsage/);
|
|
assert.match(source, /idempotencyKey/);
|
|
assert.match(source, /business:usage:(?:query|add)/);
|
|
});
|
|
|
|
test("退回页面实现申请与确认两阶段的集合和幂等契约", async () => {
|
|
const source = await readFile(
|
|
path.join(appRoot, "src/pages/business/return/index.vue"),
|
|
"utf8",
|
|
);
|
|
assert.match(source, /PalletScanInput/);
|
|
assert.match(source, /scanReturnPallet/);
|
|
assert.match(source, /mode:\s*["']request["']/);
|
|
assert.match(source, /mode:\s*["']confirm["']/);
|
|
assert.match(source, /sourceSiteId/);
|
|
assert.match(source, /targetWarehouseId/);
|
|
assert.match(source, /palletIds/);
|
|
assert.match(source, /scannedPalletIds/);
|
|
assert.match(source, /requestReturn/);
|
|
assert.match(source, /getReturn\(/);
|
|
assert.match(source, /confirmReturn/);
|
|
assert.match(source, /sameIdSet/);
|
|
assert.match(source, /REQUESTED/);
|
|
assert.match(source, /requestIdempotencyKey/);
|
|
assert.match(source, /confirmIdempotencyKey/);
|
|
assert.match(source, /business:return:(?:query|request|confirm)/);
|
|
});
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
async function importSource(relativePath) {
|
|
const source = await readFile(path.join(appRoot, relativePath), "utf8");
|
|
return import(`data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`);
|
|
}
|