Files
ShiErFeng/shierfeng-fastapi-app/tests/usage-behavior.test.mjs

1110 lines
57 KiB
JavaScript
Raw Permalink 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.
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();
});
}