2923 lines
141 KiB
TypeScript
2923 lines
141 KiB
TypeScript
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(<DownloadsContent actions={actions} model={model} />);
|
||
|
||
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(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
|
||
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> = {}): 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(<DownloadsSidebarStatus model={model} />);
|
||
|
||
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(<DownloadsContent actions={createActions()} model={withRuntime(createInput(), { animationsEnabled: false })} />);
|
||
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(<DownloadsSidebarStatus model={model} />);
|
||
|
||
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> = {}): 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> = {}): 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> = {}): 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<number>();
|
||
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> = {}): 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(<DownloadsSidebar actions={createActions()} model={runtimeModel} />);
|
||
const content = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={runtimeModel} />);
|
||
|
||
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(/<button disabled="" type="button">Liste leeren<\/button>/);
|
||
expect(content).toContain("Entpackte Downloads sind ausgeblendet");
|
||
expect(content).toContain("Entpackte Einträge ausblenden");
|
||
expect(content).not.toContain("Passe Filter oder Suche an");
|
||
expect(content).not.toContain("Noch keine Downloads");
|
||
expect(getDownloadQueueStatusMetrics(model.eligibleItems)).toEqual({
|
||
packageCount: 0,
|
||
pendingItemCount: 0,
|
||
total: { bytes: 0, unknownItems: 0 },
|
||
remaining: { bytes: 0, unknownItems: 0 },
|
||
hosterCount: 0
|
||
});
|
||
});
|
||
|
||
it("distinguishes a genuinely empty source queue from presentation filtering", () => {
|
||
const model = buildDownloadsViewModel(createInput({ packageOrder: [], packages: {}, items: {} }));
|
||
|
||
expect(model.sourceEmpty).toBe(true);
|
||
expect(model.presentationEmpty).toBe(true);
|
||
expect(model.empty).toBe(true);
|
||
expect(model.filteredEmpty).toBe(false);
|
||
});
|
||
|
||
it("supports the genuine flat file mode without synthetic package rows", () => {
|
||
const model = buildDownloadsViewModel(createInput({ displayMode: "files" }));
|
||
|
||
expect(model.packageRows).toEqual([]);
|
||
expect(model.fileRows.map((entry) => entry.id)).toEqual(["active", "queued", "failed", "done"]);
|
||
expect(model.mainRowCount).toBe(4);
|
||
});
|
||
|
||
it("reports a filtered flat-file range from the actually rendered rows", () => {
|
||
const model = buildDownloadsViewModel(createInput({ displayMode: "files", filter: "failed" }));
|
||
|
||
expect(model.paginationLabel).toBe("1\u20131 von 1");
|
||
expect(model.totalMainRowCount).toBe(1);
|
||
});
|
||
|
||
it("counts cancelled downloads only in the complete all queue", () => {
|
||
const model = buildDownloadsViewModel(createInput({
|
||
packageOrder: ["cancelled-package"],
|
||
packages: { "cancelled-package": pkg("cancelled-package", "Abgebrochen", ["cancelled-item"]) },
|
||
items: { "cancelled-item": item("cancelled-item", "cancelled-package", "cancelled") }
|
||
}));
|
||
|
||
expect(model.counts).toEqual({ all: 1, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 });
|
||
expect(buildDownloadsViewModel({ ...createInput(), filter: "completed", packageOrder: model.packageRows.map((row) => row.package.id), packages: { "cancelled-package": pkg("cancelled-package", "Abgebrochen", ["cancelled-item"]) }, items: { "cancelled-item": item("cancelled-item", "cancelled-package", "cancelled") } }).visibleItemIds).toEqual([]);
|
||
});
|
||
|
||
it("finds an account label without treating it as a provider id", () => {
|
||
const base = createInput();
|
||
const model = buildDownloadsViewModel({
|
||
...base,
|
||
items: {
|
||
...base.items,
|
||
active: { ...base.items.active, providerAccountLabel: "Privates Real-Debrid Konto" }
|
||
},
|
||
query: "privates real-debrid"
|
||
});
|
||
|
||
expect(model.visibleItemIds).toEqual(["active"]);
|
||
expect(model.providerFilter).toBe("all");
|
||
});
|
||
|
||
it("excludes collapsed children from visible and actionable row selection", () => {
|
||
const model = buildDownloadsViewModel(createInput({
|
||
collapsedPackageIds: ["package-a"],
|
||
selectedIds: ["package-a", "active", "queued"]
|
||
}));
|
||
|
||
expect(model.visibleRowIds).not.toContain("active");
|
||
expect(model.actionableSelectedIds).toEqual(["package-a"]);
|
||
});
|
||
|
||
it("visually inherits package selection without adding child ids to the action selection", () => {
|
||
const selectedIds = new Set(["package-a"]);
|
||
const child = item("active", "package-a", "downloading");
|
||
|
||
expect(isDownloadItemVisuallySelected(child, selectedIds, "packages")).toBe(true);
|
||
expect(isDownloadItemVisuallySelected(child, selectedIds, "files")).toBe(false);
|
||
expect([...selectedIds]).toEqual(["package-a"]);
|
||
});
|
||
|
||
it("keeps occupied package rows logical while preserving active packages and an actionable visible selection", () => {
|
||
const packageEntries = Array.from({ length: 264 }, (_, index) => pkg(`p-${index}`, `Paket ${index}`, [`i-${index}`]));
|
||
const itemEntries = packageEntries.map((entry, index) => item(`i-${index}`, entry.id, index === 263 ? "downloading" : "queued"));
|
||
const model = buildDownloadsViewModel(createInput({
|
||
packageOrder: packageEntries.map((entry) => entry.id),
|
||
packages: Object.fromEntries(packageEntries.map((entry) => [entry.id, entry])),
|
||
items: Object.fromEntries(itemEntries.map((entry) => [entry.id, entry])),
|
||
selectedIds: ["p-0", "i-0", "p-263", "i-263", "missing"],
|
||
renderLimit: 260
|
||
}));
|
||
|
||
expect(model.packageRows).toHaveLength(264);
|
||
expect(model.packageRows.some((row) => row.package.id === "p-263")).toBe(true);
|
||
expect(model.paginationLabel).toBe("1–264 von 264");
|
||
expect(model.actionableSelectedIds).toEqual(["p-0", "i-0", "p-263", "i-263"]);
|
||
});
|
||
|
||
it("keeps every filtered expanded row logical while the DOM window stays bounded", () => {
|
||
const model = buildDownloadsViewModel(createLargeInput(320, 2, {
|
||
selectedIds: ["p-0", "i-0-0", "p-319", "i-319-0"],
|
||
renderLimit: 20
|
||
}));
|
||
const logicalRows = buildDownloadLogicalRows(model);
|
||
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createLargeInput(320, 2, { renderLimit: 20 }))} />);
|
||
const renderedRows = html.match(/data-download-row-id=/g) ?? [];
|
||
|
||
expect(model.packageRows).toHaveLength(320);
|
||
expect(model.visibleRowIds).toHaveLength(960);
|
||
expect(model.visibleRowIds.slice(0, 3)).toEqual(["p-0", "i-0-0", "i-0-1"]);
|
||
expect(model.visibleRowIds.slice(-3)).toEqual(["p-319", "i-319-0", "i-319-1"]);
|
||
expect(model.actionableSelectedIds).toEqual(["p-0", "i-0-0", "p-319", "i-319-0"]);
|
||
expect(logicalRows).toHaveLength(960);
|
||
expect(renderedRows.length).toBeGreaterThan(0);
|
||
expect(renderedRows.length).toBeLessThan(80);
|
||
});
|
||
|
||
it("uses fixed download row geometry, overscan and pinned rows for the virtual window", () => {
|
||
const rows = [
|
||
{ id: "p-0", height: DOWNLOAD_PACKAGE_ROW_HEIGHT },
|
||
{ id: "i-0", height: DOWNLOAD_FILE_ROW_HEIGHT },
|
||
{ id: "p-1", height: DOWNLOAD_PACKAGE_ROW_HEIGHT },
|
||
{ id: "i-1", height: DOWNLOAD_FILE_ROW_HEIGHT },
|
||
{ id: "p-2", height: DOWNLOAD_PACKAGE_ROW_HEIGHT },
|
||
{ id: "i-2", height: DOWNLOAD_FILE_ROW_HEIGHT }
|
||
];
|
||
|
||
const window = calculateDownloadVirtualWindow(rows, {
|
||
scrollTop: 79,
|
||
viewportHeight: 40,
|
||
overscan: 1,
|
||
pinnedIds: ["p-0", "i-2"]
|
||
});
|
||
|
||
expect(DOWNLOAD_PACKAGE_ROW_HEIGHT).toBe(40);
|
||
expect(DOWNLOAD_FILE_ROW_HEIGHT).toBe(38);
|
||
expect(window.totalHeight).toBe(234);
|
||
expect(window.rows.map((row) => [row.id, row.index, row.top, row.height, row.pinned])).toEqual([
|
||
["p-0", 0, 0, 40, true],
|
||
["i-0", 1, 40, 38, false],
|
||
["p-1", 2, 78, 40, false],
|
||
["i-1", 3, 118, 38, false],
|
||
["p-2", 4, 156, 40, false],
|
||
["i-2", 5, 196, 38, true]
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("downloads view", () => {
|
||
it("pins the clipboard toggle separately at the bottom above the status metrics", () => {
|
||
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||
const actionsMarkup = html.match(/<div class="downloads-sidebar-actions">([\s\S]*?)<\/div>/)?.[1] ?? "";
|
||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||
|
||
expect(html).toContain('class="downloads-clipboard-toggle"');
|
||
expect(actionsMarkup).not.toContain("Zwischenablage überwachen");
|
||
expect(css).toMatch(/\.downloads-sidebar\s*\{[^}]*height:\s*100%;[^}]*padding:\s*14px 12px 6px;/s);
|
||
expect(css).toMatch(/\.downloads-clipboard-toggle\s*\{[^}]*margin-top:\s*auto;[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);/s);
|
||
});
|
||
|
||
it("shows the sidebar actions as permanently recognizable buttons", () => {
|
||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||
|
||
expect(css).toMatch(/\.downloads-sidebar-actions button\s*\{[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);[^}]*padding:\s*0 10px;/s);
|
||
});
|
||
|
||
it("marks the download filters for one measured vertical selection indicator", () => {
|
||
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||
|
||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(6);
|
||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||
expect(html.match(/aria-current="page"/g)).toHaveLength(1);
|
||
});
|
||
|
||
it("formats download filter counters as German integers", () => {
|
||
const model = withRuntime(createInput(), {
|
||
counts: { all: 3_180, active: 1_250, queued: 1_000, paused: 900, completed: 30, failed: 0 }
|
||
});
|
||
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={model} />);
|
||
|
||
for (const count of ["3.180", "1.250", "1.000", "900", "30", "0"]) {
|
||
expect(html).toContain(`<b>${count}</b>`);
|
||
}
|
||
expect(html).not.toContain("<b>3180</b>");
|
||
});
|
||
|
||
it("disables the service filter until more than one concrete service is available", () => {
|
||
const none = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [] })} />);
|
||
const one = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [{ id: "rapidgator", label: "RapidGator" }] })} />);
|
||
const multiple = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [{ id: "rapidgator", label: "RapidGator" }, { id: "ddownload", label: "DDownload" }] })} />);
|
||
|
||
expect(none).toMatch(/<select[^>]*aria-label="Service filtern"[^>]*disabled=""/);
|
||
expect(one).toMatch(/<select[^>]*aria-label="Service filtern"[^>]*disabled=""/);
|
||
expect(multiple).not.toMatch(/<select[^>]*aria-label="Service filtern"[^>]*disabled=""/);
|
||
});
|
||
|
||
it("starts with the six download filters in a compact neutral group without a package title", () => {
|
||
const sidebar = DownloadsSidebar({ actions: createActions(), model: withRuntime(createInput()) });
|
||
const children = Children.toArray(sidebar.props.children).filter(isValidElement);
|
||
const filterGroup = children[0] as ReactElement<{ className?: string }>;
|
||
const html = renderToStaticMarkup(sidebar);
|
||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||
|
||
expect(filterGroup.type).toBe(SlidingSelection);
|
||
expect(filterGroup.props.className).toBe("downloads-filter-group");
|
||
expect(html).not.toContain("downloads-mode-title");
|
||
expect(html).not.toContain(">Pakete<");
|
||
expect(html).not.toContain(">Dateien<");
|
||
expect(html).not.toContain("downloads-mode-switch");
|
||
expect(css).toMatch(/\.downloads-filter-group\s*\{[^}]*padding:\s*4px;[^}]*border:\s*1px solid var\(--ui-border\);[^}]*border-radius:\s*6px;[^}]*background:\s*var\(--ui-input\);/s);
|
||
expect(css).not.toContain(".downloads-mode-title");
|
||
});
|
||
|
||
it("places the global package disclosure action in a responsive right toolbar group", () => {
|
||
let toggles = 0;
|
||
const model = withRuntime(createInput({ filter: "paused" }));
|
||
const actions = createActions({ onToggleAllPackages: () => { toggles += 1; } });
|
||
const sidebar = renderToStaticMarkup(<DownloadsSidebar actions={actions} model={model} />);
|
||
const toolbar = DownloadsToolbar({ actions, model });
|
||
const toolbarButtons: ReactElement[] = [];
|
||
visitElements(toolbar, (element) => {
|
||
if (element.type === "button") toolbarButtons.push(element);
|
||
});
|
||
const tail = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-toolbar-tail"));
|
||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||
|
||
expect(sidebar).not.toContain("Alle ein-/ausklappen");
|
||
expect(tail.props.role).toBe("group");
|
||
expect(tail.props["aria-label"]).toBe("Paketdarstellung");
|
||
expect(toolbarButtons.at(-1)?.props.children).toBe("Alle ein-/ausklappen");
|
||
expect(css).toMatch(/\.downloads-toolbar\s*\{[^}]*flex-wrap:\s*wrap;[^}]*overflow-x:\s*hidden;/s);
|
||
expect(css).toMatch(/\.downloads-toolbar-tail\s*\{[^}]*flex:\s*1 0 auto;[^}]*justify-content:\s*flex-end;[^}]*margin-left:\s*auto;/s);
|
||
findButton(toolbar, "Alle ein-/ausklappen").props.onClick();
|
||
expect(toggles).toBe(1);
|
||
});
|
||
|
||
it("disables the global package disclosure action for a truly empty queue", () => {
|
||
const model = withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false });
|
||
const toolbar = DownloadsToolbar({ actions: createActions(), model });
|
||
|
||
expect(model.empty).toBe(true);
|
||
expect(findButton(toolbar, "Alle ein-/ausklappen").props.disabled).toBe(true);
|
||
});
|
||
|
||
it("disables the global package disclosure action when every source row is hidden", () => {
|
||
const model = withRuntime(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
|
||
}), { running: false });
|
||
|
||
expect(model.empty).toBe(false);
|
||
expect(model.presentationEmpty).toBe(true);
|
||
expect(findButton(DownloadsToolbar({ actions: createActions(), model }), "Alle ein-/ausklappen").props.disabled).toBe(true);
|
||
});
|
||
|
||
it("gates context start actions and reports blocked or rejected starts", async () => {
|
||
const DownloadContextStartActions = (downloadsViewModule as unknown as {
|
||
DownloadContextStartActions?: (props: {
|
||
actionBusy: boolean;
|
||
canStart: boolean;
|
||
onStartAll: () => void;
|
||
onStartSelected: () => void;
|
||
selectedLabel: string;
|
||
showSelected: boolean;
|
||
}) => ReactElement;
|
||
}).DownloadContextStartActions;
|
||
const runDownloadStartAction = (downloadsViewModule as unknown as {
|
||
runDownloadStartAction?: (
|
||
canStart: boolean,
|
||
action: () => Promise<void>,
|
||
onBlocked: () => void,
|
||
onError: (error: unknown) => void
|
||
) => Promise<boolean>;
|
||
}).runDownloadStartAction;
|
||
const resumeDownloadSession = (downloadsViewModule as unknown as {
|
||
resumeDownloadSession?: (
|
||
canStart: boolean,
|
||
togglePause: () => Promise<boolean>,
|
||
applyPaused: (paused: boolean) => void,
|
||
onBlocked: () => void,
|
||
onError: (error: unknown) => void
|
||
) => Promise<boolean>;
|
||
}).resumeDownloadSession;
|
||
const pauseDownloadSession = (downloadsViewModule as unknown as {
|
||
pauseDownloadSession?: (
|
||
togglePause: () => Promise<boolean>,
|
||
applyPaused: (paused: boolean) => void,
|
||
onError: (error: unknown) => void
|
||
) => Promise<boolean>;
|
||
}).pauseDownloadSession;
|
||
|
||
expect(DownloadContextStartActions).toBeTypeOf("function");
|
||
expect(runDownloadStartAction).toBeTypeOf("function");
|
||
expect(resumeDownloadSession).toBeTypeOf("function");
|
||
expect(pauseDownloadSession).toBeTypeOf("function");
|
||
if (!DownloadContextStartActions || !runDownloadStartAction || !resumeDownloadSession || !pauseDownloadSession) return;
|
||
|
||
let allStarts = 0;
|
||
let selectedStarts = 0;
|
||
const blocked = DownloadContextStartActions({
|
||
actionBusy: false,
|
||
canStart: false,
|
||
onStartAll: () => { allStarts += 1; },
|
||
onStartSelected: () => { selectedStarts += 1; },
|
||
selectedLabel: "Ausgewählte Downloads starten (2)",
|
||
showSelected: true
|
||
});
|
||
const blockedButtons: ReactElement[] = [];
|
||
visitElements(blocked, (element) => { if (element.type === "button") blockedButtons.push(element); });
|
||
|
||
expect(blockedButtons).toHaveLength(2);
|
||
expect(blockedButtons.every((button) => button.props.disabled === true)).toBe(true);
|
||
|
||
const busy = DownloadContextStartActions({
|
||
actionBusy: true,
|
||
canStart: true,
|
||
onStartAll: () => { allStarts += 1; },
|
||
onStartSelected: () => { selectedStarts += 1; },
|
||
selectedLabel: "Ausgewählte Downloads starten (2)",
|
||
showSelected: true
|
||
});
|
||
const busyButtons: ReactElement[] = [];
|
||
visitElements(busy, (element) => { if (element.type === "button") busyButtons.push(element); });
|
||
expect(busyButtons.every((button) => button.props.disabled === true)).toBe(true);
|
||
|
||
const blockedNotice = vi.fn();
|
||
const rejectedNotice = vi.fn();
|
||
const action = vi.fn(async () => { throw new Error("Kein aktiver Download-Account verfügbar"); });
|
||
|
||
await expect(runDownloadStartAction(false, action, blockedNotice, rejectedNotice)).resolves.toBe(false);
|
||
expect(action).not.toHaveBeenCalled();
|
||
expect(blockedNotice).toHaveBeenCalledTimes(1);
|
||
expect(rejectedNotice).not.toHaveBeenCalled();
|
||
|
||
await expect(runDownloadStartAction(true, action, blockedNotice, rejectedNotice)).resolves.toBe(false);
|
||
expect(rejectedNotice).toHaveBeenCalledWith(expect.objectContaining({ message: "Kein aktiver Download-Account verfügbar" }));
|
||
|
||
const applyPaused = vi.fn();
|
||
await expect(resumeDownloadSession(
|
||
true,
|
||
async () => { throw new Error("Account fehlt"); },
|
||
applyPaused,
|
||
blockedNotice,
|
||
rejectedNotice
|
||
)).resolves.toBe(false);
|
||
expect(applyPaused).not.toHaveBeenCalled();
|
||
|
||
await expect(resumeDownloadSession(true, async () => false, applyPaused, blockedNotice, rejectedNotice)).resolves.toBe(true);
|
||
expect(applyPaused).toHaveBeenCalledWith(false);
|
||
|
||
applyPaused.mockClear();
|
||
await expect(pauseDownloadSession(
|
||
async () => { throw new Error("Pause fehlgeschlagen"); },
|
||
applyPaused,
|
||
rejectedNotice
|
||
)).resolves.toBe(false);
|
||
expect(applyPaused).not.toHaveBeenCalled();
|
||
|
||
await expect(pauseDownloadSession(async () => true, applyPaused, rejectedNotice)).resolves.toBe(true);
|
||
expect(applyPaused).toHaveBeenCalledWith(true);
|
||
|
||
const enabled = DownloadContextStartActions({
|
||
actionBusy: false,
|
||
canStart: true,
|
||
onStartAll: () => { allStarts += 1; },
|
||
onStartSelected: () => { selectedStarts += 1; },
|
||
selectedLabel: "Ausgewählte Downloads starten (2)",
|
||
showSelected: true
|
||
});
|
||
const enabledButtons: ReactElement[] = [];
|
||
visitElements(enabled, (element) => { if (element.type === "button") enabledButtons.push(element); });
|
||
enabledButtons[0].props.onClick();
|
||
enabledButtons[1].props.onClick();
|
||
expect(selectedStarts).toBe(1);
|
||
expect(allStarts).toBe(1);
|
||
});
|
||
|
||
it("runs resume through the shared single-flight gate", async () => {
|
||
const runResumeDownloadAction = (downloadsViewModule as unknown as {
|
||
runResumeDownloadAction?: (
|
||
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
|
||
canStart: boolean,
|
||
togglePause: () => Promise<boolean>,
|
||
applyPaused: (paused: boolean) => void,
|
||
onBlocked: () => void,
|
||
onError: (error: unknown) => void
|
||
) => Promise<void>;
|
||
}).runResumeDownloadAction;
|
||
expect(runResumeDownloadAction).toBeTypeOf("function");
|
||
if (!runResumeDownloadAction) return;
|
||
|
||
let busy = false;
|
||
const runSingleFlight = async (action: () => Promise<unknown>): Promise<void> => {
|
||
if (busy) return;
|
||
busy = true;
|
||
try {
|
||
await action();
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
};
|
||
let release = (): void => {};
|
||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||
let paused = true;
|
||
let toggleCalls = 0;
|
||
const applied: boolean[] = [];
|
||
const togglePause = async (): Promise<boolean> => {
|
||
toggleCalls += 1;
|
||
await pending;
|
||
paused = !paused;
|
||
return paused;
|
||
};
|
||
|
||
const first = runResumeDownloadAction(runSingleFlight, true, togglePause, (value) => applied.push(value), () => {}, () => {});
|
||
const second = runResumeDownloadAction(runSingleFlight, true, togglePause, (value) => applied.push(value), () => {}, () => {});
|
||
|
||
expect(toggleCalls).toBe(1);
|
||
release();
|
||
await Promise.all([first, second]);
|
||
expect(paused).toBe(false);
|
||
expect(applied).toEqual([false]);
|
||
});
|
||
|
||
it("runs pause through the shared single-flight gate", async () => {
|
||
const runPauseDownloadAction = (downloadsViewModule as unknown as {
|
||
runPauseDownloadAction?: (
|
||
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
|
||
togglePause: () => Promise<boolean>,
|
||
applyPaused: (paused: boolean) => void,
|
||
onError: (error: unknown) => void
|
||
) => Promise<void>;
|
||
}).runPauseDownloadAction;
|
||
expect(runPauseDownloadAction).toBeTypeOf("function");
|
||
if (!runPauseDownloadAction) return;
|
||
|
||
let busy = false;
|
||
const runSingleFlight = async (action: () => Promise<unknown>): Promise<void> => {
|
||
if (busy) return;
|
||
busy = true;
|
||
try {
|
||
await action();
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
};
|
||
let release = (): void => {};
|
||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||
let paused = false;
|
||
let toggleCalls = 0;
|
||
const applied: boolean[] = [];
|
||
const togglePause = async (): Promise<boolean> => {
|
||
toggleCalls += 1;
|
||
await pending;
|
||
paused = !paused;
|
||
return paused;
|
||
};
|
||
|
||
const first = runPauseDownloadAction(runSingleFlight, togglePause, (value) => applied.push(value), () => {});
|
||
const second = runPauseDownloadAction(runSingleFlight, togglePause, (value) => applied.push(value), () => {});
|
||
|
||
expect(toggleCalls).toBe(1);
|
||
release();
|
||
await Promise.all([first, second]);
|
||
expect(paused).toBe(true);
|
||
expect(applied).toEqual([true]);
|
||
});
|
||
|
||
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
|
||
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
|
||
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
|
||
|
||
for (const marker of ["downloads-sidebar", "downloads-sidebar-status", "downloads-toolbar", "downloads-table-body", "downloads-pagination"]) {
|
||
expect(occupied.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
|
||
expect(empty.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
|
||
}
|
||
expect(occupied).not.toContain("data-visual-region=\"downloads-empty-state\"");
|
||
expect(empty).toContain("data-visual-region=\"downloads-empty-state\"");
|
||
expect(empty).toContain("F\u00fcge Links hinzu, um den ersten Download zu starten.");
|
||
});
|
||
|
||
it("renders the distinct filtered-empty guidance inside the table body", () => {
|
||
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ filter: "paused" }))} />);
|
||
|
||
expect(html).toContain("Keine passenden Downloads");
|
||
expect(html).toContain("Passe Filter oder Suche an.");
|
||
expect(html).not.toContain("data-visual-region=\"downloads-empty-state\"");
|
||
expect(html).toContain("0 von 0");
|
||
});
|
||
|
||
it("keeps sidebar, status, toolbar, table and footer as independently renderable production modules", () => {
|
||
const model = withRuntime(createInput());
|
||
const actions = createActions();
|
||
|
||
expect(renderToStaticMarkup(<DownloadsSidebar actions={actions} model={model} />)).toContain("downloads-sidebar");
|
||
expect(renderToStaticMarkup(<DownloadsSidebarStatus model={model} />)).toContain("downloads-sidebar-status");
|
||
expect(renderToStaticMarkup(<DownloadsToolbar actions={actions} model={model} />)).toContain("downloads-toolbar");
|
||
expect(renderToStaticMarkup(<DownloadsContent actions={actions} model={model} />)).toContain("downloads-table-body");
|
||
expect(renderToStaticMarkup(<DownloadsFooter actions={actions} model={model} />)).toContain("downloads-pagination");
|
||
});
|
||
|
||
it("keeps the compact download search in the sidebar instead of the action toolbar", () => {
|
||
const model = withRuntime(createInput());
|
||
const actions = createActions();
|
||
const sidebar = renderToStaticMarkup(<DownloadsSidebar actions={actions} model={model} />);
|
||
const toolbar = renderToStaticMarkup(<DownloadsToolbar actions={actions} model={model} />);
|
||
|
||
expect(sidebar).toContain("downloads-sidebar-search");
|
||
expect(sidebar).toContain("Paket, Datei oder Service");
|
||
expect(toolbar).not.toContain("downloads-search-input");
|
||
});
|
||
|
||
it("blocks native package dragging while preserving explicit reorder actions", () => {
|
||
const model = withRuntime(createInput());
|
||
const component = PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: model.columnOrder,
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: model.gridTemplate,
|
||
packageSpeedBps: 0,
|
||
row: model.packageRows[0],
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
});
|
||
const preventDefault = vi.fn();
|
||
|
||
expect(component.props.draggable).toBeUndefined();
|
||
component.props.onDragStart({ preventDefault });
|
||
expect(preventDefault).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("starts with the local Start action and dispatches toolbar actions separately", () => {
|
||
const calls: string[] = [];
|
||
const actions = createActions({
|
||
onStartDownloads: () => calls.push("start"),
|
||
onPauseDownloads: () => calls.push("pause"),
|
||
onStopDownloads: () => calls.push("stop"),
|
||
onActivateSchedule: () => calls.push("schedule"),
|
||
onMoveSelectionUp: () => calls.push("up"),
|
||
onMoveSelectionDown: () => calls.push("down"),
|
||
onRenameSelection: () => calls.push("rename"),
|
||
onRemoveSelection: () => calls.push("remove")
|
||
});
|
||
const toolbar = DownloadsToolbar({
|
||
actions,
|
||
model: withRuntime(createInput({ selectedIds: ["active"] }), { scheduleOpen: true })
|
||
});
|
||
|
||
for (const label of ["Start", "Pause", "Stop", "Planen", "Nach oben", "Nach unten", "Umbenennen", "Entfernen"]) {
|
||
findButton(toolbar, label).props.onClick();
|
||
}
|
||
|
||
const html = renderToStaticMarkup(toolbar);
|
||
expect(html).not.toContain("Links hinzufügen");
|
||
expect(html.indexOf(">Start<")).toBeLessThan(html.indexOf(">Pause<"));
|
||
expect(calls).toEqual(["start", "pause", "stop", "schedule", "up", "down", "rename", "remove"]);
|
||
});
|
||
|
||
it("uses exact toolbar disabled semantics without a dead reconnect branch", () => {
|
||
const toolbar = DownloadsToolbar({
|
||
actions: createActions(),
|
||
model: withRuntime(createInput(), { canStart: false, canPause: false, canStop: false, reconnectSeconds: 8 })
|
||
});
|
||
|
||
expect(findButton(toolbar, "Start").props.disabled).toBe(true);
|
||
expect(findButton(toolbar, "Pause").props.disabled).toBe(true);
|
||
expect(findButton(toolbar, "Stop").props.disabled).toBe(true);
|
||
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
|
||
});
|
||
|
||
it("blocks resume without a usable account and pause while another action is busy", () => {
|
||
const pausedToolbar = DownloadsToolbar({
|
||
actions: createActions(),
|
||
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
|
||
});
|
||
const busyToolbar = DownloadsToolbar({
|
||
actions: createActions(),
|
||
model: withRuntime(createInput(), { paused: false, canPause: true, actionBusy: true })
|
||
});
|
||
|
||
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(true);
|
||
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
|
||
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(true);
|
||
});
|
||
|
||
it("enables package movement only for a visible selected package row", () => {
|
||
const itemOnly = DownloadsToolbar({
|
||
actions: createActions(),
|
||
model: withRuntime(createInput({ selectedIds: ["active"] }))
|
||
});
|
||
const packageSelected = DownloadsToolbar({
|
||
actions: createActions(),
|
||
model: withRuntime(createInput({ selectedIds: ["package-a"] }))
|
||
});
|
||
|
||
expect(findButton(itemOnly, "Nach oben").props.disabled).toBe(true);
|
||
expect(findButton(itemOnly, "Nach unten").props.disabled).toBe(true);
|
||
expect(findButton(packageSelected, "Nach oben").props.disabled).toBe(false);
|
||
expect(findButton(packageSelected, "Nach unten").props.disabled).toBe(false);
|
||
});
|
||
|
||
it("keeps an active schedule visible and cancellable while the picker is closed", () => {
|
||
const toolbar = DownloadsToolbar({
|
||
actions: createActions(),
|
||
model: withRuntime(createInput(), { scheduleActive: true, scheduleOpen: false, scheduleLabel: "1m 30s" })
|
||
});
|
||
const html = renderToStaticMarkup(toolbar);
|
||
|
||
expect(html).toContain("Geplant: 1m 30s");
|
||
expect(findButton(toolbar, "Abbrechen").props.disabled).toBe(false);
|
||
});
|
||
|
||
it("keeps closed schedule controls mounted, hidden and unreachable", () => {
|
||
const toolbar = DownloadsToolbar({
|
||
actions: createActions(),
|
||
model: withRuntime(createInput(), { scheduleActive: false, scheduleOpen: false })
|
||
});
|
||
const slot = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-slot"));
|
||
const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls"));
|
||
const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit");
|
||
const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag");
|
||
|
||
expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(false);
|
||
expect(slot.props.className).toContain("is-closed");
|
||
expect(controls.props["aria-hidden"]).toBe(true);
|
||
expect(controls.props.inert).toBe("true");
|
||
expect(renderToStaticMarkup(toolbar)).toContain('inert="true"');
|
||
expect(timeInput.props.disabled).toBe(true);
|
||
expect(daySelect.props.disabled).toBe(true);
|
||
expect(findButton(toolbar, "Planen").props.disabled).toBe(true);
|
||
});
|
||
|
||
it("collects local time and the selected first start day from the open schedule slot", () => {
|
||
const calls: string[] = [];
|
||
const toolbar = DownloadsToolbar({
|
||
actions: createActions({
|
||
onScheduleTimeChange: (value) => calls.push(`time:${value}`),
|
||
onScheduleStartDayChange: (value) => calls.push(`day:${value}`)
|
||
}),
|
||
model: withRuntime(createInput(), { scheduleOpen: true, scheduleStartDay: "tomorrow", scheduleTime: "08:15" })
|
||
});
|
||
const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls"));
|
||
const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit");
|
||
const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag");
|
||
|
||
timeInput.props.onChange({ target: { value: "09:45" } });
|
||
daySelect.props.onChange({ target: { value: "today" } });
|
||
|
||
expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(true);
|
||
expect(controls.props["aria-hidden"]).toBe(false);
|
||
expect(controls.props.inert).toBeUndefined();
|
||
expect(timeInput.props.value).toBe("08:15");
|
||
expect(daySelect.props.value).toBe("tomorrow");
|
||
expect(renderToStaticMarkup(toolbar)).toContain("Ab heute");
|
||
expect(renderToStaticMarkup(toolbar)).toContain("Ab morgen");
|
||
expect(calls).toEqual(["time:09:45", "day:today"]);
|
||
});
|
||
|
||
it.each(["", "8:15", "24:00", "12:60"])("disables schedule activation for the invalid time %j", (scheduleTime) => {
|
||
const toolbar = DownloadsToolbar({
|
||
actions: createActions(),
|
||
model: withRuntime(createInput(), { scheduleOpen: true, scheduleTime, scheduleTimeValid: false })
|
||
});
|
||
|
||
expect(findButton(toolbar, "Planen").props.disabled).toBe(true);
|
||
});
|
||
|
||
it("animates the persistent schedule slot horizontally and disables it through the global motion setting", () => {
|
||
const closed = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput())} />);
|
||
const open = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { scheduleOpen: true })} />);
|
||
const motionDisabled = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { animationsEnabled: false, scheduleOpen: true })} />);
|
||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||
|
||
expect(closed).toContain("downloads-schedule-slot is-closed");
|
||
expect(open).toContain("downloads-schedule-slot is-open");
|
||
expect(motionDisabled).toContain("downloads-schedule-slot is-open is-motion-disabled");
|
||
expect(css).toMatch(/\.downloads-schedule-slot\s*\{[^}]*grid-template-columns:\s*0fr;[^}]*opacity:\s*0;[^}]*transition:/s);
|
||
expect(css).toMatch(/\.downloads-schedule-slot\.is-open\s*\{[^}]*grid-template-columns:\s*1fr;[^}]*opacity:\s*1;/s);
|
||
expect(css).toMatch(/\.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(-\d+px\);[^}]*transition:\s*transform/s);
|
||
expect(css).toMatch(/\.downloads-schedule-slot\.is-open \.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(0\);/s);
|
||
expect(css).toMatch(/\.downloads-schedule-slot\.is-motion-disabled[^\{]*\{[^}]*transition:\s*none !important;/s);
|
||
});
|
||
|
||
it("keeps the table header and all rows in one horizontal scroll context with exact dense geometry", () => {
|
||
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
|
||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||
|
||
expect(html.indexOf("downloads-table-header")).toBeGreaterThan(html.indexOf("downloads-table"));
|
||
expect(html.indexOf("data-visual-region=\"downloads-table-body\"")).toBeGreaterThan(html.indexOf("downloads-table-header"));
|
||
expect(html).toContain('class="downloads-package-card ');
|
||
expect(html).not.toContain('class="package-card');
|
||
expect(html).toContain('class="downloads-hoster-icon" data-hoster="rapidgator" src="data:image/x-icon;base64,');
|
||
expect(html).toContain('class="downloads-hoster-icon" data-hoster="ddownload" src="./provider-icons/ddownload.ico"');
|
||
expect(html).not.toContain('title="RapidGator">RG<');
|
||
expect(html).toContain('data-download-column="name"');
|
||
expect(html).toContain('data-download-column="hoster"');
|
||
expect(html).toMatch(/grid-template-columns:[^"]+ 60px/);
|
||
expect(html).toContain('class="downloads-virtual-spacer"');
|
||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s);
|
||
expect(css).not.toMatch(/\.downloads-table-body\s*\{[^}]*overflow(?:-y)?:\s*auto;/s);
|
||
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*var\(--downloads-table-min-width, 1191px\);/s);
|
||
expect(css).not.toMatch(/min-width:\s*max-content;/);
|
||
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
|
||
expect(css).toMatch(/--downloads-package-row-height:\s*40px;/s);
|
||
expect(css).toMatch(/--downloads-file-row-height:\s*38px;/s);
|
||
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*var\(--downloads-package-row-height\);/s);
|
||
expect(css).toMatch(/\.downloads-item-row\s*\{[^}]*height:\s*var\(--downloads-file-row-height\);/s);
|
||
expect(css).toMatch(/\.downloads-virtual-spacer\s*\{[^}]*position:\s*relative;[^}]*height:\s*var\(--downloads-virtual-total-height\);/s);
|
||
expect(css).toMatch(/\.downloads-virtual-row\s*\{[^}]*position:\s*absolute;[^}]*transform:\s*translateY\(var\(--downloads-virtual-row-top\)\);/s);
|
||
expect(css).toMatch(/\.downloads-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s);
|
||
expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/s);
|
||
expect(css).toMatch(/\.downloads-collapse-button\s*\{[^}]*box-sizing:\s*border-box;[^}]*flex:\s*0 0 30px;[^}]*width:\s*30px;[^}]*min-width:\s*30px;[^}]*max-width:\s*30px;/s);
|
||
expect(css).toMatch(/\.downloads-cell-slot\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*center;/s);
|
||
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*\.downloads-cell\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
||
expect(css).toMatch(/\.downloads-column-header\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
||
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
||
expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*color:\s*var\(--ui-progress-track-text/s);
|
||
expect(css).toMatch(/\.downloads-meter-label\s*\{[^}]*font-weight:\s*700;/s);
|
||
expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*clip-path:\s*inset\(0 0 0 var\(--downloads-progress\)\);/s);
|
||
expect(css).toMatch(/\.downloads-meter-label\.is-filled\s*\{[^}]*color:\s*var\(--ui-progress-fill-text/s);
|
||
expect(css).toMatch(/\.downloads-link-state\.online\s*\{[^}]*background:\s*var\(--ui-success\);/s);
|
||
expect(css).toMatch(/\.downloads-status-cell\s*\{[^}]*container-type:\s*inline-size;/s);
|
||
expect(css).toMatch(/\.downloads-service-cell\s*\{[^}]*container-type:\s*inline-size;/s);
|
||
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
||
expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s);
|
||
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*block;/s);
|
||
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*block;/s);
|
||
expect(css).toMatch(/\.downloads-virtual-spacer\s*\{[^}]*transition:\s*height 1500ms cubic-bezier\(0\.22, 1, 0\.36, 1\);/s);
|
||
expect(css).toMatch(/\.downloads-virtual-row\s*\{[^}]*transition:\s*height 1500ms[^;]*transform 1500ms[^;]*opacity 1500ms/s);
|
||
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
|
||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*box-shadow:\s*none;/s);
|
||
expect(css).not.toMatch(/gradient|nth-child/i);
|
||
});
|
||
|
||
it("keeps visible interaction text non-selectable and only inputs plus copy values selectable", () => {
|
||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||
|
||
expect(css).toMatch(/\.downloads-sidebar,\s*\.downloads-sidebar-status,\s*\.downloads-toolbar,\s*\.downloads-content,\s*\.downloads-footer\s*\{[^}]*user-select:\s*none;/s);
|
||
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
|
||
expect(css).toMatch(/\.downloads-name-cell\s+\.downloads-rename-input\s*\{[^}]*flex:\s*1 1 auto;[^}]*width:\s*100%;[^}]*min-width:\s*0;/s);
|
||
});
|
||
|
||
it("marks selected rows clearly, enlarges selection checkboxes and slows package disclosure", () => {
|
||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||
const source = readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8");
|
||
|
||
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*border-left:\s*3px solid transparent;/s);
|
||
expect(css).toMatch(/\.downloads-item-row\.is-selected,\s*\.downloads-package-card\.is-selected\s*>\s*\.downloads-package-row\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--ui-success\) 14%, var\(--ui-canvas\)\);[^}]*border-left-color:\s*var\(--ui-success\);/s);
|
||
expect(css).toMatch(/\.downloads-item-row\.is-selected::before\s*\{[^}]*content:\s*"";[^}]*position:\s*absolute;[^}]*inset:\s*-1px auto -1px -3px;[^}]*width:\s*3px;[^}]*background:\s*var\(--ui-success\);/s);
|
||
expect(css).toMatch(/\.downloads-package-card\.is-selected::before\s*\{[^}]*content:\s*"";[^}]*position:\s*absolute;[^}]*inset:\s*0 auto auto 0;[^}]*width:\s*3px;[^}]*height:\s*41px;[^}]*background:\s*var\(--ui-success\);/s);
|
||
expect(css).toMatch(/\.downloads-package-card:has\(\.downloads-package-items-inner\s*>\s*\.downloads-item-row:last-child\.is-selected\)::after\s*\{[^}]*content:\s*"";[^}]*position:\s*absolute;[^}]*inset:\s*auto auto -1px 0;[^}]*width:\s*3px;[^}]*height:\s*1px;[^}]*background:\s*var\(--ui-success\);/s);
|
||
expect(css).toMatch(/\.downloads-selection-cell\s+input\[type="checkbox"\]\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;/s);
|
||
expect(css).toMatch(/\.downloads-hoster-icon\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;[^}]*object-fit:\s*contain;/s);
|
||
expect(css).toMatch(/\.downloads-hoster-icon\[data-hoster="rapidgator"\]\s*\{[^}]*transform:\s*translateY\(-4px\) scale\(2\);/s);
|
||
expect(source).not.toContain("PackageItemsTransition");
|
||
});
|
||
|
||
it("keeps the action column visible at 1366px and 1120px through the production wrapper contract", () => {
|
||
const html = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={withRuntime(createInput())} />);
|
||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||
|
||
expect(html).toMatch(/^<main class="downloads-content">/);
|
||
expect(css).toMatch(/\.md-shell\.is-compact \.downloads-content,\s*\.md-shell\.is-minimum \.downloads-content\s*\{[^}]*--downloads-table-min-width:\s*1016px;[^}]*--downloads-name-min:\s*180px;[^}]*--downloads-status-min:\s*90px;/s);
|
||
expect(css).not.toMatch(/\.md-shell\.is-(?:compact|minimum) \.downloads-view/);
|
||
expect(css).toMatch(/\.downloads-content\s*\{[^}]*overflow:\s*hidden;/s);
|
||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*overflow-y:\s*auto;/s);
|
||
});
|
||
|
||
it("uses only semantic color variables declared by the shared theme", () => {
|
||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
|
||
const usedVariables = [...css.matchAll(/var\((--ui-[a-z-]+)/g)].map((match) => match[1]);
|
||
const declaredVariables = new Set([...theme.matchAll(/(--ui-[a-z-]+)\s*:/g)].map((match) => match[1]));
|
||
|
||
expect([...new Set(usedVariables)].filter((name) => !declaredVariables.has(name))).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("downloads App integration", () => {
|
||
it("keeps search input immediate while deferred text drives expensive filtering", () => {
|
||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||
|
||
expect(source).toMatch(/buildDownloadsViewModel\(\{[\s\S]*?query: deferredDownloadSearch,/);
|
||
expect(source).toMatch(/const downloadsViewModel = useMemo<DownloadsViewModel>\(\(\) => \(\{\n\s*\.\.\.downloadsViewCore,\n\s*query: downloadSearch,/);
|
||
});
|
||
|
||
it("commits a normalized provider filter back to renderer state", () => {
|
||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||
|
||
expect(source).toMatch(/if \(downloadsTabActive && downloadProviderFilter !== downloadsViewCore\.providerFilter\) \{\n\s*setDownloadProviderFilter\(downloadsViewCore\.providerFilter\);/);
|
||
expect(source).toContain("[downloadProviderFilter, downloadsTabActive, downloadsViewCore.providerFilter]");
|
||
});
|
||
|
||
it("keeps the priority menu open when the selected priority is already active", () => {
|
||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||
|
||
expect(source).toMatch(/disabled=\{allMatch\}[\s\S]{0,260}window\.rd\.setPackagePriority/);
|
||
});
|
||
|
||
it("keeps previously visible package rows mounted while priority order glides", () => {
|
||
const source = readFileSync(new URL("../src/renderer/views/downloads/VirtualizedDownloadsBody.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||
|
||
expect(source).toContain("getDownloadOrderTransitionPinnedIds");
|
||
expect(source).toContain("animateDownloadOrderRows");
|
||
expect(source).toContain("orderTransitionPinnedIdsRef.current");
|
||
expect(source).toContain("data-download-row-id={entry.id}");
|
||
expect(source).toContain("DOWNLOAD_ORDER_TRANSITION_DURATION_MS");
|
||
});
|
||
|
||
it("updates the clipboard checkbox optimistically before IPC reconciliation", () => {
|
||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||
|
||
expect(source).toMatch(/const toggleClipboardWatcher = useCallback\(\(\): void => \{[\s\S]*setClipboardWatcherActive\(next\);[\s\S]*window\.rd\.toggleClipboard\(\)/);
|
||
expect(source).toContain("onToggleClipboardWatcher: toggleClipboardWatcher");
|
||
expect(source).not.toContain("onToggleClipboardWatcher: () => { void performQuickAction(() => window.rd.toggleClipboard()); }");
|
||
});
|
||
|
||
it("uses the extracted table only, cleans temporary drag listeners and routes the global context start through the shared callback", () => {
|
||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||
|
||
expect(source).not.toMatch(/^const ItemRow\b|^const PackageCard\b|^interface ItemRowProps\b|^interface PackageCardProps\b/m);
|
||
expect(source).toContain('window.removeEventListener("mouseup", dragMouseUpRef.current)');
|
||
expect(source).toContain("downloadsActions.onStartDownloads(); setContextMenu(null);");
|
||
expect(source).not.toContain(") : false ? (");
|
||
expect(source).not.toContain("{false && (");
|
||
});
|
||
|
||
it("pins the inline rename row outside the initial viewport without rendering the whole queue", () => {
|
||
const model = withRuntime(createLargeInput(340, 1), {
|
||
editingPackageId: "p-339",
|
||
editingName: "Pinned Rename"
|
||
});
|
||
const html = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={model} />);
|
||
const renderedRows = html.match(/data-download-row-id=/g) ?? [];
|
||
|
||
expect(html).toContain('data-download-row-id="p-339"');
|
||
expect(html).toContain('class="downloads-rename-input"');
|
||
expect(renderedRows.length).toBeGreaterThan(0);
|
||
expect(renderedRows.length).toBeLessThan(80);
|
||
});
|
||
});
|
||
|
||
describe("download table row contracts", () => {
|
||
it("summarizes full, partial, offline and unchecked package availability", () => {
|
||
expect(getAvailabilitySummary([
|
||
item("online-a", "package-a", "queued", { onlineStatus: "online" }),
|
||
item("online-b", "package-a", "queued", { onlineStatus: "online" })
|
||
])).toEqual({ online: 2, offline: 0, total: 2, state: "online" });
|
||
expect(getAvailabilitySummary([
|
||
item("partial-a", "package-a", "queued", { onlineStatus: "online" }),
|
||
item("partial-b", "package-a", "queued", { onlineStatus: "offline" })
|
||
])).toEqual({ online: 1, offline: 1, total: 2, state: "partial" });
|
||
expect(getAvailabilitySummary([
|
||
item("offline-a", "package-a", "queued", { onlineStatus: "offline" }),
|
||
item("offline-b", "package-a", "queued", { onlineStatus: "offline" })
|
||
])).toEqual({ online: 0, offline: 2, total: 2, state: "offline" });
|
||
expect(getAvailabilitySummary([
|
||
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
|
||
])).toEqual({ online: 0, offline: 0, total: 1, state: "checking" });
|
||
expect(getAvailabilitySummary([
|
||
item("active-a", "package-a", "downloading", { onlineStatus: undefined })
|
||
])).toEqual({ online: 1, offline: 0, total: 1, state: "online" });
|
||
});
|
||
|
||
it("treats a package with online and offline links as partial even while other links are still unchecked", () => {
|
||
expect(getAvailabilitySummary([
|
||
item("mixed-online", "package-a", "queued", { onlineStatus: "online" }),
|
||
item("mixed-offline", "package-a", "queued", { onlineStatus: "offline" }),
|
||
item("mixed-checking", "package-a", "queued", { onlineStatus: "checking" })
|
||
])).toEqual({ online: 1, offline: 1, total: 3, state: "partial" });
|
||
expect(getAvailabilitySummary([
|
||
item("pending-online", "package-a", "queued", { onlineStatus: "online" }),
|
||
item("pending-unknown", "package-a", "queued", { onlineStatus: undefined })
|
||
])).toEqual({ online: 1, offline: 0, total: 2, state: "checking" });
|
||
});
|
||
|
||
it("renders partially available packages with the warning tone", () => {
|
||
const items = [
|
||
item("partial-online-a", "package-a", "queued", { onlineStatus: "online" }),
|
||
item("partial-online-b", "package-a", "queued", { onlineStatus: "online" }),
|
||
item("partial-offline", "package-a", "queued", { onlineStatus: "offline" })
|
||
];
|
||
const html = renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["availability"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "110px",
|
||
packageSpeedBps: 0,
|
||
row: { package: pkg("package-a", "Paket", items.map((entry) => entry.id)), items, allItems: items, collapsed: true },
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
|
||
expect(html).toContain("downloads-availability has-counts is-partial");
|
||
expect(html).toContain("2/3 online");
|
||
});
|
||
|
||
it("keeps offline links out of the package status error count", () => {
|
||
const renderStatus = (items: DownloadItem[]): string => renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "210px",
|
||
packageSpeedBps: 0,
|
||
row: { package: pkg("package-a", "Paket", items.map((entry) => entry.id)), items, allItems: items, collapsed: true },
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
const offlineItems = [
|
||
item("offline-a", "package-a", "failed", { onlineStatus: "offline", fullStatus: "Offline" }),
|
||
item("offline-b", "package-a", "failed", { onlineStatus: "offline", fullStatus: "Offline" }),
|
||
item("queued-c", "package-a", "queued", { onlineStatus: "online" })
|
||
];
|
||
const failedItems = [
|
||
item("failed-a", "package-a", "failed", { fullStatus: "Fehler: Provider" }),
|
||
item("offline-b", "package-a", "failed", { onlineStatus: "offline", fullStatus: "Offline" })
|
||
];
|
||
|
||
expect(getPackageProgress({ package: pkg("package-a", "Paket", offlineItems.map((entry) => entry.id)), items: offlineItems, allItems: offlineItems, collapsed: true }))
|
||
.toEqual(expect.objectContaining({ done: 0, failed: 0, offline: 2, total: 3 }));
|
||
expect(renderStatus(offlineItems)).not.toContain("Fehler");
|
||
expect(renderStatus(offlineItems)).toContain("0/3");
|
||
expect(renderStatus(failedItems)).toContain("1 Fehler");
|
||
});
|
||
|
||
it("orders package availability from fully online through partial and unchecked down to fully offline", () => {
|
||
const summary = (online: number, offline: number, total: number) => ({ online, offline, total, state: "checking" as const });
|
||
const ordered = [
|
||
summary(0, 5, 5),
|
||
summary(6, 1, 7),
|
||
summary(0, 0, 4),
|
||
summary(25, 1, 26),
|
||
summary(0, 2, 6),
|
||
summary(3, 3, 3)
|
||
].sort(compareAvailabilitySummaries);
|
||
|
||
expect(ordered.map((entry) => `${entry.online}/${entry.total}:${entry.offline}`)).toEqual(["3/3:3", "25/26:1", "6/7:1", "0/4:0", "0/6:2", "0/5:5"]);
|
||
});
|
||
|
||
it("exposes Verfügbarkeit as a sortable column header", () => {
|
||
const sorted: string[] = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
|
||
columnOrder: ["availability"],
|
||
gridTemplate: "100px",
|
||
selectedCount: 0,
|
||
sortColumn: "availability",
|
||
sortDirection: "asc",
|
||
visibleIds: []
|
||
});
|
||
const availabilityHeader = findElement(header, (element) => element.props["data-download-column"] === "availability");
|
||
const sortButton = findElement(availabilityHeader, (element) => element.type === "button" && String(element.props.children).startsWith("Verfügbarkeit"));
|
||
|
||
sortButton.props.onClick({ detail: 0 });
|
||
|
||
expect(availabilityHeader.props["aria-sort"]).toBe("ascending");
|
||
expect(sorted).toEqual(["availability"]);
|
||
});
|
||
|
||
it("shows an actively downloading item as online even without a stored availability result", () => {
|
||
const html = renderToStaticMarkup(ItemRowContent({
|
||
actions: createActions(),
|
||
columnOrder: ["name", "availability"],
|
||
gridTemplate: "200px 150px",
|
||
item: item("active-availability", "package-a", "downloading", { onlineStatus: undefined }),
|
||
selected: false
|
||
}));
|
||
|
||
expect(html).toContain(">Online</span>");
|
||
expect(html).not.toContain(">Ungeprüft</span>");
|
||
expect(html).toContain('class="downloads-link-state online"');
|
||
});
|
||
|
||
it("shows reset package availability as one compact unchecked label", () => {
|
||
const resetItems = [
|
||
item("reset-a", "package-a", "queued", { onlineStatus: undefined }),
|
||
item("reset-b", "package-a", "queued", { onlineStatus: undefined }),
|
||
item("reset-c", "package-a", "queued", { onlineStatus: undefined })
|
||
];
|
||
const html = renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["availability"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "150px",
|
||
packageSpeedBps: 0,
|
||
row: { package: pkg("package-a", "Reset", resetItems.map((entry) => entry.id)), items: resetItems, allItems: resetItems, collapsed: true },
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
|
||
expect(html).toContain(">Ungeprüft</span>");
|
||
expect(html).not.toContain(">0</span>");
|
||
expect(html).not.toContain(">3</span>");
|
||
expect(html).not.toContain(">online</span>");
|
||
});
|
||
|
||
it("renders availability for package and file rows", () => {
|
||
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
|
||
const packageHtml = renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["availability"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "110px",
|
||
packageSpeedBps: 0,
|
||
row: { package: pkg("package-a", "Paket", [onlineItem.id]), items: [onlineItem], allItems: [onlineItem], collapsed: true },
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
const itemHtml = renderToStaticMarkup(ItemRowContent({
|
||
actions: createActions(),
|
||
columnOrder: ["availability"],
|
||
gridTemplate: "110px",
|
||
item: onlineItem,
|
||
selected: false
|
||
}));
|
||
|
||
expect(packageHtml).toContain("1/1 online");
|
||
expect(packageHtml).toContain("downloads-availability has-counts is-online");
|
||
expect(packageHtml).toContain("downloads-availability-count is-online-count");
|
||
expect(packageHtml).toContain("downloads-availability-separator");
|
||
expect(packageHtml).toContain("downloads-availability-count is-total-count");
|
||
expect(packageHtml).toContain("downloads-availability-label");
|
||
expect(packageHtml).not.toContain("downloads-availability-symbol");
|
||
expect(itemHtml).toContain("Online");
|
||
expect(itemHtml).toContain("downloads-availability is-online");
|
||
});
|
||
|
||
it("keeps package availability counts compact and preserves the state colors", () => {
|
||
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
|
||
|
||
expect(css).toMatch(/\.downloads-availability\.has-counts\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*max-content max-content max-content max-content;[^}]*font-variant-numeric:\s*tabular-nums;/s);
|
||
expect(css).toMatch(/\.downloads-availability-count\.is-online-count\s*\{[^}]*text-align:\s*right;/s);
|
||
expect(css).toMatch(/\.downloads-availability-count\.is-total-count\s*\{[^}]*text-align:\s*left;/s);
|
||
expect(css).toMatch(/\.downloads-availability-label\s*\{[^}]*padding-left:\s*3px;/s);
|
||
expect(css).toMatch(/\.downloads-availability\.is-online\s*\{[^}]*color:\s*var\(--ui-success-text\);/s);
|
||
expect(css).toMatch(/\.downloads-availability\.is-partial\s*\{[^}]*color:\s*var\(--ui-warning-text\);/s);
|
||
expect(css).toMatch(/\.downloads-availability\.is-offline\s*\{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||
});
|
||
|
||
it("preserves the package download and extraction phase split", () => {
|
||
const extractionPackage = {
|
||
...pkg("extracting-package", "Entpackendes Paket", ["extracting-item"]),
|
||
status: "extracting"
|
||
} as PackageEntry;
|
||
const extractionItem = item("extracting-item", extractionPackage.id, "completed", {
|
||
fullStatus: "Entpacken 40%",
|
||
progressPercent: 100
|
||
});
|
||
const html = renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["progress"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "80px",
|
||
packageSpeedBps: 0,
|
||
row: { package: extractionPackage, items: [extractionItem], allItems: [extractionItem], collapsed: true },
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
|
||
expect(html).toContain(">70%</b>");
|
||
});
|
||
|
||
it("never exposes archive filenames as the visible package status", () => {
|
||
const extractionItem = item("archive-item", "archive-package", "completed", { fullStatus: "Entpacken - Ausstehend" });
|
||
const extractionPackage = {
|
||
...pkg("archive-package", "Archiv", [extractionItem.id]),
|
||
status: "extracting",
|
||
postProcessLabel: "release.part1.rar"
|
||
} as PackageEntry;
|
||
const html = renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "220px",
|
||
packageSpeedBps: 0,
|
||
row: { package: extractionPackage, items: [extractionItem], allItems: [extractionItem], collapsed: true },
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
|
||
expect(html).toContain(">Entpacken - Ausstehend</span>");
|
||
expect(html).not.toContain(">release.part1.rar</span>");
|
||
});
|
||
|
||
it.each([
|
||
["package", "Finalisieren (1/2) · release.part1.rar", "Finalisieren - 50%"],
|
||
["package", "Finalizing - 50% * release.part1.rar", "Finalizing - 50%"],
|
||
["item", "Finalizing (3/4) · release.part1.rar", "Finalizing - 75%"],
|
||
["item", "Finalisieren - 50% * release.part1.rar", "Finalisieren - 50%"]
|
||
])("shows %s finalization status without archive details", (target, rawStatus, expectedStatus) => {
|
||
const html = target === "package"
|
||
? renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "220px",
|
||
packageSpeedBps: 0,
|
||
row: {
|
||
package: { ...pkg("finalization-package", "Finalization", ["finalization-item"]), postProcessLabel: rawStatus, status: "extracting" } as PackageEntry,
|
||
items: [item("finalization-item", "finalization-package", "completed")],
|
||
allItems: [item("finalization-item", "finalization-package", "completed")],
|
||
collapsed: true
|
||
},
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}))
|
||
: renderToStaticMarkup(ItemRowContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status"],
|
||
gridTemplate: "220px",
|
||
item: item("finalization-item", "finalization-package", "completed", { fullStatus: rawStatus }),
|
||
selected: false
|
||
}));
|
||
|
||
expect(html).toContain(`aria-label="${expectedStatus}"`);
|
||
expect(html.match(new RegExp(`>${expectedStatus}</span>`, "g"))).toHaveLength(2);
|
||
expect(html).not.toContain(">release.part1.rar</span>");
|
||
if (target === "item") expect(html).not.toMatch(/title="[^"]*release\.part1\.rar/);
|
||
});
|
||
|
||
it.each([
|
||
["Finalisieren (3/2) · release.part1.rar", "Finalisieren - 100%"],
|
||
["Finalizing (-1/2) · release.part1.rar", "Finalizing - 0%"],
|
||
["Finalisieren (/2) · release.part1.rar", "Finalisieren"],
|
||
["Finalizing (1/) · release.part1.rar", "Finalizing"],
|
||
["Finalisieren (1/2/3) · release.part1.rar", "Finalisieren"],
|
||
["Finalizing (not-a-number/2) · release.part1.rar", "Finalizing"],
|
||
["Finalisieren (Infinity/2) · release.part1.rar", "Finalisieren"],
|
||
["Finalizing (NaN/2) · release.part1.rar", "Finalizing"],
|
||
["Finalisieren (1/0) · release.part1.rar", "Finalisieren"],
|
||
["Finalizing (invalid/2) · release.part1.rar", "Finalizing"]
|
||
])("derives only finite finalization percentages", (rawStatus, expectedStatus) => {
|
||
expect(compactDownloadStatus(rawStatus)).toBe(expectedStatus);
|
||
});
|
||
|
||
it("renders meter text in clipped track and fill layers", () => {
|
||
const html = renderToStaticMarkup(ItemRowContent({
|
||
actions: createActions(),
|
||
columnOrder: ["size", "progress"],
|
||
gridTemplate: "180px 120px",
|
||
item: item("meter", "package-a", "downloading", { downloadedBytes: 750, totalBytes: 1_000, progressPercent: 75 }),
|
||
selected: false
|
||
}));
|
||
|
||
expect(html.match(/class="downloads-meter-label is-track"/g)).toHaveLength(2);
|
||
expect(html.match(/class="downloads-meter-label is-filled"/g)).toHaveLength(2);
|
||
expect(html.match(/role="progressbar"/g)).toHaveLength(2);
|
||
expect(html).toContain("--downloads-progress:75%");
|
||
});
|
||
|
||
it("shows compact status visually while preserving the full accessible text", () => {
|
||
const html = renderToStaticMarkup(ItemRowContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status", "account"],
|
||
gridTemplate: "120px 120px",
|
||
item: item("status", "package-a", "downloading", {
|
||
fullStatus: "Download läuft (Mega-Debrid)",
|
||
providerLabel: "Mega-Debrid Web (Web Account)"
|
||
}),
|
||
selected: false
|
||
}));
|
||
|
||
expect(html).toContain('aria-label="Download läuft"');
|
||
expect(html).toContain('class="downloads-status-full"');
|
||
expect(html).toContain('class="downloads-status-compact"');
|
||
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
|
||
expect(html).toContain('title="Download läuft (Mega-Debrid)"');
|
||
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
|
||
expect(html).toContain('class="downloads-service-full">Mega-Debrid (Web)</span>');
|
||
expect(html).toContain('class="downloads-service-compact">Mega-Debrid (Web)</span>');
|
||
});
|
||
|
||
it("sets the whole visible selection atomically from the header checkbox", () => {
|
||
const calls: unknown[] = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({ onSetVisibleSelection: (ids, selected) => calls.push([ids, selected]) }),
|
||
columnOrder: ["name"],
|
||
gridTemplate: "minmax(280px, 1fr)",
|
||
selectedCount: 1,
|
||
sortColumn: "name",
|
||
sortDirection: "asc",
|
||
visibleIds: ["package-a", "active", "queued"]
|
||
});
|
||
const checkbox = findElement(header, (element) => element.type === "input");
|
||
const input = { indeterminate: false };
|
||
|
||
(checkbox as unknown as { ref: (element: typeof input) => void }).ref(input);
|
||
checkbox.props.onChange({ target: { checked: true } });
|
||
|
||
expect(checkbox.props["aria-checked"]).toBe("mixed");
|
||
expect(input.indeterminate).toBe(true);
|
||
expect(calls).toEqual([[['package-a', 'active', 'queued'], true]]);
|
||
});
|
||
|
||
it("announces sort state and exposes keyboard-operable column move controls", () => {
|
||
const calls: Array<[string, string, number]> = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({
|
||
onColumnPointerDown: (column, event) => calls.push(["down", column, event.clientX]),
|
||
onColumnPointerMove: (column, event) => calls.push(["move", column, event.clientX]),
|
||
onColumnPointerUp: (column, event) => calls.push(["up", column, event.clientX])
|
||
}),
|
||
columnOrder: ["name", "size", "account"],
|
||
gridTemplate: "200px 100px 100px",
|
||
selectedCount: 0,
|
||
sortColumn: "name",
|
||
sortDirection: "desc",
|
||
visibleIds: ["package-a"]
|
||
});
|
||
const html = renderToStaticMarkup(header);
|
||
const moveLeft = findElement(header, (element) => element.type === "button" && element.props["aria-label"] === "Geladen / Größe nach links verschieben");
|
||
const previous = { getBoundingClientRect: () => ({ left: 100, width: 100 }), matches: () => true };
|
||
const current = {
|
||
closest: () => null,
|
||
getBoundingClientRect: () => ({ left: 200, width: 100 }),
|
||
previousElementSibling: previous,
|
||
nextElementSibling: null
|
||
};
|
||
|
||
moveLeft.props.onClick({ currentTarget: { closest: () => current }, stopPropagation: () => {} });
|
||
|
||
expect(html).toMatch(/aria-sort="descending"[^>]*data-download-column="name"/);
|
||
expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="size"/);
|
||
expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="account"/);
|
||
expect(moveLeft.props.type).toBe("button");
|
||
expect(calls).toEqual([
|
||
["down", "size", 250],
|
||
["move", "size", 149],
|
||
["up", "size", 149]
|
||
]);
|
||
});
|
||
|
||
it.each([
|
||
{ clientXs: [100, 100], deliverPointerClick: false },
|
||
{ clientXs: [100, 104, 104], deliverPointerClick: true }
|
||
])("sorts exactly once when a captured pointer gesture stays below the drag threshold", ({ clientXs, deliverPointerClick }) => {
|
||
const sorted: string[] = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
|
||
columnOrder: ["name", "size"],
|
||
gridTemplate: "200px 100px",
|
||
selectedCount: 0,
|
||
sortColumn: "name",
|
||
sortDirection: "asc",
|
||
visibleIds: []
|
||
});
|
||
|
||
dispatchColumnSortPointerGesture(header, "Name", clientXs, deliverPointerClick);
|
||
|
||
expect(sorted).toEqual(["name"]);
|
||
});
|
||
|
||
it("never sorts when a pointer gesture reaches the drag threshold", () => {
|
||
const sorted: string[] = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
|
||
columnOrder: ["name", "size"],
|
||
gridTemplate: "200px 100px",
|
||
selectedCount: 0,
|
||
sortColumn: "name",
|
||
sortDirection: "asc",
|
||
visibleIds: []
|
||
});
|
||
|
||
dispatchColumnSortPointerGesture(header, "Name", [100, 105, 101], true);
|
||
|
||
expect(sorted).toEqual([]);
|
||
});
|
||
|
||
it("keeps sortable headers keyboard operable", () => {
|
||
const sorted: string[] = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
|
||
columnOrder: ["name", "size"],
|
||
gridTemplate: "200px 100px",
|
||
selectedCount: 0,
|
||
sortColumn: "name",
|
||
sortDirection: "asc",
|
||
visibleIds: []
|
||
});
|
||
const sortButton = findElement(header, (element) => element.type === "button" && String(element.props.children).startsWith("Name"));
|
||
|
||
sortButton.props.onClick({ detail: 0 });
|
||
|
||
expect(sorted).toEqual(["name"]);
|
||
});
|
||
|
||
it("exposes Service as a sortable column header", () => {
|
||
const sorted: string[] = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
|
||
columnOrder: ["account"],
|
||
gridTemplate: "100px",
|
||
selectedCount: 0,
|
||
sortColumn: "service" as any,
|
||
sortDirection: "desc",
|
||
visibleIds: []
|
||
});
|
||
const serviceHeader = findElement(header, (element) => element.props["data-download-column"] === "account");
|
||
const sortButton = findElement(serviceHeader, (element) => element.type === "button");
|
||
|
||
sortButton.props.onClick({ detail: 0 });
|
||
|
||
expect(serviceHeader.props["aria-sort"]).toBe("descending");
|
||
expect(sorted).toEqual(["service"]);
|
||
});
|
||
|
||
it("opens the column menu without letting the same context event close it again", () => {
|
||
const calls: Array<[string, number, number]> = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({ onColumnContextMenu: (column, x, y) => calls.push([column, x, y]) }),
|
||
columnOrder: ["name", "size"],
|
||
gridTemplate: "200px 100px",
|
||
selectedCount: 0,
|
||
sortColumn: "name",
|
||
sortDirection: "asc",
|
||
visibleIds: []
|
||
});
|
||
const nameHeader = findElement(header, (element) => element.props["data-download-column"] === "name");
|
||
const preventDefault = vi.fn();
|
||
const stopPropagation = vi.fn();
|
||
|
||
nameHeader.props.onContextMenu({ clientX: 320, clientY: 140, preventDefault, stopPropagation });
|
||
|
||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||
expect(stopPropagation).toHaveBeenCalledTimes(1);
|
||
expect(calls).toEqual([["name", 320, 140]]);
|
||
});
|
||
|
||
it("ignores another column move while the previous move is settling", () => {
|
||
const calls: string[] = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({
|
||
onColumnPointerDown: () => calls.push("down"),
|
||
onColumnPointerMove: () => calls.push("move"),
|
||
onColumnPointerUp: () => calls.push("up")
|
||
}),
|
||
columnOrder: ["name", "size", "account"],
|
||
gridTemplate: "200px 100px 100px",
|
||
selectedCount: 0,
|
||
sortColumn: "name",
|
||
sortDirection: "asc",
|
||
visibleIds: ["package-a"]
|
||
});
|
||
const moveRight = findElement(header, (element) => element.type === "button" && element.props["aria-label"] === "Name nach rechts verschieben");
|
||
const table = { classList: { contains: (className: string) => className === "is-column-drag-settling" } };
|
||
const sibling = { getBoundingClientRect: () => ({ left: 300, width: 100 }), matches: () => true };
|
||
const current = {
|
||
closest: (selector: string) => selector === ".downloads-table" ? table : null,
|
||
getBoundingClientRect: () => ({ left: 100, width: 200 }),
|
||
previousElementSibling: null,
|
||
nextElementSibling: sibling
|
||
};
|
||
|
||
moveRight.props.onClick({ currentTarget: { closest: () => current }, stopPropagation: () => {} });
|
||
|
||
expect(calls).toEqual([]);
|
||
});
|
||
|
||
it("does not start a pointer drag while a column move is settling", () => {
|
||
const calls: string[] = [];
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions({ onColumnPointerDown: () => calls.push("down") }),
|
||
columnOrder: ["name", "size", "account"],
|
||
gridTemplate: "200px 100px 100px",
|
||
selectedCount: 0,
|
||
sortColumn: "name",
|
||
sortDirection: "asc",
|
||
visibleIds: ["package-a"]
|
||
});
|
||
const nameHeader = findElement(header, (element) => element.props["data-download-column"] === "name");
|
||
const pointerCapture = vi.fn();
|
||
|
||
nameHeader.props.onPointerDown({
|
||
button: 0,
|
||
clientX: 200,
|
||
currentTarget: {
|
||
closest: () => ({ classList: { contains: (className: string) => className === "is-column-drag-settling" } }),
|
||
setPointerCapture: pointerCapture
|
||
},
|
||
isPrimary: true,
|
||
pointerId: 4
|
||
});
|
||
|
||
expect(pointerCapture).not.toHaveBeenCalled();
|
||
expect(calls).toEqual([]);
|
||
});
|
||
|
||
it("includes package selection state in memo equality", () => {
|
||
const model = withRuntime(createInput());
|
||
const row = model.packageRows[0];
|
||
const base = {
|
||
actions: createActions(),
|
||
columnOrder: model.columnOrder,
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: model.gridTemplate,
|
||
packageSpeedBps: 0,
|
||
row,
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 1
|
||
};
|
||
|
||
expect(arePackageCardPropsEqual(base, { ...base, selectedIds: new Set([row.package.id]), selectedVersion: 2 })).toBe(false);
|
||
});
|
||
|
||
it("invalidates visible item rows when provider, error or timestamp presentation changes", () => {
|
||
const model = withRuntime(createInput());
|
||
const base = {
|
||
actions: createActions(),
|
||
columnOrder: model.columnOrder,
|
||
gridTemplate: model.gridTemplate,
|
||
item: model.packageRows[0].items[0],
|
||
selected: false
|
||
};
|
||
|
||
expect(areItemRowPropsEqual(base, { ...base, item: { ...base.item, providerLabel: "Debrid-Link" } })).toBe(false);
|
||
expect(areItemRowPropsEqual(base, { ...base, item: { ...base.item, lastError: "Neuer Fehler" } })).toBe(false);
|
||
expect(areItemRowPropsEqual(base, { ...base, item: { ...base.item, updatedAt: base.item.updatedAt + 1 } })).toBe(false);
|
||
});
|
||
|
||
it("does not collapse a package on shift selection", () => {
|
||
const calls: string[] = [];
|
||
const model = withRuntime(createInput());
|
||
const row = model.packageRows[0];
|
||
const component = PackageCardContent({
|
||
actions: createActions({
|
||
onToggleSelection: () => calls.push("select"),
|
||
onTogglePackageCollapse: () => calls.push("collapse")
|
||
}),
|
||
columnOrder: model.columnOrder,
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: model.gridTemplate,
|
||
packageSpeedBps: 0,
|
||
row,
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 1
|
||
});
|
||
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
|
||
|
||
packageRow.props.onClick({ button: 0, ctrlKey: false, metaKey: false, shiftKey: true, target: {}, currentTarget: { contains: () => true } });
|
||
|
||
expect(calls).toEqual(["select"]);
|
||
});
|
||
|
||
it("selects a package on a plain row click", () => {
|
||
const calls: Array<[string, boolean, boolean]> = [];
|
||
const model = withRuntime(createInput());
|
||
const row = model.packageRows[0];
|
||
const component = PackageCardContent({
|
||
actions: createActions({
|
||
onToggleSelection: (id, ctrl, shift) => calls.push([id, ctrl, shift])
|
||
}),
|
||
columnOrder: model.columnOrder,
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: model.gridTemplate,
|
||
packageSpeedBps: 0,
|
||
row,
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 1
|
||
});
|
||
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
|
||
|
||
packageRow.props.onClick({ ctrlKey: false, metaKey: false, shiftKey: false });
|
||
|
||
expect(calls).toEqual([[row.package.id, false, false]]);
|
||
});
|
||
|
||
it("collapses a package through its disclosure button or a free-row double click", () => {
|
||
const calls: string[] = [];
|
||
const model = withRuntime(createInput());
|
||
const row = model.packageRows[0];
|
||
const component = PackageCardContent({
|
||
actions: createActions({
|
||
onTogglePackageCollapse: () => calls.push("collapse"),
|
||
onToggleSelection: () => calls.push("select")
|
||
}),
|
||
columnOrder: model.columnOrder,
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: model.gridTemplate,
|
||
packageSpeedBps: 0,
|
||
row,
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 1
|
||
});
|
||
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
|
||
const collapseButton = findElement(component, (element) => element.type === "button" && String(element.props.className || "").includes("downloads-collapse-button"));
|
||
|
||
packageRow.props.onClick({ ctrlKey: false, detail: 1, metaKey: false, shiftKey: false, target: {}, currentTarget: {} });
|
||
packageRow.props.onClick({ ctrlKey: false, detail: 2, metaKey: false, shiftKey: false, target: {}, currentTarget: {} });
|
||
expect(packageRow.props.onDoubleClick).toBeTypeOf("function");
|
||
packageRow.props.onDoubleClick({
|
||
ctrlKey: false,
|
||
currentTarget: {},
|
||
metaKey: false,
|
||
preventDefault: () => calls.push("prevent"),
|
||
shiftKey: false,
|
||
target: { closest: () => null }
|
||
});
|
||
collapseButton.props.onClick({ stopPropagation: () => {} });
|
||
|
||
expect(calls).toEqual(["select", "prevent", "collapse", "collapse"]);
|
||
expect(collapseButton.props["aria-expanded"]).toBe(true);
|
||
expect(collapseButton.props["aria-controls"]).toBeUndefined();
|
||
});
|
||
|
||
it("does not collapse a package when a row double click starts on interactive content", () => {
|
||
const calls: string[] = [];
|
||
const model = withRuntime(createInput());
|
||
const row = model.packageRows[0];
|
||
const component = PackageCardContent({
|
||
actions: createActions({
|
||
onStartPackageRename: () => calls.push("rename"),
|
||
onTogglePackageCollapse: () => calls.push("collapse"),
|
||
onToggleSelection: () => calls.push("select")
|
||
}),
|
||
columnOrder: model.columnOrder,
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: model.gridTemplate,
|
||
packageSpeedBps: 0,
|
||
row,
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 1
|
||
});
|
||
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
|
||
const title = findElement(component, (element) => element.type === "strong" && String(element.props.className || "").includes("downloads-copyable"));
|
||
|
||
packageRow.props.onClick({ ctrlKey: false, detail: 1, metaKey: false, shiftKey: false });
|
||
packageRow.props.onClick({ ctrlKey: false, detail: 2, metaKey: false, shiftKey: false });
|
||
expect(packageRow.props.onDoubleClick).toBeTypeOf("function");
|
||
packageRow.props.onDoubleClick({
|
||
ctrlKey: false,
|
||
currentTarget: {},
|
||
metaKey: false,
|
||
preventDefault: () => calls.push("prevent"),
|
||
shiftKey: false,
|
||
target: { closest: () => ({}) }
|
||
});
|
||
title.props.onDoubleClick({ stopPropagation: () => calls.push("stop") });
|
||
|
||
expect(calls).toEqual(["select", "stop", "rename"]);
|
||
});
|
||
|
||
it("keeps an already selected package selected during a row double click", () => {
|
||
const calls: string[] = [];
|
||
const model = withRuntime(createInput());
|
||
const row = model.packageRows[0];
|
||
const component = PackageCardContent({
|
||
actions: createActions({
|
||
onTogglePackageCollapse: () => calls.push("collapse"),
|
||
onToggleSelection: () => calls.push("select")
|
||
}),
|
||
columnOrder: model.columnOrder,
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: model.gridTemplate,
|
||
packageSpeedBps: 0,
|
||
row,
|
||
selectedIds: new Set([row.package.id]),
|
||
selectedVersion: 1
|
||
});
|
||
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
|
||
|
||
packageRow.props.onClick({ ctrlKey: false, detail: 1, metaKey: false, shiftKey: false });
|
||
packageRow.props.onClick({ ctrlKey: false, detail: 2, metaKey: false, shiftKey: false });
|
||
packageRow.props.onDoubleClick({
|
||
ctrlKey: false,
|
||
metaKey: false,
|
||
preventDefault: () => calls.push("prevent"),
|
||
shiftKey: false,
|
||
target: { closest: () => null }
|
||
});
|
||
|
||
expect(calls).toEqual(["prevent", "collapse"]);
|
||
});
|
||
|
||
it("keeps the complete package status and audio details in the tooltip", () => {
|
||
const audioPackage = {
|
||
...pkg("audio-package", "Audio package", ["audio-item"]),
|
||
postProcessLabel: "Entpacken 1%",
|
||
audioStripSummary: {
|
||
at: now,
|
||
candidates: 1,
|
||
remuxed: 1,
|
||
keptSingle: 0,
|
||
skippedNoGerman: 0,
|
||
skippedNoTool: 0,
|
||
failed: 0,
|
||
files: [{ name: "episode.mkv", action: "remuxed", reason: "German kept" }]
|
||
}
|
||
} as PackageEntry;
|
||
const html = renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "220px",
|
||
packageSpeedBps: 0,
|
||
row: { package: audioPackage, items: [item("audio-item", audioPackage.id, "queued")], allItems: [item("audio-item", audioPackage.id, "queued")], collapsed: true },
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
|
||
expect(html).toMatch(/title="0\/1 · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
|
||
});
|
||
|
||
it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => {
|
||
const html = renderToStaticMarkup(ItemRowContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status"],
|
||
gridTemplate: "220px",
|
||
item: item("extract-error", "package-a", "failed", {
|
||
fullStatus: "Entpack-Fehler [release.part1.rar]: Unerwartetes Dateiende",
|
||
lastError: "Mega-Debrid API: Kein Server verfügbar"
|
||
}),
|
||
selected: false
|
||
}));
|
||
|
||
expect(html.match(/>Entpack-Fehler<\/span>/g)).toHaveLength(2);
|
||
expect(html).toContain('title="Entpack-Fehler [release.part1.rar]: Unerwartetes Dateiende');
|
||
expect(html).toContain('Mega-Debrid API: Kein Server verfügbar');
|
||
});
|
||
|
||
it("removes redundant service suffixes from runtime statuses", () => {
|
||
expect(compactDownloadStatus("Starte... (Mega-Debrid Web)")).toBe("Starte...");
|
||
expect(compactDownloadStatus("Warte auf Daten (Mega-Debrid Web)")).toBe("Warte auf Daten");
|
||
expect(compactDownloadStatus("Warte auf Festplatte (Mega-Debrid Web)")).toBe("Warte auf Festplatte");
|
||
});
|
||
|
||
it("prioritizes disk waits and extraction errors in package status", () => {
|
||
const diskPackage = pkg("disk-package", "Disk package", ["disk-item", "active-item"]);
|
||
const diskHtml = renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "220px",
|
||
packageSpeedBps: 1_000,
|
||
row: {
|
||
package: diskPackage,
|
||
items: [
|
||
item("disk-item", diskPackage.id, "downloading", { fullStatus: "Warte auf Festplatte (Mega-Debrid Web)" }),
|
||
item("active-item", diskPackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid Web)" })
|
||
],
|
||
allItems: [
|
||
item("disk-item", diskPackage.id, "downloading", { fullStatus: "Warte auf Festplatte (Mega-Debrid Web)" }),
|
||
item("active-item", diskPackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid Web)" })
|
||
],
|
||
collapsed: true
|
||
},
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
expect(diskHtml).toMatch(/>Warte auf Festplatte<\/span>/);
|
||
|
||
const errorPackage = { ...pkg("error-package", "Error package", ["error-a", "error-b"]), status: "completed" as const };
|
||
const errorHtml = renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "220px",
|
||
packageSpeedBps: 0,
|
||
row: {
|
||
package: errorPackage,
|
||
items: [
|
||
item("error-a", errorPackage.id, "completed", { fullStatus: "Entpack-Fehler [release.part1.rar]: Kein Speicherplatz" }),
|
||
item("error-b", errorPackage.id, "completed", { fullStatus: "Entpackt - Done" })
|
||
],
|
||
allItems: [
|
||
item("error-a", errorPackage.id, "completed", { fullStatus: "Entpack-Fehler [release.part1.rar]: Kein Speicherplatz" }),
|
||
item("error-b", errorPackage.id, "completed", { fullStatus: "Entpackt - Done" })
|
||
],
|
||
collapsed: true
|
||
},
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
expect(errorHtml).toMatch(/>Entpack-Fehler<\/span>/);
|
||
expect(errorHtml).not.toMatch(/>2\/2<\/span>/);
|
||
});
|
||
|
||
it("shows only the operation in an actively downloading package status", () => {
|
||
const activePackage = pkg("active-package", "Active package", ["active-item"]);
|
||
const html = renderToStaticMarkup(PackageCardContent({
|
||
actions: createActions(),
|
||
columnOrder: ["status"],
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: "220px",
|
||
packageSpeedBps: 1_000,
|
||
row: { package: activePackage, items: [item("active-item", activePackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid API)" })], allItems: [item("active-item", activePackage.id, "downloading", { fullStatus: "Download läuft (Mega-Debrid API)" })], collapsed: true },
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 0
|
||
}));
|
||
|
||
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
|
||
expect(html).toContain('title="0/1"');
|
||
});
|
||
|
||
it("commits Enter and the resulting Blur rename sequence exactly once", () => {
|
||
const commits: string[] = [];
|
||
const model = withRuntime(createInput());
|
||
const row = model.packageRows[0];
|
||
const component = PackageCardContent({
|
||
actions: createActions({ onCommitPackageRename: (id, value) => commits.push(`${id}:${value}`) }),
|
||
columnOrder: model.columnOrder,
|
||
editing: true,
|
||
editingName: "Neuer Name",
|
||
gridTemplate: model.gridTemplate,
|
||
packageSpeedBps: 0,
|
||
row,
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 1
|
||
});
|
||
const input = findElement(component, (element) => element.type === "input" && element.props.className === "downloads-rename-input");
|
||
|
||
input.props.onKeyDown({ key: "Enter", preventDefault: () => {}, currentTarget: { blur: () => {} } });
|
||
input.props.onBlur();
|
||
|
||
expect(commits).toEqual(["package-a:Neuer Name"]);
|
||
});
|
||
|
||
it("removes the redundant package activation checkbox and preserves context actions", () => {
|
||
const calls: Array<unknown> = [];
|
||
const model = withRuntime(createInput());
|
||
const row = model.packageRows[0];
|
||
const component = PackageCardContent({
|
||
actions: createActions({
|
||
onToggleSelection: (id, ctrl, shift) => calls.push(["select", id, ctrl, shift]),
|
||
onOpenContextMenu: (id, x, y) => calls.push(["context", id, x, y])
|
||
}),
|
||
columnOrder: model.columnOrder,
|
||
editing: false,
|
||
editingName: "",
|
||
gridTemplate: model.gridTemplate,
|
||
packageSpeedBps: 0,
|
||
row,
|
||
selectedIds: new Set<string>(),
|
||
selectedVersion: 1
|
||
});
|
||
const selection = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie auswählen");
|
||
const packageElement = findElement(component, (element) => element.props["data-download-package-id"] === "package-a");
|
||
|
||
selection.props.onClick({ stopPropagation: () => {}, ctrlKey: false, metaKey: false, shiftKey: true });
|
||
packageElement.props.onContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: 30, clientY: 50 });
|
||
|
||
expect(calls).toEqual([["select", "package-a", true, true], ["context", "package-a", 30, 50]]);
|
||
expect(() => findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren")).toThrow("Element not found");
|
||
});
|
||
});
|