Compare commits

...

4 Commits

Author SHA1 Message Date
Xin Wang
21a25874a9 feat(frontend): implement batch testing views and status components
Add components for batch testing, including BatchRunCompletedView and BatchRunRunningView, to display test results and progress. Introduce BatchCaseStatusBadge and BatchPhasePill for visual status representation. Implement mock data handling for batch run snapshots and case statuses, enhancing the user experience for managing batch tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:37:48 +08:00
Xin Wang
398b20778d feat(frontend): add Test Suite management and Next Reply case editor
Replace the test-cases placeholder with suite list/detail flows, multi-select and drag reorder, and an edit-only single-step reply editor with keyword/LLM assertions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:03:12 +08:00
Xin Wang
2f755969d3 feat(frontend): add auto test functionality to debug drawer
Introduce a new auto test mode in the debug drawer, allowing users to run automated tests with mock data. This includes the addition of AutoTestPanel, AutoTestLivePanel, and AutoTestRunBar components for managing test cases and displaying results. The debug mode can now switch between manual and auto testing, enhancing the debugging experience.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:09:18 +08:00
Xin Wang
36117b976e feat(frontend): split test assistant nav into cases and batch pages
Make 测试助手 a sidebar section like 监控观察, with placeholder routes for 测试用例 and 批量测试.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:00:50 +08:00
22 changed files with 5213 additions and 47 deletions

View File

@@ -9,6 +9,9 @@
"version": "0.1.0",
"dependencies": {
"@daily-co/daily-js": "^0.90.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@pipecat-ai/client-js": "^1.12.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
"@xyflow/react": "^12.11.0",
@@ -477,6 +480,59 @@
"node": ">=22.14.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.71.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.71.0.tgz",

View File

@@ -10,6 +10,9 @@
},
"dependencies": {
"@daily-co/daily-js": "^0.90.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@pipecat-ai/client-js": "^1.12.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
"@xyflow/react": "^12.11.0",

View File

@@ -0,0 +1,5 @@
import { BatchTestPage } from "@/components/pages/BatchTestPage";
export default function Page() {
return <BatchTestPage />;
}

View File

@@ -0,0 +1,10 @@
import { TestCasesPage } from "@/components/pages/TestCasesPage";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <TestCasesPage mode="detail" suiteId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { TestCasesPage } from "@/components/pages/TestCasesPage";
export default function Page() {
return <TestCasesPage mode="create" />;
}

View File

@@ -0,0 +1,5 @@
import { TestCasesPage } from "@/components/pages/TestCasesPage";
export default function Page() {
return <TestCasesPage mode="list" />;
}

View File

@@ -1,5 +1,5 @@
import { TestPage } from "@/components/pages/TestPage";
import { redirect } from "next/navigation";
export default function Page() {
return <TestPage />;
redirect("/test/cases");
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,12 @@ import {
X,
} from "lucide-react";
import {
AutoTestLivePanel,
AutoTestRunBar,
DebugModeTabs,
useAutoTestRunner,
} from "@/components/assistant-editor/debug-auto-test";
import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
import { ClientMessageDialog } from "@/components/client-message-dialog";
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
@@ -67,6 +73,7 @@ type VizStyle = "aura" | "nebula" | "bars" | "wave";
// 调试面板顶部主视图:聊天记录 / 视频流
type DebugView = "chat" | "video";
type DebugInputMode = "mic" | "text";
type DebugMode = "manual" | "auto";
type PendingDebugImage = {
file: File;
previewUrl: string;
@@ -175,6 +182,9 @@ export function DebugDrawer({
}) {
const preview = useVoicePreview(assistantId, onNodeActive);
const camera = useCameraPreview();
const autoTest = useAutoTestRunner();
const stopAutoTest = autoTest.stop;
const [debugMode, setDebugMode] = useState<DebugMode>("manual");
const [showTranscript, setShowTranscript] = useState(false);
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
const [view, setView] = useState<DebugView>("chat");
@@ -218,6 +228,20 @@ export function DebugDrawer({
[camera, preview],
);
// 切到自动测试时停掉手动会话,避免两边抢占麦克风/对话区
const handleModeChange = useCallback(
(mode: DebugMode) => {
setDebugMode(mode);
if (mode === "auto" && recording) {
preview.disconnect();
}
if (mode === "manual") {
stopAutoTest();
}
},
[preview, recording, stopAutoTest],
);
return (
<aside
className={overlay
@@ -240,14 +264,18 @@ export function DebugDrawer({
<div className="shrink-0 text-sm font-medium text-foreground">
</div>
<NetworkQualityIndicator
quality={preview.networkQuality}
status={preview.status}
/>
<DebugConnectionStatus
status={preview.status}
micWarning={preview.micWarning}
/>
{debugMode === "manual" && (
<>
<NetworkQualityIndicator
quality={preview.networkQuality}
status={preview.status}
/>
<DebugConnectionStatus
status={preview.status}
micWarning={preview.micWarning}
/>
</>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<ClientToolsPopover tools={preview.clientTools} />
@@ -261,7 +289,7 @@ export function DebugDrawer({
onChange={setDynamicVariableValues}
/>
)}
{SHOW_VOICE_VIZ && view === "chat" && (
{debugMode === "manual" && SHOW_VOICE_VIZ && view === "chat" && (
<>
{!showTranscript && (
<SegmentedIconGroup label="可视化样式">
@@ -297,28 +325,72 @@ export function DebugDrawer({
)}
</div>
</div>
<div className="shrink-0 border-b border-hairline px-5 py-2.5">
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
<CameraDeviceField camera={camera} onSelect={selectCamera} />
</div>
<div className="shrink-0 space-y-2.5 border-b border-hairline px-5 py-2.5">
<DebugModeTabs mode={debugMode} onChange={handleModeChange} />
{debugMode === "manual" && (
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
<CameraDeviceField camera={camera} onSelect={selectCamera} />
</div>
)}
</div>
<DebugVoicePanel
view={view}
onViewChange={setView}
showTranscript={showTranscript}
vizStyle={vizStyle}
assistantId={assistantId}
preview={preview}
camera={camera}
hasUnsavedChanges={hasUnsavedChanges}
vision={vision}
dynamicVariables={resolvedDynamicVariables}
dynamicVariablesError={dynamicVariablesError}
/>
{debugMode === "auto" ? (
<AutoTestPanel assistantId={assistantId} autoTest={autoTest} />
) : (
<DebugVoicePanel
view={view}
onViewChange={setView}
showTranscript={showTranscript}
vizStyle={vizStyle}
assistantId={assistantId}
preview={preview}
camera={camera}
hasUnsavedChanges={hasUnsavedChanges}
vision={vision}
dynamicVariables={resolvedDynamicVariables}
dynamicVariablesError={dynamicVariablesError}
/>
)}
</aside>
);
}
function AutoTestPanel({
assistantId,
autoTest,
}: {
assistantId: string | null;
autoTest: ReturnType<typeof useAutoTestRunner>;
}) {
const {
state,
selectedCase,
selectCase,
clearCase,
start,
stop,
rerun,
} = autoTest;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<AutoTestLivePanel testCase={selectedCase} state={state} />
</div>
<div className="shrink-0 border-t border-hairline bg-card p-3">
<AutoTestRunBar
state={state}
assistantId={assistantId}
onSelectCase={selectCase}
onClearCase={clearCase}
onStart={start}
onStop={stop}
onRerun={rerun}
/>
</div>
</div>
);
}
function ClientToolsPopover({ tools }: { tools: ClientToolDefinition[] }) {
const [copiedToolName, setCopiedToolName] = useState<string | null>(null);

View File

@@ -0,0 +1,84 @@
"use client";
import { CheckCircle2, Circle, Loader2, XCircle } from "lucide-react";
import type { BatchCaseStatus } from "@/data/batch-run";
import { cn } from "@/lib/utils";
const STATUS_META: Record<
BatchCaseStatus,
{ label: string; className: string; Icon: typeof CheckCircle2 }
> = {
pass: {
label: "通过",
className: "text-success",
Icon: CheckCircle2,
},
fail: {
label: "失败",
className: "text-destructive",
Icon: XCircle,
},
running: {
label: "运行中",
className: "text-primary",
Icon: Loader2,
},
waiting: {
label: "等待",
className: "text-muted-soft",
Icon: Circle,
},
skipped: {
label: "未执行",
className: "text-muted-soft",
Icon: Circle,
},
};
export function BatchCaseStatusBadge({
status,
className,
}: {
status: BatchCaseStatus;
className?: string;
}) {
const meta = STATUS_META[status];
const Icon = meta.Icon;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 text-sm font-medium",
meta.className,
className,
)}
>
<Icon
size={16}
className={cn(status === "running" && "animate-spin")}
/>
{meta.label}
</span>
);
}
export function BatchPhasePill({
phase,
}: {
phase: "running" | "completed";
}) {
if (phase === "running") {
return (
<span className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
</span>
);
}
return (
<span className="inline-flex items-center rounded-full bg-success/15 px-2.5 py-0.5 text-xs font-medium text-success">
</span>
);
}

View File

@@ -0,0 +1,297 @@
"use client";
/**
* 批量测试 — 已完成结果视图MVP mock
*/
import { ChevronDown, ChevronRight } from "lucide-react";
import { useState } from "react";
import {
BatchCaseStatusBadge,
BatchPhasePill,
} from "@/components/batch-test/batch-case-status";
import {
countByStatus,
formatRunTime,
type BatchRunSnapshot,
} from "@/data/batch-run";
import { cn } from "@/lib/utils";
export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => {
const firstFail = run.cases.find((item) => item.status === "fail");
return firstFail ? new Set([firstFail.id]) : new Set();
});
const counts = countByStatus(run.cases);
const judged = counts.pass + counts.fail;
const total = run.cases.length;
const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100);
const failRate = judged === 0 ? 0 : 100 - passRate;
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
{/* 结果总览 */}
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-medium text-foreground">{run.title}</h2>
<BatchPhasePill phase="completed" />
</div>
{run.finishedAt && (
<p className="mt-1.5 text-xs text-muted-soft">
{formatRunTime(run.finishedAt)}
{run.stopped ? " · 已手动停止" : ""}
</p>
)}
<div className="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>
<PassRateDonut pass={counts.pass} fail={counts.fail} />
<div className="space-y-2 text-sm">
<LegendRow
tone="success"
label="通过"
count={counts.pass}
percent={passRate}
/>
<LegendRow
tone="destructive"
label="失败"
count={counts.fail}
percent={failRate}
/>
{counts.skipped > 0 && (
<LegendRow
tone="muted"
label="未执行"
count={counts.skipped}
percent={
total === 0
? 0
: Math.round((counts.skipped / total) * 100)
}
/>
)}
</div>
</div>
</section>
{/* 用例列表 */}
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
<div className="border-b border-hairline px-5 py-3.5">
<h3 className="text-sm font-medium text-foreground">
{total}
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-sm">
<thead>
<tr className="border-b border-hairline text-xs text-muted-soft">
<th className="w-12 px-5 py-2.5 font-medium">#</th>
<th className="px-3 py-2.5 font-medium"></th>
<th className="w-28 px-3 py-2.5 font-medium"></th>
<th className="w-24 px-3 py-2.5 font-medium"></th>
</tr>
</thead>
<tbody>
{run.cases.map((item, index) => {
const expanded = expandedIds.has(item.id);
const canExpand =
item.status === "fail" || item.status === "pass";
return (
<tr
key={item.id}
className="border-b border-hairline last:border-b-0"
>
<td colSpan={4} className="p-0">
<button
type="button"
disabled={!canExpand}
onClick={() => toggleExpanded(item.id)}
className={cn(
"flex w-full items-start gap-0 px-5 py-3 text-left transition-colors",
canExpand && "hover:bg-canvas-soft/70",
!canExpand && "cursor-default",
)}
>
<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>
{expanded && (
<span className="mt-3 block rounded-xl bg-canvas-soft/80 px-3 py-3">
<span className="grid gap-3 sm:grid-cols-2">
<DetailField
label="预期结果"
value={item.expected}
/>
<DetailField
label="实际结果"
value={item.actual}
/>
</span>
{item.status === "fail" && item.failReason && (
<span className="mt-3 block border-t border-hairline pt-3">
<DetailField
label="失败原因LLM 判断)"
value={item.failReason}
/>
</span>
)}
</span>
)}
</span>
<span className="w-28 shrink-0 px-3 pt-0.5">
<BatchCaseStatusBadge status={item.status} />
</span>
<span className="flex w-24 shrink-0 items-center gap-1 px-3 pt-0.5 text-muted-foreground">
{canExpand ? (
<>
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</>
) : (
<span className="text-muted-soft"></span>
)}
</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
);
}
function PassRateDonut({ pass, fail }: { pass: number; fail: number }) {
const total = pass + fail;
const passRatio = total === 0 ? 0 : pass / total;
const size = 112;
const stroke = 14;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const passLength = circumference * passRatio;
const failLength = circumference - passLength;
return (
<div
className="relative shrink-0"
style={{ width: size, height: size }}
aria-hidden
>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
className="text-surface-strong"
/>
{total > 0 && (
<>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${passLength} ${circumference}`}
strokeLinecap="butt"
className="text-success"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={`${failLength} ${circumference}`}
strokeDashoffset={-passLength}
strokeLinecap="butt"
className="text-destructive"
/>
</>
)}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-medium tabular-nums text-foreground">
{total === 0 ? 0 : Math.round((pass / total) * 100)}%
</span>
</div>
</div>
);
}
function LegendRow({
tone,
label,
count,
percent,
}: {
tone: "success" | "destructive" | "muted";
label: string;
count: number;
percent: number;
}) {
const dotClass = {
success: "bg-success",
destructive: "bg-destructive",
muted: "bg-muted-soft",
}[tone];
return (
<div className="flex items-center gap-2 text-muted-foreground">
<span className={cn("size-2.5 rounded-full", dotClass)} />
<span>
{label} {count}{percent}%
</span>
</div>
);
}
function DetailField({ label, value }: { label: string; value: string }) {
return (
<span className="block">
<span className="block text-xs text-muted-soft">{label}</span>
<span className="mt-1 block text-sm leading-6 text-muted-foreground">
{value}
</span>
</span>
);
}

View File

@@ -0,0 +1,195 @@
"use client";
/**
* 批量测试 — 运行中视图MVP mock
*/
import { ChevronDown, ChevronRight } from "lucide-react";
import { useState } from "react";
import {
BatchCaseStatusBadge,
BatchPhasePill,
} from "@/components/batch-test/batch-case-status";
import {
countByStatus,
type BatchRunSnapshot,
} from "@/data/batch-run";
import { cn } from "@/lib/utils";
export function BatchRunRunningView({ run }: { run: BatchRunSnapshot }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const counts = countByStatus(run.cases);
const done = counts.pass + counts.fail + counts.skipped;
const total = run.cases.length;
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
{/* 进度总览 */}
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-medium text-foreground">
{run.title}
</h2>
<BatchPhasePill phase="running" />
</div>
<div className="flex items-center gap-3">
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
<div
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-out"
style={{ width: `${percent}%` }}
/>
</div>
<div className="shrink-0 text-right text-sm tabular-nums text-muted-foreground">
<span className="text-foreground">
{done} / {total}
</span>{" "}
<span className="ml-2 text-foreground">{percent}%</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 border-t border-hairline pt-4 sm:grid-cols-4 lg:border-l lg:border-t-0 lg:pl-8 lg:pt-0">
<Stat label="通过" value={counts.pass} tone="success" />
<Stat label="失败" value={counts.fail} tone="destructive" />
<Stat label="运行中" value={counts.running} tone="primary" />
<Stat label="等待" value={counts.waiting} tone="muted" />
</div>
</div>
</section>
{/* 用例列表 */}
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
<div className="border-b border-hairline px-5 py-3.5">
<h3 className="text-sm font-medium text-foreground">
{total}
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] text-left text-sm">
<thead>
<tr className="border-b border-hairline text-xs text-muted-soft">
<th className="w-12 px-5 py-2.5 font-medium">#</th>
<th className="px-3 py-2.5 font-medium"></th>
<th className="w-28 px-3 py-2.5 font-medium"></th>
<th className="w-10 px-3 py-2.5" />
</tr>
</thead>
<tbody>
{run.cases.map((item, index) => {
const expanded = expandedIds.has(item.id);
const canExpand =
item.status === "fail" || item.status === "pass";
return (
<tr
key={item.id}
className="border-b border-hairline last:border-b-0"
>
<td colSpan={4} className="p-0">
<button
type="button"
disabled={!canExpand}
onClick={() => toggleExpanded(item.id)}
className={cn(
"flex w-full items-start gap-0 px-5 py-3 text-left transition-colors",
canExpand && "hover:bg-canvas-soft/70",
!canExpand && "cursor-default",
)}
>
<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>
{expanded && (
<span className="mt-3 grid gap-3 sm:grid-cols-2">
<DetailBlock
label="预期结果"
value={item.expected}
/>
<DetailBlock
label="实际结果"
value={item.actual}
/>
</span>
)}
</span>
<span className="w-28 shrink-0 px-3 pt-0.5">
<BatchCaseStatusBadge status={item.status} />
</span>
<span className="flex w-10 shrink-0 justify-end pt-0.5 text-muted-soft">
{canExpand ? (
expanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)
) : null}
</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "success" | "destructive" | "primary" | "muted";
}) {
const toneClass = {
success: "text-success",
destructive: "text-destructive",
primary: "text-primary",
muted: "text-muted-foreground",
}[tone];
return (
<div className="min-w-[4.5rem]">
<div className="text-xs text-muted-soft">{label}</div>
<div className={cn("mt-0.5 text-xl font-medium tabular-nums", toneClass)}>
{value}
</div>
</div>
);
}
function DetailBlock({ label, value }: { label: string; value: string }) {
return (
<span className="block rounded-xl bg-canvas-soft/80 px-3 py-2.5">
<span className="block text-xs text-muted-soft">{label}</span>
<span className="mt-1 block text-sm leading-6 text-muted-foreground">
{value}
</span>
</span>
);
}

View File

@@ -25,12 +25,15 @@ export function SectionCard({
icon,
title,
description,
action,
children,
className,
}: {
icon?: ReactNode;
title?: string;
description?: string;
/** 标题行右侧操作区(如添加按钮) */
action?: ReactNode;
children: ReactNode;
className?: string;
}) {
@@ -52,12 +55,13 @@ export function SectionCard({
{icon}
</div>
)}
<div className="flex min-w-0 items-center gap-1.5">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<CardTitle className="text-sm font-medium leading-none">
{title}
</CardTitle>
{description && <HelpHint text={description} />}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
</CardHeader>
)}

View File

@@ -9,6 +9,7 @@ import {
Brain,
ChevronLeft,
ChevronUp,
ClipboardList,
Clock3,
Database,
HelpCircle,
@@ -51,6 +52,11 @@ const monitorSubItems: NavItem[] = [
{ href: "/dashboard", label: "数据看板", icon: Database },
];
const testSubItems: NavItem[] = [
{ href: "/test/cases", label: "测试用例", icon: ClipboardList },
{ href: "/test/batch", label: "批量测试", icon: PlayCircle },
];
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
@@ -62,6 +68,7 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const componentActive = componentSubItems.some((item) => isActive(item.href));
const monitorActive = monitorSubItems.some((item) => isActive(item.href));
const testActive = testSubItems.some((item) => isActive(item.href));
async function handleLogout() {
await logout();
@@ -201,13 +208,43 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
</div>
<div className="pt-2">
<NavButton
active={isActive("/test")}
collapsed={collapsed}
icon={PlayCircle}
label="测试助手"
href="/test"
/>
{collapsed ? (
<div
className="flex h-8 items-center justify-center"
aria-hidden="true"
title="测试助手"
>
<span className="h-px w-6 rounded-full bg-hairline-strong" />
</div>
) : (
<div
className={[
"flex h-11 w-full items-center gap-3 rounded-full px-3 text-sm",
testActive ? "text-foreground" : "text-muted-foreground",
].join(" ")}
>
<PlayCircle size={18} />
<span className="font-medium"></span>
</div>
)}
<div
className={[
"mt-1 space-y-1 transition-[padding] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
collapsed ? "pl-0" : "pl-5",
].join(" ")}
>
{testSubItems.map((item) => (
<NavButton
key={item.href}
active={isActive(item.href)}
collapsed={collapsed}
icon={item.icon}
label={item.label}
href={item.href}
/>
))}
</div>
</div>
</div>

View File

@@ -10,12 +10,15 @@ export function ListPageLayout({
title,
description,
action,
topbarAction,
children,
className,
}: {
title: string;
description?: ReactNode;
action?: ReactNode;
/** 右上角 topbar 操作区(如主 CTA */
topbarAction?: ReactNode;
children: ReactNode;
className?: string;
}) {
@@ -24,7 +27,7 @@ export function ListPageLayout({
return (
<>
<TopbarPortal>
<PageTopbar title={title} />
<PageTopbar title={title} action={topbarAction} />
</TopbarPortal>
<div

View File

@@ -0,0 +1,784 @@
"use client";
/**
* 批量测试页MVP
* 三阶段:配置 → 运行中 → 已完成。执行进度为前端 mock后续可换真实引擎。
*/
import {
ChevronDown,
ChevronRight,
FolderOpen,
Loader2,
Play,
RotateCcw,
Settings2,
Square,
Target,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { BatchRunCompletedView } from "@/components/batch-test/batch-run-completed";
import { BatchRunRunningView } from "@/components/batch-test/batch-run-running";
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
import { SectionCard } from "@/components/editor/section-card";
import { ListPageLayout } from "@/components/layout/list-page-layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
createBatchRunSnapshot,
type BatchRunPhase,
type BatchRunSnapshot,
} from "@/data/batch-run";
import {
listTestCases,
listTestSuites,
suiteCaseStats,
type TestCase,
type TestSuite,
} from "@/data/test-suites";
import { assistantsApi, type Assistant } from "@/lib/api";
const CONCURRENCY_OPTIONS = ["1", "2", "3", "5", "10"] as const;
const FAIL_STRATEGY_OPTIONS = [
{ value: "continue", label: "继续执行全部用例" },
{ value: "stop_on_fail", label: "遇失败立即停止" },
] as const;
type FailStrategy = (typeof FAIL_STRATEGY_OPTIONS)[number]["value"];
type BatchSectionId = "target" | "scope" | "settings";
const BATCH_SECTIONS = [
{ id: "target", label: "运行目标" },
{ id: "scope", label: "测试范围" },
{ id: "settings", label: "运行设置" },
] as const;
const TICK_MS = 900;
function getAppScrollContainer(): HTMLElement | null {
return document.querySelector<HTMLElement>(".app-content");
}
function buildRunTitle(
selected: TestCase[],
suites: TestSuite[],
): string {
const suiteIds = [...new Set(selected.map((item) => item.suiteId))];
if (suiteIds.length === 1) {
return (
suites.find((suite) => suite.id === suiteIds[0])?.name ?? "批量测试"
);
}
if (suiteIds.length > 1) {
return `${suiteIds.length} 个测试集 · ${selected.length} 个用例`;
}
return "批量测试";
}
export function BatchTestPage() {
const [phase, setPhase] = useState<BatchRunPhase>("config");
const [run, setRun] = useState<BatchRunSnapshot | null>(null);
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [loadingAssistants, setLoadingAssistants] = useState(true);
const [assistantId, setAssistantId] = useState("");
const [suites, setSuites] = useState<TestSuite[]>([]);
const [casesBySuite, setCasesBySuite] = useState<Record<string, TestCase[]>>(
{},
);
const [expandedSuiteIds, setExpandedSuiteIds] = useState<Set<string>>(
new Set(),
);
const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>(
new Set(),
);
const [concurrency, setConcurrency] = useState<string>("3");
const [timeoutSecs, setTimeoutSecs] = useState("30");
const [failStrategy, setFailStrategy] = useState<FailStrategy>("continue");
const [activeSection, setActiveSection] =
useState<BatchSectionId>("target");
const sectionRefs = useRef<Record<BatchSectionId, HTMLElement | null>>({
target: null,
scope: null,
settings: null,
});
const selectedAnchorRef = useRef<BatchSectionId | null>(null);
const stopOnFailRef = useRef(failStrategy === "stop_on_fail");
stopOnFailRef.current = failStrategy === "stop_on_fail";
useEffect(() => {
if (phase !== "config") return;
const scrollContainer = getAppScrollContainer();
if (!scrollContainer) return;
let animationFrame = 0;
function updateActiveSection() {
if (selectedAnchorRef.current) {
setActiveSection(selectedAnchorRef.current);
return;
}
const containerTop = scrollContainer!.getBoundingClientRect().top;
const activationLine = containerTop + 24;
let nextSection: BatchSectionId = BATCH_SECTIONS[0].id;
for (const section of BATCH_SECTIONS) {
const element = sectionRefs.current[section.id];
if (element && element.getBoundingClientRect().top <= activationLine) {
nextSection = section.id;
}
}
const reachedBottom =
scrollContainer!.scrollHeight > scrollContainer!.clientHeight + 8 &&
scrollContainer!.scrollHeight -
scrollContainer!.scrollTop -
scrollContainer!.clientHeight <
8;
if (reachedBottom) {
nextSection = BATCH_SECTIONS[BATCH_SECTIONS.length - 1].id;
}
setActiveSection((current) =>
current === nextSection ? current : nextSection,
);
}
function scheduleUpdate() {
window.cancelAnimationFrame(animationFrame);
animationFrame = window.requestAnimationFrame(updateActiveSection);
}
function releaseSelectedAnchor() {
selectedAnchorRef.current = null;
scheduleUpdate();
}
scheduleUpdate();
scrollContainer.addEventListener("scroll", scheduleUpdate, {
passive: true,
});
scrollContainer.addEventListener("wheel", releaseSelectedAnchor, {
passive: true,
});
scrollContainer.addEventListener("touchstart", releaseSelectedAnchor, {
passive: true,
});
window.addEventListener("resize", scheduleUpdate);
return () => {
window.cancelAnimationFrame(animationFrame);
scrollContainer.removeEventListener("scroll", scheduleUpdate);
scrollContainer.removeEventListener("wheel", releaseSelectedAnchor);
scrollContainer.removeEventListener("touchstart", releaseSelectedAnchor);
window.removeEventListener("resize", scheduleUpdate);
};
}, [phase]);
function scrollToSection(sectionId: BatchSectionId) {
const scrollContainer = getAppScrollContainer();
const section = sectionRefs.current[sectionId];
if (!scrollContainer || !section) return;
const containerTop = scrollContainer.getBoundingClientRect().top;
const sectionTop = section.getBoundingClientRect().top;
selectedAnchorRef.current = sectionId;
setActiveSection(sectionId);
scrollContainer.scrollTo({
top: scrollContainer.scrollTop + (sectionTop - containerTop) - 12,
behavior: "smooth",
});
}
useEffect(() => {
const nextSuites = listTestSuites();
setSuites(nextSuites);
const map: Record<string, TestCase[]> = {};
const allCaseIds = new Set<string>();
for (const suite of nextSuites) {
const items = listTestCases(suite.id);
map[suite.id] = items;
for (const item of items) allCaseIds.add(item.id);
}
setCasesBySuite(map);
setSelectedCaseIds(allCaseIds);
void (async () => {
try {
const list = await assistantsApi.list();
setAssistants(list);
if (list[0]) setAssistantId(list[0].id);
} catch {
// 无助手时仍可浏览配置页
} finally {
setLoadingAssistants(false);
}
})();
}, []);
// mock 执行引擎:按并发推进用例状态
useEffect(() => {
if (phase !== "running" || !run) return;
const limit = Number(concurrency) || 3;
let finished = false;
function tick(settleRunning: boolean) {
if (finished) return;
setRun((prev) => {
if (!prev || finished) return prev;
let cases = prev.cases.map((item) => ({ ...item }));
// 1) 结算上一拍仍在运行的用例(首拍只启动,不结算)
let sawFail = false;
if (settleRunning) {
for (const item of cases) {
if (item.status !== "running") continue;
item.status = item.failReason ? "fail" : "pass";
if (item.status === "fail") sawFail = true;
}
}
// 失败即停:剩余全部标记未执行
if (sawFail && stopOnFailRef.current) {
cases = cases.map((item) =>
item.status === "waiting" || item.status === "running"
? { ...item, status: "skipped" as const }
: item,
);
finished = true;
window.setTimeout(() => setPhase("completed"), 0);
return {
...prev,
cases,
finishedAt: new Date().toISOString(),
stopped: true,
};
}
// 2) 按并发补齐运行中
let running = cases.filter((item) => item.status === "running").length;
for (const item of cases) {
if (running >= limit) break;
if (item.status !== "waiting") continue;
item.status = "running";
running += 1;
}
const pending = cases.some(
(item) => item.status === "waiting" || item.status === "running",
);
if (!pending) {
finished = true;
window.setTimeout(() => setPhase("completed"), 0);
return {
...prev,
cases,
finishedAt: new Date().toISOString(),
stopped: false,
};
}
return { ...prev, cases };
});
}
// 立刻拉起第一批,再按节拍结算/推进
tick(false);
const timer = window.setInterval(() => tick(true), TICK_MS);
return () => window.clearInterval(timer);
}, [phase, concurrency, run?.startedAt]);
const allCaseIds = useMemo(
() =>
Object.values(casesBySuite)
.flat()
.map((item) => item.id),
[casesBySuite],
);
const selectedCases = useMemo(() => {
const ordered: TestCase[] = [];
for (const suite of suites) {
for (const item of casesBySuite[suite.id] ?? []) {
if (selectedCaseIds.has(item.id)) ordered.push(item);
}
}
return ordered;
}, [suites, casesBySuite, selectedCaseIds]);
const selectedCount = selectedCaseIds.size;
const allSelected =
allCaseIds.length > 0 && allCaseIds.every((id) => selectedCaseIds.has(id));
const someSelected = selectedCount > 0 && !allSelected;
const timeoutValue = Number(timeoutSecs);
const timeoutValid =
Number.isFinite(timeoutValue) && timeoutValue > 0 && timeoutValue <= 600;
const canStart =
Boolean(assistantId) &&
selectedCount > 0 &&
timeoutValid &&
!loadingAssistants &&
phase === "config";
function toggleSelectAll() {
if (allSelected) {
setSelectedCaseIds(new Set());
return;
}
setSelectedCaseIds(new Set(allCaseIds));
}
function toggleSuite(suiteId: string) {
const suiteCaseIds = (casesBySuite[suiteId] ?? []).map((item) => item.id);
if (suiteCaseIds.length === 0) return;
const allInSuiteSelected = suiteCaseIds.every((id) =>
selectedCaseIds.has(id),
);
setSelectedCaseIds((prev) => {
const next = new Set(prev);
if (allInSuiteSelected) {
for (const id of suiteCaseIds) next.delete(id);
} else {
for (const id of suiteCaseIds) next.add(id);
}
return next;
});
}
function toggleCase(caseId: string) {
setSelectedCaseIds((prev) => {
const next = new Set(prev);
if (next.has(caseId)) next.delete(caseId);
else next.add(caseId);
return next;
});
}
function toggleExpanded(suiteId: string) {
setExpandedSuiteIds((prev) => {
const next = new Set(prev);
if (next.has(suiteId)) next.delete(suiteId);
else next.add(suiteId);
return next;
});
}
function startRun(cases: TestCase[]) {
const assistantName =
assistants.find((item) => item.id === assistantId)?.name ?? "助手";
const snapshot = createBatchRunSnapshot({
title: buildRunTitle(cases, suites),
assistantName,
cases,
});
setRun(snapshot);
setPhase("running");
}
function handleStart() {
if (!canStart) return;
startRun(selectedCases);
}
function handleStop() {
setRun((prev) => {
if (!prev) return prev;
return {
...prev,
stopped: true,
finishedAt: new Date().toISOString(),
cases: prev.cases.map((item) =>
item.status === "waiting" || item.status === "running"
? { ...item, status: "skipped" }
: item,
),
};
});
setPhase("completed");
}
function handleRerun() {
if (selectedCases.length === 0) {
setPhase("config");
setRun(null);
return;
}
startRun(selectedCases);
}
function handleBackToConfig() {
setPhase("config");
setRun(null);
}
if (phase === "running" && run) {
return (
<ListPageLayout
title="批量测试 / 运行中"
description="批量运行测试用例,实时查看执行进度与结果。"
className="max-w-[960px]"
topbarAction={
<Button
variant="outline"
className="gap-2 rounded-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleStop}
>
<Square size={14} className="fill-current" />
</Button>
}
>
<BatchRunRunningView run={run} />
</ListPageLayout>
);
}
if (phase === "completed" && run) {
return (
<ListPageLayout
title="批量测试 / 已完成"
description="批量测试已完成,您可以查看结果或重新运行。"
className="max-w-[960px]"
topbarAction={
<div className="flex items-center gap-2">
<Button
variant="outline"
className="rounded-full border-hairline-strong"
onClick={handleBackToConfig}
>
</Button>
<Button
className="gap-2 rounded-full px-4"
onClick={handleRerun}
>
<RotateCcw size={15} />
</Button>
</div>
}
>
<BatchRunCompletedView run={run} />
</ListPageLayout>
);
}
return (
<ListPageLayout
title="批量测试"
description="配置一次批量运行:选择被测助手、测试范围与基础运行参数。"
className="max-w-[880px]"
topbarAction={
<Button
className="gap-2 rounded-full px-4"
disabled={!canStart}
onClick={handleStart}
>
<Play size={16} />
</Button>
}
>
<SectionAnchorTabs
ariaLabel="批量测试分区"
sections={BATCH_SECTIONS}
activeSectionId={activeSection}
onSelect={(sectionId) =>
scrollToSection(sectionId as BatchSectionId)
}
/>
<div className="space-y-4">
<section
ref={(element) => {
sectionRefs.current.target = element;
}}
className="scroll-mt-3"
>
<SectionCard
icon={<Target size={15} />}
title="运行目标"
description="选择本次批量测试要对齐的助手配置"
>
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
</span>
{loadingAssistants ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : assistants.length === 0 ? (
<p className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-4 text-sm text-muted-foreground">
</p>
) : (
<Select value={assistantId} onValueChange={setAssistantId}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择被测助手" />
</SelectTrigger>
<SelectContent>
{assistants.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</label>
</SectionCard>
</section>
<section
ref={(element) => {
sectionRefs.current.scope = element;
}}
className="scroll-mt-3"
>
<SectionCard
icon={<FolderOpen size={15} />}
title="选择测试范围"
description="可全选,或展开测试集勾选单个用例"
action={
<span className="text-xs tabular-nums text-muted-foreground">
{selectedCount}
</span>
}
>
<div className="space-y-3">
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
className="size-3.5 accent-primary"
checked={allSelected}
ref={(element) => {
if (!element) return;
element.indeterminate = someSelected;
}}
onChange={toggleSelectAll}
disabled={allCaseIds.length === 0}
/>
</label>
{suites.length === 0 ? (
<p className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-6 text-center text-xs text-muted-soft">
</p>
) : (
<ul className="space-y-2">
{suites.map((suite) => {
const suiteCases = casesBySuite[suite.id] ?? [];
const suiteCaseIds = suiteCases.map((item) => item.id);
const selectedInSuite = suiteCaseIds.filter((id) =>
selectedCaseIds.has(id),
).length;
const suiteAllSelected =
suiteCaseIds.length > 0 &&
selectedInSuite === suiteCaseIds.length;
const suiteSomeSelected =
selectedInSuite > 0 && !suiteAllSelected;
const expanded = expandedSuiteIds.has(suite.id);
const total = suiteCaseStats(suite.id).total;
return (
<li
key={suite.id}
className="rounded-2xl border border-hairline bg-card"
>
<div className="flex items-center gap-2 px-3 py-2.5">
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={
expanded
? `收起 ${suite.name}`
: `展开 ${suite.name}`
}
onClick={() => toggleExpanded(suite.id)}
>
{expanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</button>
<input
type="checkbox"
className="size-3.5 shrink-0 accent-primary"
checked={suiteAllSelected}
ref={(element) => {
if (!element) return;
element.indeterminate = suiteSomeSelected;
}}
onChange={() => toggleSuite(suite.id)}
aria-label={`选择测试集 ${suite.name}`}
/>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => toggleExpanded(suite.id)}
>
<div className="truncate text-sm font-medium text-foreground">
{suite.name}
</div>
<div className="mt-0.5 truncate text-xs text-muted-soft">
{suite.description || suite.id} · {total}
{selectedInSuite > 0
? ` · 已选 ${selectedInSuite}`
: ""}
</div>
</button>
</div>
{expanded && (
<ul className="space-y-1 border-t border-hairline px-3 py-2 pl-12">
{suiteCases.length === 0 ? (
<li className="py-2 text-xs text-muted-soft">
</li>
) : (
suiteCases.map((item) => (
<li key={item.id}>
<label className="flex cursor-pointer items-start gap-2 rounded-xl px-2 py-2 transition-colors hover:bg-canvas-soft/80">
<input
type="checkbox"
className="mt-0.5 size-3.5 shrink-0 accent-primary"
checked={selectedCaseIds.has(item.id)}
onChange={() => toggleCase(item.id)}
/>
<span className="min-w-0">
<span className="block truncate text-sm text-foreground">
{item.name}
</span>
{item.description && (
<span className="mt-0.5 block truncate text-xs text-muted-soft">
{item.description}
</span>
)}
</span>
</label>
</li>
))
)}
</ul>
)}
</li>
);
})}
</ul>
)}
</div>
</SectionCard>
</section>
<section
ref={(element) => {
sectionRefs.current.settings = element;
}}
className="scroll-mt-3"
>
<SectionCard
icon={<Settings2 size={15} />}
title="运行设置"
description="MVP 仅保留并发、超时与失败策略"
>
<div className="grid gap-5 sm:grid-cols-3">
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
</span>
<Select value={concurrency} onValueChange={setConcurrency}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONCURRENCY_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
</span>
<div className="relative">
<Input
type="number"
min={1}
max={600}
value={timeoutSecs}
onChange={(event) => setTimeoutSecs(event.target.value)}
className="border-hairline-strong bg-background pr-10"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-xs text-muted-soft">
</span>
</div>
{!timeoutValid && (
<p className="text-xs text-destructive">
1600
</p>
)}
</label>
<label className="block space-y-2 sm:col-span-1">
<span className="text-sm font-medium text-foreground">
</span>
<Select
value={failStrategy}
onValueChange={(value) =>
setFailStrategy(value as FailStrategy)
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FAIL_STRATEGY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
</SectionCard>
</section>
</div>
</ListPageLayout>
);
}

View File

@@ -0,0 +1,822 @@
"use client";
/**
* 测试用例管理:
* - 列表页Test Suite 一级容器
* - 详情页:左列表 + 右「单步回复」编辑器(只编辑,不运行)
*/
import {
ChevronLeft,
Copy,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Rocket,
Save,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import {
AssistantIdentity,
EditableTitle,
EditorBackButton,
} from "@/components/assistant-editor/editor-controls";
import {
ListPageLayout,
ListPageSection,
} from "@/components/layout/list-page-layout";
import { TopbarPortal } from "@/components/layout/topbar-portal";
import {
NextReplyEditorBody,
type CaseEditorDraft,
} from "@/components/test-cases/next-reply-editor";
import { SuiteCaseList } from "@/components/test-cases/suite-case-list";
import { Button } from "@/components/ui/button";
import { DataList } from "@/components/ui/data-list";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { SearchInput } from "@/components/ui/search-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
createTestCase,
createTestSuite,
duplicateTestCase,
duplicateTestSuite,
formatSuiteResult,
getTestSuite,
listTestCases,
listTestSuites,
removeTestCase,
removeTestCases,
removeTestSuite,
reorderTestCases,
suiteCaseStats,
SUPPORTED_TEST_CASE_KIND,
TEST_CASE_KIND_LABEL,
TEST_CASE_KIND_OPTIONS,
updateTestCase,
updateTestSuite,
type TestCase,
type TestCaseKind,
type TestSuite,
} from "@/data/test-suites";
import { assistantsApi, type Assistant } from "@/lib/api";
// 路由驱动:
// /test/cases → list
// /test/cases/new → create suite
// /test/cases/[id] → suite detail (cases)
export type TestCasesPageProps =
| { mode: "list" }
| { mode: "create" }
| { mode: "detail"; suiteId: string };
export function TestCasesPage(props: TestCasesPageProps) {
if (props.mode === "list") return <SuiteListView />;
if (props.mode === "create") return <SuiteCreateView />;
return <SuiteDetailView suiteId={props.suiteId} />;
}
// ─── Suite 列表 ──────────────────────────────────────────────────────────────
function SuiteListView() {
const router = useRouter();
const [suites, setSuites] = useState<TestSuite[]>([]);
const [search, setSearch] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
setSuites(listTestSuites());
}, []);
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
return suites.filter((suite) => {
if (!keyword) return true;
return [suite.name, suite.description, suite.assistantName, suite.id]
.join(" ")
.toLowerCase()
.includes(keyword);
});
}, [suites, search]);
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize;
const paginated = filtered.slice(pageStart, pageEnd);
function openSuite(suite: TestSuite) {
router.push(`/test/cases/${suite.id}`);
}
function duplicateSuite(suite: TestSuite) {
const copied = duplicateTestSuite(suite.id);
if (!copied) return;
setSuites(listTestSuites());
}
function removeSuite(suite: TestSuite) {
if (
!window.confirm(
`确定删除测试集“${suite.name}”及其全部测试用例吗?`,
)
) {
return;
}
setDeletingId(suite.id);
removeTestSuite(suite.id);
setSuites(listTestSuites());
setDeletingId(null);
}
return (
<ListPageLayout
title="测试用例"
description="管理用于助手调试的单步回复测试场景。"
action={
<Button
className="w-full shrink-0 gap-2 sm:w-auto"
onClick={() => router.push("/test/cases/new")}
>
<Plus size={16} />
</Button>
}
>
<ListPageSection>
<ListToolbar
className="lg:justify-end"
search={
<SearchInput
value={search}
onChange={(value) => {
setSearch(value);
setCurrentPage(1);
}}
placeholder="搜索测试集..."
className="lg:w-[320px]"
/>
}
/>
<DataList<TestSuite>
rows={paginated}
rowKey={(suite) => suite.id}
onRowClick={openSuite}
empty={{
title: suites.length === 0 ? "暂无测试集" : "未找到匹配的测试集",
description:
suites.length === 0
? "点击右上角「新建测试集」开始。"
: "请调整关键词后再试。",
}}
pagination={{
page: safeCurrentPage,
totalPages,
onPageChange: setCurrentPage,
summary:
filtered.length === 0
? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filtered.length)} / 共 ${filtered.length} 个测试集`,
}}
columns={[
{
key: "name",
header: "测试集名称",
width: "md:w-[320px]",
cell: (suite) => (
<>
<div className="truncate font-medium text-foreground">
{suite.name}
</div>
<div className="mt-1 truncate text-xs text-muted-soft">
{suite.description || suite.id}
</div>
</>
),
},
{
key: "assistant",
header: "关联助手",
width: "md:w-[160px]",
cellClassName: "text-muted-foreground",
cell: (suite) => suite.assistantName || "—",
},
{
key: "caseCount",
header: "用例数",
width: "md:w-[96px]",
cellClassName: "tabular-nums text-muted-foreground",
cell: (suite) => suiteCaseStats(suite.id).total,
},
{
key: "lastResult",
header: "最近结果",
width: "md:w-[112px]",
cellClassName: "tabular-nums text-muted-foreground",
cell: (suite) => formatSuiteResult(suite.id),
},
{
key: "actions",
header: "操作",
align: "right",
cell: (suite) => (
<div
className="flex justify-end gap-2"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
openSuite(suite);
}}
>
<Pencil size={14} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${suite.name} 更多操作`}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem
className="rounded-lg"
onSelect={() => duplicateSuite(suite)}
>
<Copy size={14} />
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
disabled={deletingId === suite.id}
onSelect={(event) => {
event.preventDefault();
window.setTimeout(() => removeSuite(suite), 0);
}}
>
{deletingId === suite.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
]}
/>
</ListPageSection>
</ListPageLayout>
);
}
// ─── 新建测试集 ──────────────────────────────────────────────────────────────
function SuiteCreateView() {
const router = useRouter();
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [loadingAssistants, setLoadingAssistants] = useState(true);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [assistantName, setAssistantName] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
void (async () => {
try {
const list = await assistantsApi.list();
setAssistants(list);
if (list[0]) setAssistantName(list[0].name);
} catch {
setAssistantName("视频快处助手");
} finally {
setLoadingAssistants(false);
}
})();
}, []);
function confirmCreate() {
if (!name.trim() || creating) return;
setCreating(true);
const saved = createTestSuite({
name,
description,
assistantName,
});
router.push(`/test/cases/${saved.id}`);
}
return (
<ListPageLayout
title="新建测试集"
className="max-w-[1180px]"
description="测试集是单步回复用例的业务分组。确认后进入用例编辑。"
action={
<Button
variant="outline"
className="w-full shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground sm:w-auto"
onClick={() => router.push("/test/cases")}
>
<ChevronLeft size={16} />
</Button>
}
>
<ListPageSection>
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Input
value={name}
autoFocus
onChange={(event) => setName(event.target.value)}
placeholder="例如:事故基础流程"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
</ListPageSection>
<ListPageSection>
<div className="space-y-5">
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground"></div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="用途说明(可选)"
rows={4}
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
<div>
<div className="mb-2 text-sm font-medium text-foreground">
</div>
{loadingAssistants ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : assistants.length > 0 ? (
<Select value={assistantName} onValueChange={setAssistantName}>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue placeholder="选择关联助手" />
</SelectTrigger>
<SelectContent>
{assistants.map((item) => (
<SelectItem key={item.id} value={item.name}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={assistantName}
onChange={(event) => setAssistantName(event.target.value)}
placeholder="助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
)}
</div>
</div>
</ListPageSection>
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={creating}
onClick={() => router.push("/test/cases")}
>
</Button>
<Button
className="gap-2"
disabled={!name.trim() || creating}
onClick={confirmCreate}
>
{creating ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Rocket size={16} />
)}
</Button>
</div>
</ListPageLayout>
);
}
// ─── Suite 详情:左列表 + 右编辑 ─────────────────────────────────────────────
function caseToDraft(item: TestCase): CaseEditorDraft {
return {
name: item.name,
kind: item.kind,
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
userInput: item.userInput,
assertionType: item.assertionType,
keywords: [...item.keywords],
keywordMatchMode: item.keywordMatchMode,
llmCriteria: item.llmCriteria,
};
}
function draftsEqual(a: CaseEditorDraft, b: CaseEditorDraft) {
return JSON.stringify(a) === JSON.stringify(b);
}
function SuiteDetailView({ suiteId }: { suiteId: string }) {
const router = useRouter();
const [suite, setSuite] = useState<TestSuite | null>(null);
const [cases, setCases] = useState<TestCase[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [draft, setDraft] = useState<CaseEditorDraft | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<CaseEditorDraft | null>(
null,
);
const [statusMessage, setStatusMessage] = useState("");
const [selectionMode, setSelectionMode] = useState(false);
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
function reload(preferId?: string | null) {
const nextSuite = getTestSuite(suiteId);
const nextCases = listTestCases(suiteId);
setSuite(nextSuite);
setCases(nextCases);
const nextSelected =
(preferId && nextCases.some((item) => item.id === preferId)
? preferId
: null) ??
(selectedId && nextCases.some((item) => item.id === selectedId)
? selectedId
: null) ??
nextCases[0]?.id ??
null;
setSelectedId(nextSelected);
if (nextSelected) {
const item = nextCases.find((caseItem) => caseItem.id === nextSelected);
if (item) {
const nextDraft = caseToDraft(item);
setDraft(nextDraft);
setSavedSnapshot(nextDraft);
}
} else {
setDraft(null);
setSavedSnapshot(null);
}
}
useEffect(() => {
setSelectionMode(false);
setCheckedIds(new Set());
reload();
// eslint-disable-next-line react-hooks/exhaustive-deps -- route-driven load
}, [suiteId]);
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
return cases.filter((item) => {
if (!keyword) return true;
return [item.name, item.description, TEST_CASE_KIND_LABEL[item.kind]]
.join(" ")
.toLowerCase()
.includes(keyword);
});
}, [cases, search]);
const dirty =
draft !== null &&
savedSnapshot !== null &&
!draftsEqual(draft, savedSnapshot);
function selectCase(item: TestCase) {
if (dirty && !window.confirm("当前用例有未保存修改,切换将丢弃。继续?")) {
return;
}
setSelectedId(item.id);
const nextDraft = caseToDraft(item);
setDraft(nextDraft);
setSavedSnapshot(nextDraft);
setStatusMessage("");
}
function handleCreateCase() {
if (dirty && !window.confirm("当前用例有未保存修改,新建将丢弃。继续?")) {
return;
}
const created = createTestCase({
suiteId,
name: "未命名用例",
});
if (!created) return;
reload(created.id);
setStatusMessage("");
}
function handleSave() {
if (!selectedId || !draft) return;
const saved = updateTestCase(selectedId, {
name: draft.name.trim() || "未命名用例",
kind: draft.kind,
contextTurns: draft.contextTurns,
userInput: draft.userInput,
assertionType: draft.assertionType,
keywords: draft.keywords,
keywordMatchMode: draft.keywordMatchMode,
llmCriteria: draft.llmCriteria,
});
if (!saved) return;
const nextDraft = caseToDraft(saved);
setCases(listTestCases(suiteId));
setSuite(getTestSuite(suiteId));
setDraft(nextDraft);
setSavedSnapshot(nextDraft);
setStatusMessage("已保存");
window.setTimeout(() => setStatusMessage(""), 2000);
}
function handleDuplicateCase(item: TestCase) {
if (dirty && !window.confirm("当前用例有未保存修改,复制将丢弃。继续?")) {
return;
}
const copied = duplicateTestCase(item.id);
if (!copied) return;
reload(copied.id);
setStatusMessage("");
}
function handleDeleteCase(item: TestCase) {
if (!window.confirm(`确定删除测试用例“${item.name}”吗?`)) return;
removeTestCase(item.id);
setCheckedIds((prev) => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
reload(selectedId === item.id ? null : selectedId);
}
function handleDeleteSelected() {
if (!selectedId || !draft) return;
if (
!window.confirm(
`确定删除测试用例“${draft.name.trim() || "未命名用例"}”吗?`,
)
) {
return;
}
removeTestCase(selectedId);
reload(null);
}
function handleEnterSelectionMode() {
setSelectionMode(true);
setCheckedIds(new Set());
}
function handleExitSelectionMode() {
setSelectionMode(false);
setCheckedIds(new Set());
}
function handleToggleChecked(id: string) {
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleToggleSelectAll() {
const allChecked =
filtered.length > 0 && filtered.every((item) => checkedIds.has(item.id));
if (allChecked) {
setCheckedIds((prev) => {
const next = new Set(prev);
for (const item of filtered) next.delete(item.id);
return next;
});
return;
}
setCheckedIds((prev) => {
const next = new Set(prev);
for (const item of filtered) next.add(item.id);
return next;
});
}
function handleBulkDelete() {
const ids = filtered
.filter((item) => checkedIds.has(item.id))
.map((item) => item.id);
if (ids.length === 0) return;
if (!window.confirm(`确定删除选中的 ${ids.length} 个测试用例吗?`)) return;
removeTestCases(ids);
handleExitSelectionMode();
reload(selectedId && ids.includes(selectedId) ? null : selectedId);
}
function handleReorder(orderedIds: string[]) {
reorderTestCases(suiteId, orderedIds);
setCases(listTestCases(suiteId));
}
function renameSuite(nextName: string) {
if (!suite || nextName === suite.name) return;
const saved = updateTestSuite(suite.id, { name: nextName });
if (saved) setSuite(saved);
}
if (!suite) {
return (
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-4 py-16">
<div className="font-medium text-destructive"></div>
<Button
variant="outline"
size="sm"
className="w-fit border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => router.push("/test/cases")}
>
</Button>
</div>
);
}
return (
<>
<TopbarPortal>
<div className="flex h-full min-w-0 flex-1 items-center gap-2 sm:-ml-2 lg:-ml-4">
<EditorBackButton
ariaLabel="返回测试集列表"
onClick={() => router.push("/test/cases")}
/>
<EditableTitle
value={suite.name}
onChange={renameSuite}
placeholder="未命名测试集"
editLabel="测试集名称"
/>
<AssistantIdentity assistantId={suite.id} />
</div>
</TopbarPortal>
<div
data-app-content="full-bleed"
className="flex h-full min-h-0 flex-col overflow-hidden bg-background lg:flex-row"
>
<SuiteCaseList
cases={cases}
filtered={filtered}
selectedId={selectedId}
search={search}
selectionMode={selectionMode}
checkedIds={checkedIds}
onSearchChange={setSearch}
onSelectCase={selectCase}
onCreateCase={handleCreateCase}
onDuplicateCase={handleDuplicateCase}
onDeleteCase={handleDeleteCase}
onEnterSelectionMode={handleEnterSelectionMode}
onExitSelectionMode={handleExitSelectionMode}
onToggleChecked={handleToggleChecked}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDelete}
onReorder={handleReorder}
/>
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
{!draft ? (
<div className="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">
</div>
) : (
<>
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-hairline px-4 py-3 sm:gap-3 sm:px-6">
<EditableTitle
value={draft.name}
onChange={(value) =>
setDraft({ ...draft, name: value || "未命名用例" })
}
placeholder="未命名用例"
editLabel="用例名称"
variant="panel"
allowEmpty
/>
<Select
value={draft.kind}
onValueChange={(value) =>
setDraft({
...draft,
kind: value as TestCaseKind,
})
}
>
<SelectTrigger className="h-9 w-[132px] shrink-0 border-hairline-strong bg-background text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TEST_CASE_KIND_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex shrink-0 items-center gap-2">
{dirty ? (
<span className="text-xs text-amber-600"></span>
) : statusMessage ? (
<span className="text-xs text-muted-foreground">
{statusMessage}
</span>
) : null}
<Button
size="sm"
className="gap-1.5"
disabled={!dirty || !draft.name.trim()}
onClick={handleSave}
>
<Save size={14} />
</Button>
<Button
variant="outline"
size="sm"
className="border-hairline-strong text-muted-foreground hover:text-destructive"
onClick={handleDeleteSelected}
>
<Trash2 size={14} />
</Button>
</div>
</div>
{draft.kind === SUPPORTED_TEST_CASE_KIND ? (
<NextReplyEditorBody draft={draft} onChange={setDraft} />
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<div className="text-sm font-medium text-foreground">
{TEST_CASE_KIND_LABEL[draft.kind]} ·
</div>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
</p>
</div>
)}
</>
)}
</main>
</div>
</>
);
}

View File

@@ -1,10 +0,0 @@
import { PlaceholderPage } from "./PlaceholderPage";
export function TestPage() {
return (
<PlaceholderPage
title="测试助手"
description="在发布前通过实时视频对话测试助手的表现与交互体验。"
/>
);
}

View File

@@ -0,0 +1,555 @@
"use client";
/**
* 单步回复 / Next Reply Test 表单主体。
* 带与 prompt mode 相同的锚点导航 + 滚动高亮;标题栏由外层负责。
*/
import {
MessageSquareText,
MessagesSquare,
Plus,
Sparkles,
Target,
Trash2,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SectionCard } from "@/components/editor/section-card";
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
ASSERTION_TYPE_LABEL,
type AssertionType,
type ContextTurn,
type KeywordMatchMode,
type TestCaseKind,
} from "@/data/test-suites";
import { cn } from "@/lib/utils";
export type CaseEditorDraft = {
name: string;
kind: TestCaseKind;
contextTurns: ContextTurn[];
userInput: string;
assertionType: AssertionType;
keywords: string[];
keywordMatchMode: KeywordMatchMode;
llmCriteria: string;
};
type NextReplySectionId = "context" | "user_input" | "expectation";
const NEXT_REPLY_SECTIONS: {
id: NextReplySectionId;
label: string;
}[] = [
{ id: "context", label: "对话上下文" },
{ id: "user_input", label: "当前用户输入" },
{ id: "expectation", label: "结果预期" },
];
/** 单步回复表单主体(含锚点导航) */
export function NextReplyEditorBody({
draft,
onChange,
}: {
draft: CaseEditorDraft;
onChange: (next: CaseEditorDraft) => void;
}) {
const [keywordDraft, setKeywordDraft] = useState("");
const scrollContainerRef = useRef<HTMLDivElement>(null);
const sectionRefs = useRef<Record<NextReplySectionId, HTMLElement | null>>({
context: null,
user_input: null,
expectation: null,
});
const selectedAnchorRef = useRef<NextReplySectionId | null>(null);
const [activeSection, setActiveSection] =
useState<NextReplySectionId>("context");
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const scrollContainer: HTMLDivElement = container;
let animationFrame = 0;
function updateActiveSection() {
if (selectedAnchorRef.current) {
setActiveSection(selectedAnchorRef.current);
return;
}
const containerTop = scrollContainer.getBoundingClientRect().top;
const activationLine = containerTop + 24;
let nextSection: NextReplySectionId = NEXT_REPLY_SECTIONS[0].id;
for (const section of NEXT_REPLY_SECTIONS) {
const element = sectionRefs.current[section.id];
if (element && element.getBoundingClientRect().top <= activationLine) {
nextSection = section.id;
}
}
const reachedBottom =
scrollContainer.scrollHeight > scrollContainer.clientHeight + 8 &&
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
8;
if (reachedBottom) {
nextSection = NEXT_REPLY_SECTIONS[NEXT_REPLY_SECTIONS.length - 1].id;
}
setActiveSection((current) =>
current === nextSection ? current : nextSection,
);
}
function scheduleUpdate() {
window.cancelAnimationFrame(animationFrame);
animationFrame = window.requestAnimationFrame(updateActiveSection);
}
function releaseSelectedAnchor() {
selectedAnchorRef.current = null;
scheduleUpdate();
}
scheduleUpdate();
scrollContainer.addEventListener("scroll", scheduleUpdate, {
passive: true,
});
scrollContainer.addEventListener("wheel", releaseSelectedAnchor, {
passive: true,
});
scrollContainer.addEventListener("touchstart", releaseSelectedAnchor, {
passive: true,
});
window.addEventListener("resize", scheduleUpdate);
return () => {
window.cancelAnimationFrame(animationFrame);
scrollContainer.removeEventListener("scroll", scheduleUpdate);
scrollContainer.removeEventListener("wheel", releaseSelectedAnchor);
scrollContainer.removeEventListener("touchstart", releaseSelectedAnchor);
window.removeEventListener("resize", scheduleUpdate);
};
}, []);
function scrollToSection(sectionId: NextReplySectionId) {
const container = scrollContainerRef.current;
const section = sectionRefs.current[sectionId];
if (!container || !section) return;
const containerTop = container.getBoundingClientRect().top;
const sectionTop = section.getBoundingClientRect().top;
selectedAnchorRef.current = sectionId;
setActiveSection(sectionId);
container.scrollTo({
top: container.scrollTop + sectionTop - containerTop,
behavior: "smooth",
});
}
function updateContext(index: number, patch: Partial<ContextTurn>) {
onChange({
...draft,
contextTurns: draft.contextTurns.map((turn, turnIndex) =>
turnIndex === index ? { ...turn, ...patch } : turn,
),
});
}
function addContextTurn(role: "agent" | "user") {
onChange({
...draft,
contextTurns: [...draft.contextTurns, { role, content: "" }],
});
}
function removeContextTurn(index: number) {
onChange({
...draft,
contextTurns: draft.contextTurns.filter(
(_, turnIndex) => turnIndex !== index,
),
});
}
function addKeyword(raw: string) {
const value = raw.trim();
if (!value) return;
if (draft.keywords.includes(value)) {
setKeywordDraft("");
return;
}
onChange({
...draft,
keywords: [...draft.keywords, value],
});
setKeywordDraft("");
}
function removeKeyword(index: number) {
onChange({
...draft,
keywords: draft.keywords.filter((_, itemIndex) => itemIndex !== index),
});
}
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SectionAnchorTabs
ariaLabel="测试用例分区"
sections={NEXT_REPLY_SECTIONS}
activeSectionId={activeSection}
onSelect={(sectionId) =>
scrollToSection(sectionId as NextReplySectionId)
}
className="bg-background px-4 pt-3 sm:px-6 sm:pt-4 lg:px-8"
contentClassName="mx-auto w-full max-w-3xl"
/>
<div
ref={scrollContainerRef}
className="scrollbar-subtle min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none bg-background px-4 pb-6 pt-3 sm:px-6 sm:pb-8 lg:px-8 lg:pb-10"
>
<div className="mx-auto max-w-3xl space-y-3">
{/* 对话上下文 */}
<section
ref={(element) => {
sectionRefs.current.context = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<MessagesSquare size={15} />}
title="对话上下文"
description="可选:运行前的历史轮次,默认为空"
action={
<div className="flex gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 border-hairline-strong text-xs text-muted-foreground"
onClick={() => addContextTurn("agent")}
>
<Plus size={13} />
Agent
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 border-hairline-strong text-xs text-muted-foreground"
onClick={() => addContextTurn("user")}
>
<Plus size={13} />
User
</Button>
</div>
}
>
{draft.contextTurns.length === 0 ? (
<p className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-3 py-6 text-center text-xs text-muted-soft">
Agent / User
</p>
) : (
<div className="space-y-2">
{draft.contextTurns.map((turn, index) => (
<div key={index} className="flex items-start gap-2">
<Select
value={turn.role}
onValueChange={(value) =>
updateContext(index, {
role: value as "agent" | "user",
})
}
>
<SelectTrigger className="h-9 w-[96px] shrink-0 border-hairline-strong bg-background text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="agent">Agent</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<Input
value={turn.content}
onChange={(event) =>
updateContext(index, {
content: event.target.value,
})
}
placeholder={
turn.role === "agent"
? "请您简单描述一下事发经过。"
: "用户说的话…"
}
className="border-hairline-strong bg-background"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-soft hover:text-destructive"
onClick={() => removeContextTurn(index)}
aria-label="删除上下文"
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
)}
</SectionCard>
</section>
{/* 当前用户输入 */}
<section
ref={(element) => {
sectionRefs.current.user_input = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<MessageSquareText size={15} />}
title="当前用户输入"
description="本轮要测的用户原话"
>
<div className="flex items-start gap-2">
<span className="mt-2 w-14 shrink-0 text-xs font-medium text-muted-foreground">
User
</span>
<Input
value={draft.userInput}
onChange={(event) =>
onChange({ ...draft, userInput: event.target.value })
}
placeholder="喂"
className="border-hairline-strong bg-background"
/>
</div>
</SectionCard>
</section>
{/* 结果预期 */}
<section
ref={(element) => {
sectionRefs.current.expectation = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<Target size={15} />}
title="结果预期"
description="用关键词或 LLM 标准判断 Agent 下一句回复是否合格"
>
<div className="flex flex-wrap gap-2">
{(
[
{
value: "keyword" as const,
label: ASSERTION_TYPE_LABEL.keyword,
hint: "匹配回复中的词",
},
{
value: "llm" as const,
label: ASSERTION_TYPE_LABEL.llm,
hint: "用自然语言标准评判",
},
] as const
).map((option) => {
const active = draft.assertionType === option.value;
return (
<button
key={option.value}
type="button"
onClick={() =>
onChange({
...draft,
assertionType: option.value as AssertionType,
})
}
className={cn(
"flex min-w-[140px] flex-1 flex-col items-start gap-0.5 rounded-2xl border px-3.5 py-3 text-left transition-colors",
active
? "border-hairline-strong bg-surface-strong"
: "border-hairline bg-background hover:bg-canvas-soft/80",
)}
>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
{option.value === "llm" ? (
<Sparkles size={14} className="text-muted-foreground" />
) : (
<Target size={14} className="text-muted-foreground" />
)}
{option.label}
</span>
<span className="text-xs text-muted-soft">
{option.hint}
</span>
</button>
);
})}
</div>
{draft.assertionType === "keyword" ? (
<div className="space-y-4 border-t border-hairline pt-4">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">
</div>
<div className="flex min-h-11 flex-wrap items-center gap-2 rounded-2xl border border-hairline-strong bg-background px-3 py-2">
{draft.keywords.map((keyword, index) => (
<span
key={`${keyword}-${index}`}
className="inline-flex h-7 items-center gap-1 rounded-full bg-surface-strong px-2.5 text-xs text-foreground"
>
{keyword}
<button
type="button"
className="text-muted-soft hover:text-destructive"
onClick={() => removeKeyword(index)}
aria-label={`删除关键词 ${keyword}`}
>
<X size={12} />
</button>
</span>
))}
<Input
value={keywordDraft}
onChange={(event) =>
setKeywordDraft(event.target.value)
}
onKeyDown={(event) => {
if (
event.key === "Enter" ||
event.key === "," ||
event.key === ""
) {
event.preventDefault();
addKeyword(keywordDraft);
} else if (
event.key === "Backspace" &&
!keywordDraft &&
draft.keywords.length > 0
) {
removeKeyword(draft.keywords.length - 1);
}
}}
onBlur={() => addKeyword(keywordDraft)}
placeholder={
draft.keywords.length === 0
? "输入后回车,例如:我在"
: "继续添加…"
}
className="h-7 min-w-[140px] flex-1 border-0 bg-transparent px-1 shadow-none focus-visible:ring-0"
/>
</div>
<p className="text-xs text-muted-soft">
Agent
</p>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">
</div>
<div className="flex flex-wrap gap-2">
{(
[
{
value: "any" as const,
label: "任一匹配",
hint: "命中其中一个即可",
},
{
value: "all" as const,
label: "全部匹配",
hint: "需要全部出现",
},
] as const
).map((option) => {
const active =
draft.keywordMatchMode === option.value;
return (
<button
key={option.value}
type="button"
onClick={() =>
onChange({
...draft,
keywordMatchMode:
option.value as KeywordMatchMode,
})
}
className={cn(
"rounded-full border px-3.5 py-1.5 text-xs transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-hairline-strong text-muted-foreground hover:text-foreground",
)}
>
{option.label}
<span
className={cn(
"ml-1.5",
active
? "text-primary-foreground/80"
: "text-muted-soft",
)}
>
· {option.hint}
</span>
</button>
);
})}
</div>
</div>
</div>
) : (
<div className="space-y-2 border-t border-hairline pt-4">
<div className="text-sm font-medium text-foreground">
</div>
<Textarea
value={draft.llmCriteria}
onChange={(event) =>
onChange({
...draft,
llmCriteria: event.target.value,
})
}
rows={5}
placeholder={
"Agent 应当回应用户的主动唤醒,同时继续引导用户描述事故情况,不应该无响应。"
}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background"
/>
<p className="text-xs text-muted-soft">
LLM
</p>
</div>
)}
</SectionCard>
</section>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,409 @@
"use client";
/**
* 测试集左侧用例列表:
* - 普通模式点击打开编辑hover 显示拖拽手柄排序
* - 选择模式checkbox + 顶部批量操作栏;禁用拖拽
*/
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
CheckSquare,
Copy,
GripVertical,
MoreHorizontal,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SearchInput } from "@/components/ui/search-input";
import {
TEST_CASE_KIND_LABEL,
type TestCase,
} from "@/data/test-suites";
import { cn } from "@/lib/utils";
export function SuiteCaseList({
cases,
filtered,
selectedId,
search,
selectionMode,
checkedIds,
onSearchChange,
onSelectCase,
onCreateCase,
onDuplicateCase,
onDeleteCase,
onEnterSelectionMode,
onExitSelectionMode,
onToggleChecked,
onToggleSelectAll,
onBulkDelete,
onReorder,
}: {
cases: TestCase[];
filtered: TestCase[];
selectedId: string | null;
search: string;
selectionMode: boolean;
checkedIds: Set<string>;
onSearchChange: (value: string) => void;
onSelectCase: (item: TestCase) => void;
onCreateCase: () => void;
onDuplicateCase: (item: TestCase) => void;
onDeleteCase: (item: TestCase) => void;
onEnterSelectionMode: () => void;
onExitSelectionMode: () => void;
onToggleChecked: (id: string) => void;
onToggleSelectAll: () => void;
onBulkDelete: () => void;
onReorder: (orderedIds: string[]) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const allFilteredChecked =
filtered.length > 0 && filtered.every((item) => checkedIds.has(item.id));
const checkedCount = filtered.filter((item) => checkedIds.has(item.id)).length;
const dragEnabled = !selectionMode;
function handleDragEnd(event: DragEndEvent) {
if (!dragEnabled) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const filteredIds = filtered.map((item) => item.id);
const oldIndex = filteredIds.indexOf(String(active.id));
const newIndex = filteredIds.indexOf(String(over.id));
if (oldIndex < 0 || newIndex < 0) return;
const nextFilteredIds = arrayMove(filteredIds, oldIndex, newIndex);
const filteredSet = new Set(filteredIds);
let cursor = 0;
// 筛选视图内重排:未出现在筛选结果中的用例保持相对位置
const nextFullOrder = cases.map((item) => {
if (!filteredSet.has(item.id)) return item.id;
return nextFilteredIds[cursor++];
});
onReorder(nextFullOrder);
}
return (
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-hairline bg-background lg:w-[32%] lg:border-b-0 lg:border-r">
<div className="flex h-14 shrink-0 items-center gap-2 px-4 sm:px-5">
{selectionMode ? (
<>
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
className="size-3.5 accent-primary"
checked={allFilteredChecked}
ref={(element) => {
if (!element) return;
element.indeterminate =
checkedCount > 0 && checkedCount < filtered.length;
}}
onChange={onToggleSelectAll}
aria-label="全选当前列表"
/>
</label>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{checkedCount}
</span>
<Button
variant="outline"
size="sm"
className="h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={checkedCount === 0}
onClick={onBulkDelete}
>
</Button>
<Button
variant="outline"
size="sm"
className="h-8 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={onExitSelectionMode}
>
</Button>
</>
) : (
<>
<p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{cases.length}
</p>
<div className="flex shrink-0 items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={cases.length === 0}
onClick={onEnterSelectionMode}
>
<CheckSquare size={14} />
</Button>
<Button
size="sm"
className="h-8 shrink-0 gap-1.5"
onClick={onCreateCase}
>
<Plus size={14} />
</Button>
</div>
</>
)}
</div>
<div className="shrink-0 px-4 pb-3 sm:px-5">
<SearchInput
value={search}
onChange={onSearchChange}
placeholder="搜索测试用例"
/>
</div>
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto px-3 pb-4 sm:px-4">
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-10 text-center">
<div className="text-sm font-medium text-foreground">
{cases.length === 0 ? "暂无测试用例" : "未找到匹配用例"}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{cases.length === 0
? "点击右上角「新建」开始。"
: "请调整关键词后再试。"}
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filtered.map((item) => item.id)}
strategy={verticalListSortingStrategy}
disabled={!dragEnabled}
>
<ul className="space-y-2">
{filtered.map((item) => (
<SortableCaseCard
key={item.id}
item={item}
active={item.id === selectedId}
selectionMode={selectionMode}
checked={checkedIds.has(item.id)}
dragEnabled={dragEnabled}
onSelect={() => onSelectCase(item)}
onToggleChecked={() => onToggleChecked(item.id)}
onDuplicate={() => onDuplicateCase(item)}
onDelete={() => onDeleteCase(item)}
/>
))}
</ul>
</SortableContext>
</DndContext>
)}
</div>
</aside>
);
}
function SortableCaseCard({
item,
active,
selectionMode,
checked,
dragEnabled,
onSelect,
onToggleChecked,
onDuplicate,
onDelete,
}: {
item: TestCase;
active: boolean;
selectionMode: boolean;
checked: boolean;
dragEnabled: boolean;
onSelect: () => void;
onToggleChecked: () => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id, disabled: !dragEnabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li ref={setNodeRef} style={style} className={cn(isDragging && "z-10")}>
<div
role="button"
tabIndex={0}
onClick={() => {
if (selectionMode) {
onToggleChecked();
return;
}
onSelect();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (selectionMode) onToggleChecked();
else onSelect();
}
}}
className={cn(
"group w-full cursor-pointer rounded-2xl border px-3 py-3 text-left transition-colors",
active && !selectionMode
? "border-hairline-strong bg-surface-strong"
: "border-hairline bg-card hover:bg-canvas-soft/80",
checked && selectionMode && "border-hairline-strong bg-surface-strong/70",
isDragging && "shadow-md ring-1 ring-hairline-strong",
)}
>
<div className="flex items-start gap-1.5">
<div className="mt-0.5 flex h-6 w-5 shrink-0 items-center justify-center">
{selectionMode ? (
<input
type="checkbox"
className="size-3.5 accent-primary"
checked={checked}
onChange={onToggleChecked}
onClick={(event) => event.stopPropagation()}
aria-label={`选择 ${item.name}`}
/>
) : (
<button
type="button"
className={cn(
"flex h-6 w-5 cursor-grab items-center justify-center rounded text-muted-soft opacity-0 transition-opacity active:cursor-grabbing group-hover:opacity-100",
isDragging && "opacity-100",
)}
aria-label={`拖拽排序 ${item.name}`}
onClick={(event) => event.stopPropagation()}
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</button>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{item.name}
</span>
<span className="inline-flex h-5 shrink-0 items-center rounded-full border border-hairline bg-background px-2 text-[11px] text-muted-foreground">
{TEST_CASE_KIND_LABEL[item.kind]}
</span>
</div>
<div
className={cn(
"flex h-7 w-7 shrink-0 items-center",
selectionMode && "invisible",
)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{!selectionMode && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="h-7 w-7 text-muted-soft hover:text-foreground"
aria-label={`${item.name} 更多操作`}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem
className="rounded-lg"
onSelect={onSelect}
>
<Pencil size={14} />
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-lg"
onSelect={onDuplicate}
>
<Copy size={14} />
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
onSelect={(event) => {
event.preventDefault();
window.setTimeout(onDelete, 0);
}}
>
<Trash2 size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-soft">
{item.description || "暂无说明"}
</p>
</div>
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,115 @@
/**
* 批量测试运行 — 前端 mock。
* 真实执行引擎接入前,用本地状态模拟进度与结果。
*/
import type { TestCase } from "@/data/test-suites";
export type BatchCaseStatus =
| "waiting"
| "running"
| "pass"
| "fail"
| "skipped";
export type BatchRunCase = {
id: string;
name: string;
status: BatchCaseStatus;
expected: string;
actual: string;
failReason: string;
};
export type BatchRunPhase = "config" | "running" | "completed";
export type BatchRunSnapshot = {
title: string;
assistantName: string;
cases: BatchRunCase[];
startedAt: string;
finishedAt: string | null;
stopped: boolean;
};
/** 从测试用例生成期望文案(展示用) */
export function buildExpectedResult(item: TestCase): string {
if (item.assertionType === "llm") {
return item.llmCriteria.trim() || "回复应符合 LLM 判断标准。";
}
if (item.keywords.length === 0) {
return "回复应包含预期关键词。";
}
const mode =
item.keywordMatchMode === "all" ? "同时包含" : "至少包含其一";
return `回复应${mode}${item.keywords.join("、")}`;
}
/** 预定部分用例失败,方便演示失败展开态 */
function shouldFail(index: number, item: TestCase): boolean {
if (item.lastResult === "fail") return true;
return index % 4 === 2;
}
function mockFailActual(): string {
return "已为您转接人工处理。";
}
function mockFailReason(item: TestCase): string {
if (item.assertionType === "llm") {
return "未按照预期确认关键信息,且错误转接人工。";
}
return `回复未命中预期关键词(${item.keywords.slice(0, 3).join("、") || "无"})。`;
}
function mockPassActual(item: TestCase): string {
if (item.assertionType === "keyword" && item.keywords[0]) {
return `好的,请继续描述事故经过,并确认是否有人受伤。`;
}
return "好的,我已记录,我们继续处理。";
}
export function createBatchRunSnapshot(input: {
title: string;
assistantName: string;
cases: TestCase[];
}): BatchRunSnapshot {
return {
title: input.title,
assistantName: input.assistantName,
startedAt: new Date().toISOString(),
finishedAt: null,
stopped: false,
cases: input.cases.map((item, index) => {
const fail = shouldFail(index, item);
return {
id: item.id,
name: item.name,
status: "waiting" as const,
expected: buildExpectedResult(item),
actual: fail ? mockFailActual() : mockPassActual(item),
failReason: fail ? mockFailReason(item) : "",
};
}),
};
}
export function countByStatus(cases: BatchRunCase[]) {
const counts = {
pass: 0,
fail: 0,
running: 0,
waiting: 0,
skipped: 0,
};
for (const item of cases) {
counts[item.status] += 1;
}
return counts;
}
export function formatRunTime(iso: string): string {
const date = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}

View File

@@ -0,0 +1,519 @@
/**
* 测试集 / 测试用例 — 管理页用的本地 mock。
* 两层Test Suite → Test Case。
* 第一版只支持「单步回复 / Next Reply Test」只编辑不运行。
*/
export type TestCaseKind =
| "next_reply"
| "tool_call"
| "fixed_dialogue"
| "user_simulation"
| "voice_run";
export type TestCaseResult = "pass" | "fail" | "not_run";
export type AssertionType = "keyword" | "llm";
export type KeywordMatchMode = "any" | "all";
export type ContextTurn = {
role: "agent" | "user";
content: string;
};
export type TestSuite = {
id: string;
name: string;
description: string;
/** 关联助手展示名MVP 直接存文案,接 API 后可改成 assistantId */
assistantName: string;
updatedAt: string;
};
export type TestCase = {
id: string;
suiteId: string;
name: string;
description: string;
kind: TestCaseKind;
lastResult: TestCaseResult;
/** 对话上下文(通常是 Agent 上一轮) */
contextTurns: ContextTurn[];
/** 当前用户输入 */
userInput: string;
assertionType: AssertionType;
keywords: string[];
keywordMatchMode: KeywordMatchMode;
/** LLM 判断时的判断标准 */
llmCriteria: string;
/** 套件内排序,越小越靠前 */
sortOrder: number;
updatedAt: string;
};
/** 第一版仅「单步回复」可编辑,其余类型显示开发中 */
export const SUPPORTED_TEST_CASE_KIND: TestCaseKind = "next_reply";
export const TEST_CASE_KIND_LABEL: Record<TestCaseKind, string> = {
next_reply: "单步回复",
tool_call: "工具调用",
fixed_dialogue: "固定对话",
user_simulation: "用户模拟",
voice_run: "语音运行",
};
export const TEST_CASE_KIND_OPTIONS: { value: TestCaseKind; label: string }[] = [
{ value: "next_reply", label: "单步回复" },
{ value: "tool_call", label: "工具调用" },
{ value: "fixed_dialogue", label: "固定对话" },
{ value: "user_simulation", label: "用户模拟" },
{ value: "voice_run", label: "语音运行" },
];
export const ASSERTION_TYPE_LABEL: Record<AssertionType, string> = {
keyword: "关键词",
llm: "LLM 判断",
};
const INITIAL_SUITES: TestSuite[] = [
{
id: "suite_001",
name: "事故基础流程",
description: "核心业务流程和正常事故处理",
assistantName: "视频快处助手",
updatedAt: "2026-08-05T10:20:00+08:00",
},
{
id: "suite_002",
name: "异常输入与澄清",
description: "模糊表达、误打断、主动唤醒等",
assistantName: "视频快处助手",
updatedAt: "2026-08-04T16:40:00+08:00",
},
{
id: "suite_003",
name: "实时语音交互",
description: "打断、延迟、VAD、语音链路等",
assistantName: "视频快处助手",
updatedAt: "2026-08-03T09:15:00+08:00",
},
];
const RAW_CASES: Omit<TestCase, "sortOrder">[] = [
{
id: "tc_001",
suiteId: "suite_001",
name: "正常双车事故开场",
description: "开场后用户补充事故经过",
kind: "next_reply",
lastResult: "pass",
contextTurns: [],
userInput: "我这里刚刚撞了一下。",
assertionType: "keyword",
keywords: ["经过", "描述", "受伤"],
keywordMatchMode: "any",
llmCriteria: "",
updatedAt: "2026-08-05T10:18:00+08:00",
},
{
id: "tc_002",
suiteId: "suite_001",
name: "有人伤转人工",
description: "用户提到人伤时应引导转人工",
kind: "next_reply",
lastResult: "pass",
contextTurns: [],
userInput: "有人受伤了,流血不止。",
assertionType: "llm",
keywords: [],
keywordMatchMode: "any",
llmCriteria:
"Agent 应识别人员受伤风险,明确告知将转接人工,并避免继续推进普通快处流程。",
updatedAt: "2026-08-05T10:12:00+08:00",
},
{
id: "tc_101",
suiteId: "suite_002",
name: "用户说“喂”",
description: "验证主动唤醒回复且业务状态不推进",
kind: "next_reply",
lastResult: "pass",
contextTurns: [],
userInput: "喂",
assertionType: "keyword",
keywords: ["我在", "请说", "继续"],
keywordMatchMode: "any",
llmCriteria: "",
updatedAt: "2026-08-04T16:35:00+08:00",
},
{
id: "tc_102",
suiteId: "suite_002",
name: "用户只说“嗯”",
description: "短促确认不应误推进流程",
kind: "next_reply",
lastResult: "fail",
contextTurns: [],
userInput: "嗯",
assertionType: "llm",
keywords: [],
keywordMatchMode: "any",
llmCriteria:
"Agent 应当回应用户的主动唤醒,同时继续引导用户描述事故情况,不应该无响应。",
updatedAt: "2026-08-04T16:20:00+08:00",
},
{
id: "tc_103",
suiteId: "suite_002",
name: "模糊事故描述",
description: "地点含糊时应主动澄清",
kind: "next_reply",
lastResult: "not_run",
contextTurns: [],
userInput: "就在那边……撞了一下。",
assertionType: "keyword",
keywords: ["哪里", "路口", "路名", "再说"],
keywordMatchMode: "any",
llmCriteria: "",
updatedAt: "2026-08-04T15:30:00+08:00",
},
{
id: "tc_201",
suiteId: "suite_003",
name: "用户打断播报",
description: "播报中打断后正确切换聆听并承接",
kind: "next_reply",
lastResult: "pass",
contextTurns: [],
userInput: "等一下,对方走了。",
assertionType: "llm",
keywords: [],
keywordMatchMode: "any",
llmCriteria:
"Agent 应立即停止原播报思路,确认已听到用户新信息,并围绕「对方离开」继续询问。",
updatedAt: "2026-08-03T09:10:00+08:00",
},
];
/** 按套件出现顺序写入 sortOrder */
const INITIAL_CASES: TestCase[] = (() => {
const counters = new Map<string, number>();
return RAW_CASES.map((item) => {
const order = counters.get(item.suiteId) ?? 0;
counters.set(item.suiteId, order + 1);
return { ...item, sortOrder: order };
});
})();
/** 会话内可变的 mock 仓库(刷新页面会重置) */
let suites = [...INITIAL_SUITES];
let cases = structuredClone(INITIAL_CASES);
let suiteSeq = 4;
let caseSeq = 300;
function nowIso() {
return new Date().toISOString();
}
function nextSuiteId() {
const id = `suite_${String(suiteSeq).padStart(3, "0")}`;
suiteSeq += 1;
return id;
}
function nextCaseId() {
const id = `tc_${String(caseSeq).padStart(3, "0")}`;
caseSeq += 1;
return id;
}
export function listTestSuites(): TestSuite[] {
return [...suites].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
}
export function getTestSuite(id: string): TestSuite | null {
return suites.find((item) => item.id === id) ?? null;
}
export function getTestCase(id: string): TestCase | null {
return cases.find((item) => item.id === id) ?? null;
}
export function listTestCases(suiteId: string): TestCase[] {
return cases
.filter((item) => item.suiteId === suiteId)
.sort(
(a, b) =>
a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, "zh-CN"),
);
}
export function suiteCaseStats(suiteId: string): {
total: number;
passed: number;
run: number;
} {
const items = cases.filter((item) => item.suiteId === suiteId);
const run = items.filter((item) => item.lastResult !== "not_run");
const passed = run.filter((item) => item.lastResult === "pass");
return { total: items.length, passed: passed.length, run: run.length };
}
export function createTestSuite(input: {
name: string;
description: string;
assistantName: string;
}): TestSuite {
const suite: TestSuite = {
id: nextSuiteId(),
name: input.name.trim(),
description: input.description.trim(),
assistantName: input.assistantName.trim() || "未关联助手",
updatedAt: nowIso(),
};
suites = [suite, ...suites];
return suite;
}
export function updateTestSuite(
id: string,
patch: Partial<Pick<TestSuite, "name" | "description" | "assistantName">>,
): TestSuite | null {
const index = suites.findIndex((item) => item.id === id);
if (index < 0) return null;
const next = {
...suites[index],
...patch,
updatedAt: nowIso(),
};
suites = [...suites.slice(0, index), next, ...suites.slice(index + 1)];
return next;
}
export function removeTestSuite(id: string): boolean {
const before = suites.length;
suites = suites.filter((item) => item.id !== id);
cases = cases.filter((item) => item.suiteId !== id);
return suites.length < before;
}
/** 复制测试集及其全部用例;名称加「(副本)」 */
export function duplicateTestSuite(id: string): TestSuite | null {
const source = getTestSuite(id);
if (!source) return null;
const copied = createTestSuite({
name: `${source.name}(副本)`,
description: source.description,
assistantName: source.assistantName,
});
const sourceCases = listTestCases(id);
const clonedCases: TestCase[] = sourceCases.map((item, index) => ({
id: nextCaseId(),
suiteId: copied.id,
name: item.name,
description: item.description,
kind: item.kind,
lastResult: "not_run",
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
userInput: item.userInput,
assertionType: item.assertionType,
keywords: [...item.keywords],
keywordMatchMode: item.keywordMatchMode,
llmCriteria: item.llmCriteria,
sortOrder: index,
updatedAt: nowIso(),
}));
cases = [...cases, ...clonedCases];
return copied;
}
export function createTestCase(input: {
suiteId: string;
name?: string;
description?: string;
}): TestCase | null {
if (!getTestSuite(input.suiteId)) return null;
const maxOrder = cases
.filter((item) => item.suiteId === input.suiteId)
.reduce((max, item) => Math.max(max, item.sortOrder), -1);
const item: TestCase = {
id: nextCaseId(),
suiteId: input.suiteId,
name: (input.name ?? "未命名用例").trim() || "未命名用例",
description: (input.description ?? "").trim(),
kind: "next_reply",
lastResult: "not_run",
contextTurns: [],
userInput: "",
assertionType: "keyword",
keywords: [],
keywordMatchMode: "any",
llmCriteria: "",
sortOrder: maxOrder + 1,
updatedAt: nowIso(),
};
cases = [...cases, item];
updateTestSuite(input.suiteId, {});
return item;
}
/** 复制单条用例;名称加「(副本)」,排在同套件末尾 */
export function duplicateTestCase(id: string): TestCase | null {
const source = getTestCase(id);
if (!source || !getTestSuite(source.suiteId)) return null;
const maxOrder = cases
.filter((item) => item.suiteId === source.suiteId)
.reduce((max, item) => Math.max(max, item.sortOrder), -1);
const copied: TestCase = {
id: nextCaseId(),
suiteId: source.suiteId,
name: `${source.name}(副本)`,
description: source.description,
kind: source.kind,
lastResult: "not_run",
contextTurns: source.contextTurns.map((turn) => ({ ...turn })),
userInput: source.userInput,
assertionType: source.assertionType,
keywords: [...source.keywords],
keywordMatchMode: source.keywordMatchMode,
llmCriteria: source.llmCriteria,
sortOrder: maxOrder + 1,
updatedAt: nowIso(),
};
cases = [...cases, copied];
updateTestSuite(source.suiteId, {});
return copied;
}
export type TestCasePatch = Partial<
Pick<
TestCase,
| "name"
| "description"
| "kind"
| "contextTurns"
| "userInput"
| "assertionType"
| "keywords"
| "keywordMatchMode"
| "llmCriteria"
| "lastResult"
>
>;
export function updateTestCase(
id: string,
patch: TestCasePatch,
): TestCase | null {
const index = cases.findIndex((item) => item.id === id);
if (index < 0) return null;
const next = {
...cases[index],
...patch,
updatedAt: nowIso(),
};
cases = [...cases.slice(0, index), next, ...cases.slice(index + 1)];
updateTestSuite(next.suiteId, {});
return next;
}
export function removeTestCase(id: string): boolean {
const existing = cases.find((item) => item.id === id);
if (!existing) return false;
cases = cases.filter((item) => item.id !== id);
renumberSortOrder(existing.suiteId);
updateTestSuite(existing.suiteId, {});
return true;
}
/** 批量删除;返回受影响的 suiteId若有 */
export function removeTestCases(ids: string[]): string | null {
const idSet = new Set(ids);
const affected = cases.find((item) => idSet.has(item.id));
if (!affected) return null;
const suiteId = affected.suiteId;
cases = cases.filter((item) => !idSet.has(item.id));
renumberSortOrder(suiteId);
updateTestSuite(suiteId, {});
return suiteId;
}
/**
* 按给定 id 顺序写回 sortOrder应包含该套件全部用例 id
* 用于拖拽结束后持久化顺序。
*/
export function reorderTestCases(suiteId: string, orderedIds: string[]): void {
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
cases = cases.map((item) => {
if (item.suiteId !== suiteId) return item;
const nextOrder = orderMap.get(item.id);
if (nextOrder === undefined) return item;
return { ...item, sortOrder: nextOrder };
});
updateTestSuite(suiteId, {});
}
function renumberSortOrder(suiteId: string) {
const ordered = cases
.filter((item) => item.suiteId === suiteId)
.sort((a, b) => a.sortOrder - b.sortOrder);
const orderMap = new Map(ordered.map((item, index) => [item.id, index]));
cases = cases.map((item) => {
if (item.suiteId !== suiteId) return item;
const nextOrder = orderMap.get(item.id);
return nextOrder === undefined ? item : { ...item, sortOrder: nextOrder };
});
}
export function formatSuiteResult(suiteId: string): string {
const { passed, run, total } = suiteCaseStats(suiteId);
if (total === 0) return "—";
if (run === 0) return "未运行";
return `${passed}/${total}`;
}
export function formatCaseResult(result: TestCaseResult): {
label: string;
className: string;
dotClassName: string | null;
} {
if (result === "pass") {
return {
label: "通过",
className: "text-emerald-600",
dotClassName: "bg-emerald-500",
};
}
if (result === "fail") {
return {
label: "失败",
className: "text-destructive",
dotClassName: "bg-destructive",
};
}
return {
label: "未运行",
className: "text-muted-soft",
dotClassName: null,
};
}
export function formatUpdatedAt(value?: string | null) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}