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

@@ -12,9 +12,10 @@ import { BugDetailDrawer } from './BugDetailDrawer';
import { Pagination, usePagination } from '@/components/Pagination'; import { Pagination, usePagination } from '@/components/Pagination';
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, bugIntervals } from '@/lib/bug'; import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, aggregateBugActualHours, bugIntervals } from '@/lib/bug';
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; import { calcTwoMetrics } from '@/lib/work-hours';
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system'; import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary';
import type { BugStatus, BugSeverity } from '@/lib/bug'; import type { BugStatus, BugSeverity } from '@/lib/bug';
interface Props { interface Props {
@@ -43,7 +44,17 @@ export function BugTab({ versionId, requirementIds }: Props) {
const closedCount = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length; const closedCount = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
const criticalCount = versionBugs.filter((b) => b.severity === 'critical' && b.status !== 'closed' && b.status !== 'rejected').length; const criticalCount = versionBugs.filter((b) => b.severity === 'critical' && b.status !== 'closed' && b.status !== 'rejected').length;
const resolveRate = versionBugs.length > 0 ? Math.round(((fixedCount + closedCount) / versionBugs.length) * 100) : 0; const resolveRate = versionBugs.length > 0 ? Math.round(((fixedCount + closedCount) / versionBugs.length) * 100) : 0;
const { calendarHours: bugCalendarHours, manhours: bugManhours } = useMemo(() => calcTwoMetrics(bugIntervals(versionBugs)), [versionBugs]); const bugActualHours = useMemo(() => aggregateBugActualHours(versionBugs), [versionBugs]);
const { manhours: bugManhours } = useMemo(() => calcTwoMetrics(bugIntervals(versionBugs)), [versionBugs]);
const effortMetrics = useMemo(
() => buildEffortSummaryMetrics({
aiEstimateHours: sumPositiveHours(versionBugs, (bug) => bug.aiEstimateHours),
estimateHours: sumPositiveHours(versionBugs, (bug) => bug.estimateHours),
actualHours: bugActualHours,
manhours: bugManhours,
}),
[versionBugs, bugActualHours, bugManhours],
);
const [filterAssignee, setFilterAssignee] = useState(''); const [filterAssignee, setFilterAssignee] = useState('');
const [filterStatus, setFilterStatus] = useState(''); const [filterStatus, setFilterStatus] = useState('');
@@ -97,9 +108,11 @@ export function BugTab({ versionId, requirementIds }: Props) {
<span className="text-[11px] text-red-600 bg-red-50 px-1.5 py-0.5 rounded font-medium">{criticalCount} </span> <span className="text-[11px] text-red-600 bg-red-50 px-1.5 py-0.5 rounded font-medium">{criticalCount} </span>
)} )}
<span className="text-[11px] text-[var(--ink-muted)]"> {resolveRate}%</span> <span className="text-[11px] text-[var(--ink-muted)]"> {resolveRate}%</span>
{bugManhours > 0 && ( {effortMetrics.map((metric) => (
<span className="text-[11px] text-[var(--ink-muted)]"> {formatWorkHours(bugCalendarHours)} / {formatWorkHours(bugManhours)}</span> <span key={metric.label} className="text-[11px] text-[var(--ink-muted)] shrink-0">
)} {metric.label} <span className="tabular-nums text-[var(--ink)]">{metric.value}</span>
</span>
))}
</div> </div>
<div className="flex items-center gap-2 px-1 flex-wrap"> <div className="flex items-center gap-2 px-1 flex-wrap">

View File

@@ -12,10 +12,12 @@ import { DevTaskRow } from './DevTaskRow';
import { DevTaskCreateModal } from './DevTaskCreateModal'; import { DevTaskCreateModal } from './DevTaskCreateModal';
import { DevTaskDetailDrawer } from './DevTaskDetailDrawer'; import { DevTaskDetailDrawer } from './DevTaskDetailDrawer';
import { calcGroupProgress, DEV_TASK_STATUS_LABEL, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task'; import { calcGroupProgress, DEV_TASK_STATUS_LABEL, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task';
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; import { calcTwoMetrics } from '@/lib/work-hours';
import { Pagination, usePagination } from '@/components/Pagination'; import { Pagination, usePagination } from '@/components/Pagination';
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { orderByRequirementForGrouping } from '@/lib/requirement-grouping';
import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary';
import type { DevTask, DevTaskStatus } from '@/lib/dev-task'; import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
interface Props { interface Props {
@@ -60,14 +62,25 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
return result; return result;
}, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory, debouncedKeyword]); }, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory, debouncedKeyword]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredTasks, 20); const orderedTasks = useMemo(
() => orderByRequirementForGrouping(filteredTasks, requirementIds, (task) => task.requirementId),
[filteredTasks, requirementIds],
);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(orderedTasks, 20);
const progress = calcGroupProgress(versionTasks); const progress = calcGroupProgress(versionTasks);
const { estimate: totalEstimate } = aggregateDevTaskHours(versionTasks); const { actual: actualHours } = aggregateDevTaskHours(versionTasks);
const { calendarHours, manhours } = useMemo(() => calcTwoMetrics(devTaskIntervals(versionTasks)), [versionTasks]); const { manhours } = useMemo(() => calcTwoMetrics(devTaskIntervals(versionTasks)), [versionTasks]);
const overrun = manhours > totalEstimate && totalEstimate > 0; const effortMetrics = useMemo(
const underrun = manhours > 0 && manhours < totalEstimate; () => buildEffortSummaryMetrics({
const hoursTone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink-muted)]'; aiEstimateHours: sumPositiveHours(versionTasks, (task) => task.aiEstimateHours),
estimateHours: sumPositiveHours(versionTasks, (task) => task.estimateHours),
actualHours,
manhours,
}),
[versionTasks, actualHours, manhours],
);
const blockedCount = versionTasks.filter((t) => t.isBlocked).length; const blockedCount = versionTasks.filter((t) => t.isBlocked).length;
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@@ -112,7 +125,12 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
<div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0"> <div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0">
<div className="h-full rounded-full bg-[var(--accent)]" style={{ width: `${progress}%` }} /> <div className="h-full rounded-full bg-[var(--accent)]" style={{ width: `${progress}%` }} />
</div> </div>
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{versionTasks.length} · {formatWorkHours(totalEstimate)}{manhours > 0 ? <> · {formatWorkHours(calendarHours)} / <span className={`tabular-nums ${hoursTone}`}>{formatWorkHours(manhours)}</span></> : null}</span> <span className="text-[11px] text-[var(--ink-muted)] shrink-0">{versionTasks.length}</span>
{effortMetrics.map((metric) => (
<span key={metric.label} className="text-[11px] text-[var(--ink-muted)] shrink-0">
{metric.label} <span className="tabular-nums text-[var(--ink)]">{metric.value}</span>
</span>
))}
{blockedCount > 0 && <span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{blockedCount}</span>} {blockedCount > 0 && <span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{blockedCount}</span>}
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" /> <span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />

View File

@@ -14,10 +14,11 @@ import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/l
interface Props { interface Props {
versionId: string; versionId: string;
requirementIds: string[]; requirementIds: string[];
roundNo?: number;
onClose: () => void; onClose: () => void;
} }
export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Props) { export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) {
const { createTestCase } = useTestCaseStore(); const { createTestCase } = useTestCaseStore();
const { requirements } = useRequirementStore(); const { requirements } = useRequirementStore();
const { members } = useMemberStore(); const { members } = useMemberStore();
@@ -74,6 +75,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Prop
createTestCase({ createTestCase({
versionId, versionId,
requirementId: requirementId || undefined, requirementId: requirementId || undefined,
roundNo: roundNo ?? 1,
title: title.trim(), title: title.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
categoryId, categoryId,

View File

@@ -4,7 +4,6 @@ import { memo } from 'react';
import { TestCaseStatusBadge } from './TestCaseStatusBadge'; import { TestCaseStatusBadge } from './TestCaseStatusBadge';
import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case'; import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
import { formatWorkHours } from '@/lib/work-hours'; import { formatWorkHours } from '@/lib/work-hours';
import { CategoryChip } from '@/components/dev-task/CategoryChip';
import type { TestCase } from '@/lib/test-case'; import type { TestCase } from '@/lib/test-case';
import type { TaskCategory } from '@/lib/task-category'; import type { TaskCategory } from '@/lib/task-category';
@@ -32,22 +31,31 @@ function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
<div onClick={onClick} className={`flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0 ${testCase.aiDraft ? 'border-l-2 border-l-purple-400 bg-purple-50/30' : ''}`}> <div onClick={onClick} className={`flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0 ${testCase.aiDraft ? 'border-l-2 border-l-purple-400 bg-purple-50/30' : ''}`}>
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} /> <span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span> <span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span>
<span
className="inline-flex h-5 w-24 shrink-0 items-center justify-center rounded px-1.5 text-[10px] font-medium whitespace-nowrap"
style={{
backgroundColor: category?.color ? `${category.color}15` : 'var(--bg-subtle)',
color: category?.color || 'var(--ink-soft)',
}}
title={category?.name || '未分类'}
>
{category?.name || '未分类'}
</span>
<div className="flex-1 min-w-0 flex items-center gap-1.5"> <div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className="text-[13px] text-[var(--ink)] truncate">{testCase.title}</span> <span className="text-[13px] text-[var(--ink)] truncate">{testCase.title}</span>
<CategoryChip category={category} />
{testCase.aiDraft && ( {testCase.aiDraft && (
<span className="text-[10px] text-purple-600 bg-purple-100 px-1.5 py-0.5 rounded shrink-0" title="AI 拆解草案,编辑后会移除标记"> <span className="text-[10px] text-purple-600 bg-purple-100 px-1.5 py-0.5 rounded shrink-0" title="AI 拆解草案,编辑后会移除标记">
AI AI
</span> </span>
)} )}
</div> </div>
{bugCount > 0 && (
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span>
)}
<TestCaseStatusBadge status={testCase.status} /> <TestCaseStatusBadge status={testCase.status} />
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-32 text-right shrink-0 whitespace-nowrap"> <span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-32 text-right shrink-0 whitespace-nowrap">
{actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `${estimatePrefix} ${formatWorkHours(estimateHours)}`} {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `${estimatePrefix} ${formatWorkHours(estimateHours)}`}
</span> </span>
{bugCount > 0 && (
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span>
)}
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{testCase.assigneeId || '-'}</span> <span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{testCase.assigneeId || '-'}</span>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Plus, ClipboardCheck, CheckCircle2, Trash2 } from 'lucide-react'; import { Plus, ClipboardCheck, Trash2 } from 'lucide-react';
import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore'; import { useBugStore } from '@/stores/useBugStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore';
@@ -12,11 +12,13 @@ import { TestCaseRow } from './TestCaseRow';
import { TestCaseCreateModal } from './TestCaseCreateModal'; import { TestCaseCreateModal } from './TestCaseCreateModal';
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer'; import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
import { BugCreateModal } from '@/components/bug/BugCreateModal'; import { BugCreateModal } from '@/components/bug/BugCreateModal';
import { aggregateTestCaseHours, calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case'; import { aggregateTestCaseHours, calcTestProgress, canStartNextTestRound, copyTestCaseToRound, getNextTestRoundNo, getRequirementDeliveryStatus, getTestCaseRoundNo, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; import { calcTwoMetrics } from '@/lib/work-hours';
import { Pagination, usePagination } from '@/components/Pagination'; import { Pagination, usePagination } from '@/components/Pagination';
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { orderByRequirementForGrouping } from '@/lib/requirement-grouping';
import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary';
import type { TestCaseStatus } from '@/lib/test-case'; import type { TestCaseStatus } from '@/lib/test-case';
interface Props { interface Props {
@@ -25,7 +27,7 @@ interface Props {
} }
export function TestCaseTab({ versionId, requirementIds }: Props) { export function TestCaseTab({ versionId, requirementIds }: Props) {
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore(); const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore(); const { bugs, fetchBugs } = useBugStore();
const { tasks: devTasks } = useDevTaskStore(); const { tasks: devTasks } = useDevTaskStore();
const { requirements } = useRequirementStore(); const { requirements } = useRequirementStore();
@@ -41,16 +43,50 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
() => testCases.filter((c) => c.versionId === versionId), () => testCases.filter((c) => c.versionId === versionId),
[testCases, versionId], [testCases, versionId],
); );
const rounds = useMemo(
() => Array.from(new Set(versionCases.map((testCase) => getTestCaseRoundNo(testCase)))).sort((a, b) => a - b),
[versionCases],
);
const [activeRound, setActiveRound] = useState(1);
useEffect(() => {
if (rounds.length === 0) {
if (activeRound !== 1) setActiveRound(1);
return;
}
if (!rounds.includes(activeRound)) setActiveRound(rounds[rounds.length - 1]);
}, [rounds, activeRound]);
const activeRoundCases = useMemo(
() => versionCases.filter((testCase) => getTestCaseRoundNo(testCase) === activeRound),
[versionCases, activeRound],
);
const firstRoundCases = useMemo(
() => versionCases.filter((testCase) => getTestCaseRoundNo(testCase) === 1),
[versionCases],
);
const versionBugs = useMemo(
() => bugs.filter((bug) => bug.versionId === versionId),
[bugs, versionId],
);
const versionDevTasks = useMemo( const versionDevTasks = useMemo(
() => devTasks.filter((t) => reqIdSet.has(t.requirementId)), () => devTasks.filter((t) => reqIdSet.has(t.requirementId)),
[devTasks, reqIdSet], [devTasks, reqIdSet],
); );
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
const stats = calcTestProgress(versionCases); const stats = calcTestProgress(activeRoundCases, versionBugs);
const { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(versionCases); const { actual: tcActualHours } = aggregateTestCaseHours(versionCases);
const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]); const { manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]);
const effortMetrics = useMemo(
() => buildEffortSummaryMetrics({
aiEstimateHours: sumPositiveHours(versionCases, (testCase) => testCase.aiEstimateHours),
estimateHours: sumPositiveHours(versionCases, (testCase) => testCase.estimateHours),
actualHours: tcActualHours,
manhours: tcManhours,
}),
[versionCases, tcActualHours, tcManhours],
);
const canCreateNextRound = useMemo(() => canStartNextTestRound(versionCases), [versionCases]);
const nextRoundNo = useMemo(() => getNextTestRoundNo(versionCases), [versionCases]);
const [filterAssignee, setFilterAssignee] = useState(''); const [filterAssignee, setFilterAssignee] = useState('');
const [filterStatus, setFilterStatus] = useState(''); const [filterStatus, setFilterStatus] = useState('');
@@ -58,14 +94,19 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
const debouncedKeyword = useDebouncedValue(keyword, 300); const debouncedKeyword = useDebouncedValue(keyword, 300);
const filteredCases = useMemo(() => { const filteredCases = useMemo(() => {
let result = versionCases; let result = activeRoundCases;
if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee); if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee);
if (filterStatus) result = result.filter((c) => c.status === filterStatus); if (filterStatus) result = result.filter((c) => c.status === filterStatus);
if (debouncedKeyword) result = result.filter((c) => matchTitleOrNo({ title: c.title, no: c.caseNo }, debouncedKeyword)); if (debouncedKeyword) result = result.filter((c) => matchTitleOrNo({ title: c.title, no: c.caseNo }, debouncedKeyword));
return result; return result;
}, [versionCases, filterAssignee, filterStatus, debouncedKeyword]); }, [activeRoundCases, filterAssignee, filterStatus, debouncedKeyword]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 20); const orderedCases = useMemo(
() => orderByRequirementForGrouping(filteredCases, requirementIds, (testCase) => testCase.requirementId),
[filteredCases, requirementIds],
);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(orderedCases, 20);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null); const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
@@ -86,6 +127,17 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
selectedIds.forEach((id) => deleteTestCase(id)); selectedIds.forEach((id) => deleteTestCase(id));
setSelectedIds(new Set()); setSelectedIds(new Set());
}; };
const handleRoundChange = (roundNo: number) => {
setActiveRound(roundNo);
setSelectedIds(new Set());
setPage(1);
};
const handleStartNewRound = () => {
if (!canCreateNextRound) return;
const operator = user?.name || '系统';
createTestCases(firstRoundCases.map((testCase) => copyTestCaseToRound(testCase, nextRoundNo, operator)));
handleRoundChange(nextRoundNo);
};
const groupedByReq = useMemo(() => { const groupedByReq = useMemo(() => {
const map = new Map<string, typeof paged>(); const map = new Map<string, typeof paged>();
@@ -102,39 +154,31 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]); const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
const bugCountByCase = useMemo(() => { const bugCountByCase = useMemo(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1); for (const b of versionBugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
return map; return map;
}, [bugs]); }, [versionBugs]);
const assignees = useMemo(() => Array.from(new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [versionCases]); const assignees = useMemo(() => Array.from(new Set(activeRoundCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [activeRoundCases]);
const hasFilter = !!(filterAssignee || filterStatus || debouncedKeyword); const hasFilter = !!(filterAssignee || filterStatus || debouncedKeyword);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{allSubmitted && (
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-50 border border-emerald-200">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
<span className="text-[12px] font-medium text-emerald-700"></span>
<span className="text-[11px] text-emerald-600 ml-auto">{versionDevTasks.length} </span>
</div>
)}
{versionDevTasks.length > 0 && !allSubmitted && (
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-orange-50 border border-orange-200">
<span className="text-[12px] text-orange-700">{versionDevTasks.filter((t) => t.status === 'submitted').length}/{versionDevTasks.length} </span>
</div>
)}
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap"> <div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
<ClipboardCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" /> <ClipboardCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{stats.completionRate}%</span> <span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{stats.completionRate}%</span>
<div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0"> <div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${stats.completionRate}%` }} /> <div className="h-full rounded-full bg-emerald-500" style={{ width: `${stats.completionRate}%` }} />
</div> </div>
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.total} · {stats.passed} · {stats.failed} · {formatWorkHours(tcEstimateHours)} / {formatWorkHours(tcActualHours)}</span> <span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.total} · {stats.passed} · {stats.failed}</span>
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.passRate}%</span> <span className="text-[11px] text-[var(--ink-muted)] shrink-0">Bug通过{stats.passRate}%</span>
{tcManhours > 0 && ( {effortMetrics.map((metric) => (
<span className="text-[11px] text-[var(--ink-muted)] shrink-0"> {formatWorkHours(tcCalendarHours)} / {formatWorkHours(tcManhours)}</span> <span key={metric.label} className="text-[11px] text-[var(--ink-muted)] shrink-0">
)} {metric.label} <span className="tabular-nums text-[var(--ink)]">{metric.value}</span>
</span>
))}
<select value={activeRound} onChange={(e) => handleRoundChange(Number(e.target.value))} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
{(rounds.length > 0 ? rounds : [1]).map((roundNo) => <option key={roundNo} value={roundNo}>{roundNo}</option>)}
</select>
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" /> <span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
@@ -156,6 +200,14 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
<Trash2 className="h-3 w-3" />{selectedIds.size} <Trash2 className="h-3 w-3" />{selectedIds.size}
</button> </button>
)} )}
<button
onClick={handleStartNewRound}
disabled={!canCreateNextRound}
title={canCreateNextRound ? `复制第1轮用例开启第${nextRoundNo}轮测试` : '最新一轮测试用例全部测完后才能开启新一轮'}
className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="h-3 w-3" />
</button>
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]"> <button onClick={() => setShowCreate(true)} className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</button> </button>
@@ -171,13 +223,30 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
Array.from(groupedByReq.entries()).map(([reqId, cases]) => { Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
const req = reqId === '__none__' ? null : requirementMap.get(reqId); const req = reqId === '__none__' ? null : requirementMap.get(reqId);
const reqPassed = cases.filter((c) => c.status === 'passed').length; const reqPassed = cases.filter((c) => c.status === 'passed').length;
const deliveryStatus = req ? getRequirementDeliveryStatus(req.id, versionDevTasks) : null;
return ( return (
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden"> <div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-subtle)] border-b border-[var(--line)]"> <div className="flex items-center bg-[var(--bg-subtle)] border-b border-[var(--line)]">
<input type="checkbox" checked={cases.every((c) => selectedIds.has(c.id))} onChange={() => { const ids = cases.map((c) => c.id); const allSel = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSel) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" /> <div className="pl-4 flex items-center">
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{req?.code || '通用'}</span> <input type="checkbox" checked={cases.every((c) => selectedIds.has(c.id))} onChange={() => { const ids = cases.map((c) => c.id); const allSel = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSel) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
<span className="text-[12px] font-medium text-[var(--ink)] flex-1 truncate">{req?.title || '未关联需求'}</span> </div>
<span className="text-[11px] text-[var(--ink-muted)]">{reqPassed}/{cases.length} </span> <div className="flex flex-1 min-w-0 items-center gap-3 px-4 py-2">
<span className="h-2 w-2 shrink-0" />
<span className="w-14 min-w-0 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code || '通用'}>{req?.code || '通用'}</span>
{deliveryStatus ? (
<span className={`inline-flex h-5 w-24 shrink-0 items-center justify-center rounded px-1.5 text-[10px] font-medium whitespace-nowrap ${
deliveryStatus === 'submitted'
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
: 'bg-orange-50 text-orange-600 border border-orange-100'
}`}>
{deliveryStatus === 'submitted' ? '已提测' : '待提测'}
</span>
) : (
<span className="h-5 w-24 shrink-0" />
)}
<span className="text-[12px] font-medium text-[var(--ink)] flex-1 truncate">{req?.title || '未关联需求'}</span>
<span className="text-[11px] text-[var(--ink-muted)]">{reqPassed}/{cases.length} </span>
</div>
</div> </div>
{cases.map((c) => ( {cases.map((c) => (
<div key={c.id} className="flex items-center"> <div key={c.id} className="flex items-center">
@@ -196,7 +265,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />} {total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
{showCreate && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} onClose={() => setShowCreate(false)} />} {showCreate && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} roundNo={activeRound} onClose={() => setShowCreate(false)} />}
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => setBugForCaseId(id)} />} {selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => setBugForCaseId(id)} />}
{bugForCaseId && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />} {bugForCaseId && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
</div> </div>

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); 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', () => { test('pending to running writes startedAt', () => {
const result = applyTestCaseTransition(tc(), 'running', { const result = applyTestCaseTransition(tc(), 'running', {
now: new Date('2026-06-25T01:00:00.000Z'), now: new Date('2026-06-25T01:00:00.000Z'),

View File

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

View File

@@ -5,7 +5,12 @@ import {
TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_LABEL,
aggregateTestCaseHours, aggregateTestCaseHours,
calcTestProgress, calcTestProgress,
canStartNextTestRound,
copyTestCaseToRound,
getNextTestRoundNo,
getRequirementDeliveryStatus,
getTestCaseEstimateHours, getTestCaseEstimateHours,
getTestCaseRoundNo,
normalizeTestCase, normalizeTestCase,
} from './test-case'; } from './test-case';
import { DEFAULT_TEST_CATEGORY_ID } from './task-category'; import { DEFAULT_TEST_CATEGORY_ID } from './task-category';
@@ -43,6 +48,104 @@ test('normalizeTestCase keeps existing categoryId', () => {
assert.equal(tc.categoryId, 'cat-test-api'); 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', () => { test('normalizeTestCase keeps missing executor estimate empty', () => {
const tc = normalizeTestCase({ const tc = normalizeTestCase({
id: 'tc-1', id: 'tc-1',
@@ -137,6 +240,91 @@ test('calcTestProgress uses estimate-weighted completion', () => {
assert.equal(progress.completionRate, 75); 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', () => { test('aggregateTestCaseHours returns estimate and actual totals', () => {
const hours = aggregateTestCaseHours([ const hours = aggregateTestCaseHours([
normalizeTestCase({ normalizeTestCase({

View File

@@ -1,5 +1,6 @@
import type { Priority } from './derive'; 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 { DEFAULT_TEST_CATEGORY_ID } from './task-category';
import { calcActualElapsedHours, type TimeInterval } from './work-hours'; import { calcActualElapsedHours, type TimeInterval } from './work-hours';
import { aggregateWorkEffort } from './work-effort-engine'; import { aggregateWorkEffort } from './work-effort-engine';
@@ -11,6 +12,8 @@ export interface TestCase {
caseNo: string; caseNo: string;
versionId: string; versionId: string;
requirementId?: string; requirementId?: string;
roundNo?: number;
sourceCaseId?: string;
title: string; title: string;
description?: string; description?: string;
categoryId: 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')}`, caseNo: testCase.caseNo || `TC-${String(index + 1).padStart(3, '0')}`,
versionId: testCase.versionId || '', versionId: testCase.versionId || '',
requirementId: testCase.requirementId, requirementId: testCase.requirementId,
roundNo: getTestCaseRoundNo(testCase),
sourceCaseId: testCase.sourceCaseId,
title: testCase.title || `测试用例 ${index + 1}`, title: testCase.title || `测试用例 ${index + 1}`,
description: testCase.description, description: testCase.description,
categoryId: testCase.categoryId || DEFAULT_TEST_CATEGORY_ID, 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)); 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; const total = cases.length;
if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 }; 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 passed = cases.filter((c) => c.status === 'passed').length;
const failed = cases.filter((c) => c.status === 'failed').length; const failed = cases.filter((c) => c.status === 'failed').length;
const blocked = cases.filter((c) => c.status === 'blocked').length; const blocked = cases.filter((c) => c.status === 'blocked').length;
const executed = passed + failed + blocked; const effectiveBugCaseIds = new Set(
const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0; 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) => ({ const completionRate = aggregateWorkEffort(cases.map((c) => ({
estimateHours: getTestCaseEstimateHours(c), estimateHours: getTestCaseEstimateHours(c),
actualHours: getTestCaseActualHours(c), actualHours: getTestCaseActualHours(c),

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import type { TestCase, TestCaseStatus } from '@/lib/test-case'; import type { CreateTestCaseInput, TestCase, TestCaseStatus } from '@/lib/test-case';
import { generateCaseNo, normalizeTestCases } from '@/lib/test-case'; import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow'; import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
@@ -20,7 +20,8 @@ async function loadStored(): Promise<TestCase[] | null> {
interface TestCaseState { interface TestCaseState {
testCases: TestCase[]; testCases: TestCase[];
fetchTestCases: () => Promise<void>; fetchTestCases: () => Promise<void>;
createTestCase: (data: Omit<TestCase, 'id' | 'caseNo' | 'createdAt' | 'updatedAt' | 'status'>) => TestCase; createTestCase: (data: CreateTestCaseInput) => TestCase;
createTestCases: (items: CreateTestCaseInput[]) => TestCase[];
updateTestCase: (id: string, data: Partial<TestCase>) => void; updateTestCase: (id: string, data: Partial<TestCase>) => void;
deleteTestCase: (id: string) => void; deleteTestCase: (id: string) => void;
changeStatus: (id: string, to: TestCaseStatus, extra?: { failReason?: string; blockReason?: string }) => { ok: boolean; message?: string }; changeStatus: (id: string, to: TestCaseStatus, extra?: { failReason?: string; blockReason?: string }) => { ok: boolean; message?: string };
@@ -58,6 +59,28 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
return tc; return tc;
}, },
createTestCases: (items) => {
if (items.length === 0) return [];
let list = get().testCases;
const now = new Date().toISOString();
const created: TestCase[] = [];
for (const data of items) {
const tc: TestCase = normalizeTestCaseOnCreate({
...data,
id: createEntityId('tc'),
caseNo: generateCaseNo(list),
status: 'pending',
createdAt: now,
updatedAt: now,
} as TestCase);
created.push(tc);
list = [...list, tc];
}
set({ testCases: list });
saveStored(list);
return created;
},
updateTestCase: (id, data) => { updateTestCase: (id, data) => {
const updated = get().testCases.map((c) => const updated = get().testCases.map((c) =>
c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c, c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c,

View File

@@ -315,3 +315,18 @@
- 账号身份不能依赖可编辑姓名;姓名只是展示字段。 - 账号身份不能依赖可编辑姓名;姓名只是展示字段。
- 内置超管账号避免新系统初始化后被误删或降权导致无法管理。 - 内置超管账号避免新系统初始化后被误删或降权导致无法管理。
- 历史单据兼容能修复已有数据的显示错位,同时不需要批量改写业务记录。 - 历史单据兼容能修复已有数据的显示错位,同时不需要批量改写业务记录。
## 27. 测试轮次以第一轮用例为母版
**问题**:同一版本测试失败或修复后,经常需要重新跑一遍同一批测试用例。如果直接复用原用例,会覆盖第一轮执行记录;如果手工重建,又容易漏掉 AI 生成或人工补充的用例。
**决策**
- TestCase 增加 `roundNo`,旧数据和未显式写入的数据统一视为第 1 轮。
- 点击“开启新一轮测试”时,只从第 1 轮复制用例到下一轮AI 创建和人工创建的用例都复制。
- 只有最新一轮测试用例全部进入 `passed/failed/blocked` 后,才能开启下一轮;第 1 轮未测完时不能开启第 2 轮。
- 复制时保留需求、标题、描述、类型、优先级、负责人、引用来源、AI 草稿标记、AI 预估和执行预估。
- 复制时清空执行状态、开始/完成时间、执行人、失败原因和阻塞原因,新轮次从 `pending` 开始。
- 测试进度、无 Bug 通过率和列表按当前轮次查看;顶部 AI 预估、执行预估、实际耗时、人力投入按版本全部轮次累计。
- 测试用例按所属需求分组时展示“已提测/待提测”标签;只有该需求下所有开发任务都 `submitted` 才展示“已提测”,否则展示“待提测”。
**理由**:第一轮承载完整测试范围,后续轮次应复跑同一范围而不是临时拼装;执行记录按轮次隔离,整体投入按版本累计,能同时回答“这一轮测得怎么样”和“这个版本测试总共花了多少”。

View File

@@ -31,6 +31,19 @@
- 个人耗时 = 每个任务独立累加(个人维度) - 个人耗时 = 每个任务独立累加(个人维度)
``` ```
## 测试轮次流程
测试用例支持按版本开启多轮测试:
1. 旧测试用例或未写入 `roundNo` 的测试用例默认属于第 1 轮。
2. 点击“开启新一轮测试”时,系统从第 1 轮复制全部测试用例到下一轮,包含 AI 创建和人工创建的用例。
3. 只有最新一轮全部测完(状态为通过、不通过或阻塞)后,才能开启下一轮;第 1 轮未测完不能开启第 2 轮。
4. 新轮次保留用例范围与估时信息关联需求、任务类型、优先级、负责人、引用来源、AI 预估、执行预估。
5. 新轮次清空执行记录:状态回到待测试,不带开始/完成时间、执行人、失败原因、阻塞原因。
6. 测试用例列表、当前轮次进度、无 Bug 通过率按选中的轮次展示。
7. 顶部耗时汇总按当前版本全部轮次累计,实际耗时和人力投入会包含新一轮执行产生的耗时。
8. 测试用例按所属需求分组展示开发提测标签:该需求下所有开发任务都已提测时显示“已提测”,否则显示“待提测”。
## 数据联动检查清单 ## 数据联动检查清单
新加模块或字段时,检查以下点: 新加模块或字段时,检查以下点: