feat(版本详情): 优化任务统计与测试轮次

This commit is contained in:
Script Generator
2026-06-26 15:12:24 +08:00
parent 914e6180c1
commit 2d802318fa
16 changed files with 631 additions and 65 deletions

View File

@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildEffortSummaryMetrics, sumPositiveHours } from './effort-summary';
interface EffortMetricForTest {
label: string;
value: string;
}
test('sumPositiveHours only returns a total when at least one item has a positive value', () => {
const total = sumPositiveHours(
[{ hours: 0.25 }, { hours: undefined }, { hours: 1 }],
(item: { hours?: number }) => item.hours,
);
assert.equal(total, 1.25);
assert.equal(sumPositiveHours([{ hours: 0 }, { hours: undefined }], (item: { hours?: number }) => item.hours), undefined);
});
test('buildEffortSummaryMetrics keeps the shared top metric order and empty estimate placeholders', () => {
const metrics = buildEffortSummaryMetrics({
aiEstimateHours: 0.25,
estimateHours: undefined,
actualHours: 1.5,
manhours: 2,
});
assert.deepEqual(metrics.map((metric: EffortMetricForTest) => metric.label), ['AI预估', '执行预估', '实际耗时', '人力投入']);
assert.equal(metrics[0].value, '0.25h0.01天)');
assert.equal(metrics[1].value, '-');
assert.equal(metrics[2].value, '1.5h0.06天)');
assert.equal(metrics[3].value, '2h0.08天)');
});

View File

@@ -0,0 +1,35 @@
import { formatActualDuration } from './work-hours';
export interface EffortSummaryInput {
aiEstimateHours?: number;
estimateHours?: number;
actualHours: number;
manhours: number;
}
export interface EffortSummaryMetric {
label: 'AI预估' | '执行预估' | '实际耗时' | '人力投入';
value: string;
}
export function sumPositiveHours<T>(items: readonly T[], getValue: (item: T) => number | undefined): number | undefined {
let total = 0;
for (const item of items) {
const value = getValue(item);
if (typeof value === 'number' && value > 0) total += value;
}
return total > 0 ? Number(total.toFixed(2)) : undefined;
}
export function buildEffortSummaryMetrics(input: EffortSummaryInput): EffortSummaryMetric[] {
return [
{ label: 'AI预估', value: formatOptionalHours(input.aiEstimateHours) },
{ label: '执行预估', value: formatOptionalHours(input.estimateHours) },
{ label: '实际耗时', value: formatActualDuration(input.actualHours) },
{ label: '人力投入', value: formatActualDuration(input.manhours) },
];
}
function formatOptionalHours(hours?: number): string {
return typeof hours === 'number' && hours > 0 ? formatActualDuration(hours) : '-';
}

View File

@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orderByRequirementForGrouping } from './requirement-grouping';
interface GroupableItem {
id: string;
requirementId?: string;
title?: string;
}
test('keeps later-created dev tasks beside existing tasks of the same requirement before pagination', () => {
const tasks: GroupableItem[] = [
{ id: 'task-1', requirementId: 'req-1', title: 'existing req 1' },
{ id: 'task-2', requirementId: 'req-2', title: 'existing req 2' },
{ id: 'task-3', requirementId: 'req-3', title: 'existing req 3' },
{ id: 'task-4', requirementId: 'req-1', title: 'new req 1' },
];
const ordered = orderByRequirementForGrouping(tasks, ['req-1', 'req-2', 'req-3'], (task: GroupableItem) => task.requirementId);
assert.deepEqual(ordered.map((task: GroupableItem) => task.id), ['task-1', 'task-4', 'task-2', 'task-3']);
});
test('keeps unlinked test cases in a stable trailing group', () => {
const cases: GroupableItem[] = [
{ id: 'tc-1', requirementId: 'req-2' },
{ id: 'tc-2', requirementId: undefined },
{ id: 'tc-3', requirementId: 'req-1' },
{ id: 'tc-4', requirementId: undefined },
];
const ordered = orderByRequirementForGrouping(cases, ['req-1', 'req-2'], (testCase: GroupableItem) => testCase.requirementId);
assert.deepEqual(ordered.map((testCase: GroupableItem) => testCase.id), ['tc-3', 'tc-1', 'tc-2', 'tc-4']);
});

View File

@@ -0,0 +1,20 @@
export function orderByRequirementForGrouping<T>(
items: readonly T[],
requirementIds: readonly string[],
getRequirementId: (item: T) => string | undefined,
): T[] {
const rankByRequirement = new Map(requirementIds.map((id, index) => [id, index]));
const trailingRank = requirementIds.length;
return [...items]
.map((item, index) => {
const requirementId = getRequirementId(item);
return {
item,
index,
rank: requirementId ? (rankByRequirement.get(requirementId) ?? trailingRank) : trailingRank,
};
})
.sort((a, b) => a.rank - b.rank || a.index - b.index)
.map(({ item }) => item);
}

View File

@@ -33,6 +33,12 @@ test('normalizeTestCaseOnCreate forces pending and clears actual timestamps', ()
assert.equal(result.completedAt, undefined);
});
test('normalizeTestCaseOnCreate defaults missing roundNo to the first test round', () => {
const result = normalizeTestCaseOnCreate(tc());
assert.equal(result.roundNo, 1);
});
test('pending to running writes startedAt', () => {
const result = applyTestCaseTransition(tc(), 'running', {
now: new Date('2026-06-25T01:00:00.000Z'),

View File

@@ -1,5 +1,5 @@
import type { TestCase, TestCaseStatus } from './test-case';
import { canTcTransition } from './test-case';
import { canTcTransition, getTestCaseRoundNo } from './test-case';
export interface TestCaseWorkflowResult {
ok: boolean;
@@ -16,6 +16,7 @@ export interface TestCaseTransitionOptions {
export function normalizeTestCaseOnCreate(testCase: TestCase): TestCase {
return {
...testCase,
roundNo: getTestCaseRoundNo(testCase),
status: 'pending',
startedAt: undefined,
completedAt: undefined,

View File

@@ -5,7 +5,12 @@ import {
TEST_CASE_STATUS_LABEL,
aggregateTestCaseHours,
calcTestProgress,
canStartNextTestRound,
copyTestCaseToRound,
getNextTestRoundNo,
getRequirementDeliveryStatus,
getTestCaseEstimateHours,
getTestCaseRoundNo,
normalizeTestCase,
} from './test-case';
import { DEFAULT_TEST_CATEGORY_ID } from './task-category';
@@ -43,6 +48,104 @@ test('normalizeTestCase keeps existing categoryId', () => {
assert.equal(tc.categoryId, 'cat-test-api');
});
test('normalizeTestCase backfills missing roundNo to the first test round', () => {
const tc = normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'v1',
title: '第一轮用例',
priority: 'P2',
status: 'pending',
categoryId: 'cat-test-api',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any);
assert.equal(tc.roundNo, 1);
assert.equal(getTestCaseRoundNo(tc), 1);
});
test('getNextTestRoundNo returns one more than the current max round', () => {
const cases = [
normalizeTestCase({ id: 'tc-1', caseNo: 'TC-001', versionId: 'v1', title: 'R1', priority: 'P2', status: 'passed', categoryId: 'cat-test-api', roundNo: 1, createdBy: 'tester', createdAt: '2026-06-25', updatedAt: '2026-06-25' } as any),
normalizeTestCase({ id: 'tc-2', caseNo: 'TC-002', versionId: 'v1', title: 'R3', priority: 'P2', status: 'passed', categoryId: 'cat-test-api', roundNo: 3, createdBy: 'tester', createdAt: '2026-06-25', updatedAt: '2026-06-25' } as any),
];
assert.equal(getNextTestRoundNo(cases), 4);
});
test('canStartNextTestRound requires latest round cases all tested', () => {
assert.equal(canStartNextTestRound([]), false);
assert.equal(canStartNextTestRound([
normalizeTestCase({ id: 'tc-1', caseNo: 'TC-001', versionId: 'v1', title: '通过', priority: 'P2', status: 'passed', categoryId: 'cat-test-api', roundNo: 1, createdBy: 'tester', createdAt: '2026-06-25', updatedAt: '2026-06-25' } as any),
normalizeTestCase({ id: 'tc-2', caseNo: 'TC-002', versionId: 'v1', title: '待测', priority: 'P2', status: 'pending', categoryId: 'cat-test-api', roundNo: 1, createdBy: 'tester', createdAt: '2026-06-25', updatedAt: '2026-06-25' } as any),
]), false);
assert.equal(canStartNextTestRound([
normalizeTestCase({ id: 'tc-1', caseNo: 'TC-001', versionId: 'v1', title: '通过', priority: 'P2', status: 'passed', categoryId: 'cat-test-api', roundNo: 1, createdBy: 'tester', createdAt: '2026-06-25', updatedAt: '2026-06-25' } as any),
normalizeTestCase({ id: 'tc-2', caseNo: 'TC-002', versionId: 'v1', title: '不通过', priority: 'P2', status: 'failed', categoryId: 'cat-test-api', roundNo: 1, createdBy: 'tester', createdAt: '2026-06-25', updatedAt: '2026-06-25' } as any),
]), true);
assert.equal(canStartNextTestRound([
normalizeTestCase({ id: 'tc-1', caseNo: 'TC-001', versionId: 'v1', title: '首轮通过', priority: 'P2', status: 'passed', categoryId: 'cat-test-api', roundNo: 1, createdBy: 'tester', createdAt: '2026-06-25', updatedAt: '2026-06-25' } as any),
normalizeTestCase({ id: 'tc-2', caseNo: 'TC-002', versionId: 'v1', title: '二轮待测', priority: 'P2', status: 'running', categoryId: 'cat-test-api', roundNo: 2, createdBy: 'tester', createdAt: '2026-06-25', updatedAt: '2026-06-25' } as any),
]), false);
});
test('getRequirementDeliveryStatus marks a requirement submitted only when all its dev tasks are submitted', () => {
const tasks = [
{ requirementId: 'req-1', status: 'submitted' },
{ requirementId: 'req-1', status: 'submitted' },
{ requirementId: 'req-2', status: 'testing' },
] as any;
assert.equal(getRequirementDeliveryStatus('req-1', tasks), 'submitted');
assert.equal(getRequirementDeliveryStatus('req-2', tasks), 'pending');
assert.equal(getRequirementDeliveryStatus('req-empty', tasks), 'pending');
});
test('copyTestCaseToRound preserves estimates and references but clears execution state', () => {
const source = normalizeTestCase({
id: 'tc-source',
caseNo: 'TC-001',
versionId: 'v1',
requirementId: 'req-1',
title: '登录正常',
description: '步骤',
priority: 'P1',
status: 'passed',
categoryId: 'cat-test-functional',
estimateHours: 0.75,
aiEstimateHours: 0.25,
assigneeId: 'QA',
startedAt: '2026-06-25T01:00:00.000Z',
completedAt: '2026-06-25T02:00:00.000Z',
executedAt: '2026-06-25T02:00:00.000Z',
failReason: 'old fail',
blockReason: 'old block',
references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001 登录' }],
aiDraft: true,
aiDraftAt: '2026-06-25T00:00:00.000Z',
roundNo: 1,
createdBy: 'AI',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any);
const copied = copyTestCaseToRound(source, 2, 'tester');
assert.equal(copied.roundNo, 2);
assert.equal(copied.sourceCaseId, 'tc-source');
assert.equal(copied.aiEstimateHours, 0.25);
assert.equal(copied.estimateHours, 0.75);
assert.deepEqual(copied.references, source.references);
assert.equal(copied.startedAt, undefined);
assert.equal(copied.completedAt, undefined);
assert.equal(copied.executedAt, undefined);
assert.equal(copied.failReason, undefined);
assert.equal(copied.blockReason, undefined);
assert.equal(copied.createdBy, 'tester');
});
test('normalizeTestCase keeps missing executor estimate empty', () => {
const tc = normalizeTestCase({
id: 'tc-1',
@@ -137,6 +240,91 @@ test('calcTestProgress uses estimate-weighted completion', () => {
assert.equal(progress.completionRate, 75);
});
test('calcTestProgress calculates pass rate from passed cases that never had effective bugs', () => {
const progress = calcTestProgress([
normalizeTestCase({
id: 'tc-clean',
caseNo: 'TC-001',
versionId: 'v1',
title: '无 Bug 通过',
priority: 'P2',
status: 'passed',
categoryId: 'cat-test-functional',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any),
normalizeTestCase({
id: 'tc-bug-fixed',
caseNo: 'TC-002',
versionId: 'v1',
title: '提过 Bug 后通过',
priority: 'P2',
status: 'passed',
categoryId: 'cat-test-functional',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any),
normalizeTestCase({
id: 'tc-failed',
caseNo: 'TC-003',
versionId: 'v1',
title: '不通过',
priority: 'P2',
status: 'failed',
categoryId: 'cat-test-functional',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any),
normalizeTestCase({
id: 'tc-running-with-bug',
caseNo: 'TC-004',
versionId: 'v1',
title: '测试中已提 Bug',
priority: 'P2',
status: 'running',
categoryId: 'cat-test-functional',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any),
normalizeTestCase({
id: 'tc-rejected-only',
caseNo: 'TC-005',
versionId: 'v1',
title: '仅有驳回 Bug',
priority: 'P2',
status: 'passed',
categoryId: 'cat-test-functional',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any),
normalizeTestCase({
id: 'tc-pending',
caseNo: 'TC-006',
versionId: 'v1',
title: '未执行',
priority: 'P2',
status: 'pending',
categoryId: 'cat-test-functional',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any),
], [
{ testCaseId: 'tc-bug-fixed', status: 'closed' },
{ testCaseId: 'tc-running-with-bug', status: 'open' },
{ testCaseId: 'tc-rejected-only', status: 'rejected' },
]);
assert.equal(progress.executed, 5);
assert.equal(progress.passed, 3);
assert.equal(progress.passRate, 40);
});
test('aggregateTestCaseHours returns estimate and actual totals', () => {
const hours = aggregateTestCaseHours([
normalizeTestCase({

View File

@@ -1,5 +1,6 @@
import type { Priority } from './derive';
import type { Reference } from './dev-task';
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';
@@ -11,6 +12,8 @@ export interface TestCase {
caseNo: string;
versionId: string;
requirementId?: string;
roundNo?: number;
sourceCaseId?: string;
title: string;
description?: string;
categoryId: string;
@@ -83,6 +86,8 @@ export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestC
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,
@@ -110,14 +115,94 @@ 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 } {
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 executed = passed + failed + blocked;
const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0;
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),