verify-bom-compare-logic.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. const fs = require('fs');
  2. const vm = require('vm');
  3. const babel = require('@babel/core');
  4. const source = fs.readFileSync(
  5. 'src/views/productionPlan/components/bomCompareDialog.logic.js',
  6. 'utf8'
  7. );
  8. const apiSource = fs.readFileSync(
  9. 'src/api/productionPlan/bomCompare.js',
  10. 'utf8'
  11. );
  12. function deferred() {
  13. let resolve;
  14. let reject;
  15. const promise = new Promise((res, rej) => {
  16. resolve = res;
  17. reject = rej;
  18. });
  19. return { promise, resolve, reject };
  20. }
  21. function loadMethods(api) {
  22. const compiled = babel.transformSync(source, {
  23. filename: 'bomCompareDialog.logic.js',
  24. babelrc: false,
  25. configFile: false,
  26. plugins: ['@babel/plugin-transform-modules-commonjs']
  27. }).code;
  28. const module = { exports: {} };
  29. const sandbox = {
  30. module,
  31. exports: module.exports,
  32. Blob,
  33. console,
  34. Promise,
  35. Map,
  36. Set,
  37. Date,
  38. String,
  39. Number,
  40. Array,
  41. Object,
  42. setTimeout,
  43. clearTimeout,
  44. window: { setTimeout, clearTimeout },
  45. require(id) {
  46. if (id === '@/api/productionPlan') {
  47. return { bomVersionList: api.bomVersionList };
  48. }
  49. if (id === '@/api/productionPlan/bomCompare') return api;
  50. throw new Error(`unexpected import: ${id}`);
  51. }
  52. };
  53. vm.runInNewContext(compiled, sandbox, {
  54. filename: 'bomCompareDialog.logic.js'
  55. });
  56. return module.exports.default;
  57. }
  58. function loadApi(request) {
  59. const compiled = babel.transformSync(apiSource, {
  60. filename: 'bomCompare.js',
  61. babelrc: false,
  62. configFile: false,
  63. plugins: ['@babel/plugin-transform-modules-commonjs']
  64. }).code;
  65. const module = { exports: {} };
  66. const sandbox = {
  67. module,
  68. exports: module.exports,
  69. Blob,
  70. Promise,
  71. Error,
  72. JSON,
  73. Number,
  74. String,
  75. decodeURIComponent,
  76. require(id) {
  77. if (id === '@/utils/request')
  78. return { __esModule: true, default: request };
  79. throw new Error(`unexpected import: ${id}`);
  80. }
  81. };
  82. vm.runInNewContext(compiled, sandbox, { filename: 'bomCompare.js' });
  83. return module.exports;
  84. }
  85. function createContext(methods) {
  86. const messages = { success: [], error: [], warning: [] };
  87. const ctx = {
  88. ...methods,
  89. visible: true,
  90. comparing: false,
  91. optionsLoading: false,
  92. detailLoading: false,
  93. exportingDifferences: false,
  94. exportingTree: false,
  95. compareLevel: 'multiple',
  96. compareMode: 'detail',
  97. showOnlyDifferences: false,
  98. showReport: true,
  99. keyword: '',
  100. activeDifferenceType: '',
  101. currentPage: 1,
  102. pageSize: 5,
  103. detailTotal: 0,
  104. taskId: '',
  105. taskStatus: '',
  106. taskMessage: '',
  107. requestSequence: 0,
  108. resultRequestSequence: 0,
  109. pollTimer: null,
  110. pollResolve: null,
  111. filterTimer: null,
  112. plan: { categoryId: 'category-1', deptId: 'dept-1' },
  113. source: { bomId: 'pbom-1', categoryId: 'category-1', version: 'V1.0' },
  114. target: { bomId: 'mbom-1', categoryId: 'category-1', version: 'V2.0' },
  115. sourceOptions: [],
  116. targetOptions: [],
  117. sourceTree: [],
  118. targetTree: [],
  119. differences: [],
  120. summary: { totalCount: 0 },
  121. pendingChanges: false,
  122. lastComparedAt: '',
  123. $message: {
  124. success(value) {
  125. messages.success.push(value);
  126. },
  127. error(value) {
  128. messages.error.push(value);
  129. },
  130. warning(value) {
  131. messages.warning.push(value);
  132. }
  133. },
  134. $nextTick(fn) {
  135. if (fn) fn();
  136. },
  137. handleTreeNodeClick() {},
  138. scrollNodeIntoView() {},
  139. downloadBlob(file) {
  140. this.downloaded = file;
  141. }
  142. };
  143. ctx.messages = messages;
  144. return ctx;
  145. }
  146. function assert(condition, message) {
  147. if (!condition) throw new Error(message);
  148. }
  149. function treeFixture() {
  150. return {
  151. baseBom: { bomId: 'pbom-1', code: 'P', name: 'PBOM', version: 'V1' },
  152. targetBom: { bomId: 'mbom-1', code: 'M', name: 'MBOM', version: 'V2' },
  153. baseTree: [
  154. {
  155. nodeKey: 'root',
  156. materialCode: 'ROOT',
  157. materialName: '根节点',
  158. differenceType: 'EQUAL',
  159. children: [
  160. {
  161. nodeKey: 'added',
  162. materialCode: 'A',
  163. materialName: '新增件占位',
  164. differenceType: 'ADDED',
  165. nodeExists: false
  166. }
  167. ]
  168. }
  169. ],
  170. targetTree: [
  171. {
  172. nodeKey: 'root',
  173. materialCode: 'ROOT',
  174. materialName: '根节点',
  175. differenceType: 'EQUAL',
  176. children: [
  177. {
  178. nodeKey: 'added',
  179. materialCode: 'A',
  180. materialName: '新增件',
  181. differenceType: 'ADDED',
  182. nodeExists: true
  183. }
  184. ]
  185. }
  186. ],
  187. summary: { addedCount: 1, totalCount: 1 }
  188. };
  189. }
  190. function pageFixture() {
  191. return {
  192. count: 1,
  193. current: 1,
  194. list: [
  195. {
  196. nodeKey: 'added',
  197. differenceType: 'ADDED',
  198. materialCode: 'A',
  199. materialName: '新增件'
  200. }
  201. ]
  202. };
  203. }
  204. async function run() {
  205. const results = [];
  206. const test = async (name, fn) => {
  207. await fn();
  208. results.push(name);
  209. };
  210. await test('任务成功后树、分页、汇总和占位节点闭环', async () => {
  211. const api = {
  212. startBomCompare: async () => ({ taskId: 'task-1', status: 'PENDING' }),
  213. getBomCompareTask: async () => ({ taskId: 'task-1', status: 'SUCCESS' }),
  214. getBomCompareTree: async () => treeFixture(),
  215. getBomComparePage: async () => pageFixture(),
  216. getBomCompareSummary: async () => ({ addedCount: 1, totalCount: 99 }),
  217. exportBomCompareDifferences: async () => ({}),
  218. exportBomCompareTree: async () => ({}),
  219. bomVersionList: async () => []
  220. };
  221. const ctx = createContext(loadMethods(api));
  222. await ctx.runCompare();
  223. assert(ctx.taskStatus === 'SUCCESS', 'success status not closed');
  224. assert(ctx.summary.totalCount === 1, 'summary total was not reconciled');
  225. assert(
  226. ctx.detailTotal === 1 && ctx.differences[0].type === 'added',
  227. 'page not normalized'
  228. );
  229. assert(
  230. ctx.sourceTree[0].children[0].nodeExists === false,
  231. 'placeholder node lost'
  232. );
  233. assert(
  234. ctx.sourceTree[0].children[0].nodeKey ===
  235. ctx.targetTree[0].children[0].nodeKey,
  236. 'tree keys not aligned'
  237. );
  238. });
  239. await test('任务失败进入可重试终态', async () => {
  240. const api = {
  241. startBomCompare: async () => ({ taskId: 'task-2', status: 'PENDING' }),
  242. getBomCompareTask: async () => ({
  243. status: 'failed',
  244. message: '后端比较失败'
  245. }),
  246. bomVersionList: async () => []
  247. };
  248. const ctx = createContext(loadMethods(api));
  249. await ctx.runCompare();
  250. assert(
  251. ctx.taskStatus === 'FAILED' && ctx.pendingChanges,
  252. 'failed task not closed'
  253. );
  254. assert(
  255. ctx.messages.error.includes('后端比较失败'),
  256. 'failed message missing'
  257. );
  258. });
  259. await test('轮询瞬时失败可恢复', async () => {
  260. let calls = 0;
  261. const api = {
  262. startBomCompare: async () => ({ taskId: 'task-3', status: 'PENDING' }),
  263. getBomCompareTask: async () => {
  264. calls += 1;
  265. if (calls === 1) throw new Error('temporary');
  266. return { status: 'SUCCESS' };
  267. },
  268. getBomCompareTree: async () => treeFixture(),
  269. getBomComparePage: async () => pageFixture(),
  270. getBomCompareSummary: async () => ({ addedCount: 1, totalCount: 1 }),
  271. bomVersionList: async () => []
  272. };
  273. const ctx = createContext(loadMethods(api));
  274. ctx.waitForNextPoll = async () => {};
  275. await ctx.runCompare();
  276. assert(
  277. calls === 2 && ctx.taskStatus === 'SUCCESS',
  278. 'poll retry did not recover'
  279. );
  280. });
  281. await test('结果加载失败不会误报任务失败', async () => {
  282. const api = {
  283. startBomCompare: async () => ({ taskId: 'task-4', status: 'PENDING' }),
  284. getBomCompareTask: async () => ({ status: 'SUCCESS' }),
  285. getBomCompareTree: async () => {
  286. throw new Error('结果树暂不可用');
  287. },
  288. getBomComparePage: async () => pageFixture(),
  289. getBomCompareSummary: async () => ({ addedCount: 1, totalCount: 1 }),
  290. bomVersionList: async () => []
  291. };
  292. const ctx = createContext(loadMethods(api));
  293. await ctx.runCompare();
  294. assert(ctx.taskStatus === 'LOAD_FAILED', 'result failure misclassified');
  295. assert(
  296. !ctx.pendingChanges && !ctx.comparing,
  297. 'result failure not terminal'
  298. );
  299. });
  300. await test('关闭弹窗后版本请求不会回写或发起比较', async () => {
  301. const source = deferred();
  302. const target = deferred();
  303. let versionCalls = 0;
  304. let starts = 0;
  305. const api = {
  306. bomVersionList: async () => {
  307. versionCalls += 1;
  308. return versionCalls === 1 ? source.promise : target.promise;
  309. },
  310. startBomCompare: async () => {
  311. starts += 1;
  312. return { taskId: 'never' };
  313. }
  314. };
  315. const ctx = createContext(loadMethods(api));
  316. const opening = ctx.open({ categoryId: 'category-1' });
  317. ctx.close();
  318. source.resolve([{ id: 'p', status: 1 }]);
  319. target.resolve([{ id: 'm', status: 1 }]);
  320. await opening;
  321. assert(!ctx.visible && starts === 0, 'closed dialog started a task');
  322. assert(
  323. ctx.sourceOptions.length === 0 && ctx.targetOptions.length === 0,
  324. 'closed dialog was mutated'
  325. );
  326. });
  327. await test('快速筛选仅接受最新响应', async () => {
  328. const firstTree = deferred();
  329. const firstPage = deferred();
  330. let treeCalls = 0;
  331. let pageCalls = 0;
  332. const api = {
  333. getBomCompareTree: async () => {
  334. treeCalls += 1;
  335. return treeCalls === 1
  336. ? firstTree.promise
  337. : { ...treeFixture(), baseTree: [] };
  338. },
  339. getBomComparePage: async () => {
  340. pageCalls += 1;
  341. return pageCalls === 1
  342. ? firstPage.promise
  343. : { count: 0, current: 1, list: [] };
  344. },
  345. getBomCompareSummary: async () => ({ addedCount: 1, totalCount: 1 }),
  346. bomVersionList: async () => []
  347. };
  348. const ctx = createContext(loadMethods(api));
  349. ctx.taskId = 'task-5';
  350. ctx.taskStatus = 'SUCCESS';
  351. const first = ctx.loadCompareResult();
  352. const second = ctx.loadCompareResult();
  353. await second;
  354. firstTree.resolve(treeFixture());
  355. firstPage.resolve(pageFixture());
  356. await first;
  357. assert(
  358. ctx.detailTotal === 0 && ctx.sourceTree.length === 0,
  359. 'stale filter response overwrote latest'
  360. );
  361. });
  362. await test('无差异的成功任务仍可导出结果树', async () => {
  363. let exported = false;
  364. const api = {
  365. exportBomCompareTree: async () => {
  366. exported = true;
  367. return { blob: new Blob(['tree']), fileName: 'tree.xlsx' };
  368. },
  369. bomVersionList: async () => []
  370. };
  371. const ctx = createContext(loadMethods(api));
  372. ctx.taskId = 'task-6';
  373. ctx.taskStatus = 'SUCCESS';
  374. ctx.summary = { totalCount: 0 };
  375. await ctx.exportTree();
  376. assert(
  377. exported && ctx.downloaded.fileName === 'tree.xlsx',
  378. 'zero-difference tree export blocked'
  379. );
  380. });
  381. await test('关闭执行中的轮询可立即收口', async () => {
  382. const api = {
  383. startBomCompare: async () => ({ taskId: 'task-7', status: 'PENDING' }),
  384. getBomCompareTask: async () => ({ status: 'RUNNING' }),
  385. bomVersionList: async () => []
  386. };
  387. const ctx = createContext(loadMethods(api));
  388. const running = ctx.runCompare();
  389. for (let index = 0; index < 20 && !ctx.pollResolve; index += 1) {
  390. await Promise.resolve();
  391. }
  392. assert(Boolean(ctx.pollResolve), 'poll wait was not entered');
  393. ctx.close();
  394. await Promise.race([
  395. running,
  396. new Promise((_, reject) =>
  397. setTimeout(
  398. () => reject(new Error('cancelled poll did not settle')),
  399. 100
  400. )
  401. )
  402. ]);
  403. assert(
  404. !ctx.visible && !ctx.comparing,
  405. 'closed polling task remained active'
  406. );
  407. });
  408. await test('导出接口识别伪 Blob 错误并解析文件名', async () => {
  409. let response = {
  410. data: new Blob([JSON.stringify({ message: '导出参数错误' })], {
  411. type: 'application/octet-stream'
  412. }),
  413. headers: {}
  414. };
  415. const api = loadApi({
  416. post: async () => response,
  417. get: async () => response
  418. });
  419. let message = '';
  420. try {
  421. await api.exportBomCompareTree({ taskId: 'task-8' });
  422. } catch (error) {
  423. message = error.message;
  424. }
  425. assert(
  426. message === '导出参数错误',
  427. 'octet-stream JSON error was downloaded'
  428. );
  429. response = {
  430. data: new Blob(['excel']),
  431. headers: {
  432. 'content-disposition':
  433. "attachment; filename*=UTF-8''BOM%E6%AF%94%E8%BE%83.xlsx"
  434. }
  435. };
  436. const file = await api.exportBomCompareTree({ taskId: 'task-8' });
  437. assert(file.fileName === 'BOM比较.xlsx', 'export filename was not decoded');
  438. const rejectedApi = loadApi({
  439. post: async () => {
  440. const error = new Error('http 500');
  441. error.response = {
  442. data: new Blob([JSON.stringify({ message: '导出服务异常' })]),
  443. headers: {}
  444. };
  445. throw error;
  446. }
  447. });
  448. message = '';
  449. try {
  450. await rejectedApi.exportBomCompareTree({ taskId: 'task-8' });
  451. } catch (error) {
  452. message = error.message;
  453. }
  454. assert(message === '导出服务异常', 'HTTP Blob error was not decoded');
  455. });
  456. console.log(`PASS ${results.length}`);
  457. results.forEach((name) => console.log(`- ${name}`));
  458. }
  459. run().catch((error) => {
  460. console.error(`FAIL: ${error.message}`);
  461. process.exitCode = 1;
  462. });