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(); }); }