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>
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* 测试集 / 测试用例 — 管理页用的本地 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 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 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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user