feat(ssa): Complete Phase 2A frontend integration - multi-step workflow end-to-end
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>
This commit is contained in:
536
docs/03-业务模块/SSA-智能统计分析/05-测试文档/run_e2e_test.js
Normal file
536
docs/03-业务模块/SSA-智能统计分析/05-测试文档/run_e2e_test.js
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user