Remove .DS_Store from version control and update .gitignore. Enhance BatchCaseDetailDrawer and BatchRunCompletedView components to support error handling and improved UI for displaying test case statuses. Add functionality for editing and rerunning test cases. Update TestCasesPage to handle initial case selection. Refactor related components for better maintainability.

This commit is contained in:
Xin Wang
2026-08-10 09:33:29 +08:00
parent ba6c7c6be3
commit 5b8b9fd097
14 changed files with 2448 additions and 1243 deletions

View File

@@ -56,25 +56,19 @@ import {
SelectValue,
} from "@/components/ui/select";
import {
cloneOverallExpectation,
cloneOverallCriteria,
cloneTurns,
cloneUserSimulation,
cloneVoiceSettings,
createDefaultUserSimulation,
createDefaultVoiceSettings,
createEmptyFixedInputTurn,
createTestCase,
createTestSuite,
DEFAULT_INPUT_MODE,
duplicateTestCase,
duplicateTestSuite,
getTestCaseValidationMessage,
getTestSuite,
isFixedScriptMode,
isVoiceMode,
kindFromInputMode,
listTestCases,
listTestSuites,
normalizeOverallExpectation,
normalizeOverallCriteria,
removeTestCase,
removeTestCases,
removeTestSuite,
@@ -97,12 +91,18 @@ import { assistantsApi, type Assistant } from "@/lib/api";
export type TestCasesPageProps =
| { mode: "list" }
| { mode: "create" }
| { mode: "detail"; suiteId: string };
| { mode: "detail"; suiteId: string; initialCaseId?: string };
export function TestCasesPage(props: TestCasesPageProps) {
if (props.mode === "list") return <SuiteListView />;
if (props.mode === "create") return <SuiteCreateView />;
return <SuiteDetailView key={props.suiteId} suiteId={props.suiteId} />;
return (
<SuiteDetailView
key={`${props.suiteId}:${props.initialCaseId ?? ""}`}
suiteId={props.suiteId}
initialCaseId={props.initialCaseId}
/>
);
}
// ─── Suite 列表 ──────────────────────────────────────────────────────────────
@@ -441,12 +441,9 @@ function caseToDraft(item: TestCase): CaseEditorDraft {
return {
name: item.name,
inputMode: item.inputMode,
kind: item.kind,
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
turns: cloneTurns(item.turns),
voiceSettings: cloneVoiceSettings(item.voiceSettings),
userSimulation: cloneUserSimulation(item.userSimulation),
overallExpectation: cloneOverallExpectation(item.overallExpectation),
overallCriteria: cloneOverallCriteria(item.overallCriteria),
};
}
@@ -458,12 +455,9 @@ function createEmptyCaseDraft(): CaseEditorDraft {
return {
name: "未命名用例",
inputMode: DEFAULT_INPUT_MODE,
kind: kindFromInputMode(DEFAULT_INPUT_MODE),
contextTurns: [],
turns: [createEmptyFixedInputTurn()],
voiceSettings: null,
userSimulation: null,
overallExpectation: null,
overallCriteria: [],
};
}
@@ -471,25 +465,23 @@ 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;
return { ...draft, inputMode: mode };
}
function SuiteDetailView({ suiteId }: { suiteId: string }) {
function SuiteDetailView({
suiteId,
initialCaseId,
}: {
suiteId: string;
initialCaseId?: string;
}) {
const router = useRouter();
const [initial] = useState(() => {
const initialCases = listTestCases(suiteId);
const initialCase = initialCases[0] ?? null;
const initialCase =
initialCases.find((item) => item.id === initialCaseId) ??
initialCases[0] ??
null;
const initialDraft = initialCase ? caseToDraft(initialCase) : null;
return {
suite: getTestSuite(suiteId),
@@ -562,6 +554,60 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
savedSnapshot === null ||
!draftsEqual(draft, savedSnapshot));
const validationMessage = draft
? getTestCaseValidationMessage(draft)
: "请先选择测试用例";
const canSave =
Boolean(draft?.name.trim()) && dirty && validationMessage === null;
useEffect(() => {
if (!dirty) return;
function handleBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = "";
}
function handleInternalLink(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
const target = event.target;
if (!(target instanceof Element)) return;
const link = target.closest<HTMLAnchorElement>("a[href]");
if (!link || link.target === "_blank" || link.hasAttribute("download")) {
return;
}
const nextUrl = new URL(link.href, window.location.href);
if (nextUrl.origin !== window.location.origin) return;
if (
nextUrl.pathname === window.location.pathname &&
nextUrl.search === window.location.search
) {
return;
}
if (window.confirm("当前用例有未保存修改,离开将丢弃。继续?")) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
}
window.addEventListener("beforeunload", handleBeforeUnload);
document.addEventListener("click", handleInternalLink, true);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
document.removeEventListener("click", handleInternalLink, true);
};
}, [dirty]);
function selectCase(item: TestCase) {
if (dirty && !window.confirm("当前用例有未保存修改,切换将丢弃。继续?")) {
return;
@@ -584,18 +630,13 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
}
function handleSave() {
if (!draft) return;
if (!draft || validationMessage) return;
const patch = {
name: draft.name.trim() || "未命名用例",
inputMode: draft.inputMode,
kind: kindFromInputMode(draft.inputMode),
contextTurns: draft.contextTurns,
turns: draft.turns,
voiceSettings: draft.voiceSettings,
userSimulation: draft.userSimulation,
overallExpectation: normalizeOverallExpectation(
draft.overallExpectation?.criteria,
),
overallCriteria: normalizeOverallCriteria(draft.overallCriteria),
};
let saved: TestCase | null;
@@ -807,7 +848,15 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
/>
<div className="ml-auto flex shrink-0 items-center gap-2">
{dirty ? (
{dirty && validationMessage ? (
<span
className="max-w-56 truncate text-xs text-destructive"
title={validationMessage}
role="status"
>
{validationMessage}
</span>
) : dirty ? (
<span className="text-xs text-amber-600"></span>
) : statusMessage ? (
<span className="text-xs text-muted-foreground">
@@ -817,7 +866,8 @@ function SuiteDetailView({ suiteId }: { suiteId: string }) {
<Button
size="sm"
className="gap-1.5"
disabled={!dirty || !draft.name.trim()}
disabled={!canSave}
title={validationMessage ?? undefined}
onClick={handleSave}
>
<Save size={14} />