diff --git a/apps/web/components/bug/BugTab.tsx b/apps/web/components/bug/BugTab.tsx index f3dfeb5..3e78db2 100644 --- a/apps/web/components/bug/BugTab.tsx +++ b/apps/web/components/bug/BugTab.tsx @@ -12,9 +12,10 @@ import { BugDetailDrawer } from './BugDetailDrawer'; import { Pagination, usePagination } from '@/components/Pagination'; import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; -import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, bugIntervals } from '@/lib/bug'; -import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; +import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, aggregateBugActualHours, bugIntervals } from '@/lib/bug'; +import { calcTwoMetrics } from '@/lib/work-hours'; import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system'; +import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary'; import type { BugStatus, BugSeverity } from '@/lib/bug'; 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 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 { 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 [filterStatus, setFilterStatus] = useState(''); @@ -97,9 +108,11 @@ export function BugTab({ versionId, requirementIds }: Props) { {criticalCount} 致命 )} 解决率 {resolveRate}% - {bugManhours > 0 && ( - 日历 {formatWorkHours(bugCalendarHours)} / 人力 {formatWorkHours(bugManhours)} - )} + {effortMetrics.map((metric) => ( + + {metric.label} {metric.value} + + ))}
diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx index f3a923a..c8fe2e8 100644 --- a/apps/web/components/dev-task/DevTaskTab.tsx +++ b/apps/web/components/dev-task/DevTaskTab.tsx @@ -12,10 +12,12 @@ import { DevTaskRow } from './DevTaskRow'; import { DevTaskCreateModal } from './DevTaskCreateModal'; import { DevTaskDetailDrawer } from './DevTaskDetailDrawer'; 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 { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; 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'; interface Props { @@ -60,14 +62,25 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props return result; }, [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 { estimate: totalEstimate } = aggregateDevTaskHours(versionTasks); - const { calendarHours, manhours } = useMemo(() => calcTwoMetrics(devTaskIntervals(versionTasks)), [versionTasks]); - const overrun = manhours > totalEstimate && totalEstimate > 0; - const underrun = manhours > 0 && manhours < totalEstimate; - const hoursTone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink-muted)]'; + const { actual: actualHours } = aggregateDevTaskHours(versionTasks); + const { manhours } = useMemo(() => calcTwoMetrics(devTaskIntervals(versionTasks)), [versionTasks]); + const effortMetrics = useMemo( + () => buildEffortSummaryMetrics({ + 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 [showCreate, setShowCreate] = useState(false); @@ -112,7 +125,12 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
- {versionTasks.length}任务 · 预 {formatWorkHours(totalEstimate)}{manhours > 0 ? <> · 日历 {formatWorkHours(calendarHours)} / 人力 {formatWorkHours(manhours)} : null} + {versionTasks.length}任务 + {effortMetrics.map((metric) => ( + + {metric.label} {metric.value} + + ))} {blockedCount > 0 && {blockedCount}阻塞} diff --git a/apps/web/components/test-case/TestCaseCreateModal.tsx b/apps/web/components/test-case/TestCaseCreateModal.tsx index 94a08e4..1910f45 100644 --- a/apps/web/components/test-case/TestCaseCreateModal.tsx +++ b/apps/web/components/test-case/TestCaseCreateModal.tsx @@ -14,10 +14,11 @@ import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/l interface Props { versionId: string; requirementIds: string[]; + roundNo?: number; onClose: () => void; } -export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Props) { +export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) { const { createTestCase } = useTestCaseStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); @@ -74,6 +75,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Prop createTestCase({ versionId, requirementId: requirementId || undefined, + roundNo: roundNo ?? 1, title: title.trim(), description: description.trim() || undefined, categoryId, diff --git a/apps/web/components/test-case/TestCaseRow.tsx b/apps/web/components/test-case/TestCaseRow.tsx index 9ce463f..96e4a7d 100644 --- a/apps/web/components/test-case/TestCaseRow.tsx +++ b/apps/web/components/test-case/TestCaseRow.tsx @@ -4,7 +4,6 @@ import { memo } from 'react'; import { TestCaseStatusBadge } from './TestCaseStatusBadge'; import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case'; import { formatWorkHours } from '@/lib/work-hours'; -import { CategoryChip } from '@/components/dev-task/CategoryChip'; import type { TestCase } from '@/lib/test-case'; import type { TaskCategory } from '@/lib/task-category'; @@ -32,22 +31,31 @@ function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
{testCase.caseNo} + + {category?.name || '未分类'} +
{testCase.title} - {testCase.aiDraft && ( AI 草案 )}
+ {bugCount > 0 && ( + {bugCount} Bug + )} {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `${estimatePrefix} ${formatWorkHours(estimateHours)}`} - {bugCount > 0 && ( - {bugCount} Bug - )} {testCase.assigneeId || '-'}
); diff --git a/apps/web/components/test-case/TestCaseTab.tsx b/apps/web/components/test-case/TestCaseTab.tsx index 188b94f..4aa8e78 100644 --- a/apps/web/components/test-case/TestCaseTab.tsx +++ b/apps/web/components/test-case/TestCaseTab.tsx @@ -1,7 +1,7 @@ 'use client'; 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 { useBugStore } from '@/stores/useBugStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; @@ -12,11 +12,13 @@ import { TestCaseRow } from './TestCaseRow'; import { TestCaseCreateModal } from './TestCaseCreateModal'; import { TestCaseDetailDrawer } from './TestCaseDetailDrawer'; import { BugCreateModal } from '@/components/bug/BugCreateModal'; -import { aggregateTestCaseHours, calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case'; -import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; +import { aggregateTestCaseHours, calcTestProgress, canStartNextTestRound, copyTestCaseToRound, getNextTestRoundNo, getRequirementDeliveryStatus, getTestCaseRoundNo, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case'; +import { calcTwoMetrics } from '@/lib/work-hours'; import { Pagination, usePagination } from '@/components/Pagination'; import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; 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'; interface Props { @@ -25,7 +27,7 @@ interface Props { } export function TestCaseTab({ versionId, requirementIds }: Props) { - const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore(); + const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore(); const { bugs, fetchBugs } = useBugStore(); const { tasks: devTasks } = useDevTaskStore(); const { requirements } = useRequirementStore(); @@ -41,16 +43,50 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { () => testCases.filter((c) => c.versionId === 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( () => devTasks.filter((t) => reqIdSet.has(t.requirementId)), [devTasks, reqIdSet], ); - const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted'); - const stats = calcTestProgress(versionCases); - const { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(versionCases); - const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]); + const stats = calcTestProgress(activeRoundCases, versionBugs); + const { actual: tcActualHours } = aggregateTestCaseHours(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 [filterStatus, setFilterStatus] = useState(''); @@ -58,14 +94,19 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { const debouncedKeyword = useDebouncedValue(keyword, 300); const filteredCases = useMemo(() => { - let result = versionCases; + let result = activeRoundCases; if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee); if (filterStatus) result = result.filter((c) => c.status === filterStatus); if (debouncedKeyword) result = result.filter((c) => matchTitleOrNo({ title: c.title, no: c.caseNo }, debouncedKeyword)); 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 [selectedCaseId, setSelectedCaseId] = useState(null); @@ -86,6 +127,17 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { selectedIds.forEach((id) => deleteTestCase(id)); 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 map = new Map(); @@ -102,39 +154,31 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]); const bugCountByCase = useMemo(() => { const map = new Map(); - 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; - }, [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); return (
- {allSubmitted && ( -
- - 开发已全部提测,可以开始测试 - {versionDevTasks.length} 个任务已交付 -
- )} - {versionDevTasks.length > 0 && !allSubmitted && ( -
- 开发进行中({versionDevTasks.filter((t) => t.status === 'submitted').length}/{versionDevTasks.length} 已提测) -
- )} -
{stats.completionRate}%
- {stats.total}例 · 通过{stats.passed} · 不通过{stats.failed} · 预 {formatWorkHours(tcEstimateHours)} / 实 {formatWorkHours(tcActualHours)} - 通过率{stats.passRate}% - {tcManhours > 0 && ( - 日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)} - )} + {stats.total}例 · 通过{stats.passed} · 不通过{stats.failed} + 无Bug通过率{stats.passRate}% + {effortMetrics.map((metric) => ( + + {metric.label} {metric.value} + + ))} + @@ -156,6 +200,14 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { 删除{selectedIds.size}项 )} + @@ -171,13 +223,30 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { Array.from(groupedByReq.entries()).map(([reqId, cases]) => { const req = reqId === '__none__' ? null : requirementMap.get(reqId); const reqPassed = cases.filter((c) => c.status === 'passed').length; + const deliveryStatus = req ? getRequirementDeliveryStatus(req.id, versionDevTasks) : null; return (
-
- 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)]" /> - {req?.code || '通用'} - {req?.title || '未关联需求'} - {reqPassed}/{cases.length} 通过 +
+
+ 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)]" /> +
+
+ + {req?.code || '通用'} + {deliveryStatus ? ( + + {deliveryStatus === 'submitted' ? '已提测' : '待提测'} + + ) : ( + + )} + {req?.title || '未关联需求'} + {reqPassed}/{cases.length} 通过 +
{cases.map((c) => (
@@ -196,7 +265,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { {total > 20 && } - {showCreate && setShowCreate(false)} />} + {showCreate && setShowCreate(false)} />} {selectedCaseId && setSelectedCaseId(null)} onCreateBug={(id) => setBugForCaseId(id)} />} {bugForCaseId && setBugForCaseId(null)} />}
diff --git a/apps/web/lib/effort-summary.test.ts b/apps/web/lib/effort-summary.test.ts new file mode 100644 index 0000000..952ce35 --- /dev/null +++ b/apps/web/lib/effort-summary.test.ts @@ -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.25h(0.01天)'); + assert.equal(metrics[1].value, '-'); + assert.equal(metrics[2].value, '1.5h(0.06天)'); + assert.equal(metrics[3].value, '2h(0.08天)'); +}); diff --git a/apps/web/lib/effort-summary.ts b/apps/web/lib/effort-summary.ts new file mode 100644 index 0000000..46c2d88 --- /dev/null +++ b/apps/web/lib/effort-summary.ts @@ -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(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) : '-'; +} diff --git a/apps/web/lib/requirement-grouping.test.ts b/apps/web/lib/requirement-grouping.test.ts new file mode 100644 index 0000000..471f0ef --- /dev/null +++ b/apps/web/lib/requirement-grouping.test.ts @@ -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']); +}); diff --git a/apps/web/lib/requirement-grouping.ts b/apps/web/lib/requirement-grouping.ts new file mode 100644 index 0000000..90918b9 --- /dev/null +++ b/apps/web/lib/requirement-grouping.ts @@ -0,0 +1,20 @@ +export function orderByRequirementForGrouping( + 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); +} diff --git a/apps/web/lib/test-case-workflow.test.ts b/apps/web/lib/test-case-workflow.test.ts index 5661594..e2f77dc 100644 --- a/apps/web/lib/test-case-workflow.test.ts +++ b/apps/web/lib/test-case-workflow.test.ts @@ -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'), diff --git a/apps/web/lib/test-case-workflow.ts b/apps/web/lib/test-case-workflow.ts index 20e3131..9244f3e 100644 --- a/apps/web/lib/test-case-workflow.ts +++ b/apps/web/lib/test-case-workflow.ts @@ -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, diff --git a/apps/web/lib/test-case.test.ts b/apps/web/lib/test-case.test.ts index 07623b3..ba2a4be 100644 --- a/apps/web/lib/test-case.test.ts +++ b/apps/web/lib/test-case.test.ts @@ -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({ diff --git a/apps/web/lib/test-case.ts b/apps/web/lib/test-case.ts index 4fedd1d..c80c442 100644 --- a/apps/web/lib/test-case.ts +++ b/apps/web/lib/test-case.ts @@ -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, 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[] 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; + +export function getTestCaseRoundNo(testCase: Partial>): 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[]): number { + const maxRound = cases.reduce((max, testCase) => Math.max(max, getTestCaseRoundNo(testCase)), 0); + return maxRound + 1; +} + +export function isTestCaseTested(testCase: Pick): boolean { + return testCase.status === 'passed' || testCase.status === 'failed' || testCase.status === 'blocked'; +} + +export function canStartNextTestRound(cases: Pick[]): 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; + +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), diff --git a/apps/web/stores/useTestCaseStore.ts b/apps/web/stores/useTestCaseStore.ts index 1937cd2..58e94be 100644 --- a/apps/web/stores/useTestCaseStore.ts +++ b/apps/web/stores/useTestCaseStore.ts @@ -1,6 +1,6 @@ 'use client'; 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 { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow'; import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; @@ -20,7 +20,8 @@ async function loadStored(): Promise { interface TestCaseState { testCases: TestCase[]; fetchTestCases: () => Promise; - createTestCase: (data: Omit) => TestCase; + createTestCase: (data: CreateTestCaseInput) => TestCase; + createTestCases: (items: CreateTestCaseInput[]) => TestCase[]; updateTestCase: (id: string, data: Partial) => void; deleteTestCase: (id: string) => void; changeStatus: (id: string, to: TestCaseStatus, extra?: { failReason?: string; blockReason?: string }) => { ok: boolean; message?: string }; @@ -58,6 +59,28 @@ export const useTestCaseStore = create((set, get) => ({ 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) => { const updated = get().testCases.map((c) => c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c, diff --git a/docs/decisions.md b/docs/decisions.md index 6e38bcf..f669f4a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -315,3 +315,18 @@ - 账号身份不能依赖可编辑姓名;姓名只是展示字段。 - 内置超管账号避免新系统初始化后被误删或降权导致无法管理。 - 历史单据兼容能修复已有数据的显示错位,同时不需要批量改写业务记录。 + +## 27. 测试轮次以第一轮用例为母版 + +**问题**:同一版本测试失败或修复后,经常需要重新跑一遍同一批测试用例。如果直接复用原用例,会覆盖第一轮执行记录;如果手工重建,又容易漏掉 AI 生成或人工补充的用例。 + +**决策**: +- TestCase 增加 `roundNo`,旧数据和未显式写入的数据统一视为第 1 轮。 +- 点击“开启新一轮测试”时,只从第 1 轮复制用例到下一轮,AI 创建和人工创建的用例都复制。 +- 只有最新一轮测试用例全部进入 `passed/failed/blocked` 后,才能开启下一轮;第 1 轮未测完时不能开启第 2 轮。 +- 复制时保留需求、标题、描述、类型、优先级、负责人、引用来源、AI 草稿标记、AI 预估和执行预估。 +- 复制时清空执行状态、开始/完成时间、执行人、失败原因和阻塞原因,新轮次从 `pending` 开始。 +- 测试进度、无 Bug 通过率和列表按当前轮次查看;顶部 AI 预估、执行预估、实际耗时、人力投入按版本全部轮次累计。 +- 测试用例按所属需求分组时展示“已提测/待提测”标签;只有该需求下所有开发任务都 `submitted` 才展示“已提测”,否则展示“待提测”。 + +**理由**:第一轮承载完整测试范围,后续轮次应复跑同一范围而不是临时拼装;执行记录按轮次隔离,整体投入按版本累计,能同时回答“这一轮测得怎么样”和“这个版本测试总共花了多少”。 diff --git a/docs/workflow.md b/docs/workflow.md index 3ab8cea..6466e02 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -31,6 +31,19 @@ - 个人耗时 = 每个任务独立累加(个人维度) ``` +## 测试轮次流程 + +测试用例支持按版本开启多轮测试: + +1. 旧测试用例或未写入 `roundNo` 的测试用例默认属于第 1 轮。 +2. 点击“开启新一轮测试”时,系统从第 1 轮复制全部测试用例到下一轮,包含 AI 创建和人工创建的用例。 +3. 只有最新一轮全部测完(状态为通过、不通过或阻塞)后,才能开启下一轮;第 1 轮未测完不能开启第 2 轮。 +4. 新轮次保留用例范围与估时信息:关联需求、任务类型、优先级、负责人、引用来源、AI 预估、执行预估。 +5. 新轮次清空执行记录:状态回到待测试,不带开始/完成时间、执行人、失败原因、阻塞原因。 +6. 测试用例列表、当前轮次进度、无 Bug 通过率按选中的轮次展示。 +7. 顶部耗时汇总按当前版本全部轮次累计,实际耗时和人力投入会包含新一轮执行产生的耗时。 +8. 测试用例按所属需求分组展示开发提测标签:该需求下所有开发任务都已提测时显示“已提测”,否则显示“待提测”。 + ## 数据联动检查清单 新加模块或字段时,检查以下点: