Phase 2A: WorkflowPlannerService, WorkflowExecutorService, Python data quality, 6 bug fixes, DescriptiveResultView, multi-step R code/Word export, MVP UI reuse. V11 UI: Gemini-style, multi-task, single-page scroll, Word export. Architecture: Block-based rendering consensus (4 block types). New R tools: chi_square, correlation, descriptive, logistic_binary, mann_whitney, t_test_paired. Docs: dev summary, block-based plan, status updates, task list v2.0. Co-authored-by: Cursor <cursoragent@cursor.com>
537 lines
16 KiB
JavaScript
537 lines
16 KiB
JavaScript
/**
|
||
* SSA Phase 2A 端到端自动化测试
|
||
*
|
||
* 运行方式:node run_e2e_test.js
|
||
*
|
||
* 测试层次:
|
||
* - Layer 1: R 服务 - 7 个统计工具
|
||
* - Layer 2: Python DataProfile API
|
||
* - Layer 3: Node.js 后端 API
|
||
*/
|
||
|
||
const http = require('http');
|
||
const https = require('https');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// ==================== 配置 ====================
|
||
const CONFIG = {
|
||
R_SERVICE_URL: 'http://localhost:8082',
|
||
PYTHON_SERVICE_URL: 'http://localhost:8000',
|
||
BACKEND_URL: 'http://localhost:3000',
|
||
USERNAME: '13800000001',
|
||
PASSWORD: '123456',
|
||
TEST_CSV_PATH: path.join(__dirname, 'test.csv')
|
||
};
|
||
|
||
// ==================== HTTP 请求工具 ====================
|
||
|
||
function httpRequest(urlStr, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const url = new URL(urlStr);
|
||
const isHttps = url.protocol === 'https:';
|
||
const lib = isHttps ? https : http;
|
||
|
||
const reqOptions = {
|
||
hostname: url.hostname,
|
||
port: url.port || (isHttps ? 443 : 80),
|
||
path: url.pathname + url.search,
|
||
method: options.method || 'GET',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...options.headers
|
||
},
|
||
timeout: options.timeout || 60000
|
||
};
|
||
|
||
const req = lib.request(reqOptions, (res) => {
|
||
let data = '';
|
||
res.on('data', chunk => data += chunk);
|
||
res.on('end', () => {
|
||
try {
|
||
resolve({ status: res.statusCode, data: JSON.parse(data) });
|
||
} catch {
|
||
resolve({ status: res.statusCode, data });
|
||
}
|
||
});
|
||
});
|
||
|
||
req.on('error', reject);
|
||
req.on('timeout', () => {
|
||
req.destroy();
|
||
reject(new Error('Request timeout'));
|
||
});
|
||
|
||
if (options.body) {
|
||
req.write(JSON.stringify(options.body));
|
||
}
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// ==================== 日志工具 ====================
|
||
|
||
function log(emoji, message, details) {
|
||
console.log(`${emoji} ${message}`);
|
||
if (details) {
|
||
const str = JSON.stringify(details, null, 2);
|
||
const lines = str.split('\n').slice(0, 10);
|
||
console.log(' ', lines.join('\n '));
|
||
}
|
||
}
|
||
|
||
const success = (msg, details) => log('✅', msg, details);
|
||
const error = (msg, details) => log('❌', msg, details);
|
||
const info = (msg) => log('ℹ️', msg);
|
||
|
||
function section(title) {
|
||
console.log('\n' + '='.repeat(60));
|
||
console.log(` ${title}`);
|
||
console.log('='.repeat(60) + '\n');
|
||
}
|
||
|
||
// ==================== 加载测试数据 ====================
|
||
|
||
function loadTestData() {
|
||
const csvContent = fs.readFileSync(CONFIG.TEST_CSV_PATH, 'utf-8');
|
||
const lines = csvContent.trim().split('\n');
|
||
const headers = lines[0].split(',');
|
||
|
||
const data = [];
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const values = lines[i].split(',');
|
||
const row = {};
|
||
for (let j = 0; j < headers.length; j++) {
|
||
const val = values[j];
|
||
if (val === '' || val === undefined) {
|
||
row[headers[j]] = null;
|
||
} else if (!isNaN(Number(val))) {
|
||
row[headers[j]] = Number(val);
|
||
} else {
|
||
row[headers[j]] = val;
|
||
}
|
||
}
|
||
data.push(row);
|
||
}
|
||
|
||
return data;
|
||
}
|
||
|
||
// ==================== Layer 1: R 服务测试 ====================
|
||
|
||
async function testRService() {
|
||
section('Layer 1: R 服务测试(7 个统计工具 + JIT 护栏)');
|
||
|
||
const data = loadTestData();
|
||
info(`测试数据: ${data.length} 行, ${Object.keys(data[0]).length} 列`);
|
||
|
||
const results = [];
|
||
const dataSource = { type: 'inline', data };
|
||
|
||
// 1. 健康检查
|
||
try {
|
||
const res = await httpRequest(`${CONFIG.R_SERVICE_URL}/health`);
|
||
success('R 服务健康检查', {
|
||
version: res.data.version,
|
||
tools_loaded: res.data.tools_loaded
|
||
});
|
||
} catch (e) {
|
||
error('R 服务连接失败', e.message);
|
||
return { passed: false, results };
|
||
}
|
||
|
||
// 测试配置
|
||
const tests = [
|
||
{
|
||
name: 'ST_DESCRIPTIVE (描述性统计)',
|
||
endpoint: '/api/v1/skills/ST_DESCRIPTIVE',
|
||
body: {
|
||
data_source: dataSource,
|
||
params: { variables: ['age', 'bmi', 'time'], group_var: 'sex' }
|
||
},
|
||
extract: (r) => ({ summary: r.results?.summary })
|
||
},
|
||
{
|
||
name: 'ST_T_TEST_IND (独立样本T检验)',
|
||
endpoint: '/api/v1/skills/ST_T_TEST_IND',
|
||
body: {
|
||
data_source: dataSource,
|
||
params: { group_var: 'sex', value_var: 'age' },
|
||
guardrails: { check_normality: true }
|
||
},
|
||
extract: (r) => ({ p: r.results?.p_value_fmt, t: r.results?.statistic })
|
||
},
|
||
{
|
||
name: 'ST_MANN_WHITNEY (Mann-Whitney U)',
|
||
endpoint: '/api/v1/skills/ST_MANN_WHITNEY',
|
||
body: {
|
||
data_source: dataSource,
|
||
params: { group_var: 'sex', value_var: 'bmi' }
|
||
},
|
||
extract: (r) => ({ p: r.results?.p_value_fmt, U: r.results?.statistic_U })
|
||
},
|
||
{
|
||
name: 'ST_CHI_SQUARE (卡方检验)',
|
||
endpoint: '/api/v1/skills/ST_CHI_SQUARE',
|
||
body: {
|
||
data_source: dataSource,
|
||
params: { var1: 'sex', var2: 'smoke' }
|
||
},
|
||
extract: (r) => ({ p: r.results?.p_value_fmt, chi2: r.results?.statistic })
|
||
},
|
||
{
|
||
name: 'ST_CORRELATION (相关分析)',
|
||
endpoint: '/api/v1/skills/ST_CORRELATION',
|
||
body: {
|
||
data_source: dataSource,
|
||
params: { var_x: 'age', var_y: 'bmi', method: 'auto' }
|
||
},
|
||
extract: (r) => ({ r: r.results?.statistic, p: r.results?.p_value_fmt, method: r.results?.method_code })
|
||
},
|
||
{
|
||
name: 'ST_LOGISTIC_BINARY (Logistic回归)',
|
||
endpoint: '/api/v1/skills/ST_LOGISTIC_BINARY',
|
||
body: {
|
||
data_source: dataSource,
|
||
params: { outcome_var: 'Yqol', predictors: ['age', 'bmi', 'sex', 'smoke'] }
|
||
},
|
||
extract: (r) => ({
|
||
n: r.results?.n_predictors,
|
||
sig: r.results?.coefficients?.filter(c => c.significant)?.length,
|
||
aic: r.results?.model_fit?.aic
|
||
})
|
||
},
|
||
{
|
||
name: 'ST_T_TEST_PAIRED (配对T检验)',
|
||
endpoint: '/api/v1/skills/ST_T_TEST_PAIRED',
|
||
body: {
|
||
data_source: dataSource,
|
||
params: { before_var: 'mouth_open', after_var: 'bucal_relax' },
|
||
guardrails: { check_normality: true }
|
||
},
|
||
extract: (r) => ({ p: r.results?.p_value_fmt, diff: r.results?.descriptive?.difference?.mean })
|
||
},
|
||
{
|
||
name: 'JIT 护栏检查',
|
||
endpoint: '/api/v1/guardrails/jit',
|
||
body: {
|
||
data_source: dataSource,
|
||
tool_code: 'ST_T_TEST_IND',
|
||
params: { group_var: 'sex', value_var: 'age' }
|
||
},
|
||
extract: (r) => ({ checks: r.checks?.length, all_passed: r.all_checks_passed })
|
||
}
|
||
];
|
||
|
||
for (const test of tests) {
|
||
try {
|
||
const start = Date.now();
|
||
const res = await httpRequest(`${CONFIG.R_SERVICE_URL}${test.endpoint}`, {
|
||
method: 'POST',
|
||
body: test.body
|
||
});
|
||
|
||
const elapsed = Date.now() - start;
|
||
|
||
if (res.data.status === 'success') {
|
||
const extracted = test.extract(res.data);
|
||
success(`${test.name} (${elapsed}ms)`, extracted);
|
||
results.push({ name: test.name, status: 'success', time: elapsed });
|
||
} else {
|
||
error(`${test.name} 失败`, res.data.error || res.data.message);
|
||
results.push({ name: test.name, status: 'failed', time: elapsed });
|
||
}
|
||
} catch (e) {
|
||
error(`${test.name} 异常`, e.message);
|
||
results.push({ name: test.name, status: 'error', error: e.message });
|
||
}
|
||
}
|
||
|
||
const passed = results.filter(r => r.status === 'success').length;
|
||
info(`\nR 服务测试: ${passed}/${results.length} 通过`);
|
||
|
||
return { passed: passed === results.length, results, passedCount: passed, total: results.length };
|
||
}
|
||
|
||
// ==================== Layer 2: Python DataProfile 测试 ====================
|
||
|
||
async function testPythonDataProfile() {
|
||
section('Layer 2: Python DataProfile 测试');
|
||
|
||
const data = loadTestData();
|
||
|
||
// 健康检查
|
||
try {
|
||
const res = await httpRequest(`${CONFIG.PYTHON_SERVICE_URL}/api/health`);
|
||
success('Python 服务健康检查', { status: res.data.status });
|
||
} catch (e) {
|
||
error('Python 服务连接失败', e.message);
|
||
return { passed: false };
|
||
}
|
||
|
||
// DataProfile 测试
|
||
try {
|
||
const start = Date.now();
|
||
const res = await httpRequest(`${CONFIG.PYTHON_SERVICE_URL}/api/ssa/data-profile`, {
|
||
method: 'POST',
|
||
body: {
|
||
data,
|
||
max_unique_values: 20,
|
||
include_quality_score: true
|
||
}
|
||
});
|
||
|
||
const elapsed = Date.now() - start;
|
||
|
||
if (res.data.success) {
|
||
const profile = res.data.profile;
|
||
const quality = res.data.quality;
|
||
|
||
success(`DataProfile 生成成功 (${elapsed}ms)`, {
|
||
rows: profile.summary.totalRows,
|
||
columns: profile.summary.totalColumns,
|
||
numeric: profile.summary.numericColumns,
|
||
categorical: profile.summary.categoricalColumns,
|
||
missing_rate: profile.summary.overallMissingRate + '%',
|
||
quality_score: quality?.score,
|
||
quality_grade: quality?.grade
|
||
});
|
||
|
||
info('部分列画像:');
|
||
for (const col of profile.columns.slice(0, 5)) {
|
||
console.log(` - ${col.name} [${col.type}]: 缺失${col.missingRate}%`);
|
||
}
|
||
|
||
return { passed: true, profile, quality };
|
||
} else {
|
||
error('DataProfile 生成失败', res.data);
|
||
return { passed: false };
|
||
}
|
||
} catch (e) {
|
||
error('DataProfile 请求异常', e.message);
|
||
return { passed: false };
|
||
}
|
||
}
|
||
|
||
// ==================== Layer 3: Node.js 后端 API 测试 ====================
|
||
|
||
async function testBackendAPI() {
|
||
section('Layer 3: Node.js 后端 API 测试');
|
||
|
||
let token = '';
|
||
|
||
// 1. 登录
|
||
try {
|
||
const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/auth/login/password`, {
|
||
method: 'POST',
|
||
body: {
|
||
phone: CONFIG.USERNAME,
|
||
password: CONFIG.PASSWORD
|
||
}
|
||
});
|
||
|
||
if (res.data.success && res.data.data?.tokens?.accessToken) {
|
||
token = res.data.data.tokens.accessToken;
|
||
success('登录成功', {
|
||
user: res.data.data.user?.name,
|
||
token: token.substring(0, 20) + '...'
|
||
});
|
||
} else if (res.data.data?.token) {
|
||
token = res.data.data.token;
|
||
success('登录成功', { token: token.substring(0, 20) + '...' });
|
||
} else {
|
||
error('登录失败', res.data);
|
||
return { passed: false };
|
||
}
|
||
} catch (e) {
|
||
error('登录请求异常', e.message);
|
||
return { passed: false };
|
||
}
|
||
|
||
const data = loadTestData();
|
||
let sessionId = '';
|
||
|
||
// 2. 创建 SSA 会话
|
||
try {
|
||
const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/sessions`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
body: {
|
||
title: 'Phase2A 自动化测试 - ' + new Date().toISOString().slice(0, 19),
|
||
dataPayload: data,
|
||
dataSchema: {
|
||
columns: Object.keys(data[0]).map(name => ({
|
||
name,
|
||
type: typeof data[0][name] === 'number' ? 'numeric' : 'categorical'
|
||
}))
|
||
}
|
||
}
|
||
});
|
||
|
||
if (res.data.sessionId || res.data.session?.id || res.data.data?.id || res.data.id) {
|
||
sessionId = res.data.sessionId || res.data.session?.id || res.data.data?.id || res.data.id;
|
||
success('SSA 会话创建成功', { sessionId });
|
||
} else {
|
||
error('SSA 会话创建失败', res.data);
|
||
return { passed: false, token };
|
||
}
|
||
} catch (e) {
|
||
error('创建会话异常', e.message);
|
||
return { passed: false, token };
|
||
}
|
||
|
||
// 3. 生成 DataProfile(通过会话)
|
||
try {
|
||
const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/workflow/profile`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
body: { sessionId }
|
||
});
|
||
|
||
if (res.data.success) {
|
||
success('会话 DataProfile 生成成功', {
|
||
rows: res.data.profile?.summary?.totalRows
|
||
});
|
||
} else {
|
||
info('DataProfile: ' + (res.data.message || '跳过'));
|
||
}
|
||
} catch (e) {
|
||
info('DataProfile 端点: ' + e.message);
|
||
}
|
||
|
||
// 4. 规划工作流
|
||
let workflowId = '';
|
||
try {
|
||
const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/workflow/plan`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
body: {
|
||
sessionId,
|
||
userQuery: '比较不同性别的年龄和BMI差异,分析是否存在统计学显著差异'
|
||
}
|
||
});
|
||
|
||
if (res.data.success && res.data.plan) {
|
||
const plan = res.data.plan;
|
||
workflowId = res.data.workflowId || '';
|
||
success('工作流规划成功', {
|
||
workflowId,
|
||
goal: plan.goal,
|
||
steps: plan.steps?.length,
|
||
tools: plan.steps?.map(s => s.toolCode)
|
||
});
|
||
} else {
|
||
error('工作流规划失败', res.data);
|
||
return { passed: false, token, sessionId };
|
||
}
|
||
} catch (e) {
|
||
error('工作流规划异常', e.message);
|
||
return { passed: false, token, sessionId };
|
||
}
|
||
|
||
// 5. 执行工作流(如果有 workflowId)
|
||
if (workflowId) {
|
||
try {
|
||
info(`执行工作流 ${workflowId}...`);
|
||
const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/workflow/${workflowId}/execute`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
timeout: 120000 // 2 分钟超时
|
||
});
|
||
|
||
if (res.data.success) {
|
||
success('工作流执行成功', {
|
||
status: res.data.result?.status,
|
||
completedSteps: res.data.result?.completedSteps,
|
||
totalSteps: res.data.result?.totalSteps,
|
||
hasConclusion: !!res.data.result?.conclusion
|
||
});
|
||
|
||
// 显示结论摘要
|
||
if (res.data.result?.conclusion) {
|
||
info('结论摘要:');
|
||
console.log(' ' + (res.data.result.conclusion.summary || '').substring(0, 200) + '...');
|
||
}
|
||
} else {
|
||
error('工作流执行失败', res.data);
|
||
}
|
||
} catch (e) {
|
||
error('工作流执行异常', e.message);
|
||
}
|
||
}
|
||
|
||
return { passed: true, token, sessionId, workflowId };
|
||
}
|
||
|
||
// ==================== 主函数 ====================
|
||
|
||
async function main() {
|
||
console.log('\n');
|
||
console.log('╔══════════════════════════════════════════════════════════╗');
|
||
console.log('║ SSA Phase 2A 端到端自动化测试 ║');
|
||
console.log('║ ' + new Date().toISOString().slice(0, 19) + ' ║');
|
||
console.log('╚══════════════════════════════════════════════════════════╝');
|
||
|
||
const results = {
|
||
layer1: { passed: false },
|
||
layer2: { passed: false },
|
||
layer3: { passed: false }
|
||
};
|
||
|
||
// Layer 1: R 服务
|
||
try {
|
||
results.layer1 = await testRService();
|
||
} catch (e) {
|
||
error('Layer 1 测试异常', e.message);
|
||
}
|
||
|
||
// Layer 2: Python DataProfile
|
||
try {
|
||
results.layer2 = await testPythonDataProfile();
|
||
} catch (e) {
|
||
error('Layer 2 测试异常', e.message);
|
||
}
|
||
|
||
// Layer 3: Node.js 后端
|
||
try {
|
||
results.layer3 = await testBackendAPI();
|
||
} catch (e) {
|
||
error('Layer 3 测试异常', e.message);
|
||
}
|
||
|
||
// 最终汇总
|
||
section('测试汇总报告');
|
||
|
||
const layers = [
|
||
{ name: 'Layer 1: R 服务 (7工具+护栏)', result: results.layer1 },
|
||
{ name: 'Layer 2: Python DataProfile', result: results.layer2 },
|
||
{ name: 'Layer 3: Node.js 后端 API', result: results.layer3 }
|
||
];
|
||
|
||
let allPassed = true;
|
||
for (const layer of layers) {
|
||
const status = layer.result.passed ? '✅ 通过' : '❌ 失败';
|
||
let extra = '';
|
||
if (layer.result.passedCount !== undefined) {
|
||
extra = ` (${layer.result.passedCount}/${layer.result.total})`;
|
||
}
|
||
console.log(`${status} ${layer.name}${extra}`);
|
||
if (!layer.result.passed) allPassed = false;
|
||
}
|
||
|
||
console.log('\n' + '─'.repeat(60));
|
||
if (allPassed) {
|
||
console.log('🎉 Phase 2A 端到端测试全部通过!系统已准备就绪!');
|
||
} else {
|
||
console.log('⚠️ 部分测试未通过,请检查上述错误并修复');
|
||
}
|
||
console.log('─'.repeat(60) + '\n');
|
||
|
||
process.exit(allPassed ? 0 : 1);
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('测试脚本执行失败:', err);
|
||
process.exit(1);
|
||
});
|