diff --git a/frontend/src/components/batch-test/batch-case-status.tsx b/frontend/src/components/batch-test/batch-case-status.tsx new file mode 100644 index 0000000..b56cbde --- /dev/null +++ b/frontend/src/components/batch-test/batch-case-status.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { CheckCircle2, Circle, Loader2, XCircle } from "lucide-react"; + +import type { BatchCaseStatus } from "@/data/batch-run"; +import { cn } from "@/lib/utils"; + +const STATUS_META: Record< + BatchCaseStatus, + { label: string; className: string; Icon: typeof CheckCircle2 } +> = { + pass: { + label: "通过", + className: "text-success", + Icon: CheckCircle2, + }, + fail: { + label: "失败", + className: "text-destructive", + Icon: XCircle, + }, + running: { + label: "运行中", + className: "text-primary", + Icon: Loader2, + }, + waiting: { + label: "等待", + className: "text-muted-soft", + Icon: Circle, + }, + skipped: { + label: "未执行", + className: "text-muted-soft", + Icon: Circle, + }, +}; + +export function BatchCaseStatusBadge({ + status, + className, +}: { + status: BatchCaseStatus; + className?: string; +}) { + const meta = STATUS_META[status]; + const Icon = meta.Icon; + + return ( + + + {meta.label} + + ); +} + +export function BatchPhasePill({ + phase, +}: { + phase: "running" | "completed"; +}) { + if (phase === "running") { + return ( + + 运行中 + + ); + } + + return ( + + 已完成 + + ); +} diff --git a/frontend/src/components/batch-test/batch-run-completed.tsx b/frontend/src/components/batch-test/batch-run-completed.tsx new file mode 100644 index 0000000..6a9860f --- /dev/null +++ b/frontend/src/components/batch-test/batch-run-completed.tsx @@ -0,0 +1,297 @@ +"use client"; + +/** + * 批量测试 — 已完成结果视图(MVP mock)。 + */ + +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; + +import { + BatchCaseStatusBadge, + BatchPhasePill, +} from "@/components/batch-test/batch-case-status"; +import { + countByStatus, + formatRunTime, + type BatchRunSnapshot, +} from "@/data/batch-run"; +import { cn } from "@/lib/utils"; + +export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) { + const [expandedIds, setExpandedIds] = useState>(() => { + const firstFail = run.cases.find((item) => item.status === "fail"); + return firstFail ? new Set([firstFail.id]) : new Set(); + }); + + const counts = countByStatus(run.cases); + const judged = counts.pass + counts.fail; + const total = run.cases.length; + const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100); + const failRate = judged === 0 ? 0 : 100 - passRate; + + function toggleExpanded(id: string) { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+ {/* 结果总览 */} +
+
+

{run.title}

+ +
+ {run.finishedAt && ( +

+ 完成时间:{formatRunTime(run.finishedAt)} + {run.stopped ? " · 已手动停止" : ""} +

+ )} + +
+
+
+ {counts.pass} / {judged || total} 通过 +
+
+ 通过率 {passRate}% +
+
+ + + +
+ + + {counts.skipped > 0 && ( + + )} +
+
+
+ + {/* 用例列表 */} +
+
+

+ 用例列表(共 {total} 个) +

+
+ +
+ + + + + + + + + + + {run.cases.map((item, index) => { + const expanded = expandedIds.has(item.id); + const canExpand = + item.status === "fail" || item.status === "pass"; + + return ( + + + + ); + })} + +
#测试用例状态详情
+ +
+
+
+
+ ); +} + +function PassRateDonut({ pass, fail }: { pass: number; fail: number }) { + const total = pass + fail; + const passRatio = total === 0 ? 0 : pass / total; + const size = 112; + const stroke = 14; + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const passLength = circumference * passRatio; + const failLength = circumference - passLength; + + return ( +
+ + + {total > 0 && ( + <> + + + + )} + +
+ + {total === 0 ? 0 : Math.round((pass / total) * 100)}% + +
+
+ ); +} + +function LegendRow({ + tone, + label, + count, + percent, +}: { + tone: "success" | "destructive" | "muted"; + label: string; + count: number; + percent: number; +}) { + const dotClass = { + success: "bg-success", + destructive: "bg-destructive", + muted: "bg-muted-soft", + }[tone]; + + return ( +
+ + + {label} {count}({percent}%) + +
+ ); +} + +function DetailField({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} diff --git a/frontend/src/components/batch-test/batch-run-running.tsx b/frontend/src/components/batch-test/batch-run-running.tsx new file mode 100644 index 0000000..3d073bb --- /dev/null +++ b/frontend/src/components/batch-test/batch-run-running.tsx @@ -0,0 +1,195 @@ +"use client"; + +/** + * 批量测试 — 运行中视图(MVP mock)。 + */ + +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; + +import { + BatchCaseStatusBadge, + BatchPhasePill, +} from "@/components/batch-test/batch-case-status"; +import { + countByStatus, + type BatchRunSnapshot, +} from "@/data/batch-run"; +import { cn } from "@/lib/utils"; + +export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) { + const [expandedIds, setExpandedIds] = useState>(new Set()); + const counts = countByStatus(run.cases); + const done = counts.pass + counts.fail + counts.skipped; + const total = run.cases.length; + const percent = total === 0 ? 0 : Math.round((done / total) * 100); + + function toggleExpanded(id: string) { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+ {/* 进度总览 */} +
+
+
+
+

+ {run.title} +

+ +
+ +
+
+
+
+
+ + {done} / {total} + {" "} + 已完成 + {percent}% +
+
+
+ +
+ + + + +
+
+
+ + {/* 用例列表 */} +
+
+

+ 测试用例(共 {total} 个) +

+
+ +
+ + + + + + + + + + {run.cases.map((item, index) => { + const expanded = expandedIds.has(item.id); + const canExpand = + item.status === "fail" || item.status === "pass"; + + return ( + + + + ); + })} + +
#测试用例状态 +
+ +
+
+
+
+ ); +} + +function Stat({ + label, + value, + tone, +}: { + label: string; + value: number; + tone: "success" | "destructive" | "primary" | "muted"; +}) { + const toneClass = { + success: "text-success", + destructive: "text-destructive", + primary: "text-primary", + muted: "text-muted-foreground", + }[tone]; + + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function DetailBlock({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 2ea59f8..aea3315 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -54,6 +54,7 @@ const monitorSubItems: NavItem[] = [ const testSubItems: NavItem[] = [ { href: "/test/cases", label: "测试用例", icon: ClipboardList }, + { href: "/test/batch", label: "批量测试", icon: PlayCircle }, ]; export function Sidebar({ collapsed, onToggle }: SidebarProps) { diff --git a/frontend/src/components/layout/list-page-layout.tsx b/frontend/src/components/layout/list-page-layout.tsx index 8d2afbb..cefb551 100644 --- a/frontend/src/components/layout/list-page-layout.tsx +++ b/frontend/src/components/layout/list-page-layout.tsx @@ -10,12 +10,15 @@ export function ListPageLayout({ title, description, action, + topbarAction, children, className, }: { title: string; description?: ReactNode; action?: ReactNode; + /** 右上角 topbar 操作区(如主 CTA) */ + topbarAction?: ReactNode; children: ReactNode; className?: string; }) { @@ -24,7 +27,7 @@ export function ListPageLayout({ return ( <> - +
(".app-content"); +} + +function buildRunTitle( + selected: TestCase[], + suites: TestSuite[], +): string { + const suiteIds = [...new Set(selected.map((item) => item.suiteId))]; + if (suiteIds.length === 1) { + return ( + suites.find((suite) => suite.id === suiteIds[0])?.name ?? "批量测试" + ); + } + if (suiteIds.length > 1) { + return `${suiteIds.length} 个测试集 · ${selected.length} 个用例`; + } + return "批量测试"; +} export function BatchTestPage() { + const [phase, setPhase] = useState("config"); + const [run, setRun] = useState(null); + + const [assistants, setAssistants] = useState([]); + const [loadingAssistants, setLoadingAssistants] = useState(true); + const [assistantId, setAssistantId] = useState(""); + + const [suites, setSuites] = useState([]); + const [casesBySuite, setCasesBySuite] = useState>( + {}, + ); + const [expandedSuiteIds, setExpandedSuiteIds] = useState>( + new Set(), + ); + const [selectedCaseIds, setSelectedCaseIds] = useState>( + new Set(), + ); + + const [concurrency, setConcurrency] = useState("3"); + const [timeoutSecs, setTimeoutSecs] = useState("30"); + const [failStrategy, setFailStrategy] = useState("continue"); + + const [activeSection, setActiveSection] = + useState("target"); + const sectionRefs = useRef>({ + target: null, + scope: null, + settings: null, + }); + const selectedAnchorRef = useRef(null); + const stopOnFailRef = useRef(failStrategy === "stop_on_fail"); + stopOnFailRef.current = failStrategy === "stop_on_fail"; + + useEffect(() => { + if (phase !== "config") return; + + const scrollContainer = getAppScrollContainer(); + if (!scrollContainer) return; + + let animationFrame = 0; + + function updateActiveSection() { + if (selectedAnchorRef.current) { + setActiveSection(selectedAnchorRef.current); + return; + } + + const containerTop = scrollContainer!.getBoundingClientRect().top; + const activationLine = containerTop + 24; + let nextSection: BatchSectionId = BATCH_SECTIONS[0].id; + + for (const section of BATCH_SECTIONS) { + const element = sectionRefs.current[section.id]; + if (element && element.getBoundingClientRect().top <= activationLine) { + nextSection = section.id; + } + } + + const reachedBottom = + scrollContainer!.scrollHeight > scrollContainer!.clientHeight + 8 && + scrollContainer!.scrollHeight - + scrollContainer!.scrollTop - + scrollContainer!.clientHeight < + 8; + if (reachedBottom) { + nextSection = BATCH_SECTIONS[BATCH_SECTIONS.length - 1].id; + } + + setActiveSection((current) => + current === nextSection ? current : nextSection, + ); + } + + function scheduleUpdate() { + window.cancelAnimationFrame(animationFrame); + animationFrame = window.requestAnimationFrame(updateActiveSection); + } + + function releaseSelectedAnchor() { + selectedAnchorRef.current = null; + scheduleUpdate(); + } + + scheduleUpdate(); + scrollContainer.addEventListener("scroll", scheduleUpdate, { + passive: true, + }); + scrollContainer.addEventListener("wheel", releaseSelectedAnchor, { + passive: true, + }); + scrollContainer.addEventListener("touchstart", releaseSelectedAnchor, { + passive: true, + }); + window.addEventListener("resize", scheduleUpdate); + + return () => { + window.cancelAnimationFrame(animationFrame); + scrollContainer.removeEventListener("scroll", scheduleUpdate); + scrollContainer.removeEventListener("wheel", releaseSelectedAnchor); + scrollContainer.removeEventListener("touchstart", releaseSelectedAnchor); + window.removeEventListener("resize", scheduleUpdate); + }; + }, [phase]); + + function scrollToSection(sectionId: BatchSectionId) { + const scrollContainer = getAppScrollContainer(); + const section = sectionRefs.current[sectionId]; + if (!scrollContainer || !section) return; + + const containerTop = scrollContainer.getBoundingClientRect().top; + const sectionTop = section.getBoundingClientRect().top; + + selectedAnchorRef.current = sectionId; + setActiveSection(sectionId); + scrollContainer.scrollTo({ + top: scrollContainer.scrollTop + (sectionTop - containerTop) - 12, + behavior: "smooth", + }); + } + + useEffect(() => { + const nextSuites = listTestSuites(); + setSuites(nextSuites); + + const map: Record = {}; + const allCaseIds = new Set(); + for (const suite of nextSuites) { + const items = listTestCases(suite.id); + map[suite.id] = items; + for (const item of items) allCaseIds.add(item.id); + } + setCasesBySuite(map); + setSelectedCaseIds(allCaseIds); + + void (async () => { + try { + const list = await assistantsApi.list(); + setAssistants(list); + if (list[0]) setAssistantId(list[0].id); + } catch { + // 无助手时仍可浏览配置页 + } finally { + setLoadingAssistants(false); + } + })(); + }, []); + + // mock 执行引擎:按并发推进用例状态 + useEffect(() => { + if (phase !== "running" || !run) return; + + const limit = Number(concurrency) || 3; + let finished = false; + + function tick(settleRunning: boolean) { + if (finished) return; + + setRun((prev) => { + if (!prev || finished) return prev; + + let cases = prev.cases.map((item) => ({ ...item })); + + // 1) 结算上一拍仍在运行的用例(首拍只启动,不结算) + let sawFail = false; + if (settleRunning) { + for (const item of cases) { + if (item.status !== "running") continue; + item.status = item.failReason ? "fail" : "pass"; + if (item.status === "fail") sawFail = true; + } + } + + // 失败即停:剩余全部标记未执行 + if (sawFail && stopOnFailRef.current) { + cases = cases.map((item) => + item.status === "waiting" || item.status === "running" + ? { ...item, status: "skipped" as const } + : item, + ); + finished = true; + window.setTimeout(() => setPhase("completed"), 0); + return { + ...prev, + cases, + finishedAt: new Date().toISOString(), + stopped: true, + }; + } + + // 2) 按并发补齐运行中 + let running = cases.filter((item) => item.status === "running").length; + for (const item of cases) { + if (running >= limit) break; + if (item.status !== "waiting") continue; + item.status = "running"; + running += 1; + } + + const pending = cases.some( + (item) => item.status === "waiting" || item.status === "running", + ); + if (!pending) { + finished = true; + window.setTimeout(() => setPhase("completed"), 0); + return { + ...prev, + cases, + finishedAt: new Date().toISOString(), + stopped: false, + }; + } + + return { ...prev, cases }; + }); + } + + // 立刻拉起第一批,再按节拍结算/推进 + tick(false); + const timer = window.setInterval(() => tick(true), TICK_MS); + return () => window.clearInterval(timer); + }, [phase, concurrency, run?.startedAt]); + + const allCaseIds = useMemo( + () => + Object.values(casesBySuite) + .flat() + .map((item) => item.id), + [casesBySuite], + ); + + const selectedCases = useMemo(() => { + const ordered: TestCase[] = []; + for (const suite of suites) { + for (const item of casesBySuite[suite.id] ?? []) { + if (selectedCaseIds.has(item.id)) ordered.push(item); + } + } + return ordered; + }, [suites, casesBySuite, selectedCaseIds]); + + const selectedCount = selectedCaseIds.size; + const allSelected = + allCaseIds.length > 0 && allCaseIds.every((id) => selectedCaseIds.has(id)); + const someSelected = selectedCount > 0 && !allSelected; + + const timeoutValue = Number(timeoutSecs); + const timeoutValid = + Number.isFinite(timeoutValue) && timeoutValue > 0 && timeoutValue <= 600; + + const canStart = + Boolean(assistantId) && + selectedCount > 0 && + timeoutValid && + !loadingAssistants && + phase === "config"; + + function toggleSelectAll() { + if (allSelected) { + setSelectedCaseIds(new Set()); + return; + } + setSelectedCaseIds(new Set(allCaseIds)); + } + + function toggleSuite(suiteId: string) { + const suiteCaseIds = (casesBySuite[suiteId] ?? []).map((item) => item.id); + if (suiteCaseIds.length === 0) return; + + const allInSuiteSelected = suiteCaseIds.every((id) => + selectedCaseIds.has(id), + ); + setSelectedCaseIds((prev) => { + const next = new Set(prev); + if (allInSuiteSelected) { + for (const id of suiteCaseIds) next.delete(id); + } else { + for (const id of suiteCaseIds) next.add(id); + } + return next; + }); + } + + function toggleCase(caseId: string) { + setSelectedCaseIds((prev) => { + const next = new Set(prev); + if (next.has(caseId)) next.delete(caseId); + else next.add(caseId); + return next; + }); + } + + function toggleExpanded(suiteId: string) { + setExpandedSuiteIds((prev) => { + const next = new Set(prev); + if (next.has(suiteId)) next.delete(suiteId); + else next.add(suiteId); + return next; + }); + } + + function startRun(cases: TestCase[]) { + const assistantName = + assistants.find((item) => item.id === assistantId)?.name ?? "助手"; + const snapshot = createBatchRunSnapshot({ + title: buildRunTitle(cases, suites), + assistantName, + cases, + }); + setRun(snapshot); + setPhase("running"); + } + + function handleStart() { + if (!canStart) return; + startRun(selectedCases); + } + + function handleStop() { + setRun((prev) => { + if (!prev) return prev; + return { + ...prev, + stopped: true, + finishedAt: new Date().toISOString(), + cases: prev.cases.map((item) => + item.status === "waiting" || item.status === "running" + ? { ...item, status: "skipped" } + : item, + ), + }; + }); + setPhase("completed"); + } + + function handleRerun() { + if (selectedCases.length === 0) { + setPhase("config"); + setRun(null); + return; + } + startRun(selectedCases); + } + + function handleBackToConfig() { + setPhase("config"); + setRun(null); + } + + if (phase === "running" && run) { + return ( + + + 停止运行 + + } + > + + + ); + } + + if (phase === "completed" && run) { + return ( + + + +
+ } + > + + + ); + } + return ( - + description="配置一次批量运行:选择被测助手、测试范围与基础运行参数。" + className="max-w-[880px]" + topbarAction={ + + } + > + + scrollToSection(sectionId as BatchSectionId) + } + /> + +
+
{ + sectionRefs.current.target = element; + }} + className="scroll-mt-3" + > + } + title="运行目标" + description="选择本次批量测试要对齐的助手配置" + > + + +
+ +
{ + sectionRefs.current.scope = element; + }} + className="scroll-mt-3" + > + } + title="选择测试范围" + description="可全选,或展开测试集勾选单个用例" + action={ + + 已选 {selectedCount} 个用例 + + } + > +
+ + + {suites.length === 0 ? ( +

+ 暂无测试集,请先在「测试用例」中创建。 +

+ ) : ( +
    + {suites.map((suite) => { + const suiteCases = casesBySuite[suite.id] ?? []; + const suiteCaseIds = suiteCases.map((item) => item.id); + const selectedInSuite = suiteCaseIds.filter((id) => + selectedCaseIds.has(id), + ).length; + const suiteAllSelected = + suiteCaseIds.length > 0 && + selectedInSuite === suiteCaseIds.length; + const suiteSomeSelected = + selectedInSuite > 0 && !suiteAllSelected; + const expanded = expandedSuiteIds.has(suite.id); + const total = suiteCaseStats(suite.id).total; + + return ( +
  • +
    + + + { + if (!element) return; + element.indeterminate = suiteSomeSelected; + }} + onChange={() => toggleSuite(suite.id)} + aria-label={`选择测试集 ${suite.name}`} + /> + + +
    + + {expanded && ( +
      + {suiteCases.length === 0 ? ( +
    • + 该测试集暂无用例 +
    • + ) : ( + suiteCases.map((item) => ( +
    • + +
    • + )) + )} +
    + )} +
  • + ); + })} +
+ )} +
+
+
+ +
{ + sectionRefs.current.settings = element; + }} + className="scroll-mt-3" + > + } + title="运行设置" + description="MVP 仅保留并发、超时与失败策略" + > +
+ + + + + +
+
+
+
+ ); } diff --git a/frontend/src/components/pages/TestCasesPage.tsx b/frontend/src/components/pages/TestCasesPage.tsx index 9fea23c..56d4d8a 100644 --- a/frontend/src/components/pages/TestCasesPage.tsx +++ b/frontend/src/components/pages/TestCasesPage.tsx @@ -8,6 +8,7 @@ import { ChevronLeft, + Copy, Loader2, MoreHorizontal, Pencil, @@ -56,6 +57,8 @@ import { Textarea } from "@/components/ui/textarea"; import { createTestCase, createTestSuite, + duplicateTestCase, + duplicateTestSuite, formatSuiteResult, getTestSuite, listTestCases, @@ -126,6 +129,12 @@ function SuiteListView() { router.push(`/test/cases/${suite.id}`); } + function duplicateSuite(suite: TestSuite) { + const copied = duplicateTestSuite(suite.id); + if (!copied) return; + setSuites(listTestSuites()); + } + function removeSuite(suite: TestSuite) { if ( !window.confirm( @@ -265,6 +274,13 @@ function SuiteListView() { align="end" className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1" > + duplicateSuite(suite)} + > + + 复制 + setStatusMessage(""), 2000); } + function handleDuplicateCase(item: TestCase) { + if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) { + return; + } + const copied = duplicateTestCase(item.id); + if (!copied) return; + reload(copied.id); + setStatusMessage(""); + } + function handleDeleteCase(item: TestCase) { if (!window.confirm(`确定删除测试用例“${item.name}”吗?`)) return; removeTestCase(item.id); @@ -696,6 +722,7 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) { onSearchChange={setSearch} onSelectCase={selectCase} onCreateCase={handleCreateCase} + onDuplicateCase={handleDuplicateCase} onDeleteCase={handleDeleteCase} onEnterSelectionMode={handleEnterSelectionMode} onExitSelectionMode={handleExitSelectionMode} diff --git a/frontend/src/components/test-cases/suite-case-list.tsx b/frontend/src/components/test-cases/suite-case-list.tsx index c56e47a..149e952 100644 --- a/frontend/src/components/test-cases/suite-case-list.tsx +++ b/frontend/src/components/test-cases/suite-case-list.tsx @@ -23,7 +23,15 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { CheckSquare, GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"; +import { + CheckSquare, + Copy, + GripVertical, + MoreHorizontal, + Pencil, + Plus, + Trash2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -49,6 +57,7 @@ export function SuiteCaseList({ onSearchChange, onSelectCase, onCreateCase, + onDuplicateCase, onDeleteCase, onEnterSelectionMode, onExitSelectionMode, @@ -66,6 +75,7 @@ export function SuiteCaseList({ onSearchChange: (value: string) => void; onSelectCase: (item: TestCase) => void; onCreateCase: () => void; + onDuplicateCase: (item: TestCase) => void; onDeleteCase: (item: TestCase) => void; onEnterSelectionMode: () => void; onExitSelectionMode: () => void; @@ -109,71 +119,73 @@ export function SuiteCaseList({ return (