1110 lines
57 KiB
JavaScript
1110 lines
57 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import { readFile } from "node:fs/promises";
|
||
import { webcrypto } from "node:crypto";
|
||
import { test } from "node:test";
|
||
import * as Vue from "vue";
|
||
import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc";
|
||
|
||
// 执行真实 SFC script setup 和 API 封装,仅替换网络、页面生命周期与提示边界。
|
||
const read = (file) => readFile(new URL(`../src/${file}`, import.meta.url), "utf8");
|
||
const pageSource = await read("pages/business/usage/index.vue");
|
||
const apiSource = await read("api/business/usage.js");
|
||
const requestSource = await read("utils/request.js");
|
||
const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read("utils/palletScan.js"))}`);
|
||
const { descriptor } = parse(pageSource);
|
||
const script = compileScript(descriptor, { id: "usage-test", genDefaultAs: "__component" });
|
||
const template = compileTemplate({
|
||
source: descriptor.template.content,
|
||
filename: "usage/index.vue",
|
||
id: "usage-test",
|
||
compilerOptions: {
|
||
bindingMetadata: script.bindings,
|
||
isCustomElement: (tag) => ["view", "text", "scroll-view", "picker"].includes(tag),
|
||
},
|
||
});
|
||
assert.deepEqual(template.errors, []);
|
||
|
||
function bindImports(source) {
|
||
return source.replace(/import\s+([\s\S]*?)\s+from\s+["']([^"']+)["'];?/g, (_, names, module) => {
|
||
const binding = names.startsWith("{") ? names.replace(/\s+as\s+/g, ": ") : `{ default: ${names} }`;
|
||
return `const ${binding} = __modules[${JSON.stringify(module)}];`;
|
||
});
|
||
}
|
||
|
||
function deferred() {
|
||
let resolve, reject;
|
||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
async function flush() {
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
await Vue.nextTick();
|
||
}
|
||
|
||
// 在真实 Vue 渲染上下文执行模板;轻量宿主不依赖浏览器或新增 DOM 依赖。
|
||
const renderer = Vue.createRenderer({
|
||
createElement: () => ({ addEventListener() {}, removeEventListener() {} }),
|
||
createText: () => ({}),
|
||
createComment: () => ({}),
|
||
insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {},
|
||
parentNode: () => null,
|
||
nextSibling: () => null,
|
||
});
|
||
function renderOnce(render) {
|
||
let vnode;
|
||
const app = renderer.createApp({ render() { vnode = render(); return vnode; } });
|
||
app.mount({});
|
||
app.unmount();
|
||
return vnode;
|
||
}
|
||
|
||
const tunnelOptions = [
|
||
{ id: 31, code: "SD-01", name: "一号隧道" },
|
||
{ id: 32, code: "SD-02", name: "二号隧道" },
|
||
];
|
||
const lineOptions = [{ id: 41, code: "CX-01", name: "一号产线" }];
|
||
function pallet(overrides = {}) {
|
||
return {
|
||
palletId: 11, palletCode: "托盘-001", flowState: "AT_SITE_UNUSED",
|
||
warehouseId: null, siteId: 21, tunnelId: null, traceNo: null,
|
||
currentLineId: null, activeFillingId: 51, strainId: 61,
|
||
strainCode: "JZ-01", strainName: "菌种一", batchNo: "PC-001",
|
||
packageSpec: "标准包", capacityPackages: 100, remainingPackages: 100,
|
||
allowedActions: ["enter_tunnel"], ...overrides,
|
||
};
|
||
}
|
||
const boundPallet = (overrides = {}) => pallet({
|
||
tunnelId: 31, traceNo: "INTERNAL-TRACE-NOT-FOR-OPERATORS",
|
||
allowedActions: ["usage"], ...overrides,
|
||
});
|
||
|
||
function createDeviceStorage() {
|
||
return new Map([["storage_data", { user_id: 7, test_batch_id: "" }]]);
|
||
}
|
||
|
||
function createPage({ scan = () => pallet(), request: requestOverride, canOperate = true, deviceStorage = createDeviceStorage(), storageWriteError, confirm = () => false } = {}) {
|
||
const requests = [], messages = [], confirmations = [];
|
||
const hooks = { onShow: [], onHide: [], onUnload: [] };
|
||
const request = (config) => {
|
||
requests.push(structuredClone(config));
|
||
const overridden = requestOverride?.(config);
|
||
if (overridden !== undefined) return overridden;
|
||
if (config.url.includes("/scan/")) return Promise.resolve({ data: scan(decodeURIComponent(config.url.split("/scan/")[1])) });
|
||
if (config.url === "/business/usage-fifo/recommendation") return Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: "当前无先到先用建议" } });
|
||
if (config.url.endsWith("/tunnel-options")) return Promise.resolve({ data: tunnelOptions });
|
||
if (config.url.endsWith("/line-options")) return Promise.resolve({ data: lineOptions });
|
||
if (config.url.endsWith("/list")) return Promise.resolve({ rows: [] });
|
||
if (config.url.endsWith("/enter-tunnel")) return Promise.resolve({ data: {
|
||
palletId: 11, tunnelId: config.data.tunnelId, traceNo: "INTERNAL-TRACE-NOT-FOR-OPERATORS",
|
||
} });
|
||
if (config.url === "/business/usage" && config.method === "post") return Promise.resolve({ data: {
|
||
usageId: 71, remainingPackages: 83, flowState: "AT_SITE_PARTIAL",
|
||
} });
|
||
throw new Error(`测试中出现未处理请求: ${config.method} ${config.url}`);
|
||
};
|
||
const exportedNames = [...apiSource.matchAll(/export function (\w+)/g)].map((match) => match[1]);
|
||
const api = new Function("__modules", `${bindImports(apiSource).replace(/export function /g, "function ")}\nreturn { ${exportedNames.join(",")} };`)({ "@/utils/request": { default: request } });
|
||
const modules = {
|
||
vue: { ...Vue, getCurrentInstance: () => ({ proxy: {
|
||
$auth: { hasPermi: () => true, hasPermiOr: () => true, hasPermiAnd: () => canOperate },
|
||
$modal: { msg: (msg) => messages.push(msg), msgSuccess: (msg) => messages.push(msg) },
|
||
} }) },
|
||
"@dcloudio/uni-app": Object.fromEntries(Object.keys(hooks).map((name) => [name, (fn) => hooks[name].push(fn)])),
|
||
"@/components/business/PalletScanInput.vue": { default: { name: "PalletScanInput", inheritAttrs: false, render: () => null } },
|
||
"@/components/business/TestBatchBanner.vue": { default: { name: "TestBatchBanner", render: () => null } },
|
||
"@/api/business/usage": api,
|
||
"@/utils/palletScan": { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) },
|
||
"@/config": { default: { baseUrl: "http://usage-test.invalid" } },
|
||
"@/utils/constant": { default: { id: "user_id", testBatchId: "test_batch_id" } },
|
||
"@/utils/storage": { default: { get: (key) => deviceStorage.get("storage_data")?.[key] || "" } },
|
||
};
|
||
const uni = {
|
||
showModal(options) {
|
||
const prompt = { title: options.title, content: options.content, confirmText: options.confirmText, cancelText: options.cancelText };
|
||
confirmations.push(prompt);
|
||
Promise.resolve().then(() => confirm(prompt)).then(
|
||
(confirmed) => options.success({ confirm: confirmed, cancel: !confirmed }),
|
||
(error) => options.fail(error),
|
||
);
|
||
},
|
||
getStorageSync: (key) => structuredClone(deviceStorage.get(key)),
|
||
setStorageSync(key, value) {
|
||
if (storageWriteError) throw storageWriteError;
|
||
// uni 存储使用序列化值,不能用 structuredClone 直接克隆 Vue Proxy。
|
||
deviceStorage.set(key, JSON.parse(JSON.stringify(value)));
|
||
},
|
||
};
|
||
const component = new Function("__modules", "uni", `${bindImports(script.content)}\nreturn __component;`)(modules, uni);
|
||
const scope = Vue.effectScope();
|
||
const state = scope.run(() => component.setup({}, { expose() {} }));
|
||
const render = new Function("__modules", `${bindImports(template.code).replace("export function render", "function render")}\nreturn render;`)(modules);
|
||
return {
|
||
state, api, requests, messages, confirmations,
|
||
post: (url = "/business/usage") => requests.filter((item) => item.url === url && item.method === "post"),
|
||
async scan(value = "QR-001") { state.handleUsageScan(value); await flush(); },
|
||
async lifecycle(name) { hooks[name].forEach((fn) => fn()); await flush(); },
|
||
render: () => renderOnce(() => render({}, [], {}, Vue.proxyRefs(state), {}, {})),
|
||
close: () => scope.stop(),
|
||
};
|
||
}
|
||
|
||
function nodes(vnode) {
|
||
if (Array.isArray(vnode)) return vnode.flatMap(nodes);
|
||
if (!vnode || typeof vnode !== "object") return [];
|
||
return [vnode, ...nodes(vnode.children)];
|
||
}
|
||
const visibleText = (vnode) => nodes(vnode).map((node) => typeof node.children === "string" ? node.children : "").join(" ");
|
||
const button = (page, text) => nodes(page.render()).find((node) => node.type === "button" && visibleText(node).includes(text));
|
||
const chooseTunnel = (page, index = 0) => {
|
||
assert.equal(typeof page.state.handleTunnelChange, "function", "页面必须能选择隧道");
|
||
page.state.handleTunnelChange({ detail: { value: String(index) } });
|
||
};
|
||
|
||
test("APP API 对接当前后端隧道选项与独立入隧端点", async () => {
|
||
const page = createPage();
|
||
assert.equal(typeof page.api.getUsageTunnelOptions, "function", "缺少隧道选项 API");
|
||
assert.equal(typeof page.api.enterUsageTunnel, "function", "缺少入隧 API");
|
||
await page.api.getUsageTunnelOptions(21);
|
||
await page.api.enterUsageTunnel({ qrToken: "QR-001", tunnelId: 31, idempotencyKey: "entry-key" });
|
||
assert.deepEqual(page.requests, [
|
||
{ url: "/business/usage/tunnel-options", method: "get", params: { siteId: 21 } },
|
||
{ url: "/business/usage/enter-tunnel", method: "post", data: { qrToken: "QR-001", tunnelId: 31, idempotencyKey: "entry-key" } },
|
||
]);
|
||
page.close();
|
||
});
|
||
|
||
test("未入隧托盘即使填好产线和包数也不能发起使用请求", async () => {
|
||
const page = createPage();
|
||
await page.scan();
|
||
page.state.form.lineId = 41;
|
||
page.state.form.usedPackages = "10";
|
||
page.state.submitUsage();
|
||
await flush();
|
||
assert.equal(page.post().length, 0);
|
||
assert.match(page.state.submitError.value, /入隧/);
|
||
assert.equal(button(page, "确认使用").props.disabled, true);
|
||
page.close();
|
||
});
|
||
|
||
test("已入隧托盘使用必须携带 tunnelId,产线允许为空", async () => {
|
||
const page = createPage({ scan: () => boundPallet() });
|
||
await page.scan();
|
||
page.state.form.usedPackages = "10";
|
||
page.state.submitUsage();
|
||
await flush();
|
||
assert.equal(page.post().length, 1, "不应强制产线");
|
||
const { idempotencyKey, ...payload } = page.post()[0].data;
|
||
assert.deepEqual(payload, { qrToken: "QR-001", tunnelId: 31, lineId: null, usedPackages: 10 });
|
||
assert.ok(idempotencyKey);
|
||
assert.equal(page.state.usageResult.value.remainingPackages, 83, "余量使用服务端结果,不能本地减算成90");
|
||
page.state.submitUsage();
|
||
await flush();
|
||
assert.equal(page.post().length, 1, "成功后不可重复记录使用");
|
||
page.close();
|
||
});
|
||
|
||
test("已有产线保持锁定,规范 qrToken 优先于输入值", async () => {
|
||
const page = createPage({ scan: () => boundPallet({ currentLineId: 41, qrToken: "CANONICAL-QR" }) });
|
||
await page.scan("托盘-001");
|
||
page.state.handleLineChange({ detail: { value: "0" } });
|
||
page.state.form.usedPackages = "10";
|
||
page.state.submitUsage();
|
||
await flush();
|
||
assert.equal(page.post()[0]?.data.tunnelId, 31);
|
||
assert.equal(page.post()[0]?.data.lineId, 41);
|
||
assert.equal(page.post()[0]?.data.qrToken, "CANONICAL-QR");
|
||
page.close();
|
||
});
|
||
|
||
test("选择隧道后独立提交入隧,成功才锁定并允许使用,普通页面不展示 trace", async () => {
|
||
const entry = deferred();
|
||
const page = createPage({ request: (config) => config.url.endsWith("/enter-tunnel") ? entry.promise : undefined });
|
||
await page.scan();
|
||
assert.ok(page.requests.some((item) => item.url.endsWith("/tunnel-options") && item.params.siteId === 21));
|
||
chooseTunnel(page);
|
||
const text = visibleText(page.render());
|
||
assert.match(text, /一号隧道/);
|
||
assert.match(text, /SD-01/);
|
||
page.state.enterTunnel();
|
||
page.state.enterTunnel();
|
||
page.state.form.usedPackages = "10";
|
||
page.state.submitUsage();
|
||
assert.equal(page.post("/business/usage/enter-tunnel").length, 1);
|
||
assert.equal(page.post().length, 0);
|
||
const { idempotencyKey, ...payload } = page.post("/business/usage/enter-tunnel")[0].data;
|
||
assert.deepEqual(payload, { qrToken: "QR-001", tunnelId: 31 });
|
||
assert.ok(idempotencyKey);
|
||
entry.resolve({ data: { palletId: 11, tunnelId: 31, traceNo: "INTERNAL-TRACE-NOT-FOR-OPERATORS" } });
|
||
await flush();
|
||
assert.equal(page.state.tunnelLocked.value, true);
|
||
chooseTunnel(page, 1);
|
||
assert.equal(page.state.form.tunnelId, 31);
|
||
assert.doesNotMatch(visibleText(page.render()), /INTERNAL-TRACE|溯源号/);
|
||
page.state.submitUsage();
|
||
await flush();
|
||
assert.equal(page.post().length, 1);
|
||
assert.notEqual(page.post()[0].data.idempotencyKey, idempotencyKey);
|
||
page.close();
|
||
});
|
||
|
||
test("已有隧道扫码直接锁定,仍加载真实名称编号", async () => {
|
||
const page = createPage({ scan: () => boundPallet() });
|
||
await page.scan();
|
||
assert.equal(page.state.tunnelLocked?.value, true);
|
||
chooseTunnel(page, 1);
|
||
assert.equal(page.state.form.tunnelId, 31);
|
||
page.state.enterTunnel();
|
||
assert.equal(page.post("/business/usage/enter-tunnel").length, 0);
|
||
assert.match(visibleText(page.render()), /一号隧道/);
|
||
assert.match(visibleText(page.render()), /SD-01/);
|
||
page.close();
|
||
});
|
||
|
||
test("入隧结果未知时保持原键,产线/包数不影响重试,换隧道需先核对", async () => {
|
||
const page = createPage({ request: (config) => config.url.endsWith("/enter-tunnel") ? Promise.reject(new Error("网络超时")) : undefined });
|
||
await page.scan();
|
||
chooseTunnel(page);
|
||
page.state.enterTunnel();
|
||
await flush();
|
||
assert.equal(page.state.tunnelLocked.value, false);
|
||
page.state.form.usedPackages = "12";
|
||
page.state.markPayloadChanged();
|
||
page.state.handleLineChange({ detail: { value: "1" } });
|
||
page.state.enterTunnel();
|
||
await flush();
|
||
const entries = page.post("/business/usage/enter-tunnel");
|
||
assert.equal(entries[1].data.idempotencyKey, entries[0].data.idempotencyKey);
|
||
chooseTunnel(page, 1);
|
||
page.state.enterTunnel();
|
||
await flush();
|
||
assert.equal(page.post("/business/usage/enter-tunnel").length, 2);
|
||
assert.match(page.state.operationError.value, /未确认|核对/);
|
||
chooseTunnel(page, 0);
|
||
await page.state.enterTunnel();
|
||
assert.equal(page.post("/business/usage/enter-tunnel")[2].data.idempotencyKey, entries[0].data.idempotencyKey);
|
||
page.close();
|
||
});
|
||
|
||
test("使用结果未知时重试键不变,修改包数或产线不能绕过原操作核对", async () => {
|
||
const page = createPage({ scan: () => boundPallet(), request: (config) => config.url === "/business/usage" ? Promise.reject(new Error("网络超时")) : undefined });
|
||
await page.scan();
|
||
page.state.form.usedPackages = "10";
|
||
page.state.submitUsage();
|
||
await flush();
|
||
page.state.markPayloadChanged();
|
||
page.state.submitUsage();
|
||
await flush();
|
||
assert.equal(page.post().length, 2);
|
||
assert.equal(page.post()[1].data.idempotencyKey, page.post()[0].data.idempotencyKey);
|
||
page.state.form.usedPackages = "11";
|
||
page.state.markPayloadChanged();
|
||
page.state.submitUsage();
|
||
await flush();
|
||
assert.equal(page.post().length, 2);
|
||
page.state.handleLineChange({ detail: { value: "1" } });
|
||
page.state.submitUsage();
|
||
await flush();
|
||
assert.equal(page.post().length, 2);
|
||
page.state.handleLineChange({ detail: { value: "0" } });
|
||
assert.equal(page.state.form.lineId, null, "可选产线能恢复为空");
|
||
page.state.form.usedPackages = "10";
|
||
await page.state.submitUsage();
|
||
assert.deepEqual(page.post()[2].data, page.post()[0].data);
|
||
page.close();
|
||
});
|
||
|
||
test("无隧道选项不能入隧;没有产线选项仍能按隧道使用", async () => {
|
||
const page = createPage({ request: (config) => config.url.endsWith("-options") ? Promise.resolve({ data: [] }) : undefined });
|
||
await page.scan();
|
||
assert.match(page.state.operationError.value, /隧道/);
|
||
assert.equal(typeof page.state.enterTunnel, "function");
|
||
page.state.enterTunnel();
|
||
assert.equal(page.post("/business/usage/enter-tunnel").length, 0);
|
||
page.close();
|
||
const bound = createPage({ scan: () => boundPallet(), request: (config) => config.url.endsWith("/line-options") ? Promise.resolve({ data: [] }) : undefined });
|
||
await bound.scan();
|
||
bound.state.form.usedPackages = "5";
|
||
bound.state.submitUsage();
|
||
await flush();
|
||
assert.equal(bound.post().length, 1);
|
||
bound.close();
|
||
});
|
||
|
||
test("新扫码会话不接受旧扫码与选项的晚到结果", async () => {
|
||
const oldScan = deferred(), oldOptions = deferred();
|
||
const page = createPage({
|
||
scan: () => boundPallet({ palletId: 12, siteId: 22 }),
|
||
request: (config) => {
|
||
if (config.url.endsWith("/scan/OLD")) return oldScan.promise;
|
||
if (config.url.endsWith("/tunnel-options") && config.params.siteId === 21) return oldOptions.promise;
|
||
},
|
||
});
|
||
await page.scan("OLD");
|
||
page.state.resetUsageOperation();
|
||
await page.scan("NEW");
|
||
oldScan.resolve({ data: pallet() });
|
||
await flush();
|
||
assert.equal(page.state.scannedPallet.value.palletId, 12);
|
||
page.state.resetUsageOperation();
|
||
// 再创建一个选项请求尚未结束的旧会话。
|
||
const optionsPage = createPage({ request: (config) => config.url.endsWith("/tunnel-options") ? oldOptions.promise : undefined });
|
||
await optionsPage.scan();
|
||
optionsPage.state.resetUsageOperation();
|
||
oldOptions.resolve({ data: tunnelOptions });
|
||
await flush();
|
||
assert.deepEqual(optionsPage.state.tunnelOptions?.value, []);
|
||
assert.equal(optionsPage.state.optionLoading.value, false);
|
||
page.close(); optionsPage.close();
|
||
});
|
||
|
||
for (const action of ["enterTunnel", "submitUsage"]) {
|
||
for (const outcome of ["resolve", "reject"]) {
|
||
test(`${action} 的旧${outcome === "resolve" ? "成功" : "失败"}响应不能污染新扫码会话或解除新请求锁`, async () => {
|
||
const old = deferred(), current = deferred();
|
||
const endpoint = action === "enterTunnel" ? "/business/usage/enter-tunnel" : "/business/usage";
|
||
let writes = 0;
|
||
const page = createPage({
|
||
scan: (qrToken) => action === "enterTunnel"
|
||
? pallet({ palletId: qrToken === "OLD" ? 11 : 12 })
|
||
: boundPallet({ palletId: qrToken === "OLD" ? 11 : 12, currentLineId: 41 }),
|
||
request: (config) => config.url === endpoint ? (++writes === 1 ? old.promise : current.promise) : undefined,
|
||
});
|
||
await page.scan("OLD");
|
||
if (action === "enterTunnel") chooseTunnel(page);
|
||
page.state.form.usedPackages = "10";
|
||
assert.equal(typeof page.state[action], "function");
|
||
page.state[action]();
|
||
page.state.resetUsageOperation();
|
||
await page.scan("NEW");
|
||
assert.equal(page.state.form.qrToken, "NEW", "旧请求不应阻止新会话");
|
||
if (action === "enterTunnel") chooseTunnel(page, 1);
|
||
page.state.form.usedPackages = "11";
|
||
page.state[action]();
|
||
if (outcome === "resolve") old.resolve({ data: { palletId: 11, tunnelId: 31, traceNo: "OLD", remainingPackages: 90, flowState: "AT_SITE_PARTIAL" } });
|
||
else old.reject(new Error("旧请求失败"));
|
||
await flush();
|
||
assert.equal(page.state.operationPending.value, true);
|
||
assert.equal(page.state.usageResult.value, null);
|
||
assert.equal(page.state.submitError.value, "");
|
||
assert.equal(page.state.operationError.value, "");
|
||
assert.deepEqual(page.messages, []);
|
||
if (action === "enterTunnel") assert.equal(page.state.tunnelLocked.value, false);
|
||
current.reject(new Error("当前请求失败"));
|
||
await flush();
|
||
page.close();
|
||
});
|
||
}
|
||
}
|
||
|
||
for (const lifecycle of ["onHide", "onUnload"]) {
|
||
test(`离开页面 ${lifecycle} 后旧写请求及列表不能更新页面`, async () => {
|
||
const usage = deferred(), list = deferred();
|
||
const page = createPage({ scan: () => boundPallet({ currentLineId: 41 }), request: (config) => {
|
||
if (config.url === "/business/usage") return usage.promise;
|
||
if (config.url.endsWith("/list")) return list.promise;
|
||
} });
|
||
await page.scan();
|
||
page.state.loadUsageList();
|
||
page.state.form.usedPackages = "10";
|
||
page.state.submitUsage();
|
||
await page.lifecycle(lifecycle);
|
||
usage.resolve({ data: { usageId: 71, remainingPackages: 90, flowState: "AT_SITE_PARTIAL" } });
|
||
list.resolve({ rows: [{ usageId: 71 }] });
|
||
await flush();
|
||
assert.equal(page.state.scannedPallet.value, null);
|
||
assert.equal(page.state.usageResult.value, null);
|
||
assert.deepEqual(page.state.usageList.value, []);
|
||
assert.deepEqual(page.messages, []);
|
||
page.close();
|
||
});
|
||
}
|
||
|
||
test("仍通过原 PalletScanInput 的 scan 事件接收 HID/手工输入", async () => {
|
||
const page = createPage();
|
||
const input = nodes(page.render()).find((node) => node.type?.name === "PalletScanInput");
|
||
assert.ok(input);
|
||
input.props.onScan("HID-QR-001");
|
||
await flush();
|
||
assert.equal(page.state.form.qrToken, "HID-QR-001");
|
||
page.close();
|
||
});
|
||
|
||
test("无操作权限、非法包数或超余量不会发起写请求", async () => {
|
||
const page = createPage({ scan: () => boundPallet() });
|
||
await page.scan();
|
||
for (const value of ["", "0", "-1", "1.5", "101"]) {
|
||
page.state.form.usedPackages = value;
|
||
page.state.submitUsage();
|
||
}
|
||
assert.equal(page.post().length, 0);
|
||
page.close();
|
||
const denied = createPage({ canOperate: false });
|
||
await denied.scan();
|
||
assert.equal(typeof denied.state.enterTunnel, "function");
|
||
denied.state.enterTunnel();
|
||
denied.state.submitUsage();
|
||
assert.equal(denied.requests.filter((item) => item.method === "post").length, 0);
|
||
denied.close();
|
||
});
|
||
|
||
for (const amount of [10, 60]) {
|
||
test(`服务器已扣减${amount}包但响应丢失,hide 后重扫相同输入沿用原键且只扣减一次`, async () => {
|
||
const applied = new Map();
|
||
let remaining = 100, deductions = 0;
|
||
const page = createPage({
|
||
scan: () => boundPallet({ remainingPackages: remaining }),
|
||
request: (config) => {
|
||
if (config.url !== "/business/usage") return;
|
||
const { idempotencyKey, usedPackages } = config.data;
|
||
if (applied.has(idempotencyKey)) return Promise.resolve(applied.get(idempotencyKey));
|
||
remaining -= usedPackages;
|
||
deductions += 1;
|
||
applied.set(idempotencyKey, { data: { usageId: 71, remainingPackages: remaining, flowState: "AT_SITE_PARTIAL" } });
|
||
return Promise.reject(new Error("服务器已提交,响应传输超时"));
|
||
},
|
||
});
|
||
await page.scan();
|
||
page.state.form.usedPackages = String(amount);
|
||
await page.state.submitUsage();
|
||
await page.lifecycle("onHide");
|
||
await page.lifecycle("onShow");
|
||
await page.scan();
|
||
page.state.form.usedPackages = String(amount);
|
||
await page.state.submitUsage();
|
||
assert.equal(page.post().length, 2, "原命令重试不应被已扣减后的余量拦截");
|
||
assert.deepEqual(page.post()[1].data, page.post()[0].data);
|
||
assert.equal(deductions, 1);
|
||
assert.equal(remaining, 100 - amount);
|
||
assert.equal(page.state.usageResult.value.remainingPackages, 100 - amount);
|
||
page.close();
|
||
});
|
||
}
|
||
|
||
test("页面销毁重建后,已用尽且不能再扫码的托盘仍可恢复原操作核对", async () => {
|
||
const deviceStorage = createDeviceStorage(), applied = new Map();
|
||
let deductions = 0;
|
||
const request = (config) => {
|
||
if (config.url !== "/business/usage") return;
|
||
if (applied.has(config.data.idempotencyKey)) return Promise.resolve(applied.get(config.data.idempotencyKey));
|
||
deductions += 1;
|
||
applied.set(config.data.idempotencyKey, { data: { usageId: 71, remainingPackages: 0, flowState: "AT_SITE_EMPTY" } });
|
||
return Promise.reject(new Error("响应丢失"));
|
||
};
|
||
const first = createPage({ deviceStorage, scan: () => boundPallet(), request });
|
||
await first.scan();
|
||
first.state.form.usedPackages = "100";
|
||
await first.state.submitUsage();
|
||
await first.lifecycle("onUnload");
|
||
first.close();
|
||
const second = createPage({ deviceStorage, request, scan: () => boundPallet({ remainingPackages: 0, flowState: "AT_SITE_EMPTY" }) });
|
||
await second.lifecycle("onShow");
|
||
assert.match(visibleText(second.render()), /托盘-001/);
|
||
const retry = button(second, "恢复重试");
|
||
assert.ok(retry, "恢复入口不能依赖托盘重新扫码成功");
|
||
await retry.props.onClick();
|
||
await flush();
|
||
assert.deepEqual(second.post()[0].data, first.post()[0].data);
|
||
assert.equal(deductions, 1);
|
||
assert.equal(button(second, "恢复重试"), undefined);
|
||
second.close();
|
||
});
|
||
|
||
test("未确认的入隧在页面重建后可按原 payload/key 恢复", async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
const first = createPage({ deviceStorage, request: (config) => config.url.endsWith("/enter-tunnel") ? Promise.reject(new Error("响应丢失")) : undefined });
|
||
await first.scan();
|
||
chooseTunnel(first);
|
||
await first.state.enterTunnel();
|
||
await first.lifecycle("onUnload");
|
||
first.close();
|
||
const second = createPage({ deviceStorage });
|
||
await second.lifecycle("onShow");
|
||
const retry = button(second, "恢复重试");
|
||
assert.ok(retry);
|
||
await retry.props.onClick();
|
||
assert.deepEqual(second.post("/business/usage/enter-tunnel")[0].data, first.post("/business/usage/enter-tunnel")[0].data);
|
||
second.close();
|
||
});
|
||
|
||
test("不能在未确认使用结果时通过修改包数或产线发起另一笔扣减", async () => {
|
||
const page = createPage({ scan: () => boundPallet(), request: (config) => config.url === "/business/usage" ? Promise.reject(new Error("响应丢失")) : undefined });
|
||
await page.scan();
|
||
page.state.form.usedPackages = "10";
|
||
await page.state.submitUsage();
|
||
page.state.form.usedPackages = "11";
|
||
page.state.markPayloadChanged();
|
||
await page.state.submitUsage();
|
||
assert.equal(page.post().length, 1);
|
||
assert.match(page.state.submitError.value, /未确认|核对/);
|
||
const retry = button(page, "恢复重试");
|
||
assert.ok(retry);
|
||
await retry.props.onClick();
|
||
assert.deepEqual(page.post()[1].data, page.post()[0].data);
|
||
page.close();
|
||
});
|
||
|
||
test("本机恢复记录按账号及测试批次隔离,原账号返回后仍可恢复", async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
const page = createPage({ deviceStorage, scan: () => boundPallet(), request: (config) => config.url === "/business/usage" ? Promise.reject(new Error("响应丢失")) : undefined });
|
||
await page.scan();
|
||
page.state.form.usedPackages = "10";
|
||
await page.state.submitUsage();
|
||
await page.lifecycle("onHide");
|
||
for (const identity of [{ user_id: 8, test_batch_id: "" }, { user_id: 7, test_batch_id: "99" }]) {
|
||
deviceStorage.set("storage_data", identity);
|
||
await page.lifecycle("onShow");
|
||
assert.equal(button(page, "恢复重试"), undefined);
|
||
await page.lifecycle("onHide");
|
||
}
|
||
deviceStorage.set("storage_data", { user_id: 7, test_batch_id: "" });
|
||
await page.lifecycle("onShow");
|
||
assert.ok(button(page, "恢复重试"));
|
||
page.close();
|
||
});
|
||
|
||
test("恢复凭据落盘失败时不能先发送扣减请求", async () => {
|
||
const page = createPage({ scan: () => boundPallet(), storageWriteError: new Error("本机存储不可用") });
|
||
await page.scan();
|
||
page.state.form.usedPackages = "10";
|
||
await page.state.submitUsage();
|
||
assert.equal(page.post().length, 0);
|
||
assert.match(page.state.submitError.value, /存储/);
|
||
page.close();
|
||
});
|
||
|
||
test("产线选项失败不会丢失隧道选项,仍可无产线入隧并使用", async () => {
|
||
const page = createPage({ request: (config) => config.url.endsWith("/line-options") ? Promise.reject(new Error("产线服务不可用")) : undefined });
|
||
await page.scan();
|
||
assert.deepEqual(page.state.tunnelOptions.value, tunnelOptions);
|
||
assert.match(visibleText(page.render()), /产线.*失败|产线.*不可用/);
|
||
chooseTunnel(page);
|
||
await page.state.enterTunnel();
|
||
assert.equal(page.post("/business/usage/enter-tunnel").length, 1);
|
||
page.state.form.usedPackages = "10";
|
||
await page.state.submitUsage();
|
||
assert.equal(page.post()[0].data.lineId, null);
|
||
page.close();
|
||
});
|
||
|
||
test("可选产线迟迟未返回时隧道先可用;离页后旧产线失败不污染新会话", async () => {
|
||
const lines = deferred();
|
||
let scans = 0;
|
||
const page = createPage({ scan: () => pallet({ palletId: ++scans }), request: (config) => config.url.endsWith("/line-options") && scans === 1 ? lines.promise : undefined });
|
||
await page.scan();
|
||
assert.deepEqual(page.state.tunnelOptions.value, tunnelOptions);
|
||
assert.equal(page.state.optionLoading.value, false);
|
||
chooseTunnel(page);
|
||
await page.state.enterTunnel();
|
||
assert.equal(page.post("/business/usage/enter-tunnel").length, 1);
|
||
await page.lifecycle("onHide");
|
||
await page.scan("NEW");
|
||
lines.reject(new Error("旧产线失败"));
|
||
await flush();
|
||
assert.doesNotMatch(visibleText(page.render()), /旧产线失败/);
|
||
assert.deepEqual(page.state.lineOptions.value, lineOptions);
|
||
page.close();
|
||
});
|
||
|
||
test("扫码 API 对路径特殊字符编码,FIFO API 使用独立只读参数", async () => {
|
||
const page = createPage();
|
||
await page.api.scanUsagePallet("QR/A?b# 中");
|
||
assert.deepEqual(page.requests[0], { url: "/business/usage/scan/QR%2FA%3Fb%23%20%E4%B8%AD", method: "get" });
|
||
assert.equal(typeof page.api.getUsageFifoRecommendation, "function");
|
||
await page.api.getUsageFifoRecommendation("QR/A?b# 中");
|
||
assert.deepEqual(page.requests[1], { url: "/business/usage-fifo/recommendation", method: "get", params: { qrToken: "QR/A?b# 中" } });
|
||
page.close();
|
||
});
|
||
|
||
test("FIFO 扫码后使用 canonical qrToken 请求,只提示建议而不替换当前托盘", async () => {
|
||
const page = createPage({ scan: () => pallet({ qrToken: "CANONICAL-QR" }), request: (config) => config.url === "/business/usage-fifo/recommendation" ? Promise.resolve({ data: {
|
||
recommended: { palletId: 99, palletCode: "先到托盘", batchNo: "旧批次", remainingPackages: 30, siteArrivedAt: "2026-10-01 08:00:00" },
|
||
unknownArrivalCount: 1, message: "建议先使用先到托盘,另有1托盘到场时间未登记",
|
||
} }) : undefined });
|
||
await page.scan("印刷托盘号");
|
||
const fifo = page.requests.find((item) => item.url === "/business/usage-fifo/recommendation");
|
||
assert.deepEqual(fifo?.params, { qrToken: "CANONICAL-QR" });
|
||
assert.match(visibleText(page.render()), /建议先使用先到托盘,另有1托盘到场时间未登记/);
|
||
assert.equal(page.state.scannedPallet.value.palletId, 11);
|
||
chooseTunnel(page);
|
||
await page.state.enterTunnel();
|
||
page.state.form.usedPackages = "10";
|
||
await page.state.submitUsage();
|
||
assert.equal(page.post()[0].data.qrToken, "CANONICAL-QR", "FIFO 不强制换托盘或提交跳过原因");
|
||
page.close();
|
||
});
|
||
|
||
test("FIFO 请求失败仅显示固定人工核对提示,不阻断入隧和使用", async () => {
|
||
const page = createPage({ request: (config) => config.url === "/business/usage-fifo/recommendation" ? Promise.reject(new Error("内部诊断不应展示")) : undefined });
|
||
await page.scan();
|
||
assert.match(visibleText(page.render()), /先到先用建议暂不可用,请人工核对/);
|
||
assert.doesNotMatch(visibleText(page.render()), /内部诊断/);
|
||
chooseTunnel(page);
|
||
await page.state.enterTunnel();
|
||
page.state.form.usedPackages = "10";
|
||
await page.state.submitUsage();
|
||
assert.equal(page.post().length, 1);
|
||
page.close();
|
||
});
|
||
|
||
test("FIFO 请求尚未返回时不阻塞使用", async () => {
|
||
const fifo = deferred();
|
||
const page = createPage({ scan: () => boundPallet(), request: (config) => config.url === "/business/usage-fifo/recommendation" ? fifo.promise : undefined });
|
||
await page.scan();
|
||
assert.ok(page.requests.some((item) => item.url === "/business/usage-fifo/recommendation"));
|
||
page.state.form.usedPackages = "10";
|
||
await page.state.submitUsage();
|
||
assert.equal(page.post().length, 1);
|
||
fifo.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: "暂无建议" } });
|
||
await flush();
|
||
page.close();
|
||
});
|
||
|
||
for (const outcome of ["resolve", "reject"]) {
|
||
test(`FIFO 旧${outcome === "resolve" ? "成功" : "失败"}响应不污染新扫码/路由会话`, async () => {
|
||
const old = deferred();
|
||
const page = createPage({ request: (config) => {
|
||
if (config.url !== "/business/usage-fifo/recommendation") return;
|
||
return config.params.qrToken === "OLD" ? old.promise : Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: "新托盘建议" } });
|
||
} });
|
||
await page.scan("OLD");
|
||
assert.ok(page.requests.some((item) => item.url === "/business/usage-fifo/recommendation"));
|
||
await page.lifecycle("onHide");
|
||
await page.lifecycle("onShow");
|
||
await page.scan("NEW");
|
||
if (outcome === "resolve") old.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: "旧托盘建议" } });
|
||
else old.reject(new Error("旧FIFO失败"));
|
||
await flush();
|
||
assert.match(visibleText(page.render()), /新托盘建议/);
|
||
assert.doesNotMatch(visibleText(page.render()), /旧托盘建议|先到先用建议暂不可用/);
|
||
page.close();
|
||
});
|
||
}
|
||
|
||
test("FIFO 入隧后可刷新,入隧前晚到的建议不能覆盖刷新结果", async () => {
|
||
const old = deferred();
|
||
let calls = 0;
|
||
const page = createPage({ request: (config) => config.url === "/business/usage-fifo/recommendation"
|
||
? (++calls === 1 ? old.promise : Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: `入隧后建议${calls}` } }))
|
||
: undefined });
|
||
await page.scan();
|
||
chooseTunnel(page);
|
||
await page.state.enterTunnel();
|
||
await flush();
|
||
assert.equal(calls, 3, "扫码、选择目标隧道、入隧成功后分别刷新建议");
|
||
old.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: "入隧前旧建议" } });
|
||
await flush();
|
||
assert.match(visibleText(page.render()), /入隧后建议3/);
|
||
assert.doesNotMatch(visibleText(page.render()), /入隧前旧建议/);
|
||
const refresh = button(page, "刷新建议");
|
||
assert.ok(refresh);
|
||
await refresh.props.onClick();
|
||
await flush();
|
||
assert.equal(calls, 4);
|
||
page.close();
|
||
});
|
||
|
||
test("日期摘要显示真实生产日期和有效期,缺失均显示未登记", async () => {
|
||
const dated = createPage({ scan: () => pallet({ productionDate: "2026-10-01", expiryDate: "2027-01-01" }) });
|
||
await dated.scan();
|
||
assert.match(visibleText(dated.render()), /生产日期:2026-10-01/);
|
||
assert.match(visibleText(dated.render()), /有效期至:2027-01-01/);
|
||
dated.close();
|
||
const legacy = createPage();
|
||
await legacy.scan();
|
||
assert.match(visibleText(legacy.render()), /生产日期:未登记/);
|
||
assert.match(visibleText(legacy.render()), /有效期至:未登记/);
|
||
legacy.close();
|
||
});
|
||
|
||
async function saveUnknownUsage(page, qrToken = "QR-001") {
|
||
await page.scan(qrToken);
|
||
page.state.form.usedPackages = "10";
|
||
await page.state.submitUsage();
|
||
}
|
||
|
||
test("人工解除须二次确认,取消时保留原记录和重试键", async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
const page = createPage({ deviceStorage, scan: () => boundPallet(), confirm: () => false,
|
||
request: (config) => config.url === "/business/usage" ? Promise.reject(new Error("权限已撤销")) : undefined });
|
||
await saveUnknownUsage(page);
|
||
const before = structuredClone([...deviceStorage]);
|
||
const writes = page.post().length;
|
||
const release = button(page, "已人工核对,解除待确认");
|
||
assert.ok(release, "永久失败的本机记录应有明确的人工解除入口");
|
||
await release.props.onClick();
|
||
assert.equal(page.confirmations.length, 1);
|
||
assert.match(page.confirmations[0].content, /使用记录/);
|
||
assert.match(page.confirmations[0].content, /库存/);
|
||
assert.match(page.confirmations[0].content, /原操作结果/);
|
||
assert.match(page.confirmations[0].content, /重复.*扣减|重复提交/);
|
||
assert.deepEqual([...deviceStorage], before);
|
||
assert.equal(page.post().length, writes);
|
||
assert.equal(page.state.operationPending.value, false, "取消后不能一直锁住恢复区");
|
||
await page.state.submitUsage();
|
||
assert.deepEqual(page.post()[1].data, page.post()[0].data);
|
||
page.close();
|
||
});
|
||
|
||
test("人工确认只解除选中的本机记录,即使操作权限撤销也不发业务请求或宣称成功", async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
const first = createPage({ deviceStorage,
|
||
scan: (qr) => boundPallet({ palletId: qr === "FIRST" ? 11 : 12, palletCode: qr === "FIRST" ? "托盘一" : "托盘二" }),
|
||
request: (config) => config.url === "/business/usage" ? Promise.reject(new Error("库存变更")) : undefined });
|
||
await saveUnknownUsage(first, "FIRST");
|
||
await saveUnknownUsage(first, "SECOND");
|
||
const retained = structuredClone(first.post()[1].data);
|
||
first.close();
|
||
const page = createPage({ deviceStorage, canOperate: false, confirm: () => true });
|
||
await page.lifecycle("onShow");
|
||
const requestCount = page.requests.length;
|
||
const release = button(page, "已人工核对,解除待确认");
|
||
assert.ok(release);
|
||
assert.equal(Boolean(release.props.disabled), false, "本机人工核对不应依赖已撤销的业务写权限");
|
||
await release.props.onClick();
|
||
assert.equal(page.requests.length, requestCount, "解除只能写本机存储,不能发任何业务请求");
|
||
assert.deepEqual(page.messages, [], "不得调用成功提示宣称服务端业务成功");
|
||
assert.equal(page.state.pendingOperations.value.length, 1);
|
||
assert.deepEqual({ ...page.state.pendingOperations.value[0].payload }, retained);
|
||
assert.match(visibleText(page.render()), /仅.*本机/);
|
||
assert.match(visibleText(page.render()), /不代表.*成功|未确认.*成功/);
|
||
page.close();
|
||
const reloaded = createPage({ deviceStorage });
|
||
await reloaded.lifecycle("onShow");
|
||
assert.equal(reloaded.state.pendingOperations.value.length, 1);
|
||
assert.equal(reloaded.state.pendingOperations.value[0].payload.idempotencyKey, retained.idempotencyKey);
|
||
reloaded.close();
|
||
});
|
||
|
||
for (const identity of [{ user_id: 8, test_batch_id: "" }, { user_id: 7, test_batch_id: "99" }]) {
|
||
test(`人工确认等待期间切换${identity.user_id === 8 ? "账号" : "批次"},旧确认不能解除任何范围的记录`, async () => {
|
||
const deviceStorage = createDeviceStorage(), decision = deferred();
|
||
const page = createPage({ deviceStorage, scan: () => boundPallet(), confirm: () => decision.promise,
|
||
request: (config) => config.url === "/business/usage" ? Promise.reject(new Error("响应丢失")) : undefined });
|
||
await saveUnknownUsage(page);
|
||
const release = button(page, "已人工核对,解除待确认");
|
||
assert.ok(release);
|
||
const waiting = release.props.onClick();
|
||
await flush();
|
||
deviceStorage.set("storage_data", identity);
|
||
const other = createPage({ deviceStorage, scan: () => boundPallet(),
|
||
request: (config) => config.url === "/business/usage" ? Promise.reject(new Error("另一范围未确认")) : undefined });
|
||
await saveUnknownUsage(other);
|
||
const before = structuredClone([...deviceStorage]);
|
||
decision.resolve(true);
|
||
await waiting;
|
||
assert.deepEqual([...deviceStorage], before, "确认时必须重新校验实际账号/批次,不能只依赖页面缓存");
|
||
assert.deepEqual(page.messages, []);
|
||
await page.lifecycle("onHide");
|
||
deviceStorage.set("storage_data", { user_id: 7, test_batch_id: "" });
|
||
await page.lifecycle("onShow");
|
||
assert.ok(button(page, "恢复重试"));
|
||
page.close(); other.close();
|
||
});
|
||
}
|
||
|
||
test("人工解除弹窗失败时保留记录并释放操作锁", async () => {
|
||
const page = createPage({ scan: () => boundPallet(), confirm: () => Promise.reject(new Error("确认窗不可用")),
|
||
request: (config) => config.url === "/business/usage" ? Promise.reject(new Error("响应丢失")) : undefined });
|
||
await saveUnknownUsage(page);
|
||
const release = button(page, "已人工核对,解除待确认");
|
||
assert.ok(release);
|
||
await release.props.onClick();
|
||
assert.equal(page.state.pendingOperations.value.length, 1);
|
||
assert.match(page.state.recoveryError.value, /确认窗不可用/);
|
||
assert.equal(page.state.operationPending.value, false);
|
||
assert.equal(page.post().length, 1);
|
||
page.close();
|
||
});
|
||
|
||
const fifoData = (requiresSkipReason = true) => ({ recommended: null, unknownArrivalCount: 0, requiresSkipReason, message: "请按实际到货时间核对" });
|
||
const fifoRejection = (wrapped = true) => {
|
||
const body = { code: 500, data: "FIFO_SKIP_REASON_REQUIRED", msg: "请填写跳过原因" };
|
||
return Object.assign(new Error("服务端拒绝"), { response: wrapped ? { data: body } : body });
|
||
};
|
||
const fifoRequests = (page) => page.requests.filter((item) => item.url.endsWith("/recommendation"));
|
||
|
||
for (const kind of ["entry", "usage"]) {
|
||
const action = kind === "entry" ? "enterTunnel" : "submitUsage";
|
||
const endpoint = kind === "entry" ? "/business/usage/enter-tunnel" : "/business/usage";
|
||
for (const reason of [" \n ", "字".repeat(501)]) test(`FIFO ${kind} 空白或超长原因不发送且不持久化(${reason.length})`, async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
const page = createPage({ deviceStorage, scan: () => kind === "entry" ? pallet() : boundPallet(), request: config => config.url.endsWith("/recommendation") ? Promise.resolve({ data: fifoData() }) : undefined });
|
||
await page.scan(); if (kind === "entry") chooseTunnel(page); await flush();
|
||
page.state.form.usedPackages = "10"; page.state.form.fifoSkipReason = reason;
|
||
await page.state[action]();
|
||
assert.equal(page.post(endpoint).length, 0);
|
||
assert.equal(deviceStorage.size, 1);
|
||
assert.match(visibleText(page.render()), /原因|500/);
|
||
page.close();
|
||
});
|
||
|
||
test(`FIFO ${kind} trim 原因持久化,修改原因不能绕过待确认,重建后重放原原因及键`, async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
const first = createPage({ deviceStorage, scan: () => kind === "entry" ? pallet() : boundPallet(), request: config => config.url === endpoint ? Promise.reject(new Error("网络中断")) : undefined });
|
||
await first.scan(); if (kind === "entry") chooseTunnel(first);
|
||
first.state.form.usedPackages = "10"; first.state.form.fifoSkipReason = " 先到托盘正在检验 \n";
|
||
await first.state[action]();
|
||
const original = first.post(endpoint)[0].data;
|
||
assert.equal(original.fifoSkipReason, "先到托盘正在检验");
|
||
assert.equal(first.state.pendingOperations.value[0].payload.fifoSkipReason, "先到托盘正在检验");
|
||
first.state.form.fifoSkipReason = "换了原因"; await first.state[action]();
|
||
assert.equal(first.post(endpoint).length, 1);
|
||
first.close();
|
||
const second = createPage({ deviceStorage });
|
||
await button(second, "恢复重试").props.onClick();
|
||
assert.deepEqual(second.post(endpoint)[0].data, original);
|
||
assert.equal(second.state.pendingOperations.value.length, 0);
|
||
second.close();
|
||
});
|
||
|
||
for (const wrapped of [true, false]) test(`FIFO ${kind} 明确拒绝(wrapped=${wrapped})解除对应凭据并刷新,补原因后新键提交`, async () => {
|
||
let rejected = false;
|
||
const page = createPage({ scan: () => kind === "entry" ? pallet() : boundPallet(), request: config => {
|
||
if (config.url === endpoint && !rejected) { rejected = true; return Promise.reject(fifoRejection(wrapped)); }
|
||
if (config.url.endsWith("/recommendation")) return Promise.resolve({ data: fifoData(rejected) });
|
||
} });
|
||
await page.scan(); if (kind === "entry") chooseTunnel(page); await flush(); page.state.form.usedPackages = "10";
|
||
const before = fifoRequests(page).length;
|
||
await page.state[action](); await flush();
|
||
const original = page.post(endpoint)[0].data;
|
||
assert.equal(page.state.pendingOperations.value.length, 0);
|
||
assert.match(visibleText(page.render()), /原因/);
|
||
assert.ok(fifoRequests(page).length > before);
|
||
page.state.form.fifoSkipReason = " 质量待核实 ";
|
||
await page.state[action]();
|
||
assert.equal(page.post(endpoint)[1].data.fifoSkipReason, "质量待核实");
|
||
assert.notEqual(page.post(endpoint)[1].data.idempotencyKey, original.idempotencyKey);
|
||
page.close();
|
||
});
|
||
}
|
||
|
||
test("FIFO 恢复重试明确拒绝只清理该托盘;存储清理失败保留凭据", async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
const first = createPage({ deviceStorage, scan: qr => boundPallet({ palletId: qr === "FIRST" ? 11 : 12 }), request: config => config.method === "post" ? Promise.reject(new Error("响应丢失")) : undefined });
|
||
await saveUnknownUsage(first, "FIRST"); await saveUnknownUsage(first, "SECOND"); first.close();
|
||
const before = structuredClone([...deviceStorage]);
|
||
const blocked = createPage({ deviceStorage, storageWriteError: new Error("清理失败"), request: config => config.method === "post" ? Promise.reject(fifoRejection()) : undefined });
|
||
await button(blocked, "恢复重试").props.onClick();
|
||
assert.deepEqual([...deviceStorage], before);
|
||
assert.equal(blocked.state.pendingOperations.value.length, 2);
|
||
blocked.close();
|
||
const page = createPage({ deviceStorage, scan: () => boundPallet(), request: config => config.method === "post" ? Promise.reject(fifoRejection()) : undefined });
|
||
await button(page, "恢复重试").props.onClick(); await flush();
|
||
assert.equal(page.state.pendingOperations.value.length, 1);
|
||
assert.equal(page.state.pendingOperations.value[0].payload.qrToken, "SECOND");
|
||
assert.ok(fifoRequests(page).length);
|
||
assert.match(visibleText(page.render()), /原因/);
|
||
page.close();
|
||
});
|
||
|
||
for (const error of [new Error("FIFO_SKIP_REASON_REQUIRED"), { response: { data: { code: 503, data: "FIFO_SKIP_REASON_REQUIRED" } } }, { response: { data: { code: 500, data: "OTHER_BUSINESS_ERROR" } } }]) {
|
||
test(`FIFO 非明确拒绝保留原凭据 ${JSON.stringify(error)}`, async () => {
|
||
const page = createPage({ scan: () => boundPallet(), request: config => config.method === "post" ? Promise.reject(error) : undefined });
|
||
await saveUnknownUsage(page);
|
||
assert.equal(page.state.pendingOperations.value.length, 1);
|
||
assert.ok(button(page, "恢复重试"));
|
||
page.close();
|
||
});
|
||
}
|
||
|
||
for (const change of ["user", "batch", "page", "record"]) test(`FIFO 明确拒绝的旧回调不能清理 ${change} 变化后的凭据`, async () => {
|
||
const slow = deferred(), deviceStorage = createDeviceStorage();
|
||
const page = createPage({ deviceStorage, scan: () => boundPallet(), request: config => config.method === "post" ? slow.promise : undefined });
|
||
await page.scan(); page.state.form.usedPackages = "10"; const pending = page.state.submitUsage(); await flush();
|
||
if (change === "user") deviceStorage.set("storage_data", { user_id: 8, test_batch_id: "" });
|
||
if (change === "batch") deviceStorage.set("storage_data", { user_id: 7, test_batch_id: "99" });
|
||
if (change === "page") await page.lifecycle("onHide");
|
||
if (change === "record") {
|
||
const key = [...deviceStorage.keys()].find(key => key !== "storage_data"), records = structuredClone(deviceStorage.get(key));
|
||
records[0].payload.idempotencyKey = "ANOTHER-KEY"; deviceStorage.set(key, records);
|
||
}
|
||
const before = structuredClone([...deviceStorage]);
|
||
slow.reject(fifoRejection()); await pending; await flush();
|
||
assert.deepEqual([...deviceStorage], before);
|
||
assert.equal(page.state.usageResult.value, null);
|
||
page.close();
|
||
});
|
||
|
||
for (const change of ["user", "batch", "tunnel"]) test(`FIFO ${change} 变化隔离旧建议并按目标隧道请求`, async () => {
|
||
const old = deferred(), deviceStorage = createDeviceStorage();
|
||
const page = createPage({ deviceStorage, request: config => config.url.endsWith("/recommendation")
|
||
? (config.params.tunnelId === 31 ? old.promise : Promise.resolve({ data: fifoData(false) })) : undefined });
|
||
await page.scan(); chooseTunnel(page); await flush();
|
||
assert.deepEqual(fifoRequests(page).at(-1).params, { qrToken: "QR-001", tunnelId: 31 });
|
||
if (change === "user") deviceStorage.set("storage_data", { user_id: 8, test_batch_id: "" });
|
||
if (change === "batch") deviceStorage.set("storage_data", { user_id: 7, test_batch_id: "99" });
|
||
if (change === "tunnel") chooseTunnel(page, 1);
|
||
await flush(); const before = visibleText(page.render());
|
||
old.resolve({ data: { ...fifoData(), message: "OLD-FIFO" } }); await flush();
|
||
assert.equal(visibleText(page.render()), before);
|
||
page.close();
|
||
});
|
||
|
||
test("FIFO 原因多行输入绑定表单并只读展示记录原因", async () => {
|
||
const page = createPage({ request: config => config.url.endsWith("/list") ? Promise.resolve({ rows: [{ usageId: 1, fifoSkipReason: "已核对记录原因" }, { usageId: 2 }] }) : undefined });
|
||
await page.scan(); await page.lifecycle("onShow");
|
||
const input = nodes(page.render()).find(node => node.type === "textarea");
|
||
assert.ok(input);
|
||
input.props["onUpdate:modelValue"]("人工核对原因");
|
||
assert.equal(page.state.form.fifoSkipReason, "人工核对原因");
|
||
assert.match(visibleText(page.render()), /跳过先到托盘原因[::]?\s*已核对记录原因/);
|
||
assert.match(visibleText(page.render()), /跳过先到托盘原因[::]?\s*—/);
|
||
page.close();
|
||
});
|
||
|
||
for (const action of ["enterTunnel", "submitUsage"]) test(`FIFO ${action} 可选原因同样限制500字,trim后500字可提交`, async () => {
|
||
const page = createPage({ scan: () => action === "enterTunnel" ? pallet() : boundPallet() });
|
||
await page.scan(); if (action === "enterTunnel") chooseTunnel(page); await flush();
|
||
page.state.form.usedPackages = "10"; page.state.form.fifoSkipReason = "字".repeat(501);
|
||
await page.state[action]();
|
||
assert.equal(page.requests.filter(item => item.method === "post").length, 0);
|
||
assert.doesNotMatch(visibleText(page.render()), /结果未确认时请恢复重试/, "本地校验失败不能暗示业务请求已发送");
|
||
page.state.form.fifoSkipReason = ` ${"字".repeat(500)} \n`;
|
||
await page.state[action]();
|
||
assert.equal(page.requests.find(item => item.method === "post").data.fifoSkipReason, "字".repeat(500));
|
||
page.close();
|
||
});
|
||
|
||
test("FIFO 再次选择同一隧道不能清掉已知必填要求", async () => {
|
||
const page = createPage({ request: config => config.url.endsWith("/recommendation") ? Promise.resolve({ data: fifoData() }) : undefined });
|
||
await page.scan(); chooseTunnel(page); await flush();
|
||
chooseTunnel(page); await page.state.enterTunnel();
|
||
assert.equal(page.post("/business/usage/enter-tunnel").length, 0);
|
||
page.close();
|
||
});
|
||
|
||
test("FIFO 恢复使用请求被明确拒绝后,补原因保留原产线和包数", async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
const first = createPage({ deviceStorage, scan: () => boundPallet(), request: config => config.method === "post" ? Promise.reject(new Error("响应丢失")) : undefined });
|
||
await first.scan(); first.state.form.lineId = 41; first.state.form.usedPackages = "12";
|
||
await first.state.submitUsage(); first.close();
|
||
let rejected = false;
|
||
const page = createPage({ deviceStorage, scan: () => boundPallet(), request: config => {
|
||
if (config.method === "post" && !rejected) { rejected = true; return Promise.reject(fifoRejection()); }
|
||
} });
|
||
await button(page, "恢复重试").props.onClick(); await flush();
|
||
page.state.form.fifoSkipReason = "先到托盘待检验";
|
||
await page.state.submitUsage();
|
||
assert.equal(page.post()[1].data.lineId, 41);
|
||
assert.equal(page.post()[1].data.usedPackages, 12);
|
||
assert.equal(page.post()[1].data.fifoSkipReason, "先到托盘待检验");
|
||
page.close();
|
||
});
|
||
|
||
test("FIFO 旧无原因入隧凭据恢复收到明确拒绝后可补原因重新入隧", async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
const first = createPage({ deviceStorage, request: config => config.method === "post" ? Promise.reject(new Error("响应丢失")) : undefined });
|
||
await first.scan(); chooseTunnel(first); await first.state.enterTunnel();
|
||
const original = first.post("/business/usage/enter-tunnel")[0].data;
|
||
assert.equal(original.fifoSkipReason, undefined); first.close();
|
||
let rejected = false;
|
||
const page = createPage({ deviceStorage, request: config => {
|
||
if (config.method === "post" && !rejected) { rejected = true; return Promise.reject(fifoRejection()); }
|
||
} });
|
||
await button(page, "恢复重试").props.onClick(); await flush();
|
||
assert.equal(page.state.pendingOperations.value.length, 0);
|
||
assert.equal(page.state.tunnelLocked.value, false);
|
||
assert.equal(page.state.form.tunnelId, 31);
|
||
page.state.form.fifoSkipReason = "先到托盘待检验";
|
||
await page.state.enterTunnel();
|
||
const posts = page.post("/business/usage/enter-tunnel");
|
||
assert.deepEqual(posts[0].data, original);
|
||
assert.equal(posts[1].data.fifoSkipReason, "先到托盘待检验");
|
||
assert.notEqual(posts[1].data.idempotencyKey, original.idempotencyKey);
|
||
assert.equal(page.state.tunnelLocked.value, true);
|
||
page.close();
|
||
});
|
||
|
||
// 保留真实 request 的 HTTP/业务错误处理,仅替换网络与传输加解密边界。
|
||
function createHttpRequest(respond) {
|
||
const modules = {
|
||
"@/config": { default: { baseUrl: "http://usage-test.invalid" } },
|
||
"@/utils/auth": { getToken: () => "" },
|
||
"@/utils/errorCode": { default: {} },
|
||
"@/utils/constant": { default: { testBatchId: "test_batch_id" } },
|
||
"@/utils/storage": { default: { get: () => "" } },
|
||
"@/store/modules/user": {},
|
||
"@/utils/common": { toast() {}, showConfirm: () => Promise.resolve({ confirm: false }), tansParams: params => `${new URLSearchParams(params)}&` },
|
||
"@/utils/transportCrypto": {
|
||
encryptTransportRequest: async config => config,
|
||
decryptTransportResponse: async response => response,
|
||
decryptTransportErrorResponse: async error => error,
|
||
shouldRetryTransportWithFreshKey: () => false,
|
||
},
|
||
};
|
||
const uni = {
|
||
request(options) { Promise.resolve().then(() => respond(options)).then(options.success, options.fail); },
|
||
showToast() {},
|
||
};
|
||
return new Function("__modules", "uni", `${bindImports(requestSource).replace("export default request;", "return request;")}`)(modules, uni);
|
||
}
|
||
|
||
for (const kind of ["entry", "usage"]) {
|
||
const action = kind === "entry" ? "enterTunnel" : "submitUsage";
|
||
const endpoint = kind === "entry" ? "/business/usage/enter-tunnel" : "/business/usage";
|
||
const confirmed = kind === "entry"
|
||
? { palletId: 11, tunnelId: 31, traceNo: "TRACE-OK" }
|
||
: { usageId: 71, remainingPackages: 83, flowState: "AT_SITE_PARTIAL" };
|
||
const invalidResponses = [
|
||
["HTTP500无业务code", { statusCode: 500, data: { detail: "upstream timeout" } }],
|
||
["HTTP502无业务code", { statusCode: 502, data: { detail: "bad gateway" } }],
|
||
["HTTP302含成功body", { statusCode: 302, data: { code: 200, data: confirmed } }],
|
||
["HTTP503含成功body", { statusCode: 503, data: { code: 200, data: confirmed } }],
|
||
["缺失data", { statusCode: 200, data: { code: 200 } }],
|
||
["空data", { statusCode: 200, data: { code: 200, data: {} } }],
|
||
...Object.keys(confirmed).map(key => [`缺少${key}`, { statusCode: 200, data: { code: 200, data: Object.fromEntries(Object.entries(confirmed).filter(([field]) => field !== key)) } }]),
|
||
...(kind === "entry"
|
||
? [["隧道不匹配", { statusCode: 200, data: { code: 200, data: { ...confirmed, tunnelId: 32 } } }]]
|
||
: [["负数余量", { statusCode: 200, data: { code: 200, data: { ...confirmed, remainingPackages: -1 } } }]]),
|
||
];
|
||
for (const [label, response] of invalidResponses) {
|
||
for (const recovery of [false, true]) test(`F1 真实request+页面 ${kind} ${recovery ? "恢复" : "提交"} ${label} 保留原凭据且不报成功`, async () => {
|
||
const deviceStorage = createDeviceStorage();
|
||
let httpResponse = response;
|
||
const request = createHttpRequest(() => httpResponse);
|
||
const options = { deviceStorage, scan: () => kind === "entry" ? pallet() : boundPallet(), request: config => config.method === "post" ? request(config) : undefined };
|
||
let page = createPage(recovery ? { ...options, request: config => config.method === "post" ? Promise.reject(new Error("响应丢失")) : undefined } : options);
|
||
await page.scan(); if (kind === "entry") chooseTunnel(page);
|
||
page.state.form.usedPackages = "10"; page.state.form.fifoSkipReason = "原原因";
|
||
await page.state[action]();
|
||
const original = page.post(endpoint)[0].data;
|
||
if (recovery) {
|
||
page.close(); page = createPage(options);
|
||
await button(page, "恢复重试").props.onClick();
|
||
}
|
||
assert.equal(page.state.pendingOperations.value.length, 1);
|
||
assert.deepEqual({ ...page.state.pendingOperations.value[0].payload }, original);
|
||
assert.deepEqual(deviceStorage.get(page.state.recoveryStorageKey.value)[0].payload, original);
|
||
assert.deepEqual(page.messages, []);
|
||
assert.equal(page.state.usageResult.value, null);
|
||
assert.equal(page.state.tunnelLocked.value, !recovery && kind === "usage");
|
||
assert.doesNotMatch(page.state.recoveryMessage.value, /原使用已确认|原入隧已确认/);
|
||
if (!recovery) {
|
||
page.state.form.fifoSkipReason = "修改原因"; await page.state[action]();
|
||
assert.equal(page.post(endpoint).length, 1, "未知结果不能改原因并以新键发送");
|
||
}
|
||
httpResponse = { statusCode: 200, data: { code: 200, data: confirmed } };
|
||
await button(page, "恢复重试").props.onClick();
|
||
assert.deepEqual(page.post(endpoint).at(-1).data, original);
|
||
assert.equal(page.state.pendingOperations.value.length, 0);
|
||
page.close();
|
||
});
|
||
}
|
||
test(`F1 真实request+页面 ${kind} 精确FIFO拒绝仍透传并允许补原因`, async () => {
|
||
let rejected = false;
|
||
const request = createHttpRequest(() => {
|
||
if (!rejected) { rejected = true; return { statusCode: 200, data: { code: 500, data: "FIFO_SKIP_REASON_REQUIRED", msg: "请填写原因" } }; }
|
||
return { statusCode: 200, data: { code: 200, data: confirmed } };
|
||
});
|
||
const page = createPage({ scan: () => kind === "entry" ? pallet() : boundPallet(), request: config => config.method === "post" ? request(config) : undefined });
|
||
await page.scan(); if (kind === "entry") chooseTunnel(page); page.state.form.usedPackages = "10";
|
||
await page.state[action](); await flush();
|
||
assert.equal(page.state.pendingOperations.value.length, 0);
|
||
page.state.form.fifoSkipReason = "已核对原因"; await page.state[action]();
|
||
assert.equal(page.post(endpoint)[1].data.fifoSkipReason, "已核对原因");
|
||
assert.notEqual(page.post(endpoint)[1].data.idempotencyKey, page.post(endpoint)[0].data.idempotencyKey);
|
||
page.close();
|
||
});
|
||
}
|