Improve test case selection ui
This commit is contained in:
@@ -0,0 +1,333 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量测试范围选择器。
|
||||||
|
* 负责搜索、折叠和多选交互;数据仍由批量测试页持有。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Folder,
|
||||||
|
Minus,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
ASSERTION_TYPE_LABEL,
|
||||||
|
type TestCase,
|
||||||
|
type TestSuite,
|
||||||
|
} from "@/data/test-suites";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type TestScopeSelectorProps = {
|
||||||
|
suites: TestSuite[];
|
||||||
|
casesBySuite: Record<string, TestCase[]>;
|
||||||
|
selectedCaseIds: Set<string>;
|
||||||
|
onSelectionChange: (next: Set<string>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TestScopeSelector({
|
||||||
|
suites,
|
||||||
|
casesBySuite,
|
||||||
|
selectedCaseIds,
|
||||||
|
onSelectionChange,
|
||||||
|
}: TestScopeSelectorProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [expandedSuiteIds, setExpandedSuiteIds] = useState<Set<string>>(
|
||||||
|
() => new Set(suites[0] ? [suites[0].id] : []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allCases = useMemo(
|
||||||
|
() => suites.flatMap((suite) => casesBySuite[suite.id] ?? []),
|
||||||
|
[suites, casesBySuite],
|
||||||
|
);
|
||||||
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
|
|
||||||
|
const visibleSuites = useMemo(() => {
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return suites.map((suite) => ({
|
||||||
|
suite,
|
||||||
|
cases: casesBySuite[suite.id] ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return suites.flatMap((suite) => {
|
||||||
|
const suiteCases = casesBySuite[suite.id] ?? [];
|
||||||
|
const suiteMatches = suite.name
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.includes(normalizedQuery);
|
||||||
|
const matchingCases = suiteMatches
|
||||||
|
? suiteCases
|
||||||
|
: suiteCases.filter((item) =>
|
||||||
|
item.name.toLocaleLowerCase().includes(normalizedQuery),
|
||||||
|
);
|
||||||
|
|
||||||
|
return matchingCases.length > 0 ? [{ suite, cases: matchingCases }] : [];
|
||||||
|
});
|
||||||
|
}, [suites, casesBySuite, normalizedQuery]);
|
||||||
|
|
||||||
|
const selectedCount = selectedCaseIds.size;
|
||||||
|
const totalCount = allCases.length;
|
||||||
|
const allSelected = totalCount > 0 && selectedCount === totalCount;
|
||||||
|
const selectionPercent =
|
||||||
|
totalCount === 0 ? 0 : Math.round((selectedCount / totalCount) * 100);
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
onSelectionChange(
|
||||||
|
allSelected ? new Set() : new Set(allCases.map((item) => item.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSuite(suiteId: string) {
|
||||||
|
const suiteCases = casesBySuite[suiteId] ?? [];
|
||||||
|
const suiteIsSelected =
|
||||||
|
suiteCases.length > 0 &&
|
||||||
|
suiteCases.every((item) => selectedCaseIds.has(item.id));
|
||||||
|
const next = new Set(selectedCaseIds);
|
||||||
|
|
||||||
|
for (const item of suiteCases) {
|
||||||
|
if (suiteIsSelected) next.delete(item.id);
|
||||||
|
else next.add(item.id);
|
||||||
|
}
|
||||||
|
onSelectionChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCase(caseId: string) {
|
||||||
|
const next = new Set(selectedCaseIds);
|
||||||
|
if (next.has(caseId)) next.delete(caseId);
|
||||||
|
else next.add(caseId);
|
||||||
|
onSelectionChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(suiteId: string) {
|
||||||
|
setExpandedSuiteIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(suiteId)) next.delete(suiteId);
|
||||||
|
else next.add(suiteId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-hairline-strong bg-card">
|
||||||
|
<div className="border-b border-hairline bg-canvas-soft/60 p-3.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<div className="relative min-w-[220px] flex-1">
|
||||||
|
<Search
|
||||||
|
size={15}
|
||||||
|
className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted-soft"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="搜索测试集或用例"
|
||||||
|
aria-label="搜索测试集或用例"
|
||||||
|
className="h-9 rounded-xl border-hairline bg-background pr-3 pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSelectionChange(new Set())}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleAll}
|
||||||
|
disabled={totalCount === 0}
|
||||||
|
className="border-hairline-strong bg-card"
|
||||||
|
>
|
||||||
|
{allSelected ? "取消全选" : "全选全部"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||||
|
style={{ width: `${selectionPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-xs tabular-nums text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
已选 <span className="font-medium text-foreground">{selectedCount}</span>
|
||||||
|
<span className="text-muted-soft"> / {totalCount}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suites.length === 0 ? (
|
||||||
|
<EmptyState>暂无测试集,请先在「测试用例」中创建。</EmptyState>
|
||||||
|
) : visibleSuites.length === 0 ? (
|
||||||
|
<EmptyState>没有匹配的测试集或用例。</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[390px] overflow-y-auto overscroll-contain">
|
||||||
|
<ul className="divide-y divide-hairline">
|
||||||
|
{visibleSuites.map(({ suite, cases: visibleCases }) => {
|
||||||
|
const suiteCases = casesBySuite[suite.id] ?? [];
|
||||||
|
const selectedInSuite = suiteCases.filter((item) =>
|
||||||
|
selectedCaseIds.has(item.id),
|
||||||
|
).length;
|
||||||
|
const suiteAllSelected =
|
||||||
|
suiteCases.length > 0 && selectedInSuite === suiteCases.length;
|
||||||
|
const suiteSomeSelected =
|
||||||
|
selectedInSuite > 0 && !suiteAllSelected;
|
||||||
|
const expanded = normalizedQuery
|
||||||
|
? true
|
||||||
|
: expandedSuiteIds.has(suite.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={suite.id}>
|
||||||
|
<div className="flex min-h-12 items-center gap-2 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpanded(suite.id)}
|
||||||
|
aria-label={
|
||||||
|
expanded ? `收起 ${suite.name}` : `展开 ${suite.name}`
|
||||||
|
}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
disabled={Boolean(normalizedQuery)}
|
||||||
|
className="flex size-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-default"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown size={15} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<TriStateCheckbox
|
||||||
|
checked={suiteAllSelected}
|
||||||
|
indeterminate={suiteSomeSelected}
|
||||||
|
label={`选择测试集 ${suite.name}`}
|
||||||
|
onChange={() => toggleSuite(suite.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpanded(suite.id)}
|
||||||
|
disabled={Boolean(normalizedQuery)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 text-left disabled:cursor-default"
|
||||||
|
>
|
||||||
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-surface-strong text-muted-foreground">
|
||||||
|
<Folder size={14} />
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
|
{suite.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant={selectedInSuite > 0 ? "secondary" : "outline"}
|
||||||
|
className="h-6 min-w-14 tabular-nums"
|
||||||
|
>
|
||||||
|
{selectedInSuite} / {suiteCases.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<ul className="border-t border-hairline bg-canvas-soft/45 py-1.5">
|
||||||
|
{visibleCases.length === 0 ? (
|
||||||
|
<li className="px-12 py-3 text-xs text-muted-soft">
|
||||||
|
该测试集暂无用例
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
visibleCases.map((item) => {
|
||||||
|
const selected = selectedCaseIds.has(item.id);
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-10 cursor-pointer items-center gap-3 px-5 py-2 transition-colors hover:bg-surface-strong/60",
|
||||||
|
selected && "bg-surface-strong/45",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TriStateCheckbox
|
||||||
|
checked={selected}
|
||||||
|
label={`选择用例 ${item.name}`}
|
||||||
|
onChange={() => toggleCase(item.id)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-soft">
|
||||||
|
{ASSERTION_TYPE_LABEL[item.assertionType]}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriStateCheckbox({
|
||||||
|
checked,
|
||||||
|
indeterminate = false,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
indeterminate?: boolean;
|
||||||
|
label: string;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="relative flex size-4 shrink-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
ref={(element) => {
|
||||||
|
if (element) element.indeterminate = indeterminate;
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
aria-label={label}
|
||||||
|
className="peer absolute inset-0 z-10 cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-4 items-center justify-center rounded-[5px] border border-hairline-strong bg-background text-primary-foreground transition-colors peer-focus-visible:ring-3 peer-focus-visible:ring-ring/30",
|
||||||
|
(checked || indeterminate) && "border-primary bg-primary",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{indeterminate ? (
|
||||||
|
<Minus size={11} strokeWidth={2.5} />
|
||||||
|
) : checked ? (
|
||||||
|
<Check size={11} strokeWidth={2.5} />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="px-4 py-10 text-center text-sm text-muted-soft">{children}</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
@@ -20,6 +18,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { BatchRunCompletedView } from "@/components/batch-test/batch-run-completed";
|
import { BatchRunCompletedView } from "@/components/batch-test/batch-run-completed";
|
||||||
import { BatchRunRunningView } from "@/components/batch-test/batch-run-running";
|
import { BatchRunRunningView } from "@/components/batch-test/batch-run-running";
|
||||||
|
import { TestScopeSelector } from "@/components/batch-test/test-scope-selector";
|
||||||
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
|
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
|
||||||
import { SectionCard } from "@/components/editor/section-card";
|
import { SectionCard } from "@/components/editor/section-card";
|
||||||
import { ListPageLayout } from "@/components/layout/list-page-layout";
|
import { ListPageLayout } from "@/components/layout/list-page-layout";
|
||||||
@@ -40,7 +39,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
listTestCases,
|
listTestCases,
|
||||||
listTestSuites,
|
listTestSuites,
|
||||||
suiteCaseStats,
|
|
||||||
type TestCase,
|
type TestCase,
|
||||||
type TestSuite,
|
type TestSuite,
|
||||||
} from "@/data/test-suites";
|
} from "@/data/test-suites";
|
||||||
@@ -109,9 +107,6 @@ export function BatchTestPage() {
|
|||||||
|
|
||||||
const [{ suites, casesBySuite, caseIds: initialCaseIds }] =
|
const [{ suites, casesBySuite, caseIds: initialCaseIds }] =
|
||||||
useState(loadTestScope);
|
useState(loadTestScope);
|
||||||
const [expandedSuiteIds, setExpandedSuiteIds] = useState<Set<string>>(
|
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>(
|
const [selectedCaseIds, setSelectedCaseIds] = useState<Set<string>>(
|
||||||
() => new Set(initialCaseIds),
|
() => new Set(initialCaseIds),
|
||||||
);
|
);
|
||||||
@@ -305,14 +300,6 @@ export function BatchTestPage() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [phase, run?.finishedAt]);
|
}, [phase, run?.finishedAt]);
|
||||||
|
|
||||||
const allCaseIds = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(casesBySuite)
|
|
||||||
.flat()
|
|
||||||
.map((item) => item.id),
|
|
||||||
[casesBySuite],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedCases = useMemo(() => {
|
const selectedCases = useMemo(() => {
|
||||||
const ordered: TestCase[] = [];
|
const ordered: TestCase[] = [];
|
||||||
for (const suite of suites) {
|
for (const suite of suites) {
|
||||||
@@ -324,10 +311,6 @@ export function BatchTestPage() {
|
|||||||
}, [suites, casesBySuite, selectedCaseIds]);
|
}, [suites, casesBySuite, selectedCaseIds]);
|
||||||
|
|
||||||
const selectedCount = selectedCaseIds.size;
|
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 timeoutValue = Number(timeoutSecs);
|
||||||
const timeoutValid =
|
const timeoutValid =
|
||||||
Number.isFinite(timeoutValue) && timeoutValue > 0 && timeoutValue <= 600;
|
Number.isFinite(timeoutValue) && timeoutValue > 0 && timeoutValue <= 600;
|
||||||
@@ -339,50 +322,6 @@ export function BatchTestPage() {
|
|||||||
!loadingAssistants &&
|
!loadingAssistants &&
|
||||||
phase === "config";
|
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[]) {
|
function startRun(cases: TestCase[]) {
|
||||||
const assistantName =
|
const assistantName =
|
||||||
assistants.find((item) => item.id === assistantId)?.name ?? "助手";
|
assistants.find((item) => item.id === assistantId)?.name ?? "助手";
|
||||||
@@ -560,139 +499,14 @@ export function BatchTestPage() {
|
|||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<FolderOpen size={15} />}
|
icon={<FolderOpen size={15} />}
|
||||||
title="选择测试范围"
|
title="选择测试范围"
|
||||||
description="可全选,或展开测试集勾选单个用例"
|
description="搜索测试集或用例,按需组合本次运行范围"
|
||||||
action={
|
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
|
||||||
已选 {selectedCount} 个用例
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<TestScopeSelector
|
||||||
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-foreground">
|
suites={suites}
|
||||||
<input
|
casesBySuite={casesBySuite}
|
||||||
type="checkbox"
|
selectedCaseIds={selectedCaseIds}
|
||||||
className="size-3.5 accent-primary"
|
onSelectionChange={setSelectedCaseIds}
|
||||||
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>
|
</SectionCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user