Expand test case fixed-input editor and batch case detail split view.

Unify multi-turn behaviors (reply/tool), overall expectation, and input-mode picker while keeping non-text modes as coming soon; batch completed/running use a half-and-half detail panel below the top bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xin Wang
2026-08-09 17:08:53 +08:00
parent e988f9c133
commit ba6c7c6be3
11 changed files with 2363 additions and 687 deletions

View File

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