Files
ftb-project-management/apps/web/lib/test-case.ts
2026-06-26 13:06:22 +08:00

175 lines
6.4 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 { 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<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 : 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 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;
}