/** * ============================================= * 时间显示修复 - 完整回归测试脚本 * ============================================= * 测试目标: 验证 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); });