Files
ftb-project-management/apps/web/lib/test-case.ts
2026-06-29 14:18:58 +08:00

263 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
plannedTestAt?: 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<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,
plannedTestAt: testCase.plannedTestAt,
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,
plannedTestAt: source.plannedTestAt,
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;
}