refactor(web): reduz a complexidade do DownloadItem e adiciona testes com vitest
This commit is contained in:
1 parent
cc5147946c
commit
1bcb35d169
5 files changed
+515
-150
No files matched your search
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { DownloadItem, type DownloadItemData } from "./DownloadItem";
|
||||
|
||||
const base: DownloadItemData = {
|
||||
id: "t1",
|
||||
sessionId: "s1",
|
||||
url: "https://example.com/file.zip",
|
||||
filename: "file.zip",
|
||||
status: "downloading",
|
||||
progress: 40,
|
||||
totalBytes: 10 * 1024 * 1024,
|
||||
receivedBytes: 4 * 1024 * 1024,
|
||||
speed: 2 * 1024 * 1024,
|
||||
createdAt: 0,
|
||||
};
|
||||
|
||||
const render = (patch: Partial<DownloadItemData> = {}) =>
|
||||
renderToStaticMarkup(<DownloadItem item={{ ...base, ...patch }} />);
|
||||
|
||||
describe("DownloadItem", () => {
|
||||
describe("em andamento (caminho principal)", () => {
|
||||
const html = render();
|
||||
|
||||
it("mostra status, bytes, velocidade e porcentagem", () => {
|
||||
expect(html).toContain("downloading");
|
||||
expect(html).toContain("4.0 MB");
|
||||
expect(html).toContain("10.0 MB");
|
||||
expect(html).toContain("2.00 MB/s");
|
||||
expect(html).toContain("40%");
|
||||
});
|
||||
|
||||
it("usa o spinner e não mostra botão nem erro", () => {
|
||||
expect(html).toContain("animate-spin");
|
||||
expect(html).not.toContain("Save to Disk");
|
||||
expect(html).not.toContain("Ready for download");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status queued", () => {
|
||||
it("mostra a mensagem de fila e o ícone pulsante", () => {
|
||||
const html = render({ status: "queued" });
|
||||
expect(html).toContain("Queued on server...");
|
||||
expect(html).toContain("animate-pulse");
|
||||
expect(html).not.toContain("animate-spin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status completed", () => {
|
||||
const html = render({ status: "completed", progress: 100 });
|
||||
|
||||
it("mostra o botão de salvar e o tamanho final", () => {
|
||||
expect(html).toContain("Save to Disk");
|
||||
expect(html).toContain("Ready for download (10.0 MB)");
|
||||
});
|
||||
|
||||
it("esconde a seção de progresso", () => {
|
||||
expect(html).not.toContain("MB/s");
|
||||
expect(html).not.toContain("100%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status error", () => {
|
||||
it("mostra a mensagem de erro da task", () => {
|
||||
const html = render({ status: "error", error: "HTTP Error 404" });
|
||||
expect(html).toContain("HTTP Error 404");
|
||||
expect(html).not.toContain("Save to Disk");
|
||||
expect(html).not.toContain("MB/s");
|
||||
});
|
||||
|
||||
it("usa a mensagem padrão sem detalhe de erro", () => {
|
||||
expect(render({ status: "error" })).toContain("Unknown error occurred");
|
||||
});
|
||||
});
|
||||
|
||||
describe("título", () => {
|
||||
it("usa o nome do arquivo", () => {
|
||||
expect(render()).toContain('title="file.zip"');
|
||||
});
|
||||
|
||||
it("cai para a URL quando não há nome do arquivo", () => {
|
||||
expect(render({ filename: "" })).toContain(
|
||||
'title="https://example.com/file.zip"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("valores zerados durante o download", () => {
|
||||
it("tamanho total desconhecido mostra ??", () => {
|
||||
expect(render({ totalBytes: 0 })).toContain("??");
|
||||
});
|
||||
|
||||
it("sem velocidade mostra 0 B/s", () => {
|
||||
expect(render({ speed: 0 })).toContain("0 B/s");
|
||||
});
|
||||
|
||||
it("sem progresso mostra 0%", () => {
|
||||
expect(render({ progress: 0 })).toContain(">0%<");
|
||||
});
|
||||
|
||||
it("sem bytes recebidos mostra hífen", () => {
|
||||
expect(render({ receivedBytes: 0 })).toContain("- / 10.0 MB");
|
||||
});
|
||||
|
||||
it("arredonda a porcentagem", () => {
|
||||
expect(render({ progress: 40.6 })).toContain("41%");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
FileCheck,
|
||||
FileWarning,
|
||||
@@ -34,59 +35,91 @@ interface DownloadItemProps {
|
||||
item: DownloadItemData;
|
||||
}
|
||||
|
||||
const ICON_BOX = "flex h-10 w-10 items-center justify-center rounded-full";
|
||||
|
||||
const STATUS_ICON: Record<DownloadStatus, { box: string; icon: ReactNode }> = {
|
||||
completed: { box: "bg-green-500/10 text-green-500", icon: <FileCheck className="h-5 w-5" /> },
|
||||
error: { box: "bg-destructive/10 text-destructive", icon: <FileWarning className="h-5 w-5" /> },
|
||||
queued: {
|
||||
box: "bg-secondary text-secondary-foreground",
|
||||
icon: <Server className="h-5 w-5 animate-pulse" />,
|
||||
},
|
||||
downloading: { box: "bg-primary/10 text-primary", icon: <Loader2 className="h-5 w-5 animate-spin" /> },
|
||||
};
|
||||
|
||||
function StatusIcon({ status }: { status: DownloadStatus }) {
|
||||
const { box, icon } = STATUS_ICON[status];
|
||||
return <div className={`${ICON_BOX} ${box}`}>{icon}</div>;
|
||||
}
|
||||
|
||||
function ProgressSection({ item }: { item: DownloadItemData }) {
|
||||
const total = item.totalBytes ? formatBytes(item.totalBytes) : "??";
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{item.status === "queued" ? "Queued on server..." : item.status}</span>
|
||||
<span>{formatBytes(item.receivedBytes || 0)} / {total}</span>
|
||||
</div>
|
||||
<Progress value={item.progress || 0} className="h-1.5" />
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{item.speed ? formatSpeed(item.speed) : "0 B/s"}</span>
|
||||
<span>{item.progress ? `${Math.round(item.progress)}%` : "0%"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDetails({ item }: { item: DownloadItemData }) {
|
||||
if (item.status === "completed") {
|
||||
return (
|
||||
<div className="pt-2 flex items-center gap-2 text-xs text-green-500">
|
||||
<FileCheck className="w-3 h-3" />
|
||||
<span>Ready for download ({formatBytes(item.totalBytes)})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.status === "error") {
|
||||
return (
|
||||
<div className="text-sm text-destructive pt-1">
|
||||
{item.error || "Unknown error occurred"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProgressSection item={item} />;
|
||||
}
|
||||
|
||||
async function saveToDisk(item: DownloadItemData) {
|
||||
try {
|
||||
const sessionId = localStorage.getItem("downloader_session_id") || "";
|
||||
const res = await fetch(`${API_URL}/api/download/${item.id}`, {
|
||||
headers: { "x-session-id": sessionId }
|
||||
});
|
||||
if (!res.ok) throw new Error("Download failed");
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = item.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch {
|
||||
alert("Failed to download file");
|
||||
}
|
||||
}
|
||||
|
||||
export function DownloadItem({ item }: DownloadItemProps) {
|
||||
const isCompleted = item.status === "completed";
|
||||
const isError = item.status === "error";
|
||||
const isQueued = item.status === "queued";
|
||||
|
||||
const handleDownloadFile = () => {
|
||||
const download = async () => {
|
||||
try {
|
||||
const sessionId = localStorage.getItem("downloader_session_id") || "";
|
||||
const res = await fetch(`${API_URL}/api/download/${item.id}`, {
|
||||
headers: { "x-session-id": sessionId }
|
||||
});
|
||||
if (!res.ok) throw new Error("Download failed");
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = item.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch {
|
||||
alert("Failed to download file");
|
||||
}
|
||||
};
|
||||
|
||||
download();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden transition-all hover:bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-1">
|
||||
{isCompleted ? (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10 text-green-500">
|
||||
<FileCheck className="h-5 w-5" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<FileWarning className="h-5 w-5" />
|
||||
</div>
|
||||
) : isQueued ? (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<Server className="h-5 w-5 animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<StatusIcon status={item.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
@@ -99,41 +132,15 @@ export function DownloadItem({ item }: DownloadItemProps) {
|
||||
{item.url}
|
||||
</p>
|
||||
</div>
|
||||
{isCompleted && (
|
||||
<Button size="sm" onClick={handleDownloadFile} className="shrink-0 gap-2">
|
||||
{item.status === "completed" && (
|
||||
<Button size="sm" onClick={() => saveToDisk(item)} className="shrink-0 gap-2">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
Save to Disk
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
{!isCompleted && !isError && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{isQueued ? "Queued on server..." : item.status}</span>
|
||||
<span>{formatBytes(item.receivedBytes || 0)} / {item.totalBytes ? formatBytes(item.totalBytes) : "??"}</span>
|
||||
</div>
|
||||
<Progress value={item.progress || 0} className="h-1.5" />
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{item.speed ? formatSpeed(item.speed) : "0 B/s"}</span>
|
||||
<span>{item.progress ? `${Math.round(item.progress)}%` : "0%"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<div className="pt-2 flex items-center gap-2 text-xs text-green-500">
|
||||
<FileCheck className="w-3 h-3" />
|
||||
<span>Ready for download ({formatBytes(item.totalBytes)})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="text-sm text-destructive pt-1">
|
||||
{item.error || "Unknown error occurred"}
|
||||
</div>
|
||||
)}
|
||||
<StatusDetails item={item} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in new issue
Block a user