Remove .DS_Store from version control and update .gitignore. Enhance BatchCaseDetailDrawer and BatchRunCompletedView components to support error handling and improved UI for displaying test case statuses. Add functionality for editing and rerunning test cases. Update TestCasesPage to handle initial case selection. Refactor related components for better maintainability.

This commit is contained in:
Xin Wang
2026-08-10 09:33:29 +08:00
parent ba6c7c6be3
commit 5b8b9fd097
14 changed files with 2448 additions and 1243 deletions
@@ -5,31 +5,82 @@
*/
import { ChevronRight } from "lucide-react";
import { useState } from "react";
import { useRef, useState } from "react";
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
import {
BatchCaseStatusBadge,
BatchPhasePill,
getBatchCaseStatusLabel,
} from "@/components/batch-test/batch-case-status";
import {
BATCH_ERROR_STRATEGY_LABEL,
BATCH_FAILURE_STRATEGY_LABEL,
countByStatus,
formatRunDuration,
formatRunTime,
type BatchRunSnapshot,
} from "@/data/batch-run";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
type ResultFilter = "all" | "fail" | "error";
function stopReasonLabel(run: BatchRunSnapshot): string {
if (run.stopReason === "manual") return "已手动停止";
if (run.stopReason === "assertion_failure") return "因断言失败停止";
if (run.stopReason === "execution_error") return "因执行错误停止";
return "";
}
export function BatchRunCompletedView({
run,
onEditCase,
onRerunCase,
}: {
run: BatchRunSnapshot;
onEditCase: (caseId: string) => void;
onRerunCase: (caseId: string) => void;
}) {
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [filter, setFilter] = useState<ResultFilter>("all");
const detailRowRefs = useRef(new Map<string, HTMLTableRowElement>());
const selectedCase =
run.cases.find((item) => item.id === selectedCaseId) ?? null;
const detailOpen = Boolean(selectedCase);
const counts = countByStatus(run.cases);
const judged = counts.pass + counts.fail;
const judged = counts.pass + counts.fail + counts.error;
const total = run.cases.length;
const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100);
const failRate = judged === 0 ? 0 : 100 - passRate;
const failRate =
judged === 0 ? 0 : Math.round((counts.fail / judged) * 100);
const errorRate =
judged === 0 ? 0 : Math.round((counts.error / judged) * 100);
const visibleCases =
filter === "fail"
? run.cases.filter((item) => item.status === "fail")
: filter === "error"
? run.cases.filter((item) => item.status === "error")
: run.cases;
function closeDetail() {
const currentId = selectedCaseId;
setSelectedCaseId(null);
if (!currentId) return;
window.requestAnimationFrame(() => {
detailRowRefs.current.get(currentId)?.focus();
});
}
function handleCaseRowKeyDown(
event: React.KeyboardEvent<HTMLTableRowElement>,
caseId: string,
) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setSelectedCaseId(caseId);
}
return (
<div className="flex h-full min-h-0 w-full overflow-hidden bg-background">
@@ -51,10 +102,41 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
{run.finishedAt && (
<p className="mt-1.5 text-xs text-muted-soft">
完成时间:{formatRunTime(run.finishedAt)}
{run.stopped ? " · 已手动停止" : ""}
{run.stopReason ? ` · ${stopReasonLabel(run)}` : ""}
</p>
)}
<dl className="mt-4 grid gap-3 rounded-xl border border-hairline bg-canvas-soft/60 px-4 py-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-xs text-muted-foreground">被测助手</dt>
<dd className="mt-1 truncate font-medium text-foreground">
{run.assistantName}
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">测试范围</dt>
<dd className="mt-1 font-medium text-foreground">
{run.config.suiteCount} 个测试集 · {total} 个用例
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">运行参数</dt>
<dd className="mt-1 font-medium text-foreground">
{run.config.concurrency} 并发 · {run.config.timeoutSecs} 秒
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">耗时 / 处理策略</dt>
<dd className="mt-1 text-xs font-medium leading-5 text-foreground">
{formatRunDuration(run.startedAt, run.finishedAt)} · 断言失败:
{BATCH_FAILURE_STRATEGY_LABEL[run.config.failureStrategy]}
<br />
执行错误:重试 {run.config.errorRetryCount} 次,
{BATCH_ERROR_STRATEGY_LABEL[run.config.errorStrategy]}
</dd>
</div>
</dl>
<div className="mt-5 flex flex-col items-center gap-6 sm:flex-row sm:justify-between sm:gap-8">
<div className="text-center sm:text-left">
<div className="font-display text-3xl text-ink">
@@ -65,7 +147,11 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
</div>
</div>
<PassRateDonut pass={counts.pass} fail={counts.fail} />
<PassRateDonut
pass={counts.pass}
fail={counts.fail}
error={counts.error}
/>
<div className="space-y-2 text-sm">
<LegendRow
@@ -76,10 +162,16 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
/>
<LegendRow
tone="destructive"
label="失败"
label="断言失败"
count={counts.fail}
percent={failRate}
/>
<LegendRow
tone="warning"
label="执行错误"
count={counts.error}
percent={errorRate}
/>
{counts.skipped > 0 && (
<LegendRow
tone="muted"
@@ -98,10 +190,44 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
{/* 用例列表 */}
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
<div className="border-b border-hairline px-5 py-3.5">
<h3 className="text-sm font-medium text-foreground">
用例列表(共 {total} 个)
</h3>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline px-5 py-3.5">
<h3 className="text-sm font-medium text-foreground">用例列表</h3>
<div
className="flex items-center gap-2"
role="group"
aria-label="结果筛选"
>
<Button
type="button"
size="sm"
variant={filter === "all" ? "default" : "outline"}
className="rounded-full"
aria-pressed={filter === "all"}
onClick={() => setFilter("all")}
>
全部 {total}
</Button>
<Button
type="button"
size="sm"
variant={filter === "fail" ? "default" : "outline"}
className="rounded-full"
aria-pressed={filter === "fail"}
onClick={() => setFilter("fail")}
>
断言失败 {counts.fail}
</Button>
<Button
type="button"
size="sm"
variant={filter === "error" ? "default" : "outline"}
className="rounded-full"
aria-pressed={filter === "error"}
onClick={() => setFilter("error")}
>
执行错误 {counts.error}
</Button>
</div>
</div>
<div className="overflow-x-auto">
@@ -115,42 +241,64 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
</tr>
</thead>
<tbody>
{run.cases.map((item, index) => {
{visibleCases.map((item) => {
const index = run.cases.findIndex(
(caseItem) => caseItem.id === item.id,
);
return (
<tr
key={item.id}
className="border-b border-hairline last:border-b-0"
ref={(element) => {
if (element) {
detailRowRefs.current.set(item.id, element);
} else {
detailRowRefs.current.delete(item.id);
}
}}
role="button"
tabIndex={0}
aria-label={`查看用例 ${item.name} 详情,状态:${getBatchCaseStatusLabel(item.status)}`}
aria-expanded={selectedCaseId === item.id}
aria-controls="batch-case-detail"
onClick={() => setSelectedCaseId(item.id)}
onKeyDown={(event) =>
handleCaseRowKeyDown(event, item.id)
}
className={cn(
"cursor-pointer border-b border-hairline transition-colors outline-none last:border-b-0 hover:bg-canvas-soft/70 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40",
selectedCaseId === item.id && "bg-canvas-soft",
)}
>
<td colSpan={4} className="p-0">
<button
type="button"
onClick={() => setSelectedCaseId(item.id)}
aria-expanded={selectedCaseId === item.id}
className={cn(
"flex w-full items-center gap-0 px-5 py-3 text-left transition-colors hover:bg-canvas-soft/70",
selectedCaseId === item.id && "bg-canvas-soft",
)}
>
<span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
{index + 1}
</span>
<span className="min-w-0 flex-1 px-3">
<span className="block font-medium text-foreground">
{item.name}
</span>
</span>
<span className="w-28 shrink-0 px-3">
<BatchCaseStatusBadge status={item.status} />
</span>
<span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground">
查看
<ChevronRight size={14} />
</span>
</button>
<td className="w-12 px-5 py-3 tabular-nums text-muted-foreground">
{index + 1}
</td>
<td className="px-3 py-3 font-medium text-foreground">
{item.name}
</td>
<td className="w-28 px-3 py-3">
<BatchCaseStatusBadge status={item.status} />
</td>
<td className="w-24 px-3 py-2">
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
查看
<ChevronRight size={14} />
</span>
</td>
</tr>
);
})}
{visibleCases.length === 0 && (
<tr>
<td
colSpan={4}
className="px-5 py-10 text-center text-sm text-muted-foreground"
>
{filter === "error"
? "本次运行没有执行错误。"
: "本次运行没有断言失败用例。"}
</td>
</tr>
)}
</tbody>
</table>
</div>
@@ -162,22 +310,33 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
<BatchCaseDetailDrawer
item={selectedCase}
assistantName={run.assistantName}
onClose={() => setSelectedCaseId(null)}
onClose={closeDetail}
onEdit={() => onEditCase(selectedCase.id)}
onRerun={() => onRerunCase(selectedCase.id)}
/>
)}
</div>
);
}
function PassRateDonut({ pass, fail }: { pass: number; fail: number }) {
const total = pass + fail;
function PassRateDonut({
pass,
fail,
error,
}: {
pass: number;
fail: number;
error: number;
}) {
const total = pass + fail + error;
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;
const failLength = circumference * (total === 0 ? 0 : fail / total);
const errorLength = circumference * (total === 0 ? 0 : error / total);
return (
<div
@@ -197,29 +356,47 @@ function PassRateDonut({ pass, fail }: { pass: number; fail: number }) {
/>
{total > 0 && (
<>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${passLength} ${circumference}`}
strokeLinecap="butt"
className="text-success"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${failLength} ${circumference}`}
strokeDashoffset={-passLength}
strokeLinecap="butt"
className="text-destructive"
/>
{pass > 0 && (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${passLength} ${circumference}`}
strokeLinecap="butt"
className="text-success"
/>
)}
{fail > 0 && (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${failLength} ${circumference}`}
strokeDashoffset={-passLength}
strokeLinecap="butt"
className="text-destructive"
/>
)}
{error > 0 && (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${errorLength} ${circumference}`}
strokeDashoffset={-(passLength + failLength)}
strokeLinecap="butt"
className="text-amber-500"
/>
)}
</>
)}
</svg>
@@ -238,7 +415,7 @@ function LegendRow({
count,
percent,
}: {
tone: "success" | "destructive" | "muted";
tone: "success" | "destructive" | "warning" | "muted";
label: string;
count: number;
percent: number;
@@ -246,6 +423,7 @@ function LegendRow({
const dotClass = {
success: "bg-success",
destructive: "bg-destructive",
warning: "bg-amber-500",
muted: "bg-muted-soft",
}[tone];