refactor(web): reduz a complexidade do DownloadItem e adiciona testes com vitest

This commit is contained in:
evandro committed 2026-10-10 03:55:02 -03:00
1 parent cc5147946c
commit 1bcb35d169
5 files changed
+515 -150

No files matched your search

+4 -1
View File
@@ -7,6 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "vitest run",
"typecheck": "tsc -b",
"preview": "vite preview"
},
"dependencies": {
@@ -34,6 +36,7 @@
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^5.0.3"
}
}
@@ -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%");
});
});
});
+83 -76
View File
@@ -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>