import { readFileSync } from "node:fs";
import fs from "node:fs";
import path from "node:path";
import { Children, 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,
formatRemainingDownloadTooltip,
formatDownloadEta,
getDownloadQueueTotalBytes,
getDownloadQueueStatusMetrics,
getRemainingDownloadBytes,
getPendingDownloadItemCount,
getDownloadSpeedBps,
getPackageErrorItemIds,
isDownloadItemError,
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,
shouldAnimateDownloadOrderChange,
hasRemovedDownloadItems
} from "../src/renderer/views/downloads/download-order-transition";
import {
DownloadsContent,
DownloadsFooter,
DownloadsSidebar,
DownloadsSidebarStatus,
DownloadsToolbar,
DownloadsView,
type DownloadsViewActions,
type DownloadsViewModel
} from "../src/renderer/views/downloads/DownloadsView";
import * as downloadsViewModule from "../src/renderer/views/downloads/DownloadsView";
import {
DownloadsTableHeader,
ItemRowContent,
PackageCardContent,
areItemRowPropsEqual,
arePackageCardPropsEqual,
compactDownloadStatus,
downloadColumnDefinitions,
compareAvailabilitySummaries,
getAvailabilitySummary,
getPackageProgress,
getPackageSizeProgress
} from "../src/renderer/views/downloads/DownloadsTable";
import * as downloadsTableModule from "../src/renderer/views/downloads/DownloadsTable";
import {
DOWNLOAD_FILE_ROW_HEIGHT,
DOWNLOAD_PACKAGE_ROW_HEIGHT,
calculateDownloadVirtualWindow
} from "../src/renderer/views/downloads/download-virtualizer";
import { isDownloadItemVisuallySelected } from "../src/renderer/views/downloads/VirtualizedDownloadsBody";
import {
compactDownloadServiceLabel,
extractHoster,
formatHosterLabel,
normalizeDownloadServiceLabel
} from "../src/renderer/download-format";
import { getRollingMetricDirection, shouldAnimateRollingMetric } from "../src/renderer/ui/RollingMetricValue";
import { SlidingSelection } from "../src/renderer/ui/SlidingSelection";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
describe("Downloadtabellen-Spalten", () => {
it("derives grid tracks from the same column order that renders the cells", () => {
const downloadGridTemplateForOrder = (downloadsTableModule as unknown as {
downloadGridTemplateForOrder?: (order: readonly string[]) => string;
}).downloadGridTemplateForOrder;
expect(downloadGridTemplateForOrder).toBeTypeOf("function");
if (!downloadGridTemplateForOrder) return;
const order = ["size", "name"];
const expected = `36px ${downloadColumnDefinitions.size.width} ${downloadColumnDefinitions.name.width} 60px`;
const header = DownloadsTableHeader({
actions: createActions(),
columnOrder: order,
gridTemplate: `${downloadColumnDefinitions.name.width} ${downloadColumnDefinitions.size.width}`,
selectedCount: 0,
sortColumn: "name",
sortDirection: "asc",
visibleIds: []
});
expect(downloadGridTemplateForOrder(order)).toBe(expected);
expect(header.props.style.gridTemplateColumns).toBe(expected);
});
it("keeps the column layout reset out of the table action header", () => {
const actions = createActions();
const model = withRuntime(createInput());
const content = renderToStaticMarkup();
expect(content).not.toContain('aria-label="Spaltenlayout zurücksetzen"');
expect(content).toMatch(/class="downloads-action-cell" role="columnheader">Aktion<\/span>/);
});
it("never interpolates download grid tracks while column contents move", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-package-row,\s*\.downloads-item-row\s*\{[^}]*transition-property:\s*none;/s);
expect(css).toMatch(/\.downloads-table\.is-column-drag-motion-disabled\.is-column-drag-active\s+\[data-download-column\]\s*\{[^}]*transition:\s*none\s*!important;/s);
});
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("does not animate raw byte changes while the visible value stays unchanged", () => {
expect(shouldAnimateRollingMetric("1.111 TB", "1.111 TB")).toBe(false);
expect(shouldAnimateRollingMetric("1.111 TB", "1.110 TB")).toBe(true);
});
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("kennzeichnet fehlende Gesamtgrößen trotz geladener Untergrenze als unbekannt", () => {
const items = [
item("known", "package-a", "queued", { totalBytes: 4_000, downloadedBytes: 500 }),
item("fallback", "package-a", "queued", { totalBytes: null, downloadedBytes: 750 })
];
expect(getDownloadQueueTotalBytes(items)).toEqual({ bytes: 4_750, unknownItems: 1 });
});
it("zählt auch vollständig unbekannte Gesamtgrößen ohne falsche exakte Null", () => {
expect(getDownloadQueueTotalBytes([
item("unknown", "package-a", "queued", { totalBytes: null, downloadedBytes: 0 })
])).toEqual({ bytes: 0, unknownItems: 1 });
});
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 }));
});
it("uses downloaded bytes instead of equally weighting differently sized files", () => {
const completed = item("completed", "package-a", "completed", {
downloadedBytes: 2_000,
totalBytes: 2_000,
progressPercent: 100
});
const active = item("active", "package-a", "downloading", {
downloadedBytes: 250,
totalBytes: 1_000,
progressPercent: 25
});
const queued = item("queued", "package-a", "queued", {
downloadedBytes: 0,
totalBytes: 15_600,
progressPercent: 0
});
const packageEntry = pkg("package-a", "Serie", ["completed", "active", "queued"]);
const row = { package: packageEntry, items: [completed, active, queued], allItems: [completed, active, queued], collapsed: true };
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 2_250, total: 18_600, value: 12 });
expect(getPackageProgress(row)).toEqual(expect.objectContaining({ done: 1, total: 3, value: 12 }));
});
});
describe("Download-ETA", () => {
it("derives ETA from remaining bytes and current throughput before any file completes", () => {
expect(formatDownloadEta({ bytes: 600 * 1024 * 1024, unknownItems: 0 }, 10 * 1024 * 1024, true, false)).toBe("01:00");
});
it("keeps ETA unknown while paused, stopped, stalled, or missing file sizes", () => {
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 10, false, false)).toBe("--");
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 10, true, true)).toBe("--");
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 0, true, false)).toBe("--");
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 1 }, 10, true, false)).toBe("--");
});
});
describe("verbleibendes Downloadvolumen", () => {
it("shows queues of one terabyte or more as grouped gigabytes with one decimal per language", () => {
const before = Math.round(1.125 * 1024 ** 4);
const after = before - 120 * 1024 ** 2;
expect(formatRemainingDownloadBytes({ bytes: before, unknownItems: 0 }, "de")).toBe("1.152,0 GB");
expect(formatRemainingDownloadBytes({ bytes: after, unknownItems: 0 }, "de")).toBe("1.151,9 GB");
expect(formatRemainingDownloadBytes({ bytes: before, unknownItems: 0 }, "en")).toBe("1,152.0 GB");
expect(formatRemainingDownloadBytes({ bytes: Math.round(1449.5 * 1024 ** 3), unknownItems: 2 }, "de")).toBe("≥ 1.449,5 GB");
expect(formatRemainingDownloadBytes({ bytes: 998.5 * 1024 ** 3, unknownItems: 0 }, "de")).toBe("998.50 GB");
});
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");
expect(formatRemainingDownloadTooltip({ bytes: 750_000_000, unknownItems: 1 }))
.toBe("Noch unbekannte Dateigrößen: 1. Die tatsächliche Restmenge kann höher sein.");
expect(formatRemainingDownloadTooltip({ bytes: 0, unknownItems: 0 })).toBe("");
});
it("erklärt unbekannte Restgrößen direkt am verbleibenden Wert", () => {
const model = withRuntime(createInput());
model.status.remaining = "≥ 715.26 MB";
model.status.remainingTooltip = "Noch unbekannte Dateigrößen: 2. Die tatsächliche Restmenge kann höher sein.";
const html = renderToStaticMarkup();
expect(html).toContain('class="has-tooltip"');
expect(html).toContain('title="Noch unbekannte Dateigrößen: 2. Die tatsächliche Restmenge kann höher sein."');
expect(html).toContain('aria-describedby="downloads-status-remaining-tooltip"');
expect(html).toContain('class="downloads-visually-hidden"');
});
});
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("switches instantly instead of animating when a column sort changed the package order", () => {
expect(shouldAnimateDownloadOrderChange({ animationsEnabled: true, sortRevision: 3, appliedSortRevision: 3 })).toBe(true);
expect(shouldAnimateDownloadOrderChange({ animationsEnabled: true, sortRevision: 4, appliedSortRevision: 3 })).toBe(false);
expect(shouldAnimateDownloadOrderChange({ animationsEnabled: false, sortRevision: 3, appliedSortRevision: 3 })).toBe(false);
});
it("detects removed queue entries even when another entry is added in the same update", () => {
expect(hasRemovedDownloadItems({ first: {}, second: {} }, { second: {} })).toBe(true);
expect(hasRemovedDownloadItems({ first: {}, second: {} }, { second: {}, third: {} })).toBe(true);
expect(hasRemovedDownloadItems({ first: {} }, {})).toBe(true);
expect(hasRemovedDownloadItems({ first: {} }, { first: { status: "completed" }, second: {} })).toBe(false);
expect(hasRemovedDownloadItems(undefined, { first: {} })).toBe(false);
});
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", 0],
["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, 0],
["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 {
onResetColumnLayout: () => {},
onDisplayModeChange: () => {},
onFilterChange: () => {},
onProviderFilterChange: () => {},
onQueryChange: () => {},
onAddLinks: () => {},
onStartDownloads: () => {},
onPauseDownloads: () => {},
onStopDownloads: () => {},
onToggleSchedule: () => {},
onScheduleTimeChange: () => {},
onScheduleStartDayChange: () => {},
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 dispatchColumnSortPointerGesture(header: ReactElement, label: string, clientXs: readonly number[], deliverPointerClick: boolean): void {
const startX = clientXs[0];
const endX = clientXs[clientXs.length - 1];
if (startX === undefined || endX === undefined) throw new Error("Pointer gesture requires coordinates");
const columnHeader = findElement(header, (element) => element.props["data-download-column"] === "name");
const sortButton = findElement(columnHeader, (element) => element.type === "button" && String(element.props.children).startsWith(label));
const capturedPointers = new Set();
const currentTarget = {
closest: () => null,
hasPointerCapture: (pointerId: number) => capturedPointers.has(pointerId),
releasePointerCapture: (pointerId: number) => capturedPointers.delete(pointerId),
setPointerCapture: (pointerId: number) => capturedPointers.add(pointerId)
};
const target = { closest: (selector: string) => selector === ".downloads-column-sort" ? {} : null };
const event = (clientX: number) => ({
button: 0,
clientX,
currentTarget,
isPrimary: true,
pointerId: 7,
preventDefault: () => {},
target
});
columnHeader.props.onPointerDown(event(startX));
clientXs.slice(1, -1).forEach((clientX) => columnHeader.props.onPointerMove(event(clientX)));
columnHeader.props.onPointerUp(event(endX));
if (deliverPointerClick) sortButton.props.onClick({ detail: 1 });
}
function withRuntime(input: DownloadsModelInput, overrides: Partial = {}): DownloadsViewModel {
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",
scheduleTimeValid: true,
scheduleStartDay: "today",
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,
remainingTooltip: "",
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("selects only failed downloads and extraction errors for a package error reset", () => {
const packageEntry = pkg("package-a", "Reset", ["successful", "download-error", "extract-error"]);
const items = {
successful: item("successful", packageEntry.id, "completed", { fullStatus: "Entpackt" }),
"download-error": item("download-error", packageEntry.id, "failed", { fullStatus: "Fehlgeschlagen nach 3 Versuchen" }),
"extract-error": item("extract-error", packageEntry.id, "completed", { fullStatus: "Entpack-Fehler: CRCERROR" })
};
expect(isDownloadItemError(items.successful)).toBe(false);
expect(getPackageErrorItemIds([packageEntry.id], { [packageEntry.id]: packageEntry }, items)).toEqual([
"download-error",
"extract-error"
]);
});
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("uses package lifecycle status for counts and filters without counting children twice", () => {
const extractingPackage = { ...pkg("extracting-package", "Entpacken", ["extracting-a", "extracting-b"]), status: "extracting" } as PackageEntry;
const failedPackage = { ...pkg("failed-package", "Entpackfehler", ["extract-failed"]), status: "failed" } as PackageEntry;
const pausedPackage = { ...pkg("paused-package", "Pausiert", ["paused-item"]), status: "paused" } as PackageEntry;
const input = createInput({
packageOrder: [extractingPackage.id, failedPackage.id, pausedPackage.id],
packages: {
[extractingPackage.id]: extractingPackage,
[failedPackage.id]: failedPackage,
[pausedPackage.id]: pausedPackage
},
items: {
"extracting-a": item("extracting-a", extractingPackage.id, "completed"),
"extracting-b": item("extracting-b", extractingPackage.id, "completed"),
"extract-failed": item("extract-failed", failedPackage.id, "completed", { fullStatus: "Entpack-Fehler: CRC" }),
"paused-item": item("paused-item", pausedPackage.id, "queued")
}
});
const all = buildDownloadsViewModel(input);
const active = buildDownloadsViewModel({ ...input, filter: "active" });
const failed = buildDownloadsViewModel({ ...input, filter: "failed" });
const paused = buildDownloadsViewModel({ ...input, filter: "paused" });
expect(all.counts).toEqual({ all: 4, active: 2, queued: 0, paused: 1, completed: 0, failed: 1 });
expect(all.counts.active + all.counts.queued + all.counts.paused + all.counts.completed + all.counts.failed).toBe(all.counts.all);
expect(active.visibleItemIds).toEqual(["extracting-a", "extracting-b"]);
expect(failed.visibleItemIds).toEqual(["extract-failed"]);
expect(paused.visibleItemIds).toEqual(["paused-item"]);
});
it("preserves terminal children while unfinished children inherit failed or paused package state", () => {
const failedPackage = { ...pkg("mixed-failed", "Gemischter Fehler", ["failed-complete", "failed-pending"]), status: "failed" } as PackageEntry;
const pausedPackage = { ...pkg("mixed-paused", "Gemischt pausiert", ["paused-complete", "paused-pending"]), status: "paused" } as PackageEntry;
const input = createInput({
packageOrder: [failedPackage.id, pausedPackage.id],
packages: { [failedPackage.id]: failedPackage, [pausedPackage.id]: pausedPackage },
items: {
"failed-complete": item("failed-complete", failedPackage.id, "completed"),
"failed-pending": item("failed-pending", failedPackage.id, "queued"),
"paused-complete": item("paused-complete", pausedPackage.id, "completed"),
"paused-pending": item("paused-pending", pausedPackage.id, "queued")
}
});
const model = buildDownloadsViewModel(input);
expect(model.counts).toEqual({ all: 4, active: 0, queued: 0, paused: 1, completed: 2, failed: 1 });
expect(buildDownloadsViewModel({ ...input, filter: "completed" }).visibleItemIds).toEqual(["failed-complete", "paused-complete"]);
expect(buildDownloadsViewModel({ ...input, filter: "failed" }).visibleItemIds).toEqual(["failed-pending"]);
expect(buildDownloadsViewModel({ ...input, filter: "paused" }).visibleItemIds).toEqual(["paused-pending"]);
});
it("classifies Entpacken Error status variants as failures", () => {
const packageEntry = { ...pkg("extract-error-package", "Entpackfehler", ["extract-error-item"]), status: "queued" } as PackageEntry;
const input = createInput({
packageOrder: [packageEntry.id],
packages: { [packageEntry.id]: packageEntry },
items: {
"extract-error-item": item("extract-error-item", packageEntry.id, "completed", { fullStatus: "Entpacken - Error: CRC" })
}
});
const model = buildDownloadsViewModel(input);
expect(model.counts).toEqual({ all: 1, active: 0, queued: 0, paused: 0, completed: 0, failed: 1 });
expect(buildDownloadsViewModel({ ...input, filter: "failed" }).visibleItemIds).toEqual(["extract-error-item"]);
});
it("keeps cancelled, extracted, and queued siblings out of an active extraction bucket", () => {
const packageEntry = { ...pkg("extracting-mixed", "Gemischtes Entpacken", ["extracting-ready", "extracting-cancelled", "extracting-done", "extracting-queued"]), status: "extracting" } as PackageEntry;
const input = createInput({
packageOrder: [packageEntry.id],
packages: { [packageEntry.id]: packageEntry },
items: {
"extracting-ready": item("extracting-ready", packageEntry.id, "completed", { fullStatus: "Entpacken - 50%" }),
"extracting-cancelled": item("extracting-cancelled", packageEntry.id, "cancelled"),
"extracting-done": item("extracting-done", packageEntry.id, "completed", { fullStatus: "Entpackt" }),
"extracting-queued": item("extracting-queued", packageEntry.id, "queued")
}
});
expect(buildDownloadsViewModel(input).counts).toEqual({ all: 4, active: 1, queued: 1, paused: 0, completed: 1, failed: 0 });
expect(buildDownloadsViewModel({ ...input, filter: "active" }).visibleItemIds).toEqual(["extracting-ready"]);
expect(buildDownloadsViewModel({ ...input, filter: "queued" }).visibleItemIds).toEqual(["extracting-queued"]);
expect(buildDownloadsViewModel({ ...input, filter: "completed" }).visibleItemIds).toEqual(["extracting-done"]);
});
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("removes hidden extracted downloads from queue-wide visibility sources while preserving package aggregates", () => {
const model = buildDownloadsViewModel(createInput({
packageOrder: ["visible-package", "extracted-package"],
packages: {
"visible-package": pkg("visible-package", "Wartend", ["visible-item"]),
"extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"])
},
items: {
"visible-item": item("visible-item", "visible-package", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
"extracted-item": item("extracted-item", "extracted-package", "completed", { provider: "alldebrid", providerLabel: "AllDebrid", fullStatus: "Entpackt" })
},
hideExtractedItems: true
}));
expect(model.counts).toEqual({ all: 1, active: 0, queued: 1, paused: 0, completed: 0, failed: 0 });
expect(model.providerOptions).toEqual([{ id: "debridlink", label: "Debrid-Link" }]);
expect(model.eligibleItems.map((entry) => entry.id)).toEqual(["visible-item"]);
expect(model.eligiblePackageCount).toBe(1);
expect(model.packageRows[0].allItems.map((entry) => entry.id)).toEqual(["visible-item"]);
expect(model.empty).toBe(false);
expect(model.filteredEmpty).toBe(false);
});
it("keeps hidden extracted children in package progress, size, and total counts", () => {
const input = createInput({
packageOrder: ["mixed-package"],
packages: { "mixed-package": pkg("mixed-package", "Gemischtes Paket", ["extracted-item", "queued-item"]) },
items: {
"extracted-item": item("extracted-item", "mixed-package", "completed", { downloadedBytes: 100, totalBytes: 100, progressPercent: 100, fullStatus: "Entpackt" }),
"queued-item": item("queued-item", "mixed-package", "queued", { downloadedBytes: 0, totalBytes: 100, progressPercent: 0, fullStatus: "Wartet" })
},
hideExtractedItems: true
});
const model = buildDownloadsViewModel(input);
const row = model.packageRows[0];
expect(row.items.map((entry) => entry.id)).toEqual(["queued-item"]);
expect(row.allItems.map((entry) => entry.id)).toEqual(["extracted-item", "queued-item"]);
expect(getPackageProgress(row)).toEqual({ done: 1, failed: 0, offline: 0, cancelled: 0, total: 2, value: 50 });
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 100, total: 200, value: 50 });
});
it("resets a stale service filter when its last eligible provider disappears", () => {
const model = buildDownloadsViewModel(createInput({
packageOrder: ["visible-package", "extracted-package"],
packages: {
"visible-package": pkg("visible-package", "Wartend", ["visible-item"]),
"extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"])
},
items: {
"visible-item": item("visible-item", "visible-package", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
"extracted-item": item("extracted-item", "extracted-package", "completed", { provider: "alldebrid", providerLabel: "AllDebrid", fullStatus: "Entpackt" })
},
providerFilter: "alldebrid",
hideExtractedItems: true
}));
expect(model.providerFilter).toBe("all");
expect(model.visibleItemIds).toEqual(["visible-item"]);
expect(model.filteredEmpty).toBe(false);
});
it("keeps a hidden-only source queue clearable while presenting no visible entries", () => {
const input = createInput({
packageOrder: ["extracted-package"],
packages: { "extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"]) },
items: { "extracted-item": item("extracted-item", "extracted-package", "completed", { fullStatus: "Entpackt" }) },
hideExtractedItems: true
});
const model = buildDownloadsViewModel(input);
const runtimeModel = withRuntime(input);
const sidebar = renderToStaticMarkup();
const content = renderToStaticMarkup();
expect(model.eligibleItems).toEqual([]);
expect(model.eligiblePackageCount).toBe(0);
expect(model.counts.all).toBe(0);
expect(model.providerOptions).toEqual([]);
expect(model.sourceEmpty).toBe(false);
expect(model.presentationEmpty).toBe(true);
expect(model.empty).toBe(false);
expect(model.filteredEmpty).toBe(true);
expect(sidebar).not.toMatch(/