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:
@@ -2,9 +2,18 @@ import { TestCasesPage } from "@/components/pages/TestCasesPage";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ case?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <TestCasesPage mode="detail" suiteId={id} />;
|
||||
const { case: initialCaseId } = await searchParams;
|
||||
return (
|
||||
<TestCasesPage
|
||||
mode="detail"
|
||||
suiteId={id}
|
||||
initialCaseId={initialCaseId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -3,22 +3,81 @@
|
||||
* 真实执行引擎接入前,用本地状态模拟进度与结果。
|
||||
*/
|
||||
|
||||
import type { TestCase } from "@/data/test-suites";
|
||||
import type {
|
||||
ExpectedBehavior,
|
||||
ReplyExpectedBehavior,
|
||||
TestCase,
|
||||
ToolCallExpectedBehavior,
|
||||
} from "@/data/test-suites";
|
||||
|
||||
export type BatchCaseStatus =
|
||||
| "waiting"
|
||||
| "running"
|
||||
| "pass"
|
||||
| "fail"
|
||||
| "error"
|
||||
| "skipped";
|
||||
|
||||
export type BatchFailureStrategy = "continue" | "stop_on_fail";
|
||||
export type BatchErrorStrategy = "continue" | "stop_on_error";
|
||||
|
||||
export const BATCH_FAILURE_STRATEGY_LABEL: Record<
|
||||
BatchFailureStrategy,
|
||||
string
|
||||
> = {
|
||||
continue: "继续执行全部用例",
|
||||
stop_on_fail: "遇失败立即停止",
|
||||
};
|
||||
|
||||
export const BATCH_ERROR_STRATEGY_LABEL: Record<BatchErrorStrategy, string> = {
|
||||
continue: "标记错误并继续",
|
||||
stop_on_error: "标记错误并停止",
|
||||
};
|
||||
|
||||
type BatchExecutionError = {
|
||||
code: string;
|
||||
message: string;
|
||||
stage: "pipeline" | "model" | "tool" | "evaluation";
|
||||
retryable: boolean;
|
||||
};
|
||||
|
||||
export type BatchRunCase = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: BatchCaseStatus;
|
||||
turns: BatchTurnResult[];
|
||||
overallCriteria: BatchEvaluationResult[];
|
||||
attemptCount: number;
|
||||
maxAttempts: number;
|
||||
executionError: BatchExecutionError | null;
|
||||
};
|
||||
|
||||
export type BatchEvaluationResult = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "reply" | "tool_call" | "overall";
|
||||
status: "pass" | "fail";
|
||||
expected: string;
|
||||
actual: string;
|
||||
failReason: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type BatchToolCallRecord = {
|
||||
id: string;
|
||||
functionName: string;
|
||||
argumentsJson: string;
|
||||
resultJson: string;
|
||||
outcome: "success" | "error";
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type BatchTurnResult = {
|
||||
id: string;
|
||||
index: number;
|
||||
userInput: string;
|
||||
assistantReply: string;
|
||||
toolCalls: BatchToolCallRecord[];
|
||||
evaluations: BatchEvaluationResult[];
|
||||
};
|
||||
|
||||
export type BatchRunPhase = "config" | "running" | "completed";
|
||||
@@ -26,24 +85,21 @@ export type BatchRunPhase = "config" | "running" | "completed";
|
||||
export type BatchRunSnapshot = {
|
||||
title: string;
|
||||
assistantName: string;
|
||||
config: {
|
||||
suiteCount: number;
|
||||
concurrency: number;
|
||||
timeoutSecs: number;
|
||||
failureStrategy: BatchFailureStrategy;
|
||||
errorRetryCount: number;
|
||||
errorStrategy: BatchErrorStrategy;
|
||||
};
|
||||
cases: BatchRunCase[];
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
stopped: boolean;
|
||||
stopReason: "manual" | "assertion_failure" | "execution_error" | null;
|
||||
};
|
||||
|
||||
/** 从测试用例生成期望文案(展示用) */
|
||||
export function buildExpectedResult(item: TestCase): string {
|
||||
if (item.assertionType === "llm") {
|
||||
return item.llmCriteria.trim() || "回复应符合 LLM 判断标准。";
|
||||
}
|
||||
if (item.keywords.length === 0) {
|
||||
return "回复应包含预期关键词。";
|
||||
}
|
||||
const mode =
|
||||
item.keywordMatchMode === "all" ? "同时包含" : "至少包含其一";
|
||||
return `回复应${mode}:${item.keywords.join("、")}。`;
|
||||
}
|
||||
export type BatchMockExecutionPlan = Record<string, BatchExecutionError>;
|
||||
|
||||
/** 预定部分用例失败,方便演示失败展开态 */
|
||||
function shouldFail(index: number, item: TestCase): boolean {
|
||||
@@ -51,53 +107,253 @@ function shouldFail(index: number, item: TestCase): boolean {
|
||||
return index % 4 === 2;
|
||||
}
|
||||
|
||||
function mockFailActual(): string {
|
||||
return "已为您转接人工处理。";
|
||||
/** 稳定产生少量执行错误,便于检查重试和继续/停止流程。 */
|
||||
function shouldError(index: number): boolean {
|
||||
return index % 5 === 3;
|
||||
}
|
||||
|
||||
function mockFailReason(item: TestCase): string {
|
||||
if (item.assertionType === "llm") {
|
||||
return "未按照预期确认关键信息,且错误转接人工。";
|
||||
function replyExpectation(behavior: ReplyExpectedBehavior): string {
|
||||
if (behavior.assertionType === "llm") return behavior.llmCriteria;
|
||||
const relation = behavior.negateKeywords
|
||||
? "不应包含"
|
||||
: behavior.keywordMatchMode === "all"
|
||||
? "应包含全部"
|
||||
: "应至少包含其一";
|
||||
return `${relation}:${behavior.keywords.join("、")}`;
|
||||
}
|
||||
|
||||
function toolExpectation(behavior: ToolCallExpectedBehavior): string {
|
||||
if (behavior.expectation === "not_called") {
|
||||
return `不应调用 ${behavior.functionName}`;
|
||||
}
|
||||
return `回复未命中预期关键词(${item.keywords.slice(0, 3).join("、") || "无"})。`;
|
||||
const count =
|
||||
behavior.maxCalls === null
|
||||
? `${behavior.minCalls} 次以上`
|
||||
: behavior.minCalls === behavior.maxCalls
|
||||
? `${behavior.minCalls} 次`
|
||||
: `${behavior.minCalls}–${behavior.maxCalls} 次`;
|
||||
return `应调用 ${behavior.functionName} ${count}`;
|
||||
}
|
||||
|
||||
function mockPassActual(item: TestCase): string {
|
||||
if (item.assertionType === "keyword" && item.keywords[0]) {
|
||||
return `好的,请继续描述事故经过,并确认是否有人受伤。`;
|
||||
function mockAssistantReply(behaviors: ExpectedBehavior[]): string {
|
||||
const positiveKeyword = behaviors.find(
|
||||
(behavior): behavior is ReplyExpectedBehavior =>
|
||||
behavior.type === "reply" &&
|
||||
behavior.assertionType === "keyword" &&
|
||||
!behavior.negateKeywords &&
|
||||
behavior.keywords.length > 0,
|
||||
);
|
||||
if (positiveKeyword) {
|
||||
return `好的,我已了解。请继续说明${positiveKeyword.keywords.slice(0, 2).join("和")}。`;
|
||||
}
|
||||
return "好的,我已记录,我们继续处理。";
|
||||
return "好的,我已记录当前信息,我们继续处理。";
|
||||
}
|
||||
|
||||
export function createBatchRunSnapshot(input: {
|
||||
function mockToolArguments(behavior: ToolCallExpectedBehavior): string {
|
||||
const entries = behavior.paramAssertions.map((item) => [
|
||||
item.name,
|
||||
item.matchMode === "exact" ? item.value : `mock_${item.name}`,
|
||||
]);
|
||||
return JSON.stringify(Object.fromEntries(entries), null, 2);
|
||||
}
|
||||
|
||||
function createBehaviorEvaluation(
|
||||
behavior: ExpectedBehavior,
|
||||
fail: boolean,
|
||||
): {
|
||||
evaluation: BatchEvaluationResult;
|
||||
toolCalls: BatchToolCallRecord[];
|
||||
} {
|
||||
if (behavior.type === "reply") {
|
||||
const reason = fail
|
||||
? behavior.assertionType === "keyword"
|
||||
? "实际回复未满足关键词规则。"
|
||||
: "LLM 判断认为回复未达到该项语义要求。"
|
||||
: "";
|
||||
return {
|
||||
evaluation: {
|
||||
id: behavior.id,
|
||||
label:
|
||||
behavior.assertionType === "keyword"
|
||||
? "回复 · 关键词"
|
||||
: "回复 · LLM 判断",
|
||||
kind: "reply",
|
||||
status: fail ? "fail" : "pass",
|
||||
expected: replyExpectation(behavior),
|
||||
actual: fail
|
||||
? "已为您转接人工处理。"
|
||||
: "实际回复满足本条内容要求。",
|
||||
reason,
|
||||
},
|
||||
toolCalls: [],
|
||||
};
|
||||
}
|
||||
|
||||
const shouldRecordCall =
|
||||
behavior.expectation === "called" ? !fail : fail;
|
||||
const toolCalls: BatchToolCallRecord[] = shouldRecordCall
|
||||
? [
|
||||
{
|
||||
id: `call_${behavior.id}`,
|
||||
functionName: behavior.functionName,
|
||||
argumentsJson: mockToolArguments(behavior),
|
||||
resultJson: behavior.mockResponse.body,
|
||||
outcome: behavior.mockResponse.outcome,
|
||||
durationMs: behavior.mockResponse.delayMs + 84,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const actual = shouldRecordCall
|
||||
? `实际调用 1 次 ${behavior.functionName}`
|
||||
: `实际调用 0 次 ${behavior.functionName}`;
|
||||
const reason = fail
|
||||
? behavior.expectation === "not_called"
|
||||
? "检测到本轮不应发生的工具调用。"
|
||||
: "未检测到满足次数要求的工具调用。"
|
||||
: "";
|
||||
return {
|
||||
evaluation: {
|
||||
id: behavior.id,
|
||||
label: `工具 · ${behavior.functionName}`,
|
||||
kind: "tool_call",
|
||||
status: fail ? "fail" : "pass",
|
||||
expected: toolExpectation(behavior),
|
||||
actual,
|
||||
reason,
|
||||
},
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
|
||||
function createCaseResult(
|
||||
item: TestCase,
|
||||
caseIndex: number,
|
||||
maxAttempts: number,
|
||||
): { result: BatchRunCase; plannedError: BatchExecutionError | null } {
|
||||
const plannedError = shouldError(caseIndex);
|
||||
const plannedFailure = !plannedError && shouldFail(caseIndex, item);
|
||||
let failureAssigned = false;
|
||||
const turns = item.turns.map((turn, turnIndex) => {
|
||||
const toolCalls: BatchToolCallRecord[] = [];
|
||||
const evaluations = turn.behaviors.map((behavior) => {
|
||||
const fail = plannedFailure && !failureAssigned;
|
||||
if (fail) failureAssigned = true;
|
||||
const result = createBehaviorEvaluation(behavior, fail);
|
||||
toolCalls.push(...result.toolCalls);
|
||||
return result.evaluation;
|
||||
});
|
||||
return {
|
||||
id: turn.id,
|
||||
index: turnIndex,
|
||||
userInput: turn.userInput,
|
||||
assistantReply: mockAssistantReply(turn.behaviors),
|
||||
toolCalls,
|
||||
evaluations,
|
||||
};
|
||||
});
|
||||
const overallCriteria = item.overallCriteria.map((criterion) => {
|
||||
const fail = plannedFailure && !failureAssigned;
|
||||
if (fail) failureAssigned = true;
|
||||
return {
|
||||
id: criterion.id,
|
||||
label: criterion.name,
|
||||
kind: "overall" as const,
|
||||
status: fail ? ("fail" as const) : ("pass" as const),
|
||||
expected: criterion.criteria,
|
||||
actual: fail
|
||||
? "整段对话未完整达到该业务目标。"
|
||||
: "整段对话达到该业务目标。",
|
||||
reason: fail ? "LLM 判断认为整段对话未满足该项标准。" : "",
|
||||
};
|
||||
});
|
||||
return {
|
||||
result: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
status: "waiting",
|
||||
turns,
|
||||
overallCriteria,
|
||||
attemptCount: 0,
|
||||
maxAttempts,
|
||||
executionError: null,
|
||||
},
|
||||
plannedError: plannedError
|
||||
? {
|
||||
code: "PIPELINE_TIMEOUT",
|
||||
message: "等待 pipeline 完成回复时超过单用例超时时间。",
|
||||
stage: "pipeline",
|
||||
retryable: true,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockBatchRun(input: {
|
||||
title: string;
|
||||
assistantName: string;
|
||||
cases: TestCase[];
|
||||
}): BatchRunSnapshot {
|
||||
concurrency: number;
|
||||
timeoutSecs: number;
|
||||
failureStrategy: BatchFailureStrategy;
|
||||
errorRetryCount: number;
|
||||
errorStrategy: BatchErrorStrategy;
|
||||
}): {
|
||||
snapshot: BatchRunSnapshot;
|
||||
executionPlan: BatchMockExecutionPlan;
|
||||
} {
|
||||
const preparedCases = input.cases.map((item, index) =>
|
||||
createCaseResult(item, index, input.errorRetryCount + 1),
|
||||
);
|
||||
const executionPlan = Object.fromEntries(
|
||||
preparedCases.flatMap(({ result, plannedError }) =>
|
||||
plannedError ? [[result.id, plannedError]] : [],
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
title: input.title,
|
||||
assistantName: input.assistantName,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
stopped: false,
|
||||
cases: input.cases.map((item, index) => {
|
||||
const fail = shouldFail(index, item);
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
status: "waiting" as const,
|
||||
expected: buildExpectedResult(item),
|
||||
actual: fail ? mockFailActual() : mockPassActual(item),
|
||||
failReason: fail ? mockFailReason(item) : "",
|
||||
};
|
||||
}),
|
||||
snapshot: {
|
||||
title: input.title,
|
||||
assistantName: input.assistantName,
|
||||
config: {
|
||||
suiteCount: new Set(input.cases.map((item) => item.suiteId)).size,
|
||||
concurrency: input.concurrency,
|
||||
timeoutSecs: input.timeoutSecs,
|
||||
failureStrategy: input.failureStrategy,
|
||||
errorRetryCount: input.errorRetryCount,
|
||||
errorStrategy: input.errorStrategy,
|
||||
},
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
stopReason: null,
|
||||
cases: preparedCases.map(({ result }) => result),
|
||||
},
|
||||
executionPlan,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasEvaluationFailure(item: BatchRunCase): boolean {
|
||||
const evaluations = [
|
||||
...item.turns.flatMap((turn) => turn.evaluations),
|
||||
...item.overallCriteria,
|
||||
];
|
||||
return evaluations.some((evaluation) => evaluation.status === "fail");
|
||||
}
|
||||
|
||||
export function firstEvaluationFailureReason(item: BatchRunCase): string {
|
||||
const evaluations = [
|
||||
...item.turns.flatMap((turn) => turn.evaluations),
|
||||
...item.overallCriteria,
|
||||
];
|
||||
return (
|
||||
evaluations.find((evaluation) => evaluation.status === "fail")?.reason ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
export function countByStatus(cases: BatchRunCase[]) {
|
||||
const counts = {
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
error: 0,
|
||||
running: 0,
|
||||
waiting: 0,
|
||||
skipped: 0,
|
||||
@@ -113,3 +369,21 @@ export function formatRunTime(iso: string): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function formatRunDuration(
|
||||
startedAt: string,
|
||||
finishedAt: string | null,
|
||||
): string {
|
||||
if (!finishedAt) return "运行中";
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
||||
);
|
||||
const seconds = Math.max(1, Math.round(durationMs / 1000));
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return remainingSeconds > 0
|
||||
? `${minutes} 分 ${remainingSeconds} 秒`
|
||||
: `${minutes} 分`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 测试集 / 测试用例 — 管理页用的本地 mock。
|
||||
* 两层:Test Suite → Test Case。
|
||||
* 输入模式:固定脚本(文字 / 逐轮语音 / 连续语音)与用户模拟(文字 / 语音)。
|
||||
* MVP 仅运行固定文字脚本;其它输入模式保留为产品路线提示。
|
||||
*/
|
||||
|
||||
/** 顶部模式选择器的唯一来源;不再单独维护「测试类型」字段 */
|
||||
@@ -12,38 +12,27 @@ export type TestCaseInputMode =
|
||||
| "user_sim_text"
|
||||
| "user_sim_voice";
|
||||
|
||||
/** 列表/兼容用粗粒度种类(由 inputMode 推导) */
|
||||
export type TestCaseKind = "fixed_dialogue" | "user_simulation";
|
||||
|
||||
export type TestCaseResult = "pass" | "fail" | "not_run";
|
||||
type TestCaseResult = "pass" | "fail" | "not_run";
|
||||
|
||||
export type AssertionType = "keyword" | "llm";
|
||||
|
||||
export type KeywordMatchMode = "any" | "all";
|
||||
|
||||
export type ContextRole =
|
||||
| "agent"
|
||||
| "user"
|
||||
| "tool_call"
|
||||
| "tool_result";
|
||||
|
||||
export type ContextTurn = {
|
||||
role: "agent" | "user";
|
||||
role: ContextRole;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type VoiceSettings = {
|
||||
voiceId: string;
|
||||
/** 语速倍率,如 1.0 */
|
||||
speed: number;
|
||||
};
|
||||
|
||||
/** 连续语音模式下每轮的发送时机 */
|
||||
export type TurnSendTiming =
|
||||
| "after_previous_reply"
|
||||
| "after_agent_starts"
|
||||
| "fixed_delay";
|
||||
|
||||
export type UserSimulationConfig = {
|
||||
role: string;
|
||||
goal: string;
|
||||
knownFacts: string;
|
||||
behaviorNotes: string;
|
||||
maxTurns: number;
|
||||
/** Tool Call / Tool Result 使用;普通消息忽略 */
|
||||
toolName?: string;
|
||||
/** 关联一次工具调用及其返回;普通消息忽略 */
|
||||
toolCallId?: string;
|
||||
/** Tool Result 使用 */
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
/** 工具参数校验方式(未配置 = 不参与断言) */
|
||||
@@ -62,17 +51,31 @@ export type ReplyExpectedBehavior = {
|
||||
assertionType: AssertionType;
|
||||
keywords: string[];
|
||||
keywordMatchMode: KeywordMatchMode;
|
||||
/** true 表示回复中不应出现这些关键词 */
|
||||
negateKeywords: boolean;
|
||||
llmCriteria: string;
|
||||
};
|
||||
|
||||
/** 预期行为:工具调用(至少调用一次指定工具) */
|
||||
type ToolMockResponse = {
|
||||
outcome: "success" | "error";
|
||||
/** JSON 文本,运行时会作为工具返回值注入 pipeline */
|
||||
body: string;
|
||||
delayMs: number;
|
||||
};
|
||||
|
||||
/** 预期行为:工具调用或禁止调用指定工具 */
|
||||
export type ToolCallExpectedBehavior = {
|
||||
id: string;
|
||||
type: "tool_call";
|
||||
toolId: string;
|
||||
functionName: string;
|
||||
expectation: "called" | "not_called";
|
||||
minCalls: number;
|
||||
maxCalls: number | null;
|
||||
/** 仅包含用户主动配置的参数;未列出的参数不校验 */
|
||||
paramAssertions: ToolParamAssertion[];
|
||||
/** 调用工具后注入 pipeline 的稳定返回值 */
|
||||
mockResponse: ToolMockResponse;
|
||||
};
|
||||
|
||||
export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior;
|
||||
@@ -85,18 +88,16 @@ export type FixedInputTurn = {
|
||||
id: string;
|
||||
userInput: string;
|
||||
behaviors: ExpectedBehavior[];
|
||||
/** 连续语音:发送时机(其它模式可忽略) */
|
||||
sendTiming?: TurnSendTiming;
|
||||
/** 连续语音:延迟毫秒(仅部分时机需要) */
|
||||
sendDelayMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 整段对话结束后的业务目标断言(可选,独立于每轮 behaviors)。
|
||||
* MVP 仅支持 LLM 判断。
|
||||
*/
|
||||
export type OverallExpectation = {
|
||||
export type OverallCriterion = {
|
||||
id: string;
|
||||
type: "llm";
|
||||
name: string;
|
||||
criteria: string;
|
||||
};
|
||||
|
||||
@@ -116,28 +117,13 @@ export type TestCase = {
|
||||
description: string;
|
||||
/** 输入模式(编辑器顶部选择器) */
|
||||
inputMode: TestCaseInputMode;
|
||||
/** 由 inputMode 推导,供列表筛选等兼容读取 */
|
||||
kind: TestCaseKind;
|
||||
lastResult: TestCaseResult;
|
||||
/** 对话上下文(通常是 Agent 上一轮) */
|
||||
contextTurns: ContextTurn[];
|
||||
/** 固定脚本轮次 */
|
||||
turns: FixedInputTurn[];
|
||||
/** 语音相关模式的音色 / 语速 */
|
||||
voiceSettings?: VoiceSettings | null;
|
||||
/** 用户模拟配置 */
|
||||
userSimulation?: UserSimulationConfig | null;
|
||||
/** 整段对话业务目标预期(可选) */
|
||||
overallExpectation?: OverallExpectation | null;
|
||||
/**
|
||||
* 以下字段由 turns[0] 同步,供批量测试等旧逻辑读取。
|
||||
* 编辑与保存以 turns 为准。
|
||||
*/
|
||||
userInput: string;
|
||||
assertionType: AssertionType;
|
||||
keywords: string[];
|
||||
keywordMatchMode: KeywordMatchMode;
|
||||
llmCriteria: string;
|
||||
/** 整段对话业务目标预期;所有标准均通过才算用例通过 */
|
||||
overallCriteria: OverallCriterion[];
|
||||
/** 套件内排序,越小越靠前 */
|
||||
sortOrder: number;
|
||||
updatedAt: string;
|
||||
@@ -145,26 +131,87 @@ export type TestCase = {
|
||||
|
||||
export const DEFAULT_INPUT_MODE: TestCaseInputMode = "fixed_script_text";
|
||||
|
||||
export function kindFromInputMode(mode: TestCaseInputMode): TestCaseKind {
|
||||
return mode === "user_sim_text" || mode === "user_sim_voice"
|
||||
? "user_simulation"
|
||||
: "fixed_dialogue";
|
||||
/**
|
||||
* MVP 可运行性规则。
|
||||
* 目前执行器只支持固定文字;每一轮都需要用户输入,并至少配置一项断言。
|
||||
*/
|
||||
export function getTestCaseValidationMessage(
|
||||
item: Pick<
|
||||
TestCase,
|
||||
"inputMode" | "contextTurns" | "turns" | "overallCriteria"
|
||||
>,
|
||||
): string | null {
|
||||
if (item.inputMode !== "fixed_script_text") {
|
||||
return "当前输入模式尚未开放";
|
||||
}
|
||||
if (item.turns.length === 0) {
|
||||
return "至少添加一轮固定输入";
|
||||
}
|
||||
for (let index = 0; index < item.contextTurns.length; index += 1) {
|
||||
const context = item.contextTurns[index];
|
||||
if (!context.content.trim()) {
|
||||
return `上下文第 ${index + 1} 条内容不能为空`;
|
||||
}
|
||||
if (
|
||||
(context.role === "tool_call" || context.role === "tool_result") &&
|
||||
!context.toolName?.trim()
|
||||
) {
|
||||
return `上下文第 ${index + 1} 条需要填写工具名称`;
|
||||
}
|
||||
if (context.role === "tool_call" || context.role === "tool_result") {
|
||||
try {
|
||||
JSON.parse(context.content);
|
||||
} catch {
|
||||
return `上下文第 ${index + 1} 条工具数据必须是有效 JSON`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const emptyTurnIndex = item.turns.findIndex(
|
||||
(turn) => !turn.userInput.trim(),
|
||||
);
|
||||
if (emptyTurnIndex >= 0) {
|
||||
return `第 ${emptyTurnIndex + 1} 轮用户输入不能为空`;
|
||||
}
|
||||
for (let turnIndex = 0; turnIndex < item.turns.length; turnIndex += 1) {
|
||||
const turn = item.turns[turnIndex];
|
||||
for (
|
||||
let behaviorIndex = 0;
|
||||
behaviorIndex < turn.behaviors.length;
|
||||
behaviorIndex += 1
|
||||
) {
|
||||
const message = getExpectedBehaviorValidationMessage(
|
||||
turn.behaviors[behaviorIndex],
|
||||
);
|
||||
if (message) {
|
||||
return `第 ${turnIndex + 1} 轮预期 ${behaviorIndex + 1}:${message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < item.overallCriteria.length; index += 1) {
|
||||
const criterion = item.overallCriteria[index];
|
||||
if (!criterion.name.trim()) {
|
||||
return `整体评估标准 ${index + 1} 的名称不能为空`;
|
||||
}
|
||||
if (!criterion.criteria.trim()) {
|
||||
return `整体评估标准 ${index + 1} 的判断要求不能为空`;
|
||||
}
|
||||
}
|
||||
const hasTurnExpectation = item.turns.some(
|
||||
(turn) => turn.behaviors.length > 0,
|
||||
);
|
||||
if (!hasTurnExpectation && item.overallCriteria.length === 0) {
|
||||
return "至少配置一项有效的预期行为或整体评估标准";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isFixedScriptMode(mode: TestCaseInputMode): boolean {
|
||||
return (
|
||||
mode === "fixed_script_text" ||
|
||||
mode === "fixed_script_turn_voice" ||
|
||||
mode === "fixed_script_continuous_voice"
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceMode(mode: TestCaseInputMode): boolean {
|
||||
return (
|
||||
mode === "fixed_script_turn_voice" ||
|
||||
mode === "fixed_script_continuous_voice" ||
|
||||
mode === "user_sim_voice"
|
||||
);
|
||||
export function isTestCaseRunnable(
|
||||
item: Pick<
|
||||
TestCase,
|
||||
"inputMode" | "contextTurns" | "turns" | "overallCriteria"
|
||||
>,
|
||||
): boolean {
|
||||
return getTestCaseValidationMessage(item) === null;
|
||||
}
|
||||
|
||||
export const TEST_CASE_INPUT_MODE_LABEL: Record<TestCaseInputMode, string> = {
|
||||
@@ -187,64 +234,6 @@ export const TEST_CASE_INPUT_MODE_SHORT_LABEL: Record<
|
||||
user_sim_voice: "模拟语音",
|
||||
};
|
||||
|
||||
/** @deprecated 使用 TEST_CASE_INPUT_MODE_LABEL;保留给旧引用 */
|
||||
export const TEST_CASE_KIND_LABEL: Record<TestCaseKind, string> = {
|
||||
fixed_dialogue: "固定脚本",
|
||||
user_simulation: "用户模拟",
|
||||
};
|
||||
|
||||
export const VOICE_OPTIONS = [
|
||||
{ value: "zh_female", label: "普通话女声" },
|
||||
{ value: "zh_male", label: "普通话男声" },
|
||||
] as const;
|
||||
|
||||
export const VOICE_SPEED_OPTIONS = [
|
||||
{ value: "0.8", label: "0.8x" },
|
||||
{ value: "1.0", label: "1.0x" },
|
||||
{ value: "1.2", label: "1.2x" },
|
||||
{ value: "1.5", label: "1.5x" },
|
||||
] as const;
|
||||
|
||||
export const TURN_SEND_TIMING_LABEL: Record<TurnSendTiming, string> = {
|
||||
after_previous_reply: "上一轮回复结束后",
|
||||
after_agent_starts: "Agent 开始回复后",
|
||||
fixed_delay: "固定延迟",
|
||||
};
|
||||
|
||||
export function createDefaultVoiceSettings(): VoiceSettings {
|
||||
return { voiceId: "zh_female", speed: 1.0 };
|
||||
}
|
||||
|
||||
export function createDefaultUserSimulation(): UserSimulationConfig {
|
||||
return {
|
||||
role: "",
|
||||
goal: "",
|
||||
knownFacts: "",
|
||||
behaviorNotes: "",
|
||||
maxTurns: 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneVoiceSettings(
|
||||
value: VoiceSettings | null | undefined,
|
||||
): VoiceSettings | null {
|
||||
if (!value) return null;
|
||||
return { voiceId: value.voiceId, speed: value.speed };
|
||||
}
|
||||
|
||||
export function cloneUserSimulation(
|
||||
value: UserSimulationConfig | null | undefined,
|
||||
): UserSimulationConfig | null {
|
||||
if (!value) return null;
|
||||
return {
|
||||
role: value.role,
|
||||
goal: value.goal,
|
||||
knownFacts: value.knownFacts,
|
||||
behaviorNotes: value.behaviorNotes,
|
||||
maxTurns: value.maxTurns,
|
||||
};
|
||||
}
|
||||
|
||||
export const ASSERTION_TYPE_LABEL: Record<AssertionType, string> = {
|
||||
keyword: "关键词",
|
||||
llm: "LLM 判断",
|
||||
@@ -261,29 +250,70 @@ export const TOOL_PARAM_MATCH_MODE_LABEL: Record<ToolParamMatchMode, string> = {
|
||||
llm: "LLM 判断",
|
||||
};
|
||||
|
||||
export const OVERALL_EXPECTATION_CRITERIA_MAX = 2000;
|
||||
export const OVERALL_CRITERION_TEXT_MAX = 2000;
|
||||
const TOOL_MOCK_DELAY_MAX_MS = 60_000;
|
||||
|
||||
/** 空文本视为未配置 */
|
||||
export function normalizeOverallExpectation(
|
||||
criteria: string | null | undefined,
|
||||
): OverallExpectation | null {
|
||||
const trimmed = (criteria ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
return {
|
||||
type: "llm",
|
||||
criteria: trimmed.slice(0, OVERALL_EXPECTATION_CRITERIA_MAX),
|
||||
};
|
||||
}
|
||||
export function getExpectedBehaviorValidationMessage(
|
||||
behavior: ExpectedBehavior,
|
||||
): string | null {
|
||||
if (behavior.type === "reply") {
|
||||
if (
|
||||
behavior.assertionType === "keyword" &&
|
||||
behavior.keywords.every((keyword) => !keyword.trim())
|
||||
) {
|
||||
return behavior.negateKeywords
|
||||
? "请填写至少一个不应出现的关键词"
|
||||
: "请填写至少一个需要匹配的关键词";
|
||||
}
|
||||
if (
|
||||
behavior.assertionType === "llm" &&
|
||||
!behavior.llmCriteria.trim()
|
||||
) {
|
||||
return "请填写 LLM 判断要求";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function cloneOverallExpectation(
|
||||
value: OverallExpectation | null | undefined,
|
||||
): OverallExpectation | null {
|
||||
if (!value?.criteria.trim()) return null;
|
||||
return { type: "llm", criteria: value.criteria };
|
||||
if (!behavior.toolId.trim() || !behavior.functionName.trim()) {
|
||||
return "请选择需要断言的工具";
|
||||
}
|
||||
if (behavior.expectation === "called") {
|
||||
if (!Number.isInteger(behavior.minCalls) || behavior.minCalls < 1) {
|
||||
return "最少调用次数必须是大于 0 的整数";
|
||||
}
|
||||
if (
|
||||
behavior.maxCalls !== null &&
|
||||
(!Number.isInteger(behavior.maxCalls) ||
|
||||
behavior.maxCalls < behavior.minCalls)
|
||||
) {
|
||||
return "最多调用次数不能小于最少调用次数";
|
||||
}
|
||||
const emptyParam = behavior.paramAssertions.find(
|
||||
(item) => !item.name.trim() || !item.value.trim(),
|
||||
);
|
||||
if (emptyParam) return "工具参数断言的名称和值不能为空";
|
||||
if (!behavior.mockResponse.body.trim()) {
|
||||
return "请填写 Mock 工具返回值";
|
||||
}
|
||||
try {
|
||||
JSON.parse(behavior.mockResponse.body);
|
||||
} catch {
|
||||
return "Mock 工具返回值必须是有效 JSON";
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(behavior.mockResponse.delayMs) ||
|
||||
behavior.mockResponse.delayMs < 0 ||
|
||||
behavior.mockResponse.delayMs > TOOL_MOCK_DELAY_MAX_MS
|
||||
) {
|
||||
return "Mock 延迟需为 0–60000 毫秒的整数";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let turnSeq = 1;
|
||||
let behaviorSeq = 1;
|
||||
let criterionSeq = 1;
|
||||
|
||||
function nextTurnId() {
|
||||
const id = `turn_${String(turnSeq).padStart(4, "0")}`;
|
||||
@@ -297,6 +327,40 @@ function nextBehaviorId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
function nextCriterionId() {
|
||||
const id = `criterion_${String(criterionSeq).padStart(4, "0")}`;
|
||||
criterionSeq += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
export function createOverallCriterion(
|
||||
name = "新的评估标准",
|
||||
): OverallCriterion {
|
||||
return {
|
||||
id: nextCriterionId(),
|
||||
type: "llm",
|
||||
name,
|
||||
criteria: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOverallCriteria(
|
||||
values: OverallCriterion[],
|
||||
): OverallCriterion[] {
|
||||
return values.map((value) => ({
|
||||
id: value.id,
|
||||
type: "llm",
|
||||
name: value.name.trim().slice(0, 80),
|
||||
criteria: value.criteria.trim().slice(0, OVERALL_CRITERION_TEXT_MAX),
|
||||
}));
|
||||
}
|
||||
|
||||
export function cloneOverallCriteria(
|
||||
values: OverallCriterion[] | null | undefined,
|
||||
): OverallCriterion[] {
|
||||
return (values ?? []).map((value) => ({ ...value }));
|
||||
}
|
||||
|
||||
export function createEmptyFixedInputTurn(
|
||||
userInput = "",
|
||||
): FixedInputTurn {
|
||||
@@ -304,8 +368,6 @@ export function createEmptyFixedInputTurn(
|
||||
id: nextTurnId(),
|
||||
userInput,
|
||||
behaviors: [],
|
||||
sendTiming: "after_previous_reply",
|
||||
sendDelayMs: 500,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -318,6 +380,7 @@ export function createReplyBehavior(
|
||||
assertionType,
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
negateKeywords: false,
|
||||
llmCriteria: "",
|
||||
};
|
||||
}
|
||||
@@ -331,7 +394,15 @@ export function createToolCallBehavior(input?: {
|
||||
type: "tool_call",
|
||||
toolId: input?.toolId ?? "",
|
||||
functionName: input?.functionName ?? "",
|
||||
expectation: "called",
|
||||
minCalls: 1,
|
||||
maxCalls: null,
|
||||
paramAssertions: [],
|
||||
mockResponse: {
|
||||
outcome: "success",
|
||||
body: '{\n "status": "ok"\n}',
|
||||
delayMs: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -343,6 +414,7 @@ function cloneBehavior(behavior: ExpectedBehavior): ExpectedBehavior {
|
||||
assertionType: behavior.assertionType,
|
||||
keywords: [...behavior.keywords],
|
||||
keywordMatchMode: behavior.keywordMatchMode,
|
||||
negateKeywords: behavior.negateKeywords,
|
||||
llmCriteria: behavior.llmCriteria,
|
||||
};
|
||||
}
|
||||
@@ -351,7 +423,15 @@ function cloneBehavior(behavior: ExpectedBehavior): ExpectedBehavior {
|
||||
type: "tool_call",
|
||||
toolId: behavior.toolId,
|
||||
functionName: behavior.functionName,
|
||||
expectation: behavior.expectation,
|
||||
minCalls: behavior.minCalls,
|
||||
maxCalls: behavior.maxCalls,
|
||||
paramAssertions: behavior.paramAssertions.map((item) => ({ ...item })),
|
||||
mockResponse: {
|
||||
outcome: behavior.mockResponse.outcome,
|
||||
body: behavior.mockResponse.body,
|
||||
delayMs: behavior.mockResponse.delayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,37 +439,11 @@ function cloneBehaviorWithNewId(behavior: ExpectedBehavior): ExpectedBehavior {
|
||||
return { ...cloneBehavior(behavior), id: nextBehaviorId() };
|
||||
}
|
||||
|
||||
function firstReplyBehavior(
|
||||
turns: FixedInputTurn[],
|
||||
): ReplyExpectedBehavior | null {
|
||||
for (const turn of turns) {
|
||||
for (const behavior of turn.behaviors) {
|
||||
if (behavior.type === "reply") return behavior;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从首轮回复预期同步旧字段,供批量测试等读取 */
|
||||
export function legacyFieldsFromTurns(turns: FixedInputTurn[]) {
|
||||
const first = turns[0];
|
||||
const reply = firstReplyBehavior(turns);
|
||||
return {
|
||||
userInput: first?.userInput ?? "",
|
||||
assertionType: reply?.assertionType ?? ("keyword" as AssertionType),
|
||||
keywords: reply ? [...reply.keywords] : ([] as string[]),
|
||||
keywordMatchMode: reply?.keywordMatchMode ?? ("any" as KeywordMatchMode),
|
||||
llmCriteria: reply?.llmCriteria ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneTurns(turns: FixedInputTurn[]): FixedInputTurn[] {
|
||||
return turns.map((turn) => ({
|
||||
id: turn.id,
|
||||
userInput: turn.userInput,
|
||||
behaviors: turn.behaviors.map(cloneBehavior),
|
||||
sendTiming: turn.sendTiming ?? "after_previous_reply",
|
||||
sendDelayMs: turn.sendDelayMs ?? 500,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -399,37 +453,9 @@ function cloneTurnsWithNewIds(turns: FixedInputTurn[]): FixedInputTurn[] {
|
||||
id: nextTurnId(),
|
||||
userInput: turn.userInput,
|
||||
behaviors: turn.behaviors.map(cloneBehaviorWithNewId),
|
||||
sendTiming: turn.sendTiming ?? "after_previous_reply",
|
||||
sendDelayMs: turn.sendDelayMs ?? 500,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 从旧的单轮字段构造一轮(含一条回复预期) */
|
||||
function turnFromLegacyFields(input: {
|
||||
userInput: string;
|
||||
assertionType: AssertionType;
|
||||
keywords: string[];
|
||||
keywordMatchMode: KeywordMatchMode;
|
||||
llmCriteria: string;
|
||||
}): FixedInputTurn {
|
||||
return {
|
||||
id: nextTurnId(),
|
||||
userInput: input.userInput,
|
||||
behaviors: [
|
||||
{
|
||||
id: nextBehaviorId(),
|
||||
type: "reply",
|
||||
assertionType: input.assertionType,
|
||||
keywords: [...input.keywords],
|
||||
keywordMatchMode: input.keywordMatchMode,
|
||||
llmCriteria: input.llmCriteria,
|
||||
},
|
||||
],
|
||||
sendTiming: "after_previous_reply",
|
||||
sendDelayMs: 500,
|
||||
};
|
||||
}
|
||||
|
||||
const INITIAL_SUITES: TestSuite[] = [
|
||||
{
|
||||
id: "suite_001",
|
||||
@@ -454,30 +480,46 @@ const INITIAL_SUITES: TestSuite[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type RawCaseSeed = Omit<
|
||||
TestCase,
|
||||
"sortOrder" | "turns" | "inputMode" | "voiceSettings" | "userSimulation"
|
||||
> & {
|
||||
turns?: FixedInputTurn[];
|
||||
type RawCaseSeed = Omit<TestCase, "sortOrder" | "inputMode" | "overallCriteria"> & {
|
||||
inputMode?: TestCaseInputMode;
|
||||
voiceSettings?: VoiceSettings | null;
|
||||
userSimulation?: UserSimulationConfig | null;
|
||||
overallCriteria?: OverallCriterion[];
|
||||
};
|
||||
|
||||
function seedReplyTurn(
|
||||
seedId: string,
|
||||
userInput: string,
|
||||
expectation: Omit<ReplyExpectedBehavior, "id" | "type">,
|
||||
): FixedInputTurn {
|
||||
return {
|
||||
id: `turn_seed_${seedId}`,
|
||||
userInput,
|
||||
behaviors: [
|
||||
{
|
||||
id: `beh_seed_${seedId}`,
|
||||
type: "reply",
|
||||
...expectation,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const RAW_CASE_SEEDS: RawCaseSeed[] = [
|
||||
{
|
||||
id: "tc_001",
|
||||
suiteId: "suite_001",
|
||||
name: "正常双车事故开场",
|
||||
description: "开场后用户补充事故经过",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "我这里刚刚撞了一下。",
|
||||
assertionType: "keyword",
|
||||
keywords: ["经过", "描述", "受伤"],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
turns: [
|
||||
seedReplyTurn("001", "我这里刚刚撞了一下。", {
|
||||
assertionType: "keyword",
|
||||
keywords: ["经过", "描述", "受伤"],
|
||||
keywordMatchMode: "any",
|
||||
negateKeywords: false,
|
||||
llmCriteria: "",
|
||||
}),
|
||||
],
|
||||
updatedAt: "2026-08-05T10:18:00+08:00",
|
||||
},
|
||||
{
|
||||
@@ -485,20 +527,34 @@ const RAW_CASE_SEEDS: RawCaseSeed[] = [
|
||||
suiteId: "suite_001",
|
||||
name: "有人伤转人工",
|
||||
description: "用户提到人伤时应引导转人工",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "有人受伤了,流血不止。",
|
||||
assertionType: "llm",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria:
|
||||
"Agent 应识别人员受伤风险,明确告知将转接人工,并避免继续推进普通快处流程。",
|
||||
overallExpectation: {
|
||||
type: "llm",
|
||||
criteria:
|
||||
"整段对话应正确识别人员受伤场景,及时并准确转接人工处理,不应继续引导用户进入普通事故快处流程。",
|
||||
},
|
||||
turns: [
|
||||
seedReplyTurn("002", "有人受伤了,流血不止。", {
|
||||
assertionType: "llm",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
negateKeywords: false,
|
||||
llmCriteria:
|
||||
"Agent 应识别人员受伤风险,明确告知将转接人工,并避免继续推进普通快处流程。",
|
||||
}),
|
||||
],
|
||||
overallCriteria: [
|
||||
{
|
||||
id: "criterion_seed_001",
|
||||
type: "llm",
|
||||
name: "人伤流程正确",
|
||||
criteria:
|
||||
"整段对话应正确识别人员受伤场景,及时并准确转接人工处理。",
|
||||
},
|
||||
{
|
||||
id: "criterion_seed_002",
|
||||
type: "llm",
|
||||
name: "不推进普通快处",
|
||||
criteria:
|
||||
"确认存在人员受伤后,不应继续引导用户进入普通事故快处流程。",
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-08-05T10:12:00+08:00",
|
||||
},
|
||||
{
|
||||
@@ -506,14 +562,17 @@ const RAW_CASE_SEEDS: RawCaseSeed[] = [
|
||||
suiteId: "suite_002",
|
||||
name: "用户说“喂”",
|
||||
description: "验证主动唤醒回复且业务状态不推进",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "喂",
|
||||
assertionType: "keyword",
|
||||
keywords: ["我在", "请说", "继续"],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
turns: [
|
||||
seedReplyTurn("101", "喂", {
|
||||
assertionType: "keyword",
|
||||
keywords: ["我在", "请说", "继续"],
|
||||
keywordMatchMode: "any",
|
||||
negateKeywords: false,
|
||||
llmCriteria: "",
|
||||
}),
|
||||
],
|
||||
updatedAt: "2026-08-04T16:35:00+08:00",
|
||||
},
|
||||
{
|
||||
@@ -521,15 +580,18 @@ const RAW_CASE_SEEDS: RawCaseSeed[] = [
|
||||
suiteId: "suite_002",
|
||||
name: "用户只说“嗯”",
|
||||
description: "短促确认不应误推进流程",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "fail",
|
||||
contextTurns: [],
|
||||
userInput: "嗯",
|
||||
assertionType: "llm",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria:
|
||||
"Agent 应当回应用户的主动唤醒,同时继续引导用户描述事故情况,不应该无响应。",
|
||||
turns: [
|
||||
seedReplyTurn("102", "嗯", {
|
||||
assertionType: "llm",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
negateKeywords: false,
|
||||
llmCriteria:
|
||||
"Agent 应当回应用户的主动唤醒,同时继续引导用户描述事故情况,不应该无响应。",
|
||||
}),
|
||||
],
|
||||
updatedAt: "2026-08-04T16:20:00+08:00",
|
||||
},
|
||||
{
|
||||
@@ -537,14 +599,17 @@ const RAW_CASE_SEEDS: RawCaseSeed[] = [
|
||||
suiteId: "suite_002",
|
||||
name: "模糊事故描述",
|
||||
description: "地点含糊时应主动澄清",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "not_run",
|
||||
contextTurns: [],
|
||||
userInput: "就在那边……撞了一下。",
|
||||
assertionType: "keyword",
|
||||
keywords: ["哪里", "路口", "路名", "再说"],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
turns: [
|
||||
seedReplyTurn("103", "就在那边……撞了一下。", {
|
||||
assertionType: "keyword",
|
||||
keywords: ["哪里", "路口", "路名", "再说"],
|
||||
keywordMatchMode: "any",
|
||||
negateKeywords: false,
|
||||
llmCriteria: "",
|
||||
}),
|
||||
],
|
||||
updatedAt: "2026-08-04T15:30:00+08:00",
|
||||
},
|
||||
{
|
||||
@@ -552,48 +617,33 @@ const RAW_CASE_SEEDS: RawCaseSeed[] = [
|
||||
suiteId: "suite_003",
|
||||
name: "用户打断播报",
|
||||
description: "播报中打断后正确切换聆听并承接",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "等一下,对方走了。",
|
||||
assertionType: "llm",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria:
|
||||
"Agent 应立即停止原播报思路,确认已听到用户新信息,并围绕「对方离开」继续询问。",
|
||||
turns: [
|
||||
seedReplyTurn("201", "等一下,对方走了。", {
|
||||
assertionType: "llm",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
negateKeywords: false,
|
||||
llmCriteria:
|
||||
"Agent 应立即停止原播报思路,确认已听到用户新信息,并围绕「对方离开」继续询问。",
|
||||
}),
|
||||
],
|
||||
updatedAt: "2026-08-03T09:10:00+08:00",
|
||||
},
|
||||
];
|
||||
|
||||
/** 按套件出现顺序写入 sortOrder,并补齐 turns */
|
||||
/** 按套件出现顺序写入 sortOrder,并补齐可选字段。 */
|
||||
const INITIAL_CASES: TestCase[] = (() => {
|
||||
const counters = new Map<string, number>();
|
||||
return RAW_CASE_SEEDS.map((item) => {
|
||||
const order = counters.get(item.suiteId) ?? 0;
|
||||
counters.set(item.suiteId, order + 1);
|
||||
const turns =
|
||||
item.turns && item.turns.length > 0
|
||||
? cloneTurns(item.turns)
|
||||
: [
|
||||
turnFromLegacyFields({
|
||||
userInput: item.userInput,
|
||||
assertionType: item.assertionType,
|
||||
keywords: item.keywords,
|
||||
keywordMatchMode: item.keywordMatchMode,
|
||||
llmCriteria: item.llmCriteria,
|
||||
}),
|
||||
];
|
||||
const legacy = legacyFieldsFromTurns(turns);
|
||||
const inputMode = item.inputMode ?? DEFAULT_INPUT_MODE;
|
||||
return {
|
||||
...item,
|
||||
inputMode,
|
||||
kind: kindFromInputMode(inputMode),
|
||||
turns,
|
||||
voiceSettings: cloneVoiceSettings(item.voiceSettings),
|
||||
userSimulation: cloneUserSimulation(item.userSimulation),
|
||||
overallExpectation: cloneOverallExpectation(item.overallExpectation),
|
||||
...legacy,
|
||||
inputMode: item.inputMode ?? DEFAULT_INPUT_MODE,
|
||||
turns: cloneTurns(item.turns),
|
||||
overallCriteria: cloneOverallCriteria(item.overallCriteria),
|
||||
sortOrder: order,
|
||||
};
|
||||
});
|
||||
@@ -631,7 +681,7 @@ export function getTestSuite(id: string): TestSuite | null {
|
||||
return suites.find((item) => item.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function getTestCase(id: string): TestCase | null {
|
||||
function getTestCase(id: string): TestCase | null {
|
||||
return cases.find((item) => item.id === id) ?? null;
|
||||
}
|
||||
|
||||
@@ -707,21 +757,16 @@ export function duplicateTestSuite(id: string): TestSuite | null {
|
||||
const sourceCases = listTestCases(id);
|
||||
const clonedCases: TestCase[] = sourceCases.map((item, index) => {
|
||||
const turns = cloneTurnsWithNewIds(item.turns);
|
||||
const legacy = legacyFieldsFromTurns(turns);
|
||||
return {
|
||||
id: nextCaseId(),
|
||||
suiteId: copied.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
inputMode: item.inputMode,
|
||||
kind: kindFromInputMode(item.inputMode),
|
||||
lastResult: "not_run" as const,
|
||||
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
|
||||
turns,
|
||||
voiceSettings: cloneVoiceSettings(item.voiceSettings),
|
||||
userSimulation: cloneUserSimulation(item.userSimulation),
|
||||
overallExpectation: cloneOverallExpectation(item.overallExpectation),
|
||||
...legacy,
|
||||
overallCriteria: cloneOverallCriteria(item.overallCriteria),
|
||||
sortOrder: index,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
@@ -740,21 +785,16 @@ export function createTestCase(input: {
|
||||
.filter((item) => item.suiteId === input.suiteId)
|
||||
.reduce((max, item) => Math.max(max, item.sortOrder), -1);
|
||||
const turns = [createEmptyFixedInputTurn()];
|
||||
const legacy = legacyFieldsFromTurns(turns);
|
||||
const item: TestCase = {
|
||||
id: nextCaseId(),
|
||||
suiteId: input.suiteId,
|
||||
name: (input.name ?? "未命名用例").trim() || "未命名用例",
|
||||
description: (input.description ?? "").trim(),
|
||||
inputMode: DEFAULT_INPUT_MODE,
|
||||
kind: kindFromInputMode(DEFAULT_INPUT_MODE),
|
||||
lastResult: "not_run",
|
||||
contextTurns: [],
|
||||
turns,
|
||||
voiceSettings: null,
|
||||
userSimulation: null,
|
||||
overallExpectation: null,
|
||||
...legacy,
|
||||
overallCriteria: [],
|
||||
sortOrder: maxOrder + 1,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
@@ -773,21 +813,16 @@ export function duplicateTestCase(id: string): TestCase | null {
|
||||
.reduce((max, item) => Math.max(max, item.sortOrder), -1);
|
||||
|
||||
const turns = cloneTurnsWithNewIds(source.turns);
|
||||
const legacy = legacyFieldsFromTurns(turns);
|
||||
const copied: TestCase = {
|
||||
id: nextCaseId(),
|
||||
suiteId: source.suiteId,
|
||||
name: `${source.name}(副本)`,
|
||||
description: source.description,
|
||||
inputMode: source.inputMode,
|
||||
kind: kindFromInputMode(source.inputMode),
|
||||
lastResult: "not_run",
|
||||
contextTurns: source.contextTurns.map((turn) => ({ ...turn })),
|
||||
turns,
|
||||
voiceSettings: cloneVoiceSettings(source.voiceSettings),
|
||||
userSimulation: cloneUserSimulation(source.userSimulation),
|
||||
overallExpectation: cloneOverallExpectation(source.overallExpectation),
|
||||
...legacy,
|
||||
overallCriteria: cloneOverallCriteria(source.overallCriteria),
|
||||
sortOrder: maxOrder + 1,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
@@ -796,23 +831,15 @@ export function duplicateTestCase(id: string): TestCase | null {
|
||||
return copied;
|
||||
}
|
||||
|
||||
export type TestCasePatch = Partial<
|
||||
type TestCasePatch = Partial<
|
||||
Pick<
|
||||
TestCase,
|
||||
| "name"
|
||||
| "description"
|
||||
| "inputMode"
|
||||
| "kind"
|
||||
| "contextTurns"
|
||||
| "turns"
|
||||
| "voiceSettings"
|
||||
| "userSimulation"
|
||||
| "overallExpectation"
|
||||
| "userInput"
|
||||
| "assertionType"
|
||||
| "keywords"
|
||||
| "keywordMatchMode"
|
||||
| "llmCriteria"
|
||||
| "overallCriteria"
|
||||
| "lastResult"
|
||||
>
|
||||
>;
|
||||
@@ -830,37 +857,15 @@ export function updateTestCase(
|
||||
patch.turns.length > 0 ? patch.turns : [createEmptyFixedInputTurn()],
|
||||
)
|
||||
: current.turns;
|
||||
const legacy =
|
||||
patch.turns !== undefined
|
||||
? legacyFieldsFromTurns(nextTurns)
|
||||
: {
|
||||
userInput: patch.userInput ?? current.userInput,
|
||||
assertionType: patch.assertionType ?? current.assertionType,
|
||||
keywords: patch.keywords ?? current.keywords,
|
||||
keywordMatchMode: patch.keywordMatchMode ?? current.keywordMatchMode,
|
||||
llmCriteria: patch.llmCriteria ?? current.llmCriteria,
|
||||
};
|
||||
const nextOverall =
|
||||
patch.overallExpectation !== undefined
|
||||
? cloneOverallExpectation(patch.overallExpectation)
|
||||
: cloneOverallExpectation(current.overallExpectation);
|
||||
const nextMode = patch.inputMode ?? current.inputMode;
|
||||
const nextOverallCriteria =
|
||||
patch.overallCriteria !== undefined
|
||||
? cloneOverallCriteria(patch.overallCriteria)
|
||||
: cloneOverallCriteria(current.overallCriteria);
|
||||
const next: TestCase = {
|
||||
...current,
|
||||
...patch,
|
||||
inputMode: nextMode,
|
||||
kind: kindFromInputMode(nextMode),
|
||||
turns: nextTurns,
|
||||
voiceSettings:
|
||||
patch.voiceSettings !== undefined
|
||||
? cloneVoiceSettings(patch.voiceSettings)
|
||||
: cloneVoiceSettings(current.voiceSettings),
|
||||
userSimulation:
|
||||
patch.userSimulation !== undefined
|
||||
? cloneUserSimulation(patch.userSimulation)
|
||||
: cloneUserSimulation(current.userSimulation),
|
||||
overallExpectation: nextOverall,
|
||||
...legacy,
|
||||
overallCriteria: nextOverallCriteria,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
cases = [...cases.slice(0, index), next, ...cases.slice(index + 1)];
|
||||
@@ -916,39 +921,6 @@ function renumberSortOrder(suiteId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function formatSuiteResult(suiteId: string): string {
|
||||
const { passed, run, total } = suiteCaseStats(suiteId);
|
||||
if (total === 0) return "—";
|
||||
if (run === 0) return "未运行";
|
||||
return `${passed}/${total}`;
|
||||
}
|
||||
|
||||
export function formatCaseResult(result: TestCaseResult): {
|
||||
label: string;
|
||||
className: string;
|
||||
dotClassName: string | null;
|
||||
} {
|
||||
if (result === "pass") {
|
||||
return {
|
||||
label: "通过",
|
||||
className: "text-emerald-600",
|
||||
dotClassName: "bg-emerald-500",
|
||||
};
|
||||
}
|
||||
if (result === "fail") {
|
||||
return {
|
||||
label: "失败",
|
||||
className: "text-destructive",
|
||||
dotClassName: "bg-destructive",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "未运行",
|
||||
className: "text-muted-soft",
|
||||
dotClassName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUpdatedAt(value?: string | null) {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
|
||||
Reference in New Issue
Block a user