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:
Xin Wang
2026-08-09 17:08:53 +08:00
parent e988f9c133
commit ba6c7c6be3
11 changed files with 2363 additions and 687 deletions

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
/** /**
* 批量测试用例详情抽屉 * 批量测试用例详情面板
* 与 prompt mode DebugDrawer overlay 相同:作为右侧 flex 半屏,无遮罩/模糊。
* 当前展示前端 mock 数据,后续可直接替换为真实执行事件与校验结果。 * 当前展示前端 mock 数据,后续可直接替换为真实执行事件与校验结果。
*/ */
@@ -12,21 +13,15 @@ import {
MessageSquareText, MessageSquareText,
Target, Target,
TriangleAlert, TriangleAlert,
X,
} from "lucide-react"; } from "lucide-react";
import { BatchCaseStatusBadge } from "@/components/batch-test/batch-case-status"; 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 type { BatchRunCase } from "@/data/batch-run";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type BatchCaseDetailDrawerProps = { type BatchCaseDetailDrawerProps = {
item: BatchRunCase | null; item: BatchRunCase;
assistantName: string; assistantName: string;
onClose: () => void; onClose: () => void;
}; };
@@ -37,80 +32,72 @@ export function BatchCaseDetailDrawer({
onClose, onClose,
}: BatchCaseDetailDrawerProps) { }: BatchCaseDetailDrawerProps) {
return ( return (
<Sheet <aside className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-l border-hairline bg-card">
open={Boolean(item)} <div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
onOpenChange={(open) => { <button
if (!open) onClose(); type="button"
}} aria-label="关闭用例详情"
> title="关闭"
<SheetContent 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"
side="right" onClick={onClose}
className="w-[min(92vw,620px)] border-hairline bg-card p-0 sm:max-w-[620px]" >
> <X size={16} />
{item && ( </button>
<> <div className="min-w-0 flex-1">
<SheetHeader className="border-b border-hairline px-6 py-5 pr-16"> <h2 className="truncate text-sm font-medium text-foreground">
<p className="text-xs font-medium tracking-[0.08em] text-muted-soft uppercase"> {item.name}
</h2>
</p> <p className="truncate text-xs text-muted-soft">
<SheetTitle className="text-lg leading-7"> {assistantName} · Mock
{item.name} </p>
</SheetTitle> </div>
<SheetDescription> <BatchCaseStatusBadge status={item.status} />
{assistantName} · Mock </div>
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6"> <div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="space-y-6"> <div className="space-y-5">
<section className="flex items-center justify-between rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5"> <section className="rounded-2xl border border-hairline bg-canvas-soft px-4 py-3.5">
<div> <p className="text-xs text-muted-soft"></p>
<p className="text-xs text-muted-soft"></p> <p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 text-sm text-muted-foreground"> {statusDescription(item)}
{statusDescription(item)} </p>
</p> </section>
</div>
<BatchCaseStatusBadge status={item.status} />
</section>
<DetailSection <DetailSection
icon={<Target size={16} />} icon={<Target size={16} />}
title="预期结果" title="预期结果"
value={item.expected} value={item.expected}
/> />
{(item.status === "waiting" || item.status === "running") && ( {(item.status === "waiting" || item.status === "running") && (
<ExecutionTimeline status={item.status} /> <ExecutionTimeline status={item.status} />
)} )}
{(item.status === "pass" || item.status === "fail") && ( {(item.status === "pass" || item.status === "fail") && (
<DetailSection <DetailSection
icon={<MessageSquareText size={16} />} icon={<MessageSquareText size={16} />}
title="实际回复" title="实际回复"
value={item.actual} value={item.actual}
/> />
)} )}
{item.status === "fail" && item.failReason && ( {item.status === "fail" && item.failReason && (
<DetailSection <DetailSection
icon={<TriangleAlert size={16} />} icon={<TriangleAlert size={16} />}
title="失败原因LLM 判断)" title="失败原因LLM 判断)"
value={item.failReason} value={item.failReason}
tone="destructive" tone="destructive"
/> />
)} )}
{item.status === "skipped" && ( {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 className="rounded-2xl border border-dashed border-hairline-strong px-4 py-4 text-sm leading-6 text-muted-foreground">
</div>
)}
</div>
</div> </div>
</> )}
)} </div>
</SheetContent> </div>
</Sheet> </aside>
); );
} }

View File

@@ -23,6 +23,7 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null); const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const selectedCase = const selectedCase =
run.cases.find((item) => item.id === selectedCaseId) ?? null; run.cases.find((item) => item.id === selectedCaseId) ?? null;
const detailOpen = Boolean(selectedCase);
const counts = countByStatus(run.cases); const counts = countByStatus(run.cases);
const judged = counts.pass + counts.fail; const judged = counts.pass + counts.fail;
@@ -31,129 +32,140 @@ export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
const failRate = judged === 0 ? 0 : 100 - passRate; const failRate = judged === 0 ? 0 : 100 - passRate;
return ( return (
<> <div className="flex h-full min-h-0 w-full overflow-hidden bg-background">
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4"> <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
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm"> className={cn(
<div className="flex flex-wrap items-center gap-2"> "flex flex-col gap-4",
<h2 className="text-base font-medium text-foreground">{run.title}</h2> detailOpen ? "w-full" : "mx-auto w-full max-w-[960px]",
<BatchPhasePill phase="completed" />
</div>
{run.finishedAt && (
<p className="mt-1.5 text-xs text-muted-soft">
{formatRunTime(run.finishedAt)}
{run.stopped ? " · 已手动停止" : ""}
</p>
)} )}
>
<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"> <section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
<div className="font-display text-3xl text-ink"> <div className="flex flex-wrap items-center gap-2">
{counts.pass} / {judged || total} <h2 className="text-base font-medium text-foreground">
</div> {run.title}
<div className="mt-1 text-sm font-medium text-success"> </h2>
{passRate}% <BatchPhasePill phase="completed" />
</div>
</div> </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"> <PassRateDonut pass={counts.pass} fail={counts.fail} />
<LegendRow
tone="success" <div className="space-y-2 text-sm">
label="通过"
count={counts.pass}
percent={passRate}
/>
<LegendRow
tone="destructive"
label="失败"
count={counts.fail}
percent={failRate}
/>
{counts.skipped > 0 && (
<LegendRow <LegendRow
tone="muted" tone="success"
label="未执行" label="通过"
count={counts.skipped} count={counts.pass}
percent={ percent={passRate}
total === 0
? 0
: Math.round((counts.skipped / total) * 100)
}
/> />
)} <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>
</div> </section>
</section>
{/* 用例列表 */} {/* 用例列表 */}
<section className="rounded-2xl border border-hairline bg-card shadow-sm"> <section className="rounded-2xl border border-hairline bg-card shadow-sm">
<div className="border-b border-hairline px-5 py-3.5"> <div className="border-b border-hairline px-5 py-3.5">
<h3 className="text-sm font-medium text-foreground"> <h3 className="text-sm font-medium text-foreground">
{total} {total}
</h3> </h3>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-sm"> <table className="w-full min-w-[560px] text-left text-sm">
<thead> <thead>
<tr className="border-b border-hairline text-xs text-muted-soft"> <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="w-12 px-5 py-2.5 font-medium">#</th>
<th className="px-3 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-28 px-3 py-2.5 font-medium"></th>
<th className="w-24 px-3 py-2.5 font-medium"></th> <th className="w-24 px-3 py-2.5 font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{run.cases.map((item, index) => { {run.cases.map((item, index) => {
return ( return (
<tr <tr
key={item.id} key={item.id}
className="border-b border-hairline last:border-b-0" className="border-b border-hairline last:border-b-0"
> >
<td colSpan={4} className="p-0"> <td colSpan={4} className="p-0">
<button <button
type="button" type="button"
onClick={() => setSelectedCaseId(item.id)} onClick={() => setSelectedCaseId(item.id)}
aria-haspopup="dialog" aria-expanded={selectedCaseId === item.id}
className={cn( className={cn(
"flex w-full items-center gap-0 px-5 py-3 text-left transition-colors hover:bg-canvas-soft/70", "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", selectedCaseId === item.id && "bg-canvas-soft",
)} )}
> >
<span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft"> <span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
{index + 1} {index + 1}
</span>
<span className="min-w-0 flex-1 px-3">
<span className="block font-medium text-foreground">
{item.name}
</span> </span>
</span> <span className="min-w-0 flex-1 px-3">
<span className="w-28 shrink-0 px-3"> <span className="block font-medium text-foreground">
<BatchCaseStatusBadge status={item.status} /> {item.name}
</span> </span>
<span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground"> </span>
<span className="w-28 shrink-0 px-3">
<ChevronRight size={14} /> <BatchCaseStatusBadge status={item.status} />
</span> </span>
</button> <span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground">
</td>
</tr> <ChevronRight size={14} />
); </span>
})} </button>
</tbody> </td>
</table> </tr>
</div> );
</section> })}
</tbody>
</table>
</div>
</section>
</div> </div>
</div>
<BatchCaseDetailDrawer {selectedCase && (
item={selectedCase} <BatchCaseDetailDrawer
assistantName={run.assistantName} item={selectedCase}
onClose={() => setSelectedCaseId(null)} assistantName={run.assistantName}
/> onClose={() => setSelectedCaseId(null)}
</> />
)}
</div>
); );
} }

View File

@@ -22,118 +22,128 @@ export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null); const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const selectedCase = const selectedCase =
run.cases.find((item) => item.id === selectedCaseId) ?? null; run.cases.find((item) => item.id === selectedCaseId) ?? null;
const detailOpen = Boolean(selectedCase);
const counts = countByStatus(run.cases); const counts = countByStatus(run.cases);
const done = counts.pass + counts.fail + counts.skipped; const done = counts.pass + counts.fail + counts.skipped;
const total = run.cases.length; const total = run.cases.length;
const percent = total === 0 ? 0 : Math.round((done / total) * 100); const percent = total === 0 ? 0 : Math.round((done / total) * 100);
return ( return (
<> <div className="flex h-full min-h-0 w-full overflow-hidden bg-background">
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4"> <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
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm"> className={cn(
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8"> "flex flex-col gap-4",
<div className="min-w-0 flex-1 space-y-3"> detailOpen ? "w-full" : "mx-auto w-full max-w-[960px]",
<div className="flex flex-wrap items-center gap-2"> )}
<h2 className="text-base font-medium text-foreground"> >
{run.title} {/* 进度总览 */}
</h2> <section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
<BatchPhasePill phase="running" /> <div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8">
</div> <div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-3"> <h2 className="text-base font-medium text-foreground">
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong"> {run.title}
<div </h2>
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out" <BatchPhasePill phase="running" />
style={{ width: `${percent}%` }}
/>
</div> </div>
<div className="shrink-0 text-right text-sm tabular-nums text-muted-foreground">
<span className="text-foreground"> <div className="flex items-center gap-3">
{done} / {total} <div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
</span>{" "} <div
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
<span className="ml-2 text-foreground">{percent}%</span> 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> </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>
<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="overflow-x-auto">
<Stat label="通过" value={counts.pass} tone="success" /> <table className="w-full min-w-[520px] text-left text-sm">
<Stat label="失败" value={counts.fail} tone="destructive" /> <thead>
<Stat label="运行中" value={counts.running} tone="primary" /> <tr className="border-b border-hairline text-xs text-muted-soft">
<Stat label="等待" value={counts.waiting} tone="muted" /> <th className="w-12 px-5 py-2.5 font-medium">#</th>
</div> <th className="px-3 py-2.5 font-medium"></th>
</div> <th className="w-28 px-3 py-2.5 font-medium"></th>
</section> <th className="w-24 px-3 py-2.5 font-medium"></th>
</tr>
{/* 用例列表 */} </thead>
<section className="rounded-2xl border border-hairline bg-card shadow-sm"> <tbody>
<div className="border-b border-hairline px-5 py-3.5"> {run.cases.map((item, index) => {
<h3 className="text-sm font-medium text-foreground"> return (
{total} <tr
</h3> key={item.id}
</div> className="border-b border-hairline last:border-b-0"
>
<div className="overflow-x-auto"> <td colSpan={4} className="p-0">
<table className="w-full min-w-[520px] text-left text-sm"> <button
<thead> type="button"
<tr className="border-b border-hairline text-xs text-muted-soft"> onClick={() => setSelectedCaseId(item.id)}
<th className="w-12 px-5 py-2.5 font-medium">#</th> aria-expanded={selectedCaseId === item.id}
<th className="px-3 py-2.5 font-medium"></th> className={cn(
<th className="w-28 px-3 py-2.5 font-medium"></th> "flex w-full items-center gap-0 px-5 py-3 text-left transition-colors hover:bg-canvas-soft/70",
<th className="w-24 px-3 py-2.5 font-medium"></th> selectedCaseId === item.id && "bg-canvas-soft",
</tr> )}
</thead> >
<tbody> <span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
{run.cases.map((item, index) => { {index + 1}
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}
</span> </span>
</span> <span className="min-w-0 flex-1 px-3">
<span className="w-28 shrink-0 px-3"> <span className="block font-medium text-foreground">
<BatchCaseStatusBadge status={item.status} /> {item.name}
</span> </span>
<span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground"> </span>
<span className="w-28 shrink-0 px-3">
<ChevronRight size={14} /> <BatchCaseStatusBadge status={item.status} />
</span> </span>
</button> <span className="flex w-24 shrink-0 items-center gap-1 px-3 text-muted-foreground">
</td>
</tr> <ChevronRight size={14} />
); </span>
})} </button>
</tbody> </td>
</table> </tr>
</div> );
</section> })}
</tbody>
</table>
</div>
</section>
</div>
</div> </div>
<BatchCaseDetailDrawer {selectedCase && (
item={selectedCase} <BatchCaseDetailDrawer
assistantName={run.assistantName} item={selectedCase}
onClose={() => setSelectedCaseId(null)} assistantName={run.assistantName}
/> onClose={() => setSelectedCaseId(null)}
</> />
)}
</div>
); );
} }
@@ -149,14 +159,14 @@ function Stat({
const toneClass = { const toneClass = {
success: "text-success", success: "text-success",
destructive: "text-destructive", destructive: "text-destructive",
primary: "text-primary", primary: "text-foreground",
muted: "text-muted-foreground", muted: "text-muted-foreground",
}[tone]; }[tone];
return ( return (
<div className="min-w-[4.5rem]"> <div>
<div className="text-xs text-muted-soft">{label}</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} {value}
</div> </div>
</div> </div>

View File

@@ -13,6 +13,8 @@ export function ListPageLayout({
topbarAction, topbarAction,
children, children,
className, className,
/** full-bleed占满 top bar 下方区域,供左右分屏等布局使用 */
contentMode = "list",
}: { }: {
title: string; title: string;
description?: ReactNode; description?: ReactNode;
@@ -21,8 +23,10 @@ export function ListPageLayout({
topbarAction?: ReactNode; topbarAction?: ReactNode;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
contentMode?: "list" | "full-bleed";
}) { }) {
const hasHeader = Boolean(description || action); const hasHeader = Boolean(description || action);
const isFullBleed = contentMode === "full-bleed";
return ( return (
<> <>
@@ -31,13 +35,15 @@ export function ListPageLayout({
</TopbarPortal> </TopbarPortal>
<div <div
data-app-content="list" data-app-content={isFullBleed ? "full-bleed" : "list"}
className={cn( 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, className,
)} )}
> >
{hasHeader && ( {hasHeader && !isFullBleed && (
<div <div
className={cn( className={cn(
"flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4", "flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4",

View File

@@ -374,8 +374,7 @@ export function BatchTestPage() {
return ( return (
<ListPageLayout <ListPageLayout
title="批量测试 / 运行中" title="批量测试 / 运行中"
description="批量运行测试用例,实时查看执行进度与结果。" contentMode="full-bleed"
className="max-w-[960px]"
topbarAction={ topbarAction={
<Button <Button
variant="outline" variant="outline"
@@ -396,8 +395,7 @@ export function BatchTestPage() {
return ( return (
<ListPageLayout <ListPageLayout
title="批量测试 / 已完成" title="批量测试 / 已完成"
description="批量测试已完成,您可以查看结果或重新运行。" contentMode="full-bleed"
className="max-w-[960px]"
topbarAction={ topbarAction={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button

View File

@@ -3,7 +3,7 @@
/** /**
* 测试用例管理: * 测试用例管理:
* - 列表页Test Suite 一级容器 * - 列表页Test Suite 一级容器
* - 详情页:左列表 + 右「单步回复」编辑器(只编辑不运行) * - 详情页:左列表 + 右编辑器(固定脚本 / 用户模拟,只编辑不运行)
*/ */
import { import {
@@ -31,6 +31,7 @@ import {
ListPageSection, ListPageSection,
} from "@/components/layout/list-page-layout"; } from "@/components/layout/list-page-layout";
import { TopbarPortal } from "@/components/layout/topbar-portal"; import { TopbarPortal } from "@/components/layout/topbar-portal";
import { InputModePicker } from "@/components/test-cases/input-mode-picker";
import { import {
NextReplyEditorBody, NextReplyEditorBody,
type CaseEditorDraft, type CaseEditorDraft,
@@ -55,25 +56,36 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import {
cloneOverallExpectation,
cloneTurns,
cloneUserSimulation,
cloneVoiceSettings,
createDefaultUserSimulation,
createDefaultVoiceSettings,
createEmptyFixedInputTurn,
createTestCase, createTestCase,
createTestSuite, createTestSuite,
DEFAULT_INPUT_MODE,
duplicateTestCase, duplicateTestCase,
duplicateTestSuite, duplicateTestSuite,
getTestSuite, getTestSuite,
isFixedScriptMode,
isVoiceMode,
kindFromInputMode,
listTestCases, listTestCases,
listTestSuites, listTestSuites,
normalizeOverallExpectation,
removeTestCase, removeTestCase,
removeTestCases, removeTestCases,
removeTestSuite, removeTestSuite,
reorderTestCases, reorderTestCases,
suiteCaseStats, suiteCaseStats,
SUPPORTED_TEST_CASE_KIND, TEST_CASE_INPUT_MODE_LABEL,
TEST_CASE_KIND_LABEL, TEST_CASE_INPUT_MODE_SHORT_LABEL,
TEST_CASE_KIND_OPTIONS,
updateTestCase, updateTestCase,
updateTestSuite, updateTestSuite,
type TestCase, type TestCase,
type TestCaseKind, type TestCaseInputMode,
type TestSuite, type TestSuite,
} from "@/data/test-suites"; } from "@/data/test-suites";
import { assistantsApi, type Assistant } from "@/lib/api"; import { assistantsApi, type Assistant } from "@/lib/api";
@@ -147,7 +159,7 @@ function SuiteListView() {
return ( return (
<ListPageLayout <ListPageLayout
title="测试用例" title="测试用例"
description="管理用于助手调试的单步回复测试场景。" description="管理用于助手调试的固定输入测试场景。"
action={ action={
<Button <Button
className="w-full shrink-0 gap-2 sm:w-auto" className="w-full shrink-0 gap-2 sm:w-auto"
@@ -336,7 +348,7 @@ function SuiteCreateView() {
<ListPageLayout <ListPageLayout
title="新建测试集" title="新建测试集"
className="max-w-[1180px]" className="max-w-[1180px]"
description="测试集是单步回复用例的业务分组。确认后进入用例编辑。" description="测试集是固定输入用例的业务分组。确认后进入用例编辑。"
action={ action={
<Button <Button
variant="outline" variant="outline"
@@ -428,13 +440,13 @@ function SuiteCreateView() {
function caseToDraft(item: TestCase): CaseEditorDraft { function caseToDraft(item: TestCase): CaseEditorDraft {
return { return {
name: item.name, name: item.name,
inputMode: item.inputMode,
kind: item.kind, kind: item.kind,
contextTurns: item.contextTurns.map((turn) => ({ ...turn })), contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
userInput: item.userInput, turns: cloneTurns(item.turns),
assertionType: item.assertionType, voiceSettings: cloneVoiceSettings(item.voiceSettings),
keywords: [...item.keywords], userSimulation: cloneUserSimulation(item.userSimulation),
keywordMatchMode: item.keywordMatchMode, overallExpectation: cloneOverallExpectation(item.overallExpectation),
llmCriteria: item.llmCriteria,
}; };
} }
@@ -445,16 +457,34 @@ function draftsEqual(a: CaseEditorDraft, b: CaseEditorDraft) {
function createEmptyCaseDraft(): CaseEditorDraft { function createEmptyCaseDraft(): CaseEditorDraft {
return { return {
name: "未命名用例", name: "未命名用例",
kind: SUPPORTED_TEST_CASE_KIND, inputMode: DEFAULT_INPUT_MODE,
kind: kindFromInputMode(DEFAULT_INPUT_MODE),
contextTurns: [], contextTurns: [],
userInput: "", turns: [createEmptyFixedInputTurn()],
assertionType: "keyword", voiceSettings: null,
keywords: [], userSimulation: null,
keywordMatchMode: "any", overallExpectation: null,
llmCriteria: "",
}; };
} }
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 }) { function SuiteDetailView({ suiteId }: { suiteId: string }) {
const router = useRouter(); const router = useRouter();
const [initial] = useState(() => { const [initial] = useState(() => {
@@ -516,7 +546,10 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
return cases.filter((item) => { return cases.filter((item) => {
if (!keyword) return true; 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(" ") .join(" ")
.toLowerCase() .toLowerCase()
.includes(keyword); .includes(keyword);
@@ -554,13 +587,15 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
if (!draft) return; if (!draft) return;
const patch = { const patch = {
name: draft.name.trim() || "未命名用例", name: draft.name.trim() || "未命名用例",
kind: draft.kind, inputMode: draft.inputMode,
kind: kindFromInputMode(draft.inputMode),
contextTurns: draft.contextTurns, contextTurns: draft.contextTurns,
userInput: draft.userInput, turns: draft.turns,
assertionType: draft.assertionType, voiceSettings: draft.voiceSettings,
keywords: draft.keywords, userSimulation: draft.userSimulation,
keywordMatchMode: draft.keywordMatchMode, overallExpectation: normalizeOverallExpectation(
llmCriteria: draft.llmCriteria, draft.overallExpectation?.criteria,
),
}; };
let saved: TestCase | null; let saved: TestCase | null;
@@ -766,35 +801,10 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
allowEmpty allowEmpty
/> />
<Select <InputModePicker
value={draft.kind} value={draft.inputMode}
onValueChange={(value) => onChange={(mode) => setDraft(applyInputMode(draft, mode))}
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>
<div className="ml-auto flex shrink-0 items-center gap-2"> <div className="ml-auto flex shrink-0 items-center gap-2">
{dirty ? ( {dirty ? (
@@ -831,15 +841,15 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
</div> </div>
</div> </div>
{draft.kind === SUPPORTED_TEST_CASE_KIND ? ( {draft.inputMode === "fixed_script_text" ? (
<NextReplyEditorBody draft={draft} onChange={setDraft} /> <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="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"> <div className="text-sm font-medium text-foreground">
{TEST_CASE_KIND_LABEL[draft.kind]} · {TEST_CASE_INPUT_MODE_LABEL[draft.inputMode]} ·
</div> </div>
<p className="max-w-sm text-xs leading-5 text-muted-foreground"> <p className="max-w-sm text-xs leading-5 text-muted-foreground">
·
</p> </p>
</div> </div>
)} )}

View 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

View File

@@ -42,7 +42,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { SearchInput } from "@/components/ui/search-input"; import { SearchInput } from "@/components/ui/search-input";
import { import {
TEST_CASE_KIND_LABEL, TEST_CASE_INPUT_MODE_SHORT_LABEL,
type TestCase, type TestCase,
} from "@/data/test-suites"; } from "@/data/test-suites";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -340,7 +340,7 @@ function SortableCaseCard({
{item.name} {item.name}
</span> </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"> <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> </span>
</div> </div>

View File

@@ -51,15 +51,17 @@ function SheetContent({
side = "right", side = "right",
showCloseButton = true, showCloseButton = true,
showOverlay = true, showOverlay = true,
overlayClassName,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean showCloseButton?: boolean
showOverlay?: boolean showOverlay?: boolean
overlayClassName?: string
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
{showOverlay && <SheetOverlay />} {showOverlay && <SheetOverlay className={overlayClassName} />}
<SheetPrimitive.Content <SheetPrimitive.Content
data-slot="sheet-content" data-slot="sheet-content"
data-side={side} data-side={side}

View File

@@ -1,15 +1,19 @@
/** /**
* 测试集 / 测试用例 — 管理页用的本地 mock。 * 测试集 / 测试用例 — 管理页用的本地 mock。
* 两层Test Suite → Test Case。 * 两层Test Suite → Test Case。
* 第一版只支持「单步回复 / Next Reply Test」只编辑不运行 * 输入模式:固定脚本(文字 / 逐轮语音 / 连续语音)与用户模拟(文字 / 语音)
*/ */
export type TestCaseKind = /** 顶部模式选择器的唯一来源;不再单独维护「测试类型」字段 */
| "next_reply" export type TestCaseInputMode =
| "tool_call" | "fixed_script_text"
| "fixed_dialogue" | "fixed_script_turn_voice"
| "user_simulation" | "fixed_script_continuous_voice"
| "voice_run"; | "user_sim_text"
| "user_sim_voice";
/** 列表/兼容用粗粒度种类(由 inputMode 推导) */
export type TestCaseKind = "fixed_dialogue" | "user_simulation";
export type TestCaseResult = "pass" | "fail" | "not_run"; export type TestCaseResult = "pass" | "fail" | "not_run";
@@ -22,6 +26,80 @@ export type ContextTurn = {
content: string; 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 = { export type TestSuite = {
id: string; id: string;
name: string; name: string;
@@ -36,50 +114,322 @@ export type TestCase = {
suiteId: string; suiteId: string;
name: string; name: string;
description: string; description: string;
/** 输入模式(编辑器顶部选择器) */
inputMode: TestCaseInputMode;
/** 由 inputMode 推导,供列表筛选等兼容读取 */
kind: TestCaseKind; kind: TestCaseKind;
lastResult: TestCaseResult; lastResult: TestCaseResult;
/** 对话上下文(通常是 Agent 上一轮) */ /** 对话上下文(通常是 Agent 上一轮) */
contextTurns: ContextTurn[]; contextTurns: ContextTurn[];
/** 当前用户输入 */ /** 固定脚本轮次 */
turns: FixedInputTurn[];
/** 语音相关模式的音色 / 语速 */
voiceSettings?: VoiceSettings | null;
/** 用户模拟配置 */
userSimulation?: UserSimulationConfig | null;
/** 整段对话业务目标预期(可选) */
overallExpectation?: OverallExpectation | null;
/**
* 以下字段由 turns[0] 同步,供批量测试等旧逻辑读取。
* 编辑与保存以 turns 为准。
*/
userInput: string; userInput: string;
assertionType: AssertionType; assertionType: AssertionType;
keywords: string[]; keywords: string[];
keywordMatchMode: KeywordMatchMode; keywordMatchMode: KeywordMatchMode;
/** LLM 判断时的判断标准 */
llmCriteria: string; llmCriteria: string;
/** 套件内排序,越小越靠前 */ /** 套件内排序,越小越靠前 */
sortOrder: number; sortOrder: number;
updatedAt: string; updatedAt: string;
}; };
/** 第一版仅「单步回复」可编辑,其余类型显示开发中 */ export const DEFAULT_INPUT_MODE: TestCaseInputMode = "fixed_script_text";
export const SUPPORTED_TEST_CASE_KIND: TestCaseKind = "next_reply";
export const TEST_CASE_KIND_LABEL: Record<TestCaseKind, string> = { export function kindFromInputMode(mode: TestCaseInputMode): TestCaseKind {
next_reply: "单步回复", return mode === "user_sim_text" || mode === "user_sim_voice"
tool_call: "工具调用", ? "user_simulation"
fixed_dialogue: "固定对话", : "fixed_dialogue";
user_simulation: "用户模拟", }
voice_run: "语音运行",
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; export const TEST_CASE_INPUT_MODE_SHORT_LABEL: Record<
label: string; TestCaseInputMode,
available: boolean; string
}[] = [ > = {
{ value: "next_reply", label: "单步回复", available: true }, fixed_script_text: "固定文字",
{ value: "tool_call", label: "工具调用", available: false }, fixed_script_turn_voice: "逐轮语音",
{ value: "fixed_dialogue", label: "固定对话", available: false }, fixed_script_continuous_voice: "连续语音",
{ value: "user_simulation", label: "用户模拟", available: false }, user_sim_text: "模拟文字",
{ value: "voice_run", label: "语音运行", available: false }, 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> = { export const ASSERTION_TYPE_LABEL: Record<AssertionType, string> = {
keyword: "关键词", keyword: "关键词",
llm: "LLM 判断", 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[] = [ const INITIAL_SUITES: TestSuite[] = [
{ {
id: "suite_001", 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", id: "tc_001",
suiteId: "suite_001", suiteId: "suite_001",
name: "正常双车事故开场", name: "正常双车事故开场",
description: "开场后用户补充事故经过", description: "开场后用户补充事故经过",
kind: "next_reply", kind: "fixed_dialogue",
lastResult: "pass", lastResult: "pass",
contextTurns: [], contextTurns: [],
userInput: "我这里刚刚撞了一下。", userInput: "我这里刚刚撞了一下。",
@@ -125,7 +485,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
suiteId: "suite_001", suiteId: "suite_001",
name: "有人伤转人工", name: "有人伤转人工",
description: "用户提到人伤时应引导转人工", description: "用户提到人伤时应引导转人工",
kind: "next_reply", kind: "fixed_dialogue",
lastResult: "pass", lastResult: "pass",
contextTurns: [], contextTurns: [],
userInput: "有人受伤了,流血不止。", userInput: "有人受伤了,流血不止。",
@@ -134,6 +494,11 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
keywordMatchMode: "any", keywordMatchMode: "any",
llmCriteria: llmCriteria:
"Agent 应识别人员受伤风险,明确告知将转接人工,并避免继续推进普通快处流程。", "Agent 应识别人员受伤风险,明确告知将转接人工,并避免继续推进普通快处流程。",
overallExpectation: {
type: "llm",
criteria:
"整段对话应正确识别人员受伤场景,及时并准确转接人工处理,不应继续引导用户进入普通事故快处流程。",
},
updatedAt: "2026-08-05T10:12:00+08:00", updatedAt: "2026-08-05T10:12:00+08:00",
}, },
{ {
@@ -141,7 +506,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
suiteId: "suite_002", suiteId: "suite_002",
name: "用户说“喂”", name: "用户说“喂”",
description: "验证主动唤醒回复且业务状态不推进", description: "验证主动唤醒回复且业务状态不推进",
kind: "next_reply", kind: "fixed_dialogue",
lastResult: "pass", lastResult: "pass",
contextTurns: [], contextTurns: [],
userInput: "喂", userInput: "喂",
@@ -156,7 +521,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
suiteId: "suite_002", suiteId: "suite_002",
name: "用户只说“嗯”", name: "用户只说“嗯”",
description: "短促确认不应误推进流程", description: "短促确认不应误推进流程",
kind: "next_reply", kind: "fixed_dialogue",
lastResult: "fail", lastResult: "fail",
contextTurns: [], contextTurns: [],
userInput: "嗯", userInput: "嗯",
@@ -172,7 +537,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
suiteId: "suite_002", suiteId: "suite_002",
name: "模糊事故描述", name: "模糊事故描述",
description: "地点含糊时应主动澄清", description: "地点含糊时应主动澄清",
kind: "next_reply", kind: "fixed_dialogue",
lastResult: "not_run", lastResult: "not_run",
contextTurns: [], contextTurns: [],
userInput: "就在那边……撞了一下。", userInput: "就在那边……撞了一下。",
@@ -187,7 +552,7 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
suiteId: "suite_003", suiteId: "suite_003",
name: "用户打断播报", name: "用户打断播报",
description: "播报中打断后正确切换聆听并承接", description: "播报中打断后正确切换聆听并承接",
kind: "next_reply", kind: "fixed_dialogue",
lastResult: "pass", lastResult: "pass",
contextTurns: [], contextTurns: [],
userInput: "等一下,对方走了。", userInput: "等一下,对方走了。",
@@ -200,13 +565,37 @@ const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
}, },
]; ];
/** 按套件出现顺序写入 sortOrder */ /** 按套件出现顺序写入 sortOrder,并补齐 turns */
const INITIAL_CASES: TestCase[] = (() => { const INITIAL_CASES: TestCase[] = (() => {
const counters = new Map<string, number>(); const counters = new Map<string, number>();
return RAW_CASES.map((item) => { return RAW_CASE_SEEDS.map((item) => {
const order = counters.get(item.suiteId) ?? 0; const order = counters.get(item.suiteId) ?? 0;
counters.set(item.suiteId, order + 1); 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 sourceCases = listTestCases(id);
const clonedCases: TestCase[] = sourceCases.map((item, index) => ({ const clonedCases: TestCase[] = sourceCases.map((item, index) => {
id: nextCaseId(), const turns = cloneTurnsWithNewIds(item.turns);
suiteId: copied.id, const legacy = legacyFieldsFromTurns(turns);
name: item.name, return {
description: item.description, id: nextCaseId(),
kind: item.kind, suiteId: copied.id,
lastResult: "not_run", name: item.name,
contextTurns: item.contextTurns.map((turn) => ({ ...turn })), description: item.description,
userInput: item.userInput, inputMode: item.inputMode,
assertionType: item.assertionType, kind: kindFromInputMode(item.inputMode),
keywords: [...item.keywords], lastResult: "not_run" as const,
keywordMatchMode: item.keywordMatchMode, contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
llmCriteria: item.llmCriteria, turns,
sortOrder: index, voiceSettings: cloneVoiceSettings(item.voiceSettings),
updatedAt: nowIso(), userSimulation: cloneUserSimulation(item.userSimulation),
})); overallExpectation: cloneOverallExpectation(item.overallExpectation),
...legacy,
sortOrder: index,
updatedAt: nowIso(),
};
});
cases = [...cases, ...clonedCases]; cases = [...cases, ...clonedCases];
return copied; return copied;
} }
@@ -345,19 +739,22 @@ export function createTestCase(input: {
const maxOrder = cases const maxOrder = cases
.filter((item) => item.suiteId === input.suiteId) .filter((item) => item.suiteId === input.suiteId)
.reduce((max, item) => Math.max(max, item.sortOrder), -1); .reduce((max, item) => Math.max(max, item.sortOrder), -1);
const turns = [createEmptyFixedInputTurn()];
const legacy = legacyFieldsFromTurns(turns);
const item: TestCase = { const item: TestCase = {
id: nextCaseId(), id: nextCaseId(),
suiteId: input.suiteId, suiteId: input.suiteId,
name: (input.name ?? "未命名用例").trim() || "未命名用例", name: (input.name ?? "未命名用例").trim() || "未命名用例",
description: (input.description ?? "").trim(), description: (input.description ?? "").trim(),
kind: "next_reply", inputMode: DEFAULT_INPUT_MODE,
kind: kindFromInputMode(DEFAULT_INPUT_MODE),
lastResult: "not_run", lastResult: "not_run",
contextTurns: [], contextTurns: [],
userInput: "", turns,
assertionType: "keyword", voiceSettings: null,
keywords: [], userSimulation: null,
keywordMatchMode: "any", overallExpectation: null,
llmCriteria: "", ...legacy,
sortOrder: maxOrder + 1, sortOrder: maxOrder + 1,
updatedAt: nowIso(), updatedAt: nowIso(),
}; };
@@ -375,19 +772,22 @@ export function duplicateTestCase(id: string): TestCase | null {
.filter((item) => item.suiteId === source.suiteId) .filter((item) => item.suiteId === source.suiteId)
.reduce((max, item) => Math.max(max, item.sortOrder), -1); .reduce((max, item) => Math.max(max, item.sortOrder), -1);
const turns = cloneTurnsWithNewIds(source.turns);
const legacy = legacyFieldsFromTurns(turns);
const copied: TestCase = { const copied: TestCase = {
id: nextCaseId(), id: nextCaseId(),
suiteId: source.suiteId, suiteId: source.suiteId,
name: `${source.name}(副本)`, name: `${source.name}(副本)`,
description: source.description, description: source.description,
kind: source.kind, inputMode: source.inputMode,
kind: kindFromInputMode(source.inputMode),
lastResult: "not_run", lastResult: "not_run",
contextTurns: source.contextTurns.map((turn) => ({ ...turn })), contextTurns: source.contextTurns.map((turn) => ({ ...turn })),
userInput: source.userInput, turns,
assertionType: source.assertionType, voiceSettings: cloneVoiceSettings(source.voiceSettings),
keywords: [...source.keywords], userSimulation: cloneUserSimulation(source.userSimulation),
keywordMatchMode: source.keywordMatchMode, overallExpectation: cloneOverallExpectation(source.overallExpectation),
llmCriteria: source.llmCriteria, ...legacy,
sortOrder: maxOrder + 1, sortOrder: maxOrder + 1,
updatedAt: nowIso(), updatedAt: nowIso(),
}; };
@@ -401,8 +801,13 @@ export type TestCasePatch = Partial<
TestCase, TestCase,
| "name" | "name"
| "description" | "description"
| "inputMode"
| "kind" | "kind"
| "contextTurns" | "contextTurns"
| "turns"
| "voiceSettings"
| "userSimulation"
| "overallExpectation"
| "userInput" | "userInput"
| "assertionType" | "assertionType"
| "keywords" | "keywords"
@@ -418,9 +823,44 @@ export function updateTestCase(
): TestCase | null { ): TestCase | null {
const index = cases.findIndex((item) => item.id === id); const index = cases.findIndex((item) => item.id === id);
if (index < 0) return null; if (index < 0) return null;
const next = { const current = cases[index];
...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, ...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(), updatedAt: nowIso(),
}; };
cases = [...cases.slice(0, index), next, ...cases.slice(index + 1)]; cases = [...cases.slice(0, index), next, ...cases.slice(index + 1)];