import type { Priority } from './derive'; import type { DevTaskStatus, Reference } from './dev-task'; import type { Bug } from './bug'; 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; requirementName?: string; roundNo?: number; sourceCaseId?: string; title: string; description?: string; categoryId: string; priority: Priority; assigneeId?: string; status: TestCaseStatus; estimateHours?: number; aiEstimateHours?: number; plannedTestAt?: string; plannedEndAt?: string; 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, requirementName: testCase.requirementName, roundNo: getTestCaseRoundNo(testCase), sourceCaseId: testCase.sourceCaseId, 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, plannedTestAt: testCase.plannedTestAt, plannedEndAt: testCase.plannedEndAt, 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 type CreateTestCaseInput = Omit; export function getTestCaseRoundNo(testCase: Partial>): number { const roundNo = testCase.roundNo; if (typeof roundNo !== 'number' || !Number.isFinite(roundNo) || roundNo < 1) return 1; return Math.floor(roundNo); } export function getNextTestRoundNo(cases: Pick[]): number { const maxRound = cases.reduce((max, testCase) => Math.max(max, getTestCaseRoundNo(testCase)), 0); return maxRound + 1; } export function isTestCaseTested(testCase: Pick): boolean { return testCase.status === 'passed' || testCase.status === 'failed' || testCase.status === 'blocked'; } export function canStartNextTestRound(cases: Pick[]): boolean { const firstRoundCases = cases.filter((testCase) => getTestCaseRoundNo(testCase) === 1); if (firstRoundCases.length === 0) return false; const latestRoundNo = Math.max(...cases.map((testCase) => getTestCaseRoundNo(testCase))); const latestRoundCases = cases.filter((testCase) => getTestCaseRoundNo(testCase) === latestRoundNo); return latestRoundCases.length > 0 && latestRoundCases.every(isTestCaseTested); } export type RequirementDeliveryStatus = 'submitted' | 'pending'; type RequirementDevTaskRef = { requirementId: string; status: DevTaskStatus; }; export function getRequirementDeliveryStatus( requirementId: string | undefined, devTasks: RequirementDevTaskRef[], ): RequirementDeliveryStatus { if (!requirementId) return 'pending'; const requirementTasks = devTasks.filter((task) => task.requirementId === requirementId); if (requirementTasks.length === 0) return 'pending'; return requirementTasks.every((task) => task.status === 'submitted') ? 'submitted' : 'pending'; } export function copyTestCaseToRound(source: TestCase, roundNo: number, createdBy: string): CreateTestCaseInput { return { versionId: source.versionId, requirementId: source.requirementId, requirementName: source.requirementName, roundNo, sourceCaseId: source.sourceCaseId ?? source.id, title: source.title, description: source.description, categoryId: source.categoryId, priority: source.priority, estimateHours: source.estimateHours, aiEstimateHours: source.aiEstimateHours, plannedTestAt: source.plannedTestAt, plannedEndAt: source.plannedEndAt, assigneeId: source.assigneeId, startedAt: undefined, completedAt: undefined, executedAt: undefined, executedBy: undefined, failReason: undefined, blockReason: undefined, references: source.references, aiDraft: source.aiDraft, aiDraftAt: source.aiDraftAt, createdBy, }; } type TestCaseBugRef = Pick; export function calcTestProgress( cases: TestCase[], bugs: TestCaseBugRef[] = [], ): { 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 effectiveBugCaseIds = new Set( bugs.filter((bug) => bug.status !== 'rejected').map((bug) => bug.testCaseId), ); const executedCases = cases.filter( (c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked' || effectiveBugCaseIds.has(c.id), ); const cleanPassed = executedCases.filter((c) => c.status === 'passed' && !effectiveBugCaseIds.has(c.id)).length; const executed = executedCases.length; const passRate = executed > 0 ? Math.round((cleanPassed / executed) * 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 needsTestCaseClaim(testCase: Pick): boolean { return !testCase.assigneeId?.trim(); } export function hasTestCasePlan(testCase: Pick): boolean { if (!testCase.plannedTestAt || !testCase.plannedEndAt) return false; const start = new Date(testCase.plannedTestAt).getTime(); const end = new Date(testCase.plannedEndAt).getTime(); return Number.isFinite(start) && Number.isFinite(end) && end > start; } export function canStartTestCase( testCase: Pick, ): boolean { return !needsTestCaseClaim(testCase) && hasTestCasePlan(testCase); } 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; }