Restore and persist the package-based link collector with progressive metadata, bounded high-volume updates, safe hydration, visible selection, and complete localization. Harden download controls, snapshot ordering, history pagination, statistics recovery, settings saves, backup imports, notification persistence, and Windows storage races with regression coverage.
2762 lines
133 KiB
TypeScript
2762 lines
133 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,
|
||
buildDownloadLogicalRows,
|
||
type DownloadSidebarFilter,
|
||
type DownloadsModelInput
|
||
} from "../src/renderer/views/downloads/downloads-model";
|
||
import {
|
||
DOWNLOAD_DISCLOSURE_DURATION_MS,
|
||
DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS,
|
||
activateDownloadDisclosureTransition,
|
||
getDownloadDisclosurePinnedIds,
|
||
mergeDownloadDisclosureRows,
|
||
prepareDownloadDisclosureTransition,
|
||
scheduleDownloadDisclosureActivation,
|
||
stableDownloadDisclosureRows
|
||
} from "../src/renderer/views/downloads/download-disclosure-transition";
|
||
import {
|
||
DOWNLOAD_ORDER_TRANSITION_DURATION_MS,
|
||
getDownloadOrderTransitionPinnedIds,
|
||
getDownloadOrderTransformKeyframes,
|
||
isDownloadPackageOrderChange
|
||
} from "../src/renderer/views/downloads/download-order-transition";
|
||
import {
|
||
DownloadsContent,
|
||
DownloadsFooter,
|
||
DownloadsSidebar,
|
||
DownloadsSidebarStatus,
|
||
DownloadsToolbar,
|
||
DownloadsView,
|
||
type DownloadsViewActions,
|
||
type DownloadsViewModel
|
||
} from "../src/renderer/views/downloads/DownloadsView";
|
||
import * as downloadsViewModule from "../src/renderer/views/downloads/DownloadsView";
|
||
import {
|
||
DownloadsTableHeader,
|
||
ItemRowContent,
|
||
PackageCardContent,
|
||
areItemRowPropsEqual,
|
||
arePackageCardPropsEqual,
|
||
compactDownloadStatus,
|
||
downloadColumnDefinitions,
|
||
getAvailabilitySummary,
|
||
getPackageProgress,
|
||
getPackageSizeProgress
|
||
} from "../src/renderer/views/downloads/DownloadsTable";
|
||
import * as downloadsTableModule from "../src/renderer/views/downloads/DownloadsTable";
|
||
import {
|
||
DOWNLOAD_FILE_ROW_HEIGHT,
|
||
DOWNLOAD_PACKAGE_ROW_HEIGHT,
|
||
calculateDownloadVirtualWindow
|
||
} from "../src/renderer/views/downloads/download-virtualizer";
|
||
import { isDownloadItemVisuallySelected } from "../src/renderer/views/downloads/VirtualizedDownloadsBody";
|
||
import {
|
||
compactDownloadServiceLabel,
|
||
extractHoster,
|
||
formatHosterLabel,
|
||
normalizeDownloadServiceLabel
|
||
} from "../src/renderer/download-format";
|
||
import { getRollingMetricDirection, shouldAnimateRollingMetric } from "../src/renderer/ui/RollingMetricValue";
|
||
import { SlidingSelection } from "../src/renderer/ui/SlidingSelection";
|
||
|
||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||
|
||
describe("Downloadtabellen-Spalten", () => {
|
||
it("derives grid tracks from the same column order that renders the cells", () => {
|
||
const downloadGridTemplateForOrder = (downloadsTableModule as unknown as {
|
||
downloadGridTemplateForOrder?: (order: readonly string[]) => string;
|
||
}).downloadGridTemplateForOrder;
|
||
expect(downloadGridTemplateForOrder).toBeTypeOf("function");
|
||
if (!downloadGridTemplateForOrder) return;
|
||
|
||
const order = ["size", "name"];
|
||
const expected = `36px ${downloadColumnDefinitions.size.width} ${downloadColumnDefinitions.name.width} 60px`;
|
||
const header = DownloadsTableHeader({
|
||
actions: createActions(),
|
||
columnOrder: order,
|
||
gridTemplate: `${downloadColumnDefinitions.name.width} ${downloadColumnDefinitions.size.width}`,
|
||
selectedCount: 0,
|
||
sortColumn: "name",
|
||
sortDirection: "asc",
|
||
visibleIds: []
|
||
});
|
||
|
||
expect(downloadGridTemplateForOrder(order)).toBe(expected);
|
||
expect(header.props.style.gridTemplateColumns).toBe(expected);
|
||
});
|
||
|
||
it("keeps the column layout reset out of the table action header", () => {
|
||
const actions = createActions();
|
||
const model = withRuntime(createInput());
|
||
const content = renderToStaticMarkup(<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 }));
|
||
});
|
||
});
|
||
|
||
describe("Download-ETA", () => {
|
||
it("derives ETA from remaining bytes and current throughput before any file completes", () => {
|
||
expect(formatDownloadEta({ bytes: 600 * 1024 * 1024, unknownItems: 0 }, 10 * 1024 * 1024, true, false)).toBe("01:00");
|
||
});
|
||
|
||
|
||
it("keeps ETA unknown while paused, stopped, stalled, or missing file sizes", () => {
|
||
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 10, false, false)).toBe("--");
|
||
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 10, true, true)).toBe("--");
|
||
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 0, true, false)).toBe("--");
|
||
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 1 }, 10, true, false)).toBe("--");
|
||
});
|
||
});
|
||
|
||
describe("verbleibendes Downloadvolumen", () => {
|
||
it("shows a fourth TB decimal so fast downloads stay visibly responsive", () => {
|
||
expect(formatRemainingDownloadBytes({ bytes: Math.round(1.125 * 1024 ** 4), unknownItems: 0 })).toBe("1.1250 TB");
|
||
});
|
||
|
||
it("summiert nur offene bekannte Restbytes und klemmt überladene Fortschritte bei null", () => {
|
||
const items = [
|
||
item("partial", "remaining", "downloading", { downloadedBytes: 750_000_000, totalBytes: 2_000_000_000 }),
|
||
item("queued", "remaining", "queued", { downloadedBytes: 0, totalBytes: 3_000_000_000 }),
|
||
item("overshot", "remaining", "downloading", { downloadedBytes: 2_100_000_000, totalBytes: 2_000_000_000 }),
|
||
item("done", "remaining", "completed", { downloadedBytes: 9_000_000_000, totalBytes: 10_000_000_000 }),
|
||
item("failed", "remaining", "failed", { downloadedBytes: 0, totalBytes: 8_000_000_000 }),
|
||
item("cancelled", "remaining", "cancelled", { downloadedBytes: 0, totalBytes: 7_000_000_000 })
|
||
];
|
||
|
||
expect(getRemainingDownloadBytes(items)).toEqual({ bytes: 4_250_000_000, unknownItems: 0 });
|
||
});
|
||
|
||
it("meldet unbekannte Größen nur für noch offene Downloads", () => {
|
||
const items = [
|
||
item("known", "remaining", "queued", { downloadedBytes: 250_000_000, totalBytes: 1_000_000_000 }),
|
||
item("unknown-open", "remaining", "queued", { downloadedBytes: 120_000_000, totalBytes: null }),
|
||
item("unknown-done", "remaining", "completed", { totalBytes: null })
|
||
];
|
||
|
||
expect(getRemainingDownloadBytes(items)).toEqual({ bytes: 750_000_000, unknownItems: 1 });
|
||
expect(formatRemainingDownloadBytes({ bytes: 750_000_000, unknownItems: 1 })).toBe("≥ 715.26 MB");
|
||
expect(formatRemainingDownloadBytes({ bytes: 0, unknownItems: 1 })).toBe("Unbekannt");
|
||
expect(formatRemainingDownloadBytes({ bytes: 0, unknownItems: 0 })).toBe("0 B");
|
||
expect(formatRemainingDownloadTooltip({ bytes: 750_000_000, unknownItems: 1 }))
|
||
.toBe("Noch unbekannte Dateigrößen: 1. Die tatsächliche Restmenge kann höher sein.");
|
||
expect(formatRemainingDownloadTooltip({ bytes: 0, unknownItems: 0 })).toBe("");
|
||
});
|
||
|
||
it("erklärt unbekannte Restgrößen direkt am verbleibenden Wert", () => {
|
||
const model = withRuntime(createInput());
|
||
model.status.remaining = "≥ 715.26 MB";
|
||
model.status.remainingTooltip = "Noch unbekannte Dateigrößen: 2. Die tatsächliche Restmenge kann höher sein.";
|
||
|
||
const html = renderToStaticMarkup(<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("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("exports the sidebar filter contract used by the downloads shell", () => {
|
||
const filter: DownloadSidebarFilter = "queued";
|
||
|
||
expect(filter).toBe("queued");
|
||
});
|
||
|
||
it("builds sidebar counts without mutating the runtime items", () => {
|
||
const items = [
|
||
item("count-active", "count-package", "downloading"),
|
||
item("count-done", "count-package", "completed"),
|
||
item("count-cancelled", "count-package", "cancelled")
|
||
];
|
||
const snapshot = items.map((entry) => ({ ...entry }));
|
||
|
||
expect(buildDownloadSidebarCounts(items)).toEqual({ all: 3, active: 1, queued: 0, paused: 0, completed: 1, failed: 0 });
|
||
expect(items).toEqual(snapshot);
|
||
});
|
||
|
||
it("maps every runtime status into the exact semantic filter class", () => {
|
||
expect(classifyDownloadStatus("downloading")).toBe("active");
|
||
expect(classifyDownloadStatus("validating")).toBe("active");
|
||
expect(classifyDownloadStatus("extracting")).toBe("active");
|
||
expect(classifyDownloadStatus("integrity_check")).toBe("active");
|
||
expect(classifyDownloadStatus("queued")).toBe("queued");
|
||
expect(classifyDownloadStatus("reconnect_wait")).toBe("queued");
|
||
expect(classifyDownloadStatus("paused")).toBe("paused");
|
||
expect(classifyDownloadStatus("completed")).toBe("completed");
|
||
expect(classifyDownloadStatus("failed")).toBe("failed");
|
||
expect(classifyDownloadStatus("cancelled")).toBe("all");
|
||
});
|
||
|
||
it("derives sidebar counts from the complete queue before presentation filters", () => {
|
||
const model = buildDownloadsViewModel(createInput({ filter: "failed" }));
|
||
|
||
expect(model.counts).toEqual({ all: 4, active: 1, queued: 1, paused: 0, completed: 1, failed: 1 });
|
||
expect(model.visibleItemIds).toEqual(["failed"]);
|
||
});
|
||
|
||
it("uses package lifecycle status for counts and filters without counting children twice", () => {
|
||
const extractingPackage = { ...pkg("extracting-package", "Entpacken", ["extracting-a", "extracting-b"]), status: "extracting" } as PackageEntry;
|
||
const failedPackage = { ...pkg("failed-package", "Entpackfehler", ["extract-failed"]), status: "failed" } as PackageEntry;
|
||
const pausedPackage = { ...pkg("paused-package", "Pausiert", ["paused-item"]), status: "paused" } as PackageEntry;
|
||
const input = createInput({
|
||
packageOrder: [extractingPackage.id, failedPackage.id, pausedPackage.id],
|
||
packages: {
|
||
[extractingPackage.id]: extractingPackage,
|
||
[failedPackage.id]: failedPackage,
|
||
[pausedPackage.id]: pausedPackage
|
||
},
|
||
items: {
|
||
"extracting-a": item("extracting-a", extractingPackage.id, "completed"),
|
||
"extracting-b": item("extracting-b", extractingPackage.id, "completed"),
|
||
"extract-failed": item("extract-failed", failedPackage.id, "completed", { fullStatus: "Entpack-Fehler: CRC" }),
|
||
"paused-item": item("paused-item", pausedPackage.id, "queued")
|
||
}
|
||
});
|
||
|
||
const all = buildDownloadsViewModel(input);
|
||
const active = buildDownloadsViewModel({ ...input, filter: "active" });
|
||
const failed = buildDownloadsViewModel({ ...input, filter: "failed" });
|
||
const paused = buildDownloadsViewModel({ ...input, filter: "paused" });
|
||
|
||
expect(all.counts).toEqual({ all: 4, active: 2, queued: 0, paused: 1, completed: 0, failed: 1 });
|
||
expect(all.counts.active + all.counts.queued + all.counts.paused + all.counts.completed + all.counts.failed).toBe(all.counts.all);
|
||
expect(active.visibleItemIds).toEqual(["extracting-a", "extracting-b"]);
|
||
expect(failed.visibleItemIds).toEqual(["extract-failed"]);
|
||
expect(paused.visibleItemIds).toEqual(["paused-item"]);
|
||
});
|
||
|
||
it("preserves terminal children while unfinished children inherit failed or paused package state", () => {
|
||
const failedPackage = { ...pkg("mixed-failed", "Gemischter Fehler", ["failed-complete", "failed-pending"]), status: "failed" } as PackageEntry;
|
||
const pausedPackage = { ...pkg("mixed-paused", "Gemischt pausiert", ["paused-complete", "paused-pending"]), status: "paused" } as PackageEntry;
|
||
const input = createInput({
|
||
packageOrder: [failedPackage.id, pausedPackage.id],
|
||
packages: { [failedPackage.id]: failedPackage, [pausedPackage.id]: pausedPackage },
|
||
items: {
|
||
"failed-complete": item("failed-complete", failedPackage.id, "completed"),
|
||
"failed-pending": item("failed-pending", failedPackage.id, "queued"),
|
||
"paused-complete": item("paused-complete", pausedPackage.id, "completed"),
|
||
"paused-pending": item("paused-pending", pausedPackage.id, "queued")
|
||
}
|
||
});
|
||
|
||
const model = buildDownloadsViewModel(input);
|
||
expect(model.counts).toEqual({ all: 4, active: 0, queued: 0, paused: 1, completed: 2, failed: 1 });
|
||
expect(buildDownloadsViewModel({ ...input, filter: "completed" }).visibleItemIds).toEqual(["failed-complete", "paused-complete"]);
|
||
expect(buildDownloadsViewModel({ ...input, filter: "failed" }).visibleItemIds).toEqual(["failed-pending"]);
|
||
expect(buildDownloadsViewModel({ ...input, filter: "paused" }).visibleItemIds).toEqual(["paused-pending"]);
|
||
});
|
||
|
||
it("classifies Entpacken Error status variants as failures", () => {
|
||
const packageEntry = { ...pkg("extract-error-package", "Entpackfehler", ["extract-error-item"]), status: "queued" } as PackageEntry;
|
||
const input = createInput({
|
||
packageOrder: [packageEntry.id],
|
||
packages: { [packageEntry.id]: packageEntry },
|
||
items: {
|
||
"extract-error-item": item("extract-error-item", packageEntry.id, "completed", { fullStatus: "Entpacken - Error: CRC" })
|
||
}
|
||
});
|
||
|
||
const model = buildDownloadsViewModel(input);
|
||
expect(model.counts).toEqual({ all: 1, active: 0, queued: 0, paused: 0, completed: 0, failed: 1 });
|
||
expect(buildDownloadsViewModel({ ...input, filter: "failed" }).visibleItemIds).toEqual(["extract-error-item"]);
|
||
});
|
||
|
||
it("keeps cancelled, extracted, and queued siblings out of an active extraction bucket", () => {
|
||
const packageEntry = { ...pkg("extracting-mixed", "Gemischtes Entpacken", ["extracting-ready", "extracting-cancelled", "extracting-done", "extracting-queued"]), status: "extracting" } as PackageEntry;
|
||
const input = createInput({
|
||
packageOrder: [packageEntry.id],
|
||
packages: { [packageEntry.id]: packageEntry },
|
||
items: {
|
||
"extracting-ready": item("extracting-ready", packageEntry.id, "completed", { fullStatus: "Entpacken - 50%" }),
|
||
"extracting-cancelled": item("extracting-cancelled", packageEntry.id, "cancelled"),
|
||
"extracting-done": item("extracting-done", packageEntry.id, "completed", { fullStatus: "Entpackt" }),
|
||
"extracting-queued": item("extracting-queued", packageEntry.id, "queued")
|
||
}
|
||
});
|
||
|
||
expect(buildDownloadsViewModel(input).counts).toEqual({ all: 4, active: 1, queued: 1, paused: 0, completed: 1, failed: 0 });
|
||
expect(buildDownloadsViewModel({ ...input, filter: "active" }).visibleItemIds).toEqual(["extracting-ready"]);
|
||
expect(buildDownloadsViewModel({ ...input, filter: "queued" }).visibleItemIds).toEqual(["extracting-queued"]);
|
||
expect(buildDownloadsViewModel({ ...input, filter: "completed" }).visibleItemIds).toEqual(["extracting-done"]);
|
||
});
|
||
|
||
it("filters by package name, file name, provider, status and extracted visibility", () => {
|
||
const byPackage = buildDownloadsViewModel(createInput({ query: "aktive serie" }));
|
||
const byFile = buildDownloadsViewModel(createInput({ query: "done.mkv" }));
|
||
const byProvider = buildDownloadsViewModel(createInput({ providerFilter: "debridlink" }));
|
||
const hiddenExtracted = buildDownloadsViewModel(createInput({
|
||
items: { ...createInput().items, done: item("done", "package-b", "completed", { fullStatus: "Entpackt" }) },
|
||
hideExtractedItems: true
|
||
}));
|
||
|
||
expect(byPackage.packageRows.map((row) => row.package.id)).toEqual(["package-a"]);
|
||
expect(byPackage.packageRows[0].items.map((entry) => entry.id)).toEqual(["active", "queued"]);
|
||
expect(byFile.packageRows.map((row) => row.package.id)).toEqual(["package-b"]);
|
||
expect(byFile.packageRows[0].items.map((entry) => entry.id)).toEqual(["done"]);
|
||
expect(byProvider.visibleItemIds).toEqual(["queued"]);
|
||
expect(hiddenExtracted.visibleItemIds).not.toContain("done");
|
||
});
|
||
|
||
it("removes hidden extracted downloads from queue-wide visibility sources while preserving package aggregates", () => {
|
||
const model = buildDownloadsViewModel(createInput({
|
||
packageOrder: ["visible-package", "extracted-package"],
|
||
packages: {
|
||
"visible-package": pkg("visible-package", "Wartend", ["visible-item"]),
|
||
"extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"])
|
||
},
|
||
items: {
|
||
"visible-item": item("visible-item", "visible-package", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
|
||
"extracted-item": item("extracted-item", "extracted-package", "completed", { provider: "alldebrid", providerLabel: "AllDebrid", fullStatus: "Entpackt" })
|
||
},
|
||
hideExtractedItems: true
|
||
}));
|
||
|
||
expect(model.counts).toEqual({ all: 1, active: 0, queued: 1, paused: 0, completed: 0, failed: 0 });
|
||
expect(model.providerOptions).toEqual([{ id: "debridlink", label: "Debrid-Link" }]);
|
||
expect(model.eligibleItems.map((entry) => entry.id)).toEqual(["visible-item"]);
|
||
expect(model.eligiblePackageCount).toBe(1);
|
||
expect(model.packageRows[0].allItems.map((entry) => entry.id)).toEqual(["visible-item"]);
|
||
expect(model.empty).toBe(false);
|
||
expect(model.filteredEmpty).toBe(false);
|
||
});
|
||
|
||
it("keeps hidden extracted children in package progress, size, and total counts", () => {
|
||
const input = createInput({
|
||
packageOrder: ["mixed-package"],
|
||
packages: { "mixed-package": pkg("mixed-package", "Gemischtes Paket", ["extracted-item", "queued-item"]) },
|
||
items: {
|
||
"extracted-item": item("extracted-item", "mixed-package", "completed", { downloadedBytes: 100, totalBytes: 100, progressPercent: 100, fullStatus: "Entpackt" }),
|
||
"queued-item": item("queued-item", "mixed-package", "queued", { downloadedBytes: 0, totalBytes: 100, progressPercent: 0, fullStatus: "Wartet" })
|
||
},
|
||
hideExtractedItems: true
|
||
});
|
||
|
||
const model = buildDownloadsViewModel(input);
|
||
const row = model.packageRows[0];
|
||
|
||
expect(row.items.map((entry) => entry.id)).toEqual(["queued-item"]);
|
||
expect(row.allItems.map((entry) => entry.id)).toEqual(["extracted-item", "queued-item"]);
|
||
expect(getPackageProgress(row)).toEqual({ done: 1, failed: 0, cancelled: 0, total: 2, value: 50 });
|
||
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 100, total: 200, value: 50 });
|
||
});
|
||
|
||
it("resets a stale service filter when its last eligible provider disappears", () => {
|
||
const model = buildDownloadsViewModel(createInput({
|
||
packageOrder: ["visible-package", "extracted-package"],
|
||
packages: {
|
||
"visible-package": pkg("visible-package", "Wartend", ["visible-item"]),
|
||
"extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"])
|
||
},
|
||
items: {
|
||
"visible-item": item("visible-item", "visible-package", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
|
||
"extracted-item": item("extracted-item", "extracted-package", "completed", { provider: "alldebrid", providerLabel: "AllDebrid", fullStatus: "Entpackt" })
|
||
},
|
||
providerFilter: "alldebrid",
|
||
hideExtractedItems: true
|
||
}));
|
||
|
||
expect(model.providerFilter).toBe("all");
|
||
expect(model.visibleItemIds).toEqual(["visible-item"]);
|
||
expect(model.filteredEmpty).toBe(false);
|
||
});
|
||
|
||
it("keeps a hidden-only source queue clearable while presenting no visible entries", () => {
|
||
const input = createInput({
|
||
packageOrder: ["extracted-package"],
|
||
packages: { "extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"]) },
|
||
items: { "extracted-item": item("extracted-item", "extracted-package", "completed", { fullStatus: "Entpackt" }) },
|
||
hideExtractedItems: true
|
||
});
|
||
const model = buildDownloadsViewModel(input);
|
||
const runtimeModel = withRuntime(input);
|
||
const sidebar = renderToStaticMarkup(<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, 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, 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, total: 2, state: "offline" });
|
||
expect(getAvailabilitySummary([
|
||
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
|
||
])).toEqual({ online: 0, total: 1, state: "checking" });
|
||
expect(getAvailabilitySummary([
|
||
item("active-a", "package-a", "downloading", { onlineStatus: undefined })
|
||
])).toEqual({ online: 1, total: 1, state: "online" });
|
||
});
|
||
|
||
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(itemHtml).toContain("Online");
|
||
expect(itemHtml).toContain("downloads-availability is-online");
|
||
});
|
||
|
||
it("aligns availability symbols, split counts and labels on fixed axes", () => {
|
||
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*16px 4ch 1ch 3ch auto;[^}]*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");
|
||
});
|
||
});
|