feat: add persisted batch text testing
This commit is contained in:
@@ -1,16 +1,13 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 批量测试 — 已完成结果视图(MVP mock)。
|
||||
*/
|
||||
/** 批量测试已完成结果视图。 */
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Settings2 } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { BatchCaseDetailDrawer } from "@/components/batch-test/batch-case-detail-drawer";
|
||||
import {
|
||||
BatchCaseStatusBadge,
|
||||
BatchPhasePill,
|
||||
getBatchCaseStatusLabel,
|
||||
} from "@/components/batch-test/batch-case-status";
|
||||
import {
|
||||
@@ -50,13 +47,8 @@ export function BatchRunCompletedView({
|
||||
const detailOpen = Boolean(selectedCase);
|
||||
|
||||
const counts = countByStatus(run.cases);
|
||||
const judged = counts.pass + counts.fail + counts.error;
|
||||
const total = run.cases.length;
|
||||
const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100);
|
||||
const failRate =
|
||||
judged === 0 ? 0 : Math.round((counts.fail / judged) * 100);
|
||||
const errorRate =
|
||||
judged === 0 ? 0 : Math.round((counts.error / judged) * 100);
|
||||
const outcome = getRunOutcome(run, counts);
|
||||
const visibleCases =
|
||||
filter === "fail"
|
||||
? run.cases.filter((item) => item.status === "fail")
|
||||
@@ -93,26 +85,96 @@ export function BatchRunCompletedView({
|
||||
>
|
||||
{/* 结果总览 */}
|
||||
<section className="rounded-2xl border border-hairline bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-medium text-foreground">
|
||||
{run.title}
|
||||
</h2>
|
||||
<BatchPhasePill phase="completed" />
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-medium text-foreground">
|
||||
{run.title}
|
||||
</h2>
|
||||
{run.finishedAt && (
|
||||
<p className="mt-1.5 text-xs text-muted-soft">
|
||||
完成于 {formatRunTime(run.finishedAt)}
|
||||
{run.stopReason ? ` · ${stopReasonLabel(run)}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-3 py-1 text-xs font-medium",
|
||||
outcome.className,
|
||||
)}
|
||||
>
|
||||
{outcome.label}
|
||||
</span>
|
||||
</div>
|
||||
{run.finishedAt && (
|
||||
<p className="mt-1.5 text-xs text-muted-soft">
|
||||
完成时间:{formatRunTime(run.finishedAt)}
|
||||
{run.stopReason ? ` · ${stopReasonLabel(run)}` : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<dl className="mt-4 grid gap-3 rounded-xl border border-hairline bg-canvas-soft/60 px-4 py-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="mt-5 rounded-xl border border-hairline bg-canvas-soft/60 p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium tracking-wide text-muted-soft uppercase">
|
||||
测试结果
|
||||
</p>
|
||||
<p className="mt-1 font-display text-3xl text-ink">
|
||||
<span className="tabular-nums">{counts.pass}</span>
|
||||
<span className="mx-1.5 text-muted-soft">/</span>
|
||||
<span className="tabular-nums">{total}</span>
|
||||
<span className="ml-2 text-2xl">通过</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-x-5 gap-y-2 text-sm sm:shrink-0">
|
||||
<ResultCount tone="success" label="通过" count={counts.pass} />
|
||||
<ResultCount
|
||||
tone="destructive"
|
||||
label="断言失败"
|
||||
count={counts.fail}
|
||||
/>
|
||||
<ResultCount
|
||||
tone="warning"
|
||||
label="执行错误"
|
||||
count={counts.error}
|
||||
/>
|
||||
{counts.skipped > 0 && (
|
||||
<ResultCount
|
||||
tone="muted"
|
||||
label="未执行"
|
||||
count={counts.skipped}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResultDistribution counts={counts} total={total} />
|
||||
</div>
|
||||
|
||||
<dl
|
||||
className={cn(
|
||||
"mt-4 grid gap-x-6 gap-y-4 text-sm sm:grid-cols-2",
|
||||
!detailOpen && "lg:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">被测助手</dt>
|
||||
<dd className="mt-1 truncate font-medium text-foreground">
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{run.assistantName}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-xs text-muted-foreground">评估模型</dt>
|
||||
<dd
|
||||
className="mt-1 break-words font-medium text-foreground"
|
||||
title={run.config.evaluatorModelResourceName}
|
||||
>
|
||||
{run.config.evaluatorModelResourceName}
|
||||
</dd>
|
||||
{run.config.evaluatorModel && (
|
||||
<dd
|
||||
className="mt-0.5 break-all font-mono text-[11px] text-muted-soft"
|
||||
title={run.config.evaluatorModel}
|
||||
>
|
||||
{run.config.evaluatorModel}
|
||||
</dd>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">测试范围</dt>
|
||||
<dd className="mt-1 font-medium text-foreground">
|
||||
@@ -122,70 +184,34 @@ export function BatchRunCompletedView({
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">运行参数</dt>
|
||||
<dd className="mt-1 font-medium text-foreground">
|
||||
{run.config.concurrency} 并发 · {run.config.timeoutSecs} 秒
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">耗时 / 处理策略</dt>
|
||||
<dd className="mt-1 text-xs font-medium leading-5 text-foreground">
|
||||
{formatRunDuration(run.startedAt, run.finishedAt)} · 断言失败:
|
||||
{BATCH_FAILURE_STRATEGY_LABEL[run.config.failureStrategy]}
|
||||
<br />
|
||||
执行错误:重试 {run.config.errorRetryCount} 次,
|
||||
{BATCH_ERROR_STRATEGY_LABEL[run.config.errorStrategy]}
|
||||
{run.config.concurrency} 并发 · {run.config.timeoutSecs} 秒超时
|
||||
<span className="mx-1.5 text-muted-soft">·</span>
|
||||
{formatRunDuration(run.startedAt, run.finishedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-5 flex flex-col items-center gap-6 sm:flex-row sm:justify-between sm:gap-8">
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="font-display text-3xl text-ink">
|
||||
{counts.pass} / {judged || total} 通过
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-success">
|
||||
通过率 {passRate}%
|
||||
</div>
|
||||
<details className="group mt-4 border-t border-hairline pt-3">
|
||||
<summary className="flex w-fit cursor-pointer list-none items-center gap-2 rounded-full px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-canvas-soft hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-details-marker]:hidden">
|
||||
<Settings2 size={13} />
|
||||
查看运行设置
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className="transition-transform group-open:rotate-180"
|
||||
/>
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-3 rounded-xl border border-hairline bg-canvas-soft/50 px-4 py-3 text-xs leading-5 text-muted-foreground sm:grid-cols-2">
|
||||
<p>
|
||||
<span className="font-medium text-foreground">断言失败:</span>
|
||||
{BATCH_FAILURE_STRATEGY_LABEL[run.config.failureStrategy]}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium text-foreground">执行错误:</span>
|
||||
重试 {run.config.errorRetryCount} 次,
|
||||
{BATCH_ERROR_STRATEGY_LABEL[run.config.errorStrategy]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PassRateDonut
|
||||
pass={counts.pass}
|
||||
fail={counts.fail}
|
||||
error={counts.error}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<LegendRow
|
||||
tone="success"
|
||||
label="通过"
|
||||
count={counts.pass}
|
||||
percent={passRate}
|
||||
/>
|
||||
<LegendRow
|
||||
tone="destructive"
|
||||
label="断言失败"
|
||||
count={counts.fail}
|
||||
percent={failRate}
|
||||
/>
|
||||
<LegendRow
|
||||
tone="warning"
|
||||
label="执行错误"
|
||||
count={counts.error}
|
||||
percent={errorRate}
|
||||
/>
|
||||
{counts.skipped > 0 && (
|
||||
<LegendRow
|
||||
tone="muted"
|
||||
label="未执行"
|
||||
count={counts.skipped}
|
||||
percent={
|
||||
total === 0
|
||||
? 0
|
||||
: Math.round((counts.skipped / total) * 100)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{/* 用例列表 */}
|
||||
@@ -319,106 +345,53 @@ export function BatchRunCompletedView({
|
||||
);
|
||||
}
|
||||
|
||||
function PassRateDonut({
|
||||
pass,
|
||||
fail,
|
||||
error,
|
||||
}: {
|
||||
pass: number;
|
||||
fail: number;
|
||||
error: number;
|
||||
}) {
|
||||
const total = pass + fail + error;
|
||||
const passRatio = total === 0 ? 0 : pass / total;
|
||||
const size = 112;
|
||||
const stroke = 14;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const passLength = circumference * passRatio;
|
||||
const failLength = circumference * (total === 0 ? 0 : fail / total);
|
||||
const errorLength = circumference * (total === 0 ? 0 : error / total);
|
||||
type BatchCounts = ReturnType<typeof countByStatus>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden
|
||||
>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
className="text-surface-strong"
|
||||
/>
|
||||
{total > 0 && (
|
||||
<>
|
||||
{pass > 0 && (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={`${passLength} ${circumference}`}
|
||||
strokeLinecap="butt"
|
||||
className="text-success"
|
||||
/>
|
||||
)}
|
||||
{fail > 0 && (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={`${failLength} ${circumference}`}
|
||||
strokeDashoffset={-passLength}
|
||||
strokeLinecap="butt"
|
||||
className="text-destructive"
|
||||
/>
|
||||
)}
|
||||
{error > 0 && (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={`${errorLength} ${circumference}`}
|
||||
strokeDashoffset={-(passLength + failLength)}
|
||||
strokeLinecap="butt"
|
||||
className="text-amber-500"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-lg font-medium tabular-nums text-foreground">
|
||||
{total === 0 ? 0 : Math.round((pass / total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function getRunOutcome(run: BatchRunSnapshot, counts: BatchCounts) {
|
||||
if (run.status === "cancelled") {
|
||||
return {
|
||||
label: "运行已停止",
|
||||
className: "bg-surface-strong text-muted-foreground",
|
||||
};
|
||||
}
|
||||
if (counts.error > 0) {
|
||||
return {
|
||||
label: "存在执行错误",
|
||||
className: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
};
|
||||
}
|
||||
if (counts.fail > 0) {
|
||||
return {
|
||||
label: "存在断言失败",
|
||||
className: "bg-destructive/10 text-destructive",
|
||||
};
|
||||
}
|
||||
if (counts.skipped > 0) {
|
||||
return {
|
||||
label: "部分未执行",
|
||||
className: "bg-surface-strong text-muted-foreground",
|
||||
};
|
||||
}
|
||||
if (counts.pass > 0) {
|
||||
return {
|
||||
label: "全部通过",
|
||||
className: "bg-success/10 text-success",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "暂无结果",
|
||||
className: "bg-surface-strong text-muted-foreground",
|
||||
};
|
||||
}
|
||||
|
||||
function LegendRow({
|
||||
function ResultCount({
|
||||
tone,
|
||||
label,
|
||||
count,
|
||||
percent,
|
||||
}: {
|
||||
tone: "success" | "destructive" | "warning" | "muted";
|
||||
label: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
}) {
|
||||
const dotClass = {
|
||||
success: "bg-success",
|
||||
@@ -428,11 +401,48 @@ function LegendRow({
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className={cn("size-2.5 rounded-full", dotClass)} />
|
||||
<span>
|
||||
{label} {count}({percent}%)
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn("size-2 shrink-0 rounded-full", dotClass)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate text-muted-foreground">{label}</span>
|
||||
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultDistribution({
|
||||
counts,
|
||||
total,
|
||||
}: {
|
||||
counts: BatchCounts;
|
||||
total: number;
|
||||
}) {
|
||||
const segments = [
|
||||
{ key: "pass", count: counts.pass, className: "bg-success" },
|
||||
{ key: "fail", count: counts.fail, className: "bg-destructive" },
|
||||
{ key: "error", count: counts.error, className: "bg-amber-500" },
|
||||
{ key: "skipped", count: counts.skipped, className: "bg-muted-soft" },
|
||||
].filter((item) => item.count > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface-strong"
|
||||
role="img"
|
||||
aria-label={`结果分布:通过 ${counts.pass},断言失败 ${counts.fail},执行错误 ${counts.error},未执行 ${counts.skipped}`}
|
||||
>
|
||||
{total > 0 &&
|
||||
segments.map((segment) => (
|
||||
<span
|
||||
key={segment.key}
|
||||
className={segment.className}
|
||||
style={{ flexBasis: 0, flexGrow: segment.count }}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user