Files
HaHafeng 428a22adf2 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>
2026-02-20 23:09:27 +08:00

537 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});