From e988f9c13354b884c8a7f4950ff72627b8bb9a28 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sat, 8 Aug 2026 20:52:38 +0800 Subject: [PATCH] Improve test case selection ui --- .../batch-test/test-scope-selector.tsx | 333 ++++++++++++++++++ .../src/components/pages/BatchTestPage.tsx | 202 +---------- 2 files changed, 341 insertions(+), 194 deletions(-) create mode 100644 frontend/src/components/batch-test/test-scope-selector.tsx diff --git a/frontend/src/components/batch-test/test-scope-selector.tsx b/frontend/src/components/batch-test/test-scope-selector.tsx new file mode 100644 index 0000000..4a7dce3 --- /dev/null +++ b/frontend/src/components/batch-test/test-scope-selector.tsx @@ -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; + selectedCaseIds: Set; + onSelectionChange: (next: Set) => void; +}; + +export function TestScopeSelector({ + suites, + casesBySuite, + selectedCaseIds, + onSelectionChange, +}: TestScopeSelectorProps) { + const [query, setQuery] = useState(""); + const [expandedSuiteIds, setExpandedSuiteIds] = useState>( + () => 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 ( +
+
+
+
+ + setQuery(event.target.value)} + placeholder="搜索测试集或用例" + aria-label="搜索测试集或用例" + className="h-9 rounded-xl border-hairline bg-background pr-3 pl-9" + /> +
+ + + +
+ +
+
+
+
+ + 已选 {selectedCount} + / {totalCount} + +
+
+ + {suites.length === 0 ? ( + 暂无测试集,请先在「测试用例」中创建。 + ) : visibleSuites.length === 0 ? ( + 没有匹配的测试集或用例。 + ) : ( +
+
    + {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 ( +
  • +
    + + + toggleSuite(suite.id)} + /> + + + + 0 ? "secondary" : "outline"} + className="h-6 min-w-14 tabular-nums" + > + {selectedInSuite} / {suiteCases.length} + +
    + + {expanded && ( +
      + {visibleCases.length === 0 ? ( +
    • + 该测试集暂无用例 +
    • + ) : ( + visibleCases.map((item) => { + const selected = selectedCaseIds.has(item.id); + return ( +
    • + +
    • + ); + }) + )} +
    + )} +
  • + ); + })} +
+
+ )} +
+ ); +} + +function TriStateCheckbox({ + checked, + indeterminate = false, + label, + onChange, +}: { + checked: boolean; + indeterminate?: boolean; + label: string; + onChange: () => void; +}) { + return ( + + { + if (element) element.indeterminate = indeterminate; + }} + onChange={onChange} + aria-label={label} + className="peer absolute inset-0 z-10 cursor-pointer opacity-0" + /> + + {indeterminate ? ( + + ) : checked ? ( + + ) : null} + + + ); +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ); +} diff --git a/frontend/src/components/pages/BatchTestPage.tsx b/frontend/src/components/pages/BatchTestPage.tsx index 0d9ae0b..e2bcab0 100644 --- a/frontend/src/components/pages/BatchTestPage.tsx +++ b/frontend/src/components/pages/BatchTestPage.tsx @@ -6,8 +6,6 @@ */ import { - ChevronDown, - ChevronRight, FolderOpen, Loader2, Play, @@ -20,6 +18,7 @@ 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 { TestScopeSelector } from "@/components/batch-test/test-scope-selector"; import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs"; import { SectionCard } from "@/components/editor/section-card"; import { ListPageLayout } from "@/components/layout/list-page-layout"; @@ -40,7 +39,6 @@ import { import { listTestCases, listTestSuites, - suiteCaseStats, type TestCase, type TestSuite, } from "@/data/test-suites"; @@ -109,9 +107,6 @@ export function BatchTestPage() { const [{ suites, casesBySuite, caseIds: initialCaseIds }] = useState(loadTestScope); - const [expandedSuiteIds, setExpandedSuiteIds] = useState>( - new Set(), - ); const [selectedCaseIds, setSelectedCaseIds] = useState>( () => new Set(initialCaseIds), ); @@ -305,14 +300,6 @@ export function BatchTestPage() { return () => window.clearTimeout(timer); }, [phase, run?.finishedAt]); - const allCaseIds = useMemo( - () => - Object.values(casesBySuite) - .flat() - .map((item) => item.id), - [casesBySuite], - ); - const selectedCases = useMemo(() => { const ordered: TestCase[] = []; for (const suite of suites) { @@ -324,10 +311,6 @@ export function BatchTestPage() { }, [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; @@ -339,50 +322,6 @@ export function BatchTestPage() { !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 ?? "助手"; @@ -560,139 +499,14 @@ export function BatchTestPage() { } title="选择测试范围" - description="可全选,或展开测试集勾选单个用例" - action={ - - 已选 {selectedCount} 个用例 - - } + description="搜索测试集或用例,按需组合本次运行范围" > -
- - - {suites.length === 0 ? ( -

- 暂无测试集,请先在「测试用例」中创建。 -

- ) : ( -
    - {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 ( -
  • -
    - - - { - if (!element) return; - element.indeterminate = suiteSomeSelected; - }} - onChange={() => toggleSuite(suite.id)} - aria-label={`选择测试集 ${suite.name}`} - /> - - -
    - - {expanded && ( -
      - {suiteCases.length === 0 ? ( -
    • - 该测试集暂无用例 -
    • - ) : ( - suiteCases.map((item) => ( -
    • - -
    • - )) - )} -
    - )} -
  • - ); - })} -
- )} -
+