const fs = require('fs'); const vm = require('vm'); const babel = require('@babel/core'); const source = fs.readFileSync( 'src/views/productionPlan/components/bomCompareDialog.logic.js', 'utf8' ); const apiSource = fs.readFileSync( 'src/api/productionPlan/bomCompare.js', 'utf8' ); function deferred() { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function loadMethods(api) { const compiled = babel.transformSync(source, { filename: 'bomCompareDialog.logic.js', babelrc: false, configFile: false, plugins: ['@babel/plugin-transform-modules-commonjs'] }).code; const module = { exports: {} }; const sandbox = { module, exports: module.exports, Blob, console, Promise, Map, Set, Date, String, Number, Array, Object, setTimeout, clearTimeout, window: { setTimeout, clearTimeout }, require(id) { if (id === '@/api/productionPlan') { return { bomVersionList: api.bomVersionList }; } if (id === '@/api/productionPlan/bomCompare') return api; throw new Error(`unexpected import: ${id}`); } }; vm.runInNewContext(compiled, sandbox, { filename: 'bomCompareDialog.logic.js' }); return module.exports.default; } function loadApi(request) { const compiled = babel.transformSync(apiSource, { filename: 'bomCompare.js', babelrc: false, configFile: false, plugins: ['@babel/plugin-transform-modules-commonjs'] }).code; const module = { exports: {} }; const sandbox = { module, exports: module.exports, Blob, Promise, Error, JSON, Number, String, decodeURIComponent, require(id) { if (id === '@/utils/request') return { __esModule: true, default: request }; throw new Error(`unexpected import: ${id}`); } }; vm.runInNewContext(compiled, sandbox, { filename: 'bomCompare.js' }); return module.exports; } function createContext(methods) { const messages = { success: [], error: [], warning: [] }; const ctx = { ...methods, visible: true, comparing: false, optionsLoading: false, detailLoading: false, exportingDifferences: false, exportingTree: false, compareLevel: 'multiple', compareMode: 'detail', showOnlyDifferences: false, showReport: true, keyword: '', activeDifferenceType: '', currentPage: 1, pageSize: 5, detailTotal: 0, taskId: '', taskStatus: '', taskMessage: '', requestSequence: 0, resultRequestSequence: 0, pollTimer: null, pollResolve: null, filterTimer: null, plan: { categoryId: 'category-1', deptId: 'dept-1' }, source: { bomId: 'pbom-1', categoryId: 'category-1', version: 'V1.0' }, target: { bomId: 'mbom-1', categoryId: 'category-1', version: 'V2.0' }, sourceOptions: [], targetOptions: [], sourceTree: [], targetTree: [], differences: [], summary: { totalCount: 0 }, pendingChanges: false, lastComparedAt: '', $message: { success(value) { messages.success.push(value); }, error(value) { messages.error.push(value); }, warning(value) { messages.warning.push(value); } }, $nextTick(fn) { if (fn) fn(); }, handleTreeNodeClick() {}, scrollNodeIntoView() {}, downloadBlob(file) { this.downloaded = file; } }; ctx.messages = messages; return ctx; } function assert(condition, message) { if (!condition) throw new Error(message); } function treeFixture() { return { baseBom: { bomId: 'pbom-1', code: 'P', name: 'PBOM', version: 'V1' }, targetBom: { bomId: 'mbom-1', code: 'M', name: 'MBOM', version: 'V2' }, baseTree: [ { nodeKey: 'root', materialCode: 'ROOT', materialName: '根节点', differenceType: 'EQUAL', children: [ { nodeKey: 'added', materialCode: 'A', materialName: '新增件占位', differenceType: 'ADDED', nodeExists: false } ] } ], targetTree: [ { nodeKey: 'root', materialCode: 'ROOT', materialName: '根节点', differenceType: 'EQUAL', children: [ { nodeKey: 'added', materialCode: 'A', materialName: '新增件', differenceType: 'ADDED', nodeExists: true } ] } ], summary: { addedCount: 1, totalCount: 1 } }; } function pageFixture() { return { count: 1, current: 1, list: [ { nodeKey: 'added', differenceType: 'ADDED', materialCode: 'A', materialName: '新增件' } ] }; } async function run() { const results = []; const test = async (name, fn) => { await fn(); results.push(name); }; await test('任务成功后树、分页、汇总和占位节点闭环', async () => { const api = { startBomCompare: async () => ({ taskId: 'task-1', status: 'PENDING' }), getBomCompareTask: async () => ({ taskId: 'task-1', status: 'SUCCESS' }), getBomCompareTree: async () => treeFixture(), getBomComparePage: async () => pageFixture(), getBomCompareSummary: async () => ({ addedCount: 1, totalCount: 99 }), exportBomCompareDifferences: async () => ({}), exportBomCompareTree: async () => ({}), bomVersionList: async () => [] }; const ctx = createContext(loadMethods(api)); await ctx.runCompare(); assert(ctx.taskStatus === 'SUCCESS', 'success status not closed'); assert(ctx.summary.totalCount === 1, 'summary total was not reconciled'); assert( ctx.detailTotal === 1 && ctx.differences[0].type === 'added', 'page not normalized' ); assert( ctx.sourceTree[0].children[0].nodeExists === false, 'placeholder node lost' ); assert( ctx.sourceTree[0].children[0].nodeKey === ctx.targetTree[0].children[0].nodeKey, 'tree keys not aligned' ); }); await test('任务失败进入可重试终态', async () => { const api = { startBomCompare: async () => ({ taskId: 'task-2', status: 'PENDING' }), getBomCompareTask: async () => ({ status: 'failed', message: '后端比较失败' }), bomVersionList: async () => [] }; const ctx = createContext(loadMethods(api)); await ctx.runCompare(); assert( ctx.taskStatus === 'FAILED' && ctx.pendingChanges, 'failed task not closed' ); assert( ctx.messages.error.includes('后端比较失败'), 'failed message missing' ); }); await test('轮询瞬时失败可恢复', async () => { let calls = 0; const api = { startBomCompare: async () => ({ taskId: 'task-3', status: 'PENDING' }), getBomCompareTask: async () => { calls += 1; if (calls === 1) throw new Error('temporary'); return { status: 'SUCCESS' }; }, getBomCompareTree: async () => treeFixture(), getBomComparePage: async () => pageFixture(), getBomCompareSummary: async () => ({ addedCount: 1, totalCount: 1 }), bomVersionList: async () => [] }; const ctx = createContext(loadMethods(api)); ctx.waitForNextPoll = async () => {}; await ctx.runCompare(); assert( calls === 2 && ctx.taskStatus === 'SUCCESS', 'poll retry did not recover' ); }); await test('结果加载失败不会误报任务失败', async () => { const api = { startBomCompare: async () => ({ taskId: 'task-4', status: 'PENDING' }), getBomCompareTask: async () => ({ status: 'SUCCESS' }), getBomCompareTree: async () => { throw new Error('结果树暂不可用'); }, getBomComparePage: async () => pageFixture(), getBomCompareSummary: async () => ({ addedCount: 1, totalCount: 1 }), bomVersionList: async () => [] }; const ctx = createContext(loadMethods(api)); await ctx.runCompare(); assert(ctx.taskStatus === 'LOAD_FAILED', 'result failure misclassified'); assert( !ctx.pendingChanges && !ctx.comparing, 'result failure not terminal' ); }); await test('关闭弹窗后版本请求不会回写或发起比较', async () => { const source = deferred(); const target = deferred(); let versionCalls = 0; let starts = 0; const api = { bomVersionList: async () => { versionCalls += 1; return versionCalls === 1 ? source.promise : target.promise; }, startBomCompare: async () => { starts += 1; return { taskId: 'never' }; } }; const ctx = createContext(loadMethods(api)); const opening = ctx.open({ categoryId: 'category-1' }); ctx.close(); source.resolve([{ id: 'p', status: 1 }]); target.resolve([{ id: 'm', status: 1 }]); await opening; assert(!ctx.visible && starts === 0, 'closed dialog started a task'); assert( ctx.sourceOptions.length === 0 && ctx.targetOptions.length === 0, 'closed dialog was mutated' ); }); await test('快速筛选仅接受最新响应', async () => { const firstTree = deferred(); const firstPage = deferred(); let treeCalls = 0; let pageCalls = 0; const api = { getBomCompareTree: async () => { treeCalls += 1; return treeCalls === 1 ? firstTree.promise : { ...treeFixture(), baseTree: [] }; }, getBomComparePage: async () => { pageCalls += 1; return pageCalls === 1 ? firstPage.promise : { count: 0, current: 1, list: [] }; }, getBomCompareSummary: async () => ({ addedCount: 1, totalCount: 1 }), bomVersionList: async () => [] }; const ctx = createContext(loadMethods(api)); ctx.taskId = 'task-5'; ctx.taskStatus = 'SUCCESS'; const first = ctx.loadCompareResult(); const second = ctx.loadCompareResult(); await second; firstTree.resolve(treeFixture()); firstPage.resolve(pageFixture()); await first; assert( ctx.detailTotal === 0 && ctx.sourceTree.length === 0, 'stale filter response overwrote latest' ); }); await test('无差异的成功任务仍可导出结果树', async () => { let exported = false; const api = { exportBomCompareTree: async () => { exported = true; return { blob: new Blob(['tree']), fileName: 'tree.xlsx' }; }, bomVersionList: async () => [] }; const ctx = createContext(loadMethods(api)); ctx.taskId = 'task-6'; ctx.taskStatus = 'SUCCESS'; ctx.summary = { totalCount: 0 }; await ctx.exportTree(); assert( exported && ctx.downloaded.fileName === 'tree.xlsx', 'zero-difference tree export blocked' ); }); await test('关闭执行中的轮询可立即收口', async () => { const api = { startBomCompare: async () => ({ taskId: 'task-7', status: 'PENDING' }), getBomCompareTask: async () => ({ status: 'RUNNING' }), bomVersionList: async () => [] }; const ctx = createContext(loadMethods(api)); const running = ctx.runCompare(); for (let index = 0; index < 20 && !ctx.pollResolve; index += 1) { await Promise.resolve(); } assert(Boolean(ctx.pollResolve), 'poll wait was not entered'); ctx.close(); await Promise.race([ running, new Promise((_, reject) => setTimeout( () => reject(new Error('cancelled poll did not settle')), 100 ) ) ]); assert( !ctx.visible && !ctx.comparing, 'closed polling task remained active' ); }); await test('导出接口识别伪 Blob 错误并解析文件名', async () => { let response = { data: new Blob([JSON.stringify({ message: '导出参数错误' })], { type: 'application/octet-stream' }), headers: {} }; const api = loadApi({ post: async () => response, get: async () => response }); let message = ''; try { await api.exportBomCompareTree({ taskId: 'task-8' }); } catch (error) { message = error.message; } assert( message === '导出参数错误', 'octet-stream JSON error was downloaded' ); response = { data: new Blob(['excel']), headers: { 'content-disposition': "attachment; filename*=UTF-8''BOM%E6%AF%94%E8%BE%83.xlsx" } }; const file = await api.exportBomCompareTree({ taskId: 'task-8' }); assert(file.fileName === 'BOM比较.xlsx', 'export filename was not decoded'); const rejectedApi = loadApi({ post: async () => { const error = new Error('http 500'); error.response = { data: new Blob([JSON.stringify({ message: '导出服务异常' })]), headers: {} }; throw error; } }); message = ''; try { await rejectedApi.exportBomCompareTree({ taskId: 'task-8' }); } catch (error) { message = error.message; } assert(message === '导出服务异常', 'HTTP Blob error was not decoded'); }); console.log(`PASS ${results.length}`); results.forEach((name) => console.log(`- ${name}`)); } run().catch((error) => { console.error(`FAIL: ${error.message}`); process.exitCode = 1; });