Expand test case fixed-input editor and batch case detail split view.
Unify multi-turn behaviors (reply/tool), overall expectation, and input-mode picker while keeping non-text modes as coming soon; batch completed/running use a half-and-half detail panel below the top bar. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 批量测试用例详情抽屉。
|
||||
* 批量测试用例详情面板。
|
||||
* 与 prompt mode DebugDrawer overlay 相同:作为右侧 flex 半屏,无遮罩/模糊。
|
||||
* 当前展示前端 mock 数据,后续可直接替换为真实执行事件与校验结果。
|
||||
*/
|
||||
|
||||
@@ -12,21 +13,15 @@ import {
|
||||
MessageSquareText,
|
||||
Target,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { BatchCaseStatusBadge } from "@/components/batch-test/batch-case-status";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import type { BatchRunCase } from "@/data/batch-run";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BatchCaseDetailDrawerProps = {
|
||||
item: BatchRunCase | null;
|
||||
item: BatchRunCase;
|
||||
assistantName: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -37,80 +32,72 @@ export function BatchCaseDetailDrawer({
|
||||
onClose,
|
||||
}: BatchCaseDetailDrawerProps) {
|
||||
return (
|
||||
<Sheet
|
||||
open={Boolean(item)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(92vw,620px)] border-hairline bg-card p-0 sm:max-w-[620px]"
|
||||
>
|
||||
{item && (
|
||||
<>
|
||||
<SheetHeader className="border-b border-hairline px-6 py-5 pr-16">
|
||||
<p className="text-xs font-medium tracking-[0.08em] text-muted-soft uppercase">
|
||||
用例详情
|
||||
</p>
|
||||
<SheetTitle className="text-lg leading-7">
|
||||
{item.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
被测助手:{assistantName} · 当前为前端 Mock 结果
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<aside 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
|
||||
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"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-medium text-foreground">
|
||||
{item.name}
|
||||
</h2>
|
||||
<p className="truncate text-xs text-muted-soft">
|
||||
被测助手:{assistantName} · Mock 结果
|
||||
</p>
|
||||
</div>
|
||||
<BatchCaseStatusBadge status={item.status} />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="space-y-6">
|
||||
<section className="flex items-center justify-between rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5">
|
||||
<div>
|
||||
<p className="text-xs text-muted-soft">当前状态</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{statusDescription(item)}
|
||||
</p>
|
||||
</div>
|
||||
<BatchCaseStatusBadge status={item.status} />
|
||||
</section>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5">
|
||||
<p className="text-xs text-muted-soft">当前状态</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{statusDescription(item)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<DetailSection
|
||||
icon={<Target size={16} />}
|
||||
title="预期结果"
|
||||
value={item.expected}
|
||||
/>
|
||||
<DetailSection
|
||||
icon={<Target size={16} />}
|
||||
title="预期结果"
|
||||
value={item.expected}
|
||||
/>
|
||||
|
||||
{(item.status === "waiting" || item.status === "running") && (
|
||||
<ExecutionTimeline status={item.status} />
|
||||
)}
|
||||
{(item.status === "waiting" || item.status === "running") && (
|
||||
<ExecutionTimeline status={item.status} />
|
||||
)}
|
||||
|
||||
{(item.status === "pass" || item.status === "fail") && (
|
||||
<DetailSection
|
||||
icon={<MessageSquareText size={16} />}
|
||||
title="实际回复"
|
||||
value={item.actual}
|
||||
/>
|
||||
)}
|
||||
{(item.status === "pass" || item.status === "fail") && (
|
||||
<DetailSection
|
||||
icon={<MessageSquareText size={16} />}
|
||||
title="实际回复"
|
||||
value={item.actual}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.status === "fail" && item.failReason && (
|
||||
<DetailSection
|
||||
icon={<TriangleAlert size={16} />}
|
||||
title="失败原因(LLM 判断)"
|
||||
value={item.failReason}
|
||||
tone="destructive"
|
||||
/>
|
||||
)}
|
||||
{item.status === "fail" && item.failReason && (
|
||||
<DetailSection
|
||||
icon={<TriangleAlert size={16} />}
|
||||
title="失败原因(LLM 判断)"
|
||||
value={item.failReason}
|
||||
tone="destructive"
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.status === "skipped" && (
|
||||
<div className="rounded-2xl border border-dashed border-hairline-strong px-4 py-4 text-sm leading-6 text-muted-foreground">
|
||||
该用例未执行,因此没有实际回复与判断结果。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.status === "skipped" && (
|
||||
<div className="rounded-2xl border border-dashed border-hairline-strong px-4 py-4 text-sm leading-6 text-muted-foreground">
|
||||
该用例未执行,因此没有实际回复与判断结果。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
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;
|
||||
@@ -31,129 +32,140 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
|
||||
const failRate = judged === 0 ? 0 : 100 - passRate;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
|
||||
{/* 结果总览 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-medium text-foreground">{run.title}</h2>
|
||||
<BatchPhasePill phase="completed" />
|
||||
</div>
|
||||
{run.finishedAt && (
|
||||
<p className="mt-1.5 text-xs text-muted-soft">
|
||||
完成时间:{formatRunTime(run.finishedAt)}
|
||||
{run.stopped ? " · 已手动停止" : ""}
|
||||
</p>
|
||||
<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">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4",
|
||||
detailOpen ? "w-full" : "mx-auto w-full max-w-[960px]",
|
||||
)}
|
||||
|
||||
<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">
|
||||
{counts.pass} / {judged || total} 通过
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-success">
|
||||
通过率 {passRate}%
|
||||
</div>
|
||||
>
|
||||
{/* 结果总览 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-medium text-foreground">
|
||||
{run.title}
|
||||
</h2>
|
||||
<BatchPhasePill phase="completed" />
|
||||
</div>
|
||||
{run.finishedAt && (
|
||||
<p className="mt-1.5 text-xs text-muted-soft">
|
||||
完成时间:{formatRunTime(run.finishedAt)}
|
||||
{run.stopped ? " · 已手动停止" : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PassRateDonut pass={counts.pass} fail={counts.fail} />
|
||||
<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">
|
||||
{counts.pass} / {judged || total} 通过
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-success">
|
||||
通过率 {passRate}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<LegendRow
|
||||
tone="success"
|
||||
label="通过"
|
||||
count={counts.pass}
|
||||
percent={passRate}
|
||||
/>
|
||||
<LegendRow
|
||||
tone="destructive"
|
||||
label="失败"
|
||||
count={counts.fail}
|
||||
percent={failRate}
|
||||
/>
|
||||
{counts.skipped > 0 && (
|
||||
<PassRateDonut pass={counts.pass} fail={counts.fail} />
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<LegendRow
|
||||
tone="muted"
|
||||
label="未执行"
|
||||
count={counts.skipped}
|
||||
percent={
|
||||
total === 0
|
||||
? 0
|
||||
: Math.round((counts.skipped / total) * 100)
|
||||
}
|
||||
tone="success"
|
||||
label="通过"
|
||||
count={counts.pass}
|
||||
percent={passRate}
|
||||
/>
|
||||
)}
|
||||
<LegendRow
|
||||
tone="destructive"
|
||||
label="失败"
|
||||
count={counts.fail}
|
||||
percent={failRate}
|
||||
/>
|
||||
{counts.skipped > 0 && (
|
||||
<LegendRow
|
||||
tone="muted"
|
||||
label="未执行"
|
||||
count={counts.skipped}
|
||||
percent={
|
||||
total === 0
|
||||
? 0
|
||||
: Math.round((counts.skipped / total) * 100)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{/* 用例列表 */}
|
||||
<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>
|
||||
{/* 用例列表 */}
|
||||
<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>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline text-xs text-muted-soft">
|
||||
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
||||
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
||||
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
||||
<th className="w-24 px-3 py-2.5 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.cases.map((item, index) => {
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-hairline last:border-b-0"
|
||||
>
|
||||
<td colSpan={4} className="p-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCaseId(item.id)}
|
||||
aria-haspopup="dialog"
|
||||
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}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline text-xs text-muted-soft">
|
||||
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
||||
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
||||
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
||||
<th className="w-24 px-3 py-2.5 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.cases.map((item, index) => {
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-hairline last:border-b-0"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchCaseDetailDrawer
|
||||
item={selectedCase}
|
||||
assistantName={run.assistantName}
|
||||
onClose={() => setSelectedCaseId(null)}
|
||||
/>
|
||||
</>
|
||||
{selectedCase && (
|
||||
<BatchCaseDetailDrawer
|
||||
item={selectedCase}
|
||||
assistantName={run.assistantName}
|
||||
onClose={() => setSelectedCaseId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,118 +22,128 @@ export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
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 total = run.cases.length;
|
||||
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
|
||||
{/* 进度总览 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-medium text-foreground">
|
||||
{run.title}
|
||||
</h2>
|
||||
<BatchPhasePill phase="running" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<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">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4",
|
||||
detailOpen ? "w-full" : "mx-auto w-full max-w-[960px]",
|
||||
)}
|
||||
>
|
||||
{/* 进度总览 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-medium text-foreground">
|
||||
{run.title}
|
||||
</h2>
|
||||
<BatchPhasePill phase="running" />
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||
<span className="text-foreground">
|
||||
{done} / {total}
|
||||
</span>{" "}
|
||||
已完成
|
||||
<span className="ml-2 text-foreground">{percent}%</span>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||
<span className="text-foreground">
|
||||
{done} / {total}
|
||||
</span>{" "}
|
||||
已完成
|
||||
<span className="ml-2 text-foreground">{percent}%</span>
|
||||
</div>
|
||||
</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">
|
||||
<Stat label="通过" value={counts.pass} tone="success" />
|
||||
<Stat label="失败" value={counts.fail} tone="destructive" />
|
||||
<Stat label="运行中" value={counts.running} tone="primary" />
|
||||
<Stat label="等待" value={counts.waiting} tone="muted" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 用例列表 */}
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<Stat label="通过" value={counts.pass} tone="success" />
|
||||
<Stat label="失败" value={counts.fail} tone="destructive" />
|
||||
<Stat label="运行中" value={counts.running} tone="primary" />
|
||||
<Stat label="等待" value={counts.waiting} tone="muted" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 用例列表 */}
|
||||
<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>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline text-xs text-muted-soft">
|
||||
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
||||
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
||||
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
||||
<th className="w-24 px-3 py-2.5 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.cases.map((item, index) => {
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-hairline last:border-b-0"
|
||||
>
|
||||
<td colSpan={4} className="p-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCaseId(item.id)}
|
||||
aria-haspopup="dialog"
|
||||
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}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline text-xs text-muted-soft">
|
||||
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
||||
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
||||
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
||||
<th className="w-24 px-3 py-2.5 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.cases.map((item, index) => {
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-hairline last:border-b-0"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchCaseDetailDrawer
|
||||
item={selectedCase}
|
||||
assistantName={run.assistantName}
|
||||
onClose={() => setSelectedCaseId(null)}
|
||||
/>
|
||||
</>
|
||||
{selectedCase && (
|
||||
<BatchCaseDetailDrawer
|
||||
item={selectedCase}
|
||||
assistantName={run.assistantName}
|
||||
onClose={() => setSelectedCaseId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,14 +159,14 @@ function Stat({
|
||||
const toneClass = {
|
||||
success: "text-success",
|
||||
destructive: "text-destructive",
|
||||
primary: "text-primary",
|
||||
primary: "text-foreground",
|
||||
muted: "text-muted-foreground",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className="min-w-[4.5rem]">
|
||||
<div>
|
||||
<div className="text-xs text-muted-soft">{label}</div>
|
||||
<div className={cn("mt-0.5 text-xl font-medium tabular-nums", toneClass)}>
|
||||
<div className={cn("mt-0.5 text-lg font-medium tabular-nums", toneClass)}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ export function ListPageLayout({
|
||||
topbarAction,
|
||||
children,
|
||||
className,
|
||||
/** full-bleed:占满 top bar 下方区域,供左右分屏等布局使用 */
|
||||
contentMode = "list",
|
||||
}: {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
@@ -21,8 +23,10 @@ export function ListPageLayout({
|
||||
topbarAction?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentMode?: "list" | "full-bleed";
|
||||
}) {
|
||||
const hasHeader = Boolean(description || action);
|
||||
const isFullBleed = contentMode === "full-bleed";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -31,13 +35,15 @@ export function ListPageLayout({
|
||||
</TopbarPortal>
|
||||
|
||||
<div
|
||||
data-app-content="list"
|
||||
data-app-content={isFullBleed ? "full-bleed" : "list"}
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-[1440px] flex-col gap-4",
|
||||
isFullBleed
|
||||
? "flex h-full min-h-0 w-full flex-col overflow-hidden"
|
||||
: "mx-auto flex w-full max-w-[1440px] flex-col gap-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasHeader && (
|
||||
{hasHeader && !isFullBleed && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4",
|
||||
|
||||
@@ -374,8 +374,7 @@ export function BatchTestPage() {
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="批量测试 / 运行中"
|
||||
description="批量运行测试用例,实时查看执行进度与结果。"
|
||||
className="max-w-[960px]"
|
||||
contentMode="full-bleed"
|
||||
topbarAction={
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -396,8 +395,7 @@ export function BatchTestPage() {
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="批量测试 / 已完成"
|
||||
description="批量测试已完成,您可以查看结果或重新运行。"
|
||||
className="max-w-[960px]"
|
||||
contentMode="full-bleed"
|
||||
topbarAction={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/**
|
||||
* 测试用例管理:
|
||||
* - 列表页:Test Suite 一级容器
|
||||
* - 详情页:左列表 + 右「单步回复」编辑器(只编辑,不运行)
|
||||
* - 详情页:左列表 + 右编辑器(固定脚本 / 用户模拟,只编辑不运行)
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ListPageSection,
|
||||
} from "@/components/layout/list-page-layout";
|
||||
import { TopbarPortal } from "@/components/layout/topbar-portal";
|
||||
import { InputModePicker } from "@/components/test-cases/input-mode-picker";
|
||||
import {
|
||||
NextReplyEditorBody,
|
||||
type CaseEditorDraft,
|
||||
@@ -55,25 +56,36 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
cloneOverallExpectation,
|
||||
cloneTurns,
|
||||
cloneUserSimulation,
|
||||
cloneVoiceSettings,
|
||||
createDefaultUserSimulation,
|
||||
createDefaultVoiceSettings,
|
||||
createEmptyFixedInputTurn,
|
||||
createTestCase,
|
||||
createTestSuite,
|
||||
DEFAULT_INPUT_MODE,
|
||||
duplicateTestCase,
|
||||
duplicateTestSuite,
|
||||
getTestSuite,
|
||||
isFixedScriptMode,
|
||||
isVoiceMode,
|
||||
kindFromInputMode,
|
||||
listTestCases,
|
||||
listTestSuites,
|
||||
normalizeOverallExpectation,
|
||||
removeTestCase,
|
||||
removeTestCases,
|
||||
removeTestSuite,
|
||||
reorderTestCases,
|
||||
suiteCaseStats,
|
||||
SUPPORTED_TEST_CASE_KIND,
|
||||
TEST_CASE_KIND_LABEL,
|
||||
TEST_CASE_KIND_OPTIONS,
|
||||
TEST_CASE_INPUT_MODE_LABEL,
|
||||
TEST_CASE_INPUT_MODE_SHORT_LABEL,
|
||||
updateTestCase,
|
||||
updateTestSuite,
|
||||
type TestCase,
|
||||
type TestCaseKind,
|
||||
type TestCaseInputMode,
|
||||
type TestSuite,
|
||||
} from "@/data/test-suites";
|
||||
import { assistantsApi, type Assistant } from "@/lib/api";
|
||||
@@ -147,7 +159,7 @@ function SuiteListView() {
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="测试用例"
|
||||
description="管理用于助手调试的单步回复测试场景。"
|
||||
description="管理用于助手调试的固定输入测试场景。"
|
||||
action={
|
||||
<Button
|
||||
className="w-full shrink-0 gap-2 sm:w-auto"
|
||||
@@ -336,7 +348,7 @@ function SuiteCreateView() {
|
||||
<ListPageLayout
|
||||
title="新建测试集"
|
||||
className="max-w-[1180px]"
|
||||
description="测试集是单步回复用例的业务分组。确认后进入用例编辑。"
|
||||
description="测试集是固定输入用例的业务分组。确认后进入用例编辑。"
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -428,13 +440,13 @@ function SuiteCreateView() {
|
||||
function caseToDraft(item: TestCase): CaseEditorDraft {
|
||||
return {
|
||||
name: item.name,
|
||||
inputMode: item.inputMode,
|
||||
kind: item.kind,
|
||||
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
|
||||
userInput: item.userInput,
|
||||
assertionType: item.assertionType,
|
||||
keywords: [...item.keywords],
|
||||
keywordMatchMode: item.keywordMatchMode,
|
||||
llmCriteria: item.llmCriteria,
|
||||
turns: cloneTurns(item.turns),
|
||||
voiceSettings: cloneVoiceSettings(item.voiceSettings),
|
||||
userSimulation: cloneUserSimulation(item.userSimulation),
|
||||
overallExpectation: cloneOverallExpectation(item.overallExpectation),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -445,16 +457,34 @@ function draftsEqual(a: CaseEditorDraft, b: CaseEditorDraft) {
|
||||
function createEmptyCaseDraft(): CaseEditorDraft {
|
||||
return {
|
||||
name: "未命名用例",
|
||||
kind: SUPPORTED_TEST_CASE_KIND,
|
||||
inputMode: DEFAULT_INPUT_MODE,
|
||||
kind: kindFromInputMode(DEFAULT_INPUT_MODE),
|
||||
contextTurns: [],
|
||||
userInput: "",
|
||||
assertionType: "keyword",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
turns: [createEmptyFixedInputTurn()],
|
||||
voiceSettings: null,
|
||||
userSimulation: null,
|
||||
overallExpectation: null,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
const router = useRouter();
|
||||
const [initial] = useState(() => {
|
||||
@@ -516,7 +546,10 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return cases.filter((item) => {
|
||||
if (!keyword) return true;
|
||||
return [item.name, TEST_CASE_KIND_LABEL[item.kind]]
|
||||
return [
|
||||
item.name,
|
||||
TEST_CASE_INPUT_MODE_SHORT_LABEL[item.inputMode],
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
@@ -554,13 +587,15 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
if (!draft) return;
|
||||
const patch = {
|
||||
name: draft.name.trim() || "未命名用例",
|
||||
kind: draft.kind,
|
||||
inputMode: draft.inputMode,
|
||||
kind: kindFromInputMode(draft.inputMode),
|
||||
contextTurns: draft.contextTurns,
|
||||
userInput: draft.userInput,
|
||||
assertionType: draft.assertionType,
|
||||
keywords: draft.keywords,
|
||||
keywordMatchMode: draft.keywordMatchMode,
|
||||
llmCriteria: draft.llmCriteria,
|
||||
turns: draft.turns,
|
||||
voiceSettings: draft.voiceSettings,
|
||||
userSimulation: draft.userSimulation,
|
||||
overallExpectation: normalizeOverallExpectation(
|
||||
draft.overallExpectation?.criteria,
|
||||
),
|
||||
};
|
||||
|
||||
let saved: TestCase | null;
|
||||
@@ -766,35 +801,10 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
allowEmpty
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={draft.kind}
|
||||
onValueChange={(value) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
kind: value as TestCaseKind,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[132px] shrink-0 border-hairline-strong bg-background text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TEST_CASE_KIND_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={!option.available}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{!option.available && (
|
||||
<span className="ml-auto text-[11px] font-normal text-muted-soft">
|
||||
即将支持
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputModePicker
|
||||
value={draft.inputMode}
|
||||
onChange={(mode) => setDraft(applyInputMode(draft, mode))}
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{dirty ? (
|
||||
@@ -831,15 +841,15 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.kind === SUPPORTED_TEST_CASE_KIND ? (
|
||||
{draft.inputMode === "fixed_script_text" ? (
|
||||
<NextReplyEditorBody draft={draft} onChange={setDraft} />
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{TEST_CASE_KIND_LABEL[draft.kind]} · 开发中
|
||||
{TEST_CASE_INPUT_MODE_LABEL[draft.inputMode]} · 开发中
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
|
||||
当前仅支持「单步回复」编辑器,其它类型稍后接入。
|
||||
当前仅支持「固定脚本 · 文字」编辑器,其它输入模式稍后接入。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
185
frontend/src/components/test-cases/input-mode-picker.tsx
Normal file
185
frontend/src/components/test-cases/input-mode-picker.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 测试用例顶部紧凑模式选择器:固定脚本 / 用户模拟。
|
||||
*/
|
||||
|
||||
import {
|
||||
AudioLines,
|
||||
Check,
|
||||
ChevronDown,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
Type,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
TEST_CASE_INPUT_MODE_LABEL,
|
||||
type TestCaseInputMode,
|
||||
} from "@/data/test-suites";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const FIXED_SCRIPT_OPTIONS: {
|
||||
value: TestCaseInputMode;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof Type;
|
||||
available: boolean;
|
||||
}[] = [
|
||||
{
|
||||
value: "fixed_script_text",
|
||||
title: "文字",
|
||||
description: "使用固定文字逐轮测试",
|
||||
icon: Type,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
value: "fixed_script_turn_voice",
|
||||
title: "逐轮语音",
|
||||
description: "固定文字自动合成为语音",
|
||||
icon: Volume2,
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
value: "fixed_script_continuous_voice",
|
||||
title: "连续语音",
|
||||
description: "按指定时序连续发送语音",
|
||||
icon: AudioLines,
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
const USER_SIM_OPTIONS: {
|
||||
value: TestCaseInputMode;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof MessageSquareText;
|
||||
available: boolean;
|
||||
}[] = [
|
||||
{
|
||||
value: "user_sim_text",
|
||||
title: "文字",
|
||||
description: "使用模型模拟文字用户",
|
||||
icon: MessageSquareText,
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
value: "user_sim_voice",
|
||||
title: "语音",
|
||||
description: "使用模型模拟语音用户",
|
||||
icon: Mic,
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function InputModePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: TestCaseInputMode;
|
||||
onChange: (mode: TestCaseInputMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 max-w-[220px] shrink-0 gap-1.5 rounded-full border-hairline-strong bg-background px-3 text-xs font-normal text-foreground"
|
||||
>
|
||||
<span className="truncate">{TEST_CASE_INPUT_MODE_LABEL[value]}</span>
|
||||
<ChevronDown size={14} className="shrink-0 text-muted-soft" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72 p-1.5">
|
||||
<DropdownMenuLabel className="px-2.5 py-1.5 text-[11px] font-medium tracking-wide text-muted-soft uppercase">
|
||||
固定脚本
|
||||
</DropdownMenuLabel>
|
||||
{FIXED_SCRIPT_OPTIONS.map((option) => (
|
||||
<ModeMenuItem
|
||||
key={option.value}
|
||||
selected={value === option.value}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
icon={option.icon}
|
||||
available={option.available}
|
||||
onSelect={() => onChange(option.value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator className="my-1.5" />
|
||||
|
||||
<DropdownMenuLabel className="px-2.5 py-1.5 text-[11px] font-medium tracking-wide text-muted-soft uppercase">
|
||||
用户模拟
|
||||
</DropdownMenuLabel>
|
||||
{USER_SIM_OPTIONS.map((option) => (
|
||||
<ModeMenuItem
|
||||
key={option.value}
|
||||
selected={value === option.value}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
icon={option.icon}
|
||||
available={option.available}
|
||||
onSelect={() => onChange(option.value)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeMenuItem({
|
||||
selected,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
available,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof Type;
|
||||
available: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
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",
|
||||
selected && "bg-surface-strong/80",
|
||||
)}
|
||||
>
|
||||
<Icon size={15} className="mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{title}
|
||||
{selected && <Check size={13} className="text-muted-foreground" />}
|
||||
{!available && (
|
||||
<span className="ml-auto text-[11px] font-normal text-muted-soft">
|
||||
即将支持
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-4 text-muted-soft">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import {
|
||||
TEST_CASE_KIND_LABEL,
|
||||
TEST_CASE_INPUT_MODE_SHORT_LABEL,
|
||||
type TestCase,
|
||||
} from "@/data/test-suites";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -340,7 +340,7 @@ function SortableCaseCard({
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="inline-flex h-5 shrink-0 items-center rounded-full border border-hairline bg-background px-2 text-[11px] text-muted-foreground">
|
||||
{TEST_CASE_KIND_LABEL[item.kind]}
|
||||
{TEST_CASE_INPUT_MODE_SHORT_LABEL[item.inputMode]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,15 +51,17 @@ function SheetContent({
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
showOverlay = true,
|
||||
overlayClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
showOverlay?: boolean
|
||||
overlayClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
{showOverlay && <SheetOverlay />}
|
||||
{showOverlay && <SheetOverlay className={overlayClassName} />}
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* 测试集 / 测试用例 — 管理页用的本地 mock。
|
||||
* 两层:Test Suite → Test Case。
|
||||
* 第一版只支持「单步回复 / Next Reply Test」:只编辑,不运行。
|
||||
* 输入模式:固定脚本(文字 / 逐轮语音 / 连续语音)与用户模拟(文字 / 语音)。
|
||||
*/
|
||||
|
||||
export type TestCaseKind =
|
||||
| "next_reply"
|
||||
| "tool_call"
|
||||
| "fixed_dialogue"
|
||||
| "user_simulation"
|
||||
| "voice_run";
|
||||
/** 顶部模式选择器的唯一来源;不再单独维护「测试类型」字段 */
|
||||
export type TestCaseInputMode =
|
||||
| "fixed_script_text"
|
||||
| "fixed_script_turn_voice"
|
||||
| "fixed_script_continuous_voice"
|
||||
| "user_sim_text"
|
||||
| "user_sim_voice";
|
||||
|
||||
/** 列表/兼容用粗粒度种类(由 inputMode 推导) */
|
||||
export type TestCaseKind = "fixed_dialogue" | "user_simulation";
|
||||
|
||||
export type TestCaseResult = "pass" | "fail" | "not_run";
|
||||
|
||||
@@ -22,6 +26,80 @@ export type ContextTurn = {
|
||||
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;
|
||||
};
|
||||
|
||||
/** 工具参数校验方式(未配置 = 不参与断言) */
|
||||
export type ToolParamMatchMode = "exact" | "regex" | "llm";
|
||||
|
||||
export type ToolParamAssertion = {
|
||||
name: string;
|
||||
matchMode: ToolParamMatchMode;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/** 预期行为:回复要求 */
|
||||
export type ReplyExpectedBehavior = {
|
||||
id: string;
|
||||
type: "reply";
|
||||
assertionType: AssertionType;
|
||||
keywords: string[];
|
||||
keywordMatchMode: KeywordMatchMode;
|
||||
llmCriteria: string;
|
||||
};
|
||||
|
||||
/** 预期行为:工具调用(至少调用一次指定工具) */
|
||||
export type ToolCallExpectedBehavior = {
|
||||
id: string;
|
||||
type: "tool_call";
|
||||
toolId: string;
|
||||
functionName: string;
|
||||
/** 仅包含用户主动配置的参数;未列出的参数不校验 */
|
||||
paramAssertions: ToolParamAssertion[];
|
||||
};
|
||||
|
||||
export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior;
|
||||
|
||||
/**
|
||||
* 固定输入的一轮:只编辑 User 输入;Agent 回复 / Tool Call 由运行时生成。
|
||||
* behaviors 为 0~N 个预期行为(AND);空数组表示仅推动对话、不做断言。
|
||||
*/
|
||||
export type FixedInputTurn = {
|
||||
id: string;
|
||||
userInput: string;
|
||||
behaviors: ExpectedBehavior[];
|
||||
/** 连续语音:发送时机(其它模式可忽略) */
|
||||
sendTiming?: TurnSendTiming;
|
||||
/** 连续语音:延迟毫秒(仅部分时机需要) */
|
||||
sendDelayMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 整段对话结束后的业务目标断言(可选,独立于每轮 behaviors)。
|
||||
* MVP 仅支持 LLM 判断。
|
||||
*/
|
||||
export type OverallExpectation = {
|
||||
type: "llm";
|
||||
criteria: string;
|
||||
};
|
||||
|
||||
export type TestSuite = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -36,50 +114,322 @@ export type TestCase = {
|
||||
suiteId: string;
|
||||
name: string;
|
||||
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;
|
||||
/** LLM 判断时的判断标准 */
|
||||
llmCriteria: string;
|
||||
/** 套件内排序,越小越靠前 */
|
||||
sortOrder: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/** 第一版仅「单步回复」可编辑,其余类型显示开发中 */
|
||||
export const SUPPORTED_TEST_CASE_KIND: TestCaseKind = "next_reply";
|
||||
export const DEFAULT_INPUT_MODE: TestCaseInputMode = "fixed_script_text";
|
||||
|
||||
export const TEST_CASE_KIND_LABEL: Record<TestCaseKind, string> = {
|
||||
next_reply: "单步回复",
|
||||
tool_call: "工具调用",
|
||||
fixed_dialogue: "固定对话",
|
||||
user_simulation: "用户模拟",
|
||||
voice_run: "语音运行",
|
||||
export function kindFromInputMode(mode: TestCaseInputMode): TestCaseKind {
|
||||
return mode === "user_sim_text" || mode === "user_sim_voice"
|
||||
? "user_simulation"
|
||||
: "fixed_dialogue";
|
||||
}
|
||||
|
||||
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 const TEST_CASE_INPUT_MODE_LABEL: Record<TestCaseInputMode, string> = {
|
||||
fixed_script_text: "固定脚本 · 文字",
|
||||
fixed_script_turn_voice: "固定脚本 · 逐轮语音",
|
||||
fixed_script_continuous_voice: "固定脚本 · 连续语音",
|
||||
user_sim_text: "用户模拟 · 文字",
|
||||
user_sim_voice: "用户模拟 · 语音",
|
||||
};
|
||||
|
||||
export const TEST_CASE_KIND_OPTIONS: {
|
||||
value: TestCaseKind;
|
||||
label: string;
|
||||
available: boolean;
|
||||
}[] = [
|
||||
{ value: "next_reply", label: "单步回复", available: true },
|
||||
{ value: "tool_call", label: "工具调用", available: false },
|
||||
{ value: "fixed_dialogue", label: "固定对话", available: false },
|
||||
{ value: "user_simulation", label: "用户模拟", available: false },
|
||||
{ value: "voice_run", label: "语音运行", available: false },
|
||||
];
|
||||
/** 列表徽章用短标签 */
|
||||
export const TEST_CASE_INPUT_MODE_SHORT_LABEL: Record<
|
||||
TestCaseInputMode,
|
||||
string
|
||||
> = {
|
||||
fixed_script_text: "固定文字",
|
||||
fixed_script_turn_voice: "逐轮语音",
|
||||
fixed_script_continuous_voice: "连续语音",
|
||||
user_sim_text: "模拟文字",
|
||||
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 判断",
|
||||
};
|
||||
|
||||
export const KEYWORD_MATCH_MODE_LABEL: Record<KeywordMatchMode, string> = {
|
||||
any: "任一匹配",
|
||||
all: "全部匹配",
|
||||
};
|
||||
|
||||
export const TOOL_PARAM_MATCH_MODE_LABEL: Record<ToolParamMatchMode, string> = {
|
||||
exact: "精确匹配",
|
||||
regex: "正则匹配",
|
||||
llm: "LLM 判断",
|
||||
};
|
||||
|
||||
export const OVERALL_EXPECTATION_CRITERIA_MAX = 2000;
|
||||
|
||||
/** 空文本视为未配置 */
|
||||
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 cloneOverallExpectation(
|
||||
value: OverallExpectation | null | undefined,
|
||||
): OverallExpectation | null {
|
||||
if (!value?.criteria.trim()) return null;
|
||||
return { type: "llm", criteria: value.criteria };
|
||||
}
|
||||
|
||||
let turnSeq = 1;
|
||||
let behaviorSeq = 1;
|
||||
|
||||
function nextTurnId() {
|
||||
const id = `turn_${String(turnSeq).padStart(4, "0")}`;
|
||||
turnSeq += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
function nextBehaviorId() {
|
||||
const id = `beh_${String(behaviorSeq).padStart(4, "0")}`;
|
||||
behaviorSeq += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
export function createEmptyFixedInputTurn(
|
||||
userInput = "",
|
||||
): FixedInputTurn {
|
||||
return {
|
||||
id: nextTurnId(),
|
||||
userInput,
|
||||
behaviors: [],
|
||||
sendTiming: "after_previous_reply",
|
||||
sendDelayMs: 500,
|
||||
};
|
||||
}
|
||||
|
||||
export function createReplyBehavior(
|
||||
assertionType: AssertionType = "llm",
|
||||
): ReplyExpectedBehavior {
|
||||
return {
|
||||
id: nextBehaviorId(),
|
||||
type: "reply",
|
||||
assertionType,
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function createToolCallBehavior(input?: {
|
||||
toolId?: string;
|
||||
functionName?: string;
|
||||
}): ToolCallExpectedBehavior {
|
||||
return {
|
||||
id: nextBehaviorId(),
|
||||
type: "tool_call",
|
||||
toolId: input?.toolId ?? "",
|
||||
functionName: input?.functionName ?? "",
|
||||
paramAssertions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function cloneBehavior(behavior: ExpectedBehavior): ExpectedBehavior {
|
||||
if (behavior.type === "reply") {
|
||||
return {
|
||||
id: behavior.id,
|
||||
type: "reply",
|
||||
assertionType: behavior.assertionType,
|
||||
keywords: [...behavior.keywords],
|
||||
keywordMatchMode: behavior.keywordMatchMode,
|
||||
llmCriteria: behavior.llmCriteria,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: behavior.id,
|
||||
type: "tool_call",
|
||||
toolId: behavior.toolId,
|
||||
functionName: behavior.functionName,
|
||||
paramAssertions: behavior.paramAssertions.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 新建用例时复制轮次并分配新 id */
|
||||
function cloneTurnsWithNewIds(turns: FixedInputTurn[]): FixedInputTurn[] {
|
||||
return turns.map((turn) => ({
|
||||
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",
|
||||
@@ -104,13 +454,23 @@ const INITIAL_SUITES: TestSuite[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
|
||||
type RawCaseSeed = Omit<
|
||||
TestCase,
|
||||
"sortOrder" | "turns" | "inputMode" | "voiceSettings" | "userSimulation"
|
||||
> & {
|
||||
turns?: FixedInputTurn[];
|
||||
inputMode?: TestCaseInputMode;
|
||||
voiceSettings?: VoiceSettings | null;
|
||||
userSimulation?: UserSimulationConfig | null;
|
||||
};
|
||||
|
||||
const RAW_CASE_SEEDS: RawCaseSeed[] = [
|
||||
{
|
||||
id: "tc_001",
|
||||
suiteId: "suite_001",
|
||||
name: "正常双车事故开场",
|
||||
description: "开场后用户补充事故经过",
|
||||
kind: "next_reply",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "我这里刚刚撞了一下。",
|
||||
@@ -125,7 +485,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
|
||||
suiteId: "suite_001",
|
||||
name: "有人伤转人工",
|
||||
description: "用户提到人伤时应引导转人工",
|
||||
kind: "next_reply",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "有人受伤了,流血不止。",
|
||||
@@ -134,6 +494,11 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria:
|
||||
"Agent 应识别人员受伤风险,明确告知将转接人工,并避免继续推进普通快处流程。",
|
||||
overallExpectation: {
|
||||
type: "llm",
|
||||
criteria:
|
||||
"整段对话应正确识别人员受伤场景,及时并准确转接人工处理,不应继续引导用户进入普通事故快处流程。",
|
||||
},
|
||||
updatedAt: "2026-08-05T10:12:00+08:00",
|
||||
},
|
||||
{
|
||||
@@ -141,7 +506,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
|
||||
suiteId: "suite_002",
|
||||
name: "用户说“喂”",
|
||||
description: "验证主动唤醒回复且业务状态不推进",
|
||||
kind: "next_reply",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "喂",
|
||||
@@ -156,7 +521,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
|
||||
suiteId: "suite_002",
|
||||
name: "用户只说“嗯”",
|
||||
description: "短促确认不应误推进流程",
|
||||
kind: "next_reply",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "fail",
|
||||
contextTurns: [],
|
||||
userInput: "嗯",
|
||||
@@ -172,7 +537,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
|
||||
suiteId: "suite_002",
|
||||
name: "模糊事故描述",
|
||||
description: "地点含糊时应主动澄清",
|
||||
kind: "next_reply",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "not_run",
|
||||
contextTurns: [],
|
||||
userInput: "就在那边……撞了一下。",
|
||||
@@ -187,7 +552,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
|
||||
suiteId: "suite_003",
|
||||
name: "用户打断播报",
|
||||
description: "播报中打断后正确切换聆听并承接",
|
||||
kind: "next_reply",
|
||||
kind: "fixed_dialogue",
|
||||
lastResult: "pass",
|
||||
contextTurns: [],
|
||||
userInput: "等一下,对方走了。",
|
||||
@@ -200,13 +565,37 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** 按套件出现顺序写入 sortOrder */
|
||||
/** 按套件出现顺序写入 sortOrder,并补齐 turns */
|
||||
const INITIAL_CASES: TestCase[] = (() => {
|
||||
const counters = new Map<string, number>();
|
||||
return RAW_CASES.map((item) => {
|
||||
return RAW_CASE_SEEDS.map((item) => {
|
||||
const order = counters.get(item.suiteId) ?? 0;
|
||||
counters.set(item.suiteId, order + 1);
|
||||
return { ...item, sortOrder: order };
|
||||
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,
|
||||
sortOrder: order,
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -316,22 +705,27 @@ export function duplicateTestSuite(id: string): TestSuite | null {
|
||||
});
|
||||
|
||||
const sourceCases = listTestCases(id);
|
||||
const clonedCases: TestCase[] = sourceCases.map((item, index) => ({
|
||||
id: nextCaseId(),
|
||||
suiteId: copied.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
kind: item.kind,
|
||||
lastResult: "not_run",
|
||||
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
|
||||
userInput: item.userInput,
|
||||
assertionType: item.assertionType,
|
||||
keywords: [...item.keywords],
|
||||
keywordMatchMode: item.keywordMatchMode,
|
||||
llmCriteria: item.llmCriteria,
|
||||
sortOrder: index,
|
||||
updatedAt: nowIso(),
|
||||
}));
|
||||
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,
|
||||
sortOrder: index,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
});
|
||||
cases = [...cases, ...clonedCases];
|
||||
return copied;
|
||||
}
|
||||
@@ -345,19 +739,22 @@ export function createTestCase(input: {
|
||||
const maxOrder = cases
|
||||
.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(),
|
||||
kind: "next_reply",
|
||||
inputMode: DEFAULT_INPUT_MODE,
|
||||
kind: kindFromInputMode(DEFAULT_INPUT_MODE),
|
||||
lastResult: "not_run",
|
||||
contextTurns: [],
|
||||
userInput: "",
|
||||
assertionType: "keyword",
|
||||
keywords: [],
|
||||
keywordMatchMode: "any",
|
||||
llmCriteria: "",
|
||||
turns,
|
||||
voiceSettings: null,
|
||||
userSimulation: null,
|
||||
overallExpectation: null,
|
||||
...legacy,
|
||||
sortOrder: maxOrder + 1,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
@@ -375,19 +772,22 @@ export function duplicateTestCase(id: string): TestCase | null {
|
||||
.filter((item) => item.suiteId === source.suiteId)
|
||||
.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,
|
||||
kind: source.kind,
|
||||
inputMode: source.inputMode,
|
||||
kind: kindFromInputMode(source.inputMode),
|
||||
lastResult: "not_run",
|
||||
contextTurns: source.contextTurns.map((turn) => ({ ...turn })),
|
||||
userInput: source.userInput,
|
||||
assertionType: source.assertionType,
|
||||
keywords: [...source.keywords],
|
||||
keywordMatchMode: source.keywordMatchMode,
|
||||
llmCriteria: source.llmCriteria,
|
||||
turns,
|
||||
voiceSettings: cloneVoiceSettings(source.voiceSettings),
|
||||
userSimulation: cloneUserSimulation(source.userSimulation),
|
||||
overallExpectation: cloneOverallExpectation(source.overallExpectation),
|
||||
...legacy,
|
||||
sortOrder: maxOrder + 1,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
@@ -401,8 +801,13 @@ export type TestCasePatch = Partial<
|
||||
TestCase,
|
||||
| "name"
|
||||
| "description"
|
||||
| "inputMode"
|
||||
| "kind"
|
||||
| "contextTurns"
|
||||
| "turns"
|
||||
| "voiceSettings"
|
||||
| "userSimulation"
|
||||
| "overallExpectation"
|
||||
| "userInput"
|
||||
| "assertionType"
|
||||
| "keywords"
|
||||
@@ -418,9 +823,44 @@ export function updateTestCase(
|
||||
): TestCase | null {
|
||||
const index = cases.findIndex((item) => item.id === id);
|
||||
if (index < 0) return null;
|
||||
const next = {
|
||||
...cases[index],
|
||||
const current = cases[index];
|
||||
const nextTurns =
|
||||
patch.turns !== undefined
|
||||
? cloneTurns(
|
||||
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 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,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
cases = [...cases.slice(0, index), next, ...cases.slice(index + 1)];
|
||||
|
||||
Reference in New Issue
Block a user