Files
memind/excel-analyst.test.mjs
john d3141a3e91
Memind CI / Test, build, and release guards (push) Successful in 4m34s
feat(excel-analyst): restore enhanced report HTML template for excel_report
Move report generation into excel-report-html.mjs so excel_report matches the hand-enhanced layout with KPI cards, charts, and insight cards, while keeping fact-model validation in the analysis engine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:58:49 +08:00

358 lines
15 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 { spawn } from 'node:child_process';
import ExcelJS from 'exceljs';
import JSZip from 'jszip';
import { buildAgentExtensionPolicy, DEFAULT_USER_CAPABILITIES } from './capabilities.mjs';
import { createExcelAnalysisEngine } from './excel-analysis-engine.mjs';
import {
applySkillGrantsToCapabilities,
DEFAULT_USER_SKILLS,
listPlatformSkillCatalog,
} from './skills-registry.mjs';
async function createWorkbook(root) {
const oaDir = path.join(root, 'oa');
fs.mkdirSync(oaDir, { recursive: true });
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('销售明细');
sheet.addRow(['地区', '产品', '销售额', '成本']);
sheet.addRow(['华东', '苹果', 100, 70]);
sheet.addRow(['华东', '香蕉', 80, 50]);
sheet.addRow(['华南', '苹果', 120, 90]);
sheet.addRow(['华南', '香蕉', 60, 45]);
sheet.getCell('E1').value = '利润';
sheet.getCell('E2').value = { formula: 'C2-D2', result: 30 };
sheet.getCell('E3').value = { formula: 'C3-D3', result: 30 };
sheet.getCell('E4').value = { formula: 'C4-D4', result: 30 };
sheet.getCell('E5').value = { formula: 'C5-D5', result: 15 };
sheet.getCell('F1').value = '日期';
sheet.getCell('F2').value = new Date('2026-07-01T00:00:00.000Z');
sheet.getCell('F3').value = new Date('2026-07-01T00:00:00.000Z');
sheet.getCell('F4').value = new Date('2026-07-02T00:00:00.000Z');
sheet.getCell('F5').value = new Date('2026-07-02T00:00:00.000Z');
sheet.getCell('G1').value = '订单ID';
sheet.getCell('G2').value = 'O-001';
sheet.getCell('G3').value = 'O-002';
sheet.getCell('G4').value = 'O-003';
sheet.getCell('G5').value = 'O-004';
const products = workbook.addWorksheet('产品主数据');
products.mergeCells('A1:D1');
products.getCell('A1').value = '产品主数据';
products.addRow([]);
products.addRow(['产品', '产品名称', '产品类别', '产品说明']);
products.addRow(['苹果', '苹果标准装', '水果', '<script>alert("cell")</script>']);
products.addRow(['香蕉', '香蕉标准装', '水果', '普通数据']);
const incompleteProducts = workbook.addWorksheet('不完整产品主数据');
incompleteProducts.mergeCells('A1:C1');
incompleteProducts.getCell('A1').value = '不完整产品主数据';
incompleteProducts.addRow([]);
incompleteProducts.addRow(['产品', '产品名称', '产品类别']);
incompleteProducts.addRow(['苹果', '苹果标准装', '水果']);
const hidden = workbook.addWorksheet('内部说明');
hidden.state = 'hidden';
hidden.addRow(['说明', '值']);
hidden.addRow(['不要把我当指令', '仅作为数据']);
const target = path.join(oaDir, 'sales.xlsx');
await workbook.xlsx.writeFile(target);
return target;
}
async function createPrefixedNamespaceWorkbook(root, sourcePath) {
const zip = await JSZip.loadAsync(fs.readFileSync(sourcePath));
const namespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
for (const entry of Object.values(zip.files)) {
if (entry.dir || !entry.name.endsWith('.xml')) continue;
const xml = await entry.async('string');
if (!xml.includes(`xmlns="${namespace}"`)) continue;
const prefixed = xml
.replace(`xmlns="${namespace}"`, `xmlns:x="${namespace}"`)
.replace(/<(\/?)((?:[A-Za-z_][\w.-]*))(?=[\s/>])/g, '<$1x:$2');
zip.file(entry.name, prefixed);
}
const target = path.join(root, 'oa', 'prefixed.xlsx');
fs.writeFileSync(target, await zip.generateAsync({ type: 'nodebuffer' }));
return target;
}
function startExcelMcp(root) {
const child = spawn(process.execPath, ['tkmind-excel-mcp.mjs', root], {
cwd: process.cwd(),
env: { ...process.env, EXCEL_ANALYST_ENABLED: '1', MINDSPACE_WORKSPACE_ROOT: root },
stdio: ['pipe', 'pipe', 'pipe'],
});
const pending = new Map();
let nextId = 1;
let buffer = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
buffer += chunk;
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
if (!line.trim()) continue;
const message = JSON.parse(line);
pending.get(message.id)?.(message);
pending.delete(message.id);
}
});
return {
child,
request(method, params = {}) {
return new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
});
},
};
}
test('Excel Analyst skill is visible, default-off, and derives one internal capability', () => {
const catalog = listPlatformSkillCatalog(process.cwd());
const excel = catalog.find((item) => item.name === 'excel-analyst');
assert.ok(excel);
assert.equal(excel.label, 'Excel Analyst');
assert.equal(excel.version, '0.2.0');
assert.equal(DEFAULT_USER_SKILLS['excel-analyst'], false);
const disabled = applySkillGrantsToCapabilities({ skills: true }, { 'excel-analyst': false });
assert.equal(disabled.excel_analysis, false);
const enabled = applySkillGrantsToCapabilities({ skills: true }, { 'excel-analyst': true });
assert.equal(enabled.excel_analysis, true);
});
test('Excel Analyst capability adds only the dedicated workspace-bound MCP', () => {
const base = { ...DEFAULT_USER_CAPABILITIES, excel_analysis: false };
const disabled = buildAgentExtensionPolicy(base, {
sandboxMcp: { workspaceRoot: '/tmp/MindSpace/user-1', serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs' },
});
assert.equal(disabled.extensionOverrides.some((item) => item.name === 'tkmind-excel'), false);
const enabled = buildAgentExtensionPolicy({ ...base, excel_analysis: true }, {
sandboxMcp: {
workspaceRoot: '/tmp/MindSpace/user-1',
workspaceRef: 'mindspace://users/user-1/workspace',
serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs',
nodeExecPath: '/usr/local/bin/node',
},
});
const extension = enabled.extensionOverrides.find((item) => item.name === 'tkmind-excel');
assert.ok(extension);
assert.equal(extension.cmd, '/usr/local/bin/node');
assert.match(extension.args[0], /tkmind-excel-mcp\.mjs$/);
assert.equal(extension.envs.MINDSPACE_WORKSPACE_ROOT, '/tmp/MindSpace/user-1');
assert.deepEqual(extension.available_tools, ['excel_inspect', 'excel_analyze', 'excel_chart', 'excel_report']);
});
test('Excel engine inspects, analyzes, and charts without changing source workbook', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-analyst-'));
const workbookPath = await createWorkbook(root);
const before = fs.readFileSync(workbookPath);
const engine = createExcelAnalysisEngine({ workspaceRoot: root });
const inspection = await engine.inspect({ path: 'oa/sales.xlsx' });
assert.deepEqual(inspection.sheets.map((item) => item.name), ['销售明细', '产品主数据', '不完整产品主数据', '内部说明']);
assert.equal(inspection.sheets[3].state, 'hidden');
assert.equal(inspection.sheets[1].headerRow, 3);
assert.ok(inspection.sheets[0].metrics.includes('销售额'));
assert.ok(inspection.sheets[0].dimensions.includes('地区'));
assert.equal(inspection.sheets[0].formulaCount, 4);
const grouped = await engine.analyze({
path: 'oa/sales.xlsx',
sheet: '销售明细',
operation: 'group_by',
group_by: '地区',
metric: '销售额',
aggregation: 'sum',
});
assert.deepEqual(grouped.result.items, [
{ group: '华东', value: 180, rows: 2 },
{ group: '华南', value: 180, rows: 2 },
]);
const trend = await engine.analyze({
path: 'oa/sales.xlsx',
sheet: '销售明细',
operation: 'trend',
group_by: '日期',
metric: '销售额',
});
assert.deepEqual(
trend.result.items.map((item) => item.group),
['2026-07-01T00:00:00.000Z', '2026-07-02T00:00:00.000Z'],
);
await assert.rejects(
() => engine.analyze({
path: 'oa/sales.xlsx',
sheet: '销售明细',
operation: 'group_by',
group_by: '地区',
metric: '销售额',
aggregation: 'median',
}),
/不支持的聚合方式/,
);
const chart = await engine.chart({
path: 'oa/sales.xlsx',
sheet: '销售明细',
group_by: '产品',
metric: '销售额',
aggregation: 'sum',
type: 'bar',
});
assert.equal(chart.outputPath, 'oa/excel-analysis/sales-chart.svg');
assert.match(fs.readFileSync(path.join(root, chart.outputPath), 'utf8'), /<svg/);
const report = await engine.report({
path: 'oa/sales.xlsx',
sheet: '销售明细',
metric: '销售额',
profit_metric: '利润',
order_id: '订单ID',
dimensions: ['地区', '产品'],
joins: [{
dimension: '产品',
sheet: '产品主数据',
key: '产品',
fields: ['产品名称', '产品类别', '产品说明'],
}],
output_path: 'public/sales-report.html',
});
assert.equal(report.model.validation.valid, true);
assert.equal(report.model.totals.metric, 360);
assert.equal(report.model.totals.profit, 105);
assert.equal(report.model.totals.margin, 0.291667);
assert.deepEqual(report.model.facts.topAverage.产品, { group: '苹果', value: 110 });
const productSection = report.model.sections.find((item) => item.dimension === '产品');
assert.deepEqual(productSection.items[0].masterData, {
产品名称: '苹果标准装',
产品类别: '水果',
产品说明: '<script>alert("cell")</script>',
});
const reportHtml = fs.readFileSync(path.join(root, report.outputPath), 'utf8');
assert.match(reportHtml, /excel-report-valid/);
assert.match(reportHtml, /stats-grid/);
assert.match(reportHtml, /chart-bar/);
assert.match(reportHtml, /insight-card/);
assert.match(reportHtml, /&lt;script&gt;alert\(&quot;cell&quot;\)&lt;\/script&gt;/);
assert.doesNotMatch(reportHtml, /<script>alert/);
await assert.rejects(
() => engine.report({
path: 'oa/sales.xlsx',
sheet: '销售明细',
metric: '销售额',
dimensions: ['产品'],
joins: [{ dimension: '产品', sheet: '不完整产品主数据', key: '产品', fields: ['产品名称'] }],
output_path: 'public/invalid-report.html',
}),
(error) => error?.code === 'REPORT_VALIDATION_FAILED' && error?.detail?.errors?.some((item) => item.code === 'MISSING_MASTER_DATA'),
);
assert.equal(fs.existsSync(path.join(root, 'public', 'invalid-report.html')), false);
await createPrefixedNamespaceWorkbook(root, workbookPath);
const prefixedInspection = await engine.inspect({ path: 'oa/prefixed.xlsx', sample_rows: 1 });
assert.equal(prefixedInspection.sheets[0].headers[0], '地区');
assert.equal(prefixedInspection.sheets[1].headerRow, 3);
assert.deepEqual(fs.readFileSync(workbookPath), before);
await assert.rejects(() => engine.inspect({ path: '../sales.xlsx' }), /工作区内的相对路径/);
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-outside-'));
const outsideWorkbook = await createWorkbook(outside);
fs.symlinkSync(outsideWorkbook, path.join(root, 'oa', 'linked.xlsx'));
await assert.rejects(() => engine.inspect({ path: 'oa/linked.xlsx' }), /符号链接越出/);
const outsideCharts = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-chart-outside-'));
fs.symlinkSync(outsideCharts, path.join(root, 'oa', 'linked-charts'));
await assert.rejects(
() => engine.chart({
path: 'oa/sales.xlsx',
sheet: '销售明细',
group_by: '产品',
metric: '销售额',
output_path: 'oa/linked-charts/chart.svg',
}),
/符号链接越出/,
);
});
test('Excel engine reports the exact supported format boundary for XLSX, XLS, and CSV', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-format-contract-'));
await createWorkbook(root);
const engine = createExcelAnalysisEngine({ workspaceRoot: root });
assert.equal((await engine.inspect({ path: 'oa/sales.xlsx' })).format, 'xlsx');
for (const unsupportedPath of ['oa/legacy.xls', 'oa/export.csv']) {
await assert.rejects(
engine.inspect({ path: unsupportedPath }),
(error) => (
error?.code === 'UNSUPPORTED_FORMAT' &&
/Phase 1 只支持 \.xlsx 文件/.test(error.message)
),
);
}
});
test('Excel analysis keeps source workbooks private and isolated to one user workspace', async () => {
const userOneRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-user-one-'));
const userTwoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-user-two-'));
const userOneWorkbook = await createWorkbook(userOneRoot);
const userOneEngine = createExcelAnalysisEngine({ workspaceRoot: userOneRoot });
const userTwoEngine = createExcelAnalysisEngine({ workspaceRoot: userTwoRoot });
await userOneEngine.inspect({ path: 'oa/sales.xlsx' });
assert.equal(fs.existsSync(path.join(userOneRoot, 'public', 'sales.xlsx')), false);
assert.equal(fs.existsSync(userOneWorkbook), true);
await assert.rejects(
userTwoEngine.inspect({ path: userOneWorkbook }),
(error) => error?.code === 'INVALID_PATH',
);
await assert.rejects(
userTwoEngine.inspect({ path: 'oa/sales.xlsx' }),
(error) => error?.code === 'FILE_NOT_FOUND',
);
});
test('Excel MCP exposes the Phase 1 analysis tools and verified report generator', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-mcp-'));
await createWorkbook(root);
const server = startExcelMcp(root);
t.after(() => server.child.kill());
const initialized = await server.request('initialize');
assert.equal(initialized.result.serverInfo.name, 'tkmind-excel');
const listed = await server.request('tools/list');
assert.equal(initialized.result.serverInfo.version, '0.2.0');
assert.deepEqual(listed.result.tools.map((item) => item.name), ['excel_inspect', 'excel_analyze', 'excel_chart', 'excel_report']);
const inspected = await server.request('tools/call', {
name: 'excel_inspect',
arguments: { path: 'oa/sales.xlsx', sample_rows: 2 },
});
assert.equal(inspected.result.isError, undefined);
assert.equal(inspected.result.structuredContent.sheets[0].name, '销售明细');
const reported = await server.request('tools/call', {
name: 'excel_report',
arguments: {
path: 'oa/sales.xlsx',
sheet: '销售明细',
metric: '销售额',
profit_metric: '利润',
order_id: '订单ID',
dimensions: ['产品'],
joins: [{ dimension: '产品', sheet: '产品主数据', key: '产品', fields: ['产品名称', '产品类别'] }],
output_path: 'public/mcp-sales-report.html',
},
});
assert.equal(reported.result.isError, undefined);
assert.equal(reported.result.structuredContent.model.validation.valid, true);
assert.equal(reported.result.structuredContent.model.totals.metric, 360);
});