From 433ff0b2555ecb57bbc1ac4b32db6f482ce0e3d7 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sat, 8 Aug 2026 19:08:48 +0800 Subject: [PATCH] Update batch test page --- .../assistant-editor/editor-controls.tsx | 12 +- .../batch-test/batch-case-detail-drawer.tsx | 211 +++++++++++++ .../batch-test/batch-run-completed.tsx | 283 ++++++++---------- .../batch-test/batch-run-running.tsx | 241 +++++++-------- .../src/components/pages/BatchTestPage.tsx | 59 ++-- .../src/components/pages/TestCasesPage.tsx | 218 ++++++++------ .../test-cases/next-reply-editor.tsx | 2 + .../components/test-cases/suite-case-list.tsx | 3 - frontend/src/data/test-suites.ts | 16 +- 9 files changed, 607 insertions(+), 438 deletions(-) create mode 100644 frontend/src/components/batch-test/batch-case-detail-drawer.tsx diff --git a/frontend/src/components/assistant-editor/editor-controls.tsx b/frontend/src/components/assistant-editor/editor-controls.tsx index 563e2ce..a4ee4ad 100644 --- a/frontend/src/components/assistant-editor/editor-controls.tsx +++ b/frontend/src/components/assistant-editor/editor-controls.tsx @@ -70,7 +70,13 @@ export function EditorBackButton({ ); } -export function AssistantIdentity({ assistantId }: { assistantId: string | null }) { +export function AssistantIdentity({ + assistantId, + entityLabel = "助手", +}: { + assistantId: string | null; + entityLabel?: string; +}) { const [copied, setCopied] = useState(false); async function copyId() { @@ -90,7 +96,9 @@ export function AssistantIdentity({ assistantId }: { assistantId: string | null type="button" onClick={() => void copyId()} className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground" - aria-label={copied ? "助手 ID 已复制" : "复制助手 ID"} + aria-label={ + copied ? `${entityLabel} ID 已复制` : `复制${entityLabel} ID` + } title={copied ? "已复制" : "复制 ID"} > {copied ? : } diff --git a/frontend/src/components/batch-test/batch-case-detail-drawer.tsx b/frontend/src/components/batch-test/batch-case-detail-drawer.tsx new file mode 100644 index 0000000..b78efb9 --- /dev/null +++ b/frontend/src/components/batch-test/batch-case-detail-drawer.tsx @@ -0,0 +1,211 @@ +"use client"; + +/** + * 批量测试用例详情抽屉。 + * 当前展示前端 mock 数据,后续可直接替换为真实执行事件与校验结果。 + */ + +import { + CheckCircle2, + Circle, + Loader2, + MessageSquareText, + Target, + TriangleAlert, +} from "lucide-react"; + +import { BatchCaseStatusBadge } from "@/components/batch-test/batch-case-status"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import type { BatchRunCase } from "@/data/batch-run"; +import { cn } from "@/lib/utils"; + +type BatchCaseDetailDrawerProps = { + item: BatchRunCase | null; + assistantName: string; + onClose: () => void; +}; + +export function BatchCaseDetailDrawer({ + item, + assistantName, + onClose, +}: BatchCaseDetailDrawerProps) { + return ( + { + if (!open) onClose(); + }} + > + + {item && ( + <> + +

+ 用例详情 +

+ + {item.name} + + + 被测助手:{assistantName} · 当前为前端 Mock 结果 + +
+ +
+
+
+
+

当前状态

+

+ {statusDescription(item)} +

+
+ +
+ + } + title="预期结果" + value={item.expected} + /> + + {(item.status === "waiting" || item.status === "running") && ( + + )} + + {(item.status === "pass" || item.status === "fail") && ( + } + title="实际回复" + value={item.actual} + /> + )} + + {item.status === "fail" && item.failReason && ( + } + title="失败原因(LLM 判断)" + value={item.failReason} + tone="destructive" + /> + )} + + {item.status === "skipped" && ( +
+ 该用例未执行,因此没有实际回复与判断结果。 +
+ )} +
+
+ + )} +
+
+ ); +} + +function statusDescription(item: BatchRunCase): string { + const descriptions = { + waiting: "已加入执行队列,正在等待可用并发。", + running: "正在请求助手响应,结果尚未生成。", + pass: "执行与结果判断均已完成。", + fail: "执行完成,但实际回复未达到预期。", + skipped: "本轮运行已结束,该用例未执行。", + } as const; + return descriptions[item.status]; +} + +function DetailSection({ + icon, + title, + value, + tone = "default", +}: { + icon: React.ReactNode; + title: string; + value: string; + tone?: "default" | "destructive"; +}) { + return ( +
+
+ {icon} +

{title}

+
+

+ {value} +

+
+ ); +} + +function ExecutionTimeline({ status }: { status: "waiting" | "running" }) { + const isRunning = status === "running"; + const steps = [ + { + label: "载入测试上下文", + detail: isRunning ? "已完成" : "等待开始", + state: isRunning ? "done" : "pending", + }, + { + label: "请求助手响应", + detail: isRunning ? "生成中(Mock)" : "尚未开始", + state: isRunning ? "active" : "pending", + }, + { + label: "执行结果判断", + detail: "尚未开始", + state: "pending", + }, + ] as const; + + return ( +
+
+ +

执行进度

+
+
    + {steps.map((step) => ( +
  1. + {step.state === "done" ? ( + + ) : step.state === "active" ? ( + + ) : ( + + )} + + {step.label} + + {step.detail} +
  2. + ))} +
+
+ ); +} diff --git a/frontend/src/components/batch-test/batch-run-completed.tsx b/frontend/src/components/batch-test/batch-run-completed.tsx index 6a9860f..d1cb3bd 100644 --- a/frontend/src/components/batch-test/batch-run-completed.tsx +++ b/frontend/src/components/batch-test/batch-run-completed.tsx @@ -4,9 +4,10 @@ * 批量测试 — 已完成结果视图(MVP mock)。 */ -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import { useState } from "react"; +import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer"; import { BatchCaseStatusBadge, BatchPhasePill, @@ -19,10 +20,9 @@ import { 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 [selectedCaseId, setSelectedCaseId] = useState(null); + const selectedCase = + run.cases.find((item) => item.id === selectedCaseId) ?? null; const counts = countByStatus(run.cases); const judged = counts.pass + counts.fail; @@ -30,168 +30,130 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) { 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}% -
+ <> +
+ {/* 结果总览 */} +
+
+

{run.title}

+
+ {run.finishedAt && ( +

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

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

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

-
+ {/* 用例列表 */} +
+
+

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

+
-
- - - - - - - - - - - {run.cases.map((item, index) => { - const expanded = expandedIds.has(item.id); - const canExpand = - item.status === "fail" || item.status === "pass"; - - return ( - - + + ); + })} + +
#测试用例状态详情
- +
+
+
- - + + setSelectedCaseId(null)} + /> + ); } @@ -284,14 +246,3 @@ function LegendRow({ ); } - -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 index 3d073bb..c34134a 100644 --- a/frontend/src/components/batch-test/batch-run-running.tsx +++ b/frontend/src/components/batch-test/batch-run-running.tsx @@ -4,9 +4,10 @@ * 批量测试 — 运行中视图(MVP mock)。 */ -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import { useState } from "react"; +import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer"; import { BatchCaseStatusBadge, BatchPhasePill, @@ -18,142 +19,121 @@ import { import { cn } from "@/lib/utils"; export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) { - const [expandedIds, setExpandedIds] = useState>(new Set()); + const [selectedCaseId, setSelectedCaseId] = useState(null); + const selectedCase = + run.cases.find((item) => item.id === selectedCaseId) ?? null; 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} -

- -
- -
-
-
+ <> +
+ {/* 进度总览 */} +
+
+
+
+

+ {run.title} +

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

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

-
- - - - -
-
-
- - {/* 用例列表 */} -
-
-

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

-
- -
- - - - - - - - - - {run.cases.map((item, index) => { - const expanded = expandedIds.has(item.id); - const canExpand = - item.status === "fail" || item.status === "pass"; - - return ( - - + + ); + })} + +
#测试用例状态 -
- +
+
+
+
+ + setSelectedCaseId(null)} + /> + ); } @@ -182,14 +162,3 @@ function Stat({ ); } - -function DetailBlock({ label, value }: { label: string; value: string }) { - return ( - - {label} - - {value} - - - ); -} diff --git a/frontend/src/components/pages/BatchTestPage.tsx b/frontend/src/components/pages/BatchTestPage.tsx index 44cfc49..0d9ae0b 100644 --- a/frontend/src/components/pages/BatchTestPage.tsx +++ b/frontend/src/components/pages/BatchTestPage.tsx @@ -85,6 +85,20 @@ function buildRunTitle( return "批量测试"; } +function loadTestScope() { + const suites = listTestSuites(); + const casesBySuite: Record = {}; + const caseIds: string[] = []; + + for (const suite of suites) { + const items = listTestCases(suite.id); + casesBySuite[suite.id] = items; + for (const item of items) caseIds.push(item.id); + } + + return { suites, casesBySuite, caseIds }; +} + export function BatchTestPage() { const [phase, setPhase] = useState("config"); const [run, setRun] = useState(null); @@ -93,15 +107,13 @@ export function BatchTestPage() { const [loadingAssistants, setLoadingAssistants] = useState(true); const [assistantId, setAssistantId] = useState(""); - const [suites, setSuites] = useState([]); - const [casesBySuite, setCasesBySuite] = useState>( - {}, - ); + const [{ suites, casesBySuite, caseIds: initialCaseIds }] = + useState(loadTestScope); const [expandedSuiteIds, setExpandedSuiteIds] = useState>( new Set(), ); const [selectedCaseIds, setSelectedCaseIds] = useState>( - new Set(), + () => new Set(initialCaseIds), ); const [concurrency, setConcurrency] = useState("3"); @@ -116,8 +128,6 @@ export function BatchTestPage() { settings: null, }); const selectedAnchorRef = useRef(null); - const stopOnFailRef = useRef(failStrategy === "stop_on_fail"); - stopOnFailRef.current = failStrategy === "stop_on_fail"; useEffect(() => { if (phase !== "config") return; @@ -207,19 +217,6 @@ export function BatchTestPage() { } 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(); @@ -235,16 +232,13 @@ export function BatchTestPage() { // mock 执行引擎:按并发推进用例状态 useEffect(() => { - if (phase !== "running" || !run) return; + if (phase !== "running" || !run?.startedAt) return; const limit = Number(concurrency) || 3; - let finished = false; function tick(settleRunning: boolean) { - if (finished) return; - setRun((prev) => { - if (!prev || finished) return prev; + if (!prev || prev.finishedAt) return prev; let cases = prev.cases.map((item) => ({ ...item })); @@ -259,14 +253,12 @@ export function BatchTestPage() { } // 失败即停:剩余全部标记未执行 - if (sawFail && stopOnFailRef.current) { + if (sawFail && failStrategy === "stop_on_fail") { 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, @@ -288,8 +280,6 @@ export function BatchTestPage() { (item) => item.status === "waiting" || item.status === "running", ); if (!pending) { - finished = true; - window.setTimeout(() => setPhase("completed"), 0); return { ...prev, cases, @@ -306,7 +296,14 @@ export function BatchTestPage() { tick(false); const timer = window.setInterval(() => tick(true), TICK_MS); return () => window.clearInterval(timer); - }, [phase, concurrency, run?.startedAt]); + }, [phase, concurrency, failStrategy, run?.startedAt]); + + // 执行引擎只负责写入完成快照;页面阶段在快照稳定后统一切换。 + useEffect(() => { + if (phase !== "running" || !run?.finishedAt) return; + const timer = window.setTimeout(() => setPhase("completed"), 0); + return () => window.clearTimeout(timer); + }, [phase, run?.finishedAt]); const allCaseIds = useMemo( () => diff --git a/frontend/src/components/pages/TestCasesPage.tsx b/frontend/src/components/pages/TestCasesPage.tsx index 56d4d8a..8714b26 100644 --- a/frontend/src/components/pages/TestCasesPage.tsx +++ b/frontend/src/components/pages/TestCasesPage.tsx @@ -16,6 +16,7 @@ import { Rocket, Save, Trash2, + X, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; @@ -53,13 +54,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; import { createTestCase, createTestSuite, duplicateTestCase, duplicateTestSuite, - formatSuiteResult, getTestSuite, listTestCases, listTestSuites, @@ -91,27 +90,23 @@ export type TestCasesPageProps = export function TestCasesPage(props: TestCasesPageProps) { if (props.mode === "list") return ; if (props.mode === "create") return ; - return ; + return ; } // ─── Suite 列表 ────────────────────────────────────────────────────────────── function SuiteListView() { const router = useRouter(); - const [suites, setSuites] = useState([]); + const [suites, setSuites] = useState(() => listTestSuites()); const [search, setSearch] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [deletingId, setDeletingId] = useState(null); - useEffect(() => { - setSuites(listTestSuites()); - }, []); - const filtered = useMemo(() => { const keyword = search.trim().toLowerCase(); return suites.filter((suite) => { if (!keyword) return true; - return [suite.name, suite.description, suite.assistantName, suite.id] + return [suite.name, suite.assistantName, suite.id] .join(" ") .toLowerCase() .includes(keyword); @@ -209,8 +204,8 @@ function SuiteListView() {
{suite.name}
-
- {suite.description || suite.id} +
+ {suite.id}
), @@ -229,13 +224,6 @@ function SuiteListView() { cellClassName: "tabular-nums text-muted-foreground", cell: (suite) => suiteCaseStats(suite.id).total, }, - { - key: "lastResult", - header: "最近结果", - width: "md:w-[112px]", - cellClassName: "tabular-nums text-muted-foreground", - cell: (suite) => formatSuiteResult(suite.id), - }, { key: "actions", header: "操作", @@ -316,7 +304,6 @@ function SuiteCreateView() { const [assistants, setAssistants] = useState([]); const [loadingAssistants, setLoadingAssistants] = useState(true); const [name, setName] = useState(""); - const [description, setDescription] = useState(""); const [assistantName, setAssistantName] = useState(""); const [creating, setCreating] = useState(false); @@ -339,7 +326,7 @@ function SuiteCreateView() { setCreating(true); const saved = createTestSuite({ name, - description, + description: "", assistantName, }); router.push(`/test/cases/${saved.id}`); @@ -377,49 +364,36 @@ function SuiteCreateView() { -
-