release: publish Multi-Debrid Downloader v2.0.15

Synchronize live speed telemetry, preserve known package sizes, refine responsive download status presentation, improve progress contrast and accessibility, simplify account creation, and prepare the verified v2.0.15 desktop release.
This commit is contained in:
Sucukdeluxe
2026-08-10 20:38:11 +02:00
parent a5758aa905
commit b670b92147
20 changed files with 452 additions and 172 deletions
+36
View File
@@ -2,6 +2,42 @@
All notable changes to Multi-Debrid Downloader are documented in this file.
## [2.0.15] - 2026-08-10
### Highlights
- Unified live speed reporting across the header, package table, and sidebar with a single telemetry source and consistent two-decimal formatting.
- Reworked account creation into one clear service and access-type selector with the relevant credentials form directly below it.
- Improved dense download views with responsive status labels, clearer progress text, and safer package expansion behavior.
### Downloads and telemetry
- Removed the redundant Add links toolbar action so Start is now the first download control.
- Removed the additional renderer delay for large active queues, allowing manager telemetry to appear without a second buffering interval.
- Kept the header sparkline, package speeds, and sidebar speed synchronized from the same package telemetry snapshot.
- Preserved known file sizes when an unrestrict response does not provide a replacement size, preventing temporary queue-total drops when a download starts.
- Restricted package expansion and collapse to the visible disclosure button so ordinary row clicks no longer change the package state.
- Added compact window labels for link conversion, active downloads, and extraction while retaining complete status details in tooltips and accessibility labels.
- Removed duplicated access-mode wording from Mega-Debrid service labels while preserving the complete source label as a tooltip.
### Interface and accessibility
- Added clipped dual-color progress labels so text remains light over the unfilled track and dark over the green fill.
- Added a dedicated orange bandwidth-chart accent with a restrained matching area fill.
- Changed active premium account indicators to the success color.
- Kept the File, Settings, and Help menus inside the application frame at narrow window widths.
- Added semantic progressbar values and accessible labels to package and file size/progress meters.
### Settings and accounts
- Replaced the expandable account-type list with a single service/access selector.
- Displayed the selected account type description and exactly one matching credentials form.
- Retained the existing account validation, protected secret fields, and save flow.
### Reliability and testing
- Added regression coverage for stable file-size transitions, unified speed telemetry, responsive status presentation, service-label cleanup, progress contrast, package disclosure behavior, account selection, premium status colors, chart colors, toolbar ordering, and narrow application menus.
## [2.0.14] - 2026-08-10
### Highlights
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
"version": "2.0.14",
"version": "2.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
"version": "2.0.14",
"version": "2.0.15",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "2.0.14",
"version": "2.0.15",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
+3 -2
View File
@@ -68,6 +68,7 @@ import { logRenameEvent as writeRenameLogEvent } from "./rename-log";
import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerification } from "./desktop-rename-log";
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
import { mergeKnownTotalBytes } from "./download-size";
type ActiveTask = {
itemId: string;
@@ -9044,7 +9045,7 @@ export class DownloadManager extends EventEmitter {
? existingTargetPath
: path.join(pkg.outputDir, item.fileName);
item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget));
item.totalBytes = unrestricted.fileSize;
item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize);
item.status = "downloading";
const pLabel = unrestricted.providerLabel;
const statusLabel = providerLabel(unrestricted.provider) || pLabel;
@@ -9122,7 +9123,7 @@ export class DownloadManager extends EventEmitter {
item.status = "integrity_check";
item.progressPercent = 0;
item.downloadedBytes = 0;
item.totalBytes = unrestricted.fileSize;
item.totalBytes = mergeKnownTotalBytes(item.totalBytes, unrestricted.fileSize);
this.emitState();
await sleep(300);
continue;
+12
View File
@@ -0,0 +1,12 @@
export function mergeKnownTotalBytes(
currentTotalBytes: number | null | undefined,
replacementTotalBytes: number | null | undefined
): number | null {
if (replacementTotalBytes != null && Number.isFinite(replacementTotalBytes) && replacementTotalBytes > 0) {
return replacementTotalBytes;
}
if (currentTotalBytes != null && Number.isFinite(currentTotalBytes) && currentTotalBytes > 0) {
return currentTotalBytes;
}
return null;
}
+20 -37
View File
@@ -90,7 +90,7 @@ import {
StatisticsSidebarStatus,
type StatisticsViewActions
} from "./views/statistics/StatisticsView";
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
import {
@@ -1084,6 +1084,13 @@ const historyRetentionLabels: Record<AppSettings["historyRetentionMode"], string
const AUTO_RENDER_PACKAGE_LIMIT = 260;
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
let delay = itemCount >= 700 ? 0 : itemCount >= 250 ? 50 : 100;
if (!running) delay = Math.min(delay, 200);
if (activeTab !== "downloads") delay = Math.max(delay, 800);
return delay;
}
const KNOWN_HOSTERS: { id: string; label: string }[] = [
{ id: "rapidgator", label: "Rapidgator" },
{ id: "uploaded", label: "Uploaded" },
@@ -1304,7 +1311,7 @@ export function readBandwidthChartPalette(
return {
grid: readProperty("--ui-border").trim(),
text: readProperty("--ui-text-muted").trim(),
accent: readProperty("--ui-accent").trim(),
accent: readProperty("--ui-speed-accent").trim(),
fontFamily: fontFamily.trim()
};
}
@@ -1507,20 +1514,15 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
});
interface DownloadSpeedSparklineProps {
items: Record<string, DownloadItem>;
running: boolean;
paused: boolean;
speedBps: number;
speedStateRef: React.MutableRefObject<DownloadSpeedHistoryState>;
hidden?: boolean;
}
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, running, paused, speedStateRef, hidden = false }: DownloadSpeedSparklineProps): ReactElement {
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps, speedStateRef, hidden = false }: DownloadSpeedSparklineProps): ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null);
const itemsRef = useRef(items);
const activeRef = useRef(running && !paused);
const [liveSpeed, setLiveSpeed] = useState(0);
itemsRef.current = items;
activeRef.current = running && !paused;
const speedRef = useRef(speedBps);
speedRef.current = speedBps;
useEffect(() => {
const draw = (): void => {
@@ -1574,14 +1576,8 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
};
const tick = (): void => {
let target = 0;
if (activeRef.current) {
for (const it of Object.values(itemsRef.current)) {
if (it.status === "downloading") target += it.speedBps || 0;
}
}
const target = speedRef.current;
speedStateRef.current = updateDownloadSpeedHistory(speedStateRef.current, target);
setLiveSpeed(target);
draw();
};
@@ -1592,7 +1588,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, run
return (
<div className={`speed-sparkline${hidden ? " speed-sparkline-hidden" : ""}`} aria-hidden={hidden} title="Aktuelle Download-Geschwindigkeit (geglättet)">
<canvas ref={canvasRef} className="speed-sparkline-canvas" />
<span className="speed-sparkline-value">{liveSpeed > 0 ? formatSpeedMbps(liveSpeed) : "0 B/s"}</span>
<span className="speed-sparkline-value">{speedBps > 0 ? formatSpeedMbps(speedBps) : "0 B/s"}</span>
</div>
);
});
@@ -2166,19 +2162,7 @@ export function App(): ReactElement {
if (stateFlushTimerRef.current) { return; }
const itemCount = Object.keys(merged.session.items).length;
let flushDelay = itemCount >= 1500
? 900
: itemCount >= 700
? 650
: itemCount >= 250
? 400
: 150;
if (!merged.session.running) {
flushDelay = Math.min(flushDelay, 200);
}
if (activeTabRef.current !== "downloads") {
flushDelay = Math.max(flushDelay, 800);
}
const flushDelay = getSnapshotRenderDelay(itemCount, merged.session.running, activeTabRef.current);
stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null;
@@ -4961,6 +4945,7 @@ export function App(): ReactElement {
}, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]);
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
const liveDownloadSpeedBps = useMemo(() => getDownloadSpeedBps(snapshot.packageSpeedBps), [snapshot.packageSpeedBps]);
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
...downloadsViewCore,
@@ -4992,10 +4977,10 @@ export function App(): ReactElement {
total: humanSize(downloadQueueTotalBytes),
totalBytes: downloadQueueTotalBytes,
hosters: providerStats.length,
speed: snapshot.speedText,
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText
}
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.speedText, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const downloadsActions: DownloadsViewActions = {
onDisplayModeChange: setDownloadDisplayMode,
@@ -5651,9 +5636,7 @@ export function App(): ReactElement {
headerActions={(
<>
<DownloadSpeedSparkline
items={snapshot.session.items}
running={snapshot.session.running}
paused={snapshot.session.paused}
speedBps={liveDownloadSpeedBps}
speedStateRef={speedSparklineStateRef}
hidden={tab !== "downloads"}
/>
+4
View File
@@ -29,6 +29,10 @@ export function compactProviderLabels(labels: string[]): string {
return [...groups].map(([base, details]) => details.length === 0 ? base : `${base} (${details.join(" + ")})`).join(", ");
}
export function normalizeDownloadServiceLabel(label: string): string {
return label.replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1");
}
export function formatDateTime(timestamp: number): string {
if (!timestamp) return "";
const date = new Date(timestamp);
+1 -1
View File
@@ -604,7 +604,7 @@
overflow-y: auto;
}
.md-application-menu-tree .menu-bar-item:last-child > .menu-dropdown {
.md-application-menu-tree > .menu-bar-item > .menu-dropdown {
right: 0;
left: auto;
}
+6
View File
@@ -14,6 +14,9 @@
--ui-primary: #D6D6D6;
--ui-primary-hover: #E6E6E6;
--ui-accent: #4A4A4A;
--ui-speed-accent: #F2942D;
--ui-progress-track-text: #FFFFFF;
--ui-progress-fill-text: #181A1F;
--ui-success: #4ADE80;
--ui-warning: #F1C786;
--ui-danger: #F06464;
@@ -38,6 +41,9 @@
--ui-primary: #3A3A3A;
--ui-primary-hover: #202020;
--ui-accent: #5E5E5E;
--ui-speed-accent: #C2701A;
--ui-progress-track-text: #181A1F;
--ui-progress-fill-text: #181A1F;
--ui-success: #1E9E55;
--ui-warning: #E8B85D;
--ui-danger: #D94747;
+53 -18
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
import type { DownloadItem } from "../../../shared/types";
import {
compactProviderLabels,
@@ -8,6 +8,7 @@ import {
formatHosterLabel,
formatSpeedMbps,
humanSize,
normalizeDownloadServiceLabel,
providerLabels
} from "../../download-format";
import type { DownloadPackageRow } from "./downloads-model";
@@ -45,7 +46,7 @@ export const downloadColumnDefinitions: Record<string, { label: string; width: s
hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
status: { label: "Status", width: "minmax(90px, 0.85fr)" },
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
added: { label: "Hinzugefügt am", width: "minmax(135px, 1fr)" }
@@ -117,6 +118,35 @@ function progress(value: number): number {
return Math.max(0, Math.min(100, Math.round(value || 0)));
}
export function compactDownloadStatus(value: string): string {
const status = value.trim();
if (/Link wird umgewandelt/i.test(status)) return "Umwandeln";
if (/Download läuft\b/i.test(status)) return "DL läuft";
const extracting = status.match(/(Entpacken\s+\d+%)/i);
return extracting?.[1] ?? status;
}
function DownloadMeter({ value, text }: { value: number; text: string }): ReactElement {
const normalized = progress(value);
const style = { "--downloads-progress": `${normalized}%` } as CSSProperties;
return (
<span aria-label={text} aria-valuemax={100} aria-valuemin={0} aria-valuenow={normalized} className="downloads-meter" role="progressbar" style={style}>
<span className="downloads-meter-fill" />
<b aria-hidden="true" className="downloads-meter-label is-track">{text}</b>
<span aria-hidden="true" className="downloads-meter-filled-clip"><b className="downloads-meter-label is-filled">{text}</b></span>
</span>
);
}
function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement {
return (
<span aria-label={status} className="downloads-cell downloads-status-cell" title={title || status}>
<span aria-hidden="true" className="downloads-status-full">{status}</span>
<span aria-hidden="true" className="downloads-status-compact">{compactDownloadStatus(status)}</span>
</span>
);
}
function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null {
const displayStatus = displayedStatus(item, sessionRunning);
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
@@ -130,20 +160,24 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
if (column === "size") {
const total = item.totalBytes || item.downloadedBytes || 0;
const value = total > 0 ? progress((item.downloadedBytes / total) * 100) : 0;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(item.downloadedBytes)} / {humanSize(total)}</b></span> : null}</span>;
const text = `${humanSize(item.downloadedBytes)} / ${humanSize(total)}`;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <DownloadMeter text={text} value={value} /> : null}</span>;
}
if (column === "progress") {
const value = progress(item.progressPercent);
return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{value}%</b></span></span>;
return <span className="downloads-cell downloads-progress-cell"><DownloadMeter text={`${value}%`} value={value} /></span>;
}
if (column === "hoster") {
const hoster = extractHoster(item.url);
const label = formatHosterLabel(hoster);
return <HosterLabels labels={[label]} />;
}
if (column === "account") return <span className="downloads-cell">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
if (column === "account") {
const full = item.providerLabel || (item.provider ? providerLabels[item.provider] : "");
return <span className="downloads-cell" title={full}>{normalizeDownloadServiceLabel(full)}</span>;
}
if (column === "prio") return <span className="downloads-cell" />;
if (column === "status") return <span className="downloads-cell" title={statusTitle}>{displayStatus}</span>;
if (column === "status") return <DownloadStatusCell status={displayStatus} title={statusTitle} />;
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
if (column === "availability") {
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking";
@@ -227,12 +261,13 @@ interface PackageItemsTransitionProps {
collapsed: boolean;
columnOrder: readonly string[];
gridTemplate: string;
id: string;
items: DownloadItem[];
selectedIds: ReadonlySet<string>;
sessionRunning: boolean;
}
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, id, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
const [renderItems, setRenderItems] = useState(!collapsed);
const animationRef = useRef<Animation | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -278,6 +313,7 @@ function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate,
<div
aria-hidden={collapsed}
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
id={id}
ref={containerRef}
>
<div className="downloads-package-items-inner" ref={innerRef}>
@@ -327,7 +363,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
if (column === "name") {
return (
<span className="downloads-cell downloads-name-cell">
<button aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : ""}</button>
<button aria-controls={`downloads-package-items-${entry.id}`} aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : ""}</button>
{editing
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
@@ -347,21 +383,24 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
const total = row.items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
const downloaded = row.items.reduce((sum, item) => sum + item.downloadedBytes, 0);
const value = total > 0 ? progress((downloaded / total) * 100) : 0;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(downloaded)} / {humanSize(total)}</b></span> : null}</span>;
const text = `${humanSize(downloaded)} / ${humanSize(total)}`;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <DownloadMeter text={text} value={value} /> : null}</span>;
}
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${stats.value}%` }} /><b>{stats.value}%</b></span></span>;
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><DownloadMeter text={`${stats.value}%`} value={stats.value} /></span>;
if (column === "hoster") {
const labels = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].map(formatHosterLabel);
return <HosterLabels labels={labels} />;
}
if (column === "account") {
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
return <span className="downloads-cell" title={value}>{value}</span>;
const full = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
return <span className="downloads-cell" title={full}>{normalizeDownloadServiceLabel(full)}</span>;
}
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
if (column === "status") {
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
return <span className="downloads-cell" title={audio?.tooltip}>{stats.done}/{stats.total}{stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}{stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}{entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}{audio ? ` · ${audio.text}` : ""}</span>;
const status = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
const title = audio?.tooltip ? `${status}\n${audio.tooltip}` : status;
return <DownloadStatusCell status={status} title={title} />;
}
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
if (column === "availability") {
@@ -418,13 +457,9 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
role="row"
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
actions.onToggleSelection(entry.id, event.ctrlKey || event.metaKey, event.shiftKey);
return;
}
if (target.closest("button, input, select")) return;
actions.onTogglePackageCollapse(entry.id);
}}
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
@@ -433,7 +468,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button"></button></span>
</div>
<PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} />
<PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} id={`downloads-package-items-${entry.id}`} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} />
</article>
);
}
@@ -119,8 +119,6 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button className="ui-primary-button" onClick={actions.onAddLinks} type="button">Links hinzufügen</button>
<span className="downloads-toolbar-divider" />
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
@@ -83,6 +83,14 @@ export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): numbe
return total;
}
export function getDownloadSpeedBps(packageSpeeds: Record<string, number>): number {
let total = 0;
for (const speed of Object.values(packageSpeeds)) {
if (Number.isFinite(speed) && speed > 0) total += speed;
}
return total;
}
function isExtracted(item: DownloadItem): boolean {
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
}
+38 -5
View File
@@ -621,14 +621,16 @@
background: var(--ui-modal-secondary);
}
.downloads-meter > span {
.downloads-meter-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--downloads-progress);
background: var(--ui-success);
}
.downloads-meter > b {
position: relative;
.downloads-meter-label {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
@@ -638,8 +640,39 @@
font-weight: 600;
}
.downloads-item-row .downloads-meter > b {
color: var(--ui-text);
.downloads-meter-label.is-track {
color: var(--ui-progress-track-text, #fff);
}
.downloads-meter-filled-clip {
position: absolute;
inset: 0;
z-index: 2;
clip-path: inset(0 calc(100% - var(--downloads-progress)) 0 0);
pointer-events: none;
}
.downloads-meter-label.is-filled {
color: var(--ui-progress-fill-text, #171a1f);
}
.downloads-status-compact {
display: none;
}
.md-shell.is-compact .downloads-view,
.md-shell.is-minimum .downloads-view {
--downloads-status-min: 90px;
}
.md-shell.is-compact .downloads-status-full,
.md-shell.is-minimum .downloads-status-full {
display: none;
}
.md-shell.is-compact .downloads-status-compact,
.md-shell.is-minimum .downloads-status-compact {
display: inline;
}
.downloads-empty-state,
@@ -1,6 +1,5 @@
import {
cloneElement,
type ChangeEvent,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
@@ -482,9 +481,7 @@ export function AccountAddDialog({
model: AccountAddDialogModel;
actions: AccountAddDialogActions;
}): ReactElement | null {
const onFilterChange = (event: ChangeEvent<HTMLSelectElement>): void => {
actions.onFilterChange(event.target.value as AccountAddFilter);
};
const selectedOption = model.options.find((option) => option.id === model.selectedOptionId);
return (
<Dialog
actions={(
@@ -501,49 +498,34 @@ export function AccountAddDialog({
size="account"
title="Account hinzufügen"
>
<div className="settings-account-picker-controls">
<input
aria-label="Accounts durchsuchen"
<label className="settings-account-picker-selector">
<span>Dienst / Zugangstyp</span>
<select
aria-label="Dienst / Zugangstyp"
className="settings-control"
onChange={(event) => actions.onQueryChange(event.target.value)}
placeholder="Dienst oder Zugangstyp suchen"
type="search"
value={model.query}
/>
<select aria-label="Account-Typ filtern" className="settings-control" onChange={onFilterChange} value={model.filter}>
<option value="all">Alle</option>
<option value="api">API</option>
<option value="web">Web</option>
</select>
</div>
<div aria-label="Verfügbare Account-Typen" className="settings-account-picker" role="listbox">
{model.options.length === 0 ? (
<span className="settings-account-picker-empty">Keine passenden Account-Typen.</span>
) : model.options.map((option) => {
const selected = option.id === model.selectedOptionId;
return (
<div className="settings-account-picker-entry" key={option.id}>
<button
aria-selected={selected}
className={`settings-account-picker-row${selected ? " is-selected" : ""}`}
onClick={() => actions.onOptionSelect(option.id)}
role="option"
type="button"
onChange={(event) => actions.onOptionSelect(event.target.value)}
value={model.selectedOptionId ?? ""}
>
<span>
<strong>{option.title}</strong>
<small>{option.description}</small>
</span>
<span>
<strong>{option.mode}</strong>
<small>{option.functionLabel}</small>
</span>
</button>
{selected ? <AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} /> : null}
{model.options.map((option) => (
<option key={option.id} value={option.id}>{option.title} · {option.mode}</option>
))}
</select>
</label>
{selectedOption ? (
<>
<div className="settings-account-option-meta">
<div>
<strong>{selectedOption.title}</strong>
<span>{selectedOption.description}</span>
</div>
);
})}
<div>
<strong>{selectedOption.mode}</strong>
<span>{selectedOption.functionLabel}</span>
</div>
</div>
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
</>
) : null}
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
</Dialog>
);
+13 -37
View File
@@ -589,7 +589,7 @@
}
.settings-account-status-badge.is-ok::before {
background: var(--ui-primary);
background: var(--ui-success);
}
.settings-account-status-badge.is-free::before,
@@ -739,52 +739,36 @@
min-height: 0;
}
.settings-account-picker-controls {
.settings-account-picker-selector {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 8px;
gap: 7px;
}
.settings-account-picker {
display: grid;
max-height: 430px;
min-height: 0;
gap: 6px;
overflow-y: auto;
.settings-account-picker-selector > span {
color: var(--ui-text-secondary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.settings-account-picker-entry {
display: grid;
gap: 10px;
}
.settings-account-picker-row {
.settings-account-option-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
gap: 12px;
width: 100%;
min-height: 52px;
padding: 8px 10px;
padding: 10px 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
font: inherit;
text-align: left;
}
.settings-account-picker-row.is-selected {
border-color: var(--ui-accent);
background: var(--ui-active);
}
.settings-account-picker-row > span {
.settings-account-option-meta > div {
display: grid;
min-width: 0;
gap: 2px;
}
.settings-account-picker-row small {
.settings-account-option-meta span {
overflow: hidden;
color: var(--ui-text-muted);
font-size: 12px;
@@ -792,16 +776,9 @@
white-space: nowrap;
}
.settings-account-picker-empty {
padding: 24px;
color: var(--ui-text-muted);
text-align: center;
}
.settings-account-dialog-fields {
display: grid;
gap: 14px;
padding: 2px 10px 14px;
}
.settings-account-dialog-field {
@@ -873,8 +850,7 @@
}
.settings-theme-options,
.settings-account-picker-controls,
.settings-account-picker-row {
.settings-account-option-meta {
grid-template-columns: minmax(0, 1fr);
}
}
+12
View File
@@ -5,8 +5,14 @@ import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/A
import { AppHeader } from "../src/renderer/shell/AppHeader";
import { AppShell } from "../src/renderer/shell/AppShell";
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
import { getSnapshotRenderDelay } from "../src/renderer/App";
describe("desktop shell", () => {
it("does not stack renderer latency on the manager cadence for large active queues", () => {
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800);
});
it("keeps application menus mounted for animated opening and closing", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
@@ -18,6 +24,12 @@ describe("desktop shell", () => {
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-application-menu-tree \.menu-dropdown\s*\{[^}]*transition-duration:\s*150ms, 180ms, 0s !important;/);
});
it("anchors every top-level application dropdown to the right header edge", () => {
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(shellCss).toMatch(/\.md-application-menu-tree > \.menu-bar-item > \.menu-dropdown\s*\{[^}]*right:\s*0;[^}]*left:\s*auto;/s);
});
it("uses the product asset in the header brand", () => {
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
expect(html).toContain("Multi-Debrid Downloader");
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { mergeKnownTotalBytes } from "../src/main/download-size";
describe("download size transitions", () => {
it("preserves a known metadata size when unrestrict has no replacement", () => {
expect(mergeKnownTotalBytes(2_000, null)).toBe(2_000);
expect(mergeKnownTotalBytes(2_000, 0)).toBe(2_000);
});
it("accepts a positive replacement size", () => {
expect(mergeKnownTotalBytes(2_000, 2_500)).toBe(2_500);
expect(mergeKnownTotalBytes(null, 2_500)).toBe(2_500);
});
it("applies the guarded merge to every unrestrict size transition", () => {
const source = readFileSync(new URL("../src/main/download-manager.ts", import.meta.url), "utf8");
expect(source).not.toContain("item.totalBytes = unrestricted.fileSize");
expect(source.match(/item\.totalBytes = mergeKnownTotalBytes\(item\.totalBytes, unrestricted\.fileSize\)/g)).toHaveLength(2);
});
});
+123 -6
View File
@@ -10,6 +10,7 @@ import {
buildDownloadsViewModel,
classifyDownloadStatus,
getDownloadQueueTotalBytes,
getDownloadSpeedBps,
type DownloadSidebarFilter,
type DownloadsModelInput
} from "../src/renderer/views/downloads/downloads-model";
@@ -28,9 +29,11 @@ import {
PackageCardContent,
areItemRowPropsEqual,
arePackageCardPropsEqual,
compactDownloadStatus,
downloadColumnDefinitions,
getAvailabilitySummary
} from "../src/renderer/views/downloads/DownloadsTable";
import { normalizeDownloadServiceLabel } from "../src/renderer/download-format";
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
@@ -122,6 +125,21 @@ describe("Download-Gesamtgröße", () => {
});
});
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)")).toBe("DL läuft");
expect(compactDownloadStatus("Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken 1%");
expect(compactDownloadStatus("0/11 · Entpacken 1% (1/1) · Tonspur: Deutsch")).toBe("Entpacken 1%");
});
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("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
});
});
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
const items = [
item("active", "package-a", "downloading"),
@@ -265,6 +283,11 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
}
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";
@@ -487,10 +510,9 @@ describe("downloads view", () => {
expect(calls).toEqual(["start:package-a", "drop:package-b", "end"]);
});
it("dispatches add, start, pause, stop, scheduling and selection actions through separate existing callbacks", () => {
it("starts with the local Start action and dispatches toolbar actions separately", () => {
const calls: string[] = [];
const actions = createActions({
onAddLinks: () => calls.push("add"),
onStartDownloads: () => calls.push("start"),
onPauseDownloads: () => calls.push("pause"),
onStopDownloads: () => calls.push("stop"),
@@ -505,11 +527,14 @@ describe("downloads view", () => {
model: withRuntime(createInput({ selectedIds: ["active"] }), { scheduleOpen: true })
});
for (const label of ["Links hinzufügen", "Start", "Pause", "Stop", "Planen", "Nach oben", "Nach unten", "Umbenennen", "Entfernen"]) {
for (const label of ["Start", "Pause", "Stop", "Planen", "Nach oben", "Nach unten", "Umbenennen", "Entfernen"]) {
findButton(toolbar, label).props.onClick();
}
expect(calls).toEqual(["add", "start", "pause", "stop", "schedule", "up", "down", "rename", "remove"]);
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", () => {
@@ -596,7 +621,8 @@ describe("downloads view", () => {
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
expect(css).toMatch(/\.downloads-item-row\s+\.downloads-meter\s*>\s*b\s*\{[^}]*color:\s*var\(--ui-text\);/s);
expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*color:\s*var\(--ui-progress-track-text/s);
expect(css).toMatch(/\.downloads-meter-label\.is-filled\s*\{[^}]*color:\s*var\(--ui-progress-fill-text/s);
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
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);
@@ -739,7 +765,42 @@ describe("download table row contracts", () => {
selectedVersion: 0
}));
expect(html).toContain("<b>70%</b>");
expect(html).toContain(">70%</b>");
});
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 (Mega-Debrid)"');
expect(html).toContain('class="downloads-status-full"');
expect(html).toContain('class="downloads-status-compact"');
expect(html).toContain("DL läuft");
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
expect(html).toContain(">Mega-Debrid Web</span>");
});
it("sets the whole visible selection atomically from the header checkbox", () => {
@@ -818,6 +879,62 @@ describe("download table row contracts", () => {
expect(calls).toEqual(["select"]);
});
it("only collapses a package through its visible disclosure button", () => {
const calls: string[] = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({ 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);
const collapseButton = findElement(component, (element) => element.type === "button" && String(element.props.className || "").includes("downloads-collapse-button"));
packageRow.props.onClick({ ctrlKey: false, metaKey: false, shiftKey: false, target: {}, currentTarget: {} });
collapseButton.props.onClick({ stopPropagation: () => {} });
expect(calls).toEqual(["collapse"]);
expect(collapseButton.props["aria-expanded"]).toBe(true);
expect(collapseButton.props["aria-controls"]).toBe(`downloads-package-items-${row.package.id}`);
});
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")], 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("commits Enter and the resulting Blur rename sequence exactly once", () => {
const commits: string[] = [];
const model = withRuntime(createInput());
+45 -4
View File
@@ -555,6 +555,7 @@ describe("account workspace", () => {
});
it("keeps add and edit dialogs separate and every secret field protected", () => {
const options = accountOptions();
const addHtml = renderToStaticMarkup(
<AccountAddDialog
actions={{
@@ -569,7 +570,7 @@ describe("account workspace", () => {
open: true,
query: "",
filter: "all",
options: accountOptions(),
options,
selectedOptionId: "megadebrid-api",
fields: [
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
@@ -609,9 +610,19 @@ describe("account workspace", () => {
expect(addHtml).toContain("Account hinzufügen");
expect(addHtml).toContain("Prüfen und speichern");
expect(addHtml).toContain("Alle");
expect(addHtml).toContain("API");
expect(addHtml).toContain("Web");
expect(count(addHtml, "<select")).toBe(1);
expect(addHtml).toContain('aria-label="Dienst / Zugangstyp"');
expect(count(addHtml, "<option")).toBe(options.length);
options.forEach((option) => expect(addHtml).toContain(`value="${option.id}"`));
expect(addHtml).toContain('<option value="megadebrid-api" selected="">Mega-Debrid · API</option>');
expect(addHtml).toContain("Weiteren Account hinzufügen");
expect(addHtml).toContain("Login:Passwort");
expect(addHtml).not.toContain('type="search"');
expect(addHtml).not.toContain("Account-Typ filtern");
expect(addHtml).not.toContain("settings-account-picker-row");
expect(count(addHtml, 'class="settings-account-dialog-fields"')).toBe(1);
expect(addHtml.indexOf('aria-label="Dienst / Zugangstyp"')).toBeLessThan(addHtml.indexOf("settings-account-option-meta"));
expect(addHtml.indexOf("settings-account-option-meta")).toBeLessThan(addHtml.indexOf("settings-account-dialog-fields"));
expect(editHtml).toContain("Account bearbeiten");
expect(editHtml).toContain("member@example.test");
expect(editHtml).toContain("Entfernen");
@@ -619,6 +630,35 @@ describe("account workspace", () => {
expect(count(addHtml, "type=\"password\"")).toBe(1);
expect(count(editHtml, "type=\"password\"")).toBe(2);
});
it("selects the account option through the single service selector", () => {
const selected: string[] = [];
const tree = AccountAddDialog({
actions: {
onQueryChange: () => {},
onFilterChange: () => {},
onOptionSelect: (optionId) => selected.push(optionId),
onFieldChange: () => {},
onClose: () => {},
onSubmit: () => {}
},
model: {
open: true,
query: "",
filter: "all",
options: accountOptions(),
selectedOptionId: "megadebrid-api",
fields: [],
error: "",
busy: false
}
});
const selector = findElement(tree, (element) => element.type === "select" && element.props["aria-label"] === "Dienst / Zugangstyp");
selector.props.onChange({ target: { value: "debridlink-api" } });
expect(selected).toEqual(["debridlink-api"]);
});
});
describe("settings App integration", () => {
@@ -662,6 +702,7 @@ describe("settings geometry", () => {
expect(css).toMatch(/\.settings-account-table-grid\s*{[^}]*color:\s*var\(--ui-text\);/s);
expect(css).toMatch(/\.settings-account-row\s*{[^}]*height:\s*48px;/s);
expect(css).toMatch(/\.settings-account-table-body\s*{[^}]*overflow:\s*auto;/s);
expect(css).toMatch(/\.settings-account-status-badge\.is-ok::before\s*{[^}]*background:\s*var\(--ui-success\);/s);
expect(accountWorkspaceSource).toContain("onScroll={syncAccountTableScroll}");
expect(css).toMatch(/\.settings-view\s*{[^}]*min-width:\s*0;/s);
expect(css).toMatch(/\.settings-account-workspace\s*{[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s);
+17 -3
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
@@ -384,12 +385,25 @@ describe("statistics view", () => {
});
describe("bandwidth chart palette", () => {
it("defines semantic speed and progress text colors for both themes", () => {
const css = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
const dark = css.match(/:root,\s*:root\[data-theme="dark"\]\s*\{([\s\S]*?)\}/)?.[1];
const light = css.match(/:root\[data-theme="light"\]\s*\{([\s\S]*?)\}/)?.[1];
expect(dark).toContain("--ui-speed-accent: #F2942D;");
expect(dark).toContain("--ui-progress-track-text: #FFFFFF;");
expect(dark).toContain("--ui-progress-fill-text: #181A1F;");
expect(light).toContain("--ui-speed-accent: #C2701A;");
expect(light).toContain("--ui-progress-track-text: #181A1F;");
expect(light).toContain("--ui-progress-fill-text: #181A1F;");
});
it("requests only the semantic UI color properties and keeps the computed font family", () => {
const requested: string[] = [];
const values: Record<string, string> = {
"--ui-border": " rgb(61, 61, 61) ",
"--ui-text-muted": " rgb(145, 145, 145) ",
"--ui-accent": " rgb(56, 134, 255) "
"--ui-speed-accent": " rgb(242, 148, 45) "
};
const palette = readBandwidthChartPalette((property) => {
@@ -397,11 +411,11 @@ describe("bandwidth chart palette", () => {
return values[property];
}, "Inter, Segoe UI, sans-serif");
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-accent"]);
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-speed-accent"]);
expect(palette).toEqual({
grid: "rgb(61, 61, 61)",
text: "rgb(145, 145, 145)",
accent: "rgb(56, 134, 255)",
accent: "rgb(242, 148, 45)",
fontFamily: "Inter, Segoe UI, sans-serif"
});
});