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";
|
"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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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";
|
} 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
|||||||
Reference in New Issue
Block a user