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:
2026-02-20 23:09:27 +08:00
parent 23b422f758
commit 428a22adf2
62 changed files with 15416 additions and 299 deletions

View 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);
});