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, 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, 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, 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 a fourth TB decimal so fast downloads stay visibly responsive", () => { expect(formatRemainingDownloadBytes({ bytes: Math.round(1.125 * 1024 ** 4), unknownItems: 0 })).toBe("1.1250 TB"); }); 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("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("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, 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(/