298 lines
9.9 KiB
TypeScript
298 lines
9.9 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
/**
|
|||
|
|
* 批量测试 — 已完成结果视图(MVP mock)。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|||
|
|
import { useState } from "react";
|
|||
|
|
|
|||
|
|
import {
|
|||
|
|
BatchCaseStatusBadge,
|
|||
|
|
BatchPhasePill,
|
|||
|
|
} from "@/components/batch-test/batch-case-status";
|
|||
|
|
import {
|
|||
|
|
countByStatus,
|
|||
|
|
formatRunTime,
|
|||
|
|
type BatchRunSnapshot,
|
|||
|
|
} from "@/data/batch-run";
|
|||
|
|
import { cn } from "@/lib/utils";
|
|||
|
|
|
|||
|
|
export function BatchRunCompletedView({ run }: { run: BatchRunSnapshot }) {
|
|||
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => {
|
|||
|
|
const firstFail = run.cases.find((item) => item.status === "fail");
|
|||
|
|
return firstFail ? new Set([firstFail.id]) : new Set();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const counts = countByStatus(run.cases);
|
|||
|
|
const judged = counts.pass + counts.fail;
|
|||
|
|
const total = run.cases.length;
|
|||
|
|
const passRate = judged === 0 ? 0 : Math.round((counts.pass / judged) * 100);
|
|||
|
|
const failRate = judged === 0 ? 0 : 100 - passRate;
|
|||
|
|
|
|||
|
|
function toggleExpanded(id: string) {
|
|||
|
|
setExpandedIds((prev) => {
|
|||
|
|
const next = new Set(prev);
|
|||
|
|
if (next.has(id)) next.delete(id);
|
|||
|
|
else next.add(id);
|
|||
|
|
return next;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="mx-auto flex w-full max-w-[960px] flex-col gap-4">
|
|||
|
|
{/* 结果总览 */}
|
|||
|
|
<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>
|
|||
|
|
{run.finishedAt && (
|
|||
|
|
<p className="mt-1.5 text-xs text-muted-soft">
|
|||
|
|
完成时间:{formatRunTime(run.finishedAt)}
|
|||
|
|
{run.stopped ? " · 已手动停止" : ""}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<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>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<PassRateDonut pass={counts.pass} fail={counts.fail} />
|
|||
|
|
|
|||
|
|
<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}
|
|||
|
|
/>
|
|||
|
|
{counts.skipped > 0 && (
|
|||
|
|
<LegendRow
|
|||
|
|
tone="muted"
|
|||
|
|
label="未执行"
|
|||
|
|
count={counts.skipped}
|
|||
|
|
percent={
|
|||
|
|
total === 0
|
|||
|
|
? 0
|
|||
|
|
: Math.round((counts.skipped / total) * 100)
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
{/* 用例列表 */}
|
|||
|
|
<section className="rounded-2xl border border-hairline bg-card shadow-sm">
|
|||
|
|
<div className="border-b border-hairline px-5 py-3.5">
|
|||
|
|
<h3 className="text-sm font-medium text-foreground">
|
|||
|
|
用例列表(共 {total} 个)
|
|||
|
|
</h3>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full min-w-[560px] text-left text-sm">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="border-b border-hairline text-xs text-muted-soft">
|
|||
|
|
<th className="w-12 px-5 py-2.5 font-medium">#</th>
|
|||
|
|
<th className="px-3 py-2.5 font-medium">测试用例</th>
|
|||
|
|
<th className="w-28 px-3 py-2.5 font-medium">状态</th>
|
|||
|
|
<th className="w-24 px-3 py-2.5 font-medium">详情</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{run.cases.map((item, index) => {
|
|||
|
|
const expanded = expandedIds.has(item.id);
|
|||
|
|
const canExpand =
|
|||
|
|
item.status === "fail" || item.status === "pass";
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<tr
|
|||
|
|
key={item.id}
|
|||
|
|
className="border-b border-hairline last:border-b-0"
|
|||
|
|
>
|
|||
|
|
<td colSpan={4} className="p-0">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
disabled={!canExpand}
|
|||
|
|
onClick={() => toggleExpanded(item.id)}
|
|||
|
|
className={cn(
|
|||
|
|
"flex w-full items-start gap-0 px-5 py-3 text-left transition-colors",
|
|||
|
|
canExpand && "hover:bg-canvas-soft/70",
|
|||
|
|
!canExpand && "cursor-default",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<span className="w-12 shrink-0 pt-0.5 tabular-nums text-muted-soft">
|
|||
|
|
{index + 1}
|
|||
|
|
</span>
|
|||
|
|
<span className="min-w-0 flex-1 px-3">
|
|||
|
|
<span className="block font-medium text-foreground">
|
|||
|
|
{item.name}
|
|||
|
|
</span>
|
|||
|
|
{expanded && (
|
|||
|
|
<span className="mt-3 block rounded-xl bg-canvas-soft/80 px-3 py-3">
|
|||
|
|
<span className="grid gap-3 sm:grid-cols-2">
|
|||
|
|
<DetailField
|
|||
|
|
label="预期结果"
|
|||
|
|
value={item.expected}
|
|||
|
|
/>
|
|||
|
|
<DetailField
|
|||
|
|
label="实际结果"
|
|||
|
|
value={item.actual}
|
|||
|
|
/>
|
|||
|
|
</span>
|
|||
|
|
{item.status === "fail" && item.failReason && (
|
|||
|
|
<span className="mt-3 block border-t border-hairline pt-3">
|
|||
|
|
<DetailField
|
|||
|
|
label="失败原因(LLM 判断)"
|
|||
|
|
value={item.failReason}
|
|||
|
|
/>
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</span>
|
|||
|
|
<span className="w-28 shrink-0 px-3 pt-0.5">
|
|||
|
|
<BatchCaseStatusBadge status={item.status} />
|
|||
|
|
</span>
|
|||
|
|
<span className="flex w-24 shrink-0 items-center gap-1 px-3 pt-0.5 text-muted-foreground">
|
|||
|
|
{canExpand ? (
|
|||
|
|
<>
|
|||
|
|
查看
|
|||
|
|
{expanded ? (
|
|||
|
|
<ChevronDown size={14} />
|
|||
|
|
) : (
|
|||
|
|
<ChevronRight size={14} />
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<span className="text-muted-soft">—</span>
|
|||
|
|
)}
|
|||
|
|
</span>
|
|||
|
|
</button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function PassRateDonut({ pass, fail }: { pass: number; fail: number }) {
|
|||
|
|
const total = pass + fail;
|
|||
|
|
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 - passLength;
|
|||
|
|
|
|||
|
|
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 && (
|
|||
|
|
<>
|
|||
|
|
<circle
|
|||
|
|
cx={size / 2}
|
|||
|
|
cy={size / 2}
|
|||
|
|
r={radius}
|
|||
|
|
fill="none"
|
|||
|
|
stroke="currentColor"
|
|||
|
|
strokeWidth={stroke}
|
|||
|
|
strokeDasharray={`${passLength} ${circumference}`}
|
|||
|
|
strokeLinecap="butt"
|
|||
|
|
className="text-success"
|
|||
|
|
/>
|
|||
|
|
<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"
|
|||
|
|
/>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</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 LegendRow({
|
|||
|
|
tone,
|
|||
|
|
label,
|
|||
|
|
count,
|
|||
|
|
percent,
|
|||
|
|
}: {
|
|||
|
|
tone: "success" | "destructive" | "muted";
|
|||
|
|
label: string;
|
|||
|
|
count: number;
|
|||
|
|
percent: number;
|
|||
|
|
}) {
|
|||
|
|
const dotClass = {
|
|||
|
|
success: "bg-success",
|
|||
|
|
destructive: "bg-destructive",
|
|||
|
|
muted: "bg-muted-soft",
|
|||
|
|
}[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}%)
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function DetailField({ label, value }: { label: string; value: string }) {
|
|||
|
|
return (
|
|||
|
|
<span className="block">
|
|||
|
|
<span className="block text-xs text-muted-soft">{label}</span>
|
|||
|
|
<span className="mt-1 block text-sm leading-6 text-muted-foreground">
|
|||
|
|
{value}
|
|||
|
|
</span>
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|