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:
@@ -4,9 +4,15 @@ import { describe, expect, it } from "vitest";
|
||||
import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/AvatarMenu";
|
||||
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||
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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
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";
|
||||
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
||||
@@ -383,13 +384,26 @@ describe("statistics view", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bandwidth chart palette", () => {
|
||||
it("requests only the semantic UI color properties and keeps the computed font family", () => {
|
||||
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"
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user