177 lines
6.6 KiB
JavaScript
177 lines
6.6 KiB
JavaScript
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 });
|
|
}
|
|
});
|