import type { Priority } from './derive'; import type { Reference } from './dev-task'; import { DEFAULT_TEST_CATEGORY_ID } from './task-category'; import { calcActualElapsedHours, type TimeInterval } from './work-hours'; import { aggregateWorkEffort } from './work-effort-engine'; export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked'; export interface TestCase { id: string; caseNo: string; versionId: string; requirementId?: string; title: string; description?: string; categoryId: string; priority: Priority; assigneeId?: string; status: TestCaseStatus; estimateHours?: number; aiEstimateHours?: number; startedAt?: string; completedAt?: string; executedAt?: string; executedBy?: string; failReason?: string; blockReason?: string; references?: Reference[]; aiDraft?: boolean; aiDraftAt?: string; createdBy: string; createdAt: string; updatedAt: string; } export const TEST_CASE_STATUS_LABEL: Record = { pending: '待测试', running: '测试中', passed: '通过', failed: '不通过', blocked: '阻塞', }; export const TEST_CASE_STATUS_COLOR: Record = { pending: 'bg-zinc-100 text-zinc-600', running: 'bg-blue-50 text-blue-600', passed: 'bg-emerald-50 text-emerald-600', failed: 'bg-red-50 text-red-600', blocked: 'bg-orange-50 text-orange-600', }; export const TC_ALLOWED_TRANSITIONS: Record = { pending: ['running'], running: ['passed', 'failed', 'blocked'], passed: ['running'], failed: ['running'], blocked: ['running'], }; export const TEST_CASE_STATUS_PROGRESS: Record = { pending: 0, running: 50, passed: 100, failed: 100, blocked: 100, }; export function canTcTransition(from: TestCaseStatus, to: TestCaseStatus): boolean { return TC_ALLOWED_TRANSITIONS[from].includes(to); } export function generateCaseNo(existingCases: TestCase[]): string { const maxNum = existingCases.reduce((max, c) => { const num = parseInt(c.caseNo.replace('TC-', ''), 10); return isNaN(num) ? max : Math.max(max, num); }, 0); return `TC-${String(maxNum + 1).padStart(3, '0')}`; } export function normalizeTestCase(testCase: Partial, index = 0): TestCase { return { id: testCase.id || `tc-${index + 1}`, caseNo: testCase.caseNo || `TC-${String(index + 1).padStart(3, '0')}`, versionId: testCase.versionId || '', requirementId: testCase.requirementId, title: testCase.title || `测试用例 ${index + 1}`, description: testCase.description, categoryId: testCase.categoryId || DEFAULT_TEST_CATEGORY_ID, priority: testCase.priority || 'P2', assigneeId: testCase.assigneeId, status: testCase.status || 'pending', estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : undefined, aiEstimateHours: typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0 ? testCase.aiEstimateHours : undefined, startedAt: testCase.startedAt, completedAt: testCase.completedAt, executedAt: testCase.executedAt, executedBy: testCase.executedBy, failReason: testCase.failReason, blockReason: testCase.blockReason, references: testCase.references, aiDraft: testCase.aiDraft, aiDraftAt: testCase.aiDraftAt, createdBy: testCase.createdBy || '系统', createdAt: testCase.createdAt || new Date().toISOString(), updatedAt: testCase.updatedAt || new Date().toISOString(), }; } export function normalizeTestCases(cases: Partial[] = []): TestCase[] { return cases.map((tc, index) => normalizeTestCase(tc, index)); } export function calcTestProgress(cases: TestCase[]): { total: number; executed: number; passed: number; failed: number; blocked: number; passRate: number; completionRate: number } { const total = cases.length; if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 }; const passed = cases.filter((c) => c.status === 'passed').length; const failed = cases.filter((c) => c.status === 'failed').length; const blocked = cases.filter((c) => c.status === 'blocked').length; const executed = passed + failed + blocked; const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0; const completionRate = aggregateWorkEffort(cases.map((c) => ({ estimateHours: getTestCaseEstimateHours(c), actualHours: getTestCaseActualHours(c), progress: TEST_CASE_STATUS_PROGRESS[c.status], }))).progress; return { total, executed, passed, failed, blocked, passRate, completionRate }; } export function getTestCaseEstimateHours(tc: TestCase): number { if (typeof tc.estimateHours === 'number' && tc.estimateHours > 0) { return Number(tc.estimateHours.toFixed(2)); } if (typeof tc.aiEstimateHours === 'number' && tc.aiEstimateHours > 0) { return Number(tc.aiEstimateHours.toFixed(2)); } return 0; } export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number { if (!tc.startedAt) return 0; const isTerminal = tc.status === 'passed' || tc.status === 'failed' || tc.status === 'blocked'; const end = tc.completedAt ?? (isTerminal ? tc.updatedAt : now.toISOString()); return calcActualElapsedHours(tc.startedAt, end); } export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } { const summary = aggregateWorkEffort(cases.map((c) => ({ estimateHours: getTestCaseEstimateHours(c), actualHours: getTestCaseActualHours(c, now), progress: TEST_CASE_STATUS_PROGRESS[c.status], }))); return { estimate: summary.estimateHours, actual: summary.actualHours }; } export function aggregateTestCaseActualHours(cases: TestCase[], now: Date = new Date()): number { let sum = 0; for (const c of cases) sum += getTestCaseActualHours(c, now); return Math.round(sum * 2) / 2; } /** * 抽取每条已开始用例的 [startedAt, completedAt ?? now] 时间区间 * 用于双口径耗时统计(calcTwoMetrics) */ export function testCaseIntervals(cases: TestCase[], now: Date = new Date()): TimeInterval[] { const out: TimeInterval[] = []; const nowIso = now.toISOString(); for (const c of cases) { if (!c.startedAt) continue; const isTerminal = c.status === 'passed' || c.status === 'failed' || c.status === 'blocked'; out.push({ start: c.startedAt, end: c.completedAt ?? (isTerminal ? c.updatedAt : nowIso) }); } return out; }