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
@@ -11,40 +11,71 @@ import {
Circle,
Loader2,
MessageSquareText,
Pencil,
RotateCcw,
Target,
TriangleAlert,
Wrench,
X,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { BatchCaseStatusBadge } from "@/components/batch-test/batch-case-status";
import type { BatchRunCase } from "@/data/batch-run";
import { Button } from "@/components/ui/button";
import {
firstEvaluationFailureReason,
type BatchEvaluationResult,
type BatchRunCase,
type BatchToolCallRecord,
type BatchTurnResult,
} from "@/data/batch-run";
import { cn } from "@/lib/utils";
type BatchCaseDetailDrawerProps = {
item: BatchRunCase;
assistantName: string;
onClose: () => void;
onEdit?: () => void;
onRerun?: () => void;
};
export function BatchCaseDetailDrawer({
item,
assistantName,
onClose,
onEdit,
onRerun,
}: BatchCaseDetailDrawerProps) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
const failureReason = firstEvaluationFailureReason(item);
useEffect(() => {
closeButtonRef.current?.focus();
}, [item.id]);
return (
<aside className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-l border-hairline bg-card">
<aside
id="batch-case-detail"
role="complementary"
aria-labelledby="batch-case-detail-title"
className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-l border-hairline bg-card"
>
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
<button
ref={closeButtonRef}
type="button"
aria-label="关闭用例详情"
title="关闭"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
className="flex size-10 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
onClick={onClose}
>
<X size={16} />
</button>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-medium text-foreground">
<h2
id="batch-case-detail-title"
className="truncate text-sm font-medium text-foreground"
>
{item.name}
</h2>
<p className="truncate text-xs text-muted-soft">
@@ -63,29 +94,86 @@ export function BatchCaseDetailDrawer({
</p>
</section>
<DetailSection
icon={<Target size={16} />}
title="预期结果"
value={item.expected}
/>
{(item.status === "waiting" || item.status === "running") && (
<ExecutionTimeline status={item.status} />
<>
<section className="rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5">
<p className="text-xs font-medium text-foreground">执行契约</p>
<p className="mt-1 text-xs leading-5 text-muted-soft">
{item.turns.length} 轮固定输入 ·{" "}
{item.turns.reduce(
(sum, turn) => sum + turn.evaluations.length,
0,
) + item.overallCriteria.length}{" "}
项评估标准 · 全部通过才算通过
</p>
</section>
<ExecutionTimeline status={item.status} />
</>
)}
{item.status === "error" && item.executionError && (
<section>
<div className="flex items-center gap-2 text-sm font-medium text-amber-600 dark:text-amber-400">
<TriangleAlert size={16} />
<h3>执行错误</h3>
</div>
<div className="mt-2.5 rounded-2xl border border-amber-500/25 bg-amber-500/[0.06] px-4 py-3.5">
<div className="flex flex-wrap items-center gap-2">
<code className="text-xs font-medium text-foreground">
{item.executionError.code}
</code>
<span className="rounded-full border border-amber-500/25 px-2 py-0.5 text-[10px] text-amber-600 dark:text-amber-400">
{item.executionError.stage}
</span>
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{item.executionError.message}
</p>
<p className="mt-2 text-xs text-muted-soft">
已尝试 {item.attemptCount} 次(包含首次执行),重试后仍未恢复。
</p>
</div>
</section>
)}
{(item.status === "pass" || item.status === "fail") && (
<DetailSection
icon={<MessageSquareText size={16} />}
title="实际回复"
value={item.actual}
/>
<>
<section>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<MessageSquareText size={16} />
<h3>逐轮执行结果</h3>
</div>
<div className="mt-3 space-y-3">
{item.turns.map((turn) => (
<TurnResultCard key={turn.id} turn={turn} />
))}
</div>
</section>
{item.overallCriteria.length > 0 && (
<section>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Target size={16} />
<h3>整体评估标准</h3>
</div>
<div className="mt-3 space-y-2">
{item.overallCriteria.map((evaluation) => (
<EvaluationCard
key={evaluation.id}
evaluation={evaluation}
/>
))}
</div>
</section>
)}
</>
)}
{item.status === "fail" && item.failReason && (
{item.status === "fail" && failureReason && (
<DetailSection
icon={<TriangleAlert size={16} />}
title="失败原因(LLM 判断)"
value={item.failReason}
title="失败原因"
value={failureReason}
tone="destructive"
/>
)}
@@ -97,19 +185,175 @@ export function BatchCaseDetailDrawer({
)}
</div>
</div>
{(onEdit || onRerun) && (
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-hairline bg-card px-5 py-3">
{onEdit && (
<Button
type="button"
variant="outline"
className="gap-2 rounded-full border-hairline-strong"
onClick={onEdit}
>
<Pencil size={14} />
编辑用例
</Button>
)}
{onRerun && (
<Button
type="button"
className="gap-2 rounded-full"
onClick={onRerun}
>
<RotateCcw size={14} />
仅重跑此项
</Button>
)}
</div>
)}
</aside>
);
}
function statusDescription(item: BatchRunCase): string {
const descriptions = {
waiting: "已加入执行队列,正在等待可用并发。",
running: "正在请求助手响应,结果尚未生成。",
pass: "执行与结果判断均已完成。",
fail: "执行完成,但实际回复未达到预期。",
skipped: "本轮运行已结束,该用例未执行。",
} as const;
return descriptions[item.status];
if (item.status === "waiting") {
return item.attemptCount > 0
? `第 ${item.attemptCount} 次执行发生错误,正在等待重试。`
: "已加入执行队列,正在等待可用并发。";
}
if (item.status === "running") {
return item.attemptCount > 1
? `正在进行第 ${item.attemptCount} 次尝试。`
: "正在请求助手响应,结果尚未生成。";
}
if (item.status === "pass") return "执行与结果判断均已完成。";
if (item.status === "fail") return "执行完成,但实际回复未达到预期。";
if (item.status === "error") return "执行未完成,已用尽配置的重试次数。";
return "本轮运行已结束,该用例未执行。";
}
function TurnResultCard({ turn }: { turn: BatchTurnResult }) {
return (
<article className="overflow-hidden rounded-2xl border border-hairline bg-canvas-soft/60">
<div className="border-b border-hairline px-4 py-3">
<p className="text-xs font-medium text-foreground">
第 {turn.index + 1} 轮
</p>
<div className="mt-2 grid gap-2 text-xs leading-5 sm:grid-cols-[72px_minmax(0,1fr)]">
<span className="text-muted-soft">用户输入</span>
<p className="whitespace-pre-wrap text-muted-foreground">
{turn.userInput}
</p>
<span className="text-muted-soft">Agent 回复</span>
<p className="whitespace-pre-wrap text-muted-foreground">
{turn.assistantReply}
</p>
</div>
</div>
{turn.toolCalls.length > 0 && (
<div className="space-y-2 border-b border-hairline px-4 py-3">
<p className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground">
<Wrench size={13} />
工具调用记录
</p>
{turn.toolCalls.map((toolCall) => (
<ToolCallCard key={toolCall.id} toolCall={toolCall} />
))}
</div>
)}
<div className="space-y-2 px-4 py-3">
{turn.evaluations.length === 0 ? (
<p className="text-xs text-muted-soft">本轮仅推动对话,不执行评估。</p>
) : (
turn.evaluations.map((evaluation) => (
<EvaluationCard key={evaluation.id} evaluation={evaluation} />
))
)}
</div>
</article>
);
}
function ToolCallCard({ toolCall }: { toolCall: BatchToolCallRecord }) {
return (
<div className="rounded-xl border border-violet-500/20 bg-background/70 p-2.5">
<div className="flex flex-wrap items-center gap-2">
<code className="text-xs text-foreground">{toolCall.functionName}</code>
<span className="rounded-full border border-hairline px-2 py-0.5 text-[10px] text-muted-soft">
{toolCall.outcome === "success" ? "成功" : "错误"} ·{" "}
{toolCall.durationMs} ms
</span>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<CodeResult label="参数" value={toolCall.argumentsJson} />
<CodeResult label="Mock 返回" value={toolCall.resultJson} />
</div>
</div>
);
}
function CodeResult({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<p className="mb-1 text-[10px] text-muted-soft">{label}</p>
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-canvas-soft p-2 font-mono text-[10px] leading-4 text-muted-foreground">
{value}
</pre>
</div>
);
}
function EvaluationCard({
evaluation,
}: {
evaluation: BatchEvaluationResult;
}) {
const passed = evaluation.status === "pass";
return (
<div
className={cn(
"rounded-xl border bg-background/70 p-3",
passed ? "border-hairline" : "border-destructive/30 bg-destructive/5",
)}
>
<div className="flex items-center gap-2">
{passed ? (
<CheckCircle2 size={14} className="shrink-0 text-success" />
) : (
<TriangleAlert size={14} className="shrink-0 text-destructive" />
)}
<p className="min-w-0 flex-1 text-xs font-medium text-foreground">
{evaluation.label}
</p>
<span
className={cn(
"text-[10px] font-medium",
passed ? "text-success" : "text-destructive",
)}
>
{passed ? "通过" : "失败"}
</span>
</div>
<dl className="mt-2 grid gap-x-2 gap-y-1 text-[11px] leading-4 sm:grid-cols-[52px_minmax(0,1fr)]">
<dt className="text-muted-soft">预期</dt>
<dd className="whitespace-pre-wrap text-muted-foreground">
{evaluation.expected}
</dd>
<dt className="text-muted-soft">实际</dt>
<dd className="whitespace-pre-wrap text-muted-foreground">
{evaluation.actual}
</dd>
{evaluation.reason && (
<>
<dt className="text-destructive">原因</dt>
<dd className="text-foreground">{evaluation.reason}</dd>
</>
)}
</dl>
</div>
);
}
function DetailSection({
@@ -1,6 +1,12 @@
"use client";
import { CheckCircle2, Circle, Loader2, XCircle } from "lucide-react";
import {
CheckCircle2,
Circle,
Loader2,
TriangleAlert,
XCircle,
} from "lucide-react";
import type { BatchCaseStatus } from "@/data/batch-run";
import { cn } from "@/lib/utils";
@@ -15,10 +21,15 @@ const STATUS_META: Record<
Icon: CheckCircle2,
},
fail: {
label: "失败",
label: "断言失败",
className: "text-destructive",
Icon: XCircle,
},
error: {
label: "执行错误",
className: "text-amber-600 dark:text-amber-400",
Icon: TriangleAlert,
},
running: {
label: "运行中",
className: "text-primary",
@@ -36,6 +47,10 @@ const STATUS_META: Record<
},
};
export function getBatchCaseStatusLabel(status: BatchCaseStatus): string {
return STATUS_META[status].label;
}
export function BatchCaseStatusBadge({
status,
className,
@@ -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];
@@ -5,12 +5,13 @@
*/
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 {
countByStatus,
@@ -20,14 +21,33 @@ import { cn } from "@/lib/utils";
export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
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 done = counts.pass + counts.fail + counts.skipped;
const done = counts.pass + counts.fail + counts.error + counts.skipped;
const total = run.cases.length;
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
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">
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
@@ -65,9 +85,10 @@ export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
</div>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 border-t border-hairline pt-4 sm:grid-cols-4 lg:border-l lg:border-t-0 lg:pl-8 lg:pt-0">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 border-t border-hairline pt-4 sm:grid-cols-5 lg:border-l lg:border-t-0 lg:pl-8 lg:pt-0">
<Stat label="通过" value={counts.pass} tone="success" />
<Stat label="失败" value={counts.fail} tone="destructive" />
<Stat label="断言失败" value={counts.fail} tone="destructive" />
<Stat label="执行错误" value={counts.error} tone="warning" />
<Stat label="运行中" value={counts.running} tone="primary" />
<Stat label="等待" value={counts.waiting} tone="muted" />
</div>
@@ -97,34 +118,41 @@ export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
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>
);
@@ -140,7 +168,7 @@ export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
<BatchCaseDetailDrawer
item={selectedCase}
assistantName={run.assistantName}
onClose={() => setSelectedCaseId(null)}
onClose={closeDetail}
/>
)}
</div>
@@ -154,11 +182,12 @@ function Stat({
}: {
label: string;
value: number;
tone: "success" | "destructive" | "primary" | "muted";
tone: "success" | "destructive" | "warning" | "primary" | "muted";
}) {
const toneClass = {
success: "text-success",
destructive: "text-destructive",
warning: "text-amber-600 dark:text-amber-400",
primary: "text-foreground",
muted: "text-muted-foreground",
}[tone];
@@ -19,7 +19,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
ASSERTION_TYPE_LABEL,
getTestCaseValidationMessage,
isTestCaseRunnable,
type TestCase,
type TestSuite,
} from "@/data/test-suites";
@@ -32,6 +33,19 @@ type TestScopeSelectorProps = {
onSelectionChange: (next: Set<string>) => void;
};
/** 父级勾选框靠左;子级仅缩进一个层级,避免为展开箭头预留空列。 */
const PARENT_SCOPE_ROW_GRID =
"grid-cols-[1.75rem_minmax(0,1fr)_auto]";
const CHILD_SCOPE_ROW_GRID =
"grid-cols-[1.75rem_1.75rem_minmax(0,1fr)_auto]";
function caseContractSummary(item: TestCase): string {
const evaluationCount =
item.turns.reduce((sum, turn) => sum + turn.behaviors.length, 0) +
item.overallCriteria.length;
return `${item.turns.length} 轮 · ${evaluationCount} 项`;
}
export function TestScopeSelector({
suites,
casesBySuite,
@@ -47,6 +61,10 @@ export function TestScopeSelector({
() => suites.flatMap((suite) => casesBySuite[suite.id] ?? []),
[suites, casesBySuite],
);
const runnableCases = useMemo(
() => allCases.filter(isTestCaseRunnable),
[allCases],
);
const normalizedQuery = query.trim().toLocaleLowerCase();
const visibleSuites = useMemo(() => {
@@ -73,19 +91,36 @@ export function TestScopeSelector({
}, [suites, casesBySuite, normalizedQuery]);
const selectedCount = selectedCaseIds.size;
const totalCount = allCases.length;
const totalCount = runnableCases.length;
const allSelected = totalCount > 0 && selectedCount === totalCount;
const selectionPercent =
totalCount === 0 ? 0 : Math.round((selectedCount / totalCount) * 100);
const visibleRunnableCases = visibleSuites.flatMap(({ cases }) =>
cases.filter(isTestCaseRunnable),
);
const visibleRunnableCount = visibleRunnableCases.length;
const allVisibleSelected =
visibleRunnableCount > 0 &&
visibleRunnableCases.every((item) => selectedCaseIds.has(item.id));
function toggleAll() {
onSelectionChange(
allSelected ? new Set() : new Set(allCases.map((item) => item.id)),
);
if (!normalizedQuery) {
onSelectionChange(
allSelected ? new Set() : new Set(runnableCases.map((item) => item.id)),
);
return;
}
const next = new Set(selectedCaseIds);
for (const item of visibleRunnableCases) {
if (allVisibleSelected) next.delete(item.id);
else next.add(item.id);
}
onSelectionChange(next);
}
function toggleSuite(suiteId: string) {
const suiteCases = casesBySuite[suiteId] ?? [];
function toggleSuite(suiteId: string, scopedCases?: TestCase[]) {
const suiteCases = (scopedCases ?? casesBySuite[suiteId] ?? []).filter(
isTestCaseRunnable,
);
const suiteIsSelected =
suiteCases.length > 0 &&
suiteCases.every((item) => selectedCaseIds.has(item.id));
@@ -99,6 +134,8 @@ export function TestScopeSelector({
}
function toggleCase(caseId: string) {
const item = allCases.find((caseItem) => caseItem.id === caseId);
if (!item || !isTestCaseRunnable(item)) return;
const next = new Set(selectedCaseIds);
if (next.has(caseId)) next.delete(caseId);
else next.add(caseId);
@@ -117,8 +154,8 @@ export function TestScopeSelector({
return (
<div className="overflow-hidden rounded-2xl border border-hairline-strong bg-card">
<div className="border-b border-hairline bg-canvas-soft/60 p-3.5">
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative min-w-[220px] flex-1">
<div className="space-y-3">
<div className="relative">
<Search
size={15}
className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted-soft"
@@ -133,42 +170,56 @@ export function TestScopeSelector({
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onSelectionChange(new Set())}
disabled={selectedCount === 0}
className="text-muted-foreground"
>
清空
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={toggleAll}
disabled={totalCount === 0}
className="border-hairline-strong bg-card"
>
{allSelected ? "取消全选" : "全选全部"}
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<span
className="text-xs tabular-nums text-muted-foreground"
aria-live="polite"
>
{normalizedQuery
? `找到 ${visibleRunnableCount} 个结果`
: `${totalCount} 个可运行用例`}
<span className="text-muted-soft"> · </span>
已选{" "}
<span className="font-medium text-foreground">
{selectedCount}
</span>
<span className="text-muted-soft"> / {totalCount}</span>
</span>
<div className="mt-3 flex items-center gap-3">
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width: `${selectionPercent}%` }}
/>
<div className="flex items-center gap-2">
{selectedCount > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onSelectionChange(new Set())}
className="text-muted-foreground"
>
清除已选
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={toggleAll}
disabled={
normalizedQuery
? visibleRunnableCount === 0
: totalCount === 0
}
className="border-hairline-strong bg-card"
>
{normalizedQuery
? allVisibleSelected
? `取消选择结果(${visibleRunnableCount})`
: `全选搜索结果(${visibleRunnableCount})`
: allSelected
? `取消全选(${totalCount})`
: `全选 ${totalCount} 个用例`}
</Button>
</div>
</div>
<span
className="shrink-0 text-xs tabular-nums text-muted-foreground"
aria-live="polite"
>
已选 <span className="font-medium text-foreground">{selectedCount}</span>
<span className="text-muted-soft"> / {totalCount}</span>
</span>
</div>
</div>
@@ -181,11 +232,16 @@ export function TestScopeSelector({
<ul className="divide-y divide-hairline">
{visibleSuites.map(({ suite, cases: visibleCases }) => {
const suiteCases = casesBySuite[suite.id] ?? [];
const selectedInSuite = suiteCases.filter((item) =>
const runnableSuiteCases = suiteCases.filter(isTestCaseRunnable);
const selectableSuiteCases = normalizedQuery
? visibleCases.filter(isTestCaseRunnable)
: runnableSuiteCases;
const selectedInSuite = selectableSuiteCases.filter((item) =>
selectedCaseIds.has(item.id),
).length;
const suiteAllSelected =
suiteCases.length > 0 && selectedInSuite === suiteCases.length;
selectableSuiteCases.length > 0 &&
selectedInSuite === selectableSuiteCases.length;
const suiteSomeSelected =
selectedInSuite > 0 && !suiteAllSelected;
const expanded = normalizedQuery
@@ -194,7 +250,27 @@ export function TestScopeSelector({
return (
<li key={suite.id}>
<div className="flex min-h-12 items-center gap-2 px-3 py-2">
<div
className={cn(
"grid min-h-12 items-center gap-x-2 px-3 py-2",
PARENT_SCOPE_ROW_GRID,
)}
>
<span className="flex size-7 items-center justify-center">
<TriStateCheckbox
checked={suiteAllSelected}
indeterminate={suiteSomeSelected}
label={`选择测试集 ${suite.name}`}
onChange={() =>
toggleSuite(
suite.id,
normalizedQuery ? visibleCases : undefined,
)
}
disabled={selectableSuiteCases.length === 0}
/>
</span>
<button
type="button"
onClick={() => toggleExpanded(suite.id)}
@@ -203,27 +279,7 @@ export function TestScopeSelector({
}
aria-expanded={expanded}
disabled={Boolean(normalizedQuery)}
className="flex size-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-default"
>
{expanded ? (
<ChevronDown size={15} />
) : (
<ChevronRight size={15} />
)}
</button>
<TriStateCheckbox
checked={suiteAllSelected}
indeterminate={suiteSomeSelected}
label={`选择测试集 ${suite.name}`}
onChange={() => toggleSuite(suite.id)}
/>
<button
type="button"
onClick={() => toggleExpanded(suite.id)}
disabled={Boolean(normalizedQuery)}
className="flex min-w-0 flex-1 items-center gap-2 text-left disabled:cursor-default"
className="flex min-w-0 items-center gap-2 text-left disabled:cursor-default"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-surface-strong text-muted-foreground">
<Folder size={14} />
@@ -231,43 +287,77 @@ export function TestScopeSelector({
<span className="truncate text-sm font-medium text-foreground">
{suite.name}
</span>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground">
{expanded ? (
<ChevronDown size={15} />
) : (
<ChevronRight size={15} />
)}
</span>
</button>
<Badge
variant={selectedInSuite > 0 ? "secondary" : "outline"}
className="h-6 min-w-14 tabular-nums"
>
{selectedInSuite} / {suiteCases.length}
{selectedInSuite} / {selectableSuiteCases.length}
</Badge>
</div>
{expanded && (
<ul className="border-t border-hairline bg-canvas-soft/45 py-1.5">
{visibleCases.length === 0 ? (
<li className="px-12 py-3 text-xs text-muted-soft">
该测试集暂无用例
<li
className={cn(
"grid gap-x-2 px-3 py-3",
CHILD_SCOPE_ROW_GRID,
)}
>
<span className="col-start-3 text-xs text-muted-soft">
该测试集暂无用例
</span>
</li>
) : (
visibleCases.map((item) => {
const selected = selectedCaseIds.has(item.id);
const validationMessage =
getTestCaseValidationMessage(item);
const runnable = validationMessage === null;
return (
<li key={item.id}>
<label
className={cn(
"flex min-h-10 cursor-pointer items-center gap-3 px-5 py-2 transition-colors hover:bg-surface-strong/60",
"grid min-h-10 cursor-pointer items-center gap-x-2 px-3 py-2 transition-colors hover:bg-surface-strong/60",
CHILD_SCOPE_ROW_GRID,
selected && "bg-surface-strong/45",
!runnable &&
"cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<TriStateCheckbox
checked={selected}
label={`选择用例 ${item.name}`}
onChange={() => toggleCase(item.id)}
/>
<span aria-hidden />
<span className="flex size-7 items-center justify-center">
<TriStateCheckbox
checked={selected}
label={`选择用例 ${item.name}`}
onChange={() => toggleCase(item.id)}
disabled={!runnable}
/>
</span>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{item.name}
</span>
<span className="shrink-0 text-xs text-muted-soft">
{ASSERTION_TYPE_LABEL[item.assertionType]}
<span
className={cn(
"shrink-0 text-xs",
runnable
? "text-muted-foreground"
: "text-destructive",
)}
title={validationMessage ?? undefined}
>
{runnable
? caseContractSummary(item)
: "需完善"}
</span>
</label>
</li>
@@ -291,11 +381,13 @@ function TriStateCheckbox({
indeterminate = false,
label,
onChange,
disabled = false,
}: {
checked: boolean;
indeterminate?: boolean;
label: string;
onChange: () => void;
disabled?: boolean;
}) {
return (
<span className="relative flex size-4 shrink-0">
@@ -307,7 +399,8 @@ function TriStateCheckbox({
}}
onChange={onChange}
aria-label={label}
className="peer absolute inset-0 z-10 cursor-pointer opacity-0"
disabled={disabled}
className="peer absolute inset-0 z-10 cursor-pointer opacity-0 disabled:cursor-not-allowed"
/>
<span
className={cn(
+323 -99
View File
@@ -14,6 +14,7 @@ import {
Square,
Target,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { BatchRunCompletedView } from "@/components/batch-test/batch-run-completed";
@@ -32,7 +33,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import {
createBatchRunSnapshot,
BATCH_ERROR_STRATEGY_LABEL,
BATCH_FAILURE_STRATEGY_LABEL,
createMockBatchRun,
hasEvaluationFailure,
type BatchErrorStrategy,
type BatchFailureStrategy,
type BatchMockExecutionPlan,
type BatchRunPhase,
type BatchRunSnapshot,
} from "@/data/batch-run";
@@ -47,11 +54,25 @@ import { assistantsApi, type Assistant } from "@/lib/api";
const CONCURRENCY_OPTIONS = ["1", "2", "3", "5", "10"] as const;
const FAIL_STRATEGY_OPTIONS = [
{ value: "continue", label: "继续执行全部用例" },
{ value: "stop_on_fail", label: "遇失败立即停止" },
{ value: "continue", label: BATCH_FAILURE_STRATEGY_LABEL.continue },
{
value: "stop_on_fail",
label: BATCH_FAILURE_STRATEGY_LABEL.stop_on_fail,
},
] as const;
type FailStrategy = (typeof FAIL_STRATEGY_OPTIONS)[number]["value"];
const ERROR_RETRY_OPTIONS = ["0", "1", "2", "3"] as const;
const ERROR_STRATEGY_OPTIONS = [
{ value: "continue", label: BATCH_ERROR_STRATEGY_LABEL.continue },
{
value: "stop_on_error",
label: BATCH_ERROR_STRATEGY_LABEL.stop_on_error,
},
] as const satisfies ReadonlyArray<{
value: BatchErrorStrategy;
label: string;
}>;
type BatchSectionId = "target" | "scope" | "settings";
@@ -86,34 +107,37 @@ function buildRunTitle(
function loadTestScope() {
const suites = listTestSuites();
const casesBySuite: Record<string, TestCase[]> = {};
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 };
return { suites, casesBySuite };
}
export function BatchTestPage() {
const router = useRouter();
const [phase, setPhase] = useState<BatchRunPhase>("config");
const [run, setRun] = useState<BatchRunSnapshot | null>(null);
const mockExecutionPlanRef = useRef<BatchMockExecutionPlan>({});
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [loadingAssistants, setLoadingAssistants] = useState(true);
const [assistantId, setAssistantId] = useState("");
const [{ suites, casesBySuite, caseIds: initialCaseIds }] =
useState(loadTestScope);
const [{ suites, casesBySuite }] = useState(loadTestScope);
const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>(
() => new Set(initialCaseIds),
() => new Set(),
);
const [concurrency, setConcurrency] = useState<string>("3");
const [timeoutSecs, setTimeoutSecs] = useState("30");
const [failStrategy, setFailStrategy] = useState<FailStrategy>("continue");
const [failStrategy, setFailStrategy] =
useState<BatchFailureStrategy>("continue");
const [errorRetryCount, setErrorRetryCount] = useState("1");
const [errorStrategy, setErrorStrategy] =
useState<BatchErrorStrategy>("continue");
const [activeSection, setActiveSection] =
useState<BatchSectionId>("target");
@@ -229,8 +253,6 @@ export function BatchTestPage() {
useEffect(() => {
if (phase !== "running" || !run?.startedAt) return;
const limit = Number(concurrency) || 3;
function tick(settleRunning: boolean) {
setRun((prev) => {
if (!prev || prev.finishedAt) return prev;
@@ -239,16 +261,38 @@ export function BatchTestPage() {
// 1) 结算上一拍仍在运行的用例(首拍只启动,不结算)
let sawFail = false;
let sawError = false;
if (settleRunning) {
for (const item of cases) {
if (item.status !== "running") continue;
item.status = item.failReason ? "fail" : "pass";
const plannedError = mockExecutionPlanRef.current[item.id];
if (plannedError) {
if (
plannedError.retryable &&
item.attemptCount < item.maxAttempts
) {
item.status = "waiting";
} else {
item.status = "error";
item.executionError = plannedError;
sawError = true;
}
continue;
}
item.status = hasEvaluationFailure(item) ? "fail" : "pass";
if (item.status === "fail") sawFail = true;
}
}
// 失败即停:剩余全部标记未执行
if (sawFail && failStrategy === "stop_on_fail") {
const stopReason =
sawError && prev.config.errorStrategy === "stop_on_error"
? "execution_error"
: sawFail && prev.config.failureStrategy === "stop_on_fail"
? "assertion_failure"
: null;
// 配置为停止时,剩余等待/运行中的用例统一标记为未执行。
if (stopReason) {
cases = cases.map((item) =>
item.status === "waiting" || item.status === "running"
? { ...item, status: "skipped" as const }
@@ -258,16 +302,18 @@ export function BatchTestPage() {
...prev,
cases,
finishedAt: new Date().toISOString(),
stopped: true,
stopReason,
};
}
// 2) 按并发补齐运行中
const limit = prev.config.concurrency;
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";
item.attemptCount += 1;
running += 1;
}
@@ -279,7 +325,7 @@ export function BatchTestPage() {
...prev,
cases,
finishedAt: new Date().toISOString(),
stopped: false,
stopReason: null,
};
}
@@ -291,7 +337,7 @@ export function BatchTestPage() {
tick(false);
const timer = window.setInterval(() => tick(true), TICK_MS);
return () => window.clearInterval(timer);
}, [phase, concurrency, failStrategy, run?.startedAt]);
}, [phase, run?.startedAt]);
// 执行引擎只负责写入完成快照;页面阶段在快照稳定后统一切换。
useEffect(() => {
@@ -311,6 +357,7 @@ export function BatchTestPage() {
}, [suites, casesBySuite, selectedCaseIds]);
const selectedCount = selectedCaseIds.size;
const errorRetryValue = Number(errorRetryCount) || 0;
const timeoutValue = Number(timeoutSecs);
const timeoutValid =
Number.isFinite(timeoutValue) && timeoutValue > 0 && timeoutValue <= 600;
@@ -322,14 +369,42 @@ export function BatchTestPage() {
!loadingAssistants &&
phase === "config";
const runReadiness: {
label: string;
target?: BatchSectionId;
actionable?: boolean;
} = loadingAssistants
? { label: "正在加载助手…" }
: assistants.length === 0
? { label: "暂无可用助手", target: "target", actionable: true }
: !assistantId
? { label: "请选择被测助手", target: "target", actionable: true }
: selectedCount === 0
? { label: "请选择测试用例", target: "scope", actionable: true }
: !timeoutValid
? {
label: "请修正超时时间",
target: "settings",
actionable: true,
}
: {
label: `${selectedCount} 个用例 · ${concurrency} 并发 · 超时 ${timeoutValue} 秒`,
};
function startRun(cases: TestCase[]) {
const assistantName =
assistants.find((item) => item.id === assistantId)?.name ?? "助手";
const snapshot = createBatchRunSnapshot({
const { snapshot, executionPlan } = createMockBatchRun({
title: buildRunTitle(cases, suites),
assistantName,
cases,
concurrency: Number(concurrency) || 3,
timeoutSecs: timeoutValue,
failureStrategy: failStrategy,
errorRetryCount: errorRetryValue,
errorStrategy,
});
mockExecutionPlanRef.current = executionPlan;
setRun(snapshot);
setPhase("running");
}
@@ -344,7 +419,7 @@ export function BatchTestPage() {
if (!prev) return prev;
return {
...prev,
stopped: true,
stopReason: "manual",
finishedAt: new Date().toISOString(),
cases: prev.cases.map((item) =>
item.status === "waiting" || item.status === "running"
@@ -356,13 +431,44 @@ export function BatchTestPage() {
setPhase("completed");
}
function handleRerun() {
if (selectedCases.length === 0) {
function sourceCasesByIds(ids: string[]): TestCase[] {
const idSet = new Set(ids);
return suites.flatMap((suite) =>
(casesBySuite[suite.id] ?? []).filter((item) => idSet.has(item.id)),
);
}
function handleRerunAll() {
const cases = run ? sourceCasesByIds(run.cases.map((item) => item.id)) : [];
if (cases.length === 0) {
setPhase("config");
setRun(null);
return;
}
startRun(selectedCases);
startRun(cases);
}
function handleRerunFailed() {
if (!run) return;
const failedCases = sourceCasesByIds(
run.cases
.filter(
(item) => item.status === "fail" || item.status === "error",
)
.map((item) => item.id),
);
if (failedCases.length > 0) startRun(failedCases);
}
function handleRerunCase(caseId: string) {
const [item] = sourceCasesByIds([caseId]);
if (item) startRun([item]);
}
function handleEditCase(caseId: string) {
const [item] = sourceCasesByIds([caseId]);
if (!item) return;
router.push(`/test/cases/${item.suiteId}?case=${item.id}`);
}
function handleBackToConfig() {
@@ -392,6 +498,9 @@ export function BatchTestPage() {
}
if (phase === "completed" && run) {
const failedCount = run.cases.filter(
(item) => item.status === "fail" || item.status === "error",
).length;
return (
<ListPageLayout
title="批量测试 / 已完成"
@@ -406,16 +515,29 @@ export function BatchTestPage() {
返回配置
</Button>
<Button
className="gap-2 rounded-full px-4"
onClick={handleRerun}
variant="outline"
className="gap-2 rounded-full border-hairline-strong"
disabled={failedCount === 0}
onClick={handleRerunFailed}
>
<RotateCcw size={15} />
重新运行
重跑未通过{failedCount > 0 ? ` (${failedCount})` : ""}
</Button>
<Button
className="gap-2 rounded-full px-4"
onClick={handleRerunAll}
>
<RotateCcw size={15} />
重跑全部
</Button>
</div>
}
>
<BatchRunCompletedView run={run} />
<BatchRunCompletedView
run={run}
onEditCase={handleEditCase}
onRerunCase={handleRerunCase}
/>
</ListPageLayout>
);
}
@@ -426,14 +548,40 @@ export function BatchTestPage() {
description="配置一次批量运行:选择被测助手、测试范围与基础运行参数。"
className="max-w-[880px]"
topbarAction={
<Button
className="gap-2 rounded-full px-4"
disabled={!canStart}
onClick={handleStart}
>
<Play size={16} />
开始批量测试
</Button>
<div className="flex items-center gap-3">
<div
id="batch-run-readiness"
aria-live="polite"
className="hidden min-w-0 md:block"
>
{runReadiness.actionable && runReadiness.target ? (
<button
type="button"
className="max-w-72 truncate rounded-full px-2 py-1 text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={() => {
if (runReadiness.target) {
scrollToSection(runReadiness.target);
}
}}
>
{runReadiness.label}
</button>
) : (
<span className="block max-w-72 truncate px-2 text-xs text-muted-foreground">
{runReadiness.label}
</span>
)}
</div>
<Button
className="gap-2 rounded-full px-4"
disabled={!canStart}
aria-describedby="batch-run-readiness"
onClick={handleStart}
>
<Play size={16} />
开始批量测试
</Button>
</div>
}
>
<SectionAnchorTabs
@@ -517,73 +665,149 @@ export function BatchTestPage() {
<SectionCard
icon={<Settings2 size={15} />}
title="运行设置"
description="MVP 仅保留并发、超时与失败策略"
description="配置运行资源,以及断言失败和执行错误后的处理方式"
>
<div className="grid gap-5 sm:grid-cols-3">
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
并发数
</span>
<Select value={concurrency} onValueChange={setConcurrency}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONCURRENCY_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<div className="space-y-5">
<div>
<p className="mb-3 text-xs font-medium tracking-wide text-muted-soft uppercase">
基础参数
</p>
<div className="grid gap-5 sm:grid-cols-2">
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
并发数
</span>
<Select value={concurrency} onValueChange={setConcurrency}>
<SelectTrigger
aria-label="并发数"
className="w-full border-hairline-strong bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONCURRENCY_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
单用例超时时间
</span>
<div className="relative">
<Input
type="number"
min={1}
max={600}
value={timeoutSecs}
onChange={(event) => setTimeoutSecs(event.target.value)}
className="border-hairline-strong bg-background pr-10"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-xs text-muted-soft">
秒
</span>
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
单用例超时时间
</span>
<div className="relative">
<Input
type="number"
min={1}
max={600}
value={timeoutSecs}
onChange={(event) => setTimeoutSecs(event.target.value)}
aria-label="单用例超时时间 秒"
className="border-hairline-strong bg-background pr-10"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-xs text-muted-soft">
秒
</span>
</div>
{!timeoutValid && (
<p className="text-xs text-destructive">
请输入 1–600 的秒数
</p>
)}
</label>
</div>
{!timeoutValid && (
<p className="text-xs text-destructive">
请输入 1–600 的秒数
</p>
)}
</label>
</div>
<label className="block space-y-2 sm:col-span-1">
<span className="text-sm font-medium text-foreground">
失败策略
</span>
<Select
value={failStrategy}
onValueChange={(value) =>
setFailStrategy(value as FailStrategy)
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FAIL_STRATEGY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<div className="border-t border-hairline pt-5">
<div className="mb-3">
<p className="text-xs font-medium tracking-wide text-muted-soft uppercase">
结果与异常处理
</p>
<p className="mt-1 text-xs leading-5 text-muted-soft">
断言失败表示回复已生成但未通过评估;执行错误包括超时、模型或 pipeline 异常。
</p>
</div>
<div className="grid gap-5 sm:grid-cols-3">
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
断言失败后
</span>
<Select
value={failStrategy}
onValueChange={(value) =>
setFailStrategy(value as BatchFailureStrategy)
}
>
<SelectTrigger
aria-label="断言失败后的处理方式"
className="w-full border-hairline-strong bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FAIL_STRATEGY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
执行错误自动重试
</span>
<Select
value={errorRetryCount}
onValueChange={setErrorRetryCount}
>
<SelectTrigger
aria-label="执行错误自动重试次数"
className="w-full border-hairline-strong bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ERROR_RETRY_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{value === "0" ? "不重试" : `${value} 次`}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
重试后仍错误
</span>
<Select
value={errorStrategy}
onValueChange={(value) =>
setErrorStrategy(value as BatchErrorStrategy)
}
>
<SelectTrigger
aria-label="重试后仍错误的处理方式"
className="w-full border-hairline-strong bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ERROR_STRATEGY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
</div>
</div>
</SectionCard>
</section>
+92 -42
View File
@@ -56,25 +56,19 @@ import {
SelectValue,
} from "@/components/ui/select";
import {
cloneOverallExpectation,
cloneOverallCriteria,
cloneTurns,
cloneUserSimulation,
cloneVoiceSettings,
createDefaultUserSimulation,
createDefaultVoiceSettings,
createEmptyFixedInputTurn,
createTestCase,
createTestSuite,
DEFAULT_INPUT_MODE,
duplicateTestCase,
duplicateTestSuite,
getTestCaseValidationMessage,
getTestSuite,
isFixedScriptMode,
isVoiceMode,
kindFromInputMode,
listTestCases,
listTestSuites,
normalizeOverallExpectation,
normalizeOverallCriteria,
removeTestCase,
removeTestCases,
removeTestSuite,
@@ -97,12 +91,18 @@ import { assistantsApi, type Assistant } from "@/lib/api";
export type TestCasesPageProps =
| { mode: "list" }
| { mode: "create" }
| { mode: "detail"; suiteId: string };
| { mode: "detail"; suiteId: string; initialCaseId?: string };
export function TestCasesPage(props: TestCasesPageProps) {
if (props.mode === "list") return <SuiteListView />;
if (props.mode === "create") return <SuiteCreateView />;
return <SuiteDetailView key={props.suiteId} suiteId={props.suiteId} />;
return (
<SuiteDetailView
key={`${props.suiteId}:${props.initialCaseId ?? ""}`}
suiteId={props.suiteId}
initialCaseId={props.initialCaseId}
/>
);
}
// ─── Suite 列表 ──────────────────────────────────────────────────────────────
@@ -441,12 +441,9 @@ function caseToDraft(item: TestCase): CaseEditorDraft {
return {
name: item.name,
inputMode: item.inputMode,
kind: item.kind,
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
turns: cloneTurns(item.turns),
voiceSettings: cloneVoiceSettings(item.voiceSettings),
userSimulation: cloneUserSimulation(item.userSimulation),
overallExpectation: cloneOverallExpectation(item.overallExpectation),
overallCriteria: cloneOverallCriteria(item.overallCriteria),
};
}
@@ -458,12 +455,9 @@ function createEmptyCaseDraft(): CaseEditorDraft {
return {
name: "未命名用例",
inputMode: DEFAULT_INPUT_MODE,
kind: kindFromInputMode(DEFAULT_INPUT_MODE),
contextTurns: [],
turns: [createEmptyFixedInputTurn()],
voiceSettings: null,
userSimulation: null,
overallExpectation: null,
overallCriteria: [],
};
}
@@ -471,25 +465,23 @@ function applyInputMode(
draft: CaseEditorDraft,
mode: TestCaseInputMode,
): CaseEditorDraft {
const next: CaseEditorDraft = {
...draft,
inputMode: mode,
kind: kindFromInputMode(mode),
};
if (isVoiceMode(mode) && !next.voiceSettings) {
next.voiceSettings = createDefaultVoiceSettings();
}
if (!isFixedScriptMode(mode) && !next.userSimulation) {
next.userSimulation = createDefaultUserSimulation();
}
return next;
return { ...draft, inputMode: mode };
}
function SuiteDetailView({ suiteId }: { suiteId: string }) {
function SuiteDetailView({
suiteId,
initialCaseId,
}: {
suiteId: string;
initialCaseId?: string;
}) {
const router = useRouter();
const [initial] = useState(() => {
const initialCases = listTestCases(suiteId);
const initialCase = initialCases[0] ?? null;
const initialCase =
initialCases.find((item) => item.id === initialCaseId) ??
initialCases[0] ??
null;
const initialDraft = initialCase ? caseToDraft(initialCase) : null;
return {
suite: getTestSuite(suiteId),
@@ -562,6 +554,60 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
savedSnapshot === null ||
!draftsEqual(draft, savedSnapshot));
const validationMessage = draft
? getTestCaseValidationMessage(draft)
: "请先选择测试用例";
const canSave =
Boolean(draft?.name.trim()) && dirty && validationMessage === null;
useEffect(() => {
if (!dirty) return;
function handleBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = "";
}
function handleInternalLink(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
const target = event.target;
if (!(target instanceof Element)) return;
const link = target.closest<HTMLAnchorElement>("a[href]");
if (!link || link.target === "_blank" || link.hasAttribute("download")) {
return;
}
const nextUrl = new URL(link.href, window.location.href);
if (nextUrl.origin !== window.location.origin) return;
if (
nextUrl.pathname === window.location.pathname &&
nextUrl.search === window.location.search
) {
return;
}
if (window.confirm("当前用例有未保存修改,离开将丢弃。继续?")) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
}
window.addEventListener("beforeunload", handleBeforeUnload);
document.addEventListener("click", handleInternalLink, true);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
document.removeEventListener("click", handleInternalLink, true);
};
}, [dirty]);
function selectCase(item: TestCase) {
if (dirty && !window.confirm("当前用例有未保存修改,切换将丢弃。继续?")) {
return;
@@ -584,18 +630,13 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
}
function handleSave() {
if (!draft) return;
if (!draft || validationMessage) return;
const patch = {
name: draft.name.trim() || "未命名用例",
inputMode: draft.inputMode,
kind: kindFromInputMode(draft.inputMode),
contextTurns: draft.contextTurns,
turns: draft.turns,
voiceSettings: draft.voiceSettings,
userSimulation: draft.userSimulation,
overallExpectation: normalizeOverallExpectation(
draft.overallExpectation?.criteria,
),
overallCriteria: normalizeOverallCriteria(draft.overallCriteria),
};
let saved: TestCase | null;
@@ -807,7 +848,15 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
/>
<div className="ml-auto flex shrink-0 items-center gap-2">
{dirty ? (
{dirty && validationMessage ? (
<span
className="max-w-56 truncate text-xs text-destructive"
title={validationMessage}
role="status"
>
{validationMessage}
</span>
) : dirty ? (
<span className="text-xs text-amber-600">未保存</span>
) : statusMessage ? (
<span className="text-xs text-muted-foreground">
@@ -817,7 +866,8 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
<Button
size="sm"
className="gap-1.5"
disabled={!dirty || !draft.name.trim()}
disabled={!canSave}
title={validationMessage ?? undefined}
onClick={handleSave}
>
<Save size={14} />
@@ -156,12 +156,15 @@ function ModeMenuItem({
return (
<button
type="button"
onClick={onSelect}
disabled={!available}
aria-disabled={!available}
title={available ? undefined : `${title}模式即将支持`}
onClick={available ? onSelect : undefined}
className={cn(
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
available
? "hover:bg-surface-strong"
: "opacity-70 hover:bg-surface-strong/60",
: "cursor-not-allowed opacity-55",
selected && "bg-surface-strong/80",
)}
>
File diff suppressed because it is too large Load Diff