import { readFileSync } from "node:fs";
import fs from "node:fs";
import path from "node:path";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { DownloadItem, DownloadStatus, PackageEntry } from "../src/shared/types";
import {
buildDownloadSidebarCounts,
buildDownloadsViewModel,
classifyDownloadStatus,
formatRemainingDownloadBytes,
getDownloadQueueTotalBytes,
getRemainingDownloadBytes,
getPendingDownloadItemCount,
getDownloadSpeedBps,
buildDownloadLogicalRows,
type DownloadSidebarFilter,
type DownloadsModelInput
} from "../src/renderer/views/downloads/downloads-model";
import {
DOWNLOAD_DISCLOSURE_DURATION_MS,
DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS,
activateDownloadDisclosureTransition,
getDownloadDisclosurePinnedIds,
mergeDownloadDisclosureRows,
prepareDownloadDisclosureTransition,
scheduleDownloadDisclosureActivation,
stableDownloadDisclosureRows
} from "../src/renderer/views/downloads/download-disclosure-transition";
import {
DOWNLOAD_ORDER_TRANSITION_DURATION_MS,
getDownloadOrderTransitionPinnedIds,
getDownloadOrderTransformKeyframes,
isDownloadPackageOrderChange
} from "../src/renderer/views/downloads/download-order-transition";
import {
DownloadsContent,
DownloadsFooter,
DownloadsSidebar,
DownloadsSidebarStatus,
DownloadsToolbar,
DownloadsView,
type DownloadsViewActions
} from "../src/renderer/views/downloads/DownloadsView";
import {
DownloadsTableHeader,
ItemRowContent,
PackageCardContent,
areItemRowPropsEqual,
arePackageCardPropsEqual,
compactDownloadStatus,
downloadColumnDefinitions,
getAvailabilitySummary,
getPackageProgress,
getPackageSizeProgress
} from "../src/renderer/views/downloads/DownloadsTable";
import {
DOWNLOAD_FILE_ROW_HEIGHT,
DOWNLOAD_PACKAGE_ROW_HEIGHT,
calculateDownloadVirtualWindow
} from "../src/renderer/views/downloads/download-virtualizer";
import {
compactDownloadServiceLabel,
extractHoster,
formatHosterLabel,
normalizeDownloadServiceLabel
} from "../src/renderer/download-format";
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
describe("Downloadtabellen-Spalten", () => {
it("verteilt die Breite mit ausreichend Platz für vollständige Überschriften", () => {
expect(downloadColumnDefinitions.name.width).toBe("minmax(var(--downloads-name-min, 290px), 2.3fr)");
expect(downloadColumnDefinitions.progress.width).toBe("minmax(var(--downloads-progress-min, 105px), 0.85fr)");
expect(downloadColumnDefinitions.prio.width).toBe("minmax(var(--downloads-priority-min, 85px), 0.8fr)");
expect(downloadColumnDefinitions.speed).toEqual(expect.objectContaining({ label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" }));
expect(downloadColumnDefinitions.availability).toEqual(expect.objectContaining({ label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)" }));
});
it("uses the normal text color for sortable and static column headers", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*color:\s*var\(--ui-text\);/s);
});
it("moves complete columns through pointer capture and animated transforms", () => {
const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/DownloadsTable.tsx"), "utf8");
const dragSource = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/column-drag.ts"), "utf8");
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(source).toContain("onColumnPointerDown");
expect(source).toContain("setPointerCapture");
expect(source).toContain("onPointerMove");
expect(source).not.toMatch(/className="downloads-column-header"[\s\S]{0,180}\sdraggable/);
expect(dragSource).toContain('root.style.setProperty(`--downloads-column-drag-${id}`');
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-download-column\]\s*\{[^}]*transform:\s*translate3d\(var\(--downloads-column-drag-x, 0px\), 0, 0\);[^}]*transition:\s*transform 220ms/s);
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*transition:\s*none;/s);
expect(css).not.toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*background:/s);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-table\.is-column-drag-active \[data-download-column\][^{]*\{[^}]*transition-duration:\s*220ms !important;/s);
});
it("changes package collapse state only through user actions", () => {
const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/App.tsx"), "utf8");
expect(source).not.toContain("autoExpandedPkgsRef");
expect(source).not.toMatch(/isExtracting[\s\S]{0,800}setCollapsedPackages/);
});
});
describe("rollende Downloadkennzahlen", () => {
it("moves increasing values up and decreasing values down", () => {
expect(getRollingMetricDirection(300, 600)).toBe("up");
expect(getRollingMetricDirection(600, 300)).toBe("down");
expect(getRollingMetricDirection(300, 300)).toBe("none");
});
it("animates exactly the six stable sidebar metrics including the remaining volume", () => {
const html = renderToStaticMarkup();
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(6);
expect(html).toContain("Verbleibend");
expect(html.indexOf("Verbleibend")).toBeLessThan(html.indexOf("Gesamt"));
expect(html).toContain('data-status-metric="speed"');
expect(html).toContain('data-status-metric="eta"');
});
});
function item(id: string, packageId: string, status: DownloadStatus, overrides: Partial = {}): DownloadItem {
return {
id,
packageId,
url: `https://rapidgator.net/file/${id}`,
provider: "realdebrid",
providerLabel: "Real-Debrid",
status,
retries: 0,
speedBps: status === "downloading" ? 12_000_000 : 0,
downloadedBytes: status === "completed" ? 2_000_000_000 : 500_000_000,
totalBytes: 2_000_000_000,
progressPercent: status === "completed" ? 100 : 25,
fileName: `${id}.mkv`,
targetPath: `C:\\Downloads\\${id}.mkv`,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "Hoster nicht erreichbar" : "",
fullStatus: status,
createdAt: now - 1_000,
updatedAt: now,
...overrides
};
}
function pkg(id: string, name: string, itemIds: string[]): PackageEntry {
return { id, name, itemIds, createdAt: now } as PackageEntry;
}
describe("Download-Gesamtgröße", () => {
it("summiert bekannte Dateigrößen und verwendet geladene Bytes nur als Fallback", () => {
const items = [
item("known", "package-a", "queued", { totalBytes: 4_000, downloadedBytes: 500 }),
item("fallback", "package-a", "queued", { totalBytes: null, downloadedBytes: 750 })
];
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
});
it("preserves completed package bytes and progress after immediate cleanup", () => {
const active = item("active", "package-a", "downloading", {
downloadedBytes: 500,
totalBytes: 1_000,
progressPercent: 50
});
const packageEntry = pkg("package-a", "Serie", ["active"]);
packageEntry.cleanedCompletedItemCount = 2;
packageEntry.cleanedExtractedItemCount = 2;
packageEntry.cleanedDownloadedBytes = 2_000;
packageEntry.cleanedTotalBytes = 2_000;
const row = { package: packageEntry, items: [active], allItems: [active], collapsed: true };
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 2_500, total: 3_000, value: 83 });
expect(getPackageProgress(row)).toEqual(expect.objectContaining({ done: 2, total: 3, value: 83 }));
});
});
describe("verbleibendes Downloadvolumen", () => {
it("summiert nur offene bekannte Restbytes und klemmt überladene Fortschritte bei null", () => {
const items = [
item("partial", "remaining", "downloading", { downloadedBytes: 750_000_000, totalBytes: 2_000_000_000 }),
item("queued", "remaining", "queued", { downloadedBytes: 0, totalBytes: 3_000_000_000 }),
item("overshot", "remaining", "downloading", { downloadedBytes: 2_100_000_000, totalBytes: 2_000_000_000 }),
item("done", "remaining", "completed", { downloadedBytes: 9_000_000_000, totalBytes: 10_000_000_000 }),
item("failed", "remaining", "failed", { downloadedBytes: 0, totalBytes: 8_000_000_000 }),
item("cancelled", "remaining", "cancelled", { downloadedBytes: 0, totalBytes: 7_000_000_000 })
];
expect(getRemainingDownloadBytes(items)).toEqual({ bytes: 4_250_000_000, unknownItems: 0 });
});
it("meldet unbekannte Größen nur für noch offene Downloads", () => {
const items = [
item("known", "remaining", "queued", { downloadedBytes: 250_000_000, totalBytes: 1_000_000_000 }),
item("unknown-open", "remaining", "queued", { downloadedBytes: 120_000_000, totalBytes: null }),
item("unknown-done", "remaining", "completed", { totalBytes: null })
];
expect(getRemainingDownloadBytes(items)).toEqual({ bytes: 750_000_000, unknownItems: 1 });
expect(formatRemainingDownloadBytes({ bytes: 750_000_000, unknownItems: 1 })).toBe("≥ 715.26 MB");
expect(formatRemainingDownloadBytes({ bytes: 0, unknownItems: 1 })).toBe("Unbekannt");
expect(formatRemainingDownloadBytes({ bytes: 0, unknownItems: 0 })).toBe("0 B");
});
});
describe("virtualisierte Paketanimation", () => {
const packageA = pkg("package-a", "A", ["a-1", "a-2"]);
const packageB = pkg("package-b", "B", ["b-1"]);
const items = {
"a-1": item("a-1", "package-a", "queued"),
"a-2": item("a-2", "package-a", "queued"),
"b-1": item("b-1", "package-b", "queued")
};
const logicalRows = (collapsedPackageIds: string[]) => buildDownloadLogicalRows(buildDownloadsViewModel({
packageOrder: [packageA.id, packageB.id],
packages: { [packageA.id]: packageA, [packageB.id]: packageB },
items,
displayMode: "packages",
filter: "all",
providerFilter: "all",
query: "",
collapsedPackageIds,
selectedIds: [],
hideExtractedItems: false,
showAllPackages: true,
renderLimit: 100
}));
it("überspringt die Paketbewegung, wenn sie in den Einstellungen deaktiviert ist", () => {
const expanded = logicalRows([]);
const prepared = prepareDownloadDisclosureTransition(
stableDownloadDisclosureRows(logicalRows([packageA.id])),
expanded,
false
);
expect(prepared.animated).toBe(false);
expect(prepared.rows).toEqual(stableDownloadDisclosureRows(expanded));
expect(prepared.rows.some((row) => row.type === "item-group")).toBe(false);
});
it("deaktiviert alle Downloadbewegungen über den allgemeinen Animationsschalter", () => {
const html = renderToStaticMarkup();
const css = readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(html).toContain('class="downloads-table-body is-download-motion-disabled"');
expect(css).toMatch(/\.downloads-table-body\.is-download-motion-disabled \.downloads-virtual-spacer,\s*\.downloads-table-body\.is-download-motion-disabled \.downloads-virtual-row\s*\{[^}]*transition:\s*none !important;/s);
});
it("pins only previously visible rows for a real priority reorder", () => {
expect(DOWNLOAD_ORDER_TRANSITION_DURATION_MS).toBe(3000);
expect(isDownloadPackageOrderChange(["a", "b", "c"], ["b", "c", "a"])).toBe(true);
expect(isDownloadPackageOrderChange(["a", "b"], ["a", "c"])).toBe(false);
expect(getDownloadOrderTransitionPinnedIds({
enabled: true,
previousOrder: ["a", "b", "c"],
nextOrder: ["b", "c", "a"],
previousVisibleIds: ["a", "a:items", "b"],
activePinnedIds: []
})).toEqual(["a", "a:items", "b"]);
expect(getDownloadOrderTransitionPinnedIds({
enabled: false,
previousOrder: ["a", "b", "c"],
nextOrder: ["b", "c", "a"],
previousVisibleIds: ["a", "b"],
activePinnedIds: ["c"]
})).toEqual([]);
expect(getDownloadOrderTransformKeyframes(240, 159, 315)).toEqual([
{ transform: "translateY(84px)" },
{ transform: "translateY(240px)" }
]);
});
it("zeichnet den Startzustand in einem eigenen Frame vor der Aktivierung", () => {
const frames: FrameRequestCallback[] = [];
const cancelled: number[] = [];
const activated = vi.fn();
let frameId = 0;
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
frames.push(callback);
frameId += 1;
return frameId;
});
const cancelFrame = vi.fn((frame: number) => cancelled.push(frame));
const cancel = scheduleDownloadDisclosureActivation(requestFrame, cancelFrame, activated);
expect(activated).not.toHaveBeenCalled();
expect(frames).toHaveLength(1);
frames.shift()?.(16);
expect(activated).not.toHaveBeenCalled();
expect(frames).toHaveLength(1);
frames.shift()?.(32);
expect(activated).toHaveBeenCalledTimes(1);
cancel();
expect(cancelled).toContain(2);
});
it("fährt einen gemeinsamen Paketbereich aus und lässt seine Unterzeilen auf fester Höhe", () => {
const collapsed = logicalRows([packageA.id]);
const expanded = logicalRows([]);
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(collapsed), expanded);
expect(prepared.animated).toBe(true);
expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([
["package-a", 40, "stable", 1],
["package-a:items", 0, "entering", 1],
["package-b", 40, "stable", 1],
["b-1", 38, "stable", 1]
]);
const preparedGroup = prepared.rows.find((row) => row.type === "item-group");
expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => [row.id, row.height]) : []).toEqual([
["a-1", 38],
["a-2", 38]
]);
expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([
["package-a", 40, 1],
["package-a:items", 76, 1],
["package-b", 40, 1],
["b-1", 38, 1]
]);
});
it("behält die ausdrücklich gewünschte Paketbewegung auch bei reduzierten Systemanimationen bei", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(DOWNLOAD_DISCLOSURE_DURATION_MS).toBe(1500);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-virtual-spacer\s*\{[^}]*transition-duration:\s*1500ms !important;/s);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-virtual-row\s*\{[^}]*transition-duration:\s*1500ms, 1500ms, 1500ms !important;/s);
});
it("schneidet beim Einfahren den gemeinsamen Paketbereich ab statt Unterzeilen zusammenzuziehen", () => {
const expanded = logicalRows([]);
const collapsed = logicalRows([packageA.id]);
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(expanded), collapsed);
expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([
["package-a", 40, "stable", 1],
["package-a:items", 76, "leaving", 1],
["package-b", 40, "stable", 1],
["b-1", 38, "stable", 1]
]);
const preparedGroup = prepared.rows.find((row) => row.type === "item-group");
expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => row.height) : []).toEqual([38, 38]);
expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([
["package-a", 40, 1],
["package-a:items", 0, 1],
["package-b", 40, 1],
["b-1", 38, 1]
]);
expect(stableDownloadDisclosureRows(collapsed).map((row) => row.id)).toEqual(["package-a", "package-b", "b-1"]);
});
it("mountet beim Zuklappen auch erst im Ziel-Viewport sichtbare Folgepakete vor der Bewegung", () => {
const childIds = Array.from({ length: 26 }, (_, index) => `first-${index}`);
const followingPackages = Array.from({ length: 36 }, (_, index) => pkg(`following-${index}`, `Folge ${index}`, [`following-item-${index}`]));
const firstPackage = pkg("first", "Erstes Paket", childIds);
const packageOrder = [firstPackage.id, ...followingPackages.map((entry) => entry.id)];
const packages = Object.fromEntries([firstPackage, ...followingPackages].map((entry) => [entry.id, entry]));
const queueItems = Object.fromEntries([
...childIds.map((id) => [id, item(id, firstPackage.id, "queued")] as const),
...followingPackages.map((entry, index) => [`following-item-${index}`, item(`following-item-${index}`, entry.id, "queued")] as const)
]);
const buildRows = (collapsed: boolean) => buildDownloadLogicalRows(buildDownloadsViewModel({
packageOrder,
packages,
items: queueItems,
displayMode: "packages",
filter: "all",
providerFilter: "all",
query: "",
collapsedPackageIds: collapsed ? packageOrder : followingPackages.map((entry) => entry.id),
selectedIds: [],
hideExtractedItems: false,
showAllPackages: true,
renderLimit: 100
}));
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(buildRows(false)), buildRows(true));
const options = { scrollTop: 0, viewportHeight: 720, overscan: 8 };
const startWindow = calculateDownloadVirtualWindow(prepared.rows, options);
const targetWindow = calculateDownloadVirtualWindow(activateDownloadDisclosureTransition(prepared.rows), options);
const startIds = new Set(startWindow.rows.map((entry) => entry.id));
const targetOnlyIds = targetWindow.rows.map((entry) => entry.id).filter((id) => !startIds.has(id));
const pinnedIds = getDownloadDisclosurePinnedIds(prepared.rows, options);
const mountedStartWindow = calculateDownloadVirtualWindow(prepared.rows, { ...options, pinnedIds });
const mountedStartIds = new Set(mountedStartWindow.rows.map((entry) => entry.id));
expect(targetOnlyIds.length).toBeGreaterThan(10);
expect(targetOnlyIds.every((id) => mountedStartIds.has(id))).toBe(true);
expect(pinnedIds.length).toBeLessThan(80);
});
it("überspringt die Bewegung bei riesigen Einzelpaketen und hält das DOM-Fenster begrenzt", () => {
const count = DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS + 10_000;
const hugePackage = pkg("huge-package", "Groß", Array.from({ length: count }, (_, index) => `huge-${index}`));
const hugeItems = Object.fromEntries(hugePackage.itemIds.map((id) => [id, item(id, hugePackage.id, "queued")]));
const buildRows = (collapsed: boolean) => buildDownloadLogicalRows(buildDownloadsViewModel({
packageOrder: [hugePackage.id],
packages: { [hugePackage.id]: hugePackage },
items: hugeItems,
displayMode: "packages",
filter: "all",
providerFilter: "all",
query: "",
collapsedPackageIds: collapsed ? [hugePackage.id] : [],
selectedIds: [],
hideExtractedItems: false,
showAllPackages: true,
renderLimit: count + 1
}));
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(buildRows(true)), buildRows(false));
const window = calculateDownloadVirtualWindow(prepared.rows, { scrollTop: 0, viewportHeight: 720, overscan: 8 });
expect(prepared.animated).toBe(false);
expect(window.rows.length).toBeLessThan(40);
});
it("begrenzt gleichzeitig animierte Unterzeilen über alle Pakete hinweg", () => {
const manyItems = Object.fromEntries(Array.from({ length: 80 }, (_, index) => {
const packageId = index < 40 ? "many-a" : "many-b";
const id = `${packageId}-${index}`;
return [id, item(id, packageId, "queued")];
}));
const manyPackages = {
"many-a": pkg("many-a", "Viele A", Object.keys(manyItems).filter((id) => id.startsWith("many-a"))),
"many-b": pkg("many-b", "Viele B", Object.keys(manyItems).filter((id) => id.startsWith("many-b")))
};
const buildRows = (collapsedPackageIds: string[]) => buildDownloadLogicalRows(buildDownloadsViewModel({
packageOrder: ["many-a", "many-b"],
packages: manyPackages,
items: manyItems,
displayMode: "packages",
filter: "all",
providerFilter: "all",
query: "",
collapsedPackageIds,
selectedIds: [],
hideExtractedItems: false,
showAllPackages: true,
renderLimit: 100
}));
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(buildRows(["many-a", "many-b"])), buildRows([]));
expect(prepared.animated).toBe(false);
});
it("übernimmt Laufzeitupdates während der Bewegung und kehrt schnelle Gegenklicks um", () => {
const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows);
const updated = logicalRows([]).map((row) => row.type === "item" && row.id === "a-1" ? { ...row, item: { ...row.item, downloadedBytes: 987_654_321 } } : row);
const merged = mergeDownloadDisclosureRows(entering, updated);
const enteringGroup = merged.find((row) => row.type === "item-group");
const updatedItem = enteringGroup?.type === "item-group" ? enteringGroup.items.find((row) => row.id === "a-1") : null;
expect(updatedItem?.item.downloadedBytes).toBe(987_654_321);
expect(enteringGroup).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 76, disclosureOpacity: 1 }));
const collapsed = logicalRows([packageA.id]);
const leaving = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([])), collapsed).rows);
const reversed = prepareDownloadDisclosureTransition(leaving, logicalRows([]));
expect(reversed.animated).toBe(true);
expect(reversed.rows.find((row) => row.packageId === packageA.id && row.type === "item-group")).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 0 }));
});
it("gleicht die Unterzeilenmenge einer laufenden Paketbewegung sofort mit dem aktuellen Modell ab", () => {
const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows);
const current = logicalRows([]);
const replacement = item("a-3", packageA.id, "queued");
const updated = current.flatMap((row) => {
if (row.type === "item" && row.id === "a-2") {
return [{ ...row, id: replacement.id, item: replacement }];
}
return [row];
});
const merged = mergeDownloadDisclosureRows(entering, updated);
const group = merged.find((row) => row.type === "item-group");
expect(group?.type === "item-group" ? group.items.map((row) => row.id) : []).toEqual(["a-1", "a-3"]);
expect(group).toEqual(expect.objectContaining({ height: 76 }));
});
it("bricht eine laufende Gruppenbewegung ab wenn ein Filterwechsel die Virtualisierungsgrenze überschreitet", () => {
const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows);
const itemIds = Array.from({ length: DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS + 100 }, (_, index) => `expanded-${index}`);
const expandedPackage = pkg(packageA.id, packageA.name, itemIds);
const expandedItems = Object.fromEntries(itemIds.map((id) => [id, item(id, packageA.id, "queued")]));
const expandedRows = buildDownloadLogicalRows(buildDownloadsViewModel({
packageOrder: [expandedPackage.id],
packages: { [expandedPackage.id]: expandedPackage },
items: expandedItems,
displayMode: "packages",
filter: "all",
providerFilter: "all",
query: "",
collapsedPackageIds: [],
selectedIds: [],
hideExtractedItems: false,
showAllPackages: true,
renderLimit: itemIds.length + 1
}));
const merged = mergeDownloadDisclosureRows(entering, expandedRows);
const window = calculateDownloadVirtualWindow(merged, { scrollTop: 0, viewportHeight: 720, overscan: 8 });
expect(merged.some((row) => row.type === "item-group")).toBe(false);
expect(window.rows.length).toBeLessThan(40);
});
});
describe("laufender Queue-Linkzähler", () => {
it("sinkt sofort, sobald eine Unterdatei abgeschlossen ist", () => {
const items = [
item("queued", "package-a", "queued"),
item("active", "package-a", "downloading"),
item("done", "package-a", "completed"),
item("failed", "package-a", "failed")
];
expect(getPendingDownloadItemCount(items)).toBe(2);
});
it("formatiert große Sidebar-Zähler mit deutschen Tausenderpunkten", () => {
const model = withRuntime(createInput(), {
status: { ...withRuntime(createInput()).status, packages: 130, links: 2484 }
});
const html = renderToStaticMarkup();
expect(html).toContain(">2.484<");
});
});
describe("responsive Downloadstatus und Servicebezeichnungen", () => {
it("keeps full status details while providing compact table text", () => {
expect(compactDownloadStatus("Link wird umgewandelt")).toBe("Umwandeln");
expect(compactDownloadStatus("Download läuft (Mega-Debrid API)")).toBe("Download läuft");
expect(compactDownloadStatus("Download running (Mega-Debrid API)")).toBe("Download running");
expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken - 1%");
expect(compactDownloadStatus("0/11 · Entpacken 53% (1/1) · scn2-httpv7-S01E102.rar")).toBe("Entpacken - 53%");
expect(compactDownloadStatus("Extracting 53% (1/1) · archive.rar")).toBe("Extracting - 53%");
expect(compactDownloadStatus("Passwort knacken: 75% (3/4) · sau-geheim.part1.rar")).toBe("Passwort knacken: 75% (3/4)");
expect(compactDownloadStatus("Passwort gefunden · archive.part1.rar")).toBe("Passwort gefunden");
expect(compactDownloadStatus("Entpacken - Ausstehend · archive.part1.rar")).toBe("Entpacken - Ausstehend");
expect(compactDownloadStatus("Entpack-Fehler [archive.part1.rar]: Unerwartetes Dateiende")).toBe("Entpack-Fehler");
expect(compactDownloadStatus("Extraction error [archive.part1.rar]: Unexpected end of file")).toBe("Extraction error");
});
it("normalizes every supported RapidGator domain to one hoster identity", () => {
const hosters = [
extractHoster("https://rapidgator.net/file/one"),
extractHoster("https://rg.to/file/two"),
extractHoster("https://cdn.rg.to/file/three"),
extractHoster("https://rapidgator.asia/file/four")
];
expect(hosters).toEqual(["rapidgator", "rapidgator", "rapidgator", "rapidgator"]);
expect(new Set(hosters).size).toBe(1);
expect(formatHosterLabel(hosters[1])).toEqual(expect.objectContaining({ compact: "RG", title: "RapidGator", iconSrc: expect.any(String) }));
});
it("removes duplicated access-mode wording from service labels", () => {
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)");
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)");
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Access)")).toBe("Mega-Debrid (API)");
expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid (Web), Mega-Debrid (API)");
expect(compactDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid (Web), Mega-Debrid (API)");
});
});
function createInput(overrides: Partial = {}): DownloadsModelInput {
const items = [
item("active", "package-a", "downloading"),
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link", url: "https://ddownload.com/file/queued" }),
item("failed", "package-b", "failed", { provider: "alldebrid", providerLabel: "AllDebrid" }),
item("done", "package-b", "completed", { provider: "realdebrid", providerLabel: "Real-Debrid" })
];
const packages = [
pkg("package-a", "Aktive Serie", ["active", "queued"]),
pkg("package-b", "Archiv Paket", ["failed", "done"])
];
return {
packageOrder: packages.map((entry) => entry.id),
packages: Object.fromEntries(packages.map((entry) => [entry.id, entry])),
items: Object.fromEntries(items.map((entry) => [entry.id, entry])),
displayMode: "packages",
filter: "all",
providerFilter: "all",
query: "",
collapsedPackageIds: [],
selectedIds: [],
hideExtractedItems: false,
showAllPackages: false,
renderLimit: 260,
...overrides
};
}
function createLargeInput(packageCount: number, itemCount: number, overrides: Partial = {}): DownloadsModelInput {
const packageEntries = Array.from({ length: packageCount }, (_, packageIndex) => {
const ids = Array.from({ length: itemCount }, (_, itemIndex) => `i-${packageIndex}-${itemIndex}`);
return pkg(`p-${packageIndex}`, `Paket ${packageIndex}`, ids);
});
const itemEntries = packageEntries.flatMap((entry, packageIndex) => (
entry.itemIds.map((id, itemIndex) => item(id, entry.id, packageIndex === packageCount - 1 && itemIndex === 0 ? "downloading" : "queued"))
));
return {
...createInput(overrides),
packageOrder: packageEntries.map((entry) => entry.id),
packages: Object.fromEntries(packageEntries.map((entry) => [entry.id, entry])),
items: Object.fromEntries(itemEntries.map((entry) => [entry.id, entry])),
...overrides
};
}
function createActions(overrides: Partial = {}): DownloadsViewActions {
return {
onDisplayModeChange: () => {},
onFilterChange: () => {},
onProviderFilterChange: () => {},
onQueryChange: () => {},
onAddLinks: () => {},
onStartDownloads: () => {},
onPauseDownloads: () => {},
onStopDownloads: () => {},
onToggleSchedule: () => {},
onScheduleTimeChange: () => {},
onActivateSchedule: () => {},
onCancelSchedule: () => {},
onMoveSelectionUp: () => {},
onMoveSelectionDown: () => {},
onRenameSelection: () => {},
onRemoveSelection: () => {},
onToggleClipboardWatcher: () => {},
onClearAll: () => {},
onToggleAllPackages: () => {},
onShowAllPackages: () => {},
onSetVisibleSelection: () => {},
onToggleSelection: () => {},
onSelectionMouseDown: () => {},
onSelectionMouseEnter: () => {},
onTogglePackageCollapse: () => {},
onStartPackageRename: () => {},
onPackageRenameChange: () => {},
onCommitPackageRename: () => {},
onCancelPackageRename: () => {},
onCancelPackage: () => {},
onMovePackageUp: () => {},
onMovePackageDown: () => {},
onRemoveItem: () => {},
onOpenContextMenu: () => {},
onSortColumn: () => {},
onColumnPointerDown: () => {},
onColumnPointerMove: () => {},
onColumnPointerUp: () => {},
onColumnPointerCancel: () => {},
onColumnContextMenu: () => {},
...overrides
};
}
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit));
return;
}
if (!isValidElement(node)) {
return;
}
visit(node);
visitElements(node.props.children, visit);
}
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && predicate(element)) {
result = element;
}
});
if (!result) {
throw new Error("Element not found");
}
return result;
}
function findButton(node: ReactNode, label: string): ReactElement {
return findElement(node, (element) => element.type === "button" && element.props.children === label);
}
function withRuntime(input: DownloadsModelInput, overrides: Record = {}) {
return {
...buildDownloadsViewModel(input),
running: true,
paused: false,
canStart: true,
canPause: true,
canStop: true,
actionBusy: false,
reconnectSeconds: 0,
reconnectReason: "",
clipboardWatcher: true,
scheduleActive: false,
scheduleOpen: false,
scheduleTime: "23:30",
scheduleLabel: "",
packageSpeedBps: { "package-a": 12_000_000 },
disclosureRevision: 0,
animationsEnabled: true,
editingPackageId: null,
editingName: "",
columnOrder: ["name", "size", "hoster", "progress"] as const,
gridTemplate: "minmax(280px, 2fr) 140px 160px minmax(220px, 1fr)",
status: {
packages: 2,
links: 4,
session: "3,00 GB",
sessionBytes: 3_000_000_000,
total: "10,00 GB",
totalBytes: 10_000_000_000,
remaining: "6,50 GB",
remainingBytes: 6_500_000_000,
hosters: 3,
speed: "96,00 Mbit/s",
eta: "00:05:00"
},
...overrides
};
}
describe("downloads model", () => {
it("uses the package telemetry as the single live speed source", () => {
expect(getDownloadSpeedBps({ a: 45_000_000, b: 30_400_000, idle: 0 })).toBe(75_400_000);
expect(getDownloadSpeedBps({ stale: -1, invalid: Number.NaN })).toBe(0);
});
it("exports the sidebar filter contract used by the downloads shell", () => {
const filter: DownloadSidebarFilter = "queued";
expect(filter).toBe("queued");
});
it("builds sidebar counts without mutating the runtime items", () => {
const items = [
item("count-active", "count-package", "downloading"),
item("count-done", "count-package", "completed"),
item("count-cancelled", "count-package", "cancelled")
];
const snapshot = items.map((entry) => ({ ...entry }));
expect(buildDownloadSidebarCounts(items)).toEqual({ all: 3, active: 1, queued: 0, paused: 0, completed: 1, failed: 0 });
expect(items).toEqual(snapshot);
});
it("maps every runtime status into the exact semantic filter class", () => {
expect(classifyDownloadStatus("downloading")).toBe("active");
expect(classifyDownloadStatus("validating")).toBe("active");
expect(classifyDownloadStatus("extracting")).toBe("active");
expect(classifyDownloadStatus("integrity_check")).toBe("active");
expect(classifyDownloadStatus("queued")).toBe("queued");
expect(classifyDownloadStatus("reconnect_wait")).toBe("queued");
expect(classifyDownloadStatus("paused")).toBe("paused");
expect(classifyDownloadStatus("completed")).toBe("completed");
expect(classifyDownloadStatus("failed")).toBe("failed");
expect(classifyDownloadStatus("cancelled")).toBe("all");
});
it("derives sidebar counts from the complete queue before presentation filters", () => {
const model = buildDownloadsViewModel(createInput({ filter: "failed" }));
expect(model.counts).toEqual({ all: 4, active: 1, queued: 1, paused: 0, completed: 1, failed: 1 });
expect(model.visibleItemIds).toEqual(["failed"]);
});
it("filters by package name, file name, provider, status and extracted visibility", () => {
const byPackage = buildDownloadsViewModel(createInput({ query: "aktive serie" }));
const byFile = buildDownloadsViewModel(createInput({ query: "done.mkv" }));
const byProvider = buildDownloadsViewModel(createInput({ providerFilter: "debridlink" }));
const hiddenExtracted = buildDownloadsViewModel(createInput({
items: { ...createInput().items, done: item("done", "package-b", "completed", { fullStatus: "Entpackt" }) },
hideExtractedItems: true
}));
expect(byPackage.packageRows.map((row) => row.package.id)).toEqual(["package-a"]);
expect(byPackage.packageRows[0].items.map((entry) => entry.id)).toEqual(["active", "queued"]);
expect(byFile.packageRows.map((row) => row.package.id)).toEqual(["package-b"]);
expect(byFile.packageRows[0].items.map((entry) => entry.id)).toEqual(["done"]);
expect(byProvider.visibleItemIds).toEqual(["queued"]);
expect(hiddenExtracted.visibleItemIds).not.toContain("done");
});
it("supports the genuine flat file mode without synthetic package rows", () => {
const model = buildDownloadsViewModel(createInput({ displayMode: "files" }));
expect(model.packageRows).toEqual([]);
expect(model.fileRows.map((entry) => entry.id)).toEqual(["active", "queued", "failed", "done"]);
expect(model.mainRowCount).toBe(4);
});
it("reports a filtered flat-file range from the actually rendered rows", () => {
const model = buildDownloadsViewModel(createInput({ displayMode: "files", filter: "failed" }));
expect(model.paginationLabel).toBe("1\u20131 von 1");
expect(model.totalMainRowCount).toBe(1);
});
it("counts cancelled downloads only in the complete all queue", () => {
const model = buildDownloadsViewModel(createInput({
packageOrder: ["cancelled-package"],
packages: { "cancelled-package": pkg("cancelled-package", "Abgebrochen", ["cancelled-item"]) },
items: { "cancelled-item": item("cancelled-item", "cancelled-package", "cancelled") }
}));
expect(model.counts).toEqual({ all: 1, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 });
expect(buildDownloadsViewModel({ ...createInput(), filter: "completed", packageOrder: model.packageRows.map((row) => row.package.id), packages: { "cancelled-package": pkg("cancelled-package", "Abgebrochen", ["cancelled-item"]) }, items: { "cancelled-item": item("cancelled-item", "cancelled-package", "cancelled") } }).visibleItemIds).toEqual([]);
});
it("finds an account label without treating it as a provider id", () => {
const base = createInput();
const model = buildDownloadsViewModel({
...base,
items: {
...base.items,
active: { ...base.items.active, providerAccountLabel: "Privates Real-Debrid Konto" }
},
query: "privates real-debrid"
});
expect(model.visibleItemIds).toEqual(["active"]);
expect(model.providerFilter).toBe("all");
});
it("excludes collapsed children from visible and actionable row selection", () => {
const model = buildDownloadsViewModel(createInput({
collapsedPackageIds: ["package-a"],
selectedIds: ["package-a", "active", "queued"]
}));
expect(model.visibleRowIds).not.toContain("active");
expect(model.actionableSelectedIds).toEqual(["package-a"]);
});
it("keeps occupied package rows logical while preserving active packages and an actionable visible selection", () => {
const packageEntries = Array.from({ length: 264 }, (_, index) => pkg(`p-${index}`, `Paket ${index}`, [`i-${index}`]));
const itemEntries = packageEntries.map((entry, index) => item(`i-${index}`, entry.id, index === 263 ? "downloading" : "queued"));
const model = buildDownloadsViewModel(createInput({
packageOrder: packageEntries.map((entry) => entry.id),
packages: Object.fromEntries(packageEntries.map((entry) => [entry.id, entry])),
items: Object.fromEntries(itemEntries.map((entry) => [entry.id, entry])),
selectedIds: ["p-0", "i-0", "p-263", "i-263", "missing"],
renderLimit: 260
}));
expect(model.packageRows).toHaveLength(264);
expect(model.packageRows.some((row) => row.package.id === "p-263")).toBe(true);
expect(model.paginationLabel).toBe("1–264 von 264");
expect(model.actionableSelectedIds).toEqual(["p-0", "i-0", "p-263", "i-263"]);
});
it("keeps every filtered expanded row logical while the DOM window stays bounded", () => {
const model = buildDownloadsViewModel(createLargeInput(320, 2, {
selectedIds: ["p-0", "i-0-0", "p-319", "i-319-0"],
renderLimit: 20
}));
const logicalRows = buildDownloadLogicalRows(model);
const html = renderToStaticMarkup();
const renderedRows = html.match(/data-download-row-id=/g) ?? [];
expect(model.packageRows).toHaveLength(320);
expect(model.visibleRowIds).toHaveLength(960);
expect(model.visibleRowIds.slice(0, 3)).toEqual(["p-0", "i-0-0", "i-0-1"]);
expect(model.visibleRowIds.slice(-3)).toEqual(["p-319", "i-319-0", "i-319-1"]);
expect(model.actionableSelectedIds).toEqual(["p-0", "i-0-0", "p-319", "i-319-0"]);
expect(logicalRows).toHaveLength(960);
expect(renderedRows.length).toBeGreaterThan(0);
expect(renderedRows.length).toBeLessThan(80);
});
it("uses fixed download row geometry, overscan and pinned rows for the virtual window", () => {
const rows = [
{ id: "p-0", height: DOWNLOAD_PACKAGE_ROW_HEIGHT },
{ id: "i-0", height: DOWNLOAD_FILE_ROW_HEIGHT },
{ id: "p-1", height: DOWNLOAD_PACKAGE_ROW_HEIGHT },
{ id: "i-1", height: DOWNLOAD_FILE_ROW_HEIGHT },
{ id: "p-2", height: DOWNLOAD_PACKAGE_ROW_HEIGHT },
{ id: "i-2", height: DOWNLOAD_FILE_ROW_HEIGHT }
];
const window = calculateDownloadVirtualWindow(rows, {
scrollTop: 79,
viewportHeight: 40,
overscan: 1,
pinnedIds: ["p-0", "i-2"]
});
expect(DOWNLOAD_PACKAGE_ROW_HEIGHT).toBe(40);
expect(DOWNLOAD_FILE_ROW_HEIGHT).toBe(38);
expect(window.totalHeight).toBe(234);
expect(window.rows.map((row) => [row.id, row.index, row.top, row.height, row.pinned])).toEqual([
["p-0", 0, 0, 40, true],
["i-0", 1, 40, 38, false],
["p-1", 2, 78, 40, false],
["i-1", 3, 118, 38, false],
["p-2", 4, 156, 40, false],
["i-2", 5, 196, 38, true]
]);
});
});
describe("downloads view", () => {
it("pins the clipboard toggle separately at the bottom above the status metrics", () => {
const html = renderToStaticMarkup();
const actionsMarkup = html.match(/