fix: polish download status and application menus

Animate nested application menus while keeping hidden actions out of keyboard navigation and respecting reduced-motion preferences.

Normalize Mega-Debrid service labels, simplify active download and extraction statuses, and preserve full diagnostics in tooltips.

Brand the Windows development runtime and isolate concurrent launchers with PID-aware cleanup and regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-10 23:33:34 +02:00
parent 7dce19119f
commit 9b1eeada2e
11 changed files with 338 additions and 44 deletions
+18
View File
@@ -2,6 +2,24 @@
All notable changes to Multi-Debrid Downloader are documented in this file.
## [2.0.17] - 2026-08-10
### Interface fixes
- Added smooth horizontal opening and closing transitions to the Backup, Logs, Remote Support, and Diagnostics submenus.
- Kept nested menus mounted during closing so their exit motion remains visible instead of disappearing immediately.
- Preserved left-side submenu placement in narrow windows without restoring horizontal overflow.
- Removed hidden submenu actions from keyboard navigation and respected reduced-motion preferences.
- Simplified Mega-Debrid API service labels by removing redundant access-mode suffixes while retaining the complete source label as a tooltip.
- Reduced download and extraction status cells to the active operation and percentage while keeping diagnostic details in tooltips.
### Reliability and testing
- Branded the development Electron executable with the application name, version metadata, and product icon for Windows system dialogs.
- Made development launches resilient to stale executable locks by isolating each runtime executable.
- Added regression coverage for persistent nested-menu rendering, hidden interaction states, and the shared submenu transition.
- Added a Windows regression check for development executable metadata.
## [2.0.16] - 2026-08-10
### Downloads and telemetry
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
"version": "2.0.16",
"version": "2.0.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
"version": "2.0.16",
"version": "2.0.17",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "2.0.16",
"version": "2.0.17",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
@@ -10,7 +10,7 @@
"dev:renderer": "vite --port 5180 --strictPort",
"visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort",
"dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch",
"dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 electron .",
"dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 tsx scripts/run-dev-electron.ts",
"build": "npm run build:main && npm run build:renderer",
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
"build:renderer": "vite build",
+114
View File
@@ -0,0 +1,114 @@
import { execFile, spawn } from "node:child_process";
import { copyFile, mkdir, readFile, readdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import { createRequire } from "node:module";
import { promisify } from "node:util";
const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile);
const productName = "Multi-Debrid-Downloader";
const executableName = `${productName}.exe`;
async function brandExecutable(executable: string, icon: string, version: string): Promise<void> {
const editorName = process.arch === "x64" ? "rcedit-x64.exe" : "rcedit.exe";
const editor = path.resolve("node_modules", "rcedit", "bin", editorName);
await execFileAsync(editor, [
executable,
"--set-file-version", version,
"--set-product-version", version,
"--set-icon", icon,
"--set-version-string", "CompanyName", "Sucukdeluxe",
"--set-version-string", "FileDescription", productName,
"--set-version-string", "InternalName", productName,
"--set-version-string", "LegalCopyright", "Copyright © 2026 Sucukdeluxe",
"--set-version-string", "OriginalFilename", executableName,
"--set-version-string", "ProductName", productName
]);
}
export async function prepareDevElectron(source: string, target: string, icon: string, version: string): Promise<string> {
await mkdir(path.dirname(target), { recursive: true });
const temporaryTarget = `${target}.${process.pid}.tmp.exe`;
await rm(temporaryTarget, { force: true });
await copyFile(source, temporaryTarget);
await brandExecutable(temporaryTarget, icon, version);
await rm(target, { force: true });
await rename(temporaryTarget, target);
return target;
}
async function readVersion(): Promise<string> {
const packageJson = JSON.parse(await readFile(path.resolve("package.json"), "utf8")) as { version: string };
return packageJson.version;
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
async function removeStaleDevExecutables(directory: string, activeTarget: string): Promise<void> {
const names = await readdir(directory).catch(() => []);
await Promise.all(names
.filter((name) => name === executableName || (name.startsWith(`${productName}-dev-`) && name.endsWith(".exe")))
.map(async (name) => {
const candidate = path.join(directory, name);
const pid = Number(name.match(/^Multi-Debrid-Downloader-dev-(\d+)\.exe(?:\.\d+\.tmp\.exe)?$/)?.[1] || 0);
if (candidate !== activeTarget && !isProcessRunning(pid)) {
await rm(candidate, { force: true }).catch(() => undefined);
}
}));
}
async function run(): Promise<void> {
const args = process.argv.slice(2);
if (args[0] === "--prepare-only") {
const [, source, target, icon, version] = args;
if (!source || !target || !icon || !version) {
process.exitCode = 2;
return;
}
await prepareDevElectron(source, target, icon, version);
return;
}
if (args[0] === "--cleanup-only") {
const [, directory, activeTarget] = args;
if (!directory || !activeTarget) {
process.exitCode = 2;
return;
}
await removeStaleDevExecutables(directory, activeTarget);
return;
}
const source = require("electron") as string;
const directory = path.dirname(source);
const target = path.join(directory, `${productName}-dev-${process.pid}.exe`);
const icon = path.resolve("assets/app_icon.ico");
const version = await readVersion();
await removeStaleDevExecutables(directory, target);
await prepareDevElectron(source, target, icon, version);
const child = spawn(target, ["."], { stdio: "inherit", windowsHide: false });
child.on("close", async (code, signal) => {
await rm(target, { force: true }).catch(() => undefined);
process.exitCode = code ?? (signal ? 1 : 0);
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
if (!child.killed) {
child.kill(signal);
}
});
}
}
void run().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`);
process.exitCode = 1;
});
+33 -25
View File
@@ -5679,15 +5679,17 @@ export function App(): ReactElement {
onMouseLeave={() => setOpenSubmenu(null)}
>
<button className="menu-submenu-trigger">Sicherung</button>
{openSubmenu === "sicherung" && (
<div className="menu-submenu-dropdown">
<div
aria-hidden={openSubmenu !== "sicherung"}
{...(openSubmenu !== "sicherung" ? { inert: "" } : {})}
className={`menu-submenu-dropdown${openSubmenu === "sicherung" ? " is-open" : ""}`}
>
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button>
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button>
<div className="menu-separator" />
<button className="menu-dropdown-item" onClick={() => { void onCreateOnlineBackup(); }}>Online-Schlüssel exportieren</button>
<button className="menu-dropdown-item" onClick={onOpenOnlineBackupImport}>Online-Schlüssel importieren</button>
</div>
)}
</div>
</div>
<div className="menu-separator" />
<button className="menu-dropdown-item" onClick={onMenuRestart}>
@@ -5827,15 +5829,17 @@ export function App(): ReactElement {
onMouseLeave={() => setOpenSubmenu(null)}
>
<button className="menu-submenu-trigger">Logs öffnen</button>
{openSubmenu === "hilfe-log" && (
<div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}><span>Haupt-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}><span>Audit-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openRenameLog().catch(() => {}); }}><span>Rename-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}><span>Session-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openTraceLog().catch(() => {}); }}><span>Trace-Log</span></button>
</div>
)}
<div
aria-hidden={openSubmenu !== "hilfe-log"}
{...(openSubmenu !== "hilfe-log" ? { inert: "" } : {})}
className={`menu-submenu-dropdown${openSubmenu === "hilfe-log" ? " is-open" : ""}`}
>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}><span>Haupt-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}><span>Audit-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openRenameLog().catch(() => {}); }}><span>Rename-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}><span>Session-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openTraceLog().catch(() => {}); }}><span>Trace-Log</span></button>
</div>
</div>
<div
className="menu-submenu"
@@ -5843,13 +5847,15 @@ export function App(): ReactElement {
onMouseLeave={() => setOpenSubmenu(null)}
>
<button className="menu-submenu-trigger">Remote-Support</button>
{openSubmenu === "hilfe-remote" && (
<div className="menu-submenu-dropdown">
<div
aria-hidden={openSubmenu !== "hilfe-remote"}
{...(openSubmenu !== "hilfe-remote" ? { inert: "" } : {})}
className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`}
>
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose </span></button>
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
</div>
)}
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
</div>
</div>
<div className="menu-separator" />
<button className="menu-dropdown-item" onClick={() => { void onShowRecentErrors(); }}>
@@ -5861,12 +5867,14 @@ export function App(): ReactElement {
onMouseLeave={() => setOpenSubmenu(null)}
>
<button className="menu-submenu-trigger">Diagnose</button>
{openSubmenu === "hilfe-diagnose" && (
<div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { void onRunDebugSetupCheck(); }}><span>Debug-Setup prüfen</span></button>
<button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}><span>Debug-Token rotieren</span></button>
</div>
)}
<div
aria-hidden={openSubmenu !== "hilfe-diagnose"}
{...(openSubmenu !== "hilfe-diagnose" ? { inert: "" } : {})}
className={`menu-submenu-dropdown${openSubmenu === "hilfe-diagnose" ? " is-open" : ""}`}
>
<button className="menu-dropdown-item" onClick={() => { void onRunDebugSetupCheck(); }}><span>Debug-Setup prüfen</span></button>
<button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}><span>Debug-Token rotieren</span></button>
</div>
</div>
<div className="menu-separator" />
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
+1 -1
View File
@@ -30,7 +30,7 @@ export function compactProviderLabels(labels: string[]): string {
}
export function normalizeDownloadServiceLabel(label: string): string {
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1")).filter(Boolean))].join(", ");
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/^(Mega-Debrid)\s+(Web|API)(?:\s+\([^)]*\))?$/i, "$1 $2")).filter(Boolean))].join(", ");
}
export function compactDownloadServiceLabel(label: string): string {
+25
View File
@@ -612,6 +612,22 @@
.md-application-menu-tree .menu-submenu-dropdown {
right: 100%;
left: auto;
opacity: 0;
transform: translateX(8px);
visibility: hidden;
pointer-events: none;
transition:
opacity 160ms ease,
transform 220ms cubic-bezier(0.22, 0.76, 0.22, 1),
visibility 0s linear 220ms;
}
.md-application-menu-tree .menu-submenu-dropdown.is-open {
opacity: 1;
transform: translateX(0);
visibility: visible;
pointer-events: auto;
transition-delay: 0s;
}
.md-overlay-host {
@@ -909,4 +925,13 @@
.md-application-menu-tree .menu-dropdown.is-open {
transition-delay: 0s !important;
}
.md-application-menu-tree .menu-submenu-dropdown {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
.md-application-menu-tree .menu-submenu-dropdown.is-open {
transition-delay: 0s !important;
}
}
@@ -122,9 +122,13 @@ function progress(value: number): number {
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;
if (/Download läuft\b/i.test(status)) return "Download läuft";
if (/Download running\b/i.test(status)) return "Download running";
const extracting = status.match(/Entpacken\s+(\d+)%/i);
if (extracting) return `Entpacken - ${extracting[1]}%`;
const extractingEnglish = status.match(/Extracting\s+(\d+)%/i);
if (extractingEnglish) return `Extracting - ${extractingEnglish[1]}%`;
return status;
}
function DownloadMeter({ value, text }: { value: number; text: string }): ReactElement {
@@ -140,10 +144,11 @@ function DownloadMeter({ value, text }: { value: number; text: string }): ReactE
}
function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement {
const visibleStatus = compactDownloadStatus(status);
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 aria-label={visibleStatus} className="downloads-cell downloads-status-cell" title={title || status}>
<span aria-hidden="true" className="downloads-status-full">{visibleStatus}</span>
<span aria-hidden="true" className="downloads-status-compact">{visibleStatus}</span>
</span>
);
}
@@ -410,8 +415,12 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
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;
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;
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating");
const status = entry.postProcessLabel && /Entpacken\s+\d+%/i.test(entry.postProcessLabel)
? entry.postProcessLabel
: downloading ? "Download läuft" : details;
const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details;
return <DownloadStatusCell status={status} title={title} />;
}
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
+8 -1
View File
@@ -31,10 +31,17 @@ describe("desktop shell", () => {
});
it("keeps application submenus visible inside narrow right-aligned windows", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(source.match(/aria-hidden=\{openSubmenu !==/g)).toHaveLength(4);
expect(source.match(/inert: ""/g)).toHaveLength(4);
expect(source.match(/menu-submenu-dropdown\$\{openSubmenu ===/g)).toHaveLength(4);
expect(source).not.toMatch(/\{openSubmenu === "(?:sicherung|hilfe-log|hilfe-remote|hilfe-diagnose)" && \(/);
expect(shellCss).toMatch(/\.md-application-menu-tree :where\(\.menu-dropdown, \.menu-submenu-dropdown\)\s*\{[^}]*overflow:\s*visible;/s);
expect(shellCss).toMatch(/\.md-application-menu-tree \.menu-submenu-dropdown\s*\{[^}]*right:\s*100%;[^}]*left:\s*auto;/s);
expect(shellCss).toMatch(/\.md-application-menu-tree \.menu-submenu-dropdown\s*\{[^}]*right:\s*100%;[^}]*left:\s*auto;[^}]*opacity:\s*0;[^}]*transform:\s*translateX\(8px\);[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;[^}]*transition:[^}]*transform 220ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\)/s);
expect(shellCss).toMatch(/\.md-application-menu-tree \.menu-submenu-dropdown\.is-open\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*translateX\(0\);[^}]*visibility:\s*visible;[^}]*pointer-events:\s*auto;/s);
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-application-menu-tree \.menu-submenu-dropdown\s*\{[^}]*transition-duration:\s*0\.01ms !important;[^}]*transition-delay:\s*0s !important;/);
});
it("uses the product asset in the header brand", () => {
+91
View File
@@ -0,0 +1,91 @@
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { force: true, recursive: true });
}
});
describe("development Electron branding", () => {
it("uses a unique executable for each development launcher", () => {
const source = readFileSync(path.resolve("scripts/run-dev-electron.ts"), "utf8");
expect(source).toContain("`${productName}-dev-${process.pid}.exe`");
expect(source).not.toContain("path.join(path.dirname(source), executableName)");
expect(source).toContain("process.kill(pid, 0)");
});
it.runIf(process.platform === "win32")("prepares a branded development executable", () => {
const root = mkdtempSync(path.join(tmpdir(), "mdd-dev-electron-"));
roots.push(root);
const source = path.resolve("node_modules/electron/dist/electron.exe");
const target = path.join(root, "Multi-Debrid-Downloader.exe");
const icon = path.resolve("assets/app_icon.ico");
const result = spawnSync(
process.execPath,
[
path.resolve("node_modules/tsx/dist/cli.mjs"),
"scripts/run-dev-electron.ts",
"--prepare-only",
source,
target,
icon,
"2.0.17"
],
{ cwd: path.resolve("."), encoding: "utf8" }
);
expect(result.status, result.stderr || result.stdout).toBe(0);
const metadata = JSON.parse(execFileSync(
"powershell.exe",
[
"-NoProfile",
"-Command",
"$v=(Get-Item -LiteralPath $env:MDD_TEST_EXE).VersionInfo; [pscustomobject]@{FileDescription=$v.FileDescription;ProductName=$v.ProductName;OriginalFilename=$v.OriginalFilename;FileVersion=$v.FileVersion;ProductVersion=$v.ProductVersion}|ConvertTo-Json -Compress"
],
{ encoding: "utf8", env: { ...process.env, MDD_TEST_EXE: target } }
));
expect(metadata).toEqual({
FileDescription: "Multi-Debrid-Downloader",
ProductName: "Multi-Debrid-Downloader",
OriginalFilename: "Multi-Debrid-Downloader.exe",
FileVersion: "2.0.17",
ProductVersion: "2.0.17"
});
});
it("preserves executables owned by active launchers while removing stale ones", () => {
const root = mkdtempSync(path.join(tmpdir(), "mdd-dev-cleanup-"));
roots.push(root);
const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
const activeTarget = path.join(root, `Multi-Debrid-Downloader-dev-${process.pid}.exe`);
const liveTarget = path.join(root, `Multi-Debrid-Downloader-dev-${owner.pid}.exe`);
const staleTarget = path.join(root, "Multi-Debrid-Downloader-dev-999999.exe");
writeFileSync(activeTarget, "active");
writeFileSync(liveTarget, "live");
writeFileSync(staleTarget, "stale");
try {
const result = spawnSync(process.execPath, [
path.resolve("node_modules/tsx/dist/cli.mjs"),
"scripts/run-dev-electron.ts",
"--cleanup-only",
root,
activeTarget
], { cwd: path.resolve("."), encoding: "utf8" });
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(existsSync(activeTarget)).toBe(true);
expect(existsSync(liveTarget)).toBe(true);
expect(existsSync(staleTarget)).toBe(false);
} finally {
owner.kill();
}
});
});
+27 -5
View File
@@ -151,14 +151,17 @@ describe("laufender Queue-Linkzähler", () => {
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%");
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%");
});
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");
@@ -827,10 +830,11 @@ describe("download table row contracts", () => {
selected: false
}));
expect(html).toContain('aria-label="Download läuft (Mega-Debrid)"');
expect(html).toContain('aria-label="Download läuft"');
expect(html).toContain('class="downloads-status-full"');
expect(html).toContain('class="downloads-status-compact"');
expect(html).toContain("DL läuft");
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</span>');
@@ -968,6 +972,24 @@ describe("download table row contracts", () => {
expect(html).toMatch(/title="0\/1 · Entpacken 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
});
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)" })], 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());