import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import ExcelJS from 'exceljs'; import { createExcelAnalysisEngine, excelAnalysisInternals, } from '../excel-analysis-engine.mjs'; async function writeWorkbook(root, rows = [ ['华东', '苹果', 100], ['华南', '香蕉', 80], ]) { fs.mkdirSync(path.join(root, 'oa'), { recursive: true }); const workbook = new ExcelJS.Workbook(); const sales = workbook.addWorksheet('销售'); sales.addRow(['地区', '产品', '销售额']); for (const row of rows) sales.addRow(row); const inventory = workbook.addWorksheet('库存'); inventory.addRow(['产品', '库存']); inventory.addRow(['苹果', 20]); inventory.addRow(['香蕉', 30]); await workbook.xlsx.writeFile(path.join(root, 'oa/input.xlsx')); } test('XLS-06 bounded scan, sampling and file budgets fail fast', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-budget-')); try { await writeWorkbook(root); const tinyFileBudget = createExcelAnalysisEngine({ workspaceRoot: root, maxFileBytes: 32 }); await assert.rejects( () => tinyFileBudget.inspect({ path: 'oa/input.xlsx' }), (error) => error?.code === 'FILE_TOO_LARGE', ); const engine = createExcelAnalysisEngine({ workspaceRoot: root }); await assert.rejects( () => engine.inspect({ path: 'oa/input.xlsx', max_cells: 2 }), (error) => error?.code === 'WORKBOOK_TOO_LARGE', ); const sampled = await engine.inspect({ path: 'oa/input.xlsx', sample_rows: 1 }); assert.equal(sampled.sheets[0].samples.length, 1); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('XLS-08 follow-up analysis changes dimension while reusing the same uploaded path', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-followup-')); try { await writeWorkbook(root); const engine = createExcelAnalysisEngine({ workspaceRoot: root }); const byRegion = await engine.analyze({ path: 'oa/input.xlsx', sheet: '销售', operation: 'group_by', group_by: '地区', metric: '销售额', }); const byProduct = await engine.analyze({ path: byRegion.path, sheet: '销售', operation: 'group_by', group_by: '产品', metric: '销售额', }); assert.equal(byRegion.path, 'oa/input.xlsx'); assert.equal(byProduct.path, byRegion.path); assert.deepEqual(byProduct.result.items.map((item) => item.group), ['苹果', '香蕉']); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('XLS-09 report keeps source explanation and a safe original-file download entry', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-source-link-')); try { await writeWorkbook(root); const engine = createExcelAnalysisEngine({ workspaceRoot: root }); const report = await engine.report({ path: 'oa/input.xlsx', sheet: '销售', metric: '销售额', dimensions: ['地区', '产品'], output_path: 'public/dashboard.html', source_download_url: '/api/mindspace/v1/assets/asset-fixture/download', }); const html = fs.readFileSync(path.join(root, report.outputPath), 'utf8'); assert.match(html, /数据来源:oa\/input\.xlsx/); assert.match(html, /class="source-download"/); assert.match(html, /下载原始 Excel/); assert.throws( () => excelAnalysisInternals.safeSourceDownloadUrl('http://127.0.0.1/private.xlsx'), (error) => error?.code === 'INVALID_SOURCE_DOWNLOAD_URL', ); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('XLS-11 oversized/tool-timeout fallback never fabricates numbers and delivers a real artifact', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-fallback-')); try { await writeWorkbook(root); const engine = createExcelAnalysisEngine({ workspaceRoot: root }); const verified = await engine.analyze({ path: 'oa/input.xlsx', sheet: '销售', operation: 'group_by', group_by: '地区', metric: '销售额', }); const largePath = path.join(root, 'oa/input-large.xlsx'); const originalBytes = fs.readFileSync(path.join(root, 'oa/input.xlsx')); fs.writeFileSync(largePath, Buffer.concat([ originalBytes, Buffer.alloc((5 * 1024 * 1024) - originalBytes.length + 1024), ])); const boundedTool = createExcelAnalysisEngine({ workspaceRoot: root, maxFileBytes: 5 * 1024 * 1024, }); await assert.rejects( () => boundedTool.inspect({ path: 'oa/input-large.xlsx' }), (error) => error?.code === 'FILE_TOO_LARGE', ); const fallbackPath = path.join(root, 'public/fallback.json'); fs.mkdirSync(path.dirname(fallbackPath), { recursive: true }); fs.writeFileSync(fallbackPath, `${JSON.stringify({ source: verified.path, verified: true, items: verified.result.items, warning: '完整工作簿工具超时,交付已验证摘要', })}\n`); const delivered = JSON.parse(fs.readFileSync(fallbackPath, 'utf8')); assert.equal(delivered.verified, true); assert.equal(delivered.items.reduce((sum, item) => sum + item.value, 0), 180); assert.ok(fs.statSync(fallbackPath).size > 100); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('XLS-12 incremental workbook update preserves dashboard path, old data and confirmed dimensions', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-incremental-')); try { await writeWorkbook(root); const engine = createExcelAnalysisEngine({ workspaceRoot: root }); const args = { path: 'oa/input.xlsx', sheet: '销售', metric: '销售额', dimensions: ['地区', '产品'], output_path: 'public/stable-dashboard.html', title: '用户确认:区域销售看板', }; const initial = await engine.report(args); assert.equal(initial.model.totals.metric, 180); await writeWorkbook(root, [ ['华东', '苹果', 100], ['华南', '香蕉', 80], ['华北', '苹果', 40], ]); const updated = await engine.report(args); assert.equal(updated.outputPath, initial.outputPath); assert.equal(updated.model.totals.metric, 220); assert.deepEqual(updated.model.fields.dimensions, ['地区', '产品']); const html = fs.readFileSync(path.join(root, updated.outputPath), 'utf8'); assert.match(html, /用户确认:区域销售看板/); } finally { fs.rmSync(root, { recursive: true, force: true }); } });