const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const source = fs.readFileSync(path.join(__dirname, '../../src/utils/list-export.js'), 'utf8') const lib = new Function(source.replace(/^export /gm, '') + ` return { EXPORT_MAX_ROWS, formatExportTime, formatExportStamp, centToYuan, splitYeekeVariation, purchaseExportRow, PURCHASE_EXPORT_HEADER, sybProductExportRow, SYB_PRODUCT_EXPORT_HEADER, centToYuanNumber, PURCHASE_EXPORT_NUMBER_COLS, SYB_PRODUCT_EXPORT_NUMBER_COLS, exportFailureMessage, runListExport }`)() test('time and stamp use local time, empty and invalid stay empty', () => { const date = new Date(2026, 9, 9, 8, 5, 3) assert.equal(lib.formatExportTime(date.toISOString()), '2026-10-09 08:05:03') assert.equal(lib.formatExportTime(null), '') assert.equal(lib.formatExportTime(''), '') assert.equal(lib.formatExportTime('not a date'), '') assert.equal(lib.formatExportStamp(date), '20261009-080503') }) test('cent to yuan keeps two decimals and leaves empty values empty', () => { assert.equal(lib.centToYuan(12345), '123.45') assert.equal(lib.centToYuan(5), '0.05') assert.equal(lib.centToYuan(1000), '10.00') assert.equal(lib.centToYuan(0), '0.00') for (const value of [null, undefined, '']) assert.equal(lib.centToYuan(value), '') }) test('yeeke variation splits at the first English or Chinese comma, none puts all in color', () => { assert.deepEqual(lib.splitYeekeVariation('白色,L【建議65-75公斤】'), { color: '白色', size: 'L【建議65-75公斤】' }) assert.deepEqual(lib.splitYeekeVariation('黑色+白色,簡約,L'), { color: '黑色+白色', size: '簡約,L' }) assert.deepEqual(lib.splitYeekeVariation('均碼'), { color: '均碼', size: '' }) assert.deepEqual(lib.splitYeekeVariation(''), { color: '', size: '' }) assert.deepEqual(lib.splitYeekeVariation(undefined), { color: '', size: '' }) }) test('purchase row maps columns in order with status label, amount and blanks', () => { assert.equal(lib.PURCHASE_EXPORT_HEADER.length, 9) const row = lib.purchaseExportRow({ shopeeOrderNoSnapshot: '001', shopeeItemIdSnapshot: '5020', targetColorSnapshot: '白色', targetSizeSnapshot: 'L', quantity: 2, status: 'order_created', pddOrderNo: 'PDD-1', orderSubmittedAt: new Date(2026, 9, 9, 1, 2, 3).toISOString(), pddOrderAmountCent: 3990, receiverName: 'must not leak', address: 'must not leak' }, value => ({ order_created: '订单已创建' })[value]) assert.deepEqual(row, ['001', '5020', '白色', 'L', 2, '订单已创建', 'PDD-1', '2026-10-09 01:02:03', 39.9]) assert.deepEqual(lib.purchaseExportRow({ quantity: 1 }), ['', '', '', '', 1, '', '', '', '']) assert.equal(lib.purchaseExportRow({ shopeeOrderNoSnapshot: 'A', status: 'failed', pddOrderAmountCent: null })[8], '') assert.equal(lib.purchaseExportRow({ pddOrderAmountCent: undefined })[8], '') assert.equal(typeof row[8], 'number') assert.equal(lib.purchaseExportRow({ pddOrderAmountCent: 0 })[8], 0) assert.deepEqual(lib.PURCHASE_EXPORT_NUMBER_COLS, [8]) assert.equal(lib.PURCHASE_EXPORT_HEADER[8], '价格(元)') }) test('cent to yuan number is numeric, zero stays zero, empty stays empty', () => { assert.equal(lib.centToYuanNumber(1230), 12.3) assert.equal(lib.centToYuanNumber(0), 0) assert.equal(lib.centToYuanNumber('1230'), 12.3) for (const value of [null, undefined, '', 'abc']) assert.equal(lib.centToYuanNumber(value), '') }) test('syb product row uses only an active match and ignores cancelled ones', () => { const product = { orderCode: 'O1', shopName: 'shop-a', shopeeItemId: '99', unitPriceCent: 1230, targetColor: '白色', targetSize: 'L', quantity: 1 } const matchedAt = new Date(2026, 9, 9, 9, 0, 0).toISOString() assert.deepEqual(lib.SYB_PRODUCT_EXPORT_HEADER, ['SYB订单', '店铺', '虾皮商品ID', 'SYB售价(TWD)', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间']) assert.deepEqual(lib.SYB_PRODUCT_EXPORT_NUMBER_COLS, [3]) assert.deepEqual(lib.sybProductExportRow(product, { status: 'confirmed', variationName: '白色,L', yeekeQuantity: 3, matchedAt }), ['O1', 'shop-a', '99', 12.3, '白色', 'L', 1, '白色', 'L', 3, '2026-10-09 09:00:00']) assert.deepEqual(lib.sybProductExportRow(product, { status: 'matched', variationName: '均碼', yeekeQuantity: 1, matchedAt }).slice(7, 10), ['均碼', '', 1]) assert.deepEqual(lib.sybProductExportRow(product, { status: 'cancelled', variationName: 'x', yeekeQuantity: 1, matchedAt }), ['O1', 'shop-a', '99', 12.3, '白色', 'L', 1, '', '', '', '']) assert.deepEqual(lib.sybProductExportRow(product, null), ['O1', 'shop-a', '99', 12.3, '白色', 'L', 1, '', '', '', '']) assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: 0 }, null)[3], 0) for (const value of [null, undefined]) assert.equal(lib.sybProductExportRow({ ...product, unitPriceCent: value }, null)[3], '') for (const value of [null, undefined]) assert.equal(lib.sybProductExportRow({ ...product, shopName: value }, null)[1], '') }) // Runs the real Export2Excel writer with file-saver stubbed and reads the xlsx back. function writeWorkbook(options) { const XLSX = require('xlsx') const src = fs.readFileSync(path.join(__dirname, '../../src/vendor/Export2Excel.js'), 'utf8') .replace(/^import .*$/gm, '').replace(/^export function/gm, 'function') let blobParts class FakeBlob { constructor(parts) { blobParts = parts } } new Function('XLSX', 'saveAs', 'Blob', 'opts', src + '\nexport_json_to_excel(opts)')(XLSX, () => {}, FakeBlob, options) return XLSX.read(blobParts[0], { type: 'array', cellNF: true }).Sheets.SheetJS } test('excel writer makes numeric cells, 0.00 format only for requested columns', () => { const header = ['a', 'b'] const data = [[12.3, 12.3], [0, 0], ['', '']] const withFmt = writeWorkbook({ header, data, filename: 't', numberFormatCols: [0] }) assert.equal(withFmt.A2.t, 'n'); assert.equal(withFmt.A2.v, 12.3); assert.equal(withFmt.A2.z, '0.00') assert.equal(withFmt.A3.t, 'n'); assert.equal(withFmt.A3.v, 0) assert.equal(withFmt.B2.t, 'n'); assert.notEqual(withFmt.B2.z, '0.00') const plain = writeWorkbook({ header, data, filename: 't' }) assert.equal(plain.A2.t, 'n'); assert.notEqual(plain.A2.z, '0.00') }) test('real export row types and format end up in the sheet', () => { const header = lib.SYB_PRODUCT_EXPORT_HEADER const rows = [ lib.sybProductExportRow({ orderCode: 'O1', shopName: 'shop-a', unitPriceCent: 1230 }, null), lib.sybProductExportRow({ orderCode: 'O2', unitPriceCent: 0 }, null), lib.sybProductExportRow({ orderCode: 'O3', unitPriceCent: null }, null) ] const sheet = writeWorkbook({ header, data: rows, filename: 't', numberFormatCols: lib.SYB_PRODUCT_EXPORT_NUMBER_COLS }) assert.equal(sheet.B1.v, '店铺') assert.equal(sheet.B2.t, 's'); assert.equal(sheet.B2.v, 'shop-a'); assert.notEqual(sheet.B2.z, '0.00') assert.equal(sheet.D1.v, 'SYB售价(TWD)') assert.equal(sheet.D2.t, 'n'); assert.equal(sheet.D2.v, 12.3); assert.equal(sheet.D2.z, '0.00') assert.equal(sheet.D3.t, 'n'); assert.equal(sheet.D3.v, 0) assert.equal(sheet.D4.v, ''); assert.notEqual(sheet.D4.t, 'n') }) function harness(total, options = {}) { const calls = []; const saved = []; const progress = [] const pageSize = options.pageSize || 100 const run = () => lib.runListExport({ pageSize, header: ['id'], filename: '测试', mapRow: item => [item.id], fetchPage: async (page, size) => { calls.push(page) if (options.failPage === page) throw new Error('boom') const start = (page - 1) * size return { total, items: Array.from({ length: Math.max(0, Math.min(size, total - start)) }, (_, i) => ({ id: start + i + 1 })) } }, save: async file => { saved.push(file) }, onProgress: (done, steps) => progress.push([done, steps]), ...options.extra }) return { run, calls, saved, progress } } test('reads every page, reports progress, saves once with a timestamped name', async () => { const h = harness(250) const result = await h.run() assert.deepEqual(result, { ok: true, total: 250, rows: 250 }) assert.deepEqual(h.calls, [1, 2, 3]) assert.deepEqual(h.progress, [[1, 3], [2, 3], [3, 3]]) assert.equal(h.saved.length, 1) assert.equal(h.saved[0].data.length, 250) assert.deepEqual(h.saved[0].data[249], [250]) assert.match(h.saved[0].filename, /^测试_\d{8}-\d{6}$/) }) test('exactly 5000 rows are allowed, 5001 is refused after reading only page 1', async () => { const ok = harness(5000, { pageSize: 500 }) assert.equal((await ok.run()).ok, true) assert.equal(ok.calls.length, 10) const over = harness(5001, { pageSize: 500 }) const result = await over.run() assert.deepEqual(result, { ok: false, reason: 'too_many', total: 5001 }) assert.deepEqual(over.calls, [1]) assert.equal(over.saved.length, 0) assert.equal(lib.exportFailureMessage(result), '共 5001 条,超过 5000 条上限,请缩小筛选范围') }) test('empty result does not save', async () => { const h = harness(0) const result = await h.run() assert.deepEqual(result, { ok: false, reason: 'empty', total: 0 }) assert.equal(h.saved.length, 0) assert.match(lib.exportFailureMessage(result), /没有数据/) }) test('a failing page stops the export without saving', async () => { const h = harness(450, { failPage: 3 }) const result = await h.run() assert.equal(result.ok, false) assert.equal(result.reason, 'page_failed') assert.equal(result.page, 3) assert.deepEqual(h.calls, [1, 2, 3]) assert.equal(h.saved.length, 0) assert.equal(lib.exportFailureMessage(result), '导出失败:第 3 页读取失败,未生成文件') const first = harness(10, { failPage: 1 }) assert.equal((await first.run()).page, 1) assert.equal(first.saved.length, 0) }) test('enrich runs per batch (not per row), counts in progress, and a failure saves nothing', async () => { const batches = [] const h = harness(450, { pageSize: 200, extra: { enrichBatchSize: 200, enrich: async items => { batches.push(items.length); return items.map(item => ({ ...item, id: item.id * 10 })) } } }) const result = await h.run() assert.equal(result.ok, true) assert.deepEqual(batches, [200, 200, 50]) assert.deepEqual(h.saved[0].data[0], [10]) assert.deepEqual(h.progress[h.progress.length - 1], [6, 6]) const bad = harness(450, { pageSize: 200, extra: { enrich: async () => { throw new Error('boom') } } }) const failed = await bad.run() assert.equal(failed.reason, 'enrich_failed') assert.equal(bad.saved.length, 0) })