167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
import type { Priority } from './derive';
|
||
import type { Reference } from './dev-task';
|
||
import { DEFAULT_TEST_CATEGORY_ID } from './task-category';
|
||
import { calcWorkHours, 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;
|
||
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<TestCaseStatus, string> = {
|
||
pending: '待测试',
|
||
running: '测试中',
|
||
passed: '通过',
|
||
failed: '不通过',
|
||
blocked: '阻塞',
|
||
};
|
||
|
||
export const TEST_CASE_STATUS_COLOR: Record<TestCaseStatus, string> = {
|
||
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<TestCaseStatus, TestCaseStatus[]> = {
|
||
pending: ['running'],
|
||
running: ['passed', 'failed', 'blocked'],
|
||
passed: ['running'],
|
||
failed: ['running'],
|
||
blocked: ['running'],
|
||
};
|
||
|
||
export const TEST_CASE_STATUS_PROGRESS: Record<TestCaseStatus, number> = {
|
||
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<TestCase>, 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 : 0.5,
|
||
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>[] = []): 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 {
|
||
return typeof tc.estimateHours === 'number' && tc.estimateHours > 0
|
||
? Math.round(tc.estimateHours * 2) / 2
|
||
: 0.5;
|
||
}
|
||
|
||
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
|
||
if (!tc.startedAt) return 0;
|
||
const end = tc.completedAt ?? now.toISOString();
|
||
return calcWorkHours(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;
|
||
out.push({ start: c.startedAt, end: c.completedAt ?? nowIso });
|
||
}
|
||
return out;
|
||
}
|