260 lines
9.7 KiB
TypeScript
260 lines
9.7 KiB
TypeScript
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;
|
||
roundNo?: number;
|
||
sourceCaseId?: 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<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,
|
||
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,
|
||
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 type CreateTestCaseInput = Omit<TestCase, 'id' | 'caseNo' | 'createdAt' | 'updatedAt' | 'status'>;
|
||
|
||
export function getTestCaseRoundNo(testCase: Partial<Pick<TestCase, 'roundNo'>>): 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<TestCase, 'roundNo'>[]): number {
|
||
const maxRound = cases.reduce((max, testCase) => Math.max(max, getTestCaseRoundNo(testCase)), 0);
|
||
return maxRound + 1;
|
||
}
|
||
|
||
export function isTestCaseTested(testCase: Pick<TestCase, 'status'>): boolean {
|
||
return testCase.status === 'passed' || testCase.status === 'failed' || testCase.status === 'blocked';
|
||
}
|
||
|
||
export function canStartNextTestRound(cases: Pick<TestCase, 'roundNo' | 'status'>[]): 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,
|
||
roundNo,
|
||
sourceCaseId: source.sourceCaseId ?? source.id,
|
||
title: source.title,
|
||
description: source.description,
|
||
categoryId: source.categoryId,
|
||
priority: source.priority,
|
||
estimateHours: source.estimateHours,
|
||
aiEstimateHours: source.aiEstimateHours,
|
||
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<Bug, 'testCaseId' | 'status'>;
|
||
|
||
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 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;
|
||
}
|