Files
gerenjizhang/backend/test-time-fix-regression.js

608 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
/**
* =============================================
* 时间显示修复 - 完整回归测试脚本
* =============================================
* 测试目标: 验证 createdAt 排序修复和 parseDate 本地时区解析
* 测试环境: 后端 API (localhost:3001)
* 测试日期: 2026-04-26
* =============================================
*/
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const BASE_URL = 'http://localhost:3001';
const USER_ID = 1;
// 测试报告
const report = {
total: 0,
passed: 0,
failed: 0,
warnings: 0,
results: [],
startTime: new Date(),
endTime: null
};
function log(msg, level = 'INFO') {
const icon = { INFO: '📝', PASS: '✅', FAIL: '❌', WARN: '⚠️', HEADER: '📋', SEP: '─' };
const prefix = icon[level] || '📝';
console.log(`${prefix} ${msg}`);
}
function recordTest(name, status, detail = '') {
report.total++;
if (status === 'PASS') {
report.passed++;
log(`${name} - PASS${detail ? ' | ' + detail : ''}`, 'PASS');
} else if (status === 'WARN') {
report.warnings++;
log(`${name} - WARN${detail ? ' | ' + detail : ''}`, 'WARN');
} else {
report.failed++;
log(`${name} - FAIL${detail ? ' | ' + detail : ''}`, 'FAIL');
}
report.results.push({ name, status, detail });
}
// API 请求封装
function apiRequest(method, path, body = null) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const options = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: { 'Content-Type': 'application/json' }
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve({ statusCode: res.statusCode, body: JSON.parse(data) });
} catch (e) {
resolve({ statusCode: res.statusCode, body: data });
}
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
// 创建记录辅助函数
async function createRecord(type, category, amount, description, date = null) {
const payload = {
userId: USER_ID,
accountId: 3,
type,
amount,
category,
description,
date: date || new Date().toISOString().split('T')[0]
};
const result = await apiRequest('POST', '/api/records', payload);
if (result.body.success) {
return result.body.data;
} else {
throw new Error(`创建记录失败: ${result.body.message}`);
}
}
// 获取记录辅助函数
async function getRecords(params = {}) {
const query = new URLSearchParams({ userId: USER_ID, ...params });
const result = await apiRequest('GET', `/api/records?${query}`);
if (result.body.success) {
return result.body.data;
}
return [];
}
// 删除记录辅助函数
async function deleteRecord(id) {
await apiRequest('DELETE', `/api/records/${id}`);
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// =============================================
// 主测试流程
// =============================================
async function runTests() {
console.log('\n' + '='.repeat(60));
console.log('🧪 时间显示修复 - 完整回归测试');
console.log('='.repeat(60));
console.log(`测试开始时间: ${new Date().toLocaleString('zh-CN')}`);
console.log(`后端地址: ${BASE_URL}`);
console.log(`测试用户: userId=${USER_ID}`);
console.log('='.repeat(60) + '\n');
// ==========================================
// 测试1: 验证后端 parseDate 函数
// ==========================================
log('═══════════════════════════════════════════════════════', 'HEADER');
log('【测试1】验证后端 parseDate 函数对纯日期字符串的解析', 'HEADER');
log('═══════════════════════════════════════════════════════', 'HEADER');
// 创建一条记录,使用纯日期字符串
const now = new Date();
const dateStr = '2026-04-26';
const record1 = await createRecord('expense', '餐饮', 10, 'parseDate测试', dateStr);
const parsedDate = new Date(record1.date);
const expectedHour = 0; // 应该解析为当天 00:00 北京时间
const actualHour = parsedDate.getHours();
log(`输入日期字符串: "${dateStr}"`, 'INFO');
log(`解析结果: ${parsedDate.toLocaleString('zh-CN')}`, 'INFO');
log(`解析结果 ISO: ${parsedDate.toISOString()}`, 'INFO');
log(`本地时区小时: ${actualHour}:00`, 'INFO');
if (actualHour === expectedHour) {
recordTest('parseDate 解析纯日期为本地时区 00:00', 'PASS',
`解析为 ${parsedDate.toLocaleString('zh-CN')} (北京时间 00:00,非 UTC 00:00)`);
} else {
recordTest('parseDate 解析纯日期为本地时区 00:00', 'FAIL',
`期望 00:00,实际 ${actualHour}:00`);
}
// 验证 createdAt 为实际创建时间
const createdAt = new Date(record1.createdAt);
const createdAtDiff = Math.abs(createdAt.getTime() - now.getTime());
log(`记录创建时间 (createdAt): ${createdAt.toLocaleString('zh-CN')}`, 'INFO');
log(`创建时间差: ${createdAtDiff}ms`, 'INFO');
if (createdAtDiff < 5000) {
recordTest('createdAt 反映实际创建时间', 'PASS',
`与当前时间差 ${createdAtDiff}ms < 5s`);
} else {
recordTest('createdAt 反映实际创建时间', 'FAIL',
`与当前时间差 ${createdAtDiff}ms > 5s`);
}
await deleteRecord(record1.id);
// ==========================================
// 测试2: 验证后端 API 返回的 createdAt 字段
// ==========================================
log('\n═══════════════════════════════════════════════════════', 'HEADER');
log('【测试2】验证后端 API 返回的 createdAt 字段是否正确', 'HEADER');
log('═══════════════════════════════════════════════════════', 'HEADER');
const beforeCreate = new Date();
await sleep(100);
const record2 = await createRecord('expense', '交通', 20, 'createdAt验证');
const afterCreate = new Date();
const apiCreatedAt = new Date(record2.createdAt);
log(`创建前时间: ${beforeCreate.toLocaleString('zh-CN')}`, 'INFO');
log(`API返回 createdAt: ${apiCreatedAt.toLocaleString('zh-CN')}`, 'INFO');
log(`创建后时间: ${afterCreate.toLocaleString('zh-CN')}`, 'INFO');
const isWithinRange = apiCreatedAt >= beforeCreate && apiCreatedAt <= afterCreate;
if (isWithinRange) {
recordTest('API 返回的 createdAt 在创建时间范围内', 'PASS',
`${apiCreatedAt.toLocaleString('zh-CN')} 在 [${beforeCreate.toLocaleString('zh-CN')}, ${afterCreate.toLocaleString('zh-CN')}] 内`);
} else {
recordTest('API 返回的 createdAt 在创建时间范围内', 'FAIL',
`createdAt 不在预期时间范围内`);
}
// 验证 createdAt 包含时分秒
const hasTimeComponent = record2.createdAt.includes(':');
if (hasTimeComponent) {
recordTest('createdAt 包含时分秒信息', 'PASS', `值: ${record2.createdAt}`);
} else {
recordTest('createdAt 包含时分秒信息', 'FAIL', `值: ${record2.createdAt}`);
}
await deleteRecord(record2.id);
// ==========================================
// 测试3: 验证后端排序逻辑 (orderBy: { createdAt: 'desc' })
// ==========================================
log('\n═══════════════════════════════════════════════════════', 'HEADER');
log('【测试3】验证后端排序逻辑 (orderBy: createdAt desc)', 'HEADER');
log('═══════════════════════════════════════════════════════', 'HEADER');
// 清空现有记录,确保测试环境干净
const existingRecords = await getRecords();
for (const r of existingRecords) {
await deleteRecord(r.id);
}
log(`已清理 ${existingRecords.length} 条现有记录`, 'INFO');
// 创建3条同一天但不同时间的记录
const records = [];
for (let i = 0; i < 3; i++) {
const r = await createRecord('expense', '餐饮', 10 + i, `同天记录${i + 1}`);
records.push(r);
log(`创建记录 ${i + 1}: ID=${r.id}, createdAt=${new Date(r.createdAt).toLocaleTimeString('zh-CN')}`, 'INFO');
await sleep(500); // 确保 createdAt 有差异
}
// 获取排序后的记录
const sortedRecords = await getRecords();
log(`\n后端返回的排序顺序:`, 'INFO');
sortedRecords.forEach((r, i) => {
log(` [${i + 1}] ${r.category} ¥${r.amount} ${r.description} | createdAt=${new Date(r.createdAt).toLocaleTimeString('zh-CN')}`, 'INFO');
});
// 验证最新的记录在最前面
const firstRecord = sortedRecords[0];
const lastRecord = sortedRecords[sortedRecords.length - 1];
if (firstRecord && firstRecord.description === '同天记录3') {
recordTest('最新创建的记录排在第一位', 'PASS', `ID=${firstRecord.id}`);
} else {
recordTest('最新创建的记录排在第一位', 'FAIL',
`期望"同天记录3",实际"${firstRecord?.description}"`);
}
// 验证排序是严格降序
let isStrictDescending = true;
for (let i = 0; i < sortedRecords.length - 1; i++) {
const t1 = new Date(sortedRecords[i].createdAt).getTime();
const t2 = new Date(sortedRecords[i + 1].createdAt).getTime();
if (t1 < t2) {
isStrictDescending = false;
break;
}
}
if (isStrictDescending) {
recordTest('后端排序为严格降序', 'PASS', `${sortedRecords.length} 条记录排序正确`);
} else {
recordTest('后端排序为严格降序', 'FAIL', '排序出现异常');
}
// ==========================================
// 测试4: 验证前端 Dashboard 排序逻辑
// ==========================================
log('\n═══════════════════════════════════════════════════════', 'HEADER');
log('【测试4】验证前端 Dashboard 使用 createdAt 排序', 'HEADER');
log('═══════════════════════════════════════════════════════', 'HEADER');
// 读取前端代码验证排序逻辑
const dashboardPath = path.join(__dirname, '..', 'frontend', 'src', 'pages', 'Dashboard', 'index.tsx');
if (fs.existsSync(dashboardPath)) {
const dashboardCode = fs.readFileSync(dashboardPath, 'utf-8');
// 检查是否使用 createdAt 排序
const hasCreatedAtSort = dashboardCode.includes('.sort((a, b) => new Date(b.createdAt)');
const hasDateSort = dashboardCode.includes('.sort((a, b) => new Date(b.date)') && !hasCreatedAtSort;
if (hasCreatedAtSort) {
recordTest('Dashboard 使用 createdAt 排序', 'PASS', '代码使用 new Date(b.createdAt) 排序');
} else if (hasDateSort) {
recordTest('Dashboard 使用 createdAt 排序', 'FAIL', '代码仍使用 new Date(b.date) 排序');
} else {
recordTest('Dashboard 使用 createdAt 排序', 'WARN', '未能识别排序逻辑,请手动确认');
}
// 检查 formatRecordTime 函数
const hasFormatRecordTime = dashboardCode.includes('const formatRecordTime');
const usesCreatedAtForTime = dashboardCode.includes('new Date(record.createdAt)');
if (hasFormatRecordTime && usesCreatedAtForTime) {
recordTest('Dashboard 时间格式化使用 createdAt', 'PASS', 'formatRecordTime 使用 record.createdAt');
} else {
recordTest('Dashboard 时间格式化使用 createdAt', 'FAIL', '时间格式化未使用 createdAt');
}
// 检查时间显示逻辑
const showsHHMMForToday = dashboardCode.includes("createdAt.getHours()") && dashboardCode.includes("createdAt.getMinutes()");
if (showsHHMMForToday) {
recordTest('Dashboard 今天显示 HH:MM 格式', 'PASS', '使用 getHours() 和 getMinutes() 格式化');
} else {
recordTest('Dashboard 今天显示 HH:MM 格式', 'FAIL', '未找到 HH:MM 格式化逻辑');
}
const showsYesterday = dashboardCode.includes("'昨天'") || dashboardCode.includes('"昨天"');
if (showsYesterday) {
recordTest('Dashboard 昨天显示"昨天"文本', 'PASS', '包含"昨天"显示逻辑');
} else {
recordTest('Dashboard 昨天显示"昨天"文本', 'WARN', '未找到"昨天"显示逻辑');
}
} else {
recordTest('Dashboard 文件存在性', 'FAIL', `文件不存在: ${dashboardPath}`);
}
// ==========================================
// 测试5: 验证前端 Record 页面排序逻辑
// ==========================================
log('\n═══════════════════════════════════════════════════════', 'HEADER');
log('【测试5】验证前端 Record 页面使用 createdAt 排序', 'HEADER');
log('═══════════════════════════════════════════════════════', 'HEADER');
const recordPath = path.join(__dirname, '..', 'frontend', 'src', 'pages', 'Record', 'index.tsx');
if (fs.existsSync(recordPath)) {
const recordCode = fs.readFileSync(recordPath, 'utf-8');
// 检查排序逻辑
const hasCreatedAtSort = recordCode.includes('.sort((a, b) => new Date(b.createdAt)');
const hasDateSort = recordCode.includes('.sort((a, b) => new Date(b.date)') && !hasCreatedAtSort;
if (hasCreatedAtSort) {
recordTest('Record 页面使用 createdAt 排序', 'PASS', '代码使用 new Date(b.createdAt) 排序');
} else if (hasDateSort) {
recordTest('Record 页面使用 createdAt 排序', 'FAIL', '代码仍使用 new Date(b.date) 排序');
} else {
recordTest('Record 页面使用 createdAt 排序', 'WARN', '未能识别排序逻辑');
}
// 检查 formatTime 函数
const hasFormatTime = recordCode.includes('const formatTime');
const usesCreatedAtForTime = recordCode.includes('new Date(record.createdAt)');
if (hasFormatTime && usesCreatedAtForTime) {
recordTest('Record 页面时间格式化使用 createdAt', 'PASS', 'formatTime 使用 record.createdAt');
} else {
recordTest('Record 页面时间格式化使用 createdAt', 'FAIL', '时间格式化未使用 createdAt');
}
// 检查今天显示 HH:MM
const showsHHMM = recordCode.includes("hour: '2-digit'") && recordCode.includes("minute: '2-digit'");
if (showsHHMM) {
recordTest('Record 页面今天显示 HH:MM 格式', 'PASS', '使用 toLocaleTimeString 格式化');
} else {
recordTest('Record 页面今天显示 HH:MM 格式', 'FAIL', '未找到 HH:MM 格式化逻辑');
}
// 检查昨天显示
const showsYesterday = recordCode.includes("'昨天'") || recordCode.includes('"昨天"');
if (showsYesterday) {
recordTest('Record 页面昨天显示"昨天"文本', 'PASS', '包含"昨天"显示逻辑');
} else {
recordTest('Record 页面昨天显示"昨天"文本', 'WARN', '未找到"昨天"显示逻辑');
}
// 检查过滤后再排序
const filterThenSort = recordCode.includes('.filter(') && recordCode.includes('.sort(');
if (filterThenSort) {
recordTest('Record 页面先过滤再排序', 'PASS', '排序在过滤后执行');
} else {
recordTest('Record 页面先过滤再排序', 'WARN', '过滤和排序顺序需确认');
}
} else {
recordTest('Record 文件存在性', 'FAIL', `文件不存在: ${recordPath}`);
}
// ==========================================
// 测试6: 验证同一天多条记录的排序
// ==========================================
log('\n═══════════════════════════════════════════════════════', 'HEADER');
log('【测试6】验证同一天添加多条记录的排序稳定性', 'HEADER');
log('═══════════════════════════════════════════════════════', 'HEADER');
// 当前记录应该已经是按 createdAt 降序(来自测试3
// 再添加2条记录,验证最新始终在最前
const extra1 = await createRecord('expense', '购物', 50, '额外记录1');
log(`创建额外记录1: ID=${extra1.id}`, 'INFO');
await sleep(500);
const extra2 = await createRecord('income', '兼职', 100, '额外记录2');
log(`创建额外记录2: ID=${extra2.id}`, 'INFO');
await sleep(500);
const recordsAfterExtra = await getRecords();
log(`\n添加额外记录后的排序:`, 'INFO');
recordsAfterExtra.forEach((r, i) => {
log(` [${i + 1}] ${r.type === 'income' ? '+' : '-'}¥${r.amount} ${r.category} ${r.description} | ${new Date(r.createdAt).toLocaleTimeString('zh-CN')}`, 'INFO');
});
const latestRecord = recordsAfterExtra[0];
if (latestRecord && latestRecord.id === extra2.id) {
recordTest('最新添加的记录始终在最前面', 'PASS', `最新记录: "${latestRecord.description}"`);
} else {
recordTest('最新添加的记录始终在最前面', 'FAIL',
`期望"额外记录2"在最前,实际"${latestRecord?.description}"`);
}
// 验证支出筛选后的排序
const expenseRecords = await getRecords({ type: 'expense' });
let expenseSorted = true;
for (let i = 0; i < expenseRecords.length - 1; i++) {
const t1 = new Date(expenseRecords[i].createdAt).getTime();
const t2 = new Date(expenseRecords[i + 1].createdAt).getTime();
if (t1 < t2) {
expenseSorted = false;
break;
}
}
if (expenseSorted) {
recordTest('支出筛选后仍保持 createdAt 降序', 'PASS', `${expenseRecords.length} 条支出记录排序正确`);
} else {
recordTest('支出筛选后仍保持 createdAt 降序', 'FAIL', '支出排序异常');
}
// 验证收入筛选后的排序
const incomeRecords = await getRecords({ type: 'income' });
let incomeSorted = true;
for (let i = 0; i < incomeRecords.length - 1; i++) {
const t1 = new Date(incomeRecords[i].createdAt).getTime();
const t2 = new Date(incomeRecords[i + 1].createdAt).getTime();
if (t1 < t2) {
incomeSorted = false;
break;
}
}
if (incomeSorted) {
recordTest('收入筛选后仍保持 createdAt 降序', 'PASS', `${incomeRecords.length} 条收入记录排序正确`);
} else {
recordTest('收入筛选后仍保持 createdAt 降序', 'FAIL', '收入排序异常');
}
// ==========================================
// 测试7: 验证前端时间显示逻辑 (模拟)
// ==========================================
log('\n═══════════════════════════════════════════════════════', 'HEADER');
log('【测试7】验证前端时间显示格式 (模拟前端逻辑)', 'HEADER');
log('═══════════════════════════════════════════════════════', 'HEADER');
// 模拟前端 formatRecordTime 逻辑 (Dashboard)
function simulateDashboardFormatTime(record) {
const createdAt = new Date(record.createdAt);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const recordDay = new Date(createdAt.getFullYear(), createdAt.getMonth(), createdAt.getDate()).getTime();
const diffDays = Math.floor((today - recordDay) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return `${createdAt.getHours().toString().padStart(2, '0')}:${createdAt.getMinutes().toString().padStart(2, '0')}`;
} else if (diffDays === 1) {
return '昨天';
} else if (diffDays <= 7) {
return `${diffDays}天前`;
} else {
return `${createdAt.getMonth() + 1}${createdAt.getDate()}`;
}
}
// 模拟前端 formatTime 逻辑 (Record)
function simulateRecordFormatTime(record) {
const createdAt = new Date(record.createdAt);
const today = new Date();
const todayStr = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toDateString();
const recordDay = new Date(createdAt.getFullYear(), createdAt.getMonth(), createdAt.getDate()).toDateString();
if (todayStr === recordDay) {
return createdAt.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate()).toDateString();
if (yesterdayStr === recordDay) {
return '昨天';
}
return createdAt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
// 使用现有记录测试时间显示
const allRecords = await getRecords();
if (allRecords.length > 0) {
const todayRecord = allRecords.find(r => {
const created = new Date(r.createdAt);
const today = new Date();
return created.getFullYear() === today.getFullYear() &&
created.getMonth() === today.getMonth() &&
created.getDate() === today.getDate();
});
if (todayRecord) {
const dashTime = simulateDashboardFormatTime(todayRecord);
const recordTime = simulateRecordFormatTime(todayRecord);
log(`今天创建的记录: "${todayRecord.description}"`, 'INFO');
log(` Dashboard 显示: "${dashTime}"`, 'INFO');
log(` Record 显示: "${recordTime}"`, 'INFO');
// 验证不是 08:00
if (dashTime !== '08:00') {
recordTest('Dashboard 今天记录不显示 08:00', 'PASS', `显示: "${dashTime}"`);
} else {
recordTest('Dashboard 今天记录不显示 08:00', 'FAIL', `仍显示 "08:00"`);
}
if (recordTime !== '08:00') {
recordTest('Record 页面今天记录不显示 08:00', 'PASS', `显示: "${recordTime}"`);
} else {
recordTest('Record 页面今天记录不显示 08:00', 'FAIL', `仍显示 "08:00"`);
}
// 验证格式为 HH:MM
const dashTimeRegex = /^\d{2}:\d{2}$/;
if (dashTimeRegex.test(dashTime)) {
recordTest('Dashboard 时间格式为 HH:MM', 'PASS', `格式正确: "${dashTime}"`);
} else {
recordTest('Dashboard 时间格式为 HH:MM', 'FAIL', `格式不正确: "${dashTime}"`);
}
} else {
recordTest('今天记录时间显示验证', 'WARN', '当前没有今天的记录,跳过此测试');
}
} else {
recordTest('时间显示验证', 'WARN', '无记录可验证');
}
// ==========================================
// 测试8: 清理测试数据
// ==========================================
log('\n═══════════════════════════════════════════════════════', 'HEADER');
log('【测试8】清理测试数据', 'HEADER');
log('═══════════════════════════════════════════════════════', 'HEADER');
const finalRecords = await getRecords();
for (const r of finalRecords) {
await deleteRecord(r.id);
}
log(`已清理 ${finalRecords.length} 条测试记录`, 'INFO');
recordTest('测试数据清理完成', 'PASS', `清理 ${finalRecords.length} 条记录`);
// ==========================================
// 生成测试报告
// ==========================================
report.endTime = new Date();
const duration = report.endTime - report.startTime;
console.log('\n' + '='.repeat(60));
console.log('📊 测试报告');
console.log('='.repeat(60));
console.log(`测试总数: ${report.total}`);
console.log(`通过: ${report.passed}`);
console.log(`失败: ${report.failed}`);
console.log(`警告: ${report.warnings}`);
console.log(`通过率: ${((report.passed / report.total) * 100).toFixed(1)}%`);
console.log(`耗时: ${duration}ms`);
console.log('='.repeat(60));
console.log('\n📋 测试结果明细:');
console.log('-'.repeat(60));
report.results.forEach((r, i) => {
const icon = r.status === 'PASS' ? '✅' : r.status === 'WARN' ? '⚠️' : '❌';
console.log(` ${icon} [${i + 1}] ${r.name}${r.detail ? ' | ' + r.detail : ''}`);
});
console.log('\n' + '='.repeat(60));
if (report.failed === 0) {
console.log('🎉 全部测试通过!时间显示修复验证成功!');
} else {
console.log(`${report.failed} 项测试失败,请检查修复!`);
}
console.log('='.repeat(60) + '\n');
return report;
}
// 执行测试
runTests().catch(err => {
console.error('测试执行异常:', err);
process.exit(1);
});